diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift new file mode 100644 index 0000000..4a5e109 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift @@ -0,0 +1,361 @@ +import Foundation + +/// Parsed view of `hermes curator status` text + the on-disk +/// `~/.hermes/skills/.curator_state` JSON. +/// +/// Hermes v0.12 doesn't ship a `--json` flag for `curator status` — the +/// CLI writes a human-readable report. CuratorViewModel parses the text +/// output for the human-readable bits ("least recently active", "most +/// active") and reads the state file directly for last-run metadata. +public struct HermesCuratorStatus: Sendable, Equatable { + public enum RunState: String, Sendable, Equatable { + case enabled + case paused + case disabled + case unknown + } + + public let state: RunState + public let runCount: Int + public let lastRunISO: String? // raw timestamp string, parsed by callers + public let lastSummary: String? // free-text summary line + public let lastReportPath: String? // absolute path to / dir + public let intervalLabel: String // e.g. "every 7d" + public let staleAfterLabel: String // e.g. "30d unused" + public let archiveAfterLabel: String // e.g. "90d unused" + + public let totalSkills: Int + public let activeSkills: Int + public let staleSkills: Int + public let archivedSkills: Int + + public let pinnedNames: [String] + + /// Top-5 lists rendered in the curator output. Each row carries the + /// skill name + the four counters Hermes prints. + public let leastRecentlyActive: [HermesCuratorSkillRow] + public let mostActive: [HermesCuratorSkillRow] + public let leastActive: [HermesCuratorSkillRow] + + public init( + state: RunState, + runCount: Int, + lastRunISO: String?, + lastSummary: String?, + lastReportPath: String?, + intervalLabel: String, + staleAfterLabel: String, + archiveAfterLabel: String, + totalSkills: Int, + activeSkills: Int, + staleSkills: Int, + archivedSkills: Int, + pinnedNames: [String], + leastRecentlyActive: [HermesCuratorSkillRow], + mostActive: [HermesCuratorSkillRow], + leastActive: [HermesCuratorSkillRow] + ) { + self.state = state + self.runCount = runCount + self.lastRunISO = lastRunISO + self.lastSummary = lastSummary + self.lastReportPath = lastReportPath + self.intervalLabel = intervalLabel + self.staleAfterLabel = staleAfterLabel + self.archiveAfterLabel = archiveAfterLabel + self.totalSkills = totalSkills + self.activeSkills = activeSkills + self.staleSkills = staleSkills + self.archivedSkills = archivedSkills + self.pinnedNames = pinnedNames + self.leastRecentlyActive = leastRecentlyActive + self.mostActive = mostActive + self.leastActive = leastActive + } + + public static let empty = HermesCuratorStatus( + state: .unknown, + runCount: 0, + lastRunISO: nil, + lastSummary: nil, + lastReportPath: nil, + intervalLabel: "—", + staleAfterLabel: "—", + archiveAfterLabel: "—", + totalSkills: 0, + activeSkills: 0, + staleSkills: 0, + archivedSkills: 0, + pinnedNames: [], + leastRecentlyActive: [], + mostActive: [], + leastActive: [] + ) +} + +public struct HermesCuratorSkillRow: Sendable, Equatable, Identifiable { + public var id: String { name } + public let name: String + public let activityCount: Int + public let useCount: Int + public let viewCount: Int + public let patchCount: Int + public let lastActivityLabel: String // raw label as printed (e.g. "never", "2d ago") + + public init( + name: String, + activityCount: Int, + useCount: Int, + viewCount: Int, + patchCount: Int, + lastActivityLabel: String + ) { + self.name = name + self.activityCount = activityCount + self.useCount = useCount + self.viewCount = viewCount + self.patchCount = patchCount + self.lastActivityLabel = lastActivityLabel + } +} + +/// Pure parser for `hermes curator status` stdout. Public for tests. +/// +/// Format is stable enough to text-parse; we never error on missing +/// sections — we just leave the corresponding field empty so +/// CuratorView can render "—" without crashing on a future layout +/// tweak. State file overrides text-parsed values when both are present. +public enum HermesCuratorStatusParser { + public static func parse(text: String, stateFileJSON: Data? = nil) -> HermesCuratorStatus { + let lines = text.components(separatedBy: "\n") + var status = HermesCuratorStatus.empty + + // Header section: `curator: ENABLED` / `runs:` / `last run:` / + // `last summary:` / `interval:` / `stale after:` / `archive after:` + var state = HermesCuratorStatus.RunState.unknown + var runCount = 0 + var lastRunISO: String? + var lastSummary: String? + var lastReportPath: String? + var interval = "—" + var stale = "—" + var archive = "—" + + // Skill counts: `agent-created skills: N total` then + // ` active N` / ` stale N` / ` archived N` + var total = 0 + var active = 0 + var staleCount = 0 + var archived = 0 + + var pinned: [String] = [] + + // Lists: `least recently active (top 5):` / `most active (top 5):` / + // `least active (top 5):` followed by indented row lines. + enum Section { + case header + case leastRecent + case mostActive + case leastActive + } + var section = Section.header + var leastRecent: [HermesCuratorSkillRow] = [] + var mostActiveRows: [HermesCuratorSkillRow] = [] + var leastActiveRows: [HermesCuratorSkillRow] = [] + + for raw in lines { + let line = raw.trimmingCharacters(in: .whitespaces) + // Section markers + if line.hasPrefix("least recently active") { + section = .leastRecent + continue + } + if line.hasPrefix("most active") { + section = .mostActive + continue + } + if line.hasPrefix("least active") { + section = .leastActive + continue + } + + // Header section single-line keys + if line.hasPrefix("curator:") { + let val = String(line.dropFirst("curator:".count)).trimmingCharacters(in: .whitespaces).uppercased() + switch val { + case "ENABLED": state = .enabled + case "PAUSED": state = .paused + case "DISABLED": state = .disabled + default: state = .unknown + } + continue + } + if line.hasPrefix("runs:") { + runCount = Int(line.dropFirst("runs:".count).trimmingCharacters(in: .whitespaces)) ?? 0 + continue + } + if line.hasPrefix("last run:") { + let val = String(line.dropFirst("last run:".count)).trimmingCharacters(in: .whitespaces) + lastRunISO = val == "never" ? nil : val + continue + } + if line.hasPrefix("last summary:") { + let val = String(line.dropFirst("last summary:".count)).trimmingCharacters(in: .whitespaces) + lastSummary = (val == "(none)" || val.isEmpty) ? nil : val + continue + } + if line.hasPrefix("last report:") { + let val = String(line.dropFirst("last report:".count)).trimmingCharacters(in: .whitespaces) + lastReportPath = val.isEmpty ? nil : val + continue + } + if line.hasPrefix("interval:") { + interval = String(line.dropFirst("interval:".count)).trimmingCharacters(in: .whitespaces) + continue + } + if line.hasPrefix("stale after:") { + stale = String(line.dropFirst("stale after:".count)).trimmingCharacters(in: .whitespaces) + continue + } + if line.hasPrefix("archive after:") { + archive = String(line.dropFirst("archive after:".count)).trimmingCharacters(in: .whitespaces) + continue + } + + // `agent-created skills: 18 total` + if line.hasPrefix("agent-created skills:") { + let after = line.dropFirst("agent-created skills:".count).trimmingCharacters(in: .whitespaces) + if let n = Int(after.split(separator: " ").first ?? "") { + total = n + } + section = .header + continue + } + // Counts: "active 18" / "stale 0" / "archived 0" + if let row = parseStateCountRow(line) { + switch row.state { + case "active": active = row.count + case "stale": staleCount = row.count + case "archived": archived = row.count + default: break + } + continue + } + // pinned (3): foo, bar, baz + if line.hasPrefix("pinned (") { + if let colon = line.firstIndex(of: ":") { + let names = line[line.index(after: colon)...] + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + pinned = names + } + continue + } + + // Skill rows like: + // activity= N use= N view= N patches= N last_activity=