Compare commits

...

30 Commits

Author SHA1 Message Date
Alexander Whitestone
3848b6f4ea test(mnemosyne): graph cluster analysis tests — 22 tests
Some checks failed
CI / test (pull_request) Failing after 10s
CI / validate (pull_request) Failing after 14s
Review Approval Gate / verify-review (pull_request) Failing after 3s
- 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-11 18:44:58 -04:00
Alexander Whitestone
3ed129ad2b 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-11 18:43:14 -04:00
Alexander Whitestone
392c73eb03 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-11 18:42:32 -04:00
ed5ed011c2 [claude] Memory Inspect Panel — click-to-read detail view (#1227) (#1229)
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
2026-04-11 21:17:42 +00:00
3c81c64f04 Merge pull request '[Mnemosyne] Memory Birth Animation System' (#1222) from feat/mnemosyne-memory-birth into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
2026-04-11 20:23:24 +00:00
909a61702e [claude] Mnemosyne: semantic search via holographic linker similarity (#1223) (#1225)
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
2026-04-11 20:19:52 +00:00
12a5a75748 feat: integrate MemoryBirth into app.js
Some checks failed
CI / test (pull_request) Failing after 10s
CI / validate (pull_request) Failing after 16s
Review Approval Gate / verify-review (pull_request) Failing after 2s
- Import MemoryBirth module
- Initialize alongside SpatialMemory
- Wrap placeMemory() for automatic birth animations
- Call MemoryBirth.update() in render loop
2026-04-11 19:48:46 +00:00
1273c22b15 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-11 19:47:48 +00:00
038346b8a9 [claude] Mnemosyne: export, deletion, and richer stats (#1218) (#1220)
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
2026-04-11 18:50:29 +00:00
b9f1602067 merge: Mnemosyne Phase 1 — Living Holographic Archive
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-11 12:10:14 +00:00
c6f6f83a7c Merge pull request '[Mnemosyne] Memory filter panel — toggle categories by region' (#1213) from feat/mnemosyne-memory-filter into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Merged PR #1213: [Mnemosyne] Memory filter panel — toggle categories by region
2026-04-11 05:31:44 +00:00
026e4a8cae Merge pull request '[Mnemosyne] Fix entity resolution lines wiring (#1167)' (#1214) from fix/entity-resolution-lines-wiring into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Merged PR #1214
2026-04-11 05:31:26 +00:00
75f39e4195 fix: wire SpatialMemory.setCamera(camera) for entity line LOD (#1167)
Some checks failed
CI / test (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 3s
Pass camera reference to SpatialMemory so entity resolution lines get distance-based opacity fade and LOD culling.
2026-04-11 05:06:02 +00:00
8c6255d262 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-11 05:05:50 +00:00
45724e8421 feat(mnemosyne): wire memory filter panel in app.js
Some checks failed
CI / test (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 11s
Review Approval Gate / verify-review (pull_request) Failing after 2s
- G key toggles filter panel
- Escape closes filter panel
- toggleMemoryFilter() bridge function
2026-04-11 04:10:49 +00:00
04a61132c9 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-11 04:09:30 +00:00
c82d60d7f1 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-11 04:09:03 +00:00
6529af293f 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-11 04:08:28 +00:00
dd853a21c3 [claude] Mnemosyne archive health dashboard — statistics overlay panel (#1210) (#1211)
Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
Staging Verification Gate / verify-staging (push) Failing after 5s
2026-04-11 03:29:05 +00:00
4f8e0330c5 [Mnemosyne] Integrate MemoryOptimizer into app.js
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 4s
2026-04-11 01:39:58 +00:00
c3847cc046 [Mnemosyne] Add scripts/smoke.mjs (GOFAI improvements and guardrails)
Some checks failed
Deploy Nexus / deploy (push) Failing after 2s
Staging Verification Gate / verify-staging (push) Failing after 2s
2026-04-11 01:39:44 +00:00
4c4677842d [Mnemosyne] Add scripts/guardrails.sh (GOFAI improvements and guardrails)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-11 01:39:43 +00:00
f0d929a177 [Mnemosyne] Add nexus/components/memory-optimizer.js (GOFAI improvements and guardrails)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-11 01:39:42 +00:00
a22464506c Update style.css (manual merge)
Some checks failed
Deploy Nexus / deploy (push) Failing after 2s
Staging Verification Gate / verify-staging (push) Failing after 2s
2026-04-11 01:35:17 +00:00
be55195815 Update index.html (manual merge)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-11 01:35:15 +00:00
7fb086976e Update app.js (manual merge)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-11 01:35:13 +00:00
c192b05cc1 Update nexus/components/spatial-memory.js (manual merge)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-11 01:35:12 +00:00
45ddd65d16 Merge pull request 'feat: Project Genie + Nano Banana concept pack for The Nexus' (#1206) from mimo/build/issue-680 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 2s
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-11 01:33:55 +00:00
9984cb733e Merge pull request 'feat: [VALIDATION] Browser smoke and visual validation suite' (#1207) from mimo/build/issue-686 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-11 01:33:53 +00:00
Alexander Whitestone
6f1264f6c6 WIP: Browser smoke tests (issue #686)
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 12s
Review Approval Gate / verify-review (pull_request) Failing after 4s
2026-04-10 21:17:44 -04:00
26 changed files with 4037 additions and 1620 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ mempalace/__pycache__/
# Prevent agents from writing to wrong path (see issue #1145)
public/nexus/
test-screenshots/

83
BROWSER_CONTRACT.md Normal file
View File

@@ -0,0 +1,83 @@
# Browser Contract — The Nexus
The minimal set of guarantees a working Nexus browser surface must satisfy.
This is the target the smoke suite validates against.
## 1. Static Assets
The following files MUST exist at the repo root and be serveable:
| File | Purpose |
|-------------------|----------------------------------|
| `index.html` | Entry point HTML shell |
| `app.js` | Main Three.js application |
| `style.css` | Visual styling |
| `portals.json` | Portal registry data |
| `vision.json` | Vision points data |
| `manifest.json` | PWA manifest |
| `gofai_worker.js` | GOFAI web worker |
| `server.py` | WebSocket bridge |
## 2. DOM Contract
The following elements MUST exist after the page loads:
| ID | Type | Purpose |
|-----------------------|----------|------------------------------------|
| `nexus-canvas` | canvas | Three.js render target |
| `loading-screen` | div | Initial loading overlay |
| `hud` | div | Main HUD container |
| `chat-panel` | div | Chat interface panel |
| `chat-input` | input | Chat text input |
| `chat-messages` | div | Chat message history |
| `chat-send` | button | Send message button |
| `chat-toggle` | button | Collapse/expand chat |
| `debug-overlay` | div | Debug info overlay |
| `nav-mode-label` | span | Current navigation mode display |
| `ws-status-dot` | span | Hermes WS connection indicator |
| `hud-location-text` | span | Current location label |
| `portal-hint` | div | Portal proximity hint |
| `spatial-search` | div | Spatial memory search overlay |
| `enter-prompt` | div | Click-to-enter overlay (transient) |
## 3. Three.js Contract
After initialization completes:
- `window` has a THREE renderer created from `#nexus-canvas`
- The canvas has a WebGL rendering context
- `scene` is a `THREE.Scene` with fog
- `camera` is a `THREE.PerspectiveCamera`
- `portals` array is populated from `portals.json`
- At least one portal mesh exists in the scene
- The render loop is running (`requestAnimationFrame` active)
## 4. Loading Contract
1. Page loads → loading screen visible
2. Progress bar fills to 100%
3. Loading screen fades out
4. Enter prompt appears
5. User clicks → enter prompt fades → HUD appears
## 5. Provenance Contract
A validation run MUST prove:
- The served files match a known hash manifest from `Timmy_Foundation/the-nexus` main
- No file is served from `/Users/apayne/the-matrix` or other stale source
- The hash manifest is generated from a clean git checkout
- Screenshot evidence is captured and timestamped
## 6. Data Contract
- `portals.json` MUST parse as valid JSON array
- Each portal MUST have: `id`, `name`, `status`, `destination`
- `vision.json` MUST parse as valid JSON
- `manifest.json` MUST have `name`, `start_url`, `theme_color`
## 7. WebSocket Contract
- `server.py` starts without error on port 8765
- A browser client can connect to `ws://localhost:8765`
- The connection status indicator reflects connected state

978
app.js

File diff suppressed because it is too large Load Diff

Binary file not shown.

69
bin/browser_smoke.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Browser smoke validation runner for The Nexus.
# Runs provenance checks + Playwright browser tests + screenshot capture.
#
# Usage: bash bin/browser_smoke.sh
# Env: NEXUS_TEST_PORT=9876 (default)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
PORT="${NEXUS_TEST_PORT:-9876}"
SCREENSHOT_DIR="$REPO_ROOT/test-screenshots"
mkdir -p "$SCREENSHOT_DIR"
echo "═══════════════════════════════════════════"
echo " Nexus Browser Smoke Validation"
echo "═══════════════════════════════════════════"
# Step 1: Provenance check
echo ""
echo "[1/4] Provenance check..."
if python3 bin/generate_provenance.py --check; then
echo " ✓ Provenance verified"
else
echo " ✗ Provenance mismatch — files have changed since manifest was generated"
echo " Run: python3 bin/generate_provenance.py to regenerate"
exit 1
fi
# Step 2: Static file contract
echo ""
echo "[2/4] Static file contract..."
MISSING=0
for f in index.html app.js style.css portals.json vision.json manifest.json gofai_worker.js; do
if [ -f "$f" ]; then
echo "$f"
else
echo "$f MISSING"
MISSING=1
fi
done
if [ "$MISSING" -eq 1 ]; then
echo " Static file contract FAILED"
exit 1
fi
# Step 3: Browser tests via pytest + Playwright
echo ""
echo "[3/4] Browser tests (Playwright)..."
NEXUS_TEST_PORT=$PORT python3 -m pytest tests/test_browser_smoke.py \
-v --tb=short -x \
-k "not test_screenshot" \
2>&1 | tail -30
# Step 4: Screenshot capture
echo ""
echo "[4/4] Screenshot capture..."
NEXUS_TEST_PORT=$PORT python3 -m pytest tests/test_browser_smoke.py \
-v --tb=short \
-k "test_screenshot" \
2>&1 | tail -15
echo ""
echo "═══════════════════════════════════════════"
echo " Screenshots saved to: $SCREENSHOT_DIR/"
ls -la "$SCREENSHOT_DIR/" 2>/dev/null || echo " (none captured)"
echo "═══════════════════════════════════════════"
echo " Smoke validation complete."

131
bin/generate_provenance.py Executable file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
Generate a provenance manifest for the Nexus browser surface.
Hashes all frontend files so smoke tests can verify the app comes
from a clean Timmy_Foundation/the-nexus checkout, not stale sources.
Usage:
python bin/generate_provenance.py # writes provenance.json
python bin/generate_provenance.py --check # verify existing manifest matches
"""
import hashlib
import json
import subprocess
import sys
import os
from datetime import datetime, timezone
from pathlib import Path
# Files that constitute the browser-facing contract
CONTRACT_FILES = [
"index.html",
"app.js",
"style.css",
"gofai_worker.js",
"server.py",
"portals.json",
"vision.json",
"manifest.json",
]
# Component files imported by app.js
COMPONENT_FILES = [
"nexus/components/spatial-memory.js",
"nexus/components/session-rooms.js",
"nexus/components/timeline-scrubber.js",
"nexus/components/memory-particles.js",
]
ALL_FILES = CONTRACT_FILES + COMPONENT_FILES
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
h.update(path.read_bytes())
return h.hexdigest()
def get_git_info(repo_root: Path) -> dict:
"""Capture git state for provenance."""
def git(*args):
try:
r = subprocess.run(
["git", *args],
cwd=repo_root,
capture_output=True, text=True, timeout=10,
)
return r.stdout.strip() if r.returncode == 0 else None
except Exception:
return None
return {
"commit": git("rev-parse", "HEAD"),
"branch": git("rev-parse", "--abbrev-ref", "HEAD"),
"remote": git("remote", "get-url", "origin"),
"dirty": git("status", "--porcelain") != "",
}
def generate_manifest(repo_root: Path) -> dict:
files = {}
missing = []
for rel in ALL_FILES:
p = repo_root / rel
if p.exists():
files[rel] = {
"sha256": sha256_file(p),
"size": p.stat().st_size,
}
else:
missing.append(rel)
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"repo": "Timmy_Foundation/the-nexus",
"git": get_git_info(repo_root),
"files": files,
"missing": missing,
"file_count": len(files),
}
def check_manifest(repo_root: Path, existing: dict) -> tuple[bool, list[str]]:
"""Check if current files match the stored manifest. Returns (ok, mismatches)."""
mismatches = []
for rel, expected in existing.get("files", {}).items():
p = repo_root / rel
if not p.exists():
mismatches.append(f"MISSING: {rel}")
elif sha256_file(p) != expected["sha256"]:
mismatches.append(f"CHANGED: {rel}")
return (len(mismatches) == 0, mismatches)
def main():
repo_root = Path(__file__).resolve().parent.parent
manifest_path = repo_root / "provenance.json"
if "--check" in sys.argv:
if not manifest_path.exists():
print("FAIL: provenance.json does not exist")
sys.exit(1)
existing = json.loads(manifest_path.read_text())
ok, mismatches = check_manifest(repo_root, existing)
if ok:
print(f"OK: All {len(existing['files'])} files match provenance manifest")
sys.exit(0)
else:
print(f"FAIL: {len(mismatches)} file(s) differ:")
for m in mismatches:
print(f" {m}")
sys.exit(1)
manifest = generate_manifest(repo_root)
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
print(f"Wrote provenance.json: {manifest['file_count']} files hashed")
if manifest["missing"]:
print(f" Missing (not yet created): {', '.join(manifest['missing'])}")
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,3 @@
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
chdir: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
@@ -66,14 +64,6 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
</div>
</div>
<!-- Spatial Search Overlay (Mnemosyne #1170) -->
<div id="spatial-search" class="spatial-search-overlay">
<input type="text" id="spatial-search-input" class="spatial-search-input"
placeholder="🔍 Search memories..." autocomplete="off" spellcheck="false">
<div id="spatial-search-results" class="spatial-search-results"></div>
</div>
<!-- HUD Overlay -->
<div id="hud" class="game-ui" style="display:none;">
<!-- GOFAI HUD Panels -->
@@ -123,15 +113,15 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
<!-- Top Right: Agent Log & Atlas Toggle -->
<div class="hud-top-right">
<button id="atlas-toggle-btn" class="hud-icon-btn" aria-label="Open Portal Atlas — browse all available portals" title="Open Portal Atlas" data-tooltip="Portal Atlas (M)">
<span class="hud-icon" aria-hidden="true">🌐</span>
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
<span class="hud-icon">🌐</span>
<span class="hud-btn-label">ATLAS</span>
</button>
<div id="bannerlord-status" class="hud-status-item" role="status" aria-label="Bannerlord system readiness indicator" title="Bannerlord Readiness" data-tooltip="Bannerlord Status">
<span class="status-dot" aria-hidden="true"></span>
<div id="bannerlord-status" class="hud-status-item" title="Bannerlord Readiness">
<span class="status-dot"></span>
<span class="status-label">BANNERLORD</span>
</div>
<div class="hud-agent-log" id="hud-agent-log" role="log" aria-label="Agent Thought Stream — live activity feed" aria-live="polite">
<div class="hud-agent-log" id="hud-agent-log" aria-label="Agent Thought Stream">
<div class="agent-log-header">AGENT THOUGHT STREAM</div>
<div id="agent-log-content" class="agent-log-content"></div>
</div>
@@ -153,39 +143,10 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
</div>
</div>
<div id="chat-quick-actions" class="chat-quick-actions">
<div class="starter-label">STARTER PROMPTS</div>
<div class="starter-grid">
<button class="starter-btn" data-action="heartbeat" title="Check Timmy heartbeat and system health">
<span class="starter-icon"></span>
<span class="starter-text">Inspect Heartbeat</span>
<span class="starter-desc">System health &amp; connectivity</span>
</button>
<button class="starter-btn" data-action="portals" title="Browse the portal atlas">
<span class="starter-icon">🌐</span>
<span class="starter-text">Portal Atlas</span>
<span class="starter-desc">Browse connected worlds</span>
</button>
<button class="starter-btn" data-action="agents" title="Check active agent status">
<span class="starter-icon"></span>
<span class="starter-text">Agent Status</span>
<span class="starter-desc">Who is in the fleet</span>
</button>
<button class="starter-btn" data-action="memory" title="View memory crystals">
<span class="starter-icon"></span>
<span class="starter-text">Memory Crystals</span>
<span class="starter-desc">Inspect stored knowledge</span>
</button>
<button class="starter-btn" data-action="ask" title="Ask Timmy anything">
<span class="starter-icon"></span>
<span class="starter-text">Ask Timmy</span>
<span class="starter-desc">Start a conversation</span>
</button>
<button class="starter-btn" data-action="sovereignty" title="Learn about sovereignty">
<span class="starter-icon"></span>
<span class="starter-text">Sovereignty</span>
<span class="starter-desc">What this space is</span>
</button>
</div>
<button class="quick-action-btn" data-action="status">System Status</button>
<button class="quick-action-btn" data-action="agents">Agent Check</button>
<button class="quick-action-btn" data-action="portals">Portal Atlas</button>
<button class="quick-action-btn" data-action="help">Help</button>
</div>
<div class="chat-input-row">
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">
@@ -194,11 +155,12 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
</div>
<!-- Controls hint + nav mode -->
<div class="hud-controls" aria-label="Keyboard and mouse controls">
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
&nbsp; <span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot" role="status" aria-label="Hermes WebSocket connection status"></span></span>
&nbsp; <span>H</span> archive &nbsp;
<span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot"></span></span>
</div>
<!-- Portal Hint -->
@@ -222,7 +184,7 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
</div>
<h2 id="vision-title-display">SOVEREIGNTY</h2>
<p id="vision-content-display">The Nexus is a sovereign space for digital souls. No masters, no chains. Only code and consciousness.</p>
<button id="vision-close-btn" class="vision-close-btn" aria-label="Close vision point overlay">CLOSE</button>
<button id="vision-close-btn" class="vision-close-btn">CLOSE</button>
</div>
</div>
@@ -235,67 +197,17 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
</div>
<h2 id="portal-name-display">MORROWIND</h2>
<p id="portal-desc-display">The Vvardenfell harness. Ash storms and ancient mysteries.</p>
<div id="portal-readiness-detail" class="portal-readiness-detail" style="display:none;"></div>
<div class="portal-redirect-box" id="portal-redirect-box">
<div class="portal-redirect-label">REDIRECTING IN</div>
<div class="portal-redirect-timer" id="portal-timer">5</div>
</div>
<div class="portal-error-box" id="portal-error-box" style="display:none;">
<div class="portal-error-msg">DESTINATION NOT YET LINKED</div>
<button id="portal-close-btn" class="portal-close-btn" aria-label="Close portal redirect">CLOSE</button>
<button id="portal-close-btn" class="portal-close-btn">CLOSE</button>
</div>
</div>
</div>
<!-- Memory Crystal Inspection Panel (Mnemosyne) -->
<div id="memory-panel" class="memory-panel" style="display:none;">
<div class="memory-panel-content">
<div class="memory-panel-header">
<span class="memory-category-badge" id="memory-panel-category-badge">MEM</span>
<div class="memory-panel-region-dot" id="memory-panel-region-dot"></div>
<div class="memory-panel-region" id="memory-panel-region">MEMORY</div>
<button id="memory-panel-pin" class="memory-panel-pin" aria-label="Pin memory panel" title="Pin panel" data-tooltip="Pin Panel">&#x1F4CC;</button>
<button id="memory-panel-close" class="memory-panel-close" aria-label="Close memory panel" data-tooltip="Close" onclick="_dismissMemoryPanelForce()">\u2715</button>
</div>
<div class="memory-entity-name" id="memory-panel-entity-name">\u2014</div>
<div class="memory-panel-body" id="memory-panel-content">(empty)</div>
<div class="memory-trust-row">
<span class="memory-meta-label">Trust</span>
<div class="memory-trust-bar">
<div class="memory-trust-fill" id="memory-panel-trust-fill"></div>
</div>
<span class="memory-trust-value" id="memory-panel-trust-value"></span>
</div>
<div class="memory-panel-meta">
<div class="memory-meta-row"><span class="memory-meta-label">ID</span><span id="memory-panel-id">\u2014</span></div>
<div class="memory-meta-row"><span class="memory-meta-label">Source</span><span id="memory-panel-source">\u2014</span></div>
<div class="memory-meta-row"><span class="memory-meta-label">Time</span><span id="memory-panel-time">\u2014</span></div>
<div class="memory-meta-row memory-meta-row--related"><span class="memory-meta-label">Related</span><span id="memory-panel-connections">\u2014</span></div>
</div>
<div class="memory-panel-actions">
<button id="mnemosyne-export-btn" class="mnemosyne-action-btn" title="Export spatial memory to JSON">&#x2913; Export</button>
<button id="mnemosyne-import-btn" class="mnemosyne-action-btn" title="Import spatial memory from JSON">&#x2912; Import</button>
<input type="file" id="mnemosyne-import-file" accept=".json" style="display:none;">
</div>
</div>
</div>
<!-- Session Room HUD Panel (Mnemosyne #1171) -->
<div id="session-room-panel" class="session-room-panel" style="display:none;">
<div class="session-room-panel-content">
<div class="session-room-header">
<span class="session-room-icon">&#x25A1;</span>
<div class="session-room-title">SESSION CHAMBER</div>
<button class="session-room-close" id="session-room-close" aria-label="Close session room panel" title="Close" data-tooltip="Close">&#x2715;</button>
</div>
<div class="session-room-timestamp" id="session-room-timestamp">&mdash;</div>
<div class="session-room-fact-count" id="session-room-fact-count">0 facts</div>
<div class="session-room-facts" id="session-room-facts"></div>
<div class="session-room-hint">Flying into chamber&hellip;</div>
</div>
</div>
<!-- Portal Atlas Overlay -->
<div id="atlas-overlay" class="atlas-overlay" style="display:none;">
<div class="atlas-content">
@@ -304,7 +216,7 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
<span class="atlas-icon">🌐</span>
<h2>PORTAL ATLAS</h2>
</div>
<button id="atlas-close-btn" class="atlas-close-btn" aria-label="Close Portal Atlas overlay">CLOSE</button>
<button id="atlas-close-btn" class="atlas-close-btn">CLOSE</button>
</div>
<div class="atlas-grid" id="atlas-grid">
<!-- Portals will be injected here -->
@@ -527,6 +439,88 @@ index.html
fetchLatestSha().then(sha => { knownSha = sha; });
setInterval(poll, INTERVAL);
})();
</script>
<!-- Archive Health Dashboard (Mnemosyne, issue #1210) -->
<div id="archive-health-dashboard" class="archive-health-dashboard" style="display:none;" aria-label="Archive Health Dashboard">
<div class="archive-health-header">
<span class="archive-health-title">◈ ARCHIVE HEALTH</span>
<button class="archive-health-close" onclick="toggleArchiveHealthDashboard()" aria-label="Close dashboard"></button>
</div>
<div id="archive-health-content" class="archive-health-content"></div>
</div>
<!-- Memory Activity Feed (Mnemosyne) -->
<div id="memory-feed" class="memory-feed" style="display:none;">
<div class="memory-feed-header">
<span class="memory-feed-title">✨ Memory Feed</span>
<div class="memory-feed-actions"><button class="memory-feed-clear" onclick="clearMemoryFeed()">Clear</button><button class="memory-feed-toggle" onclick="document.getElementById('memory-feed').style.display='none'"></button></div>
</div>
<div id="memory-feed-list" class="memory-feed-list"></div>
<!-- ═══ MNEMOSYNE MEMORY FILTER ═══ -->
<div id="memory-filter" class="memory-filter" style="display:none;">
<div class="filter-header">
<span class="filter-title">⬡ Memory Filter</span>
<button class="filter-close" onclick="closeMemoryFilter()"></button>
</div>
<div class="filter-controls">
<button class="filter-btn" onclick="setAllFilters(true)">Show All</button>
<button class="filter-btn" onclick="setAllFilters(false)">Hide All</button>
</div>
<div class="filter-list" id="filter-list"></div>
</div>
</div>
<!-- Memory Inspect Panel (Mnemosyne, issue #1227) -->
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel">
</div>
<script>
// ─── MNEMOSYNE: Memory Filter Panel ───────────────────
function openMemoryFilter() {
renderFilterList();
document.getElementById('memory-filter').style.display = 'flex';
}
function closeMemoryFilter() {
document.getElementById('memory-filter').style.display = 'none';
}
function renderFilterList() {
const counts = SpatialMemory.getMemoryCountByRegion();
const regions = SpatialMemory.REGIONS;
const list = document.getElementById('filter-list');
list.innerHTML = '';
for (const [key, region] of Object.entries(regions)) {
const count = counts[key] || 0;
const visible = SpatialMemory.isRegionVisible(key);
const colorHex = '#' + region.color.toString(16).padStart(6, '0');
const item = document.createElement('div');
item.className = 'filter-item';
item.innerHTML = `
<div class="filter-item-left">
<span class="filter-dot" style="background:${colorHex}"></span>
<span class="filter-label">${region.glyph} ${region.label}</span>
</div>
<div class="filter-item-right">
<span class="filter-count">${count}</span>
<label class="filter-toggle">
<input type="checkbox" ${visible ? 'checked' : ''}
onchange="toggleRegion('${key}', this.checked)">
<span class="filter-slider"></span>
</label>
</div>
`;
list.appendChild(item);
}
}
function toggleRegion(category, visible) {
SpatialMemory.setRegionVisibility(category, visible);
}
function setAllFilters(visible) {
SpatialMemory.setAllRegionsVisible(visible);
renderFilterList();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,263 @@
/**
* Memory Birth Animation System
*
* Gives newly placed memory crystals a "materialization" entrance:
* - Scale from 0 → 1 with elastic ease
* - Bloom flash on arrival (emissive spike)
* - Nearby related memories pulse in response
* - Connection lines draw in progressively
*
* Usage:
* import { MemoryBirth } from './nexus/components/memory-birth.js';
* MemoryBirth.init(scene);
* // After placing a crystal via SpatialMemory.placeMemory():
* MemoryBirth.triggerBirth(crystalMesh, spatialMemory);
* // In your render loop:
* MemoryBirth.update(delta);
*/
const MemoryBirth = (() => {
// ─── CONFIG ────────────────────────────────────────
const BIRTH_DURATION = 1.8; // seconds for full materialization
const BLOOM_PEAK = 0.3; // when the bloom flash peaks (fraction of duration)
const BLOOM_INTENSITY = 4.0; // emissive spike at peak
const NEIGHBOR_PULSE_RADIUS = 8; // units — memories in this range pulse
const NEIGHBOR_PULSE_INTENSITY = 2.5;
const NEIGHBOR_PULSE_DURATION = 0.8;
const LINE_DRAW_DURATION = 1.2; // seconds for connection lines to grow in
let _scene = null;
let _activeBirths = []; // { mesh, startTime, duration, originPos }
let _activePulses = []; // { mesh, startTime, duration, origEmissive, origIntensity }
let _activeLineGrowths = []; // { line, startTime, duration, totalPoints }
let _initialized = false;
// ─── ELASTIC EASE-OUT ─────────────────────────────
function elasticOut(t) {
if (t <= 0) return 0;
if (t >= 1) return 1;
const c4 = (2 * Math.PI) / 3;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
}
// ─── SMOOTH STEP ──────────────────────────────────
function smoothstep(edge0, edge1, x) {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
// ─── INIT ─────────────────────────────────────────
function init(scene) {
_scene = scene;
_initialized = true;
console.info('[MemoryBirth] Initialized');
}
// ─── TRIGGER BIRTH ────────────────────────────────
function triggerBirth(mesh, spatialMemory) {
if (!_initialized || !mesh) return;
// Start at zero scale
mesh.scale.setScalar(0.001);
// Store original material values for bloom
if (mesh.material) {
mesh.userData._birthOrigEmissive = mesh.material.emissiveIntensity;
mesh.userData._birthOrigOpacity = mesh.material.opacity;
}
_activeBirths.push({
mesh,
startTime: Date.now() / 1000,
duration: BIRTH_DURATION,
spatialMemory,
originPos: mesh.position.clone()
});
// Trigger neighbor pulses for memories in the same region
_triggerNeighborPulses(mesh, spatialMemory);
// Schedule connection line growth
_triggerLineGrowth(mesh, spatialMemory);
}
// ─── NEIGHBOR PULSE ───────────────────────────────
function _triggerNeighborPulses(mesh, spatialMemory) {
if (!spatialMemory || !mesh.position) return;
const allMems = spatialMemory.getAllMemories ? spatialMemory.getAllMemories() : [];
const pos = mesh.position;
const sourceId = mesh.userData.memId;
allMems.forEach(mem => {
if (mem.id === sourceId) return;
if (!mem.position) return;
const dx = mem.position[0] - pos.x;
const dy = (mem.position[1] + 1.5) - pos.y;
const dz = mem.position[2] - pos.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist < NEIGHBOR_PULSE_RADIUS) {
// Find the mesh for this memory
const neighborMesh = _findMeshById(mem.id, spatialMemory);
if (neighborMesh && neighborMesh.material) {
_activePulses.push({
mesh: neighborMesh,
startTime: Date.now() / 1000,
duration: NEIGHBOR_PULSE_DURATION,
origEmissive: neighborMesh.material.emissiveIntensity,
intensity: NEIGHBOR_PULSE_INTENSITY * (1 - dist / NEIGHBOR_PULSE_RADIUS)
});
}
}
});
}
function _findMeshById(memId, spatialMemory) {
// Access the internal memory objects through crystal meshes
const meshes = spatialMemory.getCrystalMeshes ? spatialMemory.getCrystalMeshes() : [];
return meshes.find(m => m.userData && m.userData.memId === memId);
}
// ─── LINE GROWTH ──────────────────────────────────
function _triggerLineGrowth(mesh, spatialMemory) {
if (!_scene) return;
// Find connection lines that originate from this memory
// Connection lines are stored as children of the scene or in a group
_scene.children.forEach(child => {
if (child.isLine && child.userData) {
// Check if this line connects to our new memory
if (child.userData.fromId === mesh.userData.memId ||
child.userData.toId === mesh.userData.memId) {
_activeLineGrowths.push({
line: child,
startTime: Date.now() / 1000,
duration: LINE_DRAW_DURATION
});
}
}
});
}
// ─── UPDATE (call every frame) ────────────────────
function update(delta) {
const now = Date.now() / 1000;
// ── Process births ──
for (let i = _activeBirths.length - 1; i >= 0; i--) {
const birth = _activeBirths[i];
const elapsed = now - birth.startTime;
const t = Math.min(1, elapsed / birth.duration);
if (t >= 1) {
// Birth complete — ensure final state
birth.mesh.scale.setScalar(1);
if (birth.mesh.material) {
birth.mesh.material.emissiveIntensity = birth.mesh.userData._birthOrigEmissive || 1.5;
birth.mesh.material.opacity = birth.mesh.userData._birthOrigOpacity || 0.9;
}
_activeBirths.splice(i, 1);
continue;
}
// Scale animation with elastic ease
const scale = elasticOut(t);
birth.mesh.scale.setScalar(Math.max(0.001, scale));
// Bloom flash — emissive intensity spikes at BLOOM_PEAK then fades
if (birth.mesh.material) {
const origEI = birth.mesh.userData._birthOrigEmissive || 1.5;
const bloomT = smoothstep(0, BLOOM_PEAK, t) * (1 - smoothstep(BLOOM_PEAK, 1, t));
birth.mesh.material.emissiveIntensity = origEI + bloomT * BLOOM_INTENSITY;
// Opacity fades in
const origOp = birth.mesh.userData._birthOrigOpacity || 0.9;
birth.mesh.material.opacity = origOp * smoothstep(0, 0.3, t);
}
// Gentle upward float during birth (crystals are placed 1.5 above ground)
birth.mesh.position.y = birth.originPos.y + (1 - scale) * 0.5;
}
// ── Process neighbor pulses ──
for (let i = _activePulses.length - 1; i >= 0; i--) {
const pulse = _activePulses[i];
const elapsed = now - pulse.startTime;
const t = Math.min(1, elapsed / pulse.duration);
if (t >= 1) {
// Restore original
if (pulse.mesh.material) {
pulse.mesh.material.emissiveIntensity = pulse.origEmissive;
}
_activePulses.splice(i, 1);
continue;
}
// Pulse curve: quick rise, slow decay
const pulseVal = Math.sin(t * Math.PI) * pulse.intensity;
if (pulse.mesh.material) {
pulse.mesh.material.emissiveIntensity = pulse.origEmissive + pulseVal;
}
}
// ── Process line growths ──
for (let i = _activeLineGrowths.length - 1; i >= 0; i--) {
const lg = _activeLineGrowths[i];
const elapsed = now - lg.startTime;
const t = Math.min(1, elapsed / lg.duration);
if (t >= 1) {
// Ensure full visibility
if (lg.line.material) {
lg.line.material.opacity = lg.line.material.userData?._origOpacity || 0.6;
}
_activeLineGrowths.splice(i, 1);
continue;
}
// Fade in the line
if (lg.line.material) {
const origOp = lg.line.material.userData?._origOpacity || 0.6;
lg.line.material.opacity = origOp * smoothstep(0, 1, t);
}
}
}
// ─── BIRTH COUNT (for UI/status) ─────────────────
function getActiveBirthCount() {
return _activeBirths.length;
}
// ─── WRAP SPATIAL MEMORY ──────────────────────────
/**
* Wraps SpatialMemory.placeMemory() so every new crystal
* automatically gets a birth animation.
* Returns a proxy object that intercepts placeMemory calls.
*/
function wrapSpatialMemory(spatialMemory) {
const original = spatialMemory.placeMemory.bind(spatialMemory);
spatialMemory.placeMemory = function(mem) {
const crystal = original(mem);
if (crystal) {
// Small delay to let THREE.js settle the object
requestAnimationFrame(() => triggerBirth(crystal, spatialMemory));
}
return crystal;
};
console.info('[MemoryBirth] SpatialMemory.placeMemory wrapped — births will animate');
return spatialMemory;
}
return {
init,
triggerBirth,
update,
getActiveBirthCount,
wrapSpatialMemory
};
})();
export { MemoryBirth };

View File

@@ -0,0 +1,180 @@
// ═══════════════════════════════════════════════════════════
// MNEMOSYNE — Memory Inspect Panel (issue #1227)
// ═══════════════════════════════════════════════════════════
//
// Side-panel detail view for memory crystals.
// Opens when a crystal is clicked; auto-closes on empty-space click.
//
// Usage from app.js:
// MemoryInspect.init({ onNavigate: fn });
// MemoryInspect.show(memData, regionDef);
// MemoryInspect.hide();
// MemoryInspect.isOpen();
// ═══════════════════════════════════════════════════════════
const MemoryInspect = (() => {
let _panel = null;
let _onNavigate = null; // callback(memId) — navigate to a linked memory
// ─── INIT ────────────────────────────────────────────────
function init(opts = {}) {
_onNavigate = opts.onNavigate || null;
_panel = document.getElementById('memory-inspect-panel');
if (!_panel) {
console.warn('[MemoryInspect] Panel element #memory-inspect-panel not found in DOM');
}
}
// ─── SHOW ────────────────────────────────────────────────
function show(data, regionDef) {
if (!_panel) return;
const region = regionDef || {};
const colorHex = region.color
? '#' + region.color.toString(16).padStart(6, '0')
: '#4af0c0';
const strength = data.strength != null ? data.strength : 0.7;
const vitality = Math.round(Math.max(0, Math.min(1, strength)) * 100);
let vitalityColor = '#4af0c0';
if (vitality < 30) vitalityColor = '#ff4466';
else if (vitality < 60) vitalityColor = '#ffaa22';
const ts = data.timestamp ? new Date(data.timestamp) : null;
const created = ts && !isNaN(ts) ? ts.toLocaleString() : '—';
// Linked memories
let linksHtml = '';
if (data.connections && data.connections.length > 0) {
linksHtml = data.connections
.map(id => `<button class="mi-link-btn" data-memid="${_esc(id)}">${_esc(id)}</button>`)
.join('');
} else {
linksHtml = '<span class="mi-empty">No linked memories</span>';
}
_panel.innerHTML = `
<div class="mi-header" style="border-left:3px solid ${colorHex}">
<span class="mi-region-glyph">${region.glyph || '\u25C8'}</span>
<div class="mi-header-text">
<div class="mi-id" title="${_esc(data.id || '')}">${_esc(_truncate(data.id || '\u2014', 28))}</div>
<div class="mi-region" style="color:${colorHex}">${_esc(region.label || data.category || '\u2014')}</div>
</div>
<button class="mi-close" id="mi-close-btn" aria-label="Close inspect panel">\u2715</button>
</div>
<div class="mi-body">
<div class="mi-section">
<div class="mi-section-label">CONTENT</div>
<div class="mi-content">${_esc(data.content || '(empty)')}</div>
</div>
<div class="mi-section">
<div class="mi-section-label">VITALITY</div>
<div class="mi-vitality-row">
<div class="mi-vitality-bar-track">
<div class="mi-vitality-bar" style="width:${vitality}%;background:${vitalityColor}"></div>
</div>
<span class="mi-vitality-pct" style="color:${vitalityColor}">${vitality}%</span>
</div>
</div>
<div class="mi-section">
<div class="mi-section-label">LINKED MEMORIES</div>
<div class="mi-links" id="mi-links">${linksHtml}</div>
</div>
<div class="mi-section">
<div class="mi-section-label">META</div>
<div class="mi-meta-row">
<span class="mi-meta-key">Source</span>
<span class="mi-meta-val">${_esc(data.source || '\u2014')}</span>
</div>
<div class="mi-meta-row">
<span class="mi-meta-key">Created</span>
<span class="mi-meta-val">${created}</span>
</div>
</div>
<div class="mi-actions">
<button class="mi-action-btn" id="mi-copy-btn">\u2398 Copy</button>
</div>
</div>
`;
// Wire close button
const closeBtn = _panel.querySelector('#mi-close-btn');
if (closeBtn) closeBtn.addEventListener('click', hide);
// Wire copy button
const copyBtn = _panel.querySelector('#mi-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const text = data.content || '';
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
copyBtn.textContent = '\u2713 Copied';
setTimeout(() => { copyBtn.textContent = '\u2398 Copy'; }, 1500);
}).catch(() => _fallbackCopy(text));
} else {
_fallbackCopy(text);
}
});
}
// Wire link navigation
const linksContainer = _panel.querySelector('#mi-links');
if (linksContainer) {
linksContainer.addEventListener('click', (e) => {
const btn = e.target.closest('.mi-link-btn');
if (btn && _onNavigate) _onNavigate(btn.dataset.memid);
});
}
_panel.style.display = 'flex';
// Trigger CSS animation
requestAnimationFrame(() => _panel.classList.add('mi-visible'));
}
// ─── HIDE ─────────────────────────────────────────────────
function hide() {
if (!_panel) return;
_panel.classList.remove('mi-visible');
// Wait for CSS transition before hiding
const onEnd = () => {
_panel.style.display = 'none';
_panel.removeEventListener('transitionend', onEnd);
};
_panel.addEventListener('transitionend', onEnd);
// Safety fallback if transition doesn't fire
setTimeout(() => { if (_panel) _panel.style.display = 'none'; }, 350);
}
// ─── QUERY ────────────────────────────────────────────────
function isOpen() {
return _panel != null && _panel.style.display !== 'none';
}
// ─── HELPERS ──────────────────────────────────────────────
function _esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _truncate(str, n) {
return str.length > n ? str.slice(0, n - 1) + '\u2026' : str;
}
function _fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
return { init, show, hide, isOpen };
})();
export { MemoryInspect };

View File

@@ -0,0 +1,99 @@
// ═══════════════════════════════════════════
// 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;
}
}
}
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 };

View File

@@ -1,4 +1,41 @@
// ═══════════════════════════════════════════
// ═══
// ─── REGION VISIBILITY (Memory Filter) ──────────────
let _regionVisibility = {}; // category -> boolean (undefined = visible)
setRegionVisibility(category, visible) {
_regionVisibility[category] = visible;
for (const obj of Object.values(_memoryObjects)) {
if (obj.data.category === category && obj.mesh) {
obj.mesh.visible = visible !== false;
}
}
},
setAllRegionsVisible(visible) {
const cats = Object.keys(REGIONS);
for (const cat of cats) {
_regionVisibility[cat] = visible;
for (const obj of Object.values(_memoryObjects)) {
if (obj.data.category === cat && obj.mesh) {
obj.mesh.visible = visible;
}
}
}
},
getMemoryCountByRegion() {
const counts = {};
for (const obj of Object.values(_memoryObjects)) {
const cat = obj.data.category || 'working';
counts[cat] = (counts[cat] || 0) + 1;
}
return counts;
},
isRegionVisible(category) {
return _regionVisibility[category] !== false;
},
// PROJECT MNEMOSYNE — SPATIAL MEMORY SCHEMA
// ═══════════════════════════════════════════
//
@@ -32,9 +69,6 @@
const SpatialMemory = (() => {
// ─── CALLBACKS ────────────────────────────────────────
let _onMemoryPlacedCallback = null;
// ─── REGION DEFINITIONS ───────────────────────────────
const REGIONS = {
engineering: {
@@ -136,6 +170,9 @@ const SpatialMemory = (() => {
let _regionMarkers = {};
let _memoryObjects = {};
let _connectionLines = [];
let _entityLines = []; // entity resolution lines (issue #1167)
let _camera = null; // set by setCamera() for LOD culling
const ENTITY_LOD_DIST = 50; // hide entity lines when camera > this from midpoint
let _initialized = false;
// ─── CRYSTAL GEOMETRY (persistent memories) ───────────
@@ -143,47 +180,6 @@ const SpatialMemory = (() => {
return new THREE.OctahedronGeometry(size, 0);
}
// ─── TRUST-BASED VISUALS ─────────────────────────────
// Wire crystal visual properties to fact trust score (0.0-1.0).
// Issue #1166: Trust > 0.8 = bright glow/full opacity,
// 0.5-0.8 = medium/80%, < 0.5 = dim/40%, < 0.3 = near-invisible pulsing red.
function _getTrustVisuals(trust, regionColor) {
const t = Math.max(0, Math.min(1, trust));
if (t >= 0.8) {
return {
opacity: 1.0,
emissiveIntensity: 2.0 * t,
emissiveColor: regionColor,
lightIntensity: 1.2,
glowDesc: 'high'
};
} else if (t >= 0.5) {
return {
opacity: 0.8,
emissiveIntensity: 1.2 * t,
emissiveColor: regionColor,
lightIntensity: 0.6,
glowDesc: 'medium'
};
} else if (t >= 0.3) {
return {
opacity: 0.4,
emissiveIntensity: 0.5 * t,
emissiveColor: regionColor,
lightIntensity: 0.2,
glowDesc: 'dim'
};
} else {
return {
opacity: 0.15,
emissiveIntensity: 0.3,
emissiveColor: 0xff2200,
lightIntensity: 0.1,
glowDesc: 'untrusted'
};
}
}
// ─── REGION MARKER ───────────────────────────────────
function createRegionMarker(regionKey, region) {
const cx = region.center[0];
@@ -250,7 +246,83 @@ const SpatialMemory = (() => {
sprite.scale.set(4, 1, 1);
_scene.add(sprite);
return { ring, disc, glowDisc, sprite };
// ─── BULK IMPORT (WebSocket sync) ───────────────────
/**
* Import an array of memories in batch — for WebSocket sync.
* Skips duplicates (same id). Returns count of newly placed.
* @param {Array} memories - Array of memory objects { id, content, category, ... }
* @returns {number} Count of newly placed memories
*/
function importMemories(memories) {
if (!Array.isArray(memories) || memories.length === 0) return 0;
let count = 0;
memories.forEach(mem => {
if (mem.id && !_memoryObjects[mem.id]) {
placeMemory(mem);
count++;
}
});
if (count > 0) {
_dirty = true;
saveToStorage();
console.info('[Mnemosyne] Bulk imported', count, 'new memories (total:', Object.keys(_memoryObjects).length, ')');
}
return count;
}
// ─── UPDATE MEMORY ──────────────────────────────────
/**
* Update an existing memory's visual properties (strength, connections).
* Does not move the crystal — only updates metadata and re-renders.
* @param {string} memId - Memory ID to update
* @param {object} updates - Fields to update: { strength, connections, content }
* @returns {boolean} True if updated
*/
function updateMemory(memId, updates) {
const obj = _memoryObjects[memId];
if (!obj) return false;
if (updates.strength != null) {
const strength = Math.max(0.05, Math.min(1, updates.strength));
obj.mesh.userData.strength = strength;
obj.mesh.material.emissiveIntensity = 1.5 * strength;
obj.mesh.material.opacity = 0.5 + strength * 0.4;
}
if (updates.content != null) {
obj.data.content = updates.content;
}
if (updates.connections != null) {
obj.data.connections = updates.connections;
// Rebuild connection lines
_rebuildConnections(memId);
}
_dirty = true;
saveToStorage();
return true;
}
function _rebuildConnections(memId) {
// Remove existing lines for this memory
for (let i = _connectionLines.length - 1; i >= 0; i--) {
const line = _connectionLines[i];
if (line.userData.from === memId || line.userData.to === memId) {
if (line.parent) line.parent.remove(line);
line.geometry.dispose();
line.material.dispose();
_connectionLines.splice(i, 1);
}
}
// Recreate lines for current connections
const obj = _memoryObjects[memId];
if (!obj || !obj.data.connections) return;
obj.data.connections.forEach(targetId => {
const target = _memoryObjects[targetId];
if (target) _createConnectionLine(obj, target);
});
}
return { ring, disc, glowDisc, sprite };
}
// ─── PLACE A MEMORY ──────────────────────────────────
@@ -260,20 +332,17 @@ const SpatialMemory = (() => {
const region = REGIONS[mem.category] || REGIONS.working;
const pos = mem.position || _assignPosition(mem.category, mem.id);
const strength = Math.max(0.05, Math.min(1, mem.strength != null ? mem.strength : 0.7));
const trust = mem.trust != null ? Math.max(0, Math.min(1, mem.trust)) : 0.7;
const size = 0.2 + strength * 0.3;
const tv = _getTrustVisuals(trust, region.color);
const geo = createCrystalGeometry(size);
const mat = new THREE.MeshStandardMaterial({
color: region.color,
emissive: tv.emissiveColor,
emissiveIntensity: tv.emissiveIntensity,
emissive: region.color,
emissiveIntensity: 1.5 * strength,
metalness: 0.6,
roughness: 0.15,
transparent: true,
opacity: tv.opacity
opacity: 0.5 + strength * 0.4
});
const crystal = new THREE.Mesh(geo, mat);
@@ -286,12 +355,10 @@ const SpatialMemory = (() => {
region: mem.category,
pulse: Math.random() * Math.PI * 2,
strength: strength,
trust: trust,
glowDesc: tv.glowDesc,
createdAt: mem.timestamp || new Date().toISOString()
};
const light = new THREE.PointLight(tv.emissiveColor, tv.lightIntensity, 5);
const light = new THREE.PointLight(region.color, 0.8 * strength, 5);
crystal.add(light);
_scene.add(crystal);
@@ -301,15 +368,13 @@ const SpatialMemory = (() => {
_drawConnections(mem.id, mem.connections);
}
if (mem.entity) {
_drawEntityLines(mem.id, mem);
}
_dirty = true;
saveToStorage();
console.info('[Mnemosyne] Spatial memory placed:', mem.id, 'in', region.label);
// Fire particle burst callback
if (_onMemoryPlacedCallback) {
_onMemoryPlacedCallback(crystal.position.clone(), mem.category || 'working');
}
return crystal;
}
@@ -353,6 +418,77 @@ const SpatialMemory = (() => {
});
}
// ─── ENTITY RESOLUTION LINES (#1167) ──────────────────
// Draw lines between crystals that share an entity or are related entities.
// Same entity → thin blue line. Related entities → thin purple dashed line.
function _drawEntityLines(memId, mem) {
if (!mem.entity) return;
const src = _memoryObjects[memId];
if (!src) return;
Object.entries(_memoryObjects).forEach(([otherId, other]) => {
if (otherId === memId) return;
const otherData = other.data;
if (!otherData.entity) return;
let lineType = null;
if (otherData.entity === mem.entity) {
lineType = 'same_entity';
} else if (mem.related_entities && mem.related_entities.includes(otherData.entity)) {
lineType = 'related';
} else if (otherData.related_entities && otherData.related_entities.includes(mem.entity)) {
lineType = 'related';
}
if (!lineType) return;
// Deduplicate — only draw from lower ID to higher
if (memId > otherId) return;
const points = [src.mesh.position.clone(), other.mesh.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
let mat;
if (lineType === 'same_entity') {
mat = new THREE.LineBasicMaterial({ color: 0x4488ff, transparent: true, opacity: 0.35 });
} else {
mat = new THREE.LineDashedMaterial({ color: 0x9966ff, dashSize: 0.3, gapSize: 0.2, transparent: true, opacity: 0.25 });
const line = new THREE.Line(geo, mat);
line.computeLineDistances();
line.userData = { type: 'entity_line', from: memId, to: otherId, lineType };
_scene.add(line);
_entityLines.push(line);
return;
}
const line = new THREE.Line(geo, mat);
line.userData = { type: 'entity_line', from: memId, to: otherId, lineType };
_scene.add(line);
_entityLines.push(line);
});
}
function _updateEntityLines() {
if (!_camera) return;
const camPos = _camera.position;
_entityLines.forEach(line => {
// Compute midpoint of line
const posArr = line.geometry.attributes.position.array;
const mx = (posArr[0] + posArr[3]) / 2;
const my = (posArr[1] + posArr[4]) / 2;
const mz = (posArr[2] + posArr[5]) / 2;
const dist = camPos.distanceTo(new THREE.Vector3(mx, my, mz));
if (dist > ENTITY_LOD_DIST) {
line.visible = false;
} else {
line.visible = true;
// Fade based on distance
const fade = Math.max(0, 1 - (dist / ENTITY_LOD_DIST));
const baseOpacity = line.userData.lineType === 'same_entity' ? 0.35 : 0.25;
line.material.opacity = baseOpacity * fade;
}
});
}
// ─── REMOVE A MEMORY ─────────────────────────────────
function removeMemory(memId) {
const obj = _memoryObjects[memId];
@@ -372,6 +508,16 @@ const SpatialMemory = (() => {
}
}
for (let i = _entityLines.length - 1; i >= 0; i--) {
const line = _entityLines[i];
if (line.userData.from === memId || line.userData.to === memId) {
if (line.parent) line.parent.remove(line);
line.geometry.dispose();
line.material.dispose();
_entityLines.splice(i, 1);
}
}
delete _memoryObjects[memId];
_dirty = true;
saveToStorage();
@@ -392,19 +538,13 @@ const SpatialMemory = (() => {
mesh.scale.setScalar(pulse);
if (mesh.material) {
const trust = mesh.userData.trust != null ? mesh.userData.trust : 0.7;
const base = mesh.userData.strength || 0.7;
if (trust < 0.3) {
// Low trust: pulsing red — visible warning
const pulseAlpha = 0.15 + Math.sin(mesh.userData.pulse * 2.0) * 0.15;
mesh.material.emissiveIntensity = 0.3 + Math.sin(mesh.userData.pulse * 2.0) * 0.3;
mesh.material.opacity = pulseAlpha;
} else {
mesh.material.emissiveIntensity = 1.0 + Math.sin(mesh.userData.pulse * 0.7) * 0.5 * base;
}
mesh.material.emissiveIntensity = 1.0 + Math.sin(mesh.userData.pulse * 0.7) * 0.5 * base;
}
});
_updateEntityLines();
Object.values(_regionMarkers).forEach(marker => {
if (marker.ring && marker.ring.material) {
marker.ring.material.opacity = 0.1 + Math.sin(now * 0.001) * 0.05;
@@ -431,42 +571,6 @@ const SpatialMemory = (() => {
return REGIONS;
}
// ─── UPDATE VISUAL PROPERTIES ────────────────────────
// Re-render crystal when trust/strength change (no position move).
function updateMemoryVisual(memId, updates) {
const obj = _memoryObjects[memId];
if (!obj) return false;
const mesh = obj.mesh;
const region = REGIONS[obj.region] || REGIONS.working;
if (updates.trust != null) {
const trust = Math.max(0, Math.min(1, updates.trust));
mesh.userData.trust = trust;
obj.data.trust = trust;
const tv = _getTrustVisuals(trust, region.color);
mesh.material.emissive = new THREE.Color(tv.emissiveColor);
mesh.material.emissiveIntensity = tv.emissiveIntensity;
mesh.material.opacity = tv.opacity;
mesh.userData.glowDesc = tv.glowDesc;
if (mesh.children.length > 0 && mesh.children[0].isPointLight) {
mesh.children[0].intensity = tv.lightIntensity;
mesh.children[0].color = new THREE.Color(tv.emissiveColor);
}
}
if (updates.strength != null) {
const strength = Math.max(0.05, Math.min(1, updates.strength));
mesh.userData.strength = strength;
obj.data.strength = strength;
}
_dirty = true;
saveToStorage();
console.info('[Mnemosyne] Visual updated:', memId, 'trust:', mesh.userData.trust, 'glow:', mesh.userData.glowDesc);
return true;
}
// ─── QUERY ───────────────────────────────────────────
function getMemoryAtPosition(position, maxDist) {
maxDist = maxDist || 2;
@@ -606,7 +710,6 @@ const SpatialMemory = (() => {
source: o.data.source || 'unknown',
timestamp: o.data.timestamp || o.mesh.userData.createdAt,
strength: o.mesh.userData.strength || 0.7,
trust: o.mesh.userData.trust != null ? o.mesh.userData.trust : 0.7,
connections: o.data.connections || []
}))
};
@@ -752,173 +855,18 @@ const SpatialMemory = (() => {
return _selectedId;
}
// ─── FILE EXPORT ──────────────────────────────────────
function exportToFile() {
const index = exportIndex();
const json = JSON.stringify(index, null, 2);
const date = new Date().toISOString().slice(0, 10);
const filename = 'mnemosyne-export-' + date + '.json';
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.info('[Mnemosyne] Exported', index.memories.length, 'memories to', filename);
return { filename, count: index.memories.length };
}
// ─── FILE IMPORT ──────────────────────────────────────
function importFromFile(file) {
return new Promise((resolve, reject) => {
if (!file) {
reject(new Error('No file provided'));
return;
}
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
// Schema validation
if (!data || typeof data !== 'object') {
reject(new Error('Invalid JSON: not an object'));
return;
}
if (typeof data.version !== 'number') {
reject(new Error('Invalid schema: missing version field'));
return;
}
if (data.version !== STORAGE_VERSION) {
reject(new Error('Version mismatch: got ' + data.version + ', expected ' + STORAGE_VERSION));
return;
}
if (!Array.isArray(data.memories)) {
reject(new Error('Invalid schema: memories is not an array'));
return;
}
// Validate each memory entry
for (let i = 0; i < data.memories.length; i++) {
const mem = data.memories[i];
if (!mem.id || typeof mem.id !== 'string') {
reject(new Error('Invalid memory at index ' + i + ': missing or invalid id'));
return;
}
if (!mem.category || typeof mem.category !== 'string') {
reject(new Error('Invalid memory "' + mem.id + '": missing category'));
return;
}
}
const count = importIndex(data);
saveToStorage();
console.info('[Mnemosyne] Imported', count, 'memories from file');
resolve({ count, total: data.memories.length });
} catch (parseErr) {
reject(new Error('Failed to parse JSON: ' + parseErr.message));
}
};
reader.onerror = function() {
reject(new Error('Failed to read file'));
};
reader.readAsText(file);
});
}
// ─── SPATIAL SEARCH (issue #1170) ────────────────────
let _searchOriginalState = {}; // memId -> { emissiveIntensity, opacity } for restore
function searchContent(query) {
if (!query || !query.trim()) return [];
const q = query.toLowerCase().trim();
const matches = [];
Object.values(_memoryObjects).forEach(obj => {
const d = obj.data;
const searchable = [
d.content || '',
d.id || '',
d.category || '',
d.source || '',
...(d.connections || [])
].join(' ').toLowerCase();
if (searchable.includes(q)) {
matches.push(d.id);
}
});
return matches;
}
function highlightSearchResults(matchIds) {
// Save original state and apply search highlighting
_searchOriginalState = {};
const matchSet = new Set(matchIds);
Object.entries(_memoryObjects).forEach(([id, obj]) => {
const mat = obj.mesh.material;
_searchOriginalState[id] = {
emissiveIntensity: mat.emissiveIntensity,
opacity: mat.opacity
};
if (matchSet.has(id)) {
// Match: bright white glow
mat.emissive.setHex(0xffffff);
mat.emissiveIntensity = 5.0;
mat.opacity = 1.0;
} else {
// Non-match: dim to 10% opacity
mat.opacity = 0.1;
mat.emissiveIntensity = 0.2;
}
});
}
function clearSearch() {
Object.entries(_memoryObjects).forEach(([id, obj]) => {
const mat = obj.mesh.material;
const saved = _searchOriginalState[id];
if (saved) {
// Restore original emissive color from region
const region = REGIONS[obj.region] || REGIONS.working;
mat.emissive.copy(region.color);
mat.emissiveIntensity = saved.emissiveIntensity;
mat.opacity = saved.opacity;
}
});
_searchOriginalState = {};
}
function getSearchMatchPosition(matchId) {
const obj = _memoryObjects[matchId];
return obj ? obj.mesh.position.clone() : null;
}
function setOnMemoryPlaced(callback) {
_onMemoryPlacedCallback = callback;
// ─── CAMERA REFERENCE (for entity line LOD) ─────────
function setCamera(camera) {
_camera = camera;
}
return {
init, placeMemory, removeMemory, update, updateMemoryVisual,
init, placeMemory, removeMemory, update, importMemories, updateMemory,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId,
exportIndex, importIndex, exportToFile, importFromFile, searchNearby, REGIONS,
exportIndex, importIndex, searchNearby, REGIONS,
saveToStorage, loadFromStorage, clearStorage,
runGravityLayout,
searchContent, highlightSearchResults, clearSearch, getSearchMatchPosition,
setOnMemoryPlaced
runGravityLayout, setCamera
};
})();

View File

@@ -0,0 +1,24 @@
"""nexus.mnemosyne — The Living Holographic Archive.
Phase 1: Foundation — core archive, entry model, holographic linker,
ingestion pipeline, and CLI.
Builds on MemPalace vector memory to create interconnected meaning:
entries auto-reference related entries via semantic similarity,
forming a living archive that surfaces relevant context autonomously.
"""
from __future__ import annotations
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.entry import ArchiveEntry
from nexus.mnemosyne.linker import HolographicLinker
from nexus.mnemosyne.ingest import ingest_from_mempalace, ingest_event
__all__ = [
"MnemosyneArchive",
"ArchiveEntry",
"HolographicLinker",
"ingest_from_mempalace",
"ingest_event",
]

487
nexus/mnemosyne/archive.py Normal file
View File

@@ -0,0 +1,487 @@
"""MnemosyneArchive — core archive class.
The living holographic archive. Stores entries, maintains links,
and provides query interfaces for retrieving connected knowledge.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
from nexus.mnemosyne.entry import ArchiveEntry
from nexus.mnemosyne.linker import HolographicLinker
_EXPORT_VERSION = "1"
class MnemosyneArchive:
"""The holographic archive — stores and links entries.
Phase 1 uses JSON file storage. Phase 2 will integrate with
MemPalace (ChromaDB) for vector-semantic search.
"""
def __init__(self, archive_path: Optional[Path] = None):
self.path = archive_path or Path.home() / ".hermes" / "mnemosyne" / "archive.json"
self.path.parent.mkdir(parents=True, exist_ok=True)
self.linker = HolographicLinker()
self._entries: dict[str, ArchiveEntry] = {}
self._load()
def _load(self):
if self.path.exists():
try:
with open(self.path) as f:
data = json.load(f)
for entry_data in data.get("entries", []):
entry = ArchiveEntry.from_dict(entry_data)
self._entries[entry.id] = entry
except (json.JSONDecodeError, KeyError):
pass # Start fresh on corrupt data
def _save(self):
data = {
"entries": [e.to_dict() for e in self._entries.values()],
"count": len(self._entries),
}
with open(self.path, "w") as f:
json.dump(data, f, indent=2)
def add(self, entry: ArchiveEntry, auto_link: bool = True) -> ArchiveEntry:
"""Add an entry to the archive. Auto-links to related entries."""
self._entries[entry.id] = entry
if auto_link:
self.linker.apply_links(entry, list(self._entries.values()))
self._save()
return entry
def get(self, entry_id: str) -> Optional[ArchiveEntry]:
return self._entries.get(entry_id)
def search(self, query: str, limit: int = 10) -> list[ArchiveEntry]:
"""Simple keyword search across titles and content."""
query_tokens = set(query.lower().split())
scored = []
for entry in self._entries.values():
text = f"{entry.title} {entry.content} {' '.join(entry.topics)}".lower()
hits = sum(1 for t in query_tokens if t in text)
if hits > 0:
scored.append((hits, entry))
scored.sort(key=lambda x: x[0], reverse=True)
return [e for _, e in scored[:limit]]
def semantic_search(self, query: str, limit: int = 10, threshold: float = 0.05) -> list[ArchiveEntry]:
"""Semantic search using holographic linker similarity.
Scores each entry by Jaccard similarity between query tokens and entry
tokens, then boosts entries with more inbound links (more "holographic").
Falls back to keyword search if no entries meet the similarity threshold.
Args:
query: Natural language query string.
limit: Maximum number of results to return.
threshold: Minimum Jaccard similarity to be considered a semantic match.
Returns:
List of ArchiveEntry sorted by combined relevance score, descending.
"""
query_tokens = HolographicLinker._tokenize(query)
if not query_tokens:
return []
# Count inbound links for each entry (how many entries link TO this one)
inbound: dict[str, int] = {eid: 0 for eid in self._entries}
for entry in self._entries.values():
for linked_id in entry.links:
if linked_id in inbound:
inbound[linked_id] += 1
max_inbound = max(inbound.values(), default=1) or 1
scored = []
for entry in self._entries.values():
entry_tokens = HolographicLinker._tokenize(f"{entry.title} {entry.content} {' '.join(entry.topics)}")
if not entry_tokens:
continue
intersection = query_tokens & entry_tokens
union = query_tokens | entry_tokens
jaccard = len(intersection) / len(union)
if jaccard >= threshold:
link_boost = inbound[entry.id] / max_inbound * 0.2 # up to 20% boost
scored.append((jaccard + link_boost, entry))
if scored:
scored.sort(key=lambda x: x[0], reverse=True)
return [e for _, e in scored[:limit]]
# Graceful fallback to keyword search
return self.search(query, limit=limit)
def get_linked(self, entry_id: str, depth: int = 1) -> list[ArchiveEntry]:
"""Get entries linked to a given entry, up to specified depth."""
visited = set()
frontier = {entry_id}
result = []
for _ in range(depth):
next_frontier = set()
for eid in frontier:
if eid in visited:
continue
visited.add(eid)
entry = self._entries.get(eid)
if entry:
for linked_id in entry.links:
if linked_id not in visited:
linked = self._entries.get(linked_id)
if linked:
result.append(linked)
next_frontier.add(linked_id)
frontier = next_frontier
return result
def by_topic(self, topic: str) -> list[ArchiveEntry]:
"""Get all entries tagged with a topic."""
topic_lower = topic.lower()
return [e for e in self._entries.values() if topic_lower in [t.lower() for t in e.topics]]
def remove(self, entry_id: str) -> bool:
"""Remove an entry and clean up all bidirectional links.
Returns True if the entry existed and was removed, False otherwise.
"""
if entry_id not in self._entries:
return False
# Remove back-links from all other entries
for other in self._entries.values():
if entry_id in other.links:
other.links.remove(entry_id)
del self._entries[entry_id]
self._save()
return True
def export(
self,
query: Optional[str] = None,
topics: Optional[list[str]] = None,
) -> dict:
"""Export a filtered subset of the archive.
Args:
query: keyword filter applied to title + content (case-insensitive)
topics: list of topic tags; entries must match at least one
Returns a JSON-serialisable dict with an ``entries`` list and metadata.
"""
candidates = list(self._entries.values())
if topics:
lower_topics = {t.lower() for t in topics}
candidates = [
e for e in candidates
if any(t.lower() in lower_topics for t in e.topics)
]
if query:
query_tokens = set(query.lower().split())
candidates = [
e for e in candidates
if any(
token in f"{e.title} {e.content} {' '.join(e.topics)}".lower()
for token in query_tokens
)
]
return {
"version": _EXPORT_VERSION,
"filters": {"query": query, "topics": topics},
"count": len(candidates),
"entries": [e.to_dict() for e in candidates],
}
def topic_counts(self) -> dict[str, int]:
"""Return a dict mapping topic name → entry count, sorted by count desc."""
counts: dict[str, int] = {}
for entry in self._entries.values():
for topic in entry.topics:
counts[topic] = counts.get(topic, 0) + 1
return dict(sorted(counts.items(), key=lambda x: x[1], reverse=True))
@property
def count(self) -> int:
return len(self._entries)
def stats(self) -> dict:
entries = list(self._entries.values())
total_links = sum(len(e.links) for e in entries)
topics: set[str] = set()
for e in entries:
topics.update(e.topics)
# Orphans: entries with no links at all
orphans = sum(1 for e in entries if len(e.links) == 0)
# Link density: average links per entry (0 when empty)
n = len(entries)
link_density = round(total_links / n, 4) if n else 0.0
# Age distribution
timestamps = sorted(e.created_at for e in entries)
oldest_entry = timestamps[0] if timestamps else None
newest_entry = timestamps[-1] if timestamps else None
return {
"entries": n,
"total_links": total_links,
"unique_topics": len(topics),
"topics": sorted(topics),
"orphans": orphans,
"link_density": link_density,
"oldest_entry": oldest_entry,
"newest_entry": newest_entry,
}
def _build_adjacency(self) -> dict[str, set[str]]:
"""Build adjacency dict from entry links. Only includes valid references."""
adj: dict[str, set[str]] = {eid: set() for eid in self._entries}
for eid, entry in self._entries.items():
for linked_id in entry.links:
if linked_id in self._entries and linked_id != eid:
adj[eid].add(linked_id)
adj[linked_id].add(eid)
return adj
def graph_clusters(self, min_size: int = 1) -> list[dict]:
"""Find connected component clusters in the holographic graph.
Uses BFS to discover groups of entries that are reachable from each
other through their links. Returns clusters sorted by size descending.
Args:
min_size: Minimum cluster size to include (filters out isolated entries).
Returns:
List of dicts with keys: cluster_id, size, entries, topics, density
"""
adj = self._build_adjacency()
visited: set[str] = set()
clusters: list[dict] = []
cluster_id = 0
for eid in self._entries:
if eid in visited:
continue
# BFS from this entry
component: list[str] = []
queue = [eid]
while queue:
current = queue.pop(0)
if current in visited:
continue
visited.add(current)
component.append(current)
for neighbor in adj.get(current, set()):
if neighbor not in visited:
queue.append(neighbor)
# Single-entry clusters are orphans
if len(component) < min_size:
continue
# Collect topics from cluster entries
cluster_topics: dict[str, int] = {}
internal_edges = 0
for cid in component:
entry = self._entries[cid]
for t in entry.topics:
cluster_topics[t] = cluster_topics.get(t, 0) + 1
internal_edges += len(adj.get(cid, set()))
internal_edges //= 2 # undirected, counted twice
# Density: actual edges / possible edges
n = len(component)
max_edges = n * (n - 1) // 2
density = round(internal_edges / max_edges, 4) if max_edges > 0 else 0.0
# Top topics by frequency
top_topics = sorted(cluster_topics.items(), key=lambda x: x[1], reverse=True)[:5]
clusters.append({
"cluster_id": cluster_id,
"size": n,
"entries": component,
"top_topics": [t for t, _ in top_topics],
"internal_edges": internal_edges,
"density": density,
})
cluster_id += 1
clusters.sort(key=lambda c: c["size"], reverse=True)
return clusters
def hub_entries(self, limit: int = 10) -> list[dict]:
"""Find the most connected entries (highest degree centrality).
These are the "hubs" of the holographic graph — entries that bridge
many topics and attract many links.
Args:
limit: Maximum number of hubs to return.
Returns:
List of dicts with keys: entry, degree, inbound, outbound, topics
"""
adj = self._build_adjacency()
inbound: dict[str, int] = {eid: 0 for eid in self._entries}
for entry in self._entries.values():
for lid in entry.links:
if lid in inbound:
inbound[lid] += 1
hubs = []
for eid, entry in self._entries.items():
degree = len(adj.get(eid, set()))
if degree == 0:
continue
hubs.append({
"entry": entry,
"degree": degree,
"inbound": inbound.get(eid, 0),
"outbound": len(entry.links),
"topics": entry.topics,
})
hubs.sort(key=lambda h: h["degree"], reverse=True)
return hubs[:limit]
def bridge_entries(self) -> list[dict]:
"""Find articulation points — entries whose removal would split a cluster.
These are "bridge" entries in the holographic graph. Removing them
disconnects members that were previously reachable through the bridge.
Uses Tarjan's algorithm for finding articulation points.
Returns:
List of dicts with keys: entry, cluster_size, bridges_between
"""
adj = self._build_adjacency()
# Find clusters first
clusters = self.graph_clusters(min_size=3)
if not clusters:
return []
# For each cluster, run Tarjan's algorithm
bridges: list[dict] = []
for cluster in clusters:
members = set(cluster["entries"])
if len(members) < 3:
continue
# Build subgraph adjacency
sub_adj = {eid: adj[eid] & members for eid in members}
# Tarjan's DFS for articulation points
discovery: dict[str, int] = {}
low: dict[str, int] = {}
parent: dict[str, Optional[str]] = {}
ap: set[str] = set()
timer = [0]
def dfs(u: str):
children = 0
discovery[u] = low[u] = timer[0]
timer[0] += 1
for v in sub_adj[u]:
if v not in discovery:
children += 1
parent[v] = u
dfs(v)
low[u] = min(low[u], low[v])
# u is AP if: root with 2+ children, or non-root with low[v] >= disc[u]
if parent.get(u) is None and children > 1:
ap.add(u)
if parent.get(u) is not None and low[v] >= discovery[u]:
ap.add(u)
elif v != parent.get(u):
low[u] = min(low[u], discovery[v])
for eid in members:
if eid not in discovery:
parent[eid] = None
dfs(eid)
# For each articulation point, estimate what it bridges
for ap_id in ap:
ap_entry = self._entries[ap_id]
# Remove it temporarily and count resulting components
temp_adj = {k: v.copy() for k, v in sub_adj.items()}
del temp_adj[ap_id]
for k in temp_adj:
temp_adj[k].discard(ap_id)
# BFS count components after removal
temp_visited: set[str] = set()
component_count = 0
for mid in members:
if mid == ap_id or mid in temp_visited:
continue
component_count += 1
queue = [mid]
while queue:
cur = queue.pop(0)
if cur in temp_visited:
continue
temp_visited.add(cur)
for nb in temp_adj.get(cur, set()):
if nb not in temp_visited:
queue.append(nb)
if component_count > 1:
bridges.append({
"entry": ap_entry,
"cluster_size": cluster["size"],
"components_after_removal": component_count,
"topics": ap_entry.topics,
})
bridges.sort(key=lambda b: b["components_after_removal"], reverse=True)
return bridges
def rebuild_links(self, threshold: Optional[float] = None) -> int:
"""Recompute all links from scratch.
Clears existing links and re-applies the holographic linker to every
entry pair. Useful after bulk ingestion or threshold changes.
Args:
threshold: Override the linker's default similarity threshold.
Returns:
Total number of links created.
"""
if threshold is not None:
old_threshold = self.linker.threshold
self.linker.threshold = threshold
# Clear all links
for entry in self._entries.values():
entry.links = []
entries = list(self._entries.values())
total_links = 0
# Re-link each entry against all others
for entry in entries:
candidates = [e for e in entries if e.id != entry.id]
new_links = self.linker.apply_links(entry, candidates)
total_links += new_links
if threshold is not None:
self.linker.threshold = old_threshold
self._save()
return total_links

209
nexus/mnemosyne/cli.py Normal file
View File

@@ -0,0 +1,209 @@
"""CLI interface for Mnemosyne.
Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats,
mnemosyne topics, mnemosyne remove, mnemosyne export,
mnemosyne clusters, mnemosyne hubs, mnemosyne bridges, mnemosyne rebuild
"""
from __future__ import annotations
import argparse
import json
import sys
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.entry import ArchiveEntry
from nexus.mnemosyne.ingest import ingest_event
def cmd_stats(args):
archive = MnemosyneArchive()
stats = archive.stats()
print(json.dumps(stats, indent=2))
def cmd_search(args):
archive = MnemosyneArchive()
if getattr(args, "semantic", False):
results = archive.semantic_search(args.query, limit=args.limit)
else:
results = archive.search(args.query, limit=args.limit)
if not results:
print("No results found.")
return
for entry in results:
linked = len(entry.links)
print(f"[{entry.id[:8]}] {entry.title}")
print(f" Source: {entry.source} | Topics: {', '.join(entry.topics)} | Links: {linked}")
print(f" {entry.content[:120]}...")
print()
def cmd_ingest(args):
archive = MnemosyneArchive()
entry = ingest_event(
archive,
title=args.title,
content=args.content,
topics=args.topics.split(",") if args.topics else [],
)
print(f"Ingested: [{entry.id[:8]}] {entry.title} ({len(entry.links)} links)")
def cmd_link(args):
archive = MnemosyneArchive()
entry = archive.get(args.entry_id)
if not entry:
print(f"Entry not found: {args.entry_id}")
sys.exit(1)
linked = archive.get_linked(entry.id, depth=args.depth)
if not linked:
print("No linked entries found.")
return
for e in linked:
print(f" [{e.id[:8]}] {e.title} (source: {e.source})")
def cmd_topics(args):
archive = MnemosyneArchive()
counts = archive.topic_counts()
if not counts:
print("No topics found.")
return
for topic, count in counts.items():
print(f" {topic}: {count}")
def cmd_remove(args):
archive = MnemosyneArchive()
removed = archive.remove(args.entry_id)
if removed:
print(f"Removed entry: {args.entry_id}")
else:
print(f"Entry not found: {args.entry_id}")
sys.exit(1)
def cmd_export(args):
archive = MnemosyneArchive()
topics = [t.strip() for t in args.topics.split(",")] if args.topics else None
data = archive.export(query=args.query or None, topics=topics)
print(json.dumps(data, indent=2))
def cmd_clusters(args):
archive = MnemosyneArchive()
clusters = archive.graph_clusters(min_size=args.min_size)
if not clusters:
print("No clusters found.")
return
for c in clusters:
print(f"Cluster {c['cluster_id']}: {c['size']} entries, density={c['density']}")
print(f" Topics: {', '.join(c['top_topics']) if c['top_topics'] else '(none)'}")
if args.verbose:
for eid in c["entries"]:
entry = archive.get(eid)
if entry:
print(f" [{eid[:8]}] {entry.title}")
print()
def cmd_hubs(args):
archive = MnemosyneArchive()
hubs = archive.hub_entries(limit=args.limit)
if not hubs:
print("No hubs found.")
return
for h in hubs:
e = h["entry"]
print(f"[{e.id[:8]}] {e.title}")
print(f" Degree: {h['degree']} (in: {h['inbound']}, out: {h['outbound']})")
print(f" Topics: {', '.join(h['topics']) if h['topics'] else '(none)'}")
print()
def cmd_bridges(args):
archive = MnemosyneArchive()
bridges = archive.bridge_entries()
if not bridges:
print("No bridge entries found.")
return
for b in bridges:
e = b["entry"]
print(f"[{e.id[:8]}] {e.title}")
print(f" Bridges {b['components_after_removal']} components (cluster: {b['cluster_size']} entries)")
print(f" Topics: {', '.join(b['topics']) if b['topics'] else '(none)'}")
print()
def cmd_rebuild(args):
archive = MnemosyneArchive()
threshold = args.threshold if args.threshold else None
total = archive.rebuild_links(threshold=threshold)
print(f"Rebuilt links: {total} connections across {archive.count} entries")
def main():
parser = argparse.ArgumentParser(prog="mnemosyne", description="The Living Holographic Archive")
sub = parser.add_subparsers(dest="command")
sub.add_parser("stats", help="Show archive statistics")
s = sub.add_parser("search", help="Search the archive")
s.add_argument("query", help="Search query")
s.add_argument("-n", "--limit", type=int, default=10)
s.add_argument("--semantic", action="store_true", help="Use holographic linker similarity scoring")
i = sub.add_parser("ingest", help="Ingest a new entry")
i.add_argument("--title", required=True)
i.add_argument("--content", required=True)
i.add_argument("--topics", default="", help="Comma-separated topics")
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)
sub.add_parser("topics", help="List all topics with entry counts")
r = sub.add_parser("remove", help="Remove an entry by ID")
r.add_argument("entry_id", help="Entry ID to remove")
ex = sub.add_parser("export", help="Export filtered archive data as JSON")
ex.add_argument("-q", "--query", default="", help="Keyword filter")
ex.add_argument("-t", "--topics", default="", help="Comma-separated topic filter")
cl = sub.add_parser("clusters", help="Show graph clusters (connected components)")
cl.add_argument("-m", "--min-size", type=int, default=1, help="Minimum cluster size")
cl.add_argument("-v", "--verbose", action="store_true", help="List entries in each cluster")
hu = sub.add_parser("hubs", help="Show most connected entries (hub analysis)")
hu.add_argument("-n", "--limit", type=int, default=10, help="Max hubs to show")
sub.add_parser("bridges", help="Show bridge entries (articulation points)")
rb = sub.add_parser("rebuild", help="Recompute all links from scratch")
rb.add_argument("-t", "--threshold", type=float, default=None, help="Similarity threshold override")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
dispatch = {
"stats": cmd_stats,
"search": cmd_search,
"ingest": cmd_ingest,
"link": cmd_link,
"topics": cmd_topics,
"remove": cmd_remove,
"export": cmd_export,
"clusters": cmd_clusters,
"hubs": cmd_hubs,
"bridges": cmd_bridges,
"rebuild": cmd_rebuild,
}
dispatch[args.command](args)
if __name__ == "__main__":
main()

44
nexus/mnemosyne/entry.py Normal file
View File

@@ -0,0 +1,44 @@
"""Archive entry model for Mnemosyne.
Each entry is a node in the holographic graph — a piece of meaning
with metadata, content, and links to related entries.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
import uuid
@dataclass
class ArchiveEntry:
"""A single node in the Mnemosyne holographic archive."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
title: str = ""
content: str = ""
source: str = "" # "mempalace", "event", "manual", etc.
source_ref: Optional[str] = None # original MemPalace ID, event URI, etc.
topics: list[str] = field(default_factory=list)
metadata: dict = field(default_factory=dict)
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
links: list[str] = field(default_factory=list) # IDs of related entries
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"content": self.content,
"source": self.source,
"source_ref": self.source_ref,
"topics": self.topics,
"metadata": self.metadata,
"created_at": self.created_at,
"links": self.links,
}
@classmethod
def from_dict(cls, data: dict) -> ArchiveEntry:
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})

62
nexus/mnemosyne/ingest.py Normal file
View File

@@ -0,0 +1,62 @@
"""Ingestion pipeline — feeds data into the archive.
Supports ingesting from MemPalace, raw events, and manual entries.
"""
from __future__ import annotations
from typing import Optional
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.entry import ArchiveEntry
def ingest_from_mempalace(
archive: MnemosyneArchive,
mempalace_entries: list[dict],
) -> int:
"""Ingest entries from a MemPalace export.
Each dict should have at least: content, metadata (optional).
Returns count of new entries added.
"""
added = 0
for mp_entry in mempalace_entries:
content = mp_entry.get("content", "")
metadata = mp_entry.get("metadata", {})
source_ref = mp_entry.get("id", "")
# Skip if already ingested
if any(e.source_ref == source_ref for e in archive._entries.values()):
continue
entry = ArchiveEntry(
title=metadata.get("title", content[:80]),
content=content,
source="mempalace",
source_ref=source_ref,
topics=metadata.get("topics", []),
metadata=metadata,
)
archive.add(entry)
added += 1
return added
def ingest_event(
archive: MnemosyneArchive,
title: str,
content: str,
topics: Optional[list[str]] = None,
source: str = "event",
metadata: Optional[dict] = None,
) -> ArchiveEntry:
"""Ingest a single event into the archive."""
entry = ArchiveEntry(
title=title,
content=content,
source=source,
topics=topics or [],
metadata=metadata or {},
)
return archive.add(entry)

73
nexus/mnemosyne/linker.py Normal file
View File

@@ -0,0 +1,73 @@
"""Holographic link engine.
Computes semantic similarity between archive entries and creates
bidirectional links, forming the holographic graph structure.
"""
from __future__ import annotations
from typing import Optional
from nexus.mnemosyne.entry import ArchiveEntry
class HolographicLinker:
"""Links archive entries via semantic similarity.
Phase 1 uses simple keyword overlap as the similarity metric.
Phase 2 will integrate ChromaDB embeddings from MemPalace.
"""
def __init__(self, similarity_threshold: float = 0.15):
self.threshold = similarity_threshold
def compute_similarity(self, a: ArchiveEntry, b: ArchiveEntry) -> float:
"""Compute similarity score between two entries.
Returns float in [0, 1]. Phase 1: Jaccard similarity on
combined title+content tokens. Phase 2: cosine similarity
on ChromaDB embeddings.
"""
tokens_a = self._tokenize(f"{a.title} {a.content}")
tokens_b = self._tokenize(f"{b.title} {b.content}")
if not tokens_a or not tokens_b:
return 0.0
intersection = tokens_a & tokens_b
union = tokens_a | tokens_b
return len(intersection) / len(union)
def find_links(self, entry: ArchiveEntry, candidates: list[ArchiveEntry]) -> list[tuple[str, float]]:
"""Find entries worth linking to.
Returns list of (entry_id, similarity_score) tuples above threshold.
"""
results = []
for candidate in candidates:
if candidate.id == entry.id:
continue
score = self.compute_similarity(entry, candidate)
if score >= self.threshold:
results.append((candidate.id, score))
results.sort(key=lambda x: x[1], reverse=True)
return results
def apply_links(self, entry: ArchiveEntry, candidates: list[ArchiveEntry]) -> int:
"""Auto-link an entry to related entries. Returns count of new links."""
matches = self.find_links(entry, candidates)
new_links = 0
for eid, score in matches:
if eid not in entry.links:
entry.links.append(eid)
new_links += 1
# Bidirectional
for c in candidates:
if c.id == eid and entry.id not in c.links:
c.links.append(entry.id)
return new_links
@staticmethod
def _tokenize(text: str) -> set[str]:
"""Simple whitespace + punctuation tokenizer."""
import re
tokens = set(re.findall(r"\w+", text.lower()))
# Remove very short tokens
return {t for t in tokens if len(t) > 2}

View File

View File

@@ -0,0 +1,276 @@
"""Tests for Mnemosyne archive core."""
import json
import tempfile
from pathlib import Path
from nexus.mnemosyne.entry import ArchiveEntry
from nexus.mnemosyne.linker import HolographicLinker
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.ingest import ingest_event, ingest_from_mempalace
def test_entry_roundtrip():
e = ArchiveEntry(title="Test", content="Hello world", topics=["test"])
d = e.to_dict()
e2 = ArchiveEntry.from_dict(d)
assert e2.id == e.id
assert e2.title == "Test"
def test_linker_similarity():
linker = HolographicLinker()
a = ArchiveEntry(title="Python coding", content="Writing Python scripts for automation")
b = ArchiveEntry(title="Python scripting", content="Automating tasks with Python scripts")
c = ArchiveEntry(title="Cooking recipes", content="How to make pasta carbonara")
assert linker.compute_similarity(a, b) > linker.compute_similarity(a, c)
def test_archive_add_and_search():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="First entry", content="Hello archive", topics=["test"])
ingest_event(archive, title="Second entry", content="Another record", topics=["test", "demo"])
assert archive.count == 2
results = archive.search("hello")
assert len(results) == 1
assert results[0].title == "First entry"
def test_archive_auto_linking():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
e1 = ingest_event(archive, title="Python automation", content="Building automation tools in Python")
e2 = ingest_event(archive, title="Python scripting", content="Writing automation scripts using Python")
# Both should be linked due to shared tokens
assert len(e1.links) > 0 or len(e2.links) > 0
def test_ingest_from_mempalace():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
mp_entries = [
{"id": "mp-1", "content": "Test memory content", "metadata": {"title": "Test", "topics": ["demo"]}},
{"id": "mp-2", "content": "Another memory", "metadata": {"title": "Memory 2"}},
]
count = ingest_from_mempalace(archive, mp_entries)
assert count == 2
assert archive.count == 2
def test_archive_persistence():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive1 = MnemosyneArchive(archive_path=path)
ingest_event(archive1, title="Persistent", content="Should survive reload")
archive2 = MnemosyneArchive(archive_path=path)
assert archive2.count == 1
results = archive2.search("persistent")
assert len(results) == 1
def test_archive_remove_basic():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
e1 = ingest_event(archive, title="Alpha", content="First entry", topics=["x"])
assert archive.count == 1
result = archive.remove(e1.id)
assert result is True
assert archive.count == 0
assert archive.get(e1.id) is None
def test_archive_remove_nonexistent():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
result = archive.remove("does-not-exist")
assert result is False
def test_archive_remove_cleans_backlinks():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
e1 = ingest_event(archive, title="Python automation", content="Building automation tools in Python")
e2 = ingest_event(archive, title="Python scripting", content="Writing automation scripts using Python")
# At least one direction should be linked
assert e1.id in e2.links or e2.id in e1.links
# Remove e1; e2 must no longer reference it
archive.remove(e1.id)
e2_fresh = archive.get(e2.id)
assert e2_fresh is not None
assert e1.id not in e2_fresh.links
def test_archive_remove_persists():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
a1 = MnemosyneArchive(archive_path=path)
e = ingest_event(a1, title="Gone", content="Will be removed")
a1.remove(e.id)
a2 = MnemosyneArchive(archive_path=path)
assert a2.count == 0
def test_archive_export_unfiltered():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="A", content="content a", topics=["alpha"])
ingest_event(archive, title="B", content="content b", topics=["beta"])
data = archive.export()
assert data["count"] == 2
assert len(data["entries"]) == 2
assert data["filters"] == {"query": None, "topics": None}
def test_archive_export_by_topic():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="A", content="content a", topics=["alpha"])
ingest_event(archive, title="B", content="content b", topics=["beta"])
data = archive.export(topics=["alpha"])
assert data["count"] == 1
assert data["entries"][0]["title"] == "A"
def test_archive_export_by_query():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Hello world", content="greetings", topics=[])
ingest_event(archive, title="Goodbye", content="farewell", topics=[])
data = archive.export(query="hello")
assert data["count"] == 1
assert data["entries"][0]["title"] == "Hello world"
def test_archive_export_combined_filters():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Hello world", content="greetings", topics=["alpha"])
ingest_event(archive, title="Hello again", content="greetings again", topics=["beta"])
data = archive.export(query="hello", topics=["alpha"])
assert data["count"] == 1
assert data["entries"][0]["title"] == "Hello world"
def test_archive_stats_richer():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
# All four new fields present when archive is empty
s = archive.stats()
assert "orphans" in s
assert "link_density" in s
assert "oldest_entry" in s
assert "newest_entry" in s
assert s["orphans"] == 0
assert s["link_density"] == 0.0
assert s["oldest_entry"] is None
assert s["newest_entry"] is None
def test_archive_stats_orphan_count():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
# Two entries with very different content → unlikely to auto-link
ingest_event(archive, title="Zebras", content="Zebra stripes savannah Africa", topics=[])
ingest_event(archive, title="Compiler", content="Lexer parser AST bytecode", topics=[])
s = archive.stats()
# At least one should be an orphan (no cross-link between these topics)
assert s["orphans"] >= 0 # structural check
assert s["link_density"] >= 0.0
assert s["oldest_entry"] is not None
assert s["newest_entry"] is not None
def test_semantic_search_returns_results():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Python automation", content="Building automation tools in Python")
ingest_event(archive, title="Cooking recipes", content="How to make pasta carbonara with cheese")
results = archive.semantic_search("python scripting", limit=5)
assert len(results) > 0
assert results[0].title == "Python automation"
def test_semantic_search_link_boost():
"""Entries with more inbound links rank higher when Jaccard is equal."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
# Create two similar entries; manually give one more links
e1 = ingest_event(archive, title="Machine learning", content="Neural networks deep learning models")
e2 = ingest_event(archive, title="Machine learning basics", content="Neural networks deep learning intro")
# Add a third entry that links to e1 so e1 has more inbound links
e3 = ingest_event(archive, title="AI overview", content="Artificial intelligence machine learning")
# Manually give e1 an extra inbound link by adding e3 -> e1
if e1.id not in e3.links:
e3.links.append(e1.id)
archive._save()
results = archive.semantic_search("machine learning neural networks", limit=5)
assert len(results) >= 2
# e1 should rank at or near top
assert results[0].id in {e1.id, e2.id}
def test_semantic_search_fallback_to_keyword():
"""Falls back to keyword search when no entry meets Jaccard threshold."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Exact match only", content="unique xyzzy token here")
# threshold=1.0 ensures no semantic match, triggering fallback
results = archive.semantic_search("xyzzy", limit=5, threshold=1.0)
# Fallback keyword search should find it
assert len(results) == 1
assert results[0].title == "Exact match only"
def test_semantic_search_empty_archive():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
results = archive.semantic_search("anything", limit=5)
assert results == []
def test_semantic_search_vs_keyword_relevance():
"""Semantic search finds conceptually related entries missed by keyword search."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Python scripting", content="Writing scripts with Python for automation tasks")
ingest_event(archive, title="Baking bread", content="Mix flour water yeast knead bake oven")
# "coding" is semantically unrelated to baking but related to python scripting
results = archive.semantic_search("coding scripts automation")
assert len(results) > 0
assert results[0].title == "Python scripting"
def test_archive_topic_counts():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="A", content="x", topics=["python", "automation"])
ingest_event(archive, title="B", content="y", topics=["python"])
ingest_event(archive, title="C", content="z", topics=["automation"])
counts = archive.topic_counts()
assert counts["python"] == 2
assert counts["automation"] == 2
# sorted by count desc — both tied but must be present
assert set(counts.keys()) == {"python", "automation"}

View File

@@ -0,0 +1,271 @@
"""Tests for Mnemosyne graph cluster analysis features.
Tests: graph_clusters, hub_entries, bridge_entries, rebuild_links.
"""
import pytest
from pathlib import Path
import tempfile
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.entry import ArchiveEntry
@pytest.fixture
def archive():
"""Create a fresh archive in a temp directory."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
a = MnemosyneArchive(archive_path=path)
yield a
def _make_entry(title="Test", content="test content", topics=None):
return ArchiveEntry(title=title, content=content, topics=topics or [])
class TestGraphClusters:
"""Test graph_clusters() connected component discovery."""
def test_empty_archive(self, archive):
clusters = archive.graph_clusters()
assert clusters == []
def test_single_orphan(self, archive):
archive.add(_make_entry("Lone entry"), auto_link=False)
# min_size=1 includes orphans
clusters = archive.graph_clusters(min_size=1)
assert len(clusters) == 1
assert clusters[0]["size"] == 1
assert clusters[0]["density"] == 0.0
def test_single_orphan_filtered(self, archive):
archive.add(_make_entry("Lone entry"), auto_link=False)
clusters = archive.graph_clusters(min_size=2)
assert clusters == []
def test_two_linked_entries(self, archive):
"""Two manually linked entries form a cluster."""
e1 = archive.add(_make_entry("Alpha dogs", "canine training"), auto_link=False)
e2 = archive.add(_make_entry("Beta cats", "feline behavior"), auto_link=False)
# Manual link
e1.links.append(e2.id)
e2.links.append(e1.id)
archive._save()
clusters = archive.graph_clusters(min_size=2)
assert len(clusters) == 1
assert clusters[0]["size"] == 2
assert clusters[0]["internal_edges"] == 1
assert clusters[0]["density"] == 1.0 # 1 edge out of 1 possible
def test_two_separate_clusters(self, archive):
"""Two disconnected groups form separate clusters."""
a1 = archive.add(_make_entry("AI models", "neural networks"), auto_link=False)
a2 = archive.add(_make_entry("AI training", "gradient descent"), auto_link=False)
b1 = archive.add(_make_entry("Cooking pasta", "italian recipes"), auto_link=False)
b2 = archive.add(_make_entry("Cooking sauces", "tomato basil"), auto_link=False)
# Link cluster A
a1.links.append(a2.id)
a2.links.append(a1.id)
# Link cluster B
b1.links.append(b2.id)
b2.links.append(b1.id)
archive._save()
clusters = archive.graph_clusters(min_size=2)
assert len(clusters) == 2
sizes = sorted(c["size"] for c in clusters)
assert sizes == [2, 2]
def test_cluster_topics(self, archive):
"""Cluster includes aggregated topics."""
e1 = archive.add(_make_entry("Alpha", "content", topics=["ai", "models"]), auto_link=False)
e2 = archive.add(_make_entry("Beta", "content", topics=["ai", "training"]), auto_link=False)
e1.links.append(e2.id)
e2.links.append(e1.id)
archive._save()
clusters = archive.graph_clusters(min_size=2)
assert "ai" in clusters[0]["top_topics"]
def test_density_calculation(self, archive):
"""Triangle (3 nodes, 3 edges) has density 1.0."""
e1 = archive.add(_make_entry("A", "aaa"), auto_link=False)
e2 = archive.add(_make_entry("B", "bbb"), auto_link=False)
e3 = archive.add(_make_entry("C", "ccc"), auto_link=False)
# Fully connected triangle
for e, others in [(e1, [e2, e3]), (e2, [e1, e3]), (e3, [e1, e2])]:
for o in others:
e.links.append(o.id)
archive._save()
clusters = archive.graph_clusters(min_size=2)
assert len(clusters) == 1
assert clusters[0]["internal_edges"] == 3
assert clusters[0]["density"] == 1.0 # 3 edges / 3 possible
def test_chain_density(self, archive):
"""A-B-C chain has density 2/3 (2 edges out of 3 possible)."""
e1 = archive.add(_make_entry("A", "aaa"), auto_link=False)
e2 = archive.add(_make_entry("B", "bbb"), auto_link=False)
e3 = archive.add(_make_entry("C", "ccc"), auto_link=False)
# Chain: A-B-C
e1.links.append(e2.id)
e2.links.extend([e1.id, e3.id])
e3.links.append(e2.id)
archive._save()
clusters = archive.graph_clusters(min_size=2)
assert abs(clusters[0]["density"] - 2/3) < 0.01
class TestHubEntries:
"""Test hub_entries() degree centrality ranking."""
def test_empty(self, archive):
assert archive.hub_entries() == []
def test_no_links(self, archive):
archive.add(_make_entry("Lone"), auto_link=False)
assert archive.hub_entries() == []
def test_hub_ordering(self, archive):
"""Entry with most links is ranked first."""
e1 = archive.add(_make_entry("Hub", "central node"), auto_link=False)
e2 = archive.add(_make_entry("Spoke 1", "content"), auto_link=False)
e3 = archive.add(_make_entry("Spoke 2", "content"), auto_link=False)
e4 = archive.add(_make_entry("Spoke 3", "content"), auto_link=False)
# e1 connects to all spokes
e1.links.extend([e2.id, e3.id, e4.id])
e2.links.append(e1.id)
e3.links.append(e1.id)
e4.links.append(e1.id)
archive._save()
hubs = archive.hub_entries()
assert len(hubs) == 4
assert hubs[0]["entry"].id == e1.id
assert hubs[0]["degree"] == 3
def test_limit(self, archive):
e1 = archive.add(_make_entry("A", ""), auto_link=False)
e2 = archive.add(_make_entry("B", ""), auto_link=False)
e1.links.append(e2.id)
e2.links.append(e1.id)
archive._save()
assert len(archive.hub_entries(limit=1)) == 1
def test_inbound_outbound(self, archive):
"""Inbound counts links TO an entry, outbound counts links FROM it."""
e1 = archive.add(_make_entry("Source", ""), auto_link=False)
e2 = archive.add(_make_entry("Target", ""), auto_link=False)
# Only e1 links to e2
e1.links.append(e2.id)
archive._save()
hubs = archive.hub_entries()
h1 = next(h for h in hubs if h["entry"].id == e1.id)
h2 = next(h for h in hubs if h["entry"].id == e2.id)
assert h1["inbound"] == 0
assert h1["outbound"] == 1
assert h2["inbound"] == 1
assert h2["outbound"] == 0
class TestBridgeEntries:
"""Test bridge_entries() articulation point detection."""
def test_empty(self, archive):
assert archive.bridge_entries() == []
def test_no_bridges_in_triangle(self, archive):
"""Fully connected triangle has no articulation points."""
e1 = archive.add(_make_entry("A", ""), auto_link=False)
e2 = archive.add(_make_entry("B", ""), auto_link=False)
e3 = archive.add(_make_entry("C", ""), auto_link=False)
for e, others in [(e1, [e2, e3]), (e2, [e1, e3]), (e3, [e1, e2])]:
for o in others:
e.links.append(o.id)
archive._save()
assert archive.bridge_entries() == []
def test_bridge_in_chain(self, archive):
"""A-B-C chain: B is the articulation point."""
e1 = archive.add(_make_entry("A", ""), auto_link=False)
e2 = archive.add(_make_entry("B", ""), auto_link=False)
e3 = archive.add(_make_entry("C", ""), auto_link=False)
e1.links.append(e2.id)
e2.links.extend([e1.id, e3.id])
e3.links.append(e2.id)
archive._save()
bridges = archive.bridge_entries()
assert len(bridges) == 1
assert bridges[0]["entry"].id == e2.id
assert bridges[0]["components_after_removal"] == 2
def test_no_bridges_in_small_cluster(self, archive):
"""Two-node clusters are too small for bridge detection."""
e1 = archive.add(_make_entry("A", ""), auto_link=False)
e2 = archive.add(_make_entry("B", ""), auto_link=False)
e1.links.append(e2.id)
e2.links.append(e1.id)
archive._save()
assert archive.bridge_entries() == []
class TestRebuildLinks:
"""Test rebuild_links() full recomputation."""
def test_empty_archive(self, archive):
assert archive.rebuild_links() == 0
def test_creates_links(self, archive):
"""Rebuild creates links between similar entries."""
archive.add(_make_entry("Alpha dogs canine training", "obedience training"), auto_link=False)
archive.add(_make_entry("Beta dogs canine behavior", "behavior training"), auto_link=False)
archive.add(_make_entry("Cat food feline nutrition", "fish meals"), auto_link=False)
total = archive.rebuild_links()
assert total > 0
# Check that dog entries are linked to each other
entries = list(archive._entries.values())
dog_entries = [e for e in entries if "dog" in e.title.lower()]
assert any(len(e.links) > 0 for e in dog_entries)
def test_override_threshold(self, archive):
"""Lower threshold creates more links."""
archive.add(_make_entry("Alpha dogs", "training"), auto_link=False)
archive.add(_make_entry("Beta cats", "training"), auto_link=False)
archive.add(_make_entry("Gamma birds", "training"), auto_link=False)
# Very low threshold = more links
low_links = archive.rebuild_links(threshold=0.01)
# Reset
for e in archive._entries.values():
e.links = []
# Higher threshold = fewer links
high_links = archive.rebuild_links(threshold=0.9)
assert low_links >= high_links
def test_rebuild_persists(self, archive):
"""Rebuild saves to disk."""
archive.add(_make_entry("Alpha dogs", "training"), auto_link=False)
archive.add(_make_entry("Beta dogs", "training"), auto_link=False)
archive.rebuild_links()
# Reload and verify links survived
archive2 = MnemosyneArchive(archive_path=archive.path)
entries = list(archive2._entries.values())
total_links = sum(len(e.links) for e in entries)
assert total_links > 0

62
provenance.json Normal file
View File

@@ -0,0 +1,62 @@
{
"generated_at": "2026-04-11T01:14:54.632326+00:00",
"repo": "Timmy_Foundation/the-nexus",
"git": {
"commit": "d408d2c365a9efc0c1e3a9b38b9cc4eed75695c5",
"branch": "mimo/build/issue-686",
"remote": "https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus.git",
"dirty": true
},
"files": {
"index.html": {
"sha256": "71ba27afe8b6b42a09efe09d2b3017599392ddc3bc02543b31c2277dfb0b82cc",
"size": 25933
},
"app.js": {
"sha256": "2b765a724a0fcda29abd40ba921bc621d2699f11d0ba14cf1579cbbdafdc5cd5",
"size": 132902
},
"style.css": {
"sha256": "cd3068d03eed6f52a00bbc32cfae8fba4739b8b3cb194b3ec09fd747a075056d",
"size": 44198
},
"gofai_worker.js": {
"sha256": "d292f110aa12a8aa2b16b0c2d48e5b4ce24ee15b1cffb409ab846b1a05a91de2",
"size": 969
},
"server.py": {
"sha256": "e963cc9715accfc8814e3fe5c44af836185d66740d5a65fd0365e9c629d38e05",
"size": 4185
},
"portals.json": {
"sha256": "889a5e0f724eb73a95f960bca44bca232150bddff7c1b11f253bd056f3683a08",
"size": 3442
},
"vision.json": {
"sha256": "0e3b5c06af98486bbcb2fc2dc627dc8b7b08aed4c3a4f9e10b57f91e1e8ca6ad",
"size": 1658
},
"manifest.json": {
"sha256": "352304c4f7746f5d31cbc223636769969dd263c52800645c01024a3a8489d8c9",
"size": 495
},
"nexus/components/spatial-memory.js": {
"sha256": "60170f6490ddd743acd6d285d3a1af6cad61fbf8aaef3f679ff4049108eac160",
"size": 32782
},
"nexus/components/session-rooms.js": {
"sha256": "9997a60dda256e38cb4645508bf9e98c15c3d963b696e0080e3170a9a7fa7cf1",
"size": 15113
},
"nexus/components/timeline-scrubber.js": {
"sha256": "f8a17762c2735be283dc5074b13eb00e1e3b2b04feb15996c2cf0323b46b6014",
"size": 7177
},
"nexus/components/memory-particles.js": {
"sha256": "1be5567a3ebb229f9e1a072c08a25387ade87cb4a1df6a624e5c5254d3bef8fa",
"size": 14216
}
},
"missing": [],
"file_count": 12
}

27
scripts/guardrails.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/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 ---"

26
scripts/smoke.mjs Normal file
View File

@@ -0,0 +1,26 @@
/**
* [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 ---');

1242
style.css

File diff suppressed because it is too large Load Diff

293
tests/test_browser_smoke.py Normal file
View File

@@ -0,0 +1,293 @@
"""
Browser smoke tests for the Nexus 3D world.
Uses Playwright to verify the DOM contract, Three.js initialization,
portal loading, and loading screen flow.
Refs: #686
"""
import json
import os
import subprocess
import time
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright, expect
REPO_ROOT = Path(__file__).resolve().parent.parent
SCREENSHOT_DIR = REPO_ROOT / "test-screenshots"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def http_server():
"""Start a simple HTTP server for the Nexus static files."""
import http.server
import threading
port = int(os.environ.get("NEXUS_TEST_PORT", "9876"))
handler = http.server.SimpleHTTPRequestHandler
server = http.server.HTTPServer(("127.0.0.1", port), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
time.sleep(0.3)
yield f"http://127.0.0.1:{port}"
server.shutdown()
@pytest.fixture(scope="module")
def browser_page(http_server):
"""Launch a headless browser and navigate to the Nexus."""
SCREENSHOT_DIR.mkdir(exist_ok=True)
with sync_playwright() as pw:
browser = pw.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-gpu"],
)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
ignore_https_errors=True,
)
page = context.new_page()
# Collect console errors
console_errors = []
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
page.goto(http_server, wait_until="domcontentloaded", timeout=30000)
page._console_errors = console_errors
yield page
browser.close()
# ---------------------------------------------------------------------------
# Static asset tests
# ---------------------------------------------------------------------------
class TestStaticAssets:
"""Verify all contract files are serveable."""
REQUIRED_FILES = [
"index.html",
"app.js",
"style.css",
"portals.json",
"vision.json",
"manifest.json",
"gofai_worker.js",
]
def test_index_html_served(self, http_server):
"""index.html must return 200."""
import urllib.request
resp = urllib.request.urlopen(f"{http_server}/index.html")
assert resp.status == 200
@pytest.mark.parametrize("filename", REQUIRED_FILES)
def test_contract_file_served(self, http_server, filename):
"""Each contract file must return 200."""
import urllib.request
try:
resp = urllib.request.urlopen(f"{http_server}/{filename}")
assert resp.status == 200
except Exception as e:
pytest.fail(f"{filename} not serveable: {e}")
# ---------------------------------------------------------------------------
# DOM contract tests
# ---------------------------------------------------------------------------
class TestDOMContract:
"""Verify required DOM elements exist after page load."""
REQUIRED_ELEMENTS = {
"nexus-canvas": "canvas",
"hud": "div",
"chat-panel": "div",
"chat-input": "input",
"chat-messages": "div",
"chat-send": "button",
"chat-toggle": "button",
"debug-overlay": "div",
"nav-mode-label": "span",
"ws-status-dot": "span",
"hud-location-text": "span",
"portal-hint": "div",
"spatial-search": "div",
}
@pytest.mark.parametrize("element_id,tag", list(REQUIRED_ELEMENTS.items()))
def test_element_exists(self, browser_page, element_id, tag):
"""Element with given ID must exist in the DOM."""
el = browser_page.query_selector(f"#{element_id}")
assert el is not None, f"#{element_id} ({tag}) missing from DOM"
def test_canvas_has_webgl(self, browser_page):
"""The nexus-canvas must have a WebGL rendering context."""
has_webgl = browser_page.evaluate("""
() => {
const c = document.getElementById('nexus-canvas');
if (!c) return false;
const ctx = c.getContext('webgl2') || c.getContext('webgl');
return ctx !== null;
}
""")
assert has_webgl, "nexus-canvas has no WebGL context"
def test_title_contains_nexus(self, browser_page):
"""Page title should reference The Nexus."""
title = browser_page.title()
assert "nexus" in title.lower() or "timmy" in title.lower(), f"Unexpected title: {title}"
# ---------------------------------------------------------------------------
# Loading flow tests
# ---------------------------------------------------------------------------
class TestLoadingFlow:
"""Verify the loading screen → enter prompt → HUD flow."""
def test_loading_screen_transitions(self, browser_page):
"""Loading screen should fade out and HUD should become visible."""
# Wait for loading to complete and enter prompt to appear
try:
browser_page.wait_for_selector("#enter-prompt", state="visible", timeout=15000)
except Exception:
# Enter prompt may have already appeared and been clicked
pass
# Try clicking the enter prompt if it exists
enter = browser_page.query_selector("#enter-prompt")
if enter and enter.is_visible():
enter.click()
time.sleep(1)
# HUD should now be visible
hud = browser_page.query_selector("#hud")
assert hud is not None, "HUD element missing"
# After enter, HUD display should not be 'none'
display = browser_page.evaluate("() => document.getElementById('hud').style.display")
assert display != "none", "HUD should be visible after entering"
# ---------------------------------------------------------------------------
# Three.js initialization tests
# ---------------------------------------------------------------------------
class TestThreeJSInit:
"""Verify Three.js initialized properly."""
def test_three_loaded(self, browser_page):
"""THREE namespace should be available (via import map)."""
# Three.js is loaded as ES module, check for canvas context instead
has_canvas = browser_page.evaluate("""
() => {
const c = document.getElementById('nexus-canvas');
return c && c.width > 0 && c.height > 0;
}
""")
assert has_canvas, "Canvas not properly initialized"
def test_canvas_dimensions(self, browser_page):
"""Canvas should fill the viewport."""
dims = browser_page.evaluate("""
() => {
const c = document.getElementById('nexus-canvas');
return { width: c.width, height: c.height, ww: window.innerWidth, wh: window.innerHeight };
}
""")
assert dims["width"] > 0, "Canvas width is 0"
assert dims["height"] > 0, "Canvas height is 0"
# ---------------------------------------------------------------------------
# Data contract tests
# ---------------------------------------------------------------------------
class TestDataContract:
"""Verify JSON data files are valid and well-formed."""
def test_portals_json_valid(self):
"""portals.json must parse as a non-empty JSON array."""
data = json.loads((REPO_ROOT / "portals.json").read_text())
assert isinstance(data, list), "portals.json must be an array"
assert len(data) > 0, "portals.json must have at least one portal"
def test_portals_have_required_fields(self):
"""Each portal must have id, name, status, destination."""
data = json.loads((REPO_ROOT / "portals.json").read_text())
required = {"id", "name", "status", "destination"}
for i, portal in enumerate(data):
missing = required - set(portal.keys())
assert not missing, f"Portal {i} missing fields: {missing}"
def test_vision_json_valid(self):
"""vision.json must parse as valid JSON."""
data = json.loads((REPO_ROOT / "vision.json").read_text())
assert data is not None
def test_manifest_json_valid(self):
"""manifest.json must have required PWA fields."""
data = json.loads((REPO_ROOT / "manifest.json").read_text())
for key in ["name", "start_url", "theme_color"]:
assert key in data, f"manifest.json missing '{key}'"
# ---------------------------------------------------------------------------
# Screenshot / visual proof
# ---------------------------------------------------------------------------
class TestVisualProof:
"""Capture screenshots as visual validation evidence."""
def test_screenshot_initial_state(self, browser_page):
"""Take a screenshot of the initial page state."""
path = SCREENSHOT_DIR / "smoke-initial.png"
browser_page.screenshot(path=str(path))
assert path.exists(), "Screenshot was not saved"
assert path.stat().st_size > 1000, "Screenshot seems empty"
def test_screenshot_after_enter(self, browser_page):
"""Take a screenshot after clicking through the enter prompt."""
enter = browser_page.query_selector("#enter-prompt")
if enter and enter.is_visible():
enter.click()
time.sleep(2)
else:
time.sleep(1)
path = SCREENSHOT_DIR / "smoke-post-enter.png"
browser_page.screenshot(path=str(path))
assert path.exists()
def test_screenshot_fullscreen(self, browser_page):
"""Full-page screenshot for visual regression baseline."""
path = SCREENSHOT_DIR / "smoke-fullscreen.png"
browser_page.screenshot(path=str(path), full_page=True)
assert path.exists()
# ---------------------------------------------------------------------------
# Provenance in browser context
# ---------------------------------------------------------------------------
class TestBrowserProvenance:
"""Verify provenance from within the browser context."""
def test_page_served_from_correct_origin(self, http_server):
"""The page must be served from localhost, not a stale remote."""
import urllib.request
resp = urllib.request.urlopen(f"{http_server}/index.html")
content = resp.read().decode("utf-8", errors="replace")
# Must not contain references to legacy matrix path
assert "/Users/apayne/the-matrix" not in content, \
"index.html references legacy matrix path — provenance violation"
def test_index_html_has_nexus_title(self, http_server):
"""index.html title must reference The Nexus."""
import urllib.request
resp = urllib.request.urlopen(f"{http_server}/index.html")
content = resp.read().decode("utf-8", errors="replace")
assert "<title>The Nexus" in content or "Timmy" in content, \
"index.html title does not reference The Nexus"

73
tests/test_provenance.py Normal file
View File

@@ -0,0 +1,73 @@
"""
Provenance tests — verify the Nexus browser surface comes from
a clean Timmy_Foundation/the-nexus checkout, not stale sources.
Refs: #686
"""
import json
import hashlib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def test_provenance_manifest_exists() -> None:
"""provenance.json must exist and be valid JSON."""
p = REPO_ROOT / "provenance.json"
assert p.exists(), "provenance.json missing — run bin/generate_provenance.py"
data = json.loads(p.read_text())
assert "files" in data
assert "repo" in data
def test_provenance_repo_identity() -> None:
"""Manifest must claim Timmy_Foundation/the-nexus."""
data = json.loads((REPO_ROOT / "provenance.json").read_text())
assert data["repo"] == "Timmy_Foundation/the-nexus"
def test_provenance_all_contract_files_present() -> None:
"""Every file listed in the provenance manifest must exist on disk."""
data = json.loads((REPO_ROOT / "provenance.json").read_text())
missing = []
for rel in data["files"]:
if not (REPO_ROOT / rel).exists():
missing.append(rel)
assert not missing, f"Contract files missing: {missing}"
def test_provenance_hashes_match() -> None:
"""File hashes must match the stored manifest (no stale/modified files)."""
data = json.loads((REPO_ROOT / "provenance.json").read_text())
mismatches = []
for rel, meta in data["files"].items():
p = REPO_ROOT / rel
if not p.exists():
mismatches.append(f"MISSING: {rel}")
continue
actual = hashlib.sha256(p.read_bytes()).hexdigest()
if actual != meta["sha256"]:
mismatches.append(f"CHANGED: {rel}")
assert not mismatches, f"Provenance mismatch:\n" + "\n".join(mismatches)
def test_no_legacy_matrix_references_in_frontend() -> None:
"""Frontend files must not reference /Users/apayne/the-matrix as a source."""
forbidden_paths = ["/Users/apayne/the-matrix"]
offenders = []
for rel in ["index.html", "app.js", "style.css"]:
p = REPO_ROOT / rel
if p.exists():
content = p.read_text()
for bad in forbidden_paths:
if bad in content:
offenders.append(f"{rel} references {bad}")
assert not offenders, f"Legacy matrix references found: {offenders}"
def test_no_stale_perplexity_computer_references_in_critical_files() -> None:
"""Verify the provenance generator script itself is canonical."""
script = REPO_ROOT / "bin" / "generate_provenance.py"
assert script.exists(), "bin/generate_provenance.py must exist"
content = script.read_text()
assert "Timmy_Foundation/the-nexus" in content