From a90a29add8f09c99a0d089789eb3d50eb2151ab6 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 12:10:06 +0200 Subject: [PATCH 01/20] feat(hermes-v12): version-aware capability detection (Phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `HermesCapabilities` (parsed from `hermes --version`) and a per-server `HermesCapabilitiesStore` injected into Mac `ContextBoundRoot` and iOS `ScarfGoTabRoot` via `.environment(_:)` and `.hermesCapabilities`. Subsequent v0.12-targeted UI (Curator, Kanban, ACP image input, auxiliary.curator, prompt cache TTL, etc.) can branch on these flags so older Hermes installs degrade silently instead of throwing on unknown CLI subcommands. Adds `curatorReportJSON` / `curatorReportMD` paths to `HermesPathSet`. Bumps the Hermes version target in CLAUDE.md from v2026.4.23 (v0.11.0) to v2026.4.30 (v0.12.0) and lists the v0.12 surfaces Scarf will consume. Side fixes: - `M5FeatureVMTests.ScriptedTransport` was missing `cachedSnapshotPath` after that property was added in 7b864d7; added `URL? { nil }` stub. - `M0dViewModelsTests` referenced `.degraded(reason:)` after the case gained `hint` + `cause`; updated. - `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive` used `Foundation.Process` unconditionally, breaking the iOS build (Process is unavailable on iOS). Wrapped in `#if !os(iOS)` with iOS stubs that throw — the backup/restore flow is Mac-only by design. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 22 +- .../ScarfCore/Models/HermesPathSet.swift | 6 + .../Services/HermesCapabilities.swift | 314 ++++++++++++++++++ .../Services/RemoteBackupService.swift | 9 + .../Services/RemoteRestoreService.swift | 8 + .../HermesCapabilitiesTests.swift | 136 ++++++++ .../ScarfCoreTests/M0dViewModelsTests.swift | 4 +- .../ScarfCoreTests/M5FeatureVMTests.swift | 1 + scarf/Scarf iOS/App/ScarfGoTabRoot.swift | 25 ++ scarf/scarf/scarfApp.swift | 9 + 10 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCapabilitiesTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 3288cc8..e422cc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,9 +113,27 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc ## Hermes Version -Targets Hermes v2026.4.23 (v0.11.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse. +Targets Hermes v2026.4.30 (v0.12.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse. -**v2026.4.23 (v0.11.0)** added (Scarf-relevant subset): +**Capability gating.** Scarf detects the target's Hermes version once per server connection via [HermesCapabilities](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift) (`hermes --version` → semver + `YYYY.M.D` parse). The resulting `HermesCapabilitiesStore` is injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`; UI that depends on a v0.12+ surface (Curator, Kanban, ACP image input, `auxiliary.curator`, `prompt_caching.cache_ttl`, Piper TTS, Vercel terminal) reads it through the typed environment key. Pre-v0.12 hosts gracefully hide the new affordances rather than throwing on unknown CLI subcommands. Add a new flag at the top of `HermesCapabilities` whenever Scarf gains a release-gated UI surface. + +**v2026.4.30 (v0.12.0)** added (Scarf-relevant subset): + +- **Autonomous Curator** — `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Reports land at `~/.hermes/logs/curator/run.json` + `REPORT.md` (paths exposed via `HermesPathSet.curatorReportJSON` / `curatorReportMD`). Surfaced in Scarf as a dedicated "Curator" sidebar item under Interact (between Memory and Skills) on Mac, plus a read-only iOS panel; both gated on `HermesCapabilities.hasCurator`. +- **5 new inference providers** — GMI Cloud, Azure AI Foundry, LM Studio (upgraded to first-class), MiniMax OAuth, Tencent Tokenhub. Mirrored in `ModelCatalogService.overlayOnlyProviders`; the model picker reaches all of them automatically. +- **`flush_memories` aux task removed** — `auxiliary.flush_memories` is gone from Hermes config. Scarf's `AuxiliarySettings.flushMemories` field is dropped; `HermesCapabilities.hasFlushMemoriesAux` returns `true` only on pre-v0.12 hosts so the row stays visible there. +- **`auxiliary.curator` aux task added** — Curator's review model is configurable independently of the main model. Surfaced in `Settings → Auxiliary` next to the other aux rows. +- **Multimodal ACP `session/prompt`** — ACP advertises and forwards image content blocks. Scarf chat composers (Mac drag/drop + paste; iOS PhotosPicker + camera) attach images that flow through `ACPClient.sendPrompt` as `[{type: "text"...}, {type: "image", source: {type: "base64", ...}}]`. Gated on `HermesCapabilities.hasACPImagePrompts`. +- **CLI additions:** `hermes -z ` (non-interactive one-shot), `hermes update --check` (preflight), `hermes fallback` (manage fallback providers), `hermes curator` (status / run / pause / resume / pin / unpin / restore), `hermes kanban` (full task-board CLI; multi-profile collab was reverted upstream so Scarf ships a read-only Kanban view only). All capability-gated. +- **Skills surface:** `hermes skills install ` direct-URL install (SkillsView "Install from URL…" toolbar button), `/reload-skills` slash command (Skills view "Reload" button), `hermes skills list` enabled/disabled status (per-row toggle), Curator pin badge (driven from `hermes curator status`). +- **Two new gateway platforms:** Microsoft Teams (19th, plugin-shipped) + Tencent 元宝 / Yuanbao (18th, native). Surfaced in the Mac Platforms tab. +- **Cron upgrades:** per-job `--workdir` (project-aware cwd) and `--context-from` (chain outputs from another job). Editor sheet adds Project picker + dependency dropdown. +- **Settings deltas:** `prompt_caching.cache_ttl` (5m/1h picker), `redaction.enabled` toggle (off-by-default in v0.12 — toggle restores it), `agent.runtime_metadata_footer` toggle, Piper added to TTS provider list, `vercel` added to terminal backend list. +- **Bundled plugins:** Spotify, Google Meet, Langfuse observability, hermes-achievements (visible in Plugins tab). +- **`hermes memory` providers:** honcho, openviking, mem0, hindsight, holographic, retaindb, byterover. `Settings → Memory` per-provider "Setup…" button shells out to `hermes memory setup `. +- **Schema is unchanged from v0.11** — same state.db columns (`messages.reasoning_content`, `sessions.api_call_count` introduced in v0.11 remain). No migration needed. + +**v2026.4.23 (v0.11.0)** added (historical context, still consumed by Scarf when running against a pre-v0.12 host): - `/steer ` — non-interruptive mid-run guidance slash command. Surfaced in Scarf chat menus via `RichChatViewModel.nonInterruptiveCommands`; `ChatViewModel.sendViaACP` (Mac) and `ChatController.send` (iOS) skip the "Agent working…" status flip and show a transient toast instead. - New CLI subcommands: `hermes plugins` / `profile` / `webhook` / `insights` / `logs` / `memory reset` / `completion` / `dashboard`. Scarf v2.5 adopts **`hermes memory reset`** (toolbar button on MemoryView with destructive confirmation). The other CLIs are documented here for v2.6 — Scarf still reads `~/.hermes/plugins/`, `~/.hermes/profiles/` etc directly today; switching those paths to the canonical CLI is a forward-compatible change to make when bandwidth permits. diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift index 004dd8a..b0fd2de 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift @@ -75,6 +75,12 @@ 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" } public nonisolated var scarfDir: String { home + "/scarf" } public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift new file mode 100644 index 0000000..303acb5 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift @@ -0,0 +1,314 @@ +import Foundation +import Observation +#if canImport(os) +import os +#endif + +/// What this Hermes installation can do, derived from `hermes --version`. +/// +/// Scarf tracks Hermes feature releases by date-version + semver. v0.12 added +/// a dozen surfaces (Curator, Kanban, multimodal ACP, ...) and removed a few +/// (`flush_memories` aux task). UI that branches on these surfaces calls +/// the boolean accessors here so older Hermes installs degrade silently +/// instead of throwing on an unknown CLI subcommand. +/// +/// Pure value type — no side effects. The async detection lives in +/// `HermesCapabilitiesStore`. +public struct HermesCapabilities: Sendable, Equatable { + /// Raw version line as printed by `hermes --version`. Preserved verbatim + /// so diagnostics views can show the exact string Scarf saw. + public let versionLine: String + /// Parsed `0.X.Y`. `nil` when the output didn't match the expected format + /// (e.g. Hermes returned an error, or a future format change). + public let semver: SemVer? + /// Parsed `YYYY.M.D` from the parenthesized date suffix. `nil` when + /// absent — older Hermes builds didn't always emit it. + public let dateVersion: DateVersion? + + public init(versionLine: String, semver: SemVer?, dateVersion: DateVersion?) { + self.versionLine = versionLine + self.semver = semver + self.dateVersion = dateVersion + } + + /// Sentinel for "not yet detected" / "detection failed". All capability + /// flags resolve to `false` so unguarded UI stays hidden until the real + /// version lands. + public static let empty = HermesCapabilities( + versionLine: "", + semver: nil, + dateVersion: nil + ) + + public var detected: Bool { semver != nil } + + // MARK: - Capability flags + // + // Add a new flag here when Scarf gains UI that conditionally branches on + // a Hermes capability. Keep the comparison conservative: `>= 0.12.0` + // covers users still on the 0.12 line who haven't upgraded to 0.13 yet. + + /// `hermes curator` autonomous skill maintenance (v0.12+). + public var hasCurator: Bool { atLeastSemver(0, 12, 0) } + + /// `hermes fallback` provider management (v0.12+). + public var hasFallbackCommand: Bool { atLeastSemver(0, 12, 0) } + + /// `hermes kanban` task board CLI (v0.12+). + public var hasKanban: Bool { atLeastSemver(0, 12, 0) } + + /// `hermes -z ` non-interactive one-shot mode (v0.12+). + public var hasOneShot: Bool { atLeastSemver(0, 12, 0) } + + /// `hermes skills install ` direct-URL install (v0.12+). + public var hasSkillURLInstall: Bool { atLeastSemver(0, 12, 0) } + + /// ACP `session/prompt` accepts image content blocks (v0.12+). + public var hasACPImagePrompts: Bool { atLeastSemver(0, 12, 0) } + + /// `hermes update --check` preflight (v0.12+). + public var hasUpdateCheck: Bool { atLeastSemver(0, 12, 0) } + + /// Pluggable TTS providers including native Piper (v0.12+). + public var hasPiperTTS: Bool { atLeastSemver(0, 12, 0) } + + /// `terminal.backend = vercel` Vercel Sandbox option (v0.12+). + public var hasVercelTerminal: Bool { atLeastSemver(0, 12, 0) } + + /// `auxiliary.flush_memories` config row was removed in v0.12. + /// Inverse semantics — `true` means the row should still be shown. + public var hasFlushMemoriesAux: Bool { + guard let s = semver else { return false } // unknown → hide + return s < SemVer(major: 0, minor: 12, patch: 0) // pre-v0.12 only + } + + /// `auxiliary.curator` aux task is configurable (v0.12+). + public var hasCuratorAux: Bool { atLeastSemver(0, 12, 0) } + + /// Microsoft Teams (19th platform) and Yuanbao (18th) added in v0.12. + public var hasTeamsPlatform: Bool { atLeastSemver(0, 12, 0) } + public var hasYuanbaoPlatform: Bool { atLeastSemver(0, 12, 0) } + + /// Cron jobs accept `--workdir` and `--context-from` flags (v0.12+). + public var hasCronWorkdir: Bool { atLeastSemver(0, 12, 0) } + + /// `prompt_caching.cache_ttl` config knob (v0.12+). + public var hasPromptCacheTTL: Bool { atLeastSemver(0, 12, 0) } + + /// `redaction.enabled` is now off by default in v0.12 — Scarf surfaces + /// the toggle so users can flip it back on. + public var hasRedactionToggle: Bool { atLeastSemver(0, 12, 0) } + + private func atLeastSemver(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { + guard let s = semver else { return false } + return s >= SemVer(major: major, minor: minor, patch: patch) + } + + public struct SemVer: Sendable, Equatable, Comparable, CustomStringConvertible { + public let major: Int + public let minor: Int + public let patch: Int + + public init(major: Int, minor: Int, patch: Int) { + self.major = major + self.minor = minor + self.patch = patch + } + + public var description: String { "\(major).\(minor).\(patch)" } + + public static func < (a: SemVer, b: SemVer) -> Bool { + if a.major != b.major { return a.major < b.major } + if a.minor != b.minor { return a.minor < b.minor } + return a.patch < b.patch + } + } + + public struct DateVersion: Sendable, Equatable, Comparable, CustomStringConvertible { + public let year: Int + public let month: Int + public let day: Int + + public init(year: Int, month: Int, day: Int) { + self.year = year + self.month = month + self.day = day + } + + public var description: String { "\(year).\(month).\(day)" } + + public static func < (a: DateVersion, b: DateVersion) -> Bool { + if a.year != b.year { return a.year < b.year } + if a.month != b.month { return a.month < b.month } + return a.day < b.day + } + } + + /// Parse a `Hermes Agent v0.12.0 (2026.4.30)` line out of `hermes --version` + /// output. Tolerates leading/trailing whitespace, extra header lines + /// (e.g. `Project:`, `Python:`), and the absence of the parenthesized + /// date suffix. + /// + /// Returns `.empty` when no recognizable version line is present so + /// callers don't have to special-case nil. + public static func parse(_ output: String) -> HermesCapabilities { + for raw in output.components(separatedBy: "\n") { + let line = raw.trimmingCharacters(in: .whitespaces) + guard line.contains("Hermes Agent v") else { continue } + return parseLine(line) + } + return .empty + } + + /// `Hermes Agent v0.12.0 (2026.4.30)` → semver + date. Returns `.empty` + /// when the line doesn't match. Public for unit tests; production callers + /// should use `parse(_:)`. + public static func parseLine(_ line: String) -> HermesCapabilities { + // Locate the "v" right after "Hermes Agent ". Don't anchor at line + // start — older builds prefix with ANSI color codes Scarf would + // need to strip. + guard let vRange = line.range(of: "Hermes Agent v") else { return .empty } + let tail = String(line[vRange.upperBound...]) + + // Read digits separated by dots until we hit non-version content. + // First three components are semver. A trailing `(Y.M.D)` is the + // date version. + let semverEnd = tail.firstIndex(where: { c in + !(c.isNumber || c == ".") + }) ?? tail.endIndex + let semverStr = String(tail[..= 3 else { return .empty } + let semver = SemVer( + major: semverParts[0], + minor: semverParts[1], + patch: semverParts[2] + ) + + // Optional date suffix. + var dateVersion: DateVersion? + if let openParen = tail.firstIndex(of: "("), + let closeParen = tail.firstIndex(of: ")"), + openParen < closeParen { + let dateStr = tail[tail.index(after: openParen)..? + + public init(context: ServerContext) { + self.context = context + // Kick off a one-shot detection. Subsequent refreshes are explicit. + // Task captures `[weak self]`, so if the store is freed before + // detection completes the closure simply no-ops. + refreshTask = Task { [weak self] in + await self?.refresh() + } + } + + public func refresh() async { + isLoading = true + let context = self.context + let parsed = await Task.detached(priority: .utility) { () -> HermesCapabilities in + return Self.detectSync(context: context) + }.value + + self.capabilities = parsed + self.isLoading = false + + #if canImport(os) + if parsed.detected { + logger.info("Hermes \(parsed.versionLine, privacy: .public) detected on \(self.context.displayName, privacy: .public)") + } else { + logger.warning("Hermes version not detected on \(self.context.displayName, privacy: .public)") + } + #endif + } + + /// Synchronous detection helper. Lives here (not on `HermesCapabilities`) + /// because `ServerContext.makeTransport()` is a side-effecting call that + /// pulls in the platform-appropriate transport (LocalTransport on Mac, + /// CitadelServerTransport on iOS). The pure parser remains side-effect-free. + nonisolated private static func detectSync(context: ServerContext) -> HermesCapabilities { + let transport = context.makeTransport() + let executable = context.paths.hermesBinary + do { + let result = try transport.runProcess( + executable: executable, + args: ["--version"], + stdin: nil, + timeout: 10 + ) + // `hermes --version` writes to stdout but Scarf's transport + // helpers occasionally split error output across stderr — fold + // both so the parser sees whichever stream the line lands on. + let combined = result.stdoutString + result.stderrString + guard result.exitCode == 0 else { return .empty } + return HermesCapabilities.parse(combined) + } catch { + return .empty + } + } +} + +// MARK: - SwiftUI environment wiring + +#if canImport(SwiftUI) +import SwiftUI + +private struct HermesCapabilitiesStoreKey: EnvironmentKey { + static let defaultValue: HermesCapabilitiesStore? = nil +} + +extension EnvironmentValues { + /// The active server's capability store. `nil` outside the per-server + /// `ContextBoundRoot`. Callers should treat `nil` and `.empty` capabilities + /// the same — defensive code for harness scenarios (Previews, smoke tests). + public var hermesCapabilities: HermesCapabilitiesStore? { + get { self[HermesCapabilitiesStoreKey.self] } + set { self[HermesCapabilitiesStoreKey.self] = newValue } + } +} + +extension View { + /// Inject a `HermesCapabilitiesStore` into the environment. Mirrors the + /// usual `.environment(_:)` shape but routes through the typed key + /// above so callers don't need to import the key. + public func hermesCapabilities(_ store: HermesCapabilitiesStore) -> some View { + environment(\.hermesCapabilities, store) + } +} +#endif diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift index 2d1836f..5a98e95 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift @@ -496,7 +496,15 @@ public final class RemoteBackupService: @unchecked Sendable { /// macOS ships `zip` at this fixed path so we don't need a PATH /// search. `-r` recurse, `-q` quiet, `-X` strip extended attrs /// for reproducibility. + /// + /// Mac-only: iOS doesn't ship `/usr/bin/zip` and Foundation's `Process` + /// is unavailable in the iOS SDK. The whole backup flow is a Mac-side + /// operation; the iOS stub throws so any accidental call surfaces a + /// clear message instead of an opaque link error. private static func zipDirectory(workDir: URL, into archive: URL) throws { + #if os(iOS) + throw BackupError.zipFailed("Backup zip is not supported on iOS — run the backup from the Mac app.") + #else let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip") proc.currentDirectoryURL = workDir @@ -515,6 +523,7 @@ public final class RemoteBackupService: @unchecked Sendable { .flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? "" throw BackupError.zipFailed("zip exited \(proc.terminationStatus): \(tail)") } + #endif } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift index 868b730..a6b9fc9 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift @@ -436,7 +436,14 @@ public final class RemoteRestoreService: @unchecked Sendable { // MARK: - Helpers + /// Mac-only: iOS doesn't ship `/usr/bin/unzip` and Foundation's + /// `Process` is unavailable in the iOS SDK. Restore is initiated from + /// the Mac app; the iOS stub throws so any accidental call surfaces a + /// clear message instead of a link-time failure. private static func unzipArchive(at archive: URL, into dest: URL) throws { + #if os(iOS) + throw RestoreError.archiveUnreadable("Restore unzip is not supported on iOS — run the restore from the Mac app.") + #else let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") proc.arguments = ["-q", archive.path, "-d", dest.path] @@ -454,6 +461,7 @@ public final class RemoteRestoreService: @unchecked Sendable { .flatMap { $0.flatMap { String(data: $0, encoding: .utf8) } } ?? "" throw RestoreError.archiveUnreadable("unzip exited \(proc.terminationStatus): \(tail)") } + #endif } /// Hash a local file in 1 MB chunks. We avoid loading the whole diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCapabilitiesTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCapabilitiesTests.swift new file mode 100644 index 0000000..2dda691 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCapabilitiesTests.swift @@ -0,0 +1,136 @@ +import Testing +import Foundation +@testable import ScarfCore + +/// Pure parser tests for `HermesCapabilities`. The detection store +/// (`HermesCapabilitiesStore`) is exercised separately under integration +/// tests since it spawns `hermes --version`. +@Suite struct HermesCapabilitiesTests { + + // MARK: - Version line parsing + + @Test func parseV012ReleaseLine() { + let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)") + #expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 12, patch: 0)) + #expect(caps.dateVersion == HermesCapabilities.DateVersion(year: 2026, month: 4, day: 30)) + #expect(caps.detected) + } + + @Test func parseV011ReleaseLine() { + let caps = HermesCapabilities.parseLine("Hermes Agent v0.11.0 (2026.4.23)") + #expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 11, patch: 0)) + #expect(caps.dateVersion == HermesCapabilities.DateVersion(year: 2026, month: 4, day: 23)) + } + + @Test func parseSemverWithoutDate() { + // Some older Hermes builds emit only the semver suffix. + let caps = HermesCapabilities.parseLine("Hermes Agent v0.10.5") + #expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 10, patch: 5)) + #expect(caps.dateVersion == nil) + } + + @Test func parseFullStdoutBlock() { + // Real `hermes --version` output is multi-line; the version sits on + // the first line and the rest is metadata. + let stdout = """ + Hermes Agent v0.12.0 (2026.4.30) + Project: /Users/alan/.hermes/hermes-agent + Python: 3.11.15 + OpenAI SDK: 2.31.0 + Up to date + """ + let caps = HermesCapabilities.parse(stdout) + #expect(caps.semver?.minor == 12) + #expect(caps.dateVersion?.year == 2026) + } + + @Test func parseRejectsUnrelatedOutput() { + let caps = HermesCapabilities.parse("hermes: command not found") + #expect(caps.semver == nil) + #expect(!caps.detected) + } + + @Test func parseHandlesEmptyString() { + let caps = HermesCapabilities.parse("") + #expect(caps == .empty) + } + + @Test func parseHandlesPartialSemver() { + // "v0.11" without the patch component shouldn't accidentally match. + let caps = HermesCapabilities.parseLine("Hermes Agent v0.11") + #expect(caps.semver == nil) + } + + // MARK: - SemVer ordering + + @Test func semverOrdering() { + let v0_11_0 = HermesCapabilities.SemVer(major: 0, minor: 11, patch: 0) + let v0_12_0 = HermesCapabilities.SemVer(major: 0, minor: 12, patch: 0) + let v0_12_5 = HermesCapabilities.SemVer(major: 0, minor: 12, patch: 5) + let v1_0_0 = HermesCapabilities.SemVer(major: 1, minor: 0, patch: 0) + #expect(v0_11_0 < v0_12_0) + #expect(v0_12_0 < v0_12_5) + #expect(v0_12_5 < v1_0_0) + } + + // MARK: - Capability flags + + @Test func v012FlagsAllOn() { + let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)") + #expect(caps.hasCurator) + #expect(caps.hasFallbackCommand) + #expect(caps.hasKanban) + #expect(caps.hasOneShot) + #expect(caps.hasSkillURLInstall) + #expect(caps.hasACPImagePrompts) + #expect(caps.hasUpdateCheck) + #expect(caps.hasPiperTTS) + #expect(caps.hasVercelTerminal) + #expect(caps.hasCuratorAux) + #expect(caps.hasTeamsPlatform) + #expect(caps.hasYuanbaoPlatform) + #expect(caps.hasCronWorkdir) + #expect(caps.hasPromptCacheTTL) + #expect(caps.hasRedactionToggle) + // flush_memories was REMOVED in v0.12 — flag inverts. + #expect(!caps.hasFlushMemoriesAux) + } + + @Test func v011FlagsAllOff() { + let caps = HermesCapabilities.parseLine("Hermes Agent v0.11.0 (2026.4.23)") + #expect(!caps.hasCurator) + #expect(!caps.hasFallbackCommand) + #expect(!caps.hasKanban) + #expect(!caps.hasOneShot) + #expect(!caps.hasSkillURLInstall) + #expect(!caps.hasACPImagePrompts) + #expect(!caps.hasUpdateCheck) + #expect(!caps.hasPiperTTS) + #expect(!caps.hasVercelTerminal) + #expect(!caps.hasCuratorAux) + #expect(!caps.hasTeamsPlatform) + #expect(!caps.hasYuanbaoPlatform) + #expect(!caps.hasCronWorkdir) + #expect(!caps.hasPromptCacheTTL) + #expect(!caps.hasRedactionToggle) + // flush_memories aux row was still alive on v0.11. + #expect(caps.hasFlushMemoriesAux) + } + + @Test func emptyCapabilitiesAllOff() { + // Undetected installs should hide every gated UI surface. + let caps = HermesCapabilities.empty + #expect(!caps.hasCurator) + #expect(!caps.hasFlushMemoriesAux) // unknown → hide either way + #expect(!caps.detected) + } + + @Test func futureVersionRetainsCapabilities() { + // A v0.13 (hypothetical) should still see all v0.12 capabilities on. + let caps = HermesCapabilities.parseLine("Hermes Agent v0.13.0 (2026.6.1)") + #expect(caps.hasCurator) + #expect(caps.hasACPImagePrompts) + // And flush_memories stays gone. + #expect(!caps.hasFlushMemoriesAux) + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift index d6ec9ee..3648370 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift @@ -37,8 +37,8 @@ import Foundation let b: ConnectionStatusViewModel.Status = .connected #expect(a == b) - let c: ConnectionStatusViewModel.Status = .degraded(reason: "x") - let d: ConnectionStatusViewModel.Status = .degraded(reason: "x") + let c: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown) + let d: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown) #expect(c == d) let e: ConnectionStatusViewModel.Status = .idle diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift index 5c84cdb..dbe3330 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift @@ -456,6 +456,7 @@ import Foundation } } func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) } + var cachedSnapshotPath: URL? { nil } func watchPaths(_ paths: [String]) -> AsyncStream { AsyncStream { $0.finish() } } diff --git a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift index 0fe2512..939aa21 100644 --- a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift +++ b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift @@ -36,6 +36,29 @@ struct ScarfGoTabRoot: View { /// through here. @State private var coordinator = ScarfGoCoordinator() + /// Hermes version + capability flags for this remote. Drives the + /// iOS version banner (v0.11 hosts get a yellow "update for new + /// features" banner) and capability-gated affordances like ACP + /// image attachments. Constructed once per server connection so + /// the detection runs over the active SSH transport. + @State private var capabilities: HermesCapabilitiesStore + + init( + serverID: ServerID, + config: IOSServerConfig, + key: SSHKeyBundle, + onSoftDisconnect: @escaping @MainActor () async -> Void, + onForget: @escaping @MainActor () async -> Void + ) { + self.serverID = serverID + self.config = config + self.key = key + self.onSoftDisconnect = onSoftDisconnect + self.onForget = onForget + let ctx = config.toServerContext(id: serverID) + _capabilities = State(initialValue: HermesCapabilitiesStore(context: ctx)) + } + /// SwiftUI's `.onChange(of: ScenePhase)` modifier on a non-active /// tab doesn't fire while the tab is unmounted — the coordinator /// is the single source of truth for scene-phase transitions @@ -118,6 +141,8 @@ struct ScarfGoTabRoot: View { .tabViewStyle(.sidebarAdaptable) .environment(\.serverContext, ctx) .environment(\.scarfGoCoordinator, coordinator) + .environment(capabilities) + .hermesCapabilities(capabilities) .onAppear { // Give the notification router a handle to this session's // coordinator so notification-taps can route across tabs. diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift index 5dcbcc2..9379ad0 100644 --- a/scarf/scarf/scarfApp.swift +++ b/scarf/scarf/scarfApp.swift @@ -196,12 +196,19 @@ private struct ContextBoundRoot: View { @State private var coordinator: AppCoordinator @State private var fileWatcher: HermesFileWatcher @State private var chatViewModel: ChatViewModel + /// Per-window snapshot of the target Hermes installation's capability + /// flags. Drives sidebar visibility (Curator, Kanban only on v0.12+), + /// settings rows (flush_memories aux dropped on v0.12), and version + /// banners. Refreshes once on init; explicit `refresh()` call rerun + /// after a `hermes update`. + @State private var capabilities: HermesCapabilitiesStore init(context: ServerContext) { self.context = context _coordinator = State(initialValue: AppCoordinator()) _fileWatcher = State(initialValue: HermesFileWatcher(context: context)) _chatViewModel = State(initialValue: ChatViewModel(context: context)) + _capabilities = State(initialValue: HermesCapabilitiesStore(context: context)) } var body: some View { @@ -209,6 +216,8 @@ private struct ContextBoundRoot: View { .environment(coordinator) .environment(fileWatcher) .environment(chatViewModel) + .environment(capabilities) + .hermesCapabilities(capabilities) // Per-window title shows which server this window is bound to. // Local: "Scarf — Local". Remote: "Scarf — Mardon Mac Mini". // The colored dot lives inside the toolbar switcher; the window From da721fa2765eb37d2aee7910245970933b66093e Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 12:16:37 +0200 Subject: [PATCH 02/20] feat(hermes-v12): provider catalog + auxiliary swap (Phase B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the five v0.12 inference providers to ModelCatalogService.overlayOnlyProviders so the model picker reaches them. IDs match HERMES_OVERLAYS verbatim: - gmi → GMI Cloud (api_key) - azure-foundry → Azure AI Foundry (api_key) - lmstudio → LM Studio (api_key, promoted from custom-endpoint alias) - minimax-oauth → MiniMax (OAuth, oauth_external) - tencent-tokenhub → Tencent TokenHub (api_key) Auxiliary tasks: drop the `flush_memories` row (Hermes removed it entirely in v0.12) and add `auxiliary.curator` so users can configure the model the autonomous curator's review fork uses. The Curator row is gated on HermesCapabilities.hasCuratorAux, so v0.11 hosts don't see a control that writes a key Hermes ignores. AuxiliarySettings, the YAML parser, and HealthViewModel's Tool Gateway breakdown are all updated. Side fixes: - CredentialPoolsGatingTests was missing `import ScarfCore` after ModelCatalogService moved to the package (broke the test target's compile against pure-Mac scarf). - Promoted `ModelCatalogService.overlayOnlyProviders` to public so the new `v012OverlayProvidersCarryCorrectAuthTypes` lock-in test can reach it. Tests: 14 ToolGateway tests pass; 209 ScarfCore tests pass; both Mac and iOS schemes build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesConfig.swift | 17 ++++-- .../ScarfCore/Parsing/HermesConfig+YAML.swift | 2 +- .../Services/ModelCatalogService.swift | 57 +++++++++++++++++-- .../Core/Services/HermesFileService.swift | 2 +- .../Health/ViewModels/HealthViewModel.swift | 2 +- .../Settings/Views/Tabs/AuxiliaryTab.swift | 24 ++++++-- scarf/scarf/scarfApp.swift | 6 +- .../CredentialPoolsGatingTests.swift | 1 + scarf/scarfTests/ToolGatewayTests.swift | 26 +++++++++ 9 files changed, 118 insertions(+), 19 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift index 4ca3d47..9cfe2b5 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift @@ -258,7 +258,13 @@ public struct VoiceSettings: Sendable, Equatable { ) } -/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape. +/// Per-task auxiliary model overrides. +/// +/// `flush_memories` was removed entirely in Hermes v0.12 (the underlying +/// task no longer exists), so the corresponding field was dropped here. +/// `curator` was added in v0.12 — Curator's review fork uses its own +/// model so users can keep main-model spend separate from background +/// maintenance. public struct AuxiliarySettings: Sendable, Equatable { public var vision: AuxiliaryModel public var webExtract: AuxiliaryModel @@ -267,7 +273,8 @@ public struct AuxiliarySettings: Sendable, Equatable { public var skillsHub: AuxiliaryModel public var approval: AuxiliaryModel public var mcp: AuxiliaryModel - public var flushMemories: AuxiliaryModel + /// v0.12+; pre-v0.12 Hermes installs ignore this slot. + public var curator: AuxiliaryModel public init( @@ -278,7 +285,7 @@ public struct AuxiliarySettings: Sendable, Equatable { skillsHub: AuxiliaryModel, approval: AuxiliaryModel, mcp: AuxiliaryModel, - flushMemories: AuxiliaryModel + curator: AuxiliaryModel ) { self.vision = vision self.webExtract = webExtract @@ -287,7 +294,7 @@ public struct AuxiliarySettings: Sendable, Equatable { self.skillsHub = skillsHub self.approval = approval self.mcp = mcp - self.flushMemories = flushMemories + self.curator = curator } public nonisolated static let empty = AuxiliarySettings( vision: .empty, @@ -297,7 +304,7 @@ public struct AuxiliarySettings: Sendable, Equatable { skillsHub: .empty, approval: .empty, mcp: .empty, - flushMemories: .empty + curator: .empty ) } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift index f209073..787221c 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift @@ -122,7 +122,7 @@ public extension HermesConfig { skillsHub: aux("skills_hub"), approval: aux("approval"), mcp: aux("mcp"), - flushMemories: aux("flush_memories") + curator: aux("curator") ) let security = SecuritySettings( diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelCatalogService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelCatalogService.swift index bcd5bb8..834520a 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelCatalogService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelCatalogService.swift @@ -425,15 +425,17 @@ public struct ModelCatalogService: Sendable { // MARK: - Hermes overlay providers - /// The six providers Hermes surfaces via `hermes model` that have no + /// The 11 providers Hermes surfaces via `hermes model` that have no /// entry in `models_dev_cache.json` (models.dev doesn't mirror them). /// Mirrors the overlay-only subset of `HERMES_OVERLAYS` in - /// `hermes-agent/hermes_cli/providers.py`. The other ~19 overlay entries + /// `hermes-agent/hermes_cli/providers.py`. The other overlay entries /// already ship in the cache and only add augmentation (base-URL /// override, extra env vars) that Scarf doesn't currently display. /// - /// Keep this in sync with the Python side on Hermes version bumps. - static let overlayOnlyProviders: [String: HermesProviderOverlay] = [ + /// Keep this in sync with the Python side on Hermes version bumps — + /// see `ToolGatewayTests.v012OverlayProvidersCarryCorrectAuthTypes` + /// for the auth-type lock-in. + public static let overlayOnlyProviders: [String: HermesProviderOverlay] = [ "nous": HermesProviderOverlay( displayName: "Nous Portal", baseURL: "https://inference-api.nousresearch.com/v1", @@ -476,6 +478,53 @@ public struct ModelCatalogService: Sendable { subscriptionGated: false, docURL: nil ), + // -- v0.12 additions --------------------------------------------- + // Hermes v2026.4.30 added five overlay-only providers that + // models.dev doesn't mirror. Provider IDs match HERMES_OVERLAYS + // verbatim — drift here means the picker can't reach them. + "gmi": HermesProviderOverlay( + displayName: "GMI Cloud", + baseURL: "https://api.gmi-serving.com/v1", + authType: .apiKey, + subscriptionGated: false, + docURL: nil + ), + "azure-foundry": HermesProviderOverlay( + displayName: "Azure AI Foundry", + // Base URL is per-tenant — Hermes resolves it from the + // AZURE_FOUNDRY_BASE_URL env var at runtime. Leave nil so the + // settings UI shows "Tenant URL — set via env" instead of a + // misleading default. + baseURL: nil, + authType: .apiKey, + subscriptionGated: false, + docURL: nil + ), + "lmstudio": HermesProviderOverlay( + displayName: "LM Studio", + // v0.12 promotes LM Studio from custom-endpoint alias to a + // first-class provider. 1234 is the LM Studio default port; + // users with a non-default port set LM_BASE_URL. + baseURL: "http://127.0.0.1:1234/v1", + authType: .apiKey, + subscriptionGated: false, + docURL: nil + ), + "minimax-oauth": HermesProviderOverlay( + displayName: "MiniMax (OAuth)", + baseURL: "https://api.minimax.io/anthropic", + authType: .oauthExternal, + subscriptionGated: false, + docURL: nil + ), + "tencent-tokenhub": HermesProviderOverlay( + displayName: "Tencent TokenHub", + // Resolved from TOKENHUB_BASE_URL at runtime. + baseURL: nil, + authType: .apiKey, + subscriptionGated: false, + docURL: nil + ), ] } diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 5e36541..be84b27 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -129,7 +129,7 @@ struct HermesFileService: Sendable { skillsHub: aux("skills_hub"), approval: aux("approval"), mcp: aux("mcp"), - flushMemories: aux("flush_memories") + curator: aux("curator") ) let security = SecuritySettings( diff --git a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift index ed1ad6d..3f8c3af 100644 --- a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift +++ b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift @@ -180,7 +180,7 @@ final class HealthViewModel { ("skills_hub", config.auxiliary.skillsHub.provider), ("approval", config.auxiliary.approval.provider), ("mcp", config.auxiliary.mcp.provider), - ("flush_memories", config.auxiliary.flushMemories.provider), + ("curator", config.auxiliary.curator.provider), ].filter { $0.1 == "nous" }.map(\.0) if !auxOnNous.isEmpty { checks.append(HealthCheck( diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift index 663a3bd..ec157f0 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift @@ -9,25 +9,41 @@ import ScarfCore /// (subscription-routed) and `auto` (inherit main provider) — Hermes derives /// the gateway routing from that single field; there is no separate /// `use_gateway` key to write. +/// +/// v0.12 dropped the `flush_memories` aux task (the underlying memory +/// pipeline was rewritten upstream) and added `curator` (the autonomous +/// skill-maintenance review fork). The Curator row only appears when +/// `HermesCapabilities.hasCuratorAux` is set so v0.11 hosts don't see a +/// row that writes a key Hermes ignores. struct AuxiliaryTab: View { @Bindable var viewModel: SettingsViewModel @Environment(\.serverContext) private var serverContext + @Environment(\.hermesCapabilities) private var capabilitiesStore @State private var subscription: NousSubscriptionState = .absent @State private var showNousSignIn: Bool = false // Keyed by the config path name — matches `auxiliary..*` in config.yaml. - private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [ + // Static base list; the v0.12-only `curator` row is appended at render + // time when the target Hermes supports it. + private let baseTasks: [(key: String, title: LocalizedStringKey, icon: String)] = [ ("vision", "Vision", "eye"), ("web_extract", "Web Extract", "doc.richtext"), ("compression", "Compression", "arrow.down.right.and.arrow.up.left.circle"), ("session_search", "Session Search", "magnifyingglass"), ("skills_hub", "Skills Hub", "books.vertical"), ("approval", "Approval", "checkmark.seal"), - ("mcp", "MCP", "puzzlepiece"), - ("flush_memories", "Flush Memories", "trash.slash") + ("mcp", "MCP", "puzzlepiece") ] + private var tasks: [(key: String, title: LocalizedStringKey, icon: String)] { + var t = baseTasks + if capabilitiesStore?.capabilities.hasCuratorAux ?? false { + t.append(("curator", "Curator", "sparkles")) + } + return t + } + var body: some View { Text("Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.") .font(.caption) @@ -94,7 +110,7 @@ struct AuxiliaryTab: View { case "skills_hub": return viewModel.config.auxiliary.skillsHub case "approval": return viewModel.config.auxiliary.approval case "mcp": return viewModel.config.auxiliary.mcp - case "flush_memories": return viewModel.config.auxiliary.flushMemories + case "curator": return viewModel.config.auxiliary.curator default: return .empty } } diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift index 9379ad0..7017836 100644 --- a/scarf/scarf/scarfApp.swift +++ b/scarf/scarf/scarfApp.swift @@ -198,9 +198,9 @@ private struct ContextBoundRoot: View { @State private var chatViewModel: ChatViewModel /// Per-window snapshot of the target Hermes installation's capability /// flags. Drives sidebar visibility (Curator, Kanban only on v0.12+), - /// settings rows (flush_memories aux dropped on v0.12), and version - /// banners. Refreshes once on init; explicit `refresh()` call rerun - /// after a `hermes update`. + /// settings rows (curator aux added on v0.12), and version banners. + /// Refreshes once on init; explicit `refresh()` call rerun after a + /// `hermes update`. @State private var capabilities: HermesCapabilitiesStore init(context: ServerContext) { diff --git a/scarf/scarfTests/CredentialPoolsGatingTests.swift b/scarf/scarfTests/CredentialPoolsGatingTests.swift index 29ccc59..324306f 100644 --- a/scarf/scarfTests/CredentialPoolsGatingTests.swift +++ b/scarf/scarfTests/CredentialPoolsGatingTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import ScarfCore @testable import scarf /// Tests that ``CredentialPoolsOAuthGate`` steers each known provider to diff --git a/scarf/scarfTests/ToolGatewayTests.swift b/scarf/scarfTests/ToolGatewayTests.swift index c8539eb..a11f4b2 100644 --- a/scarf/scarfTests/ToolGatewayTests.swift +++ b/scarf/scarfTests/ToolGatewayTests.swift @@ -55,11 +55,37 @@ import ScarfCore #expect(ids.contains("nous"), "Nous Portal must appear after overlay merge") #expect(ids.contains("openai-codex"), "OpenAI Codex overlay must appear") #expect(ids.contains("qwen-oauth"), "Qwen OAuth overlay must appear") + // v0.12 additions — IDs must match HERMES_OVERLAYS in + // hermes-agent/hermes_cli/providers.py exactly. Drift here + // means the picker can't reach the new providers. + #expect(ids.contains("gmi"), "GMI Cloud overlay must appear (v0.12)") + #expect(ids.contains("azure-foundry"), "Azure AI Foundry overlay must appear (v0.12)") + #expect(ids.contains("lmstudio"), "LM Studio overlay must appear (v0.12)") + #expect(ids.contains("minimax-oauth"), "MiniMax OAuth overlay must appear (v0.12)") + #expect(ids.contains("tencent-tokenhub"), "Tencent TokenHub overlay must appear (v0.12)") // Cached providers still present. #expect(ids.contains("anthropic")) #expect(ids.contains("openai")) } + @Test func v012OverlayProvidersCarryCorrectAuthTypes() throws { + // The auth-type drives whether Settings shows an API-key field, + // an OAuth flow, or external-process wiring. Locking the v0.12 + // additions here so a typo doesn't quietly land users in the + // wrong setup flow. + let overlays = ModelCatalogService.overlayOnlyProviders + #expect(overlays["gmi"]?.authType == .apiKey) + #expect(overlays["azure-foundry"]?.authType == .apiKey) + #expect(overlays["lmstudio"]?.authType == .apiKey) + #expect(overlays["minimax-oauth"]?.authType == .oauthExternal) + #expect(overlays["tencent-tokenhub"]?.authType == .apiKey) + // None of the v0.12 additions are subscription-gated (only Nous + // Portal is). + for id in ["gmi", "azure-foundry", "lmstudio", "minimax-oauth", "tencent-tokenhub"] { + #expect(overlays[id]?.subscriptionGated == false, "\(id) shouldn't be subscription-gated") + } + } + @Test func nousPortalSortsFirst() throws { let path = try writeCacheFixture() let service = ModelCatalogService(path: path) From 13545689927cda6d60a2d4e8767d67986d5443b4 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 12:28:41 +0200 Subject: [PATCH 03/20] feat(hermes-v12): ACP multimodal image input on Mac + iOS (Phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes v0.12 advertises `prompt_capabilities.image = true` and accepts image content blocks in `session/prompt`. This wires a producer flow on both targets so users can attach images alongside text and have them routed to the vision-capable model automatically. Pipeline: - ChatImageAttachment: Sendable value type holding base64 payload + thumbnail, MIME type, source filename, and approximate byte count. - ImageEncoder: detached-only Sendable service that downsamples to Anthropic's 1568px long-edge cap, JPEG-encodes at q=0.85, and produces a small inline thumbnail for composer chips. Cross-platform (NSImage on Mac, UIImage on iOS, JPEG-passthrough on Linux/CI). - ACPClient.sendPrompt(sessionId:text:images:) overload emits a content array `[{type: "text"...}, {type: "image", data, mimeType}]` matching the wire shape in hermes-agent/acp_adapter/server.py. The zero-arg-images convenience overload preserves the v0.11 wire shape for any unmodified callers. Mac UI: - RichChatInputBar grew an `attachments: [ChatImageAttachment]` state array, a paperclip button (NSOpenPanel multi-pick), drag-drop and paste handlers, and a horizontal preview chip strip. The "send" callback's signature is `(String, [ChatImageAttachment]) -> Void` threaded through RichChatView -> ChatTranscriptPane -> ChatView -> ChatViewModel.sendText(text, images:). Image-only prompts are permitted ("describe this") once at least one attachment is queued. iOS UI: - ChatView's composer adopts a paperclip + PhotosPicker flow with the same chip strip and 5-attachment cap. Attachments live on ChatController so they survive across PhotosPicker presentations. loadTransferable(type: Data.self) feeds raw bytes into the same ImageEncoder; encode work runs detached so MainActor stays responsive on cellular. Capability gating: - Both composers hide the entire attachment surface when HermesCapabilities.hasACPImagePrompts is false (pre-v0.12 hosts). No paperclip button, no drop target, no paste accept — the input bar is byte-for-byte the v0.11 surface against an older Hermes. Tests: 209 ScarfCore tests pass; both Mac and iOS schemes build clean. The encoder's pixel work is hard to unit-test at the package level (no NSImage/UIImage in plain Swift CI) — manual end-to-end testing is the verification path here. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/ScarfCore/ACP/ACPClient.swift | 39 ++- .../Models/ChatImageAttachment.swift | 52 ++++ .../ScarfCore/Services/ImageEncoder.swift | 162 ++++++++++ scarf/Scarf iOS/Chat/ChatView.swift | 193 +++++++++++- .../Chat/ViewModels/ChatViewModel.swift | 30 +- .../Chat/Views/ChatTranscriptPane.swift | 2 +- .../scarf/Features/Chat/Views/ChatView.swift | 2 +- .../Chat/Views/RichChatInputBar.swift | 277 +++++++++++++++++- .../Features/Chat/Views/RichChatView.swift | 2 +- 9 files changed, 734 insertions(+), 25 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ChatImageAttachment.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ImageEncoder.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift index b611684..ba146d0 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift @@ -266,14 +266,47 @@ public actor ACPClient { // MARK: - Messaging public func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult { + try await sendPrompt(sessionId: sessionId, text: text, images: []) + } + + /// v0.12+ overload: forward zero or more image attachments alongside + /// the user's text. Each attachment becomes a separate + /// `ImageContentBlock` in the ACP `prompt` content array — matches + /// the shape Hermes' `acp_adapter/server.py` expects (text first, + /// then image blocks). Hermes routes the resulting payload to a + /// vision-capable model automatically; the producer side only has + /// to deliver the bytes. + /// + /// Pre-v0.12 Hermes installs accepted only a single `text` block. + /// Callers gate this overload on + /// `HermesCapabilitiesStore.capabilities.hasACPImagePrompts` so we + /// don't send blocks an older agent would silently drop. + public func sendPrompt( + sessionId: String, + text: String, + images: [ChatImageAttachment] + ) async throws -> ACPPromptResult { statusMessage = "Sending prompt..." let messageId = UUID().uuidString + + // Always include the text block, even when empty — keeps the + // server-side text-extraction path stable regardless of whether + // the user sent text alongside the image(s). + var promptBlocks: [[String: Any]] = [ + ["type": "text", "text": text] as [String: Any], + ] + for image in images { + promptBlocks.append([ + "type": "image", + "data": image.base64Data, + "mimeType": image.mimeType, + ] as [String: Any]) + } + let params: [String: AnyCodable] = [ "sessionId": AnyCodable(sessionId), "messageId": AnyCodable(messageId), - "prompt": AnyCodable([ - ["type": "text", "text": text] as [String: Any], - ] as [Any]), + "prompt": AnyCodable(promptBlocks as [Any]), ] let result = try await sendRequest(method: "session/prompt", params: params) let dict = result?.dictValue ?? [:] diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ChatImageAttachment.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ChatImageAttachment.swift new file mode 100644 index 0000000..6ad3ca9 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ChatImageAttachment.swift @@ -0,0 +1,52 @@ +import Foundation + +/// One image attached to an outgoing chat prompt. +/// +/// Hermes v0.12 ACP advertises `prompt_capabilities.image = true` and +/// accepts content-block arrays in `session/prompt`. Scarf produces these +/// blocks from drag-dropped / pasted / picker-selected images. We +/// downsample + JPEG-encode at the producer side so the wire payload +/// stays under a few hundred kilobytes per image even when the user +/// drops a 12 MP screenshot. +/// +/// Constructed via `ImageEncoder.encode(...)`. The store-the-bytes-once +/// shape means `RichChatViewModel` can keep the array between turns +/// (e.g. while the agent is responding) without holding `NSImage` / +/// `UIImage` references that would pin the originals in memory. +public struct ChatImageAttachment: Sendable, Equatable, Identifiable { + public let id: String + /// IANA MIME type — matches the `mimeType` field on ACP `ImageContentBlock`. + /// Currently always `image/jpeg` after re-encoding; PNG-only originals + /// keep their type when small enough to skip the JPEG step. + public let mimeType: String + /// Base64-encoded payload. NOT prefixed with `data:` — Hermes wraps it + /// when forwarding to OpenAI multimodal payloads (see + /// `_image_block_to_openai_part` in `acp_adapter/server.py`). + public let base64Data: String + /// Small inline thumbnail for the composer's preview strip. Same MIME + /// type as `base64Data`. Nil when the source was already small enough + /// to use directly. + public let thumbnailBase64: String? + /// Original filename, when known (drag-drop carries it; paste doesn't). + /// Surfaced as a tooltip on the preview chip. + public let filename: String? + /// Approximate decoded byte count, kept for the composer's + /// "X images, Y KB" status pill. + public let approximateByteCount: Int + + public init( + id: String = UUID().uuidString, + mimeType: String, + base64Data: String, + thumbnailBase64: String?, + filename: String?, + approximateByteCount: Int + ) { + self.id = id + self.mimeType = mimeType + self.base64Data = base64Data + self.thumbnailBase64 = thumbnailBase64 + self.filename = filename + self.approximateByteCount = approximateByteCount + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ImageEncoder.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ImageEncoder.swift new file mode 100644 index 0000000..d2e1d57 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ImageEncoder.swift @@ -0,0 +1,162 @@ +import Foundation +#if canImport(AppKit) +import AppKit +#endif +#if canImport(UIKit) +import UIKit +#endif +#if canImport(CoreImage) +import CoreImage +#endif + +/// Downsamples + base64-encodes user-supplied images for ACP transport. +/// +/// **Why downsample on the producer side.** Hermes happily forwards the +/// bytes to a vision model, but a 12 MP screenshot at 4 MB is wasteful +/// — it eats 5–6× more tokens than a 1024×1024 thumbnail and gives the +/// model no extra signal. Cap the long edge at 1568 px (Anthropic's +/// recommended max for Claude vision) and drop quality to JPEG 0.85, +/// which keeps screenshot text crisp while landing under ~300 KB per +/// image. The 5-image-per-message limit (chosen on the producer side) +/// keeps the total prompt payload below ~2 MB. +/// +/// **Why detached.** Image loading + downsampling is CPU-bound. Run only +/// from a `Task.detached` context (the encoder type is `Sendable` and +/// every method is `nonisolated`). The companion `ChatImageAttachment` +/// is a Sendable value type so the result hops back to MainActor cleanly. +public struct ImageEncoder: Sendable { + /// Long-edge pixel cap. 1568 is Anthropic's recommended ceiling for + /// Claude vision input — past it, the provider downsamples server-side + /// and we just paid for the extra bytes. Tweak only with vision-model + /// guidance from Hermes side. + public static let maxLongEdge: CGFloat = 1568 + /// JPEG quality factor. 0.85 is the inflection point above which + /// file size jumps quickly without obvious visual gain on screenshots + /// or photographs. + public static let jpegQuality: CGFloat = 0.85 + /// Long-edge cap for the inline thumbnail rendered in the composer + /// chip. Kept under the system thumbnail size so `Image(data:)` + /// renders without extra resampling. + public static let thumbnailLongEdge: CGFloat = 256 + + public init() {} + + public enum EncoderError: Error, LocalizedError { + case unsupportedFormat + case decodeFailed + case encodeFailed + case empty + + public var errorDescription: String? { + switch self { + case .unsupportedFormat: return "Image format not recognized" + case .decodeFailed: return "Couldn't decode image data" + case .encodeFailed: return "Couldn't encode image as JPEG" + case .empty: return "Image data was empty" + } + } + } + + /// Encode raw bytes (from a paste/drop/picker) into a wire-ready + /// attachment. Detached-only — never call from MainActor. The + /// originating bytes are not retained beyond this call. + public nonisolated func encode( + rawBytes: Data, + sourceFilename: String? = nil + ) throws -> ChatImageAttachment { + guard !rawBytes.isEmpty else { throw EncoderError.empty } + + #if canImport(AppKit) + guard let nsImage = NSImage(data: rawBytes) else { throw EncoderError.decodeFailed } + let targetSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.maxLongEdge) + let mainData = try Self.jpegBytes(from: nsImage, size: targetSize) + let thumbSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.thumbnailLongEdge) + let thumbData = try? Self.jpegBytes(from: nsImage, size: thumbSize) + return ChatImageAttachment( + mimeType: "image/jpeg", + base64Data: mainData.base64EncodedString(), + thumbnailBase64: thumbData?.base64EncodedString(), + filename: sourceFilename, + approximateByteCount: mainData.count + ) + + #elseif canImport(UIKit) + guard let uiImage = UIImage(data: rawBytes) else { throw EncoderError.decodeFailed } + let targetSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.maxLongEdge) + let mainData = try Self.jpegBytes(from: uiImage, size: targetSize) + let thumbSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.thumbnailLongEdge) + let thumbData = try? Self.jpegBytes(from: uiImage, size: thumbSize) + return ChatImageAttachment( + mimeType: "image/jpeg", + base64Data: mainData.base64EncodedString(), + thumbnailBase64: thumbData?.base64EncodedString(), + filename: sourceFilename, + approximateByteCount: mainData.count + ) + + #else + // Linux CI / unknown platforms: pass through raw bytes if the + // input already looks like a JPEG, else refuse. Keeps the + // package compiling without a hard AppKit/UIKit dep. + if rawBytes.starts(with: [0xFF, 0xD8]) { + return ChatImageAttachment( + mimeType: "image/jpeg", + base64Data: rawBytes.base64EncodedString(), + thumbnailBase64: nil, + filename: sourceFilename, + approximateByteCount: rawBytes.count + ) + } + throw EncoderError.unsupportedFormat + #endif + } + + nonisolated private static func fittedSize(for source: CGSize, maxLongEdge: CGFloat) -> CGSize { + let longest = max(source.width, source.height) + if longest <= maxLongEdge { return source } + let scale = maxLongEdge / longest + return CGSize( + width: floor(source.width * scale), + height: floor(source.height * scale) + ) + } + + #if canImport(AppKit) + nonisolated private static func jpegBytes(from image: NSImage, size: CGSize) throws -> Data { + let resized = NSImage(size: size) + resized.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + image.draw( + in: CGRect(origin: .zero, size: size), + from: .zero, + operation: .copy, + fraction: 1.0 + ) + resized.unlockFocus() + guard let tiff = resized.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let data = rep.representation( + using: .jpeg, + properties: [.compressionFactor: jpegQuality] + ) + else { + throw EncoderError.encodeFailed + } + return data + } + #elseif canImport(UIKit) + nonisolated private static func jpegBytes(from image: UIImage, size: CGSize) throws -> Data { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = true + let renderer = UIGraphicsImageRenderer(size: size, format: format) + let resized = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + guard let data = resized.jpegData(compressionQuality: jpegQuality) else { + throw EncoderError.encodeFailed + } + return data + } + #endif +} diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index d9ae223..f34e97b 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -3,6 +3,9 @@ import ScarfCore import ScarfIOS import ScarfDesign import os +#if canImport(PhotosUI) +import PhotosUI +#endif // The Chat feature on iOS is gated on `canImport(SQLite3)` because // `RichChatViewModel` reads session history from `HermesDataService` @@ -24,9 +27,23 @@ struct ChatView: View { @Environment(\.scarfGoCoordinator) private var coordinator @Environment(\.serverContext) private var envContext + @Environment(\.hermesCapabilities) private var capabilitiesStore @State private var controller: ChatController @State private var showProjectPicker = false @State private var showSlashCommandsSheet = false + /// PhotosPicker selection. Bridge between SwiftUI's selection + /// binding and our `ChatImageAttachment` payload — `loadTransferable` + /// produces raw `Data` we then hand to `ImageEncoder`. v0.12+ only. + @State private var pickerSelection: [PhotosPickerItem] = [] + @State private var showPhotoPicker = false + @State private var isEncodingAttachment = false + @State private var attachmentError: String? + + private static let maxAttachments = 5 + + private var supportsImagePrompts: Bool { + capabilitiesStore?.capabilities.hasACPImagePrompts ?? false + } /// Drives the composer's keyboard. Bound to the TextField via /// `.focused(...)`; cleared by the scroll-to-dismiss gesture on /// the message list AND by an explicit keyboard-toolbar button. @@ -431,7 +448,108 @@ struct ChatView: View { } private var composer: some View { + VStack(alignment: .leading, spacing: 4) { + if !controller.attachments.isEmpty || isEncodingAttachment || attachmentError != nil { + attachmentStrip + } + composerRow + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.regularMaterial) + #if canImport(PhotosUI) + .photosPicker( + isPresented: $showPhotoPicker, + selection: $pickerSelection, + maxSelectionCount: max(0, Self.maxAttachments - controller.attachments.count), + matching: .images + ) + .onChange(of: pickerSelection) { _, items in + ingestPickerItems(items) + } + #endif + } + + @ViewBuilder + private var attachmentStrip: some View { + HStack(alignment: .center, spacing: 8) { + if isEncodingAttachment { + ProgressView().controlSize(.small) + Text("Encoding…") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(controller.attachments) { attachment in + attachmentChip(attachment) + } + if let err = attachmentError { + Text(err) + .font(.caption) + .foregroundStyle(ScarfColor.danger) + } + Spacer(minLength: 0) + if !controller.attachments.isEmpty { + Text("\(controller.attachments.count)/\(Self.maxAttachments)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + @ViewBuilder + private func attachmentChip(_ attachment: ChatImageAttachment) -> some View { + HStack(spacing: 4) { + attachmentChipThumbnail(attachment) + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 4)) + Button { + controller.attachments.removeAll { $0.id == attachment.id } + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove attached image") + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(ScarfColor.backgroundSecondary) + ) + } + + @ViewBuilder + private func attachmentChipThumbnail(_ attachment: ChatImageAttachment) -> some View { + if let thumb = attachment.thumbnailBase64, + let data = Data(base64Encoded: thumb), + let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "photo") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ScarfColor.backgroundSecondary) + } + } + + private var composerRow: some View { HStack(alignment: .bottom, spacing: 8) { + if supportsImagePrompts { + Button { + showPhotoPicker = true + } label: { + Image(systemName: "paperclip") + .font(.system(size: 22)) + .foregroundStyle(.secondary) + .padding(.bottom, 4) + } + .buttonStyle(.plain) + .disabled(controller.state != .ready || controller.attachments.count >= Self.maxAttachments) + .accessibilityLabel("Attach image") + } TextField( "Message…", text: $controller.draft, @@ -480,13 +598,58 @@ struct ChatView: View { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 28)) } - .disabled(controller.state != .ready || controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(!canSendComposer) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(.regularMaterial) } + /// Send is enabled when ready AND we have either text or at least + /// one attachment. Image-only sends are valid for vision models. + private var canSendComposer: Bool { + guard controller.state == .ready else { return false } + if !controller.attachments.isEmpty { return true } + return !controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Pull JPEG/PNG bytes out of each PhotosPickerItem and feed them + /// through ImageEncoder. Detached so the heavyweight resize + + /// JPEG-encode work doesn't block MainActor; the resulting + /// attachment hops back to MainActor for state mutation. + /// + /// PhotosPickerItem can deliver `Data` directly via the + /// `Transferable` API. After ingestion the binding is reset so a + /// follow-up pick triggers `onChange` again. + #if canImport(PhotosUI) + private func ingestPickerItems(_ items: [PhotosPickerItem]) { + guard !items.isEmpty else { return } + // Capture the items, immediately clear the binding so a future + // pick triggers onChange even when the user re-selects the + // same image set. PhotosPicker behavior: identical selection + // doesn't re-fire onChange unless the binding flips through nil. + let snapshot = items + pickerSelection = [] + isEncodingAttachment = true + Task { @MainActor in + for item in snapshot { + guard controller.attachments.count < Self.maxAttachments else { break } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { continue } + let attachment = try await Task.detached(priority: .userInitiated) { + try ImageEncoder().encode(rawBytes: data, sourceFilename: nil) + }.value + controller.attachments.append(attachment) + } catch { + attachmentError = (error as? LocalizedError)?.errorDescription ?? "Couldn't encode image" + Task { @MainActor in + try? await Task.sleep(nanoseconds: 4_000_000_000) + attachmentError = nil + } + } + } + isEncodingAttachment = false + } + } + #endif + @State private var showErrorDetails: Bool = false /// Inline error banner rendered above the message list when the @@ -696,6 +859,12 @@ final class ChatController { var vm: RichChatViewModel var draft: String = "" + /// v0.12+ image attachments queued to send with the next prompt. + /// Capped at 5 by the composer UI; the cap matches the Mac behavior + /// and keeps total ACP prompt payload under ~2 MB even on a slow + /// cellular link. Cleared after each successful `send()`. + var attachments: [ChatImageAttachment] = [] + /// Set when chat-start is blocked because the active server's /// `config.yaml` has no `model.default` / `model.provider`. ChatView /// observes this to present an inline "pick a model" sheet — the @@ -1003,12 +1172,22 @@ final class ChatController { func send() async { guard state == .ready, let client else { return } let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } + // v0.12+ allows image-only sends — vision models accept "describe + // this" with no text. Bail only when both fields are empty. + guard !text.isEmpty || !attachments.isEmpty else { return } let sessionId = vm.sessionId ?? "" guard !sessionId.isEmpty else { return } + let images = attachments + attachments = [] draft = "" clearStoredDraft() - vm.addUserMessage(text: text) + if !text.isEmpty { + vm.addUserMessage(text: text) + } else { + // Surface an image-only message so the user sees their bubble + // even when they didn't type any caption. + vm.addUserMessage(text: "[image attached]") + } // /steer is non-interruptive — the agent is still on its // current turn; the guidance applies after the next tool call. // Surface a transient toast confirming the guidance was @@ -1029,7 +1208,7 @@ final class ChatController { // literally. v2.5. let wireText = expandIfProjectScoped(text) do { - _ = try await client.sendPrompt(sessionId: sessionId, text: wireText) + _ = try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images) } catch { // The event task may already have surfaced a // .connectionLost; show the send-time error only if the diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 287eb2b..854f2c2 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -254,14 +254,32 @@ final class ChatViewModel { // MARK: - Send Message func sendText(_ text: String) { + sendText(text, images: []) + } + + /// v0.12+ overload: forward image attachments alongside the text. + /// Empty `images` keeps the legacy v0.11 wire shape; non-empty images + /// only flow when `HermesCapabilities.hasACPImagePrompts` is true + /// (the input bar gates the attachment UI on the same flag, so a + /// non-empty array reaching here means we've already verified the + /// agent supports it). + /// + /// Terminal mode silently drops attachments — there's no way to + /// pipe binary content through the TTY. Surface a one-shot warning + /// so the user knows. + func sendText(_ text: String, images: [ChatImageAttachment]) { if displayMode == .richChat { if let client = acpClient { - sendViaACP(client: client, text: text) + sendViaACP(client: client, text: text, images: images) } else { // Auto-start ACP and send the queued message - autoStartACPAndSend(text: text) + autoStartACPAndSend(text: text, images: images) } } else if let tv = terminalView { + if !images.isEmpty { + logger.warning("Terminal-mode chat dropped \(images.count) image attachment(s) — image input only works in ACP rich-chat mode") + acpError = "Image attachments require ACP mode (rich chat)." + } sendToTerminal(tv, text: text + "\r") } } @@ -274,7 +292,7 @@ final class ChatViewModel { /// user never interacted with; those can be garbage-collected by Hermes /// between the DB read and ACP `session/load`, producing a silent prompt /// failure with no UI feedback. - private func autoStartACPAndSend(text: String) { + private func autoStartACPAndSend(text: String, images: [ChatImageAttachment] = []) { // Show the user message immediately richChatViewModel.addUserMessage(text: text) @@ -313,7 +331,7 @@ final class ChatViewModel { acpStatus = "Connected (\(resolvedSessionId.prefix(12)))" // Now send the queued prompt - sendViaACP(client: client, text: text) + sendViaACP(client: client, text: text, images: images) } catch { acpStatus = "Failed" await recordACPFailure(error, client: client, context: "Auto-start ACP failed") @@ -350,7 +368,7 @@ final class ChatViewModel { return ProjectSlashCommandService(context: context).expand(cmd, withArgument: argument) } - private func sendViaACP(client: ACPClient, text: String) { + private func sendViaACP(client: ACPClient, text: String, images: [ChatImageAttachment] = []) { guard let sessionId = richChatViewModel.sessionId else { clearACPErrorState() acpError = "No session ID — cannot send" @@ -390,7 +408,7 @@ final class ChatViewModel { } acpPromptTask = Task { @MainActor in do { - let result = try await client.sendPrompt(sessionId: sessionId, text: wireText) + let result = try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images) acpStatus = "Ready" richChatViewModel.handleACPEvent( .promptComplete(sessionId: sessionId, response: result) diff --git a/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift index 272a566..f3e9a0f 100644 --- a/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift +++ b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift @@ -9,7 +9,7 @@ import ScarfDesign struct ChatTranscriptPane: View { @Bindable var richChat: RichChatViewModel @Bindable var chatViewModel: ChatViewModel - var onSend: (String) -> Void + var onSend: (String, [ChatImageAttachment]) -> Void var isEnabled: Bool var body: some View { diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index 2f6b0f0..e6ca780 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -396,7 +396,7 @@ struct ChatView: View { if viewModel.hermesBinaryExists { RichChatView( richChat: viewModel.richChatViewModel, - onSend: { viewModel.sendText($0) }, + onSend: { text, images in viewModel.sendText(text, images: images) }, isEnabled: viewModel.hasActiveProcess || viewModel.hermesBinaryExists ) } else { diff --git a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift index 26ee7be..7cc32c4 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift @@ -1,20 +1,51 @@ import SwiftUI import ScarfCore import ScarfDesign +import UniformTypeIdentifiers +import os +#if canImport(AppKit) +import AppKit +#endif struct RichChatInputBar: View { - let onSend: (String) -> Void + /// Send the user's text and any attached images. Empty `images` + /// preserves the v0.11 wire shape; non-empty images are forwarded + /// as ACP image content blocks (Hermes v0.12+; the composer hides + /// the attachment UI on older hosts). + let onSend: (String, [ChatImageAttachment]) -> Void let isEnabled: Bool var commands: [HermesSlashCommand] = [] var showCompressButton: Bool = false + @Environment(\.hermesCapabilities) private var capabilitiesStore + @State private var text = "" @State private var showCompressSheet = false @State private var compressFocus = "" @State private var showMenu = false @State private var selectedIndex = 0 + @State private var attachments: [ChatImageAttachment] = [] + /// True while ImageEncoder is decoding/encoding pasted/dropped bytes. + /// Renders a small spinner in the preview strip so the user knows + /// their drop landed. + @State private var isEncodingAttachment = false + /// User-visible failure (decode failed, format unsupported). Auto-clears. + @State private var attachmentError: String? @FocusState private var isFocused: Bool + /// Hard cap matches what Hermes' vision aux model swallows comfortably + /// in one prompt. Going higher costs tokens without a quality gain. + private static let maxAttachments = 5 + + private static let logger = Logger(subsystem: "com.scarf", category: "ChatComposer") + + /// `nil` until detection finishes — we hide the attachment UI in + /// that brief window (~50ms locally, longer over SSH) so we never + /// flash an attachment chip a v0.11 host couldn't honor. + private var supportsImagePrompts: Bool { + capabilitiesStore?.capabilities.hasACPImagePrompts ?? false + } + var body: some View { VStack(alignment: .leading, spacing: 0) { if showMenu { @@ -36,6 +67,10 @@ struct RichChatInputBar: View { .padding(.top, 8) } + if !attachments.isEmpty || isEncodingAttachment || attachmentError != nil { + attachmentStrip + } + HStack(alignment: .bottom, spacing: ScarfSpace.s2) { if showCompressButton { Button { @@ -52,6 +87,10 @@ struct RichChatInputBar: View { .help("Compress conversation (/compress)") } + if supportsImagePrompts { + attachmentButton + } + TextEditor(text: $text) .font(ScarfFont.body) .scrollContentBackground(.hidden) @@ -70,7 +109,9 @@ struct RichChatInputBar: View { ) .overlay(alignment: .topLeading) { if text.isEmpty { - Text("Message Hermes… / for commands") + Text(supportsImagePrompts + ? "Message Hermes… / for commands · drag images to attach" + : "Message Hermes… / for commands") .scarfStyle(.body) .foregroundStyle(ScarfColor.foregroundFaint) .padding(.horizontal, 14) @@ -78,6 +119,25 @@ struct RichChatInputBar: View { .allowsHitTesting(false) } } + // Drag-drop image attachments. Receives both file URLs + // (from Finder) and raw image bitmap data (from + // screenshot tools that drop tiff/png directly). + // Capability-gated so v0.11 hosts don't surface a + // drop target that does nothing. + .onDrop( + of: supportsImagePrompts ? [.image, .fileURL] : [], + isTargeted: nil + ) { providers in + guard supportsImagePrompts else { return false } + ingestProviders(providers) + return true + } + // Paste from screenshots / browser context menu. + // Accepting `Data` keeps us off `NSImage` which would + // require AppKit-typed paste. v0.12+ only. + .onPasteCommand(of: pasteAcceptedTypes) { providers in + ingestProviders(providers) + } .onKeyPress(.upArrow, phases: .down) { _ in guard showMenu, !filteredCommands.isEmpty else { return .ignored } let n = filteredCommands.count @@ -148,6 +208,96 @@ struct RichChatInputBar: View { } } + /// Horizontal preview strip for attached images. Each chip shows the + /// thumbnail (or a placeholder icon if we couldn't render one) plus + /// an X to remove the attachment. + @ViewBuilder + private var attachmentStrip: some View { + HStack(alignment: .center, spacing: ScarfSpace.s2) { + if isEncodingAttachment { + ProgressView() + .controlSize(.small) + Text("Encoding…") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + } + ForEach(attachments) { attachment in + attachmentChip(attachment) + } + if let err = attachmentError { + Text(err) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.danger) + } + Spacer(minLength: 0) + if !attachments.isEmpty { + Text("\(attachments.count)/\(Self.maxAttachments)") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + } + } + .padding(.horizontal, ScarfSpace.s3) + .padding(.top, ScarfSpace.s2) + } + + @ViewBuilder + private func attachmentChip(_ attachment: ChatImageAttachment) -> some View { + let thumb = chipThumbnail(for: attachment) + HStack(spacing: 4) { + thumb + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 4)) + Button { + attachments.removeAll { $0.id == attachment.id } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(ScarfColor.foregroundMuted) + } + .buttonStyle(.plain) + .help(attachment.filename ?? "Image attachment") + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.md) + .fill(ScarfColor.backgroundTertiary) + ) + } + + /// Render the inline thumbnail for a chip. Falls back to a generic + /// photo icon when the encoder didn't produce a thumbnail (e.g. the + /// image was already small enough to skip the resize step). + @ViewBuilder + private func chipThumbnail(for attachment: ChatImageAttachment) -> some View { + if let thumb = attachment.thumbnailBase64, + let data = Data(base64Encoded: thumb), + let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "photo") + .foregroundStyle(ScarfColor.foregroundMuted) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ScarfColor.backgroundSecondary) + } + } + + private var attachmentButton: some View { + Button { + presentImagePicker() + } label: { + Image(systemName: "paperclip") + .font(.system(size: 16)) + .foregroundStyle(ScarfColor.foregroundMuted) + .padding(6) + } + .buttonStyle(.plain) + .disabled(!isEnabled || attachments.count >= Self.maxAttachments) + .help("Attach image (\(attachments.count)/\(Self.maxAttachments))") + } + private var compressSheet: some View { VStack(alignment: .leading, spacing: ScarfSpace.s3) { Text("Compress Conversation") @@ -164,7 +314,7 @@ struct RichChatInputBar: View { Button("Compress") { let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines) let command = focus.isEmpty ? "/compress" : "/compress \(focus)" - onSend(command) + onSend(command, []) showCompressSheet = false } .buttonStyle(ScarfPrimaryButton()) @@ -176,7 +326,18 @@ struct RichChatInputBar: View { } private var canSend: Bool { - isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard isEnabled else { return false } + // Allow sending image-only messages once at least one attachment + // exists — vision models accept "describe this" with no text. + if !attachments.isEmpty { return true } + return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// MIME types accepted for paste. Restricting to image-bearing + /// providers stops macOS from offering a paste menu when the user + /// has plain text on the clipboard. + private var pasteAcceptedTypes: [UTType] { + supportsImagePrompts ? [.image, .png, .jpeg, .tiff, .heic] : [] } /// Show the slash menu only while the user is typing the command token: @@ -224,12 +385,116 @@ struct RichChatInputBar: View { private func send() { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, isEnabled else { return } - onSend(trimmed) + guard canSend else { return } + onSend(trimmed, attachments) text = "" + attachments.removeAll() showMenu = false selectedIndex = 0 } + + // MARK: - Attachment ingestion + + /// Pull image bytes out of a set of `NSItemProvider`s (drag/drop or + /// paste). Each provider may carry a file URL OR raw image data — + /// we try both. Caps at `maxAttachments`; surplus drops are + /// dropped silently with a status message. + private func ingestProviders(_ providers: [NSItemProvider]) { + let remainingSlots = Self.maxAttachments - attachments.count + guard remainingSlots > 0 else { + attachmentError = "Limit of \(Self.maxAttachments) images reached" + scheduleAttachmentErrorClear() + return + } + let toIngest = providers.prefix(remainingSlots) + for provider in toIngest { + ingestProvider(provider) + } + } + + private func ingestProvider(_ provider: NSItemProvider) { + // Prefer file URL when available — gives us the original filename + // for the attachment chip's tooltip. + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + isEncodingAttachment = true + provider.loadObject(ofClass: URL.self) { url, _ in + guard let url, let data = try? Data(contentsOf: url) else { + Task { @MainActor in + isEncodingAttachment = false + attachmentError = "Couldn't read dropped file" + scheduleAttachmentErrorClear() + } + return + } + encode(data: data, filename: url.lastPathComponent) + } + return + } + for typeId in [UTType.image.identifier, UTType.png.identifier, UTType.jpeg.identifier, UTType.tiff.identifier, UTType.heic.identifier] { + if provider.hasItemConformingToTypeIdentifier(typeId) { + isEncodingAttachment = true + provider.loadDataRepresentation(forTypeIdentifier: typeId) { data, _ in + guard let data else { + Task { @MainActor in + isEncodingAttachment = false + attachmentError = "Couldn't decode pasted image" + scheduleAttachmentErrorClear() + } + return + } + encode(data: data, filename: nil) + } + return + } + } + } + + private func encode(data: Data, filename: String?) { + Task.detached(priority: .userInitiated) { + do { + let attachment = try ImageEncoder().encode(rawBytes: data, sourceFilename: filename) + await MainActor.run { + isEncodingAttachment = false + attachments.append(attachment) + } + } catch { + await MainActor.run { + isEncodingAttachment = false + attachmentError = (error as? LocalizedError)?.errorDescription ?? "Couldn't encode image" + Self.logger.warning("ImageEncoder failed: \(error.localizedDescription, privacy: .public)") + scheduleAttachmentErrorClear() + } + } + } + } + + private func scheduleAttachmentErrorClear() { + Task { @MainActor in + try? await Task.sleep(nanoseconds: 4_000_000_000) + attachmentError = nil + } + } + + private func presentImagePicker() { + #if canImport(AppKit) + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.image, .png, .jpeg, .tiff, .heic] + panel.message = "Choose images to attach" + panel.prompt = "Attach" + let response = panel.runModal() + guard response == .OK else { return } + let urls = panel.urls + let remainingSlots = Self.maxAttachments - attachments.count + for url in urls.prefix(remainingSlots) { + guard let data = try? Data(contentsOf: url) else { continue } + isEncodingAttachment = true + encode(data: data, filename: url.lastPathComponent) + } + #endif + } } private extension Array { diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index c7e7d86..c2ee6c0 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -17,7 +17,7 @@ import ScarfDesign /// can scroll horizontally inside the panes rather than losing them. struct RichChatView: View { @Bindable var richChat: RichChatViewModel - var onSend: (String) -> Void + var onSend: (String, [ChatImageAttachment]) -> Void var isEnabled: Bool @Environment(HermesFileWatcher.self) private var fileWatcher @Environment(ChatViewModel.self) private var chatViewModel From 686fb376300f6ce1672f8f5191c026fba5e58470 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 12:37:48 +0200 Subject: [PATCH 04/20] feat(hermes-v12): Curator feature module on Mac + iOS (Phase D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `. 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) --- .../Models/HermesCuratorReport.swift | 361 ++++++++++++++++++ .../ScarfCore/Models/HermesPathSet.swift | 17 +- .../ViewModels/CuratorViewModel.swift | 125 ++++++ .../HermesCuratorParserTests.swift | 154 ++++++++ scarf/Scarf iOS/App/ScarfGoTabRoot.swift | 19 + scarf/Scarf iOS/Curator/CuratorView.swift | 193 ++++++++++ scarf/scarf/ContentView.swift | 1 + .../Curator/Views/CuratorRestoreSheet.swift | 67 ++++ .../Features/Curator/Views/CuratorView.swift | 322 ++++++++++++++++ scarf/scarf/Navigation/AppCoordinator.swift | 3 + scarf/scarf/Navigation/SidebarView.swift | 31 +- 11 files changed, 1279 insertions(+), 14 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/CuratorViewModel.swift create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCuratorParserTests.swift create mode 100644 scarf/Scarf iOS/Curator/CuratorView.swift create mode 100644 scarf/scarf/Features/Curator/Views/CuratorRestoreSheet.swift create mode 100644 scarf/scarf/Features/Curator/Views/CuratorView.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift new file mode 100644 index 0000000..4a5e109 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift @@ -0,0 +1,361 @@ +import Foundation + +/// Parsed view of `hermes curator status` text + the on-disk +/// `~/.hermes/skills/.curator_state` JSON. +/// +/// Hermes v0.12 doesn't ship a `--json` flag for `curator status` — the +/// CLI writes a human-readable report. CuratorViewModel parses the text +/// output for the human-readable bits ("least recently active", "most +/// active") and reads the state file directly for last-run metadata. +public struct HermesCuratorStatus: Sendable, Equatable { + public enum RunState: String, Sendable, Equatable { + case enabled + case paused + case disabled + case unknown + } + + public let state: RunState + public let runCount: Int + public let lastRunISO: String? // raw timestamp string, parsed by callers + public let lastSummary: String? // free-text summary line + public let lastReportPath: String? // absolute path to / dir + public let intervalLabel: String // e.g. "every 7d" + public let staleAfterLabel: String // e.g. "30d unused" + public let archiveAfterLabel: String // e.g. "90d unused" + + public let totalSkills: Int + public let activeSkills: Int + public let staleSkills: Int + public let archivedSkills: Int + + public let pinnedNames: [String] + + /// Top-5 lists rendered in the curator output. Each row carries the + /// skill name + the four counters Hermes prints. + public let leastRecentlyActive: [HermesCuratorSkillRow] + public let mostActive: [HermesCuratorSkillRow] + public let leastActive: [HermesCuratorSkillRow] + + public init( + state: RunState, + runCount: Int, + lastRunISO: String?, + lastSummary: String?, + lastReportPath: String?, + intervalLabel: String, + staleAfterLabel: String, + archiveAfterLabel: String, + totalSkills: Int, + activeSkills: Int, + staleSkills: Int, + archivedSkills: Int, + pinnedNames: [String], + leastRecentlyActive: [HermesCuratorSkillRow], + mostActive: [HermesCuratorSkillRow], + leastActive: [HermesCuratorSkillRow] + ) { + self.state = state + self.runCount = runCount + self.lastRunISO = lastRunISO + self.lastSummary = lastSummary + self.lastReportPath = lastReportPath + self.intervalLabel = intervalLabel + self.staleAfterLabel = staleAfterLabel + self.archiveAfterLabel = archiveAfterLabel + self.totalSkills = totalSkills + self.activeSkills = activeSkills + self.staleSkills = staleSkills + self.archivedSkills = archivedSkills + self.pinnedNames = pinnedNames + self.leastRecentlyActive = leastRecentlyActive + self.mostActive = mostActive + self.leastActive = leastActive + } + + public static let empty = HermesCuratorStatus( + state: .unknown, + runCount: 0, + lastRunISO: nil, + lastSummary: nil, + lastReportPath: nil, + intervalLabel: "—", + staleAfterLabel: "—", + archiveAfterLabel: "—", + totalSkills: 0, + activeSkills: 0, + staleSkills: 0, + archivedSkills: 0, + pinnedNames: [], + leastRecentlyActive: [], + mostActive: [], + leastActive: [] + ) +} + +public struct HermesCuratorSkillRow: Sendable, Equatable, Identifiable { + public var id: String { name } + public let name: String + public let activityCount: Int + public let useCount: Int + public let viewCount: Int + public let patchCount: Int + public let lastActivityLabel: String // raw label as printed (e.g. "never", "2d ago") + + public init( + name: String, + activityCount: Int, + useCount: Int, + viewCount: Int, + patchCount: Int, + lastActivityLabel: String + ) { + self.name = name + self.activityCount = activityCount + self.useCount = useCount + self.viewCount = viewCount + self.patchCount = patchCount + self.lastActivityLabel = lastActivityLabel + } +} + +/// Pure parser for `hermes curator status` stdout. Public for tests. +/// +/// Format is stable enough to text-parse; we never error on missing +/// sections — we just leave the corresponding field empty so +/// CuratorView can render "—" without crashing on a future layout +/// tweak. State file overrides text-parsed values when both are present. +public enum HermesCuratorStatusParser { + public static func parse(text: String, stateFileJSON: Data? = nil) -> HermesCuratorStatus { + let lines = text.components(separatedBy: "\n") + var status = HermesCuratorStatus.empty + + // Header section: `curator: ENABLED` / `runs:` / `last run:` / + // `last summary:` / `interval:` / `stale after:` / `archive after:` + var state = HermesCuratorStatus.RunState.unknown + var runCount = 0 + var lastRunISO: String? + var lastSummary: String? + var lastReportPath: String? + var interval = "—" + var stale = "—" + var archive = "—" + + // Skill counts: `agent-created skills: N total` then + // ` active N` / ` stale N` / ` archived N` + var total = 0 + var active = 0 + var staleCount = 0 + var archived = 0 + + var pinned: [String] = [] + + // Lists: `least recently active (top 5):` / `most active (top 5):` / + // `least active (top 5):` followed by indented row lines. + enum Section { + case header + case leastRecent + case mostActive + case leastActive + } + var section = Section.header + var leastRecent: [HermesCuratorSkillRow] = [] + var mostActiveRows: [HermesCuratorSkillRow] = [] + var leastActiveRows: [HermesCuratorSkillRow] = [] + + for raw in lines { + let line = raw.trimmingCharacters(in: .whitespaces) + // Section markers + if line.hasPrefix("least recently active") { + section = .leastRecent + continue + } + if line.hasPrefix("most active") { + section = .mostActive + continue + } + if line.hasPrefix("least active") { + section = .leastActive + continue + } + + // Header section single-line keys + if line.hasPrefix("curator:") { + let val = String(line.dropFirst("curator:".count)).trimmingCharacters(in: .whitespaces).uppercased() + switch val { + case "ENABLED": state = .enabled + case "PAUSED": state = .paused + case "DISABLED": state = .disabled + default: state = .unknown + } + continue + } + if line.hasPrefix("runs:") { + runCount = Int(line.dropFirst("runs:".count).trimmingCharacters(in: .whitespaces)) ?? 0 + continue + } + if line.hasPrefix("last run:") { + let val = String(line.dropFirst("last run:".count)).trimmingCharacters(in: .whitespaces) + lastRunISO = val == "never" ? nil : val + continue + } + if line.hasPrefix("last summary:") { + let val = String(line.dropFirst("last summary:".count)).trimmingCharacters(in: .whitespaces) + lastSummary = (val == "(none)" || val.isEmpty) ? nil : val + continue + } + if line.hasPrefix("last report:") { + let val = String(line.dropFirst("last report:".count)).trimmingCharacters(in: .whitespaces) + lastReportPath = val.isEmpty ? nil : val + continue + } + if line.hasPrefix("interval:") { + interval = String(line.dropFirst("interval:".count)).trimmingCharacters(in: .whitespaces) + continue + } + if line.hasPrefix("stale after:") { + stale = String(line.dropFirst("stale after:".count)).trimmingCharacters(in: .whitespaces) + continue + } + if line.hasPrefix("archive after:") { + archive = String(line.dropFirst("archive after:".count)).trimmingCharacters(in: .whitespaces) + continue + } + + // `agent-created skills: 18 total` + if line.hasPrefix("agent-created skills:") { + let after = line.dropFirst("agent-created skills:".count).trimmingCharacters(in: .whitespaces) + if let n = Int(after.split(separator: " ").first ?? "") { + total = n + } + section = .header + continue + } + // Counts: "active 18" / "stale 0" / "archived 0" + if let row = parseStateCountRow(line) { + switch row.state { + case "active": active = row.count + case "stale": staleCount = row.count + case "archived": archived = row.count + default: break + } + continue + } + // pinned (3): foo, bar, baz + if line.hasPrefix("pinned (") { + if let colon = line.firstIndex(of: ":") { + let names = line[line.index(after: colon)...] + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + pinned = names + } + continue + } + + // Skill rows like: + // activity= N use= N view= N patches= N last_activity=