Compare commits

...

22 Commits

Author SHA1 Message Date
Alan Wizemann a13288e759 Merge branch 'main' of https://github.com/awizemann/scarf 2026-03-31 14:07:44 -04:00
Alan Wizemann a16c8ec2d9 Merge branch 'development' 2026-03-31 14:07:17 -04:00
Alan Wizemann 0e3712116f Merge pull request #3 from awizemann/development
Development to Main - New Voice Commands, Health Dashboard, Tools and gateway control
2026-03-31 14:05:40 -04:00
Alan Wizemann ab45f95790 Fix TTS toggle state reversed on voice enable
Hermes auto-enables TTS when voice mode turns on (auto_tts config).
Our ttsEnabled started as false, so the UI showed off when TTS was
actually on. Now reads auto_tts from config.yaml when voice enables
and sets the initial state to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:03:34 -04:00
Alan Wizemann d31bc63b6a Add microphone permission for voice chat
Hermes voice mode needs mic access when running as a Scarf subprocess.
- Added NSMicrophoneUsageDescription to Info.plist keys
- Created entitlements file with com.apple.security.device.audio-input
- Applied to both Debug and Release configurations

macOS will prompt for mic permission on first push-to-talk use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:56:49 -04:00
Alan Wizemann bc8f4b0c25 Add TTS toggle button to voice controls
Voice toolbar now shows three controls when voice is enabled:
- Mic toggle (voice on/off)
- TTS toggle (speaker icon, sends /voice tts)
- Push to Talk (waveform, sends Ctrl+B)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:50:14 -04:00
Alan Wizemann 55ee99c839 Add Hermes version compatibility section to README
Documents tested versions and the interfaces Scarf depends on
(SQLite schema v6, CLI output parsing).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:45:50 -04:00
Alan Wizemann 3477fa733f Redesign Health view with card grid and expandable sections
Replaced the long flat list with a cleaner layout:
- Compact header bar: version, update banner, pass/warn/error counts
- Status/Diagnostics tab switcher (segmented control)
- 2-column card grid: each section is a uniform card showing icon,
  title, and colored status dot counts (green/orange/red)
- Cards have a colored border accent based on worst status
- Click to expand: reveals individual check rows inline
- Only one section expanded at a time for clean scanning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:39:42 -04:00
Alan Wizemann c6f45ac22e Add System Health view with status and diagnostics
New Health section in the Manage group combining hermes status and
hermes doctor output:

- Version header with update available banner (e.g. "47 commits behind")
- Summary badges: passing/warning/issue counts
- Status sections: environment, API keys, auth providers, terminal
  backend, messaging platforms, gateway service, scheduled jobs
- Diagnostics sections: Python environment, required/optional packages,
  config files, directory structure, external tools, API connectivity,
  submodules, tool availability, Skills Hub, Honcho memory
- Each check shows green/orange/red icon with label and detail
- Refresh button to re-run both commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:36:56 -04:00
Alan Wizemann b4c93ac79c Add Gateway Control to README features, architecture, and data sources
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:33:16 -04:00
Alan Wizemann c09f167760 Add Gateway Control Center with service control and pairing management
New Gateway section in the Manage group:
- Service controls: Start/Stop/Restart buttons calling hermes gateway CLI
- Status display: state (running/stopped), PID, loaded indicator, stale
  service warning, exit reason, last update timestamp
- Platform cards: each connected messaging platform with connection state
  (reads from gateway_state.json)
- Pairing management: approved users list with revoke button, pending
  pairing codes with approve button
- Auto-refreshes via HermesFileWatcher when gateway state changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:32:29 -04:00
Alan Wizemann b79200e950 Update README with Tools Manager, session management, and revised docs
- Added Tools Manager to features list
- Updated Sessions Browser with rename/delete/export
- Updated Skills Browser with file switcher
- Updated Dashboard with live refresh
- Updated Log Viewer with text search
- Added hermes tools and hermes sessions to data sources table
- Revised How It Works section to cover management actions
- Updated architecture tree with Tools feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:02:29 -04:00
Alan Wizemann a800a630a8 Fix session rename not updating across views
After rename:
- Update selectedSession so detail header refreshes immediately
- Update sessionPreviews so previewFor() returns the new title
- Dashboard now observes HermesFileWatcher and reloads on DB changes
- Chat session menu reloads via file watcher (persists across nav)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:01:20 -04:00
Alan Wizemann e4d5bb0364 Add session management: rename, delete, export, and stats bar
Sessions browser enhancements:
- Stats bar: total sessions, messages, DB size, per-platform counts
- Right-click context menu on session rows: Rename, Export, Delete
- Detail view actions menu (ellipsis button): same actions
- Rename: sheet with text field, calls hermes sessions rename
- Delete: confirmation dialog, calls hermes sessions delete --yes
- Export single session: NSSavePanel, calls hermes sessions export
- Export all: button in stats bar, exports everything to JSONL
- Session ID shown in detail header for reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:55:35 -04:00
Alan Wizemann 36757a8c9a Add Tool Management panel with per-platform toggle switches
New Tools section in the Manage group:
- Platform tabs parsed from config.yaml (CLI, Telegram, Discord, etc.)
- Lists all toolsets with emoji icon, name, description, and toggle
- Toggle switches call hermes tools enable/disable under the hood
- Shows enabled count vs total
- MCP server status section at bottom
- Optimistic UI update on toggle with CLI fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:47:25 -04:00
Alan Wizemann cfbf3ea142 Update README with Insights, voice controls, and activity filter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:31:50 -04:00
Alan Wizemann f3cb1eb86b Add Insights Dashboard with usage analytics
New sidebar section showing rich analytics from the sessions database:
- Overview grid: sessions, messages, tokens (input/output/cache), active
  time, avg session duration, avg messages per session
- Model breakdown: sessions and total tokens per model
- Platform breakdown: CLI vs Telegram etc with session/message counts
- Top tools bar chart: ranked by call count with percentages
- Activity patterns: day-of-week bars and hourly heatmap
- Notable sessions: longest, most messages, most tokens, most tool calls
  with clickable links to open in Sessions browser
- Time period selector: 7/30/90 days or all time

Also adds ROADMAP.md documenting the full feature expansion plan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:45:35 -04:00
Alan Wizemann 2b57025f3c Merge pull request #1 from awizemann/development
Buy Me a Coffee (seriously, I am tired).
2026-03-31 03:44:49 -04:00
Alan Wizemann 2a14e28589 Fix Buy Me a Coffee button image URL
Use CDN-hosted default button instead of API-generated image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:27:43 -04:00
Alan Wizemann 39bac7d2be Add Buy Me a Coffee button to README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:26:12 -04:00
Alan Wizemann af8e120c9f Remove cost stat card from dashboard
Hermes cost tracking returns $0.00 for models not in its static
pricing table (including claude-haiku-4-5). Token counts remain
displayed since those are always accurate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:12:04 -04:00
Alan Wizemann 0d38856b3e Add session filter to Activity view
Dropdown in the filter bar lets users scope activity to a single
session or view all. Sessions are labeled with their first user
message preview. Combines with the existing tool-kind filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:03:28 -04:00
27 changed files with 2265 additions and 43 deletions
+46 -13
View File
@@ -13,26 +13,42 @@
<img src="https://img.shields.io/badge/macOS-26.2+-blue" alt="macOS">
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br><br>
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
</p>
## Features
- **Dashboard** — System health, token usage, cost tracking, recent sessions at a glance
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, and full-text search (FTS5)
- **Activity Feed** — Recent tool execution log with filtering by kind (read/edit/execute/fetch/browser) and detail inspector
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm)
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live refresh
- **Skills Browser** — Browse all installed skills by category with file content viewer
- **Dashboard** — System health, token usage, 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)
- **Sessions Browser** — Full conversation history with message rendering, 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
- **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
- **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
- **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
- **Log Viewer** — Real-time tailing of error and gateway logs with level filtering
- **Settings** — Read-only config display with raw YAML viewer and Finder path links
- **Log Viewer** — Real-time log tailing with level filtering and text search
- **Settings** — Configuration display with raw YAML viewer and Finder path links
- **Menu Bar** — Status icon showing Hermes running state with quick actions
## Requirements
- macOS 26.2+
- Xcode 26.3+
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) installed at `~/.hermes/`
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/`
### 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:
| Hermes Version | Status |
|----------------|--------|
| v0.6.0 (2026-03-30) | Verified |
| v0.6.0 (2026-03-31, 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.
## Building
@@ -59,11 +75,14 @@ scarf/
Services/ Data access (SQLite reader, file I/O, log tailing, file watcher)
Features/ Self-contained feature modules
Dashboard/ System overview and stats
Sessions/ Conversation browser with detail view
Insights/ Usage analytics and activity patterns
Sessions/ Conversation browser with rename, delete, export
Activity/ Tool execution feed with inspector
Chat/ Embedded terminal via SwiftTerm
Chat/ Embedded terminal via SwiftTerm with voice controls
Memory/ Memory viewer and editor
Skills/ Skill browser by category
Tools/ Toolset management per platform
Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer
Logs/ Real-time log viewer
Settings/ Configuration display
@@ -84,8 +103,12 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `gateway_state.json` | JSON | Read-only |
| `skills/` | Directory tree | Read-only |
| `hermes chat` | Terminal subprocess | Interactive |
| `hermes tools` | CLI commands | Enable/Disable |
| `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke |
The app **never writes** to `state.db` — it opens in read-only mode to avoid WAL contention with Hermes.
The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. Management actions (tool toggles, session rename/delete/export) go through the Hermes CLI.
### Dependencies
@@ -97,7 +120,11 @@ Everything else uses system frameworks: SQLite3 C API, Foundation JSON, Attribut
## How It Works
Scarf is a passive observer. It watches `~/.hermes/` for file changes and polls the SQLite database for new sessions and messages. The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive Hermes CLI experience with proper ANSI rendering.
Scarf watches `~/.hermes/` for file changes and queries the SQLite database for sessions, messages, and analytics. Views refresh automatically when Hermes writes new data.
The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation — switch tabs and come back without losing your conversation.
Management actions (renaming sessions, toggling tools, editing memory) call the Hermes CLI or write directly to the appropriate files, keeping Scarf and Hermes in sync.
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
@@ -111,6 +138,12 @@ Contributions are welcome. Please open an issue to discuss what you'd like to ch
4. Push to the branch (`git push origin feature/my-feature`)
5. Open a Pull Request
## Support
If you find Scarf useful, consider buying me a coffee.
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="40"></a>
## License
[MIT](LICENSE)
+58
View File
@@ -0,0 +1,58 @@
# Scarf — Feature Roadmap
## Tier 1 — High Value, Data Already Available
### 1. Insights Dashboard
Rich usage analytics pulled from the sessions and messages SQLite tables:
- Overview stats: sessions, messages, tool calls, tokens, active time, avg session duration
- Model breakdown: sessions and tokens per model
- Platform breakdown: CLI vs Telegram vs Discord usage
- Top tools chart: ranked tool usage with call counts and percentages
- Activity patterns: sessions by day-of-week, peak hours heatmap
- Notable sessions: longest, most messages, most tokens, most tool calls
- Time period selector: last 7/30/90 days
### 2. Tool Management Panel
- List all toolsets with enabled/disabled status and descriptions
- Toggle switches to enable/disable tools (via `hermes tools enable/disable`)
- Per-platform tool configuration
- MCP tool status
### 3. Session Management Enhancements
- Rename sessions from the Sessions browser (via `hermes sessions rename`)
- Delete sessions (via `hermes sessions delete`)
- Export sessions to JSONL (via `hermes sessions export`)
- Session stats card (total count, DB size, per-platform breakdown)
## Tier 2 — Medium Value, New Service Code Required
### 4. Skills Hub
- Search remote registries for new skills (6 sources)
- Install/uninstall skills from GUI
- Skill update indicator
- Trust level badges (builtin, local, hub)
### 5. Gateway Control Center
- Start/stop/restart gateway from GUI
- Real-time status: PID, uptime, connected platforms
- Pairing management: view approved users, approve/revoke
- Platform status per messaging service
### 6. System Health View
- Mirror `hermes status` and `hermes doctor` output
- API key validation, auth provider status, external tools
- Update available indicator
## Tier 3 — Nice to Have
### 7. Profile Management
- List/create/switch profiles (isolated Hermes instances)
### 8. Plugin Management
- Install from Git, enable/disable, update
### 9. MCP Server Management
- Add/remove/test MCP servers, toggle tools per server
### 10. Config Editor
- Structured form editor for config.yaml with validation
+4
View File
@@ -404,6 +404,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@@ -415,6 +416,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@@ -438,6 +440,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@@ -449,6 +452,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
+8
View File
@@ -16,6 +16,8 @@ struct ContentView: View {
switch coordinator.selectedSection {
case .dashboard:
DashboardView()
case .insights:
InsightsView()
case .sessions:
SessionsView()
case .activity:
@@ -26,8 +28,14 @@ struct ContentView: View {
MemoryView()
case .skills:
SkillsView()
case .tools:
ToolsView()
case .gateway:
GatewayView()
case .cron:
CronView()
case .health:
HealthView()
case .logs:
LogsView()
case .settings:
+3 -1
View File
@@ -13,6 +13,7 @@ struct HermesConfig: Sendable {
var streaming: Bool
var showReasoning: Bool
var verbose: Bool
var autoTTS: Bool
static let empty = HermesConfig(
model: "unknown",
@@ -26,7 +27,8 @@ struct HermesConfig: Sendable {
nudgeInterval: 0,
streaming: true,
showReasoning: false,
verbose: false
verbose: false,
autoTTS: true
)
}
+28
View File
@@ -0,0 +1,28 @@
import Foundation
struct HermesToolset: Identifiable, Sendable {
var id: String { name }
let name: String
let description: String
let icon: String
var enabled: Bool
}
struct HermesToolPlatform: Identifiable, Sendable {
var id: String { name }
let name: String
let displayName: String
let icon: String
}
enum KnownPlatforms {
static let all: [HermesToolPlatform] = [
HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"),
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
]
}
@@ -183,6 +183,112 @@ actor HermesDataService {
)
}
// 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 {
guard let db else { return 0 }
let sql = """
SELECT COUNT(*) FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE m.role = 'user' AND s.started_at >= ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
return Int(sqlite3_column_int(stmt, 0))
}
func fetchToolUsage(since: Date) -> [(name: String, count: Int)] {
guard let db else { return [] }
let sql = """
SELECT m.tool_name, COUNT(*) as cnt
FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.started_at >= ?
GROUP BY m.tool_name
ORDER BY cnt 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 results: [(name: String, count: Int)] = []
while sqlite3_step(stmt) == SQLITE_ROW {
let name = columnText(stmt!, 0)
let count = Int(sqlite3_column_int(stmt!, 1))
results.append((name: name, count: count))
}
return results
}
func fetchSessionStartHours(since: Date) -> [Int: Int] {
guard let db else { return [:] }
let sql = """
SELECT started_at FROM sessions WHERE started_at >= ?
"""
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 hours: [Int: Int] = [:]
let calendar = Calendar.current
while sqlite3_step(stmt) == SQLITE_ROW {
let ts = sqlite3_column_double(stmt!, 0)
let date = Date(timeIntervalSince1970: ts)
let hour = calendar.component(.hour, from: date)
hours[hour, default: 0] += 1
}
return hours
}
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
guard let db else { return [:] }
let sql = """
SELECT started_at FROM sessions WHERE started_at >= ?
"""
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 days: [Int: Int] = [:]
let calendar = Calendar.current
while sqlite3_step(stmt) == SQLITE_ROW {
let ts = sqlite3_column_double(stmt!, 0)
let date = Date(timeIntervalSince1970: ts)
let weekday = (calendar.component(.weekday, from: date) + 5) % 7 // Mon=0
days[weekday, default: 0] += 1
}
return days
}
func stateDBModificationDate() -> Date? {
let walPath = HermesPaths.stateDB + "-wal"
let dbPath = HermesPaths.stateDB
@@ -42,7 +42,8 @@ struct HermesFileService: Sendable {
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
streaming: values["display.streaming"] != "false",
showReasoning: values["display.show_reasoning"] == "true",
verbose: values["agent.verbose"] == "true"
verbose: values["agent.verbose"] == "true",
autoTTS: values["voice.auto_tts"] != "false"
)
}
@@ -6,9 +6,20 @@ final class ActivityViewModel {
var toolMessages: [HermesMessage] = []
var filterKind: ToolKind?
var filterSessionId: String?
var selectedEntry: ActivityEntry?
var sessionPreviews: [String: String] = [:]
var isLoading = true
var availableSessions: [(id: String, label: String)] {
var seen = Set<String>()
return toolMessages.compactMap { message in
guard seen.insert(message.sessionId).inserted else { return nil }
let label = sessionPreviews[message.sessionId] ?? message.sessionId
return (id: message.sessionId, label: label)
}
}
var filteredActivity: [ActivityEntry] {
let entries = toolMessages.flatMap { message in
message.toolCalls.map { call in
@@ -24,10 +35,11 @@ final class ActivityViewModel {
)
}
}
if let filterKind {
return entries.filter { $0.kind == filterKind }
return entries.filter { entry in
let kindOk = filterKind == nil || entry.kind == filterKind
let sessionOk = filterSessionId == nil || entry.sessionId == filterSessionId
return kindOk && sessionOk
}
return entries
}
func load() async {
@@ -38,6 +50,7 @@ final class ActivityViewModel {
return
}
toolMessages = await dataService.fetchRecentToolCalls(limit: 200)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 200)
isLoading = false
}
@@ -21,6 +21,7 @@ struct ActivityView: View {
}
private var filterBar: some View {
HStack(spacing: 12) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(label: "All", isSelected: viewModel.filterKind == nil) {
@@ -32,10 +33,25 @@ struct ActivityView: View {
}
}
}
}
Divider()
.frame(height: 16)
Picker(selection: $viewModel.filterSessionId) {
Text("All Sessions").tag(String?.none)
Divider()
ForEach(viewModel.availableSessions, id: \.id) { session in
Text(session.label)
.lineLimit(1)
.tag(String?.some(session.id))
}
} label: {
EmptyView()
}
.frame(maxWidth: 250)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
private var activityList: some View {
List(selection: Binding(
@@ -5,11 +5,15 @@ import SwiftTerm
@Observable
final class ChatViewModel {
private let dataService = HermesDataService()
private let fileService = HermesFileService()
var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:]
var terminalView: LocalProcessTerminalView?
var hasActiveProcess = false
var voiceEnabled = false
var ttsEnabled = false
var isRecording = false
private var coordinator: Coordinator?
var hermesBinaryExists: Bool {
@@ -17,14 +21,23 @@ final class ChatViewModel {
}
func startNewSession() {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat"])
}
func resumeSession(_ sessionId: String) {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat", "--resume", sessionId])
}
func continueLastSession() {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat", "--continue"])
}
@@ -42,6 +55,38 @@ final class ChatViewModel {
return session.id
}
func toggleVoice() {
guard let tv = terminalView else { return }
if voiceEnabled {
sendToTerminal(tv, text: "/voice off\r")
voiceEnabled = false
isRecording = false
} else {
sendToTerminal(tv, text: "/voice on\r")
voiceEnabled = true
ttsEnabled = fileService.loadConfig().autoTTS
}
}
func toggleTTS() {
guard let tv = terminalView, voiceEnabled else { return }
sendToTerminal(tv, text: "/voice tts\r")
ttsEnabled.toggle()
}
func pushToTalk() {
guard let tv = terminalView, voiceEnabled else { return }
// Ctrl+B = ASCII 0x02
let ctrlB: [UInt8] = [0x02]
tv.send(source: tv, data: ctrlB[0..<1])
isRecording.toggle()
}
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
let bytes = Array(text.utf8)
tv.send(source: tv, data: bytes[0..<bytes.count])
}
private func launchTerminal(arguments: [String]) {
if let existing = terminalView {
existing.terminate()
@@ -55,6 +100,8 @@ final class ChatViewModel {
let coord = Coordinator(onTerminated: { [weak self] in
self?.hasActiveProcess = false
self?.voiceEnabled = false
self?.isRecording = false
})
terminal.processDelegate = coord
self.coordinator = coord
@@ -2,6 +2,7 @@ import SwiftUI
struct ChatView: View {
@Environment(ChatViewModel.self) private var viewModel
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View {
VStack(spacing: 0) {
@@ -11,6 +12,9 @@ struct ChatView: View {
}
.navigationTitle("Chat")
.task { await viewModel.loadRecentSessions() }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.loadRecentSessions() }
}
}
private var toolbar: some View {
@@ -36,6 +40,10 @@ struct ChatView: View {
Spacer()
if viewModel.hasActiveProcess {
voiceControls
}
if !viewModel.hermesBinaryExists {
Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
.font(.caption)
@@ -80,6 +88,55 @@ struct ChatView: View {
.padding(.vertical, 6)
}
private var voiceControls: some View {
HStack(spacing: 8) {
Button {
viewModel.toggleVoice()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash")
.foregroundStyle(viewModel.voiceEnabled ? .green : .secondary)
Text(viewModel.voiceEnabled ? "Voice On" : "Voice Off")
.font(.caption)
.foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.help("Toggle voice mode (/voice)")
if viewModel.voiceEnabled {
Button {
viewModel.toggleTTS()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash")
.foregroundStyle(viewModel.ttsEnabled ? .green : .secondary)
Text(viewModel.ttsEnabled ? "TTS On" : "TTS Off")
.font(.caption)
.foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.help("Toggle text-to-speech (/voice tts)")
Button {
viewModel.pushToTalk()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle")
.foregroundStyle(viewModel.isRecording ? .red : Color.accentColor)
.symbolEffect(.pulse, isActive: viewModel.isRecording)
Text(viewModel.isRecording ? "Recording..." : "Push to Talk")
.font(.caption)
}
}
.buttonStyle(.plain)
.help("Push to talk (Ctrl+B)")
.keyboardShortcut("b", modifiers: .control)
}
}
}
@ViewBuilder
private var terminalArea: some View {
if let terminal = viewModel.terminalView {
@@ -3,6 +3,7 @@ import SwiftUI
struct DashboardView: View {
@State private var viewModel = DashboardViewModel()
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View {
ScrollView {
@@ -16,6 +17,9 @@ struct DashboardView: View {
}
.navigationTitle("Dashboard")
.task { await viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.load() }
}
}
private var statusSection: some View {
@@ -56,7 +60,6 @@ struct DashboardView: View {
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
StatCard(label: "Est. Cost", value: String(format: "$%.2f", viewModel.stats.totalCostUSD))
}
}
}
@@ -0,0 +1,187 @@
import Foundation
struct GatewayInfo {
let pid: Int?
let state: String
let exitReason: String?
let startTime: String?
let updatedAt: String?
let platforms: [PlatformInfo]
let isLoaded: Bool
let isStale: Bool
}
struct PlatformInfo: Identifiable {
var id: String { name }
let name: String
let state: String
let updatedAt: String?
var isConnected: Bool { state == "connected" }
var icon: String {
switch name {
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"
default: return "bubble.left"
}
}
}
struct PairedUser: Identifiable {
var id: String { platform + userId }
let platform: String
let userId: String
let name: String
}
struct PendingPairing: Identifiable {
var id: String { platform + code }
let platform: String
let code: String
}
@Observable
final class GatewayViewModel {
var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false)
var approvedUsers: [PairedUser] = []
var pendingPairings: [PendingPairing] = []
var isLoading = false
var actionMessage: String?
func load() {
isLoading = true
loadGatewayStatus()
loadPairing()
isLoading = false
}
func startGateway() {
runHermes(["gateway", "start"])
actionMessage = "Gateway start requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.loadGatewayStatus()
self?.actionMessage = nil
}
}
func stopGateway() {
runHermes(["gateway", "stop"])
actionMessage = "Gateway stop requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.loadGatewayStatus()
self?.actionMessage = nil
}
}
func restartGateway() {
runHermes(["gateway", "restart"])
actionMessage = "Gateway restart requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.loadGatewayStatus()
self?.actionMessage = nil
}
}
func approvePairing(platform: String, code: String) {
runHermes(["pairing", "approve", platform, code])
loadPairing()
}
func revokeUser(_ user: PairedUser) {
runHermes(["pairing", "revoke", user.platform, user.userId])
approvedUsers.removeAll { $0.id == user.id }
}
// MARK: - Private
private func loadGatewayStatus() {
let stateJSON = FileManager.default.contents(atPath: HermesPaths.gatewayStateJSON)
var pid: Int?
var state = "unknown"
var exitReason: String?
var startTime: String?
var updatedAt: String?
var platforms: [PlatformInfo] = []
if let data = stateJSON,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
pid = json["pid"] as? Int
state = json["gateway_state"] as? String ?? "unknown"
exitReason = json["exit_reason"] as? String
startTime = json["start_time"] as? String
updatedAt = json["updated_at"] as? String
if let plats = json["platforms"] as? [String: Any] {
platforms = plats.compactMap { key, value in
guard let info = value as? [String: Any] else { return nil }
return PlatformInfo(
name: key,
state: info["state"] as? String ?? "unknown",
updatedAt: info["updated_at"] as? String
)
}.sorted { $0.name < $1.name }
}
}
let statusOutput = runHermes(["gateway", "status"]).output
let isLoaded = statusOutput.contains("service is loaded")
let isStale = statusOutput.contains("stale")
gateway = GatewayInfo(
pid: pid, state: state, exitReason: exitReason,
startTime: startTime, updatedAt: updatedAt,
platforms: platforms, isLoaded: isLoaded, isStale: isStale
)
}
private func loadPairing() {
let output = runHermes(["pairing", "list"]).output
approvedUsers = []
pendingPairings = []
var inApproved = false
var inPending = false
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.contains("Approved Users") { inApproved = true; inPending = false; continue }
if trimmed.contains("Pending") { inPending = true; inApproved = false; continue }
if trimmed.isEmpty || trimmed.hasPrefix("Platform") || trimmed.hasPrefix("--------") { continue }
let parts = trimmed.split(separator: " ", omittingEmptySubsequences: true)
if inApproved && parts.count >= 3 {
let platform = String(parts[0])
let userId = String(parts[1])
let name = parts[2...].joined(separator: " ")
approvedUsers.append(PairedUser(platform: platform, userId: userId, name: name))
}
if inPending && parts.count >= 2 {
let platform = String(parts[0])
let code = String(parts[1])
pendingPairings.append(PendingPairing(platform: platform, code: code))
}
}
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -0,0 +1,205 @@
import SwiftUI
struct GatewayView: View {
@State private var viewModel = GatewayViewModel()
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
serviceSection
platformsSection
pairingSection
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Gateway")
.onAppear { viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
}
// MARK: - Service
private var serviceSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Service")
.font(.headline)
Spacer()
if let msg = viewModel.actionMessage {
Text(msg)
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
Button("Start") { viewModel.startGateway() }
Button("Stop") { viewModel.stopGateway() }
Button("Restart") { viewModel.restartGateway() }
}
.controlSize(.small)
}
HStack(spacing: 16) {
StatusBadge(
label: viewModel.gateway.state,
isActive: viewModel.gateway.state == "running"
)
if let pid = viewModel.gateway.pid {
Label("PID \(pid)", systemImage: "number")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
if viewModel.gateway.isLoaded {
Label("Loaded", systemImage: "checkmark.circle")
.font(.caption)
.foregroundStyle(.green)
}
if viewModel.gateway.isStale {
Label("Service definition stale", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
if let reason = viewModel.gateway.exitReason, !reason.isEmpty {
HStack(spacing: 4) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text(reason)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let updated = viewModel.gateway.updatedAt {
Text("Last updated: \(updated)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
// MARK: - Platforms
private var platformsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Platforms")
.font(.headline)
if viewModel.gateway.platforms.isEmpty {
Text("No platforms connected")
.font(.caption)
.foregroundStyle(.secondary)
} else {
HStack(spacing: 12) {
ForEach(viewModel.gateway.platforms) { platform in
VStack(spacing: 6) {
Image(systemName: platform.icon)
.font(.title2)
.foregroundStyle(platform.isConnected ? Color.accentColor : .secondary)
Text(platform.name.capitalized)
.font(.caption.bold())
StatusBadge(
label: platform.state,
isActive: platform.isConnected
)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
}
// MARK: - Pairing
private var pairingSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Paired Users")
.font(.headline)
if !viewModel.pendingPairings.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Label("Pending Approvals", systemImage: "clock.badge.questionmark")
.font(.caption.bold())
.foregroundStyle(.orange)
ForEach(viewModel.pendingPairings) { pending in
HStack {
Label(pending.platform.capitalized, systemImage: platformIcon(pending.platform))
Text("Code: \(pending.code)")
.font(.caption.monospaced())
Spacer()
Button("Approve") {
viewModel.approvePairing(platform: pending.platform, code: pending.code)
}
.controlSize(.small)
.buttonStyle(.borderedProminent)
}
.font(.caption)
.padding(8)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
if viewModel.approvedUsers.isEmpty && viewModel.pendingPairings.isEmpty {
Text("No paired users")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.approvedUsers) { user in
HStack {
Image(systemName: platformIcon(user.platform))
.foregroundStyle(.secondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(user.name)
Text("\(user.platform.capitalized) · \(user.userId)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Revoke", role: .destructive) {
viewModel.revokeUser(user)
}
.controlSize(.small)
}
.padding(8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
}
private func platformIcon(_ platform: String) -> String {
switch platform {
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"
default: return "bubble.left"
}
}
}
struct StatusBadge: View {
let label: String
let isActive: Bool
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(isActive ? .green : .secondary)
.frame(width: 6, height: 6)
Text(label)
.font(.caption)
}
}
}
@@ -0,0 +1,179 @@
import Foundation
struct HealthCheck: Identifiable {
let id = UUID()
let label: String
let status: CheckStatus
let detail: String?
enum CheckStatus {
case ok
case warning
case error
}
}
struct HealthSection: Identifiable {
let id = UUID()
let title: String
let icon: String
let checks: [HealthCheck]
}
@Observable
final class HealthViewModel {
var version = ""
var updateInfo = ""
var hasUpdate = false
var statusSections: [HealthSection] = []
var doctorSections: [HealthSection] = []
var issueCount = 0
var warningCount = 0
var okCount = 0
var isLoading = false
func load() {
isLoading = true
loadVersion()
let statusOutput = runHermes(["status"]).output
statusSections = parseOutput(statusOutput)
let doctorOutput = runHermes(["doctor"]).output
doctorSections = parseOutput(doctorOutput)
computeCounts()
isLoading = false
}
private func loadVersion() {
let output = runHermes(["version"]).output
let lines = output.components(separatedBy: "\n")
version = lines.first ?? ""
if let updateLine = lines.first(where: { $0.contains("commits behind") }) {
updateInfo = updateLine.trimmingCharacters(in: .whitespaces)
hasUpdate = true
} else {
updateInfo = ""
hasUpdate = false
}
}
private func parseOutput(_ output: String) -> [HealthSection] {
var sections: [HealthSection] = []
var currentTitle = ""
var currentChecks: [HealthCheck] = []
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("") {
if !currentTitle.isEmpty {
sections.append(HealthSection(
title: currentTitle,
icon: iconForSection(currentTitle),
checks: currentChecks
))
}
currentTitle = String(trimmed.dropFirst(2))
currentChecks = []
continue
}
if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .ok, detail: detail))
} else if trimmed.hasPrefix("") || trimmed.hasPrefix("") {
let text = trimmed.replacingOccurrences(of: "", with: "").replacingOccurrences(of: "", with: "")
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .warning, detail: detail))
} else if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .error, detail: detail))
} else if trimmed.hasPrefix("") || trimmed.hasPrefix("Error:") {
if !currentChecks.isEmpty {
let last = currentChecks.removeLast()
let extra = trimmed.replacingOccurrences(of: "", with: "").replacingOccurrences(of: "Error:", with: "").trimmingCharacters(in: .whitespaces)
let combined = [last.detail, extra].compactMap { $0 }.joined(separator: " ")
currentChecks.append(HealthCheck(label: last.label, status: last.status, detail: combined))
}
} else if !trimmed.isEmpty && trimmed.contains(":") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("Run ") && !trimmed.hasPrefix("Found ") && !trimmed.hasPrefix("Tip:") {
let parts = trimmed.split(separator: ":", maxSplits: 1)
if parts.count == 2 {
let key = parts[0].trimmingCharacters(in: .whitespaces)
let val = parts[1].trimmingCharacters(in: .whitespaces)
if !key.isEmpty && key.count < 30 {
currentChecks.append(HealthCheck(label: key, status: .ok, detail: val))
}
}
}
}
if !currentTitle.isEmpty {
sections.append(HealthSection(
title: currentTitle,
icon: iconForSection(currentTitle),
checks: currentChecks
))
}
return sections
}
private func splitCheck(_ text: String) -> (String, String?) {
if let parenStart = text.firstIndex(of: "(") {
let label = text[text.startIndex..<parenStart].trimmingCharacters(in: .whitespaces)
let detail = String(text[parenStart...]).trimmingCharacters(in: CharacterSet(charactersIn: "()"))
return (label, detail)
}
return (text, nil)
}
private func computeCounts() {
let allChecks = (statusSections + doctorSections).flatMap(\.checks)
okCount = allChecks.filter { $0.status == .ok }.count
warningCount = allChecks.filter { $0.status == .warning }.count
issueCount = allChecks.filter { $0.status == .error }.count
}
private func iconForSection(_ title: String) -> String {
switch title {
case "Environment": return "gearshape.2"
case "API Keys": return "key"
case "Auth Providers": return "person.badge.key"
case "API-Key Providers": return "key.horizontal"
case "Terminal Backend": return "terminal"
case "Messaging Platforms": return "bubble.left.and.bubble.right"
case "Gateway Service": return "antenna.radiowaves.left.and.right"
case "Scheduled Jobs": return "clock.arrow.2.circlepath"
case "Sessions": return "text.bubble"
case "Python Environment": return "chevron.left.forwardslash.chevron.right"
case "Required Packages": return "shippingbox"
case "Configuration Files": return "doc.text"
case "Directory Structure": return "folder"
case "External Tools": return "wrench"
case "API Connectivity": return "wifi"
case "Submodules": return "arrow.triangle.branch"
case "Tool Availability": return "wrench.and.screwdriver"
case "Skills Hub": return "lightbulb"
case "Honcho Memory": return "brain"
default: return "circle"
}
}
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -0,0 +1,219 @@
import SwiftUI
struct HealthView: View {
@State private var viewModel = HealthViewModel()
@State private var expandedSection: UUID?
@State private var selectedTab = 0
var body: some View {
VStack(spacing: 0) {
headerBar
Divider()
Picker("", selection: $selectedTab) {
Text("Status").tag(0)
Text("Diagnostics").tag(1)
}
.pickerStyle(.segmented)
.frame(maxWidth: 300)
.padding(.vertical, 8)
Divider()
ScrollView {
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
.padding()
}
}
.navigationTitle("Health")
.onAppear { viewModel.load() }
}
// MARK: - Header
private var headerBar: some View {
HStack(spacing: 16) {
if !viewModel.version.isEmpty {
Text(viewModel.version)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
if viewModel.hasUpdate {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.caption2)
Text(viewModel.updateInfo)
.font(.caption)
}
.foregroundStyle(.orange)
}
Spacer()
HStack(spacing: 12) {
MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill")
MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill")
MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill")
}
Button("Refresh") { viewModel.load() }
.controlSize(.small)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
// MARK: - Grid
private func sectionGrid(_ sections: [HealthSection]) -> some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
ForEach(sections) { section in
SectionCard(
section: section,
isExpanded: expandedSection == section.id,
onTap: {
withAnimation(.easeInOut(duration: 0.2)) {
expandedSection = expandedSection == section.id ? nil : section.id
}
}
)
}
}
}
}
// MARK: - Section Card
struct SectionCard: View {
let section: HealthSection
let isExpanded: Bool
let onTap: () -> Void
private var okCount: Int { section.checks.filter { $0.status == .ok }.count }
private var warnCount: Int { section.checks.filter { $0.status == .warning }.count }
private var errorCount: Int { section.checks.filter { $0.status == .error }.count }
private var accentColor: Color {
if errorCount > 0 { return .red }
if warnCount > 0 { return .orange }
return .green
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onTap) {
HStack(spacing: 10) {
Image(systemName: section.icon)
.font(.title3)
.foregroundStyle(accentColor)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(section.title)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
HStack(spacing: 8) {
if okCount > 0 {
HStack(spacing: 2) {
Circle().fill(.green).frame(width: 5, height: 5)
Text("\(okCount)").font(.caption2).foregroundStyle(.secondary)
}
}
if warnCount > 0 {
HStack(spacing: 2) {
Circle().fill(.orange).frame(width: 5, height: 5)
Text("\(warnCount)").font(.caption2).foregroundStyle(.secondary)
}
}
if errorCount > 0 {
HStack(spacing: 2) {
Circle().fill(.red).frame(width: 5, height: 5)
Text("\(errorCount)").font(.caption2).foregroundStyle(.secondary)
}
}
}
}
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(12)
}
.buttonStyle(.plain)
if isExpanded {
Divider()
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 3) {
ForEach(section.checks) { check in
CheckRow(check: check)
}
}
.padding(12)
}
}
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(accentColor.opacity(0.3), lineWidth: 1)
)
}
}
// MARK: - Check Row
struct CheckRow: View {
let check: HealthCheck
var body: some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
.font(.system(size: 9))
.frame(width: 12, alignment: .center)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 0) {
Text(check.label)
.font(.caption)
if let detail = check.detail {
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
private var statusIcon: String {
switch check.status {
case .ok: return "checkmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .error: return "xmark.circle.fill"
}
}
private var statusColor: Color {
switch check.status {
case .ok: return .green
case .warning: return .orange
case .error: return .red
}
}
}
// MARK: - Mini Count
struct MiniCount: View {
let count: Int
let color: Color
let icon: String
var body: some View {
HStack(spacing: 3) {
Image(systemName: icon)
.foregroundStyle(color)
.font(.caption2)
Text("\(count)")
.font(.caption.monospaced().bold())
}
}
}
@@ -0,0 +1,234 @@
import Foundation
enum InsightsPeriod: String, CaseIterable, Identifiable {
case week = "7 Days"
case month = "30 Days"
case quarter = "90 Days"
case all = "All Time"
var id: String { rawValue }
var sinceDate: Date {
let calendar = Calendar.current
switch self {
case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
case .month: return calendar.date(byAdding: .day, value: -30, to: Date()) ?? Date()
case .quarter: return calendar.date(byAdding: .day, value: -90, to: Date()) ?? Date()
case .all: return Date(timeIntervalSince1970: 0)
}
}
}
struct ModelUsage: Identifiable {
var id: String { model }
let model: String
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let cacheReadTokens: Int
let cacheWriteTokens: Int
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens }
}
struct PlatformUsage: Identifiable {
var id: String { platform }
let platform: String
let sessions: Int
let messages: Int
let tokens: Int
}
struct ToolUsage: Identifiable {
var id: String { name }
let name: String
let count: Int
let percentage: Double
}
struct NotableSession: Identifiable {
var id: String { session.id }
let label: String
let value: String
let session: HermesSession
let preview: String
}
@Observable
final class InsightsViewModel {
private let dataService = HermesDataService()
var period: InsightsPeriod = .month
var isLoading = true
var sessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:]
var userMessageCount = 0
var totalMessages = 0
var totalToolCalls = 0
var totalInputTokens = 0
var totalOutputTokens = 0
var totalCacheReadTokens = 0
var totalCacheWriteTokens = 0
var totalTokens = 0
var activeTime: TimeInterval = 0
var avgSessionDuration: TimeInterval = 0
var modelUsage: [ModelUsage] = []
var platformUsage: [PlatformUsage] = []
var toolUsage: [ToolUsage] = []
var hourlyActivity: [Int: Int] = [:]
var dailyActivity: [Int: Int] = [:]
var notableSessions: [NotableSession] = []
func load() async {
isLoading = true
let opened = await dataService.open()
guard opened else {
isLoading = false
return
}
let since = period.sinceDate
sessions = await dataService.fetchSessionsInPeriod(since: since)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
userMessageCount = await dataService.fetchUserMessageCount(since: since)
let tools = await dataService.fetchToolUsage(since: since)
hourlyActivity = await dataService.fetchSessionStartHours(since: since)
dailyActivity = await dataService.fetchSessionDaysOfWeek(since: since)
await dataService.close()
computeAggregates()
computeModelBreakdown()
computePlatformBreakdown()
computeToolBreakdown(tools)
computeNotableSessions()
isLoading = false
}
func previewFor(_ session: HermesSession) -> String {
if let title = session.title, !title.isEmpty { return title }
if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview }
return session.id
}
private func computeAggregates() {
totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
totalToolCalls = sessions.reduce(0) { $0 + $1.toolCallCount }
totalInputTokens = sessions.reduce(0) { $0 + $1.inputTokens }
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens
var total: TimeInterval = 0
var count = 0
for session in sessions {
if let dur = session.duration, dur > 0 {
total += dur
count += 1
}
}
activeTime = total
avgSessionDuration = count > 0 ? total / Double(count) : 0
}
private func computeModelBreakdown() {
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int)] = [:]
for s in sessions {
let model = s.model ?? "unknown"
var entry = grouped[model, default: (0, 0, 0, 0, 0)]
entry.sessions += 1
entry.input += s.inputTokens
entry.output += s.outputTokens
entry.cacheRead += s.cacheReadTokens
entry.cacheWrite += s.cacheWriteTokens
grouped[model] = entry
}
modelUsage = grouped.map { key, val in
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
outputTokens: val.output, cacheReadTokens: val.cacheRead,
cacheWriteTokens: val.cacheWrite)
}.sorted { $0.totalTokens > $1.totalTokens }
}
private func computePlatformBreakdown() {
var grouped: [String: (sessions: Int, messages: Int, tokens: Int)] = [:]
for s in sessions {
var entry = grouped[s.source, default: (0, 0, 0)]
entry.sessions += 1
entry.messages += s.messageCount
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens
grouped[s.source] = entry
}
platformUsage = grouped.map { key, val in
PlatformUsage(platform: key, sessions: val.sessions, messages: val.messages, tokens: val.tokens)
}.sorted { $0.sessions > $1.sessions }
}
private func computeToolBreakdown(_ tools: [(name: String, count: Int)]) {
let total = tools.reduce(0) { $0 + $1.count }
toolUsage = tools.map { tool in
ToolUsage(name: tool.name, count: tool.count,
percentage: total > 0 ? Double(tool.count) / Double(total) * 100 : 0)
}
}
private func computeNotableSessions() {
notableSessions = []
if let longest = sessions.filter({ $0.duration != nil }).max(by: { ($0.duration ?? 0) < ($1.duration ?? 0) }) {
notableSessions.append(NotableSession(
label: "Longest Session",
value: formatDuration(longest.duration ?? 0),
session: longest,
preview: previewFor(longest)
))
}
if let mostMsgs = sessions.max(by: { $0.messageCount < $1.messageCount }), mostMsgs.messageCount > 0 {
notableSessions.append(NotableSession(
label: "Most Messages",
value: "\(mostMsgs.messageCount) msgs",
session: mostMsgs,
preview: previewFor(mostMsgs)
))
}
if let mostTokens = sessions.max(by: { $0.totalTokens < $1.totalTokens }), mostTokens.totalTokens > 0 {
notableSessions.append(NotableSession(
label: "Most Tokens",
value: formatTokens(mostTokens.totalTokens),
session: mostTokens,
preview: previewFor(mostTokens)
))
}
if let mostTools = sessions.max(by: { $0.toolCallCount < $1.toolCallCount }), mostTools.toolCallCount > 0 {
notableSessions.append(NotableSession(
label: "Most Tool Calls",
value: "\(mostTools.toolCallCount) calls",
session: mostTools,
preview: previewFor(mostTools)
))
}
}
}
func formatDuration(_ interval: TimeInterval) -> String {
let hours = Int(interval) / 3600
let minutes = (Int(interval) % 3600) / 60
if hours > 0 {
return "\(hours)h \(minutes)m"
}
return "\(minutes)m"
}
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)"
}
@@ -0,0 +1,317 @@
import SwiftUI
struct InsightsView: View {
@State private var viewModel = InsightsViewModel()
@Environment(AppCoordinator.self) private var coordinator
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
periodPicker
overviewSection
modelSection
platformSection
toolsSection
activitySection
notableSection
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Insights")
.task { await viewModel.load() }
.onChange(of: viewModel.period) {
Task { await viewModel.load() }
}
}
private var periodPicker: some View {
Picker("Period", selection: $viewModel.period) {
ForEach(InsightsPeriod.allCases) { period in
Text(period.rawValue).tag(period)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 400)
}
// MARK: - Overview
private var overviewSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Overview")
.font(.headline)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 12) {
InsightCard(label: "Sessions", value: "\(viewModel.sessions.count)")
InsightCard(label: "Messages", value: "\(viewModel.totalMessages)")
InsightCard(label: "User Messages", value: "\(viewModel.userMessageCount)")
InsightCard(label: "Tool Calls", value: "\(viewModel.totalToolCalls)")
InsightCard(label: "Input Tokens", value: formatTokens(viewModel.totalInputTokens))
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
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)))
}
}
}
// MARK: - Models
private var modelSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Models")
.font(.headline)
if viewModel.modelUsage.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.modelUsage) { model in
HStack {
Image(systemName: "cpu")
.foregroundStyle(.blue)
.frame(width: 20)
Text(model.model)
.font(.system(.body, design: .monospaced))
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(model.sessions) sessions")
.font(.caption)
Text(formatTokens(model.totalTokens) + " tokens")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(10)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - Platforms
private var platformSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Platforms")
.font(.headline)
if viewModel.platformUsage.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
HStack(spacing: 12) {
ForEach(viewModel.platformUsage) { platform in
VStack(spacing: 6) {
Image(systemName: platformIcon(platform.platform))
.font(.title2)
.foregroundStyle(Color.accentColor)
Text(platform.platform)
.font(.caption.bold())
Text("\(platform.sessions) sessions")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(platform.messages) msgs")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
}
// MARK: - Tools
private var toolsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Top Tools")
.font(.headline)
if viewModel.toolUsage.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
let maxCount = viewModel.toolUsage.first?.count ?? 1
ForEach(viewModel.toolUsage.prefix(15)) { tool in
HStack(spacing: 10) {
Text(tool.name)
.font(.system(.caption, design: .monospaced))
.frame(width: 140, alignment: .trailing)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 3)
.fill(barColor(for: tool.name))
.frame(width: max(4, geo.size.width * Double(tool.count) / Double(maxCount)))
}
.frame(height: 16)
Text("\(tool.count)")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.frame(width: 40, alignment: .trailing)
Text(String(format: "%.1f%%", tool.percentage))
.font(.caption)
.foregroundStyle(.tertiary)
.frame(width: 50, alignment: .trailing)
}
.frame(height: 20)
}
}
}
}
// MARK: - Activity Patterns
private var activitySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Activity Patterns")
.font(.headline)
HStack(alignment: .top, spacing: 24) {
dayOfWeekChart
hourlyChart
}
}
}
private var dayOfWeekChart: some View {
VStack(alignment: .leading, spacing: 4) {
Text("By Day")
.font(.caption.bold())
.foregroundStyle(.secondary)
let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1)
ForEach(0..<7, id: \.self) { day in
let count = viewModel.dailyActivity[day] ?? 0
HStack(spacing: 6) {
Text(dayNames[day])
.font(.caption.monospaced())
.frame(width: 30, alignment: .trailing)
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor.opacity(0.7))
.frame(width: max(0, CGFloat(count) / CGFloat(maxVal) * 120), height: 14)
if count > 0 {
Text("\(count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
}
private var hourlyChart: some View {
VStack(alignment: .leading, spacing: 4) {
Text("By Hour")
.font(.caption.bold())
.foregroundStyle(.secondary)
let maxVal = max(1, viewModel.hourlyActivity.values.max() ?? 1)
HStack(alignment: .bottom, spacing: 2) {
ForEach(0..<24, id: \.self) { hour in
let count = viewModel.hourlyActivity[hour] ?? 0
VStack(spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(count > 0 ? Color.accentColor.opacity(0.7) : Color.secondary.opacity(0.15))
.frame(width: 12, height: max(4, CGFloat(count) / CGFloat(maxVal) * 80))
if hour % 6 == 0 {
Text("\(hour)")
.font(.system(size: 8))
.foregroundStyle(.secondary)
} else {
Text("")
.font(.system(size: 8))
}
}
}
}
}
}
// MARK: - Notable Sessions
private var notableSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Notable Sessions")
.font(.headline)
if viewModel.notableSessions.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.notableSessions) { notable in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(notable.label)
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(notable.preview)
.lineLimit(1)
}
Spacer()
Text(notable.value)
.font(.system(.body, design: .monospaced, weight: .semibold))
Button {
coordinator.selectedSessionId = notable.session.id
coordinator.selectedSection = .sessions
} label: {
Image(systemName: "arrow.right.circle")
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
.help("Open session")
}
.padding(10)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - Helpers
private func platformIcon(_ 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 "email": return "envelope"
default: return "bubble.left"
}
}
private func barColor(for toolName: String) -> Color {
switch toolName {
case "terminal": return .orange
case "read_file", "search_files": return .green
case "write_file", "patch": return .blue
case "web_search", "web_extract": return .purple
case _ where toolName.hasPrefix("browser"): return .indigo
case "memory": return .pink
case "vision", "image_gen": return .mint
default: return Color.accentColor
}
}
}
struct InsightCard: View {
let label: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.system(.title3, design: .monospaced, weight: .semibold))
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(10)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -1,4 +1,13 @@
import Foundation
import AppKit
import UniformTypeIdentifiers
struct SessionStoreStats {
let totalSessions: Int
let totalMessages: Int
let databaseSize: String
let platformCounts: [(platform: String, count: Int)]
}
@Observable
final class SessionsViewModel {
@@ -11,12 +20,20 @@ final class SessionsViewModel {
var searchText = ""
var searchResults: [HermesMessage] = []
var isSearching = false
var storeStats: SessionStoreStats?
var renameSessionId: String?
var renameText = ""
var showRenameSheet = false
var showDeleteConfirmation = false
var deleteSessionId: String?
func load() async {
let opened = await dataService.open()
guard opened else { return }
sessions = await dataService.fetchSessions(limit: 500)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
computeStats()
}
func previewFor(_ session: HermesSession) -> String {
@@ -50,4 +67,132 @@ final class SessionsViewModel {
func cleanup() async {
await dataService.close()
}
// MARK: - Session Actions
func beginRename(_ session: HermesSession) {
renameSessionId = session.id
renameText = previewFor(session)
showRenameSheet = true
}
func confirmRename() {
guard let sessionId = renameSessionId else { return }
let title = renameText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return }
let result = runHermes(["sessions", "rename", sessionId, title])
if result.exitCode == 0 {
if let idx = sessions.firstIndex(where: { $0.id == sessionId }) {
let updated = HermesSession(
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
if selectedSession?.id == sessionId {
selectedSession = updated
}
}
sessionPreviews[sessionId] = title
}
showRenameSheet = false
renameSessionId = nil
}
func beginDelete(_ session: HermesSession) {
deleteSessionId = session.id
showDeleteConfirmation = true
}
func confirmDelete() {
guard let sessionId = deleteSessionId else { return }
let result = runHermes(["sessions", "delete", "--yes", sessionId])
if result.exitCode == 0 {
sessions.removeAll { $0.id == sessionId }
if selectedSession?.id == sessionId {
selectedSession = nil
messages = []
}
computeStats()
}
showDeleteConfirmation = false
deleteSessionId = nil
}
func exportSession(_ session: HermesSession) {
let panel = NSSavePanel()
panel.nameFieldStringValue = "\(session.id).jsonl"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
runHermes(["sessions", "export", url.path, "--session-id", session.id])
}
func exportAll() {
let panel = NSSavePanel()
panel.nameFieldStringValue = "hermes-sessions.jsonl"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
runHermes(["sessions", "export", url.path])
}
// MARK: - Stats
private func computeStats() {
let totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
var platformCounts: [String: Int] = [:]
for s in sessions {
platformCounts[s.source, default: 0] += 1
}
let sorted = platformCounts.sorted { $0.value > $1.value }.map { (platform: $0.key, count: $0.value) }
let dbPath = HermesPaths.stateDB
let fileSize: String
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
let size = attrs[.size] as? Int {
if size >= 1_048_576 {
fileSize = String(format: "%.1f MB", Double(size) / 1_048_576)
} else {
fileSize = String(format: "%.0f KB", Double(size) / 1_024)
}
} else {
fileSize = "unknown"
}
storeStats = SessionStoreStats(
totalSessions: sessions.count,
totalMessages: totalMessages,
databaseSize: fileSize,
platformCounts: sorted
)
}
// MARK: - Hermes CLI
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return (output, process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -4,6 +4,9 @@ struct SessionDetailView: View {
let session: HermesSession
let messages: [HermesMessage]
var preview: String?
var onRename: (() -> Void)?
var onExport: (() -> Void)?
var onDelete: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -16,22 +19,41 @@ struct SessionDetailView: View {
private var sessionHeader: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(preview ?? session.displayTitle)
.font(.title3.bold())
Spacer()
if onRename != nil || onExport != nil || onDelete != nil {
Menu {
if let onRename { Button("Rename...") { onRename() } }
if let onExport { Button("Export...") { onExport() } }
if let onDelete {
Divider()
Button("Delete...", role: .destructive) { onDelete() }
}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundStyle(.secondary)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
}
HStack(spacing: 16) {
Label(session.source, systemImage: session.sourceIcon)
Label(session.model ?? "unknown", systemImage: "cpu")
Label("\(session.messageCount) msgs", systemImage: "bubble.left")
Label("\(session.toolCallCount) tools", systemImage: "wrench")
if let cost = session.estimatedCostUSD {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
}
if let date = session.startedAt {
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
}
}
.font(.caption)
.foregroundStyle(.secondary)
Text(session.id)
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
.textSelection(.enabled)
}
.padding()
}
@@ -5,12 +5,18 @@ struct SessionsView: View {
@Environment(AppCoordinator.self) private var coordinator
var body: some View {
VStack(spacing: 0) {
if let stats = viewModel.storeStats {
statsBar(stats)
Divider()
}
HSplitView {
sessionList
.frame(minWidth: 280, idealWidth: 320)
sessionDetail
.frame(minWidth: 400)
}
}
.navigationTitle("Sessions")
.searchable(text: $viewModel.searchText, prompt: "Search messages...")
.onSubmit(of: .search) { Task { await viewModel.search() } }
@@ -28,6 +34,33 @@ struct SessionsView: View {
}
}
.onDisappear { Task { await viewModel.cleanup() } }
.sheet(isPresented: $viewModel.showRenameSheet) {
renameSheet
}
.confirmationDialog("Delete Session?", isPresented: $viewModel.showDeleteConfirmation) {
Button("Delete", role: .destructive) { viewModel.confirmDelete() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This will permanently delete the session and all its messages.")
}
}
private func statsBar(_ stats: SessionStoreStats) -> some View {
HStack(spacing: 16) {
Label("\(stats.totalSessions) sessions", systemImage: "bubble.left.and.bubble.right")
Label("\(stats.totalMessages) messages", systemImage: "text.bubble")
Label(stats.databaseSize, systemImage: "internaldrive")
ForEach(stats.platformCounts, id: \.platform) { item in
Label("\(item.count) \(item.platform)", systemImage: platformIcon(item.platform))
}
Spacer()
Button("Export All") { viewModel.exportAll() }
.controlSize(.small)
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal)
.padding(.vertical, 6)
}
private var sessionList: some View {
@@ -64,6 +97,12 @@ struct SessionsView: View {
ForEach(viewModel.sessions) { session in
SessionRow(session: session, preview: viewModel.previewFor(session))
.tag(session.id)
.contextMenu {
Button("Rename...") { viewModel.beginRename(session) }
Button("Export...") { viewModel.exportSession(session) }
Divider()
Button("Delete...", role: .destructive) { viewModel.beginDelete(session) }
}
}
}
}
@@ -73,11 +112,50 @@ struct SessionsView: View {
@ViewBuilder
private var sessionDetail: some View {
if let session = viewModel.selectedSession {
SessionDetailView(session: session, messages: viewModel.messages, preview: viewModel.previewFor(session))
SessionDetailView(
session: session,
messages: viewModel.messages,
preview: viewModel.previewFor(session),
onRename: { viewModel.beginRename(session) },
onExport: { viewModel.exportSession(session) },
onDelete: { viewModel.beginDelete(session) }
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else {
ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var renameSheet: some View {
VStack(spacing: 16) {
Text("Rename Session")
.font(.headline)
TextField("Session title", text: $viewModel.renameText)
.textFieldStyle(.roundedBorder)
.onSubmit { viewModel.confirmRename() }
HStack {
Button("Cancel") { viewModel.showRenameSheet = false }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Rename") { viewModel.confirmRename() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(viewModel.renameText.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding()
.frame(width: 400)
}
private func platformIcon(_ 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 "email": return "envelope"
default: return "bubble.left"
}
}
}
@@ -0,0 +1,134 @@
import Foundation
@Observable
final class ToolsViewModel {
var selectedPlatform: HermesToolPlatform = KnownPlatforms.all[0]
var toolsets: [HermesToolset] = []
var mcpStatus: String = ""
var isLoading = false
var availablePlatforms: [HermesToolPlatform] = []
func load() {
loadPlatforms()
loadTools(for: selectedPlatform)
loadMCPStatus()
}
func switchPlatform(_ platform: HermesToolPlatform) {
selectedPlatform = platform
loadTools(for: platform)
}
func toggleTool(_ tool: HermesToolset) {
let action = tool.enabled ? "disable" : "enable"
let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
if result.exitCode == 0 {
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
toolsets[idx].enabled.toggle()
}
}
}
private func loadPlatforms() {
let config = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
var platforms: [HermesToolPlatform] = []
var inSection = false
for line in config.components(separatedBy: "\n") {
if line.hasPrefix("platform_toolsets:") {
inSection = true
continue
}
if inSection {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || (!line.hasPrefix(" ") && !line.hasPrefix("\t")) {
if !trimmed.isEmpty { break }
continue
}
if trimmed.hasSuffix(":") && !trimmed.hasPrefix("-") {
let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
if let known = KnownPlatforms.all.first(where: { $0.name == name }) {
platforms.append(known)
} else {
platforms.append(HermesToolPlatform(name: name, displayName: name.capitalized, icon: "bubble.left"))
}
}
}
}
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.all[0]] : platforms
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }) {
selectedPlatform = availablePlatforms[0]
}
}
private func loadTools(for platform: HermesToolPlatform) {
isLoading = true
let result = runHermes(["tools", "list", "--platform", platform.name])
toolsets = parseToolsList(result.output)
isLoading = false
}
private func loadMCPStatus() {
let result = runHermes(["mcp", "list"])
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func parseToolsList(_ output: String) -> [HermesToolset] {
var tools: [HermesToolset] = []
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
let isEnabled: Bool
if trimmed.hasPrefix("✓ enabled") {
isEnabled = true
} else if trimmed.hasPrefix("✗ disabled") {
isEnabled = false
} else {
continue
}
let rest = trimmed
.replacingOccurrences(of: "✓ enabled", with: "")
.replacingOccurrences(of: "✗ disabled", with: "")
.trimmingCharacters(in: .whitespaces)
let parts = rest.split(separator: " ", maxSplits: 1)
guard let namePart = parts.first else { continue }
let name = String(namePart)
let rawDesc = parts.count > 1 ? String(parts[1]) : name
let icon = extractEmoji(from: rawDesc)
let description = rawDesc
.unicodeScalars.filter { !$0.properties.isEmoji || $0.isASCII }
.map { String($0) }.joined()
.trimmingCharacters(in: .whitespaces)
tools.append(HermesToolset(name: name, description: description, icon: icon, enabled: isEnabled))
}
return tools
}
private func extractEmoji(from text: String) -> String {
for scalar in text.unicodeScalars {
if scalar.properties.isEmoji && !scalar.isASCII {
return String(scalar)
}
}
return "🔧"
}
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return (output, process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -0,0 +1,110 @@
import SwiftUI
struct ToolsView: View {
@State private var viewModel = ToolsViewModel()
var body: some View {
VStack(spacing: 0) {
platformPicker
Divider()
toolsList
if !viewModel.mcpStatus.isEmpty {
Divider()
mcpSection
}
}
.navigationTitle("Tools")
.onAppear { viewModel.load() }
}
private var platformPicker: some View {
HStack(spacing: 16) {
ForEach(viewModel.availablePlatforms) { platform in
Button {
viewModel.switchPlatform(platform)
} label: {
HStack(spacing: 4) {
Image(systemName: platform.icon)
Text(platform.displayName)
.font(.caption)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(viewModel.selectedPlatform.name == platform.name ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
Spacer()
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
private var toolsList: some View {
ScrollView {
LazyVStack(spacing: 1) {
ForEach(viewModel.toolsets) { tool in
ToolRow(tool: tool) {
viewModel.toggleTool(tool)
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
private var mcpSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text("MCP Servers")
.font(.caption.bold())
.foregroundStyle(.secondary)
if viewModel.mcpStatus.contains("No MCP servers") {
Label("No MCP servers configured", systemImage: "server.rack")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(viewModel.mcpStatus)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct ToolRow: View {
let tool: HermesToolset
let onToggle: () -> Void
var body: some View {
HStack(spacing: 12) {
Text(tool.icon)
.font(.title3)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(tool.name)
.font(.system(.body, design: .monospaced, weight: .medium))
Text(tool.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: Binding(
get: { tool.enabled },
set: { _ in onToggle() }
))
.toggleStyle(.switch)
.labelsHidden()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -2,12 +2,16 @@ import Foundation
enum SidebarSection: String, CaseIterable, Identifiable {
case dashboard = "Dashboard"
case insights = "Insights"
case sessions = "Sessions"
case activity = "Activity"
case chat = "Chat"
case memory = "Memory"
case skills = "Skills"
case tools = "Tools"
case gateway = "Gateway"
case cron = "Cron"
case health = "Health"
case logs = "Logs"
case settings = "Settings"
@@ -16,12 +20,16 @@ enum SidebarSection: String, CaseIterable, Identifiable {
var icon: String {
switch self {
case .dashboard: return "gauge.with.dots.needle.33percent"
case .insights: return "chart.bar"
case .sessions: return "bubble.left.and.bubble.right"
case .activity: return "bolt.horizontal"
case .chat: return "text.bubble"
case .memory: return "brain"
case .skills: return "lightbulb"
case .tools: return "wrench.and.screwdriver"
case .gateway: return "antenna.radiowaves.left.and.right"
case .cron: return "clock.arrow.2.circlepath"
case .health: return "stethoscope"
case .logs: return "doc.text"
case .settings: return "gearshape"
}
+2 -2
View File
@@ -7,7 +7,7 @@ struct SidebarView: View {
@Bindable var coordinator = coordinator
List(selection: $coordinator.selectedSection) {
Section("Monitor") {
ForEach([SidebarSection.dashboard, .sessions, .activity]) { section in
ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
}
@@ -19,7 +19,7 @@ struct SidebarView: View {
}
}
Section("Manage") {
ForEach([SidebarSection.cron, .logs, .settings]) { section in
ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>