Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b795da2
Fix verbose_json incompatibility with OpenAI transcribe models
ojhurst May 29, 2026
d635b53
fix: fall back to the backup cleanup model the moment the main one is…
Jun 28, 2026
2e9e059
fix: address CodeRabbit review (daily persistence, raw-transcript saf…
Jun 28, 2026
087a6d0
fix: address CodeRabbit re-review (RPD detection + skip doomed reques…
Jun 28, 2026
1501e3d
Add preserve-exact-wording toggle
Jul 1, 2026
8224655
Route preserve-exact-wording through verbatim translator
Jul 1, 2026
2003af9
Use verbatim-specific sanitizer for translateVerbatim
Jul 1, 2026
3446322
fix: increase URLSession timeouts for local LLM inference
Jul 1, 2026
d776b7d
Switch context model to qwen/qwen3.6-27b
marcbodea Jul 10, 2026
29bf9e3
Strip think tags before summarizing activity
marcbodea Jul 10, 2026
849b101
Merge pull request #262 from zachlatta/update-default-context-model
marcbodea Jul 10, 2026
4293959
Merge pull request #248 from ChaoticQubit/feat/preserve-exact-wording
marcbodea Jul 10, 2026
a55be95
Merge pull request #246 from pianoandglass/fix/llm-fallback-cooldown-pr
marcbodea Jul 10, 2026
6bc9ad6
fix: gate transcription response format by model capability
marcbodea Jul 10, 2026
40c5bbf
fix: honor per-request timeouts in API transport
marcbodea Jul 10, 2026
abfc058
Merge pull request #218 from ojhurst/fix/transcription-openai-model-c…
marcbodea Jul 10, 2026
7427ca9
Merge pull request #250 from JaPossert/fix/llm-transport-timeouts
marcbodea Jul 10, 2026
1de2c2f
Prepare v1.2.0 release
marcbodea Jul 13, 2026
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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ This project uses semantic versioning for public releases. Use `MAJOR.MINOR.PATC
- `MINOR` changes add user-visible features and improvements.
- `PATCH` changes fix bugs, polish existing behavior, or make small internal improvements.

## [1.2.0] - 2026-07-14

### Added

- A Preserve exact wording option that skips transcript cleanup while still supporting literal translation, voice macros, and Edit Mode.
- An instruction guard that retries or falls back to the literal transcript when cleanup appears to answer a dictated prompt instead of preserving it.
- An option to keep dictations in clipboard-manager history.
- A menu bar action for adding copied words directly to the custom vocabulary while avoiding duplicates.

### Improved

- Post-processing now switches to the fallback model immediately when the primary model is rate-limited, remembers daily limits across restarts, and shows their reset times in Settings.
- The default fallback model now uses Qwen 3.6 27B, and models scheduled for Groq shutdown in July or August 2026 have been removed from the picker.
- Transcription failures now distinguish network outages from slow providers and display clearer errors in the recording overlay.
- The default context model now uses Qwen 3.6 27B and strips reasoning tags from context summaries.
- Local model requests now honor their configured timeouts, and transcription uses response formats compatible with a wider range of OpenAI-style models.
- Permission and update checks use fewer unnecessary background wakeups.

### Fixed

- Fixed a recording overlay resource leak that could leave hidden animations running after the overlay closed.

## [1.1.0] - 2026-06-03

### Added
Expand Down
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ APP_EXECUTABLE = $(MACOS_DIR)/$(APP_NAME)
APP_EXECUTABLE_TARGET := $(subst $(space),\ ,$(APP_EXECUTABLE))

SOURCES = $(shell find Sources -name '*.swift' -type f | LC_ALL=C sort)
TEST_RUNNER = $(BUILD_DIR)/FreeFlowTests
RESOURCES = $(CONTENTS)/Resources
ARCH ?= $(shell uname -m)

Expand All @@ -25,7 +26,7 @@ ICON_SOURCE = Resources/AppIcon-Source.png
ICON_ICNS = Resources/AppIcon.icns
endif

.PHONY: all clean run icon dmg codesign-dmg notarize
.PHONY: all clean run icon dmg codesign-dmg notarize test

all: $(APP_EXECUTABLE_TARGET)

Expand Down Expand Up @@ -68,6 +69,18 @@ endif
@codesign --force --options runtime --sign "$(CODESIGN_IDENTITY)" --entitlements FreeFlow.entitlements "$(APP_BUNDLE)"
@echo "Built $(APP_BUNDLE)"

test: $(TEST_RUNNER)
@$(TEST_RUNNER)

$(TEST_RUNNER): Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift
@mkdir -p "$(BUILD_DIR)"
swiftc \
-parse-as-library \
-o "$(TEST_RUNNER)" \
-sdk $(shell xcrun --show-sdk-path) \
-target $(ARCH)-apple-macosx13.0 \
Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift

icon: $(ICON_ICNS)

$(ICON_ICNS): $(ICON_SOURCE)
Expand Down
23 changes: 17 additions & 6 deletions Sources/AppContextService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct AppContext {
}

final class AppContextService {
static let defaultContextModel = "qwen/qwen3.6-27b"
static let defaultContextPrompt = """
You are a context synthesis assistant for a speech-to-text pipeline.
Given app/window metadata and an optional screenshot, output exactly two sentences that describe what the user is doing right now and the likely writing intent in the current window.
Expand All @@ -53,14 +54,14 @@ Return only two sentences, no labels, no markdown, no extra commentary.
apiKey: String,
baseURL: String = "https://api.groq.com/openai/v1",
customContextPrompt: String = "",
contextModel: String = "meta-llama/llama-4-scout-17b-16e-instruct",
contextModel: String = AppContextService.defaultContextModel,
screenshotMaxDimension: CGFloat = AppContextService.defaultScreenshotMaxDimension
) {
self.apiKey = apiKey
self.baseURL = baseURL
self.customContextPrompt = customContextPrompt
let trimmedModel = contextModel.trimmingCharacters(in: .whitespacesAndNewlines)
self.contextModel = trimmedModel.isEmpty ? "meta-llama/llama-4-scout-17b-16e-instruct" : trimmedModel
self.contextModel = trimmedModel.isEmpty ? Self.defaultContextModel : trimmedModel
self.screenshotMaxDimension = screenshotMaxDimension > 0
? screenshotMaxDimension
: AppContextService.defaultScreenshotMaxDimension
Expand Down Expand Up @@ -278,15 +279,25 @@ Selected text: \(selectedText ?? "None")
return nil
}

let cleaned = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleaned.isEmpty else { return nil }
return (activity: normalizedActivitySummary(cleaned), prompt: fullPrompt)
guard let activity = Self.activitySummary(from: content, model: model) else { return nil }
return (activity: activity, prompt: fullPrompt)
} catch {
return nil
}
}

private func normalizedActivitySummary(_ value: String) -> String {
static func activitySummary(from rawContent: String, model: String) -> String? {
var content = rawContent
if ModelConfiguration.config(for: model).shouldStripThinkTags {
content = ModelConfiguration.stripThinkTags(content)
}

let cleaned = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleaned.isEmpty else { return nil }
return normalizedActivitySummary(cleaned)
}

private static func normalizedActivitySummary(_ value: String) -> String {
let sentences = value
.split(whereSeparator: { $0 == "." || $0 == "。" || $0 == "!" || $0 == "?" })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
Expand Down
102 changes: 93 additions & 9 deletions Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
private let contextScreenshotMaxDimensionStorageKey = "context_screenshot_max_dimension"
private let shortcutStartDelayStorageKey = "shortcut_start_delay"
private let preserveClipboardStorageKey = "preserve_clipboard"
private let preserveExactWordingStorageKey = "preserve_exact_wording"
private let keepDictationInClipboardHistoryStorageKey = "keep_dictation_in_clipboard_history"
private let pressEnterVoiceCommandStorageKey = "press_enter_voice_command_enabled"
private let alertSoundsEnabledStorageKey = "alert_sounds_enabled"
Expand Down Expand Up @@ -275,8 +276,10 @@ final class AppState: ObservableObject, @unchecked Sendable {
("ca", "Catalan")
]
static let defaultPostProcessingModel = "openai/gpt-oss-20b"
static let defaultPostProcessingFallbackModel = "meta-llama/llama-4-scout-17b-16e-instruct"
static let defaultContextModel = "meta-llama/llama-4-scout-17b-16e-instruct"
static let defaultPostProcessingFallbackModel = "qwen/qwen3.6-27b"
static let defaultContextModel = "qwen/qwen3.6-27b"
private static let deprecatedDefaultPostProcessingFallbackModel = "meta-llama/llama-4-scout-17b-16e-instruct"
private static let deprecatedDefaultContextModel = "meta-llama/llama-4-scout-17b-16e-instruct"
private static let trailingPressEnterCommandPattern = try! NSRegularExpression(
pattern: #"(?i)(?:^|[ \t\r\n,;:\-]+)press[ \t\r\n]+enter[\s\p{P}]*$"#
)
Expand Down Expand Up @@ -505,6 +508,12 @@ final class AppState: ObservableObject, @unchecked Sendable {
}
}

@Published var preserveExactWording: Bool {
didSet {
UserDefaults.standard.set(preserveExactWording, forKey: preserveExactWordingStorageKey)
}
}

@Published var keepDictationInClipboardHistory: Bool {
didSet {
UserDefaults.standard.set(keepDictationInClipboardHistory, forKey: keepDictationInClipboardHistoryStorageKey)
Expand Down Expand Up @@ -626,8 +635,10 @@ final class AppState: ObservableObject, @unchecked Sendable {
let transcriptionAPIURL = Self.loadOptionalStoredAPIValue(account: transcriptionAPIURLStorageKey)
let transcriptionAPIKey = Self.loadStoredAPIKey(account: transcriptionAPIKeyStorageKey)
let postProcessingModel = UserDefaults.standard.string(forKey: postProcessingModelStorageKey) ?? Self.defaultPostProcessingModel
let postProcessingFallbackModel = UserDefaults.standard.string(forKey: postProcessingFallbackModelStorageKey) ?? Self.defaultPostProcessingFallbackModel
let contextModel = UserDefaults.standard.string(forKey: contextModelStorageKey) ?? Self.defaultContextModel
let postProcessingFallbackModel = Self.loadStoredPostProcessingFallbackModel(
key: postProcessingFallbackModelStorageKey
)
let contextModel = Self.loadStoredContextModel(key: contextModelStorageKey)
let shortcuts = Self.loadShortcutConfiguration(
holdKey: holdShortcutStorageKey,
toggleKey: toggleShortcutStorageKey,
Expand Down Expand Up @@ -676,6 +687,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
let preserveClipboard = UserDefaults.standard.object(forKey: preserveClipboardStorageKey) == nil
? true
: UserDefaults.standard.bool(forKey: preserveClipboardStorageKey)
let preserveExactWording = UserDefaults.standard.bool(forKey: preserveExactWordingStorageKey)
let keepDictationInClipboardHistory = UserDefaults.standard.bool(forKey: keepDictationInClipboardHistoryStorageKey)
let realtimeStreamingEnabled = UserDefaults.standard.bool(forKey: realtimeStreamingEnabledStorageKey)
let realtimeStreamingModel = UserDefaults.standard.string(forKey: realtimeStreamingModelStorageKey) ?? ""
Expand Down Expand Up @@ -750,6 +762,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
self.outputLanguage = outputLanguage
self.shortcutStartDelay = shortcutStartDelay
self.preserveClipboard = preserveClipboard
self.preserveExactWording = preserveExactWording
self.keepDictationInClipboardHistory = keepDictationInClipboardHistory
self.realtimeStreamingEnabled = realtimeStreamingEnabled
self.realtimeStreamingModel = realtimeStreamingModel
Expand Down Expand Up @@ -860,6 +873,34 @@ final class AppState: ObservableObject, @unchecked Sendable {
return defaultAPIBaseURL
}

private static func loadStoredContextModel(key: String) -> String {
guard let stored = UserDefaults.standard.string(forKey: key) else {
return defaultContextModel
}

let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == deprecatedDefaultContextModel {
UserDefaults.standard.set(defaultContextModel, forKey: key)
return defaultContextModel
}

return trimmed.isEmpty ? defaultContextModel : trimmed
}

private static func loadStoredPostProcessingFallbackModel(key: String) -> String {
guard let stored = UserDefaults.standard.string(forKey: key) else {
return defaultPostProcessingFallbackModel
}

let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == deprecatedDefaultPostProcessingFallbackModel {
UserDefaults.standard.set(defaultPostProcessingFallbackModel, forKey: key)
return defaultPostProcessingFallbackModel
}

return trimmed.isEmpty ? defaultPostProcessingFallbackModel : trimmed
}

private static func loadShortcutConfiguration(
holdKey: String,
toggleKey: String,
Expand Down Expand Up @@ -1182,7 +1223,8 @@ final class AppState: ObservableObject, @unchecked Sendable {
postProcessingService: postProcessingService,
customVocabulary: capturedCustomVocabulary,
customSystemPrompt: capturedCustomSystemPrompt,
outputLanguage: self.outputLanguage
outputLanguage: self.outputLanguage,
preserveExactWording: self.preserveExactWording
)
finalTranscript = result.finalTranscript
processingStatus = Self.statusMessage(
Expand Down Expand Up @@ -2426,6 +2468,9 @@ final class AppState: ObservableObject, @unchecked Sendable {
case voiceMacro(command: String)
case postProcessingSucceeded
case postProcessingFailedFallback
case preservedExactWording
case preservedExactWordingTranslated
case preservedExactWordingTranslationFailedFallback
case commandModeSucceeded(invocation: CommandInvocation)
case commandModeFailedFallback(invocation: CommandInvocation)

Expand All @@ -2441,6 +2486,12 @@ final class AppState: ObservableObject, @unchecked Sendable {
return isRetry
? "Post-processing failed on retry, using raw transcript"
: "Post-processing failed, using raw transcript"
case .preservedExactWording:
return "Preserved exact wording, skipped post-processing"
case .preservedExactWordingTranslated:
return "Preserved exact wording, translated to output language"
case .preservedExactWordingTranslationFailedFallback:
return "Verbatim translation failed, using untranslated raw transcript"
case .commandModeSucceeded(let invocation):
return "Edit mode succeeded (\(invocation.rawValue))"
case .commandModeFailedFallback(let invocation):
Expand All @@ -2456,7 +2507,8 @@ final class AppState: ObservableObject, @unchecked Sendable {
postProcessingService: PostProcessingService,
customVocabulary: String,
customSystemPrompt: String,
outputLanguage: String = ""
outputLanguage: String = "",
preserveExactWording: Bool
) async -> (finalTranscript: String, outcome: TranscriptProcessingOutcome, prompt: String) {
let trimmedRawTranscript = rawTranscript.trimmingCharacters(in: .whitespacesAndNewlines)

Expand Down Expand Up @@ -2484,7 +2536,37 @@ final class AppState: ObservableObject, @unchecked Sendable {
os_log(.info, log: recordingLog, "Voice macro triggered: %{public}@", macro.command)
return (macro.payload, .voiceMacro(command: macro.command), "")
}


// Preserve-exact-wording mode. Two sub-cases so translation
// stays honored:
//
// 1. No Output Language set — skip the LLM entirely and
// return the raw transcript verbatim.
// 2. Output Language IS set — route through a stripped-down
// translate-only prompt. The user asked for another
// language; silently dropping translation defeats their
// settings. The translate-only path preserves filler,
// informal wording, and profanity 1:1 while still hitting
// the target language.
if preserveExactWording {
let targetLanguage = outputLanguage.trimmingCharacters(in: .whitespacesAndNewlines)
if targetLanguage.isEmpty {
return (trimmedRawTranscript, .preservedExactWording, "")
}
do {
let result = try await postProcessingService.translateVerbatim(
transcript: trimmedRawTranscript,
targetLanguage: targetLanguage
)
return (result.transcript, .preservedExactWordingTranslated, result.prompt)
} catch {
os_log(.error, log: recordingLog,
"Verbatim translation failed: %{public}@",
error.localizedDescription)
return (trimmedRawTranscript, .preservedExactWordingTranslationFailedFallback, "")
}
}

do {
let result = try await postProcessingService.postProcess(
transcript: trimmedRawTranscript,
Expand Down Expand Up @@ -2652,7 +2734,8 @@ final class AppState: ObservableObject, @unchecked Sendable {
postProcessingService: postProcessingService,
customVocabulary: self.customVocabulary,
customSystemPrompt: self.customSystemPrompt,
outputLanguage: self.outputLanguage
outputLanguage: self.outputLanguage,
preserveExactWording: self.preserveExactWording
)
try Task.checkCancellation()

Expand Down Expand Up @@ -2699,7 +2782,8 @@ final class AppState: ObservableObject, @unchecked Sendable {

let shouldPersistRawDictationFallback: Bool
switch result.outcome {
case .postProcessingFailedFallback:
case .postProcessingFailedFallback,
.preservedExactWordingTranslationFailedFallback:
shouldPersistRawDictationFallback = !trimmedFinalTranscript.isEmpty
default:
shouldPersistRawDictationFallback = false
Expand Down
31 changes: 20 additions & 11 deletions Sources/LLMAPITransport.swift
Original file line number Diff line number Diff line change
@@ -1,32 +1,41 @@
import Foundation

enum LLMAPITransport {
private static let requestSession: URLSession = {
makeEphemeralSession()
}()

private static func makeEphemeralSession() -> URLSession {
private static func makeEphemeralSession(timeout: TimeInterval) -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
configuration.timeoutIntervalForRequest = 20
configuration.timeoutIntervalForResource = 30
// URLSession's resource timeout is session-scoped, while each caller
// already puts its configured timeout on the URLRequest. Keep both
// session timers aligned with that request instead of applying one
// global timeout to every provider and operation.
configuration.timeoutIntervalForRequest = timeout
configuration.timeoutIntervalForResource = timeout
return URLSession(configuration: configuration)
}

private static func timeout(for request: URLRequest) -> TimeInterval {
let requestTimeout = request.timeoutInterval
guard requestTimeout.isFinite, requestTimeout > 0 else {
return 60
}
return requestTimeout
}

static func data(
for request: URLRequest
) async throws -> (Data, URLResponse) {
try await requestSession.data(for: request)
let session = makeEphemeralSession(timeout: timeout(for: request))
defer { session.finishTasksAndInvalidate() }
return try await session.data(for: request)
}

static func upload(
for request: URLRequest,
from bodyData: Data
) async throws -> (Data, URLResponse) {
// Use a fresh session for each upload so a bad reused connection cannot
// poison subsequent transcription uploads.
let session = makeEphemeralSession()
// Fresh session per upload — no poisoned connection re-use.
let session = makeEphemeralSession(timeout: timeout(for: request))
defer { session.finishTasksAndInvalidate() }
return try await session.upload(for: request, from: bodyData)
}
Expand Down
Loading