mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
6c96fcfa43
Adds a new "Web Tools" Settings tab (between Browser and Voice) with two distinct shapes that share the same chrome: - Pre-v0.13: a single "Backend" picker writing the legacy `web_tools.backend` key (so v0.12 users still configure web tools). - v0.13+: two pickers — Search backend writes `web_tools.search.backend` (SearXNG appears here only — Hermes registers it as a search-only dispatch), Extract backend writes `web_tools.extract.backend`. Capability gate: `hasWebToolsBackendSplit` chooses which shape renders. The tab itself is always visible — pre-v0.13 users would otherwise lose access to the legacy combined-backend picker. Model layer: - `HermesConfig.webToolsBackend` / `webToolsSearchBackend` / `webToolsExtractBackend` — three fields, each round-tripping its own YAML key. Defaults: `duckduckgo` / `duckduckgo` / `reader`. - YAML parser reads all three keys via the existing `str(...)` helper. Pre-v0.13 hosts populate only `webToolsBackend`; the split keys default to the same backend so the picker shows the same value the user already had. TODO markers (WS-7-Q6/Q7) flag the inline backend lists + legacy fallback semantics — verify against `~/.hermes/hermes-agent/ hermes_cli/web_tools.py` during integration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
361 lines
20 KiB
Swift
361 lines
20 KiB
Swift
import Foundation
|
|
import ScarfCore
|
|
import AppKit
|
|
import UniformTypeIdentifiers
|
|
import os
|
|
|
|
@Observable
|
|
final class SettingsViewModel {
|
|
private let logger = Logger(subsystem: "com.scarf", category: "SettingsViewModel")
|
|
let context: ServerContext
|
|
private let fileService: HermesFileService
|
|
|
|
init(context: ServerContext = .local) {
|
|
self.context = context
|
|
self.fileService = HermesFileService(context: context)
|
|
}
|
|
|
|
|
|
var config = HermesConfig.empty
|
|
var gatewayState: GatewayState?
|
|
var hermesRunning = false
|
|
var rawConfigYAML = ""
|
|
var personalities: [String] = []
|
|
// 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 ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts", "piper"]
|
|
var sttProviders = ["local", "groq", "openai", "mistral"]
|
|
var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"]
|
|
var saveMessage: String?
|
|
var isLoading = false
|
|
|
|
func load() {
|
|
isLoading = true
|
|
let svc = fileService
|
|
let ctx = context
|
|
let displayName = ctx.displayName
|
|
let log = logger
|
|
// Heavy load: config + gateway state + isRunning + raw YAML are
|
|
// four sync transport calls. On remote each is a blocking ssh
|
|
// round-trip; doing them on MainActor would beach-ball for ~1s.
|
|
Task.detached { [weak self] in
|
|
let cfg = svc.loadConfig()
|
|
let gw = svc.loadGatewayState()
|
|
let running = svc.isHermesRunning()
|
|
let raw = ctx.readText(ctx.paths.configYAML)
|
|
if raw == nil {
|
|
log.error("Failed to read config.yaml from \(displayName)")
|
|
}
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
self.config = cfg
|
|
self.gatewayState = gw
|
|
self.hermesRunning = running
|
|
self.rawConfigYAML = raw ?? ""
|
|
self.personalities = self.parsePersonalities()
|
|
self.isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Set a scalar config value via `hermes config set <key> <value>` and reload
|
|
/// the config on success so the UI reflects the new state.
|
|
func setSetting(_ key: String, value: String) {
|
|
let result = runHermes(["config", "set", key, value])
|
|
if result.exitCode == 0 {
|
|
saveMessage = "Saved \(key)"
|
|
config = fileService.loadConfig()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
|
self?.saveMessage = nil
|
|
}
|
|
} else {
|
|
logger.warning("hermes config set \(key) failed (exit \(result.exitCode)): \(result.output)")
|
|
saveMessage = "Failed to save \(key)"
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
|
self?.saveMessage = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Model
|
|
|
|
func setModel(_ value: String) { setSetting("model.default", value: value) }
|
|
func setProvider(_ value: String) { setSetting("model.provider", value: value) }
|
|
func setTimezone(_ value: String) { setSetting("timezone", value: value) }
|
|
|
|
// MARK: - Display
|
|
|
|
func setPersonality(_ value: String) { setSetting("display.personality", value: value) }
|
|
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
|
|
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
|
|
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
|
func setInterimAssistantMessages(_ value: Bool) { setSetting("display.interim_assistant_messages", value: value ? "true" : "false") }
|
|
func setSkin(_ value: String) { setSetting("display.skin", value: value) }
|
|
func setDisplayCompact(_ value: Bool) { setSetting("display.compact", value: value ? "true" : "false") }
|
|
func setResumeDisplay(_ value: String) { setSetting("display.resume_display", value: value) }
|
|
func setBellOnComplete(_ value: Bool) { setSetting("display.bell_on_complete", value: value ? "true" : "false") }
|
|
func setInlineDiffs(_ value: Bool) { setSetting("display.inline_diffs", value: value ? "true" : "false") }
|
|
func setToolProgressCommand(_ value: Bool) { setSetting("display.tool_progress_command", value: value ? "true" : "false") }
|
|
func setToolPreviewLength(_ value: Int) { setSetting("display.tool_preview_length", value: String(value)) }
|
|
func setBusyInputMode(_ value: String) { setSetting("display.busy_input_mode", value: value) }
|
|
|
|
// MARK: - Agent
|
|
|
|
func setMaxTurns(_ value: Int) { setSetting("agent.max_turns", value: String(value)) }
|
|
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
|
|
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
|
func setServiceTier(_ value: String) { setSetting("agent.service_tier", value: value) }
|
|
func setGatewayNotifyInterval(_ value: Int) { setSetting("agent.gateway_notify_interval", value: String(value)) }
|
|
func setGatewayTimeout(_ value: Int) { setSetting("agent.gateway_timeout", value: String(value)) }
|
|
func setToolUseEnforcement(_ value: String) { setSetting("agent.tool_use_enforcement", value: value) }
|
|
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
|
|
func setApprovalTimeout(_ value: Int) { setSetting("approvals.timeout", value: String(value)) }
|
|
|
|
// MARK: - Terminal
|
|
|
|
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
|
|
func setTerminalCwd(_ value: String) { setSetting("terminal.cwd", value: value) }
|
|
func setTerminalTimeout(_ value: Int) { setSetting("terminal.timeout", value: String(value)) }
|
|
func setPersistentShell(_ value: Bool) { setSetting("terminal.persistent_shell", value: value ? "true" : "false") }
|
|
func setDockerImage(_ value: String) { setSetting("terminal.docker_image", value: value) }
|
|
func setDockerMountCwd(_ value: Bool) { setSetting("terminal.docker_mount_cwd_to_workspace", value: value ? "true" : "false") }
|
|
func setContainerCPU(_ value: Int) { setSetting("terminal.container_cpu", value: String(value)) }
|
|
func setContainerMemory(_ value: Int) { setSetting("terminal.container_memory", value: String(value)) }
|
|
func setContainerDisk(_ value: Int) { setSetting("terminal.container_disk", value: String(value)) }
|
|
func setContainerPersistent(_ value: Bool) { setSetting("terminal.container_persistent", value: value ? "true" : "false") }
|
|
func setModalImage(_ value: String) { setSetting("terminal.modal_image", value: value) }
|
|
func setModalMode(_ value: String) { setSetting("terminal.modal_mode", value: value) }
|
|
func setDaytonaImage(_ value: String) { setSetting("terminal.daytona_image", value: value) }
|
|
func setSingularityImage(_ value: String) { setSetting("terminal.singularity_image", value: value) }
|
|
|
|
// MARK: - Browser
|
|
|
|
func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) }
|
|
func setBrowserInactivityTimeout(_ value: Int) { setSetting("browser.inactivity_timeout", value: String(value)) }
|
|
func setBrowserCommandTimeout(_ value: Int) { setSetting("browser.command_timeout", value: String(value)) }
|
|
func setBrowserRecordSessions(_ value: Bool) { setSetting("browser.record_sessions", value: value ? "true" : "false") }
|
|
func setBrowserAllowPrivateURLs(_ value: Bool) { setSetting("browser.allow_private_urls", value: value ? "true" : "false") }
|
|
func setCamofoxManagedPersistence(_ value: Bool) { setSetting("browser.camofox.managed_persistence", value: value ? "true" : "false") }
|
|
|
|
// MARK: - Web Tools
|
|
|
|
/// Pre-v0.13 combined backend. Pre-v0.13 hosts read this; v0.13+
|
|
/// hosts read it for back-compat but the WebToolsTab gates writes
|
|
/// on `hasWebToolsBackendSplit` so the tab only writes the split
|
|
/// keys on v0.13.
|
|
func setWebToolsBackend(_ value: String) { setSetting("web_tools.backend", value: value) }
|
|
func setWebToolsSearchBackend(_ value: String) { setSetting("web_tools.search.backend", value: value) }
|
|
func setWebToolsExtractBackend(_ value: String) { setSetting("web_tools.extract.backend", value: value) }
|
|
|
|
// MARK: - Voice / TTS / STT
|
|
|
|
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
|
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
|
func setRecordKey(_ value: String) { setSetting("voice.record_key", value: value) }
|
|
func setMaxRecordingSeconds(_ value: Int) { setSetting("voice.max_recording_seconds", value: String(value)) }
|
|
func setSilenceDuration(_ value: Double) { setSetting("voice.silence_duration", value: String(value)) }
|
|
func setTTSProvider(_ value: String) { setSetting("tts.provider", value: value) }
|
|
func setTTSEdgeVoice(_ value: String) { setSetting("tts.edge.voice", value: value) }
|
|
func setTTSElevenLabsVoiceID(_ value: String) { setSetting("tts.elevenlabs.voice_id", value: value) }
|
|
func setTTSElevenLabsModelID(_ value: String) { setSetting("tts.elevenlabs.model_id", value: value) }
|
|
func setTTSOpenAIModel(_ value: String) { setSetting("tts.openai.model", value: value) }
|
|
func setTTSOpenAIVoice(_ value: String) { setSetting("tts.openai.voice", value: value) }
|
|
func setTTSNeuTTSModel(_ value: String) { setSetting("tts.neutts.model", value: value) }
|
|
func setTTSNeuTTSDevice(_ value: String) { setSetting("tts.neutts.device", value: value) }
|
|
func setSTTEnabled(_ value: Bool) { setSetting("stt.enabled", value: value ? "true" : "false") }
|
|
func setSTTProvider(_ value: String) { setSetting("stt.provider", value: value) }
|
|
func setSTTLocalModel(_ value: String) { setSetting("stt.local.model", value: value) }
|
|
func setSTTLocalLanguage(_ value: String) { setSetting("stt.local.language", value: value) }
|
|
func setSTTOpenAIModel(_ value: String) { setSetting("stt.openai.model", value: value) }
|
|
func setSTTMistralModel(_ value: String) { setSetting("stt.mistral.model", value: value) }
|
|
|
|
// MARK: - Memory
|
|
|
|
func setMemoryEnabled(_ value: Bool) { setSetting("memory.memory_enabled", value: value ? "true" : "false") }
|
|
func setUserProfileEnabled(_ value: Bool) { setSetting("memory.user_profile_enabled", value: value ? "true" : "false") }
|
|
func setMemoryCharLimit(_ value: Int) { setSetting("memory.memory_char_limit", value: String(value)) }
|
|
func setUserCharLimit(_ value: Int) { setSetting("memory.user_char_limit", value: String(value)) }
|
|
func setNudgeInterval(_ value: Int) { setSetting("memory.nudge_interval", value: String(value)) }
|
|
/// Provider switching for external memory plugins. Uses `hermes memory setup/off`
|
|
/// because the CLI wizard runs provider-specific init steps beyond a simple
|
|
/// config.yaml write.
|
|
func setMemoryProvider(_ value: String) {
|
|
if value.isEmpty {
|
|
_ = runHermes(["memory", "off"])
|
|
} else {
|
|
setSetting("memory.provider", value: value)
|
|
}
|
|
config = fileService.loadConfig()
|
|
}
|
|
// Hermes v0.9.0 PR #6995: the key is camelCase in config.yaml (not snake_case like the rest of Hermes).
|
|
func setHonchoInitOnSessionStart(_ value: Bool) { setSetting("honcho.initOnSessionStart", value: value ? "true" : "false") }
|
|
|
|
// MARK: - Auxiliary model sub-tasks
|
|
|
|
func setAuxiliary(_ task: String, field: String, value: String) {
|
|
setSetting("auxiliary.\(task).\(field)", value: value)
|
|
}
|
|
func setAuxiliaryTimeout(_ task: String, value: Int) {
|
|
setSetting("auxiliary.\(task).timeout", value: String(value))
|
|
}
|
|
|
|
// MARK: - Security / Privacy
|
|
|
|
func setRedactSecrets(_ value: Bool) { setSetting("security.redact_secrets", value: value ? "true" : "false") }
|
|
func setRedactPII(_ value: Bool) { setSetting("privacy.redact_pii", value: value ? "true" : "false") }
|
|
func setTirithEnabled(_ value: Bool) { setSetting("security.tirith_enabled", value: value ? "true" : "false") }
|
|
func setTirithPath(_ value: String) { setSetting("security.tirith_path", value: value) }
|
|
func setTirithTimeout(_ value: Int) { setSetting("security.tirith_timeout", value: String(value)) }
|
|
func setTirithFailOpen(_ value: Bool) { setSetting("security.tirith_fail_open", value: value ? "true" : "false") }
|
|
func setBlocklistEnabled(_ value: Bool) { setSetting("security.website_blocklist.enabled", value: value ? "true" : "false") }
|
|
func setHumanDelayMode(_ value: String) { setSetting("human_delay.mode", value: value) }
|
|
func setHumanDelayMinMS(_ value: Int) { setSetting("human_delay.min_ms", value: String(value)) }
|
|
func setHumanDelayMaxMS(_ value: Int) { setSetting("human_delay.max_ms", value: String(value)) }
|
|
|
|
// MARK: - Performance / Advanced
|
|
|
|
func setForceIPv4(_ value: Bool) { setSetting("network.force_ipv4", value: value ? "true" : "false") }
|
|
func setFileReadMaxChars(_ value: Int) { setSetting("file_read_max_chars", value: String(value)) }
|
|
func setCompressionEnabled(_ value: Bool) { setSetting("compression.enabled", value: value ? "true" : "false") }
|
|
func setCompressionThreshold(_ value: Double) { setSetting("compression.threshold", value: String(value)) }
|
|
func setCompressionTargetRatio(_ value: Double) { setSetting("compression.target_ratio", value: String(value)) }
|
|
func setCompressionProtectLastN(_ value: Int) { setSetting("compression.protect_last_n", value: String(value)) }
|
|
func setCheckpointsEnabled(_ value: Bool) { setSetting("checkpoints.enabled", value: value ? "true" : "false") }
|
|
func setCheckpointsMaxSnapshots(_ value: Int) { setSetting("checkpoints.max_snapshots", value: String(value)) }
|
|
func setLoggingLevel(_ value: String) { setSetting("logging.level", value: value) }
|
|
func setLoggingMaxSizeMB(_ value: Int) { setSetting("logging.max_size_mb", value: String(value)) }
|
|
func setLoggingBackupCount(_ value: Int) { setSetting("logging.backup_count", value: String(value)) }
|
|
func setDelegationModel(_ value: String) { setSetting("delegation.model", value: value) }
|
|
func setDelegationProvider(_ value: String) { setSetting("delegation.provider", value: value) }
|
|
func setDelegationBaseURL(_ value: String) { setSetting("delegation.base_url", value: value) }
|
|
func setDelegationMaxIterations(_ value: Int) { setSetting("delegation.max_iterations", value: String(value)) }
|
|
func setCronWrapResponse(_ value: Bool) { setSetting("cron.wrap_response", value: value ? "true" : "false") }
|
|
|
|
// MARK: - Config diagnostics
|
|
|
|
func runConfigCheck() -> String {
|
|
let result = runHermes(["config", "check"])
|
|
return result.output
|
|
}
|
|
|
|
func runConfigMigrate() -> String {
|
|
let result = runHermes(["config", "migrate"])
|
|
config = fileService.loadConfig()
|
|
return result.output
|
|
}
|
|
|
|
// MARK: - Backup & Restore (v0.9.0)
|
|
|
|
var backupInProgress = false
|
|
|
|
func runBackup() {
|
|
backupInProgress = true
|
|
Task.detached { [fileService] in
|
|
let result = fileService.runHermesCLI(args: ["backup"], timeout: 300)
|
|
let zipPath = Self.extractZipPath(from: result.output)
|
|
await MainActor.run {
|
|
self.backupInProgress = false
|
|
if result.exitCode == 0 {
|
|
if let zipPath {
|
|
// NSWorkspace operates on the *local* Mac's filesystem;
|
|
// a remote backup path doesn't exist here, so revealing
|
|
// it would silently no-op (or worse, reveal an
|
|
// unrelated local file with the same path). Surface the
|
|
// remote location in the saveMessage instead.
|
|
if self.context.isRemote {
|
|
self.saveMessage = "Backup saved on \(self.context.displayName): \(zipPath)"
|
|
} else {
|
|
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: zipPath)])
|
|
self.saveMessage = "Backup saved"
|
|
}
|
|
} else {
|
|
self.saveMessage = "Backup complete"
|
|
}
|
|
} else {
|
|
self.saveMessage = "Backup failed"
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
|
self?.saveMessage = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Restore from a backup `.zip`. The path may be local (the user picked
|
|
/// it via `NSOpenPanel` on a local context) or remote (the user typed it
|
|
/// in the remote-path sheet). Either way, the call goes through
|
|
/// `fileService.runHermesCLI`, which is transport-aware — for an SSH
|
|
/// context the `hermes import <path>` command runs on the remote shell
|
|
/// where `<path>` is a remote filesystem path.
|
|
func runRestore(fromPath path: String) {
|
|
backupInProgress = true
|
|
Task.detached { [fileService] in
|
|
let result = fileService.runHermesCLI(args: ["import", path], timeout: 300)
|
|
await MainActor.run {
|
|
self.backupInProgress = false
|
|
self.saveMessage = result.exitCode == 0 ? "Restore complete — restart Scarf" : "Restore failed"
|
|
if result.exitCode == 0 {
|
|
self.load()
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
|
self?.saveMessage = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pull the first absolute `.zip` path out of `hermes backup` stdout.
|
|
/// Hermes prints a line like "Backup saved to /Users/foo/.hermes-backups/hermes-2026-04-14.zip (5.4 MB)".
|
|
nonisolated static func extractZipPath(from output: String) -> String? {
|
|
let pattern = #"(/[^\s]+\.zip)"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
|
let range = NSRange(output.startIndex..., in: output)
|
|
guard let match = regex.firstMatch(in: output, range: range),
|
|
let r = Range(match.range(at: 1), in: output) else { return nil }
|
|
return String(output[r])
|
|
}
|
|
|
|
func openConfigInEditor() {
|
|
// No-op for remote contexts — the file is on the remote host, not
|
|
// this Mac. The Settings tab's in-app editor is the supported way
|
|
// to edit remote configs.
|
|
context.openInLocalEditor(context.paths.configYAML)
|
|
}
|
|
|
|
private func parsePersonalities() -> [String] {
|
|
var names: [String] = []
|
|
var inPersonalities = false
|
|
for line in rawConfigYAML.components(separatedBy: "\n") {
|
|
if line.trimmingCharacters(in: .whitespaces) == "personalities:" && line.hasPrefix(" ") {
|
|
inPersonalities = true
|
|
continue
|
|
}
|
|
if inPersonalities {
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
if trimmed.isEmpty { continue }
|
|
let indent = line.prefix(while: { $0 == " " }).count
|
|
if indent <= 2 && !trimmed.isEmpty {
|
|
inPersonalities = false
|
|
continue
|
|
}
|
|
if indent == 4 && trimmed.contains(":") {
|
|
let name = String(trimmed.split(separator: ":")[0])
|
|
names.append(name)
|
|
}
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
@discardableResult
|
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
|
context.runHermes(arguments)
|
|
}
|
|
}
|