Compare commits

...

7 Commits

Author SHA1 Message Date
b804629f91 feat: add settings page with sections for general, account, agents, and appearance 2026-03-05 17:40:43 +01:00
b860e794a3 step 3.5 complete: persistent WS for agent triggers
- BackendClient.connectPersistent(): opens always-on WS to /api/v1/ws/device
  - sends device_hello frame with deviceId + active local agent IDs on connect
  - handles agent_run: reads files → sends agent_data + agent_complete frames
  - handles tool_call: DrizzleExecutor → tool_result (same as chat WS)
  - client-side heartbeat ping every 30s with 10s pong timeout
  - auto-reconnect with exponential backoff (1s→2s→4s→8s→16s→30s cap)
- BackendClient.disconnectPersistent(): clean close, disables reconnect
- handleAgentRunAndSend(): validates device ID (Step 3.3 final checkbox),
  sends agent_data + agent_complete frames over persistent WS; removes TODO
- index.ts: connectPersistent() on startup (if authenticated), will-quit handler
- authRouter.login: connectPersistent() on success
- authRouter.logout: disconnectPersistent()
- Completes Step 3.3 final checkbox (device-ID validation on agent_run)
2026-03-05 16:08:38 +01:00
6f73824e7e step 3.4 complete: agent tRPC router
- Add AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog,
  JourneyMessage Zod schemas + types to src/shared/api-types.ts

- Add proxyGet/proxyPost/proxyPut/proxyDelete methods to BackendClient
  (authenticated, casing-converted HTTP proxies with retry + auth bypass)

- Add agentRouter to src/main/router/index.ts (14 procedures):
    agent.catalog                     GET /api/v1/agents/catalog
    agent.local.{list,create,update,delete}  with deviceId injected on create
    agent.cloud.{list,create,update,delete}
    agent.runs                        GET /api/v1/agents/runs (paginated)
    agent.runNow                      POST /api/v1/agents/{id}/run
    agent.journey.{start,message}     chatbot journey endpoints

- Merge agent router into appRouter
- Mark Step 3.3 deviceId checkbox done (satisfied by local.create injection)
2026-03-05 15:51:27 +01:00
e132459fef step 3.3 complete: device ID management
- Add deviceId: string to AppSettings (electron-store) with default ''
- Add getDeviceId() helper — lazy UUID v4 generation, persisted on first call
- Add settings.deviceId tRPC query so renderer + agent router can read it
- Local agents will be device-bound (config injection in step 3.4)
2026-03-05 15:33:50 +01:00
43b031de5b step 3.2 complete: local file reader for directory agent
- Create src/main/agents/file-reader.ts:
  - readAgentFiles() — recursive directory walker with extension allowlist
  - extractContent() — dispatches by ext: text/md/eml/csv/json (readFile),
    pdf (PDFParse v2), docx (mammoth.extractRawText), unknown → error entry
  - chunkContent() — splits >50KB content on newline boundaries with chunk
    metadata; 10MB per-file size cap
  - Security: all paths resolved via realpath() before I/O; every path
    checked against allowedRoots to block symlink escapes and .. traversal
- Update BackendClient.handleAgentRun() to call readAgentFiles() and return
  { files, errors, filesRead }; WS transmission deferred to Step 3.5
- Add pdf-parse@^2.4.5 and mammoth@^1.11.0 (pure JS, no packaging changes)
2026-03-05 15:24:29 +01:00
e769ff2806 step 3.1 complete: WS agent frame types + handleAgentRun stub
- Add WsAgentRunSchema (server→client): agent_run with runId, agentId, config
- Add WsAgentDataSchema (client→server): agent_data with files array
- Add WsAgentCompleteSchema (client→server): agent_complete with filesRead + errors
- Add WsDeviceHelloSchema (client→server): device_hello with deviceId + agentIds
- Extend WsServerFrameSchema union to include agent_run
- Extend WsClientFrameSchema union to include agent_data, agent_complete, device_hello
- Add BackendClient.handleAgentRun() stub (full impl in Steps 3.2 + 3.5)
2026-03-05 15:01:46 +01:00
0c8f0c429a step 2.1 complete: no LangChain, no Copilot SDK, no local LLM 2026-03-05 11:22:08 +01:00
24 changed files with 3263 additions and 1230 deletions

View File

@@ -136,31 +136,34 @@ Tasks and checkpoints have `isAiSuggested` + `isApproved` columns. AI suggestion
## Design Context
### Target User
Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier.
### Users
Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier. They open the app mid-workday — often stressed — so the interface must feel immediately grounding and in control.
### Brand
**Calm, intelligent, warm.** Thoughtful companion, not flashy tool. Confident and understated, never loud or gamified.
### Brand Personality
**Calm. Intelligent. Warm.** A thoughtful companion, not a flashy tool. Confident and understated never loud, gamified, or corporate. Fully original aesthetic (no external design system references; this look is intentional and owned).
### Palette
### Emotional Goal
When a user opens Adiuva, the first impression should communicate **"everything is under control"** — calm clarity over urgency. The design should lower cognitive load, not raise it.
| | Canvas | Primary | Secondary | Borders |
|---|---|---|---|---|
| **Light** | Pinkish-white `#f4edf3` | Golden yellow `#fbc881` | Slate blue-gray `#8a8ea9` | Dusty lavender `#c8c3cd` |
| **Dark** | Near-black `#0c0c0c` | Pure white | — | Dark gray `#323232` |
### 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)
### Aesthetic Direction
- Light mode: pinkish-white canvas `#f4edf3`, golden yellow primary `#fbc881`, slate blue-gray secondary `#8a8ea9`, dusty lavender borders `#c8c3cd`
- Dark mode: near-black `#0c0c0c`, pure white primary, dark gray `#323232` surfaces
- Geist sans-serif, weights 400/500/600. Tight tracking (`-1px`) on headings. Body `text-sm`, metadata `text-xs`
- 10px border-radius (`rounded-lg`), `rounded-2xl` for chat/AI elements
- Glassmorphism on AI inputs (`backdrop-blur-xl`, transparency, gradient border via padding-box/border-box technique)
- Spring animations (stiffness 400, damping 30), scale-and-fade transitions
- No gamification (badges, streaks, confetti). Mature and professional
- Dashed borders + Sparkles icon = AI-pending state marker
### Accessibility
Best-effort — not formally audited. Maintain reasonable contrast and keyboard operability without targeting a specific WCAG level.
### Current Design Focus
**Polish and refinement.** The overall direction is solid; the priority is elevating specific areas that feel rough or inconsistent — tighter spacing, more intentional hierarchy, better empty/loading states, and smoother motion.
### Design Principles
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density
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
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
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density. Never sacrifice legibility for style.
2. **AI as quiet partner** — Deeply integrated but never intrusive. Dashed borders for pending AI items, Sparkles icon as the sole AI marker. Surface AI capabilities without making them the hero.
3. **Warmth in restraint**The warm palette feels approachable without being playful. Dark mode trades warmth for focus. Neither mode should feel cold or aggressive.
4. **Motion with purpose**Spring animations reinforce spatial relationships and acknowledge state changes. Never purely decorative. Respect reduced-motion preferences where possible.
5. **Polish over features** — Every surface should feel considered. Prefer refining what exists over introducing new complexity. The right amount of visual weight is the minimum needed.

2
.gitignore vendored
View File

@@ -93,4 +93,4 @@ out/
# local config files
.vscode/
.agents/

View File

@@ -194,32 +194,156 @@ Electron generates `id` (UUID v4) and `createdAt`/`updatedAt` (Unix ms) for inse
## Phase 2 — Remove Local AI Stack
### Step 2.1 — Remove local AI code and dependencies
- [ ] Delete `src/main/ai/llm.ts`, `src/main/ai/chat-copilot.ts`, `src/main/ai/copilot.ts`, `src/main/ai/provider.ts`
- [ ] Remove `import './ai/copilot'` and `initAI()` from `src/main/index.ts`
- [ ] Remove deps: `@langchain/core`, `@langchain/openai`, `@langchain/anthropic`, `@langchain/langgraph`, `@github/copilot-sdk`
- [ ] Clean up `src/main/store.ts` (remove `aiProvider`, `encryptedTokens`)
- [ ] Clean up `vite.main.config.mts` (remove externalized LangChain/Copilot packages)
- **Files:** Multiple deletions in `src/main/ai/`, `package.json`, `src/main/index.ts`, `src/main/store.ts`, `vite.main.config.mts`
- **Outcome:** ~1400 lines removed. No LangChain, no Copilot SDK, no local LLM.
### Step 2.1 — Remove local AI code and dependencies
- [x] Delete `src/main/ai/llm.ts`, `src/main/ai/chat-copilot.ts`, `src/main/ai/copilot.ts`, `src/main/ai/provider.ts`
- [x] Remove `import './ai/copilot'` and `initAI()` from `src/main/index.ts`
- [x] Remove deps: `@langchain/core`, `@langchain/openai`, `@langchain/anthropic`, `@langchain/langgraph`, `@github/copilot-sdk`
- [x] Clean up `src/main/store.ts` (remove `aiProvider`; kept `encryptedTokens` — still used by `token.ts` → `auth-manager.ts` for JWT storage)
- [x] Clean up `vite.main.config.mts` (remove externalized LangChain/Copilot packages)
- [x] Clean up `forge.config.ts` (remove LangChain/Copilot from `externalPackages`; remove copilot-sdk clipboard cleanup block)
- **Files:** `src/main/ai/{llm,chat-copilot,copilot,provider}.ts` (deleted), `package.json`, `src/main/index.ts`, `src/main/store.ts`, `vite.main.config.mts`, `forge.config.ts`
- **Outcome:** 34 npm packages removed. No LangChain, no Copilot SDK, no local LLM.
---
## Phase 3 — Local Plugin System & Batch Agents
## Phase 3 — Agent System (Local Directory + Cloud Connectors)
### Step 3.1 — Plugin manifest system and permission manager
- [ ] Create `src/main/permissions/manifest-validator.ts` + `permission-manager.ts`
- [ ] Add `plugin_permissions` and `plugin_activity_log` tables to schema
- **Outcome:** Granular, opt-in permission system.
> Two agent types at launch: **Local Directory Agent** (watches folders, Electron reads + pre-processes, backend runs AI) and **Cloud Connector Agent** (Gmail, Teams — 100% backend-managed). All configs live on the backend (synced, device-bound for local agents). Backend triggers agent runs via new WS frames when Electron is connected. Extracted data inserts into existing tables (tasks, notes, checkpoints) with `isAiSuggested=1`. Configuration prompts are built via a dedicated "Chatbot Journey" (multi-turn AI conversation on a dedicated page).
>
> **Backend Phase 3 plan:** `../adiuva-api/AI_REFACTOR_PLAN.md` Phase 3 section.
### Step 3.2 — Worker pool and batch runner
- [ ] Create `src/main/workers/worker-pool.ts`, `batch-runner.ts`, `plugin-worker.ts`
- [ ] Plugins call `BackendClient` for AI analysis (not local LLM)
- **Outcome:** Isolated plugin execution with backend-powered AI.
```
Cloud Agent Flow:
Backend cron ──► Backend fetches Gmail/Teams ──► Backend AI analyzes
──► WS tool_call(insert, table:'tasks') ──► Electron persists locally
### Step 3.3 — Implement batch agent plugins
- [ ] `src/plugins/email-scanner.ts`, `file-watcher.ts`, `calendar-sync.ts`, `browser-agent.ts`
- **Outcome:** Four batch agents using backend for AI.
Local Agent Flow:
Backend detects Electron online ──► WS agent_run frame (config + prompt)
──► Electron reads files + pre-processes ──► WS agent_data frame (content)
──► Backend AI analyzes with user prompt ──► WS tool_call(insert) ──► Electron persists
```
Key constraints:
- Local agents only run when Electron is active AND on the device where the path was configured
- Cloud agents only push results when Electron is connected (no server-side content storage)
- All AI communication goes through the backend (no local LLM)
- Tier gating: free=2 active, pro=10, power/team=unlimited
### Step 3.1 — WS frame types + agent handler ✅
- [x] Update `src/shared/api-types.ts`:
- Add `WsAgentRun` schema: `{ type: "agent_run", run_id, agent_id, config: { paths, file_extensions, prompt_template, data_types } }`
- Add `WsAgentData` schema: `{ type: "agent_data", run_id, files: [{ path, name, content, metadata }] }`
- Add `WsAgentComplete` schema: `{ type: "agent_complete", run_id, files_read, errors }`
- Add `WsDeviceHello` schema: `{ type: "device_hello", device_id, agent_ids }`
- Extend `WsServerFrame` discriminated union with `agent_run`
- Extend `WsClientFrame` with `agent_data`, `agent_complete`, `device_hello`
- [x] Update `src/main/api/backend-client.ts`:
- In WS message loop, handle `agent_run` frames:
1. Read files from configured paths using the local agent handler (Step 3.2)
2. Send `agent_data` frames back with pre-processed content
3. Continue handling `tool_call` frames for DB inserts as usual
- **Files:** `src/shared/api-types.ts`, `src/main/api/backend-client.ts`
- **Outcome:** Electron can receive agent trigger frames and respond with file data.
### Step 3.2 — Local file reader ✅
- [x] Create `src/main/agents/file-reader.ts`:
- `readDirectory(paths: string[], extensions: string[]): AsyncGenerator<FileData>` — recursively reads configured directories, filters by extension
- `preProcess(filePath: string): { name, content, metadata }`:
- `.txt`, `.md`, `.eml` — read as text
- `.pdf` — text extraction (dep: `pdf-parse`)
- `.docx` — text extraction (dep: `mammoth`)
- `.csv`, `.json` — read as structured text
- Binary files: skip with warning
- Respects path boundaries (no symlink escape, no `..` traversal)
- Chunks large files (>50KB) to stay within LLM context limits
- Returns `{ path, name, content, metadata: { size, mtime, extension } }`
- [x] Update `BackendClient.handleAgentRun()` to call `readAgentFiles()` and return `{ files, errors, filesRead }`
- **Files:** `src/main/agents/file-reader.ts`, `src/main/api/backend-client.ts`, `package.json` (`pdf-parse`, `mammoth` added)
- **Dependencies:** `pdf-parse`, `mammoth`
- **Outcome:** Electron can safely read + pre-process local files for AI analysis.
### Step 3.3 — Device ID management ✅
- [x] Update `src/main/store.ts`: add `deviceId: string` (UUID generated once on first launch and persisted)
- [x] Add `getDeviceId()` helper — lazily generates UUID v4 on first call, persists it; subsequent calls return the same value
- [x] Add `settings.deviceId` tRPC query to `settingsRouter` — renderer can read the device ID; Step 3.4 (agent router) injects it into local agent config creation calls to the backend
- [x] Electron sends `deviceId` when creating local agent configs → backend stores it (Step 3.4)
- [x] When backend triggers a local agent run, it checks `config.device_id` matches the connected Electron's `deviceId` (Step 3.5)
- **Files:** `src/main/store.ts`, `src/main/router/index.ts`
- **Outcome:** Local agents are device-bound. Only triggered on the correct machine.
### Step 3.4 — Agent tRPC router ✅
- [x] Add `agentRouter` to `src/main/router/index.ts`:
- `agent.catalog` — query: proxy to backend `GET /api/v1/agents/catalog`
- `agent.local.list` / `agent.local.create` / `agent.local.update` / `agent.local.delete` — proxy to backend with `deviceId` injected
- `agent.cloud.list` / `agent.cloud.create` / `agent.cloud.update` / `agent.cloud.delete` — proxy to backend
- `agent.runs` — query: proxy to backend run log
- `agent.runNow` — mutation: proxy to backend manual trigger
- `agent.journey.start` / `agent.journey.message` — proxy chatbot journey endpoints
- All proxy calls include JWT from AuthManager + snake_case/camelCase conversion
- [x] Also added response schemas to `src/shared/api-types.ts`: `AgentCatalogItemSchema`, `LocalAgentConfigSchema`, `CloudAgentConfigSchema`, `AgentRunLogSchema`, `JourneyMessageSchema`
- [x] Added `proxyGet/proxyPost/proxyPut/proxyDelete` methods to `BackendClient` (authenticated, casing-converted HTTP proxies)
- **Files:** `src/main/router/index.ts`, `src/shared/api-types.ts`, `src/main/api/backend-client.ts`
- **Outcome:** Renderer can manage agents through tRPC — all requests proxied to backend.
### Step 3.5 — Persistent WS connection for agent triggers ✅
- [x] Update `src/main/api/backend-client.ts`:
- `connectPersistent()` — opens persistent WS to `/api/v1/ws/device?token=<jwt>` on app start
- On connect: sends `device_hello` frame with `deviceId` and active agent IDs
- Handles incoming `agent_run` frames → dispatches to file reader → sends `agent_data` back
- Handles `tool_call` frames for DB inserts (same as chat WS)
- `handleAgentRunAndSend()` — validates device ID, calls `handleAgentRun()`, sends `agent_data` + `agent_complete` frames
- Auto-reconnects on disconnect with exponential backoff (1s → 2s → 4s → 8s → 16s → 30s cap)
- Heartbeat WS-level ping every 30s; pong/message timeout triggers force-reconnect
- `disconnectPersistent()` — disables reconnect, clears timers, closes WS cleanly
- [x] Call `connectPersistent()` from `src/main/index.ts` after auth check on app startup
- [x] `will-quit` handler in `src/main/index.ts` calls `disconnectPersistent()` for clean exit
- [x] `authRouter.login` calls `connectPersistent()` on success
- [x] `authRouter.logout` calls `disconnectPersistent()`
- [x] Device ID validation in `handleAgentRunAndSend()` (completes Step 3.3 final checkbox)
### Step 3.6 — Agent Library page ✅
- [x] Created `src/renderer/routes/settings.tsx`:
- Settings page with 2-column layout (left nav: General, Account, Agents, Appearance)
- Agents section is the agent library — catalog grid + my agents list with status indicators
- Settings icon in sidebar navigates to `/settings` (replaced dropdown)
- `validateSearch` for deep-link to specific section (e.g. `?section=account`)
- [x] Added route to `src/renderer/routeTree.gen.ts`
- [x] Updated sidebar nav in `src/renderer/components/layout/AppShell.tsx` (Settings is now a link)
### Step 3.7 — Agent config dialogs ✅
- [x] `LocalAgentConfigPanel` component (inline, inside expanded agent row in Settings → Agents):
- Native `dialog.showOpenDialog` directory picker (via new `dialog:showOpenDialog` IPC + `window.electronDialog` bridge)
- File extension filter (preset groups + custom)
- Data type selector (checkboxes: tasks, notes, checkpoints, projects)
- Schedule picker (preset: every 15min, hourly, 6h, daily, manual)
- "Customize AI Prompt" button → opens Chatbot Journey dialog
- [x] `CloudAgentConfigPanel` component (inline, inside expanded agent row):
- Provider badge + OAuth placeholder note
- Data type selector + schedule picker
- "Customize AI Prompt" button
- [x] `AddAgentDialog` for creating new agents from the catalog
- [x] Added `dialog:showOpenDialog` IPC handler in `src/main/index.ts` + `window.electronDialog` exposed in `src/preload/trpc.ts` + type declared in `src/renderer/lib/ipcLink.ts`
- **Files:** `src/renderer/routes/settings.tsx`, `src/main/index.ts`, `src/preload/trpc.ts`, `src/renderer/lib/ipcLink.ts`
- **Outcome:** Users can fully configure local and cloud agents from the Settings → Agents section.
### Step 3.8 — Chatbot Journey page ✅
- [x] `JourneyDialog` component in `src/renderer/routes/settings.tsx`:
- Dialog with spring-animated chat interface (message list, input, send button)
- Starts via `agent.journey.start` (passes `agentType` + optional `agentId`) on mount
- Multi-turn via `agent.journey.message` tRPC calls
- Shows generated prompt preview when `done === true` / `promptTemplate` present
- "Save & apply" button: saves promptTemplate to agent via `agent.local.update` / `agent.cloud.update`
- Works in both Create flow (from `AddAgentDialog`) and Edit flow (from expanded agent row)
- **Files:** `src/renderer/routes/settings.tsx`
- **Outcome:** Users configure AI prompts through a guided conversation, directly inside agent config.
### Step 3.9 — Agent run logs UI
- [ ] Create `src/renderer/components/agents/AgentRunLog.tsx`:
- Per-agent run history: timestamp, status badge, items processed/created, errors
- Collapsible section in agent detail view
- Data via `agent.runs` tRPC query
- **Files:** `src/renderer/components/agents/AgentRunLog.tsx`
- **Outcome:** Users see history and status of each agent's runs.
---

View File

@@ -15,12 +15,8 @@ import { execSync } from 'node:child_process';
// 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',
'ws',
'electron-squirrel-startup',
'electron-store',
];
@@ -108,8 +104,7 @@ const config: ForgeConfig = {
}
// 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.
// 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[] = [];
@@ -137,26 +132,6 @@ const config: ForgeConfig = {
}
}
// @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 ──────

970
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -49,12 +49,7 @@
},
"dependencies": {
"@fontsource/geist": "^5.2.8",
"@github/copilot-sdk": "^0.1.25",
"@hello-pangea/dnd": "^18.0.1",
"@langchain/anthropic": "^1.3.19",
"@langchain/core": "^1.1.27",
"@langchain/langgraph": "^1.1.5",
"@langchain/openai": "^1.2.9",
"@milkdown/crepe": "^7.18.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.0",
@@ -73,6 +68,8 @@
"electron-store": "^8.2.0",
"framer-motion": "^12.34.2",
"lucide-react": "^0.575.0",
"mammoth": "^1.11.0",
"pdf-parse": "^2.4.5",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.13.2",

95
skills-lock.json Normal file
View File

@@ -0,0 +1,95 @@
{
"version": 1,
"skills": {
"adapt": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "cc2a4e3b438553622819d2e02b996e67c6685a93de8974e3b4189c14a5414237"
},
"animate": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "d9678700cc105ae1af98763fb4f85168ba398395d241aae336ef421836d9dd82"
},
"audit": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "236c22b549d288ba400c73e13964043a66e88daeec8b31e12e91fcd239afd89e"
},
"bolder": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "210d2d6c071512370613d3816ee58b256b0c5ea69f74b1363584059a8b2d3669"
},
"clarify": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "848b37fa3e6445008d9a521930a539a9cf9bac254c0419f80f2757010d4d46db"
},
"colorize": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "c6ad8d7ae05771fee9232b72a16a42180e0e111c7372795f9009bf1cc52ccb95"
},
"critique": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "8054649a24e0b92e5041c15a300026761c9dece11899dd5021fa246dff7b93c5"
},
"delight": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "629c6efc3d12ab63bdadcf6674aa2a56f12848242c619f00b8f54f0f468bf928"
},
"distill": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "4fb24ad0ca7e75497cc25d98cf1de636a1acbbe0da919c75daab846ec63ba305"
},
"extract": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "b01b8be227a2a094e4b65f340c5a767bdbfd27d8bf2ccee4f75c203e44a5a592"
},
"frontend-design": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "5316f00e012e2d4a81f996adda009b6862a6684af4f6eef3f46a69f456121c6a"
},
"harden": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "69cecc4f28fb2f140652669bd45cc492744ad5c3c45083f5e0be5b0a5ef8acd0"
},
"normalize": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "a2d2c28085b46b1b0a97aa4133a4df5fec9cf77a7c3cef275b901065e11bc080"
},
"onboard": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "a6d7f5c09cb2828e622fbb8fa1467bc70b041519ffed2198d9de627325d97b66"
},
"optimize": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "278796e7d0b6febea9a53705f741f47d473cc4725684a5e0bd75fd4a99791b1c"
},
"polish": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "72c2426ac9540884eb5032bb438324bb344d0e2dd2b80f300a9fb49fe38d624e"
},
"quieter": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "139b3e3c9d43b79b4e6ccffaeb874265bc916d7d90662950e9f05581d6e0c06a"
},
"teach-impeccable": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "61855594bf79bc5b2665cfca3580b93840cfe645872c5a2deeaa5ad336570d5a"
}
}
}

View File

@@ -0,0 +1,382 @@
/**
* Local file reader for the directory agent (Phase 3, Step 3.2).
*
* Reads files from user-configured paths, extracts text content, and returns
* structured FileData objects ready to be sent back to the backend via the
* `agent_data` WS frame.
*
* Security guarantees:
* - All paths are resolved via `fs.realpath()` before any I/O (follows
* symlinks to their real targets).
* - Every resolved file path is checked against the user's configured roots —
* symlink escapes and `..` traversals are silently rejected.
* - Extension allowlist: only files whose extension appears in the config are
* walked or read.
* - 10 MB per-file size cap: oversized files produce an error entry.
* - No shell commands — only the Node.js `fs` API.
*
* @see AI_REFACTOR_PLAN.md — Phase 3, Step 3.2
*/
import * as fs from 'fs';
import * as path from 'path';
import { PDFParse } from 'pdf-parse';
import mammoth from 'mammoth';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Files larger than this are skipped with an error entry. */
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
/**
* Content larger than this per FileData entry is split across multiple entries
* (each ≤50 KB), split on the nearest preceding newline boundary.
*/
const CHUNK_SIZE_BYTES = 50 * 1024; // 50 KB
// Extension sets — used for content-extraction dispatch
const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.eml', '.csv', '.json']);
const PDF_EXTENSIONS = new Set(['.pdf']);
const DOCX_EXTENSIONS = new Set(['.docx']);
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface AgentRunConfig {
/** Absolute directory (or file) paths the agent is allowed to read. */
paths: string[];
/**
* Allowlist of file extensions (e.g. `[".pdf", ".md"]`).
* An empty array means no files will be read.
*/
fileExtensions: string[];
}
/** Mirrors the `files[n]` element of `WsAgentData` from `api-types.ts`. */
export interface FileData {
path: string;
name: string;
content: string;
metadata?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Read all files from the agent's configured paths, filtered by extension.
*
* @returns `files` — populated FileData objects (may be chunked for large
* files), and `errors` — human-readable strings for every I/O or
* extraction failure.
*/
export async function readAgentFiles(
config: AgentRunConfig,
): Promise<{ files: FileData[]; errors: string[] }> {
const extensions = buildExtensionSet(config.fileExtensions);
if (extensions.size === 0) {
return { files: [], errors: ['No file extensions configured — nothing to read.'] };
}
// Resolve allowed root paths once (via realpath so symlinks are transparent).
const allowedRoots = await resolveRoots(config.paths);
const files: FileData[] = [];
const errors: string[] = [];
for (const configPath of config.paths) {
const normalised = path.resolve(configPath);
let realConfigPath: string;
try {
realConfigPath = await fs.promises.realpath(normalised);
} catch {
errors.push(`Path not accessible: ${configPath}`);
continue;
}
let stat: fs.Stats;
try {
stat = await fs.promises.stat(realConfigPath);
} catch (err) {
errors.push(
`Cannot stat ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
);
continue;
}
if (stat.isDirectory()) {
for await (const filePath of walkDirectory(realConfigPath, extensions, allowedRoots)) {
const results = await processFile(filePath, allowedRoots, errors);
files.push(...results);
}
} else if (stat.isFile()) {
const ext = path.extname(realConfigPath).toLowerCase();
if (extensions.has(ext)) {
const results = await processFile(realConfigPath, allowedRoots, errors);
files.push(...results);
}
}
// Anything else (devices, sockets, etc.) is silently skipped.
}
return { files, errors };
}
// ---------------------------------------------------------------------------
// Path security helpers
// ---------------------------------------------------------------------------
/**
* Resolve each configured path to its real on-disk absolute path, ignoring
* any that are unreachable.
*/
async function resolveRoots(paths: string[]): Promise<string[]> {
const resolved: string[] = [];
for (const p of paths) {
try {
const real = await fs.promises.realpath(path.resolve(p));
resolved.push(real);
} catch {
// Will be caught when we try to open the path later.
}
}
return resolved;
}
/**
* Returns `true` only if `resolvedPath` is equal to one of `allowedRoots` or
* is a strict descendant of one. Prevents directory-traversal and
* symlink-escape attacks.
*/
function isPathWithinRoots(resolvedPath: string, allowedRoots: string[]): boolean {
return allowedRoots.some(
(root) => resolvedPath === root || resolvedPath.startsWith(root + path.sep),
);
}
// ---------------------------------------------------------------------------
// Directory walker
// ---------------------------------------------------------------------------
/**
* Recursively yields absolute real paths of files matching `extensions`
* inside `dirPath`, enforcing `allowedRoots` at every step.
*/
async function* walkDirectory(
dirPath: string,
extensions: Set<string>,
allowedRoots: string[],
): AsyncGenerator<string> {
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
} catch {
return; // Unreadable directory — skip silently.
}
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
// Resolve to real path so symlinks are checked against allowed roots.
let realPath: string;
try {
realPath = await fs.promises.realpath(entryPath);
} catch {
continue; // Broken symlink or inaccessible — skip.
}
// Security guard: real path must remain within an allowed root.
if (!isPathWithinRoots(realPath, allowedRoots)) {
continue; // Symlink escape — skip silently.
}
let stat: fs.Stats;
try {
stat = await fs.promises.stat(realPath); // Always follows symlinks.
} catch {
continue;
}
if (stat.isDirectory()) {
yield* walkDirectory(realPath, extensions, allowedRoots);
} else if (stat.isFile()) {
if (extensions.has(path.extname(entry.name).toLowerCase())) {
yield realPath;
}
}
}
}
// ---------------------------------------------------------------------------
// Per-file processing
// ---------------------------------------------------------------------------
/**
* Resolve, safety-check, size-check, extract, and (if needed) chunk a single
* file. Any non-fatal problem is recorded in `errors`; on failure the
* returned array is empty.
*/
async function processFile(
filePath: string,
allowedRoots: string[],
errors: string[],
): Promise<FileData[]> {
// Re-resolve defensively (callers may pass already-real paths, but let's
// be certain regardless).
let realPath: string;
try {
realPath = await fs.promises.realpath(filePath);
} catch (err) {
errors.push(
`Cannot resolve path ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
if (!isPathWithinRoots(realPath, allowedRoots)) {
errors.push(`Path outside allowed roots, rejected: ${filePath}`);
return [];
}
let stat: fs.Stats;
try {
stat = await fs.promises.stat(realPath);
} catch (err) {
errors.push(
`Cannot stat ${realPath}: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
if (stat.size > MAX_FILE_SIZE_BYTES) {
errors.push(
`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB), skipping: ${realPath}`,
);
return [];
}
const ext = path.extname(realPath).toLowerCase();
const baseMetadata: Record<string, unknown> = {
size: stat.size,
mtime: stat.mtimeMs,
extension: ext,
};
let content: string;
try {
content = await extractContent(realPath, ext);
} catch (err) {
errors.push(
`Failed to extract content from ${realPath}: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
const name = path.basename(realPath);
const contentBytes = Buffer.byteLength(content, 'utf8');
if (contentBytes <= CHUNK_SIZE_BYTES) {
return [{ path: realPath, name, content, metadata: baseMetadata }];
}
// Large content: split into ≤50 KB chunks on newline boundaries.
const chunks = chunkContent(content, CHUNK_SIZE_BYTES);
return chunks.map((chunk, idx) => ({
path: realPath,
name,
content: chunk,
metadata: { ...baseMetadata, chunk: idx, totalChunks: chunks.length },
}));
}
// ---------------------------------------------------------------------------
// Content extraction by file type
// ---------------------------------------------------------------------------
async function extractContent(filePath: string, ext: string): Promise<string> {
if (TEXT_EXTENSIONS.has(ext)) {
return fs.promises.readFile(filePath, 'utf8');
}
if (PDF_EXTENSIONS.has(ext)) {
const buffer = await fs.promises.readFile(filePath);
const parser = new PDFParse({ data: new Uint8Array(buffer) });
const result = await parser.getText();
return result.text;
}
if (DOCX_EXTENSIONS.has(ext)) {
const result = await mammoth.extractRawText({ path: filePath });
return result.value;
}
throw new Error(`Unsupported file extension: ${ext}`);
}
// ---------------------------------------------------------------------------
// Chunking
// ---------------------------------------------------------------------------
/**
* Splits `content` into chunks where each chunk's UTF-8 byte length is at
* most `maxBytes`. Chunk boundaries are snapped back to the last newline
* within the byte limit (if one exists), so chunks tend to end on complete
* lines.
*/
export function chunkContent(content: string, maxBytes: number = CHUNK_SIZE_BYTES): string[] {
const chunks: string[] = [];
let start = 0;
while (start < content.length) {
let byteCount = 0;
let end = start;
// Advance `end` one code point at a time until we exceed `maxBytes`.
while (end < content.length) {
const code = content.charCodeAt(end);
const charBytes =
code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0xd800 || code > 0xdfff ? 3 : 2;
// Surrogate pair: the paired low surrogate will be counted separately.
if (byteCount + charBytes > maxBytes) break;
byteCount += charBytes;
end++;
}
// Edge case: a single character exceeds the limit (shouldn't happen for
// normal UTF-8 text, but guard against infinite loops).
if (end === start) {
end = start + 1;
}
// Snap back to the last newline so chunks end on complete lines.
if (end < content.length) {
const nlIdx = content.lastIndexOf('\n', end - 1);
if (nlIdx > start) {
end = nlIdx + 1; // Include the newline character in this chunk.
}
}
chunks.push(content.slice(start, end));
start = end;
}
return chunks;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildExtensionSet(fileExtensions: string[]): Set<string> {
return new Set(
fileExtensions.map((ext) => {
const lower = ext.toLowerCase();
return lower.startsWith('.') ? lower : `.${lower}`;
}),
);
}

View File

@@ -1,230 +0,0 @@
/**
* ChatCopilot — LangChain-compatible ChatModel adapter for the GitHub Copilot SDK.
*
* Wraps the CopilotClient's session API so it can be used as a drop-in
* BaseChatModel within LangGraph, making the orchestrator provider-agnostic.
*
* Accepts a client-getter function to avoid module duplication issues when
* this file is code-split into a separate chunk by Vite.
*/
import { SimpleChatModel, type BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
import type { BaseMessage } from '@langchain/core/messages';
import { AIMessageChunk } from '@langchain/core/messages';
import { ChatGenerationChunk } from '@langchain/core/outputs';
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import type { StructuredTool } from '@langchain/core/tools';
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
/** Minimal shape of a Copilot SDK Tool (avoids importing the full SDK type) */
type CopilotNativeTool = {
name: string;
description?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parameters?: any;
handler: (args: unknown) => Promise<unknown>;
};
const COPILOT_TIMEOUT = 120_000;
export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
private getClient: () => CopilotClientType | null;
/** Native Copilot SDK tools, populated by bindTools() */
private _copilotTools: CopilotNativeTool[] = [];
constructor(getClient: () => CopilotClientType | null, tools: CopilotNativeTool[] = []) {
super({});
this.getClient = getClient;
this._copilotTools = tools;
}
_llmType(): string {
return 'copilot';
}
private requireClient(): CopilotClientType {
const client = this.getClient();
if (!client) {
throw new Error('CopilotClient not initialized. Please check that Copilot CLI is authenticated (copilot auth login).');
}
return client;
}
/**
* Convert LangChain StructuredTools to Copilot SDK native tools and return a
* new ChatCopilot instance that will pass them to createSession().
* The SDK handles the full tool-calling loop internally — no LangChain ToolMessages needed.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override bindTools(tools: StructuredTool[]): any {
const copilotTools: CopilotNativeTool[] = tools.map((t) => ({
name: t.name,
description: t.description ?? undefined,
parameters: t.schema,
handler: async (args: unknown) => {
console.log(`[ChatCopilot] tool handler called: ${t.name}`, JSON.stringify(args));
const result = await t.invoke(args as Record<string, unknown>);
const output = typeof result === 'string' ? result : JSON.stringify(result);
console.log(`[ChatCopilot] tool handler result: ${t.name}`, output.slice(0, 200));
return output;
},
}));
console.log(`[ChatCopilot] bindTools() called with:`, copilotTools.map((t) => t.name));
return new ChatCopilot(this.getClient, copilotTools);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
const client = this.requireClient();
// Extract system message and user prompt from LangChain messages
const systemContent = messages
.filter((m) => m._getType() === 'system')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const userContent = messages
.filter((m) => m._getType() === 'human')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const hasTools = this._copilotTools.length > 0;
const session = await client.createSession({
// When tools are registered, use append mode so the SDK can inject its tool-calling
// instructions before our content. mode:'replace' strips those SDK-managed sections,
// causing the model to never see/call registered tools.
systemMessage: systemContent
? hasTools
? { content: systemContent }
: { mode: 'replace', content: systemContent }
: undefined,
// Pass native tools when available — SDK handles the agentic tool-calling loop
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
streaming: false,
});
try {
const result = await session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
return result?.data.content ?? '';
} finally {
await session.destroy().catch(() => { /* ignore cleanup errors */ });
}
}
async *_streamResponseChunks(
messages: BaseMessage[],
_options: this['ParsedCallOptions'],
_runManager?: CallbackManagerForLLMRun,
): AsyncGenerator<ChatGenerationChunk> {
const client = this.requireClient();
const systemContent = messages
.filter((m) => m._getType() === 'system')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const userContent = messages
.filter((m) => m._getType() === 'human')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const hasTools = this._copilotTools.length > 0;
console.log(`[ChatCopilot] _streamResponseChunks: hasTools=${hasTools}, tools=[${this._copilotTools.map((t) => t.name).join(', ')}]`);
console.log(`[ChatCopilot] systemMessage mode: ${hasTools ? 'append' : 'replace'}`);
const session = await client.createSession({
// Same append-vs-replace logic as _call: tools require append mode so the SDK
// can inject its tool-calling instructions before our project context.
systemMessage: systemContent
? hasTools
? { content: systemContent }
: { mode: 'replace', content: systemContent }
: undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
streaming: true,
});
console.log(`[ChatCopilot] session created: ${session.sessionId}`);
// Buffer chunks via event listener and yield them
const chunks: string[] = [];
let done = false;
let sessionError: Error | null = null;
let resolveNext: (() => void) | null = null;
const unsubDelta = session.on('assistant.message_delta', (event) => {
const delta = event.data.deltaContent;
if (delta) {
chunks.push(delta);
resolveNext?.();
}
});
const unsubEnd = session.on('session.idle', () => {
console.log('[ChatCopilot] session.idle received');
done = true;
resolveNext?.();
});
const unsubError = session.on('session.error', (event) => {
console.error('[ChatCopilot] session.error received:', event.data.message);
sessionError = new Error(event.data.message);
done = true;
resolveNext?.();
});
// Log all events to understand SDK behaviour with tools
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unsubAll = session.on((event: any) => {
if (!['assistant.message_delta'].includes(event.type)) {
console.log(`[ChatCopilot] SDK event: ${event.type}`, JSON.stringify(event.data ?? {}).slice(0, 300));
}
});
// Fire the request (don't await — we'll drain via events).
const sendPromise = session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
// If sendAndWait rejects before any session events fire (e.g. send() throws
// internally due to a listModels/auth failure), wake up the while loop so it
// doesn't hang waiting for session.idle that will never arrive.
sendPromise.catch((err: unknown) => {
if (!done) {
sessionError = err instanceof Error ? err : new Error(String(err));
done = true;
resolveNext?.();
}
});
try {
while (!done || chunks.length > 0) {
if (chunks.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const text = chunks.shift()!;
const chunk = new ChatGenerationChunk({
message: new AIMessageChunk({ content: text }),
text,
});
await _runManager?.handleLLMNewToken(text);
yield chunk;
} else if (!done) {
await new Promise<void>((resolve) => {
resolveNext = resolve;
});
}
}
// Propagate any error surfaced via session.error event or sendAndWait rejection
if (sessionError) throw sessionError;
} finally {
unsubDelta();
unsubEnd();
unsubError();
unsubAll();
await session.destroy().catch(() => { /* ignore cleanup errors */ });
}
}
}

View File

@@ -1,61 +0,0 @@
import { app } from 'electron';
import { registerProvider, type AIProvider } from './provider';
// Dynamic import type — @github/copilot-sdk is ESM-only
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
let client: CopilotClientType | null = null;
let isReady = false;
const copilotProvider: AIProvider = {
name: 'copilot',
displayName: 'GitHub Copilot',
usesExternalAuth: true,
async initialize(): Promise<boolean> {
try {
// Stop existing client if re-initializing
if (client) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
await client.stop().catch(() => {});
client = null;
}
const { CopilotClient } = await import('@github/copilot-sdk');
// No githubToken — uses stored OAuth credentials from Copilot CLI
// (authenticate first with `copilot auth login`)
client = new CopilotClient({
autoStart: true,
autoRestart: true,
logLevel: 'warning',
});
await client.start();
isReady = true;
console.log('[AI] CopilotClient started (using CLI OAuth credentials)');
return true;
} catch (err) {
console.error('[AI] Failed to start CopilotClient:', err);
client = null;
isReady = false;
return false;
}
},
isReady(): boolean {
return isReady && client !== null;
},
};
/** Get the CopilotClient instance (null if not initialized). */
export function getCopilotClient(): CopilotClientType | null {
return client;
}
// Clean shutdown on app quit
app.on('before-quit', () => {
if (client) {
client.stop().catch((err: unknown) => console.error('[AI] Error stopping CopilotClient:', err));
}
});
registerProvider(copilotProvider);

View File

@@ -1,81 +0,0 @@
/**
* LLM connector factory — returns a LangChain BaseChatModel for the active provider.
*
* The agent orchestration (LangGraph) is provider-independent. This module is
* the only place that knows how to create provider-specific LLM instances.
*/
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { getActiveProviderName, getActiveProvider } from './provider';
import { getToken } from './token';
import { getCopilotClient } from './copilot';
// ---------------------------------------------------------------------------
// Provider-specific factory functions (lazy-loaded)
// ---------------------------------------------------------------------------
async function createOpenAIModel(token: string): Promise<BaseChatModel> {
const { ChatOpenAI } = await import('@langchain/openai');
return new ChatOpenAI({
apiKey: token,
model: 'gpt-4o-mini',
temperature: 0.3,
streaming: true,
});
}
async function createAnthropicModel(token: string): Promise<BaseChatModel> {
const { ChatAnthropic } = await import('@langchain/anthropic');
return new ChatAnthropic({
apiKey: token,
model: 'claude-sonnet-4-20250514',
temperature: 0.3,
streaming: true,
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function createCopilotModel(_token: string): Promise<BaseChatModel> {
// GitHub Copilot uses the Copilot SDK subprocess for auth and API access.
// We wrap it in a LangChain-compatible adapter.
// Pass getCopilotClient from this chunk (same as copilot.ts) to avoid
// module duplication when chat-copilot.ts is code-split by Vite.
const { ChatCopilot } = await import('./chat-copilot');
return new ChatCopilot(getCopilotClient);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
const MODEL_FACTORIES: Record<string, (token: string) => Promise<BaseChatModel>> = {
openai: createOpenAIModel,
anthropic: createAnthropicModel,
copilot: createCopilotModel,
};
/**
* Get a LangChain BaseChatModel for the currently active AI provider.
* Returns null if no provider is configured or no token is available.
*/
export async function getLLM(): Promise<BaseChatModel | null> {
const providerName = getActiveProviderName();
const factory = MODEL_FACTORIES[providerName];
if (!factory) {
console.log(`[AI] No LLM factory for provider "${providerName}"`);
return null;
}
const provider = getActiveProvider();
const token = provider?.usesExternalAuth ? '' : await getToken(providerName);
if (!provider?.usesExternalAuth && !token) {
console.log(`[AI] No token available for provider "${providerName}"`);
return null;
}
try {
return await factory(token ?? '');
} catch (err) {
console.error(`[AI] Failed to create LLM for "${providerName}":`, err);
return null;
}
}

View File

@@ -1,93 +0,0 @@
import { getStore } from '../store';
import { getToken, setToken as storeToken } from './token';
export interface AIProvider {
/** Internal key, e.g. 'copilot', 'openai', 'anthropic' */
name: string;
/** Human-readable label shown in Settings UI */
displayName: string;
/** Initialize with a token. Returns true if the provider is ready. */
initialize(token: string): Promise<boolean>;
/** Whether the provider is initialized and ready to handle requests. */
isReady(): boolean;
/** If true, this provider uses external auth (e.g. CLI OAuth) and doesn't need a stored token. */
usesExternalAuth?: boolean;
}
const providers = new Map<string, AIProvider>();
let activeProvider: AIProvider | null = null;
/** Register a provider implementation. Call at import time. */
export function registerProvider(provider: AIProvider): void {
providers.set(provider.name, provider);
}
/** Get the currently active provider (may be null if none configured). */
export function getActiveProvider(): AIProvider | null {
return activeProvider;
}
/** Get the active provider's name from electron-store. */
export function getActiveProviderName(): string {
return getStore().get('aiProvider');
}
/** Switch to a different registered provider. */
function setActiveProviderName(name: string): void {
const provider = providers.get(name);
if (!provider) throw new Error(`Unknown AI provider: ${name}`);
activeProvider = provider;
getStore().set('aiProvider', name);
}
/** Store token for the active provider and re-initialize it. */
export async function saveTokenAndInit(token: string): Promise<void> {
const name = getActiveProviderName();
await storeToken(name, token);
const provider = providers.get(name);
if (provider) {
await provider.initialize(token);
activeProvider = provider;
}
}
/** Check whether the active provider has credentials (stored token or external auth). */
export async function hasActiveToken(): Promise<boolean> {
const name = getActiveProviderName();
const provider = providers.get(name);
// Providers with external auth (e.g. Copilot CLI OAuth) don't need a stored token
if (provider?.usesExternalAuth) return true;
const token = await getToken(name);
return token !== null && token.length > 0;
}
/**
* Initialize the AI subsystem on app startup.
* Reads the active provider from settings, loads its token from keychain,
* and calls provider.initialize() if a token exists.
*/
export async function initAI(): Promise<void> {
const name = getActiveProviderName();
const provider = providers.get(name);
if (!provider) {
console.log(`[AI] No provider registered for "${name}"`);
return;
}
// Providers with external auth (e.g. Copilot CLI OAuth) initialize without a stored token
if (provider.usesExternalAuth) {
const ready = await provider.initialize('');
activeProvider = provider;
console.log(`[AI] Provider "${provider.displayName}" initialized (external auth): ready=${ready}`);
return;
}
const token = await getToken(name);
if (token) {
const ready = await provider.initialize(token);
activeProvider = provider;
console.log(`[AI] Provider "${provider.displayName}" initialized: ready=${ready}`);
} else {
console.log(`[AI] No token stored for provider "${provider.displayName}"`);
}
}

View File

@@ -18,15 +18,23 @@
*/
import WebSocket from 'ws';
import { getStore } from '../store';
import { getStore, getDeviceId } from '../store';
import { getAuthManager } from '../auth/auth-manager';
import { toSnakeCase, toCamelCase } from '../../shared/casing';
import {
WsServerFrameSchema,
ChatResponseSchema,
} from '../../shared/api-types';
import type { ChatRequest, ChatResponse, WsToolResult } from '../../shared/api-types';
import type {
ChatRequest,
ChatResponse,
WsToolResult,
WsAgentRun,
WsAgentData,
LocalAgentConfig,
} from '../../shared/api-types';
import { DrizzleExecutor } from './drizzle-executor';
import { readAgentFiles } from '../agents/file-reader';
// ---------------------------------------------------------------------------
// Constants
@@ -39,6 +47,14 @@ const MAX_RETRIES = 3;
const RETRY_BASE_MS = 500;
/** Maximum iterations the backend may request before we force-close. */
const MAX_TOOL_ITERATIONS = 10;
/** Interval between client-side heartbeat pings on the persistent device WS. */
const HEARTBEAT_INTERVAL_MS = 30_000;
/** Time to wait for any response after a ping before treating the connection as dead. */
const PONG_TIMEOUT_MS = 10_000;
/** Base reconnect delay for the persistent device WS (doubles each attempt). */
const RECONNECT_BASE_MS = 1_000;
/** Maximum reconnect delay cap (ms). */
const RECONNECT_MAX_MS = 30_000;
// ---------------------------------------------------------------------------
// Error types
@@ -83,6 +99,15 @@ export class BackendClient {
private static instance: BackendClient | null = null;
private executor = new DrizzleExecutor();
// Persistent device WebSocket state (Step 3.5)
private persistentWs: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private pongTimer: ReturnType<typeof setTimeout> | null = null;
private isConnecting = false;
private shouldReconnect = false;
private reconnectAttempt = 0;
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
@@ -293,6 +318,388 @@ export class BackendClient {
throw new Error(msg);
}
// -------------------------------------------------------------------------
// Generic authenticated HTTP proxies (Phase 3, Step 3.4)
// -------------------------------------------------------------------------
/** Authenticated GET — response is camelCased. */
async proxyGet<T>(path: string): Promise<T> {
return this.withRetry(
async () => {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
await this.assertHttpOk(res);
return toCamelCase<T>(await res.json());
},
(err) => !(err instanceof AuthExpiredError),
);
}
/** Authenticated POST — body is snake_cased, response is camelCased. */
async proxyPost<T>(path: string, body: Record<string, unknown>): Promise<T> {
return this.withRetry(
async () => {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(toSnakeCase(body)),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
await this.assertHttpOk(res);
return toCamelCase<T>(await res.json());
},
(err) => !(err instanceof AuthExpiredError),
);
}
/** Authenticated PUT — body is snake_cased, response is camelCased. */
async proxyPut<T>(path: string, body: Record<string, unknown>): Promise<T> {
return this.withRetry(
async () => {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(toSnakeCase(body)),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
await this.assertHttpOk(res);
return toCamelCase<T>(await res.json());
},
(err) => !(err instanceof AuthExpiredError),
);
}
/** Authenticated DELETE — response is camelCased. */
async proxyDelete<T>(path: string): Promise<T> {
return this.withRetry(
async () => {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
await this.assertHttpOk(res);
return toCamelCase<T>(await res.json());
},
(err) => !(err instanceof AuthExpiredError),
);
}
// -------------------------------------------------------------------------
// Persistent device WebSocket (Step 3.5)
// -------------------------------------------------------------------------
/**
* Opens and maintains a persistent WebSocket connection to the backend
* device endpoint (`/api/v1/ws/device`). On connect, sends a
* `device_hello` frame identifying this machine and its active local
* agents. Handles `agent_run` and `tool_call` frames; auto-reconnects
* with exponential backoff; sends WS-level heartbeat pings every 30 s.
*
* Safe to call multiple times — a no-op if already connected or
* connecting. Call once after confirming authentication on app startup,
* and again after a successful login.
*/
async connectPersistent(): Promise<void> {
if (this.isConnecting || this.persistentWs?.readyState === WebSocket.OPEN) {
return;
}
this.shouldReconnect = true;
await this.openDeviceWebSocket();
}
/**
* Gracefully closes the persistent device WebSocket and disables
* auto-reconnect. Call on logout or app quit.
*/
disconnectPersistent(): void {
this.shouldReconnect = false;
this.reconnectAttempt = 0;
this.stopHeartbeat();
this.clearReconnectTimer();
if (this.persistentWs) {
try { this.persistentWs.close(); } catch { /* ignore */ }
this.persistentWs = null;
}
console.log('[DeviceWS] Disconnected.');
}
private async openDeviceWebSocket(): Promise<void> {
if (this.isConnecting) return;
this.isConnecting = true;
let token: string | null;
try {
token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
} catch (err) {
this.isConnecting = false;
if (err instanceof AuthExpiredError) {
console.warn('[DeviceWS] No auth token — skipping persistent connection.');
this.shouldReconnect = false;
return;
}
this.scheduleReconnect();
return;
}
const wsUrl = `${this.wsBaseUrl}/api/v1/ws/device?token=${encodeURIComponent(token)}`;
console.log('[DeviceWS] Connecting…');
const ws = new WebSocket(wsUrl);
this.persistentWs = ws;
ws.on('open', async () => {
this.isConnecting = false;
this.reconnectAttempt = 0;
console.log('[DeviceWS] Connected.');
// Fetch enabled local agent IDs bound to this device
const deviceId = getDeviceId();
let agentIds: string[] = [];
try {
const agents = await this.proxyGet<LocalAgentConfig[]>('/api/v1/agents/local');
agentIds = agents
.filter((a) => a.enabled && a.deviceId === deviceId)
.map((a) => a.id);
} catch {
// Non-fatal — send hello with empty list
}
ws.send(JSON.stringify(toSnakeCase({ type: 'device_hello', deviceId, agentIds })));
console.log(`[DeviceWS] Sent device_hello (deviceId=${deviceId}, agents=${agentIds.length}).`);
this.startHeartbeat(ws);
});
ws.on('message', (raw: Buffer | string) => {
const text = typeof raw === 'string' ? raw : raw.toString('utf8');
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return;
}
const frame = WsServerFrameSchema.safeParse(toCamelCase(parsed));
if (!frame.success) return;
// Any incoming frame resets the pong timeout
this.clearPongTimer();
switch (frame.data.type) {
case 'agent_run':
void this.handleAgentRunAndSend(frame.data, ws);
break;
case 'tool_call': {
const toolCall = frame.data;
void (async () => {
let result: WsToolResult;
try {
const output = await this.executor.execute(toolCall);
result = { type: 'tool_result', id: toolCall.id, ...output } as WsToolResult;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Executor error';
result = { type: 'tool_result', id: toolCall.id, error: msg };
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(toSnakeCase(result)));
}
})();
break;
}
case 'ping':
// Server keep-alive — no-op
break;
case 'text_chunk':
case 'final':
// Chat-only frames — ignore on persistent device WS
break;
}
});
ws.on('pong', () => {
// WS-level pong — reset the timeout
this.clearPongTimer();
});
ws.on('error', (err: Error) => {
this.isConnecting = false;
console.error('[DeviceWS] Error:', err.message);
this.stopHeartbeat();
});
ws.on('close', (code: number) => {
this.isConnecting = false;
this.persistentWs = null;
this.stopHeartbeat();
console.log(`[DeviceWS] Connection closed (code ${code}).`);
if (this.shouldReconnect) {
this.scheduleReconnect();
}
});
}
/**
* Reads files for an `agent_run` frame and transmits the resulting
* `agent_data` and `agent_complete` frames back over the persistent WS.
*
* Validates device ID first — if the frame targets a different device,
* responds immediately with an error `agent_complete` frame and returns.
*/
private async handleAgentRunAndSend(frame: WsAgentRun, ws: WebSocket): Promise<void> {
const localDeviceId = getDeviceId();
// Device-binding check (Step 3.3, final checkbox): if the backend
// includes a target device ID in config, verify it matches this machine.
const targetDeviceId = (frame.config as unknown as { deviceId?: string }).deviceId;
if (targetDeviceId && targetDeviceId !== localDeviceId) {
console.warn(
`[DeviceWS] agent_run for deviceId=${targetDeviceId} ignored` +
` (this device=${localDeviceId}).`,
);
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify(
toSnakeCase({
type: 'agent_complete',
runId: frame.runId,
filesRead: 0,
errors: [`Device mismatch: expected ${targetDeviceId}, got ${localDeviceId}`],
}),
),
);
}
return;
}
const { files, errors, filesRead } = await this.handleAgentRun(frame);
if (!ws.readyState || ws.readyState !== WebSocket.OPEN) return;
// Send agent_data with all files (single frame — chunking deferred)
if (files.length > 0) {
ws.send(JSON.stringify(toSnakeCase({ type: 'agent_data', runId: frame.runId, files })));
}
// Send agent_complete
ws.send(
JSON.stringify(
toSnakeCase({ type: 'agent_complete', runId: frame.runId, filesRead, errors }),
),
);
console.log(
`[DeviceWS] agent_complete sent` +
` (runId=${frame.runId}, filesRead=${filesRead}, errors=${errors.length}).`,
);
}
private startHeartbeat(ws: WebSocket): void {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) {
this.stopHeartbeat();
return;
}
ws.ping();
// Arm a timeout — if no pong/message within PONG_TIMEOUT_MS, force reconnect
this.pongTimer = setTimeout(() => {
console.warn('[DeviceWS] Pong timeout — forcing reconnect.');
try { ws.terminate(); } catch { /* ignore */ }
}, PONG_TIMEOUT_MS);
}, HEARTBEAT_INTERVAL_MS);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
this.clearPongTimer();
}
private clearPongTimer(): void {
if (this.pongTimer !== null) {
clearTimeout(this.pongTimer);
this.pongTimer = null;
}
}
private scheduleReconnect(): void {
this.clearReconnectTimer();
const delay = Math.min(
RECONNECT_BASE_MS * 2 ** this.reconnectAttempt,
RECONNECT_MAX_MS,
);
this.reconnectAttempt += 1;
console.log(`[DeviceWS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})…`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.openDeviceWebSocket();
}, delay);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
// -------------------------------------------------------------------------
// Agent handler (Phase 3)
// -------------------------------------------------------------------------
/**
* Read files for an `agent_run` frame and return structured file data.
* Frame transmission is handled by `handleAgentRunAndSend()`.
*/
async handleAgentRun(
frame: WsAgentRun,
): Promise<{ files: WsAgentData['files']; errors: string[]; filesRead: number }> {
console.log(
`[Agent] Run requested: agentId=${frame.agentId} runId=${frame.runId}` +
` paths=${frame.config.paths.join(', ')}`,
);
const { files, errors } = await readAgentFiles({
paths: frame.config.paths,
fileExtensions: frame.config.fileExtensions,
});
if (errors.length > 0) {
console.warn(`[Agent] runId=${frame.runId} completed with ${errors.length} error(s):`, errors);
}
return { files, errors, filesRead: files.length };
}
// -------------------------------------------------------------------------
// Retry
// -------------------------------------------------------------------------

View File

@@ -1,13 +1,12 @@
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import path from 'node:path';
import started from 'electron-squirrel-startup';
import { initDb } from './db';
import { appRouter } from './router';
import { createIPCHandler } from './ipc';
import { initAI } from './ai/provider';
import { initVectorDb, migrateNotesIfNeeded } from './db/vectordb';
// Import to trigger provider registration before initAI() runs
import './ai/copilot';
import { getAuthManager } from './auth/auth-manager';
import { getBackendClient } from './api/backend-client';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
@@ -46,6 +45,13 @@ const createWindow = (): BrowserWindow => {
return mainWindow;
};
// ---------------------------------------------------------------------------
// Dialog IPC — file/folder picker
// ---------------------------------------------------------------------------
ipcMain.handle('dialog:showOpenDialog', (_event, options: Electron.OpenDialogOptions) =>
dialog.showOpenDialog(options),
);
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
@@ -53,12 +59,23 @@ app.on('ready', () => {
initDb();
const win = createWindow();
createIPCHandler({ router: appRouter, windows: [win] });
// AI init is best-effort — never block window creation
initAI().catch((err) => console.error('[AI] Init failed:', err));
// Vector DB init + migration is best-effort — runs after window is shown
initVectorDb()
.then(() => migrateNotesIfNeeded())
.catch((err) => console.error('[VectorDB] Init or migration failed:', err));
// Persistent device WebSocket for agent triggers — best-effort on startup
getAuthManager()
.isAuthenticated()
.then((authenticated) => {
if (authenticated) return getBackendClient().connectPersistent();
})
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
});
// Clean up the persistent WS before the app exits
app.on('will-quit', () => {
getBackendClient().disconnectPersistent();
});
// Quit when all windows are closed, except on macOS. There, it's common

View File

@@ -4,7 +4,9 @@ import { eq, asc, inArray, and, or, like, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/sqlite-core';
import { getDb } from '../db';
import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema';
import { getStore } from '../store';
import { getStore, getDeviceId } from '../store';
import { getBackendClient } from '../api/backend-client';
import type { AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog, JourneyMessage } from '../../shared/api-types';
import { orchestrate, dailyBrief } from '../ai/orchestrator';
import { upsertNoteEmbedding } from '../db/vectordb';
import { getAuthManager, AuthError } from '../auth/auth-manager';
@@ -543,6 +545,8 @@ const settingsRouter = router({
getStore().set('userName', input.name);
return null;
}),
/** Returns the stable device ID for this machine (UUID v4, persisted). */
deviceId: publicProcedure.query(() => getDeviceId()),
});
const aiRouter = router({
@@ -583,6 +587,242 @@ const aiRouter = router({
}),
});
// ---------------------------------------------------------------------------
// Agent router — proxy to backend agent management API
// ---------------------------------------------------------------------------
const agentLocalRouter = router({
list: publicProcedure.query(async () => {
try {
return await getBackendClient().proxyGet<LocalAgentConfig[]>('/api/v1/agents/local');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to list local agents';
console.error('[Agent] local.list error:', msg);
return [];
}
}),
create: publicProcedure
.input(z.object({
name: z.string(),
directoryPaths: z.array(z.string()),
dataTypes: z.array(z.string()),
fileExtensions: z.array(z.string()),
promptTemplate: z.string(),
scheduleCron: z.string(),
}))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<LocalAgentConfig>(
'/api/v1/agents/local',
{ ...input, deviceId: getDeviceId() },
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create local agent';
return { data: null, error: msg };
}
}),
update: publicProcedure
.input(z.object({
id: z.string(),
name: z.string().optional(),
directoryPaths: z.array(z.string()).optional(),
dataTypes: z.array(z.string()).optional(),
fileExtensions: z.array(z.string()).optional(),
promptTemplate: z.string().optional(),
scheduleCron: z.string().optional(),
enabled: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...updates } = input;
try {
const result = await getBackendClient().proxyPut<LocalAgentConfig>(
`/api/v1/agents/local/${id}`,
updates as Record<string, unknown>,
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update local agent';
return { data: null, error: msg };
}
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
try {
await getBackendClient().proxyDelete<{ ok: boolean }>(`/api/v1/agents/local/${input.id}`);
return { success: true as const, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to delete local agent';
return { success: false as const, error: msg };
}
}),
});
const agentCloudRouter = router({
list: publicProcedure.query(async () => {
try {
return await getBackendClient().proxyGet<CloudAgentConfig[]>('/api/v1/agents/cloud');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to list cloud agents';
console.error('[Agent] cloud.list error:', msg);
return [];
}
}),
create: publicProcedure
.input(z.object({
name: z.string(),
provider: z.enum(['gmail', 'teams', 'outlook']),
dataTypes: z.array(z.string()),
promptTemplate: z.string(),
scheduleCron: z.string(),
filterConfig: z.record(z.string(), z.unknown()).optional(),
}))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<CloudAgentConfig>(
'/api/v1/agents/cloud',
input as Record<string, unknown>,
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create cloud agent';
return { data: null, error: msg };
}
}),
update: publicProcedure
.input(z.object({
id: z.string(),
name: z.string().optional(),
dataTypes: z.array(z.string()).optional(),
promptTemplate: z.string().optional(),
scheduleCron: z.string().optional(),
filterConfig: z.record(z.string(), z.unknown()).optional(),
enabled: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...updates } = input;
try {
const result = await getBackendClient().proxyPut<CloudAgentConfig>(
`/api/v1/agents/cloud/${id}`,
updates as Record<string, unknown>,
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update cloud agent';
return { data: null, error: msg };
}
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
try {
await getBackendClient().proxyDelete<{ ok: boolean }>(`/api/v1/agents/cloud/${input.id}`);
return { success: true as const, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to delete cloud agent';
return { success: false as const, error: msg };
}
}),
});
const agentJourneyRouter = router({
start: publicProcedure
.input(z.object({
agentType: z.enum(['local', 'cloud']),
agentId: z.string().optional(),
}))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<JourneyMessage>(
'/api/v1/agents/journey/start',
input as Record<string, unknown>,
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to start journey';
return { data: null, error: msg };
}
}),
message: publicProcedure
.input(z.object({ sessionId: z.string(), message: z.string() }))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<JourneyMessage>(
'/api/v1/agents/journey/message',
input as Record<string, unknown>,
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to send journey message';
return { data: null, error: msg };
}
}),
});
const agentRouter = router({
/** Agent catalog — available agent types from the backend. */
catalog: publicProcedure.query(async () => {
try {
return await getBackendClient().proxyGet<AgentCatalogItem[]>('/api/v1/agents/catalog');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load catalog';
console.error('[Agent] catalog error:', msg);
return [];
}
}),
local: agentLocalRouter,
cloud: agentCloudRouter,
/** Run log — history for all agents or a specific agent. */
runs: publicProcedure
.input(z.object({
agentId: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(),
}).optional())
.query(async ({ input }) => {
try {
const params = new URLSearchParams();
if (input?.agentId) params.set('agent_id', input.agentId);
if (input?.limit !== undefined) params.set('limit', String(input.limit));
if (input?.offset !== undefined) params.set('offset', String(input.offset));
const qs = params.size > 0 ? `?${params.toString()}` : '';
return await getBackendClient().proxyGet<AgentRunLog[]>(`/api/v1/agents/runs${qs}`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load run logs';
console.error('[Agent] runs error:', msg);
return [];
}
}),
/** Manually trigger an agent run. */
runNow: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<{ ok: boolean; runId: string }>(
`/api/v1/agents/${input.id}/run`,
{},
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to trigger agent run';
return { data: null, error: msg };
}
}),
journey: agentJourneyRouter,
});
// ---------------------------------------------------------------------------
// Auth router — backend authentication
// ---------------------------------------------------------------------------
@@ -607,6 +847,8 @@ const authRouter = router({
try {
const auth = getAuthManager();
await auth.login(input.email, input.password);
// Connect persistent device WS now that we have a valid token
void getBackendClient().connectPersistent();
return { success: true as const, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Login failed';
@@ -617,6 +859,8 @@ const authRouter = router({
logout: publicProcedure.mutation(async () => {
const auth = getAuthManager();
await auth.logout();
// Disconnect persistent device WS — stops agent triggers until next login
getBackendClient().disconnectPersistent();
return { success: true as const };
}),
@@ -663,6 +907,7 @@ export const appRouter = router({
taskComments: taskCommentsRouter,
ai: aiRouter,
auth: authRouter,
agent: agentRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -2,11 +2,16 @@ import Store from 'electron-store';
interface AppSettings {
sidebarCollapsed: boolean;
aiProvider: string;
encryptedTokens: Record<string, string>;
userName: string;
/** Base URL of the Adiuva backend API (e.g. 'http://localhost:8000'). */
backendUrl: string;
/**
* Stable device identifier — UUID v4 generated once on first launch and
* persisted forever. Used to bind local agents to the machine they were
* configured on (Step 3.3).
*/
deviceId: string;
}
let _store: Store<AppSettings> | null = null;
@@ -16,12 +21,26 @@ export function getStore(): Store<AppSettings> {
_store = new Store<AppSettings>({
defaults: {
sidebarCollapsed: false,
aiProvider: 'copilot',
encryptedTokens: {},
userName: 'there',
backendUrl: 'http://localhost:8000',
deviceId: '',
},
});
}
return _store;
}
/**
* Returns the stable device ID, generating and persisting a new UUID v4 on
* first call. Subsequent calls always return the same value.
*/
export function getDeviceId(): string {
const store = getStore();
let id = store.get('deviceId');
if (!id) {
id = crypto.randomUUID();
store.set('deviceId', id);
}
return id;
}

View File

@@ -41,3 +41,11 @@ contextBridge.exposeInMainWorld('electronAI', {
};
},
});
// ---------------------------------------------------------------------------
// Dialog — native file/folder picker
// ---------------------------------------------------------------------------
contextBridge.exposeInMainWorld('electronDialog', {
showOpenDialog: (options: Electron.OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke('dialog:showOpenDialog', options),
});

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Link } from '@tanstack/react-router';
import { Sparkles, LogIn, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -43,12 +44,10 @@ const fadeUp = {
};
interface AIChatPanelProps {
onOpenSettings?: () => void;
isHomePage?: boolean;
}
export function AIChatPanel({
onOpenSettings,
isHomePage,
}: AIChatPanelProps) {
const authStatusQuery = trpc.auth.status.useQuery();
@@ -302,8 +301,8 @@ export function AIChatPanel({
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Log in to your account to enable AI features.
</p>
<Button variant="outline" size="sm" onClick={onOpenSettings}>
Open Settings
<Button variant="outline" size="sm" asChild>
<Link to="/settings">Open Settings</Link>
</Button>
</div>
) : briefLoading && !dailyBrief ? (

View File

@@ -9,11 +9,6 @@ import {
PanelLeft,
Settings,
Sparkles,
Check,
Sun,
Moon,
Monitor,
Palette
} from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
@@ -32,28 +27,8 @@ import {
SidebarTrigger,
useSidebar,
} from '@/components/ui/sidebar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuTrigger,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
import { useTheme } from '@/components/theme-provider';
import { FloatingChatProvider } from '@/context/FloatingChatContext';
const NAV_ITEMS = [
@@ -97,25 +72,17 @@ function AppShellInner({ children }: AppShellProps) {
setSidebarCollapsedMutation.mutate({ collapsed: !value });
};
// AI settings dialog state
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
const authStatusQuery = trpc.auth.status.useQuery();
// AI settings dialog state — removed; settings now live at /settings
const isHomePage = currentPath === '/';
return (
<LayoutGroup>
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
<AppSidebar
currentPath={currentPath}
setTokenDialogOpen={setTokenDialogOpen}
/>
<AppSidebar currentPath={currentPath} />
<SidebarInset>
{isHomePage ? (
<AIChatPanel
onOpenSettings={() => setTokenDialogOpen(true)}
isHomePage
/>
<AIChatPanel isHomePage />
) : (
<div className="relative flex flex-col h-full">
<header className="flex items-center gap-2 p-2 md:hidden">
@@ -129,37 +96,16 @@ function AppShellInner({ children }: AppShellProps) {
{/* Floating AI Chat — portal to document.body */}
<FloatingChatPortal />
{/* AI Settings Dialog — full login/auth UI added in Step 6.1 */}
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>AI Features</DialogTitle>
<DialogDescription>
{authStatusQuery.data?.authenticated
? 'You are signed in. AI features are available.'
: 'Sign in to your account to enable AI chat, daily briefs, and suggestions.'}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setTokenDialogOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</LayoutGroup>
);
}
interface AppSidebarProps {
currentPath: string;
setTokenDialogOpen: (open: boolean) => void;
}
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
function AppSidebar({ currentPath }: AppSidebarProps) {
const { toggleSidebar } = useSidebar();
const { theme, setTheme } = useTheme();
return (
<Sidebar collapsible="icon">
@@ -223,49 +169,20 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
</SidebarGroup>
</SidebarContent>
{/* Settings gear + Collapse toggle */}
{/* Settings + Collapse toggle */}
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip="Settings">
<Settings />
<span>Settings</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="end" className="w-56">
<DropdownMenuItem onSelect={() => setTokenDialogOpen(true)}>
<Sparkles className="mr-2 size-4" />
AI Provider
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="mr-2 size-4" />
<span>Theme</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => setTheme('light')}>
<Sun className="mr-2 size-4" />
Light
{theme === 'light' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme('dark')}>
<Moon className="mr-2 size-4" />
Dark
{theme === 'dark' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme('system')}>
<Monitor className="mr-2 size-4" />
System
{theme === 'system' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
<SidebarMenuButton
asChild
isActive={currentPath.startsWith('/settings')}
tooltip="Settings"
>
<Link to="/settings">
<Settings />
<span>Settings</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton onClick={toggleSidebar} tooltip="Toggle Sidebar">

View File

@@ -18,10 +18,21 @@ interface ElectronAI {
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => () => void;
}
interface ElectronDialog {
showOpenDialog: (options: {
properties?: string[];
title?: string;
defaultPath?: string;
filters?: { name: string; extensions: string[] }[];
multiSelections?: boolean;
}) => Promise<{ canceled: boolean; filePaths: string[] }>;
}
declare global {
interface Window {
electronTRPC: ElectronTRPC;
electronAI: ElectronAI;
electronDialog: ElectronDialog;
}
}

View File

@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as TimelineRouteImport } from './routes/timeline'
import { Route as TasksRouteImport } from './routes/tasks'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as ProjectsRouteImport } from './routes/projects'
import { Route as IndexRouteImport } from './routes/index'
import { Route as NotesNoteIdRouteImport } from './routes/notes.$noteId'
@@ -25,6 +26,11 @@ const TasksRoute = TasksRouteImport.update({
path: '/tasks',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const ProjectsRoute = ProjectsRouteImport.update({
id: '/projects',
path: '/projects',
@@ -44,6 +50,7 @@ const NotesNoteIdRoute = NotesNoteIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/projects': typeof ProjectsRoute
'/settings': typeof SettingsRoute
'/tasks': typeof TasksRoute
'/timeline': typeof TimelineRoute
'/notes/$noteId': typeof NotesNoteIdRoute
@@ -51,6 +58,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/projects': typeof ProjectsRoute
'/settings': typeof SettingsRoute
'/tasks': typeof TasksRoute
'/timeline': typeof TimelineRoute
'/notes/$noteId': typeof NotesNoteIdRoute
@@ -59,21 +67,42 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/projects': typeof ProjectsRoute
'/settings': typeof SettingsRoute
'/tasks': typeof TasksRoute
'/timeline': typeof TimelineRoute
'/notes/$noteId': typeof NotesNoteIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
fullPaths:
| '/'
| '/projects'
| '/settings'
| '/tasks'
| '/timeline'
| '/notes/$noteId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
id: '__root__' | '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
to:
| '/'
| '/projects'
| '/settings'
| '/tasks'
| '/timeline'
| '/notes/$noteId'
id:
| '__root__'
| '/'
| '/projects'
| '/settings'
| '/tasks'
| '/timeline'
| '/notes/$noteId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ProjectsRoute: typeof ProjectsRoute
SettingsRoute: typeof SettingsRoute
TasksRoute: typeof TasksRoute
TimelineRoute: typeof TimelineRoute
NotesNoteIdRoute: typeof NotesNoteIdRoute
@@ -95,6 +124,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TasksRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/projects': {
id: '/projects'
path: '/projects'
@@ -122,6 +158,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ProjectsRoute: ProjectsRoute,
SettingsRoute: SettingsRoute,
TasksRoute: TasksRoute,
TimelineRoute: TimelineRoute,
NotesNoteIdRoute: NotesNoteIdRoute,

File diff suppressed because it is too large Load Diff

View File

@@ -111,9 +111,49 @@ export const WsToolResultSchema = z.object({
});
export type WsToolResult = z.infer<typeof WsToolResultSchema>;
// --- Agent frames (Phase 3) — Client → Server --------------------------------
/** Sent by Electron with pre-processed file contents for an agent run. */
export const WsAgentDataSchema = z.object({
type: z.literal('agent_data'),
runId: z.string(),
files: z.array(
z.object({
path: z.string(),
name: z.string(),
content: z.string(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
),
});
export type WsAgentData = z.infer<typeof WsAgentDataSchema>;
/** Sent by Electron when it has finished reading all files for a run. */
export const WsAgentCompleteSchema = z.object({
type: z.literal('agent_complete'),
runId: z.string(),
filesRead: z.number().int(),
errors: z.array(z.string()),
});
export type WsAgentComplete = z.infer<typeof WsAgentCompleteSchema>;
/**
* First frame sent by Electron on the persistent device WS connection.
* Identifies the device and the agent configs it owns.
*/
export const WsDeviceHelloSchema = z.object({
type: z.literal('device_hello'),
deviceId: z.string(),
agentIds: z.array(z.string()),
});
export type WsDeviceHello = z.infer<typeof WsDeviceHelloSchema>;
export const WsClientFrameSchema = z.discriminatedUnion('type', [
WsChatRequestSchema,
WsToolResultSchema,
WsAgentDataSchema,
WsAgentCompleteSchema,
WsDeviceHelloSchema,
]);
export type WsClientFrame = z.infer<typeof WsClientFrameSchema>;
@@ -148,11 +188,32 @@ export const WsPingSchema = z.object({
});
export type WsPing = z.infer<typeof WsPingSchema>;
// --- Agent frames (Phase 3) — Server → Client --------------------------------
/**
* Sent by the backend to trigger a local directory agent run on the Electron
* client. Electron should read files matching the config and respond with
* `agent_data` frames followed by an `agent_complete` frame.
*/
export const WsAgentRunSchema = z.object({
type: z.literal('agent_run'),
runId: z.string(),
agentId: z.string(),
config: z.object({
paths: z.array(z.string()),
fileExtensions: z.array(z.string()),
promptTemplate: z.string(),
dataTypes: z.array(z.string()),
}),
});
export type WsAgentRun = z.infer<typeof WsAgentRunSchema>;
export const WsServerFrameSchema = z.discriminatedUnion('type', [
WsTextChunkSchema,
WsToolCallSchema,
WsFinalSchema,
WsPingSchema,
WsAgentRunSchema,
]);
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;
@@ -176,6 +237,83 @@ export const PermissionGrantSchema = z.object({
});
export type PermissionGrant = z.infer<typeof PermissionGrantSchema>;
// ---------------------------------------------------------------------------
// Agent REST API — response types (Phase 3, Step 3.4)
// ---------------------------------------------------------------------------
/** An item in the agent catalog returned by GET /api/v1/agents/catalog. */
export const AgentCatalogItemSchema = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['local', 'cloud']),
description: z.string(),
/** Cloud provider identifier (e.g. 'gmail', 'teams', 'outlook'). */
provider: z.string().optional(),
supportedDataTypes: z.array(z.string()),
defaultFileExtensions: z.array(z.string()).optional(),
});
export type AgentCatalogItem = z.infer<typeof AgentCatalogItemSchema>;
/** A configured local directory agent stored on the backend. */
export const LocalAgentConfigSchema = z.object({
id: z.string(),
userId: z.string(),
deviceId: z.string(),
name: z.string(),
directoryPaths: z.array(z.string()),
dataTypes: z.array(z.string()),
fileExtensions: z.array(z.string()),
promptTemplate: z.string(),
scheduleCron: z.string(),
enabled: z.boolean(),
lastRunAt: z.number().int().nullable().optional(),
createdAt: z.number().int(),
updatedAt: z.number().int(),
});
export type LocalAgentConfig = z.infer<typeof LocalAgentConfigSchema>;
/** A configured cloud connector agent stored on the backend. */
export const CloudAgentConfigSchema = z.object({
id: z.string(),
userId: z.string(),
provider: z.enum(['gmail', 'teams', 'outlook']),
name: z.string(),
dataTypes: z.array(z.string()),
promptTemplate: z.string(),
scheduleCron: z.string(),
filterConfig: z.record(z.string(), z.unknown()).optional(),
enabled: z.boolean(),
lastRunAt: z.number().int().nullable().optional(),
createdAt: z.number().int(),
updatedAt: z.number().int(),
});
export type CloudAgentConfig = z.infer<typeof CloudAgentConfigSchema>;
/** A single agent run log entry returned by GET /api/v1/agents/runs. */
export const AgentRunLogSchema = z.object({
id: z.string(),
agentId: z.string(),
agentType: z.enum(['local', 'cloud']),
userId: z.string(),
status: z.enum(['running', 'success', 'error', 'partial']),
itemsProcessed: z.number().int(),
itemsCreated: z.number().int(),
errors: z.array(z.string()),
startedAt: z.number().int(),
completedAt: z.number().int().nullable().optional(),
});
export type AgentRunLog = z.infer<typeof AgentRunLogSchema>;
/** Response from POST /api/v1/agents/journey/start or /journey/message. */
export const JourneyMessageSchema = z.object({
sessionId: z.string(),
message: z.string(),
done: z.boolean(),
/** Present on the final message when `done === true`. */
promptTemplate: z.string().optional(),
});
export type JourneyMessage = z.infer<typeof JourneyMessageSchema>;
// ---------------------------------------------------------------------------
// Backup
// ---------------------------------------------------------------------------

View File

@@ -14,15 +14,6 @@ export default defineConfig({
external: [
'better-sqlite3',
'ws',
'@github/copilot-sdk',
'@github/copilot',
// LangChain — externalize to avoid bundling Node.js-specific code
'@langchain/core',
'@langchain/langgraph',
'@langchain/openai',
'@langchain/anthropic',
'@langchain/langgraph-checkpoint',
'@langchain/langgraph-sdk',
'vectordb',
],
output: {