Files
WhispAssist/src/lib/api.ts
T

646 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Typed Tauri client — the frontend's ONLY way to reach the core.
// Mirrors docs/04-api-contracts.md. Keep these signatures in sync with
// src-tauri/src/commands.rs (a contract test enforces this — see docs/06).
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export type MeetingId = string;
export type BackendId = "npu" | "nvidia" | "amd" | "intel" | "cpu";
// Every backend command rejects an `Err` as this shape, not a native Error
// (see src-tauri/src/error.rs) — `e instanceof Error` is always false for it.
export interface WaError {
kind: string;
message: string;
}
export function errorMessage(e: unknown): string {
if (typeof e === "string") return e;
if (e instanceof Error) return e.message;
if (e && typeof e === "object" && typeof (e as WaError).message === "string") {
return (e as WaError).message;
}
return String(e);
}
export interface BackendInfo {
id: BackendId;
name: string;
available: boolean;
rank: number;
vram_mb: number | null;
}
export interface AppInfo {
version: string;
commit: string;
}
export interface HardwareStatus {
backends: BackendInfo[];
active: BackendId;
modelSize: string;
estRtf: number;
/** NPU package state (T3.4): chip detected, runtime staged, model fetched. */
npu?: { present: boolean; runtimeReady: boolean; modelInstalled: boolean };
/** DirectML GPU package (P2): applicable = staging would help this build. */
directml?: { applicable: boolean; runtimeReady: boolean; modelInstalled: boolean };
}
// One enumerated audio device — a render (playback) device for the loopback
// picker (FR-CAP-1) or a capture (microphone) device for the mic picker
// (FR-CAP-7). `id` is the persisted `Device::get_id()`; `name` is display-only.
export interface AudioDeviceInfo {
id: string;
name: string;
}
export interface LlmStatus {
provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired)
reachable: boolean;
isLocal: boolean;
models: string[];
}
export interface ModelInfo {
id: string;
label: string;
size_mb: number;
installed: boolean;
active: boolean;
/** `false` for `.en` (English-only) ggml variants; `true` for multilingual
* ones — gates the Settings language picker (T8.7, FR-TRX-4, M4.2). */
multilingual: boolean;
}
// One selectable transcription language (T8.7, FR-TRX-4) — ISO-639-1 code
// as accepted by whisper.cpp, plus a display label.
export interface LanguageOption {
code: string;
label: string;
}
export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error";
export interface MeetingListItem {
id: MeetingId;
title: string;
started_at: number;
duration_secs: number | null;
status: MeetingStatus;
tags: string[];
}
// Full-text search hit (Phase 8, FR-SEARCH-1) — same shape as MeetingListItem
// plus a highlighted excerpt of what matched.
export interface SearchHit {
id: MeetingId;
title: string;
started_at: number;
duration_secs: number | null;
status: MeetingStatus;
tags: string[];
snippet: string;
}
// list_meetings filters (Phase 8, FR-SEARCH-2). All fields optional/ANDed.
export interface MeetingFilter {
query?: string;
tag?: string;
participantId?: string;
from?: number;
to?: number;
}
export interface TranscriptSegment {
id: number;
start_ms: number;
end_ms: number;
speaker: string;
text: string;
confidence: number | null;
interim: boolean;
}
export interface SpeakerInfo {
label: string;
display_name: string | null;
participant_id: string | null;
}
// Drafted action item from a summary (Phase 5, FR-LLM-3). `id` is only set
// once `confirmActionItems` has persisted it as a row.
export interface ActionItem {
id: string | null;
text: string;
owner: string | null;
due_at: number | null;
confirmed: boolean;
// Schedule a local OS reminder for due_at (Phase 8, T8.6, FR-CAL-5).
reminder_set: boolean;
}
// On-disk shape of summary.json (FR-LLM-2/4) — `null` until generate_summary
// has run at least once for a meeting.
export interface SummaryFile {
schema: number;
generated_at: number;
provider: string;
model: string;
summary_md: string;
decisions: string[];
action_items: ActionItem[];
}
// Full meeting detail (get_meeting) — DB row + transcript + speakers + notes.
export interface Meeting {
id: MeetingId;
title: string;
started_at: number;
ended_at: number | null;
duration_secs: number | null;
status: MeetingStatus;
recorded: boolean;
// Transcription language actually used/selected for this meeting (T8.7,
// FR-TRX-4) — an ISO-639-1 code, or null if never resolved (e.g. no audio
// was ever decoded).
language: string | null;
backend_used: string | null;
model_used: string | null;
segments: TranscriptSegment[];
speakers: SpeakerInfo[];
notes_markdown: string;
summary: SummaryFile | null;
// Confirmed/edited action items (table-backed source of truth) — falls back
// to summary.action_items drafts until anything is saved. Manage via
// confirmActionItems (add/edit/delete).
action_items: ActionItem[];
calendar_event_id: string | null;
tags: string[];
template_id: string | null;
}
// Note template (Phase 8, T8.1, FR-NOTE-5) — section headers applied to
// notes.md at recording-start time.
export interface NoteTemplate {
id: string;
name: string;
sections: string[];
}
// Calendar / .pst (Phase 6, FR-CAL-*).
export interface CalendarEvent {
id: string;
source: string; // pst|graph|ics
subject: string | null;
organizer: string | null;
starts_at: number | null;
ends_at: number | null;
description: string | null;
raw_uid: string | null;
}
export interface Participant {
id: string;
name: string;
email: string | null;
role: string | null; // organizer|required|optional
}
export interface CalendarEventDetail {
event: CalendarEvent;
participants: Participant[];
}
export interface CalendarCleanupResult {
deleted: number;
protected: number;
}
export type SyncKind = "webdav" | "onedrive" | "dropbox" | "box";
// What the UI sees about a target — NEVER includes the secret (FR-SYNC-6).
export interface SyncTargetInfo {
id: string;
name: string;
kind: SyncKind;
provider_hint: string | null; // nextcloud|owncloud|cloudreve|seafile|synology|generic
base_url: string | null;
remote_base_path: string;
username: string | null;
enabled: boolean;
third_party: boolean;
host: string | null;
upload_transcript: boolean;
upload_notes: boolean;
upload_summary: boolean;
upload_recording: boolean;
trigger_on_finalize: boolean;
allow_plaintext_lan: boolean;
encrypt_before_upload: boolean;
}
// privacy_self_check() response (FR-SEC-2) — proves local-only handling.
export interface PrivacySyncTarget {
name: string;
host: string;
thirdParty: boolean;
tls: boolean;
}
export interface PrivacySelfCheck {
llmEndpoint: string;
llmIsLocal: boolean;
syncEnabled: boolean;
syncTargets: PrivacySyncTarget[];
allowlistedHosts: string[];
}
// Payload for add/update. `secret` is write-only (stored in the OS credential store).
export interface SyncTargetConfig {
id?: string;
name: string;
kind: SyncKind;
provider_hint?: string;
base_url?: string;
remote_base_path?: string;
username?: string;
secret?: string;
enabled?: boolean;
upload_transcript?: boolean;
upload_notes?: boolean;
upload_summary?: boolean;
upload_recording?: boolean;
trigger_on_finalize?: boolean;
allow_plaintext_lan?: boolean;
encrypt_before_upload?: boolean;
}
// A durable upload job for one artifact × target (sync_status). Snake_case,
// matching the Rust SyncJobInfo; the sync://job *event* is camelCase (onSyncJob).
export interface SyncJobInfo {
id: string;
target_id: string;
meeting_id: MeetingId;
artifact: string;
status: string; // pending|uploading|done|failed|skipped
attempts: number;
bytes_sent: number;
bytes_total: number | null;
last_error: string | null;
}
export interface AppSettings {
theme: string;
storage_root: string;
llm_provider: string;
llm_endpoint: string;
llm_model: string;
/** Advanced Ollama tuning; sparse (only non-default overrides). */
llm_advanced?: {
system?: string;
think?: string;
keep_alive?: string;
options?: Record<string, number | boolean | string[]>;
} | null;
preferred_backend: string;
whisper_model: string;
/** Default transcription language (T8.7, FR-TRX-4): `null` = auto-detect,
* an ISO-639-1 code forces that language. Only takes effect with a
* multilingual model — see `ModelInfo.multilingual`. */
whisper_language: string | null;
low_overhead: boolean;
default_record: boolean;
consent_acknowledged: boolean;
/** One-time "data leaves your device" ack for hosted (non-local) AI
* providers — Anthropic today (ADR-0011, T10.3). Independent of
* consent_acknowledged (that one's about recording law). */
hosted_ai_acknowledged: boolean;
sync_enabled: boolean;
mcp_enabled: boolean;
mcp_transport: string; // http|stdio
mcp_port: number;
mcp_expose: string; // none|selected|all
mcp_expose_recordings: boolean;
retention_max_age_days: number | null;
retention_max_size_gb: number | null;
pst_last_path: string | null;
pst_auto_sync: boolean;
// Days of history to import (both a manual Import click and pst_auto_sync);
// null = full mailbox history.
pst_import_range_days: number | null;
/** Auto-start recording when a calendar event begins while the app is open
* (opt-in, off by default). Armed as a one-shot UI timer — nothing polls. */
auto_record_calendar: boolean;
audio_output_device: string | null;
microphone_enabled: boolean;
audio_input_device: string | null;
}
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
export interface FeatureBrief {
id: string;
meeting_id: MeetingId;
title: string;
problem: string;
desired_outcome: string;
acceptance_criteria: string[];
target_repo: string | null;
context_excerpts: { speaker: string; text: string }[];
}
export interface FeatureBriefInfo {
id: string;
meeting_id: MeetingId;
title: string;
target_repo: string | null;
exposed: boolean;
}
export interface McpAccessEntry {
at: number;
tool: string;
meeting_id: MeetingId | null;
client: string | null;
}
// mcp_status() response (FR-MCP-1/6). `endpoint` is empty while disabled.
export interface McpStatus {
enabled: boolean;
transport: "http" | "stdio";
endpoint: string;
tokenSet: boolean;
exposeScope: "none" | "selected" | "all";
}
// ---- Commands ----
export const api = {
// `record` controls audio RETENTION (default false / off — ADR-0009).
// `language` (T8.7, FR-TRX-4): omit/undefined falls back to
// Settings.whisper_language; "auto" or omitted both mean auto-detect.
startRecording: (
meetingTitle?: string,
calendarEventId?: string,
record = false,
templateId?: string,
language?: string,
) =>
invoke<MeetingId>("start_recording", {
args: { meetingTitle, calendarEventId, record, templateId, language },
}),
listNoteTemplates: () => invoke<NoteTemplate[]>("list_note_templates"),
stopRecording: (meetingId: MeetingId) => invoke<void>("stop_recording", { meetingId }),
// Abandon an accidental recording: stop + delete files + drop the DB row.
cancelRecording: (meetingId: MeetingId) => invoke<void>("cancel_recording", { meetingId }),
// Absolute path to a playable audio.wav (decrypted if sealed), for convertFileSrc.
recordingPlaybackPath: (meetingId: MeetingId) =>
invoke<string>("recording_playback_path", { meetingId }),
pauseRecording: (meetingId: MeetingId) => invoke<void>("pause_recording", { meetingId }),
resumeRecording: (meetingId: MeetingId) => invoke<void>("resume_recording", { meetingId }),
setRecordingRetention: (meetingId: MeetingId, record: boolean) =>
invoke<void>("set_recording_retention", { meetingId, record }),
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
// Live notes (Granola-style redesign): both live-session only, err once
// the meeting is finalized — use updateNotes on the merged notes.md instead.
updateLiveNotes: (meetingId: MeetingId, markdown: string) =>
invoke<void>("update_live_notes", { meetingId, markdown }),
// anchorMs: the clicked segment's start_ms (not its id). text: "" clears it.
setSegmentNote: (meetingId: MeetingId, anchorMs: number, text: string) =>
invoke<void>("set_segment_note", { meetingId, anchorMs, text }),
appInfo: () => invoke<AppInfo>("app_info"),
openUrl: (url: string) => invoke<void>("open_url", { url }),
hardwareStatus: () => invoke<HardwareStatus>("hardware_status"),
listAudioDevices: () => invoke<AudioDeviceInfo[]>("list_audio_devices"),
listInputDevices: () => invoke<AudioDeviceInfo[]>("list_input_devices"),
setPreferredBackend: (backend: BackendId | "auto") =>
invoke<void>("set_preferred_backend", { args: { backend } }),
downloadNpuPackage: () => invoke<void>("download_npu_package"),
downloadDirectmlPackage: () => invoke<void>("download_directml_package"),
listModels: () => invoke<ModelInfo[]>("list_models"),
// The fixed segmentation+embedding pair that speaker diarization needs
// installed before it can separate speakers (T4.7, FR-MODEL-1). Same
// ModelInfo shape as whisper models; download via `downloadModel` with the
// `diar-seg`/`diar-emb` kind, remove via the shared `removeModel`.
listDiarizationModels: () => invoke<ModelInfo[]>("list_diarization_models"),
// T8.7/FR-TRX-4: static catalog of whisper.cpp-recognized language codes
// for the Settings dropdown; "Auto-detect" is a frontend-only addition.
listWhisperLanguages: () => invoke<LanguageOption[]>("list_whisper_languages"),
downloadModel: (id: string, kind: "whisper" | "diar-seg" | "diar-emb" = "whisper") =>
invoke<void>("download_model", { args: { kind, id } }),
removeModel: (id: string) => invoke<void>("remove_model", { id }),
// `language` (T8.7): omit/undefined reuses whatever language the meeting
// already had rather than resetting it to auto.
reprocessTranscript: (meetingId: MeetingId, model: string, language?: string) =>
invoke<void>("reprocess_transcript", { meetingId, model, language }),
// Manually add a meeting from an existing recording — a local audio/video
// file path or a URL (YouTube/streaming page or direct media URL). Requires
// ffmpeg (and yt-dlp for URLs) on PATH; neither is bundled. Returns the new
// meeting's id once transcription + diarization have finished.
importMedia: (source: string, title?: string) =>
invoke<MeetingId>("import_media", { source, title }),
resumeTranscription: (meetingId: MeetingId) =>
invoke<void>("resume_transcription", { meetingId }),
listMeetings: (filter?: MeetingFilter) =>
invoke<MeetingListItem[]>("list_meetings", {
query: filter?.query,
tag: filter?.tag,
participantId: filter?.participantId,
from: filter?.from,
to: filter?.to,
}),
// Full-text search across transcripts + notes — distinct from listMeetings'
// `query`, which only substring-matches the title.
search: (query: string) => invoke<SearchHit[]>("search", { query }),
setTags: (meetingId: MeetingId, tags: string[]) => invoke<void>("set_tags", { meetingId, tags }),
listTags: () => invoke<string[]>("list_tags"),
getMeeting: (meetingId: MeetingId) => invoke<Meeting>("get_meeting", { meetingId }),
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
updateNotes: (meetingId: MeetingId, markdown: string) =>
invoke<void>("update_notes", { meetingId, markdown }),
// dest is a file path for md/pdf/docx/obsidian, a folder for bundle.
// "obsidian" writes one self-contained vault note (no audio) — FR-STORE-4.
exportMeeting: (
meetingId: MeetingId,
dest: string,
format: "md" | "pdf" | "docx" | "bundle" | "obsidian",
) => invoke<string>("export_meeting", { meetingId, dest, format }),
// Every meeting matching tag/date filters, one file (or bundle folder) per
// meeting under destDir. Returns the count actually exported (T8.5, FR-STORE-4).
bulkExportMeetings: (
destDir: string,
format: "md" | "pdf" | "docx" | "bundle",
filter?: Pick<MeetingFilter, "tag" | "from" | "to">,
) =>
invoke<number>("bulk_export_meetings", {
destDir,
format,
tag: filter?.tag,
from: filter?.from,
to: filter?.to,
}),
// Import bundle(s) exported with format "bundle" — dir is a single bundle
// folder or a parent folder of them. Reconstructs each under a fresh id and
// returns the count imported (FR-STORE-4).
importMeetingBundle: (dir: string) => invoke<number>("import_meeting_bundle", { dir }),
llmStatus: () => invoke<LlmStatus>("llm_status"),
// provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011).
setLlmProvider: (config: {
provider: string;
endpoint?: string;
model?: string;
apiKey?: string;
}) => invoke<void>("set_llm_provider", { args: config }),
generateSummary: (meetingId: MeetingId, templateId?: string) =>
invoke<void>("generate_summary", { meetingId, templateId }),
confirmActionItems: (meetingId: MeetingId, items: ActionItem[]) =>
invoke<void>("confirm_action_items", { meetingId, items }),
generateTags: (meetingId: MeetingId) => invoke<string[]>("generate_tags", { meetingId }),
// rangeDays: only import events starting within the last N days; omitted/undefined imports
// the full mailbox history (a long-lived .pst otherwise re-imports years of recurring/holiday
// entries on every launch when pst_auto_sync is on).
importPst: (path: string, password?: string, rangeDays?: number) =>
invoke<number>("import_pst", { path, password, rangeDays }),
// olderThanDays: undefined deletes every unlinked event ("Delete all").
// An event attached to a recorded meeting is always kept either way.
cleanupCalendarEvents: (olderThanDays?: number) =>
invoke<CalendarCleanupResult>("cleanup_calendar_events", { olderThanDays }),
listCalendarEvents: (from?: number, to?: number) =>
invoke<CalendarEvent[]>("list_calendar_events", { from, to }),
getCalendarEvent: (eventId: string) =>
invoke<CalendarEventDetail>("get_calendar_event", { eventId }),
attachMeetingToEvent: (meetingId: MeetingId, eventId: string) =>
invoke<void>("attach_meeting_to_event", { meetingId, eventId }),
renameMeeting: (meetingId: MeetingId, title: string) =>
invoke<void>("rename_meeting", { meetingId, title }),
renameSpeaker: (meetingId: MeetingId, label: string, name: string) =>
invoke<void>("rename_speaker", { meetingId, label, name }),
mapSpeakerToParticipant: (meetingId: MeetingId, label: string, participantId: string) =>
invoke<void>("map_speaker_to_participant", { meetingId, label, participantId }),
// Sync (ADR-0010) — off by default; secrets never returned by listSyncTargets.
listSyncTargets: () => invoke<SyncTargetInfo[]>("list_sync_targets"),
addSyncTarget: (config: SyncTargetConfig) =>
invoke<SyncTargetInfo>("add_sync_target", { config }),
updateSyncTarget: (config: SyncTargetConfig & { id: string }) =>
invoke<SyncTargetInfo>("update_sync_target", { config }),
removeSyncTarget: (id: string) => invoke<void>("remove_sync_target", { id }),
testSyncTarget: (configOrId: SyncTargetConfig | { id: string }) =>
invoke<{ ok: boolean; message: string }>("test_sync_target", { config: configOrId }),
setSyncEnabled: (enabled: boolean) => invoke<void>("set_sync_enabled", { enabled }),
beginOauthLink: (kind: "onedrive" | "dropbox" | "box") =>
invoke<{ authUrl: string }>("begin_oauth_link", { kind }),
syncMeeting: (meetingId: MeetingId, targetId?: string) =>
invoke<void>("sync_meeting", { meetingId, targetId }),
syncStatus: (meetingId?: MeetingId) => invoke<SyncJobInfo[]>("sync_status", { meetingId }),
retrySyncJob: (jobId: string) => invoke<void>("retry_sync_job", { jobId }),
// Feature briefs + MCP server (ADR-0011). MCP server is loopback-only and off by default.
createFeatureBrief: (meetingId: MeetingId, targetRepo?: string) =>
invoke<FeatureBrief>("create_feature_brief", { meetingId, targetRepo }),
listFeatureBriefs: (meetingId?: MeetingId) =>
invoke<FeatureBriefInfo[]>("list_feature_briefs", { meetingId }),
getFeatureBrief: (id: string) => invoke<FeatureBrief>("get_feature_brief", { id }),
setBriefExposed: (id: string, exposed: boolean) =>
invoke<void>("set_brief_exposed", { id, exposed }),
mcpStatus: () => invoke<McpStatus>("mcp_status"),
setMcpEnabled: (enabled: boolean, transport?: "http" | "stdio", port?: number) =>
invoke<{ endpoint: string; token: string }>("set_mcp_enabled", { enabled, transport, port }),
setMcpScope: (expose: "none" | "selected" | "all", exposeRecordings?: boolean) =>
invoke<void>("set_mcp_scope", { expose, exposeRecordings }),
mcpAccessLog: (limit?: number) => invoke<McpAccessEntry[]>("mcp_access_log", { limit }),
// Layer 3 (later) push handoff.
runAgent: (
briefId: string,
tool: "claude" | "codex" | "opencode" | "copilot",
repoPath: string,
) => invoke<{ ok: boolean }>("run_agent", { briefId, tool, repoPath }),
createIssueFromBrief: (briefId: string, tracker: "github", assignCopilot?: boolean) =>
invoke<{ url: string }>("create_issue_from_brief", { briefId, tracker, assignCopilot }),
getSettings: () => invoke<AppSettings>("get_settings"),
updateSettings: (patch: Partial<AppSettings>) =>
invoke<AppSettings>("update_settings", { patch }),
privacySelfCheck: () => invoke<PrivacySelfCheck>("privacy_self_check"),
// At-rest encryption vault (Phase 8, T8.8, FR-SEC-3).
vaultStatus: () => invoke<{ enabled: boolean; unlocked: boolean }>("vault_status"),
enableVault: (password: string) => invoke<void>("enable_vault", { args: { password } }),
unlockVault: (password: string) => invoke<void>("unlock_vault", { args: { password } }),
lockVault: () => invoke<void>("lock_vault"),
changeVaultPassword: (oldPassword: string, newPassword: string) =>
invoke<void>("change_vault_password", { args: { oldPassword, newPassword } }),
};
// ---- Events (Rust → UI) ----
export const events = {
onRecordingState: (cb: (p: unknown) => void): Promise<UnlistenFn> =>
listen("recording://state", (e) => cb(e.payload)),
onRetention: (cb: (p: { meetingId: string; record: boolean }) => void): Promise<UnlistenFn> =>
listen("recording://retention", (e) => cb(e.payload as never)),
onLevel: (
cb: (p: { meetingId: string; rms: number; peak: number; mic: boolean }) => void,
): Promise<UnlistenFn> => listen("recording://level", (e) => cb(e.payload as never)),
onDeviceChanged: (
cb: (p: { meetingId: string; recovered: boolean; message: string }) => void,
): Promise<UnlistenFn> => listen("recording://device", (e) => cb(e.payload as never)),
onSegment: (
cb: (p: { meetingId: string; segment: TranscriptSegment }) => void,
): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)),
onFinalized: (
cb: (p: { meetingId: string; segmentCount: number }) => void,
): Promise<UnlistenFn> => listen("transcript://finalized", (e) => cb(e.payload as never)),
onLlmToken: (cb: (p: { meetingId: string; text: string }) => void): Promise<UnlistenFn> =>
listen("llm://token", (e) => cb(e.payload as never)),
onLlmDone: (cb: (p: { meetingId: string; summary: SummaryFile }) => void): Promise<UnlistenFn> =>
listen("llm://done", (e) => cb(e.payload as never)),
onModelProgress: (
cb: (p: { id: string; receivedBytes: number; totalBytes: number | null }) => void,
): Promise<UnlistenFn> => listen("model://progress", (e) => cb(e.payload as never)),
onHardwareChanged: (
cb: (p: { active: BackendId; reason: string }) => void,
): Promise<UnlistenFn> => listen("hardware://changed", (e) => cb(e.payload as never)),
onNpuDownload: (
cb: (p: {
stage: "model" | "runtime" | "done";
file?: number;
received?: number;
total?: number | null;
copied?: number;
ready?: boolean;
}) => void,
): Promise<UnlistenFn> => listen("npu://download", (e) => cb(e.payload as never)),
onSyncJob: (
cb: (p: {
jobId: string;
meetingId: string;
targetId: string;
artifact: string;
status: string;
bytesSent: number;
bytesTotal: number | null;
attempts?: number;
error?: string | null;
}) => void,
): Promise<UnlistenFn> => listen("sync://job", (e) => cb(e.payload as never)),
onSyncLinked: (
cb: (p: { ok: boolean; kind: string; error?: string }) => void,
): Promise<UnlistenFn> => listen("sync://linked", (e) => cb(e.payload as never)),
// Live tail of the FR-MCP-5 audit log (camelCase on the wire, unlike the
// snake_case McpAccessEntry rows `mcpAccessLog()` returns).
onMcpAccess: (
cb: (p: { at: number; tool: string; meetingId?: MeetingId; client?: string }) => void,
): Promise<UnlistenFn> => listen("mcp://access", (e) => cb(e.payload as never)),
onAgentProgress: (
cb: (p: { briefId: string; tool: string; line: string }) => void,
): Promise<UnlistenFn> => listen("agent://progress", (e) => cb(e.payload as never)),
onPstProgress: (cb: (p: { processed: number; total: number }) => void): Promise<UnlistenFn> =>
listen("pst://progress", (e) => cb(e.payload as never)),
onError: (cb: (p: { kind: string; message: string }) => void): Promise<UnlistenFn> =>
listen("error", (e) => cb(e.payload as never)),
};