diff --git a/CLAUDE.md b/CLAUDE.md index 3288cc8..2c6837d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,9 +113,28 @@ 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.curatorLogsDir` (`logs/curator`) + `curatorStateFile` (`skills/.curator_state`), with the per-cycle `run.json` / `REPORT.md` resolved at runtime from the `last_report_path` field on the state file. Surfaced in Scarf as a dedicated "Curator" sidebar item under Interact (between Memory and Skills) on Mac, plus a read-mostly iOS panel with Run Now / Pause / Resume actions and inline pin toggles; 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 (server side)** — `auxiliary.flush_memories` is gone from v0.12 Hermes config but remains alive on pre-v0.12 hosts. Scarf preserves `AuxiliarySettings.flushMemories: AuxiliaryModel`, the YAML reader still emits an `aux("flush_memories")` row, and `AuxiliaryTab` only renders the row when `HermesCapabilities.hasFlushMemoriesAux` is `true` (inverse semantics — pre-v0.12 only). v0.12 users never see the row; v0.11 users keep their edit surface. +- **`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) attach images that flow through `ACPClient.sendPrompt(sessionId:text:images:)` as `[{"type":"text","text":...}, {"type":"image","data":"","mimeType":"image/jpeg"}]` — wire shape matches `acp.schema.ImageContentBlock`. `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85 detached (never blocks MainActor). 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 via `hermes skills audit` (Skills "Reload" button — equivalent to the `/reload-skills` slash command for non-ACP contexts), enabled/disabled state read from `skills.disabled` in config.yaml (rendered as strikethrough + "OFF" pill), Curator pin badge from `~/.hermes/skills/.curator_state` (rendered as a pin glyph). The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb, and Scarf prefers reading accurately to risking a clobbered list. +- **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 that pulls AGENTS.md / CLAUDE.md / .cursorrules) is exposed in the editor sheet, gated on `HermesCapabilities.hasCronWorkdir` so pre-v0.12 hosts don't see the field (and a defensive override in `CronView` strips the value before calling `createJob`/`updateJob` even if it was hydrated from a pre-existing job). Pass an empty string on edit to clear an existing workdir, mirroring the `--script` shape. Hermes also added a `context_from` field for chaining cron outputs but only via YAML so far — Scarf reads it (HermesCronJob.contextFrom) but doesn't write it. +- **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). +- **iOS catch-up (Phase H):** read-only Webhooks / Plugins / Profiles tabs (`Scarf iOS/Webhooks/WebhooksView.swift`, `Plugins/PluginsView.swift`, `Profiles/ProfilesView.swift`) parity-match the Mac surfaces but skip mutating CLI verbs. `Scarf iOS/Components/HermesVersionBanner.swift` nudges pre-v0.12 hosts to upgrade (renders only when the connected target is below v0.12). +- **`hermes memory` providers:** honcho, openviking, mem0, hindsight, holographic, retaindb, byterover. `Settings → Memory` lists all providers in the picker; the existing "Run `hermes memory setup` in Terminal" hint stays — `hermes memory setup` is interactive (asks for tokens) so an in-app shellout would surface a frozen UI. +- **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/releases/v2.6.0/RELEASE_NOTES.md b/releases/v2.6.0/RELEASE_NOTES.md new file mode 100644 index 0000000..39ca0d1 --- /dev/null +++ b/releases/v2.6.0/RELEASE_NOTES.md @@ -0,0 +1,121 @@ +## What's in 2.6.0 + +A major release tracking **Hermes v2026.4.30 (v0.12.0)** — the largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Headline additions: the autonomous **Curator**, **multimodal image input** in chat, **5 new inference providers**, **Microsoft Teams + Yuanbao** gateway platforms, a **read-only Kanban** view, and ScarfGo gains read-only Webhooks/Plugins/Profiles plus a Hermes-version banner. + +Pre-v0.12 Hermes hosts are fully supported. Every new surface is gated on a runtime capability detector (`hermes --version` → semver), so users on older Hermes installs see the v2.5 surface unchanged. UI doesn't appear until the underlying CLI subcommand exists. + +### Curator (Mac + iOS) + +Hermes v0.12's autonomous skill curator prunes / consolidates / archives agent-created skills on a 7-day schedule. Scarf adds a dedicated **Curator** sidebar item under Interact (Mac) and a Curator nav row under the System tab (iOS). + +- **Status panel** — enabled/paused/disabled badge, last-run timestamp, last summary, run count, scheduling cadence (interval / stale-after / archive-after). +- **Run Now** button triggers `hermes curator run`; pause/resume from the kebab menu. +- **Three leaderboards** — least-recently-active, most-active, least-active. Each row carries activity / use / view / patch counters and an inline pin toggle. +- **Pin / unpin** — pinned skills are protected from auto-archive and rewrites. State pulled from `~/.hermes/skills/.curator_state` and surfaced as a pin glyph everywhere skills appear (Curator screen, Skills sidebar/list, SkillDetailView). +- **Restore archived** sheet calls `hermes curator restore ` to bring a previously-archived skill back. +- **Last report Markdown** — when present, the previous run's REPORT.md renders inline in mono. + +Capability-gated; sidebar item disappears on pre-v0.12 hosts. + +### Multimodal image input in chat (Mac + iOS) + +Hermes v0.12 advertises `prompt_capabilities.image = true` on ACP and accepts image content blocks in `session/prompt`. Scarf wires the producer side on both targets: + +- **Mac**: paperclip toolbar button on the chat composer opens NSOpenPanel multi-pick. Drag-and-drop and paste also work — drop an image (or a Finder file URL) onto the composer and it attaches. Capability-gated; the entire attachment surface is hidden on pre-v0.12 hosts. +- **iOS**: paperclip button opens PhotosPicker (multi-select up to 5 photos). Same byte-for-byte capability gate. +- **ImageEncoder** downsamples to 1568px long-edge (Anthropic's recommended ceiling) at JPEG q=0.85, so a 12 MP screenshot lands under ~300 KB on the wire. Detached only — never blocks MainActor. +- **Image-only sends are valid** — once at least one attachment is queued, the send button enables even with empty text. Vision models accept "describe this" with no caption. +- **Per-attachment chips** above the input field with thumbnail + filename tooltip + X to remove. 5-image-per-message cap; total payload stays under ~2 MB so cellular sends don't time out. + +Hermes routes the resulting prompt to a vision-capable model automatically — no extra Scarf-side work to pick the right aux model. + +### 5 new inference providers (Mac + iOS) + +Five overlay-only providers added to `ModelCatalogService.overlayOnlyProviders`. The model picker reaches all of them; provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly so a typo here doesn't strand users with an unreachable provider. + +- **GMI Cloud** (api_key) — `https://api.gmi-serving.com/v1` +- **Azure AI Foundry** (api_key) — base URL resolved from `AZURE_FOUNDRY_BASE_URL` per tenant +- **LM Studio** (api_key, first-class) — promoted from custom-endpoint alias to a real provider; defaults to `http://127.0.0.1:1234/v1` +- **MiniMax (OAuth)** (oauth_external) — `https://api.minimax.io/anthropic` +- **Tencent TokenHub** (api_key) — base URL resolved from `TOKENHUB_BASE_URL` + +### `auxiliary.curator` aux task (Mac) + +Hermes removed `auxiliary.flush_memories` entirely in v0.12 (the underlying memory pipeline was rewritten) and added `auxiliary.curator` so the curator's review fork can run on a separate model from the main agent. Settings → Auxiliary now surfaces a Curator row when the active host is v0.12+ (gated on `HermesCapabilities.hasCuratorAux`); the obsolete Flush Memories panel is gone. + +The Tool Gateway health view in HealthView lost the flushMemories-routes-through-Nous row and gained a curator row, matching the new aux task list. + +### Skills v0.12 surface (Mac + iOS) + +Three new capabilities Scarf can now reach: + +- **Direct-URL install** — `hermes skills install ` lets users pull a one-off skill without going through a registry. Mac SkillsView gains an "Install from URL…" toolbar button (capability-gated) opening a sheet with the URL field plus optional `--category` / `--name` overrides. +- **Reload** — `hermes skills audit` rescans the skills directory and refreshes the agent's view without a session restart. Wired to a "Reload" toolbar button next to the install button on Mac. +- **Enabled / disabled state** — `skills.disabled` in config.yaml is read at scan time. Disabled skills render strikethrough + an "OFF" pill on Mac and iOS rows; iOS detail view explains the state in plain text. +- **Curator pin badge** — pinned-skill names from `~/.hermes/skills/.curator_state` surface as a pin glyph on each row across Mac sidebar and iOS list, plus an explanatory chip on iOS detail view. + +The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb today, and we'd rather read accurately than risk clobbering the user's list with a half-tested write. + +### Cron — `--workdir` flag (Mac) + +Hermes v0.12 cron jobs accept `--workdir ` to inject AGENTS.md / CLAUDE.md / .cursorrules from that directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor now has a Workdir field; both create and edit paths forward the flag. Existing v0.11 jobs keep the no-cwd behaviour by leaving the field blank. + +The `context_from` chaining field is read-only from Scarf this round (Hermes hasn't exposed a `--context-from` CLI flag yet, only YAML). + +### Microsoft Teams + Yuanbao (Mac) + +Two new gateway platforms. Microsoft Teams (the 19th platform) ships as a plugin; Yuanbao 元宝 (the 18th) is a native gateway adapter. Both surface in the Platforms tab with read-only setup panels — the OAuth dance for Yuanbao and the plugin install for Teams happen outside Scarf. + +### Read-only Kanban (Mac) + +Hermes v0.12 ships a SQLite-backed multi-tenant task board with a full CLI (`hermes kanban create / list / claim / dispatch / …`). The multi-profile *collaboration* layer was reverted upstream while the design is reworked, so v2.6 ships a **read-only** Kanban view: paginated table of `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and per-row metadata. 5-second polling while the view is foregrounded; suspended on disappear. + +Create / claim / dispatch UI is deferred until upstream stabilizes — building the editor now would risk rework on a quarter-out timeline. + +### Settings deltas (Mac) + +A new **Caching & Redaction** section under Settings → Advanced with three v0.12 knobs (gated on capability): + +- **Prompt cache TTL** picker — 5m default / 1h opt-in. Reduces cache writes on long agent loops with stable system prompts. +- **Redact secrets in patches** toggle — Hermes flipped this off by default in v0.12 because the substitution corrupted patches; security-sensitive users can flip it back on here. +- **Runtime metadata footer** toggle — opt-in compact footer on each final reply (provider/model/cost/turn count). + +TTS provider list gains **piper** (native local TTS engine new in v0.12). Terminal backend list gains **vercel** (Vercel Sandbox backend for execute_code/terminal). Both ride along unconditionally — Hermes silently falls back when an older host doesn't recognize the value. + +### iOS catch-up — Webhooks / Plugins / Profiles (read-only) + +Three new System-tab nav rows in ScarfGo, all read-only: + +- **Webhooks** — list of `hermes webhook list` output with description / deliver / events / route per row. "Platform not enabled" detection so a freshly-installed Hermes shows setup guidance instead of error noise. +- **Plugins** — filesystem-first scan over `~/.hermes/plugins/` with manifest reads (plugin.json or plugin.yaml). Enabled/disabled badge, version, source, path. +- **Profiles** — `hermes profile list` with active-profile highlighting from `~/.hermes/active_profile`. Tolerant of both Rich box-drawn and plain-text outputs. + +None of the three are capability-gated — the underlying list verbs work on both v0.11 and v0.12. Create / edit / delete remain Mac-only since they touch enough state we keep them off the phone. + +### Hermes-version banner (iOS) + +Yellow banner at the top of the Dashboard tab when the active server is pre-v0.12. Lists the v0.12 capabilities the user is missing out on (curator, multimodal image input, new providers); one-tap session-dismiss; reappears on next app open. Hidden entirely on v0.12+ hosts. + +### Internal — version-aware capability detection + +The foundation of every gated surface above: + +- `HermesCapabilities` value type parses `Hermes Agent v0.12.0 (2026.4.30)` from `hermes --version` output. Exposes booleans for each release-gated UI surface (`hasCurator`, `hasACPImagePrompts`, `hasKanban`, `hasOneShot`, `hasSkillURLInstall`, `hasFallbackCommand`, `hasUpdateCheck`, `hasPiperTTS`, `hasVercelTerminal`, `hasCuratorAux`, `hasTeamsPlatform`, `hasYuanbaoPlatform`, `hasCronWorkdir`, `hasPromptCacheTTL`, `hasRedactionToggle`, `hasFlushMemoriesAux`). +- `HermesCapabilitiesStore` (`@Observable @MainActor`) caches per-server capabilities. Injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`. +- 12 parser tests + 6 curator-output parser tests lock the v0.12 / v0.11 / fallback flag matrices. + +### Bug fixes + +- **Test target compile** — `M5FeatureVMTests.ScriptedTransport` had drifted off the `ServerTransport` protocol after `cachedSnapshotPath` landed in v2.5.2; added the missing stub. `M0dViewModelsTests` got the `ConnectionStatusViewModel.Status.degraded` argument-name update. `CredentialPoolsGatingTests` got the missing `import ScarfCore`. The full `swift test` suite now runs (and passes — 215 tests across 17 suites). +- **iOS package compile** — `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive` used `Foundation.Process` unconditionally, breaking the iOS build entirely (Process is unavailable on the iOS SDK). Wrapped in `#if !os(iOS)` with iOS stubs that throw — backup/restore is Mac-only by design. + +### Hermes version + +Targets Hermes **v2026.4.30 (v0.12.0)**. v2026.4.23 (v0.11.0) hosts continue to work — every v0.12 surface is gated on capability detection, so Scarf v2.6 against v0.11 looks identical to Scarf v2.5.2 against v0.11. Update Hermes (`hermes update`) to unlock the new surfaces. + +### Compatibility + +- macOS 14+ (unchanged) +- iOS 17+ (unchanged) +- Hermes v0.11+ for the v2.5 surface; v0.12+ for the new features above. +- No data migrations. 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/Models/HermesConfig.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift index 4ca3d47..a62ccef 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift @@ -258,7 +258,16 @@ 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 in Hermes v0.12 but remains alive on +/// pre-v0.12 hosts — the field is preserved here so the YAML parser +/// can round-trip it and `AuxiliaryTab` can render the row when +/// `HermesCapabilities.hasFlushMemoriesAux` is set. On v0.12+ the +/// field stays empty and is never surfaced. +/// `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 +276,10 @@ public struct AuxiliarySettings: Sendable, Equatable { public var skillsHub: AuxiliaryModel public var approval: AuxiliaryModel public var mcp: AuxiliaryModel + /// pre-v0.12 only; on v0.12+ this stays `.empty` and the row is hidden. public var flushMemories: AuxiliaryModel + /// v0.12+; pre-v0.12 Hermes installs ignore this slot. + public var curator: AuxiliaryModel public init( @@ -278,7 +290,8 @@ public struct AuxiliarySettings: Sendable, Equatable { skillsHub: AuxiliaryModel, approval: AuxiliaryModel, mcp: AuxiliaryModel, - flushMemories: AuxiliaryModel + flushMemories: AuxiliaryModel, + curator: AuxiliaryModel ) { self.vision = vision self.webExtract = webExtract @@ -288,6 +301,7 @@ public struct AuxiliarySettings: Sendable, Equatable { self.approval = approval self.mcp = mcp self.flushMemories = flushMemories + self.curator = curator } public nonisolated static let empty = AuxiliarySettings( vision: .empty, @@ -297,7 +311,8 @@ public struct AuxiliarySettings: Sendable, Equatable { skillsHub: .empty, approval: .empty, mcp: .empty, - flushMemories: .empty + flushMemories: .empty, + curator: .empty ) } @@ -634,6 +649,24 @@ public struct HermesConfig: Sendable { /// platform. Scarf reads for display; edits go through Hermes CLI. public var platformToolsets: [String: [String]] + // -- Hermes v0.12 additions ---------------------------------------- + // Defaults match the Hermes v0.12 defaults so that an absent key in + // config.yaml looks identical to a freshly-installed v0.12 host. + + /// `prompt_caching.cache_ttl` — `"5m"` (default) or `"1h"`. Hermes + /// v0.12 added the 1-hour ceiling for users with prompt-cache-heavy + /// workloads (long agent loops with stable system prompts). + public var cacheTTL: String + /// `redaction.enabled` — flipped from `true` to `false` as the + /// upstream default in v0.12 because the substitution corrupted + /// patches and API payloads. Surface a toggle so users with hard + /// redaction requirements can opt back in. + public var redactionEnabled: Bool + /// `agent.runtime_metadata_footer` — opt-in compact footer on each + /// final reply (provider/model/cost/turn count). Off by default; + /// useful for cost auditing and screen-recording demos. + public var runtimeMetadataFooter: Bool + // Grouped blocks public var display: DisplaySettings public var terminal: TerminalSettings @@ -711,8 +744,14 @@ public struct HermesConfig: Sendable { matrix: MatrixSettings, mattermost: MattermostSettings, whatsapp: WhatsAppSettings, - homeAssistant: HomeAssistantSettings + homeAssistant: HomeAssistantSettings, + cacheTTL: String = "5m", + redactionEnabled: Bool = false, + runtimeMetadataFooter: Bool = false ) { + self.cacheTTL = cacheTTL + self.redactionEnabled = redactionEnabled + self.runtimeMetadataFooter = runtimeMetadataFooter self.model = model self.provider = provider self.maxTurns = maxTurns diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift index 16754c5..11c671e 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift @@ -19,6 +19,15 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { public nonisolated let timeoutType: String? public nonisolated let timeoutSeconds: Int? public nonisolated let silent: Bool? + /// Hermes v0.12+ — the directory the job runs from. Hermes injects + /// AGENTS.md / CLAUDE.md / .cursorrules from this dir and uses it + /// as cwd for terminal/file/code_exec tools. `nil` preserves the + /// pre-v0.12 behaviour (no project context files). + public nonisolated let workdir: String? + /// Hermes v0.12+ — chain another cron job's last output into this + /// job's prompt. YAML-only field today (no `--context-from` CLI + /// flag yet) — Scarf displays it but doesn't write it. + public nonisolated let contextFrom: [String]? public enum CodingKeys: String, CodingKey { case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent @@ -30,6 +39,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { case lastDeliveryError = "last_delivery_error" case timeoutType = "timeout_type" case timeoutSeconds = "timeout_seconds" + case workdir + case contextFrom = "context_from" } /// Memberwise init. Swift doesn't synthesize one for us because @@ -53,7 +64,9 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { lastDeliveryError: String? = nil, timeoutType: String? = nil, timeoutSeconds: Int? = nil, - silent: Bool? = nil + silent: Bool? = nil, + workdir: String? = nil, + contextFrom: [String]? = nil ) { self.id = id self.name = name @@ -73,6 +86,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { self.timeoutType = timeoutType self.timeoutSeconds = timeoutSeconds self.silent = silent + self.workdir = workdir + self.contextFrom = contextFrom } public nonisolated init(from decoder: any Decoder) throws { @@ -95,6 +110,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType) self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds) self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent) + self.workdir = try c.decodeIfPresent(String.self, forKey: .workdir) + self.contextFrom = try c.decodeIfPresent([String].self, forKey: .contextFrom) } public nonisolated func encode(to encoder: any Encoder) throws { @@ -117,6 +134,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { try c.encodeIfPresent(timeoutType, forKey: .timeoutType) try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) try c.encodeIfPresent(silent, forKey: .silent) + try c.encodeIfPresent(workdir, forKey: .workdir) + try c.encodeIfPresent(contextFrom, forKey: .contextFrom) } public nonisolated var stateIcon: String { 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=