From f7d39b910d8f769a11d285da57875e354787eebf Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Sun, 24 May 2026 18:42:22 -0700 Subject: [PATCH 1/4] Add clipboard relevance filter to drop stale/irrelevant context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clipboard content was blindly injected into every autocomplete prompt, even when the user had long moved on from whatever they copied. This adds a heuristic gate (ClipboardRelevanceFilter) that checks three signals before allowing clipboard into the prompt: 1. Staleness — drop if clipboard unchanged for >5 minutes 2. App affinity — always keep if copied in the same app 3. Token overlap — drop if clipboard shares no words with current text Zero latency, fully testable (injectable dateProvider), no model calls. --- Cotabby.xcodeproj/project.pbxproj | 5 + .../SuggestionCoordinator+Prediction.swift | 8 +- .../Coordinators/SuggestionCoordinator.swift | 3 + Cotabby/App/Core/CotabbyAppEnvironment.swift | 2 + .../Models/SuggestionSubsystemContracts.swift | 1 + .../Utilities/ClipboardContextProvider.swift | 2 + .../Support/ClipboardRelevanceFilter.swift | 64 ++++++ .../ClipboardRelevanceFilterTests.swift | 184 ++++++++++++++++++ 8 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 Cotabby/Support/ClipboardRelevanceFilter.swift create mode 100644 CotabbyTests/ClipboardRelevanceFilterTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index f038ea4..841cec5 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 194B33B52FC3F33B00DF6F60 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 194B33B42FC3F33B00DF6F60 /* Logging */; }; 19E177EA2FC1B7890067E267 /* CotabbyInference in Frameworks */ = {isa = PBXBuildFile; productRef = 19E177E92FC1B7890067E267 /* CotabbyInference */; }; 8B6282F0C1CCA0746D96B914 /* DownloadOutcomeClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 562F89255AF340C15A0554BE /* DownloadOutcomeClassifierTests.swift */; }; + G10000012FB0000100FFF001 /* ClipboardRelevanceFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = G10000112FB0000100FFF011 /* ClipboardRelevanceFilterTests.swift */; }; + A1C3E0012F90000100AAA001 /* LlamaSwift in Frameworks */ = {isa = PBXBuildFile; productRef = A1C3E0002F90000100AAA001 /* LlamaSwift */; }; A1C3E0112F90000100AAA001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A1C3E0102F90000100AAA001 /* Sparkle */; }; A404828463CADB2ECDAE7AF3 /* LlamaPromptRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29623C5C0A67B992D383A3C /* LlamaPromptRendererTests.swift */; }; ADEFEE12C197DB6C990E3812 /* SuggestionRequestFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1121FFDAD30C7F62E15289 /* SuggestionRequestFactoryTests.swift */; }; @@ -47,6 +49,7 @@ 193741492F81DE7000BEC04F /* Cotabby.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cotabby.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3FBFA92FA44AA317135426FB /* CotabbyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CotabbyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 562F89255AF340C15A0554BE /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = ""; }; + G10000112FB0000100FFF011 /* ClipboardRelevanceFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClipboardRelevanceFilterTests.swift; sourceTree = ""; }; 5A39EC1A44E9160E13E2AE77 /* SuggestionAvailabilityEvaluatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluatorTests.swift; sourceTree = ""; }; 8B1121FFDAD30C7F62E15289 /* SuggestionRequestFactoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactoryTests.swift; sourceTree = ""; }; BAAEE25772008D75883F2655 /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = ""; }; @@ -139,6 +142,7 @@ E10000142F93000100DDD014 /* BundledRuntimeLocatorTests.swift */, E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */, F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */, + G10000112FB0000100FFF011 /* ClipboardRelevanceFilterTests.swift */, ); path = CotabbyTests; sourceTree = ""; @@ -279,6 +283,7 @@ E10000042F93000100DDD004 /* BundledRuntimeLocatorTests.swift in Sources */, E10000052F93000100DDD005 /* DisplayCoordinateConverterTests.swift in Sources */, F10000012FA0000100EEE001 /* TextDirectionDetectorTests.swift in Sources */, + G10000012FB0000100FFF001 /* ClipboardRelevanceFilterTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index d1cc293..f46fc83 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -72,9 +72,15 @@ extension SuggestionCoordinator { let context = interactionState.materializeContext(from: rawContext) let visualContextSummary = visualContextCoordinator.excerpt(for: context) - let clipboardContext = settingsSnapshot.isClipboardContextEnabled + let rawClipboard = settingsSnapshot.isClipboardContextEnabled ? clipboardContextProvider.currentContext() : nil + let clipboardContext = clipboardRelevanceFilter.filter( + clipboard: rawClipboard, + pasteboardChangeCount: clipboardContextProvider.currentChangeCount, + currentBundleIdentifier: rawContext.bundleIdentifier, + precedingText: rawContext.precedingText + ) let requestBuildResult = SuggestionRequestFactory.buildRequest( context: context, settings: settingsSnapshot, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index 96a5289..42f6c33 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -42,6 +42,7 @@ final class SuggestionCoordinator: ObservableObject { let suggestionEngine: any SuggestionGenerating let suggestionSettings: any SuggestionSettingsProviding let clipboardContextProvider: any ClipboardContextProviding + let clipboardRelevanceFilter: ClipboardRelevanceFilter let visualContextCoordinator: any VisualContextCoordinating let interactionState: SuggestionInteractionState let workController: SuggestionWorkController @@ -70,6 +71,7 @@ final class SuggestionCoordinator: ObservableObject { suggestionEngine: any SuggestionGenerating, suggestionSettings: any SuggestionSettingsProviding, clipboardContextProvider: any ClipboardContextProviding, + clipboardRelevanceFilter: ClipboardRelevanceFilter, visualContextCoordinator: any VisualContextCoordinating, interactionState: SuggestionInteractionState, workController: SuggestionWorkController, @@ -87,6 +89,7 @@ final class SuggestionCoordinator: ObservableObject { self.suggestionEngine = suggestionEngine self.suggestionSettings = suggestionSettings self.clipboardContextProvider = clipboardContextProvider + self.clipboardRelevanceFilter = clipboardRelevanceFilter self.visualContextCoordinator = visualContextCoordinator self.interactionState = interactionState self.workController = workController diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 9ec44dd..5c07d51 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -83,6 +83,7 @@ final class CotabbyAppEnvironment { let overlayController = OverlayController(suggestionSettings: suggestionSettings) let activationIndicatorController = ActivationIndicatorController() let clipboardContextProvider = ClipboardContextProvider() + let clipboardRelevanceFilter = ClipboardRelevanceFilter() let summarizer = LlamaVisualContextSummarizer(runtimeManager: runtimeManager) let screenshotContextGenerator = ScreenshotContextGenerator(summarizer: summarizer) let visualContextCoordinator = VisualContextCoordinator( @@ -131,6 +132,7 @@ final class CotabbyAppEnvironment { suggestionEngine: suggestionEngine, suggestionSettings: suggestionSettings, clipboardContextProvider: clipboardContextProvider, + clipboardRelevanceFilter: clipboardRelevanceFilter, visualContextCoordinator: visualContextCoordinator, interactionState: interactionState, workController: workController, diff --git a/Cotabby/Models/SuggestionSubsystemContracts.swift b/Cotabby/Models/SuggestionSubsystemContracts.swift index 773d78b..a44dd80 100644 --- a/Cotabby/Models/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/SuggestionSubsystemContracts.swift @@ -53,6 +53,7 @@ protocol SuggestionSettingsProviding: AnyObject { @MainActor protocol ClipboardContextProviding: AnyObject { func currentContext() -> String? + var currentChangeCount: Int { get } } @MainActor diff --git a/Cotabby/Services/Utilities/ClipboardContextProvider.swift b/Cotabby/Services/Utilities/ClipboardContextProvider.swift index c7619ee..9e101c1 100644 --- a/Cotabby/Services/Utilities/ClipboardContextProvider.swift +++ b/Cotabby/Services/Utilities/ClipboardContextProvider.swift @@ -15,6 +15,8 @@ final class ClipboardContextProvider: ClipboardContextProviding { self.pasteboard = pasteboard } + var currentChangeCount: Int { pasteboard.changeCount } + func currentContext() -> String? { if let text = normalizedText(pasteboard.string(forType: .string)) { return text diff --git a/Cotabby/Support/ClipboardRelevanceFilter.swift b/Cotabby/Support/ClipboardRelevanceFilter.swift new file mode 100644 index 0000000..99cb4c6 --- /dev/null +++ b/Cotabby/Support/ClipboardRelevanceFilter.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Decides whether the current clipboard content is relevant enough to inject into the +/// autocomplete prompt. Tracks clipboard identity via an external change count, records when +/// the clipboard last changed and which app was frontmost at that moment, then applies three +/// heuristics: staleness, app affinity, and token overlap. +/// +/// The filter never reads `NSPasteboard` directly — the caller passes in a plain `Int` change +/// count and the raw clipboard string, keeping this type fully testable without AppKit. +@MainActor +final class ClipboardRelevanceFilter { + static let staleThresholdSeconds: TimeInterval = 300 + private static let minimumTokenLength = 3 + + private var lastKnownChangeCount: Int = 0 + private var lastChangeDate: Date? + private var sourceBundleIdentifier: String? + private let dateProvider: () -> Date + + init(dateProvider: @escaping () -> Date = { Date() }) { + self.dateProvider = dateProvider + } + + /// Returns `clipboard` unchanged when it looks relevant, or `nil` when it should be dropped. + func filter( + clipboard: String?, + pasteboardChangeCount: Int, + currentBundleIdentifier: String, + precedingText: String + ) -> String? { + guard let clipboard else { return nil } + + if pasteboardChangeCount != lastKnownChangeCount { + lastKnownChangeCount = pasteboardChangeCount + lastChangeDate = dateProvider() + sourceBundleIdentifier = currentBundleIdentifier + } + + guard let lastChangeDate, + dateProvider().timeIntervalSince(lastChangeDate) < Self.staleThresholdSeconds + else { + return nil + } + + if sourceBundleIdentifier == currentBundleIdentifier { + return clipboard + } + + let clipboardTokens = Self.tokens(from: clipboard) + let prefixTokens = Self.tokens(from: precedingText) + guard !clipboardTokens.isDisjoint(with: prefixTokens) else { + return nil + } + + return clipboard + } + + // MARK: - Tokenization + + private static func tokens(from text: String) -> Set { + let words = text.lowercased().components(separatedBy: .alphanumerics.inverted) + return Set(words.filter { $0.count >= minimumTokenLength }) + } +} diff --git a/CotabbyTests/ClipboardRelevanceFilterTests.swift b/CotabbyTests/ClipboardRelevanceFilterTests.swift new file mode 100644 index 0000000..f769f8f --- /dev/null +++ b/CotabbyTests/ClipboardRelevanceFilterTests.swift @@ -0,0 +1,184 @@ +import XCTest +@testable import Cotabby + +@MainActor +final class ClipboardRelevanceFilterTests: XCTestCase { + + private var now: Date! + private var filter: ClipboardRelevanceFilter! + + override func setUp() { + super.setUp() + now = Date() + filter = ClipboardRelevanceFilter(dateProvider: { [unowned self] in self.now }) + } + + // MARK: - Nil input + + func test_nilClipboard_returnsNil() { + let result = filter.filter( + clipboard: nil, + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.notes", + precedingText: "hello world" + ) + XCTAssertNil(result) + } + + // MARK: - Fresh clipboard, same app + + func test_freshClipboard_sameApp_returnsContent() { + let content = "some copied text" + let result = filter.filter( + clipboard: content, + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.notes", + precedingText: "unrelated words here" + ) + XCTAssertEqual(result, content) + } + + // MARK: - Fresh clipboard, different app, with overlap + + func test_freshClipboard_differentApp_withOverlap_returnsContent() { + // First call establishes the source app as Notes. + _ = filter.filter( + clipboard: "meeting agenda for Thursday", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.notes", + precedingText: "" + ) + + // Second call from a different app — prefix shares "meeting". + let result = filter.filter( + clipboard: "meeting agenda for Thursday", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.mail", + precedingText: "Let's discuss the meeting" + ) + XCTAssertEqual(result, "meeting agenda for Thursday") + } + + // MARK: - Fresh clipboard, different app, no overlap + + func test_freshClipboard_differentApp_noOverlap_returnsNil() { + _ = filter.filter( + clipboard: "SELECT * FROM users", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.terminal", + precedingText: "" + ) + + let result = filter.filter( + clipboard: "SELECT * FROM users", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.notes", + precedingText: "Dear hiring manager" + ) + XCTAssertNil(result) + } + + // MARK: - Staleness + + func test_staleClipboard_returnsNil() { + _ = filter.filter( + clipboard: "fresh content", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.notes", + precedingText: "fresh content" + ) + + // Advance time past the staleness threshold. + now = now.addingTimeInterval(ClipboardRelevanceFilter.staleThresholdSeconds + 1) + + let result = filter.filter( + clipboard: "fresh content", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.notes", + precedingText: "fresh content" + ) + XCTAssertNil(result) + } + + func test_staleClipboard_differentApp_returnsNil() { + _ = filter.filter( + clipboard: "some code", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.xcode", + precedingText: "" + ) + + now = now.addingTimeInterval(ClipboardRelevanceFilter.staleThresholdSeconds + 1) + + let result = filter.filter( + clipboard: "some code", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.slack", + precedingText: "some code here" + ) + XCTAssertNil(result) + } + + // MARK: - Clipboard change resets metadata + + func test_clipboardChange_resetsMetadata() { + // Initial clipboard from Notes. + _ = filter.filter( + clipboard: "old content", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.notes", + precedingText: "" + ) + + // Time passes but clipboard changes from Mail — metadata resets. + now = now.addingTimeInterval(ClipboardRelevanceFilter.staleThresholdSeconds + 1) + + let result = filter.filter( + clipboard: "new content from mail", + pasteboardChangeCount: 2, + currentBundleIdentifier: "com.app.mail", + precedingText: "completely different" + ) + // Same app as source → returned. + XCTAssertEqual(result, "new content from mail") + } + + // MARK: - Short tokens ignored + + func test_shortTokensIgnored_inOverlapCheck() { + _ = filter.filter( + clipboard: "a b c", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.terminal", + precedingText: "" + ) + + // Different app, prefix also has only short tokens — no meaningful overlap. + let result = filter.filter( + clipboard: "a b c", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.notes", + precedingText: "a b c d e" + ) + XCTAssertNil(result) + } + + // MARK: - Case insensitivity + + func test_tokenOverlap_isCaseInsensitive() { + _ = filter.filter( + clipboard: "Deployment Pipeline", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.terminal", + precedingText: "" + ) + + let result = filter.filter( + clipboard: "Deployment Pipeline", + pasteboardChangeCount: 1, + currentBundleIdentifier: "com.app.notes", + precedingText: "the deployment is running" + ) + XCTAssertEqual(result, "Deployment Pipeline") + } +} From 9afea0c30797bf76a74954514392c66ef0245ecc Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Sun, 24 May 2026 19:20:04 -0700 Subject: [PATCH 2/4] Add line-level clipboard distillation before prompt injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even when the relevance filter keeps clipboard content, the raw text can contain noisy lines (email signatures, import blocks, boilerplate) that waste prompt tokens. ClipboardContentDistiller extracts only the lines whose tokens overlap with the user's prefix text, keeping the prompt focused on what's actually relevant to the current completion. Short clipboard (≤3 lines) passes through unchanged. When no individual line overlaps, the first 300 characters are kept as a head-biased fallback. Also extracts the shared tokenizer into PromptContextSanitizer so both ClipboardRelevanceFilter and ClipboardContentDistiller use the same logic. --- Cotabby.xcodeproj/project.pbxproj | 4 + .../Support/ClipboardContentDistiller.swift | 33 ++++++ .../Support/ClipboardRelevanceFilter.swift | 5 +- Cotabby/Support/PromptContextSanitizer.swift | 7 ++ .../Support/SuggestionRequestFactory.swift | 12 +- .../ClipboardContentDistillerTests.swift | 109 ++++++++++++++++++ 6 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 Cotabby/Support/ClipboardContentDistiller.swift create mode 100644 CotabbyTests/ClipboardContentDistillerTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 841cec5..b49782d 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 19E177EA2FC1B7890067E267 /* CotabbyInference in Frameworks */ = {isa = PBXBuildFile; productRef = 19E177E92FC1B7890067E267 /* CotabbyInference */; }; 8B6282F0C1CCA0746D96B914 /* DownloadOutcomeClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 562F89255AF340C15A0554BE /* DownloadOutcomeClassifierTests.swift */; }; G10000012FB0000100FFF001 /* ClipboardRelevanceFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = G10000112FB0000100FFF011 /* ClipboardRelevanceFilterTests.swift */; }; + G10000022FB0000100FFF002 /* ClipboardContentDistillerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = G10000122FB0000100FFF012 /* ClipboardContentDistillerTests.swift */; }; A1C3E0012F90000100AAA001 /* LlamaSwift in Frameworks */ = {isa = PBXBuildFile; productRef = A1C3E0002F90000100AAA001 /* LlamaSwift */; }; A1C3E0112F90000100AAA001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A1C3E0102F90000100AAA001 /* Sparkle */; }; A404828463CADB2ECDAE7AF3 /* LlamaPromptRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29623C5C0A67B992D383A3C /* LlamaPromptRendererTests.swift */; }; @@ -50,6 +51,7 @@ 3FBFA92FA44AA317135426FB /* CotabbyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CotabbyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 562F89255AF340C15A0554BE /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = ""; }; G10000112FB0000100FFF011 /* ClipboardRelevanceFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClipboardRelevanceFilterTests.swift; sourceTree = ""; }; + G10000122FB0000100FFF012 /* ClipboardContentDistillerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistillerTests.swift; sourceTree = ""; }; 5A39EC1A44E9160E13E2AE77 /* SuggestionAvailabilityEvaluatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluatorTests.swift; sourceTree = ""; }; 8B1121FFDAD30C7F62E15289 /* SuggestionRequestFactoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactoryTests.swift; sourceTree = ""; }; BAAEE25772008D75883F2655 /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = ""; }; @@ -143,6 +145,7 @@ E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */, F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */, G10000112FB0000100FFF011 /* ClipboardRelevanceFilterTests.swift */, + G10000122FB0000100FFF012 /* ClipboardContentDistillerTests.swift */, ); path = CotabbyTests; sourceTree = ""; @@ -284,6 +287,7 @@ E10000052F93000100DDD005 /* DisplayCoordinateConverterTests.swift in Sources */, F10000012FA0000100EEE001 /* TextDirectionDetectorTests.swift in Sources */, G10000012FB0000100FFF001 /* ClipboardRelevanceFilterTests.swift in Sources */, + G10000022FB0000100FFF002 /* ClipboardContentDistillerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Cotabby/Support/ClipboardContentDistiller.swift b/Cotabby/Support/ClipboardContentDistiller.swift new file mode 100644 index 0000000..c2ef8e5 --- /dev/null +++ b/Cotabby/Support/ClipboardContentDistiller.swift @@ -0,0 +1,33 @@ +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 + + /// 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") + } +} diff --git a/Cotabby/Support/ClipboardRelevanceFilter.swift b/Cotabby/Support/ClipboardRelevanceFilter.swift index 99cb4c6..bc502fa 100644 --- a/Cotabby/Support/ClipboardRelevanceFilter.swift +++ b/Cotabby/Support/ClipboardRelevanceFilter.swift @@ -55,10 +55,7 @@ final class ClipboardRelevanceFilter { return clipboard } - // MARK: - Tokenization - private static func tokens(from text: String) -> Set { - let words = text.lowercased().components(separatedBy: .alphanumerics.inverted) - return Set(words.filter { $0.count >= minimumTokenLength }) + PromptContextSanitizer.significantTokens(from: text, minimumLength: minimumTokenLength) } } diff --git a/Cotabby/Support/PromptContextSanitizer.swift b/Cotabby/Support/PromptContextSanitizer.swift index 166fc12..5c80439 100644 --- a/Cotabby/Support/PromptContextSanitizer.swift +++ b/Cotabby/Support/PromptContextSanitizer.swift @@ -59,6 +59,13 @@ enum PromptContextSanitizer { return bounded.trimmingCharacters(in: .whitespacesAndNewlines) } + /// Extracts lowercased tokens of at least `minimumLength` characters, splitting on + /// non-alphanumeric boundaries. Used by clipboard relevance and distillation logic. + static func significantTokens(from text: String, minimumLength: Int = 3) -> Set { + let words = text.lowercased().components(separatedBy: .alphanumerics.inverted) + return Set(words.filter { $0.count >= minimumLength }) + } + static func containsAlphanumericSignal(_ text: String) -> Bool { text.unicodeScalars.contains { CharacterSet.alphanumerics.contains($0) } } diff --git a/Cotabby/Support/SuggestionRequestFactory.swift b/Cotabby/Support/SuggestionRequestFactory.swift index a122da2..adc881d 100644 --- a/Cotabby/Support/SuggestionRequestFactory.swift +++ b/Cotabby/Support/SuggestionRequestFactory.swift @@ -44,7 +44,8 @@ enum SuggestionRequestFactory { let userName = activeUserName(settings: settings) let boundedClipboardContext = activeClipboardContext( rawContext: clipboardContext, - settings: settings + settings: settings, + prefixText: prefixText ) let boundedVisualContextSummary = activeVisualContextSummary( rawSummary: visualContextSummary @@ -109,7 +110,8 @@ enum SuggestionRequestFactory { private static func activeClipboardContext( rawContext: String?, - settings: SuggestionSettingsSnapshot + settings: SuggestionSettingsSnapshot, + prefixText: String ) -> String? { guard settings.isClipboardContextEnabled, let rawContext @@ -124,7 +126,11 @@ enum SuggestionRequestFactory { return nil } - return clippedText(sanitizedContext, maxCharacters: maxClipboardContextCharacters) + let distilled = ClipboardContentDistiller.distill( + clipboard: sanitizedContext, + prefixText: prefixText + ) + return clippedText(distilled, maxCharacters: maxClipboardContextCharacters) } private static func activeVisualContextSummary(rawSummary: String?) -> String? { diff --git a/CotabbyTests/ClipboardContentDistillerTests.swift b/CotabbyTests/ClipboardContentDistillerTests.swift new file mode 100644 index 0000000..2763fec --- /dev/null +++ b/CotabbyTests/ClipboardContentDistillerTests.swift @@ -0,0 +1,109 @@ +import XCTest +@testable import Cotabby + +final class ClipboardContentDistillerTests: XCTestCase { + + // MARK: - Short clipboard passes through + + func test_shortClipboard_returnedAsIs() { + let clipboard = "line one\nline two\nline three" + let result = ClipboardContentDistiller.distill( + clipboard: clipboard, + prefixText: "completely unrelated text" + ) + XCTAssertEqual(result, clipboard) + } + + // MARK: - Long clipboard with partial overlap + + func test_longClipboard_keepsOnlyMatchingLines() { + let clipboard = [ + "import Foundation", + "import UIKit", + "func deployServer() {", + " print(\"starting deploy\")", + "}" + ].joined(separator: "\n") + + let result = ClipboardContentDistiller.distill( + clipboard: clipboard, + prefixText: "the deploy is running" + ) + XCTAssertEqual(result, [ + "func deployServer() {", + " print(\"starting deploy\")" + ].joined(separator: "\n")) + } + + // MARK: - No per-line overlap falls back to head + + func test_longClipboard_noPerLineOverlap_returnsHead() { + let clipboard = [ + "alpha bravo charlie", + "delta echo foxtrot", + "golf hotel india", + "juliet kilo lima" + ].joined(separator: "\n") + + let result = ClipboardContentDistiller.distill( + clipboard: clipboard, + prefixText: "completely different words" + ) + XCTAssertEqual(result, String(clipboard.prefix(300))) + } + + // MARK: - Case insensitive + + func test_caseInsensitiveMatching() { + let clipboard = [ + "The DEPLOYMENT pipeline", + "Some unrelated header", + "Another random line", + "Check deployment status" + ].joined(separator: "\n") + + let result = ClipboardContentDistiller.distill( + clipboard: clipboard, + prefixText: "our deployment is slow" + ) + XCTAssertEqual(result, [ + "The DEPLOYMENT pipeline", + "Check deployment status" + ].joined(separator: "\n")) + } + + // MARK: - Short tokens ignored + + func test_shortTokensIgnored() { + let clipboard = [ + "a b c d e", + "x y z w v", + "real content here", + "more filler words" + ].joined(separator: "\n") + + let result = ClipboardContentDistiller.distill( + clipboard: clipboard, + prefixText: "a b c x y z" + ) + // No tokens >= 3 chars overlap, so head fallback. + XCTAssertEqual(result, String(clipboard.prefix(300))) + } + + // MARK: - Empty prefix returns clipboard as-is + + func test_emptyPrefixText_returnsClipboardAsIs() { + let clipboard = [ + "line one content", + "line two content", + "line three content", + "line four content" + ].joined(separator: "\n") + + let result = ClipboardContentDistiller.distill( + clipboard: clipboard, + prefixText: "" + ) + XCTAssertEqual(result, clipboard) + } +} From e49c60c0a67a8ad66e8c73208c67a23f4fc18209 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Sun, 24 May 2026 21:39:09 -0700 Subject: [PATCH 3/4] Fix distiller test using camelCase name with no token overlap The clipboard contained "func deployServer() {" but the prefix was "the deploy is running". significantTokens splits only on non-alphanumeric boundaries, so deployServer lowercases to a single "deployserver" token that does not match "deploy". The line was dropped and the test expected it to be kept. Rename to "func deploy() {" so the line genuinely shares a token with the prefix. --- CotabbyTests/ClipboardContentDistillerTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CotabbyTests/ClipboardContentDistillerTests.swift b/CotabbyTests/ClipboardContentDistillerTests.swift index 2763fec..56d8703 100644 --- a/CotabbyTests/ClipboardContentDistillerTests.swift +++ b/CotabbyTests/ClipboardContentDistillerTests.swift @@ -20,7 +20,7 @@ final class ClipboardContentDistillerTests: XCTestCase { let clipboard = [ "import Foundation", "import UIKit", - "func deployServer() {", + "func deploy() {", " print(\"starting deploy\")", "}" ].joined(separator: "\n") @@ -30,7 +30,7 @@ final class ClipboardContentDistillerTests: XCTestCase { prefixText: "the deploy is running" ) XCTAssertEqual(result, [ - "func deployServer() {", + "func deploy() {", " print(\"starting deploy\")" ].joined(separator: "\n")) } From de6620e4af58eca339121c70ed0fe5830aaef1ed Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Sun, 24 May 2026 22:18:48 -0700 Subject: [PATCH 4/4] Address Greptile review of clipboard filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix four review findings on the relevance filter and its wiring: - Drop the same-app affinity heuristic. We only ever observe the typing app, not the actual copier, so recording it as the source granted unconditional injection in apps where the user merely typed. The filter now relies on staleness and token overlap only. - Gate the staleness clock on a real baseline. lastKnownChangeCount starts as Optional. The first observation records the baseline without stamping a date, so pre-launch clipboard content stays out of the prompt until the user actually copies again while Cotabby is running. - Align the filter and the distiller on the same prefix window. The coordinator now computes truncatedPromptPrefix once and feeds it to both, preventing the gate from passing on tokens that get truncated before the distiller sees them — which previously caused the head-fallback to inject 300 chars of unrelated clipboard. - Replace the concrete ClipboardRelevanceFilter dependency on the coordinator with a ClipboardRelevanceFiltering protocol, matching the rest of the coordinator's collaborators. Tests: - Drop the app-affinity cases and add baseline-gating coverage so the first observation never injects and a subsequent change does. --- .../SuggestionCoordinator+Prediction.swift | 9 +- .../Coordinators/SuggestionCoordinator.swift | 4 +- .../Models/SuggestionSubsystemContracts.swift | 13 ++ .../Support/ClipboardRelevanceFilter.swift | 36 +++-- .../Support/SuggestionRequestFactory.swift | 6 +- .../ClipboardRelevanceFilterTests.swift | 146 +++++++----------- 6 files changed, 111 insertions(+), 103 deletions(-) diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index f46fc83..bc1e22e 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -75,11 +75,16 @@ extension SuggestionCoordinator { let rawClipboard = settingsSnapshot.isClipboardContextEnabled ? clipboardContextProvider.currentContext() : nil + // Same bounded window the downstream distiller sees, so the relevance gate and the + // per-line filter can't disagree about what "shares tokens with the prefix" means. + let truncatedPrefix = SuggestionRequestFactory.truncatedPromptPrefix( + from: rawContext.precedingText, + configuration: configuration + ) let clipboardContext = clipboardRelevanceFilter.filter( clipboard: rawClipboard, pasteboardChangeCount: clipboardContextProvider.currentChangeCount, - currentBundleIdentifier: rawContext.bundleIdentifier, - precedingText: rawContext.precedingText + precedingText: truncatedPrefix ) let requestBuildResult = SuggestionRequestFactory.buildRequest( context: context, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index 42f6c33..e35e6e2 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -42,7 +42,7 @@ final class SuggestionCoordinator: ObservableObject { let suggestionEngine: any SuggestionGenerating let suggestionSettings: any SuggestionSettingsProviding let clipboardContextProvider: any ClipboardContextProviding - let clipboardRelevanceFilter: ClipboardRelevanceFilter + let clipboardRelevanceFilter: any ClipboardRelevanceFiltering let visualContextCoordinator: any VisualContextCoordinating let interactionState: SuggestionInteractionState let workController: SuggestionWorkController @@ -71,7 +71,7 @@ final class SuggestionCoordinator: ObservableObject { suggestionEngine: any SuggestionGenerating, suggestionSettings: any SuggestionSettingsProviding, clipboardContextProvider: any ClipboardContextProviding, - clipboardRelevanceFilter: ClipboardRelevanceFilter, + clipboardRelevanceFilter: any ClipboardRelevanceFiltering, visualContextCoordinator: any VisualContextCoordinating, interactionState: SuggestionInteractionState, workController: SuggestionWorkController, diff --git a/Cotabby/Models/SuggestionSubsystemContracts.swift b/Cotabby/Models/SuggestionSubsystemContracts.swift index a44dd80..763be75 100644 --- a/Cotabby/Models/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/SuggestionSubsystemContracts.swift @@ -56,6 +56,19 @@ protocol ClipboardContextProviding: AnyObject { var currentChangeCount: Int { get } } +@MainActor +protocol ClipboardRelevanceFiltering: AnyObject { + /// 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, + /// so the relevance gate and per-line distillation evaluate overlap consistently. + func filter( + clipboard: String?, + pasteboardChangeCount: Int, + precedingText: String + ) -> String? +} + @MainActor protocol SuggestionInserting: AnyObject { var lastErrorMessage: String? { get } diff --git a/Cotabby/Support/ClipboardRelevanceFilter.swift b/Cotabby/Support/ClipboardRelevanceFilter.swift index bc502fa..0afc8f7 100644 --- a/Cotabby/Support/ClipboardRelevanceFilter.swift +++ b/Cotabby/Support/ClipboardRelevanceFilter.swift @@ -2,19 +2,29 @@ import Foundation /// Decides whether the current clipboard content is relevant enough to inject into the /// autocomplete prompt. Tracks clipboard identity via an external change count, records when -/// the clipboard last changed and which app was frontmost at that moment, then applies three -/// heuristics: staleness, app affinity, and token overlap. +/// the clipboard last changed during this Cotabby session, and applies two heuristics: +/// staleness and token overlap. +/// +/// Why no source-app affinity: we never observe the actual copier — only the app that is +/// frontmost when autocomplete fires, which is always the typing app. Recording the typing +/// app as the "source" granted same-app shortcuts in apps where the user merely typed, +/// bypassing the overlap guard for unrelated clipboard content. +/// +/// Why a sentinel baseline: `NSPasteboard.changeCount` is a non-zero cumulative counter, so +/// initializing to `0` made every first observation look like a fresh copy event and reset +/// the staleness clock to "now" — granting up to five minutes of injection for content +/// copied hours before Cotabby launched. The first observation now records the baseline +/// without stamping a date, gating injection until an actual change is detected. /// /// The filter never reads `NSPasteboard` directly — the caller passes in a plain `Int` change /// count and the raw clipboard string, keeping this type fully testable without AppKit. @MainActor -final class ClipboardRelevanceFilter { +final class ClipboardRelevanceFilter: ClipboardRelevanceFiltering { static let staleThresholdSeconds: TimeInterval = 300 private static let minimumTokenLength = 3 - private var lastKnownChangeCount: Int = 0 + private var lastKnownChangeCount: Int? private var lastChangeDate: Date? - private var sourceBundleIdentifier: String? private let dateProvider: () -> Date init(dateProvider: @escaping () -> Date = { Date() }) { @@ -25,15 +35,21 @@ final class ClipboardRelevanceFilter { func filter( clipboard: String?, pasteboardChangeCount: Int, - currentBundleIdentifier: String, precedingText: String ) -> String? { guard let clipboard else { return nil } - if pasteboardChangeCount != lastKnownChangeCount { + guard let baselineChangeCount = lastKnownChangeCount else { + // First observation: record the baseline so we can detect *new* copies, but leave + // the staleness clock unset. Pre-existing clipboard content is not injected until + // the user actually copies again while Cotabby is running. + lastKnownChangeCount = pasteboardChangeCount + return nil + } + + if pasteboardChangeCount != baselineChangeCount { lastKnownChangeCount = pasteboardChangeCount lastChangeDate = dateProvider() - sourceBundleIdentifier = currentBundleIdentifier } guard let lastChangeDate, @@ -42,10 +58,6 @@ final class ClipboardRelevanceFilter { return nil } - if sourceBundleIdentifier == currentBundleIdentifier { - return clipboard - } - let clipboardTokens = Self.tokens(from: clipboard) let prefixTokens = Self.tokens(from: precedingText) guard !clipboardTokens.isDisjoint(with: prefixTokens) else { diff --git a/Cotabby/Support/SuggestionRequestFactory.swift b/Cotabby/Support/SuggestionRequestFactory.swift index adc881d..6d57bc5 100644 --- a/Cotabby/Support/SuggestionRequestFactory.swift +++ b/Cotabby/Support/SuggestionRequestFactory.swift @@ -88,7 +88,11 @@ enum SuggestionRequestFactory { } /// Keep only the latest short word tail to prevent long stale context from steering output. - private static func truncatedPromptPrefix( + /// + /// Exposed (non-private) so the coordinator can compute the same bounded window before + /// calling the relevance filter, ensuring the filter and the downstream distiller evaluate + /// token overlap against an identical prefix. + static func truncatedPromptPrefix( from precedingText: String, configuration: SuggestionConfiguration ) -> String { diff --git a/CotabbyTests/ClipboardRelevanceFilterTests.swift b/CotabbyTests/ClipboardRelevanceFilterTests.swift index f769f8f..af92bfd 100644 --- a/CotabbyTests/ClipboardRelevanceFilterTests.swift +++ b/CotabbyTests/ClipboardRelevanceFilterTests.swift @@ -19,166 +19,140 @@ final class ClipboardRelevanceFilterTests: XCTestCase { let result = filter.filter( clipboard: nil, pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.notes", precedingText: "hello world" ) XCTAssertNil(result) } - // MARK: - Fresh clipboard, same app + // MARK: - Baseline gating - func test_freshClipboard_sameApp_returnsContent() { - let content = "some copied text" + /// `NSPasteboard.changeCount` is a non-zero cumulative counter on a real system, so the + /// first observation can't tell us how old the clipboard content actually is. The filter + /// records the baseline silently and refuses injection until a *new* copy is detected. + func test_firstObservation_returnsNilEvenWithOverlap() { let result = filter.filter( - clipboard: content, - pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.notes", - precedingText: "unrelated words here" + clipboard: "meeting agenda", + pasteboardChangeCount: 42, + precedingText: "the meeting starts soon" ) - XCTAssertEqual(result, content) + XCTAssertNil(result) } - // MARK: - Fresh clipboard, different app, with overlap - - func test_freshClipboard_differentApp_withOverlap_returnsContent() { - // First call establishes the source app as Notes. + func test_firstChangeAfterBaseline_returnsContentWhenOverlapMatches() { + // Baseline observation — counts as "we know nothing about how old this is". _ = filter.filter( - clipboard: "meeting agenda for Thursday", - pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.notes", + clipboard: "irrelevant baseline content", + pasteboardChangeCount: 42, precedingText: "" ) - // Second call from a different app — prefix shares "meeting". + // User performs a fresh copy while Cotabby is running. let result = filter.filter( clipboard: "meeting agenda for Thursday", - pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.mail", + pasteboardChangeCount: 43, precedingText: "Let's discuss the meeting" ) XCTAssertEqual(result, "meeting agenda for Thursday") } - // MARK: - Fresh clipboard, different app, no overlap + // MARK: - Token overlap - func test_freshClipboard_differentApp_noOverlap_returnsNil() { + func test_freshClipboard_noOverlap_returnsNil() { _ = filter.filter( - clipboard: "SELECT * FROM users", + clipboard: "irrelevant baseline content", pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.terminal", precedingText: "" ) let result = filter.filter( clipboard: "SELECT * FROM users", - pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.notes", + pasteboardChangeCount: 2, precedingText: "Dear hiring manager" ) XCTAssertNil(result) } - // MARK: - Staleness - - func test_staleClipboard_returnsNil() { + func test_shortTokensIgnored_inOverlapCheck() { _ = filter.filter( - clipboard: "fresh content", + clipboard: "irrelevant baseline content", pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.notes", - precedingText: "fresh content" + precedingText: "" ) - // Advance time past the staleness threshold. - now = now.addingTimeInterval(ClipboardRelevanceFilter.staleThresholdSeconds + 1) - + // Prefix and clipboard share only sub-3-char tokens, which the tokenizer ignores. let result = filter.filter( - clipboard: "fresh content", - pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.notes", - precedingText: "fresh content" + clipboard: "a b c", + pasteboardChangeCount: 2, + precedingText: "a b c d e" ) XCTAssertNil(result) } - func test_staleClipboard_differentApp_returnsNil() { + func test_tokenOverlap_isCaseInsensitive() { _ = filter.filter( - clipboard: "some code", + clipboard: "irrelevant baseline content", pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.xcode", precedingText: "" ) - now = now.addingTimeInterval(ClipboardRelevanceFilter.staleThresholdSeconds + 1) - let result = filter.filter( - clipboard: "some code", - pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.slack", - precedingText: "some code here" + clipboard: "Deployment Pipeline", + pasteboardChangeCount: 2, + precedingText: "the deployment is running" ) - XCTAssertNil(result) + XCTAssertEqual(result, "Deployment Pipeline") } - // MARK: - Clipboard change resets metadata + // MARK: - Staleness - func test_clipboardChange_resetsMetadata() { - // Initial clipboard from Notes. + func test_staleClipboard_returnsNil() { + // Establish baseline. _ = filter.filter( - clipboard: "old content", + clipboard: "old baseline", pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.notes", precedingText: "" ) - // Time passes but clipboard changes from Mail — metadata resets. + // A fresh copy happens — staleness clock starts here. + _ = filter.filter( + clipboard: "fresh content here", + pasteboardChangeCount: 2, + precedingText: "fresh content here" + ) + now = now.addingTimeInterval(ClipboardRelevanceFilter.staleThresholdSeconds + 1) let result = filter.filter( - clipboard: "new content from mail", + clipboard: "fresh content here", pasteboardChangeCount: 2, - currentBundleIdentifier: "com.app.mail", - precedingText: "completely different" + precedingText: "fresh content here" ) - // Same app as source → returned. - XCTAssertEqual(result, "new content from mail") + XCTAssertNil(result) } - // MARK: - Short tokens ignored - - func test_shortTokensIgnored_inOverlapCheck() { + func test_newCopyResetsStalenessClock() { _ = filter.filter( - clipboard: "a b c", + clipboard: "baseline", pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.terminal", precedingText: "" ) - // Different app, prefix also has only short tokens — no meaningful overlap. - let result = filter.filter( - clipboard: "a b c", - pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.notes", - precedingText: "a b c d e" - ) - XCTAssertNil(result) - } - - // MARK: - Case insensitivity - - func test_tokenOverlap_isCaseInsensitive() { + // First real copy. _ = filter.filter( - clipboard: "Deployment Pipeline", - pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.terminal", - precedingText: "" + clipboard: "first content", + pasteboardChangeCount: 2, + precedingText: "first content" ) + // Time passes past the staleness threshold. + now = now.addingTimeInterval(ClipboardRelevanceFilter.staleThresholdSeconds + 1) + + // A new copy resets the clock. let result = filter.filter( - clipboard: "Deployment Pipeline", - pasteboardChangeCount: 1, - currentBundleIdentifier: "com.app.notes", - precedingText: "the deployment is running" + clipboard: "second content matching prefix", + pasteboardChangeCount: 3, + precedingText: "second content" ) - XCTAssertEqual(result, "Deployment Pipeline") + XCTAssertEqual(result, "second content matching prefix") } }