mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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 errorsLog: String { home + "/logs/errors.log" }
|
||||||
public nonisolated var agentLog: String { home + "/logs/agent.log" }
|
public nonisolated var agentLog: String { home + "/logs/agent.log" }
|
||||||
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||||
/// Curator run report, JSON (v0.12+). Written by `hermes curator` on
|
/// Curator run-reports root (v0.12+). Hermes writes per-cycle dirs
|
||||||
/// each cycle; consumed by `CuratorViewModel` for structured stats.
|
/// under here named `<YYYYMMDD-HHMMSS>/` containing `run.json` and
|
||||||
public nonisolated var curatorReportJSON: String { home + "/logs/curator/run.json" }
|
/// `REPORT.md`. The `last_report_path` field on `curator_state`
|
||||||
/// Curator human-readable run report (v0.12+). Renders as the
|
/// points at the most recent dir; `CuratorViewModel` resolves the
|
||||||
/// "Last run" text in CuratorView.
|
/// JSON/Markdown files relative to it.
|
||||||
public nonisolated var curatorReportMD: String { home + "/logs/curator/REPORT.md" }
|
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 scarfDir: String { home + "/scarf" }
|
||||||
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
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 onSoftDisconnect: @MainActor () async -> Void
|
||||||
let onForget: @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
|
/// One coordinator per server-connected session. Cross-tab
|
||||||
/// signalling (Dashboard row → Chat tab resume, Project Detail
|
/// signalling (Dashboard row → Chat tab resume, Project Detail
|
||||||
/// → in-project chat handoff, notification deep-link → Chat) flows
|
/// → in-project chat handoff, notification deep-link → Chat) flows
|
||||||
@@ -172,6 +180,8 @@ private struct SystemTab: View {
|
|||||||
let onSoftDisconnect: @MainActor () async -> Void
|
let onSoftDisconnect: @MainActor () async -> Void
|
||||||
let onForget: @MainActor () async -> Void
|
let onForget: @MainActor () async -> Void
|
||||||
|
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
@State private var showForgetConfirmation = false
|
@State private var showForgetConfirmation = false
|
||||||
@State private var isForgetting = false
|
@State private var isForgetting = false
|
||||||
@State private var isDisconnecting = false
|
@State private var isDisconnecting = false
|
||||||
@@ -206,6 +216,15 @@ private struct SystemTab: View {
|
|||||||
}
|
}
|
||||||
.scarfGoCompactListRow()
|
.scarfGoCompactListRow()
|
||||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
.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 {
|
NavigationLink {
|
||||||
CronListView(config: config)
|
CronListView(config: config)
|
||||||
} label: {
|
} 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 .projects: ProjectsView(context: serverContext)
|
||||||
case .chat: ChatView()
|
case .chat: ChatView()
|
||||||
case .memory: MemoryView(context: serverContext)
|
case .memory: MemoryView(context: serverContext)
|
||||||
|
case .curator: CuratorView(context: serverContext)
|
||||||
case .skills: SkillsView(context: serverContext)
|
case .skills: SkillsView(context: serverContext)
|
||||||
case .platforms: PlatformsView(context: serverContext)
|
case .platforms: PlatformsView(context: serverContext)
|
||||||
case .personalities: PersonalitiesView(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
|
// Interact
|
||||||
case chat = "Chat"
|
case chat = "Chat"
|
||||||
case memory = "Memory"
|
case memory = "Memory"
|
||||||
|
case curator = "Curator"
|
||||||
case skills = "Skills"
|
case skills = "Skills"
|
||||||
// Configure (Phase 2/3 additions)
|
// Configure (Phase 2/3 additions)
|
||||||
case platforms = "Platforms"
|
case platforms = "Platforms"
|
||||||
@@ -40,6 +41,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .projects: return "Projects"
|
case .projects: return "Projects"
|
||||||
case .chat: return "Chat"
|
case .chat: return "Chat"
|
||||||
case .memory: return "Memory"
|
case .memory: return "Memory"
|
||||||
|
case .curator: return "Curator"
|
||||||
case .skills: return "Skills"
|
case .skills: return "Skills"
|
||||||
case .platforms: return "Platforms"
|
case .platforms: return "Platforms"
|
||||||
case .personalities: return "Personalities"
|
case .personalities: return "Personalities"
|
||||||
@@ -67,6 +69,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .projects: return "square.grid.2x2"
|
case .projects: return "square.grid.2x2"
|
||||||
case .chat: return "text.bubble"
|
case .chat: return "text.bubble"
|
||||||
case .memory: return "brain"
|
case .memory: return "brain"
|
||||||
|
case .curator: return "sparkles"
|
||||||
case .skills: return "lightbulb"
|
case .skills: return "lightbulb"
|
||||||
case .platforms: return "dot.radiowaves.left.and.right"
|
case .platforms: return "dot.radiowaves.left.and.right"
|
||||||
case .personalities: return "theatermasks"
|
case .personalities: return "theatermasks"
|
||||||
|
|||||||
@@ -14,21 +14,36 @@ struct SidebarView: View {
|
|||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
@Environment(ServerLiveStatusRegistry.self) private var liveRegistry
|
@Environment(ServerLiveStatusRegistry.self) private var liveRegistry
|
||||||
@Environment(\.serverContext) private var serverContext
|
@Environment(\.serverContext) private var serverContext
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
private static let sections: [Section] = [
|
/// 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: "Monitor", items: [.dashboard, .insights, .sessions, .activity]),
|
||||||
Section(title: "Projects", items: [.projects]),
|
Section(title: "Projects", items: [.projects]),
|
||||||
Section(title: "Interact", items: [.chat, .memory, .skills]),
|
Section(title: "Interact", items: interact),
|
||||||
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
|
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
|
||||||
Section(title: "Manage", items: [.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]),
|
Section(title: "Manage", items: [.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]),
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
header
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
ForEach(Self.sections) { section in
|
ForEach(sections) { section in
|
||||||
sectionView(section)
|
sectionView(section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user