22 KiB
22 KiB
04 — API Contracts
Two contract surfaces:
- Frontend ⇄ Rust — Tauri commands (request/response) and events (Rust→UI push).
- Rust internal — service traits that decouple callers from concrete engines (NFR-MNT-1/2).
All payloads are serde-serializable; timestamps are unix epoch ms unless noted. Errors are typed:
each command returns Result<T, WaError> where WaError carries a kind (machine-readable) and a
message (human-readable).
1. Tauri commands (frontend → Rust)
// ---- Recording lifecycle ----
// `record` (default false) controls audio RETENTION (ADR-0009). When false, working audio is
// deleted on finalize and only the transcript/notes persist. It can be toggled mid-meeting.
// templateId (Phase 8, T8.1, FR-NOTE-5) picks a NoteTemplate — see list_note_templates below.
// language (T8.7, FR-TRX-4, M4.2): omitted/"auto" requests auto-detection; an ISO-639-1 code
// (e.g. "es") forces that language. Falls back to Settings.whisper_language when omitted.
// Only takes effect with a multilingual model loaded (ModelInfo.multilingual) — an English-only
// model forces "en" regardless (see resolve_language in src-tauri/src/transcription/mod.rs).
start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean; templateId?: string; language?: string }): MeetingId
stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef
pause_recording(input: { meetingId: MeetingId }): void
resume_recording(input: { meetingId: MeetingId }): void
set_recording_retention(input: { meetingId: MeetingId; record: boolean }): void // toggle mid-meeting (FR-REC-1)
acknowledge_recording_consent(): void // one-time (FR-REC-2)
// ---- Live notes (Granola-style redesign, `03-data-model.md`'s manual_notes.json) ----
// Both are live-session only (err "no matching active recording" once finalized — post-finalize,
// notes.md is the single editable document and `update_notes` is the command for it).
update_live_notes(input: { meetingId: MeetingId; markdown: string }): void // freeform notes typed while recording
// anchorMs: the clicked transcript segment's start_ms, not its id (survives re-transcription).
// text: "" clears that moment's note. Merged into notes.md right after the transcript paragraph
// covering anchorMs when the meeting finalizes (notes::MarkdownNotes::merge).
set_segment_note(input: { meetingId: MeetingId; anchorMs: number; text: string }): void
// ---- Hardware ----
hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number }
set_preferred_backend(input: { backend: BackendId | "auto" }): void
// ---- Transcription / models ----
// language (T8.7, M4.2): omitted reuses the meeting's current language rather than resetting it.
reprocess_transcript(input: { meetingId: MeetingId; model: string; language?: string }): void // batch mode (FR-TRX-3)
// ModelInfo gained `multilingual: boolean` (T8.7, FR-TRX-4, M4.2) — false for `.en` (English-only)
// ggml variants, true for the multilingual ones; gates the Settings language picker.
list_models(): ModelInfo[]
list_diarization_models(): ModelInfo[] // fixed seg+emb pair (T4.7, FR-MODEL-1)
download_model(input: { kind: "whisper" | "diar-seg" | "diar-emb"; id: string }): void // emits progress events
remove_model(input: { id: string }): void // disambiguated by id, not kind — ids never collide across catalogs
// Static catalog of whisper.cpp-recognized ISO-639-1 codes for the Settings language dropdown
// (T8.7, FR-TRX-4, M4.2); "Auto-detect" is a frontend-only addition, not in this list.
list_whisper_languages(): { code: string; label: string }[]
// ---- Speakers ----
rename_speaker(input: { meetingId: MeetingId; label: string; name: string }): void
merge_speakers(input: { meetingId: MeetingId; from: string[]; into: string }): void
map_speaker_to_participant(input: { meetingId: MeetingId; label: string; participantId: string }): void
// ---- Meetings / storage ----
// MeetingListItem gained a `tags: string[]` field (Phase 8, FR-SEARCH-2).
// list_meetings dropped limit/offset (never implemented — no pagination need
// yet at local-desktop meeting counts) and gained from/to date filters, per
// FR-SEARCH-2's "filter by date, tag, or participant".
list_meetings(input: { query?: string; tag?: string; participantId?: string; from?: number; to?: number }): MeetingListItem[]
// Meeting also carries `action_items: ActionItem[]` — table-backed (the confirmed/edited source
// of truth), falling back to summary.json drafts until any are saved (FR-LLM-3). TranscriptSegment
// keeps start_ms/end_ms; the UI renders these as per-line timestamps.
get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers + summary (null until generated)
delete_meeting(input: { meetingId: MeetingId }): void
// format "bundle" writes a portable folder: audio.wav + transcript.json + notes.md + summary.json
// (all decrypted) + meeting.json (the MeetingBundle manifest), re-importable on another machine.
// format "obsidian" writes one self-contained vault note (dest is a .md file path, no audio):
// YAML frontmatter (title/date/duration/participants/tags/source) + notes + summary + decisions
// + action items + timestamped transcript. For dropping a meeting into an Obsidian vault.
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" | "obsidian" }): string
// Edit commands that change an uploaded artifact — update_notes, set_tags, generate_summary,
// reprocess_transcript, confirm_action_items — auto-resync when sync is enabled (FR-SYNC-5):
// they enqueue for finalize-trigger targets and pump in the background. SHA-256 dedup means an
// edit that didn't alter a file uploads nothing.
update_notes(input: { meetingId: MeetingId; markdown: string }): void
// SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string
search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1)
set_tags(input: { meetingId: MeetingId; tags: string[] }): void
list_tags(): string[] // all known tag names, sorted
// NoteTemplate = { id: string, name: string, sections: string[] } (T8.1, FR-NOTE-5). Meeting
// gained `template_id: string | null` — the template picked at start_recording.
list_note_templates(): NoteTemplate[]
// Bulk export (T8.5, FR-STORE-4): every meeting matching tag/from/to, one file (or bundle
// folder) per meeting under destDir. Returns the count actually exported.
bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "bundle"; tag?: string; from?: number; to?: number }): number
// Import bundle(s) (FR-STORE-4): `dir` is a single bundle folder (has meeting.json) or a parent
// folder of them (from a bulk export). Each is reconstructed under a fresh meeting id (original
// title/date/duration/speakers/tags/action items preserved). Returns the count imported.
import_meeting_bundle(input: { dir: string }): number
// ---- LLM / AI provider (ADR-0007/0011) ----
// provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to
// set_llm_provider but stored only in the OS credential store; never returned by llm_status.
llm_status(): { provider: string; reachable: boolean; isLocal: boolean; models: string[] }
set_llm_provider(input: { provider: string; endpoint?: string; model?: string; apiKey?: string }): void // FR-AI-1/2
generate_summary(input: { meetingId: MeetingId; templateId?: string }): void // streams via events (FR-LLM-2/4)
// ActionItem gained `reminder_set: boolean` (T8.6, FR-CAL-5). confirm_action_items now also
// schedules/cancels each item's local reminder (Windows scheduled toast notification — the OS
// itself delivers it at due_at, no app-side polling timer; see src-tauri/src/reminders.rs).
confirm_action_items(input: { meetingId: MeetingId; items: ActionItem[] }): void
llm_setup_suggestions(): { ollamaInstalled: boolean; installUrl: string; suggestedModel: { id: string; label: string; approxSizeGb: number } } // T5.7, FR-LLM-5
pull_ollama_model(input: { model: string }): void // guided download via Ollama's own /api/pull; emits model://progress (T5.7)
// ---- Calendar / .pst ----
// rangeDays: only import events starting within the last N days; omitted imports the full mailbox
// history. Bug fix: a long-lived .pst has no natural upper bound on history (every recurring
// series expands to its cap, T4.1's RECURRENCE_MAX_OCCURRENCES, plus every one-off entry the file
// ever held, e.g. a decade of Outlook's auto-generated yearly holidays) -- unbounded import could
// produce tens of thousands of rows. Settings.pst_import_range_days persists the last choice and
// applies it to pst_auto_sync's startup re-import too.
import_pst(input: { path: string; password?: string; rangeDays?: number }): number // eventsImported; emits pst://progress (FR-CAL-1)
list_calendar_events(input: { from?: number; to?: number }): CalendarEvent[]
get_calendar_event(input: { eventId: string }): { event: CalendarEvent; participants: Participant[] } // pre-meeting panel + naming dropdown (FR-CAL-3, FR-SPK-4)
// olderThanDays omitted deletes every unlinked event ("Delete all"); Some(n) only those starting
// more than n days ago. An event attached to a recorded meeting (meetings.calendar_event_id) is
// always kept regardless of the choice -- protected reports how many were skipped for that reason.
cleanup_calendar_events(input: { olderThanDays?: number }): { deleted: number; protected: number }
attach_meeting_to_event(input: { meetingId: MeetingId; eventId: string }): void
// Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6): opt-in, explicit consent (OAuth PKCE
// + Microsoft's own consent screen), metadata-only (subject/organizer/start/end/attendees, never
// the event body). Not a SyncTarget — begin_graph_calendar_link stores its token separately from
// sync_targets and never appears in list_sync_targets.
begin_graph_calendar_link(): { authUrl: string } // opens in browser; emits calendar://linked when done
import_graph_calendar(input: { from?: number; to?: number }): number // eventsImported; emits calendar://progress
disconnect_graph_calendar(): void // best-effort credential cleanup + settings reset
// ---- Sync / upload (ADR-0010) ---- secrets are passed to add/update but stored only in the OS
// credential store; they are NEVER returned by list_sync_targets.
list_sync_targets(): SyncTargetInfo[]
add_sync_target(input: SyncTargetConfig & { secret: string }): SyncTargetInfo // FR-SYNC-2/9
update_sync_target(input: { id: string } & Partial<SyncTargetConfig> & { secret?: string }): SyncTargetInfo
remove_sync_target(input: { id: string }): void
test_sync_target(input: { id: string } | SyncTargetConfig & { secret: string }): { ok: boolean; message: string } // FR-SYNC-4
set_sync_enabled(input: { enabled: boolean }): void // master switch (FR-SYNC-1)
sync_meeting(input: { meetingId: MeetingId; targetId?: string }): void // manual "Upload now" (FR-SYNC-3)
sync_status(input?: { meetingId?: MeetingId }): SyncJobInfo[] // queue state (FR-SYNC-5)
retry_sync_job(input: { jobId: string }): void
// OAuth (secondary targets, FR-SYNC-9): begins loopback-redirect PKCE flow, returns when linked.
begin_oauth_link(input: { kind: "onedrive" | "dropbox" | "box" }): { ok: boolean; account?: string }
// ---- Feature briefs + MCP server (ADR-0011) ----
create_feature_brief(input: { meetingId: MeetingId; targetRepo?: string }): FeatureBrief // FR-MCP-4 (LLM-distilled)
list_feature_briefs(input: { meetingId?: MeetingId }): FeatureBriefInfo[]
get_feature_brief(input: { id: string }): FeatureBrief
set_brief_exposed(input: { id: string; exposed: boolean }): void // scope control (FR-MCP-3)
mcp_status(): { enabled: boolean; transport: "http" | "stdio"; endpoint: string; tokenSet: boolean; exposeScope: string }
set_mcp_enabled(input: { enabled: boolean; transport?: "http" | "stdio"; port?: number }): { endpoint: string; token: string } // FR-MCP-1/6
set_mcp_scope(input: { expose: "none" | "selected" | "all"; exposeRecordings?: boolean }): void // FR-MCP-3
mcp_access_log(input?: { limit?: number }): McpAccessEntry[] // audit (FR-MCP-5)
// (Layer 3, later) push handoff — spawn a local agent CLI / open a tracker issue from a brief.
run_agent(input: { briefId: string; tool: "claude" | "codex" | "opencode" | "copilot"; repoPath: string }): { ok: boolean } // FR-AGENT-1
create_issue_from_brief(input: { briefId: string; tracker: "github"; assignCopilot?: boolean }): { url: string } // FR-AGENT-2
// ---- Settings ----
// Settings gained `whisper_language: string | null` (T8.7, FR-TRX-4, M4.2) — the default
// transcription language applied at the next start_recording; null = auto-detect. Mirrors
// this doc's settings.json `transcription.language` (03-data-model.md).
get_settings(): Settings
update_settings(input: Partial<Settings>): Settings
// Reports the full egress allowlist so the UI can prove exactly what may leave the device (FR-SEC-2).
privacy_self_check(): {
llmEndpoint: string; llmIsLocal: boolean;
syncEnabled: boolean;
syncTargets: { name: string; host: string; thirdParty: boolean; tls: boolean }[];
allowlistedHosts: string[];
}
Conventions
- Commands return promptly; anything long-running (recording, transcription, summary, model download, PST import) reports progress/results through events below.
MeetingIdis a uuid string.BackendId∈"npu" | "nvidia" | "amd" | "intel" | "cpu".
2. Tauri events (Rust → frontend)
"recording://state" { meetingId, state: "recording"|"paused"|"stopped"|"cancelled", elapsedMs }
"recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5)
"recording://device" { meetingId, recovered: boolean, message: string } // capture device change (FR-CAP-6)
"transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2)
"transcript://finalized" { meetingId, segmentCount }
"diarization://updated" { meetingId, speakers: SpeakerInfo[] } // after post-pass (FR-SPK)
"llm://token" { meetingId, text } // streamed summary (FR-LLM-4)
"llm://done" { meetingId, summary: SummaryFile } // full summary.json contents, not just a pointer
"model://progress" { id, receivedBytes, totalBytes }
"pst://progress" { processed, total }
"calendar://linked" { ok: boolean, error?: string } // MS Graph OAuth handshake settled (M4.4)
"calendar://progress" { processed, total } // MS Graph import (M4.4)
"hardware://changed" { active: BackendId, reason: string } // fallback occurred (FR-HW-4)
"recording://retention" { meetingId, record: boolean } // retention toggled (FR-REC-1/3)
"sync://job" { jobId, meetingId, targetId, artifact, status, bytesSent, bytesTotal } // FR-SYNC-5
"sync://done" { meetingId, targetId, uploaded: number, failed: number }
"mcp://access" { at, tool, meetingId?, client? } // agent read something (FR-MCP-5)
"agent://progress" { briefId, tool, line } // push run output (FR-AGENT-1)
"error" { kind, message, context? }
3. Internal Rust service traits
These define the seams that let engines be swapped without touching callers. Signatures are indicative (async where I/O-bound).
// audio/mod.rs
pub trait AudioCapture: Send + Sync {
/// Begin WASAPI loopback capture, writing PCM to `wav_path`; frames also pushed to `sink`.
fn start(&self, wav_path: &Path, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
/// Capture the user's microphone (FR-CAP-7); frames pushed to `sink`, no WAV.
fn start_microphone(&self, device_id: Option<&str>, 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>;
}
// When the mic is enabled, `spawn_mixer` sums the loopback + mic 16kHz-mono
// frames into the single transcription stream (`list_input_devices` enumerates
// mic devices, mirroring `list_audio_devices` for render devices).
// hardware/mod.rs
pub trait HardwareDetector: Send + Sync {
fn detect(&self) -> Vec<BackendInfo>; // ranked NPU→NVIDIA→AMD→Intel→CPU
fn best(&self, preferred: Option<BackendId>) -> BackendInfo;
}
// transcription/mod.rs
pub trait Transcriber: Send + Sync {
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError> where Self: Sized;
/// Stream interim + final segments for an audio window.
fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError>;
/// One-shot batch transcription (higher accuracy).
fn transcribe_file(&self, wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError>;
}
// diarization/mod.rs
pub trait Diarizer: Send + Sync {
fn diarize(&self, wav: &Path) -> Result<Vec<SpeakerSpan>, DiarError>;
fn assign(&self, segments: &mut [TranscriptSegment], spans: &[SpeakerSpan]);
}
// storage/mod.rs (async, sqlx)
#[async_trait] pub trait Store: Send + Sync {
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError>;
async fn list_meetings(&self, f: MeetingFilter) -> Result<Vec<MeetingListItem>, StoreError>;
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>;
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
async fn search(&self, q: &str) -> Result<Vec<SearchHit>, StoreError>;
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>; // FR-REL-1
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError>;
}
// llm/mod.rs
#[async_trait] pub trait LlmProvider: Send + Sync {
async fn status(&self) -> LlmStatus; // reachable? local? models
async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result<Summary, LlmError>;
fn is_local(&self) -> bool; // FR-LLM-6 guard
}
// calendar/mod.rs
// `attendees()` (a second, separate trait method in the original design) was dropped — every
// source (PstSource, GraphSource) lists attendees inline per-appointment, so import() returns
// them together (see ADR-0008's update). CalImport gained `from`/`to` (M4.4) for a source that
// fetches by date range (Graph's calendarView); PstSource ignores them.
pub trait CalendarSource: Send + Sync {
fn import(&self, input: CalImport) -> Result<Vec<ImportedEvent>, CalError>; // pst|graph|ics
}
// notes/mod.rs
pub trait NotesRenderer: Send + Sync {
fn to_markdown(&self, t: &Transcript, speakers: &[SpeakerInfo], s: Option<&Summary>) -> String;
fn export(&self, md: &str, dest: &Path, fmt: ExportFormat) -> Result<PathBuf, NotesError>;
}
// MarkdownNotes::merge(segments, speakers, manual: &ManualNotes, summary, template) -> String is an
// inherent method (not part of the trait — only one renderer needs it): what `stop_recording` calls
// instead of `to_markdown` to fold manual_notes.json into the generated notes.md (see 03-data-model.md).
// sync/mod.rs
// One impl per provider; `WebDavTarget` covers Nextcloud/ownCloud/Cloudreve/Seafile/Synology.
// Secondary OAuth impls: OneDriveTarget, DropboxTarget, BoxTarget (ADR-0010).
#[async_trait] pub trait SyncTarget: Send + Sync {
fn kind(&self) -> SyncKind;
fn is_third_party(&self) -> bool; // false for self-hosted WebDAV
async fn test(&self) -> Result<(), SyncError>; // reachability + auth (FR-SYNC-4)
async fn ensure_dir(&self, remote_dir: &str) -> Result<(), SyncError>;
async fn exists(&self, remote_path: &str, sha256: &str) -> Result<bool, SyncError>; // skip-if-unchanged
/// Upload a file; resumable/chunked for large artifacts. Reports progress via `prog`.
async fn put(&self, local: &Path, remote_path: &str, prog: ProgressSink) -> Result<(), SyncError>;
}
// Owns the durable queue, retry/backoff, credential resolution, and TLS enforcement.
#[async_trait] pub trait SyncManager: Send + Sync {
async fn enqueue_meeting(&self, meeting_id: &MeetingId, target_id: Option<&str>) -> Result<(), SyncError>;
async fn pump(&self) -> Result<(), SyncError>; // drive pending jobs (called on finalize + startup + timer)
async fn status(&self, meeting_id: Option<&MeetingId>) -> Result<Vec<SyncJobInfo>, SyncError>;
async fn retry(&self, job_id: &str) -> Result<(), SyncError>;
}
// llm/mod.rs — `LlmProvider` (above) gains hosted impls behind the same trait (ADR-0011):
// OllamaProvider (local) · OpenAiCompatProvider (/v1/chat/completions) · AnthropicProvider (/v1/messages)
// `is_local()` stays the egress guard; hosted impls return false and require an API key from the keychain.
// mcp/mod.rs — WA as an MCP server (loopback, off by default). Tools-first (FR-MCP-2).
#[async_trait] pub trait McpServer: Send + Sync {
async fn start(&self, cfg: McpConfig) -> Result<McpHandle, McpError>; // returns endpoint + token
async fn stop(&self, h: McpHandle) -> Result<(), McpError>;
fn tools(&self) -> Vec<McpToolDescriptor>; // list_recent_meetings, get_transcript, get_action_items, get_feature_brief
}
// Builds the agent-ready spec from a transcript via the configured LlmProvider.
#[async_trait] pub trait FeatureBriefBuilder: Send + Sync {
async fn build(&self, meeting_id: &MeetingId, target_repo: Option<&str>) -> Result<FeatureBrief, BriefError>;
}
// agent/mod.rs — Layer 3 (later). Push handoff; one impl per CLI / tracker.
#[async_trait] pub trait AgentRunner: Send + Sync {
async fn run(&self, brief: &FeatureBrief, repo: &Path, out: LineSink) -> Result<RunOutcome, AgentError>; // claude -p / codex exec / …
}
#[async_trait] pub trait IssueTracker: Send + Sync {
async fn create_issue(&self, brief: &FeatureBrief, assign_copilot: bool) -> Result<String /*url*/, AgentError>;
}
Versioning
- Command/event names and payload shapes are versioned implicitly by
schemafields in persisted JSON (03-data-model.md) and explicitly in aCONTRACTS_VERSIONconstant. Breaking a command shape requires bumping it and updating the frontend client in the same change (seeCLAUDE.md"source of truth" rule).