Compare commits

..

1 Commits

Author SHA1 Message Date
72195ea957 fix: extract clean_lines/parse_room_output/normalize_event to module level (#1509)
Some checks failed
CI / test (pull_request) Failing after 1m21s
CI / validate (pull_request) Failing after 1m17s
Review Approval Gate / verify-review (pull_request) Successful in 12s
Test file imports clean_lines, parse_room_output, normalize_event from
nexus.evennia_ws_bridge, but they were nested inside playback() function.
Moved all three to module level so they're importable.

Also moved actor_located/command_issued/command_result/room_snapshot/session_bound
imports from nested playback() to module-level import block.

playback() now calls module-level functions directly.
2026-04-15 03:01:37 +00:00
5 changed files with 140 additions and 645 deletions

View File

@@ -1,116 +0,0 @@
# .gitea/workflows/check-pr-changes.yml
# CI workflow to prevent rubber-stamping of PRs with no changes
# Issue #1445: process: Prevent rubber-stamping of PRs with no changes
name: Check PR for Changes
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
check-changes:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch full history for diff comparison
- name: Check for empty PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get PR number from context
PR_NUMBER="${{ github.event.pull_request.number }}"
echo "Checking PR #$PR_NUMBER for changes..."
# Get the base and head commits
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# Get diff stats
DIFF_STATS=$(git diff --stat "$BASE_SHA" "$HEAD_SHA")
if [ -z "$DIFF_STATS" ]; then
echo "❌ ERROR: PR has no changes!"
echo ""
echo "This PR has 0 additions, 0 deletions, and 0 files changed."
echo "This is a 'zombie PR' that should not be merged."
echo ""
echo "Rubber-stamping (approving PRs with no changes) is prohibited."
echo "Reviewers must verify that PRs contain actual changes."
echo ""
echo "If this is a mistake, please add actual changes to the PR."
echo "If this PR is not needed, please close it."
exit 1
else
echo "✅ PR has changes:"
echo "$DIFF_STATS"
# Get detailed stats
ADDITIONS=$(git diff --numstat "$BASE_SHA" "$HEAD_SHA" | awk '{sum+=$1} END {print sum}')
DELETIONS=$(git diff --numstat "$BASE_SHA" "$HEAD_SHA" | awk '{sum+=$2} END {print sum}')
FILES_CHANGED=$(git diff --numstat "$BASE_SHA" "$HEAD_SHA" | wc -l)
echo ""
echo "Summary:"
echo " Files changed: $FILES_CHANGED"
echo " Additions: $ADDITIONS"
echo " Deletions: $DELETIONS"
# Check if this is a "zombie PR" (no actual changes)
if [ "$ADDITIONS" -eq 0 ] && [ "$DELETIONS" -eq 0 ]; then
echo ""
echo "⚠️ WARNING: PR has files changed but no additions or deletions!"
echo "This might be a binary file change or permission change."
echo "Reviewers should verify this is intentional."
fi
fi
- name: Check for empty commits
run: |
# Check if there are any commits with no changes
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
# Get list of commits
COMMITS=$(git log --oneline "$BASE_SHA".."$HEAD_SHA")
if [ -z "$COMMITS" ]; then
echo "❌ ERROR: PR has no commits!"
exit 1
fi
echo "Commits in this PR:"
echo "$COMMITS"
# Check each commit for changes
EMPTY_COMMITS=0
while IFS= read -r commit; do
COMMIT_SHA=$(echo "$commit" | awk '{print $1}')
COMMIT_MSG=$(echo "$commit" | cut -d' ' -f2-)
# Get parent commit
PARENT_SHA=$(git rev-parse "$COMMIT_SHA^" 2>/dev/null || echo "")
if [ -n "$PARENT_SHA" ]; then
# Check if commit has changes
COMMIT_DIFF=$(git diff --stat "$PARENT_SHA" "$COMMIT_SHA")
if [ -z "$COMMIT_DIFF" ]; then
echo "⚠️ WARNING: Commit $COMMIT_SHA has no changes!"
echo " Message: $COMMIT_MSG"
EMPTY_COMMITS=$((EMPTY_COMMITS + 1))
fi
fi
done <<< "$COMMITS"
if [ "$EMPTY_COMMITS" -gt 0 ]; then
echo ""
echo "⚠️ Found $EMPTY_COMMITS commit(s) with no changes."
echo "Consider squashing or amending these commits."
fi

View File

@@ -1,73 +1,65 @@
## Description
<!-- Provide a clear description of what this PR does -->
## Changes Made
<!-- List the specific changes made in this PR -->
### Files Changed
<!-- List the files that were modified -->
### Type of Change
<!-- Check the relevant option -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
- [ ] Test updates
- [ ] CI/CD changes
## Testing
<!-- Describe the tests you ran to verify your changes -->
### Test Instructions
<!-- Provide step-by-step instructions to test your changes -->
## Checklist
<!-- Check all that apply -->
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules
## Reviewer Guidelines
<!-- IMPORTANT: Reviewers must follow these guidelines to prevent rubber-stamping -->
### ⚠️ Reviewers MUST verify:
1. **PR has actual changes** - Check that the PR contains additions, deletions, or modifications
2. **Changes match description** - Verify the changes match what's described in the PR
3. **Code quality** - Review code for bugs, security issues, performance problems
4. **Tests are adequate** - Ensure new code is properly tested
5. **Documentation is updated** - Check if documentation needs updates
### ❌ DO NOT approve if:
- PR has 0 additions, 0 deletions, and 0 files changed (zombie PR)
- Changes don't match the PR description
- Code has obvious bugs or security issues
- No tests for new functionality
- Documentation is missing or incorrect
### ✅ DO approve if:
- PR has meaningful changes that match the description
- Code is clean, well-tested, and documented
- Changes follow project conventions
- No obvious issues or risks
## Related Issues
<!-- Link any related issues -->
- Fixes #<!-- issue number -->
- Related to #<!-- issue number -->
## Additional Notes
<!-- Add any other context about the PR here -->
---
**By submitting this PR, I confirm that:**
1. I have actually reviewed the code changes
2. The changes are meaningful and not a zombie PR
3. I have tested the changes locally (if applicable)
4. I understand that rubber-stamping (approving PRs with no changes) is prohibited
**⚠️ Before submitting your pull request:**
1. [x] I've read [BRANCH_PROTECTION.md](BRANCH_PROTECTION.md)
2. [x] I've followed [CONTRIBUTING.md](CONTRIBUTING.md) guidelines
3. [x] My changes have appropriate test coverage
4. [x] I've updated documentation where needed
5. [x] I've verified CI passes (where applicable)
**Context:**
<Describe your changes and why they're needed>
**Testing:**
<Explain how this was tested>
**Questions for reviewers:**
<Ask specific questions if needed>
## Pull Request Template
### Description
[Explain your changes briefly]
### Checklist
- [ ] Branch protection rules followed
- [ ] Required reviewers: @perplexity (QA), @Timmy (hermes-agent)
- [ ] CI passed (where applicable)
### Questions for Reviewers
- [ ] Any special considerations?
- [ ] Does this require additional documentation?
# Pull Request Template
## Summary
Briefly describe the changes in this PR.
## Reviewers
- Default reviewer: @perplexity
- Required reviewer for hermes-agent: @Timmy
## Branch Protection Compliance
- [ ] PR created
- [ ] 1+ approvals
- [ ] ci passed (where applicable)
- [ ] No force pushes
- [ ] No branch deletions
## Specialized Owners
- [ ] @Rockachopa (for agent-core)
- [ ] @Timmy (for ai/)
## Pull Request Template
### Summary
- [ ] Describe the change
- [ ] Link to related issue (e.g. `Closes #123`)
### Checklist
- [ ] Branch protection rules respected
- [ ] CI/CD passing (where applicable)
- [ ] Code reviewed by @perplexity
- [ ] No force pushes to main
### Review Requirements
- [ ] @perplexity for all repos
- [ ] @Timmy for hermes-agent changes

View File

@@ -1,193 +0,0 @@
#!/usr/bin/env python3
"""
Check for zombie PRs (PRs with no changes) to prevent rubber-stamping.
Issue #1445: process: Prevent rubber-stamping of PRs with no changes
"""
import json
import os
import sys
import urllib.request
import subprocess
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"
class ZombiePRChecker:
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) -> Any:
"""Make authenticated Gitea API request."""
url = f"{GITEA_BASE}{endpoint}"
headers = {"Authorization": f"token {self.token}"}
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
return None
error_body = e.read().decode() if e.fp else "No error body"
print(f"API Error {e.code}: {error_body}")
return None
def get_open_prs(self, repo: str) -> List[Dict]:
"""Get open PRs for a repository."""
endpoint = f"/repos/{ORG}/{repo}/pulls?state=open"
prs = self._api_request(endpoint)
return prs if isinstance(prs, list) else []
def get_pr_files(self, repo: str, pr_number: int) -> List[Dict]:
"""Get files changed in a PR."""
endpoint = f"/repos/{ORG}/{repo}/pulls/{pr_number}/files"
files = self._api_request(endpoint)
return files if isinstance(files, list) else []
def is_zombie_pr(self, repo: str, pr_number: int) -> Dict[str, Any]:
"""Check if a PR is a zombie (no actual changes)."""
pr_files = self.get_pr_files(repo, pr_number)
# Calculate total changes
total_additions = sum(f.get("additions", 0) for f in pr_files)
total_deletions = sum(f.get("deletions", 0) for f in pr_files)
total_changes = sum(f.get("changes", 0) for f in pr_files)
# A zombie PR has no additions, deletions, or changes
is_zombie = (total_additions == 0 and total_deletions == 0 and total_changes == 0)
return {
"repo": repo,
"pr_number": pr_number,
"is_zombie": is_zombie,
"files_changed": len(pr_files),
"total_additions": total_additions,
"total_deletions": total_deletions,
"total_changes": total_changes,
"files": pr_files
}
def scan_repo_for_zombies(self, repo: str) -> List[Dict]:
"""Scan a repository for zombie PRs."""
open_prs = self.get_open_prs(repo)
zombies = []
print(f"Scanning {repo} for zombie PRs...")
print(f"Found {len(open_prs)} open PRs")
for pr in open_prs:
pr_number = pr["number"]
pr_title = pr["title"]
# Check if it's a zombie
zombie_info = self.is_zombie_pr(repo, pr_number)
if zombie_info["is_zombie"]:
zombie_info["title"] = pr_title
zombie_info["author"] = pr["user"]["login"]
zombie_info["created"] = pr["created_at"]
zombies.append(zombie_info)
print(f" 🧟 ZOMBIE: #{pr_number} - {pr_title}")
else:
print(f" ✅ OK: #{pr_number} - {pr_title} ({zombie_info['total_changes']} changes)")
return zombies
def generate_report(self, zombies_by_repo: Dict[str, List[Dict]]) -> str:
"""Generate a report of zombie PRs found."""
total_zombies = sum(len(zombies) for zombies in zombies_by_repo.values())
report = "# Zombie PR Detection Report\n\n"
report += f"## Summary\n"
report += f"- **Total zombie PRs found:** {total_zombies}\n"
report += f"- **Repositories scanned:** {len(zombies_by_repo)}\n\n"
if total_zombies == 0:
report += "✅ **No zombie PRs found.**\n"
else:
report += "⚠️ **Zombie PRs found:**\n\n"
for repo, zombies in zombies_by_repo.items():
if zombies:
report += f"### {repo}\n"
for zombie in zombies:
report += f"- **#{zombie['pr_number']}**: {zombie['title']}\n"
report += f" - Author: {zombie['author']}\n"
report += f" - Created: {zombie['created']}\n"
report += f" - Files changed: {zombie['files_changed']}\n"
report += f" - Total changes: {zombie['total_changes']}\n"
report += "\n"
# Add recommendations
report += "## Recommendations\n"
report += "1. **Close zombie PRs** - PRs with no actual changes should be closed\n"
report += "2. **Validate before merge** - CI should reject PRs with no changes\n"
report += "3. **Prevent future zombies** - Agents should validate changes before creating PRs\n"
report += "4. **Review process** - Reviewers must verify PRs have actual changes\n"
return report
def main():
"""Main entry point for zombie PR checker."""
import argparse
parser = argparse.ArgumentParser(description="Check for zombie PRs (PRs with no actual changes)")
parser.add_argument("--repos", nargs="+",
default=["the-nexus", "timmy-home", "timmy-config", "hermes-agent", "the-beacon"],
help="Repositories to scan")
parser.add_argument("--report", action="store_true", help="Generate report")
parser.add_argument("--json", action="store_true", help="Output JSON instead of report")
args = parser.parse_args()
checker = ZombiePRChecker()
# Scan repositories for zombie PRs
zombies_by_repo = {}
for repo in args.repos:
zombies = checker.scan_repo_for_zombies(repo)
zombies_by_repo[repo] = zombies
# Generate output
if args.json:
print(json.dumps(zombies_by_repo, indent=2))
elif args.report:
report = checker.generate_report(zombies_by_repo)
print(report)
else:
# Default: show summary
total_zombies = sum(len(zombies) for zombies in zombies_by_repo.values())
print(f"\nZombie PR Detection Complete")
print("=" * 60)
print(f"Total zombie PRs found: {total_zombies}")
if total_zombies > 0:
print("\nZombie PRs:")
for repo, zombies in zombies_by_repo.items():
for zombie in zombies:
print(f" {repo} #{zombie['pr_number']}: {zombie['title']}")
sys.exit(1)
else:
print("\n✅ No zombie PRs found")
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1,189 +0,0 @@
# Preventing Rubber-Stamping of PRs
**Issue:** #1445 - process: Prevent rubber-stamping of PRs with no changes
**Problem:** PRs with no changes (zombie PRs) are being approved without actual review
## What is Rubber-Stamping?
Rubber-stamping occurs when:
1. A PR has 0 additions, 0 deletions, and 0 files changed (zombie PR)
2. Reviewers approve the PR without noticing it has no changes
3. The PR gets merged despite adding no value
This is a serious process issue because:
- It wastes reviewer time
- It creates false sense of review quality
- It allows zombie PRs to appear reviewed
- It clutters the PR backlog
## Prevention Measures
### 1. CI Check (`.gitea/workflows/check-pr-changes.yml`)
Automated check that runs on every PR:
- Detects PRs with no changes
- Blocks merge if PR is a zombie
- Provides clear error messages
**What it checks:**
- PR has additions, deletions, or file changes
- Commits contain actual changes
- No empty diffs
**When it runs:**
- On PR open
- On PR synchronize (new commits)
- On PR reopen
### 2. PR Template (`.github/PULL_REQUEST_TEMPLATE.md`)
Updated PR template with reviewer guidelines:
- Clear checklist for reviewers
- Explicit instructions to check for changes
- Warning against rubber-stamping
**Reviewer requirements:**
1. Verify PR has actual changes
2. Changes match description
3. Code quality review
4. Tests are adequate
5. Documentation is updated
### 3. Zombie PR Detection Script (`bin/check_zombie_prs.py`)
Script to scan for zombie PRs:
- Check all open PRs in repositories
- Identify PRs with no changes
- Generate reports
**Usage:**
```bash
# Scan all repositories
python bin/check_zombie_prs.py
# Scan specific repositories
python bin/check_zombie_prs.py --repos the-nexus timmy-home
# Generate report
python bin/check_zombie_prs.py --report
# JSON output
python bin/check_zombie_prs.py --json
```
## How to Use
### For CI/CD
The workflow runs automatically on all PRs. No setup needed.
### For Developers
1. **Before creating PR:**
- Ensure you have actual changes
- Test your changes locally
- Don't create PRs with no changes
2. **When reviewing PRs:**
- Check that PR has additions, deletions, or file changes
- Verify changes match the PR description
- Don't approve PRs with no changes
3. **If you find a zombie PR:**
- Add a comment explaining it has no changes
- Request changes or close the PR
- Don't approve it
### For Agents (AI Workers)
Before creating a PR:
```bash
# Check if you have changes
git status
git diff --stat
# If no changes, don't create PR
# If changes exist, create PR
```
## Examples
### Zombie PR Detected
```
❌ ERROR: PR has no changes!
This PR has 0 additions, 0 deletions, and 0 files changed.
This is a 'zombie PR' that should not be merged.
Rubber-stamping (approving PRs with no changes) is prohibited.
Reviewers must verify that PRs contain actual changes.
If this is a mistake, please add actual changes to the PR.
If this PR is not needed, please close it.
```
### Valid PR
```
✅ PR has changes:
README.md | 10 ++++++++++
1 file changed, 10 insertions(+)
Summary:
Files changed: 1
Additions: 10
Deletions: 0
```
## Related Issues
- **Issue #1127:** Perplexity Evening Pass triage (identified rubber-stamping)
- **Issue #1445:** This implementation
- **PR #359:** Example of rubber-stamping (3 approvals on empty PR)
## Prevention Strategy
### 1. **Automated Checks**
- CI workflow blocks zombie PRs
- Pre-commit hooks validate changes
- Automated scanning for zombie PRs
### 2. **Process Guidelines**
- Updated PR template with reviewer guidelines
- Clear instructions to check for changes
- Training on rubber-stamping prevention
### 3. **Monitoring**
- Regular scans for zombie PRs
- Reports on rubber-stamping incidents
- Continuous improvement of prevention measures
## Files Added
1. `.gitea/workflows/check-pr-changes.yml` - CI workflow
2. `.github/PULL_REQUEST_TEMPLATE.md` - Updated PR template
3. `bin/check_zombie_prs.py` - Zombie PR detection script
4. `docs/rubber-stamping-prevention.md` - This documentation
## Testing
Test the CI workflow:
```bash
# Create a test PR with no changes
git checkout -b test/zombie-pr
git commit --allow-empty -m "test: empty commit"
git push origin test/zombie-pr
# Create PR and watch CI fail
```
Test the detection script:
```bash
python bin/check_zombie_prs.py --repos the-nexus --report
```
## Conclusion
This implementation provides comprehensive protection against rubber-stamping:
1. **Automated CI checks** block zombie PRs
2. **Updated PR template** guides reviewers
3. **Detection script** identifies existing zombie PRs
4. **Documentation** explains the problem and solution
**Result:** No more rubber-stamping of PRs with no changes.
## License
Part of the Timmy Foundation project.

View File

@@ -28,11 +28,16 @@ except ImportError:
websockets = None
from nexus.evennia_event_adapter import (
actor_located,
audit_heartbeat,
command_executed,
command_issued,
command_result,
player_join,
player_leave,
player_move,
room_snapshot,
session_bound,
)
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
@@ -49,31 +54,82 @@ def strip_ansi(text: str) -> str:
return ANSI_RE.sub("", text or "")
def clean_lines(text: str) -> list[str]:
"""Strip ANSI codes and split into non-empty lines."""
text = strip_ansi(text).replace("\r", "")
return [line.strip() for line in text.split("\n") if line.strip()]
def parse_room_output(text: str) -> dict | None:
"""Parse Evennia room output into structured data with title, desc, exits, objects."""
lines = clean_lines(text)
if len(lines) < 2:
return None
title = lines[0]
desc = lines[1]
exits = []
objects = []
for line in lines[2:]:
if line.startswith("Exits:"):
raw = line.split(":", 1)[1].strip().replace(" and ", ", ")
exits = [{"key": t.strip(), "destination_id": t.strip().title(), "destination_key": t.strip().title()} for t in raw.split(",") if t.strip()]
elif line.startswith("You see:"):
raw = line.split(":", 1)[1].strip().replace(" and ", ", ")
parts = [t.strip() for t in raw.split(",") if t.strip()]
objects = [{"id": p.removeprefix("a ").removeprefix("an "), "key": p.removeprefix("a ").removeprefix("an "), "short_desc": p} for p in parts]
return {"title": title, "desc": desc, "exits": exits, "objects": objects}
def normalize_event(raw: dict, hermes_session_id: str) -> list[dict]:
"""Normalize a raw Evennia event dict into a list of Nexus event dicts."""
out = []
event = raw.get("event")
actor = raw.get("actor", "Timmy")
timestamp = raw.get("timestamp")
if event == "connect":
out.append(session_bound(hermes_session_id, evennia_account=actor, evennia_character=actor, timestamp=timestamp))
parsed = parse_room_output(raw.get("output", ""))
if parsed:
out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp))
out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp))
elif event == "command":
cmd = raw.get("command", "")
output = raw.get("output", "")
out.append(command_issued(hermes_session_id, actor, cmd, timestamp=timestamp))
success = not output.startswith("Command '") and not output.startswith("Could not find")
out.append(command_result(hermes_session_id, actor, cmd, strip_ansi(output), success=success, timestamp=timestamp))
parsed = parse_room_output(output)
if parsed:
out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp))
out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp))
return out
class LogTailer:
"""Async file tailer that yields new lines as they appear."""
def __init__(self, path: str, poll_interval: float = 0.5):
self.path = path
self.poll_interval = poll_interval
self._offset = 0
async def tail(self):
"""Yield new lines from the file, starting from end."""
# Start at end of file
if os.path.exists(self.path):
self._offset = os.path.getsize(self.path)
while True:
try:
if not os.path.exists(self.path):
await asyncio.sleep(self.poll_interval)
continue
size = os.path.getsize(self.path)
if size < self._offset:
# File was truncated/rotated
self._offset = 0
if size > self._offset:
with open(self.path, "r") as f:
f.seek(self._offset)
@@ -82,7 +138,7 @@ class LogTailer:
if line:
yield line
self._offset = f.tell()
await asyncio.sleep(self.poll_interval)
except Exception as e:
print(f"[tailer] Error reading {self.path}: {e}", flush=True)
@@ -91,44 +147,44 @@ class LogTailer:
def parse_log_line(line: str) -> Optional[dict]:
"""Parse a log line into a Nexus event, or None if not parseable."""
# Movement events
m = MOVE_RE.search(line)
if m:
return player_move(m.group(1), m.group(3), m.group(2))
# Command events
m = CMD_RE.search(line)
if m:
return command_executed(m.group(1), m.group(2), m.group(3) or "")
# Session start
m = SESSION_START_RE.search(line)
if m:
return player_join(m.group(2), m.group(1))
# Session end
m = SESSION_END_RE.search(line)
if m:
return player_leave("", m.group(1), session_duration=float(m.group(2)))
# Server login
m = LOGIN_RE.search(line)
if m:
return player_join(m.group(1), ip_address=m.group(2))
# Server logout
m = LOGOUT_RE.search(line)
if m:
return player_leave(m.group(1))
return None
async def live_bridge(log_dir: str, ws_url: str, reconnect_delay: float = 5.0):
"""
Main live bridge loop.
Tails all Evennia log files and streams parsed events to Nexus WebSocket.
Auto-reconnects on failure.
"""
@@ -138,9 +194,9 @@ async def live_bridge(log_dir: str, ws_url: str, reconnect_delay: float = 5.0):
os.path.join(log_dir, "player_activity.log"),
os.path.join(log_dir, "server.log"),
]
event_queue: asyncio.Queue = asyncio.Queue(maxsize=10000)
async def tail_file(path: str):
"""Tail a single file and put events on queue."""
tailer = LogTailer(path)
@@ -151,7 +207,7 @@ async def live_bridge(log_dir: str, ws_url: str, reconnect_delay: float = 5.0):
event_queue.put_nowait(event)
except asyncio.QueueFull:
pass # Drop oldest if queue full
async def ws_sender():
"""Send events from queue to WebSocket, with auto-reconnect."""
while True:
@@ -162,7 +218,7 @@ async def live_bridge(log_dir: str, ws_url: str, reconnect_delay: float = 5.0):
event = await event_queue.get()
ts = event.get("timestamp", "")[:19]
print(f"[{ts}] {event['type']}: {json.dumps({k: v for k, v in event.items() if k not in ('type', 'timestamp')})}", flush=True)
print(f"[bridge] Connecting to {ws_url}...", flush=True)
async with websockets.connect(ws_url) as ws:
print(f"[bridge] Connected to Nexus at {ws_url}", flush=True)
@@ -172,67 +228,17 @@ async def live_bridge(log_dir: str, ws_url: str, reconnect_delay: float = 5.0):
except Exception as e:
print(f"[bridge] WebSocket error: {e}. Reconnecting in {reconnect_delay}s...", flush=True)
await asyncio.sleep(reconnect_delay)
# Start all tailers + sender
tasks = [asyncio.create_task(tail_file(f)) for f in log_files]
tasks.append(asyncio.create_task(ws_sender()))
print(f"[bridge] Live bridge started. Watching {len(log_files)} log files.", flush=True)
await asyncio.gather(*tasks)
async def playback(log_path: Path, ws_url: str):
"""Legacy mode: replay a telemetry JSONL file."""
from nexus.evennia_event_adapter import (
actor_located, command_issued, command_result,
room_snapshot, session_bound,
)
def clean_lines(text: str) -> list[str]:
text = strip_ansi(text).replace("\r", "")
return [line.strip() for line in text.split("\n") if line.strip()]
def parse_room_output(text: str):
lines = clean_lines(text)
if len(lines) < 2:
return None
title = lines[0]
desc = lines[1]
exits = []
objects = []
for line in lines[2:]:
if line.startswith("Exits:"):
raw = line.split(":", 1)[1].strip().replace(" and ", ", ")
exits = [{"key": t.strip(), "destination_id": t.strip().title(), "destination_key": t.strip().title()} for t in raw.split(",") if t.strip()]
elif line.startswith("You see:"):
raw = line.split(":", 1)[1].strip().replace(" and ", ", ")
parts = [t.strip() for t in raw.split(",") if t.strip()]
objects = [{"id": p.removeprefix("a ").removeprefix("an "), "key": p.removeprefix("a ").removeprefix("an "), "short_desc": p} for p in parts]
return {"title": title, "desc": desc, "exits": exits, "objects": objects}
def normalize_event(raw: dict, hermes_session_id: str) -> list[dict]:
out = []
event = raw.get("event")
actor = raw.get("actor", "Timmy")
timestamp = raw.get("timestamp")
if event == "connect":
out.append(session_bound(hermes_session_id, evennia_account=actor, evennia_character=actor, timestamp=timestamp))
parsed = parse_room_output(raw.get("output", ""))
if parsed:
out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp))
out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp))
elif event == "command":
cmd = raw.get("command", "")
output = raw.get("output", "")
out.append(command_issued(hermes_session_id, actor, cmd, timestamp=timestamp))
success = not output.startswith("Command '") and not output.startswith("Could not find")
out.append(command_result(hermes_session_id, actor, cmd, strip_ansi(output), success=success, timestamp=timestamp))
parsed = parse_room_output(output)
if parsed:
out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp))
out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp))
return out
hermes_session_id = log_path.stem
async with websockets.connect(ws_url) as ws:
for line in log_path.read_text(encoding="utf-8").splitlines():
@@ -245,11 +251,6 @@ async def playback(log_path: Path, ws_url: str):
async def inject_event(event_type: str, ws_url: str, **kwargs):
"""Inject a single Evennia event into the Nexus WS gateway. Dev/test use."""
from nexus.evennia_event_adapter import (
actor_located, command_issued, command_result,
room_snapshot, session_bound,
)
builders = {
"room_snapshot": lambda: room_snapshot(
kwargs.get("room_key", "Gate"),