mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
Initial release: Scarf — macOS GUI for the Hermes AI agent
Native SwiftUI app providing full visibility into the Hermes AI agent: - Dashboard with system health, token usage, and cost tracking - Sessions browser with conversation detail and FTS5 search - Activity feed with tool call inspector (read/edit/execute/fetch/browser) - Embedded terminal chat via SwiftTerm with full ANSI/Rich rendering - Memory viewer/editor with live file-watching refresh - Skills browser by category with file content viewer - Cron job viewer with output display - Real-time log tailing with level filtering - Settings display with raw config and Finder path links - Menu bar status icon with quick actions Architecture: MVVM-Feature, zero dependencies beyond SwiftTerm, read-only SQLite access, Swift 6 strict concurrency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug in Scarf
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
|
||||
**Expected behavior**
|
||||
What you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots.
|
||||
|
||||
**Environment**
|
||||
- macOS version:
|
||||
- Xcode version:
|
||||
- Hermes version:
|
||||
- Scarf version/commit:
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea for Scarf
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem?**
|
||||
A clear description of the problem.
|
||||
|
||||
**Describe the solution you'd like**
|
||||
What you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
Other solutions you've thought about.
|
||||
|
||||
**Additional context**
|
||||
Any other context, screenshots, or mockups.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Xcode
|
||||
build/
|
||||
DerivedData/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata/
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
Packages/
|
||||
Package.pins
|
||||
Package.resolved
|
||||
*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
|
||||
.swiftpm/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
|
||||
# IDE
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
scarf/standards/backups/
|
||||
@@ -0,0 +1,40 @@
|
||||
# Scarf — macOS GUI for the Hermes AI Agent
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup — auto-discovers files)
|
||||
scarf/ Main app target source
|
||||
Core/Services/ HermesDataService, HermesFileService, HermesLogService, ACPClient, HermesFileWatcher
|
||||
Core/Models/ Plain structs: HermesSession, HermesMessage, HermesConfig, etc.
|
||||
Features/ MVVM-F feature modules (Dashboard, Sessions, Activity, Chat, Memory, Skills, Cron, Logs, Settings)
|
||||
Navigation/ AppCoordinator, SidebarView
|
||||
docs/ PRD, Architecture, Discovery notes
|
||||
standards/ Copied development standards (read-only reference)
|
||||
```
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
- **MVVM-F**: Features never import sibling features. Cross-feature goes through services.
|
||||
- **AppCoordinator**: Single `@Observable` coordinator for all navigation state, injected via `.environment()`.
|
||||
- **No external dependencies**: System SQLite3, Foundation JSON, AttributedString markdown.
|
||||
- **Read-only DB access**: Never write to `~/.hermes/state.db`. Only write to memory files and cron jobs.
|
||||
- **Sandbox disabled**: App reads `~/.hermes/` directly.
|
||||
- **Swift 6 concurrency**: `@MainActor` default. Services use `nonisolated` + async/await.
|
||||
|
||||
## Key Paths
|
||||
|
||||
- Hermes home: `~/.hermes/`
|
||||
- SQLite DB: `~/.hermes/state.db` (WAL mode, read-only)
|
||||
- Config: `~/.hermes/config.yaml`
|
||||
- Memory: `~/.hermes/memories/MEMORY.md`, `~/.hermes/memories/USER.md`
|
||||
- Sessions: `~/.hermes/sessions/session_*.json`
|
||||
- Cron: `~/.hermes/cron/jobs.json`
|
||||
- Logs: `~/.hermes/logs/errors.log`, `~/.hermes/logs/gateway.log`
|
||||
- ACP: `hermes acp` subprocess (stdio JSON-RPC)
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
# Contributing to Scarf
|
||||
|
||||
Thanks for your interest in contributing to Scarf.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork and clone the repo
|
||||
2. Open `scarf/scarf.xcodeproj` in Xcode 26.3+
|
||||
3. Build and run (requires macOS 26.2+ and Hermes installed at `~/.hermes/`)
|
||||
|
||||
## Architecture
|
||||
|
||||
Scarf uses the MVVM-Feature pattern. Each feature is a self-contained module under `Features/`:
|
||||
|
||||
```
|
||||
Features/FeatureName/
|
||||
Views/ SwiftUI views
|
||||
ViewModels/ @Observable view models
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Features never import sibling features directly
|
||||
- Cross-feature navigation goes through `AppCoordinator`
|
||||
- Services in `Core/Services/` are shared across features
|
||||
- Models in `Core/Models/` are plain structs
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep it simple. Minimal dependencies, no over-engineering.
|
||||
- No commented-out code, TODOs, or deferred functionality in PRs.
|
||||
- All code must build with zero warnings.
|
||||
- Follow existing patterns — look at how similar features are built before adding new ones.
|
||||
- The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception.
|
||||
- Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Open an issue with:
|
||||
- What you expected to happen
|
||||
- What actually happened
|
||||
- macOS version and Hermes version
|
||||
- Steps to reproduce
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Open an issue first to discuss the change
|
||||
- One feature or fix per PR
|
||||
- Include a clear description of what changed and why
|
||||
- Ensure the project builds with `xcodebuild -project scarf/scarf.xcodeproj -scheme scarf build`
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Alan Wizemann
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Scarf
|
||||
|
||||
A native macOS companion app for the [Hermes AI agent](https://github.com/hermes-ai/hermes-agent). Scarf gives you full visibility into what Hermes is doing, when, and what it creates — replacing CLI opacity with a clean, native interface.
|
||||
|
||||
  
|
||||
|
||||
## 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** — Real-time 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
|
||||
- **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
|
||||
- **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/`
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/scarf.git
|
||||
cd scarf/scarf
|
||||
open scarf.xcodeproj
|
||||
```
|
||||
|
||||
Or from the command line:
|
||||
|
||||
```bash
|
||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Scarf follows the **MVVM-Feature** pattern with zero external dependencies beyond SwiftTerm:
|
||||
|
||||
```
|
||||
scarf/
|
||||
Core/
|
||||
Models/ Plain data structs (HermesSession, HermesMessage, HermesConfig, etc.)
|
||||
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
|
||||
Activity/ Tool execution feed with inspector
|
||||
Chat/ Embedded terminal via SwiftTerm
|
||||
Memory/ Memory viewer and editor
|
||||
Skills/ Skill browser by category
|
||||
Cron/ Scheduled job viewer
|
||||
Logs/ Real-time log viewer
|
||||
Settings/ Configuration display
|
||||
Navigation/ AppCoordinator + SidebarView
|
||||
```
|
||||
|
||||
### Data Sources
|
||||
|
||||
Scarf reads Hermes data directly from `~/.hermes/`:
|
||||
|
||||
| Source | Format | Access |
|
||||
|--------|--------|--------|
|
||||
| `state.db` | SQLite (WAL mode) | Read-only |
|
||||
| `config.yaml` | YAML | Read-only |
|
||||
| `memories/*.md` | Markdown | Read/Write |
|
||||
| `cron/jobs.json` | JSON | Read-only |
|
||||
| `logs/*.log` | Text | Read-only |
|
||||
| `gateway_state.json` | JSON | Read-only |
|
||||
| `skills/` | Directory tree | Read-only |
|
||||
| `hermes chat` | Terminal subprocess | Interactive |
|
||||
|
||||
The app **never writes** to `state.db` — it opens in read-only mode to avoid WAL contention with Hermes.
|
||||
|
||||
### Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| [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.
|
||||
|
||||
## 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.
|
||||
|
||||
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||
|
||||
1. Fork the repo
|
||||
2. Create your feature branch (`git checkout -b feature/my-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add my feature'`)
|
||||
4. Push to the branch (`git push origin feature/my-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -0,0 +1,111 @@
|
||||
# Scarf — Architecture
|
||||
|
||||
## Pattern: MVVM-Feature
|
||||
|
||||
Per project standards, every feature is a self-contained module owning Models, ViewModels, and Views.
|
||||
|
||||
```
|
||||
scarf/
|
||||
Core/
|
||||
Services/ Hermes data access (SQLite, file I/O, ACP)
|
||||
Models/ Plain data structs for Hermes entities
|
||||
Features/
|
||||
Dashboard/
|
||||
Views/ DashboardView
|
||||
ViewModels/ DashboardViewModel
|
||||
Sessions/
|
||||
Views/ SessionsView, SessionDetailView
|
||||
ViewModels/ SessionsViewModel
|
||||
Activity/
|
||||
Views/ ActivityView
|
||||
ViewModels/ ActivityViewModel
|
||||
Chat/
|
||||
Views/ ChatView
|
||||
ViewModels/ ChatViewModel
|
||||
Memory/
|
||||
Views/ MemoryView
|
||||
ViewModels/ MemoryViewModel
|
||||
Skills/
|
||||
Views/ SkillsView
|
||||
ViewModels/ SkillsViewModel
|
||||
Cron/
|
||||
Views/ CronView
|
||||
ViewModels/ CronViewModel
|
||||
Logs/
|
||||
Views/ LogsView
|
||||
ViewModels/ LogsViewModel
|
||||
Settings/
|
||||
Views/ SettingsView
|
||||
ViewModels/ SettingsViewModel
|
||||
Navigation/
|
||||
AppCoordinator.swift
|
||||
SidebarView.swift
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
`AppCoordinator` is `@Observable` and injected via `.environment()` at the app root. It owns:
|
||||
- `selectedSection: SidebarSection` — which feature is active
|
||||
- `selectedSessionID: String?` — drill-down into a session
|
||||
|
||||
One `NavigationSplitView` at top level, driven by the coordinator. Leaf views read but never own navigation state.
|
||||
|
||||
## Services
|
||||
|
||||
### HermesDataService
|
||||
- Opens `~/.hermes/state.db` read-only via SQLite3 C API
|
||||
- Queries `sessions` and `messages` tables
|
||||
- Provides session list, message history, search (FTS5), and aggregate stats
|
||||
- Polling-based refresh (watches WAL modification time)
|
||||
|
||||
### HermesFileService
|
||||
- Reads config.yaml (simple line parser for the YAML subset we need)
|
||||
- Reads/writes memory markdown files
|
||||
- Reads cron jobs.json, gateway_state.json, session JSON files
|
||||
- Reads skill directory structure
|
||||
|
||||
### HermesLogService
|
||||
- Tails log files using file handle + periodic polling
|
||||
- Parses log level from line format
|
||||
|
||||
### ACPClient
|
||||
- Spawns `hermes acp` via Foundation `Process`
|
||||
- Writes JSON-RPC to stdin, reads from stdout
|
||||
- Streams events: ToolCallStart, ToolCallProgress, AgentMessage, AgentThought
|
||||
- Manages session lifecycle
|
||||
|
||||
### HermesFileWatcher
|
||||
- Uses `DispatchSource.makeFileSystemObjectSource` on key directories
|
||||
- Triggers refresh callbacks when Hermes writes new data
|
||||
|
||||
## Dependencies
|
||||
|
||||
Zero external SPM packages:
|
||||
- **SQLite**: System `sqlite3` C library (available on macOS, `import SQLite3` not needed — use `libsqlite3`)
|
||||
- **JSON**: Foundation `JSONDecoder` / `JSONSerialization`
|
||||
- **YAML**: Custom lightweight parser for flat config structure
|
||||
- **Markdown**: `AttributedString(markdown:)` (built into Foundation)
|
||||
- **File watching**: GCD `DispatchSource`
|
||||
- **Subprocess**: Foundation `Process` + `Pipe`
|
||||
|
||||
## Sandbox
|
||||
|
||||
Disabled. This app reads directly from `~/.hermes/` which is outside any app sandbox container. The `ENABLE_APP_SANDBOX` build setting is set to `NO`.
|
||||
|
||||
## Concurrency
|
||||
|
||||
- Swift 6 strict concurrency with `@MainActor` default isolation
|
||||
- Services use `nonisolated` methods with async/await for I/O
|
||||
- `@Observable` ViewModels on MainActor, call into nonisolated services
|
||||
- ACP client runs its read loop on a background task
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
~/.hermes/state.db ──→ HermesDataService ──→ ViewModels ──→ Views
|
||||
~/.hermes/config.yaml ──→ HermesFileService ──→ ViewModels ──→ Views
|
||||
~/.hermes/memories/ ──→ HermesFileService ──→ ViewModels ──→ Views
|
||||
~/.hermes/logs/ ──→ HermesLogService ──→ ViewModels ──→ Views
|
||||
hermes acp (subprocess) ──→ ACPClient ──→ ChatViewModel ──→ ChatView
|
||||
HermesFileWatcher ──→ triggers refresh on all services
|
||||
```
|
||||
@@ -0,0 +1,183 @@
|
||||
# Hermes Agent — Discovery Notes
|
||||
|
||||
## Installation
|
||||
|
||||
- Binary: `~/.local/bin/hermes` (symlink to venv wrapper)
|
||||
- Codebase: `~/.hermes/hermes-agent/` (Python 3.11 venv)
|
||||
- Version: v0.6.0 (March 30, 2026)
|
||||
- Runs as daemon process
|
||||
|
||||
## What Hermes Does
|
||||
|
||||
A self-improving AI agent with tool-calling capabilities:
|
||||
- Interactive terminal chat with syntax highlighting
|
||||
- 40+ tools (terminal, file, browser, web, code execution, vision, etc.)
|
||||
- Autonomous skill creation from complex tasks
|
||||
- Persistent memory (MEMORY.md + USER.md) with periodic nudges
|
||||
- Multi-platform messaging gateway (Telegram, Discord, Slack, WhatsApp, Signal, Email)
|
||||
- Cron scheduler for recurring tasks
|
||||
- Session persistence in SQLite with FTS5 search
|
||||
- Subagent delegation for parallel workstreams
|
||||
- MCP (Model Context Protocol) integration
|
||||
- ACP (Agent Client Protocol) for IDE integration
|
||||
|
||||
## File System Layout
|
||||
|
||||
```
|
||||
~/.hermes/
|
||||
hermes-agent/ Python codebase (70 directories)
|
||||
run_agent.py Core agent loop
|
||||
cli.py Terminal UI
|
||||
model_tools.py Tool dispatcher
|
||||
toolsets.py Tool definitions
|
||||
agent/ Agent internals
|
||||
tools/ 40+ tool implementations
|
||||
gateway/ Multi-platform messaging
|
||||
cron/ Scheduler implementation
|
||||
hermes_cli/ CLI command handlers
|
||||
acp_adapter/ Agent Client Protocol server
|
||||
venv/ Python environment
|
||||
config.yaml User configuration (8.8 KB)
|
||||
.env API keys (encrypted)
|
||||
auth.json OAuth tokens
|
||||
state.db SQLite session database (WAL mode)
|
||||
sessions/ JSON conversation snapshots
|
||||
memories/ MEMORY.md, USER.md
|
||||
skills/ 29 installed skills across 15+ categories
|
||||
cron/
|
||||
jobs.json Scheduled job definitions
|
||||
output/ Job execution output
|
||||
logs/
|
||||
errors.log Application errors
|
||||
gateway.log Gateway platform logs
|
||||
gateway_state.json Gateway process lifecycle
|
||||
```
|
||||
|
||||
## SQLite Schema (state.db, version 6)
|
||||
|
||||
### sessions table
|
||||
```sql
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT, -- 'cli', 'telegram', 'discord', etc.
|
||||
user_id TEXT,
|
||||
model TEXT,
|
||||
model_config TEXT, -- JSON
|
||||
system_prompt TEXT,
|
||||
parent_session_id TEXT, -- Session splitting on compression
|
||||
started_at REAL,
|
||||
ended_at REAL,
|
||||
end_reason TEXT,
|
||||
message_count INTEGER,
|
||||
tool_call_count INTEGER,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
cache_read_tokens INTEGER,
|
||||
cache_write_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
billing_provider TEXT,
|
||||
billing_base_url TEXT,
|
||||
billing_mode TEXT,
|
||||
estimated_cost_usd REAL,
|
||||
actual_cost_usd REAL,
|
||||
cost_status TEXT,
|
||||
cost_source TEXT,
|
||||
pricing_version TEXT,
|
||||
title TEXT UNIQUE
|
||||
```
|
||||
|
||||
### messages table
|
||||
```sql
|
||||
id INTEGER PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
role TEXT, -- 'user' or 'assistant'
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT, -- JSON array of tool invocations
|
||||
tool_name TEXT,
|
||||
timestamp REAL,
|
||||
token_count INTEGER,
|
||||
finish_reason TEXT,
|
||||
reasoning TEXT,
|
||||
reasoning_details TEXT,
|
||||
codex_reasoning_items TEXT
|
||||
```
|
||||
|
||||
### messages_fts (FTS5 virtual table)
|
||||
Full-text search on message content.
|
||||
|
||||
## Session JSON Format
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "YYYYmmdd_HHMMSS_6hexchars",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"platform": "cli",
|
||||
"session_start": "ISO8601",
|
||||
"last_updated": "ISO8601",
|
||||
"system_prompt": "...",
|
||||
"tools": [{"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "..."},
|
||||
{"role": "assistant", "content": "...", "tool_calls": [
|
||||
{"id": "call_...", "type": "function", "function": {"name": "terminal", "arguments": "{\"command\": \"...\"}"}}
|
||||
]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cron Jobs Format
|
||||
|
||||
```json
|
||||
{
|
||||
"jobs": [{
|
||||
"id": "12hexchars",
|
||||
"name": "Job Name",
|
||||
"prompt": "What to do",
|
||||
"skills": ["skill-name"],
|
||||
"schedule": {"kind": "once|cron", "run_at": "ISO8601", "display": "human readable"},
|
||||
"repeat": {"times": 1, "completed": 0},
|
||||
"enabled": true,
|
||||
"state": "scheduled|running|completed",
|
||||
"deliver": "origin|telegram|discord",
|
||||
"next_run_at": "ISO8601",
|
||||
"last_run_at": "ISO8601|null",
|
||||
"last_error": "string|null"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Config Structure (config.yaml)
|
||||
|
||||
Key sections: model (default, provider), agent (max_turns, tool_use_enforcement, personalities), terminal (backend, cwd, timeout), memory (enabled, char limits, nudge interval), display (personality, streaming, show_reasoning), platform_toolsets (tools per platform).
|
||||
|
||||
## ACP (Agent Client Protocol)
|
||||
|
||||
- Entry: `hermes acp` or `python -m acp_adapter.entry`
|
||||
- Transport: stdio JSON-RPC (not HTTP)
|
||||
- Lifecycle: initialize() -> new_session()/load_session() -> send messages
|
||||
- Events emitted: ToolCallStart, ToolCallProgress, AgentMessage, AgentThought, SessionUpdate
|
||||
- Tool kinds: read, edit, execute, fetch, search, think, other
|
||||
- Tool call IDs: `tc-{uuid.hex[:12]}`
|
||||
|
||||
## Log Format
|
||||
|
||||
```
|
||||
YYYY-MM-DD HH:MM:SS,MMM LEVEL logger_name: message
|
||||
```
|
||||
|
||||
## Gateway State
|
||||
|
||||
```json
|
||||
{
|
||||
"pid": 12345,
|
||||
"kind": "hermes-gateway",
|
||||
"gateway_state": "running|startup_failed",
|
||||
"exit_reason": "string|null",
|
||||
"platforms": {},
|
||||
"updated_at": "ISO8601"
|
||||
}
|
||||
```
|
||||
|
||||
## SQLite Contention Notes
|
||||
|
||||
Hermes uses WAL mode with aggressive retry (15 retries, 20-150ms jitter). Scarf must only open state.db in read-only mode to avoid write contention. Checkpoint every 50 writes. WAL file modification is a good signal for refresh.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Scarf — Product Requirements Document
|
||||
|
||||
## Overview
|
||||
|
||||
Scarf is a native macOS application that provides a graphical interface for the Hermes AI agent. Hermes is a CLI-based AI agent with 40+ tools, multi-platform messaging, autonomous skill creation, persistent memory, and scheduled automation. Scarf gives users visibility into what Hermes is doing, when, and what it creates.
|
||||
|
||||
## Problem
|
||||
|
||||
Hermes operates entirely through CLI with no visual dashboard. Users cannot easily:
|
||||
- See what the agent is currently doing or has done
|
||||
- Browse conversation history across sessions
|
||||
- Monitor tool executions in real-time
|
||||
- Manage memory, skills, or cron jobs visually
|
||||
- Chat with the agent through a native interface
|
||||
|
||||
## Target User
|
||||
|
||||
Developer running Hermes locally on macOS who wants transparency and control over agent activity.
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Dashboard
|
||||
- System health overview (model, provider, connection status)
|
||||
- Active session indicator
|
||||
- Token usage and cost summary (aggregated from session data)
|
||||
- Gateway platform connection status
|
||||
- Recent activity feed
|
||||
|
||||
### 2. Sessions Browser
|
||||
- List all conversation sessions with metadata (source, message count, tool calls, cost, duration)
|
||||
- Full conversation detail view with message rendering
|
||||
- Full-text search across all sessions (via SQLite FTS5)
|
||||
- Session lineage tracking (parent_session_id chains)
|
||||
|
||||
### 3. Activity Feed
|
||||
- Real-time tool execution monitoring (the core transparency feature)
|
||||
- Each entry: tool name, kind, arguments, result preview, timestamp
|
||||
- Filterable by tool type, session, time range
|
||||
- Color-coded by tool kind (read/edit/execute/fetch)
|
||||
|
||||
### 4. Live Chat
|
||||
- Send messages to Hermes via ACP (Agent Client Protocol)
|
||||
- Stream responses with tool calls shown inline
|
||||
- Session management (new, load, resume)
|
||||
|
||||
### 5. Memory Viewer/Editor
|
||||
- Display MEMORY.md and USER.md with markdown rendering
|
||||
- Edit and save changes
|
||||
- Character count vs configured limits
|
||||
|
||||
### 6. Skills Browser
|
||||
- Tree view by category
|
||||
- Skill metadata display
|
||||
- Search and filter
|
||||
|
||||
### 7. Cron Manager
|
||||
- View scheduled jobs with status, next/last run times
|
||||
- View job output
|
||||
- Enable/disable jobs
|
||||
|
||||
### 8. Log Viewer
|
||||
- Real-time log tailing (errors.log, gateway.log)
|
||||
- Level-based filtering and text search
|
||||
|
||||
### 9. Menu Bar Presence
|
||||
- Status icon showing Hermes state (running/idle/error)
|
||||
- Quick access to recent session, new chat
|
||||
|
||||
## Technical Constraints
|
||||
|
||||
- macOS 26.2+ (SwiftUI, Swift 6 concurrency)
|
||||
- No external SPM dependencies — uses system SQLite3 C API, Foundation JSON
|
||||
- Reads Hermes data from `~/.hermes/` (requires sandbox disabled)
|
||||
- ACP communication via subprocess stdio JSON-RPC
|
||||
- App sandbox disabled (developer tool needing filesystem access)
|
||||
|
||||
## Data Sources
|
||||
|
||||
| Source | Path | Format | Access |
|
||||
|--------|------|--------|--------|
|
||||
| Sessions DB | `~/.hermes/state.db` | SQLite (WAL) | Read-only |
|
||||
| Session files | `~/.hermes/sessions/*.json` | JSON | Read-only |
|
||||
| Config | `~/.hermes/config.yaml` | YAML | Read/Write |
|
||||
| Memory | `~/.hermes/memories/*.md` | Markdown | Read/Write |
|
||||
| Cron jobs | `~/.hermes/cron/jobs.json` | JSON | Read/Write |
|
||||
| Cron output | `~/.hermes/cron/output/` | Text | Read-only |
|
||||
| Logs | `~/.hermes/logs/*.log` | Text | Read-only |
|
||||
| Gateway state | `~/.hermes/gateway_state.json` | JSON | Read-only |
|
||||
| Skills | `~/.hermes/skills/` | Directory tree | Read-only |
|
||||
| ACP | `hermes acp` subprocess | JSON-RPC stdio | Bidirectional |
|
||||
|
||||
## Non-Goals (v1)
|
||||
|
||||
- Config editing UI (read-only display for v1, except memory)
|
||||
- Skill creation or management
|
||||
- Gateway platform management
|
||||
- Multi-user support
|
||||
@@ -0,0 +1,604 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
534959502F7B83B700BD31AD /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5349593F2F7B83B600BD31AD;
|
||||
remoteInfo = scarf;
|
||||
};
|
||||
5349595A2F7B83B700BD31AD /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5349593F2F7B83B600BD31AD;
|
||||
remoteInfo = scarf;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
534959402F7B83B600BD31AD /* scarf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = scarf.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5349594F2F7B83B700BD31AD /* scarfTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
534959422F7B83B600BD31AD /* scarf */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = scarf;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534959522F7B83B700BD31AD /* scarfTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = scarfTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5349595C2F7B83B700BD31AD /* scarfUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = scarfUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
5349593D2F7B83B600BD31AD /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5349594C2F7B83B700BD31AD /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
534959562F7B83B700BD31AD /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
534959372F7B83B600BD31AD = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
534959422F7B83B600BD31AD /* scarf */,
|
||||
534959522F7B83B700BD31AD /* scarfTests */,
|
||||
5349595C2F7B83B700BD31AD /* scarfUITests */,
|
||||
534959412F7B83B600BD31AD /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534959412F7B83B600BD31AD /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
534959402F7B83B600BD31AD /* scarf.app */,
|
||||
5349594F2F7B83B700BD31AD /* scarfTests.xctest */,
|
||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
5349593F2F7B83B600BD31AD /* scarf */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */;
|
||||
buildPhases = (
|
||||
5349593C2F7B83B600BD31AD /* Sources */,
|
||||
5349593D2F7B83B600BD31AD /* Frameworks */,
|
||||
5349593E2F7B83B600BD31AD /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
534959422F7B83B600BD31AD /* scarf */,
|
||||
);
|
||||
name = scarf;
|
||||
packageProductDependencies = (
|
||||
53SWIFTTERM0001 /* SwiftTerm */,
|
||||
);
|
||||
productName = scarf;
|
||||
productReference = 534959402F7B83B600BD31AD /* scarf.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
5349594E2F7B83B700BD31AD /* scarfTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 534959662F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfTests" */;
|
||||
buildPhases = (
|
||||
5349594B2F7B83B700BD31AD /* Sources */,
|
||||
5349594C2F7B83B700BD31AD /* Frameworks */,
|
||||
5349594D2F7B83B700BD31AD /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
534959512F7B83B700BD31AD /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
534959522F7B83B700BD31AD /* scarfTests */,
|
||||
);
|
||||
name = scarfTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = scarfTests;
|
||||
productReference = 5349594F2F7B83B700BD31AD /* scarfTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
534959582F7B83B700BD31AD /* scarfUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 534959692F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfUITests" */;
|
||||
buildPhases = (
|
||||
534959552F7B83B700BD31AD /* Sources */,
|
||||
534959562F7B83B700BD31AD /* Frameworks */,
|
||||
534959572F7B83B700BD31AD /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5349595B2F7B83B700BD31AD /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
5349595C2F7B83B700BD31AD /* scarfUITests */,
|
||||
);
|
||||
name = scarfUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = scarfUITests;
|
||||
productReference = 534959592F7B83B700BD31AD /* scarfUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
534959382F7B83B600BD31AD /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
5349593F2F7B83B600BD31AD = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
5349594E2F7B83B700BD31AD = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
TestTargetID = 5349593F2F7B83B600BD31AD;
|
||||
};
|
||||
534959582F7B83B700BD31AD = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
TestTargetID = 5349593F2F7B83B600BD31AD;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 5349593B2F7B83B600BD31AD /* Build configuration list for PBXProject "scarf" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 534959372F7B83B600BD31AD;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 534959412F7B83B600BD31AD /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
5349593F2F7B83B600BD31AD /* scarf */,
|
||||
5349594E2F7B83B700BD31AD /* scarfTests */,
|
||||
534959582F7B83B700BD31AD /* scarfUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
5349593E2F7B83B600BD31AD /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5349594D2F7B83B700BD31AD /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
534959572F7B83B700BD31AD /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
5349593C2F7B83B600BD31AD /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5349594B2F7B83B700BD31AD /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
534959552F7B83B700BD31AD /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
534959512F7B83B700BD31AD /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
||||
targetProxy = 534959502F7B83B700BD31AD /* PBXContainerItemProxy */;
|
||||
};
|
||||
5349595B2F7B83B700BD31AD /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
||||
targetProxy = 5349595A2F7B83B700BD31AD /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
534959612F7B83B700BD31AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
534959622F7B83B700BD31AD /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
534959642F7B83B700BD31AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
534959652F7B83B700BD31AD /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
534959672F7B83B700BD31AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/scarf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/scarf";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
534959682F7B83B700BD31AD /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/scarf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/scarf";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
5349596A2F7B83B700BD31AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = scarf;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5349596B2F7B83B700BD31AD /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = scarf;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
5349593B2F7B83B600BD31AD /* Build configuration list for PBXProject "scarf" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
534959612F7B83B700BD31AD /* Debug */,
|
||||
534959622F7B83B700BD31AD /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
534959642F7B83B700BD31AD /* Debug */,
|
||||
534959652F7B83B700BD31AD /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
534959662F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
534959672F7B83B700BD31AD /* Debug */,
|
||||
534959682F7B83B700BD31AD /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
534959692F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5349596A2F7B83B700BD31AD /* Debug */,
|
||||
5349596B2F7B83B700BD31AD /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.0.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
53SWIFTTERM0001 /* SwiftTerm */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
||||
productName = SwiftTerm;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 534959382F7B83B600BD31AD /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
} detail: {
|
||||
detailView
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
switch coordinator.selectedSection {
|
||||
case .dashboard:
|
||||
DashboardView()
|
||||
case .sessions:
|
||||
SessionsView()
|
||||
case .activity:
|
||||
ActivityView()
|
||||
case .chat:
|
||||
ChatView()
|
||||
case .memory:
|
||||
MemoryView()
|
||||
case .skills:
|
||||
SkillsView()
|
||||
case .cron:
|
||||
CronView()
|
||||
case .logs:
|
||||
LogsView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesConfig: Sendable {
|
||||
var model: String
|
||||
var provider: String
|
||||
var maxTurns: Int
|
||||
var personality: String
|
||||
var terminalBackend: String
|
||||
var memoryEnabled: Bool
|
||||
var memoryCharLimit: Int
|
||||
var userCharLimit: Int
|
||||
var nudgeInterval: Int
|
||||
var streaming: Bool
|
||||
var showReasoning: Bool
|
||||
var verbose: Bool
|
||||
|
||||
static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
provider: "unknown",
|
||||
maxTurns: 0,
|
||||
personality: "default",
|
||||
terminalBackend: "local",
|
||||
memoryEnabled: false,
|
||||
memoryCharLimit: 0,
|
||||
userCharLimit: 0,
|
||||
nudgeInterval: 0,
|
||||
streaming: true,
|
||||
showReasoning: false,
|
||||
verbose: false
|
||||
)
|
||||
}
|
||||
|
||||
struct GatewayState: Sendable, Codable {
|
||||
let pid: Int?
|
||||
let kind: String?
|
||||
let gatewayState: String?
|
||||
let exitReason: String?
|
||||
let platforms: [String: PlatformState]?
|
||||
let updatedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case pid, kind
|
||||
case gatewayState = "gateway_state"
|
||||
case exitReason = "exit_reason"
|
||||
case platforms
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
var isRunning: Bool {
|
||||
gatewayState == "running"
|
||||
}
|
||||
|
||||
var statusText: String {
|
||||
gatewayState ?? "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
struct PlatformState: Sendable, Codable {
|
||||
let connected: Bool?
|
||||
let error: String?
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
enum HermesPaths: Sendable {
|
||||
// Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory
|
||||
nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes"
|
||||
nonisolated static let stateDB: String = home + "/state.db"
|
||||
nonisolated static let configYAML: String = home + "/config.yaml"
|
||||
nonisolated static let memoriesDir: String = home + "/memories"
|
||||
nonisolated static let memoryMD: String = memoriesDir + "/MEMORY.md"
|
||||
nonisolated static let userMD: String = memoriesDir + "/USER.md"
|
||||
nonisolated static let sessionsDir: String = home + "/sessions"
|
||||
nonisolated static let cronJobsJSON: String = home + "/cron/jobs.json"
|
||||
nonisolated static let cronOutputDir: String = home + "/cron/output"
|
||||
nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json"
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let prompt: String
|
||||
let skills: [String]?
|
||||
let model: String?
|
||||
let schedule: CronSchedule
|
||||
let enabled: Bool
|
||||
let state: String
|
||||
let deliver: String?
|
||||
let nextRunAt: String?
|
||||
let lastRunAt: String?
|
||||
let lastError: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver
|
||||
case nextRunAt = "next_run_at"
|
||||
case lastRunAt = "last_run_at"
|
||||
case lastError = "last_error"
|
||||
}
|
||||
|
||||
var stateIcon: String {
|
||||
switch state {
|
||||
case "scheduled": return "clock"
|
||||
case "running": return "play.circle"
|
||||
case "completed": return "checkmark.circle"
|
||||
case "failed": return "xmark.circle"
|
||||
default: return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CronSchedule: Sendable, Codable {
|
||||
let kind: String
|
||||
let runAt: String?
|
||||
let display: String?
|
||||
let expression: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case runAt = "run_at"
|
||||
case display
|
||||
case expression
|
||||
}
|
||||
}
|
||||
|
||||
struct CronJobsFile: Sendable, Codable {
|
||||
let jobs: [HermesCronJob]
|
||||
let updatedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobs
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesMessage: Identifiable, Sendable {
|
||||
let id: Int
|
||||
let sessionId: String
|
||||
let role: String
|
||||
let content: String
|
||||
let toolCallId: String?
|
||||
let toolCalls: [HermesToolCall]
|
||||
let toolName: String?
|
||||
let timestamp: Date?
|
||||
let tokenCount: Int?
|
||||
let finishReason: String?
|
||||
|
||||
var isUser: Bool { role == "user" }
|
||||
var isAssistant: Bool { role == "assistant" }
|
||||
var isToolResult: Bool { role == "tool" }
|
||||
}
|
||||
|
||||
struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||
var id: String { callId }
|
||||
let callId: String
|
||||
let functionName: String
|
||||
let arguments: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case callId = "id"
|
||||
case type
|
||||
case function
|
||||
}
|
||||
|
||||
enum FunctionKeys: String, CodingKey {
|
||||
case name
|
||||
case arguments
|
||||
}
|
||||
|
||||
init(callId: String, functionName: String, arguments: String) {
|
||||
self.callId = callId
|
||||
self.functionName = functionName
|
||||
self.arguments = arguments
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
callId = try container.decode(String.self, forKey: .callId)
|
||||
let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
|
||||
functionName = try funcContainer.decode(String.self, forKey: .name)
|
||||
arguments = try funcContainer.decode(String.self, forKey: .arguments)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(callId, forKey: .callId)
|
||||
try container.encode("function", forKey: .type)
|
||||
var funcContainer = container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
|
||||
try funcContainer.encode(functionName, forKey: .name)
|
||||
try funcContainer.encode(arguments, forKey: .arguments)
|
||||
}
|
||||
|
||||
var toolKind: ToolKind {
|
||||
switch functionName {
|
||||
case "read_file", "search_files", "vision_analyze": return .read
|
||||
case "write_file", "patch": return .edit
|
||||
case "terminal": return .execute
|
||||
case "web_search", "web_extract": return .fetch
|
||||
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
|
||||
default: return .other
|
||||
}
|
||||
}
|
||||
|
||||
var argumentsSummary: String {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return arguments
|
||||
}
|
||||
if let command = json["command"] as? String {
|
||||
return command
|
||||
}
|
||||
if let path = json["path"] as? String {
|
||||
return path
|
||||
}
|
||||
if let query = json["query"] as? String {
|
||||
return query
|
||||
}
|
||||
if let url = json["url"] as? String {
|
||||
return url
|
||||
}
|
||||
return arguments.prefix(120) + (arguments.count > 120 ? "..." : "")
|
||||
}
|
||||
}
|
||||
|
||||
enum ToolKind: String, Sendable, CaseIterable {
|
||||
case read
|
||||
case edit
|
||||
case execute
|
||||
case fetch
|
||||
case browser
|
||||
case other
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .read: return "doc.text.magnifyingglass"
|
||||
case .edit: return "pencil"
|
||||
case .execute: return "terminal"
|
||||
case .fetch: return "globe"
|
||||
case .browser: return "safari"
|
||||
case .other: return "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .read: return "green"
|
||||
case .edit: return "blue"
|
||||
case .execute: return "orange"
|
||||
case .fetch: return "purple"
|
||||
case .browser: return "indigo"
|
||||
case .other: return "gray"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesSession: Identifiable, Sendable {
|
||||
let id: String
|
||||
let source: String
|
||||
let userId: String?
|
||||
let model: String?
|
||||
let title: String?
|
||||
let parentSessionId: String?
|
||||
let startedAt: Date?
|
||||
let endedAt: Date?
|
||||
let endReason: String?
|
||||
let messageCount: Int
|
||||
let toolCallCount: Int
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
let cacheReadTokens: Int
|
||||
let cacheWriteTokens: Int
|
||||
let estimatedCostUSD: Double?
|
||||
|
||||
var totalTokens: Int { inputTokens + outputTokens }
|
||||
|
||||
var duration: TimeInterval? {
|
||||
guard let start = startedAt, let end = endedAt else { return nil }
|
||||
return end.timeIntervalSince(start)
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
title ?? id
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesSkillCategory: Identifiable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let skills: [HermesSkill]
|
||||
}
|
||||
|
||||
struct HermesSkill: Identifiable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let category: String
|
||||
let path: String
|
||||
let files: [String]
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
actor HermesDataService {
|
||||
private var db: OpaquePointer?
|
||||
|
||||
func open() -> Bool {
|
||||
let path = HermesPaths.stateDB
|
||||
guard FileManager.default.fileExists(atPath: path) else { return false }
|
||||
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
|
||||
let result = sqlite3_open_v2(path, &db, flags, nil)
|
||||
guard result == SQLITE_OK else {
|
||||
db = nil
|
||||
return false
|
||||
}
|
||||
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
||||
return true
|
||||
}
|
||||
|
||||
func close() {
|
||||
if let db {
|
||||
sqlite3_close(db)
|
||||
}
|
||||
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,
|
||||
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 ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_int(stmt, 1, Int32(limit))
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
sessions.append(sessionFromRow(stmt!))
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
func fetchMessages(sessionId: String) -> [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
|
||||
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, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
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
|
||||
FROM messages_fts fts
|
||||
JOIN messages m ON m.id = fts.rowid
|
||||
WHERE messages_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
"""
|
||||
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_int(stmt, 2, Int32(limit))
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func fetchRecentToolCalls(limit: Int = 50) -> [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
|
||||
FROM messages
|
||||
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_int(stmt, 1, Int32(limit))
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
struct SessionStats: Sendable {
|
||||
let totalSessions: Int
|
||||
let totalMessages: Int
|
||||
let totalToolCalls: Int
|
||||
let totalInputTokens: Int
|
||||
let totalOutputTokens: Int
|
||||
let totalCostUSD: Double
|
||||
}
|
||||
|
||||
func fetchStats() -> SessionStats {
|
||||
guard let db else {
|
||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
||||
}
|
||||
let 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
func stateDBModificationDate() -> Date? {
|
||||
let walPath = HermesPaths.stateDB + "-wal"
|
||||
let dbPath = HermesPaths.stateDB
|
||||
let fm = FileManager.default
|
||||
let walDate = (try? fm.attributesOfItem(atPath: walPath))?[.modificationDate] as? Date
|
||||
let dbDate = (try? fm.attributesOfItem(atPath: dbPath))?[.modificationDate] as? Date
|
||||
if let w = walDate, let d = dbDate {
|
||||
return max(w, d)
|
||||
}
|
||||
return walDate ?? dbDate
|
||||
}
|
||||
|
||||
// MARK: - Row Parsing
|
||||
|
||||
private func sessionFromRow(_ stmt: OpaquePointer) -> HermesSession {
|
||||
HermesSession(
|
||||
id: columnText(stmt, 0),
|
||||
source: columnText(stmt, 1),
|
||||
userId: columnOptionalText(stmt, 2),
|
||||
model: columnOptionalText(stmt, 3),
|
||||
title: columnOptionalText(stmt, 4),
|
||||
parentSessionId: columnOptionalText(stmt, 5),
|
||||
startedAt: columnDate(stmt, 6),
|
||||
endedAt: columnDate(stmt, 7),
|
||||
endReason: columnOptionalText(stmt, 8),
|
||||
messageCount: Int(sqlite3_column_int(stmt, 9)),
|
||||
toolCallCount: Int(sqlite3_column_int(stmt, 10)),
|
||||
inputTokens: Int(sqlite3_column_int(stmt, 11)),
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
private func messageFromRow(_ stmt: OpaquePointer) -> HermesMessage {
|
||||
let toolCallsJSON = columnOptionalText(stmt, 5)
|
||||
let toolCalls = parseToolCalls(toolCallsJSON)
|
||||
return HermesMessage(
|
||||
id: Int(sqlite3_column_int(stmt, 0)),
|
||||
sessionId: columnText(stmt, 1),
|
||||
role: columnText(stmt, 2),
|
||||
content: columnText(stmt, 3),
|
||||
toolCallId: columnOptionalText(stmt, 4),
|
||||
toolCalls: toolCalls,
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
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)) ?? []
|
||||
}
|
||||
|
||||
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
|
||||
if let cStr = sqlite3_column_text(stmt, col) {
|
||||
return String(cString: cStr)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func columnOptionalText(_ stmt: OpaquePointer, _ col: Int32) -> String? {
|
||||
guard sqlite3_column_type(stmt, col) != SQLITE_NULL,
|
||||
let cStr = sqlite3_column_text(stmt, col) else { return nil }
|
||||
return String(cString: cStr)
|
||||
}
|
||||
|
||||
private func columnDate(_ stmt: OpaquePointer, _ col: Int32) -> Date? {
|
||||
guard sqlite3_column_type(stmt, col) != SQLITE_NULL else { return nil }
|
||||
let value = sqlite3_column_double(stmt, col)
|
||||
return Date(timeIntervalSince1970: value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesFileService: Sendable {
|
||||
|
||||
// MARK: - Config
|
||||
|
||||
func loadConfig() -> HermesConfig {
|
||||
guard let content = readFile(HermesPaths.configYAML) else { return .empty }
|
||||
return parseConfig(content)
|
||||
}
|
||||
|
||||
private func parseConfig(_ yaml: String) -> HermesConfig {
|
||||
var values: [String: String] = [:]
|
||||
var currentSection = ""
|
||||
|
||||
for line in yaml.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
if indent == 0 && trimmed.hasSuffix(":") {
|
||||
currentSection = String(trimmed.dropLast())
|
||||
continue
|
||||
}
|
||||
|
||||
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
values[currentSection + "." + key] = val
|
||||
}
|
||||
}
|
||||
|
||||
return HermesConfig(
|
||||
model: values["model.default"] ?? "unknown",
|
||||
provider: values["model.provider"] ?? "unknown",
|
||||
maxTurns: Int(values["agent.max_turns"] ?? "") ?? 0,
|
||||
personality: values["display.personality"] ?? "default",
|
||||
terminalBackend: values["terminal.backend"] ?? "local",
|
||||
memoryEnabled: values["memory.memory_enabled"] == "true",
|
||||
memoryCharLimit: Int(values["memory.memory_char_limit"] ?? "") ?? 0,
|
||||
userCharLimit: Int(values["memory.user_char_limit"] ?? "") ?? 0,
|
||||
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
|
||||
streaming: values["display.streaming"] != "false",
|
||||
showReasoning: values["display.show_reasoning"] == "true",
|
||||
verbose: values["agent.verbose"] == "true"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Gateway State
|
||||
|
||||
func loadGatewayState() -> GatewayState? {
|
||||
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
|
||||
return try? JSONDecoder().decode(GatewayState.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Memory
|
||||
|
||||
func loadMemory() -> String {
|
||||
readFile(HermesPaths.memoryMD) ?? ""
|
||||
}
|
||||
|
||||
func loadUserProfile() -> String {
|
||||
readFile(HermesPaths.userMD) ?? ""
|
||||
}
|
||||
|
||||
func saveMemory(_ content: String) {
|
||||
writeFile(HermesPaths.memoryMD, content: content)
|
||||
}
|
||||
|
||||
func saveUserProfile(_ content: String) {
|
||||
writeFile(HermesPaths.userMD, content: content)
|
||||
}
|
||||
|
||||
// MARK: - Cron
|
||||
|
||||
func loadCronJobs() -> [HermesCronJob] {
|
||||
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
|
||||
let file = try? JSONDecoder().decode(CronJobsFile.self, from: data)
|
||||
return file?.jobs ?? []
|
||||
}
|
||||
|
||||
func loadCronOutput(jobId: String) -> String? {
|
||||
let dir = HermesPaths.cronOutputDir
|
||||
let fm = FileManager.default
|
||||
guard let files = try? fm.contentsOfDirectory(atPath: dir) else { return nil }
|
||||
let matching = files.filter { $0.contains(jobId) }.sorted().last
|
||||
guard let filename = matching else { return nil }
|
||||
return readFile(dir + "/" + filename)
|
||||
}
|
||||
|
||||
// MARK: - Skills
|
||||
|
||||
func loadSkills() -> [HermesSkillCategory] {
|
||||
let dir = HermesPaths.skillsDir
|
||||
let fm = FileManager.default
|
||||
guard let categories = try? fm.contentsOfDirectory(atPath: dir) else { return [] }
|
||||
|
||||
return categories.sorted().compactMap { categoryName in
|
||||
let categoryPath = dir + "/" + categoryName
|
||||
var isDir: ObjCBool = false
|
||||
guard fm.fileExists(atPath: categoryPath, isDirectory: &isDir), isDir.boolValue else { return nil }
|
||||
guard let skillNames = try? fm.contentsOfDirectory(atPath: categoryPath) else { return nil }
|
||||
|
||||
let skills = skillNames.sorted().compactMap { skillName -> HermesSkill? in
|
||||
let skillPath = categoryPath + "/" + skillName
|
||||
var isSkillDir: ObjCBool = false
|
||||
guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil }
|
||||
let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? []
|
||||
return HermesSkill(
|
||||
id: categoryName + "/" + skillName,
|
||||
name: skillName,
|
||||
category: categoryName,
|
||||
path: skillPath,
|
||||
files: files.sorted()
|
||||
)
|
||||
}
|
||||
|
||||
guard !skills.isEmpty else { return nil }
|
||||
return HermesSkillCategory(id: categoryName, name: categoryName, skills: skills)
|
||||
}
|
||||
}
|
||||
|
||||
func loadSkillContent(path: String) -> String {
|
||||
readFile(path) ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Hermes Process
|
||||
|
||||
func isHermesRunning() -> Bool {
|
||||
let pipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
|
||||
process.arguments = ["-f", "hermes"]
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return !data.isEmpty
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File I/O
|
||||
|
||||
private func readFile(_ path: String) -> String? {
|
||||
try? String(contentsOfFile: path, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func readFileData(_ path: String) -> Data? {
|
||||
FileManager.default.contents(atPath: path)
|
||||
}
|
||||
|
||||
private func writeFile(_ path: String, content: String) {
|
||||
try? content.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class HermesFileWatcher {
|
||||
private(set) var lastChangeDate = Date()
|
||||
private var sources: [DispatchSourceFileSystemObject] = []
|
||||
private var timer: Timer?
|
||||
|
||||
func startWatching() {
|
||||
let paths = [
|
||||
HermesPaths.stateDB,
|
||||
HermesPaths.stateDB + "-wal",
|
||||
HermesPaths.configYAML,
|
||||
HermesPaths.memoryMD,
|
||||
HermesPaths.userMD,
|
||||
HermesPaths.cronJobsJSON,
|
||||
HermesPaths.gatewayStateJSON,
|
||||
HermesPaths.errorsLog,
|
||||
HermesPaths.gatewayLog
|
||||
]
|
||||
|
||||
for path in paths {
|
||||
watchFile(path)
|
||||
}
|
||||
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||
self?.lastChangeDate = Date()
|
||||
}
|
||||
}
|
||||
|
||||
func stopWatching() {
|
||||
for source in sources {
|
||||
source.cancel()
|
||||
}
|
||||
sources.removeAll()
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func watchFile(_ path: String) {
|
||||
let fd = Darwin.open(path, O_EVTONLY)
|
||||
guard fd >= 0 else { return }
|
||||
|
||||
let source = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fd,
|
||||
eventMask: [.write, .extend, .rename],
|
||||
queue: .main
|
||||
)
|
||||
source.setEventHandler { [weak self] in
|
||||
self?.lastChangeDate = Date()
|
||||
}
|
||||
source.setCancelHandler {
|
||||
Darwin.close(fd)
|
||||
}
|
||||
source.resume()
|
||||
sources.append(source)
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopWatching()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
struct LogEntry: Identifiable, Sendable {
|
||||
let id: Int
|
||||
let timestamp: String
|
||||
let level: LogLevel
|
||||
let logger: String
|
||||
let message: String
|
||||
let raw: String
|
||||
|
||||
enum LogLevel: String, Sendable, CaseIterable {
|
||||
case debug = "DEBUG"
|
||||
case info = "INFO"
|
||||
case warning = "WARNING"
|
||||
case error = "ERROR"
|
||||
case critical = "CRITICAL"
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .debug: return "secondary"
|
||||
case .info: return "primary"
|
||||
case .warning: return "orange"
|
||||
case .error: return "red"
|
||||
case .critical: return "red"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor HermesLogService {
|
||||
private var fileHandle: FileHandle?
|
||||
private var currentPath: String?
|
||||
private var entryCounter = 0
|
||||
|
||||
func openLog(path: String) {
|
||||
closeLog()
|
||||
currentPath = path
|
||||
fileHandle = FileHandle(forReadingAtPath: path)
|
||||
}
|
||||
|
||||
func closeLog() {
|
||||
try? fileHandle?.close()
|
||||
fileHandle = nil
|
||||
currentPath = nil
|
||||
}
|
||||
|
||||
func readLastLines(count: Int = 200) -> [LogEntry] {
|
||||
guard let path = currentPath,
|
||||
let data = FileManager.default.contents(atPath: path) else { return [] }
|
||||
let content = String(data: data, encoding: .utf8) ?? ""
|
||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let lastLines = Array(lines.suffix(count))
|
||||
return lastLines.map { parseLine($0) }
|
||||
}
|
||||
|
||||
func readNewLines() -> [LogEntry] {
|
||||
guard let handle = fileHandle else { return [] }
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return [] }
|
||||
let content = String(data: data, encoding: .utf8) ?? ""
|
||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
return lines.map { parseLine($0) }
|
||||
}
|
||||
|
||||
func seekToEnd() {
|
||||
fileHandle?.seekToEndOfFile()
|
||||
}
|
||||
|
||||
private func parseLine(_ line: String) -> LogEntry {
|
||||
entryCounter += 1
|
||||
// Format: YYYY-MM-DD HH:MM:SS,MMM LEVEL logger: message
|
||||
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s+(.*)$"#
|
||||
if let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
|
||||
let timestamp = String(line[Range(match.range(at: 1), in: line)!])
|
||||
let levelStr = String(line[Range(match.range(at: 2), in: line)!])
|
||||
let logger = String(line[Range(match.range(at: 3), in: line)!])
|
||||
let message = String(line[Range(match.range(at: 4), in: line)!])
|
||||
return LogEntry(
|
||||
id: entryCounter,
|
||||
timestamp: timestamp,
|
||||
level: LogEntry.LogLevel(rawValue: levelStr) ?? .info,
|
||||
logger: logger,
|
||||
message: message,
|
||||
raw: line
|
||||
)
|
||||
}
|
||||
return LogEntry(id: entryCounter, timestamp: "", level: .info, logger: "", message: line, raw: line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class ActivityViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
|
||||
var toolMessages: [HermesMessage] = []
|
||||
var filterKind: ToolKind?
|
||||
var selectedEntry: ActivityEntry?
|
||||
var isLoading = true
|
||||
|
||||
var filteredActivity: [ActivityEntry] {
|
||||
let entries = toolMessages.flatMap { message in
|
||||
message.toolCalls.map { call in
|
||||
ActivityEntry(
|
||||
id: call.callId,
|
||||
sessionId: message.sessionId,
|
||||
toolName: call.functionName,
|
||||
kind: call.toolKind,
|
||||
summary: call.argumentsSummary,
|
||||
arguments: call.arguments,
|
||||
messageContent: message.content,
|
||||
timestamp: message.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
if let filterKind {
|
||||
return entries.filter { $0.kind == filterKind }
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
let opened = await dataService.open()
|
||||
guard opened else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
toolMessages = await dataService.fetchRecentToolCalls(limit: 200)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
await dataService.close()
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityEntry: Identifiable, Sendable {
|
||||
let id: String
|
||||
let sessionId: String
|
||||
let toolName: String
|
||||
let kind: ToolKind
|
||||
let summary: String
|
||||
let arguments: String
|
||||
let messageContent: String
|
||||
let timestamp: Date?
|
||||
|
||||
var prettyArguments: String {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data, options: []),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]),
|
||||
let str = String(data: pretty, encoding: .utf8) else {
|
||||
return arguments
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ActivityView: View {
|
||||
@State private var viewModel = ActivityViewModel()
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
filterBar
|
||||
Divider()
|
||||
HSplitView {
|
||||
activityList
|
||||
.frame(minWidth: 350, idealWidth: 450)
|
||||
activityDetail
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
.task { await viewModel.load() }
|
||||
.onDisappear { Task { await viewModel.cleanup() } }
|
||||
}
|
||||
|
||||
private var filterBar: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
FilterChip(label: "All", isSelected: viewModel.filterKind == nil) {
|
||||
viewModel.filterKind = nil
|
||||
}
|
||||
ForEach(ToolKind.allCases, id: \.rawValue) { kind in
|
||||
FilterChip(label: kind.rawValue.capitalized, isSelected: viewModel.filterKind == kind) {
|
||||
viewModel.filterKind = kind
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var activityList: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedEntry?.id },
|
||||
set: { id in
|
||||
if let id {
|
||||
viewModel.selectedEntry = viewModel.filteredActivity.first(where: { $0.id == id })
|
||||
} else {
|
||||
viewModel.selectedEntry = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.filteredActivity) { entry in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: entry.kind.icon)
|
||||
.foregroundStyle(colorForKind(entry.kind))
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.toolName)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
Text(entry.summary)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
if let time = entry.timestamp {
|
||||
Text(time, style: .time)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tag(entry.id)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.filteredActivity.isEmpty && !viewModel.isLoading {
|
||||
ContentUnavailableView("No Activity", systemImage: "bolt.horizontal", description: Text("No tool calls found"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var activityDetail: some View {
|
||||
if let entry = viewModel.selectedEntry {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: entry.kind.icon)
|
||||
.font(.title2)
|
||||
.foregroundStyle(colorForKind(entry.kind))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.toolName)
|
||||
.font(.title3.bold().monospaced())
|
||||
Text(entry.kind.rawValue.capitalized)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
if let time = entry.timestamp {
|
||||
Label(time.formatted(.dateTime.month().day().hour().minute().second()), systemImage: "clock")
|
||||
}
|
||||
Button {
|
||||
coordinator.selectedSessionId = entry.sessionId
|
||||
coordinator.selectedSection = .sessions
|
||||
} label: {
|
||||
Label(String(entry.sessionId.prefix(20)), systemImage: "bubble.left.and.bubble.right")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.help("Open session")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Arguments")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.prettyArguments)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
if !entry.messageContent.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Assistant Message")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.messageContent)
|
||||
.font(.caption)
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("Select a Tool Call", systemImage: "bolt.horizontal", description: Text("Choose an entry from the list"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func colorForKind(_ kind: ToolKind) -> Color {
|
||||
switch kind {
|
||||
case .read: return .green
|
||||
case .edit: return .blue
|
||||
case .execute: return .orange
|
||||
case .fetch: return .purple
|
||||
case .browser: return .indigo
|
||||
case .other: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FilterChip: View {
|
||||
let label: String
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class ChatViewModel {
|
||||
var sessionId = UUID()
|
||||
|
||||
var hermesBinaryExists: Bool {
|
||||
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
@State private var viewModel = ChatViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
terminalArea
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
HStack {
|
||||
Image(systemName: "terminal")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Hermes Terminal")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if !viewModel.hermesBinaryExists {
|
||||
Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Button("New Session") {
|
||||
viewModel.sessionId = UUID()
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var terminalArea: some View {
|
||||
if viewModel.hermesBinaryExists {
|
||||
TerminalRepresentable(
|
||||
command: HermesPaths.hermesBinary,
|
||||
arguments: ["chat"],
|
||||
environment: [:]
|
||||
)
|
||||
.id(viewModel.sessionId)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Hermes Not Found",
|
||||
systemImage: "terminal",
|
||||
description: Text("Expected at \(HermesPaths.hermesBinary)")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import SwiftTerm
|
||||
|
||||
struct TerminalRepresentable: NSViewRepresentable {
|
||||
let command: String
|
||||
let arguments: [String]
|
||||
let environment: [String: String]
|
||||
|
||||
func makeNSView(context: Context) -> LocalProcessTerminalView {
|
||||
let terminal = LocalProcessTerminalView(frame: .zero)
|
||||
terminal.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
|
||||
terminal.nativeBackgroundColor = NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0)
|
||||
terminal.nativeForegroundColor = NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)
|
||||
terminal.processDelegate = context.coordinator
|
||||
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
for (key, value) in environment {
|
||||
env[key] = value
|
||||
}
|
||||
env["TERM"] = "xterm-256color"
|
||||
env["COLORTERM"] = "truecolor"
|
||||
|
||||
let envArray = env.map { "\($0.key)=\($0.value)" }
|
||||
|
||||
terminal.startProcess(
|
||||
executable: command,
|
||||
args: arguments,
|
||||
environment: envArray,
|
||||
execName: nil
|
||||
)
|
||||
return terminal
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: LocalProcessTerminalView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, LocalProcessTerminalViewDelegate {
|
||||
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
|
||||
|
||||
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {}
|
||||
|
||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
||||
|
||||
func processTerminated(source: TerminalView, exitCode: Int32?) {
|
||||
let terminal = source.getTerminal()
|
||||
terminal.feed(text: "\r\n[Process exited with code \(exitCode ?? -1)]\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class CronViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var jobs: [HermesCronJob] = []
|
||||
var selectedJob: HermesCronJob?
|
||||
var jobOutput: String?
|
||||
|
||||
func load() {
|
||||
jobs = fileService.loadCronJobs()
|
||||
}
|
||||
|
||||
func selectJob(_ job: HermesCronJob) {
|
||||
selectedJob = job
|
||||
jobOutput = fileService.loadCronOutput(jobId: job.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CronView: View {
|
||||
@State private var viewModel = CronViewModel()
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
jobsList
|
||||
.frame(minWidth: 300, idealWidth: 350)
|
||||
jobDetail
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.navigationTitle("Cron Jobs")
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var jobsList: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedJob?.id },
|
||||
set: { id in
|
||||
if let id, let job = viewModel.jobs.first(where: { $0.id == id }) {
|
||||
viewModel.selectJob(job)
|
||||
} else {
|
||||
viewModel.selectedJob = nil
|
||||
viewModel.jobOutput = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.jobs) { job in
|
||||
HStack {
|
||||
Image(systemName: job.stateIcon)
|
||||
.foregroundStyle(job.enabled ? .primary : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(job.name)
|
||||
.lineLimit(1)
|
||||
Text(job.schedule.display ?? job.schedule.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !job.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tag(job.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.jobs.isEmpty {
|
||||
ContentUnavailableView("No Cron Jobs", systemImage: "clock.arrow.2.circlepath", description: Text("No scheduled jobs configured"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var jobDetail: some View {
|
||||
if let job = viewModel.selectedJob {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(job.name)
|
||||
.font(.title2.bold())
|
||||
HStack(spacing: 16) {
|
||||
Label(job.state, systemImage: job.stateIcon)
|
||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||
if let deliver = job.deliver {
|
||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(job.prompt)
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if let skills = job.skills, !skills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
ForEach(skills, id: \.self) { skill in
|
||||
Text(skill)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let nextRun = job.nextRunAt {
|
||||
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let lastRun = job.lastRunAt {
|
||||
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = job.lastError {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let output = viewModel.jobOutput {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Last Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("Select a Job", systemImage: "clock.arrow.2.circlepath", description: Text("Choose a cron job from the list"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
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 recentSessions: [HermesSession] = []
|
||||
var config = HermesConfig.empty
|
||||
var gatewayState: GatewayState?
|
||||
var hermesRunning = false
|
||||
var isLoading = true
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
let opened = await dataService.open()
|
||||
if opened {
|
||||
stats = await dataService.fetchStats()
|
||||
recentSessions = await dataService.fetchSessions(limit: 5)
|
||||
await dataService.close()
|
||||
}
|
||||
config = fileService.loadConfig()
|
||||
gatewayState = fileService.loadGatewayState()
|
||||
hermesRunning = fileService.isHermesRunning()
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
@State private var viewModel = DashboardViewModel()
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
statusSection
|
||||
statsSection
|
||||
recentSessionsSection
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Dashboard")
|
||||
.task { await viewModel.load() }
|
||||
}
|
||||
|
||||
private var statusSection: some View {
|
||||
HStack(spacing: 16) {
|
||||
StatusCard(
|
||||
title: "Hermes",
|
||||
value: viewModel.hermesRunning ? "Running" : "Stopped",
|
||||
icon: "circle.fill",
|
||||
color: viewModel.hermesRunning ? .green : .secondary
|
||||
)
|
||||
StatusCard(
|
||||
title: "Model",
|
||||
value: viewModel.config.model,
|
||||
icon: "cpu",
|
||||
color: .blue
|
||||
)
|
||||
StatusCard(
|
||||
title: "Provider",
|
||||
value: viewModel.config.provider,
|
||||
icon: "cloud",
|
||||
color: .purple
|
||||
)
|
||||
StatusCard(
|
||||
title: "Gateway",
|
||||
value: viewModel.gatewayState?.statusText ?? "unknown",
|
||||
icon: "network",
|
||||
color: viewModel.gatewayState?.isRunning == true ? .green : .secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var statsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Usage Stats")
|
||||
.font(.headline)
|
||||
HStack(spacing: 16) {
|
||||
StatCard(label: "Sessions", value: "\(viewModel.stats.totalSessions)")
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var recentSessionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Recent Sessions")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("View All") {
|
||||
coordinator.selectedSection = .sessions
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
}
|
||||
ForEach(viewModel.recentSessions) { session in
|
||||
SessionRow(session: session)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
coordinator.selectedSessionId = session.id
|
||||
coordinator.selectedSection = .sessions
|
||||
}
|
||||
}
|
||||
if viewModel.recentSessions.isEmpty && !viewModel.isLoading {
|
||||
Text("No sessions found")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let title: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(color)
|
||||
.font(.caption)
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(value)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
struct StatCard: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value)
|
||||
.font(.system(.title2, design: .monospaced, weight: .semibold))
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionRow: View {
|
||||
let session: HermesSession
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: session.sourceIcon)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(session.displayTitle)
|
||||
.lineLimit(1)
|
||||
if let date = session.startedAt {
|
||||
Text(date, style: .relative)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 12) {
|
||||
Label("\(session.messageCount)", systemImage: "bubble.left")
|
||||
Label("\(session.toolCallCount)", systemImage: "wrench")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class LogsViewModel {
|
||||
private let logService = HermesLogService()
|
||||
|
||||
var entries: [LogEntry] = []
|
||||
var selectedLogFile: LogFile = .errors
|
||||
var filterLevel: LogEntry.LogLevel?
|
||||
var searchText = ""
|
||||
private var pollTimer: Timer?
|
||||
|
||||
enum LogFile: String, CaseIterable, Identifiable {
|
||||
case errors = "errors.log"
|
||||
case gateway = "gateway.log"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .errors: return HermesPaths.errorsLog
|
||||
case .gateway: return HermesPaths.gatewayLog
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var filteredEntries: [LogEntry] {
|
||||
entries.filter { entry in
|
||||
let levelOk = filterLevel == nil || entry.level == filterLevel
|
||||
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
|
||||
return levelOk && searchOk
|
||||
}
|
||||
}
|
||||
|
||||
func load() async {
|
||||
await logService.openLog(path: selectedLogFile.path)
|
||||
entries = await logService.readLastLines(count: 500)
|
||||
await logService.seekToEnd()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
func switchLogFile(_ file: LogFile) async {
|
||||
selectedLogFile = file
|
||||
entries = []
|
||||
await logService.openLog(path: file.path)
|
||||
entries = await logService.readLastLines(count: 500)
|
||||
await logService.seekToEnd()
|
||||
}
|
||||
|
||||
func startPolling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
let newEntries = await self.logService.readNewLines()
|
||||
if !newEntries.isEmpty {
|
||||
self.entries.append(contentsOf: newEntries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopPolling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
stopPolling()
|
||||
await logService.closeLog()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LogsView: View {
|
||||
@State private var viewModel = LogsViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
logList
|
||||
}
|
||||
.navigationTitle("Logs")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Filter logs...")
|
||||
.task { await viewModel.load() }
|
||||
.onDisappear { Task { await viewModel.cleanup() } }
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
HStack(spacing: 12) {
|
||||
Picker("Log File", selection: Binding(
|
||||
get: { viewModel.selectedLogFile },
|
||||
set: { file in Task { await viewModel.switchLogFile(file) } }
|
||||
)) {
|
||||
ForEach(LogsViewModel.LogFile.allCases) { file in
|
||||
Text(file.rawValue).tag(file)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("Level", selection: $viewModel.filterLevel) {
|
||||
Text("All Levels").tag(LogEntry.LogLevel?.none)
|
||||
ForEach(LogEntry.LogLevel.allCases, id: \.rawValue) { level in
|
||||
Text(level.rawValue).tag(LogEntry.LogLevel?.some(level))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 150)
|
||||
|
||||
Text("\(viewModel.filteredEntries.count) entries")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var logList: some View {
|
||||
ScrollViewReader { proxy in
|
||||
List(viewModel.filteredEntries) { entry in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(entry.timestamp)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 140, alignment: .leading)
|
||||
Text(entry.level.rawValue)
|
||||
.font(.caption.monospaced().bold())
|
||||
.foregroundStyle(colorForLevel(entry.level))
|
||||
.frame(width: 60, alignment: .leading)
|
||||
Text(entry.message)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(3)
|
||||
}
|
||||
.id(entry.id)
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.onChange(of: viewModel.entries.count) {
|
||||
if let last = viewModel.filteredEntries.last {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func colorForLevel(_ level: LogEntry.LogLevel) -> Color {
|
||||
switch level {
|
||||
case .debug: return .secondary
|
||||
case .info: return .primary
|
||||
case .warning: return .orange
|
||||
case .error, .critical: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class MemoryViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var memoryContent = ""
|
||||
var userContent = ""
|
||||
var isEditing = false
|
||||
var editingFile: EditTarget = .memory
|
||||
var editText = ""
|
||||
|
||||
enum EditTarget {
|
||||
case memory, user
|
||||
}
|
||||
|
||||
var memoryCharCount: Int { memoryContent.count }
|
||||
var userCharCount: Int { userContent.count }
|
||||
|
||||
func load() {
|
||||
memoryContent = fileService.loadMemory()
|
||||
userContent = fileService.loadUserProfile()
|
||||
}
|
||||
|
||||
func startEditing(_ target: EditTarget) {
|
||||
editingFile = target
|
||||
editText = target == .memory ? memoryContent : userContent
|
||||
isEditing = true
|
||||
}
|
||||
|
||||
func save() {
|
||||
switch editingFile {
|
||||
case .memory:
|
||||
fileService.saveMemory(editText)
|
||||
memoryContent = editText
|
||||
case .user:
|
||||
fileService.saveUserProfile(editText)
|
||||
userContent = editText
|
||||
}
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
func cancelEditing() {
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MemoryView: View {
|
||||
@State private var viewModel = MemoryViewModel()
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
|
||||
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Memory")
|
||||
.onAppear { viewModel.load() }
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
viewModel.load()
|
||||
}
|
||||
.sheet(isPresented: $viewModel.isEditing) {
|
||||
editorSheet
|
||||
}
|
||||
}
|
||||
|
||||
private func memorySection(_ title: String, content: String, charCount: Int, target: MemoryViewModel.EditTarget) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(charCount) chars")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Edit") {
|
||||
viewModel.startEditing(target)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
if content.isEmpty {
|
||||
Text("Empty")
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
Text(markdownAttributed(content))
|
||||
.textSelection(.enabled)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var editorSheet: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(viewModel.editingFile == .memory ? "Edit Agent Memory" : "Edit User Profile")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Cancel") { viewModel.cancelEditing() }
|
||||
Button("Save") { viewModel.save() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
TextEditor(text: $viewModel.editText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.padding(8)
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 400)
|
||||
}
|
||||
|
||||
private func markdownAttributed(_ text: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class SessionsViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
var selectedSession: HermesSession?
|
||||
var messages: [HermesMessage] = []
|
||||
var searchText = ""
|
||||
var searchResults: [HermesMessage] = []
|
||||
var isSearching = false
|
||||
|
||||
func load() async {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
sessions = await dataService.fetchSessions(limit: 500)
|
||||
}
|
||||
|
||||
func selectSession(_ session: HermesSession) async {
|
||||
selectedSession = session
|
||||
messages = await dataService.fetchMessages(sessionId: session.id)
|
||||
}
|
||||
|
||||
func selectSessionById(_ id: String) async {
|
||||
if let session = sessions.first(where: { $0.id == id }) {
|
||||
await selectSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
func search() async {
|
||||
let query = searchText.trimmingCharacters(in: .whitespaces)
|
||||
guard !query.isEmpty else {
|
||||
searchResults = []
|
||||
isSearching = false
|
||||
return
|
||||
}
|
||||
isSearching = true
|
||||
searchResults = await dataService.searchMessages(query: query)
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
await dataService.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SessionDetailView: View {
|
||||
let session: HermesSession
|
||||
let messages: [HermesMessage]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
sessionHeader
|
||||
Divider()
|
||||
messagesList
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var sessionHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(session.displayTitle)
|
||||
.font(.title3.bold())
|
||||
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)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var messagesList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(messages) { message in
|
||||
MessageBubble(message: message)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageBubble: View {
|
||||
let message: HermesMessage
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
|
||||
HStack {
|
||||
if message.isUser { Spacer(minLength: 60) }
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if !message.content.isEmpty {
|
||||
Text(message.content)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if !message.toolCalls.isEmpty {
|
||||
ForEach(message.toolCalls) { call in
|
||||
ToolCallBadge(call: call)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(message.isUser ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
if !message.isUser { Spacer(minLength: 60) }
|
||||
}
|
||||
if let time = message.timestamp {
|
||||
Text(time, style: .time)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: message.isUser ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolCallBadge: View {
|
||||
let call: HermesToolCall
|
||||
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Button {
|
||||
expanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: call.toolKind.icon)
|
||||
.foregroundStyle(toolColor)
|
||||
Text(call.functionName)
|
||||
.font(.caption.monospaced())
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if expanded {
|
||||
Text(call.argumentsSummary)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.padding(6)
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolColor: Color {
|
||||
switch call.toolKind {
|
||||
case .read: return .green
|
||||
case .edit: return .blue
|
||||
case .execute: return .orange
|
||||
case .fetch: return .purple
|
||||
case .browser: return .indigo
|
||||
case .other: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SessionsView: View {
|
||||
@State private var viewModel = SessionsViewModel()
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
var body: some View {
|
||||
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() } }
|
||||
.onChange(of: viewModel.searchText) {
|
||||
if viewModel.searchText.isEmpty {
|
||||
viewModel.isSearching = false
|
||||
viewModel.searchResults = []
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.load()
|
||||
if let id = coordinator.selectedSessionId {
|
||||
await viewModel.selectSessionById(id)
|
||||
coordinator.selectedSessionId = nil
|
||||
}
|
||||
}
|
||||
.onDisappear { Task { await viewModel.cleanup() } }
|
||||
}
|
||||
|
||||
private var sessionList: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedSession?.id },
|
||||
set: { id in
|
||||
if let id, let session = viewModel.sessions.first(where: { $0.id == id }) {
|
||||
Task { await viewModel.selectSession(session) }
|
||||
} else {
|
||||
viewModel.selectedSession = nil
|
||||
viewModel.messages = []
|
||||
}
|
||||
}
|
||||
)) {
|
||||
if viewModel.isSearching {
|
||||
Section("Search Results (\(viewModel.searchResults.count))") {
|
||||
ForEach(viewModel.searchResults) { message in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(message.content.prefix(100))
|
||||
.lineLimit(2)
|
||||
.font(.caption)
|
||||
Text(message.sessionId)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.tag(message.sessionId)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
Task { await viewModel.selectSessionById(message.sessionId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(viewModel.sessions) { session in
|
||||
SessionRow(session: session)
|
||||
.tag(session.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sessionDetail: some View {
|
||||
if let session = viewModel.selectedSession {
|
||||
SessionDetailView(session: session, messages: viewModel.messages)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class SettingsViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var config = HermesConfig.empty
|
||||
var gatewayState: GatewayState?
|
||||
var hermesRunning = false
|
||||
var rawConfigYAML = ""
|
||||
|
||||
func load() {
|
||||
config = fileService.loadConfig()
|
||||
gatewayState = fileService.loadGatewayState()
|
||||
hermesRunning = fileService.isHermesRunning()
|
||||
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var viewModel = SettingsViewModel()
|
||||
@State private var showRawConfig = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
configSection
|
||||
gatewaySection
|
||||
pathsSection
|
||||
rawConfigSection
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var configSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Configuration")
|
||||
.font(.headline)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
||||
SettingRow(label: "Model", value: viewModel.config.model)
|
||||
SettingRow(label: "Provider", value: viewModel.config.provider)
|
||||
SettingRow(label: "Personality", value: viewModel.config.personality)
|
||||
SettingRow(label: "Max Turns", value: "\(viewModel.config.maxTurns)")
|
||||
SettingRow(label: "Terminal Backend", value: viewModel.config.terminalBackend)
|
||||
SettingRow(label: "Memory Enabled", value: viewModel.config.memoryEnabled ? "Yes" : "No")
|
||||
SettingRow(label: "Memory Char Limit", value: "\(viewModel.config.memoryCharLimit)")
|
||||
SettingRow(label: "User Char Limit", value: "\(viewModel.config.userCharLimit)")
|
||||
SettingRow(label: "Nudge Interval", value: "\(viewModel.config.nudgeInterval) turns")
|
||||
SettingRow(label: "Streaming", value: viewModel.config.streaming ? "Yes" : "No")
|
||||
SettingRow(label: "Show Reasoning", value: viewModel.config.showReasoning ? "Yes" : "No")
|
||||
SettingRow(label: "Verbose", value: viewModel.config.verbose ? "Yes" : "No")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewaySection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Gateway")
|
||||
.font(.headline)
|
||||
HStack(spacing: 16) {
|
||||
Label(
|
||||
viewModel.gatewayState?.statusText ?? "unknown",
|
||||
systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle"
|
||||
)
|
||||
.foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary)
|
||||
if let reason = viewModel.gatewayState?.exitReason {
|
||||
Text(reason)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pathsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paths")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||
PathRow(label: "Logs", path: HermesPaths.errorsLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rawConfigSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Raw Config")
|
||||
.font(.headline)
|
||||
Button(showRawConfig ? "Hide" : "Show") {
|
||||
showRawConfig.toggle()
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
if showRawConfig {
|
||||
Text(viewModel.rawConfigYAML)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 120, alignment: .trailing)
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PathRow: View {
|
||||
let label: String
|
||||
let path: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 100, alignment: .trailing)
|
||||
Text(path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Button {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
||||
} label: {
|
||||
Image(systemName: "folder")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class SkillsViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var categories: [HermesSkillCategory] = []
|
||||
var selectedSkill: HermesSkill?
|
||||
var skillContent = ""
|
||||
var selectedFileName: String?
|
||||
var searchText = ""
|
||||
|
||||
var filteredCategories: [HermesSkillCategory] {
|
||||
guard !searchText.isEmpty else { return categories }
|
||||
return categories.compactMap { category in
|
||||
let filtered = category.skills.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.category.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
guard !filtered.isEmpty else { return nil }
|
||||
return HermesSkillCategory(id: category.id, name: category.name, skills: filtered)
|
||||
}
|
||||
}
|
||||
|
||||
var totalSkillCount: Int {
|
||||
categories.reduce(0) { $0 + $1.skills.count }
|
||||
}
|
||||
|
||||
func load() {
|
||||
categories = fileService.loadSkills()
|
||||
}
|
||||
|
||||
func selectSkill(_ skill: HermesSkill) {
|
||||
selectedSkill = skill
|
||||
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first
|
||||
if let file = mainFile {
|
||||
selectedFileName = file
|
||||
skillContent = fileService.loadSkillContent(path: skill.path + "/" + file)
|
||||
} else {
|
||||
selectedFileName = nil
|
||||
skillContent = ""
|
||||
}
|
||||
}
|
||||
|
||||
func selectFile(_ file: String) {
|
||||
guard let skill = selectedSkill else { return }
|
||||
selectedFileName = file
|
||||
skillContent = fileService.loadSkillContent(path: skill.path + "/" + file)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SkillsView: View {
|
||||
@State private var viewModel = SkillsViewModel()
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
skillsList
|
||||
.frame(minWidth: 250, idealWidth: 300)
|
||||
skillDetail
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.navigationTitle("Skills (\(viewModel.totalSkillCount))")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Filter skills...")
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var skillsList: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedSkill?.id },
|
||||
set: { id in
|
||||
if let id {
|
||||
for category in viewModel.filteredCategories {
|
||||
if let skill = category.skills.first(where: { $0.id == id }) {
|
||||
viewModel.selectSkill(skill)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModel.selectedSkill = nil
|
||||
viewModel.skillContent = ""
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.filteredCategories) { category in
|
||||
Section(category.name) {
|
||||
ForEach(category.skills) { skill in
|
||||
Label(skill.name, systemImage: "lightbulb")
|
||||
.tag(skill.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var skillDetail: some View {
|
||||
if let skill = viewModel.selectedSkill {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(skill.name)
|
||||
.font(.title2.bold())
|
||||
HStack {
|
||||
Label(skill.category, systemImage: "folder")
|
||||
Label("\(skill.files.count) files", systemImage: "doc")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
if !skill.files.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Files")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(skill.files, id: \.self) { file in
|
||||
Button {
|
||||
viewModel.selectFile(file)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: viewModel.selectedFileName == file ? "doc.fill" : "doc")
|
||||
.font(.caption)
|
||||
Text(file)
|
||||
.font(.caption.monospaced())
|
||||
}
|
||||
.foregroundStyle(viewModel.selectedFileName == file ? .primary : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !viewModel.skillContent.isEmpty {
|
||||
Divider()
|
||||
Text(viewModel.skillContent)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case dashboard = "Dashboard"
|
||||
case sessions = "Sessions"
|
||||
case activity = "Activity"
|
||||
case chat = "Chat"
|
||||
case memory = "Memory"
|
||||
case skills = "Skills"
|
||||
case cron = "Cron"
|
||||
case logs = "Logs"
|
||||
case settings = "Settings"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .dashboard: return "gauge.with.dots.needle.33percent"
|
||||
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 .cron: return "clock.arrow.2.circlepath"
|
||||
case .logs: return "doc.text"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class AppCoordinator {
|
||||
var selectedSection: SidebarSection = .dashboard
|
||||
var selectedSessionId: String?
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
var body: some View {
|
||||
@Bindable var coordinator = coordinator
|
||||
List(selection: $coordinator.selectedSection) {
|
||||
Section("Monitor") {
|
||||
ForEach([SidebarSection.dashboard, .sessions, .activity]) { 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)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
Section("Manage") {
|
||||
ForEach([SidebarSection.cron, .logs, .settings]) { section in
|
||||
Label(section.rawValue, systemImage: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationTitle("Scarf")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ScarfApp: App {
|
||||
@State private var coordinator = AppCoordinator()
|
||||
@State private var fileWatcher = HermesFileWatcher()
|
||||
@State private var menuBarStatus = MenuBarStatus()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(coordinator)
|
||||
.environment(fileWatcher)
|
||||
.onAppear {
|
||||
fileWatcher.startWatching()
|
||||
menuBarStatus.startPolling()
|
||||
}
|
||||
.onDisappear {
|
||||
fileWatcher.stopWatching()
|
||||
menuBarStatus.stopPolling()
|
||||
}
|
||||
}
|
||||
.defaultSize(width: 1100, height: 700)
|
||||
|
||||
MenuBarExtra("Scarf", systemImage: menuBarStatus.icon) {
|
||||
MenuBarMenu(status: menuBarStatus, coordinator: coordinator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class MenuBarStatus {
|
||||
private let fileService = HermesFileService()
|
||||
private var timer: Timer?
|
||||
|
||||
var hermesRunning = false
|
||||
var gatewayRunning = false
|
||||
|
||||
var icon: String {
|
||||
hermesRunning ? "hare.fill" : "hare"
|
||||
}
|
||||
|
||||
func startPolling() {
|
||||
refresh()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func stopPolling() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
hermesRunning = fileService.isHermesRunning()
|
||||
gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuBarMenu: View {
|
||||
let status: MenuBarStatus
|
||||
let coordinator: AppCoordinator
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Label(status.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle")
|
||||
Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle")
|
||||
Divider()
|
||||
Button("Open Dashboard") {
|
||||
coordinator.selectedSection = .dashboard
|
||||
NSApplication.shared.activate()
|
||||
}
|
||||
Button("New Chat") {
|
||||
coordinator.selectedSection = .chat
|
||||
NSApplication.shared.activate()
|
||||
}
|
||||
Button("View Sessions") {
|
||||
coordinator.selectedSection = .sessions
|
||||
NSApplication.shared.activate()
|
||||
}
|
||||
Divider()
|
||||
Button("Quit Scarf") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// scarfTests.swift
|
||||
// scarfTests
|
||||
//
|
||||
// Created by Alan Wizemann on 3/31/26.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import scarf
|
||||
|
||||
struct scarfTests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// scarfUITests.swift
|
||||
// scarfUITests
|
||||
//
|
||||
// Created by Alan Wizemann on 3/31/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class scarfUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
// This measures how long it takes to launch your application.
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// scarfUITestsLaunchTests.swift
|
||||
// scarfUITests
|
||||
//
|
||||
// Created by Alan Wizemann on 3/31/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class scarfUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
# 01 — Architecture Standard
|
||||
|
||||
Applies to: **InControl, ShabuBox, Threader, Modeler**
|
||||
Swift 6 / SwiftUI / SwiftData / macOS-native
|
||||
|
||||
---
|
||||
|
||||
## 1. MVVM-F (MVVM-Feature) Pattern
|
||||
|
||||
Every feature is a self-contained module that owns its **Models**, **ViewModels**, and **Views**.
|
||||
|
||||
```
|
||||
Features/
|
||||
Email/
|
||||
Models/
|
||||
ViewModels/
|
||||
Views/
|
||||
Projects/
|
||||
Models/
|
||||
ViewModels/
|
||||
Views/
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Feature modules never import or reference another feature's ViewModel directly.
|
||||
- Cross-feature communication goes through **shared services** injected into each feature.
|
||||
- A feature may depend on `Core/` and `Services/` but never on a sibling feature.
|
||||
|
||||
---
|
||||
|
||||
## 2. AppCoordinator
|
||||
|
||||
`AppCoordinator` is the **single source of truth** for all navigation state.
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
final class AppCoordinator {
|
||||
var selectedSection: SidebarSection = .inbox
|
||||
var selectedFileID: PersistentIdentifier?
|
||||
var isFilePreviewOpen: Bool = false
|
||||
// ... all navigation-related state lives here
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `AppCoordinator` is `@Observable` and injected via `.environment()` at the app root.
|
||||
- All navigation mutations flow through `AppCoordinator` methods.
|
||||
- Leaf views **read** coordinator state but never own independent navigation state.
|
||||
- Never deep-nest `NavigationStack` inside leaf views. One `NavigationStack` (or `NavigationSplitView`) at the top level, driven by the coordinator.
|
||||
|
||||
---
|
||||
|
||||
## 3. AppState vs AppCoordinator
|
||||
|
||||
These are **separate concerns**. Do not merge them.
|
||||
|
||||
| Concern | Owner | Examples |
|
||||
|---|---|---|
|
||||
| Navigation | `AppCoordinator` | `selectedSection`, `selectedFileID`, `isFilePreviewOpen`, modal presentation flags |
|
||||
| Cross-section shared state | `AppState` | Global search query, loading states, feature flags, user preferences |
|
||||
|
||||
Rules:
|
||||
- Never duplicate a navigation property in both `AppCoordinator` and `AppState`.
|
||||
- `AppState` does not drive navigation. `AppCoordinator` does not hold domain data.
|
||||
- Both are `@Observable` and injected via `.environment()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Directory Layout (Single-Platform)
|
||||
|
||||
Standard layout for macOS-only projects (InControl, Threader, Modeler).
|
||||
|
||||
```
|
||||
ProjectName/
|
||||
App/ — App entry point, AppCoordinator, AppLoadingState
|
||||
Core/ — Models, Services, Utilities, Schema
|
||||
Features/ — Per-feature modules (Email/, Projects/, Dashboard/, Settings/)
|
||||
Services/ — Shared services (Storage, AI, Sync, Network)
|
||||
Shared/ — Reusable components, Extensions
|
||||
Resources/ — Assets, Localizations
|
||||
```
|
||||
|
||||
Each feature directory mirrors the MVVM-F structure internally:
|
||||
|
||||
```
|
||||
Features/
|
||||
Email/
|
||||
Models/ — Feature-specific data types
|
||||
ViewModels/ — Feature logic, @Observable classes
|
||||
Views/ — SwiftUI views
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Directory Layout (Multiplatform)
|
||||
|
||||
Used by ShabuBox (macOS + iOS companion). This layout splits platform-specific code into separate targets while sharing models and design tokens through a shared library.
|
||||
|
||||
```
|
||||
ProjectName/
|
||||
ProjectName/ — macOS app target
|
||||
App/
|
||||
Core/
|
||||
Features/
|
||||
Services/
|
||||
Views/
|
||||
ProjectNameMobile/ — iOS companion target
|
||||
App/
|
||||
Features/
|
||||
Views/
|
||||
Shared/ — Shared library (both targets link this)
|
||||
Models/ — All SwiftData @Model types
|
||||
DesignSystem/ — Shared theme tokens
|
||||
SchemaVersioning.swift
|
||||
CloudKitSyncHelper.swift
|
||||
LoggingKit.swift
|
||||
PathUtilities.swift
|
||||
ModelContext+SafeSave.swift
|
||||
```
|
||||
|
||||
Rules:
|
||||
- **Models are ALWAYS shared.** Never duplicate a `@Model` type per platform.
|
||||
- Schema versioning lives in `Shared/` so both targets use the same migration plan.
|
||||
- Platform-specific services live in their respective target directories.
|
||||
- The `Shared/` library contains no UI code — only models, utilities, and design tokens.
|
||||
|
||||
---
|
||||
|
||||
## 6. Service Orchestration
|
||||
|
||||
Use the **Coordinator Pattern** to decouple UI state from background processing.
|
||||
|
||||
```swift
|
||||
// Services are injected, never accessed as singletons
|
||||
@Observable
|
||||
final class FilePipelineService {
|
||||
private let ocrService: OCRServiceProtocol
|
||||
private let indexingService: IndexingServiceProtocol
|
||||
|
||||
init(ocrService: OCRServiceProtocol, indexingService: IndexingServiceProtocol) {
|
||||
self.ocrService = ocrService
|
||||
self.indexingService = indexingService
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Services are **injected** via initializer or environment — never accessed as global singletons.
|
||||
- Background processing (indexing, OCR, sync, ingestion) is orchestrated by dedicated service classes, not by ViewModels.
|
||||
- ViewModels call service methods; they do not manage background task lifecycles directly.
|
||||
- Long-running operations report progress through `AppLoadingState` in the footer.
|
||||
|
||||
---
|
||||
|
||||
## 7. Protocol-Driven Design
|
||||
|
||||
All engines and services expose **protocol interfaces**, not concrete types.
|
||||
|
||||
```swift
|
||||
protocol TextGenerating: Sendable {
|
||||
func generate(prompt: String) async throws -> String
|
||||
}
|
||||
|
||||
protocol GenerationEngine: Sendable {
|
||||
func run(config: GenerationConfig) async throws -> GenerationResult
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Define a protocol for every service boundary (storage, AI, network, sync).
|
||||
- ViewModels and orchestrators depend on protocols, not concrete implementations.
|
||||
- Test suites swap real services for **protocol-conforming mocks**.
|
||||
- This enables backend swappability (e.g., local LLM vs hosted LLM behind the same `TextGenerating` protocol).
|
||||
|
||||
---
|
||||
|
||||
## 8. @Observable Architecture
|
||||
|
||||
Use the `@Observable` macro exclusively. Do **not** use `ObservableObject` / `@Published` / Combine.
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
final class AppState {
|
||||
var searchQuery: String = ""
|
||||
var isLoading: Bool = false
|
||||
}
|
||||
|
||||
// Injection at app root
|
||||
@main struct MyApp: App {
|
||||
@State private var appState = AppState()
|
||||
@State private var coordinator = AppCoordinator()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(appState)
|
||||
.environment(coordinator)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Root state objects (`AppState`, `AppCoordinator`) are injected via `.environment()`.
|
||||
- Domain services are `@Observable` classes or actors.
|
||||
- Views read state directly from environment objects — no bindings to published properties.
|
||||
- All mutations go through service or coordinator methods, not direct property writes from views.
|
||||
- No Combine. Use `@Observable` + `async/await` for reactive patterns.
|
||||
|
||||
---
|
||||
|
||||
## 9. Navigation Rules
|
||||
|
||||
All navigation is driven by `AppCoordinator`.
|
||||
|
||||
```swift
|
||||
// Three-column layout (standard for these apps)
|
||||
NavigationSplitView {
|
||||
Sidebar(coordinator: coordinator)
|
||||
} content: {
|
||||
ContentList(coordinator: coordinator)
|
||||
} detail: {
|
||||
DetailView(coordinator: coordinator)
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Use `NavigationSplitView` for three-column layouts (sidebar / content / detail).
|
||||
- **One** `NavigationStack` or `NavigationSplitView` at the top level. Never nest additional stacks inside feature views.
|
||||
- Modal flows (sheets, alerts, inspectors) are presented via boolean flags on `AppCoordinator`.
|
||||
- Deep linking and programmatic navigation go through coordinator methods.
|
||||
- Use `.navigationDestination(for:)` driven by coordinator state for push-based navigation within a column.
|
||||
@@ -0,0 +1,328 @@
|
||||
# SwiftData Standard
|
||||
|
||||
Applies to: InControl, ShabuBox, Threader, Modeler
|
||||
|
||||
---
|
||||
|
||||
## 1. Schema Versioning
|
||||
|
||||
Every SwiftData model change goes through `VersionedSchema` + `SchemaMigrationPlan`. No exceptions.
|
||||
|
||||
### Rules
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Always version | Every model or stored-property addition, removal, or rename requires a new `VersionedSchema` enum and a corresponding `MigrationStage` in the migration plan. |
|
||||
| Never modify existing versions | Once a `VersionedSchema` is shipped, treat it as immutable. Create a new version instead. |
|
||||
| List ALL active models | Each `VersionedSchema.models` array must contain every model that should exist after that version. Omitting a model drops its table on migration. |
|
||||
| No unversioned schemas | Never pass `Schema([...])` directly to `ModelContainer`. Always use `Schema(versionedSchema:)` with `migrationPlan:`. |
|
||||
|
||||
### ModelContainerFactory
|
||||
|
||||
```swift
|
||||
// Correct
|
||||
let schema = Schema(versionedSchema: AppSchemaV3.self)
|
||||
let config = ModelConfiguration(schema: schema)
|
||||
let container = try ModelContainer(
|
||||
for: schema,
|
||||
migrationPlan: AppMigrationPlan.self,
|
||||
configurations: [config]
|
||||
)
|
||||
|
||||
// Wrong -- unversioned, no migration plan
|
||||
let container = try ModelContainer(for: MyModel.self)
|
||||
```
|
||||
|
||||
### Migration Stages
|
||||
|
||||
Use **lightweight** stages for structural changes (additions, removals, renames). These are the only stages compatible with CloudKit `.automatic` sync.
|
||||
|
||||
```swift
|
||||
// Lightweight -- safe for CloudKit .automatic
|
||||
.lightweight(fromVersion: AppSchemaV1.self, toVersion: AppSchemaV2.self)
|
||||
```
|
||||
|
||||
Use **custom** stages only when data must be transformed (splitting a field, computing a derived value). Custom stages require CloudKit sync mode `.none` -- coordinate with the sync layer before adding one.
|
||||
|
||||
```swift
|
||||
// Custom -- requires CloudKit .none
|
||||
.custom(
|
||||
fromVersion: AppSchemaV2.self,
|
||||
toVersion: AppSchemaV3.self,
|
||||
willMigrate: { context in
|
||||
// transform data here
|
||||
try context.save()
|
||||
},
|
||||
didMigrate: nil
|
||||
)
|
||||
```
|
||||
|
||||
### Decision Table
|
||||
|
||||
| Change type | Stage | CloudKit compatible |
|
||||
|-------------|-------|---------------------|
|
||||
| Add model | lightweight | Yes |
|
||||
| Add optional property | lightweight | Yes |
|
||||
| Remove property | lightweight | Yes |
|
||||
| Rename property (`@Attribute(originalName:)`) | lightweight | Yes |
|
||||
| Split field into two | custom | No -- use `.none` sync |
|
||||
| Backfill computed values | custom | No -- use `.none` sync |
|
||||
|
||||
---
|
||||
|
||||
## 2. Skeleton-First Pattern
|
||||
|
||||
Create the record with `status = .processing` immediately so the UI shows it right away. Fill in real data as background processing completes.
|
||||
|
||||
```swift
|
||||
// 1. Insert skeleton record -- instant UI feedback
|
||||
let record = MyRecord(name: placeholder, status: .processing)
|
||||
modelContext.insert(record)
|
||||
try modelContext.save()
|
||||
|
||||
// 2. Process in background
|
||||
let result = await heavyWork()
|
||||
|
||||
// 3. Update the record
|
||||
record.name = result.name
|
||||
record.status = .ready
|
||||
try modelContext.save()
|
||||
```
|
||||
|
||||
The user sees the record appear in the list immediately. As processing finishes, the record fills in and the status indicator clears.
|
||||
|
||||
---
|
||||
|
||||
## 3. Indexing
|
||||
|
||||
Add `#Index` on fields that appear in predicates, sort descriptors, or frequent lookups.
|
||||
|
||||
```swift
|
||||
@Model
|
||||
final class Task {
|
||||
var title: String
|
||||
var dueDate: Date?
|
||||
var status: String
|
||||
var projectID: UUID?
|
||||
var updatedAt: Date
|
||||
// ...
|
||||
}
|
||||
|
||||
// Declare indexes for query performance
|
||||
extension Task {
|
||||
static let indexes: [[IndexColumn<Task>]] = [
|
||||
[\.dueDate],
|
||||
[\.projectID],
|
||||
[\.status],
|
||||
[\.updatedAt]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Minimum Indexes by Domain
|
||||
|
||||
| Entity | Indexed fields |
|
||||
|--------|---------------|
|
||||
| Task | dueDate, projectID, columnID, status, updatedAt |
|
||||
| Expense | date, projectID, categoryID, status |
|
||||
| Opportunity | stageID, targetCloseDate, updatedAt |
|
||||
| ExternalLink | provider, externalID, (entityType + entityID) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Query Patterns
|
||||
|
||||
### Use DataStoreActor for Background Queries
|
||||
|
||||
All views should use background actor queries. Do not use `@Query` or synchronous `DataStore` calls for production data access.
|
||||
|
||||
```swift
|
||||
// Proven pattern: background actor query + main-thread model resolution
|
||||
let (ids, count) = try await dataStoreActor.fetchItemsWithCount(page: page, limit: limit)
|
||||
|
||||
await MainActor.run {
|
||||
loadedItems = modelContext.items(from: ids)
|
||||
}
|
||||
```
|
||||
|
||||
### Prefer Database-Level Filtering
|
||||
|
||||
Use `#Predicate` for filtering whenever possible. In-memory filtering fetches all matching rows into memory and becomes a scalability problem at 10k+ records.
|
||||
|
||||
```swift
|
||||
// Good -- database-level filtering
|
||||
let predicate = #Predicate<Task> { $0.status == "active" && $0.projectID == targetID }
|
||||
let descriptor = FetchDescriptor(predicate: predicate)
|
||||
let results = try modelContext.fetch(descriptor)
|
||||
|
||||
// Bad -- fetches everything, filters in Swift
|
||||
let all = try modelContext.fetch(FetchDescriptor<Task>())
|
||||
let filtered = all.filter { $0.status == "active" }
|
||||
```
|
||||
|
||||
If in-memory filtering is unavoidable (e.g., complex path-prefix matching), add a `fetchLimit` cap and document why database-level filtering was not possible.
|
||||
|
||||
```swift
|
||||
// Acceptable only with justification and cap
|
||||
var descriptor = FetchDescriptor<Item>(predicate: basePredicate)
|
||||
descriptor.fetchLimit = 500 // Cap to prevent unbounded memory
|
||||
let items = try modelContext.fetch(descriptor)
|
||||
// In-memory filter required: destinationPath prefix matching not supported by #Predicate
|
||||
let filtered = items.filter { $0.destinationPath.hasPrefix(targetPath) }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Safe Fetch
|
||||
|
||||
Never use bare `try?` on `modelContext.fetch()`. Use the safe wrappers that log failures.
|
||||
|
||||
```swift
|
||||
// Good -- logs the error, returns empty array on failure
|
||||
let items = dataStore.safeFetch(descriptor, operation: "loading active tasks")
|
||||
|
||||
// Good -- logs the error, returns 0 on failure
|
||||
let count = dataStore.safeFetchCount(descriptor, operation: "counting inbox items")
|
||||
|
||||
// Bad -- silently swallows fetch errors
|
||||
let items = try? modelContext.fetch(descriptor)
|
||||
```
|
||||
|
||||
`safeFetch` and `safeFetchCount` wrap the call in `do/try/catch`, log with `logger.error()` including the operation name, and return a safe default (empty array or zero).
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Modeling Conventions
|
||||
|
||||
### Primary Keys
|
||||
|
||||
All entities use `UUID` primary keys.
|
||||
|
||||
```swift
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
```
|
||||
|
||||
### Timestamps
|
||||
|
||||
Every entity carries automatic timestamps.
|
||||
|
||||
```swift
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
```
|
||||
|
||||
Update `updatedAt` on every mutation.
|
||||
|
||||
### Soft Delete
|
||||
|
||||
Prefer archiving over hard deletion for auditability and sync safety.
|
||||
|
||||
```swift
|
||||
var isArchived: Bool = false
|
||||
var archivedAt: Date?
|
||||
```
|
||||
|
||||
### Money
|
||||
|
||||
Store monetary values as `Int64` minor units (cents) plus an ISO 4217 currency code. Never use floating-point for money.
|
||||
|
||||
```swift
|
||||
var amountMinor: Int64 // e.g., 1999 = $19.99
|
||||
var currencyCode: String // e.g., "USD"
|
||||
```
|
||||
|
||||
### Kanban Ordering
|
||||
|
||||
Use `sortIndex: Double` for position within a column. Doubles allow cheap insertion between two items without rewriting the entire list.
|
||||
|
||||
```swift
|
||||
var sortIndex: Double
|
||||
|
||||
// Insert between items at 1.0 and 2.0
|
||||
newItem.sortIndex = 1.5
|
||||
```
|
||||
|
||||
### Many-to-Many Relationships
|
||||
|
||||
Use explicit join tables instead of native SwiftData many-to-many. This is CloudKit-friendly and supports auditing.
|
||||
|
||||
```swift
|
||||
@Model
|
||||
final class EntityTag {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var entityType: String
|
||||
var entityID: UUID
|
||||
var tagID: UUID
|
||||
var createdAt: Date = Date()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Actor Safety
|
||||
|
||||
Isolate SwiftData models within the appropriate actor to prevent data races.
|
||||
|
||||
### Logger in @Model Classes
|
||||
|
||||
`@Model` classes are actor-isolated, but `Logger` is not `Sendable`. Declare the logger at file scope with `nonisolated(unsafe)` to avoid concurrency warnings.
|
||||
|
||||
```swift
|
||||
private nonisolated(unsafe) let logger = Logger(
|
||||
subsystem: "com.yourapp",
|
||||
category: "MyModel"
|
||||
)
|
||||
|
||||
@Model
|
||||
final class MyModel {
|
||||
// Use `logger` freely inside the class
|
||||
}
|
||||
```
|
||||
|
||||
For structs (including SwiftUI views), use `private static let logger` instead.
|
||||
|
||||
---
|
||||
|
||||
## 8. Error Handling in Models
|
||||
|
||||
### Encode / Decode
|
||||
|
||||
Never use `try?` on encode or decode operations. A silent failure here means corrupted or lost data.
|
||||
|
||||
```swift
|
||||
// Good
|
||||
do {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
self.storedData = data
|
||||
} catch {
|
||||
logger.error("Failed to encode value: \(error)")
|
||||
self.storedData = Data() // explicit fallback
|
||||
}
|
||||
|
||||
// Bad -- silently drops data
|
||||
self.storedData = try? JSONEncoder().encode(value)
|
||||
```
|
||||
|
||||
### modelContext.save()
|
||||
|
||||
Always wrap saves in `do/try/catch`. Save failures indicate data corruption or constraint violations -- they must never be silently ignored.
|
||||
|
||||
```swift
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
logger.error("Save failed: \(error)")
|
||||
}
|
||||
```
|
||||
|
||||
### When bare try? Is Acceptable
|
||||
|
||||
Only for truly ignorable, idempotent operations. Always include a comment.
|
||||
|
||||
```swift
|
||||
// Ignorable: removing file before overwrite
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
|
||||
// Ignorable: sleep cancellation
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# 03 — Storage & Sandboxing
|
||||
|
||||
Standards for iCloud storage, file operations, sandboxing, and path security across all macOS apps.
|
||||
|
||||
---
|
||||
|
||||
## 1. iCloud-First Strategy
|
||||
|
||||
All user data lives in the Ubiquity Container (`Documents/`). This provides zero-config cross-device sync via CloudKit.
|
||||
|
||||
- Use `FileManager.default.url(forUbiquityContainerIdentifier:)` to discover the container
|
||||
- Store user-created content in `Documents/` within the container
|
||||
- Monitor `NSMetadataQuery` for `ubiquitousItemHasUnresolvedConflictsKey` to detect sync conflicts
|
||||
- For apps without iCloud (e.g., hardware-specific apps), store in Application Support
|
||||
|
||||
---
|
||||
|
||||
## 2. NSFileCoordinator Mandate
|
||||
|
||||
**ALL file operations within the iCloud library root must use NSFileCoordinator.** Direct `FileManager.default` calls are prohibited.
|
||||
|
||||
| Operation | Required API |
|
||||
|-----------|-------------|
|
||||
| Read file | `FileCoordinatorService.shared.coordinatedRead()` |
|
||||
| Write file | `FileCoordinatorService.shared.coordinatedWrite()` |
|
||||
| Move file | `FileCoordinatorService.shared.coordinatedMove()` (locks both source + destination) |
|
||||
| Delete file | `SafeFileOperations.shared.coordinatedDelete()` |
|
||||
| Async file ops | `AsyncFileManager.shared.*` (routes through FileCoordinatorService) |
|
||||
| Create directory | `AsyncFileManager.shared.createDirectory()` |
|
||||
| List directory | `AsyncFileManager.shared.contentsOfDirectory()` |
|
||||
|
||||
### Allowed FileManager.default Exceptions
|
||||
|
||||
Direct `FileManager.default` is acceptable ONLY for:
|
||||
|
||||
- **`.url(forUbiquityContainerIdentifier:)`** — iCloud container discovery
|
||||
- **`.temporaryDirectory`** — Temp path access (no coordination needed)
|
||||
- **App bundle resources** — Read-only checks outside iCloud
|
||||
- **Pre-container-init checks** — Migration coordinators before container is available
|
||||
|
||||
### Async Wrappers
|
||||
|
||||
Always prefer async wrappers over synchronous coordination:
|
||||
|
||||
```swift
|
||||
// Use these:
|
||||
FileCoordinatorService.coordinatedReadAsync()
|
||||
FileCoordinatorService.coordinatedWriteAsync()
|
||||
FileCoordinatorService.coordinatedDelete()
|
||||
FileCoordinatorService.coordinatedCreateDirectory()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. TOCTOU Prevention
|
||||
|
||||
Never use `fileExists` before an operation that would fail gracefully without it.
|
||||
|
||||
| Anti-Pattern | Correct Pattern |
|
||||
|---|---|
|
||||
| `if fileExists { removeItem }` | `try? removeItem` (ignore ENOENT) |
|
||||
| `if fileExists { moveItem }` | `try moveItem` (handle error in catch) |
|
||||
| `if !fileExists { createDirectory }` | `createDirectory(withIntermediateDirectories: true)` |
|
||||
| `if fileExists { read }` | `try read` (handle ENOENT in catch) |
|
||||
|
||||
### Valid Uses of `fileExists`
|
||||
|
||||
Where the check IS the operation (no subsequent file op):
|
||||
|
||||
- **User-facing warnings** — "File missing" display-only messages
|
||||
- **Migration gates** — "Does old database exist?" (read-only decision)
|
||||
- **Cache hit checks** — Thumbnail cache optimization where race is harmless
|
||||
|
||||
---
|
||||
|
||||
## 4. Safe Deletion Paths
|
||||
|
||||
Coordinated delete operations should only allow deletion within controlled directories:
|
||||
|
||||
- `/tmp/`
|
||||
- `/Caches/`
|
||||
- `/_Duplicate`
|
||||
- `/_Deleted`
|
||||
- `/_InBox`
|
||||
- `/.bento/` (or app-specific internal directory)
|
||||
|
||||
Deletion outside these paths requires explicit user confirmation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Conflict Resolution
|
||||
|
||||
- Monitor `NSMetadataQuery` for `ubiquitousItemHasUnresolvedConflictsKey`
|
||||
- No silent last-writer-wins — merge deterministically or queue for user resolution
|
||||
- Implement conflict resolution UI for user-facing data conflicts
|
||||
- See `08-data-integrity.md` for CloudKit sync safety guards
|
||||
|
||||
---
|
||||
|
||||
## 6. Sandboxing
|
||||
|
||||
### Security-Scoped Bookmarks
|
||||
|
||||
- All apps are sandboxed for App Store distribution
|
||||
- Use security-scoped bookmarks for persistent access to user-selected folders outside the container
|
||||
- Always call `startAccessingSecurityScopedResource()` / `stopAccessingSecurityScopedResource()` in balanced pairs
|
||||
|
||||
### Inbox Pattern
|
||||
|
||||
- New documents enter through a centralized `_InBox/` directory for review and classification
|
||||
- System folder names use singular with underscore prefix: `_InBox`, `_Duplicate`, `_Deleted`
|
||||
- Never use plural forms (`_Deleted` not `_Deleteds`)
|
||||
|
||||
---
|
||||
|
||||
## 7. Path Security
|
||||
|
||||
### Filename Sanitization
|
||||
|
||||
- Sanitize ALL filenames from external sources via `PathUtilities.sanitizeFilename()` / `sanitizePath()` / `resolveURL(relativePath:rootURL:)`
|
||||
- Never trust filenames from: file imports, drag-and-drop, network responses, user input
|
||||
- Prevent path traversal (`../`) attacks
|
||||
|
||||
### LLM Prompt Injection Defense
|
||||
|
||||
- All document text must pass through `sanitizeForPrompt()` before embedding in LLM prompts (RAG chunks, summaries, chat context)
|
||||
- When adding new LLM provider support, add that provider's format markers to `sanitizeForPrompt()` (special tokens like `<s>`, `<bos>`, bracket delimiters like `[SYSTEM]`)
|
||||
- Test sanitization against the provider's known injection vectors
|
||||
|
||||
---
|
||||
|
||||
## 8. Performance Notes
|
||||
|
||||
- **`autoreleasepool`** is required in any loop processing images, thumbnails, or PDF pages (synchronous only — no `await` inside the autoreleasepool block)
|
||||
- **Single `resourceValues(forKeys:)`** — Never call `fileExists` + `attributesOfItem` separately; use one `resourceValues(forKeys:)` call
|
||||
- **Cap PDF rendering** at 4096px maximum dimension
|
||||
- **Close both Pipe file handles** after subprocess communication
|
||||
- **Check `Task.isCancelled`** at the top of every iteration in long file-processing loops
|
||||
|
||||
---
|
||||
|
||||
## 9. URL Storage
|
||||
|
||||
When storing URLs via `@AppStorage`, remember:
|
||||
- `URL.absoluteString` includes the `file://` scheme
|
||||
- Reconstruct with `URL(string:)`, **never** `URL(fileURLWithPath:)` (which would double the scheme)
|
||||
- Document this pattern wherever URL storage is used
|
||||
@@ -0,0 +1,150 @@
|
||||
# 04 — Swift Conventions
|
||||
|
||||
Centralized Swift standards for InControl, ShabuBox, Threader, and Modeler.
|
||||
All four apps target macOS, use Swift 6, SwiftUI, and SwiftData.
|
||||
|
||||
---
|
||||
|
||||
## 1. Swift 6 Concurrency Rules
|
||||
|
||||
| Rule | Details |
|
||||
|------|---------|
|
||||
| Shared mutable state | Must be `@MainActor` or actor-isolated. No unprotected shared vars. |
|
||||
| `@Sendable` closures | All closures in `Task`, `Task.detached`, and `withCheckedThrowingContinuation` must be `@Sendable`. |
|
||||
| async/await | Prefer over callbacks and closures in all new code. |
|
||||
| Progress reporting | Use `AsyncThrowingStream`, not callback-based progress handlers. |
|
||||
| DispatchQueue | Never use when Swift Concurrency works. No `DispatchQueue.main.async` -- use `@MainActor` instead. |
|
||||
| File I/O on @MainActor | Prohibited. Dispatch via `Task.detached { }.value` or an async file manager. |
|
||||
| Boolean flags | Use `os_unfair_lock` for thread-safe boolean flags (not `NSLock`). |
|
||||
| Logger in @Model classes | `private nonisolated(unsafe) let logger = Logger(...)` at file scope. Required because `@Model` classes are not Sendable. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Logging Standard
|
||||
|
||||
**No `print()` in production code.** Use `os.Logger` exclusively.
|
||||
`print()` is only acceptable in `#Preview` blocks and test helpers.
|
||||
|
||||
### Subsystem and Category
|
||||
|
||||
- **Subsystem**: `"com.<appname>.app"` -- always a static string literal. Never use `Bundle.main.bundleIdentifier ?? "..."` or any dynamic expression.
|
||||
- **Category**: The type name (e.g., `"EmailSyncService"`, `"ConversationView"`).
|
||||
|
||||
### Declaration Patterns
|
||||
|
||||
| Context | Declaration | Access |
|
||||
|---------|-------------|--------|
|
||||
| Class or actor | `private let logger = Logger(subsystem: "com.<app>.app", category: "ClassName")` | `logger` |
|
||||
| Struct or SwiftUI view | `private static let logger = Logger(subsystem: "com.<app>.app", category: "StructName")` | `Self.logger` |
|
||||
| Nested struct (e.g., sheet inside a view) | Declares its own `private static let logger`. Cannot reference the parent's `Self.logger`. | `Self.logger` |
|
||||
| `@Model` class | `private nonisolated(unsafe) let logger = Logger(subsystem: "com.<app>.app", category: "ModelName")` | `logger` |
|
||||
|
||||
### Enum Interpolation
|
||||
|
||||
`os.Logger` string interpolation requires types conforming to specific protocols. For enums and other non-conforming types:
|
||||
|
||||
```swift
|
||||
logger.info("State changed to \(String(describing: newState))")
|
||||
```
|
||||
|
||||
### Log Levels
|
||||
|
||||
| Level | Use |
|
||||
|-------|-----|
|
||||
| `.info` | Normal operational flow |
|
||||
| `.warning` | Expected failures (file not found, timeout, network unavailable) |
|
||||
| `.error` | Unexpected failures (encoding bugs, logic errors, constraint violations) |
|
||||
| `.debug` | Verbose or sensitive output (only visible in debug builds) |
|
||||
|
||||
### Never Log
|
||||
|
||||
- Tokens or credentials
|
||||
- Full API response bodies
|
||||
- Log status codes and error types only
|
||||
|
||||
---
|
||||
|
||||
## 3. Error Handling
|
||||
|
||||
### Catch Blocks
|
||||
|
||||
Every `catch` must do at least one of:
|
||||
|
||||
1. Log with `logger.error()` or `logger.warning()`
|
||||
2. Re-throw
|
||||
3. Return `Result.failure`
|
||||
|
||||
**No empty catch blocks.** Ever.
|
||||
|
||||
### modelContext.save()
|
||||
|
||||
Always wrap in explicit error handling:
|
||||
|
||||
```swift
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
logger.error("Failed to save context: \(error)")
|
||||
}
|
||||
```
|
||||
|
||||
Never use bare `try? modelContext.save()` -- save failures indicate data corruption or constraint violations.
|
||||
|
||||
### Bare try?
|
||||
|
||||
Acceptable only for truly ignorable operations:
|
||||
|
||||
- `try? await Task.sleep(...)`
|
||||
- `try? FileManager.default.removeItem(...)` before an overwrite
|
||||
- Other idempotent operations
|
||||
|
||||
**Always add a comment explaining why the error is ignorable.**
|
||||
|
||||
### Multi-Step Operations
|
||||
|
||||
Any operation with 3+ sequential steps that modify state (migration, batch import, sync) must:
|
||||
|
||||
- Implement rollback, **or**
|
||||
- Be idempotent
|
||||
|
||||
Verification after the operation must throw on failure (not just log) so the caller can roll back.
|
||||
|
||||
---
|
||||
|
||||
## 4. File Size Limits
|
||||
|
||||
| File type | Max lines | Action when approaching limit |
|
||||
|-----------|-----------|-------------------------------|
|
||||
| Services | ~1,000 | Extract helper types |
|
||||
| Views | ~800 | Extract sub-views into separate files; move single-use `@State` into the sub-view |
|
||||
|
||||
### Extraction Pattern
|
||||
|
||||
Prefer `@MainActor enum HelperName` with static methods for stateless extraction:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
enum EmailMessageStorageHelper {
|
||||
static func store(_ message: Message, in context: ModelContext) throws {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Anti-Patterns -- What NOT to Do
|
||||
|
||||
| Anti-Pattern | Correct Approach |
|
||||
|-------------|-----------------|
|
||||
| Force unwrapping (`!`) | Use `guard let`, `if let`, or nil-coalescing. Exception: IBOutlets (none in SwiftUI). |
|
||||
| `DispatchQueue.main.async` | Use `@MainActor` |
|
||||
| Combine (`ObservableObject` / `@Published`) | Use `@Observable` macro + async/await in all new code |
|
||||
| UIKit types | macOS uses AppKit: `NSImage`, `NSWorkspace`, etc. |
|
||||
| Hardcoded paths | Derive from configuration or `AppState` directories |
|
||||
| Synchronous file I/O on main thread | Dispatch to background via `Task.detached` or async file manager |
|
||||
| `print()` in production | Use `os.Logger` |
|
||||
| Bare `try?` on important operations | Use `do/try/catch` with logging. Bare `try?` only for ignorable ops (with comment). |
|
||||
| `@AppStorage` with `URL.absoluteString` | Document that reconstruction requires `URL(string:)`, **not** `URL(fileURLWithPath:)`, because the stored value includes the `file://` scheme. |
|
||||
| `Date()` allocations in hot paths | Use `os_signpost` or gate behind `#if DEBUG`. Do not add `Date()` timing to actor methods without a debug guard. |
|
||||
| `ObservableObject` / `@Published` in new code | Use `@Observable` macro exclusively |
|
||||
@@ -0,0 +1,508 @@
|
||||
# 05 -- Design System
|
||||
|
||||
Centralized visual and interaction standard for all macOS apps in this suite.
|
||||
|
||||
Each project keeps its own concrete theme file (e.g., `ShabuBoxTheme.swift`,
|
||||
`DSTokens.swift`). This document defines the structure and rules those themes
|
||||
must follow.
|
||||
|
||||
**Aesthetic target:** Things-like + Liquid Glass. Content is stable and legible;
|
||||
glass is chrome and framing only.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Principles
|
||||
|
||||
1. **Content-first.** Glass is reserved for chrome and floating UI. Primary reading and editing surfaces remain stable and high-contrast.
|
||||
2. **Calm density.** Use whitespace and grouping; avoid excessive dividers and "always visible" buttons.
|
||||
3. **Keyboard-first.** Every primary action must be accessible via keyboard and reflected in menus with shortcuts.
|
||||
4. **One coherent material model.** Use a small set of material tiers (see Section 3), consistently applied.
|
||||
5. **Accessibility always.** The UI must remain usable with Reduce Transparency, Increase Contrast, Reduce Motion, and Large Dynamic Type.
|
||||
6. **Native components by default.** Use system controls (Buttons, Lists, Toolbars, Forms) unless a custom control is materially better.
|
||||
|
||||
---
|
||||
|
||||
## 2. Aesthetic
|
||||
|
||||
The visual language blends the calm, content-first productivity feel of
|
||||
**Things** (Cultured Code) with the modern navigation chrome and depth of
|
||||
Apple's **Liquid Glass**.
|
||||
|
||||
**North star:** content is stable and legible; glass is used as chrome and
|
||||
framing, never as the primary reading surface.
|
||||
|
||||
- Prefer quiet backgrounds and generous whitespace over ornament.
|
||||
- Depth comes from the material tiers below, not from decorative gradients.
|
||||
- Accent color is used sparingly to convey meaning, not for decoration.
|
||||
|
||||
---
|
||||
|
||||
## 3. Material Tiers
|
||||
|
||||
Use only these three tiers across the app.
|
||||
|
||||
### Tier A -- Chrome Glass (default)
|
||||
|
||||
| Where | Sidebar background, toolbar background, tab bars, navigation chrome |
|
||||
|-------|---------------------------------------------------------------------|
|
||||
| Character | Subtle translucency, subtle border stroke, minimal shadow |
|
||||
|
||||
### Tier B -- Floating Glass
|
||||
|
||||
| Where | Popovers, menus, pickers, floating inspector panels, tooltips |
|
||||
|-------|---------------------------------------------------------------|
|
||||
| Character | Stronger separation than Tier A (stroke + shadow). Content remains legible over any wallpaper. |
|
||||
|
||||
### Tier C -- Content Plate (stabilizer)
|
||||
|
||||
| Where | Text fields, long lists, forms, editors, notes |
|
||||
|-------|------------------------------------------------|
|
||||
| Character | More opaque background, clear edge separation. Keeps text crisp. |
|
||||
|
||||
Used *inside* glass containers whenever content is multi-line, form inputs, or
|
||||
long lists.
|
||||
|
||||
### Hard Constraints
|
||||
|
||||
- **Never place long-form reading surfaces directly on Tier A or Tier B glass.**
|
||||
- Always wrap dense text fields and lists in a Tier C plate.
|
||||
- Respect Reduce Transparency: when enabled, glass becomes opaque with standard
|
||||
system background colors. Do not fight the system.
|
||||
|
||||
---
|
||||
|
||||
## 4. Typography
|
||||
|
||||
Use Apple semantic text styles instead of hard-coded sizes.
|
||||
|
||||
### Semantic Styles
|
||||
|
||||
| Style | Typical Size / Weight | Usage |
|
||||
|-------|----------------------|-------|
|
||||
| `.largeTitle` | 28pt Bold | Screen titles (rare) |
|
||||
| `.title` | 22pt Semibold | Section headers |
|
||||
| `.title2` | 20pt Semibold | Sub-section headers |
|
||||
| `.title3` | 18pt Semibold | Card titles |
|
||||
| `.headline` | 17pt Semibold | Emphasized body |
|
||||
| `.body` | 15pt Regular | Primary content |
|
||||
| `.callout` | 14pt Regular | Supporting text |
|
||||
| `.subheadline` | 13pt Regular | Secondary labels |
|
||||
| `.footnote` | 12pt Regular | Metadata |
|
||||
| `.caption` | 11pt Regular | Timestamps, hints |
|
||||
| `.caption` (bold) | 11pt Semibold | Badge text |
|
||||
|
||||
### Rules
|
||||
|
||||
- Max **one** primary headline per view.
|
||||
- Secondary text uses `.foregroundStyle(.secondary)`.
|
||||
- Max **3 text styles** per view unless it is a dense inspector.
|
||||
- If a design requires a size not in the table, add a named token to the
|
||||
project theme file first. Never use `.font(.system(size: N))` with an
|
||||
unlisted size.
|
||||
|
||||
### Token Example (project theme file)
|
||||
|
||||
```swift
|
||||
enum Typography {
|
||||
static let largeTitle = Font.system(.largeTitle, weight: .bold)
|
||||
static let title = Font.system(.title, weight: .semibold)
|
||||
static let headline = Font.system(.headline, weight: .semibold)
|
||||
static let body = Font.system(.body)
|
||||
static let subheadline = Font.system(.subheadline)
|
||||
static let footnote = Font.system(.footnote)
|
||||
static let caption = Font.system(.caption)
|
||||
static let captionBold = Font.system(.caption, weight: .semibold)
|
||||
static let badge = Font.system(size: 10, weight: .bold)
|
||||
static let sectionHeader = Font.system(.caption, weight: .semibold)
|
||||
.smallCaps()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Iconography
|
||||
|
||||
- Use **SF Symbols exclusively**.
|
||||
- Consistent symbol per meaning -- do not rotate icons for novelty.
|
||||
- Use **filled** variants for selected states; regular (outline) for default.
|
||||
- Icon-only buttons **must** have a tooltip and an accessibility label.
|
||||
|
||||
---
|
||||
|
||||
## 6. Spacing
|
||||
|
||||
All spacing is on a **4pt grid**. This is more flexible than a strict 8pt grid
|
||||
while still maintaining alignment.
|
||||
|
||||
### Token Table
|
||||
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| `xxs` | 4 pt |
|
||||
| `xs` | 8 pt |
|
||||
| `sm` | 12 pt |
|
||||
| `md` | 16 pt |
|
||||
| `lg` | 20 pt |
|
||||
| `xl` | 24 pt |
|
||||
| `xxl` | 32 pt |
|
||||
| `xxxl` | 40 pt |
|
||||
| `xxxxl` | 48 pt |
|
||||
|
||||
### Token Example
|
||||
|
||||
```swift
|
||||
enum Spacing {
|
||||
static let xxs: CGFloat = 4
|
||||
static let xs: CGFloat = 8
|
||||
static let sm: CGFloat = 12
|
||||
static let md: CGFloat = 16
|
||||
static let lg: CGFloat = 20
|
||||
static let xl: CGFloat = 24
|
||||
static let xxl: CGFloat = 32
|
||||
static let xxxl: CGFloat = 40
|
||||
static let xxxxl: CGFloat = 48
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Corner Radii
|
||||
|
||||
### Token Table
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `xs` | 4 pt | Small badges, pills |
|
||||
| `sm` | 8 pt | Controls, buttons |
|
||||
| `md` | 12 pt | Cards, input fields |
|
||||
| `lg` | 16 pt | Panels, sheets |
|
||||
| `xl` | 20 pt | Large floating panels |
|
||||
| `full` | 999 pt | Circular / capsule |
|
||||
|
||||
### Guideline
|
||||
|
||||
- **Controls:** 8--12 pt (`sm` to `md`).
|
||||
- **Panels / cards:** 12--16 pt (`md` to `lg`).
|
||||
- If a design requires a radius not in the table, add the token to the project
|
||||
theme first. Do not use inline `.clipShape(RoundedRectangle(cornerRadius: N))`
|
||||
with non-token values.
|
||||
|
||||
---
|
||||
|
||||
## 8. Color
|
||||
|
||||
### Approach
|
||||
|
||||
- Prefer **system semantic colors** (`primary`, `secondary`, `tertiary`,
|
||||
`separator`, `windowBackground`, `textBackground`, etc.) for automatic
|
||||
light/dark and accessibility support.
|
||||
- The accent tint conveys meaning; avoid decorative tint floods.
|
||||
- Avoid large tinted glass areas behind dense text.
|
||||
|
||||
### Semantic Color Categories
|
||||
|
||||
Each project theme should define tokens in these categories:
|
||||
|
||||
| Category | Example Tokens |
|
||||
|----------|---------------|
|
||||
| Background | `primaryBackground`, `secondaryBackground`, `tertiaryBackground`, `sidebarBackground` |
|
||||
| Text | `textPrimary`, `textSecondary`, `textTertiary` |
|
||||
| Accent | `accent`, project-specific accent variants |
|
||||
| Status | `success`, `warning`, `error`, `info` |
|
||||
| File-type (if applicable) | `fileDocument`, `fileImage`, `fileVideo`, `fileAudio`, `fileArchive`, `fileOther` |
|
||||
|
||||
### Rules
|
||||
|
||||
- No custom colors unless branding requires it. Keep branding to `tint` and
|
||||
small accents.
|
||||
- All custom colors must have both light and dark variants defined in an asset
|
||||
catalog or via `Color(light:dark:)`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Shadows
|
||||
|
||||
### Token Table
|
||||
|
||||
| Token | Radius | Opacity | Usage |
|
||||
|-------|--------|---------|-------|
|
||||
| `subtle` | 4 pt | 0.04 | Default card resting state |
|
||||
| `medium` | 8 pt | 0.08 | Elevated elements |
|
||||
| `elevated` | 16 pt | 0.12 | Floating panels |
|
||||
| `hover` | 12 pt | 0.15 | Hover state lift |
|
||||
|
||||
### Token Example
|
||||
|
||||
```swift
|
||||
enum Shadows {
|
||||
static let subtle = (radius: CGFloat(4), opacity: 0.04)
|
||||
static let medium = (radius: CGFloat(8), opacity: 0.08)
|
||||
static let elevated = (radius: CGFloat(16), opacity: 0.12)
|
||||
static let hover = (radius: CGFloat(12), opacity: 0.15)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Animations & Transitions
|
||||
|
||||
### Animation Tokens
|
||||
|
||||
| Token | Definition | Usage |
|
||||
|-------|-----------|-------|
|
||||
| `quick` | `easeOut(duration: 0.15)` | Hover, focus, toggle |
|
||||
| `standard` | `spring(response: 0.3, dampingFraction: 0.8)` | Selection, card interaction |
|
||||
| `smooth` | `spring(response: 0.4, dampingFraction: 0.85)` | Panel resize, expand/collapse |
|
||||
| `slow` | `spring(response: 0.5, dampingFraction: 0.9)` | View switches, overlays |
|
||||
| `interactive` | `spring(response: 0.3, dampingFraction: 0.7)` | User-driven toggle/tap |
|
||||
| `appear` | `easeOut(duration: 0.2)` | Content fade-in |
|
||||
|
||||
### Replacement Mapping
|
||||
|
||||
When reviewing existing code, replace ad-hoc values:
|
||||
|
||||
| Ad-hoc Value | Replace With |
|
||||
|---|---|
|
||||
| `.easeInOut(duration: 0.15)` | `Animation.quick` |
|
||||
| `.easeOut(duration: 0.2)` | `Animation.appear` |
|
||||
| `.spring(response: 0.3)` | `Animation.standard` |
|
||||
| `.spring(response: 0.3, dampingFraction: 0.7)` | `Animation.interactive` |
|
||||
| `.spring(response: 0.4, dampingFraction: 0.85)` | `Animation.smooth` |
|
||||
| Long-running repeating anims (2s+) | Keep inline |
|
||||
|
||||
### Transition Tokens
|
||||
|
||||
| Token | Effect | Usage |
|
||||
|-------|--------|-------|
|
||||
| `slideTrailing` | Move from trailing edge | Right panels, slide-in detail |
|
||||
| `slideLeading` | Move from leading edge | Left panels |
|
||||
| `slideBottom` | Move from bottom | Toasts, bottom sheets |
|
||||
| `scaleUp` | Scale up + fade | Modals |
|
||||
| `scaleSmall` | Small scale pulse | Loading indicators |
|
||||
| `fade` | Opacity crossfade | Section switching |
|
||||
|
||||
---
|
||||
|
||||
## 11. View Modifiers
|
||||
|
||||
Each project theme should provide these standard view modifiers (names may vary
|
||||
by project):
|
||||
|
||||
| Modifier | Purpose |
|
||||
|----------|---------|
|
||||
| `.glassCard(radius:shadow:)` | Frosted glass container with `.ultraThinMaterial` |
|
||||
| `.solidCard(radius:shadow:backgroundColor:)` | Opaque card with secondary background |
|
||||
| `.hoverScale(scale:hoverShadow:restShadow:)` | Scale ~1.03 + shadow lift on hover |
|
||||
| `.themeBadge(color:style:)` | Capsule badge (`.filled` or `.tinted`) |
|
||||
| `.sidebarItem(isSelected:accentColor:)` | Left accent bar + selection background |
|
||||
| `.shimmer()` | Loading skeleton animation |
|
||||
| `.themeShadow(_:)` | Apply a shadow token |
|
||||
| `.cardPress(scale:)` | Press-down micro-interaction |
|
||||
| `.sectionTransition(id:)` | Fade on content ID change |
|
||||
|
||||
---
|
||||
|
||||
## 12. Layout System
|
||||
|
||||
### Primary Window Template (3-column)
|
||||
|
||||
Use `NavigationSplitView` for the primary structure:
|
||||
|
||||
```
|
||||
+----------+---------------------+-----------+
|
||||
| Sidebar | Content List | Inspector |
|
||||
| (nav) | (work items) | (detail) |
|
||||
+----------+---------------------+-----------+
|
||||
```
|
||||
|
||||
1. **Sidebar:** Perspectives and containers (areas / projects / sections).
|
||||
2. **Content list:** Items within the current scope.
|
||||
3. **Inspector / detail:** Editable properties, notes, metadata.
|
||||
|
||||
### Sidebar Structure
|
||||
|
||||
- Short labels (1--2 words).
|
||||
- Optional count badges.
|
||||
- Clear selection highlight and hierarchy indentation.
|
||||
- Settings is **not** a sidebar item -- access via `Cmd+,` (native Settings scene).
|
||||
|
||||
### Slide-In Detail Pattern
|
||||
|
||||
For module views, use a single-pane layout where the detail view animates in
|
||||
from the trailing edge:
|
||||
|
||||
```
|
||||
+----------+----------------------------+
|
||||
| Sidebar | Module Content |
|
||||
| | +------------------------+ |
|
||||
| | | Detail (slides in) | |
|
||||
| | +------------------------+ |
|
||||
+----------+----------------------------+
|
||||
```
|
||||
|
||||
- Width: 400--500 pt (configurable).
|
||||
- Background: Tier B (Floating Glass) material.
|
||||
- Dismiss via close button or clicking outside.
|
||||
- Animation: `spring(response: 0.3, dampingFraction: 0.8)` with
|
||||
`move(edge: .trailing)` + opacity.
|
||||
|
||||
### Layout Constants
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| Sidebar expanded | ~200 pt |
|
||||
| Sidebar collapsed | ~56 pt |
|
||||
| Sidebar item height | 36 pt |
|
||||
| Right info panel | 280 pt (240 pt min) |
|
||||
| Grid cells | 160--200 pt, aspect ratio ~1.29 |
|
||||
| Grid spacing | 16 pt |
|
||||
| List row height | 52 pt |
|
||||
| List row icon size | 32 pt |
|
||||
| Toolbar height | 36 pt |
|
||||
|
||||
---
|
||||
|
||||
## 13. Components
|
||||
|
||||
### Buttons
|
||||
|
||||
Prefer system button styles:
|
||||
|
||||
| Style | Usage |
|
||||
|-------|-------|
|
||||
| `.borderedProminent` | Primary action (0--1 per view) |
|
||||
| `.bordered` | Secondary actions |
|
||||
| `.plain` | Tertiary / inline |
|
||||
| Toolbar icon buttons | Icon-only with tooltip + accessibility label |
|
||||
|
||||
**Rules:**
|
||||
- One primary action per view.
|
||||
- Do not place prominent buttons on glass without a Tier C plate behind them.
|
||||
|
||||
### Tab Bar
|
||||
|
||||
- Use for view-mode switching (e.g., Board / List), not for status filters.
|
||||
- Icon + text for each tab.
|
||||
- Selected state: accent color with ~15% opacity background.
|
||||
- Unselected state: `.secondary` foreground.
|
||||
- Tab enums should conform to a `TabItem`-style protocol (label + icon).
|
||||
|
||||
### Text Fields & Editors
|
||||
|
||||
- Search is global and always reachable (toolbar / search field).
|
||||
- Inline title editing: commit on Return, cancel on Escape.
|
||||
- Notes editor lives on a Tier C plate even if surrounding UI is glass.
|
||||
|
||||
### Lists
|
||||
|
||||
**Row anatomy:**
|
||||
- Leading: status control (checkbox / icon).
|
||||
- Center: title (`.body`), optional secondary line (`.secondary`).
|
||||
- Trailing: metadata (date / badges), de-emphasized.
|
||||
|
||||
**Interactions:**
|
||||
- Hover reveals secondary actions (overflow menu, quick actions) -- primary
|
||||
info never jumps.
|
||||
- Drag-and-drop reorder where applicable.
|
||||
- Selection state must be obvious under Increase Contrast.
|
||||
|
||||
### Inspector
|
||||
|
||||
A calm, form-like panel. Recommended sections:
|
||||
- Title + status
|
||||
- Schedule / dates
|
||||
- Tags / people
|
||||
- Notes
|
||||
- Attachments / links
|
||||
|
||||
Use popovers for quick pickers, sheets for multi-step edits.
|
||||
|
||||
### Modals
|
||||
|
||||
| Type | Usage |
|
||||
|------|-------|
|
||||
| Popover | Quick pickers / actions |
|
||||
| Sheet | Multi-field create / edit flows |
|
||||
| Alert | Destructive / irreversible confirmation only |
|
||||
|
||||
**Sheet rules:**
|
||||
- Clear title.
|
||||
- Cancel on the leading side, primary action on the trailing side.
|
||||
- Minimal fields per step (avoid mega-sheets).
|
||||
|
||||
### Context Menus
|
||||
|
||||
- Provide context menus for all list rows.
|
||||
- Menu items mirror keyboard shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## 14. Rules for New Views
|
||||
|
||||
Before shipping any new view, verify all of the following:
|
||||
|
||||
1. [ ] Import project theme tokens -- never hardcode colors, spacing, fonts, or animation values.
|
||||
2. [ ] Use `.glassCard()` or `.solidCard()` (or project equivalent) for containers.
|
||||
3. [ ] Use theme animation tokens for all animations.
|
||||
4. [ ] Use theme spacing tokens for all padding and gaps.
|
||||
5. [ ] Use theme typography tokens for all font specs.
|
||||
6. [ ] Add `.hoverScale()` or equivalent for interactive cards / elements.
|
||||
7. [ ] Support light and dark mode through semantic colors.
|
||||
8. [ ] One clear primary action (or none) per view.
|
||||
9. [ ] Visual hierarchy scannable in under 1 second.
|
||||
10. [ ] No dense content placed directly on glass -- use Tier C plate.
|
||||
11. [ ] Secondary actions hidden until hover or context menu.
|
||||
12. [ ] Keyboard shortcuts for primary actions, discoverable via menu / tooltips.
|
||||
13. [ ] Works with Reduce Transparency enabled.
|
||||
14. [ ] When SwiftUI type-checker times out, extract sub-views into `@ViewBuilder` computed properties.
|
||||
|
||||
---
|
||||
|
||||
## 15. macOS Platform Standards
|
||||
|
||||
All apps in this suite must follow these macOS conventions:
|
||||
|
||||
- **`NavigationSplitView`** for multi-column layouts.
|
||||
- **SF Symbols** exclusively for icons.
|
||||
- **Dark Mode** support -- all custom colors must work in both appearances.
|
||||
- **Dynamic Type** support -- use semantic text styles, never fixed pixel sizes
|
||||
as the sole font definition.
|
||||
- **Keyboard navigation** -- shortcuts for all primary actions. Benchmark
|
||||
depth: Things-level (Go-To shortcuts, search / command palette, context-
|
||||
sensitive "New item", inspector toggle).
|
||||
- **`.searchable`** modifier for search integration.
|
||||
- **`.inspector`** modifier for inspector panels where appropriate.
|
||||
- **`.confirmationDialog`** for destructive action confirmation.
|
||||
- **Settings** via SwiftUI `Settings` scene, accessed by `Cmd+,`. Settings is
|
||||
never a sidebar item.
|
||||
- Follow Apple HIG for toolbar placement and controls.
|
||||
|
||||
---
|
||||
|
||||
## 16. Accessibility Requirements
|
||||
|
||||
The UI must work correctly with all of the following enabled:
|
||||
|
||||
| Setting | Requirement |
|
||||
|---------|-------------|
|
||||
| **Reduce Transparency** | Glass becomes opaque with system background colors. Do not fight the system. |
|
||||
| **Increase Contrast** | Selection states, focus rings, and borders must remain clearly visible. |
|
||||
| **Large Dynamic Type** | Layouts must reflow; no truncation of primary content. |
|
||||
| **Reduce Motion** | Replace spring animations with simple crossfades or instant transitions. |
|
||||
|
||||
### Additional Requirements
|
||||
|
||||
- **VoiceOver labels** on all interactive elements.
|
||||
- All visual states (default, hover, pressed, disabled, selected, focus ring)
|
||||
must be defined for every interactive component.
|
||||
- All glass surfaces must be tested on busy wallpapers to guarantee legibility.
|
||||
- When Reduce Transparency is enabled, do not attempt to restore translucency.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Apple Liquid Glass Overview](https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass)
|
||||
- [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/)
|
||||
- [SF Symbols](https://developer.apple.com/sf-symbols/)
|
||||
- [Things by Cultured Code](https://culturedcode.com/things/features/)
|
||||
@@ -0,0 +1,255 @@
|
||||
# 06 — Editor Patterns
|
||||
|
||||
Modern Liquid Glass tabbed editor architecture for all add/edit sheets.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
All add/edit sheets follow a consistent tabbed architecture with:
|
||||
- Wide canvas (800-900px x 600-700px)
|
||||
- Tabbed navigation for progressive disclosure
|
||||
- Smart natural language input prioritized as Tab 0
|
||||
- Consistent spacing and visual hierarchy
|
||||
- Instant tab switching with smooth transitions
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Sheet Presentation
|
||||
|
||||
```swift
|
||||
.sheet(isPresented: $showEditor) {
|
||||
NavigationStack {
|
||||
EditorView(mode: editorMode, onDismiss: { showEditor = false })
|
||||
}
|
||||
.frame(minWidth: 800, idealWidth: 900, minHeight: 600, idealHeight: 700)
|
||||
}
|
||||
```
|
||||
|
||||
- Always wrap in `NavigationStack` for toolbar support
|
||||
- Use `minWidth/idealWidth` and `minHeight/idealHeight`
|
||||
|
||||
### Tab Structure
|
||||
|
||||
```swift
|
||||
private let tabs: [DSEditorTab] = [
|
||||
DSEditorTab(title: "Quick Entry", icon: "sparkles"), // Tab 0: Always NL input
|
||||
DSEditorTab(title: "Details", icon: "info.circle"), // Tab 1: Core fields
|
||||
DSEditorTab(title: "Financial", icon: "dollarsign.circle"), // Tab 2+: Domain-specific
|
||||
DSEditorTab(title: "Terms", icon: "doc.text")
|
||||
]
|
||||
|
||||
@State private var selectedTab: Int = 0
|
||||
```
|
||||
|
||||
### Body Structure
|
||||
|
||||
```swift
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
DSEditorTabBar(tabs: tabs, selectedTab: $selectedTab)
|
||||
Divider()
|
||||
ZStack {
|
||||
if selectedTab == 0 { quickEntryTab }
|
||||
if selectedTab == 1 { detailsTab }
|
||||
if selectedTab == 2 { financialTab }
|
||||
if selectedTab == 3 { termsTab }
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.navigationTitle(mode.isCreate ? "New Entity" : "Edit Entity")
|
||||
.toolbar { /* cancel + save */ }
|
||||
.onAppear { loadExisting() }
|
||||
}
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- `VStack` with `spacing: 0` for tab bar + content
|
||||
- `ZStack` with conditional `if` rendering (NOT `switch` statement)
|
||||
- Each tab gets `.transition(.opacity.animation(.easeInOut(duration: 0.15)))`
|
||||
|
||||
### Tab Content Template
|
||||
|
||||
```swift
|
||||
private var tabName: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Spacing.lg) {
|
||||
DSFormSection("Section Title", icon: "icon.name") {
|
||||
VStack(spacing: DS.Spacing.md) {
|
||||
// Fields
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(DS.Spacing.xl)
|
||||
.padding(.top, DS.Spacing.md)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tab Types
|
||||
|
||||
### Tab 0: Quick Entry (All Editors)
|
||||
|
||||
Natural language input with live parsing preview.
|
||||
|
||||
```swift
|
||||
DSSmartInputField(
|
||||
placeholder: "Entity description example...",
|
||||
text: $smartInput
|
||||
)
|
||||
```
|
||||
|
||||
Examples by entity type:
|
||||
- **Estimate**: "Estimate for BKSI for $5000 due March 15 with Net 30 terms"
|
||||
- **Person**: "John Doe at Apple, john@apple.com, (555) 123-4567"
|
||||
- **Project**: "$50k website redesign for Acme Corp, starting March 1"
|
||||
- **Expense**: "$250 client dinner at Restaurant with Jane from Acme"
|
||||
|
||||
### Tab 1: Details
|
||||
|
||||
Core entity fields. Use `DSTwoColumnRow` for related pairs (number/status, date/expiry). Keep single-column for complex fields (text editors, long pickers).
|
||||
|
||||
### Tab 2+: Domain-Specific
|
||||
|
||||
Financial (discount, tax, budget), Terms (payment, T&C), Contacts (related people/orgs), Time (tracking, estimates), Attachments, etc.
|
||||
|
||||
---
|
||||
|
||||
## Design System Components
|
||||
|
||||
| Component | Purpose | Key Features |
|
||||
|-----------|---------|-------------|
|
||||
| `DSEditorTabBar` | Custom tab bar | 56pt height, full-area clickable, accent highlight, 0.15s animation |
|
||||
| `DSFormSection` | Grouped form fields | DSPlate styling, 16pt corners, 16pt padding, icon + title header |
|
||||
| `DSTextField` | Single-line input | Label above, 8pt padding, quinary background, 10pt corners |
|
||||
| `DSTextEditor` | Multi-line input | Configurable height (default 100pt), scrollable |
|
||||
| `DSPickerRow` | Menu-style picker | Menu style, hidden redundant label, full width |
|
||||
| `DSDatePickerRow` | Compact date picker | Supports .date, .hourAndMinute, or both |
|
||||
| `DSToggleRow` | Toggle switch | System switch style |
|
||||
| `DSTwoColumnRow` | Side-by-side layout | Equal-width columns, 24pt gap, top alignment |
|
||||
| `DSSmartInputField` | NL input | 120pt height, accent border, ready for parsing |
|
||||
| `DSAmountField` | Currency input | Label + prefix ($) + input + optional suffix |
|
||||
| `DSNumericField` | Numeric input | Label + input + suffix (%) |
|
||||
|
||||
---
|
||||
|
||||
## Spacing Standards
|
||||
|
||||
| Context | Token | Value |
|
||||
|---------|-------|-------|
|
||||
| Outer padding (horizontal) | `DS.Spacing.xl` | 32pt |
|
||||
| Top padding (additional) | `DS.Spacing.md` | 16pt |
|
||||
| Between sections | `DS.Spacing.lg` | 24pt |
|
||||
| Between fields | `DS.Spacing.md` | 16pt |
|
||||
| Label-to-input | `DS.Spacing.xs` | 4pt |
|
||||
| Internal section padding | `DS.Spacing.md` | 16pt |
|
||||
| Tab bar height | Fixed | 56pt |
|
||||
| Two-column gap | `DS.Spacing.lg` | 24pt |
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Create vs Edit Mode
|
||||
|
||||
```swift
|
||||
let mode: EditorMode<Entity>
|
||||
|
||||
.navigationTitle(mode.isCreate ? "New Entity" : "Edit Entity")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(mode.isCreate ? "Create" : "Save") { save() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Loading Existing Data
|
||||
|
||||
```swift
|
||||
private func loadExisting() {
|
||||
if case .edit(let entity) = mode {
|
||||
field1 = entity.field1
|
||||
field2 = entity.field2 ?? ""
|
||||
} else {
|
||||
number = Entity.generateNumber()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Width Consistency
|
||||
|
||||
Apply `.frame(maxWidth: .infinity)` at every level:
|
||||
1. `DSFormSection` content VStack
|
||||
2. `DSPickerRow` Picker
|
||||
3. Tab content VStack (before padding)
|
||||
4. Tab content ScrollView (at end)
|
||||
|
||||
---
|
||||
|
||||
## Checklist for New Editor
|
||||
|
||||
- [ ] 3+ tabs with `DSEditorTab` definitions
|
||||
- [ ] `ZStack` with conditional `if` rendering (not `switch`)
|
||||
- [ ] Tab 0: Quick Entry with `DSSmartInputField`
|
||||
- [ ] Tab 1: Details with entity-specific fields
|
||||
- [ ] All tabs: consistent padding (`xl` + top `md`)
|
||||
- [ ] All tabs: `maxWidth/maxHeight` frames
|
||||
- [ ] `DSFormSection` for all content groups
|
||||
- [ ] `DSTwoColumnRow` for related field pairs
|
||||
- [ ] `.borderedProminent` on save/create button
|
||||
- [ ] Sheet frame: 800-900px x 600-700px
|
||||
- [ ] Smooth 0.15s tab transitions
|
||||
- [ ] Width consistency cascade
|
||||
- [ ] Full tab area clickable
|
||||
- [ ] Icons on all tabs and sections
|
||||
|
||||
---
|
||||
|
||||
## Migration from Old Form Style
|
||||
|
||||
**Before** (Form-based):
|
||||
```swift
|
||||
Form {
|
||||
Section("Title") {
|
||||
TextField("Field", text: $value)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
```
|
||||
|
||||
**After** (Tabbed editor):
|
||||
```swift
|
||||
VStack(spacing: 0) {
|
||||
DSEditorTabBar(tabs: tabs, selectedTab: $selectedTab)
|
||||
Divider()
|
||||
ZStack {
|
||||
if selectedTab == 0 { /* Quick Entry */ }
|
||||
if selectedTab == 1 {
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Spacing.lg) {
|
||||
DSFormSection("Title", icon: "icon") {
|
||||
DSTextField("Field", text: $value)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(DS.Spacing.xl)
|
||||
.padding(.top, DS.Spacing.md)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Note
|
||||
|
||||
Each project implements these DS components in its own design system namespace (e.g., `InControlDS`, `ShabuBoxTheme`). The pattern and API surface should be consistent across projects even if the concrete implementation differs.
|
||||
@@ -0,0 +1,164 @@
|
||||
# 07 — AI Integration
|
||||
|
||||
Standards for AI/ML capabilities across all macOS apps.
|
||||
|
||||
---
|
||||
|
||||
## 1. Native-First Principle
|
||||
|
||||
Always prefer Apple's on-device frameworks before reaching for LLMs:
|
||||
|
||||
| Task | Framework | Level |
|
||||
|------|-----------|-------|
|
||||
| Text recognition (OCR) | Apple Vision | Level 1 (primary) |
|
||||
| Entity extraction (names, dates, places) | NaturalLanguage | Level 1 (primary) |
|
||||
| Text classification | NaturalLanguage | Level 1 (primary) |
|
||||
| Language detection | NaturalLanguage | Level 1 (primary) |
|
||||
| Image classification | Vision | Level 1 (primary) |
|
||||
| Barcode/QR detection | Vision | Level 1 (primary) |
|
||||
| Summarization | LLM via protocol | Level 3 (enrichment) |
|
||||
| Complex reasoning | LLM via protocol | Level 3 (enrichment) |
|
||||
|
||||
**Why**: On-device frameworks are fast, private, free, and work offline. LLMs are slow, may require network, and cost compute.
|
||||
|
||||
---
|
||||
|
||||
## 2. LLM Protocol Layer
|
||||
|
||||
Access LLMs through protocols for backend swappability:
|
||||
|
||||
```swift
|
||||
protocol TextGenerating: Sendable {
|
||||
func generate(prompt: String, maxTokens: Int) async throws -> String
|
||||
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<String, Error>
|
||||
}
|
||||
```
|
||||
|
||||
This allows swapping between:
|
||||
- Local models (MLX Swift, Core ML)
|
||||
- Cloud APIs (Anthropic, OpenAI)
|
||||
- Mock implementations (testing)
|
||||
|
||||
Without changing any calling code.
|
||||
|
||||
### Implementation Rules
|
||||
|
||||
- Service layer holds the active `TextGenerating` implementation
|
||||
- Views never call LLM APIs directly
|
||||
- Configuration determines which backend is active
|
||||
- All backends conform to the same protocol
|
||||
|
||||
---
|
||||
|
||||
## 3. Hardware Acceleration
|
||||
|
||||
- Use **Accelerate framework** (vDSP) for vector math and similarity search
|
||||
- Use **Core ML** for model inference when models are available in `.mlmodel` format
|
||||
- Use **Metal Performance Shaders** only when Accelerate is insufficient
|
||||
- For apps with Python backends (e.g., Modeler), communicate via JSON-lines over stdin/stdout
|
||||
|
||||
### Python Backend Protocol (when applicable)
|
||||
|
||||
```json
|
||||
// Request (one JSON object per line on stdin)
|
||||
{"id": "req-1", "method": "generate", "params": {"prompt": "...", "steps": 50}}
|
||||
|
||||
// Response (one JSON object per line on stdout)
|
||||
{"id": "req-1", "type": "progress", "step": 25, "total": 50}
|
||||
{"id": "req-1", "type": "image_result", "path": "/path/to/output.png"}
|
||||
{"id": "req-1", "type": "complete"}
|
||||
```
|
||||
|
||||
- Backend is a managed subprocess — start on demand, health-check, auto-restart
|
||||
- Close both Pipe file handles after communication
|
||||
|
||||
---
|
||||
|
||||
## 4. Memory Hygiene
|
||||
|
||||
```swift
|
||||
// autoreleasepool in ALL loops processing images, thumbnails, or PDFs
|
||||
for page in pages {
|
||||
autoreleasepool {
|
||||
// Process page (synchronous only — no await inside)
|
||||
let image = renderPage(page)
|
||||
processImage(image)
|
||||
}
|
||||
}
|
||||
|
||||
// Check cancellation in long loops
|
||||
for item in items {
|
||||
guard !Task.isCancelled else { break }
|
||||
// process...
|
||||
}
|
||||
```
|
||||
|
||||
- **autoreleasepool**: Required for image/thumbnail/PDF processing loops. Synchronous only — no `await` inside the autoreleasepool block
|
||||
- **PDF rendering cap**: Max 4096px in any dimension
|
||||
- **Task.isCancelled**: Check at top of every iteration in long loops
|
||||
- **Pipe handles**: Always close both ends after subprocess communication
|
||||
|
||||
---
|
||||
|
||||
## 5. Pipeline Architecture
|
||||
|
||||
For apps with file ingestion or multi-stage processing, use a stage-based pipeline:
|
||||
|
||||
### Two-Phase Architecture
|
||||
|
||||
**Phase 1: "Ready-to-Review"** (target: <5s per file)
|
||||
- First-page thumbnail only
|
||||
- First-page OCR (1-3s)
|
||||
- Fast classification
|
||||
- Skeleton Record created immediately for UI feedback
|
||||
|
||||
**Phase 2: "Full Processing"** (deferred, on-demand)
|
||||
- Complete OCR (all pages)
|
||||
- AI analysis and summarization
|
||||
- Embedding generation
|
||||
- Visual fingerprinting
|
||||
|
||||
### Job Priority System
|
||||
|
||||
Each processing job conforms to a pipeline protocol and runs at a defined priority:
|
||||
|
||||
```swift
|
||||
protocol ProcessingJob {
|
||||
var priority: Int { get } // Lower = runs first
|
||||
func process(_ item: Item) async throws
|
||||
}
|
||||
```
|
||||
|
||||
Jobs register in the pipeline service and execute in priority order. Each job must:
|
||||
- Have its own `os.Logger`
|
||||
- Log entry/exit at `.info` level
|
||||
- Check `Task.isCancelled` in loops
|
||||
- Handle errors without blocking subsequent jobs
|
||||
|
||||
---
|
||||
|
||||
## 6. Prompt Injection Defense
|
||||
|
||||
All document text must be sanitized before embedding in LLM prompts:
|
||||
|
||||
```swift
|
||||
let safeText = LLMService.sanitizeForPrompt(rawDocumentText)
|
||||
let prompt = "Summarize the following document:\n\n\(safeText)"
|
||||
```
|
||||
|
||||
### Sanitization Rules
|
||||
|
||||
- Strip provider-specific special tokens (`<s>`, `<bos>`, `</s>`, `[INST]`, `[/INST]`)
|
||||
- Strip bracket delimiters (`[SYSTEM]`, `[USER]`, `[ASSISTANT]`)
|
||||
- Strip XML-style role tags (`<|system|>`, `<|user|>`, `<|assistant|>`)
|
||||
- When adding new LLM provider support, add that provider's format markers
|
||||
- Test sanitization against the provider's known injection vectors
|
||||
|
||||
---
|
||||
|
||||
## 7. When NOT to Use AI
|
||||
|
||||
- Don't use LLMs for tasks that deterministic code handles well (date parsing, number formatting, sorting)
|
||||
- Don't call LLMs on the critical path of user interactions (search, navigation, filtering)
|
||||
- Don't store LLM outputs as authoritative data — they are suggestions for user review
|
||||
- Don't send sensitive data (credentials, financial details) to cloud LLM APIs without user consent
|
||||
@@ -0,0 +1,87 @@
|
||||
# 08 — Data Integrity
|
||||
|
||||
Rules for preventing data loss during backup, restore, migration, and sync operations.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backup/Restore Serialization Parity
|
||||
|
||||
Every persisted `@Model` field must have a corresponding field in its `Backup*` struct. When adding a field to a model, update both directions:
|
||||
|
||||
- `Backup*.init(from:)` -- read from model
|
||||
- `Backup*.toModel()` -- write to new model
|
||||
- The top-level backup container struct if it is a new model type
|
||||
|
||||
**No silent data dropping.** Never assign `[]` or `nil` to backup fields unconditionally. If a field must be skipped:
|
||||
|
||||
- Add a `// WORKAROUND:` comment with the reason
|
||||
- Log at `.warning` level
|
||||
- Document what data is lost and how it can be recovered
|
||||
|
||||
**Roundtrip testing required.** Any change to a `@Model` class or its `Backup*` struct requires a test that:
|
||||
|
||||
1. Creates a model with all fields populated
|
||||
2. Converts to the backup representation and back
|
||||
3. Asserts all fields match
|
||||
|
||||
**Known gaps.** When a backup struct intentionally omits fields, add a `// TODO:` comment listing the missing fields and the reason. Track these as technical debt. Do not ship new backup structs with undocumented omissions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Migration Safety
|
||||
|
||||
### Rollback Contract
|
||||
|
||||
Multi-step migrations must:
|
||||
|
||||
- **Store original state** before any destructive step (rename, delete, overwrite)
|
||||
- **Wrap all steps** in a single `do/catch` that calls `rollback()` on failure
|
||||
- **Test `rollback()` independently** -- it is its own code path and needs its own coverage
|
||||
- **Never mark migration complete** until all verification passes
|
||||
|
||||
### Verification Must Throw
|
||||
|
||||
Validation methods (e.g., `verifyDataIntegrity()`) must `throw` on failure, not just log. The caller's `catch` block handles rollback. A log-only verification is a no-op for safety -- the migration proceeds as if everything succeeded.
|
||||
|
||||
### No New Steps Without Rollback Coverage
|
||||
|
||||
Before adding a step to any migration coordinator, verify that `rollback()` handles the new state and cleans up any artifacts the step creates.
|
||||
|
||||
---
|
||||
|
||||
## 3. CloudKit Sync Safety
|
||||
|
||||
### Explicit Conflict Resolution
|
||||
|
||||
Do not use last-write-wins without user notification. When the sync provider returns a conflict:
|
||||
|
||||
- Log conflict details
|
||||
- Either merge deterministically (union for arrays, latest timestamp for scalars) or queue for user resolution
|
||||
- Never silently discard either version
|
||||
|
||||
### Safety Guards
|
||||
|
||||
Maintain these defensive patterns:
|
||||
|
||||
| Guard | Purpose |
|
||||
|-------|---------|
|
||||
| Abort if >80% of records affected | Prevents runaway repair/cleanup from wiping the dataset |
|
||||
| Self-refreshing flag | Prevents recursive sync notification loops |
|
||||
| 0.25s debounce on notification handlers | Prevents rapid-fire processing of batched notifications |
|
||||
|
||||
### Fetch Error Handling
|
||||
|
||||
Use safe fetch wrappers (e.g., `safeFetch()`, `safeFetchCount()`) in all sync and monitor code paths. Never use bare `try?` on fetch calls -- errors in sync paths must be logged and handled, not silently swallowed.
|
||||
|
||||
---
|
||||
|
||||
## 4. SwiftData Version Coexistence
|
||||
|
||||
V1 and V2 schema versions cannot share live model types in the same process. Attempting to register both causes a "Duplicate checksums" crash.
|
||||
|
||||
When planning a schema version transition:
|
||||
|
||||
- Use `VersionedSchema` and `SchemaMigrationPlan`
|
||||
- Test the migration path from V1 to V2 in isolation
|
||||
- Confirm the old schema version is fully removed before the new one is registered
|
||||
- Never modify existing schema versions -- always create a new one
|
||||
@@ -0,0 +1,159 @@
|
||||
# 09 — Performance
|
||||
|
||||
Patterns for keeping SwiftUI views responsive, queries efficient, and memory bounded.
|
||||
|
||||
---
|
||||
|
||||
## 1. Component Extraction Pattern
|
||||
|
||||
### Problem
|
||||
|
||||
SwiftUI views with high complexity (1000+ lines, dozens of `@State` variables) cause slow task scheduling (3-4s) and expensive view reconciliation. Database queries are typically fast -- the bottleneck is view complexity.
|
||||
|
||||
### Solution: Isolated Components
|
||||
|
||||
Break monolithic views into isolated child components. The parent becomes a lightweight coordinator with minimal state. Each child owns its section-specific state locally.
|
||||
|
||||
```
|
||||
CoordinatorView (minimal state)
|
||||
|- SectionAListView (isolated state)
|
||||
|- SectionBListView (isolated state)
|
||||
|- SectionCListView (isolated state)
|
||||
`- SectionDListView (isolated state)
|
||||
```
|
||||
|
||||
### Proven Results
|
||||
|
||||
| Operation | Before | After | Improvement |
|
||||
|-----------|--------|-------|-------------|
|
||||
| Pagination | 3,800ms | 37ms | ~100x |
|
||||
| Filter change | 3,500ms | 45ms | ~78x |
|
||||
| Sort change | 3,200ms | 52ms | ~62x |
|
||||
| Search | 3,600ms | 35ms | ~103x |
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Identify state to extract** (section-specific): current page, loaded items, loaded total count, loading flags, sort option, items per page, view mode, multi-select mode, selected IDs.
|
||||
|
||||
2. **Keep in parent** (shared across sections): navigation selection, search text, preview item, root configuration.
|
||||
|
||||
3. **Create a dedicated view file** for each section:
|
||||
- Receive `modelContext` and data actor from environment
|
||||
- Accept bindings from parent for shared state
|
||||
- Use local `@State` for section-specific state
|
||||
- Use callbacks for parent actions (`onItemTap`, `onAction`)
|
||||
|
||||
4. **`.task(id:)` must include ALL dependencies:**
|
||||
```swift
|
||||
.task(id: "\(currentPage)-\(itemsPerPage)-\(searchText)-\(sortOption)-\(filter)") {
|
||||
await loadItems()
|
||||
}
|
||||
```
|
||||
Forgetting any dependency (e.g., `itemsPerPage`) means changing it will not trigger a reload.
|
||||
|
||||
5. **Use background actor queries** (see Section 3 below).
|
||||
|
||||
6. **Use `@ViewBuilder` computed properties** for complex toolbars and conditional layouts to prevent SwiftUI type-checker timeouts.
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Items per page not reloading | Add `itemsPerPage` to `.task(id:)` |
|
||||
| Faulted SwiftData objects | Filter with `object.modelContext != nil` |
|
||||
| Search not debouncing | Add debounce task + `onChange` handler (700ms) |
|
||||
| Modal shows empty state | Add `.task` to refresh data on modal appear |
|
||||
|
||||
---
|
||||
|
||||
## 2. State Ownership Matrix
|
||||
|
||||
| Owner | State |
|
||||
|-------|-------|
|
||||
| **Component owns** | Pagination, view preferences, selection, loading flags |
|
||||
| **Parent owns** | Navigation, global search, preview/modal, root config |
|
||||
| **Pass as bindings** | Filters affecting multiple sections, shared search text |
|
||||
| **Pass as callbacks** | Navigation actions, preview actions |
|
||||
|
||||
Target: ~10 `@State` variables per view. If a view exceeds this, it is a candidate for extraction.
|
||||
|
||||
---
|
||||
|
||||
## 3. Background Actor Query Pattern
|
||||
|
||||
All views should use background actor queries instead of `@Query` or synchronous data store calls.
|
||||
|
||||
```swift
|
||||
// Background actor query
|
||||
let (ids, count) = try await actor.fetchItemsWithCount(page:, limit:)
|
||||
// Main thread model conversion
|
||||
loadedItems = modelContext.items(from: ids)
|
||||
```
|
||||
|
||||
### Filtering Rules
|
||||
|
||||
- Use database-level `#Predicate` filtering whenever possible.
|
||||
- If in-memory filtering is unavoidable, add a `fetchLimit` cap and document why the predicate approach does not work.
|
||||
- Any new filter dimension must default to database-level filtering. In-memory filtering of unbounded result sets is a scalability risk.
|
||||
|
||||
---
|
||||
|
||||
## 4. View Complexity Limits
|
||||
|
||||
| Metric | Guideline |
|
||||
|--------|-----------|
|
||||
| `@State` variables per view | ~10 (target) |
|
||||
| Service file size | ~1,000 lines max |
|
||||
| View file size | ~800 lines max |
|
||||
|
||||
When the Swift type-checker times out on a view body, extract sub-expressions into `@ViewBuilder` computed properties or separate files.
|
||||
|
||||
---
|
||||
|
||||
## 5. Memory Management
|
||||
|
||||
- **`autoreleasepool`** in all loops processing images, thumbnails, or PDFs. Synchronous only -- no `await` inside the pool.
|
||||
- **No `Date()` allocations in hot paths** without `#if DEBUG`. Use `os_signpost` intervals for production performance measurement.
|
||||
- **Cap PDF rendering** at 4096px maximum dimension.
|
||||
- **Never cache large arrays.** Use database-level filtering with predicates. Fetch on demand.
|
||||
|
||||
---
|
||||
|
||||
## 6. Refactoring Priority Matrix
|
||||
|
||||
Use this template when prioritizing refactoring work:
|
||||
|
||||
```
|
||||
HIGH IMPACT + LOW RISK = DO FIRST
|
||||
|- Extract shared components (reusable controls, footers, toolbars)
|
||||
|- Create centralized observable state
|
||||
`- Async file operations
|
||||
|
||||
HIGH IMPACT + MEDIUM RISK = DO NEXT
|
||||
|- Async model saves
|
||||
|- ViewModel extraction from large views
|
||||
`- Async processing pipelines
|
||||
|
||||
MEDIUM IMPACT + MEDIUM RISK = DO LATER
|
||||
|- Replace remaining @Query usage with actor pattern
|
||||
`- Async indexing/background services
|
||||
```
|
||||
|
||||
### Metrics to Track
|
||||
|
||||
| Metric | Measure |
|
||||
|--------|---------|
|
||||
| Initial load time | Target < 100ms |
|
||||
| `@State` count in coordinator views | Target ~10 |
|
||||
| Duplicate code lines | Target < 50 |
|
||||
| Views using synchronous queries | Target 0 |
|
||||
| Main thread blocking operations | Target 0 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Lazy Loading
|
||||
|
||||
- **Never cache large arrays.** Always query the database for the current page.
|
||||
- **Database-level filtering** with `#Predicate` is the default. Index frequently queried fields.
|
||||
- **`fetchLimit` cap** when in-memory filtering is unavoidable. Document the reason.
|
||||
- **Debug logging** with `Date()` must be gated behind `#if DEBUG`. Do not add `Date()` timing to methods called per-page without the guard.
|
||||
@@ -0,0 +1,140 @@
|
||||
# 10 — Testing
|
||||
|
||||
Standards for writing reliable, deterministic, and maintainable tests in Swift projects.
|
||||
|
||||
---
|
||||
|
||||
## 1. Framework
|
||||
|
||||
Use the **Swift Testing** framework for all new tests:
|
||||
|
||||
- `@Suite` for test groupings
|
||||
- `@Test` for individual test cases
|
||||
- `#expect` and `#require` for assertions
|
||||
|
||||
Do not use XCTest for new tests. Existing XCTest suites may be migrated incrementally.
|
||||
|
||||
---
|
||||
|
||||
## 2. Mocking
|
||||
|
||||
Use **protocol-oriented mocking**:
|
||||
|
||||
- All services expose a protocol interface (e.g., `protocol FileManaging`)
|
||||
- Concrete implementations conform to the protocol
|
||||
- Tests swap real implementations for mock/stub versions
|
||||
- This enables isolated unit testing without side effects
|
||||
|
||||
```swift
|
||||
protocol DataProviding: Sendable {
|
||||
func fetchItems(page: Int, limit: Int) async throws -> [Item]
|
||||
}
|
||||
|
||||
struct MockDataProvider: DataProviding {
|
||||
var items: [Item] = []
|
||||
func fetchItems(page: Int, limit: Int) async throws -> [Item] {
|
||||
Array(items.prefix(limit))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Timing
|
||||
|
||||
**No timing-dependent tests.** Never rely on fixed `sleep` durations.
|
||||
|
||||
Use polling with early exit:
|
||||
|
||||
```swift
|
||||
for _ in 0..<20 {
|
||||
if await service.isComplete { break }
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
}
|
||||
#expect(await service.isComplete)
|
||||
```
|
||||
|
||||
- Maximum 20 iterations at 100ms each (2s total timeout)
|
||||
- Break as soon as the condition is met
|
||||
- Assert after the loop, not inside it
|
||||
|
||||
---
|
||||
|
||||
## 4. Singleton Isolation
|
||||
|
||||
Shared state must be clean before each test:
|
||||
|
||||
1. Call the service's cleanup/reset method
|
||||
2. `await Task.yield()` to let pending work complete
|
||||
3. Then run assertions
|
||||
|
||||
```swift
|
||||
await SharedService.shared.reset()
|
||||
await Task.yield()
|
||||
|
||||
// Now safe to test
|
||||
await SharedService.shared.doWork()
|
||||
#expect(await SharedService.shared.result == expected)
|
||||
```
|
||||
|
||||
Reset shared state between tests to prevent ordering dependencies.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cooperative Cancellation
|
||||
|
||||
Verify that long-running tasks respond to `Task.isCancelled`:
|
||||
|
||||
- Start a long operation in a `Task`
|
||||
- Cancel it mid-flight
|
||||
- Assert that cancellation produces expected cleanup (partial results discarded, temporary files removed, state reset)
|
||||
|
||||
```swift
|
||||
let task = Task { await service.processLargeDataset() }
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
task.cancel()
|
||||
let result = await task.value
|
||||
#expect(result == .cancelled)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Roundtrip Tests
|
||||
|
||||
**Required** for any change to backup/restore logic or serialization formats.
|
||||
|
||||
Every roundtrip test must:
|
||||
|
||||
1. Create a model instance with **all fields populated** (no defaults)
|
||||
2. Encode to the serialization format (backup struct, JSON, etc.)
|
||||
3. Decode back to a model instance
|
||||
4. Assert every field matches the original
|
||||
|
||||
Do not skip optional fields -- populate them with non-nil values to verify they survive the round trip.
|
||||
|
||||
---
|
||||
|
||||
## 7. Integration Tests
|
||||
|
||||
For systems that communicate with subprocesses or external services:
|
||||
|
||||
- Test the **request/response cycle** end to end
|
||||
- For subprocess protocols (e.g., JSON-lines over stdin/stdout), verify:
|
||||
- Request serialization produces valid output
|
||||
- Response deserialization handles all message types (progress, result, error)
|
||||
- Error responses are surfaced correctly
|
||||
- Use mock subprocess runners or in-process test servers when possible
|
||||
|
||||
---
|
||||
|
||||
## 8. Logging in Tests
|
||||
|
||||
**No `print()` in production code or test helpers.** Use `os.Logger` with an appropriate test subsystem:
|
||||
|
||||
```swift
|
||||
private let logger = Logger(subsystem: "com.app.tests", category: "ServiceTests")
|
||||
```
|
||||
|
||||
`print()` is only acceptable in `#Preview` blocks for quick debugging during development.
|
||||
|
||||
Rationale: `os.Logger` output is filterable, has log levels, and does not pollute test console output with unstructured noise.
|
||||
@@ -0,0 +1,274 @@
|
||||
# 11 — Multiplatform (iOS Companion Apps)
|
||||
|
||||
Standards for building iOS companion apps alongside macOS apps, sharing code and syncing via CloudKit.
|
||||
|
||||
**Reference implementation**: ShabuBox (working macOS + iOS with CloudKit sync)
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared Library Structure
|
||||
|
||||
Any code that both platforms need goes in `Shared/`. Models are ALWAYS shared — never duplicate a `@Model` type per platform.
|
||||
|
||||
```
|
||||
ProjectName/
|
||||
ProjectName/ -- macOS app target
|
||||
App/
|
||||
Core/
|
||||
Features/
|
||||
Services/
|
||||
Views/
|
||||
ProjectNameMobile/ -- iOS companion target
|
||||
App/ -- iOS app entry point
|
||||
Features/ -- iOS-specific views/flows
|
||||
Views/ -- iOS-specific UI
|
||||
Info.plist -- UIBackgroundModes, etc.
|
||||
Shared/ -- Shared library (both targets link this)
|
||||
Models/ -- ALL SwiftData @Model types
|
||||
DesignSystem/ -- Shared theme tokens (platform-adapted)
|
||||
SchemaVersioning.swift -- VersionedSchema + MigrationPlan
|
||||
CloudKitSyncHelper.swift
|
||||
LoggingKit.swift
|
||||
PathUtilities.swift
|
||||
ModelContext+SafeSave.swift
|
||||
```
|
||||
|
||||
### What Goes in Shared/
|
||||
|
||||
| Category | Examples | Why Shared |
|
||||
|----------|----------|-----------|
|
||||
| SwiftData models | All `@Model` types | Schema must be identical for CloudKit sync |
|
||||
| Schema versioning | `VersionedSchema`, `SchemaMigrationPlan` | Version mismatch breaks sync |
|
||||
| Sync helpers | `CloudKitSyncHelper`, manual sync utilities | Both platforms need sync control |
|
||||
| Design tokens | Theme colors, spacing, typography values | Visual consistency across platforms |
|
||||
| Logging | Logger configuration, safe-save extensions | Consistent error handling |
|
||||
| Path utilities | Filename sanitization, path security | Same rules apply everywhere |
|
||||
|
||||
### What Stays Platform-Specific
|
||||
|
||||
| Category | macOS Target | iOS Target |
|
||||
|----------|-------------|-----------|
|
||||
| App entry point | `ProjectNameApp.swift` | `ProjectName_MobileApp.swift` |
|
||||
| Navigation | `NavigationSplitView` (3-column) | `NavigationStack` or compact `NavigationSplitView` |
|
||||
| Views | Desktop-optimized layouts | Touch-optimized layouts |
|
||||
| Services | Desktop-specific services (file monitoring, etc.) | iOS-specific services |
|
||||
| Platform APIs | AppKit (`NSImage`, `NSWorkspace`) | UIKit (`UIImage`, UIBackgroundModes) |
|
||||
|
||||
---
|
||||
|
||||
## 2. CloudKit Container Setup
|
||||
|
||||
### Both Platforms Must Match
|
||||
|
||||
```swift
|
||||
// CRITICAL: Both macOS and iOS must use identical configuration
|
||||
let config = ModelConfiguration(
|
||||
cloudKitDatabase: .private("iCloud.com.yourapp.identifier")
|
||||
)
|
||||
```
|
||||
|
||||
**Never** use `.automatic` — it can infer different containers per platform, causing sync to silently fail.
|
||||
|
||||
### Entitlements (macOS)
|
||||
|
||||
```xml
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.yourapp.identifier</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
<string>CloudDocuments</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Entitlements (iOS)
|
||||
|
||||
```xml
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.yourapp.identifier</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
<string>CloudDocuments</string>
|
||||
</array>
|
||||
<key>com.apple.developer.aps-environment</key> <!-- NOT "aps-environment" -->
|
||||
<string>development</string>
|
||||
```
|
||||
|
||||
**Common mistake**: Using `aps-environment` instead of `com.apple.developer.aps-environment` for the APS key. The wrong key silently disables push notifications for CloudKit changes.
|
||||
|
||||
---
|
||||
|
||||
## 3. iOS Background Sync
|
||||
|
||||
### Info.plist Configuration
|
||||
|
||||
```xml
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Sync Behavior
|
||||
|
||||
- **Typical delay**: 5-30 seconds between devices
|
||||
- **Simulator**: 30-60 seconds (slower than physical devices)
|
||||
- **Background**: System may defer sync to save battery
|
||||
- **Sync triggers**: App launch, app background, periodic intervals, network restored, significant changes
|
||||
|
||||
### Manual Sync Trigger
|
||||
|
||||
Provide a way for users to force sync (pull-to-refresh, sync button):
|
||||
|
||||
```swift
|
||||
// Trigger foreground sync
|
||||
try modelContext.save()
|
||||
// CloudKit will push changes on next sync cycle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Platform-Adaptive UI
|
||||
|
||||
### Navigation Patterns
|
||||
|
||||
```swift
|
||||
#if os(macOS)
|
||||
NavigationSplitView {
|
||||
Sidebar()
|
||||
} content: {
|
||||
ContentList()
|
||||
} detail: {
|
||||
DetailView()
|
||||
}
|
||||
#else
|
||||
NavigationStack {
|
||||
ContentList()
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
### Size Classes
|
||||
|
||||
```swift
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
var body: some View {
|
||||
if sizeClass == .compact {
|
||||
// iPhone layout (single column)
|
||||
} else {
|
||||
// iPad layout (split view)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Platform-Specific Modifiers
|
||||
|
||||
```swift
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func platformToolbar() -> some View {
|
||||
#if os(macOS)
|
||||
self.toolbar { /* macOS toolbar items */ }
|
||||
#else
|
||||
self.toolbar { /* iOS toolbar items */ }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Shared Design System with Platform Adaptation
|
||||
|
||||
Define tokens once in `Shared/DesignSystem/`, apply platform-specific modifiers:
|
||||
|
||||
```swift
|
||||
// Shared/DesignSystem/AppTheme.swift
|
||||
enum AppTheme {
|
||||
enum Spacing {
|
||||
static let sm: CGFloat = 8
|
||||
static let md: CGFloat = 16
|
||||
static let lg: CGFloat = 24
|
||||
}
|
||||
|
||||
enum Typography {
|
||||
static let title: Font = .title
|
||||
static let body: Font = .body
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Platform-specific extensions live in each target:
|
||||
|
||||
```swift
|
||||
// macOS target
|
||||
extension View {
|
||||
func appCard() -> some View {
|
||||
self.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// iOS target
|
||||
extension View {
|
||||
func appCard() -> some View {
|
||||
self.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Schema Versioning Across Platforms
|
||||
|
||||
**Both platforms MUST use identical `VersionedSchema` definitions.** This is why schema versioning lives in `Shared/`.
|
||||
|
||||
- Add new schema versions in `Shared/SchemaVersioning.swift`
|
||||
- Both targets pick up the change automatically
|
||||
- Test migration on both platforms before shipping
|
||||
- A schema version mismatch between platforms causes sync failures or crashes
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing Across Platforms
|
||||
|
||||
| Test Type | Location | Runs On |
|
||||
|-----------|----------|---------|
|
||||
| Model tests | `SharedTests/` | Both platforms |
|
||||
| Service tests | `SharedTests/` | Both platforms |
|
||||
| macOS UI tests | `ProjectNameTests/` | macOS only |
|
||||
| iOS UI tests | `ProjectNameMobileTests/` | iOS only |
|
||||
| Sync roundtrip tests | `SharedTests/` | Both platforms |
|
||||
|
||||
---
|
||||
|
||||
## 8. CloudKit Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause | Fix |
|
||||
|---------|-------------|-----|
|
||||
| Simple models sync, complex models don't | Container ID mismatch (`.automatic` vs explicit) | Use explicit `.private("iCloud.com....")` on both |
|
||||
| iOS doesn't receive remote changes | Wrong APS entitlement key | Use `com.apple.developer.aps-environment` |
|
||||
| Sync works on device, not simulator | Simulator CloudKit delays | Wait longer (30-60s), test on device for real behavior |
|
||||
| "Duplicate checksums" crash | V1 and V2 models sharing live types | Plan version transitions — see `08-data-integrity.md` |
|
||||
| Cascade deletes cause sync issues | Complex relationship graphs | Simplify relationships, use explicit join tables |
|
||||
|
||||
---
|
||||
|
||||
## 9. When NOT to Go Multiplatform
|
||||
|
||||
Not every app needs an iOS companion. Skip it when:
|
||||
|
||||
- **Hardware-specific requirements**: Apps targeting Apple Silicon GPU (e.g., ML model training), large displays, or specific peripherals
|
||||
- **Desktop-only workflows**: Complex multi-window setups, file system manipulation, development tools
|
||||
- **Python/subprocess backends**: Apps that rely on bundled Python environments or native subprocess management
|
||||
- **No user data to sync**: If the app doesn't have user-created content that benefits from cross-device access
|
||||
|
||||
Example: Modeler (AI photo generation) requires Apple Silicon Max with 32GB+ RAM and a bundled Python environment — an iOS companion would not be practical.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Audit Checklist
|
||||
|
||||
Copy this template per project. Check each item, note gaps, and record severity.
|
||||
|
||||
**Project**: _______________
|
||||
**Date**: _______________
|
||||
**Auditor**: _______________
|
||||
|
||||
---
|
||||
|
||||
## 01 Architecture
|
||||
|
||||
- [ ] Uses MVVM-F with feature modules (Models/ViewModels/Views per feature)
|
||||
- [ ] AppCoordinator centralizes all navigation state
|
||||
- [ ] No navigation state duplication between AppCoordinator and AppState
|
||||
- [ ] Directory layout matches convention (`App/`, `Core/`, `Features/`, `Services/`, `Shared/`)
|
||||
- [ ] Protocol-driven service interfaces (not concrete types)
|
||||
- [ ] @Observable macro used (not ObservableObject/@Published) in new code
|
||||
- [ ] No deep-nested NavigationStack in leaf views
|
||||
|
||||
## 02 SwiftData
|
||||
|
||||
- [ ] VersionedSchema + SchemaMigrationPlan in use
|
||||
- [ ] No bare `Schema([...])` — uses `Schema(versionedSchema:)` + `migrationPlan:`
|
||||
- [ ] Each VersionedSchema lists ALL active models
|
||||
- [ ] Skeleton Records for immediate UI feedback (`status = .processing`)
|
||||
- [ ] `#Index` macro on frequently queried fields
|
||||
- [ ] UUID primary keys on all models
|
||||
- [ ] `createdAt` / `updatedAt` timestamps on all models
|
||||
- [ ] Soft delete pattern (`isArchived` + `archivedAt`) where applicable
|
||||
- [ ] Money stored as `Int64` minor units + `currencyCode` (ISO 4217)
|
||||
- [ ] Explicit join tables for many-to-many (CloudKit-friendly)
|
||||
- [ ] Safe fetch wrappers used (no bare `try?` on `modelContext.fetch()`)
|
||||
- [ ] DataStoreActor for background queries (not @Query in views)
|
||||
|
||||
## 03 Storage & Sandboxing
|
||||
|
||||
- [ ] NSFileCoordinator for ALL file ops within library root
|
||||
- [ ] No prohibited `FileManager.default` calls (check lookup table)
|
||||
- [ ] TOCTOU anti-patterns eliminated (no `fileExists` before idempotent ops)
|
||||
- [ ] Security-scoped bookmarks for user-selected external folders
|
||||
- [ ] Filename sanitization from external sources (`PathUtilities`)
|
||||
- [ ] Inbox pattern (`_InBox/`) for new documents
|
||||
- [ ] System folders use singular prefix (`_InBox`, `_Deleted`, `_Duplicate`)
|
||||
- [ ] LLM prompt injection sanitization on document text (if AI features exist)
|
||||
|
||||
## 04 Swift Conventions
|
||||
|
||||
- [ ] No `print()` in production code — `os.Logger` exclusively
|
||||
- [ ] `print()` only in `#Preview` blocks and test helpers
|
||||
- [ ] Logger subsystem follows `"com.<appname>.app"` pattern
|
||||
- [ ] Logger category matches type name
|
||||
- [ ] Classes/actors: `private let logger = Logger(...)`
|
||||
- [ ] Structs/views: `private static let logger = Logger(...)`, accessed via `Self.logger`
|
||||
- [ ] Every `catch` block: logs / rethrows / returns `Result.failure` (no empty catches)
|
||||
- [ ] `modelContext.save()` always in `do/try/catch` with logging
|
||||
- [ ] No bare `try?` on important operations (only for truly ignorable ops, with comment)
|
||||
- [ ] Multi-step operations (3+ steps) have rollback or are idempotent
|
||||
- [ ] File size limits: services ~1,000 lines, views ~800 lines
|
||||
- [ ] No force unwrapping (`!`) in SwiftUI code
|
||||
- [ ] No `DispatchQueue` when Swift Concurrency works
|
||||
- [ ] No `Combine` in new code — use `@Observable` + async/await
|
||||
- [ ] No UIKit types (use AppKit: `NSImage`, `NSWorkspace`)
|
||||
- [ ] All `Task`/`Task.detached` closures are `@Sendable`
|
||||
- [ ] No sync file I/O on `@MainActor`
|
||||
- [ ] `os_unfair_lock` for thread-safe flags (not `NSLock`)
|
||||
|
||||
## 05 Design System
|
||||
|
||||
- [ ] Centralized theme file exists (e.g., `AppTheme.swift`)
|
||||
- [ ] No hardcoded colors — uses theme tokens
|
||||
- [ ] No hardcoded fonts — uses Apple semantic styles or theme tokens
|
||||
- [ ] No hardcoded spacing — uses spacing tokens
|
||||
- [ ] No hardcoded animations — uses animation tokens
|
||||
- [ ] No hardcoded corner radii — uses radius tokens
|
||||
- [ ] Material tier rules followed (glass for chrome, plates for content)
|
||||
- [ ] Keyboard-first: shortcuts for all primary actions
|
||||
- [ ] SF Symbols exclusively for icons
|
||||
- [ ] Filled variants for selected states
|
||||
- [ ] Icon-only buttons have tooltip + accessibility label
|
||||
- [ ] Accessibility: works with Reduce Transparency
|
||||
- [ ] Accessibility: works with Increase Contrast
|
||||
- [ ] Accessibility: works with Large Dynamic Type
|
||||
- [ ] Accessibility: works with Reduce Motion
|
||||
|
||||
## 06 Editor Patterns
|
||||
|
||||
- [ ] Editors follow tabbed architecture (or N/A)
|
||||
- [ ] Tab 0: Quick Entry with natural language input
|
||||
- [ ] Tab 1: Details with core entity fields
|
||||
- [ ] `ZStack` with conditional `if` rendering (not `switch`)
|
||||
- [ ] `DSFormSection` for all content groups
|
||||
- [ ] Wide canvas (800-900px x 600-700px)
|
||||
- [ ] 0.15s tab transitions
|
||||
- [ ] `.borderedProminent` on save/create button
|
||||
|
||||
## 07 AI Integration
|
||||
|
||||
- [ ] Native-first: Vision/NaturalLanguage before LLMs (or N/A)
|
||||
- [ ] LLM accessed via protocol (`TextGenerating` or equivalent)
|
||||
- [ ] `autoreleasepool` in image/PDF processing loops
|
||||
- [ ] `Task.isCancelled` checked in long processing loops
|
||||
- [ ] Prompt injection sanitization on document text
|
||||
- [ ] No LLM calls on critical UI path
|
||||
|
||||
## 08 Data Integrity
|
||||
|
||||
- [ ] Backup/restore: every @Model field has Backup* struct field (or N/A)
|
||||
- [ ] No silent data dropping in backup structs
|
||||
- [ ] Roundtrip tests for serialization changes
|
||||
- [ ] Migration steps have rollback contracts
|
||||
- [ ] Verification throws on failure (not just logs)
|
||||
- [ ] CloudKit conflict resolution is explicit (not silent last-write-wins)
|
||||
- [ ] Safety guards: abort batch operations if >80% affected
|
||||
- [ ] Debounce on sync notification handlers
|
||||
|
||||
## 09 Performance
|
||||
|
||||
- [ ] No views with 20+ @State variables
|
||||
- [ ] Complex views use component extraction pattern
|
||||
- [ ] DataStoreActor for background queries
|
||||
- [ ] `autoreleasepool` in image/thumbnail/PDF loops
|
||||
- [ ] No `Date()` allocations in hot paths without `#if DEBUG`
|
||||
- [ ] `@ViewBuilder` used when type-checker times out
|
||||
- [ ] Database-level `#Predicate` filtering (not in-memory)
|
||||
|
||||
## 10 Testing
|
||||
|
||||
- [ ] Swift Testing framework (`@Suite`, `@Test` macros)
|
||||
- [ ] Protocol-based mock implementations for services
|
||||
- [ ] No timing-dependent tests (uses polling with early exit)
|
||||
- [ ] Singleton state cleaned up between tests
|
||||
- [ ] Cooperative cancellation tested for long-running tasks
|
||||
- [ ] Roundtrip tests for backup/restore changes
|
||||
- [ ] No `print()` in test code (except `#Preview`)
|
||||
|
||||
## 11 Multiplatform
|
||||
|
||||
- [ ] Shared/ directory for all code used by both platforms (or N/A)
|
||||
- [ ] ALL @Model types in Shared/ (never duplicated per platform)
|
||||
- [ ] Schema versioning in Shared/
|
||||
- [ ] Explicit CloudKit container ID (not `.automatic`)
|
||||
- [ ] iOS entitlements use `com.apple.developer.aps-environment` (not `aps-environment`)
|
||||
- [ ] `UIBackgroundModes` configured (remote-notification, fetch)
|
||||
- [ ] Platform-adaptive navigation (`#if os(...)`)
|
||||
- [ ] Shared design tokens with platform-specific modifiers
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Standard | Status | Gaps Found | Severity |
|
||||
|----------|--------|-----------|----------|
|
||||
| 01 Architecture | | | |
|
||||
| 02 SwiftData | | | |
|
||||
| 03 Storage/Sandboxing | | | |
|
||||
| 04 Swift Conventions | | | |
|
||||
| 05 Design System | | | |
|
||||
| 06 Editor Patterns | | | |
|
||||
| 07 AI Integration | | | |
|
||||
| 08 Data Integrity | | | |
|
||||
| 09 Performance | | | |
|
||||
| 10 Testing | | | |
|
||||
| 11 Multiplatform | | | |
|
||||
|
||||
**Severity Levels**: Critical (data loss / crash risk) | High (correctness / maintainability) | Medium (consistency / best practice) | Low (polish / nice-to-have)
|
||||
@@ -0,0 +1,89 @@
|
||||
# Audit & Remediate Project Instructions Against Centralized Standards
|
||||
|
||||
Use this prompt in a fresh Claude Code session for each project. Replace the two variables at the top.
|
||||
|
||||
---
|
||||
|
||||
## Project Configuration
|
||||
- **PROJECT_ROOT**: `/Users/awizemann/Developer/ShabuBox`
|
||||
- **PROJECT_NAME**: `ShabuBox`
|
||||
|
||||
## Instructions
|
||||
|
||||
This project follows centralized standards at `/Users/awizemann/Developer/standards/`. Read `INDEX.md` first, then audit this project against all 11 standards and fix violations.
|
||||
|
||||
### Phase 1: Discovery (Parallel Agents)
|
||||
|
||||
Launch up to 3 explore agents IN PARALLEL, each covering a subset of standards:
|
||||
|
||||
**Agent 1 — Code Quality & Conventions (Standards 04, 02, 01)**
|
||||
Search the project's Swift source files (exclude build/, .build/, SourcePackages/, Pods/) for:
|
||||
- `print(` in production code (not #Preview, not test files) → should be os.Logger
|
||||
- Force unwraps (`!`) — patterns like `.first!`, `as!`, `URL(...)!` (not `!=`, `!==`)
|
||||
- Bare `try?` followed by `modelContext`, `.fetch(`, `.save(`, `encode`, `decode`
|
||||
- `DispatchQueue` usage (should prefer @MainActor / Swift Concurrency)
|
||||
- `ObservableObject` / `@Published` (should be @Observable in new code)
|
||||
- `import Combine` (should use async/await)
|
||||
- `NSLock` (should use os_unfair_lock)
|
||||
- `@Query` in view files (should use DataStoreActor)
|
||||
- Files over 800 lines (views) or 1000 lines (services) — report names and line counts
|
||||
- Logger subsystem strings — check consistency (should be `"com.<appname>.app"`)
|
||||
- Logger declaration patterns — classes should use `private let`, structs/views should use `private static let`
|
||||
- Schema versioning — check for bare `Schema([` without `versionedSchema:`
|
||||
Report exact file paths, line numbers, and counts for each category.
|
||||
|
||||
**Agent 2 — Storage, Security & File Operations (Standards 03, 08)**
|
||||
Search the project's Swift source files for:
|
||||
- `FileManager.default` in service/feature files (should use FileCoordinatorService)
|
||||
- `fileExists` calls — classify each as valid (display-only, migration gate, cache) or TOCTOU anti-pattern
|
||||
- Direct file operations without NSFileCoordinator in library root paths
|
||||
- Bare `try?` on fetch/save in sync-related code
|
||||
- Missing `autoreleasepool` in loops processing images/thumbnails/PDFs
|
||||
- `Date()` allocations in hot paths without `#if DEBUG`
|
||||
- Backup/restore structs — check if @Model fields have corresponding Backup* fields
|
||||
- Migration code — check for rollback contracts
|
||||
Report exact file paths, line numbers, and counts.
|
||||
|
||||
**Agent 3 — Design System & UI (Standards 05, 06, 09)**
|
||||
Search the project's Swift source files for:
|
||||
- Hardcoded colors: `Color(` with literal values, `.foregroundColor(.red/blue/etc)`, `NSColor(calibratedRed:`, `NSColor(red:`
|
||||
- Hardcoded fonts: `.font(.system(size:` instead of theme tokens
|
||||
- Hardcoded spacing: `.padding(` with literal numeric values (not theme tokens like DS.Spacing or AppTheme)
|
||||
- Hardcoded corner radii: `.cornerRadius(` or `RoundedRectangle(cornerRadius:` with literal values
|
||||
- Hardcoded shadows: `.shadow(` with literal values instead of theme tokens
|
||||
- Hardcoded animations: `.animation(.easeInOut(duration:` with literal values instead of theme tokens
|
||||
- Views with 20+ @State variables (performance risk)
|
||||
- Missing accessibility: interactive elements without `.accessibilityLabel`
|
||||
Report counts per category and the worst-offending files.
|
||||
|
||||
### Phase 2: Report
|
||||
|
||||
After all agents complete, compile a single audit report with:
|
||||
|
||||
1. **Summary table** — category, violation count, severity (Critical/High/Medium/Low)
|
||||
2. **Top 10 files to fix** — ranked by total violations across all categories
|
||||
3. **Per-standard breakdown** — what's compliant, what's not, specific file:line references
|
||||
4. **Recommendations** — prioritized remediation order
|
||||
|
||||
### Phase 3: Backfill Project Instructions
|
||||
|
||||
1. **Backup first**: Copy every instruction/guideline file (.md files in .claude/rules/, CLAUDE.md, .agent/, docs/ that contain instructions) to `/Users/awizemann/Developer/standards/backups/{PROJECT_NAME}/2026-03-26/`
|
||||
|
||||
2. **Update the project's main instruction file** (CLAUDE.md or .agent/instructions.md):
|
||||
- Add a Standards section at the top referencing `/Users/awizemann/Developer/standards/`
|
||||
- Remove any rules that are now fully covered by the centralized standards (architecture patterns, SwiftData conventions, file operation rules, error handling rules, logging rules, testing rules, design system principles, etc.)
|
||||
- KEEP all project-specific content: build commands, key file paths, project-specific services, critical lessons, known hotspots, app-specific pipeline/processing details, unique integrations
|
||||
- Add a "Known Audit Gaps" section with the violation counts from the audit
|
||||
- Add a "Top Files to Fix" section listing the worst offenders
|
||||
|
||||
3. **Update any supplementary instruction files** (.claude/rules/*.md, design system docs, etc.):
|
||||
- Add a header line referencing the corresponding centralized standard
|
||||
- Keep project-specific details (hotspot lists, violation counts, app-specific patterns)
|
||||
- Remove duplicated generic rules
|
||||
|
||||
### Important Rules
|
||||
- This is a READ + WRITE task — you should make the file changes
|
||||
- Do NOT modify any Swift source code — only .md instruction/guideline files
|
||||
- Do NOT delete any files — only edit them
|
||||
- Preserve all project-specific content (PRDs, screen layouts, data schemas, development plans, critical lessons)
|
||||
- When in doubt about whether content is project-specific or generic, keep it
|
||||
@@ -0,0 +1,210 @@
|
||||
# Code Compliance Remediation
|
||||
|
||||
Use this prompt in a fresh Claude Code session AFTER the instruction audit has been completed. Replace the two variables at the top.
|
||||
|
||||
---
|
||||
|
||||
## Project Configuration
|
||||
- **PROJECT_ROOT**: `/Users/awizemann/Developer/ShabuBox`
|
||||
- **PROJECT_NAME**: `ShabuBox`
|
||||
- **STANDARDS_PATH**: `/Users/awizemann/Developer/standards`
|
||||
|
||||
## Instructions
|
||||
|
||||
This project has been audited against centralized standards. The audit gaps are documented in the project's instruction file (CLAUDE.md or .agent/instructions.md) under "Known Audit Gaps." Your job is to fix the violations in the actual Swift source code, working through them in priority order.
|
||||
|
||||
**Read the project's instruction file first** to find the audit gaps table and top files to fix.
|
||||
|
||||
---
|
||||
|
||||
### Execution Model: Serial Tiers with Verification
|
||||
|
||||
Work through these tiers ONE AT A TIME, in order. After completing each tier:
|
||||
1. Build the project to verify no regressions
|
||||
2. Report what was fixed and what remains
|
||||
3. Only proceed to the next tier after a clean build
|
||||
|
||||
If a tier has more than ~15 files to change, break it into sub-batches of 8-10 files, building between each batch.
|
||||
|
||||
**Do NOT run multiple tiers in parallel** — later tiers may touch the same files as earlier ones.
|
||||
|
||||
---
|
||||
|
||||
### Tier 1: Safety-Critical (Crashes & Data Loss)
|
||||
|
||||
**1a. Force Unwraps (`!`)**
|
||||
For each force unwrap found in the audit:
|
||||
- `.first!` → `guard let first = collection.first else { return }` or `.first.map { ... }`
|
||||
- `as! Type` → `as? Type` with guard/if-let and logger.error for unexpected type
|
||||
- `URL(string:)!` → `guard let url = URL(string:) else { logger.error(...); return }`
|
||||
- Do NOT change force unwraps inside `#Preview` blocks or test files
|
||||
|
||||
**1b. Bare `try?` on Critical Operations**
|
||||
For each bare `try?` on `modelContext.save()`, `modelContext.fetch()`, `encode`, `decode`:
|
||||
```swift
|
||||
// BEFORE
|
||||
let results = try? modelContext.fetch(descriptor)
|
||||
|
||||
// AFTER
|
||||
let results: [Entity]
|
||||
do {
|
||||
results = try modelContext.fetch(descriptor)
|
||||
} catch {
|
||||
logger.error("Failed to fetch entities: \(error)")
|
||||
results = [] // or return, or throw, depending on context
|
||||
}
|
||||
```
|
||||
- If the file doesn't have a logger, add one following the standard:
|
||||
- Classes/actors: `private let logger = Logger(subsystem: "com.<appname>.app", category: "TypeName")`
|
||||
- Structs/views: `private static let logger = Logger(subsystem: "com.<appname>.app", category: "TypeName")`
|
||||
- Add `import os` if not already present
|
||||
- For `try? modelContext.save()`, always wrap in do/catch — saves should never silently fail
|
||||
|
||||
**Build and verify after Tier 1.**
|
||||
|
||||
---
|
||||
|
||||
### Tier 2: Logging & Error Handling
|
||||
|
||||
**2a. Replace `print()` with `os.Logger`**
|
||||
For each `print()` call in production code (not #Preview, not test files):
|
||||
- Add a logger declaration if the file doesn't have one (follow the struct vs class pattern from standards)
|
||||
- Replace `print("message")` → `logger.error("message")` or `logger.warning("message")` or `logger.info("message")` based on context:
|
||||
- Error/failure messages → `logger.error()`
|
||||
- Expected failure paths → `logger.warning()`
|
||||
- Informational/debug → `logger.info()` or `logger.debug()`
|
||||
- Add `import os` if not already present
|
||||
- Do NOT touch `print()` inside `#Preview` blocks
|
||||
|
||||
**2b. Logger Subsystem Consistency**
|
||||
If the audit found multiple subsystem strings:
|
||||
- Standardize ALL Logger declarations to use the canonical subsystem (check the project's instruction file for the correct one)
|
||||
- Search and replace across all files
|
||||
|
||||
**2c. Logger Declaration Pattern**
|
||||
- Classes/actors should use: `private let logger = Logger(...)`
|
||||
- Structs (including SwiftUI views) should use: `private static let logger = Logger(...)` accessed via `Self.logger`
|
||||
- Nested structs must declare their own logger
|
||||
|
||||
**Build and verify after Tier 2.**
|
||||
|
||||
---
|
||||
|
||||
### Tier 3: File Operations & Concurrency
|
||||
|
||||
**3a. FileManager.default Violations**
|
||||
For each `FileManager.default` call inside the library root (iCloud container):
|
||||
- Replace with the appropriate coordinated API:
|
||||
- Read → `FileCoordinatorService.shared.coordinatedRead()`
|
||||
- Write → `FileCoordinatorService.shared.coordinatedWrite()`
|
||||
- Move → `FileCoordinatorService.shared.coordinatedMove()`
|
||||
- Delete → `SafeFileOperations.shared.coordinatedDelete()`
|
||||
- Create directory → `AsyncFileManager.shared.createDirectory()`
|
||||
- List directory → `AsyncFileManager.shared.contentsOfDirectory()`
|
||||
- Leave allowed exceptions: `.url(forUbiquityContainerIdentifier:)`, `.temporaryDirectory`, app bundle reads
|
||||
- If the project doesn't have FileCoordinatorService/AsyncFileManager, skip this sub-tier and note it
|
||||
|
||||
**3b. fileExists TOCTOU Anti-Patterns**
|
||||
For each `fileExists` that precedes a file operation:
|
||||
- `if fileExists { removeItem }` → `try? removeItem` (ignore ENOENT)
|
||||
- `if fileExists { moveItem }` → `try moveItem` in do/catch
|
||||
- `if !fileExists { createDirectory }` → `createDirectory(withIntermediateDirectories: true)`
|
||||
- Leave valid uses: display-only warnings, migration gates, cache hit checks
|
||||
|
||||
**3c. DispatchQueue → @MainActor**
|
||||
For `DispatchQueue.main.async { }` calls:
|
||||
- If inside an async context: replace with `await MainActor.run { }` or mark the closure `@MainActor`
|
||||
- If the containing function should be main-actor-isolated: add `@MainActor` to the function
|
||||
- Be careful with this one — only change if you're confident the surrounding code supports it
|
||||
- Skip if the DispatchQueue usage is for intentional delayed execution or debouncing
|
||||
|
||||
**Build and verify after Tier 3.**
|
||||
|
||||
---
|
||||
|
||||
### Tier 4: Design System Token Compliance
|
||||
|
||||
**This tier is the largest. Break into batches of 8-10 files, building between each.**
|
||||
|
||||
Before starting, read the project's design system / theme file to understand available tokens. Search for files like `*Theme.swift`, `*Tokens.swift`, `DS.swift`, `DesignSystem/` directory.
|
||||
|
||||
**4a. Hardcoded Font Sizes**
|
||||
- `.font(.system(size: 14))` → `.font(AppTheme.Typography.body)` (or the project's equivalent token)
|
||||
- Map common sizes to the nearest semantic token
|
||||
- If no matching token exists, add one to the theme file first, then use it
|
||||
|
||||
**4b. Hardcoded Colors**
|
||||
- `Color(.red)` or `Color(nsColor: ...)` → `AppTheme.Colors.error` (or equivalent)
|
||||
- `NSColor(calibratedRed: ...)` → theme token
|
||||
- Leave system semantic colors that are already correct (e.g., `.primary`, `.secondary`)
|
||||
- Leave colors defined in the theme file itself
|
||||
|
||||
**4c. Hardcoded Spacing**
|
||||
- `.padding(8)` → `.padding(AppTheme.Spacing.sm)` (or equivalent)
|
||||
- `.padding(.horizontal, 16)` → `.padding(.horizontal, AppTheme.Spacing.md)`
|
||||
- Map numeric values to nearest token: 4→xxs, 8→xs/sm, 12→sm, 16→md, 20→lg, 24→xl, 32→xxl
|
||||
- If the project's tokens use different names, use those
|
||||
|
||||
**4d. Hardcoded Corner Radii**
|
||||
- `.cornerRadius(8)` → `AppTheme.CornerRadius.sm` (or equivalent)
|
||||
- `RoundedRectangle(cornerRadius: 12)` → `RoundedRectangle(cornerRadius: AppTheme.CornerRadius.md)`
|
||||
|
||||
**4e. Hardcoded Shadows & Animations**
|
||||
- `.shadow(radius: 4)` → `.themeShadow(.subtle)` (or equivalent modifier)
|
||||
- `.animation(.easeInOut(duration: 0.3))` → `.animation(AppTheme.Animation.standard)` (or equivalent)
|
||||
|
||||
**Build and verify after each batch of 8-10 files in Tier 4.**
|
||||
|
||||
---
|
||||
|
||||
### Tier 5: Structural Improvements (Large Files & @Query Migration)
|
||||
|
||||
**This tier involves the most complex changes. One file at a time, build after each.**
|
||||
|
||||
**5a. Oversized Files (>800 lines for views, >1000 for services)**
|
||||
For each oversized file from the audit:
|
||||
- Identify logical sections that can be extracted into separate files
|
||||
- Create new files with extracted views/helpers
|
||||
- Use `@MainActor enum HelperName` with static methods for extracted logic
|
||||
- Use separate `struct SubViewName: View` for extracted views
|
||||
- Keep the parent file as the coordinator, passing state via bindings and callbacks
|
||||
- Follow the state ownership matrix from `09-performance.md`:
|
||||
- Component owns: pagination, view preferences, selection, loading state
|
||||
- Parent owns: navigation, global search, preview state
|
||||
- Pass as bindings: shared filters
|
||||
- Pass as callbacks: navigation actions
|
||||
|
||||
**5b. @Query → DataStoreActor Migration**
|
||||
This is an architectural change — only do if the project already has a DataStoreActor pattern established:
|
||||
- Replace `@Query var items: [Entity]` with a DataStoreActor-based fetch
|
||||
- Add `.task { await loadItems() }` to trigger the fetch
|
||||
- Store results in `@State private var items: [Entity] = []`
|
||||
- If the project doesn't have DataStoreActor infrastructure, skip this and note it
|
||||
|
||||
**Build and verify after each file in Tier 5.**
|
||||
|
||||
---
|
||||
|
||||
### Final Verification
|
||||
|
||||
After all tiers are complete:
|
||||
1. Run a full build: `xcodebuild -scheme {SCHEME} -destination 'platform=macOS' build`
|
||||
2. Run tests if available: `xcodebuild test -scheme {SCHEME} -destination 'platform=macOS'`
|
||||
3. Re-run the audit searches from the original audit to get updated violation counts
|
||||
4. Update the project's instruction file — change the "Known Audit Gaps" table with new counts (hopefully zeros or much lower)
|
||||
5. Report a before/after comparison table
|
||||
|
||||
---
|
||||
|
||||
### Rules
|
||||
|
||||
- **Always build between tiers** — do not proceed if the build is broken
|
||||
- **Never change test files** unless they have print() statements (Tier 2a only)
|
||||
- **Never change #Preview blocks**
|
||||
- **Never change files in build/, .build/, SourcePackages/, Pods/**
|
||||
- **Preserve existing behavior** — these are refactors, not feature changes
|
||||
- **When uncertain about a change, skip it** and note it in the report rather than risk breaking something
|
||||
- **Add imports** (`import os`, `import OSLog`) only when needed for Logger
|
||||
- **Do not refactor surrounding code** — only fix the specific violation pattern
|
||||
- **If a file has multiple violation types, fix them all in one pass** to avoid repeated reads/writes
|
||||
- **Keep changes minimal** — the goal is compliance, not improvement
|
||||
@@ -0,0 +1,65 @@
|
||||
# macOS App Suite — Centralized Standards
|
||||
|
||||
**Version**: 1.0
|
||||
**Last Updated**: 2026-03-26
|
||||
**Applies To**: InControl, ShabuBox, Threader, Modeler, and all future macOS apps in the suite
|
||||
|
||||
---
|
||||
|
||||
## Standards Files
|
||||
|
||||
| # | File | Description |
|
||||
|---|------|-------------|
|
||||
| 01 | [architecture.md](01-architecture.md) | MVVM-F pattern, AppCoordinator, directory layout (single-platform and multiplatform) |
|
||||
| 02 | [swiftdata.md](02-swiftdata.md) | SwiftData persistence, schema versioning, Skeleton Records, query patterns, data modeling conventions |
|
||||
| 03 | [storage-and-sandboxing.md](03-storage-and-sandboxing.md) | iCloud storage, NSFileCoordinator, TOCTOU prevention, sandboxing, path security |
|
||||
| 04 | [swift-conventions.md](04-swift-conventions.md) | Swift 6 concurrency, os.Logger standard, error handling, file size limits, anti-patterns |
|
||||
| 05 | [design-system.md](05-design-system.md) | Visual design principles, material tiers, typography, spacing, layout, components, checklists |
|
||||
| 06 | [editor-patterns.md](06-editor-patterns.md) | Tabbed editor architecture, form components, Quick Entry, completion checklists |
|
||||
| 07 | [ai-integration.md](07-ai-integration.md) | Native-first AI, LLM protocol layer, pipeline architecture, prompt injection defense |
|
||||
| 08 | [data-integrity.md](08-data-integrity.md) | Backup/restore parity, migration rollback contracts, CloudKit sync safety |
|
||||
| 09 | [performance.md](09-performance.md) | Component extraction pattern, state ownership, view complexity, memory management |
|
||||
| 10 | [testing.md](10-testing.md) | Swift Testing framework, protocol mocking, timing rules, cancellation testing |
|
||||
| 11 | [multiplatform.md](11-multiplatform.md) | iOS companion apps, shared libraries, CloudKit sync, platform-adaptive UI |
|
||||
| -- | [AUDIT_CHECKLIST.md](AUDIT_CHECKLIST.md) | Per-project audit template (~70 checkboxes) for gap detection |
|
||||
|
||||
---
|
||||
|
||||
## Project Conformance Matrix
|
||||
|
||||
| Standard | InControl | ShabuBox | Threader | Modeler |
|
||||
|----------|:---------:|:--------:|:--------:|:-------:|
|
||||
| 01 Architecture | Good | Good | Good | Good |
|
||||
| 02 SwiftData | Good | Partial | Good | Basic |
|
||||
| 03 Storage/Sandboxing | Partial | Best | Good | N/A |
|
||||
| 04 Swift Conventions | Partial | Good | Best | Good |
|
||||
| 05 Design System | Best (principles) | Best (tokens) | Minimal | Minimal |
|
||||
| 06 Editor Patterns | Best | Gap | Gap | Gap |
|
||||
| 07 AI Integration | Good | Best | Good | Different |
|
||||
| 08 Data Integrity | Gap | Best | Gap | Gap |
|
||||
| 09 Performance | Gap | Best | Gap | Gap |
|
||||
| 10 Testing | Basic | Good | Implicit | Basic |
|
||||
| 11 Multiplatform | Ready | Best | Partial | N/A |
|
||||
|
||||
**Legend**: Best = reference implementation | Good = compliant | Partial = some coverage | Gap = not documented | N/A = not applicable
|
||||
|
||||
---
|
||||
|
||||
## How to Use These Standards
|
||||
|
||||
### For New Projects
|
||||
Add this to your project's `CLAUDE.md`:
|
||||
```markdown
|
||||
## Standards
|
||||
This project follows the centralized standards at `/Users/awizemann/Developer/standards/`.
|
||||
See INDEX.md for the full list. Project-specific details below.
|
||||
```
|
||||
|
||||
### For Existing Projects
|
||||
1. Run the audit checklist against your project
|
||||
2. Identify gaps
|
||||
3. Update project CLAUDE.md to reference standards and remove duplicated rules
|
||||
4. Keep only project-specific content (PRD, screen layouts, key file paths, critical lessons)
|
||||
|
||||
### Backups
|
||||
Pre-edit snapshots of all modified project files are stored in `backups/{ProjectName}/{YYYY-MM-DD}/`.
|
||||
Reference in New Issue
Block a user