SPB Git

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%
5.6 KB · 148 lines swift
Raw Blame History
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