Skip to content
Merged
16 changes: 16 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,22 @@ 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 (the same
`SuggestionAvailabilityEvaluator` rule as suggestions; never secure fields, terminals, or excluded
apps), one record per piece of writing (a chat composer that clears after sending yields one record
per message), scrubs secret-like tokens and card numbers (`TypingHistoryScrubber`), and seals the
archive with AES-GCM under a Keychain key that never syncs (`TypingHistoryVault`). Nothing is
written until something is recorded or imported. 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 @@ -182,6 +182,17 @@ final class SuggestionCoordinator: ObservableObject {
/// a busy runner cannot stretch two back-to-back presses past the window.
var doubleTapUptimeProvider: () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }

/// 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 @@ -201,6 +212,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 @@ -224,6 +236,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
49 changes: 45 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,39 @@ 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. The same rule that decides whether Cotabby suggests (on, not paused,
// app or site not disabled, not paused for Low Power Mode) decides where recording may
// happen, so history is only collected where Cotabby is active. The store checks the
// field's capability, secure fields, and terminals itself.
focusModel.$snapshot
.sink { [weak typingHistoryStore, weak suggestionSettings, weak permissionManager, weak lowPowerModeMonitor] snapshot in
guard let typingHistoryStore, let suggestionSettings, let permissionManager, let lowPowerModeMonitor else { return }
typingHistoryStore.observe(snapshot) {
let settings = suggestionSettings.snapshot
return SuggestionAvailabilityEvaluator.disabledReason(
globallyEnabled: settings.isGloballyEnabled,
temporarilyPaused: settings.isTemporarilyPaused,
isLowPowerModeActive: lowPowerModeMonitor.isLowPowerModeEnabled,
isLowPowerModeAutoDisableEnabled: settings.isLowPowerModeAutoDisableEnabled,
disabledAppBundleIdentifiers: settings.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settings.suggestInIntegratedTerminals,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
focusSnapshot: snapshot,
checkCapability: false
) == nil
}
}
.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?

/// 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
Loading