mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Merge branch 'v12-updates'
Hermes v2026.4.30 (v0.12.0) compatibility — autonomous Curator (Mac + iOS), multimodal image input in chat, 5 new inference providers, Microsoft Teams + Yuanbao gateway platforms, read-only Kanban view, Skills v0.12 surface (URL install / reload / pin / disable), Cron --workdir flag, Settings deltas (cache TTL, redaction, runtime footer, Piper, Vercel), iOS read-only Webhooks/Plugins/Profiles, and a pre-v0.12 Hermes-version banner. All new surfaces capability-gated so older Hermes hosts see the v2.5 surface unchanged. Release notes: releases/v2.6.0/RELEASE_NOTES.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,9 +113,28 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc
|
|||||||
|
|
||||||
## Hermes Version
|
## 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":"<base64>","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 <prompt>` (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 <https-url>` 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 <abs-path>` (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 <prompt>` — 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.
|
- `/steer <prompt>` — 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.
|
- 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.
|
||||||
|
|||||||
@@ -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 <name>` 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 <https-url>` 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 <absolute-path>` 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.
|
||||||
@@ -266,14 +266,47 @@ public actor ACPClient {
|
|||||||
// MARK: - Messaging
|
// MARK: - Messaging
|
||||||
|
|
||||||
public func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
|
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..."
|
statusMessage = "Sending prompt..."
|
||||||
let messageId = UUID().uuidString
|
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] = [
|
let params: [String: AnyCodable] = [
|
||||||
"sessionId": AnyCodable(sessionId),
|
"sessionId": AnyCodable(sessionId),
|
||||||
"messageId": AnyCodable(messageId),
|
"messageId": AnyCodable(messageId),
|
||||||
"prompt": AnyCodable([
|
"prompt": AnyCodable(promptBlocks as [Any]),
|
||||||
["type": "text", "text": text] as [String: Any],
|
|
||||||
] as [Any]),
|
|
||||||
]
|
]
|
||||||
let result = try await sendRequest(method: "session/prompt", params: params)
|
let result = try await sendRequest(method: "session/prompt", params: params)
|
||||||
let dict = result?.dictValue ?? [:]
|
let dict = result?.dictValue ?? [:]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 struct AuxiliarySettings: Sendable, Equatable {
|
||||||
public var vision: AuxiliaryModel
|
public var vision: AuxiliaryModel
|
||||||
public var webExtract: AuxiliaryModel
|
public var webExtract: AuxiliaryModel
|
||||||
@@ -267,7 +276,10 @@ public struct AuxiliarySettings: Sendable, Equatable {
|
|||||||
public var skillsHub: AuxiliaryModel
|
public var skillsHub: AuxiliaryModel
|
||||||
public var approval: AuxiliaryModel
|
public var approval: AuxiliaryModel
|
||||||
public var mcp: AuxiliaryModel
|
public var mcp: AuxiliaryModel
|
||||||
|
/// pre-v0.12 only; on v0.12+ this stays `.empty` and the row is hidden.
|
||||||
public var flushMemories: AuxiliaryModel
|
public var flushMemories: AuxiliaryModel
|
||||||
|
/// v0.12+; pre-v0.12 Hermes installs ignore this slot.
|
||||||
|
public var curator: AuxiliaryModel
|
||||||
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -278,7 +290,8 @@ public struct AuxiliarySettings: Sendable, Equatable {
|
|||||||
skillsHub: AuxiliaryModel,
|
skillsHub: AuxiliaryModel,
|
||||||
approval: AuxiliaryModel,
|
approval: AuxiliaryModel,
|
||||||
mcp: AuxiliaryModel,
|
mcp: AuxiliaryModel,
|
||||||
flushMemories: AuxiliaryModel
|
flushMemories: AuxiliaryModel,
|
||||||
|
curator: AuxiliaryModel
|
||||||
) {
|
) {
|
||||||
self.vision = vision
|
self.vision = vision
|
||||||
self.webExtract = webExtract
|
self.webExtract = webExtract
|
||||||
@@ -288,6 +301,7 @@ public struct AuxiliarySettings: Sendable, Equatable {
|
|||||||
self.approval = approval
|
self.approval = approval
|
||||||
self.mcp = mcp
|
self.mcp = mcp
|
||||||
self.flushMemories = flushMemories
|
self.flushMemories = flushMemories
|
||||||
|
self.curator = curator
|
||||||
}
|
}
|
||||||
public nonisolated static let empty = AuxiliarySettings(
|
public nonisolated static let empty = AuxiliarySettings(
|
||||||
vision: .empty,
|
vision: .empty,
|
||||||
@@ -297,7 +311,8 @@ public struct AuxiliarySettings: Sendable, Equatable {
|
|||||||
skillsHub: .empty,
|
skillsHub: .empty,
|
||||||
approval: .empty,
|
approval: .empty,
|
||||||
mcp: .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.
|
/// platform. Scarf reads for display; edits go through Hermes CLI.
|
||||||
public var platformToolsets: [String: [String]]
|
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
|
// Grouped blocks
|
||||||
public var display: DisplaySettings
|
public var display: DisplaySettings
|
||||||
public var terminal: TerminalSettings
|
public var terminal: TerminalSettings
|
||||||
@@ -711,8 +744,14 @@ public struct HermesConfig: Sendable {
|
|||||||
matrix: MatrixSettings,
|
matrix: MatrixSettings,
|
||||||
mattermost: MattermostSettings,
|
mattermost: MattermostSettings,
|
||||||
whatsapp: WhatsAppSettings,
|
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.model = model
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.maxTurns = maxTurns
|
self.maxTurns = maxTurns
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
public nonisolated let timeoutType: String?
|
public nonisolated let timeoutType: String?
|
||||||
public nonisolated let timeoutSeconds: Int?
|
public nonisolated let timeoutSeconds: Int?
|
||||||
public nonisolated let silent: Bool?
|
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 {
|
public enum CodingKeys: String, CodingKey {
|
||||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
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 lastDeliveryError = "last_delivery_error"
|
||||||
case timeoutType = "timeout_type"
|
case timeoutType = "timeout_type"
|
||||||
case timeoutSeconds = "timeout_seconds"
|
case timeoutSeconds = "timeout_seconds"
|
||||||
|
case workdir
|
||||||
|
case contextFrom = "context_from"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Memberwise init. Swift doesn't synthesize one for us because
|
/// Memberwise init. Swift doesn't synthesize one for us because
|
||||||
@@ -53,7 +64,9 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
lastDeliveryError: String? = nil,
|
lastDeliveryError: String? = nil,
|
||||||
timeoutType: String? = nil,
|
timeoutType: String? = nil,
|
||||||
timeoutSeconds: Int? = nil,
|
timeoutSeconds: Int? = nil,
|
||||||
silent: Bool? = nil
|
silent: Bool? = nil,
|
||||||
|
workdir: String? = nil,
|
||||||
|
contextFrom: [String]? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -73,6 +86,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
self.timeoutType = timeoutType
|
self.timeoutType = timeoutType
|
||||||
self.timeoutSeconds = timeoutSeconds
|
self.timeoutSeconds = timeoutSeconds
|
||||||
self.silent = silent
|
self.silent = silent
|
||||||
|
self.workdir = workdir
|
||||||
|
self.contextFrom = contextFrom
|
||||||
}
|
}
|
||||||
|
|
||||||
public nonisolated init(from decoder: any Decoder) throws {
|
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.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType)
|
||||||
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
|
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
|
||||||
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
|
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 {
|
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(timeoutType, forKey: .timeoutType)
|
||||||
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||||
try c.encodeIfPresent(silent, forKey: .silent)
|
try c.encodeIfPresent(silent, forKey: .silent)
|
||||||
|
try c.encodeIfPresent(workdir, forKey: .workdir)
|
||||||
|
try c.encodeIfPresent(contextFrom, forKey: .contextFrom)
|
||||||
}
|
}
|
||||||
|
|
||||||
public nonisolated var stateIcon: String {
|
public nonisolated var stateIcon: String {
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Parsed view of `hermes curator status` text + the on-disk
|
||||||
|
/// `~/.hermes/skills/.curator_state` JSON.
|
||||||
|
///
|
||||||
|
/// Hermes v0.12 doesn't ship a `--json` flag for `curator status` — the
|
||||||
|
/// CLI writes a human-readable report. CuratorViewModel parses the text
|
||||||
|
/// output for the human-readable bits ("least recently active", "most
|
||||||
|
/// active") and reads the state file directly for last-run metadata.
|
||||||
|
public struct HermesCuratorStatus: Sendable, Equatable {
|
||||||
|
public enum RunState: String, Sendable, Equatable {
|
||||||
|
case enabled
|
||||||
|
case paused
|
||||||
|
case disabled
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
public let state: RunState
|
||||||
|
public let runCount: Int
|
||||||
|
public let lastRunISO: String? // raw timestamp string, parsed by callers
|
||||||
|
public let lastSummary: String? // free-text summary line
|
||||||
|
public let lastReportPath: String? // absolute path to <YYYYMMDD-HHMMSS>/ dir
|
||||||
|
public let intervalLabel: String // e.g. "every 7d"
|
||||||
|
public let staleAfterLabel: String // e.g. "30d unused"
|
||||||
|
public let archiveAfterLabel: String // e.g. "90d unused"
|
||||||
|
|
||||||
|
public let totalSkills: Int
|
||||||
|
public let activeSkills: Int
|
||||||
|
public let staleSkills: Int
|
||||||
|
public let archivedSkills: Int
|
||||||
|
|
||||||
|
public let pinnedNames: [String]
|
||||||
|
|
||||||
|
/// Top-5 lists rendered in the curator output. Each row carries the
|
||||||
|
/// skill name + the four counters Hermes prints.
|
||||||
|
public let leastRecentlyActive: [HermesCuratorSkillRow]
|
||||||
|
public let mostActive: [HermesCuratorSkillRow]
|
||||||
|
public let leastActive: [HermesCuratorSkillRow]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
state: RunState,
|
||||||
|
runCount: Int,
|
||||||
|
lastRunISO: String?,
|
||||||
|
lastSummary: String?,
|
||||||
|
lastReportPath: String?,
|
||||||
|
intervalLabel: String,
|
||||||
|
staleAfterLabel: String,
|
||||||
|
archiveAfterLabel: String,
|
||||||
|
totalSkills: Int,
|
||||||
|
activeSkills: Int,
|
||||||
|
staleSkills: Int,
|
||||||
|
archivedSkills: Int,
|
||||||
|
pinnedNames: [String],
|
||||||
|
leastRecentlyActive: [HermesCuratorSkillRow],
|
||||||
|
mostActive: [HermesCuratorSkillRow],
|
||||||
|
leastActive: [HermesCuratorSkillRow]
|
||||||
|
) {
|
||||||
|
self.state = state
|
||||||
|
self.runCount = runCount
|
||||||
|
self.lastRunISO = lastRunISO
|
||||||
|
self.lastSummary = lastSummary
|
||||||
|
self.lastReportPath = lastReportPath
|
||||||
|
self.intervalLabel = intervalLabel
|
||||||
|
self.staleAfterLabel = staleAfterLabel
|
||||||
|
self.archiveAfterLabel = archiveAfterLabel
|
||||||
|
self.totalSkills = totalSkills
|
||||||
|
self.activeSkills = activeSkills
|
||||||
|
self.staleSkills = staleSkills
|
||||||
|
self.archivedSkills = archivedSkills
|
||||||
|
self.pinnedNames = pinnedNames
|
||||||
|
self.leastRecentlyActive = leastRecentlyActive
|
||||||
|
self.mostActive = mostActive
|
||||||
|
self.leastActive = leastActive
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let empty = HermesCuratorStatus(
|
||||||
|
state: .unknown,
|
||||||
|
runCount: 0,
|
||||||
|
lastRunISO: nil,
|
||||||
|
lastSummary: nil,
|
||||||
|
lastReportPath: nil,
|
||||||
|
intervalLabel: "—",
|
||||||
|
staleAfterLabel: "—",
|
||||||
|
archiveAfterLabel: "—",
|
||||||
|
totalSkills: 0,
|
||||||
|
activeSkills: 0,
|
||||||
|
staleSkills: 0,
|
||||||
|
archivedSkills: 0,
|
||||||
|
pinnedNames: [],
|
||||||
|
leastRecentlyActive: [],
|
||||||
|
mostActive: [],
|
||||||
|
leastActive: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HermesCuratorSkillRow: Sendable, Equatable, Identifiable {
|
||||||
|
public var id: String { name }
|
||||||
|
public let name: String
|
||||||
|
public let activityCount: Int
|
||||||
|
public let useCount: Int
|
||||||
|
public let viewCount: Int
|
||||||
|
public let patchCount: Int
|
||||||
|
public let lastActivityLabel: String // raw label as printed (e.g. "never", "2d ago")
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
activityCount: Int,
|
||||||
|
useCount: Int,
|
||||||
|
viewCount: Int,
|
||||||
|
patchCount: Int,
|
||||||
|
lastActivityLabel: String
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.activityCount = activityCount
|
||||||
|
self.useCount = useCount
|
||||||
|
self.viewCount = viewCount
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.lastActivityLabel = lastActivityLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure parser for `hermes curator status` stdout. Public for tests.
|
||||||
|
///
|
||||||
|
/// Format is stable enough to text-parse; we never error on missing
|
||||||
|
/// sections — we just leave the corresponding field empty so
|
||||||
|
/// CuratorView can render "—" without crashing on a future layout
|
||||||
|
/// tweak. State file overrides text-parsed values when both are present.
|
||||||
|
public enum HermesCuratorStatusParser {
|
||||||
|
public static func parse(text: String, stateFileJSON: Data? = nil) -> HermesCuratorStatus {
|
||||||
|
let lines = text.components(separatedBy: "\n")
|
||||||
|
var status = HermesCuratorStatus.empty
|
||||||
|
|
||||||
|
// Header section: `curator: ENABLED` / `runs:` / `last run:` /
|
||||||
|
// `last summary:` / `interval:` / `stale after:` / `archive after:`
|
||||||
|
var state = HermesCuratorStatus.RunState.unknown
|
||||||
|
var runCount = 0
|
||||||
|
var lastRunISO: String?
|
||||||
|
var lastSummary: String?
|
||||||
|
var lastReportPath: String?
|
||||||
|
var interval = "—"
|
||||||
|
var stale = "—"
|
||||||
|
var archive = "—"
|
||||||
|
|
||||||
|
// Skill counts: `agent-created skills: N total` then
|
||||||
|
// ` active N` / ` stale N` / ` archived N`
|
||||||
|
var total = 0
|
||||||
|
var active = 0
|
||||||
|
var staleCount = 0
|
||||||
|
var archived = 0
|
||||||
|
|
||||||
|
var pinned: [String] = []
|
||||||
|
|
||||||
|
// Lists: `least recently active (top 5):` / `most active (top 5):` /
|
||||||
|
// `least active (top 5):` followed by indented row lines.
|
||||||
|
enum Section {
|
||||||
|
case header
|
||||||
|
case leastRecent
|
||||||
|
case mostActive
|
||||||
|
case leastActive
|
||||||
|
}
|
||||||
|
var section = Section.header
|
||||||
|
var leastRecent: [HermesCuratorSkillRow] = []
|
||||||
|
var mostActiveRows: [HermesCuratorSkillRow] = []
|
||||||
|
var leastActiveRows: [HermesCuratorSkillRow] = []
|
||||||
|
|
||||||
|
for raw in lines {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
// Section markers
|
||||||
|
if line.hasPrefix("least recently active") {
|
||||||
|
section = .leastRecent
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("most active") {
|
||||||
|
section = .mostActive
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("least active") {
|
||||||
|
section = .leastActive
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header section single-line keys
|
||||||
|
if line.hasPrefix("curator:") {
|
||||||
|
let val = String(line.dropFirst("curator:".count)).trimmingCharacters(in: .whitespaces).uppercased()
|
||||||
|
switch val {
|
||||||
|
case "ENABLED": state = .enabled
|
||||||
|
case "PAUSED": state = .paused
|
||||||
|
case "DISABLED": state = .disabled
|
||||||
|
default: state = .unknown
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("runs:") {
|
||||||
|
runCount = Int(line.dropFirst("runs:".count).trimmingCharacters(in: .whitespaces)) ?? 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("last run:") {
|
||||||
|
let val = String(line.dropFirst("last run:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
lastRunISO = val == "never" ? nil : val
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("last summary:") {
|
||||||
|
let val = String(line.dropFirst("last summary:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
lastSummary = (val == "(none)" || val.isEmpty) ? nil : val
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("last report:") {
|
||||||
|
let val = String(line.dropFirst("last report:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
lastReportPath = val.isEmpty ? nil : val
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("interval:") {
|
||||||
|
interval = String(line.dropFirst("interval:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("stale after:") {
|
||||||
|
stale = String(line.dropFirst("stale after:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("archive after:") {
|
||||||
|
archive = String(line.dropFirst("archive after:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// `agent-created skills: 18 total`
|
||||||
|
if line.hasPrefix("agent-created skills:") {
|
||||||
|
let after = line.dropFirst("agent-created skills:".count).trimmingCharacters(in: .whitespaces)
|
||||||
|
if let n = Int(after.split(separator: " ").first ?? "") {
|
||||||
|
total = n
|
||||||
|
}
|
||||||
|
section = .header
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Counts: "active 18" / "stale 0" / "archived 0"
|
||||||
|
if let row = parseStateCountRow(line) {
|
||||||
|
switch row.state {
|
||||||
|
case "active": active = row.count
|
||||||
|
case "stale": staleCount = row.count
|
||||||
|
case "archived": archived = row.count
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// pinned (3): foo, bar, baz
|
||||||
|
if line.hasPrefix("pinned (") {
|
||||||
|
if let colon = line.firstIndex(of: ":") {
|
||||||
|
let names = line[line.index(after: colon)...]
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
pinned = names
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill rows like:
|
||||||
|
// <name> activity= N use= N view= N patches= N last_activity=<label>
|
||||||
|
if section != .header, let parsed = parseSkillRow(line) {
|
||||||
|
switch section {
|
||||||
|
case .leastRecent: leastRecent.append(parsed)
|
||||||
|
case .mostActive: mostActiveRows.append(parsed)
|
||||||
|
case .leastActive: leastActiveRows.append(parsed)
|
||||||
|
case .header: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply state-file overrides if present. The .curator_state JSON
|
||||||
|
// is authoritative for last_run_at / last_run_summary /
|
||||||
|
// last_report_path because those carry timestamps the text
|
||||||
|
// output rounds.
|
||||||
|
if let json = stateFileJSON,
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] {
|
||||||
|
if obj["paused"] as? Bool == true { state = .paused }
|
||||||
|
if let count = obj["run_count"] as? Int { runCount = count }
|
||||||
|
if let lr = obj["last_run_at"] as? String { lastRunISO = lr }
|
||||||
|
if let summary = obj["last_run_summary"] as? String, !summary.isEmpty { lastSummary = summary }
|
||||||
|
if let path = obj["last_report_path"] as? String, !path.isEmpty { lastReportPath = path }
|
||||||
|
}
|
||||||
|
|
||||||
|
status = HermesCuratorStatus(
|
||||||
|
state: state,
|
||||||
|
runCount: runCount,
|
||||||
|
lastRunISO: lastRunISO,
|
||||||
|
lastSummary: lastSummary,
|
||||||
|
lastReportPath: lastReportPath,
|
||||||
|
intervalLabel: interval,
|
||||||
|
staleAfterLabel: stale,
|
||||||
|
archiveAfterLabel: archive,
|
||||||
|
totalSkills: total,
|
||||||
|
activeSkills: active,
|
||||||
|
staleSkills: staleCount,
|
||||||
|
archivedSkills: archived,
|
||||||
|
pinnedNames: pinned,
|
||||||
|
leastRecentlyActive: leastRecent,
|
||||||
|
mostActive: mostActiveRows,
|
||||||
|
leastActive: leastActiveRows
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `active 18` style row inside the skill-count block.
|
||||||
|
private static func parseStateCountRow(_ line: String) -> (state: String, count: Int)? {
|
||||||
|
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||||
|
guard parts.count >= 2,
|
||||||
|
["active", "stale", "archived"].contains(parts[0]),
|
||||||
|
let count = Int(parts[1])
|
||||||
|
else { return nil }
|
||||||
|
return (parts[0], count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skill-list row parser. Tolerates Hermes's whitespace-padded
|
||||||
|
/// layout — `activity= 0` has two spaces between `=` and the
|
||||||
|
/// number, so we can't split-on-space-then-split-on-`=`. Instead
|
||||||
|
/// we slide a key-detection cursor across the row and grab the
|
||||||
|
/// next non-whitespace token after each known key.
|
||||||
|
private static func parseSkillRow(_ line: String) -> HermesCuratorSkillRow? {
|
||||||
|
guard let activityRange = line.range(of: "activity=") else { return nil }
|
||||||
|
let name = String(line[..<activityRange.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !name.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// Map each known key to its value substring. Read positionally
|
||||||
|
// by slicing between consecutive known keys — handles arbitrary
|
||||||
|
// whitespace padding without depending on column positions.
|
||||||
|
let knownKeys = ["activity=", "use=", "view=", "patches=", "last_activity="]
|
||||||
|
var positions: [(key: String, range: Range<String.Index>)] = []
|
||||||
|
for key in knownKeys {
|
||||||
|
if let r = line.range(of: key) {
|
||||||
|
positions.append((key, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
positions.sort { $0.range.lowerBound < $1.range.lowerBound }
|
||||||
|
|
||||||
|
var activity = 0, use = 0, view = 0, patch = 0
|
||||||
|
var lastActivity = ""
|
||||||
|
|
||||||
|
for (idx, entry) in positions.enumerated() {
|
||||||
|
let valueStart = entry.range.upperBound
|
||||||
|
let valueEnd = idx + 1 < positions.count
|
||||||
|
? positions[idx + 1].range.lowerBound
|
||||||
|
: line.endIndex
|
||||||
|
let raw = String(line[valueStart..<valueEnd]).trimmingCharacters(in: .whitespaces)
|
||||||
|
switch entry.key {
|
||||||
|
case "activity=": activity = Int(raw) ?? 0
|
||||||
|
case "use=": use = Int(raw) ?? 0
|
||||||
|
case "view=": view = Int(raw) ?? 0
|
||||||
|
case "patches=": patch = Int(raw) ?? 0
|
||||||
|
case "last_activity=": lastActivity = raw
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HermesCuratorSkillRow(
|
||||||
|
name: name,
|
||||||
|
activityCount: activity,
|
||||||
|
useCount: use,
|
||||||
|
viewCount: view,
|
||||||
|
patchCount: patch,
|
||||||
|
lastActivityLabel: lastActivity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One task from `hermes kanban list --json` (v0.12+).
|
||||||
|
///
|
||||||
|
/// Hermes ships a SQLite-backed task board under `~/.hermes/kanban.db`
|
||||||
|
/// — multi-profile collaboration was reverted upstream while the
|
||||||
|
/// design is reworked, so Scarf v2.6 surfaces this as a read-only
|
||||||
|
/// list. Create / claim / dispatch / dependency-link UI is deferred
|
||||||
|
/// until upstream stabilizes.
|
||||||
|
public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
|
||||||
|
public let id: String
|
||||||
|
public let title: String
|
||||||
|
public let body: String?
|
||||||
|
public let assignee: String?
|
||||||
|
public let status: String // archived | blocked | done | ready | running | todo | triage
|
||||||
|
public let priority: Int?
|
||||||
|
public let tenant: String?
|
||||||
|
public let workspaceKind: String? // scratch | worktree | dir
|
||||||
|
public let workspacePath: String?
|
||||||
|
public let createdBy: String?
|
||||||
|
public let createdAt: String? // ISO timestamp
|
||||||
|
public let startedAt: String?
|
||||||
|
public let completedAt: String?
|
||||||
|
public let result: String?
|
||||||
|
public let skills: [String]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
body: String? = nil,
|
||||||
|
assignee: String? = nil,
|
||||||
|
status: String,
|
||||||
|
priority: Int? = nil,
|
||||||
|
tenant: String? = nil,
|
||||||
|
workspaceKind: String? = nil,
|
||||||
|
workspacePath: String? = nil,
|
||||||
|
createdBy: String? = nil,
|
||||||
|
createdAt: String? = nil,
|
||||||
|
startedAt: String? = nil,
|
||||||
|
completedAt: String? = nil,
|
||||||
|
result: String? = nil,
|
||||||
|
skills: [String] = []
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.body = body
|
||||||
|
self.assignee = assignee
|
||||||
|
self.status = status
|
||||||
|
self.priority = priority
|
||||||
|
self.tenant = tenant
|
||||||
|
self.workspaceKind = workspaceKind
|
||||||
|
self.workspacePath = workspacePath
|
||||||
|
self.createdBy = createdBy
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.completedAt = completedAt
|
||||||
|
self.result = result
|
||||||
|
self.skills = skills
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, title, body, assignee, status, priority, tenant
|
||||||
|
case workspaceKind = "workspace_kind"
|
||||||
|
case workspacePath = "workspace_path"
|
||||||
|
case createdBy = "created_by"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case completedAt = "completed_at"
|
||||||
|
case result, skills
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try c.decode(String.self, forKey: .id)
|
||||||
|
self.title = try c.decode(String.self, forKey: .title)
|
||||||
|
self.body = try c.decodeIfPresent(String.self, forKey: .body)
|
||||||
|
self.assignee = try c.decodeIfPresent(String.self, forKey: .assignee)
|
||||||
|
self.status = try c.decodeIfPresent(String.self, forKey: .status) ?? "unknown"
|
||||||
|
self.priority = try c.decodeIfPresent(Int.self, forKey: .priority)
|
||||||
|
self.tenant = try c.decodeIfPresent(String.self, forKey: .tenant)
|
||||||
|
self.workspaceKind = try c.decodeIfPresent(String.self, forKey: .workspaceKind)
|
||||||
|
self.workspacePath = try c.decodeIfPresent(String.self, forKey: .workspacePath)
|
||||||
|
self.createdBy = try c.decodeIfPresent(String.self, forKey: .createdBy)
|
||||||
|
self.createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt)
|
||||||
|
self.startedAt = try c.decodeIfPresent(String.self, forKey: .startedAt)
|
||||||
|
self.completedAt = try c.decodeIfPresent(String.self, forKey: .completedAt)
|
||||||
|
self.result = try c.decodeIfPresent(String.self, forKey: .result)
|
||||||
|
self.skills = try c.decodeIfPresent([String].self, forKey: .skills) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,17 @@ public struct HermesPathSet: Sendable, Hashable {
|
|||||||
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
||||||
public nonisolated var agentLog: String { home + "/logs/agent.log" }
|
public nonisolated var agentLog: String { home + "/logs/agent.log" }
|
||||||
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||||
|
/// Curator run-reports root (v0.12+). Hermes writes per-cycle dirs
|
||||||
|
/// under here named `<YYYYMMDD-HHMMSS>/` containing `run.json` and
|
||||||
|
/// `REPORT.md`. The `last_report_path` field on `curator_state`
|
||||||
|
/// points at the most recent dir; `CuratorViewModel` resolves the
|
||||||
|
/// JSON/Markdown files relative to it.
|
||||||
|
public nonisolated var curatorLogsDir: String { home + "/logs/curator" }
|
||||||
|
/// JSON-encoded curator state (v0.12+). Filename has no extension
|
||||||
|
/// despite holding JSON — Hermes writes it via
|
||||||
|
/// `~/.hermes/skills/.curator_state`. Carries last-run metadata,
|
||||||
|
/// run count, pause flag, and the path to the most recent report.
|
||||||
|
public nonisolated var curatorStateFile: String { home + "/skills/.curator_state" }
|
||||||
public nonisolated var scarfDir: String { home + "/scarf" }
|
public nonisolated var scarfDir: String { home + "/scarf" }
|
||||||
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ public struct HermesSkill: Identifiable, Sendable {
|
|||||||
/// Python packages). Used by `SkillPrereqService` to know what to
|
/// Python packages). Used by `SkillPrereqService` to know what to
|
||||||
/// probe; nil when the field is absent.
|
/// probe; nil when the field is absent.
|
||||||
public let dependencies: [String]?
|
public let dependencies: [String]?
|
||||||
|
/// `false` when the skill name appears in `skills.disabled` in
|
||||||
|
/// `~/.hermes/config.yaml`. Hermes v0.12 stores disable state in
|
||||||
|
/// the config rather than per-skill markers; this is read-only
|
||||||
|
/// from Scarf's side until the toggle UI lands. Defaults to `true`.
|
||||||
|
public let enabled: Bool
|
||||||
|
/// `true` when the skill is pinned via `hermes curator pin <name>`.
|
||||||
|
/// Pinned skills are protected from auto-archive / consolidation.
|
||||||
|
/// Read from `CuratorViewModel.status.pinnedNames`; defaults to
|
||||||
|
/// `false` when curator state is unavailable.
|
||||||
|
public let pinned: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -47,7 +57,9 @@ public struct HermesSkill: Identifiable, Sendable {
|
|||||||
requiredConfig: [String],
|
requiredConfig: [String],
|
||||||
allowedTools: [String]? = nil,
|
allowedTools: [String]? = nil,
|
||||||
relatedSkills: [String]? = nil,
|
relatedSkills: [String]? = nil,
|
||||||
dependencies: [String]? = nil
|
dependencies: [String]? = nil,
|
||||||
|
enabled: Bool = true,
|
||||||
|
pinned: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -58,5 +70,7 @@ public struct HermesSkill: Identifiable, Sendable {
|
|||||||
self.allowedTools = allowedTools
|
self.allowedTools = allowedTools
|
||||||
self.relatedSkills = relatedSkills
|
self.relatedSkills = relatedSkills
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
|
self.enabled = enabled
|
||||||
|
self.pinned = pinned
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ public enum KnownPlatforms {
|
|||||||
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
|
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
|
||||||
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
|
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
|
||||||
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
|
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
|
||||||
|
// -- v0.12 additions ---------------------------------------------
|
||||||
|
// Yuanbao is a native gateway adapter (18th platform); Microsoft
|
||||||
|
// Teams ships as a plugin (19th). PlatformDetail surfaces the
|
||||||
|
// distinction in the setup copy. Names match Hermes's gateway
|
||||||
|
// platform identifiers.
|
||||||
|
HermesToolPlatform(name: "yuanbao", displayName: "Yuanbao 元宝", icon: "bubble.left.and.bubble.right.fill"),
|
||||||
|
HermesToolPlatform(name: "microsoft-teams", displayName: "Microsoft Teams", icon: "person.2.fill"),
|
||||||
]
|
]
|
||||||
|
|
||||||
public static func icon(for platform: String) -> String {
|
public static func icon(for platform: String) -> String {
|
||||||
@@ -70,6 +77,8 @@ public enum KnownPlatforms {
|
|||||||
case "feishu": return "message.badge.circle"
|
case "feishu": return "message.badge.circle"
|
||||||
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
|
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
|
||||||
case "imessage": return "message.fill"
|
case "imessage": return "message.fill"
|
||||||
|
case "yuanbao": return "bubble.left.and.bubble.right.fill"
|
||||||
|
case "microsoft-teams": return "person.2.fill"
|
||||||
default: return "bubble.left"
|
default: return "bubble.left"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ public extension HermesConfig {
|
|||||||
skillsHub: aux("skills_hub"),
|
skillsHub: aux("skills_hub"),
|
||||||
approval: aux("approval"),
|
approval: aux("approval"),
|
||||||
mcp: aux("mcp"),
|
mcp: aux("mcp"),
|
||||||
flushMemories: aux("flush_memories")
|
flushMemories: aux("flush_memories"),
|
||||||
|
curator: aux("curator")
|
||||||
)
|
)
|
||||||
|
|
||||||
let security = SecuritySettings(
|
let security = SecuritySettings(
|
||||||
@@ -280,7 +281,10 @@ public extension HermesConfig {
|
|||||||
matrix: matrix,
|
matrix: matrix,
|
||||||
mattermost: mattermost,
|
mattermost: mattermost,
|
||||||
whatsapp: whatsapp,
|
whatsapp: whatsapp,
|
||||||
homeAssistant: homeAssistant
|
homeAssistant: homeAssistant,
|
||||||
|
cacheTTL: str("prompt_caching.cache_ttl", default: "5m"),
|
||||||
|
redactionEnabled: bool("redaction.enabled", default: false),
|
||||||
|
runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <prompt>` non-interactive one-shot mode (v0.12+).
|
||||||
|
public var hasOneShot: Bool { atLeastSemver(0, 12, 0) }
|
||||||
|
|
||||||
|
/// `hermes skills install <https-url>` 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[..<semverEnd])
|
||||||
|
let semverParts = semverStr.split(separator: ".").compactMap { Int($0) }
|
||||||
|
guard semverParts.count >= 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)..<closeParen]
|
||||||
|
let dateParts = dateStr.split(separator: ".").compactMap { Int($0) }
|
||||||
|
if dateParts.count == 3 {
|
||||||
|
dateVersion = DateVersion(
|
||||||
|
year: dateParts[0],
|
||||||
|
month: dateParts[1],
|
||||||
|
day: dateParts[2]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return HermesCapabilities(
|
||||||
|
versionLine: line,
|
||||||
|
semver: semver,
|
||||||
|
dateVersion: dateVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-server capability cache. One per `ContextBoundRoot` (Mac) / iOS scene
|
||||||
|
/// root, injected via `.environment(_:)`. Refreshes once on init; callers
|
||||||
|
/// invoke `refresh()` after a Hermes update or when the server changes.
|
||||||
|
///
|
||||||
|
/// Not thread-safe across instances — each server gets its own store, and
|
||||||
|
/// the underlying `runHermesCLI` call is detached so we never block
|
||||||
|
/// MainActor.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
public final class HermesCapabilitiesStore {
|
||||||
|
#if canImport(os)
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "HermesCapabilities")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public private(set) var capabilities: HermesCapabilities = .empty
|
||||||
|
public private(set) var isLoading = true
|
||||||
|
|
||||||
|
public let context: ServerContext
|
||||||
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -425,15 +425,17 @@ public struct ModelCatalogService: Sendable {
|
|||||||
|
|
||||||
// MARK: - Hermes overlay providers
|
// 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).
|
/// entry in `models_dev_cache.json` (models.dev doesn't mirror them).
|
||||||
/// Mirrors the overlay-only subset of `HERMES_OVERLAYS` in
|
/// 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
|
/// already ship in the cache and only add augmentation (base-URL
|
||||||
/// override, extra env vars) that Scarf doesn't currently display.
|
/// override, extra env vars) that Scarf doesn't currently display.
|
||||||
///
|
///
|
||||||
/// Keep this in sync with the Python side on Hermes version bumps.
|
/// Keep this in sync with the Python side on Hermes version bumps —
|
||||||
static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
|
/// see `ToolGatewayTests.v012OverlayProvidersCarryCorrectAuthTypes`
|
||||||
|
/// for the auth-type lock-in.
|
||||||
|
public static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
|
||||||
"nous": HermesProviderOverlay(
|
"nous": HermesProviderOverlay(
|
||||||
displayName: "Nous Portal",
|
displayName: "Nous Portal",
|
||||||
baseURL: "https://inference-api.nousresearch.com/v1",
|
baseURL: "https://inference-api.nousresearch.com/v1",
|
||||||
@@ -476,6 +478,53 @@ public struct ModelCatalogService: Sendable {
|
|||||||
subscriptionGated: false,
|
subscriptionGated: false,
|
||||||
docURL: nil
|
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
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -496,7 +496,15 @@ public final class RemoteBackupService: @unchecked Sendable {
|
|||||||
/// macOS ships `zip` at this fixed path so we don't need a PATH
|
/// macOS ships `zip` at this fixed path so we don't need a PATH
|
||||||
/// search. `-r` recurse, `-q` quiet, `-X` strip extended attrs
|
/// search. `-r` recurse, `-q` quiet, `-X` strip extended attrs
|
||||||
/// for reproducibility.
|
/// 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 {
|
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()
|
let proc = Process()
|
||||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||||
proc.currentDirectoryURL = workDir
|
proc.currentDirectoryURL = workDir
|
||||||
@@ -515,6 +523,7 @@ public final class RemoteBackupService: @unchecked Sendable {
|
|||||||
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
|
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
|
||||||
throw BackupError.zipFailed("zip exited \(proc.terminationStatus): \(tail)")
|
throw BackupError.zipFailed("zip exited \(proc.terminationStatus): \(tail)")
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -436,7 +436,14 @@ public final class RemoteRestoreService: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// 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 {
|
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()
|
let proc = Process()
|
||||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
||||||
proc.arguments = ["-q", archive.path, "-d", dest.path]
|
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) } } ?? ""
|
.flatMap { $0.flatMap { String(data: $0, encoding: .utf8) } } ?? ""
|
||||||
throw RestoreError.archiveUnreadable("unzip exited \(proc.terminationStatus): \(tail)")
|
throw RestoreError.archiveUnreadable("unzip exited \(proc.terminationStatus): \(tail)")
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hash a local file in 1 MB chunks. We avoid loading the whole
|
/// Hash a local file in 1 MB chunks. We avoid loading the whole
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import os
|
|||||||
public enum SkillsScanner: Sendable {
|
public enum SkillsScanner: Sendable {
|
||||||
private static let logger = Logger(subsystem: "com.scarf", category: "SkillsScanner")
|
private static let logger = Logger(subsystem: "com.scarf", category: "SkillsScanner")
|
||||||
|
|
||||||
public static func scan(context: ServerContext, transport: any ServerTransport) -> [HermesSkillCategory] {
|
public static func scan(
|
||||||
|
context: ServerContext,
|
||||||
|
transport: any ServerTransport,
|
||||||
|
disabledNames: Set<String> = [],
|
||||||
|
pinnedNames: Set<String> = []
|
||||||
|
) -> [HermesSkillCategory] {
|
||||||
let dir = context.paths.skillsDir
|
let dir = context.paths.skillsDir
|
||||||
// Fresh install: skills/ may not exist yet — return [] without
|
// Fresh install: skills/ may not exist yet — return [] without
|
||||||
// logging an error.
|
// logging an error.
|
||||||
@@ -59,7 +64,9 @@ public enum SkillsScanner: Sendable {
|
|||||||
requiredConfig: requiredConfig,
|
requiredConfig: requiredConfig,
|
||||||
allowedTools: v011.allowedTools,
|
allowedTools: v011.allowedTools,
|
||||||
relatedSkills: v011.relatedSkills,
|
relatedSkills: v011.relatedSkills,
|
||||||
dependencies: v011.dependencies
|
dependencies: v011.dependencies,
|
||||||
|
enabled: !disabledNames.contains(skillName),
|
||||||
|
pinned: pinnedNames.contains(skillName)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
#if canImport(os)
|
||||||
|
import os
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Mac + iOS view model for the v0.12 Curator surface.
|
||||||
|
///
|
||||||
|
/// Drives `hermes curator status / run / pause / resume / pin / unpin /
|
||||||
|
/// restore` plus a parsed view of `~/.hermes/skills/.curator_state`
|
||||||
|
/// JSON. The CLI doesn't ship a `--json` flag for `status`, so we
|
||||||
|
/// text-parse stdout (HermesCuratorStatusParser) and use the state
|
||||||
|
/// file for richer last-run metadata.
|
||||||
|
///
|
||||||
|
/// Capability-gated: callers should construct this only when
|
||||||
|
/// `HermesCapabilities.hasCurator` is true. The view model does not
|
||||||
|
/// gate itself — the gate happens at sidebar/tab routing time.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
public final class CuratorViewModel {
|
||||||
|
#if canImport(os)
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "CuratorViewModel")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public let context: ServerContext
|
||||||
|
|
||||||
|
public private(set) var status: HermesCuratorStatus = .empty
|
||||||
|
public private(set) var isLoading = false
|
||||||
|
public private(set) var lastReportMarkdown: String?
|
||||||
|
public var transientMessage: String?
|
||||||
|
|
||||||
|
public init(context: ServerContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let context = self.context
|
||||||
|
let parsed = await Task.detached(priority: .userInitiated) { () -> (HermesCuratorStatus, String?) in
|
||||||
|
let textResult = Self.runCuratorStatus(context: context)
|
||||||
|
let stateData = context.readData(context.paths.curatorStateFile)
|
||||||
|
let parsed = HermesCuratorStatusParser.parse(text: textResult, stateFileJSON: stateData)
|
||||||
|
// Best-effort markdown report: the state file points at the
|
||||||
|
// most recent <YYYYMMDD-HHMMSS>/ dir; load REPORT.md from
|
||||||
|
// there. Missing on first run, which is fine.
|
||||||
|
var report: String?
|
||||||
|
if let reportDir = parsed.lastReportPath {
|
||||||
|
let reportPath = reportDir.hasSuffix("/")
|
||||||
|
? "\(reportDir)REPORT.md"
|
||||||
|
: "\(reportDir)/REPORT.md"
|
||||||
|
report = context.readText(reportPath)
|
||||||
|
}
|
||||||
|
return (parsed, report)
|
||||||
|
}.value
|
||||||
|
self.status = parsed.0
|
||||||
|
self.lastReportMarkdown = parsed.1
|
||||||
|
}
|
||||||
|
|
||||||
|
public func runNow() async {
|
||||||
|
await runAndReload(args: ["curator", "run"], successMessage: "Curator run started")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pause() async {
|
||||||
|
await runAndReload(args: ["curator", "pause"], successMessage: "Curator paused")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resume() async {
|
||||||
|
await runAndReload(args: ["curator", "resume"], successMessage: "Curator resumed")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pin(_ skill: String) async {
|
||||||
|
await runAndReload(args: ["curator", "pin", skill], successMessage: "Pinned \(skill)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unpin(_ skill: String) async {
|
||||||
|
await runAndReload(args: ["curator", "unpin", skill], successMessage: "Unpinned \(skill)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func restore(_ skill: String) async {
|
||||||
|
await runAndReload(args: ["curator", "restore", skill], successMessage: "Restored \(skill)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAndReload(args: [String], successMessage: String) async {
|
||||||
|
let context = self.context
|
||||||
|
let exitCode = await Task.detached(priority: .userInitiated) {
|
||||||
|
Self.runHermes(context: context, args: args).exitCode
|
||||||
|
}.value
|
||||||
|
transientMessage = exitCode == 0 ? successMessage : "Command failed"
|
||||||
|
await load()
|
||||||
|
// Auto-clear toast after 3s.
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
self?.transientMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap the transport-level `runProcess` so the call sites don't
|
||||||
|
/// have to reach for it directly. Combined stdout+stderr.
|
||||||
|
nonisolated private static func runHermes(
|
||||||
|
context: ServerContext,
|
||||||
|
args: [String]
|
||||||
|
) -> (exitCode: Int32, output: String) {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
do {
|
||||||
|
let result = try transport.runProcess(
|
||||||
|
executable: context.paths.hermesBinary,
|
||||||
|
args: args,
|
||||||
|
stdin: nil,
|
||||||
|
timeout: 30
|
||||||
|
)
|
||||||
|
return (result.exitCode, result.stdoutString + result.stderrString)
|
||||||
|
} catch let error as TransportError {
|
||||||
|
return (-1, error.diagnosticStderr.isEmpty
|
||||||
|
? (error.errorDescription ?? "transport error")
|
||||||
|
: error.diagnosticStderr)
|
||||||
|
} catch {
|
||||||
|
return (-1, error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func runCuratorStatus(context: ServerContext) -> String {
|
||||||
|
runHermes(context: context, args: ["curator", "status"]).output
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,19 +70,109 @@ public final class SkillsViewModel {
|
|||||||
/// Awaitable scan. iOS's `.task { await vm.load() }` and the
|
/// Awaitable scan. iOS's `.task { await vm.load() }` and the
|
||||||
/// ScarfCore unit tests use this directly; Mac call sites wrap in
|
/// ScarfCore unit tests use this directly; Mac call sites wrap in
|
||||||
/// `Task { await ... }` from `onAppear`.
|
/// `Task { await ... }` from `onAppear`.
|
||||||
|
///
|
||||||
|
/// Pinned-name set is auto-fetched from the curator state file on
|
||||||
|
/// v0.12+ hosts; callers can override by passing an explicit set
|
||||||
|
/// (the Curator screen does this when it has a fresher snapshot in
|
||||||
|
/// hand).
|
||||||
@MainActor
|
@MainActor
|
||||||
public func load() async {
|
public func load(pinnedNames: Set<String>? = nil) async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
lastError = nil
|
lastError = nil
|
||||||
let ctx = context
|
let ctx = context
|
||||||
let xport = transport
|
let xport = transport
|
||||||
|
let pins = pinnedNames
|
||||||
let cats: [HermesSkillCategory] = await Task.detached {
|
let cats: [HermesSkillCategory] = await Task.detached {
|
||||||
SkillsScanner.scan(context: ctx, transport: xport)
|
let disabled = Self.readDisabledSkillNames(context: ctx)
|
||||||
|
let pinned = pins ?? Self.readPinnedSkillNames(context: ctx)
|
||||||
|
return SkillsScanner.scan(
|
||||||
|
context: ctx,
|
||||||
|
transport: xport,
|
||||||
|
disabledNames: disabled,
|
||||||
|
pinnedNames: pinned
|
||||||
|
)
|
||||||
}.value
|
}.value
|
||||||
categories = cats
|
categories = cats
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the curator's pinned-skills list from
|
||||||
|
/// `~/.hermes/skills/.curator_state` (JSON despite the lack of an
|
||||||
|
/// extension). Pre-v0.12 hosts won't have this file yet — return
|
||||||
|
/// an empty set so the pin badge stays hidden.
|
||||||
|
nonisolated static func readPinnedSkillNames(context: ServerContext) -> Set<String> {
|
||||||
|
guard let data = context.readData(context.paths.curatorStateFile),
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
else { return [] }
|
||||||
|
// Curator stores pins in either `pinned: [name, ...]` or
|
||||||
|
// `pinned_skills: [name, ...]` depending on Hermes version —
|
||||||
|
// accept both shapes so we don't break on a future rename.
|
||||||
|
let raw = (obj["pinned"] as? [String]) ?? (obj["pinned_skills"] as? [String]) ?? []
|
||||||
|
return Set(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the `skills.disabled:` array from `~/.hermes/config.yaml`.
|
||||||
|
/// Hermes v0.12 stores skill disable state there (one global list
|
||||||
|
/// + optional `skills.platform_disabled` overrides). Returns the
|
||||||
|
/// global list only — Scarf doesn't surface platform overrides
|
||||||
|
/// today. Empty set on missing file / parse failure.
|
||||||
|
nonisolated static func readDisabledSkillNames(context: ServerContext) -> Set<String> {
|
||||||
|
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
|
||||||
|
// Lightweight match: find `skills:` block, then `disabled:` array
|
||||||
|
// inside it. The full YAML parser is overkill for one nested array.
|
||||||
|
var inSkillsBlock = false
|
||||||
|
var disabledIndent: Int?
|
||||||
|
var collected: [String] = []
|
||||||
|
for raw in yaml.components(separatedBy: "\n") {
|
||||||
|
// Top-level `skills:` declaration.
|
||||||
|
if raw.hasPrefix("skills:") {
|
||||||
|
inSkillsBlock = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inSkillsBlock {
|
||||||
|
// A new top-level block ends the `skills:` scope.
|
||||||
|
if !raw.hasPrefix(" ") && !raw.hasPrefix("\t") && raw.contains(":") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.hasPrefix("disabled:") {
|
||||||
|
// Inline form `disabled: [a, b, c]`
|
||||||
|
let after = trimmed.dropFirst("disabled:".count).trimmingCharacters(in: .whitespaces)
|
||||||
|
if after.hasPrefix("[") && after.hasSuffix("]") {
|
||||||
|
let body = after.dropFirst().dropLast()
|
||||||
|
let parts = body.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
for p in parts where !p.isEmpty {
|
||||||
|
collected.append(p.trimmingCharacters(in: CharacterSet(charactersIn: "\"' ")))
|
||||||
|
}
|
||||||
|
return Set(collected)
|
||||||
|
}
|
||||||
|
// Block form: `disabled:` followed by ` - name`
|
||||||
|
disabledIndent = raw.prefix { $0 == " " || $0 == "\t" }.count
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if let baseIndent = disabledIndent {
|
||||||
|
let leading = raw.prefix { $0 == " " || $0 == "\t" }.count
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
// PyYAML's default `yaml.dump` emits list items at the
|
||||||
|
// same indent as the parent key, so `- foo` lines for
|
||||||
|
// `disabled:` arrive at `leading == baseIndent`. Only
|
||||||
|
// a strictly shallower indent — or a same-indent line
|
||||||
|
// that isn't a list item (sibling key) — ends the block.
|
||||||
|
if leading < baseIndent { break }
|
||||||
|
if leading == baseIndent && !trimmed.hasPrefix("- ") { break }
|
||||||
|
}
|
||||||
|
if trimmed.hasPrefix("- ") {
|
||||||
|
let name = trimmed.dropFirst(2).trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
|
||||||
|
if !name.isEmpty {
|
||||||
|
collected.append(String(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Set(collected)
|
||||||
|
}
|
||||||
|
|
||||||
public func selectSkill(_ skill: HermesSkill) {
|
public func selectSkill(_ skill: HermesSkill) {
|
||||||
selectedSkill = skill
|
selectedSkill = skill
|
||||||
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first
|
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first
|
||||||
@@ -200,6 +290,68 @@ public final class SkillsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.12: install a skill from a direct HTTPS URL pointing at a
|
||||||
|
/// SKILL.md (or a tarball). Hermes pulls + installs without going
|
||||||
|
/// through the registry indirection. The Mac UI gates this on
|
||||||
|
/// `HermesCapabilities.hasSkillURLInstall` so a v0.11 host doesn't
|
||||||
|
/// see a button that errors out with "unrecognized argument".
|
||||||
|
///
|
||||||
|
/// `categoryOverride` and `nameOverride` map to `--category` /
|
||||||
|
/// `--name` flags Hermes ships for direct-URL installs (the URL's
|
||||||
|
/// SKILL.md may not declare those, especially for one-off scripts).
|
||||||
|
public func installFromURL(
|
||||||
|
_ url: String,
|
||||||
|
categoryOverride: String? = nil,
|
||||||
|
nameOverride: String? = nil
|
||||||
|
) {
|
||||||
|
isHubLoading = true
|
||||||
|
hubMessage = "Installing from URL…"
|
||||||
|
let bin = context.paths.hermesBinary
|
||||||
|
let xport = transport
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
var args = ["skills", "install", url, "--yes"]
|
||||||
|
if let category = categoryOverride, !category.isEmpty {
|
||||||
|
args += ["--category", category]
|
||||||
|
}
|
||||||
|
if let name = nameOverride, !name.isEmpty {
|
||||||
|
args += ["--name", name]
|
||||||
|
}
|
||||||
|
let result = Self.runHermes(
|
||||||
|
executable: bin,
|
||||||
|
args: args,
|
||||||
|
transport: xport,
|
||||||
|
timeout: 180
|
||||||
|
)
|
||||||
|
await self?.finishInstall(identifier: url, exitCode: result.exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v0.12: trigger a hot reload of `~/.hermes/skills/` so the agent
|
||||||
|
/// picks up file edits without a session restart. Hermes ships
|
||||||
|
/// `/reload-skills` as a slash command in chat AND `hermes skills
|
||||||
|
/// audit` as a CLI form. We use `audit` here so the reload works
|
||||||
|
/// even when no chat session is active.
|
||||||
|
public func reloadSkills() async {
|
||||||
|
isHubLoading = true
|
||||||
|
let bin = context.paths.hermesBinary
|
||||||
|
let xport = transport
|
||||||
|
let result = await Task.detached {
|
||||||
|
Self.runHermes(
|
||||||
|
executable: bin,
|
||||||
|
args: ["skills", "audit"],
|
||||||
|
transport: xport,
|
||||||
|
timeout: 30
|
||||||
|
)
|
||||||
|
}.value
|
||||||
|
hubMessage = result.exitCode == 0 ? "Skills reloaded" : "Reload failed"
|
||||||
|
isHubLoading = false
|
||||||
|
await load()
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
self?.hubMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func uninstallHubSkill(_ identifier: String) {
|
public func uninstallHubSkill(_ identifier: String) {
|
||||||
let bin = context.paths.hermesBinary
|
let bin = context.paths.hermesBinary
|
||||||
let xport = transport
|
let xport = transport
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ScarfCore
|
||||||
|
|
||||||
|
@Suite struct HermesCuratorParserTests {
|
||||||
|
|
||||||
|
/// Real `hermes curator status` output captured from a v0.12.0
|
||||||
|
/// install with no curator runs yet. Locks in the empty-state
|
||||||
|
/// happy path so a Hermes layout tweak surfaces here before
|
||||||
|
/// CuratorView starts rendering "—" placeholders silently.
|
||||||
|
private static let realFreshOutput = """
|
||||||
|
curator: ENABLED
|
||||||
|
runs: 0
|
||||||
|
last run: never
|
||||||
|
last summary: (none)
|
||||||
|
interval: every 7d
|
||||||
|
stale after: 30d unused
|
||||||
|
archive after: 90d unused
|
||||||
|
|
||||||
|
agent-created skills: 18 total
|
||||||
|
active 18
|
||||||
|
stale 0
|
||||||
|
archived 0
|
||||||
|
|
||||||
|
least recently active (top 5):
|
||||||
|
Scarf Dashboard Chart Widget Parse Error Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||||
|
Scarf Project Registry Format Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||||
|
clip activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||||
|
find-nearby activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||||
|
gguf-quantization activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||||
|
|
||||||
|
least active (top 5):
|
||||||
|
Scarf Dashboard Chart Widget Parse Error Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||||
|
Scarf Project Registry Format Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||||
|
clip activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||||
|
find-nearby activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||||
|
gguf-quantization activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test func parseRealFreshOutput() {
|
||||||
|
let s = HermesCuratorStatusParser.parse(text: Self.realFreshOutput)
|
||||||
|
#expect(s.state == .enabled)
|
||||||
|
#expect(s.runCount == 0)
|
||||||
|
#expect(s.lastRunISO == nil)
|
||||||
|
#expect(s.lastSummary == nil)
|
||||||
|
#expect(s.intervalLabel == "every 7d")
|
||||||
|
#expect(s.staleAfterLabel == "30d unused")
|
||||||
|
#expect(s.archiveAfterLabel == "90d unused")
|
||||||
|
#expect(s.totalSkills == 18)
|
||||||
|
#expect(s.activeSkills == 18)
|
||||||
|
#expect(s.staleSkills == 0)
|
||||||
|
#expect(s.archivedSkills == 0)
|
||||||
|
#expect(s.pinnedNames.isEmpty)
|
||||||
|
#expect(s.leastRecentlyActive.count == 5)
|
||||||
|
#expect(s.leastActive.count == 5)
|
||||||
|
#expect(s.mostActive.isEmpty)
|
||||||
|
let firstRow = s.leastRecentlyActive.first
|
||||||
|
#expect(firstRow?.name == "Scarf Dashboard Chart Widget Parse Error Fix")
|
||||||
|
#expect(firstRow?.activityCount == 0)
|
||||||
|
#expect(firstRow?.lastActivityLabel == "never")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parsedPausedState() {
|
||||||
|
let text = """
|
||||||
|
curator: PAUSED
|
||||||
|
runs: 5
|
||||||
|
last run: 2026-04-29T03:10:00Z
|
||||||
|
last summary: pruned 2 skills, consolidated 1
|
||||||
|
interval: every 7d
|
||||||
|
stale after: 30d unused
|
||||||
|
archive after: 90d unused
|
||||||
|
|
||||||
|
agent-created skills: 12 total
|
||||||
|
active 8
|
||||||
|
stale 3
|
||||||
|
archived 1
|
||||||
|
|
||||||
|
pinned (2): kanban-orchestrator, scarf-template-author
|
||||||
|
"""
|
||||||
|
let s = HermesCuratorStatusParser.parse(text: text)
|
||||||
|
#expect(s.state == .paused)
|
||||||
|
#expect(s.runCount == 5)
|
||||||
|
#expect(s.lastRunISO == "2026-04-29T03:10:00Z")
|
||||||
|
#expect(s.lastSummary == "pruned 2 skills, consolidated 1")
|
||||||
|
#expect(s.totalSkills == 12)
|
||||||
|
#expect(s.activeSkills == 8)
|
||||||
|
#expect(s.staleSkills == 3)
|
||||||
|
#expect(s.archivedSkills == 1)
|
||||||
|
#expect(s.pinnedNames == ["kanban-orchestrator", "scarf-template-author"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stateFileOverridesTextSummary() {
|
||||||
|
// The state file is authoritative for last_run_at /
|
||||||
|
// last_run_summary / last_report_path because it carries full
|
||||||
|
// ISO timestamps the text output may have rounded. Verify that
|
||||||
|
// a state file with richer values overrides parsed text.
|
||||||
|
let text = """
|
||||||
|
curator: ENABLED
|
||||||
|
runs: 1
|
||||||
|
last run: 2026-04-30T11:00:00Z
|
||||||
|
last summary: short
|
||||||
|
interval: every 7d
|
||||||
|
stale after: 30d unused
|
||||||
|
archive after: 90d unused
|
||||||
|
|
||||||
|
agent-created skills: 3 total
|
||||||
|
active 3
|
||||||
|
stale 0
|
||||||
|
archived 0
|
||||||
|
"""
|
||||||
|
let stateJSON: [String: Any] = [
|
||||||
|
"run_count": 4,
|
||||||
|
"last_run_at": "2026-04-30T18:42:13.001Z",
|
||||||
|
"last_run_summary": "richer summary from state file",
|
||||||
|
"last_report_path": "/Users/u/.hermes/logs/curator/20260430-184213"
|
||||||
|
]
|
||||||
|
let data = try! JSONSerialization.data(withJSONObject: stateJSON)
|
||||||
|
let s = HermesCuratorStatusParser.parse(text: text, stateFileJSON: data)
|
||||||
|
#expect(s.runCount == 4)
|
||||||
|
#expect(s.lastRunISO == "2026-04-30T18:42:13.001Z")
|
||||||
|
#expect(s.lastSummary == "richer summary from state file")
|
||||||
|
#expect(s.lastReportPath == "/Users/u/.hermes/logs/curator/20260430-184213")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parsedDisabledStatus() {
|
||||||
|
let s = HermesCuratorStatusParser.parse(text: "curator: DISABLED\n runs: 0\n")
|
||||||
|
#expect(s.state == .disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parsedEmptyOutputStaysSafe() {
|
||||||
|
let s = HermesCuratorStatusParser.parse(text: "")
|
||||||
|
#expect(s.state == .unknown)
|
||||||
|
#expect(s.totalSkills == 0)
|
||||||
|
#expect(s.leastRecentlyActive.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func skillRowParserHandlesMultiWordNames() {
|
||||||
|
// Names with spaces are common (Scarf Dashboard Chart Widget…)
|
||||||
|
// The parser slices at the first `activity=` so names can be
|
||||||
|
// arbitrary length without breaking the counter columns.
|
||||||
|
let row = " Some Long Skill Name v2 activity= 12 use= 4 view= 6 patches= 2 last_activity=2026-04-25"
|
||||||
|
let s = HermesCuratorStatusParser.parse(text: """
|
||||||
|
least recently active (top 5):
|
||||||
|
\(row)
|
||||||
|
""")
|
||||||
|
let parsed = s.leastRecentlyActive.first
|
||||||
|
#expect(parsed?.name == "Some Long Skill Name v2")
|
||||||
|
#expect(parsed?.activityCount == 12)
|
||||||
|
#expect(parsed?.useCount == 4)
|
||||||
|
#expect(parsed?.viewCount == 6)
|
||||||
|
#expect(parsed?.patchCount == 2)
|
||||||
|
#expect(parsed?.lastActivityLabel == "2026-04-25")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,8 +37,8 @@ import Foundation
|
|||||||
let b: ConnectionStatusViewModel.Status = .connected
|
let b: ConnectionStatusViewModel.Status = .connected
|
||||||
#expect(a == b)
|
#expect(a == b)
|
||||||
|
|
||||||
let c: ConnectionStatusViewModel.Status = .degraded(reason: "x")
|
let c: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown)
|
||||||
let d: ConnectionStatusViewModel.Status = .degraded(reason: "x")
|
let d: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown)
|
||||||
#expect(c == d)
|
#expect(c == d)
|
||||||
|
|
||||||
let e: ConnectionStatusViewModel.Status = .idle
|
let e: ConnectionStatusViewModel.Status = .idle
|
||||||
|
|||||||
@@ -456,6 +456,7 @@ import Foundation
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) }
|
func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) }
|
||||||
|
var cachedSnapshotPath: URL? { nil }
|
||||||
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||||
AsyncStream { $0.finish() }
|
AsyncStream { $0.finish() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,43 @@ struct ScarfGoTabRoot: View {
|
|||||||
let onSoftDisconnect: @MainActor () async -> Void
|
let onSoftDisconnect: @MainActor () async -> Void
|
||||||
let onForget: @MainActor () async -> Void
|
let onForget: @MainActor () async -> Void
|
||||||
|
|
||||||
|
/// Stable per-tab context UUID — used for the System tab's Curator
|
||||||
|
/// row so its CuratorViewModel reuses the cached SSH connection
|
||||||
|
/// keyed by this id rather than building a fresh one. Same pattern
|
||||||
|
/// as `sharedContextID` on ChatView.
|
||||||
|
static let systemTabContextID: ServerID = ServerID(
|
||||||
|
uuidString: "00000000-0000-0000-0000-0000000000A2"
|
||||||
|
)!
|
||||||
|
|
||||||
/// One coordinator per server-connected session. Cross-tab
|
/// One coordinator per server-connected session. Cross-tab
|
||||||
/// signalling (Dashboard row → Chat tab resume, Project Detail
|
/// signalling (Dashboard row → Chat tab resume, Project Detail
|
||||||
/// → in-project chat handoff, notification deep-link → Chat) flows
|
/// → in-project chat handoff, notification deep-link → Chat) flows
|
||||||
/// through here.
|
/// through here.
|
||||||
@State private var coordinator = ScarfGoCoordinator()
|
@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
|
/// SwiftUI's `.onChange(of: ScenePhase)` modifier on a non-active
|
||||||
/// tab doesn't fire while the tab is unmounted — the coordinator
|
/// tab doesn't fire while the tab is unmounted — the coordinator
|
||||||
/// is the single source of truth for scene-phase transitions
|
/// is the single source of truth for scene-phase transitions
|
||||||
@@ -118,6 +149,8 @@ struct ScarfGoTabRoot: View {
|
|||||||
.tabViewStyle(.sidebarAdaptable)
|
.tabViewStyle(.sidebarAdaptable)
|
||||||
.environment(\.serverContext, ctx)
|
.environment(\.serverContext, ctx)
|
||||||
.environment(\.scarfGoCoordinator, coordinator)
|
.environment(\.scarfGoCoordinator, coordinator)
|
||||||
|
.environment(capabilities)
|
||||||
|
.hermesCapabilities(capabilities)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Give the notification router a handle to this session's
|
// Give the notification router a handle to this session's
|
||||||
// coordinator so notification-taps can route across tabs.
|
// coordinator so notification-taps can route across tabs.
|
||||||
@@ -147,6 +180,8 @@ private struct SystemTab: View {
|
|||||||
let onSoftDisconnect: @MainActor () async -> Void
|
let onSoftDisconnect: @MainActor () async -> Void
|
||||||
let onForget: @MainActor () async -> Void
|
let onForget: @MainActor () async -> Void
|
||||||
|
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
@State private var showForgetConfirmation = false
|
@State private var showForgetConfirmation = false
|
||||||
@State private var isForgetting = false
|
@State private var isForgetting = false
|
||||||
@State private var isDisconnecting = false
|
@State private var isDisconnecting = false
|
||||||
@@ -181,6 +216,15 @@ private struct SystemTab: View {
|
|||||||
}
|
}
|
||||||
.scarfGoCompactListRow()
|
.scarfGoCompactListRow()
|
||||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
|
if capabilitiesStore?.capabilities.hasCurator ?? false {
|
||||||
|
NavigationLink {
|
||||||
|
CuratorView(context: config.toServerContext(id: ScarfGoTabRoot.systemTabContextID))
|
||||||
|
} label: {
|
||||||
|
Label("Curator", systemImage: "sparkles")
|
||||||
|
}
|
||||||
|
.scarfGoCompactListRow()
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
CronListView(config: config)
|
CronListView(config: config)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -197,6 +241,36 @@ private struct SystemTab: View {
|
|||||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.6: read-only mobile views over CLI-driven Hermes
|
||||||
|
// surfaces. Mac owns the create/edit paths; phones get a
|
||||||
|
// monitoring window into what the remote agent is honoring.
|
||||||
|
// None of these are capability-gated — the underlying
|
||||||
|
// `hermes plugins/profile/webhook list` verbs exist on
|
||||||
|
// both v0.11 and v0.12, so the read views work on either.
|
||||||
|
Section("Inspect") {
|
||||||
|
NavigationLink {
|
||||||
|
WebhooksView(config: config)
|
||||||
|
} label: {
|
||||||
|
Label("Webhooks", systemImage: "arrow.up.right.square")
|
||||||
|
}
|
||||||
|
.scarfGoCompactListRow()
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
|
NavigationLink {
|
||||||
|
PluginsView(config: config)
|
||||||
|
} label: {
|
||||||
|
Label("Plugins", systemImage: "app.badge.checkmark")
|
||||||
|
}
|
||||||
|
.scarfGoCompactListRow()
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
|
NavigationLink {
|
||||||
|
ProfilesView(config: config)
|
||||||
|
} label: {
|
||||||
|
Label("Profiles", systemImage: "person.2.crop.square.stack")
|
||||||
|
}
|
||||||
|
.scarfGoCompactListRow()
|
||||||
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: $iCloudSyncEnabled) {
|
Toggle(isOn: $iCloudSyncEnabled) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import ScarfCore
|
|||||||
import ScarfIOS
|
import ScarfIOS
|
||||||
import ScarfDesign
|
import ScarfDesign
|
||||||
import os
|
import os
|
||||||
|
#if canImport(PhotosUI)
|
||||||
|
import PhotosUI
|
||||||
|
#endif
|
||||||
|
|
||||||
// The Chat feature on iOS is gated on `canImport(SQLite3)` because
|
// The Chat feature on iOS is gated on `canImport(SQLite3)` because
|
||||||
// `RichChatViewModel` reads session history from `HermesDataService`
|
// `RichChatViewModel` reads session history from `HermesDataService`
|
||||||
@@ -24,9 +27,23 @@ struct ChatView: View {
|
|||||||
|
|
||||||
@Environment(\.scarfGoCoordinator) private var coordinator
|
@Environment(\.scarfGoCoordinator) private var coordinator
|
||||||
@Environment(\.serverContext) private var envContext
|
@Environment(\.serverContext) private var envContext
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
@State private var controller: ChatController
|
@State private var controller: ChatController
|
||||||
@State private var showProjectPicker = false
|
@State private var showProjectPicker = false
|
||||||
@State private var showSlashCommandsSheet = 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
|
/// Drives the composer's keyboard. Bound to the TextField via
|
||||||
/// `.focused(...)`; cleared by the scroll-to-dismiss gesture on
|
/// `.focused(...)`; cleared by the scroll-to-dismiss gesture on
|
||||||
/// the message list AND by an explicit keyboard-toolbar button.
|
/// the message list AND by an explicit keyboard-toolbar button.
|
||||||
@@ -431,7 +448,108 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var composer: some 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) {
|
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(
|
TextField(
|
||||||
"Message…",
|
"Message…",
|
||||||
text: $controller.draft,
|
text: $controller.draft,
|
||||||
@@ -480,13 +598,89 @@ struct ChatView: View {
|
|||||||
Image(systemName: "arrow.up.circle.fill")
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
.font(.system(size: 28))
|
.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 }
|
||||||
|
// Cap up front and snapshot so the slot calculation is honest under
|
||||||
|
// concurrent ingestion (we'd otherwise have to re-check
|
||||||
|
// controller.attachments.count after every parallel completion).
|
||||||
|
let remainingSlots = Self.maxAttachments - controller.attachments.count
|
||||||
|
let snapshot = Array(items.prefix(max(remainingSlots, 0)))
|
||||||
|
// Clear the binding immediately so a follow-up pick triggers onChange
|
||||||
|
// even when the user re-selects the same image set (PhotosPicker
|
||||||
|
// doesn't re-fire onChange unless the binding flips through nil).
|
||||||
|
pickerSelection = []
|
||||||
|
guard !snapshot.isEmpty else { return }
|
||||||
|
isEncodingAttachment = true
|
||||||
|
Task { @MainActor in
|
||||||
|
// Run loadTransferable + encode for each item in parallel.
|
||||||
|
// iCloud-backed PHAssets are network-bound, so 5 picks finish
|
||||||
|
// closer to 1 round-trip than 5 sequential ones. Errors carry
|
||||||
|
// a Sendable String (not the Error itself) since `any Error`
|
||||||
|
// isn't Sendable under strict concurrency.
|
||||||
|
let outcomes = await withTaskGroup(
|
||||||
|
of: (index: Int, attachment: ChatImageAttachment?, errorMessage: String?).self
|
||||||
|
) { group in
|
||||||
|
for (index, item) in snapshot.enumerated() {
|
||||||
|
group.addTask {
|
||||||
|
do {
|
||||||
|
guard let data = try await item.loadTransferable(type: Data.self) else {
|
||||||
|
return (index, nil, nil)
|
||||||
|
}
|
||||||
|
let attachment = try await Task.detached(priority: .userInitiated) {
|
||||||
|
try ImageEncoder().encode(rawBytes: data, sourceFilename: nil)
|
||||||
|
}.value
|
||||||
|
return (index, attachment, nil)
|
||||||
|
} catch {
|
||||||
|
let message = (error as? LocalizedError)?.errorDescription ?? "Couldn't encode image"
|
||||||
|
return (index, nil, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var rows: [(index: Int, attachment: ChatImageAttachment?, errorMessage: String?)] = []
|
||||||
|
for await row in group { rows.append(row) }
|
||||||
|
return rows.sorted { $0.index < $1.index }
|
||||||
|
}
|
||||||
|
var firstError: String?
|
||||||
|
for outcome in outcomes {
|
||||||
|
if let attachment = outcome.attachment {
|
||||||
|
controller.attachments.append(attachment)
|
||||||
|
} else if firstError == nil, let message = outcome.errorMessage {
|
||||||
|
firstError = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let firstError {
|
||||||
|
attachmentError = firstError
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||||
|
attachmentError = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isEncodingAttachment = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@State private var showErrorDetails: Bool = false
|
@State private var showErrorDetails: Bool = false
|
||||||
|
|
||||||
/// Inline error banner rendered above the message list when the
|
/// Inline error banner rendered above the message list when the
|
||||||
@@ -696,6 +890,12 @@ final class ChatController {
|
|||||||
var vm: RichChatViewModel
|
var vm: RichChatViewModel
|
||||||
var draft: String = ""
|
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
|
/// Set when chat-start is blocked because the active server's
|
||||||
/// `config.yaml` has no `model.default` / `model.provider`. ChatView
|
/// `config.yaml` has no `model.default` / `model.provider`. ChatView
|
||||||
/// observes this to present an inline "pick a model" sheet — the
|
/// observes this to present an inline "pick a model" sheet — the
|
||||||
@@ -1003,12 +1203,22 @@ final class ChatController {
|
|||||||
func send() async {
|
func send() async {
|
||||||
guard state == .ready, let client else { return }
|
guard state == .ready, let client else { return }
|
||||||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
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 ?? ""
|
let sessionId = vm.sessionId ?? ""
|
||||||
guard !sessionId.isEmpty else { return }
|
guard !sessionId.isEmpty else { return }
|
||||||
|
let images = attachments
|
||||||
|
attachments = []
|
||||||
draft = ""
|
draft = ""
|
||||||
clearStoredDraft()
|
clearStoredDraft()
|
||||||
|
if !text.isEmpty {
|
||||||
vm.addUserMessage(text: text)
|
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
|
// /steer is non-interruptive — the agent is still on its
|
||||||
// current turn; the guidance applies after the next tool call.
|
// current turn; the guidance applies after the next tool call.
|
||||||
// Surface a transient toast confirming the guidance was
|
// Surface a transient toast confirming the guidance was
|
||||||
@@ -1029,7 +1239,7 @@ final class ChatController {
|
|||||||
// literally. v2.5.
|
// literally. v2.5.
|
||||||
let wireText = expandIfProjectScoped(text)
|
let wireText = expandIfProjectScoped(text)
|
||||||
do {
|
do {
|
||||||
_ = try await client.sendPrompt(sessionId: sessionId, text: wireText)
|
_ = try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images)
|
||||||
} catch {
|
} catch {
|
||||||
// The event task may already have surfaced a
|
// The event task may already have surfaced a
|
||||||
// .connectionLost; show the send-time error only if the
|
// .connectionLost; show the send-time error only if the
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Yellow banner that nudges users to upgrade Hermes when the remote
|
||||||
|
/// is running pre-v0.12. Shown on the Dashboard tab; auto-dismissed
|
||||||
|
/// for the rest of the session when the user taps the X. Persistent
|
||||||
|
/// re-show on each app open keeps the prompt visible without nagging
|
||||||
|
/// inside a single session.
|
||||||
|
///
|
||||||
|
/// Hidden entirely on v0.12+ (the new features are reachable) and
|
||||||
|
/// while capability detection is still in flight.
|
||||||
|
struct HermesVersionBanner: View {
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
@State private var dismissedThisSession = false
|
||||||
|
|
||||||
|
/// Capability gate — only render when:
|
||||||
|
/// - the store finished its initial detection AND
|
||||||
|
/// - the host returned an actual version string AND
|
||||||
|
/// - that version is below v0.12 AND
|
||||||
|
/// - the user hasn't dismissed this banner during this session.
|
||||||
|
private var shouldShow: Bool {
|
||||||
|
guard let store = capabilitiesStore else { return false }
|
||||||
|
let caps = store.capabilities
|
||||||
|
guard caps.detected else { return false } // skip while loading / on detection failure
|
||||||
|
guard !caps.hasCurator else { return false } // already on v0.12+
|
||||||
|
return !dismissedThisSession
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if shouldShow {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Hermes update available")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
Text("This server runs \(versionLabel). Update to v0.12 to unlock the autonomous curator, multimodal image input, GMI Cloud / Azure / LM Studio / MiniMax / Tencent providers, and more.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Button {
|
||||||
|
dismissedThisSession = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Dismiss this version notice for the rest of the session")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(ScarfColor.warning.opacity(0.12))
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.fill(ScarfColor.warning.opacity(0.4))
|
||||||
|
.frame(height: 1),
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pretty-print the detected version. Falls back to the raw line
|
||||||
|
/// if parsing didn't extract semver — keeps the banner honest
|
||||||
|
/// when Hermes ships an unexpected version string.
|
||||||
|
private var versionLabel: String {
|
||||||
|
let caps = capabilitiesStore?.capabilities
|
||||||
|
if let semver = caps?.semver {
|
||||||
|
return "Hermes v\(semver.description)"
|
||||||
|
}
|
||||||
|
return caps?.versionLine ?? "an older Hermes"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
#if canImport(SQLite3)
|
||||||
|
|
||||||
|
/// iOS Curator surface — read-mostly view of `hermes curator status`
|
||||||
|
/// with Run Now / Pause / Resume actions and inline pin toggles on
|
||||||
|
/// the leaderboard rows. Mirrors the Mac surface visually but folds
|
||||||
|
/// into a single SwiftUI List for thumb-friendly scrolling.
|
||||||
|
///
|
||||||
|
/// Capability-gated upstream: only routed when
|
||||||
|
/// `HermesCapabilities.hasCurator` is true.
|
||||||
|
struct CuratorView: View {
|
||||||
|
@State private var viewModel: CuratorViewModel
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: CuratorViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
statusRow
|
||||||
|
LabeledContent("Last run", value: viewModel.status.lastRunISO ?? "Never")
|
||||||
|
if let summary = viewModel.status.lastSummary {
|
||||||
|
LabeledContent("Summary", value: summary)
|
||||||
|
}
|
||||||
|
LabeledContent("Interval", value: viewModel.status.intervalLabel)
|
||||||
|
LabeledContent("Stale after", value: viewModel.status.staleAfterLabel)
|
||||||
|
LabeledContent("Archive after", value: viewModel.status.archiveAfterLabel)
|
||||||
|
LabeledContent("Runs", value: "\(viewModel.status.runCount)")
|
||||||
|
} header: {
|
||||||
|
Text("Status")
|
||||||
|
} footer: {
|
||||||
|
actionFooter
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Skills") {
|
||||||
|
LabeledContent("Total", value: "\(viewModel.status.totalSkills)")
|
||||||
|
LabeledContent("Active", value: "\(viewModel.status.activeSkills)")
|
||||||
|
LabeledContent("Stale", value: "\(viewModel.status.staleSkills)")
|
||||||
|
LabeledContent("Archived", value: "\(viewModel.status.archivedSkills)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.status.pinnedNames.isEmpty {
|
||||||
|
Section("Pinned") {
|
||||||
|
ForEach(viewModel.status.pinnedNames, id: \.self) { name in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.foregroundStyle(ScarfColor.accent)
|
||||||
|
Text(name)
|
||||||
|
Spacer()
|
||||||
|
Button("Unpin") {
|
||||||
|
Task { await viewModel.unpin(name) }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.status.leastRecentlyActive.isEmpty {
|
||||||
|
rowsSection(title: "Least recently active", rows: viewModel.status.leastRecentlyActive)
|
||||||
|
}
|
||||||
|
if !viewModel.status.mostActive.isEmpty {
|
||||||
|
rowsSection(title: "Most active", rows: viewModel.status.mostActive)
|
||||||
|
}
|
||||||
|
if !viewModel.status.leastActive.isEmpty {
|
||||||
|
rowsSection(title: "Least active", rows: viewModel.status.leastActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let report = viewModel.lastReportMarkdown {
|
||||||
|
Section("Last report") {
|
||||||
|
Text(report)
|
||||||
|
.font(ScarfFont.monoSmall)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Curator")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if let toast = viewModel.transientMessage {
|
||||||
|
toastView(toast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusRow: some View {
|
||||||
|
HStack {
|
||||||
|
Text("Curator")
|
||||||
|
Spacer()
|
||||||
|
statusBadge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusBadge: some View {
|
||||||
|
let kind: ScarfBadgeKind
|
||||||
|
let label: String
|
||||||
|
switch viewModel.status.state {
|
||||||
|
case .enabled: kind = .success; label = "Enabled"
|
||||||
|
case .paused: kind = .warning; label = "Paused"
|
||||||
|
case .disabled: kind = .neutral; label = "Disabled"
|
||||||
|
case .unknown: kind = .neutral; label = "Unknown"
|
||||||
|
}
|
||||||
|
return ScarfBadge(label, kind: kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionFooter: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.runNow() }
|
||||||
|
} label: {
|
||||||
|
Label("Run now", systemImage: "play.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(viewModel.isLoading)
|
||||||
|
|
||||||
|
if viewModel.status.state == .enabled {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.pause() }
|
||||||
|
} label: {
|
||||||
|
Label("Pause", systemImage: "pause.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
} else if viewModel.status.state == .paused {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.resume() }
|
||||||
|
} label: {
|
||||||
|
Label("Resume", systemImage: "play.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rowsSection(title: String, rows: [HermesCuratorSkillRow]) -> some View {
|
||||||
|
Section(title) {
|
||||||
|
ForEach(rows) { row in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(row.name)
|
||||||
|
.font(.body)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.pin(row.name) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.status.pinnedNames.contains(row.name) ? "pin.fill" : "pin")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("use \(row.useCount) · view \(row.viewCount) · patch \(row.patchCount)")
|
||||||
|
.font(ScarfFont.monoSmall)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(row.lastActivityLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toastView(_ text: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.success)
|
||||||
|
Text(text).font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -42,6 +42,13 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
// v2.6 Hermes-version banner. Renders only when the remote
|
||||||
|
// is pre-v0.12 and the user hasn't dismissed for this
|
||||||
|
// session. v0.12+ hosts get a tab with no banner above
|
||||||
|
// the picker; older hosts see the upgrade nudge inline so
|
||||||
|
// it's visible without burying it inside Settings.
|
||||||
|
HermesVersionBanner()
|
||||||
|
|
||||||
Picker("View", selection: $selectedSection) {
|
Picker("View", selection: $selectedSection) {
|
||||||
Text("Overview").tag(Section.overview)
|
Text("Overview").tag(Section.overview)
|
||||||
Text("Sessions").tag(Section.sessions)
|
Text("Sessions").tag(Section.sessions)
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// iOS read-only Plugins view (v2.6).
|
||||||
|
///
|
||||||
|
/// Walks `~/.hermes/plugins/` (each subdirectory is one plugin) and
|
||||||
|
/// reads the optional `plugin.json` / `plugin.yaml` manifest for each.
|
||||||
|
/// Mirrors the Mac PluginsViewModel's filesystem-first source-of-truth
|
||||||
|
/// approach — `hermes plugins list`'s box-drawn output is fragile to
|
||||||
|
/// parse from a phone form-factor.
|
||||||
|
///
|
||||||
|
/// Install / update / remove / enable / disable verbs stay on Mac for
|
||||||
|
/// v2.6 — installing a plugin from a phone is an unusual flow.
|
||||||
|
struct PluginsView: View {
|
||||||
|
let config: IOSServerConfig
|
||||||
|
|
||||||
|
@State private var plugins: [PluginRow] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var lastError: String?
|
||||||
|
@Environment(\.serverContext) private var contextFromEnv
|
||||||
|
|
||||||
|
private var context: ServerContext {
|
||||||
|
config.toServerContext(id: contextFromEnv.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let err = lastError {
|
||||||
|
Section {
|
||||||
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if plugins.isEmpty && !isLoading {
|
||||||
|
Section {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No plugins installed",
|
||||||
|
systemImage: "app.badge.checkmark",
|
||||||
|
description: Text("Hermes plugins live under `~/.hermes/plugins/<name>/`. Install one with `hermes plugins install <repo>` from the Mac app.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(plugins) { plugin in
|
||||||
|
Section(plugin.name) {
|
||||||
|
HStack {
|
||||||
|
statusBadge(plugin.enabled)
|
||||||
|
if !plugin.version.isEmpty {
|
||||||
|
Text("v\(plugin.version)")
|
||||||
|
.font(ScarfFont.monoSmall)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
if !plugin.source.isEmpty {
|
||||||
|
LabeledContent("Source", value: plugin.source)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
}
|
||||||
|
Text(plugin.path)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Plugins")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBadge(_ enabled: Bool) -> some View {
|
||||||
|
ScarfBadge(enabled ? "Enabled" : "Disabled", kind: enabled ? .success : .neutral)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let ctx = context
|
||||||
|
let entries = await Task.detached {
|
||||||
|
Self.scan(context: ctx)
|
||||||
|
}.value
|
||||||
|
self.plugins = entries
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func scan(context: ServerContext) -> [PluginRow] {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let dir = context.paths.pluginsDir
|
||||||
|
guard let entries = try? transport.listDirectory(dir) else { return [] }
|
||||||
|
var results: [PluginRow] = []
|
||||||
|
for entry in entries.sorted() where !entry.hasPrefix(".") {
|
||||||
|
let path = dir + "/" + entry
|
||||||
|
guard transport.stat(path)?.isDirectory == true else { continue }
|
||||||
|
let manifest = readManifest(path: path, context: context)
|
||||||
|
let disabled = transport.fileExists(path + "/.disabled")
|
||||||
|
results.append(PluginRow(
|
||||||
|
name: entry,
|
||||||
|
version: manifest.version,
|
||||||
|
source: manifest.source,
|
||||||
|
path: path,
|
||||||
|
enabled: !disabled
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `plugin.json` first; fall back to `plugin.yaml` for plugins
|
||||||
|
/// that author manifest in YAML. Same shape as the Mac VM so
|
||||||
|
/// parsing stays consistent across targets.
|
||||||
|
nonisolated private static func readManifest(path: String, context: ServerContext) -> (source: String, version: String) {
|
||||||
|
if let data = context.readData(path + "/plugin.json"),
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
let source = (obj["source"] as? String) ?? (obj["repository"] as? String) ?? (obj["url"] as? String) ?? ""
|
||||||
|
let version = (obj["version"] as? String) ?? ""
|
||||||
|
return (source, version)
|
||||||
|
}
|
||||||
|
if let yaml = context.readText(path + "/plugin.yaml") {
|
||||||
|
let parsed = HermesYAML.parseNestedYAML(yaml)
|
||||||
|
let source = HermesYAML.stripYAMLQuotes(parsed.values["source"] ?? parsed.values["repository"] ?? parsed.values["url"] ?? "")
|
||||||
|
let version = HermesYAML.stripYAMLQuotes(parsed.values["version"] ?? "")
|
||||||
|
return (source, version)
|
||||||
|
}
|
||||||
|
return ("", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PluginRow: Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let version: String
|
||||||
|
let source: String
|
||||||
|
let path: String
|
||||||
|
let enabled: Bool
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// iOS read-only Profiles view (v2.6).
|
||||||
|
///
|
||||||
|
/// Lists `hermes profile list` output and highlights the active profile.
|
||||||
|
/// Profile switching, creation, deletion, and import/export remain on
|
||||||
|
/// the Mac app — those involve writing data we don't want to risk
|
||||||
|
/// fat-fingering on a phone (e.g., wiping the active profile by accident).
|
||||||
|
struct ProfilesView: View {
|
||||||
|
let config: IOSServerConfig
|
||||||
|
|
||||||
|
@State private var profiles: [ProfileRow] = []
|
||||||
|
@State private var activeProfile: String?
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var lastError: String?
|
||||||
|
@Environment(\.serverContext) private var contextFromEnv
|
||||||
|
|
||||||
|
private var context: ServerContext {
|
||||||
|
config.toServerContext(id: contextFromEnv.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let err = lastError {
|
||||||
|
Section {
|
||||||
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if profiles.isEmpty && !isLoading {
|
||||||
|
Section {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No profiles",
|
||||||
|
systemImage: "person.2.crop.square.stack",
|
||||||
|
description: Text("Hermes profiles let you keep multiple HERMES_HOME directories side-by-side. Create one with `hermes profile create <name>` from the Mac app.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
ForEach(profiles) { p in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(p.name)
|
||||||
|
.font(.body)
|
||||||
|
if let aliases = p.aliasesLabel {
|
||||||
|
Text(aliases)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if p.name == activeProfile {
|
||||||
|
ScarfBadge("Active", kind: .success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
if let active = activeProfile {
|
||||||
|
Text("Active profile: \(active)")
|
||||||
|
} else {
|
||||||
|
Text("All profiles")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Switching profiles, creating new ones, and import/export live in the Mac app — they touch enough state that we keep them off the phone.")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Profiles")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let ctx = context
|
||||||
|
let result = await Task.detached { () -> (output: String, active: String?) in
|
||||||
|
let listOut = Self.runHermes(context: ctx, args: ["profile", "list"])
|
||||||
|
// Active profile lives at ~/.hermes/active_profile (text file
|
||||||
|
// with one line). Reading directly is faster than another
|
||||||
|
// CLI round-trip.
|
||||||
|
let activeRaw = ctx.readText(ctx.paths.home + "/active_profile")
|
||||||
|
let active = activeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return (listOut, active)
|
||||||
|
}.value
|
||||||
|
self.profiles = Self.parse(result.output)
|
||||||
|
self.activeProfile = result.active.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func runHermes(context: ServerContext, args: [String]) -> String {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
do {
|
||||||
|
let r = try transport.runProcess(
|
||||||
|
executable: context.paths.hermesBinary,
|
||||||
|
args: args,
|
||||||
|
stdin: nil,
|
||||||
|
timeout: 30
|
||||||
|
)
|
||||||
|
return r.stdoutString + r.stderrString
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tolerant parser for `hermes profile list`. The CLI prints a
|
||||||
|
/// table-like format with the profile name on the leading column
|
||||||
|
/// and optional alias / path columns afterwards. We surface the
|
||||||
|
/// name (always present); aliases collapse into a comma-separated
|
||||||
|
/// label in the row when present.
|
||||||
|
nonisolated private static func parse(_ output: String) -> [ProfileRow] {
|
||||||
|
var results: [ProfileRow] = []
|
||||||
|
for raw in output.components(separatedBy: "\n") {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
// Skip table-rule and header lines.
|
||||||
|
if trimmed.hasPrefix("┃") || trimmed.hasPrefix("┏") || trimmed.hasPrefix("┡")
|
||||||
|
|| trimmed.hasPrefix("┗") || trimmed.hasPrefix("━") || trimmed.hasPrefix("│") {
|
||||||
|
// Strip box-drawing chars and try to extract the leading column.
|
||||||
|
let body = trimmed
|
||||||
|
.replacingOccurrences(of: "│", with: "|")
|
||||||
|
.replacingOccurrences(of: "┃", with: "|")
|
||||||
|
if !body.contains("|") { continue }
|
||||||
|
let cols = body.split(separator: "|", omittingEmptySubsequences: true)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard let name = cols.first, !name.isEmpty,
|
||||||
|
name.range(of: "^[A-Za-z0-9_.-]+$", options: .regularExpression) != nil
|
||||||
|
else { continue }
|
||||||
|
let aliases = cols.dropFirst().filter { !$0.isEmpty }.joined(separator: ", ")
|
||||||
|
results.append(ProfileRow(name: name, aliasesLabel: aliases.isEmpty ? nil : aliases))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Plain-text fallback: first whitespace-delimited token is the name.
|
||||||
|
if let name = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).first,
|
||||||
|
name.range(of: "^[A-Za-z0-9_.-]+$", options: .regularExpression) != nil {
|
||||||
|
results.append(ProfileRow(name: String(name), aliasesLabel: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dedupe (the table-row + plain-text passes can overlap).
|
||||||
|
var seen = Set<String>()
|
||||||
|
return results.filter { seen.insert($0.name).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ProfileRow: Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let aliasesLabel: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,13 +36,34 @@ struct InstalledSkillsListView: View {
|
|||||||
NavigationLink {
|
NavigationLink {
|
||||||
SkillDetailView(skill: skill, vm: vm)
|
SkillDetailView(skill: skill, vm: vm)
|
||||||
} label: {
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(skill.name)
|
Text(skill.name)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
|
.foregroundStyle(skill.enabled ? .primary : .secondary)
|
||||||
|
.strikethrough(!skill.enabled, color: .secondary)
|
||||||
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
|
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if skill.pinned {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(ScarfColor.accent)
|
||||||
|
.accessibilityLabel("Pinned by curator")
|
||||||
|
}
|
||||||
|
if !skill.enabled {
|
||||||
|
Text("OFF")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(ScarfColor.backgroundTertiary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
.accessibilityLabel("Disabled — Hermes won't load this skill")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.scarfGoCompactListRow()
|
.scarfGoCompactListRow()
|
||||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
|
|||||||
@@ -31,6 +31,34 @@ struct SkillDetailView: View {
|
|||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
if !skill.enabled {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Disabled").font(.callout.weight(.medium))
|
||||||
|
Text("This skill is in `skills.disabled` in `~/.hermes/config.yaml`. Hermes won't load it. Re-enable from the Mac app's Skills config UI or with `hermes skills config`.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "circle.slash")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skill.pinned {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Pinned by curator").font(.callout.weight(.medium))
|
||||||
|
Text("The autonomous curator won't auto-archive or rewrite this skill. Unpin from the Curator screen.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.foregroundStyle(ScarfColor.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// iOS read-only Webhooks view (v2.6).
|
||||||
|
///
|
||||||
|
/// Lists `hermes webhook list` output so mobile users can see what
|
||||||
|
/// dynamic webhook subscriptions the remote agent is honoring. Create /
|
||||||
|
/// remove / test actions stay on Mac for v2.6 — most webhook setup
|
||||||
|
/// involves pasting URLs / secrets that are inconvenient on a phone.
|
||||||
|
///
|
||||||
|
/// Reuses the same tolerant text parser the Mac WebhooksViewModel uses.
|
||||||
|
struct WebhooksView: View {
|
||||||
|
let config: IOSServerConfig
|
||||||
|
|
||||||
|
@State private var webhooks: [WebhookRow] = []
|
||||||
|
@State private var notEnabled = false
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var lastError: String?
|
||||||
|
@Environment(\.serverContext) private var contextFromEnv
|
||||||
|
|
||||||
|
private var context: ServerContext {
|
||||||
|
// The view receives `IOSServerConfig` directly (matches the
|
||||||
|
// sibling Skills/Settings tabs); use that to construct a
|
||||||
|
// context bound to the active server. Falls back to env when
|
||||||
|
// the navigation host hasn't injected a config-derived ctx.
|
||||||
|
config.toServerContext(id: contextFromEnv.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let err = lastError {
|
||||||
|
Section {
|
||||||
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if notEnabled {
|
||||||
|
Section("Setup required") {
|
||||||
|
Text("The webhook gateway platform isn't enabled on this server. Run `hermes setup` from the Mac app or a shell to enable it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if webhooks.isEmpty && !isLoading {
|
||||||
|
Section {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No webhooks subscribed",
|
||||||
|
systemImage: "arrow.up.right.square",
|
||||||
|
description: Text("Run `hermes webhook subscribe …` from the Mac app to register one.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(webhooks) { hook in
|
||||||
|
Section(hook.name) {
|
||||||
|
if !hook.description.isEmpty {
|
||||||
|
LabeledContent("Description", value: hook.description)
|
||||||
|
}
|
||||||
|
if !hook.deliver.isEmpty {
|
||||||
|
LabeledContent("Deliver", value: hook.deliver)
|
||||||
|
}
|
||||||
|
if !hook.events.isEmpty {
|
||||||
|
LabeledContent("Events", value: hook.events.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
LabeledContent("Route", value: hook.routeSuffix)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Webhooks")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let ctx = context
|
||||||
|
let result = await Task.detached {
|
||||||
|
return Self.runHermesList(context: ctx)
|
||||||
|
}.value
|
||||||
|
if Self.detectNotEnabled(result) {
|
||||||
|
self.notEnabled = true
|
||||||
|
self.webhooks = []
|
||||||
|
self.lastError = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.notEnabled = false
|
||||||
|
let parsed = Self.parse(result)
|
||||||
|
self.webhooks = parsed
|
||||||
|
// When the CLI returned text but the parser produced nothing, the
|
||||||
|
// user otherwise sees a silent empty list. Surface a parse-failure
|
||||||
|
// message so they know to dig deeper.
|
||||||
|
self.lastError = (parsed.isEmpty && !result.isEmpty)
|
||||||
|
? "Couldn't parse webhook list output"
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func runHermesList(context: ServerContext) -> String {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
do {
|
||||||
|
let r = try transport.runProcess(
|
||||||
|
executable: context.paths.hermesBinary,
|
||||||
|
args: ["webhook", "list"],
|
||||||
|
stdin: nil,
|
||||||
|
timeout: 30
|
||||||
|
)
|
||||||
|
return r.stdoutString + r.stderrString
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func detectNotEnabled(_ output: String) -> Bool {
|
||||||
|
let lower = output.lowercased()
|
||||||
|
return lower.contains("webhook platform is not enabled")
|
||||||
|
|| lower.contains("run the gateway setup wizard")
|
||||||
|
|| lower.contains("webhook_enabled=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tolerant block-parser. Each subscription begins on a non-indented
|
||||||
|
/// line; description / deliver / events / url details follow as
|
||||||
|
/// indented `key: value` lines. Mirrors the Mac parser shape so
|
||||||
|
/// future drift only has to be fixed in one canonical place if/when
|
||||||
|
/// we promote this VM into ScarfCore.
|
||||||
|
nonisolated private static func parse(_ output: String) -> [WebhookRow] {
|
||||||
|
var results: [WebhookRow] = []
|
||||||
|
var name = ""
|
||||||
|
var desc = ""
|
||||||
|
var deliver = ""
|
||||||
|
var events: [String] = []
|
||||||
|
var route = ""
|
||||||
|
|
||||||
|
func flush() {
|
||||||
|
if !name.isEmpty {
|
||||||
|
results.append(WebhookRow(
|
||||||
|
name: name,
|
||||||
|
description: desc,
|
||||||
|
deliver: deliver,
|
||||||
|
events: events,
|
||||||
|
routeSuffix: route.isEmpty ? "/webhooks/\(name)" : route
|
||||||
|
))
|
||||||
|
}
|
||||||
|
name = ""; desc = ""; deliver = ""; events = []; route = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for raw in output.components(separatedBy: "\n") {
|
||||||
|
let line = raw
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty { continue }
|
||||||
|
if !line.hasPrefix(" ") && !line.hasPrefix("\t") {
|
||||||
|
flush()
|
||||||
|
let candidate = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: ":"))
|
||||||
|
if candidate.range(of: "^[A-Za-z0-9_-]+$", options: .regularExpression) != nil {
|
||||||
|
name = candidate
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trimmed.lowercased().hasPrefix("description:") {
|
||||||
|
desc = String(trimmed.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
} else if trimmed.lowercased().hasPrefix("deliver:") {
|
||||||
|
deliver = String(trimmed.dropFirst("deliver:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
} else if trimmed.lowercased().hasPrefix("events:") {
|
||||||
|
let list = String(trimmed.dropFirst("events:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
events = list.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||||
|
} else if trimmed.lowercased().hasPrefix("url:") || trimmed.lowercased().hasPrefix("route:") {
|
||||||
|
route = trimmed.components(separatedBy: ":").dropFirst().joined(separator: ":").trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WebhookRow: Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let deliver: String
|
||||||
|
let events: [String]
|
||||||
|
let routeSuffix: String
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ struct ContentView: View {
|
|||||||
case .projects: ProjectsView(context: serverContext)
|
case .projects: ProjectsView(context: serverContext)
|
||||||
case .chat: ChatView()
|
case .chat: ChatView()
|
||||||
case .memory: MemoryView(context: serverContext)
|
case .memory: MemoryView(context: serverContext)
|
||||||
|
case .curator: CuratorView(context: serverContext)
|
||||||
case .skills: SkillsView(context: serverContext)
|
case .skills: SkillsView(context: serverContext)
|
||||||
case .platforms: PlatformsView(context: serverContext)
|
case .platforms: PlatformsView(context: serverContext)
|
||||||
case .personalities: PersonalitiesView(context: serverContext)
|
case .personalities: PersonalitiesView(context: serverContext)
|
||||||
@@ -73,6 +74,7 @@ struct ContentView: View {
|
|||||||
case .mcpServers: MCPServersView(context: serverContext)
|
case .mcpServers: MCPServersView(context: serverContext)
|
||||||
case .gateway: GatewayView(context: serverContext)
|
case .gateway: GatewayView(context: serverContext)
|
||||||
case .cron: CronView(context: serverContext)
|
case .cron: CronView(context: serverContext)
|
||||||
|
case .kanban: KanbanView(context: serverContext)
|
||||||
case .health: HealthView(context: serverContext)
|
case .health: HealthView(context: serverContext)
|
||||||
case .logs: LogsView(context: serverContext)
|
case .logs: LogsView(context: serverContext)
|
||||||
case .settings: SettingsView(context: serverContext)
|
case .settings: SettingsView(context: serverContext)
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ struct HermesFileService: Sendable {
|
|||||||
skillsHub: aux("skills_hub"),
|
skillsHub: aux("skills_hub"),
|
||||||
approval: aux("approval"),
|
approval: aux("approval"),
|
||||||
mcp: aux("mcp"),
|
mcp: aux("mcp"),
|
||||||
flushMemories: aux("flush_memories")
|
flushMemories: aux("flush_memories"),
|
||||||
|
curator: aux("curator")
|
||||||
)
|
)
|
||||||
|
|
||||||
let security = SecuritySettings(
|
let security = SecuritySettings(
|
||||||
@@ -287,7 +288,10 @@ struct HermesFileService: Sendable {
|
|||||||
matrix: matrix,
|
matrix: matrix,
|
||||||
mattermost: mattermost,
|
mattermost: mattermost,
|
||||||
whatsapp: whatsapp,
|
whatsapp: whatsapp,
|
||||||
homeAssistant: homeAssistant
|
homeAssistant: homeAssistant,
|
||||||
|
cacheTTL: str("prompt_caching.cache_ttl", default: "5m"),
|
||||||
|
redactionEnabled: bool("redaction.enabled", default: false),
|
||||||
|
runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1573,6 +1577,39 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split-stream variant of `runHermesCLI`. Use this when you need to
|
||||||
|
/// parse stdout (e.g. JSON output) without stderr contamination, and
|
||||||
|
/// surface stderr separately as a user-facing error message. Transport
|
||||||
|
/// failures land in `stderr` with an empty `stdout`.
|
||||||
|
@discardableResult
|
||||||
|
nonisolated func runHermesCLISplit(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, stdout: String, stderr: String) {
|
||||||
|
let binary: String
|
||||||
|
if context.isRemote {
|
||||||
|
binary = context.paths.hermesBinary
|
||||||
|
} else {
|
||||||
|
guard let local = hermesBinaryPath() else { return (-1, "", "hermes binary not found") }
|
||||||
|
binary = local
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdinData = stdinInput?.data(using: .utf8)
|
||||||
|
do {
|
||||||
|
let result = try transport.runProcess(
|
||||||
|
executable: binary,
|
||||||
|
args: args,
|
||||||
|
stdin: stdinData,
|
||||||
|
timeout: timeout
|
||||||
|
)
|
||||||
|
return (result.exitCode, result.stdoutString, result.stderrString)
|
||||||
|
} catch let error as TransportError {
|
||||||
|
let message = error.diagnosticStderr.isEmpty
|
||||||
|
? (error.errorDescription ?? "transport error")
|
||||||
|
: error.diagnosticStderr
|
||||||
|
return (-1, "", message)
|
||||||
|
} catch {
|
||||||
|
return (-1, "", error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - File I/O
|
// MARK: - File I/O
|
||||||
|
|
||||||
/// Read a UTF-8 text file through the transport. Missing files and any
|
/// Read a UTF-8 text file through the transport. Missing files and any
|
||||||
|
|||||||
@@ -254,14 +254,32 @@ final class ChatViewModel {
|
|||||||
// MARK: - Send Message
|
// MARK: - Send Message
|
||||||
|
|
||||||
func sendText(_ text: String) {
|
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 displayMode == .richChat {
|
||||||
if let client = acpClient {
|
if let client = acpClient {
|
||||||
sendViaACP(client: client, text: text)
|
sendViaACP(client: client, text: text, images: images)
|
||||||
} else {
|
} else {
|
||||||
// Auto-start ACP and send the queued message
|
// Auto-start ACP and send the queued message
|
||||||
autoStartACPAndSend(text: text)
|
autoStartACPAndSend(text: text, images: images)
|
||||||
}
|
}
|
||||||
} else if let tv = terminalView {
|
} 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")
|
sendToTerminal(tv, text: text + "\r")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,7 +292,7 @@ final class ChatViewModel {
|
|||||||
/// user never interacted with; those can be garbage-collected by Hermes
|
/// user never interacted with; those can be garbage-collected by Hermes
|
||||||
/// between the DB read and ACP `session/load`, producing a silent prompt
|
/// between the DB read and ACP `session/load`, producing a silent prompt
|
||||||
/// failure with no UI feedback.
|
/// failure with no UI feedback.
|
||||||
private func autoStartACPAndSend(text: String) {
|
private func autoStartACPAndSend(text: String, images: [ChatImageAttachment] = []) {
|
||||||
// Show the user message immediately
|
// Show the user message immediately
|
||||||
richChatViewModel.addUserMessage(text: text)
|
richChatViewModel.addUserMessage(text: text)
|
||||||
|
|
||||||
@@ -313,7 +331,7 @@ final class ChatViewModel {
|
|||||||
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||||
|
|
||||||
// Now send the queued prompt
|
// Now send the queued prompt
|
||||||
sendViaACP(client: client, text: text)
|
sendViaACP(client: client, text: text, images: images)
|
||||||
} catch {
|
} catch {
|
||||||
acpStatus = "Failed"
|
acpStatus = "Failed"
|
||||||
await recordACPFailure(error, client: client, context: "Auto-start ACP 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)
|
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 {
|
guard let sessionId = richChatViewModel.sessionId else {
|
||||||
clearACPErrorState()
|
clearACPErrorState()
|
||||||
acpError = "No session ID — cannot send"
|
acpError = "No session ID — cannot send"
|
||||||
@@ -390,7 +408,7 @@ final class ChatViewModel {
|
|||||||
}
|
}
|
||||||
acpPromptTask = Task { @MainActor in
|
acpPromptTask = Task { @MainActor in
|
||||||
do {
|
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"
|
acpStatus = "Ready"
|
||||||
richChatViewModel.handleACPEvent(
|
richChatViewModel.handleACPEvent(
|
||||||
.promptComplete(sessionId: sessionId, response: result)
|
.promptComplete(sessionId: sessionId, response: result)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import ScarfDesign
|
|||||||
struct ChatTranscriptPane: View {
|
struct ChatTranscriptPane: View {
|
||||||
@Bindable var richChat: RichChatViewModel
|
@Bindable var richChat: RichChatViewModel
|
||||||
@Bindable var chatViewModel: ChatViewModel
|
@Bindable var chatViewModel: ChatViewModel
|
||||||
var onSend: (String) -> Void
|
var onSend: (String, [ChatImageAttachment]) -> Void
|
||||||
var isEnabled: Bool
|
var isEnabled: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ struct ChatView: View {
|
|||||||
if viewModel.hermesBinaryExists {
|
if viewModel.hermesBinaryExists {
|
||||||
RichChatView(
|
RichChatView(
|
||||||
richChat: viewModel.richChatViewModel,
|
richChat: viewModel.richChatViewModel,
|
||||||
onSend: { viewModel.sendText($0) },
|
onSend: { text, images in viewModel.sendText(text, images: images) },
|
||||||
isEnabled: viewModel.hasActiveProcess || viewModel.hermesBinaryExists
|
isEnabled: viewModel.hasActiveProcess || viewModel.hermesBinaryExists
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,20 +1,51 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ScarfCore
|
import ScarfCore
|
||||||
import ScarfDesign
|
import ScarfDesign
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import os
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct RichChatInputBar: View {
|
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
|
let isEnabled: Bool
|
||||||
var commands: [HermesSlashCommand] = []
|
var commands: [HermesSlashCommand] = []
|
||||||
var showCompressButton: Bool = false
|
var showCompressButton: Bool = false
|
||||||
|
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
@State private var text = ""
|
@State private var text = ""
|
||||||
@State private var showCompressSheet = false
|
@State private var showCompressSheet = false
|
||||||
@State private var compressFocus = ""
|
@State private var compressFocus = ""
|
||||||
@State private var showMenu = false
|
@State private var showMenu = false
|
||||||
@State private var selectedIndex = 0
|
@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
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if showMenu {
|
if showMenu {
|
||||||
@@ -36,6 +67,10 @@ struct RichChatInputBar: View {
|
|||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !attachments.isEmpty || isEncodingAttachment || attachmentError != nil {
|
||||||
|
attachmentStrip
|
||||||
|
}
|
||||||
|
|
||||||
HStack(alignment: .bottom, spacing: ScarfSpace.s2) {
|
HStack(alignment: .bottom, spacing: ScarfSpace.s2) {
|
||||||
if showCompressButton {
|
if showCompressButton {
|
||||||
Button {
|
Button {
|
||||||
@@ -52,6 +87,10 @@ struct RichChatInputBar: View {
|
|||||||
.help("Compress conversation (/compress)")
|
.help("Compress conversation (/compress)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if supportsImagePrompts {
|
||||||
|
attachmentButton
|
||||||
|
}
|
||||||
|
|
||||||
TextEditor(text: $text)
|
TextEditor(text: $text)
|
||||||
.font(ScarfFont.body)
|
.font(ScarfFont.body)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
@@ -70,7 +109,9 @@ struct RichChatInputBar: View {
|
|||||||
)
|
)
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
if text.isEmpty {
|
if text.isEmpty {
|
||||||
Text("Message Hermes… / for commands")
|
Text(supportsImagePrompts
|
||||||
|
? "Message Hermes… / for commands · drag images to attach"
|
||||||
|
: "Message Hermes… / for commands")
|
||||||
.scarfStyle(.body)
|
.scarfStyle(.body)
|
||||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
@@ -78,6 +119,25 @@ struct RichChatInputBar: View {
|
|||||||
.allowsHitTesting(false)
|
.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
|
.onKeyPress(.upArrow, phases: .down) { _ in
|
||||||
guard showMenu, !filteredCommands.isEmpty else { return .ignored }
|
guard showMenu, !filteredCommands.isEmpty else { return .ignored }
|
||||||
let n = filteredCommands.count
|
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 {
|
private var compressSheet: some View {
|
||||||
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
|
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
|
||||||
Text("Compress Conversation")
|
Text("Compress Conversation")
|
||||||
@@ -164,7 +314,7 @@ struct RichChatInputBar: View {
|
|||||||
Button("Compress") {
|
Button("Compress") {
|
||||||
let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines)
|
let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let command = focus.isEmpty ? "/compress" : "/compress \(focus)"
|
let command = focus.isEmpty ? "/compress" : "/compress \(focus)"
|
||||||
onSend(command)
|
onSend(command, [])
|
||||||
showCompressSheet = false
|
showCompressSheet = false
|
||||||
}
|
}
|
||||||
.buttonStyle(ScarfPrimaryButton())
|
.buttonStyle(ScarfPrimaryButton())
|
||||||
@@ -176,7 +326,18 @@ struct RichChatInputBar: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var canSend: Bool {
|
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:
|
/// Show the slash menu only while the user is typing the command token:
|
||||||
@@ -224,12 +385,118 @@ struct RichChatInputBar: View {
|
|||||||
|
|
||||||
private func send() {
|
private func send() {
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty, isEnabled else { return }
|
guard canSend else { return }
|
||||||
onSend(trimmed)
|
onSend(trimmed, attachments)
|
||||||
text = ""
|
text = ""
|
||||||
|
attachments.removeAll()
|
||||||
showMenu = false
|
showMenu = false
|
||||||
selectedIndex = 0
|
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 = Array(panel.urls.prefix(Self.maxAttachments - attachments.count))
|
||||||
|
guard !urls.isEmpty else { return }
|
||||||
|
isEncodingAttachment = true
|
||||||
|
Task.detached(priority: .userInitiated) {
|
||||||
|
for url in urls {
|
||||||
|
guard let data = try? Data(contentsOf: url) else { continue }
|
||||||
|
encode(data: data, filename: url.lastPathComponent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Array {
|
private extension Array {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import ScarfDesign
|
|||||||
/// can scroll horizontally inside the panes rather than losing them.
|
/// can scroll horizontally inside the panes rather than losing them.
|
||||||
struct RichChatView: View {
|
struct RichChatView: View {
|
||||||
@Bindable var richChat: RichChatViewModel
|
@Bindable var richChat: RichChatViewModel
|
||||||
var onSend: (String) -> Void
|
var onSend: (String, [ChatImageAttachment]) -> Void
|
||||||
var isEnabled: Bool
|
var isEnabled: Bool
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
@Environment(ChatViewModel.self) private var chatViewModel
|
@Environment(ChatViewModel.self) private var chatViewModel
|
||||||
|
|||||||
@@ -131,19 +131,24 @@ final class CronViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String) {
|
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String, workdir: String = "") {
|
||||||
var args = ["cron", "create"]
|
var args = ["cron", "create"]
|
||||||
if !name.isEmpty { args += ["--name", name] }
|
if !name.isEmpty { args += ["--name", name] }
|
||||||
if !deliver.isEmpty { args += ["--deliver", deliver] }
|
if !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||||
if !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
if !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
||||||
for skill in skills where !skill.isEmpty { args += ["--skill", skill] }
|
for skill in skills where !skill.isEmpty { args += ["--skill", skill] }
|
||||||
if !script.isEmpty { args += ["--script", script] }
|
if !script.isEmpty { args += ["--script", script] }
|
||||||
|
// v0.12+: --workdir injects AGENTS.md/CLAUDE.md context and pins
|
||||||
|
// cwd for terminal/file/code_exec tools. Hermes pre-v0.12 doesn't
|
||||||
|
// know the flag — argparse rejects unknown args, so the form
|
||||||
|
// omits the flag when the field is empty.
|
||||||
|
if !workdir.isEmpty { args += ["--workdir", workdir] }
|
||||||
args.append(schedule)
|
args.append(schedule)
|
||||||
if !prompt.isEmpty { args.append(prompt) }
|
if !prompt.isEmpty { args.append(prompt) }
|
||||||
runAndReload(args, success: "Job created")
|
runAndReload(args, success: "Job created")
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?) {
|
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?, workdir: String? = nil) {
|
||||||
var args = ["cron", "edit", id]
|
var args = ["cron", "edit", id]
|
||||||
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
|
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
|
||||||
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
|
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
|
||||||
@@ -156,6 +161,10 @@ final class CronViewModel {
|
|||||||
for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] }
|
for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] }
|
||||||
}
|
}
|
||||||
if let script { args += ["--script", script] }
|
if let script { args += ["--script", script] }
|
||||||
|
// `nil` = caller didn't touch the field (omit the flag). Empty string
|
||||||
|
// = user cleared an existing workdir; Hermes documents `--workdir ""`
|
||||||
|
// on edit as the explicit clear gesture, mirroring the `--script` shape.
|
||||||
|
if let workdir { args += ["--workdir", workdir] }
|
||||||
runAndReload(args, success: "Updated")
|
runAndReload(args, success: "Updated")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ import ScarfDesign
|
|||||||
struct CronView: View {
|
struct CronView: View {
|
||||||
@State private var viewModel: CronViewModel
|
@State private var viewModel: CronViewModel
|
||||||
@State private var pendingDelete: HermesCronJob?
|
@State private var pendingDelete: HermesCronJob?
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
init(context: ServerContext) {
|
init(context: ServerContext) {
|
||||||
_viewModel = State(initialValue: CronViewModel(context: context))
|
_viewModel = State(initialValue: CronViewModel(context: context))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hasCronWorkdir: Bool {
|
||||||
|
capabilitiesStore?.capabilities.hasCronWorkdir ?? false
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
pageHeader
|
pageHeader
|
||||||
@@ -32,7 +37,7 @@ struct CronView: View {
|
|||||||
.loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty)
|
.loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||||
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in
|
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in
|
||||||
viewModel.createJob(
|
viewModel.createJob(
|
||||||
schedule: form.schedule,
|
schedule: form.schedule,
|
||||||
prompt: form.prompt,
|
prompt: form.prompt,
|
||||||
@@ -40,7 +45,8 @@ struct CronView: View {
|
|||||||
deliver: form.deliver,
|
deliver: form.deliver,
|
||||||
skills: form.skills,
|
skills: form.skills,
|
||||||
script: form.script,
|
script: form.script,
|
||||||
repeatCount: form.repeatCount
|
repeatCount: form.repeatCount,
|
||||||
|
workdir: hasCronWorkdir ? form.workdir : ""
|
||||||
)
|
)
|
||||||
viewModel.showCreateSheet = false
|
viewModel.showCreateSheet = false
|
||||||
} onCancel: {
|
} onCancel: {
|
||||||
@@ -48,7 +54,7 @@ struct CronView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $viewModel.editingJob) { job in
|
.sheet(item: $viewModel.editingJob) { job in
|
||||||
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills) { form in
|
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in
|
||||||
viewModel.updateJob(
|
viewModel.updateJob(
|
||||||
id: job.id,
|
id: job.id,
|
||||||
schedule: form.schedule,
|
schedule: form.schedule,
|
||||||
@@ -58,7 +64,8 @@ struct CronView: View {
|
|||||||
repeatCount: form.repeatCount,
|
repeatCount: form.repeatCount,
|
||||||
newSkills: form.skills,
|
newSkills: form.skills,
|
||||||
clearSkills: form.clearSkills,
|
clearSkills: form.clearSkills,
|
||||||
script: form.script
|
script: form.script,
|
||||||
|
workdir: hasCronWorkdir ? form.workdir : nil
|
||||||
)
|
)
|
||||||
viewModel.editingJob = nil
|
viewModel.editingJob = nil
|
||||||
} onCancel: {
|
} onCancel: {
|
||||||
@@ -468,10 +475,16 @@ struct CronJobEditor: View {
|
|||||||
var skills: [String] = []
|
var skills: [String] = []
|
||||||
var clearSkills: Bool = false
|
var clearSkills: Bool = false
|
||||||
var script: String = ""
|
var script: String = ""
|
||||||
|
/// v0.12+ workdir flag — fills `--workdir <path>`. Empty string
|
||||||
|
/// preserves the v0.11 behaviour of running with no cwd hint.
|
||||||
|
var workdir: String = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
let mode: Mode
|
let mode: Mode
|
||||||
let availableSkills: [String]
|
let availableSkills: [String]
|
||||||
|
/// Pass `false` on pre-v0.12 hosts; the `--workdir` field is hidden and
|
||||||
|
/// the form's value is dropped when the parent calls `createJob`/`updateJob`.
|
||||||
|
let supportsWorkdir: Bool
|
||||||
let onSave: (FormState) -> Void
|
let onSave: (FormState) -> Void
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
|
|
||||||
@@ -506,6 +519,9 @@ struct CronJobEditor: View {
|
|||||||
formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
|
formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
|
||||||
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
|
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
|
||||||
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
|
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
|
||||||
|
if supportsWorkdir {
|
||||||
|
formField("Workdir", text: $form.workdir, placeholder: "Absolute path; pulls AGENTS.md/CLAUDE.md context", mono: true)
|
||||||
|
}
|
||||||
if !availableSkills.isEmpty {
|
if !availableSkills.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Skills")
|
Text("Skills")
|
||||||
@@ -564,6 +580,7 @@ struct CronJobEditor: View {
|
|||||||
form.deliver = job.deliver ?? ""
|
form.deliver = job.deliver ?? ""
|
||||||
form.skills = job.skills ?? []
|
form.skills = job.skills ?? []
|
||||||
form.script = job.preRunScript ?? ""
|
form.script = job.preRunScript ?? ""
|
||||||
|
form.workdir = job.workdir ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Modal that lists archived skills (state ≠ active) and exposes a
|
||||||
|
/// one-click "Restore" action per row. v0.12 archives are recoverable —
|
||||||
|
/// `hermes curator restore <name>` brings the skill back into
|
||||||
|
/// `~/.hermes/skills/<category>/<name>/` and re-marks it active.
|
||||||
|
///
|
||||||
|
/// The Curator's `status` text doesn't enumerate archived skills with
|
||||||
|
/// names; we surface what's available (counts + pinned list) and rely
|
||||||
|
/// on the user knowing the names. Hermes ergo does an interactive
|
||||||
|
/// `--name` arg if missing — but Scarf prefers explicit selection so
|
||||||
|
/// users don't have to remember names. For v2.6 we render a free-form
|
||||||
|
/// text field; once Hermes ships a `curator list-archived` (tracked
|
||||||
|
/// upstream), swap to a pickable list.
|
||||||
|
struct CuratorRestoreSheet: View {
|
||||||
|
let viewModel: CuratorViewModel
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var skillName: String = ""
|
||||||
|
@State private var isRestoring = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
|
||||||
|
Text("Restore Archived Skill")
|
||||||
|
.scarfStyle(.headline)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
|
||||||
|
Text("Hermes archives skills the curator decides are stale or redundant. Restoring brings the original SKILL.md back into place — no data lost.")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s1) {
|
||||||
|
Text("Skill name")
|
||||||
|
.scarfStyle(.captionUppercase)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
ScarfTextField("e.g. legacy-helper", text: $skillName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("\(viewModel.status.archivedSkills) archived skill(s) available — list them with `hermes curator status`.")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.buttonStyle(ScarfGhostButton())
|
||||||
|
Button("Restore") {
|
||||||
|
let trimmed = skillName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
isRestoring = true
|
||||||
|
Task {
|
||||||
|
await viewModel.restore(trimmed)
|
||||||
|
isRestoring = false
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(ScarfPrimaryButton())
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(isRestoring || skillName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(ScarfSpace.s5)
|
||||||
|
.frame(width: 420)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Mac UI for Hermes v0.12's autonomous skill curator.
|
||||||
|
///
|
||||||
|
/// Surfaces the running state (enabled / paused / disabled), last-run
|
||||||
|
/// metadata, agent-created skill counts, and the most/least-active /
|
||||||
|
/// least-recently-active leaderboards. Pin-and-restore actions hit
|
||||||
|
/// `hermes curator pin/unpin/restore` via CuratorViewModel.
|
||||||
|
///
|
||||||
|
/// Capability-gated upstream: AppCoordinator only wires the sidebar
|
||||||
|
/// item when `HermesCapabilities.hasCurator` is true. This view assumes
|
||||||
|
/// it's reachable on a v0.12+ host.
|
||||||
|
struct CuratorView: View {
|
||||||
|
@State private var viewModel: CuratorViewModel
|
||||||
|
@State private var showRestoreSheet = false
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: CuratorViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
|
||||||
|
ScarfPageHeader(
|
||||||
|
"Curator",
|
||||||
|
subtitle: "Autonomous skill maintenance — Hermes v0.12+"
|
||||||
|
) {
|
||||||
|
HStack(spacing: ScarfSpace.s2) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
Button("Run Now") {
|
||||||
|
Task { await viewModel.runNow() }
|
||||||
|
}
|
||||||
|
.buttonStyle(ScarfPrimaryButton())
|
||||||
|
.disabled(viewModel.isLoading)
|
||||||
|
Menu {
|
||||||
|
switch viewModel.status.state {
|
||||||
|
case .paused:
|
||||||
|
Button("Resume") { Task { await viewModel.resume() } }
|
||||||
|
case .enabled:
|
||||||
|
Button("Pause") { Task { await viewModel.pause() } }
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
Button("Restore Archived…") {
|
||||||
|
showRestoreSheet = true
|
||||||
|
}
|
||||||
|
.disabled(viewModel.status.archivedSkills == 0)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let toast = viewModel.transientMessage {
|
||||||
|
transientToast(toast)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusSummary
|
||||||
|
skillCountsSection
|
||||||
|
pinnedSection
|
||||||
|
activityTables
|
||||||
|
|
||||||
|
if let report = viewModel.lastReportMarkdown {
|
||||||
|
lastReportSection(markdown: report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(ScarfSpace.s4)
|
||||||
|
}
|
||||||
|
.background(ScarfColor.backgroundPrimary)
|
||||||
|
.task { await viewModel.load() }
|
||||||
|
.sheet(isPresented: $showRestoreSheet) {
|
||||||
|
CuratorRestoreSheet(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusSummary: some View {
|
||||||
|
ScarfCard {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||||
|
HStack {
|
||||||
|
statusBadge
|
||||||
|
Spacer()
|
||||||
|
Text("\(viewModel.status.runCount) runs")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
}
|
||||||
|
ScarfDivider()
|
||||||
|
infoRow(label: "Last run", value: viewModel.status.lastRunISO ?? "Never")
|
||||||
|
if let summary = viewModel.status.lastSummary {
|
||||||
|
infoRow(label: "Last summary", value: summary)
|
||||||
|
}
|
||||||
|
infoRow(label: "Interval", value: viewModel.status.intervalLabel)
|
||||||
|
infoRow(label: "Stale after", value: viewModel.status.staleAfterLabel)
|
||||||
|
infoRow(label: "Archive after", value: viewModel.status.archiveAfterLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusBadge: some View {
|
||||||
|
let kind: ScarfBadgeKind
|
||||||
|
let label: String
|
||||||
|
switch viewModel.status.state {
|
||||||
|
case .enabled: kind = .success; label = "Enabled"
|
||||||
|
case .paused: kind = .warning; label = "Paused"
|
||||||
|
case .disabled: kind = .neutral; label = "Disabled"
|
||||||
|
case .unknown: kind = .neutral; label = "Unknown"
|
||||||
|
}
|
||||||
|
return ScarfBadge(label, kind: kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var skillCountsSection: some View {
|
||||||
|
ScarfCard {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||||
|
ScarfSectionHeader("Agent-created skills")
|
||||||
|
HStack(spacing: ScarfSpace.s4) {
|
||||||
|
countCell(value: viewModel.status.totalSkills, label: "Total")
|
||||||
|
countCell(value: viewModel.status.activeSkills, label: "Active")
|
||||||
|
countCell(value: viewModel.status.staleSkills, label: "Stale")
|
||||||
|
countCell(value: viewModel.status.archivedSkills, label: "Archived")
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var pinnedSection: some View {
|
||||||
|
if !viewModel.status.pinnedNames.isEmpty {
|
||||||
|
ScarfCard {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||||
|
ScarfSectionHeader("Pinned")
|
||||||
|
Text("Pinned skills are never auto-archived or rewritten by the curator.")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
FlowLayout(spacing: ScarfSpace.s2) {
|
||||||
|
ForEach(viewModel.status.pinnedNames, id: \.self) { name in
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
Text(name)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.unpin(name) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Unpin")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: ScarfRadius.md)
|
||||||
|
.fill(ScarfColor.accentTint)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activityTables: some View {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
|
||||||
|
if !viewModel.status.leastRecentlyActive.isEmpty {
|
||||||
|
skillTable(title: "Least recently active", rows: viewModel.status.leastRecentlyActive)
|
||||||
|
}
|
||||||
|
if !viewModel.status.mostActive.isEmpty {
|
||||||
|
skillTable(title: "Most active", rows: viewModel.status.mostActive)
|
||||||
|
}
|
||||||
|
if !viewModel.status.leastActive.isEmpty {
|
||||||
|
skillTable(title: "Least active", rows: viewModel.status.leastActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func skillTable(title: String, rows: [HermesCuratorSkillRow]) -> some View {
|
||||||
|
ScarfCard {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||||
|
ScarfSectionHeader(title)
|
||||||
|
ForEach(rows) { row in
|
||||||
|
HStack(alignment: .center, spacing: ScarfSpace.s2) {
|
||||||
|
Text(row.name)
|
||||||
|
.scarfStyle(.body)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
counterChip(label: "use", value: row.useCount)
|
||||||
|
counterChip(label: "view", value: row.viewCount)
|
||||||
|
counterChip(label: "patch", value: row.patchCount)
|
||||||
|
Text(row.lastActivityLabel)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||||
|
.frame(width: 92, alignment: .trailing)
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.pin(row.name) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.status.pinnedNames.contains(row.name)
|
||||||
|
? "pin.fill" : "pin")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(viewModel.status.pinnedNames.contains(row.name) ? "Pinned" : "Pin skill")
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func counterChip(label: String, value: Int) -> some View {
|
||||||
|
Text("\(label) \(value)")
|
||||||
|
.font(ScarfFont.monoSmall)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: ScarfRadius.sm)
|
||||||
|
.fill(ScarfColor.backgroundTertiary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lastReportSection(markdown: String) -> some View {
|
||||||
|
ScarfCard {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||||
|
ScarfSectionHeader("Last report")
|
||||||
|
Text(markdown)
|
||||||
|
.scarfStyle(.mono)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func infoRow(label: String, value: String) -> some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text(label)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
.frame(width: 110, alignment: .leading)
|
||||||
|
Text(value)
|
||||||
|
.scarfStyle(.body)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func countCell(value: Int, label: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(value)")
|
||||||
|
.scarfStyle(.title2)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
Text(label)
|
||||||
|
.scarfStyle(.captionUppercase)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 64, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transientToast(_ text: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.success)
|
||||||
|
Text(text)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, ScarfSpace.s3)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(ScarfColor.accentTint)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple `FlowLayout` for the pinned-skill chips. Custom layout
|
||||||
|
/// keeps the chip wrap behaviour predictable across DynamicType
|
||||||
|
/// scales without resorting to LazyVGrid (which forces fixed columns).
|
||||||
|
private struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 8
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let maxWidth = proposal.width ?? .infinity
|
||||||
|
var x: CGFloat = 0
|
||||||
|
var y: CGFloat = 0
|
||||||
|
var rowHeight: CGFloat = 0
|
||||||
|
for subview in subviews {
|
||||||
|
let size = subview.sizeThatFits(.unspecified)
|
||||||
|
if x + size.width > maxWidth {
|
||||||
|
x = 0
|
||||||
|
y += rowHeight + spacing
|
||||||
|
rowHeight = 0
|
||||||
|
}
|
||||||
|
x += size.width + spacing
|
||||||
|
rowHeight = max(rowHeight, size.height)
|
||||||
|
}
|
||||||
|
return CGSize(width: maxWidth, height: y + rowHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
var x = bounds.minX
|
||||||
|
var y = bounds.minY
|
||||||
|
var rowHeight: CGFloat = 0
|
||||||
|
for subview in subviews {
|
||||||
|
let size = subview.sizeThatFits(.unspecified)
|
||||||
|
if x + size.width > bounds.maxX {
|
||||||
|
x = bounds.minX
|
||||||
|
y += rowHeight + spacing
|
||||||
|
rowHeight = 0
|
||||||
|
}
|
||||||
|
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||||
|
x += size.width + spacing
|
||||||
|
rowHeight = max(rowHeight, size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,7 +180,7 @@ final class HealthViewModel {
|
|||||||
("skills_hub", config.auxiliary.skillsHub.provider),
|
("skills_hub", config.auxiliary.skillsHub.provider),
|
||||||
("approval", config.auxiliary.approval.provider),
|
("approval", config.auxiliary.approval.provider),
|
||||||
("mcp", config.auxiliary.mcp.provider),
|
("mcp", config.auxiliary.mcp.provider),
|
||||||
("flush_memories", config.auxiliary.flushMemories.provider),
|
("curator", config.auxiliary.curator.provider),
|
||||||
].filter { $0.1 == "nous" }.map(\.0)
|
].filter { $0.1 == "nous" }.map(\.0)
|
||||||
if !auxOnNous.isEmpty {
|
if !auxOnNous.isEmpty {
|
||||||
checks.append(HealthCheck(
|
checks.append(HealthCheck(
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import ScarfCore
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Read-only view of `hermes kanban list --json`. Multi-profile
|
||||||
|
/// collaboration was reverted upstream while the design is reworked,
|
||||||
|
/// so v2.6 ships read-only on Mac and defers create/claim/dispatch UI
|
||||||
|
/// to v2.7+.
|
||||||
|
///
|
||||||
|
/// Polls every 5s while foregrounded so dispatcher progress is visible
|
||||||
|
/// without manual refresh; the polling task is suspended when the view
|
||||||
|
/// disappears so background windows don't keep hammering SSH.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class KanbanViewModel {
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "KanbanViewModel")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
private let fileService: HermesFileService
|
||||||
|
|
||||||
|
init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
self.fileService = HermesFileService(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks: [HermesKanbanTask] = []
|
||||||
|
var isLoading = false
|
||||||
|
var lastError: String?
|
||||||
|
var statusFilter: StatusFilter = .all
|
||||||
|
|
||||||
|
/// Subset Hermes accepts on `--status`. `.all` skips the flag.
|
||||||
|
enum StatusFilter: String, CaseIterable, Identifiable {
|
||||||
|
case all
|
||||||
|
case triage
|
||||||
|
case todo
|
||||||
|
case ready
|
||||||
|
case running
|
||||||
|
case blocked
|
||||||
|
case done
|
||||||
|
case archived
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .all: return "All"
|
||||||
|
default: return rawValue.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pollTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
func startPolling() {
|
||||||
|
stopPolling()
|
||||||
|
pollTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await self?.load()
|
||||||
|
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPolling() {
|
||||||
|
pollTask?.cancel()
|
||||||
|
pollTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
let svc = fileService
|
||||||
|
let filter = statusFilter
|
||||||
|
let result = await Task.detached { () -> (exitCode: Int32, stdout: String, stderr: String) in
|
||||||
|
var args = ["kanban", "list", "--json"]
|
||||||
|
if filter != .all {
|
||||||
|
args.append(contentsOf: ["--status", filter.rawValue])
|
||||||
|
}
|
||||||
|
return svc.runHermesCLISplit(args: args, timeout: 15)
|
||||||
|
}.value
|
||||||
|
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
guard result.exitCode == 0 else {
|
||||||
|
lastError = result.stderr.isEmpty
|
||||||
|
? "kanban list failed (\(result.exitCode))"
|
||||||
|
: result.stderr
|
||||||
|
tasks = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let data = result.stdout.data(using: .utf8) else {
|
||||||
|
lastError = "kanban list returned non-UTF8 output"
|
||||||
|
tasks = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode([HermesKanbanTask].self, from: data)
|
||||||
|
tasks = decoded
|
||||||
|
lastError = nil
|
||||||
|
} catch {
|
||||||
|
// Hermes may print a "no matching tasks" line as text instead of
|
||||||
|
// empty JSON; handle gracefully so the UI shows an empty list
|
||||||
|
// without raising an error banner.
|
||||||
|
if result.stdout.contains("no matching tasks") {
|
||||||
|
tasks = []
|
||||||
|
lastError = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.warning("kanban JSON decode failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
lastError = "Couldn't parse kanban list output"
|
||||||
|
tasks = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Mac UI for `hermes kanban list` (v0.12+). Read-only — create / claim
|
||||||
|
/// / dispatch / dependency-link UI is deferred until upstream
|
||||||
|
/// stabilizes the multi-profile collaboration design.
|
||||||
|
///
|
||||||
|
/// Capability-gated upstream: AppCoordinator only routes to this view
|
||||||
|
/// when `HermesCapabilities.hasKanban` is true.
|
||||||
|
struct KanbanView: View {
|
||||||
|
@State private var viewModel: KanbanViewModel
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: KanbanViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScarfPageHeader(
|
||||||
|
"Kanban",
|
||||||
|
subtitle: "Hermes v0.12+ task board (read-only)"
|
||||||
|
) {
|
||||||
|
HStack(spacing: ScarfSpace.s2) {
|
||||||
|
Picker("Status", selection: $viewModel.statusFilter) {
|
||||||
|
ForEach(KanbanViewModel.StatusFilter.allCases) { f in
|
||||||
|
Text(f.label).tag(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.frame(width: 120)
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
} label: {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(ScarfGhostButton())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if let err = viewModel.lastError {
|
||||||
|
errorBanner(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
if viewModel.tasks.isEmpty && !viewModel.isLoading {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
taskTable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(ScarfColor.backgroundPrimary)
|
||||||
|
.onChange(of: viewModel.statusFilter) { _, _ in
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.onAppear { viewModel.startPolling() }
|
||||||
|
.onDisappear { viewModel.stopPolling() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var taskTable: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(viewModel.tasks) { task in
|
||||||
|
taskRow(task)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(ScarfSpace.s3)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func taskRow(_ task: HermesKanbanTask) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: ScarfSpace.s3) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: ScarfSpace.s2) {
|
||||||
|
statusBadge(for: task.status)
|
||||||
|
Text(task.title)
|
||||||
|
.scarfStyle(.bodyEmph)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
metaChip(systemImage: "number", value: task.id.prefix(8) + "")
|
||||||
|
if let assignee = task.assignee, !assignee.isEmpty {
|
||||||
|
metaChip(systemImage: "person.fill", value: assignee)
|
||||||
|
}
|
||||||
|
if let workspace = task.workspaceKind {
|
||||||
|
metaChip(systemImage: "folder", value: workspace)
|
||||||
|
}
|
||||||
|
if !task.skills.isEmpty {
|
||||||
|
metaChip(systemImage: "lightbulb", value: task.skills.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
if let createdAt = task.createdAt {
|
||||||
|
Text(createdAt)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||||
|
}
|
||||||
|
if let priority = task.priority {
|
||||||
|
Text("p\(priority)")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, ScarfSpace.s2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBadge(for status: String) -> some View {
|
||||||
|
let kind: ScarfBadgeKind
|
||||||
|
switch status.lowercased() {
|
||||||
|
case "done": kind = .success
|
||||||
|
case "running": kind = .info
|
||||||
|
case "ready": kind = .info
|
||||||
|
case "blocked": kind = .warning
|
||||||
|
case "archived": kind = .neutral
|
||||||
|
default: kind = .neutral
|
||||||
|
}
|
||||||
|
return ScarfBadge(status, kind: kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metaChip(systemImage: String, value: String) -> some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
Text(value)
|
||||||
|
.font(ScarfFont.monoSmall)
|
||||||
|
}
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "rectangle.split.3x1")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||||
|
Text("No kanban tasks")
|
||||||
|
.scarfStyle(.headline)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
Text("Create one with `hermes kanban create \"task title\"`. Tasks dispatched by the gateway show up here automatically.")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 460)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(.vertical, 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorBanner(_ message: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
Text(message)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, ScarfSpace.s3)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(ScarfColor.warning.opacity(0.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,6 +147,8 @@ struct PlatformsView: View {
|
|||||||
case "imessage": IMessageSetupView(context: ctx)
|
case "imessage": IMessageSetupView(context: ctx)
|
||||||
case "homeassistant": HomeAssistantSetupView(context: ctx)
|
case "homeassistant": HomeAssistantSetupView(context: ctx)
|
||||||
case "webhook": WebhookSetupView(context: ctx)
|
case "webhook": WebhookSetupView(context: ctx)
|
||||||
|
case "yuanbao": yuanbaoPanel
|
||||||
|
case "microsoft-teams": microsoftTeamsPanel
|
||||||
default:
|
default:
|
||||||
SettingsSection(title: LocalizedStringKey(viewModel.selected.displayName), icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
|
SettingsSection(title: LocalizedStringKey(viewModel.selected.displayName), icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
|
||||||
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
|
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
|
||||||
@@ -154,6 +156,30 @@ struct PlatformsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hermes v0.12 — Yuanbao 元宝 ships as a native gateway adapter
|
||||||
|
/// (the 18th platform). Setup is YAML-driven; we surface the
|
||||||
|
/// shell command and a docs link rather than a per-field form
|
||||||
|
/// because the auth dance is OAuth-style and lives outside Scarf.
|
||||||
|
private var yuanbaoPanel: some View {
|
||||||
|
SettingsSection(title: "Yuanbao 元宝", icon: KnownPlatforms.icon(for: "yuanbao")) {
|
||||||
|
ReadOnlyRow(label: "Type", value: "Native gateway adapter (v0.12+)")
|
||||||
|
ReadOnlyRow(label: "Setup", value: "Run `hermes setup` and select Yuanbao to walk the OAuth flow.")
|
||||||
|
ReadOnlyRow(label: "Multi-image", value: "Supported via the gateway's centralized media routing.")
|
||||||
|
ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hermes v0.12 — Microsoft Teams ships as a plugin (the 19th
|
||||||
|
/// platform). Surface that explicitly so users know the setup
|
||||||
|
/// path differs from the native adapters.
|
||||||
|
private var microsoftTeamsPanel: some View {
|
||||||
|
SettingsSection(title: "Microsoft Teams", icon: KnownPlatforms.icon(for: "microsoft-teams")) {
|
||||||
|
ReadOnlyRow(label: "Type", value: "Plugin-shipped gateway platform (v0.12+)")
|
||||||
|
ReadOnlyRow(label: "Setup", value: "Install the plugin from the Plugins tab, then run `hermes setup` to register the bot.")
|
||||||
|
ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var cliPanel: some View {
|
private var cliPanel: some View {
|
||||||
SettingsSection(title: "CLI", icon: "terminal") {
|
SettingsSection(title: "CLI", icon: "terminal") {
|
||||||
ReadOnlyRow(label: "Scope", value: "Local terminal sessions")
|
ReadOnlyRow(label: "Scope", value: "Local terminal sessions")
|
||||||
|
|||||||
@@ -21,9 +21,15 @@ final class SettingsViewModel {
|
|||||||
var hermesRunning = false
|
var hermesRunning = false
|
||||||
var rawConfigYAML = ""
|
var rawConfigYAML = ""
|
||||||
var personalities: [String] = []
|
var personalities: [String] = []
|
||||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
// v0.12: terminal.backend gained `vercel` (Vercel Sandbox); tts.provider
|
||||||
|
// gained `piper` (native local TTS via the Piper engine). These show up
|
||||||
|
// unconditionally — Hermes silently ignores unknown values, so a v0.11
|
||||||
|
// host that picks "vercel" simply falls back to local. We don't gate
|
||||||
|
// either on `HermesCapabilities` because the cost of seeing an option
|
||||||
|
// that no-ops on older hosts is low compared to gating overhead.
|
||||||
|
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh", "vercel"]
|
||||||
var browserBackends = ["browseruse", "firecrawl", "local"]
|
var browserBackends = ["browseruse", "firecrawl", "local"]
|
||||||
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts"]
|
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts", "piper"]
|
||||||
var sttProviders = ["local", "groq", "openai", "mistral"]
|
var sttProviders = ["local", "groq", "openai", "mistral"]
|
||||||
var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"]
|
var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"]
|
||||||
var saveMessage: String?
|
var saveMessage: String?
|
||||||
|
|||||||
@@ -5,8 +5,16 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
/// Advanced tab — network, compression, checkpoints, logging, delegation, file read cap,
|
/// Advanced tab — network, compression, checkpoints, logging, delegation, file read cap,
|
||||||
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
|
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
|
||||||
|
///
|
||||||
|
/// v0.12 added a "Caching & Redaction" section near the top: prompt cache
|
||||||
|
/// TTL picker (5m / 1h), the redaction toggle (off-by-default in v0.12 —
|
||||||
|
/// we surface a toggle so security-sensitive users can flip it back on),
|
||||||
|
/// and the runtime metadata footer toggle. All three are gated on
|
||||||
|
/// `HermesCapabilities` so a v0.11 host doesn't see toggles that write
|
||||||
|
/// keys it ignores.
|
||||||
struct AdvancedTab: View {
|
struct AdvancedTab: View {
|
||||||
@Bindable var viewModel: SettingsViewModel
|
@Bindable var viewModel: SettingsViewModel
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
@State private var showRawConfig = false
|
@State private var showRawConfig = false
|
||||||
@State private var showRestoreConfirm = false
|
@State private var showRestoreConfirm = false
|
||||||
@State private var pendingRestorePath: String?
|
@State private var pendingRestorePath: String?
|
||||||
@@ -15,6 +23,10 @@ struct AdvancedTab: View {
|
|||||||
@State private var showDiagnostics = false
|
@State private var showDiagnostics = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if capabilitiesStore?.capabilities.hasPromptCacheTTL ?? false {
|
||||||
|
v012CachingSection
|
||||||
|
}
|
||||||
|
|
||||||
SettingsSection(title: "Network", icon: "network") {
|
SettingsSection(title: "Network", icon: "network") {
|
||||||
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
|
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
|
||||||
}
|
}
|
||||||
@@ -99,6 +111,32 @@ struct AdvancedTab: View {
|
|||||||
rawConfigSection
|
rawConfigSection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Caching, redaction, and runtime-metadata footer — all v0.12+
|
||||||
|
/// knobs. The cache_ttl picker is two options today (5m default,
|
||||||
|
/// 1h opt-in); when Hermes adds more they should be surfaced here
|
||||||
|
/// without changing the writer (`hermes config set` accepts arbitrary
|
||||||
|
/// scalars, Hermes validates).
|
||||||
|
@ViewBuilder
|
||||||
|
private var v012CachingSection: some View {
|
||||||
|
SettingsSection(title: "Caching & Redaction", icon: "lock.shield") {
|
||||||
|
PickerRow(
|
||||||
|
label: "Prompt Cache TTL",
|
||||||
|
selection: viewModel.config.cacheTTL,
|
||||||
|
options: ["5m", "1h"]
|
||||||
|
) { viewModel.setSetting("prompt_caching.cache_ttl", value: $0) }
|
||||||
|
|
||||||
|
ToggleRow(
|
||||||
|
label: "Redact secrets in patches",
|
||||||
|
isOn: viewModel.config.redactionEnabled
|
||||||
|
) { viewModel.setSetting("redaction.enabled", value: $0 ? "true" : "false") }
|
||||||
|
|
||||||
|
ToggleRow(
|
||||||
|
label: "Runtime metadata footer",
|
||||||
|
isOn: viewModel.config.runtimeMetadataFooter
|
||||||
|
) { viewModel.setSetting("agent.runtime_metadata_footer", value: $0 ? "true" : "false") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var backupSection: some View {
|
private var backupSection: some View {
|
||||||
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
|
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@@ -9,25 +9,46 @@ import ScarfCore
|
|||||||
/// (subscription-routed) and `auto` (inherit main provider) — Hermes derives
|
/// (subscription-routed) and `auto` (inherit main provider) — Hermes derives
|
||||||
/// the gateway routing from that single field; there is no separate
|
/// the gateway routing from that single field; there is no separate
|
||||||
/// `use_gateway` key to write.
|
/// `use_gateway` key to write.
|
||||||
|
///
|
||||||
|
/// v0.12 dropped the `flush_memories` aux task on the server side and
|
||||||
|
/// added `curator` (the autonomous skill-maintenance review fork). The
|
||||||
|
/// Curator row only appears when `HermesCapabilities.hasCuratorAux` is
|
||||||
|
/// set; the Flush Memories row only appears when
|
||||||
|
/// `HermesCapabilities.hasFlushMemoriesAux` is set (inverse semantics —
|
||||||
|
/// `true` only on pre-v0.12 hosts where the task still exists). v0.11
|
||||||
|
/// users keep their edit surface; v0.12 users never see it.
|
||||||
struct AuxiliaryTab: View {
|
struct AuxiliaryTab: View {
|
||||||
@Bindable var viewModel: SettingsViewModel
|
@Bindable var viewModel: SettingsViewModel
|
||||||
|
|
||||||
@Environment(\.serverContext) private var serverContext
|
@Environment(\.serverContext) private var serverContext
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
@State private var subscription: NousSubscriptionState = .absent
|
@State private var subscription: NousSubscriptionState = .absent
|
||||||
@State private var showNousSignIn: Bool = false
|
@State private var showNousSignIn: Bool = false
|
||||||
|
|
||||||
// Keyed by the config path name — matches `auxiliary.<task>.*` in config.yaml.
|
// Keyed by the config path name — matches `auxiliary.<task>.*` 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"),
|
("vision", "Vision", "eye"),
|
||||||
("web_extract", "Web Extract", "doc.richtext"),
|
("web_extract", "Web Extract", "doc.richtext"),
|
||||||
("compression", "Compression", "arrow.down.right.and.arrow.up.left.circle"),
|
("compression", "Compression", "arrow.down.right.and.arrow.up.left.circle"),
|
||||||
("session_search", "Session Search", "magnifyingglass"),
|
("session_search", "Session Search", "magnifyingglass"),
|
||||||
("skills_hub", "Skills Hub", "books.vertical"),
|
("skills_hub", "Skills Hub", "books.vertical"),
|
||||||
("approval", "Approval", "checkmark.seal"),
|
("approval", "Approval", "checkmark.seal"),
|
||||||
("mcp", "MCP", "puzzlepiece"),
|
("mcp", "MCP", "puzzlepiece")
|
||||||
("flush_memories", "Flush Memories", "trash.slash")
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
private var tasks: [(key: String, title: LocalizedStringKey, icon: String)] {
|
||||||
|
var t = baseTasks
|
||||||
|
if capabilitiesStore?.capabilities.hasFlushMemoriesAux ?? false {
|
||||||
|
t.append(("flush_memories", "Flush Memories", "trash.slash"))
|
||||||
|
}
|
||||||
|
if capabilitiesStore?.capabilities.hasCuratorAux ?? false {
|
||||||
|
t.append(("curator", "Curator", "sparkles"))
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.")
|
Text("Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -95,6 +116,7 @@ struct AuxiliaryTab: View {
|
|||||||
case "approval": return viewModel.config.auxiliary.approval
|
case "approval": return viewModel.config.auxiliary.approval
|
||||||
case "mcp": return viewModel.config.auxiliary.mcp
|
case "mcp": return viewModel.config.auxiliary.mcp
|
||||||
case "flush_memories": return viewModel.config.auxiliary.flushMemories
|
case "flush_memories": return viewModel.config.auxiliary.flushMemories
|
||||||
|
case "curator": return viewModel.config.auxiliary.curator
|
||||||
default: return .empty
|
default: return .empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// v0.12+ direct-URL skill install. Hermes accepts an HTTPS URL pointing
|
||||||
|
/// at a SKILL.md (or a tarball) and installs it under
|
||||||
|
/// `~/.hermes/skills/<category>/<name>/`. Authors who don't ship via a
|
||||||
|
/// registry can use this to share a one-off skill with a single URL.
|
||||||
|
///
|
||||||
|
/// Capability-gated upstream — SkillsView only opens this sheet when
|
||||||
|
/// `HermesCapabilities.hasSkillURLInstall` is true.
|
||||||
|
struct InstallFromURLSheet: View {
|
||||||
|
let viewModel: SkillsViewModel
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var url: String = ""
|
||||||
|
@State private var category: String = ""
|
||||||
|
@State private var nameOverride: String = ""
|
||||||
|
|
||||||
|
/// Loose validity check — accept anything that starts with `https://`
|
||||||
|
/// (HTTP gets blocked because Hermes refuses non-TLS skill URLs by
|
||||||
|
/// default to keep MITM-injected SKILL.md off the host).
|
||||||
|
private var isValid: Bool {
|
||||||
|
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.lowercased().hasPrefix("https://") && trimmed.count > 10
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
|
||||||
|
Text("Install Skill from URL")
|
||||||
|
.scarfStyle(.headline)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||||
|
|
||||||
|
Text("Paste an HTTPS URL pointing at a SKILL.md or a tarball. Hermes downloads, scans, and installs it under `~/.hermes/skills/<category>/<name>/`.")
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("URL")
|
||||||
|
.scarfStyle(.captionUppercase)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
ScarfTextField("https://example.com/path/to/SKILL.md", text: $url)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisclosureGroup("Optional overrides") {
|
||||||
|
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Category")
|
||||||
|
.scarfStyle(.captionUppercase)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
ScarfTextField("e.g. productivity (defaults to `local`)", text: $category)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Skill name")
|
||||||
|
.scarfStyle(.captionUppercase)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
ScarfTextField("Override if SKILL.md has no `name:`", text: $nameOverride)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, ScarfSpace.s2)
|
||||||
|
}
|
||||||
|
.scarfStyle(.body)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.buttonStyle(ScarfGhostButton())
|
||||||
|
Button("Install") {
|
||||||
|
let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let cat = category.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let name = nameOverride.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
viewModel.installFromURL(
|
||||||
|
trimmedURL,
|
||||||
|
categoryOverride: cat.isEmpty ? nil : cat,
|
||||||
|
nameOverride: name.isEmpty ? nil : name
|
||||||
|
)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.buttonStyle(ScarfPrimaryButton())
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(!isValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(ScarfSpace.s5)
|
||||||
|
.frame(width: 460)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,11 @@ struct SkillsView: View {
|
|||||||
/// for the active server. Drives the v2.5 "What's New" pill at
|
/// for the active server. Drives the v2.5 "What's New" pill at
|
||||||
/// the top of the Skills list. Nil before first compute.
|
/// the top of the Skills list. Nil before first compute.
|
||||||
@State private var snapshotDiff: SkillSnapshotDiff?
|
@State private var snapshotDiff: SkillSnapshotDiff?
|
||||||
|
/// Sheet for v0.12 direct-URL skill install. Capability-gated so
|
||||||
|
/// the trigger button only appears on hosts that support it.
|
||||||
|
@State private var showInstallFromURLSheet = false
|
||||||
@Environment(\.serverContext) private var serverContext
|
@Environment(\.serverContext) private var serverContext
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
@State private var currentTab: Tab = .installed
|
@State private var currentTab: Tab = .installed
|
||||||
|
|
||||||
init(context: ServerContext) {
|
init(context: ServerContext) {
|
||||||
@@ -42,7 +46,26 @@ struct SkillsView: View {
|
|||||||
ScarfPageHeader(
|
ScarfPageHeader(
|
||||||
"Skills",
|
"Skills",
|
||||||
subtitle: "Pre-packaged prompt collections the agent can call into. \(viewModel.totalSkillCount) installed."
|
subtitle: "Pre-packaged prompt collections the agent can call into. \(viewModel.totalSkillCount) installed."
|
||||||
)
|
) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.reloadSkills() }
|
||||||
|
} label: {
|
||||||
|
Label("Reload", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(ScarfGhostButton())
|
||||||
|
.help("Re-scan ~/.hermes/skills/ and pick up edits without restarting Hermes")
|
||||||
|
|
||||||
|
if capabilitiesStore?.capabilities.hasSkillURLInstall ?? false {
|
||||||
|
Button {
|
||||||
|
showInstallFromURLSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Install from URL…", systemImage: "link.badge.plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(ScarfPrimaryButton())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
modePicker
|
modePicker
|
||||||
// v2.5 "What's New" pill — only renders when the diff has
|
// v2.5 "What's New" pill — only renders when the diff has
|
||||||
// changes against a non-empty prior snapshot (first launch
|
// changes against a non-empty prior snapshot (first launch
|
||||||
@@ -92,6 +115,9 @@ struct SkillsView: View {
|
|||||||
.task {
|
.task {
|
||||||
recomputeSnapshotDiff()
|
recomputeSnapshotDiff()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showInstallFromURLSheet) {
|
||||||
|
InstallFromURLSheet(viewModel: viewModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the snapshot diff against the active server's last-seen
|
/// Compute the snapshot diff against the active server's last-seen
|
||||||
@@ -186,7 +212,7 @@ struct SkillsView: View {
|
|||||||
ForEach(viewModel.filteredCategories) { category in
|
ForEach(viewModel.filteredCategories) { category in
|
||||||
Section(category.name) {
|
Section(category.name) {
|
||||||
ForEach(category.skills) { skill in
|
ForEach(category.skills) { skill in
|
||||||
Label(skill.name, systemImage: "lightbulb")
|
skillRow(skill)
|
||||||
.tag(skill.id)
|
.tag(skill.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,6 +221,38 @@ struct SkillsView: View {
|
|||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sidebar row with enabled/disabled visual state + pin badge.
|
||||||
|
/// Disabled skills render at .secondary opacity so the user can see
|
||||||
|
/// they exist but Hermes won't load them.
|
||||||
|
@ViewBuilder
|
||||||
|
private func skillRow(_ skill: HermesSkill) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "lightbulb")
|
||||||
|
.frame(width: 14)
|
||||||
|
.foregroundStyle(skill.enabled ? .primary : .secondary)
|
||||||
|
Text(skill.name)
|
||||||
|
.foregroundStyle(skill.enabled ? .primary : .secondary)
|
||||||
|
.strikethrough(!skill.enabled, color: .secondary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if skill.pinned {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(ScarfColor.accent)
|
||||||
|
.help("Pinned by curator")
|
||||||
|
}
|
||||||
|
if !skill.enabled {
|
||||||
|
Text("OFF")
|
||||||
|
.scarfStyle(.captionUppercase)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(ScarfColor.backgroundTertiary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
.help("Disabled in skills.disabled — Hermes won't load this one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var skillDetail: some View {
|
private var skillDetail: some View {
|
||||||
if let skill = viewModel.selectedSkill {
|
if let skill = viewModel.selectedSkill {
|
||||||
|
|||||||
@@ -111,6 +111,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%@ %lld" : {
|
||||||
|
"comment" : "A small, rounded chip displaying a label and value.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$@ %2$lld"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%@ → %@" : {
|
"%@ → %@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -391,6 +403,10 @@
|
|||||||
"comment" : "A label that shows the number of API calls made by a session.",
|
"comment" : "A label that shows the number of API calls made by a session.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"%lld archived skill(s) available — list them with `hermes curator status`." : {
|
||||||
|
"comment" : "A message that shows the number of archived skills available. The argument is the number of archived skills.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"%lld changes" : {
|
"%lld changes" : {
|
||||||
"comment" : "A label showing the number of changes that will be made when installing a template. The argument is the number of changes.",
|
"comment" : "A label showing the number of changes that will be made when installing a template. The argument is the number of changes.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -825,6 +841,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%lld runs" : {
|
||||||
|
"comment" : "A label showing the number of times the curator has run.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"%lld sessions" : {
|
"%lld sessions" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -964,6 +984,18 @@
|
|||||||
},
|
},
|
||||||
"%lld." : {
|
"%lld." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%lld/%lld" : {
|
||||||
|
"comment" : "A label showing the number of attachments and the maximum allowed.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$lld/%2$lld"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"•" : {
|
"•" : {
|
||||||
|
|
||||||
@@ -2809,6 +2841,18 @@
|
|||||||
"comment" : "A description of the dashboard.",
|
"comment" : "A description of the dashboard.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Attach image (%lld/%lld)" : {
|
||||||
|
"comment" : "A button that opens a file picker to select an image to attach.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Attach image (%1$lld/%2$lld)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Auth" : {
|
"Auth" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -3757,6 +3801,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Caching & Redaction" : {
|
||||||
|
"comment" : "Section title for the advanced tab's \"Caching & Redaction\" section.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Call timeout" : {
|
"Call timeout" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -6241,6 +6289,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Create one with `hermes kanban create \"task title\"`. Tasks dispatched by the gateway show up here automatically." : {
|
||||||
|
"comment" : "A description of the Kanban view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Create Profile" : {
|
"Create Profile" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -6525,6 +6577,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Curator" : {
|
||||||
|
"comment" : "Name of the curator task.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Current: %@" : {
|
"Current: %@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -7396,6 +7452,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Disabled in skills.disabled — Hermes won't load this one" : {
|
||||||
|
"comment" : "A tooltip for a disabled skill.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Discard" : {
|
"Discard" : {
|
||||||
"comment" : "A button that discards changes made to the memory.",
|
"comment" : "A button that discards changes made to the memory.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -8291,6 +8351,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Encoding…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"End-to-End Encryption (experimental)" : {
|
"End-to-End Encryption (experimental)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9837,6 +9900,10 @@
|
|||||||
},
|
},
|
||||||
"Hermes" : {
|
"Hermes" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Hermes archives skills the curator decides are stale or redundant. Restoring brings the original SKILL.md back into place — no data lost." : {
|
||||||
|
"comment" : "A description of the curator's `curator restore` action.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"hermes at %@" : {
|
"hermes at %@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -10965,6 +11032,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Install Skill from URL" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Install Template" : {
|
"Install Template" : {
|
||||||
"comment" : "Button prompt to install a template from a file.",
|
"comment" : "Button prompt to install a template from a file.",
|
||||||
@@ -11105,6 +11175,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Kanban" : {
|
||||||
|
"comment" : "\"Kanban\" is a French term for a project management tool.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"keep (not installed by template)" : {
|
"keep (not installed by template)" : {
|
||||||
"comment" : "A description of a file that is not part of the template's installation.",
|
"comment" : "A description of a file that is not part of the template's installation.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -12403,6 +12477,9 @@
|
|||||||
"Message Hermes… / for commands" : {
|
"Message Hermes… / for commands" : {
|
||||||
"comment" : "A placeholder text displayed in the text editor of the Rich Chat input bar.",
|
"comment" : "A placeholder text displayed in the text editor of the Rich Chat input bar.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Message Hermes… / for commands · drag images to attach" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Messages will appear here as the conversation progresses." : {
|
"Messages will appear here as the conversation progresses." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -12460,6 +12537,10 @@
|
|||||||
"comment" : "A heading for the metadata section of the template export sheet.",
|
"comment" : "A heading for the metadata section of the template export sheet.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Microsoft Teams" : {
|
||||||
|
"comment" : "Name of the Microsoft Teams platform.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Migrate" : {
|
"Migrate" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -13662,6 +13743,10 @@
|
|||||||
"comment" : "A description of a tool's permission status.",
|
"comment" : "A description of a tool's permission status.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"No kanban tasks" : {
|
||||||
|
"comment" : "A message displayed when there are no kanban tasks.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No matches for \"%@\"." : {
|
"No matches for \"%@\"." : {
|
||||||
"comment" : "A message that appears when a search yields no results. The argument is the search term.",
|
"comment" : "A message that appears when a search yields no results. The argument is the search term.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -14498,6 +14583,10 @@
|
|||||||
"comment" : "Title of a section in the credential pools view that lists OAuth-authed providers.",
|
"comment" : "Title of a section in the credential pools view that lists OAuth-authed providers.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"OFF" : {
|
||||||
|
"comment" : "A label for a disabled skill.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"OK" : {
|
"OK" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -15082,6 +15171,10 @@
|
|||||||
"comment" : "A heading for optional inclusions in a backup.",
|
"comment" : "A heading for optional inclusions in a backup.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Optional overrides" : {
|
||||||
|
"comment" : "A section that lets you override the category or name of the skill.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Optional. Sets the LLM model for this turn." : {
|
"Optional. Sets the LLM model for this turn." : {
|
||||||
"comment" : "A label for the LLM model override field in the slash command editor.",
|
"comment" : "A label for the LLM model override field in the slash command editor.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -15260,6 +15353,10 @@
|
|||||||
"comment" : "A label for the template's owner and name.",
|
"comment" : "A label for the template's owner and name.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"p%lld" : {
|
||||||
|
"comment" : "A priority indicator. The argument is the priority level.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed." : {
|
"Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed." : {
|
||||||
"comment" : "A description of the benefits of using a Nous",
|
"comment" : "A description of the benefits of using a Nous",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -15351,6 +15448,10 @@
|
|||||||
"comment" : "A description of the URL field in the template installation prompt.",
|
"comment" : "A description of the URL field in the template installation prompt.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Paste an HTTPS URL pointing at a SKILL.md or a tarball. Hermes downloads, scans, and installs it under `~/.hermes/skills/<category>/<name>/`." : {
|
||||||
|
"comment" : "A description of how to install a skill from a URL.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Paste code here…" : {
|
"Paste code here…" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -15867,6 +15968,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Pin skill" : {
|
||||||
|
"comment" : "A tooltip for pinning a skill.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Pinned" : {
|
||||||
|
"comment" : "A button that pins a skill to the user's list of pinned skills.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Pinned by curator" : {
|
||||||
|
"comment" : "A tooltip for a pinned skill.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Pinned skills are never auto-archived or rewritten by the curator." : {
|
||||||
|
"comment" : "A description of pinned skills.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Placeholder shown after `/<name> ` in the menu — e.g. `<focus area>`." : {
|
"Placeholder shown after `/<name> ` in the menu — e.g. `<focus area>`." : {
|
||||||
"comment" : "A description of the placeholder shown after the slash command name in the menu.",
|
"comment" : "A description of the placeholder shown after the slash command name in the menu.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -16858,6 +16975,10 @@
|
|||||||
"comment" : "A tooltip for the \"Re-run\" button.",
|
"comment" : "A tooltip for the \"Re-run\" button.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Re-scan ~/.hermes/skills/ and pick up edits without restarting Hermes" : {
|
||||||
|
"comment" : "A help message for the reload button in the skills view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Read" : {
|
"Read" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -18161,6 +18282,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Restore Archived Skill" : {
|
||||||
|
"comment" : "A title for the curator's restore sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Restore Archived…" : {
|
||||||
|
"comment" : "A button that restores archived skills.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Restore complete" : {
|
"Restore complete" : {
|
||||||
"comment" : "A label that indicates that a restore has completed.",
|
"comment" : "A label that indicates that a restore has completed.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -20973,6 +21102,10 @@
|
|||||||
},
|
},
|
||||||
"sk-…" : {
|
"sk-…" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Skill name" : {
|
||||||
|
"comment" : "A label for the name of a skill.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Skills" : {
|
"Skills" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -23779,6 +23912,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Unpin" : {
|
||||||
|
"comment" : "A button that unpins a pinned skill.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Update" : {
|
"Update" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -25133,6 +25270,10 @@
|
|||||||
"Your tools will now route through your subscription." : {
|
"Your tools will now route through your subscription." : {
|
||||||
"comment" : "A description of the success state of the",
|
"comment" : "A description of the success state of the",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Yuanbao 元宝" : {
|
||||||
|
"comment" : "Name of the Yuanbao platform.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
// Interact
|
// Interact
|
||||||
case chat = "Chat"
|
case chat = "Chat"
|
||||||
case memory = "Memory"
|
case memory = "Memory"
|
||||||
|
case curator = "Curator"
|
||||||
case skills = "Skills"
|
case skills = "Skills"
|
||||||
// Configure (Phase 2/3 additions)
|
// Configure (Phase 2/3 additions)
|
||||||
case platforms = "Platforms"
|
case platforms = "Platforms"
|
||||||
@@ -25,6 +26,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case mcpServers = "MCP Servers"
|
case mcpServers = "MCP Servers"
|
||||||
case gateway = "Gateway"
|
case gateway = "Gateway"
|
||||||
case cron = "Cron"
|
case cron = "Cron"
|
||||||
|
case kanban = "Kanban"
|
||||||
case health = "Health"
|
case health = "Health"
|
||||||
case logs = "Logs"
|
case logs = "Logs"
|
||||||
case settings = "Settings"
|
case settings = "Settings"
|
||||||
@@ -40,6 +42,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .projects: return "Projects"
|
case .projects: return "Projects"
|
||||||
case .chat: return "Chat"
|
case .chat: return "Chat"
|
||||||
case .memory: return "Memory"
|
case .memory: return "Memory"
|
||||||
|
case .curator: return "Curator"
|
||||||
case .skills: return "Skills"
|
case .skills: return "Skills"
|
||||||
case .platforms: return "Platforms"
|
case .platforms: return "Platforms"
|
||||||
case .personalities: return "Personalities"
|
case .personalities: return "Personalities"
|
||||||
@@ -52,6 +55,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .mcpServers: return "MCP Servers"
|
case .mcpServers: return "MCP Servers"
|
||||||
case .gateway: return "Messaging Gateway"
|
case .gateway: return "Messaging Gateway"
|
||||||
case .cron: return "Cron"
|
case .cron: return "Cron"
|
||||||
|
case .kanban: return "Kanban"
|
||||||
case .health: return "Health"
|
case .health: return "Health"
|
||||||
case .logs: return "Logs"
|
case .logs: return "Logs"
|
||||||
case .settings: return "Settings"
|
case .settings: return "Settings"
|
||||||
@@ -67,6 +71,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .projects: return "square.grid.2x2"
|
case .projects: return "square.grid.2x2"
|
||||||
case .chat: return "text.bubble"
|
case .chat: return "text.bubble"
|
||||||
case .memory: return "brain"
|
case .memory: return "brain"
|
||||||
|
case .curator: return "sparkles"
|
||||||
case .skills: return "lightbulb"
|
case .skills: return "lightbulb"
|
||||||
case .platforms: return "dot.radiowaves.left.and.right"
|
case .platforms: return "dot.radiowaves.left.and.right"
|
||||||
case .personalities: return "theatermasks"
|
case .personalities: return "theatermasks"
|
||||||
@@ -79,6 +84,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .mcpServers: return "puzzlepiece.extension"
|
case .mcpServers: return "puzzlepiece.extension"
|
||||||
case .gateway: return "antenna.radiowaves.left.and.right"
|
case .gateway: return "antenna.radiowaves.left.and.right"
|
||||||
case .cron: return "clock.arrow.2.circlepath"
|
case .cron: return "clock.arrow.2.circlepath"
|
||||||
|
case .kanban: return "rectangle.split.3x1"
|
||||||
case .health: return "stethoscope"
|
case .health: return "stethoscope"
|
||||||
case .logs: return "doc.text"
|
case .logs: return "doc.text"
|
||||||
case .settings: return "gearshape"
|
case .settings: return "gearshape"
|
||||||
|
|||||||
@@ -14,21 +14,42 @@ struct SidebarView: View {
|
|||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
@Environment(ServerLiveStatusRegistry.self) private var liveRegistry
|
@Environment(ServerLiveStatusRegistry.self) private var liveRegistry
|
||||||
@Environment(\.serverContext) private var serverContext
|
@Environment(\.serverContext) private var serverContext
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
private static let sections: [Section] = [
|
/// Capability-gated sections. Curator is v0.12+ only; older Hermes
|
||||||
|
/// hosts get the same Interact section minus the Curator row.
|
||||||
|
/// Building the list lazily off the env keeps the sidebar honest
|
||||||
|
/// when the user reconnects to a different-version host.
|
||||||
|
private var sections: [Section] {
|
||||||
|
let caps = capabilitiesStore?.capabilities
|
||||||
|
|
||||||
|
var interact: [SidebarSection] = [.chat, .memory]
|
||||||
|
if caps?.hasCurator ?? false {
|
||||||
|
interact.append(.curator)
|
||||||
|
}
|
||||||
|
interact.append(.skills)
|
||||||
|
|
||||||
|
var manage: [SidebarSection] = [.tools, .mcpServers, .gateway, .cron]
|
||||||
|
if caps?.hasKanban ?? false {
|
||||||
|
manage.append(.kanban)
|
||||||
|
}
|
||||||
|
manage.append(contentsOf: [.health, .logs, .settings])
|
||||||
|
|
||||||
|
return [
|
||||||
Section(title: "Monitor", items: [.dashboard, .insights, .sessions, .activity]),
|
Section(title: "Monitor", items: [.dashboard, .insights, .sessions, .activity]),
|
||||||
Section(title: "Projects", items: [.projects]),
|
Section(title: "Projects", items: [.projects]),
|
||||||
Section(title: "Interact", items: [.chat, .memory, .skills]),
|
Section(title: "Interact", items: interact),
|
||||||
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
|
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
|
||||||
Section(title: "Manage", items: [.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]),
|
Section(title: "Manage", items: manage),
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
header
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
ForEach(Self.sections) { section in
|
ForEach(sections) { section in
|
||||||
sectionView(section)
|
sectionView(section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,12 +196,19 @@ private struct ContextBoundRoot: View {
|
|||||||
@State private var coordinator: AppCoordinator
|
@State private var coordinator: AppCoordinator
|
||||||
@State private var fileWatcher: HermesFileWatcher
|
@State private var fileWatcher: HermesFileWatcher
|
||||||
@State private var chatViewModel: ChatViewModel
|
@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 (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) {
|
init(context: ServerContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
_coordinator = State(initialValue: AppCoordinator())
|
_coordinator = State(initialValue: AppCoordinator())
|
||||||
_fileWatcher = State(initialValue: HermesFileWatcher(context: context))
|
_fileWatcher = State(initialValue: HermesFileWatcher(context: context))
|
||||||
_chatViewModel = State(initialValue: ChatViewModel(context: context))
|
_chatViewModel = State(initialValue: ChatViewModel(context: context))
|
||||||
|
_capabilities = State(initialValue: HermesCapabilitiesStore(context: context))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -209,6 +216,8 @@ private struct ContextBoundRoot: View {
|
|||||||
.environment(coordinator)
|
.environment(coordinator)
|
||||||
.environment(fileWatcher)
|
.environment(fileWatcher)
|
||||||
.environment(chatViewModel)
|
.environment(chatViewModel)
|
||||||
|
.environment(capabilities)
|
||||||
|
.hermesCapabilities(capabilities)
|
||||||
// Per-window title shows which server this window is bound to.
|
// Per-window title shows which server this window is bound to.
|
||||||
// Local: "Scarf — Local". Remote: "Scarf — Mardon Mac Mini".
|
// Local: "Scarf — Local". Remote: "Scarf — Mardon Mac Mini".
|
||||||
// The colored dot lives inside the toolbar switcher; the window
|
// The colored dot lives inside the toolbar switcher; the window
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Testing
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
@testable import scarf
|
@testable import scarf
|
||||||
|
|
||||||
/// Tests that ``CredentialPoolsOAuthGate`` steers each known provider to
|
/// Tests that ``CredentialPoolsOAuthGate`` steers each known provider to
|
||||||
|
|||||||
@@ -55,11 +55,37 @@ import ScarfCore
|
|||||||
#expect(ids.contains("nous"), "Nous Portal must appear after overlay merge")
|
#expect(ids.contains("nous"), "Nous Portal must appear after overlay merge")
|
||||||
#expect(ids.contains("openai-codex"), "OpenAI Codex overlay must appear")
|
#expect(ids.contains("openai-codex"), "OpenAI Codex overlay must appear")
|
||||||
#expect(ids.contains("qwen-oauth"), "Qwen OAuth 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.
|
// Cached providers still present.
|
||||||
#expect(ids.contains("anthropic"))
|
#expect(ids.contains("anthropic"))
|
||||||
#expect(ids.contains("openai"))
|
#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 {
|
@Test func nousPortalSortsFirst() throws {
|
||||||
let path = try writeCacheFixture()
|
let path = try writeCacheFixture()
|
||||||
let service = ModelCatalogService(path: path)
|
let service = ModelCatalogService(path: path)
|
||||||
|
|||||||
Reference in New Issue
Block a user