- name
- hermespet-macos-ai-companion
- description
- Build, configure, and extend HermesPet — a native macOS AI companion living in the Dynamic Island with multi-engine support, desktop pets, and advanced system integration
- triggers
- ["how do I build HermesPet from source","configure HermesPet AI engines","add a new AI provider to HermesPet","customize HermesPet desktop pet animations","integrate voice recognition in HermesPet","troubleshoot HermesPet Dynamic Island display","extend HermesPet with new AI capabilities","debug HermesPet memory and context system"]
# HermesPet macOS AI Companion Skill
> Skill by [ara.so](https://ara.so) — Hermes Skills collection.
## What is HermesPet?
HermesPet is a native macOS application (macOS 14+) built with Swift 6 and SwiftUI that places an AI companion in your MacBook's Dynamic Island. It supports **5 parallel AI engines** (DeepSeek, Kimi, MiniMax, OpenAI, Claude Code, Codex, OpenClaw, custom gateways), **5 pixel desktop pets**, voice input, file drag-and-drop, multi-conversation context sharing, and local memory tracking.
**Key Architecture:**
- Pure native Swift 6 (no Electron/WebView)
- SwiftUI-based UI with Dynamic Island integration
- Local SQLite for conversation/memory storage
- SFSpeechRecognizer for offline voice recognition
- Multi-threaded AI engine orchestration (up to 8 simultaneous conversations)
- Embedded opencode runtime for cloud AI (zero external dependencies)
- Apache 2.0 licensed
## Project Structure
```
HermesPet/
├── HermesPet/ # Main app target
│ ├── Models/ # Data models (Conversation, Message, AIEngine)
│ ├── Views/ # SwiftUI views
│ │ ├── DynamicIslandView.swift
│ │ ├── ChatWindowView.swift
│ │ ├── DesktopPetView.swift
│ │ └── SettingsView.swift
│ ├── Managers/ # Core business logic
│ │ ├── AIEngineManager.swift
│ │ ├── ConversationManager.swift
│ │ ├── VoiceManager.swift
│ │ ├── MemoryManager.swift
│ │ └── UpdateManager.swift
│ ├── Services/ # AI provider integrations
│ │ ├── CloudAIService.swift
│ │ ├── ClaudeCodeService.swift
│ │ ├── CodexService.swift
│ │ ├── OpenClawService.swift
│ │ └── HermesGatewayService.swift
│ └── Utils/ # Helpers (FileDropHandler, MarkdownRenderer)
└── docs/ # Assets and documentation
```
## Building from Source
### Prerequisites
```bash
# Requires Xcode 15.0+ (for Swift 6)
xcode-select --install
# Optional CLI tools (auto-detected by app)
# Claude Code
brew install anthropic-cli
# Codex (if available)
npm install -g @openai/codex-cli
# OpenClaw
npm install -g openclaw
```
### Build Steps
```bash
# Clone repository
git clone https://github.com/basionwang-bot/HermesPet.git
cd HermesPet
# Open in Xcode
open HermesPet.xcodeproj
# Or build from command line
xcodebuild -scheme HermesPet -configuration Release build
# Create DMG for distribution
# (Uses create-dmg script in scripts/)
./scripts/create-dmg.sh
```
**Code Signing Configuration:**
```swift
// HermesPet.xcodeproj settings
PRODUCT_BUNDLE_IDENTIFIER = "cc.hermespet.HermesPet"
DEVELOPMENT_TEAM = "R34KL4X4D9" // Official Team ID
CODE_SIGN_IDENTITY = "Apple Development"
ENABLE_HARDENED_RUNTIME = YES
MACOS_DEPLOYMENT_TARGET = 14.0
```
## Key Components & Extension Points
### 1. Adding a New AI Engine
Create a new service conforming to `AIServiceProtocol`:
```swift
// Services/CustomAIService.swift
import Foundation
protocol AIServiceProtocol {
var engineType: AIEngineType { get }
func sendMessage(_ message: String, context: [Message]) async throws -> String
func streamMessage(_ message: String, context: [Message]) async throws -> AsyncThrowingStream<String, Error>
func isAvailable() -> Bool
}
class CustomAIService: AIServiceProtocol {
var engineType: AIEngineType { .custom }
private let baseURL: String
private let apiKey: String
init(baseURL: String, apiKey: String) {
self.baseURL = baseURL
self.apiKey = apiKey
}
func isAvailable() -> Bool {
// Check if API key is configured and endpoint is reachable
return !apiKey.isEmpty && checkEndpointHealth()
}
func sendMessage(_ message: String, context: [Message]) async throws -> String {
let request = buildRequest(message: message, context: context)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw AIServiceError.invalidResponse
}
let result = try JSONDecoder().decode(CustomAIResponse.self, from: data)
return result.content
}
func streamMessage(_ message: String, context: [Message]) async throws -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
let request = buildStreamRequest(message: message, context: context)
let (bytes, _) = try await URLSession.shared.bytes(for: request)
for try await line in bytes.lines {
if line.hasPrefix("data: ") {
let json = String(line.dropFirst(6))
if let chunk = parseChunk(json) {
continuation.yield(chunk)
}
}
}
continuation.finish()
}
}
}
private func buildRequest(message: String, context: [Message]) -> URLRequest {
var request = URLRequest(url: URL(string: "\(baseURL)/v1/chat/completions")!)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let messages = context.map { ["role": $0.role, "content": $0.content] }
let body: [String: Any] = [
"model": "custom-model",
"messages": messages + [["role": "user", "content": message]],
"temperature": 0.7
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
return request
}
private func checkEndpointHealth() -> Bool {
// Implement health check
return true
}
}
```
Register in `AIEngineManager`:
```swift
// Managers/AIEngineManager.swift
class AIEngineManager: ObservableObject {
@Published var availableEngines: [AIEngineType] = []
private var services: [AIEngineType: AIServiceProtocol] = [:]
func detectAvailableEngines() {
services = [:]
// Cloud AI (always available with embedded runtime)
if let cloudService = CloudAIService() {
services[.cloudAI] = cloudService
availableEngines.append(.cloudAI)
}
// Custom gateway
if let customAPIKey = ProcessInfo.processInfo.environment["CUSTOM_AI_API_KEY"],
let baseURL = ProcessInfo.processInfo.environment["CUSTOM_AI_BASE_URL"] {
let customService = CustomAIService(baseURL: baseURL, apiKey: customAPIKey)
if customService.isAvailable() {
services[.custom] = customService
availableEngines.append(.custom)
}
}
// Claude Code (check CLI)
if checkCLIAvailable("claude") {
services[.claudeCode] = ClaudeCodeService()
availableEngines.append(.claudeCode)
}
// ... other engines
}
private func checkCLIAvailable(_ command: String) -> Bool {
let process = Process()
process.launchPath = "/usr/bin/which"
process.arguments = [command]
process.launch()
process.waitUntilExit()
return process.terminationStatus == 0
}
}
```
### 2. Creating Custom Desktop Pet Animations
Desktop pets are SwiftUI views with state-driven animations:
```swift
// Views/Pets/CustomPetView.swift
import SwiftUI
struct CustomPetView: View {
@State private var position: CGPoint
@State private var animationPhase: PetAnimationPhase = .idle
@State private var isSniffing = false
enum PetAnimationPhase {
case idle, walking, sniffing, eating, celebrating
}
var body: some View {
ZStack {
// Base sprite (16x16 pixel art)
Image(spriteName)
.interpolation(.none)
.resizable()
.frame(width: 48, height: 48)
// Overlay effects (sniff particles, etc.)
if isSniffing {
sniffParticles
}
}
.position(position)
.onAppear { startIdleAnimation() }
}
private var spriteName: String {
switch animationPhase {
case .idle: return "custom-pet-idle-\(idleFrame)"
case .walking: return "custom-pet-walk-\(walkFrame)"
case .sniffing: return "custom-pet-sniff"
case .eating: return "custom-pet-eat-\(eatFrame)"
case .celebrating: return "custom-pet-celebrate"
}
}
private var sniffParticles: some View {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(Color.white.opacity(0.6))
.frame(width: 4, height: 4)
.offset(x: CGFloat(i * 8) - 8, y: -10)
.animation(
.easeInOut(duration: 0.8)
.repeatForever()
.delay(Double(i) * 0.2),
value: isSniffing
)
}
}
func sniff(at location: CGPoint) {
withAnimation(.spring(response: 0.3)) {
position = location
animationPhase = .sniffing
isSniffing = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
isSniffing = false
animationPhase = .idle
}
}
}
func eat(file: URL) {
animationPhase = .eating
// Trigger eating animation cycle
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
animationPhase = .idle
}
}
private func startIdleAnimation() {
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
if animationPhase == .idle {
idleFrame = (idleFrame + 1) % 4
}
}
}
@State private var idleFrame = 0
@State private var walkFrame = 0
@State private var eatFrame = 0
}
```
Register pet in `DesktopPetManager`:
```swift
// Managers/DesktopPetManager.swift
class DesktopPetManager: ObservableObject {
@Published var currentPet: AnyView?
func setPet(for engine: AIEngineType) {
switch engine {
case .cloudAI:
currentPet = AnyView(CloudPetView())
case .claudeCode:
currentPet = AnyView(ClawdPetView())
case .custom:
currentPet = AnyView(CustomPetView(position: initialPosition))
// ... other pets
default:
currentPet = nil
}
}
}
```
### 3. Extending Memory System
The memory system tracks user interactions locally:
```swift
// Managers/MemoryManager.swift
import Foundation
import SQLite3
class MemoryManager {
private var db: OpaquePointer?
private let sensitiveKeywords = ["password", "salary", "contract", ".env", "secret"]
init() {
openDatabase()
createTables()
}
private func openDatabase() {
let fileURL = try! FileManager.default
.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appendingPathComponent("HermesPet/memory.sqlite")
if sqlite3_open(fileURL.path, &db) != SQLITE_OK {
print("Failed to open database")
}
}
private func createTables() {
let createIntentTable = """
CREATE TABLE IF NOT EXISTS user_intents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
app_name TEXT,
file_path TEXT,
query TEXT,
ai_engine TEXT,
response_summary TEXT
)
"""
executeSQL(createIntentTable)
}
func recordIntent(app: String?, filePath: String?, query: String, engine: AIEngineType, response: String) {
// Blacklist check
if let path = filePath, containsSensitiveKeyword(path) { return }
if containsSensitiveKeyword(query) { return }
let summary = summarizeResponse(response) // First 200 chars
let sql = """
INSERT INTO user_intents (timestamp, app_name, file_path, query, ai_engine, response_summary)
VALUES (?, ?, ?, ?, ?, ?)
"""
var stmt: OpaquePointer?
if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK {
sqlite3_bind_int64(stmt, 1, Int64(Date().timeIntervalSince1970))
sqlite3_bind_text(stmt, 2, app, -1, nil)
sqlite3_bind_text(stmt, 3, filePath, -1, nil)
sqlite3_bind_text(stmt, 4, query, -1, nil)
sqlite3_bind_text(stmt, 5, engine.rawValue, -1, nil)
sqlite3_bind_text(stmt, 6, summary, -1, nil)
if sqlite3_step(stmt) != SQLITE_DONE {
print("Failed to insert intent")
}
}
sqlite3_finalize(stmt)
}
func getDailySummary(for date: Date) -> String {
let startOfDay = Calendar.current.startOfDay(for: date)
let endOfDay = startOfDay.addingTimeInterval(86400)
let sql = """
SELECT app_name, file_path, query, response_summary
FROM user_intents
WHERE timestamp >= ? AND timestamp < ?
ORDER BY timestamp ASC
"""
var intents: [(app: String?, file: String?, query: String, response: String)] = []
var stmt: OpaquePointer?
if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK {
sqlite3_bind_int64(stmt, 1, Int64(startOfDay.timeIntervalSince1970))
sqlite3_bind_int64(stmt, 2, Int64(endOfDay.timeIntervalSince1970))
while sqlite3_step(stmt) == SQLITE_ROW {
let app = sqlite3_column_text(stmt, 0).map { String(cString: $0) }
let file = sqlite3_column_text(stmt, 1).map { String(cString: $0) }
let query = String(cString: sqlite3_column_text(stmt, 2))
let response = String(cString: sqlite3_column_text(stmt, 3))
intents.append((app, file, query, response))
}
}
sqlite3_finalize(stmt)
return generateSummaryPrompt(from: intents)
}
private func containsSensitiveKeyword(_ text: String) -> Bool {
let lowercase = text.lowercased()
return sensitiveKeywords.contains { lowercase.contains($0) }
}
private func generateSummaryPrompt(from intents: [(app: String?, file: String?, query: String, response: String)]) -> String {
var prompt = "Based on yesterday's activity:\n\n"
for intent in intents {
if let app = intent.app { prompt += "- Used \(app)\n" }
if let file = intent.file { prompt += "- Worked on \(file)\n" }
prompt += "- Asked: \(intent.query)\n"
}
prompt += "\nGenerate a brief daily summary in Markdown and suggest 1-2 follow-up actions."
return prompt
}
عرض على GitHub