Compare commits

...

11 Commits

Author SHA1 Message Date
Alan Wizemann ae2872e08f 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>
2026-04-04 21:46:03 -04:00
Alan Wizemann 303f4502dd Fix version reporting: update MARKETING_VERSION to 1.3.0
All builds were reporting version 1.0 because the Xcode project version
was never updated from its default. Fixes #5, fixes #7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:50:57 -04:00
Alan Wizemann 815c9dcbcd Merge pull request #6 from awizemann/code-quality
Code quality improvements and webview dashboard widget
2026-04-02 12:04:16 -04:00
Alan Wizemann ef53ac1c93 Replace webview split layout with tabbed Dashboard/Site interface
Dashboards with a webview widget now show a tab bar: Dashboard tab
renders all normal widgets, Site tab displays the web content
full-canvas with even margins. Cleaner UX than the split layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:03:50 -04:00
Alan Wizemann 2a3e8b1422 Add webview widget for embedded web browser in project dashboards
New widget type that renders any URL (local dev servers, HTML reports)
directly in the dashboard via WKWebView. Sections with webviews
automatically split layout: grid widgets left, webview right.
Configurable height, non-persistent data store, navigation error logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:29:05 -04:00
Alan Wizemann 563f5a702c Improve code quality: error logging, constants, path validation, safe defaults
- Replace try? with do/catch and [Scarf] error logging in all service-layer
  JSON decoding, file writes, and directory creation
- Extract sqliteTransient constant replacing raw unsafeBitCast(-1, ...) pattern
- Add QueryDefaults and FileSizeUnit enums for all magic numbers
- Guard HOME env var with NSHomeDirectory() fallback instead of force-unwrap
- Add path traversal validation to loadSkillContent()
- Add SessionStats.empty and use it across all initialization sites
- Replace KnownPlatforms array indexing with named .cli constant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 03:15:03 -04:00
Alan Wizemann c7f3ca9be3 Merge feature/project-dashboards into main 2026-04-01 01:30:55 -04:00
Alan Wizemann dbaadb8037 Add Project Dashboards feature with agent-generated widgets
Introduces a new Projects section that renders custom dashboards from
JSON files in project directories. Supports 7 widget types (stat,
progress, text, table, chart, list) with live file-watching refresh.
Includes project registry, SwiftUI Charts integration, schema docs,
and comprehensive README documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:48:13 -04:00
Alan Wizemann ce001fe202 Add left padding to terminal view in chat interface
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:28:46 -04:00
Alan Wizemann a329eca419 Merge branch 'development' 2026-03-31 15:30:51 -04:00
Alan Wizemann 528de938c5 Add pre-built binary install instructions to README
Universal binary (arm64 + x86_64) available on Releases page.
Updated Building section to Install with download + build options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:30:50 -04:00
40 changed files with 1740 additions and 177 deletions
+3
View File
@@ -43,3 +43,6 @@ Package.resolved
# Claude Code
.claude/
scarf/standards/backups/
# Scarf project dashboards (user-specific)
.scarf/
+162 -12
View File
@@ -19,18 +19,19 @@
## Features
- **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
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
- **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, 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
- **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
- **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)
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
- **Log Viewer** — Real-time log tailing with level filtering and text search
- **Settings** — Configuration display with raw YAML viewer and Finder path links
- **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 including reasoning effort, approval mode, cost display, and more
- **Menu Bar** — Status icon showing Hermes running state with quick actions
## Requirements
@@ -41,16 +42,26 @@
### 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 |
|----------------|--------|
| 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.
## Building
## Install
### Pre-built Binary (no Xcode required)
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
1. Download `Scarf-vX.X.X-Universal.zip`
2. Unzip and drag **Scarf.app** to Applications
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
### Build from Source
```bash
git clone https://github.com/awizemann/scarf.git
@@ -61,7 +72,7 @@ open scarf.xcodeproj
Or from the command line:
```bash
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Release -arch arm64 -arch x86_64 ONLY_ACTIVE_ARCH=NO build
```
## Architecture
@@ -78,6 +89,7 @@ scarf/
Insights/ Usage analytics and activity patterns
Sessions/ Conversation browser with rename, delete, export
Activity/ Tool execution feed with inspector
Projects/ Agent-generated project dashboards with widget rendering
Chat/ Embedded terminal via SwiftTerm with voice controls
Memory/ Memory viewer and editor
Skills/ Skill browser by category
@@ -85,7 +97,7 @@ scarf/
Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer
Logs/ Real-time log viewer
Settings/ Configuration display
Settings/ Structured config editor
Navigation/ AppCoordinator + SidebarView
```
@@ -107,6 +119,8 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke |
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
| `scarf/projects.json` | JSON (registry) | Read/Write |
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.
@@ -116,7 +130,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
|---------|---------|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, GCD file watching.
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
## How It Works
@@ -128,6 +142,142 @@ Management actions (renaming sessions, toggling tools, editing memory) call the
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
## Project Dashboards
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
### What You Can Build
- **Development dashboards** — test coverage, build status, open issues, sprint progress
- **Data project trackers** — pipeline metrics, data quality scores, processing throughput
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
- **Research dashboards** — experiment results, key findings, paper status checklists
- **Agent activity views** — cron job results, content generation stats, task completion rates
- **Embedded web apps** — local dev servers, HTML reports, Grafana dashboards, any web-based tool your agent generates
- **Any project status** — if your agent can measure it, Scarf can display it
### Quick Start
**1. Create the dashboard file**
Create `.scarf/dashboard.json` in any project folder:
```json
{
"version": 1,
"title": "My Project",
"description": "Project status at a glance",
"sections": [
{
"title": "Overview",
"columns": 3,
"widgets": [
{
"type": "stat",
"title": "Test Coverage",
"value": "87%",
"icon": "checkmark.shield",
"color": "green",
"subtitle": "+2.1% this week"
},
{
"type": "progress",
"title": "Sprint Progress",
"value": 0.73,
"label": "73% complete",
"color": "blue"
},
{
"type": "list",
"title": "Tasks",
"items": [
{ "text": "Write unit tests", "status": "done" },
{ "text": "Update API docs", "status": "active" },
{ "text": "Deploy to prod", "status": "pending" }
]
}
]
}
]
}
```
**2. Register your project**
In Scarf, go to **Projects** in the sidebar and click the **+** button to add your project folder. Or have your agent add it directly to the registry at `~/.hermes/scarf/projects.json`:
```json
{
"projects": [
{ "name": "my-project", "path": "/Users/you/Developer/my-project" }
]
}
```
**3. View in Scarf**
Select your project in the Projects sidebar — the dashboard renders immediately. Scarf watches the file for changes and refreshes automatically whenever the JSON is updated.
### Widget Types
| Type | Description | Key Fields |
|------|-------------|------------|
| `stat` | Key metric with large value display | `value`, `icon`, `color`, `subtitle` |
| `progress` | Progress bar with label | `value` (0.01.0), `label`, `color` |
| `text` | Rich text block | `content`, `format` ("markdown" or "plain") |
| `table` | Data table with headers | `columns`, `rows` |
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) |
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
| `webview` | Embedded web browser | `url`, `height` (default 400) |
The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates.
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows your normal widgets, **Site** shows the web content full-canvas with clean margins — using the entire available space in the app. This gives you the best of both worlds: compact metrics at a glance, and a full embedded browser when you need it.
```json
{
"type": "webview",
"title": "Project Report",
"url": "http://localhost:8000/dashboard",
"height": 500
}
```
- `url`: Any URL — typically a local server (`http://localhost:...`) or file path
- `height`: Height in points when displayed as an inline widget card (default: 400). The Site tab always uses full available space regardless of this setting.
**Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
**Icons**: Any [SF Symbol](https://developer.apple.com/sf-symbols/) name (e.g., `checkmark.shield`, `cpu`, `doc.text`, `chart.bar`)
### Agent-Generated Dashboards
The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context:
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, lists for task tracking, and a webview widget if the project has a local web server or HTML reports. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
### Dashboard Schema Reference
```json
{
"version": 1,
"title": "Required — dashboard title",
"description": "Optional — subtitle text",
"updatedAt": "Optional — ISO 8601 timestamp",
"sections": [
{
"title": "Section Name",
"columns": 3,
"widgets": [{ "type": "...", "title": "..." }]
}
]
}
```
Each section defines a grid with 14 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
## Contributing
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
+169
View File
@@ -0,0 +1,169 @@
# Scarf Project Dashboard Schema
Scarf can render project dashboards from a JSON file. Place a `dashboard.json` file at `.scarf/dashboard.json` in your project root, and register the project in Scarf.
## Registration
Projects are registered in `~/.hermes/scarf/projects.json`:
```json
{
"projects": [
{ "name": "my-project", "path": "/path/to/my-project" }
]
}
```
You can also add projects from the Scarf UI via the Projects section.
## Dashboard File
Create `.scarf/dashboard.json` in your project root:
```json
{
"version": 1,
"title": "My Project",
"description": "Optional description",
"updatedAt": "2026-03-31T14:00:00Z",
"sections": [
{
"title": "Section Name",
"columns": 3,
"widgets": []
}
]
}
```
## Widget Types
### stat — Key metric display
```json
{
"type": "stat",
"title": "Test Coverage",
"value": "87.3%",
"icon": "checkmark.shield",
"color": "green",
"subtitle": "+2.1% from last week"
}
```
- `value`: String or number
- `icon`: SF Symbol name (optional)
- `color`: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray (optional)
- `subtitle`: Secondary text (optional)
### progress — Progress bar
```json
{
"type": "progress",
"title": "Sprint Progress",
"value": 0.73,
"label": "73% complete",
"color": "blue"
}
```
- `value`: Number between 0.0 and 1.0
- `label`: Text below the bar (optional)
- `color`: Named color (optional)
### text — Rich text block
```json
{
"type": "text",
"title": "Release Notes",
"content": "**v2.4.1** — Fixed auth timeout\n\n- Bug fix for session handling",
"format": "markdown"
}
```
- `content`: Text content
- `format`: "markdown" or "plain" (default: plain)
### table — Data table
```json
{
"type": "table",
"title": "Recent Deploys",
"columns": ["Date", "Env", "Status"],
"rows": [
["Mar 30", "prod", "success"],
["Mar 29", "staging", "success"]
]
}
```
### chart — Line, bar, or pie chart
```json
{
"type": "chart",
"title": "Tests Over Time",
"chartType": "line",
"series": [
{
"name": "Passing",
"color": "green",
"data": [
{ "x": "Mon", "y": 142 },
{ "x": "Tue", "y": 145 }
]
}
]
}
```
- `chartType`: "line", "bar", or "pie"
- `series[].color`: Named color (optional)
- For pie charts, each series becomes a slice
### list — Checklist
```json
{
"type": "list",
"title": "TODO Items",
"icon": "checklist",
"items": [
{ "text": "Write tests", "status": "done" },
{ "text": "Update docs", "status": "active" },
{ "text": "Deploy", "status": "pending" }
]
}
```
- `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle)
### webview — Embedded web browser
```json
{
"type": "webview",
"title": "Project Dashboard",
"url": "http://localhost:8000",
"height": 500
}
```
- `url`: Any URL — local servers, file paths, or remote pages
- `height`: Height in points (optional, default: 400)
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows all normal widgets, **Site** displays the web content full-canvas. The webview widget is automatically filtered out of the Dashboard tab's grid layout.
## Agent Instructions
To have your Hermes agent generate a dashboard, include these instructions:
> Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics,
> status indicators, and visualizations. Use the Scarf dashboard schema with sections
> containing stat, progress, text, table, chart, list, and webview widgets. Register the project
> in `~/.hermes/scarf/projects.json` if not already registered.
The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically.
+12 -12
View File
@@ -407,7 +407,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -421,7 +421,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -443,7 +443,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -457,7 +457,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -475,11 +475,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -496,11 +496,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -516,10 +516,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -535,10 +535,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
+2
View File
@@ -22,6 +22,8 @@ struct ContentView: View {
SessionsView()
case .activity:
ActivityView()
case .projects:
ProjectsView()
case .chat:
ChatView()
case .memory:
+11 -1
View File
@@ -15,6 +15,11 @@ struct HermesConfig: Sendable {
var verbose: Bool
var autoTTS: Bool
var silenceThreshold: Int
var reasoningEffort: String
var showCost: Bool
var approvalMode: String
var browserBackend: String
var memoryProvider: String
static let empty = HermesConfig(
model: "unknown",
@@ -30,7 +35,12 @@ struct HermesConfig: Sendable {
showReasoning: false,
verbose: false,
autoTTS: true,
silenceThreshold: 200
silenceThreshold: 200,
reasoningEffort: "medium",
showCost: false,
approvalMode: "manual",
browserBackend: "",
memoryProvider: ""
)
}
+33 -3
View File
@@ -1,8 +1,11 @@
import Foundation
import SQLite3
enum HermesPaths: Sendable {
// Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory
nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes"
private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"]
?? NSHomeDirectory()
nonisolated static let home: String = userHome + "/.hermes"
nonisolated static let stateDB: String = home + "/state.db"
nonisolated static let configYAML: String = home + "/config.yaml"
nonisolated static let memoriesDir: String = home + "/memories"
@@ -15,5 +18,32 @@ enum HermesPaths: Sendable {
nonisolated static let skillsDir: String = home + "/skills"
nonisolated static let errorsLog: String = home + "/logs/errors.log"
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes"
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
nonisolated static let scarfDir: String = home + "/scarf"
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
}
// MARK: - SQLite Constants
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
// MARK: - Query Defaults
enum QueryDefaults: Sendable {
nonisolated static let sessionLimit = 100
nonisolated static let messageSearchLimit = 50
nonisolated static let toolCallLimit = 50
nonisolated static let sessionPreviewLimit = 10
nonisolated static let previewContentLength = 100
nonisolated static let logLineLimit = 200
nonisolated static let defaultSilenceThreshold = 200
}
// MARK: - File Size Formatting
enum FileSizeUnit: Sendable {
nonisolated static let kilobyte = 1_024.0
nonisolated static let megabyte = 1_048_576.0
}
+3 -1
View File
@@ -11,10 +11,12 @@ struct HermesMessage: Identifiable, Sendable {
let timestamp: Date?
let tokenCount: Int?
let finishReason: String?
let reasoning: String?
var isUser: Bool { role == "user" }
var isAssistant: Bool { role == "assistant" }
var isToolResult: Bool { role == "tool" }
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
}
struct HermesToolCall: Identifiable, Sendable, Codable {
@@ -61,7 +63,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
switch functionName {
case "read_file", "search_files", "vision_analyze": return .read
case "write_file", "patch": return .edit
case "terminal": return .execute
case "terminal", "execute_code": return .execute
case "web_search", "web_extract": return .fetch
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
default: return .other
+24 -9
View File
@@ -17,8 +17,16 @@ struct HermesSession: Identifiable, Sendable {
let cacheReadTokens: Int
let cacheWriteTokens: Int
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? {
guard let start = startedAt, let end = endedAt else { return nil }
@@ -30,13 +38,20 @@ struct HermesSession: Identifiable, Sendable {
}
var sourceIcon: String {
switch 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"
}
KnownPlatforms.icon(for: source)
}
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
)
}
}
+21 -1
View File
@@ -16,13 +16,33 @@ struct HermesToolPlatform: Identifiable, Sendable {
}
enum KnownPlatforms {
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
static let all: [HermesToolPlatform] = [
HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"),
cli,
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"),
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"
}
}
}
@@ -0,0 +1,138 @@
import Foundation
// MARK: - Registry
struct ProjectRegistry: Codable, Sendable {
var projects: [ProjectEntry]
}
struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
var id: String { name }
let name: String
let path: String
var dashboardPath: String { path + "/.scarf/dashboard.json" }
}
// MARK: - Dashboard
struct ProjectDashboard: Codable, Sendable {
let version: Int
let title: String
let description: String?
let updatedAt: String?
let theme: DashboardTheme?
let sections: [DashboardSection]
}
struct DashboardTheme: Codable, Sendable {
let accent: String?
}
struct DashboardSection: Codable, Sendable, Identifiable {
var id: String { title }
let title: String
let columns: Int?
let widgets: [DashboardWidget]
var columnCount: Int { columns ?? 3 }
}
struct DashboardWidget: Codable, Sendable, Identifiable {
var id: String { type + ":" + title }
let type: String
let title: String
// Stat
let value: WidgetValue?
let icon: String?
let color: String?
let subtitle: String?
// Progress
let label: String?
// Text
let content: String?
let format: String?
// Table
let columns: [String]?
let rows: [[String]]?
// Chart
let chartType: String?
let xLabel: String?
let yLabel: String?
let series: [ChartSeries]?
// List
let items: [ListItem]?
// Webview
let url: String?
let height: Double?
}
// MARK: - Widget Value (String or Number)
enum WidgetValue: Codable, Sendable, Hashable {
case string(String)
case number(Double)
var displayString: String {
switch self {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? String(Int(n))
: String(format: "%.1f", n)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let d = try? container.decode(Double.self) {
self = .number(d)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else {
throw DecodingError.typeMismatch(
WidgetValue.self,
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s): try container.encode(s)
case .number(let n): try container.encode(n)
}
}
}
// MARK: - Chart Data
struct ChartSeries: Codable, Sendable, Identifiable {
var id: String { name }
let name: String
let color: String?
let data: [ChartDataPoint]
}
struct ChartDataPoint: Codable, Sendable, Identifiable {
var id: String { x }
let x: String
let y: Double
}
// MARK: - List Data
struct ListItem: Codable, Sendable, Identifiable {
var id: String { text }
let text: String
let status: String?
}
+128 -66
View File
@@ -3,6 +3,7 @@ import SQLite3
actor HermesDataService {
private var db: OpaquePointer?
private var hasV07Schema = false
func open() -> Bool {
let path = HermesPaths.stateDB
@@ -14,6 +15,7 @@ actor HermesDataService {
return false
}
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
detectSchema()
return true
}
@@ -24,17 +26,39 @@ actor HermesDataService {
db = nil
}
func fetchSessions(limit: Int = 100) -> [HermesSession] {
guard let db else { return [] }
let sql = """
SELECT id, source, user_id, model, title, parent_session_id,
// MARK: - Schema Detection
private func detectSchema() {
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,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
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?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
@@ -47,19 +71,41 @@ actor HermesDataService {
return sessions
}
func fetchMessages(sessionId: String) -> [HermesMessage] {
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
guard let db else { return [] }
let sql = """
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
"""
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_text(stmt, 1, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
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] {
guard let db else { return [] }
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
var messages: [HermesMessage] = []
while sqlite3_step(stmt) == SQLITE_ROW {
@@ -68,11 +114,15 @@ actor HermesDataService {
return messages
}
func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] {
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
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 = """
SELECT 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
SELECT \(msgCols)
FROM messages_fts fts
JOIN messages m ON m.id = fts.rowid
WHERE messages_fts MATCH ?
@@ -82,7 +132,7 @@ actor HermesDataService {
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, query, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
sqlite3_bind_text(stmt, 1, sanitized, -1, sqliteTransient)
sqlite3_bind_int(stmt, 2, Int32(limit))
var messages: [HermesMessage] = []
@@ -92,11 +142,10 @@ actor HermesDataService {
return messages
}
func fetchRecentToolCalls(limit: Int = 50) -> [HermesMessage] {
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
guard let db else { return [] }
let sql = """
SELECT id, session_id, role, content, tool_call_id, tool_calls,
tool_name, timestamp, token_count, finish_reason
SELECT \(messageColumns)
FROM messages
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
ORDER BY timestamp DESC
@@ -114,10 +163,10 @@ actor HermesDataService {
return messages
}
func fetchSessionPreviews(limit: Int = 10) -> [String: String] {
func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
guard let db else { return [:] }
let sql = """
SELECT m.session_id, substr(m.content, 1, 100)
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
FROM messages m
INNER JOIN (
SELECT session_id, MIN(id) as min_id
@@ -142,6 +191,8 @@ actor HermesDataService {
return previews
}
// MARK: - Stats
struct SessionStats: Sendable {
let totalSessions: Int
let totalMessages: Int
@@ -149,65 +200,53 @@ actor HermesDataService {
let totalInputTokens: Int
let totalOutputTokens: Int
let totalCostUSD: Double
let totalReasoningTokens: Int
let totalActualCostUSD: Double
static let empty = SessionStats(
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0,
totalReasoningTokens: 0, totalActualCostUSD: 0
)
}
func fetchStats() -> SessionStats {
guard let db else {
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
let sql = """
guard let db else { return .empty }
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),
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
COALESCE(SUM(estimated_cost_usd),0)
FROM sessions
"""
}
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
defer { sqlite3_finalize(stmt) }
guard sqlite3_step(stmt) == SQLITE_ROW else {
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
return SessionStats(
totalSessions: Int(sqlite3_column_int(stmt, 0)),
totalMessages: Int(sqlite3_column_int(stmt, 1)),
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
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
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 = """
@@ -320,7 +359,11 @@ actor HermesDataService {
outputTokens: Int(sqlite3_column_int(stmt, 12)),
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
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
)
}
@@ -337,14 +380,20 @@ actor HermesDataService {
toolName: columnOptionalText(stmt, 6),
timestamp: columnDate(stmt, 7),
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
)
}
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
guard let json, !json.isEmpty,
let data = json.data(using: .utf8) else { return [] }
return (try? JSONDecoder().decode([HermesToolCall].self, from: data)) ?? []
do {
return try JSONDecoder().decode([HermesToolCall].self, from: data)
} catch {
print("[Scarf] Failed to decode tool calls: \(error.localizedDescription)")
return []
}
}
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
@@ -365,4 +414,17 @@ actor HermesDataService {
let value = sqlite3_column_double(stmt, col)
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",
verbose: values["agent.verbose"] == "true",
autoTTS: values["voice.auto_tts"] != "false",
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? 200
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"] ?? ""
)
}
@@ -52,7 +57,12 @@ struct HermesFileService: Sendable {
func loadGatewayState() -> GatewayState? {
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
return try? JSONDecoder().decode(GatewayState.self, from: data)
do {
return try JSONDecoder().decode(GatewayState.self, from: data)
} catch {
print("[Scarf] Failed to decode gateway state: \(error.localizedDescription)")
return nil
}
}
// MARK: - Memory
@@ -77,8 +87,13 @@ struct HermesFileService: Sendable {
func loadCronJobs() -> [HermesCronJob] {
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
let file = try? JSONDecoder().decode(CronJobsFile.self, from: data)
return file?.jobs ?? []
do {
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
return file.jobs
} catch {
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
return []
}
}
func loadCronOutput(jobId: String) -> String? {
@@ -123,7 +138,13 @@ struct HermesFileService: Sendable {
}
func loadSkillContent(path: String) -> String {
readFile(path) ?? ""
// Validate path stays within the skills directory to prevent traversal
guard !path.contains(".."),
path.hasPrefix(HermesPaths.skillsDir) else {
print("[Scarf] Rejected skill path outside skills directory: \(path)")
return ""
}
return readFile(path) ?? ""
}
// MARK: - Hermes Process
@@ -156,6 +177,10 @@ struct HermesFileService: Sendable {
}
private func writeFile(_ path: String, content: String) {
try? content.write(toFile: path, atomically: true, encoding: .utf8)
do {
try content.write(toFile: path, atomically: true, encoding: .utf8)
} catch {
print("[Scarf] Failed to write \(path): \(error.localizedDescription)")
}
}
}
@@ -3,7 +3,8 @@ import Foundation
@Observable
final class HermesFileWatcher {
private(set) var lastChangeDate = Date()
private var sources: [DispatchSourceFileSystemObject] = []
private var coreSources: [DispatchSourceFileSystemObject] = []
private var projectSources: [DispatchSourceFileSystemObject] = []
private var timer: Timer?
func startWatching() {
@@ -16,11 +17,14 @@ final class HermesFileWatcher {
HermesPaths.cronJobsJSON,
HermesPaths.gatewayStateJSON,
HermesPaths.errorsLog,
HermesPaths.gatewayLog
HermesPaths.gatewayLog,
HermesPaths.projectsRegistry
]
for path in paths {
watchFile(path)
if let source = makeSource(for: path) {
coreSources.append(source)
}
}
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
@@ -29,17 +33,30 @@ final class HermesFileWatcher {
}
func stopWatching() {
for source in sources {
for source in coreSources + projectSources {
source.cancel()
}
sources.removeAll()
coreSources.removeAll()
projectSources.removeAll()
timer?.invalidate()
timer = nil
}
private func watchFile(_ path: String) {
func updateProjectWatches(_ dashboardPaths: [String]) {
for source in projectSources {
source.cancel()
}
projectSources.removeAll()
for path in dashboardPaths {
if let source = makeSource(for: path) {
projectSources.append(source)
}
}
}
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
let fd = Darwin.open(path, O_EVTONLY)
guard fd >= 0 else { return }
guard fd >= 0 else { return nil }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
@@ -53,7 +70,7 @@ final class HermesFileWatcher {
Darwin.close(fd)
}
source.resume()
sources.append(source)
return source
}
deinit {
@@ -39,12 +39,16 @@ actor HermesLogService {
}
func closeLog() {
try? fileHandle?.close()
do {
try fileHandle?.close()
} catch {
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
}
fileHandle = nil
currentPath = nil
}
func readLastLines(count: Int = 200) -> [LogEntry] {
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
guard let path = currentPath,
let data = FileManager.default.contents(atPath: path) else { return [] }
let content = String(data: data, encoding: .utf8) ?? ""
@@ -0,0 +1,63 @@
import Foundation
struct ProjectDashboardService: Sendable {
// MARK: - Registry
func loadRegistry() -> ProjectRegistry {
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
return ProjectRegistry(projects: [])
}
do {
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
} catch {
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
return ProjectRegistry(projects: [])
}
}
func saveRegistry(_ registry: ProjectRegistry) {
let dir = HermesPaths.scarfDir
if !FileManager.default.fileExists(atPath: dir) {
do {
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
} catch {
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
return
}
}
guard let data = try? JSONEncoder().encode(registry) else { return }
// Pretty-print for readability (agents may read this file)
if let pretty = try? JSONSerialization.jsonObject(with: data),
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: formatted)
} else {
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: data)
}
}
// MARK: - Dashboard
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else {
return nil
}
do {
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
} catch {
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
return nil
}
}
func dashboardExists(for project: ProjectEntry) -> Bool {
FileManager.default.fileExists(atPath: project.dashboardPath)
}
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: project.dashboardPath) else {
return nil
}
return attrs[.modificationDate] as? Date
}
}
@@ -10,7 +10,7 @@ struct PersistentTerminalView: NSViewRepresentable {
terminalView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(terminalView)
NSLayoutConstraint.activate([
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
terminalView.topAnchor.constraint(equalTo: container.topAnchor),
terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
@@ -24,7 +24,7 @@ struct PersistentTerminalView: NSViewRepresentable {
terminalView.translatesAutoresizingMaskIntoConstraints = false
nsView.addSubview(terminalView)
NSLayoutConstraint.activate([
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor),
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor, constant: 4),
terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor),
terminalView.topAnchor.constraint(equalTo: nsView.topAnchor),
terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor),
@@ -5,10 +5,7 @@ final class DashboardViewModel {
private let dataService = HermesDataService()
private let fileService = HermesFileService()
var stats = HermesDataService.SessionStats(
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
)
var stats = HermesDataService.SessionStats.empty
var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:]
var config = HermesConfig.empty
@@ -60,6 +60,10 @@ 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))
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 {
@@ -27,7 +27,8 @@ struct ModelUsage: Identifiable {
let outputTokens: Int
let cacheReadTokens: 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 {
@@ -69,7 +70,9 @@ final class InsightsViewModel {
var totalOutputTokens = 0
var totalCacheReadTokens = 0
var totalCacheWriteTokens = 0
var totalReasoningTokens = 0
var totalTokens = 0
var totalCost: Double = 0
var activeTime: TimeInterval = 0
var avgSessionDuration: TimeInterval = 0
@@ -119,7 +122,9 @@ final class InsightsViewModel {
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
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 count = 0
@@ -134,21 +139,22 @@ final class InsightsViewModel {
}
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 {
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.input += s.inputTokens
entry.output += s.outputTokens
entry.cacheRead += s.cacheReadTokens
entry.cacheWrite += s.cacheWriteTokens
entry.reasoning += s.reasoningTokens
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)
cacheWriteTokens: val.cacheWrite, reasoningTokens: val.reasoning)
}.sorted { $0.totalTokens > $1.totalTokens }
}
@@ -158,7 +164,7 @@ final class InsightsViewModel {
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
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + s.reasoningTokens
grouped[s.source] = entry
}
platformUsage = grouped.map { key, val in
@@ -50,7 +50,9 @@ struct InsightsView: View {
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: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
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: "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)))
@@ -273,19 +275,12 @@ struct InsightsView: View {
// 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"
}
KnownPlatforms.icon(for: platform)
}
private func barColor(for toolName: String) -> Color {
switch toolName {
case "terminal": return .orange
case "terminal", "execute_code": return .orange
case "read_file", "search_files": return .green
case "write_file", "patch": return .blue
case "web_search", "web_extract": return .purple
@@ -6,6 +6,7 @@ final class MemoryViewModel {
var memoryContent = ""
var userContent = ""
var memoryProvider = ""
var isEditing = false
var editingFile: EditTarget = .memory
var editText = ""
@@ -17,9 +18,14 @@ final class MemoryViewModel {
var memoryCharCount: Int { memoryContent.count }
var userCharCount: Int { userContent.count }
var hasExternalProvider: Bool {
!memoryProvider.isEmpty && memoryProvider != "file"
}
func load() {
memoryContent = fileService.loadMemory()
userContent = fileService.loadUserProfile()
memoryProvider = fileService.loadConfig().memoryProvider
}
func startEditing(_ target: EditTarget) {
@@ -7,6 +7,18 @@ struct MemoryView: View {
var body: some View {
ScrollView {
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("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
}
@@ -0,0 +1,74 @@
import Foundation
@Observable
final class ProjectsViewModel {
private let service = ProjectDashboardService()
var projects: [ProjectEntry] = []
var selectedProject: ProjectEntry?
var dashboard: ProjectDashboard?
var dashboardError: String?
var isLoading = false
func load() {
let registry = service.loadRegistry()
projects = registry.projects
if let selected = selectedProject, !projects.contains(where: { $0.name == selected.name }) {
selectedProject = nil
dashboard = nil
}
if let selected = selectedProject {
loadDashboard(for: selected)
}
}
func selectProject(_ project: ProjectEntry) {
selectedProject = project
loadDashboard(for: project)
}
func addProject(name: String, path: String) {
var registry = service.loadRegistry()
guard !registry.projects.contains(where: { $0.name == name }) else { return }
let entry = ProjectEntry(name: name, path: path)
registry.projects.append(entry)
service.saveRegistry(registry)
projects = registry.projects
selectProject(entry)
}
func removeProject(_ project: ProjectEntry) {
var registry = service.loadRegistry()
registry.projects.removeAll { $0.name == project.name }
service.saveRegistry(registry)
projects = registry.projects
if selectedProject?.name == project.name {
selectedProject = nil
dashboard = nil
}
}
func refreshDashboard() {
guard let project = selectedProject else { return }
loadDashboard(for: project)
}
var dashboardPaths: [String] {
projects.map(\.dashboardPath)
}
private func loadDashboard(for project: ProjectEntry) {
dashboardError = nil
if !service.dashboardExists(for: project) {
dashboard = nil
dashboardError = "No dashboard found at \(project.dashboardPath)"
return
}
if let loaded = service.loadDashboard(for: project) {
dashboard = loaded
} else {
dashboard = nil
dashboardError = "Failed to parse dashboard JSON"
}
}
}
@@ -0,0 +1,330 @@
import SwiftUI
private enum DashboardTab: String, CaseIterable {
case dashboard = "Dashboard"
case site = "Site"
}
struct ProjectsView: View {
@State private var viewModel = ProjectsViewModel()
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
@State private var showingAddSheet = false
@State private var selectedTab: DashboardTab = .dashboard
var body: some View {
HSplitView {
projectList
.frame(minWidth: 180, maxWidth: 220)
dashboardArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle("Projects")
.task {
viewModel.load()
if let name = coordinator.selectedProjectName,
let project = viewModel.projects.first(where: { $0.name == name }) {
viewModel.selectProject(project)
}
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
.onChange(of: fileWatcher.lastChangeDate) {
viewModel.load()
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
}
// MARK: - Project List
private var projectList: some View {
VStack(spacing: 0) {
List(viewModel.projects, selection: Binding(
get: { viewModel.selectedProject },
set: { project in
if let project {
viewModel.selectProject(project)
}
}
)) { project in
HStack {
Image(systemName: viewModel.dashboard != nil && viewModel.selectedProject == project
? "square.grid.2x2.fill" : "square.grid.2x2")
.foregroundStyle(.secondary)
Text(project.name)
}
.tag(project)
}
.listStyle(.sidebar)
Divider()
HStack {
Button(action: { showingAddSheet = true }) {
Image(systemName: "plus")
}
.buttonStyle(.borderless)
Spacer()
if let selected = viewModel.selectedProject {
Button(action: { viewModel.removeProject(selected) }) {
Image(systemName: "minus")
}
.buttonStyle(.borderless)
}
}
.padding(8)
}
.sheet(isPresented: $showingAddSheet) {
AddProjectSheet { name, path in
viewModel.addProject(name: name, path: path)
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
}
}
// MARK: - Dashboard Area
/// First webview widget found across all sections, if any.
private var siteWidget: DashboardWidget? {
viewModel.dashboard?.sections
.flatMap(\.widgets)
.first { $0.type == "webview" }
}
@ViewBuilder
private var dashboardArea: some View {
if let dashboard = viewModel.dashboard {
VStack(spacing: 0) {
dashboardHeader(dashboard)
.padding(.horizontal)
.padding(.top)
.padding(.bottom, 8)
if siteWidget != nil {
tabBar
.padding(.horizontal)
.padding(.bottom, 8)
}
switch selectedTab {
case .dashboard:
widgetsTab(dashboard)
case .site:
if let widget = siteWidget {
siteTab(widget)
} else {
widgetsTab(dashboard)
}
}
}
} else if let error = viewModel.dashboardError {
ContentUnavailableView {
Label("No Dashboard", systemImage: "square.grid.2x2")
} description: {
Text(error)
}
} else if viewModel.projects.isEmpty {
ContentUnavailableView {
Label("No Projects", systemImage: "square.grid.2x2")
} description: {
Text("Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets.")
} actions: {
Button("Add Project") { showingAddSheet = true }
}
} else {
ContentUnavailableView {
Label("Select a Project", systemImage: "square.grid.2x2")
} description: {
Text("Choose a project from the sidebar to view its dashboard.")
}
}
}
private var tabBar: some View {
HStack(spacing: 0) {
ForEach(DashboardTab.allCases, id: \.self) { tab in
Button {
selectedTab = tab
} label: {
HStack(spacing: 4) {
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
.font(.caption)
Text(tab.rawValue)
.font(.subheadline)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear)
.foregroundStyle(selectedTab == tab ? .primary : .secondary)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
Spacer()
}
}
private func widgetsTab(_ dashboard: ProjectDashboard) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
ForEach(dashboard.sections) { section in
DashboardSectionView(section: section)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
private func siteTab(_ widget: DashboardWidget) -> some View {
WebviewWidgetView(widget: widget, fullCanvas: true)
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func dashboardHeader(_ dashboard: ProjectDashboard) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(dashboard.title)
.font(.title2.bold())
if let desc = dashboard.description {
Text(desc)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Spacer()
if let updated = dashboard.updatedAt {
Text("Updated: \(updated)")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(action: { viewModel.refreshDashboard() }) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
if let project = viewModel.selectedProject {
Button(action: { openInFinder(project.path) }) {
Image(systemName: "folder")
}
.buttonStyle(.borderless)
}
}
}
private func openInFinder(_ path: String) {
NSWorkspace.shared.open(URL(fileURLWithPath: path))
}
}
// MARK: - Section View
struct DashboardSectionView: View {
let section: DashboardSection
/// Filter out webview widgets those are rendered in the Site tab instead.
private var displayWidgets: [DashboardWidget] {
section.widgets.filter { $0.type != "webview" }
}
var body: some View {
if !displayWidgets.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(section.title)
.font(.headline)
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount),
spacing: 12
) {
ForEach(displayWidgets) { widget in
WidgetView(widget: widget)
}
}
}
}
}
}
// MARK: - Widget Dispatcher
struct WidgetView: View {
let widget: DashboardWidget
var body: some View {
Group {
switch widget.type {
case "stat":
StatWidgetView(widget: widget)
case "progress":
ProgressWidgetView(widget: widget)
case "text":
TextWidgetView(widget: widget)
case "table":
TableWidgetView(widget: widget)
case "chart":
ChartWidgetView(widget: widget)
case "list":
ListWidgetView(widget: widget)
case "webview":
WebviewWidgetView(widget: widget)
default:
VStack {
Image(systemName: "questionmark.square.dashed")
.font(.title2)
.foregroundStyle(.secondary)
Text("Unknown: \(widget.type)")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 60)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - Add Project Sheet
struct AddProjectSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var projectName = ""
@State private var projectPath = ""
let onAdd: (String, String) -> Void
var body: some View {
VStack(spacing: 16) {
Text("Add Project")
.font(.headline)
TextField("Project Name", text: $projectName)
.textFieldStyle(.roundedBorder)
HStack {
TextField("Project Path", text: $projectPath)
.textFieldStyle(.roundedBorder)
Button("Browse...") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
projectPath = url.path
if projectName.isEmpty {
projectName = url.lastPathComponent
}
}
}
}
HStack {
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Add") {
guard !projectName.isEmpty, !projectPath.isEmpty else { return }
onAdd(projectName, projectPath)
dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(projectName.isEmpty || projectPath.isEmpty)
}
}
.padding()
.frame(width: 400)
}
}
@@ -0,0 +1,82 @@
import SwiftUI
import Charts
// Flattened data point for Charts to avoid complex nested generic inference
private struct PlottablePoint: Identifiable {
let id = UUID()
let seriesName: String
let x: String
let y: Double
let color: Color
}
struct ChartWidgetView: View {
let widget: DashboardWidget
private var points: [PlottablePoint] {
guard let series = widget.series else { return [] }
return series.flatMap { s in
let color = parseColor(s.color)
return s.data.map { d in
PlottablePoint(seriesName: s.name, x: d.x, y: d.y, color: color)
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
chartContent
.frame(height: 150)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
@ViewBuilder
private var chartContent: some View {
switch widget.chartType {
case "pie":
pieChart
case "bar":
barChart
default:
lineChart
}
}
private var lineChart: some View {
Chart(points) { point in
LineMark(
x: .value("X", point.x),
y: .value("Y", point.y)
)
.foregroundStyle(point.color)
.symbol(by: .value("Series", point.seriesName))
}
}
private var barChart: some View {
Chart(points) { point in
BarMark(
x: .value("X", point.x),
y: .value("Y", point.y)
)
.foregroundStyle(point.color)
}
}
private var pieChart: some View {
Chart(points) { point in
SectorMark(
angle: .value(point.x, point.y),
innerRadius: .ratio(0.5)
)
.foregroundStyle(point.color)
}
}
}
@@ -0,0 +1,54 @@
import SwiftUI
struct ListWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
}
if let items = widget.items {
ForEach(items) { item in
HStack(spacing: 6) {
Image(systemName: statusIcon(item.status))
.font(.caption2)
.foregroundStyle(statusColor(item.status))
Text(item.text)
.font(.callout)
.strikethrough(item.status == "done")
.foregroundStyle(item.status == "done" ? .secondary : .primary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func statusIcon(_ status: String?) -> String {
switch status {
case "done": return "checkmark.circle.fill"
case "active": return "circle.inset.filled"
case "pending": return "circle"
default: return "circle"
}
}
private func statusColor(_ status: String?) -> Color {
switch status {
case "done": return .green
case "active": return .blue
default: return .secondary
}
}
}
@@ -0,0 +1,32 @@
import SwiftUI
struct ProgressWidgetView: View {
let widget: DashboardWidget
private var progressValue: Double {
switch widget.value {
case .number(let n): return n
default: return 0
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
ProgressView(value: progressValue) {
if let label = widget.label {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.tint(parseColor(widget.color))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,37 @@
import SwiftUI
struct StatWidgetView: View {
let widget: DashboardWidget
private var widgetColor: Color {
parseColor(widget.color)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(widgetColor)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
}
if let value = widget.value {
Text(value.displayString)
.font(.system(.title2, design: .monospaced, weight: .semibold))
}
if let subtitle = widget.subtitle {
Text(subtitle)
.font(.caption2)
.foregroundStyle(widgetColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,37 @@
import SwiftUI
struct TableWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
if let columns = widget.columns, let rows = widget.rows {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) {
GridRow {
ForEach(columns, id: \.self) { col in
Text(col)
.font(.caption.bold())
.foregroundStyle(.secondary)
}
}
Divider()
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
GridRow {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
Text(cell)
.font(.callout)
}
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,27 @@
import SwiftUI
struct TextWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
if let content = widget.content {
if widget.format == "markdown",
let attributed = try? AttributedString(markdown: content) {
Text(attributed)
.font(.callout)
} else {
Text(content)
.font(.callout)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,116 @@
import SwiftUI
import WebKit
struct WebviewWidgetView: View {
let widget: DashboardWidget
var fullCanvas: Bool = false
private var webURL: URL? {
guard let urlString = widget.url else { return nil }
return URL(string: urlString)
}
private var viewHeight: CGFloat {
CGFloat(widget.height ?? 400)
}
var body: some View {
if fullCanvas {
fullCanvasView
} else {
cardView
}
}
// MARK: - Full Canvas (Site tab)
private var fullCanvasView: some View {
VStack(spacing: 0) {
if let url = webURL {
WebViewRepresentable(url: url)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Card (inline widget)
private var cardView: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let urlString = widget.url {
Text(urlString)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if let url = webURL {
WebViewRepresentable(url: url)
.frame(height: viewHeight)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
.frame(height: viewHeight)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - WKWebView Wrapper
private struct WebViewRepresentable: NSViewRepresentable {
let url: URL
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
if webView.url != url {
webView.load(URLRequest(url: url))
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView navigation failed: \(error.localizedDescription)")
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView failed to load: \(error.localizedDescription)")
}
}
}
@@ -0,0 +1,19 @@
import SwiftUI
func parseColor(_ name: String?) -> Color {
switch name?.lowercased() {
case "red": return .red
case "orange": return .orange
case "yellow": return .yellow
case "green": return .green
case "blue": return .blue
case "purple": return .purple
case "pink": return .pink
case "teal", "cyan": return .teal
case "indigo": return .indigo
case "mint": return .mint
case "brown": return .brown
case "gray", "grey": return .gray
default: return .blue
}
}
@@ -83,17 +83,7 @@ final class SessionsViewModel {
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
)
let updated = sessions[idx].withTitle(title)
sessions[idx] = updated
if selectedSession?.id == sessionId {
selectedSession = updated
@@ -158,10 +148,10 @@ final class SessionsViewModel {
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)
if Double(size) >= FileSizeUnit.megabyte {
fileSize = String(format: "%.1f MB", Double(size) / FileSizeUnit.megabyte)
} else {
fileSize = String(format: "%.0f KB", Double(size) / 1_024)
fileSize = String(format: "%.0f KB", Double(size) / FileSizeUnit.kilobyte)
}
} else {
fileSize = "unknown"
@@ -44,6 +44,12 @@ struct SessionDetailView: View {
Label(session.model ?? "unknown", systemImage: "cpu")
Label("\(session.messageCount) msgs", systemImage: "bubble.left")
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 {
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
}
@@ -78,6 +84,16 @@ struct MessageBubble: View {
HStack {
if message.isUser { Spacer(minLength: 60) }
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 {
Text(message.content)
.textSelection(.enabled)
@@ -18,7 +18,12 @@ final class SettingsViewModel {
config = fileService.loadConfig()
gatewayState = fileService.loadGatewayState()
hermesRunning = fileService.isHermesRunning()
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
do {
rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
} catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
rawConfigYAML = ""
}
personalities = parsePersonalities()
}
@@ -47,6 +52,9 @@ final class SettingsViewModel {
func setVerbose(_ value: Bool) { setSetting("agent.verbose", 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 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() {
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: "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) }
}
}
@@ -68,6 +69,8 @@ struct SettingsView: View {
SettingsSection(title: "Terminal", icon: "terminal") {
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) }
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) }
}
}
@@ -2,7 +2,7 @@ import Foundation
@Observable
final class ToolsViewModel {
var selectedPlatform: HermesToolPlatform = KnownPlatforms.all[0]
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
var toolsets: [HermesToolset] = []
var mcpStatus: String = ""
var isLoading = false
@@ -30,7 +30,13 @@ final class ToolsViewModel {
}
private func loadPlatforms() {
let config = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
let config: String
do {
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
} catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
config = ""
}
var platforms: [HermesToolPlatform] = []
var inSection = false
for line in config.components(separatedBy: "\n") {
@@ -54,9 +60,10 @@ final class ToolsViewModel {
}
}
}
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.all[0]] : platforms
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }) {
selectedPlatform = availablePlatforms[0]
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.cli] : platforms
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }),
let first = availablePlatforms.first {
selectedPlatform = first
}
}
@@ -5,6 +5,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case insights = "Insights"
case sessions = "Sessions"
case activity = "Activity"
case projects = "Projects"
case chat = "Chat"
case memory = "Memory"
case skills = "Skills"
@@ -23,6 +24,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .insights: return "chart.bar"
case .sessions: return "bubble.left.and.bubble.right"
case .activity: return "bolt.horizontal"
case .projects: return "square.grid.2x2"
case .chat: return "text.bubble"
case .memory: return "brain"
case .skills: return "lightbulb"
@@ -40,4 +42,5 @@ enum SidebarSection: String, CaseIterable, Identifiable {
final class AppCoordinator {
var selectedSection: SidebarSection = .dashboard
var selectedSessionId: String?
var selectedProjectName: String?
}
+6
View File
@@ -12,6 +12,12 @@ struct SidebarView: View {
.tag(section)
}
}
Section("Projects") {
ForEach([SidebarSection.projects]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
}
}
Section("Interact") {
ForEach([SidebarSection.chat, .memory, .skills]) { section in
Label(section.rawValue, systemImage: section.icon)