From a8dfda625bfce885cb483c1c30e9a099426349ef Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 12:33:38 +0200 Subject: [PATCH 1/8] 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/8] 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), From 7ba5cadcbf14ba853864d43f9a949194849c56f2 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:03:43 -0400 Subject: [PATCH 3/8] Fix SwiftLint violations in typing history CI's `swiftlint --strict` failed on four violations: - TypingHistoryRecord.typedLength: drop the redundant `= nil` (implicit_optional_initialization); the memberwise init still defaults it. - TypingHistoryPhrasePredictor.init (complexity 13): the three-word and two-word tables now share one generic two-pass counter. - TypingHistoryPhrasePredictor.continuation (complexity 14): context-id lookup and the three-word/two-word table choice move into helpers. - BaseCompletionPromptRenderer.prompt (complexity 11 after the history section): the character/token budget choice moves into `allocate`. All behavior-preserving; the predictor and prompt tests pass unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Models/History/TypingHistoryModels.swift | 2 +- .../TypingHistoryPhrasePredictor.swift | 133 ++++++++++-------- .../BaseCompletionPromptRenderer.swift | 24 ++-- 3 files changed, 84 insertions(+), 75 deletions(-) diff --git a/Cotabby/Models/History/TypingHistoryModels.swift b/Cotabby/Models/History/TypingHistoryModels.swift index e8a0264a..1ca44e81 100644 --- a/Cotabby/Models/History/TypingHistoryModels.swift +++ b/Cotabby/Models/History/TypingHistoryModels.swift @@ -36,7 +36,7 @@ nonisolated struct TypingHistoryRecord: Codable, Equatable, Sendable, Identifiab /// 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 + var typedLength: Int? /// The part of `text` the user wrote themselves. var typedText: String { diff --git a/Cotabby/Support/History/TypingHistoryPhrasePredictor.swift b/Cotabby/Support/History/TypingHistoryPhrasePredictor.swift index 94b01710..c042fd3c 100644 --- a/Cotabby/Support/History/TypingHistoryPhrasePredictor.swift +++ b/Cotabby/Support/History/TypingHistoryPhrasePredictor.swift @@ -79,42 +79,46 @@ nonisolated struct TypingHistoryPhrasePredictor: Sendable { 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 - } + shortContinuations = Self.continuationCounts( + in: sequences, contextLength: 2, minimumCount: Self.backoffMinimumCount + ) { ids, index in + ShortContext(first: ids[index - 2], second: ids[index - 1]) } + } - var shortCounts: [ShortContext: Int32] = [:] - for ids in sequences where ids.count > 2 { - for index in 2..( + in sequences: [[Int32]], + contextLength: Int, + minimumCount: Int32, + context: ([Int32], Int) -> Key + ) -> [Key: [Int32: Int32]] { + var contextCounts: [Key: Int32] = [:] + for ids in sequences where ids.count > contextLength { + for index in contextLength.. 2 { - for index in 2..= Self.backoffMinimumCount else { continue } - shortContinuations[context, default: [:]][ids[index], default: 0] += 1 + var continuations: [Key: [Int32: Int32]] = [:] + for ids in sequences where ids.count > contextLength { + for index in contextLength..= minimumCount else { continue } + continuations[key, default: [:]][ids[index], default: 0] += 1 } } - - self.vocabulary = vocabulary - self.displayForms = displayForms - self.continuations = continuations - self.shortContinuations = shortContinuations + return continuations } /// The exact text to insert after `precedingText`, or nil when history is not confident. @@ -126,21 +130,7 @@ nonisolated struct TypingHistoryPhrasePredictor: Sendable { 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) - } - } + guard tokens.count >= 2, var ids = contextIDs(for: tokens) else { return nil } var output = "" var words = 0 @@ -148,23 +138,7 @@ nonisolated struct TypingHistoryPhrasePredictor: Sendable { 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 } + guard let (nextID, count) = confidentNextToken(after: ids, prefix: prefixFilter) else { break } let token = displayForms[Int(nextID)] if token == Self.newlineToken { @@ -192,6 +166,43 @@ nonisolated struct TypingHistoryPhrasePredictor: Sendable { return output.hasSuffix("\n") ? String(output.dropLast()) : output } + /// The ids of the three words before the caret, or nil when history cannot answer. + /// + /// 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. + private func contextIDs(for tokens: [String]) -> [Int32]? { + 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) + } + } + return ids + } + + /// The next token history is confident about (its id and count): from the three-word table + /// when it knows the context, otherwise from the stricter two-word fallback. + private func confidentNextToken(after ids: [Int32], prefix: String?) -> (Int32, Int32)? { + let context = Context(first: ids[ids.count - 3], second: ids[ids.count - 2], third: ids[ids.count - 1]) + if let candidates = continuations[context] { + return Self.confidentCandidate( + in: candidates, displayForms: displayForms, prefix: prefix, + minimumCount: Self.minimumCount, minimumShare: Self.minimumShare + ) + } + let shortContext = ShortContext(first: ids[ids.count - 2], second: ids[ids.count - 1]) + guard let candidates = shortContinuations[shortContext] else { return nil } + return Self.confidentCandidate( + in: candidates, displayForms: displayForms, prefix: prefix, + minimumCount: Self.backoffMinimumCount, minimumShare: Self.backoffMinimumShare + ) + } + private static func confidentCandidate( in candidates: [Int32: Int32], displayForms: [String], diff --git a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift index 2e2f1471..1bf822de 100644 --- a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift +++ b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift @@ -108,19 +108,7 @@ enum BaseCompletionPromptRenderer { ) ) - // Token-aware budgeting (opt-in): when a token budget is supplied, fill sections against an - // estimated-token window instead of the character approximation. Defaults to the character - // path so shipped behavior is unchanged. - let kept: [PromptSection] - if let tokenBudget { - kept = PromptSectionBudget.allocate( - sections, - totalTokens: tokenBudget, - estimate: TokenCountEstimator.estimate - ) - } else { - kept = PromptSectionBudget.allocate(sections, totalChars: contextBudget) - } + let kept = allocate(sections, contextBudget: contextBudget, tokenBudget: tokenBudget) let prefix = kept.first { $0.name == "prefix" }?.content ?? "" let preface = kept.filter { $0.name != "prefix" }.map(\.content) @@ -133,6 +121,16 @@ enum BaseCompletionPromptRenderer { return preface.joined(separator: "\n") + "\n\n" + prefix } + /// Token-aware budgeting (opt-in): when a token budget is supplied, fill sections against an + /// estimated-token window instead of the character approximation. Defaults to the character + /// path so shipped behavior is unchanged. + private static func allocate(_ sections: [PromptSection], contextBudget: Int, tokenBudget: Int?) -> [PromptSection] { + guard let tokenBudget else { + return PromptSectionBudget.allocate(sections, totalChars: contextBudget) + } + return PromptSectionBudget.allocate(sections, totalTokens: tokenBudget, estimate: TokenCountEstimator.estimate) + } + /// Surface metadata is one optional section; its representation does not affect budgeting. private static func surfaceSection(_ surface: SurfaceContext?, compact: Bool) -> PromptSection? { guard let surface else { return nil } From 626069be07752747885d5c6930829c33f5b85988 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:03:54 -0400 Subject: [PATCH 4/8] Scrub secrets across the caret and keep distinct imported messages Review findings on the pure history helpers: - TypingHistoryScrubber scrubbed the text before and after the caret separately. With the caret inside a pasted key, each half was too short to match, and joining them stored the whole key. Both sides are now scrubbed as one text while the caret boundary is carried through each replacement; a secret straddling the caret is redacted whole and counts as typed. - Card-number-length digit runs (13-19 digits, optionally grouped) are redacted too: payment fields on websites are ordinary text fields, so a typed card number reached history. Phone numbers and dates stay. - CotypistExportImporter kept only the longest row per app plus 40-char opening, so two different messages with the same greeting (support replies, weekly updates) collapsed into one. A shorter row is now only dropped as an earlier snapshot when 80% of its informative words appear in a longer kept row; edited snapshots still collapse. - Date formatters are built once per import instead of up to four per timestamp. Co-Authored-By: Claude Opus 5.5 --- .../History/CotypistExportImporter.swift | 60 ++++++++++---- .../History/TypingHistoryScrubber.swift | 79 +++++++++++++------ .../History/CotypistExportImporterTests.swift | 22 ++++++ .../History/TypingHistoryScrubberTests.swift | 34 ++++++++ 4 files changed, 155 insertions(+), 40 deletions(-) diff --git a/Cotabby/Support/History/CotypistExportImporter.swift b/Cotabby/Support/History/CotypistExportImporter.swift index 934dc9f5..ea223666 100644 --- a/Cotabby/Support/History/CotypistExportImporter.swift +++ b/Cotabby/Support/History/CotypistExportImporter.swift @@ -8,9 +8,11 @@ import Foundation /// 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. +/// of times as it grows. Only the longest (most complete) version of each piece of writing is kept; +/// otherwise retrieval would return near-copies and phrase counts would be inflated by repetition. +/// Rows that share an app and an opening are candidates for being the same writing, but a shorter +/// one is only dropped when nearly all of its words appear in a longer one: two messages that open +/// the same way ("Thanks for reaching out! I'd be happy to…") are different writing and both stay. nonisolated enum CotypistExportImporter { enum ImportError: Error, Equatable, LocalizedError { case unrecognizedFormat @@ -32,6 +34,10 @@ nonisolated enum CotypistExportImporter { /// Texts shorter than this after scrubbing are fragments ("ok", "?") with nothing to learn. static let minimumCharacters = 20 private static let groupingPrefixLength = 40 + /// Share of a shorter text's words that must appear in a longer text of the same group for the + /// shorter one to count as an earlier snapshot of it. Edits between snapshots change a few + /// words; a different message that only shares the opening keeps most of its words to itself. + private static let snapshotWordShare = 0.8 static func records(fromExport data: Data) throws -> [TypingHistoryRecord] { guard let rows = try? JSONDecoder().decode([Row].self, from: data) else { @@ -41,29 +47,49 @@ nonisolated enum CotypistExportImporter { throw ImportError.unrecognizedFormat } - var longestByGroup: [String: TypingHistoryRecord] = [:] + // Building a DateFormatter is expensive and an export has thousands of rows, so the parsers + // are made once per import rather than once per timestamp. + let dateFormatters = makeDateFormatters() + var groups: [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 = normalizedBundleIdentifier(row.appBundleIdentifier) - let createdAt = parseDate(row.createdAt) ?? Date(timeIntervalSince1970: 0) + let createdAt = parseDate(row.createdAt, using: dateFormatters) ?? Date(timeIntervalSince1970: 0) let record = TypingHistoryRecord( id: UUID(), bundleIdentifier: bundleIdentifier, domain: normalizedDomain(row.domain), createdAt: createdAt, - updatedAt: parseDate(row.updatedAt) ?? createdAt, + updatedAt: parseDate(row.updatedAt, using: dateFormatters) ?? 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 + groups[key, default: []].append(record) } - return droppingEarlierSnapshots(Array(longestByGroup.values)).sorted { $0.createdAt < $1.createdAt } + let latestVersions = groups.values.flatMap(latestVersions(in:)) + return droppingEarlierSnapshots(latestVersions).sorted { $0.createdAt < $1.createdAt } + } + + /// Within one group (same app, same opening), keeps the most complete version of each piece of + /// writing. Longest first, a text is dropped as an earlier snapshot when nearly all of its words + /// appear in a longer text already kept; otherwise it is a different message and stays. + private static func latestVersions(in group: [TypingHistoryRecord]) -> [TypingHistoryRecord] { + guard group.count > 1 else { return group } + var kept: [(record: TypingHistoryRecord, words: Set)] = [] + for record in group.sorted(by: { $0.text.count > $1.text.count }) { + let words = Set(TypingHistoryIndex.terms(in: record.text)) + let isEarlierSnapshot = kept.contains { longer in + // A text with no informative words beyond the shared opening says nothing new. + words.isEmpty || Double(words.intersection(longer.words).count) / Double(words.count) >= snapshotWordShare + } + if !isEarlierSnapshot { kept.append((record, words)) } + } + return kept.map(\.record) } /// A field's early snapshots can be shorter than the grouping prefix ("Hi Arnaud, the POC is @@ -100,16 +126,20 @@ nonisolated enum CotypistExportImporter { 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"] { + /// The export writes SQLite-style timestamps ("2026-06-05 16:23:53.028") in UTC; ISO 8601 forms + /// are accepted too. Tried in this order. + private static func makeDateFormatters() -> [DateFormatter] { + ["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"].map { format in 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 formatter } - return nil + } + + private static func parseDate(_ string: String?, using formatters: [DateFormatter]) -> Date? { + guard let string else { return nil } + return formatters.lazy.compactMap { $0.date(from: string) }.first } } diff --git a/Cotabby/Support/History/TypingHistoryScrubber.swift b/Cotabby/Support/History/TypingHistoryScrubber.swift index 1be77b78..2e9cd467 100644 --- a/Cotabby/Support/History/TypingHistoryScrubber.swift +++ b/Cotabby/Support/History/TypingHistoryScrubber.swift @@ -2,8 +2,9 @@ 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. +/// History is stored for months and fed back into prompts, so a pasted API key, private key, or +/// card number 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 { @@ -22,19 +23,31 @@ nonisolated enum TypingHistoryScrubber { 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})" ) + /// Card-number-length digit runs (13 to 19 digits, optionally grouped by single spaces or + /// dashes). Payment fields on websites are ordinary text fields, not secure ones, so a typed + /// card number reaches the recorder like any other text. Phone numbers stay below 13 digits. + private static let longDigitRun = try? NSRegularExpression( + pattern: "\\b(?:\\d[ \\-]?){12,18}\\d\\b" + ) /// 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. + /// Scrubs the text before and after the caret and caps the pair, so the boundary between what + /// the user typed and what was already there survives (`TypingHistoryRecord.typedLength`). + /// + /// Both sides are scrubbed as one text. Scrubbed separately, a caret inside a pasted key would + /// split it into two halves that are each too short to look like a secret, and joining them + /// again would store the whole key. A secret that straddles the caret is redacted whole and + /// counts as typed. 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 redacted = redact(before + after, boundary: before.utf16.count) + let split = String.Index(utf16Offset: redacted.boundary, in: redacted.text) + var typed = String(redacted.text[.. maximumRecordCharacters { rest = String(rest.prefix(afterBudget)) @@ -49,42 +62,58 @@ nonisolated enum TypingHistoryScrubber { } static func scrub(_ text: String) -> String { - var result = text - for expression in [privateKeyBlock, prefixedCredential].compactMap({ $0 }) { - result = replace(expression, in: result) + let result = redact(text, boundary: 0).text + guard result.count > maximumRecordCharacters else { return result } + return String(result.suffix(maximumRecordCharacters)) + } + + /// Replaces every secret-like span and reports where `boundary` (a UTF-16 offset into `text`) + /// lands in the result. + private static func redact(_ text: String, boundary: Int) -> (text: String, boundary: Int) { + var result = (text: text, boundary: boundary) + for expression in [privateKeyBlock, prefixedCredential, longDigitRun].compactMap({ $0 }) { + result = replaceMatches(of: expression, in: result.text, boundary: result.boundary) { _ in redaction } } if let longMixedToken { - result = replaceMatches(of: longMixedToken, in: result) { token in + result = replaceMatches(of: longMixedToken, in: result.text, boundary: result.boundary) { 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) - } - + /// Rewrites each match with `transform` and carries `boundary` (UTF-16) across the edits. A + /// boundary inside a match that is replaced moves to the end of the replacement, so the secret + /// is never split; inside a match that is kept, it keeps its place. private static func replaceMatches( of expression: NSRegularExpression, in text: String, + boundary: Int, transform: (String) -> String - ) -> String { + ) -> (text: String, boundary: Int) { let nsText = text as NSString var output = "" + var outputLength = 0 + var mappedBoundary: Int? 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 + let range = match.range + let token = nsText.substring(with: range) + let replacement = transform(token) + let unchangedLength = range.location - cursor + if mappedBoundary == nil, boundary < range.location + range.length { + mappedBoundary = boundary <= range.location || replacement == token + ? outputLength + boundary - cursor + : outputLength + unchangedLength + (replacement as NSString).length + } + output += nsText.substring(with: NSRange(location: cursor, length: unchangedLength)) + output += replacement + outputLength += unchangedLength + (replacement as NSString).length + cursor = range.location + range.length } output += nsText.substring(from: cursor) - return output + return (output, mappedBoundary ?? outputLength + boundary - cursor) } } diff --git a/CotabbyTests/Support/History/CotypistExportImporterTests.swift b/CotabbyTests/Support/History/CotypistExportImporterTests.swift index 4923bcd7..0530a69c 100644 --- a/CotabbyTests/Support/History/CotypistExportImporterTests.swift +++ b/CotabbyTests/Support/History/CotypistExportImporterTests.swift @@ -26,6 +26,28 @@ final class CotypistExportImporterTests: XCTestCase { XCTAssertTrue(records.allSatisfy { $0.source == .imported }) } + func test_differentMessagesThatOpenTheSameWayAreBothKept() throws { + let data = try export([ + ["appBundleIdentifier": "com.apple.mail", + "textUpToCursor": "Thanks for reaching out! I'd be happy to help with your billing question about the March invoice."], + ["appBundleIdentifier": "com.apple.mail", + "textUpToCursor": "Thanks for reaching out! I'd be happy to help. Unfortunately we cannot refund annual plans after thirty days."] + ]) + + XCTAssertEqual(try CotypistExportImporter.records(fromExport: data).count, 2) + } + + func test_anEditedEarlierSnapshotStillCollapsesIntoTheFinalText() throws { + let final = "Hi Arnaud, the POC is ready for review. I tested it on the staging server and everything works. Let me know." + let data = try export([ + ["appBundleIdentifier": "com.microsoft.Outlook", + "textUpToCursor": "Hi Arnaud, the POC is ready for review. I tested it on the stagng server and it works."], + ["appBundleIdentifier": "com.microsoft.Outlook", "textUpToCursor": final] + ]) + + XCTAssertEqual(try CotypistExportImporter.records(fromExport: data).map(\.text), [final]) + } + func test_fragmentsAreDropped() throws { let data = try export([["appBundleIdentifier": "net.whatsapp.WhatsApp", "textUpToCursor": "ok"]]) XCTAssertEqual(try CotypistExportImporter.records(fromExport: data), []) diff --git a/CotabbyTests/Support/History/TypingHistoryScrubberTests.swift b/CotabbyTests/Support/History/TypingHistoryScrubberTests.swift index 4bdf94c1..945c6939 100644 --- a/CotabbyTests/Support/History/TypingHistoryScrubberTests.swift +++ b/CotabbyTests/Support/History/TypingHistoryScrubberTests.swift @@ -23,11 +23,45 @@ final class TypingHistoryScrubberTests: XCTestCase { XCTAssertTrue(scrubbed.hasPrefix("key [redacted] and [redacted]")) } + func test_cardNumbersAreRedactedButPhoneNumbersAndDatesStay() { + XCTAssertEqual( + TypingHistoryScrubber.scrub("card 4111 1111 1111 1111 exp 12/27, or 5500-0000-0000-0004"), + "card [redacted] exp 12/27, or [redacted]" + ) + let prose = "Call me on +1 415 555 0100 before 2026-10-04 17:30, order 123456789012." + XCTAssertEqual(TypingHistoryScrubber.scrub(prose), prose) + } + 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_aCredentialSplitByTheCaretIsStillRedacted() { + // Each half alone is too short to look like a key; scrubbed apart and joined again, the + // whole key would be stored. + let scrubbed = TypingHistoryScrubber.scrub(before: "my key is sk-abcdefgh", after: "ijklmnop12345678 thanks") + + XCTAssertEqual(scrubbed.text, "my key is [redacted] thanks") + XCTAssertEqual(scrubbed.typedLength, "my key is [redacted]".count, "A secret around the caret counts as typed") + } + + func test_theCaretBoundarySurvivesRedactionBeforeIt() { + let scrubbed = TypingHistoryScrubber.scrub( + before: "token ghp_abcdefghijklmnopqrstuvwxyz123456 then ", after: "the rest" + ) + + XCTAssertEqual(scrubbed.text, "token [redacted] then the rest") + XCTAssertEqual(scrubbed.typedLength, "token [redacted] then ".count) + } + + func test_aKeptLongWordAroundTheCaretKeepsTheBoundaryInPlace() { + let scrubbed = TypingHistoryScrubber.scrub(before: "Donaudampfschifffahrts", after: "gesellschaft fährt") + + XCTAssertEqual(scrubbed.text, "Donaudampfschifffahrtsgesellschaft fährt") + XCTAssertEqual(scrubbed.typedLength, "Donaudampfschifffahrts".count) + } + func test_longRecordsKeepTheirTail() { let text = String(repeating: "word ", count: 5_000) + "the end" let scrubbed = TypingHistoryScrubber.scrub(text) From debc61c772b98c397225325ddf11953f87b64dd4 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:04:11 -0400 Subject: [PATCH 5/8] Fix typing-history storage, deletion, and field-identity review findings Storage and deletion: - The termination flush (and the debounced save) wrote the archive even when nothing had changed, so every quit created TypingHistory.sealed and a Keychain key for users who never turned the feature on, and quitting after Delete All recreated them. Saves now run only when records changed. - Delete All cleared the count before the vault was destroyed. If removing the file or key failed, Settings showed zero entries, disabled the button, and the history came back on relaunch. State is now cleared only after a successful delete; a failure is shown and Delete All stays available to retry. - Delete All is enabled for an archive that can't be opened, so the user has a way to reset it from Settings. - Excluding an app mid-field now also removes the part of that field a background save had already copied into history. Field identity: - A field is now one record per piece of writing, not per AX element. Chat composers are cleared when a message is sent, so the next message overwrote the record (and an emptied field deleted it): chat apps recorded nothing. When most of the text is replaced in one step, the previous writing is kept as its own record and a new one starts. - Returning to a field resumes its record only while the field still holds all of that record's text. An empty or different field that reuses an element identifier (a new compose window) no longer resumes and overwrites earlier writing. A field that briefly reads empty still returns to its record. Examples: - The example cache now keeps the ranked candidates for each 8-word block and re-runs the same-document check against the live field on every request, so a passage the field has since come to contain is no longer shown as an example (self-echo). Co-Authored-By: Claude Opus 5.5 --- .../Services/History/TypingHistoryStore.swift | 238 +++++++++++++----- .../Support/History/TypingHistoryIndex.swift | 52 +++- .../Panes/TypingHistorySectionView.swift | 32 ++- .../History/TypingHistoryStoreTests.swift | 185 +++++++++++++- 4 files changed, 421 insertions(+), 86 deletions(-) diff --git a/Cotabby/Services/History/TypingHistoryStore.swift b/Cotabby/Services/History/TypingHistoryStore.swift index 7211525d..7746b3b6 100644 --- a/Cotabby/Services/History/TypingHistoryStore.swift +++ b/Cotabby/Services/History/TypingHistoryStore.swift @@ -31,6 +31,9 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { @Published private(set) var status: Status = .loading @Published private(set) var isImporting = false @Published private(set) var lastImportMessage: String? + /// Why the last Delete All failed, until a later one succeeds. While it is set the history is + /// still stored, so Settings keeps showing it and keeps Delete All available for a retry. + @Published private(set) var deletionError: 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. @@ -47,6 +50,10 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { private var persistenceGeneration = 0 /// Numbers each captured save so an older snapshot can never overwrite a newer one. private var saveSequence = 0 + /// Whether `records` differ from what was last loaded or handed to the writer. Saves are skipped + /// while this is false, so a user who never records or imports never gets an archive file or a + /// Keychain key, and quitting after Delete All does not recreate them. + private var hasUnsavedChanges = false private let userDefaults: UserDefaults private var records: [TypingHistoryRecord] = [] private var index: TypingHistoryIndex? @@ -54,14 +61,13 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { private var rebuildGeneration = 0 private var saveTask: Task? private var activeRecording: 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. + /// Recently finished fields worth keeping, by field key. When the user returns to one (or + /// Accessibility briefly reported it unsupported or empty) and it still holds that text, + /// 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])? + /// Ranked example candidates for the current query block (see `historyExamples`). + private var exampleCache: (key: String, candidates: [TypingHistoryIndex.Candidate])? /// 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 @@ -124,10 +130,18 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { 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 - recentRecordings = recentRecordings.filter { $0.value.bundleIdentifier != bundleIdentifier } + guard excluded else { return } + recentRecordings = recentRecordings.filter { $0.value.bundleIdentifier != bundleIdentifier } + guard let active = activeRecording, active.bundleIdentifier == bundleIdentifier else { return } + // Excluding an app mid-field discards that field's text instead of keeping it, including + // any part a background save already copied into `records`. + activeRecording = nil + if let index = records.firstIndex(where: { $0.id == active.recordID }) { + records.remove(at: index) + recordCount = records.count + hasUnsavedChanges = true + scheduleSave() + rebuildSearchStructures() } } @@ -151,16 +165,16 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { } /// Writes immediately, on the calling (main) thread. Used at termination, when there is no time - /// left for a debounced background save. + /// left for a debounced background save. Writes nothing when nothing changed. func flush() { guard status == .ready else { return } saveTask?.cancel() materializeActiveRecording() - saveSequence += 1 + guard let save = captureSave() else { return } do { - try writer.save(records, generation: persistenceGeneration, sequence: saveSequence) + try writer.save(save.records, generation: save.generation, sequence: save.sequence) } catch { - CotabbyLogger.app.error("Typing history could not be saved: \(error)") + saveFailed(error, generation: save.generation) } } @@ -171,21 +185,41 @@ 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 + guard let save = self.captureSave() else { return } let writer = self.writer - let generation = self.persistenceGeneration - let sequence = self.saveSequence do { try await Task.detached(priority: .utility) { - try writer.save(snapshot, generation: generation, sequence: sequence) + try writer.save(save.records, generation: save.generation, sequence: save.sequence) }.value } catch { - CotabbyLogger.app.error("Typing history could not be saved: \(error)") + self.saveFailed(error, generation: save.generation) } } } + /// A copy of the records for the writer, numbered and tagged with the deletion generation it + /// was taken in (see `TypingHistoryWriter`). + nonisolated private struct PendingSave: Sendable { + let records: [TypingHistoryRecord] + let generation: Int + let sequence: Int + } + + /// Snapshots the records for the writer, or returns nil when they match what is already on disk. + private func captureSave() -> PendingSave? { + guard hasUnsavedChanges else { return nil } + hasUnsavedChanges = false + saveSequence += 1 + return PendingSave(records: records, generation: persistenceGeneration, sequence: saveSequence) + } + + /// A failed write leaves the records unsaved, so the next save (or the termination flush) + /// tries again. Not after a deletion, though: those records are gone. + private func saveFailed(_ error: Error, generation: Int) { + if generation == persistenceGeneration { hasUnsavedChanges = true } + 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() { @@ -235,23 +269,44 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { return } - if activeRecording?.fieldKey != fieldKey { - finishActiveRecording() - activeRecording = resumedRecording(fieldKey: fieldKey, text: text, typedLength: input.precedingText.count) - ?? ActiveRecording( - fieldKey: fieldKey, - recordID: UUID(), - bundleIdentifier: input.bundleIdentifier, - domain: SurfaceContextComposer.registrableDomain(from: input.focusedURLString), - createdAt: Date(), - rawText: text, - rawTypedLength: input.precedingText.count - ) + let typedLength = input.precedingText.count + if let active = activeRecording, active.fieldKey == fieldKey, !holdsNewDocument(active, text: text) { + activeRecording?.rawText = text + activeRecording?.rawTypedLength = typedLength + scheduleSave() return } - activeRecording?.rawText = text - activeRecording?.rawTypedLength = input.precedingText.count - scheduleSave() + // Another field, or this field now holds different writing (a sent chat message was + // cleared, or another conversation's draft is showing): keep what was there as its own + // record and start recording the new text. + finishActiveRecording() + activeRecording = resumedRecording(fieldKey: fieldKey, text: text, typedLength: typedLength) + ?? ActiveRecording( + fieldKey: fieldKey, + recordID: UUID(), + bundleIdentifier: input.bundleIdentifier, + domain: SurfaceContextComposer.registrableDomain(from: input.focusedURLString), + createdAt: Date(), + rawText: text, + rawTypedLength: typedLength + ) + } + + /// Whether the active field's new `text` is different writing from what is being recorded. + /// + /// A field is reused for many pieces of writing: chat apps clear the composer when a message is + /// sent, and some reuse one composer for every conversation. Updating the same record across + /// those would overwrite each message with the next. Typing, deleting, or editing one spot + /// leaves most of the text in place between two observations; sending or switching replaces it. + private func holdsNewDocument(_ active: ActiveRecording, text: String) -> Bool { + if Self.isWorthKeeping(active.rawText) { + return !Self.keepsMostOf(active.rawText, in: text) + } + // Nothing worth keeping is being recorded, for example just after a message was sent. If + // the field shows the writing it held a moment ago again (Accessibility briefly reported it + // empty), go back to that record rather than copying it into a new one. + guard let recent = recentRecordings[active.fieldKey], recent.recordID != active.recordID else { return false } + return text.hasPrefix(recent.rawText) } /// Terminals are never recorded: their text is shell commands and output, where secrets are @@ -260,25 +315,27 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { 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. + /// Continues the record of a recently finished field when the user comes back to it, but only + /// while the field still holds everything that record has. Accessibility element identifiers + /// can be reused by a different field (a new compose window, an empty composer), and resuming + /// into the wrong record would overwrite writing the user already did. Anything else starts a + /// new record: at worst a near-copy of an edited document, never a lost one. 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 } + guard var recent = recentRecordings[fieldKey], text.hasPrefix(recent.rawText) 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. + /// another field, the field starts holding different writing, recording stops, or the app is no + /// longer eligible. private func finishActiveRecording() { - guard activeRecording != nil else { return } + guard let finished = activeRecording else { return } let changed = materializeActiveRecording() - if let finished = activeRecording { + // Only writing worth keeping can be resumed; remembering an emptied field would replace the + // entry for the text it held before. + if Self.isWorthKeeping(finished.rawText) { recentRecordings[finished.fieldKey] = finished if recentRecordings.count > Self.maximumRecentRecordings, let oldest = recentRecordings.values .min(by: { $0.createdAt < $1.createdAt }) { @@ -292,6 +349,34 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { } } + /// Fields with less text than `minimumRecordedCharacters` are not worth keeping. + private static func isWorthKeeping(_ text: String) -> Bool { + text.trimmingCharacters(in: .whitespacesAndNewlines).count >= minimumRecordedCharacters + } + + /// Whether `new` keeps at least half of `old` in place: their common start plus common end. + /// One edit between two observations (typing, deleting, a paste, fixing a word) keeps most of + /// a text; a cleared field or a different text keeps little. Compares UTF-8 bytes because it + /// runs on every change of a field that can hold thousands of characters. + static func keepsMostOf(_ old: String, in new: String) -> Bool { + let oldCount = old.utf8.count + let newCount = new.utf8.count + var prefix = 0 + var oldBytes = old.utf8.makeIterator() + var newBytes = new.utf8.makeIterator() + while let oldByte = oldBytes.next(), let newByte = newBytes.next(), oldByte == newByte { + prefix += 1 + } + var suffix = 0 + let suffixLimit = min(oldCount, newCount) - prefix + var oldReversed = old.utf8.reversed().makeIterator() + var newReversed = new.utf8.reversed().makeIterator() + while suffix < suffixLimit, let oldByte = oldReversed.next(), let newByte = newReversed.next(), oldByte == newByte { + suffix += 1 + } + return (prefix + suffix) * 2 >= oldCount + } + /// Copies the active field's scrubbed text into `records`. Returns whether anything changed. @discardableResult private func materializeActiveRecording() -> Bool { @@ -301,11 +386,12 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { before: String(active.rawText[..= Self.minimumRecordedCharacters else { - // The user cleared the field down to nothing worth keeping. + guard Self.isWorthKeeping(text) else { + // The user deleted the field's text down to nothing worth keeping. if let existingIndex { records.remove(at: existingIndex) recordCount = records.count + hasUnsavedChanges = true return true } return false @@ -326,6 +412,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { trimToCapacity() } recordCount = records.count + hasUnsavedChanges = true return true } @@ -355,11 +442,13 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { } let knownTexts = Set(records.map(\.text)) let fresh = imported.filter { !knownTexts.contains($0.text) } + lastImportMessage = "Imported \(fresh.count) entries" + + (imported.count > fresh.count ? " (\(imported.count - fresh.count) were already in your history)." : ".") + guard !fresh.isEmpty else { return } 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)." : ".") + hasUnsavedChanges = true scheduleSave() rebuildSearchStructures() } catch { @@ -367,26 +456,36 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { } } - /// Removes every record, the encrypted file, and its Keychain key. + /// Removes every record, the encrypted file, and its Keychain key. Also the way out of an + /// archive that can no longer be opened: deleting it lets recording start over. func deleteAll() { saveTask?.cancel() persistenceGeneration += 1 + do { + // Waits for any save already writing, then deletes; saves captured earlier are dropped. + try writer.destroy(generation: persistenceGeneration) + } catch { + // The archive or its key is still on disk. Keep showing what is stored and say why, so + // Settings never reports a deletion that did not happen and the user can try again. + // A save captured before this attempt was dropped as stale, and the file may already be + // gone, so the kept records count as unsaved until written again. + hasUnsavedChanges = true + deletionError = "Typing history couldn't be deleted: \(error.localizedDescription)" + CotabbyLogger.app.error("Typing history could not be deleted: \(error)") + return + } activeRecording = nil recentRecordings = [:] records = [] recordCount = 0 + hasUnsavedChanges = false index = nil phrases = nil exampleCache = nil rebuildGeneration += 1 lastImportMessage = nil - do { - // 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)") - } + deletionError = nil + status = .ready } // MARK: - SuggestionHistoryProviding @@ -398,16 +497,21 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { // 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 + let candidates: [TypingHistoryIndex.Candidate] + if let exampleCache, exampleCache.key == cacheKey { + candidates = exampleCache.candidates + } else { + candidates = index.candidates(for: TypingHistoryQuery( + text: queryText, + bundleIdentifier: context.bundleIdentifier, + domain: SurfaceContextComposer.registrableDomain(from: context.focusedURLString), + currentFieldText: context.precedingText + )) + exampleCache = (cacheKey, candidates) + } + // The ranking holds for a whole block of words, but the field keeps growing inside the + // block, so the check against echoing the user's own draft runs on the live text each time. + return TypingHistoryIndex.examples(from: candidates, currentFieldText: context.precedingText) } func phraseContinuation(for request: SuggestionRequest, engine: SuggestionEngineKind) -> String? { diff --git a/Cotabby/Support/History/TypingHistoryIndex.swift b/Cotabby/Support/History/TypingHistoryIndex.swift index 0afa579f..799e6f17 100644 --- a/Cotabby/Support/History/TypingHistoryIndex.swift +++ b/Cotabby/Support/History/TypingHistoryIndex.swift @@ -76,11 +76,47 @@ nonisolated struct TypingHistoryIndex: Sendable { self.inverseDocumentFrequency = postings.mapValues { log((count + 1) / Double($0.count)) + 1 } } + /// A ranked match: the passage that would be shown, and the whole past text it came from so it + /// can be checked against the field as the field grows. + struct Candidate: Equatable, Sendable { + let documentText: String + let passage: String + } + /// Returns up to `limit` passages from past writing that best match the query, each at most - /// `maxCharacters` long, best first. + /// `maxCharacters` long, best first. A one-off lookup over every match; the store ranks once + /// per block of words with `candidates(for:)` instead. func examples(for query: TypingHistoryQuery, limit: Int = 2, maxCharacters: Int = 320) -> [String] { + Self.examples( + from: candidates(for: query, maxCandidates: Int.max, maxCharacters: maxCharacters), + currentFieldText: query.currentFieldText, + limit: limit + ) + } + + /// The best `limit` passages among `candidates` that are not another version of the field's + /// own text, without repeats. + /// + /// Kept apart from `candidates(for:)` so a caller can rank once per block of words and still + /// re-run this cheap check on every keystroke: the field's text grows inside a block, and a + /// passage it now contains would only teach the model to echo the user's draft. + static func examples(from candidates: [Candidate], currentFieldText: String, limit: Int = 2) -> [String] { + let currentTail = String(currentFieldText.suffix(60)).trimmingCharacters(in: .whitespacesAndNewlines) + var passages: [String] = [] + for candidate in candidates where passages.count < limit { + if isSameDocument(candidate.documentText, currentText: currentFieldText, currentTail: currentTail) { continue } + if passages.contains(candidate.passage) { continue } + passages.append(candidate.passage) + } + return passages + } + + /// Ranks past writing against the query and returns the best `maxCandidates` matches with their + /// passages, best first. A few more than the examples shown are kept so some can still be + /// dropped as versions of the field's own text (`examples(from:currentFieldText:limit:)`). + func candidates(for query: TypingHistoryQuery, maxCandidates: Int = 6, maxCharacters: Int = 320) -> [Candidate] { let queryTerms = Set(Self.terms(in: query.text)) - guard !queryTerms.isEmpty, limit > 0 else { return [] } + guard !queryTerms.isEmpty, maxCandidates > 0 else { return [] } var scores: [Int32: Double] = [:] for term in queryTerms { @@ -90,7 +126,6 @@ nonisolated struct TypingHistoryIndex: Sendable { } } - let currentTail = String(query.currentFieldText.suffix(60)).trimmingCharacters(in: .whitespacesAndNewlines) let ranked = scores .map { documentID, score -> (Int32, Double) in let document = documents[Int(documentID)] @@ -102,16 +137,15 @@ nonisolated struct TypingHistoryIndex: Sendable { .filter { $0.1 >= Self.minimumScore } .sorted { $0.1 > $1.1 } - var passages: [String] = [] + var candidates: [Candidate] = [] 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 } + guard !passage.isEmpty else { continue } + candidates.append(Candidate(documentText: text, passage: passage)) + if candidates.count == maxCandidates { break } } - return passages + return candidates } /// True when a history entry is another version of the text being typed: an earlier snapshot diff --git a/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift index 319b2fbc..fd56b0ab 100644 --- a/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift +++ b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift @@ -27,7 +27,7 @@ struct TypingHistorySectionView: View { 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.", + "Password fields, terminals, and disabled apps are never recorded.", systemImage: "record.circle" ) } @@ -49,15 +49,15 @@ 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 || store.isImporting) + .disabled(!canDeleteAll) } } label: { VStack(alignment: .leading, spacing: 2) { Text(entryCountLabel) if let message = statusMessage { - Text(message) + Text(message.text) .font(.caption) - .foregroundStyle(store.status == .ready ? Color.secondary : Color.red) + .foregroundStyle(message.isError ? Color.red : Color.secondary) .fixedSize(horizontal: false, vertical: true) } } @@ -66,13 +66,26 @@ struct TypingHistorySectionView: View { "Delete all typing history?", isPresented: $isConfirmingDeleteAll ) { - Button("Delete All \(store.recordCount) Entries", role: .destructive) { store.deleteAll() } + Button(deleteAllConfirmationTitle, role: .destructive) { store.deleteAll() } } message: { Text("This removes every recorded and imported entry and its encryption key. It can't be undone.") } } } + /// Delete All also clears an archive that can no longer be opened (its entries can't be + /// counted, so the count reads zero) and stays available after a failed deletion so it can be + /// retried. Never during an import, which would add entries back. + private var canDeleteAll: Bool { + guard !store.isImporting else { return false } + if case .unavailable = store.status { return true } + return store.recordCount > 0 || store.deletionError != nil + } + + private var deleteAllConfirmationTitle: String { + store.recordCount > 0 ? "Delete All \(store.recordCount) Entries" : "Delete Typing History" + } + private var entryCountLabel: String { switch store.status { case .loading: return "Opening typing history…" @@ -80,10 +93,11 @@ struct TypingHistorySectionView: View { } } - private var statusMessage: String? { - if case let .unavailable(message) = store.status { return message } - if store.isImporting { return "Importing…" } - return store.lastImportMessage + private var statusMessage: (text: String, isError: Bool)? { + if let deletionError = store.deletionError { return (deletionError, true) } + if case let .unavailable(message) = store.status { return (message, true) } + if store.isImporting { return ("Importing…", false) } + return store.lastImportMessage.map { ($0, false) } } /// Asks for the `user_inputs.json` file from a decrypted Cotypist export. diff --git a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift index be008fda..fbc3793d 100644 --- a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift +++ b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift @@ -6,6 +6,12 @@ import XCTest private final class InMemoryKeyStore: TypingHistoryKeyStore, @unchecked Sendable { private let lock = NSLock() private var key: SymmetricKey? + private var deletionFails = false + + var hasKey: Bool { lock.withLock { key != nil } } + + /// Makes `deleteKey()` fail, the way a locked or denied Keychain would. + func setDeletionFails(_ fails: Bool) { lock.withLock { deletionFails = fails } } func existingKey() throws -> SymmetricKey? { lock.withLock { key } } func createKey() throws -> SymmetricKey { @@ -15,7 +21,12 @@ private final class InMemoryKeyStore: TypingHistoryKeyStore, @unchecked Sendable return created } } - func deleteKey() throws { lock.withLock { key = nil } } + func deleteKey() throws { + try lock.withLock { + if deletionFails { throw TypingHistoryVault.VaultError.keychain(errSecInteractionNotAllowed) } + key = nil + } + } } @MainActor @@ -318,6 +329,178 @@ final class TypingHistoryStoreTests: XCTestCase { XCTAssertEqual(store.recordCount, 0) } + // MARK: - Storage lifecycle + + func test_quittingWithoutHistoryCreatesNoArchiveOrKey() { + let keyStore = InMemoryKeyStore() + let vault = makeVault(keyStore: keyStore) + let store = makeStore(vault: vault) + store.setRecording(true) + store.observe(focus("short")) { true } + + store.flush() + + XCTAssertFalse(FileManager.default.fileExists(atPath: vault.fileURL.path)) + XCTAssertFalse(keyStore.hasKey) + } + + func test_quittingAfterDeleteAllDoesNotRecreateTheArchive() async throws { + let keyStore = InMemoryKeyStore() + let vault = makeVault(keyStore: keyStore) + let store = makeStore(vault: vault) + await store.importCotypistExport(from: try writeExport(signOffRows(count: 3))) + store.flush() + + store.deleteAll() + store.flush() + + XCTAssertFalse(FileManager.default.fileExists(atPath: vault.fileURL.path)) + XCTAssertFalse(keyStore.hasKey) + } + + func test_aFailedDeleteAllKeepsShowingTheHistoryAndCanBeRetried() async throws { + let keyStore = InMemoryKeyStore() + let store = makeStore(vault: makeVault(keyStore: keyStore)) + await store.importCotypistExport(from: try writeExport(signOffRows(count: 3))) + store.flush() + keyStore.setDeletionFails(true) + + store.deleteAll() + + XCTAssertEqual(store.recordCount, 3, "Nothing was deleted, so nothing should look deleted") + XCTAssertNotNil(store.deletionError) + + keyStore.setDeletionFails(false) + store.deleteAll() + + XCTAssertEqual(store.recordCount, 0) + XCTAssertNil(store.deletionError) + XCTAssertFalse(keyStore.hasKey) + } + + func test_excludingAnAppMidFieldAlsoDropsTheAlreadySavedPart() throws { + let vault = makeVault() + let store = makeStore(vault: vault) + store.setRecording(true) + store.observe(focus("a long private chat message in WhatsApp", app: "net.whatsapp.WhatsApp")) { true } + store.flush() + XCTAssertEqual(store.recordCount, 1, "Saving copies the field being typed in") + + store.setExcluded("net.whatsapp.WhatsApp", excluded: true) + store.flush() + + XCTAssertEqual(store.recordCount, 0) + XCTAssertEqual(try vault.load(), []) + } + + // MARK: - Field identity + + private func storedTexts(_ store: TypingHistoryStore, vault: TypingHistoryVault) throws -> [String] { + store.flush() + return try vault.load().map(\.text).sorted() + } + + func test_eachSentChatMessageIsKeptWhenTheComposerClears() throws { + let vault = makeVault() + let store = makeStore(vault: vault) + store.setRecording(true) + + store.observe(focus("", element: "composer", sequence: 1)) { true } + store.observe(focus("Hey, are we still on for lunch tomorrow?", element: "composer", sequence: 1)) { true } + store.observe(focus("", element: "composer", sequence: 1)) { true } + store.observe(focus("Great, I will book the usual place at noon.", element: "composer", sequence: 1)) { true } + store.observe(focus("", element: "composer", sequence: 1)) { true } + store.observe(focus("", element: "search", sequence: 2)) { true } + + XCTAssertEqual(try storedTexts(store, vault: vault), [ + "Great, I will book the usual place at noon.", + "Hey, are we still on for lunch tomorrow?" + ]) + } + + func test_anEmptyFieldReusingAnElementNeverOverwritesEarlierWriting() throws { + let vault = makeVault() + let store = makeStore(vault: vault) + 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 } + // A new compose window that got the old body's element identifier, empty at first. + store.observe(focus("", element: "body", sequence: 3)) { true } + store.observe(focus("Lunch at noon tomorrow works for me, see you.", element: "body", sequence: 3)) { true } + store.observe(focus("", element: "other", sequence: 4)) { true } + + XCTAssertEqual(try storedTexts(store, vault: vault), [ + "Hi Arnaud, the Imperum POC is ready for review.", + "Lunch at noon tomorrow works for me, see you." + ]) + } + + func test_aFieldThatBrieflyReadsEmptyKeepsOneRecord() throws { + let vault = makeVault() + let store = makeStore(vault: vault) + store.setRecording(true) + + store.observe(focus("Hi Arnaud, the Imperum POC is ready", element: "body", sequence: 1)) { true } + store.observe(focus("", element: "body", sequence: 1)) { 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 } + + XCTAssertEqual(try storedTexts(store, vault: vault), ["Hi Arnaud, the Imperum POC is ready for review."]) + } + + func test_deletingAFieldsTextKeyByKeyStillDiscardsIt() { + let store = makeStore() + store.setRecording(true) + let text = "Hi Arnaud, the Imperum POC is ready for review." + store.observe(focus(text, element: "body", sequence: 1)) { true } + store.flush() + XCTAssertEqual(store.recordCount, 1) + + for length in stride(from: text.count - 1, through: 0, by: -1) { + store.observe(focus(String(text.prefix(length)), element: "body", sequence: 1)) { true } + } + store.observe(focus("", element: "other", sequence: 2)) { true } + + XCTAssertEqual(store.recordCount, 0) + } + + func test_keepsMostOfTellsEditsFromReplacements() { + let draft = "Hi Arnaud, the Imperum POC is ready for review." + + XCTAssertTrue(TypingHistoryStore.keepsMostOf(draft, in: draft + " Thanks"), "Typing at the end") + XCTAssertTrue(TypingHistoryStore.keepsMostOf(draft, in: "Hello Arnaud, the Imperum POC is ready for review."), + "Fixing the first word") + XCTAssertTrue(TypingHistoryStore.keepsMostOf(draft, in: String(draft.dropLast(5))), "Deleting a few characters") + XCTAssertFalse(TypingHistoryStore.keepsMostOf(draft, in: ""), "A sent message cleared from the composer") + XCTAssertFalse(TypingHistoryStore.keepsMostOf(draft, in: "Lunch at noon tomorrow works for me."), "Different text") + } + + // MARK: - Example cache + + func test_cachedExamplesAreRecheckedAsTheFieldGrowsWithinABlock() async throws { + let store = makeStore() + store.setUsingHistory(true) + await store.importCotypistExport(from: try writeExport([[ + "appBundleIdentifier": "com.example.TestApp", + "textUpToCursor": "we will review the Imperum connector plan with the SOC team on Monday morning, then ship it." + ]])) + // 16 words: the query block for both lookups below. + let opening = "Draft notes for Friday about unrelated budget items we will review the Imperum connector plan with " + let earlier = CotabbyTestFixtures.focusedInputContext(precedingText: opening) + // Five more words, still inside the same 8-word block, and now the field holds the past text. + let later = CotabbyTestFixtures.focusedInputContext(precedingText: opening + "the SOC team on Monday") + XCTAssertEqual( + TypingHistoryQuery.stableText(from: earlier.precedingText), + TypingHistoryQuery.stableText(from: later.precedingText) + ) + + await waitUntil { !store.historyExamples(for: earlier, engine: .llamaOpenSource).isEmpty } + + XCTAssertEqual(store.historyExamples(for: later, engine: .llamaOpenSource), [], + "The field now contains that writing; showing it would make the model echo the draft") + } + func test_unchangedTextDoesNotEvaluateTheSettingsGate() { let store = makeStore() store.setRecording(true) From fb8fa479b8af333d6d1a3fc9ff453744a10c7e2d Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:07:59 -0400 Subject: [PATCH 6/8] Record only where Cotabby suggests; keep long documents as one record - The recording gate checked only global enable, pause, and per-app disable, so with per-site disabling turned on, typing on a disabled site was still recorded (and recording ran while Cotabby was paused for Low Power Mode or lacked Input Monitoring). It now asks SuggestionAvailabilityEvaluator, the same rule that decides whether Cotabby suggests; the store still checks capability, secure fields, and terminals itself. - Focus capture keeps 4096 UTF-16 units before the caret, so in a long document the window's start slides with every keystroke. The new "different writing" check compared the two ends of the text and would have split such a document into a record per keystroke; it now skips windowed text, which stays one record as before. - ARCHITECTURE.md describes the recording gate, per-message records, card-number scrubbing, and that nothing is written until something is recorded or imported. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 19 ++++++++------- Cotabby/App/Core/CotabbyAppEnvironment.swift | 24 ++++++++++++++----- .../Services/History/TypingHistoryStore.swift | 11 ++++++--- .../History/TypingHistoryStoreTests.swift | 17 +++++++++++++ 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ece20a82..8dd17af5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -398,14 +398,17 @@ 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 +`TypingHistoryStore` keeps the text of fields where Cotabby is active (the same +`SuggestionAvailabilityEvaluator` rule as suggestions; never secure fields, terminals, or excluded +apps), one record per piece of writing (a chat composer that clears after sending yields one record +per message), scrubs secret-like tokens and card numbers (`TypingHistoryScrubber`), and seals the +archive with AES-GCM under a Keychain key that never syncs (`TypingHistoryVault`). Nothing is +written until something is recorded or imported. 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. diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 3a938801..2b8b9733 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -374,15 +374,27 @@ final class CotabbyAppEnvironment { : 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. + // the text changed. The same rule that decides whether Cotabby suggests (on, not paused, + // app or site not disabled, not paused for Low Power Mode) decides where recording may + // happen, so history is only collected where Cotabby is active. The store checks the + // field's capability, secure fields, and terminals itself. focusModel.$snapshot - .sink { [weak typingHistoryStore, weak suggestionSettings] snapshot in - guard let typingHistoryStore, let suggestionSettings else { return } + .sink { [weak typingHistoryStore, weak suggestionSettings, weak permissionManager, weak lowPowerModeMonitor] snapshot in + guard let typingHistoryStore, let suggestionSettings, let permissionManager, let lowPowerModeMonitor else { return } typingHistoryStore.observe(snapshot) { let settings = suggestionSettings.snapshot - return settings.isGloballyEnabled && !settings.isTemporarilyPaused - && !(snapshot.bundleIdentifier.map(settings.disabledAppBundleIdentifiers.contains) ?? false) + return SuggestionAvailabilityEvaluator.disabledReason( + globallyEnabled: settings.isGloballyEnabled, + temporarilyPaused: settings.isTemporarilyPaused, + isLowPowerModeActive: lowPowerModeMonitor.isLowPowerModeEnabled, + isLowPowerModeAutoDisableEnabled: settings.isLowPowerModeAutoDisableEnabled, + disabledAppBundleIdentifiers: settings.disabledAppBundleIdentifiers, + disabledDomains: PerDomainDisableSettings.disabledDomains(), + suggestInIntegratedTerminals: settings.suggestInIntegratedTerminals, + inputMonitoringGranted: permissionManager.inputMonitoringGranted, + focusSnapshot: snapshot, + checkCapability: false + ) == nil } } .store(in: &cancellables) diff --git a/Cotabby/Services/History/TypingHistoryStore.swift b/Cotabby/Services/History/TypingHistoryStore.swift index 7746b3b6..36f9089e 100644 --- a/Cotabby/Services/History/TypingHistoryStore.swift +++ b/Cotabby/Services/History/TypingHistoryStore.swift @@ -270,7 +270,8 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { } let typedLength = input.precedingText.count - if let active = activeRecording, active.fieldKey == fieldKey, !holdsNewDocument(active, text: text) { + if let active = activeRecording, active.fieldKey == fieldKey, + !holdsNewDocument(active, text: text, isWindowed: input.precedingTextMayBeTruncated) { activeRecording?.rawText = text activeRecording?.rawTypedLength = typedLength scheduleSave() @@ -298,9 +299,13 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { /// sent, and some reuse one composer for every conversation. Updating the same record across /// those would overwrite each message with the next. Typing, deleting, or editing one spot /// leaves most of the text in place between two observations; sending or switching replaces it. - private func holdsNewDocument(_ active: ActiveRecording, text: String) -> Bool { + /// + /// `isWindowed` means focus capture cut the text before the caret to its window + /// (`FocusedInputSnapshot.textWindowUTF16`): a long document, whose window start slides with + /// every keystroke, so comparing the two ends says nothing. It stays one record. + private func holdsNewDocument(_ active: ActiveRecording, text: String, isWindowed: Bool) -> Bool { if Self.isWorthKeeping(active.rawText) { - return !Self.keepsMostOf(active.rawText, in: text) + return !isWindowed && !Self.keepsMostOf(active.rawText, in: text) } // Nothing worth keeping is being recorded, for example just after a message was sent. If // the field shows the writing it held a moment ago again (Accessibility briefly reported it diff --git a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift index fbc3793d..8b73803e 100644 --- a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift +++ b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift @@ -465,6 +465,23 @@ final class TypingHistoryStoreTests: XCTestCase { XCTAssertEqual(store.recordCount, 0) } + func test_aLongDocumentsSlidingCaptureWindowStaysOneRecord() { + let store = makeStore() + store.setRecording(true) + // Focus capture keeps a fixed window before the caret, so in a long document every + // keystroke drops a character from the front of the window as it adds one at the caret. + let document = (0..<1_000).map { "word\($0)" }.joined(separator: " ") + var window = String(document.suffix(FocusedInputSnapshot.textWindowUTF16)) + store.observe(focus(window, element: "doc", sequence: 1)) { true } + for character in " and a few more words typed at the end" { + window = String((window + String(character)).suffix(FocusedInputSnapshot.textWindowUTF16)) + store.observe(focus(window, element: "doc", sequence: 1)) { true } + } + store.observe(focus("", element: "other", sequence: 2)) { true } + + XCTAssertEqual(store.recordCount, 1) + } + func test_keepsMostOfTellsEditsFromReplacements() { let draft = "Hi Arnaud, the Imperum POC is ready for review." From 5e4b88ccd3b7b69c71c5b61d05934f26a961f863 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:30:14 -0400 Subject: [PATCH 7/8] Address Greptile re-review: unfinished saves, replaced long documents, example slots - A background save cleared the unsaved flag when it captured the records, so quitting while its write was still running skipped the termination flush and could lose the latest typing. Unsaved state is now a change count that only a finished write advances; the flush writes again (after the in-flight write, via the writer's lock). - Long documents (capture window full) were never split, so another long document shown in the same view overwrote the first one's record. A windowed field now starts a new record when its window title changed and none of the old window is left in the new one; caret jumps within one document (same title) still keep one record. - Candidate collection skipped nothing, so six top-ranked versions of the user's own draft could fill every slot and hide a usable example below them. Versions of the draft are now skipped when ranking; the per-request re-check still catches ones the growing field comes to contain. - Settings shows "Stored history can't be read" instead of "0 entries" for an archive that can't be opened; the observe doc names the gate. Co-Authored-By: Claude Opus 5.5 --- .../Services/History/TypingHistoryStore.swift | 119 ++++++++++++------ .../Support/History/TypingHistoryIndex.swift | 15 ++- .../Panes/TypingHistorySectionView.swift | 3 +- .../History/TypingHistoryStoreTests.swift | 97 +++++++++++++- .../History/TypingHistoryIndexTests.swift | 13 ++ 5 files changed, 200 insertions(+), 47 deletions(-) diff --git a/Cotabby/Services/History/TypingHistoryStore.swift b/Cotabby/Services/History/TypingHistoryStore.swift index 36f9089e..e16a5e11 100644 --- a/Cotabby/Services/History/TypingHistoryStore.swift +++ b/Cotabby/Services/History/TypingHistoryStore.swift @@ -50,11 +50,16 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { private var persistenceGeneration = 0 /// Numbers each captured save so an older snapshot can never overwrite a newer one. private var saveSequence = 0 - /// Whether `records` differ from what was last loaded or handed to the writer. Saves are skipped - /// while this is false, so a user who never records or imports never gets an archive file or a - /// Keychain key, and quitting after Delete All does not recreate them. - private var hasUnsavedChanges = false + /// Counts changes to `records`; `savedChangeCount` is the latest count a finished write has put + /// on disk. While they match, saves are skipped, so a user who never records or imports never + /// gets an archive file or a Keychain key, and quitting after Delete All does not recreate them. + /// A background write still in progress does not count as saved: the termination flush writes + /// again rather than trust a write the process may exit before. + private var changeCount = 0 + private var savedChangeCount = 0 private let userDefaults: UserDefaults + /// How long typing must pause before a background save; injectable so tests need not wait. + private let saveDelayNanoseconds: UInt64 private var records: [TypingHistoryRecord] = [] private var index: TypingHistoryIndex? private var phrases: TypingHistoryPhrasePredictor? @@ -81,6 +86,9 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { var rawText: String /// Characters before the caret in `rawText` at the latest capture. var rawTypedLength: Int + /// The window title at the latest capture: for a long document seen through the capture + /// window, a changed title is what tells another document from a caret jump. + var windowTitle: String? } private enum DefaultsKey { @@ -89,10 +97,16 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { static let excludedBundleIdentifiers = "cotabbyTypingHistoryExcludedApps" } - init(vault: TypingHistoryVault = .standard(), userDefaults: UserDefaults = .standard, loadsArchive: Bool = true) { + init( + vault: TypingHistoryVault = .standard(), + userDefaults: UserDefaults = .standard, + loadsArchive: Bool = true, + saveDelayNanoseconds: UInt64 = 5_000_000_000 + ) { self.vault = vault self.writer = TypingHistoryWriter(vault: vault) self.userDefaults = userDefaults + self.saveDelayNanoseconds = saveDelayNanoseconds preferences = TypingHistoryPreferences( isUsingHistory: userDefaults.object(forKey: DefaultsKey.isUsingHistory) as? Bool ?? TypingHistoryPreferences.defaults.isUsingHistory, @@ -139,7 +153,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { if let index = records.firstIndex(where: { $0.id == active.recordID }) { records.remove(at: index) recordCount = records.count - hasUnsavedChanges = true + changeCount += 1 scheduleSave() rebuildSearchStructures() } @@ -156,6 +170,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { guard generation == persistenceGeneration else { return } records = loaded recordCount = loaded.count + savedChangeCount = changeCount status = .ready rebuildSearchStructures() } catch { @@ -173,16 +188,18 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { guard let save = captureSave() else { return } do { try writer.save(save.records, generation: save.generation, sequence: save.sequence) + savedChangeCount = max(savedChangeCount, save.changeCount) } catch { - saveFailed(error, generation: save.generation) + CotabbyLogger.app.error("Typing history could not be saved: \(error)") } } private func scheduleSave() { guard status == .ready else { return } saveTask?.cancel() + let delay = saveDelayNanoseconds saveTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 5_000_000_000) + try? await Task.sleep(nanoseconds: delay) guard let self, !Task.isCancelled else { return } self.materializeActiveRecording() guard let save = self.captureSave() else { return } @@ -191,33 +208,32 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { try await Task.detached(priority: .utility) { try writer.save(save.records, generation: save.generation, sequence: save.sequence) }.value + // Only now are these changes on disk; until here a quit flushes them again. + self.savedChangeCount = max(self.savedChangeCount, save.changeCount) } catch { - self.saveFailed(error, generation: save.generation) + CotabbyLogger.app.error("Typing history could not be saved: \(error)") } } } /// A copy of the records for the writer, numbered and tagged with the deletion generation it - /// was taken in (see `TypingHistoryWriter`). + /// was taken in (see `TypingHistoryWriter`), and the change count it covers. nonisolated private struct PendingSave: Sendable { let records: [TypingHistoryRecord] let generation: Int let sequence: Int + let changeCount: Int } - /// Snapshots the records for the writer, or returns nil when they match what is already on disk. + /// Snapshots the records for the writer, or returns nil when a finished write already holds + /// them. A failed write leaves them unsaved, so the next save (or the termination flush) tries + /// again. private func captureSave() -> PendingSave? { - guard hasUnsavedChanges else { return nil } - hasUnsavedChanges = false + guard changeCount > savedChangeCount else { return nil } saveSequence += 1 - return PendingSave(records: records, generation: persistenceGeneration, sequence: saveSequence) - } - - /// A failed write leaves the records unsaved, so the next save (or the termination flush) - /// tries again. Not after a deletion, though: those records are gone. - private func saveFailed(_ error: Error, generation: Int) { - if generation == persistenceGeneration { hasUnsavedChanges = true } - CotabbyLogger.app.error("Typing history could not be saved: \(error)") + return PendingSave( + records: records, generation: persistenceGeneration, sequence: saveSequence, changeCount: changeCount + ) } /// Rebuilds the index and phrase table off the main actor from a copy of the records. The field @@ -242,10 +258,11 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { /// 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. + /// `isAllowed` carries Cotabby's own availability rule (on, not paused, app or site not + /// disabled; see `CotabbyAppEnvironment`), 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 change: it is only evaluated once recording is on and the field's text changed. 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, @@ -270,10 +287,10 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { } let typedLength = input.precedingText.count - if let active = activeRecording, active.fieldKey == fieldKey, - !holdsNewDocument(active, text: text, isWindowed: input.precedingTextMayBeTruncated) { + if let active = activeRecording, active.fieldKey == fieldKey, !holdsNewDocument(active, input: input, text: text) { activeRecording?.rawText = text activeRecording?.rawTypedLength = typedLength + activeRecording?.windowTitle = input.windowTitle scheduleSave() return } @@ -281,7 +298,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { // cleared, or another conversation's draft is showing): keep what was there as its own // record and start recording the new text. finishActiveRecording() - activeRecording = resumedRecording(fieldKey: fieldKey, text: text, typedLength: typedLength) + activeRecording = resumedRecording(fieldKey: fieldKey, input: input, text: text) ?? ActiveRecording( fieldKey: fieldKey, recordID: UUID(), @@ -289,7 +306,8 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { domain: SurfaceContextComposer.registrableDomain(from: input.focusedURLString), createdAt: Date(), rawText: text, - rawTypedLength: typedLength + rawTypedLength: typedLength, + windowTitle: input.windowTitle ) } @@ -300,12 +318,15 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { /// those would overwrite each message with the next. Typing, deleting, or editing one spot /// leaves most of the text in place between two observations; sending or switching replaces it. /// - /// `isWindowed` means focus capture cut the text before the caret to its window - /// (`FocusedInputSnapshot.textWindowUTF16`): a long document, whose window start slides with - /// every keystroke, so comparing the two ends says nothing. It stays one record. - private func holdsNewDocument(_ active: ActiveRecording, text: String, isWindowed: Bool) -> Bool { + /// A long document is different: focus capture cuts the text before the caret to its window + /// (`FocusedInputSnapshot.textWindowUTF16`), whose start slides with every keystroke, and a + /// caret jump shows another part of the same document, so comparing the two ends says nothing. + /// There, only a new window title together with nothing of the old window left in the new one + /// marks another document (a different note or file shown in the same view). + private func holdsNewDocument(_ active: ActiveRecording, input: FocusedInputSnapshot, text: String) -> Bool { if Self.isWorthKeeping(active.rawText) { - return !isWindowed && !Self.keepsMostOf(active.rawText, in: text) + guard input.precedingTextMayBeTruncated else { return !Self.keepsMostOf(active.rawText, in: text) } + return active.windowTitle != input.windowTitle && !Self.sharesContent(active.rawText, with: text) } // Nothing worth keeping is being recorded, for example just after a message was sent. If // the field shows the writing it held a moment ago again (Accessibility briefly reported it @@ -325,10 +346,11 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { /// can be reused by a different field (a new compose window, an empty composer), and resuming /// into the wrong record would overwrite writing the user already did. Anything else starts a /// new record: at worst a near-copy of an edited document, never a lost one. - private func resumedRecording(fieldKey: String, text: String, typedLength: Int) -> ActiveRecording? { + private func resumedRecording(fieldKey: String, input: FocusedInputSnapshot, text: String) -> ActiveRecording? { guard var recent = recentRecordings[fieldKey], text.hasPrefix(recent.rawText) else { return nil } recent.rawText = text - recent.rawTypedLength = typedLength + recent.rawTypedLength = input.precedingText.count + recent.windowTitle = input.windowTitle return recent } @@ -382,6 +404,21 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { return (prefix + suffix) * 2 >= oldCount } + /// Whether `new` still contains a piece from inside `old`. Typing in a long document slides the + /// capture window a few characters at a time, so pieces from the middle of the old window are + /// still in the new one; another document contains none of them. Only asked for long text, and + /// only when the window title changed. + static func sharesContent(_ old: String, with new: String) -> Bool { + let probeLength = 48 + let oldLength = old.count + guard oldLength > probeLength * 2 else { return new.contains(old) } + for quarter in 1...3 { + let start = old.index(old.startIndex, offsetBy: (oldLength - probeLength) * quarter / 4) + if new.contains(old[start.. Bool { @@ -396,7 +433,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { if let existingIndex { records.remove(at: existingIndex) recordCount = records.count - hasUnsavedChanges = true + changeCount += 1 return true } return false @@ -417,7 +454,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { trimToCapacity() } recordCount = records.count - hasUnsavedChanges = true + changeCount += 1 return true } @@ -453,7 +490,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { records.append(contentsOf: fresh) trimToCapacity() recordCount = records.count - hasUnsavedChanges = true + changeCount += 1 scheduleSave() rebuildSearchStructures() } catch { @@ -474,7 +511,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { // Settings never reports a deletion that did not happen and the user can try again. // A save captured before this attempt was dropped as stale, and the file may already be // gone, so the kept records count as unsaved until written again. - hasUnsavedChanges = true + changeCount += 1 deletionError = "Typing history couldn't be deleted: \(error.localizedDescription)" CotabbyLogger.app.error("Typing history could not be deleted: \(error)") return @@ -483,7 +520,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { recentRecordings = [:] records = [] recordCount = 0 - hasUnsavedChanges = false + savedChangeCount = changeCount index = nil phrases = nil exampleCache = nil diff --git a/Cotabby/Support/History/TypingHistoryIndex.swift b/Cotabby/Support/History/TypingHistoryIndex.swift index 799e6f17..047aa282 100644 --- a/Cotabby/Support/History/TypingHistoryIndex.swift +++ b/Cotabby/Support/History/TypingHistoryIndex.swift @@ -101,7 +101,7 @@ nonisolated struct TypingHistoryIndex: Sendable { /// re-run this cheap check on every keystroke: the field's text grows inside a block, and a /// passage it now contains would only teach the model to echo the user's draft. static func examples(from candidates: [Candidate], currentFieldText: String, limit: Int = 2) -> [String] { - let currentTail = String(currentFieldText.suffix(60)).trimmingCharacters(in: .whitespacesAndNewlines) + let currentTail = tail(of: currentFieldText) var passages: [String] = [] for candidate in candidates where passages.count < limit { if isSameDocument(candidate.documentText, currentText: currentFieldText, currentTail: currentTail) { continue } @@ -112,8 +112,10 @@ nonisolated struct TypingHistoryIndex: Sendable { } /// Ranks past writing against the query and returns the best `maxCandidates` matches with their - /// passages, best first. A few more than the examples shown are kept so some can still be - /// dropped as versions of the field's own text (`examples(from:currentFieldText:limit:)`). + /// passages, best first. Matches that are already versions of the field's own text are skipped + /// here, so they never take a slot from a usable example; a few more candidates than examples + /// are kept because the field keeps growing and can still come to contain one + /// (`examples(from:currentFieldText:limit:)` re-checks them on every request). func candidates(for query: TypingHistoryQuery, maxCandidates: Int = 6, maxCharacters: Int = 320) -> [Candidate] { let queryTerms = Set(Self.terms(in: query.text)) guard !queryTerms.isEmpty, maxCandidates > 0 else { return [] } @@ -137,9 +139,11 @@ nonisolated struct TypingHistoryIndex: Sendable { .filter { $0.1 >= Self.minimumScore } .sorted { $0.1 > $1.1 } + let currentTail = Self.tail(of: query.currentFieldText) var candidates: [Candidate] = [] 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 else { continue } candidates.append(Candidate(documentText: text, passage: passage)) @@ -148,6 +152,11 @@ nonisolated struct TypingHistoryIndex: Sendable { return candidates } + /// The field's latest words, for spotting a history entry that already contains them. + private static func tail(of currentText: String) -> String { + String(currentText.suffix(60)).trimmingCharacters(in: .whitespacesAndNewlines) + } + /// 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 { diff --git a/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift index fd56b0ab..471ef27d 100644 --- a/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift +++ b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift @@ -89,7 +89,8 @@ struct TypingHistorySectionView: View { 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" + case .unavailable: return "Stored history can't be read" + case .ready: return store.recordCount == 1 ? "1 entry stored" : "\(store.recordCount) entries stored" } } diff --git a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift index 8b73803e..1fb294bb 100644 --- a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift +++ b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift @@ -29,6 +29,41 @@ private final class InMemoryKeyStore: TypingHistoryKeyStore, @unchecked Sendable } } +/// Holds the first key lookup, which happens inside the first archive write, until released, so a +/// test can quit while a background save is still writing. Counts lookups, one per write. +private final class GatedKeyStore: TypingHistoryKeyStore, @unchecked Sendable { + private let lock = NSLock() + private let gate = DispatchSemaphore(value: 0) + private var key: SymmetricKey? + private var lookups = 0 + private var holding = false + + var keyLookups: Int { lock.withLock { lookups } } + var isHoldingAWrite: Bool { lock.withLock { holding } } + func release() { gate.signal() } + + func existingKey() throws -> SymmetricKey? { + let isFirst = lock.withLock { + lookups += 1 + holding = lookups == 1 + return holding + } + if isFirst { + gate.wait() + lock.withLock { holding = false } + } + return 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. @@ -284,10 +319,10 @@ final class TypingHistoryStoreTests: XCTestCase { } private func focus(_ text: String, element: String, sequence: UInt64, app: String = "com.apple.mail", - isIntegratedTerminal: Bool = false) -> FocusSnapshot { + isIntegratedTerminal: Bool = false, windowTitle: String? = nil) -> FocusSnapshot { let input = CotabbyTestFixtures.focusedInputSnapshot( bundleIdentifier: app, elementIdentifier: element, precedingText: text, - isIntegratedTerminal: isIntegratedTerminal, focusChangeSequence: sequence + isIntegratedTerminal: isIntegratedTerminal, focusChangeSequence: sequence, windowTitle: windowTitle ) return FocusSnapshot(applicationName: "Mail", bundleIdentifier: app, capability: .supported, context: input) } @@ -358,6 +393,25 @@ final class TypingHistoryStoreTests: XCTestCase { XCTAssertFalse(keyStore.hasKey) } + func test_quittingWhileABackgroundSaveIsStillWritingWritesAgain() async { + let keyStore = GatedKeyStore() + let store = TypingHistoryStore( + vault: TypingHistoryVault(fileURL: directory.appendingPathComponent("TypingHistory.sealed"), keyStore: keyStore), + userDefaults: defaults, loadsArchive: false, saveDelayNanoseconds: 0 + ) + Self.retained.append(store) + store.setRecording(true) + store.observe(focus("Hi Arnaud, the Imperum POC is ready", element: "body", sequence: 1)) { true } + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.", element: "body", sequence: 1)) { true } + await waitUntil { keyStore.isHoldingAWrite } + // The background write finishes only after the quit has started waiting for it. + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { keyStore.release() } + + store.flush() + + XCTAssertEqual(keyStore.keyLookups, 2, "The quit must not trust a write that hasn't finished") + } + func test_aFailedDeleteAllKeepsShowingTheHistoryAndCanBeRetried() async throws { let keyStore = InMemoryKeyStore() let store = makeStore(vault: makeVault(keyStore: keyStore)) @@ -482,6 +536,45 @@ final class TypingHistoryStoreTests: XCTestCase { XCTAssertEqual(store.recordCount, 1) } + func test_anotherLongDocumentShownInTheSameViewKeepsBothRecords() { + let store = makeStore() + store.setRecording(true) + let window = FocusedInputSnapshot.textWindowUTF16 + let first = String((0..<1_000).map { "alpha\($0)" }.joined(separator: " ").suffix(window)) + let second = String((0..<1_000).map { "beta\($0)" }.joined(separator: " ").suffix(window)) + + store.observe(focus(first, element: "doc", sequence: 1, windowTitle: "First.txt")) { true } + store.observe(focus(second, element: "doc", sequence: 1, windowTitle: "Second.txt")) { true } + store.observe(focus("", element: "other", sequence: 2)) { true } + + XCTAssertEqual(store.recordCount, 2) + } + + func test_aCaretJumpInALongDocumentKeepsOneRecord() { + let store = makeStore() + store.setRecording(true) + let window = FocusedInputSnapshot.textWindowUTF16 + let document = (0..<2_000).map { "word\($0)" }.joined(separator: " ") + let nearEnd = String(document.suffix(window)) + let nearMiddle = String(document.prefix(document.count / 2).suffix(window)) + + store.observe(focus(nearEnd, element: "doc", sequence: 1, windowTitle: "Notes.txt")) { true } + store.observe(focus(nearMiddle, element: "doc", sequence: 1, windowTitle: "Notes.txt")) { true } + store.observe(focus("", element: "other", sequence: 2)) { true } + + XCTAssertEqual(store.recordCount, 1) + } + + func test_sharesContentFollowsASlidingWindowButNotAnotherDocument() { + let window = FocusedInputSnapshot.textWindowUTF16 + let current = String((0..<1_000).map { "word\($0)" }.joined(separator: " ").suffix(window)) + + XCTAssertTrue(TypingHistoryStore.sharesContent(current, with: String((current + " and more").suffix(window)))) + XCTAssertFalse(TypingHistoryStore.sharesContent( + current, with: String((0..<1_000).map { "other\($0)" }.joined(separator: " ").suffix(window)) + )) + } + func test_keepsMostOfTellsEditsFromReplacements() { let draft = "Hi Arnaud, the Imperum POC is ready for review." diff --git a/CotabbyTests/Support/History/TypingHistoryIndexTests.swift b/CotabbyTests/Support/History/TypingHistoryIndexTests.swift index fbaa7040..31b917bd 100644 --- a/CotabbyTests/Support/History/TypingHistoryIndexTests.swift +++ b/CotabbyTests/Support/History/TypingHistoryIndexTests.swift @@ -41,6 +41,19 @@ final class TypingHistoryIndexTests: XCTestCase { XCTAssertEqual(index.examples(for: query("Imperum POC Sentinel SOC review", field: draft + " Let me")), []) } + func test_versionsOfTheDraftNeverCrowdOutAUsableExample() { + // Seven earlier versions of the field's own text outrank the one usable example; they + // must not fill every candidate slot and leave nothing to show. + let draft = "Status: the Imperum POC with Sentinel connectors is ready for SOC review this week and " + let usable = "Separately, the Imperum connectors for Sentinel passed review yesterday." + let index = TypingHistoryIndex( + records: (0..<7).map { record(draft + "version \($0) ends here.") } + [record(usable, domain: "github.com")] + ) + let lookup = query(draft, domain: "github.com", field: draft) + + XCTAssertEqual(TypingHistoryIndex.examples(from: index.candidates(for: lookup), currentFieldText: draft), [usable]) + } + 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) From 509b3759248a3f9181542b21f30196ed0c354900 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:50:07 -0400 Subject: [PATCH 8/8] Skip only earlier draft versions when ranking history examples Ranking skipped every match the same-document check rejected, including ones rejected only because they contained the field's latest words. That half of the check changes with every keystroke, so a passage withheld at the start of an 8-word block stayed withheld for the whole block even after the wording moved on. Ranking now skips only earlier versions (the field already contains their opening, which stays true as it grows); the per-request filter still applies both halves. Co-Authored-By: Claude Opus 5.5 --- .../Support/History/TypingHistoryIndex.swift | 20 ++++++++------ .../History/TypingHistoryStoreTests.swift | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/Cotabby/Support/History/TypingHistoryIndex.swift b/Cotabby/Support/History/TypingHistoryIndex.swift index 047aa282..f52a0ac0 100644 --- a/Cotabby/Support/History/TypingHistoryIndex.swift +++ b/Cotabby/Support/History/TypingHistoryIndex.swift @@ -112,10 +112,11 @@ nonisolated struct TypingHistoryIndex: Sendable { } /// Ranks past writing against the query and returns the best `maxCandidates` matches with their - /// passages, best first. Matches that are already versions of the field's own text are skipped - /// here, so they never take a slot from a usable example; a few more candidates than examples - /// are kept because the field keeps growing and can still come to contain one - /// (`examples(from:currentFieldText:limit:)` re-checks them on every request). + /// passages, best first. Earlier versions of the field's own text (the field already contains + /// their opening, which stays true as it grows) are skipped here, so they never take a slot + /// from a usable example. Whether the field's latest words appear in a match changes with + /// every keystroke, so that half of the check is left to `examples(from:currentFieldText:limit:)`, + /// which re-runs both halves on every request; a few spare candidates cover what it drops. func candidates(for query: TypingHistoryQuery, maxCandidates: Int = 6, maxCharacters: Int = 320) -> [Candidate] { let queryTerms = Set(Self.terms(in: query.text)) guard !queryTerms.isEmpty, maxCandidates > 0 else { return [] } @@ -139,11 +140,10 @@ nonisolated struct TypingHistoryIndex: Sendable { .filter { $0.1 >= Self.minimumScore } .sorted { $0.1 > $1.1 } - let currentTail = Self.tail(of: query.currentFieldText) var candidates: [Candidate] = [] for (documentID, _) in ranked { let text = documents[Int(documentID)].text - if Self.isSameDocument(text, currentText: query.currentFieldText, currentTail: currentTail) { continue } + if Self.isEarlierVersion(text, of: query.currentFieldText) { continue } let passage = Self.bestPassage(in: text, terms: queryTerms, maxCharacters: maxCharacters) guard !passage.isEmpty else { continue } candidates.append(Candidate(documentText: text, passage: passage)) @@ -160,9 +160,13 @@ nonisolated struct TypingHistoryIndex: Sendable { /// 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 { + isEarlierVersion(text, of: currentText) || (currentTail.count >= 20 && text.contains(currentTail)) + } + + /// The field already contains this entry's opening: an earlier snapshot of the same writing. + private static func isEarlierVersion(_ text: String, of currentText: String) -> Bool { let opening = String(text.prefix(60)) - if opening.count >= 20, currentText.contains(opening) { return true } - return currentTail.count >= 20 && text.contains(currentTail) + return opening.count >= 20 && currentText.contains(opening) } // MARK: - Text helpers diff --git a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift index 1fb294bb..b85c2f2c 100644 --- a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift +++ b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift @@ -611,6 +611,32 @@ final class TypingHistoryStoreTests: XCTestCase { "The field now contains that writing; showing it would make the model echo the draft") } + func test_anExampleHiddenByTheFieldsLatestWordsComesBackWhenTheWordingMovesOn() async throws { + let store = makeStore() + store.setUsingHistory(true) + let earlier = "we will review the Imperum connector plan with the SOC team on Monday morning, then ship it." + await store.importCotypistExport(from: try writeExport([[ + "appBundleIdentifier": "com.example.TestApp", "textUpToCursor": earlier + ]])) + let probe = CotabbyTestFixtures.focusedInputContext( + elementIdentifier: "probe", precedingText: "Imperum connector plan review notes for the steering group " + ) + await waitUntil { !store.historyExamples(for: probe, engine: .llamaOpenSource).isEmpty } + // The field's latest words are inside the earlier passage, so it is withheld for now. + let echoing = "Budget notes for Friday and several other items: please review the Imperum connector plan with " + + "the SOC team on Monday" + XCTAssertEqual( + store.historyExamples(for: CotabbyTestFixtures.focusedInputContext(precedingText: echoing), engine: .llamaOpenSource), [] + ) + + // Two more words, same 8-word block: the wording has moved on, so the passage is usable again. + let movedOn = CotabbyTestFixtures.focusedInputContext(precedingText: echoing + " evening instead") + XCTAssertEqual( + TypingHistoryQuery.stableText(from: echoing), TypingHistoryQuery.stableText(from: movedOn.precedingText) + ) + XCTAssertFalse(store.historyExamples(for: movedOn, engine: .llamaOpenSource).isEmpty) + } + func test_unchangedTextDoesNotEvaluateTheSettingsGate() { let store = makeStore() store.setRecording(true)