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%
5.6 KB · 146 lines swift
Raw Blame History
1//2//  TaskTranscriptExporter.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Exports a task's transcript (prompts, run steps with tool calls/results,9//  final answers) as Markdown or PDF via NSSavePanel. The PDF path stays10//  deliberately simple: the Markdown text set on an off-screen NSTextView,11//  rendered with dataWithPDF (standard AppKit, no print dialog).12//1314import AppKit15import Foundation16import UniformTypeIdentifiers1718@MainActor19enum TaskTranscriptExporter {20    enum Format {21        case markdown22        case pdf23    }2425    /// Presents the save panel and writes the transcript in the given format.26    static func presentSavePanel(for task: AgentTask, format: Format) {27        let panel = NSSavePanel()28        switch format {29        case .markdown:30            panel.allowedContentTypes = [.plainText]31            panel.nameFieldStringValue = "\(WorkspaceManager.slug(from: task.title)).md"32        case .pdf:33            panel.allowedContentTypes = [.pdf]34            panel.nameFieldStringValue = "\(WorkspaceManager.slug(from: task.title)).pdf"35        }36        let markdown = markdown(for: task)37        panel.begin { response in38            guard response == .OK, let url = panel.url else { return }39            Task { @MainActor in40                switch format {41                case .markdown:42                    try? markdown.write(to: url, atomically: true, encoding: .utf8)43                case .pdf:44                    if let data = pdfData(from: markdown, title: task.title) {45                        try? data.write(to: url, options: .atomic)46                    }47                }48            }49        }50    }5152    // MARK: - Markdown5354    /// The task's history (prompts, steps, answers) as Markdown.55    static func markdown(for task: AgentTask) -> String {56        var lines: [String] = ["# \(task.title)", ""]57        lines.append("Model: `\(task.modelID)` (\(task.providerID.displayName)) · Safety: \(task.safetyMode.displayName)")58        if let workspace = task.workspacePath {59            lines.append("Workspace: `\(workspace)`")60        }61        lines.append("")62        for message in task.messages {63            switch message.kind {64            case .user:65                lines.append("## 🧑 Prompt")66                lines.append(message.text)67            case .agentRun:68                lines.append("## 🤖 Run")69                for step in message.steps ?? [] {70                    lines.append("### Step \(step.index)")71                    if !step.text.isEmpty { lines.append(step.text) }72                    for invocation in step.toolInvocations {73                        lines.append("```")74                        lines.append("\(invocation.call.name): \(invocation.call.argumentsJSON)")75                        if let result = invocation.result {76                            lines.append("→ \(result.content)")77                        }78                        lines.append("```")79                    }80                }81                if !message.text.isEmpty {82                    lines.append("### Result")83                    lines.append(message.text)84                }85            }86            lines.append("")87        }88        return lines.joined(separator: "\n")89    }9091    // MARK: - PDF9293    /// Renders the Markdown text into PDF data via an off-screen NSTextView.94    /// Headings get a heavier system font; fenced blocks stay monospaced.95    static func pdfData(from markdown: String, title: String) -> Data? {96        let attributed = attributedTranscript(from: markdown)97        let pageWidth: CGFloat = 612 // US Letter, points98        let inset: CGFloat = 4899        let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: pageWidth, height: 10))100        textView.textContainerInset = NSSize(width: inset, height: inset)101        textView.isEditable = false102        textView.textStorage?.setAttributedString(attributed)103        guard let container = textView.textContainer, let manager = textView.layoutManager else {104            return nil105        }106        manager.ensureLayout(for: container)107        let used = manager.usedRect(for: container)108        textView.frame = NSRect(x: 0, y: 0, width: pageWidth, height: used.height + inset * 2)109        return textView.dataWithPDF(inside: textView.bounds)110    }111112    /// Simple line-based styling: #/##/### headings, ``` code fences, body.113    private static func attributedTranscript(from markdown: String) -> NSAttributedString {114        let result = NSMutableAttributedString()115        let bodyFont = NSFont.systemFont(ofSize: 11)116        let codeFont = NSFont.monospacedSystemFont(ofSize: 9.5, weight: .regular)117        var inCodeFence = false118119        for line in markdown.components(separatedBy: "\n") {120            var font = bodyFont121            var text = line122            if line.hasPrefix("```") {123                inCodeFence.toggle()124                continue125            }126            if inCodeFence {127                font = codeFont128            } else if line.hasPrefix("### ") {129                font = NSFont.systemFont(ofSize: 12, weight: .semibold)130                text = String(line.dropFirst(4))131            } else if line.hasPrefix("## ") {132                font = NSFont.systemFont(ofSize: 14, weight: .semibold)133                text = String(line.dropFirst(3))134            } else if line.hasPrefix("# ") {135                font = NSFont.systemFont(ofSize: 18, weight: .bold)136                text = String(line.dropFirst(2))137            }138            result.append(NSAttributedString(139                string: text + "\n",140                attributes: [.font: font, .foregroundColor: NSColor.textColor]141            ))142        }143        return result144    }145}146