mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
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:
@@ -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
|
||||
@@ -1,6 +1,9 @@
|
||||
# Rust build artefacts.
|
||||
/target/
|
||||
/companions/*/target/
|
||||
/companions/*/.build/
|
||||
/companions/*/.swiftpm/
|
||||
/companions/*/dist/
|
||||
/dist/
|
||||
**/*.rs.bk
|
||||
**/*.rs.orig
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
dist/
|
||||
*.xcodeproj/xcuserdata/
|
||||
*.xcworkspace/xcuserdata/
|
||||
@@ -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>
|
||||
@@ -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")]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
}
|
||||
}
|
||||
@@ -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&b<c>"))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Executable
+43
@@ -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\""
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user