Cursor: Apply local changes for cloud agent
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
// ClipboardReplyFeedbackStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Keeps a bounded, device-local record of explicit clipboard-reply choices.
|
||||
// These model-generated candidates are comparative preference evidence, not
|
||||
// user-authored corpus. The store is intentionally excluded from iCloud sync.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct ClipboardReplyCandidateSnapshot: Codable, Equatable, Identifiable, Sendable {
|
||||
public enum Kind: String, Codable, CaseIterable, Sendable {
|
||||
case ordinary
|
||||
case formal
|
||||
case playful
|
||||
}
|
||||
|
||||
public let id: UUID
|
||||
public let kind: Kind
|
||||
public let text: String
|
||||
public let emotion: String
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
kind: Kind,
|
||||
text: String,
|
||||
emotion: String
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.text = text
|
||||
self.emotion = emotion
|
||||
}
|
||||
}
|
||||
|
||||
public struct ClipboardReplyFeedbackRecord: Codable, Equatable, Identifiable, Sendable {
|
||||
public enum Outcome: String, Codable, Sendable {
|
||||
case awaitingSelection
|
||||
case selected
|
||||
case discarded
|
||||
}
|
||||
|
||||
public let id: UUID
|
||||
public let createdAt: Date
|
||||
public let sourceText: String
|
||||
public let candidates: [ClipboardReplyCandidateSnapshot]
|
||||
public let styleID: String?
|
||||
public var selectedCandidateID: UUID?
|
||||
public var answerID: UUID?
|
||||
public var outcome: Outcome
|
||||
public var finalText: String?
|
||||
public var finalRevision: Int64?
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
createdAt: Date = Date(),
|
||||
sourceText: String,
|
||||
candidates: [ClipboardReplyCandidateSnapshot],
|
||||
styleID: String?,
|
||||
selectedCandidateID: UUID? = nil,
|
||||
answerID: UUID? = nil,
|
||||
outcome: Outcome = .awaitingSelection,
|
||||
finalText: String? = nil,
|
||||
finalRevision: Int64? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.createdAt = createdAt
|
||||
self.sourceText = sourceText
|
||||
self.candidates = candidates
|
||||
self.styleID = styleID
|
||||
self.selectedCandidateID = selectedCandidateID
|
||||
self.answerID = answerID
|
||||
self.outcome = outcome
|
||||
self.finalText = finalText
|
||||
self.finalRevision = finalRevision
|
||||
}
|
||||
|
||||
public var selectedCandidate: ClipboardReplyCandidateSnapshot? {
|
||||
guard let selectedCandidateID else { return nil }
|
||||
return candidates.first { $0.id == selectedCandidateID }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class ClipboardReplyFeedbackStore {
|
||||
public static let shared = ClipboardReplyFeedbackStore()
|
||||
|
||||
public static let maximumRecords = 100
|
||||
public static let retentionInterval: TimeInterval = 30 * 24 * 60 * 60
|
||||
public static let maximumSourceCharacters = 4_000
|
||||
public static let maximumCandidateCharacters = 1_500
|
||||
public static let maximumPayloadBytes = 512 * 1_024
|
||||
|
||||
private static let storageKey = "clipboard.replyFeedback.v1"
|
||||
|
||||
private let defaults: UserDefaults?
|
||||
|
||||
public init(defaults: UserDefaults? = AppGroup.defaultsIfAvailable) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public func records(now: Date = Date()) -> [ClipboardReplyFeedbackRecord] {
|
||||
guard let defaults,
|
||||
let data = defaults.data(forKey: Self.storageKey),
|
||||
let decoded = try? JSONDecoder().decode(
|
||||
[ClipboardReplyFeedbackRecord].self,
|
||||
from: data
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
let result = sanitized(decoded, now: now)
|
||||
if result != decoded {
|
||||
persist(result, now: now)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Converts local records into the bounded comparative schema accepted by
|
||||
/// Personal Style V2. Awaiting rows are excluded because they contain no
|
||||
/// user decision.
|
||||
public func learningExamples(
|
||||
now: Date = Date()
|
||||
) -> [PolishStyleReplyLearningExample] {
|
||||
records(now: now).compactMap { record in
|
||||
guard record.outcome != .awaitingSelection,
|
||||
let ordinary = record.candidates.first(where: {
|
||||
$0.kind == .ordinary
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
let selection: PolishStyleReplySelection
|
||||
if record.outcome == .discarded {
|
||||
selection = .discarded
|
||||
} else {
|
||||
guard let selected = record.selectedCandidate else { return nil }
|
||||
selection = learningSelection(for: selected.kind)
|
||||
}
|
||||
return PolishStyleReplyLearningExample(
|
||||
receivedMessage: record.sourceText,
|
||||
ordinaryCandidate: ordinary.text,
|
||||
formalCandidate: record.candidates.first(where: {
|
||||
$0.kind == .formal
|
||||
})?.text,
|
||||
playfulCandidate: record.candidates.first(where: {
|
||||
$0.kind == .playful
|
||||
})?.text,
|
||||
selection: selection,
|
||||
finalEdit: record.finalText,
|
||||
createdAt: record.createdAt,
|
||||
styleID: record.styleID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a comparative feedback record after reply candidates have been
|
||||
/// generated. Returns nil when the source or any candidate is sensitive.
|
||||
@discardableResult
|
||||
public func begin(
|
||||
sourceText: String,
|
||||
candidates: [ClipboardReplyCandidateSnapshot],
|
||||
styleID: String?,
|
||||
now: Date = Date()
|
||||
) -> UUID? {
|
||||
guard let source = acceptedBoundedText(
|
||||
sourceText,
|
||||
maximumCharacters: Self.maximumSourceCharacters
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let kinds = Set(candidates.map(\.kind))
|
||||
guard !candidates.isEmpty,
|
||||
candidates.count == kinds.count else {
|
||||
return nil
|
||||
}
|
||||
let sanitizedCandidates = candidates.compactMap { candidate
|
||||
-> ClipboardReplyCandidateSnapshot? in
|
||||
guard let text = acceptedBoundedText(
|
||||
candidate.text,
|
||||
maximumCharacters: Self.maximumCandidateCharacters
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return ClipboardReplyCandidateSnapshot(
|
||||
id: candidate.id,
|
||||
kind: candidate.kind,
|
||||
text: text,
|
||||
emotion: String(candidate.emotion.prefix(48))
|
||||
)
|
||||
}
|
||||
guard sanitizedCandidates.count == candidates.count else { return nil }
|
||||
|
||||
let record = ClipboardReplyFeedbackRecord(
|
||||
createdAt: now,
|
||||
sourceText: source,
|
||||
candidates: sanitizedCandidates,
|
||||
styleID: normalizedStyleID(styleID)
|
||||
)
|
||||
var next = records(now: now)
|
||||
next.insert(record, at: 0)
|
||||
persist(next, now: now)
|
||||
return record.id
|
||||
}
|
||||
|
||||
public func recordSelection(
|
||||
recordID: UUID,
|
||||
candidateID: UUID,
|
||||
answerID: UUID
|
||||
) {
|
||||
var next = records()
|
||||
guard let index = next.firstIndex(where: { $0.id == recordID }),
|
||||
next[index].candidates.contains(where: { $0.id == candidateID }) else {
|
||||
return
|
||||
}
|
||||
next[index].selectedCandidateID = candidateID
|
||||
next[index].answerID = answerID
|
||||
next[index].outcome = .selected
|
||||
persist(next)
|
||||
}
|
||||
|
||||
public func recordDiscard(recordID: UUID) {
|
||||
var next = records()
|
||||
guard let index = next.firstIndex(where: { $0.id == recordID }),
|
||||
next[index].outcome == .awaitingSelection else {
|
||||
return
|
||||
}
|
||||
next[index].outcome = .discarded
|
||||
persist(next)
|
||||
}
|
||||
|
||||
/// Records only edits that OSGKeyboard can associate with the exact AI
|
||||
/// answer ID. Arbitrary host-app edits are intentionally not inferred.
|
||||
public func recordFinalEdit(
|
||||
answerID: UUID,
|
||||
text: String,
|
||||
revision: Int64
|
||||
) {
|
||||
guard revision > 0,
|
||||
let finalText = acceptedBoundedText(
|
||||
text,
|
||||
maximumCharacters: Self.maximumCandidateCharacters
|
||||
) else {
|
||||
return
|
||||
}
|
||||
var next = records()
|
||||
guard let index = next.firstIndex(where: { $0.answerID == answerID }),
|
||||
next[index].outcome == .selected else {
|
||||
return
|
||||
}
|
||||
next[index].finalText = finalText
|
||||
next[index].finalRevision = revision
|
||||
persist(next)
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
defaults?.removeObject(forKey: Self.storageKey)
|
||||
}
|
||||
|
||||
private func acceptedBoundedText(
|
||||
_ raw: String,
|
||||
maximumCharacters: Int
|
||||
) -> String? {
|
||||
guard let accepted = ClipboardHistoryPolicy.acceptedText(from: raw) else {
|
||||
return nil
|
||||
}
|
||||
return String(accepted.prefix(maximumCharacters))
|
||||
}
|
||||
|
||||
private func normalizedStyleID(_ raw: String?) -> String? {
|
||||
let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return value.isEmpty ? nil : String(value.prefix(128))
|
||||
}
|
||||
|
||||
private func learningSelection(
|
||||
for kind: ClipboardReplyCandidateSnapshot.Kind
|
||||
) -> PolishStyleReplySelection {
|
||||
switch kind {
|
||||
case .ordinary:
|
||||
return .ordinary
|
||||
case .formal:
|
||||
return .formal
|
||||
case .playful:
|
||||
return .playful
|
||||
}
|
||||
}
|
||||
|
||||
private func sanitized(
|
||||
_ records: [ClipboardReplyFeedbackRecord],
|
||||
now: Date
|
||||
) -> [ClipboardReplyFeedbackRecord] {
|
||||
let cutoff = now.addingTimeInterval(-Self.retentionInterval)
|
||||
return Array(
|
||||
records
|
||||
.filter { $0.createdAt >= cutoff && $0.createdAt <= now }
|
||||
.sorted { $0.createdAt > $1.createdAt }
|
||||
.prefix(Self.maximumRecords)
|
||||
)
|
||||
}
|
||||
|
||||
private func persist(
|
||||
_ records: [ClipboardReplyFeedbackRecord],
|
||||
now: Date = Date()
|
||||
) {
|
||||
guard let defaults else { return }
|
||||
var next = sanitized(records, now: now)
|
||||
var encoded = try? JSONEncoder().encode(next)
|
||||
while (encoded?.count ?? 0) > Self.maximumPayloadBytes, !next.isEmpty {
|
||||
next.removeLast()
|
||||
encoded = try? JSONEncoder().encode(next)
|
||||
}
|
||||
guard !next.isEmpty, let encoded else {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
return
|
||||
}
|
||||
defaults.set(encoded, forKey: Self.storageKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user