From a8dfda625bfce885cb483c1c30e9a099426349ef Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 12:33:38 +0200 Subject: [PATCH 1/2] Add opt-in typing history: learn from what the user writes, import Cotypist Cotabby had no memory of past writing; every suggestion saw only the current field. This adds a local, encrypted typing history that shapes suggestions on the on-device engines. - TypingHistoryStore records the text of fields where Cotabby is active (never secure fields, disabled or excluded apps, or while paused), scrubs secret-like tokens, and seals the archive with AES-GCM under a ThisDeviceOnly Keychain key (TypingHistoryVault). Delete All removes the file and the key. - History is used two ways: TypingHistoryIndex adds two short passages of similar past writing to the prompt (refreshed per 8-word block so the llama KV prefix stays reusable), and TypingHistoryPhraseEngine answers from TypingHistoryPhrasePredictor when history is confident how a phrase ends, without calling the model. - Only text before the caret is learned from; the rest of a field is often a quoted thread someone else wrote. - A Cotypist user_inputs.json export can be imported, collapsing its repeated snapshots of the same field. - On-device only: the provider returns nothing for the endpoint engine, the request factory drops examples for it, and the router refuses any request that still carries them (power-source switching can change the live engine after a request is built). - Settings -> Context gains a Typing History section; both switches are off by default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017xvrRyxDAooaBCvNfZiAA7 --- ARCHITECTURE.md | 13 + Cotabby.xcodeproj/project.pbxproj | 118 ++++++ .../Coordinators/SettingsCoordinator.swift | 6 +- .../SuggestionCoordinator+Continuation.swift | 3 +- .../SuggestionCoordinator+Input.swift | 3 +- .../SuggestionCoordinator+Prediction.swift | 9 +- .../Suggestion/SuggestionCoordinator.swift | 13 + Cotabby/App/Core/AppDelegate.swift | 2 + Cotabby/App/Core/CotabbyAppEnvironment.swift | 37 +- .../Models/History/TypingHistoryModels.swift | 72 ++++ .../Request/SuggestionRequest.swift | 6 + .../SuggestionSubsystemContracts.swift | 13 + .../Services/History/TypingHistoryStore.swift | 369 ++++++++++++++++++ .../Services/History/TypingHistoryVault.swift | 131 +++++++ .../Runtime/SuggestionEngineRouter.swift | 10 + .../Runtime/TypingHistoryPhraseEngine.swift | 64 +++ .../History/CotypistExportImporter.swift | 105 +++++ .../Support/History/TypingHistoryIndex.swift | 202 ++++++++++ .../TypingHistoryPhrasePredictor.swift | 233 +++++++++++ .../History/TypingHistoryScrubber.swift | 90 +++++ .../BaseCompletionPromptRenderer.swift | 21 + .../FoundationModelPromptRenderer.swift | 10 + .../Request/SuggestionRequestFactory.swift | 9 +- .../UI/Settings/Panes/ContextPaneView.swift | 3 + .../Panes/TypingHistorySectionView.swift | 118 ++++++ .../UI/Settings/SettingsContainerView.swift | 4 +- Cotabby/UI/Settings/SettingsIndex.swift | 9 +- .../History/TypingHistoryStoreTests.swift | 244 ++++++++++++ .../Runtime/SuggestionEngineRouterTests.swift | 12 + .../TypingHistoryPhraseEngineTests.swift | 116 ++++++ .../History/CotypistExportImporterTests.swift | 50 +++ .../History/TypingHistoryIndexTests.swift | 75 ++++ .../TypingHistoryPhrasePredictorTests.swift | 121 ++++++ .../History/TypingHistoryScrubberTests.swift | 38 ++ .../TestSupport/CotabbyTestFixtures.swift | 2 + SOURCE_LAYOUT.md | 3 + 36 files changed, 2321 insertions(+), 13 deletions(-) create mode 100644 Cotabby/Models/History/TypingHistoryModels.swift create mode 100644 Cotabby/Services/History/TypingHistoryStore.swift create mode 100644 Cotabby/Services/History/TypingHistoryVault.swift create mode 100644 Cotabby/Services/Runtime/TypingHistoryPhraseEngine.swift create mode 100644 Cotabby/Support/History/CotypistExportImporter.swift create mode 100644 Cotabby/Support/History/TypingHistoryIndex.swift create mode 100644 Cotabby/Support/History/TypingHistoryPhrasePredictor.swift create mode 100644 Cotabby/Support/History/TypingHistoryScrubber.swift create mode 100644 Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift create mode 100644 CotabbyTests/Services/History/TypingHistoryStoreTests.swift create mode 100644 CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift create mode 100644 CotabbyTests/Support/History/CotypistExportImporterTests.swift create mode 100644 CotabbyTests/Support/History/TypingHistoryIndexTests.swift create mode 100644 CotabbyTests/Support/History/TypingHistoryPhrasePredictorTests.swift create mode 100644 CotabbyTests/Support/History/TypingHistoryScrubberTests.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f21a3837..ece20a82 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -396,6 +396,19 @@ before marking a secure field blocked; that lower-level acquisition remains sepa Endpoint credentials are stored in Keychain. A remote endpoint receives its bounded, legacy-scope request; its privacy scope must remain visible in settings and documentation. +Typing history (Settings → Context → Typing History) is the one store of the user's writing that +outlives its field. Both of its switches are off by default. When recording is on, +`TypingHistoryStore` keeps the text of fields where Cotabby is active (never secure fields, disabled +or excluded apps, or while paused), scrubs secret-like tokens (`TypingHistoryScrubber`), and seals +the archive with AES-GCM under a Keychain key that never syncs (`TypingHistoryVault`). Delete All +removes the file and the key. A Cotypist `user_inputs.json` export can be imported. Only text that +was before the caret is learned from, because the rest of a field is often a quoted thread. History +shapes suggestions in two ways: `TypingHistoryIndex` adds two short passages of similar past writing +to the prompt, and `TypingHistoryPhraseEngine` answers from `TypingHistoryPhrasePredictor` when +history confidently knows how a phrase ends. Both are on-device only: the provider returns nothing +for the endpoint, the request factory drops examples for it, and the router refuses to send any +request that still carries them. + ## Presentation and Sibling Features [SuggestionOverlayPresenter.swift](Cotabby/Services/Suggestion/SuggestionOverlayPresenter.swift) diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index ddd90ac8..c17ed32b 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ 109F9BD7E1AE3E7FB0C712AA /* GhostTextLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EB202C5AD6079174A14330C /* GhostTextLayoutTests.swift */; }; 1104ABD01283774EDF249EF0 /* LLMIOFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A2F75C66BECA61A722D3FB /* LLMIOFileHandler.swift */; }; 111905666B5F1D0B65B87D44 /* CapturedInputEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353191D1D8A1C655E1B5F562 /* CapturedInputEventTests.swift */; }; + 113710208D17C1998ED307E1 /* TypingHistoryScrubber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880F8FF215912CD4C337F8E /* TypingHistoryScrubber.swift */; }; 113D4A18C8782C578463A6C4 /* GhostTextLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C0792BF971B702DE0A095E /* GhostTextLayout.swift */; }; 11C548C2B50D9B27BD1B7293 /* SymSpell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E9AF341F8F7043D5344B20 /* SymSpell.swift */; }; 11D3A5C8DA17BCDB79969FC4 /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C90C9EBCB70327D215EAE07 /* FileLogHandler.swift */; }; @@ -151,6 +152,7 @@ 226C258F185E2E71518F4ABB /* OnboardingTemplateRecommender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F86D2716CBE36970BB462E8 /* OnboardingTemplateRecommender.swift */; }; 231905D5C463FA994CACFA77 /* AppsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */; }; 23676A1A7099AC04091FD6A3 /* EmojiPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B4A4368DB33C25E3AB5F3 /* EmojiPaneView.swift */; }; + 23F0FA44C965F97181F082D8 /* TypingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */; }; 2420363A5F652F51693A018B /* ScreenshotContextGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */; }; 24A2D498222EB81B64EC36C9 /* MenuBarPopoverDismisser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADE6EEA8248DA6700E11844 /* MenuBarPopoverDismisser.swift */; }; 24A393E033AB22A1C9951770 /* DecodeStopPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81D7F428477976398CFC5CCF /* DecodeStopPolicyTests.swift */; }; @@ -178,9 +180,11 @@ 2AF12199E0FD9B802BF35A10 /* SpellingDictionaryCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D41CC179EC04093971A0D0 /* SpellingDictionaryCatalog.swift */; }; 2B355C61C20866199F414EB9 /* PermissionOverlayTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E286B9A912808088FDA6B4C4 /* PermissionOverlayTracker.swift */; }; 2B4049F81730E41A1274E57F /* ClipboardContentDistiller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2229037BCDF7EFD3D47C15F /* ClipboardContentDistiller.swift */; }; + 2BA57F29DDD4CEB327CD805E /* TypingHistoryVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED222C2589A4787AFCA90EFD /* TypingHistoryVault.swift */; }; 2C4480F23602E7F7FAB50A5D /* CompletionSeamGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A4C5DEA32868F99E953D99 /* CompletionSeamGuard.swift */; }; 2C44C5142ED914BF99A70EC6 /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BC705A82669AEFBC704F59 /* InlinePreviewView.swift */; }; 2CA5740E5D8A1167043D6045 /* ArithmeticEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5897D28F4DBEAF4792FB4D24 /* ArithmeticEvaluator.swift */; }; + 2CDE0BF9AF320B269C82E937 /* TypingHistoryPhraseEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8A00E909E0A51B119F9CB6B /* TypingHistoryPhraseEngineTests.swift */; }; 2CDF880348D439708BD4F792 /* ru.txt in Resources */ = {isa = PBXBuildFile; fileRef = D8C2663427BC5219EA415D00 /* ru.txt */; }; 2CF9087AE6FDB2A182BA9C85 /* MirrorOverlayLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3732CD6886AEAF3C01F543E /* MirrorOverlayLayout.swift */; }; 2D33BFF41BDE6CE7464885B8 /* BrowserAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 266C8BF167118855C56FC7F4 /* BrowserAppDetector.swift */; }; @@ -260,6 +264,7 @@ 3EAFEEC83B25B023C606A726 /* ContextBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B8727111208CB506C25C4F /* ContextBuffer.swift */; }; 3F8E5F610B3AD803DDA007CC /* KeycapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF96999230079A768BEFD0FC /* KeycapView.swift */; }; 3FA7429EE4BDE4CE0D8E7DA0 /* SuggestionQualityMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC5CD24D2FF35FD36B8D78DE /* SuggestionQualityMetricsStoreTests.swift */; }; + 4002A961699B7565A9DB5BF7 /* TypingHistoryPhrasePredictor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CE9C2EE56728151E6CE5C7 /* TypingHistoryPhrasePredictor.swift */; }; 401409603DE521880F75C779 /* TerminalAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DC2CCFDB2BF1F1E1375620 /* TerminalAppDetector.swift */; }; 403C8D745B0F6133382D78F9 /* EmojiPickerPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872C8DDC2E86A1C4C4BBD99F /* EmojiPickerPanelController.swift */; }; 407D80367A0243FBCC9865D6 /* TrailingDuplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD851386E85D3ED0A1F17E47 /* TrailingDuplicationFilter.swift */; }; @@ -331,13 +336,16 @@ 543B45F8FF4A6AFCB9286521 /* Sparkle-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 70C489EC7EA50C2B103A6B09 /* Sparkle-LICENSE.txt */; }; 5461DD0133FE632B2EB4936A /* BrowserAppDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589029534F91D2BE26A65263 /* BrowserAppDetectorTests.swift */; }; 54F87EB87E46B987E3679514 /* FoundationModelSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17794AE5A5D5A93A7919E6BA /* FoundationModelSuggestionEngine.swift */; }; + 5509B327A1C9E67467333B06 /* CotypistExportImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD40DD8E9C1C8968956B0E /* CotypistExportImporter.swift */; }; 55160DA471782307483E359F /* SuggestionSessionReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CADFBB03C10309B9048C8E9 /* SuggestionSessionReconciler.swift */; }; 55243B4055E8067FE3483164 /* FieldStyleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C25CEF5C997F920C7BF0F /* FieldStyleCache.swift */; }; 555501CEB124DFDF657EACA4 /* TypingCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7014E6E969FD4579028BAC4 /* TypingCadence.swift */; }; 556197D64680237CBCCCDD9F /* OCRTextHygiene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736E6B390C822F91CC1DA99 /* OCRTextHygiene.swift */; }; 55DAFC9D06629C23A6868565 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53ED6F2F3354FB3E6065804 /* WelcomeTemplateStepView.swift */; }; + 5619C761998CCBB3FD50CBDB /* TypingHistoryScrubberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87625EB90EB00670718FB790 /* TypingHistoryScrubberTests.swift */; }; 56471188E5F78EE0785D7BF7 /* CotabbyInference-MIT.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2472C76E96009300EBF038D4 /* CotabbyInference-MIT.txt */; }; 564BA8F230F45E378D26B36C /* GhostSpaceBoundary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99C3973D57C7F53416CFBF6 /* GhostSpaceBoundary.swift */; }; + 56987393D95962CD49B4A4E8 /* TypingHistoryPhraseEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DB8BFC8B80A47088FDDFC24 /* TypingHistoryPhraseEngine.swift */; }; 56D2FDE72FE21C35E8BDD06C /* ActivationIndicatorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E379AF08CDFF65D6EFC565E /* ActivationIndicatorController.swift */; }; 56D7E792922B83393A578E5A /* CompletionRenderModePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 705E9119B1661BF464496D39 /* CompletionRenderModePolicy.swift */; }; 5732858F8FF26E50AE4BFF8C /* DebugForcedSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8D50115291212D03414CBE /* DebugForcedSuggestionEngine.swift */; }; @@ -384,6 +392,7 @@ 610650A14AC5E8EDDC81B4E9 /* PermissionReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A0DBF62BEF674B96CEBD2 /* PermissionReminderView.swift */; }; 6147AB8372652B7730749011 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F952E54538EF82D1B1E672D8 /* WelcomePermissionStepView.swift */; }; 61932DE224F103A22C8E4EF7 /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FD2044CDEA95EC02C9B2FE /* MarkerSelectionSynthesizer.swift */; }; + 620A8133C64753E17AD7CD67 /* TypingHistorySectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEEB296E66D24C95D3DABAB /* TypingHistorySectionView.swift */; }; 62B45766E8A2827E31AFD356 /* ScreenFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B49898B64D4DB50736CBC46 /* ScreenFrameReader.swift */; }; 62DBCF429B7F464A6B467725 /* OnboardingFeatureShowcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */; }; 632C9A837EC1949B098A0BD9 /* SettingsNavigationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CAE5772E4C748960219DA0 /* SettingsNavigationModelTests.swift */; }; @@ -423,6 +432,7 @@ 6B9B1630E1F30EAE429B5B60 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A051152D3C503A2339BF0FA /* FoundationModelPromptRenderer.swift */; }; 6BED0111C262E06BFF751BF4 /* SuggestionTextNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A19E3F68A42D2FAC95E000 /* SuggestionTextNormalizer.swift */; }; 6C4EFF98C9D606B3BE35D2A6 /* DownloadOutcomeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F324C645A4E53FB6E90061A /* DownloadOutcomeClassifier.swift */; }; + 6C75BC36C18440B54A3A35AC /* TypingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */; }; 6CB0A928230ACD262B02CFDF /* FocusDebugOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4F6E70B8A242F7BDE5361A /* FocusDebugOverlayController.swift */; }; 6CB435B203CBDD1F63B93332 /* PerAppShortcutOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A9F3C0F7B53B7F0D3BA82A /* PerAppShortcutOverride.swift */; }; 6CCFAA1D86C4E4DC7E3DB8A3 /* SuggestionSubsystemContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5652FFF8E718A17D98F87CD0 /* SuggestionSubsystemContracts.swift */; }; @@ -430,7 +440,9 @@ 6DB11ABBCB996178084FF5A2 /* WelcomeKeybindStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2576B762374F4D3CD8347AD4 /* WelcomeKeybindStepView.swift */; }; 6DFF14EEC5838BC309D03B09 /* CompletionRenderMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F93E4EC64A8773A1A7F3E24 /* CompletionRenderMode.swift */; }; 6E666BEE80ECE65CF71C7F56 /* EmojiPickerPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872C8DDC2E86A1C4C4BBD99F /* EmojiPickerPanelController.swift */; }; + 6E978AD7E340B2795F120AC0 /* CotypistExportImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD40DD8E9C1C8968956B0E /* CotypistExportImporter.swift */; }; 6EC1C0311B57074487F9658F /* GhostFontSizeLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61CA8FEFB45FB1CCA453A /* GhostFontSizeLimits.swift */; }; + 6F18827DF0D0CC9ADCCA83C2 /* TypingHistoryPhraseEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DB8BFC8B80A47088FDDFC24 /* TypingHistoryPhraseEngine.swift */; }; 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; 6F3B43828817C2C303E8D16E /* InlineCommandCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF0FF1CE97A0761ADAFEB8 /* InlineCommandCoordinatorTests.swift */; }; 6FA8CBA189210C9821B03656 /* TokenHealingBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AECDF57800BBB6617620D5 /* TokenHealingBuffer.swift */; }; @@ -453,6 +465,7 @@ 735C2E64CA51F58098B30A0D /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; 73A1C073056E07657992AE5A /* CaretTokenPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBE00782EE6813AF3224CD5 /* CaretTokenPosition.swift */; }; 73B7C4D5143F7AEC321BC873 /* PermissionAndContextModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AEBBBAB9588AA8EDBA41520 /* PermissionAndContextModelTests.swift */; }; + 73E3EFA8960FC779920935C4 /* TypingHistoryVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED222C2589A4787AFCA90EFD /* TypingHistoryVault.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 749C4953D3AFE86AFABA968C /* OnboardingTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C5E3A714F5A7818337101C /* OnboardingTemplateTests.swift */; }; 74C07F4568C4FAE1C344E789 /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD4DE5D9D8C9BB66B78633 /* TextDirectionDetectorTests.swift */; }; @@ -543,7 +556,9 @@ 8D380BEC82C2969F3ED2161A /* HardwareCapabilityProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F63A5EF992329FB9F6C4C26 /* HardwareCapabilityProbe.swift */; }; 8D83B47447409074AAD1A423 /* MidWordContinuationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F56B79BF030507CC7FAE4D /* MidWordContinuationPolicy.swift */; }; 8DA36F1521B6A59D8C20AC59 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 5A60D1467BBFECB3DFEB39C2 /* Logging */; }; + 8DCEEB9FCB3F7C90B7B4917F /* TypingHistoryIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3203A94F736B83237FEC9 /* TypingHistoryIndex.swift */; }; 8E209BC819EBBCA0E7F10775 /* FoundationModelAvailabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA97ED2A10499771F9C32B64 /* FoundationModelAvailabilityService.swift */; }; + 8E236047C490CD266E603823 /* TypingHistoryPhrasePredictorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB5276B7DDE61030A57EF9A /* TypingHistoryPhrasePredictorTests.swift */; }; 8E460A307B1ED0DA783CB247 /* FocusPollBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD5C8648740E7050CCA657F /* FocusPollBackoff.swift */; }; 8E4DFB1338AE3BDA8FF16B98 /* SuggestionContinuationPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8FE1B9BEA0B35C6910E534 /* SuggestionContinuationPlan.swift */; }; 8E5D6584BA4168C7E381EF91 /* SuggestionLengthPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA2EA95C425C3EAF246C66D /* SuggestionLengthPolicy.swift */; }; @@ -659,8 +674,10 @@ A9B01E476E483F2A712F1B90 /* HostFontRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F328B43743D803A16045110 /* HostFontRegistry.swift */; }; AA00D42DFD1EE094E01A7EEA /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7024D32C43EAD2C5F8689B2D /* AcknowledgementsView.swift */; }; AA2E09FF7E430D66ECA8ECD5 /* CotabbyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1EDFB535AAA2EE0D67828A /* CotabbyApp.swift */; }; + AAC2E374027B9A0E983D966E /* TypingHistorySectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEEB296E66D24C95D3DABAB /* TypingHistorySectionView.swift */; }; AAC519AC668EF430F68B06CA /* InlineCommandCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D68B5ABA427D3E87000E78 /* InlineCommandCoordinator.swift */; }; AAF3A7A25A05E52DC0BED6FB /* EmojiPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC8599C00D6C2CA5FB7A7E7 /* EmojiPickerController.swift */; }; + AB15E751142E1FDB2F8A389F /* TypingHistoryPhrasePredictor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CE9C2EE56728151E6CE5C7 /* TypingHistoryPhrasePredictor.swift */; }; AB1B25E213AE0A79C6993239 /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C736C539D4C97B5664E498BE /* HuggingFaceModels.swift */; }; AB5D37BA744546F14C5566E8 /* AppearancePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE491B3CA04FE9069B7B0F /* AppearancePaneView.swift */; }; AB8F186A7CB13779DAF729A0 /* MacroEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66D6D153C80A5BDE4905B81E /* MacroEngineTests.swift */; }; @@ -700,6 +717,7 @@ B582B229486BFA041A4641BA /* DateMacroEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E34A14D9D852BB9C6829DDC7 /* DateMacroEvaluatorTests.swift */; }; B5C255E58C128C4042FA7F66 /* PhrasePredictionScoringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E542E57459488F3D39A9053 /* PhrasePredictionScoringTests.swift */; }; B65B49F24F59154A7611FD22 /* HomePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1123AB515110BD0CBA39490 /* HomePaneView.swift */; }; + B695D8396E7FE07438DF9C9F /* TypingHistoryIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503A3986FC8C378991727721 /* TypingHistoryIndexTests.swift */; }; B6BA7DF77DCA55F9EF090AEE /* ScreenTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */; }; B6C2570DB8D17C8626D50F76 /* SuggestionCoordinatorContinuityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5830E18C10728B7AF1DB2CCF /* SuggestionCoordinatorContinuityTests.swift */; }; B6F12EFB0AA3CC6BA8773476 /* CaretLinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE5E9B2A196282738F8D4BAD /* CaretLinePosition.swift */; }; @@ -781,6 +799,7 @@ C8B5E45A6F45B307C067CF61 /* SurfaceContextComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B03589D82D0ADCF41B9CD0 /* SurfaceContextComposer.swift */; }; C95343A5A8496627F44233F0 /* InsertedTextAdvance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8C1A4EC935BB837432BAF2 /* InsertedTextAdvance.swift */; }; CA959F082DB2C6E95C5743C8 /* GhostBaselinePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25782BF21045F20B9721DB90 /* GhostBaselinePolicyTests.swift */; }; + CAE609E1F40C3F1B7E4AAA43 /* TypingHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFDC85F1E37BF37FB4751A1 /* TypingHistoryModels.swift */; }; CB2D43A2B7568173A8E82CE4 /* GhostWrapBandPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39AD68E34CF598F2C83D7C73 /* GhostWrapBandPolicyTests.swift */; }; CC54CAD96A348364989132D3 /* ActiveSuggestionSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F95814016D80E0A23974F0 /* ActiveSuggestionSession.swift */; }; CC615916E478E98D403F5714 /* SettingsAttentionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AAAB1C46A0E5A9C6FC7D3D /* SettingsAttentionEvaluator.swift */; }; @@ -829,6 +848,7 @@ D58B73ED080E5D253A838FAC /* SpellingDictionaryResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDBD55F205C609303913423 /* SpellingDictionaryResourceTests.swift */; }; D5929DF79BA02EE9B170B4D5 /* FieldEdgeIconIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F35CD1B715FCBB12BEB6CE62 /* FieldEdgeIconIndicatorView.swift */; }; D59584461B4A854572C331D2 /* CompletionRenderModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816106CA251AFDE69A804762 /* CompletionRenderModeTests.swift */; }; + D5BDC97A559072C580DAE0FB /* TypingHistoryStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C935BF65398E2BD0C62B6D8 /* TypingHistoryStoreTests.swift */; }; D5C6524646C545CFA08D00C9 /* PixelCaretLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A65BCABF4B23690AE3DBE93 /* PixelCaretLocator.swift */; }; D5F36CA1A9400432CB1FDAFC /* ArithmeticEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5897D28F4DBEAF4792FB4D24 /* ArithmeticEvaluator.swift */; }; D66623DB320CB93A7EBA9B86 /* FocusCapabilityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B8D2232F271197468CBC11 /* FocusCapabilityResolver.swift */; }; @@ -851,7 +871,9 @@ DA4693E09A0790D12F531CCA /* TypefaceMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5BA02F079AD5EB20CDB215 /* TypefaceMatcher.swift */; }; DA80B804C506B427A663302D /* ContextLivePreviewField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8368940C6C15CC3F932B8DFC /* ContextLivePreviewField.swift */; }; DA8D967DFA3AFDE469D1FAE7 /* DebouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B630D1F95FD08BC30E624672 /* DebouncePolicy.swift */; }; + DAD9C50637407AFA2620DBC9 /* TypingHistoryIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3203A94F736B83237FEC9 /* TypingHistoryIndex.swift */; }; DC0394C83D334B92A512A775 /* NOTICE.md in Resources */ = {isa = PBXBuildFile; fileRef = 66CF2A70D4699421AC9BD849 /* NOTICE.md */; }; + DC3B4CF0634704EF2ADA7C94 /* CotypistExportImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE896AC5D329BC26BF99EAB8 /* CotypistExportImporterTests.swift */; }; DC6C087A7C0E973967BB38D5 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1AE4120FA68524700C9324 /* BaseCompletionPromptRenderer.swift */; }; DC6FEE1602B6891CA4D0CDCA /* VisualContextStartCoalescerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70F68E9A9255F607C4439ED5 /* VisualContextStartCoalescerTests.swift */; }; DC9EBC526DBF265C00853AE4 /* LanguageTagsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB53B865B0FA4378DEF2054 /* LanguageTagsEditor.swift */; }; @@ -883,6 +905,7 @@ E5CF3CFC00B6607AE8D62FED /* EmojiRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE72A73D906285AE10F623B /* EmojiRecents.swift */; }; E60DE8299D729AB113647904 /* FocusSnapshotResolverLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110F737140F015E1A18E5A58 /* FocusSnapshotResolverLiveTests.swift */; }; E6650273045E6F1976AF23CA /* EngineAndModelPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C35A0A68DA5D2B59460A67E /* EngineAndModelPaneView.swift */; }; + E68459DC91D2F70D9D3D1A40 /* TypingHistoryScrubber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880F8FF215912CD4C337F8E /* TypingHistoryScrubber.swift */; }; E6A062DEDF942C57A77A1AB7 /* TokenHealingPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA9ADF6C0BD9D16016AA9B /* TokenHealingPlan.swift */; }; E6A96157E8689F4762D83AC8 /* Llama.cpp-MIT.txt in Resources */ = {isa = PBXBuildFile; fileRef = 617B396FAD84E6F4A270B5EA /* Llama.cpp-MIT.txt */; }; E6C99AB3E1A56E71F3A518FB /* ModelDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050DC9D858A124FD1CFA4D99 /* ModelDownloadManager.swift */; }; @@ -966,6 +989,7 @@ FBC44557CFBA826392930ABD /* SystemResourceSamplerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FA4E40E8DF352247E1374B /* SystemResourceSamplerTests.swift */; }; FC1757BF6BF6636C5581A373 /* FocusedInputContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFB06531A54FDCDBAAD14E8B /* FocusedInputContext.swift */; }; FC4706CDB470537FE069FF22 /* DisplayCoordinateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC1416A95346CB72BE036FE /* DisplayCoordinateConverter.swift */; }; + FC61422DC8E8EBD10A8F439B /* TypingHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFDC85F1E37BF37FB4751A1 /* TypingHistoryModels.swift */; }; FCB060F562093AACA90CAA21 /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FD2044CDEA95EC02C9B2FE /* MarkerSelectionSynthesizer.swift */; }; FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711293EA57808B9428C7B908 /* CotabbyAppEnvironment.swift */; }; FCD81796FE4DC55778D57686 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122298AE151ECEEC175878BF /* ConfidenceSuppressionPolicy.swift */; }; @@ -1022,6 +1046,7 @@ 0C90C9EBCB70327D215EAE07 /* FileLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogHandler.swift; sourceTree = ""; }; 0D239BFA9C9061C04956C591 /* InsertionStrategySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionStrategySelector.swift; sourceTree = ""; }; 0DA66559D50874865032EE8C /* PromptContextSanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizerTests.swift; sourceTree = ""; }; + 0DB8BFC8B80A47088FDDFC24 /* TypingHistoryPhraseEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryPhraseEngine.swift; sourceTree = ""; }; 0F328B43743D803A16045110 /* HostFontRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostFontRegistry.swift; sourceTree = ""; }; 10107090891BE11E2B34BC9E /* TypefaceMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypefaceMatcherTests.swift; sourceTree = ""; }; 110F737140F015E1A18E5A58 /* FocusSnapshotResolverLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolverLiveTests.swift; sourceTree = ""; }; @@ -1062,6 +1087,7 @@ 1DE72A73D906285AE10F623B /* EmojiRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRecents.swift; sourceTree = ""; }; 1DF8AA184C5F9CFFDF97A070 /* RuntimeBootstrapModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeBootstrapModel.swift; sourceTree = ""; }; 1E267F3BB7FC8DEAEB5E841B /* MenuBarRecoveryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarRecoveryPolicy.swift; sourceTree = ""; }; + 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryStore.swift; sourceTree = ""; }; 1EE61CD2E8D2399E23D48818 /* CompositionInputModeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionInputModeClassifierTests.swift; sourceTree = ""; }; 1EE99C84483D3A58D561C61D /* SecureFieldDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureFieldDetector.swift; sourceTree = ""; }; 1EE99DFA6D7D4B925AFAD1B4 /* OpenAICompatibleAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleAPIClient.swift; sourceTree = ""; }; @@ -1129,6 +1155,7 @@ 377A0BBB59988043005A138A /* FoundationModelSuggestionEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelSuggestionEngineTests.swift; sourceTree = ""; }; 384D85534F3C2F40E2FD8781 /* CompletionContentPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionContentPolicy.swift; sourceTree = ""; }; 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineRouter.swift; sourceTree = ""; }; + 3880F8FF215912CD4C337F8E /* TypingHistoryScrubber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryScrubber.swift; sourceTree = ""; }; 38D10022BEFABAD14D16EBF2 /* SuggestionOverlayStabilityGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayStabilityGateTests.swift; sourceTree = ""; }; 38FCE0E0C38A776383B11809 /* TypoGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoGate.swift; sourceTree = ""; }; 39AD68E34CF598F2C83D7C73 /* GhostWrapBandPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostWrapBandPolicyTests.swift; sourceTree = ""; }; @@ -1181,6 +1208,7 @@ 4F86D2716CBE36970BB462E8 /* OnboardingTemplateRecommender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateRecommender.swift; sourceTree = ""; }; 4FFD3AFACAAC963DAF59D153 /* OnboardingTemplateRecommenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateRecommenderTests.swift; sourceTree = ""; }; 50119F67E7BB9FBC8EA94BF2 /* SuggestionQualityMetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionQualityMetricsStore.swift; sourceTree = ""; }; + 503A3986FC8C378991727721 /* TypingHistoryIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryIndexTests.swift; sourceTree = ""; }; 50A879A0AD69BCA2F60D2735 /* CaretAdvanceSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretAdvanceSampler.swift; sourceTree = ""; }; 50AECDF57800BBB6617620D5 /* TokenHealingBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenHealingBuffer.swift; sourceTree = ""; }; 50CDB11D96F343194B390321 /* MacroTriggerStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroTriggerStateMachine.swift; sourceTree = ""; }; @@ -1255,6 +1283,7 @@ 6C74D10CCC6CB008695F5C0A /* SentenceBoundaryClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBoundaryClassifierTests.swift; sourceTree = ""; }; 6C896CEDC632FB738D3A42BF /* SignOffCue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOffCue.swift; sourceTree = ""; }; 6C8C1A4EC935BB837432BAF2 /* InsertedTextAdvance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvance.swift; sourceTree = ""; }; + 6C935BF65398E2BD0C62B6D8 /* TypingHistoryStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryStoreTests.swift; sourceTree = ""; }; 6CF1FBAABEF545B620AF8D78 /* ru-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "ru-100k.txt"; sourceTree = ""; }; 6D8FE1B9BEA0B35C6910E534 /* SuggestionContinuationPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionContinuationPlan.swift; sourceTree = ""; }; 6DB982BF30B3601F57277776 /* fr-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "fr-100k.txt"; sourceTree = ""; }; @@ -1294,6 +1323,7 @@ 77DB52780A112A686C06D7E3 /* EmojiPopularity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPopularity.swift; sourceTree = ""; }; 7814C85DA992C8124474FD81 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = ""; }; 78721FCF04E4D6F0C7C5DDCF /* llama-recall-cases.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "llama-recall-cases.json"; sourceTree = ""; }; + 78AD40DD8E9C1C8968956B0E /* CotypistExportImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotypistExportImporter.swift; sourceTree = ""; }; 790082EFCDEF94454A3B805F /* SuggestionContinuationPlanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionContinuationPlanTests.swift; sourceTree = ""; }; 79AA39C97E8A959E9A5B6F04 /* FocusCapabilityFlickerGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGate.swift; sourceTree = ""; }; 79AAAB1C46A0E5A9C6FC7D3D /* SettingsAttentionEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAttentionEvaluator.swift; sourceTree = ""; }; @@ -1302,11 +1332,13 @@ 7B9C30F20BE1D9C3B0AF8A9E /* SuggestionRequestFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactoryTests.swift; sourceTree = ""; }; 7C9BB65FA5FC42B89766B037 /* he-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "he-100k.txt"; sourceTree = ""; }; 7CC0C7FE7FB134C225EA7B71 /* CompletionSeamGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSeamGuardTests.swift; sourceTree = ""; }; + 7CFDC85F1E37BF37FB4751A1 /* TypingHistoryModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryModels.swift; sourceTree = ""; }; 7D2E806C1915514651343055 /* HuggingFaceModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelsTests.swift; sourceTree = ""; }; 7D2F4ED8C7493EF4B967BFF9 /* Aria2Provisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2Provisioner.swift; sourceTree = ""; }; 7DE038A6DF4A16B3EB5CEBD3 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; 7F19230243E3B5DE5A76244C /* SignOffCueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOffCueTests.swift; sourceTree = ""; }; 7F7E66CFA5AB733CE1F793A8 /* MarkerSelectionSynthesizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerSelectionSynthesizerTests.swift; sourceTree = ""; }; + 7FB5276B7DDE61030A57EF9A /* TypingHistoryPhrasePredictorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryPhrasePredictorTests.swift; sourceTree = ""; }; 80588CA72AB588807BE1C499 /* TokenHealingBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenHealingBufferTests.swift; sourceTree = ""; }; 8159F4DD2F03935DD17BD907 /* EmojiTriggerStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiTriggerStateMachine.swift; sourceTree = ""; }; 816106CA251AFDE69A804762 /* CompletionRenderModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderModeTests.swift; sourceTree = ""; }; @@ -1326,6 +1358,7 @@ 871C7A27BAB5A510273D0A19 /* EmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerView.swift; sourceTree = ""; }; 872C8DDC2E86A1C4C4BBD99F /* EmojiPickerPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelController.swift; sourceTree = ""; }; 8736D4321E8BF847D1F7338E /* EmojiPickerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerModels.swift; sourceTree = ""; }; + 87625EB90EB00670718FB790 /* TypingHistoryScrubberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryScrubberTests.swift; sourceTree = ""; }; 87C6335EED41343595F17A08 /* PopupChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupChrome.swift; sourceTree = ""; }; 8922EEFC979DE2C2E480FB0D /* ModelDownloadSessionDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelDownloadSessionDelegateTests.swift; sourceTree = ""; }; 89497C35D1825BAE9625EE06 /* ContextPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPaneView.swift; sourceTree = ""; }; @@ -1377,6 +1410,7 @@ 9F63A5EF992329FB9F6C4C26 /* HardwareCapabilityProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareCapabilityProbe.swift; sourceTree = ""; }; 9F8418BB40BEE4BCAFB4516B /* EmojiPickerPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayout.swift; sourceTree = ""; }; 9FA2CFC322EE56151646E745 /* HostBundledFontRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBundledFontRegistryTests.swift; sourceTree = ""; }; + 9FEEB296E66D24C95D3DABAB /* TypingHistorySectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistorySectionView.swift; sourceTree = ""; }; A01BF1613C95C767A2E93D8F /* ContextPaneViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPaneViewTests.swift; sourceTree = ""; }; A04DFC5BD7821D73DA21D7DB /* TagChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagChip.swift; sourceTree = ""; }; A061B0B064B469180B2F1D63 /* TypingExperienceEvalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingExperienceEvalTests.swift; sourceTree = ""; }; @@ -1460,6 +1494,7 @@ BE5E9B2A196282738F8D4BAD /* CaretLinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretLinePosition.swift; sourceTree = ""; }; BE6EA89178F52F6D5B90CBFE /* LanguageCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageCatalog.swift; sourceTree = ""; }; BE7DB9EE77511823EDA7B52E /* SuggestionAnchorCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAnchorCacheTests.swift; sourceTree = ""; }; + BE896AC5D329BC26BF99EAB8 /* CotypistExportImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotypistExportImporterTests.swift; sourceTree = ""; }; BEF60972B2D88E4EC4841AB0 /* GPL-3.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "GPL-3.0.txt"; sourceTree = ""; }; BFB53B865B0FA4378DEF2054 /* LanguageTagsEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageTagsEditor.swift; sourceTree = ""; }; C01F11F7F29EFC3A27A1EE5D /* FocusSnapshotResolverSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolverSelectionTests.swift; sourceTree = ""; }; @@ -1533,6 +1568,7 @@ D9CFD041A13DACE5612DC991 /* HostMarkedTextPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostMarkedTextPolicy.swift; sourceTree = ""; }; DA02598F555696782E180DA5 /* ShortcutConflictTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutConflictTests.swift; sourceTree = ""; }; DA0447A27B293394D9379C48 /* ExtendedContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedContextTests.swift; sourceTree = ""; }; + DAC3203A94F736B83237FEC9 /* TypingHistoryIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryIndex.swift; sourceTree = ""; }; DAC4D7129517D678D95D0BC2 /* PromptPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptPolicyTests.swift; sourceTree = ""; }; DADE6EEA8248DA6700E11844 /* MenuBarPopoverDismisser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPopoverDismisser.swift; sourceTree = ""; }; DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsContainerView.swift; sourceTree = ""; }; @@ -1563,6 +1599,7 @@ E6DC2CCFDB2BF1F1E1375620 /* TerminalAppDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAppDetector.swift; sourceTree = ""; }; E80071E4A5D844CEF5B7C487 /* RequestIDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestIDTests.swift; sourceTree = ""; }; E83C6E965A3978D0EBFB1EA2 /* ShortcutResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutResolver.swift; sourceTree = ""; }; + E8A00E909E0A51B119F9CB6B /* TypingHistoryPhraseEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryPhraseEngineTests.swift; sourceTree = ""; }; E94A6036F4B703A2FF7F68EF /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = ""; }; EA8311FAC345FE431FA89855 /* EmojiMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMatcher.swift; sourceTree = ""; }; EAA290A7B1BF37F3D125DC8F /* SuggestionCoordinatorPredictionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorPredictionTests.swift; sourceTree = ""; }; @@ -1578,6 +1615,7 @@ ECC1416A95346CB72BE036FE /* DisplayCoordinateConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverter.swift; sourceTree = ""; }; ECD5C8648740E7050CCA657F /* FocusPollBackoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPollBackoff.swift; sourceTree = ""; }; ECF1C918E49607E99AE50C80 /* OnboardingTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplate.swift; sourceTree = ""; }; + ED222C2589A4787AFCA90EFD /* TypingHistoryVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryVault.swift; sourceTree = ""; }; ED76F12EBF9C7E7B9AA95142 /* SuggestionAvailabilityEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluatorTests.swift; sourceTree = ""; }; ED7B674D586540F35682C573 /* SuggestionCoordinator+Prediction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Prediction.swift"; sourceTree = ""; }; ED8672B87CEC72BE3978C6BB /* CotabbyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CotabbyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1603,6 +1641,7 @@ F7F3F983901FC3648076B23D /* MacroReferenceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroReferenceSheet.swift; sourceTree = ""; }; F7F727A325ECE687B32CDC38 /* Aria2ProvisionerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2ProvisionerTests.swift; sourceTree = ""; }; F952E54538EF82D1B1E672D8 /* WelcomePermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePermissionStepView.swift; sourceTree = ""; }; + F9CE9C2EE56728151E6CE5C7 /* TypingHistoryPhrasePredictor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryPhrasePredictor.swift; sourceTree = ""; }; FA4364402391C8A8D9B3FCEC /* PhrasePredictionScoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhrasePredictionScoring.swift; sourceTree = ""; }; FA97ED2A10499771F9C32B64 /* FoundationModelAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityService.swift; sourceTree = ""; }; FACB1195A78B524AB65D2EF4 /* FoundationModelAvailabilityServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityServiceTests.swift; sourceTree = ""; }; @@ -1677,6 +1716,7 @@ children = ( 589F9E194F78CE0E5FF8F495 /* Context */, DD63AF32089738F21797C4D7 /* Focus */, + 3A7EDB0A4D9AA945105C75BB /* History */, 011A5248848010A06D9A182A /* Input */, 72E190A5A00C9BB76F46867A /* ModelManagement */, 3BCCC61805C656C1386B9287 /* Permission */, @@ -1983,6 +2023,7 @@ DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */, 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */, EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */, + 9FEEB296E66D24C95D3DABAB /* TypingHistorySectionView.swift */, D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */, ); path = Panes; @@ -2067,6 +2108,14 @@ path = Context; sourceTree = ""; }; + 3A7EDB0A4D9AA945105C75BB /* History */ = { + isa = PBXGroup; + children = ( + 6C935BF65398E2BD0C62B6D8 /* TypingHistoryStoreTests.swift */, + ); + path = History; + sourceTree = ""; + }; 3B1B0D8E3400BE4FC26AF6B5 /* Suggestion */ = { isa = PBXGroup; children = ( @@ -2157,6 +2206,17 @@ path = Emoji; sourceTree = ""; }; + 4DDAC211BE3881C2187F0666 /* History */ = { + isa = PBXGroup; + children = ( + BE896AC5D329BC26BF99EAB8 /* CotypistExportImporterTests.swift */, + 503A3986FC8C378991727721 /* TypingHistoryIndexTests.swift */, + 7FB5276B7DDE61030A57EF9A /* TypingHistoryPhrasePredictorTests.swift */, + 87625EB90EB00670718FB790 /* TypingHistoryScrubberTests.swift */, + ); + path = History; + sourceTree = ""; + }; 4F37A3A455E254F04FB255D6 /* Style */ = { isa = PBXGroup; children = ( @@ -2519,6 +2579,7 @@ D55AC5A7C15AD5E2917B168A /* Llama */, 3EFC2B112178CA99D6D76388 /* OpenAICompatible */, 6C034C1C9E51031A845D1184 /* SuggestionEngineRouterTests.swift */, + E8A00E909E0A51B119F9CB6B /* TypingHistoryPhraseEngineTests.swift */, ); path = Runtime; sourceTree = ""; @@ -2543,6 +2604,14 @@ path = Ranking; sourceTree = ""; }; + 7C7E899DE5F160C8E4340B46 /* History */ = { + isa = PBXGroup; + children = ( + 7CFDC85F1E37BF37FB4751A1 /* TypingHistoryModels.swift */, + ); + path = History; + sourceTree = ""; + }; 7CE2AB7F84E13EB0A98D263D /* Updates */ = { isa = PBXGroup; children = ( @@ -2657,6 +2726,7 @@ 39CD6D46EF4D9EE89761F724 /* Context */, C0079825564365520F908A23 /* Emoji */, A94BF2CB1EA33390CE76EB16 /* Focus */, + 7C7E899DE5F160C8E4340B46 /* History */, F02B51FBFE03C6BF20497A70 /* Input */, 566042A4D7A0EC38C0DE910F /* Onboarding */, F66C64436E0D9AFC2D3334D0 /* Permissions */, @@ -2766,6 +2836,7 @@ DEC0DDC1DF558A8932276294 /* Context */, 251BA8EDFCC71DCE20998F99 /* Emoji */, D2F864A6272D7DDD11AD4E7C /* Focus */, + 4DDAC211BE3881C2187F0666 /* History */, A3BA1847A60D5F0CD97195DA /* Input */, 14283EC2C587EED5D20875D3 /* Logging */, 6994DC8878D2BC1DFE0E7B80 /* Macros */, @@ -3123,6 +3194,17 @@ path = Onboarding; sourceTree = ""; }; + BF9705FD05F27E06346DBCDA /* History */ = { + isa = PBXGroup; + children = ( + 78AD40DD8E9C1C8968956B0E /* CotypistExportImporter.swift */, + DAC3203A94F736B83237FEC9 /* TypingHistoryIndex.swift */, + F9CE9C2EE56728151E6CE5C7 /* TypingHistoryPhrasePredictor.swift */, + 3880F8FF215912CD4C337F8E /* TypingHistoryScrubber.swift */, + ); + path = History; + sourceTree = ""; + }; BFDD0073B68616AB220BE0AE /* Acceptance */ = { isa = PBXGroup; children = ( @@ -3391,6 +3473,7 @@ children = ( 55E1A705ADAF153AE40216E9 /* Context */, 59681B24821B0C960A656168 /* Focus */, + FDE64F29B6370F667A52DCE5 /* History */, 1A748DD787833F29034EF2EA /* Input */, E9FA809AF98B9C97A5F472F6 /* ModelManagement */, 1F3BF5BD8958D0D0C2E34AF1 /* Permission */, @@ -3535,6 +3618,7 @@ 7FAA2090D6421D9BAC2CFD37 /* Context */, 4D07DA390100ECC4E8164547 /* Emoji */, D816DC011F025BC315F10052 /* Focus */, + BF9705FD05F27E06346DBCDA /* History */, CB1EE2A3FD527C8377056C38 /* Input */, A4EE6A8F2C4AB8940B72F681 /* Logging */, 67C78D77B58388B15AC8B954 /* Macros */, @@ -3614,6 +3698,15 @@ path = Output; sourceTree = ""; }; + FDE64F29B6370F667A52DCE5 /* History */ = { + isa = PBXGroup; + children = ( + 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */, + ED222C2589A4787AFCA90EFD /* TypingHistoryVault.swift */, + ); + path = History; + sourceTree = ""; + }; FFF55D3CE9FF3B16845823F7 /* Runtime */ = { isa = PBXGroup; children = ( @@ -3622,6 +3715,7 @@ D1FD19C28ED1EA45A68ABE16 /* OpenAICompatible */, 2C8D50115291212D03414CBE /* DebugForcedSuggestionEngine.swift */, 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */, + 0DB8BFC8B80A47088FDDFC24 /* TypingHistoryPhraseEngine.swift */, ); path = Runtime; sourceTree = ""; @@ -3892,6 +3986,7 @@ AF55F1ABEDEA10C76C307CEC /* CotabbyAppEnvironment.swift in Sources */, 4E7F611941736F526C3B9C1B /* CotabbyBrand.swift in Sources */, A5D76116479357C29E2D8405 /* CotabbyDebugOptions.swift in Sources */, + 6E978AD7E340B2795F120AC0 /* CotypistExportImporter.swift in Sources */, B803D0491F5CF19735F23B65 /* CurrencyEvaluator.swift in Sources */, 81870F2D46C12CA1E6B19523 /* CurrentWordExtractor.swift in Sources */, 378EE9C111040353A6335454 /* CurrentWordSpellChecker.swift in Sources */, @@ -4139,6 +4234,14 @@ F60A94747732F706FD666925 /* TypefaceEvidence.swift in Sources */, DA4693E09A0790D12F531CCA /* TypefaceMatcher.swift in Sources */, 555501CEB124DFDF657EACA4 /* TypingCadence.swift in Sources */, + DAD9C50637407AFA2620DBC9 /* TypingHistoryIndex.swift in Sources */, + FC61422DC8E8EBD10A8F439B /* TypingHistoryModels.swift in Sources */, + 6F18827DF0D0CC9ADCCA83C2 /* TypingHistoryPhraseEngine.swift in Sources */, + 4002A961699B7565A9DB5BF7 /* TypingHistoryPhrasePredictor.swift in Sources */, + E68459DC91D2F70D9D3D1A40 /* TypingHistoryScrubber.swift in Sources */, + 620A8133C64753E17AD7CD67 /* TypingHistorySectionView.swift in Sources */, + 6C75BC36C18440B54A3A35AC /* TypingHistoryStore.swift in Sources */, + 73E3EFA8960FC779920935C4 /* TypingHistoryVault.swift in Sources */, BDF13BDA6D30BB8FF755AC32 /* TypingPredictionCandidate.swift in Sources */, C0468063222C5FEA7C1CFAB3 /* TypoCaseTransfer.swift in Sources */, A88F3C7039E8DDB71C5D6246 /* TypoGate.swift in Sources */, @@ -4219,6 +4322,7 @@ FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */, 085BB87581DFFA260A630E24 /* CotabbyBrand.swift in Sources */, 7A31E6395C535FF017A1EFE1 /* CotabbyDebugOptions.swift in Sources */, + 5509B327A1C9E67467333B06 /* CotypistExportImporter.swift in Sources */, 1F39EE1D5FA0F5D32AFFB028 /* CurrencyEvaluator.swift in Sources */, EA353CCECBFB4D297C865447 /* CurrentWordExtractor.swift in Sources */, C56ABA04AE27A9943368035C /* CurrentWordSpellChecker.swift in Sources */, @@ -4466,6 +4570,14 @@ 2740742B866BC12043B81268 /* TypefaceEvidence.swift in Sources */, 9F8656C10B45DA6699A43787 /* TypefaceMatcher.swift in Sources */, F6A88AAC1A3FE7FB284BFC34 /* TypingCadence.swift in Sources */, + 8DCEEB9FCB3F7C90B7B4917F /* TypingHistoryIndex.swift in Sources */, + CAE609E1F40C3F1B7E4AAA43 /* TypingHistoryModels.swift in Sources */, + 56987393D95962CD49B4A4E8 /* TypingHistoryPhraseEngine.swift in Sources */, + AB15E751142E1FDB2F8A389F /* TypingHistoryPhrasePredictor.swift in Sources */, + 113710208D17C1998ED307E1 /* TypingHistoryScrubber.swift in Sources */, + AAC2E374027B9A0E983D966E /* TypingHistorySectionView.swift in Sources */, + 23F0FA44C965F97181F082D8 /* TypingHistoryStore.swift in Sources */, + 2BA57F29DDD4CEB327CD805E /* TypingHistoryVault.swift in Sources */, 733A13BF72DCBBDC37952DD9 /* TypingPredictionCandidate.swift in Sources */, CF391BC4A3C80C41048D64D1 /* TypoCaseTransfer.swift in Sources */, 48A2F371756723299597F5A1 /* TypoGate.swift in Sources */, @@ -4533,6 +4645,7 @@ 57BCDFE786675C9793F3E08E /* ControlTokenMarkersTests.swift in Sources */, F8D1C3FD1A1ACAE87D885D29 /* CotabbyDebugOptionsTests.swift in Sources */, 65D20F8E6309CED34A638D35 /* CotabbyTestFixtures.swift in Sources */, + DC3B4CF0634704EF2ADA7C94 /* CotypistExportImporterTests.swift in Sources */, 15BE5127E4BE29F6CBEEAA0E /* CurrencyEvaluatorTests.swift in Sources */, 99334CDC1399D03019202E85 /* CurrentWordExtractorTests.swift in Sources */, 81073963BC57B5CA9151B0EC /* CustomRulesTests.swift in Sources */, @@ -4741,6 +4854,11 @@ 01527F11AEDBE20935A254B6 /* TypefaceMatcherTests.swift in Sources */, FDA59446E91261744C6DDFDA /* TypingCadenceTests.swift in Sources */, BD09E1744CEF3EA688E6BB1D /* TypingExperienceEvalTests.swift in Sources */, + B695D8396E7FE07438DF9C9F /* TypingHistoryIndexTests.swift in Sources */, + 2CDE0BF9AF320B269C82E937 /* TypingHistoryPhraseEngineTests.swift in Sources */, + 8E236047C490CD266E603823 /* TypingHistoryPhrasePredictorTests.swift in Sources */, + 5619C761998CCBB3FD50CBDB /* TypingHistoryScrubberTests.swift in Sources */, + D5BDC97A559072C580DAE0FB /* TypingHistoryStoreTests.swift in Sources */, 71676E29789ACF4FE7925220 /* TypingPredictionCandidateTests.swift in Sources */, FFC99A497F09204BC9B118FD /* TypingSessionEvalScoring.swift in Sources */, 5C67947575E96AABF06B1AC9 /* TypingSessionEvalScoringTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SettingsCoordinator.swift b/Cotabby/App/Coordinators/SettingsCoordinator.swift index 5535fec0..77a288b4 100644 --- a/Cotabby/App/Coordinators/SettingsCoordinator.swift +++ b/Cotabby/App/Coordinators/SettingsCoordinator.swift @@ -25,6 +25,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { private let systemMetricsStore: SystemMetricsStore private let onShowWelcome: () -> Void private let clearEmojiHistory: () -> Void + private let typingHistoryStore: TypingHistoryStore private var settingsWindowController: NSWindowController? @@ -49,7 +50,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { qualityMetricsStore: SuggestionQualityMetricsStore, systemMetricsStore: SystemMetricsStore, onShowWelcome: @escaping () -> Void, - clearEmojiHistory: @escaping () -> Void + clearEmojiHistory: @escaping () -> Void, + typingHistoryStore: TypingHistoryStore ) { self.appUpdateManager = appUpdateManager self.permissionManager = permissionManager @@ -65,6 +67,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { self.systemMetricsStore = systemMetricsStore self.onShowWelcome = onShowWelcome self.clearEmojiHistory = clearEmojiHistory + self.typingHistoryStore = typingHistoryStore } /// Shows the settings window, reusing the existing instance if it is already open. @@ -94,6 +97,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { systemMetricsStore: systemMetricsStore, onShowWelcome: onShowWelcome, clearEmojiHistory: clearEmojiHistory, + typingHistoryStore: typingHistoryStore, onQuit: { NSApplication.shared.terminate(nil) } ) ) diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Continuation.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Continuation.swift index 776217d8..7660123e 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Continuation.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Continuation.swift @@ -59,7 +59,8 @@ extension SuggestionCoordinator { let request = SuggestionRequestFactory.buildRequest(context: context, settings: settingsSnapshot, configuration: configuration, clipboardContext: pinnedClipboardContext(rawContext: rawContext), visualContextSummary: permissionManager.screenRecordingGranted - ? visualContextCoordinator.excerpt(for: session.baseContext) : nil).request + ? visualContextCoordinator.excerpt(for: session.baseContext) : nil, + historyExamples: historyExamples(for: context)).request continuationWorkController.replaceDebouncedWork(delayMilliseconds: 0) { [weak self] workID in guard let self else { return } await self.awaitCachedGenerationContextResetIfNeeded() diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift index c0730e75..9a8a2531 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift @@ -223,7 +223,8 @@ extension SuggestionCoordinator { let request = SuggestionRequestFactory.buildRequest( context: prewarmContext, settings: settings, - configuration: configuration + configuration: configuration, + historyExamples: self.historyExamples(for: prewarmContext) ).request await suggestionEngine.prewarm(for: request) } diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift index 3df7bcc2..3853fd63 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift @@ -126,7 +126,8 @@ extension SuggestionCoordinator { settings: settingsSnapshot, configuration: configuration, clipboardContext: clipboardContext, - visualContextSummary: visualContextSummary + visualContextSummary: visualContextSummary, + historyExamples: historyExamples(for: context) ) latestGenerationNumber = context.generation let request = requestBuildResult.request @@ -288,7 +289,8 @@ extension SuggestionCoordinator { settings: settingsSnapshot, configuration: configuration, clipboardContext: clipboardContext, - visualContextSummary: visualContextSummary + visualContextSummary: visualContextSummary, + historyExamples: historyExamples(for: context) ) latestGenerationNumber = context.generation let request = requestBuildResult.request @@ -359,7 +361,8 @@ extension SuggestionCoordinator { clipboardContext: pinnedClipboardContext(rawContext: optimistic), visualContextSummary: permissionManager.screenRecordingGranted ? visualContextCoordinator.excerpt(for: context) - : nil + : nil, + historyExamples: historyExamples(for: context) ) let request = requestBuildResult.request let suggestionEngine = suggestionEngine diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift index 227073e9..cf3edcde 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift @@ -174,6 +174,17 @@ final class SuggestionCoordinator: ObservableObject { /// coordinator continues to own the timer and input-monitor effects around these transitions. var postExhaustionAcceptanceState = PostExhaustionAcceptanceState() + /// The user's typing history, when the app has one. Optional so test rigs and previews run + /// without it; the provider itself returns nothing while history is turned off. + let historyProvider: (any SuggestionHistoryProviding)? + + /// Examples of the user's past writing for this field, for every request built from it. + /// Every request kind (ordinary, speculative, continuation, prewarm) passes the same examples so + /// their prompts share one head and the llama KV cache stays reusable between them. + func historyExamples(for context: FocusedInputContext) -> [String] { + historyProvider?.historyExamples(for: context, engine: settingsSnapshot.selectedEngine) ?? [] + } + init( permissionManager: any SuggestionPermissionProviding, lowPowerModeProvider: any SuggestionLowPowerModeProviding, @@ -193,6 +204,7 @@ final class SuggestionCoordinator: ObservableObject { symSpellCorrector: SymSpellCorrector, spellingLanguageResolver: SpellingLanguageResolver = SpellingLanguageResolver(), qualityMetricsStore: SuggestionQualityMetricsStore, + historyProvider: (any SuggestionHistoryProviding)? = nil, userDefaults: UserDefaults = .standard ) { let storedTotalTabAcceptedWordCount = userDefaults.integer( @@ -216,6 +228,7 @@ final class SuggestionCoordinator: ObservableObject { self.symSpellCorrector = symSpellCorrector self.spellingLanguageResolver = spellingLanguageResolver self.qualityMetricsStore = qualityMetricsStore + self.historyProvider = historyProvider self.userDefaults = userDefaults settingsSnapshot = suggestionSettings.snapshot // These collaborators isolate "how overlay/logging works" from "when the coordinator diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index e29c9e63..cae2d0fd 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -247,6 +247,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { activationIndicatorController.hide(reason: "Activation indicator hidden because Cotabby is terminating.") focusDebugOverlayController?.hide() suggestionCoordinator.stop() + // Write the field being typed in now; the debounced background save may not have run yet. + environment.typingHistoryStore.flush() inlineCommandCoordinator.stop() inputMonitor.stop() focusModel.stop() diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index ee4a1618..3a938801 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -40,6 +40,7 @@ final class CotabbyAppEnvironment { let huggingFaceSearchService: HuggingFaceSearchService let performanceMetricsStore: PerformanceMetricsStore let qualityMetricsStore: SuggestionQualityMetricsStore + let typingHistoryStore: TypingHistoryStore let settingsCoordinator: SettingsCoordinator let activationIndicatorController: ActivationIndicatorController let focusDebugOverlayController: FocusDebugOverlayController? @@ -223,9 +224,20 @@ final class CotabbyAppEnvironment { ) // Under `-cotabby-debug` with `cotabbyDebugForcedSuggestion` set, every request answers with // that fixed text so ghost placement can be measured deterministically without a model. + // Typing history owns its own encrypted archive. Inside the XCTest host it starts empty and + // never opens the real archive or Keychain item, so tests cannot read or overwrite it. + let isTestHost = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + let typingHistoryStore = TypingHistoryStore(loadsArchive: !isTestHost) + // Phrase shortcuts answer from history before the router runs. The live engine kind is + // read per request so a power-source switch to the endpoint stops shortcuts immediately. + let historyAwareEngine = TypingHistoryPhraseEngine( + wrapping: routedEngine, + history: typingHistoryStore, + engineKind: { [weak suggestionSettings] in suggestionSettings?.selectedEngine ?? .openAICompatible } + ) let suggestionEngine: any SuggestionGenerating = DebugForcedSuggestionEngine.isConfigured() - ? DebugForcedSuggestionEngine(wrapping: routedEngine) - : routedEngine + ? DebugForcedSuggestionEngine(wrapping: historyAwareEngine) + : historyAwareEngine // Per-user emoji recents/frequency. Built before the settings coordinator so the // "Clear History" control can reach it, and before the picker which reads and writes it. @@ -247,7 +259,8 @@ final class CotabbyAppEnvironment { onShowWelcome: { [weak welcomeCoordinator] in welcomeCoordinator?.showWelcome() }, - clearEmojiHistory: { emojiUsageStore.clear() } + clearEmojiHistory: { emojiUsageStore.clear() }, + typingHistoryStore: typingHistoryStore ) let interactionState = SuggestionInteractionState() @@ -282,7 +295,8 @@ final class CotabbyAppEnvironment { spellChecker: spellChecker, symSpellCorrector: symSpellCorrector, spellingLanguageResolver: SpellingLanguageResolver(), - qualityMetricsStore: qualityMetricsStore + qualityMetricsStore: qualityMetricsStore, + historyProvider: typingHistoryStore ) // The emoji picker is a sibling to the suggestion coordinator. It reuses the input monitor, @@ -352,12 +366,27 @@ final class CotabbyAppEnvironment { self.huggingFaceSearchService = huggingFaceSearchService self.performanceMetricsStore = performanceMetricsStore self.qualityMetricsStore = qualityMetricsStore + self.typingHistoryStore = typingHistoryStore self.settingsCoordinator = settingsCoordinator self.activationIndicatorController = activationIndicatorController self.focusDebugOverlayController = CotabbyDebugOptions.areOverlaysAvailable ? FocusDebugOverlayController() : nil + // Recording reads every focus snapshot; the store ignores them unless recording is on and + // the text changed. Cotabby's own gates (globally on, not paused, app not disabled) decide + // where recording may happen, so history is only collected where Cotabby is active. + focusModel.$snapshot + .sink { [weak typingHistoryStore, weak suggestionSettings] snapshot in + guard let typingHistoryStore, let suggestionSettings else { return } + typingHistoryStore.observe(snapshot) { + let settings = suggestionSettings.snapshot + return settings.isGloballyEnabled && !settings.isTemporarilyPaused + && !(snapshot.bundleIdentifier.map(settings.disabledAppBundleIdentifiers.contains) ?? false) + } + } + .store(in: &cancellables) + // Update the AX polling timer whenever the user changes the poll interval setting. suggestionSettings.$focusPollIntervalMilliseconds .removeDuplicates() diff --git a/Cotabby/Models/History/TypingHistoryModels.swift b/Cotabby/Models/History/TypingHistoryModels.swift new file mode 100644 index 00000000..e8a0264a --- /dev/null +++ b/Cotabby/Models/History/TypingHistoryModels.swift @@ -0,0 +1,72 @@ +import Foundation + +/// File overview: +/// Value types for Cotabby's typing history: the text the user has written in fields Cotabby was +/// active in (recorded on this Mac, or imported from another autocomplete app), and the user's +/// preferences for collecting and using it. +/// +/// Why a separate subsystem: history is the only Cotabby data that outlives the field it came from. +/// Everything else in a request (caret text, clipboard, screen) is ephemeral. Keeping these values +/// apart from `SuggestionSettingsModel` lets the history store own its own storage, encryption, and +/// lifecycle, while the suggestion pipeline only sees a narrow read contract +/// (`SuggestionHistoryProviding`). + +/// One field's worth of the user's writing. +/// +/// A record is updated in place while the user keeps typing in the same field, so a long email is +/// one record rather than one per keystroke. `text` is already scrubbed of secret-like tokens +/// (`TypingHistoryScrubber`) before it is stored. +nonisolated struct TypingHistoryRecord: Codable, Equatable, Sendable, Identifiable { + enum Source: String, Codable, Sendable { + /// Captured by Cotabby while the user typed. + case recorded + /// Brought in from another app's export (for example Cotypist). + case imported + } + + let id: UUID + let bundleIdentifier: String + /// Registrable web domain for browser fields ("claude.ai"), nil for native apps. + let domain: String? + let createdAt: Date + var updatedAt: Date + var text: String + let source: Source + /// How many characters at the start of `text` were before the caret when it was captured. + /// Text after the caret is usually not the user's: in an email reply it is the quoted thread + /// other people wrote. Learning only from this part keeps their names and phrasing out of the + /// user's shortcuts. Nil means the whole text counts (records written before this existed). + var typedLength: Int? = nil + + /// The part of `text` the user wrote themselves. + var typedText: String { + guard let typedLength, typedLength < text.count else { return text } + return String(text.prefix(typedLength)) + } +} + +/// The encrypted file's plaintext payload. Versioned so a future format change can migrate rather +/// than silently dropping the user's history. +nonisolated struct TypingHistoryArchive: Codable, Equatable, Sendable { + static let currentVersion = 1 + + var version: Int + var records: [TypingHistoryRecord] +} + +/// The user's typing-history preferences, persisted by `TypingHistoryStore`. +nonisolated struct TypingHistoryPreferences: Equatable, Sendable { + /// Whether stored history shapes suggestions (prompt examples and phrase shortcuts). + var isUsingHistory: Bool + /// Whether new typing is recorded. Off by default: recording keeps the user's writing on disk, + /// which is a privacy decision the user makes, not a default Cotabby makes for them. + var isRecording: Bool + /// Apps whose fields are never recorded, by bundle identifier. + var excludedBundleIdentifiers: [String] + + static let defaults = TypingHistoryPreferences( + isUsingHistory: false, + isRecording: false, + excludedBundleIdentifiers: [] + ) +} diff --git a/Cotabby/Models/Suggestion/Request/SuggestionRequest.swift b/Cotabby/Models/Suggestion/Request/SuggestionRequest.swift index f5bc963c..5970b578 100644 --- a/Cotabby/Models/Suggestion/Request/SuggestionRequest.swift +++ b/Cotabby/Models/Suggestion/Request/SuggestionRequest.swift @@ -72,6 +72,10 @@ struct SuggestionRequest: Equatable, Sendable { /// prompt has already folded it in; this field exists so the Foundation Models renderer can /// state the same sanitized facts in its own prompt shape. let surfaceContext: SurfaceContext? + /// Passages of the user's own past writing that resemble this field (see `TypingHistoryStore`). + /// Always empty for the endpoint engine: history never leaves this Mac, and the router refuses + /// to send a request that carries any. + let historyExamples: [String] /// When enabled, the normalizer keeps multiple lines instead of truncating to the first line. let isMultiLineEnabled: Bool /// The user's word-count preset, so decoding does not stop at a sentence end before the minimum @@ -104,6 +108,7 @@ struct SuggestionRequest: Equatable, Sendable { clipboardContext: String?, visualContextSummary: String?, surfaceContext: SurfaceContext? = nil, + historyExamples: [String] = [], isMultiLineEnabled: Bool, requestID: String = "req_unknown", wordRange: SuggestionWordRange? = nil @@ -128,6 +133,7 @@ struct SuggestionRequest: Equatable, Sendable { self.clipboardContext = clipboardContext self.visualContextSummary = visualContextSummary self.surfaceContext = surfaceContext + self.historyExamples = historyExamples self.isMultiLineEnabled = isMultiLineEnabled self.requestID = requestID self.wordRange = wordRange diff --git a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift index 9c88d684..f6dd2871 100644 --- a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift @@ -101,6 +101,19 @@ protocol EmojiInputIntercepting: AnyObject { func isWordAcceptKey(_ keyEvent: InputMonitorKeyEvent) -> Bool } +/// Read-only access to the user's typing history for the suggestion pipeline. +/// +/// Both answers are empty for the endpoint engine: history stays on this Mac, so it may only shape +/// requests handled by Apple Intelligence or the in-process model. Implementations also return +/// nothing while the user has history turned off, so callers never need to check settings. +@MainActor +protocol SuggestionHistoryProviding: AnyObject { + /// Short passages of the user's past writing that resemble the current field, best first. + func historyExamples(for context: FocusedInputContext, engine: SuggestionEngineKind) -> [String] + /// Exact text to insert when history is confident how the current phrase ends, else nil. + func phraseContinuation(for request: SuggestionRequest, engine: SuggestionEngineKind) -> String? +} + @MainActor protocol SuggestionGenerating: AnyObject { func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult diff --git a/Cotabby/Services/History/TypingHistoryStore.swift b/Cotabby/Services/History/TypingHistoryStore.swift new file mode 100644 index 00000000..f4940938 --- /dev/null +++ b/Cotabby/Services/History/TypingHistoryStore.swift @@ -0,0 +1,369 @@ +import Combine +import Foundation +import Logging + +/// Owns the user's typing history: its preferences, the encrypted archive, recording new typing, and +/// the search structures that turn history into prompt examples and phrase shortcuts. +/// +/// Ownership: built once by `CotabbyAppEnvironment` and kept for the app's lifetime. The suggestion +/// coordinator and the phrase engine read it through `SuggestionHistoryProviding`; the Context +/// settings pane observes it directly (`@ObservedObject`) for its toggles, counts, Import, and +/// Delete All. Its preferences live here rather than in `SuggestionSettingsModel` because they only +/// matter to this subsystem, and keeping them together keeps Delete All and the recording gate in +/// one place. +/// +/// Concurrency: everything mutable is `@MainActor`. Expensive work runs on detached tasks against +/// value copies: decrypting and encrypting the archive (`TypingHistoryVault`) and building the index +/// and phrase table. Each rebuild carries a generation number so a slow, older build can never +/// replace a newer one. +@MainActor +final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { + enum Status: Equatable { + case loading + case ready + /// The archive exists but could not be opened. Recording and import stay off so the + /// unreadable file is never overwritten with a smaller one. + case unavailable(String) + } + + @Published private(set) var preferences: TypingHistoryPreferences + @Published private(set) var recordCount = 0 + @Published private(set) var status: Status = .loading + @Published private(set) var isImporting = false + @Published private(set) var lastImportMessage: String? + + /// Oldest records are dropped past this many. Retrieval and the phrase table stay small enough + /// to rebuild in a second or two, and very old writing says little about how the user writes now. + static let maximumRecords = 20_000 + /// Fields with less text than this are not worth keeping ("ok", a search term). + static let minimumRecordedCharacters = 20 + + private let vault: TypingHistoryVault + private let userDefaults: UserDefaults + private var records: [TypingHistoryRecord] = [] + private var index: TypingHistoryIndex? + private var phrases: TypingHistoryPhrasePredictor? + private var rebuildGeneration = 0 + private var saveTask: Task? + private var activeRecording: ActiveRecording? + /// The field most recently finished. Accessibility can briefly report a field as unsupported + /// mid-typing; when the same field comes back, recording resumes into the same record instead + /// of starting a duplicate. + private var lastFinishedRecording: ActiveRecording? + private var exampleCache: (key: String, examples: [String])? + + /// The field being typed in right now. Its raw text is kept here and only scrubbed and copied + /// into `records` when saving or when focus moves on, so recording costs a string comparison + /// per keystroke rather than a regex pass over the whole field. + private struct ActiveRecording { + let fieldKey: String + let recordID: UUID + let bundleIdentifier: String + let domain: String? + let createdAt: Date + var rawText: String + /// Characters before the caret in `rawText` at the latest capture. + var rawTypedLength: Int + } + + private enum DefaultsKey { + static let isUsingHistory = "cotabbyTypingHistoryEnabled" + static let isRecording = "cotabbyTypingHistoryRecordingEnabled" + static let excludedBundleIdentifiers = "cotabbyTypingHistoryExcludedApps" + } + + init(vault: TypingHistoryVault = .standard(), userDefaults: UserDefaults = .standard, loadsArchive: Bool = true) { + self.vault = vault + self.userDefaults = userDefaults + preferences = TypingHistoryPreferences( + isUsingHistory: userDefaults.object(forKey: DefaultsKey.isUsingHistory) as? Bool + ?? TypingHistoryPreferences.defaults.isUsingHistory, + isRecording: userDefaults.object(forKey: DefaultsKey.isRecording) as? Bool + ?? TypingHistoryPreferences.defaults.isRecording, + excludedBundleIdentifiers: userDefaults.stringArray(forKey: DefaultsKey.excludedBundleIdentifiers) ?? [] + ) + if loadsArchive { + Task { await loadArchive() } + } else { + status = .ready + } + } + + // MARK: - Preferences + + func setUsingHistory(_ enabled: Bool) { + guard preferences.isUsingHistory != enabled else { return } + preferences.isUsingHistory = enabled + userDefaults.set(enabled, forKey: DefaultsKey.isUsingHistory) + exampleCache = nil + } + + func setRecording(_ enabled: Bool) { + guard preferences.isRecording != enabled else { return } + preferences.isRecording = enabled + userDefaults.set(enabled, forKey: DefaultsKey.isRecording) + if !enabled { finishActiveRecording() } + } + + func setExcluded(_ bundleIdentifier: String, excluded: Bool) { + var identifiers = preferences.excludedBundleIdentifiers.filter { $0 != bundleIdentifier } + if excluded { identifiers.append(bundleIdentifier) } + identifiers.sort() + guard identifiers != preferences.excludedBundleIdentifiers else { return } + preferences.excludedBundleIdentifiers = identifiers + userDefaults.set(identifiers, forKey: DefaultsKey.excludedBundleIdentifiers) + if excluded, activeRecording?.bundleIdentifier == bundleIdentifier { + // Excluding an app mid-field discards that field's unsaved text instead of keeping it. + activeRecording = nil + lastFinishedRecording = nil + } + } + + // MARK: - Loading, saving, rebuilding + + func loadArchive() async { + let vault = vault + do { + let loaded = try await Task.detached(priority: .utility) { try vault.load() }.value + records = loaded + recordCount = loaded.count + status = .ready + rebuildSearchStructures() + } catch { + status = .unavailable("Typing history couldn't be opened, so it's paused to avoid overwriting it.") + CotabbyLogger.app.error("Typing history archive could not be loaded: \(error)") + } + } + + /// Writes immediately, on the calling (main) thread. Used at termination, when there is no time + /// left for a debounced background save. + func flush() { + guard status == .ready else { return } + saveTask?.cancel() + materializeActiveRecording() + do { + try vault.save(records) + } catch { + CotabbyLogger.app.error("Typing history could not be saved: \(error)") + } + } + + private func scheduleSave() { + guard status == .ready else { return } + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard let self, !Task.isCancelled else { return } + self.materializeActiveRecording() + let snapshot = self.records + let vault = self.vault + do { + try await Task.detached(priority: .utility) { try vault.save(snapshot) }.value + } catch { + CotabbyLogger.app.error("Typing history could not be saved: \(error)") + } + } + } + + /// Rebuilds the index and phrase table off the main actor from a copy of the records. The field + /// being typed in is left out so the user's unfinished draft never becomes its own example. + private func rebuildSearchStructures() { + rebuildGeneration += 1 + let generation = rebuildGeneration + let activeID = activeRecording?.recordID + let snapshot = records.filter { $0.id != activeID } + Task { [weak self] in + let built = await Task.detached(priority: .utility) { + (TypingHistoryIndex(records: snapshot), TypingHistoryPhrasePredictor(records: snapshot)) + }.value + guard let self, generation == self.rebuildGeneration else { return } + self.index = built.0 + self.phrases = built.1 + self.exampleCache = nil + } + } + + // MARK: - Recording + + /// Called for every focus snapshot. Cheap unless the field's text changed. + /// + /// `isAllowed` carries Cotabby's own gates (globally on, not paused, app not disabled), so + /// history is only ever recorded where Cotabby itself is active. It is a closure because it + /// builds a settings snapshot, and this runs on every focus poll: it is only evaluated once + /// recording is on. Secure fields never reach here as supported, and are checked again below. + func observe(_ snapshot: FocusSnapshot, isAllowed: () -> Bool) { + guard status == .ready, preferences.isRecording, + case .supported = snapshot.capability, + let input = snapshot.context, !input.isSecure, + !preferences.excludedBundleIdentifiers.contains(input.bundleIdentifier), + isAllowed() + else { + finishActiveRecording() + return + } + + let fieldKey = "\(input.bundleIdentifier)|\(input.processIdentifier)|\(input.elementIdentifier)|\(input.focusChangeSequence)" + let text = input.precedingText + input.trailingText + if activeRecording?.fieldKey != fieldKey { + finishActiveRecording() + if var resumed = lastFinishedRecording, resumed.fieldKey == fieldKey { + resumed.rawText = text + resumed.rawTypedLength = input.precedingText.count + activeRecording = resumed + } else { + activeRecording = ActiveRecording( + fieldKey: fieldKey, + recordID: UUID(), + bundleIdentifier: input.bundleIdentifier, + domain: SurfaceContextComposer.registrableDomain(from: input.focusedURLString), + createdAt: Date(), + rawText: text, + rawTypedLength: input.precedingText.count + ) + } + return + } + guard activeRecording?.rawText != text else { return } + activeRecording?.rawText = text + activeRecording?.rawTypedLength = input.precedingText.count + scheduleSave() + } + + /// Commits the active field to `records` and makes it searchable. Called when focus moves to + /// another field, recording stops, or the app is no longer eligible. + private func finishActiveRecording() { + guard activeRecording != nil else { return } + let changed = materializeActiveRecording() + lastFinishedRecording = activeRecording + activeRecording = nil + if changed { + scheduleSave() + rebuildSearchStructures() + } + } + + /// Copies the active field's scrubbed text into `records`. Returns whether anything changed. + @discardableResult + private func materializeActiveRecording() -> Bool { + guard let active = activeRecording else { return false } + let split = active.rawText.index(active.rawText.startIndex, offsetBy: min(active.rawTypedLength, active.rawText.count)) + let (text, typedLength) = TypingHistoryScrubber.scrub( + before: String(active.rawText[..= Self.minimumRecordedCharacters else { + // The user cleared the field down to nothing worth keeping. + if let existingIndex { + records.remove(at: existingIndex) + recordCount = records.count + return true + } + return false + } + if let existingIndex { + guard records[existingIndex].text != text || records[existingIndex].typedLength != typedLength else { + return false + } + records[existingIndex].text = text + records[existingIndex].typedLength = typedLength + records[existingIndex].updatedAt = Date() + } else { + records.append(TypingHistoryRecord( + id: active.recordID, bundleIdentifier: active.bundleIdentifier, domain: active.domain, + createdAt: active.createdAt, updatedAt: Date(), text: text, source: .recorded, + typedLength: typedLength + )) + trimToCapacity() + } + recordCount = records.count + return true + } + + private func trimToCapacity() { + guard records.count > Self.maximumRecords else { return } + records.sort { $0.updatedAt < $1.updatedAt } + records.removeFirst(records.count - Self.maximumRecords) + } + + // MARK: - Import and deletion + + /// Imports a Cotypist `user_inputs.json` export. Entries whose text is already in history are + /// skipped, so importing the same file twice adds nothing. + func importCotypistExport(from url: URL) async { + guard status == .ready, !isImporting else { return } + isImporting = true + defer { isImporting = false } + do { + let imported = try await Task.detached(priority: .userInitiated) { + try CotypistExportImporter.records(fromExport: Data(contentsOf: url)) + }.value + let knownTexts = Set(records.map(\.text)) + let fresh = imported.filter { !knownTexts.contains($0.text) } + records.append(contentsOf: fresh) + trimToCapacity() + recordCount = records.count + lastImportMessage = "Imported \(fresh.count) entries" + + (imported.count > fresh.count ? " (\(imported.count - fresh.count) were already in your history)." : ".") + scheduleSave() + rebuildSearchStructures() + } catch { + lastImportMessage = (error as? LocalizedError)?.errorDescription ?? "Import failed: \(error.localizedDescription)" + } + } + + /// Removes every record, the encrypted file, and its Keychain key. + func deleteAll() { + saveTask?.cancel() + activeRecording = nil + lastFinishedRecording = nil + records = [] + recordCount = 0 + index = nil + phrases = nil + exampleCache = nil + rebuildGeneration += 1 + lastImportMessage = nil + do { + try vault.destroy() + status = .ready + } catch { + CotabbyLogger.app.error("Typing history could not be deleted: \(error)") + } + } + + // MARK: - SuggestionHistoryProviding + + func historyExamples(for context: FocusedInputContext, engine: SuggestionEngineKind) -> [String] { + guard preferences.isUsingHistory, engine != .openAICompatible, let index else { return [] } + let stableText = TypingHistoryQuery.stableText(from: context.precedingText) + // The window title (an email subject, a document name) is the most stable topical signal a + // field has, so it joins the query even before the first full block of words is typed. + let queryText = [stableText, context.windowTitle ?? ""].joined(separator: " ") + let cacheKey = "\(context.focusedInputIdentityKey)|\(queryText)" + if let exampleCache, exampleCache.key == cacheKey { return exampleCache.examples } + + let examples = index.examples(for: TypingHistoryQuery( + text: queryText, + bundleIdentifier: context.bundleIdentifier, + domain: SurfaceContextComposer.registrableDomain(from: context.focusedURLString), + currentFieldText: context.precedingText + )) + exampleCache = (cacheKey, examples) + return examples + } + + func phraseContinuation(for request: SuggestionRequest, engine: SuggestionEngineKind) -> String? { + guard preferences.isUsingHistory, engine != .openAICompatible, let phrases else { return nil } + // Text after the caret on the same line would be pushed along by an inserted phrase; leave + // those mid-line positions to the model, which sees the trailing text. + let restOfLine = request.context.trailingText.prefix { !$0.isNewline } + guard restOfLine.trimmingCharacters(in: .whitespaces).isEmpty else { return nil } + return phrases.continuation( + after: request.context.precedingText, + limits: TypingHistoryPhrasePredictor.Limits( + maxWords: request.wordRange?.highWords ?? 8, + allowsNewlines: request.isMultiLineEnabled + ) + ) + } +} diff --git a/Cotabby/Services/History/TypingHistoryVault.swift b/Cotabby/Services/History/TypingHistoryVault.swift new file mode 100644 index 00000000..c141171e --- /dev/null +++ b/Cotabby/Services/History/TypingHistoryVault.swift @@ -0,0 +1,131 @@ +import CryptoKit +import Foundation +import Security + +/// Encrypted on-disk storage for typing history. +/// +/// Why encryption: the archive holds months of what the user wrote. Sealing it with AES-GCM under a +/// random 256-bit key that lives only in the login Keychain means a copied file, a backup, or another +/// app reading Application Support sees ciphertext. The Keychain item is `ThisDeviceOnly`, so the key +/// never syncs and the archive cannot be opened on another Mac. +/// +/// The vault is a value type with no shared mutable state, so the store can load and save it on a +/// background task without touching the main actor. Errors are surfaced rather than swallowed: a +/// failed decrypt must not be mistaken for "no history" and then overwritten. +nonisolated struct TypingHistoryVault: Sendable { + enum VaultError: Error, Equatable { + case keychain(OSStatus) + case corruptArchive + } + + let fileURL: URL + private let keyStore: any TypingHistoryKeyStore + + init(fileURL: URL, keyStore: any TypingHistoryKeyStore) { + self.fileURL = fileURL + self.keyStore = keyStore + } + + /// The production vault: `Application Support//TypingHistory.sealed`, keyed per bundle + /// identifier so the release app and the dev build never share (or clobber) each other's key. + static func standard(bundle: Bundle = .main) -> TypingHistoryVault { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support") + let appName = (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) ?? "Cotabby" + let identifier = bundle.bundleIdentifier ?? "com.jacobfu.tabby" + return TypingHistoryVault( + fileURL: support.appendingPathComponent(appName).appendingPathComponent("TypingHistory.sealed"), + keyStore: KeychainTypingHistoryKeyStore(service: "\(identifier).typing-history") + ) + } + + /// Returns the stored records, or an empty list when nothing has been saved yet. + func load() throws -> [TypingHistoryRecord] { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } + guard let key = try keyStore.existingKey() else { + // A file without its key can never be opened again; report it instead of returning [] + // so the caller does not overwrite it as if it were empty. + throw VaultError.corruptArchive + } + let sealed = try Data(contentsOf: fileURL) + guard let box = try? AES.GCM.SealedBox(combined: sealed), + let plaintext = try? AES.GCM.open(box, using: key), + let archive = try? JSONDecoder().decode(TypingHistoryArchive.self, from: plaintext) + else { + throw VaultError.corruptArchive + } + return archive.records + } + + func save(_ records: [TypingHistoryRecord]) throws { + let key = try keyStore.existingKey() ?? keyStore.createKey() + let plaintext = try JSONEncoder().encode(TypingHistoryArchive(version: TypingHistoryArchive.currentVersion, records: records)) + guard let sealed = try AES.GCM.seal(plaintext, using: key).combined else { throw VaultError.corruptArchive } + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try sealed.write(to: fileURL, options: [.atomic]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + } + + /// Deletes the archive and its key. Removing the key too means even an undeleted copy of the + /// file (Time Machine, a sync folder) can no longer be decrypted. + func destroy() throws { + if FileManager.default.fileExists(atPath: fileURL.path) { + try FileManager.default.removeItem(at: fileURL) + } + try keyStore.deleteKey() + } +} + +/// Where the vault's symmetric key lives. A protocol so tests can keep keys in memory instead of +/// writing items into the developer's login Keychain. +nonisolated protocol TypingHistoryKeyStore: Sendable { + func existingKey() throws -> SymmetricKey? + func createKey() throws -> SymmetricKey + func deleteKey() throws +} + +/// The production key store: one generic-password item in the login Keychain. +nonisolated struct KeychainTypingHistoryKeyStore: TypingHistoryKeyStore { + let service: String + private static let account = "archive-key" + + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: Self.account + ] + } + + func existingKey() throws -> SymmetricKey? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + // `item` is a CFData when the query asks for data; bridging to Data copies the bytes. + guard status == errSecSuccess, let data = item as? Data, data.count == 32 else { + throw TypingHistoryVault.VaultError.keychain(status) + } + return SymmetricKey(data: data) + } + + func createKey() throws -> SymmetricKey { + let key = SymmetricKey(size: .bits256) + var attributes = baseQuery + attributes[kSecValueData as String] = key.withUnsafeBytes { Data($0) } + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + attributes[kSecAttrLabel as String] = "Cotabby typing history key" + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { throw TypingHistoryVault.VaultError.keychain(status) } + return key + } + + func deleteKey() throws { + let status = SecItemDelete(baseQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw TypingHistoryVault.VaultError.keychain(status) + } + } +} diff --git a/Cotabby/Services/Runtime/SuggestionEngineRouter.swift b/Cotabby/Services/Runtime/SuggestionEngineRouter.swift index 0c184347..4add2ca4 100644 --- a/Cotabby/Services/Runtime/SuggestionEngineRouter.swift +++ b/Cotabby/Services/Runtime/SuggestionEngineRouter.swift @@ -82,6 +82,16 @@ final class SuggestionEngineRouter { recordQualityOutcome(result) return result case .openAICompatible: + // The request was built from a settings snapshot; power-source switching can move the + // live engine to the endpoint in between. Typing history must never leave this Mac, so + // a request that carries it is dropped rather than sent. + guard request.historyExamples.isEmpty else { + CotabbyLogger.suggestion.info("Withheld a request carrying typing history from the endpoint", metadata: metadata) + return SuggestionResult( + generation: request.generation, rawText: "", text: "", latency: 0, + suppressionReason: "historyWithheldFromEndpoint" + ) + } CotabbyLogger.suggestion.debug("Routing to OpenAI-compatible endpoint", metadata: metadata) let result = try await openAICompatibleEngine.generateSuggestion(for: request, onPartial: onPartial) recordPerformanceMetric(modelName: endpointModelNameProvider() ?? "Local Endpoint", latency: result.latency) diff --git a/Cotabby/Services/Runtime/TypingHistoryPhraseEngine.swift b/Cotabby/Services/Runtime/TypingHistoryPhraseEngine.swift new file mode 100644 index 00000000..e41150de --- /dev/null +++ b/Cotabby/Services/Runtime/TypingHistoryPhraseEngine.swift @@ -0,0 +1,64 @@ +import Foundation +import Logging + +/// Answers a request straight from typing history when history knows how the phrase ends, and +/// otherwise hands it to the real engine. +/// +/// Why a wrapper around the router: a phrase shortcut is just another way to produce a +/// `SuggestionResult`, so the coordinator, overlay, and acceptance code need no changes to use it. +/// Sitting in front of `SuggestionEngineRouter` also means one check covers every engine, and the +/// live engine kind is read at request time so the endpoint never gets history-derived text. +/// +/// Lifetime: built once in `CotabbyAppEnvironment` around the router and owned by the coordinator +/// as its `suggestionEngine`. +@MainActor +final class TypingHistoryPhraseEngine: SuggestionGenerating { + private let base: any SuggestionGenerating + private let history: any SuggestionHistoryProviding + private let engineKind: @MainActor () -> SuggestionEngineKind + + init( + wrapping base: any SuggestionGenerating, + history: any SuggestionHistoryProviding, + engineKind: @escaping @MainActor () -> SuggestionEngineKind + ) { + self.base = base + self.history = history + self.engineKind = engineKind + } + + func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult { + try await generateSuggestion(for: request, onPartial: nil) + } + + func generateSuggestion( + for request: SuggestionRequest, + onPartial: (@MainActor (SuggestionResult) -> Void)? + ) async throws -> SuggestionResult { + let started = ProcessInfo.processInfo.systemUptime + if let phrase = history.phraseContinuation(for: request, engine: engineKind()) { + CotabbyLogger.suggestion.debug( + "Answered from typing history", + metadata: ["request_id": .string(request.requestID), "engine": .string("history")] + ) + // The phrase is the user's own text, character for character, including the leading + // space, so the ghost renders it exactly instead of re-deciding the word boundary. + return SuggestionResult( + generation: request.generation, + rawText: phrase, + text: phrase, + latency: ProcessInfo.processInfo.systemUptime - started, + spacingIsExact: true + ) + } + return try await base.generateSuggestion(for: request, onPartial: onPartial) + } + + func resetCachedGenerationContext() async { + await base.resetCachedGenerationContext() + } + + func prewarm(for request: SuggestionRequest) async { + await base.prewarm(for: request) + } +} diff --git a/Cotabby/Support/History/CotypistExportImporter.swift b/Cotabby/Support/History/CotypistExportImporter.swift new file mode 100644 index 00000000..67d44c94 --- /dev/null +++ b/Cotabby/Support/History/CotypistExportImporter.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Converts a Cotypist typing-history export into Cotabby history records. +/// +/// Cotypist keeps its history in an encrypted database with no export button; the user's decrypted +/// copy is a JSON array of `user_inputs` rows (`textUpToCursor`, `textAfterCursor`, +/// `appBundleIdentifier`, `domain`, `createdAt`, ...). This type only maps that shape: it does no +/// file or Keychain access, so it is pure and testable. +/// +/// Cotypist stores a new row each time it snapshots the same field, so one email can appear dozens +/// of times as it grows. Rows are grouped by app plus the opening of the text, and only the longest +/// (most complete) version of each is kept; otherwise retrieval would return near-copies and phrase +/// counts would be inflated by repetition. +nonisolated enum CotypistExportImporter { + enum ImportError: Error, Equatable, LocalizedError { + case unrecognizedFormat + + var errorDescription: String? { + "This file isn't a Cotypist user_inputs export (a JSON list of typing-history rows)." + } + } + + private struct Row: Decodable { + let createdAt: String? + let updatedAt: String? + let appBundleIdentifier: String? + let textUpToCursor: String? + let textAfterCursor: String? + let domain: String? + } + + /// Texts shorter than this after scrubbing are fragments ("ok", "?") with nothing to learn. + static let minimumCharacters = 20 + private static let groupingPrefixLength = 40 + + static func records(fromExport data: Data) throws -> [TypingHistoryRecord] { + guard let rows = try? JSONDecoder().decode([Row].self, from: data) else { + throw ImportError.unrecognizedFormat + } + guard rows.isEmpty || rows.contains(where: { $0.textUpToCursor != nil }) else { + throw ImportError.unrecognizedFormat + } + + var longestByGroup: [String: TypingHistoryRecord] = [:] + for row in rows { + let (text, typedLength) = TypingHistoryScrubber.scrub( + before: row.textUpToCursor ?? "", after: row.textAfterCursor ?? "" + ) + guard text.trimmingCharacters(in: .whitespacesAndNewlines).count >= minimumCharacters else { continue } + let bundleIdentifier = row.appBundleIdentifier ?? "unknown" + let createdAt = parseDate(row.createdAt) ?? Date(timeIntervalSince1970: 0) + let record = TypingHistoryRecord( + id: UUID(), + bundleIdentifier: bundleIdentifier, + domain: normalizedDomain(row.domain), + createdAt: createdAt, + updatedAt: parseDate(row.updatedAt) ?? createdAt, + text: text, + source: .imported, + typedLength: typedLength + ) + let key = bundleIdentifier + "\u{1F}" + String(text.prefix(groupingPrefixLength)).lowercased() + if let existing = longestByGroup[key], existing.text.count >= text.count { continue } + longestByGroup[key] = record + } + return droppingEarlierSnapshots(Array(longestByGroup.values)).sorted { $0.createdAt < $1.createdAt } + } + + /// A field's early snapshots can be shorter than the grouping prefix ("Hi Arnaud, the POC is + /// read"), so they land in their own group. Within each app, drop any text that is the start of + /// a longer kept text: it is the same writing, caught before it was finished. + private static func droppingEarlierSnapshots(_ records: [TypingHistoryRecord]) -> [TypingHistoryRecord] { + var kept: [TypingHistoryRecord] = [] + for (_, appRecords) in Dictionary(grouping: records, by: \.bundleIdentifier) { + var keptInApp: [TypingHistoryRecord] = [] + for record in appRecords.sorted(by: { $0.text.count > $1.text.count }) + where !keptInApp.contains(where: { $0.text.hasPrefix(record.text) }) { + keptInApp.append(record) + } + kept.append(contentsOf: keptInApp) + } + return kept + } + + /// Cotypist uses "-" for fields with no site; treat that like no domain at all. + private static func normalizedDomain(_ domain: String?) -> String? { + guard let domain = domain?.trimmingCharacters(in: .whitespaces), !domain.isEmpty, domain != "-" else { + return nil + } + return domain + } + + /// The export writes SQLite-style timestamps ("2026-06-05 16:23:53.028") in UTC. + private static func parseDate(_ string: String?) -> Date? { + guard let string else { return nil } + for format in ["yyyy-MM-dd HH:mm:ss.SSS", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ"] { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = format + if let date = formatter.date(from: string) { return date } + } + return nil + } +} diff --git a/Cotabby/Support/History/TypingHistoryIndex.swift b/Cotabby/Support/History/TypingHistoryIndex.swift new file mode 100644 index 00000000..0afa579f --- /dev/null +++ b/Cotabby/Support/History/TypingHistoryIndex.swift @@ -0,0 +1,202 @@ +import Foundation + +/// What a retrieval looks for: the stable part of the text being written plus where it is written. +nonisolated struct TypingHistoryQuery: Equatable, Sendable { + /// Words that characterize the current writing (see `TypingHistoryQuery.stableText`). + let text: String + let bundleIdentifier: String + let domain: String? + /// The live field text. A history entry that contains its tail is the same document (an older + /// snapshot of this very field), and showing the model its own draft would only make it echo. + let currentFieldText: String + + /// Builds the query text from the caret prefix, rounded down to a whole block of words. + /// + /// The llama runtime reuses its KV cache for the unchanged head of the prompt, and examples sit + /// in that head. Querying on every keystroke would change the examples, and with them the cached + /// prefix, on nearly every request. Rounding to blocks of `wordsPerBlock` words keeps the + /// examples fixed while a few words are typed and refreshes them as the topic moves on. + static func stableText(from precedingText: String, wordsPerBlock: Int = 8, maxWords: Int = 48) -> String { + let words = precedingText.split(whereSeparator: \.isWhitespace) + let completeBlocks = (words.count / wordsPerBlock) * wordsPerBlock + guard completeBlocks > 0 else { return "" } + let window = words[..= Self.minimumDocumentCharacters else { continue } + let index = Int32(documents.count) + documents.append(Document(bundleIdentifier: record.bundleIdentifier, domain: record.domain, text: text)) + for term in Set(Self.terms(in: text)) { + postings[term, default: []].append(index) + } + } + let count = Double(documents.count) + self.documents = documents + self.postings = postings + // Smoothed IDF: every shared word counts at least 1, rarer words count more. Unsmoothed + // log(N/df) drops to zero for a word in every document, which in a small history made + // even a close match score nothing. + self.inverseDocumentFrequency = postings.mapValues { log((count + 1) / Double($0.count)) + 1 } + } + + /// Returns up to `limit` passages from past writing that best match the query, each at most + /// `maxCharacters` long, best first. + func examples(for query: TypingHistoryQuery, limit: Int = 2, maxCharacters: Int = 320) -> [String] { + let queryTerms = Set(Self.terms(in: query.text)) + guard !queryTerms.isEmpty, limit > 0 else { return [] } + + var scores: [Int32: Double] = [:] + for term in queryTerms { + guard let weight = inverseDocumentFrequency[term], let documentIDs = postings[term] else { continue } + for documentID in documentIDs { + scores[documentID, default: 0] += weight + } + } + + let currentTail = String(query.currentFieldText.suffix(60)).trimmingCharacters(in: .whitespacesAndNewlines) + let ranked = scores + .map { documentID, score -> (Int32, Double) in + let document = documents[Int(documentID)] + var boosted = score + if document.bundleIdentifier == query.bundleIdentifier { boosted *= Self.sameAppBoost } + if let domain = query.domain, document.domain == domain { boosted *= Self.sameDomainBoost } + return (documentID, boosted) + } + .filter { $0.1 >= Self.minimumScore } + .sorted { $0.1 > $1.1 } + + var passages: [String] = [] + for (documentID, _) in ranked { + let text = documents[Int(documentID)].text + if Self.isSameDocument(text, currentText: query.currentFieldText, currentTail: currentTail) { continue } + let passage = Self.bestPassage(in: text, terms: queryTerms, maxCharacters: maxCharacters) + guard !passage.isEmpty, !passages.contains(passage) else { continue } + passages.append(passage) + if passages.count == limit { break } + } + return passages + } + + /// True when a history entry is another version of the text being typed: an earlier snapshot + /// (the field now contains its opening) or a later one (it contains the field's latest words). + private static func isSameDocument(_ text: String, currentText: String, currentTail: String) -> Bool { + let opening = String(text.prefix(60)) + if opening.count >= 20, currentText.contains(opening) { return true } + return currentTail.count >= 20 && text.contains(currentTail) + } + + // MARK: - Text helpers + + private static let stopWords: Set = Set(""" + a an the and or but if then so to of in on at for with from by as is are was were be been being it its \ + this that these those i you he she we they me my your our their them us do does did not no yes can could \ + will would should have has had just also very more most some any all what which who when where why how \ + there here than too up out about into over after before again only own same other such each few both \ + one get got make made like well back still even way go going know think see want need please thanks \ + thank hi hello ok okay im dont ive ill let lets sure yeah + """.split(whereSeparator: \.isWhitespace).map(String.init)) + + /// Lowercased words of two or more letters or digits, minus stop words. + static func terms(in text: String) -> [String] { + text.lowercased() + .split(whereSeparator: { !($0.isLetter || $0.isNumber) }) + .filter { $0.count >= 2 } + .map(String.init) + .filter { !stopWords.contains($0) } + } + + /// Picks the sentence with the most query terms and grows it with its neighbors while it fits, + /// so the example reads as the user's own sentence rather than a mid-word cut. + private static func bestPassage(in text: String, terms: Set, maxCharacters: Int) -> String { + let sentences = splitSentences(text) + guard !sentences.isEmpty else { return "" } + let hits = sentences.map { sentence in Self.terms(in: sentence).filter(terms.contains).count } + guard let best = hits.indices.max(by: { hits[$0] < hits[$1] }) else { return "" } + + var lower = best + var upper = best + var length = sentences[best].count + while true { + let canGrowDown = lower > 0 && length + sentences[lower - 1].count + 1 <= maxCharacters + let canGrowUp = upper < sentences.count - 1 && length + sentences[upper + 1].count + 1 <= maxCharacters + if canGrowUp, !canGrowDown || hits[upper + 1] >= hits[lower - 1] { + upper += 1 + length += sentences[upper].count + 1 + } else if canGrowDown { + lower -= 1 + length += sentences[lower].count + 1 + } else { + break + } + } + let passage = sentences[lower...upper].joined(separator: " ") + guard passage.count > maxCharacters else { return passage } + // One very long sentence: keep whole words up to the cap. + var clipped = String(passage.prefix(maxCharacters)) + if let lastSpace = clipped.lastIndex(of: " ") { + clipped = String(clipped[.. [String] { + var sentences: [String] = [] + var current = "" + for character in text { + if character.isNewline { + appendTrimmed(current, to: &sentences) + current = "" + continue + } + current.append(character) + if ".!?".contains(character) { + appendTrimmed(current, to: &sentences) + current = "" + } + } + appendTrimmed(current, to: &sentences) + return sentences + } + + private static func appendTrimmed(_ sentence: String, to sentences: inout [String]) { + let trimmed = sentence.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { sentences.append(trimmed) } + } +} diff --git a/Cotabby/Support/History/TypingHistoryPhrasePredictor.swift b/Cotabby/Support/History/TypingHistoryPhrasePredictor.swift new file mode 100644 index 00000000..94b01710 --- /dev/null +++ b/Cotabby/Support/History/TypingHistoryPhrasePredictor.swift @@ -0,0 +1,233 @@ +import Foundation + +/// Predicts the rest of a phrase the user has typed many times before, without a model. +/// +/// The idea: after "Best regards," the user writes "Senad" nearly every time; after "let me know" +/// they usually write "if you have any questions." A word-level n-gram table built from history +/// answers those instantly and in the user's exact wording, which a general model can only guess. +/// +/// It is intentionally strict. A continuation is offered only when the same three words were +/// followed by the same next word at least `minimumCount` times and in at least `minimumShare` of +/// cases, and the chain stops at the first word history is unsure about. Anything less confident +/// falls through to the model, so a shortcut is either clearly right or absent. +/// +/// Built off the main actor; `continuation(after:)` runs on the main actor per request and is a +/// handful of dictionary lookups. +nonisolated struct TypingHistoryPhrasePredictor: Sendable { + struct Limits: Equatable, Sendable { + var maxWords: Int + var allowsNewlines: Bool + } + + private static let contextLength = 3 + private static let minimumCount: Int32 = 3 + private static let minimumShare = 0.6 + /// When the last three words were never seen together, the last two are tried instead. Two + /// words predict less, so that fallback needs more evidence and a clearer winner. + private static let backoffMinimumCount: Int32 = 5 + private static let backoffMinimumShare = 0.7 + /// Marks a context word history has never seen, so the three-word lookup misses cleanly. + private static let unknownToken: Int32 = -1 + /// A one-word shortcut ("Senad" after "Best regards,") needs more evidence than a longer chain, + /// because a single common word is more likely to be a coincidence. + private static let minimumSingleWordCount: Int32 = 5 + private static let newlineToken = "\n" + + private struct Context: Hashable, Sendable { + let first: Int32 + let second: Int32 + let third: Int32 + } + + private struct ShortContext: Hashable, Sendable { + let first: Int32 + let second: Int32 + } + + /// Lowercased token -> id. Ids index `displayForms`. + private let vocabulary: [String: Int32] + /// The spelling to insert for each token, as the user last wrote it ("Senad", "POC"). + private let displayForms: [String] + /// Next-token counts for contexts seen at least `minimumCount` times. + private let continuations: [Context: [Int32: Int32]] + /// Two-word fallback, kept only for pairs seen at least `backoffMinimumCount` times. + private let shortContinuations: [ShortContext: [Int32: Int32]] + + var contextCount: Int { continuations.count } + + init(records: [TypingHistoryRecord]) { + var vocabulary: [String: Int32] = [:] + var displayForms: [String] = [] + var sequences: [[Int32]] = [] + sequences.reserveCapacity(records.count) + for record in records { + var ids: [Int32] = [] + // Learn only from what the user typed, so names and phrasing in quoted replies below the + // caret never become the user's shortcuts. + for token in Self.tokens(in: record.typedText) { + let key = token.lowercased() + if let id = vocabulary[key] { + displayForms[Int(id)] = token + ids.append(id) + } else { + let id = Int32(displayForms.count) + vocabulary[key] = id + displayForms.append(token) + ids.append(id) + } + } + sequences.append(ids) + } + + // Two passes keep memory bounded: count every context first, then collect next-token + // counts only for contexts frequent enough to ever produce a shortcut. + var contextCounts: [Context: Int32] = [:] + for ids in sequences where ids.count > Self.contextLength { + for index in Self.contextLength.. Self.contextLength { + for index in Self.contextLength..= Self.minimumCount else { continue } + continuations[context, default: [:]][ids[index], default: 0] += 1 + } + } + + var shortCounts: [ShortContext: Int32] = [:] + for ids in sequences where ids.count > 2 { + for index in 2.. 2 { + for index in 2..= Self.backoffMinimumCount else { continue } + shortContinuations[context, default: [:]][ids[index], default: 0] += 1 + } + } + + self.vocabulary = vocabulary + self.displayForms = displayForms + self.continuations = continuations + self.shortContinuations = shortContinuations + } + + /// The exact text to insert after `precedingText`, or nil when history is not confident. + /// + /// When the caret is inside a word ("Best reg"), the typed letters filter the first word and + /// only its untyped remainder is returned ("ards, Senad"). Otherwise the result starts with + /// the separating space. + func continuation(after precedingText: String, limits: Limits) -> String? { + let endsAtBoundary = precedingText.last.map { $0.isWhitespace } ?? true + var tokens = Self.tokens(in: String(precedingText.suffix(400))) + let partial = endsAtBoundary ? nil : tokens.popLast() + guard tokens.count >= 2 else { return nil } + + // The two words nearest the caret must be known; the third may be new (it only selects the + // three-word table), in which case the two-word fallback answers. + var ids: [Int32] = tokens.count >= Self.contextLength ? [] : [Self.unknownToken] + for (offset, token) in tokens.suffix(Self.contextLength).enumerated() { + let isNearCaret = offset >= min(tokens.count, Self.contextLength) - 2 + if let id = vocabulary[token.lowercased()] { + ids.append(id) + } else if isNearCaret { + return nil + } else { + ids.append(Self.unknownToken) + } + } + + var output = "" + var words = 0 + var weakestCount = Int32.max + var isFirstStep = true + while words < limits.maxWords { + let prefixFilter = isFirstStep ? partial?.lowercased() : nil + let context = Context(first: ids[ids.count - 3], second: ids[ids.count - 2], third: ids[ids.count - 1]) + let shortContext = ShortContext(first: ids[ids.count - 2], second: ids[ids.count - 1]) + let choice: (Int32, Int32)? + if let candidates = continuations[context] { + choice = Self.confidentCandidate( + in: candidates, displayForms: displayForms, prefix: prefixFilter, + minimumCount: Self.minimumCount, minimumShare: Self.minimumShare + ) + } else if let candidates = shortContinuations[shortContext] { + choice = Self.confidentCandidate( + in: candidates, displayForms: displayForms, prefix: prefixFilter, + minimumCount: Self.backoffMinimumCount, minimumShare: Self.backoffMinimumShare + ) + } else { + choice = nil + } + guard let (nextID, count) = choice else { break } + let token = displayForms[Int(nextID)] + + if token == Self.newlineToken { + guard limits.allowsNewlines, !isFirstStep || partial == nil else { break } + output += "\n" + } else if isFirstStep, let partial { + output += String(token.dropFirst(partial.count)) + words += 1 + } else { + // A space separates words, except right after a line break or at the very start + // when the field already ends in whitespace the user typed. + let separator = output.hasSuffix("\n") || (output.isEmpty && endsAtBoundary) ? "" : " " + output += separator + token + words += 1 + } + weakestCount = min(weakestCount, count) + ids.append(nextID) + isFirstStep = false + if token.last.map({ ".!?".contains($0) }) == true { break } + } + + let visible = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !visible.isEmpty else { return nil } + if words < 2, weakestCount < Self.minimumSingleWordCount { return nil } + return output.hasSuffix("\n") ? String(output.dropLast()) : output + } + + private static func confidentCandidate( + in candidates: [Int32: Int32], + displayForms: [String], + prefix: String?, + minimumCount: Int32, + minimumShare: Double + ) -> (Int32, Int32)? { + let eligible = prefix.map { prefix in + candidates.filter { id, _ in + let form = displayForms[Int(id)].lowercased() + return form != newlineToken && form.hasPrefix(prefix) + } + } ?? candidates + let total = eligible.values.reduce(0, +) + guard let best = eligible.max(by: { $0.value < $1.value }), total > 0, + best.value >= minimumCount, Double(best.value) / Double(total) >= minimumShare + else { return nil } + return (best.key, best.value) + } + + /// Splits on spaces and tabs, keeping punctuation attached ("regards,") and turning each line + /// break into its own token so sign-offs that span lines can be learned. + static func tokens(in text: String) -> [String] { + var tokens: [String] = [] + var current = "" + for character in text { + if character.isNewline { + if !current.isEmpty { tokens.append(current); current = "" } + if tokens.last != newlineToken { tokens.append(newlineToken) } + } else if character.isWhitespace { + if !current.isEmpty { tokens.append(current); current = "" } + } else { + current.append(character) + } + } + if !current.isEmpty { tokens.append(current) } + return tokens + } +} diff --git a/Cotabby/Support/History/TypingHistoryScrubber.swift b/Cotabby/Support/History/TypingHistoryScrubber.swift new file mode 100644 index 00000000..1be77b78 --- /dev/null +++ b/Cotabby/Support/History/TypingHistoryScrubber.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Removes secret-like strings from text before it enters typing history. +/// +/// History is stored for months and fed back into prompts, so a pasted API key or private key must +/// not survive into it, even though the archive is encrypted and only on-device engines read it. +/// The rules are deliberately conservative about prose: ordinary words, names, and short numbers +/// pass through untouched, and only shapes that are almost never written by hand are replaced. +nonisolated enum TypingHistoryScrubber { + static let redaction = "[redacted]" + + /// Records longer than this keep only their tail. History is used for the user's recent wording, + /// and a 50k-character document would otherwise dominate retrieval and memory. + static let maximumRecordCharacters = 12_000 + + private static let privateKeyBlock = try? NSRegularExpression( + pattern: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)" + ) + /// Known credential prefixes followed by a run of key characters (OpenAI/Anthropic `sk-`, + /// GitHub `ghp_`/`gho_`/`github_pat_`, Slack `xox?-`, AWS access key ids). + private static let prefixedCredential = try? NSRegularExpression( + pattern: "\\b(sk-[A-Za-z0-9_\\-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}" + + "|xox[abprs]-[A-Za-z0-9\\-]{10,}|AKIA[0-9A-Z]{16})" + ) + /// Any long unbroken token mixing letters and digits: JWTs, hex digests, base64 secrets. 24 + /// characters is longer than nearly every real word or identifier a person types. + private static let longMixedToken = try? NSRegularExpression( + pattern: "[A-Za-z0-9_\\-+/=.]{24,}" + ) + + /// Scrubs the text before and after the caret separately and caps the pair, so the boundary + /// between what the user typed and what was already there survives (`TypingHistoryRecord.typedLength`). + /// When the field is too long, the typed side keeps its end (nearest the caret) and the rest + /// keeps its start, the same "around the caret" window a reader would care about. + static func scrub(before: String, after: String) -> (text: String, typedLength: Int) { + var typed = scrub(before) + var rest = scrub(after) + let afterBudget = min(rest.count, maximumRecordCharacters / 6) + if typed.count + rest.count > maximumRecordCharacters { + rest = String(rest.prefix(afterBudget)) + typed = String(typed.suffix(maximumRecordCharacters - rest.count)) + } + while typed.first?.isWhitespace == true { typed.removeFirst() } + while rest.last?.isWhitespace == true { rest.removeLast() } + if rest.isEmpty { + while typed.last?.isWhitespace == true { typed.removeLast() } + } + return (typed + rest, typed.count) + } + + static func scrub(_ text: String) -> String { + var result = text + for expression in [privateKeyBlock, prefixedCredential].compactMap({ $0 }) { + result = replace(expression, in: result) + } + if let longMixedToken { + result = replaceMatches(of: longMixedToken, in: result) { token in + // Long all-letter runs are real words in agglutinative languages (Turkish, German + // compounds); only runs carrying digits look like generated secrets. + token.contains(where: \.isNumber) && token.contains(where: \.isLetter) ? redaction : token + } + } + if result.count > maximumRecordCharacters { + result = String(result.suffix(maximumRecordCharacters)) + } + return result + } + + private static func replace(_ expression: NSRegularExpression, in text: String) -> String { + let range = NSRange(text.startIndex..., in: text) + return expression.stringByReplacingMatches(in: text, range: range, withTemplate: redaction) + } + + private static func replaceMatches( + of expression: NSRegularExpression, + in text: String, + transform: (String) -> String + ) -> String { + let nsText = text as NSString + var output = "" + var cursor = 0 + for match in expression.matches(in: text, range: NSRange(location: 0, length: nsText.length)) { + output += nsText.substring(with: NSRange(location: cursor, length: match.range.location - cursor)) + output += transform(nsText.substring(with: match.range)) + cursor = match.range.location + match.range.length + } + output += nsText.substring(from: cursor) + return output + } +} diff --git a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift index 12c3ec7c..3b63faa1 100644 --- a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift +++ b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift @@ -30,6 +30,7 @@ enum BaseCompletionPromptRenderer { clipboardContext: String? = nil, visualContextSummary: String? = nil, surfaceContext: SurfaceContext? = nil, + historyExamples: [String] = [], usesCompactSurfaceContext: Bool = false, contextBudget: Int = defaultContextBudget, maxScreenCharacters: Int = 4000, @@ -62,6 +63,9 @@ enum BaseCompletionPromptRenderer { // the whole blob lands. sections.append(Self.contextSection("notes", "Notes the writer keeps in mind: \(notes)", priority: 40, maxChars: 1300)) } + if let history = Self.historySection(historyExamples) { + sections.append(history) + } if let clip = Self.nonEmpty(clipboardContext) { sections.append(Self.contextSection("clipboard", "On the clipboard: \(clip)", priority: 35, maxChars: 400)) } @@ -140,6 +144,23 @@ enum BaseCompletionPromptRenderer { return contextSection("surface", lines.joined(separator: " "), priority: 70, maxChars: 240) } + /// The user's own earlier sentences, quoted. A base model conditions strongly on nearby text in + /// the same voice, so a couple of real examples pull its word choice toward how this person + /// writes. Sits after the stable preface and before the per-keystroke clipboard and screen + /// sections: the examples change only every few words (`TypingHistoryQuery.stableText`), so + /// placing them earlier keeps more of the prompt's head reusable from the KV cache. + private static func historySection(_ examples: [String]) -> PromptSection? { + let quoted = examples + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { "“\($0)”" } + guard !quoted.isEmpty else { return nil } + return contextSection( + "history", "Earlier writing by the same author:\n" + quoted.joined(separator: "\n"), + priority: 38, maxChars: 760 + ) + } + private static func contextSection( _ name: String, _ content: String, diff --git a/Cotabby/Support/Prompting/FoundationModelPromptRenderer.swift b/Cotabby/Support/Prompting/FoundationModelPromptRenderer.swift index fda49121..b2c428f1 100644 --- a/Cotabby/Support/Prompting/FoundationModelPromptRenderer.swift +++ b/Cotabby/Support/Prompting/FoundationModelPromptRenderer.swift @@ -137,6 +137,16 @@ enum FoundationModelPromptRenderer { sections.append(summary) } + // The user's own earlier sentences live in the per-request prompt, not the instructions: + // they change as the topic moves, and instructions are the cached part of Apple's session. + // The framing says what they are for so the chat-tuned model borrows wording, not content. + let examples = request.historyExamples.filter { !$0.isEmpty } + if !examples.isEmpty { + sections.append("") + sections.append("Earlier writing by the same user, to match their wording (do not repeat it):") + sections.append(contentsOf: examples.map { "\"\($0)\"" }) + } + if let clipboardContext = request.clipboardContext, !clipboardContext.isEmpty { sections.append("") diff --git a/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift b/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift index 8958f071..1a083d79 100644 --- a/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift +++ b/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift @@ -45,7 +45,8 @@ enum SuggestionRequestFactory { settings: SuggestionSettingsSnapshot, configuration: SuggestionConfiguration, clipboardContext: String? = nil, - visualContextSummary: String? = nil + visualContextSummary: String? = nil, + historyExamples: [String] = [] ) -> SuggestionRequestBuildResult { let prefixText = truncatedPromptPrefix( from: context.precedingText, @@ -93,6 +94,10 @@ enum SuggestionRequestFactory { fieldPlaceholder: context.fieldPlaceholder ) : nil + // Typing history stays on this Mac. The provider already returns nothing for the endpoint + // engine; dropping it here as well keeps that guarantee in the one pure place every request + // passes through. + let activeHistoryExamples = settings.selectedEngine == .openAICompatible ? [] : historyExamples // Cotabby 2 is a base-model continuation product on the Open Source path, so the local // prompt is always the base render: no instruction blob, exact caret prefix last. // Custom instructions and persona condition the output rather than being obeyed. The @@ -113,6 +118,7 @@ enum SuggestionRequestFactory { clipboardContext: boundedClipboardContext, visualContextSummary: boundedVisualContextSummary, surfaceContext: surfaceContext, + historyExamples: activeHistoryExamples, contextBudget: settings.selectedEngine == .openAICompatible ? 2400 : BaseCompletionPromptRenderer.defaultContextBudget, maxScreenCharacters: settings.selectedEngine == .openAICompatible ? 500 : 4000, screenPriority: settings.selectedEngine == .openAICompatible ? 30 : 45, @@ -145,6 +151,7 @@ enum SuggestionRequestFactory { clipboardContext: boundedClipboardContext, visualContextSummary: boundedVisualContextSummary, surfaceContext: surfaceContext, + historyExamples: activeHistoryExamples, isMultiLineEnabled: settings.isMultiLineEnabled, requestID: RequestID.generate(), wordRange: settings.effectiveWordRange diff --git a/Cotabby/UI/Settings/Panes/ContextPaneView.swift b/Cotabby/UI/Settings/Panes/ContextPaneView.swift index bf5dbcaf..5d677175 100644 --- a/Cotabby/UI/Settings/Panes/ContextPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ContextPaneView.swift @@ -25,6 +25,8 @@ import SwiftUI /// can type a trailing space; `SuggestionRequestFactory` does the once-per-request trim instead. struct ContextPaneView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel + /// Owned by `CotabbyAppEnvironment`; drives the Typing History section. + @ObservedObject var typingHistory: TypingHistoryStore private static let previewEditorMinHeight: CGFloat = 132 private static let extendedContextEditorMinHeight: CGFloat = 220 @@ -34,6 +36,7 @@ struct ContextPaneView: View { livePreviewSection extendedContextSection howThisIsUsedSection + TypingHistorySectionView(store: typingHistory) } } diff --git a/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift new file mode 100644 index 00000000..d51db69f --- /dev/null +++ b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift @@ -0,0 +1,118 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// The "Typing History" section of the Context settings pane. +/// +/// Presentation only: every action goes to `TypingHistoryStore`, which owns the preferences, +/// archive, and recording. A separate view keeps the Context pane readable and puts the privacy +/// wording for this feature in one place. +struct TypingHistorySectionView: View { + @ObservedObject var store: TypingHistoryStore + @State private var isConfirmingDeleteAll = false + + var body: some View { + Section("Typing History") { + Toggle(isOn: Binding(get: { store.preferences.isUsingHistory }, set: { store.setUsingHistory($0) })) { + SettingsRowLabel( + title: "Use My Typing History", + description: "Finishes phrases you often type and shows the model examples of how you " + + "write. Only Apple Intelligence and the local model use it; it is never sent to an endpoint.", + systemImage: "clock.arrow.circlepath" + ) + } + .settingsItem(.typingHistory) + + Toggle(isOn: Binding(get: { store.preferences.isRecording }, set: { store.setRecording($0) })) { + SettingsRowLabel( + title: "Record What I Type", + description: "Saves the text of fields where Cotabby is active, encrypted on this Mac. " + + "Password fields and disabled apps are never recorded.", + systemImage: "record.circle" + ) + } + .disabled(store.status != .ready) + + if !store.preferences.excludedBundleIdentifiers.isEmpty { + ForEach(store.preferences.excludedBundleIdentifiers, id: \.self) { identifier in + LabeledContent { + Button("Remove") { store.setExcluded(identifier, excluded: false) } + } label: { + Text("Not recorded: \(Self.displayName(for: identifier))") + } + } + } + + LabeledContent { + HStack(spacing: 8) { + Button("Exclude an App…") { chooseAppToExclude() } + Button("Import Cotypist Export…") { chooseExportToImport() } + .disabled(store.status != .ready || store.isImporting) + Button("Delete All…", role: .destructive) { isConfirmingDeleteAll = true } + .disabled(store.recordCount == 0) + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(entryCountLabel) + if let message = statusMessage { + Text(message) + .font(.caption) + .foregroundStyle(store.status == .ready ? Color.secondary : Color.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .confirmationDialog( + "Delete all typing history?", + isPresented: $isConfirmingDeleteAll + ) { + Button("Delete All \(store.recordCount) Entries", role: .destructive) { store.deleteAll() } + } message: { + Text("This removes every recorded and imported entry and its encryption key. It can't be undone.") + } + } + } + + private var entryCountLabel: String { + switch store.status { + case .loading: return "Opening typing history…" + case .ready, .unavailable: return store.recordCount == 1 ? "1 entry stored" : "\(store.recordCount) entries stored" + } + } + + private var statusMessage: String? { + if case let .unavailable(message) = store.status { return message } + if store.isImporting { return "Importing…" } + return store.lastImportMessage + } + + /// Asks for the `user_inputs.json` file from a decrypted Cotypist export. + private func chooseExportToImport() { + let panel = NSOpenPanel() + panel.title = "Import Cotypist Export" + panel.message = "Choose user_inputs.json from your Cotypist export." + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + Task { await store.importCotypistExport(from: url) } + } + + private func chooseAppToExclude() { + let panel = NSOpenPanel() + panel.title = "Don't Record in an App" + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.allowedContentTypes = [.application] + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url, + let identifier = Bundle(url: url)?.bundleIdentifier + else { return } + store.setExcluded(identifier, excluded: true) + } + + private static func displayName(for bundleIdentifier: String) -> String { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else { + return bundleIdentifier + } + return FileManager.default.displayName(atPath: url.path).replacingOccurrences(of: ".app", with: "") + } +} diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index e65917cc..89726b44 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -28,6 +28,8 @@ struct SettingsContainerView: View { let onShowWelcome: () -> Void let clearEmojiHistory: () -> Void + /// Owned by `CotabbyAppEnvironment`; the Context pane observes it for the Typing History section. + let typingHistoryStore: TypingHistoryStore let onQuit: () -> Void @AppStorage("cotabbySettingsSelectedCategoryV2") @@ -143,7 +145,7 @@ struct SettingsContainerView: View { case .writing: WritingPaneView(suggestionSettings: suggestionSettings) case .context: - ContextPaneView(suggestionSettings: suggestionSettings) + ContextPaneView(suggestionSettings: suggestionSettings, typingHistory: typingHistoryStore) case .shortcuts: ShortcutsPaneView(suggestionSettings: suggestionSettings) case .apps: diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 29ce5545..72d228ad 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -59,6 +59,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case automaticallyFixTypos // Context case extendedContext + case typingHistory case contextLivePreview // Engine & Model case engine @@ -149,6 +150,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "Spelling Dictionaries" case .automaticallyFixTypos: return "Automatically Fix Typos" case .extendedContext: return "Extended Context" + case .typingHistory: return "Typing History" case .contextLivePreview: return "Live Preview" case .engine: return "Engine" case .appleIntelligenceAvailability: return "Apple Intelligence Availability" @@ -233,6 +235,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "character.book.closed" case .automaticallyFixTypos: return "checkmark.circle" case .extendedContext: return "doc.text" + case .typingHistory: return "clock.arrow.circlepath" case .contextLivePreview: return "text.cursor" case .engine: return "cpu" case .appleIntelligenceAvailability: return "apple.logo" @@ -291,7 +294,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .length, .acceptPunctuation, .addSpaceAfterAccept, .name, .languages, .customRules, .hideSuggestionsOnTypo, .offerTypoCorrections, .spellingDictionaries, .automaticallyFixTypos: return .writing - case .extendedContext, .contextLivePreview: + case .extendedContext, .contextLivePreview, .typingHistory: return .context case .engine, .appleIntelligenceAvailability, .modelStatus, .selectedModel, .lowPowerModeAutoDisable, .powerBasedModelSwitching, .batteryModel, .pluggedInModel, @@ -360,6 +363,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "Dictionaries used to detect typos." case .automaticallyFixTypos: return "Replace a misspelled word right after you press Space." case .extendedContext: return "A glossary or notes sent with every suggestion." + case .typingHistory: return "Learn from what you type, and import Cotypist history." case .contextLivePreview: return "A real field that exercises the full pipeline." case .engine: return "Apple Intelligence, bundled Open Source, or a local endpoint." case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence." @@ -529,6 +533,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .extendedContext: return ["context", "glossary", "reference", "notes", "jargon", "instructions", "memory", "background", "system prompt", "vocabulary"] + case .typingHistory: + return ["history", "typing history", "personalize", "personalization", "learn", "record", + "cotypist", "import", "phrases", "my writing", "privacy", "delete"] case .contextLivePreview: return ["live", "preview", "test", "ghost", "try", "playground", "sandbox", "demo", "try it", "test field"] diff --git a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift new file mode 100644 index 00000000..60e104e5 --- /dev/null +++ b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift @@ -0,0 +1,244 @@ +import CryptoKit +import XCTest +@testable import Cotabby + +/// Keeps vault keys in memory so tests never touch the developer's login Keychain. +private final class InMemoryKeyStore: TypingHistoryKeyStore, @unchecked Sendable { + private let lock = NSLock() + private var key: SymmetricKey? + + func existingKey() throws -> SymmetricKey? { lock.withLock { key } } + func createKey() throws -> SymmetricKey { + lock.withLock { + let created = SymmetricKey(size: .bits256) + key = created + return created + } + } + func deleteKey() throws { lock.withLock { key = nil } } +} + +@MainActor +final class TypingHistoryStoreTests: XCTestCase { + /// App-target MainActor classes crash the app-hosted runner when deallocated; keep them alive. + private static var retained: [AnyObject] = [] + + private var directory: URL! + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + directory = FileManager.default.temporaryDirectory.appendingPathComponent("typing-history-\(UUID().uuidString)") + suiteName = "cotabby.test.typingHistory.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + private func makeVault(keyStore: InMemoryKeyStore = InMemoryKeyStore()) -> TypingHistoryVault { + TypingHistoryVault(fileURL: directory.appendingPathComponent("TypingHistory.sealed"), keyStore: keyStore) + } + + private func makeStore(vault: TypingHistoryVault? = nil) -> TypingHistoryStore { + let store = TypingHistoryStore(vault: vault ?? makeVault(), userDefaults: defaults, loadsArchive: false) + Self.retained.append(store) + return store + } + + private func writeExport(_ rows: [[String: Any]]) throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("user_inputs.json") + try JSONSerialization.data(withJSONObject: rows).write(to: url) + return url + } + + private func signOffRows(count: Int) -> [[String: Any]] { + (0.. FocusSnapshot { + let input = CotabbyTestFixtures.focusedInputSnapshot( + bundleIdentifier: app, elementIdentifier: element, precedingText: text, isSecure: isSecure + ) + return FocusSnapshot(applicationName: "Mail", bundleIdentifier: app, capability: .supported, context: input) + } + + func test_recordingCapturesAFieldOnceFocusMovesOn() { + let store = makeStore() + store.setRecording(true) + + store.observe(focus("Hi Arnaud, the Imperum POC")) { true } + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.")) { true } + XCTAssertEqual(store.recordCount, 0, "The field being typed in is not committed yet") + + store.observe(focus("", element: "other")) { true } + XCTAssertEqual(store.recordCount, 1) + } + + func test_recordingSkipsSecureFieldsExcludedAppsAndDisallowedContexts() { + let store = makeStore() + store.setRecording(true) + store.setExcluded("net.whatsapp.WhatsApp", excluded: true) + + store.observe(focus("correct horse battery staple password", isSecure: true)) { true } + store.observe(focus("", element: "x")) { true } + store.observe(focus("a long private chat message in WhatsApp", app: "net.whatsapp.WhatsApp")) { true } + store.observe(focus("", element: "y")) { true } + store.observe(focus("text typed while Cotabby is paused for now")) { false } + store.observe(focus("", element: "z")) { true } + + XCTAssertEqual(store.recordCount, 0) + } + + func test_recordingOffRecordsNothing() { + let store = makeStore() + + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.")) { true } + store.observe(focus("", element: "other")) { true } + + XCTAssertEqual(store.recordCount, 0) + } + + func test_aFieldThatBlinksUnsupportedResumesTheSameRecord() { + let store = makeStore() + store.setRecording(true) + let unsupported = FocusSnapshot(applicationName: "Mail", bundleIdentifier: "com.apple.mail", capability: .unsupported("blip"), context: nil) + + store.observe(focus("Hi Arnaud, the Imperum POC is ready")) { true } + store.observe(unsupported) { true } + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.")) { true } + store.observe(focus("", element: "other")) { true } + + XCTAssertEqual(store.recordCount, 1) + } + + func test_deleteAllRemovesRecordsAndTheFile() async throws { + let vault = makeVault() + let store = makeStore(vault: vault) + await store.importCotypistExport(from: try writeExport(signOffRows(count: 3))) + store.flush() + XCTAssertTrue(FileManager.default.fileExists(atPath: vault.fileURL.path)) + + store.deleteAll() + + XCTAssertEqual(store.recordCount, 0) + XCTAssertFalse(FileManager.default.fileExists(atPath: vault.fileURL.path)) + } +} diff --git a/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift b/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift index a5677310..20dcbcbd 100644 --- a/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift +++ b/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift @@ -114,6 +114,18 @@ final class SuggestionEngineRouterRoutingTests: XCTestCase { XCTAssertEqual(rig.metrics.entries.first?.latencyMs, 20) } + func test_endpointNeverReceivesARequestCarryingTypingHistory() async throws { + let rig = makeRig(engine: .openAICompatible) + + let result = try await rig.router.generateSuggestion( + for: CotabbyTestFixtures.suggestionRequest(historyExamples: ["My earlier sentence."]) + ) + + XCTAssertTrue(rig.endpoint.requests.isEmpty) + XCTAssertEqual(result.text, "") + XCTAssertEqual(result.suppressionReason, "historyWithheldFromEndpoint") + } + func test_llamaSelection_routesToLlamaEngineAndRecordsTheModelName() async throws { let rig = makeRig(engine: .llamaOpenSource) diff --git a/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift b/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift new file mode 100644 index 00000000..f7cf56fb --- /dev/null +++ b/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift @@ -0,0 +1,116 @@ +import XCTest +@testable import Cotabby + +@MainActor +final class TypingHistoryPhraseEngineTests: XCTestCase { + private static var retained: [AnyObject] = [] + + private final class FixedHistory: SuggestionHistoryProviding { + var phrase: String? + var engines: [SuggestionEngineKind] = [] + + func historyExamples(for context: FocusedInputContext, engine: SuggestionEngineKind) -> [String] { [] } + func phraseContinuation(for request: SuggestionRequest, engine: SuggestionEngineKind) -> String? { + engines.append(engine) + return phrase + } + } + + private final class CountingEngine: SuggestionGenerating { + private(set) var calls = 0 + func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult { + calls += 1 + return SuggestionResult(generation: request.generation, rawText: " model", text: " model", latency: 0.1) + } + func resetCachedGenerationContext() async {} + } + + private func makeEngine(phrase: String?, engine: SuggestionEngineKind = .appleIntelligence) + -> (TypingHistoryPhraseEngine, CountingEngine, FixedHistory) { + let base = CountingEngine() + let history = FixedHistory() + history.phrase = phrase + let wrapper = TypingHistoryPhraseEngine(wrapping: base, history: history, engineKind: { engine }) + Self.retained.append(contentsOf: [base, history, wrapper] as [AnyObject]) + return (wrapper, base, history) + } + + func test_confidentPhraseAnswersWithoutCallingTheModel() async throws { + let (engine, base, _) = makeEngine(phrase: " Senad") + + let result = try await engine.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(result.text, " Senad") + XCTAssertTrue(result.spacingIsExact) + XCTAssertEqual(base.calls, 0) + } + + func test_noPhraseFallsThroughToTheModel() async throws { + let (engine, base, _) = makeEngine(phrase: nil) + + let result = try await engine.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(result.text, " model") + XCTAssertEqual(base.calls, 1) + } + + func test_liveEngineKindIsPassedToHistory() async throws { + let (engine, _, history) = makeEngine(phrase: nil, engine: .openAICompatible) + + _ = try await engine.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(history.engines, [.openAICompatible]) + } +} + +final class TypingHistoryPromptTests: XCTestCase { + func test_basePromptQuotesExamplesBeforeTheCaretText() { + let prompt = BaseCompletionPromptRenderer.prompt( + prefixText: "The POC is", + applicationName: "Mail", + userName: nil, + historyExamples: ["The Imperum POC starts Monday."] + ) + + XCTAssertTrue(prompt.contains("Earlier writing by the same author:\n“The Imperum POC starts Monday.”")) + XCTAssertTrue(prompt.hasSuffix("The POC is"), "The caret text must stay last") + } + + func test_basePromptWithoutExamplesIsUnchanged() { + XCTAssertEqual( + BaseCompletionPromptRenderer.prompt(prefixText: "The POC is", applicationName: "Mail", userName: nil), + BaseCompletionPromptRenderer.prompt( + prefixText: "The POC is", applicationName: "Mail", userName: nil, historyExamples: [] + ) + ) + } + + func test_foundationPromptCarriesExamples() { + let request = CotabbyTestFixtures.suggestionRequest(historyExamples: ["The Imperum POC starts Monday."]) + + let prompt = FoundationModelPromptRenderer.prompt(for: request) + + XCTAssertTrue(prompt.contains("\"The Imperum POC starts Monday.\"")) + XCTAssertFalse(FoundationModelPromptRenderer.sessionInstructions(for: request).contains("Imperum"), + "Examples stay out of the cached instructions") + } + + func test_factoryDropsExamplesForTheEndpointEngine() { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "The POC is") + let examples = ["The Imperum POC starts Monday."] + + let endpoint = SuggestionRequestFactory.buildRequest( + context: context, settings: CotabbyTestFixtures.settingsSnapshot(selectedEngine: .openAICompatible), + configuration: .standard, historyExamples: examples + ).request + let local = SuggestionRequestFactory.buildRequest( + context: context, settings: CotabbyTestFixtures.settingsSnapshot(selectedEngine: .llamaOpenSource), + configuration: .standard, historyExamples: examples + ).request + + XCTAssertEqual(endpoint.historyExamples, []) + XCTAssertFalse(endpoint.prompt.contains("Imperum")) + XCTAssertEqual(local.historyExamples, examples) + XCTAssertTrue(local.prompt.contains("Imperum")) + } +} diff --git a/CotabbyTests/Support/History/CotypistExportImporterTests.swift b/CotabbyTests/Support/History/CotypistExportImporterTests.swift new file mode 100644 index 00000000..4923bcd7 --- /dev/null +++ b/CotabbyTests/Support/History/CotypistExportImporterTests.swift @@ -0,0 +1,50 @@ +@testable import Cotabby +import XCTest + +final class CotypistExportImporterTests: XCTestCase { + private func export(_ rows: [[String: Any]]) throws -> Data { + try JSONSerialization.data(withJSONObject: rows) + } + + func test_keepsOnlyTheMostCompleteSnapshotOfEachField() throws { + let data = try export([ + ["createdAt": "2026-06-05 16:23:53.028", "updatedAt": "2026-06-05 16:23:53.028", + "appBundleIdentifier": "com.microsoft.Outlook", "textUpToCursor": "Hi Arnaud, the POC is read", "domain": "-"], + ["createdAt": "2026-06-05 16:24:10.000", "updatedAt": "2026-06-05 16:24:10.000", + "appBundleIdentifier": "com.microsoft.Outlook", "textUpToCursor": "Hi Arnaud, the POC is ready for review.", + "textAfterCursor": " Kind regards, Senad", "domain": "-"], + ["createdAt": "2026-06-06 09:00:00.000", "appBundleIdentifier": "com.google.Chrome", + "textUpToCursor": "Can you summarize this incident report for me please?", "domain": "claude.ai"] + ]) + + let records = try CotypistExportImporter.records(fromExport: data) + + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records[0].text, "Hi Arnaud, the POC is ready for review. Kind regards, Senad") + XCTAssertNil(records[0].domain, "Cotypist's \"-\" means no site") + XCTAssertEqual(records[1].domain, "claude.ai") + XCTAssertTrue(records.allSatisfy { $0.source == .imported }) + } + + func test_fragmentsAreDropped() throws { + let data = try export([["appBundleIdentifier": "net.whatsapp.WhatsApp", "textUpToCursor": "ok"]]) + XCTAssertEqual(try CotypistExportImporter.records(fromExport: data), []) + } + + func test_secretsAreScrubbedOnImport() throws { + let data = try export([[ + "appBundleIdentifier": "com.googlecode.iterm2", + "textUpToCursor": "export OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwx1234 && run the build" + ]]) + + let text = try CotypistExportImporter.records(fromExport: data).first?.text + + XCTAssertEqual(text?.contains("sk-proj"), false) + XCTAssertEqual(text?.contains("run the build"), true) + } + + func test_otherJSONIsRejected() { + XCTAssertThrowsError(try CotypistExportImporter.records(fromExport: Data("{\"a\":1}".utf8))) + XCTAssertThrowsError(try CotypistExportImporter.records(fromExport: Data("[{\"name\":\"x\"}]".utf8))) + } +} diff --git a/CotabbyTests/Support/History/TypingHistoryIndexTests.swift b/CotabbyTests/Support/History/TypingHistoryIndexTests.swift new file mode 100644 index 00000000..fbaa7040 --- /dev/null +++ b/CotabbyTests/Support/History/TypingHistoryIndexTests.swift @@ -0,0 +1,75 @@ +@testable import Cotabby +import XCTest + +final class TypingHistoryIndexTests: XCTestCase { + private func record(_ text: String, app: String = "com.microsoft.Outlook", domain: String? = nil) -> TypingHistoryRecord { + TypingHistoryRecord( + id: UUID(), bundleIdentifier: app, domain: domain, + createdAt: Date(), updatedAt: Date(), text: text, source: .imported + ) + } + + private func query(_ text: String, app: String = "com.microsoft.Outlook", domain: String? = nil, field: String = "") -> TypingHistoryQuery { + TypingHistoryQuery(text: text, bundleIdentifier: app, domain: domain, currentFieldText: field) + } + + func test_returnsThePassageThatSharesTheMostInformativeWords() { + let index = TypingHistoryIndex(records: [ + record("The Imperum POC for the SOC team starts Monday. We will connect Microsoft Sentinel first."), + record("Dinner tonight at eight? I can book the Italian place near the station if you like."), + record("Our quarterly report covers revenue, hiring and the office move planned for spring.") + ]) + + let examples = index.examples(for: query("schedule the Imperum POC with Sentinel for the SOC")) + + XCTAssertEqual(examples.first?.contains("Sentinel"), true) + XCTAssertFalse(examples.contains { $0.contains("Dinner") }) + } + + func test_weakOverlapReturnsNothing() { + let index = TypingHistoryIndex(records: [ + record("The Imperum POC for the SOC team starts Monday. We will connect Microsoft Sentinel first.") + ]) + + XCTAssertEqual(index.examples(for: query("the team")), []) + } + + func test_anOlderSnapshotOfTheSameFieldIsSkipped() { + let draft = "Hi Arnaud, the Imperum POC with Sentinel is ready for the SOC team to review this week." + let index = TypingHistoryIndex(records: [record(draft)]) + + XCTAssertEqual(index.examples(for: query("Imperum POC Sentinel SOC review", field: draft + " Let me")), []) + } + + func test_passageIsBoundedAndKeepsWholeSentences() { + let long = "Short opener here. " + String(repeating: "Filler words about nothing in particular. ", count: 20) + + "The Imperum POC uses Sentinel connectors for SOC alerts. " + String(repeating: "More filler text follows. ", count: 20) + let index = TypingHistoryIndex(records: [record(long)]) + + let passage = index.examples(for: query("Imperum POC Sentinel connectors SOC alerts"), maxCharacters: 120).first + + XCTAssertNotNil(passage) + XCTAssertLessThanOrEqual(passage?.count ?? .max, 120) + XCTAssertEqual(passage?.contains("The Imperum POC uses Sentinel connectors for SOC alerts."), true) + } + + func test_sameSiteWinsATie() { + let index = TypingHistoryIndex(records: [ + record("Pipeline status for the Imperum connector build is green again today.", app: "com.google.Chrome", domain: "github.com"), + record("Pipeline status for the Imperum connector build is green again today!", app: "com.google.Chrome", domain: "claude.ai") + ]) + + let examples = index.examples(for: query("Imperum connector pipeline build status", app: "com.google.Chrome", domain: "claude.ai"), limit: 1) + + XCTAssertEqual(examples, ["Pipeline status for the Imperum connector build is green again today!"]) + } + + func test_stableTextOnlyChangesOnWholeWordBlocks() { + XCTAssertEqual(TypingHistoryQuery.stableText(from: "one two three", wordsPerBlock: 4), "") + XCTAssertEqual(TypingHistoryQuery.stableText(from: "a b c d e f", wordsPerBlock: 4), "a b c d") + XCTAssertEqual( + TypingHistoryQuery.stableText(from: "a b c d e f g", wordsPerBlock: 4), + TypingHistoryQuery.stableText(from: "a b c d e f", wordsPerBlock: 4) + ) + } +} diff --git a/CotabbyTests/Support/History/TypingHistoryPhrasePredictorTests.swift b/CotabbyTests/Support/History/TypingHistoryPhrasePredictorTests.swift new file mode 100644 index 00000000..59ced3c9 --- /dev/null +++ b/CotabbyTests/Support/History/TypingHistoryPhrasePredictorTests.swift @@ -0,0 +1,121 @@ +@testable import Cotabby +import XCTest + +final class TypingHistoryPhrasePredictorTests: XCTestCase { + private let limits = TypingHistoryPhrasePredictor.Limits(maxWords: 12, allowsNewlines: false) + + private func record(_ text: String) -> TypingHistoryRecord { + TypingHistoryRecord( + id: UUID(), bundleIdentifier: "com.apple.mail", domain: nil, + createdAt: Date(), updatedAt: Date(), text: text, source: .imported + ) + } + + private func predictor(_ texts: [String]) -> TypingHistoryPhrasePredictor { + TypingHistoryPhrasePredictor(records: texts.map(record)) + } + + func test_repeatedPhraseIsCompletedAfterAWordBoundary() { + let history = Array(repeating: "Thanks again, please let me know if you have any questions.", count: 4) + + XCTAssertEqual( + predictor(history).continuation(after: "Sure, please let me know ", limits: limits), + "if you have any questions." + ) + } + + func test_partialWordIsFinishedWithOnlyTheUntypedLetters() { + let history = Array(repeating: "Thanks again, please let me know if you have any questions.", count: 4) + + XCTAssertEqual( + predictor(history).continuation(after: "Sure, please let me know if you ha", limits: limits), + "ve any questions." + ) + } + + func test_fullyTypedWordWithoutSpaceGetsALeadingSpace() { + let history = Array(repeating: "Thanks again, please let me know if you have any questions.", count: 4) + + XCTAssertEqual( + predictor(history).continuation(after: "Sure, please let me know if", limits: limits), + " you have any questions." + ) + } + + func test_ambiguousContinuationIsNotOffered() { + let history = [ + "please let me know if it works", + "please let me know when it ships", + "please let me know what you think", + "please let me know how it goes" + ] + + XCTAssertNil(predictor(history).continuation(after: "please let me know ", limits: limits)) + } + + func test_tooFewOccurrencesAreNotOffered() { + let history = Array(repeating: "please let me know if you have any questions.", count: 2) + + XCTAssertNil(predictor(history).continuation(after: "please let me know ", limits: limits)) + } + + func test_singleWordShortcutNeedsStrongerEvidence() { + let three = Array(repeating: "Thanks for the update.\nBest regards, Senad\nImperum", count: 3) + let six = Array(repeating: "Thanks for the update.\nBest regards, Senad\nImperum", count: 6) + + XCTAssertNil(predictor(three).continuation(after: "Thanks!\nBest regards, ", limits: limits)) + XCTAssertEqual(predictor(six).continuation(after: "Thanks!\nBest regards, ", limits: limits), "Senad") + } + + func test_newlineContinuesOnlyWhenMultiLineIsAllowed() { + let history = Array(repeating: "Thanks for your time.\nKind regards,\nSenad Aruc\nImperum B.V.", count: 6) + let multiLine = TypingHistoryPhrasePredictor.Limits(maxWords: 12, allowsNewlines: true) + + XCTAssertNil(predictor(history).continuation(after: "Thanks for your time.\nKind regards,", limits: limits)) + XCTAssertEqual( + predictor(history).continuation(after: "Thanks for your time.\nKind regards,", limits: multiLine), + "\nSenad Aruc\nImperum B.V." + ) + } + + func test_outputUsesTheUsersSpellingAndRespectsTheWordLimit() { + let history = Array(repeating: "we will start the imperum POC with the SOC team next Monday morning", count: 4) + let twoWords = TypingHistoryPhrasePredictor.Limits(maxWords: 2, allowsNewlines: false) + + XCTAssertEqual(predictor(history).continuation(after: "Then we will start the ", limits: twoWords), "imperum POC") + } + + func test_twoWordFallbackAnswersWhenTheThirdWordIsNew() { + // "Kind regards," follows many different sentences, so the three-word context before it is + // rarely the same; the two-word fallback still knows what comes next. + let history = (0..<6).map { "Topic number \($0) is done.\nKind regards,\nSenad" } + let multiLine = TypingHistoryPhrasePredictor.Limits(maxWords: 12, allowsNewlines: true) + + XCTAssertEqual( + predictor(history).continuation(after: "Something never seen before.\nKind regards,", limits: multiLine), + "\nSenad" + ) + } + + func test_onlyTextTypedBeforeTheCaretIsLearned() { + // The quoted thread after the caret is someone else's writing; their name must not become + // the user's sign-off. + let records = (0..<6).map { _ -> TypingHistoryRecord in + let typed = "Thanks for the update.\nBest regards, Senad" + let quoted = "\n\nOn Monday Luuk wrote:\nThanks for the update.\nBest regards, Luuk" + var record = record(typed + quoted) + record.typedLength = typed.count + return record + } + + XCTAssertEqual( + TypingHistoryPhrasePredictor(records: records).continuation(after: "Done.\nBest regards, ", limits: limits), + "Senad" + ) + } + + func test_unknownContextReturnsNil() { + XCTAssertNil(predictor(["hello there general kenobi"]).continuation(after: "completely new words ", limits: limits)) + XCTAssertNil(predictor([]).continuation(after: "", limits: limits)) + } +} diff --git a/CotabbyTests/Support/History/TypingHistoryScrubberTests.swift b/CotabbyTests/Support/History/TypingHistoryScrubberTests.swift new file mode 100644 index 00000000..4bdf94c1 --- /dev/null +++ b/CotabbyTests/Support/History/TypingHistoryScrubberTests.swift @@ -0,0 +1,38 @@ +@testable import Cotabby +import XCTest + +final class TypingHistoryScrubberTests: XCTestCase { + func test_proseAndShortNumbersPassThrough() { + let text = "Hi Arnaud, the POC starts on 12 October at 10:30. Kind regards, Senad" + XCTAssertEqual(TypingHistoryScrubber.scrub(text), text) + } + + func test_longAllLetterWordsAreKept() { + let turkish = "Çekoslovakyalılaştıramadıklarımızdanmışsınız diye yazdım." + XCTAssertEqual(TypingHistoryScrubber.scrub(turkish), turkish) + } + + func test_credentialsAndTokensAreRedacted() { + let text = "key sk-ant-api03-abcdefghijklmnop1234 and ghp_abcdefghijklmnopqrstuvwxyz123456 " + + "plus token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123" + let scrubbed = TypingHistoryScrubber.scrub(text) + + XCTAssertFalse(scrubbed.contains("sk-ant")) + XCTAssertFalse(scrubbed.contains("ghp_")) + XCTAssertFalse(scrubbed.contains("eyJhbGci")) + XCTAssertTrue(scrubbed.hasPrefix("key [redacted] and [redacted]")) + } + + func test_privateKeyBlocksAreRedacted() { + let text = "here:\n-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk\n-----END OPENSSH PRIVATE KEY-----\nthanks" + XCTAssertEqual(TypingHistoryScrubber.scrub(text), "here:\n[redacted]\nthanks") + } + + func test_longRecordsKeepTheirTail() { + let text = String(repeating: "word ", count: 5_000) + "the end" + let scrubbed = TypingHistoryScrubber.scrub(text) + + XCTAssertEqual(scrubbed.count, TypingHistoryScrubber.maximumRecordCharacters) + XCTAssertTrue(scrubbed.hasSuffix("the end")) + } +} diff --git a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift index 25dd9819..105cf90c 100644 --- a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift +++ b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift @@ -126,6 +126,7 @@ enum CotabbyTestFixtures { languageInstruction: String? = nil, clipboardContext: String? = nil, visualContextSummary: String? = nil, + historyExamples: [String] = [], isMultiLineEnabled: Bool = false ) -> SuggestionRequest { let resolvedPrecedingText = precedingText ?? prefixText @@ -155,6 +156,7 @@ enum CotabbyTestFixtures { languageInstruction: languageInstruction, clipboardContext: clipboardContext, visualContextSummary: visualContextSummary, + historyExamples: historyExamples, isMultiLineEnabled: isMultiLineEnabled ) } diff --git a/SOURCE_LAYOUT.md b/SOURCE_LAYOUT.md index 5ff2b576..3c6778a3 100644 --- a/SOURCE_LAYOUT.md +++ b/SOURCE_LAYOUT.md @@ -35,6 +35,7 @@ Cotabby/ │ ├── Context/ bounded context and visual-context values │ ├── Emoji/ picker and usage values │ ├── Focus/ focus snapshots and tracking state +│ ├── History/ typing-history records, archive, and preferences │ ├── Input/ keyboard event values │ ├── Onboarding/ onboarding templates │ ├── Permissions/ TCC permission values @@ -52,6 +53,7 @@ Cotabby/ │ │ ├── Caching/ field-scoped focus caches │ │ ├── Chromium/ Chromium AX enablement and diagnostics │ │ └── Resolution/ focus snapshots, geometry, bounded AX walks +│ ├── History/ typing-history store, recording, and encrypted vault │ ├── Input/ event taps and input-source monitoring │ ├── ModelManagement/ model discovery, download, and validation │ ├── Permission/ @@ -77,6 +79,7 @@ Cotabby/ │ ├── Focus/ │ │ ├── Applications/ app, browser, domain, and terminal classification │ │ └── Capability/ supported-field capability resolution +│ ├── History/ history retrieval, phrase prediction, scrubbing, import │ ├── Input/ composition, key-label, and selection helpers │ ├── Logging/ debug options, request IDs, and JSONL handlers │ ├── Macros/ From 68f9faa7f9575f612d157e8f1c9e5b9f029f1c1c Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 14:51:26 +0200 Subject: [PATCH 2/2] Fix typing-history review findings: delete races, duplicates, terminals, prompt quotes - Delete All can no longer be undone by work already in flight. Writes and deletes go through TypingHistoryWriter (one lock; a delete raises a generation so saves captured earlier are skipped, and a save sequence stops an older snapshot from overwriting a newer one). A load or an import that finishes after Delete All discards its result. - Returning to a field continues its record instead of duplicating it: the field key no longer includes the focus sequence, and a returning field resumes only when its text still starts the same way, so a reused AX identifier cannot overwrite another field's record. - Terminal fields (terminal apps and integrated terminals) are never recorded, and the settings gate is evaluated only when text changed. - The history prompt section is all or nothing, so budget trimming can never leave an unclosed quote before the caret text. - Delete All is disabled while an import runs. - Cotypist's "unknown.bundle" placeholder maps to the unknown app. Co-Authored-By: Claude Opus 5.5 --- Cotabby.xcodeproj/project.pbxproj | 6 ++ .../Services/History/TypingHistoryStore.swift | 102 ++++++++++++++---- .../History/TypingHistoryWriter.swift | 44 ++++++++ .../History/CotypistExportImporter.swift | 12 ++- .../BaseCompletionPromptRenderer.swift | 31 ++++-- .../Panes/TypingHistorySectionView.swift | 2 +- .../History/TypingHistoryStoreTests.swift | 88 +++++++++++++++ .../TypingHistoryPhraseEngineTests.swift | 12 +++ 8 files changed, 265 insertions(+), 32 deletions(-) create mode 100644 Cotabby/Services/History/TypingHistoryWriter.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index c17ed32b..f10fed4d 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -227,6 +227,7 @@ 380D6D04743E5F0A8A71228E /* HostFontRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7344003A6327C5FBDA581028 /* HostFontRegistryTests.swift */; }; 3844577817DE8EA5D41E3AC0 /* Sparkle-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 70C489EC7EA50C2B103A6B09 /* Sparkle-LICENSE.txt */; }; 3870BA44775E528EEA9E2FC6 /* FocusedInputPollingSignature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1891267512941C84BEA4172 /* FocusedInputPollingSignature.swift */; }; + 38E9128D7C7D40C05DAFBE3D /* TypingHistoryWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3588A6D05E37ED8D4332CD2 /* TypingHistoryWriter.swift */; }; 39332A6824B1935AE3B0D8C0 /* fr-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6DB982BF30B3601F57277776 /* fr-100k.txt */; }; 3935AEF7D139557677D09318 /* KeycapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF96999230079A768BEFD0FC /* KeycapView.swift */; }; 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; @@ -586,6 +587,7 @@ 936F87925A22DAC58525B60F /* DownloadFileRescuer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC756DF8E0032B39099D2BF8 /* DownloadFileRescuer.swift */; }; 940E71A409B4FCDD63642471 /* EmojiVariantResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCD0F17E1E2BB8FE75A6E75E /* EmojiVariantResolverTests.swift */; }; 94131FA2B9717436CF0A70F1 /* StreamedGhostTextPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC3DF6826CD5E4EF695EF163 /* StreamedGhostTextPolicy.swift */; }; + 941CB45BCF47F4E7CA5320EA /* TypingHistoryWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3588A6D05E37ED8D4332CD2 /* TypingHistoryWriter.swift */; }; 9447C6F6A30C51877236EC01 /* InlineFeatureTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF21767F255146EA968B267D /* InlineFeatureTestDoubles.swift */; }; 945552C364C43F920138E58B /* LlamaDecodeGateDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8F0F084B3A58EDA94A32324 /* LlamaDecodeGateDefaultsTests.swift */; }; 948AD3BD25C113539DF12FD7 /* VisualContextModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A51B1559F4CE6728D39C959 /* VisualContextModels.swift */; }; @@ -1545,6 +1547,7 @@ D1891267512941C84BEA4172 /* FocusedInputPollingSignature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedInputPollingSignature.swift; sourceTree = ""; }; D1AB5EBB44EE64EB3EB1426F /* BundledRuntimeLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocator.swift; sourceTree = ""; }; D253D06D941F60EDAF112625 /* SuggestionSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelTests.swift; sourceTree = ""; }; + D3588A6D05E37ED8D4332CD2 /* TypingHistoryWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryWriter.swift; sourceTree = ""; }; D381F1752705DC80E491B025 /* ArithmeticEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluatorTests.swift; sourceTree = ""; }; D3C1C9A9BF1F74C16E4401EC /* CotabbyBrand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyBrand.swift; sourceTree = ""; }; D47E1471C110614F0D07E399 /* PermissionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionModels.swift; sourceTree = ""; }; @@ -3703,6 +3706,7 @@ children = ( 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */, ED222C2589A4787AFCA90EFD /* TypingHistoryVault.swift */, + D3588A6D05E37ED8D4332CD2 /* TypingHistoryWriter.swift */, ); path = History; sourceTree = ""; @@ -4242,6 +4246,7 @@ 620A8133C64753E17AD7CD67 /* TypingHistorySectionView.swift in Sources */, 6C75BC36C18440B54A3A35AC /* TypingHistoryStore.swift in Sources */, 73E3EFA8960FC779920935C4 /* TypingHistoryVault.swift in Sources */, + 38E9128D7C7D40C05DAFBE3D /* TypingHistoryWriter.swift in Sources */, BDF13BDA6D30BB8FF755AC32 /* TypingPredictionCandidate.swift in Sources */, C0468063222C5FEA7C1CFAB3 /* TypoCaseTransfer.swift in Sources */, A88F3C7039E8DDB71C5D6246 /* TypoGate.swift in Sources */, @@ -4578,6 +4583,7 @@ AAC2E374027B9A0E983D966E /* TypingHistorySectionView.swift in Sources */, 23F0FA44C965F97181F082D8 /* TypingHistoryStore.swift in Sources */, 2BA57F29DDD4CEB327CD805E /* TypingHistoryVault.swift in Sources */, + 941CB45BCF47F4E7CA5320EA /* TypingHistoryWriter.swift in Sources */, 733A13BF72DCBBDC37952DD9 /* TypingPredictionCandidate.swift in Sources */, CF391BC4A3C80C41048D64D1 /* TypoCaseTransfer.swift in Sources */, 48A2F371756723299597F5A1 /* TypoGate.swift in Sources */, diff --git a/Cotabby/Services/History/TypingHistoryStore.swift b/Cotabby/Services/History/TypingHistoryStore.swift index f4940938..7211525d 100644 --- a/Cotabby/Services/History/TypingHistoryStore.swift +++ b/Cotabby/Services/History/TypingHistoryStore.swift @@ -39,6 +39,14 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { static let minimumRecordedCharacters = 20 private let vault: TypingHistoryVault + /// Every write and delete goes through this so a deletion can never be undone by a save that + /// was already running (see `TypingHistoryWriter`). + private let writer: TypingHistoryWriter + /// Bumped by Delete All. Work that started before a deletion (a load, an import, a save) + /// compares its captured value and discards its result instead of restoring deleted history. + private var persistenceGeneration = 0 + /// Numbers each captured save so an older snapshot can never overwrite a newer one. + private var saveSequence = 0 private let userDefaults: UserDefaults private var records: [TypingHistoryRecord] = [] private var index: TypingHistoryIndex? @@ -46,10 +54,13 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { private var rebuildGeneration = 0 private var saveTask: Task? private var activeRecording: ActiveRecording? - /// The field most recently finished. Accessibility can briefly report a field as unsupported - /// mid-typing; when the same field comes back, recording resumes into the same record instead - /// of starting a duplicate. - private var lastFinishedRecording: ActiveRecording? + /// Recently finished fields, by field key. When the user returns to one (or Accessibility + /// briefly reported it unsupported), recording resumes into the same record instead of + /// starting a duplicate of the same text. + private var recentRecordings: [String: ActiveRecording] = [:] + private static let maximumRecentRecordings = 64 + /// How much of a field's opening must match for a returning field to count as the same document. + private static let sameDocumentOpeningLength = 40 private var exampleCache: (key: String, examples: [String])? /// The field being typed in right now. Its raw text is kept here and only scrubbed and copied @@ -74,6 +85,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { init(vault: TypingHistoryVault = .standard(), userDefaults: UserDefaults = .standard, loadsArchive: Bool = true) { self.vault = vault + self.writer = TypingHistoryWriter(vault: vault) self.userDefaults = userDefaults preferences = TypingHistoryPreferences( isUsingHistory: userDefaults.object(forKey: DefaultsKey.isUsingHistory) as? Bool @@ -115,7 +127,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { if excluded, activeRecording?.bundleIdentifier == bundleIdentifier { // Excluding an app mid-field discards that field's unsaved text instead of keeping it. activeRecording = nil - lastFinishedRecording = nil + recentRecordings = recentRecordings.filter { $0.value.bundleIdentifier != bundleIdentifier } } } @@ -123,8 +135,11 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { func loadArchive() async { let vault = vault + let generation = persistenceGeneration do { let loaded = try await Task.detached(priority: .utility) { try vault.load() }.value + // Delete All ran while the archive was decrypting: the loaded records are gone now. + guard generation == persistenceGeneration else { return } records = loaded recordCount = loaded.count status = .ready @@ -141,8 +156,9 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { guard status == .ready else { return } saveTask?.cancel() materializeActiveRecording() + saveSequence += 1 do { - try vault.save(records) + try writer.save(records, generation: persistenceGeneration, sequence: saveSequence) } catch { CotabbyLogger.app.error("Typing history could not be saved: \(error)") } @@ -155,10 +171,15 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { try? await Task.sleep(nanoseconds: 5_000_000_000) guard let self, !Task.isCancelled else { return } self.materializeActiveRecording() + self.saveSequence += 1 let snapshot = self.records - let vault = self.vault + let writer = self.writer + let generation = self.persistenceGeneration + let sequence = self.saveSequence do { - try await Task.detached(priority: .utility) { try vault.save(snapshot) }.value + try await Task.detached(priority: .utility) { + try writer.save(snapshot, generation: generation, sequence: sequence) + }.value } catch { CotabbyLogger.app.error("Typing history could not be saved: \(error)") } @@ -195,23 +216,29 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { guard status == .ready, preferences.isRecording, case .supported = snapshot.capability, let input = snapshot.context, !input.isSecure, - !preferences.excludedBundleIdentifiers.contains(input.bundleIdentifier), - isAllowed() + !Self.isTerminal(input), + !preferences.excludedBundleIdentifiers.contains(input.bundleIdentifier) else { finishActiveRecording() return } - let fieldKey = "\(input.bundleIdentifier)|\(input.processIdentifier)|\(input.elementIdentifier)|\(input.focusChangeSequence)" + // The focus sequence is left out on purpose: leaving a field and coming back starts a new + // focus session, but it is the same document and should stay one record. + let fieldKey = "\(input.bundleIdentifier)|\(input.processIdentifier)|\(input.elementIdentifier)" let text = input.precedingText + input.trailingText + // Unchanged text in the same field is the common case on a 50 ms poll; answer it before + // building the settings snapshot `isAllowed` needs. + if activeRecording?.fieldKey == fieldKey, activeRecording?.rawText == text { return } + guard isAllowed() else { + finishActiveRecording() + return + } + if activeRecording?.fieldKey != fieldKey { finishActiveRecording() - if var resumed = lastFinishedRecording, resumed.fieldKey == fieldKey { - resumed.rawText = text - resumed.rawTypedLength = input.precedingText.count - activeRecording = resumed - } else { - activeRecording = ActiveRecording( + activeRecording = resumedRecording(fieldKey: fieldKey, text: text, typedLength: input.precedingText.count) + ?? ActiveRecording( fieldKey: fieldKey, recordID: UUID(), bundleIdentifier: input.bundleIdentifier, @@ -220,21 +247,44 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { rawText: text, rawTypedLength: input.precedingText.count ) - } return } - guard activeRecording?.rawText != text else { return } activeRecording?.rawText = text activeRecording?.rawTypedLength = input.precedingText.count scheduleSave() } + /// Terminals are never recorded: their text is shell commands and output, where secrets are + /// common and nothing is the user's prose. + private static func isTerminal(_ input: FocusedInputSnapshot) -> Bool { + input.isIntegratedTerminal || AppSurfaceClassifier.classify(bundleIdentifier: input.bundleIdentifier) == .terminal + } + + /// Continues the record of a recently finished field when the user comes back to it. The text + /// must still start the same way: Accessibility element identifiers can be reused by a + /// different field, and resuming into the wrong record would overwrite its text. + private func resumedRecording(fieldKey: String, text: String, typedLength: Int) -> ActiveRecording? { + guard var recent = recentRecordings[fieldKey] else { return nil } + let opening = recent.rawText.prefix(Self.sameDocumentOpeningLength) + guard !opening.isEmpty, text.hasPrefix(opening) || recent.rawText.hasPrefix(text.prefix(Self.sameDocumentOpeningLength)) + else { return nil } + recent.rawText = text + recent.rawTypedLength = typedLength + return recent + } + /// Commits the active field to `records` and makes it searchable. Called when focus moves to /// another field, recording stops, or the app is no longer eligible. private func finishActiveRecording() { guard activeRecording != nil else { return } let changed = materializeActiveRecording() - lastFinishedRecording = activeRecording + if let finished = activeRecording { + recentRecordings[finished.fieldKey] = finished + if recentRecordings.count > Self.maximumRecentRecordings, let oldest = recentRecordings.values + .min(by: { $0.createdAt < $1.createdAt }) { + recentRecordings[oldest.fieldKey] = nil + } + } activeRecording = nil if changed { scheduleSave() @@ -293,10 +343,16 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { guard status == .ready, !isImporting else { return } isImporting = true defer { isImporting = false } + let generation = persistenceGeneration do { let imported = try await Task.detached(priority: .userInitiated) { try CotypistExportImporter.records(fromExport: Data(contentsOf: url)) }.value + // Delete All ran while the file was being read; adding the import now would partly undo it. + guard generation == persistenceGeneration else { + lastImportMessage = "Import cancelled because typing history was deleted." + return + } let knownTexts = Set(records.map(\.text)) let fresh = imported.filter { !knownTexts.contains($0.text) } records.append(contentsOf: fresh) @@ -314,8 +370,9 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { /// Removes every record, the encrypted file, and its Keychain key. func deleteAll() { saveTask?.cancel() + persistenceGeneration += 1 activeRecording = nil - lastFinishedRecording = nil + recentRecordings = [:] records = [] recordCount = 0 index = nil @@ -324,7 +381,8 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { rebuildGeneration += 1 lastImportMessage = nil do { - try vault.destroy() + // Waits for any save already writing, then deletes; saves captured earlier are dropped. + try writer.destroy(generation: persistenceGeneration) status = .ready } catch { CotabbyLogger.app.error("Typing history could not be deleted: \(error)") diff --git a/Cotabby/Services/History/TypingHistoryWriter.swift b/Cotabby/Services/History/TypingHistoryWriter.swift new file mode 100644 index 00000000..258fa46f --- /dev/null +++ b/Cotabby/Services/History/TypingHistoryWriter.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Serializes every write and delete of the typing-history archive, and drops writes that a +/// deletion has made stale. +/// +/// Why this exists: saves run on background tasks with a snapshot of the records. Cancelling the +/// task that awaits a save does not stop a save that has already started, so without this a save +/// begun just before Delete All could finish afterwards, create a fresh Keychain key, and write the +/// deleted history back. Here, every operation takes one lock, so a delete waits for an in-flight +/// save, and each delete raises `minimumGeneration` so any save captured before it is skipped. +/// +/// It is a lock rather than an actor because the termination flush must write synchronously on +/// the main thread; both paths share the same ordering rules. +nonisolated final class TypingHistoryWriter: @unchecked Sendable { + private let vault: TypingHistoryVault + private let lock = NSLock() + /// Writes captured at an older generation than this are stale (a deletion happened since). + private var minimumGeneration = 0 + /// Writes are numbered as they are captured, so an older snapshot finishing late cannot + /// overwrite a newer one already on disk. + private var lastWrittenSequence = 0 + + init(vault: TypingHistoryVault) { + self.vault = vault + } + + /// Writes `records` unless a deletion or a newer write has made them stale. + func save(_ records: [TypingHistoryRecord], generation: Int, sequence: Int) throws { + try lock.withLock { + guard generation >= minimumGeneration, sequence > lastWrittenSequence else { return } + try vault.save(records) + lastWrittenSequence = sequence + } + } + + /// Deletes the archive and its key, after any write already in progress, and marks every write + /// captured before `generation` as stale. + func destroy(generation: Int) throws { + try lock.withLock { + minimumGeneration = max(minimumGeneration, generation) + try vault.destroy() + } + } +} diff --git a/Cotabby/Support/History/CotypistExportImporter.swift b/Cotabby/Support/History/CotypistExportImporter.swift index 67d44c94..934dc9f5 100644 --- a/Cotabby/Support/History/CotypistExportImporter.swift +++ b/Cotabby/Support/History/CotypistExportImporter.swift @@ -47,7 +47,7 @@ nonisolated enum CotypistExportImporter { before: row.textUpToCursor ?? "", after: row.textAfterCursor ?? "" ) guard text.trimmingCharacters(in: .whitespacesAndNewlines).count >= minimumCharacters else { continue } - let bundleIdentifier = row.appBundleIdentifier ?? "unknown" + let bundleIdentifier = normalizedBundleIdentifier(row.appBundleIdentifier) let createdAt = parseDate(row.createdAt) ?? Date(timeIntervalSince1970: 0) let record = TypingHistoryRecord( id: UUID(), @@ -82,6 +82,16 @@ nonisolated enum CotypistExportImporter { return kept } + /// Placeholder for rows Cotypist could not attribute to an app ("unknown.bundle" in its export). + static let unknownBundleIdentifier = "unknown" + + private static func normalizedBundleIdentifier(_ identifier: String?) -> String { + guard let identifier = identifier?.trimmingCharacters(in: .whitespaces), !identifier.isEmpty, + identifier != "unknown.bundle", identifier != unknownBundleIdentifier + else { return unknownBundleIdentifier } + return identifier + } + /// Cotypist uses "-" for fields with no site; treat that like no domain at all. private static func normalizedDomain(_ domain: String?) -> String? { guard let domain = domain?.trimmingCharacters(in: .whitespaces), !domain.isEmpty, domain != "-" else { diff --git a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift index 3b63faa1..2e2f1471 100644 --- a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift +++ b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift @@ -149,18 +149,33 @@ enum BaseCompletionPromptRenderer { /// writes. Sits after the stable preface and before the per-keystroke clipboard and screen /// sections: the examples change only every few words (`TypingHistoryQuery.stableText`), so /// placing them earlier keeps more of the prompt's head reusable from the KV cache. + /// + /// All or nothing, like the "following" section: a budget-trimmed history section could end + /// on an unclosed quote, and the model would then read the live caret text as part of that + /// quote. Examples are dropped whole until the section fits its cap. private static func historySection(_ examples: [String]) -> PromptSection? { - let quoted = examples - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .map { "“\($0)”" } - guard !quoted.isEmpty else { return nil } - return contextSection( - "history", "Earlier writing by the same author:\n" + quoted.joined(separator: "\n"), - priority: 38, maxChars: 760 + let heading = "Earlier writing by the same author:" + var content = heading + for example in examples { + let trimmed = example.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let line = "\n“\(trimmed)”" + guard content.count + line.count <= historyMaxCharacters else { continue } + content += line + } + guard content.count > heading.count else { return nil } + return PromptSection( + name: "history", + content: content, + priority: 38, + minChars: content.count, + maxChars: content.count, + truncation: .preserveStart ) } + private static let historyMaxCharacters = 760 + private static func contextSection( _ name: String, _ content: String, diff --git a/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift index d51db69f..319b2fbc 100644 --- a/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift +++ b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift @@ -49,7 +49,7 @@ struct TypingHistorySectionView: View { Button("Import Cotypist Export…") { chooseExportToImport() } .disabled(store.status != .ready || store.isImporting) Button("Delete All…", role: .destructive) { isConfirmingDeleteAll = true } - .disabled(store.recordCount == 0) + .disabled(store.recordCount == 0 || store.isImporting) } } label: { VStack(alignment: .leading, spacing: 2) { diff --git a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift index 60e104e5..be008fda 100644 --- a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift +++ b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift @@ -241,4 +241,92 @@ final class TypingHistoryStoreTests: XCTestCase { XCTAssertEqual(store.recordCount, 0) XCTAssertFalse(FileManager.default.fileExists(atPath: vault.fileURL.path)) } + + // MARK: - Review fixes + + func test_aSaveCapturedBeforeDeleteAllIsNeverWritten() throws { + let vault = makeVault() + let writer = TypingHistoryWriter(vault: vault) + let record = TypingHistoryRecord( + id: UUID(), bundleIdentifier: "com.apple.mail", domain: nil, createdAt: Date(), updatedAt: Date(), + text: "Deleted history must stay deleted.", source: .recorded + ) + + try writer.destroy(generation: 1) + try writer.save([record], generation: 0, sequence: 1) + + XCTAssertFalse(FileManager.default.fileExists(atPath: vault.fileURL.path)) + } + + func test_anOlderSnapshotCannotOverwriteANewerOne() throws { + let vault = makeVault() + let writer = TypingHistoryWriter(vault: vault) + func record(_ text: String) -> TypingHistoryRecord { + TypingHistoryRecord(id: UUID(), bundleIdentifier: "a", domain: nil, createdAt: Date(), updatedAt: Date(), + text: text, source: .recorded) + } + + try writer.save([record("newer snapshot text")], generation: 0, sequence: 2) + try writer.save([record("older snapshot text")], generation: 0, sequence: 1) + + XCTAssertEqual(try vault.load().map(\.text), ["newer snapshot text"]) + } + + private func focus(_ text: String, element: String, sequence: UInt64, app: String = "com.apple.mail", + isIntegratedTerminal: Bool = false) -> FocusSnapshot { + let input = CotabbyTestFixtures.focusedInputSnapshot( + bundleIdentifier: app, elementIdentifier: element, precedingText: text, + isIntegratedTerminal: isIntegratedTerminal, focusChangeSequence: sequence + ) + return FocusSnapshot(applicationName: "Mail", bundleIdentifier: app, capability: .supported, context: input) + } + + func test_returningToAFieldContinuesItsRecord() { + let store = makeStore() + store.setRecording(true) + + store.observe(focus("Hi Arnaud, the Imperum POC is ready", element: "body", sequence: 1)) { true } + store.observe(focus("", element: "search", sequence: 2)) { true } + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.", element: "body", sequence: 3)) { true } + store.observe(focus("", element: "search", sequence: 4)) { true } + + XCTAssertEqual(store.recordCount, 1) + } + + func test_aReusedElementWithDifferentTextStartsANewRecord() { + let store = makeStore() + store.setRecording(true) + + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.", element: "body", sequence: 1)) { true } + store.observe(focus("", element: "other", sequence: 2)) { true } + store.observe(focus("A completely different message about lunch plans today.", element: "body", sequence: 3)) { true } + store.observe(focus("", element: "other", sequence: 4)) { true } + + XCTAssertEqual(store.recordCount, 2) + } + + func test_terminalsAreNeverRecorded() { + let store = makeStore() + store.setRecording(true) + + store.observe(focus("export OPENAI_API_KEY and run the deploy script", element: "t", sequence: 1, + app: "com.googlecode.iterm2")) { true } + store.observe(focus("git push origin main and then open the pull request", element: "vs", sequence: 2, + app: "com.microsoft.VSCode", isIntegratedTerminal: true)) { true } + store.observe(focus("", element: "x", sequence: 3)) { true } + + XCTAssertEqual(store.recordCount, 0) + } + + func test_unchangedTextDoesNotEvaluateTheSettingsGate() { + let store = makeStore() + store.setRecording(true) + var gateCalls = 0 + + store.observe(focus("Hi Arnaud, the Imperum POC is ready", element: "body", sequence: 1)) { gateCalls += 1; return true } + store.observe(focus("Hi Arnaud, the Imperum POC is ready", element: "body", sequence: 1)) { gateCalls += 1; return true } + + XCTAssertEqual(gateCalls, 1) + } + } diff --git a/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift b/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift index f7cf56fb..8d91f575 100644 --- a/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift +++ b/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift @@ -76,6 +76,18 @@ final class TypingHistoryPromptTests: XCTestCase { XCTAssertTrue(prompt.hasSuffix("The POC is"), "The caret text must stay last") } + func test_historySectionIsDroppedWholeRatherThanCutMidQuote() { + let prompt = BaseCompletionPromptRenderer.prompt( + prefixText: String(repeating: "word ", count: 30), + applicationName: "Mail", + userName: nil, + historyExamples: [String(repeating: "earlier ", count: 40)], + contextBudget: 200 + ) + + XCTAssertFalse(prompt.contains("“"), "A partly kept history section would leave an unclosed quote") + } + func test_basePromptWithoutExamplesIsUnchanged() { XCTAssertEqual( BaseCompletionPromptRenderer.prompt(prefixText: "The POC is", applicationName: "Mail", userName: nil),