Add a macOS menu bar companion that bundles and governs the server.

The accessory app ships the ai-memory binary and hooks tree, starts the
existing LaunchAgent, and opens /web, status, config, and logs. Durable
data stays in Application Support so replacing the .app is an update.
This commit is contained in:
Thiago Macedo
2026-09-20 21:25:16 +02:00
parent 1fe32bc2be
commit eadab8e4bd
33 changed files with 2271 additions and 15 deletions
+22
View File
@@ -0,0 +1,22 @@
name: macos-app
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
permissions:
contents: read
jobs:
swift-test:
name: swift test (ai-memory-macos)
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.pull_request.labels.*.name, 'macos') ||
contains(github.event.pull_request.labels.*.name, 'full-ci')
runs-on: macos-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Swift tests
run: swift test --package-path companions/ai-memory-macos
+3
View File
@@ -1,6 +1,9 @@
# Rust build artefacts.
/target/
/companions/*/target/
/companions/*/.build/
/companions/*/.swiftpm/
/companions/*/dist/
/dist/
**/*.rs.bk
**/*.rs.orig
+7
View File
@@ -197,6 +197,9 @@ evals/ live A/B harness; workspace member, not shipped.
companions/ai-memory-importer/ standalone OMC + external-conversation importer; NOT a root
workspace member — build/test it with
`--manifest-path companions/ai-memory-importer/Cargo.toml`.
companions/ai-memory-macos/ Swift menu bar wrapper; NOT a root workspace member —
`swift test --package-path companions/ai-memory-macos`
and `./companions/ai-memory-macos/build.sh`.
hooks/ per-agent lifecycle hook bundles (shell/native).
bin/ host wrapper scripts (`ai-memory`, `deploy`, `release`).
docker/ Dockerfile, compose files, TLS proxy templates.
@@ -272,6 +275,10 @@ no tiers.
`cargo test --manifest-path companions/ai-memory-importer/Cargo.toml`
(plus fmt/clippy on the same manifest). Root `--workspace` commands do
not cover it.
- Run the macOS menu bar companion separately:
`swift test --package-path companions/ai-memory-macos`
(plus `./companions/ai-memory-macos/build.sh` to stage `AI Memory.app`).
Root `--workspace` commands do not cover it.
### Platform notes
+10
View File
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- macOS menu bar companion (`companions/ai-memory-macos`) that bundles the
`ai-memory` binary and `hooks/` tree, governs the existing LaunchAgent, and
opens the built-in web UI, `ai-memory status`, `config.toml`, the data
directory, and logs. Durable memory stays in
`~/Library/Application Support/ai-memory`; replacing the `.app` does not
rewrite it. Documented as a README quick-start, an
[`install.md`](docs/install.md#macos-menu-bar-app) path, a cookbook
recipe, and [`docs/macos.md`](docs/macos.md) Scenario D.
### Security
- Bumped `rmcp` to 2.x (2.2.0), resolving three MCP transport advisories:
GHSA-9pj6-vhgr-3mwh (unauthenticated Streamable-HTTP session-table leak /
+35 -3
View File
@@ -186,6 +186,37 @@ System service installs use `/var/lib/ai-memory` and `/etc/ai-memory/` via the
packaged unit. Full user-service, system-service, auth, and provider setup is in
[`docs/install.md#arch-linux-native-packages-aur`](docs/install.md#arch-linux-native-packages-aur).
### macOS (menu bar app)
A self-contained `.app` that bundles the native `ai-memory` binary and
`hooks/` tree, starts the existing LaunchAgent, and opens `/web`,
`ai-memory status`, and `config.toml` from the menu bar. Wiki, SQLite,
config, and models stay in `~/Library/Application Support/ai-memory`, so
replacing the app is an update — it does not rewrite that tree.
Needs a Rust toolchain and Xcode / Swift 6 (same as a source build):
```bash
git clone https://github.com/akitaonrails/ai-memory
cd ai-memory
./companions/ai-memory-macos/build.sh
open "companions/ai-memory-macos/dist/AI Memory.app"
```
Drag **AI Memory.app** to `/Applications`, then **Install & Start Server**
from the menu extra (no Dock icon). When the status item is green, wire an
agent with the bundled binary:
```bash
BIN="/Applications/AI Memory.app/Contents/Resources/runtime/ai-memory"
"$BIN" install-mcp --client claude-code --apply
"$BIN" install-hooks --agent claude-code --apply
```
Prebuilt tarball and launchd-without-the-app paths:
[`docs/macos.md`](docs/macos.md). Companion details:
[`companions/ai-memory-macos`](companions/ai-memory-macos).
### Docker
You need: Docker or Podman + an agent CLI from the [Support Matrix](#support-matrix),
@@ -259,8 +290,9 @@ wrapper automatically uses Podman when Docker is not installed. Set
On Linux/macOS, that's it. Start a Claude Code session as usual - every
prompt and tool call now lands in ai-memory, and the next session you
open in this project will see a handoff with where you left off.
On macOS, the native release binary is also supported and recommended when you
do not need Docker; see [`docs/macos.md`](docs/macos.md).
On macOS the native binary is the recommended path when you do not need
Docker — either the [menu bar app](#macos-menu-bar-app) above or a
[release tarball / launchd agent](docs/macos.md).
Wiring another agent is the same two commands with a different name —
`--client codex`, `--agent codex`, and so on for every row of the support
@@ -386,7 +418,7 @@ diagram, crate breakdown, schema notes, and invariants.
| [`docs/agent-messaging.md`](docs/agent-messaging.md) | Cross-project agent-to-agent messaging: a directed, claim-once inbox/queue plus the on-start "you have mail" notice. |
| [`docs/marker-file.md`](docs/marker-file.md) | `.ai-memory.toml` workspace/project routing for multi-client trees, mono-repos, worktrees, and work/personal separation. |
| [`docs/auto-scope.md`](docs/auto-scope.md) | `[auto_scope]` modes for shared servers: default single-slot routing, session-aware isolation, and multi-user `per_actor` behavior. |
| [`docs/macos.md`](docs/macos.md) | macOS install paths: native release binary (recommended), source build, the Docker wrapper, and current limitations. |
| [`docs/macos.md`](docs/macos.md) | macOS install paths: menu bar app, native release tarball, source build, Docker wrapper, launchd, and current limitations. |
| [`docs/windows.md`](docs/windows.md) | Windows install modes: full WSL2, native Windows with Docker Desktop, prebuilt native release zip, native source builds, and caveats. |
| [`docs/mcp-install.md`](docs/mcp-install.md) | Per-client MCP and lifecycle notes, handoff-injection limits, and community bridge guidance. |
| [`docs/deploy.md`](docs/deploy.md) | Homelab deploy: bin/deploy, bearer-token auth, pointers to the TLS guide. |
+5
View File
@@ -0,0 +1,5 @@
.build/
.swiftpm/
dist/
*.xcodeproj/xcuserdata/
*.xcworkspace/xcuserdata/
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>AIMemoryMenu</string>
<key>CFBundleIdentifier</key>
<string>com.github.akitaonrails.ai-memory-menu</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>AI Memory</string>
<key>CFBundleDisplayName</key>
<string>AI Memory</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+28
View File
@@ -0,0 +1,28 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "AIMemoryMenu",
platforms: [.macOS(.v14)],
products: [
.executable(name: "AIMemoryMenu", targets: ["AIMemoryMenu"]),
],
targets: [
.target(
name: "AIMemoryMenuCore",
path: "Sources/AIMemoryMenuCore"
),
.executableTarget(
name: "AIMemoryMenu",
dependencies: ["AIMemoryMenuCore"],
path: "Sources/AIMemoryMenu"
),
.testTarget(
name: "AIMemoryMenuTests",
dependencies: ["AIMemoryMenuCore"],
path: "Tests/AIMemoryMenuTests",
resources: [.copy("fixtures")]
),
]
)
+59
View File
@@ -0,0 +1,59 @@
# AI Memory (macOS menu bar)
Self-contained macOS accessory app that **ships the `ai-memory` runtime**, **governs the LaunchAgent**, and **opens the surfaces the tool already has**. It is a wrapper, not a second operator console.
This companion is not a root Cargo workspace member. Durable memory stays in the user data directory so replacing the `.app` does not rewrite wiki, SQLite, config, models, or logs.
| In the `.app` (replaceable) | In the user data dir (survives updates) |
|---|---|
| Swift menu bar UI | wiki, SQLite, `config.toml` |
| `ai-memory` binary | hook spool, `auth.json`, capture-mode |
| bundled `hooks/` | downloaded embedding models |
| LaunchAgent template | rendered plist in `~/Library/LaunchAgents/` |
| | logs in `~/Library/Logs/ai-memory/` |
Data directory: `~/Library/Application Support/ai-memory` (the binary’s existing macOS default). Optional override in Settings writes `AI_MEMORY_DATA_DIR` into the LaunchAgent plist only.
This app does **not** replace `ai-memory status`, `/web`, or hand-editing `config.toml`. Those stay the real tools; the menu opens them.
## Build
From the repository root (needs a Rust toolchain and Xcode / Swift 6):
```bash
chmod +x companions/ai-memory-macos/build.sh
./companions/ai-memory-macos/build.sh
open "companions/ai-memory-macos/dist/AI Memory.app"
```
`build.sh` compiles `ai-memory` with Cargo, compiles the Swift menu extra, and stages:
```text
AI Memory.app/Contents/Resources/runtime/
ai-memory
hooks/
packaging/launchd/com.github.akitaonrails.ai-memory.plist
```
Drag the `.app` to `/Applications` for a stable LaunchAgent path. Notarization, Developer ID, and a Homebrew cask are out of this companion’s first version.
## Use
1. Open the app (menu bar extra; no Dock icon).
2. **Install & Start Server** — runs bundled `ai-memory init` if `config.toml` is missing, renders the existing launchd template, and `launchctl bootstrap`s `com.github.akitaonrails.ai-memory`.
3. The status item turns green when `GET /admin/status` succeeds.
4. **Open Web UI**, **Show Status…** (bundled `ai-memory status`), **Open Config**, **Open Data Directory**, **Open Logs**.
Updates: replace `/Applications/AI Memory.app`. The data dir is untouched. If the helper path inside the bundle changed, **Restart Server** re-renders the plist.
## Tests
```bash
swift test --package-path companions/ai-memory-macos
```
Root `cargo t` / `cargo tf` do not cover this package.
## Open in Xcode
Open `companions/ai-memory-macos/Package.swift`. `swift run` from the package directory will not include the staged runtime; use `build.sh` (or set `AI_MEMORY_MENU_RUNTIME` at the tarball-equivalent `runtime/` directory) to govern the service.
@@ -0,0 +1,32 @@
import AppKit
import SwiftUI
import AIMemoryMenuCore
@main
struct AIMemoryMenuApp: App {
@State private var model = AppModel()
init() {
NSApplication.shared.setActivationPolicy(.accessory)
}
var body: some Scene {
MenuBarExtra {
MenuBarView()
.environment(model)
} label: {
MenuBarLabel(icon: model.icon)
}
.menuBarExtraStyle(.menu)
Window("Status", id: "status") {
StatusOutputView()
.environment(model)
}
Settings {
SettingsView()
.environment(model)
}
}
}
@@ -0,0 +1,329 @@
import AppKit
import Foundation
import AIMemoryMenuCore
import Observation
@MainActor
@Observable
final class AppModel {
var settings: AppSettings
var report: StatusReport?
var launchd: LaunchdState = .notInstalled
var icon: IconState = .unknown
var lastError: String?
var statusOutput: String = ""
var isBusy = false
var runtimeMissing = false
var tokenConfigured = false
var lastHTTPStatus: Int?
@ObservationIgnored private var tokenStore: any TokenStore
@ObservationIgnored private var health = HealthClient()
@ObservationIgnored private var runner: any CommandRunning
@ObservationIgnored private var pollTask: Task<Void, Never>?
@ObservationIgnored private var fetchFailed = false
@ObservationIgnored private var startingDeadline: Date?
init(
settings: AppSettings = .load(),
tokenStore: any TokenStore = KeychainTokenStore(),
runner: any CommandRunning = ProcessRunner()
) {
self.settings = settings
self.tokenStore = tokenStore
self.runner = runner
self.tokenConfigured = tokenStore.read()?.isEmpty == false
startPolling()
}
var headlineVersion: String {
switch icon {
case .ok:
if let report {
return "Server running · v\(report.version)"
}
return "Server running"
case .degraded:
return "Server running · warnings"
case .authRequired:
return "Server running · auth required"
case .starting:
return "Server is starting…"
case .unreachable:
return launchd == .running ? "LaunchAgent up · server unreachable" : "Server down"
case .notInstalled:
return "LaunchAgent not installed"
case .unknown:
return "Checking server…"
}
}
var statisticLines: [String] {
guard let report else {
return []
}
return report.statisticLines
}
var showsServerStatus: Bool {
report != nil
}
var dataDir: URL {
settings.resolvedDataDir
}
var configURL: URL {
dataDir.appending(path: "config.toml")
}
var logURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appending(path: "Library/Logs/ai-memory/stderr.log")
}
func startPolling() {
guard pollTask == nil else { return }
pollTask = Task { [weak self] in
while let self, !Task.isCancelled {
await self.poll()
let interval: Duration = self.icon == .starting ? .seconds(1) : .seconds(15)
try? await Task.sleep(for: interval)
}
}
}
func poll() async {
let controller = launchdController()
launchd = controller.state()
lastHTTPStatus = nil
do {
report = try await health.fetch(baseURL: settings.serverURL, token: tokenStore.read())
fetchFailed = false
lastError = nil
} catch {
report = nil
fetchFailed = true
if let health = error as? HealthError, case let .http(code) = health {
lastHTTPStatus = code
fetchFailed = code != 401 && code != 403
}
lastError = (error as? HealthError).map(Self.describe) ?? error.localizedDescription
}
let derived = IconState.derived(
launchd: launchd,
report: report,
fetchFailed: fetchFailed,
httpStatus: lastHTTPStatus
)
let starting = startingDeadline.map { Date() < $0 } ?? false
icon = IconState.applyingStartGrace(derived, isStarting: starting)
if icon != .starting {
startingDeadline = nil
}
runtimeMissing = RuntimeLayout.resolve() == nil
tokenConfigured = tokenStore.read()?.isEmpty == false
}
func installAndStart() async {
markStarting()
await runBusy {
let runtime = try self.requireRuntime()
let controller = self.launchdController()
if FirstRun.needsInit(dataDir: self.dataDir) {
_ = try BundledCLI(runtime: runtime, runner: self.runner).initDataDir(self.dataDir)
}
let template = try String(contentsOf: runtime.plistTemplate, encoding: .utf8)
if controller.state() == .running {
try? controller.bootout()
}
try controller.writePlist(
template: template,
binary: runtime.binary,
dataDir: self.settings.dataDirOverride
)
try controller.bootstrap()
}
clearStartingIfFailed()
await poll()
}
func start() async {
markStarting()
await runBusy {
let runtime = try self.requireRuntime()
let controller = self.launchdController()
let template = try String(contentsOf: runtime.plistTemplate, encoding: .utf8)
try controller.writePlist(
template: template,
binary: runtime.binary,
dataDir: self.settings.dataDirOverride
)
try controller.bootstrap()
}
clearStartingIfFailed()
await poll()
}
func stop() async {
startingDeadline = nil
await runBusy {
try self.launchdController().bootout()
}
await poll()
}
func restart() async {
markStarting()
await runBusy {
let controller = self.launchdController()
let runtime = try self.requireRuntime()
let installed = (try? String(contentsOf: controller.plistDestination, encoding: .utf8)) ?? ""
if LaunchdController.programArgumentsBinary(inPlist: installed) != runtime.binary.path {
try? controller.bootout()
let template = try String(contentsOf: runtime.plistTemplate, encoding: .utf8)
try controller.writePlist(
template: template,
binary: runtime.binary,
dataDir: self.settings.dataDirOverride
)
try controller.bootstrap()
} else {
try controller.kickstart()
}
}
clearStartingIfFailed()
await poll()
}
func refreshStatusOutput() async {
await runBusy {
let runtime = try self.requireRuntime()
let result = try BundledCLI(runtime: runtime, runner: self.runner).status(
serverURL: self.settings.serverURL,
token: self.tokenStore.read(),
dataDir: self.settings.dataDirOverride
)
self.statusOutput = result.combinedOutput
}
}
func openWebUI() {
NSWorkspace.shared.open(HealthURL.webUI(from: settings.serverURL))
}
func openConfig() {
revealOrOpen(configURL)
}
func openDataDirectory() {
NSWorkspace.shared.open(dataDir)
}
func openLogs() {
revealOrOpen(logURL)
}
func saveSettings(
serverURLString: String,
dataDirOverride: String,
token: String?,
clearToken: Bool
) {
if let url = URL(string: serverURLString), url.scheme != nil {
settings.serverURL = url
}
let trimmed = dataDirOverride.trimmingCharacters(in: .whitespacesAndNewlines)
settings.dataDirOverride = trimmed.isEmpty ? nil : URL(fileURLWithPath: trimmed)
settings.save()
if clearToken {
try? tokenStore.clear()
} else if let token {
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
try? tokenStore.save(value)
}
}
tokenConfigured = tokenStore.read()?.isEmpty == false
Task { await poll() }
}
private func markStarting() {
startingDeadline = Date().addingTimeInterval(45)
icon = .starting
report = nil
lastError = nil
pollTask?.cancel()
pollTask = nil
startPolling()
}
private func clearStartingIfFailed() {
if lastError != nil {
startingDeadline = nil
}
}
private func launchdController() -> LaunchdController {
LaunchdController(home: FileManager.default.homeDirectoryForCurrentUser, runner: runner)
}
private func requireRuntime() throws -> RuntimeLayout {
guard let runtime = RuntimeLayout.resolve() else {
runtimeMissing = true
throw CliError.missingRuntime
}
return runtime
}
private func runBusy(_ work: () throws -> Void) async {
isBusy = true
defer { isBusy = false }
do {
try work()
lastError = nil
} catch {
lastError = error.localizedDescription
if let cli = error as? CliError, case .failed(_, let output) = cli {
statusOutput = output
lastError = output
}
}
}
private func revealOrOpen(_ url: URL) {
if FileManager.default.fileExists(atPath: url.path) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
NSWorkspace.shared.open(url.deletingLastPathComponent())
}
}
private static func describe(_ error: HealthError) -> String {
switch error {
case .badURL:
"Invalid server URL"
case .http(let code) where code == 401 || code == 403:
"Server is up but this app is not authorized (HTTP \(code)). Add a bearer in Settings."
case .http(let code):
"Server returned HTTP \(code)"
case .decode:
"Could not read /admin/status"
case .transport(let message):
message
}
}
}
extension CliError: LocalizedError {
public var errorDescription: String? {
switch self {
case .timeout:
"Timed out running ai-memory"
case .missingRuntime:
"Bundled ai-memory runtime is missing. Build with companions/ai-memory-macos/build.sh"
case .failed(_, let output):
output.isEmpty ? "ai-memory command failed" : output
}
}
}
@@ -0,0 +1,127 @@
import AppKit
import SwiftUI
import AIMemoryMenuCore
struct MenuBarView: View {
@Environment(AppModel.self) private var model
@Environment(\.openWindow) private var openWindow
var body: some View {
Text(model.headlineVersion)
.disabled(true)
.onAppear {
Task { await model.poll() }
}
ForEach(Array(model.statisticLines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.system(.body, design: .monospaced))
.disabled(true)
}
if model.runtimeMissing {
Text("Runtime not bundled — run build.sh")
.disabled(true)
}
Divider()
serviceButtons
Divider()
Button("Open Web UI") {
model.openWebUI()
}
if model.showsServerStatus {
Button("Show Status…") {
Task {
await model.refreshStatusOutput()
NSApp.activate(ignoringOtherApps: true)
openWindow(id: "status")
}
}
}
Button("Open Config") {
model.openConfig()
}
Button("Open Data Directory") {
model.openDataDirectory()
}
Button("Open Logs") {
model.openLogs()
}
Divider()
SettingsLink {
Text("Settings…")
}
Button("Quit") {
NSApp.terminate(nil)
}
}
@ViewBuilder
private var serviceButtons: some View {
switch model.launchd {
case .notInstalled:
Button("Install & Start Server") {
Task { await model.installAndStart() }
}
.disabled(model.isBusy || model.icon == .starting)
case .stopped:
Button("Start Server") {
Task { await model.start() }
}
.disabled(model.isBusy || model.icon == .starting)
case .running:
Button("Stop Server") {
Task { await model.stop() }
}
.disabled(model.isBusy)
Button("Restart Server") {
Task { await model.restart() }
}
.disabled(model.isBusy || model.icon == .starting)
}
}
}
struct MenuBarLabel: View {
var icon: IconState
var body: some View {
// Menu extras flatten SwiftUI tint to a template image, so a
// non-template NSImage is what actually changes with server state.
Image(nsImage: StatusDot.image(for: icon))
.accessibilityLabel(icon.accessibilityLabel)
}
}
enum StatusDot {
static func image(for icon: IconState) -> NSImage {
let size = NSSize(width: 18, height: 18)
let image = NSImage(size: size, flipped: false) { rect in
let inset = rect.insetBy(dx: 3, dy: 3)
color(for: icon).setFill()
NSBezierPath(ovalIn: inset).fill()
if icon == .unknown || icon == .notInstalled {
NSColor.windowBackgroundColor.setStroke()
let stroke = NSBezierPath(ovalIn: inset.insetBy(dx: 0.5, dy: 0.5))
stroke.lineWidth = 1
stroke.stroke()
}
return true
}
image.isTemplate = false
return image
}
private static func color(for icon: IconState) -> NSColor {
switch icon {
case .ok:
NSColor.systemGreen
case .starting:
NSColor.systemOrange
case .degraded, .authRequired:
NSColor.systemYellow
case .unreachable:
NSColor.systemRed
case .notInstalled, .unknown:
NSColor.systemGray
}
}
}
@@ -0,0 +1,104 @@
import ServiceManagement
import SwiftUI
import AIMemoryMenuCore
struct SettingsView: View {
@Environment(AppModel.self) private var model
@State private var serverURL: String = ""
@State private var dataDir: String = ""
@State private var token: String = ""
@State private var launchAtLogin = SMAppService.mainApp.status == .enabled
@State private var loginError: String?
@State private var saveTask: Task<Void, Never>?
var body: some View {
Form {
Section("Server") {
TextField("URL", text: $serverURL)
.onChange(of: serverURL) { _, _ in scheduleSave() }
.onSubmit { persist() }
SecureField(
model.tokenConfigured ? "Bearer token (saved in Keychain)" : "Bearer token (optional)",
text: $token
)
.onChange(of: token) { _, _ in scheduleSave() }
.onSubmit { persist() }
if model.tokenConfigured {
Button("Clear saved token") {
token = ""
model.saveSettings(
serverURLString: serverURL,
dataDirOverride: dataDir,
token: nil,
clearToken: true
)
}
}
}
Section("Data") {
TextField("Data directory override", text: $dataDir, prompt: Text(FirstRun.defaultDataDir().path))
.onChange(of: dataDir) { _, _ in scheduleSave() }
.onSubmit { persist() }
Text("Empty keeps ~/Library/Application Support/ai-memory. Changing this does not move existing files.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Login") {
Toggle("Launch menu bar app at login", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { _, enabled in
setLoginItem(enabled)
}
if let loginError {
Text(loginError)
.font(.caption)
.foregroundStyle(.red)
}
}
}
.formStyle(.grouped)
.frame(minWidth: 480, minHeight: 320)
.onAppear {
serverURL = model.settings.serverURL.absoluteString
dataDir = model.settings.dataDirOverride?.path ?? ""
launchAtLogin = SMAppService.mainApp.status == .enabled
}
.onDisappear {
persist()
}
}
private func scheduleSave() {
saveTask?.cancel()
saveTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
persist()
}
}
private func persist() {
saveTask?.cancel()
model.saveSettings(
serverURLString: serverURL,
dataDirOverride: dataDir,
token: token.isEmpty ? nil : token,
clearToken: false
)
}
private func setLoginItem(_ enabled: Bool) {
let currentlyEnabled = SMAppService.mainApp.status == .enabled
guard enabled != currentlyEnabled else { return }
do {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
loginError = nil
} catch {
loginError = error.localizedDescription
launchAtLogin = SMAppService.mainApp.status == .enabled
}
}
}
@@ -0,0 +1,35 @@
import AppKit
import SwiftUI
struct StatusOutputView: View {
@Environment(AppModel.self) private var model
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("ai-memory status")
.font(.headline)
Spacer()
Button("Refresh") {
Task { await model.refreshStatusOutput() }
}
.disabled(model.isBusy)
Button("Copy") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(model.statusOutput, forType: .string)
}
.disabled(model.statusOutput.isEmpty)
}
ScrollView {
Text(model.statusOutput.isEmpty ? "Running ai-memory status…" : model.statusOutput)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.background(Color(nsColor: .textBackgroundColor))
}
.padding()
.frame(minWidth: 560, minHeight: 360)
}
}
@@ -0,0 +1,49 @@
import Foundation
public struct AppSettings: Equatable, Sendable {
public var serverURL: URL
public var dataDirOverride: URL?
public static let defaultServerURL = URL(string: "http://127.0.0.1:49374")!
public init(serverURL: URL = defaultServerURL, dataDirOverride: URL? = nil) {
self.serverURL = serverURL
self.dataDirOverride = dataDirOverride
}
public var resolvedDataDir: URL {
dataDirOverride ?? FirstRun.defaultDataDir()
}
public static func load(defaults: UserDefaults = .standard) -> AppSettings {
let url: URL
if let stored = defaults.string(forKey: Keys.serverURL),
let parsed = URL(string: stored)
{
url = parsed
} else {
url = defaultServerURL
}
let override: URL?
if let path = defaults.string(forKey: Keys.dataDir), !path.isEmpty {
override = URL(fileURLWithPath: path)
} else {
override = nil
}
return AppSettings(serverURL: url, dataDirOverride: override)
}
public func save(defaults: UserDefaults = .standard) {
defaults.set(serverURL.absoluteString, forKey: Keys.serverURL)
if let dataDirOverride {
defaults.set(dataDirOverride.path, forKey: Keys.dataDir)
} else {
defaults.removeObject(forKey: Keys.dataDir)
}
}
private enum Keys {
static let serverURL = "serverURL"
static let dataDir = "dataDirOverride"
}
}
@@ -0,0 +1,101 @@
import Foundation
/// Layout of `Contents/Resources/runtime/`: the release-tarball sibling pair
/// (`ai-memory` + `hooks/`) plus the launchd template.
public struct RuntimeLayout: Equatable, Sendable {
public var root: URL
public init(root: URL) {
self.root = root
}
public var binary: URL {
root.appending(path: "ai-memory")
}
public var hooks: URL {
root.appending(path: "hooks")
}
public var plistTemplate: URL {
root.appending(path: "packaging/launchd/\(LaunchdController.label).plist")
}
public func validate(fileManager: FileManager = .default) -> Bool {
fileManager.isExecutableFile(atPath: binary.path)
&& fileManager.fileExists(atPath: hooks.path)
&& fileManager.fileExists(atPath: plistTemplate.path)
}
/// Prefers the staged bundle resource; `AI_MEMORY_MENU_RUNTIME` is a
/// developer override for `swift run` without wrapping an `.app`.
public static func resolve(
bundle: Bundle = .main,
environment: [String: String] = ProcessInfo.processInfo.environment,
fileManager: FileManager = .default
) -> RuntimeLayout? {
if let env = environment["AI_MEMORY_MENU_RUNTIME"], !env.isEmpty {
let layout = RuntimeLayout(root: URL(fileURLWithPath: env))
if layout.validate(fileManager: fileManager) {
return layout
}
}
if let resourceRoot = bundle.resourceURL {
let layout = RuntimeLayout(root: resourceRoot.appending(path: "runtime"))
if layout.validate(fileManager: fileManager) {
return layout
}
}
return nil
}
}
public enum FirstRun {
public static func needsInit(dataDir: URL, fileManager: FileManager = .default) -> Bool {
!fileManager.fileExists(atPath: dataDir.appending(path: "config.toml").path)
}
public static func defaultDataDir(fileManager: FileManager = .default) -> URL {
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory())
.appending(path: "Library/Application Support")
return base.appending(path: "ai-memory")
}
}
public struct BundledCLI: Sendable {
public var runtime: RuntimeLayout
public var runner: any CommandRunning
public init(runtime: RuntimeLayout, runner: any CommandRunning = ProcessRunner()) {
self.runtime = runtime
self.runner = runner
}
public func initDataDir(_ dataDir: URL) throws -> ProcessResult {
try run(arguments: ["--data-dir", dataDir.path, "init"], extraEnv: [:])
}
public func status(serverURL: URL, token: String?, dataDir: URL?) throws -> ProcessResult {
var args: [String] = []
var env: [String: String] = [
"AI_MEMORY_SERVER_URL": serverURL.absoluteString,
]
if let dataDir {
args.append(contentsOf: ["--data-dir", dataDir.path])
}
if let token, !token.isEmpty {
env["AI_MEMORY_AUTH_TOKEN"] = token
}
args.append("status")
return try run(arguments: args, extraEnv: env)
}
private func run(arguments: [String], extraEnv: [String: String]) throws -> ProcessResult {
let result = try runner.run(binary: runtime.binary, arguments: arguments, extraEnv: extraEnv)
if result.exitCode != 0 {
throw CliError.failed(result.exitCode, result.combinedOutput)
}
return result
}
}
@@ -0,0 +1,60 @@
import Foundation
public enum HealthError: Error, Equatable, Sendable {
case badURL
case http(Int)
case decode
case transport(String)
}
public struct HealthClient: Sendable {
public var timeout: TimeInterval
private let session: URLSession
public init(timeout: TimeInterval = 3) {
self.timeout = timeout
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = timeout
config.timeoutIntervalForResource = timeout
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.waitsForConnectivity = false
session = URLSession(configuration: config)
}
public func fetch(baseURL: URL, token: String?) async throws -> StatusReport {
let url = baseURL.appending(path: "admin/status")
var request = URLRequest(url: url)
request.timeoutInterval = timeout
request.cachePolicy = .reloadIgnoringLocalCacheData
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token, !token.isEmpty {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw HealthError.transport(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw HealthError.transport("non-HTTP response")
}
guard (200 ..< 300).contains(http.statusCode) else {
throw HealthError.http(http.statusCode)
}
do {
return try JSONDecoder().decode(StatusReport.self, from: data)
} catch {
throw HealthError.decode
}
}
}
public enum HealthURL {
public static func webUI(from baseURL: URL) -> URL {
baseURL.appending(path: "web")
}
}
@@ -0,0 +1,281 @@
import Foundation
/// Wire shape of `GET /admin/status`. Unknown fields are ignored so an
/// older or newer server still decodes the headlines this wrapper shows.
public struct StatusReport: Decodable, Equatable, Sendable {
public var version: String
public var dataDir: String?
public var bind: String?
public var counts: StatusCounts
public var writeQueue: WriteQueue?
public var providers: ProviderHealthSnapshot?
public var ingest: IngestSnapshot?
public init(
version: String,
dataDir: String? = nil,
bind: String? = nil,
counts: StatusCounts,
writeQueue: WriteQueue? = nil,
providers: ProviderHealthSnapshot? = nil,
ingest: IngestSnapshot? = nil
) {
self.version = version
self.dataDir = dataDir
self.bind = bind
self.counts = counts
self.writeQueue = writeQueue
self.providers = providers
self.ingest = ingest
}
enum CodingKeys: String, CodingKey {
case version
case bind
case counts
case providers
case ingest
case dataDir = "data_dir"
case writeQueue = "write_queue"
}
public var isDegraded: Bool {
if let queue = writeQueue, queue.queued > 0 {
return true
}
if providers?.llm.status == "error" {
return true
}
if providers?.embedding.status == "error" {
return true
}
return false
}
public var llmHeadline: String {
roleHeadline(label: "LLM", role: providers?.llm)
}
public var embeddingHeadline: String {
roleHeadline(label: "Embed", role: providers?.embedding)
}
/// Lines shown in the menu extra. Counts come from `GET /admin/status`.
public var statisticLines: [String] {
var lines: [String] = []
if let bind, !bind.isEmpty {
lines.append("Bind \(bind)")
}
lines.append(contentsOf: [
"Pages \(counts.pagesLatest) (all versions \(counts.pagesAll))",
"Sessions \(counts.sessions)",
"Observations \(counts.observations)",
llmHeadline,
embeddingHeadline,
])
if let queue = writeQueue, queue.queued > 0 {
lines.append("Write queue \(queue.queued)/\(queue.capacity)")
}
if let ingest {
lines.append("Ingest accepted \(ingest.accepted)")
if ingest.droppedByPolicy > 0 {
lines.append("Dropped by policy \(ingest.droppedByPolicy)")
}
lines.append("Last write \(Self.lastWriteLabel(ingest.lastPersistedMs))")
}
return lines
}
private func roleHeadline(label: String, role: ProviderRoleHealth?) -> String {
guard let role else {
return "\(label) unknown"
}
var text = "\(label) \(role.status)"
if let provider = role.provider, !provider.isEmpty {
if let model = role.model, !model.isEmpty {
text += " \(provider)/\(model)"
} else {
text += " \(provider)"
}
}
return text
}
public static func lastWriteLabel(_ unixMs: UInt64?) -> String {
guard let unixMs else {
return "—"
}
let nowMs = UInt64(max(0, Date().timeIntervalSince1970 * 1000))
let ageMs = nowMs > unixMs ? nowMs - unixMs : 0
let secs = ageMs / 1000
if secs < 60 {
return "\(secs)s ago"
}
if secs < 3600 {
return "\(secs / 60)m ago"
}
if secs < 86_400 {
return "\(secs / 3600)h ago"
}
return "\(secs / 86_400)d ago"
}
}
public struct IngestSnapshot: Decodable, Equatable, Sendable {
public var accepted: UInt64
public var droppedByPolicy: UInt64
public var shedSaturated: UInt64
public var shedRateLimited: UInt64
public var lastPersistedMs: UInt64?
public init(
accepted: UInt64 = 0,
droppedByPolicy: UInt64 = 0,
shedSaturated: UInt64 = 0,
shedRateLimited: UInt64 = 0,
lastPersistedMs: UInt64? = nil
) {
self.accepted = accepted
self.droppedByPolicy = droppedByPolicy
self.shedSaturated = shedSaturated
self.shedRateLimited = shedRateLimited
self.lastPersistedMs = lastPersistedMs
}
enum CodingKeys: String, CodingKey {
case accepted
case droppedByPolicy = "dropped_by_policy"
case shedSaturated = "shed_saturated"
case shedRateLimited = "shed_rate_limited"
case lastPersistedMs = "last_persisted_ms"
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
accepted = try container.decodeIfPresent(UInt64.self, forKey: .accepted) ?? 0
droppedByPolicy = try container.decodeIfPresent(UInt64.self, forKey: .droppedByPolicy) ?? 0
shedSaturated = try container.decodeIfPresent(UInt64.self, forKey: .shedSaturated) ?? 0
shedRateLimited = try container.decodeIfPresent(UInt64.self, forKey: .shedRateLimited) ?? 0
lastPersistedMs = try container.decodeIfPresent(UInt64.self, forKey: .lastPersistedMs)
}
}
public struct StatusCounts: Decodable, Equatable, Sendable {
public var pagesLatest: UInt64
public var pagesAll: UInt64
public var sessions: UInt64
public var observations: UInt64
public init(pagesLatest: UInt64, pagesAll: UInt64, sessions: UInt64, observations: UInt64) {
self.pagesLatest = pagesLatest
self.pagesAll = pagesAll
self.sessions = sessions
self.observations = observations
}
enum CodingKeys: String, CodingKey {
case pagesLatest = "pages_latest"
case pagesAll = "pages_all"
case sessions
case observations
}
}
/// JSON encoding of the server's `(queued, capacity)` tuple.
public struct WriteQueue: Decodable, Equatable, Sendable {
public var queued: Int
public var capacity: Int
public init(queued: Int, capacity: Int) {
self.queued = queued
self.capacity = capacity
}
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
queued = try container.decode(Int.self)
capacity = try container.decode(Int.self)
}
}
public struct ProviderHealthSnapshot: Decodable, Equatable, Sendable {
public var llm: ProviderRoleHealth
public var embedding: ProviderRoleHealth
public init(llm: ProviderRoleHealth, embedding: ProviderRoleHealth) {
self.llm = llm
self.embedding = embedding
}
}
public struct ProviderRoleHealth: Decodable, Equatable, Sendable {
public var status: String
public var provider: String?
public var model: String?
public init(status: String, provider: String? = nil, model: String? = nil) {
self.status = status
self.provider = provider
self.model = model
}
}
public enum IconState: Equatable, Sendable {
case unknown
case notInstalled
case unreachable
case starting
case authRequired
case ok
case degraded
public static func derived(
launchd: LaunchdState,
report: StatusReport?,
fetchFailed: Bool,
httpStatus: Int? = nil
) -> IconState {
if let report {
return report.isDegraded ? .degraded : .ok
}
if let httpStatus, httpStatus == 401 || httpStatus == 403 {
return .authRequired
}
if fetchFailed {
return launchd == .notInstalled ? .notInstalled : .unreachable
}
return .unknown
}
/// Keep the "starting" overlay until `/admin/status` answers or the grace expires.
public static func applyingStartGrace(_ derived: IconState, isStarting: Bool) -> IconState {
guard isStarting else {
return derived
}
switch derived {
case .ok, .degraded, .authRequired:
return derived
default:
return .starting
}
}
public var accessibilityLabel: String {
switch self {
case .unknown:
"ai-memory, status unknown"
case .notInstalled:
"ai-memory, not installed"
case .unreachable:
"ai-memory, server down"
case .starting:
"ai-memory, server is starting"
case .authRequired:
"ai-memory, running, authentication required"
case .ok:
"ai-memory, server running"
case .degraded:
"ai-memory, running with warnings"
}
}
}
@@ -0,0 +1,136 @@
import Darwin
import Foundation
public enum LaunchdState: Equatable, Sendable {
case notInstalled
case stopped
case running
}
public struct LaunchdController: Sendable {
public static let label = "com.github.akitaonrails.ai-memory"
public var home: URL
public var runner: any CommandRunning
public init(home: URL, runner: any CommandRunning = ProcessRunner()) {
self.home = home
self.runner = runner
}
public var plistDestination: URL {
home.appending(path: "Library/LaunchAgents")
.appending(path: "\(Self.label).plist")
}
public var logDirectory: URL {
home.appending(path: "Library/Logs/ai-memory")
}
public static func renderTemplate(
_ template: String,
binary: URL,
home: URL,
dataDir: URL?
) -> String {
var rendered = template
.replacingOccurrences(of: "__AI_MEMORY_BIN__", with: binary.path)
.replacingOccurrences(of: "__HOME__", with: home.path)
if let dataDir {
let env = """
<key>EnvironmentVariables</key>
<dict>
<key>AI_MEMORY_DATA_DIR</key>
<string>\(xmlEscape(dataDir.path))</string>
</dict>
"""
if let range = rendered.range(of: "</dict>", options: .backwards) {
rendered.replaceSubrange(range, with: env + "</dict>")
}
}
return rendered
}
public static func programArgumentsBinary(inPlist plist: String) -> String? {
// First <string> after ProgramArguments is the executable.
guard let argsRange = plist.range(of: "<key>ProgramArguments</key>") else {
return nil
}
let rest = plist[argsRange.upperBound...]
guard let start = rest.range(of: "<string>") else {
return nil
}
let after = rest[start.upperBound...]
guard let end = after.range(of: "</string>") else {
return nil
}
return String(after[..<end.lowerBound])
}
public func writePlist(template: String, binary: URL, dataDir: URL?) throws {
let fm = FileManager.default
try fm.createDirectory(
at: plistDestination.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try fm.createDirectory(at: logDirectory, withIntermediateDirectories: true)
let body = Self.renderTemplate(template, binary: binary, home: home, dataDir: dataDir)
try body.write(to: plistDestination, atomically: true, encoding: .utf8)
}
public func state() -> LaunchdState {
let plistExists = FileManager.default.fileExists(atPath: plistDestination.path)
let result = try? runner.run(
binary: URL(fileURLWithPath: "/bin/launchctl"),
arguments: ["print", domainService],
extraEnv: [:]
)
if let result, result.exitCode == 0 {
if result.stdout.contains("state = running") {
return .running
}
return .stopped
}
return plistExists ? .stopped : .notInstalled
}
public func bootstrap() throws {
try runLaunchctl(["bootstrap", domain, plistDestination.path])
}
public func bootout() throws {
try runLaunchctl(["bootout", domainService])
}
public func kickstart() throws {
try runLaunchctl(["kickstart", "-k", domainService])
}
private func runLaunchctl(_ arguments: [String]) throws {
let result = try runner.run(
binary: URL(fileURLWithPath: "/bin/launchctl"),
arguments: arguments,
extraEnv: [:]
)
if result.exitCode != 0 {
throw CliError.failed(result.exitCode, result.combinedOutput)
}
}
private var domain: String {
"gui/\(getuid())"
}
private var domainService: String {
"\(domain)/\(Self.label)"
}
private static func xmlEscape(_ value: String) -> String {
value
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
}
@@ -0,0 +1,78 @@
import Foundation
public struct ProcessResult: Equatable, Sendable {
public var exitCode: Int32
public var stdout: String
public var stderr: String
public init(exitCode: Int32, stdout: String, stderr: String) {
self.exitCode = exitCode
self.stdout = stdout
self.stderr = stderr
}
public var combinedOutput: String {
let out = stdout.trimmingCharacters(in: .whitespacesAndNewlines)
let err = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
if err.isEmpty {
return out
}
if out.isEmpty {
return err
}
return out + "\n" + err
}
}
public protocol CommandRunning: Sendable {
func run(binary: URL, arguments: [String], extraEnv: [String: String]) throws -> ProcessResult
}
public struct ProcessRunner: CommandRunning {
public var timeout: TimeInterval
public init(timeout: TimeInterval = 30) {
self.timeout = timeout
}
public func run(binary: URL, arguments: [String], extraEnv: [String: String]) throws -> ProcessResult {
let process = Process()
process.executableURL = binary
process.arguments = arguments
var env = ProcessInfo.processInfo.environment
for (key, value) in extraEnv {
env[key] = value
}
process.environment = env
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
try process.run()
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning, Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning {
process.terminate()
throw CliError.timeout
}
let outData = stdout.fileHandleForReading.readDataToEndOfFile()
let errData = stderr.fileHandleForReading.readDataToEndOfFile()
return ProcessResult(
exitCode: process.terminationStatus,
stdout: String(data: outData, encoding: .utf8) ?? "",
stderr: String(data: errData, encoding: .utf8) ?? ""
)
}
}
public enum CliError: Error, Equatable, Sendable {
case timeout
case missingRuntime
case failed(Int32, String)
}
@@ -0,0 +1,106 @@
import Foundation
import Security
public protocol TokenStore: Sendable {
func read() -> String?
func save(_ token: String) throws
func clear() throws
}
public struct MemoryTokenStore: TokenStore, Sendable {
private let box: LockingBox<String?>
public init(_ initial: String? = nil) {
box = LockingBox(initial)
}
public func read() -> String? {
box.value
}
public func save(_ token: String) throws {
box.value = token
}
public func clear() throws {
box.value = nil
}
}
/// Tiny mutex so `MemoryTokenStore` can be `Sendable` in tests.
final class LockingBox<Value>: @unchecked Sendable {
private let lock = NSLock()
private var storage: Value
init(_ value: Value) {
storage = value
}
var value: Value {
get {
lock.lock()
defer { lock.unlock() }
return storage
}
set {
lock.lock()
defer { lock.unlock() }
storage = newValue
}
}
}
public struct KeychainTokenStore: TokenStore, Sendable {
public static let service = "com.github.akitaonrails.ai-memory-menu"
public static let account = "bearer"
public init() {}
public func read() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: Self.account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
public func save(_ token: String) throws {
try clear()
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: Self.account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unhandled(status)
}
}
public func clear() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: Self.account,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unhandled(status)
}
}
}
public enum KeychainError: Error, Equatable, Sendable {
case unhandled(OSStatus)
}
@@ -0,0 +1,23 @@
import Foundation
import Testing
@testable import AIMemoryMenuCore
struct AppSettingsTests {
@Test func loadAndSaveRoundTrip() throws {
let name = "ai-memory-menu-settings-test-\(UUID().uuidString)"
let suite = try #require(UserDefaults(suiteName: name))
defer { suite.removePersistentDomain(forName: name) }
var settings = AppSettings.load(defaults: suite)
#expect(settings.serverURL == AppSettings.defaultServerURL)
#expect(settings.dataDirOverride == nil)
settings.serverURL = URL(string: "http://127.0.0.1:8080")!
settings.dataDirOverride = URL(fileURLWithPath: "/tmp/custom-memory")
settings.save(defaults: suite)
let loaded = AppSettings.load(defaults: suite)
#expect(loaded.serverURL.absoluteString == "http://127.0.0.1:8080")
#expect(loaded.dataDirOverride?.path == "/tmp/custom-memory")
}
}
@@ -0,0 +1,108 @@
import Foundation
import Testing
@testable import AIMemoryMenuCore
struct HealthModelsTests {
@Test func decodesStatusFixture() throws {
let url = try #require(Bundle.module.url(forResource: "status", withExtension: "json", subdirectory: "fixtures"))
let data = try Data(contentsOf: url)
let report = try JSONDecoder().decode(StatusReport.self, from: data)
#expect(report.version == "2.3.2")
#expect(report.counts.pagesLatest == 138)
#expect(report.counts.sessions == 27)
#expect(report.writeQueue == WriteQueue(queued: 0, capacity: 128))
#expect(report.providers?.llm.status == "ok")
#expect(report.providers?.embedding.status == "disabled")
#expect(report.ingest?.accepted == 4198)
#expect(!report.isDegraded)
#expect(report.llmHeadline.contains("ok"))
let stats = report.statisticLines
#expect(stats.contains { $0.hasPrefix("Pages 138") })
#expect(stats.contains { $0.hasPrefix("Sessions 27") })
#expect(stats.contains { $0.hasPrefix("Observations 4198") })
#expect(stats.contains { $0.hasPrefix("Bind ") })
#expect(stats.contains { $0.hasPrefix("Ingest accepted 4198") })
}
@Test func decodesOlderPayloadWithoutWriteQueueOrProviders() throws {
let json = """
{
"version": "1.0.0",
"counts": {
"pages_latest": 1,
"pages_all": 1,
"sessions": 0,
"observations": 0
}
}
""".data(using: .utf8)!
let report = try JSONDecoder().decode(StatusReport.self, from: json)
#expect(report.writeQueue == nil)
#expect(report.providers == nil)
#expect(!report.isDegraded)
}
@Test func degradedWhenProviderErrorsOrQueueIsBusy() {
let ok = StatusReport(
version: "2.3.2",
counts: StatusCounts(pagesLatest: 1, pagesAll: 1, sessions: 0, observations: 0),
writeQueue: WriteQueue(queued: 0, capacity: 8),
providers: ProviderHealthSnapshot(
llm: ProviderRoleHealth(status: "ok"),
embedding: ProviderRoleHealth(status: "disabled")
)
)
#expect(!ok.isDegraded)
var queued = ok
queued.writeQueue = WriteQueue(queued: 1, capacity: 8)
#expect(queued.isDegraded)
var llmError = ok
llmError.writeQueue = WriteQueue(queued: 0, capacity: 8)
llmError.providers = ProviderHealthSnapshot(
llm: ProviderRoleHealth(status: "error"),
embedding: ProviderRoleHealth(status: "ok")
)
#expect(llmError.isDegraded)
var embedError = ok
embedError.providers = ProviderHealthSnapshot(
llm: ProviderRoleHealth(status: "ok"),
embedding: ProviderRoleHealth(status: "error")
)
#expect(embedError.isDegraded)
}
}
struct IconStateTests {
@Test func derivedStates() {
let report = StatusReport(
version: "2.3.2",
counts: StatusCounts(pagesLatest: 1, pagesAll: 1, sessions: 0, observations: 0)
)
#expect(IconState.derived(launchd: .running, report: report, fetchFailed: false) == .ok)
var degraded = report
degraded.writeQueue = WriteQueue(queued: 3, capacity: 8)
#expect(IconState.derived(launchd: .running, report: degraded, fetchFailed: false) == .degraded)
#expect(IconState.derived(launchd: .notInstalled, report: nil, fetchFailed: true) == .notInstalled)
#expect(IconState.derived(launchd: .stopped, report: nil, fetchFailed: true) == .unreachable)
#expect(IconState.derived(launchd: .running, report: nil, fetchFailed: true) == .unreachable)
#expect(IconState.derived(launchd: .notInstalled, report: nil, fetchFailed: false) == .unknown)
#expect(
IconState.derived(
launchd: .running,
report: nil,
fetchFailed: true,
httpStatus: 401
) == .authRequired
)
#expect(
IconState.applyingStartGrace(.unreachable, isStarting: true) == .starting
)
#expect(IconState.applyingStartGrace(.ok, isStarting: true) == .ok)
#expect(IconState.applyingStartGrace(.unreachable, isStarting: false) == .unreachable)
}
}
@@ -0,0 +1,74 @@
import Foundation
import Testing
@testable import AIMemoryMenuCore
struct LaunchdControllerTests {
@Test func substitutesPlaceholders() {
let template = """
<string>__AI_MEMORY_BIN__</string>
<string>__HOME__/Library/Logs/ai-memory/stderr.log</string>
"""
let rendered = LaunchdController.renderTemplate(
template,
binary: URL(fileURLWithPath: "/Applications/AI Memory.app/Contents/Resources/runtime/ai-memory"),
home: URL(fileURLWithPath: "/Users/ada"),
dataDir: nil
)
#expect(rendered.contains("/Applications/AI Memory.app/Contents/Resources/runtime/ai-memory"))
#expect(rendered.contains("/Users/ada/Library/Logs/ai-memory/stderr.log"))
#expect(!rendered.contains("__AI_MEMORY_BIN__"))
#expect(!rendered.contains("EnvironmentVariables"))
}
@Test func injectsDataDirEnvironment() {
let template = """
<dict>
<key>Label</key>
<string>com.github.akitaonrails.ai-memory</string>
</dict>
"""
let rendered = LaunchdController.renderTemplate(
template,
binary: URL(fileURLWithPath: "/bin/ai-memory"),
home: URL(fileURLWithPath: "/Users/ada"),
dataDir: URL(fileURLWithPath: "/Users/ada/.ai-memory")
)
#expect(rendered.contains("<key>AI_MEMORY_DATA_DIR</key>"))
#expect(rendered.contains("<string>/Users/ada/.ai-memory</string>"))
#expect(rendered.contains("<key>EnvironmentVariables</key>"))
}
@Test func rendersCheckedInLaunchdTemplate() throws {
let templateURL = repoRoot()
.appending(path: "packaging/launchd/com.github.akitaonrails.ai-memory.plist")
let template = try String(contentsOf: templateURL, encoding: .utf8)
let binary = URL(fileURLWithPath: "/Applications/AI Memory.app/Contents/Resources/runtime/ai-memory")
let home = URL(fileURLWithPath: "/Users/ada")
let rendered = LaunchdController.renderTemplate(template, binary: binary, home: home, dataDir: nil)
#expect(LaunchdController.programArgumentsBinary(inPlist: rendered) == binary.path)
#expect(rendered.contains("/Users/ada/Library/Logs/ai-memory/stderr.log"))
#expect(rendered.contains("serve"))
#expect(rendered.contains("--enable-web"))
#expect(!rendered.contains("__HOME__"))
}
@Test func xmlEscapesDataDir() {
let template = "<dict></dict>"
let rendered = LaunchdController.renderTemplate(
template,
binary: URL(fileURLWithPath: "/bin/ai-memory"),
home: URL(fileURLWithPath: "/Users/ada"),
dataDir: URL(fileURLWithPath: "/tmp/a&b<c>")
)
#expect(rendered.contains("/tmp/a&amp;b&lt;c&gt;"))
}
}
private func repoRoot(file: String = #filePath) -> URL {
URL(fileURLWithPath: file)
.deletingLastPathComponent() // Tests/AIMemoryMenuTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // companions/ai-memory-macos
.deletingLastPathComponent() // companions
.deletingLastPathComponent() // repo
}
@@ -0,0 +1,132 @@
import Foundation
import Testing
@testable import AIMemoryMenuCore
struct RuntimeAndCliTests {
@Test func layoutPointsAtTarballSiblings() {
let root = URL(fileURLWithPath: "/tmp/runtime")
let layout = RuntimeLayout(root: root)
#expect(layout.binary.path.hasSuffix("/runtime/ai-memory"))
#expect(layout.hooks.path.hasSuffix("/runtime/hooks"))
#expect(layout.plistTemplate.path.hasSuffix("/packaging/launchd/com.github.akitaonrails.ai-memory.plist"))
}
@Test func validateRequiresBinaryHooksAndPlist() throws {
let dir = FileManager.default.temporaryDirectory
.appending(path: "ai-memory-menu-runtime-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: dir) }
let layout = RuntimeLayout(root: dir)
#expect(!layout.validate())
try FileManager.default.createDirectory(at: layout.hooks, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: layout.plistTemplate.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data().write(to: layout.binary)
try Data().write(to: layout.plistTemplate)
#expect(!layout.validate())
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: layout.binary.path)
#expect(layout.validate())
}
@Test func resolvePrefersEnvironmentOverride() throws {
let dir = FileManager.default.temporaryDirectory
.appending(path: "ai-memory-menu-env-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: dir) }
let layout = RuntimeLayout(root: dir)
try FileManager.default.createDirectory(at: layout.hooks, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: layout.plistTemplate.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data().write(to: layout.binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: layout.binary.path)
try Data().write(to: layout.plistTemplate)
let resolved = RuntimeLayout.resolve(
bundle: Bundle.main,
environment: ["AI_MEMORY_MENU_RUNTIME": dir.path]
)
#expect(resolved?.root.path == dir.path)
}
@Test func needsInitOnlyWhenConfigMissing() throws {
let dir = FileManager.default.temporaryDirectory
.appending(path: "ai-memory-menu-init-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: dir) }
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
#expect(FirstRun.needsInit(dataDir: dir))
try "bind = \"127.0.0.1:49374\"\n".write(
to: dir.appending(path: "config.toml"),
atomically: true,
encoding: .utf8
)
#expect(!FirstRun.needsInit(dataDir: dir))
}
@Test func bundledCLIInvokesInitAndStatus() throws {
let runner = MockRunner()
runner.results.append(ProcessResult(exitCode: 0, stdout: "initialized\n", stderr: ""))
runner.results.append(ProcessResult(exitCode: 0, stdout: "ai-memory 2.3.2 (server)\n", stderr: ""))
let cli = BundledCLI(
runtime: RuntimeLayout(root: URL(fileURLWithPath: "/tmp/runtime")),
runner: runner
)
let dataDir = URL(fileURLWithPath: "/tmp/data")
_ = try cli.initDataDir(dataDir)
_ = try cli.status(
serverURL: URL(string: "http://127.0.0.1:49374")!,
token: "secret",
dataDir: dataDir
)
#expect(runner.calls.count == 2)
#expect(runner.calls[0].arguments == ["--data-dir", "/tmp/data", "init"])
#expect(runner.calls[1].arguments == ["--data-dir", "/tmp/data", "status"])
#expect(runner.calls[1].extraEnv["AI_MEMORY_SERVER_URL"] == "http://127.0.0.1:49374")
#expect(runner.calls[1].extraEnv["AI_MEMORY_AUTH_TOKEN"] == "secret")
}
@Test func bundledCLISurfacesNonZeroExit() {
let runner = MockRunner()
runner.results.append(ProcessResult(exitCode: 2, stdout: "", stderr: "could not reach server\n"))
let cli = BundledCLI(
runtime: RuntimeLayout(root: URL(fileURLWithPath: "/tmp/runtime")),
runner: runner
)
do {
_ = try cli.status(
serverURL: URL(string: "http://127.0.0.1:49374")!,
token: nil,
dataDir: nil
)
Issue.record("expected failure")
} catch let CliError.failed(code, output) {
#expect(code == 2)
#expect(output.contains("could not reach server"))
} catch {
Issue.record("wrong error \(error)")
}
}
}
private final class MockRunner: CommandRunning, @unchecked Sendable {
struct Call {
var binary: URL
var arguments: [String]
var extraEnv: [String: String]
}
var calls: [Call] = []
var results: [ProcessResult] = []
func run(binary: URL, arguments: [String], extraEnv: [String: String]) throws -> ProcessResult {
calls.append(Call(binary: binary, arguments: arguments, extraEnv: extraEnv))
if results.isEmpty {
return ProcessResult(exitCode: 0, stdout: "", stderr: "")
}
return results.removeFirst()
}
}
@@ -0,0 +1,31 @@
{
"version": "2.3.2",
"data_dir": "/Users/test/Library/Application Support/ai-memory",
"bind": "127.0.0.1:49374",
"db_path": "/Users/test/Library/Application Support/ai-memory/db/memory.sqlite",
"counts": {
"pages_latest": 138,
"pages_all": 162,
"sessions": 27,
"observations": 4198
},
"write_queue": [0, 128],
"providers": {
"llm": {
"status": "ok",
"provider": "openai",
"model": "gpt-4.1"
},
"embedding": {
"status": "disabled"
},
"llm_candidates": []
},
"ingest": {
"accepted": 4198,
"dropped_by_policy": 12,
"shed_saturated": 0,
"shed_rate_limited": 0,
"last_persisted_ms": 1700000000000
}
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Build AI Memory.app: Swift menu bar + staged ai-memory runtime (binary + hooks).
set -euo pipefail
COMPANION="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$COMPANION/../.." && pwd)"
DIST="${1:-$COMPANION/dist/AI Memory.app}"
CONFIG="${CONFIGURATION:-release}"
echo "building ai-memory ($CONFIG) from $ROOT"
if [[ "$CONFIG" == "release" ]]; then
cargo build --release --bin ai-memory --manifest-path "$ROOT/Cargo.toml"
BINARY="$ROOT/target/release/ai-memory"
SWIFT_FLAGS=(-c release)
else
cargo build --bin ai-memory --manifest-path "$ROOT/Cargo.toml"
BINARY="$ROOT/target/debug/ai-memory"
SWIFT_FLAGS=(-c debug)
fi
echo "building AIMemoryMenu"
swift build "${SWIFT_FLAGS[@]}" --package-path "$COMPANION"
BIN_PATH="$(swift build "${SWIFT_FLAGS[@]}" --package-path "$COMPANION" --show-bin-path)"
APP="$DIST"
echo "staging $APP"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS"
mkdir -p "$APP/Contents/Resources/runtime/packaging/launchd"
cp "$BIN_PATH/AIMemoryMenu" "$APP/Contents/MacOS/AIMemoryMenu"
cp "$COMPANION/Info.plist" "$APP/Contents/Info.plist"
printf 'APPL????' > "$APP/Contents/PkgInfo"
cp "$BINARY" "$APP/Contents/Resources/runtime/ai-memory"
chmod 755 "$APP/Contents/Resources/runtime/ai-memory"
rsync -a --delete "$ROOT/hooks/" "$APP/Contents/Resources/runtime/hooks/"
cp "$ROOT/packaging/launchd/com.github.akitaonrails.ai-memory.plist" \
"$APP/Contents/Resources/runtime/packaging/launchd/"
echo "built $APP"
echo "runtime: $APP/Contents/Resources/runtime/ai-memory"
echo "open with: open \"$APP\""
+50
View File
@@ -178,6 +178,56 @@ Re-home by kind:
6. Only after repeated usage, consider whether ai-memory core lacks a small,
generic API seam; do not start by patching core endpoints.
## `ai-memory-macos`: menu bar wrapper
Self-contained macOS accessory app at
[`companions/ai-memory-macos`](../companions/ai-memory-macos). It is a
**wrapper**, not a data-seam dashboard: it ships the `ai-memory` binary and
`hooks/` tree inside an `.app`, governs the existing LaunchAgent, and opens
`/web`, `ai-memory status`, `config.toml`, the data directory, and logs.
It is not a root workspace member. Build and test it separately:
```bash
./companions/ai-memory-macos/build.sh
swift test --package-path companions/ai-memory-macos
```
### Goal
Give macOS a first-class install that does not require a prior tarball, without
reimplementing status, search, wiki browsing, or config editing in SwiftUI.
### How it talks to ai-memory
- `GET /admin/status` for the menu-bar traffic light and two headline lines
(version, page/session counts, LLM role).
- Bundled `ai-memory status` / `ai-memory init` via `Process` (the real CLI).
- `launchctl` against `com.github.akitaonrails.ai-memory` and the checked-in
plist template in `packaging/launchd/`.
- `NSWorkspace` to open `/web`, `config.toml`, the data dir, and logs.
It does not open SQLite or the wiki, does not call writable `/admin` routes, and
does not add MCP tools.
### Data vs bundle
Durable state stays in `~/Library/Application Support/ai-memory` (the binary’s
existing macOS default) and logs under `~/Library/Logs/ai-memory`. Replacing
`/Applications/AI Memory.app` does not rewrite that tree. An optional data-dir
override is written only into the rendered LaunchAgent plist
(`AI_MEMORY_DATA_DIR`), never into the bundle.
### Safety requirements
- Do not silently start the LaunchAgent on first launch; **Install & Start**
is an explicit click.
- Do not write `AI_MEMORY_AUTH_TOKEN` into the plist. The menu bar’s own HTTP
client may keep a bearer in the Keychain.
- Do not sandbox the app in a way that blocks `launchctl` or LaunchAgents.
See [`docs/macos.md`](macos.md#scenario-d-menu-bar-app).
## `ai-memory-web-editor`: browser chat/editor companion
This is the companion shape for PR #123.
+26 -1
View File
@@ -1,7 +1,8 @@
# ai-memory cookbook
A task-oriented cheat sheet: "I want to do X" → how. For the full reference see
[`ARCHITECTURE.md`](ARCHITECTURE.md); for install see [`install.md`](install.md);
[`ARCHITECTURE.md`](ARCHITECTURE.md); for install see [`install.md`](install.md)
(including the [macOS menu bar app](install.md#macos-menu-bar-app));
for the tool-routing table see [`usage.md`](usage.md).
## What ai-memory is, in one paragraph
@@ -87,6 +88,30 @@ before implementing against it.
add the export endpoint" (`memory_message_send`), and over there "check my
inbox" (`memory_message_pop`). See [`agent-messaging.md`](agent-messaging.md).
## Recipe: run the server on a Mac
Use the menu bar app when you want one `.app` that starts the server and
opens the existing tools (web UI, `ai-memory status`, config, logs). It does
not replace those tools with a second dashboard.
```bash
./companions/ai-memory-macos/build.sh
open "companions/ai-memory-macos/dist/AI Memory.app"
```
Drag **AI Memory.app** to `/Applications`, then **Install & Start Server**
from the menu extra. Wire an agent with the bundled binary:
```bash
BIN="/Applications/AI Memory.app/Contents/Resources/runtime/ai-memory"
"$BIN" install-mcp --client claude-code --apply
"$BIN" install-hooks --agent claude-code --apply
```
Memory stays in `~/Library/Application Support/ai-memory`. Replacing the
`.app` does not rewrite it. Full paths (tarball, source, Docker, launchd):
[`macos.md`](macos.md).
## From the terminal (CLI)
Most people never need these — the agent does it — but they exist:
+50 -2
View File
@@ -1,13 +1,16 @@
# Installation cookbook
The [README quick-start](../README.md#quick-start) covers the happy
path (docker + Claude Code). This page covers everything else:
paths (Docker + Claude Code, Arch AUR, macOS menu bar app). This page
covers everything else:
- [Server on a different machine](#server-on-a-different-machine)
(homelab, LAN box, remote server)
- [Configuring the CLI URL and auth](#configuring-the-cli-url-and-auth)
- [Arch Linux native packages (AUR)](#arch-linux-native-packages-aur)
(systemd system service or user service)
- [macOS menu bar app](#macos-menu-bar-app)
(self-contained `.app` + LaunchAgent)
- [Configuring other agent CLIs](#configuring-other-agent-clis)
(Codex, Command Code, Devin CLI, OpenCode, OMP, Pi, Cursor, Claude Desktop, Gemini CLI, Antigravity CLI, Grok Build CLI, Zero, ZCode, Kimi Code, Kiro CLI, Pool, OpenClaw, VS Code Copilot, Zed)
- [Installing hooks without docker](#installing-hooks-without-docker)
@@ -714,6 +717,42 @@ AI_MEMORY_NATIVE_TEST_IMAGE=quay.io/toolbx/arch-toolbox:latest scripts/test-nati
---
## macOS menu bar app
On a Mac, the self-contained menu bar app is the GUI install: it bundles the
native `ai-memory` binary and `hooks/` tree, governs the existing LaunchAgent
(`com.github.akitaonrails.ai-memory`), and opens `/web`, `ai-memory status`,
`config.toml`, the data directory, and logs. It does not replace those tools
with a second dashboard.
Needs a Rust toolchain and Xcode / Swift 6 (the same as a source build):
```bash
git clone https://github.com/akitaonrails/ai-memory
cd ai-memory
./companions/ai-memory-macos/build.sh
open "companions/ai-memory-macos/dist/AI Memory.app"
```
Drag **AI Memory.app** to `/Applications`, then **Install & Start Server**
from the menu extra (no Dock icon). When the status item is green, wire an
agent with the bundled binary so `install-hooks` finds the sibling `hooks/`
tree:
```bash
BIN="/Applications/AI Memory.app/Contents/Resources/runtime/ai-memory"
"$BIN" install-mcp --client claude-code --apply
"$BIN" install-hooks --agent claude-code --apply
```
Durable memory stays in `~/Library/Application Support/ai-memory`. Replacing
the `.app` is an update and does not rewrite that tree. Prebuilt tarball,
source-build, Docker-wrapper, and hand-installed launchd paths remain in
[`docs/macos.md`](macos.md). Companion source:
[`companions/ai-memory-macos`](../companions/ai-memory-macos).
---
## Configuring other agent CLIs
> `install-mcp --server-url` accepts either the bare server origin or the full
@@ -1543,7 +1582,9 @@ The `serve` subcommand also accepts:
| _(config only)_ | `AI_MEMORY_HOOK_RATE_PER_SEC`, `AI_MEMORY_HOOK_RATE_BURST` | Optional per-actor/session hook ingest token bucket. Unset/`0` rate disables it; burst defaults to the rate (minimum one token when enabled). |
On macOS, see [`docs/macos.md`](macos.md); use the archive matching your
architecture: `aarch64` for Apple Silicon, `x86_64` for Intel. On Windows, see
architecture: `aarch64` for Apple Silicon, `x86_64` for Intel. The
[menu bar app](#macos-menu-bar-app) is the self-contained GUI path (bundles
the binary, starts the LaunchAgent, opens `/web` and status). On Windows, see
[`docs/windows.md`](windows.md).
The short version: run the install commands from the same environment that
launches the agent. WSL2-launched agents need WSL paths and POSIX `.sh` hooks.
@@ -2369,6 +2410,11 @@ unrelated Compose project just because its file occupies a conventional path;
the wrapper instead writes the inspected standalone recreation script for
review, preserving the existing `/data` mount and other runtime options.
The macOS menu bar app is not covered by `ai-memory upgrade`. Rebuild with
`./companions/ai-memory-macos/build.sh` (or replace `/Applications/AI Memory.app`
with a newer staged bundle). Wiki, SQLite, config, and models stay in
`~/Library/Application Support/ai-memory`.
Set `AI_MEMORY_NO_VERSION_CHECK=1` to silence the daily check. To pin wrapper
self-upgrades to a fork or tagged release, set `AI_MEMORY_WRAPPER_URL=<url>`;
the wrapper requires `<url>.sha256` unless
@@ -2412,6 +2458,8 @@ write to `~/.local/share/ai-memory/hooks/`.
## See also
- [`docs/macos.md`](macos.md) - macOS install paths: menu bar app, native
release tarball, source build, Docker wrapper, and launchd
- [`docs/deploy.md`](deploy.md) - homelab deploy walkthrough
(`bin/deploy`, cloudflared TLS, env-file management)
- [`docs/usage.md`](usage.md) - handoffs, proactive querying, web UI, slim
+84 -8
View File
@@ -4,12 +4,23 @@ macOS is a supported platform: the workspace test suite runs on macOS CI and
tagged releases publish native `ai-memory-macos-aarch64.tar.gz` (Apple Silicon)
and `ai-memory-macos-x86_64.tar.gz` (Intel) binaries.
On macOS the **native binary** (a prebuilt release or a source build) is the
recommended way to run ai-memory. It binds the server on `127.0.0.1:49374`, and
both the MCP endpoint and the lifecycle hooks talk to that loopback address —
which the native agent can reach and which is already in the default Host-header
allowlist. The Docker wrapper is also supported when you prefer a containerised
server.
On macOS the **native binary** is the recommended way to run ai-memory. Three
native installs exist:
- **[Scenario D — menu bar app](#scenario-d-menu-bar-app)** — self-contained
`.app` that bundles the binary, starts the LaunchAgent, and opens `/web`,
status, and config. The GUI path if you are building from source.
- **[Scenario A — prebuilt tarball](#scenario-a-prebuilt-release-binary-recommended-no-toolchain)** —
no Rust toolchain; run `serve` in a terminal or install the LaunchAgent by
hand.
- **[Scenario B — source build](#scenario-b-source-build)** — developing
ai-memory itself.
It binds the server on `127.0.0.1:49374`, and both the MCP endpoint and the
lifecycle hooks talk to that loopback address — which the native agent can
reach and which is already in the default Host-header allowlist. The Docker
wrapper ([Scenario C](#scenario-c-docker-wrapper)) is also supported when you
prefer a containerised server.
Unlike Windows there is only one "path world" on macOS: POSIX paths and POSIX
`.sh` hooks throughout. There is no WSL-vs-native split to get wrong.
@@ -32,6 +43,10 @@ normal Terminal.
Set `AI_MEMORY_HOOK_PLATFORM` before wiring hooks to override the default.
- The [menu bar app](#scenario-d-menu-bar-app) still needs `install-mcp` /
`install-hooks` from a shell; use the bundled binary inside the `.app` so
hook discovery sees the sibling `hooks/` tree.
## Scenario A: Prebuilt Release Binary (Recommended, No Toolchain)
Use this when you want a local server plus native hooks without a Rust toolchain
@@ -174,10 +189,56 @@ symlink caution above (#546) does not apply here.
The published Docker image includes both `linux/amd64` and `linux/arm64`, so
Apple Silicon pulls the native arm64 image without `--platform linux/amd64`.
## Scenario D: Menu bar app
Use this when you want a self-contained macOS install: one `.app` that contains
the `ai-memory` binary and `hooks/`, starts the existing LaunchAgent, and opens
the surfaces the tool already has (`/web`, `ai-memory status`, `config.toml`,
logs). It does not replace those tools with a second dashboard.
From a source checkout (Rust 1.95 + Xcode / Swift 6):
```bash
./companions/ai-memory-macos/build.sh
open "companions/ai-memory-macos/dist/AI Memory.app"
```
Drag `AI Memory.app` to `/Applications` so the LaunchAgent path stays stable
across rebuilds. The menu extra has no Dock icon.
1. **Install & Start Server** — runs bundled `ai-memory init` if
`~/Library/Application Support/ai-memory/config.toml` is missing, renders
`packaging/launchd/com.github.akitaonrails.ai-memory.plist`, and bootstraps
the same `com.github.akitaonrails.ai-memory` label as the hand-installed
LaunchAgent below. Do not skip this click; the app does not start the
server on first launch by itself.
2. The status item is green when `GET /admin/status` succeeds, yellow when the
server is up but the LLM/embedding role is in error or the write queue is
non-empty, red when the server is unreachable.
3. **Open Web UI**, **Show Status…**, **Open Config**, **Open Data Directory**,
and **Open Logs** call the existing browser UI, the bundled CLI, and Finder.
Wire an agent with the **bundled** binary so `install-hooks` finds the sibling
`hooks/` tree (#546):
```bash
BIN="/Applications/AI Memory.app/Contents/Resources/runtime/ai-memory"
"$BIN" install-mcp --client claude-code --apply
"$BIN" install-hooks --agent claude-code --apply
```
Memory, config, models, and logs stay outside the bundle
(`~/Library/Application Support/ai-memory` and `~/Library/Logs/ai-memory`).
Replacing the `.app` is an update; it does not rewrite that tree. Details:
[`companions/ai-memory-macos/README.md`](../companions/ai-memory-macos/README.md).
Notarization and a Homebrew cask are not part of this companion yet.
## Run as a Login Service (launchd)
Every scenario above leaves the server in the foreground: close that terminal
and capture stops. The macOS counterpart of a systemd user unit is a
Scenarios A–C leave the server in the foreground: close that terminal and
capture stops. Scenario D already installs this LaunchAgent from **Install &
Start Server**. The macOS counterpart of a systemd user unit is a
**LaunchAgent** — a plist in `~/Library/LaunchAgents/` that the per-user
launchd domain starts at login and restarts on failure. The repo ships one at
`packaging/launchd/com.github.akitaonrails.ai-memory.plist`, and the macOS
@@ -329,6 +390,16 @@ wrapper's `posix` shell-script path does not. Re-run `install-hooks --agent
reuse that stale copy instead and report success. Run `install-hooks` via
the real extracted/built path instead of the symlink
until that's fixed.
- **No Dock icon after opening AI Memory.app:** that is the menu extra. Look
in the menu bar (brain / status symbol), not the Dock.
- **Menu extra says "Runtime not bundled":** `swift run` from the package
does not stage `ai-memory` + `hooks/`. Use
`./companions/ai-memory-macos/build.sh`.
- **Install & Start does not turn the item green:** check
`~/Library/Logs/ai-memory/stderr.log` and
`launchctl print gui/$(id -u)/com.github.akitaonrails.ai-memory`. The app
never writes into its own bundle; a missing `config.toml` is created under
`~/Library/Application Support/ai-memory` by bundled `ai-memory init`.
## Suggested Test Checklist
@@ -343,6 +414,11 @@ wrapper's `posix` shell-script path does not. Re-run `install-hooks --agent
5. Launch the agent, call `memory_status`, send a prompt, then confirm capture
(`ai-memory status` shows non-zero observations, or query the SQLite
`observations` table).
6. **Scenario D:** after **Install & Start Server**, the menu extra is green;
**Open Web UI** loads `http://127.0.0.1:49374/web`;
`launchctl print gui/$(id -u)/com.github.akitaonrails.ai-memory` shows
`state = running`; replacing the `.app` leaves
`~/Library/Application Support/ai-memory` untouched.
Report which scenario you used, your chip (Apple Silicon / Intel), the agent and
version, and whether hooks executed or failed with a connect/resolve error.
+1 -1
View File
@@ -7,7 +7,7 @@
| Area | Status | Notes |
|---|---|---|
| Linux | Supported | Primary Docker/server target and CI platform. Published Docker images support `linux/amd64` and `linux/arm64`. Native Arch/AUR packages include system and user systemd units. |
| macOS | Supported | Workspace tests run in CI; tagged releases publish native `ai-memory-macos-aarch64.tar.gz` and `ai-memory-macos-x86_64.tar.gz` binaries, which include a per-user launchd agent. The native binary is the recommended path on Apple Silicon. See [`docs/macos.md`](macos.md). |
| macOS | Supported | Workspace tests run in CI; tagged releases publish native `ai-memory-macos-aarch64.tar.gz` and `ai-memory-macos-x86_64.tar.gz` binaries, which include a per-user launchd agent. The native binary is the recommended path on Apple Silicon. A source-built menu bar app (`companions/ai-memory-macos`) bundles that binary, starts the LaunchAgent, and opens `/web` and `ai-memory status` — README quick-start and [`docs/macos.md`](macos.md) Scenario D. |
| Windows via WSL2 | Supported | Use the Linux install path inside WSL2 when the agent runs there. |
| Native Windows | Experimental | Tagged releases publish `ai-memory-windows-x86_64.zip` with `ai-memory.exe`; Docker Desktop wrapper and source builds are also available. Local supported profiles default to host-native hook commands; Claude Code may use its Windows exec form, while other agents use native single command strings matching their hook schema. PowerShell/Git Bash scripts are compatibility fallbacks. See [`docs/windows.md`](windows.md). |
| Claude Code | Supported | MCP config + lifecycle hooks; native commands enforce capture exclusions. `install-mcp --session-aware` optionally enables per-session auto-scope isolation through a local stdio bridge. Optionally captures the assistant's final turn on `Stop` when installed with `--capture-assistant` and the server enables `capture_assistant` (double opt-in, off by default). |
+3
View File
@@ -385,6 +385,9 @@ Start the server with `--enable-web` and open
ai-memory serve --transport http --bind 127.0.0.1:49374 --enable-web
```
On macOS the [menu bar app](macos.md#scenario-d-menu-bar-app) already starts
the LaunchAgent with `--enable-web`; **Open Web UI** opens that same URL.
Docker compose users can add the flag to the service command:
```yaml