From 96ea5fe6f6284c5355a99fec6b46096f0dd1e86b Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Wed, 24 Jun 2026 12:55:32 -0500 Subject: [PATCH] feat(ai): on-device RAG over bookmarks + Spotlight indexing + unified logging - SpotlightIndexer: actually push BookmarkEntity into the Spotlight index via indexAppEntities (IndexedEntity conformance alone indexes nothing). Wired into every sync/mutation point. This is what lets Apple Intelligence answer free-form Siri questions grounded in the user's bookmarks. - On-device RAG: BookmarkAssistant (LanguageModelSession) + BookmarkSearchTool + SpotlightBookmarkSearch (CSSearchQuery retrieval), surfaced via AskView with lightweight Markdown rendering of answers. - Log: os.Logger facility (com.magicive.marks) replacing ad-hoc print(); shared with ShareExtension. ClaudeService now logs server error bodies on non-2xx. Co-Authored-By: Claude Opus 4.8 (1M context) --- Marks.xcodeproj/project.pbxproj | 26 ++++ Marks/Intents/MarksAppIntents.swift | 7 + Marks/Services/AnalyticsService.swift | 4 +- Marks/Services/BookmarkAssistant.swift | 81 ++++++++++ Marks/Services/BookmarkSearchTool.swift | 37 +++++ Marks/Services/ClaudeService.swift | 13 +- Marks/Services/Log.swift | 43 ++++++ Marks/Services/MarksAuth.swift | 2 +- Marks/Services/SpotlightBookmarkSearch.swift | 70 +++++++++ Marks/Services/SpotlightIndexer.swift | 40 +++++ Marks/Views/AskView.swift | 152 +++++++++++++++++++ Marks/Views/BookmarksView.swift | 9 ++ Marks/Views/BookmarksViewModel.swift | 32 ++-- project.yml | 1 + 14 files changed, 499 insertions(+), 18 deletions(-) create mode 100644 Marks/Services/BookmarkAssistant.swift create mode 100644 Marks/Services/BookmarkSearchTool.swift create mode 100644 Marks/Services/Log.swift create mode 100644 Marks/Services/SpotlightBookmarkSearch.swift create mode 100644 Marks/Services/SpotlightIndexer.swift create mode 100644 Marks/Views/AskView.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index c183d80..971d6c0 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -12,17 +12,22 @@ 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; + 212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; }; + 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; }; 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; + 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; }; 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; + 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E6702DE1210AC38ED16D1 /* AskView.swift */; }; 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; }; 706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; }; 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; }; 8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; }; + 8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */; }; 8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7A85A23A13D754F6A75E4D /* ShareView.swift */; }; 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; }; 852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; }; @@ -35,8 +40,10 @@ 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; }; A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; }; + AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; }; AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; }; AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; + B085CDBFC47F0357D1A28911 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; }; B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; }; B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; }; @@ -103,8 +110,11 @@ 093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = ""; }; 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = ""; }; + 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = ""; }; + 217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = ""; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = ""; }; 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = ""; }; 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentBookmarksWidget.swift; sourceTree = ""; }; @@ -133,6 +143,7 @@ B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = ""; }; B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = ""; }; BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = ""; }; C8B10AC011AF8F242E525440 /* MarksTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MarksTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; @@ -142,10 +153,12 @@ D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = ""; }; + E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = ""; }; F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = ""; }; F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; }; + FB7728D15C17219ABFF3EFFE /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -232,10 +245,15 @@ isa = PBXGroup; children = ( 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */, + E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */, + C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */, 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */, 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */, + FB7728D15C17219ABFF3EFFE /* Log.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, + 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */, + 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */, ); path = Services; sourceTree = ""; @@ -258,6 +276,7 @@ isa = PBXGroup; children = ( AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, + 217E6702DE1210AC38ED16D1 /* AskView.swift */, CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, @@ -460,6 +479,7 @@ files = ( 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */, 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */, + B085CDBFC47F0357D1A28911 /* Log.swift in Sources */, B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */, F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */, 8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */, @@ -486,11 +506,14 @@ files = ( AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */, B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, + 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, + 8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */, FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, + 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */, @@ -500,6 +523,7 @@ DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */, EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, + 212F713DCC289C48087B79AE /* Log.swift in Sources */, 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */, E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */, 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */, @@ -509,6 +533,8 @@ EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */, 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */, 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, + 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */, + AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */, B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */, 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */, E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */, diff --git a/Marks/Intents/MarksAppIntents.swift b/Marks/Intents/MarksAppIntents.swift index 588485b..08f7c3d 100644 --- a/Marks/Intents/MarksAppIntents.swift +++ b/Marks/Intents/MarksAppIntents.swift @@ -133,6 +133,13 @@ struct SummarizeBookmarkIntent: AppIntent { } } +// Note: there is intentionally no "Ask" AppIntent. Apple Intelligence already +// answers free-form questions over the user's bookmarks by querying the +// Spotlight index of `BookmarkEntity` (an `IndexedEntity`) directly — verified +// on device (Campo → CoreSpotlight → on-device model). A named intent would be +// redundant, and "Ask " collides with Siri's built-in "Ask". The in-app +// `AskView` remains as a manual on-device-RAG surface. + // MARK: - App Shortcuts struct MarksShortcuts: AppShortcutsProvider { diff --git a/Marks/Services/AnalyticsService.swift b/Marks/Services/AnalyticsService.swift index 06fe485..ec830b4 100644 --- a/Marks/Services/AnalyticsService.swift +++ b/Marks/Services/AnalyticsService.swift @@ -18,10 +18,10 @@ enum Analytics { let (_, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 if status < 200 || status > 299 { - print("[Analytics] \(event) → HTTP \(status)") + Log.analytics.error("\(event, privacy: .public) → HTTP \(status, privacy: .public)") } } catch { - print("[Analytics] \(event) failed: \(error.localizedDescription)") + Log.analytics.error("\(event, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") } } } diff --git a/Marks/Services/BookmarkAssistant.swift b/Marks/Services/BookmarkAssistant.swift new file mode 100644 index 0000000..0c8af17 --- /dev/null +++ b/Marks/Services/BookmarkAssistant.swift @@ -0,0 +1,81 @@ +import Foundation +import Observation +import FoundationModels + +/// Generation half of on-device RAG. Wraps a `LanguageModelSession` configured +/// with `BookmarkSearchTool`, so every question is answered by Apple's +/// on-device model grounded in the user's own bookmarks — no network, no +/// third-party LLM. Mirrors the `ClaudeService` role, but fully on-device. +@MainActor +@Observable +final class BookmarkAssistant { + enum Status: Equatable { + case checking + case ready + case unavailable(String) + } + + private(set) var status: Status = .checking + private var session: LanguageModelSession? + + init() { configure() } + + private func configure() { + switch SystemLanguageModel.default.availability { + case .available: + session = LanguageModelSession( + tools: [BookmarkSearchTool()], + instructions: """ + You are an assistant inside a bookmarks app. The user's saved bookmarks \ + are your only source of truth. To answer any question, call the \ + searchBookmarks tool to look things up — never rely on outside knowledge \ + or invent links. Ground every answer in the returned bookmarks, refer to \ + them by title, and include their URLs when relevant. If nothing relevant \ + is found, say so plainly. Keep answers concise. + """ + ) + status = .ready + Log.assistant.info("On-device model available; session ready") + case .unavailable(let reason): + status = .unavailable(Self.message(for: reason)) + Log.assistant.notice("On-device model unavailable: \(String(describing: reason), privacy: .public)") + } + } + + /// Ask a question; the model retrieves from Spotlight via the tool as needed. + func ask(_ question: String) async throws -> String { + guard let session else { + Log.assistant.error("ask() with no session (model unavailable)") + throw AssistantError.unavailable + } + Log.assistant.info("Ask: \(question, privacy: .public)") + let start = Date() + do { + let answer = try await session.respond(to: question).content + let ms = Int(Date().timeIntervalSince(start) * 1000) + Log.assistant.info("Answered in \(ms, privacy: .public)ms, \(answer.count, privacy: .public) chars") + return answer + } catch { + Log.assistant.error("Ask failed: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + private static func message(for reason: SystemLanguageModel.Availability.UnavailableReason) -> String { + switch reason { + case .deviceNotEligible: + return "This device doesn't support Apple Intelligence." + case .appleIntelligenceNotEnabled: + return "Turn on Apple Intelligence in Settings to ask your bookmarks." + case .modelNotReady: + return "The on-device model is still downloading. Try again shortly." + @unknown default: + return "On-device intelligence isn't available right now." + } + } + + enum AssistantError: LocalizedError { + case unavailable + var errorDescription: String? { "On-device intelligence is unavailable." } + } +} diff --git a/Marks/Services/BookmarkSearchTool.swift b/Marks/Services/BookmarkSearchTool.swift new file mode 100644 index 0000000..199e5a1 --- /dev/null +++ b/Marks/Services/BookmarkSearchTool.swift @@ -0,0 +1,37 @@ +import Foundation +import FoundationModels + +/// The tool the on-device model calls to look things up in the user's library. +/// It is the bridge between `LanguageModelSession` (generation) and the +/// Spotlight index (retrieval) — together they form on-device RAG over the +/// private bookmark corpus. +struct BookmarkSearchTool: Tool { + let name = "searchBookmarks" + let description = """ + Search the user's saved bookmarks. Returns the most relevant bookmarks with \ + their title, website, description, and URL. Call this for any question about \ + what the user has saved, read, or wants to find again. + """ + + @Generable + struct Arguments { + @Guide(description: "Keywords or a short phrase describing what to look for in the saved bookmarks") + var query: String + } + + func call(arguments: Arguments) async throws -> String { + Log.tool.info("searchBookmarks invoked query=\(arguments.query, privacy: .public)") + let hits = await SpotlightBookmarkSearch.run(query: arguments.query, limit: 8) + Log.tool.info("searchBookmarks returned \(hits.count, privacy: .public) bookmarks") + guard !hits.isEmpty else { + return "No bookmarks matched \"\(arguments.query)\"." + } + return hits.enumerated().map { idx, b in + var line = "\(idx + 1). \(b.title)" + if !b.host.isEmpty { line += " — \(b.host)" } + if !b.description.isEmpty { line += "\n \(b.description)" } + if !b.url.isEmpty { line += "\n \(b.url)" } + return line + }.joined(separator: "\n") + } +} diff --git a/Marks/Services/ClaudeService.swift b/Marks/Services/ClaudeService.swift index 1ea39ad..5179cb4 100644 --- a/Marks/Services/ClaudeService.swift +++ b/Marks/Services/ClaudeService.swift @@ -95,12 +95,13 @@ struct ClaudeService: Sendable { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) - print("[AI] POST \(path)") + Log.network.debug("POST \(path, privacy: .public)") let (data, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - print("[AI] POST \(path) → \(status)") + Log.network.info("POST \(path, privacy: .public) → \(status, privacy: .public)") guard (200...299).contains(status) else { - print("[AI] error: \(String(data: data, encoding: .utf8)?.prefix(200) ?? "")") + let bodyText = String(data: data, encoding: .utf8)?.prefix(400) ?? "" + Log.network.error("POST \(path, privacy: .public) → \(status, privacy: .public) body=\(bodyText, privacy: .public)") throw APIError.badStatus(status) } return data @@ -111,11 +112,13 @@ struct ClaudeService: Sendable { guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl } var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - print("[AI] GET \(path)") + Log.network.debug("GET \(path, privacy: .public)") let (data, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - print("[AI] GET \(path) → \(status)") + Log.network.info("GET \(path, privacy: .public) → \(status, privacy: .public)") guard (200...299).contains(status) else { + let bodyText = String(data: data, encoding: .utf8)?.prefix(400) ?? "" + Log.network.error("GET \(path, privacy: .public) → \(status, privacy: .public) body=\(bodyText, privacy: .public)") throw APIError.badStatus(status) } return data diff --git a/Marks/Services/Log.swift b/Marks/Services/Log.swift new file mode 100644 index 0000000..1390796 --- /dev/null +++ b/Marks/Services/Log.swift @@ -0,0 +1,43 @@ +import Foundation +import os + +/// Organized, category-based logging for Marks, backed by `os.Logger`. +/// +/// Unlike `print()` — which only reaches stdout and is invisible to +/// `log collect` / Console.app — these entries are captured under the +/// `com.magicive.marks` subsystem and can be filtered by category off-device. +/// +/// Pull logs from a connected device: +/// ``` +/// log collect --device-udid --last 5m --output marks.logarchive +/// log show marks.logarchive --info --debug \ +/// --predicate 'subsystem == "com.magicive.marks"' +/// # one category: +/// log show marks.logarchive --predicate \ +/// 'subsystem == "com.magicive.marks" AND category == "assistant"' +/// ``` +/// +/// Privacy: `os.Logger` redacts interpolations to `` by default. The +/// on-device RAG path (`assistant`, `tool`, `spotlight`) logs queries/counts as +/// `.public` so the feature is debuggable; bookmark *content* in the cloud +/// enrichment path stays default-private. +enum Log { + private static let subsystem = "com.magicive.marks" + + /// On-device RAG: model availability, questions asked, answers produced. + static let assistant = Logger(subsystem: subsystem, category: "assistant") + /// The `searchBookmarks` tool the on-device model calls. + static let tool = Logger(subsystem: subsystem, category: "tool") + /// Spotlight retrieval (`CSSearchQuery`) and indexing (`indexAppEntities`). + static let spotlight = Logger(subsystem: subsystem, category: "spotlight") + /// Cloud AI enrichment (summaries, tags) via the backend. + static let ai = Logger(subsystem: subsystem, category: "ai") + /// Bookmark sync / disk cache. + static let sync = Logger(subsystem: subsystem, category: "sync") + /// Backend HTTP primitives (requests, status codes, error bodies). + static let network = Logger(subsystem: subsystem, category: "network") + /// Anonymous device auth / token registration. + static let auth = Logger(subsystem: subsystem, category: "auth") + /// Product analytics event delivery. + static let analytics = Logger(subsystem: subsystem, category: "analytics") +} diff --git a/Marks/Services/MarksAuth.swift b/Marks/Services/MarksAuth.swift index 6ea67d3..7d1416b 100644 --- a/Marks/Services/MarksAuth.swift +++ b/Marks/Services/MarksAuth.swift @@ -46,7 +46,7 @@ enum MarksAuth { let resp = try JSONDecoder().decode(Resp.self, from: data) UserDefaults.standard.set(resp.access_token, forKey: tokenKey) UserDefaults.standard.set(Date().addingTimeInterval(TimeInterval(resp.expires_in)), forKey: tokenExpiryKey) - print("[Auth] Registered \(id.prefix(8))…") + Log.auth.info("Registered device \(id.prefix(8), privacy: .public)…") return resp.access_token } } diff --git a/Marks/Services/SpotlightBookmarkSearch.swift b/Marks/Services/SpotlightBookmarkSearch.swift new file mode 100644 index 0000000..bb55a6d --- /dev/null +++ b/Marks/Services/SpotlightBookmarkSearch.swift @@ -0,0 +1,70 @@ +import Foundation +import CoreSpotlight + +/// A bookmark retrieved from the Spotlight index, reduced to the fields the +/// on-device model needs to ground an answer. +struct RetrievedBookmark: Sendable { + let title: String + let host: String + let description: String + let url: String +} + +/// Retrieval half of on-device RAG: queries the Spotlight index that +/// `SpotlightIndexer` populated and returns the best-matching bookmarks. The +/// search runs entirely on-device against the app's own indexed entities. +enum SpotlightBookmarkSearch { + static func run(query rawQuery: String, limit: Int) async -> [RetrievedBookmark] { + guard CSSearchableIndex.isIndexingAvailable() else { + Log.spotlight.notice("Search skipped: indexing unavailable") + return [] + } + let queryString = makeQueryString(from: rawQuery) + guard !queryString.isEmpty else { return [] } + Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)") + + let context = CSSearchQueryContext() + context.fetchAttributes = ["title", "contentDescription", "keywords", "url"] + let query = CSSearchQuery(queryString: queryString, queryContext: context) + + var out: [RetrievedBookmark] = [] + do { + for try await result in query.results { + let a = result.item.attributeSet + out.append(RetrievedBookmark( + title: a.title ?? "Untitled", + host: a.url?.host() ?? "", + description: a.contentDescription ?? "", + url: a.url?.absoluteString ?? "" + )) + if out.count >= limit { break } + } + Log.spotlight.info("Search \"\(rawQuery, privacy: .public)\" → \(out.count, privacy: .public) hits") + } catch { + Log.spotlight.error("Search failed: \(error.localizedDescription, privacy: .public)") + } + query.cancel() + return out + } + + /// Build a CoreSpotlight query string that OR-matches each significant token + /// across title / description / keywords, case- and diacritic-insensitive. + private static func makeQueryString(from raw: String) -> String { + let tokens = raw + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .map { $0.lowercased() } + .filter { $0.count >= 3 } + let terms = (tokens.isEmpty ? [raw] : tokens).map(sanitize).filter { !$0.isEmpty } + let fields = ["title", "contentDescription", "keywords"] + let clauses = terms.flatMap { term in + fields.map { "\($0) == \"*\(term)*\"cd" } + } + guard !clauses.isEmpty else { return "" } + return "(" + clauses.joined(separator: " || ") + ")" + } + + /// Keep only alphanumerics so a token can't break the query string syntax. + private static func sanitize(_ s: String) -> String { + String(String.UnicodeScalarView(s.unicodeScalars.filter(CharacterSet.alphanumerics.contains))) + } +} diff --git a/Marks/Services/SpotlightIndexer.swift b/Marks/Services/SpotlightIndexer.swift new file mode 100644 index 0000000..df679c4 --- /dev/null +++ b/Marks/Services/SpotlightIndexer.swift @@ -0,0 +1,40 @@ +import Foundation +import CoreSpotlight +import AppIntents + +/// Pushes Linkding bookmarks into the system Spotlight index via the +/// `IndexedEntity` conformance on `BookmarkEntity`. +/// +/// This is the step that actually makes bookmarks discoverable: conforming to +/// `IndexedEntity` only describes *how* an entity would be indexed (its +/// `attributeSet`) — nothing reaches Spotlight until the entities are handed to +/// `CSSearchableIndex`. Once indexed, bookmarks show up in Spotlight search and +/// are reachable by Siri / Apple Intelligence. +/// +/// All calls are best-effort: failures are logged, never surfaced. If indexing +/// is unavailable or fails, search degrades but nothing else breaks. +enum SpotlightIndexer { + /// Insert or update the given bookmarks in the Spotlight index. + static func index(_ bookmarks: [Bookmark]) async { + guard CSSearchableIndex.isIndexingAvailable(), !bookmarks.isEmpty else { return } + do { + try await CSSearchableIndex.default() + .indexAppEntities(bookmarks.map(BookmarkEntity.init(from:))) + Log.spotlight.info("Indexed \(bookmarks.count, privacy: .public) bookmarks") + } catch { + Log.spotlight.error("Index failed: \(error.localizedDescription, privacy: .public)") + } + } + + /// Remove bookmarks from the index by id (e.g. after delete / archive). + static func remove(ids: [Int]) async { + guard CSSearchableIndex.isIndexingAvailable(), !ids.isEmpty else { return } + do { + try await CSSearchableIndex.default() + .deleteAppEntities(identifiedBy: ids, ofType: BookmarkEntity.self) + Log.spotlight.info("Removed \(ids.count, privacy: .public) bookmarks") + } catch { + Log.spotlight.error("Delete failed: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/Marks/Views/AskView.swift b/Marks/Views/AskView.swift new file mode 100644 index 0000000..3988741 --- /dev/null +++ b/Marks/Views/AskView.swift @@ -0,0 +1,152 @@ +import SwiftUI + +/// "Ask Your Bookmarks" — the on-device RAG surface. Questions are answered by +/// Apple's on-device model, grounded in the user's bookmarks via Spotlight. +struct AskView: View { + @State private var assistant = BookmarkAssistant() + @State private var question = "" + @State private var answer = "" + @State private var isLoading = false + @State private var errorText: String? + @Environment(\.dismiss) private var dismiss + @FocusState private var focused: Bool + + var body: some View { + NavigationStack { + Group { + switch assistant.status { + case .checking: + ProgressView() + case .unavailable(let message): + ContentUnavailableView( + "Unavailable", + systemImage: "sparkles.slash", + description: Text(message) + ) + case .ready: + ready + } + } + .navigationTitle("Ask Your Bookmarks") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } + + private var ready: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if answer.isEmpty && !isLoading && errorText == nil { + ContentUnavailableView { + Label("Ask anything", systemImage: "sparkles") + } description: { + Text("Answers come from your saved bookmarks, generated on-device.") + } + .padding(.top, 40) + } + if isLoading { + HStack(spacing: 8) { + ProgressView() + Text("Searching your bookmarks…").foregroundStyle(.secondary) + } + } + if !answer.isEmpty { + MarkdownAnswer(text: answer) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + if let errorText { + Text(errorText).foregroundStyle(.red) + } + } + .padding() + } + + HStack(spacing: 10) { + TextField("Ask about your bookmarks…", text: $question, axis: .vertical) + .lineLimit(1...4) + .focused($focused) + .submitLabel(.send) + .onSubmit(send) + Button(action: send) { + Image(systemName: "arrow.up.circle.fill").font(.title2) + } + .disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) + } + .padding() + .background(.bar) + } + .onAppear { focused = true } + } + + private func send() { + let q = question.trimmingCharacters(in: .whitespacesAndNewlines) + guard !q.isEmpty, !isLoading else { return } + question = "" + answer = "" + errorText = nil + isLoading = true + Task { + do { + answer = try await assistant.ask(q) + } catch { + errorText = error.localizedDescription + } + isLoading = false + } + } +} + +/// Renders the model's answer as lightweight Markdown. SwiftUI's `Text` only +/// parses *inline* Markdown (bold/italic/code/links), so we split into block +/// elements — headings, bullet/numbered lists, paragraphs — and lay them out, +/// applying inline parsing per line. +private struct MarkdownAnswer: View { + let text: String + + private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + switch block { + case .heading(let line): + inline(line).font(.headline) + case .bullet(let line): + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("•").foregroundStyle(.secondary) + inline(line) + } + case .paragraph(let line): + inline(line) + } + } + } + } + + private var blocks: [Block] { + text.split(separator: "\n", omittingEmptySubsequences: true).map { raw in + let line = raw.trimmingCharacters(in: .whitespaces) + if let r = line.range(of: "^#{1,6}\\s+", options: .regularExpression) { + return .heading(String(line[r.upperBound...])) + } + if let r = line.range(of: "^([-*+]|\\d+\\.)\\s+", options: .regularExpression) { + return .bullet(String(line[r.upperBound...])) + } + return .paragraph(line) + } + } + + private func inline(_ s: String) -> Text { + let opts = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + if let attr = try? AttributedString(markdown: s, options: opts) { + return Text(attr) + } + return Text(s) + } +} diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 635ae7b..8053b19 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -59,6 +59,7 @@ struct BookmarksView: View { @State private var browsingBookmark: Bookmark? @State private var showFullPlayer = false @State private var showPodcastLibrary = false + @State private var showAsk = false @State private var readingProgress: [String: Double] = ReadingProgress.all() var body: some View { @@ -95,6 +96,11 @@ struct BookmarksView: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Menu { + Button { + showAsk = true + } label: { + Label("Ask Your Bookmarks", systemImage: "bubble.left.and.text.bubble.right") + } Button { Task { await viewModel.generateSmartCollections() } showCollections = true @@ -167,6 +173,9 @@ struct BookmarksView: View { .sheet(isPresented: $showCollections) { CollectionsView(viewModel: viewModel) } + .sheet(isPresented: $showAsk) { + AskView() + } .sheet(isPresented: $showAddBookmark) { AddBookmarkView(viewModel: viewModel) } diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index d0ecc03..790a002 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -42,6 +42,7 @@ final class BookmarksViewModel { WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map { WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl) }) + Task { await SpotlightIndexer.index(bookmarks) } } catch { self.error = error.localizedDescription } @@ -57,6 +58,8 @@ final class BookmarksViewModel { bookmarks.append(contentsOf: response.results) nextPageUrl = response.next restoreAIData() + let added = response.results + Task { await SpotlightIndexer.index(added) } } catch { self.error = error.localizedDescription } @@ -102,21 +105,24 @@ final class BookmarksViewModel { let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false) let bookmark = try await api.createBookmark(create) bookmarks.insert(bookmark, at: 0) - print("[AI] addBookmark: saved id=\(bookmark.id)") + Task { await SpotlightIndexer.index([bookmark]) } + Log.ai.info("addBookmark saved id=\(bookmark.id, privacy: .public)") Task { - print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)") + Log.ai.info("addBookmark enrich start id=\(bookmark.id, privacy: .public)") do { let (summary, aiTags) = try await claude.enrich(bookmark: bookmark) - print("[AI] addBookmark: enrich success summary=\(summary.prefix(60)) tags=\(aiTags)") + Log.ai.info("addBookmark enrich ok id=\(bookmark.id, privacy: .public) tags=\(aiTags.count, privacy: .public)") guard let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { - print("[AI] addBookmark: bookmark id=\(bookmark.id) not found in list after enrich") + Log.ai.notice("addBookmark id=\(bookmark.id, privacy: .public) gone before enrich applied") return } bookmarks[i].aiSummary = summary bookmarks[i].aiTags = aiTags saveAIData(for: bookmarks[i]) + let enriched = bookmarks[i] + Task { await SpotlightIndexer.index([enriched]) } } catch { - print("[AI] addBookmark: enrich FAILED \(error)") + Log.ai.error("addBookmark enrich failed: \(error.localizedDescription, privacy: .public)") self.error = "AI enrichment failed: \(error.localizedDescription)" } } @@ -126,6 +132,7 @@ final class BookmarksViewModel { do { try await api.deleteBookmark(id: bookmark.id) bookmarks.removeAll { $0.id == bookmark.id } + Task { await SpotlightIndexer.remove(ids: [bookmark.id]) } } catch { self.error = error.localizedDescription } @@ -135,6 +142,7 @@ final class BookmarksViewModel { do { try await api.archiveBookmark(id: bookmark.id) bookmarks.removeAll { $0.id == bookmark.id } + Task { await SpotlightIndexer.remove(ids: [bookmark.id]) } } catch { self.error = error.localizedDescription } @@ -180,6 +188,8 @@ final class BookmarksViewModel { bookmarks[i].aiSummary = summary bookmarks[i].aiTags = tags saveAIData(for: bookmarks[i]) + let enriched = bookmarks[i] + Task { await SpotlightIndexer.index([enriched]) } enrichmentProgress = Double(done + 1) / Double(toEnrich.count) } catch { break @@ -196,23 +206,25 @@ final class BookmarksViewModel { enrichTask = Task { [weak self] in guard let self else { return } let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) } - print("[AI] startEnrichment: \(indices.count) bookmarks to enrich") + Log.ai.info("startEnrichment: \(indices.count, privacy: .public) to enrich") for i in indices { guard !Task.isCancelled else { return } let claude = await MainActor.run(body: { self.claude }) do { let bm = await MainActor.run { bookmarks[i] } - print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)") + Log.ai.debug("startEnrichment enriching id=\(bm.id, privacy: .public)") let (summary, tags) = try await claude.enrich(bookmark: bm) - print("[AI] startEnrichment: done id=\(bm.id) summary=\(summary.prefix(60))") + Log.ai.debug("startEnrichment done id=\(bm.id, privacy: .public)") await MainActor.run { guard i < bookmarks.count else { return } bookmarks[i].aiSummary = summary bookmarks[i].aiTags = tags saveAIData(for: bookmarks[i]) + let enriched = bookmarks[i] + Task { await SpotlightIndexer.index([enriched]) } } } catch { - print("[AI] startEnrichment: enrich FAILED id=\(i) \(error)") + Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)") break } } @@ -239,7 +251,7 @@ final class BookmarksViewModel { let data = try Self.cacheEncoder.encode(list) try data.write(to: cacheFileUrl, options: .atomic) } catch { - print("[Cache] saveToCache failed: \(error)") + Log.sync.error("saveToCache failed: \(error.localizedDescription, privacy: .public)") } } diff --git a/project.yml b/project.yml index 5361619..a235e3d 100644 --- a/project.yml +++ b/project.yml @@ -89,6 +89,7 @@ targets: - path: Marks/Models/ServerConfig.swift - path: Marks/Services/LinkdingAPI.swift - path: Marks/Services/MarksAuth.swift + - path: Marks/Services/Log.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension