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 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) {
|
||||||
|
let column = String(cString: name)
|
||||||
|
if column == "reasoning_tokens" {
|
||||||
hasV07Schema = true
|
hasV07Schema = true
|
||||||
return
|
}
|
||||||
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user