diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift index ae1145a..a4eab3d 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift @@ -12,6 +12,12 @@ public struct HermesMessage: Identifiable, Sendable { public let tokenCount: Int? public let finishReason: String? public let reasoning: String? + /// Hermes v2026.4.23+ richer reasoning column. Some providers + /// emit a structured "thinking" payload separate from the + /// classic `reasoning` blob; both can be present on the same + /// message during the v0.10 → v0.11 transition. UI prefers + /// `reasoningContent` when set, falls back to `reasoning`. + public let reasoningContent: String? public init( @@ -25,7 +31,8 @@ public struct HermesMessage: Identifiable, Sendable { timestamp: Date?, tokenCount: Int?, finishReason: String?, - reasoning: String? + reasoning: String?, + reasoningContent: String? = nil ) { self.id = id self.sessionId = sessionId @@ -38,11 +45,25 @@ public struct HermesMessage: Identifiable, Sendable { self.tokenCount = tokenCount self.finishReason = finishReason self.reasoning = reasoning + self.reasoningContent = reasoningContent } public var isUser: Bool { role == "user" } public var isAssistant: Bool { role == "assistant" } public var isToolResult: Bool { role == "tool" } - public var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) } + /// True when ANY reasoning channel has content. UI uses this to + /// decide whether to render the "Thinking…" disclosure. + public var hasReasoning: Bool { + let r = reasoning ?? "" + let rc = reasoningContent ?? "" + return !r.isEmpty || !rc.isEmpty + } + /// Preferred reasoning text for rendering — `reasoningContent` + /// (newer, richer) wins over the legacy `reasoning` blob when + /// both are present. + public var preferredReasoning: String? { + if let rc = reasoningContent, !rc.isEmpty { return rc } + return reasoning + } } public struct HermesToolCall: Identifiable, Sendable, Codable { diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSession.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSession.swift index c661e96..be8ac46 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSession.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSession.swift @@ -21,6 +21,12 @@ public struct HermesSession: Identifiable, Sendable { public let actualCostUSD: Double? public let costStatus: String? public let billingProvider: String? + /// Number of API calls Hermes made for this session (Hermes + /// v2026.4.23+; populated from `sessions.api_call_count`). Distinct + /// from `toolCallCount` — every tool round-trip is a tool call, + /// but each agent reasoning step also costs an API call. `0` on + /// older Hermes hosts that don't have the column. + public let apiCallCount: Int public init( @@ -43,7 +49,8 @@ public struct HermesSession: Identifiable, Sendable { reasoningTokens: Int, actualCostUSD: Double?, costStatus: String?, - billingProvider: String? + billingProvider: String?, + apiCallCount: Int = 0 ) { self.id = id self.source = source @@ -65,6 +72,7 @@ public struct HermesSession: Identifiable, Sendable { self.actualCostUSD = actualCostUSD self.costStatus = costStatus self.billingProvider = billingProvider + self.apiCallCount = apiCallCount } public var isSubagent: Bool { parentSessionId != nil } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift index 6eccb7d..931b2df 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift @@ -47,6 +47,11 @@ public actor HermesDataService { private var db: OpaquePointer? private var hasV07Schema = false + /// True when the connected DB carries the Hermes v2026.4.23+ + /// columns (`sessions.api_call_count`, `messages.reasoning_content`). + /// Detected via PRAGMA table_info in `detectSchema`. Drives + /// optional-column SELECT shape so older DBs keep working. + private var hasV011Schema = false /// Local filesystem path we last opened. For remote contexts this is /// the cached snapshot under `~/Library/Caches/scarf/snapshots//`. private var openedAtPath: String? @@ -170,13 +175,42 @@ public actor HermesDataService { private func detectSchema() { guard let db else { return } + // Sessions schema 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 + if sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK { + defer { sqlite3_finalize(stmt) } + while sqlite3_step(stmt) == SQLITE_ROW { + if let name = sqlite3_column_text(stmt, 1) { + let column = String(cString: name) + if column == "reasoning_tokens" { + hasV07Schema = true + } + if column == "api_call_count" { + hasV011Schema = true + } + } + } + } + // Messages schema — confirm `reasoning_content` exists. We + // upgrade to v0.11 only if BOTH new columns are present so + // partial-migration DBs (sessions migrated, messages not yet) + // don't trigger a "no such column" runtime error on message + // reads. Belt-and-braces. + if hasV011Schema { + var msgStmt: OpaquePointer? + var sawReasoningContent = false + if sqlite3_prepare_v2(db, "PRAGMA table_info(messages)", -1, &msgStmt, nil) == SQLITE_OK { + defer { sqlite3_finalize(msgStmt) } + while sqlite3_step(msgStmt) == SQLITE_ROW { + if let name = sqlite3_column_text(msgStmt, 1), + String(cString: name) == "reasoning_content" { + sawReasoningContent = true + break + } + } + } + if !sawReasoningContent { + hasV011Schema = false } } } @@ -193,6 +227,9 @@ public actor HermesDataService { if hasV07Schema { cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider" } + if hasV011Schema { + cols += ", api_call_count" + } return cols } @@ -251,6 +288,9 @@ public actor HermesDataService { if hasV07Schema { cols += ", reasoning" } + if hasV011Schema { + cols += ", reasoning_content" + } return cols } @@ -273,9 +313,9 @@ public actor HermesDataService { 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" + var msgCols = "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" + if hasV07Schema { msgCols += ", m.reasoning" } + if hasV011Schema { msgCols += ", m.reasoning_content" } let sql = """ SELECT \(msgCols) FROM messages_fts fts @@ -603,7 +643,15 @@ public actor HermesDataService { // MARK: - Row Parsing private func sessionFromRow(_ stmt: OpaquePointer) -> HermesSession { - HermesSession( + // v0.11 column lives at index 20 (after the 16 base + 4 v0.7 + // columns). Read defensively — old DBs that lack the column + // never reach this code path because hasV011Schema gates the + // SELECT shape. + let apiCallCount: Int = { + guard hasV011Schema else { return 0 } + return Int(sqlite3_column_int(stmt, 20)) + }() + return HermesSession( id: columnText(stmt, 0), source: columnText(stmt, 1), userId: columnOptionalText(stmt, 2), @@ -623,13 +671,18 @@ public actor HermesDataService { 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 + billingProvider: hasV07Schema ? columnOptionalText(stmt, 19) : nil, + apiCallCount: apiCallCount ) } private func messageFromRow(_ stmt: OpaquePointer) -> HermesMessage { let toolCallsJSON = columnOptionalText(stmt, 5) let toolCalls = parseToolCalls(toolCallsJSON) + // reasoning lives at index 10 (v0.7+); reasoning_content at 11 + // when v0.11 schema is present. Both columns can carry text + // simultaneously — UI prefers `reasoningContent`. + let reasoningContent: String? = hasV011Schema ? columnOptionalText(stmt, 11) : nil return HermesMessage( id: Int(sqlite3_column_int(stmt, 0)), sessionId: columnText(stmt, 1), @@ -641,7 +694,8 @@ public actor HermesDataService { timestamp: columnDate(stmt, 7), tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil, finishReason: columnOptionalText(stmt, 9), - reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil + reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil, + reasoningContent: reasoningContent ) } diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 184d6d6..76660a5 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -990,7 +990,9 @@ private struct MessageBubble: View { HStack(alignment: .bottom) { if message.isUser { Spacer(minLength: 40) } VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) { - if message.hasReasoning, let r = message.reasoning, !r.isEmpty { + // v2.5: prefer reasoning_content (Hermes v0.11+); + // fall back to legacy reasoning when only it's set. + if message.hasReasoning, let r = message.preferredReasoning, !r.isEmpty { ReasoningDisclosure(reasoning: r) } // Only render the bubble when there's actual text diff --git a/scarf/Scarf iOS/Dashboard/DashboardView.swift b/scarf/Scarf iOS/Dashboard/DashboardView.swift index cdfbd10..2c85c2d 100644 --- a/scarf/Scarf iOS/Dashboard/DashboardView.swift +++ b/scarf/Scarf iOS/Dashboard/DashboardView.swift @@ -220,6 +220,11 @@ struct DashboardView: View { .font(.caption) .foregroundStyle(.secondary) } + if session.apiCallCount > 0 { + Label("\(session.apiCallCount)", systemImage: "network") + .font(.caption2) + .foregroundStyle(.secondary) + } } if let projectName = vm.projectName(for: session) { Label(projectName, systemImage: "folder.fill") diff --git a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift index 29e48f4..ca9848d 100644 --- a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift +++ b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift @@ -95,7 +95,10 @@ struct RichMessageBubble: View { private var reasoningSection: some View { DisclosureGroup { - Text(message.reasoning ?? "") + // v2.5: prefer the v0.11 `reasoning_content` column (newer, + // typically richer); fall back to the legacy `reasoning` + // blob when only it's populated. + Text(message.preferredReasoning ?? "") .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) diff --git a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift index 209ed6a..1f6f8da 100644 --- a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift +++ b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift @@ -233,6 +233,10 @@ struct SessionRow: View { HStack(spacing: 12) { Label("\(session.messageCount)", systemImage: "bubble.left") Label("\(session.toolCallCount)", systemImage: "wrench") + if session.apiCallCount > 0 { + Label("\(session.apiCallCount)", systemImage: "network") + .help("API calls (Hermes v2026.4.23+)") + } if let cost = session.displayCostUSD, cost > 0 { Label(cost.formatted(.currency(code: "USD").precision(.fractionLength(4))), systemImage: "dollarsign.circle") } diff --git a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift index 4dd074b..40cf9db 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift @@ -57,6 +57,11 @@ struct SessionDetailView: View { Label(session.model ?? "unknown", systemImage: "cpu") Label("\(session.messageCount) msgs", systemImage: "bubble.left") Label("\(session.toolCallCount) tools", systemImage: "wrench") + if session.apiCallCount > 0 { + // Hermes v2026.4.23+ — distinct from tool calls; + // every reasoning step costs an API call too. + Label("\(session.apiCallCount) API", systemImage: "network") + } if session.reasoningTokens > 0 { Label("\(session.reasoningTokens) reasoning", systemImage: "brain") }