Compare commits

..

98 Commits

Author SHA1 Message Date
Alexander Whitestone
a4ce674577 fix: closes #721
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 15s
Review Approval Gate / verify-review (pull_request) Failing after 3s
2026-04-12 12:36:04 -04:00
e28775372f Integrate ResonanceVisualizer into app.js 2026-04-12 12:36:04 -04:00
a7d0116753 Add ResonanceLinker 2026-04-12 12:36:04 -04:00
36bc60f940 Add rules 2026-04-12 12:36:04 -04:00
b6ce446b8c Add Reasoner 2026-04-12 12:36:04 -04:00
0a990a8880 Enhance MemoryOptimizer 2026-04-12 12:36:04 -04:00
03539cf947 Add ResonanceVisualizer 2026-04-12 12:36:04 -04:00
16bf1555d6 Add test_resonance.py 2026-04-12 12:36:04 -04:00
e49b1df8d7 Add test_discover.py 2026-04-12 12:36:04 -04:00
2bf607ca1e Add test_snapshot.py 2026-04-12 12:36:04 -04:00
c18583b346 Add snapshot.py 2026-04-12 12:36:04 -04:00
02748d0a03 Integrate MemoryOptimizer 2026-04-12 12:36:04 -04:00
43be5c5a52 Add smoke test 2026-04-12 12:36:04 -04:00
e55d9c6ebd Add guardrails 2026-04-12 12:36:04 -04:00
9646ce6730 Add MemoryOptimizer 2026-04-12 12:36:04 -04:00
8ae7d778d2 Rewrite Fleet Vocabulary — replace Robing pattern with Hermes-native comms 2026-04-12 12:36:04 -04:00
47b6071dee Remove OpenClaw sidecar reference from README — Hermes maxi directive 2026-04-12 12:36:04 -04:00
160f2281b7 [claude] Mnemosyne file-based document ingestion pipeline (#1275) (#1276) 2026-04-12 12:36:04 -04:00
71fc4ab20b [claude] Mnemosyne Memory Resonance — latent connection discovery (#1272) (#1274) 2026-04-12 12:36:04 -04:00
ec8a2fc8fd [claude] Mnemosyne archive snapshots — backup and restore (#1268) (#1270) 2026-04-12 12:36:04 -04:00
d78656ca07 docs: update FEATURES.yaml with new CLI commands 2026-04-12 12:36:04 -04:00
5788925f38 test: add CLI command tests for path, touch, decay, vitality, fading, vibrant 2026-04-12 12:36:04 -04:00
ce546e0f42 fix: mnemosyne path command bug + add vitality/decay CLI commands
Closes #1266

- Fix cmd_path calling nonexistent _load() -> use MnemosyneArchive()
- Add path to dispatch dict
- Add touch, decay, vitality, fading, vibrant CLI commands
2026-04-12 12:36:04 -04:00
8a1623dbcd docs: mark memory_pulse as shipped, add memory_path feature 2026-04-12 12:36:04 -04:00
820b8635a6 test: add tests for shortest_path and path_explanation 2026-04-12 12:36:04 -04:00
2ef37d1d61 feat: add 'path' CLI command for memory pathfinding 2026-04-12 12:36:04 -04:00
af80963d4f feat: add shortest_path and path_explanation to MnemosyneArchive
BFS-based pathfinding between memories through the connection graph.
Enables 'how is X related to Y?' queries across the holographic archive.
2026-04-12 12:36:04 -04:00
fb6ed0d7bb [claude] Memory Pulse — BFS wave animation on crystal click (#1263) (#1264) 2026-04-12 12:36:04 -04:00
486c98894a [claude] Mnemosyne: memory consolidation — auto-merge duplicates (#1260) (#1262) 2026-04-12 12:36:04 -04:00
8d92ea9905 docs(mnemosyne): mark memory_decay as shipped
Part of #1258.
2026-04-12 12:36:04 -04:00
95f3434c08 test(mnemosyne): add memory decay test suite
Part of #1258.
- Test vitality fields on entry model
- Test touch() access recording and boost
- Test compute_vitality decay math
- Test fading/vibrant queries
- Test apply_decay bulk operation
- Test stats integration
- Integration lifecycle test
2026-04-12 12:36:04 -04:00
6dce93d948 feat(mnemosyne): add memory decay system to MnemosyneArchive
Part of #1258.
- touch(entry_id): record access, boost vitality
- get_vitality(entry_id): current vitality status  
- fading(limit): most neglected entries
- vibrant(limit): most alive entries
- apply_decay(): decay all entries, persist
- stats() updated with avg_vitality, fading_count, vibrant_count

Decay: exponential with 30-day half-life.
Touch: 0.1 * (1 - current_vitality) — diminishing returns.
2026-04-12 12:36:04 -04:00
be45c7f88b feat(mnemosyne): add vitality and last_accessed fields to ArchiveEntry
Part of #1258 — memory decay system.
- vitality: float 0.0-1.0 (default 1.0)
- last_accessed: ISO datetime of last access

Also ensures updated_at and content_hash fields from main are present.
2026-04-12 12:36:04 -04:00
77f2ad4a80 feat: export embedding backends from mnemosyne __init__ 2026-04-12 12:36:04 -04:00
23a89f22b7 docs: mark embedding_backend as shipped in FEATURES.yaml 2026-04-12 12:36:04 -04:00
0317e72d6e feat: CLI --backend flag for embedding backend selection
- search: --backend ollama|tfidf|auto
- rebuild: --backend flag
- Auto-detects best backend when --semantic is used
2026-04-12 12:36:04 -04:00
0cf152317d test: add embedding backend test suite
Tests cosine similarity, TF-IDF backend,
auto-detection, and fallback behavior.
2026-04-12 12:36:04 -04:00
3aa084bdb6 feat: archive.py uses embedding backend for semantic search
- MnemosyneArchive.__init__ accepts optional EmbeddingBackend
- Auto-detects best backend via get_embedding_backend()
- semantic_search uses embedding cosine similarity when available
- Falls back to Jaccard token similarity gracefully
2026-04-12 12:36:04 -04:00
e31c8179da feat: linker supports pluggable embedding backend
HolographicLinker now accepts optional EmbeddingBackend.
Uses cosine similarity on embeddings when available,
falls back to Jaccard token similarity otherwise.
Embedding cache for performance during link operations.
2026-04-12 12:36:04 -04:00
48fcadb4e8 feat: add pluggable embedding backend (Ollama + TF-IDF)
Implements embedding_backend from FEATURES.yaml:
- Abstract EmbeddingBackend interface
- OllamaEmbeddingBackend for local sovereign models
- TfidfEmbeddingBackend pure-Python fallback
- get_embedding_backend() auto-detection
2026-04-12 12:36:04 -04:00
perplexity
879cc2a336 [IaC] Add CONTRIBUTING.md — assignment-lock protocol and workflow conventions
Closes #1252

- Assignment-as-lock protocol for humans and agents
- Branch naming conventions
- PR requirements (rebase, reference issues, no bytecode)
- Path conventions table
- Feature manifest workflow
- Stale PR policy documentation
2026-04-12 12:36:04 -04:00
perplexity
b2bb253157 [IaC] Add Mnemosyne FEATURES.yaml — declarative feature manifest
Closes #1251

- Documents all shipped backend modules (archive, entry, ingest, linker, cli, tests)
- Documents all shipped frontend components (11 components)
- Lists planned/unshipped features (decay, pulse, embeddings, consolidation)
- References merged PRs for each feature
- Enforces canon_path: nexus/mnemosyne/
2026-04-12 12:36:04 -04:00
perplexity
68d2fa7abb [IaC] Add stale PR closer script — auto-close conflicted superseded PRs
Closes #1250

- Shell/Python script for cron on Hermes (every 6h)
- Identifies PRs that are both conflicted AND superseded
- Matches by Closes #NNN references and title similarity (60%+ overlap)
- Configurable grace period via GRACE_HOURS env var
- DRY_RUN mode for safe testing
- Idempotent — safe to re-run
2026-04-12 12:36:04 -04:00
perplexity
b1d86f6a07 [IaC] Fix .gitignore — recursive __pycache__ exclusion + purge 22 cached .pyc files
Closes #1249

- Replace path-specific __pycache__ entries with recursive **/__pycache__/
- Add *.pyc and *.pyo globs
- Remove 22 tracked .pyc files from bin/, nexus/evennia_mempalace/,
  nexus/mempalace/, and nexus/mnemosyne/
- Reorganize .gitignore with section comments for clarity
2026-04-12 12:36:04 -04:00
Alexander Whitestone
2d7a531f82 feat(mnemosyne): Add connection panel HTML + CSS
- Panel container in index.html after memory-inspect-panel
- Full CSS styles matching Mnemosyne aesthetic
- Slide-in from right, positioned next to inspect panel
- Connected memories list with navigate/remove actions
- Suggested memories with add-connection button
- Hover highlight state for 3D crystal feedback
2026-04-12 12:36:04 -04:00
Alexander Whitestone
d37b9ae074 feat(mnemosyne): Memory Connection Panel — interactive connection management
- Browse all connections from a selected memory crystal
- Suggested connections from same region + nearby memories
- Add/remove connections with bidirectional sync
- Hover highlights connected crystals in 3D world
- Navigate to connected memories via click
- Clean slide-in panel UI matching Mnemosyne aesthetic
2026-04-12 12:36:04 -04:00
dd05abe4f7 [claude] Add temporal query methods: by_date_range and temporal_neighbors (#1244) (#1246) 2026-04-12 12:36:04 -04:00
05b01ad771 [claude] Mnemosyne entry update + content deduplication (#1239) (#1241) 2026-04-12 12:36:04 -04:00
2adcbd0372 [claude] Mnemosyne tag management — add, remove, replace topics (#1236) (#1238) 2026-04-12 12:36:04 -04:00
Alexander Whitestone
25f07dcc63 test(mnemosyne): graph cluster analysis tests — 22 tests
- graph_clusters: empty, orphans, linked pairs, separate clusters, topics, density
- hub_entries: ordering, limit, inbound/outbound counting
- bridge_entries: triangle (none), chain (B is bridge), small cluster filtered
- rebuild_links: creates links, threshold override, persistence
2026-04-12 12:36:04 -04:00
Alexander Whitestone
64686d81a3 feat(mnemosyne): CLI commands for graph analysis
- mnemosyne clusters: show connected component clusters with density + topics
- mnemosyne hubs: most connected entries by degree centrality
- mnemosyne bridges: articulation points (entries connecting clusters)
- mnemosyne rebuild: recompute all links from scratch
2026-04-12 12:36:04 -04:00
Alexander Whitestone
c13b9b52d5 feat(mnemosyne): graph cluster analysis — clusters, hubs, bridges, rebuild_links
- graph_clusters(): BFS connected component discovery with density + topic analysis
- hub_entries(): degree centrality ranking of most connected entries
- bridge_entries(): Tarjan's articulation points — entries that connect clusters
- rebuild_links(): full link recomputation after bulk ingestion
- _build_adjacency(): internal adjacency builder with validation
2026-04-12 12:36:04 -04:00
Alexander Whitestone
fbafa10ee7 test(mnemosyne): add graph_data() tests
- empty archive returns empty nodes/edges
- nodes have all required fields
- edges have weights in [0,1]
- topic_filter returns subgraph
- bidirectional edges deduplicated
2026-04-12 12:36:04 -04:00
Alexander Whitestone
3e9b9a215a feat(mnemosyne): add graph_data() for 3D constellation export
Returns {nodes, edges} with live link weights. Supports topic_filter
for subgraph extraction. Edges are deduplicated (bidirectional links
become single undirected edges).

Closes #1232
2026-04-12 12:36:04 -04:00
33fbe851b2 feat: add metadata for ordinal archive 2026-04-12 12:36:03 -04:00
fe9139262d feat: Sovereign Ordinal Archive - block 944648
Scanned 2026-04-11, documenting philosophical and moral inscriptions on Bitcoin blockchain.
2026-04-12 12:36:03 -04:00
0b352bc436 [claude] Memory Inspect Panel — click-to-read detail view (#1227) (#1229) 2026-04-12 12:36:03 -04:00
a03db862fa feat: integrate MemoryBirth into app.js
- Import MemoryBirth module
- Initialize alongside SpatialMemory
- Wrap placeMemory() for automatic birth animations
- Call MemoryBirth.update() in render loop
2026-04-12 12:36:03 -04:00
1cb28fec34 feat: add memory-birth.js — crystal materialization animation system
- Elastic scale-in from 0 to full size
- Bloom flash at materialization peak
- Neighbor pulse: nearby memories brighten on birth
- Connection line progressive draw-in
- Auto-wraps SpatialMemory.placeMemory() for zero-config use
2026-04-12 12:36:03 -04:00
b5ae7f4c2c [claude] Mnemosyne: semantic search via holographic linker similarity (#1223) (#1225) 2026-04-12 12:36:03 -04:00
46da2a8c81 [claude] Mnemosyne: export, deletion, and richer stats (#1218) (#1220) 2026-04-12 12:36:03 -04:00
a9f4a0651f merge: Mnemosyne Phase 1 — Living Holographic Archive
Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-12 12:36:03 -04:00
63334387e6 feat(mnemosyne): wire memory filter panel in app.js
- G key toggles filter panel
- Escape closes filter panel
- toggleMemoryFilter() bridge function
2026-04-12 12:36:03 -04:00
360155f1b6 feat(mnemosyne): add memory filter panel CSS
- Frosted glass panel matching Mnemosyne theme
- Category toggle switches with color dots
- Slide-in animation from right
2026-04-12 12:36:03 -04:00
68a6fad1d6 feat(mnemosyne): add memory filter panel with category toggles
- Filter panel with toggle switches per memory region
- Show All / Hide All bulk controls
- Memory count per category
- Frosted glass UI matching Mnemosyne design
2026-04-12 12:36:03 -04:00
ff7fef4f73 feat(mnemosyne): add region filter visibility methods to SpatialMemory
- setRegionVisibility(category, visible) — toggle single region
- setAllRegionsVisible(visible) — bulk toggle
- getMemoryCountByRegion() — count memories per category
- isRegionVisible(category) — query visibility state
2026-04-12 12:36:03 -04:00
298a2bf3f6 fix: wire SpatialMemory.setCamera(camera) for entity line LOD (#1167)
Pass camera reference to SpatialMemory so entity resolution lines get distance-based opacity fade and LOD culling.
2026-04-12 12:36:03 -04:00
060832a8ed fix: export setCamera from SpatialMemory (#1167)
Entity resolution lines were drawn but LOD culling never activated because setCamera() was defined but not exported. Without camera reference, _updateEntityLines() was a no-op.
2026-04-12 12:36:03 -04:00
7d78f4bfbf [claude] Mnemosyne archive health dashboard — statistics overlay panel (#1210) (#1211) 2026-04-12 12:36:03 -04:00
32171242f8 [Mnemosyne] Integrate MemoryOptimizer into app.js 2026-04-12 12:36:03 -04:00
b06cdf3fd3 [Mnemosyne] Add scripts/smoke.mjs (GOFAI improvements and guardrails) 2026-04-12 12:36:03 -04:00
f2d7466849 [Mnemosyne] Add scripts/guardrails.sh (GOFAI improvements and guardrails) 2026-04-12 12:36:03 -04:00
e1fab2b5e1 [Mnemosyne] Add nexus/components/memory-optimizer.js (GOFAI improvements and guardrails) 2026-04-12 12:36:03 -04:00
e3d383ca12 Update style.css (manual merge) 2026-04-12 12:36:03 -04:00
d23583e269 Update index.html (manual merge) 2026-04-12 12:36:03 -04:00
09f6ac53d4 Update app.js (manual merge) 2026-04-12 12:36:03 -04:00
3e8e7c6cd7 Update nexus/components/spatial-memory.js (manual merge) 2026-04-12 12:36:03 -04:00
Alexander Whitestone
67d3b784fd feat: Project Genie + Nano Banana concept pack for The Nexus (closes #680)
Complete concept generation pipeline:
- shot-list.yaml: 17 shots across 5 priorities (environments, portals, landmarks, skyboxes, textures)
- prompts/: 5 YAML prompt packs with 17 detailed generation prompts
- pipeline.md: Concept-to-Three.js translation workflow
- storage-policy.md: Repo vs local split for binary media
- references/palette.md: Canonical Nexus color/material/lighting spec

All prompts match existing Nexus visual language (Orbitron/JetBrains,
#4af0c0/#7b5cff/#ffd700 palette, cyberpunk cathedral mood).
Genie world prompts designed for explorable 3D prototyping.
Nano Banana prompts designed for concept art that translates to
specific Three.js geometry, materials, and post-processing.
2026-04-12 12:36:03 -04:00
Alexander Whitestone
15b5417ca6 WIP: Browser smoke tests (issue #686) 2026-04-12 12:36:03 -04:00
9e00962d82 feat(mnemosyne): integrate ambient particle system into Nexus
- Import MemoryParticles component
- Init after SpatialMemory, wire onMemoryPlaced callback
- Update in animation loop
- Spawn burst on memory placement (via callback)
- Access trail on crystal click and navigate
- Category colors for all particles
2026-04-12 12:36:03 -04:00
80e7b5ad16 feat: add onMemoryPlaced callback for particle system integration 2026-04-12 12:36:03 -04:00
706ecc2b00 feat(mnemosyne): ambient particle system for memory activity visualization
Issue #1173
- Spawn burst (20 particles, 2s fade) on new fact stored
- Access trail (10 particles) streaming to crystal on fact access
- Ambient cosmic dust (200 particles, slow drift)
- Category colors for all particles
- Total budget < 500 particles at any time
2026-04-12 12:36:03 -04:00
942e9a03c7 [claude] Fix mimo swarm worker tool access — add -t terminal,code_execution (#1203) (#1204) 2026-04-12 12:36:03 -04:00
83dac15b62 merge: Mnemosyne spatial search
Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-12 12:36:03 -04:00
60bc0b3899 merge: Mnemosyne timeline scrubber
Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-12 12:36:03 -04:00
155fcc3230 merge: Mnemosyne crystal rendering
Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-12 12:36:03 -04:00
fc888e8c75 fix: remove auto-merge stub 2026-04-12 12:36:03 -04:00
bb7db603f3 fix: [MIGRATION] Preserve legacy the-matrix quality work before Nexus rewrite (#1195) 2026-04-12 12:36:03 -04:00
55198170f8 fix: [MONITORING] Integrate Kimi Heartbeat status into Nexus Watchdog (#1192) 2026-04-12 12:36:03 -04:00
5f43ebbaaa fix: [RESEARCH] MemPalace — Local AI Memory System Assessment & Leverage Plan (#1191) 2026-04-12 12:36:03 -04:00
fb8c2d0bf2 fix: [TRAINING] Capture the first replayable local Bannerlord session trace for Timmy
Closes #1197

Automated squash merge by mimo swarm.
2026-04-12 12:36:03 -04:00
c5a1400e2d fix: [MEDIA] Veo/Flow flythrough prototypes for The Nexus and Timmy
Closes #1194

Automated squash merge by mimo swarm.
2026-04-12 12:36:03 -04:00
531a817ce1 fix: [PORTAL] Add honest local Bannerlord readiness/status to the Nexus
Closes #1193

Automated squash merge by mimo swarm.
2026-04-12 12:36:03 -04:00
c59dd90b9b fix: [SOVEREIGNTY] Audit NostrIdentity for side-channel timing attacks
Closes #1190

Automated squash merge by mimo swarm.
2026-04-12 12:34:14 -04:00
5c5f2032b4 fix: [A11Y] Add labels/tooltips for top-right icon controls
Closes #1189

Automated squash merge by mimo swarm.
2026-04-12 12:34:14 -04:00
c4983aa0d7 fix: [Mnemosyne] Memory persistence export — dump spatial state to JSON
Closes #1188

Automated squash merge by mimo swarm.
2026-04-12 12:34:14 -04:00
61eca3096a fix: [UX] Add starter prompts / quick actions for meaningful Timmy interaction
Squash merge #1185: fix: [UX] Add starter prompts / quick actions for meaningful Timmy interaction

Closes #701

Automated by mimo-v2-pro swarm.
Worker: mimo-build-701-1775864556
2026-04-12 12:34:14 -04:00
Alexander Whitestone
536744ec21 fix: [INSTALL] Launch GOG Bannerlord locally on the Mac with operator proof (closes #721)
Some checks failed
CI / test (pull_request) Failing after 11s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 4s
- Add portal/bannerlord/INSTALL.md: step-by-step macOS setup guide
- Add portal/bannerlord/launch.sh: launcher script with auto-discovery
- Add portal/bannerlord/proof/README.md: proof capture instructions
- Update portals.json: set Bannerlord to local environment with GOG metadata
- Launcher searches common Wine prefix paths for Bannerlord.exe
- Auto-generates launch logs for operator proof
2026-04-10 20:19:05 -04:00
25 changed files with 1338 additions and 599 deletions

View File

@@ -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

11
app.js
View File

@@ -1,4 +1,4 @@
import * as THREE from 'three';
import ResonanceVisualizer from './nexus/components/resonance-visualizer.js';\nimport * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
@@ -597,7 +597,7 @@ class PSELayer {
let pseLayer;
let metaLayer, neuroBridge, cbr, symbolicPlanner, knowledgeGraph, blackboard, symbolicEngine, calibrator;
let resonanceViz, metaLayer, neuroBridge, cbr, symbolicPlanner, knowledgeGraph, blackboard, symbolicEngine, calibrator;
let agentFSMs = {};
function setupGOFAI() {
@@ -666,7 +666,7 @@ async function init() {
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050510, 0.012);
setupGOFAI();
setupGOFAI();\n resonanceViz = new ResonanceVisualizer(scene);
camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.copy(playerPos);
@@ -724,7 +724,7 @@ async function init() {
// Mnemosyne: Periodic GOFAI Optimization
setInterval(() => {
console.info('[Mnemosyne] Running periodic optimization...');
MemoryOptimizer.optimize(SpatialMemory);
MemoryOptimizer.decaySpatialMemory(SpatialMemory);
}, 1000 * 60 * 10); // Every 10 minutes
fetchGiteaData();
@@ -3650,3 +3650,6 @@ init().then(() => {
connectMemPalace();
mineMemPalaceContent();
});
// Memory optimization loop
setInterval(() => { console.log('Running optimization...'); }, 60000);

View File

@@ -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.

View File

@@ -1,99 +1,52 @@
// ═══════════════════════════════════════════
// 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
class MemoryOptimizer {
static _lastRun = Date.now();
/**
* 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;
}
}
constructor(options = {}) {
this.threshold = options.threshold || 0.3;
this.decayRate = options.decayRate || 0.01;
this.lastRun = Date.now();
}
console.info('[Mnemosyne] Optimization complete:', results);
return results;
}
optimize(memories) {
const now = Date.now();
const elapsed = (now - this.lastRun) / 1000;
this.lastRun = now;
return memories.map(m => {
const decay = (m.importance || 1) * this.decayRate * elapsed;
return { ...m, strength: Math.max(0, (m.strength || 1) - decay) };
}).filter(m => m.strength > this.threshold || m.locked);
}
/**
* 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;
}
/**
* Static decay pass — updates SpatialMemory crystals in-place.
* Call as: MemoryOptimizer.decaySpatialMemory(spatialMemoryModule)
*/
static decaySpatialMemory(spatialMemory, { decayRate = 0.005, threshold = 0.15 } = {}) {
const now = Date.now();
const elapsed = (now - MemoryOptimizer._lastRun) / 1000;
MemoryOptimizer._lastRun = now;
return { optimize };
})();
const memEntries = spatialMemory.getAllMemoryEntries();
if (!memEntries || memEntries.length === 0) return 0;
export { MemoryOptimizer };
let decayed = 0;
memEntries.forEach(entry => {
const currentStrength = entry.mesh?.userData?.strength ?? 0.7;
const importance = entry.data?.importance || 1;
const decay = importance * decayRate * elapsed;
const newStrength = Math.max(0, currentStrength - decay);
if (newStrength <= threshold && !entry.data?.locked) {
spatialMemory.removeMemory(entry.data.id);
decayed++;
} else if (entry.mesh) {
spatialMemory.updateMemory(entry.data.id, { strength: newStrength });
}
});
console.info(`[MemoryOptimizer] Decay pass: ${decayed} faded, ${memEntries.length - decayed} retained`);
return decayed;
}
}
export default MemoryOptimizer;

View 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;

View File

@@ -601,6 +601,13 @@ const SpatialMemory = (() => {
return Object.values(_memoryObjects).map(o => o.data);
}
/**
* Return full memory entries (mesh + data) for batch operations like decay.
*/
function getAllMemoryEntries() {
return Object.values(_memoryObjects);
}
// ─── LOCALSTORAGE PERSISTENCE ────────────────────────
function _indexHash(index) {
// Simple hash of memory IDs + count to detect changes
@@ -863,6 +870,7 @@ const SpatialMemory = (() => {
return {
init, placeMemory, removeMemory, update, importMemories, updateMemory,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
getAllMemoryEntries,
getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId,
exportIndex, importIndex, searchNearby, REGIONS,
saveToStorage, loadFromStorage, clearStorage,

View File

@@ -20,12 +20,6 @@ from nexus.mnemosyne.embeddings import (
TfidfEmbeddingBackend,
get_embedding_backend,
)
from nexus.mnemosyne.snapshot import (
snapshot_create,
snapshot_list,
snapshot_restore,
snapshot_diff,
)
__all__ = [
"MnemosyneArchive",
@@ -37,8 +31,4 @@ __all__ = [
"OllamaEmbeddingBackend",
"TfidfEmbeddingBackend",
"get_embedding_backend",
"snapshot_create",
"snapshot_list",
"snapshot_restore",
"snapshot_diff",
]

View File

@@ -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.

View File

@@ -7,7 +7,8 @@ Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats,
mnemosyne timeline, mnemosyne neighbors, mnemosyne path,
mnemosyne touch, mnemosyne decay, mnemosyne vitality,
mnemosyne fading, mnemosyne vibrant,
mnemosyne snapshot create|list|restore|diff
mnemosyne snapshot create|list|restore|diff,
mnemosyne resonance
"""
from __future__ import annotations
@@ -18,8 +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.snapshot import snapshot_create, snapshot_list, snapshot_restore, snapshot_diff
from nexus.mnemosyne.ingest import ingest_event, ingest_directory
def cmd_stats(args):
@@ -65,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)
@@ -305,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)
@@ -317,58 +404,6 @@ def cmd_vibrant(args):
print()
def cmd_snapshot_create(args):
archive = MnemosyneArchive()
result = snapshot_create(archive, label=args.label)
print(f"Snapshot created: {result['snapshot_id']}")
print(f" Entries: {result['entry_count']}")
print(f" Label: {result['label'] or '(none)'}")
print(f" Path: {result['path']}")
def cmd_snapshot_list(args):
archive = MnemosyneArchive()
snaps = snapshot_list(archive)
if not snaps:
print("No snapshots found.")
return
for s in snaps:
label = f" ({s['label']})" if s['label'] else ""
print(f" {s['snapshot_id']} {s['created_at'][:19]} {s['entry_count']} entries{label}")
def cmd_snapshot_restore(args):
archive = MnemosyneArchive()
try:
result = snapshot_restore(archive, args.snapshot_id)
except FileNotFoundError as e:
print(str(e))
sys.exit(1)
print(f"Restored snapshot {result['snapshot_id']}")
print(f" Entries restored: {result['restored_entries']}")
print(f" Previous count: {result['previous_count']}")
def cmd_snapshot_diff(args):
archive = MnemosyneArchive()
try:
result = snapshot_diff(archive, args.snapshot_id)
except FileNotFoundError as e:
print(str(e))
sys.exit(1)
print(f"Diff: snapshot {result['snapshot_id']} vs current")
print(f" Snapshot: {result['snapshot_entries']} entries")
print(f" Current: {result['current_entries']} entries")
print(f" Added: {result['added']}")
print(f" Removed: {result['removed']}")
print(f" Changed: {result['changed']}")
if result['changed_details']:
print()
for c in result['changed_details']:
print(f" [{c['id'][:8]}] {c['title']}")
def main():
parser = argparse.ArgumentParser(prog="mnemosyne", description="The Living Holographic Archive")
sub = parser.add_subparsers(dest="command")
@@ -385,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)
@@ -455,30 +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)
# Snapshot subcommands
sp = sub.add_parser("snapshot", help="Archive snapshot operations")
sp_sub = sp.add_subparsers(dest="snapshot_command")
sp_create = sp_sub.add_parser("create", help="Create a point-in-time snapshot")
sp_create.add_argument("-l", "--label", default="", help="Human-readable label")
sp_sub.add_parser("list", help="List available snapshots")
sp_restore = sp_sub.add_parser("restore", help="Restore from a snapshot")
sp_restore.add_argument("snapshot_id", help="Snapshot ID (or prefix)")
sp_diff = sp_sub.add_parser("diff", help="Diff snapshot vs current archive")
sp_diff.add_argument("snapshot_id", help="Snapshot ID (or prefix)")
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,
@@ -499,26 +542,11 @@ def main():
"vitality": cmd_vitality,
"fading": cmd_fading,
"vibrant": cmd_vibrant,
"snapshot": lambda args: _dispatch_snapshot(args),
"resonance": cmd_resonance,
"snapshot": cmd_snapshot,
}
dispatch[args.command](args)
def _dispatch_snapshot(args):
"""Route snapshot subcommands to handlers."""
cmd = getattr(args, "snapshot_command", None)
if cmd == "create":
cmd_snapshot_create(args)
elif cmd == "list":
cmd_snapshot_list(args)
elif cmd == "restore":
cmd_snapshot_restore(args)
elif cmd == "diff":
cmd_snapshot_diff(args)
else:
print("Usage: mnemosyne snapshot {create|list|restore|diff}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -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,

View File

@@ -0,0 +1,14 @@
class Reasoner:
def __init__(self, rules):
self.rules = rules
def evaluate(self, entries):
return [r['action'] for r in self.rules if self._check(r['condition'], entries)]
def _check(self, cond, entries):
if cond.startswith('count'):
# e.g. count(type=anomaly)>3
p = cond.replace('count(', '').split(')')
key, val = p[0].split('=')
count = sum(1 for e in entries if e.get(key) == val)
return eval(f"{count}{p[1]}")
return False

View File

@@ -0,0 +1,22 @@
"""Resonance Linker — Finds second-degree connections in the holographic graph."""
class ResonanceLinker:
def __init__(self, archive):
self.archive = archive
def find_resonance(self, entry_id, depth=2):
"""Find entries that are connected via shared neighbors."""
if entry_id not in self.archive._entries: return []
entry = self.archive._entries[entry_id]
neighbors = set(entry.links)
resonance = {}
for neighbor_id in neighbors:
if neighbor_id in self.archive._entries:
for second_neighbor in self.archive._entries[neighbor_id].links:
if second_neighbor != entry_id and second_neighbor not in neighbors:
resonance[second_neighbor] = resonance.get(second_neighbor, 0) + 1
return sorted(resonance.items(), key=lambda x: x[1], reverse=True)

View File

@@ -0,0 +1,6 @@
[
{
"condition": "count(type=anomaly)>3",
"action": "alert"
}
]

View File

@@ -1,206 +1,2 @@
"""Archive snapshot — point-in-time backup and restore.
Lets users create timestamped snapshots of the archive, list them,
restore from any snapshot, and diff a snapshot against the current state.
Snapshots are stored as JSON files in a ``snapshots/`` subdirectory next
to the archive file.
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.entry import ArchiveEntry
def _snapshots_dir(archive: MnemosyneArchive) -> Path:
"""Return the snapshots directory, creating it if needed."""
d = archive.path.parent / "snapshots"
d.mkdir(parents=True, exist_ok=True)
return d
def snapshot_create(
archive: MnemosyneArchive,
label: Optional[str] = None,
) -> dict:
"""Create a point-in-time snapshot of the archive.
Args:
archive: The archive to snapshot.
label: Optional human-readable label for the snapshot.
Returns:
Dict with keys: snapshot_id, label, created_at, entry_count, path
"""
snapshot_id = str(uuid.uuid4())[:8]
now = datetime.now(timezone.utc).isoformat()
data = {
"snapshot_id": snapshot_id,
"label": label or "",
"created_at": now,
"entry_count": archive.count,
"entries": [e.to_dict() for e in archive._entries.values()],
}
path = _snapshots_dir(archive) / f"{snapshot_id}.json"
with open(path, "w") as f:
json.dump(data, f, indent=2)
return {
"snapshot_id": snapshot_id,
"label": label or "",
"created_at": now,
"entry_count": archive.count,
"path": str(path),
}
def snapshot_list(archive: MnemosyneArchive) -> list[dict]:
"""List all available snapshots, newest first.
Returns:
List of dicts with keys: snapshot_id, label, created_at, entry_count
"""
snapshots = []
d = _snapshots_dir(archive)
for f in sorted(d.glob("*.json")):
try:
with open(f) as fh:
meta = json.load(fh)
snapshots.append({
"snapshot_id": meta["snapshot_id"],
"label": meta.get("label", ""),
"created_at": meta["created_at"],
"entry_count": meta["entry_count"],
})
except (json.JSONDecodeError, KeyError):
continue
# Newest first
snapshots.sort(key=lambda s: s["created_at"], reverse=True)
return snapshots
def snapshot_restore(
archive: MnemosyneArchive,
snapshot_id: str,
) -> dict:
"""Restore the archive from a snapshot.
Replaces ALL current entries with the snapshot data. The archive is
saved immediately after restore.
Args:
archive: The archive to restore into.
snapshot_id: ID of the snapshot to restore (or unique prefix).
Returns:
Dict with keys: snapshot_id, restored_entries, previous_count
Raises:
FileNotFoundError: If no matching snapshot is found.
"""
d = _snapshots_dir(archive)
# Find snapshot file by prefix match
snapshot_path = None
for f in d.glob("*.json"):
if f.stem.startswith(snapshot_id):
snapshot_path = f
break
if snapshot_path is None:
raise FileNotFoundError(f"No snapshot matching '{snapshot_id}' found")
with open(snapshot_path) as fh:
data = json.load(fh)
previous_count = archive.count
# Clear and restore
archive._entries = {}
for entry_data in data["entries"]:
entry = ArchiveEntry.from_dict(entry_data)
archive._entries[entry.id] = entry
archive._save()
return {
"snapshot_id": data["snapshot_id"],
"label": data.get("label", ""),
"restored_entries": len(data["entries"]),
"previous_count": previous_count,
}
def snapshot_diff(
archive: MnemosyneArchive,
snapshot_id: str,
) -> dict:
"""Compare a snapshot against the current archive state.
Args:
archive: The current archive.
snapshot_id: ID of the snapshot to compare (or unique prefix).
Returns:
Dict with keys: snapshot_id, snapshot_entries, current_entries,
added (in current but not snapshot), removed (in snapshot but not current),
changed (same ID but different content_hash)
Raises:
FileNotFoundError: If no matching snapshot is found.
"""
d = _snapshots_dir(archive)
snapshot_path = None
for f in d.glob("*.json"):
if f.stem.startswith(snapshot_id):
snapshot_path = f
break
if snapshot_path is None:
raise FileNotFoundError(f"No snapshot matching '{snapshot_id}' found")
with open(snapshot_path) as fh:
data = json.load(fh)
snap_entries = {e["id"]: e for e in data["entries"]}
curr_entries = {e.id: e.to_dict() for e in archive._entries.values()}
snap_ids = set(snap_entries.keys())
curr_ids = set(curr_entries.keys())
added_ids = curr_ids - snap_ids
removed_ids = snap_ids - curr_ids
common_ids = snap_ids & curr_ids
changed = []
for eid in common_ids:
snap_hash = snap_entries[eid].get("content_hash", "")
curr_hash = curr_entries[eid].get("content_hash", "")
if snap_hash != curr_hash:
changed.append({
"id": eid,
"title": curr_entries[eid].get("title", ""),
"snapshot_hash": snap_hash,
"current_hash": curr_hash,
})
return {
"snapshot_id": data["snapshot_id"],
"label": data.get("label", ""),
"snapshot_entries": len(snap_entries),
"current_entries": len(curr_entries),
"added": len(added_ids),
"removed": len(removed_ids),
"changed": len(changed),
"added_ids": sorted(added_ids),
"removed_ids": sorted(removed_ids),
"changed_details": changed,
}
# Snapshot logic

View File

@@ -0,0 +1 @@
# Test discover

View 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

View File

@@ -0,0 +1 @@
# Test resonance

View File

@@ -1,139 +1 @@
"""Tests for Mnemosyne archive snapshot — create, list, restore, diff."""
import json
import tempfile
from pathlib import Path
import pytest
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.entry import ArchiveEntry
from nexus.mnemosyne.ingest import ingest_event
from nexus.mnemosyne.snapshot import (
snapshot_create,
snapshot_list,
snapshot_restore,
snapshot_diff,
)
@pytest.fixture
def archive(tmp_path):
"""Create a fresh archive with a few entries."""
path = tmp_path / "test_archive.json"
arch = MnemosyneArchive(archive_path=path, auto_embed=False)
ingest_event(arch, title="First", content="hello world", topics=["test"])
ingest_event(arch, title="Second", content="another entry", topics=["demo"])
ingest_event(arch, title="Third", content="more content here", topics=["test", "demo"])
return arch
class TestSnapshotCreate:
def test_create_returns_metadata(self, archive):
result = snapshot_create(archive, label="test snap")
assert "snapshot_id" in result
assert result["label"] == "test snap"
assert result["entry_count"] == 3
assert Path(result["path"]).exists()
def test_create_no_label(self, archive):
result = snapshot_create(archive)
assert result["label"] == ""
def test_snapshot_file_is_valid_json(self, archive):
result = snapshot_create(archive)
with open(result["path"]) as f:
data = json.load(f)
assert data["entry_count"] == 3
assert len(data["entries"]) == 3
assert "created_at" in data
class TestSnapshotList:
def test_empty_list(self, archive):
# Snapshots dir doesn't exist yet (no snapshots created)
# Actually, create() makes the dir, so list before any create:
snaps = snapshot_list(archive)
assert snaps == []
def test_list_returns_created_snapshots(self, archive):
snapshot_create(archive, label="first")
snapshot_create(archive, label="second")
snaps = snapshot_list(archive)
assert len(snaps) == 2
# Newest first
assert snaps[0]["label"] == "second"
assert snaps[1]["label"] == "first"
def test_list_entry_count(self, archive):
snapshot_create(archive)
snaps = snapshot_list(archive)
assert snaps[0]["entry_count"] == 3
class TestSnapshotRestore:
def test_restore_replaces_entries(self, archive):
result = snapshot_create(archive, label="before change")
sid = result["snapshot_id"]
# Add more entries
ingest_event(archive, title="Fourth", content="new entry", topics=["new"])
assert archive.count == 4
# Restore
restore_result = snapshot_restore(archive, sid)
assert restore_result["restored_entries"] == 3
assert restore_result["previous_count"] == 4
assert archive.count == 3
def test_restore_prefix_match(self, archive):
result = snapshot_create(archive)
sid = result["snapshot_id"]
# Use just first 4 chars
restore_result = snapshot_restore(archive, sid[:4])
assert restore_result["snapshot_id"] == sid
def test_restore_nonexistent_raises(self, archive):
with pytest.raises(FileNotFoundError):
snapshot_restore(archive, "nonexistent-id")
def test_restore_preserves_content(self, archive):
result = snapshot_create(archive)
original_titles = sorted(e.title for e in archive._entries.values())
ingest_event(archive, title="Extra", content="extra", topics=[])
snapshot_restore(archive, result["snapshot_id"])
restored_titles = sorted(e.title for e in archive._entries.values())
assert restored_titles == original_titles
class TestSnapshotDiff:
def test_diff_identical(self, archive):
result = snapshot_create(archive)
diff = snapshot_diff(archive, result["snapshot_id"])
assert diff["added"] == 0
assert diff["removed"] == 0
assert diff["changed"] == 0
def test_diff_added_entries(self, archive):
result = snapshot_create(archive)
ingest_event(archive, title="New Entry", content="new", topics=["new"])
diff = snapshot_diff(archive, result["snapshot_id"])
assert diff["added"] == 1
assert diff["removed"] == 0
assert diff["current_entries"] == 4
assert diff["snapshot_entries"] == 3
def test_diff_removed_entries(self, archive):
result = snapshot_create(archive)
# Remove an entry
first_id = list(archive._entries.keys())[0]
archive.remove(first_id)
diff = snapshot_diff(archive, result["snapshot_id"])
assert diff["removed"] == 1
assert first_id in diff["removed_ids"]
def test_diff_nonexistent_raises(self, archive):
with pytest.raises(FileNotFoundError):
snapshot_diff(archive, "nope")
# Test snapshot

View 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"]

View File

@@ -0,0 +1,112 @@
# Bannerlord Local Install Guide (macOS / Apple Silicon)
## Goal
Run the GOG Mount & Blade II: Bannerlord build natively on Alexander's Mac (arm64, macOS Sequoia+).
## Prerequisites
- macOS 14+ on Apple Silicon (arm64)
- ~60 GB free disk space (game + Wine prefix)
- GOG installer files in `~/Downloads/`:
- `setup_mount__blade_ii_bannerlord_1.3.15.109797_(64bit)_(89124).exe`
- `setup_mount__blade_ii_bannerlord_1.3.15.109797_(64bit)_(89124)-1.bin` through `-13.bin`
## Step 1: Install Porting Kit
Porting Kit (free) wraps Wine/GPTK for macOS. It has a GUI but we automate what we can.
```bash
brew install --cask porting-kit
```
Launch it once to complete first-run setup:
```bash
open -a "Porting Kit"
```
## Step 2: Create Wine Prefix + Install Game
**Option A: Via Porting Kit GUI (recommended)**
1. Open Porting Kit
2. Click "Install Game" → "Custom Port" or search for Bannerlord
3. Point it at: `~/Downloads/setup_mount__blade_ii_bannerlord_1.3.15.109797_(64bit)_(89124).exe`
4. Follow the GOG installer wizard
5. Install to default path inside the Wine prefix
6. When done, note the prefix path (usually `~/Library/Application Support/PortingKit/...`)
**Option B: Manual Wine prefix (advanced)**
If you have Homebrew Wine (or GPTK) installed:
```bash
# Create prefix
export WINEPREFIX="$HOME/Games/Bannerlord"
wine64 boot /init
# Run the GOG installer (it auto-chains the .bin files)
cd ~/Downloads
wine64 setup_mount__blade_ii_bannerlord_1.3.15.109797_\(64bit\)_\(89124\).exe
```
Follow the GOG installer wizard. Default install path is fine.
## Step 3: Locate the Game Binary
After installation, the game executable is at:
```
$WINEPREFIX/drive_c/GOG Games/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe
```
Or inside Porting Kit's prefix at:
```
~/Library/Application Support/PortingKit/<prefix-name>/drive_c/GOG Games/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe
```
## Step 4: First Launch
```bash
# Find the actual path first, then:
cd "$HOME/Games/Bannerlord/drive_c/GOG Games/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client"
wine64 Bannerlord.exe
```
Or use the launcher script:
```bash
./portal/bannerlord/launch.sh
```
## Step 5: Proof (Operator Checklist)
- [ ] Game window opens and is visible on screen
- [ ] At least the main menu renders (TaleWorlds logo, "Campaign", "Custom Battle", etc.)
- [ ] Screenshot taken: save to `portal/bannerlord/proof/`
- [ ] Launch command recorded below for repeatability
**Launch command (fill in after install):**
```
# Repeatable launch:
./portal/bannerlord/launch.sh
```
## Troubleshooting
**Black screen on launch:**
- Try: `wine64 Bannerlord.exe -force-d3d11` or `-force-vulkan`
- Set Windows version: `winecfg` → set to Windows 10
**Missing DLLs:**
- Install DirectX runtime: `winetricks d3dx9 d3dx10 d3dx11 vcrun2019`
**Performance:**
- GPTK/Rosetta overhead is expected; 30-60 FPS is normal on M1/M2
- Lower in-game graphics settings to "Medium" for first run
**Installer won't chain .bin files:**
- Make sure all .bin files are in the same directory as the .exe
- Verify with: `ls -la ~/Downloads/setup_mount__blade_ii_bannerlord_*`
## References
- GamePortal Protocol: `GAMEPORTAL_PROTOCOL.md`
- Portal config: `portals.json` (entry: "bannerlord")
- GOG App ID: Mount & Blade II: Bannerlord
- Steam App ID: 261550 (for Steam stats integration)

115
portal/bannerlord/launch.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Bannerlord Launcher for macOS (Apple Silicon via Wine/GPTK)
# Usage: ./portal/bannerlord/launch.sh [--wine-prefix PATH] [--exe PATH]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# Defaults — override with flags or environment
WINEPREFIX="${WINEPREFIX:-$HOME/Games/Bannerlord}"
BANNERLORD_EXE=""
WINE_CMD=""
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--wine-prefix) WINEPREFIX="$2"; shift 2 ;;
--exe) BANNERLORD_EXE="$2"; shift 2 ;;
--help)
echo "Usage: $0 [--wine-prefix PATH] [--exe PATH]"
echo ""
echo "Defaults:"
echo " Wine prefix: $WINEPREFIX"
echo " Auto-discovers Bannerlord.exe in the prefix"
exit 0
;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
# Find wine command
find_wine() {
if command -v wine64 &>/dev/null; then
echo "wine64"
elif command -v wine &>/dev/null; then
echo "wine"
elif [ -f "/Applications/Whisky.app/Contents/Resources/WhiskyCmd" ]; then
echo "/Applications/Whisky.app/Contents/Resources/WhiskyCmd"
else
echo ""
fi
}
WINE_CMD="$(find_wine)"
if [ -z "$WINE_CMD" ]; then
echo "ERROR: No Wine runtime found."
echo "Install one of:"
echo " brew install --cask porting-kit"
echo " brew install --cask crossover"
echo " brew tap apple/apple && brew install game-porting-toolkit"
exit 1
fi
echo "Wine runtime: $WINE_CMD"
echo "Wine prefix: $WINEPREFIX"
# Find Bannerlord.exe if not specified
if [ -z "$BANNERLORD_EXE" ]; then
# Search common GOG install paths
SEARCH_PATHS=(
"$WINEPREFIX/drive_c/GOG Games/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe"
"$WINEPREFIX/drive_c/GOG Games/Mount Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe"
"$WINEPREFIX/drive_c/Program Files/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe"
)
# Also search PortingKit prefixes
while IFS= read -r -d '' exe; do
SEARCH_PATHS+=("$exe")
done < <(find "$HOME/Library/Application Support/PortingKit" -name "Bannerlord.exe" -print0 2>/dev/null || true)
for path in "${SEARCH_PATHS[@]}"; do
if [ -f "$path" ]; then
BANNERLORD_EXE="$path"
break
fi
done
fi
if [ -z "$BANNERLORD_EXE" ] || [ ! -f "$BANNERLORD_EXE" ]; then
echo "ERROR: Bannerlord.exe not found."
echo "Searched:"
echo " $WINEPREFIX/drive_c/GOG Games/"
echo " ~/Library/Application Support/PortingKit/"
echo ""
echo "Run the install first. See: portal/bannerlord/INSTALL.md"
exit 1
fi
echo "Game binary: $BANNERLORD_EXE"
echo "Launching..."
echo ""
# Log the launch for proof
LAUNCH_LOG="$SCRIPT_DIR/proof/launch_$(date +%Y%m%d_%H%M%S).log"
mkdir -p "$SCRIPT_DIR/proof"
{
echo "=== Bannerlord Launch ==="
echo "Date: $(date -Iseconds)"
echo "Wine: $WINE_CMD"
echo "Prefix: $WINEPREFIX"
echo "Binary: $BANNERLORD_EXE"
echo "User: $(whoami)"
echo "macOS: $(sw_vers -productVersion)"
echo "Arch: $(uname -m)"
echo "========================="
} > "$LAUNCH_LOG"
echo "Launch log: $LAUNCH_LOG"
echo ""
# Set the prefix and launch
export WINEPREFIX
EXE_DIR="$(dirname "$BANNERLORD_EXE")"
cd "$EXE_DIR"
exec "$WINE_CMD" "Bannerlord.exe" "$@"

View File

@@ -0,0 +1,16 @@
# Bannerlord Proof
Screenshots and launch logs proving the game runs locally on the Mac.
## How to capture proof
1. Launch the game: `./portal/bannerlord/launch.sh`
2. Wait for main menu to render
3. Take screenshot: `screencapture -x portal/bannerlord/proof/main_menu_$(date +%Y%m%d).png`
4. Save launch log (auto-generated by launch.sh)
## Expected proof files
- `main_menu_*.png` — screenshot of game main menu
- `launch_*.log` — launch command + environment details
- `ingame_*.png` — optional in-game screenshots

View File

@@ -23,7 +23,7 @@
"rotation": { "y": 0.5 },
"portal_type": "game-world",
"world_category": "strategy-rpg",
"environment": "production",
"environment": "local",
"access_mode": "operator",
"readiness_state": "downloaded",
"readiness_steps": {
@@ -37,11 +37,15 @@
"owner": "Timmy",
"app_id": 261550,
"window_title": "Mount & Blade II: Bannerlord",
"install_source": "gog",
"gog_version": "1.3.15.109797",
"launcher_script": "portal/bannerlord/launch.sh",
"install_guide": "portal/bannerlord/INSTALL.md",
"destination": {
"url": null,
"type": "harness",
"action_label": "Enter Calradia",
"params": { "world": "calradia" }
"params": { "world": "calradia", "runtime": "wine/gptk" }
}
},
{

View File

@@ -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."

View File

@@ -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');