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 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 {
@@ -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 }
@@ -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/<id>/`.
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
)
}