SPB Git

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