feat(state.db): reasoning_content + api_call_count (Phase 4)

Hermes v2026.4.23 added two columns to state.db:
- messages.reasoning_content — newer richer reasoning channel some
  providers emit alongside the legacy messages.reasoning blob.
- sessions.api_call_count — distinct from tool_call_count; counts
  per-turn API round-trips so the user can see the cost breakdown.

ScarfCore models:
- HermesMessage gains reasoningContent: String? + computed
  preferredReasoning + updated hasReasoning to consider both
  channels.
- HermesSession gains apiCallCount: Int (default 0 for old hosts).

ScarfCore HermesDataService:
- hasV011Schema flag detects both new columns via PRAGMA
  table_info; only flips true when BOTH are present (partial
  migrations stay on the v0.7 path to avoid runtime "no such
  column" errors).
- sessionColumns / messageColumns / searchMessages SELECT lists
  conditionally append the new columns.
- sessionFromRow / messageFromRow read them defensively (column
  index 20 / 11 respectively when v0.11 schema is on).

UI surfacing:
- Mac SessionDetailView shows "<N> API" label next to msgs/tools
  when apiCallCount > 0.
- Mac Dashboard SessionRow + iOS Dashboard sessionRow add a
  network-icon chip with the API call count.
- Mac RichMessageBubble + iOS MessageBubble switch to
  message.preferredReasoning for the disclosure body.

Verified: ScarfCore + Mac + iOS build. 179/179 ScarfCore tests pass
unchanged (existing tests didn't construct sessions/messages with
the new fields; defaults preserve behaviour).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 09:27:22 +02:00
parent 751c9e6778
commit 8057beb001
8 changed files with 119 additions and 17 deletions
@@ -12,6 +12,12 @@ public struct HermesMessage: Identifiable, Sendable {
public let tokenCount: Int? public let tokenCount: Int?
public let finishReason: String? public let finishReason: String?
public let reasoning: 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( public init(
@@ -25,7 +31,8 @@ public struct HermesMessage: Identifiable, Sendable {
timestamp: Date?, timestamp: Date?,
tokenCount: Int?, tokenCount: Int?,
finishReason: String?, finishReason: String?,
reasoning: String? reasoning: String?,
reasoningContent: String? = nil
) { ) {
self.id = id self.id = id
self.sessionId = sessionId self.sessionId = sessionId
@@ -38,11 +45,25 @@ public struct HermesMessage: Identifiable, Sendable {
self.tokenCount = tokenCount self.tokenCount = tokenCount
self.finishReason = finishReason self.finishReason = finishReason
self.reasoning = reasoning self.reasoning = reasoning
self.reasoningContent = reasoningContent
} }
public var isUser: Bool { role == "user" } public var isUser: Bool { role == "user" }
public var isAssistant: Bool { role == "assistant" } public var isAssistant: Bool { role == "assistant" }
public var isToolResult: Bool { role == "tool" } 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 { public struct HermesToolCall: Identifiable, Sendable, Codable {
@@ -21,6 +21,12 @@ public struct HermesSession: Identifiable, Sendable {
public let actualCostUSD: Double? public let actualCostUSD: Double?
public let costStatus: String? public let costStatus: String?
public let billingProvider: 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( public init(
@@ -43,7 +49,8 @@ public struct HermesSession: Identifiable, Sendable {
reasoningTokens: Int, reasoningTokens: Int,
actualCostUSD: Double?, actualCostUSD: Double?,
costStatus: String?, costStatus: String?,
billingProvider: String? billingProvider: String?,
apiCallCount: Int = 0
) { ) {
self.id = id self.id = id
self.source = source self.source = source
@@ -65,6 +72,7 @@ public struct HermesSession: Identifiable, Sendable {
self.actualCostUSD = actualCostUSD self.actualCostUSD = actualCostUSD
self.costStatus = costStatus self.costStatus = costStatus
self.billingProvider = billingProvider self.billingProvider = billingProvider
self.apiCallCount = apiCallCount
} }
public var isSubagent: Bool { parentSessionId != nil } public var isSubagent: Bool { parentSessionId != nil }
@@ -47,6 +47,11 @@ public actor HermesDataService {
private var db: OpaquePointer? private var db: OpaquePointer?
private var hasV07Schema = false 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 /// Local filesystem path we last opened. For remote contexts this is
/// the cached snapshot under `~/Library/Caches/scarf/snapshots/<id>/`. /// the cached snapshot under `~/Library/Caches/scarf/snapshots/<id>/`.
private var openedAtPath: String? private var openedAtPath: String?
@@ -170,13 +175,42 @@ public actor HermesDataService {
private func detectSchema() { private func detectSchema() {
guard let db else { return } guard let db else { return }
// Sessions schema
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK else { return } if sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK {
defer { sqlite3_finalize(stmt) } defer { sqlite3_finalize(stmt) }
while sqlite3_step(stmt) == SQLITE_ROW { while sqlite3_step(stmt) == SQLITE_ROW {
if let name = sqlite3_column_text(stmt, 1), String(cString: name) == "reasoning_tokens" { if let name = sqlite3_column_text(stmt, 1) {
hasV07Schema = true let column = String(cString: name)
return 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 { if hasV07Schema {
cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider" cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider"
} }
if hasV011Schema {
cols += ", api_call_count"
}
return cols return cols
} }
@@ -251,6 +288,9 @@ public actor HermesDataService {
if hasV07Schema { if hasV07Schema {
cols += ", reasoning" cols += ", reasoning"
} }
if hasV011Schema {
cols += ", reasoning_content"
}
return cols return cols
} }
@@ -273,9 +313,9 @@ public actor HermesDataService {
guard let db else { return [] } guard let db else { return [] }
let sanitized = sanitizeFTSQuery(query) let sanitized = sanitizeFTSQuery(query)
guard !sanitized.isEmpty else { return [] } guard !sanitized.isEmpty else { return [] }
let msgCols = hasV07Schema 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"
? "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" if hasV07Schema { msgCols += ", 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" if hasV011Schema { msgCols += ", m.reasoning_content" }
let sql = """ let sql = """
SELECT \(msgCols) SELECT \(msgCols)
FROM messages_fts fts FROM messages_fts fts
@@ -603,7 +643,15 @@ public actor HermesDataService {
// MARK: - Row Parsing // MARK: - Row Parsing
private func sessionFromRow(_ stmt: OpaquePointer) -> HermesSession { 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), id: columnText(stmt, 0),
source: columnText(stmt, 1), source: columnText(stmt, 1),
userId: columnOptionalText(stmt, 2), userId: columnOptionalText(stmt, 2),
@@ -623,13 +671,18 @@ public actor HermesDataService {
reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0, reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0,
actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil, actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil,
costStatus: hasV07Schema ? columnOptionalText(stmt, 18) : 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 { private func messageFromRow(_ stmt: OpaquePointer) -> HermesMessage {
let toolCallsJSON = columnOptionalText(stmt, 5) let toolCallsJSON = columnOptionalText(stmt, 5)
let toolCalls = parseToolCalls(toolCallsJSON) 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( return HermesMessage(
id: Int(sqlite3_column_int(stmt, 0)), id: Int(sqlite3_column_int(stmt, 0)),
sessionId: columnText(stmt, 1), sessionId: columnText(stmt, 1),
@@ -641,7 +694,8 @@ public actor HermesDataService {
timestamp: columnDate(stmt, 7), timestamp: columnDate(stmt, 7),
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil, 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 reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil,
reasoningContent: reasoningContent
) )
} }
+3 -1
View File
@@ -990,7 +990,9 @@ private struct MessageBubble: View {
HStack(alignment: .bottom) { HStack(alignment: .bottom) {
if message.isUser { Spacer(minLength: 40) } if message.isUser { Spacer(minLength: 40) }
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) { 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) ReasoningDisclosure(reasoning: r)
} }
// Only render the bubble when there's actual text // Only render the bubble when there's actual text
@@ -220,6 +220,11 @@ struct DashboardView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if session.apiCallCount > 0 {
Label("\(session.apiCallCount)", systemImage: "network")
.font(.caption2)
.foregroundStyle(.secondary)
}
} }
if let projectName = vm.projectName(for: session) { if let projectName = vm.projectName(for: session) {
Label(projectName, systemImage: "folder.fill") Label(projectName, systemImage: "folder.fill")
@@ -95,7 +95,10 @@ struct RichMessageBubble: View {
private var reasoningSection: some View { private var reasoningSection: some View {
DisclosureGroup { 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()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.textSelection(.enabled) .textSelection(.enabled)
@@ -233,6 +233,10 @@ struct SessionRow: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Label("\(session.messageCount)", systemImage: "bubble.left") Label("\(session.messageCount)", systemImage: "bubble.left")
Label("\(session.toolCallCount)", systemImage: "wrench") 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 { if let cost = session.displayCostUSD, cost > 0 {
Label(cost.formatted(.currency(code: "USD").precision(.fractionLength(4))), systemImage: "dollarsign.circle") Label(cost.formatted(.currency(code: "USD").precision(.fractionLength(4))), systemImage: "dollarsign.circle")
} }
@@ -57,6 +57,11 @@ struct SessionDetailView: View {
Label(session.model ?? "unknown", systemImage: "cpu") Label(session.model ?? "unknown", systemImage: "cpu")
Label("\(session.messageCount) msgs", systemImage: "bubble.left") Label("\(session.messageCount) msgs", systemImage: "bubble.left")
Label("\(session.toolCallCount) tools", systemImage: "wrench") 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 { if session.reasoningTokens > 0 {
Label("\(session.reasoningTokens) reasoning", systemImage: "brain") Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
} }