mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +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:
@@ -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
|
||||
Reference in New Issue
Block a user