// // PocheStore.swift // Poche // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // import Foundation import SwiftData /// Sendable snapshot handed to tools — @Model classes must not cross /// actor boundaries. struct TaskSnapshot: Sendable { let uuid: UUID let title: String let due: Date? let isDone: Bool } /// Sendable document for the semantic index and searchMyData. struct SearchDocument: Sendable { let kind: String let title: String let body: String var text: String { "\(title). \(body)" } } enum StoreError: Error { case taskNotFound } /// Local store. Write methods are called by ActionExecutor only — /// tools go through the Confirm layer, never here (CLAUDE.md §3). @MainActor final class PocheStore { private let context: ModelContext init(container: ModelContainer) { self.context = container.mainContext } // MARK: - Writes (ActionExecutor only) @discardableResult func addNote(title: String, content: String) -> Note { let note = Note(title: title, content: content) context.insert(note) try? context.save() return note } @discardableResult func addTask(title: String, details: String?, due: Date?) -> TaskItem { let task = TaskItem(title: title, details: details, dueDate: due) context.insert(task) try? context.save() return task } func updateTask(uuid: UUID, newTitle: String?, newDue: Date?, markDone: Bool?) throws { let descriptor = FetchDescriptor(predicate: #Predicate { $0.uuid == uuid }) guard let task = try? context.fetch(descriptor).first else { throw StoreError.taskNotFound } if let newTitle { task.title = newTitle } if let newDue { task.dueDate = newDue } if let markDone { task.isDone = markDone } task.updatedAt = .now try? context.save() } // MARK: - Conversation persistence func newConversation() -> ConversationRecord { let record = ConversationRecord() context.insert(record) try? context.save() return record } func appendTurn(to conversation: ConversationRecord, role: String, text: String) { let turn = TurnRecord(role: role, text: text) turn.conversation = conversation context.insert(turn) try? context.save() } /// Persists the condensation summary: long-term memory lives in /// SwiftData, searchable across sessions via `searchMyData` /// (CLAUDE.md §5). func updateSummary(of conversation: ConversationRecord, to summary: String) { conversation.summary = summary try? context.save() } /// Imports notes shared into Poche from other apps (Share Extension /// inbox). The user explicitly shared each item — that gesture is the /// confirmation; the model is not involved (CLAUDE.md §3 concerns the /// model's writes). func importSharedInbox(from url: URL? = SharedInbox.url) { for item in SharedInbox.drain(at: url) { addNote(title: item.title, content: item.content) } } // MARK: - Reads (tools receive Sendable snapshots) func findTasks(matching query: String, limit: Int = 5) -> [TaskSnapshot] { let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]) let all = (try? context.fetch(descriptor)) ?? [] let needle = query.lowercased() return all .filter { $0.title.lowercased().contains(needle) } .prefix(limit) .map { TaskSnapshot(uuid: $0.uuid, title: $0.title, due: $0.dueDate, isDone: $0.isDone) } } /// Full searchable corpus for `searchMyData`. Bodies are clipped: tool /// output is budgeted, and so is what feeds it. func corpus() -> [SearchDocument] { var documents: [SearchDocument] = [] let notes = (try? context.fetch(FetchDescriptor())) ?? [] for note in notes { documents.append(SearchDocument(kind: "note", title: note.title, body: String(note.content.prefix(300)))) } let tasks = (try? context.fetch(FetchDescriptor())) ?? [] for task in tasks { let due = task.dueDate.map { " — échéance \(DateResolver.display($0))" } ?? "" let state = task.isDone ? "faite" : "à faire" documents.append(SearchDocument(kind: "tâche", title: task.title, body: "\(state)\(due). \(task.details ?? "")")) } let conversations = (try? context.fetch(FetchDescriptor())) ?? [] for conversation in conversations { if let summary = conversation.summary, !summary.isEmpty { documents.append(SearchDocument(kind: "conversation", title: "Conversation", body: String(summary.prefix(300)))) } } return documents } }