From 6d2a28d1318ab573c438e54d12551801e64f2719 Mon Sep 17 00:00:00 2001 From: Subham-u Date: Sun, 25 Oct 2026 22:20:04 +0530 Subject: [PATCH 1/4] Convert CapScreen to an on-device desktop app with cloud sign-in Desktop (Electron) app where cap tables, documents and screenings never leave the user's machine. The only cloud piece is account sign-in. Desktop shell (desktop/) - Electron main supervises three loopback sidecars: Watchman (self-built pure-Go binary, no libpostal), the Express backend and the Next.js UI. - capscreen://app protocol proxies UI + API; per-launch token gates the API. - Tray, native notifications, crash restart, clean shutdown, single instance. - Offline sanctions data: list snapshot cache, automatic fallback when an online start fails, automatic return to live lists. - Claude-Code-style sign-in: browser hand-off over RFC 8252 loopback + PKCE, session sealed in the OS keychain, 14-day offline grace. - electron-builder packaging (dmg/nsis/AppImage). Backend - SQLite storage via a MongoDB-driver-compatible store under mongoose (node:sqlite); all models and queries unchanged. - Pluggable LLM providers: Anthropic, native Ollama, OpenAI-compatible. - AI Control Center API: BYO keys (AES-256-GCM, keychain-sealed master key), model roles, local-only policy, Ollama model management. - Local files instead of R2, in-memory cache instead of Upstash. - Encrypted backup/restore, hash-chained audit export, watched folders, background re-screening, sanctions list versions stored on results. - Cloud auth service (src/authService) for Vercel: existing account system plus desktop token exchange and a hosted sign-in portal. - Fix: PDF ingestion (pdf-parse v2 API), refresh cookie path, persisted watchmanListInfo, misleading Watchman log. Frontend - AI Control Center and Data & Backup pages, desktop bridge, watched folder and PDF export controls, signed-in identity, offline banners. - New CapScreen brand (logo, favicon, app and tray icons). Co-Authored-By: Claude Opus 5.5 --- .env.example | 26 +- .gitignore | 6 + CLAUDE.md | 160 +- backend/api/index.ts | 4 + backend/package.json | 4 +- backend/src/authServer.ts | 6 + backend/src/authService/app.ts | 44 + backend/src/authService/logo.ts | 2 + backend/src/authService/portal.ts | 206 +++ backend/src/index.ts | 51 +- backend/src/lib/auth/cookies.ts | 4 +- backend/src/lib/auth/tokens.ts | 4 +- backend/src/lib/documents/generateIcMemo.ts | 6 +- .../documents/generateScreeningAnalysis.ts | 6 +- backend/src/lib/infra/backup.ts | 136 ++ backend/src/lib/infra/cache.ts | 58 +- backend/src/lib/infra/dataDir.ts | 20 + backend/src/lib/infra/db.ts | 86 +- backend/src/lib/infra/fileStorage.ts | 141 ++ backend/src/lib/infra/folderWatch.ts | 143 ++ backend/src/lib/infra/index.ts | 1 - backend/src/lib/infra/r2Config.ts | 41 - backend/src/lib/infra/r2Storage.ts | 136 -- backend/src/lib/infra/settings.ts | 53 + backend/src/lib/infra/sqliteStore/query.ts | 405 +++++ backend/src/lib/infra/sqliteStore/store.ts | 705 ++++++++ backend/src/lib/infra/sqliteStore/update.ts | 201 +++ backend/src/lib/llm/agent.ts | 83 - backend/src/lib/llm/allowedModels.ts | 10 - backend/src/lib/llm/claude.ts | 44 - backend/src/lib/llm/complete.ts | 35 + .../src/lib/llm/controlCenter/localRuntime.ts | 165 ++ backend/src/lib/llm/controlCenter/store.ts | 153 ++ backend/src/lib/llm/friendlyError.ts | 15 +- backend/src/lib/llm/index.ts | 4 +- backend/src/lib/llm/models.ts | 17 - backend/src/lib/llm/providers/anthropic.ts | 92 + backend/src/lib/llm/providers/index.ts | 18 + backend/src/lib/llm/providers/ollama.ts | 266 +++ .../src/lib/llm/providers/openaiCompatible.ts | 197 +++ backend/src/lib/llm/providers/registry.ts | 274 +++ backend/src/lib/llm/providers/types.ts | 84 + backend/src/lib/llm/streamAgent.ts | 104 +- backend/src/lib/llm/validateKey.ts | 56 - backend/src/lib/llm/validateLlm.ts | 56 + .../src/lib/portfolio/rescreenScheduler.ts | 41 +- backend/src/lib/rag/embed.ts | 60 +- backend/src/lib/rag/extractEntities.ts | 17 +- backend/src/lib/rag/parseDocument.ts | 38 +- backend/src/lib/rag/retrieveChunks.ts | 8 +- backend/src/lib/screening/explain.ts | 11 +- backend/src/lib/screening/inferCsvSchema.ts | 12 +- backend/src/lib/screening/watchman.ts | 16 +- backend/src/lib/shared/dataResidency.ts | 19 +- backend/src/lib/tools/documentTypes.ts | 6 +- backend/src/lib/tools/registry.ts | 6 +- backend/src/lib/tools/searchTools.ts | 3 +- backend/src/loadEnv.ts | 16 +- backend/src/middleware/desktopToken.ts | 26 + backend/src/middleware/requireAuth.ts | 89 +- backend/src/middleware/requireJwt.ts | 37 + backend/src/models/auditLog.ts | 37 - backend/src/models/auth/desktopAuthCode.ts | 24 + backend/src/models/capTableCsv.ts | 50 - backend/src/models/index.ts | 1 + backend/src/models/rag/docChunk.ts | 4 +- backend/src/models/schemas/screening.ts | 110 -- .../src/models/screening/schemas/screening.ts | 3 + backend/src/models/startup.ts | 40 - backend/src/models/workflowShare.ts | 14 - backend/src/routes/aiControlCenter.ts | 373 +++++ backend/src/routes/assistantChat.ts | 15 +- backend/src/routes/auth.ts | 6 +- backend/src/routes/chat.ts | 12 +- backend/src/routes/config.ts | 9 +- backend/src/routes/desktopData.ts | 155 ++ backend/src/routes/desktopOAuth.ts | 166 ++ .../src/routes/documents/singleDocuments.ts | 8 +- backend/src/routes/startups.ts | 805 --------- backend/src/routes/startups/ragDocuments.ts | 6 +- backend/src/routes/tabularReview.ts | 17 +- backend/vercel.json | 4 + desktop/electron-builder.yml | 65 + desktop/package.json | 32 + desktop/resources/icons/icon.png | Bin 0 -> 53896 bytes desktop/resources/icons/trayTemplate.png | Bin 0 -> 561 bytes desktop/resources/icons/trayTemplate@2x.png | Bin 0 -> 1304 bytes desktop/scripts/build-frontend.mjs | 63 + desktop/scripts/build-watchman.sh | 21 + desktop/scripts/bundle-backend.mjs | 46 + desktop/scripts/make-icons.js | 52 + desktop/src/authFlow.ts | 150 ++ desktop/src/ipc.ts | 119 ++ desktop/src/listCache.ts | 73 + desktop/src/main.ts | 372 +++++ desktop/src/notifications.ts | 55 + desktop/src/paths.ts | 22 + desktop/src/ports.ts | 32 + desktop/src/preload.ts | 32 + desktop/src/protocol.ts | 98 ++ desktop/src/session.ts | 156 ++ desktop/src/sidecars.ts | 369 +++++ desktop/src/splash.ts | 87 + desktop/src/vault.ts | 37 + desktop/tsconfig.json | 14 + docs/DESKTOP_PLAN.md | 113 ++ frontend/next.config.ts | 2 + .../VC Fund Research and Tool Building.svg | 12 - frontend/public/apple-touch-icon.png | Bin 0 -> 9732 bytes frontend/public/capscreen-logo-reversed.svg | 6 + frontend/public/capscreen-logo.svg | 6 + frontend/public/capscreen-mark.svg | 5 + frontend/src/app/(pages)/account/page.tsx | 48 + frontend/src/app/(pages)/ai/page.tsx | 65 + frontend/src/app/(pages)/layout.tsx | 5 +- .../src/app/(pages)/settings/data/page.tsx | 340 ++++ .../src/app/components/ai/LocalModels.tsx | 278 ++++ .../src/app/components/ai/PrivacyCard.tsx | 103 ++ .../src/app/components/ai/ProviderList.tsx | 299 ++++ .../src/app/components/ai/RoleAssignments.tsx | 233 +++ .../components/assistant/AssistantMessage.tsx | 8 +- .../src/app/components/assistant/EditCard.tsx | 6 +- .../src/app/components/shared/AppSidebar.tsx | 22 +- .../app/components/shared/DesktopShell.tsx | 68 + .../src/app/components/shared/DocPanel.tsx | 12 +- .../src/app/components/shared/DocxView.tsx | 6 +- .../components/shared/StartupDocumentView.tsx | 6 +- .../startups/IcMemoDownloadCard.tsx | 44 +- .../components/startups/RagDocumentsStrip.tsx | 5 +- .../startups/StartupScreenerPage.tsx | 2 + .../components/startups/WatchFolderButton.tsx | 101 ++ frontend/src/app/favicon.ico | Bin 9195 -> 15778 bytes frontend/src/app/hooks/useDocumentVersions.ts | 7 +- frontend/src/app/hooks/useFetchDocxBytes.ts | 6 +- frontend/src/app/hooks/useFetchSingleDoc.ts | 7 +- frontend/src/app/icon.svg | 131 +- frontend/src/app/layout.tsx | 2 +- frontend/src/app/lib/auditLogApi.ts | 5 +- frontend/src/app/lib/capScreenApi.ts | 3 +- frontend/src/app/lib/screenerApi.ts | 5 +- frontend/src/components/app-logo.tsx | 38 +- frontend/src/contexts/AuthContext.tsx | 11 +- frontend/src/contexts/UserProfileContext.tsx | 12 +- frontend/src/lib/aiControlCenterApi.ts | 169 ++ frontend/src/lib/apiBase.ts | 10 + frontend/src/lib/authApi.ts | 5 +- frontend/src/lib/desktop.ts | 51 + frontend/src/lib/desktopDataApi.ts | 81 + frontend/src/lib/downloadIcMemo.ts | 23 + frontend/src/lib/portfolioApi.ts | 5 +- frontend/src/lib/screeningExplainApi.ts | 5 +- frontend/src/lib/serverConfig.ts | 25 +- frontend/src/lib/startupsApi.ts | 5 +- frontend/src/lib/uiPreview.ts | 17 +- package.json | 5 +- pnpm-lock.yaml | 1475 ++++++++++++++++- pnpm-workspace.yaml | 1 + 157 files changed, 10222 insertions(+), 2164 deletions(-) create mode 100644 backend/api/index.ts create mode 100644 backend/src/authServer.ts create mode 100644 backend/src/authService/app.ts create mode 100644 backend/src/authService/logo.ts create mode 100644 backend/src/authService/portal.ts create mode 100644 backend/src/lib/infra/backup.ts create mode 100644 backend/src/lib/infra/dataDir.ts create mode 100644 backend/src/lib/infra/fileStorage.ts create mode 100644 backend/src/lib/infra/folderWatch.ts delete mode 100644 backend/src/lib/infra/r2Config.ts delete mode 100644 backend/src/lib/infra/r2Storage.ts create mode 100644 backend/src/lib/infra/settings.ts create mode 100644 backend/src/lib/infra/sqliteStore/query.ts create mode 100644 backend/src/lib/infra/sqliteStore/store.ts create mode 100644 backend/src/lib/infra/sqliteStore/update.ts delete mode 100644 backend/src/lib/llm/agent.ts delete mode 100644 backend/src/lib/llm/allowedModels.ts delete mode 100644 backend/src/lib/llm/claude.ts create mode 100644 backend/src/lib/llm/complete.ts create mode 100644 backend/src/lib/llm/controlCenter/localRuntime.ts create mode 100644 backend/src/lib/llm/controlCenter/store.ts delete mode 100644 backend/src/lib/llm/models.ts create mode 100644 backend/src/lib/llm/providers/anthropic.ts create mode 100644 backend/src/lib/llm/providers/index.ts create mode 100644 backend/src/lib/llm/providers/ollama.ts create mode 100644 backend/src/lib/llm/providers/openaiCompatible.ts create mode 100644 backend/src/lib/llm/providers/registry.ts create mode 100644 backend/src/lib/llm/providers/types.ts delete mode 100644 backend/src/lib/llm/validateKey.ts create mode 100644 backend/src/lib/llm/validateLlm.ts create mode 100644 backend/src/middleware/desktopToken.ts create mode 100644 backend/src/middleware/requireJwt.ts delete mode 100644 backend/src/models/auditLog.ts create mode 100644 backend/src/models/auth/desktopAuthCode.ts delete mode 100644 backend/src/models/capTableCsv.ts delete mode 100644 backend/src/models/schemas/screening.ts delete mode 100644 backend/src/models/startup.ts delete mode 100644 backend/src/models/workflowShare.ts create mode 100644 backend/src/routes/aiControlCenter.ts create mode 100644 backend/src/routes/desktopData.ts create mode 100644 backend/src/routes/desktopOAuth.ts delete mode 100644 backend/src/routes/startups.ts create mode 100644 backend/vercel.json create mode 100644 desktop/electron-builder.yml create mode 100644 desktop/package.json create mode 100644 desktop/resources/icons/icon.png create mode 100644 desktop/resources/icons/trayTemplate.png create mode 100644 desktop/resources/icons/trayTemplate@2x.png create mode 100644 desktop/scripts/build-frontend.mjs create mode 100755 desktop/scripts/build-watchman.sh create mode 100644 desktop/scripts/bundle-backend.mjs create mode 100644 desktop/scripts/make-icons.js create mode 100644 desktop/src/authFlow.ts create mode 100644 desktop/src/ipc.ts create mode 100644 desktop/src/listCache.ts create mode 100644 desktop/src/main.ts create mode 100644 desktop/src/notifications.ts create mode 100644 desktop/src/paths.ts create mode 100644 desktop/src/ports.ts create mode 100644 desktop/src/preload.ts create mode 100644 desktop/src/protocol.ts create mode 100644 desktop/src/session.ts create mode 100644 desktop/src/sidecars.ts create mode 100644 desktop/src/splash.ts create mode 100644 desktop/src/vault.ts create mode 100644 desktop/tsconfig.json create mode 100644 docs/DESKTOP_PLAN.md delete mode 100644 frontend/public/VC Fund Research and Tool Building.svg create mode 100644 frontend/public/apple-touch-icon.png create mode 100644 frontend/public/capscreen-logo-reversed.svg create mode 100644 frontend/public/capscreen-logo.svg create mode 100644 frontend/public/capscreen-mark.svg create mode 100644 frontend/src/app/(pages)/ai/page.tsx create mode 100644 frontend/src/app/(pages)/settings/data/page.tsx create mode 100644 frontend/src/app/components/ai/LocalModels.tsx create mode 100644 frontend/src/app/components/ai/PrivacyCard.tsx create mode 100644 frontend/src/app/components/ai/ProviderList.tsx create mode 100644 frontend/src/app/components/ai/RoleAssignments.tsx create mode 100644 frontend/src/app/components/shared/DesktopShell.tsx create mode 100644 frontend/src/app/components/startups/WatchFolderButton.tsx create mode 100644 frontend/src/lib/aiControlCenterApi.ts create mode 100644 frontend/src/lib/apiBase.ts create mode 100644 frontend/src/lib/desktop.ts create mode 100644 frontend/src/lib/desktopDataApi.ts diff --git a/.env.example b/.env.example index 4a4f92e..f04a65a 100644 --- a/.env.example +++ b/.env.example @@ -42,9 +42,19 @@ RESEND_FROM_EMAIL=onboarding@resend.dev # EMAIL_APP_NAME=CapScreen # EMAIL_LOGO_URL=https://your-domain.example/logo.png -# Required — get yours at console.anthropic.com +# ── Model provider (backend/src/lib/llm/providers/) ────────────────────── +# Default: Anthropic + claude-haiku-4-5 via ANTHROPIC_API_KEY (console.anthropic.com). ANTHROPIC_API_KEY= -# Model is locked to Claude Haiku in code (claude-haiku-4-5). ANTHROPIC_MODEL is ignored. +# Any other provider — presets: anthropic | openai | ollama | lmstudio | llamacpp | +# openrouter | groq | gemini. Anything else is treated as a custom OpenAI-compatible +# server and needs LLM_BASE_URL. Loopback base URLs count as local (no data egress). +# LLM_PROVIDER=ollama +# LLM_MODEL=qwen3:8b +# LLM_UTILITY_MODEL= # narratives / CSV inference / extraction (defaults to LLM_MODEL) +# LLM_BASE_URL= +# LLM_API_KEY= # overrides the preset's key env var (OPENAI_API_KEY, GROQ_API_KEY, …) +# LLM_SUPPORTS_TOOLS=false # for local models without function calling +# LLM_CONFIG_FILE= # JSON { providers, roles } — takes precedence over the vars above # Watchman sanctions screening API # Local: http://localhost:8084 (docker-compose.dev.yml) @@ -71,7 +81,7 @@ TAVILY_API_KEY= # SCREENING_LOG=1 # AUDIT_LOG_RETENTION_DAYS=1825 -# DATA_RESIDENCY= +# DATA_RESIDENCY= # restricted|eu|in — blocks PII to remote LLMs; local models still allowed # Rate limits (optional) # RATE_LIMIT_SCREENER_CHAT_MAX=60 @@ -94,3 +104,13 @@ UPSTASH_REDIS_REST_TOKEN= # with a vector index on the embeddings collection. OPENAI_API_KEY= MONGO_VECTOR_SEARCH_ENABLED=true + +# ── Cloud auth service (backend/src/authService, deployed on Vercel) ─────── +# Accounts only — no screening data. Set these in the Vercel project, not here. +# CAPSCREEN_DB=mongodb +# MONGODB_URI= +# JWT_ACCESS_TOKEN_SECRET_PRIVATE= # pnpm --filter vc-screener-backend run generate:jwt-keys +# JWT_ACCESS_TOKEN_SECRET_PUBLIC= +# FRONTEND_URL=https://.vercel.app +# RESEND_API_KEY= # email verification + password reset +# DESKTOP_REFRESH_TOKEN_EXPIRATION_DAYS=30 diff --git a/.gitignore b/.gitignore index ce25006..c559b46 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ next-env.d.ts # Test coverage coverage + +# Desktop build artifacts +desktop/resources/bin/ +desktop/resources/backend/ +desktop/resources/frontend/ +desktop/release/ diff --git a/CLAUDE.md b/CLAUDE.md index 10295f5..7c8b714 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,119 +4,109 @@ ## Project Overview -A VC compliance tool that screens startup cap tables for sanctions risk. Users create "startups," upload cap-table CSVs, and get an AI-powered risk report with an ownership graph, entity risk table, and a Claude-generated plain-English narrative. The screener chat carries full context (CSV content + screening results) across all turns in a session. +A **desktop app** (Electron) that screens startup cap tables for sanctions risk. Users create "startups," upload cap-table CSVs, and get a risk report with an ownership graph, entity risk table, and an AI-written plain-English narrative. **All data stays on the user's computer**: SQLite database, local files, bundled sanctions engine. AI runs on-device (Ollama/LM Studio) or with the user's own cloud API keys, chosen in the in-app **AI Control Center**. **It never decides guilt.** It narrows the review set for a human compliance officer. +The only cloud piece is a small **auth service** (accounts + sign-in, on Vercel + Atlas); no screening data goes there. No EC2, R2 or Upstash. + --- ## Architecture -**Canonical doc:** `docs/ARCHITECTURE.md` - -### Deployment topology +**Canonical doc:** `docs/DESKTOP_PLAN.md` -| Layer | Where | How | -|---|---|---| -| Frontend | **Vercel** | `frontend/` Next.js app; `NEXT_PUBLIC_API_BASE_URL` → EC2 API | -| Backend | **AWS EC2** | `docker-compose.yml` → `backend/Dockerfile`, port 3001 | -| Watchman | **AWS EC2** (same compose stack) | `moov/watchman:static`, internal Docker network only | -| MongoDB | **Atlas** | `MONGODB_URI` in `.env.production` | -| Cache | **Upstash Redis** | Watchman result cache (optional) | -| Storage | **Cloudflare R2** | Document uploads | -| LLM | **Anthropic** (+ OpenAI for RAG) | API keys on EC2 only | +### Runtime topology ``` -Vercel (Next.js) ──HTTPS/CORS──► EC2: backend + watchman (Docker) - ├── Atlas · Upstash · R2 · Resend +Electron main (desktop/src/main.ts) + ├─ Watchman sidecar 127.0.0.1: self-built Go binary (no libpostal) — OFAC/EU lists + ├─ Backend 127.0.0.1: Express, utilityProcess; per-launch token required + │ ├─ SQLite /capscreen.db mongoose models via lib/infra/sqliteStore + │ └─ Files /files/ + ├─ UI (Next.js) 127.0.0.1: standalone server, utilityProcess + └─ Window loads capscreen://app → protocol.ts proxies UI + /__api/* (adds token) ``` -Local dev: Watchman via `docker compose -f docker-compose.dev.yml up`; backend + frontend on host via `pnpm dev` and root `.env`. +`` = `~/Library/Application Support/CapScreen` (macOS), `%APPDATA%/CapScreen` (Windows). -Production EC2: `docker compose --env-file .env.production up -d --build`. Watchman URL is overridden to `http://watchman:8084` inside compose. +Network egress: public sanctions-list downloads (Watchman, every 12 h) and cloud LLM calls **only** when the user assigns a cloud model. Local-only mode blocks the latter in `getLlm()`. ### Backend stack -- `express` with `helmet`, `cors`, `express-rate-limit`, JWT auth (RS256 cookies) -- `@anthropic-ai/sdk` — Claude Haiku (`claude-haiku-4-5`) for analyst notes + agentic chat -- `mongoose` — MongoDB Atlas (startups, CSVs, chats, RAG vectors) +- `express` + `helmet`, `cors`, `express-rate-limit`; loopback-only; `middleware/desktopToken.ts` gate; single local user (`middleware/requireAuth.ts`) +- **SQLite via mongoose**: `lib/infra/sqliteStore/` implements the MongoDB driver surface mongoose calls (`node:sqlite`, no native deps). All mongoose models/queries run unchanged. `$vectorSearch` is in-process cosine. +- **Pluggable LLMs** (`lib/llm/providers/`): Anthropic, native Ollama, and OpenAI-compatible (OpenAI, Gemini, OpenRouter, Groq, LM Studio, llama.cpp, any custom endpoint). Roles: `agent`, `utility`, `embedding`. +- **AI Control Center** (`lib/llm/controlCenter/`, `routes/aiControlCenter.ts`): providers, model assignments, local-only policy; API keys AES-256-GCM encrypted with a master key sealed by the OS keychain (Electron `safeStorage`). - `graphology` — directed ownership graph (BFS traversal, circular ownership detection) -- Watchman — OFAC SDN + EU Consolidated via `GET /v2/search`; co-located in Docker on EC2 +- Watchman — `GET /v2/search`; every screening result stores `watchmanListInfo` (list counts + SHA-256 per list) ### Frontend stack -- Next.js 15 App Router, React 19, TypeScript — **deployed on Vercel** -- Tailwind CSS + Shadcn/ui components -- `@xyflow/react` (React Flow) — interactive ownership graph -- `recharts` — data visualisation -- JWT auth via httpOnly cookies to EC2 backend (`FRONTEND_URL` must match Vercel origin) +- Next.js App Router, React 19, TypeScript — built `output: "standalone"` for the desktop bundle +- Tailwind CSS + Shadcn/ui; `@xyflow/react` ownership graph; `recharts` +- `lib/apiBase.ts` — backend origin comes from the preload bridge (`window.capscreenDesktop`); never read `NEXT_PUBLIC_API_BASE_URL` directly +- `lib/desktop.ts` — typed bridge (`useDesktop()` for render-time use; hydration-safe) + +--- + +## Accounts & sign-in (cloud auth service) + +Accounts are the **only** cloud component. Sign-in works like Claude Code: the app opens the browser, the user signs in on the hosted portal, the browser hands a one-time code back to a loopback listener, and the app exchanges it (PKCE) for a session sealed in the OS keychain. Cap tables, documents and screenings never leave the machine. + +| Piece | Where | +|---|---| +| Auth service (Express) | `backend/src/authService/app.ts` — mounts `routes/auth.ts` (signup/signin/verify/reset) + `routes/desktopOAuth.ts` (RFC 8252 + PKCE) + portal | +| Hosted portal (sign-in UI) | `backend/src/authService/portal.ts` — one self-contained page; serves `/desktop/authorize`, `/verify-email`, `/reset-password` | +| Vercel entry | `backend/api/index.ts`, `backend/vercel.json` (project root: `backend/`) | +| Accounts DB | MongoDB Atlas — `CAPSCREEN_DB=mongodb` switches `lib/infra/db.ts` to Atlas (auth service only) | +| Desktop client | `desktop/src/authFlow.ts` (protocol, Electron-free) + `desktop/src/session.ts` (keychain, restore, 14-day offline grace, refresh every 12 h) | +| Gate | `desktop/src/main.ts` + `splash.ts` (`signInHtml`) — shown until signed in; sidecars boot in parallel | +| Attribution | Shell writes `/identity.json`; `middleware/requireAuth.ts` uses it for `userEmail` (audit, reviews). Data owner stays `local-user`. | + +Auth service env (Vercel): `CAPSCREEN_DB=mongodb`, `MONGODB_URI`, `JWT_ACCESS_TOKEN_SECRET_PRIVATE`/`_PUBLIC` (`pnpm --filter vc-screener-backend run generate:jwt-keys`), `FRONTEND_URL` = the service's own URL (email links), `RESEND_API_KEY` + `RESEND_FROM_EMAIL` (email verification; without it signups are auto-confirmed), optional `DESKTOP_REFRESH_TOKEN_EXPIRATION_DAYS` (default 30). + +Desktop: production auth URL is `capscreenAuthUrl` in `desktop/package.json` (override with `CAPSCREEN_AUTH_URL`). Dev defaults to `http://127.0.0.1:4100` — run `pnpm --filter vc-screener-backend auth:dev`, or skip sign-in with `CAPSCREEN_SKIP_AUTH=1` (dev only). --- ## Key Data Flow ``` -User uploads CSV - → POST /api/startups/:id/csvs (store raw CSV in MongoDB) +User uploads CSV (or drops it in a watched folder) + → POST /api/startups/:id/csvs (stored in SQLite) → POST /api/chat (send with csvContent) → screen_cap_table tool: parseCapTable → buildOwnershipGraph → searchWatchman → classifyScore → explainMatch - → returns ScreeningResult + Claude narrative - → screeningResult saved back to startup in MongoDB (lastScreeningResult) + → returns ScreeningResult (+ watchmanListInfo) + LLM narrative + → screeningResult saved to the startup (lastScreeningResult) → ScreeningResultsPanel opens: OwnershipGraph + RiskTable + RiskCards ``` +Screening fails **closed**: if Watchman is unavailable, `runScreening` throws instead of returning "clear". + --- ## Dev Setup -### 1. Watchman (required — sanctions data) - ```bash -docker compose -f docker-compose.dev.yml up -# Takes ~30–60 s to load SDN/EU lists. Must be running for any screening to work. -``` - -### 2. MongoDB - -Use local `mongod` or point `MONGODB_URI` at Atlas in root `.env` (same cluster as production is fine for dev). - -### 3. Environment & dev server - -```bash -cp .env.example .env # monorepo root — backend + frontend on host pnpm install -pnpm dev # backend :3001 + frontend :3000 +pnpm --filter capscreen-desktop run build:watchman # once; needs Go 1.24+ (brew install go) +pnpm desktop # Electron + backend (tsx) + Next dev, all sidecars ``` -Env vars (root `.env` — see `.env.example`): +`pnpm desktop` uses `~/Library/Application Support/CapScreen` like the packaged app. Configure models in the app's **AI Control Center** (or install Ollama and click "Use on-device AI"). Dev-only: `CAPSCREEN_REMOTE_DEBUG=9333` exposes CDP on loopback for automation. -| Variable | Local | Production (EC2 / Vercel) | -|---|---|---| -| `NEXT_PUBLIC_API_BASE_URL` | `http://localhost:3001` | EC2 public URL (Vercel env) | -| `FRONTEND_URL` | `http://localhost:3000` | Vercel deployment URL (backend CORS) | -| `PORT` | `3001` | `3001` (compose maps host port) | -| `WATCHMAN_URL` | `http://localhost:8084` | `http://watchman:8084` (set by compose) | -| `MONGODB_URI` | local or Atlas | Atlas | -| `ANTHROPIC_API_KEY` | — | Required on EC2 | -| *(model)* | `claude-haiku-4-5` | Locked in `backend/src/lib/llm/models.ts` | +Browser-only dev without Electron (`pnpm dev`) still works: the backend falls back to root `.env` (`LLM_PROVIDER`/`LLM_MODEL`/keys, see `.env.example`), data in `~/.capscreen`, and Watchman via `docker compose -f docker-compose.dev.yml up`. When launched by Electron (`CAPSCREEN_DESKTOP_TOKEN` set) the backend never reads `.env`. -Production secrets live in `.env.production` on EC2 (from `.env.example`); never commit. Vercel only needs `NEXT_PUBLIC_*` vars. - -> `pnpm dev` at the root starts both services on the host. Individual: `pnpm --filter vc-screener-backend dev` or `pnpm --filter capscreen dev`. - -**Preview mode:** Set `ALLOW_PREVIEW_MODE=true` and `NEXT_PUBLIC_ALLOW_PREVIEW_MODE=true` in root `.env` for local dev without auth — never in production. - -### 4. Production deploy (reference) +### Packaging ```bash -# EC2 -docker compose --env-file .env.production up -d --build - -# Vercel — deploy frontend/, set NEXT_PUBLIC_API_BASE_URL to EC2 API URL +pnpm --filter capscreen-desktop run dist:mac # → desktop/release/CapScreen--arm64.dmg (+ x64) +pnpm --filter capscreen-desktop run dist # all targets in electron-builder.yml ``` -See `docs/ARCHITECTURE.md` for full topology and checklist. +Build each OS on that OS (Next's traced `sharp` binary is platform-specific). Unsigned unless `CSC_*` signing env is set — sign + notarize before distributing. --- @@ -138,10 +128,17 @@ backend/src/ runScreening.ts — orchestrates parse→graph→watchman→classify→explain → ScreeningResult chatTools.ts — SCREENER_TOOLS (Anthropic Tool[]) + createToolExecutor llm/ - claude.ts — Claude API wrapper - agent.ts — runAgentWithTools (agentic loop with tool-use) + providers/ — neutral types, Anthropic + OpenAI-compatible adapters, registry (roles: agent/utility/embedding) + complete.ts — completeText / chatText (single-shot + multi-turn, provider-neutral) + streamAgent.ts — runAgentStream (SSE agentic loop with tool-use; tool-less fallback) + validateLlm.ts — boot-time ping of the configured agent model index.ts — re-exports - db.ts — mongoose connect (singleton, lazy) + infra/db.ts — opens SQLite store and attaches it to mongoose (applies pending restore) + infra/sqliteStore/ — MongoDB-driver-compatible store on node:sqlite (query, update, store) + infra/backup.ts — encrypted .capscreen-backup create/restore + infra/folderWatch.ts — watched data-room folders → auto-ingest + infra/fileStorage.ts — local file storage + signed download URLs + infra/settings.ts — settings.json (background screening, sanctions lists) models/ startup.ts — Startup mongoose schema (name, createdAt, lastScreeningResult) capTableCsv.ts — CapTableCsv schema (startupId, filename, content, uploadedAt) @@ -222,6 +219,8 @@ frontend/src/ | `/assistant` | CapScreen compliance assistant | Primary chat shell | | `/assistant/chat/:id` | Persisted chat view | | | `/screen` | Redirects → `/assistant` | Unused | +| `/ai` | AI Control Center | Providers, BYO keys, local models, model assignments, local-only mode | +| `/settings/data` | Data & Backup | Sanctions lists/versions, background re-screening, encrypted backup/restore, audit export | The sidebar shows "Startups" → `/startups` and "Recent Projects" (up to 5). @@ -241,9 +240,12 @@ The sidebar shows "Startups" → `/startups` and "Recent Projects" (up to 5). | Capability | Status | Command / doc | |---|---|---| -| Agent tool loop | ✅ | `backend/src/lib/llm/agent.ts` | +| Agent tool loop | ✅ | `backend/src/lib/llm/streamAgent.ts` | +| Pluggable / local models | ✅ | `backend/src/lib/llm/providers/` | +| Desktop app (Electron) | ✅ | `desktop/`, `docs/DESKTOP_PLAN.md` | +| AI Control Center (BYO keys, local models) | ✅ | `/ai`, `backend/src/routes/aiControlCenter.ts` | | Persistent agent memory | ✅ seeded | `.claude/agent-memory/project/` | -| Document RAG | 🔲 planned | `/compliance-rag-roadmap` | +| Document RAG | ✅ local embeddings | `backend/src/lib/rag/` | | Chat-first UI shell | ✅ restored | `frontend/.../assistant/` | | Gap audit | — | `/compliance-parity-check` | @@ -265,10 +267,17 @@ See the plan in conversation context for full details. Short version: ```bash # Root (pnpm workspace) pnpm install # install all workspaces -pnpm dev # start backend + frontend in parallel +pnpm desktop # run the desktop app (dev) +pnpm desktop:dist # build installers +pnpm dev # browser-only dev: backend + frontend on host pnpm -w run typecheck # backend tsc --noEmit pnpm -w run lint # frontend eslint +# Desktop only +pnpm --filter capscreen-desktop run build:watchman # build Watchman binaries (Go) +pnpm --filter capscreen-desktop run typecheck +pnpm --filter capscreen-desktop run dist:mac + # Backend only pnpm --filter vc-screener-backend run dev pnpm --filter vc-screener-backend run build @@ -279,11 +288,8 @@ pnpm --filter capscreen run dev pnpm --filter capscreen run build pnpm --filter capscreen run lint -# Watchman (local dev) +# Watchman for browser-only dev (the desktop app bundles its own) docker compose -f docker-compose.dev.yml up - -# Production stack (EC2) -docker compose --env-file .env.production up -d --build ``` ## TypeScript / Lint Notes diff --git a/backend/api/index.ts b/backend/api/index.ts new file mode 100644 index 0000000..a70cfdc --- /dev/null +++ b/backend/api/index.ts @@ -0,0 +1,4 @@ +// Vercel entry for the CapScreen auth service (project root: backend/). +import { createAuthApp } from "../src/authService/app"; + +export default createAuthApp(); diff --git a/backend/package.json b/backend/package.json index 8b7e052..097a046 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,12 +9,14 @@ "start": "node dist/index.js", "generate:jwt-keys": "bash scripts/generate-jwt-keys.sh", "seed:demo": "tsx scripts/seed-demo-startup.ts", - "db:reset": "tsx scripts/reset-database.ts" + "db:reset": "tsx scripts/reset-database.ts", + "auth:dev": "tsx watch src/authServer.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-s3": "^3.1062.0", "@aws-sdk/s3-request-presigner": "^3.1062.0", + "@thednp/dommatrix": "3.1.2", "@upstash/redis": "^1.38.0", "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", diff --git a/backend/src/authServer.ts b/backend/src/authServer.ts new file mode 100644 index 0000000..1a3fe78 --- /dev/null +++ b/backend/src/authServer.ts @@ -0,0 +1,6 @@ +import "./loadEnv"; +import { createAuthApp } from "./authService/app"; + +// Local run of the cloud auth service: pnpm --filter vc-screener-backend auth:dev +const port = Number(process.env.AUTH_PORT ?? 4100); +createAuthApp().listen(port, "127.0.0.1", () => console.log(`Auth service on http://127.0.0.1:${port}`)); diff --git a/backend/src/authService/app.ts b/backend/src/authService/app.ts new file mode 100644 index 0000000..8a9d65d --- /dev/null +++ b/backend/src/authService/app.ts @@ -0,0 +1,44 @@ +import cookieParser from "cookie-parser"; +import express from "express"; +import helmet from "helmet"; +import { authRouter } from "../routes/auth"; +import { desktopOAuthRouter } from "../routes/desktopOAuth"; +import { portalHtml } from "./portal"; + +/** + * Cloud auth service (Vercel): accounts + desktop sign-in only. No screening + * data ever reaches it. Requires CAPSCREEN_DB=mongodb, MONGODB_URI, JWT keys; + * RESEND_API_KEY enables email verification; FRONTEND_URL = this service's URL. + */ +export function createAuthApp(): express.Express { + const app = express(); + app.disable("x-powered-by"); + app.set("trust proxy", 1); // Vercel edge + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:"], + // The portal hands the browser to the app's loopback callback. + formAction: ["'self'"], + frameAncestors: ["'none'"], + }, + }, + }), + ); + app.use(cookieParser()); + app.use(express.json({ limit: "100kb" })); + + app.use("/api/auth/desktop", desktopOAuthRouter); + app.use("/api/auth", authRouter); + app.get("/health", (_req, res) => res.json({ ok: true })); + + const page = (_req: express.Request, res: express.Response) => res.type("html").send(portalHtml()); + for (const p of ["/", "/signin", "/desktop/authorize", "/verify-email", "/reset-password"]) app.get(p, page); + + app.use((_req, res) => res.status(404).json({ detail: "Not found" })); + return app; +} diff --git a/backend/src/authService/logo.ts b/backend/src/authService/logo.ts new file mode 100644 index 0000000..2ce985a --- /dev/null +++ b/backend/src/authService/logo.ts @@ -0,0 +1,2 @@ +/** Generated from frontend/public/capscreen-logo.svg — keep in sync with the brand assets. */ +export const LOGO_SVG = "\n \n \n \n"; diff --git a/backend/src/authService/portal.ts b/backend/src/authService/portal.ts new file mode 100644 index 0000000..b7482dc --- /dev/null +++ b/backend/src/authService/portal.ts @@ -0,0 +1,206 @@ +import { LOGO_SVG } from "./logo"; + +/** + * The hosted sign-in portal: one self-contained page (no build step, no + * external scripts) that drives the existing /api/auth endpoints. The desktop + * app opens it in the user's browser at /desktop/authorize. + */ +export function portalHtml(): string { + return ` + + + + + +Sign in · CapScreen + + + +
+ +
+ + +
+

Sign in

+

Use your CapScreen account.

+
+ + + +
+ +
+ + + + + + + + + +
+
+
CapScreen · screening decision support, not legal advice
+
+ + +`; +} + +/** Page the desktop app's loopback server shows after the browser hands back. */ +export function desktopDonePage(ok: boolean, message: string): string { + return `CapScreen + +

${ok ? "You're signed in" : "Sign-in didn't complete"}

${message}

`; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index bf6ba6c..d92b0d5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,10 +4,6 @@ import cors from "cors"; import helmet from "helmet"; import cookieParser from "cookie-parser"; import rateLimit from "express-rate-limit"; -import { logAuthStartupWarnings } from "./lib/auth/config"; -import { logEncryptionSecretWarnings } from "./lib/auth/encryptionSecret"; -import { logEmailStartupWarnings } from "./lib/auth/emailConfig"; -import { logR2StartupWarnings } from "./lib/infra/r2Config"; import { logRagStartupWarnings } from "./lib/rag/embed"; import { logWebSearchStartupWarnings } from "./lib/infra/webSearchConfig"; import { @@ -25,10 +21,9 @@ import { chatsRouter } from "./routes/chats"; import { configRouter } from "./routes/config"; import { tabularReviewRouter } from "./routes/tabularReview"; import { portfolioRouter } from "./routes/portfolioMonitoring"; -import { authRouter } from "./routes/auth"; import { userRouter } from "./routes/user"; -import { getAnthropicModel } from "./lib/llm/models"; -import { validateAnthropicKeyLive } from "./lib/llm/validateKey"; +import { describeLlmConfig } from "./lib/llm/providers"; +import { validateLlmLive } from "./lib/llm/validateLlm"; import { checkWatchmanHealth } from "./lib/screening/watchman"; import { startRescreenScheduler } from "./lib/portfolio/rescreenScheduler"; import { disconnectDb } from "./lib/infra/db"; @@ -38,7 +33,12 @@ import { assistantChatLimiter, screenerChatLimiter, } from "./lib/infra/chatRateLimits"; -import { isCacheRedis } from "./lib/infra/cache"; +import { desktopToken } from "./middleware/desktopToken"; +import { aiControlCenterRouter } from "./routes/aiControlCenter"; +import { desktopDataRouter } from "./routes/desktopData"; +import { startFolderWatcher } from "./lib/infra/folderWatch"; +import { fileDownloadRouter } from "./lib/infra/fileStorage"; +import { connectDb } from "./lib/infra/db"; const app = express(); const PORT = process.env.PORT ?? 3001; @@ -71,7 +71,7 @@ const screenLimiter = rateLimit({ }); app.disable("x-powered-by"); -app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); +app.set("trust proxy", false); // loopback desktop API — no reverse proxy app.use( helmet({ @@ -84,13 +84,18 @@ app.use( }), ); +// The desktop token (below) is the real gate; CORS just admits the app's own +// origin and loopback dev servers, whose ports change every launch. +const LOOPBACK_ORIGIN = /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/; app.use( cors({ - origin: process.env.FRONTEND_URL ?? "http://localhost:3000", + origin: (origin, cb) => + cb(null, !origin || origin === process.env.FRONTEND_URL || origin === "capscreen://app" || LOOPBACK_ORIGIN.test(origin)), credentials: true, }), ); +app.use(desktopToken); app.use(generalLimiter); app.use(cookieParser()); app.use(express.json({ limit: "10mb" })); @@ -114,7 +119,9 @@ app.use("/api/audit-logs", auditLogsRouter); app.use("/api/config", configRouter); app.use("/api/portfolio", portfolioRouter); app.use("/tabular-review", tabularReviewRouter); -app.use("/auth", authRouter); +app.use("/api/ai", requireAuth, aiControlCenterRouter); +app.use("/api/desktop", requireAuth, desktopDataRouter); +app.use("/api/files", fileDownloadRouter); app.use("/user", userRouter); app.get("/health", (_req, res) => res.json({ ok: true })); @@ -152,20 +159,22 @@ app.use((err: unknown, _req: express.Request, res: express.Response, _next: expr res.status(status).json({ detail: message }); }); -const server = app.listen(PORT, () => { - console.log(`Backend running on port ${PORT}`); - logAuthStartupWarnings(); - logEncryptionSecretWarnings(); - logEmailStartupWarnings(); - logR2StartupWarnings(); +// Loopback only: the API is for this machine's CapScreen window, never the network. +const HOST = process.env.HOST ?? "127.0.0.1"; + +const server = app.listen(Number(PORT), HOST, () => { + console.log(`Backend running on http://${HOST}:${PORT}`); + // Open the local database before the first request and so the TTL sweeper runs. + void connectDb(); logRagStartupWarnings(); logWebSearchStartupWarnings(); - console.log(`Anthropic model: ${getAnthropicModel()}`); - console.log(`Cache backend: ${isCacheRedis() ? "Upstash Redis" : "in-memory (no UPSTASH_REDIS_REST_URL set)"}`); + const agentLlm = describeLlmConfig().roles.agent; + console.log(`LLM: ${agentLlm ? `${agentLlm.provider}/${agentLlm.model}${agentLlm.local ? " (local)" : ""}` : "not configured"}`); console.log("IC memo: POST /api/startups/:id/ic-memo"); - void validateAnthropicKeyLive(); + void validateLlmLive(); void checkWatchmanHealth(); startRescreenScheduler(); + startFolderWatcher(); }); function shutdown(signal: string): void { @@ -186,7 +195,7 @@ function shutdown(signal: string): void { process.exit(0); }) .catch((err) => { - console.error("Error closing MongoDB connection:", err); + console.error("Error closing local database:", err); process.exit(1); }); }); diff --git a/backend/src/lib/auth/cookies.ts b/backend/src/lib/auth/cookies.ts index 432f287..67094d8 100644 --- a/backend/src/lib/auth/cookies.ts +++ b/backend/src/lib/auth/cookies.ts @@ -13,7 +13,7 @@ export function setRefreshTokenCookie( httpOnly: true, secure: isProduction, sameSite: "strict", - path: "/auth", + path: "/api/auth", // auth service mounts its routes here (authService/app.ts) expires: expiresAt, }); } @@ -23,6 +23,6 @@ export function clearRefreshTokenCookie(res: Response): void { httpOnly: true, secure: isProduction, sameSite: "strict", - path: "/auth", + path: "/api/auth", // auth service mounts its routes here (authService/app.ts) }); } diff --git a/backend/src/lib/auth/tokens.ts b/backend/src/lib/auth/tokens.ts index f2bbaa5..182c54a 100644 --- a/backend/src/lib/auth/tokens.ts +++ b/backend/src/lib/auth/tokens.ts @@ -16,12 +16,14 @@ export interface AuthTokens { export async function generateAuthTokens( user: UserDocument, + /** Desktop sessions last longer (like a native app) than browser cookies. */ + refreshDays: number = REFRESH_TOKEN_EXPIRATION_DAYS, ): Promise { const accessExpires = new Date( Date.now() + JWT_ACCESS_TOKEN_EXPIRATION_MINUTES * 60 * 1000, ); const refreshExpires = new Date( - Date.now() + REFRESH_TOKEN_EXPIRATION_DAYS * 24 * 60 * 60 * 1000, + Date.now() + refreshDays * 24 * 60 * 60 * 1000, ); const accessToken = signAccessToken( diff --git a/backend/src/lib/documents/generateIcMemo.ts b/backend/src/lib/documents/generateIcMemo.ts index a036ffe..05d707c 100644 --- a/backend/src/lib/documents/generateIcMemo.ts +++ b/backend/src/lib/documents/generateIcMemo.ts @@ -1,5 +1,4 @@ -import { completeClaudeText } from "../llm/claude"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; import type { ScreeningResult } from "../../types/screening"; import { wrapEntityName } from "../shared/promptDelimiters"; @@ -114,8 +113,7 @@ Structure: Use professional compliance tone. Be concise.`; try { - return await completeClaudeText({ - model: getAnthropicModel(), + return await completeText({ systemPrompt: "You draft IC compliance memos for VC associates. Use only provided screening facts. Never confirm guilt or sanctions violations.", user: prompt, diff --git a/backend/src/lib/documents/generateScreeningAnalysis.ts b/backend/src/lib/documents/generateScreeningAnalysis.ts index 5a6cb03..97d5d17 100644 --- a/backend/src/lib/documents/generateScreeningAnalysis.ts +++ b/backend/src/lib/documents/generateScreeningAnalysis.ts @@ -1,5 +1,4 @@ -import { completeClaudeText } from "../llm/claude"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; import type { ScreeningResult } from "../../types/screening"; import { wrapEntityName } from "../shared/promptDelimiters"; import { buildFactsBlock } from "./generateIcMemo"; @@ -44,8 +43,7 @@ Structure: Use clear analyst tone. Do not use "Investment Committee" headings. Be concise.`; try { - return await completeClaudeText({ - model: getAnthropicModel(), + return await completeText({ systemPrompt: "You write sanctions screening analysis documents for VC compliance analysts. Use only provided facts. Never confirm guilt or sanctions violations.", user: prompt, diff --git a/backend/src/lib/infra/backup.ts b/backend/src/lib/infra/backup.ts new file mode 100644 index 0000000..ed8a629 --- /dev/null +++ b/backend/src/lib/infra/backup.ts @@ -0,0 +1,136 @@ +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; +import zlib from "zlib"; +import { dataDir, dataFile } from "./dataDir"; +import { getSqliteDb } from "./db"; + +/** + * Encrypted single-file backups. + * + * "CAPSCREENBAK1\n" | salt(16) | iv(12) | tag(16) | AES-256-GCM( gzip( manifest JSON "\n" + file bytes… ) ) + * + * Key = scrypt(password, salt). Includes the database, stored documents, app + * settings and AI model assignments. API keys are excluded on purpose: they are + * sealed to this computer's keychain and must be re-entered after a restore. + */ + +const MAGIC = Buffer.from("CAPSCREENBAK1\n"); +const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; + +type Entry = { path: string; size: number }; +type Manifest = { version: 1; createdAt: string; app: "capscreen"; entries: Entry[] }; + +function deriveKey(password: string, salt: Buffer): Buffer { + return crypto.scryptSync(password, salt, 32, SCRYPT); +} + +function walk(dir: string, base = dir): string[] { + if (!fs.existsSync(dir)) return []; + const out: string[] = []; + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name); + if (fs.statSync(full).isDirectory()) out.push(...walk(full, base)); + else out.push(path.relative(base, full)); + } + return out; +} + +export function createBackup(password: string): { file: Buffer; manifest: Manifest } { + if (password.length < 8) throw new Error("Use a backup password of at least 8 characters"); + + const snapshot = path.join(dataDir("tmp"), `backup-${Date.now()}.db`); + getSqliteDb().backupTo(snapshot); + try { + const sources: Array<{ rel: string; abs: string }> = [ + { rel: "capscreen.db", abs: snapshot }, + ...walk(dataDir("files")).map((rel) => ({ rel: `files/${rel}`, abs: path.join(dataDir("files"), rel) })), + ]; + for (const rel of ["settings.json", "ai/config.json"]) { + const abs = dataFile(...rel.split("/")); + if (fs.existsSync(abs)) sources.push({ rel, abs }); + } + + const blobs = sources.map((s) => fs.readFileSync(s.abs)); + const manifest: Manifest = { + version: 1, + createdAt: new Date().toISOString(), + app: "capscreen", + entries: sources.map((s, i) => ({ path: s.rel, size: blobs[i].length })), + }; + const plain = zlib.gzipSync(Buffer.concat([Buffer.from(JSON.stringify(manifest) + "\n"), ...blobs])); + + const salt = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", deriveKey(password, salt), iv); + cipher.setAAD(MAGIC); + const body = Buffer.concat([cipher.update(plain), cipher.final()]); + return { file: Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), body]), manifest }; + } finally { + fs.rmSync(snapshot, { force: true }); + } +} + +const stagingDir = () => dataDir("restore-staging"); +const markerFile = () => dataFile("restore-pending.json"); + +/** Decrypt + validate, then stage for swap-in on next backend start. */ +export function stageRestore(file: Buffer, password: string): Manifest { + if (file.length < MAGIC.length + 44 || !file.subarray(0, MAGIC.length).equals(MAGIC)) { + throw new Error("Not a CapScreen backup file"); + } + let o = MAGIC.length; + const salt = file.subarray(o, (o += 16)); + const iv = file.subarray(o, (o += 12)); + const tag = file.subarray(o, (o += 16)); + const decipher = crypto.createDecipheriv("aes-256-gcm", deriveKey(password, salt), iv); + decipher.setAAD(MAGIC); + decipher.setAuthTag(tag); + let plain: Buffer; + try { + plain = zlib.gunzipSync(Buffer.concat([decipher.update(file.subarray(o)), decipher.final()])); + } catch { + throw new Error("Wrong password, or the backup file is damaged"); + } + + const nl = plain.indexOf(0x0a); + const manifest = JSON.parse(plain.subarray(0, nl).toString("utf8")) as Manifest; + if (manifest.app !== "capscreen" || manifest.version !== 1) throw new Error("Unsupported backup version"); + + fs.rmSync(stagingDir(), { recursive: true, force: true }); + let offset = nl + 1; + for (const e of manifest.entries) { + const target = path.resolve(stagingDir(), e.path); + if (!target.startsWith(stagingDir() + path.sep)) throw new Error("Backup contains an invalid path"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, plain.subarray(offset, offset + e.size), { mode: 0o600 }); + offset += e.size; + } + fs.writeFileSync(markerFile(), JSON.stringify({ stagedAt: new Date().toISOString(), createdAt: manifest.createdAt })); + return manifest; +} + +/** Called before the database opens: swap a staged restore into place. */ +export function applyPendingRestore(dbFile: string): boolean { + if (!fs.existsSync(markerFile())) return false; + const staged = stagingDir(); + const stagedDb = path.join(staged, "capscreen.db"); + if (fs.existsSync(stagedDb)) { + for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(dbFile + suffix, { force: true }); + fs.renameSync(stagedDb, dbFile); + } + if (fs.existsSync(path.join(staged, "files"))) { + // Plain path join: dataDir() would recreate the folder we are replacing. + const filesDir = path.join(dataDir(), "files"); + fs.rmSync(filesDir, { recursive: true, force: true }); + fs.renameSync(path.join(staged, "files"), filesDir); + } + for (const rel of ["settings.json", "ai/config.json"]) { + const from = path.join(staged, rel); + if (fs.existsSync(from)) fs.renameSync(from, dataFile(...rel.split("/"))); + } + fs.rmSync(staged, { recursive: true, force: true }); + fs.rmSync(markerFile(), { force: true }); + console.log("[backup] restored data from backup"); + return true; +} diff --git a/backend/src/lib/infra/cache.ts b/backend/src/lib/infra/cache.ts index 47582a1..210232b 100644 --- a/backend/src/lib/infra/cache.ts +++ b/backend/src/lib/infra/cache.ts @@ -1,8 +1,7 @@ -import { Redis } from "@upstash/redis"; import type { WatchmanMatch } from "../../types/screening"; // --------------------------------------------------------------------------- -// Shared async interface — implemented by both Redis and in-memory backends. +// Shared async interface. // --------------------------------------------------------------------------- export interface AsyncCache { @@ -14,7 +13,7 @@ export interface AsyncCache { } // --------------------------------------------------------------------------- -// In-memory fallback — used when UPSTASH_REDIS_REST_URL is not set. +// In-memory cache. // --------------------------------------------------------------------------- interface MemEntry { @@ -65,64 +64,13 @@ class MemoryCache implements AsyncCache { } // --------------------------------------------------------------------------- -// Upstash Redis backend. +// Factory — single desktop process, so an in-memory cache is always correct. // --------------------------------------------------------------------------- -class RedisCache implements AsyncCache { - constructor(private client: Redis) {} - - async get(key: string): Promise { - return this.client.get(key); - } - - async set(key: string, value: T, ttlMs: number): Promise { - const ex = Math.max(1, Math.ceil(ttlMs / 1000)); - await this.client.set(key, value, { ex }); - } - - async delete(key: string): Promise { - await this.client.del(key); - } - - async invalidatePrefix(prefix: string): Promise { - let cursor = 0; - do { - const [next, keys] = await this.client.scan(cursor, { - match: `${prefix}*`, - count: 100, - }); - cursor = Number(next); - if (keys.length > 0) { - await this.client.del(...keys); - } - } while (cursor !== 0); - } -} - -// --------------------------------------------------------------------------- -// Factory — picks Redis when Upstash env vars are present, else memory. -// --------------------------------------------------------------------------- - -let sharedRedis: Redis | null = null; - -function getRedis(): Redis | null { - const url = process.env.UPSTASH_REDIS_REST_URL?.trim(); - const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); - if (!url || !token) return null; - if (!sharedRedis) sharedRedis = new Redis({ url, token }); - return sharedRedis; -} - function createCache(maxMemorySize = 1_000): AsyncCache { - const redis = getRedis(); - if (redis) return new RedisCache(redis); return new MemoryCache(maxMemorySize); } -export function isCacheRedis(): boolean { - return getRedis() !== null; -} - // --------------------------------------------------------------------------- // TTL constants (ms). // --------------------------------------------------------------------------- diff --git a/backend/src/lib/infra/dataDir.ts b/backend/src/lib/infra/dataDir.ts new file mode 100644 index 0000000..db0ca72 --- /dev/null +++ b/backend/src/lib/infra/dataDir.ts @@ -0,0 +1,20 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +/** + * Root for all on-device data (database, documents, model config). + * The Electron shell sets CAPSCREEN_DATA_DIR to app.getPath("userData"); + * `pnpm dev` falls back to ~/.capscreen. + */ +export function dataDir(...sub: string[]): string { + const root = process.env.CAPSCREEN_DATA_DIR?.trim() || path.join(os.homedir(), ".capscreen"); + const dir = path.join(root, ...sub); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + return dir; +} + +export function dataFile(...parts: string[]): string { + const file = parts.pop()!; + return path.join(dataDir(...parts), file); +} diff --git a/backend/src/lib/infra/db.ts b/backend/src/lib/infra/db.ts index 422bc8c..613c279 100644 --- a/backend/src/lib/infra/db.ts +++ b/backend/src/lib/infra/db.ts @@ -1,28 +1,80 @@ import mongoose from "mongoose"; +import { dataFile } from "./dataDir"; +import { SqliteClient, SqliteDb } from "./sqliteStore/store"; +import { applyPendingRestore } from "./backup"; -let pending: Promise | null = null; +/** + * On-device database. Mongoose models run unchanged on top of a SQLite file + * (see sqliteStore/) — nothing is sent to a database server. + * + * Exception: the cloud auth service (src/authService, deployed to Vercel) sets + * CAPSCREEN_DB=mongodb and stores accounts — only accounts — in MongoDB Atlas. + * The desktop app never sets it. + */ + +let sqlite: SqliteDb | null = null; +let mongoPending: Promise | null = null; + +function usesMongo(): boolean { + return process.env.CAPSCREEN_DB === "mongodb"; +} + +export function databaseFile(): string { + return process.env.CAPSCREEN_DB_FILE?.trim() || dataFile("capscreen.db"); +} export async function connectDb(): Promise { - if (mongoose.connection.readyState === 1) return; - if (pending) return pending; - - const uri = - process.env.MONGODB_URI ?? "mongodb://localhost:27017/vc-screener"; - - pending = mongoose - .connect(uri) - .then(() => { - console.log("MongoDB connected"); - pending = null; - }) - .catch((err) => { - pending = null; + if (usesMongo()) { + if (mongoose.connection.readyState === 1) return; + const uri = process.env.MONGODB_URI?.trim(); + if (!uri) throw new Error("MONGODB_URI is required when CAPSCREEN_DB=mongodb"); + // Serverless: reuse one connection across warm invocations. + mongoPending ??= mongoose.connect(uri, { serverSelectionTimeoutMS: 8000 }).catch((err) => { + mongoPending = null; throw err; }); + await mongoPending; + return; + } + if (sqlite) return; + + applyPendingRestore(databaseFile()); + sqlite = new SqliteDb(databaseFile()); + const client = new SqliteClient(sqlite); + + // Mirror what mongoose's native driver does in _setClient(), then open. + const conn = mongoose.connection as unknown as { + db: unknown; + client: unknown; + name: string; + host: string; + port: number; + onOpen(): void; + }; + conn.db = sqlite; + conn.client = client; + conn.name = sqlite.databaseName; + conn.host = "localhost"; + conn.port = 0; + conn.onOpen(); + + sqlite.startTtlSweeper(); + console.log(`Local database: ${sqlite.file}`); +} - return pending; +export function getSqliteDb(): SqliteDb { + if (!sqlite) throw new Error("Database not connected"); + return sqlite; } export async function disconnectDb(): Promise { - await mongoose.disconnect(); + if (usesMongo()) { + await mongoose.disconnect(); + mongoPending = null; + return; + } + if (!sqlite) return; + sqlite.close(); + sqlite = null; + (mongoose.connection as unknown as { readyState: number }).readyState = 0; } diff --git a/backend/src/lib/infra/fileStorage.ts b/backend/src/lib/infra/fileStorage.ts new file mode 100644 index 0000000..a18b22d --- /dev/null +++ b/backend/src/lib/infra/fileStorage.ts @@ -0,0 +1,141 @@ +import crypto from "crypto"; +import fs from "fs/promises"; +import path from "path"; +import express from "express"; +import { dataDir } from "./dataDir"; + +/** + * On-device object storage. Keys are relative paths under /files; + * downloads are served by the backend through short-lived signed URLs. + */ + +const SIGNING_SECRET = crypto.randomBytes(32); + +function root(): string { + return dataDir("files"); +} + +function resolveKey(key: string): string { + const full = path.resolve(root(), key); + if (!full.startsWith(root() + path.sep)) throw new Error("Invalid storage key"); + return full; +} + +/** Always true on desktop — kept so callers read the same as before. */ +export function storageConfigured(): boolean { + return true; +} + +export function safeStorageFilename(filename: string): string { + const base = filename.split(/[/\\]/).pop()?.trim() || "file"; + const cleaned = base.replace(/[^\w.\-()+\s]/g, "_").replace(/\s+/g, "_"); + return cleaned.slice(0, 180) || "file"; +} + +export function buildUserDocumentKey(params: { + userId: string; + docId: string; + filename: string; + projectId?: string | null; +}): string { + const safe = safeStorageFilename(params.filename); + if (params.projectId) { + return `users/${params.userId}/projects/${params.projectId}/documents/${params.docId}/${safe}`; + } + return `users/${params.userId}/documents/${params.docId}/${safe}`; +} + +export function buildRagDocumentKey(params: { + userId: string; + startupId: string; + docId: string; + filename: string; +}): string { + const safe = safeStorageFilename(params.filename); + return `users/${params.userId}/startups/${params.startupId}/rag/${params.docId}/${safe}`; +} + +export async function uploadObject(params: { + key: string; + body: Buffer; + contentType: string; +}): Promise { + const file = resolveKey(params.key); + await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + await fs.writeFile(file, params.body, { mode: 0o600 }); +} + +export async function downloadObject(key: string): Promise { + try { + return await fs.readFile(resolveKey(key)); + } catch { + return null; + } +} + +export async function deleteObject(key: string): Promise { + if (!key) return; + await fs.rm(resolveKey(key), { force: true }); +} + +function sign(payload: string): string { + return crypto.createHmac("sha256", SIGNING_SECRET).update(payload).digest("base64url"); +} + +function apiOrigin(): string { + return (process.env.CAPSCREEN_API_ORIGIN ?? `http://127.0.0.1:${process.env.PORT ?? 3001}`).replace(/\/$/, ""); +} + +export async function getObjectDownloadUrl( + key: string, + filename: string, + expiresInSeconds = 3600, +): Promise { + const payload = Buffer.from( + JSON.stringify({ k: key, f: safeStorageFilename(filename), e: Date.now() + expiresInSeconds * 1000 }), + ).toString("base64url"); + return `${apiOrigin()}/api/files/${payload}.${sign(payload)}`; +} + +/** GET /api/files/:token — serves a file for a signed, unexpired token. */ +export const fileDownloadRouter = express.Router(); + +fileDownloadRouter.get("/:token", async (req, res) => { + const [payload, sig] = req.params.token.split("."); + const expected = payload ? sign(payload) : ""; + if (!sig || sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { + res.status(403).json({ detail: "Invalid download link" }); + return; + } + const { k, f, e } = JSON.parse(Buffer.from(payload, "base64url").toString()) as { k: string; f: string; e: number }; + if (Date.now() > e) { + res.status(410).json({ detail: "Download link expired" }); + return; + } + const body = await downloadObject(k); + if (!body) { + res.status(404).json({ detail: "File not found" }); + return; + } + res.setHeader("Content-Disposition", `attachment; filename="${f.replace(/"/g, "")}"`); + res.type(path.extname(f) || "application/octet-stream").send(body); +}); + +export function detectFileType( + filename: string, + mimeType: string, +): string | null { + const ext = filename.split(".").pop()?.toLowerCase(); + if (ext === "pdf" || mimeType === "application/pdf") return "pdf"; + if ( + ext === "docx" || + mimeType === + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) { + return "docx"; + } + if (ext === "doc" || mimeType === "application/msword") return "doc"; + if (ext === "csv" || mimeType === "text/csv") return "csv"; + if (ext === "txt" || mimeType === "text/plain") return "txt"; + return ext ?? null; +} diff --git a/backend/src/lib/infra/folderWatch.ts b/backend/src/lib/infra/folderWatch.ts new file mode 100644 index 0000000..e57abc2 --- /dev/null +++ b/backend/src/lib/infra/folderWatch.ts @@ -0,0 +1,143 @@ +import fs from "fs"; +import path from "path"; +import { dataFile } from "./dataDir"; + +/** + * Watched data-room folders: drop a file into a startup's folder and CapScreen + * ingests it — CSVs as cap tables / entity lists, PDF/DOCX/TXT into document + * search. Polling (not fs.watch) so it behaves the same on macOS, Windows and + * Linux, with network drives, and with half-copied files. + * + * Ingestion goes through the app's own HTTP endpoints so uploads get exactly + * the same validation, parsing and audit logging as manual ones. + */ + +type WatchEntry = { path: string; processed: Record; lastScanAt?: string; lastError?: string | null }; +type WatchFile = Record; + +const SCAN_EVERY_MS = 20_000; +const STABLE_MS = 3_000; +const DOC_EXT = new Set([".pdf", ".docx", ".txt", ".md"]); +const MAX_DEPTH = 2; + +const ledgerPath = () => dataFile("folder-watch.json"); + +function readLedger(): WatchFile { + try { + return JSON.parse(fs.readFileSync(ledgerPath(), "utf8")) as WatchFile; + } catch { + return {}; + } +} + +function writeLedger(l: WatchFile): void { + fs.writeFileSync(ledgerPath(), JSON.stringify(l, null, 2), { mode: 0o600 }); +} + +export function getWatchFolder(startupId: string): (Omit & { ingestedCount: number }) | null { + const e = readLedger()[startupId]; + return e ? { path: e.path, lastScanAt: e.lastScanAt, lastError: e.lastError ?? null, ingestedCount: Object.keys(e.processed).length } : null; +} + +export function setWatchFolder(startupId: string, folder: string | null): void { + const l = readLedger(); + if (!folder) { + delete l[startupId]; + } else { + if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) throw new Error("Folder does not exist"); + l[startupId] = { path: path.resolve(folder), processed: l[startupId]?.path === path.resolve(folder) ? l[startupId].processed : {} }; + } + writeLedger(l); +} + +function listFiles(dir: string, depth = 0): string[] { + const out: string[] = []; + for (const name of fs.readdirSync(dir)) { + if (name.startsWith(".") || name.startsWith("~$")) continue; + const full = path.join(dir, name); + const st = fs.statSync(full); + if (st.isDirectory() && depth < MAX_DEPTH) out.push(...listFiles(full, depth + 1)); + else if (st.isFile()) out.push(full); + } + return out; +} + +function api(): { base: string; headers: Record } { + const token = process.env.CAPSCREEN_DESKTOP_TOKEN; + return { + base: `http://127.0.0.1:${process.env.PORT ?? 3001}`, + headers: token ? { "x-capscreen-token": token } : {}, + }; +} + +async function ingest(startupId: string, file: string): Promise { + const ext = path.extname(file).toLowerCase(); + const { base, headers } = api(); + const filename = path.basename(file); + let resp: Response; + if (ext === ".csv") { + resp = await fetch(`${base}/api/startups/${startupId}/csvs`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ filename, content: fs.readFileSync(file, "utf8") }), + }); + } else { + const form = new FormData(); + form.append("file", new Blob([fs.readFileSync(file)]), filename); + resp = await fetch(`${base}/api/startups/${startupId}/rag-documents`, { method: "POST", headers, body: form }); + } + if (!resp.ok) { + const body = (await resp.json().catch(() => ({}))) as { detail?: string }; + throw new Error(`${filename}: ${body.detail ?? resp.status}`); + } +} + +let scanning = false; + +export async function scanWatchedFolders(): Promise { + if (scanning) return; + scanning = true; + try { + const ledger = readLedger(); + for (const [startupId, entry] of Object.entries(ledger)) { + entry.lastScanAt = new Date().toISOString(); + if (!fs.existsSync(entry.path)) { + entry.lastError = "Folder is missing or disconnected"; + continue; + } + entry.lastError = null; + for (const file of listFiles(entry.path)) { + const ext = path.extname(file).toLowerCase(); + if (ext !== ".csv" && !DOC_EXT.has(ext)) continue; + const st = fs.statSync(file); + if (Date.now() - st.mtimeMs < STABLE_MS) continue; // still being written + const rel = path.relative(entry.path, file); + const sig = `${st.size}:${Math.floor(st.mtimeMs)}`; + if (entry.processed[rel] === sig) continue; + try { + await ingest(startupId, file); + entry.processed[rel] = sig; + console.log(`[folder-watch] ingested ${rel} for startup ${startupId}`); + } catch (err) { + // Mark as seen so a bad file isn't retried every 20 s; editing it retries. + entry.processed[rel] = sig; + entry.lastError = err instanceof Error ? err.message : String(err); + console.warn(`[folder-watch] ${entry.lastError}`); + } + } + } + // Merge into a fresh read: the user may have changed folders mid-scan. + const latest = readLedger(); + for (const [id, scanned] of Object.entries(ledger)) { + if (latest[id]?.path === scanned.path) latest[id] = scanned; + } + writeLedger(latest); + } finally { + scanning = false; + } +} + +export function startFolderWatcher(): void { + setTimeout(() => void scanWatchedFolders(), 10_000).unref(); + setInterval(() => void scanWatchedFolders(), SCAN_EVERY_MS).unref(); +} diff --git a/backend/src/lib/infra/index.ts b/backend/src/lib/infra/index.ts index 3b26296..8e54c5e 100644 --- a/backend/src/lib/infra/index.ts +++ b/backend/src/lib/infra/index.ts @@ -11,5 +11,4 @@ export { chatListCache, workflowListCache, cacheKey, - isCacheRedis, } from "./cache"; diff --git a/backend/src/lib/infra/r2Config.ts b/backend/src/lib/infra/r2Config.ts deleted file mode 100644 index 4263969..0000000 --- a/backend/src/lib/infra/r2Config.ts +++ /dev/null @@ -1,41 +0,0 @@ -export function getR2AccountId(): string | null { - return process.env.R2_ACCOUNT_ID?.trim() || null; -} - -export function getR2BucketName(): string { - return process.env.R2_BUCKET_NAME?.trim() || "capscreen-screener"; -} - -export function getR2EndpointUrl(): string | null { - const explicit = process.env.R2_ENDPOINT_URL?.trim(); - if (explicit) return explicit.replace(/\/$/, ""); - const accountId = getR2AccountId(); - if (!accountId) return null; - return `https://${accountId}.r2.cloudflarestorage.com`; -} - -export function r2Configured(): boolean { - return Boolean( - getR2EndpointUrl() && - process.env.R2_ACCESS_KEY_ID?.trim() && - process.env.R2_SECRET_ACCESS_KEY?.trim() && - getR2BucketName(), - ); -} - -export function getR2PublicUrl(): string | null { - const raw = process.env.R2_PUBLIC_URL?.trim(); - return raw ? raw.replace(/\/$/, "") : null; -} - -export function logR2StartupWarnings(): void { - if (r2Configured()) { - console.log( - `[storage] Cloudflare R2 configured — bucket ${getR2BucketName()}`, - ); - return; - } - console.warn( - "[storage] R2 is not configured — file uploads will fail until R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_BUCKET_NAME are set", - ); -} diff --git a/backend/src/lib/infra/r2Storage.ts b/backend/src/lib/infra/r2Storage.ts deleted file mode 100644 index 41d5044..0000000 --- a/backend/src/lib/infra/r2Storage.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { - DeleteObjectCommand, - GetObjectCommand, - PutObjectCommand, - S3Client, -} from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { - getR2BucketName, - getR2EndpointUrl, - getR2PublicUrl, - r2Configured, -} from "./r2Config"; - -let client: S3Client | null = null; - -function getClient(): S3Client { - if (!r2Configured()) { - throw new Error("R2 storage is not configured"); - } - if (!client) { - client = new S3Client({ - region: "auto", - endpoint: getR2EndpointUrl()!, - credentials: { - accessKeyId: process.env.R2_ACCESS_KEY_ID!.trim(), - secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!.trim(), - }, - }); - } - return client; -} - -export { r2Configured }; - -export function safeStorageFilename(filename: string): string { - const base = filename.split(/[/\\]/).pop()?.trim() || "file"; - const cleaned = base.replace(/[^\w.\-()+\s]/g, "_").replace(/\s+/g, "_"); - return cleaned.slice(0, 180) || "file"; -} - -export function buildUserDocumentKey(params: { - userId: string; - docId: string; - filename: string; - projectId?: string | null; -}): string { - const safe = safeStorageFilename(params.filename); - if (params.projectId) { - return `users/${params.userId}/projects/${params.projectId}/documents/${params.docId}/${safe}`; - } - return `users/${params.userId}/documents/${params.docId}/${safe}`; -} - -export function buildRagDocumentKey(params: { - userId: string; - startupId: string; - docId: string; - filename: string; -}): string { - const safe = safeStorageFilename(params.filename); - return `users/${params.userId}/startups/${params.startupId}/rag/${params.docId}/${safe}`; -} - -export async function uploadObject(params: { - key: string; - body: Buffer; - contentType: string; -}): Promise { - await getClient().send( - new PutObjectCommand({ - Bucket: getR2BucketName(), - Key: params.key, - Body: params.body, - ContentType: params.contentType, - }), - ); -} - -export async function downloadObject(key: string): Promise { - if (!r2Configured()) return null; - try { - const response = await getClient().send( - new GetObjectCommand({ Bucket: getR2BucketName(), Key: key }), - ); - if (!response.Body) return null; - const bytes = await response.Body.transformToByteArray(); - return Buffer.from(bytes); - } catch { - return null; - } -} - -export async function deleteObject(key: string): Promise { - if (!r2Configured() || !key) return; - await getClient().send( - new DeleteObjectCommand({ Bucket: getR2BucketName(), Key: key }), - ); -} - -export async function getObjectDownloadUrl( - key: string, - filename: string, - expiresInSeconds = 3600, -): Promise { - const publicBase = getR2PublicUrl(); - if (publicBase) { - return `${publicBase}/${key.split("/").map(encodeURIComponent).join("/")}`; - } - if (!r2Configured()) return null; - const command = new GetObjectCommand({ - Bucket: getR2BucketName(), - Key: key, - ResponseContentDisposition: `attachment; filename="${safeStorageFilename(filename).replace(/"/g, "")}"`, - }); - return getSignedUrl(getClient(), command, { expiresIn: expiresInSeconds }); -} - -export function detectFileType( - filename: string, - mimeType: string, -): string | null { - const ext = filename.split(".").pop()?.toLowerCase(); - if (ext === "pdf" || mimeType === "application/pdf") return "pdf"; - if ( - ext === "docx" || - mimeType === - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) { - return "docx"; - } - if (ext === "doc" || mimeType === "application/msword") return "doc"; - if (ext === "csv" || mimeType === "text/csv") return "csv"; - if (ext === "txt" || mimeType === "text/plain") return "txt"; - return ext ?? null; -} diff --git a/backend/src/lib/infra/settings.ts b/backend/src/lib/infra/settings.ts new file mode 100644 index 0000000..5eaaf02 --- /dev/null +++ b/backend/src/lib/infra/settings.ts @@ -0,0 +1,53 @@ +import fs from "fs"; +import { dataFile } from "./dataDir"; + +/** + * Desktop app settings, shared with the Electron shell (which reads + * sanctionsLists to configure Watchman). One JSON file in the data dir. + */ +export type AppSettings = { + backgroundScreening: { enabled: boolean; intervalHours: number }; + /** Watchman source lists, e.g. us_ofac, eu_csl, uk_csl, un_csl, us_non_sdn. */ + sanctionsLists: string[]; +}; + +export const DEFAULT_SETTINGS: AppSettings = { + backgroundScreening: { enabled: true, intervalHours: 24 }, + sanctionsLists: ["us_ofac", "eu_csl"], +}; + +export const AVAILABLE_SANCTIONS_LISTS: Array<{ id: string; label: string }> = [ + { id: "us_ofac", label: "US OFAC — SDN list" }, + { id: "us_non_sdn", label: "US OFAC — Non-SDN consolidated" }, + { id: "us_csl", label: "US Consolidated Screening List (Commerce/State/Treasury)" }, + { id: "eu_csl", label: "EU Consolidated Financial Sanctions" }, + { id: "uk_csl", label: "UK HM Treasury (OFSI) Consolidated List" }, + { id: "un_csl", label: "UN Security Council Consolidated List" }, + { id: "us_fincen_311", label: "FinCEN Section 311 special measures" }, +]; + +const file = () => dataFile("settings.json"); + +export function readSettings(): AppSettings { + try { + const raw = JSON.parse(fs.readFileSync(file(), "utf8")) as Partial; + return { + backgroundScreening: { ...DEFAULT_SETTINGS.backgroundScreening, ...(raw.backgroundScreening ?? {}) }, + sanctionsLists: Array.isArray(raw.sanctionsLists) && raw.sanctionsLists.length ? raw.sanctionsLists : DEFAULT_SETTINGS.sanctionsLists, + }; + } catch { + return structuredClone(DEFAULT_SETTINGS); + } +} + +export function writeSettings(patch: Partial): AppSettings { + const cur = readSettings(); + const next: AppSettings = { + backgroundScreening: { ...cur.backgroundScreening, ...(patch.backgroundScreening ?? {}) }, + sanctionsLists: patch.sanctionsLists ?? cur.sanctionsLists, + }; + const tmp = `${file()}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, file()); + return next; +} diff --git a/backend/src/lib/infra/sqliteStore/query.ts b/backend/src/lib/infra/sqliteStore/query.ts new file mode 100644 index 0000000..0c01931 --- /dev/null +++ b/backend/src/lib/infra/sqliteStore/query.ts @@ -0,0 +1,405 @@ +/** + * MongoDB query semantics over plain JS documents — the subset mongoose emits + * for this codebase: comparison, logical, element, array and regex operators, + * dotted paths with implicit array traversal, projection and sort. + */ + +export type Doc = Record; +export type Filter = Record; + +export class UnsupportedOperatorError extends Error { + constructor(op: string) { + super(`SQLite store does not support MongoDB operator ${op}`); + this.name = "UnsupportedOperatorError"; + } +} + +// ── Value helpers ──────────────────────────────────────────────────────────── + +export function isBsonValue(v: unknown): v is { _bsontype: string } { + return !!v && typeof v === "object" && typeof (v as { _bsontype?: unknown })._bsontype === "string"; +} + +export function isPlainObject(v: unknown): v is Doc { + if (!v || typeof v !== "object" || Array.isArray(v)) return false; + if (v instanceof Date || v instanceof RegExp || Buffer.isBuffer(v) || isBsonValue(v)) return false; + return true; +} + +function bsonString(v: { _bsontype: string }): string { + const s = v as unknown as { toHexString?: () => string; toString: (enc?: string) => string }; + return s.toHexString ? s.toHexString() : s.toString("hex"); +} + +/** MongoDB BSON type ordering for cross-type comparisons. */ +function typeRank(v: unknown): number { + if (v === undefined || v === null) return 1; + if (typeof v === "number" || typeof v === "bigint") return 2; + if (typeof v === "string") return 3; + if (Array.isArray(v)) return 5; + if (v instanceof Date) return 9; + if (v instanceof RegExp) return 11; + if (typeof v === "boolean") return 8; + if (isBsonValue(v)) { + if (v._bsontype === "ObjectId") return 7; + if (v._bsontype === "Binary") return 6; + if (["Decimal128", "Long", "Int32", "Double"].includes(v._bsontype)) return 2; + return 10; + } + if (Buffer.isBuffer(v)) return 6; + return 4; +} + +function numeric(v: unknown): number { + if (typeof v === "number") return v; + if (typeof v === "bigint") return Number(v); + return Number(String(v)); +} + +/** Total order across values, matching MongoDB's sort order closely enough for app use. */ +export function compareValues(a: unknown, b: unknown): number { + const ra = typeRank(a); + const rb = typeRank(b); + if (ra !== rb) return ra - rb; + switch (ra) { + case 1: + return 0; + case 2: + return numeric(a) - numeric(b); + case 3: + return (a as string) < (b as string) ? -1 : (a as string) > (b as string) ? 1 : 0; + case 7: + case 6: { + const sa = isBsonValue(a) ? bsonString(a) : (a as Buffer).toString("hex"); + const sb = isBsonValue(b) ? bsonString(b) : (b as Buffer).toString("hex"); + return sa < sb ? -1 : sa > sb ? 1 : 0; + } + case 8: + return Number(a) - Number(b); + case 9: + return (a as Date).getTime() - (b as Date).getTime(); + case 5: { + const aa = a as unknown[]; + const bb = b as unknown[]; + for (let i = 0; i < Math.min(aa.length, bb.length); i++) { + const c = compareValues(aa[i], bb[i]); + if (c !== 0) return c; + } + return aa.length - bb.length; + } + case 4: { + const ka = Object.keys(a as Doc); + const kb = Object.keys(b as Doc); + for (let i = 0; i < Math.min(ka.length, kb.length); i++) { + if (ka[i] !== kb[i]) return ka[i] < kb[i] ? -1 : 1; + const c = compareValues((a as Doc)[ka[i]], (b as Doc)[kb[i]]); + if (c !== 0) return c; + } + return ka.length - kb.length; + } + default: + return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0; + } +} + +export function valuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if ((a === null || a === undefined) && (b === null || b === undefined)) return true; + if (typeRank(a) !== typeRank(b)) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => valuesEqual(v, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const ka = Object.keys(a).filter((k) => a[k] !== undefined); + const kb = Object.keys(b).filter((k) => b[k] !== undefined); + return ka.length === kb.length && ka.every((k) => valuesEqual(a[k], b[k])); + } + return compareValues(a, b) === 0; +} + +// ── Path resolution ────────────────────────────────────────────────────────── + +/** All values reachable at a dotted path, traversing arrays like MongoDB does. */ +export function resolvePath(value: unknown, parts: string[], i = 0): unknown[] { + if (i === parts.length) return [value]; + if (Array.isArray(value)) { + const out: unknown[] = []; + if (/^\d+$/.test(parts[i])) out.push(...resolvePath(value[Number(parts[i])], parts, i + 1)); + for (const el of value) { + if (isPlainObject(el)) out.push(...resolvePath(el, parts, i)); + } + return out.length ? out : [undefined]; + } + if (isPlainObject(value)) return resolvePath(value[parts[i]], parts, i + 1); + return [undefined]; +} + +export function getPath(doc: unknown, path: string): unknown { + let cur: unknown = doc; + for (const p of path.split(".")) { + if (cur === null || cur === undefined) return undefined; + if (Array.isArray(cur) && /^\d+$/.test(p)) cur = cur[Number(p)]; + else if (isPlainObject(cur)) cur = cur[p]; + else return undefined; + } + return cur; +} + +// ── Matching ───────────────────────────────────────────────────────────────── + +function isOperatorObject(v: unknown): v is Doc { + if (!isPlainObject(v)) return false; + const keys = Object.keys(v); + return keys.length > 0 && keys.every((k) => k.startsWith("$")); +} + +/** Equality with array-contains semantics and regex support. */ +function candidateEquals(candidate: unknown, expected: unknown): boolean { + if (expected instanceof RegExp) { + if (typeof candidate === "string") return expected.test(candidate); + if (Array.isArray(candidate)) return candidate.some((c) => typeof c === "string" && expected.test(c)); + return false; + } + if (valuesEqual(candidate, expected)) return true; + if (Array.isArray(candidate)) return candidate.some((c) => valuesEqual(c, expected)); + return false; +} + +function anyEquals(candidates: unknown[], expected: unknown): boolean { + return candidates.some((c) => candidateEquals(c, expected)); +} + +function flatten(candidates: unknown[]): unknown[] { + const out: unknown[] = []; + for (const c of candidates) { + if (Array.isArray(c)) out.push(...c); + else out.push(c); + } + return out; +} + +function compareOp(candidates: unknown[], expected: unknown, test: (c: number) => boolean): boolean { + return flatten(candidates).some( + (c) => c !== undefined && typeRank(c) === typeRank(expected) && test(compareValues(c, expected)), + ); +} + +function toRegExp(pattern: unknown, options: unknown): RegExp { + if (pattern instanceof RegExp) { + return options ? new RegExp(pattern.source, String(options)) : pattern; + } + return new RegExp(String(pattern), typeof options === "string" ? options : undefined); +} + +function matchOperators(candidates: unknown[], ops: Doc): boolean { + for (const [op, arg] of Object.entries(ops)) { + switch (op) { + case "$eq": + if (!anyEquals(candidates, arg)) return false; + break; + case "$ne": + if (anyEquals(candidates, arg)) return false; + break; + case "$in": + if (!(arg as unknown[]).some((v) => anyEquals(candidates, v))) return false; + break; + case "$nin": + if ((arg as unknown[]).some((v) => anyEquals(candidates, v))) return false; + break; + case "$gt": + if (!compareOp(candidates, arg, (c) => c > 0)) return false; + break; + case "$gte": + if (!compareOp(candidates, arg, (c) => c >= 0)) return false; + break; + case "$lt": + if (!compareOp(candidates, arg, (c) => c < 0)) return false; + break; + case "$lte": + if (!compareOp(candidates, arg, (c) => c <= 0)) return false; + break; + case "$exists": { + const exists = candidates.some((c) => c !== undefined); + if (exists !== Boolean(arg)) return false; + break; + } + case "$regex": { + const re = toRegExp(arg, ops.$options); + if (!flatten(candidates).some((c) => typeof c === "string" && re.test(c))) return false; + break; + } + case "$options": + break; + case "$size": + if (!candidates.some((c) => Array.isArray(c) && c.length === arg)) return false; + break; + case "$all": + if (!(arg as unknown[]).every((v) => anyEquals(candidates, v))) return false; + break; + case "$elemMatch": { + const sub = arg as Doc; + const hit = candidates.some( + (c) => + Array.isArray(c) && + c.some((el) => + isOperatorObject(sub) ? matchOperators([el], sub) : isPlainObject(el) && matchesFilter(el, sub), + ), + ); + if (!hit) return false; + break; + } + case "$not": { + const inner = arg instanceof RegExp ? { $regex: arg } : (arg as Doc); + if (matchOperators(candidates, inner)) return false; + break; + } + case "$type": { + const want = String(arg); + const names: Record = { 1: "null", 2: "number", 3: "string", 4: "object", 5: "array", 7: "objectId", 8: "bool", 9: "date" }; + if (!candidates.some((c) => names[typeRank(c)] === want || (want === "double" && typeof c === "number"))) return false; + break; + } + default: + throw new UnsupportedOperatorError(op); + } + } + return true; +} + +export function matchesFilter(doc: Doc, filter: Filter | null | undefined): boolean { + if (!filter) return true; + for (const [key, cond] of Object.entries(filter)) { + if (key === "$and") { + if (!(cond as Filter[]).every((f) => matchesFilter(doc, f))) return false; + continue; + } + if (key === "$or") { + if (!(cond as Filter[]).some((f) => matchesFilter(doc, f))) return false; + continue; + } + if (key === "$nor") { + if ((cond as Filter[]).some((f) => matchesFilter(doc, f))) return false; + continue; + } + if (key === "$comment") continue; + if (key.startsWith("$")) throw new UnsupportedOperatorError(key); + + const candidates = resolvePath(doc, key.split(".")); + if (isOperatorObject(cond)) { + if (!matchOperators(candidates, cond)) return false; + } else if (!anyEquals(candidates, cond)) { + return false; + } + } + return true; +} + +/** Equality fields from a filter — seeds the document created by an upsert. */ +export function equalityFields(filter: Filter | null | undefined): Array<[string, unknown]> { + const out: Array<[string, unknown]> = []; + if (!filter) return out; + for (const [key, cond] of Object.entries(filter)) { + if (key === "$and") { + for (const f of cond as Filter[]) out.push(...equalityFields(f)); + } else if (!key.startsWith("$")) { + if (isOperatorObject(cond)) { + if ("$eq" in cond) out.push([key, cond.$eq]); + } else if (!(cond instanceof RegExp)) { + out.push([key, cond]); + } + } + } + return out; +} + +// ── Sort & projection ──────────────────────────────────────────────────────── + +export type SortSpec = Array<[string, 1 | -1]>; + +export function normalizeSort(sort: unknown): SortSpec { + if (!sort) return []; + const entries: Array<[string, unknown]> = + sort instanceof Map + ? [...sort.entries()] + : Array.isArray(sort) + ? (sort as Array<[string, unknown]>) + : typeof sort === "string" + ? [[sort, 1]] + : Object.entries(sort as Doc); + return entries.map(([k, v]) => { + const dir = v === -1 || v === "desc" || v === "descending" ? -1 : 1; + return [k, dir as 1 | -1]; + }); +} + +function sortKey(doc: Doc, path: string, dir: 1 | -1): unknown { + const values = flatten(resolvePath(doc, path.split("."))); + if (values.length <= 1) return values[0]; + // Arrays sort by their min (asc) or max (desc) element. + return values.reduce((best, v) => (compareValues(v, best) * dir < 0 ? v : best)); +} + +export function sortDocs(docs: T[], sort: SortSpec): T[] { + if (!sort.length) return docs; + return [...docs].sort((a, b) => { + for (const [path, dir] of sort) { + const c = compareValues(sortKey(a, path, dir), sortKey(b, path, dir)); + if (c !== 0) return c * dir; + } + return 0; + }); +} + +function setPathRaw(target: Doc, path: string, value: unknown): void { + const parts = path.split("."); + let cur: Doc = target; + for (let i = 0; i < parts.length - 1; i++) { + if (!isPlainObject(cur[parts[i]])) cur[parts[i]] = {}; + cur = cur[parts[i]] as Doc; + } + cur[parts[parts.length - 1]] = value; +} + +function unsetPathRaw(target: Doc, path: string): void { + const parts = path.split("."); + let cur: unknown = target; + for (let i = 0; i < parts.length - 1; i++) { + if (!isPlainObject(cur)) return; + cur = cur[parts[i]]; + } + if (isPlainObject(cur)) delete cur[parts[parts.length - 1]]; +} + +/** Apply a find projection. `meta` supplies values for { $meta: … } fields. */ +export function projectDoc(doc: Doc, projection: Doc | null | undefined, meta?: Record): Doc { + if (!projection || Object.keys(projection).length === 0) return doc; + + const entries = Object.entries(projection); + const isMeta = (v: unknown) => isPlainObject(v) && "$meta" in v; + const inclusive = entries.some(([k, v]) => k !== "_id" && !isMeta(v) && v !== 0 && v !== false); + + if (inclusive) { + const out: Doc = {}; + if (projection._id !== 0 && projection._id !== false && doc._id !== undefined) out._id = doc._id; + for (const [k, v] of entries) { + if (k === "_id") continue; + if (isMeta(v)) { + out[k] = meta?.[String((v as Doc).$meta)]; + continue; + } + if (v === 0 || v === false) continue; + if (isPlainObject(v)) throw new UnsupportedOperatorError(`projection ${JSON.stringify(v)}`); + const value = getPath(doc, k); + if (value !== undefined) setPathRaw(out, k, value); + } + return out; + } + + const out: Doc = { ...doc }; + for (const [k, v] of entries) { + if (isMeta(v)) out[k] = meta?.[String((v as Doc).$meta)]; + else if (k.includes(".")) unsetPathRaw(out, k); + else delete out[k]; + } + return out; +} diff --git a/backend/src/lib/infra/sqliteStore/store.ts b/backend/src/lib/infra/sqliteStore/store.ts new file mode 100644 index 0000000..b5ddd46 --- /dev/null +++ b/backend/src/lib/infra/sqliteStore/store.ts @@ -0,0 +1,705 @@ +/** + * SQLite-backed implementation of the MongoDB driver surface that mongoose + * calls (Db / Collection / Cursor / ClientSession). + * + * Every collection lives in one `documents` table as EJSON. The process owning + * the file keeps a parsed in-memory copy and writes through on every mutation, + * so reads never touch disk. The desktop app is the only writer. + */ +import { DatabaseSync, type StatementSync } from "node:sqlite"; +import mongoose from "mongoose"; +import { + equalityFields, + getPath, + isBsonValue, + isPlainObject, + matchesFilter, + normalizeSort, + projectDoc, + sortDocs, + UnsupportedOperatorError, + valuesEqual, + type Doc, + type Filter, +} from "./query"; +import { applyUpdate, isUpdateDocument } from "./update"; + +const { EJSON } = mongoose.mongo.BSON; +const ObjectId = mongoose.Types.ObjectId; + +// ── Cloning / keys ─────────────────────────────────────────────────────────── + +export function cloneValue(v: T): T { + if (v === null || typeof v !== "object") return v; + if (v instanceof Date) return new Date(v.getTime()) as T; + if (isBsonValue(v) || v instanceof RegExp) return v; + // Mongoose documents/arrays expose toBSON() — exactly what the real driver + // serializes. (Mongoose overrides Array#map, so never map its arrays directly.) + const toBSON = (v as { toBSON?: () => unknown }).toBSON; + if (typeof toBSON === "function") return cloneValue(toBSON.call(v)) as T; + if (Array.isArray(v)) return Array.from(v, (x) => cloneValue(x)) as T; + if (Buffer.isBuffer(v)) return Buffer.from(v) as T; + if (v instanceof Map) { + const out: Doc = {}; + for (const [k, val] of v) out[String(k)] = cloneValue(val); + return out as T; + } + const out: Doc = {}; + for (const k of Object.keys(v as Doc)) { + const val = (v as Doc)[k]; + if (val !== undefined) out[k] = cloneValue(val); + } + return out as T; +} + +function idKey(id: unknown): string { + if (isBsonValue(id) && id._bsontype === "ObjectId") return `o:${(id as unknown as { toHexString(): string }).toHexString()}`; + if (typeof id === "string") return `s:${id}`; + if (typeof id === "number") return `n:${id}`; + return `j:${EJSON.stringify(id as Doc, { relaxed: false })}`; +} + +function duplicateKeyError(collection: string, keys: Doc, doc: Doc): Error { + const keyValue: Doc = {}; + for (const k of Object.keys(keys)) keyValue[k] = getPath(doc, k); + const err = new Error( + `E11000 duplicate key error collection: ${collection} index: ${Object.keys(keys).join("_")} dup key: ${EJSON.stringify(keyValue)}`, + ); + return Object.assign(err, { name: "MongoServerError", code: 11000, keyPattern: keys, keyValue }); +} + +type IndexSpec = { + name: string; + key: Doc; + unique?: boolean; + sparse?: boolean; + expireAfterSeconds?: number; + partialFilterExpression?: Filter; +}; + +type FindOptions = { + sort?: unknown; + limit?: number; + skip?: number; + projection?: Doc; + [k: string]: unknown; +}; + +// ── Cursor ─────────────────────────────────────────────────────────────────── + +export class SqliteCursor { + private _sort: unknown; + private _limit = 0; + private _skip = 0; + private _projection: Doc | undefined; + private _maps: Array<(d: unknown) => unknown> = []; + private buffer: T[] | null = null; + + constructor(private readonly source: () => Doc[], opts: FindOptions = {}) { + this._sort = opts.sort; + this._limit = opts.limit ?? 0; + this._skip = opts.skip ?? 0; + this._projection = opts.projection; + } + + sort(s: unknown) { this._sort = s; return this; } + limit(n: number) { this._limit = n; return this; } + skip(n: number) { this._skip = n; return this; } + project(p: Doc) { this._projection = p; return this; } + map(fn: (d: T) => U): SqliteCursor { + this._maps.push(fn as (d: unknown) => unknown); + return this as unknown as SqliteCursor; + } + batchSize() { return this; } + maxTimeMS() { return this; } + allowDiskUse() { return this; } + addCursorFlag() { return this; } + hint() { return this; } + collation() { return this; } + comment() { return this; } + + private materialize(): T[] { + if (this.buffer) return this.buffer; + let docs = sortDocs(this.source(), normalizeSort(this._sort)); + if (this._skip) docs = docs.slice(this._skip); + if (this._limit) docs = docs.slice(0, Math.abs(this._limit)); + let out: unknown[] = docs.map((d) => projectDoc(cloneValue(d), this._projection, (d as { __meta?: Doc }).__meta)); + for (const fn of this._maps) out = out.map(fn); + this.buffer = out as T[]; + return this.buffer; + } + + async toArray(): Promise { return [...this.materialize()]; } + async next(): Promise { return this.materialize().shift() ?? null; } + async tryNext(): Promise { return this.next(); } + async hasNext(): Promise { return this.materialize().length > 0; } + async forEach(fn: (d: T) => unknown): Promise { for (const d of this.materialize()) await fn(d); } + async count(): Promise { return this.materialize().length; } + async close(): Promise { this.buffer = []; } + rewind(): void { this.buffer = null; } + get closed(): boolean { return this.buffer !== null && this.buffer.length === 0; } + + async *[Symbol.asyncIterator](): AsyncGenerator { + for (const d of this.materialize()) yield d; + } +} + +// ── Collection ─────────────────────────────────────────────────────────────── + +export class SqliteCollection { + readonly docs = new Map(); + readonly indexSpecs = new Map([["_id_", { name: "_id_", key: { _id: 1 } }]]); + + constructor( + readonly collectionName: string, + private readonly db: SqliteDb, + ) {} + + get namespace(): string { return `${this.db.databaseName}.${this.collectionName}`; } + get dbName(): string { return this.db.databaseName; } + + // internal --------------------------------------------------------------- + + private matching(filter: Filter | undefined): Doc[] { + const f = filter ?? {}; + // Fast path for _id lookups, which is what save()/findById emit. + const keys = Object.keys(f); + if (keys.length === 1 && keys[0] === "_id" && !isPlainObject(f._id)) { + const d = this.docs.get(idKey(f._id)); + return d ? [d] : []; + } + const out: Doc[] = []; + for (const d of this.docs.values()) if (matchesFilter(d, f)) out.push(d); + return out; + } + + private first(filter: Filter | undefined, sort?: unknown): Doc | undefined { + const docs = this.matching(filter); + return sort ? sortDocs(docs, normalizeSort(sort))[0] : docs[0]; + } + + private checkUnique(doc: Doc, selfKey: string): void { + for (const idx of this.indexSpecs.values()) { + if (!idx.unique || idx.name === "_id_") continue; + const fields = Object.keys(idx.key); + if (idx.partialFilterExpression && !matchesFilter(doc, idx.partialFilterExpression)) continue; + const values = fields.map((f) => getPath(doc, f)); + if (idx.sparse && values.every((v) => v === undefined)) continue; + for (const [k, other] of this.docs) { + if (k === selfKey) continue; + if (idx.partialFilterExpression && !matchesFilter(other, idx.partialFilterExpression)) continue; + if (fields.every((f, i) => valuesEqual(getPath(other, f), values[i]))) { + throw duplicateKeyError(this.namespace, idx.key, doc); + } + } + } + } + + private put(doc: Doc, isNew: boolean): void { + const key = idKey(doc._id); + if (isNew && this.docs.has(key)) throw duplicateKeyError(this.namespace, { _id: 1 }, doc); + this.checkUnique(doc, key); + this.docs.set(key, doc); + this.db.persist(this.collectionName, key, doc); + } + + private remove(doc: Doc): void { + const key = idKey(doc._id); + this.docs.delete(key); + this.db.unpersist(this.collectionName, key); + } + + private prepareInsert(raw: Doc): Doc { + const doc = cloneValue(raw); + if (doc._id === undefined) doc._id = new ObjectId(); + raw._id ??= doc._id; // driver mutates the input like this too + return doc; + } + + /** Apply update to a copy; commit only if every constraint passes. */ + private updateDoc(existing: Doc, update: Doc | Doc[], isInsert: boolean): Doc { + if (Array.isArray(update)) throw new UnsupportedOperatorError("update pipeline"); + const next = cloneValue(existing); + if (isUpdateDocument(update)) { + applyUpdate(next, update, isInsert, cloneValue); + } else { + for (const k of Object.keys(next)) if (k !== "_id") delete next[k]; + Object.assign(next, cloneValue(update)); + } + if (!valuesEqual(next._id, existing._id) && !isInsert) { + throw new Error("Performing an update on the path '_id' would modify the immutable field '_id'"); + } + return next; + } + + private upsertSeed(filter: Filter | undefined): Doc { + const seed: Doc = {}; + for (const [path, value] of equalityFields(filter)) { + applyUpdate(seed, { $set: { [path]: value } }, true, cloneValue); + } + return seed; + } + + private doUpdate(filter: Filter, update: Doc | Doc[], opts: Doc, many: boolean) { + const targets = many ? this.matching(filter) : [this.first(filter, opts.sort)].filter(Boolean) as Doc[]; + if (targets.length === 0 && opts.upsert) { + const seed = this.upsertSeed(filter); + const doc = this.updateDoc(seed, update, true); + if (doc._id === undefined) doc._id = new ObjectId(); + this.db.transaction(() => this.put(doc, true)); + return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedCount: 1, upsertedId: doc._id, doc, before: null }; + } + let modified = 0; + let last: { doc: Doc; before: Doc } | null = null; + this.db.transaction(() => { + for (const t of targets) { + const next = this.updateDoc(t, update, false); + if (!valuesEqual(next, t)) { + this.put(next, false); + modified++; + } + last = { doc: next, before: t }; + } + }); + const l = last as { doc: Doc; before: Doc } | null; + return { + acknowledged: true, + matchedCount: targets.length, + modifiedCount: modified, + upsertedCount: 0, + upsertedId: null, + doc: l?.doc ?? null, + before: l?.before ?? null, + }; + } + + private modifyResult(value: Doc | null, opts: Doc, meta: Doc) { + const projected = value ? projectDoc(cloneValue(value), opts.projection as Doc | undefined) : null; + return opts.includeResultMetadata ? { value: projected, ok: 1, lastErrorObject: meta } : projected; + } + + // driver API ------------------------------------------------------------- + + find(filter?: Filter, opts: FindOptions = {}) { + return new SqliteCursor(() => this.matching(filter), opts); + } + + async findOne(filter?: Filter, opts: FindOptions = {}): Promise { + const docs = await this.find(filter, { ...opts, limit: 1 }).toArray(); + return docs[0] ?? null; + } + + async countDocuments(filter?: Filter, opts: FindOptions = {}): Promise { + let n = this.matching(filter).length - (opts.skip ?? 0); + if (opts.limit) n = Math.min(n, opts.limit); + return Math.max(0, n); + } + + async estimatedDocumentCount(): Promise { + return this.docs.size; + } + + async distinct(key: string, filter?: Filter): Promise { + const out: unknown[] = []; + for (const d of this.matching(filter)) { + const v = getPath(d, key); + for (const item of Array.isArray(v) ? v : [v]) { + if (item === undefined) continue; + if (!out.some((o) => valuesEqual(o, item))) out.push(cloneValue(item)); + } + } + return out; + } + + async insertOne(raw: Doc) { + const doc = this.prepareInsert(raw); + this.db.transaction(() => this.put(doc, true)); + return { acknowledged: true, insertedId: doc._id }; + } + + async insertMany(raws: Doc[], opts: Doc = {}) { + const insertedIds: Record = {}; + const ordered = opts.ordered !== false; + const errors: Error[] = []; + this.db.transaction(() => { + raws.forEach((raw, i) => { + try { + const doc = this.prepareInsert(raw); + this.put(doc, true); + insertedIds[i] = doc._id; + } catch (err) { + if (ordered) throw err; + errors.push(err as Error); + } + }); + }); + if (errors.length) throw errors[0]; + return { acknowledged: true, insertedCount: Object.keys(insertedIds).length, insertedIds }; + } + + async updateOne(filter: Filter, update: Doc, opts: Doc = {}) { + const { doc: _d, before: _b, ...res } = this.doUpdate(filter, update, opts, false); + return res; + } + + async updateMany(filter: Filter, update: Doc, opts: Doc = {}) { + const { doc: _d, before: _b, ...res } = this.doUpdate(filter, update, opts, true); + return res; + } + + async replaceOne(filter: Filter, replacement: Doc, opts: Doc = {}) { + if (isUpdateDocument(replacement)) throw new Error("Replacement document must not contain atomic operators"); + return this.updateOne(filter, replacement, opts); + } + + async findOneAndUpdate(filter: Filter, update: Doc, opts: Doc = {}) { + const res = this.doUpdate(filter, update, opts, false); + const after = opts.returnDocument === "after" || opts.returnOriginal === false; + const value = res.upsertedCount ? (after ? res.doc : null) : after ? res.doc : res.before; + return this.modifyResult(value, opts, { + n: res.matchedCount || res.upsertedCount, + updatedExisting: res.matchedCount > 0, + ...(res.upsertedCount ? { upserted: res.upsertedId } : {}), + }); + } + + async findOneAndReplace(filter: Filter, replacement: Doc, opts: Doc = {}) { + if (isUpdateDocument(replacement)) throw new Error("Replacement document must not contain atomic operators"); + return this.findOneAndUpdate(filter, replacement, opts); + } + + async findOneAndDelete(filter: Filter, opts: Doc = {}) { + const doc = this.first(filter, opts.sort) ?? null; + if (doc) this.db.transaction(() => this.remove(doc)); + return this.modifyResult(doc, opts, { n: doc ? 1 : 0 }); + } + + async deleteOne(filter?: Filter) { + const doc = this.first(filter); + if (doc) this.db.transaction(() => this.remove(doc)); + return { acknowledged: true, deletedCount: doc ? 1 : 0 }; + } + + async deleteMany(filter?: Filter) { + const docs = this.matching(filter); + this.db.transaction(() => docs.forEach((d) => this.remove(d))); + return { acknowledged: true, deletedCount: docs.length }; + } + + async bulkWrite(ops: Doc[]) { + const res = { insertedCount: 0, matchedCount: 0, modifiedCount: 0, deletedCount: 0, upsertedCount: 0, insertedIds: {} as Doc, upsertedIds: {} as Doc }; + for (const [i, op] of ops.entries()) { + const [kind, a] = Object.entries(op)[0] as [string, Doc]; + if (kind === "insertOne") { + const r = await this.insertOne(a.document as Doc); + res.insertedCount++; + res.insertedIds[i] = r.insertedId; + } else if (kind === "updateOne" || kind === "updateMany" || kind === "replaceOne") { + const update = (kind === "replaceOne" ? a.replacement : a.update) as Doc; + const r = this.doUpdate(a.filter as Filter, update, a, kind === "updateMany"); + res.matchedCount += r.matchedCount; + res.modifiedCount += r.modifiedCount; + if (r.upsertedCount) { + res.upsertedCount++; + res.upsertedIds[i] = r.upsertedId; + } + } else if (kind === "deleteOne") { + res.deletedCount += (await this.deleteOne(a.filter as Filter)).deletedCount; + } else if (kind === "deleteMany") { + res.deletedCount += (await this.deleteMany(a.filter as Filter)).deletedCount; + } else { + throw new UnsupportedOperatorError(`bulkWrite ${kind}`); + } + } + return { ok: 1, ...res, isOk: () => true, hasWriteErrors: () => false, getWriteErrors: () => [] }; + } + + aggregate(pipeline: Doc[]) { + return new SqliteCursor(() => this.runPipeline(pipeline)); + } + + private runPipeline(pipeline: Doc[]): Doc[] { + let docs: Doc[] = [...this.docs.values()]; + for (const stage of pipeline) { + const [op, arg] = Object.entries(stage)[0] as [string, Doc]; + switch (op) { + case "$match": + docs = docs.filter((d) => matchesFilter(d, arg)); + break; + case "$vectorSearch": + docs = vectorSearch(docs, arg); + break; + case "$sort": + docs = sortDocs(docs, normalizeSort(arg)); + break; + case "$skip": + docs = docs.slice(Number(arg)); + break; + case "$limit": + docs = docs.slice(0, Number(arg)); + break; + case "$project": + docs = docs.map((d) => projectDoc(cloneValue(d), arg, (d as { __meta?: Doc }).__meta)); + break; + case "$count": + docs = [{ [String(arg)]: docs.length }]; + break; + default: + throw new UnsupportedOperatorError(`aggregate stage ${op}`); + } + } + return docs; + } + + async createIndex(key: Doc | string, opts: Doc = {}): Promise { + const k: Doc = typeof key === "string" ? { [key]: 1 } : key; + const name = (opts.name as string) ?? Object.entries(k).map(([f, d]) => `${f}_${d}`).join("_"); + this.indexSpecs.set(name, { + name, + key: k, + unique: Boolean(opts.unique), + sparse: Boolean(opts.sparse), + ...(typeof opts.expireAfterSeconds === "number" ? { expireAfterSeconds: opts.expireAfterSeconds } : {}), + ...(opts.partialFilterExpression ? { partialFilterExpression: opts.partialFilterExpression as Filter } : {}), + }); + return name; + } + + async createIndexes(specs: Array): Promise { + const names: string[] = []; + for (const { key, ...opts } of specs) names.push(await this.createIndex(key, opts)); + return names; + } + + listIndexes() { + return new SqliteCursor(() => [...this.indexSpecs.values()].map((i) => ({ v: 2, ...i }) as Doc)); + } + + async indexes(): Promise { return this.listIndexes().toArray(); } + async indexExists(names: string | string[]): Promise { + return (Array.isArray(names) ? names : [names]).every((n) => this.indexSpecs.has(n)); + } + async indexInformation(): Promise { + return Object.fromEntries([...this.indexSpecs.values()].map((i) => [i.name, Object.entries(i.key)])); + } + async dropIndex(name: string): Promise { this.indexSpecs.delete(name); return { ok: 1 }; } + async dropIndexes(): Promise { + for (const n of [...this.indexSpecs.keys()]) if (n !== "_id_") this.indexSpecs.delete(n); + return true; + } + async drop(): Promise { await this.deleteMany({}); return true; } + + watch(): never { + throw new UnsupportedOperatorError("change streams"); + } + + /** Delete documents whose TTL index field has expired. */ + sweepExpired(now = Date.now()): number { + let removed = 0; + for (const idx of this.indexSpecs.values()) { + if (idx.expireAfterSeconds === undefined) continue; + const field = Object.keys(idx.key)[0]; + const expired = [...this.docs.values()].filter((d) => { + const v = getPath(d, field); + return v instanceof Date && v.getTime() + idx.expireAfterSeconds! * 1000 <= now; + }); + if (expired.length) { + this.db.transaction(() => expired.forEach((d) => this.remove(d))); + removed += expired.length; + } + } + return removed; + } +} + +/** In-process stand-in for Atlas $vectorSearch: exact cosine over the candidate set. */ +function vectorSearch(docs: Doc[], spec: Doc): Doc[] { + const path = String(spec.path); + const q = spec.queryVector as number[]; + const limit = Number(spec.limit ?? 10); + const qNorm = Math.hypot(...q) || 1; + const filtered = spec.filter ? docs.filter((d) => matchesFilter(d, spec.filter as Filter)) : docs; + + const scored: Array<{ d: Doc; score: number }> = []; + for (const d of filtered) { + const v = getPath(d, path); + if (!Array.isArray(v) || v.length !== q.length) continue; + let dot = 0; + let norm = 0; + for (let i = 0; i < q.length; i++) { + dot += q[i] * (v[i] as number); + norm += (v[i] as number) ** 2; + } + if (!norm) continue; + const cosine = dot / (qNorm * Math.sqrt(norm)); + // Atlas reports cosine similarity normalised to [0, 1]. + scored.push({ d, score: (1 + cosine) / 2 }); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, limit).map(({ d, score }) => Object.assign(Object.create(null), d, { __meta: { vectorSearchScore: score } }) as Doc); +} + +// ── Session / client / db ──────────────────────────────────────────────────── + +class SqliteSession { + readonly id = { id: new ObjectId() }; + hasEnded = false; + transaction = { state: "NO_TRANSACTION", isActive: false, isCommitted: false }; + inTransaction(): boolean { return false; } + startTransaction(): void {} + async commitTransaction(): Promise {} + async abortTransaction(): Promise {} + async endSession(): Promise { this.hasEnded = true; } + /** + * Single-writer local store: run the callback directly. Each driver call is + * already atomic; cross-call rollback is not provided. + */ + async withTransaction(fn: (s: SqliteSession) => Promise): Promise { + return fn(this); + } + [Symbol.asyncDispose] = () => this.endSession(); +} + +export class SqliteDb { + readonly databaseName = "capscreen"; + private readonly sql: DatabaseSync; + private readonly collections = new Map(); + private readonly upsertStmt: StatementSync; + private readonly deleteStmt: StatementSync; + private txDepth = 0; + private ttlTimer: NodeJS.Timeout | null = null; + + constructor(readonly file: string) { + this.sql = new DatabaseSync(file); + this.sql.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS documents ( + collection TEXT NOT NULL, + id TEXT NOT NULL, + body TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (collection, id) + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '1'); + `); + this.upsertStmt = this.sql.prepare( + `INSERT INTO documents (collection, id, body, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT (collection, id) DO UPDATE SET body = excluded.body, updated_at = excluded.updated_at`, + ); + this.deleteStmt = this.sql.prepare(`DELETE FROM documents WHERE collection = ? AND id = ?`); + this.load(); + } + + private load(): void { + const rows = this.sql.prepare(`SELECT collection, id, body FROM documents`).all() as Array<{ + collection: string; + id: string; + body: string; + }>; + for (const row of rows) { + this.collection(row.collection).docs.set(row.id, EJSON.parse(row.body, { relaxed: true }) as Doc); + } + } + + persist(collection: string, id: string, doc: Doc): void { + this.upsertStmt.run(collection, id, EJSON.stringify(doc, { relaxed: true }), Date.now()); + } + + unpersist(collection: string, id: string): void { + this.deleteStmt.run(collection, id); + } + + /** Synchronous SQLite transaction; nested calls join the outer one. */ + transaction(fn: () => void): void { + if (this.txDepth > 0) { + fn(); + return; + } + this.txDepth++; + this.sql.exec("BEGIN"); + try { + fn(); + this.sql.exec("COMMIT"); + } catch (err) { + this.sql.exec("ROLLBACK"); + // Memory may have partially applied; reload this process's view from disk. + for (const c of this.collections.values()) c.docs.clear(); + this.load(); + throw err; + } finally { + this.txDepth--; + } + } + + collection(name: string): SqliteCollection { + let c = this.collections.get(name); + if (!c) { + c = new SqliteCollection(name, this); + this.collections.set(name, c); + } + return c; + } + + async createCollection(name: string): Promise { + return this.collection(name); + } + + listCollections() { + return new SqliteCursor(() => [...this.collections.keys()].map((name) => ({ name, type: "collection" }))); + } + + async dropCollection(name: string): Promise { + await this.collection(name).drop(); + this.collections.delete(name); + return true; + } + + async dropDatabase(): Promise { + for (const name of [...this.collections.keys()]) await this.dropCollection(name); + return true; + } + + async command(cmd: Doc): Promise { + if ("ping" in cmd) return { ok: 1 }; + if ("buildInfo" in cmd || "buildinfo" in cmd) return { ok: 1, version: "7.0.0", versionArray: [7, 0, 0, 0] }; + if ("hello" in cmd || "isMaster" in cmd) return { ok: 1, isWritablePrimary: true, maxWireVersion: 21 }; + throw new UnsupportedOperatorError(`command ${Object.keys(cmd)[0]}`); + } + + admin() { + return { command: (cmd: Doc) => this.command(cmd), ping: () => this.command({ ping: 1 }) }; + } + + startTtlSweeper(intervalMs = 60_000): void { + const sweep = () => { + for (const c of this.collections.values()) c.sweepExpired(); + }; + sweep(); + this.ttlTimer = setInterval(sweep, intervalMs); + this.ttlTimer.unref(); + } + + /** Consistent single-file copy (safe while the app is running). */ + backupTo(target: string): void { + this.sql.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`); + } + + close(): void { + if (this.ttlTimer) clearInterval(this.ttlTimer); + this.sql.close(); + } +} + +/** Minimal MongoClient stand-in: mongoose only needs db(), startSession() and close(). */ +export class SqliteClient { + constructor(readonly sqliteDb: SqliteDb) {} + db(): SqliteDb { return this.sqliteDb; } + startSession(): SqliteSession { return new SqliteSession(); } + async close(): Promise { this.sqliteDb.close(); } + on(): this { return this; } + once(): this { return this; } + removeListener(): this { return this; } +} diff --git a/backend/src/lib/infra/sqliteStore/update.ts b/backend/src/lib/infra/sqliteStore/update.ts new file mode 100644 index 0000000..0abf7ec --- /dev/null +++ b/backend/src/lib/infra/sqliteStore/update.ts @@ -0,0 +1,201 @@ +/** + * MongoDB update operators applied in place to a plain JS document. + */ +import { + UnsupportedOperatorError, + compareValues, + isPlainObject, + matchesFilter, + valuesEqual, + type Doc, +} from "./query"; + +function parentOf(doc: Doc, path: string, create: boolean): { parent: Doc | unknown[]; key: string } | null { + const parts = path.split("."); + let cur: unknown = doc; + for (let i = 0; i < parts.length - 1; i++) { + const p = parts[i]; + if (p === "$" || p.startsWith("$[")) throw new UnsupportedOperatorError(`positional path ${path}`); + if (Array.isArray(cur)) { + const idx = Number(p); + if (!Number.isInteger(idx)) return null; + if (cur[idx] === undefined || cur[idx] === null) { + if (!create) return null; + cur[idx] = {}; + } + cur = cur[idx]; + } else if (isPlainObject(cur)) { + if (cur[p] === undefined || cur[p] === null) { + if (!create) return null; + cur[p] = {}; + } + cur = cur[p]; + } else { + return null; + } + } + if (!Array.isArray(cur) && !isPlainObject(cur)) return null; + return { parent: cur as Doc | unknown[], key: parts[parts.length - 1] }; +} + +function getAt(doc: Doc, path: string): unknown { + const loc = parentOf(doc, path, false); + if (!loc) return undefined; + return Array.isArray(loc.parent) ? loc.parent[Number(loc.key)] : loc.parent[loc.key]; +} + +function setAt(doc: Doc, path: string, value: unknown): void { + const loc = parentOf(doc, path, true); + if (!loc) throw new Error(`Cannot set path "${path}"`); + if (Array.isArray(loc.parent)) loc.parent[Number(loc.key)] = value; + else loc.parent[loc.key] = value; +} + +function unsetAt(doc: Doc, path: string): void { + const loc = parentOf(doc, path, false); + if (!loc) return; + if (Array.isArray(loc.parent)) loc.parent[Number(loc.key)] = null; + else delete loc.parent[loc.key]; +} + +function arrayAt(doc: Doc, path: string, op: string): unknown[] { + const cur = getAt(doc, path); + if (cur === undefined || cur === null) { + const arr: unknown[] = []; + setAt(doc, path, arr); + return arr; + } + if (!Array.isArray(cur)) throw new Error(`${op} on non-array field "${path}"`); + return cur; +} + +function matchesPullCondition(el: unknown, cond: unknown): boolean { + if (isPlainObject(cond)) { + const keys = Object.keys(cond); + if (keys.length && keys.every((k) => k.startsWith("$"))) { + return matchesFilter({ v: el }, { v: cond }); + } + return isPlainObject(el) ? matchesFilter(el, cond) : false; + } + return valuesEqual(el, cond); +} + +export function isUpdateDocument(update: Doc): boolean { + return Object.keys(update).some((k) => k.startsWith("$")); +} + +/** + * Apply `update` to `doc` in place. `isInsert` enables $setOnInsert. + * `clone` copies incoming values so stored documents never alias caller objects. + */ +export function applyUpdate(doc: Doc, update: Doc, isInsert: boolean, clone: (v: T) => T): void { + for (const [op, fields] of Object.entries(update)) { + const entries = Object.entries((fields ?? {}) as Doc); + switch (op) { + case "$set": + for (const [p, v] of entries) setAt(doc, p, clone(v)); + break; + case "$setOnInsert": + if (isInsert) for (const [p, v] of entries) setAt(doc, p, clone(v)); + break; + case "$unset": + for (const [p] of entries) unsetAt(doc, p); + break; + case "$inc": + for (const [p, v] of entries) setAt(doc, p, (Number(getAt(doc, p)) || 0) + Number(v)); + break; + case "$mul": + for (const [p, v] of entries) setAt(doc, p, (Number(getAt(doc, p)) || 0) * Number(v)); + break; + case "$min": + for (const [p, v] of entries) { + const cur = getAt(doc, p); + if (cur === undefined || compareValues(v, cur) < 0) setAt(doc, p, clone(v)); + } + break; + case "$max": + for (const [p, v] of entries) { + const cur = getAt(doc, p); + if (cur === undefined || compareValues(v, cur) > 0) setAt(doc, p, clone(v)); + } + break; + case "$currentDate": + for (const [p] of entries) setAt(doc, p, new Date()); + break; + case "$rename": + for (const [p, to] of entries) { + const v = getAt(doc, p); + if (v === undefined) continue; + unsetAt(doc, p); + setAt(doc, String(to), v); + } + break; + case "$push": + for (const [p, v] of entries) { + const arr = arrayAt(doc, p, op); + const spec = isPlainObject(v) && "$each" in v ? v : null; + const items = (spec ? (spec.$each as unknown[]) : [v]).map((x) => clone(x)); + if (spec && typeof spec.$position === "number") arr.splice(spec.$position, 0, ...items); + else arr.push(...items); + if (spec && spec.$sort !== undefined) { + const s = spec.$sort; + if (typeof s === "number") arr.sort((a, b) => compareValues(a, b) * s); + else { + const keys = Object.entries(s as Doc); + arr.sort((a, b) => { + for (const [k, d] of keys) { + const c = compareValues((a as Doc)?.[k], (b as Doc)?.[k]); + if (c) return c * Number(d); + } + return 0; + }); + } + } + if (spec && typeof spec.$slice === "number") { + const n = spec.$slice; + const kept = n >= 0 ? arr.slice(0, n) : arr.slice(n); + arr.length = 0; + arr.push(...kept); + } + } + break; + case "$addToSet": + for (const [p, v] of entries) { + const arr = arrayAt(doc, p, op); + const items = isPlainObject(v) && "$each" in v ? (v.$each as unknown[]) : [v]; + for (const item of items) { + if (!arr.some((x) => valuesEqual(x, item))) arr.push(clone(item)); + } + } + break; + case "$pull": + for (const [p, cond] of entries) { + const cur = getAt(doc, p); + if (!Array.isArray(cur)) continue; + const kept = cur.filter((el) => !matchesPullCondition(el, cond)); + cur.length = 0; + cur.push(...kept); + } + break; + case "$pullAll": + for (const [p, values] of entries) { + const cur = getAt(doc, p); + if (!Array.isArray(cur)) continue; + const kept = cur.filter((el) => !(values as unknown[]).some((v) => valuesEqual(el, v))); + cur.length = 0; + cur.push(...kept); + } + break; + case "$pop": + for (const [p, dir] of entries) { + const cur = getAt(doc, p); + if (!Array.isArray(cur)) continue; + if (Number(dir) < 0) cur.shift(); + else cur.pop(); + } + break; + default: + throw new UnsupportedOperatorError(op); + } + } +} diff --git a/backend/src/lib/llm/agent.ts b/backend/src/lib/llm/agent.ts deleted file mode 100644 index 8286916..0000000 --- a/backend/src/lib/llm/agent.ts +++ /dev/null @@ -1,83 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk"; -import type { ToolActivity } from "../chat/chatTools"; - -function client(): Anthropic { - const key = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; - if (!key) throw new Error("ANTHROPIC_API_KEY is not set"); - return new Anthropic({ apiKey: key }); -} - -function truncate(s: string, max = 120): string { - const one = s.replace(/\s+/g, " ").trim(); - return one.length <= max ? one : `${one.slice(0, max)}…`; -} - -export async function runAgentWithTools(params: { - model: string; - systemPrompt: string; - messages: Anthropic.MessageParam[]; - tools: Anthropic.Tool[]; - executeTool: ( - name: string, - input: unknown, - ) => Promise<{ content: string }>; - maxTokens?: number; - maxRounds?: number; -}): Promise<{ text: string; toolActivity: ToolActivity[] }> { - const anthropic = client(); - const toolActivity: ToolActivity[] = []; - const messages = [...params.messages]; - const maxRounds = params.maxRounds ?? 8; - - for (let round = 0; round < maxRounds; round++) { - const resp = await anthropic.messages.create({ - model: params.model, - max_tokens: params.maxTokens ?? 2048, - system: params.systemPrompt, - tools: params.tools, - messages, - }); - - const textBlocks = resp.content.filter( - (b): b is Anthropic.TextBlock => b.type === "text", - ); - const text = textBlocks.map((b) => b.text).join(""); - - if (resp.stop_reason === "end_turn" || resp.stop_reason === "max_tokens") { - return { text, toolActivity }; - } - - if (resp.stop_reason !== "tool_use") { - return { text, toolActivity }; - } - - messages.push({ role: "assistant", content: resp.content }); - - const toolResults: Anthropic.ToolResultBlockParam[] = []; - for (const block of resp.content) { - if (block.type !== "tool_use") continue; - - toolActivity.push({ name: block.name, status: "running" }); - const { content } = await params.executeTool(block.name, block.input); - const idx = toolActivity.length - 1; - toolActivity[idx] = { - name: block.name, - status: "done", - summary: truncate(content), - }; - - toolResults.push({ - type: "tool_result", - tool_use_id: block.id, - content, - }); - } - - messages.push({ role: "user", content: toolResults }); - } - - return { - text: "I hit the tool-call limit for this turn. Please try a simpler question.", - toolActivity, - }; -} diff --git a/backend/src/lib/llm/allowedModels.ts b/backend/src/lib/llm/allowedModels.ts deleted file mode 100644 index 2966a36..0000000 --- a/backend/src/lib/llm/allowedModels.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const ALLOWED_TABULAR_MODELS = new Set([ - "gemini-3.1-pro-preview", - "gemini-3-flash-preview", - "gpt-5.5", - "gpt-5.4-mini", -]); - -export function isAllowedTabularModel(model: string): boolean { - return ALLOWED_TABULAR_MODELS.has(model.trim()); -} diff --git a/backend/src/lib/llm/claude.ts b/backend/src/lib/llm/claude.ts deleted file mode 100644 index 2990003..0000000 --- a/backend/src/lib/llm/claude.ts +++ /dev/null @@ -1,44 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk"; - -export type ChatMessage = { role: "user" | "assistant"; content: string }; - -function client(): Anthropic { - const key = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; - if (!key) throw new Error("ANTHROPIC_API_KEY is not set"); - return new Anthropic({ apiKey: key }); -} - -// Single-shot completion — used for the explanation narrator in explain.ts -export async function completeClaudeText(params: { - model: string; - systemPrompt?: string; - user: string; - maxTokens?: number; -}): Promise { - return chatWithClaude({ - model: params.model, - systemPrompt: params.systemPrompt, - messages: [{ role: "user", content: params.user }], - maxTokens: params.maxTokens, - }); -} - -// Multi-turn chat — caller owns the full message history and sends it each time -export async function chatWithClaude(params: { - model: string; - systemPrompt?: string; - messages: ChatMessage[]; - maxTokens?: number; -}): Promise { - const anthropic = client(); - const resp = await anthropic.messages.create({ - model: params.model, - max_tokens: params.maxTokens ?? 1024, - system: params.systemPrompt, - messages: params.messages as Anthropic.MessageParam[], - }); - return resp.content - .filter((b): b is Anthropic.TextBlock => b.type === "text") - .map((b) => b.text) - .join(""); -} diff --git a/backend/src/lib/llm/complete.ts b/backend/src/lib/llm/complete.ts new file mode 100644 index 0000000..56553df --- /dev/null +++ b/backend/src/lib/llm/complete.ts @@ -0,0 +1,35 @@ +import { getLlm, responseText, type LlmRole } from "./providers"; + +export type ChatMessage = { role: "user" | "assistant"; content: string }; + +// Single-shot completion — narratives, CSV schema inference, entity extraction +export async function completeText(params: { + systemPrompt?: string; + user: string; + maxTokens?: number; + role?: LlmRole; +}): Promise { + return chatText({ + systemPrompt: params.systemPrompt, + messages: [{ role: "user", content: params.user }], + maxTokens: params.maxTokens, + role: params.role, + }); +} + +// Multi-turn chat — caller owns the full message history and sends it each time +export async function chatText(params: { + systemPrompt?: string; + messages: ChatMessage[]; + maxTokens?: number; + role?: LlmRole; +}): Promise { + const llm = getLlm(params.role ?? "utility"); + const resp = await llm.provider.complete({ + model: llm.model, + systemPrompt: params.systemPrompt, + messages: params.messages, + maxTokens: params.maxTokens ?? 1024, + }); + return responseText(resp); +} diff --git a/backend/src/lib/llm/controlCenter/localRuntime.ts b/backend/src/lib/llm/controlCenter/localRuntime.ts new file mode 100644 index 0000000..2b98fb6 --- /dev/null +++ b/backend/src/lib/llm/controlCenter/localRuntime.ts @@ -0,0 +1,165 @@ +import os from "os"; + +/** + * On-device model runtimes (Ollama, LM Studio) — detection, hardware fit, and + * model management. Everything here talks to 127.0.0.1 only. + */ + +export const OLLAMA_URL = (process.env.OLLAMA_HOST?.trim() || "http://127.0.0.1:11434").replace(/\/$/, ""); +const LMSTUDIO_URL = "http://127.0.0.1:1234"; + +export type Hardware = { + platform: NodeJS.Platform; + arch: string; + cpu: string; + cores: number; + totalMemGB: number; + freeMemGB: number; + /** Apple Silicon shares RAM with the GPU, so most of it is usable for models. */ + unifiedMemory: boolean; +}; + +export function detectHardware(): Hardware { + const cpus = os.cpus(); + const gb = (n: number) => Math.round((n / 1024 ** 3) * 10) / 10; + return { + platform: process.platform, + arch: process.arch, + cpu: cpus[0]?.model ?? "unknown", + cores: cpus.length, + totalMemGB: gb(os.totalmem()), + freeMemGB: gb(os.freemem()), + unifiedMemory: process.platform === "darwin" && process.arch === "arm64", + }; +} + +export type CatalogModel = { + id: string; + label: string; + family: string; + sizeGB: number; + /** RAM needed to run alongside the app comfortably. */ + minRamGB: number; + tools: boolean; + kind: "chat" | "embedding"; + note: string; +}; + +/** Curated Ollama models known to work with CapScreen's tool-calling agent. */ +export const MODEL_CATALOG: CatalogModel[] = [ + { id: "qwen3:4b", label: "Qwen3 4B", family: "Qwen", sizeGB: 2.5, minRamGB: 8, tools: true, kind: "chat", note: "Best fit for 8 GB machines. Solid tool calling." }, + { id: "qwen3:8b", label: "Qwen3 8B", family: "Qwen", sizeGB: 5.2, minRamGB: 12, tools: true, kind: "chat", note: "Stronger reasoning; runs on 8 GB but slowly." }, + { id: "llama3.1:8b", label: "Llama 3.1 8B", family: "Llama", sizeGB: 4.9, minRamGB: 12, tools: true, kind: "chat", note: "Reliable general model with tool support." }, + { id: "qwen3:14b", label: "Qwen3 14B", family: "Qwen", sizeGB: 9.3, minRamGB: 20, tools: true, kind: "chat", note: "Noticeably better analysis; needs 24 GB+." }, + { id: "gpt-oss:20b", label: "gpt-oss 20B", family: "OpenAI", sizeGB: 14, minRamGB: 24, tools: true, kind: "chat", note: "Open-weight reasoning model; 24 GB+." }, + { id: "qwen3:30b", label: "Qwen3 30B (MoE)", family: "Qwen", sizeGB: 19, minRamGB: 32, tools: true, kind: "chat", note: "Fast mixture-of-experts; 32 GB+." }, + { id: "gemma3:4b", label: "Gemma 3 4B", family: "Gemma", sizeGB: 3.3, minRamGB: 8, tools: false, kind: "chat", note: "No tool calling — use only for narratives/utility." }, + { id: "nomic-embed-text", label: "Nomic Embed", family: "Nomic", sizeGB: 0.3, minRamGB: 4, tools: false, kind: "embedding", note: "Document search embeddings (768-dim)." }, + { id: "mxbai-embed-large", label: "mxbai Embed Large", family: "Mixedbread", sizeGB: 0.7, minRamGB: 6, tools: false, kind: "embedding", note: "Higher-quality embeddings (1024-dim)." }, +]; + +export type Fit = "great" | "ok" | "tight" | "too-large"; + +export function modelFit(model: CatalogModel, hw: Hardware): Fit { + // Discrete-GPU PCs keep weights in RAM too; be a little more conservative. + const usable = hw.unifiedMemory ? hw.totalMemGB : hw.totalMemGB * 0.9; + if (usable >= model.minRamGB * 1.5) return "great"; + if (usable >= model.minRamGB) return "ok"; + if (usable >= model.sizeGB + 2.5) return "tight"; + return "too-large"; +} + +export function recommendedChatModel(hw: Hardware): string { + const fits = MODEL_CATALOG.filter((m) => m.kind === "chat" && m.tools && ["great", "ok"].includes(modelFit(m, hw))); + return fits.sort((a, b) => b.sizeGB - a.sizeGB)[0]?.id ?? "qwen3:4b"; +} + +async function getJson(url: string, timeoutMs = 2500): Promise { + try { + const resp = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!resp.ok) return null; + return (await resp.json()) as T; + } catch { + return null; + } +} + +export type InstalledModel = { name: string; sizeGB: number; family: string | null; parameterSize: string | null; modifiedAt: string }; + +export async function ollamaStatus(): Promise<{ running: boolean; version: string | null; models: InstalledModel[] }> { + const version = await getJson<{ version: string }>(`${OLLAMA_URL}/api/version`); + if (!version) return { running: false, version: null, models: [] }; + const tags = await getJson<{ + models: Array<{ name: string; size: number; modified_at: string; details?: { family?: string; parameter_size?: string } }>; + }>(`${OLLAMA_URL}/api/tags`); + return { + running: true, + version: version.version, + models: (tags?.models ?? []).map((m) => ({ + name: m.name, + sizeGB: Math.round((m.size / 1024 ** 3) * 10) / 10, + family: m.details?.family ?? null, + parameterSize: m.details?.parameter_size ?? null, + modifiedAt: m.modified_at, + })), + }; +} + +export async function lmStudioStatus(): Promise<{ running: boolean; models: string[] }> { + const models = await getJson<{ data: Array<{ id: string }> }>(`${LMSTUDIO_URL}/v1/models`, 1500); + return { running: !!models, models: models?.data.map((m) => m.id) ?? [] }; +} + +/** Ollama model capabilities ("tools", "embedding", "completion", …) where the server reports them. */ +export async function ollamaCapabilities(model: string): Promise { + try { + const resp = await fetch(`${OLLAMA_URL}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + signal: AbortSignal.timeout(5000), + }); + if (!resp.ok) return null; + const body = (await resp.json()) as { capabilities?: string[] }; + return body.capabilities ?? null; + } catch { + return null; + } +} + +export type PullProgress = { status: string; completed?: number; total?: number; error?: string }; + +/** Stream `ollama pull` progress. Resolves when the pull ends or fails. */ +export async function pullOllamaModel(model: string, onProgress: (p: PullProgress) => void, signal?: AbortSignal): Promise { + const resp = await fetch(`${OLLAMA_URL}/api/pull`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model, stream: true }), + signal, + }); + if (!resp.ok || !resp.body) throw new Error(`Ollama pull failed (${resp.status})`); + + const decoder = new TextDecoder(); + let buffered = ""; + for await (const chunk of resp.body as unknown as AsyncIterable) { + buffered += decoder.decode(chunk, { stream: true }); + let nl: number; + while ((nl = buffered.indexOf("\n")) >= 0) { + const line = buffered.slice(0, nl).trim(); + buffered = buffered.slice(nl + 1); + if (!line) continue; + const p = JSON.parse(line) as PullProgress; + if (p.error) throw new Error(p.error); + onProgress(p); + } + } +} + +export async function deleteOllamaModel(model: string): Promise { + const resp = await fetch(`${OLLAMA_URL}/api/delete`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + }); + if (!resp.ok) throw new Error(`Ollama delete failed (${resp.status})`); +} diff --git a/backend/src/lib/llm/controlCenter/store.ts b/backend/src/lib/llm/controlCenter/store.ts new file mode 100644 index 0000000..79e7afc --- /dev/null +++ b/backend/src/lib/llm/controlCenter/store.ts @@ -0,0 +1,153 @@ +import crypto from "crypto"; +import fs from "fs"; +import { dataFile } from "../../infra/dataDir"; +import type { LlmRole, ProviderConfig, RoleBinding } from "../providers/registry"; + +/** + * AI Control Center persistence. + * + * /ai/config.json providers, role bindings, privacy policy (no secrets) + * /ai/secrets.json API keys, AES-256-GCM encrypted with the master key + * + * The master key comes from the Electron shell (CAPSCREEN_MASTER_KEY, itself + * sealed by the OS keychain via safeStorage). Under `pnpm dev` a key file is + * generated next to the data instead. + */ + +export type StoredProvider = Omit; + +export type AiConfig = { + version: 1; + providers: StoredProvider[]; + roles: Partial>; + /** When true, only on-device providers may be used. */ + localOnly: boolean; + updatedAt: string; +}; + +type SealedSecret = { iv: string; tag: string; data: string; hint: string; updatedAt: string }; +type SecretsFile = Record; + +const configPath = () => dataFile("ai", "config.json"); +const secretsPath = () => dataFile("ai", "secrets.json"); + +let masterKey: Buffer | null = null; + +function getMasterKey(): Buffer { + if (masterKey) return masterKey; + const fromEnv = process.env.CAPSCREEN_MASTER_KEY?.trim(); + if (fromEnv) { + const key = Buffer.from(fromEnv, "base64"); + if (key.length !== 32) throw new Error("CAPSCREEN_MASTER_KEY must be 32 bytes (base64)"); + masterKey = key; + return key; + } + const devKeyFile = dataFile("ai", "dev-master.key"); + if (!fs.existsSync(devKeyFile)) { + fs.writeFileSync(devKeyFile, crypto.randomBytes(32).toString("base64"), { mode: 0o600 }); + console.warn(`[ai] No CAPSCREEN_MASTER_KEY — generated dev key at ${devKeyFile} (the desktop app uses the OS keychain)`); + } + masterKey = Buffer.from(fs.readFileSync(devKeyFile, "utf8").trim(), "base64"); + return masterKey; +} + +function writeJsonAtomic(file: string, value: unknown): void { + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, file); +} + +function readJson(file: string): T | null { + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as T; + } catch { + return null; + } +} + +// ── config ─────────────────────────────────────────────────────────────────── + +let configCache: { mtimeMs: number; value: AiConfig } | null = null; + +export function readAiConfig(): AiConfig | null { + const file = configPath(); + let mtimeMs: number; + try { + mtimeMs = fs.statSync(file).mtimeMs; + } catch { + return null; + } + if (configCache?.mtimeMs === mtimeMs) return configCache.value; + const value = readJson(file); + if (!value) return null; + configCache = { mtimeMs, value }; + return value; +} + +export function writeAiConfig(next: Omit): AiConfig { + const value: AiConfig = { version: 1, ...next, updatedAt: new Date().toISOString() }; + writeJsonAtomic(configPath(), value); + configCache = null; + return value; +} + +export function updateAiConfig(fn: (cfg: Omit) => void): AiConfig { + const cur = readAiConfig() ?? { version: 1 as const, providers: [], roles: {}, localOnly: false, updatedAt: "" }; + const draft = structuredClone({ providers: cur.providers, roles: cur.roles, localOnly: cur.localOnly }); + fn(draft); + return writeAiConfig(draft); +} + +// ── secrets ────────────────────────────────────────────────────────────────── + +let secretsCache: SecretsFile | null = null; + +function readSecrets(): SecretsFile { + secretsCache ??= readJson(secretsPath()) ?? {}; + return secretsCache; +} + +export function setProviderKey(providerId: string, apiKey: string): void { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", getMasterKey(), iv); + cipher.setAAD(Buffer.from(providerId)); + const data = Buffer.concat([cipher.update(apiKey, "utf8"), cipher.final()]); + const secrets = { ...readSecrets() }; + secrets[providerId] = { + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + data: data.toString("base64"), + hint: apiKey.length > 8 ? `…${apiKey.slice(-4)}` : "set", + updatedAt: new Date().toISOString(), + }; + writeJsonAtomic(secretsPath(), secrets); + secretsCache = secrets; + configCache = null; // force registry rebuild with the new key +} + +export function deleteProviderKey(providerId: string): void { + const secrets = { ...readSecrets() }; + delete secrets[providerId]; + writeJsonAtomic(secretsPath(), secrets); + secretsCache = secrets; + configCache = null; +} + +export function getProviderKey(providerId: string): string | null { + const sealed = readSecrets()[providerId]; + if (!sealed) return null; + try { + const decipher = crypto.createDecipheriv("aes-256-gcm", getMasterKey(), Buffer.from(sealed.iv, "base64")); + decipher.setAAD(Buffer.from(providerId)); + decipher.setAuthTag(Buffer.from(sealed.tag, "base64")); + return Buffer.concat([decipher.update(Buffer.from(sealed.data, "base64")), decipher.final()]).toString("utf8"); + } catch { + console.error(`[ai] Could not decrypt key for "${providerId}" — re-enter it in the AI Control Center`); + return null; + } +} + +export function providerKeyInfo(providerId: string): { configured: boolean; hint: string | null; updatedAt: string | null } { + const sealed = readSecrets()[providerId]; + return { configured: !!sealed, hint: sealed?.hint ?? null, updatedAt: sealed?.updatedAt ?? null }; +} diff --git a/backend/src/lib/llm/friendlyError.ts b/backend/src/lib/llm/friendlyError.ts index f9f47eb..5f31479 100644 --- a/backend/src/lib/llm/friendlyError.ts +++ b/backend/src/lib/llm/friendlyError.ts @@ -14,7 +14,7 @@ export function friendlyLlmError(err: unknown): string { msg.includes("invalid x-api-key") || msg.includes("401") ) { - return "Anthropic API key is invalid. Set ANTHROPIC_API_KEY in root `.env` (or `.env.production` on EC2) and restart the backend."; + return "The model provider rejected the API key. Update the key in model settings (or `.env`) and restart the backend."; } if (msg.includes("rate_limit") || msg.includes("429")) { @@ -25,13 +25,18 @@ export function friendlyLlmError(err: unknown): string { return "AI service is temporarily overloaded. Try again shortly."; } - if (msg.includes("ANTHROPIC_API_KEY is not set")) { - return "Anthropic API key is not configured on the server."; + if (msg.includes("API key for provider")) { + return "No API key is configured for the selected model provider."; } - if (msg.includes("ECONNREFUSED") && msg.includes("8084")) { - return "Watchman sanctions service is not running. Local dev: docker compose -f docker-compose.dev.yml up"; + if (msg.includes("does not support tools")) { + return "The selected local model does not support tool calling. Pick a tool-capable model or disable tools for it in model settings."; } + if (msg.includes("ECONNREFUSED") && /:(11434|1234|8080)\b/.test(msg)) { + return "The local model server is not running. Start Ollama / LM Studio and try again."; + } + + return msg; } diff --git a/backend/src/lib/llm/index.ts b/backend/src/lib/llm/index.ts index 4cd6441..9e7ddc9 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -1,2 +1,2 @@ -export { completeClaudeText, chatWithClaude, type ChatMessage } from "./claude"; -export { getAnthropicModel } from "./models"; +export { completeText, chatText, type ChatMessage } from "./complete"; +export { getLlm, describeLlmConfig, type LlmRole } from "./providers"; diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts deleted file mode 100644 index 19402a0..0000000 --- a/backend/src/lib/llm/models.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Anthropic model — Haiku only (dev + production). - * Do not switch to Sonnet/Opus here; cost is prohibitive at screening volume. - * Model id is server-side only; never expose selection to the frontend. - */ - -export const ANTHROPIC_MODEL_ID = "claude-haiku-4-5"; - -export function getAnthropicModel(): string { - const override = process.env.ANTHROPIC_MODEL?.trim(); - if (override && override !== ANTHROPIC_MODEL_ID) { - console.warn( - `[llm] Ignoring ANTHROPIC_MODEL=${override} — this app uses Haiku only (${ANTHROPIC_MODEL_ID})`, - ); - } - return ANTHROPIC_MODEL_ID; -} diff --git a/backend/src/lib/llm/providers/anthropic.ts b/backend/src/lib/llm/providers/anthropic.ts new file mode 100644 index 0000000..045a442 --- /dev/null +++ b/backend/src/lib/llm/providers/anthropic.ts @@ -0,0 +1,92 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { + LlmProvider, + LlmRequest, + LlmResponse, + LlmStopReason, + LlmStreamEvent, +} from "./types"; + +function toStopReason(reason: string | null): LlmStopReason { + if (reason === "end_turn" || reason === "tool_use" || reason === "max_tokens") { + return reason; + } + return "other"; +} + +function toResponse(msg: Anthropic.Message): LlmResponse { + const content: LlmResponse["content"] = []; + for (const b of msg.content) { + if (b.type === "text") content.push({ type: "text", text: b.text }); + else if (b.type === "tool_use") { + content.push({ type: "tool_use", id: b.id, name: b.name, input: b.input }); + } + } + return { content, stopReason: toStopReason(msg.stop_reason) }; +} + +export class AnthropicProvider implements LlmProvider { + readonly kind = "anthropic" as const; + readonly local = false; + private client: Anthropic | null = null; + + constructor( + readonly id: string, + private readonly apiKey: string, + private readonly baseUrl?: string, + ) {} + + private sdk(): Anthropic { + if (!this.apiKey) throw new Error(`API key for provider "${this.id}" is not set`); + this.client ??= new Anthropic({ + apiKey: this.apiKey, + ...(this.baseUrl ? { baseURL: this.baseUrl } : {}), + }); + return this.client; + } + + private params(req: LlmRequest): Anthropic.MessageCreateParamsNonStreaming { + return { + model: req.model, + max_tokens: req.maxTokens ?? 2048, + ...(req.systemPrompt ? { system: req.systemPrompt } : {}), + // Neutral blocks share Anthropic's field names, so this is structural. + messages: req.messages as Anthropic.MessageParam[], + ...(req.tools?.length ? { tools: req.tools as Anthropic.Tool[] } : {}), + ...(req.tools?.length && req.toolChoice + ? { tool_choice: req.toolChoice as Anthropic.ToolChoice } + : {}), + }; + } + + async complete(req: LlmRequest): Promise { + return toResponse(await this.sdk().messages.create(this.params(req))); + } + + async *stream(req: LlmRequest): AsyncIterable { + const stream = this.sdk().messages.stream(this.params(req)); + for await (const event of stream) { + if ( + event.type === "content_block_start" && + event.content_block.type === "tool_use" + ) { + yield { + type: "tool_use_start", + id: event.content_block.id, + name: event.content_block.name, + }; + } else if ( + event.type === "content_block_delta" && + event.delta.type === "text_delta" + ) { + yield { type: "text_delta", text: event.delta.text }; + } + } + yield { type: "done", response: toResponse(await stream.finalMessage()) }; + } + + async listModels(): Promise { + const page = await this.sdk().models.list({ limit: 100 }); + return page.data.map((m) => m.id); + } +} diff --git a/backend/src/lib/llm/providers/index.ts b/backend/src/lib/llm/providers/index.ts new file mode 100644 index 0000000..ba15f6f --- /dev/null +++ b/backend/src/lib/llm/providers/index.ts @@ -0,0 +1,18 @@ +export * from "./types"; +export { + getLlm, + getLlmConfig, + hasLlmRole, + describeLlmConfig, + isLoopbackUrl, + PROVIDER_PRESETS, + type LlmRole, + type LlmConfig, + type ProviderConfig, + type RoleBinding, + type ResolvedLlm, + type LlmRoleSummary, + isProviderLocal, + buildProvider, + mergeWithPreset, +} from "./registry"; diff --git a/backend/src/lib/llm/providers/ollama.ts b/backend/src/lib/llm/providers/ollama.ts new file mode 100644 index 0000000..6b0a2cb --- /dev/null +++ b/backend/src/lib/llm/providers/ollama.ts @@ -0,0 +1,266 @@ +import crypto from "crypto"; +import http from "http"; +import https from "https"; +import os from "os"; +import type { + LlmMessage, + LlmProvider, + LlmRequest, + LlmResponse, + LlmStopReason, + LlmStreamEvent, + LlmToolUseBlock, +} from "./types"; + +/** + * Native Ollama adapter (/api/chat). Preferred over Ollama's OpenAI-compatible + * endpoint because it lets us set the context window (Ollama defaults to 4K, + * too small for CapScreen's tool schemas + screening results) and keep a + * reasoning model's "thinking" out of the reply text. + */ + +type OllamaMessage = { + role: "system" | "user" | "assistant" | "tool"; + content: string; + tool_calls?: Array<{ function: { name: string; arguments: unknown } }>; + tool_name?: string; +}; + +type OllamaChunk = { + message?: { content?: string; thinking?: string; tool_calls?: Array<{ function: { name: string; arguments: unknown } }> }; + done?: boolean; + done_reason?: string; + error?: string; +}; + +/** Context window sized to the machine: KV cache grows with it. */ +function defaultContextWindow(): number { + const fromEnv = Number(process.env.OLLAMA_NUM_CTX); + if (Number.isFinite(fromEnv) && fromEnv >= 2048) return fromEnv; + const gb = os.totalmem() / 1024 ** 3; + if (gb <= 10) return 8192; + if (gb <= 20) return 16384; + return 32768; +} + +function toolId(): string { + return `call_${crypto.randomBytes(8).toString("hex")}`; +} + +function toOllamaMessages(systemPrompt: string | undefined, messages: LlmMessage[]): OllamaMessage[] { + const out: OllamaMessage[] = []; + if (systemPrompt) out.push({ role: "system", content: systemPrompt }); + // Ollama links tool results by name, not id. + const namesById = new Map(); + + for (const m of messages) { + if (typeof m.content === "string") { + out.push({ role: m.role, content: m.content }); + continue; + } + if (m.role === "assistant") { + const text = m.content.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join(""); + const calls = m.content.filter((b): b is LlmToolUseBlock => b.type === "tool_use"); + calls.forEach((c) => namesById.set(c.id, c.name)); + out.push({ + role: "assistant", + content: text, + ...(calls.length ? { tool_calls: calls.map((c) => ({ function: { name: c.name, arguments: c.input ?? {} } })) } : {}), + }); + continue; + } + const text: string[] = []; + for (const b of m.content) { + if (b.type === "tool_result") { + out.push({ role: "tool", content: b.content, tool_name: namesById.get(b.tool_use_id) ?? "" }); + } else if (b.type === "text") { + text.push(b.text); + } + } + if (text.length) out.push({ role: "user", content: text.join("\n") }); + } + return out; +} + +function stopReason(doneReason: string | undefined, hasTools: boolean): LlmStopReason { + if (hasTools) return "tool_use"; + if (doneReason === "length") return "max_tokens"; + return "end_turn"; +} + +export class OllamaProvider implements LlmProvider { + readonly kind = "ollama" as const; + readonly local: boolean; + private readonly base: string; + private readonly caps = new Map>(); + + constructor( + readonly id: string, + baseUrl: string | undefined, + local: boolean, + ) { + // Accept either the server root or an OpenAI-style /v1 URL. + this.base = (baseUrl ?? "http://127.0.0.1:11434").replace(/\/+$/, "").replace(/\/v1$/, ""); + this.local = local; + } + + private capabilities(model: string): Promise { + let p = this.caps.get(model); + if (!p) { + p = fetch(`${this.base}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + }) + .then(async (r) => (r.ok ? (((await r.json()) as { capabilities?: string[] }).capabilities ?? []) : [])) + .catch(() => []); + this.caps.set(model, p); + } + return p; + } + + private async body(req: LlmRequest, stream: boolean) { + const caps = await this.capabilities(req.model); + const thinking = caps.includes("thinking"); + let systemPrompt = req.systemPrompt; + // Ollama has no tool_choice; ask for the forced tool explicitly instead. + if (req.tools?.length && req.toolChoice?.type === "tool") { + systemPrompt = `${systemPrompt ?? ""}\n\nIMPORTANT: Your first action must be to call the \`${req.toolChoice.name}\` tool.`; + } else if (req.tools?.length && req.toolChoice?.type === "any") { + systemPrompt = `${systemPrompt ?? ""}\n\nIMPORTANT: You must call one of the available tools before answering.`; + } + return { + model: req.model, + messages: toOllamaMessages(systemPrompt, req.messages), + ...(req.tools?.length + ? { + tools: req.tools.map((t) => ({ + type: "function", + function: { name: t.name, description: t.description ?? "", parameters: t.input_schema }, + })), + } + : {}), + stream, + // Reasoning models: some builds keep reasoning even with think:false and + // then write it into the answer. Asking for it explicitly makes Ollama + // return it in a separate field, which we drop — the reply stays clean. + ...(thinking ? { think: true } : {}), + // Release memory soon after use — matters on 8–16 GB machines. + keep_alive: "5m", + options: { + num_ctx: defaultContextWindow(), + // Reasoning tokens count against the budget; leave room for the answer. + num_predict: (req.maxTokens ?? 2048) + (thinking ? 4096 : 0), + temperature: 0.2, + }, + }; + } + + /** + * POST via node:http rather than fetch: on low-memory machines prompt + * processing can exceed fetch's fixed 300 s header timeout, and Ollama sends + * no headers until it starts generating. + */ + private post(path: string, body: unknown): Promise { + const url = new URL(`${this.base}${path}`); + const payload = JSON.stringify(body); + const lib = url.protocol === "https:" ? https : http; + return new Promise((resolve, reject) => { + const req = lib.request( + url, + { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } }, + (res) => { + if ((res.statusCode ?? 500) < 400) { + resolve(res); + return; + } + let detail = ""; + res.setEncoding("utf8"); + res.on("data", (c: string) => (detail += c)); + res.on("end", () => { + let msg = detail; + try { + msg = (JSON.parse(detail) as { error?: string }).error ?? detail; + } catch { + /* plain text */ + } + reject(new Error(`Ollama ${res.statusCode}: ${msg}`)); + }); + }, + ); + req.on("error", reject); + req.end(payload); + }); + } + + private async readJson(res: http.IncomingMessage): Promise { + let text = ""; + for await (const chunk of res) text += chunk; + return JSON.parse(text) as T; + } + + async complete(req: LlmRequest): Promise { + const chunk = await this.readJson(await this.post("/api/chat", await this.body(req, false))); + return this.finalize(chunk.message?.content ?? "", chunk.message?.tool_calls ?? [], chunk.done_reason); + } + + async *stream(req: LlmRequest): AsyncIterable { + const resp = await this.post("/api/chat", await this.body(req, true)); + const decoder = new TextDecoder(); + let buffered = ""; + let text = ""; + let doneReason: string | undefined; + const calls: Array<{ function: { name: string; arguments: unknown } }> = []; + + for await (const part of resp as AsyncIterable) { + buffered += decoder.decode(part, { stream: true }); + let nl: number; + while ((nl = buffered.indexOf("\n")) >= 0) { + const line = buffered.slice(0, nl).trim(); + buffered = buffered.slice(nl + 1); + if (!line) continue; + const chunk = JSON.parse(line) as OllamaChunk; + if (chunk.error) throw new Error(`Ollama: ${chunk.error}`); + const content = chunk.message?.content; + if (content) { + text += content; + yield { type: "text_delta", text: content }; + } + for (const call of chunk.message?.tool_calls ?? []) { + calls.push(call); + yield { type: "tool_use_start", id: "", name: call.function.name }; + } + if (chunk.done) doneReason = chunk.done_reason; + } + } + yield { type: "done", response: this.finalize(text, calls, doneReason) }; + } + + private finalize(text: string, calls: Array<{ function: { name: string; arguments: unknown } }>, doneReason?: string): LlmResponse { + const content: LlmResponse["content"] = []; + if (text) content.push({ type: "text", text }); + for (const c of calls) { + let input = c.function.arguments; + if (typeof input === "string") { + try { + input = JSON.parse(input); + } catch { + input = {}; + } + } + content.push({ type: "tool_use", id: toolId(), name: c.function.name, input: input ?? {} }); + } + return { content, stopReason: stopReason(doneReason, calls.length > 0) }; + } + + async embed(model: string, texts: string[]): Promise { + const res = await this.post("/api/embed", { model, input: texts, keep_alive: "5m" }); + return (await this.readJson<{ embeddings: number[][] }>(res)).embeddings; + } + + async listModels(): Promise { + const resp = await fetch(`${this.base}/api/tags`); + if (!resp.ok) throw new Error(`Ollama ${resp.status}`); + return ((await resp.json()) as { models: Array<{ name: string }> }).models.map((m) => m.name); + } +} diff --git a/backend/src/lib/llm/providers/openaiCompatible.ts b/backend/src/lib/llm/providers/openaiCompatible.ts new file mode 100644 index 0000000..f1fbd36 --- /dev/null +++ b/backend/src/lib/llm/providers/openaiCompatible.ts @@ -0,0 +1,197 @@ +import crypto from "crypto"; +import OpenAI from "openai"; +import type { + LlmMessage, + LlmProvider, + LlmRequest, + LlmResponse, + LlmStopReason, + LlmStreamEvent, + LlmToolChoice, + LlmToolUseBlock, +} from "./types"; + +type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam; +type ChatTool = OpenAI.Chat.Completions.ChatCompletionTool; + +function toolId(): string { + return `call_${crypto.randomBytes(8).toString("hex")}`; +} + +function parseArgs(raw: string | undefined): unknown { + if (!raw?.trim()) return {}; + try { + return JSON.parse(raw); + } catch { + return {}; + } +} + +function toChatMessages(systemPrompt: string | undefined, messages: LlmMessage[]): ChatMessage[] { + const out: ChatMessage[] = []; + if (systemPrompt) out.push({ role: "system", content: systemPrompt }); + + for (const m of messages) { + if (typeof m.content === "string") { + out.push({ role: m.role, content: m.content }); + continue; + } + + if (m.role === "assistant") { + const text = m.content + .filter((b) => b.type === "text") + .map((b) => (b as { text: string }).text) + .join(""); + const toolCalls = m.content + .filter((b): b is LlmToolUseBlock => b.type === "tool_use") + .map((b) => ({ + id: b.id, + type: "function" as const, + function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) }, + })); + out.push({ + role: "assistant", + content: text || null, + ...(toolCalls.length ? { tool_calls: toolCalls } : {}), + }); + continue; + } + + // User turn: tool results become `tool` messages, text stays a user message. + const text: string[] = []; + for (const b of m.content) { + if (b.type === "tool_result") { + out.push({ role: "tool", tool_call_id: b.tool_use_id, content: b.content }); + } else if (b.type === "text") { + text.push(b.text); + } + } + if (text.length) out.push({ role: "user", content: text.join("\n") }); + } + return out; +} + +function toToolChoice(choice: LlmToolChoice | undefined) { + if (!choice) return undefined; + if (choice.type === "auto") return "auto" as const; + if (choice.type === "any") return "required" as const; + return { type: "function" as const, function: { name: choice.name } }; +} + +function toStopReason(finish: string | null | undefined, hasToolCalls: boolean): LlmStopReason { + // Several local servers report "stop" even when they emitted tool calls. + if (hasToolCalls) return "tool_use"; + if (finish === "stop") return "end_turn"; + if (finish === "length") return "max_tokens"; + return "other"; +} + +export class OpenAICompatibleProvider implements LlmProvider { + readonly kind = "openai-compatible" as const; + private client: OpenAI | null = null; + + constructor( + readonly id: string, + private readonly baseUrl: string | undefined, + private readonly apiKey: string, + readonly local: boolean, + ) {} + + private sdk(): OpenAI { + // Local servers ignore the key, but the SDK requires a non-empty value. + this.client ??= new OpenAI({ + apiKey: this.apiKey || "not-needed", + ...(this.baseUrl ? { baseURL: this.baseUrl } : {}), + }); + return this.client; + } + + private params(req: LlmRequest) { + const tools: ChatTool[] | undefined = req.tools?.length + ? req.tools.map((t) => ({ + type: "function", + function: { + name: t.name, + description: t.description ?? "", + parameters: t.input_schema as Record, + }, + })) + : undefined; + return { + model: req.model, + max_tokens: req.maxTokens ?? 2048, + messages: toChatMessages(req.systemPrompt, req.messages), + ...(tools ? { tools } : {}), + ...(tools && req.toolChoice ? { tool_choice: toToolChoice(req.toolChoice) } : {}), + }; + } + + async complete(req: LlmRequest): Promise { + const resp = await this.sdk().chat.completions.create({ ...this.params(req), stream: false }); + const choice = resp.choices[0]; + const content: LlmResponse["content"] = []; + if (choice?.message.content) content.push({ type: "text", text: choice.message.content }); + for (const tc of choice?.message.tool_calls ?? []) { + if (tc.type !== "function") continue; + content.push({ + type: "tool_use", + id: tc.id || toolId(), + name: tc.function.name, + input: parseArgs(tc.function.arguments), + }); + } + const hasTools = content.some((b) => b.type === "tool_use"); + return { content, stopReason: toStopReason(choice?.finish_reason, hasTools) }; + } + + async *stream(req: LlmRequest): AsyncIterable { + const stream = await this.sdk().chat.completions.create({ ...this.params(req), stream: true }); + + let text = ""; + let finish: string | null = null; + const calls = new Map(); + + for await (const chunk of stream) { + const choice = chunk.choices[0]; + if (!choice) continue; + if (choice.finish_reason) finish = choice.finish_reason; + + const delta = choice.delta; + if (delta?.content) { + text += delta.content; + yield { type: "text_delta", text: delta.content }; + } + for (const tc of delta?.tool_calls ?? []) { + let call = calls.get(tc.index); + if (!call) { + call = { id: tc.id || toolId(), name: tc.function?.name ?? "", args: "" }; + calls.set(tc.index, call); + if (call.name) yield { type: "tool_use_start", id: call.id, name: call.name }; + } else if (!call.name && tc.function?.name) { + call.name = tc.function.name; + yield { type: "tool_use_start", id: call.id, name: call.name }; + } + if (tc.function?.arguments) call.args += tc.function.arguments; + } + } + + const content: LlmResponse["content"] = []; + if (text) content.push({ type: "text", text }); + for (const call of [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, c]) => c)) { + if (!call.name) continue; + content.push({ type: "tool_use", id: call.id, name: call.name, input: parseArgs(call.args) }); + } + const hasTools = content.some((b) => b.type === "tool_use"); + yield { type: "done", response: { content, stopReason: toStopReason(finish, hasTools) } }; + } + + async embed(model: string, texts: string[]): Promise { + const resp = await this.sdk().embeddings.create({ model, input: texts }); + return resp.data.map((d) => d.embedding); + } + + async listModels(): Promise { + const page = await this.sdk().models.list(); + return page.data.map((m) => m.id); + } +} diff --git a/backend/src/lib/llm/providers/registry.ts b/backend/src/lib/llm/providers/registry.ts new file mode 100644 index 0000000..a49bfa0 --- /dev/null +++ b/backend/src/lib/llm/providers/registry.ts @@ -0,0 +1,274 @@ +import { AnthropicProvider } from "./anthropic"; +import { getProviderKey, readAiConfig } from "../controlCenter/store"; +import { OpenAICompatibleProvider } from "./openaiCompatible"; +import { OllamaProvider } from "./ollama"; +import type { LlmProvider, ProviderKind } from "./types"; + +/** + * Model provider configuration. + * + * Two sources, in priority order: + * 1. AI Control Center — /ai/config.json + encrypted keys (set in the app) + * 2. Env vars — LLM_PROVIDER / LLM_MODEL / LLM_UTILITY_MODEL / LLM_BASE_URL / LLM_API_KEY + * (developer fallback for `pnpm dev`) + */ + +export type LlmRole = "agent" | "utility" | "embedding"; + +export type ProviderConfig = { + id: string; + kind: ProviderKind; + label?: string; + baseUrl?: string; + /** Literal key — desktop injects from the OS keychain; avoid in files. */ + apiKey?: string; + /** Name of an env var holding the key. */ + apiKeyEnv?: string; + /** Override locality detection. Defaults to true for loopback base URLs. */ + local?: boolean; +}; + +export type RoleBinding = { + provider: string; + model: string; + /** Set false for local models without function calling; the agent then runs tool-less. */ + supportsTools?: boolean; +}; + +export type LlmConfig = { + providers: ProviderConfig[]; + roles: Partial>; + /** Refuse any provider that is not on this machine. */ + localOnly?: boolean; + source: "control-center" | "env"; +}; + +export type ResolvedLlm = { + provider: LlmProvider; + providerId: string; + model: string; + supportsTools: boolean; + local: boolean; +}; + +const DEFAULT_ANTHROPIC_MODEL = "claude-haiku-4-5"; + +/** Known endpoints. Any other OpenAI-compatible server works via `custom` + LLM_BASE_URL. */ +export const PROVIDER_PRESETS: Record> = { + anthropic: { kind: "anthropic", label: "Anthropic", apiKeyEnv: "ANTHROPIC_API_KEY" }, + openai: { kind: "openai-compatible", label: "OpenAI", apiKeyEnv: "OPENAI_API_KEY" }, + ollama: { kind: "ollama", label: "Ollama (local)", baseUrl: "http://127.0.0.1:11434" }, + lmstudio: { kind: "openai-compatible", label: "LM Studio (local)", baseUrl: "http://127.0.0.1:1234/v1" }, + llamacpp: { kind: "openai-compatible", label: "llama.cpp server (local)", baseUrl: "http://127.0.0.1:8080/v1" }, + openrouter: { + kind: "openai-compatible", + label: "OpenRouter", + baseUrl: "https://openrouter.ai/api/v1", + apiKeyEnv: "OPENROUTER_API_KEY", + }, + groq: { + kind: "openai-compatible", + label: "Groq", + baseUrl: "https://api.groq.com/openai/v1", + apiKeyEnv: "GROQ_API_KEY", + }, + gemini: { + kind: "openai-compatible", + label: "Google Gemini", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", + apiKeyEnv: "GEMINI_API_KEY", + }, +}; + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]); + +export function isLoopbackUrl(url: string | undefined): boolean { + if (!url) return false; + try { + return LOOPBACK_HOSTS.has(new URL(url).hostname); + } catch { + return false; + } +} + +function env(name: string): string | undefined { + const v = process.env[name]?.trim(); + return v ? v : undefined; +} + +function configFromEnv(): LlmConfig { + // Desktop app: models are configured only in the AI Control Center. + if (process.env.CAPSCREEN_DESKTOP_TOKEN || (!env("LLM_PROVIDER") && !env("ANTHROPIC_API_KEY") && !env("LLM_API_KEY"))) { + return { providers: [], roles: {}, source: "env" }; + } + const presetId = env("LLM_PROVIDER") ?? "anthropic"; + const preset = PROVIDER_PRESETS[presetId] ?? { kind: "openai-compatible" as const }; + const provider: ProviderConfig = { + id: presetId, + ...preset, + ...(env("LLM_BASE_URL") ? { baseUrl: env("LLM_BASE_URL") } : {}), + ...(env("LLM_API_KEY") ? { apiKey: env("LLM_API_KEY") } : {}), + }; + + const model = + env("LLM_MODEL") ?? (provider.kind === "anthropic" ? DEFAULT_ANTHROPIC_MODEL : ""); + if (!model) { + throw new Error(`LLM_MODEL must be set when LLM_PROVIDER=${presetId}`); + } + if (env("ANTHROPIC_MODEL")) { + console.warn("[llm] ANTHROPIC_MODEL is ignored — use LLM_MODEL instead"); + } + const supportsTools = env("LLM_SUPPORTS_TOOLS")?.toLowerCase() !== "false"; + + const providers = [provider]; + const roles: LlmConfig["roles"] = { + agent: { provider: provider.id, model, supportsTools }, + utility: { provider: provider.id, model: env("LLM_UTILITY_MODEL") ?? model, supportsTools }, + }; + + // Embeddings keep their historical default (OpenAI) unless configured. + const embedProvider = env("EMBEDDING_PROVIDER"); + if (embedProvider) { + if (embedProvider !== provider.id) { + providers.push({ id: embedProvider, ...(PROVIDER_PRESETS[embedProvider] ?? { kind: "openai-compatible" }) }); + } + roles.embedding = { provider: embedProvider, model: env("EMBEDDING_MODEL") ?? "text-embedding-3-small" }; + } else if (env("OPENAI_API_KEY")) { + if (provider.id !== "openai") providers.push({ id: "openai", ...PROVIDER_PRESETS.openai }); + roles.embedding = { provider: "openai", model: env("EMBEDDING_MODEL") ?? "text-embedding-3-small" }; + } + + return { providers, roles, source: "env" }; +} + +/** Stored overrides on top of a preset. A preset's adapter kind always wins. */ +export function mergeWithPreset(p: ProviderConfig | (Omit & { kind?: ProviderKind })): ProviderConfig { + const preset = PROVIDER_PRESETS[p.id]; + return { ...(preset ?? {}), ...p, ...(preset ? { kind: preset.kind } : {}) } as ProviderConfig; +} + +function configFromControlCenter(): LlmConfig | null { + const cfg = readAiConfig(); + if (!cfg) return null; + return { + providers: cfg.providers.map((p) => { + const merged = mergeWithPreset(p); + // Keys entered in the app win; a preset's env var still works in dev. + const key = getProviderKey(p.id); + return key ? { ...merged, apiKey: key } : merged; + }), + roles: cfg.roles, + localOnly: cfg.localOnly, + source: "control-center", + }; +} + +export function getLlmConfig(): LlmConfig { + return configFromControlCenter() ?? configFromEnv(); +} + +const providerCache = new Map(); + +function providerKey(p: ProviderConfig): string { + return JSON.stringify([p.id, p.kind, p.baseUrl, p.apiKey, p.apiKeyEnv, p.local]); +} + +function isProviderLocal(p: ProviderConfig): boolean { + return p.local ?? isLoopbackUrl(p.baseUrl); +} + +export function buildProvider(p: ProviderConfig): LlmProvider { + const key = providerKey(p); + const cached = providerCache.get(key); + if (cached) return cached; + + const apiKey = p.apiKey ?? (p.apiKeyEnv ? env(p.apiKeyEnv) : undefined) ?? ""; + const provider = + p.kind === "anthropic" + ? new AnthropicProvider(p.id, apiKey, p.baseUrl) + : p.kind === "ollama" + ? new OllamaProvider(p.id, p.baseUrl, isProviderLocal(p)) + : new OpenAICompatibleProvider(p.id, p.baseUrl, apiKey, isProviderLocal(p)); + providerCache.set(key, provider); + return provider; +} + +export function getLlm(role: LlmRole): ResolvedLlm { + const config = getLlmConfig(); + // utility falls back to agent so single-model setups need one binding. + const binding = config.roles[role] ?? (role === "utility" ? config.roles.agent : undefined); + if (!binding) throw new Error(`No model configured for role "${role}"`); + + const pc = config.providers.find((p) => p.id === binding.provider); + if (!pc) throw new Error(`Role "${role}" references unknown provider "${binding.provider}"`); + + const provider = buildProvider(pc); + if (config.localOnly && !provider.local) { + throw new Error( + `Local-only mode is on, so "${pc.label ?? pc.id}" (a cloud provider) is blocked. ` + + "Pick an on-device model or turn off Local-only mode in the AI Control Center.", + ); + } + return { + provider, + providerId: pc.id, + model: binding.model, + supportsTools: binding.supportsTools ?? true, + local: provider.local, + }; +} + +export function hasLlmRole(role: LlmRole): boolean { + try { + getLlm(role); + return true; + } catch { + return false; + } +} + +/** Safe-to-expose summary (no keys) for /api/config and the settings UI. */ +export type LlmRoleSummary = { + provider: string; + label: string; + model: string; + local: boolean; + supportsTools: boolean; + error?: string; +}; + +export function describeLlmConfig(): { + roles: Partial>; + localOnly: boolean; + source: LlmConfig["source"]; + error?: string; +} { + let config: LlmConfig; + try { + config = getLlmConfig(); + } catch (err) { + return { roles: {}, localOnly: false, source: "env", error: err instanceof Error ? err.message : String(err) }; + } + const roles: Partial> = {}; + for (const role of ["agent", "utility", "embedding"] as const) { + const binding = config.roles[role] ?? (role === "utility" ? config.roles.agent : undefined); + if (!binding) continue; + const pc = config.providers.find((p) => p.id === binding.provider); + const base: LlmRoleSummary = { + provider: binding.provider, + label: pc?.label ?? binding.provider, + model: binding.model, + local: pc ? isProviderLocal(pc) : false, + supportsTools: binding.supportsTools ?? true, + }; + try { + getLlm(role); + roles[role] = base; + } catch (err) { + roles[role] = { ...base, error: err instanceof Error ? err.message : String(err) }; + } + } + return { roles, localOnly: !!config.localOnly, source: config.source }; +} + +export { isProviderLocal }; diff --git a/backend/src/lib/llm/providers/types.ts b/backend/src/lib/llm/providers/types.ts new file mode 100644 index 0000000..16fe093 --- /dev/null +++ b/backend/src/lib/llm/providers/types.ts @@ -0,0 +1,84 @@ +/** + * Provider-neutral LLM types. + * + * Field names deliberately mirror the Anthropic wire format (input_schema, + * tool_use_id, …) so existing tool definitions work unchanged; adapters + * translate to each provider's own format. + */ + +export type LlmTextBlock = { type: "text"; text: string }; +export type LlmToolUseBlock = { + type: "tool_use"; + id: string; + name: string; + input: unknown; +}; +export type LlmToolResultBlock = { + type: "tool_result"; + tool_use_id: string; + content: string; +}; + +export type LlmContentBlock = LlmTextBlock | LlmToolUseBlock | LlmToolResultBlock; + +export type LlmMessage = { + role: "user" | "assistant"; + content: string | LlmContentBlock[]; +}; + +export type LlmToolSchema = { + name: string; + description?: string; + input_schema: { + type: "object"; + properties?: Record | null; + required?: string[] | null; + [k: string]: unknown; + }; +}; + +export type LlmToolChoice = + | { type: "auto" } + | { type: "any" } + | { type: "tool"; name: string }; + +export type LlmStopReason = "end_turn" | "tool_use" | "max_tokens" | "other"; + +export type LlmRequest = { + model: string; + systemPrompt?: string; + messages: LlmMessage[]; + tools?: LlmToolSchema[]; + toolChoice?: LlmToolChoice; + maxTokens?: number; +}; + +export type LlmResponse = { + content: Array; + stopReason: LlmStopReason; +}; + +export type LlmStreamEvent = + | { type: "text_delta"; text: string } + | { type: "tool_use_start"; id: string; name: string } + | { type: "done"; response: LlmResponse }; + +export type ProviderKind = "anthropic" | "openai-compatible" | "ollama"; + +export interface LlmProvider { + readonly id: string; + readonly kind: ProviderKind; + /** True when requests never leave this machine (Ollama, LM Studio, …). */ + readonly local: boolean; + complete(req: LlmRequest): Promise; + stream(req: LlmRequest): AsyncIterable; + embed?(model: string, texts: string[]): Promise; + listModels?(): Promise; +} + +export function responseText(resp: LlmResponse): string { + return resp.content + .filter((b): b is LlmTextBlock => b.type === "text") + .map((b) => b.text) + .join(""); +} diff --git a/backend/src/lib/llm/streamAgent.ts b/backend/src/lib/llm/streamAgent.ts index 818b9c1..c07266a 100644 --- a/backend/src/lib/llm/streamAgent.ts +++ b/backend/src/lib/llm/streamAgent.ts @@ -1,11 +1,14 @@ -import Anthropic from "@anthropic-ai/sdk"; import type { ToolActivity, ToolDocumentResult } from "../chat/chatTools"; - -function client(): Anthropic { - const key = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; - if (!key) throw new Error("ANTHROPIC_API_KEY is not set"); - return new Anthropic({ apiKey: key }); -} +import { + getLlm, + type LlmMessage, + type LlmResponse, + type LlmRole, + type LlmToolChoice, + type LlmToolResultBlock, + type LlmToolSchema, + type LlmToolUseBlock, +} from "./providers"; function truncate(s: string, max = 120): string { const one = s.replace(/\s+/g, " ").trim(); @@ -14,11 +17,14 @@ function truncate(s: string, max = 120): string { export type SseWrite = (line: string) => void; +const NO_TOOLS_NOTE = + "\n\n## Tool access\nThe configured model does not support tool calling. Answer only from the context in this prompt, " + + "say plainly when data you would normally look up is unavailable, and never invent entities, scores, or matches."; + export async function runAgentStream(params: { - model: string; systemPrompt: string; - messages: Anthropic.MessageParam[]; - tools: Anthropic.Tool[]; + messages: LlmMessage[]; + tools: LlmToolSchema[]; executeTool: ( name: string, input: unknown, @@ -27,75 +33,59 @@ export async function runAgentStream(params: { maxTokens?: number; maxRounds?: number; /** Force a specific tool on round 0 only. Subsequent rounds always use auto. */ - toolChoice?: Anthropic.ToolChoiceAuto | Anthropic.ToolChoiceAny | Anthropic.ToolChoiceTool; + toolChoice?: LlmToolChoice; + role?: LlmRole; }): Promise<{ fullText: string; toolActivity: ToolActivity[] }> { - const anthropic = client(); + const llm = getLlm(params.role ?? "agent"); + const tools = llm.supportsTools ? params.tools : []; + const systemPrompt = llm.supportsTools ? params.systemPrompt : params.systemPrompt + NO_TOOLS_NOTE; + const toolActivity: ToolActivity[] = []; const messages = [...params.messages]; const maxRounds = params.maxRounds ?? 8; let fullText = ""; for (let round = 0; round < maxRounds; round++) { - const stream = anthropic.messages.stream({ - model: params.model, - max_tokens: params.maxTokens ?? 2048, - system: params.systemPrompt, - tools: params.tools, - messages, - ...(round === 0 && params.toolChoice ? { tool_choice: params.toolChoice } : {}), - }); - - let roundText = ""; const announcedTools = new Set(); + let finalResponse: LlmResponse | null = null; - for await (const event of stream) { - if (event.type === "content_block_start") { - if (event.content_block.type === "tool_use") { - const name = event.content_block.name; - if (!announcedTools.has(name)) { - announcedTools.add(name); - params.write( - `data: ${JSON.stringify({ - type: "tool_call_start", - name, - })}\n\n`, - ); - } + for await (const event of llm.provider.stream({ + model: llm.model, + maxTokens: params.maxTokens ?? 2048, + systemPrompt, + tools, + messages, + ...(round === 0 && params.toolChoice ? { toolChoice: params.toolChoice } : {}), + })) { + if (event.type === "tool_use_start") { + if (!announcedTools.has(event.name)) { + announcedTools.add(event.name); + params.write( + `data: ${JSON.stringify({ type: "tool_call_start", name: event.name })}\n\n`, + ); } - } - - if ( - event.type === "content_block_delta" && - event.delta.type === "text_delta" - ) { - const text = event.delta.text; - roundText += text; - fullText += text; + } else if (event.type === "text_delta") { + fullText += event.text; params.write( - `data: ${JSON.stringify({ type: "content_delta", text })}\n\n`, + `data: ${JSON.stringify({ type: "content_delta", text: event.text })}\n\n`, ); + } else { + finalResponse = event.response; } } - const finalMessage = await stream.finalMessage(); - const stopReason = finalMessage.stop_reason; - const toolUses = finalMessage.content.filter( - (b): b is Anthropic.ToolUseBlock => b.type === "tool_use", + const toolUses = (finalResponse?.content ?? []).filter( + (b): b is LlmToolUseBlock => b.type === "tool_use", ); - if (stopReason === "end_turn" || stopReason === "max_tokens") { - params.write(`data: ${JSON.stringify({ type: "content_done" })}\n\n`); - return { fullText, toolActivity }; - } - - if (stopReason !== "tool_use" || toolUses.length === 0) { + if (finalResponse?.stopReason !== "tool_use" || toolUses.length === 0) { params.write(`data: ${JSON.stringify({ type: "content_done" })}\n\n`); return { fullText, toolActivity }; } - messages.push({ role: "assistant", content: finalMessage.content }); + messages.push({ role: "assistant", content: finalResponse.content }); - const toolResults: Anthropic.ToolResultBlockParam[] = []; + const toolResults: LlmToolResultBlock[] = []; for (const block of toolUses) { toolActivity.push({ name: block.name, status: "running" }); const { content, document } = await params.executeTool( diff --git a/backend/src/lib/llm/validateKey.ts b/backend/src/lib/llm/validateKey.ts deleted file mode 100644 index 3b10a1e..0000000 --- a/backend/src/lib/llm/validateKey.ts +++ /dev/null @@ -1,56 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk"; -import { getAnthropicModel } from "./models"; - -const PLACEHOLDERS = new Set([ - "", - "your-anthropic-key", - "sk-ant-your-key-here", -]); - -export function anthropicKeyStatus(): { - ok: boolean; - message: string; -} { - const key = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; - if (PLACEHOLDERS.has(key)) { - return { - ok: false, - message: - "ANTHROPIC_API_KEY is missing or still the .env.example placeholder", - }; - } - if (!key.startsWith("sk-ant-")) { - return { - ok: false, - message: "ANTHROPIC_API_KEY does not look like a valid Anthropic key", - }; - } - return { ok: true, message: "format OK" }; -} - -/** One-token ping — fails fast on boot if the key is rejected by Anthropic. */ -export async function validateAnthropicKeyLive(): Promise { - const status = anthropicKeyStatus(); - if (!status.ok) { - console.error(`[startup] ${status.message}`); - return; - } - - try { - const client = new Anthropic({ - apiKey: process.env.ANTHROPIC_API_KEY!.trim(), - }); - await client.messages.create({ - model: getAnthropicModel(), - max_tokens: 1, - messages: [{ role: "user", content: "ping" }], - }); - console.log("[startup] Anthropic API key verified"); - } catch (err) { - const msg = - err instanceof Error ? err.message : "Anthropic authentication failed"; - console.error( - `[startup] Anthropic API key rejected (401). Update backend/.env and restart the server — tsx watch does not reload .env changes.\n ${msg}`, - ); - } -} diff --git a/backend/src/lib/llm/validateLlm.ts b/backend/src/lib/llm/validateLlm.ts new file mode 100644 index 0000000..91c4188 --- /dev/null +++ b/backend/src/lib/llm/validateLlm.ts @@ -0,0 +1,56 @@ +import { getLlm } from "./providers"; + +const ANTHROPIC_PLACEHOLDERS = new Set([ + "", + "your-anthropic-key", + "sk-ant-your-key-here", +]); + +/** One-token ping of the agent model — fails fast on boot if the provider rejects us. */ +export async function validateLlmLive(): Promise { + let llm; + try { + llm = getLlm("agent"); + } catch (err) { + console.error(`[startup] LLM config: ${err instanceof Error ? err.message : String(err)}`); + return; + } + + const label = `${llm.providerId}/${llm.model}${llm.local ? " (local)" : ""}`; + if ( + llm.providerId === "anthropic" && + ANTHROPIC_PLACEHOLDERS.has(process.env.ANTHROPIC_API_KEY?.trim() ?? "") && + !process.env.LLM_API_KEY?.trim() + ) { + console.error("[startup] ANTHROPIC_API_KEY is missing or still the .env.example placeholder"); + return; + } + + // Local models: confirm it is installed, but don't load gigabytes of weights + // into memory at every launch just to say "ping". + if (llm.local && llm.provider.listModels) { + try { + const models = await llm.provider.listModels(); + const ok = models.some((m) => m === llm.model || m.replace(/:latest$/, "") === llm.model); + if (ok) console.log(`[startup] LLM installed: ${label}`); + else console.error(`[startup] ${label} is not installed — download it in the AI Control Center`); + } catch (err) { + console.error(`[startup] Local model server unreachable for ${label}: ${err instanceof Error ? err.message : String(err)}`); + } + return; + } + + try { + await llm.provider.complete({ + model: llm.model, + maxTokens: 1, + messages: [{ role: "user", content: "ping" }], + }); + console.log(`[startup] LLM reachable: ${label}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error( + `[startup] LLM check failed for ${label}. Fix the provider config and restart — tsx watch does not reload .env changes.\n ${msg}`, + ); + } +} diff --git a/backend/src/lib/portfolio/rescreenScheduler.ts b/backend/src/lib/portfolio/rescreenScheduler.ts index 8abf53d..c1a499f 100644 --- a/backend/src/lib/portfolio/rescreenScheduler.ts +++ b/backend/src/lib/portfolio/rescreenScheduler.ts @@ -6,6 +6,8 @@ import { saveScreeningSnapshot } from "../screening/screeningDelta"; import { syncPortfolioMonitoringReview } from "./portfolioGrid"; import type { RiskLevel, ScreeningResult } from "../../types/screening"; import { ownerFilter } from "../../routes/startups/middleware"; +import { LOCAL_USER_ID } from "../../middleware/requireAuth"; +import { readSettings } from "../infra/settings"; function entityRiskMap( result: ScreeningResult | null | undefined, @@ -185,7 +187,7 @@ export async function runPortfolioRescreen(options?: { } await syncPortfolioMonitoringReview({ - userId: options?.ownerId ?? "preview-user", + userId: options?.ownerId ?? LOCAL_USER_ID, userEmail: options?.userEmail ?? "admin@capscreen.local", }); @@ -227,22 +229,37 @@ export async function runPortfolioRescreen(options?: { }; } -export function startRescreenScheduler(): void { - const enabled = process.env.RESCREEN_SCHEDULER_ENABLED === "true"; - if (!enabled) return; +const CHECK_EVERY_MS = 15 * 60 * 1000; +let rescreenRunning = false; - const hours = Number.parseInt(process.env.RESCREEN_INTERVAL_HOURS ?? "168", 10); - const ms = Math.max(1, hours) * 60 * 60 * 1000; +/** + * Desktop background re-screening. Rather than a fixed timer from launch, this + * checks every 15 minutes whether a run is due (last digest older than the + * configured interval), so it catches up after sleep, restarts, or days off. + */ +export function startRescreenScheduler(): void { + const tick = async () => { + const { enabled, intervalHours } = readSettings().backgroundScreening; + if (!enabled || rescreenRunning) return; + const last = (await ScreeningDigest.findOne().sort({ generatedAt: -1 }).lean()) as { generatedAt?: Date } | null; + const dueAt = (last?.generatedAt ? new Date(last.generatedAt).getTime() : 0) + Math.max(1, intervalHours) * 3_600_000; + if (Date.now() < dueAt) return; + if ((await Startup.countDocuments({ deletedAt: null, latestCsvId: { $ne: null } })) === 0) return; - const tick = () => { - void runPortfolioRescreen().catch((err) => { + rescreenRunning = true; + try { + const r = await runPortfolioRescreen(); + console.log(`[rescreen-scheduler] re-screened ${r.rescreened}/${r.total} startups`); + } catch (err) { console.error("[rescreen-scheduler]", err); - }); + } finally { + rescreenRunning = false; + } }; - setTimeout(tick, 30_000); - setInterval(tick, ms); - console.log(`[rescreen-scheduler] enabled — every ${hours}h`); + // First check shortly after boot (Watchman lists need a minute to load). + setTimeout(() => void tick(), 2 * 60_000).unref(); + setInterval(() => void tick(), CHECK_EVERY_MS).unref(); } export async function getLatestDigest() { diff --git a/backend/src/lib/rag/embed.ts b/backend/src/lib/rag/embed.ts index aad3607..b4db3df 100644 --- a/backend/src/lib/rag/embed.ts +++ b/backend/src/lib/rag/embed.ts @@ -1,64 +1,40 @@ -import OpenAI from "openai"; +import { getLlm, hasLlmRole } from "../llm/providers"; -const OPENAI_EMBED_MODEL = "text-embedding-3-small"; const EMBED_BATCH_SIZE = parseInt(process.env.RAG_EMBED_BATCH_SIZE ?? "16", 10); -const EMBEDDING_DIM = 1536; -let client: OpenAI | null = null; - -function getClient(): OpenAI | null { - const key = process.env.OPENAI_API_KEY; - if (!key) return null; - if (!client) client = new OpenAI({ apiKey: key }); - return client; +/** + * Embeddings go through the "embedding" model role — e.g. nomic-embed-text in + * Ollama (on-device) or a cloud embedding model chosen in the AI Control Center. + * With no embedding model configured, chunks get empty vectors and retrieval + * uses keyword search. + */ +export function isEmbeddingEnabled(): boolean { + if (!hasLlmRole("embedding")) return false; + return !!getLlm("embedding").provider.embed; } export async function embedTexts(texts: string[]): Promise { - const openai = getClient(); - if (!openai) { - console.warn("[rag] OPENAI_API_KEY not set — returning zero vectors (RAG disabled)"); - return texts.map(() => new Array(EMBEDDING_DIM).fill(0) as number[]); - } + if (!isEmbeddingEnabled()) return texts.map(() => []); + const llm = getLlm("embedding"); const results: number[][] = []; for (let i = 0; i < texts.length; i += EMBED_BATCH_SIZE) { const batch = texts.slice(i, i + EMBED_BATCH_SIZE); - const response = await openai.embeddings.create({ - model: OPENAI_EMBED_MODEL, - input: batch, - }); - for (const item of response.data) { - results.push(item.embedding); - } + results.push(...(await llm.provider.embed!(llm.model, batch))); } return results; } export async function embedQuery(text: string): Promise { - const openai = getClient(); - if (!openai) { - return new Array(EMBEDDING_DIM).fill(0) as number[]; - } - const response = await openai.embeddings.create({ - model: OPENAI_EMBED_MODEL, - input: text, - }); - return response.data[0]?.embedding ?? (new Array(EMBEDDING_DIM).fill(0) as number[]); -} - -export function isEmbeddingEnabled(): boolean { - return !!process.env.OPENAI_API_KEY; + const [vector] = await embedTexts([text]); + return vector ?? []; } export function logRagStartupWarnings(): void { if (isEmbeddingEnabled()) { - const vectorOn = process.env.MONGO_VECTOR_SEARCH_ENABLED === "true"; - console.log( - `[rag] OpenAI embeddings enabled${vectorOn ? " — Atlas vector search on" : " — keyword fallback (local MongoDB)"}`, - ); + const llm = getLlm("embedding"); + console.log(`[rag] embeddings: ${llm.providerId}/${llm.model}${llm.local ? " (local)" : ""}`); return; } - console.warn( - "[rag] OPENAI_API_KEY is not set — document embeddings disabled; search_startup_documents uses keyword fallback only", - ); + console.warn("[rag] no embedding model configured — document search uses keyword matching"); } diff --git a/backend/src/lib/rag/extractEntities.ts b/backend/src/lib/rag/extractEntities.ts index ef86684..46159b9 100644 --- a/backend/src/lib/rag/extractEntities.ts +++ b/backend/src/lib/rag/extractEntities.ts @@ -1,5 +1,4 @@ -import Anthropic from "@anthropic-ai/sdk"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; export type ExtractedEntity = { name: string; @@ -7,13 +6,12 @@ export type ExtractedEntity = { context: string; }; -const MAX_TEXT_CHARS = 18000; // ~4500 tokens — enough for Haiku +const MAX_TEXT_CHARS = 18000; // ~4500 tokens — fits small local models too export async function extractEntitiesFromText( text: string, filename: string, ): Promise { - const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); const truncated = text.length > MAX_TEXT_CHARS ? text.slice(0, MAX_TEXT_CHARS) + "\n…[truncated]" : text; const prompt = `Extract all named persons and legal entities (companies, funds, LLCs, trusts, partnerships) from the document below. @@ -34,16 +32,7 @@ ${truncated} Respond with JSON only, no prose.`; - const message = await client.messages.create({ - model: getAnthropicModel(), - max_tokens: 2048, - messages: [{ role: "user", content: prompt }], - }); - - const raw = message.content - .filter((b) => b.type === "text") - .map((b) => (b as { type: "text"; text: string }).text) - .join(""); + const raw = await completeText({ user: prompt, maxTokens: 2048 }); try { const jsonMatch = raw.match(/\[[\s\S]*\]/); diff --git a/backend/src/lib/rag/parseDocument.ts b/backend/src/lib/rag/parseDocument.ts index cca1e09..425e28e 100644 --- a/backend/src/lib/rag/parseDocument.ts +++ b/backend/src/lib/rag/parseDocument.ts @@ -1,5 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/no-require-imports -const pdfParse = require("pdf-parse") as (buffer: Buffer) => Promise<{ text: string; numpages: number }>; import mammoth from "mammoth"; export type ParsedPage = { pageNum: number | null; text: string }; @@ -32,15 +30,37 @@ export async function parseDocumentToText( throw new Error(`Unsupported file type: ${mimeType} (${filename})`); } -async function parsePdf(buffer: Buffer): Promise { - const data = await pdfParse(buffer); - const raw = data.text ?? ""; +type PdfParseModule = typeof import("pdf-parse"); +let pdfModule: Promise | null = null; - // Split by form-feed character for rough page boundaries - const pageSplits = raw.split(/\f/).filter((p: string) => p.trim().length > 0); - if (pageSplits.length === 0) return []; +/** + * Loaded on first use: pdf.js (inside pdf-parse v2) touches DOMMatrix at import + * time, which plain Node lacks. A pure-JS polyfill covers text extraction, and + * lazy loading keeps any PDF-stack problem from taking down the whole backend. + */ +function loadPdfParse(): Promise { + pdfModule ??= (async () => { + const g = globalThis as { DOMMatrix?: unknown }; + if (!g.DOMMatrix) { + const { default: CSSMatrix } = await import("@thednp/dommatrix"); + g.DOMMatrix = CSSMatrix; + } + return import("pdf-parse"); + })(); + return pdfModule; +} - return pageSplits.map((text: string, i: number) => ({ pageNum: i + 1, text: text.trim() })); +async function parsePdf(buffer: Buffer): Promise { + const { PDFParse } = await loadPdfParse(); + const parser = new PDFParse({ data: new Uint8Array(buffer) }); + try { + const result = await parser.getText(); + return result.pages + .map((p) => ({ pageNum: p.num, text: p.text.trim() })) + .filter((p) => p.text.length > 0); + } finally { + await parser.destroy(); + } } async function parseDocx(buffer: Buffer): Promise { diff --git a/backend/src/lib/rag/retrieveChunks.ts b/backend/src/lib/rag/retrieveChunks.ts index ecb16e1..aca813a 100644 --- a/backend/src/lib/rag/retrieveChunks.ts +++ b/backend/src/lib/rag/retrieveChunks.ts @@ -2,7 +2,6 @@ import mongoose from "mongoose"; import { DocChunk } from "../../models/rag/docChunk"; import { embedQuery, isEmbeddingEnabled } from "./embed"; -const VECTOR_SEARCH_ENABLED = process.env.MONGO_VECTOR_SEARCH_ENABLED === "true"; const TOP_K = parseInt(process.env.RAG_TOP_K ?? "6", 10); const MIN_SCORE = 0.60; @@ -21,8 +20,11 @@ export async function retrieveRelevantChunks(params: { }): Promise { const { startupId, query, topK = TOP_K, documentId } = params; - if (VECTOR_SEARCH_ENABLED && isEmbeddingEnabled()) { - return vectorSearch(startupId, query, topK, documentId); + if (isEmbeddingEnabled()) { + // Chunks embedded with a different model (dimension mismatch) are skipped + // by vector search, so fall back to keywords rather than return nothing. + const hits = await vectorSearch(startupId, query, topK, documentId); + if (hits.length) return hits; } return keywordFallback(startupId, query, topK, documentId); } diff --git a/backend/src/lib/screening/explain.ts b/backend/src/lib/screening/explain.ts index 21f3a8c..96613a5 100644 --- a/backend/src/lib/screening/explain.ts +++ b/backend/src/lib/screening/explain.ts @@ -1,5 +1,5 @@ -import { completeClaudeText } from "../llm/claude"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; +import { describeLlmConfig } from "../llm/providers"; import type { WatchmanMatch } from "./watchman"; import type { GraphNode } from "./graph"; import type { RiskLevel } from "./classify"; @@ -83,7 +83,7 @@ Write exactly 3–4 plain-English sentences for a human compliance reviewer (pla Do NOT make a legal determination or confirm a sanctions violation. You are narrating a screening signal for human review, not deciding guilt.`; - screeningLog("Claude narrative (AI explains Watchman match — does not create the match)", { + screeningLog("LLM narrative (AI explains Watchman match — does not create the match)", { entity: entity.name, riskLevel, watchmanMatches: matches.slice(0, 3).map((m) => ({ @@ -91,12 +91,11 @@ Do NOT make a legal determination or confirm a sanctions violation. You are narr score: `${(m.match * 100).toFixed(0)}%`, program: m.programs.join(", ") || "n/a", })), - source: "anthropic-claude", + source: describeLlmConfig().roles.utility?.provider ?? "unconfigured", }); try { - return await completeClaudeText({ - model: getAnthropicModel(), + return await completeText({ systemPrompt: "You are a cautious compliance analyst. Always recommend human verification. Never conclude guilt.", user: prompt, diff --git a/backend/src/lib/screening/inferCsvSchema.ts b/backend/src/lib/screening/inferCsvSchema.ts index 0c5406c..58f9966 100644 --- a/backend/src/lib/screening/inferCsvSchema.ts +++ b/backend/src/lib/screening/inferCsvSchema.ts @@ -1,11 +1,11 @@ -import { completeClaudeText } from "../llm/claude"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; import type { ColumnMapping, CsvKind } from "../../types/screening"; import { UNTRUSTED_DATA_PROMPT_GUARD, wrapCsvData, } from "../shared/promptDelimiters"; import { isExternalLlmBlocked } from "../shared/dataResidency"; +import { hasLlmRole } from "../llm/providers"; export interface InferredCsvSchema { csvKind: CsvKind; @@ -96,10 +96,7 @@ export async function inferCsvSchema(params: { headers: string[]; sampleRows: string[][]; }): Promise { - if (isExternalLlmBlocked()) return null; - - const key = process.env.ANTHROPIC_API_KEY?.trim(); - if (!key) return null; + if (isExternalLlmBlocked() || !hasLlmRole("utility")) return null; const sample = params.sampleRows .slice(0, 8) @@ -117,8 +114,7 @@ export async function inferCsvSchema(params: { .join("\n"); try { - const text = await completeClaudeText({ - model: getAnthropicModel(), + const text = await completeText({ systemPrompt: SYSTEM, user, maxTokens: 1024, diff --git a/backend/src/lib/screening/watchman.ts b/backend/src/lib/screening/watchman.ts index feaef59..83e9386 100644 --- a/backend/src/lib/screening/watchman.ts +++ b/backend/src/lib/screening/watchman.ts @@ -22,7 +22,7 @@ let listInfoCache: { data: WatchmanListInfo; at: number } | null = null; export class WatchmanUnavailableError extends Error { constructor( - message = "Sanctions data source (Watchman) is unavailable. Start Watchman Docker and retry.", + message = "Sanctions screening engine (Watchman) is unavailable. Restart CapScreen; if it persists, check logs/watchman.log.", ) { super(message); this.name = "WatchmanUnavailableError"; @@ -45,7 +45,7 @@ export function getWatchmanBaseUrl(): string { return (process.env.WATCHMAN_URL ?? DEFAULT_BASE).replace(/\/$/, ""); } -/** Ping Watchman at startup — logs whether Docker sanctions data is reachable. */ +/** Ping Watchman at startup — logs whether the local sanctions engine is reachable. */ export async function checkWatchmanHealth(): Promise<{ ok: boolean; url: string; @@ -58,10 +58,10 @@ export async function checkWatchmanHealth(): Promise<{ }); if (resp.ok) { const body = (await resp.text()).trim(); - screeningLog("Watchman reachable — sanctions lists loaded from Docker", { + screeningLog("Watchman reachable — sanctions lists loaded", { url, ping: body || "ok", - source: "watchman-docker", + source: "watchman-local", }); return { ok: true, url, detail: body || "ok" }; } @@ -73,7 +73,7 @@ export async function checkWatchmanHealth(): Promise<{ } catch (err) { const detail = err instanceof Error ? err.message : String(err); screeningWarn( - "Watchman unreachable — screening will return all CLEAR (no sanctions data)", + "Watchman unreachable — screenings are refused until the sanctions engine is available", { url, error: detail }, ); return { ok: false, url, detail }; @@ -264,7 +264,7 @@ async function fetchFromWatchman( const data = (await resp.json()) as Record; const matches = normalizeResponse(data, minMatch); if (matches.length > 0) { - screeningLog("Watchman match (deterministic — from Docker sanctions lists)", { + screeningLog("Watchman match (deterministic — from local sanctions lists)", { entity: name, type, endpoint: label, @@ -273,7 +273,7 @@ async function fetchFromWatchman( topScore: `${(matches[0].match * 100).toFixed(0)}%`, program: matches[0].programs[0] ?? "n/a", sourceList: matches[0].sourceList ?? "n/a", - source: "watchman-docker", + source: "watchman-local", requestId: options.requestId, }); } else { @@ -281,7 +281,7 @@ async function fetchFromWatchman( entity: name, type, endpoint: label, - source: "watchman-docker", + source: "watchman-local", requestId: options.requestId, }); } diff --git a/backend/src/lib/shared/dataResidency.ts b/backend/src/lib/shared/dataResidency.ts index ecd3055..b596550 100644 --- a/backend/src/lib/shared/dataResidency.ts +++ b/backend/src/lib/shared/dataResidency.ts @@ -1,5 +1,20 @@ -/** When set, blocks sending PII to external LLM APIs (Anthropic). */ -export function isExternalLlmBlocked(): boolean { +import { getLlm, type LlmRole } from "../llm/providers"; + +/** When set, PII may not be sent to LLM APIs that run off this machine. */ +export function isDataResidencyRestricted(): boolean { const value = process.env.DATA_RESIDENCY?.trim().toLowerCase(); return value === "restricted" || value === "eu" || value === "in"; } + +/** + * True when the model bound to `role` may not receive PII: residency is + * restricted and the provider is remote. Local providers are always allowed. + */ +export function isExternalLlmBlocked(role: LlmRole = "utility"): boolean { + if (!isDataResidencyRestricted()) return false; + try { + return !getLlm(role).local; + } catch { + return true; + } +} diff --git a/backend/src/lib/tools/documentTypes.ts b/backend/src/lib/tools/documentTypes.ts index 1937422..c6a1669 100644 --- a/backend/src/lib/tools/documentTypes.ts +++ b/backend/src/lib/tools/documentTypes.ts @@ -4,8 +4,7 @@ import { createScreeningAnalysisDocument, } from "../documents/icMemoDocument"; import { buildFactsBlock } from "../documents/generateIcMemo"; -import { completeClaudeText } from "../llm/claude"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; import { toToolDocument } from "./_shared"; import type { ToolContext, ToolDocumentResult, ToolExecutorResult } from "./types"; import type { ScreeningResult } from "../screening/runScreening"; @@ -66,8 +65,7 @@ async function genericClaudeDocument( const facts = buildFactsBlock(startupName, resolved.result); const intentLine = contentIntent ? `Intent: ${contentIntent}\n\n` : ""; - const customContent = await completeClaudeText({ - model: getAnthropicModel(), + const customContent = await completeText({ systemPrompt: "You are a VC compliance analyst. Write the requested document using only the provided screening facts. " + "Never confirm guilt or sanctions violations. Use Markdown formatting.", diff --git a/backend/src/lib/tools/registry.ts b/backend/src/lib/tools/registry.ts index 69933ed..cecc855 100644 --- a/backend/src/lib/tools/registry.ts +++ b/backend/src/lib/tools/registry.ts @@ -1,9 +1,9 @@ -import type Anthropic from "@anthropic-ai/sdk"; +import type { LlmToolSchema } from "../llm/providers"; import type { ToolContext, ToolExecutorResult } from "./types"; export interface ToolDefinition { name: string; - schema: Anthropic.Tool; + schema: LlmToolSchema; handler: (input: unknown, ctx: ToolContext) => Promise; /** Optional routing hint included in the system prompt's "Tool routing" section. */ routingHint?: string; @@ -20,7 +20,7 @@ export class ToolRegistry { return this; } - getTools(): Anthropic.Tool[] { + getTools(): LlmToolSchema[] { return [...this._tools.values()].map((d) => d.schema); } diff --git a/backend/src/lib/tools/searchTools.ts b/backend/src/lib/tools/searchTools.ts index 0a61e0b..56ae4bb 100644 --- a/backend/src/lib/tools/searchTools.ts +++ b/backend/src/lib/tools/searchTools.ts @@ -11,7 +11,8 @@ import { coalesceStartupId } from "../shared/startupId"; import { wrapEntityName } from "../shared/promptDelimiters"; import type { ToolDefinition } from "./registry"; -const CORPUS_DIR = join(__dirname, "../../compliance-corpus"); +// Packaged desktop app: the shell points this at the bundled copy. +const CORPUS_DIR = process.env.CAPSCREEN_CORPUS_DIR ?? join(__dirname, "../../compliance-corpus"); function searchCorpus(query: string): string { let files: string[]; diff --git a/backend/src/loadEnv.ts b/backend/src/loadEnv.ts index ff9d3ba..dffc42f 100644 --- a/backend/src/loadEnv.ts +++ b/backend/src/loadEnv.ts @@ -4,10 +4,14 @@ import path from "path"; const backendRoot = path.resolve(__dirname, ".."); const repoRoot = path.resolve(backendRoot, ".."); -/** Monorepo root `.env` — single source of truth for local dev. */ -config({ path: path.join(repoRoot, ".env") }); -config({ path: path.join(repoRoot, ".env.local"), override: true }); +// Launched by the desktop shell: all configuration arrives via the environment +// and model keys come from the AI Control Center — never read a developer .env. +if (!process.env.CAPSCREEN_DESKTOP_TOKEN) { + /** Monorepo root `.env` — single source of truth for `pnpm dev`. */ + config({ path: path.join(repoRoot, ".env") }); + config({ path: path.join(repoRoot, ".env.local"), override: true }); -/** Optional per-package overrides (legacy); prefer editing root `.env`. */ -config({ path: path.join(backendRoot, ".env"), override: true }); -config({ path: path.join(backendRoot, ".env.local"), override: true }); + /** Optional per-package overrides (legacy); prefer editing root `.env`. */ + config({ path: path.join(backendRoot, ".env"), override: true }); + config({ path: path.join(backendRoot, ".env.local"), override: true }); +} diff --git a/backend/src/middleware/desktopToken.ts b/backend/src/middleware/desktopToken.ts new file mode 100644 index 0000000..0aa9756 --- /dev/null +++ b/backend/src/middleware/desktopToken.ts @@ -0,0 +1,26 @@ +import crypto from "crypto"; +import type { NextFunction, Request, Response } from "express"; + +/** + * The Electron shell generates a random token per launch, passes it to the + * backend as CAPSCREEN_DESKTOP_TOKEN, and attaches it to every renderer request + * as `x-capscreen-token`. Other local processes and web pages can reach + * 127.0.0.1 but cannot know the token. + * + * Unset in `pnpm dev` (no Electron) — the API is then loopback-only but open. + */ +export const DESKTOP_TOKEN_HEADER = "x-capscreen-token"; + +export function desktopToken(req: Request, res: Response, next: NextFunction): void { + const expected = process.env.CAPSCREEN_DESKTOP_TOKEN; + if (!expected || req.method === "OPTIONS" || req.path === "/health" || req.path.startsWith("/api/files/")) { + next(); + return; + } + const got = req.header(DESKTOP_TOKEN_HEADER) ?? ""; + if (got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) { + next(); + return; + } + res.status(403).json({ detail: "Forbidden" }); +} diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index 2caf9bc..8a80d09 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -1,56 +1,53 @@ +import fs from "fs"; import type { NextFunction, Request, Response } from "express"; -import { jwtConfigured, previewModeEnabled } from "../lib/auth/config"; -import { verifyAccessToken } from "../lib/auth/jwt"; -import { connectDb } from "../lib/infra/db"; -import { findUserById } from "../models"; +import { dataFile } from "../lib/infra/dataDir"; -const PREVIEW_USER_ID = "preview-user"; -const PREVIEW_USER_EMAIL = "admin@capscreen.local"; +/** + * Desktop app: one local data owner per installation. Requests are already + * gated by the per-launch desktop token (middleware/desktopToken.ts). + * + * The signed-in CapScreen account (written to identity.json by the desktop + * shell) is used for attribution — audit entries and review decisions record + * who acted — while data ownership stays `local-user`, so records don't vanish + * when a different person signs in on the same machine. + */ +export const LOCAL_USER_ID = "local-user"; +export const LOCAL_USER_EMAIL = "you@capscreen.local"; -function setPreviewUser(res: Response): void { - res.locals.userId = PREVIEW_USER_ID; - res.locals.userEmail = PREVIEW_USER_EMAIL; +type Identity = { email: string; displayName: string | null; organisation: string | null }; +let cached: { mtimeMs: number; identity: Identity | null } | null = null; + +export function signedInIdentity(): Identity | null { + const file = dataFile("identity.json"); + let mtimeMs: number; + try { + mtimeMs = fs.statSync(file).mtimeMs; + } catch { + cached = null; + return null; + } + if (cached?.mtimeMs === mtimeMs) return cached.identity; + let identity: Identity | null = null; + try { + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Partial; + if (typeof raw.email === "string" && raw.email) { + identity = { email: raw.email.toLowerCase(), displayName: raw.displayName ?? null, organisation: raw.organisation ?? null }; + } + } catch { + identity = null; + } + cached = { mtimeMs, identity }; + return identity; } export async function requireAuth( - req: Request, + _req: Request, res: Response, next: NextFunction, ): Promise { - const header = req.headers.authorization; - if (header?.startsWith("Bearer ")) { - const token = header.slice(7).trim(); - if (token) { - if (!jwtConfigured()) { - res.status(500).json({ detail: "Server auth is not configured" }); - return; - } - - try { - await connectDb(); - const claims = verifyAccessToken(token); - const user = await findUserById(claims.sub); - if (!user) { - res.status(401).json({ detail: "Invalid or expired token" }); - return; - } - res.locals.userId = user._id.toString(); - res.locals.userEmail = user.email.toLowerCase(); - res.locals.token = token; - next(); - return; - } catch { - res.status(401).json({ detail: "Invalid or expired token" }); - return; - } - } - } - - if (previewModeEnabled()) { - setPreviewUser(res); - next(); - return; - } - - res.status(401).json({ detail: "Authentication required" }); + const identity = signedInIdentity(); + res.locals.userId = LOCAL_USER_ID; + res.locals.userEmail = identity?.email ?? LOCAL_USER_EMAIL; + res.locals.userName = identity?.displayName ?? null; + next(); } diff --git a/backend/src/middleware/requireJwt.ts b/backend/src/middleware/requireJwt.ts new file mode 100644 index 0000000..689ddcd --- /dev/null +++ b/backend/src/middleware/requireJwt.ts @@ -0,0 +1,37 @@ +import type { NextFunction, Request, Response } from "express"; +import { jwtConfigured } from "../lib/auth/config"; +import { verifyAccessToken } from "../lib/auth/jwt"; +import { connectDb } from "../lib/infra/db"; +import { findUserById } from "../models"; + +/** + * Cloud auth service: require a valid RS256 access token (Authorization: Bearer). + * The desktop app uses middleware/requireAuth.ts (single local user) instead. + */ +export async function requireJwt(req: Request, res: Response, next: NextFunction): Promise { + const header = req.headers.authorization; + const token = header?.startsWith("Bearer ") ? header.slice(7).trim() : ""; + if (!token) { + res.status(401).json({ detail: "Authentication required" }); + return; + } + if (!jwtConfigured()) { + res.status(500).json({ detail: "Server auth is not configured" }); + return; + } + try { + await connectDb(); + const claims = verifyAccessToken(token); + const user = await findUserById(claims.sub); + if (!user) { + res.status(401).json({ detail: "Invalid or expired token" }); + return; + } + res.locals.userId = user._id.toString(); + res.locals.userEmail = user.email.toLowerCase(); + res.locals.token = token; + next(); + } catch { + res.status(401).json({ detail: "Invalid or expired token" }); + } +} diff --git a/backend/src/models/auditLog.ts b/backend/src/models/auditLog.ts deleted file mode 100644 index f93c6a7..0000000 --- a/backend/src/models/auditLog.ts +++ /dev/null @@ -1,37 +0,0 @@ -import mongoose from "mongoose"; - -export type AuditEventType = - | "screening_completed" - | "csv_uploaded" - | "csv_updated" - | "entity_reviewed" - | "portfolio_reviewed" - | "ic_memo_generated"; - -const schema = new mongoose.Schema({ - startupId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Startup", - index: true, - default: null, - }, - eventType: { - type: String, - enum: [ - "screening_completed", - "csv_uploaded", - "csv_updated", - "entity_reviewed", - "portfolio_reviewed", - "ic_memo_generated", - ], - required: true, - }, - performedBy: { type: String, required: true }, - performedByEmail: { type: String, required: true }, - details: { type: mongoose.Schema.Types.Mixed, default: {} }, - createdAt: { type: Date, default: Date.now, index: true }, -}); - -export const AuditLog = - mongoose.models["AuditLog"] ?? mongoose.model("AuditLog", schema); diff --git a/backend/src/models/auth/desktopAuthCode.ts b/backend/src/models/auth/desktopAuthCode.ts new file mode 100644 index 0000000..ba2191c --- /dev/null +++ b/backend/src/models/auth/desktopAuthCode.ts @@ -0,0 +1,24 @@ +import crypto from "crypto"; +import mongoose from "mongoose"; + +/** + * One-time authorization codes for the desktop sign-in flow (RFC 8252 + PKCE). + * Stored hashed; valid for 2 minutes; single use. + */ +const schema = new mongoose.Schema({ + codeHash: { type: String, required: true, unique: true }, + userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + codeChallenge: { type: String, required: true }, + redirectUri: { type: String, required: true }, + expiresAt: { type: Date, required: true }, + usedAt: { type: Date, default: null }, +}); + +schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +export const DesktopAuthCode = + mongoose.models["DesktopAuthCode"] ?? mongoose.model("DesktopAuthCode", schema); + +export function hashAuthCode(code: string): string { + return crypto.createHash("sha256").update(code).digest("hex"); +} diff --git a/backend/src/models/capTableCsv.ts b/backend/src/models/capTableCsv.ts deleted file mode 100644 index 16a497f..0000000 --- a/backend/src/models/capTableCsv.ts +++ /dev/null @@ -1,50 +0,0 @@ -import mongoose from "mongoose"; -import { parseErrorSchema } from "../models/screening/schemas/screening"; - -const schema = new mongoose.Schema({ - startupId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Startup", - required: true, - index: true, - }, - filename: { type: String, required: true }, - content: { type: String, required: true }, - uploadedAt: { type: Date, default: Date.now }, - parseStatus: { - type: String, - enum: ["pending", "valid", "invalid", "needs_review"], - default: "pending", - }, - parseErrors: { type: [parseErrorSchema], default: [] }, - recordCount: { type: Number, default: 0 }, - csvKind: { - type: String, - enum: [ - "ownership_cap_table", - "entity_roster", - "reference_metadata", - "unknown", - ], - default: null, - }, - parseSource: { - type: String, - enum: ["strict", "heuristic", "ai"], - default: null, - }, - confidence: { type: Number, default: null }, - columnMapping: { type: mongoose.Schema.Types.Mixed, default: null }, - normalizedContent: { type: String, default: null }, - ingestWarnings: { type: [String], default: [] }, - rosterPurpose: { - type: String, - enum: ["cap_table", "co_investor", "vendor", "entity_roster"], - default: "cap_table", - }, -}); - -schema.index({ startupId: 1, uploadedAt: -1 }); - -export const CapTableCsv = - mongoose.models["CapTableCsv"] ?? mongoose.model("CapTableCsv", schema); diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index a97eb4d..c73e30c 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -46,3 +46,4 @@ export { } from "./sample/hiddenSampleAsset"; export { RagDocument, type IRagDocument, type RagDocumentStatus } from "./rag/ragDocument"; export { DocChunk, type IDocChunk } from "./rag/docChunk"; +export { DesktopAuthCode, hashAuthCode } from "./auth/desktopAuthCode"; diff --git a/backend/src/models/rag/docChunk.ts b/backend/src/models/rag/docChunk.ts index 0722609..c89788c 100644 --- a/backend/src/models/rag/docChunk.ts +++ b/backend/src/models/rag/docChunk.ts @@ -19,7 +19,7 @@ const DocChunkSchema = new Schema({ mimeType: { type: String, required: true }, chunkIndex: { type: Number, required: true }, chunkText: { type: String, required: true }, - embedding: { type: [Number], required: true }, + embedding: { type: [Number], default: [] }, // empty when no embedding model is configured pageNumber: { type: Number, default: null }, createdAt: { type: Date, default: () => new Date() }, }); @@ -27,7 +27,7 @@ const DocChunkSchema = new Schema({ // Compound index for bulk deletes and ownership queries DocChunkSchema.index({ startupId: 1, documentId: 1 }); -// Text index as keyword-search fallback when Atlas Vector Search is not enabled +// Text index (ignored by the SQLite store; keyword fallback scans chunks) DocChunkSchema.index({ chunkText: "text" }); export const DocChunk = mongoose.model("DocChunk", DocChunkSchema, "doc_chunks"); diff --git a/backend/src/models/schemas/screening.ts b/backend/src/models/schemas/screening.ts deleted file mode 100644 index ec9e990..0000000 --- a/backend/src/models/schemas/screening.ts +++ /dev/null @@ -1,110 +0,0 @@ -import mongoose from "mongoose"; - -export const watchmanMatchSchema = new mongoose.Schema( - { - sdnName: { type: String, required: true }, - sdnType: { type: String, required: true }, - match: { type: Number, required: true }, - programs: { type: [String], default: [] }, - remarks: { type: String, default: null }, - hitSource: { type: String, enum: ["sdn", "altName"], default: "sdn" }, - entityID: { type: String, default: null }, - matchedName: { type: String, default: null }, - title: { type: String, default: null }, - alternateID: { type: String, default: null }, - alternateType: { type: String, default: null }, - alternateName: { type: String, default: null }, - alternateRemarks: { type: String, default: null }, - callSign: { type: String, default: null }, - vesselType: { type: String, default: null }, - tonnage: { type: String, default: null }, - grossRegisteredTonnage: { type: String, default: null }, - vesselFlag: { type: String, default: null }, - vesselOwner: { type: String, default: null }, - }, - { _id: false }, -); - -const ownershipPathStepSchema = new mongoose.Schema( - { - name: { type: String, required: true }, - edgePct: { type: Number, default: null }, - }, - { _id: false }, -); - -export const entityResultSchema = new mongoose.Schema( - { - name: { type: String, required: true }, - type: { type: String, enum: ["person", "company"], required: true }, - riskLevel: { - type: String, - enum: ["clear", "review", "flagged"], - required: true, - }, - topScore: { type: Number, default: null }, - matches: { type: [watchmanMatchSchema], default: [] }, - ownershipPath: { type: [String], default: [] }, - indirectOwnershipPct: { type: Number, default: null }, - ownershipPathSteps: { type: [ownershipPathStepSchema], default: [] }, - ownershipRuleFlags: { type: [String], default: [] }, - exposureStatement: { type: String, default: null }, - role: { type: String, default: null }, - isUltimateOwner: { type: Boolean, default: false }, - ultimateOwner: { type: String, default: null }, - explanation: { type: String, default: null }, - }, - { _id: false }, -); - -export const ownershipEdgeSchema = new mongoose.Schema( - { - from: { type: String, required: true }, - to: { type: String, required: true }, - pct: { type: Number, required: true }, - }, - { _id: false }, -); - -export const screeningResultSchema = new mongoose.Schema( - { - totalEntities: { type: Number, required: true }, - flaggedCount: { type: Number, required: true }, - reviewCount: { type: Number, required: true }, - clearCount: { type: Number, required: true }, - entities: { type: [entityResultSchema], default: [] }, - edges: { type: [ownershipEdgeSchema], default: [] }, - startupName: { type: String, default: null }, - maxSanctionedExposurePct: { type: Number, default: null }, - sanctionedExposureSummary: { type: String, default: null }, - screenedAt: { type: String, default: null }, - csvId: { type: String, default: null }, - screeningMode: { - type: String, - enum: ["ownership_graph", "entity_roster"], - default: null, - }, - csvKind: { type: String, default: null }, - }, - { _id: false }, -); - -export const parseErrorSchema = new mongoose.Schema( - { - row: { type: Number, required: true }, - field: { type: String, default: null }, - message: { type: String, required: true }, - }, - { _id: false }, -); - -export const ownershipRecordSchema = new mongoose.Schema( - { - entity: { type: String, required: true }, - entityType: { type: String, enum: ["person", "company"], required: true }, - owner: { type: String, required: true }, - ownerType: { type: String, enum: ["person", "company"], required: true }, - ownershipPct: { type: Number, required: true }, - }, - { _id: false }, -); diff --git a/backend/src/models/screening/schemas/screening.ts b/backend/src/models/screening/schemas/screening.ts index ec9e990..2a53aee 100644 --- a/backend/src/models/screening/schemas/screening.ts +++ b/backend/src/models/screening/schemas/screening.ts @@ -85,6 +85,9 @@ export const screeningResultSchema = new mongoose.Schema( default: null, }, csvKind: { type: String, default: null }, + // Which sanctions-list versions this result was checked against (counts, + // SHA-256 per list, load time) — audit evidence for "as of" screening. + watchmanListInfo: { type: mongoose.Schema.Types.Mixed, default: null }, }, { _id: false }, ); diff --git a/backend/src/models/startup.ts b/backend/src/models/startup.ts deleted file mode 100644 index 80b4a64..0000000 --- a/backend/src/models/startup.ts +++ /dev/null @@ -1,40 +0,0 @@ -import mongoose from "mongoose"; -import { screeningResultSchema } from "../models/screening/schemas/screening"; - -const schema = new mongoose.Schema({ - name: { type: String, required: true, trim: true }, - ownerId: { type: String, index: true, default: null }, - createdAt: { type: Date, default: Date.now }, - latestCsvId: { - type: mongoose.Schema.Types.ObjectId, - ref: "CapTableCsv", - default: null, - }, - lastScreeningResult: { type: screeningResultSchema, default: null }, - lastScreenedAt: { type: Date, default: null }, - lastScreenedCsvId: { - type: mongoose.Schema.Types.ObjectId, - ref: "CapTableCsv", - default: null, - }, - lastCoInvestorScreeningResult: { - type: screeningResultSchema, - default: null, - }, - lastCoInvestorScreenedAt: { type: Date, default: null }, - lastVendorScreeningResult: { - type: screeningResultSchema, - default: null, - }, - lastVendorScreenedAt: { type: Date, default: null }, - latestCsvByPurpose: { type: mongoose.Schema.Types.Mixed, default: {} }, - portfolioReviewStatus: { - type: String, - enum: ["pending", "cleared", "escalated", "blocked"], - default: "pending", - }, - portfolioReviewNotes: { type: String, default: null }, -}); - -export const Startup = - mongoose.models["Startup"] ?? mongoose.model("Startup", schema); diff --git a/backend/src/models/workflowShare.ts b/backend/src/models/workflowShare.ts deleted file mode 100644 index 1fac64c..0000000 --- a/backend/src/models/workflowShare.ts +++ /dev/null @@ -1,14 +0,0 @@ -import mongoose from "mongoose"; - -const schema = new mongoose.Schema({ - _id: { type: String, required: true }, - workflow_id: { type: String, required: true, index: true }, - shared_by_user_id: { type: String, required: true }, - shared_with_email: { type: String, required: true, index: true }, - allow_edit: { type: Boolean, default: false }, - created_at: { type: String, required: true }, -}); - -export const WorkflowShareModel = - mongoose.models["WorkflowShare"] ?? - mongoose.model("WorkflowShare", schema); diff --git a/backend/src/routes/aiControlCenter.ts b/backend/src/routes/aiControlCenter.ts new file mode 100644 index 0000000..ad66e23 --- /dev/null +++ b/backend/src/routes/aiControlCenter.ts @@ -0,0 +1,373 @@ +import express from "express"; +import { + buildProvider, + describeLlmConfig, + mergeWithPreset, + isProviderLocal, + PROVIDER_PRESETS, + responseText, + type LlmRole, + type ProviderConfig, +} from "../lib/llm/providers"; +import { + deleteProviderKey, + getProviderKey, + providerKeyInfo, + readAiConfig, + setProviderKey, + updateAiConfig, + type StoredProvider, +} from "../lib/llm/controlCenter/store"; +import { + deleteOllamaModel, + detectHardware, + lmStudioStatus, + MODEL_CATALOG, + modelFit, + ollamaCapabilities, + ollamaStatus, + pullOllamaModel, + recommendedChatModel, +} from "../lib/llm/controlCenter/localRuntime"; +import { friendlyLlmError } from "../lib/llm/friendlyError"; + +/** + * AI Control Center — the user's own model providers, keys, and assignments. + * Keys go in, never out: responses carry only "configured" + a 4-char hint. + */ +export const aiControlCenterRouter = express.Router(); + +const ROLES: LlmRole[] = ["agent", "utility", "embedding"]; +const ID_RE = /^[a-z0-9][a-z0-9-]{1,39}$/; + +/** Providers that need no key (local servers). */ +const KEYLESS = new Set(["ollama", "lmstudio", "llamacpp"]); + +function storedProviders(): StoredProvider[] { + return readAiConfig()?.providers ?? []; +} + +/** Full provider config for `id`: stored overrides on top of the preset, plus the decrypted key. */ +function resolveProvider(id: string): ProviderConfig | null { + const stored = storedProviders().find((p) => p.id === id); + const preset = PROVIDER_PRESETS[id]; + if (!stored && !preset) return null; + const merged = mergeWithPreset({ id, ...(preset ?? {}), ...(stored ?? {}) } as ProviderConfig); + const key = getProviderKey(id); + return key ? { ...merged, apiKey: key } : merged; +} + +function providerView(p: ProviderConfig, enabled: boolean) { + const key = providerKeyInfo(p.id); + const keyless = KEYLESS.has(p.id) || isProviderLocal(p); + return { + id: p.id, + label: p.label ?? p.id, + kind: p.kind, + baseUrl: p.baseUrl ?? null, + local: isProviderLocal(p), + preset: p.id in PROVIDER_PRESETS, + enabled, + needsKey: !keyless, + key: { configured: key.configured, hint: key.hint, updatedAt: key.updatedAt }, + // Dev only: an env var can stand in for an in-app key. + envKey: !key.configured && !!p.apiKeyEnv && !!process.env[p.apiKeyEnv]?.trim(), + }; +} + +aiControlCenterRouter.get("/state", (_req, res) => { + const cfg = readAiConfig(); + const stored = storedProviders(); + const ids = [...new Set([...Object.keys(PROVIDER_PRESETS), ...stored.map((p) => p.id)])]; + res.json({ + configured: !!cfg, + localOnly: cfg?.localOnly ?? false, + roles: cfg?.roles ?? {}, + effective: describeLlmConfig(), + providers: ids.map((id) => providerView(resolveProvider(id)!, stored.some((p) => p.id === id))), + }); +}); + +aiControlCenterRouter.put("/providers/:id", (req, res) => { + const id = req.params.id; + if (!ID_RE.test(id)) { + res.status(400).json({ detail: "Provider id must be 2–40 lowercase letters, digits or dashes" }); + return; + } + const { label, baseUrl, kind, local } = req.body as Partial; + const preset = PROVIDER_PRESETS[id]; + if (!preset && !baseUrl) { + res.status(400).json({ detail: "Custom providers need a base URL" }); + return; + } + if (baseUrl && !/^https?:\/\//.test(baseUrl)) { + res.status(400).json({ detail: "Base URL must start with http:// or https://" }); + return; + } + updateAiConfig((cfg) => { + const next: StoredProvider = { + id, + kind: preset?.kind ?? (kind === "anthropic" ? "anthropic" : "openai-compatible"), + ...(label ? { label: String(label).slice(0, 60) } : {}), + ...(baseUrl ? { baseUrl: baseUrl.replace(/\/$/, "") } : {}), + ...(typeof local === "boolean" ? { local } : {}), + }; + cfg.providers = [...cfg.providers.filter((p) => p.id !== id), next]; + }); + res.json({ ok: true }); +}); + +aiControlCenterRouter.delete("/providers/:id", (req, res) => { + const id = req.params.id; + deleteProviderKey(id); + updateAiConfig((cfg) => { + cfg.providers = cfg.providers.filter((p) => p.id !== id); + for (const role of ROLES) if (cfg.roles[role]?.provider === id) delete cfg.roles[role]; + }); + res.json({ ok: true }); +}); + +aiControlCenterRouter.put("/providers/:id/key", (req, res) => { + const id = req.params.id; + const apiKey = String((req.body as { apiKey?: unknown }).apiKey ?? "").trim(); + if (!resolveProvider(id)) { + res.status(404).json({ detail: "Unknown provider" }); + return; + } + if (apiKey.length < 8 || apiKey.length > 512 || /\s/.test(apiKey)) { + res.status(400).json({ detail: "That does not look like an API key" }); + return; + } + setProviderKey(id, apiKey); + updateAiConfig((cfg) => { + if (!cfg.providers.some((p) => p.id === id)) { + const preset = PROVIDER_PRESETS[id]; + cfg.providers.push({ id, kind: preset?.kind ?? "openai-compatible" }); + } + }); + res.json({ ok: true, key: providerKeyInfo(id) }); +}); + +aiControlCenterRouter.delete("/providers/:id/key", (req, res) => { + deleteProviderKey(req.params.id); + res.json({ ok: true }); +}); + +aiControlCenterRouter.get("/providers/:id/models", async (req, res) => { + const pc = resolveProvider(req.params.id); + if (!pc) { + res.status(404).json({ detail: "Unknown provider" }); + return; + } + try { + const provider = buildProvider(pc); + const models = (await provider.listModels?.()) ?? []; + res.json({ models: models.sort() }); + } catch (err) { + res.status(502).json({ detail: friendlyLlmError(err) }); + } +}); + +/** One-shot connectivity + latency check, optionally against a specific model. */ +aiControlCenterRouter.post("/providers/:id/test", async (req, res) => { + const pc = resolveProvider(req.params.id); + if (!pc) { + res.status(404).json({ detail: "Unknown provider" }); + return; + } + const model = (req.body as { model?: string }).model; + const started = Date.now(); + try { + const provider = buildProvider(pc); + if (model) { + const reply = await provider.complete({ + model, + maxTokens: 16, + messages: [{ role: "user", content: "Reply with the single word: ready" }], + }); + res.json({ ok: true, latencyMs: Date.now() - started, reply: responseText(reply).trim().slice(0, 80) }); + } else { + const models = (await provider.listModels?.()) ?? []; + res.json({ ok: true, latencyMs: Date.now() - started, modelCount: models.length }); + } + } catch (err) { + res.json({ ok: false, latencyMs: Date.now() - started, error: friendlyLlmError(err) }); + } +}); + +/** Does this model actually call tools? Forces a trivial tool call and checks. */ +aiControlCenterRouter.post("/probe-tools", async (req, res) => { + const { provider: id, model } = req.body as { provider?: string; model?: string }; + const pc = id ? resolveProvider(id) : null; + if (!pc || !model) { + res.status(400).json({ detail: "provider and model are required" }); + return; + } + if (pc.id === "ollama") { + const caps = await ollamaCapabilities(model); + if (caps) { + res.json({ supportsTools: caps.includes("tools"), source: "ollama" }); + return; + } + } + try { + const reply = await buildProvider(pc).complete({ + model, + maxTokens: 64, + messages: [{ role: "user", content: "What time is it? Use the tool." }], + tools: [{ name: "get_time", description: "Returns the current time", input_schema: { type: "object", properties: {} } }], + toolChoice: { type: "auto" }, + }); + res.json({ supportsTools: reply.content.some((b) => b.type === "tool_use"), source: "probe" }); + } catch (err) { + const msg = friendlyLlmError(err); + if (/tool/i.test(msg)) res.json({ supportsTools: false, source: "probe", detail: msg }); + else res.status(502).json({ detail: msg }); + } +}); + +aiControlCenterRouter.put("/roles", async (req, res) => { + const body = req.body as Partial>; + for (const role of Object.keys(body)) { + if (!ROLES.includes(role as LlmRole)) { + res.status(400).json({ detail: `Unknown role "${role}"` }); + return; + } + } + for (const role of ROLES) { + const b = body[role]; + if (b && (!resolveProvider(b.provider) || !b.model?.trim())) { + res.status(400).json({ detail: `Role "${role}" needs a known provider and a model` }); + return; + } + } + + // Fill in tool support for Ollama models when the caller did not say. + for (const role of ["agent", "utility"] as const) { + const b = body[role]; + if (b && b.supportsTools === undefined && b.provider === "ollama") { + const caps = await ollamaCapabilities(b.model); + if (caps) b.supportsTools = caps.includes("tools"); + } + } + + updateAiConfig((cfg) => { + for (const role of ROLES) { + if (!(role in body)) continue; + const b = body[role]; + if (!b) { + delete cfg.roles[role]; + continue; + } + cfg.roles[role] = { provider: b.provider, model: b.model.trim(), ...(b.supportsTools !== undefined ? { supportsTools: b.supportsTools } : {}) }; + if (!cfg.providers.some((p) => p.id === b.provider)) { + const preset = PROVIDER_PRESETS[b.provider]; + cfg.providers.push({ id: b.provider, kind: preset?.kind ?? "openai-compatible" }); + } + } + }); + res.json({ ok: true, effective: describeLlmConfig() }); +}); + +aiControlCenterRouter.put("/policy", (req, res) => { + const { localOnly } = req.body as { localOnly?: boolean }; + updateAiConfig((cfg) => { + if (typeof localOnly === "boolean") cfg.localOnly = localOnly; + }); + res.json({ ok: true, effective: describeLlmConfig() }); +}); + +// ── On-device runtimes ─────────────────────────────────────────────────────── + +aiControlCenterRouter.get("/local", async (_req, res) => { + const hw = detectHardware(); + const [ollama, lmstudio] = await Promise.all([ollamaStatus(), lmStudioStatus()]); + const installed = new Set(ollama.models.map((m) => m.name.replace(/:latest$/, ""))); + res.json({ + hardware: hw, + ollama, + lmstudio, + recommended: recommendedChatModel(hw), + catalog: MODEL_CATALOG.map((m) => ({ ...m, fit: modelFit(m, hw), installed: installed.has(m.id) })), + }); +}); + +/** SSE: streams `ollama pull` progress as { status, completed, total, percent }. */ +aiControlCenterRouter.post("/local/pull", async (req, res) => { + const model = String((req.body as { model?: unknown }).model ?? "").trim(); + if (!/^[\w.\-/]+(:[\w.\-]+)?$/.test(model)) { + res.status(400).json({ detail: "Invalid model name" }); + return; + } + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + + const abort = new AbortController(); + res.on("close", () => abort.abort()); + const send = (o: unknown) => res.write(`data: ${JSON.stringify(o)}\n\n`); + + try { + await pullOllamaModel( + model, + (p) => { + const percent = p.total ? Math.round(((p.completed ?? 0) / p.total) * 1000) / 10 : null; + send({ type: "progress", status: p.status, completed: p.completed ?? null, total: p.total ?? null, percent }); + }, + abort.signal, + ); + send({ type: "done", model }); + } catch (err) { + if (!abort.signal.aborted) send({ type: "error", detail: friendlyLlmError(err) }); + } + res.end(); +}); + +aiControlCenterRouter.delete("/local/models/:name", async (req, res) => { + try { + await deleteOllamaModel(req.params.name); + res.json({ ok: true }); + } catch (err) { + res.status(502).json({ detail: friendlyLlmError(err) }); + } +}); + +/** + * One-click on-device setup: Ollama for chat (best installed tool-capable model) + * and embeddings, Local-only mode on. + */ +aiControlCenterRouter.post("/quick-setup/local", async (_req, res) => { + const ollama = await ollamaStatus(); + if (!ollama.running) { + res.status(409).json({ detail: "Ollama is not running. Install it from ollama.com and open it, then try again." }); + return; + } + const names = ollama.models.map((m) => m.name); + const withTools: Array<{ name: string; sizeGB: number }> = []; + for (const m of ollama.models) { + const caps = await ollamaCapabilities(m.name); + if (caps?.includes("tools")) withTools.push(m); + } + const hw = detectHardware(); + // Largest tool-capable model that still fits in memory. + const chat = withTools + .filter((m) => m.sizeGB + 2.5 <= hw.totalMemGB) + .sort((a, b) => b.sizeGB - a.sizeGB)[0]?.name; + if (!chat) { + res.status(409).json({ + detail: `No tool-capable chat model is installed. Download ${recommendedChatModel(hw)} from the Local models section first.`, + }); + return; + } + const embed = names.find((n) => /embed/.test(n)); + + updateAiConfig((cfg) => { + if (!cfg.providers.some((p) => p.id === "ollama")) cfg.providers.push({ id: "ollama", kind: "ollama" }); + cfg.roles.agent = { provider: "ollama", model: chat, supportsTools: true }; + cfg.roles.utility = { provider: "ollama", model: chat, supportsTools: true }; + if (embed) cfg.roles.embedding = { provider: "ollama", model: embed }; + cfg.localOnly = true; + }); + res.json({ ok: true, chat, embed: embed ?? null, effective: describeLlmConfig() }); +}); diff --git a/backend/src/routes/assistantChat.ts b/backend/src/routes/assistantChat.ts index 36e52f5..41549ef 100644 --- a/backend/src/routes/assistantChat.ts +++ b/backend/src/routes/assistantChat.ts @@ -1,10 +1,10 @@ import { randomUUID } from "crypto"; import express from "express"; -import type Anthropic from "@anthropic-ai/sdk"; +import type { LlmMessage } from "../lib/llm/providers"; import { assistantRegistry, type ToolContext } from "../lib/tools"; import { buildSystemPrompt } from "../lib/chat/buildSystemPrompt"; import { runAgentStream } from "../lib/llm/streamAgent"; -import { getAnthropicModel } from "../lib/llm/models"; +import { friendlyLlmError } from "../lib/llm/friendlyError"; import { connectDb } from "../lib/infra/db"; import { buildWorkflowStore } from "../lib/workflows/workflowMemory"; import { @@ -76,7 +76,7 @@ function parseAssistantMessages( return { ok: true, messages: out }; } -function toAnthropicMessages(messages: ChatMessage[]): Anthropic.MessageParam[] { +function toLlmMessages(messages: ChatMessage[]): LlmMessage[] { return messages.map((m) => { let content = m.content; if (m.role === "user" && m.workflow?.id) { @@ -238,9 +238,8 @@ assistantChatRouter.post("/", async (req, res) => { try { await runAgentStream({ - model: getAnthropicModel(), systemPrompt: system, - messages: toAnthropicMessages(validMessages), + messages: toLlmMessages(validMessages), tools: assistantRegistry.getTools(), write, executeTool: async (name, input) => { @@ -267,11 +266,7 @@ assistantChatRouter.post("/", async (req, res) => { write("data: [DONE]\n\n"); } catch (err) { console.error("[assistantChat] error:", err); - let message = err instanceof Error ? err.message : "Stream error"; - if (message.includes("authentication_error") || message.includes("401")) { - message = - "Anthropic API key invalid. Set ANTHROPIC_API_KEY in backend/.env, then restart (pnpm dev)."; - } + const message = friendlyLlmError(err); write(`data: ${JSON.stringify({ type: "error", message })}\n\n`); write("data: [DONE]\n\n"); } finally { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 1df5b49..560c997 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from "express"; import rateLimit from "express-rate-limit"; import { connectDb } from "../lib/infra/db"; -import { requireAuth } from "../middleware/requireAuth"; +import { requireJwt } from "../middleware/requireJwt"; import { generateAuthTokens, serializeAuthTokens, @@ -325,7 +325,7 @@ authRouter.post("/reset-password", emailActionLimiter, async (req, res) => { } }); -authRouter.post("/signout", signoutLimiter, requireAuth, async (req, res) => { +authRouter.post("/signout", signoutLimiter, requireJwt, async (req, res) => { const refreshToken = readRefreshToken(req); if (!refreshToken) { clearRefreshTokenCookie(res); @@ -385,7 +385,7 @@ authRouter.post("/refresh-tokens", refreshLimiter, async (req, res) => { } }); -authRouter.get("/me", requireAuth, async (req, res) => { +authRouter.get("/me", requireJwt, async (req, res) => { const userId = res.locals.userId as string | undefined; if (!userId) { res.status(401).json({ detail: "Authentication required" }); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 4c5a8d8..62f0cae 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -1,7 +1,6 @@ import express from "express"; -import type Anthropic from "@anthropic-ai/sdk"; +import type { LlmMessage, LlmToolChoice } from "../lib/llm/providers"; import { runAgentStream } from "../lib/llm/streamAgent"; -import { getAnthropicModel } from "../lib/llm/models"; import { screenerRegistry, SCREEN_CAP_TABLE_TOOL_NAME, @@ -37,9 +36,9 @@ const BASE_SYSTEM = `- When you call web_search, you MUST include every source URL from the results as a markdown hyperlink [title](url) in your response. Never summarise web results without citing links.\n\n` + `CSV ingest: any CSV format is accepted — the system auto-detects cap tables (entity + owner + ownership %) or flat entity lists.`; -function toAnthropicMessages( +function toLlmMessages( messages: Array<{ role: "user" | "assistant"; content: string }>, -): Anthropic.MessageParam[] { +): LlmMessage[] { return messages.map((m) => ({ role: m.role, content: m.content, @@ -136,16 +135,15 @@ chatRouter.post("/", async (req, res) => { // Force screen_cap_table on the first turn when a CSV is attached and no // prior result exists — makes the critical routing case deterministic. - const toolChoice: Anthropic.ToolChoiceTool | undefined = + const toolChoice: LlmToolChoice | undefined = ctx.csvContent && !ctx.screeningResult ? { type: "tool", name: SCREEN_CAP_TABLE_TOOL_NAME } : undefined; try { const { toolActivity } = await runAgentStream({ - model: getAnthropicModel(), systemPrompt: system, - messages: toAnthropicMessages(validMessages), + messages: toLlmMessages(validMessages), tools: screenerRegistry.getTools(), executeTool: wrappedExecute, write, diff --git a/backend/src/routes/config.ts b/backend/src/routes/config.ts index c2f3e09..bce909b 100644 --- a/backend/src/routes/config.ts +++ b/backend/src/routes/config.ts @@ -1,18 +1,15 @@ import express from "express"; -import { getAnthropicModel } from "../lib/llm/models"; +import { describeLlmConfig } from "../lib/llm/providers"; import { getScreeningThresholds } from "../lib/screening/screeningConfig"; import { isEmbeddingEnabled } from "../lib/rag/embed"; import { isWebSearchEnabled } from "../lib/infra/webSearchConfig"; export const configRouter = express.Router(); -/** Public runtime config — model selection lives on the server only. */ +/** Public runtime config — provider/model names only, never keys. */ configRouter.get("/", (_req, res) => { res.json({ - llm: { - provider: "anthropic", - model: getAnthropicModel(), - }, + llm: describeLlmConfig(), screening: getScreeningThresholds(), ragEnabled: isEmbeddingEnabled(), webSearchEnabled: isWebSearchEnabled(), diff --git a/backend/src/routes/desktopData.ts b/backend/src/routes/desktopData.ts new file mode 100644 index 0000000..b5486e2 --- /dev/null +++ b/backend/src/routes/desktopData.ts @@ -0,0 +1,155 @@ +import crypto from "crypto"; +import fs from "fs"; +import { dataFile } from "../lib/infra/dataDir"; +import express from "express"; +import { createBackup, stageRestore } from "../lib/infra/backup"; +import { getWatchFolder, scanWatchedFolders, setWatchFolder } from "../lib/infra/folderWatch"; +import { AVAILABLE_SANCTIONS_LISTS, readSettings, writeSettings } from "../lib/infra/settings"; +import { databaseFile } from "../lib/infra/db"; +import { fetchWatchmanListInfo, getWatchmanBaseUrl } from "../lib/screening/watchman"; +import { AuditLog } from "../models"; +import { ownerFilter } from "./startups/middleware"; +import { Startup } from "../models"; + +/** Desktop-only: settings, sanctions data, backup/restore, watched folders, verified audit export. */ +export const desktopDataRouter = express.Router(); + +/** Exit code the Electron supervisor treats as "restart me" (not a crash). */ +export const RESTART_EXIT_CODE = 75; + +desktopDataRouter.get("/settings", (_req, res) => { + res.json({ settings: readSettings(), availableLists: AVAILABLE_SANCTIONS_LISTS, databaseFile: databaseFile() }); +}); + +desktopDataRouter.put("/settings", (req, res) => { + const body = req.body as { backgroundScreening?: { enabled?: unknown; intervalHours?: unknown }; sanctionsLists?: unknown }; + const patch: Parameters[0] = {}; + if (body.backgroundScreening) { + const cur = readSettings().backgroundScreening; + const hours = Number(body.backgroundScreening.intervalHours ?? cur.intervalHours); + patch.backgroundScreening = { + enabled: typeof body.backgroundScreening.enabled === "boolean" ? body.backgroundScreening.enabled : cur.enabled, + intervalHours: Number.isFinite(hours) ? Math.min(24 * 30, Math.max(1, Math.round(hours))) : cur.intervalHours, + }; + } + if (body.sanctionsLists !== undefined) { + const valid = new Set(AVAILABLE_SANCTIONS_LISTS.map((l) => l.id)); + const lists = Array.isArray(body.sanctionsLists) ? body.sanctionsLists.map(String).filter((l) => valid.has(l)) : []; + if (!lists.length) { + res.status(400).json({ detail: "Choose at least one sanctions list" }); + return; + } + patch.sanctionsLists = lists; + } + // The desktop shell watches settings.json and reloads Watchman when lists change. + res.json({ settings: writeSettings(patch) }); +}); + +/** Written by the desktop shell: "online", or "offline" when using its list snapshot. */ +function engineStatus(): { mode: "online" | "offline"; cached: Record } { + try { + const raw = JSON.parse(fs.readFileSync(dataFile("watchman", "status.json"), "utf8")) as { mode?: string; cached?: Record }; + return { mode: raw.mode === "offline" ? "offline" : "online", cached: raw.cached ?? {} }; + } catch { + return { mode: "online", cached: {} }; + } +} + +desktopDataRouter.get("/sanctions", async (_req, res) => { + const info = await fetchWatchmanListInfo(); + res.json({ + engineUrl: getWatchmanBaseUrl(), + available: !!info, + info, + configured: readSettings().sanctionsLists, + ...engineStatus(), + }); +}); + +desktopDataRouter.post("/backup", (req, res) => { + const password = String((req.body as { password?: unknown }).password ?? ""); + try { + const { file, manifest } = createBackup(password); + const stamp = manifest.createdAt.slice(0, 10); + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Content-Disposition", `attachment; filename="capscreen-${stamp}.capscreen-backup"`); + res.setHeader("X-Backup-Entries", String(manifest.entries.length)); + res.send(file); + } catch (err) { + res.status(400).json({ detail: err instanceof Error ? err.message : "Backup failed" }); + } +}); + +desktopDataRouter.post( + "/restore", + express.raw({ type: "application/octet-stream", limit: "2gb" }), + (req, res) => { + const password = req.header("x-backup-password") ?? ""; + try { + const manifest = stageRestore(req.body as Buffer, decodeURIComponent(password)); + res.json({ ok: true, createdAt: manifest.createdAt, entries: manifest.entries.length, restarting: true }); + // Swap happens before the database opens on the next start. + setTimeout(() => process.exit(RESTART_EXIT_CODE), 300); + } catch (err) { + res.status(400).json({ detail: err instanceof Error ? err.message : "Restore failed" }); + } + }, +); + +desktopDataRouter.get("/watch-folders/:startupId", (req, res) => { + res.json({ watch: getWatchFolder(req.params.startupId) }); +}); + +desktopDataRouter.put("/watch-folders/:startupId", async (req, res) => { + const startup = await Startup.findOne({ _id: req.params.startupId, ...ownerFilter(res.locals.userId as string) }).lean(); + if (!startup) { + res.status(404).json({ detail: "Startup not found" }); + return; + } + const folder = (req.body as { path?: string | null }).path ?? null; + try { + setWatchFolder(req.params.startupId, folder); + if (folder) void scanWatchedFolders(); + res.json({ watch: getWatchFolder(req.params.startupId) }); + } catch (err) { + res.status(400).json({ detail: err instanceof Error ? err.message : "Could not watch folder" }); + } +}); + +/** + * Tamper-evident audit export: every entry carries sha256(prev_hash + entry), + * and the final hash seals the file. Changing, removing or reordering any line + * breaks the chain from that point on. + */ +desktopDataRouter.get("/audit-export", async (req, res) => { + const { startupId } = req.query as { startupId?: string }; + const query = startupId ? { startupId } : {}; + const logs = (await AuditLog.find(query).sort({ createdAt: 1 }).lean()) as Array>; + + let prev = "0".repeat(64); + const entries = logs.map((d) => { + const entry = { + id: String(d._id), + startupId: d.startupId ? String(d.startupId) : null, + eventType: d.eventType, + performedBy: d.performedBy, + createdAt: new Date(d.createdAt as Date).toISOString(), + details: d.details ?? {}, + }; + const hash = crypto.createHash("sha256").update(prev + JSON.stringify(entry)).digest("hex"); + prev = hash; + return { ...entry, hash }; + }); + + const doc = { + format: "capscreen-audit-v1", + exportedAt: new Date().toISOString(), + scope: startupId ? { startupId } : "all", + count: entries.length, + chain: { algorithm: "sha256(prevHash + JSON(entry))", genesis: "0".repeat(64), finalHash: prev }, + entries, + }; + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Disposition", `attachment; filename="capscreen-audit-${doc.exportedAt.slice(0, 10)}.json"`); + res.send(JSON.stringify(doc, null, 2)); +}); diff --git a/backend/src/routes/desktopOAuth.ts b/backend/src/routes/desktopOAuth.ts new file mode 100644 index 0000000..ade16f2 --- /dev/null +++ b/backend/src/routes/desktopOAuth.ts @@ -0,0 +1,166 @@ +import crypto from "crypto"; +import { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { connectDb } from "../lib/infra/db"; +import { generateAuthTokens } from "../lib/auth/tokens"; +import { requireJwt } from "../middleware/requireJwt"; +import { + DesktopAuthCode, + findUserById, + findValidRefreshToken, + hashAuthCode, + revokeRefreshToken, + serializePublicUser, +} from "../models"; + +/** + * Desktop sign-in, the way native apps (and Claude Code) do it — RFC 8252: + * + * 1. The app opens the browser at /desktop/authorize with a loopback + * redirect_uri, a random state, and a PKCE S256 code_challenge. + * 2. The user signs in on the portal; the portal calls POST /authorize-code + * with their access token and gets a one-time code. + * 3. The browser is sent to http://127.0.0.1:/callback?code&state. + * 4. The app exchanges code + code_verifier at POST /token for a session. + */ +export const desktopOAuthRouter = Router(); + +const CODE_TTL_MS = 2 * 60 * 1000; +const DESKTOP_REFRESH_DAYS = Number.parseInt(process.env.DESKTOP_REFRESH_TOKEN_EXPIRATION_DAYS ?? "30", 10); + +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { detail: "Too many requests. Please try again later." }, +}); + +desktopOAuthRouter.use(limiter); +desktopOAuthRouter.use(async (_req, _res, next) => { + try { + await connectDb(); + next(); + } catch (err) { + next(err); + } +}); + +/** Only loopback HTTP callbacks — the code can never be sent to another host. */ +export function isLoopbackRedirect(uri: unknown): uri is string { + if (typeof uri !== "string") return false; + try { + const u = new URL(uri); + return ( + u.protocol === "http:" && + (u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "[::1]") && + u.port !== "" && + u.pathname === "/callback" && + !u.username && + !u.password + ); + } catch { + return false; + } +} + +const B64URL = /^[A-Za-z0-9_-]{43,128}$/; + +desktopOAuthRouter.post("/authorize-code", requireJwt, async (req, res) => { + const { redirect_uri, state, code_challenge, code_challenge_method } = req.body as Record; + if (!isLoopbackRedirect(redirect_uri)) { + res.status(400).json({ detail: "redirect_uri must be a loopback /callback URL" }); + return; + } + if (code_challenge_method !== "S256" || typeof code_challenge !== "string" || !B64URL.test(code_challenge)) { + res.status(400).json({ detail: "A PKCE S256 code_challenge is required" }); + return; + } + if (typeof state !== "string" || state.length < 16 || state.length > 256) { + res.status(400).json({ detail: "Invalid state" }); + return; + } + + const code = crypto.randomBytes(32).toString("base64url"); + await DesktopAuthCode.create({ + codeHash: hashAuthCode(code), + userId: res.locals.userId, + codeChallenge: code_challenge, + redirectUri: redirect_uri, + expiresAt: new Date(Date.now() + CODE_TTL_MS), + }); + + const target = new URL(redirect_uri); + target.searchParams.set("code", code); + target.searchParams.set("state", state); + res.json({ redirect: target.toString() }); +}); + +async function sessionResponse(user: Parameters[0]) { + const issued = await generateAuthTokens(user, DESKTOP_REFRESH_DAYS); + return { + user: serializePublicUser(user), + accessToken: issued.accessToken.token, + accessExpires: issued.accessToken.expires, + refreshToken: issued.refreshTokenValue, + refreshExpires: issued.refreshExpires.toISOString(), + }; +} + +desktopOAuthRouter.post("/token", async (req, res) => { + const { code, code_verifier, redirect_uri } = req.body as Record; + if (typeof code !== "string" || typeof code_verifier !== "string" || typeof redirect_uri !== "string") { + res.status(400).json({ detail: "code, code_verifier and redirect_uri are required" }); + return; + } + // Atomically claim the code so it can only ever be redeemed once. + const record = (await DesktopAuthCode.findOneAndUpdate( + { codeHash: hashAuthCode(code), usedAt: null, expiresAt: { $gt: new Date() } }, + { $set: { usedAt: new Date() } }, + { new: false }, + ).lean()) as { userId: unknown; codeChallenge: string; redirectUri: string } | null; + if (!record || record.redirectUri !== redirect_uri) { + res.status(400).json({ detail: "Invalid or expired code" }); + return; + } + const challenge = crypto.createHash("sha256").update(code_verifier).digest("base64url"); + const a = Buffer.from(challenge); + const b = Buffer.from(record.codeChallenge); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { + res.status(400).json({ detail: "PKCE verification failed" }); + return; + } + const user = await findUserById(String(record.userId)); + if (!user) { + res.status(400).json({ detail: "Account no longer exists" }); + return; + } + res.json(await sessionResponse(user)); +}); + +/** Rotate a desktop refresh token (sent in the body; desktops have no cookie jar). */ +desktopOAuthRouter.post("/refresh", async (req, res) => { + const refreshToken = (req.body as { refreshToken?: unknown }).refreshToken; + if (typeof refreshToken !== "string" || !refreshToken) { + res.status(401).json({ detail: "Invalid or expired session" }); + return; + } + const tokenDoc = await findValidRefreshToken(refreshToken); + if (!tokenDoc) { + res.status(401).json({ detail: "Invalid or expired session" }); + return; + } + const user = await findUserById(tokenDoc.userId.toString()); + await tokenDoc.deleteOne(); + if (!user) { + res.status(401).json({ detail: "Invalid or expired session" }); + return; + } + res.json(await sessionResponse(user)); +}); + +desktopOAuthRouter.post("/revoke", async (req, res) => { + const refreshToken = (req.body as { refreshToken?: unknown }).refreshToken; + if (typeof refreshToken === "string" && refreshToken) await revokeRefreshToken(refreshToken).catch(() => {}); + res.json({ ok: true }); +}); diff --git a/backend/src/routes/documents/singleDocuments.ts b/backend/src/routes/documents/singleDocuments.ts index 6994eb0..eb91276 100644 --- a/backend/src/routes/documents/singleDocuments.ts +++ b/backend/src/routes/documents/singleDocuments.ts @@ -11,9 +11,9 @@ import { deleteObject, detectFileType, getObjectDownloadUrl, - r2Configured, + storageConfigured, uploadObject, -} from "../../lib/infra/r2Storage"; +} from "../../lib/infra/fileStorage"; import { toAppDocument } from "./serializers"; export const singleDocumentsRouter = Router(); @@ -44,7 +44,7 @@ singleDocumentsRouter.get("/", async (req, res) => { }); singleDocumentsRouter.post("/", upload.single("file"), async (req, res) => { - if (!r2Configured()) { + if (!storageConfigured()) { res.status(503).json({ detail: "Object storage is not configured" }); return; } @@ -153,7 +153,7 @@ projectDocumentsRouter.post( "/:projectId/documents", upload.single("file"), async (req, res) => { - if (!r2Configured()) { + if (!storageConfigured()) { res.status(503).json({ detail: "Object storage is not configured" }); return; } diff --git a/backend/src/routes/startups.ts b/backend/src/routes/startups.ts deleted file mode 100644 index 1730488..0000000 --- a/backend/src/routes/startups.ts +++ /dev/null @@ -1,805 +0,0 @@ -import express from "express"; -import mongoose from "mongoose"; -import { connectDb } from "../lib/infra/db"; -import { deriveParseStatus, ingestCsv, toIngestApiResponse } from "../lib/screening/csvIngest"; -import { runScreening } from "../lib/screening/runScreening"; -import { syncPortfolioMonitoringReview } from "../lib/portfolio/portfolioGrid"; -import { detectRosterPurpose } from "../lib/screening/rosterPurpose"; -import { runPortfolioRescreen } from "../lib/portfolio/rescreenScheduler"; -import { extractCsvTable } from "../lib/screening/parseCapTable"; -import { - computeScreeningDelta, - getStartupScreeningDelta, - saveScreeningSnapshot, -} from "../lib/screening/screeningDelta"; -import { syncEntityReviewStatus } from "../lib/tabular/reviewStatusSync"; -import { Startup } from "../models/startup"; -import { CapTableCsv } from "../models/capTableCsv"; -import { StartupChat } from "../models/chat/startupChat"; -import { EntityReview } from "../models/screening/entityReview"; -import { AuditLog } from "../models/audit/auditLog"; -import { StartupDocument } from "../models/documents/startupDocument"; -import { - createIcMemoDocument, - getLatestIcMemo, - getStartupDocument, - listIcMemos, -} from "../lib/documents/icMemoDocument"; -import { requireAuth } from "../middleware/requireAuth"; -import type { ScreeningSummary, ScreeningResult, RosterPurpose } from "../types/screening"; - -export const startupsRouter = express.Router(); - -startupsRouter.use(requireAuth); -startupsRouter.use(async (_req, _res, next) => { - try { - await connectDb(); - next(); - } catch (err) { - next(err); - } -}); - -function ownerFilter(userId: string) { - return { ownerId: userId }; -} - -async function findOwnedStartup(id: string, userId: string) { - if (!mongoose.Types.ObjectId.isValid(id)) return null; - const s = await Startup.findOne({ _id: id, ...ownerFilter(userId) }).lean(); - return s; -} - -startupsRouter.param("id", async (req, res, next, id) => { - if (!mongoose.Types.ObjectId.isValid(id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - const userId = res.locals.userId as string; - const owned = await findOwnedStartup(id, userId); - if (!owned) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - next(); -}); - -// ── Portfolio summary (must be before /:id) ───────────────────────────────── - -startupsRouter.get("/screening-summary", async (req, res) => { - const userId = res.locals.userId as string; - const list = await Startup.find(ownerFilter(userId)) - .sort({ createdAt: -1 }) - .lean(); - res.json(list.map(toScreeningSummary)); -}); - -// ── Startups ──────────────────────────────────────────────────────────────── - -startupsRouter.get("/", async (req, res) => { - const userId = res.locals.userId as string; - const list = await Startup.find(ownerFilter(userId)) - .sort({ createdAt: -1 }) - .lean(); - res.json(list.map(toStartup)); -}); - -startupsRouter.post("/", async (req, res) => { - const { name } = req.body as { name?: string }; - if (!name?.trim()) { - res.status(400).json({ detail: "name is required" }); - return; - } - const s = await Startup.create({ - name: name.trim(), - ownerId: res.locals.userId as string, - }); - res.status(201).json(toStartup(s.toObject())); -}); - -startupsRouter.get("/:id", async (req, res) => { - const userId = res.locals.userId as string; - const s = await findOwnedStartup(req.params.id, userId); - if (!s) { res.status(404).json({ detail: "Startup not found" }); return; } - res.json(toStartup(s)); -}); - -startupsRouter.patch("/:id", async (req, res) => { - const userId = res.locals.userId as string; - const { name } = req.body as { name?: string }; - const s = await Startup.findOneAndUpdate( - { _id: req.params.id, ...ownerFilter(userId) }, - { ...(name?.trim() && { name: name.trim() }) }, - { new: true }, - ).lean(); - if (!s) { res.status(404).json({ detail: "Startup not found" }); return; } - res.json(toStartup(s)); -}); - -startupsRouter.delete("/:id", async (req, res) => { - const userId = res.locals.userId as string; - const s = await findOwnedStartup(req.params.id, userId); - if (!s) { res.status(404).json({ detail: "Startup not found" }); return; } - await Startup.findByIdAndDelete(req.params.id); - await CapTableCsv.deleteMany({ startupId: req.params.id }); - await StartupChat.deleteOne({ startupId: req.params.id }); - await StartupDocument.deleteMany({ startupId: req.params.id }); - res.status(204).send(); -}); - -// ── Cap-table CSVs ────────────────────────────────────────────────────────── - -startupsRouter.post("/analyze-csv", async (req, res) => { - const { filename, content } = req.body as { - filename?: string; - content?: string; - }; - if (!content?.trim()) { - res.status(400).json({ detail: "content is required" }); - return; - } - const ingest = await ingestCsv(content, filename?.trim()); - res.json(toIngestApiResponse(ingest)); -}); - -startupsRouter.get("/:id/csvs", async (req, res) => { - const list = await CapTableCsv.find({ startupId: req.params.id }) - .sort({ uploadedAt: -1 }) - .lean(); - res.json(list.map(toCsv)); -}); - -startupsRouter.post("/:id/csvs/analyze", async (req, res) => { - const { filename, content } = req.body as { - filename?: string; - content?: string; - }; - if (!content?.trim()) { - res.status(400).json({ detail: "content is required" }); - return; - } - const ingest = await ingestCsv(content, filename?.trim()); - res.json(toIngestApiResponse(ingest)); -}); - -startupsRouter.post("/:id/csvs", async (req, res) => { - const { filename, content, confirmMapping } = req.body as { - filename?: string; - content?: string; - confirmMapping?: boolean; - }; - if (!filename?.trim() || !content?.trim()) { - res.status(400).json({ detail: "filename and content are required" }); - return; - } - if (!mongoose.Types.ObjectId.isValid(req.params.id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - - const ingest = await ingestCsv(content, filename.trim()); - const parseStatus = deriveParseStatus(ingest, confirmMapping); - const table = extractCsvTable(content); - const rosterPurpose = resolveRosterPurpose( - filename.trim(), - table?.headers ?? [], - ingest.csvKind, - ); - const recordCount = - ingest.csvKind === "entity_roster" - ? ingest.rosterEntities.length - : ingest.records.length; - - const csv = await CapTableCsv.create({ - startupId: req.params.id, - filename: filename.trim(), - content, - parseStatus, - parseErrors: ingest.errors, - recordCount, - csvKind: ingest.csvKind, - parseSource: ingest.parseSource, - confidence: ingest.confidence, - columnMapping: ingest.columnMapping, - normalizedContent: ingest.normalizedContent, - ingestWarnings: ingest.warnings, - rosterPurpose, - }); - await Startup.findByIdAndUpdate(req.params.id, { - latestCsvId: csv._id, - [`latestCsvByPurpose.${purposeStorageKey(rosterPurpose)}`]: csv._id, - }); - - // Audit: csv_uploaded - await AuditLog.create({ - startupId: req.params.id, - eventType: "csv_uploaded", - performedBy: res.locals.userId ?? "preview-user", - performedByEmail: res.locals.userEmail ?? "admin@capscreen.local", - details: { - filename: filename.trim(), - recordCount, - parseStatus, - csvKind: ingest.csvKind, - parseSource: ingest.parseSource, - canScreen: ingest.canScreen, - rosterPurpose, - }, - }); - - res.status(201).json(toCsv(csv.toObject())); -}); - -startupsRouter.patch("/:id/csvs/:csvId", async (req, res) => { - const { content, confirmMapping } = req.body as { - content?: string; - confirmMapping?: boolean; - }; - if (!content?.trim()) { - res.status(400).json({ detail: "content is required" }); - return; - } - if (!mongoose.Types.ObjectId.isValid(req.params.id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - - const ingest = await ingestCsv(content); - const parseStatus = deriveParseStatus(ingest, confirmMapping); - const recordCount = - ingest.csvKind === "entity_roster" - ? ingest.rosterEntities.length - : ingest.records.length; - - const csv = await CapTableCsv.findOneAndUpdate( - { _id: req.params.csvId, startupId: req.params.id }, - { - content, - parseStatus, - parseErrors: ingest.errors, - recordCount, - csvKind: ingest.csvKind, - parseSource: ingest.parseSource, - confidence: ingest.confidence, - columnMapping: ingest.columnMapping, - normalizedContent: ingest.normalizedContent, - ingestWarnings: ingest.warnings, - }, - { new: true }, - ).lean(); - - if (!csv) { - res.status(404).json({ detail: "CSV not found" }); - return; - } - - await AuditLog.create({ - startupId: req.params.id, - eventType: "csv_updated", - performedBy: res.locals.userId ?? "preview-user", - performedByEmail: res.locals.userEmail ?? "admin@capscreen.local", - details: { - csvId: req.params.csvId, - recordCount, - parseStatus, - csvKind: ingest.csvKind, - }, - }); - - res.json(toCsv(csv)); -}); - -startupsRouter.delete("/:id/csvs/:csvId", async (req, res) => { - const deleted = await CapTableCsv.findOneAndDelete({ - _id: req.params.csvId, - startupId: req.params.id, - }); - if (!deleted) { - res.status(404).json({ detail: "CSV not found" }); - return; - } - - const startup = (await Startup.findById(req.params.id).lean()) as - | Record - | null; - if (startup?.latestCsvId?.toString() === req.params.csvId) { - const latest = (await CapTableCsv.findOne({ startupId: req.params.id }) - .sort({ uploadedAt: -1 }) - .lean()) as Record | null; - await Startup.findByIdAndUpdate(req.params.id, { - latestCsvId: latest?._id ?? null, - }); - } - - res.status(204).send(); -}); - -// ── Portfolio re-screen (must be before /:id routes) ──────────────────────── - -startupsRouter.post("/rescreen-all", async (req, res) => { - const result = await runPortfolioRescreen(); - res.json({ - rescreened: result.rescreened, - total: result.total, - digestId: result.digestId, - results: [], - }); -}); - -// ── Screening ──────────────────────────────────────────────────────────────── - -startupsRouter.post("/:id/screen", async (req, res) => { - if (!mongoose.Types.ObjectId.isValid(req.params.id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - - const { csvId, purpose: purposeBody } = (req.body ?? {}) as { - csvId?: string; - purpose?: string; - }; - - const startup = (await Startup.findById(req.params.id).lean()) as - | Record - | null; - if (!startup) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - - const purposeKey = - purposeBody === "co_investor" || purposeBody === "vendor" - ? purposeBody - : "cap_table"; - - let csvDoc = null as Record | null; - - if (csvId && mongoose.Types.ObjectId.isValid(csvId)) { - csvDoc = (await CapTableCsv.findOne({ - _id: csvId, - startupId: req.params.id, - }).lean()) as Record | null; - } else { - const byPurpose = startup.latestCsvByPurpose as - | Record - | undefined; - const mappedId = byPurpose?.[purposeKey]; - if (mappedId) { - csvDoc = (await CapTableCsv.findById(mappedId).lean()) as Record< - string, - unknown - > | null; - } - } - if (!csvDoc && purposeKey === "cap_table" && startup.latestCsvId) { - csvDoc = (await CapTableCsv.findById(startup.latestCsvId as string).lean()) as - | Record - | null; - } - if (!csvDoc) { - csvDoc = (await CapTableCsv.findOne({ - startupId: req.params.id, - rosterPurpose: - purposeKey === "cap_table" - ? { $in: ["cap_table", "entity_roster"] } - : purposeKey, - }) - .sort({ uploadedAt: -1 }) - .lean()) as Record | null; - } - if (!csvDoc) { - res.status(400).json({ detail: "No cap-table CSV uploaded for this startup" }); - return; - } - if (csvDoc.parseStatus === "invalid") { - res.status(400).json({ - detail: "Latest CSV has parse errors — fix and re-upload before screening", - parseErrors: csvDoc.parseErrors ?? [], - }); - return; - } - if (csvDoc.parseStatus === "needs_review") { - res.status(400).json({ - detail: - "Latest CSV needs review — confirm the detected column mapping before screening", - parseStatus: "needs_review", - }); - return; - } - - try { - const result = await runScreening(csvDoc.content as string, { - csvId: String(csvDoc._id), - filename: csvDoc.filename as string | undefined, - }); - const purpose = (csvDoc.rosterPurpose as RosterPurpose) ?? "cap_table"; - const storagePurpose = - purpose === "co_investor" - ? "co_investor" - : purpose === "vendor" - ? "vendor" - : "cap_table"; - - let delta = null; - if (storagePurpose === "cap_table") { - delta = await computeScreeningDelta(req.params.id, result); - await saveScreeningSnapshot(req.params.id, "cap_table", result); - } - - const update = screeningUpdateForPurpose(purpose, result, csvDoc._id); - const updated = await Startup.findByIdAndUpdate( - req.params.id, - update, - { new: true }, - ).lean(); - if (!updated) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - - await syncPortfolioMonitoringReview({ - userId: res.locals.userId ?? "preview-user", - userEmail: res.locals.userEmail ?? "admin@capscreen.local", - }).catch(() => {}); - res.json({ - screeningResult: result, - startup: toStartup(updated), - delta, - purpose: storagePurpose, - }); - } catch (err) { - const detail = err instanceof Error ? err.message : "Screening failed"; - res.status(400).json({ detail }); - } -}); - -startupsRouter.post("/:id/ic-memo", async (req, res) => { - const startup = (await Startup.findById(req.params.id).lean()) as - | Record - | null; - if (!startup) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - const result = startup.lastScreeningResult as ScreeningResult | null; - if (!result) { - res.status(400).json({ detail: "No screening result — run a screen first" }); - return; - } - - try { - const userId = res.locals.userId as string; - const userEmail = res.locals.userEmail as string; - const doc = await createIcMemoDocument({ - startupId: req.params.id, - screeningResult: result, - performedBy: userId, - performedByEmail: userEmail, - }); - res.status(201).json(doc); - } catch (err) { - const detail = err instanceof Error ? err.message : "Failed to generate IC memo"; - res.status(500).json({ detail }); - } -}); - -startupsRouter.get("/:id/ic-memo", async (req, res) => { - const docs = await listIcMemos(req.params.id); - res.json(docs); -}); - -startupsRouter.get("/:id/ic-memo/latest", async (req, res) => { - const doc = await getLatestIcMemo(req.params.id); - if (!doc) { - res.status(404).json({ detail: "No IC memo found" }); - return; - } - res.json(doc); -}); - -startupsRouter.get("/:id/ic-memo/:docId", async (req, res) => { - const doc = await getStartupDocument(req.params.id, req.params.docId); - if (!doc) { - res.status(404).json({ detail: "Document not found" }); - return; - } - res.json(doc); -}); - -startupsRouter.get("/:id/screening-delta", async (req, res) => { - const startup = (await Startup.findById(req.params.id).lean()) as - | Record - | null; - if (!startup?.lastScreeningResult) { - res.json({ - hasPrevious: false, - previousScreenedAt: null, - changes: [], - summary: "No screening on file.", - }); - return; - } - const delta = await getStartupScreeningDelta( - req.params.id, - startup.lastScreeningResult as ScreeningResult, - ); - res.json(delta); -}); - -startupsRouter.get("/:id/screening-report", async (req, res) => { - const format = (req.query.format as string) ?? "json"; - const startup = (await Startup.findById(req.params.id).lean()) as - | Record - | null; - if (!startup) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - const result = startup.lastScreeningResult as ScreeningResult | null; - if (!result) { - res.status(400).json({ detail: "No screening result" }); - return; - } - - if (format === "csv") { - const header = - "entity,type,risk,match_score,ultimate_owner,ownership_path,sanctions_match\n"; - const rows = result.entities.map((e) => { - const match = e.matches[0]?.sdnName ?? ""; - const path = e.ownershipPath.join(" → "); - const score = e.topScore != null ? String(Math.round(e.topScore * 100)) : ""; - return [ - `"${e.name.replace(/"/g, '""')}"`, - e.type, - e.riskLevel, - score, - `"${(e.ultimateOwner ?? "").replace(/"/g, '""')}"`, - `"${path.replace(/"/g, '""')}"`, - `"${match.replace(/"/g, '""')}"`, - ].join(","); - }); - res.setHeader("Content-Type", "text/csv; charset=utf-8"); - res.setHeader( - "Content-Disposition", - `attachment; filename="${(startup.name as string).replace(/\s+/g, "-")}-screening.csv"`, - ); - res.send(header + rows.join("\n")); - return; - } - - res.json({ - startupName: startup.name, - screenedAt: startup.lastScreenedAt, - screeningResult: result, - }); -}); - -startupsRouter.patch("/:id/screening-result", async (req, res) => { - const { screeningResult } = req.body as { screeningResult?: unknown }; - if (!screeningResult || typeof screeningResult !== "object") { - res.status(400).json({ detail: "screeningResult is required" }); - return; - } - const s = await Startup.findByIdAndUpdate( - req.params.id, - { lastScreeningResult: screeningResult, lastScreenedAt: new Date() }, - { new: true }, - ).lean(); - if (!s) { res.status(404).json({ detail: "Startup not found" }); return; } - res.json(toStartup(s)); -}); - -// ── Entity reviews ─────────────────────────────────────────────────────────── - -// GET /api/startups/:id/entity-reviews -startupsRouter.get("/:id/entity-reviews", async (req, res) => { - const reviews = await EntityReview.find({ startupId: req.params.id }) - .sort({ reviewedAt: -1 }) - .lean(); - res.json(reviews.map(toReview)); -}); - -// POST /api/startups/:id/entity-reviews -startupsRouter.post("/:id/entity-reviews", async (req, res) => { - const { entityName, status, notes } = req.body as { - entityName?: string; - status?: string; - notes?: string; - }; - const validStatuses = ["pending", "cleared", "escalated", "blocked"]; - if (!entityName?.trim()) { - res.status(400).json({ detail: "entityName is required" }); - return; - } - if (!status || !validStatuses.includes(status)) { - res.status(400).json({ detail: "status must be one of: " + validStatuses.join(", ") }); - return; - } - - const reviewedBy = res.locals.userId ?? "preview-user"; - const reviewedByEmail = res.locals.userEmail ?? "admin@capscreen.local"; - - await syncEntityReviewStatus({ - startupId: req.params.id, - entityName: entityName.trim(), - status: status as "pending" | "cleared" | "escalated" | "blocked", - notes: notes?.trim() ?? null, - performedBy: reviewedBy, - performedByEmail: reviewedByEmail, - }); - - const review = await EntityReview.findOne({ - startupId: req.params.id, - entityName: entityName.trim(), - }).lean(); - - res.status(201).json(toReview(review as Record)); -}); - -// ── Screener chat ─────────────────────────────────────────────────────────── - -startupsRouter.get("/:id/chat", async (req, res) => { - if (!mongoose.Types.ObjectId.isValid(req.params.id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - const doc = (await StartupChat.findOne({ startupId: req.params.id }).lean()) as - | { messages?: unknown[] } - | null; - res.json({ messages: doc?.messages ?? [] }); -}); - -startupsRouter.put("/:id/chat", async (req, res) => { - if (!mongoose.Types.ObjectId.isValid(req.params.id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - const { messages } = req.body as { messages?: unknown[] }; - if (!Array.isArray(messages)) { - res.status(400).json({ detail: "messages array is required" }); - return; - } - await StartupChat.findOneAndUpdate( - { startupId: req.params.id }, - { $set: { messages, updatedAt: new Date() } }, - { upsert: true }, - ); - res.json({ ok: true }); -}); - -// ── Helpers ───────────────────────────────────────────────────────────────── - -function toStartup(raw: unknown) { - const s = raw as Record; - return { - id: String(s._id), - name: s.name, - createdAt: s.createdAt, - latestCsvId: s.latestCsvId ? String(s.latestCsvId) : null, - latestCsvByPurpose: s.latestCsvByPurpose ?? {}, - lastScreeningResult: s.lastScreeningResult ?? null, - lastScreenedAt: s.lastScreenedAt ?? null, - lastScreenedCsvId: s.lastScreenedCsvId ? String(s.lastScreenedCsvId) : null, - lastCoInvestorScreeningResult: s.lastCoInvestorScreeningResult ?? null, - lastCoInvestorScreenedAt: s.lastCoInvestorScreenedAt ?? null, - lastVendorScreeningResult: s.lastVendorScreeningResult ?? null, - lastVendorScreenedAt: s.lastVendorScreenedAt ?? null, - portfolioReviewStatus: s.portfolioReviewStatus ?? "pending", - portfolioReviewNotes: s.portfolioReviewNotes ?? null, - }; -} - -function toCsv(raw: unknown) { - const c = raw as Record; - return { - id: String(c._id), - startupId: String(c.startupId), - filename: c.filename, - content: c.content, - uploadedAt: c.uploadedAt, - parseStatus: c.parseStatus ?? "pending", - parseErrors: c.parseErrors ?? [], - recordCount: c.recordCount ?? 0, - csvKind: c.csvKind ?? null, - parseSource: c.parseSource ?? null, - confidence: c.confidence ?? null, - columnMapping: c.columnMapping ?? null, - normalizedContent: c.normalizedContent ?? null, - ingestWarnings: c.ingestWarnings ?? [], - rosterPurpose: c.rosterPurpose ?? "cap_table", - }; -} - -function purposeStorageKey(purpose: RosterPurpose): string { - if (purpose === "co_investor") return "co_investor"; - if (purpose === "vendor") return "vendor"; - return "cap_table"; -} - -function resolveRosterPurpose( - filename: string, - headers: string[], - csvKind: string, -): RosterPurpose { - const detected = detectRosterPurpose(filename, headers); - if (detected !== "cap_table") return detected; - if (csvKind === "entity_roster") return "entity_roster"; - return "cap_table"; -} - -function screeningUpdateForPurpose( - purpose: RosterPurpose, - result: ScreeningResult, - csvId: unknown, -): Record { - if (purpose === "co_investor") { - return { - lastCoInvestorScreeningResult: result, - lastCoInvestorScreenedAt: new Date(), - }; - } - if (purpose === "vendor") { - return { - lastVendorScreeningResult: result, - lastVendorScreenedAt: new Date(), - }; - } - return { - lastScreeningResult: result, - lastScreenedAt: new Date(), - lastScreenedCsvId: csvId, - }; -} - -function toReview(raw: Record | null) { - if (!raw) return null; - return { - id: String(raw._id), - startupId: String(raw.startupId), - entityName: raw.entityName, - status: raw.status, - notes: raw.notes ?? null, - reviewedBy: raw.reviewedBy, - reviewedByEmail: raw.reviewedByEmail, - reviewedAt: raw.reviewedAt, - }; -} - -function toScreeningSummary(raw: unknown): ScreeningSummary { - const s = raw as Record; - const result = s.lastScreeningResult as - | { - totalEntities?: number; - flaggedCount?: number; - reviewCount?: number; - entities?: Array<{ name: string; riskLevel: string }>; - } - | null - | undefined; - - let highestRiskEntity: string | null = null; - let highestRiskLevel: ScreeningSummary["highestRiskLevel"] = null; - - if (result?.entities?.length) { - const flagged = result.entities.find((e) => e.riskLevel === "flagged"); - const review = result.entities.find((e) => e.riskLevel === "review"); - const top = flagged ?? review ?? null; - if (top) { - highestRiskEntity = top.name; - highestRiskLevel = top.riskLevel as ScreeningSummary["highestRiskLevel"]; - } - } - - return { - startupId: String(s._id), - startupName: s.name as string, - lastScreenedAt: s.lastScreenedAt - ? new Date(s.lastScreenedAt as string).toISOString() - : null, - totalEntities: result?.totalEntities ?? 0, - flaggedCount: result?.flaggedCount ?? 0, - reviewCount: result?.reviewCount ?? 0, - highestRiskEntity, - highestRiskLevel, - }; -} diff --git a/backend/src/routes/startups/ragDocuments.ts b/backend/src/routes/startups/ragDocuments.ts index 3480492..bea109e 100644 --- a/backend/src/routes/startups/ragDocuments.ts +++ b/backend/src/routes/startups/ragDocuments.ts @@ -9,9 +9,9 @@ import { authIdentityOr500 } from "../../middleware/userIdentity"; import { buildRagDocumentKey, deleteObject, - r2Configured, + storageConfigured, uploadObject, -} from "../../lib/infra/r2Storage"; +} from "../../lib/infra/fileStorage"; export const ragDocumentsRouter = Router(); @@ -55,7 +55,7 @@ ragDocumentsRouter.post( res.status(400).json({ detail: "No file uploaded. Send file as multipart field 'file'." }); return; } - if (!r2Configured()) { + if (!storageConfigured()) { res.status(503).json({ detail: "Object storage is not configured" }); return; } diff --git a/backend/src/routes/tabularReview.ts b/backend/src/routes/tabularReview.ts index 1d8ac3b..ce86330 100644 --- a/backend/src/routes/tabularReview.ts +++ b/backend/src/routes/tabularReview.ts @@ -1,6 +1,6 @@ import { randomUUID } from "crypto"; import express from "express"; -import type Anthropic from "@anthropic-ai/sdk"; +import type { LlmMessage } from "../lib/llm/providers"; import mongoose from "mongoose"; import { requireAuth } from "../middleware/requireAuth"; import { connectDb } from "../lib/infra/db"; @@ -24,7 +24,7 @@ import { } from "../lib/chat/startupMentions"; import { assistantRegistry, type ToolContext } from "../lib/tools"; import { runAgentStream } from "../lib/llm/streamAgent"; -import { getAnthropicModel } from "../lib/llm/models"; +import { friendlyLlmError } from "../lib/llm/friendlyError"; import { buildWorkflowStore } from "../lib/workflows/workflowMemory"; import type { ColumnConfig, TabularRowDoc } from "../types/tabular"; import { sendEmailSafe, sendReviewInviteEmail } from "../lib/auth/email"; @@ -483,9 +483,9 @@ function parseTabularChatMessages( return { ok: true, messages: out }; } -function toAnthropicMessages( +function toLlmMessages( messages: TabularChatMessage[], -): Anthropic.MessageParam[] { +): LlmMessage[] { return messages.map((m) => ({ role: m.role, content: m.content })); } @@ -617,9 +617,8 @@ tabularReviewRouter.post("/:id/chat", async (req, res) => { try { await runAgentStream({ - model: getAnthropicModel(), systemPrompt: system, - messages: toAnthropicMessages(parsed.messages), + messages: toLlmMessages(parsed.messages), tools: assistantRegistry.getTools(), write, executeTool: async (name, input) => { @@ -646,11 +645,7 @@ tabularReviewRouter.post("/:id/chat", async (req, res) => { write("data: [DONE]\n\n"); } catch (err) { console.error("[tabularReview/chat] error:", err); - let message = err instanceof Error ? err.message : "Stream error"; - if (message.includes("authentication_error") || message.includes("401")) { - message = - "Anthropic API key invalid. Set ANTHROPIC_API_KEY in .env, then restart (pnpm dev)."; - } + const message = friendlyLlmError(err); write(`data: ${JSON.stringify({ type: "error", message })}\n\n`); write("data: [DONE]\n\n"); } finally { diff --git a/backend/vercel.json b/backend/vercel.json new file mode 100644 index 0000000..fbeec9f --- /dev/null +++ b/backend/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "rewrites": [{ "source": "/(.*)", "destination": "/api" }] +} diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml new file mode 100644 index 0000000..492c271 --- /dev/null +++ b/desktop/electron-builder.yml @@ -0,0 +1,65 @@ +appId: com.capscreen.desktop +productName: CapScreen +copyright: CapScreen +directories: + output: release + buildResources: resources/icons + +# The main process has no runtime npm dependencies — only Electron built-ins. +files: + - dist/**/* + - package.json +asar: true + +# Sidecars live outside the asar so they can be executed/forked directly. +extraResources: + - from: resources/backend + to: backend + - from: resources/frontend + to: frontend + # Standalone Next server needs its traced node_modules (excluded by default). + filter: + - "**/*" + - "**/node_modules/**" + - "**/.pnpm/**" + - from: resources/icons + to: icons + +mac: + category: public.app-category.finance + icon: resources/icons/icon.png + target: + - target: dmg + arch: [arm64, x64] + hardenedRuntime: true + gatekeeperAssess: false + extraResources: + - from: resources/bin/watchman-darwin-${arch} + to: bin/watchman-darwin-${arch} + +dmg: + title: CapScreen ${version} + +win: + icon: resources/icons/icon.png + target: + - target: nsis + arch: [x64] + extraResources: + - from: resources/bin/watchman-win32-x64.exe + to: bin/watchman-win32-x64.exe + +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + +linux: + icon: resources/icons/icon.png + category: Office + target: + - target: AppImage + arch: [x64] + extraResources: + - from: resources/bin/watchman-linux-${arch} + to: bin/watchman-linux-${arch} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..ef46275 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,32 @@ +{ + "name": "capscreen-desktop", + "productName": "CapScreen", + "version": "1.0.0", + "private": true, + "description": "CapScreen \u2014 on-device cap-table sanctions screening", + "main": "dist/main.js", + "scripts": { + "build:main": "tsc -p tsconfig.json", + "build:backend": "node scripts/bundle-backend.mjs", + "build:frontend": "node scripts/build-frontend.mjs", + "build": "pnpm run build:main && pnpm run build:backend && pnpm run build:frontend", + "dev": "pnpm run build:main && cross-env CAPSCREEN_DEV=1 electron .", + "start": "pnpm run build:main && electron .", + "dist": "pnpm run build && electron-builder --config electron-builder.yml", + "dist:dir": "pnpm run build && electron-builder --config electron-builder.yml --dir", + "typecheck": "tsc -p tsconfig.json --noEmit", + "build:watchman": "bash scripts/build-watchman.sh", + "icons": "electron scripts/make-icons.js", + "dist:mac": "pnpm run build && electron-builder --config electron-builder.yml --mac" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "cross-env": "7.0.3", + "electron": "43.7.9", + "electron-builder": "26.17.0", + "esbuild": "0.25.4", + "typescript": "^5.8.3" + }, + "license": "AGPL-3.0-only", + "capscreenAuthUrl": "https://capscreen-auth.vercel.app" +} diff --git a/desktop/resources/icons/icon.png b/desktop/resources/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e735aed37c5235732b0aa138b84fe6d8971d2494 GIT binary patch literal 53896 zcmeFZ`9IX{8#aE8y+xFyWNSf%Bzu;bN+}8@BwJ-GiR}B#w4#NOeHksHY#}D=s6^JW zOO}ykkbSJfEYCH%yYJ8Q{Rh6kJo5|p>o)KAwVc;^9>;MWSD{7*y1W~OHv#~7&z{k_ z2mlBCl>_i_!51^Cb-eq5;qiO-QibN&T(Gb5zYpqx<2-s_5_I+)@BDCPX+u

35Y zVqgD>nrg8OWdSd zgH+8J0I*liZU8Sr5Sl<*TL^#$pBBK1}Rb z^@dr&sKn`T&+-~^%j)PCh9e^MD-b(i7`NQYt=!F5Qq13ugVXMRAB01{|KfiTX*S^~ z1VX=IMGsgN&P4ECe4mF)LDE3NnmyE_sBV-gZvXsvSDd+0 zqpPH<8a~#8pP6OQ*Z2x#Z{6^}m7STnJ1IxMR@^r5`M!_yjT<|Y#cn+P-Ih-L?s>Cxq3E{%?#Ps}#JizC@|t6) zvBOh6a0zB>l-McDQ@#B@J*~WVr{jwDmeZxn%kh5mWS_Pgp)4B$Bb!fqWuP%w%zy0J zCi${ECND9CL|f~^i%j+7?d8oppybF=?)>-ab9{EMtOw_;Yo?1wQ$DXv77)e8WvD}~ z0@S?mvkaHd! zVTOd6SzTS78$U}+8NdB8zPTrgPCJVP;LIbM0}6dkm8)ORmHG4I&9+{ziOxp!NVJ)? z+T@3?F`sDrF76S@{77a!{RtO{d@n4(ycoM2tMPdzI^D2UT==NncSO+IQlGrmOOmQq zH>oydAbmIJi=yWQY%XO=sQ>vC*ETvNARP2n5tDB#caKx%C%SSmF5eyt7PJZpSt}tA zYx#|!!=nRsgJ&R>BL>u7oDS&UnBfpTr&vx|c)-NIJQkk6qULg z<_^ipN2vbJcx~E3^|ReL>xw|C@%W=k*@NYKjBYBX3TLw2;E+(b0VEE7Nmz*uXgFjV zFrF6cEvn-D#Xi~ia%SS9`7D44uDSd&27%TpA-s?N{AYdyEWoEbF9{C0$_B1o^R4{jiyG>#N|KO z7+6pGzQjduW9&k`QTU3o0Cm&JNm%o0C06n0&!1u{wrOelvFN_1$!|V7a{7)8s!e+}Y%+7tN2!+W4ubktV%psNYi18I{c!&Yam z1=>YiujrgopD{t2(d$dT;0Q>c>E673x?!4M^RwF8%+|Q$WEhrHaI6_m%>}QTa=gJ8 zj!S*M&Dum_O}ID`X;n8P@uFl#THdokUme56BPav-IY)XEN0uvcPk7Yr&${M{MO4 zxuU+9t)*2~bjhVeXK@`^Aab_WsMh1Wyu4as zWH?;xZv`2)&RiS$L{(R`dt-t!-nn$Dh%j>~ZKIA`?WU<%|E%X1(K}efO*L;`rmhvM zW;N68JHI^KWXKq}&*$(acY#@y=QePh%s|iILB7tskla-)CH>Eb@0hBPbwWZy1D@wi z&Gq}}Th$H@Po+*(-|5CY&J8*^cX^Mc-NP$o>MukyQr@ULJC6}?1 z@sa=?VtS*1B~@-`n0j4~^{WN0%#ysLFLJ)}AiTJeXM6E85%KaGC-^wmVd z^-%Oyv{joU2&1aju3sq5MCh3-g9rCi_kZM}JSNP?b)myo7;7^a&7i%-sc;{n8nhUe zb8rFT?of0O=&RSbkE-RB03PkShH6k=FEds$r)gEtls&(M41h z^V@-gvmTb%Q|ITnbmXEbi~_yyn>s9q8sZgor;2)9V&CMhV2WT~)9ipLNJ6S%9?_X? z;b_W1`%PjpmX)KV&R=OZ(es0Q`=q0na$S#VE^$N&7|9&BS(Gnw70fW#kCCN1lo$uP zojiFzy5cvfuWGZH(y}3 zmru_e&+}>IVc1ztBsa$+F5vXCw-%G71x?yToOz`6WqD zT+b`I?0Q}h(r*tMv*o~UUhv>$LKG%f3cJ`JFeBR0-tK7vONx)8048P)PO9<L7I_}%Y~cl$Ecm0C`8libTq4EYC-Z6y^bKi-ip$i* z+=nb4o7Bn+)~LWob7kOuY>Hd;E}0TNc(zkF0IwS$^~#sB-=s{$EbCYg-(}}EfM9_X z!k}}9EchPEI%V+p(=RhqB6qdrcSojtpLfsgnI@5jy6~lnPwmNTXvzt=5Ce&ls1JI6 zhUVHHncwro=E5|lZ)lO@Y%9!cY?!+|I*_8Aa4@sC+TNaz?*QW~6G+-GKA~jqEU(|> zzgoc0n^RV4FLY&L&9{c7nzifqE=>=UDCROMRD6HXa^Db40y(#D-1t#_jsYi!b&r>- zTwRF)7d$Bd?xrHJsVhP9oahcx)ku9TGu4$;VROW0 zqQ(RRhr{tXi|cZ!cebBOrQxG3qN^rzF5r@fGB^(|J%#T!%B&?QyY`o{*A^$F;XqJ@uq1x2kQ+$+8qCMM`^k{@>)N>o(hdWoxh$tJ=loG4iIO0O1ZQj1qOiqaYM#XHR{} z`8uM4pbcNVF1n#p7yBh^8|H0NEoRxfNqtmFlk$BZ!N8D#RD_TyPj=N9em`V~;-jgq zT-=+B@92liX1|o1=2%i|MsXN)fmIXpLihnrcOsJmv2lQMXycs62%&ixNig+nbrnxk zu&vIxl~YWPfFx4I85<|$w_pio|%P@O-_*lsc3|4B294Rj^xby1^ z@tLT`QgP1LCJVOri+4~i+$cV{Hg?e~ih}1ELb8{Sq@|^em_57c@a{#_{K3pW0DHYk z=w?@3`^q(MiB%RA6~&1vJGFbR>84SnPz>~E=6*y84AlyWT$<36c)31Kt#`0!5sv6H z{92K$0W<`jtb{qMQI&(X+-cz&6h16Px8`HFlLTyE6~Wa_?i8Cb-{oN@eOV|ux?&6$ zPg|bqQMHiuHx&Gf^5Gy_vb(yLqu;)jf}^mJz4CD^t3mwx?ZItn-{(#sjYG^tH+q1r;E6Dldb7X`T}=9^9^z(N5y8oE1l0qhn1>^f0X%okZ-Yd@)@^73sOM1_{102qcx#%_;tYxDu1L+z%$)EF-~JD=Q&;!voxkLy|8~O% z$jdV!kX_R9_;gDkmVUjZcqT?a)T=5^^32i9s*;g>8`-5FhBj1H0p^p*TGmoLzhqHl zmk7qE0`{GL`bqBwn*>M0m&nX^vf-&H=0Y@+sS0c0lLjs3Qa*EV*=TF%KR{yc8TnqE zpA@zj|=R`7l6mO((Sgy}G~@Ivl1+V8VS=yhaUDXt^xVIN%O1e9n+s*g+j&Vxd#CUzOdAByQrI-elbuI+Y`-jH)Z?4Cb1_jSu&VZOXzVU zMKM`5TRO28WBYpkCbM_wzj8Yrow(@+`NS1}O**?`QPcMPtg`bWYmn`{ZFb8QzWzYy z*e*S)y$?D>4S8lG8kEmf?>4?-VS#bf{_sy`LiwrO{`tauLvw#WdeA^)O36wuKnhh% z+FYrJjkD!M(M*m~6utL~Y9RlUvLs9^%-IoFO?P$t_!)e6YB+D%#{k-z|!3jdc{07&Prc zH$>7GNW43bTyxg?#_+A^TJ@j(AXl6(9TKw(q{3Vi-$`bNt%6f3{^p|)|G%VG0c6J)-6Gy>{Il`z( zm3`&izU#hRDB1%xs*U$;_*#k-^P6zk|70U$dAv(?FMU;6y}tgcYS!gGCY>5AoMu zsKq@zS{65VE!t>#_0FAXpV0zXhYBD-)QU-=;p1|pSm=*G2f~6{m;yEW1Lqr0E_XZP z%-$?_Fx@M5VMh`BfeaYj^P(1GVPVndQu?e|GH4OEo-T~`Imo(H4{J<)Y?E_%diYfJ z>|RYipk5ZzA~ZZq3d?zhU0Pa7+$}6@AbR8~mNmVM?7~}Jbr6irIN!dlrMuCFKAMPV zekKT7O!e4n4!JjEpNzvL1~Zbo@UDcMS`0BDV&merjQ?m{|7rJuLB`F5*J@{vQCUS11NSwoP6>SZL= z0ShGpv4Z-;rL^;UYYxB^v43+x@k)N+q|kdGSICZU-LkOg32%tTNUfjN!wyefs((8) zxKvLM?j(-VGjM*WJlv_xvtLffjueoKY{q_L7{NLYMFi*Wd>dsI6|o)y2E$t1)HWDJ zc8v*Md_{$Y=%R_{n4HTuPGK!%i)N}0VR;54U1*oZYM#i^XXXl6;DPY+v47oF`D=2WXS}D$x^vb za`6f;l+$W4b$O}5|JH*fOUS|&9GqRNR|YHCQU^|b)=X=@My*86m4plVcwZ%-w zb!=JnU{}xbMAuB4Ll(*F`_#SEPwhHoqrxE)!eEt3=bUfb{Gr#W%LY?!zKT(f!ob%^ zSU2n>JzZ>!BTPK+Ns`Doi)=)Uz!iukr7>OBw2Fx6r2^7;51tgVy6S({t53;}q@nYd zRT&tF^_4*dz(KH*N0BVFY;pH?TGK)P<7IbBq620yFV~m>w_VE@J8R(ioN;n-*#Tb& z=Gwcv4^B-@r9TzZT9qGHGO-iTJvTzP-8iKonECS2Wkjl3aTEpyPAk(zPEL;0WV4EM zr@fDi`uC%YRJCP5Q7_P9ql~8vN<9;NYR=j&|I`{iO_-Z+XWq*MJLIhcPZ=AxJ*3*K zL1h9+q`+D#lWCg0$}qLK?PY)Le6jOI31axSrEfKE?ffu}d_KfKKcQ9Rhzs&2K6>@LsXsrG~4GnqLxKYMy+&f8Ft zUjkt#h$WaqSs*bQ@ZpP!5^}lDK>+G5i`>y8Bwll2Uls_dI02_g^mN^{LsqT0qC#7d zYN(Eu{Hw^ppF@doFC`ofUnO%4ma}0^fWp^J8HVa+%yLt(@B@mp3XV|Yecjl_;*#J@ z%s;7;#1h&ccqUMORBfHN*!XaLiZD}p=IPOPWunRlkfZcv65)*v0sXph`L1wrz`ehj zTIc$GV|SBp<=%^@XJlX0SNgHyA#EiEC_ZG_?{f zXGN>TJD9un!dA;W)&l143sW1MT6lRW;=G7L^m6#2k29CD9z8#@sU3vTS0rtG9=|VS`P8ZOi{t}nC!P)R_qL>| zfQ|Hf8qjfGH2$vLwjgVXRB(6FS&dXuyL2k zd}s>VSy|h-=%-U=X<|YG%Mbu4*;fknSiwd)TsoOO>kIn=e|!N)wUS-Vx*n{N;&jK4 z_4*t#JsF+HmReL-zUnHd-k)jF1qFJO`5@fsoFVCd!wddGHV5(QCzsB&2!g%eM)zJ! zmD0YIag;&1RZo1FUfvHNaAx2HE%~4cHRui%k6CI(}&FCdZiwTLUHFgmnb%k8m&o6f(aP7moG! zYL3;D-=&fP}5UQB2>goXMDPTi?JBcSbOovlD@(A*E!Nt0Z>_aVB+^WX^49A*6T!K)Y)zL;y}$X=L}O{w!$xTFbzKz6)_T| znJjRAAewrbg&>Geg_VCBMkc2U^;Fs0h^&2+qAGzCRf^!th$CgjWZvoLWq3$z8VJiI zS+)b-xqSayIN$Z;*$^5EK*a%VvKZ&m6T0O=WsawW$vY)K+ z(v9b=x1%H>KR>t*`3+Juse-iz%};I*=F5lCuSW$6gFUb+snO0c^t075O+TR;VW4U_ z)zDut)QR2q?+Mevv;=0x#oNA#dLk(!*`Ku+Y39O<$m6*7A+S?67M_#7CavY(w=%h9 zzZZ|+PvCCvm~+-;y*wJK4^PZFKP)|#$@xKjo9J>yvU`Jn9)z#+uRTbiT&U<4kWq0_ znz@8Ncph8gLke0>`h1_;rtQa%v6Y%PnvBK&1;Odl);0wW&jM#ZbH_dvQKyhSOHSz} z(u5LL+Bh<{3WE+aC-zD?e-|0ktd$zrMf1tc9nFCqd;mWG_;@oa3nIM{SbszgJd|cW zOhB**oeq2I<2bqBSEGH)YKUd4zcYh7J36NN(+$gq#+iRb?Y$9~(UWcI;q4uZ-F*$} z-hEzOH6CAtUA%tt_-(FrZaq7YG0dFwF!Q*l>h6^{iE{EC1=$9Mu z?)ktPikgdHsI`SvK)@QwgO4e*wDuoGG%&EXc0q3bP*}J~c(Z~>47*<3e{306<4fPS zU3xO|+Ce?c{IC~ZZeAAKUEnDGsm(xxUi|LeS=KtFCYWYlk!0zC7XlzsMDEu(y9}3O zP)%yEfAgJJ8;m$1=+n-8VEeG>X~aI=l&d*3M;whMzJU%~vKTA(Zy4{xc#0nKZfR3L zn-?f{Qm|vg`_JzO9LpHaKKoc*jhf}DThmOU*nhOG%IVa+2o_3+Vz{&J77C`JHTv{j zFG2|Izh~!9;~(qPOU}#*Vsi>>@h?|*bwxM+9V&2p_JS?vW{lg11! zb-|K?!)RdDoG$K%r|@7VJ6p9JD7k#Nc*HyK`|~}x$*x>iGu50InEn4(>WDQ{%>)gW z`^(n?#?_A#nfIH9&J9P8?aSb*ANn++=1|<8Y_Yr%n~Pq|JkKra*__SJwRv`RilzBs zE89CdYN7h#t?!CVQNGn#T^8B3pG5h-l`RFqubEp*z#(Ufyt;@`uzkI@G-c+9rXJ%BeLPMNM^z9(U>9j37?SA)*Wo^DhnX?9#$|+00-~|2t)*o+tu!xuAJ^0RWXcV<>< zpQy44ydx{FJet_=KH1}!JQP>nQHaMAv~Dz~I;_R6N8pzjy)?U#x~8Vi&OI7uehC7L zNpT@eCU|T$?kI=?mk~xmG0+);bZx{TnFF5b+3ITxnEIB`|FUS+omci9XGs-{jSgXv z>T!KZyD6|p9d--jZI13P6dcKK-`@r;{F!^+CFOzx`B0>7@#Ls@rj8LascKOEgFBpGjK1fz z27cNE5Mgmbmrj>V?1QgepJ4(N-}S767CXK!LohV@z?IzyN(Rw+bt}6ZZ7Z|;ha8Jw z?}fdwT-m!uZnvEKe;-Q1s6$$Qe1Y8-^5wAJsW^+iCpg@31Z~8mn&MX0EPr>zwmysP zGPJ2!v2$Stj- z)K?pP6kUIO*i!(VR|gBAek56tcCq`3w8Ma@nOSUi!#0Bg$WG)7l$DguK%vOK5{rQ3 z^T7DLk%J=Xv~3ZLng$i$*{i#b+J!*i;{a)B(YY<)%odme7yQN|pvfj>FN3h5azY(6 z1Nc(oGu#j`K1EMn$<{pUo|O|9`6a8YWMr;@Cqy#i!3a!&+mhFoOh+4V z{!ZkT@+IMA)oCU-_J$vfxRGb7{m)fNfyU5hAZPyJ>T!pF(PW>aX7hXE1=-;ymP1T; zsNk8H))ntwH{I@=>B+6U_afLp2y5yPlZk5HY<*MQ8o-I|c?@7XA^gwu;kmUkjp%jn z_NPxS^|%&baN6CmwXz)zhkF`SzOT1!ZY3t3glc6JhPg(f)XC(&8Oo&myyXxeSFm0D zTQ^<>o|JU6#$NvqQ2Vhff_+N4f@ixFO{>F1ql)eCE{ns|Q=GHTx1+ll<905~e8ZPp zEe_?9Y27bU56K=cUI!0(KRU~|U4<$<{!K0eXE$BL0on~#=P$F!Qk}kh`HOCAG}YcC zZC^p4*e?b42DhQ=+Ue4n!vT|dx(+=M@!7#7$p$h+=NsZpGCO+}oqseJNNU_wO~g4O z&P0s$vQRY#FP0MB17@ArhDlsTu@y%tmLBt|q7%O-|F8*D^}{y(hvh3)?lq)nNe6lP z7d0ens1|sN`^Q^ss16sG7mzylynLDQJaFt}Kz;Me+h5k=VXS-&g-vi3zPCXIg@s;2_TrqF_IrBg13BSA~EB|WS&>;Q<_x7Q5y){yu$lg)(rtz-GfY1FbrzL((laN zjjvq0tbC$jc2O;uA}|^8TnLcr&jPOm#$v9I#&WMm?Bit*@CKD4p1d|Vr1m%!VN`K( zH*kq!oUnMiYnD?S#ziZc2`h}?DK zb2Zq&c83@dxa62<=!iQfhM~E^<8~KB3oARl5OM#$#~l`ox*F{zg`3XK+X3nt+Td-Q)KGUPRtajuqiX9q+!-vpP3>OOr3TEz`5Z=<0&Yv?od3gCj$5 z-s`@un4g_P4@97x;5lsIADX{?Oc!boXhliYmlmQfj(@d)NSSG<6=wl!ZD*0{A(ZUw_!W z{eo_-wD#PFcr~|Ivupav#}@Z@!FJp}aY$skpzWnp0EStdrt9QKE6OGt=QJ4aVaF{Lwm2dA)Ost*B zX*&&b{v4|<6U8m&D^XAmQyrU5Uz~#N@TZ|Leb<`$A9=P9NFIA5;vRgu*>-wU@)Y~c0HpWmOcJX78c*3xRD?7McusE5=iigkc~Eb$CyZ)uAVtWFOu38 zTJ`B0^ySsfpnY(?KBSqW`t-Y>j^eqJd-!wXMcTNp-XQI&3gvDT@g)9(KEcF z$2{*1ORbG)^B9TL^ld-3MJOiqQcR6oy<3C(Wa(|K3a;6OHbd8vUypY8pH&4MVDP)_ z6antm$Y*Zx?GiV`w3aT%AM#A5PoN|ShF_Tu0dovw8tnD^|>p!GS z_344zKjyZ((l%VHs0Jp2wf!Whml=>76QzCnyv!UdKHYf<&f*t4E$R=ZCq;Hcvy3$0 z7a}JDRz|zOMQfos_DvZ#qC^=av9guOZ;p=ZR9d`s<9(Lb>5`RIay{}MJky%~#qqP^ zK?@4;lFi7K&wOz-cB_V&d(`A3gx$L0L7rqE$tOfUn96R9Ivg%o?Cg2wPTf;K!dE;u zuXuo+7dv|yJpsF3uN%slZzH0A|F*Z^2P=-B62;7hj<-Zp1>>b(;k-J{DOCa(0q{_3 zu1X-D6b%)pdO zPSU#JUdqP|^0D={F6vUkL+dL(O+wyq2PzLA>a3bthc1`csTDRd-J8V;kYsKRgFVP2v#7PdH%hstwl^XGwb)ka`7`RH@eG!gKJ z2e_~rN#(1HtTS0L`>?<6i}(BXXuNv$=sTU{7u zLj@l+eruP22p08@16U~;vz!+mP3n$o5Zm4=44LS_;G@A{J`R@0Z(*M~Wx$tTojiBw z#R7(!ZZaOkb8}6?(B}F`NZfbexcXqozG2ukg$;-UwrJ_bnYUEYASkR*dm(dndKFKJ zD}J^;el3KczKyNQ#{K5wY;Y%s&I#?hQ6B$Q=P11il0;zM&f$I%%r(vvLrMDMv`b`)xe4iaQPj) z=FcfA;v??dxz;1icttDF42vybOYQOiu(6p;EG#POhn^CEz#f6Zjg|Y*Gu1kXDC80R zeB^7%5JQT|uz?3I5P!GAa4Ce9fDt?1?#ZcV&M6TV4DQ*L#vu!#qa3o=-`X4yYumlf<76<4%j}n*VnI)h2{kg@P7PPuz1XFmruu2(5p8Q&o`5oQgVZ)Zp8<4 zv7wZ+Z3x!KjvZsTLPTA$H~DQzkzCkV5j;i~p2k^R*Emtx+x_d(qNgJZ<1SA3CrtX> zWHnv5qlroCZJN=l1@QPpexo(@5SWUNO^@lLqA)v5xxcRHZ@9ft`%Lna7UfEt5~TGq zb9816Hl)pNu)H96WmQ$oD=4tCf#d#Dg68!x9Q~PU2r6SI3mUT#1eRte1bT`36lH&p4Dn`!=F)+FtSmM|}1X3T5 zH#V{_k3&fgpwe85M|M!BeF!p8Nf!bK9H6}H-f~K)R(&*SA250c;a@!@SvEUicjbkr z(@!=ul!r)A_O)a+SUg`M1`G>@LAeX(8NODWlZ%+r+nlDzNj>Z(vtpk&(2=ls0;(D5 z_3+|^T~m@e8%T>ga_yd_d#$AVaD=#!whi>;Te}3+$28_cr=%GxRkSXiObg^q?f&;2 zc^qgKz4vP=wz=Vf-h^TaYx6LV_H=c)`EX%d%c1V*zSv>rSTVf`9CD-0)zsU^T1mlE zoe5B^4$XBJIe+Os@WagChMtZv+ShRO{sn`5-xi5)G#uyw+Uyb?f@!Gr!8)TCJKF4U zK}**td=mWRf~vr`aUD8=4A`P6Z!d|Tesj?j<0kZjwju48e2aLlAZUsPXjAYhNC0b& znEjc0{Qi}5ur*8bg{fc!a-9ZE*wuXhIZ(e67K=R)4`u6Qgd(`sW?azV!F|=)n|JwKTG1KZn!3W;!Z>`{J?yu7IJme{2 zC3{z>9v1v{)S81z37wjfVYIa#&Bz6)t*ZULw*LGKrX4ho!RNlu%_S;{Lp4kjfxQk{ zM6nyx<$CmpwG|$%Go26;_4BODSNti6RD%-Z&YE78s8YO?B&)0At$nLgk%d1U#Tvd1 zi=*V?h+^}*?6jZ2s~yqm)uW1#(OFd3>pz}RF@x^t{`bXfl_R%6PwZx~-o4OYma{^- z^OctkBm_>~vZ>F4ZN>Dkuq`)l-xf{jSMIxe(S@BqmQGuzWP6kVjpsF@SrnRhD?X*! ze>l@HvjL`H#*l3|-K2ur=vr4VPml$vEiAG}qBeD~;5QGhhlQI(MP=;Anme=KtaO1^ zz!M=kdmU=G#X9v78Z&evGKMJ!#<^e%5u9-t@0E2z)g>!;e1NAnHd`pi;q2HZ^hJL%GC4^GsfnYd3PLa;XgY3H79Gp=t?(qo+f*U^V5 z3><+7{haR9k=S}Z%*sgw_J9VHHb3${GiMv9HL>HwS_o8TBP0g@ZV?;}M+>;qW4Rk> zvSZJja|tBJn^T^-2A*^vrpN()ZdS#y445iB>NcqJ%OmB?IcwQ`wn}*1U_Z&N`~Ne9 znIgeJehC?XwJTw)PK;QPR$~B4B%X-hbodhr?APz#h^1}796S$TOX2P-#DH8o7s{Ix zrQeU-JF?RCJSwW@pbblZFrtmU4&Ml>x?v|X=B9U;u=14^nzXdx=@dL$rN|pZD)!HQ zjGTg9anm=5<_h20f!>W^e)BUL_c+YaHPVEMoYJEtTpw($gXbJDCAfd(RsCKojr&5y zCTSwnWxzrnhc!zdD1%*=9!*m)3#*U9^xVBohhMO`2E_ZGo#u|Ui(Mp%B)*U;UtLo$ zX1k&6pL{b@G`AT@yd*co?_-GU+5{^acSb9<1+mPOP>PC^IQQ z3^;Sdxgsv>u!E3v$V}uEsoY=~TEQWCJ`4tHlmPTAA3KpR5QGm!@?xKNz-F5u(jnB| zTxYL>Lrb8z3UnuoH3q6A5YIFT>0Gz8#fzPdu+7-Z_)-q759>PMOz+0S%-Cs|dK|$p zbEt1R@1;RmOSC%i6!V-2fl72cCO$hv*N2?lUC;~2{r zrjNs7JIsa`797=?M(H_(6w2yn+0p!a?iem@xIJE<%tFpQb)TG^d_fE(UxIEQBgFhJ z|KIP@>*}JDlWan&zU$ujA~V=XQJaPx?j=(*2_L9gOU{I+i!k*dUC*XIk*|<|+DK6u zi-UoTUKR<9v2cmz3%UWt{Uq1#wXY-B0=cjjF3gijYc4JCn%zP?4KH1Y8*%HEgN(jW zmpwJ8W#IEe90FxvkfXRxf;QQ#+=I7JU?=ADyWHGZ3s-t*l4ooCv~3awJ}=_W^9%=* z|2&U}2#mh3?E)|xmQbL&WLhzwJi`MdeBb8zSMOrVL4U~Gz@gowG3_R5Fto@_&+h^} zTX}(3{&L-qWW~J^85#-cG$y>j2_*ur_|p?mqT9z@#vsYGllxCgU178GJyixnq7S+{ z`}G330LYG!HQx(?-diYd2(SSjqAqgv0=1inmV)MVAy5);!3w@QIq-N^4iwr79&2bj z5vtSY)|6Cy)V^*zYn|E21zmw$(7kP@CcicGLP*ZW9gxU;3xJ5Q6duCz4!_9)HefXU zGFU9K%@AqTnPt8YUKxfu#tRti=fgB9XAr5z5m4`5f68UN;2$)fNSd7E zMF7t`@X2H90Lu3pNNlMFMaI>}Cqs3#S~eeou-^dz#_WVYjz2j-DW9>b_j_{YlAzq( z!)1H>A*F`>N>|q`0QQoz3YsEIXDTcJSdF(}L$9{sHUd%Tz~O_NcCd%qZa+Ut-XXqnzD;TBCx#)I+H?L`OpBo=zNfvnp<6(~h!6 z>a#Oheh;MnKKjp)w?h)25(vi`!_q9^?QJ}yjdiZ*`Q5uzU6KHgI81ccII((EE%t~n zT3oQqaGOA>sN22Qtp>tZ>`n#TIJtlhi_Z2Y45ViHTjXwljN#4|_tWcti&MT8BLao0 z{5!u+AVAd;1gF=3fx znHHe}B}*>Az&VV2d={|MRg>Qq+7aG-j|;13>QLXOXghM7#fRUxOf!}08LY>;$9rJa zh2lKeMs;DgCFA6#zr$T>Qr%62x781xhRPl$ zU!i7UpgzJnHdq&O($ywjR&BUb0jII+B_}V4W@WL>N1GB=4Ye2F8LzxY0+c+tclMnj zyaScvTRg!tdVa6M%KEJAMsBa0fcgxBAh;W8!!bU>7M2kJcWj%sk}a<_?MgzzS)3NV z0@JKi^v%e$61?T%FjnWtdJ#5N-*S86BL4)&f$Aig26fx>rYtoc4{vAO{cLI*eGsVrXv-Ul$Mrl$zA)K)5F znKE!X1u%JVco-Bf{hUxEMMjxDKPe;(-lsv0IfxA9#>Bu;4boNs?kyzGSVn8t^rrYc zn3q@gqpCYSgZsZD;*9Jb?S#u~Z(WPn46&MShN zW0SKwye!TMZ{@&C(ZBi%qex=4G^J#ET+91n}>K+X|Z_uO&KE8%C=~I9m-(uem z6SF!fiRi+o?ehpLfrVV%RUq{6R#u?Ab*y=MW`9C3BM5esp#M!tarBT!(@`~yKxi?K z)mZ(%MPyD6pf<1+M|k7%!yo+8>;hYU6g+``eqC@}*#Vxx&3I}t+R&e2YRi7#D35lt@Lzifx9b#H1m3*EyID(bd$}cO*Z<~5-#A6buyZMz=FpW}0*y!C zA8ZidICBURD#LPA^rb^1V=?hnd!-G}F_27;3ivE;a?;XzLiC}$A!o5Q2g4V}*sD*+ zx*bMINQb+kKXVGzO?Jyea^QBg>86mi?_Em_eKBm~D28G;tjUcMhtJ-;ym4!kFjQY$ zsN;s1LjY8Ts^g)u(-$Aqv|P3S^wcoG5&@(9JABLtg2`Fi&!mc*!!EL?0vaCLh zU3{wyBn5WiEY;|KqB2o8XK zBdq9fj%=4-25mwuZyoQx4}-^nk?k7vgmsHx z3%PfN$sjTHangijpBHoNewywToG5Wfb8R_+(MyOiPs7W7N1(H6S}bVc0;?|r+`jxl zH|Jb9v3=_Mw!Ic-tu*|Thw&aG(0cIDja|V2BE!}84KHe;hBMs*diQz#zVMFZ*%tl@ z5A!%5+0w7=ys82^RqX3ArC*YB4t^r=N=8t?kOc?{eGIXYB+Mz{e;_!@ZE3>oq@ zR?Jp?s)FQ%&m9`)V2zk1CTXz5;^PGoOGRgvh+J9Qv+M`j)4+?$IaPp!l>16u(M5(7TA#)+k4^sY3x{0GF0ZXUI zNJ~3~%{Y30$NT_XZ=-m4P>*|e+Y2FHyyS*U;Z10Sm(XiC33`71^yNw23mHL8i1 z3Zs`ja_oDLKXv%OAh5O>*c{_LRJV6qM-BAxC+E`HfdmKn7Q&~$J5Wwyi0``6br}CQ zZw=i7$`A-2dux1p|7mITpZZ`FL{6$-xxcfV=e+OPf6F;M9@p(5gK@9gstXs1r{*gk zQkK=z1{W&-6u@J}=(|BPo7IauX^(ch@WTY0GFcou$K&_H{l-OkBdM-oy)@Zc*Q@FT z-Kb-WS31)TG5b9Pp+V+BO;P}~i~BEc)U1|n_v#WwViRoPsEV7+r~9E7{f$An_aba_ zaKpd-viwz?@qBK>B2UN54Y?tI7ZlcKl7or2xG?6tW&A z>T$SYB8OB5`);j6YD0qVR_a1Sg{)=!HvMkTwp-$uh1cTnIAmyf7&ru;XcvFEK?by+ z_428ke6%YfLlUakgy>jn|Fh89HWlwdVet@Zd%E^FwTBr>-cu-EQ;-2M?f+rxz2m9; z|Nrp|h0082WQC~gmA$eeQB+2@P?SRUESbs7%xH;_6Os{5Av+?otnBPPk8^&H>-2iR z->=X2_WPIXJg?{VoR4w8-yh@S-Yd_XD&VO=_HtKO-v*9x=SHH%sPA=LraTG<5xG$m z$K0Ikx(YZML1(srn1+*~$6$FjxwXvs*f#>(_pc#{_!Ujvtmhv$asB&UZp~?>tugVv zX96J5>a_xmXs5WYfeH*K#8?H!7+q{3_Va=05~Yi~to)n;0#P1-rK*aiW;~-c8(*cV zf?;}Qzh65wSd81i(#eqr3lQdGO>D}+wN1Bq>yf;J%!lXTuN7RcS5O^iS;@qWL%axC z6Q8990fNqnM74 z$76>ExynzHTxSKq;K!%R5o&yiY)8HvL=ptJF012EmmLmwS(hoNdQr8D^d|puiwjAS z*h0%Mq|8eGm1x^3avlW$+|dp+f%T;bTk15{URl>1K)u*4us#B(n=)$SMa@q{Ytsn+ zCt&bPZuoXSqRa#GOy=%CZ}CBn@d&JKK?t9%)E~cl9<(2j63Em2th=x<(DvW|#DB66IZq`uPb0JC66hG7QEGnQ%BO}dC~ zNu0YL-DS)c8@1F;32 zY=MLsFF2>YBwf%+<%uW$mzF-&3El@*Kfzfx_`%N2QX{ttb@;@spW3%o2gY)U$Uh7JGf1+W2 z<7QzhxW~9>UUa=U1m=g0tgU-#YmvUXoPF2ZBm*fug5T;lDfFw8m=Cq4u1D&mNf$Oa z*Xwl!k~`$jr1 z2s^Xd;?xw9W&fJ4|NfFOX)QVSZ^^A~@Ar3uQZT+V&*!qVzHnlVn@E*J#&bE3B z!jQ@~Z)mnnCE2DXaXlGE{y=vdvp(JwZx;P&VjGkF%h3_TH0lcZ^W4lBh8*Pw(K(qwac$|?JSs_%j=`IhN?UgY3Xc;~EB zottf#m!MG9n*Q65AoZjD{X=!q+$4SG(T=5Tr9GKF_8sARgSv>Z<^)(>Z*-sQEP%gM zr#%Xo&HWvgb3j>;VaeldB`A#Q!t~@RCFnTX?K`Fn;YF6v6o6XQO|H)%Y~w zq?V28*?>)aLtdRq z;uqP=dxw;b2v4j=T+d|!j0k|rn~z}FIvc?PM}3+!Hn>#Qq&*gtQgHlFv0X~18C1ko zw086@RtZX4nDTGt^q=(?EmJmQjWsxuny_E)@a!au)+rHW44Dk+gTlW*g6%fIua+;b zxH(Wvst_l=+@93+rE0ID;nvG*G`t>P8VRi7{6qW|^1E}cHDwV@I*(BI2)%Oh?585r zDxae9FS_ZNt~{($oJ84_+v1aMWTKdlkk5K9+1&H@c3*T-V{P+`1CYCcyJiTsMN4J? z-b-cPdA-*xjbhVuT{w!rGOPHRmX^&BzZQ=0E}Z#-NHrHm-u^(~S$J2W*r3=SXVlLi zR901)nfuI3s?77uJDACgN|#OLVgpQnuk-2B?(m21Rgw+ymsr&wZb>Q?hZaOi_o4AH zKCG(-gfuODoj*QzPu{n~nEgcoq^&d=o|nDW!>=xzzfJEiwz%P|PQ^)d@5Vja!s

}7_4RK^-{4c`?rB&qSq&zmEq$T1AjtXie`-88lg{x)*J3O>vtBrYUnN~N%1*8 ziq^8UeJKm$fJyUfp(;woF={Yy`r7Gy*@~zvl0S*sY2ba?Y57A$%_Uu8rt47eR9iYf zC1d<&OSL=q+Gp8WQxRhwd{xIj$|Pn3k?NP`#tN=T#NY2od=eaGkiC#EY*wKL%28%2 zp2_>j`40y9u&)@gO)iQzG05=}@bJ@UW&;7^qAN@RmofP?s2e`EY0kn`=z;6Snjd}? zs%7bRlYIcBSPFRb=8|zqA-u|-=H7oSYNQ_<853vNc}QH9XD7NpUZUnR&-;oGGz-RW z%>5=y^nNZLW9VP`zK^m2i?^*m7FisYpmq}?fNYPSyqlY!NU}I~g(IGD@8&Z~u?|YT zI2J*HJ&exb;~G{iQGzce*{y$kR6%{{*DGgTRyx5T>9!}Ir8-;tDyzSSN6=U6R@qW@ zE-SxlF2CHzu+6RT%G7c{DwD25PenJIjjXS=B>ULupU&fti)U>AMlpxmUftha%k%zh z>+tT=(*~~-UoKTBL54})^}4^Rx~q5#F1}`#c(y&fOw8txcw0`M zjjy)3uG0b30@GbEPbpC5*@>9kR|Tj%6}RMY{Vv`I8`UXlG8Z%C%QXwO3D5RMs!XUC zO3cI0Vle$@(s`a?Eq3>w&-*=pv2Ad%ziWMSgo0D=+>5a4qX0QSwyz~p5ESjI zBiZE6#)~g-B_8|a{+wL!zfsOo*`z)8hD0H=kHnCNzQWRHGMAk{nT%H&;JQ)g7Qa_Y zQK@2jx`gkOoyfa8OLuu6T3Jx{e4MHEugcrrw8T<;z!4_hFp+y6hJSW7e(k=DF{!kO z8$reHAOofwsPA_Fe&c&9;?>@HJ=gIU;As7P0JTs$t=9X3k_8z$=Y$A+b3~F=Pd8xW z(gqX=0Ch?lDxlw|H_P*V2*Z1aFiZisZ>!GlTL)}9Ey6;2hj9cNpQFlM#}So_JBK<3 zsB*sUo;)B_y*1jJ_;u^?KtRSFgbGe{N?MXDe7(YrLt?7B3&H{OC5HRI{E(neK6}T+ z&eGB1_d%O;H+kvoI`7~ISss`G#~k6xt4VC%b2ne@jG3?8HbWBI zDQc^aB451MyL%rkAM=v&au51<67XaJpo+wUGUhzixz1~0N#|a_IB~e=QCT9U>P_#! zwU%DRxx9+Y*GZoL-g`N}7TBfMjykkM^ zKC@;uU42Ks-CbG1xbgagLwvxiV|pf25l+{-Sk&c}#T1Kdp4@yA-S^=(+5xeu%rq_C zg>>wC52?yMhmtiV_`!Ab=czfNdpnxz4zd=d$AGNdSI~12R7v*n$@PS7dPac5fJO)6 zifzC+lMmRP-~Z6E%i%p!cN6yAu^^m-=^$t+NCntm_Ahcbve3MBVdaKUvt9|tmH`wE zzBENijw)nuVeE)`Rhz=Xgvu(&2SXF1GXY(EgpVe^)W%Qw9yxmt*2Z;(o3d7S3bnOU zX+_K#akGqZoxO8h0!{mX**8A!zXA$0nwQE=s1A>b9rzHuM>sj1=VPTSGPY6?T6K`~ z*2O?+zf3>YOy)SA{zCFG;gwETG^LR;Adbq14e&35@(3tXJZ$cUmk(sTapFB=QcY3G zYIX7OOPt4e$ld)pQ8~k0qi>G!2UA@mxzZLX33>y;%Zy%ciFqCUi1TY~!#;~q=2I>?4}amK+WBdPcl@83I~#rQIsq4$Fkcu)aMPt2mhHW&aZkfZLw2u<~yfw6Jh{ z2&e%{52p*Ud~yXZJuRdS3H%J#7&uQeluHLFb~$9xT?}ltZD8$S;VZn=_Z+TVTd~sC z8?v_j->O6Fnco_eu5J!zlhEy}-VrpqTq!Jn+AuLM_!sh!p_livB0CGq#ePwWG2xl4 zZldFOa8PwDvg_@y?cGJ;BUeH&{&`U%e2*@(^udv;8hCEBa&7qZ3@!D=mz%=@7I4!D zm;BAlp#k5W$B&Nz_xgw-A&alC>V5pdm&!qb>cE(=wHGl^ z!rObgSjuH()NA8LqzgLEacY0g28ysC&KYw{RDsPW=U2Z_e)|384wBFmB@-uF%|$C4 zdJOzm@R)d0?Uc{Lh0w+*Y%UNM^?H!+PU5HD(b=Od^!4M9CkUr_!`m;vHzaw~-S_)g zVDpUz*3d%6j?eyQ>bvp8w^!Yho;;dFQ= z?b+2&Qhq!44?NzMAMFWRfi@zsVOi?aXsTju?kQwl6o^?e{WY9_Hn8+HA>*F9B1>zs z&DRK8rs&wDr^NhW9~gKYdZr(5mqXvoMRiwJhTgvi@cY*()iP>2MgB9zzT0B;R&{Y) z_aR*03_hgw>B6dXa^;gXtowMkXEMr*q#y~aQdyW^;hP`#_FFAt>~<+*qUD$ERQfme zF_D*8N;C)lqu3yEJ}t$9-Tt-iVAro-hLp_=lQ)9%d;}KvAN;*~g0D4{B-Isp(zKFq z)X}yTS+OV!-WQmlw*Rb>XEy=OfRxzr8eHD4(1ckEAVAde)uV8;;?6r?~aG*Rk=RrU1cy zWV+t+n6pi)yx zViJ_zmh$bijpal^!jjUVp>p_EXJT8V0=#MWn-@7TukyzW6A3vWK9BZqhfSyJ$*M0o z>MX5Wuk;_MCGBdDRPY|^{pJ~2c@I*)sDG=2R}gon_=X#eiT^YKWTD#Uw9aFuxtRe} zvY=(ziW^+QceniWh8L(mTb3PRq-_cUpztNs0?Z@a=XF-bg5j&jHnZjJ=Dv6)7-eGP9>!zi*#t}7m_MViElL0TxAZWui{2?VQ>xjTJUNYIt|K_ z(D~UFx%S&8xh-$z#5bnB zt|l!ruh*<+EFQh73@7uPI8q^Q56B2jUFdn@7Vk5KKlU}WoVu-jucoe=)!U2*|FgT9 zf1G?}K#m2~I38rpvXd5T!Y0% zb;14gil%k?-Moc;vIuQT(!b2Rac7nIy;&`>bJsd{QJgIuAbt zM@i!L?(fCM;{lpsV&WT?`3JF{7&Lm3k^v}&h`hYKctEKi!ht}h#!bU}-#9a~sypn> z8vu8H$h&xW*wDhy%`Owmx9gCp!!&hom$e)o0ZIGbI)$_YU^x~ zBo~H$fny^RJvQLWAk(dUdM;ih(DI}+NFCTJDQUXhdrbGQ)gCL7%qGMgIHq)gJ zU?x7~p*{N+qoK~)i$RE-?DtM7)Z8Gci)_#F=`{v$8VU}Cgy3SjR#rY~tgIUg@-sz6 zAUP)bBA9ynuDxFLf^&087vt*SKPw?Ysa?xM`8ByIz-ag|{gHxc zvWcU(X$yttXMS!1kW*mnF{IuF#H&ZkL>q$4i=V`Fx!)g zKg z<}*{aezo8pydD!bz`l87X9YhqA^Mmo{*B-aTtd>pvee*nb3V9~Exx243O!!+P$vkGlYj8; z9xVx99nakK{<9_5@_Qyg;=>23V{mbHa-o)D)I^6d_hDDS@LyB$s!4m~oQW~FQo7eF zuuHMBydA?ZxyTLF=8}&z^z~M1So$6CN1GOtjn%9>k`)f9$`vdHOEq=)x(y2K*>{rg zt!`VM9XifMJif3eR9n$?)M4=mP8$fEw6+RU$4(!;em(SHvkmz#yF-pH8RI0?DY#I{ z_rfJ4bat-13wo^C9aj}yC*=N62fjBvx5OH?Le1s?w@+;tp8Gx;_|a)-Wj5F!WuWKn zH+{*8BrJkQc$M1GI>(Rn=;i_T;bC*dRKDNVCF-_Cj2ujvmp8BL5&hh#Znk1J@jRcF z3Aj&PSE&MbypOFs6&_mfd;az)<- zJ&V#!0iLnOa0|J@+sccy_~u<$<&j%3Wc9jh7w@q<-<41r8}Bp4t#Mm0EQ&|ats75B z$m{v?dr^$(jaA|YY|Hy+>=MYC_~Wj10lw<&S0I>n#vu__6Z;7%zMe3;?#j7JnyuaHj258@D?cp$w6R`KPBQCV6oSAjzXCU?i`&y zvKk)KiLn>v%3e1QU!xtazg!Ng!6ESMYf3m9bH*wmb0)wJA1^d_710==*t%Sn%h=<{ zK4qsTSTMp%a`D{F6385at(bVEnCQDieXYON`4Rqh;0)u&nLueJI+3{t3Wn=+n$8)V zy}8bHfD-#ZroNlLkm%JBX}6iv{Z60m>`%!1>i5%`ZNyc*tW^4}PuY%znRE5g=f@V0 zvjl%>d?Ew(`sVf0Xcb8F&KM1fdMrkEkHL?(*C!`cI)DB=lVjQkei6-*<1&HopyB$c zbga#ah2P|2a(kF1NL;I4VixZav7xEA=(>>iOA^i7rJI(*WiDPNHPbb}2Q)WxaJh7N z#w0#tz8?LpjhR_au$=gfpI=&Hrl<1n9+LQJUUUb$Qq)@}E-{1dP>x^(HYN%dVLJVzvWc_w4rX9fR`RPl8PzNJUAlmu}y zm6+(Vuk$Jk;(vX6UfmgTF_}73+VrdKiiI4+d82PLJnvaN&W>k9TNoNUz2Y#wA&T~$ z&Av1;Z9tAg!TSRzlifL^ygc>GcSzN|tPf8fe3+xO`r=xideIfJhLcgE2B zXGqEI)Tgz$O|k7>^<8_lcS;<$<+p^5`U^EGA<0skiyoC{vikb8e`-Vx=%Qo<%BXUO zD=lAMF-v32t4t<1huH^u9S%G45-wNg8qQ*|`uEzg<}6CwbDJMSHa)tO_&$i*P>J`1 zb@qi_{6UU}4pH~JWWI8>?V;r@CizsA>Niaf6i{r8LbY)V*198>77jrByDVAe(s z3O%&MCe`>W-MW3ud0ok~UzB`IaAoB$un=R9#Kbc;qoin+r8hb$FDDiCceO2yz9BxA zcL{QmI+S^mIeI(u>^iuyFO^9v!+PD`dbe=58ym*|)e_)Q!gQInfwJ58ki#WF4GK2{rn#H|D@WQ;Y4I2Q066~_ z7m&%gO%S~78~uI_>z(26{RV=H*Zv0hfB>!W+N1RRLPj7^HwW6W9-qrW-Edm=hzi-~ z>p9ecUmqt4u*jFGx4sj%Tc?VhmP&1}u&NDd0_^Ah?k@}-joT!wemiTiYx~c<%`0ZQOMfAX-!PT@^F0Q&mQ`u=rka$;^rKD+SH2RCmw!^_5y~# zcG%3ebyu&Y)@1CI)JIF4dR3e(8fl6I<7>!UN&Ux5-6~6f@@>*x;kX!hugm%pW+IQ{ zRmg$cwyi!tPrGpt;VMikgD~R38Wo1;-V#(ZDOa{?_X!x` z%yt@lgyn%5#5g8ktxg2x^8PqAyMV?CE;Rr88PWurxBGuTKR_oh804LJV;B9E@0+dK zaV2>>9nsrH$6@_P5Z8Z-X|}*8nZ*|;_%@rNR*o=4_TN9l@Ywuun)pCH^DW@e#lV{6 zoNE-{vPWBoY2yh?HyVyaVjB5dh#{Q0DCSKn`WCQ99fEf2`CqL^uwR6t8E#b-p2Ouf z!D?GYHV5ks>sD;sInV&6#%z^b-E`dnhK*xgA@bN@3mi7w>AeBD}NQh`~A227!}b8oDVDf^Ev8!YFb_L5KrQQCv|Ef3s{^6OTjA=`Of zS)fz1_8#lZb6`?(H&#@s~`-_F?Pn9lQ`$$Q6~zEr2TQ`pav%J{p77D$nK2u0AH;0vzWqh z3v8KRBF{uImv$?YHKwXwynu`RFgj@o(?<^TM>Fv09+^I}siBOwR6Kp5Vb$WtDQtmr z#YOG|bCU*FhF!IA`C$=zn^HEEklnN-;$IG@pR=BD9<3wWSMhcGjJZC=>ZIh8(OC!l zbz^U~iyJ;d+D{Ng{x!J~QqEn&P=kCaeG@zx*VWQzs~f>OTGM@A)>K-oowzw z<=$|+rK09;ZXm8VDm-BA)?LL3y`Am+?~6dRhXGZ14{(~3|HT>qO-YY_y>N`0UEYYN z*tC8oVw=$X^a8;7Jp3SEEM{GI4Mo7Ktoe>}bHqYyr3DU&Ge46bd{VHwL1)3T>n7Q8 z+p5^&lS7qLl9_wTqZdD^53LfoBZn2lFJmSNjJxYO-BV$Eig}YhG+*+7Wd<=stxQF~ ziYPQs=3n*7CX0$g@wS?`#|6U^38jnf0s+Ka$CYVmsEc+?<}dL^xRB&~+@ItWTX8k( zlgsg$^4IKIJ@x7CY`pFmqV*YVe?Sr;0^6o>ZK!?uAm#FOLVj4 zb;V|Qo%TzaaPEvs-l&kw5G2TbaK(bdoPXc9_9@D^DFCoOoxAkZ`AFdkni57mneJ}4 z{b%g2A;}cy-ob?*BTqF)f@F!UObE7~hTG_vPW(8P@fs>sa7pl>LOhvMJ3>c?OH!e zKV`Zx4zNo&LmCzmNxm>iIpw3cI}{rak2tj&eTifa6qB5qCg?jP%tqS7(w&L!s-QMikx`P&b-&JW}pvaAbVm8483cpa)i9x9m z>2s(PA~S!TpS+s+CsuD1q%{Npd_Gz(De6FwaKHeDC1&w`tzx}rc;y+H zL&Wi=872*!k;X06P5{vZU^Icgc9EaA7`i|2Y6Dnl24L$sQI}t7v}`Ri+*lZpfhH^? zIg}K|H7s%A4kLcV5A-fTM6eR$<0=!Htj_yTu+sNC%}-CKG{SpW(5Tbz6M-`~43WO; zlaw-E%H@7>AXY6BCCw#w*0NH~{2Q^aY#AhhGyOh`2Z}&Ts$~{~1-uexe{1sRNa{d` zzsMH1w01-!TwRyl2neYGrcQrhuubluEU?JpA*RTM(M5wdWw0PxoZA5)_I?}HSb_f7 z>3jyv*NzP88|&saRKZU9?u^~rNCI6K0OXw)5=LnnIURcT>dq_O*falD0N$V4$X%5f=$$^#8@<0dXQ(bO@ZY%u=S za2_`U6%_@!wS#PbEF^rSirfF&4;q-85Qb=($#}H+srXDB&)5m;9C|e58wy5ZXgr6< z002PYe|&GhkDNHM9EAt!DlYpw{6#klpKI;o>lJ)3e-=Nf>P_7Wei=X*{gvd;v%;(G_A(*L;PJ0b&edY=>HEb+5X`_qdT zv;j*w)1@c-hX;L^q?^NrY5P{F_m&$vrs<~H`SuW+uJqmpzPA9ZjcQ$)lRgFZI8}Ck zw3Sn1IJ^#*Xr#3J921dj2>pqcX@~}CIi^iIM+S6MZ1We+GRHDG$xl4h1HfZg=73Db zGELzzqKnQ4*jAp@UqIRT2~$=VDpQ*2TeO$YQ7>0#LPP|l*XaEKz8@Pv0owsV9v0p1 zTc7red5X@Fnb@+CZ=f2@`S^$`qS8 zUoSO&khttpu1spA%-Idgsp_)3`~SR^jecUmTzT3ub$JLYY7g=DO18MQH8$0@>B-Hn zqPIAVqj#&!Kw8U~A`vx=4rKgnTvk|J>Q|_Cu(I6_M0R*ak+9vK-9ABGQx=lLjdlqo zw5SO#gMt(=vgj1DGx5V_K9TleEX4Se4jv-fQAhf73^3+?KpX;@o@{i;Xj~WAB^@Zj zXSRa8S!ap)*sqx+BLnc9(;lGNQOH_Htb&K84^SPPh!fA8L89Ds1c0*~GH^=4Dx0=% z>)&1Ygb|54F%v;-0`P-5f*GLM1OvO)l3vV=rIS=g7Y!OPJ|7$3E3X8^KwIF(pbroM z-*&ij1(3ypgquq{(%2rHR{3U|v3f~8MaK0FqI}pCv@TJ-IFgrjX?gRaJb*A)08Z0~ z-$(x+_o(CL*T>|vPEEUGM7%-e+3gddtX!^>t$uQgx0 zPR;QzICy6BZm9I;;7fxSV{`!8%-$*m=df2u3BXCP2B+f{H2G}g&#`~~JA1o=NY&yC zXK+bs3o`F_Ug-zM68e}`+cyvZI$AH>Rnqg~5RjUAOG!DE7TJK=slIo?Vn^Q&_PA{F zYSD!B19ATs1=$OyUQYf89u9o+Gr*1i6+r4X^Sg%+61YH8ih~vWQ_PUyqx3#*`H-8! zI;5)l9rE6Jk_U1PkUrO0MkQ^b8<>FYmMK_^IaPBqGVEiAW}FL9kG;9C9XWL$NHj*X zp1}RK%{WU1RNIEM$LQv^0*V3qUu*s-((h9);Np_74d87)7%}(im4sEM%ot5BVurCv zqM4_=3Mdb)(Wn@i#4WU6ODV2PfI#PW=C~Wvz#S?C7owPZL%zMx`1s5UVLNm6FC81H zv2FVj$p}>aVp8SfC{@a%wah*&M}ggc%j(bkJy>bKMW=>pY2eXn9q;mG-SiP*3rDFbJVIRFWd!=hu5J^rT2Pz;L)^ z)~>jlHV2^zQZ>+{LOI@IRdNpH-@G87M7<8R-?U(WP%dQ-yK8~ulqpK^xfUvvk1Sn# zbrgA9psMx~U*>Q%TPImW1?n`wZrPVtQgRje>HHTjk_9_ch-w!?auK?THl3o8@;a>U z{MQF$&&H1rZ-v0%j`;1(4maxHnLX%UF}-;FKM>=gk?Tkr(BKV}wnn0xp^^{UwheA; z8OW6izonaDLrctnYmw>w;LIC?#3~EkJx3d@=r&^^#+k0GJ;D{!T-$5eN`i2hFij4m zHp`Ka-nf(apICr6{yBS=x6rvmC~_IsY4nFsD0Wf&ahkmTHo&09;r>HMrySP%SwANQ zv5hf`70)T6*J?E!ugXKtCR{#l4KteXX>0VtqvidgGk=}bUYX)LG_r zx5*QM5SIO_iW~*2=Ai8-+eyC*JQ`B|pjhw}IJFYci>&QQDPbG=^O5SU)zL2w08IQw z##(9s3Ryq03dmNID7h#KQczYn=(-@kGi}497t_ES`fu*BJcx=^_6?AV81dA#vBny` zE3^D^y$JC1Eoh=0j6$f_D2%R^NQe@OL~+)iaKE4b^xa36Hqd)OBN(tD__+^rvw8Gc zJvDXydNc*BvLp6wNl>sLXsr2kEYo1}vtH#x7e>DRBdakvdm=E1qL?(03A42v&2buV zp*^OXbp_)Q}ImOqthDj|mQeuZCxcQAMQ8V~Wm)%rus6`~Wm?0deWb$b)I)poc+zCV-EOvSX@ z!Y>+FEB?iRN%#QD3eTc_$FVntD!t(RE<%aCdJ5|vB%?XNp3{i5+#kp*zwgqCyiXIB zP#g<8KHkv=sDEu#SpXL44KuHat6nxrLCd6GzAkxF&yF92oA8v!aAdI;n3D52C~O-G zvT&MCL_G=4Kdmy$@TbHKT5bDEW%f)WGcz%=^!r^B*aBhBQIqmZziIX2P4A4i_ z30L46%*Mo|zTg}qvzF&Zsfql^k!H8%usUgt*gQ;iDoP&-w`&Wwi>oV(W|~3=5^mb3 z1S(|pg27e~^Gw$(V^ocPoZmu55A;Zn16%g>cC%r1oD5?HjH8008$09#|Hr^P4lf^< zfotur%$Bk~y5&*1dtkw$T+*Y+LEFs4TSKL4)|J5f+cirOvyA}z|H$M((yssgdNRRUed#foX5Z%5|AKuCvBudrnUe|5>}xivT1Yu@ zc#IX*+$U<9k|3OIt>p{2Rc!NiW^yB?!vAI5jaYwqxPPT1nwiqvps#oO zmBy39oLX?5D8hAesldSQ@60W?YDTl4>^0l;li8gk@|sz2=3HFXNluosO)9%bCTFtF@14SGFjARCdk+_VN^qTXd z%17{Vz^q2g64-6Xv@^uBzM%NmxI&6q9#nh{X=KJN;p=8V(DkmEKhBdRE&`>89z=qH zfbz*0_0hI^`E;0sQ4vaD|DF<`5_8oZ!+{2x1p3Z3fAYXpbYjQM2?JBQE&2P(zyW}1 z&DjPm#Y~x`iOex+tbv|&x+bw%H!vS#Zw`PERuOD$IN~o`x0wO?k!;G9OH}wAt@+{q za>+T`5u*<|Aby3=r2v1xcg`lX8ERa?xiG)8xhRa18pPb^j7WgjC}9mWzkJYay>^=nR9=W(v&loDC0ktJOELv7G8kDkJdVP4Jx4ZiINn)wU*C3V$dDI zT}-dIz=rxJ4WQoTsE)YJEMqJn%C&;p_OO8)(_Vr6R*Tqf8uqQhqFE?=CC`6VSmeo& zQw9JOqT{`F<4^?*5pa{x-Wj!=1^-n%ntyKEvrDEJx1A%XS-Ph+d zqD9_;{a5;#1{kQ&3!=ZiXAmsJB;i&4t_M z&?M`PHg)nTdlwgij6*aygqlSGT%D@c`D(n9&kLL+8uLb=MP|=>_I4Q%43!d4#tM{rXf3Z#bA@sR zylJ7~b^1gjTDO^dbH{6@EB-16(Eo67d%`7{+mz~j9 zL!$q0ni2$8Y&BLBbS!YAjIdTYlU=npX7SSPhr=f{iEh}D*z@_Ey3gF7=%mYDg~6-? zX_l<^(?2T2+AwO?+kZW&UeRK(Qz6)q?!CMN!lY+lp37iJXaxLV)-leZY#Lb<_dS}S;3q73sYzC=wxg42v0u02&r=Bz=j`>$$2Gzm?@1VB z;VMONG0RumELvhts6tnLCkO6T39%_sSKUTkyo?^G;rw0pK!M=7CeH3T63}!vz{ha%I$WGhH`+)hO)c9F z7^iWB*Ub3R{Qd89VzIo$M|>&Bp>*ZN{Gdr zPX-m{rd!#5x*%94_QO zm^UcVH+#xQu@^vgd>D2A7D1^NApEKWzRONA@0YGm8}l zA`=4=sEC3#Oz(XOCRK*&O49~FW8Y<$dLrI(7$nDd%y%o&?hsO%= zk#(7r=)-qjiZB{B9QG{yM-6P-h~)jv%NJTKNuK`zg@*}H)p`nrMJNLa?L&TnEmso` zaRhAMYI=JhM){0ZpoGJ;`mKitm?*y<8qmY!=&3!m`%H0!o~4Dmqj(IT9Tz96vf@;g}RDcrPX2$JH8s)@qM!VQVbBS=R*P2jW@ zh4T3K(>#Y=PtdSRbK(_#3vE6zFD5{+Tx?rZh%j!j-JVt`Nc7oL`=C@JBNf=H3Rez0v7wtr-9^&N@1TSlS0wv>LA{sxn;UD)~7QqLB_ z(nb{uHYS28KVlgQQWo8}ku+hPj|$JEquSGZw1MYm_WGIE*;1V{-&v)@{MhV)DGPdD zzIqfu5mt9DBJ42!@qc^P2XxLJC{M;KHF*COqO7tP<+L1s~+Q zD9_9R2J~7hZ7WGW(|`!{7Ep+q zC3{V|FC+;aMH)}qG(;t!veYi4;gy3Fe*cLDSTLBkhu76aos~mwA~#b0_rmvNNZ{FZ zwh9m%d57C>CvYP%hrm_hXn|5EeXgoDNHDC!j@rE9()*H#_fdRY7h*|)#GcoQ!c^}> z;ARZ5aVSO~QR{%uhDbn~2JBfQGyOO8l+mL2blxqKGVzTsiT`0j~OK{ZEq>(F=;CXyuY-sAOP>D%CU+ zQx7qBW=hUi@xEo#K8WcBF|4y*Nu2usTm7mw6rb&99UL6A_amk{nTiV4+|R+OML6TN zwY5j7n?Tvp2>E=`%*<@}xrz@JBOG?*us2hjkbJS@$i(0`@pYwbLO&;5wgLs_RoYG! ze|WthcDxS}`wi!F#NAB}QR_!bvN}A62dxEqY@tHJvv1^|PrV?aV-ETJ__yfdv3H^N zWR_#~dP%9&f&St1;>gGZT-i?oCVC)T#f8MsfC`TuE>BBO$u9y4q*=eCvA*v13k1j| zu051QDh~#*ASAGn!bE5BJ!O`jalv(*obKX9r#vP<|n&cK+V^14Y zJPt4qfVv@WuC85Y!j7npd|gVG5^xQ%y{!;&yS_;d8G2VxFjSlqHbjLG5``^AR;F?YqD;VHD|ws*n?$RGyM%m>hMZud04{(xo;RJVvH*%L zJipv6KH0%T?A`ZdskoPJw-w~wk=tp;SFifrbapO8lyhNMvTrXE@UNhtk1_K38wjqP zF*Nteg3Fe6Kce3a8ns}lx>C@xOBmPuBB8sm!);9y@ZpF2BOm03YU>@LV~-_yJJ~X` zz`CikT&_73aEilML=PqYk|2CZrY>(=pGgUu<5qXHpkhqe3~%6I$v5``H#)IeI!{rF z$tSOGvi)L<`h-BSyn6vxudd(9%E|&EXZ_*B_rU#Vgjg9b@;nW=LLeM$Shol&XI2h( z?@cf_(xjjH0!6CGIH~(c=oEf+E#}XpmJeQyhakHB;9`Y$PyhtTnot1+G;xmv z&(2z8G6e`GEAAD{OKp#>qYu)$n$06Io_B&yiS9F}dg8@>+N8lfbc`mjJI~N!7-K=; z0L^htgfks>nlbS-hgxfb$iZ6J8G@j@WV=j`Ag>J^9rJGgy0)prt-0V;^`4AV40?Aauu{4OplV{GR5Ij}(s{V3=#K`^uFm*_z>JHec*7QdC#Z-$y3M;eOK4w+dtPP z^cter_TK;PNer7iwrshtvvXwv4i^o*Be1qtK1}5*CoA}T9PpaD-w$cS{mKja!35MM zf>3Vs@U-6V^(ZJ*5nuk3RyFiLL@{{9+P1kfeujI-Ws8J@ zRr*y?kv_utEW>m3K>6;1QVU80Rd%{`eNVV@Nzs95-waylhm6hj&;us$w zY2@arv~GH}z2CKc-VC?uje5OLOuHp=Khpc0?$)7<#AD= zpJmHGl5%r%H-v?Rjv-;WXwf@R2(o0S)%NqNLe67mW*oMMHmmK~hm)YTJ0+ra6+#jc z5)uMT@}A$DM$OiKAdQ@gq%me6v1x@yw}XC)*%+D7!l6BWx%n!I3&e|BppMrBs(1n^ zaC795oT|DE%Yi!kS*KT1h%M}sQr z9stm>Z*6TsaHf15BwC+>b9^3{<@-ruQVsMJuHt90)!b0B;V@j0;8o}wmd@j$BHJp9 z^Dw@hCXmX8BCzy1)~GOWwXmnB$8Dw{C7cEw&Pi=ZYig|kxS$3088;aIDNu}F8I1n+ z6aXs<;90#vqJ0dyDGXbt_odgm#g&WP8^JzBI^8DIR+RVQ+6%F5dmlMZ)9{Q0luQ?o z*M=@`E32yq-zU)UDaoTN%4s56310?5>sL^`Vipi;s5ISF`evpJoQE5&OFJsCo<4aQ z7!>DUFWuM$48zi@0^jryPI#NH#YkY)J#Dh%l^UH4jDwl@{pt4^t?%>E@(WYf3J$Wk z37A((4^e?>)Gz^h!$+Tghg;@+n@l#;1YcE<@Z96%<7*!@owvIt`GnJj>H&?a&CvOX zp28niyOM!uMdofZkG=MY)KnIdKwk;hNvdtoSvUju(djZkpImfgMuC=H%?^uz89_4l8o zrd~e|SLXd3nXGF;w+VXkxo!eyZ90-QX;q)s{=H%5-1`|Ma*fy==XNJ>})<`jkl)e zr><_vO1Kwd2y@0%F6&B(YoRBjzuLj3AzdhO8-NXiB zNcUX|n*{d0xShN`yZ8-M6?Z%W&>!IOrrWV+w}24z8RXK8 z7Q{?JX`yT1`KX@4o2{=Xq)2qIb;2*Kn17|0`c;>d*xrJjhA5xV5000Gdva$>lMEKT z*Q%$A;`TTL$xAz^zz_>-zgxbVet3o!Laf~)xrv23pN`JC8}7yelvv+pke1=64QN>T z0~~z5$1$q?2>9)c)&&Q1xufN|hNw^_mCCKr$6}5>k5@pM_F#-4DiR6$J6gOKQ+X&j zq)u_p8tLdG|RH3>)?{ntopdT`Ut51A{?v zHnbk4wZ3VILlzvQ7na9RK>*G{UC3j~Y>&r(8=Hk1IOl(Rp%nn-8Y(L8xPT^L6u758 zrCOUk{)5pZJT!&OztrWN9Ag#;`rmClTR*91*Dha2u0Va-v0vTYlH89YBK9F8`49?% z5{pMyJth6~jf*t`uRLKAJ*Nxm-Wg+#`j;<*A89``6JTaGICDakoI`Lyim(c&VHklN z`HCf1B&DlXJ0xJ6o^HnB;qIPH9VnI*O04^67~Bu+qS64&4hWUlZp`(58>99bd(yv5 zPBNmt$Ib>R15`FTwZR#p+|^onJ(~&g1hlZ*yYd|zwtwH2Q4Isbqh`8kG5JaAmNYA7 zIn?NY5{>KeX6}Ci+=i?D_9JuyU>^&DBKKV{usudKA&`ZfJbBWkRkWcl8sj^W=K=zt z5LPJzDP0innOy|GylcMGtdgR9C-c-!e1mmLscNw@nH;iaeBJC;>8&4A9KBsvjPagm z#q6CYx$ouR$Qkvrw-1HnC~J!N6x(Vt10nGNc(p3{yn=#-^0(u$%VRmOq7p|()fN?c zT#vOg!f&Zqx@nNBwdW_l?lZZGJmOn^w)k{61DIp=nv52{TJ29&VB+fNteu_1y-GIu zm$`pK1)(^U06f{|XQ8+?LS|g;Th6(O#*ZK)KnEF@+VS_bH1SZL?)${;3=Sx?mh5na z8s4#{(|2@2W$M(Dsx>7}M1FEEgSy9g^wbJdgUy}!dMQT-hmA95v<2kWnXSPzhpig= z0j-#nBDEN_Wk{W?DoNS%?mAys!SCA8hC{v(`KDl#IDzl>{j`ukm`&m}`|-l~s;Uw%iF!Eo-7b$=E_tvV9=TlB8cBvnh_!o5A-!1YwCqzcxvGwgD??y>2Qog-E$ttbMO{`8TIR2&*fvCoiM z0!^uut5>f=KyKiLqS$+fZtYd0lX-@iv(b`8?(bxKno*)77g~O24t$$Q(Q_J6R~pVU zLxDc8xue6Kv`8Zx*y<~Q-n%Fdm94rIkcZ}b_7uvg9mrBc9!ZS6y907ceRN>o`?F*b zXPt2OZ(Y3TR#GuqXcG@6>i^f?n+8I?zhUFwL8&B_HG8R?lH_FH3u!r#kYuYA3S${V z*3pK@Qk^8*M2jNJP#D`NYlX3nb;!Q&VK8R?_tfc}-~Yw)`g!wwUsTRvW_*@=x$f(} zFTh6>DQ(I%(9?IVJowVP465YtM6v(s*ohUWiS}26LW4N7=2&K+b@HmO=Er?FAhSHv zTxypJ+zRe(BaiqVkVK3DsnIXlS9D(&7M`F5fbLI4ca%KlqPe-b_O00$geL33F8+0| zN>Uu}w}keGKW%yT5spBJD1*-`hMTQXDTm%3{p}s#BHU|LmFh$?LDx4!%xHQ)6I#6%`emvTU`- zdTqsuDzb)WK#zNvV5z~0@NHXkJ*%r*PJ;2+9;GUR>u7aU#crdYo}&B?-M^@8LU7`Qt_EVsSl0q+Rb%L{EnRdny( zp$ql`?mWxhKaU{9uHLd3QOc#^M?7*OFP34oaVI{#1W7OXIa;Nn2;%D5yY-;YQPj_G z|M^q)a$cUERZjfwBNj7~N5LY=4HN7&yT$GNoE_gPiy%{v+c)U^_ZA(I;~Rru^sQ^f z6EwwX*4brlQz19fYoLS<<<{XuNA%mESV+IPQ&Dj5ZQ|Fs5E>Gdm zb!*;ic`LG)MrHDb{Bu&>%C-eLJ|NasDmhE9VV;3+IL3={Fg(Dz+um*o@;vz&(1uhd3EtZBl=PIn=7{$67>E$kh zLDos#D%naq9*b})2pk6l!0{yGzM%Wc(9Gy)>}xNSr*}_9(*xd+8Zf-SOcTol=Zg3E zf^q9a9XaC_s#;N@qG}N1wo6roDuz5D)CV9M-fXpaZQR%FE9Fe9pFSAgn;Qc7oWS)$ ziL9;+KiV3#a-p^ofqocbeYmunOY zp-X>bL~=TX#bS5#vbGwLG%;m=0Rq|+kUI-61M4OCShqHFX0d~*$6E#{K#Dw%plQ^* zxtnYk<57j&2zh^5F!#r185x=SL$b16bDXo!_p(r;U(dY4a7Km;9CRZdF}1;~r%Gr> z8ou+tl7+rxy=cR6!DhFbslXf5dd0^BRS_SL8ax#8vA3|m_wZca1b6@U<8x^qcxuKU zLqoIDmkjTdnu#ZQb)&cZTty0Dah9&8!>^3)E&zvEXzT87TzoV&DdG1ighL-G6plS* zZM~ic0oIHYMQZr3)3!`$rGY_5vG6Jl4WT=xU~VadE4zxsD4$7f-JKsNT+=c$R|D}{ zCEh^r0BTYDB}>azDpR5zL%u@dTXtbyac1U6>f4EngRAs?YkN|=?&+ZIHWNnIPEeNEWuHGB8ed=5r4Ikk*P9i50@zw|>o%zKs zHJzEMj=Jfj%t%j5yV?8rv7TIYrynFo|2{^t=J<72N5_i_*J{3JE&t_fz2dNj(#`$n(RgTOy>9+7#4~(fW$g8O zwY&jrW*b>bQW$w>LP`jJb$CwYOVo%$594T*)@scY7QG;A$}ocVL}AzOErGv%4*Jwi zvb_~U(w4T*+@YdHFB~(nlZ4~F^;h<8H<7$3AB3HtP+91jz%rC7!Tc*>7fH#c$w>b9T?xqz$A?`=v}9{?9Nsde22p@e)uAG*v+mK^^5qs`XU z)m6|8vo|q1RQ_86*16hA3}BegfMAQnsFwd}IH-S0Uf%YaD!EYP#oN3*uM5|&&(|TZ z9f~4VnD_tZg;+0XO=+btp;(VyAU()gXbqfr8aQznITiM0ybR~|jbjkL0#RA6HT_`e@$ z+{|UBRh|17TwvRpZ__9EjhI3_aaXbqEdJqNCaBs@NH}UYWb+llATz!aP|70t0|;8% zZwoTpi!w=pt6%CgP1G#cu-X$de|IIzZ{MlAeLrYkRkU|*a#PnJ3Aj-Uxtn2jLw$QA zhg;Di@;@hJrJmSnVP)GpTRYpQD;LvJy7Ut*u&M$(f-l&7x!^rH$y8kW(^$uIq3Xx= z;j&f*qsaj6j6Up9W`9W3U?CH~AD@|GAUuLL(vNdZS8M!};y2o` zyLa58fjB;s$Mq}P7KrfSxV-7Jy<@Fqb@25`FBL_$ylRM^mnUHOp>LTp8&G&aY^4$8 z<{=VYWk3aIPOgJUTQ;E;DWdeCv?0nJ^aG^D3lxFj-@OIb)vrysU|BS`D;MSFc;m(? zvT5y3-hWbse?6A_)Rc=o`->aX(Yx=GM=eZ4%z4Xh9ESWp?7E>dB_m;Y$96FL+fp5p z+sJ!`V(@1o=N65$skeH;gYn27IIv@FB$2B7@5}vkh4+hlIy?8L#hkq42CFqBsw^KR zy6fED(49rn`nA_>4!N(8P_{z0nv3?n6~J4zGzZK9?@r)%Fl&L&4v%3= zD^toX5zSxqOrDmLgll&Z)4#{5|-MI>J!W`0-$aiowW(=!@RIY>U2I7dC4& zPY&1JaCMy-`|;fRcG427;PdUiPN2mD!sl;apUU9oo{{L+pBC7bFeyff$#{|r+W2C+r@}~~0WMO9F*Q(CdxcLX+85~M>mC|W)yxF# z?W;gY8%T|bi4hL&TWVu{lxQ;8&K3uz9SGr6@PHN?GvBZ~f7)_{TH#+Wb(dAQKpP2Z z;|U!%G+;O^cUNBP`n!w_+0tl7HsFQ*iqR`Rhuw6@PUAL>kfhRE1kmbFo*4fyc(}WU zLl;w*{eVK~GJbWmCp+28%gn5`=JDg5EH{4cBIw7yN>0AA*pRFn$vYCiPNZ@)knewi zK)|x`$Vuot(|gkF;80xXa>=>~V$(Id`4VL3PNHJY&!F=6A1k?;H&JH1p;6R|nC=Xk zV7&aE&)ctsB@^sbYAveKA(X@5_LREK&CFB@|M-8tjf?KzgM+IqK*VkR_O;=05`1gp z5!RCFovF3!AslkZAOsLqIjtQcStZI!lt zGn7r+pAiZkX26ebl0enGzqrZC#%LQ=`kH^S?_y_fTGY>@R%1J^$xJhI!pUge+|Zb= zIa!4sS+UTq0RqAk^R~Yn)*|YC2tb#fZ;gqegtR)`_VT|F`mf~~KI81-vSTsHwq#i_ zLim_hgQ{E&KknKof}{wX@FK#Mqj~D@qsCSXEc2WLnLuIGC@Ls;mG(9?3D|K+v>a@4 zZ9i$H*(r0S66%1n0)6YwtZYYRQ|%PpUIQZ}`DXermr=v)`U z_n4!Li;rmlbPs^%oHm)6YW1Th_4nrP-@YcpwD}lf8F740Lf)T4(vLpPcJd+jGrV`) zQBU#S$!(>60MOwr&Y!o+(AskOV&K=Yqx%1w#MJwKpcLgZ@>_b`$`=J88{`~6h_B}l z*fAorj+WqVDCv(nbd;mx*Y=-idX!+9&rq+M`5PMGx}Gd`I?ecb`}0ZKugHK{=kLcr z)7+A2o@wR&3C41-r`&R#^X{C-YskeY5*`?0E})%GO6f+Q1-pcGzBtSs@$#gyEzx5U ztEkv&^6lFaooN7}egX&4;|47KZEAUXP1Y!lS(pz43``^Ael87!?KmFZ5&9)I1SjrCKpmey1o<-FiEiM(Zuac5#oF z_>+$x8rxf>t3jEn&wYuBY!m)7C<@WjZSX@~e7Sfo>~RV{imjr0g5px}G9`un@Fxuh zPK@Ju+m*JaCVDNH6}czV$GdJ6k-8|jQ(=Y?h?A#?Wg}AaXX(ZxYM%>tzo3#;GgLLF zm;6C0*%j~hd9L@5!-uC`#aTB;@>0tPqplxU2V@G&Cny$9mChSgtfi4c7a~zIVNB6V zNPin|u#HZ$We?vj-Jv4}Ej>;0(96wyIi8LR52LlPf|Kq1;~3_+3=_n)XYo+%pSR3e zFbmKonl$VDTGkhF;W4pfOhTc%O%(3}CRT2*E7TZ=d>5amTztR05dS+*fbyUplzg7N zT^=9T^drB*mq#?K%7+lT1|;`@nLZ0~ep?&c{wV6UDJlY$pAuq9$~^P$XV0MZ5(%0- zFE|Ra-pt;bM}7<|FTTllR*U&cobT<5ex_YH_rwq)dHR7+u4?tWu8|*md&h*JkGVA? zBLf4{GR}-!|FcPd4UKCOeO+PBLTtL<{={vBFO@@96NT-1gMSDkjeO0q%f9ofnvbR^ zA8if+AF~h|rKe9HMNh!QaErVWZ^_@@9I}=;k3}1+66qNqS9D2=Tnf{s_p6iUibfF> zIHbZIkwMO^`h=OQy<-Vna>Z_y;Je>|1bC&Z%4dh{|AOnI&j5_C%4ei65001kG^KPU zV+{K>#uhoJMIV8EG2IpC~169BIT1f1WTU>6uw_3H@(j|jP zAlsoEIyOWn6jcuD9ylF#+lzw^y}qKWQ=q{?2n+k#_mkxScR&=Z!OaO~A<#qm7C_eP zD|gOIw`IRdQu@el-o|&gzYA3EUXC-+gODfRM{_Pg&D+puUMIwNw=W1Z$o**>8`r{R zO8<0K{#T#?@j~ozQ`_a#9y`*?Fi9?lk9lkxp<>X-U8Op*S;GTSM?6B3iEl94i1so^ zL#agBWJhk4h^B9??sz;ZEIj(-zo{`!ePkF8@T3HOuaZ*6c8KAnGrla!~g2JKr2e}cM8&Ae!u~EstKW??b2FZyRjP*C(lOQBLlh_4 zzq)Ym03u>HpQm(C^WmUD_vmzH-U6UoOk*ZvJuq{6^JO7=8Y|2)G-cP}XIkc?i+m>A z8*wp-;a;if>w#XV(7JXt>Emi^PxYb)Uu0AAJ^LEWIWVe=q>viH;nC{_IxXt!$Lc&G*SQEckjXR(VitzWjbw!ais+7u3~Fdzaxt9v`cu; z!*2v)-7Fy~at-AOw3(@8j*Gq)Oh%EFvBf(twgx{i>^gnprG2(0{#{JkoZZa!<$fEn zV>2|l{_ee-&eJE}OQpR1Ja1Why=Q8JW8YhXOzyl(8-Ajt@1 zs$bh36#jmmyIDW1oeuQO0pFejmUg*vwLkoDR6X zhbKX7;P~ToAd9Co^I?ZnaPPY~R^^c8<_6OvBO`Ckk?iXV&qS(?&+cj}I?_fgG@6V0 z+npzf-Z!#&Vo}fA7e%l%Y6xiTWE70wcWq4BH=?Yhr1bIQN32{$q0!X3#eskQ!+dvL zS65dHl@Z_K*UJ~iycfG~Ke^h9l7TU_o1>`?9`Qm3B_JX$0ct0)ZySSQp*1bQURk6NF|D>er5% z?VF(U4jZ5*>$$!yRV#=fcph<$(aO1jYKd!JCqri4S?e@-7mA|TVrGpL4(|*7C-0-X zWf+ef;Oyf+quE|LN$X_U0Uz@^S!1C)gP(usNo&B*lWX-_YsS@P1@8ljVYXU0hX0~Z z+eKlXM?d(O3Y_y>xr69WGxzIsilAU>Y++$e&U{E3Ts?Kh&71GFbYH(|F-Y5~`oCDX z!S#|gCq`VW17oJcDa)9|yV*COU@ayjRUDna^y|g=mN^*W#~b((*;54JO(Tw^{3Mdx?r>dgN`Mh2nZb z!E(=9cW1t9Z{SZN3>0IYu3kuo^v^}+g1^TKp;~+`?Hpa|jw8YsY#N7nMFb6g{Gh}( z4J06Z+_MQY z-WR`s`XFb3459>ChK?8r4YRnZ=aJ{s|Lp0h5u!DRJ61Wz#!*N9fu;5+EsB@CA zR08EAs)c8R^jcil{?OMN$sg%mrR1;n`Y%YJN4xULf6vdCo;Nj3_|f+8)C@`KUwiJO z;BYRt}%}%X`lC zSvVOxxC2)Ifc04jao=3jE6*?S8Z3p7G7m;RVkOaSX!B+eTN5|%9jcrgjsE3Tj=PYcYDb_aVpKSho zYh&XMW--phWMCb%a@$<>NI^y)H%mV5e2Po_oYYT3<^pcs|2D(hGrgfwcD(e(eUG<- zgG1k*S+{q0n?i_rFt|hD$ir!^+hP9&F@f_GiU4+qbfKjB!vRLQik_W~LJIc%-F}h| zKi8JTJ`6Q`m%4rt-st`WS1WJ@vio7E)7pr6>3pj-z#2<(2M_MVvVP6N7Bl9XH~XO) z>pMA~j$;gfN~ymw`F!-%o*8oJD;v5W|1uwQ!=c*#twfpH{W@164WIHz)om;}^?eD$ z5ShV3%sKd9G%?ZaN$8Yau|s{q z&q3$XPeew*=3(X4xpc-b0YsdPG82yUm2tNCog5Q*mCe(MY$wRgCk`KA>3N8%BR#`|F%|7nONyJ8_e8w=A4__}`ySgOFclEt+O z{k{@D;v-^WQ!qF3O{4{o$LD3ChR?`Jda_emTEYt>j~{t(2CXdctsPx|KoCFi$B|%U zX)AsrxLQS3t*&FpX5m=i1?agy>eHUxr!6ZZBl1od)hz+*DM^AN+B*^YJG(^yK$)I{ zj|}Q3Et(DtCJx_)0a$)>Gp4g9bgAp6nrFbFTH#e|+ZRq}@Bv@=w<#V2d(UcW?M z!Dh)~A+5GVkJPtP!BR;hh1$C6gsmVzdopmW=tJ-GpzmgJfKas~e|w8eD| ze1#4LpYOoK@-eos7^{GFe)ZwQp+JX=t$f*4(`{yV|Ett2Zp!rb^sI25ZR_cgpD0-# z?#3>4INfFzzse-a!TJ4OSX@fp5vr)e^+wo zs#Ij|elG7h`aSnT#wDLqCML~RAEk8G?R2XXe+AF->c&&`rsnikzoU!dXG_dYW;!a? z)>H7yp@CVe;h_$8I->RbaMd>ov2oaOs8k zBD}H~eCU(f*pH-chCVygYBhWke4z&=^QWD4uAO9`hLPJt}vFo z%zosj(?Bvuf*J4?#uta%^x-w~V7`s}x2Y4X(Tq2x z__8m(qG;AA2~?KKCdhp8K9AhC;Iv0j>IVcQv12#c+7n;O#9YnPbYR$h7y?~P9A>(k z$iwQ#$H!GN#qCplUyNTA-P2w6{?bs({YKk%Ri%q+*j%A!v*W__MM5*P=yhC`MxphR&z$;spkhdWmmz#-cSH#d2NzWaCy6n ztRs8b4bcy@HZ(^pbsQxK9SmdZtBdA!K>=pGI*RNqnMmu{@X^43*8hGSxQI$F9JZ1K z)RD$K7fXCG@E!3*ixf-c#RT!(2R2an>Fp-BL-tfc>87a)y6LeaSGYPk+`gG~a7Swh zUa%{hBD=2^)qqp5!l6Z9QD7^s3iRG^t&rw@=?Sl4?8Uhd?QYK|*ll^j zhYz*RoBkG>gn>5*(GcP}nf2ivPOdYz`f|xi^@ZbUwx#q=r`)*5Mjsy-14O4sC0I}2 z`z&!o_x|m_Y6j7W)7F;RTijR^|a;|BEo*UdtDe}1!&egNi zx{|dgg1Qp$qiQhuG)nBU+WVZG@sm(ceYIK;v>K~(>J))b@L7cji$nBowA2TGa7lgO zKB<%9I*b7fau^ycs%P+!i%Kjf2-`QhM!XYPddJ5wv`ajW|19wcj&aB1Ix293dfx<5 zFCEl&5?rr!Axl8FqB|#aTi;`>uNeVOtNE&+z)`N11K98=ygVS@)1OW1u(y2A1c!LRuPVuX`Dni#h@lWhW~tAU)i2_uwRNb>wl9 ziOYwssdo={I&~rN<*7)>V}btlw~4pB*tyUMqUr%#mGxy>LhL zS0Y-zrv0e)aID=|7&M^f4}9u!pkOK&7Z-a#{rt<3@<09*VXxZ05K(#fOC2KBv-g~F z9ah_F{*r%H`<%hvC=?P@Gk&^Rk6j=d?( z0~=O$6|vTA=@KR`oGcMYGha;zSh~Ru6HZFHSTVPgnOR*X^d+r-`}%b?J|Za4{&p|f zdAq5gprBdYjOM|*;%<%AIPESVMV_~hJX7mV@R_xMlbe8jZjkEZ8EX*l^C6BInHNjw zco<>to)zhfDb7ew$3X5|O#xn6#;_T^4&v68VMu0hz*aw63UW-!mX*YY9|vtr4lvdg zP&@6k@0Zk*X%7nRNI!2=XCtCxdcR}Stfi2{o=eQ`7j=-P;T4Ol{NIPnUHUw+1Chkv zH|D5f%k0RQ5LiPyz2k!b%A`?kDR)>&`alBbwXLoNJFa(h!+oU4)+Bmlt-PqjMeh%4 zBOV*Uww0Gn2tXUhLveWCeoGx2mc?|UmCsIn{IMrZ3XA)kyiANh3KRVH}vLeQv zyCXUw^?WvGM4^-61bSp*&=8~Mq&5K@0?UTay*FeZS|fqpNI}MHVJ!HQT}2D@T2+Wv zuFdTiBbLp~+(?T+Qa_q*VP=!L|77h0*Pn8`6M3K$Vb=1<8;{Cf^x=~P(WjztY)S z$XhmZ7$HRt3<6{XtifJ{Wm@FeA~5_bXxTQB%*p`^Zb-A6L#*4;>}m@%+umk#RtH7sDO zZHUuM*KP*$EKI%#YT=NjQ7Pv;R%g7>h2HG9>WD0NJA8otX!(|Iq}9l>w~8YnJwA2d zbSWO2zp(xu=7?p?6~Y*vTXeo`w9^&mb))*b3LM206CzB9U3-qvor_AS6u}POl_XU| z8Q|8?JAs8`WUR=VMeet|^{Ku$pD^5@LeE1f zTaI;WLvtxfRh~ji$yV06(HJeBE)=xEgEu_XdR$(>O_Q}Jg!*lFC5c`B4NN6IW{pqr zrxT^8+YHzzhs(QnU#?rP_sj4}oKTWD@rmpNZb&`+zFQ>zxbi4JmQ?AR7I6=LRKs%J zc=p!ONPhu}0Xw}m^}K)T!WL3}YamGM@_@}W66u2Su3j2HN~)%gR(tA zFVEehZgRWCr6%afMQS5&^wG%j$x_rF;rR6PJU5vgdb3IhIx@ZCvhuU9t*esyB)n>( z;;C{nO7*UC(;o`YZ*r()+&6xa8n>oNCfnWWQ{+T~67PQ$DKjz_y>M5u)oMAxv~4f9 zHs_#d&%>AJUe6avPy|+vcyWymzDr6i8XigTWN5%ppvO1epJ#xm>d1Ypnf7Fq%IJ$( zgHL@CN?WYegi*&RDO%4mF>p3YOu2CR82D;DPH+p3?@-SRc50JPtJ*{Rpx%`udFy@| zsRIL0isp;+bhcdfQ>2nxu_W{Um*;Sm+l0+RsF=_6&||R^SD2y*(!N_vg6AYwvo=bcLsMEzn~U$>Rn&L$ zJnj82FgAJUCWAh>ue8srab-uyo1F~Uy4G%QO+g_Xycd$=zsS)gXdgO9scycGz89NJ0i%~lZ;@U=!Nptjw6BM2r?O6N3Ka7=wa`; zbqE1v8RYp6FXMzMmo>f>R<@R>eMZ9Tkzpgxt6kiPWNwp%O2}Nh^D-wQxt>BN1bJ$m zP!bo>*(r?Zc)cz{BEQyyHILRJdhJWPBT>Wp&z^iut=__bpUmh5TlO6ieB5IuN7SmU#{G@ zhl{XDL|;2vn)PKwFnl>14)0~601>tV3~sE!@h$1x27sh2jP<=McT*(Fyx@Ow=)Z(h zG?|>u`CY{II}2|{{y_9us62wqi~nT*L6Cv`-}=WoCBhX!P|2tCj+wGP(*M0r^8eob t|9l3@m|zH)Ots*@z<3}VS)0X<1b80%Ar}ze&sw-s`i6R$$1mUc{{SG-QPcnc literal 0 HcmV?d00001 diff --git a/desktop/resources/icons/trayTemplate.png b/desktop/resources/icons/trayTemplate.png new file mode 100644 index 0000000000000000000000000000000000000000..a9d854ce74e383653a7c4fb8f8a655ad7d8e963b GIT binary patch literal 561 zcmV-10?z%3P)CnrbHDzM_Zt}Xg5bc9d*{wM-PkPNek8RTTVHN#B$CAA{nh^HNOd4 z(34U0VhL_SD@@602il-t^Ldzq4p^0s`Q}aiEeygM{DDtOVhyywLl}Sx=!7BVvsZ!- z;G-Sw1C+Srm)f}-;U`>^h;4sbN3b3KS1K&{EPR9Z@Rx?e0<36PSKvvImpIMAgqB?L zOUq!`CB^s`7L~toMR%Il%EpdcD&M6@ize{GCDqyu@8KCN%fZ&}KqdTw`%1|p1$t*k zIDp&mU81iwx9?V8FX1j+haUJ6TNS-)iQbORf~(yw=EL&;aEy z;Zm*8m>}YY3NC1jW-us%3kXp|To8jkC@6}E_#nPWh#M-3xI{%1 z6ct3`@*rViOh6;<7{zt;L5y4C5_M+m_Wx7=;hwJa8Zk-1$L+p#>eOGSs!mmRX@iwQ zH2btKL2Us0&Bpx-X@u{)fWyQLu$$ih0=@+wi$A*f*lI!1(W&4PJ!kZ2M`0_#6ME*p z1{M@UJrCRhMngU8{?W4*-tlvp`5sgIm2G@)(P=j^!{9b>jovpzVZEc<47P>d-2-j_ z6V^dgt&5nwz;obaOOdePxLbP{6N^t1Bex1H0PC%ds9Faxds>?JBP@+(C~MsBW@u)9 zCoM(&BTMV>2g^j|jv0Zv#GE8C!#22(Zo=H>YKfY%u+Ym)3@h#e=UEk*PT~hCu`T;# zq-KF>;1vtGt(J*lRNhd7G8q0BF|oKW_QiF{0nfD*t<4&WV92u6su9JEoIkg=_r`lbf+2+a)Y8IAk&d{m)ixe$B-#=)PK@)g@1rTT-x(cnu< z*N&YO0j`g$)#-GwOg}K(erCdYwl3hdo(vjLF1Y7 ztMt4Kd?w?^!4ga9VSm~3R>&>;D|iMxYiSuwSvA#%g7Xn)8kaYda-IlPD&< z<%$qRGcgWXl^9M z`2}r`47TM3jhDhTuAvNue8ID6x`a(yYvLT_V(-(MU-G?h8W}OBgNH5rfc!r$`T07F zrRODohx-{+So_h@kV$Qo9Qd3^yJim|vEJ`J!}2-5ofwv~gF_nf3h@-!6`W|*0{30n z?@wvpjcm^Y_+m>(P1|WLyEyc%$t&!brK`v+YyHLGBX3&kg}(^Y)c#WNpj9blpg$X8 z*U=_HC}yZ&L73-hV$r}z!Drw}jy0ssL2|8I$QRR91021!IgR09An_=kC@|1o+1 O0000