17 KiB
CLAUDE.md
Guide Claude Code when work in repo.
Keeping This File Up to Date
Update when lesson learned. Update when:
- Non-obvious arch decision made or found
- Gotcha, footgun, surprising behavior hit (+ fix/workaround)
- New command, workflow, tool added
- Convention set that not obvious from code
- Integration detail clarified (IPC protocol behavior, agent tool call edge cases)
Do not add derivable-from-code things, generic best practices, or ephemeral task notes — durable knowledge only.
Repository Layout
Monorepo with git submodules. Each submodule independent repo with own .claude/CLAUDE.md.
| Directory | What | Submodule |
|---|---|---|
adiuvAI/ |
Electron desktop app (TypeScript/React) | git.muticolturano.com/adiuvAI/adiuvAI |
api/ |
FastAPI backend (Python) | git.muticolturano.com/adiuvAI/api |
website/ |
Landing page (single index.html) |
git.muticolturano.com/adiuvAI/website |
docs/ |
Planning docs & working memory (not a submodule) | -- |
After clone, run git submodule update --init --recursive to populate submodules.
adiuvAI (Electron App)
Detailed docs:
adiuvAI/.claude/CLAUDE.md— commands, architecture, AI subsystem, design context, conventions.
Commands
cd adiuvAI
npm run start # Start dev server (Electron + Vite)
npm run lint # ESLint
npm run knip # Dead code analysis
npm run make # Build installers (Windows/Linux/macOS)
npm run package # Package without creating installers
npx drizzle-kit generate # Generate migration from schema changes
npx drizzle-kit push # Push schema directly (dev only)
No test suite currently.
Architecture
Renderer (React 19 + TanStack Router)
↓ custom ipcLink (NOT electron-trpc — incompatible with tRPC v11)
Preload (contextBridge: window.electronTRPC + window.electronAI)
↓ IPC channels
Main Process (Node.js)
├── tRPC router (all CRUD + AI procedures)
├── SQLite (better-sqlite3 + Drizzle ORM, WAL mode)
├── LanceDB (vector embeddings, 1536-dim text-embedding-3-small)
└── LangGraph orchestrator (3 specialist agents, pluggable LLM providers)
Local-first app. All user data (tasks, notes, projects) in local SQLite. AI system (LangGraph + LangChain) runs in Electron main process, pluggable providers (OpenAI, Anthropic, GitHub Copilot).
IPC channels:
'trpc'— bidirectional tRPC request/response (all CRUD)'ai:stream'— one-way token streaming main → renderer'ai:action'— AI side-effects (e.g. agent auto-creates task)
Key source paths:
src/main/ipc.ts— Custom tRPC↔IPC bridgesrc/main/router/index.ts— All tRPC routers (~600 LOC)src/main/ai/orchestrator.ts— LangGraph intent routing + 3 agents (~991 LOC)src/main/db/schema.ts— 6 tables (clients, projects, tasks, checkpoints, notes, taskComments)src/renderer/routes/— File-based routing (TanStack Router auto-generatesrouteTree.gen.ts)src/renderer/components/ui/— shadcn/ui primitives (new-york theme, neutral colors)src/main/auth/auth-manager.ts— Login, register, logout, OAuth flow (singleton)src/main/auth/backup-key.ts— Device-specific AES-256 backup key (safeStorage, not password-derived)src/main/ai/token.ts— Two-tier token storage: safeStorage + electron-store fallbacksrc/main/auth/locale-defaults.ts— Detects timezone, date/time format, language from OS localesrc/main/api/format-row.ts— Formats timestamp columns using user's FormatPrefs
Non-obvious details:
electron-trpcNOT used — custom IPC bridge inipc.ts+ipcLink.tsbecause electron-trpc bundles tRPC v10 internals- Vite configs use
.mtsextension to avoid ESM/CJS conflicts with electron-forge forge.config.tshas complex cross-compilation hooks (downloads platform-specific native binaries for better-sqlite3 and LanceDB)- DB has no foreign key constraints — cascade deletes in tRPC procedures
- Timestamps are milliseconds (
Date.getTime()), not ISO strings - Notes auto-embed to LanceDB on create/update (fire-and-forget, errors swallowed)
Settings Page (shared Electron + Web):
- Settings page runs in both Electron and standalone web SPA (future landing-page portal). Same React components — no duplication.
- Platform Adapter pattern:
PlatformProvidercontext (src/renderer/lib/platform.tsx) exposesisElectron/isWeb/hasLocalAgents/hasFileDialogflags. Components useusePlatform()to gate Electron-only features or disable on web. - 6 sections: Profile, AI Preferences, Account, Billing, Appearance, Agents. Sidebar nav with icons in
types.ts(SECTIONSarray). - Web build:
vite.web.config.mtsbuilds standalone SPA todist-web/. Entry:web.html→src/renderer/web-main.tsx(useshttpBatchLinkviasrc/renderer/lib/httpLink.tsinstead ofipcLink). Scripts:npm run dev:web,npm run build:web,npm run preview:web. - Electron-only gating: Device ID card and local agent filesystem gated behind
platform.isElectron. On web: visible but disabled, not hidden. - Gotcha: Do NOT add Electron-specific settings (server URL, native file pickers) without wrapping in
platform.isElectron. Same component tree renders on web.
Onboarding Wizard:
- First-run wizard collects 5 fields:
job_role,industry,primary_use_case,tone_preference,language. Plususer_namefromname+surname. - All fields stored as encrypted core memory (backend
MemoryMiddleware), not local electron-store. onboarding_completed_atonuserstable (nullable TIMESTAMPTZ) gates flow —null= show wizard, non-null = skip.AppShell.tsxgates: ifprofile.onboardingCompletedAt == null→ render<OnboardingFlow>instead of app.auth.statustRPC procedure auto-seedslanguageanduser_nameinto MemoryCore if missing (fire-and-forget.catch(() => {})).- Format prefs (timezone, dateFormat, timeFormat) stored in electron-store (
FormatPrefs), not core memory — device-specific. drizzle-executor.tswraps all query results throughformatRow()/formatRows()using user's FormatPrefs.- Settings > Profile allows post-onboarding edit of all fields + format prefs.
- Gotcha — shadcn Button
outlinevariant in dark mode: Variant definesdark:bg-input/30 dark:border-input dark:hover:bg-input/50— overrides customclassNamebackground. Fix: switch betweenvariant="default"andvariant="outline"instead of className overrides. - Gotcha — locale codes vs human names:
app.getLocale()andnavigator.languagereturn codes likeen-US. UseIntl.DisplayNames(undefined, { type: 'language' })to convert to "English". Must do in both main process (locale-defaults.ts) and renderer (OnboardingFlow.tsx).
i18n (Internationalization):
- Uses
i18next+react-i18nextwith bundled JSON translations (no lazy loading). - Config in
src/renderer/i18n.ts. 5 languages: EN, IT, ES, FR, DE.SUPPORTED_LANGUAGESexported for UI selectors. - Translation files:
src/renderer/locales/{en,it,es,fr,de}/translation.json. Namespaces:nav,auth,tasks,settings,common,errors,home,timeline,projects,agents. common.*namespace holds shared labels (save,cancel,delete,edit,add,rename,saving,deleting,creating,renameDescription,deleteTitle). Checkcommon.*before adding new key.- Pluralization uses i18next
_one/_othersuffixes (e.g.tasksDueToday_one,tasksDueToday_other). LanguageSynccomponent insrc/renderer/index.tsxreads persisteduiLanguagefrom electron-store via tRPC on startup, syncs to i18next.- Language selector in
GeneralSection.tsx(Settings > General). On change: (1) callsi18n.changeLanguage(), (2) persists to electron-store viasetUiLanguagemutation, (3) writes to backend core memory so AI responds in same language. getUiLanguage()exported fromsrc/main/store.ts— used byorchestrator.tsto append language hint to daily brief prompt.- Static data arrays needing translation use
labelKeypattern (notlabel): store translation key, callt(labelKey)at render. Used inNAV_ITEMS,COLUMNS,SECTIONS,SUGGESTION_CHIPS. - When adding new translated text: add key to all 5 JSON files. Keep
common.*consistent across all languages.
Google OAuth (adiuvAI side):
adiuvai://NOT accepted by Google as redirect URI — Google only acceptshttp://localhostorhttps://. API backend exposesGET /auth/oauth/google/web-callbackwhich receives Google redirect and bounces toadiuvai://oauth/callback?.... Redirect URI in Google Cloud Console points to backend, not Electron app.app.requestSingleInstanceLock()required forsecond-instanceevent on Windows/Linux. If returnsfalse, callapp.quit()immediately.- In dev (
process.defaultApp === true),setAsDefaultProtocolClient('adiuvai')must include[path.resolve(process.argv[1])]as third arg so OS protocol registration includes entry script. loginWithOAuthusesfetch()directly (notthis.get()) — authorize endpoint is public,get()throws when not authenticated.- Backup key in
backup-key.tsstored inencryptedTokensunder keybackup_key, reusinggetToken/setTokenfromtoken.ts. Device-bound, never password-derived — social-login users can use backup features.
api (FastAPI Backend)
Commands
cd api
# Development
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Production
gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 --timeout 120
# Database migrations
alembic upgrade head
# Testing
pytest # all tests
pytest -v # verbose
pytest tests/test_agents.py # single file
pytest tests/test_agents.py -k test_name # single test
# Linting/formatting
ruff check .
ruff format .
# Docker (full stack: app + postgres + minio + qdrant)
docker compose up --build
Architecture
FastAPI app (app/main.py)
├── Middleware: TierRateLimiter → Sanitizer → CORS
├── HTTP Routes (app/api/routes/)
│ ├── auth.py — register, login, token refresh (bcrypt + HS256 JWT)
│ ├── chat.py — POST /chat, WS /chat/stream
│ ├── plans.py — execution plan playbooks
│ ├── storage.py — E2E-encrypted cloud storage (S3)
│ ├── backup.py — encrypted backup upload/download
│ ├── vectors.py — encrypted vector upsert/search (Pinecone/Qdrant)
│ ├── plugins.py — plugin marketplace (Power+ tier)
│ └── billing.py — Stripe subscriptions
├── Agent System (app/agents/)
│ ├── task_agent.py — 8 tools
│ ├── project_agent.py — 6 tools
│ ├── checkpoint_agent.py — 4 tools
│ └── note_agent.py — 5 tools
├── Orchestration (app/core/)
│ ├── orchestrator.py — intent classification + agent routing
│ ├── agent_registry.py — decorator-based agent registry
│ ├── execution_plan.py — server-side prompt templates + plan builder
│ ├── llm.py — LiteLLM factory (100+ providers)
│ └── memory_middleware.py
├── Billing (app/billing/)
│ ├── tier_manager.py — feature matrix (Free/Pro/Power/Team)
│ └── stripe_service.py — Stripe checkout + webhooks
├── Storage (app/storage/) — S3 blob store, vector store, encryption
└── Marketplace (app/marketplace/) — plugin catalog, review, revenue sharing
LLM routing: gpt-4o-mini classifies intent → routes to domain agent → agent uses gpt-4o with tools → tool calls describe client-side ops (JSON) → Electron executes locally, returns results.
Zero-trust data model: Backend never stores or decrypts user content. PostgreSQL holds only auth, billing, plugin metadata, storage record pointers. All user data E2E-encrypted before leaving Electron client.
Key config: app/config/settings.py — all env vars via Pydantic Settings. Copy .env.example to .env for local dev. Stripe and S3 gracefully stub when keys absent.
Database: PostgreSQL with async SQLAlchemy 2.0 + asyncpg. 9 ORM models in app/models.py. Alembic migrations in alembic/versions/.
Testing: pytest + pytest-asyncio. Fixtures in tests/conftest.py create in-memory SQLite + moto-mocked S3. Test users seeded per tier (free/pro/power/team).
Non-obvious details
- Tier from DB, not JWT:
get_current_userdecodes JWT but fetches authoritative tier fromsubscriptionstable — tier changes take effect immediately, no re-login needed - Refresh tokens hashed: Plaintext returned to client, stored as SHA-256 in DB — server can never retrieve plaintext (intentional)
- WebSocket auth via query param:
?token=<jwt>instead of Bearer header (WebSocket handshake limitation) - Prompt IP protection:
PromptTemplateRegistrykeeps prompts server-side; clients receive opaquetemplate_id.SanitizerMiddlewarestrips leaked fragments from responses - Agents don't execute operations: Tools return JSON describing client-side ops — Electron client executes against local SQLite
- Alembic async/sync split: App uses
postgresql+asyncpg, Alembic CLI needspostgresql+psycopg2—env.pyhandles URL conversion - Tool loop cap: Agent
_tool_loopstops after 5 iterations to prevent infinite loops - Route order matters:
/backup/historymust be declared before/backup/{backup_id}to avoid path param shadowing - CORS includes
app://: Electron uses customapp://protocol, not http/https - Vector search on encrypted data is not semantic: Backend derives deterministic 32-dim floats from blob SHA-256 for storage/search — known trade-off
Onboarding (API side):
PUT /auth/me/memory— updates core memory k/v pairs, optionally marks onboarding complete (mark_onboarded: truesetsusers.onboarding_completed_at).POST /auth/me/onboarding/reset— nullifiesonboarding_completed_atso wizard re-runs.POST /auth/onboarding/normalize— LLM-normalizes free-text onboarding inputs viagpt-4o-mini; returns inputs unchanged on error.get_current_user()inauth.pymiddleware decrypts core memory blocks, includes inUserProfile.memorydict.users.onboarding_completed_at— nullable TIMESTAMPTZ, returned as epoch ms (int) in UserProfile schema.
i18n (API side):
_language_instruction()inapp/core/deep_agent.pyreads user'slanguagefromMemoryCore, appends system prompt directive ("Always respond in {language}") to all 4run_*functions.- Electron client writes chosen language to backend core memory on change — API picks up on next agent call.
Google OAuth (api side):
- OAuth routes in
app/api/routes/auth.py:GET /auth/oauth/{provider}/authorize,POST /auth/oauth/{provider}/callback,GET /auth/oauth/{provider}/web-callback(bounces to deep link, excluded from OpenAPI schema). - Provider abstraction in
app/auth/oauth_providers.py—GoogleOAuthProvideruseshttpxdirectly (noauthlib). PKCE S256 implemented manually viagenerate_pkce_pair(). _pending_statesdict inroutes/auth.pyis in-memory — works for single-process dev, doesn't survive restarts, doesn't scale to multiple workers. Replace with Redis in production.users.password_hashis nullable — social-only users havepassword_hash=None.await db.flush()required before creating linkedOAuthAccountto populatenew_user.idbefore commit.OAUTH_REDIRECT_URImust point to API backend (e.g.https://api.adiuvai.com/...), not website domain.adiuvai.comis static site with no server-side routing.- Unverified email + existing account = 409: if
email_verified=Falseand email already registered, callback returns 409. Without this guard, branch 3 would INSERT duplicate email and crash with DB constraint violation (500). - Testing OAuth routes: mock
GoogleOAuthProvider.exchange_codeandget_userinfowithpatch.object(..., new=AsyncMock(...))— works because FastAPI instantiates new provider per request. Usemonkeypatch.setattr(settings, "GOOGLE_AUTH_CLIENT_ID", ...)to simulate configured credentials without restart.
Tier System
| Feature | Free | Pro | Power | Team |
|---|---|---|---|---|
| Rate limit | 20/min | 60/min | 120/min | 200/min |
| Agents | 3 | unlimited | unlimited | unlimited |
| Cloud storage | 0 GB | 5 GB | 25 GB | unlimited |
| Plugin marketplace | no | no | yes | yes |
Enforced in app/api/middleware/rate_limit.py (sliding window) and app/billing/tier_manager.py (feature checks + quota enforcement).
Cross-Project Integration
Electron app and FastAPI backend communicate via WebSocket (/chat/stream):
- Electron connects with
?token=<jwt>query param - Client sends
ChatRequestJSON frame - Server streams text chunks, then final frame:
{"done": true, "response": "...", "actions": []} - Server sends
tool_callframes → Electron executes against local SQLite → returnstool_result - Server pings every 30 seconds to keep alive
Electron also has fully local AI path (LangGraph orchestrator in main process) that doesn't require backend — primary path for desktop use.
MCP Servers
- Langfuse Docs (
https://langfuse.com/api/mcp) — workspace-level, prompt management docs - shadcn (
npx shadcn@latest mcp) — configured inadiuvAI/for UI component generation