diff --git a/.github/workflows/porter-test.yml b/.github/workflows/porter-test.yml
new file mode 100644
index 0000000..decb2b1
--- /dev/null
+++ b/.github/workflows/porter-test.yml
@@ -0,0 +1,29 @@
+"on":
+ push:
+ branches:
+ - test-porter
+name: Deploy to mentra-notes-test-porter
+jobs:
+ porter-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Set Github tag
+ id: vars
+ run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+ - name: Setup porter
+ uses: porter-dev/setup-porter@v0.1.0
+ - name: Deploy stack
+ timeout-minutes: 30
+ run: exec porter apply -f ./porter.yaml
+ env:
+ PORTER_APP_NAME: mentra-notes-dev
+ PORTER_CLUSTER: "4689"
+ PORTER_DEPLOYMENT_TARGET_ID: 4a24a192-04c8-421f-8fc2-22db1714fdc0
+ PORTER_HOST: https://dashboard.porter.run
+ PORTER_PR_NUMBER: ${{ github.event.number }}
+ PORTER_PROJECT: "15081"
+ PORTER_REPO_NAME: ${{ github.event.repository.name }}
+ PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
+ PORTER_TOKEN: ${{ secrets.PORTER_APP_15081_4689 }}
diff --git a/env.example b/env.example
index e9c2c99..aa62eb5 100644
--- a/env.example
+++ b/env.example
@@ -31,6 +31,25 @@ ANTHROPIC_API_KEY=your_anthropic_api_key_here
MONGODB_URI=mongodb+srv://user:pass@cluster.mongodb.net/notes
+# =============================================================================
+# Cloudflare R2 - For storing transcripts and photos
+# =============================================================================
+
+CLOUDFLARE_R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
+CLOUDFLARE_R2_BUCKET_NAME=mentra-notes
+CLOUDFLARE_R2_ACCESS_KEY_ID=your_r2_access_key
+CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_r2_secret_key
+
+# =============================================================================
+# Email (Resend) - For sending notes and transcripts via email
+# =============================================================================
+
+RESEND_API_KEY=re_your_resend_api_key
+
+# Optional: Override the base URL for download links in emails.
+# If not set, automatically derived from the request Origin/Host header.
+# BASE_URL=https://your-app-domain.com
+
# =============================================================================
# Development Tip
# =============================================================================
diff --git a/figma-design/src/app/views/FolderDetail.tsx b/figma-design/src/app/views/FolderDetail.tsx
index 15fe9fb..2f8a8e0 100644
--- a/figma-design/src/app/views/FolderDetail.tsx
+++ b/figma-design/src/app/views/FolderDetail.tsx
@@ -304,7 +304,7 @@ This note covers the discussion from ${timeRange}.
)}
- {folder.isTrashed ? 'Restore from Trash' : 'Move to Trash'}
+ {folder.isTrashed ? 'Restore from Trash' : 'Trash'}
diff --git a/issues/1-auto-conversation-notes/PLAN.md b/issues/1-auto-conversation-notes/PLAN.md
new file mode 100644
index 0000000..81c83c6
--- /dev/null
+++ b/issues/1-auto-conversation-notes/PLAN.md
@@ -0,0 +1,406 @@
+# Automatic Conversation Notes — Implementation Plan
+
+Owner: Aryan
+Priority: High — Core product differentiator
+
+---
+
+## Overview
+
+The system listens to a continuous transcript stream, classifies chunks as meaningful or filler, tracks conversations in real-time, and generates structured notes when conversations end. Built on a 40-second heartbeat cycle with 5 pipeline stages.
+
+---
+
+## What Already Exists
+
+- `TranscriptManager` — accumulates transcript segments (interim + final), stores daily transcripts, generates hour summaries
+- `DailyTranscript` model — stores segments grouped by user's timezone day
+- `HourSummary` model — AI-generated hourly summaries
+- `Note` model — both manual and AI-generated notes
+- `NotesManager` — AI note generation + manual notes
+- LLM abstraction layer (`services/llm/`) — Gemini + Anthropic support
+- WebSocket sync (`@ballah/synced`) — real-time state broadcast to frontend
+
+---
+
+## Step-by-Step Build Order
+
+### Phase 1: Buffer System & Chunk Storage
+
+**Goal:** Get 40-second chunks flowing and persisting.
+
+#### Step 1.1 — Create Chunk Database Model
+- New file: `src/backend/models/transcript-chunk.model.ts`
+- Schema fields:
+ - `userId: string`
+ - `chunkIndex: number` (sequential per day)
+ - `text: string` (raw transcript text for this 40s window)
+ - `wordCount: number`
+ - `startTime: Date`
+ - `endTime: Date`
+ - `date: string` (YYYY-MM-DD, user's timezone)
+ - `classification: 'pending' | 'filler' | 'meaningful' | 'auto-skipped'`
+ - `conversationId: string | null` (links to which conversation this chunk belongs to, if any)
+ - `metadata: object` (flexible field for debug info)
+- Index on `{ userId, date, chunkIndex }`
+
+#### Step 1.2 — Create ChunkBuffer Class
+- New file: `src/backend/session/managers/ChunkBufferManager.ts`
+- Responsibilities:
+ - Accumulate incoming transcript segments from `TranscriptManager` into a rolling buffer
+ - Every 40 seconds, package buffer contents into a chunk
+ - Handle sentence boundaries — if a sentence is mid-flow at the 40s mark, include the full sentence in the current chunk
+ - Persist every chunk to DB (including filler — needed for Stage 5 safety pass)
+ - Emit event / call callback when a new chunk is ready
+- Uses a `setInterval` (40s) as the heartbeat
+- Reads from `TranscriptManager`'s existing segment accumulation
+
+#### Step 1.3 — Wire ChunkBuffer into NotesSession
+- Modify `src/backend/session/NotesSession.ts`:
+ - Instantiate `ChunkBufferManager`
+ - Feed transcript data from `TranscriptManager` into the buffer
+ - Start/stop the 40s heartbeat with session lifecycle
+
+#### Step 1.4 — Test the Buffer
+- Verify chunks are being created every ~40 seconds
+- Verify sentence boundary handling works
+- Verify chunks persist to MongoDB
+- Verify chunk index increments correctly per day
+
+---
+
+### Phase 2: Triage Classifier (Stage 2)
+
+**Goal:** Classify each chunk as auto-skip, filler, or meaningful.
+
+#### Step 2.1 — Create Triage Classifier
+- New file: `src/backend/services/auto-notes/TriageClassifier.ts`
+- Logic:
+ 1. **Auto-skip check** (no LLM call needed):
+ - Under 4 words AND no high-signal keywords → mark `auto-skipped`
+ - Even short chunks with important keywords go to LLM (e.g., "Cancel the deal.")
+ 2. **LLM classification**:
+ - Send chunk text + domain context to LLM
+ - Prompt returns: `FILLER` or `MEANINGFUL`
+ - If `MEANINGFUL`: pull previous 2 chunks from DB for context, pass to Stage 3
+
+#### Step 2.2 — Create Domain Context Configuration
+- New file: `src/backend/services/auto-notes/domain-config.ts`
+- Define room context profiles:
+ - `medical`: patient names, medications, vitals, procedures
+ - `engineering`: deploy, sprint, bug, migration, deadline
+ - `home`: user-configured keywords
+ - `general`: sensible defaults
+- Store active profile in user settings (extend `UserSettings` model)
+- Inject domain context into every classifier prompt
+
+#### Step 2.3 — Create Configurable Parameters Store
+- New file: `src/backend/services/auto-notes/config.ts`
+- All tunable parameters in one place:
+ ```
+ BUFFER_INTERVAL_MS = 40_000
+ PRE_FILTER_WORD_MIN = 4
+ SILENCE_PAUSE_CHUNKS = 1
+ SILENCE_END_CHUNKS = 3
+ CONTEXT_LOOKBACK_CHUNKS = 2
+ SUMMARY_MAX_WORDS = 300
+ RESUMPTION_WINDOW_MS = 30 * 60 * 1000
+ CHUNK_RETENTION_HOURS = 24
+ ```
+
+#### Step 2.4 — Wire Triage into the Pipeline
+- When `ChunkBufferManager` emits a new chunk:
+ - Run it through `TriageClassifier`
+ - Update chunk's `classification` field in DB
+ - If `MEANINGFUL` → forward to Stage 3 (conversation tracker)
+
+---
+
+### Phase 3: Conversation Tracker (Stage 3)
+
+**Goal:** Track active conversations, handle continuations, new topics, and session end.
+
+#### Step 3.1 — Create Conversation Model
+- New file: `src/backend/models/conversation.model.ts`
+- Schema fields:
+ - `userId: string`
+ - `date: string` (YYYY-MM-DD)
+ - `title: string` (generated after conversation ends)
+ - `status: 'active' | 'paused' | 'ended'`
+ - `startTime: Date`
+ - `endTime: Date | null`
+ - `chunkIds: string[]` (ordered list of chunk IDs in this conversation)
+ - `runningSummary: string` (compressed every 3 chunks)
+ - `pausedAt: Date | null`
+ - `resumedFrom: string | null` (ID of conversation this was resumed from)
+ - `noteId: string | null` (link to generated note)
+
+#### Step 3.2 — Create ConversationTracker
+- New file: `src/backend/services/auto-notes/ConversationTracker.ts`
+- State machine with states: `IDLE`, `TRACKING`, `PAUSED`
+- On each incoming meaningful chunk:
+ - If `IDLE` → start new conversation, transition to `TRACKING`
+ - If `TRACKING` → classify chunk as:
+ - `CONTINUATION` — same topic, append to conversation
+ - `NEW_CONVERSATION` — close current, start new
+ - `FILLER` — transition to `PAUSED` (1 silent chunk = pause)
+ - If `PAUSED`:
+ - Next chunk is on-topic → resume conversation (back to `TRACKING`)
+ - Next chunk is filler → increment silence counter
+ - 3 consecutive silent/filler chunks (2 min) → end conversation permanently
+- LLM prompt for classification includes: current running summary + new chunk + domain context
+
+#### Step 3.3 — Running Summary Compression
+- Every 3 chunks added to a conversation:
+ - Send full running summary + last 3 chunks to LLM
+ - Compress to under 300 words
+ - Preserve: names, numbers, decisions, action items
+ - Update `runningSummary` field on Conversation document
+
+#### Step 3.4 — Resumption Detection
+- When a new meaningful chunk arrives and there's no active conversation:
+ - Check DB for conversations with `status: 'paused'` or `status: 'ended'` in the last 30 minutes
+ - Send chunk + previous conversation's summary to LLM: "Is this a continuation of the previous conversation?"
+ - If yes → reopen that conversation instead of creating a new one
+ - Update `resumedFrom` field
+
+#### Step 3.5 — Wire Tracker into Pipeline
+- Modify the triage output handler:
+ - `MEANINGFUL` chunks → `ConversationTracker.processChunk()`
+ - Tracker manages its own state machine
+ - When conversation ends → trigger Stage 4
+
+---
+
+### Phase 4: Note Generation (Stage 4)
+
+**Goal:** Generate structured notes from completed conversations.
+
+#### Step 4.1 — Create Note Generator
+- New file: `src/backend/services/auto-notes/NoteGenerator.ts`
+- When a conversation ends:
+ 1. Fetch all chunks belonging to this conversation from DB
+ 2. Assemble full transcript text (ordered by chunkIndex)
+ 3. Send to stronger LLM (e.g., Gemini Pro or Claude Sonnet) with prompt to generate:
+ - **Title** (e.g., "Client Deadline Acceleration")
+ - **Participants** (Speaker 1, Speaker 2, etc.)
+ - **Summary** (2-3 paragraph overview)
+ - **Key Points** (bulleted list of facts discussed)
+ - **Decisions Made** (bulleted list)
+ - **Action Items** (with owners if identifiable)
+ 4. Create a `Note` document using existing `Note` model
+ 5. Link note back to Conversation document (`noteId` field)
+ 6. Mark note as `type: 'auto'` to distinguish from manual notes
+
+#### Step 4.2 — Integrate with Existing NotesManager
+- Use existing `NotesManager` to create and sync the note
+- The note should appear in the frontend alongside manual notes
+- Tag auto-generated notes visually (e.g., "Auto" badge)
+
+#### Step 4.3 — Wire Note Generation into Pipeline
+- When `ConversationTracker` ends a conversation → call `NoteGenerator.generate(conversationId)`
+- Handle errors gracefully (LLM timeout, etc.) — retry once, then log and move on
+
+---
+
+### Phase 4B: Frontend — "Conversations" Tab
+
+**Goal:** Surface auto-detected conversations in a dedicated tab on the DayPage, with real-time status indicators.
+
+#### Step 4B.1 — Add Shared Types
+
+- Add to `src/shared/types.ts`:
+ ```typescript
+ interface Conversation {
+ id: string;
+ userId: string;
+ date: string;
+ title: string;
+ status: 'active' | 'paused' | 'ended';
+ startTime: Date;
+ endTime: Date | null;
+ runningSummary: string;
+ chunks: ConversationChunk[];
+ note: Note | null; // populated after note generation
+ generatingNote: boolean; // true while LLM is producing the note
+ }
+
+ interface ConversationChunk {
+ id: string;
+ text: string;
+ startTime: Date;
+ endTime: Date;
+ wordCount: number;
+ }
+ ```
+- Add `ConversationManagerI` interface:
+ ```typescript
+ interface ConversationManagerI {
+ state: {
+ conversations: Conversation[];
+ activeConversationId: string | null;
+ };
+ }
+ ```
+
+#### Step 4B.2 — Create Backend ConversationManager
+
+- New file: `src/backend/session/managers/ConversationManager.ts`
+- Exposes conversations for the current day via `@ballah/synced`
+- Syncs state to frontend in real-time:
+ - When `ConversationTracker` starts a new conversation → push to `conversations` array, set `activeConversationId`
+ - When new chunks arrive → append to active conversation's `chunks`
+ - When conversation is paused/ended → update `status`
+ - When note generation starts → set `generatingNote: true`
+ - When note generation finishes → attach `note`, set `generatingNote: false`
+- Wire into `NotesSession.ts` alongside existing managers
+
+#### Step 4B.3 — Add "Conversations" Tab to DayPage
+
+- Modify `src/frontend/pages/day/DayPage.tsx`:
+ - Add to `TabType`: `"conversations"`
+ - Add to `tabs` array: `{ id: "conversations", label: "Conversations", icon: MessagesSquare }`
+ - Place between Transcript and Notes tabs (order: Transcript, Conversations, Notes)
+- Access data: `const conversations = session?.conversation?.conversations ?? [];`
+
+#### Step 4B.4 — Create ConversationsTab Component
+
+- New file: `src/frontend/pages/day/components/tabs/ConversationsTab.tsx`
+- **List view:** Shows conversation cards for the selected day, ordered by startTime (newest first)
+- **Each card shows:**
+ - Title (or "Untitled Conversation" while active/before note generation)
+ - Time range (e.g., "2:14 PM – 2:38 PM")
+ - Status badge: "Live" (green pulse) / "Paused" / "Ended"
+ - Summary preview (first ~120 chars of runningSummary or note summary)
+- **Tap a card → expand inline or navigate to detail view showing:**
+ - **Section 1: Summary** — the full structured note (title, participants, summary, key points, decisions, action items)
+ - **Section 2: Full Transcript** — all chunks concatenated in order with timestamps
+- **Empty state:** Friendly message like "No conversations detected yet today"
+
+#### Step 4B.5 — Real-Time "Generating Note" Status Bar
+
+- When a conversation ends and note generation begins, show a **status banner** at the top of the Conversations tab:
+ - Banner text: "Generating conversation note..." with a subtle loading animation
+ - Stays visible while `generatingNote: true` on any conversation
+ - Disappears once the note is attached
+- Also show inline on the conversation card itself: replace the status badge with "Generating note..." indicator
+- This gives the user immediate feedback that the system detected something and is processing it
+
+#### Step 4B.6 — Active Conversation Live Indicator
+
+- While a conversation is `active` (being tracked in real-time):
+ - The card has a green "Live" badge with a subtle pulse animation
+ - The running summary updates in real-time as new chunks come in
+ - The transcript section (if expanded) shows chunks appearing live
+- While `paused`: show an amber "Paused" badge — the system is waiting to see if the conversation resumes
+- This makes the whole pipeline visible to the user — they can see the system is listening and deciding
+
+---
+
+### Phase 5: Safety Pass (Stage 5) — DO LATER, ONLY IF NEEDED
+
+**Goal:** End-of-day review to catch missed conversations and discard false positives.
+
+#### Step 5.1 — Create Safety Pass Service
+- New file: `src/backend/services/auto-notes/SafetyPass.ts`
+- Runs once at end of day (triggered by `TimeManager` EOD detection)
+- **Job A — Review Captured Conversations:**
+ - For each conversation captured today: send summary + sample chunks to LLM
+ - LLM returns: `KEEP` or `DISCARD`
+ - Discarded conversations: mark note as `discarded: true` (soft delete)
+- **Job B — Scan Filler for Missed Conversations:**
+ - Fetch all chunks for the day classified as `filler` or `auto-skipped`
+ - Send sequential batches to LLM: "Do any of these consecutive filler chunks actually form a meaningful conversation?"
+ - If yes → run those chunks through Stage 4 (note generation)
+ - Also check for fragmentation: two separate conversations that are actually the same topic → flag for merge
+
+---
+
+### Phase 6: Feedback Loop — DO LATER
+
+**Goal:** Collect user feedback to improve classification over time.
+
+#### Step 6.1 — Add Feedback UI
+- Add thumbs-up / thumbs-down buttons to auto-generated note cards in frontend
+- New field on `Note` model: `feedback: 'positive' | 'negative' | null`
+- New field on `Note` model: `feedbackComment: string | null` (optional text)
+
+#### Step 6.2 — Create Feedback Model / Extend Note Model
+- Store feedback in the Note document itself (simplest approach)
+- API endpoint: `PATCH /api/notes/:id/feedback` with `{ rating: 'positive' | 'negative', comment?: string }`
+
+#### Step 6.3 — Log Feedback
+- Just persist to DB for now — data collection only
+- No action taken on feedback yet (Phase 2 of feedback loop)
+
+---
+
+## File Map (New Files)
+
+```
+src/backend/
+├── models/
+│ ├── transcript-chunk.model.ts (Phase 1)
+│ └── conversation.model.ts (Phase 3)
+├── services/
+│ └── auto-notes/
+│ ├── config.ts (Phase 2)
+│ ├── domain-config.ts (Phase 2)
+│ ├── TriageClassifier.ts (Phase 2)
+│ ├── ConversationTracker.ts (Phase 3)
+│ ├── NoteGenerator.ts (Phase 4)
+│ └── SafetyPass.ts (Phase 5)
+└── session/
+ └── managers/
+ ├── ChunkBufferManager.ts (Phase 1)
+ └── ConversationManager.ts (Phase 4B)
+
+src/frontend/
+└── pages/
+ └── day/
+ └── components/
+ └── tabs/
+ └── ConversationsTab.tsx (Phase 4B)
+```
+
+## Files to Modify
+
+```
+src/backend/session/NotesSession.ts — instantiate ChunkBufferManager + ConversationManager, wire pipeline
+src/backend/models/note.model.ts — add feedback fields, auto-note type
+src/backend/models/user-settings.model.ts — add domain context profile
+src/shared/types.ts — add Conversation, ConversationChunk, ConversationManagerI types
+src/frontend/pages/day/DayPage.tsx — add "Conversations" tab to TabType and tabs array
+src/frontend/ (NoteCard, DayPage) — auto badge + feedback buttons (Phase 6)
+```
+
+---
+
+## Edge Cases & Risks to Handle
+
+### 1. Sentence Boundary — Max Wait Cap
+The buffer waits for a sentence to finish at the 40s mark, but a long rambling sentence could delay the chunk indefinitely. **Cap the wait at 10 extra seconds** (50s max chunk). If the sentence still isn't done, cut it — the next chunk will pick up the remainder.
+
+### 2. Server Crash Recovery
+The `ConversationTracker` state machine lives in memory. If the server restarts mid-conversation, that state is lost. **On startup:** check DB for conversations with `status: 'active'` or `status: 'paused'` for this user. Reconstruct tracker state from the conversation document (runningSummary, chunkIds, pausedAt) and resume tracking.
+
+### 3. Resumption — Only Resume Paused, Not Ended
+The resumption window (30 min) should only apply to `status: 'paused'` conversations. A conversation that reached `ended` (3 consecutive silent chunks / 2 minutes of silence) should stay ended. Creating a new conversation is the correct behavior in that case.
+
+### 4. Chunk Retention Cleanup
+Configurable parameter says 24-hour retention, but no cleanup job is defined. **Add a daily cleanup task** (can run alongside the Safety Pass or as a separate scheduled job) that deletes chunks older than the retention window. Conversations and their notes persist — only raw chunks are cleaned up.
+
+### 5. Note Generation Failure
+If the LLM call for note generation fails (timeout, rate limit, etc.): retry once after 5 seconds. If it still fails, mark the conversation with `noteGenerationFailed: true` and log it. The user sees a "Note generation failed" state on the card. Provide a manual "Retry" button in the frontend.
+
+---
+
+## Immediate First Steps (Start Here)
+
+1. Create `src/backend/services/auto-notes/config.ts` with all configurable parameters
+2. Create `src/backend/models/transcript-chunk.model.ts`
+3. Create `src/backend/session/managers/ChunkBufferManager.ts`
+4. Wire `ChunkBufferManager` into `NotesSession.ts`
+5. Test that chunks are flowing and persisting every 40 seconds
+
+Once the buffer is solid, move to the triage classifier (Phase 2).
diff --git a/issues/10-homepage-conversations-ui/PLAN.md b/issues/10-homepage-conversations-ui/PLAN.md
new file mode 100644
index 0000000..c0035c5
--- /dev/null
+++ b/issues/10-homepage-conversations-ui/PLAN.md
@@ -0,0 +1,186 @@
+# Homepage Conversations UI Refactor
+
+Owner: Aryan
+Priority: Medium — UI-only, no schema/backend changes
+
+---
+
+## Overview
+
+Redesign the HomePage from a **day-based folder list** to a **conversation-based list** matching the new Paper designs. The core change is: instead of grouping by day and showing folder cards, we show individual conversations grouped by day sections (Today, Yesterday, date headers). No backend or schema changes — we already have `ConversationManagerI` with `Conversation[]` on the session.
+
+---
+
+## What Already Exists (DO NOT TOUCH)
+
+- `ConversationManagerI` — exposes `conversations: Conversation[]` and `activeConversationId`
+- `Conversation` interface — has `id`, `title`, `status`, `startTime`, `endTime`, `chunks`, `runningSummary`, `aiSummary`, `generatingSummary`
+- `FileManagerI` — existing file/folder state (keep for filter logic, archive, trash)
+- `TranscriptManager.isRecording` — recording status
+- All backend managers, models, RPCs — untouched
+- Router structure — untouched
+
+---
+
+## Design States (from Paper)
+
+### State 1: Empty (no conversations)
+- Header: "MENTRA NOTES" brand label (red, uppercase, tracking-widest) + "Conversations" title (30px extrabold)
+- Subtitle: "No conversations yet"
+- Center: chat bubble icon in rounded square + "Start a conversation" message + description text
+- Bottom pill: "Microphone active · Listening" (red indicator, shown when `isRecording`)
+- Top-right: overflow menu button (3 dots + collapse)
+- FAB: red "+" button (bottom-right, above tab bar)
+- Tab bar: Conversations (active/filled), Search, Notes, Settings
+
+### State 2: Populated conversations list
+- Same header with "Conversations" title
+- **Filter bar**: filter icon button + list/calendar view toggle (pill segmented control)
+- **Filter pills**: "All" (active/dark) and "Today" (inactive/gray)
+- **Day section headers**: "TODAY", "YESTERDAY", "FRI MAR 6" (uppercase, tracking-widest, muted)
+- **Conversation rows** (each row):
+ - Left: time (hour:minute bold) + AM/PM below
+ - Center: conversation title (16px semibold) + metadata row (duration badge "16 min" + speaker names "You, Sarah, Mike")
+ - Right: chevron
+ - Active conversation: red time + red "Transcribing now" badge with audio bars animation + red bottom border
+- FAB + top-right menu + tab bar (same as empty state)
+
+### State 3: Swipe-to-manage (conversation row)
+- Swipe left reveals: Archive (black bg) + Delete (red bg) action buttons
+- Shows icon + label for each action
+
+### State 4: FAB expanded (floating action menu)
+- FAB becomes X (close)
+- Stacked action pills above FAB:
+ - "Ask AI" + star icon (white bg, shadow)
+ - "Add manual note" + document icon (white bg, shadow)
+ - "Stop transcribing" + mic icon (red bg — only when recording)
+
+---
+
+## Data Mapping
+
+| Design element | Source | Notes |
+|---|---|---|
+| Conversation title | `conversation.title` | From ConversationManagerI |
+| Time (2:10 PM) | `conversation.startTime` | Format with date-fns |
+| Duration (16 min) | `conversation.startTime` + `conversation.endTime` | Calculate diff, null endTime = active |
+| "Transcribing now" | `conversation.status === "active"` | Red styling + audio bars |
+| Speaker names | Not in schema yet | Use placeholder or omit for now |
+| Day grouping | `conversation.date` | Group by YYYY-MM-DD, compare to today/yesterday |
+| "All" / "Today" filter | Frontend-only state | Filter conversations by date |
+| Archive/Delete swipe | `file.archiveFile()` / existing trash RPCs | Wire to existing FileManager RPCs |
+| "Ask AI" action | Opens GlobalAIChat | Already exists |
+| "Add manual note" | Navigate to `/day/{today}/note/new` or equivalent | Existing flow |
+| "Stop transcribing" | Existing transcript stop RPC | Only shown when recording |
+| Microphone active pill | `session.transcript.isRecording` | Already available |
+
+---
+
+## Implementation Steps
+
+### Step 1: Create ConversationList component
+**File:** `src/frontend/pages/home/components/ConversationList.tsx`
+
+Replace `FolderList` usage in HomePage with a new `ConversationList` that:
+- Takes `conversations: Conversation[]`, `isRecording: boolean`, `onSelectConversation`
+- Groups conversations by day (Today / Yesterday / formatted date)
+- Renders day section headers (uppercase, tracking-widest, muted text)
+- Renders conversation rows with: time | title + metadata | chevron
+- Active conversation row gets red styling + "Transcribing now" badge
+- Keep the existing `FolderList` file intact (don't delete it — may be needed for folder view mode)
+
+### Step 2: Create ConversationRow component
+**File:** `src/frontend/pages/home/components/ConversationRow.tsx`
+
+Individual conversation row:
+- Time column (fixed width): formatted hour:minute + AM/PM
+- Content column (flex grow): title + metadata row (duration pill + speaker count placeholder)
+- Chevron right
+- Active state: red text color for time, red "Transcribing now" badge with animated audio bars
+- Swipe-to-reveal: Archive + Delete action buttons (use touch events or a swipe library)
+
+### Step 3: Update HomePage header
+**File:** `src/frontend/pages/home/HomePage.tsx`
+
+- Replace current header with new design:
+ - Brand label: "MENTRA NOTES" (red, 11px, uppercase, tracking-widest, Red Hat Display font)
+ - Title: "Conversations" (30px, extrabold, Red Hat Display)
+ - Subtitle: count text like "Today · 6 conversations" (only when populated)
+- Right side: filter button + list/calendar segmented control toggle
+- Add filter pills row below header: "All" (active) / "Today"
+- Top-right: overflow/collapse menu (absolute positioned)
+
+### Step 4: Update empty state
+**File:** `src/frontend/pages/home/HomePage.tsx`
+
+Match Paper empty design:
+- Centered chat bubble icon in rounded square (64px)
+- "Start a conversation" bold heading
+- Description text about background listening
+- "Microphone active · Listening" pill (red, shown when `isRecording`)
+
+### Step 5: Create FAB menu component
+**File:** `src/frontend/pages/home/components/FABMenu.tsx`
+
+- Default: red "+" FAB button (bottom-right, 52px, rounded-2xl, red shadow)
+- Expanded: X close button + stacked action pills animating in from below
+ - "Ask AI" — triggers GlobalAIChat
+ - "Add manual note" — navigates to note creation
+ - "Stop transcribing" — only shown when recording, stops transcript
+- Backdrop overlay when expanded
+- Smooth animation (motion/react)
+
+### Step 6: Wire up HomePage to use conversations
+**File:** `src/frontend/pages/home/HomePage.tsx`
+
+- Read `session.conversation.conversations` instead of (or alongside) `session.file.files`
+- Pass conversations to ConversationList
+- Handle conversation selection → navigate to conversation detail or day page
+- Keep existing filter/view logic working alongside new conversation view
+
+### Step 7: Add tab bar
+**File:** `src/frontend/components/shared/TabBar.tsx` (or in layout)
+
+- Fixed bottom bar: Conversations, Search, Notes, Settings
+- Active state: filled icon + semibold label (dark)
+- Inactive: outlined icon + medium label (muted)
+- This may already exist or be part of the router layout — check before creating
+
+---
+
+## Design Tokens (from Paper)
+
+```
+Font: Red Hat Display, system-ui, sans-serif
+Background: #FAFAF9 (warm off-white)
+Text primary: #1C1917
+Text muted: #A8A29E
+Text secondary: #78716C
+Accent red: #DC2626
+Accent red light: #FEE2E2
+Active bg dark: #1C1917
+Surface: #F5F5F4
+Border: #E7E5E4
+Border active: #FEE2E2 (red tint for active conversation)
+
+Brand label: 11px, uppercase, tracking-widest, bold, red
+Page title: 30px, -0.03em tracking, extrabold
+Section header: 11px, uppercase, tracking-widest, bold, muted
+Conversation title: 16px, semibold
+Time: 14px, semibold
+Duration badge: 11px, medium, in rounded pill with #F5F5F4 bg
+Tab label: 10px, active=semibold, inactive=medium
+```
+
+---
+
+## What NOT to Do
+
+- Do NOT change any backend code, schemas, or models
+- Do NOT change the router structure
+- Do NOT remove existing components — add new ones alongside
+- Do NOT implement conversation detection logic (that's issue #1)
+- Do NOT implement speaker identification (not in schema yet)
+- Do NOT add new RPCs or manager methods
+- Do NOT change the sync/websocket layer
diff --git a/issues/11-interim-word-threshold/PLAN.md b/issues/11-interim-word-threshold/PLAN.md
new file mode 100644
index 0000000..70c6e29
--- /dev/null
+++ b/issues/11-interim-word-threshold/PLAN.md
@@ -0,0 +1,146 @@
+# Interim Word Threshold — Force-Finalize Long Interim Segments
+
+Owner: Aryan
+Priority: Medium — UX reliability for continuous speech
+Related: `issues/2-interim-fallback` (timeout-based, different trigger)
+
+---
+
+## Problem
+
+When a user speaks continuously without pausing, the speech recognition engine may not emit an `isFinal` segment for a long time. The interim text keeps growing unboundedly — a single interim can become hundreds of words. This causes:
+
+1. **UI issue**: The interim text blob on the frontend keeps growing, making it hard to read
+2. **Data loss risk**: If the connection drops mid-interim, all that text is lost (only final segments are persisted)
+3. **Chunk pipeline starvation**: The auto-notes chunk buffer only receives final segments, so a 2-minute monologue produces zero chunks
+
+---
+
+## Solution: Word Count Threshold on Interim Text
+
+When interim text exceeds a word count threshold (50 words), force-finalize it:
+
+1. Take the current interim text and emit it as a final segment
+2. Reset the interim buffer
+3. The next interim from the speech engine will contain **all** accumulated text (old + new), but we only keep the **new portion** (everything after what we already finalized)
+
+This creates a stream of reasonably-sized final segments even during continuous speech.
+
+---
+
+## Key Design Decision: Cumulative Word Count Stripping
+
+The speech recognition engine doesn't know we force-finalized. It will keep sending full cumulative interim text that includes words we already finalized. We strip the prefix using a **cumulative word count** — we track how many words have been force-finalized so far, and skip that many words from each new interim.
+
+This is simpler and more reliable than text comparison, since the speech engine may revise earlier words.
+
+---
+
+## Files to Modify
+
+```
+src/backend/session/managers/TranscriptManager.ts — add threshold logic in addSegment()
+```
+
+---
+
+## Implementation Steps
+
+### Step 1 — Add Threshold Config
+
+In `TranscriptManager.ts`, add a constant:
+
+```ts
+const INTERIM_WORD_THRESHOLD = 50; // Force-finalize after this many words
+```
+
+### Step 2 — Track Force-Finalized Word Count
+
+Add instance variables to `TranscriptManager`:
+
+```ts
+private _forceFinalizedWordCount: number = 0; // Cumulative words force-finalized
+```
+
+### Step 3 — Modify `addSegment()` Interim Handling
+
+Current logic (simplified):
+```ts
+if (!isFinal) {
+ this.interimText = text;
+ return;
+}
+```
+
+New logic:
+```ts
+if (!isFinal) {
+ // Strip already-finalized words from the interim using word count
+ const interimWords = text.trim().split(/\s+/);
+ const cleanWords = interimWords.slice(this._forceFinalizedWordCount);
+ const cleanInterim = cleanWords.join(" ");
+
+ if (cleanWords.length >= INTERIM_WORD_THRESHOLD) {
+ // Force-finalize: treat this interim as a final segment
+ console.log(`[TranscriptManager] Force-finalizing interim (${cleanWords.length} words)`);
+ this._forceFinalizedWordCount = interimWords.length; // Track total words finalized
+ // Fall through to final segment handling with cleanInterim as the text
+ // ... (create segment with isFinal: true, cleanInterim as text)
+ } else {
+ this.interimText = cleanInterim;
+ return;
+ }
+}
+```
+
+### Step 4 — Reset on Final Segment
+
+When a real `isFinal` segment arrives from the speech engine, reset the tracking:
+
+```ts
+if (isFinal) {
+ this._forceFinalizedWordCount = 0; // Reset — speech engine finalized naturally
+ // ... existing final handling
+}
+```
+
+### Step 5 — Reset on Recording Stop
+
+In `stopRecording()` or equivalent, reset:
+```ts
+this._forceFinalizedWordCount = 0;
+```
+
+---
+
+## Edge Cases
+
+### 1. User pauses briefly then continues
+The speech engine may emit a final for the first part, then start fresh interims for the continuation. `_forceFinalizedWordCount` gets reset on real finals, so this works naturally.
+
+### 2. Force-finalized segment overlaps with a late real final
+If the speech engine finally emits a real `isFinal` that covers the same text we already force-finalized, we'd get duplicates.
+
+**Mitigation**: When a real final arrives and `_forceFinalizedWordCount > 0`, check if its text substantially overlaps with what we already force-finalized. If >70% word overlap, skip it and just reset the counter.
+
+### 3. Connection drops mid-interim
+With the threshold in place, we force-finalize every ~50 words, so at most ~50 words of speech would be lost on disconnect instead of potentially minutes of speech.
+
+---
+
+## Testing Checklist
+
+- [ ] Normal speech with natural pauses: verify force-finalize never triggers (pauses produce real finals before 50 words)
+- [ ] Continuous monologue (no pauses): verify segments are force-finalized every ~50 words
+- [ ] Verify stripped text doesn't include duplicates from previous force-finalized segments
+- [ ] Verify real finals after force-finalized segments don't create duplicates
+- [ ] Verify chunk buffer receives the force-finalized segments correctly
+- [ ] Verify UI shows clean interim text (not growing unboundedly)
+
+---
+
+## Config Summary
+
+| Parameter | Value | Purpose |
+|-----------|-------|---------|
+| `INTERIM_WORD_THRESHOLD` | 50 words | Force-finalize interim text after this many words |
diff --git a/issues/12-swipe-to-action-fix/PLAN.md b/issues/12-swipe-to-action-fix/PLAN.md
new file mode 100644
index 0000000..c824379
--- /dev/null
+++ b/issues/12-swipe-to-action-fix/PLAN.md
@@ -0,0 +1,120 @@
+# Issue 12: Swipe-to-Action — Fix Janky Gesture Handling
+
+## Problem
+
+The swipe-to-reveal-actions gesture on ConversationRow and NoteRow is broken on mobile:
+
+1. **Fast swipes cause jittering** — Quick left/right flicks make the row jump erratically between open/closed states
+2. **Hold-and-release snaps back** — Dragging left past the threshold and releasing causes the row to snap back to closed instead of staying open
+3. **Conflicts with vertical scroll** — `dragDirectionLock` isn't enough; small diagonal gestures still trigger horizontal drag
+4. **Action buttons bleed through** — NoteRow wrapper is missing `overflow-hidden`, so delete button shows through at rest
+5. **`dragSnapToOrigin` fights `animate`** — Both try to control the `x` position, causing the row to rubber-band back even when `isSwiped` is true
+
+## Root Cause
+
+The current approach uses Framer Motion's `drag="x"` with `dragSnapToOrigin`, `dragDirectionLock`, and `animate` all competing to control position. This creates conflicts:
+
+- `dragSnapToOrigin` always wants to return to 0
+- `animate` wants to go to `-146` when swiped
+- These fight each other, causing the jitter on fast gestures
+- `dragDirectionLock` doesn't have a dead zone, so tiny movements trigger drag
+
+## Solution: Drop Framer Motion drag, use native touch events
+
+Replace the Framer Motion drag system with manual `onTouchStart`/`onTouchMove`/`onTouchEnd` handlers. This gives full control over the gesture lifecycle with no conflicting animation systems.
+
+### Architecture
+
+Create a shared `useSwipeToReveal` hook used by both ConversationRow and NoteRow.
+
+```
+src/frontend/hooks/useSwipeToReveal.ts ← new shared hook
+```
+
+### Hook API
+
+```ts
+const { containerRef, rowStyle, handlers } = useSwipeToReveal({
+ openDistance: 146, // how far to slide open (button widths)
+ threshold: 0.3, // 30% of openDistance to trigger
+ deadZone: 10, // px of movement before deciding axis
+ onOpen?: () => void,
+ onClose?: () => void,
+});
+```
+
+### Implementation Steps
+
+#### Step 1: Create `useSwipeToReveal` hook
+
+The hook manages three phases of touch:
+
+**Phase 1 — Dead zone (first 10px of movement)**
+- Track `touchStart` x/y position
+- Do NOT move the row yet
+- Once total movement exceeds `deadZone` (10px), decide axis:
+ - If `|deltaY| > |deltaX|` → vertical scroll, abort horizontal gesture entirely
+ - If `|deltaX| > |deltaY|` → lock to horizontal, prevent scroll with `e.preventDefault()`
+
+**Phase 2 — Tracking (finger is moving horizontally)**
+- Update `translateX` via `useMotionValue` (for action button opacity transforms)
+- Clamp between `-openDistance` and `0` (no overscroll, no rightward drag)
+- No animation during this phase — raw 1:1 finger tracking
+
+**Phase 3 — Release (touchend)**
+- If `|totalDeltaX| > openDistance * threshold` → animate to `-openDistance` (open)
+- Else → animate to `0` (closed)
+- Use `animate(x, target, { type: "tween", duration: 0.2, ease: "easeOut" })` from Framer Motion's imperative API
+- Start auto-close timer if opened
+
+**States:**
+- `idle` → not being touched, position is either 0 or -openDistance
+- `tracking` → finger is down and we've locked to horizontal
+- `settling` → finger released, animating to final position
+
+#### Step 2: Update ConversationRow
+
+- Remove: `drag`, `dragDirectionLock`, `dragSnapToOrigin`, `dragConstraints`, `dragElastic`, `onDragStart`, `onDragEnd`
+- Keep: `motion.div` with `style={{ x }}` and action button opacity transforms
+- Add: `onTouchStart`, `onTouchMove`, `onTouchEnd` from the hook
+- Keep: `onTap` → replaced with `onClick` that checks `isDragging`
+- Keep: `overflow-hidden` on wrapper
+
+#### Step 3: Update NoteRow
+
+- Same changes as ConversationRow
+- Add `overflow-hidden` back to the wrapper div (currently missing)
+
+#### Step 4: Handle edge cases
+
+| Edge case | How to handle |
+|---|---|
+| Tap (no movement) | Dead zone never exceeded → fire `onSelect` |
+| Fast flick | Use velocity check: if `velocity.x < -300` and direction is left, treat as open regardless of distance |
+| Swipe right to close | If already open, allow rightward drag; if `deltaX > threshold` from open position → close |
+| Multiple rows open | Optional: emit event so parent closes other open rows (not required for MVP) |
+| Scroll while swiped open | Auto-close on scroll (attach scroll listener to parent) |
+
+### Files to Modify
+
+| File | Change |
+|---|---|
+| `src/frontend/hooks/useSwipeToReveal.ts` | **New** — shared hook |
+| `src/frontend/pages/home/components/ConversationRow.tsx` | Replace drag props with hook |
+| `src/frontend/pages/notes/NoteRow.tsx` | Replace drag props with hook, add `overflow-hidden` |
+
+### Testing Checklist
+
+- [ ] Slow drag left past 30% → opens smoothly, stays open
+- [ ] Slow drag left under 30% → snaps back smoothly
+- [ ] Fast flick left → opens
+- [ ] Fast flick right on open row → closes
+- [ ] Tap on closed row → navigates (no swipe triggered)
+- [ ] Tap on open row → closes row
+- [ ] Vertical scroll through list → no horizontal movement on any row
+- [ ] Diagonal gesture → no jitter, picks one axis
+- [ ] Action buttons not visible at rest
+- [ ] Archive button works when revealed
+- [ ] Delete button works when revealed
+- [ ] Auto-close after 6 seconds
+- [ ] No bounce/jitter on any gesture
diff --git a/issues/13-folders-system/PLAN.md b/issues/13-folders-system/PLAN.md
new file mode 100644
index 0000000..b9474ef
--- /dev/null
+++ b/issues/13-folders-system/PLAN.md
@@ -0,0 +1,204 @@
+Go for it.# Issue 13: Folders System — Notes Organization
+
+## Overview
+
+Add a folder system to organize notes. Users can create folders with a name and color, and assign any note to exactly one folder. Every new user gets two default folders: "Personal" and "Work". Folders are manageable (rename, delete, change color). Notes are assigned to folders via a dropdown selector on the note editor page.
+
+## Data Model
+
+### Folder Schema (`folder.model.ts`)
+
+```ts
+{
+ id: string; // unique ID
+ userId: string; // owner
+ name: string; // e.g. "Work Notes"
+ color: "red" | "gray" | "blue"; // one of 3 options
+ createdAt: Date;
+ updatedAt: Date;
+}
+```
+
+**MongoDB collection:** `folders`
+
+**Color hex mapping:**
+- `red` → `#DC2626`
+- `gray` → `#78716C`
+- `blue` → `#2563EB`
+
+### Note Schema Change
+
+Add optional `folderId` field to existing `Note` interface:
+
+```ts
+// in shared/types.ts — Note interface
+folderId?: string; // ID of folder this note belongs to (null = no folder)
+```
+
+Also add to the Mongoose note model schema.
+
+## Default Folders
+
+On first session creation (or when user has 0 folders), auto-create:
+
+| Name | Color |
+|------|-------|
+| Personal | blue |
+| Work | red |
+
+**Where:** In `NotesManager` or a new `FoldersManager` initialization, triggered during session setup. Check `folders.countDocuments({ userId })` — if 0, seed defaults.
+
+## Backend Implementation
+
+### New Files
+
+| File | Purpose |
+|------|---------|
+| `src/backend/models/folder.model.ts` | Mongoose schema + CRUD functions: `createFolder`, `getFolders`, `updateFolder`, `deleteFolder` |
+| `src/backend/session/managers/FoldersManager.ts` | Synced manager exposing folders state + methods to frontend |
+
+### Model Functions (`folder.model.ts`)
+
+```ts
+createFolder(userId: string, name: string, color: FolderColor): Promise
+getFolders(userId: string): Promise
+updateFolder(userId: string, folderId: string, updates: { name?: string; color?: FolderColor }): Promise
+deleteFolder(userId: string, folderId: string): Promise
+// When a folder is deleted, unset folderId on all notes that reference it
+```
+
+### FoldersManager (synced manager)
+
+Exposes to frontend via sync system:
+- **State:** `folders: Folder[]`
+- **Methods:** `createFolder(name, color)`, `updateFolder(folderId, updates)`, `deleteFolder(folderId)`
+
+Register in session setup alongside existing managers (NotesManager, ConversationManager, etc.)
+
+### Note Model Changes
+
+- Add `folderId` to note Mongoose schema (optional String, default null)
+- Add/update model function: `updateNote` already exists — ensure it can set `folderId`
+- When a folder is deleted, run: `Note.updateMany({ userId, folderId }, { $unset: { folderId: 1 } })`
+
+### Shared Types (`shared/types.ts`)
+
+```ts
+type FolderColor = "red" | "gray" | "blue";
+
+interface Folder {
+ id: string;
+ name: string;
+ color: FolderColor;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+// Add to SessionI or create FoldersSyncedI:
+interface FoldersSyncedI {
+ folders: Folder[];
+ createFolder(name: string, color: FolderColor): Promise;
+ updateFolder(folderId: string, updates: { name?: string; color?: FolderColor }): Promise;
+ deleteFolder(folderId: string): Promise;
+}
+```
+
+Add `folderId?: string` to existing `Note` interface.
+
+## Frontend Implementation
+
+### 1. Note Editor — Folder Dropdown (`/note/:id`)
+
+Add a folder selector bar at the bottom of the note editor (above keyboard area or below content).
+
+**Collapsed state (from Paper design):**
+```
+┌─────────────────────────────────┐
+│ 📁 No folder ▼ │
+└─────────────────────────────────┘
+```
+- Rounded pill, `bg-[#F5F5F4]`, folder icon + label + chevron down
+- Label shows current folder name or "No folder"
+
+**Expanded state (dropdown):**
+- List of all user folders, each showing: color dot + folder name
+- "No folder" option at top to unassign
+- Tapping a folder calls `session.notes.updateNote(noteId, { folderId })` and closes dropdown
+
+**File:** Add `FolderPicker` component at `src/frontend/pages/note/FolderPicker.tsx`
+
+### 2. CollectionsPage Updates
+
+Replace placeholder `PLACEHOLDER_FOLDERS` with real data from `session.folders.folders`.
+
+- Folder cards show real name, color bar, and note count
+- Note count: derive from `notes.filter(n => n.folderId === folder.id).length`
+- "New Folder" button → opens a create folder modal/sheet
+
+### 3. Create Folder Flow
+
+Simple inline creation (modal or bottom sheet):
+- Text input for name
+- 3 color swatches to pick from (red, gray, blue)
+- "Create" button
+- Calls `session.folders.createFolder(name, color)`
+
+**File:** `src/frontend/pages/notes/CreateFolderSheet.tsx`
+
+### 4. Folder Detail Page (`/folder/:id`)
+
+Reuses the NotesPage layout but filtered:
+- Header: folder icon (colored) + folder name as title
+- Subtitle: "X notes"
+- List: only notes where `note.folderId === folderId`
+- Same NoteRow components with swipe-to-delete/archive
+- Edit button in header → rename/change color/delete folder
+
+**File:** `src/frontend/pages/notes/FolderPage.tsx`
+**Route:** `/folder/:id`
+
+### 5. Folder Management (Edit/Delete)
+
+On FolderPage header, an edit/settings button opens options:
+- Rename folder (text input)
+- Change color (3 swatches)
+- Delete folder (confirmation prompt — notes are unassigned, not deleted)
+
+## File Changes Summary
+
+### New Files
+
+| File | Description |
+|------|-------------|
+| `src/backend/models/folder.model.ts` | Mongoose model + CRUD |
+| `src/backend/session/managers/FoldersManager.ts` | Synced manager |
+| `src/frontend/pages/note/FolderPicker.tsx` | Dropdown on note editor |
+| `src/frontend/pages/notes/CreateFolderSheet.tsx` | Create folder modal |
+| `src/frontend/pages/notes/FolderPage.tsx` | Folder detail view |
+
+### Modified Files
+
+| File | Change |
+|------|--------|
+| `src/shared/types.ts` | Add `Folder`, `FolderColor`, `FoldersSyncedI`, add `folderId?` to `Note` |
+| `src/backend/models/index.ts` | Export folder model functions |
+| `src/backend/models/note.model.ts` | Add `folderId` to schema |
+| `src/backend/session/managers/NotesManager.ts` | Ensure `updateNote` handles `folderId` |
+| `src/backend/session/Session.ts` (or equivalent) | Register FoldersManager |
+| `src/frontend/router.tsx` | Add `/folder/:id` route |
+| `src/frontend/pages/notes/CollectionsPage.tsx` | Replace placeholders with real folder data |
+| `src/frontend/pages/notes/NotesFABMenu.tsx` | Wire "Create folder" to open CreateFolderSheet |
+| `src/frontend/pages/note/NotePage.tsx` | Add FolderPicker component |
+
+## Implementation Order
+
+1. **Backend model** — `folder.model.ts` with Mongoose schema + CRUD
+2. **Shared types** — Add `Folder`, `FolderColor` types, `folderId` to `Note`
+3. **Note model update** — Add `folderId` field to note schema
+4. **FoldersManager** — Synced manager with default folder seeding
+5. **Register manager** — Wire into session setup
+6. **FolderPicker** — Dropdown component on NotePage
+7. **CollectionsPage** — Replace placeholders with real data
+8. **CreateFolderSheet** — Create folder UI
+9. **FolderPage** — Folder detail view with filtered notes
+10. **Folder management** — Rename, recolor, delete actions
diff --git a/issues/14-notes-filter-system/PLAN.md b/issues/14-notes-filter-system/PLAN.md
new file mode 100644
index 0000000..19a22fc
--- /dev/null
+++ b/issues/14-notes-filter-system/PLAN.md
@@ -0,0 +1,235 @@
+# Issue 14: Notes Filter System — Trash, Archive, Favourites, Sort, AI/Manual
+
+## Overview
+
+Add the same filter/sort system from conversations to notes. Users can favourite, archive, and trash individual notes. Filter drawer with sort and show filters. Active filters show as tags in the pill bar. Empty Trash permanently deletes notes. Swipe-to-delete only available in trash view.
+
+## Note Schema Changes
+
+### Rename `isStarred` → `isFavourite`
+
+The Note model has `isStarred` but conversations/files use `isFavourite`. Rename for consistency. Add fallback: if `isStarred` is true on existing docs, treat as `isFavourite: true`.
+
+### Add new fields
+
+```ts
+isFavourite: boolean; // default false (replaces isStarred, with migration fallback)
+isArchived: boolean; // default false
+isTrashed: boolean; // default false
+```
+
+### Fix `updateNote()` data parameter
+
+Current `updateNote()` only accepts `title`, `content`, `summary`, `isStarred`, `folderId`. Must expand to include all new fields:
+
+```ts
+data: Partial<{
+ title: string;
+ content: string;
+ summary: string;
+ isFavourite: boolean;
+ isArchived: boolean;
+ isTrashed: boolean;
+ folderId: string | null;
+}>
+```
+
+### Fix `persistNoteUpdate()` in NotesManager
+
+Currently cherry-picks only `title`, `summary`, `content`, `folderId`. Must also pass through `isFavourite`, `isArchived`, `isTrashed`.
+
+### Files to modify:
+- `src/backend/models/note.model.ts` — Rename `isStarred` → `isFavourite`, add `isArchived`, `isTrashed`, expand `updateNote()` type
+- `src/shared/types.ts` — Add fields to `Note` interface
+- `src/backend/session/managers/NotesManager.ts` — Update `NoteData`, hydrate mapping (with `isStarred` fallback), `persistNoteUpdate`
+
+### Hydrate fallback for `isStarred` → `isFavourite`
+
+```ts
+isFavourite: n.isFavourite ?? n.isStarred ?? false,
+```
+
+This handles old docs that have `isStarred: true` but no `isFavourite` field.
+
+## New RPCs on NotesManager
+
+```ts
+favouriteNote(noteId: string): Promise; // sets isFavourite=true, clears archived+trashed
+unfavouriteNote(noteId: string): Promise;
+archiveNote(noteId: string): Promise; // sets isArchived=true, clears favourite+trashed
+unarchiveNote(noteId: string): Promise;
+trashNote(noteId: string): Promise; // sets isTrashed=true, clears favourite+archived
+untrashNote(noteId: string): Promise;
+permanentlyDeleteNote(noteId: string): Promise; // hard delete from MongoDB
+emptyNoteTrash(): Promise; // permanently deletes all trashed notes
+```
+
+States are mutually exclusive (same as conversations).
+
+**Important:** The existing `deleteNote()` RPC remains as a hard delete but is only called from:
+- `permanentlyDeleteNote()` (new)
+- `emptyNoteTrash()` (new)
+- Swipe-to-delete in trash view only
+
+Normal "delete" actions (3-dots menu, swipe on non-trashed notes) call `trashNote()` instead.
+
+### Files to modify:
+- `src/backend/session/managers/NotesManager.ts` — Add RPCs
+- `src/shared/types.ts` — Add to `NotesManagerI`
+
+## Archive Refactor: Note-Level Only
+
+**Current problem:** `handleArchiveNote` calls `session.file.archiveFile(note.date)` which archives the entire day, not the individual note.
+
+**Fix:** Archive is now note-level. Remove file-level archive calls from notes. Use `session.notes.archiveNote(noteId)` instead of `session.file.archiveFile(date)`.
+
+### Files to modify:
+- `src/frontend/pages/notes/NotesPage.tsx` — Change `handleArchiveNote` to call note-level RPC
+- `src/frontend/pages/notes/FolderPage.tsx` — Same change
+
+## Frontend: NotesFilterDrawer
+
+Create `src/frontend/components/shared/NotesFilterDrawer.tsx` — same pattern as `ConversationFilterDrawer`.
+
+### Sort options:
+- Most recent (default)
+- Oldest first
+
+### Show filters:
+- All notes (hides archived + trashed)
+- Favourites
+- Archived
+- Trash
+
+### No date range filter.
+
+## Frontend: NotesPage Changes
+
+### Filter state
+```ts
+const [sortBy, setSortBy] = useState("recent");
+const [showFilter, setShowFilter] = useState("all");
+const [filterLoading, setFilterLoading] = useState(false);
+```
+
+### Filter logic in `filteredNotes` useMemo
+
+Two-level filtering: show filter + pill filter (AI/Manual) stack together.
+
+```ts
+let result = [...notes];
+
+// Show filter (mutually exclusive states)
+if (showFilter === "favourites") result = result.filter(n => n.isFavourite);
+else if (showFilter === "archived") result = result.filter(n => n.isArchived);
+else if (showFilter === "trash") result = result.filter(n => n.isTrashed);
+else result = result.filter(n => !n.isTrashed && !n.isArchived); // "all"
+
+// Pill filters (AI/Manual) — applied on top
+if (activeFilter === "manual") result = result.filter(n => !n.isAIGenerated);
+else if (activeFilter === "ai") result = result.filter(n => n.isAIGenerated);
+
+// Sort
+if (sortBy === "oldest") result.sort(byOldest);
+else result.sort(byNewest);
+```
+
+### Filter pill bar
+
+Current pills: All | Favorites | Manual | AI Generated
+
+Changes:
+- Rename "AI Generated" → "AI"
+- "Favorites" pill sets `showFilter = "favourites"` (no longer a broken TODO)
+- Add show filter tag (black pill) for Favourites/Archived/Trash when active from drawer
+- When show filter is active from drawer, gray out All pill
+- Tapping "All" pill resets show filter to "all"
+- Horizontal scroll (`overflow-x-auto`) if pills overflow
+- No date range tags
+
+### Trash view
+
+When `showFilter === "trash"`:
+- Show "X notes in trash" + "Empty Trash" button above list
+- Swipe-to-delete on trashed notes calls `permanentlyDeleteNote()` (hard delete)
+- "Empty Trash" opens `BottomDrawer` confirmation: "Your notes will be permanently deleted. Are you sure?" with Cancel / Delete All
+
+### Swipe actions by context
+
+| Note state | Left swipe reveals |
+|---|---|
+| Normal (not trashed/archived) | Archive + Trash |
+| Trashed | Restore + Delete (permanent) |
+| Archived | shown via archive filter only, swipe reveals Unarchive + Trash |
+
+### Loading animation
+
+Same `LoadingState` spinner for 3 seconds when switching show filters via drawer.
+
+### Empty state
+
+Same dot art "Nothing found" illustration with contextual messages:
+- Trash: "Your trash is empty"
+- Archived: "No archived notes"
+- Favourites: "No favourite notes yet"
+- Default: "Try adjusting your filters"
+
+## Frontend: NotePage 3-Dots Menu
+
+Current menu: Send Email | Delete Note
+
+Replace with:
+- Send Email
+- Divider
+- Favourite / Unfavourite (moved star button here)
+- Archive / Unarchive
+- Divider
+- Trash / Untrash
+
+Remove the standalone star button from header (move to menu).
+
+Trash navigates back to `/notes`. States are mutually exclusive.
+
+### Files to modify:
+- `src/frontend/pages/note/NotePage.tsx` — Update dropdown menu, remove star button
+
+## Frontend: FolderPage
+
+Filter out trashed notes from folder view:
+```ts
+const folderNotes = notes.filter(n => n.folderId === folderId && !n.isTrashed);
+```
+
+### Files to modify:
+- `src/frontend/pages/notes/FolderPage.tsx` — Filter trashed notes
+
+## Files Summary
+
+### New files:
+| File | Description |
+|------|-------------|
+| `src/frontend/components/shared/NotesFilterDrawer.tsx` | Filter drawer for notes |
+
+### Modified files:
+| File | Change |
+|------|--------|
+| `src/backend/models/note.model.ts` | Rename `isStarred` → `isFavourite`, add `isArchived`/`isTrashed`, expand `updateNote` type |
+| `src/shared/types.ts` | Add fields to `Note`, add RPCs to `NotesManagerI` |
+| `src/backend/session/managers/NotesManager.ts` | Update `NoteData`, hydrate (with fallback), `persistNoteUpdate`, add RPCs |
+| `src/frontend/pages/notes/NotesPage.tsx` | Filter state, filter logic, pill bar, trash UI, loading, empty state |
+| `src/frontend/pages/note/NotePage.tsx` | Update 3-dots menu, remove star button |
+| `src/frontend/pages/notes/NoteRow.tsx` | Context-aware swipe actions |
+| `src/frontend/pages/notes/FolderPage.tsx` | Filter out trashed notes |
+
+## Implementation Order
+
+1. **Schema** — Rename `isStarred` → `isFavourite`, add `isArchived`/`isTrashed` to model + types
+2. **Fix updateNote + persistNoteUpdate** — Expand accepted fields
+3. **Backend RPCs** — Add favourite/archive/trash/permanentlyDelete/emptyTrash RPCs
+4. **Hydrate fallback** — Map new fields with `isStarred` fallback
+5. **NotesFilterDrawer** — Create the filter drawer component
+6. **NotesPage** — Filter state, logic, pill bar tags, trash view, loading, empty state
+7. **NotePage menu** — Update 3-dots with favourite/archive/trash, remove star button
+8. **NoteRow swipe** — Context-aware swipe actions
+9. **FolderPage** — Filter out trashed notes
+10. **Archive refactor** — Change note archive from file-level to note-level
diff --git a/issues/15-multi-select-share/PLAN.md b/issues/15-multi-select-share/PLAN.md
new file mode 100644
index 0000000..a861c59
--- /dev/null
+++ b/issues/15-multi-select-share/PLAN.md
@@ -0,0 +1,442 @@
+# Multi-Select & Share/Export Feature
+
+## Overview
+
+Add long-press multi-select to **Notes**, **Conversations**, and **Transcripts** lists. Selected items can be batch exported (clipboard, text file, native share), moved to folders (notes only), favorited, or deleted. Tapping "Export" opens a bottom drawer with content toggles and export destination options.
+
+**Edge case:** Active/paused conversations (`status !== "ended"`) cannot be selected — they're still recording.
+
+---
+
+## Screens
+
+### A. Multi-Select Mode (Notes)
+- Long-press any note row → enters selection mode
+- Header becomes: `Cancel` | `{n} selected` | `Select All`
+- Each row shows animated checkbox (left side), swipe gestures disabled
+- Bottom action bar: **Export** | **Move** | **Favorite** | **Delete**
+
+### B. Multi-Select Mode (Conversations)
+- Same long-press trigger, same header
+- **Cannot select** conversations where `status === "active" || status === "paused"`
+- Bottom action bar: **Export** | **Favorite** | **Delete** (no Move)
+
+### C. Multi-Select Mode (Transcripts)
+- Long-press any transcript date row → enters selection mode
+- Same header pattern
+- Bottom action bar: **Export** | **Delete**
+- No favorite or move (transcripts are date-based files, not individual items)
+
+### D. Export Drawer
+- Title: "Export Note" (single) / "Export {n} Notes" (batch)
+- Subtitle: note title (single) or note count summary
+- **Included in Export** section:
+ - Toggle: "Note Content" — summary, decisions, action items (default ON)
+ - Toggle: "Linked Transcript" — full conversation with speaker labels (default OFF)
+- **Export To** section:
+ - Clipboard | Text File | Share (native share sheet)
+- "Export Note" button at bottom
+
+For conversations: same drawer but toggles are "Conversation Summary" + "Full Transcript"
+For transcripts: single toggle "Transcript Content" (always on), no linked content
+
+---
+
+## Implementation Tasks
+
+### Task 1: Create `useMultiSelect` hook
+
+**New file:** `src/frontend/hooks/useMultiSelect.ts`
+
+```ts
+import { useState, useCallback, useRef } from "react";
+
+interface UseMultiSelectReturn {
+ /** Whether selection mode is active */
+ isSelecting: boolean;
+ /** Set of selected item IDs */
+ selectedIds: Set;
+ /** Number of selected items */
+ count: number;
+ /** Enter selection mode and select the first item */
+ startSelecting: (id: T) => void;
+ /** Toggle an item's selection state */
+ toggleItem: (id: T) => void;
+ /** Select all items from a given list */
+ selectAll: (allIds: T[]) => void;
+ /** Exit selection mode and clear selection */
+ cancel: () => void;
+ /** Long-press handler factory — returns onTouchStart/onTouchEnd props */
+ longPressProps: (id: T, disabled?: boolean) => {
+ onTouchStart: () => void;
+ onTouchEnd: () => void;
+ onTouchMove: () => void;
+ };
+}
+```
+
+**Behavior:**
+- `longPressProps(id, disabled?)` returns touch handlers that trigger `startSelecting(id)` after 500ms hold
+- Moving finger cancels the long-press (prevents conflict with scroll/swipe)
+- `disabled` param prevents selection (for active conversations)
+- Once in selection mode, tapping a row calls `toggleItem(id)` instead of navigating
+- If `selectedIds` becomes empty after a toggle, auto-exit selection mode
+- `selectAll(ids)` selects everything in the filtered list
+- `cancel()` clears everything and exits selection mode
+
+**Implementation detail:** Use `useRef` for the timeout ID to avoid re-renders during the hold period. Clear timeout on `touchMove` and `touchEnd`.
+
+---
+
+### Task 2: Create `MultiSelectBar` component
+
+**New file:** `src/frontend/components/shared/MultiSelectBar.tsx`
+
+A fixed bottom bar that replaces the tab bar during selection mode.
+
+```ts
+interface MultiSelectBarProps {
+ actions: Array<{
+ icon: ReactNode;
+ label: string;
+ onClick: () => void;
+ variant?: "default" | "danger";
+ }>;
+}
+```
+
+**Layout:**
+- Fixed to bottom, same height as tab bar (72px + safe area)
+- White background with top border `#F5F5F4`
+- Actions evenly spaced as icon + label columns
+- "Delete" action uses `#DC2626` (red) for icon and label
+- Slides up with `motion` animation when entering selection mode
+
+**Actions by context:**
+
+| Context | Export | Move | Favorite | Delete |
+|---------|--------|------|----------|--------|
+| Notes | yes | yes | yes | yes |
+| Conversations | yes | no | yes | yes |
+| Transcripts | yes | no | no | yes |
+
+---
+
+### Task 3: Create `ExportDrawer` component
+
+**New file:** `src/frontend/components/shared/ExportDrawer.tsx`
+
+Uses existing `BottomDrawer` (vaul) as the base.
+
+```ts
+interface ExportDrawerProps {
+ isOpen: boolean;
+ onClose: () => void;
+ /** "note" | "conversation" | "transcript" */
+ itemType: "note" | "conversation" | "transcript";
+ /** Title of the single item, or count for batch */
+ itemLabel: string;
+ /** Number of items being exported */
+ count: number;
+ /** Callback when export is triggered */
+ onExport: (options: ExportOptions) => Promise;
+}
+
+interface ExportOptions {
+ includeContent: boolean;
+ includeTranscript: boolean;
+ destination: "clipboard" | "textFile" | "share";
+}
+```
+
+**UI Structure:**
+1. Drag handle bar
+2. Title row: "Export Note" / "Export 3 Notes" + X close button
+3. Subtitle: item title or "{n} notes selected"
+4. Section label: "INCLUDED IN EXPORT" (uppercase, 12px, `#A8A29E`)
+5. Toggle row: "Note Content" / subtitle / toggle switch (blue when on)
+6. Divider
+7. Toggle row: "Linked Transcript" / subtitle / toggle switch
+8. Section label: "EXPORT TO"
+9. Three option cards in a row: Clipboard (default selected, black border) | Text File | Share
+10. Full-width "Export Note" button (`#1C1917` bg, white text, rounded-xl)
+
+**Toggle switch:** Custom component — 48x28px pill, blue (#3B82F6) when on, gray (#E7E5E4) when off, white circle knob.
+
+**Export To cards:** 88x88px, rounded-xl border, icon + label. Selected state has 2px black border.
+
+---
+
+### Task 4: Create `SelectionHeader` component
+
+**New file:** `src/frontend/components/shared/SelectionHeader.tsx`
+
+Replaces the page header when in selection mode.
+
+```ts
+interface SelectionHeaderProps {
+ count: number;
+ onCancel: () => void;
+ onSelectAll: () => void;
+}
+```
+
+**Layout:**
+- `Cancel` (red, left) | `{n} selected` (center, bold) | `Select All` (right)
+- Same horizontal padding as existing headers (24px)
+- Font: Red Hat Display, 15px, weight 500 (Cancel/Select All), 600 (count)
+- Animated swap with the normal header using `AnimatePresence`
+
+---
+
+### Task 5: Modify `NoteRow.tsx` for multi-select
+
+**File:** `src/frontend/pages/notes/NoteRow.tsx`
+
+**Changes:**
+1. Add props: `isSelecting: boolean`, `isSelected: boolean`, `onLongPress: () => void`, `onToggleSelect: () => void`
+2. When `isSelecting`:
+ - Show animated checkbox on the left (slide in from left, 40px width)
+ - Checkbox: 24x24 rounded-full, red fill (#DC2626) + white checkmark when selected, gray border (#D6D3D1) when unselected
+ - Disable swipe gestures (don't call `useSwipeToReveal` handlers)
+ - Tapping the row calls `onToggleSelect()` instead of `onSelect(note)`
+ - Row background: light red tint (`#FEF2F2`) when selected
+3. When not `isSelecting`:
+ - Normal behavior (navigate on tap, swipe for actions)
+ - Attach long-press handlers from `useMultiSelect.longPressProps`
+
+**Animation:** Checkbox slides in with `motion.div` — `initial={{ width: 0, opacity: 0 }}`, `animate={{ width: 40, opacity: 1 }}`. Use `layout` prop on the content area for smooth shift.
+
+---
+
+### Task 6: Modify `ConversationRow.tsx` for multi-select
+
+**File:** `src/frontend/pages/home/components/ConversationRow.tsx`
+
+**Same pattern as NoteRow** with one key difference:
+
+- Add `canSelect` prop derived from conversation status:
+ ```ts
+ const canSelect = conversation.status === "ended";
+ ```
+- If `isSelecting && !canSelect`: row appears dimmed (opacity 0.4), checkbox hidden, not tappable
+- Long-press on active/paused conversations does nothing (disabled in `longPressProps`)
+
+---
+
+### Task 7: Modify `TranscriptList.tsx` for multi-select
+
+**File:** `src/frontend/pages/home/components/TranscriptList.tsx`
+
+**Changes:**
+1. Add props: `isSelecting`, `selectedDates: Set`, `onLongPress: (date: string) => void`, `onToggleSelect: (date: string) => void`
+2. When `isSelecting`:
+ - Show checkbox on left of each transcript row
+ - Tapping toggles selection instead of navigating
+ - Active/live transcript rows (today + recording) cannot be selected (dimmed)
+3. When not selecting: normal behavior with long-press handlers attached
+
+---
+
+### Task 8: Wire up `NotesPage.tsx`
+
+**File:** `src/frontend/pages/notes/NotesPage.tsx`
+
+**Changes:**
+1. Import and use `useMultiSelect` hook
+2. Conditionally render `SelectionHeader` vs normal header based on `isSelecting`
+3. Pass selection props down to each `NoteRow`
+4. Conditionally render `MultiSelectBar` vs bottom tab bar
+5. Hide FAB menu during selection mode
+6. Hide filter pills during selection mode
+
+**Action handlers:**
+- **Export:** Open `ExportDrawer` with selected note IDs
+- **Move:** Open existing folder picker (or a new simple drawer listing folders)
+- **Favorite:** Batch call `session.notes.favouriteNote(id)` for each selected ID, then exit selection
+- **Delete:** Batch call `session.notes.trashNote(id)` for each selected ID, then exit selection
+
+---
+
+### Task 9: Wire up `HomePage.tsx` (Conversations tab)
+
+**File:** `src/frontend/pages/home/HomePage.tsx`
+
+**Changes:**
+1. Import and use `useMultiSelect` hook
+2. When conversations tab is active and `isSelecting`:
+ - Show `SelectionHeader`
+ - Show `MultiSelectBar` with Export / Favorite / Delete
+ - Hide FAB menu
+3. Pass selection props to `ConversationList` → `ConversationRow`
+4. Filter out active/paused conversations from `selectAll`
+
+**Action handlers:**
+- **Export:** Open `ExportDrawer` with selected conversation IDs
+- **Favorite:** Batch `session.conversation.favouriteConversation(id)`
+- **Delete:** Batch `session.conversation.trashConversation(id)`
+
+---
+
+### Task 10: Wire up `HomePage.tsx` (Transcripts tab)
+
+**File:** `src/frontend/pages/home/HomePage.tsx`
+
+**Changes:**
+1. Use a separate `useMultiSelect` instance for transcripts (or share one with tab awareness)
+2. Pass selection props to `TranscriptList`
+3. Show `MultiSelectBar` with Export / Delete only
+
+**Action handlers:**
+- **Export:** Open `ExportDrawer` with selected transcript dates
+- **Delete:** Batch delete transcript files for selected dates
+
+---
+
+### Task 11: Backend — Batch export RPC
+
+**File:** `src/backend/session/managers/NotesManager.ts`
+
+Add new RPC methods:
+
+```ts
+@rpc
+async batchTrashNotes(noteIds: string[]): Promise {
+ for (const id of noteIds) {
+ await this.trashNote(id);
+ }
+}
+
+@rpc
+async batchFavouriteNotes(noteIds: string[]): Promise {
+ for (const id of noteIds) {
+ await this.favouriteNote(id);
+ }
+}
+
+@rpc
+async batchMoveNotes(noteIds: string[], folderId: string): Promise {
+ for (const id of noteIds) {
+ await this.updateNote(id, { folderId });
+ }
+}
+
+@rpc
+async exportNotesAsText(noteIds: string[], includeTranscript: boolean): Promise {
+ // Compile all selected notes into a single text block
+ // For each note: title, date, content (stripped HTML)
+ // If includeTranscript: append linked conversation transcript
+ // Return the combined string
+}
+```
+
+**File:** `src/backend/session/managers/ConversationManager.ts`
+
+```ts
+@rpc
+async batchTrashConversations(ids: string[]): Promise {
+ for (const id of ids) {
+ await this.trashConversation(id);
+ }
+}
+
+@rpc
+async batchFavouriteConversations(ids: string[]): Promise {
+ for (const id of ids) {
+ await this.favouriteConversation(id);
+ }
+}
+
+@rpc
+async exportConversationsAsText(ids: string[], includeTranscript: boolean): Promise {
+ // Compile selected conversations into text
+ // Summary + optionally full transcript segments
+}
+```
+
+---
+
+### Task 12: Export logic (frontend)
+
+**File:** `src/frontend/components/shared/ExportDrawer.tsx` (inside `onExport` handler)
+
+**Clipboard:**
+```ts
+const text = await session.notes.exportNotesAsText(noteIds, includeTranscript);
+await navigator.clipboard.writeText(text);
+// Show toast: "Copied to clipboard"
+```
+
+**Text File:**
+```ts
+const text = await session.notes.exportNotesAsText(noteIds, includeTranscript);
+const blob = new Blob([text], { type: "text/plain" });
+const url = URL.createObjectURL(blob);
+const a = document.createElement("a");
+a.href = url;
+a.download = `notes-export-${new Date().toISOString().split("T")[0]}.txt`;
+a.click();
+URL.revokeObjectURL(url);
+```
+
+**Share (native):**
+```ts
+const text = await session.notes.exportNotesAsText(noteIds, includeTranscript);
+if (navigator.share) {
+ await navigator.share({ title: "Exported Notes", text });
+} else {
+ // Fallback to clipboard
+ await navigator.clipboard.writeText(text);
+}
+```
+
+---
+
+## File Summary
+
+| Action | File | Description |
+|--------|------|-------------|
+| **Create** | `src/frontend/hooks/useMultiSelect.ts` | Long-press + selection state hook |
+| **Create** | `src/frontend/components/shared/MultiSelectBar.tsx` | Bottom action bar |
+| **Create** | `src/frontend/components/shared/ExportDrawer.tsx` | Export drawer with toggles + destinations |
+| **Create** | `src/frontend/components/shared/SelectionHeader.tsx` | Cancel / count / Select All header |
+| **Modify** | `src/frontend/pages/notes/NoteRow.tsx` | Add checkbox, long-press, select mode |
+| **Modify** | `src/frontend/pages/notes/NotesPage.tsx` | Wire up multi-select + actions |
+| **Modify** | `src/frontend/pages/home/components/ConversationRow.tsx` | Add checkbox, block active conversations |
+| **Modify** | `src/frontend/pages/home/components/TranscriptList.tsx` | Add checkbox, long-press, select mode |
+| **Modify** | `src/frontend/pages/home/HomePage.tsx` | Wire up both conversation + transcript select |
+| **Modify** | `src/backend/session/managers/NotesManager.ts` | Batch RPCs + export text |
+| **Modify** | `src/backend/session/managers/ConversationManager.ts` | Batch RPCs + export text |
+
+---
+
+## Implementation Order
+
+1. **Task 1** — `useMultiSelect` hook (foundation, everything depends on this)
+2. **Task 4** — `SelectionHeader` (simple, needed by pages)
+3. **Task 2** — `MultiSelectBar` (needed by pages)
+4. **Task 3** — `ExportDrawer` (needed by export action)
+5. **Task 5** — `NoteRow` modifications
+6. **Task 8** — `NotesPage` wiring (first full flow — test here)
+7. **Task 11** — Backend batch RPCs
+8. **Task 12** — Export logic
+9. **Task 6** — `ConversationRow` modifications
+10. **Task 9** — `HomePage` conversations wiring
+11. **Task 7** — `TranscriptList` modifications
+12. **Task 10** — `HomePage` transcripts wiring
+
+---
+
+## Edge Cases
+
+- **Active/paused conversations** — cannot be selected, appear dimmed in selection mode
+- **Live transcript (today + recording)** — cannot be selected
+- **Empty selection** — auto-exits selection mode
+- **Filter change during selection** — clear selection and exit selection mode
+- **Tab switch during selection** — clear selection and exit selection mode
+- **Navigating away** — clear selection (useEffect cleanup)
+- **Single item export** — drawer shows item title as subtitle
+- **Batch export** — drawer shows "{n} items selected" as subtitle
+- **No transcript linked** — "Linked Transcript" toggle disabled (grayed out) with "No transcript linked" subtitle
+- **Clipboard API unavailable** — fallback to `document.execCommand("copy")` or show error
+- **`navigator.share` unavailable** — hide "Share" option, show only Clipboard + Text File
diff --git a/issues/16-merge-conversations/PLAN.md b/issues/16-merge-conversations/PLAN.md
new file mode 100644
index 0000000..7b6bca9
--- /dev/null
+++ b/issues/16-merge-conversations/PLAN.md
@@ -0,0 +1,211 @@
+# Issue #16: Merge Conversations
+
+## Overview
+
+Allow users to select multiple conversations and merge them into a single new conversation. This handles the case where the auto-conversation tracker splits what should be one continuous conversation into multiple pieces.
+
+## User Flow
+
+1. User enters multi-select mode on the Conversations tab
+2. Selects 2+ conversations
+3. Taps **Merge** button in the bottom bar
+4. A **Merge Drawer** appears with:
+ - Title: "Merge {N} Conversations?"
+ - Description explaining what will happen
+ - Checkbox: "Move original conversations to trash after merge" (default: checked)
+ - **Merge** button (primary, dark)
+ - **Cancel** button
+5. On confirm:
+ - Segments from all selected conversations are loaded and sorted by timestamp (oldest → newest)
+ - A new conversation is created with all accumulated segments
+ - AI generates a new summary and title for the merged conversation
+ - If "move to trash" is checked, original conversations are trashed (not permanently deleted)
+ - The linked notes on the original conversations are NOT carried over (treated as a fresh conversation)
+ - User exits selection mode, sees the new merged conversation in the list
+
+## Segment Ordering
+
+Segments are sorted chronologically across all source conversations:
+- Oldest segments first, newest last
+- If conversations span multiple dates (e.g., yesterday + today), segments from yesterday come before today's
+- Within the same date, sort by timestamp
+
+## Edge Cases (Basic)
+
+| Case | Behavior |
+|------|----------|
+| Conversations have linked AI notes | Notes are ignored — merge creates a fresh conversation with no noteId |
+| Conversations span multiple dates | New conversation uses the earliest date as its `date`, with `startTime` from earliest segment and `endTime` from latest |
+| One conversation is active/paused | Active/paused conversations cannot be selected, so this can't happen |
+| Only 1 conversation selected | Merge button is disabled (need 2+) |
+| Conversations are from trash | Merge button hidden when in trash filter |
+| Original conversations trashed | Optional via checkbox — moves to trash, not permanent delete |
+| Merged conversation has 0 segments | Shouldn't happen since we require ended conversations with chunks, but fallback: use chunks text if segments fail to load |
+
+## Edge Cases (Deep Investigation)
+
+### HIGH RISK
+
+#### Chunk References & Orphaning
+- Each `TranscriptChunk` has a `conversationId` field. When merging, all chunks from source conversations must be reassigned to the merged conversation's ID
+- If any chunk is missed, it becomes orphaned — queryable only by time range, breaking conversation integrity
+- `chunkIds` array must be merged in chronological order by `chunkIndex`/`startTime`, not insertion order
+- If a chunk was already deleted (cleanup), the merged conversation will silently have fewer segments
+- **Mitigation:** Reassign chunk `conversationId` in bulk via `TranscriptChunk.updateMany()`, deduplicate chunk IDs before merging
+
+#### Frontend Sync & State Mismatch
+- The frontend syncs via `conversations.set()` which replaces the entire list
+- If merging happens server-side without properly syncing, the frontend still has old conversation objects
+- Clicking a trashed/deleted source conversation after merge crashes the UI
+- **Mitigation:** Atomic sync — add the new conversation and remove/update the old ones in a single `conversations.mutate()` call
+
+#### Stale Embeddings (Semantic Search)
+- Conversations store an `embedding` field (vector for semantic search), generated when `aiSummary` is set
+- Merging changes the conversation's content, making old embeddings irrelevant
+- If not regenerated, semantic search returns incorrect results
+- **Mitigation:** Regenerate embedding after generating the new AI summary
+
+#### Concurrency & Race Conditions
+- If a merge is in progress and new chunks are being added to one of the source conversations, the merge could miss chunks
+- If a note is being generated from a source conversation simultaneously, it might fail or generate from partial data
+- **Mitigation:** Only allow merging of "ended" conversations (already enforced). Add a merge lock to prevent concurrent merges on the same conversations
+
+### MEDIUM RISK
+
+#### Cross-Date Conversations
+- A conversation's `date` field stores ONE date (start date in user's timezone)
+- Merging conversations across different dates forces a choice — merged conversation can only belong to one date
+- This breaks the assumption that conversations are grouped by day
+- **Mitigation:** Use the earliest date. Show a note in the merge drawer if conversations span multiple dates
+
+#### Running Summary Word Count
+- Each conversation has a `runningSummary` (compressed transcript text, max 300 words)
+- Merging 3 conversations with 300-word summaries = 900 words, exceeding the compression target
+- The `runningSummary` is used by the LLM for chunk classification and resumption checks
+- **Mitigation:** Don't concatenate running summaries — regenerate from the AI summary of the merged conversation
+
+#### Orphaned Notes
+- Source conversations may have linked notes (`noteId`). After merging:
+ - The notes still exist but now reference conversations that may be trashed
+ - The `From: conversation title` label on those notes becomes stale
+- **Mitigation:** Don't delete notes. They remain as standalone notes. Their `From:` label might show a trashed conversation title — acceptable
+
+#### Segment Loading from Different Sources
+- Segments come from a 3-tier fallback: in-memory → MongoDB → R2
+- Merging conversations from different dates may hit different sources
+- If R2 data was deleted for one conversation (transcript deleted feature), those segments are silently lost
+- **Mitigation:** Log a warning if any source conversation returns 0 segments. Show in the merge drawer if data is missing
+
+#### Title Decision
+- Each source conversation has a different title
+- **Mitigation:** Regenerate title from the merged AI summary (LLM call). Don't try to combine existing titles
+
+### LOW RISK
+
+#### Favourite/Archive/Trash Status Conflicts
+- Source conversations may have different statuses (one favourited, one archived)
+- **Mitigation:** New conversation starts as default (not favourited, not archived, not trashed). User can favourite after
+
+#### Resumption Metadata
+- Conversations have `resumedFrom` field for lineage tracking
+- Merging breaks the lineage chain
+- **Mitigation:** Set `resumedFrom: null` on merged conversation. Not user-facing
+
+#### Metadata & Timestamps
+- `createdAt`/`updatedAt` on the merged conversation should use current time
+- Chunks have `metadata` field that may have conflicting data
+- **Mitigation:** Use current time for `createdAt`. Chunk metadata is preserved as-is
+
+## Implementation Plan
+
+### Phase 1: Backend RPC
+
+**File:** `src/backend/session/managers/ConversationManager.ts`
+
+Add new RPC method:
+
+```typescript
+@rpc
+async mergeConversations(
+ conversationIds: string[],
+ trashOriginals: boolean
+): Promise // returns new conversation ID
+```
+
+Steps:
+1. Validate: all conversations exist, status is "ended", at least 2 IDs
+2. Sort source conversations by `startTime` ascending (chronological order)
+3. For each conversation, load segments via `getSegmentsForConversation()`
+4. Merge all segments into one array, sort by `timestamp` ascending, deduplicate
+5. Merge all `chunkIds` from source conversations, sorted chronologically
+6. Reassign chunk `conversationId` to the new conversation ID via bulk update
+7. Create new conversation record with:
+ - `date`: earliest conversation's date
+ - `startTime`: earliest segment/chunk timestamp
+ - `endTime`: latest segment/chunk timestamp
+ - `status`: "ended"
+ - `noteId`: null
+ - `isFavourite`: false, `isArchived`: false, `isTrashed`: false
+ - Combined `chunkIds`
+ - `runningSummary`: empty (will be regenerated)
+8. Generate AI summary + title via existing `generateAISummary()` flow
+9. Generate embedding from the new AI summary
+10. If `trashOriginals`: call `trashConversation()` for each source (NOT delete — preserves data)
+11. Sync to frontend: add new conversation, update trashed ones in a single `conversations.mutate()`
+12. Return the new conversation ID
+
+### Phase 2: Frontend — Merge Button (DONE)
+
+**File:** `src/frontend/pages/home/HomePage.tsx`
+
+- ✅ `MergeIcon` added to `MultiSelectBar.tsx`
+- ✅ Merge action added to `convSelectActions`
+- TODO: Disable merge when < 2 conversations selected
+- TODO: Wire onClick to open merge drawer
+
+### Phase 3: Frontend — Merge Drawer
+
+**File:** `src/frontend/pages/home/HomePage.tsx` (inline)
+
+Vaul Drawer with:
+- Title: "Merge {N} Conversations?"
+- List of conversation titles being merged (scrollable if many)
+- Warning if conversations span multiple dates
+- Warning if any source conversation has 0 loadable segments
+- Checkbox: "Move originals to trash" (default checked)
+- Merge button (calls the RPC, shows loading state)
+- Cancel button
+
+### Phase 4: Type Updates
+
+**File:** `src/shared/types.ts`
+
+Add to `ConversationManagerI`:
+```typescript
+mergeConversations(conversationIds: string[], trashOriginals: boolean): Promise;
+```
+
+## Files to Modify
+
+| File | Change |
+|------|--------|
+| `src/shared/types.ts` | Add `mergeConversations` to `ConversationManagerI` |
+| `src/backend/session/managers/ConversationManager.ts` | Add `mergeConversations` RPC |
+| `src/backend/models/conversation.model.ts` | May need bulk chunk reassignment helper |
+| `src/frontend/components/shared/MultiSelectBar.tsx` | ✅ `MergeIcon` added |
+| `src/frontend/pages/home/HomePage.tsx` | Add merge drawer, handler, state |
+
+## Design Notes
+
+- Merge button uses a "combine" icon (two arrows merging into one line)
+- Button sits between Export and Favorite in the bottom bar
+- Only enabled when 2+ conversations are selected
+- Drawer matches the existing delete confirmation drawer style (vaul, warm stone design)
+- Merge is an async operation — show a loading spinner on the button while in progress
+- After merge completes, auto-navigate to the new conversation detail page (optional)
+
+## Decisions
+
+1. **No auto-navigate after merge.** Spinner on merge button → generates → exits selection mode → merged conversation appears in the list naturally.
+2. **Max 10 conversations** can be merged at once. Show error/disable if > 10 selected.
+3. **No preview** in the merge drawer. Keep it simple — title, description, trash checkbox, merge button.
diff --git a/issues/17-memory-leaks/FINDINGS.md b/issues/17-memory-leaks/FINDINGS.md
new file mode 100644
index 0000000..6102e2c
--- /dev/null
+++ b/issues/17-memory-leaks/FINDINGS.md
@@ -0,0 +1,275 @@
+# Issue #17: Memory Leak Investigation
+
+## Overview
+
+Memory grows steadily from ~350MB to ~900MB+ over 1-2 days, then drops sharply on server restart. Classic sawtooth pattern indicating objects allocated but never freed by the garbage collector.
+
+## Memory Graph Analysis
+
+```
+900MB ─────────────/│ /│
+ / │ / │
+700MB ──────── / │ / │
+ / │ / │
+350MB ──────/ │ / │──
+ restart restart restart
+ 3/16 3/18 3/20
+```
+
+- Growth rate: ~25MB/hour (~500MB over 20 hours)
+- Baseline after restart: ~150-350MB
+- Peak before restart: ~900-1000MB
+- Pattern is consistent across restarts — same leak sources every time
+
+---
+
+## CRITICAL — Fix Immediately
+
+### 1. S3Client Created Per-Request (Not Shared)
+
+**Severity:** CRITICAL
+**Estimated impact:** 40-60% of memory growth
+**Files:**
+- `src/backend/services/r2Upload.service.ts` (lines 73, 284, 340, 421)
+- `src/backend/services/r2Fetch.service.ts` (line 38)
+
+**Problem:** Every R2 operation creates a brand new `S3Client` instance. S3 clients maintain internal connection pools, HTTP agents, and request buffers. These are heavyweight objects (~100-500KB each) designed to be long-lived singletons.
+
+With the app processing hundreds of transcript segments per day:
+- `uploadPhotoToR2()` — new client per photo
+- `uploadToR2()` — new client per batch upload
+- `fetchExistingBatch()` — new client per fetch
+- `fetchTranscriptFromR2()` — new client per historical transcript load
+- `listR2TranscriptDates()` — new client per date listing
+- `deleteFromR2()` — new client per deletion
+
+Over 24 hours with multiple users, this creates hundreds-thousands of S3Client instances. The AWS SDK v3 S3Client does not self-cleanup — connection pools persist until the process exits.
+
+**Fix:**
+```typescript
+// Before (in every function):
+const s3Client = new S3Client({ ... });
+
+// After (module-level singleton):
+let _s3Client: S3Client | null = null;
+function getS3Client(): S3Client {
+ if (!_s3Client) {
+ _s3Client = new S3Client({
+ region: "auto",
+ endpoint: process.env.R2_ENDPOINT,
+ credentials: {
+ accessKeyId: process.env.R2_ACCESS_KEY_ID!,
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
+ },
+ });
+ }
+ return _s3Client;
+}
+```
+
+**Effort:** 1-2 hours
+**Risk:** Low — singleton pattern is the AWS-recommended approach
+
+---
+
+### 2. ConversationManager.segmentCache Never Cleared
+
+**Severity:** HIGH
+**Estimated impact:** 20-30% of memory growth
+**File:** `src/backend/session/managers/ConversationManager.ts` (line 42)
+
+```typescript
+private segmentCache = new Map();
+```
+
+**Problem:** This cache stores ALL transcript segments from R2 for each date that a conversation's segments are loaded. A single day's transcript can have 1000-3000+ segments, each with full text content.
+
+- Cache entries are added when `getSegmentsForConversation()` fetches from R2 (line ~926)
+- Cache is NEVER cleared — not on session destroy, not on time expiry, never
+- `destroy()` method (line 166) doesn't clear it
+- Each date entry: 1000 segments * ~200 bytes = ~200KB per date
+- User accessing 10 historical dates = 2MB cached per session
+- Multiple users over 24 hours = 10-50MB+ in dead caches
+
+**Fix:**
+1. Clear cache in `destroy()`:
+ ```typescript
+ destroy() {
+ this.segmentCache.clear();
+ // ... existing cleanup
+ }
+ ```
+2. Add TTL-based eviction (optional):
+ ```typescript
+ // Clear entries older than 30 minutes
+ private segmentCacheTimestamps = new Map();
+ ```
+3. Cap cache size (e.g., max 5 dates cached, LRU eviction)
+
+**Effort:** 30 minutes
+**Risk:** Very low
+
+---
+
+## SIGNIFICANT — Fix This Week
+
+### 3. Session Cleanup on Disconnect
+
+**Severity:** MEDIUM-HIGH
+**File:** `src/backend/session/NotesSession.ts`, `src/backend/session/SessionManager.ts`
+
+**Problem:** When a user disconnects (tab close, network drop), the session and all its managers must be fully destroyed. If any manager holds references to large objects (segment arrays, chunk buffers, cached data), those objects persist in memory until the session is garbage collected.
+
+**What to verify:**
+- Are all `setInterval`/`setTimeout` timers cleared in every manager's `destroy()`?
+- Are all event listeners removed?
+- Are all arrays/Maps/Sets emptied?
+- Is the session removed from the SessionManager's session map?
+
+**Key managers to audit:**
+- `ChunkBufferManager` — heartbeat interval (5s) must be cleared
+- `SummaryManager` — rolling summary timer must be cleared
+- `TranscriptManager` — pendingSegments must be flushed/cleared
+- `R2Manager` — any batch timers must be cleared
+
+---
+
+### 4. ChunkBufferManager Heartbeat Interval
+
+**Severity:** MEDIUM
+**File:** `src/backend/session/managers/ChunkBufferManager.ts`
+
+**Problem:** The heartbeat runs every 5 seconds (`setInterval`). If not properly cleared on session disconnect, it keeps the entire ChunkBufferManager (and everything it references) alive in memory indefinitely.
+
+**What to check:**
+- Is `clearInterval(heartbeat)` called in `destroy()`?
+- Does the heartbeat callback reference `this` (keeping the manager alive via closure)?
+
+---
+
+### 5. TranscriptManager.pendingSegments Race Condition
+
+**Severity:** MEDIUM
+**File:** `src/backend/session/managers/TranscriptManager.ts` (lines 64, 375-382)
+
+**Problem:** Segments are added to `pendingSegments[]` and flushed to MongoDB on a 30-second timer. If MongoDB is slow or the persist call fails:
+- Segments keep accumulating (3-5 segments/sec = 90-150 segments in 30s)
+- Failed persist doesn't clear the array — it keeps growing
+- At ~200 bytes per segment, a 10-minute MongoDB outage = 180KB of queued segments per user
+
+**Fix:** Add max queue size + error recovery:
+```typescript
+if (this.pendingSegments.length > 500) {
+ console.warn("[TranscriptManager] pendingSegments overflow, dropping oldest");
+ this.pendingSegments = this.pendingSegments.slice(-200);
+}
+```
+
+---
+
+### 6. FileManager._operationInProgress Promise Chain
+
+**Severity:** MEDIUM
+**File:** `src/backend/session/managers/FileManager.ts` (lines 71, 623-641)
+
+**Problem:** File operations (trash, archive, favourite) create a Promise that subsequent operations `await`. If an operation throws without calling `resolveOperation()`, the Promise never resolves — blocking all future file operations for that session AND keeping the Promise chain in memory.
+
+**Fix:** Ensure `resolveOperation()` is always called in `finally`:
+```typescript
+// Already done in most places, but verify ALL paths
+try {
+ // ... operation
+} finally {
+ resolveOperation!(); // Must always be called
+ this._operationInProgress = null;
+}
+```
+
+---
+
+### 7. provisionalTitleInFlight Set Leak
+
+**Severity:** LOW-MEDIUM
+**File:** `src/backend/session/managers/ConversationManager.ts` (line 294)
+
+**Problem:** The `provisionalTitleInFlight` Set tracks conversation IDs currently generating titles. If a conversation is deleted/trashed while title generation is in-flight, the ID stays in the Set forever (small leak, ~50 bytes per ID).
+
+**Fix:** Add timeout-based cleanup:
+```typescript
+setTimeout(() => {
+ this.provisionalTitleInFlight.delete(convId);
+}, 30000); // 30s max for title generation
+```
+
+---
+
+## LOW PRIORITY — Nice to Have
+
+### 8. Mongoose Connection Pool
+
+**Severity:** LOW
+**Problem:** MongoDB connections are pooled by Mongoose. Default pool size is 5. Under heavy load, pool may grow. Not a leak per se, but worth monitoring.
+
+### 9. Closure Chains in waitForSentenceBoundary
+
+**Severity:** LOW
+**File:** `src/backend/session/managers/ChunkBufferManager.ts` (lines 182-212)
+
+**Problem:** Recursive `setTimeout` in `waitForSentenceBoundary` creates closure chains that capture buffer state. Each 500ms check creates a new closure referencing `initialText` and `this.buffer`. Over 8s max wait = 16 closures per chunk boundary check.
+
+**Fix:** Use a single timer that checks periodically instead of recursive timeouts.
+
+---
+
+## Implementation Plan
+
+### Phase 1 — Quick Wins (1-2 hours, biggest impact)
+
+1. **Singleton S3Client** — Create module-level singleton in r2Upload.service.ts and r2Fetch.service.ts
+2. **Clear segmentCache** — Add `this.segmentCache.clear()` to ConversationManager.destroy()
+3. **Clear provisionalTitleInFlight** — Add `this.provisionalTitleInFlight.clear()` to destroy()
+
+### Phase 2 — Session Cleanup Audit (2-3 hours)
+
+4. Audit every manager's `destroy()` method for:
+ - Timers not cleared
+ - Event listeners not removed
+ - Arrays/Maps/Sets not emptied
+5. Add `pendingSegments` overflow protection in TranscriptManager
+6. Verify `_operationInProgress` Promise resolution in all FileManager paths
+
+### Phase 3 — Monitoring (1 hour)
+
+7. Add memory usage logging on session create/destroy:
+ ```typescript
+ console.log(`[Session] Memory: ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`);
+ ```
+8. Add session count logging to track active sessions vs memory
+
+---
+
+## Expected Impact
+
+| Fix | Memory Saved | Confidence |
+|-----|-------------|------------|
+| Singleton S3Client | 200-400MB/day | High |
+| Clear segmentCache | 50-150MB/day | High |
+| Session cleanup audit | 50-100MB/day | Medium |
+| pendingSegments cap | 10-30MB/day | Medium |
+| provisionalTitle cleanup | 1-5MB/day | Low |
+
+**Total estimated reduction: 300-600MB/day** — should keep steady-state memory under 500MB instead of climbing to 900MB+.
+
+---
+
+## Files to Modify
+
+| File | Change |
+|------|--------|
+| `src/backend/services/r2Upload.service.ts` | Singleton S3Client |
+| `src/backend/services/r2Fetch.service.ts` | Singleton S3Client |
+| `src/backend/session/managers/ConversationManager.ts` | Clear segmentCache + provisionalTitleInFlight in destroy() |
+| `src/backend/session/managers/TranscriptManager.ts` | Cap pendingSegments, clear in destroy() |
+| `src/backend/session/managers/FileManager.ts` | Verify Promise resolution, clear in destroy() |
+| `src/backend/session/managers/ChunkBufferManager.ts` | Verify heartbeat cleanup in destroy() |
+| `src/backend/session/NotesSession.ts` | Add memory logging on create/destroy |
diff --git a/issues/18-release-notes-v3/API-REFERENCE.md b/issues/18-release-notes-v3/API-REFERENCE.md
new file mode 100644
index 0000000..ad00acd
--- /dev/null
+++ b/issues/18-release-notes-v3/API-REFERENCE.md
@@ -0,0 +1,190 @@
+# Mentra Notes v3.0.0 — API Reference
+
+## Health & Status
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/health` | Server health check, returns active session count |
+| GET | `/api/auth/status` | Authentication status check |
+| GET | `/api/session/status` | Current session state (connected, recording, counts) |
+
+## Notes
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/notes` | Get all notes for authenticated user |
+| GET | `/api/notes/:id` | Get specific note by ID |
+| POST | `/api/notes` | Create manual note (title, content) |
+| POST | `/api/notes/generate` | Generate AI note from transcript |
+| PUT | `/api/notes/:id` | Update note (title, content) |
+| DELETE | `/api/notes/:id` | Delete note |
+| GET | `/api/notes/:id/download/:format` | Download note as PDF/TXT/DOCX (signed URL token required) |
+
+## Transcripts
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/transcripts/today` | Get today's transcript segments |
+| GET | `/api/transcripts/:date` | Get historical transcript for specific date |
+| DELETE | `/api/transcripts/today` | Clear today's transcript |
+
+## Settings
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/settings` | Get user settings |
+| PUT | `/api/settings` | Update user settings |
+
+## Files (Date Management)
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/files` | Get all files with optional filter query param |
+| GET | `/api/files/:date` | Get specific date's file metadata |
+| PATCH | `/api/files/:date` | Update file flags (archived, trashed, favourite) |
+
+## Photos
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/photos/:date/:filename` | Proxy R2 photo to browser |
+
+## Email
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| POST | `/api/email/send` | Send notes email via Resend |
+| POST | `/api/transcript/email` | Send transcript email via Resend |
+
+### POST /api/email/send — Request Body
+
+```json
+{
+ "to": "user@email.com",
+ "cc": ["cc@email.com"],
+ "sessionDate": "March 22, 2026",
+ "sessionStartTime": "9:00 AM",
+ "sessionEndTime": "5:00 PM",
+ "notes": [
+ {
+ "noteId": "abc123",
+ "noteTimestamp": "9:30 AM",
+ "noteTitle": "Meeting Notes",
+ "noteContent": "
HTML content...
",
+ "noteType": "AI Generated"
+ }
+ ]
+}
+```
+
+### POST /api/transcript/email — Request Body
+
+```json
+{
+ "to": "user@email.com",
+ "cc": ["cc@email.com"],
+ "userId": "user-id",
+ "date": "2026-03-22",
+ "sessionDate": "March 22, 2026",
+ "sessionStartTime": "9:00 AM",
+ "sessionEndTime": "5:00 PM",
+ "segments": [
+ { "timestamp": "9:00 AM", "text": "Hello everyone..." }
+ ]
+}
+```
+
+## Search
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/search?q=...&limit=10&userId=...` | Semantic search across notes and conversations |
+
+## Authentication
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/mentra/auth/init` | Initialize auth (exchange temp token / verify signed token) |
+
+## WebSocket
+
+| Endpoint | Description |
+|----------|-------------|
+| `ws://host/ws/sync?userId=...` | Real-time sync connection |
+
+### WebSocket Messages (Client → Server)
+
+```json
+{ "type": "rpc", "manager": "notes", "method": "generateNote", "args": [...], "id": "uuid" }
+```
+
+### WebSocket Messages (Server → Client)
+
+```json
+{ "type": "connected" }
+{ "type": "snapshot", "state": { ... } }
+{ "type": "state_change", "manager": "notes", "property": "notes", "value": [...] }
+{ "type": "rpc_response", "id": "uuid", "result": { ... } }
+```
+
+## RPC Methods (via WebSocket)
+
+### TranscriptManager
+- `getRecentSegments(count?)` — Get last N segments
+- `getFullText()` — Get full transcript as text
+- `clear()` — Clear transcript
+- `loadDateTranscript(date)` — Load historical transcript
+- `loadTodayTranscript()` — Switch back to today
+- `removeDates(dates[])` — Remove dates from available list
+
+### NotesManager
+- `generateNote(title?, startTime?, endTime?)` — AI-generate note
+- `createManualNote(title, content)` — Create manual note
+- `updateNote(noteId, updates)` — Update note
+- `deleteNote(noteId)` — Delete note
+- `favouriteNote(noteId)` / `unfavouriteNote(noteId)`
+- `archiveNote(noteId)` / `unarchiveNote(noteId)`
+- `trashNote(noteId)` / `untrashNote(noteId)` / `permanentlyDeleteNote(noteId)`
+- `emptyNoteTrash()` — Empty notes trash
+- `batchFavouriteNotes(ids[])` / `batchTrashNotes(ids[])` / `batchMoveNotes(ids[], folderId)`
+
+### ConversationManager
+- `deleteConversation(id)` — Delete conversation
+- `linkNoteToConversation(convId, noteId)` — Link note
+- `loadConversationSegments(convId)` — Load transcript segments
+- `favouriteConversation(id)` / `unfavouriteConversation(id)`
+- `archiveConversation(id)` / `unarchiveConversation(id)`
+- `trashConversation(id)` / `untrashConversation(id)`
+- `emptyTrash()` — Empty conversations trash
+- `mergeConversations(ids[], trashOriginals)` — Merge conversations
+- `batchFavouriteConversations(ids[])` / `batchTrashConversations(ids[])`
+
+### ChatManager
+- `sendMessage(content)` — Send chat message
+- `clearHistory()` — Clear chat
+- `loadDateChat(date)` — Load chat for specific date
+
+### SettingsManager
+- `updateSettings(settings)` — Update user settings
+- `getSettings()` — Get current settings
+
+### FileManager
+- `refreshFiles()` — Force refresh file list
+- `getFilesRpc(filter?)` — Get files with filter
+- `setFilter(filter)` — Change active filter
+- `archiveFile(date)` / `unarchiveFile(date)`
+- `trashFile(date)` — Trash file (deletes R2 + MongoDB transcript data)
+- `restoreFile(date)` — Restore from trash
+- `favouriteFile(date)` / `unfavouriteFile(date)`
+- `permanentlyDeleteFile(date)` — Permanent delete
+- `purgeDate(date)` — Delete all data for a date
+- `emptyTrash()` — Empty files trash
+
+### FoldersManager
+- `createFolder(name, color)` — Create folder
+- `updateFolder(folderId, updates)` — Update folder
+- `deleteFolder(folderId)` — Delete folder
+
+### SummaryManager
+- `generateHourSummary(hour)` — Generate summary for specific hour
+- `loadSummariesForDate(date)` — Load hour summaries for a date
diff --git a/issues/18-release-notes-v3/ARCHITECTURE.md b/issues/18-release-notes-v3/ARCHITECTURE.md
new file mode 100644
index 0000000..77bb3b0
--- /dev/null
+++ b/issues/18-release-notes-v3/ARCHITECTURE.md
@@ -0,0 +1,179 @@
+# Mentra Notes v3.0.0 — Architecture
+
+## System Architecture
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ MentraOS Glasses │
+│ Microphone → Audio Stream → MentraOS SDK → WebSocket │
+└──────────────────────────┬──────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────┐
+│ Hono.js Server (Bun) │
+│ │
+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ │ Auth MW │ │ API Routes │ │ WebSocket │ │
+│ │ (MentraOS) │ │ (REST) │ │ (Sync) │ │
+│ └──────────────┘ └──────────────┘ └──────────────┘ │
+│ │ │
+│ ┌─────────────────────────────────────────────────┐ │
+│ │ Session Manager │ │
+│ │ ┌─────────────────────────────────────────┐ │ │
+│ │ │ Per-User Session │ │ │
+│ │ │ │ │ │
+│ │ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ │
+│ │ │ │Transcript │ │Conversa- │ │ Notes │ │ │ │
+│ │ │ │ Manager │ │tion Mgr │ │Manager │ │ │ │
+│ │ │ └──────────┘ └──────────┘ └────────┘ │ │ │
+│ │ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ │
+│ │ │ │ Summary │ │ Chat │ │Settings│ │ │ │
+│ │ │ │ Manager │ │ Manager │ │Manager │ │ │ │
+│ │ │ └──────────┘ └──────────┘ └────────┘ │ │ │
+│ │ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ │
+│ │ │ │ File │ │ Folder │ │ R2 │ │ │ │
+│ │ │ │ Manager │ │ Manager │ │Manager │ │ │ │
+│ │ │ └──────────┘ └──────────┘ └────────┘ │ │ │
+│ │ │ ┌──────────┐ ┌──────────┐ │ │ │
+│ │ │ │ Chunk │ │ Input │ │ │ │
+│ │ │ │ Buffer │ │ Manager │ │ │ │
+│ │ │ └──────────┘ └──────────┘ │ │ │
+│ │ └─────────────────────────────────────────┘ │ │
+│ └─────────────────────────────────────────────────┘ │
+│ │
+│ ┌──────────────────────────────────────────────────┐ │
+│ │ Auto-Notes Pipeline │ │
+│ │ ChunkBuffer → TriageClassifier → ConvTracker │ │
+│ └──────────────────────────────────────────────────┘ │
+│ │
+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ │ LLM Service │ │ R2 Service │ │ Email Service│ │
+│ │ (Gemini/ │ │ (Cloudflare)│ │ (Resend) │ │
+│ │ Claude/OAI)│ │ │ │ │ │
+│ └──────────────┘ └──────────────┘ └──────────────┘ │
+└─────────────────────────────────────────────────────────┘
+ │
+ ┌────────────┼────────────┐
+ ▼ ▼ ▼
+ ┌────────┐ ┌────────┐ ┌────────┐
+ │MongoDB │ │ R2 │ │ Resend │
+ │ │ │(Photos │ │(Email) │
+ │(Data) │ │+Trans) │ │ │
+ └────────┘ └────────┘ └────────┘
+```
+
+## Data Flow: Audio → Note
+
+```
+1. Glasses Mic → Audio Stream
+2. MentraOS SDK → Transcription (Deepgram/Google)
+3. Transcript Segment → TranscriptManager
+ ├── Persist to MongoDB (DailyTranscript)
+ ├── Sync to frontend (live display)
+ └── Feed to ChunkBufferManager
+4. ChunkBuffer (40s window) → TranscriptChunk
+5. TriageClassifier → MEANINGFUL / FILLER / AUTO-SKIPPED
+6. ConversationTracker (state machine)
+ ├── IDLE: Wait for meaningful chunk
+ ├── PENDING: Buffer 3 chunks to confirm
+ ├── TRACKING: Group chunks into conversation
+ ├── PAUSED: Detect silence (7 chunks to end)
+ └── END: Trigger AI summary + note generation
+7. NotesManager.generateNote()
+ ├── Load segments for conversation time range
+ ├── Build transcript text
+ ├── LLM generates structured HTML note
+ └── Link note to conversation
+8. Sync to frontend → User sees note in list
+```
+
+## Frontend Architecture
+
+```
+┌─────────────────────────────────────────────────────┐
+│ React 19 App │
+│ │
+│ ┌─────────────────────────────────────────────┐ │
+│ │ Wouter Router │ │
+│ │ / → HomePage │ │
+│ │ /notes → NotesPage │ │
+│ │ /note/:id → NotePage (editor) │ │
+│ │ /conversation/:id → ConversationDetailPage │ │
+│ │ /conversation/:id/transcript → TranscriptP │ │
+│ │ /conversation/:id/generating → GeneratingP │ │
+│ │ /transcript/:date → TranscriptPage │ │
+│ │ /collections → CollectionsPage │ │
+│ │ /folder/:id → FolderPage │ │
+│ │ /search → SearchPage │ │
+│ │ /settings → SettingsPage │ │
+│ │ /onboarding → OnboardingPage │ │
+│ └─────────────────────────────────────────────┘ │
+│ │
+│ ┌──────────────┐ ┌──────────────────────────┐ │
+│ │ useSynced │ │ Shared Components │ │
+│ │ (WebSocket │ │ ExportDrawer │ │
+│ │ + RPC) │ │ EmailDrawer │ │
+│ │ │ │ MultiSelectBar │ │
+│ │ useMulti- │ │ SelectionHeader │ │
+│ │ Select │ │ FilterDrawers │ │
+│ │ │ │ BottomDrawer │ │
+│ │ useAutoScr- │ │ LoadingState │ │
+│ │ oll │ │ WaveIndicator │ │
+│ │ │ │ SkeletonLoader │ │
+│ │ useSwipeTo- │ │ FABMenu │ │
+│ │ Reveal │ │ DropdownMenu │ │
+│ └──────────────┘ └──────────────────────────┘ │
+└─────────────────────────────────────────────────────┘
+```
+
+## Database Schema
+
+### MongoDB Collections
+
+| Collection | Key Fields | Purpose |
+|-----------|-----------|---------|
+| `usersettings` | userId, displayName, timezone, onboardingCompleted, role, company, priorities | User preferences |
+| `notes` | userId, title, content, date, isAIGenerated, isFavourite, isArchived, isTrashed, folderId | Notes data |
+| `conversations` | userId, date, title, status, startTime, endTime, chunkIds, aiSummary, noteId | Detected conversations |
+| `transcriptchunks` | userId, date, text, startTime, endTime, chunkIndex, classification, conversationId | 40s audio chunks |
+| `dailytranscripts` | userId, date, segments[] | Full day transcripts |
+| `hoursummaries` | userId, date, hour, summary, segmentCount | Hourly AI summaries |
+| `chatmessages` | userId, date, role, content | AI chat history |
+| `files` | userId, date, hasTranscript, hasNotes, r2Key, segmentCount, hourCount | Date metadata |
+| `folders` | userId, name, color | Note folders |
+| `userstates` | userId, batchEndOfDay | R2 batch scheduling |
+
+### Cloudflare R2 Structure
+
+```
+transcripts/
+ {userId}/
+ {YYYY-MM-DD}/
+ transcript.json ← Full day transcript (archived)
+ photos/
+ photo-{timestamp}.jpg ← Captured photos
+```
+
+## Sync Protocol
+
+### Connection
+1. Frontend opens WebSocket to `/ws/sync?userId=...`
+2. Backend creates/gets session for user
+3. Backend sends `{ type: "connected" }`
+4. Backend sends `{ type: "snapshot", state: {...} }` with full state
+5. Frontend renders from snapshot
+
+### State Updates
+- Backend: `@synced` property changes → diff sent as `{ type: "state_change", manager, property, value }`
+- Frontend: `useSynced()` hook receives changes, triggers React re-render
+
+### RPC Calls
+- Frontend: `session.notes.generateNote(title, start, end)`
+- Serialized as: `{ type: "rpc", manager: "notes", method: "generateNote", args: [...], id: "uuid" }`
+- Backend executes, returns: `{ type: "rpc_response", id: "uuid", result: {...} }`
+- Frontend resolves the Promise
+
+### Reconnection
+- On visibility change (tab hidden → visible): check connection, reconnect if needed
+- On reconnect: full snapshot re-sent (ensures consistency)
+- `onboardingResolvedRef` prevents onboarding re-trigger on reconnect
diff --git a/issues/18-release-notes-v3/CHANGELOG.md b/issues/18-release-notes-v3/CHANGELOG.md
new file mode 100644
index 0000000..8fde970
--- /dev/null
+++ b/issues/18-release-notes-v3/CHANGELOG.md
@@ -0,0 +1,134 @@
+# Mentra Notes v3.0.0 — Changelog
+
+## New Features
+
+### Auto-Conversation Pipeline
+- 3-stage pipeline: ChunkBuffer → TriageClassifier → ConversationTracker
+- Automatic conversation detection from meaningful speech
+- Provisional title generation every 3 chunks
+- AI summary generation on conversation end
+- Running summary maintained per conversation
+- Configurable silence thresholds, word minimums, model tiers
+
+### Multi-Select & Batch Operations
+- Long-press to enter selection mode on conversations, notes, and transcripts
+- Selection header with count, Cancel, Select All
+- Bottom action bar with contextual actions
+- No text selection during selection mode (select-none)
+- Instant checkbox appearance (no shift animation)
+
+### Conversation Merging
+- Merge 2-10 ended conversations into one
+- Fresh AI summary + title for merged conversation
+- Chunks reassigned to merged conversation
+- Option to trash originals after merge
+- Merged conversation highlighted with 4-second red pulse animation
+- Merge button grayed out when < 2 selected
+
+### Export & Email System
+- Export drawer with content toggles and destination selection
+- Clipboard export with formatted text + metadata
+- Email export via Resend with HTML templates
+- Notes: linked conversation + conversation transcript sub-toggles
+- Conversations: linked transcript + linked AI note toggles
+- Transcripts: per-date cards with segment tables
+- Content toggle always on (not disableable)
+- CC support with "Remember CC" checkbox
+
+### Transcript Deletion
+- Permanently deletes from R2 + MongoDB (not soft delete)
+- Warning drawer if conversations exist on those dates
+- "Transcript deleted" indicator on conversation detail + transcript pages
+- Removed from available dates on session reconnect
+
+### Redesigned Pages
+- Settings page: warm stone design, user profile at top, Supabase avatar
+- Email templates: warm stone colors, responsive, type badges
+- Conversation rows: favourite star icon, "Generating title..." spinner
+- Notes page: filter/view toggle visible on empty state
+
+### Mic Status Indicator
+- Compact circle next to FAB button
+- Red pulsing dot = mic on, gray icon = mic off
+- Shown on HomePage and NotesPage
+
+## Improvements
+
+### Conversation Detection
+- Requires 3 meaningful chunks to confirm (was 2)
+- Requires 2 consecutive fillers to pause (was 1)
+- Requires 7 silence chunks to end conversation (was 4, ~35s)
+- Topic change goes through PENDING confirmation (was instant new conversation)
+- Resumption classifier more lenient (only splits on completely unrelated subjects)
+- Default to continuation on LLM error (was default to new topic)
+- Background audio detection: TV, radio, podcasts classified as filler
+- Fast-track threshold raised from 10 to 25 words
+
+### Note Generation
+- Fallback to all segments when time filter matches 0 (timezone mismatch fix)
+- Uses conversation's startTime/endTime as fallback when chunks are empty
+- Summary generation uses chunkIds directly (not time range query)
+- Merge-aware prompt for broader title generation
+- Error logged to console for debugging
+
+### Scroll Behavior
+- TranscriptTab: scroll unlocks on scroll up, re-locks near bottom (200px threshold)
+- No auto-scroll on interim text updates (removed characterData from MutationObserver)
+- useAutoScroll hook: same fix applied to ConversationTranscriptPage
+- Initial scroll to bottom only fires once (not on every re-render)
+
+### Search
+- AbortController cancels stale queries (prevents flash of old results)
+- Minimum 2-second loading + actual query time
+- No more "nothing found" → results flash
+
+### Performance
+- Checkbox animations removed (instant show/hide, no framer-motion)
+- ConversationList overflow: visible (not hidden) for selection highlight
+- NotesPage: proper scroll container with min-h-0 + overflow-y-auto
+
+## Bug Fixes
+
+### Critical
+- Fixed: Notes page couldn't scroll (missing overflow-y-auto + min-h-0)
+- Fixed: Swipe-to-reveal broken by long-press handlers overwriting touch events (merged handlers)
+- Fixed: Conversations empty state blocked transcripts tab (removed early return)
+- Fixed: Transcript segments flashing between loading and "no transcript" (segmentsLoadedRef guard)
+- Fixed: S3Client memory leak — hundreds of instances per day (singleton pattern)
+- Fixed: ConversationManager segmentCache never cleared (clear in destroy)
+
+### Conversation
+- Fixed: "uh-huh" immediately pausing conversations (now needs 2 consecutive fillers)
+- Fixed: Topic change instantly creating new conversation (now goes through PENDING)
+- Fixed: Note generation failing due to time range mismatch (fallback to all segments)
+- Fixed: 0 minute duration display (minimum 1 minute)
+- Fixed: "New Conversation" / "Untitled Conversation" placeholder (now shows spinner)
+- Fixed: Merged conversation title same as source (merge-aware prompt + chunkIds-based query)
+- Fixed: Merged conversation had 0 chunks (use chunkIds array, not DB query)
+- Fixed: AI summary prompt mentioning "smart glasses" (removed)
+
+### UI
+- Fixed: Text selectable during multi-select mode (select-none always applied)
+- Fixed: Onboarding re-triggers on reconnect (onboardingResolvedRef guard)
+- Fixed: Dark mode leaking to new users (forced light mode)
+- Fixed: Favourites filter pill not showing in notes (added to condition)
+- Fixed: "Nothing found" flash before conversations hydrate (guard on isConversationsHydrated)
+- Fixed: Filter loading fake 3-second delay (removed, filters apply instantly)
+- Fixed: Loading/empty states not centered (h-full instead of flex-1)
+
+### Memory Leaks
+- Fixed: S3Client created per-request in r2Upload + r2Fetch (singleton)
+- Fixed: ConversationManager.segmentCache never cleared
+- Fixed: ConversationManager.provisionalTitleInFlight not cleared on destroy
+- Fixed: ChunkBufferManager buffer/callbacks not cleared on destroy
+- Fixed: TranscriptManager pendingSegments not cleared on destroy
+- Fixed: FileManager had no destroy() method
+- Fixed: ChatManager had no destroy() method
+- Fixed: SummaryManager LLM provider not cleared on destroy
+- Added: Memory logging on session create/destroy
+
+## Removed
+- Dark mode toggle (always light mode, commented out for future re-enable)
+- Text File export option (replaced by Email)
+- Share export option (replaced by Email)
+- Fake filter loading delays (3-second and 1-second timers removed)
diff --git a/issues/18-release-notes-v3/CONFIGURATION.md b/issues/18-release-notes-v3/CONFIGURATION.md
new file mode 100644
index 0000000..49b6532
--- /dev/null
+++ b/issues/18-release-notes-v3/CONFIGURATION.md
@@ -0,0 +1,108 @@
+# Mentra Notes v3.0.0 — Configuration
+
+## Environment Variables
+
+### Required
+
+| Variable | Description |
+|----------|-------------|
+| `MONGODB_URI` | MongoDB connection string |
+| `CLOUDFLARE_R2_ENDPOINT` | R2 endpoint URL |
+| `CLOUDFLARE_R2_ACCESS_KEY_ID` | R2 access key |
+| `CLOUDFLARE_R2_SECRET_ACCESS_KEY` | R2 secret key |
+| `CLOUDFLARE_R2_BUCKET_NAME` | R2 bucket name |
+
+### AI Provider (at least one required)
+
+| Variable | Description |
+|----------|-------------|
+| `GEMINI_API_KEY` | Google Gemini API key (primary) |
+| `ANTHROPIC_API_KEY` | Anthropic Claude API key (fallback) |
+| `OPENAI_API_KEY` | OpenAI API key (fallback) |
+
+### Optional
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `RESEND_API_KEY` | Resend email API key | — |
+| `PORT` | Server port | 3000 |
+| `NODE_ENV` | Environment (development/production) | development |
+| `POSTHOG_API_KEY` | PostHog analytics key | — |
+
+## Auto-Notes Pipeline Configuration
+
+Located in `src/backend/core/auto-conversation/config.ts`:
+
+### Buffer (Stage 1)
+
+| Parameter | Value | Description |
+|-----------|-------|-------------|
+| `BUFFER_INTERVAL_MS` | 5,000 | How often to package buffer into chunks (ms) |
+| `SENTENCE_BOUNDARY_MAX_WAIT_MS` | 3,000 | Max wait for sentence boundary after interval |
+
+### Triage (Stage 2)
+
+| Parameter | Value | Description |
+|-----------|-------|-------------|
+| `PRE_FILTER_WORD_MIN` | 4 | Chunks under this are auto-skipped |
+| `CONTEXT_LOOKBACK_CHUNKS` | 2 | Previous chunks for classification context |
+
+### Conversation Tracking (Stage 3)
+
+| Parameter | Value | Description |
+|-----------|-------|-------------|
+| `MIN_CHUNKS_TO_CONFIRM` | 3 | Meaningful chunks needed to create conversation (~15s) |
+| `PENDING_SILENCE_THRESHOLD` | 3 | Filler chunks in PENDING before discarding |
+| `CONTEXT_PREAMBLE_CHUNKS` | 3 | Preceding chunks pulled into new conversation |
+| `SILENCE_PAUSE_CHUNKS` | 2 | Consecutive fillers to pause conversation |
+| `SILENCE_END_CHUNKS` | 7 | Consecutive silence chunks to end conversation (~35s) |
+| `SUMMARY_MAX_WORDS` | 300 | Max words for running summary |
+| `SUMMARY_COMPRESSION_INTERVAL` | 3 | Chunks before compressing summary |
+| `RESUMPTION_WINDOW_MS` | 1,800,000 | Resume window (30 minutes) |
+
+### LLM Tiers
+
+| Parameter | Value | Description |
+|-----------|-------|-------------|
+| `TRIAGE_MODEL_TIER` | "fast" | Model for triage classification |
+| `TRACKER_MODEL_TIER` | "fast" | Model for conversation tracking |
+| `NOTE_GENERATION_MODEL_TIER` | "smart" | Model for note generation |
+| `SUMMARY_MODEL_TIER` | "fast" | Model for summary compression |
+
+### Token Limits
+
+| Parameter | Value |
+|-----------|-------|
+| `TRIAGE_MAX_TOKENS` | 64 |
+| `TRACKER_MAX_TOKENS` | 128 |
+| `NOTE_GENERATION_MAX_TOKENS` | 4,096 |
+| `SUMMARY_MAX_TOKENS` | 512 |
+
+## Feature Flags (PostHog)
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| `frontend-onboard` | true | Show onboarding flow for new users |
+
+## Porter Deployment Configuration
+
+```yaml
+name: mentra-notes
+services:
+ - name: mentra-notes
+ type: web
+ runtime: docker
+ plan: starter-2 # 2 CPU cores
+ ram: 5120 # 5GB RAM
+ port: 3000
+ healthcheck: /api/health
+ domains:
+ - general.mentra.glass
+```
+
+## Merge Limits
+
+| Parameter | Value | Description |
+|-----------|-------|-------------|
+| Min conversations to merge | 2 | Must select at least 2 |
+| Max conversations to merge | 10 | Performance limit |
diff --git a/issues/18-release-notes-v3/FEATURES.md b/issues/18-release-notes-v3/FEATURES.md
new file mode 100644
index 0000000..4c34244
--- /dev/null
+++ b/issues/18-release-notes-v3/FEATURES.md
@@ -0,0 +1,273 @@
+# Mentra Notes v3.0.0 — Complete Feature List
+
+## 1. Real-Time Transcription
+
+### Always-On Recording
+- Continuous microphone capture from MentraOS smart glasses
+- Final vs. interim text handling (real-time updates as user speaks)
+- Force-finalize at 50-word threshold to prevent buffer overflow
+- Transcription pause/resume toggle (settings + FAB menu)
+
+### Multi-Speaker Support
+- Speaker ID detection and tracking
+- Color-coded speaker labels in transcript view (6 colors)
+- Speaker-aware segments for conversation transcripts
+
+### Photo Capture
+- Photos captured at wake word time (parallel with user speaking)
+- Uploaded to Cloudflare R2 with metadata (timestamp, timezone)
+- Embedded in AI-generated notes when relevant
+- LLM-powered photo description for context
+
+### Transcript Organization
+- Per-day organization (YYYY-MM-DD folders)
+- Available dates tracking across MongoDB + R2
+- Historical transcript loading with date navigation
+- Hour-by-hour segment grouping with collapsible sections
+- Segment count per hour/day
+
+## 2. Auto-Notes Pipeline
+
+### Stage 1: ChunkBuffer
+- Accumulates transcript into 40-second chunks
+- 5-second heartbeat interval
+- Sentence boundary detection (waits up to 3 seconds for natural pause)
+- Silence signal emission when buffer empty + not speaking
+- Configurable via `AUTO_NOTES_CONFIG`
+
+### Stage 2: TriageClassifier
+- **Auto-skip**: Chunks under 4 words with no high-signal keywords
+- **Fast-track**: Chunks with 25+ words marked meaningful immediately
+- **LLM classification**: 4-24 word chunks classified by AI as MEANINGFUL or FILLER
+- Filler detection: background audio, TV/radio, podcasts, one-word acknowledgments
+- Context-aware: uses 2 previous chunks for classification context
+- Domain profile support (general, product manager, etc.)
+
+### Stage 3: ConversationTracker
+- State machine: `IDLE → PENDING → TRACKING → PAUSED → END`
+- **PENDING**: Requires 3 meaningful chunks (~15 seconds) before creating a conversation
+- **TRACKING**: Monitors for topic continuation, new topics, or filler
+- **PAUSED**: Requires 2 consecutive fillers to pause, 7 silence chunks to end
+- **Resumption**: LLM checks if new speech continues the paused conversation (lenient — only splits on completely unrelated subjects)
+- **Topic change**: Goes through PENDING confirmation instead of instant new conversation
+- Running summary maintained per conversation (compressed every 3 chunks, max 300 words)
+
+### Note Generation
+- Triggered automatically when conversation ends
+- Uses Gemini 2.5 Flash for structured note generation
+- HTML output with `
` headings, bullet lists, `` emphasis
+- 100-500 word target
+- Photo embedding when relevant (LLM decides relevance)
+- Safety pass for content review
+- Linked back to source conversation via `noteId`
+
+## 3. Notes Management
+
+### CRUD Operations
+- **Create**: Manual (rich text editor) or AI-generated from conversation
+- **Read**: Full note view with TipTap editor, metadata display
+- **Update**: Title + content editing, folder assignment
+- **Delete**: Three-tier — trash → permanent delete, or empty trash
+
+### Organization
+- **Folders**: Create with name + color (red, gray, blue), move notes between folders
+- **Favorites**: Quick-access flagging with star icon
+- **Archive**: Hide without deleting
+- **Trash**: Soft delete with recovery option, empty trash for permanent deletion
+- **Filter pills**: All, Manual, AI Generated
+- **Filter drawer**: Sort (recent/oldest), show filter (all/favourites/archived/trash)
+
+### Rich Text Editor (TipTap)
+- Headings (H1-H3)
+- Bold, italic, underline
+- Bullet and numbered lists
+- Code blocks
+- Image embedding (from photos)
+- Link insertion
+
+### Batch Operations (Multi-Select)
+- Long-press to enter selection mode
+- Select All / Cancel buttons
+- Actions: Export, Move to Folder, Favorite, Trash
+- Selection count display
+- No text selection during selection mode (select-none)
+
+## 4. Conversations
+
+### Auto-Detection
+- Conversations automatically detected from meaningful speech chunks
+- Provisional title generated every 3 chunks (LLM)
+- AI summary generated when conversation ends
+- Speaker-aware segments with color coding
+
+### Conversation Detail
+- Title (auto-generated, updates as conversation progresses)
+- Time range + duration (minimum 1 minute display)
+- AI summary section
+- Transcript section with speaker labels
+- "View full transcript" expand toggle (for 8+ segments)
+- Generate Note button (if no note linked)
+- Go to Note button (if note exists)
+- "Generating title..." spinner when title not yet available
+- "Transcript deleted" indicator if transcript data was removed
+
+### Conversation Actions
+- **Favorite** with star icon (visible in list)
+- **Archive / Unarchive** via swipe
+- **Trash** via swipe or multi-select
+- **Export**: clipboard or email with conversation summary + transcript + linked AI note
+- **Merge**: Combine 2-10 conversations into one with fresh AI summary
+- **Generate Note**: Create AI note from conversation segments
+
+### Merge Feature
+- Select 2-10 ended conversations
+- Merge drawer shows titles of conversations being merged
+- "Move originals to trash" checkbox (default: checked)
+- Creates new conversation with:
+ - Combined chunks from all sources (chronologically sorted)
+ - Fresh AI-generated title and summary (merge-aware prompt)
+ - Positioned after latest source conversation in list
+- Merged conversation highlighted with subtle red pulse for 4 seconds
+- Merge button grayed out when < 2 selected
+
+## 5. Transcripts Tab
+
+### Transcript List
+- Shows all available transcript dates with segment counts
+- Today highlighted with live recording indicator
+- "X days of transcripts" subtitle
+- Multi-select for batch export/delete
+
+### Transcript Page (Per-Day View)
+- Date header with back navigation
+- Hour-by-hour collapsible sections
+- Each hour shows:
+ - Hour label (9 AM, 2 PM, etc.)
+ - Conversation banner (linked summary title)
+ - Preview segments (first 2) with "+N more" expand
+ - Full expanded view with all segments
+ - Collapse button
+- Compact mode: single-line per hour with expand affordance
+- "Transcript deleted" full-page indicator if data was removed
+- Export and email via action menu
+
+### Transcript Deletion
+- Multi-select → Trash button
+- Confirmation drawer with warning if conversations exist on those dates
+- Permanently deletes from R2 + MongoDB (not recoverable)
+- Removed from available dates list
+- "Transcript deleted" shown on conversation detail and transcript pages
+
+## 6. Search
+
+### Semantic Search
+- Search across notes and conversations
+- Jina integration for semantic relevance
+- Results grouped by type (Notes, Conversations)
+- Score-based ranking
+
+### Search UX
+- Debounced input (400ms)
+- Minimum 2-second loading state + actual query time
+- Abort controller for canceling stale queries
+- Recent searches in localStorage
+- Filter pills: All, Notes, Conversations
+- "Nothing found" empty state with suggestions
+
+## 7. Export & Sharing
+
+### Clipboard Export
+- Notes: content + metadata (date, type, from conversation)
+- Conversations: summary + transcript segments (timestamped) + linked AI note
+- Transcripts: date headers + timestamped segments per day
+
+### Email Export
+- Rich HTML emails via Resend
+- Note cards with type badges (AI Generated / Manual / Conversation / Transcript)
+- Download buttons (PDF, TXT, Word) with signed URLs
+- Session info header (date, time range)
+- Responsive layout (620px desktop, full-width mobile)
+- CC support with "Remember CC" checkbox
+- Conversation email: includes summary, transcript table, linked notes
+- Transcript email: date-separated cards with segment tables
+
+### Export Drawer
+- Content toggle (always on, not disableable)
+- Linked Conversation toggle (notes) → sub-toggle for Conversation Transcript
+- Linked Transcript toggle (conversations)
+- Linked AI Note toggle (conversations)
+- Warning for items without linked data
+- Destination: Clipboard or Email
+
+## 8. Settings
+
+### User Profile
+- Display name, role, company (from onboarding)
+- Avatar from Supabase storage (falls back to initials)
+
+### Recording
+- Persistent transcription toggle
+
+### Onboarding
+- Reset onboarding option (restarts tutorial)
+
+### Timezone
+- Auto-detected from glasses
+- Displayed in settings
+
+### Mic Status Indicator
+- Compact circle next to FAB button
+- Red pulsing dot = mic on
+- Gray muted-mic icon = mic off
+- Shown on both HomePage and NotesPage
+
+## 9. Onboarding (9 Steps)
+
+1. Welcome screen
+2. About You (name, role, company)
+3. Priorities selection
+4. Contacts & Topics input
+5. Tutorial: Always-On recording
+6. Tutorial: AI Does the Work
+7. Tutorial: Stay Organized
+8. Tutorial: Swipe to Manage
+9. You're All Set (completion)
+
+- Feature flag controlled (`FRONTEND_ONBOARD`)
+- Won't re-trigger on reconnect (ref-guarded)
+- Settings persisted to MongoDB
+
+## 10. Glasses Integration (MentraOS)
+
+### Connection
+- Auto-detect glasses connection
+- Full mode when glasses connected (mic + display)
+- Timezone extraction from glasses settings
+
+### Display Modes
+- Live transcript on glasses
+- Hour summaries
+- Key points (future)
+
+### Input
+- Button press listeners
+- Touch input handling
+- Photo capture trigger
+
+## 11. Real-Time Sync
+
+### Architecture
+- Custom sync library (`@synced` decorator)
+- WebSocket connection per user
+- RPC pattern: frontend calls backend methods
+- Automatic state sync on property changes
+- Snapshot on connect, incremental updates after
+- Reconnect handling with visibility change detection
+- Multi-tab support (multiple clients per session)
+
+### Session Management
+- One session per user (shared across tabs/devices)
+- 11+ managers per session
+- Automatic hydration from MongoDB on session create
+- Graceful cleanup on disconnect
+- Memory logging on create/destroy
diff --git a/issues/18-release-notes-v3/OVERVIEW.md b/issues/18-release-notes-v3/OVERVIEW.md
new file mode 100644
index 0000000..7674fe2
--- /dev/null
+++ b/issues/18-release-notes-v3/OVERVIEW.md
@@ -0,0 +1,76 @@
+# Mentra Notes v3.0.0 — Release Overview
+
+## What is Mentra Notes?
+
+Mentra Notes is an always-on transcription and AI-powered note-taking application for MentraOS smart glasses. It continuously listens through the glasses microphone, automatically detects conversations, generates structured notes, and organizes everything by day — all without the user lifting a finger.
+
+## What's New in v3
+
+### Auto-Conversation Detection
+The app now automatically detects when a real conversation is happening vs. background noise. It uses a 3-stage pipeline — **Buffer → Triage → Track** — to classify audio chunks, group them into conversations, and trigger AI note generation when a conversation ends. No manual "start recording" button needed.
+
+### Multi-Select & Batch Operations
+Long-press any conversation, note, or transcript to enter selection mode. Select multiple items and perform batch actions:
+- **Export** to clipboard or email
+- **Merge** conversations into one (conversations only)
+- **Favorite** / **Trash** in bulk
+- **Move** notes to folders
+
+### Conversation Merging
+Select 2-10 conversations and merge them into a single conversation with a fresh AI-generated summary. Useful when the auto-detector splits what should be one continuous discussion. Option to trash the originals after merge.
+
+### Export & Email Sharing
+Export notes, conversations, or transcripts via:
+- **Clipboard** — formatted plain text with metadata
+- **Email** — rich HTML email with styled cards, timestamps, and download links (PDF/TXT/Word)
+
+Export options are contextual:
+- Notes: include linked conversation + conversation transcript
+- Conversations: include summary, linked transcript, linked AI note
+- Transcripts: include full segment-by-segment content
+
+### Redesigned Settings Page
+Warm stone design matching the rest of the app. Shows user profile (name, role, company, avatar) at top, followed by recording settings, preferences, and onboarding reset.
+
+### Redesigned Email Templates
+Both notes and transcript email templates updated to match the warm stone design:
+- Light beige background (#FAFAF9)
+- Stone-colored typography and borders
+- Responsive layout (full-width on mobile, 620px centered on desktop)
+- Note type badges: AI Generated (red), Manual (gray), Conversation (gray), Transcript (gray)
+
+### Transcript Deletion with Warnings
+Deleting transcripts now permanently removes data from both MongoDB and Cloudflare R2. If conversations exist on those dates, a warning shows how many will lose their linked transcript. Deleted transcripts show a clear "Transcript deleted" indicator on both the conversation detail and transcript pages.
+
+### Conversation End Detection Improvements
+- Requires 2 consecutive filler chunks before pausing (was 1) — "uh-huh" alone won't pause
+- Requires 7 silence chunks to end (was 4) — ~35 seconds instead of ~20
+- Topic change goes through PENDING confirmation (needs 3 chunks, not instant)
+- Resumption classifier is more lenient — only splits on completely unrelated subjects
+- Background audio detection improved (TV, radio, podcasts classified as filler)
+
+### Memory Leak Fixes
+- S3Client singleton pattern (was creating new client per R2 operation)
+- ConversationManager segment cache cleared on session destroy
+- All manager destroy() methods audited and fixed
+- Memory logging on session create/destroy for monitoring
+
+## Tech Stack
+
+| Layer | Technology |
+|-------|-----------|
+| Runtime | Bun |
+| Server | Hono.js |
+| Frontend | React 19 + Tailwind CSS 4 |
+| Editor | TipTap |
+| Animations | Framer Motion |
+| Routing | Wouter |
+| Database | MongoDB + Mongoose |
+| Object Storage | Cloudflare R2 |
+| AI/LLM | Gemini 2.5 Flash (primary), Claude, OpenAI |
+| Email | Resend |
+| Auth | MentraOS SDK |
+| Search | Jina (semantic) |
+| Analytics | PostHog |
+| Sync | Custom WebSocket + RPC library |
+| Deployment | Docker via Porter |
diff --git a/issues/19-componentize-frontend/PLAN.md b/issues/19-componentize-frontend/PLAN.md
new file mode 100644
index 0000000..beee5d6
--- /dev/null
+++ b/issues/19-componentize-frontend/PLAN.md
@@ -0,0 +1,97 @@
+# Issue 19: Componentize Frontend — Extract SVGs & Split Large Components
+
+Pure frontend cleanup. No backend changes. No logic changes. Just moving code into proper components.
+
+---
+
+## Part 1: Shared Icon Library
+
+Create `/src/frontend/components/icons/` with reusable icon components. Each icon takes `size` (default 20) and `className` props.
+
+### Icons to extract (6 patterns, 33+ duplicates):
+
+| Icon | Occurrences | Files |
+|------|-------------|-------|
+| **StarIcon** | 7 | ConversationFilterDrawer, NotesFilterDrawer, NotePage, CollectionsPage, NotesPage, TutorialAINotes, ConversationDetailPage |
+| **CloseIcon** (X) | 8+ | ExportDrawer, HomePage (×3), SearchPage, SkipDialog (×2), ContactsStep |
+| **MicrophoneIcon** | 10 | FABMenu (×2), TranscriptList (×3), HomePage, SearchPage, CollectionsPage, NotesPage (×2) |
+| **NoteIcon** (document) | 6 | NotesFilterDrawer, FABMenu, NotesPage, NotesFABMenu, WelcomeStep, ConversationDetailPage |
+| **FolderIcon** | 5 | MultiSelectBar, FolderPicker, FolderPage, NotesFABMenu |
+| **ArchiveIcon** / **TrashIcon** | 4+ | CollectionsPage, TutorialSwipe |
+
+### Steps:
+1. Create `components/icons/index.ts` barrel export
+2. Create each icon component (StarIcon.tsx, CloseIcon.tsx, etc.)
+3. Find-and-replace inline SVGs file by file — verify visually after each file
+
+---
+
+## Part 2: Split Large Components
+
+### P0 — HomePage.tsx (1,469 lines → ~400 lines)
+
+The biggest offender. Has 3 view modes, multi-select, export, and 12+ inline SVGs all in one file.
+
+**Extract:**
+- `components/ConversationListView.tsx` — conversation list with filters, sorting, date grouping
+- `components/TranscriptListView.tsx` — transcript list with its own filters
+- `components/HomeToolbar.tsx` — multi-select action bar (export, merge, favorite, delete)
+- `components/HomeHeader.tsx` — top bar with search, filter toggle, view switcher
+
+HomePage becomes an orchestrator: manages view state, passes handlers down.
+
+### P1 — NotesPage.tsx (887 lines → ~400 lines)
+
+**Extract:**
+- `components/NoteFilterPanel.tsx` — show filter, pill filter, search input
+- `components/FilteredNotesList.tsx` — renders filtered/sorted note rows
+- `components/NotesToolbar.tsx` — multi-select bar (export, move to folder, delete)
+- `hooks/useNoteFilters.ts` — filter state logic as a custom hook
+
+### P1 — TranscriptTab.tsx (816 lines → ~400 lines)
+
+**Extract:**
+- `components/HourSection.tsx` — collapsible hour group with sticky header
+- `components/TranscriptBanner.tsx` — interim text vs summary vs preview rendering
+- `hooks/useHourGrouping.ts` — segment grouping + collapse state logic
+
+### P2 — NotePage.tsx (782 lines → ~400 lines)
+
+**Extract:**
+- `components/NoteEditor.tsx` — TipTap editor with content
+- `components/EditorToolbar.tsx` — formatting toolbar (bold, italic, lists, etc.)
+- `components/NoteHeader.tsx` — title, timestamps, favorite/share/delete actions
+
+### P2 — SearchPage.tsx (504 lines)
+
+**Extract:**
+- `components/SearchResultsList.tsx` — result rendering
+- `components/RecentSearches.tsx` — recent search chips/list
+
+### P3 — ConversationDetailPage.tsx (478 lines)
+
+**Extract:**
+- `components/ConversationHeader.tsx` — title, actions bar
+- `components/ConversationActions.tsx` — favorite, delete, export actions
+
+### P3 — ConversationsTab.tsx (491 lines)
+
+**Extract:**
+- `components/ConversationCard.tsx` — individual conversation card with expand/collapse
+
+---
+
+## Part 3: Onboarding SVG Cleanup
+
+The onboarding components (WelcomeStep, TutorialAlwaysOn, TutorialAINotes, TutorialOrganize, TutorialSwipe, TutorialComplete, ContactsStep, SkipDialog) have ~40 inline SVGs total.
+
+**Approach:** These are mostly unique illustrations, not shared icons. Extract only the ones that overlap with the shared icon library (star, close, mic, note icons). Leave tutorial-specific decorative SVGs inline — they're used once and extracting them adds indirection without reducing duplication.
+
+---
+
+## Rules
+
+- No logic changes. No backend changes. Behavior stays identical.
+- Follow existing architecture: page-specific components go in that page's `components/` folder. Only truly shared icons go in `components/icons/`.
+- Verify visually after each extraction — no broken icons or missing props.
+- Do Part 1 (icons) first since it touches many files. Then split components top-down by priority.
diff --git a/issues/2-interim-fallback/PLAN.md b/issues/2-interim-fallback/PLAN.md
new file mode 100644
index 0000000..8c3f325
--- /dev/null
+++ b/issues/2-interim-fallback/PLAN.md
@@ -0,0 +1,213 @@
+# Interim Transcript Fallback — Implementation Plan
+
+Owner: Aryan
+Priority: Medium — Reliability fix for poor microphone scenarios
+
+---
+
+## Problem
+
+The chunk buffer only accepts **final** transcript segments (`NotesSession.ts:162`). If the speech recognition engine struggles (bad mic, background noise, accent issues), it may produce a stream of interim results that never finalize. In this case:
+
+1. `markSpeaking(true)` keeps firing (interims flowing)
+2. `addText()` is never called (no finals arrive)
+3. Heartbeat sees empty buffer + `isSpeaking = true` → skips silently
+4. **Result: Speech is lost. No chunks, no notes, no error.**
+
+---
+
+## Solution: Interim Timeout Fallback
+
+If interim transcripts have been flowing for N seconds without a single final arriving, take the **latest interim text** and promote it to a final — feeding it into the chunk buffer.
+
+This keeps finals as the primary (accurate) source while preventing silent data loss.
+
+---
+
+## Files to Modify
+
+```
+src/backend/session/NotesSession.ts — track interim state, add timeout logic
+src/backend/services/auto-notes/config.ts — add INTERIM_FALLBACK_TIMEOUT_MS parameter
+```
+
+No new files needed.
+
+---
+
+## Implementation Steps
+
+### Step 1 — Add Config Parameter
+
+In `config.ts`, add:
+
+```
+INTERIM_FALLBACK_TIMEOUT_MS: 15_000 // 15 seconds of interims with no final → promote latest interim
+```
+
+Why 15 seconds: Most speech recognition engines finalize within 5-10 seconds. 15s gives plenty of room for slow finalization while still catching the "never finalizes" case before the user loses a meaningful amount of speech.
+
+### Step 2 — Track Interim State in NotesSession
+
+Add to `NotesSession`:
+
+- `private _lastInterimText: string = ""` — stores the most recent interim transcript text
+- `private _lastInterimTime: number = 0` — timestamp of when interims started flowing (without a final in between)
+- `private _interimFallbackTimer: ReturnType | null = null`
+
+### Step 3 — Modify `onTranscription()` in NotesSession
+
+Current logic:
+```ts
+onTranscription(text: string, isFinal: boolean, speakerId?: string): void {
+ this.transcript.addSegment(text, isFinal, speakerId);
+ this.chunkBuffer.markSpeaking(!isFinal);
+ if (isFinal && text.trim()) {
+ this.chunkBuffer.addText(text);
+ }
+}
+```
+
+New logic:
+```ts
+onTranscription(text: string, isFinal: boolean, speakerId?: string): void {
+ this.transcript.addSegment(text, isFinal, speakerId);
+ this.chunkBuffer.markSpeaking(!isFinal);
+
+ if (isFinal && text.trim()) {
+ // Got a real final — feed it to chunk buffer and reset interim tracking
+ this.chunkBuffer.addText(text);
+ this.clearInterimFallback();
+ } else if (!isFinal && text.trim()) {
+ // Interim result — track it and start fallback timer if not already running
+ this._lastInterimText = text;
+ this.startInterimFallbackIfNeeded();
+ }
+}
+```
+
+### Step 4 — Implement Fallback Timer Methods
+
+```ts
+private startInterimFallbackIfNeeded(): void {
+ // Timer already running — just update the stored text (done in onTranscription)
+ if (this._interimFallbackTimer) return;
+
+ this._interimFallbackTimer = setTimeout(() => {
+ this.promoteInterim();
+ }, AUTO_NOTES_CONFIG.INTERIM_FALLBACK_TIMEOUT_MS);
+}
+
+private promoteInterim(): void {
+ if (this._lastInterimText.trim()) {
+ console.warn(
+ `[NotesSession] No final transcript in ${AUTO_NOTES_CONFIG.INTERIM_FALLBACK_TIMEOUT_MS}ms — promoting interim: "${this._lastInterimText.substring(0, 50)}..."`
+ );
+ this.chunkBuffer.addText(this._lastInterimText);
+ }
+ this.clearInterimFallback();
+}
+
+private clearInterimFallback(): void {
+ if (this._interimFallbackTimer) {
+ clearTimeout(this._interimFallbackTimer);
+ this._interimFallbackTimer = null;
+ }
+ this._lastInterimText = "";
+}
+```
+
+### Step 5 — Clean Up on Disconnect
+
+In `clearAppSession()`, add:
+```ts
+this.clearInterimFallback();
+```
+
+This prevents a stale timer from firing after glasses disconnect.
+
+---
+
+## Edge Cases
+
+### 1. Interim text keeps changing — which version do we use?
+We always store the **latest** interim (`_lastInterimText` gets overwritten on each interim event). By the time the 15s timer fires, we have the most refined version the speech engine produced. This is the best guess available.
+
+### 2. Final arrives just before the timer fires
+`clearInterimFallback()` is called on every final, which cancels the timer. No double-feeding occurs. The final takes priority as intended.
+
+### 3. Rapid alternating interim → final → interim → final
+Normal behavior. Each final cancels the timer, each new interim-only streak restarts it. The fallback only triggers during a sustained 15-second gap with no finals.
+
+### 4. Interim text overlaps with a late-arriving final
+Speech recognition engines replace interim text with the final. If a final arrives for the same utterance after we already promoted the interim, we'd get **duplicate text** in the buffer.
+
+**Mitigation:** After promoting an interim, set a short "cooldown" flag. If a final arrives within 2 seconds of a promotion and its text substantially overlaps with the promoted text (e.g., >70% word overlap), skip that final to avoid duplication.
+
+Add to config:
+```
+INTERIM_PROMOTION_COOLDOWN_MS: 2_000
+```
+
+Implementation:
+```ts
+private _lastPromotionTime: number = 0;
+private _lastPromotedText: string = "";
+
+// In the final handling path:
+if (isFinal && text.trim()) {
+ if (this.shouldSkipDuplicateFinal(text)) {
+ console.log(`[NotesSession] Skipping duplicate final after interim promotion`);
+ this.clearInterimFallback();
+ return; // skip addText, still clear the timer
+ }
+ this.chunkBuffer.addText(text);
+ this.clearInterimFallback();
+}
+
+private shouldSkipDuplicateFinal(finalText: string): boolean {
+ if (!this._lastPromotedText) return false;
+ const elapsed = Date.now() - this._lastPromotionTime;
+ if (elapsed > AUTO_NOTES_CONFIG.INTERIM_PROMOTION_COOLDOWN_MS) return false;
+
+ // Simple word overlap check
+ const promotedWords = new Set(this._lastPromotedText.toLowerCase().split(/\s+/));
+ const finalWords = finalText.toLowerCase().split(/\s+/);
+ const overlap = finalWords.filter(w => promotedWords.has(w)).length;
+ return overlap / finalWords.length > 0.7;
+}
+```
+
+### 5. User goes silent (stops speaking) — interims stop, timer is still running
+If the user stops speaking, interims stop flowing and `markSpeaking(false)` is called (from the last final or silence detection). The timer may still fire with stale interim text.
+
+**Mitigation:** In `promoteInterim()`, check `this.chunkBuffer._isSpeaking`. If false (no speech activity), skip promotion — the user already stopped talking and the text was likely already captured via finals or is truly silence.
+
+### 6. Very short interims (single words like "um", "uh")
+These would be promoted if 15 seconds pass. This is acceptable — the triage classifier downstream already filters chunks under 4 words as `auto-skipped`. No special handling needed.
+
+### 7. Session dispose while timer is pending
+Add to `dispose()`:
+```ts
+this.clearInterimFallback();
+```
+
+---
+
+## Testing Checklist
+
+- [ ] Normal mic: finals flow normally, fallback timer never fires
+- [ ] Simulate bad mic: send only interims for 20s → verify interim gets promoted at 15s
+- [ ] Send interims for 14s then a final → verify timer cancels, final is used
+- [ ] Send interims, promote happens, then late final arrives → verify dedup works
+- [ ] Disconnect glasses while timer is running → verify timer is cleaned up
+- [ ] Session dispose while timer is running → verify no errors
+
+---
+
+## Config Summary
+
+| Parameter | Value | Purpose |
+|-----------|-------|---------|
+| `INTERIM_FALLBACK_TIMEOUT_MS` | 15,000 ms | How long to wait for a final before promoting interim |
+| `INTERIM_PROMOTION_COOLDOWN_MS` | 2,000 ms | Window after promotion where duplicate finals are skipped |
diff --git a/issues/3-semantic-search/PLAN.md b/issues/3-semantic-search/PLAN.md
new file mode 100644
index 0000000..887edd0
--- /dev/null
+++ b/issues/3-semantic-search/PLAN.md
@@ -0,0 +1,375 @@
+# Semantic Search — Phase 1 & Phase 3 Implementation Plan
+
+## Overview
+
+Implement semantic search over notes and conversations (Phase 1), then add AI quick answers synthesized from search results (Phase 3). Phase 2 (transcript ingestion from R2) and Phase 4 (conversational AI) are out of scope.
+
+---
+
+## Phase 1: Semantic Search over Notes & Conversations
+
+### Task 1: Embedding Service
+
+**New file:** `src/backend/services/embedding.service.ts`
+
+Create a thin wrapper around OpenAI's embeddings API (already in `package.json` as `openai`). Use `text-embedding-3-small` — cheap, fast, 1536 dimensions, good enough for this use case.
+
+```ts
+// Exports:
+export async function generateEmbedding(text: string): Promise
+export async function generateEmbeddings(texts: string[]): Promise
+```
+
+- Use the existing `OPENAI_API_KEY` env var.
+- For notes: strip HTML tags from content, then concatenate `title + " " + stripped_content`.
+- For conversations: concatenate `title + " " + aiSummary` (only embed conversations that have an `aiSummary`).
+- Keep it simple — no batching beyond what OpenAI supports in a single call.
+
+---
+
+### Task 2: Add `embedding` Field to Note & Conversation Models
+
+**Files:**
+- `src/backend/models/note.model.ts`
+- `src/backend/models/conversation.model.ts`
+
+Add to both schemas:
+
+```ts
+embedding: { type: [Number], default: [] }
+```
+
+Add to both interfaces:
+
+```ts
+embedding: number[];
+```
+
+No migration needed — existing docs will just have `embedding: []` (empty), and won't show up in vector search results until they get backfilled.
+
+---
+
+### Task 3: Generate Embeddings on Create/Update
+
+**Files:**
+- `src/backend/models/note.model.ts` — update `createNote()` and `updateNote()` helpers
+- `src/backend/models/conversation.model.ts` — update `createConversation()` and `updateConversation()`
+
+For notes:
+- In `createNote()`: after creation, strip HTML from content, generate embedding from `title + " " + stripped_content`, and save it.
+- In `updateNote()`: if `title` or `content` changed, strip HTML, regenerate embedding.
+- HTML stripping: simple regex (`text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()`) is sufficient — no need for a DOM parser.
+
+For conversations:
+- In `updateConversation()`: if `aiSummary` is being set (conversation ended), generate embedding from `title + " " + aiSummary` and save it.
+- **Only embed conversations that have an `aiSummary`.** Active/paused conversations without `aiSummary` are skipped entirely — no embedding, won't appear in search.
+- Backfill script also skips conversations where `aiSummary` is empty.
+
+Fire-and-forget pattern: don't block the response on embedding generation. Use `.then()` / `.catch()` to update async.
+
+---
+
+### Task 4: Create MongoDB Atlas Vector Search Indexes
+
+Two indexes needed (created via Atlas UI or CLI, not in app code):
+
+**Index 1 — notes collection:**
+```json
+{
+ "type": "vectorSearch",
+ "definition": {
+ "fields": [
+ {
+ "type": "vector",
+ "path": "embedding",
+ "numDimensions": 1536,
+ "similarity": "cosine"
+ },
+ {
+ "type": "filter",
+ "path": "userId"
+ }
+ ]
+ }
+}
+```
+
+**Index 2 — conversations collection:**
+Same structure, same field paths.
+
+Index names: `notes_vector_index`, `conversations_vector_index`.
+
+Add a script or document the Atlas CLI commands to create these indexes. Consider adding a setup script at `src/scripts/create-vector-indexes.ts`.
+
+---
+
+### Task 5: Search Service
+
+**New file:** `src/backend/services/search.service.ts`
+
+```ts
+interface SearchResult {
+ id: string;
+ type: "note" | "conversation";
+ title: string;
+ summary: string;
+ date: string;
+ score: number;
+ content?: string; // For notes
+}
+
+export async function semanticSearch(
+ userId: string,
+ query: string,
+ limit?: number,
+): Promise
+```
+
+Implementation:
+1. Generate embedding for the query using `generateEmbedding()`.
+2. Run two `$vectorSearch` aggregation pipelines in parallel (one per collection):
+ ```ts
+ collection.aggregate([
+ {
+ $vectorSearch: {
+ index: "notes_vector_index",
+ path: "embedding",
+ queryVector: queryEmbedding,
+ numCandidates: 50,
+ limit: limit || 10,
+ filter: { userId },
+ },
+ },
+ {
+ $project: {
+ title: 1,
+ summary: 1,
+ content: 1,
+ date: 1,
+ score: { $meta: "vectorSearchScore" },
+ },
+ },
+ ]);
+ ```
+3. Merge results from both collections, sort by score descending, return top N.
+
+---
+
+### Task 6: Search API Endpoint
+
+**File:** `src/backend/api/router.ts`
+
+Add a new authenticated endpoint:
+
+```
+GET /api/search?q=&limit=
+```
+
+- Requires `authMiddleware`.
+- Calls `semanticSearch(userId, query, limit)`.
+- Returns `{ results: SearchResult[] }`.
+
+---
+
+### Task 7: Backfill Script
+
+**New file:** `src/scripts/backfill-embeddings.ts`
+
+A one-time script to generate embeddings for all existing notes and conversations that have `embedding: []`.
+
+- Process in batches of 20 (OpenAI embeddings API supports batch input).
+- Log progress.
+- Run with `bun run src/scripts/backfill-embeddings.ts`.
+
+---
+
+### Task 8: Search Page (Frontend)
+
+**New file:** `src/frontend/pages/search/SearchPage.tsx`
+
+Layout:
+- Search bar at top (shadcn `Input` with search icon).
+- Results list below, each result is a clickable card showing:
+ - Type badge ("Note" or "Conversation")
+ - Title
+ - Summary (truncated)
+ - Date
+ - Relevance score (optional, subtle)
+- Empty state when no query / no results.
+- Loading state while searching.
+
+Use existing patterns:
+- `useSynced` for auth context / userId.
+- `fetch()` to call `/api/search?q=...`.
+- shadcn `Input`, `Badge`, `Card` components.
+- `motion` for list animations (consistent with other pages).
+
+---
+
+### Task 9: Add Search to Navigation
+
+**File:** `src/frontend/components/layout/Shell.tsx`
+
+Add a search icon (`Search` from lucide-react) to the bottom nav:
+- **Mobile:** Add as 4th item in bottom bar (between Home and Settings, or as a dedicated icon).
+- **Desktop:** Add to the sidebar icon list.
+
+Route: `/search`.
+
+**File:** `src/frontend/router.tsx`
+
+Add route:
+```tsx
+
+```
+
+---
+
+### Task 10: Click-Through from Search Results
+
+When a user clicks a search result:
+- **Note result:** Navigate to `/note/:id` (existing route).
+- **Conversation result:** Navigate to `/day/:date?tab=conversations&conversationId=`. The DayPage reads the query params, switches to the Conversations tab, scrolls to the matching conversation, and auto-expands it.
+
+**DayPage changes needed:**
+- Read `tab` and `conversationId` from URL query params on mount.
+- If `tab=conversations`, set the active tab to Conversations.
+- If `conversationId` is present, scroll that conversation into view and expand/open it.
+- Use `scrollIntoView({ behavior: "smooth" })` after the conversation list renders.
+
+**SearchResult type update:**
+- Conversation results must include the `conversationId` so the frontend can build the URL.
+
+---
+
+## Phase 3: AI Quick Answers
+
+### Task 11: Answer Generation Service
+
+**New file:** `src/backend/services/answer.service.ts`
+
+```ts
+export async function generateAnswer(
+ query: string,
+ searchResults: SearchResult[],
+): Promise
+```
+
+Implementation:
+- Take the top 5 search results.
+- Build a prompt with the search results as context.
+- Call the existing LLM provider (`createProviderFromEnv()`) with tier `"fast"`.
+- System prompt: "You are a helpful assistant. Answer the user's question based ONLY on the provided context. If the context doesn't contain enough information, say so. Be concise — 1-3 sentences."
+- Return the generated answer string.
+
+---
+
+### Task 12: Add AI Answer to Search Endpoint
+
+**File:** `src/backend/api/router.ts`
+
+Extend the search endpoint with an optional `ai=true` query param:
+
+```
+GET /api/search?q=&limit=10&ai=true
+```
+
+When `ai=true`:
+1. Run semantic search as before.
+2. Pass results to `generateAnswer()`.
+3. Return `{ answer: string, results: SearchResult[] }`.
+
+This keeps the AI answer opt-in so the base search stays fast.
+
+---
+
+### Task 13: AI Answer UI
+
+**File:** `src/frontend/pages/search/SearchPage.tsx`
+
+Add an AI answer section above the results list:
+- Shows a card with the AI-generated answer.
+- Has a subtle "AI Answer" label.
+- Appears with a fade-in animation after search results load.
+- Loading state: skeleton or shimmer while generating.
+- Toggle or auto-enabled — start with always-on when results exist.
+
+---
+
+## Implementation Order
+
+| # | Task | Phase | Depends On |
+|----|------|-------|------------|
+| 1 | Embedding service | 1 | — |
+| 2 | Add embedding field to models | 1 | — |
+| 3 | Generate embeddings on create/update | 1 | 1, 2 |
+| 4 | Create vector search indexes | 1 | 2 |
+| 5 | Search service | 1 | 1, 4 |
+| 6 | Search API endpoint | 1 | 5 |
+| 7 | Backfill script | 1 | 1, 2 |
+| 8 | Search page (frontend) | 1 | 6 |
+| 9 | Add search to nav | 1 | 8 |
+| 10 | Click-through from results | 1 | 8 |
+| 11 | Answer generation service | 3 | 5 |
+| 12 | Add AI answer to endpoint | 3 | 6, 11 |
+| 13 | AI answer UI | 3 | 8, 12 |
+
+Tasks 1, 2, and 4 can be done in parallel. Tasks 8, 9, 10 can be done in parallel once 6 is done. Phase 3 tasks (11-13) are sequential and depend on Phase 1 being complete.
+
+---
+
+## Edge Cases & Type Safety
+
+### Types that must NOT change
+- `Note` in `src/shared/types.ts` — shared with frontend, no `embedding` field. Leave it alone.
+- `NoteData` in `NotesManager.ts` — same, frontend-facing. No `embedding`.
+- `Conversation` in `src/shared/types.ts` — frontend type, no `embedding`.
+- `ConversationManagerI` in `src/shared/types.ts` — no changes.
+
+### Types that DO change (backend only)
+- `NoteI` in `note.model.ts` — add `embedding: number[]`.
+- `ConversationI` in `conversation.model.ts` — add `embedding: number[]`.
+- These are Mongoose interfaces, never sent to the frontend directly. `NotesManager.hydrate()` already cherry-picks fields into `NoteData`, so the `embedding` field is naturally excluded.
+
+### `updateNote()` model helper
+- Current signature only accepts `title`, `content`, `summary`, `isStarred`. It does NOT accept `embedding`.
+- **Do NOT widen that function** — it's used by the frontend-facing RPC and we don't want embeddings passed through there.
+- Instead, embedding updates use a separate direct Mongoose call: `Note.updateOne({ _id }, { $set: { embedding } })`. Keep embedding logic isolated in the embedding service or a dedicated helper.
+
+### `$project` in vector search must exclude `embedding`
+- The `$project` stage in the `$vectorSearch` pipeline must NOT return the `embedding` field. Embeddings are 1536 floats — sending them over the wire to the frontend would be wasteful and leak internal data.
+
+### HTML stripping edge cases
+- Notes can contain `` tags with photo URLs. Strip those too — they're not searchable text.
+- Empty content after stripping (e.g., a note that's only photos) → embed just the title. If title is also empty, skip embedding.
+- `&`, `<`, etc. — decode HTML entities after stripping tags.
+
+### Conversations without `aiSummary`
+- Active and paused conversations have no `aiSummary`. They are NOT embedded and will NOT appear in search results. This is intentional.
+- The backfill script must filter: `{ aiSummary: { $ne: "" }, embedding: { $size: 0 } }` (or check for missing `embedding` field).
+
+### Empty/short content
+- If a note's stripped text (title + content) is less than ~5 characters, skip embedding. It won't produce meaningful search results.
+- Same for conversations — if `title + aiSummary` is trivially short, skip.
+
+### Embedding failures
+- Fire-and-forget: if OpenAI embedding call fails (rate limit, network), the document just doesn't get an embedding. It won't appear in search.
+- Log the error but don't throw — never block note creation/update on embedding failure.
+- The backfill script can be re-run to catch any documents that were missed.
+
+### Search with zero results
+- If both `$vectorSearch` queries return empty (user has no embedded content yet), return `{ results: [] }` — not an error.
+- Frontend shows a "No results found" empty state.
+
+### Concurrent embedding updates
+- If a user rapidly edits a note, multiple embedding calls could fire. Last-write-wins is fine — the final embedding will reflect the latest content. No locking needed.
+
+---
+
+## Key Decisions
+
+- **Embedding model:** OpenAI `text-embedding-3-small` (1536 dims). Cheap ($0.02/1M tokens), fast, good quality. OpenAI SDK is already a dependency.
+- **Inline embeddings:** Stored directly on note/conversation documents. Simple, no extra collections for Phase 1.
+- **Fire-and-forget embedding generation:** Don't block CRUD operations on embedding calls. If embedding fails, document just won't appear in search until next update.
+- **No external vector DB:** MongoDB Atlas Vector Search handles everything. No Pinecone/Weaviate dependency.
+- **AI answers use existing LLM provider:** Reuses the `createProviderFromEnv()` infrastructure. No new LLM dependency.
diff --git a/issues/4-share-feature/PLAN.md b/issues/4-share-feature/PLAN.md
new file mode 100644
index 0000000..03c36c1
--- /dev/null
+++ b/issues/4-share-feature/PLAN.md
@@ -0,0 +1,767 @@
+# Production Readiness Plan — Notes-Isaiah
+
+## Overview
+
+This plan addresses all issues found during the production audit of the diff against `origin/dev`. Issues are grouped into implementation tasks ordered by priority.
+
+---
+
+## Task 1: Add auth middleware to email & download endpoints
+
+**Why:** Email send endpoints (`POST /email/send`, `POST /transcript/email`) and download endpoints (`GET /notes/:id/download/:format`, `GET /transcripts/:transcriptId/download/:format`) have NO authentication. Anyone can send emails through our Resend account or download any user's notes/transcripts.
+
+**File:** `src/backend/api/router.ts`
+
+### 1a. Add `authMiddleware` to email endpoints
+
+**Line 684** — Change:
+```ts
+api.post("/email/send", async (c) => {
+```
+To:
+```ts
+api.post("/email/send", authMiddleware, async (c) => {
+```
+
+**Line 829** — Change:
+```ts
+api.post("/transcript/email", async (c) => {
+```
+To:
+```ts
+api.post("/transcript/email", authMiddleware, async (c) => {
+```
+
+### 1b. Add signed token system for download URLs
+
+Download links are clicked from emails (no auth cookie), so we need a different approach: **HMAC-signed URLs**.
+
+**Create new file:** `src/backend/services/signedUrl.service.ts`
+
+```ts
+import { createHmac } from "crypto";
+
+const SECRET = process.env.COOKIE_SECRET || process.env.MENTRAOS_API_KEY || "fallback-secret";
+
+// Token expires after 7 days
+const EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
+
+/**
+ * Generate a signed token for a download URL.
+ * Format: {expiresAt_hex}.{hmac_hex}
+ */
+export function generateDownloadToken(resourceId: string): string {
+ const expiresAt = Date.now() + EXPIRY_MS;
+ const payload = `${resourceId}:${expiresAt}`;
+ const hmac = createHmac("sha256", SECRET).update(payload).digest("hex");
+ return `${expiresAt.toString(16)}.${hmac}`;
+}
+
+/**
+ * Verify a signed download token.
+ * Returns true if valid and not expired.
+ */
+export function verifyDownloadToken(resourceId: string, token: string): boolean {
+ try {
+ const [expiresHex, hmac] = token.split(".");
+ if (!expiresHex || !hmac) return false;
+
+ const expiresAt = parseInt(expiresHex, 16);
+ if (Date.now() > expiresAt) return false;
+
+ const payload = `${resourceId}:${expiresAt}`;
+ const expected = createHmac("sha256", SECRET).update(payload).digest("hex");
+ return hmac === expected;
+ } catch {
+ return false;
+ }
+}
+```
+
+**In `src/backend/services/resend.service.ts`:**
+
+Import and use `generateDownloadToken` when building download URLs:
+
+```ts
+import { generateDownloadToken } from "./signedUrl.service";
+```
+
+**Notes email** — download URLs are built entirely in `buildNoteCardHtml` (JS, no template placeholder), so just append the token:
+
+In `buildNoteCardHtml` (line 34), change:
+```ts
+const downloadBase = `${BASE_URL}/api/notes/${note.noteId}/download`;
+```
+To:
+```ts
+const token = generateDownloadToken(note.noteId);
+const downloadBase = `${BASE_URL}/api/notes/${note.noteId}/download`;
+```
+
+Then update the 3 download links in the return template to append `?token=${token}`:
+```
+href="${downloadBase}/pdf?token=${token}"
+href="${downloadBase}/txt?token=${token}"
+href="${downloadBase}/docx?token=${token}"
+```
+
+**Transcript email** — download URLs use `{{downloadBase}}` as an HTML template placeholder in `transcript-email.html`. Since the token needs to be per-format URL, replace the single `{{downloadBase}}` with 3 separate placeholders in the HTML template:
+
+In `src/public/resend-email-template/transcript-email.html`, change the 3 download links from:
+```html
+PDF
+...
+TXT
+...
+Word
+```
+To:
+```html
+PDF
+...
+TXT
+...
+Word
+```
+
+Then in `buildTranscriptEmailHtml` (line 180), replace:
+```ts
+const downloadBase = `${BASE_URL}/api/transcripts/${transcriptId}/download`;
+```
+With:
+```ts
+const token = generateDownloadToken(transcriptId);
+const downloadBase = `${BASE_URL}/api/transcripts/${transcriptId}/download`;
+const downloadPdf = `${downloadBase}/pdf?token=${token}`;
+const downloadTxt = `${downloadBase}/txt?token=${token}`;
+const downloadDocx = `${downloadBase}/docx?token=${token}`;
+```
+
+And update the template replacements from:
+```ts
+.replaceAll("{{downloadBase}}", downloadBase)
+```
+To:
+```ts
+.replaceAll("{{downloadPdf}}", downloadPdf)
+.replaceAll("{{downloadTxt}}", downloadTxt)
+.replaceAll("{{downloadDocx}}", downloadDocx)
+```
+
+**In `src/backend/api/router.ts` download endpoints:**
+
+Add token verification at the start of each download handler:
+
+For note download (line 720):
+```ts
+api.get("/notes/:id/download/:format", async (c) => {
+ try {
+ const noteId = c.req.param("id");
+ const format = c.req.param("format");
+ const token = c.req.query("token");
+
+ // Verify signed token
+ const { verifyDownloadToken } = await import("../services/signedUrl.service");
+ if (!token || !verifyDownloadToken(noteId, token)) {
+ return c.json({ error: "Invalid or expired download link" }, 403);
+ }
+ // ... rest of handler
+```
+
+For transcript download (line 868):
+```ts
+api.get("/transcripts/:transcriptId/download/:format", async (c) => {
+ try {
+ const transcriptId = c.req.param("transcriptId");
+ const format = c.req.param("format");
+ const token = c.req.query("token");
+
+ const { verifyDownloadToken } = await import("../services/signedUrl.service");
+ if (!token || !verifyDownloadToken(transcriptId, token)) {
+ return c.json({ error: "Invalid or expired download link" }, 403);
+ }
+ // ... rest of handler
+```
+
+### 1c. Scope note download session search to authenticated user
+
+**Line 732-740** in `router.ts` — The note download loops through ALL active sessions. Since we're using signed tokens (not auth), we keep the loop but this is acceptable because the token gates access. However, if we later add auth, scope to:
+```ts
+// Only check the user's own session, not all sessions
+const session = sessions.get(userId);
+if (session) {
+ const found = session.notes.notes.find((n: any) => n.id === noteId);
+ if (found) noteData = found;
+}
+```
+
+For now with signed URLs, the current approach is acceptable.
+
+---
+
+## Task 2: Derive BASE_URL from request + update env.example
+
+**Why:** The current `BASE_URL` defaults to a dev ngrok URL (`https://general.dev.tpa.ngrok.app`). The server can't auto-detect its public URL (it only sees `localhost:3000`), but we can derive it from the `Origin` or `Host` header of the incoming request when the email endpoint is called — the browser already knows the correct origin.
+
+**File:** `src/backend/services/resend.service.ts`
+
+Remove the module-level `BASE_URL` constant (line 7):
+```ts
+// DELETE THIS LINE:
+const BASE_URL = process.env.BASE_URL || "https://general.dev.tpa.ngrok.app";
+```
+
+Instead, **pass `baseUrl` as a parameter** to the email builder functions. Derive it in the router from the request.
+
+Update `buildNoteCardHtml` signature to accept `baseUrl`:
+```ts
+function buildNoteCardHtml(note: NoteItem, baseUrl: string): string {
+```
+
+Update `buildNotesEmailHtml` to accept and pass `baseUrl`:
+```ts
+function buildNotesEmailHtml({
+ sessionDate, sessionStartTime, sessionEndTime, notes, baseUrl,
+}: Omit & { baseUrl: string }) {
+ const noteCards = notes.map((n) => buildNoteCardHtml(n, baseUrl)).join("\n");
+ // ...
+}
+```
+
+Same for `sendNotesEmail`, `buildTranscriptEmailHtml`, `sendTranscriptEmail` — add `baseUrl` parameter.
+
+**File:** `src/backend/api/router.ts`
+
+Add a helper to extract the base URL from the request:
+```ts
+function getBaseUrl(c: any): string {
+ // Try Origin header first (set by browser on same-origin requests)
+ const origin = c.req.header("origin");
+ if (origin) return origin;
+
+ // Fallback to Host header + protocol
+ const host = c.req.header("host");
+ const proto = c.req.header("x-forwarded-proto") || "https";
+ if (host) return `${proto}://${host}`;
+
+ // Last resort fallback
+ return process.env.BASE_URL || "https://localhost:3000";
+}
+```
+
+Then in each email endpoint, pass it through:
+```ts
+const baseUrl = getBaseUrl(c);
+const result = await sendNotesEmail({ to, cc, sessionDate, sessionStartTime, sessionEndTime, notes, baseUrl });
+```
+
+**File:** `env.example`
+
+`BASE_URL` is no longer required, but keep it as an optional override. Add the missing env vars:
+
+```env
+# =============================================================================
+# Cloudflare R2 - For storing transcripts and photos
+# =============================================================================
+
+CLOUDFLARE_R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
+CLOUDFLARE_R2_BUCKET_NAME=mentra-notes
+CLOUDFLARE_R2_ACCESS_KEY_ID=your_r2_access_key
+CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_r2_secret_key
+
+# =============================================================================
+# Email (Resend) - For sending notes and transcripts via email
+# =============================================================================
+
+RESEND_API_KEY=re_your_resend_api_key
+
+# Optional: Override the base URL for download links in emails.
+# If not set, automatically derived from the request Origin/Host header.
+# BASE_URL=https://your-app-domain.com
+```
+
+---
+
+## Task 3: Remove error details leak (all endpoints)
+
+**Why:** `details: String(err)` and `err.message` in error responses can leak internal stack traces, file paths, or API keys to the client.
+
+**File:** `src/backend/api/router.ts`
+
+**Line 709** — Remove `details`:
+```ts
+return c.json({ error: err.message || "Failed to send email", details: String(err) }, 500);
+```
+To:
+```ts
+return c.json({ error: "Failed to send email" }, 500);
+```
+
+**Line 818** — Download endpoint:
+```ts
+return c.json({ error: err.message || "Failed to generate download" }, 500);
+```
+To:
+```ts
+return c.json({ error: "Failed to generate download" }, 500);
+```
+
+**Line 860** — Remove `details`:
+```ts
+return c.json({ error: err.message || "Failed to send email", details: String(err) }, 500);
+```
+To:
+```ts
+return c.json({ error: "Failed to send email" }, 500);
+```
+
+**Line 1009** — Download endpoint:
+```ts
+return c.json({ error: err.message || "Failed to generate download" }, 500);
+```
+To:
+```ts
+return c.json({ error: "Failed to generate download" }, 500);
+```
+
+Keep the `console.error` calls — those log to server only.
+
+---
+
+## Task 4: Validate & sanitize email inputs
+
+**Why:** The `notes` array from the request body is passed directly to `sendNotesEmail` without validation. Malformed input could inject arbitrary HTML into emails. Transcript `seg.text` is also interpolated raw into email HTML with no escaping.
+
+**File:** `src/backend/api/router.ts`
+
+### 4a. Validate notes array
+
+After line 693 (the `notes.length === 0` check), add:
+
+```ts
+// Validate each note item
+for (const note of notes) {
+ if (!note.noteId || !note.noteTitle || typeof note.noteContent !== "string") {
+ return c.json({ error: "Each note must have noteId, noteTitle, and noteContent" }, 400);
+ }
+}
+```
+
+### 4b. Sanitize note content (proper HTML sanitization)
+
+Script-tag-only stripping is insufficient — email HTML injection can happen via ``, ``, `
{options.map((option, index) => {
if (isDivider(option)) {
- return (
-
- );
+ return ;
}
- const Icon = option.icon;
-
return (