Add all scaffolding, design, and decision documentation AKA first commit

This commit is contained in:
iamdoubz
2026-06-30 16:12:55 -05:00
parent b19e01c171
commit aae22a0e23
63 changed files with 4690 additions and 0 deletions
+44
View File
@@ -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<String>;
pub struct RunOutcome {
pub branch: Option<String>,
pub pr_url: Option<String>,
}
/// 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<RunOutcome, AgentError>;
}
/// 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<String /* url */, AgentError>;
}
// Concrete impls (CliAgentRunner, GitHubIssueTracker) are added in Phase 10c behind a feature flag.
+61
View File
@@ -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<Vec<f32>>;
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<CaptureHandle, AudioError>;
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>;
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>;
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError>;
}
/// 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<CaptureHandle, AudioError> {
// 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<CaptureSummary, AudioError> {
todo!("Phase 1 — stop capture, finalize WAV")
}
}
+40
View File
@@ -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<String>,
}
pub trait CalendarSource: Send + Sync {
fn import(&self, input: CalImport) -> Result<Vec<CalendarEvent>, CalError>;
fn attendees(&self, event_id: &str) -> Result<Vec<Participant>, 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<Vec<CalendarEvent>, 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<Vec<Participant>, CalError> {
todo!("Phase 6 — attendees for event")
}
}
+253
View File
@@ -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<String>,
pub calendar_event_id: Option<String>,
/// 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<MeetingId> {
// 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<Vec<BackendInfo>> {
todo!("Phase 3 — hardware_status")
}
// ---- Meetings / storage (Phase 2) ----
#[tauri::command]
pub async fn list_meetings(_query: Option<String>) -> WaResult<Vec<MeetingListItem>> {
todo!("Phase 2 — list_meetings")
}
#[tauri::command]
pub async fn get_meeting(_meeting_id: MeetingId) -> WaResult<MeetingListItem> {
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<String> {
todo!("Phase 2/8 — export_meeting (md|pdf|docx|bundle)")
}
// ---- LLM (Phase 5) ----
#[tauri::command]
pub async fn llm_status() -> WaResult<serde_json::Value> {
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<String>) -> 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<String>) -> WaResult<u32> {
todo!("Phase 6 — import_pst")
}
// ---- Sync / upload (Phase 9, ADR-0010) ----
#[tauri::command]
pub async fn list_sync_targets() -> WaResult<Vec<SyncTargetInfo>> {
// 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<SyncTargetInfo> {
// `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<SyncTargetInfo> {
// `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<serde_json::Value> {
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<serde_json::Value> {
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<String>) -> 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<MeetingId>) -> WaResult<Vec<SyncJobInfo>> {
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<String>) -> WaResult<FeatureBrief> {
// 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<MeetingId>) -> WaResult<Vec<FeatureBriefInfo>> {
todo!("Phase 10b — list_feature_briefs")
}
#[tauri::command]
pub async fn get_feature_brief(_id: String) -> WaResult<FeatureBrief> {
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<serde_json::Value> {
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<String>, _port: Option<u16>) -> WaResult<serde_json::Value> {
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<bool>) -> WaResult<()> {
todo!("Phase 10b — set_mcp_scope")
}
#[tauri::command]
pub async fn mcp_access_log(_limit: Option<u32>) -> WaResult<Vec<McpAccessEntry>> {
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<serde_json::Value> {
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<bool>) -> WaResult<serde_json::Value> {
todo!("Phase 10c — create_issue_from_brief")
}
// ---- Settings + privacy (Phase 2/7) ----
#[tauri::command]
pub async fn get_settings() -> WaResult<Settings> {
todo!("Phase 2 — get_settings")
}
#[tauri::command]
pub async fn update_settings(_patch: serde_json::Value) -> WaResult<Settings> {
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<serde_json::Value> {
todo!("Phase 7 — privacy_self_check")
}
+40
View File
@@ -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<Vec<SpeakerSpan>, 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<Vec<SpeakerSpan>, 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")
}
}
+26
View File
@@ -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<String>, message: impl Into<String>) -> Self {
Self { kind: kind.into(), message: message.into() }
}
}
impl<E: std::error::Error> From<E> for WaError {
fn from(e: E) -> Self {
WaError::new("internal", e.to_string())
}
}
pub type WaResult<T> = Result<T, WaError>;
+34
View File
@@ -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<BackendInfo>;
/// Pick the best available backend, honoring an optional user preference.
fn best(&self, preferred: Option<BackendId>) -> BackendInfo;
}
/// Default Windows detector (DXGI for GPUs, ONNX/Windows ML for NPU).
pub struct WinHardwareDetector;
impl HardwareDetector for WinHardwareDetector {
fn detect(&self) -> Vec<BackendInfo> {
// 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<BackendId>) -> BackendInfo {
todo!("Phase 3 — choose best backend / honor override")
}
}
+93
View File
@@ -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<dyn hardware::HardwareDetector>,
pub store: Arc<dyn storage::Store>,
pub llm: Arc<dyn llm::LlmProvider>,
pub sync: Arc<dyn sync::SyncManager>,
pub mcp: Arc<dyn mcp::McpServer>,
/// The single in-flight recording, if any. Guarded so start/stop/pause are atomic.
pub session: Mutex<Option<RecordingSession>>,
}
/// 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");
}
+106
View File
@@ -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<String>,
}
pub struct Summary {
pub summary_md: String,
pub decisions: Vec<String>,
pub action_items: Vec<crate::models::ActionItem>,
}
pub struct LlmStatus {
pub provider: String, // ollama|custom|off
pub reachable: bool,
pub is_local: bool,
pub models: Vec<String>,
}
/// Streamed tokens delivered to the UI via "llm://token".
pub type TokenSink = std::sync::mpsc::Sender<String>;
#[async_trait]
pub trait LlmProvider: Send + Sync {
async fn status(&self) -> LlmStatus;
async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result<Summary, LlmError>;
/// 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<Summary, LlmError> {
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<Summary, LlmError> {
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<Summary, LlmError> {
todo!("Phase 10a — POST /v1/messages streaming")
}
fn is_local(&self) -> bool {
false
}
}
+7
View File
@@ -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();
}
+106
View File
@@ -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:<port>/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<McpHandle, McpError>;
async fn stop(&self, handle: McpHandle) -> Result<(), McpError>;
/// Tools-first surface (FR-MCP-2).
fn tools(&self) -> Vec<McpToolDescriptor>;
}
/// 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<FeatureBrief, BriefError>;
}
/// 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<McpHandle, McpError> {
// 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<McpToolDescriptor> {
// 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." },
]
}
}
+194
View File
@@ -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<u32>,
}
#[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<f32>,
pub interim: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpeakerInfo {
pub label: String, // "S1"
pub display_name: Option<String>,
pub participant_id: Option<String>,
}
/// 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<i64>,
pub status: MeetingStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionItem {
pub id: Option<String>,
pub text: String,
pub owner: Option<String>,
pub due_at: Option<i64>,
pub confirmed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarEvent {
pub id: String,
pub source: String, // pst|graph|ics
pub subject: Option<String>,
pub organizer: Option<String>,
pub starts_at: Option<i64>,
pub ends_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Participant {
pub id: String,
pub name: String,
pub email: Option<String>,
pub role: Option<String>,
}
#[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<String>, // nextcloud|owncloud|cloudreve|seafile|synology|generic
pub base_url: Option<String>,
pub remote_base_path: String,
pub username: Option<String>,
pub enabled: bool,
pub third_party: bool,
pub host: Option<String>,
}
#[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<u64>,
pub last_error: Option<String>,
}
// ---- 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<String>,
pub target_repo: Option<String>,
pub context_excerpts: Vec<ContextExcerpt>,
}
#[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<String>,
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<MeetingId>,
pub client: Option<String>,
}
+55
View File
@@ -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<PathBuf, NotesError>;
}
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<PathBuf, NotesError> {
// T2.6 (.md/bundle); T8.4 (pdf/docx via local conversion).
todo!("Phase 2/8 — export notes")
}
}
+74
View File
@@ -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<String>,
}
pub struct Retention {
pub max_age_days: Option<u32>,
pub max_size_gb: Option<u32>,
}
#[async_trait]
pub trait Store: Send + Sync {
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
async fn finalize_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
async fn list_meetings(&self, query: Option<String>) -> Result<Vec<MeetingListItem>, 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<Vec<MeetingListItem>, StoreError>;
/// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1).
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>;
/// Enforce retention; returns count removed. Skips in-progress meetings.
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError>;
}
/// 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<MeetingId, StoreError> {
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<String>) -> Result<Vec<MeetingListItem>, 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<Vec<MeetingListItem>, StoreError> {
todo!("Phase 8 — FTS5 search")
}
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError> {
todo!("Phase 2 — recovery scan")
}
async fn enforce_retention(&self, _policy: Retention) -> Result<u32, StoreError> {
todo!("Phase 2 — retention enforcement")
}
}
+104
View File
@@ -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<bool, SyncError>;
/// 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<Vec<SyncJobInfo>, 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<String>;
}
/// WebDAV provider — primary targets + Synology (feature `sync`).
#[cfg(feature = "sync")]
pub struct WebDavTarget {
pub base_url: String, // https://host/remote.php/dav/files/<user>/ , /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<bool, SyncError> {
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;
+57
View File
@@ -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<f32>,
pub offset_ms: u64,
}
/// Where produced segments are delivered (interim then final).
pub type SegmentSink = std::sync::mpsc::Sender<TranscriptSegment>;
pub trait Transcriber: Send + Sync {
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError>
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<Vec<TranscriptSegment>, 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<Self, TrxError> {
// 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<Vec<TranscriptSegment>, TrxError> {
todo!("Phase 3 — batch transcription")
}
}
/// ONNX Runtime + DirectML transcriber for the NPU tier (Phase 3).
#[cfg(feature = "directml")]
pub struct OnnxNpuTranscriber;