Skip to content
Closed
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
12 changes: 8 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,12 @@ or endpoint mode so mapped weights and Metal buffers do not stay resident unnece

Context sources are independently enabled and bounded: recent AX prefix/trailing text, surface
metadata, user rules/extended context, relevant clipboard content, visual OCR, language, and settings.
[PromptContextSanitizer.swift](Cotabby/Support/Context/PromptContextSanitizer.swift) sanitizes optional text,
and prompt renderers apply per-section budgets.
[PromptContextSanitizer.swift](Cotabby/Support/Context/PromptContextSanitizer.swift) sanitizes optional text
and extracts Latin terms plus CJK bigrams. [ContextRelevanceSelector.swift](Cotabby/Support/Context/ContextRelevanceSelector.swift)
then ranks clipboard and OCR lines against the live caret prefix under source-local line/character caps.
Local engines retain a bounded visual fallback for semantically related text without lexical overlap;
configured endpoints receive visual text only when relevance evidence survives. Prompt renderers apply
the remaining global section budget.

[ClipboardContextProvider.swift](Cotabby/Services/Context/ClipboardContextProvider.swift) reads a
fresh bounded value at request time rather than recording clipboard history. Relevance and distillation
Expand Down Expand Up @@ -403,8 +407,8 @@ policies, prompt utilities, normalization, and layout logic should receive focus
| Rapid Tab leaks to the host after exhausting a tail | PostExhaustionAcceptanceState, acceptance coordinator |
| Acceptance key passes through or is stolen | InputMonitor, acceptance validation |
| Wrong or repeated inserted text | SuggestionInserter, InputSuppressionController, reconciler |
| Clipboard context is irrelevant | ClipboardRelevanceFilter, ClipboardContentDistiller |
| Screenshot context is stale/noisy | VisualContextCoordinator, OCRTextHygiene |
| Clipboard context is irrelevant | ClipboardRelevanceFilter, ClipboardContentDistiller, ContextRelevanceSelector |
| Screenshot context is stale/noisy | VisualContextCoordinator, OCRTextHygiene, ContextRelevanceSelector |
| Emoji or macro conflicts with suggestions | InlineCommandCoordinator, feature trigger machine |
| Permission loop or lost grant | PermissionManager, PermissionGuidanceController, app identity |
| Settings/onboarding window issue | SettingsCoordinator, WelcomeCoordinator |
Expand Down
10 changes: 10 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
27A7976690FC7FDA7EE364D8 /* SuggestionTextColorCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CF768650A90705FC0D2730 /* SuggestionTextColorCodec.swift */; };
27B1A99D61398752863DB251 /* RequestIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80071E4A5D844CEF5B7C487 /* RequestIDTests.swift */; };
27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */; };
288DC9575FDCA1370AA73185 /* ContextRelevanceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45B7D55C95DB033B3A8793E /* ContextRelevanceSelector.swift */; };
28B4967D21137565D652A953 /* InsertionSafetyGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89746E8CE7E9487337EE6F /* InsertionSafetyGate.swift */; };
28D217A96946A2005FCBEBFD /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; };
2905970239BB48AD6AA95A92 /* PermissionDragSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F000304686E4573BC351AD3F /* PermissionDragSourceView.swift */; };
Expand Down Expand Up @@ -175,6 +176,7 @@
33EC776945F8CE612A8E85A3 /* CustomRulesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7D1184F0BC707F0A9148DD /* CustomRulesEditor.swift */; };
35F1681148A324CBEC797BAF /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DE038A6DF4A16B3EB5CEBD3 /* MenuBarView.swift */; };
36DBF7CA5EC371E4AA860843 /* SpellingDictionaryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B8156EEF23664F775190AB /* SpellingDictionaryPicker.swift */; };
36EE3D0E98A4E1CA9EB62436 /* ContextRelevanceSelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95ACE8721762F01C8146BE4C /* ContextRelevanceSelectorTests.swift */; };
37029DDB055255BA5419C4FA /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F952E54538EF82D1B1E672D8 /* WelcomePermissionStepView.swift */; };
37625DC44E2228CC897222B7 /* ru-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6CF1FBAABEF545B620AF8D78 /* ru-100k.txt */; };
37644CB48FCDF44AFA5EBA6E /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD93D7DE258C8E5367A433D /* EmojiCatalog.swift */; };
Expand Down Expand Up @@ -535,6 +537,7 @@
AD0FE3F0F75A40B827109589 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */; };
AD7940C54015532D2E8973DD /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9316289DD11A72D3EAC0A929 /* CompositionInputModeClassifier.swift */; };
ADB10BCA76295920FE4A9FE1 /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B351B5FDF077F8FF49A9A7 /* SuggestionFadeInPolicy.swift */; };
ADD9949A333F6E78708773E9 /* ContextRelevanceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45B7D55C95DB033B3A8793E /* ContextRelevanceSelector.swift */; };
AE739989AC401113B99018BC /* SuggestionCoordinator+Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFA31F5FA8D164CB6C30D06 /* SuggestionCoordinator+Input.swift */; };
AED18FC69D7ECC5A0A80B8FB /* CustomRulesCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D93E3819F3BB0957FF3DF6 /* CustomRulesCatalog.swift */; };
AEF6FC272F0B1B20B43FD0EE /* SuggestionTextColorCodecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655F56C230F45CAC13EEE59C /* SuggestionTextColorCodecTests.swift */; };
Expand Down Expand Up @@ -1065,6 +1068,7 @@
928A884292355FEF8B605B64 /* SuggestionFadeInPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicyTests.swift; sourceTree = "<group>"; };
9316289DD11A72D3EAC0A929 /* CompositionInputModeClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionInputModeClassifier.swift; sourceTree = "<group>"; };
94A26D86030FC5FA67371C52 /* DateMacroEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateMacroEvaluator.swift; sourceTree = "<group>"; };
95ACE8721762F01C8146BE4C /* ContextRelevanceSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextRelevanceSelectorTests.swift; sourceTree = "<group>"; };
95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchRankerTests.swift; sourceTree = "<group>"; };
961A47BBFDBF7C233BC4DDC4 /* TextLayoutCaretEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLayoutCaretEstimatorTests.swift; sourceTree = "<group>"; };
9720913FECB22A9A28C96333 /* SuggestionOverlayViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayViews.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1195,6 +1199,7 @@
D253D06D941F60EDAF112625 /* SuggestionSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelTests.swift; sourceTree = "<group>"; };
D381F1752705DC80E491B025 /* ArithmeticEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluatorTests.swift; sourceTree = "<group>"; };
D3C1C9A9BF1F74C16E4401EC /* CotabbyBrand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyBrand.swift; sourceTree = "<group>"; };
D45B7D55C95DB033B3A8793E /* ContextRelevanceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextRelevanceSelector.swift; sourceTree = "<group>"; };
D47E1471C110614F0D07E399 /* PermissionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionModels.swift; sourceTree = "<group>"; };
D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritingPaneView.swift; sourceTree = "<group>"; };
D4BA53E8B4DBF1612C8F70BC /* SuggestionFocusFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFocusFreshnessTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2119,6 +2124,7 @@
children = (
B2229037BCDF7EFD3D47C15F /* ClipboardContentDistiller.swift */,
2BF37C1A0F734BB940E371C2 /* ClipboardRelevanceFilter.swift */,
D45B7D55C95DB033B3A8793E /* ContextRelevanceSelector.swift */,
7736E6B390C822F91CC1DA99 /* OCRTextHygiene.swift */,
D182046E27CB170D943E8F86 /* PromptContextSanitizer.swift */,
27B03589D82D0ADCF41B9CD0 /* SurfaceContextComposer.swift */,
Expand Down Expand Up @@ -2794,6 +2800,7 @@
children = (
3680E1B8FA712A888F509640 /* ClipboardContentDistillerTests.swift */,
04364C3242A25358E7C85FD6 /* ClipboardRelevanceFilterTests.swift */,
95ACE8721762F01C8146BE4C /* ContextRelevanceSelectorTests.swift */,
64B0584FA16465453F04CA8C /* OCRTextHygieneTests.swift */,
0DA66559D50874865032EE8C /* PromptContextSanitizerTests.swift */,
652973AF18C20D51F81F76C5 /* SurfaceContextComposerTests.swift */,
Expand Down Expand Up @@ -3273,6 +3280,7 @@
B9348E5D08C50B82D2C62BCF /* ContextBuffer.swift in Sources */,
DA80B804C506B427A663302D /* ContextLivePreviewField.swift in Sources */,
930BA578E742D96FD9D340ED /* ContextPaneView.swift in Sources */,
ADD9949A333F6E78708773E9 /* ContextRelevanceSelector.swift in Sources */,
BFE1811C699B5B87530C3248 /* ControlTokenMarkers.swift in Sources */,
B55B160E0534AE23BAC1C3DA /* CotabbyApp.swift in Sources */,
AF55F1ABEDEA10C76C307CEC /* CotabbyAppEnvironment.swift in Sources */,
Expand Down Expand Up @@ -3554,6 +3562,7 @@
3EAFEEC83B25B023C606A726 /* ContextBuffer.swift in Sources */,
76933444A4EF1F1CE5039E9E /* ContextLivePreviewField.swift in Sources */,
2089E4C64C1BFDCDA9F2F721 /* ContextPaneView.swift in Sources */,
288DC9575FDCA1370AA73185 /* ContextRelevanceSelector.swift in Sources */,
1415DED5362F6F343AF63123 /* ControlTokenMarkers.swift in Sources */,
AA2E09FF7E430D66ECA8ECD5 /* CotabbyApp.swift in Sources */,
FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */,
Expand Down Expand Up @@ -3818,6 +3827,7 @@
5F2F0475865445A777B5E3F0 /* CompletionSeamGuardTests.swift in Sources */,
9BDC4312E18A9E67045DD4F1 /* CompositionInputModeClassifierTests.swift in Sources */,
3E235B9AECC972973A94B678 /* ConfidenceSuppressionPolicyTests.swift in Sources */,
36EE3D0E98A4E1CA9EB62436 /* ContextRelevanceSelectorTests.swift in Sources */,
57BCDFE786675C9793F3E08E /* ControlTokenMarkersTests.swift in Sources */,
F8D1C3FD1A1ACAE87D885D29 /* CotabbyDebugOptionsTests.swift in Sources */,
65D20F8E6309CED34A638D35 /* CotabbyTestFixtures.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,22 +302,25 @@ extension SuggestionCoordinator {
}

/// Resolves the clipboard prompt section under the pinning policy documented on
/// `clipboardPrefaceMemo`: an accepted (non-nil) verdict is reused for the rest of the field
/// session so the prompt head stays stable and the engine's KV common prefix survives; a nil
/// verdict re-evaluates per request because it adds nothing to the prompt and the clipboard
/// may only become relevant once more text is typed. A new copy or a field switch always
/// `clipboardPrefaceMemo`: an accepted (non-nil) verdict is reused only while the copy remains
/// fresh, so short-term prompt stability does not turn into indefinite same-field retention. A
/// nil verdict re-evaluates per request because it adds nothing to the prompt and the clipboard
/// may only become relevant once more text is typed. A new copy, field switch, or expiry always
/// re-evaluates.
private func pinnedClipboardContext(rawContext: FocusedInputSnapshot) -> String? {
guard settingsSnapshot.isClipboardContextEnabled else {
return nil
}

let changeCount = clipboardContextProvider.currentChangeCount
let now = Date()
if let memo = clipboardPrefaceMemo,
memo.focusSequence == rawContext.focusChangeSequence,
memo.changeCount == changeCount,
memo.value != nil {
return memo.value
let value = memo.reusableValue(
focusSequence: rawContext.focusChangeSequence,
changeCount: changeCount,
now: now
) {
return value
}

// Same bounded window the downstream distiller sees, so the relevance gate and the
Expand All @@ -327,15 +330,17 @@ extension SuggestionCoordinator {
configuration: configuration,
engine: settingsSnapshot.selectedEngine
)
let selectionPrefix = SuggestionRequestFactory.contextSelectionPrefix(from: truncatedPrefix)
let value = clipboardRelevanceFilter.filter(
clipboard: clipboardContextProvider.currentContext(),
pasteboardChangeCount: changeCount,
precedingText: truncatedPrefix
precedingText: selectionPrefix
)
clipboardPrefaceMemo = ClipboardPrefaceMemo(
focusSequence: rawContext.focusChangeSequence,
changeCount: changeCount,
value: value
value: value,
expiresAt: clipboardRelevanceFilter.acceptedContextExpiresAt ?? now
)
return value
}
Expand Down
30 changes: 22 additions & 8 deletions Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,32 @@ final class SuggestionCoordinator: ObservableObject {
// barrier task that the next generation must cross before it can ask the runtime for output.
var cacheResetSequence: UInt64 = 0
var pendingCacheReset: (sequence: UInt64, task: Task<Void, Never>)?
/// One accepted clipboard-relevance verdict per (field session, pasteboard state). The verdict
/// used to be re-evaluated against the live prefix on every request, and because the clipboard
/// section precedes the typed prefix in the prompt, every flip rewrote the prompt HEAD and
/// collapsed the engine's reusable common prefix back to zero (a full re-prefill). A pinned
/// non-nil verdict keeps the prompt head stable for the field session; a nil verdict keeps
/// re-evaluating because adding nothing to the prompt cannot destabilize the head, and the
/// clipboard may only become relevant once more text is typed. A new copy (change count) or a
/// field switch (focus sequence) always re-evaluates. See `pinnedClipboardContext`.
/// One accepted clipboard-relevance verdict per (field session, pasteboard state). Pinning the
/// bounded source prevents relevance from flipping on every keystroke; the request factory may
/// still select different lines from that source as the live prefix becomes more specific. A nil
/// verdict keeps re-evaluating because adding nothing cannot destabilize the prompt head and the
/// clipboard may become relevant later. A field switch, new copy, or freshness expiry always
/// re-evaluates. See `pinnedClipboardContext`.
struct ClipboardPrefaceMemo {
let focusSequence: UInt64
let changeCount: Int
let value: String?
let expiresAt: Date

/// A memo hit must still honor the relevance filter's freshness window. Without this check,
/// one accepted copy could remain in prompts for an arbitrarily long same-field session.
func reusableValue(
focusSequence: UInt64,
changeCount: Int,
now: Date
) -> String? {
guard self.focusSequence == focusSequence,
self.changeCount == changeCount,
now < expiresAt else {
return nil
}
return value
}
}

var clipboardPrefaceMemo: ClipboardPrefaceMemo?
Expand Down
10 changes: 10 additions & 0 deletions Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ protocol ClipboardContextProviding: AnyObject {

@MainActor
protocol ClipboardRelevanceFiltering: AnyObject {
/// Absolute expiry of the currently accepted clipboard value, derived from the observed copy
/// time rather than from each field/request that reuses it.
var acceptedContextExpiresAt: Date? { get }

/// Returns `clipboard` when it should be injected into the prompt, or `nil` to drop it.
///
/// `precedingText` should be the same bounded window the downstream distiller will see,
Expand All @@ -207,6 +211,12 @@ protocol ClipboardRelevanceFiltering: AnyObject {
) -> String?
}

extension ClipboardRelevanceFiltering {
/// Test doubles or alternate filters without a source-age clock fail closed: their accepted value
/// is not memoized beyond the request that produced it.
var acceptedContextExpiresAt: Date? { nil }
}

@MainActor
protocol SuggestionInserting: AnyObject {
var lastErrorMessage: String? { get }
Expand Down
51 changes: 23 additions & 28 deletions Cotabby/Support/Context/ClipboardContentDistiller.swift
Original file line number Diff line number Diff line change
@@ -1,33 +1,28 @@
import Foundation

/// Extracts only the clipboard lines that share meaningful tokens with the user's current
/// prefix text. Short clipboard content passes through unchanged; longer content is filtered
/// to the lines most likely to help the autocomplete model.
enum ClipboardContentDistiller {
private static let compactLineThreshold = 3
private static let headFallbackCharacters = 300
/// Selects the bounded clipboard lines that are relevant to the user's current caret context.
///
/// `ClipboardRelevanceFilter` remains the owner of permission-adjacent freshness and pasteboard
/// identity. This value only performs the second, line-level pass after that gate succeeds. Keeping
/// the two responsibilities separate prevents one matching line from admitting unrelated neighbors,
/// which was especially harmful for short copied blocks and long clipboard head fallbacks.
nonisolated enum ClipboardContentDistiller {
static let defaultLimits = ContextRelevanceSelector.Limits(
maxLines: 3,
maxCharacters: 400
)

/// Returns a distilled version of `clipboard` containing only lines relevant to `prefixText`.
///
/// - Clipboard with ≤3 lines or empty `prefixText` is returned as-is.
/// - Longer clipboard keeps only lines whose tokens overlap with `prefixText`.
/// - If no individual line overlaps, the first 300 characters are returned as a head fallback.
static func distill(clipboard: String, prefixText: String) -> String {
let lines = clipboard.components(separatedBy: "\n")
guard lines.count > compactLineThreshold else { return clipboard }

let prefixTokens = PromptContextSanitizer.significantTokens(from: prefixText)
guard !prefixTokens.isEmpty else { return clipboard }

let relevantLines = lines.filter { line in
let lineTokens = PromptContextSanitizer.significantTokens(from: line)
return !lineTokens.isDisjoint(with: prefixTokens)
}

if relevantLines.isEmpty {
return String(clipboard.prefix(headFallbackCharacters))
}

return relevantLines.joined(separator: "\n")
/// Returns relevant clipboard lines in their original order, or `nil` when no line shares
/// meaningful Latin terms or CJK bigrams with `prefixText`.
static func distill(
clipboard: String,
prefixText: String,
limits: ContextRelevanceSelector.Limits = defaultLimits
) -> String? {
ContextRelevanceSelector.selectRelevantLines(
from: clipboard,
prefixText: prefixText,
limits: limits
)
}
}
Loading