Compare commits
1 Commits
mimo/code/
...
mimo/resea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
479b9ec132 |
203
FINDINGS-issue-1047.md
Normal file
203
FINDINGS-issue-1047.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# FINDINGS: MemPalace Local AI Memory System Assessment & Leverage Plan
|
||||
|
||||
**Issue:** #1047
|
||||
**Date:** 2026-04-10
|
||||
**Investigator:** mimo-v2-pro (swarm researcher)
|
||||
|
||||
---
|
||||
|
||||
## 1. What Issue #1047 Claims
|
||||
|
||||
The issue (authored by Bezalel, dated 2026-04-07) describes MemPalace as:
|
||||
- An open-source local-first AI memory system with highest published LongMemEval scores (96.6% R@5)
|
||||
- A Python CLI + MCP server using ChromaDB + SQLite with a "palace" hierarchy metaphor
|
||||
- AAAK compression dialect for ~30x context compression
|
||||
- 19 MCP tools for agent memory
|
||||
|
||||
It recommends that every wizard clone/vendor MemPalace, configure rooms, mine workspace, and wire the searcher into heartbeats.
|
||||
|
||||
## 2. What Actually Exists in the Codebase (Current State)
|
||||
|
||||
The Nexus repo already contains **substantial MemPalace integration** that goes well beyond the original research proposal. Here is the full inventory:
|
||||
|
||||
### 2.1 Core Python Layer — `nexus/mempalace/` (3 files, ~290 lines)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `config.py` | Environment-driven config: palace paths, fleet path, wing name, core rooms, collection name |
|
||||
| `searcher.py` | ChromaDB-backed search/write API with `search_memories()`, `search_fleet()`, `add_memory()` |
|
||||
| `__init__.py` | Package marker |
|
||||
|
||||
**Status:** Functional. Clean API. Lazy ChromaDB import with graceful `MemPalaceUnavailable` exception.
|
||||
|
||||
### 2.2 Fleet Management Tools — `mempalace/` (8 files, ~800 lines)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `rooms.yaml` | Fleet-wide room taxonomy standard (5 core rooms + optional rooms) |
|
||||
| `validate_rooms.py` | Validates wizard `mempalace.yaml` against fleet standard |
|
||||
| `audit_privacy.py` | Scans fleet palace for policy violations (raw drawers, oversized closets, private paths) |
|
||||
| `retain_closets.py` | 90-day retention enforcement for closet aging |
|
||||
| `export_closets.sh` | Privacy-safe closet export for rsync to Alpha fleet palace |
|
||||
| `fleet_api.py` | HTTP API for shared fleet palace (search, record, wings) |
|
||||
| `tunnel_sync.py` | Pull closets from remote wizard's fleet API into local palace |
|
||||
| `__init__.py` | Package marker |
|
||||
|
||||
**Status:** Well-structured. Each tool has clear CLI interface and proper error handling.
|
||||
|
||||
### 2.3 Evennia MUD Integration — `nexus/evennia_mempalace/` (6 files, ~580 lines)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `commands/recall.py` | `CmdRecall` (semantic search), `CmdEnterRoom` (teleport), `CmdAsk` (NPC query) |
|
||||
| `commands/write.py` | `CmdRecord`, `CmdNote`, `CmdEvent` (memory writing commands) |
|
||||
| `typeclasses/rooms.py` | `MemPalaceRoom` typeclass |
|
||||
| `typeclasses/npcs.py` | `StewardNPC` with question-answering via palace search |
|
||||
|
||||
**Status:** Complete. Evennia stub fallback for testing outside live environment.
|
||||
|
||||
### 2.4 3D Visualization — `nexus/components/spatial-memory.js` (~665 lines)
|
||||
|
||||
Maps memory categories to spatial regions in the Nexus Three.js world:
|
||||
- Inner ring: Documents, Projects, Code, Conversations, Working Memory, Archive
|
||||
- Outer ring (MemPalace zones, issue #1168): User Preferences, Project Facts, Tool Knowledge, General Facts
|
||||
- Crystal geometry with deterministic positioning, connection lines, localStorage persistence
|
||||
|
||||
**Status:** Functional 3D visualization with region markers, memory crystals, and animation.
|
||||
|
||||
### 2.5 Frontend Integration — `mempalace.js` (~44 lines)
|
||||
|
||||
Basic Electron/browser integration class that:
|
||||
- Initializes a palace wing
|
||||
- Auto-mines chat content every 30 seconds
|
||||
- Exposes `search()` method
|
||||
- Updates stats display
|
||||
|
||||
**Status:** Minimal but functional as a bridge between browser UI and CLI mempalace.
|
||||
|
||||
### 2.6 Scripts & Automation — `scripts/` (5 files)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `mempalace-incremental-mine.sh` | Re-mines only changed files since last run |
|
||||
| `mempalace_nightly.sh` | Nightly maintenance |
|
||||
| `mempalace_export.py` | Export utility |
|
||||
| `validate_mempalace_taxonomy.py` | Taxonomy validation script |
|
||||
| `audit_mempalace_privacy.py` | Privacy audit script |
|
||||
| `sync_fleet_to_alpha.sh` | Fleet sync to Alpha server |
|
||||
|
||||
### 2.7 Tests — `tests/` (7 test files)
|
||||
|
||||
| File | Tests |
|
||||
|------|-------|
|
||||
| `test_mempalace_searcher.py` | Searcher API, config |
|
||||
| `test_mempalace_validate_rooms.py` | Room taxonomy validation |
|
||||
| `test_mempalace_retain_closets.py` | Closet retention |
|
||||
| `test_mempalace_audit_privacy.py` | Privacy auditor |
|
||||
| `test_mempalace_fleet_api.py` | Fleet HTTP API |
|
||||
| `test_mempalace_tunnel_sync.py` | Remote wizard sync |
|
||||
| `test_evennia_mempalace_commands.py` | Evennia commands + NPC helpers |
|
||||
|
||||
### 2.8 CI/CD
|
||||
|
||||
- **ci.yml**: Validates palace taxonomy on every PR, plus Python/JSON/YAML syntax checks
|
||||
- **weekly-audit.yml**: Monday 05:00 UTC — runs privacy audit + dry-run retention against test fixtures
|
||||
|
||||
### 2.9 Documentation
|
||||
|
||||
- `docs/mempalace_taxonomy.yaml` — Full taxonomy standard (145 lines)
|
||||
- `docs/mempalace/rooms.yaml` — Rooms documentation
|
||||
- `docs/mempalace/bezalel_example.yaml` — Example wizard config
|
||||
- `docs/bezalel/evennia/` — Evennia integration examples (steward NPC, palace commands)
|
||||
- `reports/bezalel/2026-04-07-mempalace-field-report.md` — Original field report
|
||||
|
||||
## 3. Gap Analysis: Issue #1047 vs. Reality
|
||||
|
||||
| Issue #1047 Proposes | Current State | Gap |
|
||||
|---------------------|---------------|-----|
|
||||
| "Each wizard should clone/vendor it" | Vendor infrastructure exists (`scripts/mempalace-incremental-mine.sh`) | **DONE** |
|
||||
| "Write a mempalace.yaml" | Fleet taxonomy standard + validator exist | **DONE** |
|
||||
| "Run mempalace mine" | Incremental mining script exists | **DONE** |
|
||||
| "Wire searcher into heartbeat scripts" | `nexus/mempalace/searcher.py` provides API | **DONE** (needs adoption verification) |
|
||||
| AAAK compression | Not implemented in repo | **OPEN** — no AAAK dialect code |
|
||||
| MCP server (19 tools) | No MCP server integration | **OPEN** — no MCP tool definitions |
|
||||
| Benchmark validation | No LongMemEval test harness in repo | **OPEN** — claims unverified locally |
|
||||
| Fleet-wide adoption | Only Bezalel field report exists | **OPEN** — no evidence of Timmy/Allegro/Ezra adoption |
|
||||
| Hermes harness integration | No direct harness/memory-tool bridge | **OPEN** — searcher exists but no harness wiring |
|
||||
|
||||
## 4. What's Actually Broken
|
||||
|
||||
### 4.1 No AAAK Implementation
|
||||
The issue describes AAAK (~30x compression, ~170 tokens wake-up context) as a key feature, but there is zero AAAK code in the repo. The `nexus/mempalace/` layer has no compression functions. This is a missing feature, not a bug.
|
||||
|
||||
### 4.2 No MCP Server Bridge
|
||||
The upstream MemPalace offers 19 MCP tools, but the Nexus integration only exposes the ChromaDB Python API. There is no MCP server definition, no tool registration for the harness, and no bridge to the `mcp_config.json` at repo root.
|
||||
|
||||
### 4.3 Fleet Adoption Gap
|
||||
Only Bezalel has a documented field report (#1072). There is no evidence that Timmy, Allegro, or Ezra have populated palaces, configured room taxonomies, or run incremental mining. The `export_closets.sh` script hardcodes Bezalel paths.
|
||||
|
||||
### 4.4 Frontend Integration Stale
|
||||
`mempalace.js` references `window.electronAPI.execPython()` which only works in the Electron shell. The main `app.js` (Three.js world) does not import or use `mempalace.js`. The `spatial-memory.js` component defines MemPalace zones but has no data pipeline to populate them from actual palace data.
|
||||
|
||||
### 4.5 Upstream Quality Concern
|
||||
Bezalel's field report notes the upstream repo is "astroturfed hype" — 13.4k LOC in a single commit, 5,769 GitHub stars in 48 hours, ~125 lines of tests. The code is not malicious but is not production-grade. The Nexus has effectively forked/vendored the useful parts and rewritten the critical integration layers.
|
||||
|
||||
## 5. What's Working Well
|
||||
|
||||
1. **Clean architecture separation** — `nexus/mempalace/` is a proper Python package with config/searcher separation. Testable without ChromaDB installed.
|
||||
|
||||
2. **Privacy-first fleet design** — closet-only export policy, privacy auditor, retention enforcement, and private path detection are solid operational safeguards.
|
||||
|
||||
3. **Taxonomy standardization** — `rooms.yaml` + validator ensures consistent memory structure across wizards.
|
||||
|
||||
4. **CI integration** — Taxonomy validation in PR checks + weekly privacy audit cron are good DevOps practices.
|
||||
|
||||
5. **Evennia integration** — The MUD commands (recall, enter room, ask steward) are well-designed and testable outside Evennia via stubs.
|
||||
|
||||
6. **Spatial visualization** — `spatial-memory.js` is a creative 3D representation with deterministic positioning and category zones.
|
||||
|
||||
## 6. Recommended Actions
|
||||
|
||||
### Priority 1: Fleet Adoption Verification (effort: small)
|
||||
- Confirm each wizard (Timmy, Allegro, Ezra) has run `mempalace mine` and has a populated palace
|
||||
- Verify `mempalace.yaml` exists on each wizard's VPS
|
||||
- Update `export_closets.sh` to not hardcode Bezalel paths (use env vars)
|
||||
|
||||
### Priority 2: Hermes Harness Bridge (effort: medium)
|
||||
- Wire `nexus/mempalace/searcher.py` into the Hermes harness as a memory tool
|
||||
- Add memory search/recall to the agent loop so wizards get cross-session context automatically
|
||||
- Map MemPalace search to the existing `memory`/`fact_store` tools or add a dedicated `palace_search` tool
|
||||
|
||||
### Priority 3: MCP Server Registration (effort: medium)
|
||||
- Create an MCP server that exposes search, write, and status tools
|
||||
- Register in `mcp_config.json`
|
||||
- Enable any harness agent to use MemPalace without Python imports
|
||||
|
||||
### Priority 4: AAAK Compression (effort: large, optional)
|
||||
- Implement or port the AAAK compression dialect
|
||||
- Generate wake-up context summaries from palace data
|
||||
- This is a nice-to-have, not critical — the raw ChromaDB search is functional
|
||||
|
||||
### Priority 5: 3D Pipeline Bridge (effort: medium)
|
||||
- Connect `spatial-memory.js` to live palace data via WebSocket or REST
|
||||
- Populate memory crystals from actual search results
|
||||
- Visual feedback when new memories are added
|
||||
|
||||
## 7. Effort Summary
|
||||
|
||||
| Action | Effort | Impact |
|
||||
|--------|--------|--------|
|
||||
| Fleet adoption verification | 2-4 hours | High — ensures all wizards have memory |
|
||||
| Hermes harness bridge | 1-2 days | High — automatic cross-session context |
|
||||
| MCP server registration | 1 day | Medium — enables any agent to use palace |
|
||||
| AAAK compression | 2-3 days | Low — nice-to-have |
|
||||
| 3D pipeline bridge | 1-2 days | Medium — visual representation of memory |
|
||||
| Fix export_closets.sh hardcoded paths | 30 min | Low — operational hygiene |
|
||||
|
||||
## 8. Conclusion
|
||||
|
||||
Issue #1047 was a research request from 2026-04-07. Since then, significant implementation work has been completed — far exceeding the original proposal. The core memory infrastructure (searcher, fleet tools, privacy, taxonomy, Evennia integration, tests, CI) is **built and functional**.
|
||||
|
||||
The primary remaining gap is **fleet-wide adoption** (only Bezalel has documented use) and **harness integration** (the searcher exists but isn't wired into the agent loop). The AAAK and MCP features from the original research are not implemented but are not blocking — the ChromaDB-backed search provides the core value proposition.
|
||||
|
||||
**Verdict:** The MemPalace integration is substantially complete at the infrastructure level. The next bottleneck is operational adoption and harness wiring, not new feature development.
|
||||
@@ -1,151 +0,0 @@
|
||||
# Bannerlord Local Mac Setup
|
||||
|
||||
> **Status:** READY FOR TESTING
|
||||
> **Platform:** macOS (Apple Silicon / Intel)
|
||||
> **Source:** GOG (not Steam)
|
||||
> **Last Updated:** 2026-04-10
|
||||
|
||||
## Problem
|
||||
|
||||
Bannerlord is a Windows game. Alexander has it from GOG on macOS.
|
||||
We need it running locally through emulation before the harness can observe it.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ LOCAL BANNERLORD ON MAC │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ Bannerlord │ │ Emulator │ │ macOS Desktop │ │
|
||||
│ │ (GOG) │───►│ Wine/Whisky/ │───►│ (the screen) │ │
|
||||
│ │ │ │ CrossOver │ │ │ │
|
||||
│ └──────────────┘ └──────────────┘ └────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────────────────────────┤ │
|
||||
│ │ Bannerlord Harness │ │
|
||||
│ │ ┌────────────┐ ┌───────────┐ ┌───────────┐ │ │
|
||||
│ │ │ capture_ │ │ execute_ │ │ bannerlord│ │ │
|
||||
│ │ │ state() │ │ action() │ │ _local.py │ │ │
|
||||
│ │ └────────────┘ └───────────┘ └───────────┘ │ │
|
||||
│ │ │ ▲ │ │ │
|
||||
│ │ ▼ │ ▼ │ │
|
||||
│ │ ┌─────────────────────────────────────────┐ │ │
|
||||
│ │ │ MCP Servers (desktop-control) │ │ │
|
||||
│ │ │ Screenshots + keyboard/mouse │ │ │
|
||||
│ │ └─────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ Hermes WebSocket │ │
|
||||
│ │ Telemetry + ODA loop │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `scripts/bannerlord_launcher.sh` | Shell launcher — detects emulator + game, launches |
|
||||
| `nexus/bannerlord_local.py` | Python module — programmatic readiness + launch control |
|
||||
| `nexus/bannerlord_harness.py` | Existing harness — extended with `--local` and `--launch-local` |
|
||||
| `portals.json` | Portal metadata — updated with `local_launch` block |
|
||||
|
||||
## Emulator Priority
|
||||
|
||||
1. **Whisky** — `/Applications/Whisky.app` (preferred, best macOS integration)
|
||||
2. **CrossOver** — `/Applications/CrossOver.app` (good, paid)
|
||||
3. **Homebrew Wine** — `wine64` / `wine` on PATH (free, may need Rosetta on ARM)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Check Readiness
|
||||
|
||||
```bash
|
||||
# Shell
|
||||
./scripts/bannerlord_launcher.sh --check --verbose
|
||||
|
||||
# Python
|
||||
python3 -m nexus.bannerlord_local --check --json
|
||||
|
||||
# Through harness
|
||||
python3 -m nexus.bannerlord_harness --local --mock
|
||||
```
|
||||
|
||||
### Launch Game
|
||||
|
||||
```bash
|
||||
# Shell
|
||||
./scripts/bannerlord_launcher.sh --launch
|
||||
|
||||
# Python
|
||||
python3 -m nexus.bannerlord_local --launch --json
|
||||
|
||||
# Through harness (launches game, then runs ODA)
|
||||
python3 -m nexus.bannerlord_harness --launch-local --mock
|
||||
```
|
||||
|
||||
### Stop Game
|
||||
|
||||
```bash
|
||||
python3 -m nexus.bannerlord_local --stop
|
||||
```
|
||||
|
||||
## GOG Install Paths Searched
|
||||
|
||||
The launcher checks these paths in order:
|
||||
|
||||
1. `/Applications/Games/Mount & Blade II Bannerlord`
|
||||
2. `~/GOG Games/Mount and Blade II Bannerlord`
|
||||
3. `~/Games/Mount & Blade II Bannerlord`
|
||||
4. `/Applications/Mount & Blade II Bannerlord`
|
||||
5. `~/Library/Application Support/GOG.com/Galaxy/Applications/*/`
|
||||
6. Recursive `find` as last resort
|
||||
|
||||
The game must have `bin/Generic/Bannerlord.exe` relative to the install root.
|
||||
|
||||
## Portal Metadata
|
||||
|
||||
The `portals.json` bannerlord entry now includes:
|
||||
|
||||
```json
|
||||
"environment": "local",
|
||||
"local_launch": {
|
||||
"platform": "macos",
|
||||
"source": "gog",
|
||||
"emulator_required": true,
|
||||
"emulator_options": ["whisky", "crossover", "wine"],
|
||||
"launcher": "scripts/bannerlord_launcher.sh",
|
||||
"harness_bridge": "nexus/bannerlord_local.py",
|
||||
"check_command": "python3 -m nexus.bannerlord_local --check --json"
|
||||
}
|
||||
```
|
||||
|
||||
## Honest Status
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| Launcher script | Written, needs Mac testing |
|
||||
| Python local module | Written, needs Mac testing |
|
||||
| Harness integration | Added `--local`/`--launch-local` flags |
|
||||
| Portal metadata | Updated |
|
||||
| MCP observation of emulated window | Untested — depends on emulator window visibility |
|
||||
| ODA loop with emulated game | Untested — needs game actually running |
|
||||
|
||||
## What Could Go Wrong
|
||||
|
||||
- **Emulator not installed:** User must install Whisky, CrossOver, or wine
|
||||
- **Game not found:** User must install GOG Bannerlord to a known path
|
||||
- **Performance:** Wine on Apple Silicon requires Rosetta + possible DXVK setup
|
||||
- **Window title:** The emulated window may not match "Mount & Blade II: Bannerlord" — the harness may need to detect the actual window title
|
||||
- **MCP desktop-control on macOS:** pyautogui on macOS needs Accessibility permissions
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Alexander runs `./scripts/bannerlord_launcher.sh --check --verbose` on his Mac
|
||||
2. If missing emulator, install Whisky (`brew install --cask whisky`)
|
||||
3. If missing game, install GOG Bannerlord
|
||||
4. Run `--launch` to verify the game opens
|
||||
5. Run `--launch-local --mock` to verify harness integration
|
||||
6. Test MCP screenshots of the emulated window
|
||||
@@ -836,43 +836,8 @@ async def main():
|
||||
default=1.0,
|
||||
help="Delay between iterations in seconds (default: 1.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="Check local macOS Bannerlord readiness before starting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--launch-local",
|
||||
action="store_true",
|
||||
help="Launch local Bannerlord on macOS via emulator before ODA loop",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle local macOS Bannerlord
|
||||
if args.local or args.launch_local:
|
||||
try:
|
||||
from nexus.bannerlord_local import (
|
||||
check_local_readiness, launch_bannerlord, LocalStatus,
|
||||
)
|
||||
|
||||
state = check_local_readiness()
|
||||
log.info(f"Local check: {state.status.value}")
|
||||
log.info(f" Emulator: {state.emulator.name or 'none'}")
|
||||
log.info(f" Game: {state.game.game_dir or 'not found'}")
|
||||
log.info(f" Message: {state.message}")
|
||||
|
||||
if args.launch_local:
|
||||
if state.status == LocalStatus.READY:
|
||||
state = launch_bannerlord(state)
|
||||
log.info(f"Launch result: {state.status.value} — {state.message}")
|
||||
elif state.status == LocalStatus.RUNNING:
|
||||
log.info(f"Already running (PID: {state.process_id})")
|
||||
else:
|
||||
log.error(f"Cannot launch: {state.message}")
|
||||
return
|
||||
except ImportError:
|
||||
log.warning("bannerlord_local module not available — skipping local check")
|
||||
|
||||
# Create harness
|
||||
harness = BannerlordHarness(
|
||||
hermes_ws_url=args.hermes_ws,
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bannerlord Local Manager — macOS Emulator Bridge
|
||||
|
||||
Detects and manages a local Bannerlord installation on macOS.
|
||||
Provides status queries, launch control, and process monitoring
|
||||
for the Bannerlord harness.
|
||||
|
||||
This module bridges the gap between:
|
||||
- The GamePortal Protocol (MCP-based observation/action)
|
||||
- A local GOG Bannerlord running through Wine/Whisky/CrossOver on macOS
|
||||
|
||||
The harness does NOT change — this module just manages the game process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("bannerlord.local")
|
||||
|
||||
|
||||
class EmulatorType(Enum):
|
||||
WHISKY = "whisky"
|
||||
CROSSOVER = "crossover"
|
||||
WINE = "wine"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class LocalStatus(Enum):
|
||||
READY = "ready"
|
||||
MISSING_EMULATOR = "missing_emulator"
|
||||
MISSING_GAME = "missing_game"
|
||||
RUNNING = "running"
|
||||
CRASHED = "crashed"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
# Standard GOG install paths on macOS
|
||||
GOG_SEARCH_PATHS = [
|
||||
Path("/Applications/Games/Mount & Blade II Bannerlord"),
|
||||
Path.home() / "GOG Games" / "Mount and Blade II Bannerlord",
|
||||
Path.home() / "Games" / "Mount & Blade II Bannerlord",
|
||||
Path("/Applications/Mount & Blade II Bannerlord"),
|
||||
]
|
||||
|
||||
BANNERLORD_EXE_RELATIVE = "bin/Generic/Bannerlord.exe"
|
||||
|
||||
LAUNCHER_SCRIPT = Path(__file__).parent.parent / "scripts" / "bannerlord_launcher.sh"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmulatorInfo:
|
||||
"""Detected Windows emulator on macOS."""
|
||||
name: str = ""
|
||||
path: str = ""
|
||||
emulator_type: EmulatorType = EmulatorType.UNKNOWN
|
||||
found: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameInstall:
|
||||
"""Detected Bannerlord GOG installation."""
|
||||
game_dir: str = ""
|
||||
game_exe: str = ""
|
||||
found: bool = False
|
||||
source: str = "" # "gog", "gog-galaxy", "manual"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalState:
|
||||
"""Full local Bannerlord state."""
|
||||
status: LocalStatus = LocalStatus.ERROR
|
||||
emulator: EmulatorInfo = field(default_factory=EmulatorInfo)
|
||||
game: GameInstall = field(default_factory=GameInstall)
|
||||
process_id: Optional[int] = None
|
||||
message: str = ""
|
||||
is_macos: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"emulator": {
|
||||
"name": self.emulator.name,
|
||||
"path": self.emulator.path,
|
||||
"type": self.emulator.emulator_type.value,
|
||||
"found": self.emulator.found,
|
||||
},
|
||||
"game": {
|
||||
"game_dir": self.game.game_dir,
|
||||
"game_exe": self.game.game_exe,
|
||||
"found": self.game.found,
|
||||
"source": self.game.source,
|
||||
},
|
||||
"process_id": self.process_id,
|
||||
"message": self.message,
|
||||
"is_macos": self.is_macos,
|
||||
}
|
||||
|
||||
|
||||
def detect_macos() -> bool:
|
||||
"""Check if running on macOS."""
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
|
||||
def detect_emulator() -> EmulatorInfo:
|
||||
"""Find a Windows emulator on macOS."""
|
||||
info = EmulatorInfo()
|
||||
|
||||
# Whisky
|
||||
whisky_path = "/Applications/Whisky.app/Contents/Resources/Libraries/wine/bin/wine64"
|
||||
if os.path.isfile(whisky_path) and os.access(whisky_path, os.X_OK):
|
||||
info.name = "Whisky"
|
||||
info.path = whisky_path
|
||||
info.emulator_type = EmulatorType.WHISKY
|
||||
info.found = True
|
||||
return info
|
||||
|
||||
# CrossOver
|
||||
cx_path = "/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine"
|
||||
if os.path.isfile(cx_path) and os.access(cx_path, os.X_OK):
|
||||
info.name = "CrossOver"
|
||||
info.path = cx_path
|
||||
info.emulator_type = EmulatorType.CROSSOVER
|
||||
info.found = True
|
||||
return info
|
||||
|
||||
# Homebrew wine
|
||||
for candidate in ["wine64", "wine"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["which", candidate],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
info.name = candidate
|
||||
info.path = result.stdout.strip()
|
||||
info.emulator_type = EmulatorType.WINE
|
||||
info.found = True
|
||||
return info
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
continue
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def detect_game() -> GameInstall:
|
||||
"""Find the Bannerlord GOG installation."""
|
||||
install = GameInstall()
|
||||
|
||||
# Check standard paths
|
||||
for path in GOG_SEARCH_PATHS:
|
||||
exe_path = path / BANNERLORD_EXE_RELATIVE
|
||||
if exe_path.is_file():
|
||||
install.game_dir = str(path)
|
||||
install.game_exe = str(exe_path)
|
||||
install.found = True
|
||||
install.source = "gog"
|
||||
return install
|
||||
|
||||
# Check GOG Galaxy paths
|
||||
galaxy_base = Path.home() / "Library/Application Support/GOG.com/Galaxy/Applications"
|
||||
if galaxy_base.is_dir():
|
||||
for child in galaxy_base.iterdir():
|
||||
candidate = child / "Mount & Blade II Bannerlord" / BANNERLORD_EXE_RELATIVE
|
||||
if candidate.is_file():
|
||||
install.game_dir = str(candidate.parent.parent)
|
||||
install.game_exe = str(candidate)
|
||||
install.found = True
|
||||
install.source = "gog-galaxy"
|
||||
return install
|
||||
|
||||
# Last resort: find
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["find", "/Applications", str(Path.home() / "GOG Games"),
|
||||
str(Path.home() / "Games"), "-name", "Bannerlord.exe",
|
||||
"-type", "f"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
first_line = result.stdout.strip().split("\n")[0]
|
||||
install.game_exe = first_line
|
||||
install.game_dir = str(Path(first_line).parent.parent)
|
||||
install.found = True
|
||||
install.source = "search"
|
||||
return install
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
return install
|
||||
|
||||
|
||||
def check_local_readiness() -> LocalState:
|
||||
"""Full local readiness check. Returns complete state."""
|
||||
state = LocalState()
|
||||
state.is_macos = detect_macos()
|
||||
|
||||
if not state.is_macos:
|
||||
state.status = LocalStatus.ERROR
|
||||
state.message = "Not macOS — local manager is Mac-only"
|
||||
return state
|
||||
|
||||
state.emulator = detect_emulator()
|
||||
if not state.emulator.found:
|
||||
state.status = LocalStatus.MISSING_EMULATOR
|
||||
state.message = "No Windows emulator found (install Whisky, CrossOver, or wine)"
|
||||
return state
|
||||
|
||||
state.game = detect_game()
|
||||
if not state.game.found:
|
||||
state.status = LocalStatus.MISSING_GAME
|
||||
state.message = "Bannerlord GOG installation not found in known paths"
|
||||
return state
|
||||
|
||||
# Check if already running
|
||||
pid = _read_pid()
|
||||
if pid and _is_process_running(pid):
|
||||
state.status = LocalStatus.RUNNING
|
||||
state.process_id = pid
|
||||
state.message = f"Bannerlord already running (PID: {pid})"
|
||||
else:
|
||||
state.status = LocalStatus.READY
|
||||
state.message = "Ready to launch"
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def launch_bannerlord(state: Optional[LocalState] = None) -> LocalState:
|
||||
"""Launch Bannerlord via the emulator. Returns updated state."""
|
||||
if state is None:
|
||||
state = check_local_readiness()
|
||||
|
||||
if state.status not in (LocalStatus.READY, LocalStatus.RUNNING):
|
||||
return state
|
||||
|
||||
if state.status == LocalStatus.RUNNING:
|
||||
state.message = f"Already running (PID: {state.process_id})"
|
||||
return state
|
||||
|
||||
# Check if launcher script exists
|
||||
if LAUNCHER_SCRIPT.is_file():
|
||||
log.info(f"Using launcher script: {LAUNCHER_SCRIPT}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", str(LAUNCHER_SCRIPT), "--launch"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
cwd=str(LAUNCHER_SCRIPT.parent.parent),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Parse PID from output
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if "PID:" in line:
|
||||
try:
|
||||
pid = int(line.split("PID:")[1].strip().rstrip(")"))
|
||||
state.process_id = pid
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
state.status = LocalStatus.RUNNING
|
||||
state.message = "Launched via launcher script"
|
||||
return state
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
log.warning(f"Launcher script failed: {e}, falling back to direct launch")
|
||||
|
||||
# Direct launch fallback
|
||||
try:
|
||||
log.info(f"Launching Bannerlord directly via {state.emulator.name}")
|
||||
proc = subprocess.Popen(
|
||||
[state.emulator.path, state.game.game_exe],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
cwd=state.game.game_dir,
|
||||
)
|
||||
state.process_id = proc.pid
|
||||
state.status = LocalStatus.RUNNING
|
||||
state.message = f"Launched (PID: {proc.pid})"
|
||||
_write_pid(proc.pid)
|
||||
except Exception as e:
|
||||
state.status = LocalStatus.CRASHED
|
||||
state.message = f"Launch failed: {e}"
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def stop_bannerlord() -> bool:
|
||||
"""Stop a running Bannerlord process."""
|
||||
pid = _read_pid()
|
||||
if not pid or not _is_process_running(pid):
|
||||
_clear_pid()
|
||||
return False
|
||||
|
||||
try:
|
||||
os.kill(pid, 15) # SIGTERM
|
||||
time.sleep(1)
|
||||
if _is_process_running(pid):
|
||||
os.kill(pid, 9) # SIGKILL
|
||||
_clear_pid()
|
||||
log.info(f"Stopped Bannerlord (PID: {pid})")
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
_clear_pid()
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PID FILE MANAGEMENT
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
PID_FILE = Path("/tmp/bannerlord.pid")
|
||||
|
||||
|
||||
def _read_pid() -> Optional[int]:
|
||||
try:
|
||||
if PID_FILE.is_file():
|
||||
return int(PID_FILE.read_text().strip())
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _write_pid(pid: int):
|
||||
try:
|
||||
PID_FILE.write_text(str(pid))
|
||||
except OSError as e:
|
||||
log.warning(f"Failed to write PID file: {e}")
|
||||
|
||||
|
||||
def _clear_pid():
|
||||
try:
|
||||
if PID_FILE.is_file():
|
||||
PID_FILE.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _is_process_running(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
parser = argparse.ArgumentParser(description="Bannerlord Local Manager — macOS")
|
||||
parser.add_argument("--check", action="store_true", help="Check readiness")
|
||||
parser.add_argument("--launch", action="store_true", help="Launch the game")
|
||||
parser.add_argument("--stop", action="store_true", help="Stop running game")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.stop:
|
||||
stopped = stop_bannerlord()
|
||||
if args.json:
|
||||
print(json.dumps({"stopped": stopped}))
|
||||
else:
|
||||
print("Stopped." if stopped else "Not running.")
|
||||
return
|
||||
|
||||
if args.launch:
|
||||
state = launch_bannerlord()
|
||||
else:
|
||||
state = check_local_readiness()
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(state.to_dict(), indent=2))
|
||||
else:
|
||||
print(f"Status: {state.status.value}")
|
||||
print(f"Emulator: {state.emulator.name or 'none'} ({state.emulator.emulator_type.value})")
|
||||
print(f"Game: {state.game.game_dir or 'not found'}")
|
||||
if state.process_id:
|
||||
print(f"PID: {state.process_id}")
|
||||
print(f"Message: {state.message}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
11
portals.json
11
portals.json
@@ -23,22 +23,13 @@
|
||||
"rotation": { "y": 0.5 },
|
||||
"portal_type": "game-world",
|
||||
"world_category": "strategy-rpg",
|
||||
"environment": "local",
|
||||
"environment": "production",
|
||||
"access_mode": "operator",
|
||||
"readiness_state": "active",
|
||||
"telemetry_source": "hermes-harness:bannerlord",
|
||||
"owner": "Timmy",
|
||||
"app_id": 261550,
|
||||
"window_title": "Mount & Blade II: Bannerlord",
|
||||
"local_launch": {
|
||||
"platform": "macos",
|
||||
"source": "gog",
|
||||
"emulator_required": true,
|
||||
"emulator_options": ["whisky", "crossover", "wine"],
|
||||
"launcher": "scripts/bannerlord_launcher.sh",
|
||||
"harness_bridge": "nexus/bannerlord_local.py",
|
||||
"check_command": "python3 -m nexus.bannerlord_local --check --json"
|
||||
},
|
||||
"destination": {
|
||||
"url": "https://bannerlord.timmy.foundation",
|
||||
"type": "harness",
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bannerlord Local Launcher for macOS
|
||||
# Detects Wine/Whisky/CrossOver, finds GOG Bannerlord install, launches it.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/bannerlord_launcher.sh [--check] [--launch] [--verbose]
|
||||
#
|
||||
# Modes:
|
||||
# --check Check environment only (no launch). Exits 0 if ready.
|
||||
# --launch Launch the game (default if no flags)
|
||||
# --verbose Print detailed diagnostic info
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONFIGURATION
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
BANNERLORD_EXE="bin/Generic/Bannerlord.exe"
|
||||
GOG_PATHS=(
|
||||
"/Applications/Games/Mount & Blade II Bannerlord"
|
||||
"$HOME/GOG Games/Mount and Blade II Bannerlord"
|
||||
"$HOME/Games/Mount & Blade II Bannerlord"
|
||||
"/Applications/Mount & Blade II Bannerlord"
|
||||
)
|
||||
# Also check common GOG Galaxy paths
|
||||
GOG_GALAXY_PATHS=(
|
||||
"$HOME/Library/Application Support/GOG.com/Galaxy/Applications/*/Mount & Blade II Bannerlord"
|
||||
)
|
||||
|
||||
# Emulator priority: Whisky > CrossOver > Homebrew Wine > system wine
|
||||
EMULATOR_NAMES=("Whisky" "CrossOver" "Wine" "wine64" "wine")
|
||||
|
||||
VERBOSE=0
|
||||
CHECK_ONLY=0
|
||||
LAUNCH=0
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ARGUMENT PARSING
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--check) CHECK_ONLY=1 ;;
|
||||
--launch) LAUNCH=1 ;;
|
||||
--verbose) VERBOSE=1 ;;
|
||||
*) echo "Unknown arg: $arg"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$CHECK_ONLY" -eq 0 ] && [ "$LAUNCH" -eq 0 ]; then
|
||||
LAUNCH=1 # Default to launch mode
|
||||
fi
|
||||
|
||||
log() { echo "[bannerlord] $*"; }
|
||||
vlog() { [ "$VERBOSE" -eq 1 ] && echo "[bannerlord:debug] $*" || true; }
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# EMULATOR DETECTION
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
find_emulator() {
|
||||
local emulator_path=""
|
||||
local emulator_name=""
|
||||
local emulator_type=""
|
||||
|
||||
# Check for Whisky (macOS Wine wrapper)
|
||||
if [ -d "/Applications/Whisky.app" ]; then
|
||||
emulator_path="/Applications/Whisky.app/Contents/Resources/Libraries/wine/bin/wine64"
|
||||
if [ -x "$emulator_path" ]; then
|
||||
emulator_name="Whisky"
|
||||
emulator_type="whisky"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for CrossOver
|
||||
if [ -z "$emulator_path" ] && [ -d "/Applications/CrossOver.app" ]; then
|
||||
emulator_path="/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine"
|
||||
if [ -x "$emulator_path" ]; then
|
||||
emulator_name="CrossOver"
|
||||
emulator_type="crossover"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for Homebrew wine
|
||||
if [ -z "$emulator_path" ]; then
|
||||
for candidate in wine64 wine; do
|
||||
if command -v "$candidate" >/dev/null 2>&1; then
|
||||
emulator_path="$(command -v "$candidate")"
|
||||
emulator_name="$candidate"
|
||||
emulator_type="wine"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "$emulator_path" ]; then
|
||||
EMULATOR_PATH="$emulator_path"
|
||||
EMULATOR_NAME="$emulator_name"
|
||||
EMULATOR_TYPE="$emulator_type"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# GAME DETECTION
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
find_bannerlord() {
|
||||
# Check standard GOG paths
|
||||
for path in "${GOG_PATHS[@]}"; do
|
||||
if [ -f "$path/$BANNERLORD_EXE" ]; then
|
||||
GAME_DIR="$path"
|
||||
GAME_EXE="$path/$BANNERLORD_EXE"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Check GOG Galaxy paths (glob expansion)
|
||||
for pattern in "${GOG_GALAXY_PATHS[@]}"; do
|
||||
# shellcheck disable=SC2086
|
||||
for path in $pattern; do
|
||||
if [ -d "$path" ] && [ -f "$path/$BANNERLORD_EXE" ]; then
|
||||
GAME_DIR="$path"
|
||||
GAME_EXE="$path/$BANNERLORD_EXE"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Search with find as last resort
|
||||
local found
|
||||
found=$(find /Applications "$HOME/GOG Games" "$HOME/Games" -name "Bannerlord.exe" -type f 2>/dev/null | head -1)
|
||||
if [ -n "$found" ]; then
|
||||
GAME_EXE="$found"
|
||||
GAME_DIR="$(dirname "$(dirname "$found")")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# STATUS REPORTING
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
emit_status() {
|
||||
local status="$1"
|
||||
local message="$2"
|
||||
# JSON output for harness consumption
|
||||
echo "{\"status\":\"$status\",\"emulator\":\"${EMULATOR_NAME:-none}\",\"emulator_type\":\"${EMULATOR_TYPE:-none}\",\"game_dir\":\"${GAME_DIR:-}\",\"game_exe\":\"${GAME_EXE:-}\",\"message\":\"$message\"}"
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# MAIN
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
main() {
|
||||
# Verify macOS
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
emit_status "error" "Not macOS — this launcher is Mac-only"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Bannerlord Local Launcher — macOS"
|
||||
|
||||
# Find emulator
|
||||
if find_emulator; then
|
||||
log "Emulator found: $EMULATOR_NAME ($EMULATOR_PATH)"
|
||||
vlog " Type: $EMULATOR_TYPE"
|
||||
else
|
||||
log "ERROR: No Windows emulator found."
|
||||
log "Install one of: Whisky, CrossOver, or wine (brew install --cask wine-stable)"
|
||||
emit_status "missing_emulator" "No Windows emulator installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find game
|
||||
if find_bannerlord; then
|
||||
log "Bannerlord found: $GAME_DIR"
|
||||
vlog " Exe: $GAME_EXE"
|
||||
else
|
||||
log "ERROR: Bannerlord not found in known GOG paths."
|
||||
log "Checked: ${GOG_PATHS[*]}"
|
||||
emit_status "missing_game" "Bannerlord GOG installation not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check mode
|
||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||
log "Check passed. Ready to launch."
|
||||
emit_status "ready" "Emulator and game both found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Launch
|
||||
if [ "$LAUNCH" -eq 1 ]; then
|
||||
log "Launching Bannerlord via $EMULATOR_NAME..."
|
||||
emit_status "launching" "Starting Bannerlord through $EMULATOR_NAME"
|
||||
|
||||
cd "$GAME_DIR"
|
||||
# Launch in background, redirect output
|
||||
"$EMULATOR_PATH" "$GAME_EXE" "$@" >/dev/null 2>&1 &
|
||||
local pid=$!
|
||||
log "Bannerlord started (PID: $pid)"
|
||||
echo "$pid" > /tmp/bannerlord.pid
|
||||
|
||||
# Wait a moment and check it's still running
|
||||
sleep 2
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
log "Bannerlord is running."
|
||||
emit_status "running" "Bannerlord PID $pid"
|
||||
exit 0
|
||||
else
|
||||
log "WARNING: Bannerlord process exited quickly. Check Wine logs."
|
||||
emit_status "crashed" "Process exited within 2 seconds"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user