| 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 — 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
xcode-select --install
brew install anthropic-cli
npm install -g @openai/codex-cli
npm install -g openclaw
Build Steps
git clone https://github.com/basionwang-bot/HermesPet.git
cd HermesPet
open HermesPet.xcodeproj
xcodebuild -scheme HermesPet -configuration Release build
./scripts/create-dmg.sh
Code Signing Configuration:
PRODUCT_BUNDLE_IDENTIFIER = "cc.hermespet.HermesPet"
DEVELOPMENT_TEAM = "R34KL4X4D9"
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:
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 () -> {
apiKey.isEmpty checkEndpointHealth()
}
( : , : []) -> {
request buildRequest(message: message, context: context)
(data, response) .shared.data(for: request)
httpResponse response ,
().contains(httpResponse.statusCode) {
.invalidResponse
}
result ().decode(., from: data)
result.content
}
( : , : []) -> <, > {
{ continuation
{
request buildStreamRequest(message: message, context: context)
(bytes, ) .shared.bytes(for: request)
line bytes.lines {
line.hasPrefix() {
json (line.dropFirst())
chunk parseChunk(json) {
continuation.yield(chunk)
}
}
}
continuation.finish()
}
}
}
(: , : []) -> {
request (url: (string: ))
request.httpMethod
request.setValue(, forHTTPHeaderField: )
request.setValue(, forHTTPHeaderField: )
messages context.map { [: .role, : .content] }
body: [: ] [
: ,
: messages [[: , : message]],
:
]
request.httpBody .data(withJSONObject: body)
request
}
() -> {
}
}
Register in AIEngineManager:
class AIEngineManager: ObservableObject {
@Published var availableEngines: [AIEngineType] = []
private var services: [AIEngineType: AIServiceProtocol] = [:]
func detectAvailableEngines() {
services = [:]
if let cloudService = CloudAIService() {
services[.cloudAI] = cloudService
availableEngines.append(.cloudAI)
}
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)
}
}
if checkCLIAvailable("claude") {
services[.claudeCode] = ClaudeCodeService()
availableEngines.append(.claudeCode)
}
}
( : ) -> {
process ()
process.launchPath
process.arguments [command]
process.launch()
process.waitUntilExit()
process.terminationStatus
}
}
2. Creating Custom Desktop Pet Animations
Desktop pets are SwiftUI views with state-driven animations:
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 {
Image(spriteName)
.interpolation(.none)
.resizable()
.frame(width: 48, height: 48)
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:
.eating:
.celebrating:
}
}
sniffParticles: {
(, id: \.) { i
()
.fill(.white.opacity())
.frame(width: , height: )
.offset(x: (i ) , y: )
.animation(
.easeInOut(duration: )
.repeatForever()
.delay((i) ),
value: isSniffing
)
}
}
( : ) {
withAnimation(.spring(response: )) {
position location
animationPhase .sniffing
isSniffing
}
.main.asyncAfter(deadline: .now() ) {
withAnimation {
isSniffing
animationPhase .idle
}
}
}
(: ) {
animationPhase .eating
.main.asyncAfter(deadline: .now() ) {
animationPhase .idle
}
}
() {
.scheduledTimer(withTimeInterval: , repeats: ) {
animationPhase .idle {
idleFrame (idleFrame )
}
}
}
idleFrame
walkFrame
eatFrame
}
Register pet in DesktopPetManager:
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))
default:
currentPet = nil
}
}
}
3. Extending Memory System
The memory system tracks user interactions locally:
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: ?, : ?, : , : , : ) {
path filePath, containsSensitiveKeyword(path) { }
containsSensitiveKeyword(query) { }
summary summarizeResponse(response)
sql
stmt: ?
sqlite3_prepare_v2(db, sql, , stmt, ) {
sqlite3_bind_int64(stmt, , (().timeIntervalSince1970))
sqlite3_bind_text(stmt, , app, , )
sqlite3_bind_text(stmt, , filePath, , )
sqlite3_bind_text(stmt, , query, , )
sqlite3_bind_text(stmt, , engine.rawValue, , )
sqlite3_bind_text(stmt, , summary, , )
sqlite3_step(stmt) {
()
}
}
sqlite3_finalize(stmt)
}
( : ) -> {
startOfDay .current.startOfDay(for: date)
endOfDay startOfDay.addingTimeInterval()
sql
intents: [(app: ?, file: ?, query: , response: )] []
stmt: ?
sqlite3_prepare_v2(db, sql, , stmt, ) {
sqlite3_bind_int64(stmt, , (startOfDay.timeIntervalSince1970))
sqlite3_bind_int64(stmt, , (endOfDay.timeIntervalSince1970))
sqlite3_step(stmt) {
app sqlite3_column_text(stmt, ).map { (cString: ) }
file sqlite3_column_text(stmt, ).map { (cString: ) }
query (cString: sqlite3_column_text(stmt, ))
response (cString: sqlite3_column_text(stmt, ))
intents.append((app, file, query, response))
}
}
sqlite3_finalize(stmt)
generateSummaryPrompt(from: intents)
}
( : ) -> {
lowercase text.lowercased()
sensitiveKeywords.contains { lowercase.contains() }
}
( : [(app: ?, file: ?, query: , response: )]) -> {
prompt
intent intents {
app intent.app { prompt }
file intent.file { prompt }
prompt
}
prompt
prompt
}
() -> ? {
sql
intents: [[: ]] []
stmt: ?
sqlite3_prepare_v2(db, sql, , stmt, ) {
sqlite3_step(stmt) {
intent: [: ] [:]
i sqlite3_column_count(stmt) {
name (cString: sqlite3_column_name(stmt, i))
text sqlite3_column_text(stmt, i) {
intent[name] (cString: text)
}
}
intents.append(intent)
}
}
sqlite3_finalize(stmt)
.data(withJSONObject: intents, options: .prettyPrinted)
}
}
4. Integrating Voice Recognition
Voice input uses SFSpeechRecognizer for offline Chinese/English:
import Speech
import AVFoundation
class VoiceManager: NSObject, ObservableObject {
@Published var isRecording = false
@Published var transcription = ""
@Published var permissionGranted = false
private let speechRecognizer: SFSpeechRecognizer?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private let audioEngine = AVAudioEngine()
override init() {
let locale = Locale.current.language.languageCode?.identifier ?? "zh-CN"
self.speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: locale))
super.init()
requestPermission()
}
func requestPermission() {
.requestAuthorization { status
.main.async {
.permissionGranted (status .authorized)
}
}
}
() {
permissionGranted {
.permissionDenied
}
recognitionTask.cancel()
recognitionTask
audioSession .sharedInstance()
audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
audioSession.setActive(, options: .notifyOthersOnDeactivation)
recognitionRequest ()
recognitionRequest recognitionRequest {
.recognitionUnavailable
}
recognitionRequest.shouldReportPartialResults
inputNode audioEngine.inputNode
recognitionTask speechRecognizer.recognitionTask(with: recognitionRequest) { [ ] result, error
result result {
.main.async {
.transcription result.bestTranscription.formattedString
}
}
error result.isFinal {
.stopRecording()
}
}
recordingFormat inputNode.outputFormat(forBus: )
inputNode.installTap(onBus: , bufferSize: , format: recordingFormat) { buffer,
recognitionRequest.append(buffer)
}
audioEngine.prepare()
audioEngine.start()
.main.async {
.isRecording
}
}
() {
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: )
recognitionRequest.endAudio()
.main.async {
.isRecording
}
}
}
: {
permissionDenied
recognitionUnavailable
}
Trigger via global hotkey (⌘⇧V):
import Carbon
class AppDelegate: NSObject, NSApplicationDelegate {
var hotKeyRef: EventHotKeyRef?
let voiceManager = VoiceManager()
func applicationDidFinishLaunching(_ notification: Notification) {
registerPushToTalkHotkey()
}
private func registerPushToTalkHotkey() {
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
InstallEventHandler(GetApplicationEventTarget(), { _, event, userData in
let manager = Unmanaged<VoiceManager>.fromOpaque(userData!).takeUnretainedValue()
try? manager.startRecording()
return noErr
}, 1, &eventType, Unmanaged.passUnretained(voiceManager).toOpaque(), nil)
let hotKeyID = EventHotKeyID(signature: 0x48505054, id: 1)
RegisterEventHotKey((kVK_ANSI_V), (cmdKey shiftKey), hotKeyID, (), , hotKeyRef)
}
}
5. Dynamic Island Integration
The Dynamic Island view responds to app state:
import SwiftUI
struct DynamicIslandView: View {
@ObservedObject var conversationManager: ConversationManager
@ObservedObject var engineManager: AIEngineManager
@State private var isExpanded = false
@State private var showCompletionCheckmark = false
var body: some View {
HStack(spacing: 12) {
currentPetSprite
.frame(width: 24, height: 24)
Spacer()
statusIndicator
.frame(width: 24, height: 24)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
Capsule()
.fill(isExpanded ? Color.black.opacity(0.9) : Color.clear)
.overlay(
Capsule()
.stroke(engineManager.currentEngine.accentColor, linewidth: isExpanded ? 2 : 0)
)
)
.frame(width: isExpanded ? : , height: )
.position(x: .main.frame.width , y: )
.animation(.spring(response: , dampingFraction: ), value: isExpanded)
.onHover { hovering
isExpanded hovering
}
.onChange(of: conversationManager.isProcessing) { processing
processing {
showCompletionAnimation()
}
}
}
currentPetSprite: {
(engineManager.currentEngine.petSpriteName)
.interpolation(.none)
.resizable()
.aspectRatio(contentMode: .fit)
}
statusIndicator: {
{
conversationManager.isProcessing {
()
.trim(from: , to: )
.stroke(engineManager.currentEngine.accentColor, lineWidth: )
.rotationEffect(.degrees(rotationAngle))
.onAppear {
withAnimation(.linear(duration: ).repeatForever(autoreverses: )) {
rotationAngle
}
}
} showCompletionCheckmark {
{ path
path.move(to: (x: , y: ))
path.addLine(to: (x: , y: ))
path.addLine(to: (x: , y: ))
}
.trim(from: , to: checkmarkProgress)
.stroke(.green, style: (lineWidth: , lineCap: .round, lineJoin: .round))
.onAppear {
withAnimation(.easeInOut(duration: )) {
checkmarkProgress
}
}
}
}
}
rotationAngle:
checkmarkProgress:
() {
showCompletionCheckmark
checkmarkProgress
.main.asyncAfter(deadline: .now() ) {
withAnimation {
showCompletionCheckmark
}
}
}
}
Configuration
Environment Variables
export DEEPSEEK_API_KEY="sk-..."
export KIMI_API_KEY="sk-..."
export MINIMAX_API_KEY="..."
export OPENAI_API_KEY="sk-..."
export HERMES_GATEWAY_BASE_URL="https://your-gateway.com/v1"
export HERMES_GATEWAY_API_KEY="..."
export HERMESPET_DISABLE_CLAUDE_CODE=1
export HERMESPET_DISABLE_CODEX=1
export HERMESPET_DISABLE_MEMORY=1
App Settings (Settings.bundle)
<dict>
<key>PreferenceSpecifiers</key>
<array>
<dict>
<key>Type</key>
<string>PSToggleSwitchSpecifier</string>
<key>Title</key>
<string>Enable Memory Tracking</string>
<key>Key</key>
<string>memory_enabled</string>
<key>DefaultValue</key>
<true/>
</dict>
<dict>
<key>Type</key>
<string>PSMultiValueSpecifier</string>
<key>Title</key>
<string>Default AI Engine</>
Key
default_engine
Values
cloudAI
claudeCode
codex
DefaultValue
cloudAI
Common Patterns
Sending a Message with Context
let conversationManager = ConversationManager()
let conversation = conversationManager.createConversation(engine: .cloudAI)
Task {
do {
let response = try await conversationManager.sendMessage(
"Explain Swift concurrency",
to: conversation,
attachments: [URL(fileURLWithPath: "/path/to/code.swift")]
)
print("AI Response: \(response)")
} catch {
print("Error: \(error)")
}
}
Streaming Response
Task {
let stream = try await conversationManager.streamMessage(
"Write a SwiftUI animation",
to: conversation
)
for try await chunk in stream {
print(chunk, terminator: "")
}
}
File Drop Handling
.onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in
providers.forEach { provider in
_ = provider.loadObject(ofClass: URL.self) { url, error in
guard let url = url else { return }
DispatchQueue.main.async {
conversationManager.addAttachment(url, to: currentConversation)
}
}
}
return true
}
Tool Permission Confirmation
class ToolPermissionManager: ObservableObject {
@Published var pendingRequest: ToolRequest?
func requestPermission(for tool: ToolRequest, completion: @escaping (Bool) -> Void) {
DispatchQueue.main.async {
self.pendingRequest = tool
self.permissionCallback = completion
}
}