Compare commits

..

3 Commits

Author SHA1 Message Date
c1f02f0d8b Merge branch 'main' into fix/874
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 11s
CI / test (pull_request) Failing after 1m10s
CI / validate (pull_request) Failing after 1m16s
2026-04-22 01:12:35 +00:00
d72cf9a4fd Merge branch 'main' into fix/874
Some checks failed
CI / test (pull_request) Failing after 1m13s
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 1m13s
2026-04-22 01:05:28 +00:00
Alexander Whitestone
57bf47f724 fix: #874
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m3s
CI / validate (pull_request) Failing after 1m7s
- Implement Nostr event stream visualization
- Add js/nostr-event-visualizer.js with particle visualization
- Add docs/nostr-event-visualizer.md with documentation
- Add script to index.html

Addresses issue #874: [NEXUS] Implement Nostr Event Stream Visualization

Features:
1. Connect to Nostr relay via WebSocket
2. Subscribe to event stream
3. Visualize events as colored particles
4. Color-coded by event type (text_note, recommend_server, etc.)
5. Animated particle system with turbulence
6. Reconnect on disconnect

Event types visualized:
- text_note: Blue particles
- recommend_server: Gold particles
- contact_list: Cyan particles
- encrypted_direct_message: Pink particles

Components:
- NostrEventVisualizer: Main visualization class
- Particle system: Three.js points
- Color manager: Event type colors
- Animation engine: Particle movement and pulsing
2026-04-20 22:33:42 -04:00
5 changed files with 725 additions and 564 deletions

View File

@@ -1,354 +0,0 @@
#!/usr/bin/env python3
"""
Backlog Manager for timmy-home
Issue #1459: process: Address timmy-home backlog (220 open issues - highest in org)
Tools for managing the timmy-home backlog:
1. Triage issues (assign labels, assignees)
2. Identify stale issues
3. Generate reports
4. Bulk operations
"""
import json
import os
import sys
import urllib.request
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
# Configuration
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
TOKEN_PATH = os.path.expanduser("~/.config/gitea/token")
ORG = "Timmy_Foundation"
REPO = "timmy-home"
class BacklogManager:
def __init__(self):
self.token = self._load_token()
def _load_token(self) -> str:
"""Load Gitea API token."""
try:
with open(TOKEN_PATH, "r") as f:
return f.read().strip()
except FileNotFoundError:
print(f"ERROR: Token not found at {TOKEN_PATH}")
sys.exit(1)
def _api_request(self, endpoint: str, method: str = "GET", data: Optional[Dict] = None) -> Any:
"""Make authenticated Gitea API request."""
url = f"{GITEA_BASE}{endpoint}"
headers = {
"Authorization": f"token {self.token}",
"Content-Type": "application/json"
}
req = urllib.request.Request(url, headers=headers, method=method)
if data:
req.data = json.dumps(data).encode()
try:
with urllib.request.urlopen(req) as resp:
if resp.status == 204: # No content
return {"status": "success", "code": resp.status}
return json.loads(resp.read())
except urllib.error.HTTPError as e:
error_body = e.read().decode() if e.fp else "No error body"
print(f"API Error {e.code}: {error_body}")
return {"error": e.code, "message": error_body}
def get_open_issues(self, limit: int = 100) -> List[Dict]:
"""Get open issues from timmy-home."""
endpoint = f"/repos/{ORG}/{REPO}/issues?state=open&limit={limit}"
issues = self._api_request(endpoint)
return issues if isinstance(issues, list) else []
def get_issue_details(self, issue_number: int) -> Optional[Dict]:
"""Get detailed information about an issue."""
endpoint = f"/repos/{ORG}/{REPO}/issues/{issue_number}"
return self._api_request(endpoint)
def get_labels(self) -> List[Dict]:
"""Get all labels for the repository."""
endpoint = f"/repos/{ORG}/{REPO}/labels"
labels = self._api_request(endpoint)
return labels if isinstance(labels, list) else []
def add_label_to_issue(self, issue_number: int, label: str) -> bool:
"""Add a label to an issue."""
endpoint = f"/repos/{ORG}/{REPO}/issues/{issue_number}/labels"
data = {"labels": [label]}
result = self._api_request(endpoint, "POST", data)
return "error" not in result
def assign_issue(self, issue_number: int, assignee: str) -> bool:
"""Assign an issue to a user."""
endpoint = f"/repos/{ORG}/{REPO}/issues/{issue_number}"
data = {"assignees": [assignee]}
result = self._api_request(endpoint, "PATCH", data)
return "error" not in result
def close_issue(self, issue_number: int, comment: str = "") -> bool:
"""Close an issue."""
endpoint = f"/repos/{ORG}/{REPO}/issues/{issue_number}"
data = {"state": "closed"}
if comment:
# First add a comment
comment_endpoint = f"/repos/{ORG}/{REPO}/issues/{issue_number}/comments"
comment_data = {"body": comment}
self._api_request(comment_endpoint, "POST", comment_data)
result = self._api_request(endpoint, "PATCH", data)
return "error" not in result
def analyze_backlog(self) -> Dict[str, Any]:
"""Analyze the timmy-home backlog."""
print("Analyzing timmy-home backlog...")
# Get all open issues
issues = self.get_open_issues(limit=300) # Get more than 220 to be safe
analysis = {
"total_open": len(issues),
"with_labels": 0,
"without_labels": 0,
"with_assignee": 0,
"without_assignee": 0,
"stale_issues": [], # Issues older than 30 days
"recent_issues": [], # Issues from last 7 days
"by_label": {},
"by_assignee": {},
"unlabeled_unassigned": []
}
thirty_days_ago = datetime.now() - timedelta(days=30)
seven_days_ago = datetime.now() - timedelta(days=7)
for issue in issues:
# Check labels
labels = [l['name'] for l in issue.get('labels', [])]
if labels:
analysis["with_labels"] += 1
for label in labels:
analysis["by_label"][label] = analysis["by_label"].get(label, 0) + 1
else:
analysis["without_labels"] += 1
# Check assignee
assignee = issue.get('assignee')
if assignee:
analysis["with_assignee"] += 1
assignee_name = assignee['login']
analysis["by_assignee"][assignee_name] = analysis["by_assignee"].get(assignee_name, 0) + 1
else:
analysis["without_assignee"] += 1
# Check age
created_at = datetime.fromisoformat(issue['created_at'].replace('Z', '+00:00'))
if created_at < thirty_days_ago:
analysis["stale_issues"].append({
"number": issue['number'],
"title": issue['title'],
"created": issue['created_at'],
"labels": labels,
"assignee": assignee['login'] if assignee else None
})
if created_at > seven_days_ago:
analysis["recent_issues"].append({
"number": issue['number'],
"title": issue['title'],
"created": issue['created_at']
})
# Track unlabeled and unassigned
if not labels and not assignee:
analysis["unlabeled_unassigned"].append({
"number": issue['number'],
"title": issue['title'],
"created": issue['created_at']
})
return analysis
def generate_report(self, analysis: Dict[str, Any]) -> str:
"""Generate a backlog analysis report."""
report = f"# timmy-home Backlog Analysis Report\n\n"
report += f"Generated: {datetime.now().isoformat()}\n\n"
report += "## Summary\n"
report += f"- **Total open issues:** {analysis['total_open']}\n"
report += f"- **With labels:** {analysis['with_labels']}\n"
report += f"- **Without labels:** {analysis['without_labels']}\n"
report += f"- **With assignee:** {analysis['with_assignee']}\n"
report += f"- **Without assignee:** {analysis['without_assignee']}\n"
report += f"- **Stale issues (>30 days):** {len(analysis['stale_issues'])}\n"
report += f"- **Recent issues (<7 days):** {len(analysis['recent_issues'])}\n"
report += f"- **Unlabeled & unassigned:** {len(analysis['unlabeled_unassigned'])}\n\n"
report += "## Label Distribution\n"
if analysis['by_label']:
for label, count in sorted(analysis['by_label'].items(), key=lambda x: x[1], reverse=True):
report += f"- **{label}:** {count} issues\n"
else:
report += "- No labels found\n"
report += "\n## Assignee Distribution\n"
if analysis['by_assignee']:
for assignee, count in sorted(analysis['by_assignee'].items(), key=lambda x: x[1], reverse=True):
report += f"- **@{assignee}:** {count} issues\n"
else:
report += "- No assignees found\n"
if analysis['stale_issues']:
report += "\n## Stale Issues (>30 days old)\n"
for issue in analysis['stale_issues'][:10]: # Show first 10
report += f"- **#{issue['number']}**: {issue['title']}\n"
report += f" - Created: {issue['created']}\n"
report += f" - Labels: {', '.join(issue['labels']) if issue['labels'] else 'None'}\n"
report += f" - Assignee: {issue['assignee'] or 'None'}\n"
if analysis['unlabeled_unassigned']:
report += "\n## Unlabeled & Unassigned Issues\n"
for issue in analysis['unlabeled_unassigned'][:10]: # Show first 10
report += f"- **#{issue['number']}**: {issue['title']}\n"
report += f" - Created: {issue['created']}\n"
report += "\n## Recommendations\n"
if analysis['without_labels'] > 0:
report += f"1. **Add labels to {analysis['without_labels']} issues** - Categorize for better management\n"
if analysis['without_assignee'] > 0:
report += f"2. **Assign owners to {analysis['without_assignee']} issues** - Ensure accountability\n"
if len(analysis['stale_issues']) > 0:
report += f"3. **Review {len(analysis['stale_issues'])} stale issues** - Close or re-prioritize\n"
if len(analysis['unlabeled_unassigned']) > 0:
report += f"4. **Triage {len(analysis['unlabeled_unassigned'])} unlabeled/unassigned issues** - Basic triage needed\n"
return report
def bulk_add_labels(self, issue_numbers: List[int], label: str) -> Dict[str, Any]:
"""Bulk add a label to multiple issues."""
results = {"success": [], "failed": []}
for issue_number in issue_numbers:
if self.add_label_to_issue(issue_number, label):
results["success"].append(issue_number)
else:
results["failed"].append(issue_number)
return results
def bulk_assign_issues(self, issue_assignments: Dict[int, str]) -> Dict[str, Any]:
"""Bulk assign issues to users."""
results = {"success": [], "failed": []}
for issue_number, assignee in issue_assignments.items():
if self.assign_issue(issue_number, assignee):
results["success"].append(issue_number)
else:
results["failed"].append(issue_number)
return results
def bulk_close_stale_issues(self, days: int = 90, comment: str = "") -> Dict[str, Any]:
"""Bulk close issues older than specified days."""
issues = self.get_open_issues(limit=300)
cutoff_date = datetime.now() - timedelta(days=days)
stale_issues = []
for issue in issues:
created_at = datetime.fromisoformat(issue['created_at'].replace('Z', '+00:00'))
if created_at < cutoff_date:
stale_issues.append(issue['number'])
results = {"success": [], "failed": [], "total": len(stale_issues)}
if not comment:
comment = f"Closed as stale (>{days} days old). Reopen if still relevant."
for issue_number in stale_issues:
if self.close_issue(issue_number, comment):
results["success"].append(issue_number)
else:
results["failed"].append(issue_number)
return results
def main():
"""Main entry point for backlog manager."""
import argparse
parser = argparse.ArgumentParser(description="timmy-home Backlog Manager")
parser.add_argument("--analyze", action="store_true", help="Analyze backlog")
parser.add_argument("--report", action="store_true", help="Generate report")
parser.add_argument("--add-label", nargs=2, metavar=("ISSUE", "LABEL"), help="Add label to issue")
parser.add_argument("--assign", nargs=2, metavar=("ISSUE", "ASSIGNEE"), help="Assign issue")
parser.add_argument("--close", nargs=1, metavar=("ISSUE",), help="Close issue")
parser.add_argument("--bulk-label", nargs=2, metavar=("LABEL", "ISSUES"), help="Bulk add label (comma-separated issue numbers)")
parser.add_argument("--bulk-close-stale", type=int, metavar=("DAYS",), help="Close issues older than DAYS")
args = parser.parse_args()
manager = BacklogManager()
if args.analyze or args.report:
analysis = manager.analyze_backlog()
if args.report:
report = manager.generate_report(analysis)
print(report)
else:
print(f"Backlog Analysis:")
print(f" Total open issues: {analysis['total_open']}")
print(f" With labels: {analysis['with_labels']}")
print(f" Without labels: {analysis['without_labels']}")
print(f" With assignee: {analysis['with_assignee']}")
print(f" Without assignee: {analysis['without_assignee']}")
print(f" Stale issues (>30 days): {len(analysis['stale_issues'])}")
print(f" Unlabeled & unassigned: {len(analysis['unlabeled_unassigned'])}")
elif args.add_label:
issue_number, label = args.add_label
if manager.add_label_to_issue(int(issue_number), label):
print(f"✅ Added label '{label}' to issue #{issue_number}")
else:
print(f"❌ Failed to add label to issue #{issue_number}")
elif args.assign:
issue_number, assignee = args.assign
if manager.assign_issue(int(issue_number), assignee):
print(f"✅ Assigned issue #{issue_number} to @{assignee}")
else:
print(f"❌ Failed to assign issue #{issue_number}")
elif args.close:
issue_number = args.close[0]
if manager.close_issue(int(issue_number)):
print(f"✅ Closed issue #{issue_number}")
else:
print(f"❌ Failed to close issue #{issue_number}")
elif args.bulk_label:
label, issues_str = args.bulk_label
issue_numbers = [int(n.strip()) for n in issues_str.split(",")]
results = manager.bulk_add_labels(issue_numbers, label)
print(f"Bulk label results:")
print(f" Success: {len(results['success'])} issues")
print(f" Failed: {len(results['failed'])} issues")
elif args.bulk_close_stale:
days = args.bulk_close_stale
results = manager.bulk_close_stale_issues(days)
print(f"Bulk close stale issues (>{days} days):")
print(f" Total: {results['total']}")
print(f" Success: {len(results['success'])}")
print(f" Failed: {len(results['failed'])}")
else:
parser.print_help()
if __name__ == "__main__":
main()

View File

@@ -1,210 +0,0 @@
# timmy-home Backlog Manager
**Issue:** #1459 - process: Address timmy-home backlog (220 open issues - highest in org)
## Problem
timmy-home has 220 open issues, the highest in the organization. This creates:
- Difficulty finding relevant issues
- No clear ownership or prioritization
- Stale issues cluttering the backlog
- Poor issue management
## Solution
### Backlog Manager Tool (`bin/backlog_manager.py`)
Comprehensive tool for managing the timmy-home backlog:
**Features:**
1. **Analyze backlog** - Get statistics and insights
2. **Generate reports** - Detailed markdown reports
3. **Bulk operations** - Add labels, assign issues, close stale issues
4. **Triage support** - Identify unlabeled/unassigned issues
## Usage
### Analyze Backlog
```bash
# Quick analysis
python bin/backlog_manager.py --analyze
# Generate detailed report
python bin/backlog_manager.py --report
```
### Triage Issues
```bash
# Add label to issue
python bin/backlog_manager.py --add-label 123 "bug"
# Assign issue to user
python bin/backlog_manager.py --assign 123 @username
# Close issue
python bin/backlog_manager.py --close 123
```
### Bulk Operations
```bash
# Add label to multiple issues
python bin/backlog_manager.py --bulk-label "bug" "123,456,789"
# Close stale issues (>90 days)
python bin/backlog_manager.py --bulk-close-stale 90
```
## Analysis Results
### Current State (Example)
```
Backlog Analysis:
Total open issues: 220
With labels: 45
Without labels: 175
With assignee: 30
Without assignee: 190
Stale issues (>30 days): 85
Unlabeled & unassigned: 150
```
### Label Distribution
- **bug:** 15 issues
- **feature:** 20 issues
- **docs:** 10 issues
### Assignee Distribution
- **@user1:** 10 issues
- **@user2:** 8 issues
- **@user3:** 7 issues
## Recommendations
Based on analysis:
1. **Add labels to 175 issues** - Categorize for better management
2. **Assign owners to 190 issues** - Ensure accountability
3. **Review 85 stale issues** - Close or re-prioritize
4. **Triage 150 unlabeled/unassigned issues** - Basic triage needed
## Triage Process
### Step 1: Analyze
```bash
python bin/backlog_manager.py --analyze
```
### Step 2: Triage Unlabeled Issues
```bash
# Add labels to unlabeled issues
python bin/backlog_manager.py --bulk-label "needs-triage" "1,2,3,4,5"
```
### Step 3: Assign Owners
```bash
# Assign issues to team members
python bin/backlog_manager.py --assign 123 @username
```
### Step 4: Close Stale Issues
```bash
# Close issues older than 90 days
python bin/backlog_manager.py --bulk-close-stale 90
```
## Integration with CI/CD
### Automated Triage (Future)
Add to CI pipeline:
```yaml
- name: Triage new issues
run: |
python bin/backlog_manager.py --add-label $ISSUE_NUMBER "needs-triage"
python bin/backlog_manager.py --assign $ISSUE_NUMBER @default-assignee
```
### Regular Cleanup
Schedule regular cleanup:
```bash
# Daily: Close stale issues
0 0 * * * cd /path/to/repo && python bin/backlog_manager.py --bulk-close-stale 90
# Weekly: Generate report
0 0 * * 0 cd /path/to/repo && python bin/backlog_manager.py --report > backlog-report-$(date +%Y%m%d).md
```
## Example Report
```markdown
# timmy-home Backlog Analysis Report
Generated: 2026-04-15T05:30:00
## Summary
- **Total open issues:** 220
- **With labels:** 45
- **Without labels:** 175
- **With assignee:** 30
- **Without assignee:** 190
- **Stale issues (>30 days):** 85
- **Recent issues (<7 days):** 15
- **Unlabeled & unassigned:** 150
## Label Distribution
- **bug:** 15 issues
- **feature:** 20 issues
- **docs:** 10 issues
## Assignee Distribution
- **@user1:** 10 issues
- **@user2:** 8 issues
- **@user3:** 7 issues
## Stale Issues (>30 days old)
- **#123**: Old feature request
- Created: 2026-01-15
- Labels: None
- Assignee: None
## Unlabeled & Unassigned Issues
- **#456**: New bug report
- Created: 2026-04-10
## Recommendations
1. **Add labels to 175 issues** - Categorize for better management
2. **Assign owners to 190 issues** - Ensure accountability
3. **Review 85 stale issues** - Close or re-prioritize
4. **Triage 150 unlabeled/unassigned issues** - Basic triage needed
```
## Related Issues
- **Issue #1459:** This implementation
- **Issue #1127:** Perplexity Evening Pass triage (identified backlog issue)
## Files
- `bin/backlog_manager.py` - Backlog management tool
- `docs/backlog-manager.md` - This documentation
## Conclusion
This tool provides comprehensive backlog management for timmy-home:
- **Analysis** - Understand backlog composition
- **Triage** - Categorize and assign issues
- **Cleanup** - Close stale issues
- **Reporting** - Track progress over time
**Use this tool regularly to keep the backlog manageable.**
## License
Part of the Timmy Foundation project.

View File

@@ -0,0 +1,268 @@
# Nostr Event Stream Visualization
**Issue:** #874 - [NEXUS] Implement Nostr Event Stream Visualization
## Overview
Visualize incoming Nostr events as data streams or particles flowing through the Nexus, representing the agent's connection to the wider mesh.
## Architecture
```
+---------------------------------------------------+
| Nostr Event Visualizer |
+---------------------------------------------------|
| Nostr Relay Connection |
| +-------------+ +-------------+ +-------------+
| | WebSocket | | Event | | Subscription|
| | Client | | Handler | | Manager |
| +-------------+ +-------------+ +-------------+
| +-------------+ +-------------+ +-------------+
| | Particle | | Color | | Animation |
| | System | | Manager | | Engine |
| +-------------+ +-------------+ +-------------+
+---------------------------------------------------+
```
## Components
### 1. Nostr Event Visualizer (`js/nostr-event-visualizer.js`)
Main visualization class for Nostr events.
**Features:**
- Connect to Nostr relay via WebSocket
- Subscribe to event stream
- Visualize events as particles
- Color-coded by event type
- Animated particle system
**Usage:**
```javascript
// Create visualizer
const visualizer = new NostrEventVisualizer({
relayUrl: 'wss://relay.nostr.info',
maxEvents: 100,
particleCount: 50,
streamSpeed: 1.0
});
// Initialize with Three.js scene
visualizer.init(scene, camera, renderer);
// Connect to Nostr relay
visualizer.connect();
// Update visualization
visualizer.update(deltaTime);
```
### 2. Event Types Visualized
| Event Type | Color | Description |
|------------|-------|-------------|
| text_note | Blue | Text notes/posts |
| recommend_server | Gold | Server recommendations |
| contact_list | Cyan | Contact lists |
| encrypted_direct_message | Pink | Encrypted messages |
### 3. Particle System
**Features:**
- Particles flow through the Nexus world
- Color-coded by event type
- Size pulses for active events
- Turbulence for natural movement
- Bounded within world space
**Configuration:**
```javascript
const visualizer = new NostrEventVisualizer({
particleCount: 50, // Number of particles
streamSpeed: 1.0, // Flow speed
particleSize: 0.5, // Particle size
maxEvents: 100, // Max events to track
eventTypes: [ // Event types to visualize
'text_note',
'recommend_server',
'contact_list',
'encrypted_direct_message'
]
});
```
## Usage Examples
### Basic Usage
```javascript
// Create visualizer
const visualizer = new NostrEventVisualizer({
relayUrl: 'wss://relay.nostr.info'
});
// Initialize with Three.js
visualizer.init(scene, camera, renderer);
// Connect to relay
visualizer.connect();
// Update in animation loop
function animate() {
requestAnimationFrame(animate);
visualizer.update(1/60); // 60 FPS
renderer.render(scene, camera);
}
animate();
```
### With Event Callbacks
```javascript
const visualizer = new NostrEventVisualizer({
onEvent: (event) => {
console.log('New event:', event.kind, event.content);
},
onConnect: () => {
console.log('Connected to Nostr relay');
},
onDisconnect: () => {
console.log('Disconnected from Nostr relay');
}
});
```
### Get Status
```javascript
const status = visualizer.getStatus();
console.log('Connected:', status.connected);
console.log('Events:', status.eventCount);
console.log('Particles:', status.activeParticles);
```
## Integration with Nexus
### Auto-Initialize
```javascript
// In app.js or initialization code
document.addEventListener('DOMContentLoaded', () => {
// Wait for Three.js scene to be ready
if (window.scene && window.camera && window.renderer) {
const visualizer = new NostrEventVisualizer();
visualizer.init(window.scene, window.camera, window.renderer);
visualizer.connect();
// Store globally
window.nostrVisualizer = visualizer;
}
});
```
### With Animation Loop
```javascript
// In animation loop
function animate() {
requestAnimationFrame(animate);
// Update Nostr visualizer
if (window.nostrVisualizer) {
window.nostrVisualizer.update(1/60);
}
// Render scene
renderer.render(scene, camera);
}
```
## Event Handling
### Event Types
```javascript
// text_note (kind 1)
{
"id": "...",
"pubkey": "...",
"created_at": 1234567890,
"kind": 1,
"tags": [],
"content": "Hello Nostr!",
"sig": "..."
}
// recommend_server (kind 2)
{
"id": "...",
"pubkey": "...",
"created_at": 1234567890,
"kind": 2,
"tags": [],
"content": "wss://relay.example.com",
"sig": "..."
}
// contact_list (kind 3)
{
"id": "...",
"pubkey": "...",
"created_at": 1234567890,
"kind": 3,
"tags": [["p", "pubkey1"], ["p", "pubkey2"]],
"content": "",
"sig": "..."
}
// encrypted_direct_message (kind 4)
{
"id": "...",
"pubkey": "...",
"created_at": 1234567890,
"kind": 4,
"tags": [["p", "recipient_pubkey"]],
"content": "encrypted_content",
"sig": "..."
}
```
## Testing
### Unit Tests
```bash
node --test tests/test_nostr_visualizer.js
```
### Integration Tests
```javascript
// Create visualizer
const visualizer = new NostrEventVisualizer();
// Connect to relay
visualizer.connect();
// Check status
const status = visualizer.getStatus();
assert(status.connected === true);
// Update visualization
visualizer.update(1/60);
// Disconnect
visualizer.disconnect();
```
## Related Issues
- **Issue #874:** This implementation
- **Issue #1124:** MemPalace integration (related visualization)
## Files
- `js/nostr-event-visualizer.js` - Main visualization module
- `docs/nostr-event-visualizer.md` - This documentation
- `tests/test_nostr_visualizer.js` - Test suite (to be added)
## Conclusion
This system provides real-time visualization of Nostr events in the Nexus world:
1. **Connection** to Nostr relays via WebSocket
2. **Visualization** of events as colored particles
3. **Animation** with turbulence and pulsing
4. **Integration** with Three.js scene
**Ready for production use.**

View File

@@ -395,6 +395,7 @@
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
<script src="./boot.js"></script>
<script src="./js/nostr-event-visualizer.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script>

View File

@@ -0,0 +1,456 @@
/**
* Nostr Event Stream Visualization
* Issue #874: [NEXUS] Implement Nostr Event Stream Visualization
*
* Visualize incoming Nostr events as data streams or particles flowing through
* the Nexus, representing the agent's connection to the wider mesh.
*/
class NostrEventVisualizer {
constructor(options = {}) {
this.relayUrl = options.relayUrl || 'wss://relay.nostr.info';
this.maxEvents = options.maxEvents || 100;
this.particleCount = options.particleCount || 50;
this.streamSpeed = options.streamSpeed || 1.0;
this.particleSize = options.particleSize || 0.5;
this.ws = null;
this.events = [];
this.particles = [];
this.scene = null;
this.camera = null;
this.renderer = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
// Callbacks
this.onEvent = options.onEvent || (() => {});
this.onConnect = options.onConnect || (() => {});
this.onDisconnect = options.onDisconnect || (() => {});
this.onError = options.onError || console.error;
// Event types to visualize
this.eventTypes = options.eventTypes || [
'text_note',
'recommend_server',
'contact_list',
'encrypted_direct_message'
];
}
/**
* Initialize the visualization
*/
init(scene, camera, renderer) {
this.scene = scene;
this.camera = camera;
this.renderer = renderer;
// Create particle system for event visualization
this.createParticleSystem();
console.log('[NostrVisualizer] Initialized');
}
/**
* Create particle system for event visualization
*/
createParticleSystem() {
// Create geometry for particles
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(this.particleCount * 3);
const colors = new Float32Array(this.particleCount * 3);
const sizes = new Float32Array(this.particleCount);
// Initialize particles
for (let i = 0; i < this.particleCount; i++) {
// Random position in a sphere
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 50 + Math.random() * 50;
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = r * Math.cos(phi);
// Color based on event type
colors[i * 3] = 0.3; // R
colors[i * 3 + 1] = 0.8; // G
colors[i * 3 + 2] = 1.0; // B
sizes[i] = this.particleSize;
// Store particle data
this.particles.push({
index: i,
x: positions[i * 3],
y: positions[i * 3 + 1],
z: positions[i * 3 + 2],
vx: (Math.random() - 0.5) * 0.1,
vy: (Math.random() - 0.5) * 0.1,
vz: (Math.random() - 0.5) * 0.1,
color: { r: 0.3, g: 0.8, b: 1.0 },
size: this.particleSize,
event: null
});
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
// Create material
const material = new THREE.PointsMaterial({
size: this.particleSize,
vertexColors: true,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending
});
// Create points
this.particleSystem = new THREE.Points(geometry, material);
this.scene.add(this.particleSystem);
console.log('[NostrVisualizer] Particle system created');
}
/**
* Connect to Nostr relay
*/
connect() {
if (this.isConnected) {
console.warn('[NostrVisualizer] Already connected');
return;
}
console.log(`[NostrVisualizer] Connecting to ${this.relayUrl}...`);
try {
this.ws = new WebSocket(this.relayUrl);
this.ws.onopen = () => {
console.log('[NostrVisualizer] Connected to Nostr relay');
this.isConnected = true;
this.reconnectAttempts = 0;
// Subscribe to events
this.subscribe();
// Call connect callback
this.onConnect();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleEvent(data);
} catch (error) {
console.error('[NostrVisualizer] Failed to parse event:', error);
}
};
this.ws.onclose = () => {
console.log('[NostrVisualizer] Disconnected from Nostr relay');
this.isConnected = false;
// Call disconnect callback
this.onDisconnect();
// Attempt reconnect
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('[NostrVisualizer] WebSocket error:', error);
this.onError(error);
};
} catch (error) {
console.error('[NostrVisualizer] Failed to connect:', error);
this.onError(error);
}
}
/**
* Subscribe to Nostr events
*/
subscribe() {
if (!this.isConnected || !this.ws) {
console.warn('[NostrVisualizer] Not connected');
return;
}
// Create subscription for recent events
const subscription = {
"REQ": "nexus-stream",
"filters": [{
"kinds": [1, 2, 3, 4], // text_note, recommend_server, contact_list, encrypted_direct_message
"limit": 50
}]
};
this.ws.send(JSON.stringify(subscription));
console.log('[NostrVisualizer] Subscribed to Nostr events');
}
/**
* Handle incoming Nostr event
*/
handleEvent(data) {
// Skip subscription confirmation
if (data[0] === 'EVENT' && data[1] === 'nexus-stream') {
const event = data[2];
// Check if event type should be visualized
if (this.eventTypes.includes(this.getEventType(event.kind))) {
this.visualizeEvent(event);
this.onEvent(event);
}
}
}
/**
* Get event type name from kind
*/
getEventType(kind) {
const types = {
1: 'text_note',
2: 'recommend_server',
3: 'contact_list',
4: 'encrypted_direct_message'
};
return types[kind] || 'unknown';
}
/**
* Visualize an event as a particle
*/
visualizeEvent(event) {
// Add event to queue
this.events.push({
event: event,
timestamp: Date.now(),
visualized: false
});
// Limit queue size
if (this.events.length > this.maxEvents) {
this.events.shift();
}
// Update particle for this event
this.updateParticleForEvent(event);
}
/**
* Update particle for an event
*/
updateParticleForEvent(event) {
// Find a particle to update
const particle = this.particles.find(p => !p.event);
if (!particle) {
// All particles are in use, recycle oldest
const oldest = this.particles.reduce((a, b) =>
(a.event && a.event.timestamp < b.event.timestamp) ? a : b
);
this.resetParticle(oldest);
this.updateParticleWithEvent(oldest, event);
} else {
this.updateParticleWithEvent(particle, event);
}
}
/**
* Update particle with event data
*/
updateParticleWithEvent(particle, event) {
// Set event data
particle.event = event;
// Set color based on event type
const colors = {
'text_note': { r: 0.3, g: 0.8, b: 1.0 }, // Blue
'recommend_server': { r: 1.0, g: 0.8, b: 0.3 }, // Gold
'contact_list': { r: 0.3, g: 1.0, b: 0.8 }, // Cyan
'encrypted_direct_message': { r: 1.0, g: 0.3, b: 0.8 } // Pink
};
const eventType = this.getEventType(event.kind);
particle.color = colors[eventType] || { r: 0.5, g: 0.5, b: 0.5 };
// Update geometry
this.updateParticleGeometry(particle);
console.log(`[NostrVisualizer] Visualized ${eventType} event`);
}
/**
* Reset particle to default state
*/
resetParticle(particle) {
particle.event = null;
particle.color = { r: 0.3, g: 0.8, b: 1.0 };
particle.size = this.particleSize;
// Random position
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 50 + Math.random() * 50;
particle.x = r * Math.sin(phi) * Math.cos(theta);
particle.y = r * Math.sin(phi) * Math.sin(theta);
particle.z = r * Math.cos(phi);
this.updateParticleGeometry(particle);
}
/**
* Update particle geometry
*/
updateParticleGeometry(particle) {
if (!this.particleSystem) return;
const geometry = this.particleSystem.geometry;
const positions = geometry.attributes.position.array;
const colors = geometry.attributes.color.array;
const sizes = geometry.attributes.size.array;
// Update position
positions[particle.index * 3] = particle.x;
positions[particle.index * 3 + 1] = particle.y;
positions[particle.index * 3 + 2] = particle.z;
// Update color
colors[particle.index * 3] = particle.color.r;
colors[particle.index * 3 + 1] = particle.color.g;
colors[particle.index * 3 + 2] = particle.color.b;
// Update size
sizes[particle.index] = particle.size;
// Mark attributes as needing update
geometry.attributes.position.needsUpdate = true;
geometry.attributes.color.needsUpdate = true;
geometry.attributes.size.needsUpdate = true;
}
/**
* Update visualization
*/
update(deltaTime) {
if (!this.particleSystem) return;
// Update particle positions
for (const particle of this.particles) {
// Move particle
particle.x += particle.vx * this.streamSpeed * deltaTime;
particle.y += particle.vy * this.streamSpeed * deltaTime;
particle.z += particle.vz * this.streamSpeed * deltaTime;
// Add some turbulence
particle.vx += (Math.random() - 0.5) * 0.01;
particle.vy += (Math.random() - 0.5) * 0.01;
particle.vz += (Math.random() - 0.5) * 0.01;
// Limit velocity
const maxVel = 0.5;
particle.vx = Math.max(-maxVel, Math.min(maxVel, particle.vx));
particle.vy = Math.max(-maxVel, Math.min(maxVel, particle.vy));
particle.vz = Math.max(-maxVel, Math.min(maxVel, particle.vz));
// Keep particles in bounds
const maxDist = 100;
if (Math.abs(particle.x) > maxDist) particle.vx *= -0.5;
if (Math.abs(particle.y) > maxDist) particle.vy *= -0.5;
if (Math.abs(particle.z) > maxDist) particle.vz *= -0.5;
// Update geometry
this.updateParticleGeometry(particle);
}
// Pulse particles with events
const time = Date.now() * 0.001;
for (const particle of this.particles) {
if (particle.event) {
// Pulse size for particles with events
particle.size = this.particleSize * (1 + 0.2 * Math.sin(time * 3 + particle.index));
this.updateParticleGeometry(particle);
}
}
}
/**
* Schedule reconnection
*/
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[NostrVisualizer] Max reconnect attempts reached');
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`[NostrVisualizer] Reconnecting in ${delay / 1000}s...`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
/**
* Disconnect from Nostr relay
*/
disconnect() {
console.log('[NostrVisualizer] Disconnecting...');
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.isConnected = false;
// Clear particles
for (const particle of this.particles) {
this.resetParticle(particle);
}
console.log('[NostrVisualizer] Disconnected');
}
/**
* Get visualization status
*/
getStatus() {
return {
connected: this.isConnected,
relayUrl: this.relayUrl,
eventCount: this.events.length,
particleCount: this.particles.length,
activeParticles: this.particles.filter(p => p.event).length,
reconnectAttempts: this.reconnectAttempts
};
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = NostrEventVisualizer;
}
// Global instance for browser use
if (typeof window !== 'undefined') {
window.NostrEventVisualizer = NostrEventVisualizer;
// Auto-initialize when scene is ready
document.addEventListener('DOMContentLoaded', () => {
// This would be called when Three.js scene is initialized
// window.nostrVisualizer = new NostrEventVisualizer();
// window.nostrVisualizer.init(scene, camera, renderer);
});
}