mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
feat(hermes-v12): Curator feature module on Mac + iOS (Phase D)
Hermes v0.12 ships an autonomous Curator that prunes / consolidates agent-created skills on a 7-day cycle. This phase brings that surface into Scarf so users can see status, trigger runs, pin protected skills, and restore archived ones. Pipeline: - HermesCuratorStatus + HermesCuratorSkillRow: Sendable value types for parsed status + per-skill leaderboard rows. - HermesCuratorStatusParser: pure text parser for `hermes curator status` stdout (no `--json` flag exists upstream). Tolerates Hermes's whitespace-padded leaderboard layout (`activity= 0` with N spaces between `=` and the value) by slicing between known key positions rather than splitting on whitespace. State-file JSON overrides text-parsed values for last_run_at / last_run_summary / last_report_path because the file carries full ISO timestamps the text output may have rounded. - CuratorViewModel: @Observable @MainActor, drives the CLI verbs (status / run / pause / resume / pin / unpin / restore) via transport.runProcess so it works equally over local and Citadel SSH. - HermesPathSet: adds curatorLogsDir + curatorStateFile (the latter is `.curator_state` with no extension despite holding JSON). Mac: - Features/Curator/Views/CuratorView.swift — page-header + status card + skill counts + pinned chips + 3 leaderboard tables (least recent, most active, least active) with inline pin toggles and a per-skill counter chip row. "Run Now" button + a kebab menu for Pause/Resume + Restore Archived. - Features/Curator/Views/CuratorRestoreSheet.swift — name-entry sheet for `hermes curator restore <skill>`. Free-form text field; Hermes doesn't ship a `curator list-archived` yet so we don't synthesize a picker. - Sidebar: AppCoordinator + SidebarView gain a `.curator` case under Interact (between Memory and Skills); the row is filtered out by SidebarView's capability-aware `sections` computed property when `HermesCapabilities.hasCurator` is false. ContentView routes `.curator` to CuratorView. Pre-v0.12 hosts see the v0.11 sidebar unchanged. iOS: - Scarf iOS/Curator/CuratorView.swift — read-mostly List with the same status / skill counts / pinned / leaderboards + inline pin toggles. Run Now / Pause / Resume actions in the section footer. - ScarfGoTabRoot's System tab gains a Curator NavigationLink under Features, gated on `hasCurator`. Uses a stable `systemTabContextID` so the SSH transport pool reuses the cached Citadel connection keyed by that id. Tests: 6 new parser tests (215 total, all green). Locks the empty-state output captured from a real v0.12.0 install + paused-state + state-file override + multi-word-name-row parsing. Both Mac and iOS schemes build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <YYYYMMDD-HHMMSS>/ 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:
|
||||
// <name> activity= N use= N view= N patches= N last_activity=<label>
|
||||
if section != .header, let parsed = parseSkillRow(line) {
|
||||
switch section {
|
||||
case .leastRecent: leastRecent.append(parsed)
|
||||
case .mostActive: mostActiveRows.append(parsed)
|
||||
case .leastActive: leastActiveRows.append(parsed)
|
||||
case .header: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply state-file overrides if present. The .curator_state JSON
|
||||
// is authoritative for last_run_at / last_run_summary /
|
||||
// last_report_path because those carry timestamps the text
|
||||
// output rounds.
|
||||
if let json = stateFileJSON,
|
||||
let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] {
|
||||
if obj["paused"] as? Bool == true { state = .paused }
|
||||
if let count = obj["run_count"] as? Int { runCount = count }
|
||||
if let lr = obj["last_run_at"] as? String { lastRunISO = lr }
|
||||
if let summary = obj["last_run_summary"] as? String, !summary.isEmpty { lastSummary = summary }
|
||||
if let path = obj["last_report_path"] as? String, !path.isEmpty { lastReportPath = path }
|
||||
}
|
||||
|
||||
status = HermesCuratorStatus(
|
||||
state: state,
|
||||
runCount: runCount,
|
||||
lastRunISO: lastRunISO,
|
||||
lastSummary: lastSummary,
|
||||
lastReportPath: lastReportPath,
|
||||
intervalLabel: interval,
|
||||
staleAfterLabel: stale,
|
||||
archiveAfterLabel: archive,
|
||||
totalSkills: total,
|
||||
activeSkills: active,
|
||||
staleSkills: staleCount,
|
||||
archivedSkills: archived,
|
||||
pinnedNames: pinned,
|
||||
leastRecentlyActive: leastRecent,
|
||||
mostActive: mostActiveRows,
|
||||
leastActive: leastActiveRows
|
||||
)
|
||||
return status
|
||||
}
|
||||
|
||||
/// `active 18` style row inside the skill-count block.
|
||||
private static func parseStateCountRow(_ line: String) -> (state: String, count: Int)? {
|
||||
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
guard parts.count >= 2,
|
||||
["active", "stale", "archived"].contains(parts[0]),
|
||||
let count = Int(parts[1])
|
||||
else { return nil }
|
||||
return (parts[0], count)
|
||||
}
|
||||
|
||||
/// Skill-list row parser. Tolerates Hermes's whitespace-padded
|
||||
/// layout — `activity= 0` has two spaces between `=` and the
|
||||
/// number, so we can't split-on-space-then-split-on-`=`. Instead
|
||||
/// we slide a key-detection cursor across the row and grab the
|
||||
/// next non-whitespace token after each known key.
|
||||
private static func parseSkillRow(_ line: String) -> HermesCuratorSkillRow? {
|
||||
guard let activityRange = line.range(of: "activity=") else { return nil }
|
||||
let name = String(line[..<activityRange.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||
guard !name.isEmpty else { return nil }
|
||||
|
||||
// Map each known key to its value substring. Read positionally
|
||||
// by slicing between consecutive known keys — handles arbitrary
|
||||
// whitespace padding without depending on column positions.
|
||||
let knownKeys = ["activity=", "use=", "view=", "patches=", "last_activity="]
|
||||
var positions: [(key: String, range: Range<String.Index>)] = []
|
||||
for key in knownKeys {
|
||||
if let r = line.range(of: key) {
|
||||
positions.append((key, r))
|
||||
}
|
||||
}
|
||||
positions.sort { $0.range.lowerBound < $1.range.lowerBound }
|
||||
|
||||
var activity = 0, use = 0, view = 0, patch = 0
|
||||
var lastActivity = ""
|
||||
|
||||
for (idx, entry) in positions.enumerated() {
|
||||
let valueStart = entry.range.upperBound
|
||||
let valueEnd = idx + 1 < positions.count
|
||||
? positions[idx + 1].range.lowerBound
|
||||
: line.endIndex
|
||||
let raw = String(line[valueStart..<valueEnd]).trimmingCharacters(in: .whitespaces)
|
||||
switch entry.key {
|
||||
case "activity=": activity = Int(raw) ?? 0
|
||||
case "use=": use = Int(raw) ?? 0
|
||||
case "view=": view = Int(raw) ?? 0
|
||||
case "patches=": patch = Int(raw) ?? 0
|
||||
case "last_activity=": lastActivity = raw
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return HermesCuratorSkillRow(
|
||||
name: name,
|
||||
activityCount: activity,
|
||||
useCount: use,
|
||||
viewCount: view,
|
||||
patchCount: patch,
|
||||
lastActivityLabel: lastActivity
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -75,12 +75,17 @@ public struct HermesPathSet: Sendable, Hashable {
|
||||
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
||||
public nonisolated var agentLog: String { home + "/logs/agent.log" }
|
||||
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||
/// Curator run report, JSON (v0.12+). Written by `hermes curator` on
|
||||
/// each cycle; consumed by `CuratorViewModel` for structured stats.
|
||||
public nonisolated var curatorReportJSON: String { home + "/logs/curator/run.json" }
|
||||
/// Curator human-readable run report (v0.12+). Renders as the
|
||||
/// "Last run" text in CuratorView.
|
||||
public nonisolated var curatorReportMD: String { home + "/logs/curator/REPORT.md" }
|
||||
/// Curator run-reports root (v0.12+). Hermes writes per-cycle dirs
|
||||
/// under here named `<YYYYMMDD-HHMMSS>/` containing `run.json` and
|
||||
/// `REPORT.md`. The `last_report_path` field on `curator_state`
|
||||
/// points at the most recent dir; `CuratorViewModel` resolves the
|
||||
/// JSON/Markdown files relative to it.
|
||||
public nonisolated var curatorLogsDir: String { home + "/logs/curator" }
|
||||
/// JSON-encoded curator state (v0.12+). Filename has no extension
|
||||
/// despite holding JSON — Hermes writes it via
|
||||
/// `~/.hermes/skills/.curator_state`. Carries last-run metadata,
|
||||
/// run count, pause flag, and the path to the most recent report.
|
||||
public nonisolated var curatorStateFile: String { home + "/skills/.curator_state" }
|
||||
public nonisolated var scarfDir: String { home + "/scarf" }
|
||||
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Mac + iOS view model for the v0.12 Curator surface.
|
||||
///
|
||||
/// Drives `hermes curator status / run / pause / resume / pin / unpin /
|
||||
/// restore` plus a parsed view of `~/.hermes/skills/.curator_state`
|
||||
/// JSON. The CLI doesn't ship a `--json` flag for `status`, so we
|
||||
/// text-parse stdout (HermesCuratorStatusParser) and use the state
|
||||
/// file for richer last-run metadata.
|
||||
///
|
||||
/// Capability-gated: callers should construct this only when
|
||||
/// `HermesCapabilities.hasCurator` is true. The view model does not
|
||||
/// gate itself — the gate happens at sidebar/tab routing time.
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class CuratorViewModel {
|
||||
#if canImport(os)
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "CuratorViewModel")
|
||||
#endif
|
||||
|
||||
public let context: ServerContext
|
||||
|
||||
public private(set) var status: HermesCuratorStatus = .empty
|
||||
public private(set) var isLoading = false
|
||||
public private(set) var lastReportMarkdown: String?
|
||||
public var transientMessage: String?
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
let context = self.context
|
||||
let parsed = await Task.detached(priority: .userInitiated) { () -> (HermesCuratorStatus, String?) in
|
||||
let textResult = Self.runCuratorStatus(context: context)
|
||||
let stateData = context.readData(context.paths.curatorStateFile)
|
||||
let parsed = HermesCuratorStatusParser.parse(text: textResult, stateFileJSON: stateData)
|
||||
// Best-effort markdown report: the state file points at the
|
||||
// most recent <YYYYMMDD-HHMMSS>/ dir; load REPORT.md from
|
||||
// there. Missing on first run, which is fine.
|
||||
var report: String?
|
||||
if let reportDir = parsed.lastReportPath {
|
||||
let reportPath = reportDir.hasSuffix("/")
|
||||
? "\(reportDir)REPORT.md"
|
||||
: "\(reportDir)/REPORT.md"
|
||||
report = context.readText(reportPath)
|
||||
}
|
||||
return (parsed, report)
|
||||
}.value
|
||||
self.status = parsed.0
|
||||
self.lastReportMarkdown = parsed.1
|
||||
}
|
||||
|
||||
public func runNow() async {
|
||||
await runAndReload(args: ["curator", "run"], successMessage: "Curator run started")
|
||||
}
|
||||
|
||||
public func pause() async {
|
||||
await runAndReload(args: ["curator", "pause"], successMessage: "Curator paused")
|
||||
}
|
||||
|
||||
public func resume() async {
|
||||
await runAndReload(args: ["curator", "resume"], successMessage: "Curator resumed")
|
||||
}
|
||||
|
||||
public func pin(_ skill: String) async {
|
||||
await runAndReload(args: ["curator", "pin", skill], successMessage: "Pinned \(skill)")
|
||||
}
|
||||
|
||||
public func unpin(_ skill: String) async {
|
||||
await runAndReload(args: ["curator", "unpin", skill], successMessage: "Unpinned \(skill)")
|
||||
}
|
||||
|
||||
public func restore(_ skill: String) async {
|
||||
await runAndReload(args: ["curator", "restore", skill], successMessage: "Restored \(skill)")
|
||||
}
|
||||
|
||||
private func runAndReload(args: [String], successMessage: String) async {
|
||||
let context = self.context
|
||||
let exitCode = await Task.detached(priority: .userInitiated) {
|
||||
Self.runHermes(context: context, args: args).exitCode
|
||||
}.value
|
||||
transientMessage = exitCode == 0 ? successMessage : "Command failed"
|
||||
await load()
|
||||
// Auto-clear toast after 3s.
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
self?.transientMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap the transport-level `runProcess` so the call sites don't
|
||||
/// have to reach for it directly. Combined stdout+stderr.
|
||||
nonisolated private static func runHermes(
|
||||
context: ServerContext,
|
||||
args: [String]
|
||||
) -> (exitCode: Int32, output: String) {
|
||||
let transport = context.makeTransport()
|
||||
do {
|
||||
let result = try transport.runProcess(
|
||||
executable: context.paths.hermesBinary,
|
||||
args: args,
|
||||
stdin: nil,
|
||||
timeout: 30
|
||||
)
|
||||
return (result.exitCode, result.stdoutString + result.stderrString)
|
||||
} catch let error as TransportError {
|
||||
return (-1, error.diagnosticStderr.isEmpty
|
||||
? (error.errorDescription ?? "transport error")
|
||||
: error.diagnosticStderr)
|
||||
} catch {
|
||||
return (-1, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func runCuratorStatus(context: ServerContext) -> String {
|
||||
runHermes(context: context, args: ["curator", "status"]).output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
@Suite struct HermesCuratorParserTests {
|
||||
|
||||
/// Real `hermes curator status` output captured from a v0.12.0
|
||||
/// install with no curator runs yet. Locks in the empty-state
|
||||
/// happy path so a Hermes layout tweak surfaces here before
|
||||
/// CuratorView starts rendering "—" placeholders silently.
|
||||
private static let realFreshOutput = """
|
||||
curator: ENABLED
|
||||
runs: 0
|
||||
last run: never
|
||||
last summary: (none)
|
||||
interval: every 7d
|
||||
stale after: 30d unused
|
||||
archive after: 90d unused
|
||||
|
||||
agent-created skills: 18 total
|
||||
active 18
|
||||
stale 0
|
||||
archived 0
|
||||
|
||||
least recently active (top 5):
|
||||
Scarf Dashboard Chart Widget Parse Error Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
Scarf Project Registry Format Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
clip activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
find-nearby activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
gguf-quantization activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
|
||||
least active (top 5):
|
||||
Scarf Dashboard Chart Widget Parse Error Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
Scarf Project Registry Format Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
clip activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
find-nearby activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
gguf-quantization activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
"""
|
||||
|
||||
@Test func parseRealFreshOutput() {
|
||||
let s = HermesCuratorStatusParser.parse(text: Self.realFreshOutput)
|
||||
#expect(s.state == .enabled)
|
||||
#expect(s.runCount == 0)
|
||||
#expect(s.lastRunISO == nil)
|
||||
#expect(s.lastSummary == nil)
|
||||
#expect(s.intervalLabel == "every 7d")
|
||||
#expect(s.staleAfterLabel == "30d unused")
|
||||
#expect(s.archiveAfterLabel == "90d unused")
|
||||
#expect(s.totalSkills == 18)
|
||||
#expect(s.activeSkills == 18)
|
||||
#expect(s.staleSkills == 0)
|
||||
#expect(s.archivedSkills == 0)
|
||||
#expect(s.pinnedNames.isEmpty)
|
||||
#expect(s.leastRecentlyActive.count == 5)
|
||||
#expect(s.leastActive.count == 5)
|
||||
#expect(s.mostActive.isEmpty)
|
||||
let firstRow = s.leastRecentlyActive.first
|
||||
#expect(firstRow?.name == "Scarf Dashboard Chart Widget Parse Error Fix")
|
||||
#expect(firstRow?.activityCount == 0)
|
||||
#expect(firstRow?.lastActivityLabel == "never")
|
||||
}
|
||||
|
||||
@Test func parsedPausedState() {
|
||||
let text = """
|
||||
curator: PAUSED
|
||||
runs: 5
|
||||
last run: 2026-04-29T03:10:00Z
|
||||
last summary: pruned 2 skills, consolidated 1
|
||||
interval: every 7d
|
||||
stale after: 30d unused
|
||||
archive after: 90d unused
|
||||
|
||||
agent-created skills: 12 total
|
||||
active 8
|
||||
stale 3
|
||||
archived 1
|
||||
|
||||
pinned (2): kanban-orchestrator, scarf-template-author
|
||||
"""
|
||||
let s = HermesCuratorStatusParser.parse(text: text)
|
||||
#expect(s.state == .paused)
|
||||
#expect(s.runCount == 5)
|
||||
#expect(s.lastRunISO == "2026-04-29T03:10:00Z")
|
||||
#expect(s.lastSummary == "pruned 2 skills, consolidated 1")
|
||||
#expect(s.totalSkills == 12)
|
||||
#expect(s.activeSkills == 8)
|
||||
#expect(s.staleSkills == 3)
|
||||
#expect(s.archivedSkills == 1)
|
||||
#expect(s.pinnedNames == ["kanban-orchestrator", "scarf-template-author"])
|
||||
}
|
||||
|
||||
@Test func stateFileOverridesTextSummary() {
|
||||
// The state file is authoritative for last_run_at /
|
||||
// last_run_summary / last_report_path because it carries full
|
||||
// ISO timestamps the text output may have rounded. Verify that
|
||||
// a state file with richer values overrides parsed text.
|
||||
let text = """
|
||||
curator: ENABLED
|
||||
runs: 1
|
||||
last run: 2026-04-30T11:00:00Z
|
||||
last summary: short
|
||||
interval: every 7d
|
||||
stale after: 30d unused
|
||||
archive after: 90d unused
|
||||
|
||||
agent-created skills: 3 total
|
||||
active 3
|
||||
stale 0
|
||||
archived 0
|
||||
"""
|
||||
let stateJSON: [String: Any] = [
|
||||
"run_count": 4,
|
||||
"last_run_at": "2026-04-30T18:42:13.001Z",
|
||||
"last_run_summary": "richer summary from state file",
|
||||
"last_report_path": "/Users/u/.hermes/logs/curator/20260430-184213"
|
||||
]
|
||||
let data = try! JSONSerialization.data(withJSONObject: stateJSON)
|
||||
let s = HermesCuratorStatusParser.parse(text: text, stateFileJSON: data)
|
||||
#expect(s.runCount == 4)
|
||||
#expect(s.lastRunISO == "2026-04-30T18:42:13.001Z")
|
||||
#expect(s.lastSummary == "richer summary from state file")
|
||||
#expect(s.lastReportPath == "/Users/u/.hermes/logs/curator/20260430-184213")
|
||||
}
|
||||
|
||||
@Test func parsedDisabledStatus() {
|
||||
let s = HermesCuratorStatusParser.parse(text: "curator: DISABLED\n runs: 0\n")
|
||||
#expect(s.state == .disabled)
|
||||
}
|
||||
|
||||
@Test func parsedEmptyOutputStaysSafe() {
|
||||
let s = HermesCuratorStatusParser.parse(text: "")
|
||||
#expect(s.state == .unknown)
|
||||
#expect(s.totalSkills == 0)
|
||||
#expect(s.leastRecentlyActive.isEmpty)
|
||||
}
|
||||
|
||||
@Test func skillRowParserHandlesMultiWordNames() {
|
||||
// Names with spaces are common (Scarf Dashboard Chart Widget…)
|
||||
// The parser slices at the first `activity=` so names can be
|
||||
// arbitrary length without breaking the counter columns.
|
||||
let row = " Some Long Skill Name v2 activity= 12 use= 4 view= 6 patches= 2 last_activity=2026-04-25"
|
||||
let s = HermesCuratorStatusParser.parse(text: """
|
||||
least recently active (top 5):
|
||||
\(row)
|
||||
""")
|
||||
let parsed = s.leastRecentlyActive.first
|
||||
#expect(parsed?.name == "Some Long Skill Name v2")
|
||||
#expect(parsed?.activityCount == 12)
|
||||
#expect(parsed?.useCount == 4)
|
||||
#expect(parsed?.viewCount == 6)
|
||||
#expect(parsed?.patchCount == 2)
|
||||
#expect(parsed?.lastActivityLabel == "2026-04-25")
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,14 @@ struct ScarfGoTabRoot: View {
|
||||
let onSoftDisconnect: @MainActor () async -> Void
|
||||
let onForget: @MainActor () async -> Void
|
||||
|
||||
/// Stable per-tab context UUID — used for the System tab's Curator
|
||||
/// row so its CuratorViewModel reuses the cached SSH connection
|
||||
/// keyed by this id rather than building a fresh one. Same pattern
|
||||
/// as `sharedContextID` on ChatView.
|
||||
static let systemTabContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A2"
|
||||
)!
|
||||
|
||||
/// One coordinator per server-connected session. Cross-tab
|
||||
/// signalling (Dashboard row → Chat tab resume, Project Detail
|
||||
/// → in-project chat handoff, notification deep-link → Chat) flows
|
||||
@@ -172,6 +180,8 @@ private struct SystemTab: View {
|
||||
let onSoftDisconnect: @MainActor () async -> Void
|
||||
let onForget: @MainActor () async -> Void
|
||||
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
|
||||
@State private var showForgetConfirmation = false
|
||||
@State private var isForgetting = false
|
||||
@State private var isDisconnecting = false
|
||||
@@ -206,6 +216,15 @@ private struct SystemTab: View {
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||
if capabilitiesStore?.capabilities.hasCurator ?? false {
|
||||
NavigationLink {
|
||||
CuratorView(context: config.toServerContext(id: ScarfGoTabRoot.systemTabContextID))
|
||||
} label: {
|
||||
Label("Curator", systemImage: "sparkles")
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||
}
|
||||
NavigationLink {
|
||||
CronListView(config: config)
|
||||
} label: {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
#if canImport(SQLite3)
|
||||
|
||||
/// iOS Curator surface — read-mostly view of `hermes curator status`
|
||||
/// with Run Now / Pause / Resume actions and inline pin toggles on
|
||||
/// the leaderboard rows. Mirrors the Mac surface visually but folds
|
||||
/// into a single SwiftUI List for thumb-friendly scrolling.
|
||||
///
|
||||
/// Capability-gated upstream: only routed when
|
||||
/// `HermesCapabilities.hasCurator` is true.
|
||||
struct CuratorView: View {
|
||||
@State private var viewModel: CuratorViewModel
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: CuratorViewModel(context: context))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
statusRow
|
||||
LabeledContent("Last run", value: viewModel.status.lastRunISO ?? "Never")
|
||||
if let summary = viewModel.status.lastSummary {
|
||||
LabeledContent("Summary", value: summary)
|
||||
}
|
||||
LabeledContent("Interval", value: viewModel.status.intervalLabel)
|
||||
LabeledContent("Stale after", value: viewModel.status.staleAfterLabel)
|
||||
LabeledContent("Archive after", value: viewModel.status.archiveAfterLabel)
|
||||
LabeledContent("Runs", value: "\(viewModel.status.runCount)")
|
||||
} header: {
|
||||
Text("Status")
|
||||
} footer: {
|
||||
actionFooter
|
||||
}
|
||||
|
||||
Section("Skills") {
|
||||
LabeledContent("Total", value: "\(viewModel.status.totalSkills)")
|
||||
LabeledContent("Active", value: "\(viewModel.status.activeSkills)")
|
||||
LabeledContent("Stale", value: "\(viewModel.status.staleSkills)")
|
||||
LabeledContent("Archived", value: "\(viewModel.status.archivedSkills)")
|
||||
}
|
||||
|
||||
if !viewModel.status.pinnedNames.isEmpty {
|
||||
Section("Pinned") {
|
||||
ForEach(viewModel.status.pinnedNames, id: \.self) { name in
|
||||
HStack {
|
||||
Image(systemName: "pin.fill")
|
||||
.foregroundStyle(ScarfColor.accent)
|
||||
Text(name)
|
||||
Spacer()
|
||||
Button("Unpin") {
|
||||
Task { await viewModel.unpin(name) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.status.leastRecentlyActive.isEmpty {
|
||||
rowsSection(title: "Least recently active", rows: viewModel.status.leastRecentlyActive)
|
||||
}
|
||||
if !viewModel.status.mostActive.isEmpty {
|
||||
rowsSection(title: "Most active", rows: viewModel.status.mostActive)
|
||||
}
|
||||
if !viewModel.status.leastActive.isEmpty {
|
||||
rowsSection(title: "Least active", rows: viewModel.status.leastActive)
|
||||
}
|
||||
|
||||
if let report = viewModel.lastReportMarkdown {
|
||||
Section("Last report") {
|
||||
Text(report)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Curator")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if let toast = viewModel.transientMessage {
|
||||
toastView(toast)
|
||||
}
|
||||
}
|
||||
.task { await viewModel.load() }
|
||||
}
|
||||
|
||||
private var statusRow: some View {
|
||||
HStack {
|
||||
Text("Curator")
|
||||
Spacer()
|
||||
statusBadge
|
||||
}
|
||||
}
|
||||
|
||||
private var statusBadge: some View {
|
||||
let kind: ScarfBadgeKind
|
||||
let label: String
|
||||
switch viewModel.status.state {
|
||||
case .enabled: kind = .success; label = "Enabled"
|
||||
case .paused: kind = .warning; label = "Paused"
|
||||
case .disabled: kind = .neutral; label = "Disabled"
|
||||
case .unknown: kind = .neutral; label = "Unknown"
|
||||
}
|
||||
return ScarfBadge(label, kind: kind)
|
||||
}
|
||||
|
||||
private var actionFooter: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await viewModel.runNow() }
|
||||
} label: {
|
||||
Label("Run now", systemImage: "play.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.isLoading)
|
||||
|
||||
if viewModel.status.state == .enabled {
|
||||
Button {
|
||||
Task { await viewModel.pause() }
|
||||
} label: {
|
||||
Label("Pause", systemImage: "pause.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
} else if viewModel.status.state == .paused {
|
||||
Button {
|
||||
Task { await viewModel.resume() }
|
||||
} label: {
|
||||
Label("Resume", systemImage: "play.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
private func rowsSection(title: String, rows: [HermesCuratorSkillRow]) -> some View {
|
||||
Section(title) {
|
||||
ForEach(rows) { row in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(row.name)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await viewModel.pin(row.name) }
|
||||
} label: {
|
||||
Image(systemName: viewModel.status.pinnedNames.contains(row.name) ? "pin.fill" : "pin")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text("use \(row.useCount) · view \(row.viewCount) · patch \(row.patchCount)")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(row.lastActivityLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toastView(_ text: String) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(ScarfColor.success)
|
||||
Text(text).font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
.padding(.bottom, 12)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -61,6 +61,7 @@ struct ContentView: View {
|
||||
case .projects: ProjectsView(context: serverContext)
|
||||
case .chat: ChatView()
|
||||
case .memory: MemoryView(context: serverContext)
|
||||
case .curator: CuratorView(context: serverContext)
|
||||
case .skills: SkillsView(context: serverContext)
|
||||
case .platforms: PlatformsView(context: serverContext)
|
||||
case .personalities: PersonalitiesView(context: serverContext)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Modal that lists archived skills (state ≠ active) and exposes a
|
||||
/// one-click "Restore" action per row. v0.12 archives are recoverable —
|
||||
/// `hermes curator restore <name>` brings the skill back into
|
||||
/// `~/.hermes/skills/<category>/<name>/` and re-marks it active.
|
||||
///
|
||||
/// The Curator's `status` text doesn't enumerate archived skills with
|
||||
/// names; we surface what's available (counts + pinned list) and rely
|
||||
/// on the user knowing the names. Hermes ergo does an interactive
|
||||
/// `--name` arg if missing — but Scarf prefers explicit selection so
|
||||
/// users don't have to remember names. For v2.6 we render a free-form
|
||||
/// text field; once Hermes ships a `curator list-archived` (tracked
|
||||
/// upstream), swap to a pickable list.
|
||||
struct CuratorRestoreSheet: View {
|
||||
let viewModel: CuratorViewModel
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var skillName: String = ""
|
||||
@State private var isRestoring = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
|
||||
Text("Restore Archived Skill")
|
||||
.scarfStyle(.headline)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
|
||||
Text("Hermes archives skills the curator decides are stale or redundant. Restoring brings the original SKILL.md back into place — no data lost.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s1) {
|
||||
Text("Skill name")
|
||||
.scarfStyle(.captionUppercase)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
ScarfTextField("e.g. legacy-helper", text: $skillName)
|
||||
}
|
||||
|
||||
Text("\(viewModel.status.archivedSkills) archived skill(s) available — list them with `hermes curator status`.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
.buttonStyle(ScarfGhostButton())
|
||||
Button("Restore") {
|
||||
let trimmed = skillName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
isRestoring = true
|
||||
Task {
|
||||
await viewModel.restore(trimmed)
|
||||
isRestoring = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(isRestoring || skillName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s5)
|
||||
.frame(width: 420)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Mac UI for Hermes v0.12's autonomous skill curator.
|
||||
///
|
||||
/// Surfaces the running state (enabled / paused / disabled), last-run
|
||||
/// metadata, agent-created skill counts, and the most/least-active /
|
||||
/// least-recently-active leaderboards. Pin-and-restore actions hit
|
||||
/// `hermes curator pin/unpin/restore` via CuratorViewModel.
|
||||
///
|
||||
/// Capability-gated upstream: AppCoordinator only wires the sidebar
|
||||
/// item when `HermesCapabilities.hasCurator` is true. This view assumes
|
||||
/// it's reachable on a v0.12+ host.
|
||||
struct CuratorView: View {
|
||||
@State private var viewModel: CuratorViewModel
|
||||
@State private var showRestoreSheet = false
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: CuratorViewModel(context: context))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
|
||||
ScarfPageHeader(
|
||||
"Curator",
|
||||
subtitle: "Autonomous skill maintenance — Hermes v0.12+"
|
||||
) {
|
||||
HStack(spacing: ScarfSpace.s2) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Button("Run Now") {
|
||||
Task { await viewModel.runNow() }
|
||||
}
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.disabled(viewModel.isLoading)
|
||||
Menu {
|
||||
switch viewModel.status.state {
|
||||
case .paused:
|
||||
Button("Resume") { Task { await viewModel.resume() } }
|
||||
case .enabled:
|
||||
Button("Pause") { Task { await viewModel.pause() } }
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
Button("Restore Archived…") {
|
||||
showRestoreSheet = true
|
||||
}
|
||||
.disabled(viewModel.status.archivedSkills == 0)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let toast = viewModel.transientMessage {
|
||||
transientToast(toast)
|
||||
}
|
||||
|
||||
statusSummary
|
||||
skillCountsSection
|
||||
pinnedSection
|
||||
activityTables
|
||||
|
||||
if let report = viewModel.lastReportMarkdown {
|
||||
lastReportSection(markdown: report)
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s4)
|
||||
}
|
||||
.background(ScarfColor.backgroundPrimary)
|
||||
.task { await viewModel.load() }
|
||||
.sheet(isPresented: $showRestoreSheet) {
|
||||
CuratorRestoreSheet(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusSummary: some View {
|
||||
ScarfCard {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
HStack {
|
||||
statusBadge
|
||||
Spacer()
|
||||
Text("\(viewModel.status.runCount) runs")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
ScarfDivider()
|
||||
infoRow(label: "Last run", value: viewModel.status.lastRunISO ?? "Never")
|
||||
if let summary = viewModel.status.lastSummary {
|
||||
infoRow(label: "Last summary", value: summary)
|
||||
}
|
||||
infoRow(label: "Interval", value: viewModel.status.intervalLabel)
|
||||
infoRow(label: "Stale after", value: viewModel.status.staleAfterLabel)
|
||||
infoRow(label: "Archive after", value: viewModel.status.archiveAfterLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusBadge: some View {
|
||||
let kind: ScarfBadgeKind
|
||||
let label: String
|
||||
switch viewModel.status.state {
|
||||
case .enabled: kind = .success; label = "Enabled"
|
||||
case .paused: kind = .warning; label = "Paused"
|
||||
case .disabled: kind = .neutral; label = "Disabled"
|
||||
case .unknown: kind = .neutral; label = "Unknown"
|
||||
}
|
||||
return ScarfBadge(label, kind: kind)
|
||||
}
|
||||
|
||||
private var skillCountsSection: some View {
|
||||
ScarfCard {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
ScarfSectionHeader("Agent-created skills")
|
||||
HStack(spacing: ScarfSpace.s4) {
|
||||
countCell(value: viewModel.status.totalSkills, label: "Total")
|
||||
countCell(value: viewModel.status.activeSkills, label: "Active")
|
||||
countCell(value: viewModel.status.staleSkills, label: "Stale")
|
||||
countCell(value: viewModel.status.archivedSkills, label: "Archived")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pinnedSection: some View {
|
||||
if !viewModel.status.pinnedNames.isEmpty {
|
||||
ScarfCard {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
ScarfSectionHeader("Pinned")
|
||||
Text("Pinned skills are never auto-archived or rewritten by the curator.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
FlowLayout(spacing: ScarfSpace.s2) {
|
||||
ForEach(viewModel.status.pinnedNames, id: \.self) { name in
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "pin.fill")
|
||||
.font(.system(size: 10))
|
||||
Text(name)
|
||||
.scarfStyle(.caption)
|
||||
Button {
|
||||
Task { await viewModel.unpin(name) }
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Unpin")
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.md)
|
||||
.fill(ScarfColor.accentTint)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activityTables: some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
|
||||
if !viewModel.status.leastRecentlyActive.isEmpty {
|
||||
skillTable(title: "Least recently active", rows: viewModel.status.leastRecentlyActive)
|
||||
}
|
||||
if !viewModel.status.mostActive.isEmpty {
|
||||
skillTable(title: "Most active", rows: viewModel.status.mostActive)
|
||||
}
|
||||
if !viewModel.status.leastActive.isEmpty {
|
||||
skillTable(title: "Least active", rows: viewModel.status.leastActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func skillTable(title: String, rows: [HermesCuratorSkillRow]) -> some View {
|
||||
ScarfCard {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
ScarfSectionHeader(title)
|
||||
ForEach(rows) { row in
|
||||
HStack(alignment: .center, spacing: ScarfSpace.s2) {
|
||||
Text(row.name)
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
counterChip(label: "use", value: row.useCount)
|
||||
counterChip(label: "view", value: row.viewCount)
|
||||
counterChip(label: "patch", value: row.patchCount)
|
||||
Text(row.lastActivityLabel)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.frame(width: 92, alignment: .trailing)
|
||||
Button {
|
||||
Task { await viewModel.pin(row.name) }
|
||||
} label: {
|
||||
Image(systemName: viewModel.status.pinnedNames.contains(row.name)
|
||||
? "pin.fill" : "pin")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(viewModel.status.pinnedNames.contains(row.name) ? "Pinned" : "Pin skill")
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func counterChip(label: String, value: Int) -> some View {
|
||||
Text("\(label) \(value)")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.sm)
|
||||
.fill(ScarfColor.backgroundTertiary)
|
||||
)
|
||||
}
|
||||
|
||||
private func lastReportSection(markdown: String) -> some View {
|
||||
ScarfCard {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
ScarfSectionHeader("Last report")
|
||||
Text(markdown)
|
||||
.scarfStyle(.mono)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoRow(label: String, value: String) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(label)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
Text(value)
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func countCell(value: Int, label: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(value)")
|
||||
.scarfStyle(.title2)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Text(label)
|
||||
.scarfStyle(.captionUppercase)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
.frame(minWidth: 64, alignment: .leading)
|
||||
}
|
||||
|
||||
private func transientToast(_ text: String) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(ScarfColor.success)
|
||||
Text(text)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, ScarfSpace.s3)
|
||||
.padding(.vertical, 6)
|
||||
.background(ScarfColor.accentTint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md))
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple `FlowLayout` for the pinned-skill chips. Custom layout
|
||||
/// keeps the chip wrap behaviour predictable across DynamicType
|
||||
/// scales without resorting to LazyVGrid (which forces fixed columns).
|
||||
private struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 8
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x + size.width > maxWidth {
|
||||
x = 0
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
return CGSize(width: maxWidth, height: y + rowHeight)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
var x = bounds.minX
|
||||
var y = bounds.minY
|
||||
var rowHeight: CGFloat = 0
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x + size.width > bounds.maxX {
|
||||
x = bounds.minX
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
// Interact
|
||||
case chat = "Chat"
|
||||
case memory = "Memory"
|
||||
case curator = "Curator"
|
||||
case skills = "Skills"
|
||||
// Configure (Phase 2/3 additions)
|
||||
case platforms = "Platforms"
|
||||
@@ -40,6 +41,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case .projects: return "Projects"
|
||||
case .chat: return "Chat"
|
||||
case .memory: return "Memory"
|
||||
case .curator: return "Curator"
|
||||
case .skills: return "Skills"
|
||||
case .platforms: return "Platforms"
|
||||
case .personalities: return "Personalities"
|
||||
@@ -67,6 +69,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case .projects: return "square.grid.2x2"
|
||||
case .chat: return "text.bubble"
|
||||
case .memory: return "brain"
|
||||
case .curator: return "sparkles"
|
||||
case .skills: return "lightbulb"
|
||||
case .platforms: return "dot.radiowaves.left.and.right"
|
||||
case .personalities: return "theatermasks"
|
||||
|
||||
@@ -14,21 +14,36 @@ struct SidebarView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(ServerLiveStatusRegistry.self) private var liveRegistry
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
|
||||
private static let sections: [Section] = [
|
||||
Section(title: "Monitor", items: [.dashboard, .insights, .sessions, .activity]),
|
||||
Section(title: "Projects", items: [.projects]),
|
||||
Section(title: "Interact", items: [.chat, .memory, .skills]),
|
||||
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
|
||||
Section(title: "Manage", items: [.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]),
|
||||
]
|
||||
/// Capability-gated sections. Curator is v0.12+ only; older Hermes
|
||||
/// hosts get the same Interact section minus the Curator row.
|
||||
/// Building the list lazily off the env keeps the sidebar honest
|
||||
/// when the user reconnects to a different-version host.
|
||||
private var sections: [Section] {
|
||||
let caps = capabilitiesStore?.capabilities
|
||||
|
||||
var interact: [SidebarSection] = [.chat, .memory]
|
||||
if caps?.hasCurator ?? false {
|
||||
interact.append(.curator)
|
||||
}
|
||||
interact.append(.skills)
|
||||
|
||||
return [
|
||||
Section(title: "Monitor", items: [.dashboard, .insights, .sessions, .activity]),
|
||||
Section(title: "Projects", items: [.projects]),
|
||||
Section(title: "Interact", items: interact),
|
||||
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
|
||||
Section(title: "Manage", items: [.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]),
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(Self.sections) { section in
|
||||
ForEach(sections) { section in
|
||||
sectionView(section)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user