// // HistoryStore.swift // Prisme // // Author: Simon-Pierre Boucher // import CryptoKit import Foundation import Observation import SwiftData /// The semantic history: records distilled visits and answers natural /// language queries locally. Ranking blends vector similarity, keyword /// hits and recency — no network, no model quota, works offline. @MainActor @Observable final class HistoryStore { private let container: ModelContainer private let embedder = SentenceEmbedder() private let snapshotsDirectory: URL private var context: ModelContext { container.mainContext } init() { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] do { // Explicit store URL: every SwiftData container in the app gets // its own file, or they would clash on the default store. let config = ModelConfiguration(url: base.appendingPathComponent("history.store")) container = try ModelContainer(for: PageVisit.self, configurations: config) } catch { // A corrupt store must never take the browser down; degrade to // an in-memory history for this session. let memoryOnly = ModelConfiguration(isStoredInMemoryOnly: true) container = try! ModelContainer(for: PageVisit.self, configurations: memoryOnly) } snapshotsDirectory = base.appendingPathComponent("Snapshots", isDirectory: true) try? FileManager.default.createDirectory(at: snapshotsDirectory, withIntermediateDirectories: true) } // MARK: - Recording /// Records a finished page load. Callers must have already excluded /// sensitive containers — this store never sees them. func record(url: URL, content: ExtractedContent, containerID: UUID) { guard url.scheme == "https" || url.scheme == "http" else { return } let title = content.title.isEmpty ? (url.host() ?? url.absoluteString) : content.title let gist = content.leadSentence ?? "" let hash = DigestCache.key(for: content) let searchable = "\(title). \(gist)" let language = embedder.languageCode(for: searchable) let vector = embedder.embed(searchable, languageCode: language) if let existing = visit(for: url.absoluteString) { existing.title = title existing.gist = gist existing.visitedAt = Date() existing.visitCount += 1 existing.contentHash = hash existing.languageCode = language existing.embedding = vector } else { context.insert(PageVisit( urlString: url.absoluteString, host: url.host() ?? "", title: title, gist: gist, containerID: containerID, visitedAt: Date(), contentHash: hash, languageCode: language, embedding: vector )) } try? context.save() writeSnapshot(content, urlString: url.absoluteString) } // MARK: - Recall var recent: [PageVisit] { var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] ) descriptor.fetchLimit = 200 return (try? context.fetch(descriptor)) ?? [] } /// Natural language search. Vector similarity within the query's /// language, keyword matching across everything, recent pages float up. func search(_ query: String, limit: Int = 20) -> [PageVisit] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.count >= 2 else { return [] } let lowered = trimmed.lowercased() let queryLanguage = embedder.languageCode(for: trimmed) let queryVector = embedder.embed(trimmed, languageCode: queryLanguage) let scored: [(PageVisit, Float)] = recent.compactMap { visit in var score: Float = 0 if let queryVector, let vector = visit.embedding, visit.languageCode == queryLanguage { score += 0.6 * max(0, SentenceEmbedder.cosine(queryVector, vector)) } let haystack = "\(visit.title) \(visit.host) \(visit.gist)".lowercased() let words = lowered.split(separator: " ") let hits = words.filter { haystack.contains($0) }.count if !words.isEmpty { score += 0.3 * Float(hits) / Float(words.count) } // Half-life of ~30 days keeps yesterday above last spring. let age = Date().timeIntervalSince(visit.visitedAt) score += 0.1 * Float(exp(-age / (30 * 24 * 3600))) return score > 0.08 ? (visit, score) : nil } return scored .sorted { $0.1 > $1.1 } .prefix(limit) .map(\.0) } // MARK: - Snapshots (durable text, feeds the future temporal diff) private func writeSnapshot(_ content: ExtractedContent, urlString: String) { let name = SHA256.hash(data: Data(urlString.utf8)) .map { String(format: "%02x", $0) }.joined() let file = snapshotsDirectory.appendingPathComponent("\(name).txt") let text = ([content.title] + content.blocks.map(\.text)).joined(separator: "\n\n") try? text.write(to: file, atomically: true, encoding: .utf8) } private func visit(for urlString: String) -> PageVisit? { var descriptor = FetchDescriptor( predicate: #Predicate { $0.urlString == urlString } ) descriptor.fetchLimit = 1 return (try? context.fetch(descriptor))?.first } }