openquack

SPEC-014 — Local audio + transcript history

Status: draft (M3) Owner: OpenQuackKit/History/ Last updated: 2026-04-30

Goal

You vaguely remember dictating something useful yesterday. Open Settings → History, scan the list, click the row, and the transcript is back on the clipboard — no re-dictation, no remembering exact phrasing. Recall is the headline because every user gets it by default.

For users who opt in to audio storage, history also unlocks crash-recovery: app dies mid-transcribe on a 90-second memo, and on next launch a popover offers “We found a recording from 2 minutes ago that didn’t finish. Recover?” One click and the transcript is at the cursor.

This spec defines a local-only on-disk store of recent dictations: transcripts always (small, useful), audio behind an opt-in toggle (bigger, more sensitive). Retention is bounded by count, age, and total size. A “Delete all” button clears everything immediately.

Non-goals

Defaults — defended

The central design call: SPEC-001 deliberately keeps audio in memory. Persisting it shifts the privacy posture. We split the toggle:

Consequence: transcript recall is the universal benefit; crash-recovery is opt-in. Revisit if user signal disagrees.

Public surface

public actor HistoryStore {
    public init(rootURL: URL = .applicationSupport
        .appendingPathComponent("OpenQuack/History"),
                policy: RetentionPolicy = .default)

    /// Persist a completed dictation. Audio is included only if `saveAudio`
    /// is true; pass the raw [Float] from AudioRecorder. Throws on disk
    /// errors — the caller falls back to in-memory-only behaviour.
    public func save(audio: [Float]?,
                     transcript: String,
                     language: String?,
                     modelID: String,
                     durationSeconds: TimeInterval) async throws -> HistoryEntry

    /// Mark an entry as transcribed. Used by the crash-recovery flow when
    /// audio was saved before transcription completed.
    public func markTranscribed(_ id: UUID, transcript: String) async throws

    /// Entries with audio but no `transcribedAt` — candidates for recovery.
    public func recoverable() async -> [HistoryEntry]

    /// Recent entries, newest first.
    public func list(limit: Int = 50) async -> [HistoryEntry]

    public func delete(_ id: UUID) async throws
    public func purgeAll() async throws

    /// Apply the retention policy. Called on save and on app launch.
    public func enforceRetention() async
}

public struct HistoryEntry: Sendable, Identifiable, Codable {
    public let id: UUID
    public let recordedAt: Date
    public let transcribedAt: Date?
    public let durationSeconds: TimeInterval
    public let transcript: String?
    public let language: String?
    public let modelID: String?
    /// Nil when audio history is off or the recording is transcript-only.
    public let audioURL: URL?
}

public struct RetentionPolicy: Sendable {
    public var maxEntries: Int           // default 50
    public var maxAge: TimeInterval      // default 14 days
    public var maxBytesOnDisk: Int64     // default 500 MB
    public static let `default`: RetentionPolicy
}

Storage layout

One recording = one directory under ~/Library/Application Support/OpenQuack/History/:

History/
  <UUID>/
    meta.json     ~1 KB — recordedAt, transcribedAt, language, modelID,
                          durationSeconds, audioFormat
    transcript.txt — UTF-8, present iff transcribed
    audio.opus    — present iff audio history is on

Behaviour

Pipeline integration in AppDelegate.stopAndTranscribe:

let id = UUID()
if settings.saveAudio {
    try? await history.save(audio: pcm, transcript: "", language: nil,
                            modelID: modelID, durationSeconds: dur)
    // recordedAt set; transcribedAt nil → recoverable if we crash here.
}
let raw = try await transcriber.transcribe(...)
let polished = polish(raw)
if settings.saveTranscripts {
    if settings.saveAudio {
        try? await history.markTranscribed(id, transcript: polished)
    } else {
        try? await history.save(audio: nil, transcript: polished, ...)
    }
}
PasteService.paste(polished)

Failures in history.save MUST NOT block the paste path — a disk-full or permission error degrades to in-memory-only for that turn, with a single banner notification (not per-turn).

enforceRetention runs:

Crash recovery

On applicationDidFinishLaunching, after the status item installs:

  1. history.recoverable() — entries with audioURL != nil and transcribedAt == nil.
  2. If non-empty, show a non-modal popover anchored to the menu-bar icon:

    We found a recording from 2 minutes ago that didn’t finish. [Recover] [Discard] [Show in Finder]

  3. Recover → run the normal transcription pipeline against audioURL, then deliver via paste-at-cursor (or open the conversation panel if the active agent is non-passthrough). The overlay shows the same .transcribing state as a fresh dictation; the user sees a familiar flow, not a recovery-specific UI.
  4. Discardhistory.delete(id) immediately.
  5. Show in Finder → reveal the directory; useful for power users debugging a recurring failure.

If multiple recoverable entries exist (e.g., laptop slept overnight mid-transcribe twice), show the count: “3 recordings to recover.” The popover lists them; recover/discard each individually or “Discard all.”

Edge cases:

Privacy contract

Extending VISION.md’s privacy contract:

  1. Nothing in History/ ever leaves the machine. No sync, no upload, no analytics on transcript content or counts.
  2. Audio is opt-in, sticky, never silently re-enabled. Toggle in Settings → History plus a one-time onboarding step.
  3. At-rest protection. macOS’s volume-level encryption (FileVault, on by default since macOS 10.10) is the binding mechanism: when the user logs out the History/ directory becomes unreadable along with the rest of ~/Library. The iOS-style file-protection attribute (URLResourceKey.fileProtectionKey = .complete) is a silent no-op on macOS; it’s recorded in the implementation under an #if os(iOS) guard so the intent transfers if we ever ship on iPadOS, but it doesn’t bind today. App-level encryption (Keychain + AES) is overkill for a single-user device app and we skip it.
  4. Delete is irreversible — both per-entry and “Delete all.” No soft-delete, no undo. Confirm dialog with explicit copy: “Permanently delete N recordings (X MB). This cannot be undone.”
  5. Recovered audio is not re-saved as a new entry — recovery transcribes in place.

Settings UX

New tab: Settings → History.

Quality gates

Open questions

References