Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions KeyType/Logic/Completion/CompletionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -577,13 +577,21 @@ final class CompletionController {
let healExtraTokens = healSlack > 0 ? 1 : 0
// Completion length is user-configurable (Settings) and maps to the decoder's token/width budget.
let length = settings.completionLength
// Clipboard and OCR are background context, not text to reproduce; carry them so the output
// filter can drop a completion that just parrots them verbatim. History is excluded — it is
// already same-app/domain scoped and echoing the user's own recurring phrases is intended.
let injectedContext = Self.injectedContext(
pasteboardText: sideContext.pasteboardText,
screenText: sideContext.screenText
)
let request = CompletionRequest(
context: context,
prompt: promptResult.prompt,
requiredPrefixBytes: requiredPrefixBytes,
mode: policy.completionMode,
maxCompletionTokens: length.maxCompletionTokens + healExtraTokens,
maxDisplayWidth: length.maxDisplayWidth + healSlack
maxDisplayWidth: length.maxDisplayWidth + healSlack,
injectedContext: injectedContext
)
rememberFullPromptDebug(
for: request,
Expand Down Expand Up @@ -886,11 +894,19 @@ final class CompletionController {
return (cached, true)
}

// Scope history to the focused app. Cross-app recent samples bleed unrelated content into the
// prompt — e.g. a Notes draft about an API key surfacing as a verbatim suggestion in a fresh
// Gmail message — which the small model tends to parrot. Same-app history still personalizes
// tone/recurring phrases without leaking content across contexts.
// Normalize an empty domain to nil so it can't collapse the same-app filter to `domain == ""`
// and silently drop all real history for the app.
let scopedDomain = context.target.domain.flatMap { $0.isEmpty ? nil : $0 }
let query = WritingHistoryQuery(
bundleIdentifier: context.target.bundleIdentifier,
domain: context.target.domain,
domain: scopedDomain,
typingContext: context.typingContext,
language: context.detectedLanguage
language: context.detectedLanguage,
sameAppOnly: true
)
let previousUserInputs = settings.historyEnabled
? history.samples(for: query)
Expand Down Expand Up @@ -970,6 +986,59 @@ final class CompletionController {
case notApplicable
}

/// Clipboard + OCR text injected into the prompt, as the echo guard consumes it. History is
/// intentionally excluded (same-app/domain scoped; echoing the user's own phrases is intended).
private static func injectedContext(pasteboardText: String?, screenText: String?) -> [String] {
[pasteboardText, screenText].compactMap { $0 }
}

/// Re-check the context-dependent suppression nets against the *live* context before re-showing a
/// cached completion. The candidate was filtered once at generation time, but reuse re-shows it
/// without going back through the pipeline, and the inputs those nets key off can change after the
/// fact:
/// - prefix-repetition / suffix-overlap key off `beforeCursor`/`afterCursor`, which grow as the
/// user types through the suggestion — a tail clean at anchor time can become a verbatim
/// repetition (or suffix duplication) of text just typed;
/// - the echo guard keys off injected clipboard/OCR context, which can change mid-burst or differ
/// from when an older reused snapshot was generated. We check it against the currently-frozen
/// side context (already cached, so no hot-path pasteboard read).
/// Returns `true` when the remaining text is still safe to show.
private func reuseRemainingPassesLiveGuards(remaining: String, context: TextFieldContext) -> Bool {
Self.reuseRemainingIsSafe(
remaining: remaining,
context: context,
injectedContext: Self.injectedContext(
pasteboardText: frozenSideContext?.pasteboardText,
screenText: frozenSideContext?.screenText
)
)
}

/// Pure decision behind `reuseRemainingPassesLiveGuards`, factored out so the reuse-safety rules
/// are unit-testable without constructing a controller. `true` when `remaining` is still safe to
/// re-show against the given live context and injected side context.
nonisolated static func reuseRemainingIsSafe(
remaining: String,
context: TextFieldContext,
injectedContext: [String]
) -> Bool {
guard !remaining.isEmpty else { return true }
if PrefixRepetitionGuard.repeatsPrefix(completion: remaining, beforeCursor: context.beforeCursor) {
return false
}
if SuffixOverlapGuard.duplicatesSuffix(
completion: remaining,
beforeCursor: context.beforeCursor,
afterCursor: context.afterCursor
) {
return false
}
if ContextEchoGuard.echoesInjectedContext(completion: remaining, injectedContext: injectedContext) {
return false
}
return true
}

@discardableResult
private func applyReuseHistoryIfUseful(
for live: TextFieldContext,
Expand All @@ -980,6 +1049,11 @@ final class CompletionController {

switch reuseHistory.decision(for: live) {
case let .reuse(reuse):
guard reuseRemainingPassesLiveGuards(remaining: reuse.remainingText, context: live) else {
predictionLog.append("REUSE rejected by live guard remaining=\"\(PredictionLog.escape(reuse.remainingText))\"")
clearCompletion()
return .mustRecompute
}
anchorText = reuse.anchorText
anchorContext = reuse.anchorContext
if updateLatestContext { latestContext = live }
Expand Down Expand Up @@ -1292,6 +1366,10 @@ final class CompletionController {
) -> Bool {
switch decision {
case let .reuse(reuse):
guard reuseRemainingPassesLiveGuards(remaining: reuse.remainingText, context: optimistic) else {
predictionLog.append("REUSE rejected by live guard remaining=\"\(PredictionLog.escape(reuse.remainingText))\"")
return false
}
anchorText = reuse.anchorText
anchorContext = reuse.anchorContext
latestContext = optimistic
Expand Down
18 changes: 17 additions & 1 deletion KeyType/Logic/Context/ScreenContextController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ final class ScreenContextController {
let key = windowKey(for: snapshot)
guard key != lastWindowKey else { return }
lastWindowKey = key
// Drop the previous window's cached OCR *before* kicking off the new (async) capture, so a
// completion fired in the just-focused window can't be fed the prior window's screen text
// while the fresh capture is still in flight. Without this, switching browser tabs/windows
// leaks the old page's text (e.g. a "2 of 10 …" results counter) into the new one's prompt.
engine.clear()
capture(for: snapshot)
}

Expand All @@ -120,7 +125,18 @@ final class ScreenContextController {
// screen context carries only the *surrounding* on-screen text.
let context = snapshot.context
let fieldText = context.beforeCursor + context.afterCursor
engine.refresh(pid: pid, fieldText: fieldText)
// The caret location lets the capturer pick the right window when the app has several open,
// so screen context can't bleed in text from a different window of the same app. `caretRect`
// is in AppKit space (bottom-left origin) but ScreenCaptureKit window frames are in CG space
// (top-left origin), so convert before handing it down — otherwise the Y axes don't match and
// the wrong window (or none) is selected.
let focusPoint = snapshot.caretRect.flatMap { rect -> CGPoint? in
DisplayCoordinateConverter.coreGraphicsPoint(
fromAppKitPoint: CGPoint(x: rect.midX, y: rect.midY),
displays: ScreenDisplayGeometryProvider.current()
)
}
engine.refresh(pid: pid, fieldText: fieldText, focusPoint: focusPoint)
}

// MARK: - Eligibility
Expand Down
42 changes: 42 additions & 0 deletions KeyTypeTests/KeyTypeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,48 @@ struct KeyTypeTests {
#expect(advanced == nil)
}

// MARK: - Reuse re-check (H2)

@Test func reuseRejectsRemainingThatRepeatsRecentlyTypedText() {
// As the user types through a cached suggestion, beforeCursor grows; a tail that becomes a
// verbatim repetition of just-typed text must not be re-shown via reuse.
let context = TextFieldContext(
beforeCursor: "You can use it to access the OpenAI. And",
target: Self.target
)
#expect(
CompletionController.reuseRemainingIsSafe(
remaining: " you can use it to access the OpenAI again",
context: context,
injectedContext: []
) == false
)
}

@Test func reuseRejectsRemainingThatEchoesInjectedClipboard() {
// A cached completion (clean at anchor time) must not be re-shown if it now parrots the
// currently-injected clipboard/OCR context.
let context = TextFieldContext(beforeCursor: "Hi Molly,", target: Self.target)
#expect(
CompletionController.reuseRemainingIsSafe(
remaining: " if you require maintenance of UPS systems or",
context: context,
injectedContext: ["if you require maintenance of UPS systems or backup power, call us."]
) == false
)
}

@Test func reuseAllowsGenuineRemaining() {
let context = TextFieldContext(beforeCursor: "Hi Molly,", target: Self.target)
#expect(
CompletionController.reuseRemainingIsSafe(
remaining: " hope you are doing well today",
context: context,
injectedContext: ["if you require maintenance of UPS systems or backup power, call us."]
)
)
}

@Test func promotionCachePromotesLowerRankedBranchWhenTopIsInvalidated() {
let cache = Self.promotionCache(candidates: [
"ship it today",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

/// Shared text normalization for the content-overlap guards (`SuffixOverlapGuard`,
/// `PrefixRepetitionGuard`, `ContextEchoGuard`). Comparisons are done on case-folded alphanumeric
/// scalars only, so differences in whitespace, punctuation, and stray symbol glyphs the model
/// sometimes prepends ("**", "•") don't defeat a match.
enum AlphanumericNormalizer {
/// Case-folded string of only the alphanumeric scalars in `text`.
static func normalize(_ text: String) -> String {
var result = String.UnicodeScalarView()
for scalar in text.lowercased().unicodeScalars where CharacterSet.alphanumerics.contains(scalar) {
result.append(scalar)
}
return String(result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,21 +135,29 @@ public struct CompletionRequest: Equatable {
public var mode: CompletionMode
public var maxCompletionTokens: Int
public var maxDisplayWidth: Int
/// Side-context text injected into the prompt that the user did NOT type — clipboard contents and
/// on-screen OCR text. Carried alongside the request so the output filter can drop a completion
/// that merely parrots it verbatim (`ContextEchoGuard`). Writing-history samples are deliberately
/// excluded: they are scoped to the same app/domain and reproducing the user's own recurring
/// phrases is the point of that feature.
public var injectedContext: [String]

public init(
context: TextFieldContext,
prompt: String,
requiredPrefixBytes: [UInt8] = [],
mode: CompletionMode = .prose,
maxCompletionTokens: Int = 4,
maxDisplayWidth: Int = 80
maxDisplayWidth: Int = 80,
injectedContext: [String] = []
) {
self.context = context
self.prompt = prompt
self.requiredPrefixBytes = requiredPrefixBytes
self.mode = mode
self.maxCompletionTokens = maxCompletionTokens
self.maxDisplayWidth = maxDisplayWidth
self.injectedContext = injectedContext
}
}

Expand Down Expand Up @@ -202,6 +210,13 @@ public enum SuppressionReason: Equatable {
/// A mid-line / fill-in-the-middle completion that is too long or too low-probability to show
/// without risking a wrong suggestion.
case lowConfidenceMidLine
/// The completion reproduces a phrase that is already present in the recent text before the caret.
/// Accepting it would create a verbatim repetition loop. See `PrefixRepetitionGuard`.
case repeatsRecentPrefix
/// The completion verbatim-reproduces a span of injected side context the user did not type
/// (clipboard, on-screen OCR text) — the small model parroting context instead of predicting.
/// See `ContextEchoGuard`.
case echoesInjectedContext
case noCandidate
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Foundation

/// Shared "does this completion reproduce a phrase from some text" test, used by both
/// `PrefixRepetitionGuard` (against the recent typed prefix) and `ContextEchoGuard` (against injected
/// side context). Two shapes are detected on case-folded alphanumerics:
///
/// 1. **Whole** — the entire (normalized) completion is a substring of the text. A strong signal, so
/// a short match (`minimumWhole`) is enough.
/// 2. **Leading** — the completion *begins* with a run that appears in the text and then diverges, so
/// shape 1 misses it. A leading run of length ≥ `minimumLeading` exists iff the leading slice of
/// exactly that length is a substring (any longer contained run has it as a prefix), so one
/// `contains` decides it. The larger floor keeps chance word collisions from firing.
enum RepeatedSpanDetector {
static func reproduces(
normalizedCompletion: String,
within normalizedText: String,
minimumWhole: Int,
minimumLeading: Int
) -> Bool {
guard !normalizedCompletion.isEmpty, !normalizedText.isEmpty else { return false }

if normalizedCompletion.count >= minimumWhole,
normalizedText.contains(normalizedCompletion) {
return true
}

guard normalizedCompletion.count >= minimumLeading else { return false }
return normalizedText.contains(String(normalizedCompletion.prefix(minimumLeading)))
}
}

/// Detects completions that merely parrot injected side context — clipboard contents or on-screen
/// OCR text the prompt carries but the user did not type. The small model frequently copies such
/// context verbatim instead of using it as background (e.g. text copied from a localhost page in
/// one browser surfacing as a suggestion in a different app's compose field).
///
/// Writing-history samples are intentionally NOT passed here: they are already scoped to the same
/// app/domain, and reproducing the user's own recurring phrases (a signature, a stock reply) is the
/// purpose of that personalization — suppressing it would be a regression.
public enum ContextEchoGuard {

/// `true` when `completion` verbatim-reproduces a span of any string in `injectedContext`.
///
/// `minimumWhole` is a touch higher than `PrefixRepetitionGuard`'s because the injected corpus is
/// larger (more chance of an incidental short match); `minimumLeading` matches it.
public static func echoesInjectedContext(
completion: String,
injectedContext: [String],
minimumWhole: Int = 12,
minimumLeading: Int = 16
) -> Bool {
guard !injectedContext.isEmpty else { return false }
let normalizedCompletion = AlphanumericNormalizer.normalize(completion)
guard !normalizedCompletion.isEmpty else { return false }

for sample in injectedContext {
let normalizedSample = AlphanumericNormalizer.normalize(sample)
if RepeatedSpanDetector.reproduces(
normalizedCompletion: normalizedCompletion,
within: normalizedSample,
minimumWhole: minimumWhole,
minimumLeading: minimumLeading
) {
return true
}
}
return false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation

/// Detects completions that would create a verbatim repetition loop by reproducing a phrase already
/// present in the recent text before the cursor.
///
/// The failure mode this guards against: the model predicts " i want to write about" after
/// "...AI meetup." because that exact phrase already appeared earlier in the text. If the user
/// accepts it, the sentence repeats — and the model will predict the same continuation again,
/// looping indefinitely.
///
/// Two repetition shapes are caught, both on case-folded alphanumerics within the last
/// `lookbackCharacters` of `beforeCursor`:
///
/// 1. **Whole-completion** — the entire suggestion already appears verbatim in the recent text. A
/// strong signal, so a short match (`minimumAlphanumericLength`) is enough.
/// 2. **Leading** — the suggestion *begins* by reproducing a recent phrase and then diverges
/// ("…access the OpenAI" + " API to do X"). The whole string is no longer a substring, so shape 1
/// misses it; this catches it when the repeated leading run is long enough
/// (`minimumLeadingRepeat`) to be a genuine loop rather than a chance word collision.
///
/// The minimum lengths keep short common phrases ("the", "and") from triggering false positives.
public enum PrefixRepetitionGuard {

/// `true` when `completion` reproduces a phrase that already appears in the recent prefix,
/// meaning accepting it would create a repetition.
public static func repeatsPrefix(
completion: String,
beforeCursor: String,
lookbackCharacters: Int = 300,
minimumAlphanumericLength: Int = 8,
minimumLeadingRepeat: Int = 16
) -> Bool {
let normalizedCompletion = AlphanumericNormalizer.normalize(completion)

// Only look back a bounded window — we don't want to suppress completions that share a
// common phrase with text written hours ago in a very long document.
let lookback = String(beforeCursor.suffix(lookbackCharacters))
let normalizedPrefix = AlphanumericNormalizer.normalize(lookback)

// Shape 1 (whole) catches a short verbatim repeat; shape 2 (leading) catches a repeat that
// then diverges. See `RepeatedSpanDetector`.
return RepeatedSpanDetector.reproduces(
normalizedCompletion: normalizedCompletion,
within: normalizedPrefix,
minimumWhole: minimumAlphanumericLength,
minimumLeading: minimumLeadingRepeat
)
}
}
Loading