spb/poche Public
Agent personnel 100 % on-device — SwiftUI + Apple Foundation Models + EventKit + SwiftData. Aucune API, aucun serveur.
Swift 100%
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