feat(ai): on-device RAG over bookmarks + Spotlight indexing + unified logging
All checks were successful
CI / build-and-deploy (push) Successful in 22s

- 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) <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-06-24 12:55:32 -05:00
parent e82e69c5f3
commit 96ea5fe6f6
14 changed files with 499 additions and 18 deletions

View File

@@ -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)")
}
}
}

View File

@@ -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." }
}
}

View File

@@ -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")
}
}

View File

@@ -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

43
Marks/Services/Log.swift Normal file
View File

@@ -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 <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 `<private>` 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")
}

View File

@@ -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
}
}

View File

@@ -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)))
}
}

View File

@@ -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)")
}
}
}