mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user