spb/prisme Public MIT
Navigateur iOS intelligent — chaque page comprise localement avant d'être affichée. SwiftUI · WebKit · Foundation Models, 100% on-device.
Swift 96.7%
JavaScript 3.3%
1//2// HistoryStore.swift3// Prisme4//5// Author: Simon-Pierre Boucher <contact@spboucher.ai>6//78import CryptoKit9import Foundation10import Observation11import SwiftData1213/// The semantic history: records distilled visits and answers natural14/// language queries locally. Ranking blends vector similarity, keyword15/// hits and recency — no network, no model quota, works offline.16@MainActor17@Observable18final class HistoryStore {19 private let container: ModelContainer20 private let embedder = SentenceEmbedder()21 private let snapshotsDirectory: URL2223 private var context: ModelContext { container.mainContext }2425 init() {26 let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]27 do {28 // Explicit store URL: every SwiftData container in the app gets29 // its own file, or they would clash on the default store.30 let config = ModelConfiguration(url: base.appendingPathComponent("history.store"))31 container = try ModelContainer(for: PageVisit.self, configurations: config)32 } catch {33 // A corrupt store must never take the browser down; degrade to34 // an in-memory history for this session.35 let memoryOnly = ModelConfiguration(isStoredInMemoryOnly: true)36 container = try! ModelContainer(for: PageVisit.self, configurations: memoryOnly)37 }38 snapshotsDirectory = base.appendingPathComponent("Snapshots", isDirectory: true)39 try? FileManager.default.createDirectory(at: snapshotsDirectory, withIntermediateDirectories: true)40 }4142 // MARK: - Recording4344 /// Records a finished page load. Callers must have already excluded45 /// sensitive containers — this store never sees them.46 func record(url: URL, content: ExtractedContent, containerID: UUID) {47 guard url.scheme == "https" || url.scheme == "http" else { return }48 let title = content.title.isEmpty ? (url.host() ?? url.absoluteString) : content.title49 let gist = content.leadSentence ?? ""50 let hash = DigestCache.key(for: content)51 let searchable = "\(title). \(gist)"52 let language = embedder.languageCode(for: searchable)53 let vector = embedder.embed(searchable, languageCode: language)5455 if let existing = visit(for: url.absoluteString) {56 existing.title = title57 existing.gist = gist58 existing.visitedAt = Date()59 existing.visitCount += 160 existing.contentHash = hash61 existing.languageCode = language62 existing.embedding = vector63 } else {64 context.insert(PageVisit(65 urlString: url.absoluteString,66 host: url.host() ?? "",67 title: title,68 gist: gist,69 containerID: containerID,70 visitedAt: Date(),71 contentHash: hash,72 languageCode: language,73 embedding: vector74 ))75 }76 try? context.save()77 writeSnapshot(content, urlString: url.absoluteString)78 }7980 // MARK: - Recall8182 var recent: [PageVisit] {83 var descriptor = FetchDescriptor<PageVisit>(84 sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]85 )86 descriptor.fetchLimit = 20087 return (try? context.fetch(descriptor)) ?? []88 }8990 /// Natural language search. Vector similarity within the query's91 /// language, keyword matching across everything, recent pages float up.92 func search(_ query: String, limit: Int = 20) -> [PageVisit] {93 let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)94 guard trimmed.count >= 2 else { return [] }9596 let lowered = trimmed.lowercased()97 let queryLanguage = embedder.languageCode(for: trimmed)98 let queryVector = embedder.embed(trimmed, languageCode: queryLanguage)99100 let scored: [(PageVisit, Float)] = recent.compactMap { visit in101 var score: Float = 0102103 if let queryVector,104 let vector = visit.embedding,105 visit.languageCode == queryLanguage {106 score += 0.6 * max(0, SentenceEmbedder.cosine(queryVector, vector))107 }108109 let haystack = "\(visit.title) \(visit.host) \(visit.gist)".lowercased()110 let words = lowered.split(separator: " ")111 let hits = words.filter { haystack.contains($0) }.count112 if !words.isEmpty {113 score += 0.3 * Float(hits) / Float(words.count)114 }115116 // Half-life of ~30 days keeps yesterday above last spring.117 let age = Date().timeIntervalSince(visit.visitedAt)118 score += 0.1 * Float(exp(-age / (30 * 24 * 3600)))119120 return score > 0.08 ? (visit, score) : nil121 }122123 return scored124 .sorted { $0.1 > $1.1 }125 .prefix(limit)126 .map(\.0)127 }128129 // MARK: - Snapshots (durable text, feeds the future temporal diff)130131 private func writeSnapshot(_ content: ExtractedContent, urlString: String) {132 let name = SHA256.hash(data: Data(urlString.utf8))133 .map { String(format: "%02x", $0) }.joined()134 let file = snapshotsDirectory.appendingPathComponent("\(name).txt")135 let text = ([content.title] + content.blocks.map(\.text)).joined(separator: "\n\n")136 try? text.write(to: file, atomically: true, encoding: .utf8)137 }138139 private func visit(for urlString: String) -> PageVisit? {140 var descriptor = FetchDescriptor<PageVisit>(141 predicate: #Predicate { $0.urlString == urlString }142 )143 descriptor.fetchLimit = 1144 return (try? context.fetch(descriptor))?.first145 }146147}148