Status: draft (M3)
Owner: OpenQuackKit/History/
Last updated: 2026-04-30
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.
The central design call: SPEC-001 deliberately keeps audio in memory. Persisting it shifts the privacy posture. We split the toggle:
~/Library/Application Support/OpenQuack/History/ adds no new leak
surface and unlocks recall. ~1 KB per entry.Consequence: transcript recall is the universal benefit; crash-recovery is opt-in. Revisit if user signal disagrees.
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
}
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
AVAssetWriter. ~180 KB/min
vs. SPEC-001’s raw 1.4 MB/min — 8× smaller, transparent at speech
bitrates. Whisper re-decodes from the file at recovery time; we
measure no detectable WER delta vs. raw WAV in bench.cat, grep,
or mdfind their own dictations from the terminal. Privacy posture:
the user owns the data, including its readability.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:
save (cheap — directory list + a few stats).applicationDidFinishLaunching.recordedAt.On applicationDidFinishLaunching, after the status item installs:
history.recoverable() — entries with audioURL != nil and
transcribedAt == nil.We found a recording from 2 minutes ago that didn’t finish. [Recover] [Discard] [Show in Finder]
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.history.delete(id) immediately.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:
Extending VISION.md’s privacy contract:
~/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.New tab: Settings → History.
openquack-bench as a smoke.audio.opus file exists in History/ after a
dictation cycle.CSSearchableItem (transcripts only, opt-in) or in-app fuzzy search
is a follow-up if users ask.rootURL override in Settings → Advanced; the
user owns that privacy implication.URLResourceKey.fileProtectionKey / .complete — Apple docs.AVAssetWriter Opus encoding — kAudioFormatOpus, mono 16 kHz, 24 kbps.