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
13 changes: 13 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,19 @@ before marking a secure field blocked; that lower-level acquisition remains sepa
Endpoint credentials are stored in Keychain. A remote endpoint receives its bounded, legacy-scope
request; its privacy scope must remain visible in settings and documentation.

Typing history (Settings → Context → Typing History) is the one store of the user's writing that
outlives its field. Both of its switches are off by default. When recording is on,
`TypingHistoryStore` keeps the text of fields where Cotabby is active (never secure fields, disabled
or excluded apps, or while paused), scrubs secret-like tokens (`TypingHistoryScrubber`), and seals
the archive with AES-GCM under a Keychain key that never syncs (`TypingHistoryVault`). Delete All
removes the file and the key. A Cotypist `user_inputs.json` export can be imported. Only text that
was before the caret is learned from, because the rest of a field is often a quoted thread. History
shapes suggestions in two ways: `TypingHistoryIndex` adds two short passages of similar past writing
to the prompt, and `TypingHistoryPhraseEngine` answers from `TypingHistoryPhrasePredictor` when
history confidently knows how a phrase ends. Both are on-device only: the provider returns nothing
for the endpoint, the request factory drops examples for it, and the router refuses to send any
request that still carries them.

## Presentation and Sibling Features

[SuggestionOverlayPresenter.swift](Cotabby/Services/Suggestion/SuggestionOverlayPresenter.swift)
Expand Down
124 changes: 124 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion Cotabby/App/Coordinators/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
private let systemMetricsStore: SystemMetricsStore
private let onShowWelcome: () -> Void
private let clearEmojiHistory: () -> Void
private let typingHistoryStore: TypingHistoryStore

private var settingsWindowController: NSWindowController?

Expand All @@ -49,7 +50,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
qualityMetricsStore: SuggestionQualityMetricsStore,
systemMetricsStore: SystemMetricsStore,
onShowWelcome: @escaping () -> Void,
clearEmojiHistory: @escaping () -> Void
clearEmojiHistory: @escaping () -> Void,
typingHistoryStore: TypingHistoryStore
) {
self.appUpdateManager = appUpdateManager
self.permissionManager = permissionManager
Expand All @@ -65,6 +67,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
self.systemMetricsStore = systemMetricsStore
self.onShowWelcome = onShowWelcome
self.clearEmojiHistory = clearEmojiHistory
self.typingHistoryStore = typingHistoryStore
}

/// Shows the settings window, reusing the existing instance if it is already open.
Expand Down Expand Up @@ -94,6 +97,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
systemMetricsStore: systemMetricsStore,
onShowWelcome: onShowWelcome,
clearEmojiHistory: clearEmojiHistory,
typingHistoryStore: typingHistoryStore,
onQuit: { NSApplication.shared.terminate(nil) }
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ extension SuggestionCoordinator {
let request = SuggestionRequestFactory.buildRequest(context: context, settings: settingsSnapshot,
configuration: configuration, clipboardContext: pinnedClipboardContext(rawContext: rawContext),
visualContextSummary: permissionManager.screenRecordingGranted
? visualContextCoordinator.excerpt(for: session.baseContext) : nil).request
? visualContextCoordinator.excerpt(for: session.baseContext) : nil,
historyExamples: historyExamples(for: context)).request
continuationWorkController.replaceDebouncedWork(delayMilliseconds: 0) { [weak self] workID in
guard let self else { return }
await self.awaitCachedGenerationContextResetIfNeeded()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ extension SuggestionCoordinator {
let request = SuggestionRequestFactory.buildRequest(
context: prewarmContext,
settings: settings,
configuration: configuration
configuration: configuration,
historyExamples: self.historyExamples(for: prewarmContext)
).request
await suggestionEngine.prewarm(for: request)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ extension SuggestionCoordinator {
settings: settingsSnapshot,
configuration: configuration,
clipboardContext: clipboardContext,
visualContextSummary: visualContextSummary
visualContextSummary: visualContextSummary,
historyExamples: historyExamples(for: context)
)
latestGenerationNumber = context.generation
let request = requestBuildResult.request
Expand Down Expand Up @@ -288,7 +289,8 @@ extension SuggestionCoordinator {
settings: settingsSnapshot,
configuration: configuration,
clipboardContext: clipboardContext,
visualContextSummary: visualContextSummary
visualContextSummary: visualContextSummary,
historyExamples: historyExamples(for: context)
)
latestGenerationNumber = context.generation
let request = requestBuildResult.request
Expand Down Expand Up @@ -359,7 +361,8 @@ extension SuggestionCoordinator {
clipboardContext: pinnedClipboardContext(rawContext: optimistic),
visualContextSummary: permissionManager.screenRecordingGranted
? visualContextCoordinator.excerpt(for: context)
: nil
: nil,
historyExamples: historyExamples(for: context)
)
let request = requestBuildResult.request
let suggestionEngine = suggestionEngine
Expand Down
13 changes: 13 additions & 0 deletions Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ final class SuggestionCoordinator: ObservableObject {
/// coordinator continues to own the timer and input-monitor effects around these transitions.
var postExhaustionAcceptanceState = PostExhaustionAcceptanceState()

/// The user's typing history, when the app has one. Optional so test rigs and previews run
/// without it; the provider itself returns nothing while history is turned off.
let historyProvider: (any SuggestionHistoryProviding)?

/// Examples of the user's past writing for this field, for every request built from it.
/// Every request kind (ordinary, speculative, continuation, prewarm) passes the same examples so
/// their prompts share one head and the llama KV cache stays reusable between them.
func historyExamples(for context: FocusedInputContext) -> [String] {
historyProvider?.historyExamples(for: context, engine: settingsSnapshot.selectedEngine) ?? []
}

init(
permissionManager: any SuggestionPermissionProviding,
lowPowerModeProvider: any SuggestionLowPowerModeProviding,
Expand All @@ -193,6 +204,7 @@ final class SuggestionCoordinator: ObservableObject {
symSpellCorrector: SymSpellCorrector,
spellingLanguageResolver: SpellingLanguageResolver = SpellingLanguageResolver(),
qualityMetricsStore: SuggestionQualityMetricsStore,
historyProvider: (any SuggestionHistoryProviding)? = nil,
userDefaults: UserDefaults = .standard
) {
let storedTotalTabAcceptedWordCount = userDefaults.integer(
Expand All @@ -216,6 +228,7 @@ final class SuggestionCoordinator: ObservableObject {
self.symSpellCorrector = symSpellCorrector
self.spellingLanguageResolver = spellingLanguageResolver
self.qualityMetricsStore = qualityMetricsStore
self.historyProvider = historyProvider
self.userDefaults = userDefaults
settingsSnapshot = suggestionSettings.snapshot
// These collaborators isolate "how overlay/logging works" from "when the coordinator
Expand Down
2 changes: 2 additions & 0 deletions Cotabby/App/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
activationIndicatorController.hide(reason: "Activation indicator hidden because Cotabby is terminating.")
focusDebugOverlayController?.hide()
suggestionCoordinator.stop()
// Write the field being typed in now; the debounced background save may not have run yet.
environment.typingHistoryStore.flush()
inlineCommandCoordinator.stop()
inputMonitor.stop()
focusModel.stop()
Expand Down
37 changes: 33 additions & 4 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class CotabbyAppEnvironment {
let huggingFaceSearchService: HuggingFaceSearchService
let performanceMetricsStore: PerformanceMetricsStore
let qualityMetricsStore: SuggestionQualityMetricsStore
let typingHistoryStore: TypingHistoryStore
let settingsCoordinator: SettingsCoordinator
let activationIndicatorController: ActivationIndicatorController
let focusDebugOverlayController: FocusDebugOverlayController?
Expand Down Expand Up @@ -223,9 +224,20 @@ final class CotabbyAppEnvironment {
)
// Under `-cotabby-debug` with `cotabbyDebugForcedSuggestion` set, every request answers with
// that fixed text so ghost placement can be measured deterministically without a model.
// Typing history owns its own encrypted archive. Inside the XCTest host it starts empty and
// never opens the real archive or Keychain item, so tests cannot read or overwrite it.
let isTestHost = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
let typingHistoryStore = TypingHistoryStore(loadsArchive: !isTestHost)
// Phrase shortcuts answer from history before the router runs. The live engine kind is
// read per request so a power-source switch to the endpoint stops shortcuts immediately.
let historyAwareEngine = TypingHistoryPhraseEngine(
wrapping: routedEngine,
history: typingHistoryStore,
engineKind: { [weak suggestionSettings] in suggestionSettings?.selectedEngine ?? .openAICompatible }
)
let suggestionEngine: any SuggestionGenerating = DebugForcedSuggestionEngine.isConfigured()
? DebugForcedSuggestionEngine(wrapping: routedEngine)
: routedEngine
? DebugForcedSuggestionEngine(wrapping: historyAwareEngine)
: historyAwareEngine

// Per-user emoji recents/frequency. Built before the settings coordinator so the
// "Clear History" control can reach it, and before the picker which reads and writes it.
Expand All @@ -247,7 +259,8 @@ final class CotabbyAppEnvironment {
onShowWelcome: { [weak welcomeCoordinator] in
welcomeCoordinator?.showWelcome()
},
clearEmojiHistory: { emojiUsageStore.clear() }
clearEmojiHistory: { emojiUsageStore.clear() },
typingHistoryStore: typingHistoryStore
)

let interactionState = SuggestionInteractionState()
Expand Down Expand Up @@ -282,7 +295,8 @@ final class CotabbyAppEnvironment {
spellChecker: spellChecker,
symSpellCorrector: symSpellCorrector,
spellingLanguageResolver: SpellingLanguageResolver(),
qualityMetricsStore: qualityMetricsStore
qualityMetricsStore: qualityMetricsStore,
historyProvider: typingHistoryStore
)

// The emoji picker is a sibling to the suggestion coordinator. It reuses the input monitor,
Expand Down Expand Up @@ -352,12 +366,27 @@ final class CotabbyAppEnvironment {
self.huggingFaceSearchService = huggingFaceSearchService
self.performanceMetricsStore = performanceMetricsStore
self.qualityMetricsStore = qualityMetricsStore
self.typingHistoryStore = typingHistoryStore
self.settingsCoordinator = settingsCoordinator
self.activationIndicatorController = activationIndicatorController
self.focusDebugOverlayController = CotabbyDebugOptions.areOverlaysAvailable
? FocusDebugOverlayController()
: nil

// Recording reads every focus snapshot; the store ignores them unless recording is on and
// the text changed. Cotabby's own gates (globally on, not paused, app not disabled) decide
// where recording may happen, so history is only collected where Cotabby is active.
focusModel.$snapshot
.sink { [weak typingHistoryStore, weak suggestionSettings] snapshot in
guard let typingHistoryStore, let suggestionSettings else { return }
typingHistoryStore.observe(snapshot) {
let settings = suggestionSettings.snapshot
return settings.isGloballyEnabled && !settings.isTemporarilyPaused
&& !(snapshot.bundleIdentifier.map(settings.disabledAppBundleIdentifiers.contains) ?? false)
}
}
.store(in: &cancellables)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Update the AX polling timer whenever the user changes the poll interval setting.
suggestionSettings.$focusPollIntervalMilliseconds
.removeDuplicates()
Expand Down
72 changes: 72 additions & 0 deletions Cotabby/Models/History/TypingHistoryModels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation

/// File overview:
/// Value types for Cotabby's typing history: the text the user has written in fields Cotabby was
/// active in (recorded on this Mac, or imported from another autocomplete app), and the user's
/// preferences for collecting and using it.
///
/// Why a separate subsystem: history is the only Cotabby data that outlives the field it came from.
/// Everything else in a request (caret text, clipboard, screen) is ephemeral. Keeping these values
/// apart from `SuggestionSettingsModel` lets the history store own its own storage, encryption, and
/// lifecycle, while the suggestion pipeline only sees a narrow read contract
/// (`SuggestionHistoryProviding`).

/// One field's worth of the user's writing.
///
/// A record is updated in place while the user keeps typing in the same field, so a long email is
/// one record rather than one per keystroke. `text` is already scrubbed of secret-like tokens
/// (`TypingHistoryScrubber`) before it is stored.
nonisolated struct TypingHistoryRecord: Codable, Equatable, Sendable, Identifiable {
enum Source: String, Codable, Sendable {
/// Captured by Cotabby while the user typed.
case recorded
/// Brought in from another app's export (for example Cotypist).
case imported
}

let id: UUID
let bundleIdentifier: String
/// Registrable web domain for browser fields ("claude.ai"), nil for native apps.
let domain: String?
let createdAt: Date
var updatedAt: Date
var text: String
let source: Source
/// How many characters at the start of `text` were before the caret when it was captured.
/// Text after the caret is usually not the user's: in an email reply it is the quoted thread
/// other people wrote. Learning only from this part keeps their names and phrasing out of the
/// user's shortcuts. Nil means the whole text counts (records written before this existed).
var typedLength: Int? = nil

/// The part of `text` the user wrote themselves.
var typedText: String {
guard let typedLength, typedLength < text.count else { return text }
return String(text.prefix(typedLength))
}
}

/// The encrypted file's plaintext payload. Versioned so a future format change can migrate rather
/// than silently dropping the user's history.
nonisolated struct TypingHistoryArchive: Codable, Equatable, Sendable {
static let currentVersion = 1

var version: Int
var records: [TypingHistoryRecord]
}

/// The user's typing-history preferences, persisted by `TypingHistoryStore`.
nonisolated struct TypingHistoryPreferences: Equatable, Sendable {
/// Whether stored history shapes suggestions (prompt examples and phrase shortcuts).
var isUsingHistory: Bool
/// Whether new typing is recorded. Off by default: recording keeps the user's writing on disk,
/// which is a privacy decision the user makes, not a default Cotabby makes for them.
var isRecording: Bool
/// Apps whose fields are never recorded, by bundle identifier.
var excludedBundleIdentifiers: [String]

static let defaults = TypingHistoryPreferences(
isUsingHistory: false,
isRecording: false,
excludedBundleIdentifiers: []
)
}
6 changes: 6 additions & 0 deletions Cotabby/Models/Suggestion/Request/SuggestionRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ struct SuggestionRequest: Equatable, Sendable {
/// prompt has already folded it in; this field exists so the Foundation Models renderer can
/// state the same sanitized facts in its own prompt shape.
let surfaceContext: SurfaceContext?
/// Passages of the user's own past writing that resemble this field (see `TypingHistoryStore`).
/// Always empty for the endpoint engine: history never leaves this Mac, and the router refuses
/// to send a request that carries any.
let historyExamples: [String]
/// When enabled, the normalizer keeps multiple lines instead of truncating to the first line.
let isMultiLineEnabled: Bool
/// The user's word-count preset, so decoding does not stop at a sentence end before the minimum
Expand Down Expand Up @@ -104,6 +108,7 @@ struct SuggestionRequest: Equatable, Sendable {
clipboardContext: String?,
visualContextSummary: String?,
surfaceContext: SurfaceContext? = nil,
historyExamples: [String] = [],
isMultiLineEnabled: Bool,
requestID: String = "req_unknown",
wordRange: SuggestionWordRange? = nil
Expand All @@ -128,6 +133,7 @@ struct SuggestionRequest: Equatable, Sendable {
self.clipboardContext = clipboardContext
self.visualContextSummary = visualContextSummary
self.surfaceContext = surfaceContext
self.historyExamples = historyExamples
self.isMultiLineEnabled = isMultiLineEnabled
self.requestID = requestID
self.wordRange = wordRange
Expand Down
13 changes: 13 additions & 0 deletions Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,19 @@ protocol EmojiInputIntercepting: AnyObject {
func isWordAcceptKey(_ keyEvent: InputMonitorKeyEvent) -> Bool
}

/// Read-only access to the user's typing history for the suggestion pipeline.
///
/// Both answers are empty for the endpoint engine: history stays on this Mac, so it may only shape
/// requests handled by Apple Intelligence or the in-process model. Implementations also return
/// nothing while the user has history turned off, so callers never need to check settings.
@MainActor
protocol SuggestionHistoryProviding: AnyObject {
/// Short passages of the user's past writing that resemble the current field, best first.
func historyExamples(for context: FocusedInputContext, engine: SuggestionEngineKind) -> [String]
/// Exact text to insert when history is confident how the current phrase ends, else nil.
func phraseContinuation(for request: SuggestionRequest, engine: SuggestionEngineKind) -> String?
}

@MainActor
protocol SuggestionGenerating: AnyObject {
func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult
Expand Down
Loading