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:
Alan Wizemann
2026-03-31 02:30:04 -04:00
commit 18278a3357
67 changed files with 7076 additions and 0 deletions
+111
View File
@@ -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
```
+183
View File
@@ -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.
+97
View File
@@ -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
+604
View File
@@ -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
}
}
+37
View File
@@ -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"
}
}
+121
View File
@@ -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"
}
}
}
+15
View File
@@ -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?
}
+31
View File
@@ -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")
}
}
+89
View File
@@ -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")
}
}
}
+17
View File
@@ -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.
}
}
+41
View File
@@ -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 its 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)
}
}
+232
View File
@@ -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.
+328
View File
@@ -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
+150
View File
@@ -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 |
+508
View File
@@ -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/)
+255
View File
@@ -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.
+164
View File
@@ -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
+87
View File
@@ -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
+159
View File
@@ -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.
+140
View File
@@ -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.
+274
View File
@@ -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.
+166
View File
@@ -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
+210
View File
@@ -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
+65
View File
@@ -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}/`.