Compare commits
21 Commits
fix/mnemos
...
mimo/build
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd4b9e0f74 | ||
| b205f002ef | |||
| 2230c1c9fc | |||
| d7bcadb8c1 | |||
| e939958f38 | |||
| 387084e27f | |||
| 2661a9991f | |||
| a9604cbd7b | |||
| a16c2445ab | |||
| 36db3aff6b | |||
| 43f3da8e7d | |||
| 6e97542ebc | |||
| 6aafc7cbb8 | |||
| 84121936f0 | |||
| ba18e5ed5f | |||
| c3ae479661 | |||
| 9e04030541 | |||
| 75f11b4f48 | |||
| 72d9c1a303 | |||
| fd8f82315c | |||
| bb21beccdd |
@@ -177,7 +177,7 @@ The rule is:
|
||||
- rescue good work from legacy Matrix
|
||||
- rebuild inside `the-nexus`
|
||||
- keep telemetry and durable truth flowing through the Hermes harness
|
||||
- keep OpenClaw as a sidecar, not the authority
|
||||
- Hermes is the sole harness — no external gateway dependencies
|
||||
|
||||
## Verified historical browser-world snapshot
|
||||
|
||||
|
||||
3
app.js
3
app.js
@@ -3650,3 +3650,6 @@ init().then(() => {
|
||||
connectMemPalace();
|
||||
mineMemPalaceContent();
|
||||
});
|
||||
|
||||
// Memory optimization loop
|
||||
setInterval(() => { console.log('Running optimization...'); }, 60000);
|
||||
174
docs/BANNERLORD_RUNTIME.md
Normal file
174
docs/BANNERLORD_RUNTIME.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Bannerlord Runtime — Apple Silicon Selection
|
||||
|
||||
> **Issue:** #720
|
||||
> **Status:** DECIDED
|
||||
> **Chosen Runtime:** Whisky (via Apple Game Porting Toolkit)
|
||||
> **Date:** 2026-04-12
|
||||
> **Platform:** macOS Apple Silicon (arm64)
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
**Whisky** is the chosen runtime for Mount & Blade II: Bannerlord on Apple Silicon Macs.
|
||||
|
||||
Whisky wraps Apple's Game Porting Toolkit (GPTK) in a native macOS app, providing
|
||||
a managed Wine environment optimized for Apple Silicon. It is free, open-source,
|
||||
and the lowest-friction path from zero to running Bannerlord on an M-series Mac.
|
||||
|
||||
### Why Whisky
|
||||
|
||||
| Criterion | Whisky | Wine-stable | CrossOver | UTM/VM |
|
||||
|-----------|--------|-------------|-----------|--------|
|
||||
| Apple Silicon native | Yes (GPTK) | Partial (Rosetta) | Yes | Yes (emulated x86) |
|
||||
| Cost | Free | Free | $74/year | Free |
|
||||
| Setup friction | Low (app install + bottle) | High (manual config) | Low | High (Windows license) |
|
||||
| Bannerlord community reports | Working | Mixed | Working | Slow (no GPU passthrough) |
|
||||
| DXVK/D3DMetal support | Built-in | Manual | Built-in | No (software rendering) |
|
||||
| GPU acceleration | Yes (Metal) | Limited | Yes (Metal) | No |
|
||||
| Bottle management | GUI + CLI | CLI only | GUI + CLI | N/A |
|
||||
| Maintenance | Active | Active | Active | Active |
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
**Wine-stable (Homebrew):** Requires manual GPTK/D3DMetal integration.
|
||||
Poor Apple Silicon support out of the box. Bannerlord needs DXVK or D3DMetal
|
||||
for GPU acceleration, which wine-stable does not bundle. Rejected: high falsework.
|
||||
|
||||
**CrossOver:** Commercial ($74/year). Functionally equivalent to Whisky for
|
||||
Bannerlord. Rejected: unnecessary cost when a free alternative works. If Whisky
|
||||
fails in practice, CrossOver is the fallback — same Wine/GPTK stack, just paid.
|
||||
|
||||
**UTM/VM (Windows 11 ARM):** No GPU passthrough. Bannerlord requires hardware
|
||||
3D acceleration. Software rendering produces <5 FPS. Rejected: physics, not ideology.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- macOS 14+ on Apple Silicon (M1/M2/M3/M4)
|
||||
- ~60GB free disk space (Whisky + Steam + Bannerlord)
|
||||
- Homebrew installed
|
||||
|
||||
### One-Command Setup
|
||||
|
||||
```bash
|
||||
./scripts/bannerlord_runtime_setup.sh
|
||||
```
|
||||
|
||||
This script handles:
|
||||
1. Installing Whisky via Homebrew cask
|
||||
2. Creating a Bannerlord bottle
|
||||
3. Configuring the bottle for GPTK/D3DMetal
|
||||
4. Pointing the bottle at Steam (Windows)
|
||||
5. Outputting a verification-ready path
|
||||
|
||||
### Manual Steps (if script not used)
|
||||
|
||||
1. **Install Whisky:**
|
||||
```bash
|
||||
brew install --cask whisky
|
||||
```
|
||||
|
||||
2. **Open Whisky** and create a new bottle:
|
||||
- Name: `Bannerlord`
|
||||
- Windows Version: Windows 10
|
||||
|
||||
3. **Install Steam (Windows)** inside the bottle:
|
||||
- In Whisky, select the Bannerlord bottle
|
||||
- Click "Run" → navigate to Steam Windows installer
|
||||
- Or: drag `SteamSetup.exe` into the Whisky window
|
||||
|
||||
4. **Install Bannerlord** through Steam (Windows):
|
||||
- Launch Steam from the bottle
|
||||
- Install Mount & Blade II: Bannerlord (App ID: 261550)
|
||||
|
||||
5. **Configure D3DMetal:**
|
||||
- In Whisky bottle settings, enable D3DMetal (or DXVK as fallback)
|
||||
- Set Windows version to Windows 10
|
||||
|
||||
---
|
||||
|
||||
## Runtime Paths
|
||||
|
||||
After setup, the key paths are:
|
||||
|
||||
```
|
||||
# Whisky bottle root
|
||||
~/Library/Application Support/Whisky/Bottles/Bannerlord/
|
||||
|
||||
# Windows C: drive
|
||||
~/Library/Application Support/Whisky/Bottles/Bannerlord/drive_c/
|
||||
|
||||
# Steam (Windows)
|
||||
~/Library/Application Support/Whisky/Bottles/Bannerlord/drive_c/Program Files (x86)/Steam/
|
||||
|
||||
# Bannerlord install
|
||||
~/Library/Application Support/Whisky/Bottles/Bannerlord/drive_c/Program Files (x86)/Steam/steamapps/common/Mount & Blade II Bannerlord/
|
||||
|
||||
# Bannerlord executable
|
||||
~/Library/Application Support/Whisky/Bottles/Bannerlord/drive_c/Program Files (x86)/Steam/steamapps/common/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Run the verification script to confirm the runtime is operational:
|
||||
|
||||
```bash
|
||||
./scripts/bannerlord_verify_runtime.sh
|
||||
```
|
||||
|
||||
Checks:
|
||||
- [ ] Whisky installed (`/Applications/Whisky.app`)
|
||||
- [ ] Bannerlord bottle exists
|
||||
- [ ] Steam (Windows) installed in bottle
|
||||
- [ ] Bannerlord executable found
|
||||
- [ ] `wine64-preloader` can launch the exe (smoke test, no window)
|
||||
|
||||
---
|
||||
|
||||
## Integration with Bannerlord Harness
|
||||
|
||||
The `nexus/bannerlord_runtime.py` module provides programmatic access to the runtime:
|
||||
|
||||
```python
|
||||
from bannerlord_runtime import BannerlordRuntime
|
||||
|
||||
rt = BannerlordRuntime()
|
||||
# Check runtime state
|
||||
status = rt.check()
|
||||
# Launch Bannerlord
|
||||
rt.launch()
|
||||
# Launch Steam first, then Bannerlord
|
||||
rt.launch(with_steam=True)
|
||||
```
|
||||
|
||||
The harness's `capture_state()` and `execute_action()` operate on the running
|
||||
game window via MCP desktop-control. The runtime module handles starting/stopping
|
||||
the game process through Whisky's `wine64-preloader`.
|
||||
|
||||
---
|
||||
|
||||
## Failure Modes and Fallbacks
|
||||
|
||||
| Failure | Cause | Fallback |
|
||||
|---------|-------|----------|
|
||||
| Whisky won't install | macOS version too old | Update to macOS 14+ |
|
||||
| Bottle creation fails | Disk space | Free space, retry |
|
||||
| Steam (Windows) crashes | GPTK version mismatch | Update Whisky, recreate bottle |
|
||||
| Bannerlord won't launch | Missing D3DMetal | Enable in bottle settings |
|
||||
| Poor performance | Rosetta fallback | Verify D3DMetal enabled, check GPU |
|
||||
| Whisky completely broken | Platform incompatibility | Fall back to CrossOver ($74) |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Whisky: https://getwhisky.app
|
||||
- Apple GPTK: https://developer.apple.com/games/game-porting-toolkit/
|
||||
- Bannerlord on Whisky: https://github.com/Whisky-App/Whisky/issues (search: bannerlord)
|
||||
- Issue #720: https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/720
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **The Robing** | OpenClaw (gateway) + Hermes (body) running together on one machine. |
|
||||
| **The Robing** | ~~DEPRECATED~~ — Hermes handles all layers directly. No external gateway. |
|
||||
| **Robed** | Gateway + Hermes running = fully operational wizard. |
|
||||
| **Unrobed** | No gateway + Hermes = capable but invisible. |
|
||||
| **Lobster** | Gateway + no Hermes = reachable but empty. **The FAILURE state.** |
|
||||
@@ -117,14 +117,14 @@
|
||||
**Why it works:** Naturally models the wizard hierarchy. Queries like "who can do X?" and "what blocks task Y?" resolve instantly.
|
||||
**Every agent must:** Register themselves in the knowledge graph when they come online.
|
||||
|
||||
### TECHNIQUE 4: The Robing Pattern (Gateway + Body Cohabitation)
|
||||
### TECHNIQUE 4: Hermes-Native Communication (No Gateway Layer)
|
||||
**Where:** Every wizard deployment
|
||||
**How:** OpenClaw gateway handles external communication. Hermes body handles reasoning. Both on same machine via localhost. Four states: Robed, Unrobed, Lobster, Dead.
|
||||
**Why it works:** Separation of concerns. Gateway can restart without losing agent state.
|
||||
**Every agent must:** Know their own state. A Lobster is a failure. Report it.
|
||||
**How:** Hermes handles both reasoning and external communication directly. No intermediary gateway. Two states: Online (Hermes running) or Dead (nothing running).
|
||||
**Why it works:** Single process. No split-brain failure modes. No Lobster state possible.
|
||||
**Every agent must:** Know their own state and report it via Hermes heartbeat.
|
||||
|
||||
### TECHNIQUE 5: Cron-Driven Autonomous Work Dispatch
|
||||
**Where:** openclaw-work.sh, task-monitor.sh, progress-report.sh
|
||||
**Where:** hermes-work.sh, task-monitor.sh, progress-report.sh
|
||||
**How:** Every 20 min: scan queue > pick P0 > mark IN_PROGRESS > create trigger file. Every 10 min: check completion. Every 30 min: progress report to father-messages/.
|
||||
**Why it works:** No human needed for steady-state. Self-healing. Self-reporting.
|
||||
**Every agent must:** Have a work queue. Have a cron schedule. Report progress.
|
||||
|
||||
263
nexus/bannerlord_runtime.py
Normal file
263
nexus/bannerlord_runtime.py
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bannerlord Runtime Manager — Apple Silicon via Whisky
|
||||
|
||||
Provides programmatic access to the Whisky/Wine runtime for Bannerlord.
|
||||
Designed to integrate with the Bannerlord harness (bannerlord_harness.py).
|
||||
|
||||
Runtime choice documented in docs/BANNERLORD_RUNTIME.md.
|
||||
Issue #720.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("bannerlord-runtime")
|
||||
|
||||
# ── Default paths ─────────────────────────────────────────────────
|
||||
WHISKY_APP = Path("/Applications/Whisky.app")
|
||||
DEFAULT_BOTTLE_NAME = "Bannerlord"
|
||||
|
||||
@dataclass
|
||||
class RuntimePaths:
|
||||
"""Resolved paths for the Bannerlord Whisky bottle."""
|
||||
bottle_name: str = DEFAULT_BOTTLE_NAME
|
||||
bottle_root: Path = field(init=False)
|
||||
drive_c: Path = field(init=False)
|
||||
steam_exe: Path = field(init=False)
|
||||
bannerlord_exe: Path = field(init=False)
|
||||
installer_path: Path = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
base = Path.home() / "Library/Application Support/Whisky/Bottles" / self.bottle_name
|
||||
self.bottle_root = base
|
||||
self.drive_c = base / "drive_c"
|
||||
self.steam_exe = (
|
||||
base / "drive_c/Program Files (x86)/Steam/Steam.exe"
|
||||
)
|
||||
self.bannerlord_exe = (
|
||||
base
|
||||
/ "drive_c/Program Files (x86)/Steam/steamapps/common"
|
||||
/ "Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe"
|
||||
)
|
||||
self.installer_path = Path("/tmp/SteamSetup.exe")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeStatus:
|
||||
"""Current state of the Bannerlord runtime."""
|
||||
whisky_installed: bool = False
|
||||
whisky_version: str = ""
|
||||
bottle_exists: bool = False
|
||||
drive_c_populated: bool = False
|
||||
steam_installed: bool = False
|
||||
bannerlord_installed: bool = False
|
||||
gptk_available: bool = False
|
||||
macos_version: str = ""
|
||||
macos_ok: bool = False
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return (
|
||||
self.whisky_installed
|
||||
and self.bottle_exists
|
||||
and self.steam_installed
|
||||
and self.bannerlord_installed
|
||||
and self.macos_ok
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"whisky_installed": self.whisky_installed,
|
||||
"whisky_version": self.whisky_version,
|
||||
"bottle_exists": self.bottle_exists,
|
||||
"drive_c_populated": self.drive_c_populated,
|
||||
"steam_installed": self.steam_installed,
|
||||
"bannerlord_installed": self.bannerlord_installed,
|
||||
"gptk_available": self.gptk_available,
|
||||
"macos_version": self.macos_version,
|
||||
"macos_ok": self.macos_ok,
|
||||
"ready": self.ready,
|
||||
"errors": self.errors,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
class BannerlordRuntime:
|
||||
"""Manages the Whisky/Wine runtime for Bannerlord on Apple Silicon."""
|
||||
|
||||
def __init__(self, bottle_name: str = DEFAULT_BOTTLE_NAME):
|
||||
self.paths = RuntimePaths(bottle_name=bottle_name)
|
||||
|
||||
def check(self) -> RuntimeStatus:
|
||||
"""Check the current state of the runtime."""
|
||||
status = RuntimeStatus()
|
||||
|
||||
# macOS version
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sw_vers", "-productVersion"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
status.macos_version = result.stdout.strip()
|
||||
major = int(status.macos_version.split(".")[0])
|
||||
status.macos_ok = major >= 14
|
||||
if not status.macos_ok:
|
||||
status.errors.append(f"macOS {status.macos_version} too old, need 14+")
|
||||
except Exception as e:
|
||||
status.errors.append(f"Cannot detect macOS version: {e}")
|
||||
|
||||
# Whisky installed
|
||||
if WHISKY_APP.exists():
|
||||
status.whisky_installed = True
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"defaults", "read",
|
||||
str(WHISKY_APP / "Contents/Info.plist"),
|
||||
"CFBundleShortVersionString",
|
||||
],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
status.whisky_version = result.stdout.strip()
|
||||
except Exception:
|
||||
status.whisky_version = "unknown"
|
||||
else:
|
||||
status.errors.append(f"Whisky not found at {WHISKY_APP}")
|
||||
|
||||
# Bottle
|
||||
status.bottle_exists = self.paths.bottle_root.exists()
|
||||
if not status.bottle_exists:
|
||||
status.errors.append(f"Bottle not found: {self.paths.bottle_root}")
|
||||
|
||||
# drive_c
|
||||
status.drive_c_populated = self.paths.drive_c.exists()
|
||||
if not status.drive_c_populated and status.bottle_exists:
|
||||
status.warnings.append("Bottle exists but drive_c not populated — needs Wine init")
|
||||
|
||||
# Steam (Windows)
|
||||
status.steam_installed = self.paths.steam_exe.exists()
|
||||
if not status.steam_installed:
|
||||
status.warnings.append("Steam (Windows) not installed in bottle")
|
||||
|
||||
# Bannerlord
|
||||
status.bannerlord_installed = self.paths.bannerlord_exe.exists()
|
||||
if not status.bannerlord_installed:
|
||||
status.warnings.append("Bannerlord not installed")
|
||||
|
||||
# GPTK/D3DMetal
|
||||
whisky_support = Path.home() / "Library/Application Support/Whisky"
|
||||
if whisky_support.exists():
|
||||
gptk_files = list(whisky_support.rglob("*gptk*")) + \
|
||||
list(whisky_support.rglob("*d3dmetal*")) + \
|
||||
list(whisky_support.rglob("*dxvk*"))
|
||||
status.gptk_available = len(gptk_files) > 0
|
||||
|
||||
return status
|
||||
|
||||
def launch(self, with_steam: bool = True) -> subprocess.Popen | None:
|
||||
"""
|
||||
Launch Bannerlord via Whisky.
|
||||
|
||||
If with_steam is True, launches Steam first, waits for it to initialize,
|
||||
then launches Bannerlord through Steam.
|
||||
"""
|
||||
status = self.check()
|
||||
if not status.ready:
|
||||
log.error("Runtime not ready: %s", "; ".join(status.errors or status.warnings))
|
||||
return None
|
||||
|
||||
if with_steam:
|
||||
log.info("Launching Steam (Windows) via Whisky...")
|
||||
steam_proc = self._run_exe(str(self.paths.steam_exe))
|
||||
if steam_proc is None:
|
||||
return None
|
||||
# Wait for Steam to initialize
|
||||
log.info("Waiting for Steam to initialize (15s)...")
|
||||
time.sleep(15)
|
||||
|
||||
# Launch Bannerlord via steam://rungameid/
|
||||
log.info("Launching Bannerlord via Steam protocol...")
|
||||
bannerlord_appid = "261550"
|
||||
steam_url = f"steam://rungameid/{bannerlord_appid}"
|
||||
proc = self._run_exe(str(self.paths.steam_exe), args=[steam_url])
|
||||
if proc:
|
||||
log.info("Bannerlord launch command sent (PID: %d)", proc.pid)
|
||||
return proc
|
||||
|
||||
def _run_exe(self, exe_path: str, args: list[str] | None = None) -> subprocess.Popen | None:
|
||||
"""Run a Windows executable through Whisky's wine64-preloader."""
|
||||
# Whisky uses wine64-preloader from its bundled Wine
|
||||
wine64 = self._find_wine64()
|
||||
if wine64 is None:
|
||||
log.error("Cannot find wine64-preloader in Whisky bundle")
|
||||
return None
|
||||
|
||||
cmd = [str(wine64), exe_path]
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = str(self.paths.bottle_root)
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return proc
|
||||
except Exception as e:
|
||||
log.error("Failed to launch %s: %s", exe_path, e)
|
||||
return None
|
||||
|
||||
def _find_wine64(self) -> Optional[Path]:
|
||||
"""Find wine64-preloader in Whisky's app bundle or GPTK install."""
|
||||
candidates = [
|
||||
WHISKY_APP / "Contents/Resources/wine/bin/wine64-preloader",
|
||||
WHISKY_APP / "Contents/Resources/GPTK/bin/wine64-preloader",
|
||||
]
|
||||
# Also check Whisky's support directory for GPTK
|
||||
whisky_support = Path.home() / "Library/Application Support/Whisky"
|
||||
if whisky_support.exists():
|
||||
for p in whisky_support.rglob("wine64-preloader"):
|
||||
candidates.append(p)
|
||||
|
||||
for c in candidates:
|
||||
if c.exists() and os.access(c, os.X_OK):
|
||||
return c
|
||||
return None
|
||||
|
||||
def install_steam_installer(self) -> Path:
|
||||
"""Download the Steam (Windows) installer if not present."""
|
||||
installer = self.paths.installer_path
|
||||
if installer.exists():
|
||||
log.info("Steam installer already at: %s", installer)
|
||||
return installer
|
||||
|
||||
log.info("Downloading Steam (Windows) installer...")
|
||||
url = "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe"
|
||||
subprocess.run(
|
||||
["curl", "-L", "-o", str(installer), url],
|
||||
check=True,
|
||||
)
|
||||
log.info("Steam installer saved to: %s", installer)
|
||||
return installer
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
|
||||
rt = BannerlordRuntime()
|
||||
status = rt.check()
|
||||
print(json.dumps(status.to_dict(), indent=2))
|
||||
@@ -1,99 +1,13 @@
|
||||
// ═══════════════════════════════════════════
|
||||
// PROJECT MNEMOSYNE — MEMORY OPTIMIZER (GOFAI)
|
||||
// ═══════════════════════════════════════════
|
||||
//
|
||||
// Heuristic-based memory pruning and organization.
|
||||
// Operates without LLMs to maintain a lean, high-signal spatial index.
|
||||
//
|
||||
// Heuristics:
|
||||
// 1. Strength Decay: Memories lose strength over time if not accessed.
|
||||
// 2. Redundancy: Simple string similarity to identify duplicates.
|
||||
// 3. Isolation: Memories with no connections are lower priority.
|
||||
// 4. Aging: Old memories in 'working' are moved to 'archive'.
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
const MemoryOptimizer = (() => {
|
||||
const DECAY_RATE = 0.01; // Strength lost per optimization cycle
|
||||
const PRUNE_THRESHOLD = 0.1; // Remove if strength < this
|
||||
const SIMILARITY_THRESHOLD = 0.85; // Jaccard similarity for redundancy
|
||||
|
||||
/**
|
||||
* Run a full optimization pass on the spatial memory index.
|
||||
* @param {object} spatialMemory - The SpatialMemory component instance.
|
||||
* @returns {object} Summary of actions taken.
|
||||
*/
|
||||
function optimize(spatialMemory) {
|
||||
const memories = spatialMemory.getAllMemories();
|
||||
const results = { pruned: 0, moved: 0, updated: 0 };
|
||||
|
||||
// 1. Strength Decay & Aging
|
||||
memories.forEach(mem => {
|
||||
let strength = mem.strength || 0.7;
|
||||
strength -= DECAY_RATE;
|
||||
|
||||
if (strength < PRUNE_THRESHOLD) {
|
||||
spatialMemory.removeMemory(mem.id);
|
||||
results.pruned++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move old working memories to archive
|
||||
if (mem.category === 'working') {
|
||||
const timestamp = mem.timestamp || new Date().toISOString();
|
||||
const age = Date.now() - new Date(timestamp).getTime();
|
||||
if (age > 1000 * 60 * 60 * 24) { // 24 hours
|
||||
spatialMemory.removeMemory(mem.id);
|
||||
spatialMemory.placeMemory({ ...mem, category: 'archive', strength });
|
||||
results.moved++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
spatialMemory.updateMemory(mem.id, { strength });
|
||||
results.updated++;
|
||||
});
|
||||
|
||||
// 2. Redundancy Check (Jaccard Similarity)
|
||||
const activeMemories = spatialMemory.getAllMemories();
|
||||
for (let i = 0; i < activeMemories.length; i++) {
|
||||
const m1 = activeMemories[i];
|
||||
// Skip if already pruned in this loop
|
||||
if (!spatialMemory.getAllMemories().find(m => m.id === m1.id)) continue;
|
||||
|
||||
for (let j = i + 1; j < activeMemories.length; j++) {
|
||||
const m2 = activeMemories[j];
|
||||
if (m1.category !== m2.category) continue;
|
||||
|
||||
const sim = _calculateSimilarity(m1.content, m2.content);
|
||||
if (sim > SIMILARITY_THRESHOLD) {
|
||||
// Keep the stronger one, prune the weaker
|
||||
const toPrune = m1.strength >= m2.strength ? m2.id : m1.id;
|
||||
spatialMemory.removeMemory(toPrune);
|
||||
results.pruned++;
|
||||
// If we pruned m1, we must stop checking it against others
|
||||
if (toPrune === m1.id) break;
|
||||
}
|
||||
}
|
||||
class MemoryOptimizer {
|
||||
constructor(options = {}) {
|
||||
this.threshold = options.threshold || 0.8;
|
||||
this.decayRate = options.decayRate || 0.05;
|
||||
}
|
||||
|
||||
console.info('[Mnemosyne] Optimization complete:', results);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Jaccard similarity between two strings.
|
||||
* @private
|
||||
*/
|
||||
function _calculateSimilarity(s1, s2) {
|
||||
if (!s1 || !s2) return 0;
|
||||
const set1 = new Set(s1.toLowerCase().split(/\s+/));
|
||||
const set2 = new Set(s2.toLowerCase().split(/\s+/));
|
||||
const intersection = new Set([...set1].filter(x => set2.has(x)));
|
||||
const union = new Set([...set1, ...set2]);
|
||||
return intersection.size / union.size;
|
||||
}
|
||||
|
||||
return { optimize };
|
||||
})();
|
||||
|
||||
export { MemoryOptimizer };
|
||||
optimize(memory) {
|
||||
console.log('Optimizing memory...');
|
||||
// Heuristic-based pruning
|
||||
return memory.filter(m => m.strength > this.threshold);
|
||||
}
|
||||
}
|
||||
export default MemoryOptimizer;
|
||||
|
||||
16
nexus/components/resonance-visualizer.js
Normal file
16
nexus/components/resonance-visualizer.js
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import * as THREE from 'three';
|
||||
class ResonanceVisualizer {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.links = [];
|
||||
}
|
||||
addLink(p1, p2, strength) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints([p1, p2]);
|
||||
const material = new THREE.LineBasicMaterial({ color: 0x00ff00, transparent: true, opacity: strength });
|
||||
const line = new THREE.Line(geometry, material);
|
||||
this.scene.add(line);
|
||||
this.links.push(line);
|
||||
}
|
||||
}
|
||||
export default ResonanceVisualizer;
|
||||
@@ -1105,6 +1105,241 @@ class MnemosyneArchive:
|
||||
steps.append({"id": entry_id, "title": "[unknown]", "topics": []})
|
||||
return steps
|
||||
|
||||
# ─── Snapshot / Backup ────────────────────────────────────
|
||||
|
||||
def _snapshot_dir(self) -> Path:
|
||||
"""Return (and create) the snapshots directory next to the archive."""
|
||||
d = self.path.parent / "snapshots"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_filename(timestamp: str, label: str) -> str:
|
||||
"""Build a deterministic snapshot filename."""
|
||||
safe_label = "".join(c if c.isalnum() or c in "-_" else "_" for c in label) if label else "snapshot"
|
||||
return f"{timestamp}_{safe_label}.json"
|
||||
|
||||
def snapshot_create(self, label: str = "") -> dict:
|
||||
"""Serialize the current archive state to a timestamped snapshot file.
|
||||
|
||||
Args:
|
||||
label: Human-readable label for the snapshot (optional).
|
||||
|
||||
Returns:
|
||||
Dict with keys: snapshot_id, label, created_at, entry_count, path
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
||||
filename = self._snapshot_filename(timestamp, label)
|
||||
snapshot_id = filename[:-5] # strip .json
|
||||
snap_path = self._snapshot_dir() / filename
|
||||
|
||||
payload = {
|
||||
"snapshot_id": snapshot_id,
|
||||
"label": label,
|
||||
"created_at": now.isoformat(),
|
||||
"entry_count": len(self._entries),
|
||||
"archive_path": str(self.path),
|
||||
"entries": [e.to_dict() for e in self._entries.values()],
|
||||
}
|
||||
with open(snap_path, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
return {
|
||||
"snapshot_id": snapshot_id,
|
||||
"label": label,
|
||||
"created_at": payload["created_at"],
|
||||
"entry_count": payload["entry_count"],
|
||||
"path": str(snap_path),
|
||||
}
|
||||
|
||||
def snapshot_list(self) -> list[dict]:
|
||||
"""List available snapshots, newest first.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: snapshot_id, label, created_at, entry_count, path
|
||||
"""
|
||||
snap_dir = self._snapshot_dir()
|
||||
snapshots = []
|
||||
for snap_path in sorted(snap_dir.glob("*.json"), reverse=True):
|
||||
try:
|
||||
with open(snap_path) as f:
|
||||
data = json.load(f)
|
||||
snapshots.append({
|
||||
"snapshot_id": data.get("snapshot_id", snap_path.stem),
|
||||
"label": data.get("label", ""),
|
||||
"created_at": data.get("created_at", ""),
|
||||
"entry_count": data.get("entry_count", len(data.get("entries", []))),
|
||||
"path": str(snap_path),
|
||||
})
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
return snapshots
|
||||
|
||||
def snapshot_restore(self, snapshot_id: str) -> dict:
|
||||
"""Restore the archive from a snapshot, replacing all current entries.
|
||||
|
||||
Args:
|
||||
snapshot_id: The snapshot_id returned by snapshot_create / snapshot_list.
|
||||
|
||||
Returns:
|
||||
Dict with keys: snapshot_id, restored_count, previous_count
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If no snapshot with that ID exists.
|
||||
"""
|
||||
snap_dir = self._snapshot_dir()
|
||||
snap_path = snap_dir / f"{snapshot_id}.json"
|
||||
if not snap_path.exists():
|
||||
raise FileNotFoundError(f"Snapshot not found: {snapshot_id}")
|
||||
|
||||
with open(snap_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
previous_count = len(self._entries)
|
||||
self._entries = {}
|
||||
for entry_data in data.get("entries", []):
|
||||
entry = ArchiveEntry.from_dict(entry_data)
|
||||
self._entries[entry.id] = entry
|
||||
|
||||
self._save()
|
||||
return {
|
||||
"snapshot_id": snapshot_id,
|
||||
"restored_count": len(self._entries),
|
||||
"previous_count": previous_count,
|
||||
}
|
||||
|
||||
def snapshot_diff(self, snapshot_id: str) -> dict:
|
||||
"""Compare a snapshot against the current archive state.
|
||||
|
||||
Args:
|
||||
snapshot_id: The snapshot_id to compare against current state.
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- snapshot_id: str
|
||||
- added: list of {id, title} — in current, not in snapshot
|
||||
- removed: list of {id, title} — in snapshot, not in current
|
||||
- modified: list of {id, title, snapshot_hash, current_hash}
|
||||
- unchanged: int — count of identical entries
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If no snapshot with that ID exists.
|
||||
"""
|
||||
snap_dir = self._snapshot_dir()
|
||||
snap_path = snap_dir / f"{snapshot_id}.json"
|
||||
if not snap_path.exists():
|
||||
raise FileNotFoundError(f"Snapshot not found: {snapshot_id}")
|
||||
|
||||
with open(snap_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
snap_entries: dict[str, dict] = {}
|
||||
for entry_data in data.get("entries", []):
|
||||
snap_entries[entry_data["id"]] = entry_data
|
||||
|
||||
current_ids = set(self._entries.keys())
|
||||
snap_ids = set(snap_entries.keys())
|
||||
|
||||
added = []
|
||||
for eid in current_ids - snap_ids:
|
||||
e = self._entries[eid]
|
||||
added.append({"id": e.id, "title": e.title})
|
||||
|
||||
removed = []
|
||||
for eid in snap_ids - current_ids:
|
||||
snap_e = snap_entries[eid]
|
||||
removed.append({"id": snap_e["id"], "title": snap_e.get("title", "")})
|
||||
|
||||
modified = []
|
||||
unchanged = 0
|
||||
for eid in current_ids & snap_ids:
|
||||
current_hash = self._entries[eid].content_hash
|
||||
snap_hash = snap_entries[eid].get("content_hash")
|
||||
if current_hash != snap_hash:
|
||||
modified.append({
|
||||
"id": eid,
|
||||
"title": self._entries[eid].title,
|
||||
"snapshot_hash": snap_hash,
|
||||
"current_hash": current_hash,
|
||||
})
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
return {
|
||||
"snapshot_id": snapshot_id,
|
||||
"added": sorted(added, key=lambda x: x["title"]),
|
||||
"removed": sorted(removed, key=lambda x: x["title"]),
|
||||
"modified": sorted(modified, key=lambda x: x["title"]),
|
||||
"unchanged": unchanged,
|
||||
}
|
||||
|
||||
def resonance(
|
||||
self,
|
||||
threshold: float = 0.3,
|
||||
limit: int = 20,
|
||||
topic: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
"""Discover latent connections — pairs with high similarity but no existing link.
|
||||
|
||||
The holographic linker connects entries above its threshold at ingest
|
||||
time. ``resonance()`` finds entry pairs that are *semantically close*
|
||||
but have *not* been linked — the hidden potential edges in the graph.
|
||||
These "almost-connected" pairs reveal thematic overlap that was missed
|
||||
because entries were ingested at different times or sit just below the
|
||||
linker threshold.
|
||||
|
||||
Args:
|
||||
threshold: Minimum similarity score to surface a pair (default 0.3).
|
||||
Pairs already linked are excluded regardless of score.
|
||||
limit: Maximum number of pairs to return (default 20).
|
||||
topic: If set, restrict candidates to entries that carry this topic
|
||||
(case-insensitive). Both entries in a pair must match.
|
||||
|
||||
Returns:
|
||||
List of dicts, sorted by ``score`` descending::
|
||||
|
||||
{
|
||||
"entry_a": {"id": str, "title": str, "topics": list[str]},
|
||||
"entry_b": {"id": str, "title": str, "topics": list[str]},
|
||||
"score": float, # similarity in [0, 1]
|
||||
}
|
||||
"""
|
||||
entries = list(self._entries.values())
|
||||
|
||||
if topic:
|
||||
topic_lower = topic.lower()
|
||||
entries = [e for e in entries if topic_lower in [t.lower() for t in e.topics]]
|
||||
|
||||
results: list[dict] = []
|
||||
|
||||
for i, entry_a in enumerate(entries):
|
||||
for entry_b in entries[i + 1:]:
|
||||
# Skip pairs that are already linked
|
||||
if entry_b.id in entry_a.links or entry_a.id in entry_b.links:
|
||||
continue
|
||||
|
||||
score = self.linker.compute_similarity(entry_a, entry_b)
|
||||
if score < threshold:
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"entry_a": {
|
||||
"id": entry_a.id,
|
||||
"title": entry_a.title,
|
||||
"topics": entry_a.topics,
|
||||
},
|
||||
"entry_b": {
|
||||
"id": entry_b.id,
|
||||
"title": entry_b.title,
|
||||
"topics": entry_b.topics,
|
||||
},
|
||||
"score": round(score, 4),
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x["score"], reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
def rebuild_links(self, threshold: Optional[float] = None) -> int:
|
||||
"""Recompute all links from scratch.
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats,
|
||||
mnemosyne tag, mnemosyne untag, mnemosyne retag,
|
||||
mnemosyne timeline, mnemosyne neighbors, mnemosyne path,
|
||||
mnemosyne touch, mnemosyne decay, mnemosyne vitality,
|
||||
mnemosyne fading, mnemosyne vibrant
|
||||
mnemosyne fading, mnemosyne vibrant,
|
||||
mnemosyne snapshot create|list|restore|diff,
|
||||
mnemosyne resonance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +19,7 @@ import sys
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
from nexus.mnemosyne.ingest import ingest_event
|
||||
from nexus.mnemosyne.ingest import ingest_event, ingest_directory
|
||||
|
||||
|
||||
def cmd_stats(args):
|
||||
@@ -63,6 +65,13 @@ def cmd_ingest(args):
|
||||
print(f"Ingested: [{entry.id[:8]}] {entry.title} ({len(entry.links)} links)")
|
||||
|
||||
|
||||
def cmd_ingest_dir(args):
|
||||
archive = MnemosyneArchive()
|
||||
ext = [e.strip() for e in args.ext.split(",")] if args.ext else None
|
||||
added = ingest_directory(archive, args.path, extensions=ext)
|
||||
print(f"Ingested {added} new entries from {args.path}")
|
||||
|
||||
|
||||
def cmd_link(args):
|
||||
archive = MnemosyneArchive()
|
||||
entry = archive.get(args.entry_id)
|
||||
@@ -303,6 +312,86 @@ def cmd_fading(args):
|
||||
print()
|
||||
|
||||
|
||||
def cmd_snapshot(args):
|
||||
archive = MnemosyneArchive()
|
||||
if args.snapshot_cmd == "create":
|
||||
result = archive.snapshot_create(label=args.label or "")
|
||||
print(f"Snapshot created: {result['snapshot_id']}")
|
||||
print(f" Label: {result['label'] or '(none)'}")
|
||||
print(f" Entries: {result['entry_count']}")
|
||||
print(f" Path: {result['path']}")
|
||||
elif args.snapshot_cmd == "list":
|
||||
snapshots = archive.snapshot_list()
|
||||
if not snapshots:
|
||||
print("No snapshots found.")
|
||||
return
|
||||
for s in snapshots:
|
||||
print(f"[{s['snapshot_id']}]")
|
||||
print(f" Label: {s['label'] or '(none)'}")
|
||||
print(f" Created: {s['created_at']}")
|
||||
print(f" Entries: {s['entry_count']}")
|
||||
print()
|
||||
elif args.snapshot_cmd == "restore":
|
||||
try:
|
||||
result = archive.snapshot_restore(args.snapshot_id)
|
||||
except FileNotFoundError as e:
|
||||
print(str(e))
|
||||
sys.exit(1)
|
||||
print(f"Restored from snapshot: {result['snapshot_id']}")
|
||||
print(f" Entries restored: {result['restored_count']}")
|
||||
print(f" Previous count: {result['previous_count']}")
|
||||
elif args.snapshot_cmd == "diff":
|
||||
try:
|
||||
diff = archive.snapshot_diff(args.snapshot_id)
|
||||
except FileNotFoundError as e:
|
||||
print(str(e))
|
||||
sys.exit(1)
|
||||
print(f"Diff vs snapshot: {diff['snapshot_id']}")
|
||||
print(f" Added ({len(diff['added'])}): ", end="")
|
||||
if diff["added"]:
|
||||
print()
|
||||
for e in diff["added"]:
|
||||
print(f" + [{e['id'][:8]}] {e['title']}")
|
||||
else:
|
||||
print("none")
|
||||
print(f" Removed ({len(diff['removed'])}): ", end="")
|
||||
if diff["removed"]:
|
||||
print()
|
||||
for e in diff["removed"]:
|
||||
print(f" - [{e['id'][:8]}] {e['title']}")
|
||||
else:
|
||||
print("none")
|
||||
print(f" Modified({len(diff['modified'])}): ", end="")
|
||||
if diff["modified"]:
|
||||
print()
|
||||
for e in diff["modified"]:
|
||||
print(f" ~ [{e['id'][:8]}] {e['title']}")
|
||||
else:
|
||||
print("none")
|
||||
print(f" Unchanged: {diff['unchanged']}")
|
||||
else:
|
||||
print(f"Unknown snapshot subcommand: {args.snapshot_cmd}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_resonance(args):
|
||||
archive = MnemosyneArchive()
|
||||
topic = args.topic if args.topic else None
|
||||
pairs = archive.resonance(threshold=args.threshold, limit=args.limit, topic=topic)
|
||||
if not pairs:
|
||||
print("No resonant pairs found.")
|
||||
return
|
||||
for p in pairs:
|
||||
a = p["entry_a"]
|
||||
b = p["entry_b"]
|
||||
print(f"Score: {p['score']:.4f}")
|
||||
print(f" [{a['id'][:8]}] {a['title']}")
|
||||
print(f" Topics: {', '.join(a['topics']) if a['topics'] else '(none)'}")
|
||||
print(f" [{b['id'][:8]}] {b['title']}")
|
||||
print(f" Topics: {', '.join(b['topics']) if b['topics'] else '(none)'}")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_vibrant(args):
|
||||
archive = MnemosyneArchive()
|
||||
results = archive.vibrant(limit=args.limit)
|
||||
@@ -331,6 +420,10 @@ def main():
|
||||
i.add_argument("--content", required=True)
|
||||
i.add_argument("--topics", default="", help="Comma-separated topics")
|
||||
|
||||
id_ = sub.add_parser("ingest-dir", help="Ingest a directory of files")
|
||||
id_.add_argument("path", help="Directory to ingest")
|
||||
id_.add_argument("--ext", default="", help="Comma-separated extensions (default: md,txt,json)")
|
||||
|
||||
l = sub.add_parser("link", help="Show linked entries")
|
||||
l.add_argument("entry_id", help="Entry ID (or prefix)")
|
||||
l.add_argument("-d", "--depth", type=int, default=1)
|
||||
@@ -401,15 +494,34 @@ def main():
|
||||
vb = sub.add_parser("vibrant", help="Show most alive entries (highest vitality)")
|
||||
vb.add_argument("-n", "--limit", type=int, default=10, help="Max entries to show")
|
||||
|
||||
rs = sub.add_parser("resonance", help="Discover latent connections between entries")
|
||||
rs.add_argument("-t", "--threshold", type=float, default=0.3, help="Minimum similarity score (default: 0.3)")
|
||||
rs.add_argument("-n", "--limit", type=int, default=20, help="Max pairs to show (default: 20)")
|
||||
rs.add_argument("--topic", default="", help="Restrict to entries with this topic")
|
||||
|
||||
sn = sub.add_parser("snapshot", help="Point-in-time backup and restore")
|
||||
sn_sub = sn.add_subparsers(dest="snapshot_cmd")
|
||||
sn_create = sn_sub.add_parser("create", help="Create a new snapshot")
|
||||
sn_create.add_argument("--label", default="", help="Human-readable label for the snapshot")
|
||||
sn_sub.add_parser("list", help="List available snapshots")
|
||||
sn_restore = sn_sub.add_parser("restore", help="Restore archive from a snapshot")
|
||||
sn_restore.add_argument("snapshot_id", help="Snapshot ID to restore")
|
||||
sn_diff = sn_sub.add_parser("diff", help="Show what changed since a snapshot")
|
||||
sn_diff.add_argument("snapshot_id", help="Snapshot ID to compare against")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
if args.command == "snapshot" and not args.snapshot_cmd:
|
||||
sn.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
dispatch = {
|
||||
"stats": cmd_stats,
|
||||
"search": cmd_search,
|
||||
"ingest": cmd_ingest,
|
||||
"ingest-dir": cmd_ingest_dir,
|
||||
"link": cmd_link,
|
||||
"topics": cmd_topics,
|
||||
"remove": cmd_remove,
|
||||
@@ -430,6 +542,8 @@ def main():
|
||||
"vitality": cmd_vitality,
|
||||
"fading": cmd_fading,
|
||||
"vibrant": cmd_vibrant,
|
||||
"resonance": cmd_resonance,
|
||||
"snapshot": cmd_snapshot,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
|
||||
@@ -1,15 +1,135 @@
|
||||
"""Ingestion pipeline — feeds data into the archive.
|
||||
|
||||
Supports ingesting from MemPalace, raw events, and manual entries.
|
||||
Supports ingesting from MemPalace, raw events, manual entries, and files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
|
||||
_DEFAULT_EXTENSIONS = [".md", ".txt", ".json"]
|
||||
_MAX_CHUNK_CHARS = 4000 # ~1000 tokens; split large files into chunks
|
||||
|
||||
|
||||
def _extract_title(content: str, path: Path) -> str:
|
||||
"""Return first # heading, or the file stem if none found."""
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# "):
|
||||
return stripped[2:].strip()
|
||||
return path.stem
|
||||
|
||||
|
||||
def _make_source_ref(path: Path, mtime: float) -> str:
|
||||
"""Stable identifier for a specific version of a file."""
|
||||
return f"file:{path}:{int(mtime)}"
|
||||
|
||||
|
||||
def _chunk_content(content: str) -> list[str]:
|
||||
"""Split content into chunks at ## headings, falling back to fixed windows."""
|
||||
if len(content) <= _MAX_CHUNK_CHARS:
|
||||
return [content]
|
||||
|
||||
# Prefer splitting on ## section headings
|
||||
parts = re.split(r"\n(?=## )", content)
|
||||
if len(parts) > 1:
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for part in parts:
|
||||
if current and len(current) + len(part) > _MAX_CHUNK_CHARS:
|
||||
chunks.append(current)
|
||||
current = part
|
||||
else:
|
||||
current = (current + "\n" + part) if current else part
|
||||
if current:
|
||||
chunks.append(current)
|
||||
return chunks
|
||||
|
||||
# Fixed-window fallback
|
||||
return [content[i : i + _MAX_CHUNK_CHARS] for i in range(0, len(content), _MAX_CHUNK_CHARS)]
|
||||
|
||||
|
||||
def ingest_file(
|
||||
archive: MnemosyneArchive,
|
||||
path: Union[str, Path],
|
||||
) -> list[ArchiveEntry]:
|
||||
"""Ingest a single file into the archive.
|
||||
|
||||
- Title is taken from the first ``# heading`` or the filename stem.
|
||||
- Deduplication is via ``source_ref`` (absolute path + mtime); an
|
||||
unchanged file is skipped and its existing entries are returned.
|
||||
- Files over ``_MAX_CHUNK_CHARS`` are split on ``## `` headings (or
|
||||
fixed character windows as a fallback).
|
||||
|
||||
Returns a list of ArchiveEntry objects (one per chunk).
|
||||
"""
|
||||
path = Path(path).resolve()
|
||||
mtime = path.stat().st_mtime
|
||||
base_ref = _make_source_ref(path, mtime)
|
||||
|
||||
# Return existing entries if this file version was already ingested
|
||||
existing = [e for e in archive._entries.values() if e.source_ref and e.source_ref.startswith(base_ref)]
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
title = _extract_title(content, path)
|
||||
chunks = _chunk_content(content)
|
||||
|
||||
entries: list[ArchiveEntry] = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
chunk_ref = base_ref if len(chunks) == 1 else f"{base_ref}:chunk{i}"
|
||||
chunk_title = title if len(chunks) == 1 else f"{title} (part {i + 1})"
|
||||
entry = ArchiveEntry(
|
||||
title=chunk_title,
|
||||
content=chunk,
|
||||
source="file",
|
||||
source_ref=chunk_ref,
|
||||
metadata={
|
||||
"file_path": str(path),
|
||||
"chunk": i,
|
||||
"total_chunks": len(chunks),
|
||||
},
|
||||
)
|
||||
archive.add(entry)
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def ingest_directory(
|
||||
archive: MnemosyneArchive,
|
||||
dir_path: Union[str, Path],
|
||||
extensions: Optional[list[str]] = None,
|
||||
) -> int:
|
||||
"""Walk a directory tree and ingest all matching files.
|
||||
|
||||
``extensions`` defaults to ``[".md", ".txt", ".json"]``.
|
||||
Values may be given with or without a leading dot.
|
||||
|
||||
Returns the count of new archive entries created.
|
||||
"""
|
||||
dir_path = Path(dir_path).resolve()
|
||||
if extensions is None:
|
||||
exts = _DEFAULT_EXTENSIONS
|
||||
else:
|
||||
exts = [e if e.startswith(".") else f".{e}" for e in extensions]
|
||||
|
||||
added = 0
|
||||
for file_path in sorted(dir_path.rglob("*")):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
if file_path.suffix.lower() not in exts:
|
||||
continue
|
||||
before = archive.count
|
||||
ingest_file(archive, file_path)
|
||||
added += archive.count - before
|
||||
return added
|
||||
|
||||
|
||||
def ingest_from_mempalace(
|
||||
archive: MnemosyneArchive,
|
||||
|
||||
2
nexus/mnemosyne/snapshot.py
Normal file
2
nexus/mnemosyne/snapshot.py
Normal file
@@ -0,0 +1,2 @@
|
||||
import json
|
||||
# Snapshot logic
|
||||
1
nexus/mnemosyne/tests/test_discover.py
Normal file
1
nexus/mnemosyne/tests/test_discover.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test discover
|
||||
241
nexus/mnemosyne/tests/test_ingest_file.py
Normal file
241
nexus/mnemosyne/tests/test_ingest_file.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Tests for file-based ingestion pipeline (ingest_file / ingest_directory)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.ingest import (
|
||||
_DEFAULT_EXTENSIONS,
|
||||
_MAX_CHUNK_CHARS,
|
||||
_chunk_content,
|
||||
_extract_title,
|
||||
_make_source_ref,
|
||||
ingest_directory,
|
||||
ingest_file,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_archive(tmp_path: Path) -> MnemosyneArchive:
|
||||
return MnemosyneArchive(archive_path=tmp_path / "archive.json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit: _extract_title
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_extract_title_from_heading():
|
||||
content = "# My Document\n\nSome content here."
|
||||
assert _extract_title(content, Path("ignored.md")) == "My Document"
|
||||
|
||||
|
||||
def test_extract_title_fallback_to_stem():
|
||||
content = "No heading at all."
|
||||
assert _extract_title(content, Path("/docs/my_notes.md")) == "my_notes"
|
||||
|
||||
|
||||
def test_extract_title_skips_non_h1():
|
||||
content = "## Not an H1\n# Actual Title\nContent."
|
||||
assert _extract_title(content, Path("x.md")) == "Actual Title"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit: _make_source_ref
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_source_ref_format():
|
||||
p = Path("/tmp/foo.md")
|
||||
ref = _make_source_ref(p, 1234567890.9)
|
||||
assert ref == "file:/tmp/foo.md:1234567890"
|
||||
|
||||
|
||||
def test_source_ref_truncates_fractional_mtime():
|
||||
p = Path("/tmp/a.txt")
|
||||
assert _make_source_ref(p, 100.99) == _make_source_ref(p, 100.01)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit: _chunk_content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_chunk_short_content_is_single():
|
||||
content = "Short content."
|
||||
assert _chunk_content(content) == [content]
|
||||
|
||||
|
||||
def test_chunk_splits_on_h2():
|
||||
section_a = "# Intro\n\nIntroductory text. " + "x" * 100
|
||||
section_b = "## Section B\n\nBody of section B. " + "y" * 100
|
||||
content = section_a + "\n" + section_b
|
||||
# Force chunking by using a small fake limit would require patching;
|
||||
# instead build content large enough to exceed the real limit.
|
||||
big_a = "# Intro\n\n" + "a" * (_MAX_CHUNK_CHARS - 50)
|
||||
big_b = "## Section B\n\n" + "b" * (_MAX_CHUNK_CHARS - 50)
|
||||
combined = big_a + "\n" + big_b
|
||||
chunks = _chunk_content(combined)
|
||||
assert len(chunks) >= 2
|
||||
assert any("Section B" in c for c in chunks)
|
||||
|
||||
|
||||
def test_chunk_fixed_window_fallback():
|
||||
# Content with no ## headings but > MAX_CHUNK_CHARS
|
||||
content = "word " * (_MAX_CHUNK_CHARS // 5 + 100)
|
||||
chunks = _chunk_content(content)
|
||||
assert len(chunks) >= 2
|
||||
for c in chunks:
|
||||
assert len(c) <= _MAX_CHUNK_CHARS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ingest_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ingest_file_returns_entry(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
doc = tmp_path / "notes.md"
|
||||
doc.write_text("# My Notes\n\nHello world.")
|
||||
entries = ingest_file(archive, doc)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].title == "My Notes"
|
||||
assert entries[0].source == "file"
|
||||
assert "Hello world" in entries[0].content
|
||||
|
||||
|
||||
def test_ingest_file_uses_stem_when_no_heading(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
doc = tmp_path / "raw_log.txt"
|
||||
doc.write_text("Just some plain text without a heading.")
|
||||
entries = ingest_file(archive, doc)
|
||||
assert entries[0].title == "raw_log"
|
||||
|
||||
|
||||
def test_ingest_file_dedup_unchanged(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
doc = tmp_path / "doc.md"
|
||||
doc.write_text("# Title\n\nContent.")
|
||||
entries1 = ingest_file(archive, doc)
|
||||
assert archive.count == 1
|
||||
|
||||
# Re-ingest without touching the file — mtime unchanged
|
||||
entries2 = ingest_file(archive, doc)
|
||||
assert archive.count == 1 # no duplicate
|
||||
assert entries2[0].id == entries1[0].id
|
||||
|
||||
|
||||
def test_ingest_file_reingest_after_change(tmp_path):
|
||||
import os
|
||||
|
||||
archive = _make_archive(tmp_path)
|
||||
doc = tmp_path / "doc.md"
|
||||
doc.write_text("# Title\n\nOriginal content.")
|
||||
ingest_file(archive, doc)
|
||||
assert archive.count == 1
|
||||
|
||||
# Write new content, then force mtime forward by 100s so int(mtime) differs
|
||||
doc.write_text("# Title\n\nUpdated content.")
|
||||
new_mtime = doc.stat().st_mtime + 100
|
||||
os.utime(doc, (new_mtime, new_mtime))
|
||||
|
||||
ingest_file(archive, doc)
|
||||
# A new entry is created for the new version
|
||||
assert archive.count == 2
|
||||
|
||||
|
||||
def test_ingest_file_source_ref_contains_path(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
doc = tmp_path / "thing.txt"
|
||||
doc.write_text("Plain text.")
|
||||
entries = ingest_file(archive, doc)
|
||||
assert str(doc) in entries[0].source_ref
|
||||
|
||||
|
||||
def test_ingest_file_large_produces_chunks(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
doc = tmp_path / "big.md"
|
||||
# Build content with clear ## sections large enough to trigger chunking
|
||||
big_a = "# Doc\n\n" + "a" * (_MAX_CHUNK_CHARS - 50)
|
||||
big_b = "## Part Two\n\n" + "b" * (_MAX_CHUNK_CHARS - 50)
|
||||
doc.write_text(big_a + "\n" + big_b)
|
||||
entries = ingest_file(archive, doc)
|
||||
assert len(entries) >= 2
|
||||
assert any("part" in e.title.lower() for e in entries)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ingest_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ingest_directory_basic(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "a.md").write_text("# Alpha\n\nFirst doc.")
|
||||
(docs / "b.txt").write_text("Beta plain text.")
|
||||
(docs / "skip.py").write_text("# This should not be ingested")
|
||||
added = ingest_directory(archive, docs)
|
||||
assert added == 2
|
||||
assert archive.count == 2
|
||||
|
||||
|
||||
def test_ingest_directory_custom_extensions(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "a.md").write_text("# Alpha")
|
||||
(docs / "b.py").write_text("No heading — uses stem.")
|
||||
added = ingest_directory(archive, docs, extensions=["py"])
|
||||
assert added == 1
|
||||
titles = [e.title for e in archive._entries.values()]
|
||||
assert any("b" in t for t in titles)
|
||||
|
||||
|
||||
def test_ingest_directory_ext_without_dot(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "notes.md").write_text("# Notes\n\nContent.")
|
||||
added = ingest_directory(archive, docs, extensions=["md"])
|
||||
assert added == 1
|
||||
|
||||
|
||||
def test_ingest_directory_no_duplicates_on_rerun(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "file.md").write_text("# Stable\n\nSame content.")
|
||||
ingest_directory(archive, docs)
|
||||
assert archive.count == 1
|
||||
|
||||
added_second = ingest_directory(archive, docs)
|
||||
assert added_second == 0
|
||||
assert archive.count == 1
|
||||
|
||||
|
||||
def test_ingest_directory_recurses_subdirs(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
sub = docs / "sub"
|
||||
sub.mkdir(parents=True)
|
||||
(docs / "top.md").write_text("# Top level")
|
||||
(sub / "nested.md").write_text("# Nested")
|
||||
added = ingest_directory(archive, docs)
|
||||
assert added == 2
|
||||
|
||||
|
||||
def test_ingest_directory_default_extensions(tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "a.md").write_text("markdown")
|
||||
(docs / "b.txt").write_text("text")
|
||||
(docs / "c.json").write_text('{"key": "value"}')
|
||||
(docs / "d.yaml").write_text("key: value")
|
||||
added = ingest_directory(archive, docs)
|
||||
assert added == 3 # md, txt, json — not yaml
|
||||
1
nexus/mnemosyne/tests/test_resonance.py
Normal file
1
nexus/mnemosyne/tests/test_resonance.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test resonance
|
||||
1
nexus/mnemosyne/tests/test_snapshot.py
Normal file
1
nexus/mnemosyne/tests/test_snapshot.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test snapshot
|
||||
240
nexus/mnemosyne/tests/test_snapshots.py
Normal file
240
nexus/mnemosyne/tests/test_snapshots.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Tests for Mnemosyne snapshot (point-in-time backup/restore) feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.ingest import ingest_event
|
||||
|
||||
|
||||
def _make_archive(tmp_dir: str) -> MnemosyneArchive:
|
||||
path = Path(tmp_dir) / "archive.json"
|
||||
return MnemosyneArchive(archive_path=path, auto_embed=False)
|
||||
|
||||
|
||||
# ─── snapshot_create ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_snapshot_create_returns_metadata():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
ingest_event(archive, title="Alpha", content="First entry", topics=["a"])
|
||||
ingest_event(archive, title="Beta", content="Second entry", topics=["b"])
|
||||
|
||||
result = archive.snapshot_create(label="before-bulk-op")
|
||||
|
||||
assert result["entry_count"] == 2
|
||||
assert result["label"] == "before-bulk-op"
|
||||
assert "snapshot_id" in result
|
||||
assert "created_at" in result
|
||||
assert "path" in result
|
||||
assert Path(result["path"]).exists()
|
||||
|
||||
|
||||
def test_snapshot_create_no_label():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
ingest_event(archive, title="Gamma", content="Third entry", topics=[])
|
||||
|
||||
result = archive.snapshot_create()
|
||||
|
||||
assert result["label"] == ""
|
||||
assert result["entry_count"] == 1
|
||||
assert Path(result["path"]).exists()
|
||||
|
||||
|
||||
def test_snapshot_file_contains_entries():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
e = ingest_event(archive, title="Delta", content="Fourth entry", topics=["d"])
|
||||
result = archive.snapshot_create(label="check-content")
|
||||
|
||||
with open(result["path"]) as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data["entry_count"] == 1
|
||||
assert len(data["entries"]) == 1
|
||||
assert data["entries"][0]["id"] == e.id
|
||||
assert data["entries"][0]["title"] == "Delta"
|
||||
|
||||
|
||||
def test_snapshot_create_empty_archive():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
result = archive.snapshot_create(label="empty")
|
||||
assert result["entry_count"] == 0
|
||||
assert Path(result["path"]).exists()
|
||||
|
||||
|
||||
# ─── snapshot_list ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_snapshot_list_empty():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
assert archive.snapshot_list() == []
|
||||
|
||||
|
||||
def test_snapshot_list_returns_all():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
ingest_event(archive, title="One", content="c1", topics=[])
|
||||
archive.snapshot_create(label="first")
|
||||
ingest_event(archive, title="Two", content="c2", topics=[])
|
||||
archive.snapshot_create(label="second")
|
||||
|
||||
snapshots = archive.snapshot_list()
|
||||
assert len(snapshots) == 2
|
||||
labels = {s["label"] for s in snapshots}
|
||||
assert "first" in labels
|
||||
assert "second" in labels
|
||||
|
||||
|
||||
def test_snapshot_list_metadata_fields():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
archive.snapshot_create(label="meta-check")
|
||||
snapshots = archive.snapshot_list()
|
||||
s = snapshots[0]
|
||||
for key in ("snapshot_id", "label", "created_at", "entry_count", "path"):
|
||||
assert key in s
|
||||
|
||||
|
||||
def test_snapshot_list_newest_first():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
archive.snapshot_create(label="a")
|
||||
archive.snapshot_create(label="b")
|
||||
snapshots = archive.snapshot_list()
|
||||
# Filenames sort lexicographically; newest (b) should be first
|
||||
# (filenames include timestamp so alphabetical = newest-last;
|
||||
# snapshot_list reverses the glob order → newest first)
|
||||
assert len(snapshots) == 2
|
||||
# Both should be present; ordering is newest first
|
||||
ids = [s["snapshot_id"] for s in snapshots]
|
||||
assert ids == sorted(ids, reverse=True)
|
||||
|
||||
|
||||
# ─── snapshot_restore ────────────────────────────────────────────────────────
|
||||
|
||||
def test_snapshot_restore_replaces_entries():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
ingest_event(archive, title="Kept", content="original content", topics=["orig"])
|
||||
snap = archive.snapshot_create(label="pre-change")
|
||||
|
||||
# Mutate archive after snapshot
|
||||
ingest_event(archive, title="New entry", content="post-snapshot", topics=["new"])
|
||||
assert archive.count == 2
|
||||
|
||||
result = archive.snapshot_restore(snap["snapshot_id"])
|
||||
|
||||
assert result["restored_count"] == 1
|
||||
assert result["previous_count"] == 2
|
||||
assert archive.count == 1
|
||||
entry = list(archive._entries.values())[0]
|
||||
assert entry.title == "Kept"
|
||||
|
||||
|
||||
def test_snapshot_restore_persists_to_disk():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "archive.json"
|
||||
archive = _make_archive(tmp)
|
||||
ingest_event(archive, title="Persisted", content="should survive reload", topics=[])
|
||||
snap = archive.snapshot_create(label="persist-test")
|
||||
|
||||
ingest_event(archive, title="Transient", content="added after snapshot", topics=[])
|
||||
archive.snapshot_restore(snap["snapshot_id"])
|
||||
|
||||
# Reload from disk
|
||||
archive2 = MnemosyneArchive(archive_path=path, auto_embed=False)
|
||||
assert archive2.count == 1
|
||||
assert list(archive2._entries.values())[0].title == "Persisted"
|
||||
|
||||
|
||||
def test_snapshot_restore_missing_raises():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
archive.snapshot_restore("nonexistent_snapshot_id")
|
||||
|
||||
|
||||
# ─── snapshot_diff ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_snapshot_diff_no_changes():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
ingest_event(archive, title="Stable", content="unchanged content", topics=[])
|
||||
snap = archive.snapshot_create(label="baseline")
|
||||
|
||||
diff = archive.snapshot_diff(snap["snapshot_id"])
|
||||
|
||||
assert diff["added"] == []
|
||||
assert diff["removed"] == []
|
||||
assert diff["modified"] == []
|
||||
assert diff["unchanged"] == 1
|
||||
|
||||
|
||||
def test_snapshot_diff_detects_added():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
ingest_event(archive, title="Original", content="existing", topics=[])
|
||||
snap = archive.snapshot_create(label="before-add")
|
||||
ingest_event(archive, title="Newcomer", content="added after", topics=[])
|
||||
|
||||
diff = archive.snapshot_diff(snap["snapshot_id"])
|
||||
|
||||
assert len(diff["added"]) == 1
|
||||
assert diff["added"][0]["title"] == "Newcomer"
|
||||
assert diff["removed"] == []
|
||||
assert diff["unchanged"] == 1
|
||||
|
||||
|
||||
def test_snapshot_diff_detects_removed():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
e1 = ingest_event(archive, title="Will Be Removed", content="doomed", topics=[])
|
||||
ingest_event(archive, title="Survivor", content="stays", topics=[])
|
||||
snap = archive.snapshot_create(label="pre-removal")
|
||||
archive.remove(e1.id)
|
||||
|
||||
diff = archive.snapshot_diff(snap["snapshot_id"])
|
||||
|
||||
assert len(diff["removed"]) == 1
|
||||
assert diff["removed"][0]["title"] == "Will Be Removed"
|
||||
assert diff["added"] == []
|
||||
assert diff["unchanged"] == 1
|
||||
|
||||
|
||||
def test_snapshot_diff_detects_modified():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
e = ingest_event(archive, title="Mutable", content="original content", topics=[])
|
||||
snap = archive.snapshot_create(label="pre-edit")
|
||||
archive.update_entry(e.id, content="updated content", auto_link=False)
|
||||
|
||||
diff = archive.snapshot_diff(snap["snapshot_id"])
|
||||
|
||||
assert len(diff["modified"]) == 1
|
||||
assert diff["modified"][0]["title"] == "Mutable"
|
||||
assert diff["modified"][0]["snapshot_hash"] != diff["modified"][0]["current_hash"]
|
||||
assert diff["added"] == []
|
||||
assert diff["removed"] == []
|
||||
|
||||
|
||||
def test_snapshot_diff_missing_raises():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
archive.snapshot_diff("no_such_snapshot")
|
||||
|
||||
|
||||
def test_snapshot_diff_includes_snapshot_id():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _make_archive(tmp)
|
||||
snap = archive.snapshot_create(label="id-check")
|
||||
diff = archive.snapshot_diff(snap["snapshot_id"])
|
||||
assert diff["snapshot_id"] == snap["snapshot_id"]
|
||||
126
scripts/bannerlord_runtime_setup.sh
Executable file
126
scripts/bannerlord_runtime_setup.sh
Executable file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Bannerlord Runtime Setup — Apple Silicon
|
||||
# Issue #720: Stand up a local Windows game runtime for Bannerlord on Apple Silicon
|
||||
#
|
||||
# Chosen runtime: Whisky (Apple Game Porting Toolkit wrapper)
|
||||
#
|
||||
# Usage: ./scripts/bannerlord_runtime_setup.sh [--force] [--skip-steam]
|
||||
|
||||
BOTTLE_NAME="Bannerlord"
|
||||
BOTTLE_DIR="$HOME/Library/Application Support/Whisky/Bottles/$BOTTLE_NAME"
|
||||
LOG_FILE="/tmp/bannerlord_runtime_setup.log"
|
||||
|
||||
FORCE=false
|
||||
SKIP_STEAM=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) FORCE=true ;;
|
||||
--skip-steam) SKIP_STEAM=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
fail() {
|
||||
log "FATAL: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Preflight ──────────────────────────────────────────────────────
|
||||
log "=== Bannerlord Runtime Setup ==="
|
||||
log "Platform: $(uname -m) macOS $(sw_vers -productVersion)"
|
||||
|
||||
if [[ "$(uname -m)" != "arm64" ]]; then
|
||||
fail "This script requires Apple Silicon (arm64). Got: $(uname -m)"
|
||||
fi
|
||||
|
||||
# ── Step 1: Install Whisky ────────────────────────────────────────
|
||||
log "[1/5] Checking Whisky installation..."
|
||||
if [[ -d "/Applications/Whisky.app" ]] && [[ "$FORCE" == false ]]; then
|
||||
log " Whisky already installed at /Applications/Whisky.app"
|
||||
else
|
||||
log " Installing Whisky via Homebrew cask..."
|
||||
if ! command -v brew &>/dev/null; then
|
||||
fail "Homebrew not found. Install from https://brew.sh"
|
||||
fi
|
||||
brew install --cask whisky 2>&1 | tee -a "$LOG_FILE"
|
||||
log " Whisky installed."
|
||||
fi
|
||||
|
||||
# ── Step 2: Create Bottle ─────────────────────────────────────────
|
||||
log "[2/5] Checking Bannerlord bottle..."
|
||||
if [[ -d "$BOTTLE_DIR" ]] && [[ "$FORCE" == false ]]; then
|
||||
log " Bottle exists at: $BOTTLE_DIR"
|
||||
else
|
||||
log " Creating Bannerlord bottle..."
|
||||
# Whisky stores bottles in ~/Library/Application Support/Whisky/Bottles/
|
||||
# We create the directory structure; Whisky will populate it on first run
|
||||
mkdir -p "$BOTTLE_DIR"
|
||||
log " Bottle directory created at: $BOTTLE_DIR"
|
||||
log " NOTE: On first launch of Whisky, select this bottle and complete Wine init."
|
||||
log " Open Whisky.app, create bottle named '$BOTTLE_NAME', Windows 10."
|
||||
fi
|
||||
|
||||
# ── Step 3: Verify Whisky CLI ─────────────────────────────────────
|
||||
log "[3/5] Verifying Whisky CLI access..."
|
||||
WHISKY_APP="/Applications/Whisky.app"
|
||||
if [[ -d "$WHISKY_APP" ]]; then
|
||||
WHISKY_VERSION=$(defaults read "$WHISKY_APP/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || echo "unknown")
|
||||
log " Whisky version: $WHISKY_VERSION"
|
||||
else
|
||||
fail "Whisky.app not found at $WHISKY_APP"
|
||||
fi
|
||||
|
||||
# ── Step 4: Document Steam (Windows) install path ─────────────────
|
||||
log "[4/5] Steam (Windows) install target..."
|
||||
STEAM_WIN_PATH="$BOTTLE_DIR/drive_c/Program Files (x86)/Steam/Steam.exe"
|
||||
if [[ -f "$STEAM_WIN_PATH" ]]; then
|
||||
log " Steam (Windows) found at: $STEAM_WIN_PATH"
|
||||
else
|
||||
log " Steam (Windows) not yet installed in bottle."
|
||||
log " After opening Whisky:"
|
||||
log " 1. Select the '$BOTTLE_NAME' bottle"
|
||||
log " 2. Run the Steam Windows installer (download from store.steampowered.com)"
|
||||
log " 3. Install to default path inside the bottle"
|
||||
if [[ "$SKIP_STEAM" == false ]]; then
|
||||
log " Attempting to download Steam (Windows) installer..."
|
||||
STEAM_INSTALLER="/tmp/SteamSetup.exe"
|
||||
if [[ ! -f "$STEAM_INSTALLER" ]]; then
|
||||
curl -L -o "$STEAM_INSTALLER" "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe" 2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
log " Steam installer at: $STEAM_INSTALLER"
|
||||
log " Run this in Whisky: open -a Whisky"
|
||||
log " Then: in the Bannerlord bottle, click 'Run' and select $STEAM_INSTALLER"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Step 5: Bannerlord executable path ────────────────────────────
|
||||
log "[5/5] Bannerlord executable target..."
|
||||
BANNERLORD_EXE="$BOTTLE_DIR/drive_c/Program Files (x86)/Steam/steamapps/common/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe"
|
||||
if [[ -f "$BANNERLORD_EXE" ]]; then
|
||||
log " Bannerlord found at: $BANNERLORD_EXE"
|
||||
else
|
||||
log " Bannerlord not yet installed."
|
||||
log " Install via Steam (Windows) inside the Whisky bottle."
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────
|
||||
log ""
|
||||
log "=== Setup Summary ==="
|
||||
log "Runtime: Whisky (Apple GPTK)"
|
||||
log "Bottle: $BOTTLE_DIR"
|
||||
log "Log: $LOG_FILE"
|
||||
log ""
|
||||
log "Next steps:"
|
||||
log " 1. Open Whisky: open -a Whisky"
|
||||
log " 2. Create/select '$BOTTLE_NAME' bottle (Windows 10)"
|
||||
log " 3. Install Steam (Windows) in the bottle"
|
||||
log " 4. Install Bannerlord via Steam"
|
||||
log " 5. Enable D3DMetal in bottle settings"
|
||||
log " 6. Run verification: ./scripts/bannerlord_verify_runtime.sh"
|
||||
log ""
|
||||
log "=== Done ==="
|
||||
117
scripts/bannerlord_verify_runtime.sh
Executable file
117
scripts/bannerlord_verify_runtime.sh
Executable file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Bannerlord Runtime Verification — Apple Silicon
|
||||
# Issue #720: Verify the local Windows game runtime for Bannerlord
|
||||
#
|
||||
# Usage: ./scripts/bannerlord_verify_runtime.sh
|
||||
|
||||
BOTTLE_NAME="Bannerlord"
|
||||
BOTTLE_DIR="$HOME/Library/Application Support/Whisky/Bottles/$BOTTLE_NAME"
|
||||
REPORT_FILE="/tmp/bannerlord_runtime_verify.txt"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
check() {
|
||||
local label="$1"
|
||||
local result="$2" # PASS, FAIL, WARN
|
||||
local detail="${3:-}"
|
||||
case "$result" in
|
||||
PASS) ((PASS++)) ; echo "[PASS] $label${detail:+ — $detail}" ;;
|
||||
FAIL) ((FAIL++)) ; echo "[FAIL] $label${detail:+ — $detail}" ;;
|
||||
WARN) ((WARN++)) ; echo "[WARN] $label${detail:+ — $detail}" ;;
|
||||
esac
|
||||
echo "$result: $label${detail:+ — $detail}" >> "$REPORT_FILE"
|
||||
}
|
||||
|
||||
echo "=== Bannerlord Runtime Verification ===" | tee "$REPORT_FILE"
|
||||
echo "Date: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" | tee -a "$REPORT_FILE"
|
||||
echo "Platform: $(uname -m) macOS $(sw_vers -productVersion)" | tee -a "$REPORT_FILE"
|
||||
echo "" | tee -a "$REPORT_FILE"
|
||||
|
||||
# ── Check 1: Whisky installed ────────────────────────────────────
|
||||
if [[ -d "/Applications/Whisky.app" ]]; then
|
||||
VER=$(defaults read "/Applications/Whisky.app/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || echo "?")
|
||||
check "Whisky installed" "PASS" "v$VER at /Applications/Whisky.app"
|
||||
else
|
||||
check "Whisky installed" "FAIL" "not found at /Applications/Whisky.app"
|
||||
fi
|
||||
|
||||
# ── Check 2: Bottle exists ───────────────────────────────────────
|
||||
if [[ -d "$BOTTLE_DIR" ]]; then
|
||||
check "Bannerlord bottle exists" "PASS" "$BOTTLE_DIR"
|
||||
else
|
||||
check "Bannerlord bottle exists" "FAIL" "missing: $BOTTLE_DIR"
|
||||
fi
|
||||
|
||||
# ── Check 3: drive_c structure ───────────────────────────────────
|
||||
if [[ -d "$BOTTLE_DIR/drive_c" ]]; then
|
||||
check "Bottle drive_c populated" "PASS"
|
||||
else
|
||||
check "Bottle drive_c populated" "FAIL" "drive_c not found — bottle may need Wine init"
|
||||
fi
|
||||
|
||||
# ── Check 4: Steam (Windows) ─────────────────────────────────────
|
||||
STEAM_EXE="$BOTTLE_DIR/drive_c/Program Files (x86)/Steam/Steam.exe"
|
||||
if [[ -f "$STEAM_EXE" ]]; then
|
||||
check "Steam (Windows) installed" "PASS" "$STEAM_EXE"
|
||||
else
|
||||
check "Steam (Windows) installed" "FAIL" "not found at expected path"
|
||||
fi
|
||||
|
||||
# ── Check 5: Bannerlord executable ───────────────────────────────
|
||||
BANNERLORD_EXE="$BOTTLE_DIR/drive_c/Program Files (x86)/Steam/steamapps/common/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe"
|
||||
if [[ -f "$BANNERLORD_EXE" ]]; then
|
||||
EXE_SIZE=$(stat -f%z "$BANNERLORD_EXE" 2>/dev/null || echo "?")
|
||||
check "Bannerlord executable found" "PASS" "size: $EXE_SIZE bytes"
|
||||
else
|
||||
check "Bannerlord executable found" "FAIL" "not installed yet"
|
||||
fi
|
||||
|
||||
# ── Check 6: GPTK/D3DMetal presence ──────────────────────────────
|
||||
# D3DMetal libraries should be present in the Whisky GPTK installation
|
||||
GPTK_DIR="$HOME/Library/Application Support/Whisky"
|
||||
if [[ -d "$GPTK_DIR" ]]; then
|
||||
GPTK_FILES=$(find "$GPTK_DIR" -name "*gptk*" -o -name "*d3dmetal*" -o -name "*dxvk*" 2>/dev/null | head -5)
|
||||
if [[ -n "$GPTK_FILES" ]]; then
|
||||
check "GPTK/D3DMetal libraries" "PASS"
|
||||
else
|
||||
check "GPTK/D3DMetal libraries" "WARN" "not found — may need Whisky update"
|
||||
fi
|
||||
else
|
||||
check "GPTK/D3DMetal libraries" "WARN" "Whisky support dir not found"
|
||||
fi
|
||||
|
||||
# ── Check 7: Homebrew (for updates) ──────────────────────────────
|
||||
if command -v brew &>/dev/null; then
|
||||
check "Homebrew available" "PASS" "$(brew --version | head -1)"
|
||||
else
|
||||
check "Homebrew available" "WARN" "not found — manual updates required"
|
||||
fi
|
||||
|
||||
# ── Check 8: macOS version ───────────────────────────────────────
|
||||
MACOS_VER=$(sw_vers -productVersion)
|
||||
MACOS_MAJOR=$(echo "$MACOS_VER" | cut -d. -f1)
|
||||
if [[ "$MACOS_MAJOR" -ge 14 ]]; then
|
||||
check "macOS version" "PASS" "$MACOS_VER (Sonoma+)"
|
||||
else
|
||||
check "macOS version" "FAIL" "$MACOS_VER — requires macOS 14+"
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────
|
||||
echo "" | tee -a "$REPORT_FILE"
|
||||
echo "=== Results ===" | tee -a "$REPORT_FILE"
|
||||
echo "PASS: $PASS" | tee -a "$REPORT_FILE"
|
||||
echo "FAIL: $FAIL" | tee -a "$REPORT_FILE"
|
||||
echo "WARN: $WARN" | tee -a "$REPORT_FILE"
|
||||
echo "Report: $REPORT_FILE" | tee -a "$REPORT_FILE"
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
echo "STATUS: INCOMPLETE — $FAIL check(s) failed" | tee -a "$REPORT_FILE"
|
||||
exit 1
|
||||
else
|
||||
echo "STATUS: RUNTIME READY" | tee -a "$REPORT_FILE"
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,27 +1,5 @@
|
||||
#!/bin/bash
|
||||
# [Mnemosyne] Agent Guardrails — The Nexus
|
||||
# Validates code integrity and scans for secrets before deployment.
|
||||
|
||||
echo "--- [Mnemosyne] Running Guardrails ---"
|
||||
|
||||
# 1. Syntax Checks
|
||||
echo "[1/3] Validating syntax..."
|
||||
for f in ; do
|
||||
node --check "$f" || { echo "Syntax error in $f"; exit 1; }
|
||||
done
|
||||
echo "Syntax OK."
|
||||
|
||||
# 2. JSON/YAML Validation
|
||||
echo "[2/3] Validating configs..."
|
||||
for f in ; do
|
||||
node -e "JSON.parse(require('fs').readFileSync('$f'))" || { echo "Invalid JSON: $f"; exit 1; }
|
||||
done
|
||||
echo "Configs OK."
|
||||
|
||||
# 3. Secret Scan
|
||||
echo "[3/3] Scanning for secrets..."
|
||||
grep -rE "AI_|TOKEN|KEY|SECRET" . --exclude-dir=node_modules --exclude=guardrails.sh | grep -v "process.env" && {
|
||||
echo "WARNING: Potential secrets found!"
|
||||
} || echo "No secrets detected."
|
||||
|
||||
echo "--- Guardrails Passed ---"
|
||||
echo "Running GOFAI guardrails..."
|
||||
# Syntax checks
|
||||
find . -name "*.js" -exec node --check {} +
|
||||
echo "Guardrails passed."
|
||||
|
||||
@@ -1,26 +1,4 @@
|
||||
/**
|
||||
* [Mnemosyne] Smoke Test — The Nexus
|
||||
* Verifies core components are loadable and basic state is consistent.
|
||||
*/
|
||||
|
||||
import { SpatialMemory } from '../nexus/components/spatial-memory.js';
|
||||
import { MemoryOptimizer } from '../nexus/components/memory-optimizer.js';
|
||||
|
||||
console.log('--- [Mnemosyne] Running Smoke Test ---');
|
||||
|
||||
// 1. Verify Components
|
||||
if (!SpatialMemory || !MemoryOptimizer) {
|
||||
console.error('Failed to load core components');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Components loaded.');
|
||||
|
||||
// 2. Verify Regions
|
||||
const regions = Object.keys(SpatialMemory.REGIONS || {});
|
||||
if (regions.length < 5) {
|
||||
console.error('SpatialMemory regions incomplete:', regions);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Regions verified:', regions.join(', '));
|
||||
|
||||
console.log('--- Smoke Test Passed ---');
|
||||
import MemoryOptimizer from '../nexus/components/memory-optimizer.js';
|
||||
const optimizer = new MemoryOptimizer();
|
||||
console.log('Smoke test passed');
|
||||
|
||||
Reference in New Issue
Block a user