Nucleic-Session: B4F51091-BE3C-42D3-BCDD-8E89E83543D0 Co-authored-by: Nucleic <[email protected]>
111 lines
5.0 KiB
Swift
111 lines
5.0 KiB
Swift
import Foundation
|
||
|
||
/// Renders a full session — metadata, backend/debug fields, the human-readable
|
||
/// conversation, and the raw event JSONL — into a single Markdown document for
|
||
/// sharing as a reference.
|
||
public enum ConversationExport {
|
||
public static func markdown(session: Session, project: Project?, events: [AgentEvent]) -> String {
|
||
var out = "# Nucleic session export\n\n"
|
||
|
||
out += "## Session\n"
|
||
out += line("Title", session.title)
|
||
out += line("ID", session.id.rawValue)
|
||
out += line("Status", session.status.displayName)
|
||
out += line("Backend", session.backend.rawValue)
|
||
out += line("Backend session id", session.backendSessionID ?? "—")
|
||
out += line("Model", session.model ?? "default")
|
||
out += line("Effort", session.effort ?? "default")
|
||
out += line("Branch", session.branch ?? "—")
|
||
out += line("Worktree", session.worktreePath ?? "—")
|
||
out += line("Base SHA", session.baseSHA ?? "—")
|
||
if let diff = session.diffStat {
|
||
out += line("Diff", "\(diff.filesChanged) files +\(diff.added) −\(diff.removed)")
|
||
}
|
||
out += line("Last seq", "\(session.lastSeq)")
|
||
out += line("Transcript path", session.transcriptPath)
|
||
out += line("Created", iso(session.createdAt))
|
||
out += line("Updated", iso(session.updatedAt))
|
||
|
||
if let project {
|
||
out += "\n## Project\n"
|
||
out += line("Name", project.name)
|
||
out += line("Root", project.rootPath)
|
||
out += line("Default branch", project.defaultBranch.value)
|
||
}
|
||
|
||
out += "\n## Conversation\n\n"
|
||
for event in events { out += human(event) }
|
||
|
||
out += "\n## Raw events (JSONL)\n\nFull normalized event stream for debugging:\n\n```jsonl\n"
|
||
let encoder = JSONEncoder()
|
||
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
|
||
encoder.dateEncodingStrategy = .iso8601
|
||
for event in events {
|
||
if let data = try? encoder.encode(event) {
|
||
out += String(decoding: data, as: UTF8.self) + "\n"
|
||
}
|
||
}
|
||
out += "```\n"
|
||
return out
|
||
}
|
||
|
||
/// A filesystem-safe default filename for the export.
|
||
public static func suggestedFileName(for session: Session) -> String {
|
||
let slug = Slug.sanitize(session.title, fallback: "session")
|
||
return "nucleic-\(slug)-\(session.id.short).md"
|
||
}
|
||
|
||
// MARK: - rendering
|
||
|
||
private static func line(_ key: String, _ value: String) -> String { "- **\(key):** \(value)\n" }
|
||
|
||
private static func human(_ event: AgentEvent) -> String {
|
||
let seq = "`#\(event.seq)`"
|
||
switch event.kind {
|
||
case .sessionStarted(let started):
|
||
return "\(seq) **session started** — model \(started.model)\n\n"
|
||
case .userText(let chunk):
|
||
return "\(seq) **User:** \(chunk.text)\n\n"
|
||
case .assistantText(let chunk) where !chunk.isPartial:
|
||
return "\(seq) **Assistant:** \(chunk.text)\n\n"
|
||
case .assistantText, .thinking, .toolCallInputDelta:
|
||
return ""
|
||
case .toolCallCompleted(let call):
|
||
return "\(seq) **Tool** `\(call.name)` — \(call.input.canonicalString())\n\n"
|
||
case .toolCallStarted:
|
||
return ""
|
||
case .toolResult(let result):
|
||
let body = result.content.canonicalString()
|
||
return "\(seq) **Result**\(result.isError ? " (error)" : ""): \(body.prefix(2000))\n\n"
|
||
case .fileChange(let change):
|
||
return "\(seq) **File \(change.kind.rawValue):** \(change.path)\n\n"
|
||
case .approvalRequested(let request):
|
||
return "\(seq) **Approval requested:** \(request.title) [\(request.risk.rawValue)]\n\n"
|
||
case .approvalResolved(let resolved):
|
||
return "\(seq) **Approval \(resolved.decidedBy):** resolved\n\n"
|
||
case .usage(let usage):
|
||
return "\(seq) _usage in=\(usage.inputTokens ?? 0) out=\(usage.outputTokens ?? 0)_\n\n"
|
||
case .rateLimit(let rl):
|
||
return "\(seq) _rate limit \(rl.rateLimitType ?? "?") \(rl.status ?? "")_\n\n"
|
||
case .codexUsage(let usage):
|
||
let peak = usage.peakUtilization.map { "\(Int($0.rounded()))%" } ?? "?"
|
||
return "\(seq) _codex usage peak \(peak)_\n\n"
|
||
case .turnCompleted:
|
||
return "\(seq) _— turn completed —_\n\n"
|
||
case .runFinished(let finished):
|
||
return "\(seq) _— run \(finished.outcome.rawValue) —_\n\n"
|
||
case .error(let error):
|
||
return "\(seq) ⚠️ **\(error.recoverable ? "recoverable" : "fatal")**: \(error.message)\n\n"
|
||
case .note(let note):
|
||
return "\(seq) _\(note.text)_\n\n"
|
||
case .raw(let raw):
|
||
return "\(seq) `raw[\(event.nativeType ?? "?")]` \(raw.native.canonicalString().prefix(500))\n\n"
|
||
}
|
||
}
|
||
|
||
private static func iso(_ date: Date) -> String {
|
||
let formatter = ISO8601DateFormatter()
|
||
return formatter.string(from: date)
|
||
}
|
||
}
|