From ae2872e08f4c8e470fb989fe8c6968b50fa7c1a7 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 4 Apr 2026 21:46:03 -0400 Subject: [PATCH] Add Hermes v0.7.0 compatibility: reasoning tokens, cost tracking, schema detection - Auto-detect v0.7.0 database schema with backward compat for older DBs - Surface reasoning tokens, actual cost, and billing provider from sessions - Display model reasoning/thinking content in session message bubbles - Add cost tracking to Dashboard, Insights, and session detail views - Fix FTS5 search crash on dotted terms (e.g., "config.yaml", "v0.7.0") - Add missing platforms: Home Assistant, Webhook, Matrix - Consolidate platform icon mapping into shared KnownPlatforms.icon(for:) - Map execute_code tool to ToolKind.execute - Add Settings UI for reasoning effort, approval mode, show cost - Show memory provider warning when external provider (Honcho) is active - Replace fragile manual HermesSession init with withTitle() helper - De-duplicate formatTokens utility function - Bump version to 1.4.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 16 +- scarf/scarf.xcodeproj/project.pbxproj | 24 +-- scarf/scarf/Core/Models/HermesConfig.swift | 12 +- scarf/scarf/Core/Models/HermesMessage.swift | 4 +- scarf/scarf/Core/Models/HermesSession.swift | 33 +++- scarf/scarf/Core/Models/HermesTool.swift | 19 ++ .../Core/Services/HermesDataService.swift | 170 ++++++++++++------ .../Core/Services/HermesFileService.swift | 7 +- .../Dashboard/Views/DashboardView.swift | 12 +- .../ViewModels/InsightsViewModel.swift | 18 +- .../Insights/Views/InsightsView.swift | 13 +- .../Memory/ViewModels/MemoryViewModel.swift | 6 + .../Features/Memory/Views/MemoryView.swift | 12 ++ .../ViewModels/SessionsViewModel.swift | 12 +- .../Sessions/Views/SessionDetailView.swift | 16 ++ .../ViewModels/SettingsViewModel.swift | 3 + .../Settings/Views/SettingsView.swift | 3 + 17 files changed, 260 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 5ffc387..3e577b6 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,19 @@ ## Features -- **Dashboard** — System health, token usage, recent sessions with live refresh -- **Insights** — Usage analytics with token breakdown, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time) -- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, full-text search, rename, delete, and JSONL export +- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh +- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time) +- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export - **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments - **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls -- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh +- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, etc.) - **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher -- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, etc.) with toggle switches, MCP server status +- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix) with toggle switches, MCP server status - **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke) - **Cron Manager** — View scheduled jobs, their status, prompts, and output - **Log Viewer** — Real-time log tailing with level filtering and text search - **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically -- **Settings** — Structured config editor for all Hermes settings +- **Settings** — Structured config editor for all Hermes settings including reasoning effort, approval mode, cost display, and more - **Menu Bar** — Status icon showing Hermes running state with quick actions ## Requirements @@ -42,12 +42,12 @@ ### Compatibility -Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Tested and verified against: +Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions. | Hermes Version | Status | |----------------|--------| | v0.6.0 (2026-03-30) | Verified | -| v0.6.0 (2026-03-31, latest) | Verified | +| v0.7.0 (2026-04-03, latest) | Verified | If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings. diff --git a/scarf/scarf.xcodeproj/project.pbxproj b/scarf/scarf.xcodeproj/project.pbxproj index 181dd00..d435013 100644 --- a/scarf/scarf.xcodeproj/project.pbxproj +++ b/scarf/scarf.xcodeproj/project.pbxproj @@ -407,7 +407,7 @@ CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 3Q6X2L86C4; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -421,7 +421,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarf; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -443,7 +443,7 @@ CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 3Q6X2L86C4; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -457,7 +457,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarf; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -475,11 +475,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 3Q6X2L86C4; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -496,11 +496,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 3Q6X2L86C4; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -516,10 +516,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 3Q6X2L86C4; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -535,10 +535,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 3Q6X2L86C4; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/scarf/scarf/Core/Models/HermesConfig.swift b/scarf/scarf/Core/Models/HermesConfig.swift index ca1b964..87b6782 100644 --- a/scarf/scarf/Core/Models/HermesConfig.swift +++ b/scarf/scarf/Core/Models/HermesConfig.swift @@ -15,6 +15,11 @@ struct HermesConfig: Sendable { var verbose: Bool var autoTTS: Bool var silenceThreshold: Int + var reasoningEffort: String + var showCost: Bool + var approvalMode: String + var browserBackend: String + var memoryProvider: String static let empty = HermesConfig( model: "unknown", @@ -30,7 +35,12 @@ struct HermesConfig: Sendable { showReasoning: false, verbose: false, autoTTS: true, - silenceThreshold: 200 + silenceThreshold: 200, + reasoningEffort: "medium", + showCost: false, + approvalMode: "manual", + browserBackend: "", + memoryProvider: "" ) } diff --git a/scarf/scarf/Core/Models/HermesMessage.swift b/scarf/scarf/Core/Models/HermesMessage.swift index 6383e79..2b696f4 100644 --- a/scarf/scarf/Core/Models/HermesMessage.swift +++ b/scarf/scarf/Core/Models/HermesMessage.swift @@ -11,10 +11,12 @@ struct HermesMessage: Identifiable, Sendable { let timestamp: Date? let tokenCount: Int? let finishReason: String? + let reasoning: String? var isUser: Bool { role == "user" } var isAssistant: Bool { role == "assistant" } var isToolResult: Bool { role == "tool" } + var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) } } struct HermesToolCall: Identifiable, Sendable, Codable { @@ -61,7 +63,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable { switch functionName { case "read_file", "search_files", "vision_analyze": return .read case "write_file", "patch": return .edit - case "terminal": return .execute + case "terminal", "execute_code": return .execute case "web_search", "web_extract": return .fetch case "browser_navigate", "browser_click", "browser_screenshot": return .browser default: return .other diff --git a/scarf/scarf/Core/Models/HermesSession.swift b/scarf/scarf/Core/Models/HermesSession.swift index 9d67d23..f293c06 100644 --- a/scarf/scarf/Core/Models/HermesSession.swift +++ b/scarf/scarf/Core/Models/HermesSession.swift @@ -17,8 +17,16 @@ struct HermesSession: Identifiable, Sendable { let cacheReadTokens: Int let cacheWriteTokens: Int let estimatedCostUSD: Double? + let reasoningTokens: Int + let actualCostUSD: Double? + let costStatus: String? + let billingProvider: String? - var totalTokens: Int { inputTokens + outputTokens } + var totalTokens: Int { inputTokens + outputTokens + reasoningTokens } + + var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD } + + var costIsActual: Bool { actualCostUSD != nil } var duration: TimeInterval? { guard let start = startedAt, let end = endedAt else { return nil } @@ -30,13 +38,20 @@ struct HermesSession: Identifiable, Sendable { } var sourceIcon: String { - switch source { - case "cli": return "terminal" - case "telegram": return "paperplane" - case "discord": return "bubble.left.and.bubble.right" - case "slack": return "number" - case "email": return "envelope" - default: return "bubble.left" - } + KnownPlatforms.icon(for: source) + } + + func withTitle(_ newTitle: String) -> HermesSession { + HermesSession( + id: id, source: source, userId: userId, model: model, + title: newTitle, parentSessionId: parentSessionId, + startedAt: startedAt, endedAt: endedAt, endReason: endReason, + messageCount: messageCount, toolCallCount: toolCallCount, + inputTokens: inputTokens, outputTokens: outputTokens, + cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens, + estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens, + actualCostUSD: actualCostUSD, costStatus: costStatus, + billingProvider: billingProvider + ) } } diff --git a/scarf/scarf/Core/Models/HermesTool.swift b/scarf/scarf/Core/Models/HermesTool.swift index e240876..8037b42 100644 --- a/scarf/scarf/Core/Models/HermesTool.swift +++ b/scarf/scarf/Core/Models/HermesTool.swift @@ -25,5 +25,24 @@ enum KnownPlatforms { HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"), HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"), HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"), + HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"), + HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"), + HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"), ] + + static func icon(for platform: String) -> String { + switch platform { + case "cli": return "terminal" + case "telegram": return "paperplane" + case "discord": return "bubble.left.and.bubble.right" + case "slack": return "number" + case "whatsapp": return "phone.bubble" + case "signal": return "lock.shield" + case "email": return "envelope" + case "homeassistant": return "house" + case "webhook": return "arrow.up.right.square" + case "matrix": return "lock.rectangle.stack" + default: return "bubble.left" + } + } } diff --git a/scarf/scarf/Core/Services/HermesDataService.swift b/scarf/scarf/Core/Services/HermesDataService.swift index a10a955..30ffe5c 100644 --- a/scarf/scarf/Core/Services/HermesDataService.swift +++ b/scarf/scarf/Core/Services/HermesDataService.swift @@ -3,6 +3,7 @@ import SQLite3 actor HermesDataService { private var db: OpaquePointer? + private var hasV07Schema = false func open() -> Bool { let path = HermesPaths.stateDB @@ -14,6 +15,7 @@ actor HermesDataService { return false } sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil) + detectSchema() return true } @@ -24,17 +26,39 @@ actor HermesDataService { db = nil } + // MARK: - Schema Detection + + private func detectSchema() { + guard let db else { return } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + while sqlite3_step(stmt) == SQLITE_ROW { + if let name = sqlite3_column_text(stmt, 1), String(cString: name) == "reasoning_tokens" { + hasV07Schema = true + return + } + } + } + + // MARK: - Session Queries + + private var sessionColumns: String { + var cols = """ + id, source, user_id, model, title, parent_session_id, + started_at, ended_at, end_reason, message_count, tool_call_count, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + estimated_cost_usd + """ + if hasV07Schema { + cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider" + } + return cols + } + func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] { guard let db else { return [] } - let sql = """ - SELECT id, source, user_id, model, title, parent_session_id, - started_at, ended_at, end_reason, message_count, tool_call_count, - input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, - estimated_cost_usd - FROM sessions - ORDER BY started_at DESC - LIMIT ? - """ + let sql = "SELECT \(sessionColumns) FROM sessions ORDER BY started_at DESC LIMIT ?" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } defer { sqlite3_finalize(stmt) } @@ -47,15 +71,37 @@ actor HermesDataService { return sessions } + func fetchSessionsInPeriod(since: Date) -> [HermesSession] { + guard let db else { return [] } + let sql = "SELECT \(sessionColumns) FROM sessions WHERE started_at >= ? ORDER BY started_at DESC" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + + var sessions: [HermesSession] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + sessions.append(sessionFromRow(stmt!)) + } + return sessions + } + + // MARK: - Message Queries + + private var messageColumns: String { + var cols = """ + id, session_id, role, content, tool_call_id, tool_calls, + tool_name, timestamp, token_count, finish_reason + """ + if hasV07Schema { + cols += ", reasoning" + } + return cols + } + func fetchMessages(sessionId: String) -> [HermesMessage] { guard let db else { return [] } - let sql = """ - SELECT id, session_id, role, content, tool_call_id, tool_calls, - tool_name, timestamp, token_count, finish_reason - FROM messages - WHERE session_id = ? - ORDER BY timestamp ASC - """ + let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } defer { sqlite3_finalize(stmt) } @@ -70,9 +116,13 @@ actor HermesDataService { func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] { guard let db else { return [] } + let sanitized = sanitizeFTSQuery(query) + guard !sanitized.isEmpty else { return [] } + let msgCols = hasV07Schema + ? "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason, m.reasoning" + : "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason" let sql = """ - SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, - m.tool_name, m.timestamp, m.token_count, m.finish_reason + SELECT \(msgCols) FROM messages_fts fts JOIN messages m ON m.id = fts.rowid WHERE messages_fts MATCH ? @@ -82,7 +132,7 @@ actor HermesDataService { var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } defer { sqlite3_finalize(stmt) } - sqlite3_bind_text(stmt, 1, query, -1, sqliteTransient) + sqlite3_bind_text(stmt, 1, sanitized, -1, sqliteTransient) sqlite3_bind_int(stmt, 2, Int32(limit)) var messages: [HermesMessage] = [] @@ -95,8 +145,7 @@ actor HermesDataService { func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] { guard let db else { return [] } let sql = """ - SELECT id, session_id, role, content, tool_call_id, tool_calls, - tool_name, timestamp, token_count, finish_reason + SELECT \(messageColumns) FROM messages WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != '' ORDER BY timestamp DESC @@ -142,6 +191,8 @@ actor HermesDataService { return previews } + // MARK: - Stats + struct SessionStats: Sendable { let totalSessions: Int let totalMessages: Int @@ -149,21 +200,35 @@ actor HermesDataService { let totalInputTokens: Int let totalOutputTokens: Int let totalCostUSD: Double + let totalReasoningTokens: Int + let totalActualCostUSD: Double static let empty = SessionStats( totalSessions: 0, totalMessages: 0, totalToolCalls: 0, - totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0 + totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0, + totalReasoningTokens: 0, totalActualCostUSD: 0 ) } func fetchStats() -> SessionStats { guard let db else { return .empty } - let sql = """ - SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0), - COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), - COALESCE(SUM(estimated_cost_usd),0) - FROM sessions - """ + let sql: String + if hasV07Schema { + sql = """ + SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0), + COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), + COALESCE(SUM(estimated_cost_usd),0), + COALESCE(SUM(reasoning_tokens),0), COALESCE(SUM(actual_cost_usd),0) + FROM sessions + """ + } else { + sql = """ + SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0), + COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), + COALESCE(SUM(estimated_cost_usd),0) + FROM sessions + """ + } var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty } defer { sqlite3_finalize(stmt) } @@ -174,35 +239,14 @@ actor HermesDataService { totalToolCalls: Int(sqlite3_column_int(stmt, 2)), totalInputTokens: Int(sqlite3_column_int(stmt, 3)), totalOutputTokens: Int(sqlite3_column_int(stmt, 4)), - totalCostUSD: sqlite3_column_double(stmt, 5) + totalCostUSD: sqlite3_column_double(stmt, 5), + totalReasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 6)) : 0, + totalActualCostUSD: hasV07Schema ? sqlite3_column_double(stmt, 7) : 0 ) } // MARK: - Insights Queries - func fetchSessionsInPeriod(since: Date) -> [HermesSession] { - guard let db else { return [] } - let sql = """ - SELECT id, source, user_id, model, title, parent_session_id, - started_at, ended_at, end_reason, message_count, tool_call_count, - input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, - estimated_cost_usd - FROM sessions - WHERE started_at >= ? - ORDER BY started_at DESC - """ - var stmt: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } - defer { sqlite3_finalize(stmt) } - sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) - - var sessions: [HermesSession] = [] - while sqlite3_step(stmt) == SQLITE_ROW { - sessions.append(sessionFromRow(stmt!)) - } - return sessions - } - func fetchUserMessageCount(since: Date) -> Int { guard let db else { return 0 } let sql = """ @@ -315,7 +359,11 @@ actor HermesDataService { outputTokens: Int(sqlite3_column_int(stmt, 12)), cacheReadTokens: Int(sqlite3_column_int(stmt, 13)), cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)), - estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil + estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil, + reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0, + actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil, + costStatus: hasV07Schema ? columnOptionalText(stmt, 18) : nil, + billingProvider: hasV07Schema ? columnOptionalText(stmt, 19) : nil ) } @@ -332,7 +380,8 @@ actor HermesDataService { toolName: columnOptionalText(stmt, 6), timestamp: columnDate(stmt, 7), tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil, - finishReason: columnOptionalText(stmt, 9) + finishReason: columnOptionalText(stmt, 9), + reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil ) } @@ -365,4 +414,17 @@ actor HermesDataService { let value = sqlite3_column_double(stmt, col) return Date(timeIntervalSince1970: value) } + + /// Wraps each whitespace-delimited token in double quotes to prevent FTS5 parse errors + /// on terms containing dots, hyphens, or FTS5 operators (e.g., "v0.7.0", "config.yaml"). + private func sanitizeFTSQuery(_ raw: String) -> String { + raw.split(separator: " ") + .map { token in + let t = String(token) + let stripped = t.replacingOccurrences(of: "\"", with: "") + return stripped.isEmpty ? nil : "\"\(stripped)\"" + } + .compactMap { $0 } + .joined(separator: " ") + } } diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 32be55c..a51a4a5 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -44,7 +44,12 @@ struct HermesFileService: Sendable { showReasoning: values["display.show_reasoning"] == "true", verbose: values["agent.verbose"] == "true", autoTTS: values["voice.auto_tts"] != "false", - silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold + silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold, + reasoningEffort: values["agent.reasoning_effort"] ?? "medium", + showCost: values["display.show_cost"] == "true", + approvalMode: values["approvals.mode"] ?? "manual", + browserBackend: values["browser.backend"] ?? "", + memoryProvider: values["memory.provider"] ?? "" ) } diff --git a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift index 47fc502..8c55cf7 100644 --- a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift +++ b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift @@ -60,6 +60,10 @@ struct DashboardView: View { StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)") StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)") StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens)) + let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD + if cost > 0 { + StatCard(label: "Cost", value: String(format: "$%.2f", cost)) + } } } } @@ -90,14 +94,6 @@ struct DashboardView: View { } } - private func formatTokens(_ count: Int) -> String { - if count >= 1_000_000 { - return String(format: "%.1fM", Double(count) / 1_000_000) - } else if count >= 1_000 { - return String(format: "%.1fK", Double(count) / 1_000) - } - return "\(count)" - } } struct StatusCard: View { diff --git a/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift b/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift index 980f97c..1595094 100644 --- a/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift +++ b/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift @@ -27,7 +27,8 @@ struct ModelUsage: Identifiable { let outputTokens: Int let cacheReadTokens: Int let cacheWriteTokens: Int - var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens } + let reasoningTokens: Int + var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens } } struct PlatformUsage: Identifiable { @@ -69,7 +70,9 @@ final class InsightsViewModel { var totalOutputTokens = 0 var totalCacheReadTokens = 0 var totalCacheWriteTokens = 0 + var totalReasoningTokens = 0 var totalTokens = 0 + var totalCost: Double = 0 var activeTime: TimeInterval = 0 var avgSessionDuration: TimeInterval = 0 @@ -119,7 +122,9 @@ final class InsightsViewModel { totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens } totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens } totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens } - totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + totalReasoningTokens = sessions.reduce(0) { $0 + $1.reasoningTokens } + totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + totalReasoningTokens + totalCost = sessions.reduce(0.0) { $0 + ($1.displayCostUSD ?? 0) } var total: TimeInterval = 0 var count = 0 @@ -134,21 +139,22 @@ final class InsightsViewModel { } private func computeModelBreakdown() { - var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int)] = [:] + var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int, reasoning: Int)] = [:] for s in sessions { let model = s.model ?? "unknown" - var entry = grouped[model, default: (0, 0, 0, 0, 0)] + var entry = grouped[model, default: (0, 0, 0, 0, 0, 0)] entry.sessions += 1 entry.input += s.inputTokens entry.output += s.outputTokens entry.cacheRead += s.cacheReadTokens entry.cacheWrite += s.cacheWriteTokens + entry.reasoning += s.reasoningTokens grouped[model] = entry } modelUsage = grouped.map { key, val in ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input, outputTokens: val.output, cacheReadTokens: val.cacheRead, - cacheWriteTokens: val.cacheWrite) + cacheWriteTokens: val.cacheWrite, reasoningTokens: val.reasoning) }.sorted { $0.totalTokens > $1.totalTokens } } @@ -158,7 +164,7 @@ final class InsightsViewModel { var entry = grouped[s.source, default: (0, 0, 0)] entry.sessions += 1 entry.messages += s.messageCount - entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + s.reasoningTokens grouped[s.source] = entry } platformUsage = grouped.map { key, val in diff --git a/scarf/scarf/Features/Insights/Views/InsightsView.swift b/scarf/scarf/Features/Insights/Views/InsightsView.swift index 8615efd..cc2b0aa 100644 --- a/scarf/scarf/Features/Insights/Views/InsightsView.swift +++ b/scarf/scarf/Features/Insights/Views/InsightsView.swift @@ -50,7 +50,9 @@ struct InsightsView: View { InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens)) InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens)) InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens)) + InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens)) InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens)) + InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost)) InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime)) InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration)) InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count))) @@ -273,19 +275,12 @@ struct InsightsView: View { // MARK: - Helpers private func platformIcon(_ platform: String) -> String { - switch platform { - case "cli": return "terminal" - case "telegram": return "paperplane" - case "discord": return "bubble.left.and.bubble.right" - case "slack": return "number" - case "email": return "envelope" - default: return "bubble.left" - } + KnownPlatforms.icon(for: platform) } private func barColor(for toolName: String) -> Color { switch toolName { - case "terminal": return .orange + case "terminal", "execute_code": return .orange case "read_file", "search_files": return .green case "write_file", "patch": return .blue case "web_search", "web_extract": return .purple diff --git a/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift b/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift index ffcded0..3451b4d 100644 --- a/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift +++ b/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift @@ -6,6 +6,7 @@ final class MemoryViewModel { var memoryContent = "" var userContent = "" + var memoryProvider = "" var isEditing = false var editingFile: EditTarget = .memory var editText = "" @@ -17,9 +18,14 @@ final class MemoryViewModel { var memoryCharCount: Int { memoryContent.count } var userCharCount: Int { userContent.count } + var hasExternalProvider: Bool { + !memoryProvider.isEmpty && memoryProvider != "file" + } + func load() { memoryContent = fileService.loadMemory() userContent = fileService.loadUserProfile() + memoryProvider = fileService.loadConfig().memoryProvider } func startEditing(_ target: EditTarget) { diff --git a/scarf/scarf/Features/Memory/Views/MemoryView.swift b/scarf/scarf/Features/Memory/Views/MemoryView.swift index f1fc32c..287116d 100644 --- a/scarf/scarf/Features/Memory/Views/MemoryView.swift +++ b/scarf/scarf/Features/Memory/Views/MemoryView.swift @@ -7,6 +7,18 @@ struct MemoryView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { + if viewModel.hasExternalProvider { + HStack(spacing: 8) { + Image(systemName: "info.circle") + Text("Memory is managed by \(viewModel.memoryProvider). File contents shown here may be stale.") + } + .font(.caption) + .foregroundStyle(.orange) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory) memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user) } diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index f76ede3..1f16090 100644 --- a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -83,17 +83,7 @@ final class SessionsViewModel { let result = runHermes(["sessions", "rename", sessionId, title]) if result.exitCode == 0 { if let idx = sessions.firstIndex(where: { $0.id == sessionId }) { - let updated = HermesSession( - id: sessions[idx].id, source: sessions[idx].source, - userId: sessions[idx].userId, model: sessions[idx].model, - title: title, parentSessionId: sessions[idx].parentSessionId, - startedAt: sessions[idx].startedAt, endedAt: sessions[idx].endedAt, - endReason: sessions[idx].endReason, messageCount: sessions[idx].messageCount, - toolCallCount: sessions[idx].toolCallCount, inputTokens: sessions[idx].inputTokens, - outputTokens: sessions[idx].outputTokens, cacheReadTokens: sessions[idx].cacheReadTokens, - cacheWriteTokens: sessions[idx].cacheWriteTokens, - estimatedCostUSD: sessions[idx].estimatedCostUSD - ) + let updated = sessions[idx].withTitle(title) sessions[idx] = updated if selectedSession?.id == sessionId { selectedSession = updated diff --git a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift index 8459a34..bb4f0ea 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift @@ -44,6 +44,12 @@ struct SessionDetailView: View { Label(session.model ?? "unknown", systemImage: "cpu") Label("\(session.messageCount) msgs", systemImage: "bubble.left") Label("\(session.toolCallCount) tools", systemImage: "wrench") + if session.reasoningTokens > 0 { + Label("\(session.reasoningTokens) reasoning", systemImage: "brain") + } + if let cost = session.displayCostUSD { + Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle") + } if let date = session.startedAt { Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar") } @@ -78,6 +84,16 @@ struct MessageBubble: View { HStack { if message.isUser { Spacer(minLength: 60) } VStack(alignment: .leading, spacing: 6) { + if message.hasReasoning { + DisclosureGroup("Reasoning") { + Text(message.reasoning ?? "") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + .font(.caption.bold()) + .foregroundStyle(.orange) + } if !message.content.isEmpty { Text(message.content) .textSelection(.enabled) diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index 0ecbf89..b1278f7 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -52,6 +52,9 @@ final class SettingsViewModel { func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") } func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") } func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) } + func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) } + func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") } + func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) } func openConfigInEditor() { NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML)) diff --git a/scarf/scarf/Features/Settings/Views/SettingsView.swift b/scarf/scarf/Features/Settings/Views/SettingsView.swift index 7369e5b..2815251 100644 --- a/scarf/scarf/Features/Settings/Views/SettingsView.swift +++ b/scarf/scarf/Features/Settings/Views/SettingsView.swift @@ -58,6 +58,7 @@ struct SettingsView: View { } ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) } ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) } + ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) } ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) } } } @@ -68,6 +69,8 @@ struct SettingsView: View { SettingsSection(title: "Terminal", icon: "terminal") { PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) } StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) } + PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) } + PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) } } }