import CryptoKit import Foundation struct AgentOutboxRow: Codable, Equatable, Sendable { var requestID: String var content: String var createdAt: Date var status: String = "sending" var seenOnServer = false var confirmed = false var error: String? var attachments: [AgentAttachment]? = nil // Optional for legacy text-only outboxes. var draftID: String? = nil // Recovers a crash between outbox persistence and clearing the composer. var id: String { "inbound:sybil:\(requestID)" } var message: Message { var info: [String: JSONValue] = ["kind": .string("message"), "status": .string(status), "requestId": .string(requestID)] if let error { info["error"] = .string(error) } if let attachments, !attachments.isEmpty { info["agentAttachments"] = .array(attachments.map(\.metadataValue)) } return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info)) } } struct AgentOutboxStorage: Sendable { var read: @Sendable () throws -> [AgentOutboxRow] var write: @Sendable ([AgentOutboxRow]) throws -> Void static func file(baseURL: URL) -> Self { // Separate installations, but never persist tokens in a filename or payload. let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined() let directory = URL.applicationSupportDirectory.appendingPathComponent("AgentOutbox", isDirectory: true) let url = directory.appendingPathComponent("\(key).json") return Self(read: { guard FileManager.default.fileExists(atPath: url.path) else { return [] } return try JSONDecoder().decode([AgentOutboxRow].self, from: Data(contentsOf: url)) }, write: { rows in let data = try JSONEncoder().encode(rows) guard rows.count <= 100, data.count <= 2 * 1024 * 1024 else { throw AgentFailure(message: "Retry pending messages before sending more.") } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) }) } }