Compare commits
1 Commits
queue/1336
...
burn/1339-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3cef2e4dc |
78
.gitea/workflows/deploy-nginx.yml
Normal file
78
.gitea/workflows/deploy-nginx.yml
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Deploy Nexus (Nginx)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'index.html'
|
||||
- 'app.js'
|
||||
- 'style.css'
|
||||
- 'nginx.conf'
|
||||
- 'Dockerfile.nginx'
|
||||
- 'docker-compose.nginx.yml'
|
||||
- 'server.py'
|
||||
- 'requirements.txt'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preflight secrets check
|
||||
env:
|
||||
H: ${{ secrets.DEPLOY_HOST }}
|
||||
U: ${{ secrets.DEPLOY_USER }}
|
||||
K: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
if [ -z "$H" ] || [ -z "$U" ] || [ -z "$K" ]; then
|
||||
echo "ERROR: Missing deploy secret. Configure DEPLOY_HOST/DEPLOY_USER/DEPLOY_SSH_KEY in Settings → Actions → Secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy to VPS via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
echo "Deploying Nexus with nginx..."
|
||||
|
||||
# Clone or update repo
|
||||
if [ ! -d ~/the-nexus ]; then
|
||||
git clone http://143.198.27.163:3000/Timmy_Foundation/the-nexus.git ~/the-nexus
|
||||
fi
|
||||
|
||||
cd ~/the-nexus
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
|
||||
# Stop existing containers
|
||||
docker compose -f docker-compose.nginx.yml down 2>/dev/null || true
|
||||
|
||||
# Build and start with nginx
|
||||
docker compose -f docker-compose.nginx.yml build
|
||||
docker compose -f docker-compose.nginx.yml up -d
|
||||
|
||||
# Verify deployment
|
||||
sleep 5
|
||||
if curl -s http://localhost/health | grep -q "OK"; then
|
||||
echo "✅ Nexus deployed successfully with nginx"
|
||||
echo "🌐 Access at: http://$(hostname -I | awk '{print $1}')"
|
||||
else
|
||||
echo "❌ Deployment failed - health check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
notify:
|
||||
needs: deploy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify on success
|
||||
if: success()
|
||||
run: |
|
||||
echo "Nexus deployed successfully with nginx!"
|
||||
echo "URL: http://nexus.alexanderwhitestone.com (if DNS configured)"
|
||||
108
DEPLOYMENT.md
Normal file
108
DEPLOYMENT.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Nexus Deployment Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Deploy with Nginx (Recommended)
|
||||
|
||||
```bash
|
||||
# Deploy main (port 80)
|
||||
./deploy.sh
|
||||
|
||||
# Deploy staging (port 8080)
|
||||
./deploy.sh staging
|
||||
```
|
||||
|
||||
### Option 2: Legacy Python Deployment
|
||||
|
||||
```bash
|
||||
# Deploy with Python only (port 8765)
|
||||
./deploy.sh legacy
|
||||
```
|
||||
|
||||
## URL Configuration
|
||||
|
||||
### Local Development
|
||||
- **Nginx Main:** http://localhost
|
||||
- **Nginx Staging:** http://localhost:8080
|
||||
- **Legacy Python:** ws://localhost:8765
|
||||
|
||||
### Production (Ezra VPS)
|
||||
- **Main Site:** http://nexus.alexanderwhitestone.com (after DNS setup)
|
||||
- **Direct IP:** http://143.198.27.163
|
||||
|
||||
## DNS Setup
|
||||
|
||||
To point a domain to the Nexus:
|
||||
|
||||
1. Create an A record:
|
||||
```
|
||||
nexus.alexanderwhitestone.com → 143.198.27.163
|
||||
```
|
||||
|
||||
2. (Optional) Set up SSL with Let's Encrypt:
|
||||
```bash
|
||||
sudo certbot --nginx -d nexus.alexanderwhitestone.com
|
||||
```
|
||||
|
||||
## Manual VPS Deployment
|
||||
|
||||
1. SSH into Ezra VPS:
|
||||
```bash
|
||||
ssh root@143.198.27.163
|
||||
```
|
||||
|
||||
2. Clone and deploy:
|
||||
```bash
|
||||
cd ~
|
||||
git clone http://143.198.27.163:3000/Timmy_Foundation/the-nexus.git
|
||||
cd the-nexus
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Module Import Errors
|
||||
If you see "Failed to resolve module specifier" errors:
|
||||
- Ensure you're accessing via HTTP (not file://)
|
||||
- Check that nginx is serving from the correct root
|
||||
- Verify CORS headers are present
|
||||
|
||||
### WebSocket Connection Failed
|
||||
If WebSocket connection fails:
|
||||
- Check that port 8765 is open
|
||||
- Verify server.py is running
|
||||
- Check firewall rules
|
||||
|
||||
### Container Won't Start
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose -f docker-compose.nginx.yml logs
|
||||
|
||||
# Rebuild
|
||||
docker compose -f docker-compose.nginx.yml build --no-cache
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Nginx (port 80) │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Static Files (HTML/JS/CSS) │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Python WebSocket Server │ │
|
||||
│ │ (port 8765) │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `nginx.conf` — Nginx configuration
|
||||
- `Dockerfile.nginx` — Multi-stage build (nginx + Python)
|
||||
- `docker-compose.nginx.yml` — Docker Compose for nginx deployment
|
||||
- `docker-entrypoint.sh` — Starts both nginx and Python server
|
||||
- `.gitea/workflows/deploy-nginx.yml` — CI/CD workflow
|
||||
56
Dockerfile.nginx
Normal file
56
Dockerfile.nginx
Normal file
@@ -0,0 +1,56 @@
|
||||
# Multi-stage build: Python backend + Nginx frontend
|
||||
FROM python:3.11-slim AS backend
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python deps
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Backend
|
||||
COPY nexus/ nexus/
|
||||
COPY server.py ./
|
||||
|
||||
# Frontend stage
|
||||
FROM nginx:alpine AS frontend
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy our nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/nexus.conf
|
||||
|
||||
# Copy frontend assets
|
||||
COPY index.html help.html style.css app.js service-worker.js manifest.json /usr/share/nginx/html/
|
||||
COPY portals.json vision.json robots.txt /usr/share/nginx/html/
|
||||
|
||||
# Create a simple health check
|
||||
RUN echo "OK" > /usr/share/nginx/html/health
|
||||
|
||||
# Final stage - combine both
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy nginx config
|
||||
COPY --from=frontend /etc/nginx/conf.d/nexus.conf /etc/nginx/conf.d/
|
||||
|
||||
# Copy frontend assets
|
||||
COPY --from=frontend /usr/share/nginx/html/ /usr/share/nginx/html/
|
||||
|
||||
# Copy Python backend
|
||||
COPY --from=backend /app/ /app/
|
||||
|
||||
# Install Python in final image
|
||||
RUN apk add --no-cache python3 py3-pip
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt /app/
|
||||
RUN cd /app && pip3 install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
EXPOSE 80 8765
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,21 +0,0 @@
|
||||
"""
|
||||
agent — Cross-session agent memory and lifecycle hooks.
|
||||
|
||||
Provides persistent memory for agents via MemPalace integration.
|
||||
Agents recall context at session start and write diary entries at session end.
|
||||
|
||||
Modules:
|
||||
memory.py — AgentMemory class (recall, remember, diary)
|
||||
memory_hooks.py — Session lifecycle hooks (drop-in integration)
|
||||
"""
|
||||
|
||||
from agent.memory import AgentMemory, MemoryContext, SessionTranscript, create_agent_memory
|
||||
from agent.memory_hooks import MemoryHooks
|
||||
|
||||
__all__ = [
|
||||
"AgentMemory",
|
||||
"MemoryContext",
|
||||
"MemoryHooks",
|
||||
"SessionTranscript",
|
||||
"create_agent_memory",
|
||||
]
|
||||
396
agent/memory.py
396
agent/memory.py
@@ -1,396 +0,0 @@
|
||||
"""
|
||||
agent.memory — Cross-session agent memory via MemPalace.
|
||||
|
||||
Gives agents persistent memory across sessions. On wake-up, agents
|
||||
recall relevant context from past sessions. On session end, they
|
||||
write a diary entry summarizing what happened.
|
||||
|
||||
Architecture:
|
||||
Session Start → memory.recall_context() → inject L0/L1 into prompt
|
||||
During Session → memory.remember() → store important facts
|
||||
Session End → memory.write_diary() → summarize session
|
||||
|
||||
All operations degrade gracefully — if MemPalace is unavailable,
|
||||
the agent continues without memory and logs a warning.
|
||||
|
||||
Usage:
|
||||
from agent.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(agent_name="bezalel", wing="wing_bezalel")
|
||||
|
||||
# Session start — load context
|
||||
context = mem.recall_context("What was I working on last time?")
|
||||
|
||||
# During session — store important decisions
|
||||
mem.remember("Switched CI runner from GitHub Actions to self-hosted", room="forge")
|
||||
|
||||
# Session end — write diary
|
||||
mem.write_diary("Fixed PR #1386, reconciled fleet registry locations")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("agent.memory")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryContext:
|
||||
"""Context loaded at session start from MemPalace."""
|
||||
relevant_memories: list[dict] = field(default_factory=list)
|
||||
recent_diaries: list[dict] = field(default_factory=list)
|
||||
facts: list[dict] = field(default_factory=list)
|
||||
loaded: bool = False
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_prompt_block(self) -> str:
|
||||
"""Format context as a text block to inject into the agent prompt."""
|
||||
if not self.loaded:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
|
||||
if self.recent_diaries:
|
||||
parts.append("=== Recent Session Summaries ===")
|
||||
for d in self.recent_diaries[:3]:
|
||||
ts = d.get("timestamp", "")
|
||||
text = d.get("text", "")
|
||||
parts.append(f"[{ts}] {text[:500]}")
|
||||
|
||||
if self.facts:
|
||||
parts.append("\n=== Known Facts ===")
|
||||
for f in self.facts[:10]:
|
||||
text = f.get("text", "")
|
||||
parts.append(f"- {text[:200]}")
|
||||
|
||||
if self.relevant_memories:
|
||||
parts.append("\n=== Relevant Past Memories ===")
|
||||
for m in self.relevant_memories[:5]:
|
||||
text = m.get("text", "")
|
||||
score = m.get("score", 0)
|
||||
parts.append(f"[{score:.2f}] {text[:300]}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionTranscript:
|
||||
"""A running log of the current session for diary writing."""
|
||||
agent_name: str
|
||||
wing: str
|
||||
started_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
entries: list[dict] = field(default_factory=list)
|
||||
|
||||
def add_user_turn(self, text: str):
|
||||
self.entries.append({
|
||||
"role": "user",
|
||||
"text": text[:2000],
|
||||
"ts": time.time(),
|
||||
})
|
||||
|
||||
def add_agent_turn(self, text: str):
|
||||
self.entries.append({
|
||||
"role": "agent",
|
||||
"text": text[:2000],
|
||||
"ts": time.time(),
|
||||
})
|
||||
|
||||
def add_tool_call(self, tool: str, args: str, result_summary: str):
|
||||
self.entries.append({
|
||||
"role": "tool",
|
||||
"tool": tool,
|
||||
"args": args[:500],
|
||||
"result": result_summary[:500],
|
||||
"ts": time.time(),
|
||||
})
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Generate a compact transcript summary."""
|
||||
if not self.entries:
|
||||
return "Empty session."
|
||||
|
||||
turns = []
|
||||
for e in self.entries[-20:]: # last 20 entries
|
||||
role = e["role"]
|
||||
if role == "user":
|
||||
turns.append(f"USER: {e['text'][:200]}")
|
||||
elif role == "agent":
|
||||
turns.append(f"AGENT: {e['text'][:200]}")
|
||||
elif role == "tool":
|
||||
turns.append(f"TOOL({e.get('tool','')}): {e.get('result','')[:150]}")
|
||||
|
||||
return "\n".join(turns)
|
||||
|
||||
|
||||
class AgentMemory:
|
||||
"""
|
||||
Cross-session memory for an agent.
|
||||
|
||||
Wraps MemPalace with agent-specific conventions:
|
||||
- Each agent has a wing (e.g., "wing_bezalel")
|
||||
- Session summaries go in the "hermes" room
|
||||
- Important decisions go in room-specific closets
|
||||
- Facts go in the "nexus" room
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_name: str,
|
||||
wing: Optional[str] = None,
|
||||
palace_path: Optional[Path] = None,
|
||||
):
|
||||
self.agent_name = agent_name
|
||||
self.wing = wing or f"wing_{agent_name}"
|
||||
self.palace_path = palace_path
|
||||
self._transcript: Optional[SessionTranscript] = None
|
||||
self._available: Optional[bool] = None
|
||||
|
||||
def _check_available(self) -> bool:
|
||||
"""Check if MemPalace is accessible."""
|
||||
if self._available is not None:
|
||||
return self._available
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import search_memories, add_memory, _get_client
|
||||
from nexus.mempalace.config import MEMPALACE_PATH
|
||||
|
||||
path = self.palace_path or MEMPALACE_PATH
|
||||
_get_client(path)
|
||||
self._available = True
|
||||
logger.info(f"MemPalace available at {path}")
|
||||
except Exception as e:
|
||||
self._available = False
|
||||
logger.warning(f"MemPalace unavailable: {e}")
|
||||
|
||||
return self._available
|
||||
|
||||
def recall_context(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
n_results: int = 5,
|
||||
) -> MemoryContext:
|
||||
"""
|
||||
Load relevant context from past sessions.
|
||||
|
||||
Called at session start to inject L0/L1 memory into the prompt.
|
||||
|
||||
Args:
|
||||
query: What to search for. If None, loads recent diary entries.
|
||||
n_results: Max memories to recall.
|
||||
"""
|
||||
ctx = MemoryContext()
|
||||
|
||||
if not self._check_available():
|
||||
ctx.error = "MemPalace unavailable"
|
||||
return ctx
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import search_memories
|
||||
|
||||
# Load recent diary entries (session summaries)
|
||||
ctx.recent_diaries = [
|
||||
{"text": r.text, "score": r.score, "timestamp": r.metadata.get("timestamp", "")}
|
||||
for r in search_memories(
|
||||
"session summary",
|
||||
palace_path=self.palace_path,
|
||||
wing=self.wing,
|
||||
room="hermes",
|
||||
n_results=3,
|
||||
)
|
||||
]
|
||||
|
||||
# Load known facts
|
||||
ctx.facts = [
|
||||
{"text": r.text, "score": r.score}
|
||||
for r in search_memories(
|
||||
"important facts decisions",
|
||||
palace_path=self.palace_path,
|
||||
wing=self.wing,
|
||||
room="nexus",
|
||||
n_results=5,
|
||||
)
|
||||
]
|
||||
|
||||
# Search for relevant memories if query provided
|
||||
if query:
|
||||
ctx.relevant_memories = [
|
||||
{"text": r.text, "score": r.score, "room": r.room}
|
||||
for r in search_memories(
|
||||
query,
|
||||
palace_path=self.palace_path,
|
||||
wing=self.wing,
|
||||
n_results=n_results,
|
||||
)
|
||||
]
|
||||
|
||||
ctx.loaded = True
|
||||
|
||||
except Exception as e:
|
||||
ctx.error = str(e)
|
||||
logger.warning(f"Failed to recall context: {e}")
|
||||
|
||||
return ctx
|
||||
|
||||
def remember(
|
||||
self,
|
||||
text: str,
|
||||
room: str = "nexus",
|
||||
source_file: str = "",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Store a memory.
|
||||
|
||||
Args:
|
||||
text: The memory content.
|
||||
room: Target room (forge, hermes, nexus, issues, experiments).
|
||||
source_file: Optional source attribution.
|
||||
metadata: Extra metadata.
|
||||
|
||||
Returns:
|
||||
Document ID if stored, None if MemPalace unavailable.
|
||||
"""
|
||||
if not self._check_available():
|
||||
logger.warning("Cannot store memory — MemPalace unavailable")
|
||||
return None
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import add_memory
|
||||
|
||||
doc_id = add_memory(
|
||||
text=text,
|
||||
room=room,
|
||||
wing=self.wing,
|
||||
palace_path=self.palace_path,
|
||||
source_file=source_file,
|
||||
extra_metadata=metadata or {},
|
||||
)
|
||||
logger.debug(f"Stored memory in {room}: {text[:80]}...")
|
||||
return doc_id
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store memory: {e}")
|
||||
return None
|
||||
|
||||
def write_diary(
|
||||
self,
|
||||
summary: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Write a session diary entry to MemPalace.
|
||||
|
||||
Called at session end. If summary is None, auto-generates one
|
||||
from the session transcript.
|
||||
|
||||
Args:
|
||||
summary: Override summary text. If None, generates from transcript.
|
||||
|
||||
Returns:
|
||||
Document ID if stored, None if unavailable.
|
||||
"""
|
||||
if summary is None and self._transcript:
|
||||
summary = self._transcript.summary()
|
||||
|
||||
if not summary:
|
||||
return None
|
||||
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
diary_text = f"[{timestamp}] Session by {self.agent_name}:\n{summary}"
|
||||
|
||||
return self.remember(
|
||||
diary_text,
|
||||
room="hermes",
|
||||
metadata={
|
||||
"type": "session_diary",
|
||||
"agent": self.agent_name,
|
||||
"timestamp": timestamp,
|
||||
"entry_count": len(self._transcript.entries) if self._transcript else 0,
|
||||
},
|
||||
)
|
||||
|
||||
def start_session(self) -> SessionTranscript:
|
||||
"""
|
||||
Begin a new session transcript.
|
||||
|
||||
Returns the transcript object for recording turns.
|
||||
"""
|
||||
self._transcript = SessionTranscript(
|
||||
agent_name=self.agent_name,
|
||||
wing=self.wing,
|
||||
)
|
||||
logger.info(f"Session started for {self.agent_name}")
|
||||
return self._transcript
|
||||
|
||||
def end_session(self, diary_summary: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
End the current session, write diary, return diary doc ID.
|
||||
"""
|
||||
doc_id = self.write_diary(diary_summary)
|
||||
self._transcript = None
|
||||
logger.info(f"Session ended for {self.agent_name}")
|
||||
return doc_id
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
room: Optional[str] = None,
|
||||
n_results: int = 5,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Search memories. Useful during a session for recall.
|
||||
|
||||
Returns list of {text, room, wing, score} dicts.
|
||||
"""
|
||||
if not self._check_available():
|
||||
return []
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import search_memories
|
||||
|
||||
results = search_memories(
|
||||
query,
|
||||
palace_path=self.palace_path,
|
||||
wing=self.wing,
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
)
|
||||
return [
|
||||
{"text": r.text, "room": r.room, "wing": r.wing, "score": r.score}
|
||||
for r in results
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Search failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# --- Fleet-wide memory helpers ---
|
||||
|
||||
def create_agent_memory(
|
||||
agent_name: str,
|
||||
palace_path: Optional[Path] = None,
|
||||
) -> AgentMemory:
|
||||
"""
|
||||
Factory for creating AgentMemory with standard config.
|
||||
|
||||
Reads wing from MEMPALACE_WING env or defaults to wing_{agent_name}.
|
||||
"""
|
||||
wing = os.environ.get("MEMPALACE_WING", f"wing_{agent_name}")
|
||||
return AgentMemory(
|
||||
agent_name=agent_name,
|
||||
wing=wing,
|
||||
palace_path=palace_path,
|
||||
)
|
||||
@@ -1,183 +0,0 @@
|
||||
"""
|
||||
agent.memory_hooks — Session lifecycle hooks for agent memory.
|
||||
|
||||
Integrates AgentMemory into the agent session lifecycle:
|
||||
- on_session_start: Load context, inject into prompt
|
||||
- on_user_turn: Record user input
|
||||
- on_agent_turn: Record agent output
|
||||
- on_tool_call: Record tool usage
|
||||
- on_session_end: Write diary, clean up
|
||||
|
||||
These hooks are designed to be called from the Hermes harness or
|
||||
any agent framework. They're fire-and-forget — failures are logged
|
||||
but never crash the session.
|
||||
|
||||
Usage:
|
||||
from agent.memory_hooks import MemoryHooks
|
||||
|
||||
hooks = MemoryHooks(agent_name="bezalel")
|
||||
hooks.on_session_start() # loads context
|
||||
|
||||
# In your agent loop:
|
||||
hooks.on_user_turn("Check CI pipeline health")
|
||||
hooks.on_agent_turn("Running CI check...")
|
||||
hooks.on_tool_call("shell", "pytest tests/", "12 passed")
|
||||
|
||||
# End of session:
|
||||
hooks.on_session_end() # writes diary
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from agent.memory import AgentMemory, MemoryContext, create_agent_memory
|
||||
|
||||
logger = logging.getLogger("agent.memory_hooks")
|
||||
|
||||
|
||||
class MemoryHooks:
|
||||
"""
|
||||
Drop-in session lifecycle hooks for agent memory.
|
||||
|
||||
Wraps AgentMemory with error boundaries — every hook catches
|
||||
exceptions and logs warnings so memory failures never crash
|
||||
the agent session.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_name: str,
|
||||
palace_path=None,
|
||||
auto_diary: bool = True,
|
||||
):
|
||||
self.agent_name = agent_name
|
||||
self.auto_diary = auto_diary
|
||||
self._memory: Optional[AgentMemory] = None
|
||||
self._context: Optional[MemoryContext] = None
|
||||
self._active = False
|
||||
|
||||
@property
|
||||
def memory(self) -> AgentMemory:
|
||||
if self._memory is None:
|
||||
self._memory = create_agent_memory(
|
||||
self.agent_name,
|
||||
palace_path=getattr(self, '_palace_path', None),
|
||||
)
|
||||
return self._memory
|
||||
|
||||
def on_session_start(self, query: Optional[str] = None) -> str:
|
||||
"""
|
||||
Called at session start. Loads context from MemPalace.
|
||||
|
||||
Returns a prompt block to inject into the agent's context, or
|
||||
empty string if memory is unavailable.
|
||||
|
||||
Args:
|
||||
query: Optional recall query (e.g., "What was I working on?")
|
||||
"""
|
||||
try:
|
||||
self.memory.start_session()
|
||||
self._active = True
|
||||
|
||||
self._context = self.memory.recall_context(query=query)
|
||||
block = self._context.to_prompt_block()
|
||||
|
||||
if block:
|
||||
logger.info(
|
||||
f"Loaded {len(self._context.recent_diaries)} diaries, "
|
||||
f"{len(self._context.facts)} facts, "
|
||||
f"{len(self._context.relevant_memories)} relevant memories "
|
||||
f"for {self.agent_name}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"No prior memory for {self.agent_name}")
|
||||
|
||||
return block
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Session start memory hook failed: {e}")
|
||||
return ""
|
||||
|
||||
def on_user_turn(self, text: str):
|
||||
"""Record a user message."""
|
||||
if not self._active:
|
||||
return
|
||||
try:
|
||||
if self.memory._transcript:
|
||||
self.memory._transcript.add_user_turn(text)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to record user turn: {e}")
|
||||
|
||||
def on_agent_turn(self, text: str):
|
||||
"""Record an agent response."""
|
||||
if not self._active:
|
||||
return
|
||||
try:
|
||||
if self.memory._transcript:
|
||||
self.memory._transcript.add_agent_turn(text)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to record agent turn: {e}")
|
||||
|
||||
def on_tool_call(self, tool: str, args: str, result_summary: str):
|
||||
"""Record a tool invocation."""
|
||||
if not self._active:
|
||||
return
|
||||
try:
|
||||
if self.memory._transcript:
|
||||
self.memory._transcript.add_tool_call(tool, args, result_summary)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to record tool call: {e}")
|
||||
|
||||
def on_important_decision(self, text: str, room: str = "nexus"):
|
||||
"""
|
||||
Record an important decision or fact for long-term memory.
|
||||
|
||||
Use this when the agent makes a significant decision that
|
||||
should persist beyond the current session.
|
||||
"""
|
||||
try:
|
||||
self.memory.remember(text, room=room, metadata={"type": "decision"})
|
||||
logger.info(f"Remembered decision: {text[:80]}...")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remember decision: {e}")
|
||||
|
||||
def on_session_end(self, summary: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Called at session end. Writes diary entry.
|
||||
|
||||
Args:
|
||||
summary: Override diary text. If None, auto-generates.
|
||||
|
||||
Returns:
|
||||
Diary document ID, or None.
|
||||
"""
|
||||
if not self._active:
|
||||
return None
|
||||
|
||||
try:
|
||||
doc_id = self.memory.end_session(diary_summary=summary)
|
||||
self._active = False
|
||||
self._context = None
|
||||
return doc_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Session end memory hook failed: {e}")
|
||||
self._active = False
|
||||
return None
|
||||
|
||||
def search(self, query: str, room: Optional[str] = None) -> list[dict]:
|
||||
"""
|
||||
Search memories during a session.
|
||||
|
||||
Returns list of {text, room, wing, score}.
|
||||
"""
|
||||
try:
|
||||
return self.memory.search(query, room=room)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory search failed: {e}")
|
||||
return []
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self._active
|
||||
@@ -1,258 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
memory_mine.py — Mine session transcripts into MemPalace.
|
||||
|
||||
Reads Hermes session logs (JSONL format) and stores summaries
|
||||
in the palace. Supports batch mining, single-file processing,
|
||||
and live directory watching.
|
||||
|
||||
Usage:
|
||||
# Mine a single session file
|
||||
python3 bin/memory_mine.py ~/.hermes/sessions/2026-04-13.jsonl
|
||||
|
||||
# Mine all sessions from last 7 days
|
||||
python3 bin/memory_mine.py --days 7
|
||||
|
||||
# Mine a specific wing's sessions
|
||||
python3 bin/memory_mine.py --wing wing_bezalel --days 14
|
||||
|
||||
# Dry run — show what would be mined
|
||||
python3 bin/memory_mine.py --dry-run --days 7
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("memory-mine")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
def parse_session_file(path: Path) -> list[dict]:
|
||||
"""
|
||||
Parse a JSONL session file into turns.
|
||||
|
||||
Each line is expected to be a JSON object with:
|
||||
- role: "user" | "assistant" | "system" | "tool"
|
||||
- content: text
|
||||
- timestamp: ISO string (optional)
|
||||
"""
|
||||
turns = []
|
||||
with open(path) as f:
|
||||
for i, line in enumerate(f):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
turn = json.loads(line)
|
||||
turns.append(turn)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(f"Skipping malformed line {i+1} in {path}")
|
||||
return turns
|
||||
|
||||
|
||||
def summarize_session(turns: list[dict], agent_name: str = "unknown") -> str:
|
||||
"""
|
||||
Generate a compact summary of a session's turns.
|
||||
|
||||
Keeps user messages and key agent responses, strips noise.
|
||||
"""
|
||||
if not turns:
|
||||
return "Empty session."
|
||||
|
||||
user_msgs = []
|
||||
agent_msgs = []
|
||||
tool_calls = []
|
||||
|
||||
for turn in turns:
|
||||
role = turn.get("role", "")
|
||||
content = str(turn.get("content", ""))[:300]
|
||||
|
||||
if role == "user":
|
||||
user_msgs.append(content)
|
||||
elif role == "assistant":
|
||||
agent_msgs.append(content)
|
||||
elif role == "tool":
|
||||
tool_name = turn.get("name", turn.get("tool", "unknown"))
|
||||
tool_calls.append(f"{tool_name}: {content[:150]}")
|
||||
|
||||
parts = [f"Session by {agent_name}:"]
|
||||
|
||||
if user_msgs:
|
||||
parts.append(f"\nUser asked ({len(user_msgs)} messages):")
|
||||
for msg in user_msgs[:5]:
|
||||
parts.append(f" - {msg[:200]}")
|
||||
if len(user_msgs) > 5:
|
||||
parts.append(f" ... and {len(user_msgs) - 5} more")
|
||||
|
||||
if agent_msgs:
|
||||
parts.append(f"\nAgent responded ({len(agent_msgs)} messages):")
|
||||
for msg in agent_msgs[:3]:
|
||||
parts.append(f" - {msg[:200]}")
|
||||
|
||||
if tool_calls:
|
||||
parts.append(f"\nTools used ({len(tool_calls)} calls):")
|
||||
for tc in tool_calls[:5]:
|
||||
parts.append(f" - {tc}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def mine_session(
|
||||
path: Path,
|
||||
wing: str,
|
||||
palace_path: Optional[Path] = None,
|
||||
dry_run: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Mine a single session file into MemPalace.
|
||||
|
||||
Returns the document ID if stored, None on failure or dry run.
|
||||
"""
|
||||
try:
|
||||
from agent.memory import AgentMemory
|
||||
except ImportError:
|
||||
logger.error("Cannot import agent.memory — is the repo in PYTHONPATH?")
|
||||
return None
|
||||
|
||||
turns = parse_session_file(path)
|
||||
if not turns:
|
||||
logger.debug(f"Empty session file: {path}")
|
||||
return None
|
||||
|
||||
agent_name = wing.replace("wing_", "")
|
||||
summary = summarize_session(turns, agent_name)
|
||||
|
||||
if dry_run:
|
||||
print(f"\n--- {path.name} ---")
|
||||
print(summary[:500])
|
||||
print(f"({len(turns)} turns)")
|
||||
return None
|
||||
|
||||
mem = AgentMemory(agent_name=agent_name, wing=wing, palace_path=palace_path)
|
||||
doc_id = mem.remember(
|
||||
summary,
|
||||
room="hermes",
|
||||
source_file=str(path),
|
||||
metadata={
|
||||
"type": "mined_session",
|
||||
"source": str(path),
|
||||
"turn_count": len(turns),
|
||||
"agent": agent_name,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
if doc_id:
|
||||
logger.info(f"Mined {path.name} → {doc_id} ({len(turns)} turns)")
|
||||
else:
|
||||
logger.warning(f"Failed to mine {path.name}")
|
||||
|
||||
return doc_id
|
||||
|
||||
|
||||
def find_session_files(
|
||||
sessions_dir: Path,
|
||||
days: int = 7,
|
||||
pattern: str = "*.jsonl",
|
||||
) -> list[Path]:
|
||||
"""
|
||||
Find session files from the last N days.
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
files = []
|
||||
|
||||
if not sessions_dir.exists():
|
||||
logger.warning(f"Sessions directory not found: {sessions_dir}")
|
||||
return files
|
||||
|
||||
for path in sorted(sessions_dir.glob(pattern)):
|
||||
# Use file modification time as proxy for session date
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
if mtime >= cutoff:
|
||||
files.append(path)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Mine session transcripts into MemPalace"
|
||||
)
|
||||
parser.add_argument(
|
||||
"files", nargs="*", help="Session files to mine (JSONL format)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--days", type=int, default=7,
|
||||
help="Mine sessions from last N days (default: 7)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sessions-dir",
|
||||
default=str(Path.home() / ".hermes" / "sessions"),
|
||||
help="Directory containing session JSONL files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wing", default=None,
|
||||
help="Wing name (default: auto-detect from MEMPALACE_WING env or 'wing_timmy')"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--palace-path", default=None,
|
||||
help="Override palace path"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Show what would be mined without storing"
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
wing = args.wing or os.environ.get("MEMPALACE_WING", "wing_timmy")
|
||||
palace_path = Path(args.palace_path) if args.palace_path else None
|
||||
|
||||
if args.files:
|
||||
files = [Path(f) for f in args.files]
|
||||
else:
|
||||
sessions_dir = Path(args.sessions_dir)
|
||||
files = find_session_files(sessions_dir, days=args.days)
|
||||
|
||||
if not files:
|
||||
logger.info("No session files found to mine.")
|
||||
return 0
|
||||
|
||||
logger.info(f"Mining {len(files)} session files (wing={wing})")
|
||||
|
||||
mined = 0
|
||||
failed = 0
|
||||
for path in files:
|
||||
result = mine_session(path, wing=wing, palace_path=palace_path, dry_run=args.dry_run)
|
||||
if result:
|
||||
mined += 1
|
||||
elif result is None and not args.dry_run:
|
||||
failed += 1
|
||||
|
||||
if args.dry_run:
|
||||
logger.info(f"Dry run complete — {len(files)} files would be mined")
|
||||
else:
|
||||
logger.info(f"Mining complete — {mined} mined, {failed} failed")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
55
deploy.sh
55
deploy.sh
@@ -1,17 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — spin up (or update) the Nexus staging environment
|
||||
# Usage: ./deploy.sh — rebuild and restart nexus-main (port 4200)
|
||||
# ./deploy.sh staging — rebuild and restart nexus-staging (port 4201)
|
||||
# deploy.sh — deploy the Nexus to production or staging
|
||||
# Usage: ./deploy.sh — deploy main with nginx (port 80)
|
||||
# ./deploy.sh staging — deploy staging with nginx (port 8080)
|
||||
# ./deploy.sh legacy — deploy with Python only (port 8765)
|
||||
# ./deploy.sh --help — show help
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="${1:-nexus-main}"
|
||||
SERVICE="${1:-main}"
|
||||
USE_NGINX=true
|
||||
|
||||
case "$SERVICE" in
|
||||
staging) SERVICE="nexus-staging" ;;
|
||||
main) SERVICE="nexus-main" ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [main|staging|legacy]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " main (default) — Deploy main with nginx (port 80)"
|
||||
echo " staging — Deploy staging with nginx (port 8080)"
|
||||
echo " legacy — Deploy with Python only (port 8765)"
|
||||
echo " --help — Show this help"
|
||||
exit 0
|
||||
;;
|
||||
staging)
|
||||
SERVICE="nexus-staging"
|
||||
PORT="8080"
|
||||
;;
|
||||
legacy)
|
||||
SERVICE="nexus-main"
|
||||
USE_NGINX=false
|
||||
PORT="8765"
|
||||
;;
|
||||
main|*)
|
||||
SERVICE="nexus-main"
|
||||
PORT="80"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "==> Deploying $SERVICE …"
|
||||
docker compose build "$SERVICE"
|
||||
docker compose up -d --force-recreate "$SERVICE"
|
||||
echo "==> Deploying $SERVICE ..."
|
||||
|
||||
if [ "$USE_NGINX" = true ]; then
|
||||
echo "==> Using nginx deployment..."
|
||||
docker compose -f docker-compose.nginx.yml build "$SERVICE"
|
||||
docker compose -f docker-compose.nginx.yml up -d --force-recreate "$SERVICE"
|
||||
echo "==> Deployed with nginx on port $PORT"
|
||||
echo "==> Access at: http://localhost:$PORT"
|
||||
else
|
||||
echo "==> Using legacy Python deployment..."
|
||||
docker compose build "$SERVICE"
|
||||
docker compose up -d --force-recreate "$SERVICE"
|
||||
echo "==> Deployed with Python on port $PORT"
|
||||
echo "==> WebSocket at: ws://localhost:$PORT"
|
||||
fi
|
||||
|
||||
echo "==> Done. Container: $SERVICE"
|
||||
|
||||
42
docker-compose.nginx.yml
Normal file
42
docker-compose.nginx.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
nexus-main:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.nginx
|
||||
container_name: nexus-main
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80" # Nginx HTTP
|
||||
- "8765:8765" # WebSocket server
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- ./logs:/var/log/nginx
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
nexus-staging:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.nginx
|
||||
container_name: nexus-staging
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80" # Nginx HTTP (staging)
|
||||
- "8766:8765" # WebSocket server (staging)
|
||||
environment:
|
||||
- NODE_ENV=staging
|
||||
volumes:
|
||||
- ./logs-staging:/var/log/nginx
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
31
docker-entrypoint.sh
Executable file
31
docker-entrypoint.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Starting Nexus deployment..."
|
||||
|
||||
# Start nginx in background
|
||||
echo "Starting nginx on port 80..."
|
||||
nginx -g "daemon off;" &
|
||||
NGINX_PID=$!
|
||||
|
||||
# Start Python WebSocket server in background
|
||||
echo "Starting WebSocket server on port 8765..."
|
||||
cd /app && python3 server.py &
|
||||
PYTHON_PID=$!
|
||||
|
||||
# Function to handle shutdown
|
||||
shutdown() {
|
||||
echo "Shutting down..."
|
||||
kill $NGINX_PID 2>/dev/null
|
||||
kill $PYTHON_PID 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Trap SIGTERM and SIGINT
|
||||
trap shutdown SIGTERM SIGINT
|
||||
|
||||
# Wait for any process to exit
|
||||
wait -n
|
||||
|
||||
# Exit with status of process that exited first
|
||||
exit $?
|
||||
10
index.html
10
index.html
@@ -152,18 +152,18 @@
|
||||
|
||||
<!-- Top Right: Agent Log, Atlas & SOUL 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">WORLDS</span>
|
||||
</button>
|
||||
<button id="atlas-toggle-btn" class="hud-icon-btn" title="World Directory">
|
||||
<button id="soul-toggle-btn" class="hud-icon-btn" title="Timmy's SOUL">
|
||||
<span class="hud-icon">✦</span>
|
||||
<span class="hud-btn-label">SOUL</span>
|
||||
</button>
|
||||
<button id="mode-toggle-btn" class="hud-icon-btn mode-toggle" title="Toggle Mode">
|
||||
<span class="hud-icon">👁</span>
|
||||
<span class="hud-btn-label" id="mode-label">VISITOR</span>
|
||||
</button>
|
||||
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
|
||||
<span class="hud-icon">🌐</span>
|
||||
<span class="hud-btn-label">WORLDS</span>
|
||||
</button>
|
||||
<div id="bannerlord-status" class="hud-status-item" title="Bannerlord Readiness">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-label">BANNERLORD</span>
|
||||
|
||||
62
nginx.conf
Normal file
62
nginx.conf
Normal file
@@ -0,0 +1,62 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name nexus.alexanderwhitestone.com;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_min_length 1000;
|
||||
gzip_comp_level 6;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Handle SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# WebSocket proxy for server.py (if needed)
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8765;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# CORS headers for development
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type" always;
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS (if SSL is configured)
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name nexus.alexanderwhitestone.com;
|
||||
|
||||
# SSL configuration (commented out for now)
|
||||
# ssl_certificate /etc/nginx/ssl/nexus.crt;
|
||||
# ssl_certificate_key /etc/nginx/ssl/nexus.key;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# For now, redirect to HTTP
|
||||
return 301 http://$server_name$request_uri;
|
||||
}
|
||||
120
setup-vps.sh
Executable file
120
setup-vps.sh
Executable file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-vps.sh — Initial setup for Ezra VPS
|
||||
# Run this once on a fresh VPS to prepare for Nexus deployment
|
||||
set -euo pipefail
|
||||
|
||||
echo "Setting up Ezra VPS for Nexus deployment..."
|
||||
|
||||
# Update system
|
||||
echo "Updating system packages..."
|
||||
apt-get update && apt-get upgrade -y
|
||||
|
||||
# Install Docker
|
||||
echo "Installing Docker..."
|
||||
if ! command -v docker &> /dev/null; then
|
||||
apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
apt-get update
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
echo "✅ Docker installed"
|
||||
else
|
||||
echo "✅ Docker already installed"
|
||||
fi
|
||||
|
||||
# Install Docker Compose
|
||||
echo "Installing Docker Compose..."
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
curl -L "https://github.com/docker/compose/releases/download/v2.20.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
chmod +x /usr/local/bin/docker-compose
|
||||
echo "✅ Docker Compose installed"
|
||||
else
|
||||
echo "✅ Docker Compose already installed"
|
||||
fi
|
||||
|
||||
# Install nginx (for reverse proxy if needed)
|
||||
echo "Installing nginx..."
|
||||
if ! command -v nginx &> /dev/null; then
|
||||
apt-get install -y nginx
|
||||
systemctl enable nginx
|
||||
systemctl start nginx
|
||||
echo "✅ Nginx installed"
|
||||
else
|
||||
echo "✅ Nginx already installed"
|
||||
fi
|
||||
|
||||
# Configure firewall
|
||||
echo "Configuring firewall..."
|
||||
if command -v ufw &> /dev/null; then
|
||||
ufw allow 22/tcp # SSH
|
||||
ufw allow 80/tcp # HTTP
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw allow 8765/tcp # WebSocket
|
||||
ufw allow 3000/tcp # Gitea
|
||||
ufw --force enable
|
||||
echo "✅ Firewall configured"
|
||||
else
|
||||
echo "⚠️ ufw not available, skipping firewall configuration"
|
||||
fi
|
||||
|
||||
# Create nexus user (optional)
|
||||
echo "Creating nexus user..."
|
||||
if ! id -u nexus &>/dev/null; then
|
||||
useradd -m -s /bin/bash nexus
|
||||
usermod -aG docker nexus
|
||||
echo "✅ nexus user created"
|
||||
else
|
||||
echo "✅ nexus user already exists"
|
||||
fi
|
||||
|
||||
# Clone repository
|
||||
echo "Cloning Nexus repository..."
|
||||
if [ ! -d /home/nexus/the-nexus ]; then
|
||||
sudo -u nexus git clone http://143.198.27.163:3000/Timmy_Foundation/the-nexus.git /home/nexus/the-nexus
|
||||
echo "✅ Repository cloned"
|
||||
else
|
||||
echo "✅ Repository already exists"
|
||||
fi
|
||||
|
||||
# Set up nginx reverse proxy (optional)
|
||||
echo "Setting up nginx reverse proxy..."
|
||||
cat > /etc/nginx/sites-available/nexus << 'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name nexus.alexanderwhitestone.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8765;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
ln -sf /etc/nginx/sites-available/nexus /etc/nginx/sites-enabled/
|
||||
nginx -t && systemctl reload nginx
|
||||
echo "✅ Nginx reverse proxy configured"
|
||||
|
||||
echo ""
|
||||
echo "🎉 VPS setup complete!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Point DNS: nexus.alexanderwhitestone.com → $(hostname -I | awk '{print $1}')"
|
||||
echo "2. Deploy: cd /home/nexus/the-nexus && ./deploy.sh"
|
||||
echo "3. (Optional) Set up SSL: certbot --nginx -d nexus.alexanderwhitestone.com"
|
||||
echo ""
|
||||
@@ -1,377 +0,0 @@
|
||||
"""
|
||||
Tests for agent memory — cross-session agent memory via MemPalace.
|
||||
|
||||
Tests the memory module, hooks, and session mining without requiring
|
||||
a live ChromaDB instance. Uses mocking for the MemPalace backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.memory import (
|
||||
AgentMemory,
|
||||
MemoryContext,
|
||||
SessionTranscript,
|
||||
create_agent_memory,
|
||||
)
|
||||
from agent.memory_hooks import MemoryHooks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionTranscript tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionTranscript:
|
||||
def test_create(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
assert t.agent_name == "test"
|
||||
assert t.wing == "wing_test"
|
||||
assert len(t.entries) == 0
|
||||
|
||||
def test_add_user_turn(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
t.add_user_turn("Hello")
|
||||
assert len(t.entries) == 1
|
||||
assert t.entries[0]["role"] == "user"
|
||||
assert t.entries[0]["text"] == "Hello"
|
||||
|
||||
def test_add_agent_turn(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
t.add_agent_turn("Response")
|
||||
assert t.entries[0]["role"] == "agent"
|
||||
|
||||
def test_add_tool_call(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
t.add_tool_call("shell", "ls", "file1 file2")
|
||||
assert t.entries[0]["role"] == "tool"
|
||||
assert t.entries[0]["tool"] == "shell"
|
||||
|
||||
def test_summary_empty(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
assert t.summary() == "Empty session."
|
||||
|
||||
def test_summary_with_entries(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
t.add_user_turn("Do something")
|
||||
t.add_agent_turn("Done")
|
||||
t.add_tool_call("shell", "ls", "ok")
|
||||
|
||||
summary = t.summary()
|
||||
assert "USER: Do something" in summary
|
||||
assert "AGENT: Done" in summary
|
||||
assert "TOOL(shell): ok" in summary
|
||||
|
||||
def test_text_truncation(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
long_text = "x" * 5000
|
||||
t.add_user_turn(long_text)
|
||||
assert len(t.entries[0]["text"]) == 2000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryContext tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMemoryContext:
|
||||
def test_empty_context(self):
|
||||
ctx = MemoryContext()
|
||||
assert ctx.to_prompt_block() == ""
|
||||
|
||||
def test_unloaded_context(self):
|
||||
ctx = MemoryContext()
|
||||
ctx.loaded = False
|
||||
assert ctx.to_prompt_block() == ""
|
||||
|
||||
def test_loaded_with_data(self):
|
||||
ctx = MemoryContext()
|
||||
ctx.loaded = True
|
||||
ctx.recent_diaries = [
|
||||
{"text": "Fixed PR #1386", "timestamp": "2026-04-13T10:00:00Z"}
|
||||
]
|
||||
ctx.facts = [
|
||||
{"text": "Bezalel runs on VPS Beta", "score": 0.95}
|
||||
]
|
||||
ctx.relevant_memories = [
|
||||
{"text": "Changed CI runner", "score": 0.87}
|
||||
]
|
||||
|
||||
block = ctx.to_prompt_block()
|
||||
assert "Recent Session Summaries" in block
|
||||
assert "Fixed PR #1386" in block
|
||||
assert "Known Facts" in block
|
||||
assert "Bezalel runs on VPS Beta" in block
|
||||
assert "Relevant Past Memories" in block
|
||||
|
||||
def test_loaded_empty(self):
|
||||
ctx = MemoryContext()
|
||||
ctx.loaded = True
|
||||
# No data — should return empty string
|
||||
assert ctx.to_prompt_block() == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentMemory tests (with mocked MemPalace)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAgentMemory:
|
||||
def test_create(self):
|
||||
mem = AgentMemory(agent_name="bezalel")
|
||||
assert mem.agent_name == "bezalel"
|
||||
assert mem.wing == "wing_bezalel"
|
||||
|
||||
def test_custom_wing(self):
|
||||
mem = AgentMemory(agent_name="bezalel", wing="custom_wing")
|
||||
assert mem.wing == "custom_wing"
|
||||
|
||||
def test_factory(self):
|
||||
mem = create_agent_memory("ezra")
|
||||
assert mem.agent_name == "ezra"
|
||||
assert mem.wing == "wing_ezra"
|
||||
|
||||
def test_unavailable_graceful(self):
|
||||
"""Test graceful degradation when MemPalace is unavailable."""
|
||||
mem = AgentMemory(agent_name="test")
|
||||
mem._available = False # Force unavailable
|
||||
|
||||
# Should not raise
|
||||
ctx = mem.recall_context("test query")
|
||||
assert ctx.loaded is False
|
||||
assert ctx.error == "MemPalace unavailable"
|
||||
|
||||
# remember returns None
|
||||
assert mem.remember("test") is None
|
||||
|
||||
# search returns empty
|
||||
assert mem.search("test") == []
|
||||
|
||||
def test_start_end_session(self):
|
||||
mem = AgentMemory(agent_name="test")
|
||||
mem._available = False
|
||||
|
||||
transcript = mem.start_session()
|
||||
assert isinstance(transcript, SessionTranscript)
|
||||
assert mem._transcript is not None
|
||||
|
||||
doc_id = mem.end_session()
|
||||
assert mem._transcript is None
|
||||
|
||||
def test_remember_graceful_when_unavailable(self):
|
||||
"""Test remember returns None when MemPalace is unavailable."""
|
||||
mem = AgentMemory(agent_name="test")
|
||||
mem._available = False
|
||||
|
||||
doc_id = mem.remember("some important fact")
|
||||
assert doc_id is None
|
||||
|
||||
def test_write_diary_from_transcript(self):
|
||||
mem = AgentMemory(agent_name="test")
|
||||
mem._available = False
|
||||
|
||||
transcript = mem.start_session()
|
||||
transcript.add_user_turn("Hello")
|
||||
transcript.add_agent_turn("Hi there")
|
||||
|
||||
# Write diary should handle unavailable gracefully
|
||||
doc_id = mem.write_diary()
|
||||
assert doc_id is None # MemPalace unavailable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryHooks tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMemoryHooks:
|
||||
def test_create(self):
|
||||
hooks = MemoryHooks(agent_name="bezalel")
|
||||
assert hooks.agent_name == "bezalel"
|
||||
assert hooks.is_active is False
|
||||
|
||||
def test_session_lifecycle(self):
|
||||
hooks = MemoryHooks(agent_name="test")
|
||||
|
||||
# Force memory unavailable
|
||||
hooks._memory = AgentMemory(agent_name="test")
|
||||
hooks._memory._available = False
|
||||
|
||||
# Start session
|
||||
block = hooks.on_session_start()
|
||||
assert hooks.is_active is True
|
||||
assert block == "" # No memory available
|
||||
|
||||
# Record turns
|
||||
hooks.on_user_turn("Hello")
|
||||
hooks.on_agent_turn("Hi")
|
||||
hooks.on_tool_call("shell", "ls", "ok")
|
||||
|
||||
# Record decision
|
||||
hooks.on_important_decision("Switched to self-hosted CI")
|
||||
|
||||
# End session
|
||||
doc_id = hooks.on_session_end()
|
||||
assert hooks.is_active is False
|
||||
|
||||
def test_hooks_before_session(self):
|
||||
"""Hooks before session start should be no-ops."""
|
||||
hooks = MemoryHooks(agent_name="test")
|
||||
hooks._memory = AgentMemory(agent_name="test")
|
||||
hooks._memory._available = False
|
||||
|
||||
# Should not raise
|
||||
hooks.on_user_turn("Hello")
|
||||
hooks.on_agent_turn("Response")
|
||||
|
||||
def test_hooks_after_session_end(self):
|
||||
"""Hooks after session end should be no-ops."""
|
||||
hooks = MemoryHooks(agent_name="test")
|
||||
hooks._memory = AgentMemory(agent_name="test")
|
||||
hooks._memory._available = False
|
||||
|
||||
hooks.on_session_start()
|
||||
hooks.on_session_end()
|
||||
|
||||
# Should not raise
|
||||
hooks.on_user_turn("Late message")
|
||||
doc_id = hooks.on_session_end()
|
||||
assert doc_id is None
|
||||
|
||||
def test_search_during_session(self):
|
||||
hooks = MemoryHooks(agent_name="test")
|
||||
hooks._memory = AgentMemory(agent_name="test")
|
||||
hooks._memory._available = False
|
||||
|
||||
results = hooks.search("some query")
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session mining tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionMining:
|
||||
def test_parse_session_file(self):
|
||||
from bin.memory_mine import parse_session_file
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||
f.write('{"role": "user", "content": "Hello"}\n')
|
||||
f.write('{"role": "assistant", "content": "Hi there"}\n')
|
||||
f.write('{"role": "tool", "name": "shell", "content": "ls output"}\n')
|
||||
f.write("\n") # blank line
|
||||
f.write("not json\n") # malformed
|
||||
path = Path(f.name)
|
||||
|
||||
turns = parse_session_file(path)
|
||||
assert len(turns) == 3
|
||||
assert turns[0]["role"] == "user"
|
||||
assert turns[1]["role"] == "assistant"
|
||||
assert turns[2]["role"] == "tool"
|
||||
path.unlink()
|
||||
|
||||
def test_summarize_session(self):
|
||||
from bin.memory_mine import summarize_session
|
||||
|
||||
turns = [
|
||||
{"role": "user", "content": "Check CI"},
|
||||
{"role": "assistant", "content": "Running CI check..."},
|
||||
{"role": "tool", "name": "shell", "content": "5 tests passed"},
|
||||
{"role": "assistant", "content": "CI is healthy"},
|
||||
]
|
||||
|
||||
summary = summarize_session(turns, "bezalel")
|
||||
assert "bezalel" in summary
|
||||
assert "Check CI" in summary
|
||||
assert "shell" in summary
|
||||
|
||||
def test_summarize_empty(self):
|
||||
from bin.memory_mine import summarize_session
|
||||
|
||||
assert summarize_session([], "test") == "Empty session."
|
||||
|
||||
def test_find_session_files(self, tmp_path):
|
||||
from bin.memory_mine import find_session_files
|
||||
|
||||
# Create some test files
|
||||
(tmp_path / "session1.jsonl").write_text("{}\n")
|
||||
(tmp_path / "session2.jsonl").write_text("{}\n")
|
||||
(tmp_path / "notes.txt").write_text("not a session")
|
||||
|
||||
files = find_session_files(tmp_path, days=365)
|
||||
assert len(files) == 2
|
||||
assert all(f.suffix == ".jsonl" for f in files)
|
||||
|
||||
def test_find_session_files_missing_dir(self):
|
||||
from bin.memory_mine import find_session_files
|
||||
|
||||
files = find_session_files(Path("/nonexistent/path"), days=7)
|
||||
assert files == []
|
||||
|
||||
def test_mine_session_dry_run(self, tmp_path):
|
||||
from bin.memory_mine import mine_session
|
||||
|
||||
session_file = tmp_path / "test.jsonl"
|
||||
session_file.write_text(
|
||||
'{"role": "user", "content": "Hello"}\n'
|
||||
'{"role": "assistant", "content": "Hi"}\n'
|
||||
)
|
||||
|
||||
result = mine_session(session_file, wing="wing_test", dry_run=True)
|
||||
assert result is None # dry run doesn't store
|
||||
|
||||
def test_mine_session_empty_file(self, tmp_path):
|
||||
from bin.memory_mine import mine_session
|
||||
|
||||
session_file = tmp_path / "empty.jsonl"
|
||||
session_file.write_text("")
|
||||
|
||||
result = mine_session(session_file, wing="wing_test")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test — full lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFullLifecycle:
|
||||
"""Test the full session lifecycle without a real MemPalace backend."""
|
||||
|
||||
def test_full_session_flow(self):
|
||||
hooks = MemoryHooks(agent_name="bezalel")
|
||||
|
||||
# Force memory unavailable
|
||||
hooks._memory = AgentMemory(agent_name="bezalel")
|
||||
hooks._memory._available = False
|
||||
|
||||
# 1. Session start
|
||||
context_block = hooks.on_session_start("What CI issues do I have?")
|
||||
assert isinstance(context_block, str)
|
||||
|
||||
# 2. User asks question
|
||||
hooks.on_user_turn("Check CI pipeline health")
|
||||
|
||||
# 3. Agent uses tool
|
||||
hooks.on_tool_call("shell", "pytest tests/", "12 passed")
|
||||
|
||||
# 4. Agent responds
|
||||
hooks.on_agent_turn("CI pipeline is healthy. All 12 tests passing.")
|
||||
|
||||
# 5. Important decision
|
||||
hooks.on_important_decision("Decided to keep current CI runner", room="forge")
|
||||
|
||||
# 6. More interaction
|
||||
hooks.on_user_turn("Good, check memory integration next")
|
||||
hooks.on_agent_turn("Will test agent.memory module")
|
||||
|
||||
# 7. Session end
|
||||
doc_id = hooks.on_session_end()
|
||||
assert hooks.is_active is False
|
||||
Reference in New Issue
Block a user