8 Commits

Author SHA1 Message Date
0fcfa3e5bb Merge branch 'feature/agents-plugin' into develop 2026-03-04 09:21:39 +01:00
e8c8ddd48d feat: add CI/CD pipeline with cross-compilation support 2026-03-04 09:00:36 +01:00
b77c6d1195 updated plan 2026-03-02 17:57:02 +01:00
489e8e3bc9 update refactor plan 2026-03-02 14:06:38 +01:00
1ba9c9eee2 remove unused file 2026-03-02 00:07:32 +01:00
Roberto Musso
0f3c63c4de feat(settings): add permissions for git commands in settings.json 2026-03-01 23:31:57 +01:00
Roberto Musso
aa089975df docs: split plan into Electron app + separate backend repo
- AI_REFACTOR_PLAN.md: Electron-only, 7 phases, 18 steps
- BACKEND_PLAN.md: standalone FastAPI backend guide for separate repo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:28:42 +01:00
Roberto Musso
a6c04e52af docs: create AI refactoring plan
Comprehensive step-by-step plan for transforming Adiuva into a
local-first multi-agent platform with cloud backend orchestration,
plugin-based batch agents, E2E encrypted backup, granular permissions,
and multi-provider LLM support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:20:18 +01:00
9 changed files with 1002 additions and 160 deletions

View File

@@ -1,139 +1,166 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands ## Commands
```bash ```bash
# Development source ~/.nvm/nvm.sh && npm start # Dev with hot-reload
source ~/.nvm/nvm.sh && npm start # Start Electron app with hot-reload source ~/.nvm/nvm.sh && npm run make # Build distributable packages
source ~/.nvm/nvm.sh && npm run package # Package without installers
# Build & Package source ~/.nvm/nvm.sh && npm run lint # ESLint (.ts/.tsx)
source ~/.nvm/nvm.sh && npm run make # Build distributable packages source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema
source ~/.nvm/nvm.sh && npm run package # Package without making installers source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
# Lint
source ~/.nvm/nvm.sh && npm run lint # ESLint over .ts/.tsx files
# Database migrations (Drizzle)
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema changes
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
``` ```
There is no test suite currently. No test suite currently.
## Architecture Overview ## Architecture
Adiuva is a local-first Electron desktop app. The three Electron processes communicate via a custom tRPCIPC bridge (the public `electron-trpc` package is incompatible with tRPC v11, so a custom implementation is used). Adiuva is a local-first Electron desktop app. The three processes communicate via a custom tRPC v11 ↔ IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11).
### Process Boundaries
``` ```
Renderer (React) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite) Renderer (React 19) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
``` ```
1. **Main process** (`src/main/`) — Node.js, owns the database and all business logic ### Main Process (`src/main/`)
- `index.ts` — Window creation, app lifecycle
- `ipc.ts` — Custom handler that bridges `ipcMain` to tRPC procedures
- `router/index.ts` — All tRPC routers (clients, projects, tasks, checkpoints, notes, settings, ai)
- `db/index.ts` — Drizzle + better-sqlite3, WAL mode, singleton `getDb()`
- `db/schema.ts` — All table definitions (clients, projects, tasks, checkpoints, notes)
- `store.ts` — electron-store for persistent UI settings (e.g., `sidebarCollapsed`)
2. **Preload** (`src/preload/trpc.ts`) — Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()` Owns the database and all business logic.
3. **Renderer** (`src/renderer/`) — React 19, never accesses Node APIs directly | File | Purpose |
- `lib/ipcLink.ts` — Custom TRPCLink that routes calls through `window.electronTRPC` |---|---|
- `lib/trpc.ts``createTRPCReact<AppRouter>()` typed client | `index.ts` | Window creation, app lifecycle |
- `index.tsx` — QueryClient + tRPC + Router providers | `ipc.ts` | Bridges `ipcMain` to tRPC procedures |
- All data access is through `trpc.*.*useQuery()` / `trpc.*.*.useMutation()` | `router/index.ts` | All tRPC sub-routers merged into `appRouter` |
| `db/index.ts` | Drizzle + better-sqlite3, WAL mode, singleton `getDb()` |
| `db/schema.ts` | Table definitions: clients, projects, tasks, checkpoints, notes, taskComments |
| `db/vectordb.ts` | LanceDB vector store for note embeddings |
| `store.ts` | electron-store for persistent UI settings |
### Preload (`src/preload/trpc.ts`)
Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`.
### Renderer (`src/renderer/`)
React 19 — never accesses Node APIs directly. All data through `trpc.*.useQuery()` / `trpc.*.useMutation()`.
| File | Purpose |
|---|---|
| `lib/ipcLink.ts` | Custom TRPCLink routing through `window.electronTRPC` |
| `lib/trpc.ts` | `createTRPCReact<AppRouter>()` typed client |
| `index.tsx` | QueryClient + tRPC + Router providers |
### Routing ### Routing
File-based routing via TanStack Router. Add a file to `src/renderer/routes/` and the route tree (`src/renderer/routeTree.gen.ts`) is auto-regenerated by the Vite plugin on next `npm start`. Routes: File-based via TanStack Router (`tsr.config.json` at root). Route tree auto-generated at `routeTree.gen.ts`.
- `__root.tsx` — Root layout wrapping everything in `AppShell`
- `index.tsx`, `tasks.tsx`, `timeline.tsx`, `projects.tsx` Routes: `__root.tsx` (AppShell layout), `index`, `tasks`, `timeline`, `projects`, `notes.$noteId`
### tRPC Routers
`health`, `settings`, `clients`, `projects`, `tasks`, `checkpoints`, `notes`, `taskComments`, `ai`
### Database ### Database
Schema lives in `src/main/db/schema.ts`. Migrations are in `src/main/db/migrations/`. The DB is created in Electron's `userData` directory as `adiuva.db`. On startup, `initDb()` runs non-destructive migrations (CREATE TABLE IF NOT EXISTS). Schema in `src/main/db/schema.ts`, migrations in `src/main/db/migrations/`. DB created in Electron's `userData` as `adiuva.db`. On startup, `initDb()` runs non-destructive migrations.
To add a new table or column: edit `schema.ts`, run `drizzle-kit generate`, then `drizzle-kit push` (dev) or commit the migration file. To add a table/column: edit `schema.ts` `drizzle-kit generate` `drizzle-kit push` (dev) or commit the migration.
### Adding a New Feature (end-to-end pattern) ### Adding a Feature (end-to-end)
1. **Schema** Add table/columns to `src/main/db/schema.ts` 1. **Schema**`src/main/db/schema.ts`
2. **Router** — Add a tRPC sub-router in `src/main/router/index.ts`, merge it into `appRouter` 2. **Router** — Add sub-router in `src/main/router/index.ts`, merge into `appRouter`
3. **Types**`AppRouter` is exported from `src/main/router/index.ts` and imported in `src/renderer/lib/trpc.ts` — types flow automatically 3. **Types**Flow automatically via `AppRouter` export
4. **UI** — Create components under `src/renderer/components/<feature>/`, use `trpc.*.*useQuery()` for data 4. **UI** — Components in `src/renderer/components/<feature>/`, data via `trpc.*.useQuery()`
### AI Subsystem (`src/main/ai/`) ## AI Subsystem (`src/main/ai/`)
LangGraph-based agentic system with pluggable LLM providers (OpenAI, Anthropic, GitHub Copilot). LangGraph-based agentic system with pluggable LLM providers.
**Orchestrator** (`orchestrator.ts`): Classifies user intent → routes to one of three specialist agents: ### Orchestrator (`orchestrator.ts`)
- **Project agent** — project-scoped Q&A with tools: `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks`
- **Knowledge agent** — cross-project semantic search via `vector_search_all`
- **General agent** — workspace-wide `add_task`
Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindTools()` + ToolMessage loop (max 5 iterations); Copilot uses SDK-native tools (loop handled internally). Classifies user intent → routes to a specialist agent:
**Streaming**: Orchestrator calls `sendStreamChunk(sender, token, done)` over IPC channel `'ai:stream'`. Renderer subscribes via `window.electronAI.onStreamChunk()` in `AIChatPanel.tsx`. `<tool_call>` blocks are filtered before sending to renderer. | Agent | Scope | Tools |
|---|---|---|
| Project | Project-scoped Q&A | `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks` |
| Knowledge | Cross-project search | `vector_search_all` |
| General | Workspace-wide | `add_task` |
All providers use LangChain `bindTools()` + ToolMessage loop (max 5 iterations).
Also exports `dailyBrief()` for AI-generated daily summaries (`ai.dailyBrief` tRPC mutation).
### Streaming
`sendStreamChunk(sender, token, done)` over IPC `'ai:stream'`. Renderer subscribes via `window.electronAI.onStreamChunk()` in `AIChatPanel.tsx`. `<tool_call>` blocks are filtered before display.
### Providers (`llm.ts`)
| Provider | Model | Notes |
|---|---|---|
| OpenAI | `gpt-4o-mini` | Via LangChain |
| Anthropic | `claude-sonnet-4-20250514` | Via LangChain |
| Copilot | `ChatCopilot` wrapper | `copilot.ts` / `chat-copilot.ts` |
All use `temperature: 0.3`, streaming enabled. Provider management in `provider.ts`.
### Vector Embeddings (`db/vectordb.ts`)
**Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled. **Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled.
**Token storage** (`token.ts`) — three-tier fallback: **Token storage** (`token.ts`) — two-tier fallback:
1. keytar (OS keychain) — preferred, encrypted per-user 1. electron-store + `safeStorage` — encrypted at rest (preferred)
2. electron-store + `safeStorage` — encrypted at rest 2. Plain electron-store — last resort (e.g. WSL with no keyring)
3. Plain electron-store — WSL fallback
Keytar service name is `'adiuva'`. Once keytar fails, `keytarFailed` flag skips it for the session.
**AI approval pattern**: Tasks and checkpoints have `isAiSuggested` (bool) and `isApproved` (bool) columns. AI-suggested items appear in the UI pending user approval before being treated as real records. **AI approval pattern**: Tasks and checkpoints have `isAiSuggested` (bool) and `isApproved` (bool) columns. AI-suggested items appear in the UI pending user approval before being treated as real records.
### Vector Embeddings (`src/main/db/vectordb.ts`) ### Vector Embeddings (`src/main/db/vectordb.ts`)
LanceDB stored in `{userData}/vectors/`. Table schema: `{ id, projectId, content, vector }`. Vectors are 1536-dimensional (`text-embedding-3-small`). Embeddings use a priority chain: Copilot CLI token → OpenAI token. LanceDB in `{userData}/vectors/`. Schema: `{ id, projectId, content, vector }` (1536-dim, `text-embedding-3-small` via `embeddings.ts`). Embedding priority: Copilot CLI token → OpenAI token.
- Note create/update fires `upsertNoteEmbedding()` (fire-and-forget, errors swallowed) - `upsertNoteEmbedding()` on note create/update (fire-and-forget)
- `migrateNotesIfNeeded()` backfills existing notes on first startup - `migrateNotesIfNeeded()` backfills on first startup
- `searchNotes(query, limit=5)` is called by the Knowledge agent tool - `searchNotes(query, limit=5)` used by Knowledge agent
### Key Config Notes ### AI Approval Pattern
- Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflicts with electron-forge's externalize-deps plugin Tasks and checkpoints have `isAiSuggested` + `isApproved` columns. AI suggestions appear pending user approval (dashed borders in UI).
- `@/*` path alias resolves to `src/renderer/*` (TypeScript + Vite + shadcn/ui all share this alias)
- shadcn/ui style: **new-york**, base color: **neutral** ## Config Notes
- Icons: **lucide-react** throughout — do not introduce other icon libraries
- Tailwind 4 (not 3) — use CSS variable theming via `globals.css`, not `tailwind.config.js` - Vite configs use `.mts` (not `.ts`) — avoids ESM/CJS conflicts with electron-forge
- Notes use Milkdown (`@milkdown/crepe`) as the markdown editor (`src/renderer/components/notes/MilkdownEditor.tsx`) - `@/*` path alias → `src/renderer/*` (TypeScript + Vite + shadcn/ui)
- Routes: `index`, `tasks`, `timeline`, `projects`, `notes.$noteId` (note ID is a URL param) - **shadcn/ui**: new-york style, neutral base color
- **Icons**: lucide-react only — do not introduce other icon libraries
- **Tailwind 4** — CSS variable theming in `globals.css`, no `tailwind.config.js`
- **Notes editor**: Milkdown (`@milkdown/crepe`) at `src/renderer/components/notes/MilkdownEditor.tsx`
## Design Context ## Design Context
### Users ### Target User
Freelancers and solo professionals managing their own client work projects, tasks, notes, and timelines. They work alone and need a single workspace that keeps everything organized without the overhead of enterprise tools. The AI assistant is a force multiplier, helping them stay on top of their workload. Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier.
### Brand Personality ### Brand
**Calm, intelligent, warm.** Adiuva is a thoughtful companion, not a flashy tool. It should feel like a well-organized desk — everything in its place, nothing competing for attention. The tone is confident and understated, never loud or gamified. **Calm, intelligent, warm.** Thoughtful companion, not flashy tool. Confident and understated, never loud or gamified.
### Aesthetic Direction ### Palette
- **Visual tone**: Editorial, premium, content-first. Inspired by Notion's clean typography and warm neutrals, but with a distinct identity through the warm pinkish-white canvas and golden yellow accent
- **Light mode**: Soft and warm — pinkish-white (`#f4edf3`) canvas, golden yellow (`#fbc881`) primary, slate blue-gray (`#8a8ea9`) secondary, dusty lavender borders (`#c8c3cd`) | | Canvas | Primary | Secondary | Borders |
- **Dark mode**: Stark monochrome — near-black canvas (`#0c0c0c`), crisp white text, dark gray surfaces (`#323232`). No color accent; primary is pure white |---|---|---|---|---|
- **Typography**: Geist (geometric sans-serif) at 400/500/600. Tight tracking on large headings (`-1px`). Body at `text-sm`, metadata at `text-xs` | **Light** | Pinkish-white `#f4edf3` | Golden yellow `#fbc881` | Slate blue-gray `#8a8ea9` | Dusty lavender `#c8c3cd` |
- **Corners**: 10px base radius, consistently rounded. Chat elements use `rounded-2xl` | **Dark** | Near-black `#0c0c0c` | Pure white | — | Dark gray `#323232` |
- **Signature effects**: Glassmorphism on AI inputs/floating chat (`backdrop-blur-xl`, transparency). Spring physics animations (stiffness 400, damping 30). Subtle scale-and-fade transitions
- **Anti-references**: No gamification (badges, streaks, confetti). No corporate/enterprise density. Keep it mature and professional ### Typography
Geist sans-serif, weights 400/500/600. Tight tracking (`-1px`) on headings. Body `text-sm`, metadata `text-xs`.
### Visual Language
- 10px border-radius, `rounded-2xl` for chat elements
- Glassmorphism on AI inputs (`backdrop-blur-xl`, transparency)
- Spring animations (stiffness 400, damping 30), scale-and-fade transitions
- No gamification (badges, streaks, confetti). Mature and professional
### Design Principles ### Design Principles
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density
1. **Clarity over cleverness** — Every element should communicate its purpose instantly. Prefer clear hierarchy and whitespace over decorative flourish. Information density should feel comfortable, not cramped. 2. **AI as quiet partner** — Deeply integrated but never intrusive. Dashed borders for pending AI items, Sparkles icon as AI marker
3. **Warmth in restraint** — Warm palette feels approachable without being playful. Dark mode trades warmth for focus
2. **AI as quiet partner** — The AI is deeply integrated (floating chat, suggestions) but never intrusive. AI-suggested items use dashed borders to signal "pending." The Sparkles icon is the consistent AI identity marker. 4. **Motion with purpose** — Animations reinforce spatial relationships, never decorative
5. **Confidence through consistency** — CSS variable tokens, shadcn/ui primitives, Geist font. Predictable, keyboard-first
3. **Warmth in restraint** — The palette is deliberately warm (pinkish whites, golden yellows) to feel approachable without being playful. Dark mode trades warmth for focus. Let the content breathe.
4. **Motion with purpose** — Spring physics and glassmorphism create a sense of physicality and depth. Animations should feel natural and responsive, never decorative or slow. Every transition should reinforce spatial relationships.
5. **Confidence through consistency** — Use the established token system (CSS variables, shadcn/ui primitives, Geist font). The user should feel in control — predictable patterns, keyboard-first interactions, no surprises.

8
.claude/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git add AI_REFACTOR_PLAN.md)",
"Bash(git commit:*)"
]
}
}

124
.gitea/workflows/build.yaml Normal file
View File

@@ -0,0 +1,124 @@
name: Release Electron App
run-name: Releasing ${{ gitea.ref_name }}
on:
push:
tags:
- 'v*'
jobs:
release-desktop:
runs-on: ubuntu-latest
container:
image: electronuserland/builder:wine
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Install System Dependencies for Linux Makers
run: |
apt-get update
apt-get install -y fakeroot dpkg mono-complete
- name: Install Dependencies
run: npm install
- name: Set version from tag
run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version --allow-same-version
- name: Make App (Linux)
run: npm run make -- --platform=linux --arch=x64
- name: Initialize Wine
run: |
export WINEDEBUG=-all
export DISPLAY=
wineboot --init
env:
WINEDEBUG: '-all'
- name: Make App (Windows)
run: npm run make -- --platform=win32 --arch=x64
env:
WINEDEBUG: '-all'
- name: Create Gitea Release
run: |
GITEA_URL="http://10.0.0.119:3000"
TAG="${GITHUB_REF_NAME}"
REPO="${GITHUB_REPOSITORY}"
TOKEN="${{ gitea.token }}"
# Check if release exists, create if not
RELEASE_ID=$(curl -sf \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID=$(curl -sf \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
fi
echo "Release ID: ${RELEASE_ID}"
echo "RELEASE_ID=${RELEASE_ID}" >> "$GITHUB_ENV"
- name: Upload Release Assets
shell: bash
run: |
GITEA_URL="http://10.0.0.119:3000"
REPO="${GITHUB_REPOSITORY}"
TOKEN="${{ gitea.token }}"
MAX_RETRIES=3
upload_file() {
local file="$1"
local name
name=$(basename "$file")
local encoded_name
encoded_name=$(printf '%s' "$name" | sed 's/ /%20/g')
local attempt=1
while [ $attempt -le $MAX_RETRIES ]; do
local filesize
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "unknown")
echo "Uploading ${name} (${filesize} bytes, attempt ${attempt}/${MAX_RETRIES})..."
RESPONSE=$(curl -s -w "\n%{http_code}" \
--max-time 300 \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Expect:" \
-F "attachment=@${file}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -n -1)
if [ "$HTTP_CODE" -ge 200 ] 2>/dev/null && [ "$HTTP_CODE" -lt 300 ] 2>/dev/null; then
echo "✅ Uploaded ${name}"
return 0
fi
echo "⚠️ Upload failed (HTTP ${HTTP_CODE}), body: ${BODY}"
echo "Retrying in 5s..."
sleep 5
attempt=$((attempt + 1))
done
echo "❌ Failed to upload ${name} after ${MAX_RETRIES} attempts"
return 1
}
FAILED=0
while IFS= read -r -d '' file; do
upload_file "$file" || FAILED=1
done < <(find out/make -type f \( -name "*.exe" -o -name "*.zip" -o -name "*.deb" -o -name "*.rpm" \) -print0)
if [ $FAILED -eq 1 ]; then
echo "Some uploads failed"
exit 1
fi

526
AI_REFACTOR_PLAN.md Normal file
View File

@@ -0,0 +1,526 @@
# AI Refactor Plan — Adiuva Electron App
> **Objective:** Transform the Electron app into a hybrid-first multi-agent client. The user controls where data is stored (local / cloud / sync), which AI provider to use (BYOK multi-provider), and which automations to run — either custom batch agents built with the LLM-powered Batch Builder, or pre-built plugins from the marketplace. All data access is opt-in, transparent, and auditable.
>
> **Backend:** Lives in a separate repository. See `../adiuva-api/BACKEND_PLAN.md` for the API contract and backend implementation guide.
>
> **Protocol:** Execute steps sequentially. Each step is atomic and committable. Mark `[x]` when done.
---
## Phase 0 — API Contracts & Types
### Step 0.1 — Define backend API contract types
- [ ] Create `src/shared/api-types.ts` with all interfaces the Electron app needs to communicate with the backend:
- `ExecutionPlan`, `PlanStep`, `PlanAction` (action types: `create_record`, `update_record`, `delete_record`, `index_document`, `send_notification`, `call_agent`)
- `ChatRequest` (message, context, execution_mode: `'direct'` | `'plan'`)
- `ChatResponse` (response, actions)
- `ChatContext` (user_profile, relevant_documents, recent_tasks, conversation_history)
- `AgentManifest` (name, description, permissions, schedule)
- `PermissionGrant` (plugin, permission type, resource path, granted_at)
- `BackupMetadata` (version, timestamp, checksum, chunk_count)
- `BillingTier` enum (`free`, `pro`, `power`, `team`)
- `AuthTokens` (access_token, refresh_token, expires_at)
- `UserProfile` (id, email, tier)
- [ ] Create `src/shared/batch-types.ts` with all types for the batch builder and storage layer:
- `StorageTarget``'local'` | `'cloud'` | `'sync'` | `'none'`
- `ConnectorType``'imap'` | `'filesystem'` | `'calendar'` | `'api'` | `'gmail'` | `'gdrive'` | `'outlook'`
- `BatchActionType``'create_record'` | `'update_record'` | `'delete_record'` | `'index_document'` | `'send_notification'` | `'call_agent'`
- `BatchSource``{ connector: ConnectorType, config: Record<string, unknown> }`
- `BatchTrigger``{ type: 'cron' | 'event', schedule?: string, timezone?: string }`
- `BatchAnalysis``{ prompt: string, model_override?: string, output_schema?: object }`
- `BatchAction``{ type: BatchActionType, table?: string, mapping?: Record<string, string> }`
- `BatchStorage``{ records: StorageTarget, vectors: StorageTarget, raw_data: StorageTarget }`
- `BatchConfig` — full config object: `id`, `name`, `description`, `enabled`, `source`, `trigger`, `analysis`, `actions`, `storage`, `permissions`
- `BatchStatus``'idle'` | `'running'` | `'error'` | `'disabled'`
- `BatchRunResult``{ batchId, runAt, status, itemsProcessed, errors }`
- `PluginListing``{ id, name, description, author, version, rating, installs, category, permissions, price }`
- `InstalledPlugin``{ listing: PluginListing, installedAt, enabled, storageConfig: BatchStorage }`
- `DataSourceInfo``{ type: ConnectorType, label, recordCount, sizeBytes, storageTarget: StorageTarget }`
- `StorageStats``{ localUsedBytes, cloudUsedBytes, cloudLimitBytes, sources: DataSourceInfo[] }`
- [ ] Update `tsconfig.json` paths if needed to include `src/shared/`
- **Files:** `src/shared/api-types.ts`, `src/shared/batch-types.ts`, `tsconfig.json`
- **Outcome:** Type-safe contracts for all backend communication and the batch/storage subsystem. Backend repo mirrors these as Pydantic schemas.
---
## Phase 1 — LiteLLM Multi-Provider Client
### Step 1.1 — Create unified LLM client wrapper
- [ ] Create `src/main/llm/litellm-client.ts`:
- `LiteLLMClient` class with unified interface:
- `complete(messages: Message[], options?: CompletionOptions): Promise<CompletionResponse>`
- `stream(messages: Message[], options?: CompletionOptions): AsyncGenerator<string>`
- `embed(text: string): Promise<number[]>`
- `CompletionOptions`: model override, temperature, max_tokens, tools
- Provider-agnostic: internally maps to the correct provider SDK
- Fallback chain: tries primary provider, on failure tries secondary, logs each attempt
- Timeout handling: per-provider configurable timeouts
- [ ] Create `src/main/llm/providers.ts`:
- `ProviderConfig` interface: name, apiKey, model, endpoint (for Ollama), timeout, isLocal
- `ProviderRegistry`: manages configured providers, persists to electron-store
- `getActiveProvider()`, `setActiveProvider(name)`, `addProvider(config)`, `removeProvider(name)`
- `getFallbackChain(): ProviderConfig[]`
- Supported providers: OpenAI, Anthropic, Google (Gemini), Mistral, Groq, Ollama (local)
- [ ] Create `src/main/llm/embeddings.ts` (refactored):
- Support multiple embedding providers (OpenAI text-embedding-3-small, local ONNX with all-MiniLM-L6-v2)
- Auto-select: use local ONNX if available, fall back to API
- Same `embedText(text): Promise<number[]>` interface
- **Files:** `src/main/llm/litellm-client.ts`, `src/main/llm/providers.ts`, `src/main/llm/embeddings.ts`
- **Outcome:** Single LLM interface that all local components use. Supports 6+ providers with fallback.
### Step 1.2 — Migrate existing AI code to use new LLM client
- [ ] Update `src/main/ai/orchestrator.ts`:
- Replace direct `getLLM()` calls with `LiteLLMClient.complete()` / `LiteLLMClient.stream()`
- Keep local orchestration working with the new client (backend delegation comes in Phase 3)
- [ ] Update `src/main/ai/llm.ts`:
- Deprecate. Redirect `getLLM()` to instantiate via `LiteLLMClient` as a thin compatibility shim
- [ ] Update `src/main/ai/embeddings.ts` to delegate to `src/main/llm/embeddings.ts`
- [ ] Update `src/main/ai/token.ts`:
- Add `listStoredProviders(): Promise<string[]>` to enumerate which providers have tokens
- [ ] Ensure all existing AI features (chat, daily brief, tool calling) continue to work
- **Files:** `src/main/ai/orchestrator.ts`, `src/main/ai/llm.ts`, `src/main/ai/embeddings.ts`, `src/main/ai/token.ts`
- **Outcome:** Existing AI features work identically but go through the new unified LLM client.
---
## Phase 2 — Local Plugin System & Batch Agents
### Step 2.1 — Create plugin manifest system and permission manager
- [ ] Create `src/main/permissions/manifest-validator.ts`:
- `PluginManifest` interface: `name`, `description`, `version`, `permissions: PermissionRequest[]`, `schedule?: string` (cron), `entryPoint: string`
- `PermissionRequest`: `type` (read_folder, read_email, read_calendar, read_browser_history), `resource?: string` (path, account), `reason: string`
- `validateManifest(manifest): ValidationResult` — validates structure, checks for dangerous permissions
- [ ] Create `src/main/permissions/permission-manager.ts`:
- `PermissionManager` class (singleton):
- `grantPermission(pluginName, permission): void` — persists to SQLite
- `revokePermission(pluginName, permission): void`
- `checkPermission(pluginName, permission): boolean`
- `getPluginPermissions(pluginName): PermissionGrant[]`
- `getAllGrants(): PermissionGrant[]`
- `logAccess(pluginName, permission, resource, timestamp): void` — activity log
- `getActivityLog(pluginName?, limit?): ActivityLogEntry[]`
- Permission grants stored in a new `plugin_permissions` SQLite table
- Activity log stored in a new `plugin_activity_log` SQLite table
- [ ] Add `plugin_permissions` and `plugin_activity_log` tables to `src/main/db/schema.ts`
- [ ] Generate and apply migration
- **Files:** `src/main/permissions/manifest-validator.ts`, `src/main/permissions/permission-manager.ts`, `src/main/db/schema.ts`, `src/main/db/migrations/`
- **Outcome:** Granular, opt-in permission system for plugins. Every access is logged.
### Step 2.2 — Create worker pool and batch runner
- [ ] Create `src/main/workers/worker-pool.ts`:
- `WorkerPool` class:
- Manages a pool of Node.js `worker_threads`
- `runPlugin(manifest, context): Promise<PluginResult>` — spawns or reuses a worker, sends manifest + context, receives result
- Worker lifecycle: create, send message, receive result, terminate on timeout
- Max concurrent workers: configurable (default 4)
- Error isolation: worker crash doesn't affect main process
- [ ] Create `src/main/workers/batch-runner.ts`:
- `BatchRunner` class:
- `registerPlugin(manifest): void` — validates manifest, stores in registry
- `startScheduler(): void` — cron-based scheduler using `node-cron` or simple setInterval
- `runPlugin(name, triggerContext?): Promise<PluginResult>` — manual trigger
- `stopAll(): void` — graceful shutdown of all scheduled plugins
- Scheduler checks permissions before each run; skips if revoked
- Results logged to activity log
- [ ] Create `src/main/workers/plugin-worker.ts`:
- Worker thread entry point
- Receives plugin config + context via `parentPort.on('message')`
- Dynamically imports the plugin entry point
- Executes `run(context)` with sandboxed access (only permitted resources)
- Posts result back via `parentPort.postMessage()`
- **Files:** `src/main/workers/worker-pool.ts`, `src/main/workers/batch-runner.ts`, `src/main/workers/plugin-worker.ts`
- **Outcome:** Isolated plugin execution environment with scheduling, permissions enforcement, and error isolation.
### Step 2.3 — Implement batch agent plugins
- [ ] Create `src/plugins/email-scanner.ts`:
- Manifest: requires `read_email` permission
- Connects to IMAP via `imapflow` (account configured in settings)
- Scans for new emails since last run
- Uses `LiteLLMClient` to classify each email (has actionable task? extract title, priority, description)
- Returns extracted task metadata (never raw email content) for execution via backend or local playbook
- [ ] Create `src/plugins/file-watcher.ts`:
- Manifest: requires `read_folder` permission for each watched path
- Uses `chokidar` to watch approved directories
- On new/modified file: reads content, generates embedding, upserts into vector store
- Supports: .txt, .md, .pdf (text extraction), .docx (basic extraction)
- [ ] Create `src/plugins/calendar-sync.ts`:
- Manifest: requires `read_calendar` permission
- Parses ICS files or connects to CalDAV endpoint
- Detects scheduling conflicts
- Suggests reorganizations via LLM analysis
- Returns calendar events + conflict reports
- [ ] Create `src/plugins/browser-agent.ts`:
- Manifest: requires `read_browser_history` permission (explicit opt-in)
- Reads browser bookmarks and history from known browser paths (Chrome, Firefox, Edge)
- Indexes relevant entries into vector store
- Privacy-first: only indexes URLs and titles, not page content
- **Files:** `src/plugins/email-scanner.ts`, `src/plugins/file-watcher.ts`, `src/plugins/calendar-sync.ts`, `src/plugins/browser-agent.ts`
- **Outcome:** Four local batch agents running as isolated worker threads, using LiteLLM for analysis.
---
## Phase 3 — Backend Integration
### Step 3.1 — Create backend HTTP/WebSocket client
- [ ] Create `src/main/api/backend-client.ts`:
- `BackendClient` class:
- `baseUrl` configurable (default: production cloud URL, overridable for dev)
- `setAuthToken(jwt: string): void`
- `chat(request: ChatRequest): Promise<ChatResponse>` — POST /api/v1/chat
- `chatStream(request: ChatRequest): AsyncGenerator<string>` — WebSocket /api/v1/chat/stream
- `getPlaybooks(): Promise<ExecutionPlan[]>` — GET /api/v1/plans/playbook
- `uploadBackup(blob: Buffer, metadata: BackupMetadata): Promise<void>` — PUT /api/v1/backup
- `downloadBackup(): Promise<{ blob: Buffer, metadata: BackupMetadata }>` — GET /api/v1/backup
- Automatic retry with exponential backoff (max 3 attempts)
- Offline detection: returns cached playbook responses when offline
- `isOnline(): boolean` — connectivity check
- [ ] Create `src/main/api/plan-runner.ts`:
- `PlanRunner` class:
- `execute(plan: ExecutionPlan): Promise<PlanResult>` — executes plan steps locally
- Step handlers: `create_record` (inserts into SQLite), `update_record`, `delete_record`, `index_document` (upserts into vector store), `send_notification` (Electron notification API)
- Each step logs to activity log
- Supports `data_from_step` references (pipeline execution)
- Validates plan structure before execution
- **Files:** `src/main/api/backend-client.ts`, `src/main/api/plan-runner.ts`
- **Outcome:** Electron can communicate with the cloud backend and execute returned plans locally.
### Step 3.2 — Refactor orchestrator to delegate to backend
- [ ] Update `src/main/ai/orchestrator.ts`:
- When online: forward chat requests to backend via `BackendClient.chatStream()`
- Build `ChatRequest` from local context: query SQLite for user profile, relevant documents (from vector store), recent tasks, conversation history
- Stream backend response tokens to renderer via existing `ai:stream` IPC channel
- Execute any returned actions via `PlanRunner`
- When offline: fall back to local orchestration (existing LangGraph pipeline) with degraded capabilities
- Remove direct agent logic (project agent, knowledge agent, general agent tool definitions) — these now live on the backend
- Keep `buildProjectContext()` and `buildGlobalContext()` as context builders for the request payload
- [ ] Update `src/main/router/index.ts` `ai` sub-router:
- `chat` mutation: call refactored orchestrator (which now delegates to backend)
- Add `getPlaybooks` query: fetches cached playbooks
- Keep `dailyBrief` mutation: sends daily brief request to backend
- [ ] Add IPC handler for plan execution results
- **Files:** `src/main/ai/orchestrator.ts`, `src/main/router/index.ts`, `src/main/ipc.ts`
- **Outcome:** Chat intelligence lives on the backend; Electron is the execution layer.
### Step 3.3 — Implement Shared Memory (three-tier local memory)
- [ ] Create `src/main/database/shared-memory.ts`:
- **Short-term memory**: In-memory conversation buffer
- `ConversationBuffer` class: stores last N messages per session
- `addMessage(sessionId, role, content)`, `getHistory(sessionId, limit?) -> Message[]`
- Cleared on session end
- **Long-term KV store**: SQLite-backed key-value store
- New `agent_memory` table: `id`, `namespace` (agent name), `key`, `value` (JSON text), `updated_at`
- `AgentMemoryStore` class: `get(namespace, key)`, `set(namespace, key, value)`, `delete(namespace, key)`, `listKeys(namespace)`
- Used by agents to persist learned facts, user preferences
- **Vector store**: Already exists (LanceDB). Enhance with:
- Multi-collection support: separate tables for notes, emails, files, calendar
- `searchByCollection(collection, query, limit) -> SearchResult[]`
- [ ] Add `agent_memory` table to `src/main/db/schema.ts`
- [ ] Generate migration
- **Files:** `src/main/database/shared-memory.ts`, `src/main/db/schema.ts`, `src/main/db/migrations/`
- **Outcome:** Three-tier memory system supporting short-term conversation, long-term agent facts, and semantic search.
---
## Phase 4 — Security: E2E Backup & Offline Mode
### Step 4.1 — Implement E2E encrypted backup
- [ ] Create `src/main/backup/e2e-crypto.ts`:
- `generatePassphrase(): string` — BIP39-compatible 12-word recovery phrase
- `deriveKey(passphrase: string, salt: Buffer): Promise<Buffer>` — Argon2id key derivation (time cost 3, memory 64MB, parallelism 1)
- `encrypt(data: Buffer, key: Buffer): { ciphertext: Buffer, iv: Buffer, authTag: Buffer }` — AES-256-GCM
- `decrypt(ciphertext: Buffer, key: Buffer, iv: Buffer, authTag: Buffer): Buffer`
- Uses `node:crypto` for AES and `argon2` npm package for key derivation
- [ ] Create `src/main/backup/backup-manager.ts`:
- `BackupManager` class:
- `createBackup(passphrase: string): Promise<BackupBlob>` — Exports SQLite DB, encrypts, returns blob + metadata
- `restoreBackup(blob: Buffer, passphrase: string): Promise<void>` — Decrypts blob, replaces local DB, re-initializes
- `uploadBackup(passphrase: string): Promise<void>` — Creates backup, uploads via `BackendClient`
- `downloadAndRestore(passphrase: string): Promise<void>` — Downloads from backend, decrypts, restores
- Incremental backup: chunks DB into segments, encrypts each separately, tracks content hashes to skip unchanged chunks
- Metadata header: version, timestamp, checksum (SHA-256 of plaintext), chunk count
- **Files:** `src/main/backup/e2e-crypto.ts`, `src/main/backup/backup-manager.ts`
- **Outcome:** User data never leaves the device unencrypted. Backend stores only opaque blobs.
### Step 4.2 — Implement offline sync queue
- [ ] Create `src/main/backup/sync-queue.ts`:
- `SyncQueue` class:
- `enqueue(action: QueuedAction): void` — Adds action to persistent queue (SQLite table `sync_queue`)
- `processQueue(): Promise<void>` — Processes queued actions in FIFO order when online
- `getQueueSize(): number`
- `clearQueue(): void`
- Conflict resolution: last-write-wins with timestamps
- New `sync_queue` table: `id`, `action_type`, `payload` (JSON), `created_at`, `status` (pending/processing/failed), `retry_count`, `last_error`
- Auto-drain: watches connectivity, starts processing when online
- Failed actions: retry up to 3 times with exponential backoff, then mark as `failed` for user review
- [ ] Add `sync_queue` table to schema
- [ ] Integrate with `BackendClient`: when offline, chat/backup calls enqueue instead of failing
- **Files:** `src/main/backup/sync-queue.ts`, `src/main/db/schema.ts`, `src/main/api/backend-client.ts`
- **Outcome:** App works offline; queued actions sync automatically when connectivity returns.
---
## Phase 5 — Auth Integration & Database Encryption
### Step 5.1 — Integrate auth into Electron app
- [ ] Create `src/main/auth/auth-manager.ts`:
- `AuthManager` class:
- `login(email, password): Promise<void>` — Calls backend POST /api/v1/auth/login, stores JWT in secure storage (via token.ts)
- `register(email, password): Promise<void>` — Calls POST /api/v1/auth/register
- `logout(): void` — Clears stored JWT
- `getToken(): string | null` — Returns current JWT
- `refreshToken(): Promise<void>` — Auto-refresh before expiry
- `isAuthenticated(): boolean`
- `getCurrentTier(): BillingTier`
- Auto-refresh: checks token expiry every 5 minutes, refreshes if < 10 minutes remaining
- [ ] Add tRPC procedures: `auth.login`, `auth.register`, `auth.logout`, `auth.status`, `auth.tier`
- [ ] Wire `BackendClient` to use `AuthManager.getToken()` for all requests
- **Files:** `src/main/auth/auth-manager.ts`, `src/main/router/index.ts`, `src/main/api/backend-client.ts`
- **Outcome:** Electron app has full auth flow; backend requests are authenticated.
### Step 5.2 — Migrate from better-sqlite3 to SQLCipher
- [ ] Add `@journeyapps/sqlcipher` to dependencies (replaces `better-sqlite3`)
- [ ] Update `src/main/db/index.ts`:
- Replace `better-sqlite3` import with `@journeyapps/sqlcipher`
- On first launch: derive DB key from OS keychain or prompt user
- `initDb(password)`: opens DB with `PRAGMA key = 'password'`
- Migration path for existing unencrypted DBs: detect export create encrypted import delete old
- WAL mode still enabled after keying
- [ ] Update `src/main/index.ts`: pass password to `initDb()`
- [ ] Test that all existing Drizzle operations work with SQLCipher
- **Files:** `package.json`, `src/main/db/index.ts`, `src/main/index.ts`
- **Outcome:** All local data encrypted at rest with SQLCipher.
---
## Phase 6 — Renderer UI Updates
> **Navigation model:** The app has a sidebar with top-level routes matching the pages below. Each page is a full-screen view. Shared hooks live in `src/renderer/hooks/`. All data access goes through tRPC procedures — no direct IPC calls from components.
### Step 6.1 — Restructure app shell and routing
- [ ] Update `src/renderer/App.tsx`:
- Define top-level routes: `/chat`, `/batch-builder`, `/plugins`, `/data-manager`, `/settings`, `/activity`
- Add sidebar navigation with icons and labels for each route
- Persist last active route in electron-store
- [ ] Create `src/renderer/hooks/useProvider.ts`:
- `useProvider()` returns active provider config, `setProvider()`, `testProvider()`, list of configured providers
- Backed by tRPC `provider.*` procedures (to be added in Phase 1)
- [ ] Create `src/renderer/hooks/useStorage.ts`:
- `useStorage()` returns `StorageStats`, `setStorageTarget(source, target)`, `migrateData(source, from, to)`
- Backed by tRPC `storage.*` procedures (to be added in Phase 2)
- **Files:** `src/renderer/App.tsx`, `src/renderer/hooks/useProvider.ts`, `src/renderer/hooks/useStorage.ts`
- **Outcome:** App shell with all top-level routes and shared data hooks.
### Step 6.2 — ChatPage with context panel
- [ ] Create `src/renderer/pages/ChatPage.tsx`:
- Two-column layout: chat area (left/main) + collapsible `ContextPanel` (right)
- Wraps `ChatWindow` and `ContextPanel` components
- Online/offline status bar at top
- [ ] Create `src/renderer/components/chat/ChatWindow.tsx`:
- Message list rendering `MessageBubble` for each entry
- Input bar with send button and attachment support
- Handles streaming tokens from `useChat` hook
- Plan approval UI inline: expandable plan steps with approve/reject per-step
- Error states: offline, auth expired, rate limited, server error (distinct UI for each)
- [ ] Create `src/renderer/components/chat/MessageBubble.tsx`:
- Renders user / assistant / system messages
- Supports markdown rendering for assistant messages
- Shows tool-call indicators when the agent uses a tool
- Timestamp and copy-to-clipboard action
- [ ] Create `src/renderer/components/chat/ContextPanel.tsx`:
- Shows what context the agent used for the last response: matched documents, recent tasks, memory entries
- Each context item links to its source (note, file, batch result)
- Collapsible, persists open/closed state
- [ ] Create `src/renderer/hooks/useChat.ts`:
- `useChat(sessionId)` message list, `sendMessage()`, streaming state, connection mode (`'backend'` | `'local'`)
- Automatically falls back to local orchestrator when offline
- Exposes `approveStep(stepId)` / `rejectStep(stepId)` for plan execution
- **Files:** `src/renderer/pages/ChatPage.tsx`, `src/renderer/components/chat/ChatWindow.tsx`, `src/renderer/components/chat/MessageBubble.tsx`, `src/renderer/components/chat/ContextPanel.tsx`, `src/renderer/hooks/useChat.ts`
- **Outcome:** Full chat UI with context transparency, plan approval, and seamless online/offline fallback.
### Step 6.3 — BatchBuilderPage
- [ ] Create `src/renderer/pages/BatchBuilderPage.tsx`:
- Two views: **Active Batches** list (default) and **Create New Batch** wizard
- Active list renders `BatchCard` for each active batch config
- "Create" button opens the wizard
- [ ] Create `src/renderer/components/batch-builder/NaturalLanguageInput.tsx`:
- Textarea where the user describes the batch in plain language
- "Generate" button calls `useBatchBuilder().generate(description)`
- Loading skeleton while the LLM generates the config
- [ ] Create `src/renderer/components/batch-builder/ConfigPreview.tsx`:
- Shows the generated `BatchConfig` as an editable form (not raw JSON)
- Sections: Source, Trigger, Analysis, Actions, Storage each collapsible
- Inline editing for every field (prompt textarea, cron expression with human-readable label, mapping table)
- "Edit raw JSON" toggle for power users
- [ ] Create `src/renderer/components/batch-builder/ConnectorPicker.tsx`:
- Dropdown of available connector types (IMAP, Filesystem, Gmail, GDrive, Outlook, Calendar, Generic API)
- When selected, shows connector-specific config fields (e.g. IMAP: host, folder, filter_from; Filesystem: path picker)
- OAuth connectors show "Connect account" button that opens the OAuth flow
- [ ] Create `src/renderer/components/batch-builder/StoragePicker.tsx`:
- Three-way toggle per storage dimension: **Local** / **Cloud** / **Sync** / **None**
- Dimensions: Records, Vectors, Raw data
- Shows storage impact estimate per option
- Disabled options grayed out with tier tooltip if current tier doesn't support cloud
- [ ] Create `src/renderer/components/batch-builder/SchedulePicker.tsx`:
- Mode toggle: **Cron** (with human-readable label, e.g. "Every day at 08:00") / **Event** (on new data from connector)
- Timezone selector (defaults to system timezone)
- Visual cron builder for non-technical users (with raw cron input fallback)
- [ ] Create `src/renderer/components/batch-builder/BatchCard.tsx`:
- Shows batch name, connector icon, last run time, next run time, status badge (`idle` / `running` / `error` / `disabled`)
- Actions: Run now, Edit, Disable/Enable, Delete
- Expandable to show last run summary (items processed, errors)
- [ ] Create `src/renderer/components/batch-builder/BatchTestRunner.tsx`:
- "Dry Run" panel: picks one real item from the source, runs the full analysis pipeline, shows output without saving
- Shows LLM output, action mapping preview, what would be stored and where
- Pass/Fail indicator with detailed error on failure
- [ ] Create `src/renderer/hooks/useBatchBuilder.ts`:
- `useBatchBuilder()` `generate(description): Promise<BatchConfig>`, `validate(config)`, `save(config)`, `activate(id)`, `deactivate(id)`, `runNow(id)`, `dryRun(id)`, `delete(id)`, list of saved configs with live status
- Backed by tRPC `batch.*` procedures
- **Files:** `src/renderer/pages/BatchBuilderPage.tsx`, `src/renderer/components/batch-builder/{NaturalLanguageInput,ConfigPreview,ConnectorPicker,StoragePicker,SchedulePicker,BatchCard,BatchTestRunner}.tsx`, `src/renderer/hooks/useBatchBuilder.ts`
- **Outcome:** Full Batch Builder UI users can describe a batch in natural language, review/edit the generated config, dry-run it, and activate it with a single flow.
### Step 6.4 — PluginStorePage
- [ ] Create `src/renderer/pages/PluginStorePage.tsx`:
- Two tabs: **Marketplace** (browse available plugins) and **Installed** (manage installed plugins)
- Marketplace: search bar, category filter chips, grid of plugin cards sorted by rating/installs
- Installed: list of `InstalledPlugin` entries with enable/disable toggles and settings links
- [ ] Create plugin card component (inline or shared `common/`):
- Shows name, author, description, rating (stars), install count, category badge, price/free badge
- "Install" button triggers permission request dialog installs plugin
- "Settings" button (installed) opens plugin-specific config drawer
- [ ] Plugin install flow:
- On install click: fetch plugin manifest from backend
- Show `PermissionDialog` with the permissions the plugin requires
- On approve: call tRPC `plugins.install(id)`, download and register the plugin worker
- Show `StoragePicker` for the plugin's data (what goes local/cloud/sync)
- **Files:** `src/renderer/pages/PluginStorePage.tsx`
- **Outcome:** Users can discover and install pre-built plugins from the marketplace with full permission visibility.
### Step 6.5 — DataManagerPage
- [ ] Create `src/renderer/pages/DataManagerPage.tsx`:
- Top section: `StorageOverview` dashboard
- Below: list of `DataSourceCard` for each active data source (one card per connector/plugin)
- "Migrate" button opens `MigrationWizard`
- [ ] Create `src/renderer/components/data-manager/StorageOverview.tsx`:
- Visual breakdown: local disk used vs. cloud used vs. cloud limit
- Per-category breakdown (emails, files, notes, calendar, vectors)
- Tier upgrade CTA if approaching cloud limit
- [ ] Create `src/renderer/components/data-manager/DataSourceCard.tsx`:
- Card per data source (e.g. "Gmail Scanner", "Documenti/Fatture watcher")
- Shows record count, size, last sync time
- Inline `StoragePicker` toggle for that source (where its data lives)
- "Clear local cache" / "Delete all data" actions with confirmation
- [ ] Create `src/renderer/components/data-manager/MigrationWizard.tsx`:
- Step wizard: select source select direction (local cloud or cloud local) confirm
- Shows estimated data size and time
- Progress indicator during migration
- Rolls back on error
- **Files:** `src/renderer/pages/DataManagerPage.tsx`, `src/renderer/components/data-manager/{StorageOverview,DataSourceCard,MigrationWizard}.tsx`
- **Outcome:** Users have full visibility and control over where every piece of their data lives.
### Step 6.6 — ActivityLogPage
- [ ] Create `src/renderer/pages/ActivityLogPage.tsx`:
- Full-page filterable table of all batch/plugin activity entries
- Columns: timestamp, source (batch name / plugin name), action type, data accessed, storage destination, status
- Filters: source, date range, action type, status (success/error)
- Row expand: shows full detail which records were created/updated, which files were read, LLM calls made
- Export as CSV button
- **Files:** `src/renderer/pages/ActivityLogPage.tsx`
- **Outcome:** Complete transparency log so users can audit exactly what each agent did and when.
### Step 6.7 — SettingsPage (multi-provider, auth, backup, embeddings)
- [ ] Create `src/renderer/pages/SettingsPage.tsx` with tabbed sections:
- **AI Providers** tab:
- List of configured providers with status badge (active / inactive / error)
- Add provider form: name dropdown (OpenAI, Anthropic, Google, Mistral, Groq, Ollama), API key input, model selection, endpoint (for Ollama)
- Set primary provider and fallback chain
- Test connection button per provider
- Separate "Embeddings provider" section: provider + model for embeddings (OpenAI, Cohere, Voyage, Mistral Embed)
- Info callout: "Text sent to the embeddings provider to generate vectors make sure you trust this provider with your data"
- **Account & Billing** tab:
- Login/register form (when not authenticated)
- Current tier display with feature list and upgrade CTA
- Usage indicators (batch count, cloud storage used)
- Logout button
- **Backup & Sync** tab:
- Recovery passphrase: generate new / view existing (masked, reveal on click)
- Manual backup trigger with last backup timestamp
- Auto-backup schedule toggle + interval picker
- Backup history table (timestamp, size, restore button)
- **Permissions** tab:
- Table of all active permission grants (plugin/batch, permission type, resource, granted date)
- Revoke button per grant
- Links to ActivityLogPage for per-source audit
- [ ] Create `src/renderer/components/common/ProviderSelector.tsx`:
- Reusable dropdown that lists configured LLM providers
- Used in BatchBuilder (model_override field) and Settings
- [ ] Create `src/renderer/components/common/PermissionDialog.tsx`:
- Modal triggered when a plugin/batch requests new permissions
- Lists each requested permission with its reason and resource path
- Per-permission approve/deny toggles (deny is default)
- Shows plugin/batch manifest info (name, description, version)
- "Approve selected" confirms; "Deny all" closes without granting
- **Files:** `src/renderer/pages/SettingsPage.tsx`, `src/renderer/components/common/PermissionDialog.tsx`, `src/renderer/components/common/ProviderSelector.tsx`
- **Outcome:** Centralised settings covering providers, embeddings, auth, backup, and permissions.
---
## Phase 7 — Cleanup & Hardening
### Step 7.1 — Remove deprecated AI code
- [ ] Delete `src/main/ai/copilot.ts` (Copilot SDK replaced by LiteLLM)
- [ ] Delete `src/main/ai/chat-copilot.ts` (LangChain adapter no longer needed)
- [ ] Delete or archive `src/main/ai/llm.ts` (replaced by `src/main/llm/litellm-client.ts`)
- [ ] Remove `@github/copilot-sdk`, `@langchain/langgraph` from dependencies (if unused)
- [ ] Clean up `src/main/ai/provider.ts`: simplify to delegate to `src/main/llm/providers.ts`
- [ ] Remove `currentSender` module-level mutable state from orchestrator (proper context passing)
- [ ] Update `src/main/index.ts` startup: remove `import './ai/copilot'`, add `BatchRunner.startScheduler()`, add `AuthManager` init
- **Files:** Multiple files under `src/main/ai/`, `package.json`, `src/main/index.ts`
- **Outcome:** No dead code; clean, maintainable codebase.
### Step 7.2 — Add error handling and logging
- [ ] Implement structured logging in main process:
- Log levels: debug, info, warn, error
- Log destinations: console (dev), file (production, rotated)
- Correlation IDs for request tracing across IPC backend response
- [ ] Add error boundaries in renderer:
- Per-route error boundaries
- AI chat error boundary (graceful degradation)
- Plugin error boundary (shows which plugin failed)
- **Files:** `src/main/utils/logger.ts` (new), `src/renderer/components/ErrorBoundary.tsx` (new)
- **Outcome:** Production-ready error handling and observability.
### Step 7.3 — Electron integration tests
- [ ] Test BackendClient with mocked HTTP responses
- [ ] Test PlanRunner with sample execution plans
- [ ] Test SyncQueue offline online transition
- [ ] Test BackupManager encrypt decrypt round-trip
- [ ] Test PermissionManager grant check revoke cycle
- **Files:** `src/main/__tests__/` (new test directory)
- **Outcome:** Confidence that all Electron-side components work correctly.
---
## New Dependencies (package.json)
| Package | Purpose |
|---|---|
| `@journeyapps/sqlcipher` | Encrypted SQLite (replaces `better-sqlite3`) |
| `argon2` | Key derivation for E2E backup |
| `node-cron` | Batch agent scheduling |
| `chokidar` | File watching (FileWatcher plugin) |
| `imapflow` | IMAP client (IMAP connector) |
| `googleapis` | Gmail + GDrive OAuth connectors |
| `lancedb` | Local vector store |
| `onnxruntime-node` | Local embeddings (optional, future) |
---
## Execution Notes
- **Each step is independently committable** and produces working code.
- **Phases 1-2** (LLM client + plugins) are independent of the backend can start immediately.
- **Phase 3** (backend integration) requires the backend repo to have the `/api/v1/chat` endpoint ready.
- **Phase 5.2** (SQLCipher) is intentionally late to avoid encryption overhead during active schema changes.
- **The existing app continues to work** throughout the migration. Local orchestration is preserved until backend is ready (Step 3.2).
- **One step at a time.** Implement one numbered step per session. When the step is fully done, mark all its checkboxes as `[x]` in this file and commit with message `step N complete: <outcome line>`.

View File

@@ -7,12 +7,232 @@ import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-nati
import { VitePlugin } from '@electron-forge/plugin-vite'; import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses'; import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses'; import { FuseV1Options, FuseVersion } from '@electron/fuses';
import path from 'node:path';
import fs from 'node:fs';
import { execSync } from 'node:child_process';
// Packages externalized in vite.main.config.mts that must be installed at runtime.
// Keep this list in sync with the Vite external array.
const externalPackages = [
'better-sqlite3',
'@github/copilot-sdk',
'@langchain/core',
'@langchain/langgraph',
'@langchain/openai',
'@langchain/anthropic',
'vectordb',
'electron-squirrel-startup',
'electron-store',
];
const config: ForgeConfig = { const config: ForgeConfig = {
packagerConfig: { packagerConfig: {
asar: true, asar: {
unpack: '**/{*.node,*.dll,*.so,*.dylib}',
},
name: 'adiuva',
}, },
rebuildConfig: {}, rebuildConfig: {},
hooks: {
packageAfterCopy: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => {
// The VitePlugin's ignore filter only copies .vite/ into the build.
// Externalized packages need to be installed into node_modules here.
// At this point, only .vite/ exists. The VitePlugin writes package.json
// in its own afterCopy hook (which may run after ours). Read from source.
const srcPjPath = path.resolve(__dirname, 'package.json');
const pjPath = path.resolve(buildPath, 'package.json');
const pj = JSON.parse(fs.readFileSync(srcPjPath, 'utf-8'));
// Keep only externalized packages in dependencies
const filtered: Record<string, string> = {};
for (const pkg of externalPackages) {
if (pj.dependencies?.[pkg]) {
filtered[pkg] = pj.dependencies[pkg];
}
}
pj.dependencies = filtered;
delete pj.devDependencies;
fs.writeFileSync(pjPath, JSON.stringify(pj, null, 2));
// Copy lockfile for reproducible installs
const lockSrc = path.resolve(buildPath, '..', '..', 'package-lock.json');
if (fs.existsSync(lockSrc)) {
fs.copyFileSync(lockSrc, path.resolve(buildPath, 'package-lock.json'));
}
// Install only the externalized runtime deps
console.log('[forge] Installing externalized dependencies...');
execSync('npm install --omit=dev', {
cwd: buildPath,
stdio: 'inherit',
env: { ...process.env, npm_config_nodedir: '' },
});
const targetKey = `${platform}-${arch}`;
// vectordb uses platform-specific optional deps (@lancedb/vectordb-<platform>-<arch>-*).
// npm install on Linux only pulls the Linux variant. Force-install the target's.
const platformNativePackages: Record<string, Record<string, string>> = {
'win32-x64': {
'@lancedb/vectordb-win32-x64-msvc': '',
},
'linux-x64': {
'@lancedb/vectordb-linux-x64-gnu': '',
},
'darwin-x64': {
'@lancedb/vectordb-darwin-x64': '',
},
'darwin-arm64': {
'@lancedb/vectordb-darwin-arm64': '',
},
};
const nativePkgs = platformNativePackages[targetKey];
if (nativePkgs) {
// Remove wrong-platform lancedb native packages
const nmPath = path.join(buildPath, 'node_modules', '@lancedb');
if (fs.existsSync(nmPath)) {
for (const entry of fs.readdirSync(nmPath)) {
if (entry.startsWith('vectordb-') && !Object.keys(nativePkgs).includes(`@lancedb/${entry}`)) {
fs.rmSync(path.join(nmPath, entry), { recursive: true, force: true });
console.log(`[forge] Removed non-target native package: @lancedb/${entry}`);
}
}
}
// Install correct platform packages
const pkgsToInstall = Object.keys(nativePkgs).join(' ');
console.log(`[forge] Installing platform-specific packages for ${targetKey}: ${pkgsToInstall}`);
execSync(`npm install ${pkgsToInstall} --omit=dev --no-save --force`, {
cwd: buildPath,
stdio: 'inherit',
});
}
// Remove cross-platform prebuilt binaries that don't match the target.
// Packages like @github/copilot ship prebuilds for all platforms;
// keeping foreign-arch .node files breaks rpmbuild's strip step.
const nodeModulesPath = path.join(buildPath, 'node_modules');
const findPrebuilds = (dir: string): string[] => {
const results: string[] = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'prebuilds') {
results.push(full);
} else {
results.push(...findPrebuilds(full));
}
}
}
return results;
};
for (const prebuildsDir of findPrebuilds(nodeModulesPath)) {
for (const entry of fs.readdirSync(prebuildsDir)) {
if (entry !== targetKey) {
const fullPath = path.join(prebuildsDir, entry);
fs.rmSync(fullPath, { recursive: true, force: true });
console.log(`[forge] Removed non-target prebuild: ${entry}`);
}
}
}
// @github/copilot ships @teddyzhu/clipboard-* platform packages outside
// of prebuilds/. Remove non-target variants to avoid bundling wrong binaries.
const clipboardDir = path.join(buildPath, 'node_modules', '@github', 'copilot', 'clipboard', 'node_modules', '@teddyzhu');
if (fs.existsSync(clipboardDir)) {
const targetClipboardMap: Record<string, string> = {
'win32-x64': 'clipboard-win32-x64-msvc',
'win32-arm64': 'clipboard-win32-arm64-msvc',
'linux-x64': 'clipboard-linux-x64-gnu',
'linux-arm64': 'clipboard-linux-arm64-gnu',
'darwin-x64': 'clipboard-darwin-x64',
'darwin-arm64': 'clipboard-darwin-arm64',
};
const wantedPkg = targetClipboardMap[targetKey];
for (const entry of fs.readdirSync(clipboardDir)) {
if (entry.startsWith('clipboard-') && entry !== wantedPkg) {
fs.rmSync(path.join(clipboardDir, entry), { recursive: true, force: true });
console.log(`[forge] Removed non-target clipboard package: @teddyzhu/${entry}`);
}
}
}
},
// ── Post-rebuild: fix native binaries for cross-compilation ──────
// Forge runs @electron/rebuild AFTER packageAfterCopy, which
// recompiles native addons for the BUILD platform (Linux).
// packageAfterPrune runs AFTER rebuild+prune, so we can safely
// replace the Linux .node files with the correct target prebuilts.
packageAfterPrune: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => {
const targetKey = `${platform}-${arch}`;
const buildKey = `${process.platform}-${process.arch}`;
if (targetKey === buildKey) return; // native build — nothing to fix
console.log(`[forge:afterPrune] Cross-compile fixup: ${buildKey}${targetKey}`);
const electronVersion = JSON.parse(
fs.readFileSync(path.resolve(__dirname, 'node_modules', 'electron', 'package.json'), 'utf-8'),
).version;
// Replace native addons that @electron/rebuild compiled for the host.
const nativeModules = ['better-sqlite3'];
for (const mod of nativeModules) {
const modDir = path.join(buildPath, 'node_modules', mod);
if (!fs.existsSync(modDir)) continue;
// Remove the host-platform binary left by @electron/rebuild
const buildRelease = path.join(modDir, 'build', 'Release');
if (fs.existsSync(buildRelease)) {
fs.rmSync(buildRelease, { recursive: true, force: true });
console.log(`[forge:afterPrune] Cleaned host-platform build/Release for ${mod}`);
}
// Download the correct prebuilt for the TARGET platform
console.log(`[forge:afterPrune] Downloading ${mod} prebuilt for ${targetKey} (Electron ${electronVersion})...`);
execSync(
`npx --yes prebuild-install -r electron -t ${electronVersion} ` +
`--platform ${platform} --arch ${arch} --tag-prefix v --verbose`,
{ cwd: modDir, stdio: 'inherit' },
);
// Verify the binary exists and is for the correct platform.
const releaseDir = path.join(modDir, 'build', 'Release');
if (!fs.existsSync(releaseDir)) {
throw new Error(
`[forge] FATAL: build/Release/ not found for ${mod} after prebuild-install. ` +
`The native binary was not downloaded.`,
);
}
const nodeFiles = fs.readdirSync(releaseDir).filter((f) => f.endsWith('.node'));
if (nodeFiles.length === 0) {
throw new Error(
`[forge] FATAL: No .node files in build/Release/ for ${mod} after prebuild-install.`,
);
}
for (const f of nodeFiles) {
const buf = Buffer.alloc(4);
const fd = fs.openSync(path.join(releaseDir, f), 'r');
fs.readSync(fd, buf, 0, 4, 0);
fs.closeSync(fd);
// ELF magic: 0x7f 'E' 'L' 'F'
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
throw new Error(
`[forge] FATAL: ${mod} build/Release/${f} is an ELF binary! ` +
`Cross-compilation failed — refusing to package a Linux .node for Windows.`,
);
}
// PE magic: 'M' 'Z' (0x4d 0x5a) — expected for win32
if (platform === 'win32' && !(buf[0] === 0x4d && buf[1] === 0x5a)) {
throw new Error(
`[forge] FATAL: ${mod} build/Release/${f} is not a PE (Windows) binary! ` +
`Magic bytes: ${buf.toString('hex')}. Refusing to package.`,
);
}
console.log(`[forge:afterPrune] Verified ${mod}/${f} — correct platform ✓`);
}
}
},
},
makers: [ makers: [
new MakerSquirrel({}), new MakerSquirrel({}),
new MakerZIP({}, ['darwin']), new MakerZIP({}, ['darwin']),

18
package-lock.json generated
View File

@@ -32,7 +32,6 @@
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"framer-motion": "^12.34.2", "framer-motion": "^12.34.2",
"keytar": "^7.9.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",
@@ -16007,17 +16006,6 @@
"node": ">= 12" "node": ">= 12"
} }
}, },
"node_modules/keytar": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^4.3.0",
"prebuild-install": "^7.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -18209,12 +18197,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
"license": "MIT"
},
"node_modules/node-api-version": { "node_modules/node-api-version": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",

View File

@@ -14,7 +14,7 @@
"knip": "knip" "knip": "knip"
}, },
"keywords": [], "keywords": [],
"author": "rmusso", "author": "roberto",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.11.1", "@electron-forge/cli": "^7.11.1",
@@ -71,7 +71,6 @@
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"framer-motion": "^12.34.2", "framer-motion": "^12.34.2",
"keytar": "^7.9.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",

View File

@@ -2,27 +2,11 @@ import { safeStorage } from 'electron';
import { getStore } from '../store'; import { getStore } from '../store';
/** /**
* Token storage with three-tier fallback: * Token storage with two-tier fallback:
* 1. OS keychain via keytar (best — encrypted, per-user) * 1. Electron safeStorage + electron-store (encrypted at rest)
* 2. Electron safeStorage + electron-store (encrypted at rest) * 2. Plain electron-store (last resort — e.g. WSL with no keyring)
* 3. Plain electron-store (last resort — e.g. WSL with no keyring)
*/ */
let keytar: typeof import('keytar') | null = null;
let keytarFailed = false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
keytar = require('keytar') as typeof import('keytar');
} catch {
keytarFailed = true;
console.log('[Token] keytar native module unavailable');
}
function useKeytar(): boolean {
return keytar !== null && !keytarFailed;
}
function canUseSafeStorage(): boolean { function canUseSafeStorage(): boolean {
try { try {
return safeStorage.isEncryptionAvailable(); return safeStorage.isEncryptionAvailable();
@@ -31,8 +15,6 @@ function canUseSafeStorage(): boolean {
} }
} }
const SERVICE_NAME = 'adiuva';
// --- electron-store helpers (with optional safeStorage encryption) --- // --- electron-store helpers (with optional safeStorage encryption) ---
function readFromStore(providerName: string): string | null { function readFromStore(providerName: string): string | null {
@@ -74,41 +56,16 @@ function removeFromStore(providerName: string): void {
/** Read a stored token for the given provider. */ /** Read a stored token for the given provider. */
export async function getToken(providerName: string): Promise<string | null> { export async function getToken(providerName: string): Promise<string | null> {
if (useKeytar()) {
try {
return await keytar!.getPassword(SERVICE_NAME, providerName);
} catch (err) {
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
keytarFailed = true;
}
}
return readFromStore(providerName); return readFromStore(providerName);
} }
/** Store a token for the given provider. */ /** Store a token for the given provider. */
export async function setToken(providerName: string, token: string): Promise<void> { export async function setToken(providerName: string, token: string): Promise<void> {
if (useKeytar()) {
try {
await keytar!.setPassword(SERVICE_NAME, providerName, token);
return;
} catch (err) {
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
keytarFailed = true;
}
}
writeToStore(providerName, token); writeToStore(providerName, token);
} }
/** Delete a stored token for the given provider. */ /** Delete a stored token for the given provider. */
async function deleteToken(providerName: string): Promise<boolean> { async function deleteToken(providerName: string): Promise<boolean> {
if (useKeytar()) {
try {
return await keytar!.deletePassword(SERVICE_NAME, providerName);
} catch (err) {
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
keytarFailed = true;
}
}
removeFromStore(providerName); removeFromStore(providerName);
return true; return true;
} }

View File

@@ -7,7 +7,6 @@ export default defineConfig({
// Externalize native Node modules — they're rebuilt by electron-forge // Externalize native Node modules — they're rebuilt by electron-forge
external: [ external: [
'better-sqlite3', 'better-sqlite3',
'keytar',
'@github/copilot-sdk', '@github/copilot-sdk',
'@github/copilot', '@github/copilot',
// LangChain — externalize to avoid bundling Node.js-specific code // LangChain — externalize to avoid bundling Node.js-specific code