diff --git a/ROADMAP.md b/ROADMAP.md index 8c45ef0..8d5b5fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,7 +27,7 @@ Features that fit the on-device, single-process philosophy but require more code ### Speaker management - **Manual speaker split.** Inverse of merge — split a speaker profile if the user realizes two voices were collapsed. - **Split candidate at naming time.** When the naming prompt's clips reveal that diarization merged two voices into one cluster, let the user split the candidate into multiple `SpeakerProfile`s instead of just discarding it. Today the only options are name (and accept the merged embedding) or discard via "Multiple speakers" (no profile created). A future "Split…" action would let the user assign different names per clip and re-cluster the underlying segments — requires segment-level reassignment and a more involved naming UI. -- **Bulk-delete / archive old speakers.** Speakers with no meeting activity in N months. +- ✅ **Bulk-delete / archive old speakers.** Speakers with no meeting activity in N months — implemented as automatic deletion at launch based on `speakerRetentionDays` setting (default 90 days), configurable in Advanced → Speaker Archive. Set to 0 to disable. ### Pipeline & output - **Alternative output formats.** Out of scope per spec today, but worth re-evaluating: plain `.txt`, `.srt`, VTT for video workflows, or a lightweight HTML with anchors. diff --git a/Sources/HeardCore/AppModel.swift b/Sources/HeardCore/AppModel.swift index 44b11c5..15ee142 100644 --- a/Sources/HeardCore/AppModel.swift +++ b/Sources/HeardCore/AppModel.swift @@ -78,6 +78,9 @@ public final class AppModel: ObservableObject { updateChecker: updateChecker ) + // Archive speaker profiles inactive beyond the configured retention window + speakerStore.archiveInactiveSpeakers(retentionDays: settingsStore.settings.speakerRetentionDays) + // Clean stale recordings (>48h), preserving files referenced by active jobs let activeJobPaths = Set( queueStore.jobs diff --git a/Sources/HeardCore/CoreModels.swift b/Sources/HeardCore/CoreModels.swift index e3bfcf8..07e660d 100644 --- a/Sources/HeardCore/CoreModels.swift +++ b/Sources/HeardCore/CoreModels.swift @@ -359,6 +359,9 @@ public struct AppSettings: Codable, Equatable { /// concurrently, halving peak RAM during the VAD stage (~400 MB instead of ~800 MB). /// Useful on machines with 8 GB unified memory under heavy load. public var lowMemoryMode: Bool + /// How many days of inactivity before a speaker profile is automatically deleted. + /// 0 means never delete automatically. + public var speakerRetentionDays: Int public static let `default` = AppSettings( userName: "", @@ -385,7 +388,8 @@ public struct AppSettings: Codable, Equatable { enableWebexDetection: true, diarizationClusteringSimilarity: 0.65, appearance: .system, - lowMemoryMode: false + lowMemoryMode: false, + speakerRetentionDays: 90 ) public init( @@ -410,7 +414,8 @@ public struct AppSettings: Codable, Equatable { enableWebexDetection: Bool = true, diarizationClusteringSimilarity: Double = 0.65, appearance: AppAppearance = .system, - lowMemoryMode: Bool = false + lowMemoryMode: Bool = false, + speakerRetentionDays: Int = 90 ) { self.userName = userName self.launchAtLogin = launchAtLogin @@ -434,6 +439,7 @@ public struct AppSettings: Codable, Equatable { self.diarizationClusteringSimilarity = diarizationClusteringSimilarity self.appearance = appearance self.lowMemoryMode = lowMemoryMode + self.speakerRetentionDays = speakerRetentionDays } } diff --git a/Sources/HeardCore/Stores.swift b/Sources/HeardCore/Stores.swift index dd0a002..96e9447 100644 --- a/Sources/HeardCore/Stores.swift +++ b/Sources/HeardCore/Stores.swift @@ -165,6 +165,11 @@ public final class SettingsStore: ObservableObject { diarizationClusteringSimilarity = val.doubleValue } + var speakerRetentionDays = base.speakerRetentionDays + if let val = defaults.object(forKey: "speakerRetentionDays") as? NSNumber { + speakerRetentionDays = val.intValue + } + settings = AppSettings( userName: defaults.string(forKey: "userName") ?? base.userName, launchAtLogin: defaults.object(forKey: "launchAtLogin") as? Bool ?? base.launchAtLogin, @@ -184,7 +189,8 @@ public final class SettingsStore: ObservableObject { enableTeamsDetection: defaults.object(forKey: "enableTeamsDetection") as? Bool ?? base.enableTeamsDetection, enableZoomDetection: defaults.object(forKey: "enableZoomDetection") as? Bool ?? base.enableZoomDetection, enableWebexDetection: defaults.object(forKey: "enableWebexDetection") as? Bool ?? base.enableWebexDetection, - diarizationClusteringSimilarity: diarizationClusteringSimilarity + diarizationClusteringSimilarity: diarizationClusteringSimilarity, + speakerRetentionDays: speakerRetentionDays ) } @@ -214,6 +220,7 @@ public final class SettingsStore: ObservableObject { defaults.set(settings.enableZoomDetection, forKey: "enableZoomDetection") defaults.set(settings.enableWebexDetection, forKey: "enableWebexDetection") defaults.set(settings.diarizationClusteringSimilarity, forKey: "diarizationClusteringSimilarity") + defaults.set(settings.speakerRetentionDays, forKey: "speakerRetentionDays") } } @@ -268,6 +275,19 @@ public final class SpeakerStore: ObservableObject { persist() } + /// Deletes speakers whose `lastSeen` is older than `retentionDays` days. + /// Pass 0 to skip archiving entirely. + @discardableResult + public func archiveInactiveSpeakers(retentionDays: Int) -> Int { + guard retentionDays > 0 else { return 0 } + let cutoff = Date().addingTimeInterval(-Double(retentionDays) * 86400) + let stale = speakers.filter { $0.lastSeen < cutoff } + for speaker in stale { + delete(id: speaker.id) + } + return stale.count + } + public func merge(primaryID: UUID, secondaryID: UUID) { guard let primaryIndex = speakers.firstIndex(where: { $0.id == primaryID }), diff --git a/Sources/HeardCore/Views.swift b/Sources/HeardCore/Views.swift index 00b1e56..252df1f 100644 --- a/Sources/HeardCore/Views.swift +++ b/Sources/HeardCore/Views.swift @@ -1569,6 +1569,34 @@ public struct SettingsView: View { } } + sectionGroup("Speaker Archive") { + SettingsCard { + CardRow(isLast: false) { + HStack { + Text("Archive speakers after") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(HeardTheme.Paper.ink) + Spacer() + Picker("", selection: settingsBinding(\.speakerRetentionDays)) { + Text("Never").tag(0) + Text("30 days").tag(30) + Text("60 days").tag(60) + Text("90 days").tag(90) + Text("180 days").tag(180) + Text("1 year").tag(365) + } + .labelsHidden() + .fixedSize() + } + } + CardRow(isLast: true) { + Text("Speaker profiles not seen in any meeting for the selected period are automatically deleted on the next app launch. Their audio clips and embeddings are removed.") + .font(.system(size: 11)) + .foregroundStyle(HeardTheme.Paper.mute) + } + } + } + sectionGroup("Debugging") { SettingsCard { ToggleRow( diff --git a/handoff.md b/handoff.md index 9c49df8..2ae755f 100644 --- a/handoff.md +++ b/handoff.md @@ -144,11 +144,16 @@ The dictation feature captures mic audio, transcribes in real-time, and injects - Run with `swift run HeardTests` ### Persistence -- `SettingsStore`: UserDefaults-backed app settings (includes `dictationEnabled`, `dictationHotkey`) +- `SettingsStore`: UserDefaults-backed app settings (includes `dictationEnabled`, `dictationHotkey`, `speakerRetentionDays`) - `SpeakerStore`: JSON file at `~/Library/Application Support/Heard/speakers.json` — `SpeakerProfile` now carries an optional `audioClipURL` pointing into `speaker_clips/` - `PipelineQueueStore`: JSON file at `~/Library/Application Support/Heard/queue.json` - `~/Library/Application Support/Heard/speaker_clips/`: persistent voice samples for replay (kept beyond the 48-hour `recordings/` cleanup) +### Speaker Archive +- `SpeakerStore.archiveInactiveSpeakers(retentionDays:)` deletes profiles whose `lastSeen` is older than N days (and their audio clips). Called at app launch via `AppModel.bootstrap()`. +- Default is 90 days; configurable via `AppSettings.speakerRetentionDays` (0 = never). UI in Advanced → Speaker Archive (Picker: Never / 30 / 60 / 90 / 180 / 365 days). +- Also fixed a pre-existing extraneous `}` in `Views.swift` that was causing the file to fail to compile (struct `SettingsView` was closing one brace too early, pushing pane-helper functions out of scope). + ## Architecture | Target | Purpose |