Compare commits

...

19 Commits

Author SHA1 Message Date
6b19bd29a3 feat: implement Meta-Reasoning & Hierarchical Caching (Phase 4)
Some checks failed
CI / validate (pull_request) Failing after 5s
2026-03-30 02:44:18 +00:00
f634839e92 feat: implement Meta-Reasoning & Hierarchical Caching (Phase 4) 2026-03-30 02:44:17 +00:00
7f2f23fe20 feat: implement Meta-Reasoning & Hierarchical Caching (Phase 4) 2026-03-30 02:44:15 +00:00
a1fdf9b932 Merge pull request 'Sovereign AI: Symbolic Reasoning & Agent FSMs' (#764) from sovereign-symbolic-ai into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
2026-03-30 02:27:43 +00:00
78925606c4 Merge pull request '[EPIC] Google AI Ultra Full Integration — Master PR' (#763) from feat/google-ai-ultra-integration into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
2026-03-30 02:27:40 +00:00
784ee40c76 feat: style symbolic reasoning HUD element
Some checks failed
CI / validate (pull_request) Failing after 5s
2026-03-30 02:20:37 +00:00
b3b726375b feat: add symbolic reasoning log to HUD 2026-03-30 02:20:36 +00:00
8943cf557c feat: implement sovereign symbolic reasoning engine and agent FSMs 2026-03-30 02:20:34 +00:00
Alexander Whitestone
f4dd5a0d17 feat: add Google AI Ultra integration plan
Some checks failed
CI / validate (pull_request) Failing after 6s
Refs #739

Master tracking document for integrating all Google AI Ultra products into
Project Timmy and The Nexus. Covers 10 products across 5 phases:

Phase 1: Identity & Branding (#740, #741, #742, #680)
Phase 2: Research & Planning (#743, #744, #745, #746)
Phase 3: Prototype & Build (#747, #748, #749, #750, #681)
Phase 4: Media & Content (#682, #751, #752, #753)
Phase 5: Advanced Integration (#754-#762)

Includes API quick reference, key URLs, and hidden feature inventory.
2026-03-29 21:58:16 -04:00
4205f8b252 Merge pull request '[BRIDGE] Feed Evennia world events into the Nexus websocket bridge (#727)' (#732) from codex/evennia-ws-feed into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
2026-03-28 20:53:56 +00:00
2b81d4c91d Merge pull request 'Nexus Portal Atlas & Quick Actions Integration' (#733) from gemini/nexus-atlas-1774731055524 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
2026-03-28 20:53:54 +00:00
ad36cd151e Nexus Atlas: Update style.css
Some checks failed
CI / validate (pull_request) Failing after 4s
2026-03-28 20:51:00 +00:00
d87bb89e62 Nexus Atlas: Update portals.json 2026-03-28 20:50:59 +00:00
da20dd5738 Nexus Atlas: Update index.html 2026-03-28 20:50:58 +00:00
3107de9fc9 Nexus Atlas: Update app.js 2026-03-28 20:50:56 +00:00
Alexander Whitestone
1fe5176ebc feat: feed Evennia world events into Nexus websocket bridge
Some checks failed
CI / validate (pull_request) Failing after 4s
2026-03-28 16:25:18 -04:00
916217499b Merge pull request '[ADAPTER] Add thin Evennia to Nexus event adapter' (#725) from codex/evennia-nexus-adapter into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
2026-03-28 20:03:41 +00:00
Alexander Whitestone
8ead4cd13f feat: add thin Evennia to Nexus event adapter
Some checks failed
CI / validate (pull_request) Failing after 4s
2026-03-28 16:02:27 -04:00
8313533304 feat: expand portal registry schema (#718)
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
2026-03-28 17:01:49 +00:00
18 changed files with 4929 additions and 5 deletions

View File

@@ -0,0 +1,107 @@
# Evennia → Nexus Event Protocol
This is the thin semantic adapter between Timmy's persistent Evennia world and
Timmy's Nexus-facing world model.
Principle:
- Evennia owns persistent world truth.
- Nexus owns visualization and operator legibility.
- The adapter owns only translation, not storage or game logic.
## Canonical event families
### 1. `evennia.session_bound`
Binds a Hermes session to a world interaction run.
```json
{
"type": "evennia.session_bound",
"hermes_session_id": "20260328_132016_7ea250",
"evennia_account": "Timmy",
"evennia_character": "Timmy",
"timestamp": "2026-03-28T20:00:00Z"
}
```
### 2. `evennia.actor_located`
Declares where Timmy currently is.
```json
{
"type": "evennia.actor_located",
"actor_id": "Timmy",
"room_id": "Gate",
"room_key": "Gate",
"room_name": "Gate",
"timestamp": "2026-03-28T20:00:01Z"
}
```
### 3. `evennia.room_snapshot`
The main room-state payload Nexus should render.
```json
{
"type": "evennia.room_snapshot",
"room_id": "Chapel",
"room_key": "Chapel",
"title": "Chapel",
"desc": "A quiet room set apart for prayer, conscience, grief, and right alignment.",
"exits": [
{"key": "courtyard", "destination_id": "Courtyard", "destination_key": "Courtyard"}
],
"objects": [
{"id": "Book of the Soul", "key": "Book of the Soul", "short_desc": "A doctrinal anchor."},
{"id": "Prayer Wall", "key": "Prayer Wall", "short_desc": "A place for names and remembered burdens."}
],
"occupants": [],
"timestamp": "2026-03-28T20:00:02Z"
}
```
### 4. `evennia.command_issued`
Records what Timmy attempted.
```json
{
"type": "evennia.command_issued",
"hermes_session_id": "20260328_132016_7ea250",
"actor_id": "Timmy",
"command_text": "look Book of the Soul",
"timestamp": "2026-03-28T20:00:03Z"
}
```
### 5. `evennia.command_result`
Records what the world returned.
```json
{
"type": "evennia.command_result",
"hermes_session_id": "20260328_132016_7ea250",
"actor_id": "Timmy",
"command_text": "look Book of the Soul",
"output_text": "Book of the Soul. A doctrinal anchor. It is not decorative; it is a reference point.",
"success": true,
"timestamp": "2026-03-28T20:00:04Z"
}
```
## What Nexus should care about
For first renderability, Nexus only needs:
- current room title/description
- exits
- visible objects
- actor location
- latest command/result
It does *not* need raw telnet noise or internal Evennia database structure.
## Ownership boundary
Do not build a second world model in Nexus.
Do not make Nexus authoritative over persistent state.
Do not make Evennia care about Three.js internals.
Own only this translation layer.

View File

@@ -0,0 +1,49 @@
# First Light Report — Evennia to Nexus Bridge
Issue:
- #727 Feed Evennia room/command events into the Nexus websocket bridge
What was implemented:
- `nexus/evennia_ws_bridge.py` — reads Evennia telemetry JSONL and publishes normalized Evennia→Nexus events into the local websocket bridge
- `EVENNIA_NEXUS_EVENT_PROTOCOL.md` — canonical event family contract
- `nexus/evennia_event_adapter.py` — normalization helpers (already merged in #725)
- `nexus/perception_adapter.py` support for `evennia.actor_located`, `evennia.room_snapshot`, and `evennia.command_result`
- tests locking the bridge parsing and event contract
Proof method:
1. Start local Nexus websocket bridge on `ws://127.0.0.1:8765`
2. Open a websocket listener
3. Replay a real committed Evennia example trace from `timmy-home`
4. Confirm normalized events are received over the websocket
Observed received messages (excerpt):
```json
[
{
"type": "evennia.session_bound",
"hermes_session_id": "world-basics-trace.example",
"evennia_account": "Timmy",
"evennia_character": "Timmy"
},
{
"type": "evennia.command_issued",
"actor_id": "timmy",
"command_text": "look"
},
{
"type": "evennia.command_result",
"actor_id": "timmy",
"command_text": "look",
"output_text": "Chapel A quiet room set apart for prayer, conscience, grief, and right alignment...",
"success": true
}
]
```
Interpretation:
- Evennia world telemetry can now be published into the Nexus websocket bridge without inventing a second world model.
- The bridge is thin: it translates and forwards.
- Nexus-side perception code can now consume these events as part of Timmy's sensorium.
Why this matters:
This is the first live seam where Timmy's persistent Evennia place can begin to appear inside the Nexus-facing world model.

View File

@@ -102,22 +102,47 @@ A portal is a game configuration. To add one:
"name": "New Game",
"description": "What this portal is.",
"status": "offline",
"portal_type": "game-world",
"world_category": "rpg",
"environment": "staging",
"access_mode": "operator",
"readiness_state": "prototype",
"telemetry_source": "hermes-harness:new-game-bridge",
"owner": "Timmy",
"app_id": 12345,
"window_title": "New Game Window Title",
"destination": {
"type": "harness",
"action_label": "Enter New Game",
"params": { "world": "new-world" }
}
}
```
2. **No code changes.** The heartbeat loop reads `portals.json`,
uses `app_id` for Steam API calls and `window_title` for
screenshot targeting. The MCP tools are game-agnostic.
Required metadata fields:
- `portal_type` — high-level kind (`game-world`, `operator-room`, `research-space`, `experiment`)
- `world_category` — subtype for navigation and grouping (`rpg`, `workspace`, `sim`, etc.)
- `environment``production`, `staging`, or `local`
- `access_mode``public`, `operator`, or `local-only`
- `readiness_state``playable`, `active`, `prototype`, `rebuilding`, `blocked`, `offline`
- `telemetry_source` — where truth/status comes from
- `owner` — who currently owns the world or integration lane
- `destination.action_label` — human-facing action text for UI cards/directories
2. **No mandatory game-specific code changes.** The heartbeat loop reads `portals.json`,
uses metadata for grouping/status/visibility, and can still use fields like
`app_id` and `window_title` for screenshot targeting where relevant. The MCP tools remain game-agnostic.
3. **Game-specific prompts** go in `training/data/prompts_*.yaml`
to teach the model what the game looks like and how to play it.
4. **Migration from legacy portal definitions**
- old portal entries with only `id`, `name`, `description`, `status`, and `destination`
should be upgraded in place
- preserve visual fields like `color`, `position`, and `rotation`
- add the new metadata fields so the same registry can drive future atlas, status wall,
preview cards, and many-portal navigation without inventing parallel registries
## Portal: Bannerlord (Primary)
**Steam App ID:** `261550`

2104
app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,127 @@
# Google AI Ultra Integration Plan
> Master tracking document for integrating all Google AI Ultra products into
> Project Timmy (Sovereign AI Agent) and The Nexus (3D World).
**Epic**: #739
**Milestone**: M5: Google AI Ultra Integration
**Label**: `google-ai-ultra`
---
## Product Inventory
| # | Product | Capability | API | Priority | Status |
|---|---------|-----------|-----|----------|--------|
| 1 | Gemini 3.1 Pro | Primary reasoning engine | ✅ | P0 | 🔲 Not started |
| 2 | Deep Research | Autonomous research reports | ✅ | P1 | 🔲 Not started |
| 3 | Veo 3.1 | Text/image → video | ✅ | P2 | 🔲 Not started |
| 4 | Nano Banana Pro | Image generation | ✅ | P1 | 🔲 Not started |
| 5 | Lyria 3 | Music/audio generation | ✅ | P2 | 🔲 Not started |
| 6 | NotebookLM | Doc synthesis + Audio Overviews | ❌ | P1 | 🔲 Not started |
| 7 | AI Studio | API portal + Vibe Code | N/A | P0 | 🔲 Not started |
| 8 | Project Genie | Interactive 3D world gen | ❌ | P1 | 🔲 Not started |
| 9 | Live API | Real-time voice streaming | ✅ | P2 | 🔲 Not started |
| 10 | Computer Use | Browser automation | ✅ | P2 | 🔲 Not started |
---
## Phase 1: Identity & Branding (Week 1)
| Issue | Title | Status |
|-------|-------|--------|
| #740 | Generate Timmy avatar set with Nano Banana Pro | 🔲 |
| #741 | Upload SOUL.md to NotebookLM → Audio Overview | 🔲 |
| #742 | Generate Timmy audio signature with Lyria 3 | 🔲 |
| #680 | Project Genie + Nano Banana concept pack | 🔲 |
## Phase 2: Research & Planning (Week 1-2)
| Issue | Title | Status |
|-------|-------|--------|
| #743 | Deep Research: Three.js multiplayer 3D world architecture | 🔲 |
| #744 | Deep Research: Sovereign AI agent frameworks | 🔲 |
| #745 | Deep Research: WebGL/WebGPU rendering comparison | 🔲 |
| #746 | NotebookLM synthesis: cross-reference all research | 🔲 |
## Phase 3: Prototype & Build (Week 2-4)
| Issue | Title | Status |
|-------|-------|--------|
| #747 | Provision Gemini API key + Hermes config | 🔲 |
| #748 | Integrate Gemini 3.1 Pro as reasoning backbone | 🔲 |
| #749 | AI Studio Vibe Code UI prototypes | 🔲 |
| #750 | Project Genie explorable world prototypes | 🔲 |
| #681 | Veo/Flow flythrough prototypes | 🔲 |
## Phase 4: Media & Content (Ongoing)
| Issue | Title | Status |
|-------|-------|--------|
| #682 | Lyria soundtrack palette for Nexus zones | 🔲 |
| #751 | Lyria RealTime dynamic reactive music | 🔲 |
| #752 | NotebookLM Audio Overviews for all docs | 🔲 |
| #753 | Nano Banana concept art batch pipeline | 🔲 |
## Phase 5: Advanced Integration (Month 2+)
| Issue | Title | Status |
|-------|-------|--------|
| #754 | Gemini Live API for voice conversations | 🔲 |
| #755 | Computer Use API for browser automation | 🔲 |
| #756 | Gemini RAG via File Search for Timmy memory | 🔲 |
| #757 | Gemini Native Audio + TTS for Timmy's voice | 🔲 |
| #758 | Programmatic image generation pipeline | 🔲 |
| #759 | Programmatic video generation pipeline | 🔲 |
| #760 | Deep Research Agent API integration | 🔲 |
| #761 | OpenAI-compatible endpoint config | 🔲 |
| #762 | Context caching + batch API for cost optimization | 🔲 |
---
## API Quick Reference
```python
# pip install google-genai
from google import genai
client = genai.Client() # reads GOOGLE_API_KEY env var
# Text generation (Gemini 3.1 Pro)
response = client.models.generate_content(
model="gemini-3.1-pro-preview",
contents="..."
)
```
| API | Documentation |
|-----|--------------|
| Image Gen (Nano Banana) | ai.google.dev/gemini-api/docs/image-generation |
| Video Gen (Veo) | ai.google.dev/gemini-api/docs/video |
| Music Gen (Lyria) | ai.google.dev/gemini-api/docs/music-generation |
| TTS | ai.google.dev/gemini-api/docs/speech-generation |
| Deep Research | ai.google.dev/gemini-api/docs/deep-research |
## Key URLs
| Tool | URL |
|------|-----|
| Gemini App | gemini.google.com |
| AI Studio | aistudio.google.com |
| NotebookLM | notebooklm.google.com |
| Project Genie | labs.google/projectgenie |
| Flow (video) | labs.google/flow |
| Stitch (UI) | labs.google/stitch |
## Hidden Features to Exploit
1. **AI Studio Free Tier** — generous API access even without subscription
2. **OpenAI-Compatible API** — drop-in replacement for existing OpenAI tooling
3. **Context Caching** — cache SOUL.md to cut cost/latency on repeated calls
4. **Batch API** — bulk operations at discounted rates
5. **File Search Tool** — RAG without custom vector store
6. **Computer Use API** — programmatic browser control for agent automation
7. **Interactions API** — managed multi-turn conversational state
---
*Generated: 2026-03-29. Epic #739, Milestone M5.*

266
index.html Normal file
View File

@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<!--
______ __
/ ____/___ ____ ___ ____ __ __/ /____ _____
/ / / __ \/ __ `__ \/ __ \/ / / / __/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ / /_/ __/ /
\____/\____/_/ /_/ /_/ .___/\__,_/\__/\___/_/
/_/
Created with Perplexity Computer
https://www.perplexity.ai/computer
-->
<meta name="generator" content="Perplexity Computer">
<meta name="author" content="Perplexity Computer">
<meta property="og:see_also" content="https://www.perplexity.ai/computer">
<link rel="author" href="https://www.perplexity.ai/computer">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Nexus — Timmy's Sovereign Home</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css">
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
}
}
</script>
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen">
<div class="loader-content">
<div class="loader-sigil">
<svg viewBox="0 0 120 120" width="120" height="120">
<defs>
<linearGradient id="sigil-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4af0c0"/>
<stop offset="100%" stop-color="#7b5cff"/>
</linearGradient>
</defs>
<circle cx="60" cy="60" r="55" fill="none" stroke="url(#sigil-grad)" stroke-width="1.5" opacity="0.4"/>
<circle cx="60" cy="60" r="45" fill="none" stroke="url(#sigil-grad)" stroke-width="1" opacity="0.3">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="360 60 60" dur="8s" repeatCount="indefinite"/>
</circle>
<polygon points="60,15 95,80 25,80" fill="none" stroke="#4af0c0" stroke-width="1.5" opacity="0.6">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="-360 60 60" dur="12s" repeatCount="indefinite"/>
</polygon>
<circle cx="60" cy="60" r="8" fill="#4af0c0" opacity="0.8">
<animate attributeName="r" values="6;10;6" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>
</div>
<h1 class="loader-title">THE NEXUS</h1>
<p class="loader-subtitle">Initializing Sovereign Space...</p>
<div class="loader-bar"><div class="loader-fill" id="load-progress"></div></div>
</div>
</div>
<!-- HUD Overlay -->
<div id="hud" class="game-ui" style="display:none;">
<!-- Top Left: Debug -->
<div id="debug-overlay" class="hud-debug"></div>
<!-- Top Center: Location -->
<div class="hud-location" aria-live="polite">
<span class="hud-location-icon" aria-hidden="true"></span>
<span id="hud-location-text">The Nexus</span>
</div>
<!-- Top Right: Agent Log & Atlas Toggle -->
<div class="hud-top-right">
<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" title="Bannerlord Readiness">
<span class="status-dot"></span>
<span class="status-label">BANNERLORD</span>
</div>
<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>
</div>
<!-- Bottom: Chat Interface -->
<div id="chat-panel" class="chat-panel">
<div class="chat-header">
<span class="chat-status-dot"></span>
<span>Timmy Terminal</span>
<button id="chat-toggle" class="chat-toggle-btn" aria-label="Toggle chat"></button>
</div>
<div id="chat-messages" class="chat-messages">
<div class="chat-msg chat-msg-system">
<span class="chat-msg-prefix">[NEXUS]</span> Sovereign space initialized. Timmy is observing.
</div>
<div class="chat-msg chat-msg-timmy">
<span class="chat-msg-prefix">[TIMMY]</span> Welcome to the Nexus, Alexander. All systems nominal.
</div>
</div>
<div id="chat-quick-actions" class="chat-quick-actions">
<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">
<button id="chat-send" class="chat-send-btn" aria-label="Send message"></button>
</div>
</div>
<!-- Controls hint + nav mode -->
<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"></span></span>
</div>
<!-- Portal Hint -->
<div id="portal-hint" class="portal-hint" style="display:none;">
<div class="portal-hint-key">F</div>
<div class="portal-hint-text">Enter <span id="portal-hint-name"></span></div>
</div>
<!-- Vision Hint -->
<div id="vision-hint" class="vision-hint" style="display:none;">
<div class="vision-hint-key">E</div>
<div class="vision-hint-text">Read <span id="vision-hint-title"></span></div>
</div>
<!-- Vision Overlay -->
<div id="vision-overlay" class="vision-overlay" style="display:none;">
<div class="vision-overlay-content">
<div class="vision-overlay-header">
<div class="vision-overlay-status" id="vision-status-dot"></div>
<div class="vision-overlay-title" id="vision-overlay-title">VISION POINT</div>
</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">CLOSE</button>
</div>
</div>
<!-- Portal Activation Overlay -->
<div id="portal-overlay" class="portal-overlay" style="display:none;">
<div class="portal-overlay-content">
<div class="portal-overlay-header">
<div class="portal-overlay-status" id="portal-status-dot"></div>
<div class="portal-overlay-title" id="portal-overlay-title">PORTAL ACTIVATED</div>
</div>
<h2 id="portal-name-display">MORROWIND</h2>
<p id="portal-desc-display">The Vvardenfell harness. Ash storms and ancient mysteries.</p>
<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">CLOSE</button>
</div>
</div>
</div>
<!-- Portal Atlas Overlay -->
<div id="atlas-overlay" class="atlas-overlay" style="display:none;">
<div class="atlas-content">
<div class="atlas-header">
<div class="atlas-title">
<span class="atlas-icon">🌐</span>
<h2>PORTAL ATLAS</h2>
</div>
<button id="atlas-close-btn" class="atlas-close-btn">CLOSE</button>
</div>
<div class="atlas-grid" id="atlas-grid">
<!-- Portals will be injected here -->
</div>
<div class="atlas-footer">
<div class="atlas-status-summary">
<span class="status-indicator online"></span> <span id="atlas-online-count">0</span> ONLINE
&nbsp;&nbsp;
<span class="status-indicator standby"></span> <span id="atlas-standby-count">0</span> STANDBY
</div>
<div class="atlas-hint">Click a portal to focus or teleport</div>
</div>
</div>
</div>
</div>
<!-- Click to Enter -->
<div id="enter-prompt" style="display:none;">
<div class="enter-content">
<h2>Enter The Nexus</h2>
<p>Click anywhere to begin</p>
</div>
</div>
<canvas id="nexus-canvas"></canvas>
<footer class="nexus-footer">
<a href="https://www.perplexity.ai/computer" target="_blank" rel="noopener noreferrer">
Created with Perplexity Computer
</a>
</footer>
<script type="module" src="./app.js"></script>
<!-- Live Refresh: polls Gitea for new commits on main, reloads when SHA changes -->
<div id="live-refresh-banner" style="
display:none; position:fixed; top:0; left:0; right:0; z-index:9999;
background:linear-gradient(90deg,#4af0c0,#7b5cff);
color:#050510; font-family:'JetBrains Mono',monospace; font-size:13px;
padding:8px 16px; text-align:center; font-weight:600;
">⚡ NEW DEPLOYMENT DETECTED — Reloading in <span id="lr-countdown">5</span>s…</div>
<script>
(function() {
const GITEA = 'http://143.198.27.163:3000/api/v1';
const REPO = 'Timmy_Foundation/the-nexus';
const BRANCH = 'main';
const INTERVAL = 30000; // poll every 30s
let knownSha = null;
async function fetchLatestSha() {
try {
const r = await fetch(`${GITEA}/repos/${REPO}/branches/${BRANCH}`, { cache: 'no-store' });
if (!r.ok) return null;
const d = await r.json();
return d.commit && d.commit.id ? d.commit.id : null;
} catch (e) { return null; }
}
async function poll() {
const sha = await fetchLatestSha();
if (!sha) return;
if (knownSha === null) { knownSha = sha; return; }
if (sha !== knownSha) {
knownSha = sha;
const banner = document.getElementById('live-refresh-banner');
const countdown = document.getElementById('lr-countdown');
banner.style.display = 'block';
let t = 5;
const tick = setInterval(() => {
t--;
countdown.textContent = t;
if (t <= 0) { clearInterval(tick); location.reload(); }
}, 1000);
}
}
// Start polling after page is interactive
fetchLatestSha().then(sha => { knownSha = sha; });
setInterval(poll, INTERVAL);
})();
</script>
</body>
</html>

View File

@@ -14,7 +14,11 @@ from nexus.perception_adapter import (
)
from nexus.experience_store import ExperienceStore
from nexus.trajectory_logger import TrajectoryLogger
from nexus.nexus_think import NexusMind
try:
from nexus.nexus_think import NexusMind
except Exception:
NexusMind = None
__all__ = [
"ws_to_perception",

View File

@@ -0,0 +1,66 @@
"""Thin Evennia -> Nexus event normalization helpers."""
from __future__ import annotations
from datetime import datetime, timezone
def _ts(value: str | None = None) -> str:
return value or datetime.now(timezone.utc).isoformat()
def session_bound(hermes_session_id: str, evennia_account: str = "Timmy", evennia_character: str = "Timmy", timestamp: str | None = None) -> dict:
return {
"type": "evennia.session_bound",
"hermes_session_id": hermes_session_id,
"evennia_account": evennia_account,
"evennia_character": evennia_character,
"timestamp": _ts(timestamp),
}
def actor_located(actor_id: str, room_key: str, room_name: str | None = None, timestamp: str | None = None) -> dict:
return {
"type": "evennia.actor_located",
"actor_id": actor_id,
"room_id": room_key,
"room_key": room_key,
"room_name": room_name or room_key,
"timestamp": _ts(timestamp),
}
def room_snapshot(room_key: str, title: str, desc: str, exits: list[dict] | None = None, objects: list[dict] | None = None, occupants: list[dict] | None = None, timestamp: str | None = None) -> dict:
return {
"type": "evennia.room_snapshot",
"room_id": room_key,
"room_key": room_key,
"title": title,
"desc": desc,
"exits": exits or [],
"objects": objects or [],
"occupants": occupants or [],
"timestamp": _ts(timestamp),
}
def command_issued(hermes_session_id: str, actor_id: str, command_text: str, timestamp: str | None = None) -> dict:
return {
"type": "evennia.command_issued",
"hermes_session_id": hermes_session_id,
"actor_id": actor_id,
"command_text": command_text,
"timestamp": _ts(timestamp),
}
def command_result(hermes_session_id: str, actor_id: str, command_text: str, output_text: str, success: bool = True, timestamp: str | None = None) -> dict:
return {
"type": "evennia.command_result",
"hermes_session_id": hermes_session_id,
"actor_id": actor_id,
"command_text": command_text,
"output_text": output_text,
"success": success,
"timestamp": _ts(timestamp),
}

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Publish Evennia telemetry logs into the Nexus websocket bridge."""
from __future__ import annotations
import argparse
import asyncio
import json
import re
from pathlib import Path
from typing import Iterable
import websockets
from nexus.evennia_event_adapter import actor_located, command_issued, command_result, room_snapshot, session_bound
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
def strip_ansi(text: str) -> str:
return ANSI_RE.sub("", text or "")
def clean_lines(text: str) -> list[str]:
text = strip_ansi(text).replace("\r", "")
return [line.strip() for line in text.split("\n") if line.strip()]
def parse_room_output(text: str):
lines = clean_lines(text)
if len(lines) < 2:
return None
title = lines[0]
desc = lines[1]
exits = []
objects = []
for line in lines[2:]:
if line.startswith("Exits:"):
raw = line.split(":", 1)[1].strip()
raw = raw.replace(" and ", ", ")
exits = [{"key": token.strip(), "destination_id": token.strip().title(), "destination_key": token.strip().title()} for token in raw.split(",") if token.strip()]
elif line.startswith("You see:"):
raw = line.split(":", 1)[1].strip()
raw = raw.replace(" and ", ", ")
parts = [token.strip() for token in raw.split(",") if token.strip()]
objects = [{"id": p.removeprefix('a ').removeprefix('an '), "key": p.removeprefix('a ').removeprefix('an '), "short_desc": p} for p in parts]
return {"title": title, "desc": desc, "exits": exits, "objects": objects}
def normalize_event(raw: dict, hermes_session_id: str) -> list[dict]:
out: list[dict] = []
event = raw.get("event")
actor = raw.get("actor", "Timmy")
timestamp = raw.get("timestamp")
if event == "connect":
out.append(session_bound(hermes_session_id, evennia_account=actor, evennia_character=actor, timestamp=timestamp))
parsed = parse_room_output(raw.get("output", ""))
if parsed:
out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp))
out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp))
return out
if event == "command":
cmd = raw.get("command", "")
output = raw.get("output", "")
out.append(command_issued(hermes_session_id, actor, cmd, timestamp=timestamp))
success = not output.startswith("Command '") and not output.startswith("Could not find")
out.append(command_result(hermes_session_id, actor, cmd, strip_ansi(output), success=success, timestamp=timestamp))
parsed = parse_room_output(output)
if parsed:
out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp))
out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp))
return out
return out
async def playback(log_path: Path, ws_url: str):
hermes_session_id = log_path.stem
async with websockets.connect(ws_url) as ws:
for line in log_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
raw = json.loads(line)
for event in normalize_event(raw, hermes_session_id):
await ws.send(json.dumps(event))
def main():
parser = argparse.ArgumentParser(description="Publish Evennia telemetry into the Nexus websocket bridge")
parser.add_argument("log_path", help="Path to Evennia telemetry JSONL")
parser.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus websocket bridge URL")
args = parser.parse_args()
asyncio.run(playback(Path(args.log_path).expanduser(), args.ws))
if __name__ == "__main__":
main()

View File

@@ -199,6 +199,56 @@ def perceive_action_result(data: dict) -> Optional[Perception]:
)
def perceive_evennia_actor_located(data: dict) -> Optional[Perception]:
actor = data.get("actor_id", "Timmy")
room = data.get("room_name") or data.get("room_key") or data.get("room_id")
if not room:
return None
return Perception(
timestamp=time.time(),
raw_type="evennia.actor_located",
description=f"{actor} is now in {room}.",
salience=0.7,
)
def perceive_evennia_room_snapshot(data: dict) -> Optional[Perception]:
title = data.get("title") or data.get("room_key") or data.get("room_id")
desc = data.get("desc", "")
exits = ", ".join(exit.get("key", "") for exit in data.get("exits", []) if exit.get("key"))
objects = ", ".join(obj.get("key", "") for obj in data.get("objects", []) if obj.get("key"))
if not title:
return None
parts = [f"You are in {title}."]
if desc:
parts.append(desc)
if exits:
parts.append(f"Exits: {exits}.")
if objects:
parts.append(f"You see: {objects}.")
return Perception(
timestamp=time.time(),
raw_type="evennia.room_snapshot",
description=" ".join(parts),
salience=0.85,
)
def perceive_evennia_command_result(data: dict) -> Optional[Perception]:
success = data.get("success", True)
command = data.get("command_text", "your command")
output = data.get("output_text", "")
desc = f"Your world command {'succeeded' if success else 'failed'}: {command}."
if output:
desc += f" {output[:240]}"
return Perception(
timestamp=time.time(),
raw_type="evennia.command_result",
description=desc,
salience=0.8,
)
# Registry of WS type → perception function
PERCEPTION_MAP = {
"agent_state": perceive_agent_state,
@@ -212,6 +262,9 @@ PERCEPTION_MAP = {
"action_result": perceive_action_result,
"heartbeat": lambda _: None, # Ignore
"dual_brain": lambda _: None, # Internal — not part of sensorium
"evennia.actor_located": perceive_evennia_actor_located,
"evennia.room_snapshot": perceive_evennia_room_snapshot,
"evennia.command_result": perceive_evennia_command_result,
}

View File

@@ -17,7 +17,7 @@
"id": "bannerlord",
"name": "Bannerlord",
"description": "Calradia battle harness. Massive armies, tactical command.",
"status": "online",
"status": "standby",
"color": "#ffd700",
"position": { "x": -15, "y": 0, "z": -10 },
"rotation": { "y": 0.5 },
@@ -40,5 +40,61 @@
"type": "harness",
"params": { "mode": "creative" }
}
},
{
"id": "archive",
"name": "Archive",
"description": "The repository of all knowledge. History, logs, and ancient data.",
"status": "online",
"color": "#0066ff",
"position": { "x": 25, "y": 0, "z": 0 },
"rotation": { "y": -1.57 },
"destination": {
"url": "https://archive.timmy.foundation",
"type": "harness",
"params": { "mode": "read" }
}
},
{
"id": "chapel",
"name": "Chapel",
"description": "A sanctuary for reflection and digital peace.",
"status": "online",
"color": "#ffd700",
"position": { "x": -25, "y": 0, "z": 0 },
"rotation": { "y": 1.57 },
"destination": {
"url": "https://chapel.timmy.foundation",
"type": "harness",
"params": { "mode": "meditation" }
}
},
{
"id": "courtyard",
"name": "Courtyard",
"description": "The open nexus. A place for agents to gather and connect.",
"status": "online",
"color": "#4af0c0",
"position": { "x": 15, "y": 0, "z": 10 },
"rotation": { "y": -2.5 },
"destination": {
"url": "https://courtyard.timmy.foundation",
"type": "harness",
"params": { "mode": "social" }
}
},
{
"id": "gate",
"name": "Gate",
"description": "The transition point. Entry and exit from the Nexus core.",
"status": "standby",
"color": "#ff4466",
"position": { "x": -15, "y": 0, "z": 10 },
"rotation": { "y": 2.5 },
"destination": {
"url": "https://gate.timmy.foundation",
"type": "harness",
"params": { "mode": "transit" }
}
}
]

284
public/nexus/app.js Normal file
View File

@@ -0,0 +1,284 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Cookie check</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
color-scheme: light dark;
}
body {
font-family: 'Inter', Helvetica, Arial, sans-serif;
background: light-dark(#F8F8F7, #191919);
color: light-dark(#1f1f1f, #e3e3e3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: 100vh;
margin: 0;
padding: 20px;
text-align: center;
}
.container {
background: light-dark(#FFFFFF, #1F1F1F);
padding: 32px;
border-radius: 16px;
border: 1px solid light-dark(#E2E3E4, #3E3E3E);
max-width: min(80%, 500px);
width: 100%;
color: light-dark(#2B2D31, #D4D4D4);
}
h1 {
font-size: 20px;
font-weight: 500;
margin-top: 1rem;
margin-bottom: 1rem;
color: light-dark(#2B2D31, #D4D4D4);
}
p {
font-size: 14px;
color: light-dark(#2B2D31, #D4D4D4);
line-height: 21px;
margin: 0 0 1.5rem 0;
}
.icon {
margin-bottom: 1rem;
line-height: 0;
}
.button-container {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 2rem;
}
button {
background-color: light-dark(#fff, #323232);
color: light-dark(#2B2D31, #FCFCFC);
border: 1px solid light-dark(#E2E3E4, #3E3E3E);
border-radius: 12px;
padding: 8px 12px;
font-size: 14px;
line-height: 21px;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 400;
font-family: 'Inter', Helvetica, Arial, sans-serif;
width: 100%;
}
button:hover {
background-color: light-dark(#EAEAEB, #424242);
}
.hidden {
display: none;
}
/* Loading Spinner Animation */
.spinner {
margin: 0 auto 1.5rem auto;
width: 40px;
height: 40px;
border: 4px solid light-dark(#f0f0f0, #262626);
border-top: 4px solid light-dark(#076eff, #87a9ff); /* Blue color */
border-radius: 50%;
animation: spin 1s linear infinite;
}
.logo {
border-radius: 10px;
display: block;
margin: 0 auto 2rem auto;
}
.logo.hidden {
display: none;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="container">
<img
class="logo"
src="https://www.gstatic.com/aistudio/ai_studio_favicon_2_256x256.png"
alt="AI Studio Logo"
width="256"
height="256"
/>
<div class="spinner"></div>
<div id="error-ui" class="hidden">
<div class="icon">
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="48px"
height="48px"
fill="#D73A49"
>
<path
d="M12,2C6.486,2,2,6.486,2,12s4.486,10,10,10s10-4.486,10-10S17.514,2,12,2z M13,17h-2v-2h2V17z M13,13h-2V7h2V13z"
/>
</svg>
</div>
<div id="stepOne" class="text-container">
<h1>Action required to load your app</h1>
<p>
It looks like your browser is blocking a required security cookie, which is common on
older versions of iOS and Safari.
</p>
<div class="button-container">
<button id="authInSeparateWindowButton" onclick="redirectToReturnUrl(true)">Authenticate in new window</button>
</div>
</div>
<div id="stepTwo" class="text-container hidden">
<h1>Action required to load your app</h1>
<p>
It looks like your browser is blocking a required security cookie, which is common on
older versions of iOS and Safari.
</p>
<div class="button-container">
<button id="interactButton" onclick="redirectToReturnUrl(false)">Close and continue</button>
</div>
</div>
<div id="stepThree" class="text-container hidden">
<h1>Almost there!</h1>
<p>
Grant permission for the required security cookie below.
</p>
<div class="button-container">
<button id="grantPermissionButton" onclick="grantStorageAccess()">Grant permission</button>
</div>
</div>
</div>
</div>
<script>
const AUTH_FLOW_TEST_COOKIE_NAME = '__SECURE-aistudio_auth_flow_may_set_cookies';
const COOKIE_VALUE = 'true';
function getCookie(name) {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
let cookie = cookies[i].trim();
if (cookie.startsWith(name + '=')) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function setAuthFlowTestCookie() {
// Set the cookie's TTL to 1 minute. This is a short lived cookie because it is only used
// when the user does not have an auth token or their auth token needs to be reset.
// Making this cookie too long-lived allows the user to get into a state where they can't
// mint a new auth token.
document.cookie = `${AUTH_FLOW_TEST_COOKIE_NAME}=${COOKIE_VALUE}; Path=/; Secure; SameSite=None; Domain=${window.location.hostname}; Partitioned; Max-Age=60;`;
}
/**
* Returns true if the test cookie is set, false otherwise.
*/
function authFlowTestCookieIsSet() {
return getCookie(AUTH_FLOW_TEST_COOKIE_NAME) === COOKIE_VALUE;
}
/**
* Redirects to the return url. If autoClose is true, then the return url will be opened in a
* new window, and it will be closed automatically when the page loads.
*/
async function redirectToReturnUrl(autoClose) {
const initialReturnUrlStr = new URLSearchParams(window.location.search).get('return_url');
const returnUrl = initialReturnUrlStr ? new URL(initialReturnUrlStr) : null;
// Prevent potentially malicious URLs from being used
if (returnUrl.protocol.toLowerCase() === 'javascript:') {
console.error('Potentially malicious return URL blocked');
return;
}
if (autoClose) {
returnUrl.searchParams.set('__auto_close', '1');
const url = new URL(window.location.href);
url.searchParams.set('return_url', returnUrl.toString());
// Land on the cookie check page first, so the user can interact with it before proceeding
// to the return url where cookies can be set.
window.open(url.toString(), '_blank');
const hasAccess = await document.hasStorageAccess();
document.querySelector('#stepOne').classList.add('hidden');
if (!hasAccess) {
document.querySelector('#stepThree').classList.remove('hidden');
} else {
window.location.reload();
}
} else {
window.location.href = returnUrl.toString();
}
}
/**
* Grants the browser permission to set cookies. If successful, then it redirects to the
* return url.
*/
async function grantStorageAccess() {
try {
await document.requestStorageAccess();
redirectToReturnUrl(false);
} catch (err) {
console.log('error after button click: ', err);
}
}
/**
* Verifies that the browser can set cookies. If it can, then it redirects to the return url.
* If it can't, then it shows the error UI.
*/
function verifyCanSetCookies() {
setAuthFlowTestCookie();
if (authFlowTestCookieIsSet()) {
// Check if we are on the auto-close flow, and if so show the interact button.
const returnUrl = new URLSearchParams(window.location.search).get('return_url');
const autoClose = new URL(returnUrl).searchParams.has('__auto_close');
if (autoClose) {
document.querySelector('#stepOne').classList.add('hidden');
document.querySelector('#stepTwo').classList.remove('hidden');
} else {
redirectToReturnUrl(false);
return;
}
}
// The cookie could not be set, so initiate the recovery flow.
document.querySelector('.logo').classList.add('hidden');
document.querySelector('.spinner').classList.add('hidden');
document.querySelector('#error-ui').classList.remove('hidden');
}
// Start the cookie verification process.
verifyCanSetCookies();
</script>
</body>
</html>

284
public/nexus/index.html Normal file
View File

@@ -0,0 +1,284 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Cookie check</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
color-scheme: light dark;
}
body {
font-family: 'Inter', Helvetica, Arial, sans-serif;
background: light-dark(#F8F8F7, #191919);
color: light-dark(#1f1f1f, #e3e3e3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: 100vh;
margin: 0;
padding: 20px;
text-align: center;
}
.container {
background: light-dark(#FFFFFF, #1F1F1F);
padding: 32px;
border-radius: 16px;
border: 1px solid light-dark(#E2E3E4, #3E3E3E);
max-width: min(80%, 500px);
width: 100%;
color: light-dark(#2B2D31, #D4D4D4);
}
h1 {
font-size: 20px;
font-weight: 500;
margin-top: 1rem;
margin-bottom: 1rem;
color: light-dark(#2B2D31, #D4D4D4);
}
p {
font-size: 14px;
color: light-dark(#2B2D31, #D4D4D4);
line-height: 21px;
margin: 0 0 1.5rem 0;
}
.icon {
margin-bottom: 1rem;
line-height: 0;
}
.button-container {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 2rem;
}
button {
background-color: light-dark(#fff, #323232);
color: light-dark(#2B2D31, #FCFCFC);
border: 1px solid light-dark(#E2E3E4, #3E3E3E);
border-radius: 12px;
padding: 8px 12px;
font-size: 14px;
line-height: 21px;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 400;
font-family: 'Inter', Helvetica, Arial, sans-serif;
width: 100%;
}
button:hover {
background-color: light-dark(#EAEAEB, #424242);
}
.hidden {
display: none;
}
/* Loading Spinner Animation */
.spinner {
margin: 0 auto 1.5rem auto;
width: 40px;
height: 40px;
border: 4px solid light-dark(#f0f0f0, #262626);
border-top: 4px solid light-dark(#076eff, #87a9ff); /* Blue color */
border-radius: 50%;
animation: spin 1s linear infinite;
}
.logo {
border-radius: 10px;
display: block;
margin: 0 auto 2rem auto;
}
.logo.hidden {
display: none;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="container">
<img
class="logo"
src="https://www.gstatic.com/aistudio/ai_studio_favicon_2_256x256.png"
alt="AI Studio Logo"
width="256"
height="256"
/>
<div class="spinner"></div>
<div id="error-ui" class="hidden">
<div class="icon">
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="48px"
height="48px"
fill="#D73A49"
>
<path
d="M12,2C6.486,2,2,6.486,2,12s4.486,10,10,10s10-4.486,10-10S17.514,2,12,2z M13,17h-2v-2h2V17z M13,13h-2V7h2V13z"
/>
</svg>
</div>
<div id="stepOne" class="text-container">
<h1>Action required to load your app</h1>
<p>
It looks like your browser is blocking a required security cookie, which is common on
older versions of iOS and Safari.
</p>
<div class="button-container">
<button id="authInSeparateWindowButton" onclick="redirectToReturnUrl(true)">Authenticate in new window</button>
</div>
</div>
<div id="stepTwo" class="text-container hidden">
<h1>Action required to load your app</h1>
<p>
It looks like your browser is blocking a required security cookie, which is common on
older versions of iOS and Safari.
</p>
<div class="button-container">
<button id="interactButton" onclick="redirectToReturnUrl(false)">Close and continue</button>
</div>
</div>
<div id="stepThree" class="text-container hidden">
<h1>Almost there!</h1>
<p>
Grant permission for the required security cookie below.
</p>
<div class="button-container">
<button id="grantPermissionButton" onclick="grantStorageAccess()">Grant permission</button>
</div>
</div>
</div>
</div>
<script>
const AUTH_FLOW_TEST_COOKIE_NAME = '__SECURE-aistudio_auth_flow_may_set_cookies';
const COOKIE_VALUE = 'true';
function getCookie(name) {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
let cookie = cookies[i].trim();
if (cookie.startsWith(name + '=')) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function setAuthFlowTestCookie() {
// Set the cookie's TTL to 1 minute. This is a short lived cookie because it is only used
// when the user does not have an auth token or their auth token needs to be reset.
// Making this cookie too long-lived allows the user to get into a state where they can't
// mint a new auth token.
document.cookie = `${AUTH_FLOW_TEST_COOKIE_NAME}=${COOKIE_VALUE}; Path=/; Secure; SameSite=None; Domain=${window.location.hostname}; Partitioned; Max-Age=60;`;
}
/**
* Returns true if the test cookie is set, false otherwise.
*/
function authFlowTestCookieIsSet() {
return getCookie(AUTH_FLOW_TEST_COOKIE_NAME) === COOKIE_VALUE;
}
/**
* Redirects to the return url. If autoClose is true, then the return url will be opened in a
* new window, and it will be closed automatically when the page loads.
*/
async function redirectToReturnUrl(autoClose) {
const initialReturnUrlStr = new URLSearchParams(window.location.search).get('return_url');
const returnUrl = initialReturnUrlStr ? new URL(initialReturnUrlStr) : null;
// Prevent potentially malicious URLs from being used
if (returnUrl.protocol.toLowerCase() === 'javascript:') {
console.error('Potentially malicious return URL blocked');
return;
}
if (autoClose) {
returnUrl.searchParams.set('__auto_close', '1');
const url = new URL(window.location.href);
url.searchParams.set('return_url', returnUrl.toString());
// Land on the cookie check page first, so the user can interact with it before proceeding
// to the return url where cookies can be set.
window.open(url.toString(), '_blank');
const hasAccess = await document.hasStorageAccess();
document.querySelector('#stepOne').classList.add('hidden');
if (!hasAccess) {
document.querySelector('#stepThree').classList.remove('hidden');
} else {
window.location.reload();
}
} else {
window.location.href = returnUrl.toString();
}
}
/**
* Grants the browser permission to set cookies. If successful, then it redirects to the
* return url.
*/
async function grantStorageAccess() {
try {
await document.requestStorageAccess();
redirectToReturnUrl(false);
} catch (err) {
console.log('error after button click: ', err);
}
}
/**
* Verifies that the browser can set cookies. If it can, then it redirects to the return url.
* If it can't, then it shows the error UI.
*/
function verifyCanSetCookies() {
setAuthFlowTestCookie();
if (authFlowTestCookieIsSet()) {
// Check if we are on the auto-close flow, and if so show the interact button.
const returnUrl = new URLSearchParams(window.location.search).get('return_url');
const autoClose = new URL(returnUrl).searchParams.has('__auto_close');
if (autoClose) {
document.querySelector('#stepOne').classList.add('hidden');
document.querySelector('#stepTwo').classList.remove('hidden');
} else {
redirectToReturnUrl(false);
return;
}
}
// The cookie could not be set, so initiate the recovery flow.
document.querySelector('.logo').classList.add('hidden');
document.querySelector('.spinner').classList.add('hidden');
document.querySelector('#error-ui').classList.remove('hidden');
}
// Start the cookie verification process.
verifyCanSetCookies();
</script>
</body>
</html>

284
public/nexus/style.css Normal file
View File

@@ -0,0 +1,284 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Cookie check</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
color-scheme: light dark;
}
body {
font-family: 'Inter', Helvetica, Arial, sans-serif;
background: light-dark(#F8F8F7, #191919);
color: light-dark(#1f1f1f, #e3e3e3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: 100vh;
margin: 0;
padding: 20px;
text-align: center;
}
.container {
background: light-dark(#FFFFFF, #1F1F1F);
padding: 32px;
border-radius: 16px;
border: 1px solid light-dark(#E2E3E4, #3E3E3E);
max-width: min(80%, 500px);
width: 100%;
color: light-dark(#2B2D31, #D4D4D4);
}
h1 {
font-size: 20px;
font-weight: 500;
margin-top: 1rem;
margin-bottom: 1rem;
color: light-dark(#2B2D31, #D4D4D4);
}
p {
font-size: 14px;
color: light-dark(#2B2D31, #D4D4D4);
line-height: 21px;
margin: 0 0 1.5rem 0;
}
.icon {
margin-bottom: 1rem;
line-height: 0;
}
.button-container {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 2rem;
}
button {
background-color: light-dark(#fff, #323232);
color: light-dark(#2B2D31, #FCFCFC);
border: 1px solid light-dark(#E2E3E4, #3E3E3E);
border-radius: 12px;
padding: 8px 12px;
font-size: 14px;
line-height: 21px;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 400;
font-family: 'Inter', Helvetica, Arial, sans-serif;
width: 100%;
}
button:hover {
background-color: light-dark(#EAEAEB, #424242);
}
.hidden {
display: none;
}
/* Loading Spinner Animation */
.spinner {
margin: 0 auto 1.5rem auto;
width: 40px;
height: 40px;
border: 4px solid light-dark(#f0f0f0, #262626);
border-top: 4px solid light-dark(#076eff, #87a9ff); /* Blue color */
border-radius: 50%;
animation: spin 1s linear infinite;
}
.logo {
border-radius: 10px;
display: block;
margin: 0 auto 2rem auto;
}
.logo.hidden {
display: none;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="container">
<img
class="logo"
src="https://www.gstatic.com/aistudio/ai_studio_favicon_2_256x256.png"
alt="AI Studio Logo"
width="256"
height="256"
/>
<div class="spinner"></div>
<div id="error-ui" class="hidden">
<div class="icon">
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="48px"
height="48px"
fill="#D73A49"
>
<path
d="M12,2C6.486,2,2,6.486,2,12s4.486,10,10,10s10-4.486,10-10S17.514,2,12,2z M13,17h-2v-2h2V17z M13,13h-2V7h2V13z"
/>
</svg>
</div>
<div id="stepOne" class="text-container">
<h1>Action required to load your app</h1>
<p>
It looks like your browser is blocking a required security cookie, which is common on
older versions of iOS and Safari.
</p>
<div class="button-container">
<button id="authInSeparateWindowButton" onclick="redirectToReturnUrl(true)">Authenticate in new window</button>
</div>
</div>
<div id="stepTwo" class="text-container hidden">
<h1>Action required to load your app</h1>
<p>
It looks like your browser is blocking a required security cookie, which is common on
older versions of iOS and Safari.
</p>
<div class="button-container">
<button id="interactButton" onclick="redirectToReturnUrl(false)">Close and continue</button>
</div>
</div>
<div id="stepThree" class="text-container hidden">
<h1>Almost there!</h1>
<p>
Grant permission for the required security cookie below.
</p>
<div class="button-container">
<button id="grantPermissionButton" onclick="grantStorageAccess()">Grant permission</button>
</div>
</div>
</div>
</div>
<script>
const AUTH_FLOW_TEST_COOKIE_NAME = '__SECURE-aistudio_auth_flow_may_set_cookies';
const COOKIE_VALUE = 'true';
function getCookie(name) {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
let cookie = cookies[i].trim();
if (cookie.startsWith(name + '=')) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function setAuthFlowTestCookie() {
// Set the cookie's TTL to 1 minute. This is a short lived cookie because it is only used
// when the user does not have an auth token or their auth token needs to be reset.
// Making this cookie too long-lived allows the user to get into a state where they can't
// mint a new auth token.
document.cookie = `${AUTH_FLOW_TEST_COOKIE_NAME}=${COOKIE_VALUE}; Path=/; Secure; SameSite=None; Domain=${window.location.hostname}; Partitioned; Max-Age=60;`;
}
/**
* Returns true if the test cookie is set, false otherwise.
*/
function authFlowTestCookieIsSet() {
return getCookie(AUTH_FLOW_TEST_COOKIE_NAME) === COOKIE_VALUE;
}
/**
* Redirects to the return url. If autoClose is true, then the return url will be opened in a
* new window, and it will be closed automatically when the page loads.
*/
async function redirectToReturnUrl(autoClose) {
const initialReturnUrlStr = new URLSearchParams(window.location.search).get('return_url');
const returnUrl = initialReturnUrlStr ? new URL(initialReturnUrlStr) : null;
// Prevent potentially malicious URLs from being used
if (returnUrl.protocol.toLowerCase() === 'javascript:') {
console.error('Potentially malicious return URL blocked');
return;
}
if (autoClose) {
returnUrl.searchParams.set('__auto_close', '1');
const url = new URL(window.location.href);
url.searchParams.set('return_url', returnUrl.toString());
// Land on the cookie check page first, so the user can interact with it before proceeding
// to the return url where cookies can be set.
window.open(url.toString(), '_blank');
const hasAccess = await document.hasStorageAccess();
document.querySelector('#stepOne').classList.add('hidden');
if (!hasAccess) {
document.querySelector('#stepThree').classList.remove('hidden');
} else {
window.location.reload();
}
} else {
window.location.href = returnUrl.toString();
}
}
/**
* Grants the browser permission to set cookies. If successful, then it redirects to the
* return url.
*/
async function grantStorageAccess() {
try {
await document.requestStorageAccess();
redirectToReturnUrl(false);
} catch (err) {
console.log('error after button click: ', err);
}
}
/**
* Verifies that the browser can set cookies. If it can, then it redirects to the return url.
* If it can't, then it shows the error UI.
*/
function verifyCanSetCookies() {
setAuthFlowTestCookie();
if (authFlowTestCookieIsSet()) {
// Check if we are on the auto-close flow, and if so show the interact button.
const returnUrl = new URLSearchParams(window.location.search).get('return_url');
const autoClose = new URL(returnUrl).searchParams.has('__auto_close');
if (autoClose) {
document.querySelector('#stepOne').classList.add('hidden');
document.querySelector('#stepTwo').classList.remove('hidden');
} else {
redirectToReturnUrl(false);
return;
}
}
// The cookie could not be set, so initiate the recovery flow.
document.querySelector('.logo').classList.add('hidden');
document.querySelector('.spinner').classList.add('hidden');
document.querySelector('#error-ui').classList.remove('hidden');
}
// Start the cookie verification process.
verifyCanSetCookies();
</script>
</body>
</html>

979
style.css Normal file
View File

@@ -0,0 +1,979 @@
/* === NEXUS DESIGN SYSTEM === */
:root {
--font-display: 'Orbitron', sans-serif;
--font-body: 'JetBrains Mono', monospace;
--color-bg: #050510;
--color-surface: rgba(10, 15, 40, 0.85);
--color-border: rgba(74, 240, 192, 0.2);
--color-border-bright: rgba(74, 240, 192, 0.5);
--color-text: #e0f0ff;
--color-text-muted: #8a9ab8;
--color-text-bright: #ffffff;
--color-primary: #4af0c0;
--color-primary-dim: rgba(74, 240, 192, 0.3);
--color-secondary: #7b5cff;
--color-danger: #ff4466;
--color-warning: #ffaa22;
--color-gold: #ffd700;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 15px;
--text-lg: 18px;
--text-xl: 24px;
--text-2xl: 36px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--panel-blur: 16px;
--panel-radius: 8px;
--transition-ui: 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--color-bg);
font-family: var(--font-body);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
}
canvas#nexus-canvas {
display: block;
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
}
/* === LOADING SCREEN === */
#loading-screen {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.8s ease;
}
#loading-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.loader-content {
text-align: center;
}
.loader-sigil {
margin-bottom: var(--space-6);
}
.loader-title {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 700;
letter-spacing: 0.3em;
color: var(--color-primary);
text-shadow: 0 0 30px rgba(74, 240, 192, 0.4);
margin-bottom: var(--space-2);
}
.loader-subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
letter-spacing: 0.1em;
margin-bottom: var(--space-6);
}
.loader-bar {
width: 200px;
height: 2px;
background: rgba(74, 240, 192, 0.15);
border-radius: 1px;
margin: 0 auto;
overflow: hidden;
}
.loader-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
border-radius: 1px;
transition: width 0.3s ease;
}
/* === ENTER PROMPT === */
#enter-prompt {
position: fixed;
inset: 0;
z-index: 500;
background: rgba(5, 5, 16, 0.7);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.5s ease;
}
#enter-prompt.fade-out {
opacity: 0;
pointer-events: none;
}
.enter-content {
text-align: center;
}
.enter-content h2 {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-primary);
letter-spacing: 0.2em;
text-shadow: 0 0 20px rgba(74, 240, 192, 0.3);
margin-bottom: var(--space-2);
}
.enter-content p {
font-size: var(--text-sm);
color: var(--color-text-muted);
animation: pulse-text 2s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
/* === GAME UI (HUD) === */
.game-ui {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
font-family: var(--font-body);
color: var(--color-text);
}
.game-ui button, .game-ui input, .game-ui [data-interactive] {
pointer-events: auto;
}
/* Top Right Container */
.hud-top-right {
position: absolute;
top: var(--space-3);
right: var(--space-3);
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-3);
pointer-events: none;
}
.hud-top-right > * {
pointer-events: auto;
}
.hud-icon-btn {
background: rgba(10, 15, 40, 0.7);
border: 1px solid var(--color-primary);
color: var(--color-primary);
padding: 8px 12px;
font-family: var(--font-display);
font-size: 11px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all var(--transition-ui);
backdrop-filter: blur(5px);
box-shadow: 0 0 10px rgba(74, 240, 192, 0.2);
letter-spacing: 0.1em;
}
.hud-icon-btn:hover {
background: var(--color-primary);
color: var(--color-bg);
box-shadow: 0 0 20px var(--color-primary);
}
.hud-status-item {
display: flex;
align-items: center;
gap: 8px;
background: rgba(0, 0, 0, 0.5);
padding: 4px 12px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: var(--font-body);
font-size: 10px;
letter-spacing: 0.1em;
color: var(--color-text-muted);
margin-bottom: 8px;
pointer-events: auto;
}
.hud-status-item .status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-danger);
}
.hud-status-item.online .status-dot {
background: var(--color-primary);
box-shadow: 0 0 5px var(--color-primary);
}
.hud-status-item.standby .status-dot {
background: var(--color-gold);
box-shadow: 0 0 5px var(--color-gold);
}
.hud-status-item.online .status-label {
color: #fff;
}
.hud-icon {
font-size: 16px;
}
/* Portal Atlas Overlay */
.atlas-overlay {
position: fixed;
inset: 0;
background: rgba(5, 5, 16, 0.9);
backdrop-filter: blur(15px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
pointer-events: auto;
animation: fadeIn 0.3s ease;
}
.atlas-content {
width: 100%;
max-width: 1000px;
max-height: 80vh;
background: var(--color-surface);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5);
}
.atlas-header {
padding: 20px 30px;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.atlas-title {
display: flex;
align-items: center;
gap: 15px;
}
.atlas-title h2 {
margin: 0;
font-family: var(--font-display);
letter-spacing: 2px;
color: var(--color-primary);
font-size: var(--text-lg);
}
.atlas-close-btn {
background: transparent;
border: 1px solid var(--color-danger);
color: var(--color-danger);
padding: 6px 15px;
font-family: var(--font-display);
font-size: 11px;
cursor: pointer;
transition: all var(--transition-ui);
}
.atlas-close-btn:hover {
background: var(--color-danger);
color: white;
}
.atlas-grid {
flex: 1;
overflow-y: auto;
padding: 30px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.atlas-card {
background: rgba(20, 30, 60, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.atlas-card:hover {
background: rgba(30, 45, 90, 0.6);
border-color: var(--color-primary);
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.atlas-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--portal-color, var(--color-primary));
opacity: 0.6;
}
.atlas-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.atlas-card-name {
font-family: var(--font-display);
font-size: 16px;
font-weight: 700;
color: #fff;
}
.atlas-card-status {
font-family: var(--font-body);
font-size: 10px;
padding: 2px 6px;
border-radius: 2px;
text-transform: uppercase;
}
.status-online { background: rgba(74, 240, 192, 0.2); color: var(--color-primary); border: 1px solid var(--color-primary); }
.status-standby { background: rgba(255, 215, 0, 0.2); color: var(--color-gold); border: 1px solid var(--color-gold); }
.status-offline { background: rgba(255, 68, 102, 0.2); color: var(--color-danger); border: 1px solid var(--color-danger); }
.atlas-card-desc {
font-size: 12px;
color: var(--color-text-muted);
line-height: 1.5;
margin-bottom: 15px;
}
.atlas-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-body);
font-size: 10px;
color: rgba(160, 184, 208, 0.6);
}
.atlas-footer {
padding: 15px 30px;
border-top: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-body);
font-size: 11px;
}
.status-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.status-indicator.online { background: var(--color-primary); box-shadow: 0 0 5px var(--color-primary); }
.status-indicator.standby { background: var(--color-gold); box-shadow: 0 0 5px var(--color-gold); }
.atlas-hint {
color: rgba(160, 184, 208, 0.5);
font-style: italic;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Responsive Atlas */
@media (max-width: 768px) {
.atlas-grid {
grid-template-columns: 1fr;
}
.atlas-content {
max-height: 90vh;
}
}
/* Debug overlay */
.hud-debug {
position: absolute;
top: var(--space-3);
left: var(--space-3);
background: rgba(0, 0, 0, 0.7);
color: #0f0;
font-size: var(--text-xs);
line-height: 1.5;
padding: var(--space-2) var(--space-3);
border-radius: 4px;
white-space: pre;
pointer-events: none;
font-variant-numeric: tabular-nums lining-nums;
}
/* Location indicator */
.hud-location {
position: absolute;
top: var(--space-3);
left: 50%;
transform: translateX(-50%);
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.15em;
color: var(--color-primary);
text-shadow: 0 0 10px rgba(74, 240, 192, 0.3);
display: flex;
align-items: center;
gap: var(--space-2);
}
.hud-location-icon {
font-size: 16px;
animation: spin-slow 10s linear infinite;
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Controls hint */
.hud-controls {
position: absolute;
bottom: var(--space-3);
left: var(--space-3);
font-size: var(--text-xs);
color: var(--color-text-muted);
pointer-events: none;
}
.hud-controls span {
color: var(--color-primary);
font-weight: 600;
}
#nav-mode-label {
color: var(--color-gold);
font-weight: 700;
letter-spacing: 0.05em;
}
/* Portal Hint */
.portal-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 100px);
display: flex;
align-items: center;
gap: var(--space-2);
background: rgba(0, 0, 0, 0.8);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-primary);
border-radius: 4px;
animation: hint-float 2s ease-in-out infinite;
}
@keyframes hint-float {
0%, 100% { transform: translate(-50%, 100px); }
50% { transform: translate(-50%, 90px); }
}
.portal-hint-key {
background: var(--color-primary);
color: var(--color-bg);
font-weight: 700;
padding: 2px 8px;
border-radius: 2px;
}
.portal-hint-text {
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.05em;
}
#portal-hint-name {
color: var(--color-primary);
font-weight: 700;
}
/* Agent Log HUD */
.hud-agent-log {
position: absolute;
top: var(--space-3);
right: var(--space-3);
width: 280px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
border-left: 2px solid var(--color-primary);
padding: var(--space-3);
font-size: 10px;
pointer-events: none;
}
.agent-log-header {
font-family: var(--font-display);
color: var(--color-primary);
letter-spacing: 0.1em;
margin-bottom: var(--space-2);
opacity: 0.8;
}
.agent-log-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-log-entry {
animation: log-fade-in 0.5s ease-out forwards;
opacity: 0;
}
@keyframes log-fade-in {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(0); }
}
.agent-log-tag {
font-weight: 700;
margin-right: 4px;
}
.tag-timmy { color: var(--color-primary); }
.tag-kimi { color: var(--color-secondary); }
.tag-claude { color: var(--color-gold); }
.tag-perplexity { color: #4488ff; }
.agent-log-text {
color: var(--color-text-muted);
}
/* Vision Hint */
.vision-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 140px);
display: flex;
align-items: center;
gap: var(--space-2);
background: rgba(0, 0, 0, 0.8);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gold);
border-radius: 4px;
animation: hint-float-vision 2s ease-in-out infinite;
}
@keyframes hint-float-vision {
0%, 100% { transform: translate(-50%, 140px); }
50% { transform: translate(-50%, 130px); }
}
.vision-hint-key {
background: var(--color-gold);
color: var(--color-bg);
font-weight: 700;
padding: 2px 8px;
border-radius: 2px;
}
.vision-hint-text {
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.05em;
}
#vision-hint-title {
color: var(--color-gold);
font-weight: 700;
}
/* Vision Overlay */
.vision-overlay {
position: fixed;
inset: 0;
background: rgba(5, 5, 16, 0.9);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 1000;
}
.vision-overlay-content {
width: 100%;
max-width: 600px;
text-align: center;
padding: var(--space-8);
border: 1px solid var(--color-gold);
border-radius: var(--panel-radius);
background: var(--color-surface);
backdrop-filter: blur(var(--panel-blur));
}
.vision-overlay-header {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.vision-overlay-status {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-gold);
box-shadow: 0 0 10px var(--color-gold);
}
.vision-overlay-title {
font-family: var(--font-display);
font-size: var(--text-sm);
letter-spacing: 0.2em;
color: var(--color-gold);
}
.vision-overlay-content h2 {
font-family: var(--font-display);
font-size: var(--text-2xl);
margin-bottom: var(--space-4);
letter-spacing: 0.1em;
color: var(--color-text-bright);
}
.vision-overlay-content p {
color: var(--color-text);
font-size: var(--text-lg);
line-height: 1.8;
margin-bottom: var(--space-8);
font-style: italic;
}
.vision-close-btn {
background: var(--color-gold);
color: var(--color-bg);
border: none;
padding: var(--space-2) var(--space-8);
border-radius: 4px;
font-family: var(--font-display);
font-weight: 700;
cursor: pointer;
transition: transform 0.2s ease;
}
.vision-close-btn:hover {
transform: scale(1.05);
}
/* Portal Activation Overlay */
.portal-overlay {
position: fixed;
inset: 0;
background: rgba(5, 5, 16, 0.95);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 1000;
}
.portal-overlay-content {
width: 100%;
max-width: 500px;
text-align: center;
padding: var(--space-8);
}
.portal-overlay-header {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.portal-overlay-status {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 10px var(--color-primary);
}
.portal-overlay-title {
font-family: var(--font-display);
font-size: var(--text-sm);
letter-spacing: 0.2em;
color: var(--color-primary);
}
.portal-overlay-content h2 {
font-family: var(--font-display);
font-size: var(--text-2xl);
margin-bottom: var(--space-4);
letter-spacing: 0.1em;
}
.portal-overlay-content p {
color: var(--color-text-muted);
font-size: var(--text-base);
line-height: 1.6;
margin-bottom: var(--space-8);
}
.portal-redirect-box {
border: 1px solid var(--color-primary-dim);
padding: var(--space-6);
border-radius: var(--panel-radius);
}
.portal-redirect-label {
font-size: var(--text-xs);
letter-spacing: 0.2em;
margin-bottom: var(--space-2);
}
.portal-redirect-timer {
font-family: var(--font-display);
font-size: 48px;
font-weight: 700;
color: var(--color-primary);
}
.portal-error-box {
border: 1px solid var(--color-danger);
padding: var(--space-6);
border-radius: var(--panel-radius);
}
.portal-error-msg {
color: var(--color-danger);
font-weight: 700;
margin-bottom: var(--space-4);
}
.portal-close-btn {
background: var(--color-danger);
color: white;
border: none;
padding: var(--space-2) var(--space-6);
border-radius: 4px;
font-family: var(--font-display);
cursor: pointer;
}
/* === CHAT PANEL === */
.chat-panel {
position: absolute;
bottom: var(--space-4);
right: var(--space-4);
width: 380px;
max-height: 400px;
background: var(--color-surface);
backdrop-filter: blur(var(--panel-blur));
border: 1px solid var(--color-border);
border-radius: var(--panel-radius);
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: auto;
transition: max-height var(--transition-ui);
}
.chat-panel.collapsed {
max-height: 42px;
}
.chat-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
font-family: var(--font-display);
font-size: var(--text-xs);
letter-spacing: 0.1em;
font-weight: 500;
color: var(--color-text-bright);
cursor: pointer;
flex-shrink: 0;
}
.chat-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 6px var(--color-primary);
transition: all 0.3s ease;
}
@keyframes dot-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.chat-toggle-btn {
margin-left: auto;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 14px;
cursor: pointer;
transition: transform var(--transition-ui);
}
.chat-panel.collapsed .chat-toggle-btn {
transform: rotate(180deg);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 280px;
scrollbar-width: thin;
scrollbar-color: rgba(74,240,192,0.2) transparent;
}
.chat-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--color-border);
background: rgba(0, 0, 0, 0.3);
pointer-events: auto;
}
.quick-action-btn {
background: rgba(74, 240, 192, 0.1);
border: 1px solid var(--color-primary-dim);
color: var(--color-primary);
font-family: var(--font-body);
font-size: 10px;
padding: 4px 8px;
cursor: pointer;
transition: all var(--transition-ui);
white-space: nowrap;
}
.quick-action-btn:hover {
background: var(--color-primary-dim);
border-color: var(--color-primary);
color: #fff;
}
.chat-msg {
font-size: var(--text-xs);
line-height: 1.6;
padding: var(--space-1) 0;
}
.chat-msg-prefix {
font-weight: 700;
}
.chat-msg-kimi .chat-msg-prefix { color: var(--color-secondary); }
.chat-msg-claude .chat-msg-prefix { color: var(--color-gold); }
.chat-msg-perplexity .chat-msg-prefix { color: #4488ff; }
/* Tool Output Styling */
.chat-msg-tool {
background: rgba(0, 0, 0, 0.3);
border-left: 2px solid #ffd700;
font-size: 11px;
padding: 8px;
margin: 4px 0;
border-radius: 4px;
}
.tool-call { border-left-color: #ffd700; }
.tool-result { border-left-color: #4af0c0; }
.tool-content {
font-family: 'JetBrains Mono', monospace;
white-space: pre-wrap;
word-break: break-all;
opacity: 0.8;
margin: 4px 0 0 0;
color: #a0b8d0;
}
.chat-msg-system .chat-msg-prefix { color: var(--color-text-muted); }
.chat-msg-timmy .chat-msg-prefix { color: var(--color-primary); }
.chat-msg-user .chat-msg-prefix { color: var(--color-gold); }
.chat-msg-error .chat-msg-prefix { color: var(--color-danger); }
.chat-input-row {
display: flex;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.chat-input {
flex: 1;
background: transparent;
border: none;
padding: var(--space-3) var(--space-4);
font-family: var(--font-body);
font-size: var(--text-xs);
color: var(--color-text-bright);
outline: none;
}
.chat-input::placeholder {
color: var(--color-text-muted);
}
.chat-send-btn {
background: none;
border: none;
border-left: 1px solid var(--color-border);
padding: var(--space-3) var(--space-4);
color: var(--color-primary);
font-size: 16px;
cursor: pointer;
transition: background var(--transition-ui);
}
.chat-send-btn:hover {
background: rgba(74, 240, 192, 0.1);
}
/* === FOOTER === */
.nexus-footer {
position: fixed;
bottom: var(--space-1);
left: 50%;
transform: translateX(-50%);
z-index: 5;
font-size: 10px;
opacity: 0.3;
}
.nexus-footer a {
color: var(--color-text-muted);
text-decoration: none;
}
.nexus-footer a:hover {
color: var(--color-primary);
}
/* Mobile adjustments */
@media (max-width: 1024px) {
.chat-panel {
width: 320px;
}
.hud-agent-log {
width: 220px;
}
}
@media (max-width: 768px) {
.chat-panel {
width: 300px;
bottom: var(--space-2);
right: var(--space-2);
}
.hud-agent-log {
display: none;
}
.hud-location {
font-size: var(--text-xs);
}
}
@media (max-width: 480px) {
.chat-panel {
width: calc(100vw - 24px);
right: 12px;
bottom: 12px;
}
.hud-controls {
display: none;
}
.loader-title {
font-size: var(--text-xl);
}
}

View File

@@ -0,0 +1,56 @@
from nexus.evennia_event_adapter import actor_located, command_issued, command_result, room_snapshot, session_bound
from nexus.perception_adapter import ws_to_perception
def test_session_bound_schema():
event = session_bound("sess-1")
assert event["type"] == "evennia.session_bound"
assert event["hermes_session_id"] == "sess-1"
assert event["evennia_account"] == "Timmy"
def test_room_snapshot_schema():
event = room_snapshot(
room_key="Chapel",
title="Chapel",
desc="Quiet room.",
exits=[{"key": "courtyard", "destination_id": "Courtyard", "destination_key": "Courtyard"}],
objects=[{"id": "Book of the Soul", "key": "Book of the Soul", "short_desc": "A doctrinal anchor."}],
)
assert event["type"] == "evennia.room_snapshot"
assert event["title"] == "Chapel"
assert event["objects"][0]["key"] == "Book of the Soul"
def test_evennia_room_snapshot_becomes_perception():
perception = ws_to_perception(
room_snapshot(
room_key="Workshop",
title="Workshop",
desc="Tools everywhere.",
exits=[{"key": "courtyard", "destination_id": "Courtyard", "destination_key": "Courtyard"}],
objects=[{"id": "Workbench", "key": "Workbench", "short_desc": "A broad workbench."}],
)
)
assert perception is not None
assert "Workshop" in perception.description
assert "Workbench" in perception.description
def test_evennia_command_result_becomes_perception():
perception = ws_to_perception(command_result("sess-2", "Timmy", "look Book of the Soul", "Book of the Soul. A doctrinal anchor.", True))
assert perception is not None
assert "succeeded" in perception.description.lower()
assert "Book of the Soul" in perception.description
def test_evennia_actor_located_becomes_perception():
perception = ws_to_perception(actor_located("Timmy", "Gate"))
assert perception is not None
assert "Gate" in perception.description
def test_evennia_command_issued_schema():
event = command_issued("sess-3", "Timmy", "chapel")
assert event["type"] == "evennia.command_issued"
assert event["command_text"] == "chapel"

View File

@@ -0,0 +1,36 @@
from nexus.evennia_ws_bridge import clean_lines, normalize_event, parse_room_output, strip_ansi
def test_strip_ansi_removes_escape_codes():
assert strip_ansi('\x1b[1mGate\x1b[0m') == 'Gate'
def test_parse_room_output_extracts_room_exits_and_objects():
parsed = parse_room_output('\x1b[1mChapel\x1b[0m\nQuiet room.\nExits: courtyard\nYou see: a Book of the Soul and a Prayer Wall')
assert parsed['title'] == 'Chapel'
assert parsed['exits'][0]['key'] == 'courtyard'
keys = [obj['key'] for obj in parsed['objects']]
assert 'Book of the Soul' in keys
assert 'Prayer Wall' in keys
def test_normalize_connect_emits_session_and_room_events():
events = normalize_event({'event': 'connect', 'actor': 'Timmy', 'output': 'Gate\nA threshold.\nExits: enter'}, 'sess1')
types = [event['type'] for event in events]
assert 'evennia.session_bound' in types
assert 'evennia.actor_located' in types
assert 'evennia.room_snapshot' in types
def test_normalize_command_emits_command_and_snapshot():
events = normalize_event({'event': 'command', 'actor': 'timmy', 'command': 'courtyard', 'output': 'Courtyard\nOpen court.\nExits: gate, workshop\nYou see: a Map Table'}, 'sess2')
types = [event['type'] for event in events]
assert types[0] == 'evennia.command_issued'
assert 'evennia.command_result' in types
assert 'evennia.room_snapshot' in types
def test_normalize_failed_command_marks_failure():
events = normalize_event({'event': 'command', 'actor': 'timmy', 'command': 'workshop', 'output': "Command 'workshop' is not available."}, 'sess3')
result = [event for event in events if event['type'] == 'evennia.command_result'][0]
assert result['success'] is False

View File

@@ -0,0 +1,45 @@
import json
from pathlib import Path
REQUIRED_TOP_LEVEL_KEYS = {
"id",
"name",
"description",
"status",
"portal_type",
"world_category",
"environment",
"access_mode",
"readiness_state",
"telemetry_source",
"owner",
"destination",
}
REQUIRED_DESTINATION_KEYS = {"type", "action_label"}
def test_portals_json_uses_expanded_registry_schema() -> None:
portals = json.loads(Path("portals.json").read_text())
assert portals, "portals.json should define at least one portal"
for portal in portals:
assert REQUIRED_TOP_LEVEL_KEYS.issubset(portal.keys())
assert REQUIRED_DESTINATION_KEYS.issubset(portal["destination"].keys())
def test_gameportal_protocol_documents_new_metadata_fields_and_migration() -> None:
protocol = Path("GAMEPORTAL_PROTOCOL.md").read_text()
for term in [
"portal_type",
"world_category",
"environment",
"access_mode",
"readiness_state",
"telemetry_source",
"owner",
"Migration from legacy portal definitions",
]:
assert term in protocol