SPB Git

spb/poche Public

Agent personnel 100 % on-device — SwiftUI + Apple Foundation Models + EventKit + SwiftData. Aucune API, aucun serveur.

Swift 100%
4.9 KB · 147 lines swift
Raw Blame History
1//2//  PocheStore.swift3//  Poche4//5//  Author:  Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//89import Foundation10import SwiftData1112/// Sendable snapshot handed to tools — @Model classes must not cross13/// actor boundaries.14struct TaskSnapshot: Sendable {15    let uuid: UUID16    let title: String17    let due: Date?18    let isDone: Bool19}2021/// Sendable document for the semantic index and searchMyData.22struct SearchDocument: Sendable {23    let kind: String24    let title: String25    let body: String2627    var text: String { "\(title). \(body)" }28}2930enum StoreError: Error {31    case taskNotFound32}3334/// Local store. Write methods are called by ActionExecutor only —35/// tools go through the Confirm layer, never here (CLAUDE.md §3).36@MainActor37final class PocheStore {38    private let context: ModelContext3940    init(container: ModelContainer) {41        self.context = container.mainContext42    }4344    // MARK: - Writes (ActionExecutor only)4546    @discardableResult47    func addNote(title: String, content: String) -> Note {48        let note = Note(title: title, content: content)49        context.insert(note)50        try? context.save()51        return note52    }5354    @discardableResult55    func addTask(title: String, details: String?, due: Date?) -> TaskItem {56        let task = TaskItem(title: title, details: details, dueDate: due)57        context.insert(task)58        try? context.save()59        return task60    }6162    func updateTask(uuid: UUID, newTitle: String?, newDue: Date?, markDone: Bool?) throws {63        let descriptor = FetchDescriptor<TaskItem>(predicate: #Predicate { $0.uuid == uuid })64        guard let task = try? context.fetch(descriptor).first else {65            throw StoreError.taskNotFound66        }67        if let newTitle { task.title = newTitle }68        if let newDue { task.dueDate = newDue }69        if let markDone { task.isDone = markDone }70        task.updatedAt = .now71        try? context.save()72    }7374    // MARK: - Conversation persistence7576    func newConversation() -> ConversationRecord {77        let record = ConversationRecord()78        context.insert(record)79        try? context.save()80        return record81    }8283    func appendTurn(to conversation: ConversationRecord, role: String, text: String) {84        let turn = TurnRecord(role: role, text: text)85        turn.conversation = conversation86        context.insert(turn)87        try? context.save()88    }8990    /// Persists the condensation summary: long-term memory lives in91    /// SwiftData, searchable across sessions via `searchMyData`92    /// (CLAUDE.md §5).93    func updateSummary(of conversation: ConversationRecord, to summary: String) {94        conversation.summary = summary95        try? context.save()96    }9798    /// Imports notes shared into Poche from other apps (Share Extension99    /// inbox). The user explicitly shared each item — that gesture is the100    /// confirmation; the model is not involved (CLAUDE.md §3 concerns the101    /// model's writes).102    func importSharedInbox(from url: URL? = SharedInbox.url) {103        for item in SharedInbox.drain(at: url) {104            addNote(title: item.title, content: item.content)105        }106    }107108    // MARK: - Reads (tools receive Sendable snapshots)109110    func findTasks(matching query: String, limit: Int = 5) -> [TaskSnapshot] {111        let descriptor = FetchDescriptor<TaskItem>(sortBy: [SortDescriptor(\.updatedAt, order: .reverse)])112        let all = (try? context.fetch(descriptor)) ?? []113        let needle = query.lowercased()114        return all115            .filter { $0.title.lowercased().contains(needle) }116            .prefix(limit)117            .map { TaskSnapshot(uuid: $0.uuid, title: $0.title, due: $0.dueDate, isDone: $0.isDone) }118    }119120    /// Full searchable corpus for `searchMyData`. Bodies are clipped: tool121    /// output is budgeted, and so is what feeds it.122    func corpus() -> [SearchDocument] {123        var documents: [SearchDocument] = []124125        let notes = (try? context.fetch(FetchDescriptor<Note>())) ?? []126        for note in notes {127            documents.append(SearchDocument(kind: "note", title: note.title, body: String(note.content.prefix(300))))128        }129130        let tasks = (try? context.fetch(FetchDescriptor<TaskItem>())) ?? []131        for task in tasks {132            let due = task.dueDate.map { " — échéance \(DateResolver.display($0))" } ?? ""133            let state = task.isDone ? "faite" : "à faire"134            documents.append(SearchDocument(kind: "tâche", title: task.title, body: "\(state)\(due). \(task.details ?? "")"))135        }136137        let conversations = (try? context.fetch(FetchDescriptor<ConversationRecord>())) ?? []138        for conversation in conversations {139            if let summary = conversation.summary, !summary.isEmpty {140                documents.append(SearchDocument(kind: "conversation", title: "Conversation", body: String(summary.prefix(300))))141            }142        }143144        return documents145    }146}147