mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Add Hermes v0.7.0 compatibility: reasoning tokens, cost tracking, schema detection
- Auto-detect v0.7.0 database schema with backward compat for older DBs - Surface reasoning tokens, actual cost, and billing provider from sessions - Display model reasoning/thinking content in session message bubbles - Add cost tracking to Dashboard, Insights, and session detail views - Fix FTS5 search crash on dotted terms (e.g., "config.yaml", "v0.7.0") - Add missing platforms: Home Assistant, Webhook, Matrix - Consolidate platform icon mapping into shared KnownPlatforms.icon(for:) - Map execute_code tool to ToolKind.execute - Add Settings UI for reasoning effort, approval mode, show cost - Show memory provider warning when external provider (Honcho) is active - Replace fragile manual HermesSession init with withTitle() helper - De-duplicate formatTokens utility function - Bump version to 1.4.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,19 +19,19 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Dashboard** — System health, token usage, recent sessions with live refresh
|
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
|
||||||
- **Insights** — Usage analytics with token breakdown, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
|
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
|
||||||
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, full-text search, rename, delete, and JSONL export
|
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export
|
||||||
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments
|
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments
|
||||||
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls
|
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls
|
||||||
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh
|
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, etc.)
|
||||||
- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher
|
- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher
|
||||||
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, etc.) with toggle switches, MCP server status
|
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix) with toggle switches, MCP server status
|
||||||
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
||||||
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
||||||
- **Log Viewer** — Real-time log tailing with level filtering and text search
|
- **Log Viewer** — Real-time log tailing with level filtering and text search
|
||||||
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
||||||
- **Settings** — Structured config editor for all Hermes settings
|
- **Settings** — Structured config editor for all Hermes settings including reasoning effort, approval mode, cost display, and more
|
||||||
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -42,12 +42,12 @@
|
|||||||
|
|
||||||
### Compatibility
|
### Compatibility
|
||||||
|
|
||||||
Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Tested and verified against:
|
Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions.
|
||||||
|
|
||||||
| Hermes Version | Status |
|
| Hermes Version | Status |
|
||||||
|----------------|--------|
|
|----------------|--------|
|
||||||
| v0.6.0 (2026-03-30) | Verified |
|
| v0.6.0 (2026-03-30) | Verified |
|
||||||
| v0.6.0 (2026-03-31, latest) | Verified |
|
| v0.7.0 (2026-04-03, latest) | Verified |
|
||||||
|
|
||||||
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
||||||
|
|
||||||
|
|||||||
@@ -407,7 +407,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -443,7 +443,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -475,11 +475,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -496,11 +496,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -516,10 +516,10 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -535,10 +535,10 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ struct HermesConfig: Sendable {
|
|||||||
var verbose: Bool
|
var verbose: Bool
|
||||||
var autoTTS: Bool
|
var autoTTS: Bool
|
||||||
var silenceThreshold: Int
|
var silenceThreshold: Int
|
||||||
|
var reasoningEffort: String
|
||||||
|
var showCost: Bool
|
||||||
|
var approvalMode: String
|
||||||
|
var browserBackend: String
|
||||||
|
var memoryProvider: String
|
||||||
|
|
||||||
static let empty = HermesConfig(
|
static let empty = HermesConfig(
|
||||||
model: "unknown",
|
model: "unknown",
|
||||||
@@ -30,7 +35,12 @@ struct HermesConfig: Sendable {
|
|||||||
showReasoning: false,
|
showReasoning: false,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
autoTTS: true,
|
autoTTS: true,
|
||||||
silenceThreshold: 200
|
silenceThreshold: 200,
|
||||||
|
reasoningEffort: "medium",
|
||||||
|
showCost: false,
|
||||||
|
approvalMode: "manual",
|
||||||
|
browserBackend: "",
|
||||||
|
memoryProvider: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ struct HermesMessage: Identifiable, Sendable {
|
|||||||
let timestamp: Date?
|
let timestamp: Date?
|
||||||
let tokenCount: Int?
|
let tokenCount: Int?
|
||||||
let finishReason: String?
|
let finishReason: String?
|
||||||
|
let reasoning: String?
|
||||||
|
|
||||||
var isUser: Bool { role == "user" }
|
var isUser: Bool { role == "user" }
|
||||||
var isAssistant: Bool { role == "assistant" }
|
var isAssistant: Bool { role == "assistant" }
|
||||||
var isToolResult: Bool { role == "tool" }
|
var isToolResult: Bool { role == "tool" }
|
||||||
|
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HermesToolCall: Identifiable, Sendable, Codable {
|
struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||||
@@ -61,7 +63,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
|||||||
switch functionName {
|
switch functionName {
|
||||||
case "read_file", "search_files", "vision_analyze": return .read
|
case "read_file", "search_files", "vision_analyze": return .read
|
||||||
case "write_file", "patch": return .edit
|
case "write_file", "patch": return .edit
|
||||||
case "terminal": return .execute
|
case "terminal", "execute_code": return .execute
|
||||||
case "web_search", "web_extract": return .fetch
|
case "web_search", "web_extract": return .fetch
|
||||||
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
|
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
|
||||||
default: return .other
|
default: return .other
|
||||||
|
|||||||
@@ -17,8 +17,16 @@ struct HermesSession: Identifiable, Sendable {
|
|||||||
let cacheReadTokens: Int
|
let cacheReadTokens: Int
|
||||||
let cacheWriteTokens: Int
|
let cacheWriteTokens: Int
|
||||||
let estimatedCostUSD: Double?
|
let estimatedCostUSD: Double?
|
||||||
|
let reasoningTokens: Int
|
||||||
|
let actualCostUSD: Double?
|
||||||
|
let costStatus: String?
|
||||||
|
let billingProvider: String?
|
||||||
|
|
||||||
var totalTokens: Int { inputTokens + outputTokens }
|
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
|
||||||
|
|
||||||
|
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
|
||||||
|
|
||||||
|
var costIsActual: Bool { actualCostUSD != nil }
|
||||||
|
|
||||||
var duration: TimeInterval? {
|
var duration: TimeInterval? {
|
||||||
guard let start = startedAt, let end = endedAt else { return nil }
|
guard let start = startedAt, let end = endedAt else { return nil }
|
||||||
@@ -30,13 +38,20 @@ struct HermesSession: Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sourceIcon: String {
|
var sourceIcon: String {
|
||||||
switch source {
|
KnownPlatforms.icon(for: source)
|
||||||
case "cli": return "terminal"
|
|
||||||
case "telegram": return "paperplane"
|
|
||||||
case "discord": return "bubble.left.and.bubble.right"
|
|
||||||
case "slack": return "number"
|
|
||||||
case "email": return "envelope"
|
|
||||||
default: return "bubble.left"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withTitle(_ newTitle: String) -> HermesSession {
|
||||||
|
HermesSession(
|
||||||
|
id: id, source: source, userId: userId, model: model,
|
||||||
|
title: newTitle, parentSessionId: parentSessionId,
|
||||||
|
startedAt: startedAt, endedAt: endedAt, endReason: endReason,
|
||||||
|
messageCount: messageCount, toolCallCount: toolCallCount,
|
||||||
|
inputTokens: inputTokens, outputTokens: outputTokens,
|
||||||
|
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
|
||||||
|
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
|
||||||
|
actualCostUSD: actualCostUSD, costStatus: costStatus,
|
||||||
|
billingProvider: billingProvider
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,5 +25,24 @@ enum KnownPlatforms {
|
|||||||
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
|
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
|
||||||
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
|
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
|
||||||
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
|
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
|
||||||
|
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
|
||||||
|
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
|
||||||
|
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
static func icon(for platform: String) -> String {
|
||||||
|
switch platform {
|
||||||
|
case "cli": return "terminal"
|
||||||
|
case "telegram": return "paperplane"
|
||||||
|
case "discord": return "bubble.left.and.bubble.right"
|
||||||
|
case "slack": return "number"
|
||||||
|
case "whatsapp": return "phone.bubble"
|
||||||
|
case "signal": return "lock.shield"
|
||||||
|
case "email": return "envelope"
|
||||||
|
case "homeassistant": return "house"
|
||||||
|
case "webhook": return "arrow.up.right.square"
|
||||||
|
case "matrix": return "lock.rectangle.stack"
|
||||||
|
default: return "bubble.left"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SQLite3
|
|||||||
|
|
||||||
actor HermesDataService {
|
actor HermesDataService {
|
||||||
private var db: OpaquePointer?
|
private var db: OpaquePointer?
|
||||||
|
private var hasV07Schema = false
|
||||||
|
|
||||||
func open() -> Bool {
|
func open() -> Bool {
|
||||||
let path = HermesPaths.stateDB
|
let path = HermesPaths.stateDB
|
||||||
@@ -14,6 +15,7 @@ actor HermesDataService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
||||||
|
detectSchema()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,17 +26,39 @@ actor HermesDataService {
|
|||||||
db = nil
|
db = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
// MARK: - Schema Detection
|
||||||
guard let db else { return [] }
|
|
||||||
let sql = """
|
private func detectSchema() {
|
||||||
SELECT id, source, user_id, model, title, parent_session_id,
|
guard let db else { return }
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Queries
|
||||||
|
|
||||||
|
private var sessionColumns: String {
|
||||||
|
var cols = """
|
||||||
|
id, source, user_id, model, title, parent_session_id,
|
||||||
started_at, ended_at, end_reason, message_count, tool_call_count,
|
started_at, ended_at, end_reason, message_count, tool_call_count,
|
||||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||||
estimated_cost_usd
|
estimated_cost_usd
|
||||||
FROM sessions
|
|
||||||
ORDER BY started_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
"""
|
"""
|
||||||
|
if hasV07Schema {
|
||||||
|
cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider"
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||||
|
guard let db else { return [] }
|
||||||
|
let sql = "SELECT \(sessionColumns) FROM sessions ORDER BY started_at DESC LIMIT ?"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
@@ -47,15 +71,37 @@ actor HermesDataService {
|
|||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||||
|
guard let db else { return [] }
|
||||||
|
let sql = "SELECT \(sessionColumns) FROM sessions WHERE started_at >= ? ORDER BY started_at DESC"
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
|
||||||
|
var sessions: [HermesSession] = []
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
sessions.append(sessionFromRow(stmt!))
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message Queries
|
||||||
|
|
||||||
|
private var messageColumns: String {
|
||||||
|
var cols = """
|
||||||
|
id, session_id, role, content, tool_call_id, tool_calls,
|
||||||
|
tool_name, timestamp, token_count, finish_reason
|
||||||
|
"""
|
||||||
|
if hasV07Schema {
|
||||||
|
cols += ", reasoning"
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
func fetchMessages(sessionId: String) -> [HermesMessage] {
|
func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
|
||||||
tool_name, timestamp, token_count, finish_reason
|
|
||||||
FROM messages
|
|
||||||
WHERE session_id = ?
|
|
||||||
ORDER BY timestamp ASC
|
|
||||||
"""
|
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
@@ -70,9 +116,13 @@ actor HermesDataService {
|
|||||||
|
|
||||||
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
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"
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls,
|
SELECT \(msgCols)
|
||||||
m.tool_name, m.timestamp, m.token_count, m.finish_reason
|
|
||||||
FROM messages_fts fts
|
FROM messages_fts fts
|
||||||
JOIN messages m ON m.id = fts.rowid
|
JOIN messages m ON m.id = fts.rowid
|
||||||
WHERE messages_fts MATCH ?
|
WHERE messages_fts MATCH ?
|
||||||
@@ -82,7 +132,7 @@ actor HermesDataService {
|
|||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
sqlite3_bind_text(stmt, 1, query, -1, sqliteTransient)
|
sqlite3_bind_text(stmt, 1, sanitized, -1, sqliteTransient)
|
||||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||||
|
|
||||||
var messages: [HermesMessage] = []
|
var messages: [HermesMessage] = []
|
||||||
@@ -95,8 +145,7 @@ actor HermesDataService {
|
|||||||
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
SELECT \(messageColumns)
|
||||||
tool_name, timestamp, token_count, finish_reason
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
@@ -142,6 +191,8 @@ actor HermesDataService {
|
|||||||
return previews
|
return previews
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats
|
||||||
|
|
||||||
struct SessionStats: Sendable {
|
struct SessionStats: Sendable {
|
||||||
let totalSessions: Int
|
let totalSessions: Int
|
||||||
let totalMessages: Int
|
let totalMessages: Int
|
||||||
@@ -149,21 +200,35 @@ actor HermesDataService {
|
|||||||
let totalInputTokens: Int
|
let totalInputTokens: Int
|
||||||
let totalOutputTokens: Int
|
let totalOutputTokens: Int
|
||||||
let totalCostUSD: Double
|
let totalCostUSD: Double
|
||||||
|
let totalReasoningTokens: Int
|
||||||
|
let totalActualCostUSD: Double
|
||||||
|
|
||||||
static let empty = SessionStats(
|
static let empty = SessionStats(
|
||||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
|
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0,
|
||||||
|
totalReasoningTokens: 0, totalActualCostUSD: 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchStats() -> SessionStats {
|
func fetchStats() -> SessionStats {
|
||||||
guard let db else { return .empty }
|
guard let db else { return .empty }
|
||||||
let sql = """
|
let sql: String
|
||||||
|
if hasV07Schema {
|
||||||
|
sql = """
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||||
|
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||||
|
COALESCE(SUM(estimated_cost_usd),0),
|
||||||
|
COALESCE(SUM(reasoning_tokens),0), COALESCE(SUM(actual_cost_usd),0)
|
||||||
|
FROM sessions
|
||||||
|
"""
|
||||||
|
} else {
|
||||||
|
sql = """
|
||||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||||
COALESCE(SUM(estimated_cost_usd),0)
|
COALESCE(SUM(estimated_cost_usd),0)
|
||||||
FROM sessions
|
FROM sessions
|
||||||
"""
|
"""
|
||||||
|
}
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
@@ -174,35 +239,14 @@ actor HermesDataService {
|
|||||||
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
|
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
|
||||||
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
|
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
|
||||||
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
|
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
|
||||||
totalCostUSD: sqlite3_column_double(stmt, 5)
|
totalCostUSD: sqlite3_column_double(stmt, 5),
|
||||||
|
totalReasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 6)) : 0,
|
||||||
|
totalActualCostUSD: hasV07Schema ? sqlite3_column_double(stmt, 7) : 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Insights Queries
|
// MARK: - Insights Queries
|
||||||
|
|
||||||
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
|
||||||
guard let db else { return [] }
|
|
||||||
let sql = """
|
|
||||||
SELECT id, source, user_id, model, title, parent_session_id,
|
|
||||||
started_at, ended_at, end_reason, message_count, tool_call_count,
|
|
||||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
||||||
estimated_cost_usd
|
|
||||||
FROM sessions
|
|
||||||
WHERE started_at >= ?
|
|
||||||
ORDER BY started_at DESC
|
|
||||||
"""
|
|
||||||
var stmt: OpaquePointer?
|
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
|
||||||
defer { sqlite3_finalize(stmt) }
|
|
||||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
|
||||||
|
|
||||||
var sessions: [HermesSession] = []
|
|
||||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
|
||||||
sessions.append(sessionFromRow(stmt!))
|
|
||||||
}
|
|
||||||
return sessions
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchUserMessageCount(since: Date) -> Int {
|
func fetchUserMessageCount(since: Date) -> Int {
|
||||||
guard let db else { return 0 }
|
guard let db else { return 0 }
|
||||||
let sql = """
|
let sql = """
|
||||||
@@ -315,7 +359,11 @@ actor HermesDataService {
|
|||||||
outputTokens: Int(sqlite3_column_int(stmt, 12)),
|
outputTokens: Int(sqlite3_column_int(stmt, 12)),
|
||||||
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
|
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
|
||||||
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
|
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
|
||||||
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil
|
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil,
|
||||||
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +380,8 @@ actor HermesDataService {
|
|||||||
toolName: columnOptionalText(stmt, 6),
|
toolName: columnOptionalText(stmt, 6),
|
||||||
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,4 +414,17 @@ actor HermesDataService {
|
|||||||
let value = sqlite3_column_double(stmt, col)
|
let value = sqlite3_column_double(stmt, col)
|
||||||
return Date(timeIntervalSince1970: value)
|
return Date(timeIntervalSince1970: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wraps each whitespace-delimited token in double quotes to prevent FTS5 parse errors
|
||||||
|
/// on terms containing dots, hyphens, or FTS5 operators (e.g., "v0.7.0", "config.yaml").
|
||||||
|
private func sanitizeFTSQuery(_ raw: String) -> String {
|
||||||
|
raw.split(separator: " ")
|
||||||
|
.map { token in
|
||||||
|
let t = String(token)
|
||||||
|
let stripped = t.replacingOccurrences(of: "\"", with: "")
|
||||||
|
return stripped.isEmpty ? nil : "\"\(stripped)\""
|
||||||
|
}
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ struct HermesFileService: Sendable {
|
|||||||
showReasoning: values["display.show_reasoning"] == "true",
|
showReasoning: values["display.show_reasoning"] == "true",
|
||||||
verbose: values["agent.verbose"] == "true",
|
verbose: values["agent.verbose"] == "true",
|
||||||
autoTTS: values["voice.auto_tts"] != "false",
|
autoTTS: values["voice.auto_tts"] != "false",
|
||||||
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold
|
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold,
|
||||||
|
reasoningEffort: values["agent.reasoning_effort"] ?? "medium",
|
||||||
|
showCost: values["display.show_cost"] == "true",
|
||||||
|
approvalMode: values["approvals.mode"] ?? "manual",
|
||||||
|
browserBackend: values["browser.backend"] ?? "",
|
||||||
|
memoryProvider: values["memory.provider"] ?? ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ struct DashboardView: View {
|
|||||||
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
|
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
|
||||||
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
|
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
|
||||||
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
||||||
|
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
|
||||||
|
if cost > 0 {
|
||||||
|
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,14 +94,6 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatTokens(_ count: Int) -> String {
|
|
||||||
if count >= 1_000_000 {
|
|
||||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
|
||||||
} else if count >= 1_000 {
|
|
||||||
return String(format: "%.1fK", Double(count) / 1_000)
|
|
||||||
}
|
|
||||||
return "\(count)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatusCard: View {
|
struct StatusCard: View {
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ struct ModelUsage: Identifiable {
|
|||||||
let outputTokens: Int
|
let outputTokens: Int
|
||||||
let cacheReadTokens: Int
|
let cacheReadTokens: Int
|
||||||
let cacheWriteTokens: Int
|
let cacheWriteTokens: Int
|
||||||
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens }
|
let reasoningTokens: Int
|
||||||
|
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlatformUsage: Identifiable {
|
struct PlatformUsage: Identifiable {
|
||||||
@@ -69,7 +70,9 @@ final class InsightsViewModel {
|
|||||||
var totalOutputTokens = 0
|
var totalOutputTokens = 0
|
||||||
var totalCacheReadTokens = 0
|
var totalCacheReadTokens = 0
|
||||||
var totalCacheWriteTokens = 0
|
var totalCacheWriteTokens = 0
|
||||||
|
var totalReasoningTokens = 0
|
||||||
var totalTokens = 0
|
var totalTokens = 0
|
||||||
|
var totalCost: Double = 0
|
||||||
var activeTime: TimeInterval = 0
|
var activeTime: TimeInterval = 0
|
||||||
var avgSessionDuration: TimeInterval = 0
|
var avgSessionDuration: TimeInterval = 0
|
||||||
|
|
||||||
@@ -119,7 +122,9 @@ final class InsightsViewModel {
|
|||||||
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
|
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
|
||||||
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
||||||
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
|
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
|
||||||
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens
|
totalReasoningTokens = sessions.reduce(0) { $0 + $1.reasoningTokens }
|
||||||
|
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + totalReasoningTokens
|
||||||
|
totalCost = sessions.reduce(0.0) { $0 + ($1.displayCostUSD ?? 0) }
|
||||||
|
|
||||||
var total: TimeInterval = 0
|
var total: TimeInterval = 0
|
||||||
var count = 0
|
var count = 0
|
||||||
@@ -134,21 +139,22 @@ final class InsightsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func computeModelBreakdown() {
|
private func computeModelBreakdown() {
|
||||||
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int)] = [:]
|
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int, reasoning: Int)] = [:]
|
||||||
for s in sessions {
|
for s in sessions {
|
||||||
let model = s.model ?? "unknown"
|
let model = s.model ?? "unknown"
|
||||||
var entry = grouped[model, default: (0, 0, 0, 0, 0)]
|
var entry = grouped[model, default: (0, 0, 0, 0, 0, 0)]
|
||||||
entry.sessions += 1
|
entry.sessions += 1
|
||||||
entry.input += s.inputTokens
|
entry.input += s.inputTokens
|
||||||
entry.output += s.outputTokens
|
entry.output += s.outputTokens
|
||||||
entry.cacheRead += s.cacheReadTokens
|
entry.cacheRead += s.cacheReadTokens
|
||||||
entry.cacheWrite += s.cacheWriteTokens
|
entry.cacheWrite += s.cacheWriteTokens
|
||||||
|
entry.reasoning += s.reasoningTokens
|
||||||
grouped[model] = entry
|
grouped[model] = entry
|
||||||
}
|
}
|
||||||
modelUsage = grouped.map { key, val in
|
modelUsage = grouped.map { key, val in
|
||||||
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
|
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
|
||||||
outputTokens: val.output, cacheReadTokens: val.cacheRead,
|
outputTokens: val.output, cacheReadTokens: val.cacheRead,
|
||||||
cacheWriteTokens: val.cacheWrite)
|
cacheWriteTokens: val.cacheWrite, reasoningTokens: val.reasoning)
|
||||||
}.sorted { $0.totalTokens > $1.totalTokens }
|
}.sorted { $0.totalTokens > $1.totalTokens }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +164,7 @@ final class InsightsViewModel {
|
|||||||
var entry = grouped[s.source, default: (0, 0, 0)]
|
var entry = grouped[s.source, default: (0, 0, 0)]
|
||||||
entry.sessions += 1
|
entry.sessions += 1
|
||||||
entry.messages += s.messageCount
|
entry.messages += s.messageCount
|
||||||
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens
|
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + s.reasoningTokens
|
||||||
grouped[s.source] = entry
|
grouped[s.source] = entry
|
||||||
}
|
}
|
||||||
platformUsage = grouped.map { key, val in
|
platformUsage = grouped.map { key, val in
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ struct InsightsView: View {
|
|||||||
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
|
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
|
||||||
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
|
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
|
||||||
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
||||||
|
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
|
||||||
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
||||||
|
InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost))
|
||||||
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
||||||
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
|
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
|
||||||
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
|
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
|
||||||
@@ -273,19 +275,12 @@ struct InsightsView: View {
|
|||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func platformIcon(_ platform: String) -> String {
|
private func platformIcon(_ platform: String) -> String {
|
||||||
switch platform {
|
KnownPlatforms.icon(for: platform)
|
||||||
case "cli": return "terminal"
|
|
||||||
case "telegram": return "paperplane"
|
|
||||||
case "discord": return "bubble.left.and.bubble.right"
|
|
||||||
case "slack": return "number"
|
|
||||||
case "email": return "envelope"
|
|
||||||
default: return "bubble.left"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func barColor(for toolName: String) -> Color {
|
private func barColor(for toolName: String) -> Color {
|
||||||
switch toolName {
|
switch toolName {
|
||||||
case "terminal": return .orange
|
case "terminal", "execute_code": return .orange
|
||||||
case "read_file", "search_files": return .green
|
case "read_file", "search_files": return .green
|
||||||
case "write_file", "patch": return .blue
|
case "write_file", "patch": return .blue
|
||||||
case "web_search", "web_extract": return .purple
|
case "web_search", "web_extract": return .purple
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ final class MemoryViewModel {
|
|||||||
|
|
||||||
var memoryContent = ""
|
var memoryContent = ""
|
||||||
var userContent = ""
|
var userContent = ""
|
||||||
|
var memoryProvider = ""
|
||||||
var isEditing = false
|
var isEditing = false
|
||||||
var editingFile: EditTarget = .memory
|
var editingFile: EditTarget = .memory
|
||||||
var editText = ""
|
var editText = ""
|
||||||
@@ -17,9 +18,14 @@ final class MemoryViewModel {
|
|||||||
var memoryCharCount: Int { memoryContent.count }
|
var memoryCharCount: Int { memoryContent.count }
|
||||||
var userCharCount: Int { userContent.count }
|
var userCharCount: Int { userContent.count }
|
||||||
|
|
||||||
|
var hasExternalProvider: Bool {
|
||||||
|
!memoryProvider.isEmpty && memoryProvider != "file"
|
||||||
|
}
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
memoryContent = fileService.loadMemory()
|
memoryContent = fileService.loadMemory()
|
||||||
userContent = fileService.loadUserProfile()
|
userContent = fileService.loadUserProfile()
|
||||||
|
memoryProvider = fileService.loadConfig().memoryProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func startEditing(_ target: EditTarget) {
|
func startEditing(_ target: EditTarget) {
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ struct MemoryView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
if viewModel.hasExternalProvider {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
Text("Memory is managed by \(viewModel.memoryProvider). File contents shown here may be stale.")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
|
memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
|
||||||
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
|
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,17 +83,7 @@ final class SessionsViewModel {
|
|||||||
let result = runHermes(["sessions", "rename", sessionId, title])
|
let result = runHermes(["sessions", "rename", sessionId, title])
|
||||||
if result.exitCode == 0 {
|
if result.exitCode == 0 {
|
||||||
if let idx = sessions.firstIndex(where: { $0.id == sessionId }) {
|
if let idx = sessions.firstIndex(where: { $0.id == sessionId }) {
|
||||||
let updated = HermesSession(
|
let updated = sessions[idx].withTitle(title)
|
||||||
id: sessions[idx].id, source: sessions[idx].source,
|
|
||||||
userId: sessions[idx].userId, model: sessions[idx].model,
|
|
||||||
title: title, parentSessionId: sessions[idx].parentSessionId,
|
|
||||||
startedAt: sessions[idx].startedAt, endedAt: sessions[idx].endedAt,
|
|
||||||
endReason: sessions[idx].endReason, messageCount: sessions[idx].messageCount,
|
|
||||||
toolCallCount: sessions[idx].toolCallCount, inputTokens: sessions[idx].inputTokens,
|
|
||||||
outputTokens: sessions[idx].outputTokens, cacheReadTokens: sessions[idx].cacheReadTokens,
|
|
||||||
cacheWriteTokens: sessions[idx].cacheWriteTokens,
|
|
||||||
estimatedCostUSD: sessions[idx].estimatedCostUSD
|
|
||||||
)
|
|
||||||
sessions[idx] = updated
|
sessions[idx] = updated
|
||||||
if selectedSession?.id == sessionId {
|
if selectedSession?.id == sessionId {
|
||||||
selectedSession = updated
|
selectedSession = updated
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ 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.reasoningTokens > 0 {
|
||||||
|
Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
|
||||||
|
}
|
||||||
|
if let cost = session.displayCostUSD {
|
||||||
|
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
|
||||||
|
}
|
||||||
if let date = session.startedAt {
|
if let date = session.startedAt {
|
||||||
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
||||||
}
|
}
|
||||||
@@ -78,6 +84,16 @@ struct MessageBubble: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if message.isUser { Spacer(minLength: 60) }
|
if message.isUser { Spacer(minLength: 60) }
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if message.hasReasoning {
|
||||||
|
DisclosureGroup("Reasoning") {
|
||||||
|
Text(message.reasoning ?? "")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
if !message.content.isEmpty {
|
if !message.content.isEmpty {
|
||||||
Text(message.content)
|
Text(message.content)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ final class SettingsViewModel {
|
|||||||
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
||||||
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||||
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||||
|
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
|
||||||
|
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
||||||
|
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
|
||||||
|
|
||||||
func openConfigInEditor() {
|
func openConfigInEditor() {
|
||||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||||
|
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
|
||||||
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,8 @@ struct SettingsView: View {
|
|||||||
SettingsSection(title: "Terminal", icon: "terminal") {
|
SettingsSection(title: "Terminal", icon: "terminal") {
|
||||||
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
||||||
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
||||||
|
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) }
|
||||||
|
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user