Add all scaffolding, design, and decision documentation AKA first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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." },
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user