Compare commits

...

10 Commits

Author SHA1 Message Date
b7ce929c50 feat: add one-step installer script and enhance README with installation instructions 2026-05-20 16:24:47 -06:00
4000747641 feat: enhance AI Workspace Menu Bar App with packaging scripts, login management, and service status improvements 2026-05-20 16:11:45 -06:00
ab36e4b465 feat: add initial implementation of AI Workspace macOS Menu Bar App with service management functionality 2026-05-20 15:52:58 -06:00
8c58210c0c feat: enhance service status reporting with JSON output and add related tests 2026-05-20 15:29:21 -06:00
b21889c4ab feat: add AI Workspace Menu Bar App design and enhance MCP server with resource definitions and read functionality 2026-05-20 15:22:37 -06:00
cfd61bdee3 feat: add AI Workspace context MCP configuration and enhance communication channel filtering in server 2026-05-20 15:16:41 -06:00
d3e909d39e feat: implement AI Workspace context MCP server with read-only access and add related tests 2026-05-20 14:57:54 -06:00
9f8d3b975f feat: enhance service manager with manifest validation, logging rotation, and command existence checks 2026-05-20 14:51:09 -06:00
1121433db8 feat: implement AI Workspace service manager with lifecycle control for local services 2026-05-20 14:43:52 -06:00
eb11bb9442 feat: update Mattermost integration documentation with MCP support guidelines and focused reader mode instructions 2026-05-20 14:30:43 -06:00
27 changed files with 2636 additions and 1 deletions

8
.agents/mcp_config.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"aiw-context-mcp": {
"url": "http://127.0.0.1:8765/mcp",
"serverUrl": "http://127.0.0.1:8765/mcp"
}
}
}

3
.gitignore vendored
View File

@@ -41,3 +41,6 @@ project-knowledge/.obsidian/cache/
# Antigravity CLI local workspace configuration # Antigravity CLI local workspace configuration
.antigravitycli/ .antigravitycli/
# AI Workspace local service runtime
.aiw/runtime/

View File

@@ -150,6 +150,8 @@ Repeatable working guides for:
Helpers for automation around memory access, context generation, communication drafting, and imports. Helpers for automation around memory access, context generation, communication drafting, and imports.
- `scripts/aiw/` -> local AI Workspace service manager for profile services such as the Mattermost proxy mirror, Photo Inbox, and context MCP
- `scripts/mcp/` -> local MCP servers that expose bounded read-only workspace context to AI clients
- `scripts/memory/` -> project-agnostic interface for canonical memory - `scripts/memory/` -> project-agnostic interface for canonical memory
- `scripts/obsidian/` -> current Obsidian adapter and URI helpers - `scripts/obsidian/` -> current Obsidian adapter and URI helpers
- `scripts/mattermost/` -> live communication connector - `scripts/mattermost/` -> live communication connector

View File

@@ -1,7 +1,7 @@
--- ---
type: agent-integration type: agent-integration
status: active status: active
updated: 2026-05-19 updated: 2026-05-20
tags: tags:
- communication - communication
- evidence - evidence
@@ -27,6 +27,8 @@ Mattermost is the current live communication connector.
- Latest-message requests are read-first. The agent may identify a memory update candidate, but should not edit `project-knowledge/` from the latest-message command unless the user explicitly asks to promote the fact. - Latest-message requests are read-first. The agent may identify a memory update candidate, but should not edit `project-knowledge/` from the latest-message command unless the user explicitly asks to promote the fact.
- Standup generation is a separate required-refresh flow: it must fetch Mattermost before drafting, even though general prompts should not sync automatically. - Standup generation is a separate required-refresh flow: it must fetch Mattermost before drafting, even though general prompts should not sync automatically.
- Standup reads should use the focused reader mode, `scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD`, which reads date-bucketed previous-workday/today records and should use the active profile's configured `AIW_MATTERMOST_CONTEXT_CHANNELS` when available. Avoid loading broad mirror `latest.md` into standup prompts because it may include stale or unrelated channels and waste tokens. Keep project-specific channel names out of reusable connector code. - Standup reads should use the focused reader mode, `scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD`, which reads date-bucketed previous-workday/today records and should use the active profile's configured `AIW_MATTERMOST_CONTEXT_CHANNELS` when available. Avoid loading broad mirror `latest.md` into standup prompts because it may include stale or unrelated channels and waste tokens. Keep project-specific channel names out of reusable connector code.
- If adding MCP support for Mattermost, treat it as a read-only query wrapper over the existing proxy mirror and `read-context.py`, not as a replacement for the capture/mirror pipeline. Keep the mirror's file layout as canonical raw evidence and expose only narrow tools such as latest, standup/date, channel, and thread reads with channel filters and limits.
- Do not build a write-capable Mattermost MCP or expose tokens, cookies, raw headers, or broad unfiltered raw dumps through MCP. MCP output should remain evidence for agent reasoning; promotion to `project-knowledge/` still follows normal memory rules.
- If the proxy mirror is running, treat it as fresher than legacy `mattermost-latest.md` / generated JSONL. Do not ignore mirror evidence merely because a legacy sync command also ran. - If the proxy mirror is running, treat it as fresher than legacy `mattermost-latest.md` / generated JSONL. Do not ignore mirror evidence merely because a legacy sync command also ran.
- Do not refresh Mattermost just because a prompt mentions a manager or stakeholder. - Do not refresh Mattermost just because a prompt mentions a manager or stakeholder.
- Treat document review, message polishing, translation, and "does this align with Jeff's expectations?" prompts as normal drafting tasks unless the user explicitly asks for the latest message or fresh Mattermost evidence. - Treat document review, message polishing, translation, and "does this align with Jeff's expectations?" prompts as normal drafting tasks unless the user explicitly asks for the latest message or fresh Mattermost evidence.

9
apps/mac/AIWorkspace/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.build/
.swiftpm/
dist/
DerivedData/
*.xcodeproj
*.xcworkspace
xcuserdata/
*.xcuserstate
.DS_Store

View File

@@ -0,0 +1,19 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "AIWorkspace",
platforms: [
.macOS(.v13)
],
products: [
.executable(name: "AIWorkspace", targets: ["AIWorkspace"])
],
targets: [
.executableTarget(
name: "AIWorkspace",
path: "Sources"
)
]
)

View File

@@ -0,0 +1,77 @@
# AI Workspace macOS Menu Bar App
Minimal SwiftUI `MenuBarExtra` app for controlling local AI Workspace services.
The app is intentionally a thin UI over the service manager. It reads live status from:
```bash
python3 scripts/aiw/services.py status --profile fidelity --json
```
and sends lifecycle actions through `scripts/aiw/services.py`.
## Build
```bash
swift build --package-path apps/mac/AIWorkspace
```
## Package as `.app`
```bash
apps/mac/AIWorkspace/scripts/package-app.sh
```
Install to `~/Applications/AIWorkspace.app`:
```bash
apps/mac/AIWorkspace/scripts/package-app.sh --install
```
One-step local install, optionally enabling start at login and opening the app:
```bash
apps/mac/AIWorkspace/scripts/install.sh --start-at-login --open
```
## Start at login
After installing the app bundle:
```bash
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
```
To remove the login item:
```bash
apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh
```
## Run during development
```bash
swift run --package-path apps/mac/AIWorkspace AIWorkspace
```
## Current actions
- Refresh status
- Start Fidelity services
- Stop Fidelity services
- Restart Context MCP
- Open Mattermost through the service manager
- Open Mattermost through the local proxy-managed launcher
- Run Doctor
- Copy Doctor JSON
- Copy Photo Inbox URL
- Copy recent logs
- Open MCP Health
- Open logs folder
- Open project knowledge
## Notes
- This is not yet packaged as a signed `.app` bundle.
- Start at login should be implemented later through a LaunchAgent or app login item.
- The app should remain a UI layer; service lifecycle remains in `scripts/aiw/services.py`.

View File

@@ -0,0 +1,455 @@
import AppKit
import Foundation
import SwiftUI
private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true)
private let defaultProfile = "fidelity"
@main
struct AIWorkspaceApp: App {
@StateObject private var model = ServiceStatusModel(profile: defaultProfile)
var body: some Scene {
MenuBarExtra {
ServiceMenuView(model: model)
.task {
await model.refresh()
}
} label: {
Label("AI Workspace", systemImage: model.menuBarSymbol)
}
.menuBarExtraStyle(.window)
}
}
@MainActor
final class ServiceStatusModel: ObservableObject {
@Published private(set) var report: StatusReport?
@Published private(set) var lastError: String?
@Published private(set) var lanIP: String?
@Published private(set) var isRefreshing = false
let profile: String
init(profile: String) {
self.profile = profile
}
var menuBarSymbol: String {
guard let report else { return "circle.dashed" }
if report.services.contains(where: { $0.status == "unhealthy" || ($0.enabled && $0.status == "stopped") }) {
return "exclamationmark.triangle"
}
if report.services.contains(where: { $0.status == "running" }) {
return "checkmark.circle"
}
return "circle"
}
func refresh() async {
isRefreshing = true
defer { isRefreshing = false }
do {
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
report = try JSONDecoder().decode(StatusReport.self, from: data)
lanIP = await NetworkInfo.primaryLANIP()
lastError = nil
} catch {
lastError = String(describing: error)
}
}
func startProfile() {
runAction(["start", "--profile", profile])
}
func stopProfile() {
runAction(["stop", "--profile", profile])
}
func restartMCP() {
runAction(["restart", "aiw-context-mcp", "--profile", profile])
}
func runDoctor() {
runAction(["doctor", "--profile", profile])
}
func copyDoctorJSON() {
Task {
do {
let data = try await ServiceManager.run(["doctor", "--profile", profile, "--json"])
copyToPasteboard(String(data: data, encoding: .utf8) ?? "")
} catch {
lastError = String(describing: error)
}
}
}
func copyPhotoInboxURL() {
let host = lanIP ?? "127.0.0.1"
copyToPasteboard("http://\(host):8787/upload")
}
func copyRecentLogs() {
Task {
do {
var chunks: [String] = []
for service in report?.services ?? [] {
let data = try await ServiceManager.run(["logs", service.name, "--profile", profile, "--lines", "30"])
if let text = String(data: data, encoding: .utf8), !text.isEmpty {
chunks.append(text)
}
}
copyToPasteboard(chunks.joined(separator: "\n\n"))
} catch {
lastError = String(describing: error)
}
}
}
func openMCPHealth() {
if let url = URL(string: "http://127.0.0.1:8765/health") {
NSWorkspace.shared.open(url)
}
}
func openLogsFolder() {
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(".aiw/runtime/logs", isDirectory: true))
}
func openProjectKnowledge() {
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent("project-knowledge", isDirectory: true))
}
func openMattermost() {
runAction(["start", "mattermost-desktop", "--profile", profile])
}
private func copyToPasteboard(_ value: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
private func runAction(_ arguments: [String]) {
Task {
do {
_ = try await ServiceManager.run(arguments)
await refresh()
} catch {
lastError = String(describing: error)
}
}
}
}
struct ServiceMenuView: View {
@ObservedObject var model: ServiceStatusModel
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text("AI Workspace")
.font(.title3.bold())
Text("Profile: \(model.profile)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
Task { await model.refresh() }
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Refresh")
}
Divider()
if let report = model.report {
VStack(spacing: 8) {
ForEach(report.services) { service in
ServiceRow(service: service)
}
}
} else if let error = model.lastError {
Label("Status unavailable", systemImage: "exclamationmark.triangle")
Text(error)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(3)
} else {
Label("Loading status...", systemImage: "hourglass")
}
if let lanIP = model.lanIP {
HStack(spacing: 8) {
Image(systemName: "wifi")
.foregroundStyle(.secondary)
Text("Photo Inbox LAN")
Spacer()
Text("http://\(lanIP):8787/upload")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
.textSelection(.enabled)
}
Divider()
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ActionButton(title: "Start Fidelity", systemImage: "play.fill", action: model.startProfile)
ActionButton(title: "Stop Fidelity", systemImage: "stop.fill", role: .destructive, action: model.stopProfile)
ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", action: model.restartMCP)
ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", action: model.openMattermost)
}
Divider()
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ActionButton(title: "Run Doctor", systemImage: "stethoscope", action: model.runDoctor)
ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", action: model.copyDoctorJSON)
ActionButton(title: "Copy Photo URL", systemImage: "link", action: model.copyPhotoInboxURL)
ActionButton(title: "Copy Logs", systemImage: "doc.text", action: model.copyRecentLogs)
ActionButton(title: "MCP Health", systemImage: "heart.text.square", action: model.openMCPHealth)
ActionButton(title: "Logs Folder", systemImage: "folder", action: model.openLogsFolder)
}
ActionButton(title: "Open Project Knowledge", systemImage: "books.vertical", action: model.openProjectKnowledge)
Divider()
HStack {
if let error = model.lastError {
Label(error, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
}
Spacer()
Button("Quit") {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("q")
}
}
.padding(18)
.frame(width: 430)
}
}
struct ActionButton: View {
let title: String
let systemImage: String
var role: ButtonRole?
let action: () -> Void
var body: some View {
Button(role: role, action: action) {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
struct ServiceRow: View {
let service: ServiceStatus
var body: some View {
HStack(spacing: 10) {
Image(systemName: symbol)
.foregroundStyle(color)
.frame(width: 16, alignment: .center)
VStack(alignment: .leading, spacing: 2) {
Text(service.displayName)
.font(.body.weight(.medium))
Text(service.detail)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 12)
StatusBadge(text: service.compactStatus, color: color)
}
.padding(.vertical, 7)
.padding(.horizontal, 10)
.background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.help(service.detail)
}
private var symbol: String {
switch service.status {
case "running": "checkmark.circle"
case "launcher": "arrow.up.forward.app"
case "externally running": "link.circle"
case "unhealthy": "exclamationmark.triangle"
case "disabled": "minus.circle"
default: "xmark.circle"
}
}
private var color: Color {
switch service.status {
case "running", "launcher", "externally running": .green
case "unhealthy": .orange
case "disabled": .secondary
default: .red
}
}
}
struct StatusBadge: View {
let text: String
let color: Color
var body: some View {
Text(text)
.font(.caption.weight(.semibold))
.foregroundStyle(color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.14), in: Capsule())
}
}
enum ServiceManager {
static func run(_ arguments: [String]) async throws -> Data {
try await Task.detached(priority: .userInitiated) {
let process = Process()
process.currentDirectoryURL = workspaceRoot
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["python3", "scripts/aiw/services.py"] + arguments
let output = Pipe()
let error = Pipe()
process.standardOutput = output
process.standardError = error
try process.run()
process.waitUntilExit()
let data = output.fileHandleForReading.readDataToEndOfFile()
let errorData = error.fileHandleForReading.readDataToEndOfFile()
guard process.terminationStatus == 0 else {
let message = String(data: errorData.isEmpty ? data : errorData, encoding: .utf8) ?? "service command failed"
throw ServiceManagerError.commandFailed(message.trimmingCharacters(in: .whitespacesAndNewlines))
}
return data
}.value
}
}
enum NetworkInfo {
static func primaryLANIP() async -> String? {
for interface in ["en0", "en1"] {
if let value = try? await runIPConfig(interface), !value.isEmpty {
return value
}
}
return nil
}
private static func runIPConfig(_ interface: String) async throws -> String {
try await Task.detached(priority: .utility) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/sbin/ipconfig")
process.arguments = ["getifaddr", interface]
let output = Pipe()
process.standardOutput = output
process.standardError = Pipe()
try process.run()
process.waitUntilExit()
let data = output.fileHandleForReading.readDataToEndOfFile()
guard process.terminationStatus == 0 else { return "" }
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}.value
}
}
enum ServiceManagerError: LocalizedError {
case commandFailed(String)
var errorDescription: String? {
switch self {
case .commandFailed(let message): message
}
}
}
struct StatusReport: Decodable {
let profile: String
let workspace: String
let runtime: String
let services: [ServiceStatus]
}
struct ServiceStatus: Decodable, Identifiable {
var id: String { name }
let name: String
let enabled: Bool
let kind: String
let status: String
let pid: Int?
let command: [String]
let health: Health
let state: [String: JSONValue]
var displayName: String {
switch name {
case "aiw-context-mcp": "Context MCP"
case "mattermost-proxy": "Mattermost Proxy"
case "mattermost-desktop": "Mattermost Desktop via Proxy"
case "photo-inbox": "Photo Inbox"
default: name
}
}
var detail: String {
if name == "mattermost-desktop" {
return "Launches Mattermost through local proxy 127.0.0.1:8080"
}
return health.detail
}
var compactStatus: String {
switch status {
case "externally running": "external"
default: status
}
}
}
struct Health: Decodable {
let ok: Bool?
let detail: String
}
enum JSONValue: Decodable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
} else {
self = .array(try container.decode([JSONValue].self))
}
}
}

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
APP_PATH="${APP_PATH:-$HOME/Applications/AIWorkspace.app}"
PLIST_PATH="$HOME/Library/LaunchAgents/com.aiworkspace.menu.plist"
if [[ ! -d "$APP_PATH" ]]; then
echo "App not found: $APP_PATH" >&2
echo "Run apps/mac/AIWorkspace/scripts/package-app.sh --install first." >&2
exit 1
fi
mkdir -p "$HOME/Library/LaunchAgents"
cat > "$PLIST_PATH" <<PLIST
<?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>Label</key>
<string>com.aiworkspace.menu</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/open</string>
<string>-a</string>
<string>$APP_PATH</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>$HOME/Library/Logs/AIWorkspace-menu.log</string>
<key>StandardErrorPath</key>
<string>$HOME/Library/Logs/AIWorkspace-menu.err.log</string>
</dict>
</plist>
PLIST
launchctl unload "$PLIST_PATH" >/dev/null 2>&1 || true
launchctl load "$PLIST_PATH"
echo "Installed and loaded LaunchAgent: $PLIST_PATH"

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_LOGIN_ITEM=0
OPEN_APP=0
for arg in "$@"; do
case "$arg" in
--start-at-login)
INSTALL_LOGIN_ITEM=1
;;
--open)
OPEN_APP=1
;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: $0 [--start-at-login] [--open]" >&2
exit 1
;;
esac
done
"$SCRIPT_DIR/package-app.sh" --install
if [[ "$INSTALL_LOGIN_ITEM" == "1" ]]; then
"$SCRIPT_DIR/install-start-at-login.sh"
fi
if [[ "$OPEN_APP" == "1" ]]; then
open "$HOME/Applications/AIWorkspace.app"
fi
echo "AI Workspace app install complete."

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
WORKSPACE_ROOT="$(cd "$APP_ROOT/../../.." && pwd)"
CONFIGURATION="${CONFIGURATION:-release}"
APP_NAME="AIWorkspace"
BUILD_DIR="$APP_ROOT/.build/$CONFIGURATION"
OUTPUT_DIR="$APP_ROOT/dist"
APP_BUNDLE="$OUTPUT_DIR/$APP_NAME.app"
INSTALL_DIR="${INSTALL_DIR:-$HOME/Applications}"
INSTALL=0
if [[ "${1:-}" == "--install" ]]; then
INSTALL=1
fi
swift build --package-path "$APP_ROOT" -c "$CONFIGURATION"
rm -rf "$APP_BUNDLE"
mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources"
cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
cat > "$APP_BUNDLE/Contents/Info.plist" <<PLIST
<?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>$APP_NAME</string>
<key>CFBundleIdentifier</key>
<string>com.aiworkspace.menu</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>AI Workspace</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Local AI Workspace utility.</string>
</dict>
</plist>
PLIST
echo "Built $APP_BUNDLE"
if [[ "$INSTALL" == "1" ]]; then
mkdir -p "$INSTALL_DIR"
rm -rf "$INSTALL_DIR/$APP_NAME.app"
cp -R "$APP_BUNDLE" "$INSTALL_DIR/$APP_NAME.app"
echo "Installed $INSTALL_DIR/$APP_NAME.app"
fi

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
PLIST_PATH="$HOME/Library/LaunchAgents/com.aiworkspace.menu.plist"
launchctl unload "$PLIST_PATH" >/dev/null 2>&1 || true
rm -f "$PLIST_PATH"
echo "Removed LaunchAgent: $PLIST_PATH"

View File

@@ -0,0 +1,47 @@
# macOS Installation Model
## How production macOS utilities commonly do it
Apps such as Cloudflare WARP, VPN clients, Docker Desktop, and device agents usually separate:
- a user-facing app or menu bar app;
- one or more background services;
- launchd configuration for automatic startup;
- privileged helpers only when system-level networking, drivers, packet filtering, or protected paths are required.
Common mechanisms:
- `LaunchAgent` in `~/Library/LaunchAgents` for per-user background/login startup.
- `LaunchDaemon` in `/Library/LaunchDaemons` for root/system services.
- `SMAppService` / login items for sandboxed or App Store-aligned apps.
- Privileged helper tools via `SMJobBless` when admin-level installation is required.
- `.pkg` installers when the install needs privileged locations, daemons, receipts, or managed deployment.
## Recommended AI Workspace approach
Use a staged model:
1. **Current local developer install**
- Build a real `.app` bundle into `apps/mac/AIWorkspace/dist/`.
- Install to `~/Applications/AIWorkspace.app`.
- Install a per-user `LaunchAgent` for start at login.
2. **Production-ready local install**
- Keep using a per-user LaunchAgent because services are local user tools and do not require root.
- Add a one-step installer script that builds, installs, optionally enables start at login, and opens the app.
- Avoid privileged helpers until a real system-level requirement appears.
3. **Future polished distribution**
- Create a signed/notarized `.app` or `.pkg`.
- Consider `SMAppService` for login item management from inside the app.
- Add a small daemon API if the UI needs richer lifecycle control than shelling out to `services.py`.
## Why not LaunchDaemon now
The current services are user-context services:
- Mattermost Desktop launching must happen in the user's GUI session.
- Photo Inbox writes to user-owned folders and uses clipboard/notifications.
- The MCP and proxy bind localhost ports and do not require root.
A root daemon would add unnecessary permission prompts and security risk. A per-user LaunchAgent is the correct production-leaning step for this stage.

View File

@@ -0,0 +1,81 @@
# AI Workspace Menu Bar App Design
## Goal
Provide a small native macOS control surface for the AI Workspace Service Manager.
The app should not reimplement service logic. It should call the service manager/daemon API or CLI and display status, actions, and diagnostics.
## Recommended Shape
- SwiftUI `MenuBarExtra` app.
- Local-only, no cloud dependency.
- Start at login optional through `LaunchAgent` later.
- Read-only status by default; explicit user actions for start/stop/restart.
- The UI should not own MCP profile selection. AI clients pass profiles to MCP tools/resources; the menu bar app is only a local operations surface.
## Initial UI
```text
AI Workspace ▾
Profile: Fidelity
Services
✓ Context MCP Running
✓ Mattermost Proxy Running
✓ Mattermost Desktop Launched
✓ Photo Inbox Running
Actions
Start Fidelity
Stop Fidelity
Restart Context MCP
Open Mattermost
Open Photo Inbox Folder
Copy Photo Inbox Upload URL
Copy Recent Logs
Open Project Knowledge
Open Logs
Diagnostics
Run Doctor
Copy Doctor JSON
Show Recent Errors
Settings
Start at Login
Open Config Folder
```
## Backend Contract
The first version can shell out to:
```bash
python3 scripts/aiw/services.py status --profile fidelity
python3 scripts/aiw/services.py status --profile fidelity --json
python3 scripts/aiw/services.py doctor --profile fidelity --json
python3 scripts/aiw/services.py start --profile fidelity
python3 scripts/aiw/services.py stop --profile fidelity
python3 scripts/aiw/services.py restart aiw-context-mcp --profile fidelity
```
Use `status --json` for frequent UI refreshes and `doctor --json` for explicit diagnostics. Longer term, prefer a small local daemon HTTP/Unix-socket API so the UI does not parse terminal text.
## Production-Ready Rules
- Do not store secrets in the app bundle.
- Do not expose services beyond localhost unless explicitly configured.
- Show whether a process is managed or externally running.
- Surface missing dependencies from doctor checks.
- Never let the app promote project memory automatically.
- Keep capture services and context MCP separate; the app only orchestrates lifecycle.
## Implementation Phases
1. CLI-backed SwiftUI menu bar app using `status --json` for live status and `doctor --json` for diagnostics.
2. Add profile selector and service action buttons.
3. Add LaunchAgent support for start at login.
4. Replace shell parsing with a daemon API if daily use proves stable.
See `multi-profile-runtime-model.md` for how this should evolve when multiple profiles run in parallel.

View File

@@ -0,0 +1,49 @@
# Multi-Profile Runtime Model
## Principle
Profiles are selected by clients and services, not by the menu bar UI.
The menu bar app is a local operations surface for the current operator machine. It can monitor the local services that are enabled now, but it should not become the source of truth for which project an AI client is allowed to query.
## Desired Model
- MCP clients choose the profile at call time, for example `{"profile":"fidelity"}`.
- Multiple profiles may run in parallel when their service ports and inbox paths do not conflict.
- Capture services can be profile-specific or shared:
- shared service: one Mattermost mirror with profile-scoped query filters;
- profile-specific service: separate mirror/output path/port per profile.
- The MCP query layer should remain profile-aware and read from profile manifests/config.
- The menu bar app should show local runtime health, not force a single global profile selection.
## Current Fidelity Setup
The first menu bar version targets the Fidelity service set because that is the active local workflow. This does not prevent MCP clients from querying other profiles when those profiles have canonical memory or context-source config.
## Parallel Profile Requirements
Before running another profile in parallel, define unique values for any conflicting service:
- MCP HTTP port if using separate MCP instances.
- Mattermost proxy listen port if using separate proxy instances.
- Photo Inbox port if using separate upload receivers.
- Mirror/inbox output directory.
- Profile-specific context channels.
Example future split:
```text
fidelity:
aiw-context-mcp: 127.0.0.1:8765
mattermost-proxy: 127.0.0.1:8080
photo-inbox: 0.0.0.0:8787
it-support:
aiw-context-mcp: same shared MCP, profile argument selects context
mattermost-proxy: 127.0.0.1:8081 if a separate capture session is needed
photo-inbox: 0.0.0.0:8788 if a separate receiver is needed
```
## Menu Bar Direction
The menu bar app should evolve toward showing service groups or all running profiles, but not a manual profile selector for MCP query behavior. Profile selection belongs in the MCP tool/resource arguments and client prompts.

View File

@@ -0,0 +1,28 @@
# AI Workspace Service Manager
## Principle
The AI Workspace should unify local service lifecycle without collapsing service responsibilities.
- Service manager: starts, stops, checks, and logs local services.
- Context MCP: exposes bounded read-only context to AI clients.
- Capture services: produce local evidence such as Mattermost mirror records or photo inbox files.
- Canonical memory remains under `project-knowledge/` and is updated by the agent using memory rules.
## Service Types
- `process`: long-running local command with PID, logs, and optional health check.
- `app-launcher`: one-shot command that opens an application or helper.
- `mcp`: a process service that exposes an MCP-compatible context interface.
## Profile Manifests
Project-specific services should be declared under `profiles/<profile>/services.json`.
Manifests should avoid project facts in reusable code. Profile-specific channel names, paths, ports, and enabled services belong in the profile manifest or local `.env` files.
## Responsibility Boundaries
Do not put capture lifecycle inside the context MCP. The MCP should query local evidence produced by capture services. The service manager may start both the MCP and capture services as one profile-level operation.
This keeps the same core usable for Fidelity, IT support, or another project with different communication sources.

View File

@@ -23,6 +23,11 @@
"OBSIDIAN_HOST": "127.0.0.1", "OBSIDIAN_HOST": "127.0.0.1",
"OBSIDIAN_PORT": "27124" "OBSIDIAN_PORT": "27124"
} }
},
"aiw-context-mcp": {
"type": "remote",
"url": "http://127.0.0.1:8765/mcp",
"enabled": true
} }
}, },
"permission": { "permission": {

View File

@@ -0,0 +1,15 @@
{
"profile": "fidelity",
"communication_sources": {
"mattermost": {
"type": "mattermost_mirror",
"context_channels": [
"fidelity-preguntas",
"fidelity-standup",
"fidelity-code-review",
"fidelity-interface-meetings-on-calendar-outlook-team-etc",
"dm-david--jeff"
]
}
}
}

View File

@@ -0,0 +1,69 @@
{
"profile": "fidelity",
"description": "Local AI Workspace services for the Fidelity profile.",
"services": {
"aiw-context-mcp": {
"enabled": true,
"kind": "mcp",
"description": "Read-only AI Workspace context MCP server.",
"command": ["python3", "scripts/mcp/aiw-context-mcp/server.py", "--transport", "http"],
"groups": ["mcp", "context"],
"restart": "on-failure",
"doctor": {
"required_commands": ["python3"],
"required_paths": ["scripts/mcp/aiw-context-mcp/server.py"]
},
"health": {
"type": "http",
"url": "http://127.0.0.1:8765/health"
}
},
"mattermost-proxy": {
"enabled": true,
"kind": "process",
"description": "Local mitmproxy mirror that captures normalized Mattermost evidence.",
"command": ["scripts/mattermost-proxy/run-mirror.sh"],
"groups": ["communication", "mattermost", "capture"],
"restart": "on-failure",
"doctor": {
"required_commands": ["mitmdump"],
"optional_paths": ["scripts/mattermost-proxy/.env"]
},
"health": {
"type": "tcp",
"host": "127.0.0.1",
"port": 8080
}
},
"mattermost-desktop": {
"enabled": true,
"kind": "app-launcher",
"description": "Launch Mattermost Desktop through the local proxy.",
"command": ["scripts/mattermost-proxy/launch-mattermost.sh"],
"groups": ["communication", "mattermost"],
"depends_on": ["mattermost-proxy"],
"restart": "never",
"doctor": {
"required_paths": ["/Applications/Mattermost.app"],
"optional_paths": ["scripts/mattermost-proxy/.env"]
}
},
"photo-inbox": {
"enabled": true,
"kind": "process",
"description": "macOS HTTP receiver for iPhone photo uploads into the local inbox.",
"command": ["scripts/iphone-photo-inbox/run.sh"],
"groups": ["inbox", "photos", "capture"],
"restart": "on-failure",
"doctor": {
"required_commands": ["python3"],
"optional_commands": ["swiftc"],
"optional_paths": ["scripts/iphone-photo-inbox/.env"]
},
"health": {
"type": "http",
"url": "http://127.0.0.1:8787/health"
}
}
}
}

View File

@@ -2,6 +2,7 @@
This directory contains helpers that automate: This directory contains helpers that automate:
- AI Workspace local service lifecycle
- context aggregation - context aggregation
- canonical memory access - canonical memory access
- standup generation - standup generation
@@ -15,6 +16,25 @@ The project-agnostic memory interface lives in:
Recommended commands: Recommended commands:
```bash
python3 scripts/aiw/services.py status --profile fidelity
python3 scripts/aiw/services.py doctor --profile fidelity
python3 scripts/aiw/services.py start --profile fidelity
```
The service manager reads `profiles/<profile>/services.json` and manages local
services such as the Mattermost proxy mirror and Photo Inbox. Runtime PID/state
and logs stay under `.aiw/runtime/`.
The local context MCP server lives in:
- `scripts/mcp/aiw-context-mcp/`
It exposes read-only workspace context to local AI clients and is started as the
`aiw-context-mcp` service for profiles that enable it.
Recommended memory commands:
```bash ```bash
bash scripts/memory/memory.sh health bash scripts/memory/memory.sh health
bash scripts/memory/memory.sh search "PDIAP-15765" bash scripts/memory/memory.sh search "PDIAP-15765"

50
scripts/aiw/README.md Normal file
View File

@@ -0,0 +1,50 @@
# AI Workspace Service Manager
The service manager is the local lifecycle layer for AI Workspace services.
It reads `profiles/<profile>/services.json`, starts/stops enabled services, records logs under `.aiw/runtime/logs/`, and keeps PID/state files under `.aiw/runtime/`.
## Common commands
```bash
python3 scripts/aiw/services.py status --profile fidelity
python3 scripts/aiw/services.py status --profile fidelity --json
python3 scripts/aiw/services.py doctor --profile fidelity
python3 scripts/aiw/services.py doctor --profile fidelity --json
python3 scripts/aiw/services.py start --profile fidelity
python3 scripts/aiw/services.py stop --profile fidelity
python3 scripts/aiw/services.py logs mattermost-proxy --profile fidelity
```
Start a subset by group:
```bash
python3 scripts/aiw/services.py start --profile fidelity --group communication
python3 scripts/aiw/services.py start --profile fidelity --group inbox
```
## Current Fidelity services
- `mattermost-proxy`: runs the local Mattermost proxy mirror.
- `mattermost-desktop`: launches Mattermost Desktop through the proxy.
- `photo-inbox`: runs the local HTTP photo receiver.
- `aiw-context-mcp`: read-only context MCP server for local AI clients.
The service manager unifies startup and status. It does not move capture behavior into the MCP.
## Robustness features
- Manifest validation before lifecycle actions.
- Dependency-aware startup through `depends_on`.
- Managed PID/state files under `.aiw/runtime/`.
- Per-service logs under `.aiw/runtime/logs/`.
- Simple log rotation before service start.
- TCP/HTTP health checks.
- Doctor checks for required commands and paths declared in the profile manifest.
- Protection against starting duplicate services when a matching health check is already passing externally.
## Tests
```bash
python3 scripts/aiw/test_services.py
```

507
scripts/aiw/services.py Normal file
View File

@@ -0,0 +1,507 @@
#!/usr/bin/env python3
"""AI Workspace local service manager.
This is the profile-aware lifecycle layer for local capture/query services. It is
intentionally small and dependency-free so it can run before the future desktop
UI or MCP server exists.
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
RUNTIME_DIR = ROOT / ".aiw" / "runtime"
PID_DIR = RUNTIME_DIR / "pids"
LOG_DIR = RUNTIME_DIR / "logs"
STATE_DIR = RUNTIME_DIR / "state"
DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024
DEFAULT_LOG_BACKUPS = 3
DEFAULT_STOP_TIMEOUT_SECONDS = 5.0
@dataclass(frozen=True)
class ServiceRef:
name: str
config: dict[str, Any]
def ensure_runtime() -> None:
for path in [PID_DIR, LOG_DIR, STATE_DIR]:
path.mkdir(parents=True, exist_ok=True)
def manifest_path(profile: str) -> Path:
return ROOT / "profiles" / profile / "services.json"
def load_manifest(profile: str) -> dict[str, Any]:
path = manifest_path(profile)
if not path.is_file():
raise SystemExit(f"services manifest not found: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def validate_manifest(manifest: dict[str, Any]) -> list[str]:
errors: list[str] = []
services = manifest.get("services")
if not isinstance(services, dict):
return ["manifest must contain a services object"]
for name, config in services.items():
if not isinstance(config, dict):
errors.append(f"{name}: service config must be an object")
continue
command = config.get("command")
if config.get("enabled", True) and (not isinstance(command, list) or not command):
errors.append(f"{name}: enabled services require a non-empty command list")
kind = config.get("kind", "process")
if kind not in {"process", "app-launcher", "mcp"}:
errors.append(f"{name}: unsupported kind {kind!r}")
restart = config.get("restart", "never")
if restart not in {"never", "on-failure", "always"}:
errors.append(f"{name}: unsupported restart policy {restart!r}")
for dependency in config.get("depends_on") or []:
if dependency not in services:
errors.append(f"{name}: depends on unknown service {dependency!r}")
return errors
def service_items(manifest: dict[str, Any], include_disabled: bool = False) -> list[ServiceRef]:
services = manifest.get("services") or {}
refs: list[ServiceRef] = []
for name, config in services.items():
if not include_disabled and not config.get("enabled", True):
continue
refs.append(ServiceRef(name, config))
return refs
def select_services(manifest: dict[str, Any], names: list[str], group: str | None, include_disabled: bool = False) -> list[ServiceRef]:
refs = service_items(manifest, include_disabled=include_disabled)
by_name = {ref.name: ref for ref in refs}
if names:
selected: list[ServiceRef] = []
missing: list[str] = []
for name in names:
ref = by_name.get(name)
if ref is None:
missing.append(name)
else:
selected.append(ref)
if missing:
raise SystemExit("unknown or disabled service(s): " + ", ".join(missing))
return selected
if group:
return [ref for ref in refs if group in (ref.config.get("groups") or [])]
return refs
def pid_path(profile: str, service: str) -> Path:
return PID_DIR / profile / f"{service}.pid"
def state_path(profile: str, service: str) -> Path:
return STATE_DIR / profile / f"{service}.json"
def log_path(profile: str, service: str) -> Path:
return LOG_DIR / profile / f"{service}.log"
def resolve_workspace_path(raw: str) -> Path:
path = Path(raw).expanduser()
return path if path.is_absolute() else ROOT / path
def command_exists(command: str) -> bool:
if not command:
return False
path = Path(command)
if path.is_absolute() or "/" in command:
resolved = resolve_workspace_path(command)
return resolved.exists() and os.access(resolved, os.X_OK)
return shutil_which(command) is not None
def rotate_log_if_needed(path: Path, max_bytes: int = DEFAULT_LOG_MAX_BYTES, backups: int = DEFAULT_LOG_BACKUPS) -> None:
if max_bytes <= 0 or backups <= 0 or not path.exists() or path.stat().st_size < max_bytes:
return
oldest = path.with_suffix(path.suffix + f".{backups}")
oldest.unlink(missing_ok=True)
for index in range(backups - 1, 0, -1):
src = path.with_suffix(path.suffix + f".{index}")
dst = path.with_suffix(path.suffix + f".{index + 1}")
if src.exists():
src.replace(dst)
path.replace(path.with_suffix(path.suffix + ".1"))
def read_pid(profile: str, service: str) -> int | None:
path = pid_path(profile, service)
if not path.is_file():
return None
try:
return int(path.read_text(encoding="utf-8").strip())
except ValueError:
return None
def is_running(pid: int | None) -> bool:
if not pid or pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
try:
result = subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], check=False, capture_output=True, text=True)
if result.returncode != 0:
return False
state = result.stdout.strip()
if state.startswith("Z"):
return False
except OSError:
pass
return True
def write_state(profile: str, service: str, state: dict[str, Any]) -> None:
path = state_path(profile, service)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def read_state(profile: str, service: str) -> dict[str, Any]:
path = state_path(profile, service)
if not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
def health_ok(config: dict[str, Any], timeout: float = 1.0) -> tuple[bool | None, str]:
health = config.get("health") or {}
kind = health.get("type")
if not kind:
return None, "no health check"
if kind == "tcp":
host = str(health.get("host") or "127.0.0.1")
port = int(health.get("port") or 0)
try:
with socket.create_connection((host, port), timeout=timeout):
return True, f"tcp {host}:{port} ok"
except OSError as error:
return False, f"tcp {host}:{port} failed: {error}"
if kind == "http":
url = str(health.get("url") or "")
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
ok = 200 <= int(response.status) < 400
return ok, f"http {url} status {response.status}"
except (urllib.error.URLError, TimeoutError, OSError) as error:
return False, f"http {url} failed: {error}"
return None, f"unknown health type: {kind}"
def service_status(profile: str, ref: ServiceRef) -> dict[str, Any]:
enabled = ref.config.get("enabled", True)
kind = ref.config.get("kind", "process")
command = ref.config.get("command") or []
pid = read_pid(profile, ref.name) if enabled and kind != "app-launcher" else None
running = is_running(pid)
ok, detail = health_ok(ref.config) if enabled else (None, "health skipped")
if not enabled:
label = "disabled"
elif kind == "app-launcher":
label = "launcher"
elif running and ok is not False:
label = "running"
elif running:
label = "unhealthy"
elif ok is True:
label = "externally running"
else:
label = "stopped"
return {
"name": ref.name,
"enabled": enabled,
"kind": kind,
"status": label,
"pid": pid,
"command": command,
"health": {"ok": ok, "detail": detail},
"state": read_state(profile, ref.name),
}
def wait_for_health(config: dict[str, Any], seconds: float = 8.0) -> tuple[bool | None, str]:
deadline = time.time() + seconds
last: tuple[bool | None, str] = (None, "no health check")
while time.time() <= deadline:
last = health_ok(config)
if last[0] is True or last[0] is None:
return last
time.sleep(0.4)
return last
def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], started: set[str]) -> None:
if ref.name in started:
return
for dependency in ref.config.get("depends_on") or []:
dep_config = (manifest.get("services") or {}).get(dependency)
if not dep_config or not dep_config.get("enabled", True):
raise SystemExit(f"{ref.name} depends on missing/disabled service: {dependency}")
start_service(profile, ServiceRef(dependency, dep_config), manifest, started)
kind = ref.config.get("kind", "process")
command = ref.config.get("command") or []
if not command:
raise SystemExit(f"{ref.name} has no command")
if not command_exists(str(command[0])):
raise SystemExit(f"{ref.name} command is not executable or not found: {command[0]}")
if kind != "app-launcher":
pid = read_pid(profile, ref.name)
if is_running(pid):
ok, detail = health_ok(ref.config)
status = "running" if ok is not False else "running unhealthy"
print(f"{ref.name}: {status} ({detail})")
started.add(ref.name)
return
ok, detail = health_ok(ref.config)
if ok is True:
print(f"{ref.name}: externally running ({detail}); not starting duplicate")
started.add(ref.name)
return
path = log_path(profile, ref.name)
path.parent.mkdir(parents=True, exist_ok=True)
rotate_log_if_needed(path, int(ref.config.get("log_max_bytes", DEFAULT_LOG_MAX_BYTES)), int(ref.config.get("log_backups", DEFAULT_LOG_BACKUPS)))
with path.open("ab") as log_file:
log_file.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode("utf-8"))
if kind == "app-launcher":
result = subprocess.run(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, check=False)
write_state(profile, ref.name, {"last_launch_exit": result.returncode, "launched_at": time.time()})
print(f"{ref.name}: launched (exit {result.returncode})")
else:
process = subprocess.Popen(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True)
pid_file = pid_path(profile, ref.name)
pid_file.parent.mkdir(parents=True, exist_ok=True)
pid_file.write_text(str(process.pid) + "\n", encoding="utf-8")
ok, detail = wait_for_health(ref.config)
state = "started" if ok is not False else "started but health check failed"
write_state(profile, ref.name, {"pid": process.pid, "started_at": time.time(), "health": detail})
print(f"{ref.name}: {state} pid={process.pid} ({detail})")
started.add(ref.name)
def stop_service(profile: str, ref: ServiceRef) -> None:
kind = ref.config.get("kind", "process")
if kind == "app-launcher":
print(f"{ref.name}: launcher service has no managed process")
return
pid = read_pid(profile, ref.name)
if not is_running(pid):
ok, detail = health_ok(ref.config)
if ok is True:
print(f"{ref.name}: externally running ({detail}); no managed pid to stop")
return
print(f"{ref.name}: not running")
pid_path(profile, ref.name).unlink(missing_ok=True)
return
assert pid is not None
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
pass
except PermissionError:
os.kill(pid, signal.SIGTERM)
deadline = time.time() + float(ref.config.get("stop_timeout_seconds", DEFAULT_STOP_TIMEOUT_SECONDS))
while time.time() < deadline and is_running(pid):
time.sleep(0.2)
if is_running(pid):
try:
os.killpg(pid, signal.SIGKILL)
except Exception:
os.kill(pid, signal.SIGKILL)
print(f"{ref.name}: killed pid={pid}")
else:
print(f"{ref.name}: stopped")
pid_path(profile, ref.name).unlink(missing_ok=True)
write_state(profile, ref.name, {"stopped_at": time.time()})
def status_service(profile: str, ref: ServiceRef) -> None:
status = service_status(profile, ref)
if not status["enabled"]:
print(f"{ref.name}: disabled")
return
if status["kind"] == "app-launcher":
state = status["state"]
launched = state.get("launched_at")
suffix = f"last launched {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(launched))}" if launched else "not launched by manager"
print(f"{ref.name}: launcher ({suffix})")
return
print(f"{ref.name}: {status['status']} pid={status['pid'] or '-'} ({status['health']['detail']})")
def status_report(profile: str, refs: list[ServiceRef]) -> dict[str, Any]:
"""Return lightweight machine-readable live service state."""
return {
"profile": profile,
"workspace": str(ROOT),
"runtime": str(RUNTIME_DIR),
"services": [service_status(profile, ref) for ref in refs],
}
def tail_log(profile: str, service: str, lines: int) -> None:
path = log_path(profile, service)
if not path.is_file():
print(f"no log file: {path}")
return
content = path.read_text(encoding="utf-8", errors="replace").splitlines()
for line in content[-lines:]:
print(line)
def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]:
errors = validate_manifest(manifest)
service_reports = []
for ref in service_items(manifest, include_disabled=True):
command = ref.config.get("command") or []
first = command[0] if command else ""
doctor = ref.config.get("doctor") or {}
checks = []
for command_name in doctor.get("required_commands") or []:
checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name)})
for command_name in doctor.get("optional_commands") or []:
checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name)})
for raw_path in doctor.get("required_paths") or []:
checks.append({"type": "required_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
for raw_path in doctor.get("optional_paths") or []:
checks.append({"type": "optional_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
status = service_status(profile, ref)
status["command_ok"] = command_exists(first) if first else False
status["checks"] = checks
service_reports.append(status)
return {
"profile": profile,
"workspace": str(ROOT),
"manifest": str(manifest_path(profile)),
"runtime": str(RUNTIME_DIR),
"manifest_ok": not errors,
"manifest_errors": errors,
"services": service_reports,
}
def run_doctor(profile: str, manifest: dict[str, Any], json_output: bool = False) -> None:
report = doctor_report(profile, manifest)
if json_output:
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
return
print(f"AI Workspace doctor profile={profile}")
print(f"workspace: {ROOT}")
print(f"manifest: {manifest_path(profile)}")
ensure_runtime()
print(f"runtime: {RUNTIME_DIR}")
errors = report["manifest_errors"]
if errors:
print("manifest: invalid")
for error in errors:
print(f" ! {error}")
else:
print("manifest: ok")
for service in report["services"]:
enabled_text = "enabled" if service["enabled"] else "disabled"
if not service["enabled"]:
print(f"- {service['name']}: {enabled_text}; command={'ok' if service['command_ok'] else 'missing'}; health skipped")
continue
health_text = service["health"]["detail"] if service["health"]["ok"] is not None else "no health check"
print(f"- {service['name']}: {enabled_text}; command={'ok' if service['command_ok'] else 'missing'}; {health_text}")
for check in service["checks"]:
label = check["type"].replace("_", " ")
print(f" {label} {check['name']}: {'ok' if check['ok'] else 'missing'}")
def shutil_which(command: str) -> str | None:
paths = os.environ.get("PATH", "").split(os.pathsep)
for directory in paths:
candidate = Path(directory) / command
if candidate.exists() and os.access(candidate, os.X_OK):
return str(candidate)
return None
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["start", "stop", "restart", "status", "logs", "doctor"])
parser.add_argument("services", nargs="*", help="Optional service names for start/stop/restart/status/logs.")
parser.add_argument("--profile", default=os.getenv("AIW_PROJECT_PROFILE", "fidelity"))
parser.add_argument("--group", default="", help="Start/stop/status services in a group, e.g. communication or inbox.")
parser.add_argument("--lines", type=int, default=80, help="Number of log lines for logs action.")
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON for supported actions such as doctor.")
args = parser.parse_args()
ensure_runtime()
manifest = load_manifest(args.profile)
if args.action == "doctor":
run_doctor(args.profile, manifest, json_output=args.json)
return
errors = validate_manifest(manifest)
if errors:
raise SystemExit("invalid services manifest:\n" + "\n".join(f"- {error}" for error in errors))
include_disabled = args.action == "status"
refs = select_services(manifest, args.services, args.group or None, include_disabled=include_disabled)
if args.action == "start":
started: set[str] = set()
for ref in refs:
start_service(args.profile, ref, manifest, started)
elif args.action == "stop":
for ref in reversed(refs):
stop_service(args.profile, ref)
elif args.action == "restart":
for ref in reversed(refs):
stop_service(args.profile, ref)
started = set()
for ref in refs:
start_service(args.profile, ref, manifest, started)
elif args.action == "status":
if args.json:
print(json.dumps(status_report(args.profile, refs), ensure_ascii=False, indent=2, sort_keys=True))
else:
for ref in refs:
status_service(args.profile, ref)
elif args.action == "logs":
if not args.services:
raise SystemExit("logs requires at least one service name")
for service in args.services:
print(f"==> {service} <==")
tail_log(args.profile, service, args.lines)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,197 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import io
import socket
import sys
import tempfile
import unittest
import warnings
from pathlib import Path
from contextlib import redirect_stdout
from unittest.mock import patch
SERVICES_PATH = Path(__file__).with_name("services.py")
SPEC = importlib.util.spec_from_file_location("aiw_services", SERVICES_PATH)
services = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = services
SPEC.loader.exec_module(services)
def sample_manifest() -> dict:
return {
"services": {
"alpha": {
"enabled": True,
"kind": "process",
"command": ["python3", "-c", "import time; time.sleep(60)"],
"groups": ["core"],
},
"beta": {
"enabled": True,
"kind": "process",
"command": ["python3", "-c", "import time; time.sleep(60)"],
"groups": ["capture"],
"depends_on": ["alpha"],
},
"disabled": {
"enabled": False,
"kind": "process",
"command": ["python3", "-c", "import time; time.sleep(60)"],
"groups": ["core"],
},
}
}
class ServiceManagerTests(unittest.TestCase):
def test_select_services_excludes_disabled_by_default(self) -> None:
selected = services.select_services(sample_manifest(), names=[], group=None)
self.assertEqual([item.name for item in selected], ["alpha", "beta"])
def test_select_services_can_include_disabled_for_status(self) -> None:
selected = services.select_services(sample_manifest(), names=[], group=None, include_disabled=True)
self.assertEqual([item.name for item in selected], ["alpha", "beta", "disabled"])
def test_select_services_filters_by_group(self) -> None:
selected = services.select_services(sample_manifest(), names=[], group="capture")
self.assertEqual([item.name for item in selected], ["beta"])
def test_health_ok_tcp_reports_open_port(self) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.bind(("127.0.0.1", 0))
server.listen(1)
port = server.getsockname()[1]
ok, detail = services.health_ok({"health": {"type": "tcp", "host": "127.0.0.1", "port": port}})
self.assertTrue(ok)
self.assertIn(f"127.0.0.1:{port}", detail)
def test_validate_manifest_reports_bad_dependencies(self) -> None:
manifest = {
"services": {
"bad": {
"enabled": True,
"kind": "process",
"command": ["python3", "-c", "pass"],
"depends_on": ["missing"],
}
}
}
errors = services.validate_manifest(manifest)
self.assertTrue(any("depends on unknown service" in error for error in errors))
def test_validate_manifest_reports_missing_command_for_enabled_service(self) -> None:
manifest = {"services": {"bad": {"enabled": True, "kind": "process"}}}
errors = services.validate_manifest(manifest)
self.assertTrue(any("non-empty command" in error for error in errors))
def test_command_exists_supports_relative_executable_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
script = root / "bin" / "tool.sh"
script.parent.mkdir(parents=True)
script.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
script.chmod(0o755)
with patch.object(services, "ROOT", root):
self.assertTrue(services.command_exists("bin/tool.sh"))
def test_rotate_log_if_needed_keeps_backups(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
log = Path(tmp) / "service.log"
log.write_text("old", encoding="utf-8")
services.rotate_log_if_needed(log, max_bytes=1, backups=2)
self.assertFalse(log.exists())
self.assertEqual(log.with_suffix(".log.1").read_text(encoding="utf-8"), "old")
def test_doctor_report_is_machine_readable(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
with patch.object(services, "ROOT", root), \
patch.object(services, "RUNTIME_DIR", root / ".aiw" / "runtime"), \
patch.object(services, "PID_DIR", root / ".aiw" / "runtime" / "pids"), \
patch.object(services, "LOG_DIR", root / ".aiw" / "runtime" / "logs"), \
patch.object(services, "STATE_DIR", root / ".aiw" / "runtime" / "state"):
report = services.doctor_report("test", sample_manifest())
self.assertTrue(report["manifest_ok"])
self.assertEqual(report["services"][0]["name"], "alpha")
self.assertIn("health", report["services"][0])
def test_status_report_is_lightweight_machine_readable(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
refs = services.service_items(sample_manifest(), include_disabled=True)
with patch.object(services, "ROOT", root), \
patch.object(services, "RUNTIME_DIR", root / ".aiw" / "runtime"), \
patch.object(services, "PID_DIR", root / ".aiw" / "runtime" / "pids"), \
patch.object(services, "LOG_DIR", root / ".aiw" / "runtime" / "logs"), \
patch.object(services, "STATE_DIR", root / ".aiw" / "runtime" / "state"):
report = services.status_report("test", refs)
self.assertEqual(report["profile"], "test")
self.assertEqual(len(report["services"]), 3)
self.assertIn("status", report["services"][0])
self.assertNotIn("checks", report["services"][0])
def test_read_pid_ignores_invalid_pid_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
pid_dir = Path(tmp) / "pids"
target = pid_dir / "fidelity"
target.mkdir(parents=True)
(target / "alpha.pid").write_text("not-a-pid\n", encoding="utf-8")
with patch.object(services, "PID_DIR", pid_dir):
self.assertIsNone(services.read_pid("fidelity", "alpha"))
def test_start_and_stop_managed_process(self) -> None:
manifest = {
"services": {
"sleeper": {
"enabled": True,
"kind": "process",
"command": [sys.executable, "-c", "import time; time.sleep(60)"],
"restart": "never",
}
}
}
ref = services.ServiceRef("sleeper", manifest["services"]["sleeper"])
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
with patch.object(services, "PID_DIR", root / "pids"), \
patch.object(services, "LOG_DIR", root / "logs"), \
patch.object(services, "STATE_DIR", root / "state"):
started: set[str] = set()
services.ensure_runtime()
with warnings.catch_warnings():
warnings.simplefilter("ignore", ResourceWarning)
with redirect_stdout(io.StringIO()):
services.start_service("test", ref, manifest, started)
pid = services.read_pid("test", "sleeper")
self.assertTrue(services.is_running(pid))
self.assertIn("sleeper", started)
with redirect_stdout(io.StringIO()):
services.stop_service("test", ref)
self.assertFalse(services.is_running(pid))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,68 @@
# AIW Context MCP
Read-only local Model Context Protocol server for AI Workspace context.
The server exposes bounded local evidence and canonical Markdown context to MCP clients. It does not capture traffic, send messages, mutate files, or promote memory.
## HTTP transport
The service manager starts the HTTP transport by default:
```bash
python3 scripts/aiw/services.py start aiw-context-mcp --profile fidelity
```
Endpoint:
```text
http://127.0.0.1:8765/mcp
```
Health:
```text
http://127.0.0.1:8765/health
```
## stdio transport
For clients that require stdio, launch:
```bash
python3 scripts/mcp/aiw-context-mcp/server.py --transport stdio
```
## Tools
- `context_profiles`
- `communication_latest`
- `communication_date_context`
- `communication_standup_context`
- `communication_channel_context`
- `communication_thread_context`
- `project_current_context`
- `project_search_memory`
- `photos_latest`
All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown under `project-knowledge/`.
Mattermost latest/date/standup tools filter to the active profile's context channels by default. For Fidelity, that list lives in `profiles/fidelity/context-sources.json`. Pass explicit `channels` to override the profile list, or `include_all_channels: true` when broad unfiltered mirror evidence is intentionally needed.
## Resources
The server also exposes profile resources such as:
```text
aiw://profiles/fidelity/current-work
aiw://profiles/fidelity/work-items
aiw://profiles/fidelity/mattermost/latest
aiw://profiles/fidelity/photos/latest
```
See `client-configs.md` for client setup snippets and verification prompts.
## Tests
```bash
python3 scripts/mcp/aiw-context-mcp/test_server.py
```

View File

@@ -0,0 +1,69 @@
# AIW Context MCP Client Config Notes
The AIW Context MCP server supports both local HTTP and stdio.
## Preferred HTTP endpoint
When the Service Manager is running:
```text
http://127.0.0.1:8765/mcp
```
Health check:
```text
http://127.0.0.1:8765/health
```
Use this for clients that support MCP Streamable HTTP/local HTTP servers.
## stdio command
Use this for clients that launch MCP servers as subprocesses:
```json
{
"mcpServers": {
"aiw-context-mcp": {
"command": "python3",
"args": [
"/Users/david/Developer/fidelity-ai-workspace/scripts/mcp/aiw-context-mcp/server.py",
"--transport",
"stdio"
]
}
}
}
```
## Antigravity verification prompt
```text
Use the AI Workspace MCP server. List available tools and resources. Then read the resource aiw://profiles/fidelity/current-work and call communication_latest with {"profile":"fidelity","limit":100}. Report the channel_scope and unique returned channel names.
```
## Codex/OpenAI-style verification prompt
```text
Check whether the AIW Context MCP server is available. If available, list its tools/resources, then use project_current_context for profile fidelity. State which MCP calls were used. If unavailable, say so explicitly.
```
## Expected tool names
- `context_profiles`
- `communication_latest`
- `communication_date_context`
- `communication_standup_context`
- `communication_channel_context`
- `communication_thread_context`
- `project_current_context`
- `project_search_memory`
- `photos_latest`
## Expected resource URIs
- `aiw://profiles/fidelity/current-work`
- `aiw://profiles/fidelity/work-items`
- `aiw://profiles/fidelity/mattermost/latest`
- `aiw://profiles/fidelity/photos/latest`

View File

@@ -0,0 +1,541 @@
#!/usr/bin/env python3
"""Read-only AI Workspace context MCP server.
This server intentionally exposes bounded local evidence and canonical memory. It
does not capture traffic, send messages, or promote memory. Capture lifecycle is
owned by the AI Workspace Service Manager.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.parse
from datetime import date, datetime, timedelta
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[3]
PROTOCOL_VERSION = "2025-06-18"
SERVER_NAME = "aiw-context-mcp"
SERVER_VERSION = "0.1.0"
LOCAL_ENV = ROOT / "scripts" / "mattermost-proxy" / ".env"
def load_local_env(path: Path = LOCAL_ENV) -> None:
if not path.is_file():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[len("export ") :].strip()
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip("'\"")
if key and key not in os.environ:
os.environ[key] = value
def profile_dir(profile: str) -> Path:
if profile == "fidelity":
return ROOT
candidate = ROOT / "profiles" / profile
return candidate if candidate.exists() else ROOT
def knowledge_dir(profile: str) -> Path:
base = profile_dir(profile)
candidate = base / "project-knowledge"
return candidate if candidate.exists() else ROOT / "project-knowledge"
def inbox_dir(profile: str) -> Path:
base = profile_dir(profile)
candidate = base / "ai" / "inbox"
return candidate if candidate.exists() else ROOT / "ai" / "inbox"
def mattermost_mirror_dir(profile: str) -> Path:
configured = os.getenv("MATTERMOST_MIRROR_DIR", "").strip()
if configured:
path = Path(configured).expanduser()
return path if path.is_absolute() else ROOT / path
return inbox_dir(profile) / "mattermost-mirror"
def profile_context_channels(profile: str, source: str = "mattermost") -> set[str]:
path = ROOT / "profiles" / profile / "context-sources.json"
if not path.is_file():
return set()
try:
config = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return set()
channels = (((config.get("communication_sources") or {}).get(source) or {}).get("context_channels") or [])
return {str(item).strip().lower() for item in channels if str(item).strip()}
def photo_inbox_dir(profile: str) -> Path:
configured = os.getenv("PHOTO_INBOX_DIR", "").strip()
if configured:
return Path(configured).expanduser()
linked = inbox_dir(profile) / "photos"
if linked.exists():
return linked
return Path.home() / "Pictures" / "Photo Inbox"
def parse_channels(raw: str | None, profile: str | None = None) -> set[str]:
if not raw:
raw = os.getenv("AIW_MATTERMOST_CONTEXT_CHANNELS", "") or os.getenv("AIW_MATTERMOST_PROJECT_CHANNELS", "")
channels = {item.strip().lower() for item in (raw or "").split(",") if item.strip()}
if channels:
return channels
return profile_context_channels(profile or "fidelity") if profile else set()
def should_use_all_channels(args: dict[str, Any]) -> bool:
return bool(args.get("include_all_channels") or args.get("all_channels"))
def previous_workday(today: date) -> date:
day = today - timedelta(days=1)
while day.weekday() >= 5:
day -= timedelta(days=1)
return day
def read_jsonl(path: Path, limit: int | None = None) -> list[dict[str, Any]]:
if not path.is_file():
return []
records: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
if limit and limit > 0:
return records[-limit:]
return records
def filter_channels(records: list[dict[str, Any]], channels: set[str]) -> list[dict[str, Any]]:
if not channels:
return records
return [
item
for item in records
if str(item.get("channel_name", "")).lower() in channels
or str(item.get("channel_id", "")).lower() in channels
]
def daily_by_date_path(profile: str, day: date) -> Path:
return mattermost_mirror_dir(profile) / "by-date" / f"{day:%Y}" / f"{day:%m}" / f"{day:%Y-%m-%d}.jsonl"
def daily_channel_path(profile: str, channel: str, day: date) -> Path:
return mattermost_mirror_dir(profile) / "channels" / channel / f"{day:%Y}" / f"{day:%m}" / f"{day:%Y-%m-%d}.jsonl"
def tool_result(data: Any, text_prefix: str | None = None) -> dict[str, Any]:
text = json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)
if text_prefix:
text = f"{text_prefix}\n{text}"
return {
"content": [{"type": "text", "text": text}],
"structuredContent": data,
"isError": False,
}
def tool_error(message: str, data: Any | None = None) -> dict[str, Any]:
payload = {"error": message, "data": data}
return {
"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)}],
"structuredContent": payload,
"isError": True,
}
def as_date(raw: str | None) -> date:
return datetime.strptime(raw, "%Y-%m-%d").date() if raw else date.today()
def clamp_limit(value: Any, default: int = 80, maximum: int = 300) -> int:
try:
limit = int(value)
except (TypeError, ValueError):
limit = default
if limit <= 0:
return default
return min(limit, maximum)
def list_profiles(_: dict[str, Any]) -> dict[str, Any]:
profiles = sorted(path.name for path in (ROOT / "profiles").iterdir() if (path / "profile.md").is_file())
return tool_result({"profiles": profiles, "active_default": os.getenv("AIW_PROJECT_PROFILE", "fidelity")})
def communication_latest(args: dict[str, Any]) -> dict[str, Any]:
profile = str(args.get("profile") or "fidelity")
limit = clamp_limit(args.get("limit"), default=50)
channels = set() if should_use_all_channels(args) else parse_channels(args.get("channels"), profile=profile)
records = filter_channels(read_jsonl(mattermost_mirror_dir(profile) / "latest.jsonl", limit=None), channels)[-limit:]
return tool_result({"profile": profile, "source": "mattermost", "evidence_type": "communication", "canonical": False, "channel_scope": "all" if not channels else "profile", "channels": sorted(channels), "records": records})
def communication_date_context(args: dict[str, Any]) -> dict[str, Any]:
profile = str(args.get("profile") or "fidelity")
day = as_date(args.get("date"))
limit = clamp_limit(args.get("limit"), default=100)
channels = set() if should_use_all_channels(args) else parse_channels(args.get("channels"), profile=profile)
records = filter_channels(read_jsonl(daily_by_date_path(profile, day), limit=None), channels)[-limit:]
return tool_result({"profile": profile, "source": "mattermost", "date": day.isoformat(), "channels": sorted(channels), "records": records, "canonical": False})
def communication_standup_context(args: dict[str, Any]) -> dict[str, Any]:
profile = str(args.get("profile") or "fidelity")
today = as_date(args.get("today") or args.get("date"))
previous = previous_workday(today)
limit = clamp_limit(args.get("limit"), default=80)
channels = set() if should_use_all_channels(args) else parse_channels(args.get("channels"), profile=profile)
previous_records = filter_channels(read_jsonl(daily_by_date_path(profile, previous)), channels)[-limit:]
today_records = filter_channels(read_jsonl(daily_by_date_path(profile, today)), channels)[-limit:]
return tool_result({
"profile": profile,
"source": "mattermost",
"mode": "standup",
"canonical": False,
"channels": sorted(channels),
"previous_workday": previous.isoformat(),
"today": today.isoformat(),
"previous_records": previous_records,
"today_records": today_records,
})
def communication_channel_context(args: dict[str, Any]) -> dict[str, Any]:
profile = str(args.get("profile") or "fidelity")
channel = str(args.get("channel") or "").strip()
if not channel:
return tool_error("channel is required")
day = as_date(args.get("date"))
limit = clamp_limit(args.get("limit"), default=100)
records = read_jsonl(daily_channel_path(profile, channel, day), limit=limit)
return tool_result({"profile": profile, "source": "mattermost", "channel": channel, "date": day.isoformat(), "records": records, "canonical": False})
def communication_thread_context(args: dict[str, Any]) -> dict[str, Any]:
profile = str(args.get("profile") or "fidelity")
thread_id = str(args.get("thread_id") or "").strip()
if not thread_id:
return tool_error("thread_id is required")
limit = clamp_limit(args.get("limit"), default=100)
path = mattermost_mirror_dir(profile) / "threads" / f"{thread_id}.jsonl"
records = read_jsonl(path, limit=limit)
return tool_result({"profile": profile, "source": "mattermost", "thread_id": thread_id, "records": records, "canonical": False})
def project_current_context(args: dict[str, Any]) -> dict[str, Any]:
profile = str(args.get("profile") or "fidelity")
base = knowledge_dir(profile)
files = [base / "01-current" / "current-work.md", base / "01-current" / "work-items.md"]
result = []
for path in files:
if path.is_file():
result.append({"path": str(path.relative_to(ROOT)), "text": path.read_text(encoding="utf-8", errors="replace")})
return tool_result({"profile": profile, "canonical": True, "files": result})
def project_search_memory(args: dict[str, Any]) -> dict[str, Any]:
profile = str(args.get("profile") or "fidelity")
query = str(args.get("query") or "").strip().lower()
if not query:
return tool_error("query is required")
limit = clamp_limit(args.get("limit"), default=10, maximum=50)
base = knowledge_dir(profile)
matches: list[dict[str, Any]] = []
for path in sorted(base.rglob("*.md")):
rel = path.relative_to(base)
if str(rel).startswith("09-templates/"):
continue
text = path.read_text(encoding="utf-8", errors="replace")
lowered = text.lower()
index = lowered.find(query)
if index < 0:
continue
start = max(0, index - 220)
end = min(len(text), index + len(query) + 220)
matches.append({"path": str(path.relative_to(ROOT)), "snippet": text[start:end].strip()})
if len(matches) >= limit:
break
return tool_result({"profile": profile, "canonical": True, "query": query, "matches": matches})
def photos_latest(args: dict[str, Any]) -> dict[str, Any]:
profile = str(args.get("profile") or "fidelity")
limit = clamp_limit(args.get("limit"), default=20, maximum=100)
base = photo_inbox_dir(profile)
photos = []
if base.exists():
candidates = [path for path in base.iterdir() if path.is_file() and path.suffix.lower() in {".jpg", ".jpeg", ".png", ".heic"}]
for path in sorted(candidates, key=lambda item: item.stat().st_mtime)[-limit:]:
photos.append({"path": str(path), "modified_at": datetime.fromtimestamp(path.stat().st_mtime).isoformat(), "bytes": path.stat().st_size})
return tool_result({"profile": profile, "source": "photo-inbox", "canonical": False, "photos": photos})
def resource_definitions() -> list[dict[str, Any]]:
resources: list[dict[str, Any]] = []
for profile in sorted(path.name for path in (ROOT / "profiles").iterdir() if (path / "profile.md").is_file()):
resources.extend([
{
"uri": f"aiw://profiles/{profile}/current-work",
"name": f"{profile}-current-work",
"title": f"{profile} Current Work",
"description": "Canonical current work context.",
"mimeType": "text/markdown",
"annotations": {"audience": ["assistant"], "priority": 0.95},
},
{
"uri": f"aiw://profiles/{profile}/work-items",
"name": f"{profile}-work-items",
"title": f"{profile} Active Work Items",
"description": "Canonical active work-item summary.",
"mimeType": "text/markdown",
"annotations": {"audience": ["assistant"], "priority": 0.9},
},
{
"uri": f"aiw://profiles/{profile}/mattermost/latest",
"name": f"{profile}-mattermost-latest",
"title": f"{profile} Mattermost Latest",
"description": "Profile-filtered latest Mattermost mirror evidence as JSON.",
"mimeType": "application/json",
"annotations": {"audience": ["assistant"], "priority": 0.75},
},
{
"uri": f"aiw://profiles/{profile}/photos/latest",
"name": f"{profile}-photos-latest",
"title": f"{profile} Photo Inbox Latest",
"description": "Latest local Photo Inbox file metadata as JSON; image data is not embedded.",
"mimeType": "application/json",
"annotations": {"audience": ["assistant"], "priority": 0.45},
},
])
return resources
def read_resource(uri: str) -> dict[str, Any] | None:
parsed = urllib.parse.urlparse(uri)
if parsed.scheme != "aiw":
return None
parts = [part for part in parsed.path.split("/") if part]
if parsed.netloc != "profiles" or len(parts) < 2:
return None
profile = parts[0]
resource = "/".join(parts[1:])
base = knowledge_dir(profile)
if resource == "current-work":
path = base / "01-current" / "current-work.md"
if not path.is_file():
return None
return {"uri": uri, "mimeType": "text/markdown", "text": path.read_text(encoding="utf-8", errors="replace")}
if resource == "work-items":
path = base / "01-current" / "work-items.md"
if not path.is_file():
return None
return {"uri": uri, "mimeType": "text/markdown", "text": path.read_text(encoding="utf-8", errors="replace")}
if resource == "mattermost/latest":
data = communication_latest({"profile": profile, "limit": 80})["structuredContent"]
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)}
if resource == "photos/latest":
data = photos_latest({"profile": profile, "limit": 30})["structuredContent"]
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)}
return None
TOOLS: dict[str, dict[str, Any]] = {
"context_profiles": {"handler": list_profiles, "description": "List AI Workspace project profiles.", "properties": {}},
"communication_latest": {"handler": communication_latest, "description": "Read bounded latest Mattermost mirror evidence, filtered to the profile's context channels by default.", "properties": {"profile": {"type": "string"}, "channels": {"type": "string"}, "include_all_channels": {"type": "boolean"}, "limit": {"type": "integer"}}},
"communication_date_context": {"handler": communication_date_context, "description": "Read Mattermost mirror evidence for one date, filtered to profile context channels by default unless include_all_channels is true.", "properties": {"profile": {"type": "string"}, "date": {"type": "string"}, "channels": {"type": "string"}, "include_all_channels": {"type": "boolean"}, "limit": {"type": "integer"}}},
"communication_standup_context": {"handler": communication_standup_context, "description": "Read previous-workday and today Mattermost evidence for standup drafting, filtered to profile context channels by default.", "properties": {"profile": {"type": "string"}, "today": {"type": "string"}, "channels": {"type": "string"}, "include_all_channels": {"type": "boolean"}, "limit": {"type": "integer"}}},
"communication_channel_context": {"handler": communication_channel_context, "description": "Read Mattermost mirror evidence for a channel and date.", "properties": {"profile": {"type": "string"}, "channel": {"type": "string"}, "date": {"type": "string"}, "limit": {"type": "integer"}}},
"communication_thread_context": {"handler": communication_thread_context, "description": "Read Mattermost mirror evidence for a thread id.", "properties": {"profile": {"type": "string"}, "thread_id": {"type": "string"}, "limit": {"type": "integer"}}},
"project_current_context": {"handler": project_current_context, "description": "Read canonical current-work and work-items context.", "properties": {"profile": {"type": "string"}}},
"project_search_memory": {"handler": project_search_memory, "description": "Search canonical project-knowledge Markdown files.", "properties": {"profile": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer"}}},
"photos_latest": {"handler": photos_latest, "description": "List recent local Photo Inbox files without embedding image data.", "properties": {"profile": {"type": "string"}, "limit": {"type": "integer"}}},
}
def tool_definitions() -> list[dict[str, Any]]:
definitions = []
for name, item in TOOLS.items():
definitions.append({
"name": name,
"title": name.replace("_", " ").title(),
"description": item["description"],
"inputSchema": {"type": "object", "properties": item["properties"], "additionalProperties": False},
"annotations": {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False},
})
return definitions
def jsonrpc_response(request_id: Any, result: Any) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": request_id, "result": result}
def jsonrpc_error(request_id: Any, code: int, message: str, data: Any | None = None) -> dict[str, Any]:
error: dict[str, Any] = {"code": code, "message": message}
if data is not None:
error["data"] = data
return {"jsonrpc": "2.0", "id": request_id, "error": error}
def handle_request(message: dict[str, Any]) -> dict[str, Any] | None:
method = message.get("method")
request_id = message.get("id")
params = message.get("params") or {}
if request_id is None:
return None
if method == "initialize":
requested = str(params.get("protocolVersion") or PROTOCOL_VERSION)
return jsonrpc_response(request_id, {
"protocolVersion": requested if requested in {PROTOCOL_VERSION, "2025-03-26"} else PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": False}, "resources": {"listChanged": False}},
"serverInfo": {"name": SERVER_NAME, "title": "AI Workspace Context MCP", "version": SERVER_VERSION},
"instructions": "Read-only local AI Workspace context. Evidence is not canonical memory unless promoted by the agent/user.",
})
if method == "ping":
return jsonrpc_response(request_id, {})
if method == "tools/list":
return jsonrpc_response(request_id, {"tools": tool_definitions()})
if method == "resources/list":
return jsonrpc_response(request_id, {"resources": resource_definitions()})
if method == "resources/read":
uri = str(params.get("uri") or "")
content = read_resource(uri)
if content is None:
return jsonrpc_error(request_id, -32002, "Resource not found", {"uri": uri})
return jsonrpc_response(request_id, {"contents": [content]})
if method == "resources/templates/list":
return jsonrpc_response(request_id, {"resourceTemplates": []})
if method == "tools/call":
name = str(params.get("name") or "")
arguments = params.get("arguments") or {}
tool = TOOLS.get(name)
if tool is None:
return jsonrpc_error(request_id, -32602, f"Unknown tool: {name}")
try:
return jsonrpc_response(request_id, tool["handler"](arguments))
except Exception as error: # Keep protocol alive; report tool failure.
return jsonrpc_response(request_id, tool_error(str(error)))
return jsonrpc_error(request_id, -32601, f"Method not found: {method}")
class MCPHandler(BaseHTTPRequestHandler):
server_version = "AIWContextMCP/0.1"
def do_GET(self) -> None:
if self.path == "/health":
self.send_json(HTTPStatus.OK, {"status": "ok", "server": SERVER_NAME, "version": SERVER_VERSION})
return
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/mcp":
self.send_error(HTTPStatus.METHOD_NOT_ALLOWED, "SSE GET stream is not implemented")
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path != "/mcp":
self.send_error(HTTPStatus.NOT_FOUND)
return
if not self.origin_allowed():
self.send_error(HTTPStatus.FORBIDDEN, "origin not allowed")
return
length = int(self.headers.get("Content-Length") or 0)
if length <= 0 or length > 2_000_000:
self.send_error(HTTPStatus.BAD_REQUEST, "invalid body length")
return
try:
message = json.loads(self.rfile.read(length).decode("utf-8"))
except json.JSONDecodeError:
self.send_json(HTTPStatus.BAD_REQUEST, jsonrpc_error(None, -32700, "Parse error"))
return
if not isinstance(message, dict):
self.send_json(HTTPStatus.BAD_REQUEST, jsonrpc_error(None, -32600, "Invalid Request"))
return
response = handle_request(message)
if response is None:
self.send_response(HTTPStatus.ACCEPTED)
self.end_headers()
return
self.send_json(HTTPStatus.OK, response, mcp=True)
def origin_allowed(self) -> bool:
origin = self.headers.get("Origin")
if not origin:
return True
parsed = urllib.parse.urlparse(origin)
return parsed.hostname in {"127.0.0.1", "localhost", "::1"}
def send_json(self, status: HTTPStatus, payload: dict[str, Any], mcp: bool = False) -> None:
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
if mcp:
self.send_header("MCP-Protocol-Version", PROTOCOL_VERSION)
self.end_headers()
self.wfile.write(encoded)
def log_message(self, format: str, *args: object) -> None:
print(f"{self.address_string()} - {format % args}", file=sys.stderr, flush=True)
def run_http(host: str, port: int) -> None:
load_local_env()
server = ThreadingHTTPServer((host, port), MCPHandler)
print(f"{SERVER_NAME} listening on http://{host}:{port}/mcp", file=sys.stderr, flush=True)
server.serve_forever()
def run_stdio() -> None:
load_local_env()
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
message = json.loads(line)
response = handle_request(message)
except Exception as error:
response = jsonrpc_error(None, -32603, str(error))
if response is not None:
print(json.dumps(response, ensure_ascii=False, separators=(",", ":")), flush=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--transport", choices=["http", "stdio"], default="http")
parser.add_argument("--host", default=os.getenv("AIW_CONTEXT_MCP_HOST", "127.0.0.1"))
parser.add_argument("--port", type=int, default=int(os.getenv("AIW_CONTEXT_MCP_PORT", "8765")))
args = parser.parse_args()
if args.transport == "stdio":
run_stdio()
else:
run_http(args.host, args.port)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import json
import sys
import tempfile
import unittest
from datetime import date
from pathlib import Path
from unittest.mock import patch
SERVER_PATH = Path(__file__).with_name("server.py")
SPEC = importlib.util.spec_from_file_location("aiw_context_mcp", SERVER_PATH)
server = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = server
SPEC.loader.exec_module(server)
class ContextMCPTests(unittest.TestCase):
def test_initialize_response_declares_tools(self) -> None:
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": server.PROTOCOL_VERSION}})
self.assertEqual(response["result"]["protocolVersion"], server.PROTOCOL_VERSION)
self.assertIn("tools", response["result"]["capabilities"])
def test_tools_list_includes_project_search(self) -> None:
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
names = {tool["name"] for tool in response["result"]["tools"]}
self.assertIn("project_search_memory", names)
self.assertIn("communication_latest", names)
def test_initialize_response_declares_resources(self) -> None:
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": server.PROTOCOL_VERSION}})
self.assertIn("resources", response["result"]["capabilities"])
def test_resources_list_includes_current_work(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
profile = root / "profiles" / "fidelity" / "profile.md"
profile.parent.mkdir(parents=True)
profile.write_text("# Fidelity", encoding="utf-8")
with patch.object(server, "ROOT", root):
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "resources/list"})
uris = {item["uri"] for item in response["result"]["resources"]}
self.assertIn("aiw://profiles/fidelity/current-work", uris)
def test_resources_read_current_work(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
profile = root / "profiles" / "fidelity" / "profile.md"
current = root / "project-knowledge" / "01-current" / "current-work.md"
profile.parent.mkdir(parents=True)
current.parent.mkdir(parents=True)
profile.write_text("# Fidelity", encoding="utf-8")
current.write_text("# Current\nImportant", encoding="utf-8")
with patch.object(server, "ROOT", root):
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "aiw://profiles/fidelity/current-work"}})
self.assertEqual(response["result"]["contents"][0]["text"], "# Current\nImportant")
def test_unknown_tool_returns_protocol_error(self) -> None:
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "missing", "arguments": {}}})
self.assertEqual(response["error"]["code"], -32602)
def test_notification_returns_none(self) -> None:
response = server.handle_request({"jsonrpc": "2.0", "method": "notifications/initialized"})
self.assertIsNone(response)
def test_communication_latest_reads_bounded_records(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
latest = root / "ai" / "inbox" / "mattermost-mirror" / "latest.jsonl"
latest.parent.mkdir(parents=True)
for index in range(3):
latest.write_text("", encoding="utf-8") if index == 0 else None
latest.write_text(
"\n".join(json.dumps({"post_id": str(index), "message": f"m{index}"}) for index in range(3)) + "\n",
encoding="utf-8",
)
with patch.object(server, "ROOT", root):
result = server.communication_latest({"profile": "fidelity", "limit": 2})["structuredContent"]
self.assertEqual([item["message"] for item in result["records"]], ["m1", "m2"])
def test_communication_latest_filters_to_profile_channels_by_default(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
latest = root / "ai" / "inbox" / "mattermost-mirror" / "latest.jsonl"
profile_config = root / "profiles" / "fidelity" / "context-sources.json"
latest.parent.mkdir(parents=True)
profile_config.parent.mkdir(parents=True)
profile_config.write_text(json.dumps({
"communication_sources": {
"mattermost": {"context_channels": ["fidelity-code-review", "dm-david--jeff"]}
}
}), encoding="utf-8")
latest.write_text(
"\n".join([
json.dumps({"post_id": "1", "channel_name": "design-team", "message": "ignore"}),
json.dumps({"post_id": "2", "channel_name": "fidelity-code-review", "message": "keep"}),
]) + "\n",
encoding="utf-8",
)
with patch.object(server, "ROOT", root), patch.dict(server.os.environ, {"AIW_MATTERMOST_CONTEXT_CHANNELS": "", "AIW_MATTERMOST_PROJECT_CHANNELS": ""}, clear=False):
result = server.communication_latest({"profile": "fidelity", "limit": 10})["structuredContent"]
self.assertEqual([item["message"] for item in result["records"]], ["keep"])
self.assertEqual(result["channel_scope"], "profile")
def test_communication_latest_can_include_all_channels(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
latest = root / "ai" / "inbox" / "mattermost-mirror" / "latest.jsonl"
profile_config = root / "profiles" / "fidelity" / "context-sources.json"
latest.parent.mkdir(parents=True)
profile_config.parent.mkdir(parents=True)
profile_config.write_text(json.dumps({
"communication_sources": {"mattermost": {"context_channels": ["fidelity-code-review"]}}
}), encoding="utf-8")
latest.write_text(
"\n".join([
json.dumps({"post_id": "1", "channel_name": "design-team", "message": "include"}),
json.dumps({"post_id": "2", "channel_name": "fidelity-code-review", "message": "keep"}),
]) + "\n",
encoding="utf-8",
)
with patch.object(server, "ROOT", root), patch.dict(server.os.environ, {"AIW_MATTERMOST_CONTEXT_CHANNELS": "", "AIW_MATTERMOST_PROJECT_CHANNELS": ""}, clear=False):
result = server.communication_latest({"profile": "fidelity", "include_all_channels": True, "limit": 10})["structuredContent"]
self.assertEqual([item["message"] for item in result["records"]], ["include", "keep"])
self.assertEqual(result["channel_scope"], "all")
def test_project_search_skips_templates(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
real = root / "project-knowledge" / "03-context" / "project.md"
template = root / "project-knowledge" / "09-templates" / "daily.md"
real.parent.mkdir(parents=True)
template.parent.mkdir(parents=True)
real.write_text("Important XFlow context", encoding="utf-8")
template.write_text("Important XFlow template", encoding="utf-8")
with patch.object(server, "ROOT", root):
result = server.project_search_memory({"profile": "fidelity", "query": "XFlow"})["structuredContent"]
self.assertEqual(len(result["matches"]), 1)
self.assertIn("03-context/project.md", result["matches"][0]["path"])
def test_previous_workday_skips_weekend(self) -> None:
monday = date(2026, 5, 18)
self.assertEqual(server.previous_workday(monday), date(2026, 5, 15))
if __name__ == "__main__":
unittest.main()