spb/zyquo-agent Public MIT
The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.
Swift 94.7%
Shell 4.1%
Python 0.7%
Makefile 0.5%
1//2// TaskStore.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// CRUD + persistence for AgentTasks. Each task is saved as its own JSON9// document under `Tasks/<uuid>.json` in the app data folder (small atomic10// writes even when a task embeds a long step history). Provides the sidebar11// grouping (Pinned/Today/Yesterday/Previous 7 Days/Older) and full-text12// search across titles and message history.13//1415import Combine16import Foundation1718@MainActor19final class TaskStore: ObservableObject {20 @Published private(set) var tasks: [AgentTask] = []21 @Published var selectedID: AgentTask.ID?22 @Published var searchText = ""23 /// Prompt handed from the empty state to the next opened task's input24 /// bar (consumed once by the detail view).25 @Published var pendingDraft: String?2627 private let persistence: PersistenceService28 private static let tasksSubdirectory = "Tasks"2930 init(persistence: PersistenceService = .shared) {31 self.persistence = persistence32 try? FileManager.default.createDirectory(33 at: tasksDirectory, withIntermediateDirectories: true34 )35 loadAll()36 }3738 private var tasksDirectory: URL {39 persistence.rootDirectory.appendingPathComponent(Self.tasksSubdirectory)40 }4142 // MARK: - CRUD4344 /// Creates a new task and selects it.45 @discardableResult46 func newTask(model: AIModel?, safetyMode: SafetyMode = .guarded, personaID: UUID? = nil) -> AgentTask {47 var task = AgentTask(48 title: "New Task",49 modelID: model?.id ?? "",50 providerID: model?.provider ?? .anthropic,51 safetyMode: safetyMode52 )53 task.personaID = personaID54 tasks.insert(task, at: 0)55 save(task)56 selectedID = task.id57 return task58 }5960 /// Imports task records (Settings › Advanced). Existing IDs are skipped61 /// so re-importing an export never duplicates; imported tasks arrive with62 /// any active status normalized to idle. Returns how many were added.63 @discardableResult64 func importTasks(_ imported: [AgentTask]) -> Int {65 var added = 066 let existing = Set(tasks.map(\.id))67 for var task in imported where !existing.contains(task.id) {68 if task.status.isActive { task.status = .idle }69 tasks.append(task)70 save(task)71 added += 172 }73 tasks.sort { $0.updatedAt > $1.updatedAt }74 return added75 }7677 func task(id: AgentTask.ID) -> AgentTask? {78 tasks.first { $0.id == id }79 }8081 /// Replaces the stored task and persists it; bumps `updatedAt`.82 func update(_ task: AgentTask, touch: Bool = true) {83 var task = task84 if touch { task.updatedAt = Date() }85 guard let index = tasks.firstIndex(where: { $0.id == task.id }) else { return }86 tasks[index] = task87 save(task)88 }8990 func rename(_ id: AgentTask.ID, to title: String) {91 guard var task = task(id: id) else { return }92 let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)93 guard !trimmed.isEmpty else { return }94 task.title = trimmed95 update(task)96 }9798 func togglePin(_ id: AgentTask.ID) {99 guard var task = task(id: id) else { return }100 task.pinned.toggle()101 update(task, touch: false)102 }103104 func setStatus(_ status: AgentTaskStatus, for id: AgentTask.ID) {105 guard var task = task(id: id), task.status != status else { return }106 task.status = status107 update(task, touch: false)108 }109110 func appendMessage(_ message: TaskMessage, to id: AgentTask.ID) {111 guard var task = task(id: id) else { return }112 task.messages.append(message)113 update(task)114 }115116 /// Deletes the task record and, when requested, its workspace folder.117 func delete(_ id: AgentTask.ID, deleteWorkspace: Bool) {118 guard let task = task(id: id) else { return }119 if deleteWorkspace, let workspace = task.workspaceURL {120 // Only ever remove directories that live under the managed121 // Workspaces/ root — never an arbitrary user path.122 let root = persistence.workspacesDirectory.standardizedFileURL.path123 let target = workspace.standardizedFileURL.path124 if target.hasPrefix(root + "/") {125 try? FileManager.default.removeItem(at: workspace)126 }127 }128 tasks.removeAll { $0.id == id }129 try? FileManager.default.removeItem(at: fileURL(for: id))130 if selectedID == id { selectedID = nil }131 }132133 // MARK: - Sidebar grouping & search134135 struct SidebarGroup: Identifiable {136 var id: String137 var title: String138 var tasks: [AgentTask]139 }140141 /// Tasks matching the search text (title + message history), newest first.142 var filteredTasks: [AgentTask] {143 let query = searchText.trimmingCharacters(in: .whitespaces)144 let sorted = tasks.sorted { $0.updatedAt > $1.updatedAt }145 guard !query.isEmpty else { return sorted }146 return sorted.filter { task in147 if task.title.localizedCaseInsensitiveContains(query) { return true }148 return task.messages.contains { $0.text.localizedCaseInsensitiveContains(query) }149 }150 }151152 /// Pinned / Today / Yesterday / Previous 7 Days / Older.153 var sidebarGroups: [SidebarGroup] {154 let calendar = Calendar.current155 let now = Date()156 var pinned: [AgentTask] = []157 var today: [AgentTask] = []158 var yesterday: [AgentTask] = []159 var previousWeek: [AgentTask] = []160 var older: [AgentTask] = []161162 for task in filteredTasks {163 if task.pinned {164 pinned.append(task)165 } else if calendar.isDateInToday(task.updatedAt) {166 today.append(task)167 } else if calendar.isDateInYesterday(task.updatedAt) {168 yesterday.append(task)169 } else if let days = calendar.dateComponents([.day], from: task.updatedAt, to: now).day, days < 7 {170 previousWeek.append(task)171 } else {172 older.append(task)173 }174 }175176 return [177 SidebarGroup(id: "pinned", title: "Pinned", tasks: pinned),178 SidebarGroup(id: "today", title: "Today", tasks: today),179 SidebarGroup(id: "yesterday", title: "Yesterday", tasks: yesterday),180 SidebarGroup(id: "week", title: "Previous 7 Days", tasks: previousWeek),181 SidebarGroup(id: "older", title: "Older", tasks: older),182 ].filter { !$0.tasks.isEmpty }183 }184185 // MARK: - Persistence186187 private func fileURL(for id: AgentTask.ID) -> URL {188 tasksDirectory.appendingPathComponent("\(id.uuidString).json")189 }190191 private func save(_ task: AgentTask) {192 persistence.save(task, to: "\(Self.tasksSubdirectory)/\(task.id.uuidString).json")193 }194195 private func loadAll() {196 let files = (try? FileManager.default.contentsOfDirectory(197 at: tasksDirectory, includingPropertiesForKeys: nil198 )) ?? []199 var loaded: [AgentTask] = []200 for file in files where file.pathExtension == "json" {201 if let task = persistence.load(AgentTask.self, from: "\(Self.tasksSubdirectory)/\(file.lastPathComponent)") {202 loaded.append(task)203 }204 }205 // A crash mid-run must not leave phantom "Running" pills behind.206 tasks = loaded207 .map { task in208 var task = task209 if task.status.isActive { task.status = .idle }210 return task211 }212 .sorted { $0.updatedAt > $1.updatedAt }213 }214}215