Skip to content
Merged
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
9 changes: 9 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
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 */; };
G20000012FB0000100FFF001 /* ClipboardRelevanceFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000112FB0000100FFF011 /* 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 */; };
ADEFEE12C197DB6C990E3812 /* SuggestionRequestFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1121FFDAD30C7F62E15289 /* SuggestionRequestFactoryTests.swift */; };
Expand Down Expand Up @@ -48,6 +51,8 @@
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 = "<group>"; };
G20000112FB0000100FFF011 /* ClipboardRelevanceFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClipboardRelevanceFilterTests.swift; sourceTree = "<group>"; };
G10000122FB0000100FFF012 /* ClipboardContentDistillerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistillerTests.swift; sourceTree = "<group>"; };
5A39EC1A44E9160E13E2AE77 /* SuggestionAvailabilityEvaluatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluatorTests.swift; sourceTree = "<group>"; };
8B1121FFDAD30C7F62E15289 /* SuggestionRequestFactoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactoryTests.swift; sourceTree = "<group>"; };
BAAEE25772008D75883F2655 /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -142,6 +147,8 @@
E10000142F93000100DDD014 /* BundledRuntimeLocatorTests.swift */,
E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */,
F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */,
G20000112FB0000100FFF011 /* ClipboardRelevanceFilterTests.swift */,
G10000122FB0000100FFF012 /* ClipboardContentDistillerTests.swift */,
G10000112FB0000100FFF011 /* WordCountFormatterTests.swift */,
);
path = CotabbyTests;
Expand Down Expand Up @@ -283,6 +290,8 @@
E10000042F93000100DDD004 /* BundledRuntimeLocatorTests.swift in Sources */,
E10000052F93000100DDD005 /* DisplayCoordinateConverterTests.swift in Sources */,
F10000012FA0000100EEE001 /* TextDirectionDetectorTests.swift in Sources */,
G20000012FB0000100FFF001 /* ClipboardRelevanceFilterTests.swift in Sources */,
G10000022FB0000100FFF002 /* ClipboardContentDistillerTests.swift in Sources */,
G10000012FB0000100FFF001 /* WordCountFormatterTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
13 changes: 12 additions & 1 deletion Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,20 @@ 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
// 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,
precedingText: truncatedPrefix
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
let requestBuildResult = SuggestionRequestFactory.buildRequest(
context: context,
settings: settingsSnapshot,
Expand Down
3 changes: 3 additions & 0 deletions Cotabby/App/Coordinators/SuggestionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class SuggestionCoordinator: ObservableObject {
let suggestionEngine: any SuggestionGenerating
let suggestionSettings: any SuggestionSettingsProviding
let clipboardContextProvider: any ClipboardContextProviding
let clipboardRelevanceFilter: any ClipboardRelevanceFiltering
let visualContextCoordinator: any VisualContextCoordinating
let interactionState: SuggestionInteractionState
let workController: SuggestionWorkController
Expand Down Expand Up @@ -70,6 +71,7 @@ final class SuggestionCoordinator: ObservableObject {
suggestionEngine: any SuggestionGenerating,
suggestionSettings: any SuggestionSettingsProviding,
clipboardContextProvider: any ClipboardContextProviding,
clipboardRelevanceFilter: any ClipboardRelevanceFiltering,
visualContextCoordinator: any VisualContextCoordinating,
interactionState: SuggestionInteractionState,
workController: SuggestionWorkController,
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -131,6 +132,7 @@ final class CotabbyAppEnvironment {
suggestionEngine: suggestionEngine,
suggestionSettings: suggestionSettings,
clipboardContextProvider: clipboardContextProvider,
clipboardRelevanceFilter: clipboardRelevanceFilter,
visualContextCoordinator: visualContextCoordinator,
interactionState: interactionState,
workController: workController,
Expand Down
14 changes: 14 additions & 0 deletions Cotabby/Models/SuggestionSubsystemContracts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ protocol SuggestionSettingsProviding: AnyObject {
@MainActor
protocol ClipboardContextProviding: AnyObject {
func currentContext() -> String?
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
Expand Down
2 changes: 2 additions & 0 deletions Cotabby/Services/Utilities/ClipboardContextProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions Cotabby/Support/ClipboardContentDistiller.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
73 changes: 73 additions & 0 deletions Cotabby/Support/ClipboardRelevanceFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 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: ClipboardRelevanceFiltering {
static let staleThresholdSeconds: TimeInterval = 300
private static let minimumTokenLength = 3

private var lastKnownChangeCount: Int?
private var lastChangeDate: Date?
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,
precedingText: String
) -> String? {
guard let clipboard else { return nil }

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()
}

guard let lastChangeDate,
dateProvider().timeIntervalSince(lastChangeDate) < Self.staleThresholdSeconds
else {
return nil
}

let clipboardTokens = Self.tokens(from: clipboard)
let prefixTokens = Self.tokens(from: precedingText)
guard !clipboardTokens.isDisjoint(with: prefixTokens) else {
return nil
}

return clipboard
}

private static func tokens(from text: String) -> Set<String> {
PromptContextSanitizer.significantTokens(from: text, minimumLength: minimumTokenLength)
}
}
7 changes: 7 additions & 0 deletions Cotabby/Support/PromptContextSanitizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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) }
}
Expand Down
18 changes: 14 additions & 4 deletions Cotabby/Support/SuggestionRequestFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,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 {
Expand All @@ -109,7 +114,8 @@ enum SuggestionRequestFactory {

private static func activeClipboardContext(
rawContext: String?,
settings: SuggestionSettingsSnapshot
settings: SuggestionSettingsSnapshot,
prefixText: String
) -> String? {
guard settings.isClipboardContextEnabled,
let rawContext
Expand All @@ -124,7 +130,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? {
Expand Down
Loading