Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions Sources/Bark/CompositionRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,20 @@ enum CompositionRoot {
let stt: STTEngine = SpeechAnalyzerEngine() // Apple on-device, macOS 26
let history: HistoryStore = EncryptedHistoryStore()

// Smart Replies (009): read the focused app on-device, only when opted in.
let contextProvider: ContextProvider = AccessibilityContextReader()

let llm: TextCleaner?
let branchSuggester: BranchSuggester?
#if MLXCleanup
llm = MLXTextCleaner(modelID: "mlx-community/Qwen3-4B-Instruct-2507-4bit")
// One shared model: cleanup rewrite + reply suggestions reuse a single
// Qwen3-4B download and in-memory container.
let host = MLXModelHost(modelID: "mlx-community/Qwen3-4B-Instruct-2507-4bit")
llm = MLXTextCleaner(host: host)
branchSuggester = MLXBranchSuggester(host: host)
#else
llm = nil // LLM rewrite modes fall back to the deterministic cleaner
llm = nil // LLM rewrite modes fall back to the deterministic cleaner
branchSuggester = nil // Smart Replies falls back to deterministic quick replies
#endif

return DictationController(
Expand All @@ -32,7 +41,9 @@ enum CompositionRoot {
stt: stt,
handsFreeHotkey: handsFreeHotkey,
llmCleaner: llm,
history: history
history: history,
branchSuggester: branchSuggester,
contextProvider: contextProvider
)
}
}
115 changes: 112 additions & 3 deletions Sources/Bark/DictationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ public final class DictationController {
public private(set) var isReinserting = false // serializes one-click re-insert (Codex)
public private(set) var inputLevel: Float = 0 // 0...1 smoothed mic level for the HUD meter

// Smart Replies (009): context-aware reply options for mid-session prompts.
public private(set) var branchOptions: [BranchOption] = []
public private(set) var branchSuggesting = false // LLM suggestion in flight
public private(set) var branchUsedLLM = false // current options came from the model
public private(set) var branchNotice: String? // non-fatal status (no context / fallback)

/// Side-channel callbacks wired by the app layer (AppKit windows/HUD).
public var onPhaseChange: (@MainActor (DictationPhase) -> Void)?
public var onOpenSettings: (@MainActor () -> Void)?
Expand All @@ -49,6 +55,8 @@ public final class DictationController {
private let keystrokeInjector: TextInjector
private let clipboardInjector: TextInjector
private let history: HistoryStore?
private let branchSuggester: BranchSuggester?
private let contextProvider: ContextProvider?
private let cleanupDeadline: Double
private let targetProvider: @MainActor () -> InjectionTarget?

Expand All @@ -64,6 +72,7 @@ public final class DictationController {
private var llmTask: Task<Void, Never>?
private var handsFreeTask: Task<Void, Never>?
private var handsFreeAudio: AudioCapturing?
private var branchContext: ConversationContext? // context read for the open menu; never persisted

public init(
settings: SettingsStore,
Expand All @@ -73,6 +82,8 @@ public final class DictationController {
handsFreeHotkey: HotkeyManager = HotkeyManager(),
llmCleaner: TextCleaner?,
history: HistoryStore? = nil,
branchSuggester: BranchSuggester? = nil,
contextProvider: ContextProvider? = nil,
audioFactory: @escaping @Sendable () -> AudioCapturing = { AudioCaptureEngine() },
pasteInjector: TextInjector = PasteboardInjector(),
keystrokeInjector: TextInjector = KeystrokeInjector(),
Expand All @@ -87,6 +98,8 @@ public final class DictationController {
self.stt = stt
self.llmCleaner = llmCleaner
self.history = history
self.branchSuggester = branchSuggester
self.contextProvider = contextProvider
self.audioFactory = audioFactory
self.pasteInjector = pasteInjector
self.keystrokeInjector = keystrokeInjector
Expand Down Expand Up @@ -280,19 +293,28 @@ public final class DictationController {
/// or another re-insert is in flight. (007 / ADV-004)
public func reinsert(_ record: HistoryRecord) async {
guard !phase.isActive, !isReinserting else { return }
isReinserting = true
defer { isReinserting = false }
await performTargetedInsert(record.output)
}

/// Type `text` into the app captured by `snapshotReinsertTarget` (re-insert /
/// Smart Replies). Re-verifies that app is still frontmost via the injector's
/// preflight (pid compare), sanitizes, honours the secure-field guard +
/// output routing, and never synthesizes Return. Callers serialize via
/// `isReinserting`. (007 / 009 / ADV-004)
private func performTargetedInsert(_ text: String) async {
guard let target = reinsertTarget else {
lastError = Self.injectionMessage(InjectionError.focusChanged); return
}
let sanitized = TextSanitizer.sanitize(
record.output,
text,
options: .init(allowNewlines: !target.isTerminal, stripTrailingNewlines: true)
)
guard !sanitized.isEmpty else { return }
let strategy = InjectionRouter.strategy(routing: settings.settings.outputRouting,
isTerminal: target.isTerminal)
let plan = InjectionPlan(target: target, strategy: strategy, stripTrailingNewlines: true)
isReinserting = true
defer { isReinserting = false }
do {
try await injector(for: strategy).inject(sanitized, plan: plan)
lastResult = sanitized
Expand All @@ -302,6 +324,93 @@ public final class DictationController {
}
}

// MARK: - Smart Replies (009)

/// True when an LLM reply-suggester is compiled into this build (MLX build).
public var branchSuggesterPresent: Bool { branchSuggester != nil }

public var smartRepliesEnabled: Bool {
get { settings.settings.smartRepliesEnabled }
set {
settings.update { $0.smartRepliesEnabled = newValue }
if !newValue { clearBranchSuggestions() } // stop holding any read context
}
}

/// Called when the Smart Replies UI appears: snapshot the target app (so a
/// later pick lands there, not in Bark's popover — same pattern as re-insert),
/// read its latest message, and publish the instant deterministic quick
/// replies. The read context is held only until the menu closes.
public func prepareBranchContext() async {
clearBranchSuggestions()
guard smartRepliesEnabled, !phase.isActive else { return }
snapshotReinsertTarget()
guard let provider = contextProvider,
let context = await provider.currentContext(),
!context.lastMessage.isEmpty else {
branchNotice = "No reply context found in the focused app."
return
}
branchContext = context
branchOptions = BasicBranchSuggester.suggestions(for: context)
}

/// Whether we have a read context to suggest replies from (menu is showing
/// options for a real focused-app message).
public var hasBranchContext: Bool { branchContext != nil }

/// Whether the model is enabled, present, and ready to generate AI replies.
public var canSuggestWithLLM: Bool {
smartRepliesEnabled && settings.settings.llmEnabled
&& branchSuggesterPresent && llmStatus == .ready
}

/// Replace the quick replies with the model's most-likely replies. Runs under
/// the same hard deadline as cleanup and falls back (keeps the quick replies)
/// on timeout/failure/empty — never blocks. (Principle V)
public func requestLLMSuggestions() async {
guard smartRepliesEnabled, !branchSuggesting, let context = branchContext else { return }
guard settings.settings.llmEnabled, let suggester = branchSuggester, await suggester.isAvailable else {
branchNotice = "Enable the LLM (Settings → General) for AI suggestions."
return
}
branchSuggesting = true
branchNotice = nil
defer { branchSuggesting = false }
do {
let options = try await withThrowingDeadline(seconds: cleanupDeadline) {
try await suggester.suggest(for: context, maxOptions: 4)
}
guard !options.isEmpty else {
branchNotice = "No AI suggestions — using quick replies."
return
}
branchOptions = options
branchUsedLLM = true
} catch {
branchNotice = "Couldn't generate AI suggestions — using quick replies."
BarkLog.cleanup.error("branch suggest failed: \(String(describing: error), privacy: .public)")
}
}

/// Type a chosen reply into the snapshotted app. Does NOT submit (no Return —
/// auto-submit is out of scope, Principle IV). Serialized with re-insert.
public func chooseBranch(_ option: BranchOption) async {
guard !phase.isActive, !isReinserting else { return }
isReinserting = true
await performTargetedInsert(option.payload)
isReinserting = false
clearBranchSuggestions()
}

/// Drop any suggestions and the read context (called on dismiss / disable).
public func clearBranchSuggestions() {
branchOptions = []
branchUsedLLM = false
branchNotice = nil
branchContext = nil
}

public func purgeHistory() async {
try? await history?.purge()
}
Expand Down
69 changes: 69 additions & 0 deletions Sources/Bark/UI/MenuContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ struct MenuContentView: View {
.fixedSize(horizontal: false, vertical: true)
}

if controller.smartRepliesEnabled && !controller.phase.isActive {
SmartRepliesSection(controller: controller)
}

controlButton

Button {
Expand Down Expand Up @@ -163,6 +167,71 @@ struct RecentMenu: View {
}
}

/// Context-aware reply options for mid-session prompts (009). On appear it reads
/// the focused app's latest message and shows instant quick replies; "AI
/// suggestions" asks the on-device model for the most likely replies. Picking one
/// types it into the app you were in — it never presses Return. None fit? Dictate
/// a custom reply with the normal hotkey.
struct SmartRepliesSection: View {
@Bindable var controller: DictationController

var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Label("Smart Replies", systemImage: "bubble.left.and.bubble.right")
.font(.subheadline.bold())
Spacer()
if controller.branchSuggesting { ProgressView().controlSize(.small) }
}

if controller.branchOptions.isEmpty {
Text(controller.branchNotice ?? "Reading the focused app…")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
ForEach(controller.branchOptions) { option in
Button {
Task { await controller.chooseBranch(option) }
} label: {
Text(option.label)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.bordered)
.disabled(controller.isReinserting)
}
if let notice = controller.branchNotice {
Text(notice).font(.caption2).foregroundStyle(.secondary)
}
}

HStack {
if controller.branchSuggesterPresent {
Button {
Task { await controller.requestLLMSuggestions() }
} label: {
Label("AI suggestions", systemImage: "sparkles")
}
.buttonStyle(.borderless)
.font(.caption)
.disabled(!controller.canSuggestWithLLM || controller.branchSuggesting
|| !controller.hasBranchContext)
}
Spacer()
Text("Hold fn for a custom reply")
.font(.caption2).foregroundStyle(.secondary)
}
}
.padding(8)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
.task {
// Snapshot the user's app + read context BEFORE they interact with the
// popover, so a pick targets that app and not Bark (Codex/ADV-004).
await controller.prepareBranchContext()
}
}
}

struct PermissionsBanner: View {
@Bindable var controller: DictationController

Expand Down
15 changes: 15 additions & 0 deletions Sources/Bark/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,19 @@ private struct GeneralPane: View {
.font(.caption).foregroundStyle(.secondary)
}
}
Section("Smart Replies") {
Toggle("Suggest replies from the focused app", isOn: $controller.smartRepliesEnabled)
Text("Off by default. When on, opening the Bark menu reads the latest message in the "
+ "app you're using (on-device, via Accessibility) and offers tappable replies. "
+ "Picking one types it in — Bark never presses Return. AI suggestions also "
+ "require the LLM above; otherwise you get instant quick replies.")
.font(.caption).foregroundStyle(.secondary)
if controller.smartRepliesEnabled && !controller.branchSuggesterPresent {
Text("This build ships without the on-device LLM, so Smart Replies offers "
+ "deterministic quick replies (Yes/No and common follow-ups) only.")
.font(.caption).foregroundStyle(.secondary)
}
}
Section("Output") {
Picker("When dictation ends", selection: $controller.outputRouting) {
ForEach(OutputRouting.allCases) { Text($0.label).tag($0) }
Expand Down Expand Up @@ -472,6 +485,8 @@ private struct PrivacyPane: View {
Label("Never presses Return (won't run terminal commands)", systemImage: "terminal")
Label("Restores your clipboard after pasting", systemImage: "doc.on.clipboard")
Label("History is off by default, encrypted when on", systemImage: "lock.doc")
Label("Smart Replies reads the focused app on-device, only when enabled",
systemImage: "bubble.left.and.bubble.right")
}
}
.formStyle(.grouped)
Expand Down
50 changes: 50 additions & 0 deletions Sources/BarkCleanupMLX/MLXBranchSuggester.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation
import BarkCore

#if MLXCleanup

/// LLM reply-suggestion backend (009). Shares the cleanup model via `MLXModelHost`
/// so enabling the LLM loads Qwen3-4B once for both skills.
///
/// Each call is a fresh, stateless turn: the read context is fenced as untrusted
/// data inside an injection-safe prompt (`BranchPromptTemplate`) and the model is
/// told to *propose replies*, not act. `parse` bounds count and length; the caller
/// falls back to the deterministic quick replies on any failure.
public struct MLXBranchSuggester: BranchSuggester {
private let host: MLXModelHost

public init(host: MLXModelHost) {
self.host = host
}

public var isAvailable: Bool {
get async { await host.isLoaded }
}

public func prepare(progress: @escaping @Sendable (Double) -> Void) async throws {
try await host.prepare(progress: progress)
}

public func suggest(for context: ConversationContext, maxOptions: Int) async throws -> [BranchOption] {
let bounded = context.bounded()
let response = try await host.respond(
instructions: BranchPromptTemplate.system(maxOptions: maxOptions),
to: BranchPromptTemplate.user(context: bounded)
)
return BranchPromptTemplate.parse(response, maxOptions: maxOptions)
}
}

#else

/// Stub compiled when the MLX engine is disabled (the default). Always
/// unavailable, so Smart Replies uses the deterministic `BasicBranchSuggester`.
public struct MLXBranchSuggester: BranchSuggester {
public init(host: MLXModelHost = MLXModelHost()) {}
public var isAvailable: Bool { get async { false } }
public func suggest(for context: ConversationContext, maxOptions: Int) async throws -> [BranchOption] {
throw CleanupError.modelUnavailable
}
}

#endif
Loading