# Project export: Aslope

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Aslope is an agentic and camera focus and posture coach. It catches you slouching or grabbing your phone, delivers a personalized AI nudge, and trains your brain to bounce back, no shame, no blockers.
- Devpost: https://devpost.com/software/aslope
- GitHub: https://github.com/adam-ajroudi/Aslope
- Team: 1 GitHub contributor(s) — Adam Ajroudi (13 commits)

## Devpost submission (written by the team)

### Overview

A camera-based focus coach that catches you slouching or reaching for your phone, delivers a personalized AI nudge, and trains your brain to bounce back. No shame, no blockers.

### Inspiration

I have ADHD, and the thing that wrecks my focus is not the distraction. It is what happens half a second after I notice it: the jolt of panic and self-blame, the shame cycle. The distraction is cheap. The emotional friction that follows is what actually costs me the next thirty minutes. This is not just a feeling. Mark et al. (2008) found that a knowledge worker is interrupted roughly every 11 minutes and needs about 23 minutes to fully return to task. If you model the share of time actually spent in productive focus as $$ P = \frac{t_{\text{focus}}}{t_{\text{focus}} + t_{\text{recover}}} $$ then with $t_{\text{focus}} = 11$ and $t_{\text{recover}} = 23$ minutes you get $P \approx 0.32$. Only about a third of the day is real focus. For people with ADHD, the recovery term is neurologically larger, and every recovery is taxed again by shame. Most productivity tools attack the wrong variable. Blockers and timers try to shrink interruptions, but the real leverage is in the denominator: cut $t_{\text{recover}}$, the time it takes to bounce back. My capstone, Anchor, was my first attempt at this. I reverse-engineered a Bluetooth smart ring and paired it with a desktop app, so that when I caught myself drifting I could click the ring and instantly receive AI-generated, context-aware encouragement instead of spiraling. It externalized the act of refocusing and logged every recovery as a win. Anchor worked, but it had a catch: it required me to notice and choose to click. aslope is the question Anchor left me with. What if the system noticed for me? What if refocusing did not depend on me having the self-awareness, in the worst moment, to act? The name is the joke and the thesis: the slow slope of a slouching spine, and the slippery slope of one glance at your phone becoming twenty minutes gone.

### What it does

aslope quietly watches your work session through your laptop camera and looks for the two behaviors that most reliably mark the start of a drift: slouching and picking up your phone. The instant it sees one persist, it does not scold you. It delivers a personalized nudge that was generated for you in advance: an image, or a short line spoken aloud by an AI voice coach. You correct, you recover, and it logs the win. After each session, the system reasons about what just happened. It looks at how often you slouched, how often you reached for your phone, and crucially how fast you recovered each time, then it tunes the next session's nudges and tasks to what actually worked for you. The dashboard shows a Log of Wins rather than a tally of failures. Each user picks a framing that fits them. Reward mode shows aspirational imagery of focus and good posture. Consequence mode shows the long-term cost of the habit. Neither mode ever blames the person. The framing is always about the trajectory, never about failure in the moment.

### How we built it

The entire build was done with Claude Code, and the whole architecture follows one rule: slow generation never touches the real-time path. The system runs on two clocks. The slow clock runs at the start and end of a session and handles all reasoning and asset generation. The fast clock runs continuously during the session and handles detection and reward delivery, with a latency budget under 300 milliseconds from trigger to on-screen nudge. Redis bridges the two: the slow clock fills a cache of ready-to-serve assets, and the fast clock only ever reads from it. The Anthropic API is the orchestration brain. Using the Messages API MCP connector, a single Claude call both reasons and drives generation: it writes the prompts, generates the quote pool, and calls Midjourney directly through the connector. After a session, Claude does the heavy reasoning that personalizes the next one. Midjourney generates the nudge imagery, themed to each user's interests and chosen mode. Deepgram is the voice coach, reading the nudge aloud. Top lines are pre-rendered during prep so the first nudges are instant. Redis is both the fast-clock cache and cross-session agent memory, using vector search so the coach can semantically recall past wins. Sentry wraps the long-running generation jobs, the CV loop, and the IPC layer, since the most failure-prone parts are the multi-step pipelines and the flaky third-party servers.

### Challenges we ran into

The biggest design challenge was latency. Midjourney takes tens of seconds to minutes, and a nudge that arrives a minute after you slouch is worthless. Recognizing that it simply could not live in the real-time loop, and then redesigning around pre-generation plus a Redis cache, was the decision that made the whole thing feasible. The Midjourney MCP server was the flakiest integration. It is prerelease, and we hit OAuth and Cloudflare challenge errors more than once. We mitigated it by generating a real image stock early while the connection was healthy and making the cache able to serve pre-baked images so the demo never depended on a live call. Computer vision was the riskiest core, so we built it first. Posture detection only became reliable once we added a calibration step to capture each person's good-posture baseline, plus persistence thresholds and a cooldown so the system was not firing constantly or reacting to a single shift in the chair. The hardest challenge was not technical. It was making sure the consequence framing never tipped into shame, because shame is the exact thing we are trying to remove. Every generated line had to be about the trajectory, not the person.

### What we learned

The deepest lesson came from Anchor and shaped aslope directly: the metric you choose can quietly sabotage the goal. Anchor's original dashboard counted clicks, but a raw count cannot tell resilience apart from dysregulation. A frantic, spiraling day and a calm, recovering day could produce the same number. That insight led to the Focus Wall idea of banking focus intervals instead of counting interruptions, and in aslope it became the principle that recovery time, not interruption count, is the real measure. Faster bounce-back is the thing worth optimizing. We also learned that passive sensing changes the psychology. Anchor required a deliberate act, which gave the user agency but also a way to fail by simply not clicking. aslope removes that dependency, but it raises a new responsibility: a system that watches you has to be gentle, or it becomes one more source of pressure. That tension drove most of our design decisions.

### What's next

for aslope The research framework from the Anchor capstone still points the way. The next real step is a within-subject feasibility pilot to test whether aslope genuinely helps people redirect faster and feel less shame, grounded in established behavioral approaches to ADHD intervention rather than in how useful people merely say it looks. Perceiving the value of a tool is not the same as testing it, and recovery time finally gives us an honest number to test against.

## README (from the GitHub repository)

# Anchor Vision

A camera-driven focus coach for the Berkeley AI Hackathon. See [PLAN.md](./PLAN.md) for the full architecture.

## Quick start

On WSL, use Linux-native pnpm (not the Windows shim):

```bash
corepack enable && corepack prepare pnpm@9.15.4 --activate
cp .env.example .env
# Fill in ANTHROPIC_API_KEY, REDIS_URL, and SENTRY_DSN (optional but recommended)

pnpm install
pnpm dev
```

### WSL / Linux note

**Webcam:** WSL2 cannot access your laptop camera. To use the webcam, run the app on **native Windows** (not WSL, not `\\wsl$\...`).

PowerShell **cannot** use `\\wsl$\...` as a working directory (`UNC paths are not supported`), and WSL's `node_modules` installs the **Linux** Electron binary anyway.

### Run with camera (Windows)

One-time setup in **PowerShell**:

```powershell
# 1. Clone/copy the repo to a Windows path (not WSL)
cd $env:USERPROFILE\developer
git clone https://github.com/adam-ajroudi/Aslope.git ai-hackathon-berkeley-2026
cd ai-hackathon-berkeley-2026

# 2. Copy your .env from WSL (adjust distro name if needed)
copy \\wsl$\Ubuntu\home\adam\developer\hackathons\ai-hackathon-berkeley-2026\.env .env

# 3. Install Node 20+ on Windows if needed: https://nodejs.org
corepack enable
pnpm install
pnpm dev
```

Camera + fullscreen overlay will work from this Windows install. Keep coding in WSL; sync via git or copy `.env` when keys change.

**WSL without camera:** use `pnpm dev` in WSL and click **Use demo feed** in the app.

If Electron fails with `libnss3.so: cannot open shared object file`, install the required libraries:

```bash
# Ubuntu 24.04 (Noble) — note the t64 suffix on some packages
sudo apt-get install -y \
  libnss3 libnspr4 libatk1.0-0t64 libatk-bridge2.0-0t64 \
  libcups2t64 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
  libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 \
  libcairo2 libx11-xcb1 libxcb-dri3-0 libxshmfence1
```

## Milestone 5

- Midjourney MCP via Anthropic connector (`mcp-client-2025-11-20`)
- After session prep, generates up to 4 images in the **background** (one per trigger/mode)
- Downloads CDN assets to local cache, serves via `nudge://` protocol in overlay
- Falls back to SVG placeholders if Midjourney fails or credentials missing

**Requires:** `MIDJOURNEY_MCP_URL`, `MIDJOURNEY_OAUTH_TOKEN` in `.env`  
**Optional:** `MIDJOURNEY_PREP_LIMIT=4` (max images per session)

## Milestone 4

- Claude session prep via Anthropic Messages API (no MCP yet)
- Generates personalized **quote pools** + **Midjourney image prompts** per trigger/mode
- Quotes cached in Redis (`quotes:*`, `assets:*`); image prompts stored at `prep:{userId}:prompts` for M5
- In-memory fallback when Redis is down; hardcoded fallback when API key missing
- UI shows "Claude is prepping…" during session start

**Requires:** `ANTHROPIC_API_KEY` in `.env`

## Milestone 3

- Redis asset cache — session start seeds nudges, triggers read from cache
- Session + trigger events logged to Redis (`session:*`, `events:*`)
- Falls back to hardcoded nudges if `REDIS_URL` is missing or unreachable
- Status panel shows Redis connection state

**Redis keys used:** `assets:{userId}:{trigger}:{mode}`, `session:{sessionId}`, `events:{userId}`

## Milestone 2

- End-to-end trigger → nudge loop (zero AI)
- Main process serves hardcoded image + quote per trigger/mode
- Ambient overlay on trigger with auto-dismiss
- **Fullscreen overlay window** — covers your entire display even when the main app is minimized
- Demo trigger buttons (CV detection lands in M1)

**Try it:** `pnpm dev` → Start session → click Slouch or Phone → overlay appears.

Pika integration is deferred to milestone 8.

## Milestone 0

- Electron + React + TypeScript shell
- Live webcam feed in the renderer
- Sentry initialized in main and renderer processes
- Typed IPC bridge (`window.anchor`) with stub handlers

## Scripts

| Command | Description |
|---------|-------------|
| `pnpm dev` | Start Electron in development mode |
| `pnpm build` | Build for production |
| `pnpm preview` | Preview production build |
| `pnpm typecheck` | Run TypeScript checks |


## Detected evidence (automated analysis)

Indexed codebase: 71 recognized source files, 319 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- React (technology) — detected in the code
- Redis (technology) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (78 of 78)

```
.gitignore
assets/placeholders/.gitkeep
electron.vite.config.ts
electron/ipc/channels.ts
electron/main.ts
electron/overlayWindow.ts
electron/pipelines/.gitkeep
electron/pipelines/assetCache.ts
electron/pipelines/assetDownload.ts
electron/pipelines/imageGeneration.ts
electron/pipelines/postSession.ts
electron/pipelines/postSessionFallback.ts
electron/pipelines/prepFallback.ts
electron/pipelines/seerComeback.ts
electron/pipelines/sessionPrep.ts
electron/pipelines/voicePrep.ts
electron/preload-overlay.ts
electron/preload.ts
electron/services/agentMemory.ts
electron/services/anthropic.ts
electron/services/assetProtocol.ts
electron/services/deepgram.ts
electron/services/devCoaching.ts
electron/services/mediaPermissions.ts
electron/services/midjourneyMcp.ts
electron/services/nudgeAudio.ts
electron/services/nudgeHardcoded.ts
electron/services/nudgeModality.ts
electron/services/redis.ts
electron/services/resolveAsset.ts
electron/services/reverseNudge.ts
electron/services/seer.ts
electron/services/sentry.ts
electron/services/systemInfo.ts
electron/utils/withTimeout.ts
package.json
PLAN.md
README.md
renderer/index.html
renderer/overlay.html
renderer/src/App.css
renderer/src/App.tsx
renderer/src/components/DevCoachPanel.css
renderer/src/components/DevCoachPanel.tsx
renderer/src/components/NudgeAudioPlayer.tsx
renderer/src/components/NudgeOverlay.css
renderer/src/components/NudgeOverlay.tsx
renderer/src/components/ReverseNudgeOverlay.css
renderer/src/components/ReverseNudgeOverlay.tsx
renderer/src/components/SessionDashboard.css
renderer/src/components/SessionDashboard.tsx
renderer/src/components/SessionSetup.css
renderer/src/components/SessionSetup.tsx
renderer/src/components/WebcamFeed.css
renderer/src/components/WebcamFeed.tsx
renderer/src/hooks/useVisionMonitor.ts
renderer/src/index.css
renderer/src/main.tsx
renderer/src/overlay/main.tsx
renderer/src/overlay/OverlayApp.tsx
renderer/src/state/store.ts
renderer/src/vision/calibration.ts
renderer/src/vision/camera.ts
renderer/src/vision/phoneDetector.ts
renderer/src/vision/poseDetector.ts
renderer/src/vision/triggerEngine.ts
renderer/src/vision/types.ts
shared/agentMemory.ts
shared/devCoaching.ts
shared/postSessionSchema.ts
shared/prepSchema.ts
shared/schema.ts
shared/seer.ts
shared/types.ts
tsconfig.json
tsconfig.node.json
tsconfig.node.tsbuildinfo
tsconfig.web.json
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.105.0, @huggingface/transformers@^4.2.0, @mediapipe/tasks-vision@^0.10.35, @sentry/electron@^6.11.0, @types/node@^22.13.4, @types/react@^18.3.18, @types/react-dom@^18.3.5, @types/uuid@^10.0.0, @vitejs/plugin-react@^4.3.4, dotenv@^16.4.7, electron@^34.2.0, electron-vite@^3.0.0, react@^18.3.1, react-dom@^18.3.1, redis@^6.0.0, typescript@^5.7.3, uuid@^11.1.0, vite@^6.1.0

### Recent commits (newest first)

- Update build config, add mediapipe dependencies, and remove .env.example
- Update frontend app flow, session setup, and HTML entry points
- Wire nudge audio IPC, overlay preload, and pipeline adjustments
- Enhance electron services: Redis queue, asset resolution, and media permissions
- Update nudge overlay and webcam feed UI with vision integration
- Add nudge audio playback and modality selection system
- Add useVisionMonitor hook for webcam-based posture and phone tracking
- Add vision pipeline with pose detection, phone detection, and trigger engine
- Update shared types and agent memory with nudge audio fields
- Wire reverse-nudge overlay, Seer sidebar, and session-end App flow.
- Add Seer reverse-nudge backend with evolving friendship memory.
- Add Log of Wins dashboard and end-session controls.
- Fix missing prepFallback import in session prep pipeline.
- Expose session end and memory APIs in preload bridge.
- Document setup, env vars, and session workflow in README.
- Add fullscreen nudge overlay renderer and placeholder assets.
- Enhance webcam feed with camera utilities and trigger UI.
- Add session setup flow and main app orchestration.
- Wire main process IPC, overlay window, and session lifecycle.
- Add asset cache and post-session summary pipeline.

## Key source files (fetched from GitHub, selected and truncated for size)

### PLAN.md

```markdown
# Anchor Vision: Implementation Plan and Architecture

> Working title. This is a new project for the Berkeley AI Hackathon, conceptually descended from Anchor but built fresh during the event. Rename freely.

A camera-driven focus coach. It watches for two behaviors during a work session (slouching and phone-in-hand), and when one fires it instantly delivers a pre-generated nudge: an image, a short voice line, or a video. After each session, Claude analyzes what happened and personalizes the next session's nudges and tasks. Built with Claude Code, orchestrated by the Anthropic API, with Midjourney, Pika, Deepgram, Redis, Sentry, and Arize as the integrated layers.

---

## 1. Core design principle: the latency split

Everything in this architecture follows from one rule. Slow generation never sits in the real-time path.

Midjourney and Pika take tens of seconds to minutes. A nudge that arrives a minute after you slouch is useless. So the system runs in two clocks:

- **Slow clock (seconds to minutes):** asset generation and reasoning. Runs at session start and after a session ends. Claude, Midjourney, and Pika live here.
- **Fast clock (under ~300ms):** detection and reward delivery. Camera, computer vision, cache lookup, overlay, and voice playback live here. No slow API is ever called on this path.

The bridge between the two clocks is Redis. The slow clock fills a cache of ready-to-serve assets. The fast clock only ever reads from that cache.

Target latency budget for a trigger to its on-screen reward: under 300ms, with Deepgram audio following within ~1s.

---

## 2. System architecture

Three layers, mirroring the original Anchor split but with the ring replaced by the camera.

### Layer 1: Local client (Electron)

- **Main process (Node.js):** owns orchestration, all outbound API and MCP calls, Redis connection, IPC, and Sentry. This is the controller.
- **Renderer process (React):** owns the webcam feed, the computer vision pipeline, the session setup UI, the nudge overlay, and the dashboard.
- IPC carries two things: trigger events up from renderer to main, and nudge payloads down from main to renderer.

### Layer 2: Reasoning and generation (cloud, slow clock)

- **Anthropic Messages API** as the orchestration brain. It reads the user profile and session data, decides what to generate, and drives Midjourney and Pika through the MCP connector in a single API flow.
- **Midjourney MCP** for still images.
- **Pika MCP** for short video clips, including the personal-photo feature.
- **Deepgram** REST API for text-to-speech (this one is fast enough to call near real time, but we still pre-fetch where possible).

### Layer 3: State, memory, and observability

- **Redis** as both the fast-clock asset cache and the cross-session agent memory (with vector search for semantic recall of past nudges and wins).
- **Sentry** wrapping the long-running generation jobs, the CV loop, and all IPC and API calls.
- **Arize** instrumenting the generation and
[truncated — 22333 more characters]
```

### package.json

```
{
  "name": "anchor-vision",
  "version": "0.1.0",
  "description": "Camera-driven focus coach for the Berkeley AI Hackathon",
  "main": "./out/main/index.js",
  "private": true,
  "packageManager": "pnpm@9.15.4",
  "type": "module",
  "scripts": {
    "dev": "electron-vite dev",
    "build": "electron-vite build",
    "preview": "electron-vite preview",
    "typecheck": "tsc --noEmit -p tsconfig.web.json && tsc --noEmit -p tsconfig.node.json"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.105.0",
    "@huggingface/transformers": "^4.2.0",
    "@mediapipe/tasks-vision": "^0.10.35",
    "@sentry/electron": "^6.11.0",
    "dotenv": "^16.4.7",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "redis": "^6.0.0",
    "uuid": "^11.1.0"
  },
  "devDependencies": {
    "@types/node": "^22.13.4",
    "@types/react": "^18.3.18",
    "@types/react-dom": "^18.3.5",
    "@types/uuid": "^10.0.0",
    "@vitejs/plugin-react": "^4.3.4",
    "electron": "^34.2.0",
    "electron-vite": "^3.0.0",
    "typescript": "^5.7.3",
    "vite": "^6.1.0"
  }
}

```

### electron/main.ts

```typescript
import 'dotenv/config'
import { app, BrowserWindow, ipcMain, shell } from 'electron'

// Overlay nudges play TTS in a separate BrowserWindow with no prior user gesture.
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')

import { join } from 'path'
import { v4 as uuidv4 } from 'uuid'
import type { SeerBanterEvent } from '@shared/seer'
import { loadDevCoaching, saveDevCoaching } from './services/devCoaching'
import { IPC_CHANNELS } from './ipc/channels'
import {
  clearSessionEvents,
  getSession,
  getSessionEvents,
  logTriggerEvent,
  saveSession,
  serveNudge,
  syncPrepToRedis
} from './pipelines/assetCache'
import { loadAgentMemory, saveAgentMemory } from './services/agentMemory'
import { runPostSession } from './pipelines/postSession'
import { prepareSessionImages } from './pipelines/imageGeneration'
import { countQuotes } from './pipelines/prepFallback'
import { runSessionPrep } from './pipelines/sessionPrep'
import { synthesizeLiveQuote, startVoicePrep } from './pipelines/voicePrep'
import { registerNudgeProtocol, setupNudgeProtocol } from './services/assetProtocol'
import { isDeepgramConfigured } from './services/deepgram'
import type { Profile, SessionEndPayload, SessionStartPayload, TriggerPayload } from '@shared/types'
import { generateSeerComeback } from './pipelines/seerComeback'
import {
  appendSeerBanterEvent,
  evolveRelationshipAfterCoach,
  getSeerVibeLabel,
  loadSeerRelationship,
  saveSeerRelationship
} from './services/seer'
import { registerReverseNudgeDelivery } from './services/reverseNudge'
import { destroyOverlayWindow, hideOverlayWindow, showFullscreenNudge, showFullscreenReverseNudge } from './overlayWindow'
import { disconnectRedis, isRedisReady } from './services/redis'
import { setupMediaPermissions, configureWindowAudio } from './services/mediaPermissions'
import { initSentryMain, Sentry } from './services/sentry'
import { playNudgeAudio, stopNudgeAudio } from './services/nudgeAudio'
import { normalizeAssetUrl } from './services/resolveAsset'
import { applyModality, pickModality } from './services/nudgeModality'
import { isWsl } from './services/systemInfo'
import type { DevCoachEntry, DevCoachSubmitPayload, ReverseNudgePayload } from '@shared/devCoaching'

initSentryMain()
registerNudgeProtocol()

process.on('uncaughtException', (err) => {
  if (err.message?.includes('ECONNRESET') || err.message?.includes('ETIMEDOUT')) {
    console.warn('[process] swallowed socket error:', err.message)
    return
  }
  Sentry.captureException(err)
  console.error('[process] uncaught:', err)
})

let mainWindow: BrowserWindow | null = null

const pendingIncidents = new Map<string, ReverseNudgePayload>()

const DEFAULT_PROFILE: Profile = {
  userId: 'local-user',
  interests: []
}

function createWindow(): void {
  mainWindow = new BrowserWindow({
    width: 960,
    height: 720,
    minWidth: 640,
    minHeight: 480,
    show: false,
    webPreferences: {
      preload: join(__dirname, '../preload/index.mjs'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: false
    }
  })

  mainWindow.on('ready-to-show', () => {
    if (mainWindow) {
      configureWindowAudio(mainWindow)
    }
    mainWindow?.show()
  })

  mainWindow.webContents.setWindowOpenHandler((details) => {
    shell.openExternal(details.url)
    return { action: 'deny' }
  })

  if (process.env.ELECTRON_RENDERER_URL) {
    mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }

  mainWindow.on('closed', () => {
    destroyOverlayWindow()
    mainWindow = null
  })
}

function deliverReverseNudge(payload: ReverseNudgePayload): void {
  pendingIncidents.set(payload.incidentId, payload)
  showFullscreenReverseNudge(payload, mainWindow)
  mainWindow?.webContents.send(IPC_CHANNELS.REVERSE_NUDGE_RECEIVE, payload)
}

function registerIpcHandlers(): void {
  ipcMain.handle(IPC_CHANNELS.PROFILE_GET, () => {
    return Sentry.startSpan({ name: 'ipc.profile:get', op: 'ipc' }, () => DEFAULT_PROFILE)
  })

  ipcMain.handle(IPC_CHANNELS.SYSTEM_INFO, async () => {
    return {
      isWsl: isWsl(),
      platform: process.platform,
      redisConnected: await isRedisReady()
    }
  })

  ipcMain.handle(IPC_CHANNELS.SESSION_START, async (_event, payload: SessionStartPayload) => {
    return Sentry.startSpan({ name: 'ipc.session:start', op: 'ipc' }, async () => {
      const sessionId = uuidv4()
      const startedAt = Date.now()

      const { prep, source } = await runSessionPrep({
        profile: DEFAULT_PROFILE,
        taskIntent: payload.taskIntent
      })

      const quoteCount = countQuotes(prep)
      syncPrepToRedis(DEFAULT_PROFILE.userId, prep)
      startVoicePrep(DEFAULT_PROFILE.userId, prep, mainWindow)

      const imageCount = await prepareSessionImages(
        DEFAULT_PROFILE.userId,
        prep.imagePrompts
      )

      void saveSession(sessionId, {
        userId: DEFAULT_PROFILE.userId,
        taskIntent: payload.taskIntent,
        startedAt,
        prepSource: source
      }).catch((err: unknown) => {
        console.error('[session:save] redis failed:', err)
      })

      console.log('[session:start]', {
        sessionId,
        taskIntent: payload.taskIntent,
        prepSource: source,
        quoteCount
      })

      return {
        sessionId,
        cacheSeeded: true,
        prepSource: source,
        quoteCount,
        imageCount,
        imagesPending: false,
        voicePending: isDeepgramConfigured()
      }
    })
  })

  ipcMain.handle(IPC_CHANNELS.SESSION_END, async (_event, payload: SessionEndPayload) => {
    return Sentry.startSpan({ name: 'ipc.session:end', op: 'ipc' }, async () => {
      const endedAt = Date.now()
      const sessionData = await getSession(payload.sessionId)

      if (!sessionData) {
        throw new Error(`Session not found: ${payload.sessionId}`)
      }

      const events = await getSessionEvents(payload.sessio
[truncated — 7119 more characters]
```

### renderer/src/main.tsx

```typescript
/// <reference types="vite/client" />

import './index.css'
import './App.css'

import * as Sentry from '@sentry/electron/renderer'
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

Sentry.init({
  integrations: [Sentry.replayIntegration()],
  replaysSessionSampleRate: import.meta.env.DEV ? 1.0 : 0.1,
  replaysOnErrorSampleRate: 1.0
})

Sentry.addBreadcrumb({
  category: 'app',
  message: 'Anchor Vision renderer started',
  level: 'info'
})

Sentry.captureMessage('Anchor Vision M0 startup (renderer)', 'info')

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

```

### renderer/src/App.tsx

```typescript
import { useCallback, useEffect, useState } from 'react'
import { DevCoachPanel } from './components/DevCoachPanel'
import { NudgeAudioPlayer } from './components/NudgeAudioPlayer'
import { SessionDashboard } from './components/SessionDashboard'
import { SessionSetup } from './components/SessionSetup'
import { WebcamFeed } from './components/WebcamFeed'
import type { AgentMemory, PostSessionResult } from '@shared/agentMemory'
import type { DevCoachEntry } from '@shared/devCoaching'
import type { Profile, SeerState, TriggerType } from '@shared/types'

export default function App(): React.ReactElement {
  const [profile, setProfile] = useState<Profile | null>(null)
  const [ipcReady, setIpcReady] = useState(false)
  const [redisConnected, setRedisConnected] = useState(false)
  const [sessionId, setSessionId] = useState<string | null>(null)
  const [cacheSeeded, setCacheSeeded] = useState(false)
  const [prepSource, setPrepSource] = useState<'claude' | 'fallback' | null>(null)
  const [quoteCount, setQuoteCount] = useState(0)
  const [sessionPreparing, setSessionPreparing] = useState(false)
  const [sessionEnding, setSessionEnding] = useState(false)
  const [triggerBusy, setTriggerBusy] = useState(false)
  const [lastTrigger, setLastTrigger] = useState<string | null>(null)
  const [lastQuote, setLastQuote] = useState<string | null>(null)
  const [imageCount, setImageCount] = useState(0)
  const [voicePending, setVoicePending] = useState(false)
  const [audioCount, setAudioCount] = useState(0)
  const [postSession, setPostSession] = useState<PostSessionResult | null>(null)
  const [memory, setMemory] = useState<AgentMemory | null>(null)
  const [devCoaching, setDevCoaching] = useState<DevCoachEntry[]>([])
  const [seerState, setSeerState] = useState<SeerState | null>(null)
  const [stressTesting, setStressTesting] = useState(false)

  useEffect(() => {
    if (!window.anchor) {
      console.warn('Anchor IPC bridge not available (running outside Electron?)')
      return
    }

    setIpcReady(true)

    window.anchor
      .getProfile()
      .then(setProfile)
      .catch((err: unknown) => {
        console.error('Failed to load profile:', err)
      })

    window.anchor
      .getMemory()
      .then(setMemory)
      .catch((err: unknown) => {
        console.error('Failed to load memory:', err)
      })

    window.anchor
      .getSeerState()
      .then(setSeerState)
      .catch((err: unknown) => {
        console.error('Failed to load Seer state:', err)
      })

    window.anchor
      .getDevCoaching()
      .then(setDevCoaching)
      .catch((err: unknown) => {
        console.error('Failed to load dev coaching:', err)
      })

    window.anchor
      .getSystemInfo()
      .then((info) => setRedisConnected(info.redisConnected))
      .catch((err: unknown) => {
        console.error('Failed to load system info:', err)
      })

    const unsubscribeNudge = window.anchor.onNudge((payload) => {
      console.log('[nudge:receive]', payload)
      setTriggerBusy(false)
      setLastTrigger(payload.type)
      setLastQuote(payload.quote)
    })

    const unsubscribeImages = window.anchor.onImagesReady((payload) => {
      console.log('[images:ready]', payload)
      setImageCount(payload.imageCount)
    })

    const unsubscribeVoice = window.anchor.onVoiceReady((payload) => {
      console.log('[voice:ready]', payload)
      setVoicePending(false)
      setAudioCount(payload.audioCount)
    })

    const unsubscribeDevCoach = window.anchor.onDevCoachingSaved((entry) => {
      setDevCoaching((prev) => [entry, ...prev].slice(0, 20))
      void window.anchor?.getSeerState().then(setSeerState)
    })

    return () => {
      unsubscribeNudge()
      unsubscribeImages()
      unsubscribeVoice()
      unsubscribeDevCoach()
    }
  }, [])

  const handleSessionStart = useCallback(async (taskIntent: string) => {
    if (!window.anchor) return

    setSessionPreparing(true)
    setPostSession(null)

    try {
      const result = await window.anchor.startSession({ taskIntent })
      setSessionId(result.sessionId)
      setCacheSeeded(result.cacheSeeded)
      setPrepSource(result.prepSource)
      setQuoteCount(result.quoteCount)
      setImageCount(result.imageCount)
      setVoicePending(result.voicePending)
      setAudioCount(0)
      setLastTrigger(null)
      setLastQuote(null)

      const info = await window.anchor.getSystemInfo()
      setRedisConnected(info.redisConnected)

      console.log('[session:active]', result)
    } finally {
      setSessionPreparing(false)
    }
  }, [])

  const handleSessionEnd = useCallback(async () => {
    if (!window.anchor || !sessionId || sessionEnding) return

    setSessionEnding(true)

    try {
      const result = await window.anchor.endSession({ sessionId })
      setPostSession(result)
      setMemory(result.memory)
      setSessionId(null)
      setPrepSource(null)
      setQuoteCount(0)
      setImageCount(0)
      setVoicePending(false)
      setAudioCount(0)
      setLastTrigger(null)
      setLastQuote(null)

      console.log('[session:complete]', result)
    } catch (err) {
      console.error('Session end failed:', err)
    } finally {
      setSessionEnding(false)
    }
  }, [sessionEnding, sessionId])

  const handleStressTest = useCallback(async () => {
    if (!window.anchor || stressTesting) return

    setStressTesting(true)
    try {
      await window.anchor.stressTest()
    } catch (err) {
      console.error('Stress test failed:', err)
    } finally {
      setStressTesting(false)
    }
  }, [stressTesting])

  const handleTrigger = useCallback(
    async (type: TriggerType) => {
      if (!window.anchor || !sessionId || triggerBusy) return

      setTriggerBusy(true)

      try {
        await window.anchor.sendTrigger({
          sessionId,
          type,
          timestamp: Date.now()
        })
      } catch (err) {
        console.error('Trigger failed:', err)
        setTriggerBusy(false)
      }
    },
    [s
[truncated — 3301 more characters]
```

### renderer/src/overlay/main.tsx

```typescript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { OverlayApp } from './OverlayApp'
import '../index.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <OverlayApp />
  </StrictMode>
)

```

### electron.vite.config.ts

```typescript
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  main: {
    plugins: [externalizeDepsPlugin()],
    build: {
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'electron/main.ts')
        }
      }
    },
    resolve: {
      alias: {
        '@shared': resolve(__dirname, 'shared')
      }
    }
  },
  preload: {
    plugins: [externalizeDepsPlugin()],
    build: {
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'electron/preload.ts'),
          overlay: resolve(__dirname, 'electron/preload-overlay.ts')
        }
      }
    },
    resolve: {
      alias: {
        '@shared': resolve(__dirname, 'shared')
      }
    }
  },
  renderer: {
    root: resolve(__dirname, 'renderer'),
    build: {
      target: 'esnext',
      rollupOptions: {
        input: {
          index: resolve(__dirname, 'renderer/index.html'),
          overlay: resolve(__dirname, 'renderer/overlay.html')
        }
      }
    },
    plugins: [react()],
    optimizeDeps: {
      exclude: ['@huggingface/transformers', 'onnxruntime-node']
    },
    resolve: {
      alias: {
        '@shared': resolve(__dirname, 'shared')
      }
    }
  }
})

```

### shared/devCoaching.ts

```typescript
export type ReverseNudgePayload = {
  incidentId: string
  errorMessage: string
  component: string
  plea: string
  seerVibe: string
  vibeLabel: string
  banterIndex: number
  timestamp: number
}

export type DevCoachSubmitPayload = {
  incidentId: string
  coachMessage: string
}

export type DevCoachEntry = {
  id: string
  incidentId: string
  timestamp: number
  errorMessage: string
  component: string
  plea: string
  coachMessage: string
  seerComeback: string
  seerVibe: string
  vibeLabel: string
  banterIndex: number
}

```

### renderer/index.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: nudge: http://localhost:* https:; media-src 'self' blob: nudge: http://localhost:*; connect-src 'self' blob: https:; worker-src 'self' blob:;"
    />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Anchor Vision</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

```

### renderer/overlay.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: nudge: http://localhost:* https:; media-src 'self' blob: nudge: http://localhost:*; connect-src 'self' https://cdn.jsdelivr.net https://storage.googleapis.com https://huggingface.co https://*.huggingface.co blob:;"
    />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Anchor Vision — Nudge</title>
    <style>
      html,
      body,
      #root {
        margin: 0;
        width: 100%;
        height: 100%;
        overflow: hidden;
        background: transparent;
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/overlay/main.tsx"></script>
  </body>
</html>

```

[60 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]