From aae22a0e231c74cebd813161e23f895f83633393 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 30 Jun 2026 16:12:55 -0500 Subject: [PATCH] Add all scaffolding, design, and decision documentation AKA first commit --- .env.example | 4 + .gitignore | 34 ++ .prettierrc | 6 + CLAUDE.md | 102 ++++++ README-SCAFFOLD.md | 17 + docs/00-overview.md | 68 ++++ docs/01-requirements.md | 228 +++++++++++++ docs/02-architecture.md | 178 ++++++++++ docs/03-data-model.md | 311 ++++++++++++++++++ docs/04-api-contracts.md | 243 ++++++++++++++ docs/05-roadmap.md | 237 +++++++++++++ docs/06-test-strategy.md | 147 +++++++++ docs/07-research-findings.md | 111 +++++++ docs/adr/0001-app-shell-tauri.md | 49 +++ docs/adr/0002-frontend-svelte.md | 39 +++ docs/adr/0003-transcription-engine.md | 41 +++ docs/adr/0004-hardware-acceleration.md | 46 +++ docs/adr/0005-diarization-sherpa-onnx.md | 37 +++ docs/adr/0006-storage-sqlite.md | 39 +++ docs/adr/0007-llm-ollama.md | 49 +++ docs/adr/0008-pst-calendar.md | 38 +++ docs/adr/0009-optional-recording-consent.md | 45 +++ docs/adr/0010-remote-sync-targets.md | 72 ++++ .../0011-external-ai-and-agent-integration.md | 88 +++++ index.html | 12 + package.json | 32 ++ scripts/download-models.mjs | 12 + src-tauri/Cargo.toml | 75 +++++ src-tauri/build.rs | 3 + src-tauri/icons/README.md | 7 + src-tauri/migrations/0001_init.sql | 89 +++++ src-tauri/migrations/0002_sync.sql | 50 +++ src-tauri/migrations/0003_ai_mcp.sql | 27 ++ src-tauri/src/agent/mod.rs | 44 +++ src-tauri/src/audio/mod.rs | 61 ++++ src-tauri/src/calendar/mod.rs | 40 +++ src-tauri/src/commands.rs | 253 ++++++++++++++ src-tauri/src/diarization/mod.rs | 40 +++ src-tauri/src/error.rs | 26 ++ src-tauri/src/hardware/mod.rs | 34 ++ src-tauri/src/lib.rs | 93 ++++++ src-tauri/src/llm/mod.rs | 106 ++++++ src-tauri/src/main.rs | 7 + src-tauri/src/mcp/mod.rs | 106 ++++++ src-tauri/src/models.rs | 194 +++++++++++ src-tauri/src/notes/mod.rs | 55 ++++ src-tauri/src/storage/mod.rs | 74 +++++ src-tauri/src/sync/mod.rs | 104 ++++++ src-tauri/src/transcription/mod.rs | 57 ++++ src-tauri/tauri.conf.json | 39 +++ src/App.svelte | 68 ++++ src/lib/api.ts | 195 +++++++++++ src/lib/stores/recording.svelte.ts | 38 +++ src/lib/stores/settings.svelte.ts | 134 ++++++++ src/lib/views/MeetingsList.svelte | 37 +++ src/lib/views/Settings.svelte | 245 ++++++++++++++ src/lib/views/SummaryPanel.svelte | 18 + src/lib/views/TranscriptNotes.svelte | 27 ++ src/main.ts | 6 + svelte.config.js | 5 + tests/README.md | 17 + tsconfig.json | 15 + vite.config.ts | 16 + 63 files changed, 4690 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 CLAUDE.md create mode 100644 README-SCAFFOLD.md create mode 100644 docs/00-overview.md create mode 100644 docs/01-requirements.md create mode 100644 docs/02-architecture.md create mode 100644 docs/03-data-model.md create mode 100644 docs/04-api-contracts.md create mode 100644 docs/05-roadmap.md create mode 100644 docs/06-test-strategy.md create mode 100644 docs/07-research-findings.md create mode 100644 docs/adr/0001-app-shell-tauri.md create mode 100644 docs/adr/0002-frontend-svelte.md create mode 100644 docs/adr/0003-transcription-engine.md create mode 100644 docs/adr/0004-hardware-acceleration.md create mode 100644 docs/adr/0005-diarization-sherpa-onnx.md create mode 100644 docs/adr/0006-storage-sqlite.md create mode 100644 docs/adr/0007-llm-ollama.md create mode 100644 docs/adr/0008-pst-calendar.md create mode 100644 docs/adr/0009-optional-recording-consent.md create mode 100644 docs/adr/0010-remote-sync-targets.md create mode 100644 docs/adr/0011-external-ai-and-agent-integration.md create mode 100644 index.html create mode 100644 package.json create mode 100644 scripts/download-models.mjs create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/icons/README.md create mode 100644 src-tauri/migrations/0001_init.sql create mode 100644 src-tauri/migrations/0002_sync.sql create mode 100644 src-tauri/migrations/0003_ai_mcp.sql create mode 100644 src-tauri/src/agent/mod.rs create mode 100644 src-tauri/src/audio/mod.rs create mode 100644 src-tauri/src/calendar/mod.rs create mode 100644 src-tauri/src/commands.rs create mode 100644 src-tauri/src/diarization/mod.rs create mode 100644 src-tauri/src/error.rs create mode 100644 src-tauri/src/hardware/mod.rs create mode 100644 src-tauri/src/lib.rs create mode 100644 src-tauri/src/llm/mod.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/src/mcp/mod.rs create mode 100644 src-tauri/src/models.rs create mode 100644 src-tauri/src/notes/mod.rs create mode 100644 src-tauri/src/storage/mod.rs create mode 100644 src-tauri/src/sync/mod.rs create mode 100644 src-tauri/src/transcription/mod.rs create mode 100644 src-tauri/tauri.conf.json create mode 100644 src/App.svelte create mode 100644 src/lib/api.ts create mode 100644 src/lib/stores/recording.svelte.ts create mode 100644 src/lib/stores/settings.svelte.ts create mode 100644 src/lib/views/MeetingsList.svelte create mode 100644 src/lib/views/Settings.svelte create mode 100644 src/lib/views/SummaryPanel.svelte create mode 100644 src/lib/views/TranscriptNotes.svelte create mode 100644 src/main.ts create mode 100644 svelte.config.js create mode 100644 tests/README.md create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8d2edf5 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Example environment. Copy to `.env` for local dev. Never commit `.env`. +# WhispAssist is local-only; the only network target is a LOCAL LLM endpoint. +WA_LLM_ENDPOINT=http://localhost:11434 +WA_LLM_MODEL=llama3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45828c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Rust / Tauri +/src-tauri/target/ +**/*.rs.bk +*.pdb + +# Node / Vite / Svelte +node_modules/ +/dist/ +/.svelte-kit/ +/build/ +*.local + +# Models (downloaded at runtime, never committed) +/models/ +*.bin +*.gguf +*.onnx + +# Local app data captured during dev runs +/.wa-dev-data/ +*.wav +*.flac + +# Editor / OS +.vscode/* +!.vscode/extensions.json +.idea/ +.DS_Store +Thumbs.db + +# Secrets / env +.env +.env.* +!.env.example diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f56153e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }], + "printWidth": 100, + "singleQuote": false +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4b3d8b1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md — Working agreement for AI builders + +This file orients an AI coding agent (or human) working in this repository. Read it before +making changes. + +## What this project is + +WhispAssist (WA) is a **fully local, open-source, Windows-native** meeting assistant. The +non-negotiable product invariant: **no audio or transcript leaves the device except to +destinations the user has explicitly configured** — namely a *local* LLM endpoint (ADR-0007) and +enabled **sync targets** (ADR-0010). Both are **off by default**. With neither configured, WA +makes no content egress at all. Treat any outbound connection for meeting content that is *not* +to a user-configured, allowlisted destination as a design violation requiring sign-off. + +Two related product rules: +- **Recording is opt-in (ADR-0009).** Audio is retained as `.wav` only when the user turns on + "Record this meeting" (default off); otherwise working audio is deleted on finalize. A one-time + consent notice (recording may be unlawful without consent) must be acknowledged first. +- **Sync is explicit (ADR-0010).** Credentials live only in the OS credential store (never in + `settings.json`/`wa.db`); TLS is required; third-party targets are clearly labeled. The egress + allowlist is derived from settings and enforced in the Rust `sync`/`llm` services. + +External-AI rule (ADR-0011): hosted summary providers (Anthropic/OpenAI-compatible) are optional, +off by default, third-party egress (keys in the OS credential store). WA can also expose a **local +MCP server** so the user's own coding agents (Claude/Codex/Copilot/OpenCode) pull meeting context — +this is **inbound on loopback, off by default, token-gated, scope-limited, audited, and adds no +egress**. The data only leaves the device via the connected agent's own provider, which WA discloses. + +## Source of truth + +The plan in `docs/` is authoritative. When in doubt: + +1. Requirements & acceptance criteria → `docs/01-requirements.md` +2. Component boundaries & data flow → `docs/02-architecture.md` +3. DB schema, file paths, JSON shapes → `docs/03-data-model.md` +4. Function signatures & events → `docs/04-api-contracts.md` +5. What to build next & in what order → `docs/05-roadmap.md` +6. How to prove it works → `docs/06-test-strategy.md` +7. Why the stack is what it is → `docs/adr/` + +If code and docs disagree, the docs win unless you update them in the same change and note why. + +## Stack (decided — see ADRs before changing) + +- **App shell:** Tauri 2 (Rust core + WebView2). ADR-0001. +- **Frontend:** Svelte + TypeScript, Vite. ADR-0002. +- **Transcription:** `whisper-rs` primary; `ort` (ONNX Runtime + DirectML) for NPU. ADR-0003/0004. +- **Diarization:** `sherpa-onnx`. ADR-0005. +- **Storage:** SQLite + files under `%LOCALAPPDATA%\WhispAssist`. ADR-0006. +- **LLM:** Ollama HTTP, localhost only. ADR-0007. +- **Calendar/PST:** `outlook-pst` crate. ADR-0008. +- **Recording:** opt-in retention + consent. ADR-0009. +- **Sync:** WebDAV (Nextcloud/ownCloud/Cloudreve/Seafile/Synology) primary; OneDrive/Dropbox/Box via OAuth. ADR-0010. +- **External AI / agents:** hosted LLM providers behind `LlmProvider`; WA-as-MCP-server (`rmcp`) for agent handoff; feature briefs. ADR-0011. + +## Architectural rules + +- The Rust core is split into independent service modules (`audio`, `transcription`, + `diarization`, `storage`, `llm`, `calendar`, `hardware`, `notes`, `sync`, `mcp`). Each exposes a + trait in its `mod.rs`. Depend on the **trait**, not the concrete type, so engines/providers can be + swapped (one `WebDavTarget` covers all primary sync targets; one `McpServer` serves all agents). +- The frontend never touches the filesystem, the database, or the network directly. It calls + Tauri **commands** and subscribes to Tauri **events** (see `docs/04-api-contracts.md`). +- **Audio is the source of truth.** Persist raw audio first; transcripts/notes are derived and + must be regenerable after a crash. Never delete audio as a side effect of a transcript op. +- Long-running work (capture, inference) runs off the UI/IPC thread. Stream partial results via + events; never block a command for the duration of a meeting. +- Hardware acceleration is a runtime decision, not a compile-time one where avoidable. Always + degrade gracefully NPU → NVIDIA → AMD → Intel → CPU and surface the chosen backend in the UI. + +## Conventions + +- Rust: `cargo fmt` + `cargo clippy -- -D warnings` must pass. Public items get doc comments. +- TS/Svelte: Prettier + ESLint. Strict TypeScript. +- Errors: Rust uses `thiserror` per-module error enums surfaced as typed command errors; no + `unwrap()`/`expect()` on paths reachable from user input or hardware state. +- Commits: conventional commits (`feat:`, `fix:`, `docs:`, `test:`, `chore:`). Reference the + requirement or task ID where relevant (e.g. `feat(audio): WASAPI loopback capture (FR-CAP-1)`). +- Tests live next to the code (`#[cfg(test)]`) for units; cross-service tests in `/tests`. + +## Definition of done (per task) + +A task is done when: code compiles with no clippy warnings; the relevant requirement's +acceptance criteria are demonstrably met; tests named in `docs/06-test-strategy.md` for that +phase pass; and docs are updated if behavior or contracts changed. + +## Guardrails + +- Do not add telemetry, analytics, crash reporting, or auto-update phone-home without an ADR + and an explicit opt-in. Default to off and local. +- Never persist sync credentials or OAuth tokens to `settings.json`, `wa.db`, logs, or events — + only the OS credential store. Never return secrets from commands like `list_sync_targets`. +- Recording defaults OFF; sync defaults OFF; hosted AI providers default OFF; MCP server defaults + OFF. Any new egress path must be added to the settings-derived allowlist and covered by the + privacy egress test before merge. +- The MCP server is inbound/loopback-only and token-gated; it must never bind a non-loopback + address, never serve recordings unless explicitly allowed, and never add a host to the egress + allowlist. Every agent read is logged. +- Do not require admin privileges for normal operation. +- Do not add WA to OS startup without explicit user consent (NFR-RES-4). +- Keep idle resource use near zero (NFR-RES-1): no busy loops, no polling timers running when + not recording. diff --git a/README-SCAFFOLD.md b/README-SCAFFOLD.md new file mode 100644 index 0000000..0c909b3 --- /dev/null +++ b/README-SCAFFOLD.md @@ -0,0 +1,17 @@ +# Scaffold notes + +This skeleton is **structural**, not yet buildable end-to-end. It exists to give the build a shape +that matches `docs/`. Dependency versions in `package.json` and `src-tauri/Cargo.toml` are +indicative — pin and verify them at implementation time (see `docs/07-research-findings.md` "Open +items"). + +What's here: +- `src-tauri/` — Rust core with one module per service (`audio`, `hardware`, `transcription`, + `diarization`, `storage`, `llm`, `calendar`, `notes`), each exposing the trait from + `docs/04-api-contracts.md` and a `todo!()` default impl that maps to a roadmap task. +- `src/` — Svelte frontend skeleton: the three-pane shell, stores, and a typed Tauri client. +- `scripts/` — placeholder helper scripts (model download). +- `tests/` — fixtures dir + where cross-service integration tests will live. + +Start at **Phase 1** in `docs/05-roadmap.md`. The `todo!()` markers are intentional checkpoints — +each corresponds to a task ID. diff --git a/docs/00-overview.md b/docs/00-overview.md new file mode 100644 index 0000000..92e1804 --- /dev/null +++ b/docs/00-overview.md @@ -0,0 +1,68 @@ +# 00 — Overview + +## Vision + +WhispAssist (WA) is a privacy-first, Windows-native meeting assistant that runs **entirely on the +local machine**. It captures system audio, transcribes it with Whisper-class models using +on-device acceleration (NPU → GPU → CPU), structures the result into Markdown notes, and +optionally augments those notes with a locally hosted LLM. It combines **Meetily's** local-only +architecture with **Granola's** calendar-aware, role-aware note workflows, and leans harder into +Windows hardware acceleration and Outlook `.pst` integration than either. + +## Product goals + +1. High-quality, real-time or near-real-time transcription, fully on-device. +2. Strict local-only data handling — recordings and transcripts never leave the machine except by + explicit user export. +3. Intelligent note-taking and summarization via a locally configured LLM endpoint. +4. Calendar and local Outlook `.pst` integration for meeting context and speaker mapping. +5. A modern, responsive desktop experience: light/dark themes, fast startup, low-latency + interaction, and near-zero idle footprint. + +## Non-goals (for v1) + +- Cloud sync, multi-device, or team sharing of recordings/transcripts. +- Joining meetings as a bot. +- Mobile apps (Windows-on-ARM is a "later", not a v1 target). +- Cloud AI for transcription or summarization (a user *may* point the LLM at a remote + OpenAI-compatible endpoint, but that is off by default and clearly labeled). + +## How to read this plan + +| Doc | Answers | +|---|---| +| `00-overview.md` (this) | What and why, at a glance; glossary | +| `01-requirements.md` | Exactly what it must do (FR) and how well (NFR), with IDs | +| `02-architecture.md` | How the pieces fit; data flow; threading | +| `03-data-model.md` | Database schema, file layout, transcript JSON | +| `04-api-contracts.md` | Tauri commands/events + internal Rust traits | +| `05-roadmap.md` | Build order: 8 phases, tasks, acceptance criteria | +| `06-test-strategy.md` | How each phase is proven correct | +| `07-research-findings.md` | Evidence the stack works (with sources) | +| `adr/*` | The big decisions and their trade-offs | + +Requirement IDs (e.g. `FR-CAP-1`) are referenced throughout the roadmap, tests, and code commits +so any line of work traces back to a requirement. + +## Glossary + +- **System / loopback audio** — the audio the OS plays back (what you hear), captured without a + microphone or meeting bot, via WASAPI loopback. +- **Backend** — the hardware execution path for inference (NPU, NVIDIA, AMD, Intel, CPU). +- **Segment** — a timestamped chunk of transcript text with an associated speaker ID. +- **Speaker ID** — an internal label (`S1`, `S2`, …) assigned by diarization, later mapped to a + human name or calendar participant. +- **Diarization** — partitioning audio by "who spoke when". +- **Real-time factor (RTF)** — processing time ÷ audio duration; < 1.0 means faster than real time. +- **Provider** — a configured local LLM endpoint (Ollama by default). +- **Meeting** — the top-level record: audio + transcript + notes + metadata + speakers. + +## One-paragraph architecture + +A **Svelte** frontend in a **WebView2** window talks over Tauri IPC to a **Rust core** split into +independent services — `audio` (WASAPI capture), `hardware` (backend detection), `transcription` +(whisper.cpp / ONNX), `diarization` (sherpa-onnx), `storage` (SQLite + files), `llm` (Ollama +HTTP), `calendar` (`.pst`/future Graph), and `notes`. Capture writes audio to disk first (source +of truth), streams it to transcription, which emits segments to the UI live; on stop, diarization +and (optionally) the LLM refine and summarize, and everything is persisted locally. See +`02-architecture.md`. diff --git a/docs/01-requirements.md b/docs/01-requirements.md new file mode 100644 index 0000000..f76689c --- /dev/null +++ b/docs/01-requirements.md @@ -0,0 +1,228 @@ +# 01 — Requirements + +Each requirement has a stable ID used across the roadmap, tests, and commits. Priority: +**M** = must (v1), **S** = should, **C** = could (later). Each FR notes the phase that delivers it +(see `05-roadmap.md`). + +## Functional requirements + +### Audio capture (CAP) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-CAP-1 | M | 1 | Capture system (loopback) audio on Windows via WASAPI without a meeting bot or microphone. | +| FR-CAP-2 | M | 1 | Write captured audio to disk continuously during recording (audio = source of truth). | +| FR-CAP-3 | M | 1 | Start, stop, pause, and resume recording from the UI. | +| FR-CAP-4 | M | 1 | Show an unambiguous "recording active" indicator (in-app banner + tray icon). | +| FR-CAP-5 | S | 7 | Render a live input waveform / level meter while recording. | +| FR-CAP-6 | S | 7 | Handle audio device changes mid-recording without losing the session. | + +### Recording retention & consent (REC) — see ADR-0009 + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-REC-1 | M | 1 | "Record this meeting" is **off by default** (global default + per-meeting toggle). When on, the meeting's audio is retained and saved as a `.wav` file; when off, working audio is deleted on finalize and only the transcript/notes persist. | +| FR-REC-2 | M | 1 | Before retaining a recording for the first time (and shown near the toggle thereafter), display a consent notice: recording without participants' consent may be illegal in some regions; advise checking local laws. Require a one-time acknowledgment; store it. This is a caution, not legal advice. | +| FR-REC-3 | M | 1 | When retention is on, the UI indicates the meeting is being **saved** (in addition to the "recording active" indicator, FR-CAP-4). | +| FR-REC-4 | M | 2 | Deleting working audio on finalize happens only **after** the transcript is successfully finalized; never race with crash recovery (audio stays source of truth until then). | + +### Hardware acceleration (HW) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-HW-1 | M | 3 | Detect available compute backends at startup and on demand: NPU, NVIDIA, AMD, Intel GPU, CPU. | +| FR-HW-2 | S | 3 | Select the best backend by the order NPU→NVIDIA→AMD→Intel→CPU; allow a manual override. | +| FR-HW-3 | M | 3 | Display the active backend, model size, and estimated RTF in Settings and the recording bar. | +| FR-HW-4 | M | 3 | Fall back gracefully to a lower tier (down to CPU) when a backend is unavailable or fails. | + +### Transcription (TRX) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-TRX-1 | M | 1 | Transcribe audio to timestamped text segments using a Whisper-class model (CPU baseline). | +| FR-TRX-2 | M | 1 | Stream interim transcript segments to the UI in near real time during recording. | +| FR-TRX-3 | S | 3 | Offer a post-meeting batch re-transcription with a larger model for higher accuracy. | +| FR-TRX-4 | S | 8 | Support multiple languages via multilingual Whisper models; expose language selection/auto. | +| FR-TRX-5 | M | 3 | Run accelerated inference (NPU/GPU) when available; identical segment output across backends. | + +### Speaker diarization & naming (SPK) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-SPK-1 | M | 4 | Assign internal speaker IDs (S1, S2, …) to transcript segments via offline diarization. | +| FR-SPK-2 | M | 4 | Let the user name a speaker during recording; apply to that speaker's past & future segments. | +| FR-SPK-3 | M | 4 | Provide a post-meeting review to rename speakers and **merge** over-split speakers. | +| FR-SPK-4 | S | 6 | Populate the naming UI with attendees from the linked calendar event, plus "add new name". | +| FR-SPK-5 | M | 4 | Persist speaker→name mappings and apply them at render/export (never destructively rewrite). | + +### Notes & Markdown (NOTE) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-NOTE-1 | M | 2 | Present meeting notes as rendered Markdown with speaker-tagged dialogue sections. | +| FR-NOTE-2 | M | 2 | Edit notes (headings, bold/italic, lists, checkboxes) via Markdown syntax and/or a toolbar. | +| FR-NOTE-3 | M | 2 | Export a meeting's notes as `.md`. | +| FR-NOTE-4 | S | 8 | Export notes as PDF and/or Word via local conversion (no cloud). | +| FR-NOTE-5 | C | 8 | User-defined note templates by meeting type (e.g. Sales Call, 1:1, Standup). | +| FR-NOTE-6 | S | 2 | Show raw transcript and rendered notes side-by-side. | + +### Local LLM (LLM) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-LLM-1 | M | 5 | Configure a local LLM provider (Ollama default, custom OpenAI-compatible endpoint, or off). | +| FR-LLM-2 | M | 5 | Generate a summary, decisions, and action items from transcript + metadata (+ optional template). | +| FR-LLM-3 | M | 5 | Parse LLM output into editable action items the user confirms before they become tasks. | +| FR-LLM-4 | S | 5 | Stream LLM output token-by-token into the summary panel. | +| FR-LLM-5 | S | 5 | Detect a missing provider and offer guided install + hardware-aware model suggestions. | +| FR-LLM-6 | M | 5 | Validate the endpoint is local; if remote/proxying, show a clear "data leaves WA" banner. | + +### External AI providers (AI) — see ADR-0011 (Layer 1), ADR-0007 update + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-AI-1 | S | 10 | Optionally configure a **hosted** summary provider behind the same `LlmProvider` model: Anthropic Messages API and an OpenAI-compatible client (OpenAI, OpenRouter, LM Studio, gateways). Off by default. | +| FR-AI-2 | M | 10 | Hosted providers are explicit third-party egress: off by default, labeled "data leaves your device", host added to the settings-derived allowlist, API keys in the OS credential store (never settings/DB). | +| FR-AI-3 | S | 10 | Per-use provider selection is allowed (e.g. local Ollama for one meeting, Claude for another); the active provider is shown wherever a summary is generated. | + +### MCP server — WhispAssist as a tool source (MCP) — see ADR-0011 (Layer 2) + +WA exposes its own meeting context to the user's coding agents (Claude, Codex, Copilot, OpenCode, +…) via a local MCP server. This is the primary "get started right away" handoff (**pull** model). + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-MCP-1 | S | 10 | WA can expose a **local MCP server**, **off by default**, bound to loopback, requiring a token; explicit enable. | +| FR-MCP-2 | M | 10 | Capabilities are exposed as MCP **tools** (not resources/prompts) for max client compatibility (Copilot cloud agent supports tools only): e.g. `list_recent_meetings`, `get_transcript`, `get_action_items`, `get_feature_brief`. | +| FR-MCP-3 | M | 10 | **Scope control:** the user chooses which meetings/artifacts are exposed; recordings (`.wav`) are never exposed unless explicitly allowed. | +| FR-MCP-4 | S | 10 | **Feature brief** primitive: distill a transcript (via the configured LLM) into a structured, agent-ready spec — problem, desired outcome, acceptance criteria, target repo/context, source meeting — served by `get_feature_brief`/`create_feature_brief`. | +| FR-MCP-5 | M | 10 | **Disclosure + audit:** the UI states that a connected agent may send served data to its provider's cloud (outside WA's control); WA logs what each agent read. | +| FR-MCP-6 | S | 10 | Support both MCP transports: **stdio** (thin adapter) and **Streamable HTTP** on loopback (`/mcp`). HTTP+SSE (deprecated) is not used. | +| FR-MCP-7 | M | all | The MCP server is **inbound on loopback** and adds no WA egress; enabling it must not add any host to the egress allowlist. | + +### Agent push & task-tracker handoff (AGENT) — see ADR-0011 (Layer 3, later) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-AGENT-1 | C | 10c | Optional `AgentRunner`: spawn a local coding-agent CLI headless (`claude -p`, `codex exec`, `opencode run`, `copilot`) against a user-chosen repo to produce a branch/PR from a feature brief. | +| FR-AGENT-2 | C | 10c | Optional task-tracker handoff: create a GitHub issue from a confirmed action item / feature brief; optionally assign Copilot's cloud agent. Third-party egress: off by default, labeled, allowlisted. | + +### Storage & persistence (STORE) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-STORE-1 | M | 2 | Persist audio, transcript, notes, and metadata locally (files + SQLite index). | +| FR-STORE-2 | S | 2 | Configure base storage directory and a retention policy (max size and/or max age). | +| FR-STORE-3 | M | 2 | Single-meeting export (audio + transcript + notes) to a chosen folder. | +| FR-STORE-4 | S | 8 | Bulk export by date range or tag. | +| FR-STORE-5 | M | 2 | List, open, and delete meetings. | + +### Search & tagging (SEARCH) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-SEARCH-1 | S | 8 | Full-text search across transcripts and notes (SQLite FTS5). | +| FR-SEARCH-2 | S | 8 | Tag meetings (project/client/topic) and filter the list by date, tag, or participant. | + +### Calendar & Outlook .pst (CAL) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-CAL-1 | S | 6 | Read a user-selected `.pst` (with optional password) and extract events + attendees. | +| FR-CAL-2 | S | 6 | Show upcoming/recent meetings; let the user attach a recording to an event. | +| FR-CAL-3 | S | 6 | Display pre-meeting context: title, organizer, participants. | +| FR-CAL-4 | C | 6 | Link a recording to a historical event from `.pst` history. | +| FR-CAL-5 | C | 8 | Create local reminders / OS notifications for action items. | +| FR-CAL-6 | C | later | Optional Microsoft Graph calendar source (explicit consent; calendar metadata only). | + +### Remote sync / upload (SYNC) — see ADR-0010 + +Sync is an **explicit, user-configured export**; it is the only egress for meeting content besides +the local LLM endpoint, and it is **off by default**. Primary targets are self-hostable. + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-SYNC-1 | M | 9 | Sync is disabled by default. No artifact is uploaded anywhere unless the user has configured a target and explicitly enabled it (with acknowledgment). | +| FR-SYNC-2 | M | 9 | Configure one or more **WebDAV** targets covering the primary set — **Nextcloud, ownCloud, Cloudreve, Seafile** — and **Synology** (URL + username + app password; user-chosen remote base path). | +| FR-SYNC-3 | M | 9 | Choose, per target, **what** to upload (transcript, notes, summary, and — only if retained per FR-REC-1 — the `.wav` recording) and **when** (on finalize, manual "Upload now", and automatic retry). | +| FR-SYNC-4 | M | 9 | "Test connection" validates a target's reachability + credentials before saving. | +| FR-SYNC-5 | M | 9 | Durable upload queue with per-job status, exponential-backoff retry, SHA-256 skip-if-unchanged (idempotent), and resumable/chunked upload for large recordings. | +| FR-SYNC-6 | M | 9 | Store target credentials in the OS credential store (Windows Credential Manager / DPAPI), never in `settings.json` or the database. | +| FR-SYNC-7 | M | 9 | Require TLS; refuse plaintext `http://` unless the user explicitly allows it for a LAN address, with a warning. | +| FR-SYNC-8 | M | 9 | Surface sync state in the UI and label targets: self-hosted/primary as "your server"; third-party clouds carry a clear "data leaves your device to a third party" banner. | +| FR-SYNC-9 | S | 9 | **Secondary** targets via provider APIs + OAuth 2.0 (PKCE, loopback redirect): **OneDrive** (MS Graph), **Dropbox**, **Box**. | +| FR-SYNC-10 | C | 9 | Optional client-side encryption of artifacts before upload (ties to FR-SEC-3): destination holds only ciphertext. | + +### UX, accessibility, recovery (UX) + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-UX-1 | M | 7 | Three-pane layout: meetings list · transcript/notes · summary/action-items/participants. | +| FR-UX-2 | M | 7 | Light/dark themes following system setting, with user override. | +| FR-UX-3 | S | 7 | Keyboard shortcuts for start/stop recording, view toggles, and template apply. | +| FR-UX-4 | S | 7 | Screen-reader-friendly labels and sufficient contrast. | +| FR-REL-1 | M | 2 | Crash recovery: regenerate transcript/notes from persisted audio after an unexpected exit. | +| FR-REL-2 | M | 2 | Auto-save transcript and notes at intervals. | + +### Privacy & security (SEC) — see also NFR-SEC + +| ID | Pri | Phase | Requirement | +|---|---|---|---| +| FR-SEC-1 | M | all | WA originates **no** outbound connection for audio or transcript content except to destinations the user has explicitly configured: the LLM endpoint (local or, if chosen, a hosted AI provider — ADR-0007/0011), enabled sync targets (ADR-0010), and (Layer 3) a configured task tracker. With none configured, WA makes no content egress at all. Model downloads are the only other allowed egress and are explicit. The **local MCP server is inbound on loopback and is not egress** (FR-MCP-7). | +| FR-SEC-2 | M | 7 | A privacy panel shows current hardware backend, LLM/AI provider endpoint, **every enabled sync target** (self-hosted vs third-party), and **MCP server state** (and that connected agents may forward served data to their own provider), stating exactly what leaves the device. | +| FR-SEC-3 | C | 8 | Optional at-rest encryption of stored audio/transcripts; optional password-protected vault. | +| FR-MODEL-1 | S | 3 | Model management UI: choose Whisper size, download/remove transcription & diarization models. | + +## Non-functional requirements + +### Performance (PERF) +- **NFR-PERF-1 (M):** Real-time transcription latency under ~2–3 s per phrase on accelerated paths. +- **NFR-PERF-2 (M):** Batch transcription of a 60-min meeting completes within ≤1–2× meeting length + on CPU-only; faster on NPU/GPU. +- **NFR-PERF-3 (M):** Out-of-the-box defaults run acceptably on a typical Windows laptop with no + dedicated GPU/NPU (small/efficient model, conservative settings). +- **NFR-PERF-4 (S):** Cold start to interactive UI under ~2 s; heavy components (models) lazy-load + after the window is shown. + +### Resource usage (RES) +- **NFR-RES-1 (M):** Near-zero CPU/GPU/NPU and minimal memory/disk when idle (no recording or active + transcription) — behave like a lightweight background app; no polling timers when idle. +- **NFR-RES-2 (M):** During recording, prefer accelerated backends but stay responsive on CPU-only. +- **NFR-RES-3 (S):** A "Low overhead" preset caps model size, disables real-time summarization, and + reduces background indexing. +- **NFR-RES-4 (M):** Do not add WA to OS startup or run invasive background tasks without explicit + user consent. + +### Reliability (REL) +- **NFR-REL-1 (M):** Tolerate audio glitches and device changes without crashing or corrupting data. +- **NFR-REL-2 (M):** Graceful fallback when hardware acceleration is unavailable. +- **NFR-REL-3 (M):** No data loss on crash — audio persisted continuously; derived artifacts + regenerable. + +### Security (SEC) +- **NFR-SEC-1 (M):** Store data under the user profile, respecting Windows ACLs. +- **NFR-SEC-2 (M):** Operate without admin privileges. +- **NFR-SEC-3 (S):** Network egress is restricted to an allowlist derived from user configuration: + the LLM/AI-provider endpoint, enabled sync target hosts, a configured task tracker, and explicit + model downloads; nothing else. Verifiable (network test in CI / a privacy self-check). Any + connection to a host not on the derived allowlist is a defect. The MCP server is inbound and does + not add to the allowlist. +- **NFR-SEC-4 (M):** Sync and AI-provider credentials/keys live only in the OS credential store; TLS + is required for sync transport (plaintext only via explicit per-target LAN opt-in). See FR-SYNC-6/7, + FR-AI-2. +- **NFR-SEC-5 (M):** The MCP server binds to **loopback only**, requires a token, is **off by + default**, and honors the configured exposure scope; it never serves recordings unless explicitly + allowed, and logs agent reads (FR-MCP-1/3/5). + +### Maintainability & portability (MNT) +- **NFR-MNT-1 (M):** Modular services (audio, transcription, diarization, storage, llm, calendar, + hardware, notes, sync, mcp) with trait-defined boundaries enabling component replacement. +- **NFR-MNT-2 (S):** Engines selectable/swappable without changes to callers. +- **NFR-MNT-3 (C):** Architecture portable to macOS/Linux/Windows-on-ARM later without a rewrite. +- **NFR-MNT-4 (M):** CPU-only build always compiles; accelerated backends behind Cargo features. + +## Traceability + +The roadmap (`05-roadmap.md`) tags each task with the FR/NFR it satisfies; the test strategy +(`06-test-strategy.md`) maps tests to the same IDs. A requirement is "done" only when its +acceptance criteria (in the roadmap) pass the tests named for it. diff --git a/docs/02-architecture.md b/docs/02-architecture.md new file mode 100644 index 0000000..1baee2d --- /dev/null +++ b/docs/02-architecture.md @@ -0,0 +1,178 @@ +# 02 — Architecture + +## Component map + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ WebView2 window │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Svelte frontend (TypeScript) │ │ +│ │ views: MeetingsList · TranscriptNotes · SummaryPanel · │ │ +│ │ Settings · SpeakerReview · CalendarView │ │ +│ │ stores: recording, meeting, transcript, hardware, settings │ │ +│ └───────────────▲───────────────────────────┬───────────────────┘ │ +│ Tauri events (push) Tauri commands (request/response) │ +└──────────────────┼───────────────────────────┼──────────────────────┘ + │ │ +┌──────────────────┴───────────────────────────▼──────────────────────┐ +│ Rust core (Tauri) │ +│ │ +│ commands.rs ──orchestrates──► AppState { service handles } │ +│ │ +│ ┌─────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ audio │ │ hardware │ │transcription│ │ diarization │ │ +│ │ (WASAPI)│ │(detect) │ │(whisper/onnx)│ │(sherpa-onnx)│ │ +│ └────┬────┘ └────┬─────┘ └─────┬──────┘ └──────┬───────┘ │ +│ │ │ │ │ │ +│ ┌────▼────────────▼──────────────▼────────────────▼───────┐ │ +│ │ storage (SQLite + files) notes (markdown) │ │ +│ └────┬───────────────────────────────────┬───────────────┘ │ +│ │ │ │ +│ ┌────▼─────┐ ┌──────────────┐ ┌──────────┐ ┌─────▼──────┐ │ +│ │ llm │ │ sync │ │ mcp │ │ calendar │ │ +│ │(Ollama / │ │(WebDAV/OAuth)│ │ (server) │ │ (.pst/Graph)│ │ +│ │ cloud) │ └──────┬───────┘ └────▲─────┘ └────────────┘ │ +│ └────┬─────┘ │ │ loopback (INBOUND) │ +└────────┼────────────────┼───────────────┼──────────────────────────────┘ + │ HTTP/HTTPS │ HTTPS │ MCP over stdio / http://127.0.0.1/mcp + │ (local or │ (targets, │ (off by default; token; NOT egress) + │ cloud AI) │ off default) │ + ┌─────▼─────┐ ┌──────▼───────────┐ │ ┌──────────────────────────────┐ + │ Ollama / │ │ Nextcloud·Seafile│ └───│ Claude · Codex · Copilot · │ + │ Anthropic │ │ ·Cloudreve·… │ │ OpenCode (user's own agents) │ + │ /OpenAI │ │ |OneDrive·Dropbox│ │ — they may forward to their │ + └───────────┘ └──────────────────┘ │ own cloud (outside WA) │ + └──────────────────────────────┘ +``` + +The `mcp` arrow points **inward**: agents connect to WA, not the reverse. WA opens no socket for it. + +Each Rust service lives in its own module under `src-tauri/src//` and exposes a **trait** +plus a default implementation. Callers depend on the trait (NFR-MNT-1/2). The frontend never +touches files, DB, or network directly — only Tauri commands/events (`04-api-contracts.md`). + +## Services (responsibilities & key dependencies) + +| Service | Responsibility | Primary crate(s) | +|---|---|---| +| `audio` | WASAPI loopback capture; PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` | +| `hardware` | Enumerate NPU/GPU/CPU; rank backends; report capabilities | `ort`, DXGI via `windows` | +| `transcription` | Load model on a backend; stream segments (whisper.cpp) or NPU (ONNX) | `whisper-rs`, `ort` | +| `diarization` | Post-process audio → speaker spans; align to segments; merge | `sherpa-onnx` (FFI) | +| `storage` | SQLite index + file layout; CRUD; FTS search; retention; recovery scan | `sqlx`, `serde_json` | +| `notes` | Assemble Markdown from segments+speakers+summary; render/export | `pulldown-cmark` | +| `llm` | Talk to Ollama/custom endpoint; build prompts; stream; parse action items | `reqwest`, `serde` | +| `calendar` | Parse `.pst` events/attendees; (future) Graph/ICS sources | `outlook-pst` | +| `sync` | Upload artifacts to user-configured targets; durable queue + retry; WebDAV + OAuth providers; OS-keychain creds | `reqwest`, `reqwest_dav`, `keyring` | +| `mcp` | Host a local (loopback) MCP server exposing meeting context as tools; build feature briefs; scope + token + audit | `rmcp`, `axum`/`hyper` | + +The `audio` service also owns **recording retention** (ADR-0009): the working WAV is kept as the +permanent `audio.wav` only when "Record this meeting" is on; otherwise it is deleted on finalize. + +The `llm` service is extended (ADR-0011, Layer 1) with optional **hosted** providers (Anthropic +Messages, OpenAI-compatible) behind the same `LlmProvider` trait, in addition to local Ollama. An +optional `agent` module (Layer 3, later) spawns local coding-agent CLIs and/or creates task-tracker +issues from feature briefs. + +## Data flow — a typical meeting + +1. User selects/creates a meeting (optionally from a calendar event) → `start_recording`. +2. `audio` begins WASAPI loopback capture, writing PCM to `audio.wav` **continuously** (FR-CAP-2), + and pushes frames into an in-memory ring buffer. +3. `transcription` consumes windowed audio from the ring buffer on the selected `hardware` backend + and emits interim `TranscriptSegment`s → `transcript_segment` events to the UI (FR-TRX-2). +4. Provisional speaker turns (from cheap segmentation) tag segments live; the user may name a + speaker, applied immediately to past/future segments (FR-SPK-2). +5. The notes view renders Markdown that updates as segments arrive (FR-NOTE-1, FR-NOTE-6). +6. On `stop_recording`: finalize the WAV; run full `diarization`, align speaker IDs to segments, + apply name mappings; persist transcript JSON + metadata via `storage` (FR-STORE-1). If "Record + this meeting" is **off**, delete the working WAV **after** the transcript is finalized; if **on**, + keep it as `audio.wav` (ADR-0009, FR-REC-1/4). +7. If an LLM provider is configured: `llm` builds a prompt (transcript + metadata + template), + streams a summary/decisions/action-items into the summary panel (FR-LLM-2/4). +8. Action items are parsed and presented for confirmation; confirmed ones persist and may raise + local OS reminders (FR-LLM-3, FR-CAL-5). +9. If sync is configured and enabled, `storage` finalization enqueues the selected artifacts + (transcript/notes/summary, and the recording if retained) onto the `sync` queue; `sync` uploads + them to each target with retry/backoff and reports progress via events (FR-SYNC-3/5). Nothing is + uploaded if no target is enabled (FR-SYNC-1). + +## Data flow — meeting → code (agent handoff, ADR-0011) + +The "customer asks for a feature, get started right away" path, **pull** model: + +1. WA distills the relevant part of the transcript into a **feature brief** (via the configured + `llm`): problem, desired outcome, acceptance criteria, target repo/context, source meeting + (FR-MCP-4). It is stored with the meeting and offered as a tool result. +2. The user has the `mcp` server enabled (off by default) with a chosen exposure scope (FR-MCP-1/3). +3. In **their own** agent (Claude Code, Codex, Copilot, OpenCode), the developer asks it to act; + the agent connects to WA's loopback MCP server and calls `get_feature_brief` / `get_action_items` + to pull structured context (FR-MCP-2), then scaffolds/branches/PRs in their repo. +4. WA logs what was read and reminds the user that the agent may forward served data to its own cloud + (FR-MCP-5). WA itself sends nothing (FR-MCP-7). +5. *(Layer 3, later)* Alternatively, WA **pushes**: an `AgentRunner` spawns a CLI agent headless + against a repo, or WA opens a GitHub issue (optionally assigned to Copilot's cloud agent) from the + brief (FR-AGENT-1/2). + +## Threading & concurrency model + +- **UI/IPC thread:** Tauri commands return quickly. Nothing that runs for the length of a meeting + blocks a command — long work is spawned and reports via **events**. +- **Audio thread:** a dedicated thread runs the WASAPI **polling** capture loop. (WASAPI loopback + cannot use event-callback mode, so we poll with a small interval — see `07-research-findings.md`.) + It only does capture + disk write + ring-buffer push; no inference. +- **Inference worker(s):** transcription runs on its own worker, pulling audio windows from the + ring buffer; diarization runs as a discrete post-stop job. Backends are constructed once per + session from the `hardware` ranking. +- **Async runtime:** `tokio` for `storage` (sqlx), `llm` (reqwest streaming), and orchestration. +- **State:** a `tauri::State` holds `Arc`-wrapped service handles and a `RecordingSession` + guarded so start/stop/pause transitions are atomic. +- **Backpressure:** the ring buffer is bounded; if transcription falls behind (CPU-only, large + model), it drops to interim/skip strategy for the live view while the on-disk audio remains + complete for an accurate post-meeting pass. + +## Idle behavior (NFR-RES-1) + +When not recording, no audio thread, inference worker, or polling timer runs. Background indexing +(FTS, retention) is event-driven or scheduled with idle-priority, and is reduced further under the +"Low overhead" preset (NFR-RES-3). Models are unloaded from memory when idle and lazy-loaded on the +next recording (NFR-PERF-4). + +## Error handling & resilience + +- Each module defines a `thiserror` error enum; commands surface typed errors to the frontend. +- Hardware/backend failures degrade one tier at a time down to CPU (FR-HW-4, NFR-REL-2) and surface + a non-blocking notice; they never abort a recording. +- `audio` is the source of truth: a crash leaves a recoverable `audio.wav`; on next launch a + reconcile scan finds meetings with audio but no finalized transcript and offers recovery + (FR-REL-1, NFR-REL-3). +- PST parse failures and missing LLM providers are non-fatal: core capture/transcription/notes + keep working. + +## Security boundaries (FR-SEC-1, NFR-SEC-*) + +- WA opens sockets only to destinations the user configured: the LLM endpoint (**local** Ollama or, + if chosen, a **hosted** AI provider — ADR-0011), **enabled sync target hosts**, a configured task + tracker (Layer 3), and model-download hosts during an explicit download. These form an **allowlist + derived from settings**; a privacy self-check (and a CI network test) asserts no connection to any + off-allowlist host (NFR-SEC-3). With nothing configured, WA makes no content egress at all. +- **MCP server (ADR-0011) is inbound, not egress.** WA *listens* on loopback for the user's agents; + it opens no outbound socket and adds nothing to the allowlist (FR-MCP-7/NFR-SEC-5). It is off by + default, token-gated, scope-limited (never serves recordings unless allowed), and audited. The + data-leaves-device disclosure for this path is "the agent you connected may forward served data to + its own provider" — outside WA's control, like the remote-LLM caveat (ADR-0007). +- **Sync** is off by default and is an explicit user export (ADR-0010): credentials live in the OS + credential store (never in settings/DB, FR-SYNC-6/NFR-SEC-4); TLS is required (plaintext only via + explicit per-target LAN opt-in, FR-SYNC-7); third-party targets are clearly labeled (FR-SYNC-8). +- The WebView CSP also restricts `connect-src` to localhost, so the frontend itself cannot reach the + network — all egress goes through the Rust `sync`/`llm` services where the allowlist is enforced. +- Data is stored under the user profile honoring ACLs; no admin rights required; optional at-rest + encryption in Phase 8, optionally applied **before** upload so a sync target holds only ciphertext + (FR-SYNC-10). + +## Portability seam (NFR-MNT-3) + +Windows-specific code (WASAPI, DXGI enumeration, toast notifications, PST) sits behind the service +traits. A future macOS/Linux port implements the same traits with platform equivalents +(CoreAudio/PulseAudio capture, Metal/Vulkan backends, etc.) without changing callers or the +frontend. diff --git a/docs/03-data-model.md b/docs/03-data-model.md new file mode 100644 index 0000000..3c0f98c --- /dev/null +++ b/docs/03-data-model.md @@ -0,0 +1,311 @@ +# 03 — Data Model + +## On-disk layout + +Default root: `%LOCALAPPDATA%\WhispAssist\` (user-configurable, FR-STORE-2). + +``` +%LOCALAPPDATA%\WhispAssist\ +├── wa.db # SQLite index (relations, metadata, FTS) +├── settings.json # app settings (theme, provider, storage, presets) +├── models\ # downloaded models (whisper, diarization) +│ ├── whisper-base.q5.bin +│ ├── seg-pyannote-3.0.onnx +│ └── spk-eres2net.onnx +└── meetings\ + └── \ # one folder per meeting (uuid) + ├── audio.wav # canonical recording — present ONLY if "Record" was on (ADR-0009) + ├── transcript.json # canonical transcript (segments+speakers+timings) + ├── notes.md # user-editable Markdown notes + ├── summary.json # LLM summary, decisions, action items (if generated) + └── briefs/ # feature briefs distilled from this meeting (ADR-0011), if any + └── .json # agent-ready spec served via the MCP `get_feature_brief` tool +``` + +Rule: while a meeting is in progress a working WAV is the source of truth for crash recovery. On +finalize, it is **kept** as `audio.wav` if "Record this meeting" was on, or **deleted** if not +(FR-REC-1/4) — deletion happens only after `transcript.json` is finalized. `transcript.json`, +`notes.md`, and `summary.json` are **derived** and regenerable (regenerable only while the audio +still exists — i.e. for recorded meetings). + +## SQLite schema (`wa.db`) + +```sql +-- A meeting is the top-level record. +CREATE TABLE meetings ( + id TEXT PRIMARY KEY, -- uuid v4 + title TEXT NOT NULL DEFAULT 'Untitled meeting', + started_at INTEGER NOT NULL, -- unix epoch seconds + ended_at INTEGER, -- null while recording + duration_secs INTEGER, -- finalized on stop + folder_path TEXT NOT NULL, -- absolute path to meeting folder + audio_path TEXT, -- audio.wav; NULL if not retained (ADR-0009) + recorded INTEGER NOT NULL DEFAULT 0, -- 1 = audio retained as .wav, 0 = transcript-only + status TEXT NOT NULL, -- recording|transcribing|ready|recovering|error + language TEXT, -- detected/selected language code + backend_used TEXT, -- npu|nvidia|amd|intel|cpu + model_used TEXT, -- e.g. whisper-base + calendar_event_id TEXT, -- FK -> calendar_events.id (nullable) + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- Internal speakers detected per meeting (S1, S2, …) and their assigned names. +CREATE TABLE speakers ( + id TEXT PRIMARY KEY, -- uuid + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + label TEXT NOT NULL, -- 'S1','S2',… (internal, stable per meeting) + display_name TEXT, -- user/participant name (nullable) + participant_id TEXT REFERENCES participants(id), -- if mapped to a calendar attendee + color TEXT, -- UI color hint + UNIQUE(meeting_id, label) +); + +-- People known from calendar/.pst; reused across meetings for continuity. +CREATE TABLE participants ( + id TEXT PRIMARY KEY, -- uuid + name TEXT NOT NULL, + email TEXT, -- nullable + UNIQUE(name, email) +); + +CREATE TABLE meeting_participants ( -- attendee list per meeting + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + participant_id TEXT NOT NULL REFERENCES participants(id), + role TEXT, -- organizer|required|optional + PRIMARY KEY (meeting_id, participant_id) +); + +-- Calendar events imported from .pst (or future sources). +CREATE TABLE calendar_events ( + id TEXT PRIMARY KEY, -- uuid (stable from source uid if available) + source TEXT NOT NULL, -- pst|graph|ics + subject TEXT, + organizer TEXT, + starts_at INTEGER, + ends_at INTEGER, + description TEXT, + raw_uid TEXT -- source's own id for dedup +); + +CREATE TABLE tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); +CREATE TABLE meeting_tags ( + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (meeting_id, tag_id) +); + +-- Action items parsed from LLM output, then user-confirmed. +CREATE TABLE action_items ( + id TEXT PRIMARY KEY, + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + text TEXT NOT NULL, + owner TEXT, -- assignee name (nullable) + due_at INTEGER, -- nullable + confirmed INTEGER NOT NULL DEFAULT 0, -- 0=suggested, 1=confirmed by user + reminder_set INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +-- Feature briefs distilled from a meeting for coding-agent handoff (ADR-0011). The JSON body +-- lives in the meeting's briefs/ folder; this table indexes it for the MCP tools. +CREATE TABLE feature_briefs ( + id TEXT PRIMARY KEY, -- uuid + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + title TEXT NOT NULL, + target_repo TEXT, -- optional repo hint for the agent + path TEXT NOT NULL, -- briefs/.json + exposed INTEGER NOT NULL DEFAULT 0, -- visible to the MCP server? (scope control, FR-MCP-3) + created_at INTEGER NOT NULL +); + +-- Audit of what an MCP client (agent) read (FR-MCP-5). +CREATE TABLE mcp_access_log ( + id TEXT PRIMARY KEY, + at INTEGER NOT NULL, + tool TEXT NOT NULL, -- e.g. get_feature_brief + meeting_id TEXT, -- subject, if any + client TEXT -- client-reported name, if provided +); + +-- Configured sync/upload destinations (ADR-0010). Credentials are NOT stored here — +-- only a reference into the OS credential store (FR-SYNC-6). +CREATE TABLE sync_targets ( + id TEXT PRIMARY KEY, -- uuid + name TEXT NOT NULL, -- user label, e.g. "Home Nextcloud" + kind TEXT NOT NULL, -- webdav|onedrive|dropbox|box + provider_hint TEXT, -- nextcloud|owncloud|cloudreve|seafile|synology|generic + base_url TEXT, -- WebDAV URL (kind=webdav) + remote_base_path TEXT NOT NULL DEFAULT '/WhispAssist', + username TEXT, -- WebDAV username (secret is in credential store) + credential_ref TEXT NOT NULL, -- key into OS credential store + enabled INTEGER NOT NULL DEFAULT 0, -- off by default (FR-SYNC-1) + upload_transcript INTEGER NOT NULL DEFAULT 1, + upload_notes INTEGER NOT NULL DEFAULT 1, + upload_summary INTEGER NOT NULL DEFAULT 1, + upload_recording INTEGER NOT NULL DEFAULT 0, -- only meaningful if a meeting is recorded + trigger_on_finalize INTEGER NOT NULL DEFAULT 1, + allow_plaintext_lan INTEGER NOT NULL DEFAULT 0, -- FR-SYNC-7 + encrypt_before_upload INTEGER NOT NULL DEFAULT 0, -- FR-SYNC-10 + created_at INTEGER NOT NULL +); + +-- One upload job per (artifact, target). The durable queue (FR-SYNC-5). +CREATE TABLE sync_jobs ( + id TEXT PRIMARY KEY, -- uuid + target_id TEXT NOT NULL REFERENCES sync_targets(id) ON DELETE CASCADE, + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + artifact TEXT NOT NULL, -- transcript|notes|summary|recording + local_path TEXT NOT NULL, + remote_path TEXT NOT NULL, + sha256 TEXT, -- skip-if-unchanged idempotency + status TEXT NOT NULL, -- pending|uploading|done|failed|skipped + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + next_attempt_at INTEGER, -- backoff schedule + bytes_total INTEGER, + bytes_sent INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + UNIQUE(target_id, meeting_id, artifact) +); + +-- Full-text search over transcript + notes (FR-SEARCH-1). +CREATE VIRTUAL TABLE meeting_fts USING fts5( + meeting_id UNINDEXED, + title, + transcript_text, + notes_text, + tokenize = 'porter unicode61' +); + +CREATE INDEX idx_meetings_started ON meetings(started_at DESC); +CREATE INDEX idx_speakers_meeting ON speakers(meeting_id); +CREATE INDEX idx_action_meeting ON action_items(meeting_id); +CREATE INDEX idx_syncjobs_status ON sync_jobs(status, next_attempt_at); +CREATE INDEX idx_briefs_meeting ON feature_briefs(meeting_id); +``` + +Schema migrations are versioned (e.g. `sqlx::migrate!`), each migration numbered and forward-only. + +## `transcript.json` (canonical transcript) + +```jsonc +{ + "schema": 1, + "meeting_id": "f1c2…", + "language": "en", + "model": "whisper-base", + "backend": "nvidia", + "segments": [ + { + "id": 0, + "start_ms": 1240, // offset from recording start + "end_ms": 4880, + "speaker": "S1", // internal speaker id; name resolved at render time + "text": "Let's start with the roadmap.", + "confidence": 0.91, // optional + "interim": false // true while streaming, false once finalized + } + ], + "speakers": [ + { "label": "S1", "display_name": "Alex" }, + { "label": "S2", "display_name": null } + ] +} +``` + +Names are stored as a mapping and applied at render/export — segments keep the internal `speaker` +label so re-diarization or renaming never requires rewriting every segment (FR-SPK-5). + +## `summary.json` + +```jsonc +{ + "schema": 1, + "generated_at": 1751299200, + "provider": "ollama", + "model": "llama3", + "summary_md": "## Summary\n…", + "decisions": ["Adopt Tauri for the shell"], + "action_items": [ + { "text": "Send the API contract draft", "owner": "Jordan", "due": "2026-07-03" } + ] +} +``` + +## `briefs/.json` (feature brief — ADR-0011) + +Agent-ready spec the MCP `get_feature_brief` tool returns. Designed to drop straight into a coding +agent's context. + +```jsonc +{ + "schema": 1, + "id": "b7a1…", + "meeting_id": "f1c2…", + "title": "Bulk CSV export for the reporting view", + "problem": "Customer can't get their data out for offline analysis.", + "desired_outcome": "One-click CSV export of the current filtered report.", + "acceptance_criteria": [ + "Export button on the report toolbar", + "Respects active filters and column order", + "Streams large exports without blocking the UI" + ], + "target_repo": "acme/reporting-web", // optional hint for the agent + "context_excerpts": [ // minimal transcript quotes that ground the request + { "speaker": "Customer", "text": "We really need to pull this into our own spreadsheets." } + ], + "source": { "meeting_title": "Acme quarterly sync", "at": 1751299200 } +} +``` + +## `settings.json` + +```jsonc +{ + "theme": "system", // system|light|dark + "storage_root": "%LOCALAPPDATA%\\WhispAssist", + "retention": { "max_age_days": 90, "max_size_gb": 10 }, + "transcription": { "model": "base", "language": "auto", "batch_reprocess": false }, + "hardware": { "preferred_backend": "auto", "low_overhead": false }, + "recording": { + "default_record": false, // "Record this meeting" default — OFF (FR-REC-1) + "consent_acknowledged": false // set true after the one-time consent notice (FR-REC-2) + }, + "llm": { + "provider": "ollama", // ollama|custom|anthropic|openai|off (ADR-0007/0011) + "endpoint": "http://localhost:11434", + "model": "llama3", + "stream": true + // API keys for hosted providers (anthropic|openai) live in the OS credential store, not here. + }, + // Sync target rows live in wa.db (sync_targets); secrets live in the OS credential store. + // settings.json only holds the global default. No credentials here (FR-SYNC-6). + "sync": { "enabled": false }, // master off switch — OFF by default (FR-SYNC-1) + // Local MCP server (ADR-0011). OFF by default; inbound loopback only; token in credential store. + "mcp": { + "enabled": false, // FR-MCP-1 + "transport": "http", // http (127.0.0.1/mcp) | stdio + "port": 4849, + "expose": "selected", // none | selected | all (scope control, FR-MCP-3) + "expose_recordings": false // never serve .wav unless explicitly true (FR-MCP-3) + }, + "privacy": { "encrypt_at_rest": false } +} +``` + +## Retention & recovery semantics + +- **Retention** (FR-STORE-2): a background job deletes whole meeting folders + rows once a meeting + exceeds the age/size policy, oldest first; never runs while a meeting is `recording`/`transcribing`. +- **Recovery** (FR-REL-1): on startup, any meeting whose **working WAV still exists** but whose + `transcript.json` is missing/partial is marked `recovering`; the user can re-run transcription from + the audio. (A cleanly finalized non-recorded meeting has no audio and needs no recovery; an + interrupted one still has its working WAV, so it is always recoverable.) +- **Sync** (FR-SYNC-5): on startup and after each finalize, the `sync` service scans `sync_jobs` for + `pending`/`failed` jobs whose `next_attempt_at` has passed and resumes them with backoff. Deleting + a meeting cascades to its `sync_jobs` (local rows only; already-uploaded remote copies are left to + the user/server). diff --git a/docs/04-api-contracts.md b/docs/04-api-contracts.md new file mode 100644 index 0000000..53bad7f --- /dev/null +++ b/docs/04-api-contracts.md @@ -0,0 +1,243 @@ +# 04 — API Contracts + +Two contract surfaces: +1. **Frontend ⇄ Rust** — Tauri **commands** (request/response) and **events** (Rust→UI push). +2. **Rust internal** — service **traits** that decouple callers from concrete engines (NFR-MNT-1/2). + +All payloads are `serde`-serializable; timestamps are unix epoch ms unless noted. Errors are typed: +each command returns `Result` where `WaError` carries a `kind` (machine-readable) and a +`message` (human-readable). + +## 1. Tauri commands (frontend → Rust) + +```ts +// ---- Recording lifecycle ---- +// `record` (default false) controls audio RETENTION (ADR-0009). When false, working audio is +// deleted on finalize and only the transcript/notes persist. It can be toggled mid-meeting. +start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean }): MeetingId +stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef +pause_recording(input: { meetingId: MeetingId }): void +resume_recording(input: { meetingId: MeetingId }): void +set_recording_retention(input: { meetingId: MeetingId; record: boolean }): void // toggle mid-meeting (FR-REC-1) +acknowledge_recording_consent(): void // one-time (FR-REC-2) + +// ---- Hardware ---- +hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number } +set_preferred_backend(input: { backend: BackendId | "auto" }): void + +// ---- Transcription / models ---- +reprocess_transcript(input: { meetingId: MeetingId; model: string }): void // batch mode (FR-TRX-3) +list_models(): ModelInfo[] +download_model(input: { kind: "whisper" | "diar-seg" | "diar-emb"; id: string }): void // emits progress events +remove_model(input: { id: string }): void + +// ---- Speakers ---- +rename_speaker(input: { meetingId: MeetingId; label: string; name: string }): void +merge_speakers(input: { meetingId: MeetingId; from: string[]; into: string }): void +map_speaker_to_participant(input: { meetingId: MeetingId; label: string; participantId: string }): void + +// ---- Meetings / storage ---- +list_meetings(input: { query?: string; tag?: string; participantId?: string; limit?: number; offset?: number }): MeetingListItem[] +get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers +delete_meeting(input: { meetingId: MeetingId }): void +export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" }): string +update_notes(input: { meetingId: MeetingId; markdown: string }): void +search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1) +set_tags(input: { meetingId: MeetingId; tags: string[] }): void + +// ---- LLM / AI provider (ADR-0007/0011) ---- +// provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to +// set_llm_provider but stored only in the OS credential store; never returned by llm_status. +llm_status(): { provider: string; reachable: boolean; isLocal: boolean; models: string[] } +set_llm_provider(input: { provider: string; endpoint?: string; model?: string; apiKey?: string }): void // FR-AI-1/2 +generate_summary(input: { meetingId: MeetingId; templateId?: string }): void // streams via events (FR-LLM-2/4) +confirm_action_items(input: { meetingId: MeetingId; items: ActionItem[] }): void + +// ---- Calendar / .pst ---- +import_pst(input: { path: string; password?: string }): { eventsImported: number } // FR-CAL-1 +list_calendar_events(input: { from?: number; to?: number }): CalendarEvent[] +attach_meeting_to_event(input: { meetingId: MeetingId; eventId: string }): void + +// ---- Sync / upload (ADR-0010) ---- secrets are passed to add/update but stored only in the OS +// credential store; they are NEVER returned by list_sync_targets. +list_sync_targets(): SyncTargetInfo[] +add_sync_target(input: SyncTargetConfig & { secret: string }): SyncTargetInfo // FR-SYNC-2/9 +update_sync_target(input: { id: string } & Partial & { secret?: string }): SyncTargetInfo +remove_sync_target(input: { id: string }): void +test_sync_target(input: { id: string } | SyncTargetConfig & { secret: string }): { ok: boolean; message: string } // FR-SYNC-4 +set_sync_enabled(input: { enabled: boolean }): void // master switch (FR-SYNC-1) +sync_meeting(input: { meetingId: MeetingId; targetId?: string }): void // manual "Upload now" (FR-SYNC-3) +sync_status(input?: { meetingId?: MeetingId }): SyncJobInfo[] // queue state (FR-SYNC-5) +retry_sync_job(input: { jobId: string }): void +// OAuth (secondary targets, FR-SYNC-9): begins loopback-redirect PKCE flow, returns when linked. +begin_oauth_link(input: { kind: "onedrive" | "dropbox" | "box" }): { ok: boolean; account?: string } + +// ---- Feature briefs + MCP server (ADR-0011) ---- +create_feature_brief(input: { meetingId: MeetingId; targetRepo?: string }): FeatureBrief // FR-MCP-4 (LLM-distilled) +list_feature_briefs(input: { meetingId?: MeetingId }): FeatureBriefInfo[] +get_feature_brief(input: { id: string }): FeatureBrief +set_brief_exposed(input: { id: string; exposed: boolean }): void // scope control (FR-MCP-3) +mcp_status(): { enabled: boolean; transport: "http" | "stdio"; endpoint: string; tokenSet: boolean; exposeScope: string } +set_mcp_enabled(input: { enabled: boolean; transport?: "http" | "stdio"; port?: number }): { endpoint: string; token: string } // FR-MCP-1/6 +set_mcp_scope(input: { expose: "none" | "selected" | "all"; exposeRecordings?: boolean }): void // FR-MCP-3 +mcp_access_log(input?: { limit?: number }): McpAccessEntry[] // audit (FR-MCP-5) +// (Layer 3, later) push handoff — spawn a local agent CLI / open a tracker issue from a brief. +run_agent(input: { briefId: string; tool: "claude" | "codex" | "opencode" | "copilot"; repoPath: string }): { ok: boolean } // FR-AGENT-1 +create_issue_from_brief(input: { briefId: string; tracker: "github"; assignCopilot?: boolean }): { url: string } // FR-AGENT-2 + +// ---- Settings ---- +get_settings(): Settings +update_settings(input: Partial): Settings +// Reports the full egress allowlist so the UI can prove exactly what may leave the device (FR-SEC-2). +privacy_self_check(): { + llmEndpoint: string; llmIsLocal: boolean; + syncEnabled: boolean; + syncTargets: { name: string; host: string; thirdParty: boolean; tls: boolean }[]; + allowlistedHosts: string[]; +} +``` + +### Conventions +- Commands return promptly; anything long-running (recording, transcription, summary, model + download, PST import) reports progress/results through **events** below. +- `MeetingId` is a uuid string. `BackendId` ∈ `"npu" | "nvidia" | "amd" | "intel" | "cpu"`. + +## 2. Tauri events (Rust → frontend) + +```ts +"recording://state" { meetingId, state: "recording"|"paused"|"stopped", elapsedMs } +"recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5) +"transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2) +"transcript://finalized" { meetingId, segmentCount } +"diarization://updated" { meetingId, speakers: SpeakerInfo[] } // after post-pass (FR-SPK) +"llm://token" { meetingId, text } // streamed summary (FR-LLM-4) +"llm://done" { meetingId, summary: SummaryRef } +"model://progress" { id, receivedBytes, totalBytes } +"pst://progress" { processed, total } +"hardware://changed" { active: BackendId, reason: string } // fallback occurred (FR-HW-4) +"recording://retention" { meetingId, record: boolean } // retention toggled (FR-REC-1/3) +"sync://job" { jobId, meetingId, targetId, artifact, status, bytesSent, bytesTotal } // FR-SYNC-5 +"sync://done" { meetingId, targetId, uploaded: number, failed: number } +"mcp://access" { at, tool, meetingId?, client? } // agent read something (FR-MCP-5) +"agent://progress" { briefId, tool, line } // push run output (FR-AGENT-1) +"error" { kind, message, context? } +``` + +## 3. Internal Rust service traits + +These define the seams that let engines be swapped without touching callers. Signatures are +indicative (async where I/O-bound). + +```rust +// audio/mod.rs +pub trait AudioCapture: Send + Sync { + /// Begin WASAPI loopback capture, writing PCM to `wav_path`; frames also pushed to `sink`. + fn start(&self, wav_path: &Path, sink: FrameSink) -> Result; + fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>; + fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>; + fn stop(&self, h: CaptureHandle) -> Result; +} + +// hardware/mod.rs +pub trait HardwareDetector: Send + Sync { + fn detect(&self) -> Vec; // ranked NPU→NVIDIA→AMD→Intel→CPU + fn best(&self, preferred: Option) -> BackendInfo; +} + +// transcription/mod.rs +pub trait Transcriber: Send + Sync { + fn load(model: &Path, backend: BackendId) -> Result where Self: Sized; + /// Stream interim + final segments for an audio window. + fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError>; + /// One-shot batch transcription (higher accuracy). + fn transcribe_file(&self, wav: &Path) -> Result, TrxError>; +} + +// diarization/mod.rs +pub trait Diarizer: Send + Sync { + fn diarize(&self, wav: &Path) -> Result, DiarError>; + fn assign(&self, segments: &mut [TranscriptSegment], spans: &[SpeakerSpan]); +} + +// storage/mod.rs (async, sqlx) +#[async_trait] pub trait Store: Send + Sync { + async fn create_meeting(&self, m: NewMeeting) -> Result; + async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError>; + async fn list_meetings(&self, f: MeetingFilter) -> Result, StoreError>; + async fn get_meeting(&self, id: &MeetingId) -> Result; + async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>; + async fn search(&self, q: &str) -> Result, StoreError>; + async fn recover_scan(&self) -> Result, StoreError>; // FR-REL-1 + async fn enforce_retention(&self, policy: Retention) -> Result; +} + +// llm/mod.rs +#[async_trait] pub trait LlmProvider: Send + Sync { + async fn status(&self) -> LlmStatus; // reachable? local? models + async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result; + fn is_local(&self) -> bool; // FR-LLM-6 guard +} + +// calendar/mod.rs +pub trait CalendarSource: Send + Sync { + fn import(&self, input: CalImport) -> Result, CalError>; // pst|graph|ics + fn attendees(&self, event_id: &str) -> Result, CalError>; +} + +// notes/mod.rs +pub trait NotesRenderer: Send + Sync { + fn to_markdown(&self, t: &Transcript, speakers: &[SpeakerInfo], s: Option<&Summary>) -> String; + fn export(&self, md: &str, dest: &Path, fmt: ExportFormat) -> Result; +} + +// sync/mod.rs +// One impl per provider; `WebDavTarget` covers Nextcloud/ownCloud/Cloudreve/Seafile/Synology. +// Secondary OAuth impls: OneDriveTarget, DropboxTarget, BoxTarget (ADR-0010). +#[async_trait] pub trait SyncTarget: Send + Sync { + fn kind(&self) -> SyncKind; + fn is_third_party(&self) -> bool; // false for self-hosted WebDAV + async fn test(&self) -> Result<(), SyncError>; // reachability + auth (FR-SYNC-4) + async fn ensure_dir(&self, remote_dir: &str) -> Result<(), SyncError>; + async fn exists(&self, remote_path: &str, sha256: &str) -> Result; // skip-if-unchanged + /// Upload a file; resumable/chunked for large artifacts. Reports progress via `prog`. + async fn put(&self, local: &Path, remote_path: &str, prog: ProgressSink) -> Result<(), SyncError>; +} + +// Owns the durable queue, retry/backoff, credential resolution, and TLS enforcement. +#[async_trait] pub trait SyncManager: Send + Sync { + async fn enqueue_meeting(&self, meeting_id: &MeetingId, target_id: Option<&str>) -> Result<(), SyncError>; + async fn pump(&self) -> Result<(), SyncError>; // drive pending jobs (called on finalize + startup + timer) + async fn status(&self, meeting_id: Option<&MeetingId>) -> Result, SyncError>; + async fn retry(&self, job_id: &str) -> Result<(), SyncError>; +} + +// llm/mod.rs — `LlmProvider` (above) gains hosted impls behind the same trait (ADR-0011): +// OllamaProvider (local) · OpenAiCompatProvider (/v1/chat/completions) · AnthropicProvider (/v1/messages) +// `is_local()` stays the egress guard; hosted impls return false and require an API key from the keychain. + +// mcp/mod.rs — WA as an MCP server (loopback, off by default). Tools-first (FR-MCP-2). +#[async_trait] pub trait McpServer: Send + Sync { + async fn start(&self, cfg: McpConfig) -> Result; // returns endpoint + token + async fn stop(&self, h: McpHandle) -> Result<(), McpError>; + fn tools(&self) -> Vec; // list_recent_meetings, get_transcript, get_action_items, get_feature_brief +} +// Builds the agent-ready spec from a transcript via the configured LlmProvider. +#[async_trait] pub trait FeatureBriefBuilder: Send + Sync { + async fn build(&self, meeting_id: &MeetingId, target_repo: Option<&str>) -> Result; +} + +// agent/mod.rs — Layer 3 (later). Push handoff; one impl per CLI / tracker. +#[async_trait] pub trait AgentRunner: Send + Sync { + async fn run(&self, brief: &FeatureBrief, repo: &Path, out: LineSink) -> Result; // claude -p / codex exec / … +} +#[async_trait] pub trait IssueTracker: Send + Sync { + async fn create_issue(&self, brief: &FeatureBrief, assign_copilot: bool) -> Result; +} +``` + +## Versioning + +- Command/event names and payload shapes are versioned implicitly by `schema` fields in persisted + JSON (`03-data-model.md`) and explicitly in a `CONTRACTS_VERSION` constant. Breaking a command + shape requires bumping it and updating the frontend client in the same change (see `CLAUDE.md` + "source of truth" rule). diff --git a/docs/05-roadmap.md b/docs/05-roadmap.md new file mode 100644 index 0000000..b8b2a2a --- /dev/null +++ b/docs/05-roadmap.md @@ -0,0 +1,237 @@ +# 05 — Roadmap + +Eight phases, each a shippable increment. Every task carries an ID and the requirement(s) it +satisfies; every phase has **acceptance criteria** that gate "done" (verified by the tests in +`06-test-strategy.md`). Phases are ordered so each builds on a working previous one. Suggested +effort labels: **S** ≤1 day, **M** a few days, **L** ~1–2 weeks (team-dependent, indicative only). + +Legend: `[T1.2]` = task; → FR/NFR satisfied. + +--- + +## Phase 1 — Foundation: capture + CPU transcription +**Goal:** a window where you click Record, system audio is captured to disk, and plain transcript +text streams in on CPU. + +- `[T1.1]` Scaffold Tauri 2 + Svelte project; window, build, dev loop. **M** → ADR-0001/0002 +- `[T1.2]` `audio`: WASAPI loopback capture thread (polling), PCM ring buffer, WAV writer. **L** → FR-CAP-1/2 +- `[T1.3]` Recording lifecycle commands + `recording://state` events; start/stop/pause/resume. **M** → FR-CAP-3 +- `[T1.4]` Recording indicator: in-app banner + tray icon. **S** → FR-CAP-4 +- `[T1.5]` `transcription`: integrate `whisper-rs` CPU; load small model; window+overlap feeder. **L** → FR-TRX-1, NFR-MNT-4 +- `[T1.6]` Stream interim/final segments → `transcript://segment`; minimal transcript view. **M** → FR-TRX-2 +- `[T1.7]` Idle discipline: no threads/timers when not recording; model unloaded when idle. **S** → NFR-RES-1 +- `[T1.8]` "Record this meeting" toggle (default OFF) + mid-meeting `set_recording_retention`; "saving" indicator. **S** → FR-REC-1/3 +- `[T1.9]` Consent notice + one-time acknowledgment before first retained recording. **S** → FR-REC-2 + +**Acceptance:** Record a 5-min meeting; transcript appears live within a few seconds of speech on a +CPU-only laptop; stopping leaves the app idle with near-zero CPU. With "Record" ON, a playable +`audio.wav` remains; with it OFF (default), no audio file remains after finalize. The consent notice +appears before the first retained recording and must be acknowledged once. (Tests: P1 suite.) + +--- + +## Phase 2 — Storage, notes, persistence, recovery +**Goal:** meetings are saved, listable, editable as Markdown, exportable, and survive a crash. + +- `[T2.1]` `storage`: SQLite schema + migrations; meeting folder layout; transactional writes. **L** → FR-STORE-1 +- `[T2.2]` Persist `transcript.json` + metadata on stop; status lifecycle. **M** → FR-STORE-1 +- `[T2.3]` Meetings list (create/open/delete) UI + commands. **M** → FR-STORE-5 +- `[T2.4]` `notes`: assemble Markdown (speaker-tagged) from segments; render + raw side-by-side. **M** → FR-NOTE-1/6 +- `[T2.5]` Markdown editor with toolbar (headings/bold/italic/lists/checkboxes); `update_notes`. **M** → FR-NOTE-2 +- `[T2.6]` Export single meeting to `.md` and bundle (audio+transcript+notes). **M** → FR-NOTE-3, FR-STORE-3 +- `[T2.10]` Finalize path honors retention: delete working WAV only **after** transcript finalized; set `recorded`/`audio_path` (FR-REC-4). **S** → FR-REC-1/4 +- `[T2.7]` Auto-save notes/transcript at intervals. **S** → FR-REL-2 +- `[T2.8]` Startup recover-scan: audio-without-transcript → `recovering`; re-run flow. **M** → FR-REL-1, NFR-REL-3 +- `[T2.9]` Storage settings: base directory + retention policy (size/age) + background enforcement. **M** → FR-STORE-2 + +**Acceptance:** Kill the app mid-meeting; on relaunch the meeting is recoverable from audio and +transcription can be regenerated. Notes edit + `.md` export round-trip. Retention deletes the +oldest meeting when the cap is exceeded and never touches a recording in progress. (Tests: P2 suite.) + +--- + +## Phase 3 — Hardware acceleration + model management +**Goal:** detect and use NPU/GPU; switch models; show the active backend; batch re-transcribe. + +- `[T3.1]` `hardware`: enumerate CPU/GPU (DXGI) + NPU (ONNX/Windows ML); rank backends. **L** → FR-HW-1 +- `[T3.2]` Backend selection (auto order + manual override); construct matching Transcriber. **M** → FR-HW-2 +- `[T3.3]` whisper.cpp accelerated builds behind Cargo features (CUDA/Vulkan); CPU always builds. **L** → FR-TRX-5, NFR-MNT-4 +- `[T3.4]` NPU path: `ort` + DirectML Whisper-ONNX `Transcriber` (same trait/output). **L** → FR-TRX-5, ADR-0004 +- `[T3.5]` Graceful fallback NPU→GPU→CPU on failure; `hardware://changed` notice. **M** → FR-HW-4, NFR-REL-2 +- `[T3.6]` Show active backend, model size, est. RTF in Settings + recording bar. **S** → FR-HW-3 +- `[T3.7]` Model management UI: choose Whisper size; download/remove with progress. **M** → FR-MODEL-1 +- `[T3.8]` Batch re-transcription with a larger model post-meeting. **M** → FR-TRX-3 +- `[T3.9]` "Low overhead" preset (CPU + small model + no realtime summary + reduced indexing). **S** → NFR-RES-3, NFR-PERF-3 + +**Acceptance:** On a machine with a supported GPU/NPU, the active backend is detected and shown, +and transcription is measurably faster than the CPU baseline; forcing a backend failure falls back +cleanly to CPU without aborting. Output segments are equivalent across backends. (Tests: P3 suite.) + +--- + +## Phase 4 — Speaker diarization & naming +**Goal:** segments get speaker labels; users name/merge speakers during and after meetings. + +- `[T4.1]` `diarization`: integrate `sherpa-onnx` (segmentation+embedding+clustering) via FFI. **L** → FR-SPK-1, ADR-0005 +- `[T4.2]` Align speaker spans to transcript segments by timestamp overlap. **M** → FR-SPK-1 +- `[T4.3]` Live provisional speaker turns from cheap segmentation during recording. **M** → FR-SPK-2 +- `[T4.4]` In-session naming: name a speaker → applies to past & future segments. **M** → FR-SPK-2 +- `[T4.5]` Post-meeting review screen: rename + **merge** over-split speakers. **M** → FR-SPK-3 +- `[T4.6]` Persist speaker→name mapping; apply at render/export (non-destructive). **S** → FR-SPK-5 +- `[T4.7]` Diarization models in model-management; download/select. **S** → FR-MODEL-1 + +**Acceptance:** A two-person recording yields ≥2 speakers; naming a speaker relabels all their +segments live; merging two IDs into one updates notes and export; renaming never rewrites segment +speaker IDs in storage. (Tests: P4 suite.) + +--- + +## Phase 5 — Local LLM integration +**Goal:** configure a local LLM and get summaries, decisions, and editable action items. + +- `[T5.1]` `llm`: Ollama client (`/api/tags`, `/api/chat` streaming) behind `LlmProvider`. **M** → FR-LLM-1, ADR-0007 +- `[T5.2]` Provider settings: Ollama | custom endpoint | off; `llm_status` + reachability. **M** → FR-LLM-1 +- `[T5.3]` Local-endpoint guard + "data leaves WA" banner for remote/proxy endpoints. **S** → FR-LLM-6, FR-SEC-1 +- `[T5.4]` Prompt assembly: transcript + metadata + optional template → summary/decisions/actions. **M** → FR-LLM-2 +- `[T5.5]` Stream tokens to summary panel (`llm://token`/`done`); persist `summary.json`. **M** → FR-LLM-4 +- `[T5.6]` Parse action items into editable list; confirm → `action_items` rows. **M** → FR-LLM-3 +- `[T5.7]` Guided provider install + hardware-aware model suggestions when none detected. **M** → FR-LLM-5 + +**Acceptance:** With Ollama running, a finished meeting produces a streamed summary plus a list of +action items the user can edit and confirm; with the LLM off or unreachable, capture/transcription/ +notes still work and the UI degrades gracefully. (Tests: P5 suite.) + +--- + +## Phase 6 — Calendar & Outlook .pst integration +**Goal:** import `.pst` events/attendees, attach recordings to events, suggest speaker names. + +- `[T6.1]` `calendar`: `.pst` reader (`outlook-pst`; libpff fallback) → events + attendees. **L** → FR-CAL-1, ADR-0008 +- `[T6.2]` `import_pst` command with optional password; progress events; persist events/participants. **M** → FR-CAL-1 +- `[T6.3]` Calendar/meetings view: upcoming/recent; attach a recording to an event. **M** → FR-CAL-2 +- `[T6.4]` Pre-meeting context panel: title, organizer, participants. **S** → FR-CAL-3 +- `[T6.5]` Participant-aware naming: attendee dropdown in speaker UI + "add new name". **M** → FR-SPK-4 +- `[T6.6]` Link recording ↔ historical `.pst` event; continuity of named speakers. **M** → FR-CAL-4 + +**Acceptance:** Importing a sample `.pst` yields events with attendees; selecting an event prefills +the speaker-naming dropdown; PST parse failure is non-fatal and surfaced clearly. (Tests: P6 suite.) + +--- + +## Phase 7 — UX polish, themes, accessibility, privacy panel +**Goal:** the modern, fast, themeable, accessible experience. + +- `[T7.1]` Final three-pane layout (list · transcript/notes · summary/actions/participants). **M** → FR-UX-1 +- `[T7.2]` Light/dark themes following system + override; tokens/contrast pass. **M** → FR-UX-2 +- `[T7.3]` Live waveform/level meter; device-change resilience. **M** → FR-CAP-5/6 +- `[T7.4]` Keyboard shortcuts (record start/stop, view toggles, template apply). **S** → FR-UX-3 +- `[T7.5]` Screen-reader labels, focus order, ARIA; contrast audit. **M** → FR-UX-4 +- `[T7.6]` Privacy panel: active backend + LLM endpoint + local-only confirmation; `privacy_self_check`. **M** → FR-SEC-2 +- `[T7.7]` Startup/perf pass: cold start < ~2 s, lazy-load models, idle audit. **M** → NFR-PERF-4, NFR-RES-1 + +**Acceptance:** Themes switch with the OS and override; keyboard-only operation works for core +flows; a contrast/screen-reader audit passes; the privacy panel accurately reflects egress; cold +start meets target. (Tests: P7 suite.) + +--- + +## Phase 8 — Advanced features +**Goal:** templates, search/tagging, reminders, richer export, encryption. + +- `[T8.1]` Note templates by meeting type (sections per type); apply on creation. **M** → FR-NOTE-5 +- `[T8.2]` Full-text search (FTS5) across transcripts + notes. **M** → FR-SEARCH-1 +- `[T8.3]` Tagging + list filters (date/tag/participant). **M** → FR-SEARCH-2 +- `[T8.4]` PDF/Word export via local conversion. **M** → FR-NOTE-4 +- `[T8.5]` Bulk export by date range/tag. **S** → FR-STORE-4 +- `[T8.6]` Local reminders / OS notifications for action items. **M** → FR-CAL-5 +- `[T8.7]` Multi-language transcription + UI localization scaffold. **M** → FR-TRX-4 +- `[T8.8]` Optional at-rest encryption + password-protected vault. **L** → FR-SEC-3 +- `[T8.9]` (Later) Microsoft Graph calendar source behind `CalendarSource` (opt-in, consented). **L** → FR-CAL-6 + +**Acceptance:** Search returns relevant meetings; templates structure new notes; PDF/Word exports +render correctly offline; reminders fire locally; encryption (when enabled) protects the storage +root and unlocks with the vault password. (Tests: P8 suite.) + +--- + +## Phase 9 — Remote sync / upload +**Goal:** optionally upload meeting artifacts to user-configured destinations — off by default, +self-hostable first. (ADR-0010.) Can begin in parallel after Phase 2 since it only needs the +storage/file layer; OAuth sub-phase is independent of the WebDAV one. + +### 9a — Core + WebDAV primary set (Nextcloud, ownCloud, Cloudreve, Seafile, Synology) +- `[T9.1]` `sync` module: `SyncTarget`/`SyncManager` traits; `sync_targets`/`sync_jobs` schema (migration 0002). **M** → FR-SYNC-1 +- `[T9.2]` `WebDavTarget`: PUT/MKCOL/PROPFIND; per-provider base paths; chunked upload for large files. **L** → FR-SYNC-2 +- `[T9.3]` Target config UI + `add/update/remove/list_sync_targets`; per-target artifact + trigger selection. **M** → FR-SYNC-2/3 +- `[T9.4]` `test_sync_target` (reachability + auth) with provider-specific setup hints (e.g. enable SeafDAV). **M** → FR-SYNC-4 +- `[T9.5]` Durable queue: enqueue on finalize / "Upload now"; backoff retry; SHA-256 skip-if-unchanged; `sync://job` events. **L** → FR-SYNC-5 +- `[T9.6]` Credentials in OS credential store (`keyring`); never in settings/DB. **M** → FR-SYNC-6, NFR-SEC-4 +- `[T9.7]` TLS enforcement; refuse plaintext http except explicit per-target LAN opt-in. **S** → FR-SYNC-7 +- `[T9.8]` Sync state + target labeling in UI (self-hosted vs third-party); wire into privacy panel + `privacy_self_check`. **M** → FR-SYNC-8, FR-SEC-2 + +### 9b — Secondary OAuth targets (OneDrive, Dropbox, Box) +- `[T9.9]` OAuth 2.0 PKCE with loopback redirect; token storage in credential store; `begin_oauth_link`. **L** → FR-SYNC-9 +- `[T9.10]` `OneDriveTarget` (MS Graph), `DropboxTarget`, `BoxTarget`: upload + resumable sessions behind `SyncTarget`. **L** → FR-SYNC-9 +- `[T9.11]` Third-party "data leaves your device" banner + explicit enable acknowledgment. **S** → FR-SYNC-8 + +### 9c — Optional +- `[T9.12]` Client-side encryption before upload (uses the Phase 8 vault): destination holds only ciphertext. **M** → FR-SYNC-10 + +**Acceptance:** With sync disabled (default), the egress allowlist contains no sync hosts and nothing +uploads. After adding and enabling a WebDAV target (tested against a real Nextcloud/Seafile or an +`rclone serve webdav` instance), finishing a meeting uploads the selected artifacts to the correct +remote path; interrupting the network leaves a `failed` job that retries and completes; an unchanged +re-upload is skipped. Credentials never appear in `settings.json`/`wa.db`. Third-party targets show +the leaves-your-device banner. (Tests: P9 suite.) + +--- + +## Phase 10 — External AI & coding-agent integration +**Goal:** optionally use hosted AI for summaries, and hand a meeting off to the user's coding agent +to "get started right away." (ADR-0011.) All off by default. Needs Phase 5 (LLM) for distillation; +otherwise independent. + +### 10a — Cloud summary providers (Layer 1) +- `[T10.1]` Extend `LlmProvider` with `OpenAiCompatProvider` (`/v1/chat/completions`) and `AnthropicProvider` (`/v1/messages`). **M** → FR-AI-1 +- `[T10.2]` `set_llm_provider`; API keys in OS credential store; host added to egress allowlist; third-party banner. **M** → FR-AI-2, FR-SEC-1 +- `[T10.3]` Per-use provider selection + active-provider display. **S** → FR-AI-3 + +### 10b — WhispAssist as an MCP server + feature briefs (Layer 2, the primary handoff) +- `[T10.4]` `mcp` module on `rmcp`: loopback MCP server, **off by default**, token-gated; Streamable HTTP (`/mcp`) + stdio adapter. **L** → FR-MCP-1/6, NFR-SEC-5 +- `[T10.5]` Tools-first surface: `list_recent_meetings`, `get_transcript`, `get_action_items`, `get_feature_brief`. **M** → FR-MCP-2 +- `[T10.6]` `FeatureBriefBuilder`: distill transcript → structured brief; `create/list/get_feature_brief`; persist to `briefs/`. **L** → FR-MCP-4 +- `[T10.7]` Scope control (`none|selected|all`, recordings excluded by default) + `set_brief_exposed`. **M** → FR-MCP-3 +- `[T10.8]` Disclosure UI ("connected agents may forward data") + `mcp_access_log` audit. **M** → FR-MCP-5 +- `[T10.9]` Privacy panel + `privacy_self_check` show MCP state and confirm it adds no egress. **S** → FR-MCP-7, FR-SEC-2 + +### 10c — Push & task-tracker handoff (Layer 3, later) +- `[T10.10]` `AgentRunner`: spawn `claude -p` / `codex exec` / `opencode run` / `copilot` headless against a repo; stream output. **L** → FR-AGENT-1 +- `[T10.11]` `IssueTracker`: create a GitHub issue from a brief; optional assign-to-Copilot-cloud. **M** → FR-AGENT-2 + +**Acceptance:** With everything off (default), no AI host is on the allowlist and no MCP port is +open. Enabling a hosted provider routes a summary to it and shows the third-party banner. Enabling +the MCP server lets a local agent (e.g. Claude Code) call `get_feature_brief` and receive a usable +spec; the access is logged; WA opens no outbound socket for it (egress test unchanged). Recordings +are never served unless explicitly allowed. (Tests: P10 suite.) + +--- + +## Cross-cutting (every phase) +- Privacy invariant FR-SEC-1 verified continuously: the CI network test asserts egress stays within + the settings-derived allowlist (LLM + enabled sync hosts + model downloads). See `06-test-strategy.md`. +- `cargo fmt`/`clippy -D warnings` + Prettier/ESLint clean. +- Update `docs/` whenever a contract or behavior changes (CLAUDE.md rule). + +## Dependency graph (high level) +``` +P1 ─► P2 ─► P3 ─► P4 ─► P5 ─► P10 (AI/MCP; 10a/10b need P5's LLM) + │ └► P6 ─► P7 ─► P8 + └► P9 (sync; needs only P2's storage layer — can run in parallel) +P5 and P6 can proceed in parallel after P4; P7 needs P3–P6 features to polish; P8 last. +P9 needs only P2; 9b (OAuth) is independent of 9a (WebDAV); 9c needs P8's vault. +P10 needs P5; 10b (MCP) is the priority; 10c (push/issue) is later and optional. +``` + +## Suggested first milestone (thin vertical slice) +T1.1 → T1.2 → T1.5 → T1.6 → T2.1 → T2.2 → T2.4 gives a usable "record → live transcript → saved +Markdown notes" loop — the smallest thing worth dogfooding. diff --git a/docs/06-test-strategy.md b/docs/06-test-strategy.md new file mode 100644 index 0000000..328a7ba --- /dev/null +++ b/docs/06-test-strategy.md @@ -0,0 +1,147 @@ +# 06 — Test Strategy + +Testing mirrors the architecture (per-service) and the roadmap (per-phase). Every requirement ID +has at least one test that asserts its acceptance criteria. A phase is "done" only when its suite +is green and the cross-cutting gates pass. + +## Test levels + +| Level | Scope | Tooling | +|---|---|---| +| Unit | One module/function; pure logic, parsers, mappers | Rust `#[cfg(test)]`; Vitest (frontend) | +| Service/integration | One service against real deps (SQLite, files, Ollama, models) | Rust integration tests in `/tests` | +| Contract | Tauri command/event payload shapes match `04-api-contracts.md` | shared TS↔Rust fixtures, schema assertions | +| End-to-end | Full flows through the UI (record→notes→summary) | Tauri WebDriver / Playwright-style harness | +| Non-functional | Performance, resource, privacy egress | Criterion benches; resource probes; network monitor | + +## Key fixtures (`/tests/fixtures`) +- Short stereo WAVs with known content (1-speaker, 2-speaker, overlap, silence, device-glitch). +- A golden `transcript.json` for deterministic alignment/merge tests. +- A small synthetic `.pst` with known events/attendees (or a generator script) — never real mail. +- A canned Ollama response set (recorded) for offline LLM tests + a live-Ollama opt-in lane. +- A disposable WebDAV server for sync tests (`rclone serve webdav` or a throwaway Nextcloud/Seafile + container) + mocked OAuth responses for the secondary providers. + +## Per-phase suites + +### P1 — capture + CPU transcription +- Unit: WAV writer produces valid headers/duration; ring buffer bounded + lossless under load. +- Service: capture a fixed audio source → byte-accurate `audio.wav` (FR-CAP-1/2). +- Service: feed a known WAV through the CPU Transcriber → expected segments within tolerance (FR-TRX-1). +- E2E: start→speak→segments appear via `transcript://segment`; stop→idle (FR-CAP-3, FR-TRX-2). +- NFR: idle audit — 0 audio threads / 0 polling timers after stop (NFR-RES-1). +- Service: retention OFF (default) → no `audio.wav` after finalize; retention ON → playable + `audio.wav` retained; toggling mid-meeting takes effect (FR-REC-1/3). +- Unit/E2E: consent notice shown before first retained recording; acknowledgment persists and is not + re-shown (FR-REC-2). + +### P2 — storage, notes, recovery +- Unit: schema migrations apply forward cleanly; Markdown assembly is speaker-tagged + deterministic. +- Service: create→finalize→get round-trips meeting + transcript (FR-STORE-1/5). +- Service: **crash-recovery** — write working audio, kill process, relaunch → meeting `recovering`, + re-transcribe succeeds (FR-REL-1, NFR-REL-3). +- Service: retention deletes oldest past cap; refuses to touch an in-progress meeting (FR-STORE-2). +- Service: working-WAV deletion on finalize happens only after transcript is finalized; a crash + before finalize leaves a recoverable WAV regardless of retention setting (FR-REC-4). +- Contract: `.md` export round-trips notes (FR-NOTE-3); bundle export contains audio+transcript+notes. + +### P3 — hardware + models +- Unit: backend ranking honors NPU→NVIDIA→AMD→Intel→CPU; override respected (FR-HW-2). +- Service: same WAV across available backends → equivalent segments (FR-TRX-5). +- Service: simulated backend failure → `hardware://changed` + CPU fallback, no abort (FR-HW-4). +- NFR bench: accelerated RTF < CPU RTF on capable hardware; latency target met (NFR-PERF-1/2). +- Service: model download/remove with progress; corrupt/aborted download handled (FR-MODEL-1). + +### P4 — diarization & naming +- Service: 2-speaker fixture → ≥2 speaker IDs; alignment overlap accuracy ≥ threshold (FR-SPK-1). +- Unit: rename applies to all of a speaker's segments at render; storage IDs unchanged (FR-SPK-5). +- Unit: merge(from=[S2,S3], into=S1) updates notes/export; idempotent (FR-SPK-3). +- E2E: in-session naming updates live view (FR-SPK-2). + +### P5 — LLM +- Service (recorded): prompt assembly includes transcript+metadata+template; output parsed into + action items (FR-LLM-2/3). +- Service: streaming tokens arrive via `llm://token`; `summary.json` persisted (FR-LLM-4). +- Unit: local-endpoint guard flags non-loopback hosts → banner (FR-LLM-6, FR-SEC-1). +- Resilience: provider off/unreachable → core app unaffected, clear status (FR-LLM-1/5). +- Live lane (opt-in): against a real local Ollama, end-to-end summary sanity check. + +### P6 — calendar / .pst +- Service: parse synthetic `.pst` → expected events + attendees; password path; corrupt file → + non-fatal error surfaced (FR-CAL-1). +- Unit: attendee list populates speaker-naming dropdown; "add new name" path (FR-SPK-4). +- Service: attach meeting↔event persists link; participant continuity across meetings (FR-CAL-2/4). + +### P7 — UX / accessibility / privacy +- E2E: theme follows OS + override; keyboard-only core flows (FR-UX-2/3). +- A11y: automated axe-style audit (labels/roles/contrast) passes (FR-UX-4). +- Service: `privacy_self_check` reports only allowed egress incl. configured sync targets with + correct third-party/TLS labeling; backend/endpoint accurate (FR-SEC-2). +- NFR: cold-start time under target; model lazy-load verified (NFR-PERF-4). + +### P8 — advanced +- FTS search relevance on fixture corpus (FR-SEARCH-1); tag/filter correctness (FR-SEARCH-2). +- Template application structures notes by type (FR-NOTE-5). +- PDF/Word export renders offline + opens (FR-NOTE-4); bulk export by range/tag (FR-STORE-4). +- Reminder fires locally (FR-CAL-5). Encryption: locked store unreadable without vault password; + unlock round-trips (FR-SEC-3). + +### P9 — remote sync / upload +- Service: **sync disabled (default)** → no `sync_jobs` enqueued, no sync hosts in the egress + allowlist, nothing uploads (FR-SYNC-1). This is the most important sync test. +- Service (WebDAV against `rclone serve webdav` or a disposable Nextcloud/Seafile container): + finalize → selected artifacts land at the correct remote path (FR-SYNC-2/3). +- Service: `test_sync_target` succeeds with good creds, fails clearly with bad creds/URL (FR-SYNC-4). +- Resilience: kill the network mid-upload → job `failed`, retried with backoff, completes; unchanged + re-upload is `skipped` via SHA-256; large-file resumable/chunked path exercised (FR-SYNC-5). +- Security: credentials never written to `settings.json`/`wa.db` (assert by scanning both); + `list_sync_targets` never returns secrets (FR-SYNC-6). Plaintext `http://` refused unless explicit + LAN opt-in (FR-SYNC-7). +- Unit: host-allowlist derivation includes only enabled targets; disabling a target removes its host. +- Secondary (mocked OAuth): PKCE loopback flow links an account; token stored in credential store; + third-party banner shown (FR-SYNC-8/9). Optional: encrypt-before-upload yields ciphertext remotely + (FR-SYNC-10). + +### P10 — external AI & coding-agent integration +- Service: **all off (default)** → no AI host on the allowlist, MCP port closed, no briefs exposed. + This is the most important P10 test. +- Service (10a): summary routes to a mocked OpenAI-compatible and a mocked Anthropic endpoint + (correct `/v1/chat/completions` vs `/v1/messages` shapes); API key read from the credential store, + never from settings/DB; host appears on the allowlist only when configured (FR-AI-1/2). +- Service (10b): with the MCP server enabled, an in-process MCP **client** calls `get_feature_brief` + and receives a schema-valid brief; tools-only surface (no required resources/prompts) (FR-MCP-2/4). +- Security: MCP server binds to **loopback only** (assert it does not bind a non-loopback address), + requires a token, and **opens no outbound socket** — the egress test is unchanged with MCP on (FR-MCP-7, + NFR-SEC-5). Recordings are not served unless `expose_recordings` is true (FR-MCP-3). +- Audit: every tool call appends an `mcp_access_log` row / `mcp://access` event (FR-MCP-5). +- Unit: `FeatureBriefBuilder` produces problem/outcome/acceptance-criteria from a golden transcript. +- (10c, when built) push: `AgentRunner` invokes a stub CLI with the brief; `IssueTracker` creates a + mocked GitHub issue and (optional) Copilot assignment (FR-AGENT-1/2). + +## Cross-cutting gates (run in CI every change) + +1. **Privacy egress test (FR-SEC-1):** run a representative flow under a network monitor; assert + **no** outbound connection except hosts on the settings-derived allowlist — the configured LLM/AI + provider endpoint, **enabled sync target hosts**, a configured task tracker, and explicit + model-download hosts. With nothing configured, assert zero egress. **Enabling the MCP server must + not change the egress set** (it is inbound/loopback). Any off-allowlist connection blocks merge. + This is the product's most important automated guarantee. +2. **Build matrix:** CPU-only build must always pass; accelerated features (CUDA/Vulkan/DirectML) + built on capable runners, skipped (not failed) where hardware/SDK is absent (NFR-MNT-4). +3. **Lint/format:** `cargo fmt --check`, `cargo clippy -- -D warnings`, Prettier, ESLint, `tsc`. +4. **Contract check:** TS client types and Rust command/event payloads validated against shared + fixtures so the IPC boundary can't silently drift. + +## Performance & resource benchmarks (tracked over time) +- Real-time latency per phrase by backend (NFR-PERF-1). +- 60-min batch wall-clock vs meeting length by backend (NFR-PERF-2). +- Idle CPU/memory snapshot (NFR-RES-1); recording CPU on CPU-only laptop (NFR-RES-2). +- Cold-start to interactive (NFR-PERF-4). Regressions beyond a threshold fail the bench gate. + +## Manual / exploratory checklist (pre-release) +Real meeting dogfood; device hot-swap mid-recording; very long (2 h) meeting; low-disk during +capture; abrupt power loss (recover from audio); high-DPI + both themes; screen-reader pass. + +## Definition of done (recap from CLAUDE.md) +Compiles, no clippy warnings, requirement acceptance criteria met, named phase tests pass, docs +updated if contracts/behavior changed. diff --git a/docs/07-research-findings.md b/docs/07-research-findings.md new file mode 100644 index 0000000..0d46635 --- /dev/null +++ b/docs/07-research-findings.md @@ -0,0 +1,111 @@ +# 07 — Research Findings (Stack Validation) + +This document records the technical feasibility checks performed before committing to a stack. +Each finding includes the conclusion that fed into the ADRs. Dates reflect research done +**June 2026**; re-verify versions at implementation time. + +## Summary + +Every load-bearing assumption in the design document is supported by a mature, offline-capable, +permissively licensed component. No blocker was found. The one area needing care is the **NPU +path** (newer, vendor-dependent) and **`.pst` parsing** (use a battle-tested library, treat as +read-only). Both have viable primary + fallback options. + +## Findings + +### App shell — Tauri 2 ✅ +Tauri 2.0 reached stable in **October 2024** and is the current major line. On Windows it +renders through **WebView2** (the OS Chromium-based webview) rather than bundling a browser, +which is the key reason its binaries and memory footprint are far smaller than Electron's — +directly serving WA's "fast, low memory" goal. Cross-platform (Windows/macOS/Linux) leaves the +door open for the design doc's "future" non-Windows support without a rewrite. +→ **ADR-0001.** + +### Frontend — Svelte ✅ +Svelte compiles components to small imperative JS with no runtime framework shipped, giving the +smallest bundle and lowest per-view memory among mainstream options — consistent with the +low-overhead NFRs. Pairs cleanly with Tauri via Vite. → **ADR-0002.** + +### Audio capture — WASAPI loopback in Rust ✅ +The `wasapi` crate provides safe Rust access to WASAPI including a **loopback** example +(capture system output without a meeting bot). `cpal` is the cross-platform fallback. Known +constraint: `AUDCLNT_STREAMFLAGS_LOOPBACK` and `AUDCLNT_STREAMFLAGS_EVENTCALLBACK` cannot be +combined, so the capture loop must **poll** rather than rely on event callbacks. This shapes +the audio thread design in `docs/02-architecture.md`. → **ADR-0001 / architecture.** + +### Transcription — whisper-rs (+ ONNX for NPU) ✅ +`whisper-rs` wraps **whisper.cpp** with feature-gated acceleration: **CUDA** (NVIDIA), +**Vulkan** (cross-vendor GPU incl. AMD/Intel), **ROCm/hipBLAS**, **Metal**, **OpenBLAS**, and +plain CPU. whisper.cpp has **no native NPU backend**, so the NPU tier is served separately via +**ONNX Runtime** (see below) running a Whisper-ONNX model. This split — whisper.cpp for +CPU/GPU, ONNX for NPU — is the basis of the acceleration ladder. → **ADR-0003.** + +### Hardware acceleration — ONNX Runtime `ort` + DirectML/Windows ML ✅ +The Rust `ort` crate exposes ONNX Runtime execution providers including **DirectML**, which can +target an **NPU** (`device_filter = "npu"`) on Windows. Execution providers fall back in order +(NPU → GPU → CPU) automatically when an operator is unsupported. **Windows ML** is now GA as the +production on-device runtime on Windows 11. DirectX 12 is required for DirectML (ubiquitous on +modern hardware). Note: Microsoft is steering new Windows ONNX work toward Windows ML over raw +DirectML, so the hardware layer is abstracted (`hardware::Backend`) to allow swapping the +provider without touching callers. → **ADR-0004.** + +### Diarization — sherpa-onnx ✅ +`sherpa-onnx` (k2-fsa) provides **offline speaker diarization**: pyannote segmentation model + +a speaker-embedding extractor (e.g. 3D-Speaker ERes2Net) + clustering, all as ONNX, fully +offline. It ships C/C++ APIs callable from Rust via FFI. This satisfies "Speaker 1/2 …" labeling +and post-meeting merge/rename without any cloud dependency. → **ADR-0005.** + +### Storage — SQLite ✅ +SQLite (via `sqlx` for async or `rusqlite` for sync) is the standard embedded store, matches +Meetily's proven model, requires no server, and supports full-text search (FTS5) for the +transcript search requirement. → **ADR-0006.** + +### Local LLM — Ollama HTTP ✅ +Ollama serves a REST API on **`http://localhost:11434`** with `/api/chat` (OpenAI-style +messages), `/api/generate` (one-shot), `/api/tags` (list models), `/api/pull` (download), and +token **streaming** via newline-delimited JSON. WA calls only this local endpoint; a "custom +endpoint" option lets advanced users point at any OpenAI-compatible local server. → **ADR-0007.** + +### Outlook `.pst` — outlook-pst / libpff ✅ +The Rust `outlook-pst` crate offers **read-only** access to PST files modeled on the published +MS-PST specification (emails, folders, and — relevant here — calendar appointments/attendees). +The mature C library **libpff** (with `pffexport`) is the fallback for tricky/encrypted files +via FFI or a sidecar. Treat PST strictly as read-only input. → **ADR-0008.** + +### External AI / MCP — all targets are MCP clients; Rust SDK exists ✅ +- **Claude** (Code & Desktop): MCP client over stdio + HTTP; Claude Code also runs **headless** + (`claude -p`, `--bare`) and ships the **Claude Agent SDK** (Python/TS) — usable for the optional + push path. Claude Code can itself act as an MCP server. +- **OpenAI Codex** (CLI): MCP via **stdio** (local child process) **and remote Streamable HTTP** + (OAuth/bearer); managed with `codex mcp`; `codex exec` for headless. Has an Agents SDK. +- **GitHub Copilot:** MCP across **IDE, CLI, and the cloud agent**. Caveat: the cloud agent supports + MCP **tools only** (no resources/prompts) and **no OAuth-remote** MCP, and runs in GitHub's cloud + (can't reach a localhost server) — so WA exposes capabilities as **tools**, and Copilot *cloud* is + better served by the GitHub-issue handoff. +- **OpenCode:** full MCP client (local + remote), 75+ LLM providers, has an SDK. +- **Transports:** MCP defines exactly **stdio** and **Streamable HTTP** (HTTP+SSE deprecated). For a + long-running desktop app holding shared state, a **loopback Streamable HTTP** `/mcp` endpoint fits; + a thin **stdio** adapter covers agents that spawn their server. +- **Rust SDK:** the official **`rmcp`** crate (`modelcontextprotocol/rust-sdk`, v0.16+, `server` + feature) implements tools/resources/prompts with pluggable transports — so WA's Rust core can host + the server natively and lightweightly. +- **Summary APIs:** **Anthropic** is native `/v1/messages` (needs its own adapter); **OpenAI** is + `/v1/chat/completions`; most others are OpenAI-compatible. → **ADR-0011** (and ADR-0007 update). + +## Open items to confirm at build time +- Exact `whisper-rs` version and which acceleration features build cleanly on the Windows CI image. +- `ort` ↔ ONNX Runtime binary version pinning for the DirectML/NPU path on target Windows builds. +- `outlook-pst` coverage of password-protected and very large PST files; fall back to libpff if gaps. +- Whether to consume `sherpa-onnx` as a prebuilt C library or build from source in CI. + +## Sources +- [Tauri 2.0 Stable Release](https://v2.tauri.app/blog/tauri-20/) · [Tauri vs Electron 2026](https://tech-insider.org/tauri-vs-electron-2026/) +- [whisper-rs (crates.io)](https://crates.io/crates/whisper-rs) · [whisper-rs repo](https://github.com/tazz4843/whisper-rs) +- [ort execution providers](https://ort.pyke.io/perf/execution-providers) · [ONNX Runtime DirectML EP](https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html) · [Windows ML execution providers](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/supported-execution-providers) +- [sherpa-onnx speaker diarization](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/index.html) +- [wasapi (crates.io)](https://crates.io/crates/wasapi) · [cpal](https://github.com/RustAudio/cpal) +- [Ollama API docs](https://github.com/ollama/ollama/blob/main/docs/api.md) +- [outlook-pst (docs.rs)](https://docs.rs/outlook-pst/latest/outlook_pst/) · [libpff](https://github.com/libyal/libpff) +- MCP clients: [Copilot MCP + coding agent](https://docs.github.com/en/copilot/concepts/agents/coding-agent/mcp-and-coding-agent) · [Codex MCP](https://developers.openai.com/codex/mcp) · [OpenCode MCP/config](https://opencode.ai/docs/config/) · [Claude Code headless](https://code.claude.com/docs/en/headless) +- MCP protocol: [Transports spec](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports) · [Rust SDK `rmcp`](https://github.com/modelcontextprotocol/rust-sdk) ([docs.rs](https://docs.rs/rmcp)) +- Summary APIs: [Anthropic Messages vs OpenAI Chat Completions](https://portkey.ai/blog/open-ai-responses-api-vs-chat-completions-vs-anthropic-anthropic-messages-api/) diff --git a/docs/adr/0001-app-shell-tauri.md b/docs/adr/0001-app-shell-tauri.md new file mode 100644 index 0000000..50d77b7 --- /dev/null +++ b/docs/adr/0001-app-shell-tauri.md @@ -0,0 +1,49 @@ +# ADR-0001 — Application shell: Tauri 2 + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Deciders:** WA core team +- **Context source:** Design doc §"Desktop UI Layer", NFR resource-usage requirements + +## Context + +The design doc leaves the UI framework open (WPF/.NET, WinUI 3, Electron, or native) and the +user's only hard constraint is: **fast and low memory footprint**. WA is Windows-first but the +doc mentions possible future Windows-on-ARM and (implicitly) cross-platform support. The app +must idle like a lightweight background process (NFR-RES-1) yet present a modern, themeable UI +with waveforms, live transcript streaming, and rich note editing. + +## Options considered + +1. **Electron + native modules** — fastest UI development, largest ecosystem, but bundles + Chromium → high baseline memory and large binaries. Conflicts with the low-overhead NFRs. +2. **.NET + WinUI 3** — excellent native Windows + hardware story (Windows ML, DirectML, + WASAPI), good performance. But heavier runtime than Rust/native, Windows-locked, and a + larger install. Strong second choice. +3. **C++ / Qt or fully native Win32** — lowest overhead, maximal control. Slowest UI iteration, + most code, hardest to staff for an open-source project. +4. **Tauri 2 (Rust core + WebView2 + web UI)** — uses the OS WebView2 instead of bundling a + browser → small binaries, low idle memory; Rust core gives native-class performance for + audio/ML; web frontend keeps modern UX fast to build; cross-platform for the future. + +## Decision + +**Use Tauri 2.** It is the best fit for "fast + low memory + modern UI + open source": +WebView2 keeps the footprint near-native while the Rust core handles WASAPI capture, whisper.cpp +inference, ONNX Runtime, SQLite, and HTTP to Ollama with no GC pauses and direct access to the +exact crates validated in `07-research-findings.md`. + +## Consequences + +- **Positive:** small footprint; one language (Rust) for all system-level services; mature + access to `wasapi`, `whisper-rs`, `ort`, `sherpa-onnx`, `outlook-pst`; future macOS/Linux/ARM + reachable without a rewrite. +- **Negative / risks:** depends on WebView2 runtime (preinstalled on Win11, bootstrappable on + Win10); WASAPI loopback must poll (no event callback) — handled in the audio thread design; + web↔Rust boundary requires disciplined command/event contracts (`docs/04-api-contracts.md`). +- **Rejected because:** Electron's memory cost contradicts the primary constraint; WinUI 3 and + native were viable but more Windows-locked / slower to iterate for an OSS project. + +## Revisit if +WebView2 proves unreliable for real-time waveform/transcript rendering, or NPU tooling becomes +materially better from .NET than from Rust. diff --git a/docs/adr/0002-frontend-svelte.md b/docs/adr/0002-frontend-svelte.md new file mode 100644 index 0000000..65f3456 --- /dev/null +++ b/docs/adr/0002-frontend-svelte.md @@ -0,0 +1,39 @@ +# ADR-0002 — Frontend framework: Svelte + TypeScript + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Context source:** Design doc §"UX and Performance Principles", NFR-RES-1 + +## Context + +Tauri (ADR-0001) renders a web frontend. We need light/dark theming, a three-pane layout +(meetings list · transcript/notes · summary/participants), live-updating transcript and +waveform, and a Markdown editor — all while keeping per-view memory and render cost low. + +## Options considered + +- **React** — largest ecosystem and component availability, but ships a runtime + virtual DOM; + heavier than necessary for our footprint goals. +- **Vue** — middle ground; runtime overhead between React and Svelte. +- **Svelte** — compiles components to small imperative JS with no shipped framework runtime; + smallest bundles and lowest memory; fine-grained reactivity ideal for high-frequency + transcript/waveform updates. +- **SolidJS** — comparably lean, but smaller ecosystem/talent pool than Svelte. + +## Decision + +**Use Svelte with TypeScript, bundled by Vite.** Best memory/performance profile and a good fit +for streaming UI updates, with enough ecosystem maturity for an OSS project. + +## Consequences + +- **Positive:** smallest footprint; simple, fast reactive updates for live transcript; first-class + Vite integration with Tauri. +- **Negative:** smaller component ecosystem than React (mitigated: WA's UI is bespoke); team must + know Svelte 5 reactivity model. +- Theming via CSS custom properties + `prefers-color-scheme`, with a user override stored in + settings (FR-UX-2). + +## Revisit if +We need a large third-party component (e.g. complex data grid) only available for React/Vue, or +hiring constraints favor React. diff --git a/docs/adr/0003-transcription-engine.md b/docs/adr/0003-transcription-engine.md new file mode 100644 index 0000000..449871d --- /dev/null +++ b/docs/adr/0003-transcription-engine.md @@ -0,0 +1,41 @@ +# ADR-0003 — Transcription engine: whisper-rs (whisper.cpp) with pluggable backends + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Context source:** Design doc §"Transcription Engine", §"Transcription Modes" + +## Context + +WA needs accurate, offline, multilingual speech-to-text with both a low-latency streaming mode +and a higher-accuracy batch mode, runnable across CPU and several GPU vendors, and (separately) +on NPUs. + +## Decision + +Use **`whisper-rs`** (Rust bindings to **whisper.cpp**) as the primary engine, behind a +`transcription::Transcriber` trait so the concrete engine is swappable. whisper.cpp gives: +- CPU (AVX2/AVX-512) baseline that runs anywhere, +- **Vulkan** for cross-vendor GPU (AMD/Intel/NVIDIA), +- **CUDA** for NVIDIA fast path, +- selectable model sizes (tiny→large) for the speed/accuracy and "low-overhead" presets, +- multilingual models for FR-LANG-1. + +The **NPU** path is *not* served by whisper.cpp (it has no NPU backend); it is a second +`Transcriber` implementation using ONNX Runtime — see ADR-0004. Both implementations satisfy the +same trait and emit the same `TranscriptSegment` stream. + +## Consequences + +- **Positive:** one mature dependency covers CPU + all desktop GPUs; model-size switching maps + directly to the performance presets; trait boundary lets the NPU/ONNX engine slot in without + touching callers (audio, storage, UI). +- **Negative:** two engines (whisper.cpp + ONNX) to maintain for full hardware coverage; + whisper.cpp diarization is weak, so diarization is a separate component (ADR-0005); building + acceleration features on Windows CI needs the right toolchain (CUDA/Vulkan SDKs) — gated by + Cargo features so CPU-only always builds. +- **Streaming mode:** feed fixed audio windows with overlap; emit interim segments, then finalize. +- **Batch mode:** optional re-run with a larger model after stop for maximum accuracy. + +## Revisit if +A single engine gains solid NPU + GPU + CPU coverage (would let us drop the ONNX engine), or a +materially better local ASR model (e.g. Parakeet) outperforms Whisper for our languages. diff --git a/docs/adr/0004-hardware-acceleration.md b/docs/adr/0004-hardware-acceleration.md new file mode 100644 index 0000000..ac0b6c4 --- /dev/null +++ b/docs/adr/0004-hardware-acceleration.md @@ -0,0 +1,46 @@ +# ADR-0004 — Hardware acceleration strategy & detection ladder + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Context source:** Design doc §"Hardware Detection and Transcription Strategy" + +## Context + +The design mandates a strict preference order **NPU → NVIDIA → AMD → Intel → CPU**, detected at +startup and on demand, with graceful fallback and the chosen backend surfaced to the user. + +## Decision + +Introduce a `hardware` module that performs **capability detection** and returns a ranked list of +available `Backend`s; the transcription layer selects the highest-ranked one and constructs the +matching `Transcriber`. + +Detection mapping: + +| Tier | Detection | Execution path | +|---|---|---| +| NPU | ONNX Runtime / Windows ML enumeration; DirectML `device_filter="npu"` | ONNX `Transcriber` (Whisper-ONNX) via `ort` + DirectML | +| NVIDIA GPU | adapter enumeration (DXGI) + CUDA availability | whisper.cpp **CUDA** | +| AMD GPU | DXGI adapter; Vulkan device present | whisper.cpp **Vulkan** (DirectML alt.) | +| Intel GPU | DXGI adapter; Vulkan device present | whisper.cpp **Vulkan** | +| CPU | always | whisper.cpp **CPU** (AVX2/AVX-512 if present) | + +- ONNX Runtime execution providers also fall back internally (NPU→GPU→CPU) per-operator, giving a + second safety net on the NPU path. +- The selected backend, model size, and an estimated real-time factor are reported via the + `hardware_status` command and shown in Settings + the recording bar (FR-HW-3). +- A user override lets advanced users pin a specific backend (FR-HW-2); "Low overhead" preset + forces CPU + small model + no real-time summarization (NFR-RES-3). + +## Consequences + +- **Positive:** one abstraction satisfies the full ladder; per-operator EP fallback makes the + NPU path robust; clean place to add future vendors/runtimes (Windows ML is abstracted behind + `Backend`, so a DirectML→Windows ML migration is local to this module). +- **Negative:** NPU support is hardware/driver dependent and the newest, least-tested tier — + must degrade silently to GPU/CPU and never hard-fail; requires pinning `ort`↔ONNX Runtime + binary versions on Windows builds. + +## Revisit if +Windows ML supersedes DirectML for our models, or a vendor SDK gives materially better NPU +throughput than the ONNX/DirectML path. diff --git a/docs/adr/0005-diarization-sherpa-onnx.md b/docs/adr/0005-diarization-sherpa-onnx.md new file mode 100644 index 0000000..b5754d6 --- /dev/null +++ b/docs/adr/0005-diarization-sherpa-onnx.md @@ -0,0 +1,37 @@ +# ADR-0005 — Speaker diarization: sherpa-onnx + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Context source:** Design doc §"Speaker Diarization and Naming" + +## Context + +WA must label transcript segments by speaker (Speaker 1/2…), allow naming during and after a +meeting, support merging over-split speakers, and feed calendar participant names into the +naming UI — all offline. + +## Decision + +Use **`sherpa-onnx`** offline speaker diarization: a pyannote **segmentation** model + a +speaker-**embedding** extractor (e.g. 3D-Speaker ERes2Net) + **clustering**, all ONNX and fully +local. Wrap it behind a `diarization::Diarizer` trait. Diarization runs as **post-processing** +over the recorded audio (audio is the source of truth, ADR-0006), producing speaker-ID-tagged +spans that are aligned to whisper segments by timestamp overlap. + +During live recording, show provisional speaker turns from segmentation (cheap), then refine +labels in the post-meeting pass; this keeps latency low while improving final accuracy. + +## Consequences + +- **Positive:** purpose-built, offline, ONNX (shares the runtime with the NPU transcription path); + supports the segmentation+embedding+clustering pipeline the design assumes; models are + downloadable and swappable. +- **Negative:** separate models to download/manage (model-management UI, FR-MODEL-1); C/C++ FFI + to integrate; clustering may over/under-split → the **merge** workflow (FR-SPK-3) is required, + not optional. +- Speaker IDs (`S1`, `S2`, …) are internal and stable per meeting; name mappings live in the DB + and are applied at render/export time, never destructively rewritten onto segments. + +## Revisit if +A single model gives joint ASR + diarization with better accuracy, or whisper.cpp gains +production diarization. diff --git a/docs/adr/0006-storage-sqlite.md b/docs/adr/0006-storage-sqlite.md new file mode 100644 index 0000000..28619f4 --- /dev/null +++ b/docs/adr/0006-storage-sqlite.md @@ -0,0 +1,39 @@ +# ADR-0006 — Storage: SQLite index + on-disk audio/transcript files + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Context source:** Design doc §"Recording Persistence and Storage", §"Crash Recovery" + +## Context + +WA stores recordings, transcripts, notes, speakers, participants, tags, and meeting metadata +locally; needs fast list/filter/search; must survive crashes; and must let users choose the base +directory, set retention, and export. + +## Decision + +- **Large/opaque artifacts on the filesystem:** audio (`.wav`/compressed) and the canonical + transcript JSON live as files under the storage root, one folder per meeting. +- **Index & relations in SQLite** (via `sqlx`): meetings, speakers, participants, tags, action + items, and file paths; **FTS5** virtual table over transcript/notes text for full-text search + (FR-SEARCH-1). +- **Audio is the source of truth.** Audio is flushed to disk during capture; transcript/notes are + derived and regenerable. Auto-save transcript/notes at intervals; on startup, detect meetings + with audio but no finalized transcript and offer recovery (FR-REL-1). + +Default root: `%LOCALAPPDATA%\WhispAssist` with per-meeting subfolders; user-configurable base +directory and retention policy (size cap / age cap) in Settings (FR-STORE-2). + +## Consequences + +- **Positive:** embedded, serverless, zero-config; FTS5 covers search; files keep the DB small and + make export/backup a folder copy; crash recovery is straightforward because audio persists first. +- **Negative:** must keep DB rows and files consistent (transactional writes + a reconcile pass on + startup); retention enforcement is a background job that must respect "audio is source of truth" + (never orphan a meeting's audio while its transcript is mid-generation). +- Optional at-rest encryption (NFR-SEC-3) can wrap the storage root and/or use SQLCipher; deferred + to Phase 8. + +## Revisit if +We need multi-device sync (would change the data model and conflict story) — out of scope for the +local-only product. diff --git a/docs/adr/0007-llm-ollama.md b/docs/adr/0007-llm-ollama.md new file mode 100644 index 0000000..65cac54 --- /dev/null +++ b/docs/adr/0007-llm-ollama.md @@ -0,0 +1,49 @@ +# ADR-0007 — Local LLM integration: Ollama HTTP (localhost-only) + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Context source:** Design doc §"Local LLM Integration (Ollama / Unsloth)" + +## Context + +WA augments notes with summaries, decisions, and action items via a **local** LLM, configurable +by the user, and must never originate cloud calls for meeting content itself. + +## Decision + +Integrate via the **Ollama REST API** on `http://localhost:11434` behind an `llm::LlmProvider` +trait. Use `/api/tags` to list installed models, `/api/chat` (OpenAI-style messages) for +summarization with **streaming** token output, and `/api/pull` to assist guided model download. +Provide three provider options in Settings: +1. **Ollama** (default, autodetected), +2. **Custom OpenAI-compatible endpoint** (any local server the user runs — covers Unsloth-served + models, llama.cpp server, LM Studio, etc.), +3. **Disabled** (notes-only, no LLM). + +WA validates that the configured host resolves to **loopback/local**; if a user deliberately +points at a remote/proxying endpoint, WA shows a clear banner that upstream behavior is outside +WA's control (the design doc's Unsloth-proxy caveat). + +## Consequences + +- **Positive:** Ollama is the de-facto local LLM runtime with a simple, stable API and streaming; + the trait + "custom endpoint" keeps us provider-agnostic and honors the design's pluggability; + guided install/pull improves first-run UX with hardware-aware model suggestions. +- **Negative:** depends on the user having Ollama (or another local server) installed → need + detection + guided setup and a graceful "LLM unavailable, notes still work" path; summary quality + varies by model. +- Prompts are assembled from transcript + meeting metadata + optional template (FR-LLM-2); output + is parsed into editable action items (FR-LLM-3) that the user confirms before they become tasks. + +## Update (2026-06-30) — cloud providers behind the same trait + +`LlmProvider` is extended (ADR-0011, Layer 1) with optional **hosted** providers for summaries: +**Anthropic Messages API** (`/v1/messages` — native, not OpenAI-shaped) and an **OpenAI-compatible** +client (OpenAI, OpenRouter, LM Studio, gateways). These are **off by default**, explicitly opt-in, +and labeled as third-party egress; their hosts join the settings-derived egress allowlist +(FR-SEC-1). The `is_local()` guard still distinguishes local from remote so the UI can warn. Ollama +remains the default and the only zero-egress option. + +## Revisit if +A clearly better local-LLM runtime/protocol emerges, or we embed an inference engine directly +(would remove the external dependency but enlarge the app). diff --git a/docs/adr/0008-pst-calendar.md b/docs/adr/0008-pst-calendar.md new file mode 100644 index 0000000..348b1e8 --- /dev/null +++ b/docs/adr/0008-pst-calendar.md @@ -0,0 +1,38 @@ +# ADR-0008 — Calendar & Outlook .pst integration + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Context source:** Design doc §"Calendar Integration", §"Outlook .pst Integration" + +## Context + +WA enriches meetings with calendar context (title, organizer, attendees) and reads local Outlook +`.pst` backups to build meeting history and suggest speaker names — all local, no cloud sync +required. + +## Decision + +- **`.pst` reading:** use the Rust **`outlook-pst`** crate (read-only, MS-PST-based) to extract + calendar appointments and attendees. Fall back to **libpff** (`pffexport`/FFI or a small + sidecar) for password-protected or edge-case files. PST is **read-only input**; WA never writes + to it. +- **Live calendar:** abstract behind a `calendar::CalendarSource` trait. Phase 6 ships the PST + source. Microsoft Graph (with explicit user consent) and local ICS files are future sources + behind the same trait; Graph is the *one* allowed remote call and only for calendar metadata, + never meeting content, and only with consent. +- **Reminders:** action items become **local OS notifications** (Windows toast); optional + calendar entry creation is deferred and, where added, stays local/consented. +- **Participant-aware naming:** attendee names from the selected event populate the speaker-naming + dropdown (FR-SPK-2), with an "add new name" escape hatch; mappings persist per meeting. + +## Consequences + +- **Positive:** delivers Granola-style context fully offline from data the user already has; + trait keeps Graph/ICS additions non-invasive; read-only PST avoids corrupting user mail stores. +- **Negative:** PST parsing is fiddly (encryption, large files, format variance) → keep libpff as + a fallback and treat parse failures as non-fatal; Graph path (future) introduces OAuth + the + product's only remote dependency, so it must be strictly opt-in and clearly labeled. + +## Revisit if +`outlook-pst` proves insufficient in practice (promote libpff to primary), or users need live +Exchange more than PST (prioritize the Graph source). diff --git a/docs/adr/0009-optional-recording-consent.md b/docs/adr/0009-optional-recording-consent.md new file mode 100644 index 0000000..20db5d0 --- /dev/null +++ b/docs/adr/0009-optional-recording-consent.md @@ -0,0 +1,45 @@ +# ADR-0009 — Optional audio recording & consent + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Context source:** User request (2026-06-30); design doc §"Audio Capture and Privacy" + +## Context + +WA captures system audio to transcribe it. Transcription needs audio only transiently — the design +already treats `audio.wav` as the source of truth for crash recovery. However, **persisting** a +full meeting recording is a distinct, higher-stakes choice: it is more sensitive than a transcript, +and recording conversations without consent is **illegal in some jurisdictions** (e.g. all-party / +two-party consent regions). The user wants recording to be a deliberate opt-in, saved as `.wav`, +with a clear legal caution. + +## Decision + +1. **Recording is OFF by default.** A per-meeting and a global default toggle ("Record this + meeting") control whether the captured audio is **retained** after processing. +2. When recording is **off**, audio is still written to a working file during the session (so + transcription and crash recovery work), but that working audio is **deleted** when the meeting + is finalized — only the transcript/notes persist. +3. When recording is **on**, the working audio is kept as the meeting's permanent `audio.wav`. +4. **Consent notice.** The first time a user enables recording (and surfaced near the toggle + thereafter), WA shows a non-blocking notice: *"Recording conversations without the consent of + participants may be illegal in your region. Check your local recording laws."* The user must + acknowledge once; the acknowledgment is stored. This is a caution, **not legal advice**. +5. The recording indicator (FR-CAP-4) already makes active capture obvious; when retention is on, + the UI additionally indicates the meeting is being **saved**. + +## Consequences + +- **Positive:** privacy-respecting default (transcript-only); users opt into the heavier artifact + knowingly; the legal caution reduces the chance of inadvertent unlawful recording; reuses the + existing working-audio path, so little new machinery. +- **Negative / care:** the "delete working audio on finalize" path must be robust and must not race + with crash recovery — deletion happens only after the transcript is successfully finalized + (audio remains the source of truth until then, ADR-0006); retention/sync must treat a non-recorded + meeting as "no audio artifact exists". +- **Interaction with sync (ADR-0010):** only **retained** recordings are eligible to upload; a + non-recorded meeting can still sync its transcript/notes. + +## Revisit if +We add region detection to tailor the consent copy, or a "record microphone too" feature (separate +consent considerations). diff --git a/docs/adr/0010-remote-sync-targets.md b/docs/adr/0010-remote-sync-targets.md new file mode 100644 index 0000000..5b7a794 --- /dev/null +++ b/docs/adr/0010-remote-sync-targets.md @@ -0,0 +1,72 @@ +# ADR-0010 — Remote sync / upload targets + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Context source:** User request (2026-06-30) + +## Context + +WA stores everything locally. The user wants the **option** to upload meeting artifacts +(transcript, notes, summary, and optionally the `.wav` recording) to a destination they configure. +Required (primary) targets: **Nextcloud, ownCloud, Cloudreve, Seafile**. Desired (secondary): +**OneDrive, Dropbox, Box, Synology NAS**. + +This must be reconciled with WA's core promise (local-only). The reconciliation: sync is an +**explicit, user-configured export** — exactly the carve-out the privacy invariant already allows. +It is **off by default**, never automatic without configuration, and clearly surfaced. The primary +targets are notably all **self-hostable**, so the default story is "your data, on your server." + +## Decision + +### One protocol covers the entire primary set: WebDAV +Nextcloud, ownCloud, Cloudreve, and Seafile (via SeafDAV) all expose **WebDAV (RFC 4918)**, as does +**Synology** (WebDAV Server package). So a single `WebDavTarget` implementation serves all four +primary targets **and** Synology: +- Upload: `PUT`; create folders: `MKCOL`; existence/listing: `PROPFIND`. +- Large files (`.wav`): chunked/resumable upload where the server supports it (e.g. Nextcloud + chunked upload); otherwise single `PUT` with a size guard. +- Per-server base paths, e.g. Nextcloud/ownCloud `…/remote.php/dav/files//`, + Seafile `…/seafdav/`, Cloudreve `…/dav`. Seafile note: SeafDAV is **disabled by default** + server-side and may need `LOCK` disabled — surfaced in setup help. +- Auth: URL + username + **app password** (recommended over account password where supported). + +### Secondary targets use provider APIs + OAuth (one impl each) +`OneDriveTarget` (Microsoft Graph), `DropboxTarget` (Dropbox API v2), `BoxTarget` (Box API). Each +uses **OAuth 2.0 Authorization Code + PKCE** with a **loopback redirect** (`http://127.0.0.1:`) +— desktop-appropriate, no client secret embedded. Resumable/upload-session APIs for large files. +Synology can alternatively use its FileStation API, but WebDAV is the default path for it. + +### Common architecture +- A `SyncTarget` trait (`test`, `ensure_dir`, `put`, `exists`) abstracts all providers; a + `SyncManager` owns a **durable queue** of upload jobs (per artifact × per target). +- **What/when to upload is configurable** (FR-SYNC-3): choose artifacts (transcript / notes / + summary / recording) and trigger (on finalize, manual "Upload now", or retry). Recording is + uploadable only if it was retained (ADR-0009). +- **Reliability:** queue with exponential-backoff retry; SHA-256 to skip unchanged files + (idempotent); resumable upload for large recordings; status per job surfaced via events. +- **Remote layout:** mirrors local meeting folders under a user-chosen base path. +- **Credentials:** stored in the **OS credential store** (Windows Credential Manager / DPAPI), + referenced by target id — **never** in `settings.json` or the DB. +- **Transport security:** TLS required; plaintext `http://` is refused unless the user explicitly + allows it for a LAN address, with a warning. +- **Privacy labeling:** primary/self-hosted targets are labeled "your server"; third-party clouds + (OneDrive/Dropbox/Box) carry a clear "data leaves your device to a third party" banner, mirroring + the remote-LLM caveat (ADR-0007). Enabling any sync requires explicit acknowledgment. +- **Optional client-side encryption** before upload (ties to FR-SEC-3): when the WA vault is + enabled, artifacts can be encrypted locally so the destination only ever holds ciphertext — + provider-agnostic, independent of any server-side E2EE. + +## Consequences + +- **Positive:** four primary targets + Synology delivered by **one** WebDAV implementation — small, + testable surface; secondary set is additive and isolated behind the same trait; default-off + + explicit config keeps the privacy promise intact; self-hostable primaries fit the ethos. +- **Negative / care:** secondary targets each need an OAuth app registration (client IDs) and + per-provider quirks (chunk sizes, path APIs) — hence "secondary"; sync introduces real network + egress, so the privacy egress test (FR-SEC-1) must be widened to *allow only* configured target + hosts and *fail* on any other; credential handling and TLS enforcement are security-critical. +- **Phasing:** WebDAV primary set first, then OAuth secondary set (see Phase 9 in `05-roadmap.md`). + +## Revisit if +A primary target drops WebDAV, or we need provider-native features (e.g. Nextcloud Talk, Seafile +library sharing) beyond plain file upload — would add a provider-specific path behind the trait. diff --git a/docs/adr/0011-external-ai-and-agent-integration.md b/docs/adr/0011-external-ai-and-agent-integration.md new file mode 100644 index 0000000..9d98b68 --- /dev/null +++ b/docs/adr/0011-external-ai-and-agent-integration.md @@ -0,0 +1,88 @@ +# ADR-0011 — External AI providers & coding-agent integration + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Context source:** User request (2026-06-30); research in `07-research-findings.md` (MCP section) + +## Context + +WhispAssist already summarizes meetings with a **local** LLM (Ollama, ADR-0007). The user wants to +also tie into external AI tools — **Claude, OpenAI Codex, GitHub Copilot, OpenCode, and eventually +others** — for two purposes: + +1. **Summaries / follow-ups** (what the local LLM does today), optionally using a hosted model. +2. **Turn a meeting into actionable dev work** — e.g. a customer asks for a feature on a call and a + developer can "get started right away" (scaffold, branch, PR) from that request. + +These are **two different jobs** needing **two different mechanisms**. Codex, Copilot, and OpenCode +are coding *agents*, not summarization endpoints — calling them for a summary is the wrong shape. + +Research finding that drives the decision: **all four targets are MCP clients today** (Claude +Code/Desktop, Codex CLI, Copilot across IDE/CLI/cloud, OpenCode), and an official **Rust** MCP SDK +(`rmcp`) exists. So a single MCP **server** in WA's Rust core is consumable by all of them — and by +future tools — without per-tool adapters. + +## Decision — a layered model, MCP-server-first for handoff + +### Layer 1 — Cloud summary providers (extends ADR-0007) +Add hosted providers behind the existing `llm::LlmProvider` trait: **Anthropic Messages API** and an +**OpenAI-compatible** client (covers OpenAI, OpenRouter, LM Studio, gateways). This is "use Claude/ +GPT to write summaries." Off by default; third-party "data leaves WA" banner; same provider model as +Ollama. (Note: Anthropic's native endpoint is `/v1/messages`, not OpenAI's `/v1/chat/completions`, +so it needs its own adapter.) + +### Layer 2 — WhispAssist *is* an MCP server (the primary handoff; PULL model) +WA hosts a **local MCP server**, off by default, exposing meeting context and a small set of +**tools** (tools-first, because Copilot's cloud agent supports MCP tools but not resources/prompts): +- `list_recent_meetings`, `get_transcript`, `get_action_items`, +- `get_feature_brief(meeting_id)` / `create_feature_brief(...)` — the bridge primitive below. + +The developer drives from **their own** agent ("Claude, grab the feature request from this morning's +call and scaffold it"); the agent connects to WA as an MCP client and pulls structured context. WA +stays **agent-agnostic** — implement once, works with Claude/Codex/Copilot/OpenCode and "eventually +others." + +- **Transports:** local **stdio** (agent spawns a thin WA MCP adapter) and **Streamable HTTP** on + loopback (WA, already long-running and holding the DB, hosts `http://127.0.0.1:/mcp`). + HTTP+SSE is deprecated; do not use it. +- **Auth/scope:** loopback-bound; token required; explicit per-server enable; **scope control** over + which meetings/artifacts are exposed; recordings excluded unless explicitly allowed. +- **Audit:** log what an agent read. + +### The bridge primitive — "feature brief" +WA distills a feature request from a transcript (via the configured LLM) into a structured, +agent-ready spec: problem, desired outcome, acceptance criteria, target repo/context, source meeting. +It is the object that turns "we talked about X" into "here's a branch." Exposed as the +`get_feature_brief` MCP tool and reused by the push/issue paths (Layer 3). + +### Layer 3 — Push & task-tracker handoff (documented now, built later) +- **`AgentRunner`** trait: optionally spawn a local coding-agent CLI headless (`claude -p`, + `codex exec`, `opencode run`, `copilot`) against a chosen repo to produce a branch/PR — a one-click + "scaffold this" button in WA. +- **Task-tracker handoff:** create a **GitHub issue** from a confirmed action item; optionally assign + **Copilot's cloud agent**, which opens a PR. This is the right path for Copilot *cloud* (it runs in + GitHub's cloud and cannot reach a localhost MCP server). + +## Privacy reconciliation (critical) + +- **The MCP server adds no WA egress.** It *listens* on loopback; WA opens no new outbound socket, so + the egress allowlist and the CI network test are unaffected. The data only leaves the device when + the *connected agent* sends it to its provider's cloud — outside WA's control. WA must **disclose** + this ("WhispAssist serves this locally; the agent you connected may send it to its provider"). +- **Cloud summary providers (Layer 1) and task-tracker handoff (Layer 3) are real third-party + egress** — off by default, explicit opt-in, clearly labeled, and added to the settings-derived + allowlist exactly like remote sync (ADR-0010) and remote LLM (ADR-0007). +- Everything here is **off by default**. With nothing configured, WA remains fully local. + +## Consequences + +- **Positive:** one MCP server covers all four agents + future ones (the "eventually others" is free); + Rust `rmcp` keeps it lightweight; the local-only identity holds because the server is loopback and + egress stays opt-in; the feature-brief primitive is a genuinely WA-specific value-add. +- **Negative / care:** tools-first design required for Copilot compatibility; MCP server is an inbound + surface, so loopback-binding + token + scope + audit are mandatory; Anthropic needs a bespoke + adapter; the Layer-3 push/issue paths each need per-agent/per-tracker work, hence deferred. + +## Revisit if +A meeting-to-code standard emerges beyond MCP, or Copilot cloud gains localhost/OAuth-remote MCP +(would let the pull model serve it directly without the issue handoff). diff --git a/index.html b/index.html new file mode 100644 index 0000000..4d11e7d --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + WhispAssist + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..316f46d --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "whispassist", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Privacy-first, fully local Windows meeting assistant.", + "license": "MIT OR Apache-2.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json", + "tauri": "tauri", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@tauri-apps/cli": "^2.0.0", + "@tsconfig/svelte": "^5.0.0", + "eslint": "^9.0.0", + "prettier": "^3.3.0", + "prettier-plugin-svelte": "^3.2.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^5.4.0" + }, + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } +} diff --git a/scripts/download-models.mjs b/scripts/download-models.mjs new file mode 100644 index 0000000..430148f --- /dev/null +++ b/scripts/download-models.mjs @@ -0,0 +1,12 @@ +// Dev helper (placeholder) — download default models into %LOCALAPPDATA%\WhispAssist\models. +// In the app, model download is a Phase 3 in-product flow (FR-MODEL-1) with progress events; +// this script is only for setting up a dev machine quickly. +// +// Intended models (pin URLs/checksums at implementation time): +// - Whisper: base (q5) GGUF for whisper.cpp +// - Diarization: pyannote segmentation (ONNX) + ERes2Net speaker embedding (ONNX) +// +// Usage (once implemented): node scripts/download-models.mjs --set default + +console.log("TODO: implement model download (Phase 3). See docs/05-roadmap.md T3.7."); +process.exit(0); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..d1a789e --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "whispassist" +version = "0.0.0" +description = "Privacy-first, fully local Windows meeting assistant" +authors = ["WhispAssist contributors"] +license = "MIT OR Apache-2.0" +edition = "2021" +rust-version = "1.77" + +# NOTE: versions are indicative. Pin + verify at implementation time +# (see docs/07-research-findings.md "Open items"). + +[lib] +name = "whispassist_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +async-trait = "0.1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs"] } +tracing = "0.1" +tracing-subscriber = "0.3" +uuid = { version = "1", features = ["v4"] } + +# storage +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] } + +# llm + sync (HTTP/WebDAV) +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +futures-util = "0.3" +sha2 = "0.10" # skip-if-unchanged hashing (sync) +keyring = { version = "3", optional = true } # OS credential store (sync + AI creds) +rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP server (ADR-0011) + +# audio / transcription / diarization / calendar are integrated per-phase and are +# feature-gated so the CPU-only build always compiles (NFR-MNT-4). +hound = { version = "3", optional = true } # WAV I/O (Phase 1) + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Media_Audio", # WASAPI (Phase 1) + "Win32_Graphics_Dxgi", # GPU enumeration (Phase 3) + "Win32_System_Com", +] } +wasapi = { version = "0.15", optional = true } # Phase 1 + +[features] +default = ["audio", "cpu-transcription"] +# Phase 1 +audio = ["dep:wasapi", "dep:hound"] +cpu-transcription = [] # enables whisper-rs CPU build (add dep when wired in Phase 1) +# Phase 3 acceleration (built on capable CI runners; never required) +cuda = [] # whisper.cpp CUDA +vulkan = [] # whisper.cpp Vulkan +directml = [] # ort + DirectML NPU path +# Phase 4 / 6 (added when integrated) +diarization = [] # sherpa-onnx +pst = [] # outlook-pst +# Phase 9 +sync = ["dep:keyring"] # remote upload (WebDAV + OAuth providers) +# Phase 10 +mcp = ["dep:rmcp", "dep:keyring"] # WhispAssist as an MCP server + hosted-AI creds + +[profile.release] +opt-level = "z" # optimize for size — keep the binary small (NFR-RES-1) +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..261851f --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/src-tauri/icons/README.md b/src-tauri/icons/README.md new file mode 100644 index 0000000..993561f --- /dev/null +++ b/src-tauri/icons/README.md @@ -0,0 +1,7 @@ +# Icons + +Placeholder. Add before first bundle: +- `icon.ico` — app/installer icon (multi-resolution). +- `tray.png` — system tray icon (referenced by `tauri.conf.json`). + +Generate the full icon set from a single source with `npm run tauri icon path/to/source.png`. diff --git a/src-tauri/migrations/0001_init.sql b/src-tauri/migrations/0001_init.sql new file mode 100644 index 0000000..b4aa90f --- /dev/null +++ b/src-tauri/migrations/0001_init.sql @@ -0,0 +1,89 @@ +-- WhispAssist initial schema (Phase 2). Mirrors docs/03-data-model.md. +-- Forward-only migration applied via sqlx::migrate!. +PRAGMA foreign_keys = ON; + +CREATE TABLE meetings ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT 'Untitled meeting', + started_at INTEGER NOT NULL, + ended_at INTEGER, + duration_secs INTEGER, + folder_path TEXT NOT NULL, + audio_path TEXT NOT NULL, + status TEXT NOT NULL, -- recording|transcribing|ready|recovering|error + language TEXT, + backend_used TEXT, + model_used TEXT, + calendar_event_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE participants ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + UNIQUE(name, email) +); + +CREATE TABLE speakers ( + id TEXT PRIMARY KEY, + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + label TEXT NOT NULL, -- 'S1','S2',… + display_name TEXT, + participant_id TEXT REFERENCES participants(id), + color TEXT, + UNIQUE(meeting_id, label) +); + +CREATE TABLE meeting_participants ( + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + participant_id TEXT NOT NULL REFERENCES participants(id), + role TEXT, -- organizer|required|optional + PRIMARY KEY (meeting_id, participant_id) +); + +CREATE TABLE calendar_events ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, -- pst|graph|ics + subject TEXT, + organizer TEXT, + starts_at INTEGER, + ends_at INTEGER, + description TEXT, + raw_uid TEXT +); + +CREATE TABLE tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE meeting_tags ( + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (meeting_id, tag_id) +); + +CREATE TABLE action_items ( + id TEXT PRIMARY KEY, + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + text TEXT NOT NULL, + owner TEXT, + due_at INTEGER, + confirmed INTEGER NOT NULL DEFAULT 0, + reminder_set INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +CREATE VIRTUAL TABLE meeting_fts USING fts5( + meeting_id UNINDEXED, + title, + transcript_text, + notes_text, + tokenize = 'porter unicode61' +); + +CREATE INDEX idx_meetings_started ON meetings(started_at DESC); +CREATE INDEX idx_speakers_meeting ON speakers(meeting_id); +CREATE INDEX idx_action_meeting ON action_items(meeting_id); diff --git a/src-tauri/migrations/0002_sync.sql b/src-tauri/migrations/0002_sync.sql new file mode 100644 index 0000000..c18d13f --- /dev/null +++ b/src-tauri/migrations/0002_sync.sql @@ -0,0 +1,50 @@ +-- WhispAssist sync + recording-retention schema (Phase 9 / ADR-0010, ADR-0009). +-- Forward-only migration. Mirrors docs/03-data-model.md. +PRAGMA foreign_keys = ON; + +-- Recording retention flag on meetings (ADR-0009). audio_path becomes nullable in practice: +-- a non-recorded meeting has no audio file. (SQLite keeps the existing column definition; +-- new rows set recorded=0 and may leave audio_path empty.) +ALTER TABLE meetings ADD COLUMN recorded INTEGER NOT NULL DEFAULT 0; + +-- Configured upload destinations. Secrets are NOT stored here — only a reference into the +-- OS credential store (FR-SYNC-6). +CREATE TABLE sync_targets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL, -- webdav|onedrive|dropbox|box + provider_hint TEXT, -- nextcloud|owncloud|cloudreve|seafile|synology|generic + base_url TEXT, + remote_base_path TEXT NOT NULL DEFAULT '/WhispAssist', + username TEXT, + credential_ref TEXT NOT NULL, -- key into OS credential store + enabled INTEGER NOT NULL DEFAULT 0, + upload_transcript INTEGER NOT NULL DEFAULT 1, + upload_notes INTEGER NOT NULL DEFAULT 1, + upload_summary INTEGER NOT NULL DEFAULT 1, + upload_recording INTEGER NOT NULL DEFAULT 0, + trigger_on_finalize INTEGER NOT NULL DEFAULT 1, + allow_plaintext_lan INTEGER NOT NULL DEFAULT 0, + encrypt_before_upload INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +CREATE TABLE sync_jobs ( + id TEXT PRIMARY KEY, + target_id TEXT NOT NULL REFERENCES sync_targets(id) ON DELETE CASCADE, + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + artifact TEXT NOT NULL, -- transcript|notes|summary|recording + local_path TEXT NOT NULL, + remote_path TEXT NOT NULL, + sha256 TEXT, + status TEXT NOT NULL, -- pending|uploading|done|failed|skipped + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + next_attempt_at INTEGER, + bytes_total INTEGER, + bytes_sent INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + UNIQUE(target_id, meeting_id, artifact) +); + +CREATE INDEX idx_syncjobs_status ON sync_jobs(status, next_attempt_at); diff --git a/src-tauri/migrations/0003_ai_mcp.sql b/src-tauri/migrations/0003_ai_mcp.sql new file mode 100644 index 0000000..6f6b0ef --- /dev/null +++ b/src-tauri/migrations/0003_ai_mcp.sql @@ -0,0 +1,27 @@ +-- WhispAssist external-AI + MCP schema (Phase 10 / ADR-0011). +-- Forward-only migration. Mirrors docs/03-data-model.md. +PRAGMA foreign_keys = ON; + +-- Feature briefs distilled from a meeting for coding-agent handoff (FR-MCP-4). +-- The JSON body lives in the meeting's briefs/ folder; this table indexes it for the MCP tools. +CREATE TABLE feature_briefs ( + id TEXT PRIMARY KEY, + meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, + title TEXT NOT NULL, + target_repo TEXT, + path TEXT NOT NULL, -- briefs/.json + exposed INTEGER NOT NULL DEFAULT 0, -- visible to the MCP server? (scope control, FR-MCP-3) + created_at INTEGER NOT NULL +); + +-- Audit of what an MCP client (agent) read (FR-MCP-5). +CREATE TABLE mcp_access_log ( + id TEXT PRIMARY KEY, + at INTEGER NOT NULL, + tool TEXT NOT NULL, -- e.g. get_feature_brief + meeting_id TEXT, -- subject, if any + client TEXT -- client-reported name, if provided +); + +CREATE INDEX idx_briefs_meeting ON feature_briefs(meeting_id); +CREATE INDEX idx_mcplog_at ON mcp_access_log(at DESC); diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs new file mode 100644 index 0000000..f2db676 --- /dev/null +++ b/src-tauri/src/agent/mod.rs @@ -0,0 +1,44 @@ +//! Agent push & task-tracker handoff (Phase 10c, ADR-0011 Layer 3 — LATER). +//! +//! Optional "push" alternative to the MCP pull model: WA actively kicks off work from a +//! feature brief — either by spawning a local coding-agent CLI headless, or by opening a +//! tracker issue (which e.g. Copilot's cloud agent can pick up). +//! +//! Both are third-party egress where they reach a cloud (agent provider / tracker API): +//! off by default, explicit, labeled, allowlisted (FR-AGENT-1/2). + +use crate::models::FeatureBrief; +use async_trait::async_trait; +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +pub enum AgentError { + #[error("agent CLI not found: {0}")] + NotFound(String), + #[error("run failed: {0}")] + Run(String), + #[error("tracker error: {0}")] + Tracker(String), +} + +/// Streamed CLI output lines → "agent://progress" events. +pub type LineSink = std::sync::mpsc::Sender; + +pub struct RunOutcome { + pub branch: Option, + pub pr_url: Option, +} + +/// Spawns a local coding-agent CLI headless against a repo (claude -p / codex exec / opencode run / copilot). +#[async_trait] +pub trait AgentRunner: Send + Sync { + async fn run(&self, brief: &FeatureBrief, repo: &Path, out: LineSink) -> Result; +} + +/// Creates a work item from a brief (e.g. a GitHub issue, optionally assigned to Copilot's cloud agent). +#[async_trait] +pub trait IssueTracker: Send + Sync { + async fn create_issue(&self, brief: &FeatureBrief, assign_copilot: bool) -> Result; +} + +// Concrete impls (CliAgentRunner, GitHubIssueTracker) are added in Phase 10c behind a feature flag. diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs new file mode 100644 index 0000000..54f1406 --- /dev/null +++ b/src-tauri/src/audio/mod.rs @@ -0,0 +1,61 @@ +//! Audio capture service — WASAPI loopback (Phase 1, FR-CAP-1/2). +//! +//! Design notes (see docs/02-architecture.md): +//! - WASAPI loopback CANNOT use event-callback mode, so capture POLLS on a +//! dedicated thread (research finding, docs/07). +//! - Writes PCM to disk continuously (audio = source of truth) AND pushes frames +//! into a bounded ring buffer consumed by the transcription worker. +//! - Does no inference itself. + +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +pub enum AudioError { + #[error("audio device unavailable: {0}")] + Device(String), + #[error("capture failed: {0}")] + Capture(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +/// Opaque handle to a running capture, returned by `start` and consumed by `stop`. +pub struct CaptureHandle; + +/// Where captured frames are delivered for live transcription. +pub type FrameSink = std::sync::mpsc::Sender>; + +pub struct CaptureSummary { + pub duration_ms: u64, + pub sample_rate: u32, + pub channels: u16, +} + +pub trait AudioCapture: Send + Sync { + fn start(&self, wav_path: &Path, sink: FrameSink) -> Result; + fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>; + fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>; + fn stop(&self, h: CaptureHandle) -> Result; +} + +/// Default Windows WASAPI implementation (Phase 1, feature `audio`). +#[cfg(feature = "audio")] +pub struct WasapiCapture; + +#[cfg(feature = "audio")] +impl AudioCapture for WasapiCapture { + fn start(&self, _wav_path: &Path, _sink: FrameSink) -> Result { + // T1.2: open default render device in loopback mode, spawn polling thread, + // write WAV via `hound`, push frames to `sink`. + todo!("Phase 1 — WASAPI loopback capture") + } + fn pause(&self, _h: &CaptureHandle) -> Result<(), AudioError> { + todo!("Phase 1 — pause capture") + } + fn resume(&self, _h: &CaptureHandle) -> Result<(), AudioError> { + todo!("Phase 1 — resume capture") + } + fn stop(&self, _h: CaptureHandle) -> Result { + todo!("Phase 1 — stop capture, finalize WAV") + } +} diff --git a/src-tauri/src/calendar/mod.rs b/src-tauri/src/calendar/mod.rs new file mode 100644 index 0000000..5da7709 --- /dev/null +++ b/src-tauri/src/calendar/mod.rs @@ -0,0 +1,40 @@ +//! Calendar & Outlook .pst integration (Phase 6, FR-CAL-*). `outlook-pst` +//! (read-only) for PST; future Graph/ICS behind the same trait (ADR-0008). +//! PST is read-only input — WA never writes to it. + +use crate::models::{CalendarEvent, Participant}; + +#[derive(Debug, thiserror::Error)] +pub enum CalError { + #[error("cannot open file: {0}")] + Open(String), + #[error("parse failed: {0}")] + Parse(String), + #[error("password required or incorrect")] + Password, +} + +pub struct CalImport { + pub path: String, + pub password: Option, +} + +pub trait CalendarSource: Send + Sync { + fn import(&self, input: CalImport) -> Result, CalError>; + fn attendees(&self, event_id: &str) -> Result, CalError>; +} + +/// .pst source. Falls back to libpff for tricky/encrypted files. +#[cfg(feature = "pst")] +pub struct PstSource; + +#[cfg(feature = "pst")] +impl CalendarSource for PstSource { + fn import(&self, _input: CalImport) -> Result, CalError> { + // T6.1/T6.2: read appointments + attendees; non-fatal on parse failure. + todo!("Phase 6 — import .pst") + } + fn attendees(&self, _event_id: &str) -> Result, CalError> { + todo!("Phase 6 — attendees for event") + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..fbbfd20 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,253 @@ +//! Tauri command handlers — the frontend's only entry into the core. +//! Contract: `docs/04-api-contracts.md`. Commands return promptly; long work +//! is spawned and reported via events ("recording://*", "transcript://*", …). +//! +//! These are typed stubs. Each `todo!()` maps to a roadmap task and must be +//! replaced with a real implementation that depends on the service traits. + +use crate::error::WaResult; +use crate::models::*; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct StartRecordingArgs { + pub meeting_title: Option, + pub calendar_event_id: Option, + /// Retain audio as .wav? Defaults to false (ADR-0009). Controls retention, not capture. + #[serde(default)] + pub record: bool, +} + +// ---- Recording lifecycle (Phase 1) ---- + +#[tauri::command] +pub async fn start_recording(_args: StartRecordingArgs) -> WaResult { + // T1.2/T1.3: create meeting row, start WASAPI capture, spawn transcription worker. + todo!("Phase 1 — start_recording") +} + +#[tauri::command] +pub async fn stop_recording(_meeting_id: MeetingId) -> WaResult<()> { + // T1.3 + T4: finalize WAV, run diarization, persist, optional summary. + todo!("Phase 1 — stop_recording") +} + +#[tauri::command] +pub async fn pause_recording(_meeting_id: MeetingId) -> WaResult<()> { + todo!("Phase 1 — pause_recording") +} + +#[tauri::command] +pub async fn resume_recording(_meeting_id: MeetingId) -> WaResult<()> { + todo!("Phase 1 — resume_recording") +} + +/// Toggle audio retention mid-meeting (ADR-0009, FR-REC-1). +#[tauri::command] +pub async fn set_recording_retention(_meeting_id: MeetingId, _record: bool) -> WaResult<()> { + todo!("Phase 1 — set_recording_retention") +} + +/// Record the one-time recording-consent acknowledgment (FR-REC-2). +#[tauri::command] +pub async fn acknowledge_recording_consent() -> WaResult<()> { + todo!("Phase 1 — acknowledge_recording_consent") +} + +// ---- Hardware (Phase 3) ---- + +#[tauri::command] +pub async fn hardware_status() -> WaResult> { + todo!("Phase 3 — hardware_status") +} + +// ---- Meetings / storage (Phase 2) ---- + +#[tauri::command] +pub async fn list_meetings(_query: Option) -> WaResult> { + todo!("Phase 2 — list_meetings") +} + +#[tauri::command] +pub async fn get_meeting(_meeting_id: MeetingId) -> WaResult { + todo!("Phase 2 — get_meeting (returns full Meeting incl. transcript)") +} + +#[tauri::command] +pub async fn delete_meeting(_meeting_id: MeetingId) -> WaResult<()> { + todo!("Phase 2 — delete_meeting") +} + +#[tauri::command] +pub async fn update_notes(_meeting_id: MeetingId, _markdown: String) -> WaResult<()> { + todo!("Phase 2 — update_notes") +} + +#[tauri::command] +pub async fn export_meeting( + _meeting_id: MeetingId, + _dest: String, + _format: String, +) -> WaResult { + todo!("Phase 2/8 — export_meeting (md|pdf|docx|bundle)") +} + +// ---- LLM (Phase 5) ---- + +#[tauri::command] +pub async fn llm_status() -> WaResult { + todo!("Phase 5 — llm_status") +} + +/// Select/configure the LLM/AI provider. `apiKey` (hosted providers) goes to the OS credential +/// store, never settings/DB (Phase 10a, FR-AI-1/2). +#[tauri::command] +pub async fn set_llm_provider(_config: serde_json::Value) -> WaResult<()> { + todo!("Phase 10a — set_llm_provider (ollama|custom|anthropic|openai|off)") +} + +#[tauri::command] +pub async fn generate_summary(_meeting_id: MeetingId, _template_id: Option) -> WaResult<()> { + // Streams via "llm://token" / "llm://done". + todo!("Phase 5 — generate_summary") +} + +// ---- Calendar / .pst (Phase 6) ---- + +#[tauri::command] +pub async fn import_pst(_path: String, _password: Option) -> WaResult { + todo!("Phase 6 — import_pst") +} + +// ---- Sync / upload (Phase 9, ADR-0010) ---- + +#[tauri::command] +pub async fn list_sync_targets() -> WaResult> { + // Never returns secrets (FR-SYNC-6). + todo!("Phase 9 — list_sync_targets") +} + +#[tauri::command] +pub async fn add_sync_target(_config: serde_json::Value) -> WaResult { + // `config` includes a `secret` stored to the OS credential store, not the DB. + todo!("Phase 9 — add_sync_target") +} + +#[tauri::command] +pub async fn update_sync_target(_config: serde_json::Value) -> WaResult { + // `config` carries an `id` and optional fields; an optional `secret` updates the credential store. + todo!("Phase 9 — update_sync_target") +} + +#[tauri::command] +pub async fn remove_sync_target(_id: String) -> WaResult<()> { + todo!("Phase 9 — remove_sync_target") +} + +/// Begin OAuth 2.0 PKCE linking for a secondary target (loopback redirect). FR-SYNC-9. +#[tauri::command] +pub async fn begin_oauth_link(_kind: String) -> WaResult { + todo!("Phase 9b — begin_oauth_link (onedrive|dropbox|box)") +} + +#[tauri::command] +pub async fn retry_sync_job(_job_id: String) -> WaResult<()> { + todo!("Phase 9 — retry_sync_job") +} + +#[tauri::command] +pub async fn test_sync_target(_config_or_id: serde_json::Value) -> WaResult { + todo!("Phase 9 — test_sync_target (reachability + auth)") +} + +#[tauri::command] +pub async fn set_sync_enabled(_enabled: bool) -> WaResult<()> { + todo!("Phase 9 — set_sync_enabled (master switch)") +} + +#[tauri::command] +pub async fn sync_meeting(_meeting_id: MeetingId, _target_id: Option) -> WaResult<()> { + // Manual "Upload now"; streams progress via "sync://job" events. + todo!("Phase 9 — sync_meeting") +} + +#[tauri::command] +pub async fn sync_status(_meeting_id: Option) -> WaResult> { + todo!("Phase 9 — sync_status") +} + +// ---- Feature briefs + MCP server (Phase 10b, ADR-0011) ---- + +#[tauri::command] +pub async fn create_feature_brief(_meeting_id: MeetingId, _target_repo: Option) -> WaResult { + // T10.6: distill transcript → structured brief via the configured LlmProvider. + todo!("Phase 10b — create_feature_brief") +} + +#[tauri::command] +pub async fn list_feature_briefs(_meeting_id: Option) -> WaResult> { + todo!("Phase 10b — list_feature_briefs") +} + +#[tauri::command] +pub async fn get_feature_brief(_id: String) -> WaResult { + todo!("Phase 10b — get_feature_brief") +} + +/// Scope control: include/exclude a brief from the MCP server (FR-MCP-3). +#[tauri::command] +pub async fn set_brief_exposed(_id: String, _exposed: bool) -> WaResult<()> { + todo!("Phase 10b — set_brief_exposed") +} + +#[tauri::command] +pub async fn mcp_status() -> WaResult { + todo!("Phase 10b — mcp_status") +} + +/// Enable/disable the loopback MCP server; returns endpoint + token on enable (FR-MCP-1/6). +#[tauri::command] +pub async fn set_mcp_enabled(_enabled: bool, _transport: Option, _port: Option) -> WaResult { + todo!("Phase 10b — set_mcp_enabled (loopback only, token)") +} + +/// Set exposure scope (none|selected|all) and whether recordings may be served (FR-MCP-3). +#[tauri::command] +pub async fn set_mcp_scope(_expose: String, _expose_recordings: Option) -> WaResult<()> { + todo!("Phase 10b — set_mcp_scope") +} + +#[tauri::command] +pub async fn mcp_access_log(_limit: Option) -> WaResult> { + todo!("Phase 10b — mcp_access_log (audit)") +} + +// ---- Agent push / task-tracker handoff (Phase 10c, later) ---- + +#[tauri::command] +pub async fn run_agent(_brief_id: String, _tool: String, _repo_path: String) -> WaResult { + todo!("Phase 10c — run_agent (claude|codex|opencode|copilot)") +} + +#[tauri::command] +pub async fn create_issue_from_brief(_brief_id: String, _tracker: String, _assign_copilot: Option) -> WaResult { + todo!("Phase 10c — create_issue_from_brief") +} + +// ---- Settings + privacy (Phase 2/7) ---- + +#[tauri::command] +pub async fn get_settings() -> WaResult { + todo!("Phase 2 — get_settings") +} + +#[tauri::command] +pub async fn update_settings(_patch: serde_json::Value) -> WaResult { + todo!("Phase 2 — update_settings") +} + +/// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2). +#[tauri::command] +pub async fn privacy_self_check() -> WaResult { + todo!("Phase 7 — privacy_self_check") +} diff --git a/src-tauri/src/diarization/mod.rs b/src-tauri/src/diarization/mod.rs new file mode 100644 index 0000000..2d92564 --- /dev/null +++ b/src-tauri/src/diarization/mod.rs @@ -0,0 +1,40 @@ +//! Speaker diarization (Phase 4, FR-SPK-*). sherpa-onnx: segmentation + +//! embedding + clustering, fully offline (ADR-0005). +//! +//! Runs as a post-stop pass over the recorded WAV (audio = source of truth), +//! then aligns speaker spans to transcript segments by timestamp overlap. +//! Speaker IDs are internal ("S1"…); names are applied at render time and never +//! destructively rewritten onto segments (FR-SPK-5). + +use crate::models::{SpeakerSpan, TranscriptSegment}; +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +pub enum DiarError { + #[error("model load failed: {0}")] + Load(String), + #[error("diarization failed: {0}")] + Run(String), +} + +pub trait Diarizer: Send + Sync { + /// Partition audio into speaker spans. + fn diarize(&self, wav: &Path) -> Result, DiarError>; + /// Assign speaker labels to transcript segments by overlap with spans. + fn assign(&self, segments: &mut [TranscriptSegment], spans: &[SpeakerSpan]); +} + +#[cfg(feature = "diarization")] +pub struct SherpaDiarizer; + +#[cfg(feature = "diarization")] +impl Diarizer for SherpaDiarizer { + fn diarize(&self, _wav: &Path) -> Result, DiarError> { + // T4.1: sherpa-onnx segmentation + embedding + clustering via FFI. + todo!("Phase 4 — diarize") + } + fn assign(&self, _segments: &mut [TranscriptSegment], _spans: &[SpeakerSpan]) { + // T4.2: timestamp-overlap alignment. + todo!("Phase 4 — assign speakers to segments") + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..fc19123 --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,26 @@ +//! Top-level error surfaced to the frontend. Each service has its own +//! `thiserror` enum (see the module files) that converts into this. + +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct WaError { + /// Machine-readable category, e.g. "audio", "transcription", "llm", "storage". + pub kind: String, + /// Human-readable message for display. + pub message: String, +} + +impl WaError { + pub fn new(kind: impl Into, message: impl Into) -> Self { + Self { kind: kind.into(), message: message.into() } + } +} + +impl From for WaError { + fn from(e: E) -> Self { + WaError::new("internal", e.to_string()) + } +} + +pub type WaResult = Result; diff --git a/src-tauri/src/hardware/mod.rs b/src-tauri/src/hardware/mod.rs new file mode 100644 index 0000000..91a0510 --- /dev/null +++ b/src-tauri/src/hardware/mod.rs @@ -0,0 +1,34 @@ +//! Hardware detection & backend ranking (Phase 3, FR-HW-1/2/4). +//! +//! Returns backends ranked NPU→NVIDIA→AMD→Intel→CPU (ADR-0004). The chosen +//! backend determines which `Transcriber` is constructed. Windows ML / DirectML +//! is abstracted here so the provider can be swapped without touching callers. + +use crate::models::{BackendId, BackendInfo}; + +#[derive(Debug, thiserror::Error)] +pub enum HardwareError { + #[error("enumeration failed: {0}")] + Enumerate(String), +} + +pub trait HardwareDetector: Send + Sync { + /// Enumerate and rank available backends (best first). + fn detect(&self) -> Vec; + /// Pick the best available backend, honoring an optional user preference. + fn best(&self, preferred: Option) -> BackendInfo; +} + +/// Default Windows detector (DXGI for GPUs, ONNX/Windows ML for NPU). +pub struct WinHardwareDetector; + +impl HardwareDetector for WinHardwareDetector { + fn detect(&self) -> Vec { + // T3.1: enumerate DXGI adapters (NVIDIA/AMD/Intel), probe NPU via ONNX/Windows ML, + // always include CPU. Return ranked list. + todo!("Phase 3 — detect backends") + } + fn best(&self, _preferred: Option) -> BackendInfo { + todo!("Phase 3 — choose best backend / honor override") + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..7759623 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,93 @@ +//! WhispAssist core library. +//! +//! Architecture: a thin Tauri shell over independent service modules, each +//! exposing a trait (see `docs/04-api-contracts.md`). Callers depend on the +//! trait, not the concrete engine, so engines are swappable (NFR-MNT-1/2). + +pub mod agent; +pub mod audio; +pub mod calendar; +pub mod commands; +pub mod diarization; +pub mod error; +pub mod hardware; +pub mod llm; +pub mod mcp; +pub mod models; +pub mod notes; +pub mod storage; +pub mod sync; +pub mod transcription; + +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Shared application state handed to every Tauri command via `tauri::State`. +/// Service handles are `Arc`-wrapped so they can be cloned into worker tasks. +pub struct AppState { + pub hardware: Arc, + pub store: Arc, + pub llm: Arc, + pub sync: Arc, + pub mcp: Arc, + /// The single in-flight recording, if any. Guarded so start/stop/pause are atomic. + pub session: Mutex>, +} + +/// Tracks the currently-recording meeting and its worker handles. +pub struct RecordingSession { + pub meeting_id: models::MeetingId, + // capture handle, transcription worker join handle, etc. — populated in Phase 1. +} + +/// Build state, register commands/events, and run the app. +pub fn run() { + tracing_subscriber::fmt().with_env_filter("info").init(); + + // NOTE: concrete service implementations are constructed here once they exist. + // The skeleton wires the command surface; `todo!()`s mark per-phase tasks. + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + commands::start_recording, + commands::stop_recording, + commands::pause_recording, + commands::resume_recording, + commands::set_recording_retention, + commands::acknowledge_recording_consent, + commands::hardware_status, + commands::list_meetings, + commands::get_meeting, + commands::delete_meeting, + commands::update_notes, + commands::export_meeting, + commands::llm_status, + commands::set_llm_provider, + commands::generate_summary, + commands::import_pst, + commands::list_sync_targets, + commands::add_sync_target, + commands::update_sync_target, + commands::remove_sync_target, + commands::test_sync_target, + commands::set_sync_enabled, + commands::begin_oauth_link, + commands::sync_meeting, + commands::sync_status, + commands::retry_sync_job, + commands::create_feature_brief, + commands::list_feature_briefs, + commands::get_feature_brief, + commands::set_brief_exposed, + commands::mcp_status, + commands::set_mcp_enabled, + commands::set_mcp_scope, + commands::mcp_access_log, + commands::run_agent, + commands::create_issue_from_brief, + commands::get_settings, + commands::update_settings, + commands::privacy_self_check, + ]) + .run(tauri::generate_context!()) + .expect("error while running WhispAssist"); +} diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs new file mode 100644 index 0000000..9f4c2ea --- /dev/null +++ b/src-tauri/src/llm/mod.rs @@ -0,0 +1,106 @@ +//! Local LLM integration (Phase 5, FR-LLM-*). Ollama HTTP on localhost +//! (ADR-0007). The ONLY network egress WA originates for content, and it must be +//! local — `is_local` gates a "data leaves WA" warning for remote endpoints. + +use async_trait::async_trait; + +#[derive(Debug, thiserror::Error)] +pub enum LlmError { + #[error("provider unreachable: {0}")] + Unreachable(String), + #[error("request failed: {0}")] + Request(String), +} + +pub struct Prompt { + pub transcript: String, + pub metadata: String, + pub template: Option, +} + +pub struct Summary { + pub summary_md: String, + pub decisions: Vec, + pub action_items: Vec, +} + +pub struct LlmStatus { + pub provider: String, // ollama|custom|off + pub reachable: bool, + pub is_local: bool, + pub models: Vec, +} + +/// Streamed tokens delivered to the UI via "llm://token". +pub type TokenSink = std::sync::mpsc::Sender; + +#[async_trait] +pub trait LlmProvider: Send + Sync { + async fn status(&self) -> LlmStatus; + async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result; + /// True if the endpoint resolves to loopback/local (FR-LLM-6, FR-SEC-1). + fn is_local(&self) -> bool; +} + +/// Ollama provider (default). Talks to /api/tags, /api/chat (streaming), /api/pull. +pub struct OllamaProvider { + pub endpoint: String, // e.g. http://localhost:11434 + pub model: String, +} + +#[async_trait] +impl LlmProvider for OllamaProvider { + async fn status(&self) -> LlmStatus { + todo!("Phase 5 — GET /api/tags, reachability") + } + async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result { + todo!("Phase 5 — POST /api/chat streaming + parse action items") + } + fn is_local(&self) -> bool { + // T5.3: parse host, assert loopback (127.0.0.1/::1/localhost). + todo!("Phase 5 — local-endpoint guard") + } +} + +// ---- Hosted providers (Phase 10a, ADR-0011) ---- +// Same `LlmProvider` trait; `is_local()` returns false so the UI warns. API keys are read from +// the OS credential store, never settings/DB. Hosts join the egress allowlist when configured. + +/// OpenAI-compatible (`/v1/chat/completions`): OpenAI, OpenRouter, LM Studio, gateways. +pub struct OpenAiCompatProvider { + pub endpoint: String, + pub model: String, + pub credential_ref: String, +} + +/// Anthropic Messages API (`/v1/messages`) — native shape, NOT OpenAI-compatible. +pub struct AnthropicProvider { + pub model: String, + pub credential_ref: String, +} + +#[async_trait] +impl LlmProvider for OpenAiCompatProvider { + async fn status(&self) -> LlmStatus { + todo!("Phase 10a — OpenAI-compatible status") + } + async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result { + todo!("Phase 10a — POST /v1/chat/completions streaming") + } + fn is_local(&self) -> bool { + false + } +} + +#[async_trait] +impl LlmProvider for AnthropicProvider { + async fn status(&self) -> LlmStatus { + todo!("Phase 10a — Anthropic status") + } + async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result { + todo!("Phase 10a — POST /v1/messages streaming") + } + fn is_local(&self) -> bool { + false + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..afe2e08 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,7 @@ +// WhispAssist entry point. Keep this thin: all wiring lives in lib.rs so the +// app can also be exercised from integration tests. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + whispassist_lib::run(); +} diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs new file mode 100644 index 0000000..5814c61 --- /dev/null +++ b/src-tauri/src/mcp/mod.rs @@ -0,0 +1,106 @@ +//! MCP server — WhispAssist as a tool source for coding agents (Phase 10b, ADR-0011). +//! +//! WA hosts a LOCAL Model Context Protocol server so the user's own agents (Claude Code, +//! Codex, Copilot, OpenCode, …) can pull meeting context and "feature briefs" and start +//! coding. This is the primary "get started right away" handoff (PULL model). +//! +//! Security invariants (enforced here; see CLAUDE.md / NFR-SEC-5): +//! - OFF by default; binds to LOOPBACK only; token required. +//! - Tools-first surface (Copilot cloud supports tools, not resources/prompts). +//! - Scope-limited: never serves recordings unless explicitly allowed. +//! - INBOUND only — opens no outbound socket, adds nothing to the egress allowlist (FR-MCP-7). +//! - Every agent read is logged (FR-MCP-5). + +use crate::models::{FeatureBrief, MeetingId}; +use async_trait::async_trait; + +#[derive(Debug, thiserror::Error)] +pub enum McpError { + #[error("refusing to bind non-loopback address")] + NonLoopback, + #[error("server error: {0}")] + Server(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum BriefError { + #[error("llm error: {0}")] + Llm(String), + #[error("meeting not found")] + NotFound, +} + +#[derive(Debug, Clone)] +pub struct McpConfig { + pub transport: McpTransport, + pub port: u16, + pub expose: ExposeScope, + pub expose_recordings: bool, +} + +#[derive(Debug, Clone, Copy)] +pub enum McpTransport { + /// Streamable HTTP on http://127.0.0.1:/mcp (loopback only). + Http, + /// JSON-RPC over stdio (a thin adapter the agent spawns). + Stdio, +} + +#[derive(Debug, Clone, Copy)] +pub enum ExposeScope { + None, + Selected, + All, +} + +/// Returned on start: where to point the agent + the token it must present. +pub struct McpHandle { + pub endpoint: String, + pub token: String, +} + +pub struct McpToolDescriptor { + pub name: &'static str, + pub description: &'static str, +} + +/// The MCP server. Built on the official Rust SDK (`rmcp`, feature `mcp`). +#[async_trait] +pub trait McpServer: Send + Sync { + async fn start(&self, cfg: McpConfig) -> Result; + async fn stop(&self, handle: McpHandle) -> Result<(), McpError>; + /// Tools-first surface (FR-MCP-2). + fn tools(&self) -> Vec; +} + +/// Distills a transcript into an agent-ready spec (FR-MCP-4) using the configured LlmProvider. +#[async_trait] +pub trait FeatureBriefBuilder: Send + Sync { + async fn build(&self, meeting_id: &MeetingId, target_repo: Option<&str>) -> Result; +} + +/// Default rmcp-backed server (feature `mcp`). +#[cfg(feature = "mcp")] +pub struct RmcpServer; + +#[cfg(feature = "mcp")] +#[async_trait] +impl McpServer for RmcpServer { + async fn start(&self, _cfg: McpConfig) -> Result { + // T10.4: bind loopback ONLY (reject non-loopback), mint a token, register tools, + // serve over Streamable HTTP (/mcp) or stdio. Never opens an outbound socket. + todo!("Phase 10b — start MCP server (loopback, token)") + } + async fn stop(&self, _handle: McpHandle) -> Result<(), McpError> { + todo!("Phase 10b — stop MCP server") + } + fn tools(&self) -> Vec { + // T10.5: the tools an agent can call. Tools-first for Copilot compatibility. + vec![ + McpToolDescriptor { name: "list_recent_meetings", description: "Recent meetings (scoped)." }, + McpToolDescriptor { name: "get_transcript", description: "Transcript for a meeting (scoped)." }, + McpToolDescriptor { name: "get_action_items", description: "Action items for a meeting." }, + McpToolDescriptor { name: "get_feature_brief", description: "Agent-ready spec distilled from a meeting." }, + ] + } +} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs new file mode 100644 index 0000000..8fdbfb9 --- /dev/null +++ b/src-tauri/src/models.rs @@ -0,0 +1,194 @@ +//! Shared data types crossing the IPC boundary and between services. +//! Mirrors `docs/03-data-model.md` and `docs/04-api-contracts.md`. + +use serde::{Deserialize, Serialize}; + +pub type MeetingId = String; // uuid v4 + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum BackendId { + Npu, + Nvidia, + Amd, + Intel, + Cpu, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendInfo { + pub id: BackendId, + pub name: String, + pub available: bool, + /// Lower rank = higher priority (NPU=0 … CPU=4). + pub rank: u8, + pub vram_mb: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MeetingStatus { + Recording, + Transcribing, + Ready, + Recovering, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TranscriptSegment { + pub id: u64, + pub start_ms: u64, + pub end_ms: u64, + /// Internal speaker label ("S1"…); name resolved at render time (FR-SPK-5). + pub speaker: String, + pub text: String, + pub confidence: Option, + pub interim: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpeakerInfo { + pub label: String, // "S1" + pub display_name: Option, + pub participant_id: Option, +} + +/// A diarization result span before alignment to transcript segments. +#[derive(Debug, Clone)] +pub struct SpeakerSpan { + pub start_ms: u64, + pub end_ms: u64, + pub speaker: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeetingListItem { + pub id: MeetingId, + pub title: String, + pub started_at: i64, + pub duration_secs: Option, + pub status: MeetingStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionItem { + pub id: Option, + pub text: String, + pub owner: Option, + pub due_at: Option, + pub confirmed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CalendarEvent { + pub id: String, + pub source: String, // pst|graph|ics + pub subject: Option, + pub organizer: Option, + pub starts_at: Option, + pub ends_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Participant { + pub id: String, + pub name: String, + pub email: Option, + pub role: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub theme: String, // system|light|dark + pub storage_root: String, + pub llm_provider: String, // ollama|custom|off + pub llm_endpoint: String, + pub llm_model: String, + pub preferred_backend: String, // auto|npu|nvidia|amd|intel|cpu + pub low_overhead: bool, + // Recording retention (ADR-0009). Default OFF. + pub default_record: bool, + pub consent_acknowledged: bool, + // Sync master switch (ADR-0010). Default OFF. Target rows live in the DB; secrets in OS keychain. + pub sync_enabled: bool, +} + +// ---- Sync (ADR-0010) ---- + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SyncKind { + WebDav, + OneDrive, + Dropbox, + Box, +} + +/// What the UI sees about a target. NEVER contains the secret (FR-SYNC-6). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncTargetInfo { + pub id: String, + pub name: String, + pub kind: SyncKind, + pub provider_hint: Option, // nextcloud|owncloud|cloudreve|seafile|synology|generic + pub base_url: Option, + pub remote_base_path: String, + pub username: Option, + pub enabled: bool, + pub third_party: bool, + pub host: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncJobInfo { + pub id: String, + pub target_id: String, + pub meeting_id: MeetingId, + pub artifact: String, // transcript|notes|summary|recording + pub status: String, // pending|uploading|done|failed|skipped + pub attempts: u32, + pub bytes_sent: u64, + pub bytes_total: Option, + pub last_error: Option, +} + +// ---- Feature briefs + MCP (ADR-0011) ---- + +/// Agent-ready spec distilled from a meeting; served by the MCP `get_feature_brief` tool. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeatureBrief { + pub id: String, + pub meeting_id: MeetingId, + pub title: String, + pub problem: String, + pub desired_outcome: String, + pub acceptance_criteria: Vec, + pub target_repo: Option, + pub context_excerpts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextExcerpt { + pub speaker: String, + pub text: String, +} + +/// Lightweight listing row (no full body). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeatureBriefInfo { + pub id: String, + pub meeting_id: MeetingId, + pub title: String, + pub target_repo: Option, + pub exposed: bool, +} + +/// One row of the MCP audit log (FR-MCP-5). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpAccessEntry { + pub at: i64, + pub tool: String, + pub meeting_id: Option, + pub client: Option, +} diff --git a/src-tauri/src/notes/mod.rs b/src-tauri/src/notes/mod.rs new file mode 100644 index 0000000..3a27e79 --- /dev/null +++ b/src-tauri/src/notes/mod.rs @@ -0,0 +1,55 @@ +//! Notes assembly & export (Phase 2; PDF/Word in Phase 8). FR-NOTE-*. +//! +//! Renders speaker-tagged Markdown from transcript + speaker names (+ optional +//! summary). Names are resolved here from the mapping; segments keep internal IDs. + +use crate::models::{SpeakerInfo, TranscriptSegment}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, thiserror::Error)] +pub enum NotesError { + #[error("render failed: {0}")] + Render(String), + #[error("export failed: {0}")] + Export(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +#[derive(Debug, Clone, Copy)] +pub enum ExportFormat { + Md, + Pdf, + Docx, + Bundle, // audio + transcript + notes +} + +pub trait NotesRenderer: Send + Sync { + /// Build Markdown with speaker-tagged dialogue (+ summary if present). + fn to_markdown( + &self, + segments: &[TranscriptSegment], + speakers: &[SpeakerInfo], + summary_md: Option<&str>, + ) -> String; + + fn export(&self, markdown: &str, dest: &Path, fmt: ExportFormat) -> Result; +} + +pub struct MarkdownNotes; + +impl NotesRenderer for MarkdownNotes { + fn to_markdown( + &self, + _segments: &[TranscriptSegment], + _speakers: &[SpeakerInfo], + _summary_md: Option<&str>, + ) -> String { + // T2.4: resolve speaker names, group dialogue, prepend summary section. + todo!("Phase 2 — assemble Markdown") + } + fn export(&self, _markdown: &str, _dest: &Path, _fmt: ExportFormat) -> Result { + // T2.6 (.md/bundle); T8.4 (pdf/docx via local conversion). + todo!("Phase 2/8 — export notes") + } +} diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs new file mode 100644 index 0000000..1bec824 --- /dev/null +++ b/src-tauri/src/storage/mod.rs @@ -0,0 +1,74 @@ +//! Storage service (Phase 2, FR-STORE-*, FR-REL-*). SQLite index + on-disk +//! audio/transcript/notes files (ADR-0006). Async via sqlx. +//! +//! Invariant: audio is the source of truth. Derived artifacts (transcript/notes/ +//! summary) are regenerable; retention never touches an in-progress meeting. + +use crate::models::{MeetingId, MeetingListItem}; +use async_trait::async_trait; + +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("db error: {0}")] + Db(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("not found: {0}")] + NotFound(String), +} + +pub struct NewMeeting { + pub title: String, + pub calendar_event_id: Option, +} + +pub struct Retention { + pub max_age_days: Option, + pub max_size_gb: Option, +} + +#[async_trait] +pub trait Store: Send + Sync { + async fn create_meeting(&self, m: NewMeeting) -> Result; + async fn finalize_meeting(&self, id: &MeetingId) -> Result<(), StoreError>; + async fn list_meetings(&self, query: Option) -> Result, StoreError>; + async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>; + async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>; + /// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1). + async fn search(&self, query: &str) -> Result, StoreError>; + /// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1). + async fn recover_scan(&self) -> Result, StoreError>; + /// Enforce retention; returns count removed. Skips in-progress meetings. + async fn enforce_retention(&self, policy: Retention) -> Result; +} + +/// SQLite-backed store. Migrations live in `migrations/` (sqlx::migrate!). +pub struct SqliteStore; + +#[async_trait] +impl Store for SqliteStore { + async fn create_meeting(&self, _m: NewMeeting) -> Result { + todo!("Phase 2 — create meeting row + folder") + } + async fn finalize_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> { + todo!("Phase 2 — persist transcript.json + metadata") + } + async fn list_meetings(&self, _query: Option) -> Result, StoreError> { + todo!("Phase 2 — list meetings") + } + async fn delete_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> { + todo!("Phase 2 — delete meeting + folder") + } + async fn update_notes(&self, _id: &MeetingId, _markdown: &str) -> Result<(), StoreError> { + todo!("Phase 2 — write notes.md + FTS") + } + async fn search(&self, _query: &str) -> Result, StoreError> { + todo!("Phase 8 — FTS5 search") + } + async fn recover_scan(&self) -> Result, StoreError> { + todo!("Phase 2 — recovery scan") + } + async fn enforce_retention(&self, _policy: Retention) -> Result { + todo!("Phase 2 — retention enforcement") + } +} diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs new file mode 100644 index 0000000..5eb29bf --- /dev/null +++ b/src-tauri/src/sync/mod.rs @@ -0,0 +1,104 @@ +//! Remote sync / upload service (Phase 9, FR-SYNC-*). ADR-0010. +//! +//! Off by default. The ONLY content egress besides the local LLM. A single +//! `WebDavTarget` covers the primary set (Nextcloud, ownCloud, Cloudreve, +//! Seafile) AND Synology; OneDrive/Dropbox/Box are secondary OAuth impls. +//! +//! Security invariants (enforced here, see CLAUDE.md): +//! - Credentials come from the OS credential store, never settings/DB/logs/events. +//! - TLS required; plaintext http only via explicit per-target LAN opt-in. +//! - `SyncManager` derives the egress allowlist from enabled targets. + +use crate::models::{MeetingId, SyncJobInfo, SyncKind}; +use async_trait::async_trait; +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +pub enum SyncError { + #[error("target unreachable: {0}")] + Unreachable(String), + #[error("authentication failed")] + Auth, + #[error("insecure transport refused (enable LAN plaintext explicitly to allow)")] + InsecureTransport, + #[error("upload failed: {0}")] + Upload(String), + #[error("credential store error: {0}")] + Credential(String), +} + +/// Reports upload progress for a single file → "sync://job" events. +pub type ProgressSink = std::sync::mpsc::Sender<(u64 /*sent*/, u64 /*total*/)>; + +/// One provider. `WebDavTarget` serves all primary targets + Synology. +#[async_trait] +pub trait SyncTarget: Send + Sync { + fn kind(&self) -> SyncKind; + /// False for self-hosted WebDAV; true for OneDrive/Dropbox/Box (drives the UI banner). + fn is_third_party(&self) -> bool; + async fn test(&self) -> Result<(), SyncError>; + async fn ensure_dir(&self, remote_dir: &str) -> Result<(), SyncError>; + /// True if a file with this hash already exists remotely (skip-if-unchanged). + async fn exists(&self, remote_path: &str, sha256: &str) -> Result; + /// Upload a file; resumable/chunked for large artifacts. + async fn put(&self, local: &Path, remote_path: &str, prog: ProgressSink) -> Result<(), SyncError>; +} + +/// Owns the durable queue, retry/backoff, credential resolution, TLS enforcement, +/// and the settings-derived egress allowlist. +#[async_trait] +pub trait SyncManager: Send + Sync { + /// Enqueue a meeting's selected artifacts for one or all enabled targets. + async fn enqueue_meeting(&self, meeting_id: &MeetingId, target_id: Option<&str>) -> Result<(), SyncError>; + /// Drive pending jobs (called on finalize, on startup, and on a low-frequency timer). + async fn pump(&self) -> Result<(), SyncError>; + async fn status(&self, meeting_id: Option<&MeetingId>) -> Result, SyncError>; + async fn retry(&self, job_id: &str) -> Result<(), SyncError>; + /// Hosts WA is permitted to contact for sync (enabled targets only). Feeds privacy_self_check. + fn allowlisted_hosts(&self) -> Vec; +} + +/// WebDAV provider — primary targets + Synology (feature `sync`). +#[cfg(feature = "sync")] +pub struct WebDavTarget { + pub base_url: String, // https://host/remote.php/dav/files// , /seafdav , /dav , … + pub remote_base_path: String, + pub username: String, + pub credential_ref: String, // key into the OS credential store — resolved at use, not stored here + pub third_party: bool, // false for self-hosted + pub allow_plaintext_lan: bool, +} + +#[cfg(feature = "sync")] +#[async_trait] +impl SyncTarget for WebDavTarget { + fn kind(&self) -> SyncKind { + SyncKind::WebDav + } + fn is_third_party(&self) -> bool { + self.third_party + } + async fn test(&self) -> Result<(), SyncError> { + // T9.4: PROPFIND base path; verify TLS (or explicit LAN opt-in); resolve creds from keyring. + todo!("Phase 9 — WebDAV test connection") + } + async fn ensure_dir(&self, _remote_dir: &str) -> Result<(), SyncError> { + // T9.2: MKCOL the meeting folder path. + todo!("Phase 9 — WebDAV ensure_dir (MKCOL)") + } + async fn exists(&self, _remote_path: &str, _sha256: &str) -> Result { + todo!("Phase 9 — WebDAV exists / hash compare") + } + async fn put(&self, _local: &Path, _remote_path: &str, _prog: ProgressSink) -> Result<(), SyncError> { + // T9.2/T9.5: PUT (chunked for large files); stream progress. + todo!("Phase 9 — WebDAV upload") + } +} + +// Secondary OAuth providers (Phase 9b) — same trait, separate impls. +#[cfg(feature = "sync")] +pub struct OneDriveTarget; // MS Graph +#[cfg(feature = "sync")] +pub struct DropboxTarget; +#[cfg(feature = "sync")] +pub struct BoxTarget; diff --git a/src-tauri/src/transcription/mod.rs b/src-tauri/src/transcription/mod.rs new file mode 100644 index 0000000..3af5052 --- /dev/null +++ b/src-tauri/src/transcription/mod.rs @@ -0,0 +1,57 @@ +//! Transcription engine (Phase 1 CPU; Phase 3 acceleration). FR-TRX-*. +//! +//! Primary engine: whisper-rs (whisper.cpp) for CPU/Vulkan/CUDA. The NPU path is +//! a second `Transcriber` impl using ONNX Runtime (ort + DirectML) — same trait, +//! same `TranscriptSegment` output (ADR-0003/0004). Callers never branch on engine. + +use crate::models::{BackendId, TranscriptSegment}; +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +pub enum TrxError { + #[error("model load failed: {0}")] + Load(String), + #[error("inference failed: {0}")] + Inference(String), +} + +/// A chunk of audio (mono f32, 16 kHz) handed to the streaming transcriber. +pub struct AudioWindow { + pub samples: Vec, + pub offset_ms: u64, +} + +/// Where produced segments are delivered (interim then final). +pub type SegmentSink = std::sync::mpsc::Sender; + +pub trait Transcriber: Send + Sync { + fn load(model: &Path, backend: BackendId) -> Result + where + Self: Sized; + /// Streaming: emit interim + final segments for a window (FR-TRX-2). + fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError>; + /// Batch: one-shot, higher accuracy (FR-TRX-3). + fn transcribe_file(&self, wav: &Path) -> Result, TrxError>; +} + +/// whisper.cpp-backed transcriber (CPU baseline; GPU via Cargo features). +#[cfg(feature = "cpu-transcription")] +pub struct WhisperTranscriber; + +#[cfg(feature = "cpu-transcription")] +impl Transcriber for WhisperTranscriber { + fn load(_model: &Path, _backend: BackendId) -> Result { + // T1.5 / T3.3: init whisper-rs with the backend's acceleration features. + todo!("Phase 1 — load whisper model") + } + fn transcribe_stream(&self, _audio: AudioWindow, _out: SegmentSink) -> Result<(), TrxError> { + todo!("Phase 1 — streaming transcription") + } + fn transcribe_file(&self, _wav: &Path) -> Result, TrxError> { + todo!("Phase 3 — batch transcription") + } +} + +/// ONNX Runtime + DirectML transcriber for the NPU tier (Phase 3). +#[cfg(feature = "directml")] +pub struct OnnxNpuTranscriber; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..bf9dbd0 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "WhispAssist", + "version": "0.0.0", + "identifier": "bet.dou.whispassist", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:1420", + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build" + }, + "app": { + "windows": [ + { + "title": "WhispAssist", + "width": 1200, + "height": 800, + "minWidth": 880, + "minHeight": 600, + "resizable": true + } + ], + "security": { + "csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; style-src 'self' 'unsafe-inline'" + }, + "trayIcon": { + "iconPath": "icons/tray.png", + "tooltip": "WhispAssist" + } + }, + "bundle": { + "active": true, + "targets": ["msi", "nsis"], + "icon": ["icons/icon.ico"], + "windows": { + "webviewInstallMode": { "type": "downloadBootstrapper" } + } + } +} diff --git a/src/App.svelte b/src/App.svelte new file mode 100644 index 0000000..e3b0304 --- /dev/null +++ b/src/App.svelte @@ -0,0 +1,68 @@ + + +
+
+ WhispAssist + local · private +
+ {#if recording.state === "idle"} + + {:else} + + Recording… + {/if} + +
+ + {#if showSettings} + (showSettings = false)} /> + {/if} + +
+ +
+ +
+
+ + diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..4874b45 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,195 @@ +// Typed Tauri client — the frontend's ONLY way to reach the core. +// Mirrors docs/04-api-contracts.md. Keep these signatures in sync with +// src-tauri/src/commands.rs (a contract test enforces this — see docs/06). + +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +export type MeetingId = string; +export type BackendId = "npu" | "nvidia" | "amd" | "intel" | "cpu"; + +export interface MeetingListItem { + id: MeetingId; + title: string; + started_at: number; + duration_secs: number | null; + status: "recording" | "transcribing" | "ready" | "recovering" | "error"; +} + +export interface TranscriptSegment { + id: number; + start_ms: number; + end_ms: number; + speaker: string; + text: string; + confidence: number | null; + interim: boolean; +} + +export type SyncKind = "webdav" | "onedrive" | "dropbox" | "box"; + +// What the UI sees about a target — NEVER includes the secret (FR-SYNC-6). +export interface SyncTargetInfo { + id: string; + name: string; + kind: SyncKind; + provider_hint: string | null; // nextcloud|owncloud|cloudreve|seafile|synology|generic + base_url: string | null; + remote_base_path: string; + username: string | null; + enabled: boolean; + third_party: boolean; + host: string | null; +} + +// Payload for add/update. `secret` is write-only (stored in the OS credential store). +export interface SyncTargetConfig { + id?: string; + name: string; + kind: SyncKind; + provider_hint?: string; + base_url?: string; + remote_base_path?: string; + username?: string; + secret?: string; + enabled?: boolean; + upload_transcript?: boolean; + upload_notes?: boolean; + upload_summary?: boolean; + upload_recording?: boolean; + trigger_on_finalize?: boolean; + allow_plaintext_lan?: boolean; + encrypt_before_upload?: boolean; +} + +export interface AppSettings { + theme: string; + storage_root: string; + llm_provider: string; + llm_endpoint: string; + llm_model: string; + preferred_backend: string; + low_overhead: boolean; + default_record: boolean; + consent_acknowledged: boolean; + sync_enabled: boolean; + mcp_enabled: boolean; +} + +// Feature brief — agent-ready spec distilled from a meeting (ADR-0011). +export interface FeatureBrief { + id: string; + meeting_id: MeetingId; + title: string; + problem: string; + desired_outcome: string; + acceptance_criteria: string[]; + target_repo: string | null; + context_excerpts: { speaker: string; text: string }[]; +} + +export interface FeatureBriefInfo { + id: string; + meeting_id: MeetingId; + title: string; + target_repo: string | null; + exposed: boolean; +} + +export interface McpAccessEntry { + at: number; + tool: string; + meeting_id: MeetingId | null; + client: string | null; +} + +// ---- Commands ---- +export const api = { + // `record` controls audio RETENTION (default false / off — ADR-0009). + startRecording: (meetingTitle?: string, calendarEventId?: string, record = false) => + invoke("start_recording", { args: { meetingTitle, calendarEventId, record } }), + stopRecording: (meetingId: MeetingId) => invoke("stop_recording", { meetingId }), + pauseRecording: (meetingId: MeetingId) => invoke("pause_recording", { meetingId }), + resumeRecording: (meetingId: MeetingId) => invoke("resume_recording", { meetingId }), + setRecordingRetention: (meetingId: MeetingId, record: boolean) => + invoke("set_recording_retention", { meetingId, record }), + acknowledgeRecordingConsent: () => invoke("acknowledge_recording_consent"), + + hardwareStatus: () => invoke("hardware_status"), + listMeetings: (query?: string) => invoke("list_meetings", { query }), + getMeeting: (meetingId: MeetingId) => invoke("get_meeting", { meetingId }), + deleteMeeting: (meetingId: MeetingId) => invoke("delete_meeting", { meetingId }), + updateNotes: (meetingId: MeetingId, markdown: string) => + invoke("update_notes", { meetingId, markdown }), + exportMeeting: (meetingId: MeetingId, dest: string, format: string) => + invoke("export_meeting", { meetingId, dest, format }), + + llmStatus: () => invoke("llm_status"), + // provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011). + setLlmProvider: (config: { provider: string; endpoint?: string; model?: string; apiKey?: string }) => + invoke("set_llm_provider", { config }), + generateSummary: (meetingId: MeetingId, templateId?: string) => + invoke("generate_summary", { meetingId, templateId }), + + importPst: (path: string, password?: string) => invoke("import_pst", { path, password }), + + // Sync (ADR-0010) — off by default; secrets never returned by listSyncTargets. + listSyncTargets: () => invoke("list_sync_targets"), + addSyncTarget: (config: SyncTargetConfig) => invoke("add_sync_target", { config }), + updateSyncTarget: (config: SyncTargetConfig & { id: string }) => + invoke("update_sync_target", { config }), + removeSyncTarget: (id: string) => invoke("remove_sync_target", { id }), + testSyncTarget: (configOrId: SyncTargetConfig | { id: string }) => + invoke<{ ok: boolean; message: string }>("test_sync_target", { configOrId }), + setSyncEnabled: (enabled: boolean) => invoke("set_sync_enabled", { enabled }), + beginOauthLink: (kind: "onedrive" | "dropbox" | "box") => + invoke<{ ok: boolean; account?: string }>("begin_oauth_link", { kind }), + syncMeeting: (meetingId: MeetingId, targetId?: string) => + invoke("sync_meeting", { meetingId, targetId }), + syncStatus: (meetingId?: MeetingId) => invoke("sync_status", { meetingId }), + retrySyncJob: (jobId: string) => invoke("retry_sync_job", { jobId }), + + // Feature briefs + MCP server (ADR-0011). MCP server is loopback-only and off by default. + createFeatureBrief: (meetingId: MeetingId, targetRepo?: string) => + invoke("create_feature_brief", { meetingId, targetRepo }), + listFeatureBriefs: (meetingId?: MeetingId) => + invoke("list_feature_briefs", { meetingId }), + getFeatureBrief: (id: string) => invoke("get_feature_brief", { id }), + setBriefExposed: (id: string, exposed: boolean) => + invoke("set_brief_exposed", { id, exposed }), + mcpStatus: () => invoke("mcp_status"), + setMcpEnabled: (enabled: boolean, transport?: "http" | "stdio", port?: number) => + invoke<{ endpoint: string; token: string }>("set_mcp_enabled", { enabled, transport, port }), + setMcpScope: (expose: "none" | "selected" | "all", exposeRecordings?: boolean) => + invoke("set_mcp_scope", { expose, exposeRecordings }), + mcpAccessLog: (limit?: number) => invoke("mcp_access_log", { limit }), + // Layer 3 (later) push handoff. + runAgent: (briefId: string, tool: "claude" | "codex" | "opencode" | "copilot", repoPath: string) => + invoke<{ ok: boolean }>("run_agent", { briefId, tool, repoPath }), + createIssueFromBrief: (briefId: string, tracker: "github", assignCopilot?: boolean) => + invoke<{ url: string }>("create_issue_from_brief", { briefId, tracker, assignCopilot }), + + getSettings: () => invoke("get_settings"), + updateSettings: (patch: Partial) => invoke("update_settings", { patch }), + privacySelfCheck: () => invoke("privacy_self_check"), +}; + +// ---- Events (Rust → UI) ---- +export const events = { + onRecordingState: (cb: (p: unknown) => void): Promise => + listen("recording://state", (e) => cb(e.payload)), + onLevel: (cb: (p: unknown) => void): Promise => + listen("recording://level", (e) => cb(e.payload)), + onSegment: (cb: (p: { meetingId: string; segment: TranscriptSegment }) => void): Promise => + listen("transcript://segment", (e) => cb(e.payload as never)), + onLlmToken: (cb: (p: { meetingId: string; text: string }) => void): Promise => + listen("llm://token", (e) => cb(e.payload as never)), + onSyncJob: (cb: (p: { jobId: string; meetingId: string; targetId: string; artifact: string; status: string; bytesSent: number; bytesTotal: number }) => void): Promise => + listen("sync://job", (e) => cb(e.payload as never)), + onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise => + listen("mcp://access", (e) => cb(e.payload as never)), + onAgentProgress: (cb: (p: { briefId: string; tool: string; line: string }) => void): Promise => + listen("agent://progress", (e) => cb(e.payload as never)), + onError: (cb: (p: { kind: string; message: string }) => void): Promise => + listen("error", (e) => cb(e.payload as never)), +}; diff --git a/src/lib/stores/recording.svelte.ts b/src/lib/stores/recording.svelte.ts new file mode 100644 index 0000000..5a29b7d --- /dev/null +++ b/src/lib/stores/recording.svelte.ts @@ -0,0 +1,38 @@ +// Recording state store (Svelte 5 runes-friendly via a small class). +// Subscribes to recording/transcript events and exposes reactive state. + +import { api, events, type TranscriptSegment, type MeetingId } from "../api"; + +class RecordingStore { + meetingId = $state(null); + state = $state<"idle" | "recording" | "paused">("idle"); + elapsedMs = $state(0); + segments = $state([]); + + async init() { + await events.onRecordingState((p) => { + const e = p as { state: "recording" | "paused" | "stopped"; elapsedMs: number }; + this.state = e.state === "stopped" ? "idle" : e.state; + this.elapsedMs = e.elapsedMs ?? this.elapsedMs; + }); + await events.onSegment(({ segment }) => { + // Replace an interim segment with the same id, else append. + const i = this.segments.findIndex((s) => s.id === segment.id); + if (i >= 0) this.segments[i] = segment; + else this.segments.push(segment); + }); + } + + async start(title?: string) { + this.segments = []; + this.meetingId = await api.startRecording(title); + this.state = "recording"; + } + + async stop() { + if (this.meetingId) await api.stopRecording(this.meetingId); + this.state = "idle"; + } +} + +export const recording = new RecordingStore(); diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts new file mode 100644 index 0000000..2cf0ff4 --- /dev/null +++ b/src/lib/stores/settings.svelte.ts @@ -0,0 +1,134 @@ +// Settings + sync-target store (Svelte 5 runes). +// Wraps the typed api; tolerant of the current `todo!()` backend so the UI is +// demonstrable before Phase 9 lands. Once the commands are implemented, the +// optimistic fallbacks below simply stop being exercised. + +import { api, type AppSettings, type SyncTargetInfo, type SyncTargetConfig } from "../api"; + +const DEFAULT_SETTINGS: AppSettings = { + theme: "system", + storage_root: "%LOCALAPPDATA%\\WhispAssist", + llm_provider: "ollama", + llm_endpoint: "http://localhost:11434", + llm_model: "llama3", + preferred_backend: "auto", + low_overhead: false, + default_record: false, // recording OFF by default (ADR-0009) + consent_acknowledged: false, + sync_enabled: false, // sync OFF by default (ADR-0010) + mcp_enabled: false, // MCP server OFF by default (ADR-0011) +}; + +class SettingsStore { + settings = $state({ ...DEFAULT_SETTINGS }); + targets = $state([]); + loaded = $state(false); + /** Set when the backend isn't wired yet, so the UI can show a "stub" hint. */ + backendStub = $state(false); + + async load() { + try { + this.settings = await api.getSettings(); + this.targets = await api.listSyncTargets(); + } catch { + // Backend command is still a todo!(); fall back to local defaults. + this.backendStub = true; + this.settings = { ...DEFAULT_SETTINGS }; + } + this.loaded = true; + } + + async patch(patch: Partial) { + // Optimistic: reflect immediately, then persist (ignore stub errors). + this.settings = { ...this.settings, ...patch }; + try { + this.settings = await api.updateSettings(patch); + } catch { + this.backendStub = true; + } + } + + setDefaultRecord(on: boolean) { + return this.patch({ default_record: on }); + } + + async acknowledgeConsent() { + this.settings = { ...this.settings, consent_acknowledged: true }; + try { + await api.acknowledgeRecordingConsent(); + } catch { + this.backendStub = true; + } + } + + async setSyncEnabled(on: boolean) { + this.settings = { ...this.settings, sync_enabled: on }; + try { + await api.setSyncEnabled(on); + } catch { + this.backendStub = true; + } + } + + async addTarget(config: SyncTargetConfig) { + try { + const created = await api.addSyncTarget(config); + this.targets = [...this.targets, created]; + } catch { + // Stub: synthesize a local row so the list is demonstrable. + this.backendStub = true; + this.targets = [...this.targets, stubTarget(config)]; + } + } + + async removeTarget(id: string) { + this.targets = this.targets.filter((t) => t.id !== id); + try { + await api.removeSyncTarget(id); + } catch { + this.backendStub = true; + } + } + + async toggleTargetEnabled(t: SyncTargetInfo) { + const enabled = !t.enabled; + this.targets = this.targets.map((x) => (x.id === t.id ? { ...x, enabled } : x)); + try { + await api.updateSyncTarget({ id: t.id, name: t.name, kind: t.kind, enabled }); + } catch { + this.backendStub = true; + } + } + + async test(config: SyncTargetConfig): Promise<{ ok: boolean; message: string }> { + try { + return await api.testSyncTarget(config); + } catch { + this.backendStub = true; + return { ok: false, message: "Backend not implemented yet (Phase 9)." }; + } + } +} + +function stubTarget(c: SyncTargetConfig): SyncTargetInfo { + let host: string | null = null; + try { + host = c.base_url ? new URL(c.base_url).host : null; + } catch { + host = null; + } + return { + id: crypto.randomUUID(), + name: c.name, + kind: c.kind, + provider_hint: c.provider_hint ?? null, + base_url: c.base_url ?? null, + remote_base_path: c.remote_base_path ?? "/WhispAssist", + username: c.username ?? null, + enabled: c.enabled ?? false, + third_party: c.kind !== "webdav", + host, + }; +} + +export const settings = new SettingsStore(); diff --git a/src/lib/views/MeetingsList.svelte b/src/lib/views/MeetingsList.svelte new file mode 100644 index 0000000..e5da7e2 --- /dev/null +++ b/src/lib/views/MeetingsList.svelte @@ -0,0 +1,37 @@ + + +
+ + {#if meetings.length === 0} +

No meetings yet. Click ● Record to start.

+ {:else} +
    + {#each meetings as m (m.id)} +
  • {m.title}
  • + {/each} +
+ {/if} +
+ + diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte new file mode 100644 index 0000000..0f0be93 --- /dev/null +++ b/src/lib/views/Settings.svelte @@ -0,0 +1,245 @@ + + + + + diff --git a/src/lib/views/SummaryPanel.svelte b/src/lib/views/SummaryPanel.svelte new file mode 100644 index 0000000..aad5a00 --- /dev/null +++ b/src/lib/views/SummaryPanel.svelte @@ -0,0 +1,18 @@ + + +
+

Summary

+

Generated locally after the meeting (requires a local LLM provider).

+

Action items

+

Parsed from the summary; edit and confirm before they become tasks.

+

Participants

+

Populated from the linked calendar event.

+
+ + diff --git a/src/lib/views/TranscriptNotes.svelte b/src/lib/views/TranscriptNotes.svelte new file mode 100644 index 0000000..5487c52 --- /dev/null +++ b/src/lib/views/TranscriptNotes.svelte @@ -0,0 +1,27 @@ + + +
+ {#if recording.segments.length === 0} +

Transcript will appear here as you record.

+ {:else} + {#each recording.segments as s (s.id)} +

+ {speakerName(s.speaker)}: {s.text} +

+ {/each} + {/if} +
+ + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..b1f2754 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,6 @@ +import App from "./App.svelte"; +import { mount } from "svelte"; + +const app = mount(App, { target: document.getElementById("app")! }); + +export default app; diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..d0e6448 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..523c04b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,17 @@ +# Tests + +Cross-service integration and contract tests live here; unit tests live next to the code +(`#[cfg(test)]` in Rust, `*.test.ts` for the frontend). The full plan is in +[`../docs/06-test-strategy.md`](../docs/06-test-strategy.md). + +## Fixtures (`fixtures/`) +- Short WAVs: 1-speaker, 2-speaker, overlap, silence, device-glitch (add binaries here; ignored + by git via `*.wav`). +- `golden_transcript.json` — deterministic alignment/merge expectations. +- Synthetic `.pst` (or a generator) with known events/attendees — never real mail data. +- Recorded Ollama responses for offline LLM tests. + +## Cross-cutting gate +The **privacy egress test** (FR-SEC-1) runs a representative flow under a network monitor and +asserts no outbound connection except the configured local LLM endpoint and explicit model +downloads. This gate blocks merge on failure — it is the product's most important guarantee. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4c61a2a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9380b0a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +// Tauri expects a fixed port and serves the built assets from dist/. +export default defineConfig({ + plugins: [svelte()], + clearScreen: false, + server: { + port: 1420, + strictPort: true, + }, + build: { + target: "esnext", + outDir: "dist", + }, +});