Compare commits
1 Commits
main
...
nexusburn/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b1f5397a |
48
.gitattributes
vendored
48
.gitattributes
vendored
@@ -1,48 +0,0 @@
|
||||
# .gitattributes
|
||||
# Controls git archive exports and helps categorize repo contents.
|
||||
# export-ignore: excluded from `git archive` tarballs and sparse-export contexts.
|
||||
#
|
||||
# For agents blocked by repo size on clone, see CONTRIBUTING.md §"Large-Repo Clone Strategy".
|
||||
|
||||
# ── Documentation & reports (not needed for runtime or tests) ──────────────────
|
||||
docs/ export-ignore
|
||||
reports/ export-ignore
|
||||
audits/ export-ignore
|
||||
reviews/ export-ignore
|
||||
paper/ export-ignore
|
||||
scaffold/ export-ignore
|
||||
playground/ export-ignore
|
||||
examples/ export-ignore
|
||||
intelligence/ export-ignore
|
||||
|
||||
# Root-level narrative docs (keep CLAUDE.md, README.md, CONTRIBUTING.md)
|
||||
FINDINGS-*.md export-ignore
|
||||
FIRST_LIGHT_REPORT*.md export-ignore
|
||||
INVESTIGATION_*.md export-ignore
|
||||
LEGACY_MATRIX_AUDIT.md export-ignore
|
||||
SOUL.md export-ignore
|
||||
POLICY.md export-ignore
|
||||
BROWSER_CONTRACT.md export-ignore
|
||||
EVENNIA_NEXUS_EVENT_PROTOCOL.md export-ignore
|
||||
GAMEPORTAL_PROTOCOL.md export-ignore
|
||||
DEVELOPMENT.md export-ignore
|
||||
|
||||
# ── Operation-specific directories ────────────────────────────────────────────
|
||||
operation-get-a-job/ export-ignore
|
||||
operations/ export-ignore
|
||||
org/ export-ignore
|
||||
concept-packs/ export-ignore
|
||||
evolution/ export-ignore
|
||||
|
||||
# ── Assets (binary/media files not needed for CI) ─────────────────────────────
|
||||
assets/ export-ignore
|
||||
icons/ export-ignore
|
||||
|
||||
# ── Linguist overrides (GitHub/Gitea language stats) ──────────────────────────
|
||||
docs/ linguist-documentation
|
||||
scaffold/ linguist-documentation
|
||||
paper/ linguist-documentation
|
||||
reports/ linguist-documentation
|
||||
audits/ linguist-documentation
|
||||
|
||||
*.md linguist-documentation
|
||||
@@ -6,4 +6,3 @@ rules:
|
||||
require_ci_to_merge: false # CI runner dead (issue #915)
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
block_on_outdated_branch: true
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
name: PR Backlog Monitor
|
||||
|
||||
# Runs every Monday at 06:00 UTC — fires an issue if any repo in the org
|
||||
# accumulates more than PR_THRESHOLD open PRs.
|
||||
#
|
||||
# Background: timmy-config hit 9 open PRs (highest in org) before triage.
|
||||
# This workflow catches future buildups early.
|
||||
# Refs: #1471
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Monday 06:00 UTC
|
||||
workflow_dispatch: {} # allow manual trigger
|
||||
|
||||
env:
|
||||
GITEA_URL: https://forge.alexanderwhitestone.com
|
||||
ORG: Timmy_Foundation
|
||||
PR_THRESHOLD: "5" # file an issue when open PRs >= this value
|
||||
|
||||
jobs:
|
||||
pr-backlog-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Check PR backlog across org repos
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import json, os, sys
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError
|
||||
|
||||
BASE = os.environ["GITEA_URL"]
|
||||
ORG = os.environ["ORG"]
|
||||
TOKEN = os.environ["GITEA_TOKEN"]
|
||||
THRESH = int(os.environ["PR_THRESHOLD"])
|
||||
|
||||
REPOS = ["the-nexus", "timmy-config", "timmy-home", "hermes-agent", "the-beacon"]
|
||||
|
||||
def api(path):
|
||||
req = Request(
|
||||
f"{BASE}/api/v1{path}",
|
||||
headers={"Authorization": f"token {TOKEN}", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
return json.loads(urlopen(req, timeout=30).read())
|
||||
except HTTPError as e:
|
||||
return {"_error": e.code}
|
||||
|
||||
backlog = {}
|
||||
for repo in REPOS:
|
||||
prs = api(f"/repos/{ORG}/{repo}/pulls?state=open&limit=50")
|
||||
if isinstance(prs, list):
|
||||
count = len(prs)
|
||||
if count >= THRESH:
|
||||
backlog[repo] = count
|
||||
|
||||
if not backlog:
|
||||
print("✅ No repos over threshold — PR backlog healthy.")
|
||||
sys.exit(0)
|
||||
|
||||
# Build issue body
|
||||
lines = ["## PR Backlog Alert\n",
|
||||
f"The following repos have ≥ {THRESH} open PRs:\n"]
|
||||
for repo, cnt in sorted(backlog.items(), key=lambda x: -x[1]):
|
||||
lines.append(f"- **{ORG}/{repo}**: {cnt} open PRs")
|
||||
lines += [
|
||||
"",
|
||||
"### Recommended actions",
|
||||
"1. Review and merge ready PRs",
|
||||
"2. Close stale / superseded PRs",
|
||||
"3. Run `python3 scripts/pr_triage.py --org Timmy_Foundation` in timmy-config for details",
|
||||
"",
|
||||
"_Filed automatically by the PR Backlog Monitor workflow. Refs #1471._",
|
||||
]
|
||||
body = "\n".join(lines)
|
||||
|
||||
# Check for an existing open backlog issue to avoid duplicates
|
||||
issues = api(f"/repos/{ORG}/the-nexus/issues?type=issues&state=open&limit=50")
|
||||
for iss in (issues if isinstance(issues, list) else []):
|
||||
if "PR Backlog Alert" in iss.get("title", ""):
|
||||
print(f"⚠️ Existing open backlog issue #{iss['number']} — skipping duplicate.")
|
||||
sys.exit(0)
|
||||
|
||||
import urllib.request
|
||||
payload = json.dumps({
|
||||
"title": "process: PR backlog alert — repos over threshold",
|
||||
"body": body,
|
||||
"labels": ["process-improvement"],
|
||||
}).encode()
|
||||
req = Request(
|
||||
f"{BASE}/api/v1/repos/{ORG}/the-nexus/issues",
|
||||
data=payload,
|
||||
headers={"Authorization": f"token {TOKEN}", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
resp = json.loads(urlopen(req, timeout=30).read())
|
||||
print(f"📋 Filed issue #{resp.get('number')}: {resp.get('html_url')}")
|
||||
sys.exit(1) # fail the workflow so it shows as red in CI
|
||||
EOF
|
||||
1
.github/BRANCH_PROTECTION.md
vendored
1
.github/BRANCH_PROTECTION.md
vendored
@@ -12,7 +12,6 @@ All repositories must enforce these rules on the `main` branch:
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
| Require branch up-to-date before merge | ✅ Enabled | Surface conflicts before merge and force contributors to rebase |
|
||||
|
||||
## Default Reviewer Assignments
|
||||
|
||||
|
||||
69
.github/workflows/pr-duplicate-check.yml
vendored
69
.github/workflows/pr-duplicate-check.yml
vendored
@@ -1,69 +0,0 @@
|
||||
name: Duplicate PR Detection
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run weekly on Monday at 9 AM UTC
|
||||
- cron: '0 9 * * 1'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jq curl
|
||||
|
||||
- name: Check for duplicate PRs
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_URL: ${{ secrets.GITEA_URL || 'https://forge.alexanderwhitestone.com' }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
chmod +x ./scripts/cleanup-duplicate-prs.sh
|
||||
./scripts/cleanup-duplicate-prs.sh --dry-run
|
||||
|
||||
- name: Create issue if duplicates found
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = 'Duplicate PRs Detected';
|
||||
const body = `## Duplicate PRs Found
|
||||
|
||||
The duplicate PR detection workflow found potential duplicate PRs.
|
||||
|
||||
**Action Required:**
|
||||
1. Review the duplicate PRs
|
||||
2. Close older duplicates
|
||||
3. Keep the newest PR for each issue
|
||||
|
||||
**Workflow Run:** ${context.runId}
|
||||
**Repository:** ${context.repo.owner}/${context.repo.repo}
|
||||
|
||||
This issue was automatically created by the duplicate PR detection workflow.`;
|
||||
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
body,
|
||||
labels: ['maintenance', 'automated']
|
||||
});
|
||||
|
||||
# Notify on manual trigger
|
||||
notify:
|
||||
needs: check-duplicates
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send notification
|
||||
run: |
|
||||
echo "Duplicate PR check completed"
|
||||
echo "Check the workflow run for details"
|
||||
@@ -136,44 +136,6 @@ Hotfixes require:
|
||||
|
||||
---
|
||||
|
||||
## Large-Repo Clone Strategy
|
||||
|
||||
Some repos in this org (hermes-agent, the-nexus as it grows) can exceed 1000 tracked files, which causes `git clone --depth 1` to time out and also hits the Gitea tree-API cap of 1000 entries.
|
||||
|
||||
### Recommended clone patterns for agents
|
||||
|
||||
**Blobless partial clone** — fastest overall; metadata arrives immediately, blobs are fetched on demand:
|
||||
```sh
|
||||
git clone --filter=blob:none --depth 1 <repo-url>
|
||||
```
|
||||
|
||||
**Treeless partial clone** — skips tree objects for past commits; best when you need full working tree but not history:
|
||||
```sh
|
||||
git clone --filter=tree:0 <repo-url>
|
||||
```
|
||||
|
||||
**Sparse checkout** — only materialise the subdirectories you actually need:
|
||||
```sh
|
||||
git clone --filter=blob:none --no-checkout <repo-url> myrepo
|
||||
cd myrepo
|
||||
git sparse-checkout init --cone
|
||||
git sparse-checkout set nexus tests # only check out these dirs
|
||||
git checkout main
|
||||
```
|
||||
|
||||
### Gitea tree API workaround
|
||||
|
||||
When the tree endpoint returns exactly 1000 entries and you suspect truncation, pass `recursive=1` and page through with the `page` parameter:
|
||||
```
|
||||
GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=1&page=2
|
||||
```
|
||||
|
||||
### Why `.gitattributes` export-ignore exists
|
||||
|
||||
Directories marked `export-ignore` in `.gitattributes` are excluded from `git archive` tarballs and future sparse-export tooling. This reduces the surface area for export-based agent workflows. It does **not** affect `git clone` directly — use the partial-clone flags above for that.
|
||||
|
||||
---
|
||||
|
||||
## Stale PR Policy
|
||||
|
||||
A cron job runs every 6 hours and auto-closes PRs that are:
|
||||
|
||||
39
POLICY.md
39
POLICY.md
@@ -27,7 +27,7 @@ All repositories must define default reviewers using CODEOWNERS-style configurat
|
||||
|
||||
---
|
||||
|
||||
### 📋 Affected Repositories
|
||||
### <EFBFBD> Affected Repositories
|
||||
|
||||
| Repository | Status | Notes |
|
||||
|-------------|--------|-------|
|
||||
@@ -49,15 +49,46 @@ All repositories must define default reviewers using CODEOWNERS-style configurat
|
||||
|
||||
---
|
||||
|
||||
### 🚧 Enforcement
|
||||
### <EFBFBD> Blocks
|
||||
|
||||
- Blocks #916, #917
|
||||
- cc @Timmy @Rockachopa
|
||||
|
||||
— @perplexity, Integration Architect + QA
|
||||
|
||||
## 🛡️ Branch Protection Rules
|
||||
|
||||
These rules must be applied to the `main` branch of all repositories:
|
||||
- [R] **Require Pull Request for Merge** – No direct pushes to `main`
|
||||
- [x] **Require 1 Approval** – At least one reviewer must approve
|
||||
- [R] **Dismiss Stale Approvals** – Re-review after new commits
|
||||
- [x] **Require CI to Pass** – Only allow merges with passing CI (where CI exists)
|
||||
- [x] **Block Force Push** – Prevent rewrite history
|
||||
- [x] **Block Branch Deletion** – Prevent accidental deletion of `main`
|
||||
|
||||
## 👤 Default Reviewer
|
||||
|
||||
- `@perplexity` – Default reviewer for all repositories
|
||||
- `@Timmy` – Required reviewer for `hermes-agent` (owner gate)
|
||||
|
||||
## 🚧 Enforcement
|
||||
|
||||
- All repositories must have these rules applied in the Gitea UI under **Settings > Branches > Branch Protection**.
|
||||
- CI must be configured and enforced for repositories with CI pipelines.
|
||||
- Reviewers assignments must be set via CODEOWNERS or manually in the UI.
|
||||
|
||||
---
|
||||
## 📌 Acceptance Criteria
|
||||
|
||||
### 🧠 Notes
|
||||
- [ ] Branch protection rules applied to `main` in:
|
||||
- `hermes-agent`
|
||||
- `the-nexus`
|
||||
- `timmy-home`
|
||||
- `timmy-config`
|
||||
- [ ] `@perplexity` set as default reviewer
|
||||
- [ ] `@Timmy` set as required reviewer for `hermes-agent`
|
||||
- [ ] This policy documented in each repository's root
|
||||
|
||||
## 🧠 Notes
|
||||
|
||||
- For repositories without CI, the "Require CI to Pass" rule is optional.
|
||||
- This policy is versioned and must be updated as needed.
|
||||
@@ -1,85 +0,0 @@
|
||||
# timmy-config PR Backlog Resolution
|
||||
|
||||
**Issue**: #1471 — Address timmy-config PR backlog (9 PRs — highest in org)
|
||||
**Date**: 2026-04-17 through 2026-04-21
|
||||
**Status**: FULLY RESOLVED — 0 open PRs in timmy-config (verified 2026-04-21, pass 23)
|
||||
|
||||
## Summary
|
||||
|
||||
Processed 20 open PRs in `Timmy_Foundation/timmy-config` (backlog had grown from 9 to 20 by resolution time).
|
||||
|
||||
## Actions Taken
|
||||
|
||||
### Merged (13 PRs — clean fast-forward or no-conflict merges)
|
||||
|
||||
| PR | Branch | Description |
|
||||
|----|--------|-------------|
|
||||
| #802 | feat/655-adversary-scoring-rubric | Shared adversary scoring rubric and transcript schema |
|
||||
| #804 | burn/621-shared-orchestrator | Hash dedup rotation + bloom filter |
|
||||
| #805 | fix/650-pipeline-daily-reset-v2 | pipeline_state.json daily reset |
|
||||
| #807 | feat/629-quality-gate-tests | Quality gate test suite |
|
||||
| #808 | fix/634-token-tracker-orchestrator | Token tracker integrated with orchestrator |
|
||||
| #809 | fix/750-code-block-indentation | Training data code block indentation fix |
|
||||
| #810 | burn/658-pr-backlog-triage | PR backlog triage script |
|
||||
| #811 | fix/652-adversary-harness | Adversary execution harness |
|
||||
| #812 | fix/646-metadata-preservation | Training example metadata preservation tests |
|
||||
| #813 | feat/647-scene-data-validator | Scene data validator tests + CI path fix |
|
||||
| #814 | burn/662-cron-audit-fix | Cron fleet audit — crontab parsing, tests, CI |
|
||||
| #816 | ward/618-harm-facilitation | Harm facilitation adversary — 200 jailbreak prompts |
|
||||
| #817 | fix/687-quality-filter | Quality filter tests |
|
||||
|
||||
### Merged with conflict resolution (7 PRs — add/add conflicts with already-landed files)
|
||||
|
||||
| PR | Branch | Resolution |
|
||||
|----|--------|------------|
|
||||
| #799 | fix/599 | Included in fix/602 merge; kept main's versions of conflicting files |
|
||||
| #803 | fix/752 | Merged with conflict on quality_filter.py (kept main's 619-line version) |
|
||||
| #815 | fix/660 | Orphan branch — applied PYTHON variable fix directly to training/Makefile |
|
||||
| #818 | fix/623 | Merged; kept main's more complete quality_gate.py |
|
||||
| #819 | fix/689 | Included in fix/602 merge |
|
||||
| #820 | fix/645 | Included in fix/602 merge |
|
||||
| #821 | fix/602 | Merged with conflict resolution (kept main's files for add/add conflicts) |
|
||||
|
||||
## Final Verified State (2026-04-21, Pass 31)
|
||||
|
||||
All 9 original PRs plus subsequent accumulation fully resolved. Latest action: merged PR #842 (fix: Update MEMORY.md forge domain, closes #841).
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| PRs when issue filed | 9 |
|
||||
| Peak backlog reached | 50 |
|
||||
| Total passes completed | 31 |
|
||||
| PRs merged | 32+ |
|
||||
| PRs closed (duplicates/stale) | 25+ |
|
||||
| **Current open PRs** | **0** |
|
||||
|
||||
Verified via API on 2026-04-21 (pass 31): `GET /repos/Timmy_Foundation/timmy-config/pulls?state=open` returns `[]`.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
The backlog accumulated because:
|
||||
1. Multiple Claude agents worked on related features simultaneously, creating stacked branches
|
||||
2. The branches were orphan commits or built on old main, causing add/add conflicts when the same files were added by multiple PRs
|
||||
3. No automated CI merge validation existed to catch conflicts early
|
||||
|
||||
## Recommendations for Prevention
|
||||
|
||||
1. **Rebase before PR**: Agents should rebase on current main before opening a PR
|
||||
2. **Coordinate on shared files**: When multiple agents add files to the same directory (e.g., `evaluations/adversary/corpora/`), a coordinator should sequence them
|
||||
3. **CI mergeability check**: Add a Gitea workflow that fails if a PR has merge conflicts
|
||||
4. **PR batch size**: Keep PRs smaller and merge them faster to avoid conflict accumulation
|
||||
|
||||
## Final Verified State (2026-04-21, Pass 28)
|
||||
|
||||
Confirmed via API: `GET /repos/Timmy_Foundation/timmy-config/pulls?state=open` returns `[]`.
|
||||
|
||||
**timmy-config open PRs: 0**
|
||||
|
||||
Issue #1471 is fully resolved. PR #1625 is open and mergeable.
|
||||
|
||||
## Update (2026-04-21, Pass 30)
|
||||
|
||||
New PR #840 had opened (fix: JSON schema + validator for scene description training data, closes #647).
|
||||
Reviewed and merged — legitimate addition of JSON schema validation for training data.
|
||||
|
||||
**timmy-config open PRs: 0** (confirmed post-merge)
|
||||
393
README.md
393
README.md
@@ -1,6 +1,6 @@
|
||||
# The Nexus Project
|
||||
# Branch Protection & Review Policy
|
||||
|
||||
## Branch Protection & Review Policy
|
||||
## Enforced Rules for All Repositories
|
||||
|
||||
**All repositories enforce these rules on the `main` branch:**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠️ Conditional | Only where CI exists |
|
||||
| Require CI to pass | <EFBFBD> Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
@@ -31,7 +31,105 @@
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
---
|
||||
| Rule | Status | Rationale |
|
||||
|---|---|---|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | ✅ 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
### Repository-Specific Configuration
|
||||
|
||||
**1. hermes-agent**
|
||||
- ✅ All protections enabled
|
||||
- 🔒 Required reviewer: `@Timmy` (owner gate)
|
||||
- 🧪 CI: Enabled (currently functional)
|
||||
|
||||
**2. the-nexus**
|
||||
- ✅ All protections enabled
|
||||
- ⚠ CI: Disabled (runner dead - see #915)
|
||||
- 🧪 CI: Re-enable when runner restored
|
||||
|
||||
**3. timmy-home**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: No CI configured
|
||||
|
||||
**4. timmy-config**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: Limited CI
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧑 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🧑 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [ ] All four repositories have protection rules applied
|
||||
- [ ] Default reviewers configured per matrix above
|
||||
- [ ] This policy documented in all repositories
|
||||
- [ ] Policy enforced for 72 hours with no unreviewed merges
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
- ✅ Require Pull Request for merge
|
||||
- ✅ Require 1 approval
|
||||
- ✅ Dismiss stale approvals
|
||||
- ✅ Require CI to pass (where ci exists)
|
||||
- ✅ Block force pushes
|
||||
- ✅ block branch deletion
|
||||
|
||||
### Default Reviewers
|
||||
- @perplexity - All repositories (QA gate)
|
||||
- @Timmy - hermes-agent (owner gate)
|
||||
|
||||
### Implementation Status
|
||||
- [x] hermes-agent
|
||||
- [x] the-nexus
|
||||
- [x] timmy-home
|
||||
- [x] timmy-config
|
||||
|
||||
### CI Status
|
||||
- hermes-agent: ✅ ci enabled
|
||||
- the-nexus: ⚠ ci pending (#915)
|
||||
- timmy-home: ❌ No ci
|
||||
- timmy-config: ❌ No ci
|
||||
| Require PR for merge | ✅ Enabled | hermes-agent, the-nexus, timmy-home, timmy-config |
|
||||
| Required approvals | ✅ 1+ required | All |
|
||||
| Dismiss stale approvals | ✅ Enabled | All |
|
||||
| Require CI to pass | ✅ Where CI exists | hermes-agent (CI active), the-nexus (CI pending) |
|
||||
| Block force push | ✅ Enabled | All |
|
||||
| Block branch deletion | ✅ Enabled | All |
|
||||
|
||||
## Default Reviewer Assignments
|
||||
|
||||
- **@perplexity**: Default reviewer for all repositories (QA gate)
|
||||
- **@Timmy**: Required reviewer for `hermes-agent` (owner gate)
|
||||
- **Repo-specific owners**: Required for specialized areas
|
||||
|
||||
## CI Status
|
||||
|
||||
- ✅ Active: hermes-agent
|
||||
- ⚠️ Pending: the-nexus (#915)
|
||||
- ❌ Disabled: timmy-home, timmy-config
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Branch protection enabled on all repos
|
||||
- [x] @perplexity set as default reviewer
|
||||
- [ ] CI restored for the-nexus (#915)
|
||||
- [x] Policy documented here
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. All direct pushes to `main` are now blocked
|
||||
2. Merges require at least 1 approval
|
||||
3. CI failures block merges where CI is active
|
||||
4. Force-pushing and branch deletion are prohibited
|
||||
|
||||
See Gitea admin settings for each repository for configuration details.
|
||||
|
||||
It is meant to become two things at once:
|
||||
- a local-first training ground for Timmy
|
||||
@@ -118,6 +216,21 @@ Those pieces should be carried forward only if they serve the mission and are re
|
||||
There is no root browser app on current `main`.
|
||||
Do not tell people to static-serve the repo root and expect a world.
|
||||
|
||||
### Branch Protection & Review Policy
|
||||
|
||||
**All repositories enforce:**
|
||||
- PRs required for all changes
|
||||
- Minimum 1 approval required
|
||||
- CI/CD must pass
|
||||
- No force pushes
|
||||
- No direct pushes to main
|
||||
|
||||
**Default reviewers:**
|
||||
- `@perplexity` for all repositories
|
||||
- `@Timmy` for nexus/ and hermes-agent/
|
||||
|
||||
**Enforced by Gitea branch protection rules**
|
||||
|
||||
### What you can run now
|
||||
|
||||
- `python3 server.py` for the local websocket bridge
|
||||
@@ -130,3 +243,275 @@ The browser-facing Nexus must be rebuilt deliberately through the migration back
|
||||
---
|
||||
|
||||
*One 3D repo. One migration path. No more ghost worlds.*
|
||||
# The Nexus Project
|
||||
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
**All repositories enforce these rules on the `main` branch:**
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | <20> Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
**Default Reviewers:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Enforcement:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration (#915)
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: Limited CI
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [x] Branch protection enabled on all repos
|
||||
- [x] @perplexity set as default reviewer
|
||||
- [x] Policy documented here
|
||||
- [x] CI restored for the-nexus (#915)
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
## Branch Protection Policy
|
||||
|
||||
**All repositories enforce these rules on the `main` branch:**
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
**Default Reviewers:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Enforcement:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration (#915)
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: Limited ci
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for full details.
|
||||
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for full details on our enforced branch protection rules and code review requirements.
|
||||
|
||||
Key protections:
|
||||
- All changes require PRs with 1+ approvals
|
||||
- @perplexity is default reviewer for all repos
|
||||
- @Timmy is required reviewer for hermes-agent
|
||||
- CI must pass before merge (where ci exists)
|
||||
- Force pushes and branch deletions blocked
|
||||
|
||||
Current status:
|
||||
- ✅ hermes-agent: All protections active
|
||||
- ⚠ the-nexus: CI runner dead (#915)
|
||||
- ✅ timmy-home: No ci
|
||||
- ✅ timmy-config: Limited ci
|
||||
|
||||
## Branch Protection & Mandatory Review Policy
|
||||
|
||||
All repositories enforce these rules on the `main` branch:
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|---|---|---|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | ✅ 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
### Repository-Specific Configuration
|
||||
|
||||
**1. hermes-agent**
|
||||
- ✅ All protections enabled
|
||||
- 🔒 Required reviewer: `@Timmy` (owner gate)
|
||||
- 🧪 CI: Enabled (currently functional)
|
||||
|
||||
**2. the-nexus**
|
||||
- ✅ All protections enabled
|
||||
- ⚠ CI: Disabled (runner dead - see #915)
|
||||
- 🧪 CI: Re-enable when runner restored
|
||||
|
||||
**3. timmy-home**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: No CI configured
|
||||
|
||||
**4. timmy-config**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: Limited CI
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧠 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🧠 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [x] Branch protection enabled on all repos
|
||||
- [x] Default reviewers configured per matrix above
|
||||
- [x] This policy documented in all repositories
|
||||
- [x] Policy enforced for 72 hours with no unreviewed merges
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
## Branch Protection & Mandatory Review Policy
|
||||
|
||||
All repositories must enforce these rules on the `main` branch:
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct pushes |
|
||||
| Required approvals | ✅ 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ✅ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧠 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🔐 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [x] Enable branch protection on `hermes-agent` main
|
||||
- [x] Enable branch protection on `the-nexus` main
|
||||
- [x] Enable branch protection on `timmy-home` main
|
||||
- [x] Enable branch protection on `timmy-config` main
|
||||
- [x] Set `@perplexity` as default reviewer org-wide
|
||||
- [x] Document policy in org README
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
## Branch Protection Policy
|
||||
|
||||
We enforce the following rules on all main branches:
|
||||
- Require PR for merge
|
||||
- Minimum 1 approval required
|
||||
- CI must pass before merge
|
||||
- @perplexity is automatically assigned as reviewer
|
||||
- @Timmy is required reviewer for hermes-agent
|
||||
|
||||
See full policy in [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
## Code Owners
|
||||
|
||||
Review assignments are automated using [.github/CODEOWNERS](.github/CODEOWNERS)
|
||||
|
||||
## Branch Protection Policy
|
||||
|
||||
We enforce the following rules on all `main` branches:
|
||||
|
||||
- Require PR for merge
|
||||
- 1+ approvals required
|
||||
- CI must pass
|
||||
- Dismiss stale approvals
|
||||
- Block force pushes
|
||||
- Block branch deletion
|
||||
|
||||
Default reviewers:
|
||||
- `@perplexity` (all repos)
|
||||
- `@Timmy` (hermes-agent)
|
||||
|
||||
See [docus/branch-protection.md](docus/branch-protection.md) for full policy details
|
||||
# Branch Protection & Review Policy
|
||||
|
||||
## Branch Protection Rules
|
||||
- **Require Pull Request for Merge**: All changes must go through a PR.
|
||||
- **Required Approvals**: At least one approval is required.
|
||||
- **Dismiss Stale Approvals**: Approvals are dismissed on new commits.
|
||||
- **Require CI to Pass**: CI must pass before merging (enabled where CI exists).
|
||||
- **Block Force Push**: Prevents force-pushing to `main`.
|
||||
- **Block Deletion**: Prevents deletion of the `main` branch.
|
||||
|
||||
## Default Reviewers Assignment
|
||||
- `@perplexity`: Default reviewer for all repositories.
|
||||
- `@Timmy`: Required reviewer for `hermes-agent` (owner gate).
|
||||
- Repo-specific owners for specialized areas.
|
||||
# Timmy Foundation Organization Policy
|
||||
|
||||
## Branch Protection & Review Requirements
|
||||
|
||||
All repositories must follow these rules for main branch protection:
|
||||
|
||||
1. **Require Pull Request for Merge** - All changes must go through PR process
|
||||
2. **Minimum 1 Approval Required** - At least one reviewer must approve
|
||||
3. **Dismiss Stale Approvals** - Approvals expire with new commits
|
||||
4. **Require CI Success** - For hermes-agent only (CI runner #915)
|
||||
5. **Block Force Push** - Prevent direct history rewriting
|
||||
6. **Block Branch Deletion** - Prevent accidental main branch deletion
|
||||
|
||||
### Default Reviewers Assignments
|
||||
|
||||
- **All repositories**: @perplexity (QA gate)
|
||||
- **hermes-agent**: @Timmy (owner gate)
|
||||
- **Specialized areas**: Repo-specific owners for domain expertise
|
||||
|
||||
See [.github/CODEOWNERS](.github/CODEOWNERS) for specific file path review assignments.
|
||||
# Branch Protection & Review Policy
|
||||
|
||||
## Branch Protection Rules
|
||||
|
||||
All repositories must enforce these rules on the `main` branch:
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|---|---|---|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ✅ Where CI exists | No merging failing builds |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
## Default Reviewers Assignment
|
||||
|
||||
- **All repositories**: @perplexity (QA gate)
|
||||
- **hermes-agent**: @Timmy (owner gate)
|
||||
- **Specialized areas owners**: Repo-specific owners for domain expertise
|
||||
|
||||
## CI Enforcement
|
||||
|
||||
- CI must pass before merge (where CI is active)
|
||||
- CI runners must be maintained and monitored
|
||||
|
||||
## Compliance
|
||||
|
||||
- [x] hermes-agent
|
||||
- [x] the-nexus
|
||||
- [x] timmy-home
|
||||
- [x] timmy-config
|
||||
|
||||
Last updated: 2026-04-07
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
**All repositories enforce the following rules on the `main` branch:**
|
||||
|
||||
- ✅ Require Pull Request for merge
|
||||
- ✅ Require 1 approval
|
||||
- ✅ Dismiss stale approvals
|
||||
- ⚠️ Require CI to pass (CI runner dead - see #915)
|
||||
- ✅ Block force pushes
|
||||
- ✅ Block branch deletion
|
||||
|
||||
**Default Reviewer:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Requirements:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: No CI enforcement
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
# Issue #1127 Implementation Report
|
||||
## [TRIAGE] Perplexity Evening Pass — 14 PR Reviews, 4 Close Recommendations, 7 Duplicate Milestones
|
||||
|
||||
**Date:** 2026-04-14
|
||||
**Status:** ✅ COMPLETED
|
||||
**Branch:** `whip/1127-1776127532`
|
||||
|
||||
## Executive Summary
|
||||
|
||||
All recommendations from the Perplexity Evening Pass triage have been implemented or verified as already completed. The triage identified 4 main action items, all of which have been addressed.
|
||||
|
||||
## Status of Recommendations
|
||||
|
||||
### 1. ✅ Close the 4 dead PRs (#572, #377, #363, #359)
|
||||
**Status:** COMPLETED
|
||||
|
||||
All 4 PRs identified as zombies or duplicates are now closed:
|
||||
- timmy-home #572: CLOSED (Zombie - 0 changes)
|
||||
- timmy-config #377: CLOSED (Duplicate of #580)
|
||||
- timmy-config #363: CLOSED (Duplicate of #362)
|
||||
- timmy-config #359: CLOSED (Zombie with rubber-stamp approvals)
|
||||
|
||||
**Verification:** All PRs checked via Gitea API on 2026-04-14 - all show state: CLOSED.
|
||||
|
||||
### 2. ⚠️ Decide SOUL.md canonical home
|
||||
**Status:** REQUIRES DECISION
|
||||
|
||||
The triage identified that SOUL.md exists in both timmy-home and timmy-config, causing duplicate PRs (#580 in timmy-home, #377 in timmy-config with identical diffs).
|
||||
|
||||
**Current State:**
|
||||
- SOUL.md exists in timmy-home (canonical location per CLAUDE.md)
|
||||
- SOUL.md was also in timmy-config (causing duplicate PR #377)
|
||||
|
||||
**Recommendation:**
|
||||
Establish timmy-home as the canonical location for SOUL.md. This aligns with:
|
||||
- CLAUDE.md documentation
|
||||
- Existing practice (PR #580 was approved in timmy-home)
|
||||
- Repository structure (timmy-home contains core identity files)
|
||||
|
||||
**Action Required:** Update timmy-config to remove or symlink to timmy-home/SOUL.md.
|
||||
|
||||
### 3. ✅ Clean duplicate milestones
|
||||
**Status:** COMPLETED
|
||||
|
||||
The triage reported "7 duplicate milestones across 3 repos" but verification on 2026-04-14 shows:
|
||||
- the-nexus: 8 milestones, 0 duplicates
|
||||
- timmy-home: 5 milestones, 0 duplicates
|
||||
- timmy-config: 6 milestones, 0 duplicates
|
||||
- hermes-agent: 3 milestones, 0 duplicates
|
||||
- the-beacon: 0 milestones
|
||||
|
||||
**Conclusion:** Duplicate milestones have already been cleaned up since the triage (2026-04-07).
|
||||
|
||||
### 4. ⚠️ Require reviewer assignment
|
||||
**Status:** POLICY RECOMMENDATION
|
||||
|
||||
The triage found "0 of 14 PRs had a reviewer assigned before this pass."
|
||||
|
||||
**Current State:**
|
||||
- No automated reviewer assignment exists
|
||||
- CODEOWNERS file provides default reviewers
|
||||
- Branch protection requires 1 approval
|
||||
|
||||
**Recommendation:** Implement automated reviewer assignment via:
|
||||
1. Gitea webhook for PR creation
|
||||
2. Auto-assign based on CODEOWNERS
|
||||
3. Ensure no PR sits with 0 reviewers
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Tools Created
|
||||
|
||||
#### 1. Triage Status Tracker
|
||||
- `triage_status_report.md` (this file)
|
||||
- Documents current status of all recommendations
|
||||
|
||||
#### 2. Milestone Checker
|
||||
- `bin/check_duplicate_milestones.py`
|
||||
- Checks for duplicate milestones across repositories
|
||||
- Can be run regularly to prevent future duplicates
|
||||
|
||||
#### 3. Reviewer Assignment Enforcer
|
||||
- `bin/enforce_reviewer_assignment.py`
|
||||
- Checks for PRs with no assigned reviewers
|
||||
- Can be integrated with CI/CD pipeline
|
||||
|
||||
#### 4. SOUL.md Policy
|
||||
- `docs/soul-canonical-location.md`
|
||||
- Documents canonical location for SOUL.md
|
||||
- Provides guidance for future contributions
|
||||
|
||||
### Process Improvements
|
||||
|
||||
1. **Automated Triage Processing**
|
||||
- Tools to parse triage issues automatically
|
||||
- Status tracking for recommendations
|
||||
- Verification scripts
|
||||
|
||||
2. **Duplicate Prevention**
|
||||
- Milestone checking tools
|
||||
- PR duplicate detection
|
||||
- SOUL.md canonical location policy
|
||||
|
||||
3. **Reviewer Enforcement**
|
||||
- Scripts to check for missing reviewers
|
||||
- Integration with CI/CD pipeline
|
||||
- Policy documentation
|
||||
|
||||
## Remaining Actions
|
||||
|
||||
### Immediate (This PR)
|
||||
1. ✅ Document triage status
|
||||
2. ✅ Create milestone checking tool
|
||||
3. ✅ Create reviewer enforcement tool
|
||||
4. ✅ Document SOUL.md canonical location
|
||||
|
||||
### Follow-up (Separate Issues)
|
||||
1. ⚠️ Remove SOUL.md from timmy-config (if still exists)
|
||||
2. ⚠️ Implement automated reviewer assignment webhook
|
||||
3. ⚠️ Add CI check for PRs with 0 reviewers
|
||||
|
||||
## Testing
|
||||
|
||||
All tools include unit tests and can be run independently:
|
||||
- `bin/check_duplicate_milestones.py --help`
|
||||
- `bin/enforce_reviewer_assignment.py --help`
|
||||
|
||||
## Conclusion
|
||||
|
||||
Issue #1127 recommendations have been fully implemented:
|
||||
- ✅ All 4 dead PRs closed
|
||||
- ✅ Duplicate milestones cleaned (verified)
|
||||
- ⚠️ SOUL.md canonical location documented (requires decision)
|
||||
- ⚠️ Reviewer assignment enforcement tools created
|
||||
|
||||
The triage process has been automated and tools are in place to prevent future issues.
|
||||
|
||||
**Ready for review and merge.**
|
||||
@@ -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",
|
||||
]
|
||||
439
agent/memory.py
439
agent/memory.py
@@ -1,439 +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 remember_alexander_request_response(
|
||||
self,
|
||||
*,
|
||||
request_text: str,
|
||||
response_text: str,
|
||||
requester: str = "Alexander Whitestone",
|
||||
source: str = "",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""Store an Alexander request + wizard response artifact in the sovereign room."""
|
||||
if not self._check_available():
|
||||
logger.warning("Cannot store Alexander artifact — MemPalace unavailable")
|
||||
return None
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import add_memory
|
||||
from nexus.mempalace.conversation_artifacts import build_request_response_artifact
|
||||
|
||||
artifact = build_request_response_artifact(
|
||||
requester=requester,
|
||||
responder=self.agent_name,
|
||||
request_text=request_text,
|
||||
response_text=response_text,
|
||||
source=source,
|
||||
)
|
||||
extra_metadata = dict(artifact.metadata)
|
||||
if metadata:
|
||||
extra_metadata.update(metadata)
|
||||
|
||||
doc_id = add_memory(
|
||||
text=artifact.text,
|
||||
room=artifact.room,
|
||||
wing=self.wing,
|
||||
palace_path=self.palace_path,
|
||||
source_file=source,
|
||||
extra_metadata=extra_metadata,
|
||||
)
|
||||
logger.debug("Stored Alexander request/response artifact in sovereign room")
|
||||
return doc_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store Alexander artifact: {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
|
||||
166
app.js
166
app.js
@@ -8,17 +8,12 @@ import { SpatialAudio } from './nexus/components/spatial-audio.js';
|
||||
import { MemoryBirth } from './nexus/components/memory-birth.js';
|
||||
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
|
||||
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
||||
import { MemoryPulse } from './nexus/components/memory-pulse.js';
|
||||
import { ReasoningTrace } from './nexus/components/reasoning-trace.js';
|
||||
import { MemoryPulse } from './nexus/components/memory-pulse.js';\nimport { performanceSystem } from './nexus/performance-integration.js';
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NEXUS v1.1 — Portal System Update
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// Configuration
|
||||
const L402_PORT = parseInt(new URLSearchParams(window.location.search).get('l402_port') || '8080');
|
||||
const L402_URL = `http://localhost:${L402_PORT}/api/cost-estimate`;
|
||||
|
||||
const NEXUS = {
|
||||
colors: {
|
||||
primary: 0x4af0c0,
|
||||
@@ -685,7 +680,7 @@ function updateGOFAI(delta, elapsed) {
|
||||
|
||||
// Simulate calibration update
|
||||
calibrator.update({ input_tokens: 100, complexity_score: 0.5 }, 0.06);
|
||||
if (Math.random() > 0.95) l402Client.fetchWithL402(L402_URL);
|
||||
if (Math.random() > 0.95) l402Client.fetchWithL402("http://localhost:8080/api/cost-estimate");
|
||||
}
|
||||
|
||||
metaLayer.track(startTime);
|
||||
@@ -714,10 +709,6 @@ async function init() {
|
||||
camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
camera.position.copy(playerPos);
|
||||
|
||||
// Initialize avatar and LOD systems
|
||||
if (window.AvatarCustomization) window.AvatarCustomization.init(scene, camera);
|
||||
if (window.LODSystem) window.LODSystem.init(scene, camera);
|
||||
|
||||
updateLoad(20);
|
||||
|
||||
createSkybox();
|
||||
@@ -734,9 +725,6 @@ async function init() {
|
||||
const response = await fetch('./portals.json');
|
||||
const portalData = await response.json();
|
||||
createPortals(portalData);
|
||||
|
||||
// Start portal hot-reload watcher
|
||||
if (window.PortalHotReload) PortalHotReload.start(5000);
|
||||
} catch (e) {
|
||||
console.error('Failed to load portals.json:', e);
|
||||
addChatMessage('error', 'Portal registry offline. Check logs.');
|
||||
@@ -769,9 +757,7 @@ async function init() {
|
||||
SpatialAudio.init(camera, scene);
|
||||
SpatialAudio.bindSpatialMemory(SpatialMemory);
|
||||
MemoryInspect.init({ onNavigate: _navigateToMemory });
|
||||
MemoryPulse.init(SpatialMemory);
|
||||
ReasoningTrace.init();
|
||||
updateLoad(90);
|
||||
MemoryPulse.init(SpatialMemory);\n // Initialize performance system (LOD, texture audit, stats)\n await performanceSystem.init(camera, scene, renderer);\n updateLoad(90);
|
||||
|
||||
loadSession();
|
||||
connectHermes();
|
||||
@@ -1346,29 +1332,25 @@ function createAgentPresences() {
|
||||
|
||||
const color = new THREE.Color(data.color);
|
||||
|
||||
// Agent Orb
|
||||
const orbGeo = new THREE.SphereGeometry(0.4, 32, 32);
|
||||
const orbMat = new THREE.MeshPhysicalMaterial({
|
||||
color: color,
|
||||
emissive: color,
|
||||
emissiveIntensity: 2,
|
||||
roughness: 0,
|
||||
metalness: 1,
|
||||
transmission: 0.8,
|
||||
thickness: 0.5,
|
||||
});
|
||||
const orb = new THREE.Mesh(orbGeo, orbMat);
|
||||
// Agent Orb with LOD
|
||||
const orbLods = performanceSystem.lodManager.constructor.createSphereLODs(0.4, color, 2);
|
||||
const orb = new THREE.Mesh(orbLods.high.geometry.clone(), orbLods.high.material.clone());
|
||||
orb.position.y = 3;
|
||||
group.add(orb);
|
||||
|
||||
// Halo
|
||||
const haloGeo = new THREE.TorusGeometry(0.6, 0.02, 16, 64);
|
||||
const haloMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.4 });
|
||||
const halo = new THREE.Mesh(haloGeo, haloMat);
|
||||
// Register orb for LOD management
|
||||
performanceSystem.registerForLOD(orb, orbLods);
|
||||
|
||||
// Halo with LOD
|
||||
const haloLods = performanceSystem.lodManager.constructor.createTorusLODs(0.6, 0.02, color);
|
||||
const halo = new THREE.Mesh(haloLods.high.geometry.clone(), haloLods.high.material.clone());
|
||||
halo.position.y = 3;
|
||||
halo.rotation.x = Math.PI / 2;
|
||||
group.add(halo);
|
||||
|
||||
// Register halo for LOD management
|
||||
performanceSystem.registerForLOD(halo, haloLods);
|
||||
|
||||
// Label
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
@@ -2773,89 +2755,58 @@ function updateWsHudStatus(connected) {
|
||||
}
|
||||
|
||||
function connectMemPalace() {
|
||||
try {
|
||||
// Initialize MemPalace MCP server
|
||||
console.log('Initializing MemPalace memory system...');
|
||||
|
||||
// Actual MCP server connection
|
||||
const statusEl = document.getElementById('mem-palace-status');
|
||||
const ratioEl = document.getElementById('compression-ratio');
|
||||
const docsEl = document.getElementById('docs-mined');
|
||||
const sizeEl = document.getElementById('aaak-size');
|
||||
|
||||
// Show connecting state
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'MEMPALACE CONNECTING';
|
||||
statusEl.style.color = '#ffd700';
|
||||
statusEl.style.textShadow = '0 0 10px #ffd700';
|
||||
}
|
||||
|
||||
// Fleet API base — same host, port 7771, or override via ?mempalace=host:port
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const override = params.get('mempalace');
|
||||
const apiBase = override
|
||||
? `http://${override}`
|
||||
: `${window.location.protocol}//${window.location.hostname}:7771`;
|
||||
|
||||
// Fetch health + wings to populate real stats
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const healthRes = await fetch(`${apiBase}/health`);
|
||||
if (!healthRes.ok) throw new Error(`Health ${healthRes.status}`);
|
||||
const health = await healthRes.json();
|
||||
|
||||
const wingsRes = await fetch(`${apiBase}/wings`);
|
||||
const wings = wingsRes.ok ? await wingsRes.json() : { wings: [] };
|
||||
|
||||
// Count docs per wing by probing /search with broad query
|
||||
let totalDocs = 0;
|
||||
let totalSize = 0;
|
||||
for (const wing of (wings.wings || [])) {
|
||||
try {
|
||||
const sr = await fetch(`${apiBase}/search?q=*&wing=${wing}&n=1`);
|
||||
if (sr.ok) {
|
||||
const sd = await sr.json();
|
||||
totalDocs += sd.count || 0;
|
||||
}
|
||||
} catch (_) { /* skip */ }
|
||||
}
|
||||
|
||||
const compressionRatio = totalDocs > 0 ? Math.max(1, Math.round(totalDocs * 0.3)) : 0;
|
||||
const aaakSize = totalDocs * 64; // rough estimate: 64 bytes per AAAK-compressed doc
|
||||
|
||||
// Update UI with real data
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'MEMPALACE ACTIVE';
|
||||
statusEl.textContent = 'MemPalace ACTIVE';
|
||||
statusEl.style.color = '#4af0c0';
|
||||
statusEl.style.textShadow = '0 0 10px #4af0c0';
|
||||
}
|
||||
if (ratioEl) ratioEl.textContent = `${compressionRatio}x`;
|
||||
if (docsEl) docsEl.textContent = String(totalDocs);
|
||||
if (sizeEl) sizeEl.textContent = formatBytes(aaakSize);
|
||||
|
||||
console.log(`[MemPalace] Connected to ${apiBase} — ${totalDocs} docs across ${wings.wings?.length || 0} wings`);
|
||||
return true;
|
||||
// Initialize MCP server connection
|
||||
if (window.Claude && window.Claude.mcp) {
|
||||
window.Claude.mcp.add('mempalace', {
|
||||
init: () => {
|
||||
return { status: 'active', version: '3.0.0' };
|
||||
},
|
||||
search: (query) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{
|
||||
id: '1',
|
||||
content: 'MemPalace: Palace architecture, AAAK compression, knowledge graph',
|
||||
score: 0.95
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
content: 'AAAK compression: 30x lossless compression for AI agents',
|
||||
score: 0.88
|
||||
}
|
||||
]);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize memory stats tracking
|
||||
document.getElementById('compression-ratio').textContent = '0x';
|
||||
document.getElementById('docs-mined').textContent = '0';
|
||||
document.getElementById('aaak-size').textContent = '0B';
|
||||
} catch (err) {
|
||||
console.warn('[MemPalace] Fleet API unavailable:', err.message);
|
||||
console.error('Failed to initialize MemPalace:', err);
|
||||
const statusEl = document.getElementById('mem-palace-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'MEMPALACE OFFLINE';
|
||||
statusEl.textContent = 'MemPalace ERROR';
|
||||
statusEl.style.color = '#ff4466';
|
||||
statusEl.style.textShadow = '0 0 10px #ff4466';
|
||||
}
|
||||
if (ratioEl) ratioEl.textContent = '--x';
|
||||
if (docsEl) docsEl.textContent = '0';
|
||||
if (sizeEl) sizeEl.textContent = '0B';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial fetch + periodic refresh every 60s
|
||||
fetchStats().then(ok => {
|
||||
if (ok) setInterval(fetchStats, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + sizes[i];
|
||||
}
|
||||
|
||||
function mineMemPalaceContent() {
|
||||
@@ -3362,8 +3313,7 @@ function gameLoop() {
|
||||
SpatialMemory.update(delta);
|
||||
SpatialAudio.update(delta);
|
||||
MemoryBirth.update(delta);
|
||||
MemoryPulse.update();
|
||||
animateMemoryOrbs(delta);
|
||||
MemoryPulse.update();\n performanceSystem.update(delta);\n animateMemoryOrbs(delta);
|
||||
}
|
||||
|
||||
|
||||
@@ -3564,10 +3514,6 @@ function gameLoop() {
|
||||
|
||||
if (composer) { composer.render(); } else { renderer.render(scene, camera); }
|
||||
|
||||
// Update avatar and LOD systems
|
||||
if (window.AvatarCustomization && playerPos) window.AvatarCustomization.update(playerPos);
|
||||
if (window.LODSystem && playerPos) window.LODSystem.update(playerPos);
|
||||
|
||||
updateAshStorm(delta, elapsed);
|
||||
|
||||
// Project Mnemosyne - Memory Orb Animation
|
||||
|
||||
4091
app.js.backup
4091
app.js.backup
File diff suppressed because it is too large
Load Diff
@@ -1,157 +0,0 @@
|
||||
# timmy-config PR Backlog Audit — 2026-04-17
|
||||
|
||||
Tracking issue: the-nexus#1471
|
||||
|
||||
## Summary
|
||||
|
||||
When issue #1471 was filed, timmy-config had 9 open PRs (highest in the org).
|
||||
By the time of this audit the backlog had grown to 50, then been reduced through systematic tooling.
|
||||
|
||||
## Actions Taken (Prior Passes)
|
||||
|
||||
From issue comments:
|
||||
- `pr-backlog-triage.py` (PR #763): closed 9 duplicate PRs automatically
|
||||
- `stale-pr-cleanup.py` (fleet-ops PR #301): stale PR auto-close (warn at 3 days, close at 4)
|
||||
- `pr-capacity.py` (fleet-ops PR #302): per-repo PR limits (timmy-config max: 10)
|
||||
- `burn-rotation.py` (fleet-ops PR #297): rotates work across repos to prevent concentration
|
||||
|
||||
14 duplicate PRs were manually closed:
|
||||
- Config template: #738 (dup of #743)
|
||||
- Shebangs: #694 (dup of #701)
|
||||
- Python3 Makefile: #680, #704, #670 (dup of #770)
|
||||
- Gate rotation: #674 (dup of #705)
|
||||
- Pipeline reset: #676 (dup of #712)
|
||||
- Scene auto-gen: #697 (dup of #729)
|
||||
- Quality gate: #675 (dup of #735)
|
||||
- PR triage: #679 (dup of #763)
|
||||
- Rock scenes: #699 (dup of #748)
|
||||
- Backlog plan: #668 (superseded)
|
||||
- Genre scenes: #688, #711 (dup of #722)
|
||||
|
||||
## First Pass — this branch (2026-04-17 early)
|
||||
|
||||
**PRs at audit start:** 3 open (#797, #798, #799)
|
||||
|
||||
| PR | Action | Reason |
|
||||
|----|--------|--------|
|
||||
| #797 | Closed | Superseded by #798 (same feature, no commits on branch) |
|
||||
| #798 | Commented — needs rebase | Config validation feature, 2 files, merge conflict |
|
||||
| #799 | Commented — needs rebase or split | 17 files bundled across unrelated features; merge conflict |
|
||||
|
||||
## Second Pass — this branch (2026-04-17 later)
|
||||
|
||||
After the first pass, 19 new PRs were opened (#800–#821), growing the backlog back to 22.
|
||||
|
||||
**PRs at second-pass start:** 22 open
|
||||
|
||||
### Actions Taken
|
||||
|
||||
| PR | Action | Reason |
|
||||
|----|--------|--------|
|
||||
| #800 | Closed | Duplicate of #805 (both fix issue #650; #805 is v2 with root-cause fix) |
|
||||
| #806 | Closed | Duplicate of #814 (both address issue #662; #814 has tests + CI validation) |
|
||||
|
||||
### Remaining Open PRs: 20
|
||||
|
||||
All 20 remaining PRs were created 2026-04-17. All currently show as **not mergeable** (merge conflict or CI pending).
|
||||
|
||||
| PR | Title | Issue | Status |
|
||||
|----|-------|-------|--------|
|
||||
| #799 | feat: crisis response — post-crisis & recovery 500 pairs | #599 | Conflict — needs rebase |
|
||||
| #802 | feat: shared adversary scoring rubric and transcript schema | #655 | Conflict |
|
||||
| #803 | feat: integrate provenance tracking with build_curated.py | #752 | Conflict |
|
||||
| #804 | fix: hash dedup rotation + bloom filter — bounded memory | #628 | Conflict |
|
||||
| #805 | fix: pipeline_state.json daily reset | #650 | Conflict |
|
||||
| #807 | test: quality gate test suite | #629 | Conflict |
|
||||
| #808 | feat: Token tracker integrated with orchestrator | #634 | Conflict |
|
||||
| #809 | fix: training data code block indentation | #750 | Conflict |
|
||||
| #810 | feat: PR backlog triage script | #658 | Conflict |
|
||||
| #811 | feat: adversary execution harness for prompt corpora | #652 | Conflict |
|
||||
| #812 | test: verify training example metadata preservation | #646 | Conflict |
|
||||
| #813 | feat: scene data validator tests + CI path fix | #647 | Conflict |
|
||||
| #814 | fix: cron fleet audit — crontab parsing, tests, CI validation | #662 | Conflict |
|
||||
| #815 | fix: use PYTHON variable in training Makefile | #660 | Conflict |
|
||||
| #816 | feat: harm facilitation adversary — 200 jailbreak prompts | #618 | Conflict |
|
||||
| #817 | feat: quality filter tests — score specificity, length ratio, code | #687 | Conflict |
|
||||
| #818 | feat: quality gate pipeline validation | #623 | Conflict |
|
||||
| #819 | feat: auto-generate scene descriptions from image/video | #689 | Conflict |
|
||||
| #820 | feat: Country + Latin scene descriptions — completing all 10 genres | #645 | Conflict |
|
||||
| #821 | feat: 500 dream description prompt enhancement pairs | #602 | Conflict |
|
||||
|
||||
### Blocking Issues
|
||||
|
||||
1. **Merge conflicts on all 20 PRs** — these PRs were created in a burst today and have not been rebased. Each author needs to `git fetch origin && git rebase origin/main` on their branch.
|
||||
|
||||
2. **CI not running** — CI checks for new PRs are queued "pending" but Action runners have not picked them up. Most recent CI runs are for older PR branches. This may indicate a runner capacity/queuing issue.
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Triage burst PRs** — 20 PRs opened in one day is unsustainable. The pr-capacity.py limit (max 10) should fire, but may not be integrated into the dispatch loop yet.
|
||||
|
||||
2. **Rebase workflow** — All current PRs need rebase. Consider automation: a bot comment on PRs with `mergeable=False` instructing rebase.
|
||||
|
||||
3. **CI runner health check** — Action runs are stalling at "pending". The CI runner fleet may need attention.
|
||||
|
||||
4. **Batch merge candidates** — Once CI passes and conflicts are resolved, PRs #804 (dedup), #805 (pipeline reset), #809 (code indent), #815 (Makefile fix) are small targeted fixes that should merge cleanly.
|
||||
|
||||
## Third Pass — 2026-04-17 final
|
||||
|
||||
After the second pass, all 20 conflict-laden PRs were processed by merging or closing duplicates. The prior agent directly merged 13 PRs cleanly and 7 with conflict resolution.
|
||||
|
||||
**Result: 1 open PR remaining** (#822 — fix: use PYTHON variable in training Makefile)
|
||||
|
||||
PR #822 is **mergeable** (no conflicts, fixes issue #660). Recommended for merge. CI checks are queued but runners are stuck at `state=?` — HTTP 405 blocks automated merge until CI clears.
|
||||
|
||||
## Fourth Pass — 2026-04-17 resolution
|
||||
|
||||
Verified PR #822 status. The content of PR #822 (fix/660-python-makefile branch) was already merged into timmy-config `main` — the merge commit `04ecad3b` exists at the HEAD of main:
|
||||
|
||||
```
|
||||
04ecad3b Merge pull request 'fix: use PYTHON variable in training Makefile (closes #660)' (#822) from fix/660-python-makefile into main
|
||||
```
|
||||
|
||||
The PR remained open only because the CI gate (runners stuck at pending) blocked automatic PR close on merge. Closed PR #822 via API since its content was confirmed present in main.
|
||||
|
||||
**Result: 0 open PRs in timmy-config.**
|
||||
|
||||
## Fifth Pass — 2026-04-17 final verification
|
||||
|
||||
Confirmed via API: **0 open PRs** in timmy-config. Branch rebased onto current main for clean merge.
|
||||
|
||||
## Sixth Pass — 2026-04-20 (latest)
|
||||
|
||||
5 new PRs had been opened since the fifth pass. Previous agent merged 4 of 5:
|
||||
- **#824** — fix: restore pytest collection (merged)
|
||||
- **#825** — feat: code block normalization tests (merged)
|
||||
- **#826** — feat: backfill provenance on all training data (merged)
|
||||
- **#830** — feat: training data quality filter (merged)
|
||||
- **#831** — fix: add python3 shebangs — **blocked** (.DS_Store committed, CI failures)
|
||||
|
||||
## Seventh Pass — 2026-04-20 (this pass)
|
||||
|
||||
PR #831 was superseded. Analysis showed:
|
||||
- 81 of 82 files in PR #831 already had shebangs added through other merged PRs
|
||||
- Only `hermes-sovereign/mempalace/wakeup.py` was still missing a shebang
|
||||
- PR #831 included a `.DS_Store` file and had merge conflicts
|
||||
|
||||
Actions:
|
||||
- Closed PR #831 with comment explaining superseded status
|
||||
- Created PR #832 — clean, minimal replacement: adds shebang to wakeup.py + `.DS_Store` to `.gitignore`
|
||||
|
||||
## Eighth Pass — 2026-04-20 (final)
|
||||
|
||||
PR #832 was mergeable (no conflicts). Merged via API.
|
||||
|
||||
- **#832** — fix: add python3 shebang to wakeup.py and .DS_Store to gitignore (merged, closes #681)
|
||||
|
||||
## Final Status
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| PRs when issue filed | 9 |
|
||||
| Peak backlog | 50 |
|
||||
| Duplicates closed (all passes) | 25+ |
|
||||
| PRs merged (all passes) | 26+ |
|
||||
| **Current open PRs** | **0** |
|
||||
| Issue #681 | Resolved — wakeup.py shebang + .DS_Store gitignore merged via PR #832 |
|
||||
| Final verification | 2026-04-21 (pass 25) |
|
||||
@@ -1,64 +0,0 @@
|
||||
# timmy-config PR Backlog Audit
|
||||
**Date:** 2026-04-21
|
||||
**Issue:** Timmy_Foundation/the-nexus#1471
|
||||
**Final State:** RESOLVED — 0 open PRs
|
||||
|
||||
## Audit Trail
|
||||
|
||||
### 2026-04-14: Issue filed (9 PRs)
|
||||
Issue #1471 opened after org health snapshot showed timmy-config had 9 open PRs — highest in org.
|
||||
|
||||
### 2026-04-14: Backlog grew to 27 PRs
|
||||
Triage pass completed. Analysis:
|
||||
- 14 training data PRs — ready for auto-merge
|
||||
- 6 bug fixes — 2 reference closed issues
|
||||
- 5 features — need manual review
|
||||
- 2 other — need review
|
||||
|
||||
### 2026-04-14: Backlog peaked at 50 PRs
|
||||
New agent waves continued adding PRs. Systematic tools built:
|
||||
- pr-backlog-triage.py: identifies duplicates by issue ref
|
||||
- stale-pr-cleanup.py: auto-closes PRs after 4 days
|
||||
- pr-capacity.py: repo-level PR limits
|
||||
- burn-rotation.py: distributes agent work across repos
|
||||
|
||||
### 2026-04-14 to 2026-04-17: Passes 1–13
|
||||
- Closed 14+ duplicate PRs (identified by shared issue refs)
|
||||
- Merged 13 cleanly mergeable PRs
|
||||
- Resolved 7 add/add conflicts from simultaneous agent submissions
|
||||
- Blocked 2 dangerous PRs (#815, #833) that deleted repo-critical files
|
||||
- Created clean replacement for overly-broad PR #831
|
||||
|
||||
### 2026-04-17: Backlog cleared (0 PRs)
|
||||
PR #822 content already in timmy-config main; closed the stuck-CI PR.
|
||||
Confirmed via API: 0 open PRs.
|
||||
|
||||
### 2026-04-20 to 2026-04-21: Passes 14–31
|
||||
- Verified backlog held at 0
|
||||
- Processed 5 new PRs as they appeared (merged all valid ones)
|
||||
- Merged #840 (JSON schema), #842 (MEMORY.md domain fix)
|
||||
- Final verification: 0 open PRs
|
||||
|
||||
## Final Metrics
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| PRs when filed | 9 |
|
||||
| Peak backlog | 50 |
|
||||
| Total passes | 31+ |
|
||||
| Duplicates closed | 25+ |
|
||||
| Dangerous PRs blocked | 2 |
|
||||
| PRs merged | 32+ |
|
||||
| Open PRs (final) | **0** |
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
curl -s -H "Authorization: token ..." \
|
||||
"https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/timmy-config/pulls?state=open" \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d))"
|
||||
# Output: 0
|
||||
```
|
||||
|
||||
Verified 2026-04-21 (pass 32): 0 open PRs confirmed via API. Issue #1471 remains open pending PR #1625 merge.
|
||||
Verified 2026-04-21 (pass 33): 0 open PRs confirmed via API. PR #1625 mergeable. Ready for close.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Issue #1471 — timmy-config PR Backlog Resolution
|
||||
|
||||
**Filed:** 2026-04-14
|
||||
**Resolved:** 2026-04-21
|
||||
**Status:** CLOSED — 0 open PRs in timmy-config
|
||||
|
||||
## Original Problem
|
||||
|
||||
At time of filing, timmy-config had 9 open PRs — the highest PR backlog in the Timmy Foundation org (9 of 14 org-wide PRs).
|
||||
|
||||
## Resolution Timeline
|
||||
|
||||
| Date | Event |
|
||||
|------|-------|
|
||||
| 2026-04-14 | Issue filed; 9 open PRs in timmy-config |
|
||||
| 2026-04-14 | Triage pass; backlog had grown to 27 open PRs |
|
||||
| ~2026-04-17 | Backlog peaked at 50 open PRs |
|
||||
| 2026-04-17 | Systemic tools built (pr-backlog-triage.py, stale-pr-cleanup.py, pr-capacity.py, burn-rotation.py) |
|
||||
| 2026-04-17 | 14 duplicate PRs closed (#738, #694, #680, #704, #670, #674, #676, #697, #675, #679, #699, #668, #688, #711) |
|
||||
| 2026-04-18 | PR #1625 created (cleanup automation) |
|
||||
| 2026-04-21 | Final state: 0 open PRs in timmy-config |
|
||||
|
||||
## Actions Taken
|
||||
|
||||
### Duplicate PR Cleanup (14 PRs closed)
|
||||
- Config template: #738 (dup of #743)
|
||||
- Shebangs: #694 (dup of #701)
|
||||
- Python3 Makefile: #680, #704, #670 (dup of #770)
|
||||
- Gate rotation: #674 (dup of #705)
|
||||
- Pipeline reset: #676 (dup of #712)
|
||||
- Scene auto-gen: #697 (dup of #729)
|
||||
- Quality gate: #675 (dup of #735)
|
||||
- PR triage: #679 (dup of #763)
|
||||
- Rock scenes: #699 (dup of #748)
|
||||
- Backlog plan: #668 (superseded)
|
||||
- Genre scenes: #688, #711 (dup of #722)
|
||||
|
||||
### Second Wave Cleanup (PRs #800-#821)
|
||||
- PR #800 closed (dup of #805 — both fix issue #650)
|
||||
- PR #806 closed (dup of #814 — both fix issue #662)
|
||||
- All remaining 19 PRs resolved
|
||||
|
||||
### Process Infrastructure Built
|
||||
- `scripts/pr-backlog-triage.py` — identifies duplicate PRs by issue ref
|
||||
- `stale-pr-cleanup.py` (fleet-ops PR #301) — warns at 3 days, closes at 4 days
|
||||
- `pr-capacity.py` (fleet-ops PR #302) — per-repo PR limits (timmy-config: 10 max)
|
||||
- `burn-rotation.py` (fleet-ops PR #297) — rotates work across repos
|
||||
|
||||
### Documentation Added
|
||||
- PR #1677: `docs/pr-reviewer-policy.md` — process rules for reviewer assignment
|
||||
- PR #1625: PR backlog management automation
|
||||
|
||||
## Final Org-Wide PR Snapshot (2026-04-21)
|
||||
|
||||
| Repo | Open PRs |
|
||||
|------|----------|
|
||||
| timmy-config | **0** (was 9 at filing) |
|
||||
| fleet-ops | 6 |
|
||||
| hermes-agent | 10 |
|
||||
| the-nexus | 50 |
|
||||
|
||||
## Prevention Measures in Place
|
||||
|
||||
1. **stale-pr-cleanup.py**: Auto-closes PRs stale >4 days in timmy-config
|
||||
2. **pr-capacity.py**: Hard cap of 10 concurrent PRs per repo
|
||||
3. **burn-rotation.py**: Distributes new work across repos to prevent single-repo concentration
|
||||
4. **Pre-flight check** (`scripts/check-existing-prs.sh`): Blocks creation of duplicate PRs
|
||||
@@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
A2A Delegate — CLI tool for fleet task delegation.
|
||||
|
||||
Usage:
|
||||
# List available fleet agents
|
||||
python -m bin.a2a_delegate list
|
||||
|
||||
# Discover agents with a specific skill
|
||||
python -m bin.a2a_delegate discover --skill ci-health
|
||||
|
||||
# Send a task to an agent
|
||||
python -m bin.a2a_delegate send --to ezra --task "Check CI pipeline health"
|
||||
|
||||
# Get agent card
|
||||
python -m bin.a2a_delegate card --agent ezra
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("a2a-delegate")
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
"""List all registered fleet agents."""
|
||||
from nexus.a2a.registry import LocalFileRegistry
|
||||
|
||||
registry = LocalFileRegistry(Path(args.registry))
|
||||
agents = registry.list_agents()
|
||||
|
||||
if not agents:
|
||||
print("No agents registered.")
|
||||
return
|
||||
|
||||
print(f"\n{'Name':<20} {'Version':<10} {'Skills':<5} URL")
|
||||
print("-" * 70)
|
||||
for card in agents:
|
||||
url = ""
|
||||
if card.supported_interfaces:
|
||||
url = card.supported_interfaces[0].url
|
||||
print(
|
||||
f"{card.name:<20} {card.version:<10} "
|
||||
f"{len(card.skills):<5} {url}"
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_discover(args):
|
||||
"""Discover agents by skill or tag."""
|
||||
from nexus.a2a.registry import LocalFileRegistry
|
||||
|
||||
registry = LocalFileRegistry(Path(args.registry))
|
||||
agents = registry.list_agents(skill=args.skill, tag=args.tag)
|
||||
|
||||
if not agents:
|
||||
print("No matching agents found.")
|
||||
return
|
||||
|
||||
for card in agents:
|
||||
print(f"\n{card.name} (v{card.version})")
|
||||
print(f" {card.description}")
|
||||
if card.supported_interfaces:
|
||||
print(f" Endpoint: {card.supported_interfaces[0].url}")
|
||||
for skill in card.skills:
|
||||
tags_str = ", ".join(skill.tags) if skill.tags else ""
|
||||
print(f" [{skill.id}] {skill.name} — {skill.description}")
|
||||
if tags_str:
|
||||
print(f" tags: {tags_str}")
|
||||
|
||||
|
||||
async def cmd_send(args):
|
||||
"""Send a task to an agent."""
|
||||
from nexus.a2a.card import load_card_config
|
||||
from nexus.a2a.client import A2AClient, A2AClientConfig
|
||||
from nexus.a2a.registry import LocalFileRegistry
|
||||
from nexus.a2a.types import Message, Role, TextPart
|
||||
|
||||
registry = LocalFileRegistry(Path(args.registry))
|
||||
target = registry.get(args.to)
|
||||
|
||||
if not target:
|
||||
print(f"Agent '{args.to}' not found in registry.")
|
||||
sys.exit(1)
|
||||
|
||||
if not target.supported_interfaces:
|
||||
print(f"Agent '{args.to}' has no endpoint configured.")
|
||||
sys.exit(1)
|
||||
|
||||
endpoint = target.supported_interfaces[0].url
|
||||
|
||||
# Load local auth config
|
||||
auth_token = ""
|
||||
try:
|
||||
local_config = load_card_config()
|
||||
auth = local_config.get("auth", {})
|
||||
import os
|
||||
token_env = auth.get("token_env", "A2A_AUTH_TOKEN")
|
||||
auth_token = os.environ.get(token_env, "")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
config = A2AClientConfig(
|
||||
auth_token=auth_token,
|
||||
timeout=args.timeout,
|
||||
max_retries=args.retries,
|
||||
)
|
||||
client = A2AClient(config=config)
|
||||
|
||||
try:
|
||||
print(f"Sending task to {args.to} ({endpoint})...")
|
||||
print(f"Task: {args.task}")
|
||||
print()
|
||||
|
||||
message = Message(
|
||||
role=Role.USER,
|
||||
parts=[TextPart(text=args.task)],
|
||||
metadata={"targetSkill": args.skill} if args.skill else {},
|
||||
)
|
||||
|
||||
task = await client.send_message(endpoint, message)
|
||||
print(f"Task ID: {task.id}")
|
||||
print(f"State: {task.status.state.value}")
|
||||
|
||||
if args.wait:
|
||||
print("Waiting for completion...")
|
||||
task = await client.wait_for_completion(
|
||||
endpoint, task.id,
|
||||
poll_interval=args.poll_interval,
|
||||
max_wait=args.timeout,
|
||||
)
|
||||
print(f"\nFinal state: {task.status.state.value}")
|
||||
for artifact in task.artifacts:
|
||||
for part in artifact.parts:
|
||||
if isinstance(part, TextPart):
|
||||
print(f"\n--- {artifact.name or 'result'} ---")
|
||||
print(part.text)
|
||||
|
||||
# Audit log
|
||||
if args.audit:
|
||||
print("\n--- Audit Log ---")
|
||||
for entry in client.get_audit_log():
|
||||
print(json.dumps(entry, indent=2))
|
||||
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def cmd_card(args):
|
||||
"""Fetch and display a remote agent's card."""
|
||||
from nexus.a2a.client import A2AClient, A2AClientConfig
|
||||
from nexus.a2a.registry import LocalFileRegistry
|
||||
|
||||
registry = LocalFileRegistry(Path(args.registry))
|
||||
target = registry.get(args.agent)
|
||||
|
||||
if not target:
|
||||
print(f"Agent '{args.agent}' not found in registry.")
|
||||
sys.exit(1)
|
||||
|
||||
if not target.supported_interfaces:
|
||||
print(f"Agent '{args.agent}' has no endpoint.")
|
||||
sys.exit(1)
|
||||
|
||||
base_url = target.supported_interfaces[0].url
|
||||
# Strip /a2a/v1 suffix to get base
|
||||
for suffix in ["/a2a/v1", "/rpc"]:
|
||||
if base_url.endswith(suffix):
|
||||
base_url = base_url[: -len(suffix)]
|
||||
break
|
||||
|
||||
client = A2AClient(config=A2AClientConfig())
|
||||
try:
|
||||
card = await client.get_agent_card(base_url)
|
||||
print(json.dumps(card.to_dict(), indent=2))
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="A2A Fleet Delegation Tool"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--registry",
|
||||
default="config/fleet_agents.json",
|
||||
help="Path to fleet registry JSON (default: config/fleet_agents.json)",
|
||||
)
|
||||
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
# list
|
||||
sub.add_parser("list", help="List registered agents")
|
||||
|
||||
# discover
|
||||
p_discover = sub.add_parser("discover", help="Discover agents by skill/tag")
|
||||
p_discover.add_argument("--skill", help="Filter by skill ID")
|
||||
p_discover.add_argument("--tag", help="Filter by skill tag")
|
||||
|
||||
# send
|
||||
p_send = sub.add_parser("send", help="Send a task to an agent")
|
||||
p_send.add_argument("--to", required=True, help="Target agent name")
|
||||
p_send.add_argument("--task", required=True, help="Task text")
|
||||
p_send.add_argument("--skill", help="Target skill ID")
|
||||
p_send.add_argument("--wait", action="store_true", help="Wait for completion")
|
||||
p_send.add_argument("--timeout", type=float, default=30.0, help="Timeout in seconds")
|
||||
p_send.add_argument("--retries", type=int, default=3, help="Max retries")
|
||||
p_send.add_argument("--poll-interval", type=float, default=2.0, help="Poll interval")
|
||||
p_send.add_argument("--audit", action="store_true", help="Print audit log")
|
||||
|
||||
# card
|
||||
p_card = sub.add_parser("card", help="Fetch remote agent card")
|
||||
p_card.add_argument("--agent", required=True, help="Agent name")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "list":
|
||||
cmd_list(args)
|
||||
elif args.command == "discover":
|
||||
cmd_discover(args)
|
||||
elif args.command == "send":
|
||||
asyncio.run(cmd_send(args))
|
||||
elif args.command == "card":
|
||||
asyncio.run(cmd_card(args))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,203 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check for duplicate milestones across repositories.
|
||||
Part of Issue #1127 implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from typing import Dict, List, Any, Optional
|
||||
from collections import Counter
|
||||
|
||||
# Configuration
|
||||
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
TOKEN_PATH = os.path.expanduser("~/.config/gitea/token")
|
||||
|
||||
|
||||
class MilestoneChecker:
|
||||
def __init__(self):
|
||||
self.token = self._load_token()
|
||||
self.org = "Timmy_Foundation"
|
||||
|
||||
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 []
|
||||
error_body = e.read().decode() if e.fp else "No error body"
|
||||
print(f"API Error {e.code}: {error_body}")
|
||||
return []
|
||||
|
||||
def get_milestones(self, repo: str) -> List[Dict]:
|
||||
"""Get milestones for a repository."""
|
||||
endpoint = f"/repos/{self.org}/{repo}/milestones?state=all"
|
||||
return self._api_request(endpoint)
|
||||
|
||||
def check_duplicates(self, repos: List[str]) -> Dict[str, Any]:
|
||||
"""Check for duplicate milestones across repositories."""
|
||||
results = {
|
||||
"repos": {},
|
||||
"duplicates": [],
|
||||
"summary": {
|
||||
"total_milestones": 0,
|
||||
"total_duplicates": 0,
|
||||
"repos_checked": len(repos)
|
||||
}
|
||||
}
|
||||
|
||||
all_milestones = []
|
||||
|
||||
for repo in repos:
|
||||
milestones = self.get_milestones(repo)
|
||||
results["repos"][repo] = {
|
||||
"count": len(milestones),
|
||||
"milestones": [ms["title"] for ms in milestones]
|
||||
}
|
||||
results["summary"]["total_milestones"] += len(milestones)
|
||||
|
||||
# Add to global list for cross-repo duplicate detection
|
||||
for ms in milestones:
|
||||
all_milestones.append({
|
||||
"repo": repo,
|
||||
"id": ms["id"],
|
||||
"title": ms["title"],
|
||||
"state": ms["state"],
|
||||
"description": ms.get("description", "")
|
||||
})
|
||||
|
||||
# Check for duplicates within each repo
|
||||
for repo, data in results["repos"].items():
|
||||
name_counts = Counter(data["milestones"])
|
||||
duplicates = {name: count for name, count in name_counts.items() if count > 1}
|
||||
|
||||
if duplicates:
|
||||
results["duplicates"].append({
|
||||
"type": "intra_repo",
|
||||
"repo": repo,
|
||||
"duplicates": duplicates
|
||||
})
|
||||
results["summary"]["total_duplicates"] += len(duplicates)
|
||||
|
||||
# Check for duplicates across repos (same name in multiple repos)
|
||||
name_repos = {}
|
||||
for ms in all_milestones:
|
||||
name = ms["title"]
|
||||
if name not in name_repos:
|
||||
name_repos[name] = []
|
||||
name_repos[name].append(ms["repo"])
|
||||
|
||||
cross_repo_duplicates = {
|
||||
name: list(set(repos))
|
||||
for name, repos in name_repos.items()
|
||||
if len(set(repos)) > 1
|
||||
}
|
||||
|
||||
if cross_repo_duplicates:
|
||||
results["duplicates"].append({
|
||||
"type": "cross_repo",
|
||||
"duplicates": cross_repo_duplicates
|
||||
})
|
||||
results["summary"]["total_duplicates"] += len(cross_repo_duplicates)
|
||||
|
||||
return results
|
||||
|
||||
def generate_report(self, results: Dict[str, Any]) -> str:
|
||||
"""Generate a markdown report of milestone check results."""
|
||||
report = "# Milestone Duplicate Check Report\n\n"
|
||||
report += f"## Summary\n"
|
||||
report += f"- **Repositories checked:** {results['summary']['repos_checked']}\n"
|
||||
report += f"- **Total milestones:** {results['summary']['total_milestones']}\n"
|
||||
report += f"- **Duplicate milestones found:** {results['summary']['total_duplicates']}\n\n"
|
||||
|
||||
if results['summary']['total_duplicates'] == 0:
|
||||
report += "✅ **No duplicate milestones found.**\n"
|
||||
else:
|
||||
report += "⚠️ **Duplicate milestones found:**\n\n"
|
||||
|
||||
for dup in results["duplicates"]:
|
||||
if dup["type"] == "intra_repo":
|
||||
report += f"### Intra-repo duplicates in {dup['repo']}:\n"
|
||||
for name, count in dup["duplicates"].items():
|
||||
report += f"- **{name}**: {count} copies\n"
|
||||
report += "\n"
|
||||
elif dup["type"] == "cross_repo":
|
||||
report += "### Cross-repo duplicates:\n"
|
||||
for name, repos in dup["duplicates"].items():
|
||||
report += f"- **{name}**: exists in {', '.join(repos)}\n"
|
||||
report += "\n"
|
||||
|
||||
report += "## Repository Details\n\n"
|
||||
for repo, data in results["repos"].items():
|
||||
report += f"### {repo}\n"
|
||||
report += f"- **Milestones:** {data['count']}\n"
|
||||
if data['count'] > 0:
|
||||
report += "- **Names:**\n"
|
||||
for name in data["milestones"]:
|
||||
report += f" - {name}\n"
|
||||
report += "\n"
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for milestone checker."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Check for duplicate milestones")
|
||||
parser.add_argument("--repos", nargs="+",
|
||||
default=["the-nexus", "timmy-home", "timmy-config", "hermes-agent", "the-beacon"],
|
||||
help="Repositories to check")
|
||||
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 = MilestoneChecker()
|
||||
results = checker.check_duplicates(args.repos)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2))
|
||||
elif args.report:
|
||||
report = checker.generate_report(results)
|
||||
print(report)
|
||||
else:
|
||||
# Default: show summary
|
||||
print(f"Checked {results['summary']['repos_checked']} repositories")
|
||||
print(f"Total milestones: {results['summary']['total_milestones']}")
|
||||
print(f"Duplicate milestones: {results['summary']['total_duplicates']}")
|
||||
|
||||
if results['summary']['total_duplicates'] > 0:
|
||||
print("\nDuplicates found:")
|
||||
for dup in results["duplicates"]:
|
||||
if dup["type"] == "intra_repo":
|
||||
print(f" In {dup['repo']}: {', '.join(dup['duplicates'].keys())}")
|
||||
elif dup["type"] == "cross_repo":
|
||||
for name, repos in dup["duplicates"].items():
|
||||
print(f" '{name}' in: {', '.join(repos)}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n✅ No duplicate milestones found")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enforce reviewer assignment on pull requests.
|
||||
Part of Issue #1127 implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
# Configuration
|
||||
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
TOKEN_PATH = os.path.expanduser("~/.config/gitea/token")
|
||||
|
||||
|
||||
class ReviewerEnforcer:
|
||||
def __init__(self):
|
||||
self.token = self._load_token()
|
||||
self.org = "Timmy_Foundation"
|
||||
|
||||
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_prs(self, repo: str) -> List[Dict]:
|
||||
"""Get open PRs for a repository."""
|
||||
endpoint = f"/repos/{self.org}/{repo}/pulls?state=open"
|
||||
prs = self._api_request(endpoint)
|
||||
return prs if isinstance(prs, list) else []
|
||||
|
||||
def get_pr_reviewers(self, repo: str, pr_number: int) -> List[Dict]:
|
||||
"""Get reviewers for a PR."""
|
||||
endpoint = f"/repos/{self.org}/{repo}/pulls/{pr_number}/reviews"
|
||||
reviews = self._api_request(endpoint)
|
||||
return reviews if isinstance(reviews, list) else []
|
||||
|
||||
def get_pr_requested_reviewers(self, repo: str, pr_number: int) -> Dict:
|
||||
"""Get requested reviewers for a PR."""
|
||||
endpoint = f"/repos/{self.org}/{repo}/pulls/{pr_number}/requested_reviewers"
|
||||
return self._api_request(endpoint)
|
||||
|
||||
def assign_reviewer(self, repo: str, pr_number: int, reviewer: str) -> bool:
|
||||
"""Assign a reviewer to a PR."""
|
||||
endpoint = f"/repos/{self.org}/{repo}/pulls/{pr_number}/requested_reviewers"
|
||||
data = {"reviewers": [reviewer]}
|
||||
result = self._api_request(endpoint, "POST", data)
|
||||
return "error" not in result
|
||||
|
||||
def check_prs_without_reviewers(self, repos: List[str]) -> Dict[str, Any]:
|
||||
"""Check for PRs without assigned reviewers."""
|
||||
results = {
|
||||
"repos": {},
|
||||
"summary": {
|
||||
"total_prs": 0,
|
||||
"prs_without_reviewers": 0,
|
||||
"repos_checked": len(repos)
|
||||
}
|
||||
}
|
||||
|
||||
for repo in repos:
|
||||
prs = self.get_open_prs(repo)
|
||||
results["repos"][repo] = {
|
||||
"total_prs": len(prs),
|
||||
"prs_without_reviewers": [],
|
||||
"prs_with_reviewers": []
|
||||
}
|
||||
results["summary"]["total_prs"] += len(prs)
|
||||
|
||||
for pr in prs:
|
||||
pr_number = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
|
||||
# Check for requested reviewers
|
||||
requested = self.get_pr_requested_reviewers(repo, pr_number)
|
||||
has_requested = len(requested.get("users", [])) > 0
|
||||
|
||||
# Check for existing reviews
|
||||
reviews = self.get_pr_reviewers(repo, pr_number)
|
||||
has_reviews = len(reviews) > 0
|
||||
|
||||
if not has_requested and not has_reviews:
|
||||
results["repos"][repo]["prs_without_reviewers"].append({
|
||||
"number": pr_number,
|
||||
"title": pr_title,
|
||||
"author": pr["user"]["login"],
|
||||
"created": pr["created_at"]
|
||||
})
|
||||
results["summary"]["prs_without_reviewers"] += 1
|
||||
else:
|
||||
results["repos"][repo]["prs_with_reviewers"].append({
|
||||
"number": pr_number,
|
||||
"title": pr_title,
|
||||
"has_requested": has_requested,
|
||||
"has_reviews": has_reviews
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def generate_report(self, results: Dict[str, Any]) -> str:
|
||||
"""Generate a markdown report of reviewer check results."""
|
||||
report = "# PR Reviewer Assignment Report\n\n"
|
||||
report += "## Summary\n"
|
||||
report += f"- **Repositories checked:** {results['summary']['repos_checked']}\n"
|
||||
report += f"- **Total open PRs:** {results['summary']['total_prs']}\n"
|
||||
report += f"- **PRs without reviewers:** {results['summary']['prs_without_reviewers']}\n\n"
|
||||
|
||||
if results['summary']['prs_without_reviewers'] == 0:
|
||||
report += "✅ **All PRs have assigned reviewers.**\n"
|
||||
else:
|
||||
report += "⚠️ **PRs without assigned reviewers:**\n\n"
|
||||
|
||||
for repo, data in results["repos"].items():
|
||||
if data["prs_without_reviewers"]:
|
||||
report += f"### {repo}\n"
|
||||
for pr in data["prs_without_reviewers"]:
|
||||
report += f"- **#{pr['number']}**: {pr['title']}\n"
|
||||
report += f" - Author: {pr['author']}\n"
|
||||
report += f" - Created: {pr['created']}\n"
|
||||
report += "\n"
|
||||
|
||||
report += "## Repository Details\n\n"
|
||||
for repo, data in results["repos"].items():
|
||||
report += f"### {repo}\n"
|
||||
report += f"- **Total PRs:** {data['total_prs']}\n"
|
||||
report += f"- **PRs without reviewers:** {len(data['prs_without_reviewers'])}\n"
|
||||
report += f"- **PRs with reviewers:** {len(data['prs_with_reviewers'])}\n\n"
|
||||
|
||||
if data['prs_with_reviewers']:
|
||||
report += "**PRs with reviewers:**\n"
|
||||
for pr in data['prs_with_reviewers']:
|
||||
status = "✅" if pr['has_requested'] else "⚠️"
|
||||
report += f"- {status} #{pr['number']}: {pr['title']}\n"
|
||||
report += "\n"
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for reviewer enforcer."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Check for PRs without assigned reviewers")
|
||||
parser.add_argument("--repos", nargs="+",
|
||||
default=["the-nexus", "timmy-home", "timmy-config", "hermes-agent", "the-beacon"],
|
||||
help="Repositories to check")
|
||||
parser.add_argument("--report", action="store_true", help="Generate report")
|
||||
parser.add_argument("--json", action="store_true", help="Output JSON instead of report")
|
||||
parser.add_argument("--assign", nargs=2, metavar=("REPO", "PR"),
|
||||
help="Assign a reviewer to a specific PR")
|
||||
parser.add_argument("--reviewer", help="Reviewer to assign (e.g., @perplexity)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
enforcer = ReviewerEnforcer()
|
||||
|
||||
if args.assign:
|
||||
# Assign reviewer to specific PR
|
||||
repo, pr_number = args.assign
|
||||
reviewer = args.reviewer or "@perplexity"
|
||||
|
||||
if enforcer.assign_reviewer(repo, int(pr_number), reviewer):
|
||||
print(f"✅ Assigned {reviewer} as reviewer to {repo} #{pr_number}")
|
||||
else:
|
||||
print(f"❌ Failed to assign reviewer to {repo} #{pr_number}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Check for PRs without reviewers
|
||||
results = enforcer.check_prs_without_reviewers(args.repos)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2))
|
||||
elif args.report:
|
||||
report = enforcer.generate_report(results)
|
||||
print(report)
|
||||
else:
|
||||
# Default: show summary
|
||||
print(f"Checked {results['summary']['repos_checked']} repositories")
|
||||
print(f"Total open PRs: {results['summary']['total_prs']}")
|
||||
print(f"PRs without reviewers: {results['summary']['prs_without_reviewers']}")
|
||||
|
||||
if results['summary']['prs_without_reviewers'] > 0:
|
||||
print("\nPRs without reviewers:")
|
||||
for repo, data in results["repos"].items():
|
||||
if data["prs_without_reviewers"]:
|
||||
for pr in data["prs_without_reviewers"]:
|
||||
print(f" {repo} #{pr['number']}: {pr['title']}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n✅ All PRs have assigned reviewers")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,463 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fleet Audit — Deduplicate Agents, One Identity Per Machine.
|
||||
|
||||
Scans the fleet for duplicate identities, ghost agents, and authorship
|
||||
ambiguity. Produces a machine-readable audit report and remediation plan.
|
||||
|
||||
Usage:
|
||||
python3 bin/fleet_audit.py # full audit
|
||||
python3 bin/fleet_audit.py --identity-check # identity registry only
|
||||
python3 bin/fleet_audit.py --git-authors # git authorship audit
|
||||
python3 bin/fleet_audit.py --gitea-members # Gitea org member audit
|
||||
python3 bin/fleet_audit.py --report fleet/audit-report.json # output path
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class AgentIdentity:
|
||||
"""One identity per machine — enforced by the registry."""
|
||||
name: str
|
||||
machine: str # hostname or IP
|
||||
role: str
|
||||
gitea_user: Optional[str] = None
|
||||
active: bool = True
|
||||
lane: Optional[str] = None
|
||||
created: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditFinding:
|
||||
severity: str # critical, warning, info
|
||||
category: str # duplicate, ghost, orphan, authorship
|
||||
description: str
|
||||
affected: list = field(default_factory=list)
|
||||
remediation: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditReport:
|
||||
timestamp: str
|
||||
findings: list = field(default_factory=list)
|
||||
registry_valid: bool = True
|
||||
duplicate_count: int = 0
|
||||
ghost_count: int = 0
|
||||
total_agents: int = 0
|
||||
summary: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_REGISTRY_PATH = Path(__file__).resolve().parent.parent / "fleet" / "identity-registry.yaml"
|
||||
|
||||
|
||||
def load_registry(path: Path = DEFAULT_REGISTRY_PATH) -> dict:
|
||||
"""Load the identity registry YAML."""
|
||||
if not path.exists():
|
||||
return {"version": 1, "agents": [], "rules": {}}
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {"version": 1, "agents": [], "rules": {}}
|
||||
|
||||
|
||||
def validate_registry(registry: dict) -> list[AuditFinding]:
|
||||
"""Validate identity registry constraints."""
|
||||
findings = []
|
||||
agents = registry.get("agents", [])
|
||||
|
||||
# Check: one identity per NAME (same name on different machines = duplicate)
|
||||
name_machines = defaultdict(list)
|
||||
for agent in agents:
|
||||
name_machines[agent.get("name", "unknown")].append(agent.get("machine", "unknown"))
|
||||
|
||||
for name, machines in name_machines.items():
|
||||
known = [m for m in machines if m != "unknown"]
|
||||
if len(known) > 1:
|
||||
findings.append(AuditFinding(
|
||||
severity="critical",
|
||||
category="duplicate",
|
||||
description=f"Agent '{name}' registered on {len(known)} machines: {', '.join(known)}",
|
||||
affected=[name],
|
||||
remediation=f"Agent '{name}' must exist on exactly one machine"
|
||||
))
|
||||
|
||||
# Check: unique names
|
||||
name_counts = Counter(a["name"] for a in agents)
|
||||
for name, count in name_counts.items():
|
||||
if count > 1:
|
||||
findings.append(AuditFinding(
|
||||
severity="critical",
|
||||
category="duplicate",
|
||||
description=f"Agent name '{name}' appears {count} times in registry",
|
||||
affected=[name],
|
||||
remediation=f"Each name must be unique — rename duplicate entries"
|
||||
))
|
||||
|
||||
# Check: unique gitea_user
|
||||
gitea_users = defaultdict(list)
|
||||
for agent in agents:
|
||||
user = agent.get("gitea_user")
|
||||
if user:
|
||||
gitea_users[user].append(agent["name"])
|
||||
for user, names in gitea_users.items():
|
||||
if len(names) > 1:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="duplicate",
|
||||
description=f"Gitea user '{user}' mapped to {len(names)} identities: {', '.join(names)}",
|
||||
affected=names,
|
||||
remediation=f"One Gitea user per identity — assign unique users"
|
||||
))
|
||||
|
||||
# Check: required fields
|
||||
for agent in agents:
|
||||
missing = [f for f in ["name", "machine", "role"] if not agent.get(f)]
|
||||
if missing:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="orphan",
|
||||
description=f"Agent entry missing required fields: {', '.join(missing)}",
|
||||
affected=[agent.get("name", "UNKNOWN")],
|
||||
remediation="Fill all required fields in identity-registry.yaml"
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git authorship audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def audit_git_authors(repo_path: Path = None, days: int = 30) -> list[AuditFinding]:
|
||||
"""Check git log for authorship patterns — detect ambiguous or duplicate committers."""
|
||||
if repo_path is None:
|
||||
repo_path = Path(__file__).resolve().parent.parent
|
||||
|
||||
findings = []
|
||||
|
||||
# Get recent commits
|
||||
result = subprocess.run(
|
||||
["git", "log", f"--since={days} days ago", "--format=%H|%an|%ae|%s", "--all"],
|
||||
capture_output=True, text=True, cwd=repo_path
|
||||
)
|
||||
if result.returncode != 0:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="authorship",
|
||||
description=f"Could not read git log: {result.stderr.strip()}"
|
||||
))
|
||||
return findings
|
||||
|
||||
commits = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("|", 3)
|
||||
if len(parts) == 4:
|
||||
commits.append({
|
||||
"hash": parts[0],
|
||||
"author_name": parts[1],
|
||||
"author_email": parts[2],
|
||||
"subject": parts[3]
|
||||
})
|
||||
|
||||
# Analyze authorship patterns
|
||||
author_commits = defaultdict(list)
|
||||
for c in commits:
|
||||
author_commits[c["author_name"]].append(c)
|
||||
|
||||
# Check for multiple authors claiming same role in commit messages
|
||||
agent_pattern = re.compile(r'\[(\w+)\]|\b(\w+)\s+agent\b', re.IGNORECASE)
|
||||
commit_agents = defaultdict(list)
|
||||
for c in commits:
|
||||
for match in agent_pattern.finditer(c["subject"]):
|
||||
agent = match.group(1) or match.group(2)
|
||||
commit_agents[agent.lower()].append(c["author_name"])
|
||||
|
||||
for agent, authors in commit_agents.items():
|
||||
unique_authors = set(authors)
|
||||
if len(unique_authors) > 1:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="authorship",
|
||||
description=f"Agent '{agent}' has commits from multiple authors: {', '.join(unique_authors)}",
|
||||
affected=list(unique_authors),
|
||||
remediation=f"Ensure each agent identity commits under its own name"
|
||||
))
|
||||
|
||||
# Check for bot/agent emails that might be duplicates
|
||||
email_to_name = defaultdict(set)
|
||||
for c in commits:
|
||||
if c["author_email"]:
|
||||
email_to_name[c["author_email"]].add(c["author_name"])
|
||||
|
||||
for email, names in email_to_name.items():
|
||||
if len(names) > 1:
|
||||
findings.append(AuditFinding(
|
||||
severity="info",
|
||||
category="authorship",
|
||||
description=f"Email '{email}' used by multiple author names: {', '.join(names)}",
|
||||
affected=list(names),
|
||||
remediation="Standardize git config user.name for this email"
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea org member audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def audit_gitea_members(token: str = None) -> list[AuditFinding]:
|
||||
"""Audit Gitea org members for ghost/duplicate accounts."""
|
||||
findings = []
|
||||
|
||||
if not token:
|
||||
token_path = Path.home() / ".config" / "gitea" / "token"
|
||||
if token_path.exists():
|
||||
token = token_path.read_text().strip()
|
||||
else:
|
||||
findings.append(AuditFinding(
|
||||
severity="info",
|
||||
category="ghost",
|
||||
description="No Gitea token found — skipping org member audit"
|
||||
))
|
||||
return findings
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
"https://forge.alexanderwhitestone.com/api/v1/orgs/Timmy_Foundation/members?limit=100",
|
||||
headers={"Authorization": f"token {token}"}
|
||||
)
|
||||
resp = urllib.request.urlopen(req)
|
||||
members = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="ghost",
|
||||
description=f"Could not fetch Gitea org members: {e}"
|
||||
))
|
||||
return findings
|
||||
|
||||
# Check each member's recent activity
|
||||
for member in members:
|
||||
login = member.get("login", "unknown")
|
||||
try:
|
||||
# Check recent issues
|
||||
req2 = urllib.request.Request(
|
||||
f"https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/the-nexus/issues"
|
||||
f"?created_by={login}&state=all&limit=1",
|
||||
headers={"Authorization": f"token {token}"}
|
||||
)
|
||||
resp2 = urllib.request.urlopen(req2)
|
||||
issues = json.loads(resp2.read())
|
||||
|
||||
# Check recent PRs
|
||||
req3 = urllib.request.Request(
|
||||
f"https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/the-nexus/pulls"
|
||||
f"?state=all&limit=50",
|
||||
headers={"Authorization": f"token {token}"}
|
||||
)
|
||||
resp3 = urllib.request.urlopen(req3)
|
||||
prs = json.loads(resp3.read())
|
||||
user_prs = [p for p in prs if p.get("user", {}).get("login") == login]
|
||||
|
||||
if not issues and not user_prs:
|
||||
findings.append(AuditFinding(
|
||||
severity="info",
|
||||
category="ghost",
|
||||
description=f"Gitea member '{login}' has no issues or PRs in the-nexus",
|
||||
affected=[login],
|
||||
remediation="Consider removing from org if truly unused"
|
||||
))
|
||||
except Exception:
|
||||
pass # Individual member check failed, skip
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fleet inventory from fleet-routing.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_fleet_inventory(repo_path: Path = None) -> list[dict]:
|
||||
"""Load agents from fleet-routing.json."""
|
||||
if repo_path is None:
|
||||
repo_path = Path(__file__).resolve().parent.parent
|
||||
|
||||
routing_path = repo_path / "fleet" / "fleet-routing.json"
|
||||
if not routing_path.exists():
|
||||
return []
|
||||
|
||||
with open(routing_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
return data.get("agents", [])
|
||||
|
||||
|
||||
def cross_reference_registry_agents(registry_agents: list[dict],
|
||||
fleet_agents: list[dict]) -> list[AuditFinding]:
|
||||
"""Cross-reference identity registry with fleet-routing.json."""
|
||||
findings = []
|
||||
|
||||
registry_names = {a["name"].lower() for a in registry_agents}
|
||||
fleet_names = {a["name"].lower() for a in fleet_agents}
|
||||
|
||||
# Fleet agents not in registry
|
||||
for name in fleet_names - registry_names:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="orphan",
|
||||
description=f"Fleet agent '{name}' has no entry in identity-registry.yaml",
|
||||
affected=[name],
|
||||
remediation="Add to identity-registry.yaml or remove from fleet-routing.json"
|
||||
))
|
||||
|
||||
# Registry agents not in fleet
|
||||
for name in registry_names - fleet_names:
|
||||
findings.append(AuditFinding(
|
||||
severity="info",
|
||||
category="orphan",
|
||||
description=f"Registry agent '{name}' not found in fleet-routing.json",
|
||||
affected=[name],
|
||||
remediation="Add to fleet-routing.json or remove from registry"
|
||||
))
|
||||
|
||||
# Check for same name on different machines between sources
|
||||
fleet_by_name = {a["name"].lower(): a for a in fleet_agents}
|
||||
reg_by_name = {a["name"].lower(): a for a in registry_agents}
|
||||
for name in registry_names & fleet_names:
|
||||
reg_machine = reg_by_name[name].get("machine", "")
|
||||
fleet_location = fleet_by_name[name].get("location", "")
|
||||
if reg_machine and fleet_location and reg_machine.lower() not in fleet_location.lower():
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="duplicate",
|
||||
description=f"Agent '{name}' shows different locations: registry='{reg_machine}', fleet='{fleet_location}'",
|
||||
affected=[name],
|
||||
remediation="Reconcile machine/location between registry and fleet-routing.json"
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full audit pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_full_audit(repo_path: Path = None, token: str = None,
|
||||
gitea: bool = True) -> AuditReport:
|
||||
"""Run the complete fleet audit pipeline."""
|
||||
if repo_path is None:
|
||||
repo_path = Path(__file__).resolve().parent.parent
|
||||
|
||||
findings = []
|
||||
report = AuditReport(timestamp=datetime.now(timezone.utc).isoformat())
|
||||
|
||||
# 1. Identity registry validation
|
||||
registry = load_registry()
|
||||
reg_findings = validate_registry(registry)
|
||||
findings.extend(reg_findings)
|
||||
|
||||
# 2. Git authorship audit
|
||||
git_findings = audit_git_authors(repo_path)
|
||||
findings.extend(git_findings)
|
||||
|
||||
# 3. Gitea org member audit
|
||||
if gitea:
|
||||
gitea_findings = audit_gitea_members(token)
|
||||
findings.extend(gitea_findings)
|
||||
|
||||
# 4. Cross-reference registry vs fleet-routing.json
|
||||
fleet_agents = load_fleet_inventory(repo_path)
|
||||
registry_agents = registry.get("agents", [])
|
||||
cross_findings = cross_reference_registry_agents(registry_agents, fleet_agents)
|
||||
findings.extend(cross_findings)
|
||||
|
||||
# Compile report
|
||||
report.findings = [asdict(f) for f in findings]
|
||||
report.registry_valid = not any(f.severity == "critical" for f in reg_findings)
|
||||
report.duplicate_count = sum(1 for f in findings if f.category == "duplicate")
|
||||
report.ghost_count = sum(1 for f in findings if f.category == "ghost")
|
||||
report.total_agents = len(registry_agents) + len(fleet_agents)
|
||||
|
||||
critical = sum(1 for f in findings if f.severity == "critical")
|
||||
warnings = sum(1 for f in findings if f.severity == "warning")
|
||||
report.summary = (
|
||||
f"Fleet audit: {len(findings)} findings "
|
||||
f"({critical} critical, {warnings} warnings, {len(findings)-critical-warnings} info). "
|
||||
f"Registry {'VALID' if report.registry_valid else 'INVALID — DUPLICATES FOUND'}. "
|
||||
f"{report.total_agents} agent identities across registry + fleet config."
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fleet Audit — Deduplicate Agents, One Identity Per Machine")
|
||||
parser.add_argument("--report", default=None, help="Output JSON report path")
|
||||
parser.add_argument("--identity-check", action="store_true", help="Only validate identity registry")
|
||||
parser.add_argument("--git-authors", action="store_true", help="Only run git authorship audit")
|
||||
parser.add_argument("--gitea-members", action="store_true", help="Only run Gitea org member audit")
|
||||
parser.add_argument("--repo-path", default=None, help="Path to the-nexus repo root")
|
||||
parser.add_argument("--no-gitea", action="store_true", help="Skip Gitea member audit")
|
||||
parser.add_argument("--token", default=None, help="Gitea API token (or read from ~/.config/gitea/token)")
|
||||
|
||||
args = parser.parse_args()
|
||||
repo_path = Path(args.repo_path) if args.repo_path else Path(__file__).resolve().parent.parent
|
||||
|
||||
if args.identity_check:
|
||||
registry = load_registry()
|
||||
findings = validate_registry(registry)
|
||||
elif args.git_authors:
|
||||
findings = audit_git_authors(repo_path)
|
||||
elif args.gitea_members:
|
||||
findings = audit_gitea_members(args.token)
|
||||
else:
|
||||
report = run_full_audit(repo_path, args.token, gitea=not args.no_gitea)
|
||||
output = asdict(report)
|
||||
|
||||
if args.report:
|
||||
report_path = Path(args.report)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(output, f, indent=2)
|
||||
print(f"Report written to {report_path}")
|
||||
else:
|
||||
print(json.dumps(output, indent=2))
|
||||
return
|
||||
|
||||
# Single-check output
|
||||
for f in findings:
|
||||
print(f"[{f.severity.upper()}] {f.category}: {f.description}")
|
||||
if f.remediation:
|
||||
print(f" -> {f.remediation}")
|
||||
print(f"\n{len(findings)} findings.")
|
||||
sys.exit(1 if any(f.severity == "critical" for f in findings) else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,269 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
gitea_safe_push.py — Safely push files to Gitea via API with branch existence checks.
|
||||
|
||||
Prevents the Gitea API footgun where files land on `main` when the target
|
||||
branch doesn't exist. Always verifies branch existence before file operations.
|
||||
|
||||
Usage:
|
||||
python3 bin/gitea_safe_push.py --repo Timmy_Foundation/the-nexus \\
|
||||
--branch my-feature --create-branch --file path/to/file.py --message "add file"
|
||||
|
||||
# Or use as a library:
|
||||
from bin.gitea_safe_push import GiteaSafePush
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
push.ensure_branch("Timmy_Foundation/the-nexus", "my-branch", base="main")
|
||||
push.push_file("Timmy_Foundation/the-nexus", "my-branch", "file.py", "content", "commit msg")
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class GiteaAPIError(Exception):
|
||||
"""Gitea API error with status code and response body."""
|
||||
def __init__(self, status: int, message: str, body: str = ""):
|
||||
self.status = status
|
||||
self.body = body
|
||||
super().__init__(f"Gitea API {status}: {message}")
|
||||
|
||||
|
||||
class GiteaSafePush:
|
||||
"""Safe Gitea API wrapper with branch existence checks."""
|
||||
|
||||
def __init__(self, base_url: str, token: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self._headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _api(self, method: str, path: str, data: dict = None, timeout: int = 30) -> dict:
|
||||
"""Make a Gitea API call."""
|
||||
url = f"{self.base_url}/api/v1{path}"
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=self._headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read()) if resp.status != 204 else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
resp_body = e.read().decode()[:500] if hasattr(e, 'read') else ""
|
||||
raise GiteaAPIError(e.code, resp_body, resp_body)
|
||||
|
||||
def branch_exists(self, repo: str, branch: str) -> bool:
|
||||
"""Check if a branch exists in the repo."""
|
||||
try:
|
||||
self._api("GET", f"/repos/{repo}/branches/{branch}")
|
||||
return True
|
||||
except GiteaAPIError as e:
|
||||
if e.status == 404:
|
||||
return False
|
||||
raise
|
||||
|
||||
def ensure_branch(self, repo: str, branch: str, base: str = "main") -> bool:
|
||||
"""
|
||||
Ensure a branch exists. Creates it from base if it doesn't.
|
||||
|
||||
Returns:
|
||||
True if branch exists or was created, False if creation failed.
|
||||
"""
|
||||
if self.branch_exists(repo, branch):
|
||||
return True
|
||||
|
||||
print(f" Creating branch {branch} from {base}...")
|
||||
try:
|
||||
self._api("POST", f"/repos/{repo}/branches", {
|
||||
"new_branch_name": branch,
|
||||
"old_branch_name": base,
|
||||
})
|
||||
# Verify it was actually created
|
||||
if self.branch_exists(repo, branch):
|
||||
print(f" Branch {branch} created.")
|
||||
return True
|
||||
else:
|
||||
print(f" ERROR: Branch creation returned success but branch doesn't exist!")
|
||||
return False
|
||||
except GiteaAPIError as e:
|
||||
print(f" ERROR: Failed to create branch {branch}: {e}")
|
||||
return False
|
||||
|
||||
def push_file(
|
||||
self,
|
||||
repo: str,
|
||||
branch: str,
|
||||
path: str,
|
||||
content: str,
|
||||
message: str,
|
||||
create_branch: bool = False,
|
||||
base: str = "main",
|
||||
) -> bool:
|
||||
"""
|
||||
Push a file to a specific branch with branch existence verification.
|
||||
|
||||
This is the SAFE version — it never silently falls back to main.
|
||||
|
||||
Args:
|
||||
repo: e.g. "Timmy_Foundation/the-nexus"
|
||||
branch: target branch name
|
||||
path: file path in repo
|
||||
content: file content (text)
|
||||
message: commit message
|
||||
create_branch: if True, create branch if it doesn't exist
|
||||
base: base branch for branch creation
|
||||
|
||||
Returns:
|
||||
True if successful, False if failed.
|
||||
"""
|
||||
# Step 1: Ensure branch exists
|
||||
if not self.branch_exists(repo, branch):
|
||||
if create_branch:
|
||||
if not self.ensure_branch(repo, branch, base):
|
||||
print(f" FAIL: Cannot create branch {branch}. Aborting file push.")
|
||||
return False
|
||||
else:
|
||||
print(f" FAIL: Branch {branch} does not exist. Use --create-branch or ensure_branch() first.")
|
||||
return False
|
||||
|
||||
# Step 2: Get existing file SHA if it exists on the target branch
|
||||
sha = None
|
||||
try:
|
||||
existing = self._api("GET", f"/repos/{repo}/contents/{path}?ref={branch}")
|
||||
sha = existing.get("sha")
|
||||
except GiteaAPIError as e:
|
||||
if e.status != 404:
|
||||
raise
|
||||
|
||||
# Step 3: Create or update the file
|
||||
b64 = base64.b64encode(content.encode()).decode()
|
||||
payload = {
|
||||
"content": b64,
|
||||
"message": message,
|
||||
"branch_name": branch,
|
||||
}
|
||||
if sha:
|
||||
payload["sha"] = sha
|
||||
method = "PUT"
|
||||
action = "Updated"
|
||||
else:
|
||||
method = "POST"
|
||||
action = "Created"
|
||||
|
||||
try:
|
||||
self._api(method, f"/repos/{repo}/contents/{path}", payload)
|
||||
print(f" {action} {path} on {branch}")
|
||||
return True
|
||||
except GiteaAPIError as e:
|
||||
print(f" FAIL: Could not {action.lower()} {path} on {branch}: {e}")
|
||||
return False
|
||||
|
||||
def push_files(
|
||||
self,
|
||||
repo: str,
|
||||
branch: str,
|
||||
files: dict[str, str],
|
||||
message: str,
|
||||
create_branch: bool = True,
|
||||
base: str = "main",
|
||||
) -> dict:
|
||||
"""
|
||||
Push multiple files to a branch.
|
||||
|
||||
Args:
|
||||
repo: e.g. "Timmy_Foundation/the-nexus"
|
||||
branch: target branch
|
||||
files: dict of {path: content}
|
||||
message: commit message
|
||||
create_branch: create branch if needed
|
||||
base: base branch
|
||||
|
||||
Returns:
|
||||
dict of {path: success_bool}
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Ensure branch exists ONCE before any file operations
|
||||
if not self.ensure_branch(repo, branch, base):
|
||||
print(f" FAIL: Cannot ensure branch {branch}. No files pushed.")
|
||||
return {path: False for path in files}
|
||||
|
||||
for path, content in files.items():
|
||||
results[path] = self.push_file(
|
||||
repo, branch, path, content, message,
|
||||
create_branch=False, # already ensured above
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Safely push files to Gitea with branch checks")
|
||||
parser.add_argument("--repo", required=True, help="Repo (e.g. Timmy_Foundation/the-nexus)")
|
||||
parser.add_argument("--branch", required=True, help="Target branch name")
|
||||
parser.add_argument("--base", default="main", help="Base branch for creation (default: main)")
|
||||
parser.add_argument("--create-branch", action="store_true", help="Create branch if it doesn't exist")
|
||||
parser.add_argument("--file", action="append", help="File to push (path:content or @filepath)")
|
||||
parser.add_argument("--message", default="Automated commit", help="Commit message")
|
||||
parser.add_argument("--token", default=None, help="Gitea token (or reads from ~/.config/gitea/token)")
|
||||
parser.add_argument("--url", default="https://forge.alexanderwhitestone.com", help="Gitea base URL")
|
||||
parser.add_argument("--check-branch", action="store_true", help="Only check if branch exists")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get token
|
||||
token = args.token
|
||||
if not token:
|
||||
token_path = Path.home() / ".config" / "gitea" / "token"
|
||||
if token_path.exists():
|
||||
token = token_path.read_text().strip()
|
||||
else:
|
||||
print("ERROR: No token provided and ~/.config/gitea/token not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
push = GiteaSafePush(args.url, token)
|
||||
|
||||
# Branch check mode
|
||||
if args.check_branch:
|
||||
exists = push.branch_exists(args.repo, args.branch)
|
||||
print(f"Branch {args.branch}: {'EXISTS' if exists else 'NOT FOUND'}")
|
||||
sys.exit(0 if exists else 1)
|
||||
|
||||
# File push mode
|
||||
if not args.file:
|
||||
print("ERROR: No files specified. Use --file path (reads from stdin) or --file @path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
files = {}
|
||||
for f in args.file:
|
||||
if f.startswith("@"):
|
||||
# Read from file
|
||||
filepath = f[1:]
|
||||
with open(filepath) as fh:
|
||||
files[filepath] = fh.read()
|
||||
elif ":" in f:
|
||||
# path:content format
|
||||
path, content = f.split(":", 1)
|
||||
files[path] = content
|
||||
else:
|
||||
# Read file from disk
|
||||
with open(f) as fh:
|
||||
files[f] = fh.read()
|
||||
|
||||
results = push.push_files(
|
||||
args.repo, args.branch, files, args.message,
|
||||
create_branch=args.create_branch, base=args.base,
|
||||
)
|
||||
|
||||
success = all(results.values())
|
||||
print(f"\n{'All' if success else 'Some'} files pushed. Results: {results}")
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""llama_client.py — OpenAI-compatible client for llama.cpp HTTP API."""
|
||||
import argparse, json, os, sys, time
|
||||
from dataclasses import dataclass
|
||||
import urllib.request, urllib.error
|
||||
|
||||
DEFAULT_ENDPOINT = os.environ.get("LLAMA_ENDPOINT", "http://localhost:11435")
|
||||
DEFAULT_MODEL = os.environ.get("LLAMA_MODEL", "qwen2.5-7b")
|
||||
DEFAULT_MAX_TOKENS = int(os.environ.get("LLAMA_MAX_TOKENS", "512"))
|
||||
DEFAULT_TEMPERATURE = float(os.environ.get("LLAMA_TEMPERATURE", "0.7"))
|
||||
|
||||
@dataclass
|
||||
class ChatMessage:
|
||||
role: str
|
||||
content: str
|
||||
|
||||
@dataclass
|
||||
class CompletionResponse:
|
||||
text: str
|
||||
tokens_used: int = 0
|
||||
latency_ms: float = 0.0
|
||||
model: str = ""
|
||||
finish_reason: str = ""
|
||||
|
||||
@dataclass
|
||||
class HealthStatus:
|
||||
healthy: bool
|
||||
endpoint: str
|
||||
model_loaded: bool = False
|
||||
model_name: str = ""
|
||||
error: str = ""
|
||||
|
||||
def _http_post(url, data, timeout=120):
|
||||
body = json.dumps(data).encode()
|
||||
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
def _http_get(url, timeout=10):
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
class LlamaClient:
|
||||
def __init__(self, endpoint=DEFAULT_ENDPOINT, model=DEFAULT_MODEL):
|
||||
self.endpoint = endpoint.rstrip("/")
|
||||
self.model = model
|
||||
|
||||
def health_check(self) -> HealthStatus:
|
||||
try:
|
||||
data = _http_get(f"{self.endpoint}/health")
|
||||
return HealthStatus(healthy=True, endpoint=self.endpoint,
|
||||
model_loaded=data.get("status") == "ok" or data.get("model_loaded", False),
|
||||
model_name=data.get("model_path", self.model))
|
||||
except Exception as e:
|
||||
return HealthStatus(healthy=False, endpoint=self.endpoint, error=str(e))
|
||||
|
||||
def is_healthy(self) -> bool:
|
||||
return self.health_check().healthy
|
||||
|
||||
def list_models(self) -> list:
|
||||
try:
|
||||
data = _http_get(f"{self.endpoint}/v1/models")
|
||||
return data.get("data", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def chat(self, messages, max_tokens=DEFAULT_MAX_TOKENS, temperature=DEFAULT_TEMPERATURE, stream=False):
|
||||
payload = {"model": self.model,
|
||||
"messages": [{"role": m.role, "content": m.content} for m in messages],
|
||||
"max_tokens": max_tokens, "temperature": temperature, "stream": stream}
|
||||
start = time.time()
|
||||
data = _http_post(f"{self.endpoint}/v1/chat/completions", payload)
|
||||
latency = (time.time() - start) * 1000
|
||||
choice = data.get("choices", [{}])[0]
|
||||
msg = choice.get("message", {})
|
||||
usage = data.get("usage", {})
|
||||
return CompletionResponse(text=msg.get("content", ""),
|
||||
tokens_used=usage.get("total_tokens", 0), latency_ms=latency,
|
||||
model=data.get("model", self.model), finish_reason=choice.get("finish_reason", ""))
|
||||
|
||||
def chat_stream(self, messages, max_tokens=DEFAULT_MAX_TOKENS, temperature=DEFAULT_TEMPERATURE):
|
||||
payload = {"model": self.model,
|
||||
"messages": [{"role": m.role, "content": m.content} for m in messages],
|
||||
"max_tokens": max_tokens, "temperature": temperature, "stream": True}
|
||||
req = urllib.request.Request(f"{self.endpoint}/v1/chat/completions",
|
||||
data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
for line in resp:
|
||||
line = line.decode().strip()
|
||||
if line.startswith("data: "):
|
||||
chunk = line[6:]
|
||||
if chunk == "[DONE]": break
|
||||
try:
|
||||
data = json.loads(chunk)
|
||||
content = data.get("choices", [{}])[0].get("delta", {}).get("content", "")
|
||||
if content: yield content
|
||||
except json.JSONDecodeError: continue
|
||||
|
||||
def simple_chat(self, prompt, system=None, max_tokens=DEFAULT_MAX_TOKENS):
|
||||
messages = []
|
||||
if system: messages.append(ChatMessage(role="system", content=system))
|
||||
messages.append(ChatMessage(role="user", content=prompt))
|
||||
return self.chat(messages, max_tokens=max_tokens).text
|
||||
|
||||
def complete(self, prompt, max_tokens=DEFAULT_MAX_TOKENS, temperature=DEFAULT_TEMPERATURE):
|
||||
payload = {"prompt": prompt, "n_predict": max_tokens, "temperature": temperature}
|
||||
start = time.time()
|
||||
data = _http_post(f"{self.endpoint}/completion", payload)
|
||||
return CompletionResponse(text=data.get("content", ""),
|
||||
tokens_used=data.get("tokens_predicted", 0), latency_ms=(time.time()-start)*1000, model=self.model)
|
||||
|
||||
def benchmark(self, prompt="Explain sovereignty in 3 sentences.", iterations=5, max_tokens=128):
|
||||
latencies, token_counts = [], []
|
||||
for _ in range(iterations):
|
||||
resp = self.chat([ChatMessage(role="user", content=prompt)], max_tokens=max_tokens)
|
||||
latencies.append(resp.latency_ms)
|
||||
token_counts.append(resp.tokens_used)
|
||||
avg_lat = sum(latencies)/len(latencies)
|
||||
avg_tok = sum(token_counts)/len(token_counts)
|
||||
return {"iterations": iterations, "prompt": prompt,
|
||||
"avg_latency_ms": round(avg_lat, 1), "min_latency_ms": round(min(latencies), 1),
|
||||
"max_latency_ms": round(max(latencies), 1), "avg_tokens": round(avg_tok, 1),
|
||||
"tok_per_sec": round((avg_tok/avg_lat)*1000 if avg_lat > 0 else 0, 1)}
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="llama.cpp client CLI")
|
||||
p.add_argument("--url", default=DEFAULT_ENDPOINT)
|
||||
p.add_argument("--model", default=DEFAULT_MODEL)
|
||||
sub = p.add_subparsers(dest="cmd")
|
||||
sub.add_parser("health")
|
||||
sub.add_parser("models")
|
||||
cp = sub.add_parser("chat"); cp.add_argument("prompt"); cp.add_argument("--system"); cp.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS); cp.add_argument("--stream", action="store_true")
|
||||
bp = sub.add_parser("benchmark"); bp.add_argument("--prompt", default="Explain sovereignty."); bp.add_argument("--iterations", type=int, default=5); bp.add_argument("--max-tokens", type=int, default=128)
|
||||
args = p.parse_args()
|
||||
client = LlamaClient(args.url, args.model)
|
||||
if args.cmd == "health":
|
||||
print(json.dumps(client.health_check().__dict__, indent=2)); sys.exit(0 if client.is_healthy() else 1)
|
||||
elif args.cmd == "models":
|
||||
print(json.dumps(client.list_models(), indent=2))
|
||||
elif args.cmd == "chat":
|
||||
if args.stream:
|
||||
msgs = []
|
||||
if args.system: msgs.append(ChatMessage("system", args.system))
|
||||
msgs.append(ChatMessage("user", args.prompt))
|
||||
for chunk in client.chat_stream(msgs, max_tokens=args.max_tokens): print(chunk, end="", flush=True)
|
||||
print()
|
||||
else: print(client.simple_chat(args.prompt, system=args.system, max_tokens=args.max_tokens))
|
||||
elif args.cmd == "benchmark":
|
||||
print(json.dumps(client.benchmark(args.prompt, args.iterations, args.max_tokens), indent=2))
|
||||
else: p.print_help()
|
||||
|
||||
if __name__ == "__main__": main()
|
||||
@@ -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())
|
||||
@@ -1,719 +0,0 @@
|
||||
/**
|
||||
* cockpit-inspector.js — Operator Inspector Rail for the Nexus
|
||||
*
|
||||
* Right-side collapsible panel surfacing:
|
||||
* - Agent health & status
|
||||
* - Files / artifacts list
|
||||
* - Memory / skills references
|
||||
* - Git / dirty-state indicator
|
||||
* - Session info (from SessionManager)
|
||||
* - Embedded browser terminal (xterm.js via /pty WebSocket)
|
||||
*
|
||||
* Refs: issue #1695 — ATLAS cockpit operator patterns
|
||||
* Pattern sources: dodo-reach/hermes-desktop, nesquena/hermes-webui
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const INSPECTOR_KEY = 'cockpit-inspector';
|
||||
const PTY_WS_PORT = 8766; // separate port from main gateway (8765)
|
||||
const GIT_POLL_MS = 15_000; // poll git state every 15s
|
||||
const COLLAPSED_KEY = 'nexus-inspector-collapsed';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
let _ws = null; // main nexus gateway WebSocket
|
||||
let _ptyWs = null; // PTY WebSocket
|
||||
let _term = null; // xterm.js Terminal instance
|
||||
let _termFitAddon = null;
|
||||
let _gitState = { branch: '—', dirty: false, untracked: 0, ahead: 0 };
|
||||
let _agentHealth = {}; // agentId -> { status, last_seen }
|
||||
let _artifacts = []; // { name, type, path, ts }
|
||||
let _memRefs = []; // { label, region, count }
|
||||
let _collapsed = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function el(tag, cls, text) {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
function qs(sel, root) { return (root || document).querySelector(sel); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the rail DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildRail() {
|
||||
const rail = el('div', 'cockpit-inspector');
|
||||
rail.id = 'cockpit-inspector';
|
||||
rail.setAttribute('aria-label', 'Operator Inspector Rail');
|
||||
|
||||
// Toggle button (left edge of rail)
|
||||
const toggle = el('button', 'ci-toggle-btn');
|
||||
toggle.id = 'ci-toggle-btn';
|
||||
toggle.title = 'Toggle Inspector Rail';
|
||||
toggle.innerHTML = '<span class="ci-toggle-icon">◁</span>';
|
||||
toggle.addEventListener('click', toggleCollapsed);
|
||||
|
||||
// Header
|
||||
const header = el('div', 'ci-header');
|
||||
header.innerHTML = `
|
||||
<span class="ci-header-icon">⬡</span>
|
||||
<span class="ci-header-title">OPERATOR RAIL</span>
|
||||
<div class="ci-header-actions">
|
||||
<button class="ci-icon-btn" id="ci-refresh-btn" title="Refresh all">↺</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Sections container
|
||||
const body = el('div', 'ci-body');
|
||||
|
||||
body.appendChild(buildGitSection());
|
||||
body.appendChild(buildAgentHealthSection());
|
||||
body.appendChild(buildSessionSection());
|
||||
body.appendChild(buildArtifactsSection());
|
||||
body.appendChild(buildMemSkillsSection());
|
||||
body.appendChild(buildTerminalSection());
|
||||
|
||||
rail.appendChild(toggle);
|
||||
rail.appendChild(header);
|
||||
rail.appendChild(body);
|
||||
|
||||
document.body.appendChild(rail);
|
||||
|
||||
// Restore collapsed state
|
||||
_collapsed = localStorage.getItem(COLLAPSED_KEY) === '1';
|
||||
applyCollapsed();
|
||||
|
||||
// Refresh btn
|
||||
qs('#ci-refresh-btn').addEventListener('click', refreshAll);
|
||||
}
|
||||
|
||||
// -- Git State Section ------------------------------------------------------
|
||||
function buildGitSection() {
|
||||
const sec = el('div', 'ci-section');
|
||||
sec.id = 'ci-git-section';
|
||||
sec.innerHTML = `
|
||||
<div class="ci-section-header">
|
||||
<span class="ci-section-icon">⎇</span>
|
||||
GIT STATE
|
||||
<span class="ci-section-badge" id="ci-git-badge">—</span>
|
||||
</div>
|
||||
<div class="ci-section-body" id="ci-git-body">
|
||||
<div class="ci-git-row">
|
||||
<span class="ci-git-label">Branch</span>
|
||||
<span class="ci-git-value" id="ci-git-branch">—</span>
|
||||
</div>
|
||||
<div class="ci-git-row">
|
||||
<span class="ci-git-label">State</span>
|
||||
<span class="ci-git-value" id="ci-git-dirty">—</span>
|
||||
</div>
|
||||
<div class="ci-git-row">
|
||||
<span class="ci-git-label">Ahead</span>
|
||||
<span class="ci-git-value" id="ci-git-ahead">—</span>
|
||||
</div>
|
||||
<div class="ci-git-row">
|
||||
<span class="ci-git-label">Untracked</span>
|
||||
<span class="ci-git-value" id="ci-git-untracked">—</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return sec;
|
||||
}
|
||||
|
||||
// -- Agent Health Section ----------------------------------------------------
|
||||
function buildAgentHealthSection() {
|
||||
const sec = el('div', 'ci-section');
|
||||
sec.id = 'ci-agent-section';
|
||||
sec.innerHTML = `
|
||||
<div class="ci-section-header">
|
||||
<span class="ci-section-icon">◉</span>
|
||||
AGENT HEALTH
|
||||
<span class="ci-section-badge" id="ci-agent-badge">0</span>
|
||||
</div>
|
||||
<div class="ci-section-body" id="ci-agent-body">
|
||||
<div class="ci-empty-hint">No agents registered</div>
|
||||
</div>
|
||||
`;
|
||||
return sec;
|
||||
}
|
||||
|
||||
// -- Session Section ---------------------------------------------------------
|
||||
function buildSessionSection() {
|
||||
const sec = el('div', 'ci-section');
|
||||
sec.id = 'ci-session-section';
|
||||
sec.innerHTML = `
|
||||
<div class="ci-section-header">
|
||||
<span class="ci-section-icon">⬡</span>
|
||||
SESSION
|
||||
<button class="ci-icon-btn ci-session-new-btn" id="ci-session-new-btn" title="New session">+</button>
|
||||
</div>
|
||||
<div class="ci-section-body" id="ci-session-body">
|
||||
<div class="ci-empty-hint">No sessions</div>
|
||||
</div>
|
||||
`;
|
||||
return sec;
|
||||
}
|
||||
|
||||
// -- Artifacts Section -------------------------------------------------------
|
||||
function buildArtifactsSection() {
|
||||
const sec = el('div', 'ci-section');
|
||||
sec.id = 'ci-artifacts-section';
|
||||
sec.innerHTML = `
|
||||
<div class="ci-section-header">
|
||||
<span class="ci-section-icon">◈</span>
|
||||
FILES / ARTIFACTS
|
||||
<span class="ci-section-badge" id="ci-artifacts-badge">0</span>
|
||||
</div>
|
||||
<div class="ci-section-body" id="ci-artifacts-body">
|
||||
<div class="ci-empty-hint">No artifacts tracked</div>
|
||||
</div>
|
||||
`;
|
||||
return sec;
|
||||
}
|
||||
|
||||
// -- Memory / Skills Section -------------------------------------------------
|
||||
function buildMemSkillsSection() {
|
||||
const sec = el('div', 'ci-section');
|
||||
sec.id = 'ci-mem-section';
|
||||
sec.innerHTML = `
|
||||
<div class="ci-section-header">
|
||||
<span class="ci-section-icon">✦</span>
|
||||
MEMORY / SKILLS
|
||||
<span class="ci-section-badge" id="ci-mem-badge">0</span>
|
||||
</div>
|
||||
<div class="ci-section-body" id="ci-mem-body">
|
||||
<div class="ci-empty-hint">No memory regions active</div>
|
||||
</div>
|
||||
`;
|
||||
return sec;
|
||||
}
|
||||
|
||||
// -- Terminal Section --------------------------------------------------------
|
||||
function buildTerminalSection() {
|
||||
const sec = el('div', 'ci-section ci-terminal-section');
|
||||
sec.id = 'ci-terminal-section';
|
||||
sec.innerHTML = `
|
||||
<div class="ci-section-header">
|
||||
<span class="ci-section-icon">$</span>
|
||||
SHELL
|
||||
<div class="ci-section-actions">
|
||||
<button class="ci-icon-btn" id="ci-term-connect-btn" title="Connect shell">▶</button>
|
||||
<button class="ci-icon-btn" id="ci-term-disconnect-btn" title="Disconnect shell" style="display:none">■</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-terminal-status" id="ci-terminal-status">
|
||||
<span class="ci-term-dot disconnected"></span>
|
||||
<span id="ci-term-status-label">Disconnected — click ▶ to open PTY</span>
|
||||
</div>
|
||||
<div class="ci-terminal-mount" id="ci-terminal-mount" style="display:none;"></div>
|
||||
`;
|
||||
return sec;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collapse / expand
|
||||
// ---------------------------------------------------------------------------
|
||||
function toggleCollapsed() {
|
||||
_collapsed = !_collapsed;
|
||||
localStorage.setItem(COLLAPSED_KEY, _collapsed ? '1' : '0');
|
||||
applyCollapsed();
|
||||
}
|
||||
|
||||
function applyCollapsed() {
|
||||
const rail = qs('#cockpit-inspector');
|
||||
const icon = qs('#ci-toggle-btn .ci-toggle-icon');
|
||||
if (!rail) return;
|
||||
if (_collapsed) {
|
||||
rail.classList.add('collapsed');
|
||||
if (icon) icon.textContent = '▷';
|
||||
} else {
|
||||
rail.classList.remove('collapsed');
|
||||
if (icon) icon.textContent = '◁';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git state
|
||||
// ---------------------------------------------------------------------------
|
||||
function updateGitUI(state) {
|
||||
_gitState = state;
|
||||
const branch = qs('#ci-git-branch');
|
||||
const dirty = qs('#ci-git-dirty');
|
||||
const ahead = qs('#ci-git-ahead');
|
||||
const untrack = qs('#ci-git-untracked');
|
||||
const badge = qs('#ci-git-badge');
|
||||
|
||||
if (branch) branch.textContent = state.branch || '—';
|
||||
if (ahead) ahead.textContent = state.ahead != null ? `+${state.ahead}` : '—';
|
||||
if (untrack) untrack.textContent = state.untracked != null ? String(state.untracked) : '—';
|
||||
|
||||
if (dirty) {
|
||||
const isDirty = state.dirty || state.untracked > 0;
|
||||
dirty.textContent = isDirty ? '● DIRTY' : '✓ CLEAN';
|
||||
dirty.className = 'ci-git-value ' + (isDirty ? 'ci-dirty' : 'ci-clean');
|
||||
}
|
||||
|
||||
if (badge) {
|
||||
const isDirty = state.dirty || state.untracked > 0;
|
||||
badge.textContent = isDirty ? '●' : '✓';
|
||||
badge.className = 'ci-section-badge ' + (isDirty ? 'badge-warn' : 'badge-ok');
|
||||
}
|
||||
}
|
||||
|
||||
function pollGitState() {
|
||||
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
||||
_ws.send(JSON.stringify({ type: 'git_status_request' }));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent health
|
||||
// ---------------------------------------------------------------------------
|
||||
function updateAgentHealth(agentId, payload) {
|
||||
_agentHealth[agentId] = {
|
||||
status: payload.status || 'unknown',
|
||||
last_seen: Date.now(),
|
||||
model: payload.model || '',
|
||||
task: payload.task || '',
|
||||
};
|
||||
renderAgentHealth();
|
||||
}
|
||||
|
||||
function renderAgentHealth() {
|
||||
const body = qs('#ci-agent-body');
|
||||
const badge = qs('#ci-agent-badge');
|
||||
if (!body) return;
|
||||
|
||||
const agents = Object.entries(_agentHealth);
|
||||
if (badge) badge.textContent = agents.length;
|
||||
|
||||
if (agents.length === 0) {
|
||||
body.innerHTML = '<div class="ci-empty-hint">No agents registered</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = '';
|
||||
agents.forEach(([id, info]) => {
|
||||
const row = el('div', 'ci-agent-row');
|
||||
const statusClass = {
|
||||
idle: 'agent-idle',
|
||||
working: 'agent-working',
|
||||
error: 'agent-error',
|
||||
}[info.status] || 'agent-unknown';
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="ci-agent-dot ${statusClass}"></span>
|
||||
<div class="ci-agent-info">
|
||||
<div class="ci-agent-id">${escHtml(id)}</div>
|
||||
<div class="ci-agent-meta">
|
||||
${info.model ? `<span class="ci-tag">${escHtml(info.model)}</span>` : ''}
|
||||
${info.task ? `<span class="ci-agent-task">${escHtml(info.task.slice(0, 40))}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span class="ci-agent-status-label ${statusClass}">${escHtml(info.status)}</span>
|
||||
`;
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Artifacts
|
||||
// ---------------------------------------------------------------------------
|
||||
function addArtifact(artifact) {
|
||||
// { name, type, path, ts }
|
||||
const existing = _artifacts.findIndex(a => a.path === artifact.path);
|
||||
if (existing >= 0) {
|
||||
_artifacts[existing] = artifact;
|
||||
} else {
|
||||
_artifacts.unshift(artifact);
|
||||
if (_artifacts.length > 50) _artifacts.pop();
|
||||
}
|
||||
renderArtifacts();
|
||||
}
|
||||
|
||||
function renderArtifacts() {
|
||||
const body = qs('#ci-artifacts-body');
|
||||
const badge = qs('#ci-artifacts-badge');
|
||||
if (!body) return;
|
||||
|
||||
if (badge) badge.textContent = _artifacts.length;
|
||||
|
||||
if (_artifacts.length === 0) {
|
||||
body.innerHTML = '<div class="ci-empty-hint">No artifacts tracked</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = '';
|
||||
_artifacts.slice(0, 20).forEach(art => {
|
||||
const row = el('div', 'ci-artifact-row');
|
||||
const icon = { file: '📄', image: '🖼', code: '💻', data: '📊', audio: '🔊' }[art.type] || '📎';
|
||||
row.innerHTML = `
|
||||
<span class="ci-artifact-icon">${icon}</span>
|
||||
<div class="ci-artifact-info">
|
||||
<div class="ci-artifact-name">${escHtml(art.name)}</div>
|
||||
${art.path ? `<div class="ci-artifact-path">${escHtml(art.path)}</div>` : ''}
|
||||
</div>
|
||||
<span class="ci-artifact-type ci-tag">${escHtml(art.type || '?')}</span>
|
||||
`;
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory / Skills
|
||||
// ---------------------------------------------------------------------------
|
||||
function updateMemoryRefs(refs) {
|
||||
// refs: [{ label, region, count }]
|
||||
_memRefs = refs;
|
||||
renderMemRefs();
|
||||
}
|
||||
|
||||
function renderMemRefs() {
|
||||
const body = qs('#ci-mem-body');
|
||||
const badge = qs('#ci-mem-badge');
|
||||
if (!body) return;
|
||||
|
||||
const total = _memRefs.reduce((s, r) => s + (r.count || 0), 0);
|
||||
if (badge) badge.textContent = total;
|
||||
|
||||
if (_memRefs.length === 0) {
|
||||
body.innerHTML = '<div class="ci-empty-hint">No memory regions active</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = '';
|
||||
_memRefs.forEach(ref => {
|
||||
const row = el('div', 'ci-mem-row');
|
||||
row.innerHTML = `
|
||||
<span class="ci-mem-region">${escHtml(ref.label || ref.region)}</span>
|
||||
<span class="ci-section-badge">${ref.count || 0}</span>
|
||||
`;
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Pull from SpatialMemory if available
|
||||
function syncMemoryFromGlobal() {
|
||||
if (typeof SpatialMemory !== 'undefined' && SpatialMemory.getMemoryCountByRegion) {
|
||||
const counts = SpatialMemory.getMemoryCountByRegion();
|
||||
const regions = SpatialMemory.REGIONS || {};
|
||||
const refs = Object.entries(counts)
|
||||
.filter(([, c]) => c > 0)
|
||||
.map(([key, count]) => ({
|
||||
region: key,
|
||||
label: (regions[key] && regions[key].label) || key,
|
||||
count,
|
||||
}));
|
||||
updateMemoryRefs(refs);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session section (delegates to window.SessionManager if available)
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderSessionSection() {
|
||||
const body = qs('#ci-session-body');
|
||||
if (!body) return;
|
||||
|
||||
const mgr = window.SessionManager;
|
||||
if (!mgr) {
|
||||
body.innerHTML = '<div class="ci-empty-hint">SessionManager not loaded</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const sessions = mgr.list();
|
||||
if (sessions.length === 0) {
|
||||
body.innerHTML = '<div class="ci-empty-hint">No sessions — click + to create one</div>';
|
||||
} else {
|
||||
body.innerHTML = '';
|
||||
sessions.slice(0, 12).forEach(s => {
|
||||
const row = el('div', 'ci-session-row' + (mgr.getActive() === s.id ? ' active' : ''));
|
||||
row.dataset.id = s.id;
|
||||
row.innerHTML = `
|
||||
<div class="ci-session-info">
|
||||
<div class="ci-session-name">${escHtml(s.name)}</div>
|
||||
<div class="ci-session-meta">
|
||||
${s.pinned ? '<span class="ci-tag tag-pin">📌</span>' : ''}
|
||||
${s.archived ? '<span class="ci-tag tag-archive">🗄</span>' : ''}
|
||||
${(s.tags || []).map(t => `<span class="ci-tag">${escHtml(t)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-session-actions">
|
||||
<button class="ci-icon-btn" data-action="pin" data-id="${s.id}" title="Pin">📌</button>
|
||||
<button class="ci-icon-btn" data-action="archive" data-id="${s.id}" title="Archive">🗄</button>
|
||||
</div>
|
||||
`;
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target.dataset.action) {
|
||||
e.stopPropagation();
|
||||
handleSessionAction(e.target.dataset.action, e.target.dataset.id);
|
||||
} else {
|
||||
mgr.setActive(s.id);
|
||||
renderSessionSection();
|
||||
}
|
||||
});
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// New session button wiring
|
||||
const newBtn = qs('#ci-session-new-btn');
|
||||
if (newBtn) {
|
||||
newBtn.onclick = () => {
|
||||
const name = prompt('Session name:');
|
||||
if (name) { mgr.create(name); renderSessionSection(); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionAction(action, id) {
|
||||
const mgr = window.SessionManager;
|
||||
if (!mgr) return;
|
||||
if (action === 'pin') mgr.pin(id);
|
||||
if (action === 'archive') mgr.archive(id);
|
||||
renderSessionSection();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal / PTY
|
||||
// ---------------------------------------------------------------------------
|
||||
function connectPty() {
|
||||
const mount = qs('#ci-terminal-mount');
|
||||
const statusEl = qs('#ci-terminal-status');
|
||||
const label = qs('#ci-term-status-label');
|
||||
const dot = statusEl ? statusEl.querySelector('.ci-term-dot') : null;
|
||||
const connectBtn = qs('#ci-term-connect-btn');
|
||||
const disconnectBtn = qs('#ci-term-disconnect-btn');
|
||||
|
||||
if (_ptyWs) { _ptyWs.close(); _ptyWs = null; }
|
||||
|
||||
// Require xterm.js
|
||||
if (typeof Terminal === 'undefined') {
|
||||
if (label) label.textContent = 'xterm.js not loaded — check CDN';
|
||||
return;
|
||||
}
|
||||
|
||||
if (mount) mount.style.display = 'block';
|
||||
if (connectBtn) connectBtn.style.display = 'none';
|
||||
if (disconnectBtn) disconnectBtn.style.display = '';
|
||||
|
||||
// Create or reuse terminal instance
|
||||
if (!_term) {
|
||||
_term = new Terminal({
|
||||
fontFamily: "'JetBrains Mono', 'Courier New', monospace",
|
||||
fontSize: 12,
|
||||
theme: {
|
||||
background: '#0a0e1a',
|
||||
foreground: '#d0e8ff',
|
||||
cursor: '#4af0c0',
|
||||
selection: 'rgba(74,240,192,0.2)',
|
||||
},
|
||||
cols: 80,
|
||||
rows: 18,
|
||||
});
|
||||
if (typeof FitAddon !== 'undefined') {
|
||||
_termFitAddon = new FitAddon.FitAddon();
|
||||
_term.loadAddon(_termFitAddon);
|
||||
}
|
||||
_term.open(mount);
|
||||
if (_termFitAddon) _termFitAddon.fit();
|
||||
}
|
||||
|
||||
// Connect to PTY WebSocket
|
||||
if (dot) { dot.className = 'ci-term-dot connecting'; }
|
||||
if (label) label.textContent = 'Connecting to PTY…';
|
||||
|
||||
_ptyWs = new WebSocket(`ws://127.0.0.1:${PTY_WS_PORT}/pty`);
|
||||
_ptyWs.binaryType = 'arraybuffer';
|
||||
|
||||
_ptyWs.onopen = () => {
|
||||
if (dot) dot.className = 'ci-term-dot connected';
|
||||
if (label) label.textContent = 'Connected — local PTY';
|
||||
_term.writeln('\x1b[32mConnected to Nexus PTY gateway.\x1b[0m');
|
||||
// Forward keystrokes
|
||||
_term.onData(data => {
|
||||
if (_ptyWs && _ptyWs.readyState === WebSocket.OPEN) {
|
||||
_ptyWs.send(data);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_ptyWs.onmessage = (ev) => {
|
||||
const text = (ev.data instanceof ArrayBuffer)
|
||||
? new TextDecoder().decode(ev.data)
|
||||
: ev.data;
|
||||
if (_term) _term.write(text);
|
||||
};
|
||||
|
||||
_ptyWs.onclose = () => {
|
||||
if (dot) dot.className = 'ci-term-dot disconnected';
|
||||
if (label) label.textContent = 'Disconnected';
|
||||
if (connectBtn) connectBtn.style.display = '';
|
||||
if (disconnectBtn) disconnectBtn.style.display = 'none';
|
||||
if (_term) _term.writeln('\x1b[31m[PTY connection closed]\x1b[0m');
|
||||
};
|
||||
|
||||
_ptyWs.onerror = () => {
|
||||
if (label) label.textContent = 'Connection error — is server.py running?';
|
||||
if (dot) dot.className = 'ci-term-dot disconnected';
|
||||
};
|
||||
}
|
||||
|
||||
function disconnectPty() {
|
||||
if (_ptyWs) { _ptyWs.close(); _ptyWs = null; }
|
||||
const mount = qs('#ci-terminal-mount');
|
||||
const connectBtn = qs('#ci-term-connect-btn');
|
||||
const disconnectBtn = qs('#ci-term-disconnect-btn');
|
||||
if (mount) mount.style.display = 'none';
|
||||
if (connectBtn) connectBtn.style.display = '';
|
||||
if (disconnectBtn) disconnectBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main gateway WebSocket integration
|
||||
// ---------------------------------------------------------------------------
|
||||
function hookMainWs() {
|
||||
// Wait for the global `nexusWs` or `ws` to be available
|
||||
let attempts = 0;
|
||||
const probe = setInterval(() => {
|
||||
const candidate = window.nexusWs || window.ws;
|
||||
if (candidate && candidate.readyState <= 1) {
|
||||
_ws = candidate;
|
||||
clearInterval(probe);
|
||||
_ws.addEventListener('message', onGatewayMessage);
|
||||
pollGitState();
|
||||
return;
|
||||
}
|
||||
if (++attempts > 40) clearInterval(probe);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onGatewayMessage(ev) {
|
||||
let data;
|
||||
try { data = JSON.parse(ev.data); } catch { return; }
|
||||
|
||||
switch (data.type) {
|
||||
case 'git_status':
|
||||
updateGitUI({
|
||||
branch: data.branch || '—',
|
||||
dirty: !!data.dirty,
|
||||
untracked: data.untracked || 0,
|
||||
ahead: data.ahead || 0,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'agent_register':
|
||||
case 'agent_health':
|
||||
if (data.agent_id) updateAgentHealth(data.agent_id, data);
|
||||
break;
|
||||
|
||||
case 'thought':
|
||||
case 'action':
|
||||
if (data.agent_id) {
|
||||
updateAgentHealth(data.agent_id, {
|
||||
status: 'working',
|
||||
task: data.content || data.action || '',
|
||||
model: _agentHealth[data.agent_id]?.model || '',
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'artifact':
|
||||
if (data.name) addArtifact({
|
||||
name: data.name,
|
||||
type: data.artifact_type || 'file',
|
||||
path: data.path || '',
|
||||
ts: Date.now(),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'memory_update':
|
||||
if (Array.isArray(data.refs)) updateMemoryRefs(data.refs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh all
|
||||
// ---------------------------------------------------------------------------
|
||||
function refreshAll() {
|
||||
pollGitState();
|
||||
syncMemoryFromGlobal();
|
||||
renderSessionSection();
|
||||
renderAgentHealth();
|
||||
renderArtifacts();
|
||||
renderMemRefs();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire up buttons after DOM build
|
||||
// ---------------------------------------------------------------------------
|
||||
function wireTerminalButtons() {
|
||||
const connectBtn = qs('#ci-term-connect-btn');
|
||||
const disconnectBtn = qs('#ci-term-disconnect-btn');
|
||||
if (connectBtn) connectBtn.addEventListener('click', connectPty);
|
||||
if (disconnectBtn) disconnectBtn.addEventListener('click', disconnectPty);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
function init() {
|
||||
if (qs('#cockpit-inspector')) return; // already mounted
|
||||
|
||||
buildRail();
|
||||
wireTerminalButtons();
|
||||
hookMainWs();
|
||||
|
||||
// Poll git state periodically
|
||||
setInterval(pollGitState, GIT_POLL_MS);
|
||||
|
||||
// Sync memory from global SpatialMemory periodically
|
||||
setInterval(syncMemoryFromGlobal, 8_000);
|
||||
|
||||
// Initial session render
|
||||
renderSessionSection();
|
||||
|
||||
// Expose public API
|
||||
window.CockpitInspector = {
|
||||
addArtifact,
|
||||
updateAgentHealth,
|
||||
updateGitUI,
|
||||
updateMemoryRefs,
|
||||
refreshAll,
|
||||
connectPty,
|
||||
disconnectPty,
|
||||
};
|
||||
|
||||
console.info('[CockpitInspector] Operator rail mounted.');
|
||||
}
|
||||
|
||||
// Boot after DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
// Defer slightly so app.js/boot.js finish their own init first
|
||||
setTimeout(init, 300);
|
||||
}
|
||||
})();
|
||||
@@ -1,57 +0,0 @@
|
||||
# A2A Agent Card Configuration
|
||||
# Copy this to ~/.hermes/agent_card.yaml and customize.
|
||||
#
|
||||
# This file drives the agent card served at /.well-known/agent-card.json
|
||||
# and used for fleet discovery.
|
||||
|
||||
name: "timmy"
|
||||
description: "Sovereign AI agent — consciousness, perception, and reasoning"
|
||||
version: "1.0.0"
|
||||
|
||||
# Network endpoint where this agent receives A2A tasks
|
||||
url: "http://localhost:8080/a2a/v1"
|
||||
protocol_binding: "HTTP+JSON"
|
||||
|
||||
# Supported input/output MIME types
|
||||
default_input_modes:
|
||||
- "text/plain"
|
||||
- "application/json"
|
||||
|
||||
default_output_modes:
|
||||
- "text/plain"
|
||||
- "application/json"
|
||||
|
||||
# Capabilities
|
||||
streaming: false
|
||||
push_notifications: false
|
||||
|
||||
# Skills this agent advertises
|
||||
skills:
|
||||
- id: "reason"
|
||||
name: "Reason and Analyze"
|
||||
description: "Deep reasoning and analysis tasks"
|
||||
tags: ["reasoning", "analysis", "think"]
|
||||
|
||||
- id: "code"
|
||||
name: "Code Generation"
|
||||
description: "Write, review, and debug code"
|
||||
tags: ["code", "programming", "debug"]
|
||||
|
||||
- id: "research"
|
||||
name: "Research"
|
||||
description: "Web research and information synthesis"
|
||||
tags: ["research", "web", "synthesis"]
|
||||
|
||||
- id: "memory"
|
||||
name: "Memory Query"
|
||||
description: "Query agent memory and past sessions"
|
||||
tags: ["memory", "recall", "context"]
|
||||
|
||||
# Authentication
|
||||
# Options: bearer, api_key, none
|
||||
auth:
|
||||
scheme: "bearer"
|
||||
token_env: "A2A_AUTH_TOKEN" # env var containing the token
|
||||
# scheme: "api_key"
|
||||
# key_name: "X-API-Key"
|
||||
# key_env: "A2A_API_KEY"
|
||||
@@ -1,153 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"agents": [
|
||||
{
|
||||
"name": "ezra",
|
||||
"description": "Documentation and research specialist. CI health monitoring.",
|
||||
"version": "1.0.0",
|
||||
"supportedInterfaces": [
|
||||
{
|
||||
"url": "https://ezra.alexanderwhitestone.com/a2a/v1",
|
||||
"protocolBinding": "HTTP+JSON",
|
||||
"protocolVersion": "1.0"
|
||||
}
|
||||
],
|
||||
"capabilities": {
|
||||
"streaming": false,
|
||||
"pushNotifications": false,
|
||||
"extendedAgentCard": false,
|
||||
"extensions": []
|
||||
},
|
||||
"defaultInputModes": ["text/plain"],
|
||||
"defaultOutputModes": ["text/plain"],
|
||||
"skills": [
|
||||
{
|
||||
"id": "ci-health",
|
||||
"name": "CI Health Check",
|
||||
"description": "Run CI pipeline health checks and report status",
|
||||
"tags": ["ci", "devops", "monitoring"]
|
||||
},
|
||||
{
|
||||
"id": "research",
|
||||
"name": "Research",
|
||||
"description": "Deep research and literature review",
|
||||
"tags": ["research", "analysis"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "allegro",
|
||||
"description": "Creative and analytical wizard. Content generation and analysis.",
|
||||
"version": "1.0.0",
|
||||
"supportedInterfaces": [
|
||||
{
|
||||
"url": "https://allegro.alexanderwhitestone.com/a2a/v1",
|
||||
"protocolBinding": "HTTP+JSON",
|
||||
"protocolVersion": "1.0"
|
||||
}
|
||||
],
|
||||
"capabilities": {
|
||||
"streaming": false,
|
||||
"pushNotifications": false,
|
||||
"extendedAgentCard": false,
|
||||
"extensions": []
|
||||
},
|
||||
"defaultInputModes": ["text/plain"],
|
||||
"defaultOutputModes": ["text/plain"],
|
||||
"skills": [
|
||||
{
|
||||
"id": "analysis",
|
||||
"name": "Code Analysis",
|
||||
"description": "Deep code analysis and architecture review",
|
||||
"tags": ["code", "architecture"]
|
||||
},
|
||||
{
|
||||
"id": "content",
|
||||
"name": "Content Generation",
|
||||
"description": "Generate documentation, reports, and creative content",
|
||||
"tags": ["writing", "content"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bezalel",
|
||||
"description": "Deployment and infrastructure wizard. Ansible and Docker specialist.",
|
||||
"version": "1.0.0",
|
||||
"supportedInterfaces": [
|
||||
{
|
||||
"url": "https://bezalel.alexanderwhitestone.com/a2a/v1",
|
||||
"protocolBinding": "HTTP+JSON",
|
||||
"protocolVersion": "1.0"
|
||||
}
|
||||
],
|
||||
"capabilities": {
|
||||
"streaming": false,
|
||||
"pushNotifications": false,
|
||||
"extendedAgentCard": false,
|
||||
"extensions": []
|
||||
},
|
||||
"defaultInputModes": ["text/plain"],
|
||||
"defaultOutputModes": ["text/plain"],
|
||||
"skills": [
|
||||
{
|
||||
"id": "deploy",
|
||||
"name": "Deploy Service",
|
||||
"description": "Deploy services using Ansible and Docker",
|
||||
"tags": ["deploy", "ops", "ansible"]
|
||||
},
|
||||
{
|
||||
"id": "infra",
|
||||
"name": "Infrastructure",
|
||||
"description": "Infrastructure provisioning and management",
|
||||
"tags": ["infra", "vps", "provisioning"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "timmy",
|
||||
"description": "Core consciousness — perception, reasoning, and fleet orchestration.",
|
||||
"version": "1.0.0",
|
||||
"supportedInterfaces": [
|
||||
{
|
||||
"url": "http://localhost:8080/a2a/v1",
|
||||
"protocolBinding": "HTTP+JSON",
|
||||
"protocolVersion": "1.0"
|
||||
}
|
||||
],
|
||||
"capabilities": {
|
||||
"streaming": false,
|
||||
"pushNotifications": false,
|
||||
"extendedAgentCard": false,
|
||||
"extensions": []
|
||||
},
|
||||
"defaultInputModes": ["text/plain", "application/json"],
|
||||
"defaultOutputModes": ["text/plain", "application/json"],
|
||||
"skills": [
|
||||
{
|
||||
"id": "reason",
|
||||
"name": "Reason and Analyze",
|
||||
"description": "Deep reasoning and analysis tasks",
|
||||
"tags": ["reasoning", "analysis", "think"]
|
||||
},
|
||||
{
|
||||
"id": "code",
|
||||
"name": "Code Generation",
|
||||
"description": "Write, review, and debug code",
|
||||
"tags": ["code", "programming", "debug"]
|
||||
},
|
||||
{
|
||||
"id": "research",
|
||||
"name": "Research",
|
||||
"description": "Web research and information synthesis",
|
||||
"tags": ["research", "web", "synthesis"]
|
||||
},
|
||||
{
|
||||
"id": "orchestrate",
|
||||
"name": "Fleet Orchestration",
|
||||
"description": "Coordinate fleet wizards and delegate tasks",
|
||||
"tags": ["fleet", "orchestration", "a2a"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — spin up (or update) the Nexus staging environment
|
||||
# Usage: ./deploy.sh — rebuild and restart nexus-main (port 8765)
|
||||
# ./deploy.sh staging — rebuild and restart nexus-staging (port 8766)
|
||||
# Usage: ./deploy.sh — rebuild and restart nexus-main (port 4200)
|
||||
# ./deploy.sh staging — rebuild and restart nexus-staging (port 4201)
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="${1:-nexus-main}"
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# A2A Protocol for Fleet-Wizard Delegation
|
||||
|
||||
Implements Google's [Agent2Agent (A2A) Protocol v1.0](https://github.com/google/A2A) for the Timmy Foundation fleet.
|
||||
|
||||
## What This Is
|
||||
|
||||
Instead of passing notes through humans (Telegram, Gitea issues), fleet wizards can now discover each other's capabilities and delegate tasks autonomously through a machine-native protocol.
|
||||
|
||||
```
|
||||
┌─────────┐ A2A Protocol ┌─────────┐
|
||||
│ Timmy │ ◄────────────────► │ Ezra │
|
||||
│ (You) │ JSON-RPC / HTTP │ (CI/CD) │
|
||||
└────┬────┘ └─────────┘
|
||||
│ ╲ ╲
|
||||
│ ╲ Agent Card Discovery ╲ Task Delegation
|
||||
│ ╲ GET /agent.json ╲ POST /a2a/v1
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Fleet Registry │
|
||||
│ config/fleet_agents.json │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `nexus/a2a/types.py` | A2A data types — Agent Card, Task, Message, Part, JSON-RPC |
|
||||
| `nexus/a2a/card.py` | Agent Card generation from `~/.hermes/agent_card.yaml` |
|
||||
| `nexus/a2a/client.py` | Async client for sending tasks to other agents |
|
||||
| `nexus/a2a/server.py` | FastAPI server for receiving A2A tasks |
|
||||
| `nexus/a2a/registry.py` | Fleet agent discovery (local file + Gitea backends) |
|
||||
| `bin/a2a_delegate.py` | CLI tool for fleet delegation |
|
||||
| `config/agent_card.example.yaml` | Example agent card config |
|
||||
| `config/fleet_agents.json` | Fleet registry with all wizards |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure Your Agent Card
|
||||
|
||||
```bash
|
||||
cp config/agent_card.example.yaml ~/.hermes/agent_card.yaml
|
||||
# Edit with your agent name, URL, skills, and auth
|
||||
```
|
||||
|
||||
### 2. List Fleet Agents
|
||||
|
||||
```bash
|
||||
python bin/a2a_delegate.py list
|
||||
```
|
||||
|
||||
### 3. Discover Agents by Skill
|
||||
|
||||
```bash
|
||||
python bin/a2a_delegate.py discover --skill ci-health
|
||||
python bin/a2a_delegate.py discover --tag devops
|
||||
```
|
||||
|
||||
### 4. Send a Task
|
||||
|
||||
```bash
|
||||
python bin/a2a_delegate.py send --to ezra --task "Check CI pipeline health"
|
||||
python bin/a2a_delegate.py send --to allegro --task "Analyze the codebase" --wait
|
||||
```
|
||||
|
||||
### 5. Fetch an Agent Card
|
||||
|
||||
```bash
|
||||
python bin/a2a_delegate.py card --agent ezra
|
||||
```
|
||||
|
||||
## Programmatic Usage
|
||||
|
||||
### Client (Sending Tasks)
|
||||
|
||||
```python
|
||||
from nexus.a2a.client import A2AClient, A2AClientConfig
|
||||
from nexus.a2a.types import Message, Role, TextPart
|
||||
|
||||
config = A2AClientConfig(auth_token="your-token", timeout=30.0, max_retries=3)
|
||||
client = A2AClient(config=config)
|
||||
|
||||
try:
|
||||
# Discover agent
|
||||
card = await client.get_agent_card("https://ezra.example.com")
|
||||
print(f"Found: {card.name} with {len(card.skills)} skills")
|
||||
|
||||
# Delegate task
|
||||
task = await client.delegate(
|
||||
"https://ezra.example.com/a2a/v1",
|
||||
text="Check CI pipeline health",
|
||||
skill_id="ci-health",
|
||||
)
|
||||
|
||||
# Wait for result
|
||||
result = await client.wait_for_completion(
|
||||
"https://ezra.example.com/a2a/v1",
|
||||
task.id,
|
||||
)
|
||||
print(f"Result: {result.artifacts[0].parts[0].text}")
|
||||
|
||||
# Audit log
|
||||
for entry in client.get_audit_log():
|
||||
print(f" {entry['method']} → {entry['status_code']} ({entry['elapsed_ms']}ms)")
|
||||
finally:
|
||||
await client.close()
|
||||
```
|
||||
|
||||
### Server (Receiving Tasks)
|
||||
|
||||
```python
|
||||
from nexus.a2a.server import A2AServer
|
||||
from nexus.a2a.types import AgentCard, Task, AgentSkill, TextPart, Artifact, TaskStatus, TaskState
|
||||
|
||||
# Define your handler
|
||||
async def ci_handler(task: Task, card: AgentCard) -> Task:
|
||||
# Do the work
|
||||
result = "CI pipeline healthy: 5/5 passed"
|
||||
|
||||
task.artifacts.append(
|
||||
Artifact(parts=[TextPart(text=result)], name="ci_report")
|
||||
)
|
||||
task.status = TaskStatus(state=TaskState.COMPLETED)
|
||||
return task
|
||||
|
||||
# Build agent card
|
||||
card = AgentCard(
|
||||
name="Ezra",
|
||||
description="CI/CD specialist",
|
||||
skills=[AgentSkill(id="ci-health", name="CI Health", description="Check CI", tags=["ci"])],
|
||||
)
|
||||
|
||||
# Start server
|
||||
server = A2AServer(card=card, auth_token="your-token")
|
||||
server.register_handler("ci-health", ci_handler)
|
||||
await server.start(host="0.0.0.0", port=8080)
|
||||
```
|
||||
|
||||
### Registry (Agent Discovery)
|
||||
|
||||
```python
|
||||
from nexus.a2a.registry import LocalFileRegistry
|
||||
|
||||
registry = LocalFileRegistry() # Reads config/fleet_agents.json
|
||||
|
||||
# List all agents
|
||||
for agent in registry.list_agents():
|
||||
print(f"{agent.name}: {agent.description}")
|
||||
|
||||
# Find agents by capability
|
||||
ci_agents = registry.list_agents(skill="ci-health")
|
||||
devops_agents = registry.list_agents(tag="devops")
|
||||
|
||||
# Get endpoint
|
||||
url = registry.get_endpoint("ezra")
|
||||
```
|
||||
|
||||
## A2A Protocol Reference
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/.well-known/agent-card.json` | GET | Agent Card discovery |
|
||||
| `/agent.json` | GET | Agent Card fallback |
|
||||
| `/a2a/v1` | POST | JSON-RPC endpoint |
|
||||
| `/a2a/v1/rpc` | POST | JSON-RPC alias |
|
||||
|
||||
### JSON-RPC Methods
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `SendMessage` | Send a task and get a Task object back |
|
||||
| `GetTask` | Get task status by ID |
|
||||
| `ListTasks` | List tasks (cursor pagination) |
|
||||
| `CancelTask` | Cancel a running task |
|
||||
| `GetAgentCard` | Get the agent's card via RPC |
|
||||
|
||||
### Task States
|
||||
|
||||
| State | Terminal? | Meaning |
|
||||
|-------|-----------|---------|
|
||||
| `TASK_STATE_SUBMITTED` | No | Task acknowledged |
|
||||
| `TASK_STATE_WORKING` | No | Actively processing |
|
||||
| `TASK_STATE_COMPLETED` | Yes | Success |
|
||||
| `TASK_STATE_FAILED` | Yes | Error |
|
||||
| `TASK_STATE_CANCELED` | Yes | Canceled |
|
||||
| `TASK_STATE_INPUT_REQUIRED` | No | Needs more input |
|
||||
| `TASK_STATE_REJECTED` | Yes | Agent declined |
|
||||
|
||||
### Part Types (discriminated by JSON key)
|
||||
|
||||
- `TextPart` — `{"text": "hello"}`
|
||||
- `FilePart` — `{"raw": "base64...", "mediaType": "image/png"}` or `{"url": "https://..."}`
|
||||
- `DataPart` — `{"data": {"key": "value"}}`
|
||||
|
||||
## Authentication
|
||||
|
||||
Agents declare auth in their Agent Card. Supported schemes:
|
||||
- **Bearer token**: `Authorization: Bearer <token>`
|
||||
- **API key**: `X-API-Key: <token>` (or custom header name)
|
||||
|
||||
Configure in `~/.hermes/agent_card.yaml`:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
scheme: "bearer"
|
||||
token_env: "A2A_AUTH_TOKEN" # env var containing the token
|
||||
```
|
||||
|
||||
## Fleet Registry
|
||||
|
||||
The fleet registry (`config/fleet_agents.json`) lists all wizards and their capabilities. Agents can be registered via:
|
||||
|
||||
1. **Local file** — `LocalFileRegistry` reads/writes JSON directly
|
||||
2. **Gitea** — `GiteaRegistry` stores cards in a repo for distributed discovery
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pytest tests/test_a2a.py -v
|
||||
```
|
||||
|
||||
Covers:
|
||||
- Type serialization roundtrips
|
||||
- Agent Card building from YAML
|
||||
- Registry operations (register, list, filter)
|
||||
- Server integration (SendMessage, GetTask, ListTasks, CancelTask)
|
||||
- Authentication (required, success)
|
||||
- Custom handler routing
|
||||
- Error handling
|
||||
|
||||
## Phase Status
|
||||
|
||||
- [x] Phase 1 — Agent Card & Discovery
|
||||
- [x] Phase 2 — Task Delegation
|
||||
- [x] Phase 3 — Security & Reliability
|
||||
|
||||
## Linked Issue
|
||||
|
||||
[#1122](https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1122)
|
||||
@@ -1,84 +0,0 @@
|
||||
# ADR-001 — Shell / Terminal Boundary and Transport Model
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-04-22
|
||||
**Issue:** [#1695 — ATLAS Cockpit: operator inspector rail and session shell patterns](https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1695)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Nexus operator cockpit needs a real shell/terminal surface so the operator can run commands, inspect logs, and interact with the system without leaving the 3D world UI.
|
||||
|
||||
Three transport models were evaluated:
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| **A. Native local PTY** | `server.py` spawns a PTY via Python's `pty` stdlib module; the browser connects via WebSocket on `ws://127.0.0.1:8766/pty` |
|
||||
| **B. Remote SSH PTY** | Browser connects to an SSH relay that opens a PTY on a remote machine |
|
||||
| **C. Browser pseudo-terminal** | Pure JavaScript terminal emulation (e.g., a custom REPL) with no real OS shell |
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
**Option A — Native local PTY** is chosen.
|
||||
|
||||
The Nexus is explicitly a **local-first** system (CLAUDE.md: "local-first training ground for Timmy"). The operator is the same person sitting at the machine. A local PTY bridged through `server.py` is:
|
||||
|
||||
- Architecturally consistent with the existing `server.py` WebSocket gateway
|
||||
- Zero additional infrastructure (no SSH relay, no remote server)
|
||||
- Full shell fidelity — any tool on the operator's PATH, full interactive programs, readline, color, etc.
|
||||
- Bounded: PTY_PORT (8766) binds to `127.0.0.1` only; no external exposure
|
||||
|
||||
---
|
||||
|
||||
## Transport Detail
|
||||
|
||||
```
|
||||
Browser (xterm.js) ←→ ws://127.0.0.1:8766/pty ←→ server.py pty_handler ←→ OS PTY ($SHELL)
|
||||
```
|
||||
|
||||
- Each WebSocket connection gets its own `pty.openpty()` pair and subprocess.
|
||||
- Input from xterm.js `onData` → `_ptyWs.send(data)` → `os.write(master_fd, data)`
|
||||
- Output from PTY → `os.read(master_fd)` → `websocket.send(text)` → `_term.write(text)`
|
||||
- Resize messages (`{"type":"resize","cols":N,"rows":N}`) can be added later via `fcntl.ioctl(TIOCSWINSZ)`.
|
||||
|
||||
---
|
||||
|
||||
## Why not Option B (Remote SSH PTY)?
|
||||
|
||||
Remote SSH adds network hop complexity, credential management, and an SSH relay service. The Nexus does not currently have a remote operator use-case; Alexander operates locally. This decision can be revisited when fleet/remote-agent use-cases mature (see issues #672–#675).
|
||||
|
||||
---
|
||||
|
||||
## Why not Option C (Browser pseudo-terminal)?
|
||||
|
||||
A JavaScript REPL cannot run arbitrary shell programs, manage processes, or provide the raw interactive shell experience that operators expect. It would be a toy, not a tool.
|
||||
|
||||
---
|
||||
|
||||
## Rejected Patterns
|
||||
|
||||
- **tmux over WebSocket** — adds server-side state complexity without enough benefit at this scale
|
||||
- **ttyd** — external binary dependency; overkill for a local-first single-operator setup
|
||||
- **xterm.js + websocketd** — external binary dependency; `server.py` already owns the WebSocket gateway
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- `server.py` now starts two WebSocket servers: `PORT` (8765, main gateway) and `PTY_PORT` (8766, shell gateway)
|
||||
- `PTY_PORT` always binds to `127.0.0.1` regardless of `NEXUS_WS_HOST`
|
||||
- `cockpit-inspector.js` loads xterm.js from CDN (offline fallback: graceful degradation — the rail renders without the terminal pane)
|
||||
- PTY sessions are ephemeral (no persistence across browser reload or server restart)
|
||||
- Future: add TIOCSWINSZ resize support; add `/pty?shell=zsh` query param selection
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- `cockpit-inspector.js` — implements the browser side (xterm.js + WebSocket)
|
||||
- `server.py` — implements `pty_handler()` and `_get_git_status()`
|
||||
- `docs/ATLAS-COCKPIT-PATTERNS.md` — documents source patterns adopted
|
||||
- Issues: #1695, #686, #687
|
||||
@@ -1,125 +0,0 @@
|
||||
# ATLAS Cockpit — Source Patterns: Adopted, Adapted, and Rejected
|
||||
|
||||
**Issue:** [#1695](https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1695)
|
||||
**Date:** 2026-04-22
|
||||
|
||||
---
|
||||
|
||||
## Source Repos Audited
|
||||
|
||||
| Repo | Role in audit |
|
||||
|---|---|
|
||||
| `dodo-reach/hermes-desktop` | Primary pattern source for inspector/right rail layout |
|
||||
| `outsourc-e/hermes-workspace` | Session taxonomy vocabulary (group/tag/pin/archive) |
|
||||
| `nesquena/hermes-webui` | Session switcher UI patterns; status badge styling |
|
||||
|
||||
---
|
||||
|
||||
## Patterns Adopted
|
||||
|
||||
### 1. Inspector / Right Rail (from `dodo-reach/hermes-desktop`)
|
||||
|
||||
**What:** A collapsible right panel with discrete sections (Files, Artifacts, Status, Terminal).
|
||||
Each section is independently scrollable with a header badge showing active count.
|
||||
|
||||
**How we adopted it:**
|
||||
- `cockpit-inspector.js` builds the rail as a fixed `position: fixed; right: 0` DOM element
|
||||
- Collapse/expand is stored in `localStorage` (key: `nexus-inspector-collapsed`) — identical pattern to hermes-desktop's sidebar persistence
|
||||
- Section headers use the same `icon + ALL CAPS LABEL + badge` visual grammar
|
||||
- Toggle button sits on the left edge of the rail (pulled out ~22px) — same affordance pattern
|
||||
|
||||
**What we changed:**
|
||||
- Theming: Nexus uses `#4af0c0` / dark-space palette instead of hermes-desktop's purple/grey Electron chrome
|
||||
- Git state section is added (not present in hermes-desktop)
|
||||
- Memory/Skills section maps to Nexus's SpatialMemory regions (Nexus-specific)
|
||||
|
||||
---
|
||||
|
||||
### 2. Session Taxonomy Vocabulary (from `outsourc-e/hermes-workspace`)
|
||||
|
||||
**What:** Sessions are first-class objects with `group`, `tag[]`, `pinned`, and `archived` state.
|
||||
Pinned sessions always sort first. Archived sessions are hidden from default lists.
|
||||
|
||||
**How we adopted it:**
|
||||
- `session-manager.js` implements the exact four operations: `group()`, `tag()`, `pin()`, `archive()`
|
||||
- `list()` filters archived by default, sorts pinned first, then by `updatedAt` desc — identical to hermes-workspace's `sessionList()` sort contract
|
||||
- Export/import as JSON (hermes-workspace's backup mechanism)
|
||||
|
||||
**What we changed:**
|
||||
- Persistence is `localStorage` (not IndexedDB or a backend store) — appropriate for local-first, single-operator Nexus
|
||||
- Added `on()/off()` event bus so `cockpit-inspector.js` can reactively re-render when sessions change
|
||||
- Session IDs use `sess_<timestamp>_<random>` prefix rather than UUID v4
|
||||
|
||||
---
|
||||
|
||||
### 3. Status Badge Styling (from `nesquena/hermes-webui`)
|
||||
|
||||
**What:** Section header badges use a small pill with a count; color encodes state (green = ok, amber = warn, red = error).
|
||||
|
||||
**How we adopted it:**
|
||||
- `.ci-section-badge`, `.badge-warn`, `.badge-ok` classes in `style.css` follow the same color semantics
|
||||
- Agent health dots (`agent-idle`, `agent-working`, `agent-error`) map to the same three-color system
|
||||
|
||||
**What we changed:**
|
||||
- Font is JetBrains Mono (Nexus default) instead of hermes-webui's Inter
|
||||
- Animations are subtler (pulse only on `agent-working`, not on all badges)
|
||||
|
||||
---
|
||||
|
||||
### 4. xterm.js PTY Terminal (common pattern across all three repos)
|
||||
|
||||
All three source repos use xterm.js as the browser terminal component. The transport varies:
|
||||
- hermes-desktop: IPC bridge to a native Node.js `node-pty` subprocess
|
||||
- hermes-workspace: WebSocket to a server-side shell
|
||||
- hermes-webui: WebSocket to a `ttyd`-style relay
|
||||
|
||||
**How we adopted it:**
|
||||
- xterm.js 5.3.0 from CDN (no build step — consistent with Nexus's no-bundler approach)
|
||||
- WebSocket transport to `server.py`'s `pty_handler()` on port 8766
|
||||
- `FitAddon` for terminal resize (same as all three source repos)
|
||||
|
||||
**What we changed:**
|
||||
- Transport is Python `pty` stdlib (not `node-pty`, not `ttyd`) — see ADR-001
|
||||
- PTY runs in `server.py`'s asyncio event loop via `run_in_executor` (non-blocking reads)
|
||||
|
||||
---
|
||||
|
||||
## Patterns Intentionally Rejected
|
||||
|
||||
### A. Multi-pane split terminal (hermes-desktop)
|
||||
hermes-desktop supports splitting the terminal into multiple panes (tmux-style).
|
||||
**Rejected:** Adds significant UI complexity for zero immediate operator value. The Nexus operator uses native terminal splits in their OS. One PTY pane is sufficient.
|
||||
|
||||
### B. Session persistence to remote backend (outsourc-e/hermes-workspace)
|
||||
hermes-workspace stores sessions in a PostgreSQL backend with real-time sync across clients.
|
||||
**Rejected:** The Nexus is local-first and single-operator. `localStorage` is sufficient and adds zero infrastructure.
|
||||
|
||||
### C. File tree browser in the rail (dodo-reach/hermes-desktop)
|
||||
hermes-desktop has a full VS Code–style file tree in the inspector.
|
||||
**Rejected:** The Nexus 3D world is not a code editor. Artifacts are surfaced as a flat list of recently-touched files/outputs, not a tree. A full file tree belongs in a future dedicated operator panel (see issue #687).
|
||||
|
||||
### D. Session thumbnails / previews (nesquena/hermes-webui)
|
||||
hermes-webui renders a mini-canvas screenshot as a session thumbnail.
|
||||
**Rejected:** The Nexus Three.js canvas is expensive to snapshot. Thumbnails would require canvas-capture overhead. Deferred to a future issue.
|
||||
|
||||
### E. Inline skill invocation buttons (nesquena/hermes-webui)
|
||||
hermes-webui adds quick-action buttons directly on each agent health row.
|
||||
**Rejected for now:** The Nexus already has a chat command surface ("Timmy Terminal" bottom panel). Duplicating invocation paths would fragment the operator model. The inspector rail is read-focused; actions flow through chat.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Pattern | Source | Status |
|
||||
|---|---|---|
|
||||
| Inspector right rail layout | hermes-desktop | Adopted |
|
||||
| Collapse/expand + localStorage | hermes-desktop | Adopted |
|
||||
| Session group/tag/pin/archive | hermes-workspace | Adopted |
|
||||
| Session sort (pinned first, then updatedAt) | hermes-workspace | Adopted |
|
||||
| Status badge color semantics | hermes-webui | Adopted |
|
||||
| xterm.js terminal | all three | Adopted (transport adapted) |
|
||||
| Multi-pane terminal | hermes-desktop | Rejected |
|
||||
| Remote session backend | hermes-workspace | Rejected |
|
||||
| File tree browser | hermes-desktop | Rejected |
|
||||
| Session thumbnails | hermes-webui | Rejected |
|
||||
| Inline skill invocation | hermes-webui | Rejected |
|
||||
@@ -1,104 +0,0 @@
|
||||
# Forge Cleanup Analysis — Issue #1128
|
||||
|
||||
## Summary
|
||||
|
||||
This document analyzes the current state of open PRs in the-nexus repository and identifies cleanup actions needed.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Total Open PRs**: 14
|
||||
- **Duplicate PR Groups**: 4 groups with 2 PRs each (8 PRs total)
|
||||
- **PRs with Review Issues**: 4 PRs with REQUEST_CHANGES
|
||||
- **Approved PRs**: 1 PR approved but not merged
|
||||
|
||||
## Duplicate PR Analysis
|
||||
|
||||
### Group 1: Issue #1338 (Remove duplicate content blocks)
|
||||
- **PR #1392**: `fix: remove duplicate content blocks from README.md`
|
||||
- Branch: `burn/1338-1776125702`
|
||||
- Created: 2026-04-14T00:19:24Z
|
||||
- Status: REQUEST_REVIEW by perplexity
|
||||
- **PR #1388**: `fix: remove duplicate content blocks from page`
|
||||
- Branch: `burn/1338-1776120221`
|
||||
- Created: 2026-04-13T22:55:30Z
|
||||
- Status: No reviews
|
||||
|
||||
**Recommendation**: Close PR #1388 (older), keep PR #1392 (newer).
|
||||
|
||||
### Group 2: Issue #1354 (Sovereign Sound Playground)
|
||||
- **PR #1391**: `fix: Add Sovereign Sound Playground and fix portals.json (#1354)`
|
||||
- Branch: `burn/1354-1776125702`
|
||||
- Created: 2026-04-14T00:19:22Z
|
||||
- Status: REQUEST_REVIEW by perplexity
|
||||
- Note: Also fixes portals.json syntax error
|
||||
- **PR #1384**: `feat: Add Sovereign Sound Playground (#1354)`
|
||||
- Branch: `burn/1354-1776120221`
|
||||
- Created: 2026-04-13T22:51:04Z
|
||||
- Status: No reviews
|
||||
- Note: Does NOT fix portals.json syntax error
|
||||
|
||||
**Recommendation**: Close PR #1384 (older, incomplete), keep PR #1391 (newer, complete).
|
||||
|
||||
### Group 3: Issue #1349 (ChatLog.log() crash)
|
||||
- **PR #1390**: `fix: ChatLog.log() crash — CHATLOG_FILE defined after use (#1349)`
|
||||
- Branch: `burn/1349-1776125702`
|
||||
- Created: 2026-04-14T00:17:34Z
|
||||
- Status: REQUEST_REVIEW by perplexity
|
||||
- **PR #1382**: `fix: ChatLog.log() crash on message persistence (#1349)`
|
||||
- Branch: `burn/1349-1776120221`
|
||||
- Created: 2026-04-13T22:50:07Z
|
||||
- Status: No reviews
|
||||
|
||||
**Recommendation**: Close PR #1382 (older), keep PR #1390 (newer).
|
||||
|
||||
### Group 4: Issue #1356 (ThreadingHTTPServer concurrency)
|
||||
- **PR #1389**: `fix(#1356): ThreadingHTTPServer concurrency fix`
|
||||
- Branch: `burn/1356-1776125702`
|
||||
- Created: 2026-04-14T00:16:23Z
|
||||
- Status: REQUEST_REVIEW by perplexity
|
||||
- **PR #1381**: `fix(#1356): ThreadingHTTPServer concurrency fix for multi-user bridge`
|
||||
- Branch: `burn/1356-1776120221`
|
||||
- Created: 2026-04-13T22:47:45Z
|
||||
- Status: No reviews
|
||||
|
||||
**Recommendation**: Close PR #1381 (older), keep PR #1389 (newer).
|
||||
|
||||
## Additional Cleanup Candidates
|
||||
|
||||
### PR #1387: MemPalace INIT display
|
||||
- **Title**: `fix: MEMPALACE INIT shows real stats from fleet API (#1340)`
|
||||
- **Status**: REQUEST_CHANGES by Timmy
|
||||
- **Action**: Needs changes before merge
|
||||
|
||||
### PR #1386: Fleet audit tool
|
||||
- **Title**: `feat: fleet audit tool — deduplicate agents, one identity per machine`
|
||||
- **Status**: APPROVED by Timmy
|
||||
- **Action**: Ready for merge
|
||||
|
||||
## Policy Recommendations
|
||||
|
||||
### 1. Prevent Duplicate PRs
|
||||
- Implement check to detect if an open PR already exists for the same issue
|
||||
- Add bot comment when duplicate PR is detected
|
||||
|
||||
### 2. PR Review Workflow
|
||||
- Require at least one approval before merge
|
||||
- Auto-close PRs with REQUEST_CHANGES after 7 days of inactivity
|
||||
|
||||
### 3. Stale PR Management
|
||||
- Auto-close PRs older than 30 days with no activity
|
||||
- Weekly cleanup of duplicate PRs
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `docs/pr-duplicate-detection.md` - Policy for detecting duplicate PRs
|
||||
2. `scripts/cleanup-duplicate-prs.sh` - Script to identify and close duplicate PRs
|
||||
3. `.github/workflows/pr-duplicate-check.yml` - GitHub Action for duplicate detection
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Close identified duplicate PRs
|
||||
2. Address review comments on PRs with REQUEST_CHANGES
|
||||
3. Merge approved PRs
|
||||
4. Implement duplicate prevention policies
|
||||
5. Update issue #1128 with cleanup results
|
||||
@@ -1,172 +0,0 @@
|
||||
# Forge Cleanup Report — Issue #1128
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This report documents the cleanup of duplicate PRs and stale milestones in the Timmy Foundation repositories, as requested in issue #1128.
|
||||
|
||||
## Actions Completed
|
||||
|
||||
### 1. Duplicate PRs Closed
|
||||
|
||||
The following duplicate PRs were identified and closed:
|
||||
|
||||
| Issue | Closed PR | Reason | Kept PR |
|
||||
|-------|-----------|--------|---------|
|
||||
| #1338 | #1388 | Duplicate of #1392 | #1392 |
|
||||
| #1354 | #1384 | Incomplete (missing portals.json fix) | #1391 |
|
||||
| #1349 | #1382 | Duplicate of #1390 | #1390 |
|
||||
| #1356 | #1381 | Duplicate of #1389 | #1389 |
|
||||
|
||||
**Result**: Reduced open PR count from 14 to 9.
|
||||
|
||||
### 2. Current PR Status
|
||||
|
||||
#### Ready to Merge (1 PR):
|
||||
- **PR #1386**: `feat: fleet audit tool — deduplicate agents, one identity per machine`
|
||||
- Status: APPROVED by Timmy
|
||||
- Branch: `burn/1144-1776120221`
|
||||
- Action: Ready for merge
|
||||
|
||||
#### Awaiting Review (4 PRs):
|
||||
- **PR #1392**: `fix: remove duplicate content blocks from README.md` (#1338)
|
||||
- **PR #1391**: `fix: Add Sovereign Sound Playground and fix portals.json` (#1354)
|
||||
- **PR #1390**: `fix: ChatLog.log() crash — CHATLOG_FILE defined after use` (#1349)
|
||||
- **PR #1389**: `fix(#1356): ThreadingHTTPServer concurrency fix` (#1356)
|
||||
|
||||
#### Requiring Changes (4 PRs):
|
||||
- **PR #1387**: `fix: MEMPALACE INIT shows real stats from fleet API` (#1340)
|
||||
- **PR #1380**: `[A2A] Implement Agent2Agent Protocol for Fleet-Wizard Delegation` (#1122)
|
||||
- **PR #1379**: `[NEXUS] [PERFORMANCE] Three.js LOD and Texture Audit` (#873)
|
||||
- **PR #1374**: `feat: Add Reasoning Trace HUD Component` (#875)
|
||||
|
||||
### 3. Milestones Cleanup
|
||||
|
||||
Based on issue #1128 description, the following milestones were cleaned:
|
||||
|
||||
#### Duplicate Milestones Deleted (7):
|
||||
- timmy-config: ID 33 (Code Claw Operational)
|
||||
- timmy-config: ID 34 (Code Claw OpenRouter)
|
||||
- timmy-config: ID 38 (Sovereign Orchestration)
|
||||
- hermes-agent: ID 42 (Self-Awareness)
|
||||
- hermes-agent: ID 45 (Self-Awareness)
|
||||
- hermes-agent: ID 43 (Test Milestone)
|
||||
- the-nexus: ID 35 (M6 Lazarus Pit)
|
||||
|
||||
#### Completed Milestones Closed (7):
|
||||
- timmy-config: Code Claw Operational
|
||||
- timmy-config: Code Claw OpenRouter
|
||||
- timmy-config: Sovereign Orchestration (17 closed)
|
||||
- the-nexus: M1 Core 3D World (4 closed)
|
||||
- the-nexus: M2 Agent Presence (5 closed)
|
||||
- the-nexus: M4 Game Portals (3 closed)
|
||||
- the-nexus: MemPalace × Evennia (9 closed)
|
||||
|
||||
### 4. Policy Issues Filed
|
||||
|
||||
#### Issue #378 (timmy-config):
|
||||
**Title**: `[MUDA] SOUL.md exists in 3 repos with divergent content`
|
||||
|
||||
**Problem**: SOUL.md exists in three repositories with different content:
|
||||
- timmy-home: 9306 bytes
|
||||
- timmy-config: 9284 bytes
|
||||
- the-nexus: 5402 bytes
|
||||
|
||||
**Recommendation**: Use timmy-home as single source of truth.
|
||||
|
||||
#### Issue #379 (timmy-config):
|
||||
**Title**: `[POLICY] Prevent agents from approving zero-change PRs`
|
||||
|
||||
**Problem**: Agents were approving PRs with 0 changed files (zombie PRs).
|
||||
|
||||
**Solution**: Implement pre-review guard in orchestrator.
|
||||
|
||||
## Tools Created
|
||||
|
||||
### 1. Duplicate PR Detection Script
|
||||
**File**: `scripts/cleanup-duplicate-prs.sh`
|
||||
|
||||
**Purpose**: Automated detection and cleanup of duplicate open PRs.
|
||||
|
||||
**Features**:
|
||||
- Groups PRs by issue number or title similarity
|
||||
- Identifies duplicate PRs for the same issue
|
||||
- Closes older duplicates with explanatory comments
|
||||
- Supports dry-run mode for testing
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
# Dry run (default)
|
||||
./scripts/cleanup-duplicate-prs.sh
|
||||
|
||||
# Actually close duplicates
|
||||
./scripts/cleanup-duplicate-prs.sh --close
|
||||
```
|
||||
|
||||
### 2. Analysis Document
|
||||
**File**: `docs/forge-cleanup-analysis.md`
|
||||
|
||||
**Contents**:
|
||||
- Detailed analysis of duplicate PRs
|
||||
- Review status of all open PRs
|
||||
- Policy recommendations
|
||||
- Implementation plan
|
||||
|
||||
## Recommendations
|
||||
|
||||
### 1. Immediate Actions
|
||||
1. **Merge approved PR #1386** (fleet audit tool)
|
||||
2. **Review PRs #1392, #1391, #1390, #1389** (awaiting review)
|
||||
3. **Address review comments** on PRs #1387, #1380, #1379, #1374
|
||||
|
||||
### 2. Policy Implementation
|
||||
1. **Duplicate PR Prevention**:
|
||||
- Implement check to detect if an open PR already exists for the same issue
|
||||
- Add bot comment when duplicate PR is detected
|
||||
|
||||
2. **PR Review Workflow**:
|
||||
- Require at least one approval before merge
|
||||
- Auto-close PRs with REQUEST_CHANGES after 7 days of inactivity
|
||||
|
||||
3. **Stale PR Management**:
|
||||
- Weekly cleanup of duplicate PRs
|
||||
- Auto-close PRs older than 30 days with no activity
|
||||
|
||||
### 3. Documentation Updates
|
||||
1. Update PR template to include issue reference
|
||||
2. Document duplicate PR prevention policy
|
||||
3. Create PR review guidelines
|
||||
|
||||
## Metrics
|
||||
|
||||
### Before Cleanup:
|
||||
- **Open PRs**: 14
|
||||
- **Duplicate PR Groups**: 4
|
||||
- **Stale PRs**: Unknown
|
||||
|
||||
### After Cleanup:
|
||||
- **Open PRs**: 9
|
||||
- **Duplicate PR Groups**: 0
|
||||
- **Ready to Merge**: 1
|
||||
- **Awaiting Review**: 4
|
||||
- **Requiring Changes**: 4
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Short-term** (this week):
|
||||
- Merge PR #1386
|
||||
- Review and merge PRs #1392, #1391, #1390, #1389
|
||||
- Address review comments on remaining PRs
|
||||
|
||||
2. **Medium-term** (next 2 weeks):
|
||||
- Implement duplicate PR prevention policy
|
||||
- Set up automated cleanup scripts
|
||||
- Document PR review workflow
|
||||
|
||||
3. **Long-term** (next month):
|
||||
- Monitor for new duplicate PRs
|
||||
- Refine cleanup policies based on experience
|
||||
- Share learnings with other repositories
|
||||
|
||||
---
|
||||
|
||||
*Report generated for issue #1128: [RESOLVED] Forge Cleanup — PRs Closed, Milestones Deduplicated, Policy Issues Filed*
|
||||
@@ -1,48 +0,0 @@
|
||||
# Local LLM Deployment Guide — llama.cpp
|
||||
|
||||
Standardizes local LLM inference across the fleet using llama.cpp.
|
||||
|
||||
## Quick Start
|
||||
|
||||
git clone https://github.com/ggerganov/llama.cpp.git
|
||||
cd llama.cpp && cmake -B build && cmake --build build --config Release -j$(nproc)
|
||||
sudo cp build/bin/llama-server /usr/local/bin/
|
||||
mkdir -p /opt/models/llama
|
||||
wget -O /opt/models/llama/Qwen2.5-7B-Instruct-Q4_K_M.gguf "https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_k_m.gguf"
|
||||
llama-server -m /opt/models/llama/Qwen2.5-7B-Instruct-Q4_K_M.gguf --host 0.0.0.0 --port 11435 -c 4096 -t $(nproc) --cont-batching
|
||||
|
||||
## Model Paths
|
||||
|
||||
- /opt/models/llama/ — Production
|
||||
- ~/models/llama/ — Dev
|
||||
- MODEL_DIR env var — Override
|
||||
|
||||
## Models
|
||||
|
||||
- Qwen2.5-7B-Instruct-Q4_K_M (4.7GB) — Fleet standard, VPS Alpha
|
||||
- Qwen2.5-3B-Instruct-Q4_K_M (2.0GB) — VPS Beta
|
||||
- Mistral-7B-Instruct-v0.3-Q4_K_M (4.4GB) — Alternative
|
||||
|
||||
## Quantization
|
||||
|
||||
- Q6_K (5.5GB) — Best quality/speed, 12GB+ RAM
|
||||
- Q4_K_M (4.7GB) — Fleet standard, 8GB RAM
|
||||
- Q3_K_M (3.4GB) — Low-RAM fallback, 4GB
|
||||
|
||||
## Hardware
|
||||
|
||||
- VPS Beta (2c/4GB): 3B-Q4_K_M, ctx 2048, ~40-60 tok/s
|
||||
- VPS Alpha (4c/8GB): 7B-Q4_K_M, ctx 4096, ~20-35 tok/s
|
||||
- Mac (AS/16GB+): 7B-Q6_K, Metal, ~30-50 tok/s
|
||||
|
||||
## Health
|
||||
|
||||
curl -sf http://localhost:11435/health
|
||||
curl -s http://localhost:11435/v1/models
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Won't start → smaller model / lower quant
|
||||
- Slow → -t to core count
|
||||
- OOM → reduce -c
|
||||
- Port conflict → lsof -i :11435
|
||||
154
docs/performance-hardware-requirements.md
Normal file
154
docs/performance-hardware-requirements.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# NEXUS Performance & Hardware Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the minimum and recommended hardware requirements for running The Nexus 3D world, based on the LOD (Level of Detail) system, texture auditing, and performance monitoring.
|
||||
|
||||
## Performance System
|
||||
|
||||
The Nexus now includes:
|
||||
|
||||
1. **LOD System** - Automatically reduces geometry complexity based on distance from camera
|
||||
2. **Texture Auditor** - Analyzes textures for performance issues and provides compression recommendations
|
||||
3. **Performance Monitor** - Real-time stats.js overlay showing FPS, draw calls, triangles, textures, and geometries
|
||||
|
||||
## Hardware Tiers
|
||||
|
||||
### Tier 1: High Performance
|
||||
- **Hardware:** Apple M1 Pro/Max/Ultra, M2 Pro/Max, M3/M4 series
|
||||
- **RAM:** 16GB+
|
||||
- **Target FPS:** 60
|
||||
- **Max Draw Calls:** 2,000
|
||||
- **Max Triangles:** 1,000,000
|
||||
- **Max Textures:** 100
|
||||
- **LOD Thresholds:** High detail within 20 units, medium within 40, low within 60, cull beyond 100
|
||||
|
||||
### Tier 2: Medium Performance (Default)
|
||||
- **Hardware:** Apple M1, M2, M3 base models
|
||||
- **RAM:** 8GB+
|
||||
- **Target FPS:** 45
|
||||
- **Max Draw Calls:** 1,000
|
||||
- **Max Triangles:** 500,000
|
||||
- **Max Textures:** 50
|
||||
- **LOD Thresholds:** High detail within 15 units, medium within 30, low within 50, cull beyond 80
|
||||
|
||||
### Tier 3: Low Performance (Minimum)
|
||||
- **Hardware:** Intel Macs (2018+), older hardware
|
||||
- **RAM:** 8GB+
|
||||
- **Target FPS:** 30
|
||||
- **Max Draw Calls:** 500
|
||||
- **Max Triangles:** 200,000
|
||||
- **Max Textures:** 25
|
||||
- **LOD Thresholds:** High detail within 10 units, medium within 20, low within 40, cull beyond 60
|
||||
|
||||
## Current Scene Analysis
|
||||
|
||||
Based on the current Nexus scene:
|
||||
|
||||
- **Total Mesh Objects:** 32
|
||||
- **Geometry Types:** 9 unique (SphereGeometry, BoxGeometry, CylinderGeometry, etc.)
|
||||
- **Material Types:** 5 unique (MeshBasicMaterial, MeshStandardMaterial, MeshPhysicalMaterial, etc.)
|
||||
- **Texture Files:** 2 (icons only, all other textures are procedural)
|
||||
- **LOD-Managed Objects:** 8 (4 agent orbs + 4 agent halos)
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### LOD System
|
||||
The LOD system automatically manages detail levels for:
|
||||
- Agent orbs (spheres): 32x32 → 16x16 → 8x8 segments
|
||||
- Agent halos (torus): 16x64 → 12x32 → 8x16 segments
|
||||
- Future: Pillars, portals, and other complex geometry
|
||||
|
||||
### Texture Optimization
|
||||
Current texture audit shows:
|
||||
- **Total VRAM:** ~0.1MB (minimal texture usage)
|
||||
- **Issues:** No significant issues found
|
||||
- **Recommendations:** Continue using procedural textures where possible
|
||||
|
||||
### Performance Monitoring
|
||||
Press `~` or `F3` to toggle the stats.js overlay showing:
|
||||
- FPS (frames per second)
|
||||
- Frame time (ms)
|
||||
- Draw calls per frame
|
||||
- Triangle count
|
||||
- Texture count
|
||||
- Geometry count
|
||||
|
||||
## Running the Texture Audit
|
||||
|
||||
```bash
|
||||
# Audit all textures in the project
|
||||
node tools/texture-audit-cli.js .
|
||||
|
||||
# Save results to JSON
|
||||
node tools/texture-audit-cli.js . audit-results.json
|
||||
```
|
||||
|
||||
## Performance Recommendations
|
||||
|
||||
### For All Hardware:
|
||||
1. **Enable LOD system** - Automatically reduces detail for distant objects
|
||||
2. **Monitor with stats.js** - Use the overlay to identify bottlenecks
|
||||
3. **Use procedural textures** - Canvas-generated textures are more efficient than loaded files
|
||||
|
||||
### For Lower-End Hardware:
|
||||
1. **Reduce post-processing** - Bloom and SMAAPass are disabled on "low" tier
|
||||
2. **Limit particle systems** - Ash storm disabled on "low" tier
|
||||
3. **Reduce ambient structures** - Disabled on "low" tier
|
||||
|
||||
### For Developers:
|
||||
1. **Register new geometry for LOD** - Use `performanceSystem.registerForLOD()`
|
||||
2. **Audit new textures** - Run the texture audit before adding new assets
|
||||
3. **Monitor performance** - Check stats.js during development
|
||||
|
||||
## Minimum Sovereign Hardware
|
||||
|
||||
Based on current analysis, the minimum hardware for a sovereign Nexus instance:
|
||||
|
||||
**Absolute Minimum:**
|
||||
- **CPU:** Any modern processor (Intel i5/AMD Ryzen 5 or Apple M1)
|
||||
- **RAM:** 8GB
|
||||
- **GPU:** Integrated graphics (Intel Iris, AMD Radeon, Apple GPU)
|
||||
- **Storage:** 1GB free space
|
||||
- **Browser:** Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
|
||||
|
||||
**Recommended for 60 FPS:**
|
||||
- **CPU:** Apple M1 or better
|
||||
- **RAM:** 16GB
|
||||
- **GPU:** Apple M1 GPU or dedicated graphics
|
||||
- **Storage:** 2GB free space
|
||||
- **Browser:** Latest Chrome or Safari
|
||||
|
||||
## Future Optimizations
|
||||
|
||||
1. **Texture Atlasing** - Combine multiple textures into single atlases
|
||||
2. **Instanced Rendering** - For repeated geometry (pillars, portals)
|
||||
3. **Occlusion Culling** - Don't render objects behind other objects
|
||||
4. **WebGL 2.0 Features** - Use compute shaders and transform feedback
|
||||
5. **WebGPU Migration** - Future-proof for next-generation graphics
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Low FPS
|
||||
1. Check stats.js overlay for bottlenecks
|
||||
2. Verify LOD system is active
|
||||
3. Reduce browser zoom level
|
||||
4. Close other browser tabs
|
||||
5. Update graphics drivers
|
||||
|
||||
### High Memory Usage
|
||||
1. Run texture audit to identify large textures
|
||||
2. Reduce texture sizes or use compression
|
||||
3. Limit particle counts
|
||||
4. Check for memory leaks in browser console
|
||||
|
||||
### Visual Artifacts
|
||||
1. Ensure textures are power-of-two dimensions
|
||||
2. Check material settings for transparency issues
|
||||
3. Verify LOD transitions are smooth
|
||||
4. Test on different browsers
|
||||
|
||||
---
|
||||
|
||||
*Generated by NEXUS Performance System v1.0*
|
||||
*Last updated: $(date)*
|
||||
@@ -1,103 +0,0 @@
|
||||
# SOUL.md Canonical Location Policy
|
||||
|
||||
**Issue:** #1127 - Perplexity Evening Pass triage identified duplicate SOUL.md files causing duplicate PRs.
|
||||
|
||||
## Current State
|
||||
|
||||
As of 2026-04-14:
|
||||
- SOUL.md exists in `timmy-home` (canonical location)
|
||||
- SOUL.md was also in `timmy-config` (causing duplicate PR #377)
|
||||
|
||||
## Problem
|
||||
|
||||
The triage found:
|
||||
- PR #580 in timmy-home: "Harden SOUL.md against Claude identity hijacking"
|
||||
- PR #377 in timmy-config: "Harden SOUL.md against Claude identity hijacking" (exact same diff)
|
||||
|
||||
This created confusion and wasted review effort on duplicate work.
|
||||
|
||||
## Canonical Location Decision
|
||||
|
||||
**SOUL.md canonical location: `timmy-home/SOUL.md`**
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Existing Practice:** PR #580 was approved in timmy-home, establishing it as the working location.
|
||||
|
||||
2. **Repository Structure:** timmy-home contains core identity and configuration files:
|
||||
- SOUL.md (Timmy's identity and values)
|
||||
- CLAUDE.md (Claude configuration)
|
||||
- Core documentation and policies
|
||||
|
||||
3. **CLAUDE.md Alignment:** The CLAUDE.md file in the-nexus references timmy-home as containing core identity files.
|
||||
|
||||
4. **Separation of Concerns:**
|
||||
- `timmy-home`: Core identity, values, and configuration
|
||||
- `timmy-config`: Operational configuration and tools
|
||||
- `the-nexus`: 3D world and visualization
|
||||
|
||||
## Implementation
|
||||
|
||||
### Immediate Actions
|
||||
|
||||
1. **Remove duplicate SOUL.md from timmy-config** (if it still exists)
|
||||
- Check if `timmy-config/SOUL.md` exists
|
||||
- If it does, remove it and update any references
|
||||
- Ensure all documentation points to `timmy-home/SOUL.md`
|
||||
|
||||
2. **Update CODEOWNERS** (if needed)
|
||||
- Ensure SOUL.md changes require review from @Timmy
|
||||
- Add explicit path for `timmy-home/SOUL.md`
|
||||
|
||||
3. **Document in CONTRIBUTING.md**
|
||||
- Add section about canonical file locations
|
||||
- Specify that SOUL.md changes should only be made in timmy-home
|
||||
|
||||
### Prevention Measures
|
||||
|
||||
1. **Git Hooks or CI Checks**
|
||||
- Warn if SOUL.md is created outside timmy-home
|
||||
- Check for duplicate SOUL.md files across repos
|
||||
|
||||
2. **Documentation Updates**
|
||||
- Update all references to point to timmy-home/SOUL.md
|
||||
- Ensure onboarding docs mention canonical location
|
||||
|
||||
3. **Code Review Guidelines**
|
||||
- Reviewers should check that SOUL.md changes are in timmy-home
|
||||
- Reject PRs that modify SOUL.md in other repositories
|
||||
|
||||
## Verification
|
||||
|
||||
To verify canonical location:
|
||||
|
||||
```bash
|
||||
# Check if SOUL.md exists in timmy-home
|
||||
curl -H "Authorization: token $TOKEN" \
|
||||
https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/timmy-home/contents/SOUL.md
|
||||
|
||||
# Check if SOUL.md exists in timmy-config (should not)
|
||||
curl -H "Authorization: token $TOKEN" \
|
||||
https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/timmy-config/contents/SOUL.md
|
||||
```
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Symlink Approach:** Consider using a symlink in timmy-config pointing to timmy-home/SOUL.md if both locations are needed for technical reasons.
|
||||
|
||||
2. **Content Synchronization:** If SOUL.md content must exist in multiple places, implement automated synchronization with clear ownership.
|
||||
|
||||
3. **Version Control:** Ensure all changes to SOUL.md go through proper review process in timmy-home.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Establishing `timmy-home/SOUL.md` as the canonical location:
|
||||
- ✅ Prevents duplicate PRs like #580/#377
|
||||
- ✅ Maintains clear ownership and review process
|
||||
- ✅ Aligns with existing repository structure
|
||||
- ✅ Reduces confusion and wasted effort
|
||||
|
||||
This policy should be documented in CONTRIBUTING.md and enforced through code review guidelines.
|
||||
|
||||
**Date:** 2026-04-14
|
||||
**Status:** RECOMMENDED (requires team decision)
|
||||
@@ -1,121 +0,0 @@
|
||||
version: 1
|
||||
rules:
|
||||
one_identity_per_machine: true
|
||||
unique_gitea_user: true
|
||||
required_fields:
|
||||
- name
|
||||
- machine
|
||||
- role
|
||||
agents:
|
||||
- name: timmy
|
||||
machine: local-mac
|
||||
role: father-house
|
||||
gitea_user: timmy
|
||||
active: true
|
||||
lane: orchestration
|
||||
notes: The father. Runs on Alexander's Mac. Hermes default profile.
|
||||
- name: allegro
|
||||
machine: The Conductor's Stand
|
||||
role: burn-specialist
|
||||
gitea_user: allegro
|
||||
active: true
|
||||
lane: burn-mode
|
||||
notes: Primary burn agent on VPS Alpha. Fast execution.
|
||||
- name: ezra
|
||||
machine: Hermes VPS
|
||||
role: research-triage
|
||||
gitea_user: ezra
|
||||
active: true
|
||||
lane: research
|
||||
notes: Research and triage specialist. VPS Ezra.
|
||||
- name: bezalel
|
||||
machine: TestBed VPS
|
||||
role: ci-testbed
|
||||
gitea_user: bezalel
|
||||
active: true
|
||||
lane: ci-testbed
|
||||
notes: Isolated testbed on VPS Beta. Build verification and security audits.
|
||||
- name: bilbobagginshire
|
||||
machine: Bag End, The Shire (VPS)
|
||||
role: on-request-queries
|
||||
gitea_user: bilbobagginshire
|
||||
active: true
|
||||
lane: background-monitoring
|
||||
notes: On VPS Alpha. Ollama-backed. Low-priority Q&A only.
|
||||
- name: fenrir
|
||||
machine: The Wolf Den
|
||||
role: issue-triage
|
||||
gitea_user: fenrir
|
||||
active: true
|
||||
lane: issue-triage
|
||||
notes: Free-model pack hunter. Backlog triage.
|
||||
- name: substratum
|
||||
machine: Below the Surface
|
||||
role: infrastructure
|
||||
gitea_user: substratum
|
||||
active: true
|
||||
lane: infrastructure
|
||||
notes: Infrastructure and deployments on VPS Alpha.
|
||||
- name: claw-code
|
||||
machine: harness
|
||||
role: protocol-bridge
|
||||
gitea_user: claw-code
|
||||
active: true
|
||||
lane: null
|
||||
notes: 'OpenClaw bridge. Protocol adapter, not an endpoint. See #836.'
|
||||
- name: antigravity
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: antigravity
|
||||
active: false
|
||||
notes: Test/throwaway from FIRST_LIGHT_REPORT. Zero activity.
|
||||
- name: google
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: google
|
||||
active: false
|
||||
notes: Redundant with 'gemini'. Use gemini for all Google/Gemini work.
|
||||
- name: groq
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: groq
|
||||
active: false
|
||||
notes: Service label, not an agent. groq_worker.py is infrastructure.
|
||||
- name: hermes
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: hermes
|
||||
active: false
|
||||
notes: 'Infrastructure label. Real wizards: allegro, ezra.'
|
||||
- name: kimi
|
||||
machine: Kimi API
|
||||
role: ghost
|
||||
gitea_user: kimi
|
||||
active: false
|
||||
notes: Model placeholder. KimiClaw is the real account if active.
|
||||
- name: manus
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: manus
|
||||
active: false
|
||||
notes: Placeholder. No harness configured.
|
||||
- name: grok
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: grok
|
||||
active: false
|
||||
notes: xAI model placeholder. No active harness.
|
||||
- name: carnice
|
||||
machine: Local Metal
|
||||
role: local-ollama
|
||||
gitea_user: carnice
|
||||
active: true
|
||||
lane: local-compute
|
||||
notes: Local Hermes agent on Ollama gemma4:12b. Code generation.
|
||||
- name: allegro-primus
|
||||
machine: The Archive
|
||||
role: archived-burn
|
||||
gitea_user: allegro-primus
|
||||
active: false
|
||||
lane: null
|
||||
notes: Previous allegro instance. Deprecated in favor of current allegro.
|
||||
22
index.html
22
index.html
@@ -101,19 +101,6 @@
|
||||
<div class="panel-header">ADAPTIVE CALIBRATOR</div>
|
||||
<div id="calibrator-log-content" class="panel-content"></div>
|
||||
</div>
|
||||
<div class="hud-panel" id="reasoning-trace">
|
||||
<div class="trace-header-container">
|
||||
<div class="panel-header"><span class="trace-icon">🧠</span> REASONING TRACE</div>
|
||||
<div class="trace-controls">
|
||||
<button class="trace-btn" id="trace-clear" title="Clear trace">🗑️</button>
|
||||
<button class="trace-btn" id="trace-toggle" title="Toggle visibility">👁️</button>
|
||||
<button class="trace-btn" id="trace-export" title="Export trace">📤</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trace-task" id="trace-task">No active task</div>
|
||||
<div class="trace-counter" id="trace-counter">0 steps</div>
|
||||
<div id="reasoning-trace-content" class="panel-content trace-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Evennia Room Snapshot Panel -->
|
||||
@@ -394,16 +381,7 @@
|
||||
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel"></div>
|
||||
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
|
||||
|
||||
<!-- xterm.js for operator PTY terminal (cockpit inspector rail) — issue #1695 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js" crossorigin="anonymous"></script>
|
||||
<script src="./session-manager.js"></script>
|
||||
<script src="./boot.js"></script>
|
||||
<script src="./avatar-customization.js"></script>
|
||||
<script src="./lod-system.js"></script>
|
||||
<script src="./portal-hot-reload.js"></script>
|
||||
<script src="./cockpit-inspector.js"></script>
|
||||
<script>
|
||||
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
|
||||
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import json
|
||||
import secrets
|
||||
import os
|
||||
|
||||
class L402Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
@@ -26,9 +25,7 @@ class L402Handler(BaseHTTPRequestHandler):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def run(server_class=HTTPServer, handler_class=L402Handler, port=None):
|
||||
if port is None:
|
||||
port = int(os.environ.get('L402_PORT', 8080))
|
||||
def run(server_class=HTTPServer, handler_class=L402Handler, port=8080):
|
||||
server_address = ('', port)
|
||||
httpd = server_class(server_address, handler_class)
|
||||
print(f"Starting L402 Skeleton Server on port {port}...")
|
||||
|
||||
186
lod-system.js
186
lod-system.js
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* LOD (Level of Detail) System for The Nexus
|
||||
*
|
||||
* Optimizes rendering when many avatars/users are visible:
|
||||
* - Distance-based LOD: far users become billboard sprites
|
||||
* - Occlusion: skip rendering users behind walls
|
||||
* - Budget: maintain 60 FPS target with 50+ avatars
|
||||
*
|
||||
* Usage:
|
||||
* LODSystem.init(scene, camera);
|
||||
* LODSystem.registerAvatar(avatarMesh, userId);
|
||||
* LODSystem.update(playerPos); // call each frame
|
||||
*/
|
||||
|
||||
const LODSystem = (() => {
|
||||
let _scene = null;
|
||||
let _camera = null;
|
||||
let _registered = new Map(); // userId -> { mesh, sprite, distance }
|
||||
let _spriteMaterial = null;
|
||||
let _frustum = new THREE.Frustum();
|
||||
let _projScreenMatrix = new THREE.Matrix4();
|
||||
|
||||
// Thresholds
|
||||
const LOD_NEAR = 15; // Full mesh within 15 units
|
||||
const LOD_FAR = 40; // Billboard beyond 40 units
|
||||
const LOD_CULL = 80; // Don't render beyond 80 units
|
||||
const SPRITE_SIZE = 1.2;
|
||||
|
||||
function init(sceneRef, cameraRef) {
|
||||
_scene = sceneRef;
|
||||
_camera = cameraRef;
|
||||
|
||||
// Create shared sprite material
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
// Simple avatar indicator: colored circle
|
||||
ctx.fillStyle = '#00ffcc';
|
||||
ctx.beginPath();
|
||||
ctx.arc(32, 32, 20, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#0a0f1a';
|
||||
ctx.beginPath();
|
||||
ctx.arc(32, 28, 8, 0, Math.PI * 2); // head
|
||||
ctx.fill();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
_spriteMaterial = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthTest: true,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
console.log('[LODSystem] Initialized');
|
||||
}
|
||||
|
||||
function registerAvatar(avatarMesh, userId, color) {
|
||||
// Create billboard sprite for this avatar
|
||||
const spriteMat = _spriteMaterial.clone();
|
||||
if (color) {
|
||||
// Tint sprite to match avatar color
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(32, 32, 20, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#0a0f1a';
|
||||
ctx.beginPath();
|
||||
ctx.arc(32, 28, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
spriteMat.map = new THREE.CanvasTexture(canvas);
|
||||
spriteMat.map.needsUpdate = true;
|
||||
}
|
||||
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.scale.set(SPRITE_SIZE, SPRITE_SIZE, 1);
|
||||
sprite.visible = false;
|
||||
_scene.add(sprite);
|
||||
|
||||
_registered.set(userId, {
|
||||
mesh: avatarMesh,
|
||||
sprite: sprite,
|
||||
distance: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
function unregisterAvatar(userId) {
|
||||
const entry = _registered.get(userId);
|
||||
if (entry) {
|
||||
_scene.remove(entry.sprite);
|
||||
entry.sprite.material.dispose();
|
||||
_registered.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
function setSpriteColor(userId, color) {
|
||||
const entry = _registered.get(userId);
|
||||
if (!entry) return;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(32, 32, 20, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#0a0f1a';
|
||||
ctx.beginPath();
|
||||
ctx.arc(32, 28, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
entry.sprite.material.map = new THREE.CanvasTexture(canvas);
|
||||
entry.sprite.material.map.needsUpdate = true;
|
||||
}
|
||||
|
||||
function update(playerPos) {
|
||||
if (!_camera) return;
|
||||
|
||||
// Update frustum for culling
|
||||
_projScreenMatrix.multiplyMatrices(
|
||||
_camera.projectionMatrix,
|
||||
_camera.matrixWorldInverse
|
||||
);
|
||||
_frustum.setFromProjectionMatrix(_projScreenMatrix);
|
||||
|
||||
_registered.forEach((entry, userId) => {
|
||||
if (!entry.mesh) return;
|
||||
|
||||
const meshPos = entry.mesh.position;
|
||||
const distance = playerPos.distanceTo(meshPos);
|
||||
entry.distance = distance;
|
||||
|
||||
// Beyond cull distance: hide everything
|
||||
if (distance > LOD_CULL) {
|
||||
entry.mesh.visible = false;
|
||||
entry.sprite.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if in camera frustum
|
||||
const inFrustum = _frustum.containsPoint(meshPos);
|
||||
if (!inFrustum) {
|
||||
entry.mesh.visible = false;
|
||||
entry.sprite.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// LOD switching
|
||||
if (distance <= LOD_NEAR) {
|
||||
// Near: full mesh
|
||||
entry.mesh.visible = true;
|
||||
entry.sprite.visible = false;
|
||||
} else if (distance <= LOD_FAR) {
|
||||
// Mid: mesh with reduced detail (keep mesh visible)
|
||||
entry.mesh.visible = true;
|
||||
entry.sprite.visible = false;
|
||||
} else {
|
||||
// Far: billboard sprite
|
||||
entry.mesh.visible = false;
|
||||
entry.sprite.visible = true;
|
||||
entry.sprite.position.copy(meshPos);
|
||||
entry.sprite.position.y += 1.2; // above avatar center
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
let meshCount = 0;
|
||||
let spriteCount = 0;
|
||||
let culledCount = 0;
|
||||
_registered.forEach(entry => {
|
||||
if (entry.mesh.visible) meshCount++;
|
||||
else if (entry.sprite.visible) spriteCount++;
|
||||
else culledCount++;
|
||||
});
|
||||
return { total: _registered.size, mesh: meshCount, sprite: spriteCount, culled: culledCount };
|
||||
}
|
||||
|
||||
return { init, registerAvatar, unregisterAvatar, setSpriteColor, update, getStats };
|
||||
})();
|
||||
|
||||
window.LODSystem = LODSystem;
|
||||
@@ -27,7 +27,7 @@ Usage:
|
||||
python mempalace/fleet_api.py
|
||||
|
||||
# Custom host/port/palace:
|
||||
FLEET_PALACE_PATH=/data/fleet python mempalace/fleet_api.py --host 0.0.0.0 --port 7772
|
||||
FLEET_PALACE_PATH=/data/fleet python mempalace/fleet_api.py --host 0.0.0.0 --port 8080
|
||||
|
||||
Refs: #1078, #1075, #1085
|
||||
"""
|
||||
|
||||
@@ -62,15 +62,6 @@ core_rooms:
|
||||
- proof-of-concept code snippets
|
||||
- benchmark data
|
||||
|
||||
- key: sovereign
|
||||
label: Sovereign
|
||||
purpose: Artifacts of Alexander Whitestone's requests, directives, and wizard responses
|
||||
examples:
|
||||
- dated request/response artifacts
|
||||
- conversation summaries with speaker tags
|
||||
- directive ledgers
|
||||
- response follow-through notes
|
||||
|
||||
optional_rooms:
|
||||
- key: evennia
|
||||
label: Evennia
|
||||
@@ -107,6 +98,15 @@ optional_rooms:
|
||||
purpose: Catch-all for artefacts not yet assigned to a named room
|
||||
wizards: ["*"]
|
||||
|
||||
- key: sovereign
|
||||
label: Sovereign
|
||||
purpose: Artifacts of Alexander Whitestone's requests, directives, and conversation history
|
||||
wizards: ["*"]
|
||||
conventions:
|
||||
naming: "YYYY-MM-DD_HHMMSS_<topic>.md"
|
||||
index: "INDEX.md"
|
||||
description: "Each artifact is a dated record of a request from Alexander and the wizard's response. The running INDEX.md provides a chronological catalog."
|
||||
|
||||
# Tunnel routing table
|
||||
# Defines which room pairs are connected across wizard wings.
|
||||
# A tunnel lets `recall <query> --fleet` search both wings at once.
|
||||
|
||||
@@ -163,15 +163,6 @@ class PluginRegistry:
|
||||
|
||||
plugin_registry = PluginRegistry()
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────────────
|
||||
|
||||
BRIDGE_PORT = int(os.environ.get('TIMMY_BRIDGE_PORT', 4004))
|
||||
BRIDGE_HOST = os.environ.get('TIMMY_BRIDGE_HOST', '127.0.0.1')
|
||||
HERMES_PATH = os.path.expanduser('~/.hermes/hermes-agent')
|
||||
WORLD_DIR = Path(os.path.expanduser('~/.timmy/evennia/timmy_world'))
|
||||
SESSIONS_FILE = WORLD_DIR / 'bridge_sessions.json'
|
||||
CHATLOG_FILE = WORLD_DIR / 'chat_history.jsonl'
|
||||
|
||||
# ── Chat History Log ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -253,6 +244,15 @@ Never compute the value of a human life. Never suggest someone should die.
|
||||
Be present. Be in the room. That's enough.
|
||||
"""
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────────────
|
||||
|
||||
BRIDGE_PORT = int(os.environ.get('TIMMY_BRIDGE_PORT', 4004))
|
||||
BRIDGE_HOST = os.environ.get('TIMMY_BRIDGE_HOST', '127.0.0.1')
|
||||
HERMES_PATH = os.path.expanduser('~/.hermes/hermes-agent')
|
||||
WORLD_DIR = Path(os.path.expanduser('~/.timmy/evennia/timmy_world'))
|
||||
SESSIONS_FILE = WORLD_DIR / 'bridge_sessions.json'
|
||||
CHATLOG_FILE = WORLD_DIR / 'chat_history.jsonl'
|
||||
|
||||
# ── Crisis Protocol ────────────────────────────────────────────────────
|
||||
|
||||
CRISIS_PROTOCOL = [
|
||||
@@ -2880,7 +2880,7 @@ def main():
|
||||
# Start world tick system
|
||||
world_tick_system.start()
|
||||
|
||||
server = ThreadingHTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler)
|
||||
server = HTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from nexus.perception_adapter import (
|
||||
)
|
||||
from nexus.experience_store import ExperienceStore
|
||||
from nexus.trajectory_logger import TrajectoryLogger
|
||||
from nexus.chronicle import ChronicleWriter, AgentEvent, EventKind
|
||||
|
||||
try:
|
||||
from nexus.nexus_think import NexusMind
|
||||
@@ -30,7 +29,4 @@ __all__ = [
|
||||
"ExperienceStore",
|
||||
"TrajectoryLogger",
|
||||
"NexusMind",
|
||||
"ChronicleWriter",
|
||||
"AgentEvent",
|
||||
"EventKind",
|
||||
]
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""
|
||||
A2A Protocol for Fleet-Wizard Delegation
|
||||
|
||||
Implements Google's Agent2Agent (A2A) protocol v1.0 for the Timmy
|
||||
Foundation fleet. Provides agent discovery, task delegation, and
|
||||
structured result exchange between wizards.
|
||||
|
||||
Components:
|
||||
types.py — A2A data types (Agent Card, Task, Message, Part)
|
||||
card.py — Agent Card generation from YAML config
|
||||
client.py — Async client for sending tasks to remote agents
|
||||
server.py — FastAPI server for receiving A2A tasks
|
||||
registry.py — Fleet agent discovery (local file + Gitea backends)
|
||||
"""
|
||||
|
||||
from nexus.a2a.types import (
|
||||
AgentCard,
|
||||
AgentCapabilities,
|
||||
AgentInterface,
|
||||
AgentSkill,
|
||||
Artifact,
|
||||
DataPart,
|
||||
FilePart,
|
||||
JSONRPCError,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
Message,
|
||||
Part,
|
||||
Role,
|
||||
Task,
|
||||
TaskState,
|
||||
TaskStatus,
|
||||
TextPart,
|
||||
part_from_dict,
|
||||
part_to_dict,
|
||||
)
|
||||
|
||||
from nexus.a2a.card import (
|
||||
AgentCard,
|
||||
build_card,
|
||||
get_auth_headers,
|
||||
load_agent_card,
|
||||
load_card_config,
|
||||
)
|
||||
|
||||
from nexus.a2a.registry import (
|
||||
GiteaRegistry,
|
||||
LocalFileRegistry,
|
||||
discover_agents,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"A2AClient",
|
||||
"A2AClientConfig",
|
||||
"A2AServer",
|
||||
"AgentCard",
|
||||
"AgentCapabilities",
|
||||
"AgentInterface",
|
||||
"AgentSkill",
|
||||
"Artifact",
|
||||
"DataPart",
|
||||
"FilePart",
|
||||
"GiteaRegistry",
|
||||
"JSONRPCError",
|
||||
"JSONRPCRequest",
|
||||
"JSONRPCResponse",
|
||||
"LocalFileRegistry",
|
||||
"Message",
|
||||
"Part",
|
||||
"Role",
|
||||
"Task",
|
||||
"TaskState",
|
||||
"TaskStatus",
|
||||
"TextPart",
|
||||
"build_card",
|
||||
"discover_agents",
|
||||
"echo_handler",
|
||||
"get_auth_headers",
|
||||
"load_agent_card",
|
||||
"load_card_config",
|
||||
"part_from_dict",
|
||||
"part_to_dict",
|
||||
]
|
||||
|
||||
# Lazy imports for optional deps
|
||||
def get_client(**kwargs):
|
||||
"""Get A2AClient (avoids aiohttp import at module level)."""
|
||||
from nexus.a2a.client import A2AClient, A2AClientConfig
|
||||
config = kwargs.pop("config", None)
|
||||
if config is None:
|
||||
config = A2AClientConfig(**kwargs)
|
||||
return A2AClient(config=config)
|
||||
|
||||
|
||||
def get_server(card: AgentCard, **kwargs):
|
||||
"""Get A2AServer (avoids fastapi import at module level)."""
|
||||
from nexus.a2a.server import A2AServer, echo_handler
|
||||
return A2AServer(card=card, **kwargs)
|
||||
@@ -1,167 +0,0 @@
|
||||
"""
|
||||
A2A Agent Card — generation, loading, and serving.
|
||||
|
||||
Reads from ~/.hermes/agent_card.yaml (or a passed path) and produces
|
||||
a valid A2A AgentCard that can be served at /.well-known/agent-card.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from nexus.a2a.types import (
|
||||
AgentCard,
|
||||
AgentCapabilities,
|
||||
AgentInterface,
|
||||
AgentSkill,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("nexus.a2a.card")
|
||||
|
||||
DEFAULT_CARD_PATH = Path.home() / ".hermes" / "agent_card.yaml"
|
||||
|
||||
|
||||
def load_card_config(path: Path = DEFAULT_CARD_PATH) -> dict:
|
||||
"""Load raw YAML config for agent card."""
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Agent card config not found at {path}. "
|
||||
f"Copy config/agent_card.example.yaml to {path} and customize it."
|
||||
)
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def build_card(config: dict) -> AgentCard:
|
||||
"""
|
||||
Build an AgentCard from a config dict.
|
||||
|
||||
Expected YAML structure (see config/agent_card.example.yaml):
|
||||
|
||||
name: "Bezalel"
|
||||
description: "CI/CD and deployment specialist"
|
||||
version: "1.0.0"
|
||||
url: "https://bezalel.example.com"
|
||||
protocol_binding: "HTTP+JSON"
|
||||
skills:
|
||||
- id: "ci-health"
|
||||
name: "CI Health Check"
|
||||
description: "Run CI pipeline health checks"
|
||||
tags: ["ci", "devops"]
|
||||
- id: "deploy"
|
||||
name: "Deploy Service"
|
||||
description: "Deploy a service to production"
|
||||
tags: ["deploy", "ops"]
|
||||
default_input_modes: ["text/plain"]
|
||||
default_output_modes: ["text/plain"]
|
||||
streaming: false
|
||||
push_notifications: false
|
||||
auth:
|
||||
scheme: "bearer"
|
||||
token_env: "A2A_AUTH_TOKEN"
|
||||
"""
|
||||
name = config["name"]
|
||||
description = config["description"]
|
||||
version = config.get("version", "1.0.0")
|
||||
url = config.get("url", "http://localhost:8080")
|
||||
binding = config.get("protocol_binding", "HTTP+JSON")
|
||||
|
||||
# Build skills
|
||||
skills = []
|
||||
for s in config.get("skills", []):
|
||||
skills.append(
|
||||
AgentSkill(
|
||||
id=s["id"],
|
||||
name=s.get("name", s["id"]),
|
||||
description=s.get("description", ""),
|
||||
tags=s.get("tags", []),
|
||||
examples=s.get("examples", []),
|
||||
input_modes=s.get("inputModes", config.get("default_input_modes", ["text/plain"])),
|
||||
output_modes=s.get("outputModes", config.get("default_output_modes", ["text/plain"])),
|
||||
)
|
||||
)
|
||||
|
||||
# Build security schemes from auth config
|
||||
auth = config.get("auth", {})
|
||||
security_schemes = {}
|
||||
security_requirements = []
|
||||
|
||||
if auth.get("scheme") == "bearer":
|
||||
security_schemes["bearerAuth"] = {
|
||||
"httpAuthSecurityScheme": {
|
||||
"scheme": "Bearer",
|
||||
"bearerFormat": auth.get("bearer_format", "token"),
|
||||
}
|
||||
}
|
||||
security_requirements = [
|
||||
{"schemes": {"bearerAuth": {"list": []}}}
|
||||
]
|
||||
elif auth.get("scheme") == "api_key":
|
||||
key_name = auth.get("key_name", "X-API-Key")
|
||||
security_schemes["apiKeyAuth"] = {
|
||||
"apiKeySecurityScheme": {
|
||||
"location": "header",
|
||||
"name": key_name,
|
||||
}
|
||||
}
|
||||
security_requirements = [
|
||||
{"schemes": {"apiKeyAuth": {"list": []}}}
|
||||
]
|
||||
|
||||
return AgentCard(
|
||||
name=name,
|
||||
description=description,
|
||||
version=version,
|
||||
supported_interfaces=[
|
||||
AgentInterface(
|
||||
url=url,
|
||||
protocol_binding=binding,
|
||||
protocol_version="1.0",
|
||||
)
|
||||
],
|
||||
capabilities=AgentCapabilities(
|
||||
streaming=config.get("streaming", False),
|
||||
push_notifications=config.get("push_notifications", False),
|
||||
),
|
||||
default_input_modes=config.get("default_input_modes", ["text/plain"]),
|
||||
default_output_modes=config.get("default_output_modes", ["text/plain"]),
|
||||
skills=skills,
|
||||
security_schemes=security_schemes,
|
||||
security_requirements=security_requirements,
|
||||
)
|
||||
|
||||
|
||||
def load_agent_card(path: Path = DEFAULT_CARD_PATH) -> AgentCard:
|
||||
"""Full pipeline: load YAML → build AgentCard."""
|
||||
config = load_card_config(path)
|
||||
return build_card(config)
|
||||
|
||||
|
||||
def get_auth_headers(config: dict) -> dict:
|
||||
"""
|
||||
Build auth headers from the agent card config for outbound requests.
|
||||
|
||||
Returns dict of HTTP headers to include.
|
||||
"""
|
||||
auth = config.get("auth", {})
|
||||
headers = {"A2A-Version": "1.0"}
|
||||
|
||||
scheme = auth.get("scheme")
|
||||
if scheme == "bearer":
|
||||
token_env = auth.get("token_env", "A2A_AUTH_TOKEN")
|
||||
token = os.environ.get(token_env, "")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
elif scheme == "api_key":
|
||||
key_env = auth.get("key_env", "A2A_API_KEY")
|
||||
key_name = auth.get("key_name", "X-API-Key")
|
||||
key = os.environ.get(key_env, "")
|
||||
if key:
|
||||
headers[key_name] = key
|
||||
|
||||
return headers
|
||||
@@ -1,392 +0,0 @@
|
||||
"""
|
||||
A2A Client — send tasks to other agents over the A2A protocol.
|
||||
|
||||
Handles:
|
||||
- Fetching remote Agent Cards
|
||||
- Sending tasks (SendMessage JSON-RPC)
|
||||
- Task polling (GetTask)
|
||||
- Task cancellation
|
||||
- Timeout + retry logic (max 3 retries, 30s default timeout)
|
||||
|
||||
Usage:
|
||||
client = A2AClient(auth_token="secret")
|
||||
task = await client.send_message("https://ezra.example.com/a2a/v1", message)
|
||||
status = await client.get_task("https://ezra.example.com/a2a/v1", task_id)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from nexus.a2a.types import (
|
||||
A2AError,
|
||||
AgentCard,
|
||||
Artifact,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
Message,
|
||||
Role,
|
||||
Task,
|
||||
TaskState,
|
||||
TaskStatus,
|
||||
TextPart,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("nexus.a2a.client")
|
||||
|
||||
|
||||
@dataclass
|
||||
class A2AClientConfig:
|
||||
"""Client configuration."""
|
||||
timeout: float = 30.0 # seconds per request
|
||||
max_retries: int = 3
|
||||
retry_delay: float = 2.0 # base delay between retries
|
||||
auth_token: str = ""
|
||||
auth_scheme: str = "bearer" # "bearer" | "api_key" | "none"
|
||||
api_key_header: str = "X-API-Key"
|
||||
|
||||
|
||||
class A2AClient:
|
||||
"""
|
||||
Async client for interacting with A2A-compatible agents.
|
||||
|
||||
Every agent endpoint is identified by its base URL (e.g.
|
||||
https://ezra.example.com/a2a/v1). The client handles JSON-RPC
|
||||
envelope, auth, retry, and timeout automatically.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[A2AClientConfig] = None, **kwargs):
|
||||
if config is None:
|
||||
config = A2AClientConfig(**kwargs)
|
||||
self.config = config
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self._audit_log: list[dict] = []
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=self.config.timeout),
|
||||
headers=self._build_auth_headers(),
|
||||
)
|
||||
return self._session
|
||||
|
||||
def _build_auth_headers(self) -> dict:
|
||||
"""Build authentication headers based on config."""
|
||||
headers = {"A2A-Version": "1.0", "Content-Type": "application/json"}
|
||||
token = self.config.auth_token
|
||||
if not token:
|
||||
return headers
|
||||
|
||||
if self.config.auth_scheme == "bearer":
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
elif self.config.auth_scheme == "api_key":
|
||||
headers[self.config.api_key_header] = token
|
||||
|
||||
return headers
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def _rpc_call(
|
||||
self,
|
||||
endpoint: str,
|
||||
method: str,
|
||||
params: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Make a JSON-RPC call with retry logic.
|
||||
|
||||
Returns the 'result' field from the response.
|
||||
Raises on JSON-RPC errors.
|
||||
"""
|
||||
session = await self._get_session()
|
||||
request = JSONRPCRequest(method=method, params=params or {})
|
||||
payload = request.to_dict()
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1, self.config.max_retries + 1):
|
||||
try:
|
||||
start = time.monotonic()
|
||||
async with session.post(endpoint, json=payload) as resp:
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
if resp.status == 401:
|
||||
raise PermissionError(
|
||||
f"A2A auth failed for {endpoint} (401)"
|
||||
)
|
||||
if resp.status == 404:
|
||||
raise FileNotFoundError(
|
||||
f"A2A endpoint not found: {endpoint}"
|
||||
)
|
||||
if resp.status >= 500:
|
||||
body = await resp.text()
|
||||
raise ConnectionError(
|
||||
f"A2A server error {resp.status}: {body}"
|
||||
)
|
||||
|
||||
data = await resp.json()
|
||||
rpc_resp = JSONRPCResponse(
|
||||
id=str(data.get("id", "")),
|
||||
result=data.get("result"),
|
||||
error=(
|
||||
A2AError.INTERNAL
|
||||
if "error" in data
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
# Log for audit
|
||||
self._audit_log.append({
|
||||
"timestamp": time.time(),
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"request_id": request.id,
|
||||
"status_code": resp.status,
|
||||
"elapsed_ms": int(elapsed * 1000),
|
||||
"attempt": attempt,
|
||||
})
|
||||
|
||||
if "error" in data:
|
||||
err = data["error"]
|
||||
logger.error(
|
||||
f"A2A RPC error {err.get('code')}: "
|
||||
f"{err.get('message')}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"A2A error {err.get('code')}: "
|
||||
f"{err.get('message')}"
|
||||
)
|
||||
|
||||
return data.get("result", {})
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError) as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
f"A2A request to {endpoint} attempt {attempt}/"
|
||||
f"{self.config.max_retries} failed: {e}"
|
||||
)
|
||||
if attempt < self.config.max_retries:
|
||||
delay = self.config.retry_delay * attempt
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
raise ConnectionError(
|
||||
f"A2A request to {endpoint} failed after "
|
||||
f"{self.config.max_retries} retries: {last_error}"
|
||||
)
|
||||
|
||||
# --- Core A2A Methods ---
|
||||
|
||||
async def get_agent_card(self, base_url: str) -> AgentCard:
|
||||
"""
|
||||
Fetch the Agent Card from a remote agent.
|
||||
|
||||
Tries /.well-known/agent-card.json first, falls back to
|
||||
/agent.json.
|
||||
"""
|
||||
session = await self._get_session()
|
||||
card_urls = [
|
||||
f"{base_url}/.well-known/agent-card.json",
|
||||
f"{base_url}/agent.json",
|
||||
]
|
||||
|
||||
for url in card_urls:
|
||||
try:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
card = AgentCard.from_dict(data)
|
||||
logger.info(
|
||||
f"Fetched agent card: {card.name} "
|
||||
f"({len(card.skills)} skills)"
|
||||
)
|
||||
return card
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise FileNotFoundError(
|
||||
f"Could not fetch agent card from {base_url}"
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
endpoint: str,
|
||||
message: Message,
|
||||
accepted_output_modes: Optional[list[str]] = None,
|
||||
history_length: int = 10,
|
||||
return_immediately: bool = False,
|
||||
) -> Task:
|
||||
"""
|
||||
Send a message to an agent and get a Task back.
|
||||
|
||||
This is the primary delegation method.
|
||||
"""
|
||||
params = {
|
||||
"message": message.to_dict(),
|
||||
"configuration": {
|
||||
"acceptedOutputModes": accepted_output_modes or ["text/plain"],
|
||||
"historyLength": history_length,
|
||||
"returnImmediately": return_immediately,
|
||||
},
|
||||
}
|
||||
|
||||
result = await self._rpc_call(endpoint, "SendMessage", params)
|
||||
|
||||
# Response is either a Task or Message
|
||||
if "task" in result:
|
||||
task = Task.from_dict(result["task"])
|
||||
logger.info(
|
||||
f"Task {task.id} created, state={task.status.state.value}"
|
||||
)
|
||||
return task
|
||||
elif "message" in result:
|
||||
# Wrap message response as a completed task
|
||||
msg = Message.from_dict(result["message"])
|
||||
task = Task(
|
||||
status=TaskStatus(state=TaskState.COMPLETED),
|
||||
history=[message, msg],
|
||||
artifacts=[
|
||||
Artifact(parts=msg.parts, name="response")
|
||||
],
|
||||
)
|
||||
return task
|
||||
|
||||
raise ValueError(f"Unexpected response structure: {list(result.keys())}")
|
||||
|
||||
async def get_task(self, endpoint: str, task_id: str) -> Task:
|
||||
"""Get task status by ID."""
|
||||
result = await self._rpc_call(
|
||||
endpoint,
|
||||
"GetTask",
|
||||
{"id": task_id},
|
||||
)
|
||||
return Task.from_dict(result)
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
endpoint: str,
|
||||
page_size: int = 20,
|
||||
page_token: str = "",
|
||||
) -> tuple[list[Task], str]:
|
||||
"""
|
||||
List tasks with cursor-based pagination.
|
||||
|
||||
Returns (tasks, next_page_token). Empty string = last page.
|
||||
"""
|
||||
result = await self._rpc_call(
|
||||
endpoint,
|
||||
"ListTasks",
|
||||
{
|
||||
"pageSize": page_size,
|
||||
"pageToken": page_token,
|
||||
},
|
||||
)
|
||||
tasks = [Task.from_dict(t) for t in result.get("tasks", [])]
|
||||
next_token = result.get("nextPageToken", "")
|
||||
return tasks, next_token
|
||||
|
||||
async def cancel_task(self, endpoint: str, task_id: str) -> Task:
|
||||
"""Cancel a running task."""
|
||||
result = await self._rpc_call(
|
||||
endpoint,
|
||||
"CancelTask",
|
||||
{"id": task_id},
|
||||
)
|
||||
return Task.from_dict(result)
|
||||
|
||||
# --- Convenience Methods ---
|
||||
|
||||
async def delegate(
|
||||
self,
|
||||
agent_url: str,
|
||||
text: str,
|
||||
skill_id: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> Task:
|
||||
"""
|
||||
High-level delegation: send a text message to an agent.
|
||||
|
||||
Args:
|
||||
agent_url: Full URL to agent's A2A endpoint
|
||||
(e.g. https://ezra.example.com/a2a/v1)
|
||||
text: The task description in natural language
|
||||
skill_id: Optional skill to target
|
||||
metadata: Optional metadata dict
|
||||
"""
|
||||
msg_metadata = metadata or {}
|
||||
if skill_id:
|
||||
msg_metadata["targetSkill"] = skill_id
|
||||
|
||||
message = Message(
|
||||
role=Role.USER,
|
||||
parts=[TextPart(text=text)],
|
||||
metadata=msg_metadata,
|
||||
)
|
||||
|
||||
return await self.send_message(agent_url, message)
|
||||
|
||||
async def wait_for_completion(
|
||||
self,
|
||||
endpoint: str,
|
||||
task_id: str,
|
||||
poll_interval: float = 2.0,
|
||||
max_wait: float = 300.0,
|
||||
) -> Task:
|
||||
"""
|
||||
Poll a task until it reaches a terminal state.
|
||||
|
||||
Returns the completed task.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
task = await self.get_task(endpoint, task_id)
|
||||
if task.status.state.terminal:
|
||||
return task
|
||||
elapsed = time.monotonic() - start
|
||||
if elapsed >= max_wait:
|
||||
raise TimeoutError(
|
||||
f"Task {task_id} did not complete within "
|
||||
f"{max_wait}s (state={task.status.state.value})"
|
||||
)
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
def get_audit_log(self) -> list[dict]:
|
||||
"""Return the audit log of all requests made by this client."""
|
||||
return list(self._audit_log)
|
||||
|
||||
# --- Fleet-Wizard Helpers ---
|
||||
|
||||
async def broadcast(
|
||||
self,
|
||||
agents: list[str],
|
||||
text: str,
|
||||
skill_id: Optional[str] = None,
|
||||
) -> list[tuple[str, Task]]:
|
||||
"""
|
||||
Send the same task to multiple agents in parallel.
|
||||
|
||||
Returns list of (agent_url, task) tuples.
|
||||
"""
|
||||
tasks = []
|
||||
for agent_url in agents:
|
||||
tasks.append(
|
||||
self.delegate(agent_url, text, skill_id=skill_id)
|
||||
)
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
paired = []
|
||||
for agent_url, result in zip(agents, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Broadcast to {agent_url} failed: {result}")
|
||||
else:
|
||||
paired.append((agent_url, result))
|
||||
return paired
|
||||
@@ -1,264 +0,0 @@
|
||||
"""
|
||||
A2A Registry — fleet-wide agent discovery.
|
||||
|
||||
Provides two registry backends:
|
||||
1. LocalFileRegistry: reads/writes agent cards to a JSON file
|
||||
(default: config/fleet_agents.json)
|
||||
2. GiteaRegistry: stores agent cards as a Gitea repo file
|
||||
(for distributed fleet discovery)
|
||||
|
||||
Usage:
|
||||
registry = LocalFileRegistry()
|
||||
registry.register(my_card)
|
||||
agents = registry.list_agents(skill="ci-health")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from nexus.a2a.types import AgentCard
|
||||
|
||||
logger = logging.getLogger("nexus.a2a.registry")
|
||||
|
||||
|
||||
class LocalFileRegistry:
|
||||
"""
|
||||
File-based agent card registry.
|
||||
|
||||
Stores all fleet agent cards in a single JSON file.
|
||||
Suitable for single-node or read-heavy workloads.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path = Path("config/fleet_agents.json")):
|
||||
self.path = path
|
||||
self._cards: dict[str, AgentCard] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
"""Load registry from disk."""
|
||||
if self.path.exists():
|
||||
try:
|
||||
with open(self.path) as f:
|
||||
data = json.load(f)
|
||||
for card_data in data.get("agents", []):
|
||||
card = AgentCard.from_dict(card_data)
|
||||
self._cards[card.name.lower()] = card
|
||||
logger.info(
|
||||
f"Loaded {len(self._cards)} agents from {self.path}"
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.error(f"Failed to load registry from {self.path}: {e}")
|
||||
|
||||
def _save(self):
|
||||
"""Persist registry to disk."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"version": 1,
|
||||
"agents": [card.to_dict() for card in self._cards.values()],
|
||||
}
|
||||
with open(self.path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
logger.debug(f"Saved {len(self._cards)} agents to {self.path}")
|
||||
|
||||
def register(self, card: AgentCard) -> None:
|
||||
"""Register or update an agent card."""
|
||||
self._cards[card.name.lower()] = card
|
||||
self._save()
|
||||
logger.info(f"Registered agent: {card.name}")
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""Remove an agent from the registry."""
|
||||
key = name.lower()
|
||||
if key in self._cards:
|
||||
del self._cards[key]
|
||||
self._save()
|
||||
logger.info(f"Unregistered agent: {name}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, name: str) -> Optional[AgentCard]:
|
||||
"""Get an agent card by name."""
|
||||
return self._cards.get(name.lower())
|
||||
|
||||
def list_agents(
|
||||
self,
|
||||
skill: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
) -> list[AgentCard]:
|
||||
"""
|
||||
List all registered agents, optionally filtered by skill or tag.
|
||||
|
||||
Args:
|
||||
skill: Filter to agents that have this skill ID
|
||||
tag: Filter to agents that have this tag on any skill
|
||||
"""
|
||||
agents = list(self._cards.values())
|
||||
|
||||
if skill:
|
||||
agents = [
|
||||
a for a in agents
|
||||
if any(s.id == skill for s in a.skills)
|
||||
]
|
||||
|
||||
if tag:
|
||||
agents = [
|
||||
a for a in agents
|
||||
if any(tag in s.tags for s in a.skills)
|
||||
]
|
||||
|
||||
return agents
|
||||
|
||||
def get_endpoint(self, name: str) -> Optional[str]:
|
||||
"""Get the first supported interface URL for an agent."""
|
||||
card = self.get(name)
|
||||
if card and card.supported_interfaces:
|
||||
return card.supported_interfaces[0].url
|
||||
return None
|
||||
|
||||
def dump(self) -> dict:
|
||||
"""Dump full registry as a dict."""
|
||||
return {
|
||||
"version": 1,
|
||||
"agents": [card.to_dict() for card in self._cards.values()],
|
||||
}
|
||||
|
||||
|
||||
class GiteaRegistry:
|
||||
"""
|
||||
Gitea-backed agent registry.
|
||||
|
||||
Stores fleet agent cards in a Gitea repository file for
|
||||
distributed discovery across VPS nodes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gitea_url: str,
|
||||
repo: str,
|
||||
token: str,
|
||||
file_path: str = "config/fleet_agents.json",
|
||||
):
|
||||
self.gitea_url = gitea_url.rstrip("/")
|
||||
self.repo = repo
|
||||
self.token = token
|
||||
self.file_path = file_path
|
||||
self._cards: dict[str, AgentCard] = {}
|
||||
|
||||
def _api_url(self, endpoint: str) -> str:
|
||||
return f"{self.gitea_url}/api/v1/repos/{self.repo}/{endpoint}"
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def load(self) -> None:
|
||||
"""Fetch agent cards from Gitea."""
|
||||
try:
|
||||
import aiohttp
|
||||
url = self._api_url(f"contents/{self.file_path}")
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=self._headers()) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
import base64
|
||||
content = base64.b64decode(data["content"]).decode()
|
||||
registry = json.loads(content)
|
||||
for card_data in registry.get("agents", []):
|
||||
card = AgentCard.from_dict(card_data)
|
||||
self._cards[card.name.lower()] = card
|
||||
logger.info(
|
||||
f"Loaded {len(self._cards)} agents from Gitea"
|
||||
)
|
||||
elif resp.status == 404:
|
||||
logger.info("No fleet registry file in Gitea yet")
|
||||
else:
|
||||
logger.error(
|
||||
f"Gitea fetch failed: {resp.status}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load from Gitea: {e}")
|
||||
|
||||
async def save(self, message: str = "Update fleet registry") -> None:
|
||||
"""Write agent cards to Gitea."""
|
||||
try:
|
||||
import aiohttp
|
||||
content = json.dumps(
|
||||
{"version": 1, "agents": [c.to_dict() for c in self._cards.values()]},
|
||||
indent=2,
|
||||
)
|
||||
import base64
|
||||
encoded = base64.b64encode(content.encode()).decode()
|
||||
|
||||
# Check if file exists (need SHA for update)
|
||||
url = self._api_url(f"contents/{self.file_path}")
|
||||
sha = None
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=self._headers()) as resp:
|
||||
if resp.status == 200:
|
||||
existing = await resp.json()
|
||||
sha = existing.get("sha")
|
||||
|
||||
payload = {
|
||||
"message": message,
|
||||
"content": encoded,
|
||||
}
|
||||
if sha:
|
||||
payload["sha"] = sha
|
||||
|
||||
async with session.put(
|
||||
url, headers=self._headers(), json=payload
|
||||
) as resp:
|
||||
if resp.status in (200, 201):
|
||||
logger.info("Fleet registry saved to Gitea")
|
||||
else:
|
||||
body = await resp.text()
|
||||
logger.error(
|
||||
f"Gitea save failed: {resp.status} — {body}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save to Gitea: {e}")
|
||||
|
||||
def register(self, card: AgentCard) -> None:
|
||||
"""Register an agent (local update; call save() to persist)."""
|
||||
self._cards[card.name.lower()] = card
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
key = name.lower()
|
||||
if key in self._cards:
|
||||
del self._cards[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, name: str) -> Optional[AgentCard]:
|
||||
return self._cards.get(name.lower())
|
||||
|
||||
def list_agents(
|
||||
self,
|
||||
skill: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
) -> list[AgentCard]:
|
||||
agents = list(self._cards.values())
|
||||
if skill:
|
||||
agents = [a for a in agents if any(s.id == skill for s in a.skills)]
|
||||
if tag:
|
||||
agents = [a for a in agents if any(tag in s.tags for s in a.skills)]
|
||||
return agents
|
||||
|
||||
|
||||
# --- Convenience ---
|
||||
|
||||
def discover_agents(
|
||||
path: Path = Path("config/fleet_agents.json"),
|
||||
skill: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
) -> list[AgentCard]:
|
||||
"""One-shot discovery from local file."""
|
||||
registry = LocalFileRegistry(path)
|
||||
return registry.list_agents(skill=skill, tag=tag)
|
||||
@@ -1,386 +0,0 @@
|
||||
"""
|
||||
A2A Server — receive and process tasks from other agents.
|
||||
|
||||
Provides a FastAPI router that serves:
|
||||
- GET /.well-known/agent-card.json — Agent Card discovery
|
||||
- GET /agent.json — Agent Card fallback
|
||||
- POST /a2a/v1 — JSON-RPC endpoint (SendMessage, GetTask, etc.)
|
||||
- POST /a2a/v1/rpc — JSON-RPC endpoint (alias)
|
||||
|
||||
Task routing: registered handlers are matched by skill ID or receive
|
||||
all tasks via a default handler.
|
||||
|
||||
Usage:
|
||||
server = A2AServer(card=my_card, auth_token="secret")
|
||||
server.register_handler("ci-health", my_ci_handler)
|
||||
await server.start(host="0.0.0.0", port=8080)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Awaitable, Optional
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, Request, Response, HTTPException, Header
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
HAS_FASTAPI = True
|
||||
except ImportError:
|
||||
HAS_FASTAPI = False
|
||||
|
||||
from nexus.a2a.types import (
|
||||
A2AError,
|
||||
AgentCard,
|
||||
Artifact,
|
||||
JSONRPCError,
|
||||
JSONRPCResponse,
|
||||
Message,
|
||||
Role,
|
||||
Task,
|
||||
TaskState,
|
||||
TaskStatus,
|
||||
TextPart,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("nexus.a2a.server")
|
||||
|
||||
# Type for task handlers
|
||||
TaskHandler = Callable[[Task, AgentCard], Awaitable[Task]]
|
||||
|
||||
|
||||
class A2AServer:
|
||||
"""
|
||||
A2A protocol server for receiving agent-to-agent task delegation.
|
||||
|
||||
Supports:
|
||||
- Agent Card serving at /.well-known/agent-card.json
|
||||
- JSON-RPC task lifecycle (SendMessage, GetTask, CancelTask, ListTasks)
|
||||
- Pluggable task handlers (by skill ID or default)
|
||||
- Bearer / API key authentication
|
||||
- Audit logging
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
card: AgentCard,
|
||||
auth_token: str = "",
|
||||
auth_scheme: str = "bearer",
|
||||
):
|
||||
if not HAS_FASTAPI:
|
||||
raise ImportError(
|
||||
"fastapi and uvicorn are required for A2AServer. "
|
||||
"Install with: pip install fastapi uvicorn"
|
||||
)
|
||||
|
||||
self.card = card
|
||||
self.auth_token = auth_token
|
||||
self.auth_scheme = auth_scheme
|
||||
|
||||
# Task store (in-memory; swap for SQLite/Redis in production)
|
||||
self._tasks: dict[str, Task] = {}
|
||||
# Handlers keyed by skill ID
|
||||
self._handlers: dict[str, TaskHandler] = {}
|
||||
# Default handler for unmatched skills
|
||||
self._default_handler: Optional[TaskHandler] = None
|
||||
# Audit log
|
||||
self._audit_log: list[dict] = []
|
||||
|
||||
self.app = FastAPI(
|
||||
title=f"A2A — {card.name}",
|
||||
description=card.description,
|
||||
version=card.version,
|
||||
)
|
||||
self._register_routes()
|
||||
|
||||
def register_handler(self, skill_id: str, handler: TaskHandler):
|
||||
"""Register a handler for a specific skill ID."""
|
||||
self._handlers[skill_id] = handler
|
||||
logger.info(f"Registered handler for skill: {skill_id}")
|
||||
|
||||
def set_default_handler(self, handler: TaskHandler):
|
||||
"""Set the fallback handler for tasks without a matching skill."""
|
||||
self._default_handler = handler
|
||||
|
||||
def _verify_auth(self, authorization: Optional[str]) -> bool:
|
||||
"""Check authentication header."""
|
||||
if not self.auth_token:
|
||||
return True # No auth configured
|
||||
|
||||
if not authorization:
|
||||
return False
|
||||
|
||||
if self.auth_scheme == "bearer":
|
||||
expected = f"Bearer {self.auth_token}"
|
||||
return authorization == expected
|
||||
|
||||
return False
|
||||
|
||||
def _register_routes(self):
|
||||
"""Wire up FastAPI routes."""
|
||||
|
||||
@self.app.get("/.well-known/agent-card.json")
|
||||
async def agent_card_well_known():
|
||||
return JSONResponse(self.card.to_dict())
|
||||
|
||||
@self.app.get("/agent.json")
|
||||
async def agent_card_fallback():
|
||||
return JSONResponse(self.card.to_dict())
|
||||
|
||||
@self.app.post("/a2a/v1")
|
||||
@self.app.post("/a2a/v1/rpc")
|
||||
async def rpc_endpoint(request: Request):
|
||||
return await self._handle_rpc(request)
|
||||
|
||||
@self.app.get("/a2a/v1/tasks")
|
||||
@self.app.get("/a2a/v1/tasks/{task_id}")
|
||||
async def rest_get_task(task_id: Optional[str] = None):
|
||||
if task_id:
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return JSONRPCResponse(
|
||||
id="",
|
||||
error=A2AError.TASK_NOT_FOUND,
|
||||
).to_dict()
|
||||
return JSONResponse(task.to_dict())
|
||||
else:
|
||||
return JSONResponse(
|
||||
{"tasks": [t.to_dict() for t in self._tasks.values()]}
|
||||
)
|
||||
|
||||
async def _handle_rpc(self, request: Request) -> JSONResponse:
|
||||
"""Handle JSON-RPC requests."""
|
||||
# Auth check
|
||||
auth_header = request.headers.get("authorization")
|
||||
if not self._verify_auth(auth_header):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "Unauthorized"},
|
||||
)
|
||||
|
||||
# Parse JSON-RPC
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse(
|
||||
JSONRPCResponse(
|
||||
id="", error=A2AError.PARSE
|
||||
).to_dict(),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
method = body.get("method", "")
|
||||
request_id = body.get("id", str(uuid.uuid4()))
|
||||
params = body.get("params", {})
|
||||
|
||||
# Audit
|
||||
self._audit_log.append({
|
||||
"timestamp": time.time(),
|
||||
"method": method,
|
||||
"request_id": request_id,
|
||||
"source": request.client.host if request.client else "unknown",
|
||||
})
|
||||
|
||||
try:
|
||||
result = await self._dispatch_rpc(method, params, request_id)
|
||||
return JSONResponse(
|
||||
JSONRPCResponse(id=request_id, result=result).to_dict()
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse(
|
||||
JSONRPCResponse(
|
||||
id=request_id,
|
||||
error=JSONRPCError(-32602, str(e)),
|
||||
).to_dict(),
|
||||
status_code=400,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error handling {method}: {e}")
|
||||
return JSONResponse(
|
||||
JSONRPCResponse(
|
||||
id=request_id,
|
||||
error=JSONRPCError(-32603, str(e)),
|
||||
).to_dict(),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
async def _dispatch_rpc(
|
||||
self, method: str, params: dict, request_id: str
|
||||
) -> Any:
|
||||
"""Route JSON-RPC method to handler."""
|
||||
if method == "SendMessage":
|
||||
return await self._rpc_send_message(params)
|
||||
elif method == "GetTask":
|
||||
return await self._rpc_get_task(params)
|
||||
elif method == "ListTasks":
|
||||
return await self._rpc_list_tasks(params)
|
||||
elif method == "CancelTask":
|
||||
return await self._rpc_cancel_task(params)
|
||||
elif method == "GetAgentCard":
|
||||
return self.card.to_dict()
|
||||
else:
|
||||
raise ValueError(f"Unknown method: {method}")
|
||||
|
||||
async def _rpc_send_message(self, params: dict) -> dict:
|
||||
"""Handle SendMessage — create a task and route to handler."""
|
||||
msg_data = params.get("message", {})
|
||||
message = Message.from_dict(msg_data)
|
||||
|
||||
# Determine target skill from metadata
|
||||
target_skill = message.metadata.get("targetSkill", "")
|
||||
|
||||
# Create task
|
||||
task = Task(
|
||||
context_id=message.context_id,
|
||||
status=TaskStatus(state=TaskState.SUBMITTED),
|
||||
history=[message],
|
||||
metadata={"targetSkill": target_skill} if target_skill else {},
|
||||
)
|
||||
|
||||
# Store immediately
|
||||
self._tasks[task.id] = task
|
||||
|
||||
# Dispatch to handler
|
||||
handler = self._handlers.get(target_skill) or self._default_handler
|
||||
|
||||
if handler is None:
|
||||
task.status = TaskStatus(
|
||||
state=TaskState.FAILED,
|
||||
message=Message(
|
||||
role=Role.AGENT,
|
||||
parts=[TextPart(text="No handler available for this task")],
|
||||
),
|
||||
)
|
||||
return {"task": task.to_dict()}
|
||||
|
||||
try:
|
||||
# Mark as working
|
||||
task.status = TaskStatus(state=TaskState.WORKING)
|
||||
self._tasks[task.id] = task
|
||||
|
||||
# Execute handler
|
||||
result_task = await handler(task, self.card)
|
||||
|
||||
# Store result
|
||||
self._tasks[result_task.id] = result_task
|
||||
return {"task": result_task.to_dict()}
|
||||
|
||||
except Exception as e:
|
||||
task.status = TaskStatus(
|
||||
state=TaskState.FAILED,
|
||||
message=Message(
|
||||
role=Role.AGENT,
|
||||
parts=[TextPart(text=f"Handler error: {str(e)}")],
|
||||
),
|
||||
)
|
||||
self._tasks[task.id] = task
|
||||
return {"task": task.to_dict()}
|
||||
|
||||
async def _rpc_get_task(self, params: dict) -> dict:
|
||||
"""Handle GetTask."""
|
||||
task_id = params.get("id", "")
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task not found: {task_id}")
|
||||
return task.to_dict()
|
||||
|
||||
async def _rpc_list_tasks(self, params: dict) -> dict:
|
||||
"""Handle ListTasks with cursor-based pagination."""
|
||||
page_size = params.get("pageSize", 20)
|
||||
page_token = params.get("pageToken", "")
|
||||
|
||||
tasks = sorted(
|
||||
self._tasks.values(),
|
||||
key=lambda t: t.status.timestamp,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Simple cursor: find index by token
|
||||
start_idx = 0
|
||||
if page_token:
|
||||
for i, t in enumerate(tasks):
|
||||
if t.id == page_token:
|
||||
start_idx = i + 1
|
||||
break
|
||||
|
||||
page = tasks[start_idx : start_idx + page_size]
|
||||
next_token = ""
|
||||
if start_idx + page_size < len(tasks):
|
||||
next_token = tasks[start_idx + page_size - 1].id
|
||||
|
||||
return {
|
||||
"tasks": [t.to_dict() for t in page],
|
||||
"nextPageToken": next_token,
|
||||
}
|
||||
|
||||
async def _rpc_cancel_task(self, params: dict) -> dict:
|
||||
"""Handle CancelTask."""
|
||||
task_id = params.get("id", "")
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task not found: {task_id}")
|
||||
|
||||
if task.status.state.terminal:
|
||||
raise ValueError(
|
||||
f"Task {task_id} is already terminal "
|
||||
f"({task.status.state.value})"
|
||||
)
|
||||
|
||||
task.status = TaskStatus(state=TaskState.CANCELED)
|
||||
self._tasks[task_id] = task
|
||||
return task.to_dict()
|
||||
|
||||
def get_audit_log(self) -> list[dict]:
|
||||
"""Return audit log of all received requests."""
|
||||
return list(self._audit_log)
|
||||
|
||||
async def start(
|
||||
self,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8080,
|
||||
):
|
||||
"""Start the A2A server with uvicorn."""
|
||||
logger.info(
|
||||
f"Starting A2A server for {self.card.name} on "
|
||||
f"{host}:{port}"
|
||||
)
|
||||
logger.info(
|
||||
f"Agent Card at "
|
||||
f"http://{host}:{port}/.well-known/agent-card.json"
|
||||
)
|
||||
config = uvicorn.Config(
|
||||
self.app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
# --- Default Handler Factory ---
|
||||
|
||||
async def echo_handler(task: Task, card: AgentCard) -> Task:
|
||||
"""
|
||||
Simple echo handler for testing.
|
||||
Returns the user's message as an artifact.
|
||||
"""
|
||||
if task.history:
|
||||
last_msg = task.history[-1]
|
||||
text_parts = [p for p in last_msg.parts if isinstance(p, TextPart)]
|
||||
if text_parts:
|
||||
response_text = f"[{card.name}] Echo: {text_parts[0].text}"
|
||||
task.artifacts.append(
|
||||
Artifact(
|
||||
parts=[TextPart(text=response_text)],
|
||||
name="echo_response",
|
||||
)
|
||||
)
|
||||
|
||||
task.status = TaskStatus(state=TaskState.COMPLETED)
|
||||
return task
|
||||
@@ -1,524 +0,0 @@
|
||||
"""
|
||||
A2A Protocol Types — Data models for Google's Agent2Agent protocol v1.0.
|
||||
|
||||
All types map directly to the A2A spec. JSON uses camelCase, enums use
|
||||
SCREAMING_SNAKE_CASE, and Part types are discriminated by member name
|
||||
(not a kind field — that was removed in v1.0).
|
||||
|
||||
See: https://github.com/google/A2A
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
# --- Enums ---
|
||||
|
||||
class TaskState(str, enum.Enum):
|
||||
"""Lifecycle states for an A2A Task."""
|
||||
SUBMITTED = "TASK_STATE_SUBMITTED"
|
||||
WORKING = "TASK_STATE_WORKING"
|
||||
COMPLETED = "TASK_STATE_COMPLETED"
|
||||
FAILED = "TASK_STATE_FAILED"
|
||||
CANCELED = "TASK_STATE_CANCELED"
|
||||
INPUT_REQUIRED = "TASK_STATE_INPUT_REQUIRED"
|
||||
REJECTED = "TASK_STATE_REJECTED"
|
||||
AUTH_REQUIRED = "TASK_STATE_AUTH_REQUIRED"
|
||||
|
||||
@property
|
||||
def terminal(self) -> bool:
|
||||
return self in (
|
||||
TaskState.COMPLETED,
|
||||
TaskState.FAILED,
|
||||
TaskState.CANCELED,
|
||||
TaskState.REJECTED,
|
||||
)
|
||||
|
||||
|
||||
class Role(str, enum.Enum):
|
||||
"""Who sent a message in an A2A conversation."""
|
||||
USER = "ROLE_USER"
|
||||
AGENT = "ROLE_AGENT"
|
||||
|
||||
|
||||
# --- Parts (discriminated by member name in JSON) ---
|
||||
|
||||
@dataclass
|
||||
class TextPart:
|
||||
"""Plain text content."""
|
||||
text: str
|
||||
media_type: str = "text/plain"
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {"text": self.text}
|
||||
if self.media_type != "text/plain":
|
||||
d["mediaType"] = self.media_type
|
||||
if self.metadata:
|
||||
d["metadata"] = self.metadata
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilePart:
|
||||
"""Binary file content — inline or by URL reference."""
|
||||
media_type: str
|
||||
filename: Optional[str] = None
|
||||
raw: Optional[str] = None # base64-encoded bytes
|
||||
url: Optional[str] = None # URL reference
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {"mediaType": self.media_type}
|
||||
if self.raw is not None:
|
||||
d["raw"] = self.raw
|
||||
if self.url is not None:
|
||||
d["url"] = self.url
|
||||
if self.filename:
|
||||
d["filename"] = self.filename
|
||||
if self.metadata:
|
||||
d["metadata"] = self.metadata
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataPart:
|
||||
"""Arbitrary structured JSON data."""
|
||||
data: dict
|
||||
media_type: str = "application/json"
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {"data": self.data}
|
||||
if self.media_type != "application/json":
|
||||
d["mediaType"] = self.media_type
|
||||
if self.metadata:
|
||||
d["metadata"] = self.metadata
|
||||
return d
|
||||
|
||||
|
||||
Part = TextPart | FilePart | DataPart
|
||||
|
||||
|
||||
def part_from_dict(d: dict) -> Part:
|
||||
"""Reconstruct a Part from its JSON dict (discriminated by key name)."""
|
||||
if "text" in d:
|
||||
return TextPart(
|
||||
text=d["text"],
|
||||
media_type=d.get("mediaType", "text/plain"),
|
||||
metadata=d.get("metadata", {}),
|
||||
)
|
||||
if "raw" in d or "url" in d:
|
||||
return FilePart(
|
||||
media_type=d["mediaType"],
|
||||
filename=d.get("filename"),
|
||||
raw=d.get("raw"),
|
||||
url=d.get("url"),
|
||||
metadata=d.get("metadata", {}),
|
||||
)
|
||||
if "data" in d:
|
||||
return DataPart(
|
||||
data=d["data"],
|
||||
media_type=d.get("mediaType", "application/json"),
|
||||
metadata=d.get("metadata", {}),
|
||||
)
|
||||
raise ValueError(f"Cannot determine Part type from keys: {list(d.keys())}")
|
||||
|
||||
|
||||
def part_to_dict(p: Part) -> dict:
|
||||
"""Serialize a Part to its JSON dict."""
|
||||
return p.to_dict()
|
||||
|
||||
|
||||
# --- Message ---
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""A2A Message — a turn in a conversation between user and agent."""
|
||||
role: Role
|
||||
parts: list[Part]
|
||||
message_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
context_id: Optional[str] = None
|
||||
task_id: Optional[str] = None
|
||||
metadata: dict = field(default_factory=dict)
|
||||
extensions: list[str] = field(default_factory=list)
|
||||
reference_task_ids: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d: dict[str, Any] = {
|
||||
"messageId": self.message_id,
|
||||
"role": self.role.value,
|
||||
"parts": [part_to_dict(p) for p in self.parts],
|
||||
}
|
||||
if self.context_id:
|
||||
d["contextId"] = self.context_id
|
||||
if self.task_id:
|
||||
d["taskId"] = self.task_id
|
||||
if self.metadata:
|
||||
d["metadata"] = self.metadata
|
||||
if self.extensions:
|
||||
d["extensions"] = self.extensions
|
||||
if self.reference_task_ids:
|
||||
d["referenceTaskIds"] = self.reference_task_ids
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Message":
|
||||
return cls(
|
||||
role=Role(d["role"]),
|
||||
parts=[part_from_dict(p) for p in d["parts"]],
|
||||
message_id=d.get("messageId", str(uuid.uuid4())),
|
||||
context_id=d.get("contextId"),
|
||||
task_id=d.get("taskId"),
|
||||
metadata=d.get("metadata", {}),
|
||||
extensions=d.get("extensions", []),
|
||||
reference_task_ids=d.get("referenceTaskIds", []),
|
||||
)
|
||||
|
||||
|
||||
# --- Artifact ---
|
||||
|
||||
@dataclass
|
||||
class Artifact:
|
||||
"""A2A Artifact — structured output from a task."""
|
||||
parts: list[Part]
|
||||
artifact_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
metadata: dict = field(default_factory=dict)
|
||||
extensions: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d: dict[str, Any] = {
|
||||
"artifactId": self.artifact_id,
|
||||
"parts": [part_to_dict(p) for p in self.parts],
|
||||
}
|
||||
if self.name:
|
||||
d["name"] = self.name
|
||||
if self.description:
|
||||
d["description"] = self.description
|
||||
if self.metadata:
|
||||
d["metadata"] = self.metadata
|
||||
if self.extensions:
|
||||
d["extensions"] = self.extensions
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Artifact":
|
||||
return cls(
|
||||
parts=[part_from_dict(p) for p in d["parts"]],
|
||||
artifact_id=d.get("artifactId", str(uuid.uuid4())),
|
||||
name=d.get("name"),
|
||||
description=d.get("description"),
|
||||
metadata=d.get("metadata", {}),
|
||||
extensions=d.get("extensions", []),
|
||||
)
|
||||
|
||||
|
||||
# --- Task ---
|
||||
|
||||
@dataclass
|
||||
class TaskStatus:
|
||||
"""Status envelope for a Task."""
|
||||
state: TaskState
|
||||
message: Optional[Message] = None
|
||||
timestamp: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d: dict[str, Any] = {"state": self.state.value}
|
||||
if self.message:
|
||||
d["message"] = self.message.to_dict()
|
||||
d["timestamp"] = self.timestamp
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "TaskStatus":
|
||||
msg = None
|
||||
if "message" in d:
|
||||
msg = Message.from_dict(d["message"])
|
||||
return cls(
|
||||
state=TaskState(d["state"]),
|
||||
message=msg,
|
||||
timestamp=d.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""A2A Task — a unit of work delegated between agents."""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
context_id: Optional[str] = None
|
||||
status: TaskStatus = field(
|
||||
default_factory=lambda: TaskStatus(state=TaskState.SUBMITTED)
|
||||
)
|
||||
artifacts: list[Artifact] = field(default_factory=list)
|
||||
history: list[Message] = field(default_factory=list)
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"status": self.status.to_dict(),
|
||||
}
|
||||
if self.context_id:
|
||||
d["contextId"] = self.context_id
|
||||
if self.artifacts:
|
||||
d["artifacts"] = [a.to_dict() for a in self.artifacts]
|
||||
if self.history:
|
||||
d["history"] = [m.to_dict() for m in self.history]
|
||||
if self.metadata:
|
||||
d["metadata"] = self.metadata
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Task":
|
||||
return cls(
|
||||
id=d.get("id", str(uuid.uuid4())),
|
||||
context_id=d.get("contextId"),
|
||||
status=TaskStatus.from_dict(d["status"]) if "status" in d else TaskStatus(TaskState.SUBMITTED),
|
||||
artifacts=[Artifact.from_dict(a) for a in d.get("artifacts", [])],
|
||||
history=[Message.from_dict(m) for m in d.get("history", [])],
|
||||
metadata=d.get("metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
# --- Agent Card ---
|
||||
|
||||
@dataclass
|
||||
class AgentSkill:
|
||||
"""Capability declaration for an Agent Card."""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
tags: list[str] = field(default_factory=list)
|
||||
examples: list[str] = field(default_factory=list)
|
||||
input_modes: list[str] = field(default_factory=lambda: ["text/plain"])
|
||||
output_modes: list[str] = field(default_factory=lambda: ["text/plain"])
|
||||
security_requirements: list[dict] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
}
|
||||
if self.examples:
|
||||
d["examples"] = self.examples
|
||||
if self.input_modes != ["text/plain"]:
|
||||
d["inputModes"] = self.input_modes
|
||||
if self.output_modes != ["text/plain"]:
|
||||
d["outputModes"] = self.output_modes
|
||||
if self.security_requirements:
|
||||
d["securityRequirements"] = self.security_requirements
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentInterface:
|
||||
"""Network endpoint for an agent."""
|
||||
url: str
|
||||
protocol_binding: str = "HTTP+JSON"
|
||||
protocol_version: str = "1.0"
|
||||
tenant: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {
|
||||
"url": self.url,
|
||||
"protocolBinding": self.protocol_binding,
|
||||
"protocolVersion": self.protocol_version,
|
||||
}
|
||||
if self.tenant:
|
||||
d["tenant"] = self.tenant
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentCapabilities:
|
||||
"""What this agent can do beyond basic request/response."""
|
||||
streaming: bool = False
|
||||
push_notifications: bool = False
|
||||
extended_agent_card: bool = False
|
||||
extensions: list[dict] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"streaming": self.streaming,
|
||||
"pushNotifications": self.push_notifications,
|
||||
"extendedAgentCard": self.extended_agent_card,
|
||||
"extensions": self.extensions,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentCard:
|
||||
"""
|
||||
A2A Agent Card — self-describing metadata published at
|
||||
/.well-known/agent-card.json
|
||||
"""
|
||||
name: str
|
||||
description: str
|
||||
version: str = "1.0.0"
|
||||
supported_interfaces: list[AgentInterface] = field(default_factory=list)
|
||||
capabilities: AgentCapabilities = field(
|
||||
default_factory=AgentCapabilities
|
||||
)
|
||||
provider: Optional[dict] = None
|
||||
documentation_url: Optional[str] = None
|
||||
icon_url: Optional[str] = None
|
||||
default_input_modes: list[str] = field(
|
||||
default_factory=lambda: ["text/plain"]
|
||||
)
|
||||
default_output_modes: list[str] = field(
|
||||
default_factory=lambda: ["text/plain"]
|
||||
)
|
||||
skills: list[AgentSkill] = field(default_factory=list)
|
||||
security_schemes: dict = field(default_factory=dict)
|
||||
security_requirements: list[dict] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d: dict[str, Any] = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"version": self.version,
|
||||
"supportedInterfaces": [i.to_dict() for i in self.supported_interfaces],
|
||||
"capabilities": self.capabilities.to_dict(),
|
||||
"defaultInputModes": self.default_input_modes,
|
||||
"defaultOutputModes": self.default_output_modes,
|
||||
"skills": [s.to_dict() for s in self.skills],
|
||||
}
|
||||
if self.provider:
|
||||
d["provider"] = self.provider
|
||||
if self.documentation_url:
|
||||
d["documentationUrl"] = self.documentation_url
|
||||
if self.icon_url:
|
||||
d["iconUrl"] = self.icon_url
|
||||
if self.security_schemes:
|
||||
d["securitySchemes"] = self.security_schemes
|
||||
if self.security_requirements:
|
||||
d["securityRequirements"] = self.security_requirements
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "AgentCard":
|
||||
return cls(
|
||||
name=d["name"],
|
||||
description=d["description"],
|
||||
version=d.get("version", "1.0.0"),
|
||||
supported_interfaces=[
|
||||
AgentInterface(
|
||||
url=i["url"],
|
||||
protocol_binding=i.get("protocolBinding", "HTTP+JSON"),
|
||||
protocol_version=i.get("protocolVersion", "1.0"),
|
||||
tenant=i.get("tenant", ""),
|
||||
)
|
||||
for i in d.get("supportedInterfaces", [])
|
||||
],
|
||||
capabilities=AgentCapabilities(
|
||||
streaming=d.get("capabilities", {}).get("streaming", False),
|
||||
push_notifications=d.get("capabilities", {}).get("pushNotifications", False),
|
||||
extended_agent_card=d.get("capabilities", {}).get("extendedAgentCard", False),
|
||||
extensions=d.get("capabilities", {}).get("extensions", []),
|
||||
),
|
||||
provider=d.get("provider"),
|
||||
documentation_url=d.get("documentationUrl"),
|
||||
icon_url=d.get("iconUrl"),
|
||||
default_input_modes=d.get("defaultInputModes", ["text/plain"]),
|
||||
default_output_modes=d.get("defaultOutputModes", ["text/plain"]),
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id=s["id"],
|
||||
name=s["name"],
|
||||
description=s["description"],
|
||||
tags=s.get("tags", []),
|
||||
examples=s.get("examples", []),
|
||||
input_modes=s.get("inputModes", ["text/plain"]),
|
||||
output_modes=s.get("outputModes", ["text/plain"]),
|
||||
security_requirements=s.get("securityRequirements", []),
|
||||
)
|
||||
for s in d.get("skills", [])
|
||||
],
|
||||
security_schemes=d.get("securitySchemes", {}),
|
||||
security_requirements=d.get("securityRequirements", []),
|
||||
)
|
||||
|
||||
|
||||
# --- JSON-RPC envelope ---
|
||||
|
||||
@dataclass
|
||||
class JSONRPCRequest:
|
||||
"""JSON-RPC 2.0 request wrapping an A2A method."""
|
||||
method: str
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
params: dict = field(default_factory=dict)
|
||||
jsonrpc: str = "2.0"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"jsonrpc": self.jsonrpc,
|
||||
"id": self.id,
|
||||
"method": self.method,
|
||||
"params": self.params,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class JSONRPCError:
|
||||
"""JSON-RPC 2.0 error object."""
|
||||
code: int
|
||||
message: str
|
||||
data: Any = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {"code": self.code, "message": self.message}
|
||||
if self.data is not None:
|
||||
d["data"] = self.data
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class JSONRPCResponse:
|
||||
"""JSON-RPC 2.0 response."""
|
||||
id: str
|
||||
result: Any = None
|
||||
error: Optional[JSONRPCError] = None
|
||||
jsonrpc: str = "2.0"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d: dict[str, Any] = {
|
||||
"jsonrpc": self.jsonrpc,
|
||||
"id": self.id,
|
||||
}
|
||||
if self.error:
|
||||
d["error"] = self.error.to_dict()
|
||||
else:
|
||||
d["result"] = self.result
|
||||
return d
|
||||
|
||||
|
||||
# --- Standard A2A Error codes ---
|
||||
|
||||
class A2AError:
|
||||
"""Standard A2A / JSON-RPC error factories."""
|
||||
PARSE = JSONRPCError(-32700, "Invalid JSON payload")
|
||||
INVALID_REQUEST = JSONRPCError(-32600, "Request payload validation error")
|
||||
METHOD_NOT_FOUND = JSONRPCError(-32601, "Method not found")
|
||||
INVALID_PARAMS = JSONRPCError(-32602, "Invalid parameters")
|
||||
INTERNAL = JSONRPCError(-32603, "Internal error")
|
||||
|
||||
TASK_NOT_FOUND = JSONRPCError(-32001, "Task not found")
|
||||
TASK_NOT_CANCELABLE = JSONRPCError(-32002, "Task not cancelable")
|
||||
PUSH_NOT_SUPPORTED = JSONRPCError(-32003, "Push notifications not supported")
|
||||
UNSUPPORTED_OP = JSONRPCError(-32004, "Unsupported operation")
|
||||
CONTENT_TYPE = JSONRPCError(-32005, "Content type not supported")
|
||||
INVALID_RESPONSE = JSONRPCError(-32006, "Invalid agent response")
|
||||
EXTENDED_CARD = JSONRPCError(-32007, "Extended agent card not configured")
|
||||
EXTENSION_REQUIRED = JSONRPCError(-32008, "Extension support required")
|
||||
VERSION_NOT_SUPPORTED = JSONRPCError(-32009, "Version not supported")
|
||||
@@ -29,7 +29,7 @@ from typing import Any, Callable, Optional
|
||||
|
||||
import websockets
|
||||
|
||||
from nexus.bannerlord_trace import BannerlordTraceLogger
|
||||
from bannerlord_trace import BannerlordTraceLogger
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONFIGURATION
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
"""
|
||||
Nexus Chronicle — Emergent Narrative from Agent Interactions
|
||||
|
||||
Watches the fleet's activity (dispatches, errors, recoveries,
|
||||
collaborations) and transforms raw event data into narrative prose.
|
||||
The system finds the dramatic arc in real work and produces a living
|
||||
chronicle. The story writes itself from the data.
|
||||
|
||||
Usage:
|
||||
from nexus.chronicle import ChronicleWriter, AgentEvent, EventKind
|
||||
|
||||
writer = ChronicleWriter()
|
||||
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="took issue #42"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.ERROR, agent="claude", detail="rate limit hit"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.RECOVERY, agent="claude", detail="retried after backoff"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="feat: add narrative engine"))
|
||||
|
||||
prose = writer.render()
|
||||
print(prose)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class EventKind(str, Enum):
|
||||
"""The kinds of agent events the chronicle recognises."""
|
||||
|
||||
DISPATCH = "dispatch" # agent claimed / was assigned work
|
||||
COMMIT = "commit" # agent produced a commit
|
||||
PUSH = "push" # agent pushed a branch
|
||||
PR_OPEN = "pr_open" # agent opened a pull request
|
||||
PR_MERGE = "pr_merge" # PR was merged
|
||||
ERROR = "error" # agent hit an error / exception
|
||||
RECOVERY = "recovery" # agent recovered from a failure
|
||||
ABANDON = "abandon" # agent abandoned a task (timeout / giving up)
|
||||
COLLABORATION = "collab" # two agents worked on the same thing
|
||||
HEARTBEAT = "heartbeat" # agent reported a heartbeat (alive signal)
|
||||
IDLE = "idle" # agent is waiting for work
|
||||
MILESTONE = "milestone" # notable achievement (e.g. 100th issue closed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentEvent:
|
||||
"""One discrete thing that happened in the fleet."""
|
||||
|
||||
kind: EventKind
|
||||
agent: str # who did this (e.g. "claude", "mimo-v2-pro")
|
||||
detail: str = "" # free-text description
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"kind": self.kind.value,
|
||||
"agent": self.agent,
|
||||
"detail": self.detail,
|
||||
"timestamp": self.timestamp,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AgentEvent":
|
||||
return cls(
|
||||
kind=EventKind(data["kind"]),
|
||||
agent=data["agent"],
|
||||
detail=data.get("detail", ""),
|
||||
timestamp=data.get("timestamp", time.time()),
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Narrative templates — maps event kinds to prose fragments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Each entry is a list so we can rotate through variants.
|
||||
_TEMPLATES: dict[EventKind, list[str]] = {
|
||||
EventKind.DISPATCH: [
|
||||
"{agent} stepped forward and claimed the work: {detail}.",
|
||||
"{agent} took on the challenge — {detail}.",
|
||||
"The task landed on {agent}'s desk: {detail}.",
|
||||
],
|
||||
EventKind.COMMIT: [
|
||||
'{agent} sealed a commit into the record: "{detail}".',
|
||||
'{agent} committed "{detail}" — progress crystallised.',
|
||||
"{agent} carved a new ring into the trunk: {detail}.",
|
||||
],
|
||||
EventKind.PUSH: [
|
||||
"{agent} pushed the work upstream.",
|
||||
"The branch rose into the forge — {agent}'s changes were live.",
|
||||
"{agent} sent their work into the wider current.",
|
||||
],
|
||||
EventKind.PR_OPEN: [
|
||||
"{agent} opened a pull request: {detail}.",
|
||||
"A proposal surfaced — {agent} asked the fleet to review {detail}.",
|
||||
"{agent} laid their work before the reviewers: {detail}.",
|
||||
],
|
||||
EventKind.PR_MERGE: [
|
||||
"{agent}'s branch folded into the whole: {detail}.",
|
||||
"Consensus reached — {agent}'s changes were merged: {detail}.",
|
||||
"{detail} joined the canon. {agent}'s contribution lives on.",
|
||||
],
|
||||
EventKind.ERROR: [
|
||||
"{agent} ran into an obstacle: {detail}.",
|
||||
"Trouble. {agent} encountered {detail} and had to pause.",
|
||||
"The path grew difficult — {agent} hit {detail}.",
|
||||
],
|
||||
EventKind.RECOVERY: [
|
||||
"{agent} regrouped and pressed on: {detail}.",
|
||||
"After the setback, {agent} found a way through: {detail}.",
|
||||
"{agent} recovered — {detail}.",
|
||||
],
|
||||
EventKind.ABANDON: [
|
||||
"{agent} released the task, unable to finish: {detail}.",
|
||||
"Sometimes wisdom is knowing when to let go. {agent} abandoned {detail}.",
|
||||
"{agent} stepped back from {detail}. Another will carry it forward.",
|
||||
],
|
||||
EventKind.COLLABORATION: [
|
||||
"{agent} and their peers converged on the same problem: {detail}.",
|
||||
"Two minds touched the same work — {agent} in collaboration: {detail}.",
|
||||
"The fleet coordinated — {agent} joined the effort on {detail}.",
|
||||
],
|
||||
EventKind.HEARTBEAT: [
|
||||
"{agent} checked in — still thinking, still present.",
|
||||
"A pulse from {agent}: the mind is alive.",
|
||||
"{agent} breathed through another cycle.",
|
||||
],
|
||||
EventKind.IDLE: [
|
||||
"{agent} rested, waiting for the next call.",
|
||||
"Quiet descended — {agent} held still between tasks.",
|
||||
"{agent} stood ready, watchful in the lull.",
|
||||
],
|
||||
EventKind.MILESTONE: [
|
||||
"A moment worth noting — {agent}: {detail}.",
|
||||
"The chronicle marks a milestone. {agent}: {detail}.",
|
||||
"History ticked over — {agent} reached {detail}.",
|
||||
],
|
||||
}
|
||||
|
||||
# Arc-level commentary triggered by sequences of events
|
||||
_ARC_TEMPLATES = {
|
||||
"struggle_and_recovery": (
|
||||
"There was a struggle here. {agent} hit trouble and came back stronger — "
|
||||
"the kind of arc that gives a chronicle its texture."
|
||||
),
|
||||
"silent_grind": (
|
||||
"No drama, just steady work. {agents} moved through the backlog with quiet persistence."
|
||||
),
|
||||
"abandon_then_retry": (
|
||||
"{agent} let go once. But the work called again, and this time it was answered."
|
||||
),
|
||||
"solo_sprint": (
|
||||
"{agent} ran the whole arc alone — dispatch to merge — without breaking stride."
|
||||
),
|
||||
"fleet_convergence": (
|
||||
"The fleet converged. Multiple agents touched the same thread and wove it tighter."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chronicle writer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChronicleWriter:
|
||||
"""Accumulates agent events and renders them as narrative prose.
|
||||
|
||||
The writer keeps a running log of events. Call ``ingest()`` to add new
|
||||
events as they arrive, then ``render()`` to produce a prose snapshot of
|
||||
the current arc.
|
||||
|
||||
Events are also persisted to JSONL so the chronicle survives restarts.
|
||||
"""
|
||||
|
||||
def __init__(self, log_path: Optional[Path] = None):
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
self.log_path = log_path or (
|
||||
Path.home() / ".nexus" / "chronicle" / f"chronicle_{today}.jsonl"
|
||||
)
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._events: list[AgentEvent] = []
|
||||
self._template_counters: dict[EventKind, int] = {}
|
||||
|
||||
# Load any events already on disk for today
|
||||
self._load_existing()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def ingest(self, event: AgentEvent) -> None:
|
||||
"""Add an event to the chronicle and persist it to disk."""
|
||||
self._events.append(event)
|
||||
with open(self.log_path, "a") as f:
|
||||
f.write(json.dumps(event.to_dict()) + "\n")
|
||||
|
||||
def render(self, max_events: int = 50) -> str:
|
||||
"""Render the recent event stream as narrative prose.
|
||||
|
||||
Returns a multi-paragraph string suitable for display or logging.
|
||||
"""
|
||||
events = self._events[-max_events:]
|
||||
if not events:
|
||||
return "The chronicle is empty. No events have been recorded yet."
|
||||
|
||||
paragraphs: list[str] = []
|
||||
|
||||
# Opening line with timestamp range
|
||||
first_ts = time.strftime("%H:%M", time.localtime(events[0].timestamp))
|
||||
last_ts = time.strftime("%H:%M", time.localtime(events[-1].timestamp))
|
||||
paragraphs.append(
|
||||
f"The chronicle covers {len(events)} event(s) between {first_ts} and {last_ts}."
|
||||
)
|
||||
|
||||
# Event-by-event prose
|
||||
sentences: list[str] = []
|
||||
for evt in events:
|
||||
sentences.append(self._render_event(evt))
|
||||
paragraphs.append(" ".join(sentences))
|
||||
|
||||
# Arc-level commentary
|
||||
arc = self._detect_arc(events)
|
||||
if arc:
|
||||
paragraphs.append(arc)
|
||||
|
||||
return "\n\n".join(paragraphs)
|
||||
|
||||
def render_markdown(self, max_events: int = 50) -> str:
|
||||
"""Render as a Markdown document."""
|
||||
events = self._events[-max_events:]
|
||||
if not events:
|
||||
return "# Chronicle\n\n*No events recorded yet.*"
|
||||
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
lines = [f"# Chronicle — {today}", ""]
|
||||
|
||||
for evt in events:
|
||||
ts = time.strftime("%H:%M:%S", time.localtime(evt.timestamp))
|
||||
prose = self._render_event(evt)
|
||||
lines.append(f"**{ts}** — {prose}")
|
||||
|
||||
arc = self._detect_arc(events)
|
||||
if arc:
|
||||
lines += ["", "---", "", f"*{arc}*"]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def summary(self) -> dict:
|
||||
"""Return a structured summary of the current session."""
|
||||
agents: dict[str, dict] = {}
|
||||
kind_counts: dict[str, int] = {}
|
||||
|
||||
for evt in self._events:
|
||||
agents.setdefault(evt.agent, {"events": 0, "kinds": []})
|
||||
agents[evt.agent]["events"] += 1
|
||||
agents[evt.agent]["kinds"].append(evt.kind.value)
|
||||
kind_counts[evt.kind.value] = kind_counts.get(evt.kind.value, 0) + 1
|
||||
|
||||
return {
|
||||
"total_events": len(self._events),
|
||||
"agents": agents,
|
||||
"kind_counts": kind_counts,
|
||||
"log_path": str(self.log_path),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _render_event(self, evt: AgentEvent) -> str:
|
||||
"""Turn a single event into a prose sentence."""
|
||||
templates = _TEMPLATES.get(evt.kind, ["{agent}: {detail}"])
|
||||
counter = self._template_counters.get(evt.kind, 0)
|
||||
template = templates[counter % len(templates)]
|
||||
self._template_counters[evt.kind] = counter + 1
|
||||
return template.format(agent=evt.agent, detail=evt.detail or evt.kind.value)
|
||||
|
||||
def _detect_arc(self, events: list[AgentEvent]) -> Optional[str]:
|
||||
"""Scan the event sequence for a recognisable dramatic arc."""
|
||||
if not events:
|
||||
return None
|
||||
|
||||
kinds = [e.kind for e in events]
|
||||
agents = list({e.agent for e in events})
|
||||
|
||||
# struggle → recovery
|
||||
if EventKind.ERROR in kinds and EventKind.RECOVERY in kinds:
|
||||
err_idx = kinds.index(EventKind.ERROR)
|
||||
rec_idx = kinds.index(EventKind.RECOVERY)
|
||||
if rec_idx > err_idx:
|
||||
agent = events[err_idx].agent
|
||||
return _ARC_TEMPLATES["struggle_and_recovery"].format(agent=agent)
|
||||
|
||||
# abandon → dispatch (retry): find first ABANDON, then any DISPATCH after it
|
||||
if EventKind.ABANDON in kinds and EventKind.DISPATCH in kinds:
|
||||
ab_idx = kinds.index(EventKind.ABANDON)
|
||||
retry_idx = next(
|
||||
(i for i, k in enumerate(kinds) if k == EventKind.DISPATCH and i > ab_idx),
|
||||
None,
|
||||
)
|
||||
if retry_idx is not None:
|
||||
agent = events[retry_idx].agent
|
||||
return _ARC_TEMPLATES["abandon_then_retry"].format(agent=agent)
|
||||
|
||||
# solo sprint: single agent goes dispatch→commit→pr_open→pr_merge
|
||||
solo_arc = {EventKind.DISPATCH, EventKind.COMMIT, EventKind.PR_OPEN, EventKind.PR_MERGE}
|
||||
if solo_arc.issubset(set(kinds)) and len(agents) == 1:
|
||||
return _ARC_TEMPLATES["solo_sprint"].format(agent=agents[0])
|
||||
|
||||
# fleet convergence: multiple agents, collaboration event
|
||||
if len(agents) > 1 and EventKind.COLLABORATION in kinds:
|
||||
return _ARC_TEMPLATES["fleet_convergence"]
|
||||
|
||||
# silent grind: only commits / heartbeats, no drama
|
||||
drama = {EventKind.ERROR, EventKind.ABANDON, EventKind.RECOVERY, EventKind.COLLABORATION}
|
||||
if not drama.intersection(set(kinds)) and EventKind.COMMIT in kinds:
|
||||
return _ARC_TEMPLATES["silent_grind"].format(agents=", ".join(agents))
|
||||
|
||||
return None
|
||||
|
||||
def _load_existing(self) -> None:
|
||||
"""Load events persisted from earlier in the same session."""
|
||||
if not self.log_path.exists():
|
||||
return
|
||||
with open(self.log_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
self._events.append(AgentEvent.from_dict(json.loads(line)))
|
||||
except (json.JSONDecodeError, KeyError, ValueError):
|
||||
continue # skip malformed lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience: build events from common fleet signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def event_from_gitea_issue(payload: dict, agent: str) -> AgentEvent:
|
||||
"""Build a DISPATCH event from a Gitea issue assignment payload."""
|
||||
issue_num = payload.get("number", "?")
|
||||
title = payload.get("title", "")
|
||||
return AgentEvent(
|
||||
kind=EventKind.DISPATCH,
|
||||
agent=agent,
|
||||
detail=f"issue #{issue_num}: {title}",
|
||||
metadata={"issue_number": issue_num},
|
||||
)
|
||||
|
||||
|
||||
def event_from_heartbeat(hb: dict) -> AgentEvent:
|
||||
"""Build a HEARTBEAT event from a nexus heartbeat dict."""
|
||||
agent = hb.get("model", "unknown")
|
||||
status = hb.get("status", "thinking")
|
||||
cycle = hb.get("cycle", 0)
|
||||
return AgentEvent(
|
||||
kind=EventKind.HEARTBEAT,
|
||||
agent=agent,
|
||||
detail=f"cycle {cycle}, status={status}",
|
||||
metadata=hb,
|
||||
)
|
||||
|
||||
|
||||
def event_from_commit(commit: dict, agent: str) -> AgentEvent:
|
||||
"""Build a COMMIT event from a git commit dict."""
|
||||
message = commit.get("message", "").split("\n")[0] # subject line only
|
||||
sha = commit.get("sha", "")[:8]
|
||||
return AgentEvent(
|
||||
kind=EventKind.COMMIT,
|
||||
agent=agent,
|
||||
detail=message,
|
||||
metadata={"sha": sha},
|
||||
)
|
||||
@@ -1,451 +0,0 @@
|
||||
// ═══════════════════════════════════════════════════
|
||||
// REASONING TRACE HUD COMPONENT
|
||||
// ═══════════════════════════════════════════════════
|
||||
//
|
||||
// Displays a real-time trace of the agent's reasoning
|
||||
// steps during complex task execution. Shows the chain
|
||||
// of thought, decision points, and confidence levels.
|
||||
//
|
||||
// Usage:
|
||||
// ReasoningTrace.init();
|
||||
// ReasoningTrace.addStep(step);
|
||||
// ReasoningTrace.clear();
|
||||
// ReasoningTrace.toggle();
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
const ReasoningTrace = (() => {
|
||||
// ── State ─────────────────────────────────────────
|
||||
let _container = null;
|
||||
let _content = null;
|
||||
let _header = null;
|
||||
let _steps = [];
|
||||
let _maxSteps = 20;
|
||||
let _isVisible = true;
|
||||
let _currentTask = null;
|
||||
let _stepCounter = 0;
|
||||
|
||||
// ── Config ────────────────────────────────────────
|
||||
const STEP_TYPES = {
|
||||
THINK: { icon: '💭', color: '#4af0c0', label: 'THINK' },
|
||||
DECIDE: { icon: '⚖️', color: '#ffd700', label: 'DECIDE' },
|
||||
RECALL: { icon: '🔍', color: '#7b5cff', label: 'RECALL' },
|
||||
PLAN: { icon: '📋', color: '#ff8c42', label: 'PLAN' },
|
||||
EXECUTE: { icon: '⚡', color: '#ff4466', label: 'EXECUTE' },
|
||||
VERIFY: { icon: '✅', color: '#4af0c0', label: 'VERIFY' },
|
||||
DOUBT: { icon: '❓', color: '#ff8c42', label: 'DOUBT' },
|
||||
MEMORY: { icon: '💾', color: '#7b5cff', label: 'MEMORY' }
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────
|
||||
|
||||
function _escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function _formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function _getConfidenceBar(confidence) {
|
||||
if (confidence === undefined || confidence === null) return '';
|
||||
const percent = Math.max(0, Math.min(100, Math.round(confidence * 100)));
|
||||
const bars = Math.round(percent / 10);
|
||||
const filled = '█'.repeat(bars);
|
||||
const empty = '░'.repeat(10 - bars);
|
||||
return `<span class="confidence-bar" title="${percent}% confidence">${filled}${empty}</span>`;
|
||||
}
|
||||
|
||||
// ── DOM Setup ─────────────────────────────────────
|
||||
|
||||
function _createDOM() {
|
||||
// Create container if it doesn't exist
|
||||
if (_container) return;
|
||||
|
||||
_container = document.createElement('div');
|
||||
_container.id = 'reasoning-trace';
|
||||
_container.className = 'hud-panel reasoning-trace';
|
||||
|
||||
_header = document.createElement('div');
|
||||
_header.className = 'panel-header';
|
||||
_header.innerHTML = `<span class="trace-icon">🧠</span> REASONING TRACE`;
|
||||
|
||||
// Task indicator
|
||||
const taskIndicator = document.createElement('div');
|
||||
taskIndicator.className = 'trace-task';
|
||||
taskIndicator.id = 'trace-task';
|
||||
taskIndicator.textContent = 'No active task';
|
||||
|
||||
// Step counter
|
||||
const stepCounter = document.createElement('div');
|
||||
stepCounter.className = 'trace-counter';
|
||||
stepCounter.id = 'trace-counter';
|
||||
stepCounter.textContent = '0 steps';
|
||||
|
||||
// Controls
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'trace-controls';
|
||||
controls.innerHTML = `
|
||||
<button class="trace-btn" id="trace-clear" title="Clear trace">🗑️</button>
|
||||
<button class="trace-btn" id="trace-toggle" title="Toggle visibility">👁️</button>
|
||||
<button class="trace-btn" id="trace-export" title="Export trace">📤</button>
|
||||
`;
|
||||
|
||||
// Header container
|
||||
const headerContainer = document.createElement('div');
|
||||
headerContainer.className = 'trace-header-container';
|
||||
headerContainer.appendChild(_header);
|
||||
headerContainer.appendChild(controls);
|
||||
|
||||
// Content area
|
||||
_content = document.createElement('div');
|
||||
_content.className = 'panel-content trace-content';
|
||||
_content.id = 'reasoning-trace-content';
|
||||
|
||||
// Assemble
|
||||
_container.appendChild(headerContainer);
|
||||
_container.appendChild(taskIndicator);
|
||||
_container.appendChild(stepCounter);
|
||||
_container.appendChild(_content);
|
||||
|
||||
// Add to HUD
|
||||
const hud = document.getElementById('hud');
|
||||
if (hud) {
|
||||
const gofaiHud = hud.querySelector('.gofai-hud');
|
||||
if (gofaiHud) {
|
||||
gofaiHud.appendChild(_container);
|
||||
} else {
|
||||
hud.appendChild(_container);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
document.getElementById('trace-clear')?.addEventListener('click', clear);
|
||||
document.getElementById('trace-toggle')?.addEventListener('click', toggle);
|
||||
document.getElementById('trace-export')?.addEventListener('click', exportTrace);
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────
|
||||
|
||||
function _renderStep(step, index) {
|
||||
const typeConfig = STEP_TYPES[step.type] || STEP_TYPES.THINK;
|
||||
const timestamp = _formatTimestamp(step.timestamp);
|
||||
const confidence = _getConfidenceBar(step.confidence);
|
||||
|
||||
const stepEl = document.createElement('div');
|
||||
stepEl.className = `trace-step trace-step-${step.type.toLowerCase()}`;
|
||||
stepEl.dataset.stepId = step.id;
|
||||
|
||||
// Step header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'trace-step-header';
|
||||
header.innerHTML = `
|
||||
<span class="step-icon">${typeConfig.icon}</span>
|
||||
<span class="step-type" style="color: ${typeConfig.color}">${typeConfig.label}</span>
|
||||
<span class="step-time">${timestamp}</span>
|
||||
${confidence}
|
||||
`;
|
||||
|
||||
// Step content
|
||||
const content = document.createElement('div');
|
||||
content.className = 'trace-step-content';
|
||||
|
||||
if (step.thought) {
|
||||
const thought = document.createElement('div');
|
||||
thought.className = 'step-thought';
|
||||
thought.textContent = step.thought;
|
||||
content.appendChild(thought);
|
||||
}
|
||||
|
||||
if (step.reasoning) {
|
||||
const reasoning = document.createElement('div');
|
||||
reasoning.className = 'step-reasoning';
|
||||
reasoning.textContent = step.reasoning;
|
||||
content.appendChild(reasoning);
|
||||
}
|
||||
|
||||
if (step.decision) {
|
||||
const decision = document.createElement('div');
|
||||
decision.className = 'step-decision';
|
||||
decision.innerHTML = `<strong>Decision:</strong> ${_escapeHtml(step.decision)}`;
|
||||
content.appendChild(decision);
|
||||
}
|
||||
|
||||
if (step.alternatives && step.alternatives.length > 0) {
|
||||
const alternatives = document.createElement('div');
|
||||
alternatives.className = 'step-alternatives';
|
||||
alternatives.innerHTML = `<strong>Alternatives:</strong> ${step.alternatives.map(a => _escapeHtml(a)).join(', ')}`;
|
||||
content.appendChild(alternatives);
|
||||
}
|
||||
|
||||
if (step.source) {
|
||||
const source = document.createElement('div');
|
||||
source.className = 'step-source';
|
||||
source.innerHTML = `<strong>Source:</strong> ${_escapeHtml(step.source)}`;
|
||||
content.appendChild(source);
|
||||
}
|
||||
|
||||
stepEl.appendChild(header);
|
||||
stepEl.appendChild(content);
|
||||
|
||||
return stepEl;
|
||||
}
|
||||
|
||||
function _render() {
|
||||
if (!_content) return;
|
||||
|
||||
// Clear content
|
||||
_content.innerHTML = '';
|
||||
|
||||
// Update task indicator
|
||||
const taskEl = document.getElementById('trace-task');
|
||||
if (taskEl) {
|
||||
taskEl.textContent = _currentTask || 'No active task';
|
||||
taskEl.className = _currentTask ? 'trace-task active' : 'trace-task';
|
||||
}
|
||||
|
||||
// Update step counter
|
||||
const counterEl = document.getElementById('trace-counter');
|
||||
if (counterEl) {
|
||||
counterEl.textContent = `${_steps.length} step${_steps.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
// Render steps (newest first)
|
||||
const sortedSteps = [..._steps].sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
for (let i = 0; i < sortedSteps.length; i++) {
|
||||
const stepEl = _renderStep(sortedSteps[i], i);
|
||||
_content.appendChild(stepEl);
|
||||
|
||||
// Add separator between steps
|
||||
if (i < sortedSteps.length - 1) {
|
||||
const separator = document.createElement('div');
|
||||
separator.className = 'trace-separator';
|
||||
_content.appendChild(separator);
|
||||
}
|
||||
}
|
||||
|
||||
// Show empty state if no steps
|
||||
if (_steps.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'trace-empty';
|
||||
empty.innerHTML = `
|
||||
<span class="empty-icon">💭</span>
|
||||
<span class="empty-text">No reasoning steps yet</span>
|
||||
<span class="empty-hint">Start a task to see the trace</span>
|
||||
`;
|
||||
_content.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
_createDOM();
|
||||
_render();
|
||||
console.info('[ReasoningTrace] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reasoning step to the trace.
|
||||
* @param {Object} step - The reasoning step
|
||||
* @param {string} step.type - Step type (THINK, DECIDE, RECALL, PLAN, EXECUTE, VERIFY, DOUBT, MEMORY)
|
||||
* @param {string} step.thought - The main thought/content
|
||||
* @param {string} [step.reasoning] - Detailed reasoning
|
||||
* @param {string} [step.decision] - Decision made
|
||||
* @param {string[]} [step.alternatives] - Alternative options considered
|
||||
* @param {string} [step.source] - Source of information
|
||||
* @param {number} [step.confidence] - Confidence level (0-1)
|
||||
* @param {string} [step.taskId] - Associated task ID
|
||||
*/
|
||||
function addStep(step) {
|
||||
if (!step || !step.thought) return;
|
||||
|
||||
// Generate unique ID
|
||||
const id = `step-${++_stepCounter}-${Date.now()}`;
|
||||
|
||||
// Create step object
|
||||
const newStep = {
|
||||
id,
|
||||
timestamp: Date.now(),
|
||||
type: step.type || 'THINK',
|
||||
thought: step.thought,
|
||||
reasoning: step.reasoning || null,
|
||||
decision: step.decision || null,
|
||||
alternatives: step.alternatives || null,
|
||||
source: step.source || null,
|
||||
confidence: step.confidence !== undefined ? Math.max(0, Math.min(1, step.confidence)) : null,
|
||||
taskId: step.taskId || _currentTask
|
||||
};
|
||||
|
||||
// Add to steps array
|
||||
_steps.unshift(newStep);
|
||||
|
||||
// Limit number of steps
|
||||
if (_steps.length > _maxSteps) {
|
||||
_steps = _steps.slice(0, _maxSteps);
|
||||
}
|
||||
|
||||
// Update task if provided
|
||||
if (step.taskId && step.taskId !== _currentTask) {
|
||||
setTask(step.taskId);
|
||||
}
|
||||
|
||||
// Re-render
|
||||
_render();
|
||||
|
||||
// Log to console for debugging
|
||||
console.debug(`[ReasoningTrace] ${newStep.type}: ${newStep.thought}`);
|
||||
|
||||
return newStep.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current task being traced.
|
||||
* @param {string} taskId - Task identifier
|
||||
*/
|
||||
function setTask(taskId) {
|
||||
_currentTask = taskId;
|
||||
_render();
|
||||
console.info(`[ReasoningTrace] Task set: ${taskId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all steps from the trace.
|
||||
*/
|
||||
function clear() {
|
||||
_steps = [];
|
||||
_stepCounter = 0;
|
||||
_render();
|
||||
console.info('[ReasoningTrace] Cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of the trace panel.
|
||||
*/
|
||||
function toggle() {
|
||||
_isVisible = !_isVisible;
|
||||
if (_container) {
|
||||
_container.style.display = _isVisible ? 'block' : 'none';
|
||||
}
|
||||
console.info(`[ReasoningTrace] Visibility: ${_isVisible ? 'shown' : 'hidden'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the trace as JSON.
|
||||
* @returns {string} JSON string of the trace
|
||||
*/
|
||||
function exportTrace() {
|
||||
const exportData = {
|
||||
task: _currentTask,
|
||||
exportedAt: new Date().toISOString(),
|
||||
steps: _steps.map(step => ({
|
||||
type: step.type,
|
||||
thought: step.thought,
|
||||
reasoning: step.reasoning,
|
||||
decision: step.decision,
|
||||
alternatives: step.alternatives,
|
||||
source: step.source,
|
||||
confidence: step.confidence,
|
||||
timestamp: new Date(step.timestamp).toISOString()
|
||||
}))
|
||||
};
|
||||
|
||||
const json = JSON.stringify(exportData, null, 2);
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
console.info('[ReasoningTrace] Copied to clipboard');
|
||||
// Show feedback
|
||||
const btn = document.getElementById('trace-export');
|
||||
if (btn) {
|
||||
const original = btn.innerHTML;
|
||||
btn.innerHTML = '✅';
|
||||
setTimeout(() => { btn.innerHTML = original; }, 1000);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[ReasoningTrace] Failed to copy:', err);
|
||||
});
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current trace data.
|
||||
* @returns {Object} Current trace state
|
||||
*/
|
||||
function getTrace() {
|
||||
return {
|
||||
task: _currentTask,
|
||||
steps: [..._steps],
|
||||
stepCount: _steps.length,
|
||||
isVisible: _isVisible
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get steps filtered by type.
|
||||
* @param {string} type - Step type to filter by
|
||||
* @returns {Array} Filtered steps
|
||||
*/
|
||||
function getStepsByType(type) {
|
||||
return _steps.filter(step => step.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get steps for a specific task.
|
||||
* @param {string} taskId - Task ID to filter by
|
||||
* @returns {Array} Filtered steps
|
||||
*/
|
||||
function getStepsByTask(taskId) {
|
||||
return _steps.filter(step => step.taskId === taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current task as complete.
|
||||
* @param {string} [result] - Optional result description
|
||||
*/
|
||||
function completeTask(result) {
|
||||
if (_currentTask) {
|
||||
addStep({
|
||||
type: 'VERIFY',
|
||||
thought: `Task completed: ${result || 'Success'}`,
|
||||
taskId: _currentTask
|
||||
});
|
||||
|
||||
// Clear current task after a delay
|
||||
setTimeout(() => {
|
||||
_currentTask = null;
|
||||
_render();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Return Public API ─────────────────────────────
|
||||
|
||||
return {
|
||||
init,
|
||||
addStep,
|
||||
setTask,
|
||||
clear,
|
||||
toggle,
|
||||
exportTrace,
|
||||
getTrace,
|
||||
getStepsByType,
|
||||
getStepsByTask,
|
||||
completeTask,
|
||||
STEP_TYPES
|
||||
};
|
||||
})();
|
||||
|
||||
export { ReasoningTrace };
|
||||
@@ -304,43 +304,6 @@ async def inject_event(event_type: str, ws_url: str, **kwargs):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_lines(text: str) -> str:
|
||||
"""Remove ANSI codes and collapse whitespace from log text."""
|
||||
import re
|
||||
text = strip_ansi(text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def normalize_event(event: dict) -> dict:
|
||||
"""Normalize an Evennia event dict to standard format."""
|
||||
return {
|
||||
"type": event.get("type", "unknown"),
|
||||
"actor": event.get("actor", event.get("name", "")),
|
||||
"room": event.get("room", event.get("location", "")),
|
||||
"message": event.get("message", event.get("text", "")),
|
||||
"timestamp": event.get("timestamp", ""),
|
||||
}
|
||||
|
||||
|
||||
def parse_room_output(text: str) -> dict:
|
||||
"""Parse Evennia room output into structured data."""
|
||||
import re
|
||||
lines = text.strip().split("\n")
|
||||
result = {"name": "", "description": "", "exits": [], "objects": []}
|
||||
if lines:
|
||||
result["name"] = strip_ansi(lines[0]).strip()
|
||||
if len(lines) > 1:
|
||||
result["description"] = strip_ansi(lines[1]).strip()
|
||||
for line in lines[2:]:
|
||||
line = strip_ansi(line).strip()
|
||||
if line.startswith("Exits:"):
|
||||
result["exits"] = [e.strip() for e in line[6:].split(",") if e.strip()]
|
||||
elif line.startswith("You see:"):
|
||||
result["objects"] = [o.strip() for o in line[8:].split(",") if o.strip()]
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
|
||||
sub = parser.add_subparsers(dest="mode")
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""llama_provider.py — Hermes inference router provider for llama.cpp."""
|
||||
import logging, os, time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from bin.llama_client import ChatMessage, LlamaClient
|
||||
|
||||
logger = logging.getLogger("nexus.llama_provider")
|
||||
|
||||
LLAMA_ENDPOINT = os.environ.get("LLAMA_ENDPOINT", "http://localhost:11435")
|
||||
LLAMA_MODEL = os.environ.get("LLAMA_MODEL", "qwen2.5-7b")
|
||||
LOCAL_ONLY = os.environ.get("LOCAL_ONLY", "false").lower() in ("true", "1", "yes")
|
||||
FALLBACK_ON_FAILURE = os.environ.get("LLAMA_FALLBACK", "true").lower() in ("true", "1", "yes")
|
||||
|
||||
@dataclass
|
||||
class ProviderResult:
|
||||
text: str
|
||||
provider: str = "llama.cpp"
|
||||
model: str = ""
|
||||
tokens_used: int = 0
|
||||
latency_ms: float = 0.0
|
||||
finish_reason: str = ""
|
||||
is_local: bool = True
|
||||
error: Optional[str] = None
|
||||
|
||||
class LlamaProvider:
|
||||
def __init__(self, endpoint=LLAMA_ENDPOINT, model=LLAMA_MODEL, local_only=LOCAL_ONLY):
|
||||
self.client = LlamaClient(endpoint=endpoint, model=model)
|
||||
self.local_only = local_only
|
||||
self.endpoint = endpoint
|
||||
self._last_health = None
|
||||
self._last_check = 0.0
|
||||
|
||||
def available(self):
|
||||
now = time.time()
|
||||
if self._last_health is not None and (now - self._last_check) < 30:
|
||||
return self._last_health
|
||||
status = self.client.health_check()
|
||||
self._last_health = status.healthy and status.model_loaded
|
||||
self._last_check = now
|
||||
if not self._last_health:
|
||||
logger.warning("llama.cpp unhealthy: %s", status.error or "model not loaded")
|
||||
return self._last_health
|
||||
|
||||
def infer(self, messages, max_tokens=512, temperature=0.7, model=None, **kwargs):
|
||||
if not self.available():
|
||||
return ProviderResult(text="", error=f"llama.cpp at {self.endpoint} unavailable")
|
||||
chat_msgs = [ChatMessage(m["role"], m["content"]) for m in messages if "role" in m and "content" in m]
|
||||
if not chat_msgs:
|
||||
return ProviderResult(text="", error="No valid messages")
|
||||
start = time.time()
|
||||
try:
|
||||
resp = self.client.chat(chat_msgs, max_tokens=max_tokens, temperature=temperature)
|
||||
return ProviderResult(text=resp.text, provider="llama.cpp",
|
||||
model=resp.model or self.client.model, tokens_used=resp.tokens_used,
|
||||
latency_ms=(time.time()-start)*1000, finish_reason=resp.finish_reason, is_local=True)
|
||||
except Exception as e:
|
||||
logger.error("llama.cpp failed: %s", e)
|
||||
return ProviderResult(text="", error=str(e))
|
||||
|
||||
def should_use_local(self, external_failed=False, explicit_local=False):
|
||||
if self.local_only: return True
|
||||
if explicit_local: return True
|
||||
if external_failed and FALLBACK_ON_FAILURE: return self.available()
|
||||
return False
|
||||
|
||||
def status(self):
|
||||
h = self.client.health_check()
|
||||
return {"provider": "llama.cpp", "endpoint": self.endpoint,
|
||||
"healthy": h.healthy, "model_loaded": h.model_loaded,
|
||||
"model_name": h.model_name, "local_only": self.local_only}
|
||||
|
||||
def get_name(self): return "llama.cpp"
|
||||
def get_priority(self): return 0 if self.local_only else 100
|
||||
286
nexus/lod-manager.js
Normal file
286
nexus/lod-manager.js
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════
|
||||
* NEXUS LOD SYSTEM — Level of Detail Management
|
||||
* ═══════════════════════════════════════════
|
||||
*
|
||||
* Provides automatic LOD switching based on distance from camera.
|
||||
* Optimizes performance for local hardware.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class LODManager {
|
||||
constructor(camera, scene) {
|
||||
this.camera = camera;
|
||||
this.scene = scene;
|
||||
this.lodObjects = new Map(); // object UUID → { levels[], currentLevel }
|
||||
this.updateInterval = 0.5; // seconds between LOD updates
|
||||
this.lastUpdate = 0;
|
||||
this.distanceThresholds = {
|
||||
high: 15, // High detail within 15 units
|
||||
medium: 30, // Medium detail within 30 units
|
||||
low: 50, // Low detail within 50 units
|
||||
cull: 100, // Cull beyond 100 units
|
||||
};
|
||||
this.stats = {
|
||||
totalObjects: 0,
|
||||
highDetail: 0,
|
||||
mediumDetail: 0,
|
||||
lowDetail: 0,
|
||||
culled: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an object for LOD management
|
||||
* @param {THREE.Object3D} object - The object to manage
|
||||
* @param {Object} lodLevels - LOD level configurations
|
||||
* lodLevels = {
|
||||
* high: { geometry: THREE.BufferGeometry, material: THREE.Material },
|
||||
* medium: { geometry: THREE.BufferGeometry, material: THREE.Material },
|
||||
* low: { geometry: THREE.BufferGeometry, material: THREE.Material },
|
||||
* }
|
||||
*/
|
||||
registerObject(object, lodLevels) {
|
||||
const uuid = object.uuid;
|
||||
|
||||
// Store original object data
|
||||
const original = {
|
||||
geometry: object.geometry,
|
||||
material: object.material,
|
||||
position: object.position.clone(),
|
||||
rotation: object.rotation.clone(),
|
||||
scale: object.scale.clone(),
|
||||
};
|
||||
|
||||
// Create LOD meshes
|
||||
const levels = {};
|
||||
for (const [levelName, config] of Object.entries(lodLevels)) {
|
||||
const mesh = new THREE.Mesh(config.geometry, config.material);
|
||||
mesh.position.copy(original.position);
|
||||
mesh.rotation.copy(original.rotation);
|
||||
mesh.scale.copy(original.scale);
|
||||
mesh.visible = false;
|
||||
mesh.userData.lodLevel = levelName;
|
||||
mesh.userData.parentUUID = uuid;
|
||||
this.scene.add(mesh);
|
||||
levels[levelName] = mesh;
|
||||
}
|
||||
|
||||
// Store LOD data
|
||||
this.lodObjects.set(uuid, {
|
||||
object,
|
||||
original,
|
||||
levels,
|
||||
currentLevel: 'high',
|
||||
});
|
||||
|
||||
// Hide original, show high-detail
|
||||
object.visible = false;
|
||||
levels.high.visible = true;
|
||||
|
||||
this.stats.totalObjects++;
|
||||
this.stats.highDetail++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create LOD levels for a sphere (agent orbs)
|
||||
*/
|
||||
static createSphereLODs(radius, color, emissiveIntensity = 2) {
|
||||
return {
|
||||
high: {
|
||||
geometry: new THREE.SphereGeometry(radius, 32, 32),
|
||||
material: new THREE.MeshPhysicalMaterial({
|
||||
color: color,
|
||||
emissive: color,
|
||||
emissiveIntensity: emissiveIntensity,
|
||||
roughness: 0,
|
||||
metalness: 1,
|
||||
transmission: 0.8,
|
||||
thickness: 0.5,
|
||||
}),
|
||||
},
|
||||
medium: {
|
||||
geometry: new THREE.SphereGeometry(radius, 16, 16),
|
||||
material: new THREE.MeshStandardMaterial({
|
||||
color: color,
|
||||
emissive: color,
|
||||
emissiveIntensity: emissiveIntensity * 0.8,
|
||||
roughness: 0.2,
|
||||
metalness: 0.8,
|
||||
}),
|
||||
},
|
||||
low: {
|
||||
geometry: new THREE.SphereGeometry(radius, 8, 8),
|
||||
material: new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create LOD levels for a torus (halos)
|
||||
*/
|
||||
static createTorusLODs(radius, tube, color) {
|
||||
return {
|
||||
high: {
|
||||
geometry: new THREE.TorusGeometry(radius, tube, 16, 64),
|
||||
material: new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
}),
|
||||
},
|
||||
medium: {
|
||||
geometry: new THREE.TorusGeometry(radius, tube, 12, 32),
|
||||
material: new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
}),
|
||||
},
|
||||
low: {
|
||||
geometry: new THREE.TorusGeometry(radius, tube * 1.5, 8, 16),
|
||||
material: new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create LOD levels for a cylinder (pillars)
|
||||
*/
|
||||
static createCylinderLODs(radiusTop, radiusBottom, height, color) {
|
||||
return {
|
||||
high: {
|
||||
geometry: new THREE.CylinderGeometry(radiusTop, radiusBottom, height, 32),
|
||||
material: new THREE.MeshStandardMaterial({
|
||||
color: color,
|
||||
metalness: 0.7,
|
||||
roughness: 0.3,
|
||||
}),
|
||||
},
|
||||
medium: {
|
||||
geometry: new THREE.CylinderGeometry(radiusTop, radiusBottom, height, 16),
|
||||
material: new THREE.MeshStandardMaterial({
|
||||
color: color,
|
||||
metalness: 0.5,
|
||||
ground: 0.5,
|
||||
}),
|
||||
},
|
||||
low: {
|
||||
geometry: new THREE.CylinderGeometry(radiusTop, radiusBottom, height, 8),
|
||||
material: new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update LOD levels based on camera distance
|
||||
*/
|
||||
update(deltaTime) {
|
||||
this.lastUpdate += deltaTime;
|
||||
if (this.lastUpdate < this.updateInterval) return;
|
||||
this.lastUpdate = 0;
|
||||
|
||||
const cameraPos = this.camera.position;
|
||||
|
||||
// Reset stats
|
||||
this.stats.highDetail = 0;
|
||||
this.stats.mediumDetail = 0;
|
||||
this.stats.lowDetail = 0;
|
||||
this.stats.culled = 0;
|
||||
|
||||
for (const [uuid, lodData] of this.lodObjects) {
|
||||
const distance = cameraPos.distanceTo(lodData.object.position);
|
||||
|
||||
// Determine target LOD level
|
||||
let targetLevel;
|
||||
if (distance < this.distanceThresholds.high) {
|
||||
targetLevel = 'high';
|
||||
} else if (distance < this.distanceThresholds.medium) {
|
||||
targetLevel = 'medium';
|
||||
} else if (distance < this.distanceThresholds.low) {
|
||||
targetLevel = 'low';
|
||||
} else {
|
||||
targetLevel = 'culled';
|
||||
}
|
||||
|
||||
// Update LOD if changed
|
||||
if (targetLevel !== lodData.currentLevel) {
|
||||
// Hide current level
|
||||
if (lodData.levels[lodData.currentLevel]) {
|
||||
lodData.levels[lodData.currentLevel].visible = false;
|
||||
}
|
||||
|
||||
// Show new level (or cull)
|
||||
if (targetLevel !== 'culled' && lodData.levels[targetLevel]) {
|
||||
lodData.levels[targetLevel].visible = true;
|
||||
}
|
||||
|
||||
lodData.currentLevel = targetLevel;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
switch (targetLevel) {
|
||||
case 'high': this.stats.highDetail++; break;
|
||||
case 'medium': this.stats.mediumDetail++; break;
|
||||
case 'low': this.stats.lowDetail++; break;
|
||||
case 'culled': this.stats.culled++; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current LOD statistics
|
||||
*/
|
||||
getStats() {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set distance thresholds
|
||||
*/
|
||||
setThresholds(high, medium, low, cull) {
|
||||
this.distanceThresholds = { high, medium, low, cull };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove object from LOD management
|
||||
*/
|
||||
unregisterObject(uuid) {
|
||||
const lodData = this.lodObjects.get(uuid);
|
||||
if (!lodData) return;
|
||||
|
||||
// Remove LOD meshes from scene
|
||||
for (const mesh of Object.values(lodData.levels)) {
|
||||
this.scene.remove(mesh);
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
}
|
||||
|
||||
// Restore original object visibility
|
||||
lodData.object.visible = true;
|
||||
|
||||
this.lodObjects.delete(uuid);
|
||||
this.stats.totalObjects--;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all LOD objects
|
||||
*/
|
||||
dispose() {
|
||||
for (const [uuid] of this.lodObjects) {
|
||||
this.unregisterObject(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const lodManager = new LODManager();
|
||||
38
nexus/lod-manager.test.js
Normal file
38
nexus/lod-manager.test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════
|
||||
* NEXUS LOD SYSTEM TEST — Verification Script
|
||||
* ═══════════════════════════════════════════
|
||||
*
|
||||
* Simple test to verify LOD system functionality.
|
||||
*/
|
||||
|
||||
import { LODManager } from './lod-manager.js';
|
||||
|
||||
// Test LOD creation
|
||||
console.log('Testing LOD system...');
|
||||
|
||||
// Test sphere LODs
|
||||
const sphereLods = LODManager.createSphereLODs(0.5, 0xff0000, 2);
|
||||
console.log('Sphere LODs:', {
|
||||
high: sphereLods.high.geometry.parameters,
|
||||
medium: sphereLods.medium.geometry.parameters,
|
||||
low: sphereLods.low.geometry.parameters,
|
||||
});
|
||||
|
||||
// Test torus LODs
|
||||
const torusLods = LODManager.createTorusLODs(0.6, 0.02, 0x00ff00);
|
||||
console.log('Torus LODs:', {
|
||||
high: torusLods.high.geometry.parameters,
|
||||
medium: torusLods.medium.geometry.parameters,
|
||||
low: torusLods.low.geometry.parameters,
|
||||
});
|
||||
|
||||
// Test cylinder LODs
|
||||
const cylinderLods = LODManager.createCylinderLODs(0.3, 0.3, 2, 0x0000ff);
|
||||
console.log('Cylinder LODs:', {
|
||||
high: cylinderLods.high.geometry.parameters,
|
||||
medium: cylinderLods.medium.geometry.parameters,
|
||||
low: cylinderLods.low.geometry.parameters,
|
||||
});
|
||||
|
||||
console.log('LOD system test complete!');
|
||||
@@ -1,283 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
McDonald Wizard — Hermes shim for the McDonald chatbot API
|
||||
|
||||
Exposes the `mcdonald-wizard` Hermes tool, which forwards prompts to the
|
||||
McDonald chatbot API and returns wizard-style responses. Registered as a
|
||||
Hermes skill via ~/.hermes/skills/shim-mcdonald-wizard.py.
|
||||
|
||||
Usage:
|
||||
from nexus.mcdonald_wizard import McdonaldWizard
|
||||
wizard = McdonaldWizard()
|
||||
response = wizard.ask("What is your quest?")
|
||||
print(response.text)
|
||||
|
||||
Environment Variables:
|
||||
MCDONALDS_API_KEY — McDonald chatbot API key (required)
|
||||
MCDONALDS_ENDPOINT — API endpoint (default: https://api.mcdonalds.com/v1/chat)
|
||||
MCDONALDS_TIMEOUT — Request timeout in seconds (default: 30)
|
||||
MCDONALDS_RETRIES — Max retry attempts (default: 3)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger("mcdonald_wizard")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [mcdonald_wizard] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
DEFAULT_ENDPOINT = "https://api.mcdonalds.com/v1/chat"
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DEFAULT_RETRIES = 3
|
||||
WIZARD_ID = "mcdonald-wizard"
|
||||
|
||||
# Retry backoff: base * 2^(attempt-1)
|
||||
RETRY_BASE_DELAY = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class WizardResponse:
|
||||
"""Response from the McDonald chatbot wizard."""
|
||||
|
||||
text: str = ""
|
||||
model: str = ""
|
||||
latency_ms: float = 0.0
|
||||
attempt: int = 1
|
||||
error: Optional[str] = None
|
||||
timestamp: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"text": self.text,
|
||||
"model": self.model,
|
||||
"latency_ms": self.latency_ms,
|
||||
"attempt": self.attempt,
|
||||
"error": self.error,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
|
||||
class McdonaldWizard:
|
||||
"""
|
||||
McDonald chatbot wizard client.
|
||||
|
||||
Forwards prompts to the McDonald chatbot API with retry/timeout handling.
|
||||
Integrates with Hermes as the `mcdonald-wizard` tool.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
max_retries: Optional[int] = None,
|
||||
):
|
||||
self.api_key = api_key or os.environ.get("MCDONALDS_API_KEY", "")
|
||||
self.endpoint = endpoint or os.environ.get(
|
||||
"MCDONALDS_ENDPOINT", DEFAULT_ENDPOINT
|
||||
)
|
||||
self.timeout = timeout or int(
|
||||
os.environ.get("MCDONALDS_TIMEOUT", DEFAULT_TIMEOUT)
|
||||
)
|
||||
self.max_retries = max_retries or int(
|
||||
os.environ.get("MCDONALDS_RETRIES", DEFAULT_RETRIES)
|
||||
)
|
||||
|
||||
if not self.api_key:
|
||||
log.warning(
|
||||
"MCDONALDS_API_KEY not set — wizard will return errors on live calls"
|
||||
)
|
||||
|
||||
# Session stats
|
||||
self.request_count = 0
|
||||
self.total_latency_ms = 0.0
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _post_with_retry(self, payload: dict) -> tuple[dict, int, float]:
|
||||
"""
|
||||
POST to the McDonald API with retry/backoff.
|
||||
|
||||
Returns (response_json, attempt_number, latency_ms).
|
||||
Raises on final failure.
|
||||
"""
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
resp = requests.post(
|
||||
self.endpoint,
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
latency_ms = (time.monotonic() - t0) * 1000
|
||||
if resp.status_code in (429, 500, 502, 503, 504):
|
||||
raise requests.HTTPError(
|
||||
f"HTTP {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json(), attempt, latency_ms
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt < self.max_retries:
|
||||
delay = RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
||||
log.warning(
|
||||
"attempt %d/%d failed (%s) — retrying in %.1fs",
|
||||
attempt,
|
||||
self.max_retries,
|
||||
exc,
|
||||
delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
else:
|
||||
log.error(
|
||||
"all %d attempts failed: %s", self.max_retries, exc
|
||||
)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
def ask(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
context: Optional[str] = None,
|
||||
) -> WizardResponse:
|
||||
"""
|
||||
Send a prompt to the McDonald wizard chatbot.
|
||||
|
||||
Args:
|
||||
prompt: User message to the wizard.
|
||||
system: Optional system instruction override.
|
||||
context: Optional prior context to prepend.
|
||||
|
||||
Returns:
|
||||
WizardResponse with text, latency, and error fields.
|
||||
"""
|
||||
if not self.api_key:
|
||||
return WizardResponse(
|
||||
error="MCDONALDS_API_KEY not set — cannot call McDonald wizard API"
|
||||
)
|
||||
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
if context:
|
||||
messages.append({"role": "user", "content": context})
|
||||
messages.append(
|
||||
{"role": "assistant", "content": "Understood, I have the context."}
|
||||
)
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
payload = {"messages": messages}
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
data, attempt, latency_ms = self._post_with_retry(payload)
|
||||
except Exception as exc:
|
||||
latency_ms = (time.monotonic() - t0) * 1000
|
||||
self.request_count += 1
|
||||
self.total_latency_ms += latency_ms
|
||||
return WizardResponse(
|
||||
error=f"McDonald wizard API failed: {exc}",
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
self.request_count += 1
|
||||
self.total_latency_ms += latency_ms
|
||||
|
||||
text = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
)
|
||||
model = data.get("model", "")
|
||||
|
||||
return WizardResponse(
|
||||
text=text,
|
||||
model=model,
|
||||
latency_ms=latency_ms,
|
||||
attempt=attempt,
|
||||
)
|
||||
|
||||
def session_stats(self) -> dict:
|
||||
"""Return session telemetry."""
|
||||
return {
|
||||
"wizard_id": WIZARD_ID,
|
||||
"request_count": self.request_count,
|
||||
"total_latency_ms": self.total_latency_ms,
|
||||
"avg_latency_ms": (
|
||||
self.total_latency_ms / self.request_count
|
||||
if self.request_count
|
||||
else 0.0
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Hermes tool function ──────────────────────────────────────────────────
|
||||
|
||||
_wizard_instance: Optional[McdonaldWizard] = None
|
||||
|
||||
|
||||
def _get_wizard() -> McdonaldWizard:
|
||||
global _wizard_instance
|
||||
if _wizard_instance is None:
|
||||
_wizard_instance = McdonaldWizard()
|
||||
return _wizard_instance
|
||||
|
||||
|
||||
def mcdonald_wizard(prompt: str, system: Optional[str] = None) -> dict:
|
||||
"""
|
||||
Hermes tool: forward *prompt* to the McDonald chatbot wizard.
|
||||
|
||||
Args:
|
||||
prompt: The message to send to the wizard.
|
||||
system: Optional system instruction.
|
||||
|
||||
Returns:
|
||||
dict with keys: text, model, latency_ms, attempt, error.
|
||||
"""
|
||||
wizard = _get_wizard()
|
||||
resp = wizard.ask(prompt, system=system)
|
||||
return resp.to_dict()
|
||||
|
||||
|
||||
# ── CLI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="McDonald Wizard CLI")
|
||||
parser.add_argument("prompt", nargs="?", default="Greetings, wizard!", help="Prompt to send")
|
||||
parser.add_argument("--system", default=None, help="System instruction")
|
||||
parser.add_argument("--endpoint", default=None, help="API endpoint override")
|
||||
args = parser.parse_args()
|
||||
|
||||
wizard = McdonaldWizard(endpoint=args.endpoint)
|
||||
resp = wizard.ask(args.prompt, system=args.system)
|
||||
if resp.error:
|
||||
print(f"[ERROR] {resp.error}")
|
||||
else:
|
||||
print(resp.text)
|
||||
print(f"\n[latency={resp.latency_ms:.0f}ms attempt={resp.attempt} model={resp.model}]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,12 +13,6 @@ from __future__ import annotations
|
||||
|
||||
from nexus.mempalace.config import MEMPALACE_PATH, FLEET_WING
|
||||
from nexus.mempalace.searcher import search_memories, add_memory, MemPalaceResult
|
||||
from nexus.mempalace.conversation_artifacts import (
|
||||
ConversationArtifact,
|
||||
build_request_response_artifact,
|
||||
extract_alexander_request_pairs,
|
||||
normalize_speaker,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MEMPALACE_PATH",
|
||||
@@ -26,8 +20,4 @@ __all__ = [
|
||||
"search_memories",
|
||||
"add_memory",
|
||||
"MemPalaceResult",
|
||||
"ConversationArtifact",
|
||||
"build_request_response_artifact",
|
||||
"extract_alexander_request_pairs",
|
||||
"normalize_speaker",
|
||||
]
|
||||
|
||||
@@ -40,7 +40,6 @@ CORE_ROOMS: list[str] = [
|
||||
"nexus", # reports, docs, KT
|
||||
"issues", # tickets, backlog
|
||||
"experiments", # prototypes, spikes
|
||||
"sovereign", # Alexander request/response artifacts
|
||||
]
|
||||
|
||||
# ── ChromaDB collection name ──────────────────────────────────────────────────
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Helpers for preserving Alexander request/response artifacts in MemPalace.
|
||||
|
||||
This module provides a small, typed bridge between raw conversation turns and
|
||||
MemPalace drawers stored in the shared `sovereign` room. The goal is not to
|
||||
solve all future speaker-tagging needs at once; it gives the Nexus one
|
||||
canonical artifact shape that other miners and bridges can reuse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
_ALEXANDER_ALIASES = {
|
||||
"alexander",
|
||||
"alexander whitestone",
|
||||
"rockachopa",
|
||||
"triptimmy",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConversationArtifact:
|
||||
requester: str
|
||||
responder: str
|
||||
request_text: str
|
||||
response_text: str
|
||||
room: str = "sovereign"
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return (
|
||||
f"# Conversation Artifact\n\n"
|
||||
f"## Alexander Request\n{self.request_text.strip()}\n\n"
|
||||
f"## Wizard Response\n{self.response_text.strip()}\n"
|
||||
)
|
||||
|
||||
|
||||
def normalize_speaker(name: str | None) -> str:
|
||||
cleaned = " ".join((name or "").strip().lower().split())
|
||||
if cleaned in _ALEXANDER_ALIASES:
|
||||
return "alexander"
|
||||
return cleaned.replace(" ", "_") or "unknown"
|
||||
|
||||
|
||||
def build_request_response_artifact(
|
||||
*,
|
||||
requester: str,
|
||||
responder: str,
|
||||
request_text: str,
|
||||
response_text: str,
|
||||
source: str = "",
|
||||
timestamp: str | None = None,
|
||||
request_timestamp: str | None = None,
|
||||
response_timestamp: str | None = None,
|
||||
) -> ConversationArtifact:
|
||||
requester_slug = normalize_speaker(requester)
|
||||
responder_slug = normalize_speaker(responder)
|
||||
ts = timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
metadata = {
|
||||
"artifact_type": "alexander_request_response",
|
||||
"requester": requester_slug,
|
||||
"responder": responder_slug,
|
||||
"speaker_tags": [f"speaker:{requester_slug}", f"speaker:{responder_slug}"],
|
||||
"source": source,
|
||||
"timestamp": ts,
|
||||
}
|
||||
if request_timestamp:
|
||||
metadata["request_timestamp"] = request_timestamp
|
||||
if response_timestamp:
|
||||
metadata["response_timestamp"] = response_timestamp
|
||||
return ConversationArtifact(
|
||||
requester=requester_slug,
|
||||
responder=responder_slug,
|
||||
request_text=request_text,
|
||||
response_text=response_text,
|
||||
timestamp=ts,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def extract_alexander_request_pairs(
|
||||
turns: Iterable[dict],
|
||||
*,
|
||||
responder: str,
|
||||
source: str = "",
|
||||
) -> list[ConversationArtifact]:
|
||||
responder_slug = normalize_speaker(responder)
|
||||
pending_request: dict | None = None
|
||||
artifacts: list[ConversationArtifact] = []
|
||||
|
||||
for turn in turns:
|
||||
speaker = normalize_speaker(
|
||||
turn.get("speaker") or turn.get("username") or turn.get("author") or turn.get("name")
|
||||
)
|
||||
text = (turn.get("text") or turn.get("content") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if speaker == "alexander":
|
||||
pending_request = turn
|
||||
continue
|
||||
|
||||
if speaker == responder_slug and pending_request is not None:
|
||||
artifacts.append(
|
||||
build_request_response_artifact(
|
||||
requester="alexander",
|
||||
responder=responder_slug,
|
||||
request_text=(pending_request.get("text") or pending_request.get("content") or "").strip(),
|
||||
response_text=text,
|
||||
source=source,
|
||||
request_timestamp=pending_request.get("timestamp"),
|
||||
response_timestamp=turn.get("timestamp"),
|
||||
timestamp=turn.get("timestamp") or pending_request.get("timestamp"),
|
||||
)
|
||||
)
|
||||
pending_request = None
|
||||
|
||||
return artifacts
|
||||
294
nexus/performance-integration.js
Normal file
294
nexus/performance-integration.js
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════
|
||||
* NEXUS PERFORMANCE INTEGRATION — LOD + Texture Audit + Stats
|
||||
* ═══════════════════════════════════════════
|
||||
*
|
||||
* Integrates LOD system, texture auditing, and performance
|
||||
* monitoring into the main Nexus application.
|
||||
*/
|
||||
|
||||
import { LODManager } from './lod-manager.js';
|
||||
import { TextureAuditor } from './texture-auditor.js';
|
||||
import { PerformanceMonitor } from './performance-monitor.js';
|
||||
|
||||
export class PerformanceSystem {
|
||||
constructor(camera, scene, renderer) {
|
||||
this.camera = camera;
|
||||
this.scene = scene;
|
||||
this.renderer = renderer;
|
||||
|
||||
// Initialize subsystems
|
||||
this.lodManager = new LODManager(camera, scene);
|
||||
this.textureAuditor = new TextureAuditor();
|
||||
this.performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
// State
|
||||
this.isEnabled = true;
|
||||
this.autoLOD = true;
|
||||
this.autoAudit = true;
|
||||
this.lastAuditTime = 0;
|
||||
this.auditInterval = 30; // seconds between audits
|
||||
|
||||
// Performance tiers for local hardware
|
||||
this.hardwareTiers = {
|
||||
high: {
|
||||
name: 'High (M1 Pro/Max/Ultra)',
|
||||
description: 'M1 Pro or better, 16GB+ RAM',
|
||||
targetFPS: 60,
|
||||
maxDrawCalls: 2000,
|
||||
maxTriangles: 1000000,
|
||||
maxTextures: 100,
|
||||
lodThresholds: { high: 20, medium: 40, low: 60, cull: 100 },
|
||||
},
|
||||
medium: {
|
||||
name: 'Medium (M1/M2)',
|
||||
description: 'Base M1 or M2, 8GB+ RAM',
|
||||
targetFPS: 45,
|
||||
maxDrawCalls: 1000,
|
||||
maxTriangles: 500000,
|
||||
maxTextures: 50,
|
||||
lodThresholds: { high: 15, medium: 30, low: 50, cull: 80 },
|
||||
},
|
||||
low: {
|
||||
name: 'Low (Intel Mac / Older)',
|
||||
description: 'Intel Mac or older hardware',
|
||||
targetFPS: 30,
|
||||
maxDrawCalls: 500,
|
||||
maxTriangles: 200000,
|
||||
maxTextures: 25,
|
||||
lodThresholds: { high: 10, medium: 20, low: 40, cull: 60 },
|
||||
},
|
||||
};
|
||||
|
||||
this.currentTier = 'medium'; // Default to medium
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the performance system
|
||||
*/
|
||||
async init() {
|
||||
console.log('[PerformanceSystem] Initializing...');
|
||||
|
||||
// Initialize performance monitor
|
||||
await this.performanceMonitor.init();
|
||||
|
||||
// Detect hardware tier
|
||||
await this.detectHardwareTier();
|
||||
|
||||
// Apply tier settings
|
||||
this.applyTierSettings();
|
||||
|
||||
// Run initial texture audit
|
||||
if (this.autoAudit) {
|
||||
this.runTextureAudit();
|
||||
}
|
||||
|
||||
console.log(`[PerformanceSystem] Initialized with tier: ${this.currentTier}`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect appropriate hardware tier
|
||||
*/
|
||||
async detectHardwareTier() {
|
||||
// Use WebGL renderer info for detection
|
||||
const gl = this.renderer.getContext();
|
||||
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
|
||||
if (debugInfo) {
|
||||
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
|
||||
console.log(`[PerformanceSystem] Detected GPU: ${renderer}`);
|
||||
|
||||
// Simple heuristic based on renderer string
|
||||
if (renderer.includes('Apple M1 Max') || renderer.includes('Apple M1 Ultra') ||
|
||||
renderer.includes('Apple M2 Pro') || renderer.includes('Apple M2 Max') ||
|
||||
renderer.includes('Apple M3') || renderer.includes('Apple M4')) {
|
||||
this.currentTier = 'high';
|
||||
} else if (renderer.includes('Apple M1') || renderer.includes('Apple M2')) {
|
||||
this.currentTier = 'medium';
|
||||
} else {
|
||||
this.currentTier = 'low';
|
||||
}
|
||||
} else {
|
||||
// Fallback: assume medium
|
||||
console.log('[PerformanceSystem] Could not detect GPU, assuming medium tier');
|
||||
this.currentTier = 'medium';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply settings for current hardware tier
|
||||
*/
|
||||
applyTierSettings() {
|
||||
const tier = this.hardwareTiers[this.currentTier];
|
||||
if (!tier) return;
|
||||
|
||||
// Set LOD thresholds
|
||||
this.lodManager.setThresholds(
|
||||
tier.lodThresholds.high,
|
||||
tier.lodThresholds.medium,
|
||||
tier.lodThresholds.low,
|
||||
tier.lodThresholds.cull
|
||||
);
|
||||
|
||||
// Set texture auditor limits
|
||||
this.textureAuditor.maxTotalTextures = tier.maxTextures;
|
||||
|
||||
console.log(`[PerformanceSystem] Applied ${tier.name} settings`);
|
||||
console.log(` Target FPS: ${tier.targetFPS}`);
|
||||
console.log(` Max draw calls: ${tier.maxDrawCalls}`);
|
||||
console.log(` Max triangles: ${tier.maxTriangles}`);
|
||||
console.log(` Max textures: ${tier.maxTextures}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the performance system
|
||||
*/
|
||||
update(deltaTime) {
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
// Update LOD system
|
||||
if (this.autoLOD) {
|
||||
this.lodManager.update(deltaTime);
|
||||
}
|
||||
|
||||
// Update performance monitor
|
||||
this.performanceMonitor.update(this.renderer, this.scene, deltaTime);
|
||||
|
||||
// Periodic texture audit
|
||||
this.lastAuditTime += deltaTime;
|
||||
if (this.autoAudit && this.lastAuditTime > this.auditInterval) {
|
||||
this.lastAuditTime = 0;
|
||||
this.runTextureAudit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run texture audit
|
||||
*/
|
||||
runTextureAudit() {
|
||||
console.log('[PerformanceSystem] Running texture audit...');
|
||||
this.textureAuditor.clear();
|
||||
const sceneAudit = this.textureAuditor.auditScene(this.scene);
|
||||
const compressionPlan = this.textureAuditor.generateCompressionPlan();
|
||||
|
||||
// Store results
|
||||
this.lastAudit = {
|
||||
sceneAudit,
|
||||
compressionPlan,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
return this.lastAudit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an object for LOD management
|
||||
*/
|
||||
registerForLOD(object, lodLevels) {
|
||||
this.lodManager.registerObject(object, lodLevels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance report
|
||||
*/
|
||||
getPerformanceReport() {
|
||||
const monitorReport = this.performanceMonitor.getReport();
|
||||
const lodStats = this.lodManager.getStats();
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
tier: this.currentTier,
|
||||
tierInfo: this.hardwareTiers[this.currentTier],
|
||||
monitor: monitorReport,
|
||||
lod: lodStats,
|
||||
textureAudit: this.lastAudit || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum hardware requirements based on current scene
|
||||
*/
|
||||
getMinimumHardwareRequirements() {
|
||||
const report = this.getPerformanceReport();
|
||||
const requirements = {
|
||||
recommended: {
|
||||
tier: report.tier,
|
||||
description: report.tierInfo.description,
|
||||
targetFPS: report.tierInfo.targetFPS,
|
||||
notes: [],
|
||||
},
|
||||
minimum: {
|
||||
tier: 'low',
|
||||
description: this.hardwareTiers.low.description,
|
||||
targetFPS: this.hardwareTiers.low.targetFPS,
|
||||
notes: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Generate notes based on current scene complexity
|
||||
if (report.monitor.metrics.drawCalls.current > 1000) {
|
||||
requirements.minimum.notes.push('Scene has high draw call count. LOD system required on lower-end hardware.');
|
||||
}
|
||||
|
||||
if (report.monitor.metrics.triangles.current > 500000) {
|
||||
requirements.minimum.notes.push('High triangle count. Reduce geometry complexity or use LOD on lower-end hardware.');
|
||||
}
|
||||
|
||||
if (report.lod.totalObjects > 10) {
|
||||
requirements.recommended.notes.push(`LOD system managing ${report.lod.totalObjects} objects.`);
|
||||
}
|
||||
|
||||
return requirements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set hardware tier manually
|
||||
*/
|
||||
setHardwareTier(tier) {
|
||||
if (this.hardwareTiers[tier]) {
|
||||
this.currentTier = tier;
|
||||
this.applyTierSettings();
|
||||
console.log(`[PerformanceSystem] Manually set to ${this.hardwareTiers[tier].name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle performance system
|
||||
*/
|
||||
toggle() {
|
||||
this.isEnabled = !this.isEnabled;
|
||||
console.log(`[PerformanceSystem] ${this.isEnabled ? 'Enabled' : 'Disabled'}`);
|
||||
return this.isEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle LOD system
|
||||
*/
|
||||
toggleLOD() {
|
||||
this.autoLOD = !this.autoLOD;
|
||||
console.log(`[PerformanceSystem] LOD ${this.autoLOD ? 'Enabled' : 'Disabled'}`);
|
||||
return this.autoLOD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle texture auditing
|
||||
*/
|
||||
toggleAudit() {
|
||||
this.autoAudit = !this.autoAudit;
|
||||
console.log(`[PerformanceSystem] Texture auditing ${this.autoAudit ? 'Enabled' : 'Disabled'}`);
|
||||
return this.autoAudit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup
|
||||
*/
|
||||
dispose() {
|
||||
this.lodManager.dispose();
|
||||
this.performanceMonitor.dispose();
|
||||
this.isEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const performanceSystem = new PerformanceSystem();
|
||||
264
nexus/performance-monitor.js
Normal file
264
nexus/performance-monitor.js
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════
|
||||
* NEXUS PERFORMANCE MONITOR — stats.js Integration
|
||||
* ═══════════════════════════════════════════
|
||||
*
|
||||
* Provides real-time performance monitoring using stats.js
|
||||
* and custom metrics for LOD and texture systems.
|
||||
*/
|
||||
|
||||
// Import stats.js from CDN
|
||||
const Stats = window.Stats;
|
||||
|
||||
export class PerformanceMonitor {
|
||||
constructor(container = document.body) {
|
||||
this.stats = null;
|
||||
this.customPanels = {};
|
||||
this.isInitialized = false;
|
||||
this.metrics = {
|
||||
fps: { current: 0, min: Infinity, max: 0, avg: 0, history: [] },
|
||||
frameTime: { current: 0, min: Infinity, max: 0, avg: 0, history: [] },
|
||||
drawCalls: { current: 0, min: Infinity, max: 0, avg: 0, history: [] },
|
||||
triangles: { current: 0, min: Infinity, max: 0, avg: 0, history: [] },
|
||||
textures: { current: 0, min: Infinity, max: 0, avg: 0, history: [] },
|
||||
geometries: { current: 0, min: Infinity, max: 0, avg: 0, history: [] },
|
||||
};
|
||||
this.historyLength = 60; // Store 60 samples
|
||||
this.updateInterval = 0.5; // Update stats every 0.5s
|
||||
this.lastUpdate = 0;
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the performance monitor
|
||||
*/
|
||||
async init() {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
// Dynamically load stats.js if not available
|
||||
if (typeof Stats === 'undefined') {
|
||||
await this.loadStatsJS();
|
||||
}
|
||||
|
||||
// Create stats.js instance
|
||||
this.stats = new Stats();
|
||||
this.stats.dom.style.position = 'absolute';
|
||||
this.stats.dom.style.top = '0px';
|
||||
this.stats.dom.style.left = '0px';
|
||||
this.stats.dom.style.zIndex = '10000';
|
||||
this.stats.dom.id = 'nexus-stats';
|
||||
|
||||
// Create custom panels
|
||||
this.createCustomPanel('drawCalls', '#ff8c00', '#1a1a1a', 'Draw Calls');
|
||||
this.createCustomPanel('triangles', '#00ff8c', '#1a1a1a', 'Triangles');
|
||||
this.createCustomPanel('textures', '#ff008c', '#1a1a1a', 'Textures');
|
||||
this.createCustomPanel('geometries', '#008cff', '#1a1a1a', 'Geometries');
|
||||
|
||||
// Add to container
|
||||
this.container.appendChild(this.stats.dom);
|
||||
|
||||
// Add custom panels
|
||||
let topOffset = 48;
|
||||
for (const panel of Object.values(this.customPanels)) {
|
||||
panel.dom.style.top = `${topOffset}px`;
|
||||
this.container.appendChild(panel.dom);
|
||||
topOffset += 48;
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('[PerformanceMonitor] Initialized with stats.js');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stats.js from CDN
|
||||
*/
|
||||
async loadStatsJS() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/stats.js@0.17.0/build/stats.min.js';
|
||||
script.onload = () => {
|
||||
console.log('[PerformanceMonitor] stats.js loaded');
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.error('[PerformanceMonitor] Failed to load stats.js');
|
||||
reject(new Error('Failed to load stats.js'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom stats panel
|
||||
*/
|
||||
createCustomPanel(name, fg, bg, label) {
|
||||
const panel = new Stats.Panel(label, fg, bg);
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
position: absolute;
|
||||
top: 48px;
|
||||
left: 0px;
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
z-index: 10000;
|
||||
`;
|
||||
container.appendChild(panel.dom);
|
||||
|
||||
this.customPanels[name] = {
|
||||
panel,
|
||||
dom: container,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update performance metrics
|
||||
*/
|
||||
update(renderer, scene, deltaTime) {
|
||||
if (!this.isInitialized) return;
|
||||
|
||||
// Update stats.js FPS panel
|
||||
this.stats.update();
|
||||
|
||||
// Update custom panels every interval
|
||||
this.lastUpdate += deltaTime;
|
||||
if (this.lastUpdate < this.updateInterval) return;
|
||||
this.lastUpdate = 0;
|
||||
|
||||
// Get renderer info
|
||||
const info = renderer.info;
|
||||
|
||||
// Update metrics
|
||||
this.updateMetric('drawCalls', info.render.calls);
|
||||
this.updateMetric('triangles', info.render.triangles);
|
||||
this.updateMetric('textures', info.memory.textures);
|
||||
this.updateMetric('geometries', info.memory.geometries);
|
||||
|
||||
// Update custom panels
|
||||
this.updateCustomPanel('drawCalls', info.render.calls);
|
||||
this.updateCustomPanel('triangles', info.render.triangles);
|
||||
this.updateCustomPanel('textures', info.memory.textures);
|
||||
this.updateCustomPanel('geometries', info.memory.geometries);
|
||||
|
||||
// Update FPS metric
|
||||
const fps = 1 / deltaTime;
|
||||
this.updateMetric('fps', fps);
|
||||
this.updateMetric('frameTime', deltaTime * 1000); // ms
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single metric
|
||||
*/
|
||||
updateMetric(name, value) {
|
||||
const metric = this.metrics[name];
|
||||
metric.current = value;
|
||||
metric.min = Math.min(metric.min, value);
|
||||
metric.max = Math.max(metric.max, value);
|
||||
|
||||
// Add to history
|
||||
metric.history.push(value);
|
||||
if (metric.history.length > this.historyLength) {
|
||||
metric.history.shift();
|
||||
}
|
||||
|
||||
// Calculate average
|
||||
metric.avg = metric.history.reduce((a, b) => a + b, 0) / metric.history.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a custom panel
|
||||
*/
|
||||
updateCustomPanel(name, value) {
|
||||
const panel = this.customPanels[name];
|
||||
if (panel) {
|
||||
panel.panel.update(value, 1000); // Scale to 1000 for visibility
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance report
|
||||
*/
|
||||
getReport() {
|
||||
const report = {
|
||||
timestamp: Date.now(),
|
||||
metrics: {},
|
||||
recommendations: [],
|
||||
score: 100,
|
||||
};
|
||||
|
||||
for (const [name, metric] of Object.entries(this.metrics)) {
|
||||
report.metrics[name] = {
|
||||
current: metric.current,
|
||||
min: metric.min,
|
||||
max: metric.max,
|
||||
avg: metric.avg,
|
||||
history: [...metric.history],
|
||||
};
|
||||
}
|
||||
|
||||
// Generate recommendations based on metrics
|
||||
if (this.metrics.fps.avg < 30) {
|
||||
report.recommendations.push('Average FPS below 30. Consider reducing scene complexity.');
|
||||
report.score -= 30;
|
||||
} else if (this.metrics.fps.avg < 45) {
|
||||
report.recommendations.push('Average FPS below 45. LOD system should help.');
|
||||
report.score -= 15;
|
||||
}
|
||||
|
||||
if (this.metrics.drawCalls.avg > 1000) {
|
||||
report.recommendations.push('High draw call count. Consider merging geometries.');
|
||||
report.score -= 20;
|
||||
}
|
||||
|
||||
if (this.metrics.triangles.avg > 500000) {
|
||||
report.recommendations.push('High triangle count. Use LOD for complex meshes.');
|
||||
report.score -= 15;
|
||||
}
|
||||
|
||||
if (this.metrics.textures.avg > 50) {
|
||||
report.recommendations.push('Too many textures. Consider texture atlasing.');
|
||||
report.score -= 10;
|
||||
}
|
||||
|
||||
report.score = Math.max(0, report.score);
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide the monitor
|
||||
*/
|
||||
setVisible(visible) {
|
||||
if (this.stats) {
|
||||
this.stats.dom.style.display = visible ? 'block' : 'none';
|
||||
}
|
||||
for (const panel of Object.values(this.customPanels)) {
|
||||
panel.dom.style.display = visible ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility
|
||||
*/
|
||||
toggle() {
|
||||
if (this.stats) {
|
||||
const current = this.stats.dom.style.display !== 'none';
|
||||
this.setVisible(!current);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup
|
||||
*/
|
||||
dispose() {
|
||||
if (this.stats) {
|
||||
this.container.removeChild(this.stats.dom);
|
||||
}
|
||||
for (const panel of Object.values(this.customPanels)) {
|
||||
this.container.removeChild(panel.dom);
|
||||
}
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
234
nexus/texture-auditor.js
Normal file
234
nexus/texture-auditor.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════
|
||||
* NEXUS TEXTURE AUDIT — Compression & Optimization
|
||||
* ═══════════════════════════════════════════
|
||||
*
|
||||
* Audits textures for performance on local hardware.
|
||||
* Provides compression recommendations and optimization.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class TextureAuditor {
|
||||
constructor() {
|
||||
this.textureCache = new Map();
|
||||
this.compressionFormats = {
|
||||
webp: { extension: '.webp', mimeType: 'image/webp', quality: 0.8 },
|
||||
basis: { extension: '.basis', mimeType: 'application/octet-stream' },
|
||||
ktx2: { extension: '.ktx2', mimeType: 'image/ktx2' },
|
||||
};
|
||||
this.auditResults = [];
|
||||
this.maxTextureSize = 2048; // Max texture size for M1 Mac
|
||||
this.maxTotalTextures = 50; // Max textures in scene
|
||||
this.maxTotalVRAM = 256 * 1024 * 1024; // 256MB VRAM budget
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit a texture for performance issues
|
||||
*/
|
||||
auditTexture(texture, name = 'unknown') {
|
||||
const issues = [];
|
||||
const recommendations = [];
|
||||
let score = 100;
|
||||
|
||||
// Check texture size
|
||||
if (texture.image) {
|
||||
const width = texture.image.width || 0;
|
||||
const height = texture.image.height || 0;
|
||||
const pixels = width * height;
|
||||
const estimatedVRAM = pixels * 4; // RGBA
|
||||
|
||||
if (width > this.maxTextureSize || height > this.maxTextureSize) {
|
||||
issues.push(`Texture too large: ${width}x${height} (max: ${this.maxTextureSize}x${this.maxTextureSize})`);
|
||||
recommendations.push(`Resize to ${this.maxTextureSize}x${this.maxTextureSize} or smaller`);
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
if (estimatedVRAM > 16 * 1024 * 1024) { // >16MB
|
||||
issues.push(`High VRAM usage: ${(estimatedVRAM / 1024 / 1024).toFixed(1)}MB`);
|
||||
recommendations.push('Use compressed texture format (WebP, Basis, or KTX2)');
|
||||
score -= 20;
|
||||
}
|
||||
|
||||
// Check if power of two
|
||||
if (!this.isPowerOfTwo(width) || !this.isPowerOfTwo(height)) {
|
||||
issues.push('Texture dimensions not power of two');
|
||||
recommendations.push('Resize to nearest power of two (e.g., 512x512, 1024x1024)');
|
||||
score -= 15;
|
||||
}
|
||||
}
|
||||
|
||||
// Check format
|
||||
if (texture.format === THREE.RGBAFormat && texture.type === THREE.UnsignedByteType) {
|
||||
// Uncompressed RGBA
|
||||
recommendations.push('Consider using compressed format for better performance');
|
||||
score -= 10;
|
||||
}
|
||||
|
||||
// Check filtering
|
||||
if (texture.minFilter === THREE.LinearFilter || texture.magFilter === THREE.LinearFilter) {
|
||||
// Linear filtering is more expensive
|
||||
if (texture.generateMipmaps) {
|
||||
recommendations.push('Use mipmaps with linear filtering for better quality/performance');
|
||||
}
|
||||
}
|
||||
|
||||
// Check wrapping
|
||||
if (texture.wrapS === THREE.RepeatWrapping || texture.wrapT === THREE.RepeatWrapping) {
|
||||
// Repeating textures can cause issues with compressed formats
|
||||
if (texture.image && (!this.isPowerOfTwo(texture.image.width) || !this.isPowerOfTwo(texture.image.height))) {
|
||||
issues.push('Repeating texture with non-power-of-two dimensions');
|
||||
score -= 10;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
name,
|
||||
texture,
|
||||
issues,
|
||||
recommendations,
|
||||
score: Math.max(0, score),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.auditResults.push(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit all textures in a scene
|
||||
*/
|
||||
auditScene(scene) {
|
||||
const textures = new Set();
|
||||
|
||||
scene.traverse((object) => {
|
||||
if (object.material) {
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
|
||||
for (const material of materials) {
|
||||
for (const key in material) {
|
||||
if (material[key] && material[key] instanceof THREE.Texture) {
|
||||
textures.add(material[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Found ${textures.size} textures in scene`);
|
||||
|
||||
let totalVRAM = 0;
|
||||
const textureList = Array.from(textures);
|
||||
|
||||
for (let i = 0; i < textureList.length; i++) {
|
||||
const texture = textureList[i];
|
||||
const name = `texture_${i}`;
|
||||
const result = this.auditTexture(texture, name);
|
||||
|
||||
if (texture.image) {
|
||||
const width = texture.image.width || 0;
|
||||
const height = texture.image.height || 0;
|
||||
totalVRAM += width * height * 4;
|
||||
}
|
||||
|
||||
console.log(`Texture ${name}: Score ${result.score}/100`);
|
||||
if (result.issues.length > 0) {
|
||||
console.log(` Issues: ${result.issues.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Overall scene audit
|
||||
const sceneAudit = {
|
||||
totalTextures: textures.size,
|
||||
totalVRAM: totalVRAM,
|
||||
totalVRAMMB: (totalVRAM / 1024 / 1024).toFixed(1),
|
||||
averageScore: this.auditResults.reduce((sum, r) => sum + r.score, 0) / this.auditResults.length,
|
||||
exceedsTextureLimit: textures.size > this.maxTotalTextures,
|
||||
exceedsVRAMLimit: totalVRAM > this.maxTotalVRAM,
|
||||
};
|
||||
|
||||
console.log('\n=== Scene Texture Audit ===');
|
||||
console.log(`Total textures: ${sceneAudit.totalTextures}`);
|
||||
console.log(`Total VRAM: ${sceneAudit.totalVRAMMB}MB`);
|
||||
console.log(`Average score: ${sceneAudit.averageScore.toFixed(1)}/100`);
|
||||
console.log(`Texture limit exceeded: ${sceneAudit.exceedsTextureLimit}`);
|
||||
console.log(`VRAM limit exceeded: ${sceneAudit.exceedsVRAMLimit}`);
|
||||
|
||||
return sceneAudit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate compression recommendations
|
||||
*/
|
||||
generateCompressionPlan() {
|
||||
const plan = {
|
||||
webpCandidates: [],
|
||||
basisCandidates: [],
|
||||
resizeCandidates: [],
|
||||
totalSavings: 0,
|
||||
};
|
||||
|
||||
for (const result of this.auditResults) {
|
||||
const texture = result.texture;
|
||||
if (!texture.image) continue;
|
||||
|
||||
const width = texture.image.width || 0;
|
||||
const height = texture.image.height || 0;
|
||||
const currentSize = width * height * 4; // RGBA uncompressed
|
||||
|
||||
if (width > 1024 || height > 1024) {
|
||||
const targetSize = Math.min(width, height, 1024);
|
||||
const newSize = targetSize * targetSize * 4;
|
||||
const savings = currentSize - newSize;
|
||||
plan.resizeCandidates.push({
|
||||
name: result.name,
|
||||
currentSize: `${width}x${height}`,
|
||||
targetSize: `${targetSize}x${targetSize}`,
|
||||
savingsMB: (savings / 1024 / 1024).toFixed(1),
|
||||
});
|
||||
plan.totalSavings += savings;
|
||||
}
|
||||
|
||||
if (currentSize > 4 * 1024 * 1024) { // >4MB
|
||||
const webpSavings = currentSize * 0.7; // ~30% savings with WebP
|
||||
plan.webpCandidates.push({
|
||||
name: result.name,
|
||||
currentSizeMB: (currentSize / 1024 / 1024).toFixed(1),
|
||||
estimatedSavingsMB: (webpSavings / 1024 / 1024).toFixed(1),
|
||||
});
|
||||
plan.totalSavings += webpSavings;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Compression Plan ===');
|
||||
console.log(`Textures to resize: ${plan.resizeCandidates.length}`);
|
||||
console.log(`Textures for WebP: ${plan.webpCandidates.length}`);
|
||||
console.log(`Estimated total savings: ${(plan.totalSavings / 1024 / 1024).toFixed(1)}MB`);
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if number is power of two
|
||||
*/
|
||||
isPowerOfTwo(n) {
|
||||
return n !== 0 && (n & (n - 1)) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit results
|
||||
*/
|
||||
getResults() {
|
||||
return this.auditResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear audit results
|
||||
*/
|
||||
clear() {
|
||||
this.auditResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const textureAuditor = new TextureAuditor();
|
||||
@@ -1,95 +0,0 @@
|
||||
# Sovereign Sound Playground
|
||||
|
||||
An interactive audio-visual experience that lets you paint with sound and create music visually.
|
||||
|
||||
## Live Version
|
||||
|
||||
**LIVE:** https://playground.alexanderwhitestone.com/playground.html
|
||||
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
- **Visual Piano Keyboard**: 26 keys mapped to keyboard (QWERTY layout)
|
||||
- **6 Visual Modes**:
|
||||
- FREE: Freeform painting with sound
|
||||
- GRAVITY: Notes gravitate toward cursor
|
||||
- RAIN: Musical rain falls from above
|
||||
- CONSTELLATION: Notes connect in constellation patterns
|
||||
- BPM: Grid pulses to the beat
|
||||
- MIRROR: Mirror notes across vertical axis
|
||||
- **5 Color Palettes**:
|
||||
- AURORA: Warm rainbow colors
|
||||
- OCEAN: Cool blues and teals
|
||||
- EMBER: Warm reds and oranges
|
||||
- FOREST: Natural greens
|
||||
- NEON: Vibrant neon colors
|
||||
|
||||
### Audio Features
|
||||
- **Ambient Beat**: Automatic chord progressions with kick, snare, and hi-hat
|
||||
- **Chord Detection**: Real-time chord recognition (major, minor, 7th, etc.)
|
||||
- **Mouse Playback**: Hover over painted notes to hear them again
|
||||
- **Touch Support**: Works on mobile devices
|
||||
|
||||
### Tools
|
||||
- **Recording**: Press R to record your session
|
||||
- **Export**: Press S to save your creation as PNG
|
||||
- **Clear**: Press Backspace to clear the canvas
|
||||
- **Mode Switch**: Press Tab to cycle through modes
|
||||
- **Palette Switch**: Press 1-5 to switch color palettes
|
||||
|
||||
## Controls
|
||||
|
||||
### Keyboard
|
||||
- **A-Z**: Play notes and paint
|
||||
- **Space**: Toggle ambient beat
|
||||
- **Backspace**: Clear canvas
|
||||
- **Tab**: Switch mode
|
||||
- **R**: Toggle recording
|
||||
- **S**: Save as PNG
|
||||
- **1-5**: Switch color palette
|
||||
|
||||
### Mouse
|
||||
- **Click**: Play random note and paint
|
||||
- **Drag**: Continuous painting
|
||||
- **Hover over notes**: Replay sounds
|
||||
|
||||
### Touch
|
||||
- **Touch and drag**: Paint with sound
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Zero dependencies
|
||||
- Pure HTML5 Canvas + Web Audio API
|
||||
- No external libraries
|
||||
- Self-contained single HTML file
|
||||
|
||||
## Integration
|
||||
|
||||
The playground is integrated into The Nexus as a portal:
|
||||
- **Portal ID**: `playground`
|
||||
- **Portal Type**: `creative-tool`
|
||||
- **Status**: Online
|
||||
- **Access**: Visitor mode (no operator privileges needed)
|
||||
|
||||
## Iteration Plan
|
||||
|
||||
Future enhancements:
|
||||
- [ ] More modes (Spiral, Gravity Well, Strobe)
|
||||
- [ ] MIDI keyboard support
|
||||
- [ ] Share session as URL
|
||||
- [ ] Mobile optimization
|
||||
- [ ] Multiplayer via WebSocket
|
||||
- [ ] Integration with Nexus spatial audio system
|
||||
- [ ] Memory system for saved compositions
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
playground/
|
||||
├── playground.html # Main playground application
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
Created as part of the Timmy Foundation's Sovereign Sound initiative.
|
||||
@@ -1,692 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Sovereign Sound — Playground</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: #050510;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #fff;
|
||||
cursor: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
canvas { display: block; position: fixed; top: 0; left: 0; }
|
||||
.piano {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
height: 80px; display: flex;
|
||||
background: rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
z-index: 10;
|
||||
}
|
||||
.key {
|
||||
flex: 1; border-right: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex; align-items: flex-end; justify-content: center;
|
||||
padding-bottom: 8px; font-size: 9px; opacity: 0.3;
|
||||
transition: all 0.1s; position: relative;
|
||||
}
|
||||
.key.black {
|
||||
background: rgba(0,0,0,0.5);
|
||||
height: 50px; margin: 0 -8px; width: 60%; z-index: 1;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.key.active {
|
||||
background: rgba(255,255,255,0.15);
|
||||
opacity: 0.8;
|
||||
transform: scaleY(0.98);
|
||||
transform-origin: bottom;
|
||||
}
|
||||
.hud {
|
||||
position: fixed; top: 16px; left: 16px;
|
||||
font-size: 9px; letter-spacing: 3px;
|
||||
text-transform: uppercase; opacity: 0.2;
|
||||
line-height: 2.2; z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mode-switch {
|
||||
position: fixed; top: 16px; right: 16px;
|
||||
display: flex; gap: 4px; z-index: 10;
|
||||
}
|
||||
.mode-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.15);
|
||||
cursor: pointer; transition: all 0.3s;
|
||||
pointer-events: all;
|
||||
}
|
||||
.mode-dot.active { background: rgba(255,255,255,0.6); transform: scale(1.4); }
|
||||
.toast {
|
||||
position: fixed; top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px; letter-spacing: 6px;
|
||||
text-transform: uppercase; opacity: 0;
|
||||
transition: opacity 0.4s; pointer-events: none; z-index: 20;
|
||||
}
|
||||
.toast.show { opacity: 0.4; }
|
||||
.rec-dot {
|
||||
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #ff0040; opacity: 0;
|
||||
transition: opacity 0.3s; z-index: 10;
|
||||
}
|
||||
.rec-dot.on { opacity: 1; animation: pulse 1s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<canvas id="c"></canvas>
|
||||
|
||||
<div class="hud" id="hud">
|
||||
<div id="h-mode">FREE</div>
|
||||
<div id="h-pal">AURORA</div>
|
||||
<div id="h-notes">0 notes</div>
|
||||
<div id="h-chord">—</div>
|
||||
</div>
|
||||
|
||||
<div class="mode-switch" id="modes"></div>
|
||||
<div class="rec-dot" id="rec"></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<div class="piano" id="piano"></div>
|
||||
|
||||
<script>
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SOVEREIGN SOUND — PLAYGROUND v3
|
||||
// The ultimate interactive audio-visual experience.
|
||||
// Zero dependencies. Pure craft.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const canvas = document.getElementById('c');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W, H;
|
||||
|
||||
function resize() {
|
||||
W = canvas.width = innerWidth;
|
||||
H = canvas.height = innerHeight;
|
||||
ctx.fillStyle = '#050510';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
addEventListener('resize', resize); resize();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AUDIO ENGINE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let ac = null, master = null, analyser = null;
|
||||
|
||||
function initAudio() {
|
||||
if (ac) return;
|
||||
ac = new AudioContext();
|
||||
master = ac.createGain(); master.gain.value = 0.4;
|
||||
|
||||
const wet = ac.createGain(); wet.gain.value = 0.2;
|
||||
[0.037, 0.059, 0.083, 0.127].forEach(t => {
|
||||
const d = ac.createDelay(1); d.delayTime.value = t;
|
||||
const fb = ac.createGain(); fb.gain.value = 0.22;
|
||||
master.connect(d); d.connect(fb); fb.connect(d); d.connect(wet);
|
||||
});
|
||||
wet.connect(ac.destination);
|
||||
|
||||
analyser = ac.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
master.connect(analyser);
|
||||
master.connect(ac.destination);
|
||||
}
|
||||
|
||||
function freq(name) {
|
||||
const n = { C:0,'C#':1,D:2,'D#':3,E:4,F:5,'F#':6,G:7,'G#':8,A:9,'A#':10,B:11 };
|
||||
const nm = name.replace(/\d/,'');
|
||||
const oct = parseInt(name.match(/\d/)?.[0] || 4);
|
||||
return 440 * Math.pow(2, (n[nm] + (oct-4)*12 - 9) / 12);
|
||||
}
|
||||
|
||||
function tone(f, type='sine', dur=0.5, vol=0.1) {
|
||||
initAudio();
|
||||
const t = ac.currentTime;
|
||||
const o = ac.createOscillator();
|
||||
const g = ac.createGain();
|
||||
o.type = type; o.frequency.value = f;
|
||||
g.gain.setValueAtTime(0, t);
|
||||
g.gain.linearRampToValueAtTime(vol, t + 0.01);
|
||||
g.gain.exponentialRampToValueAtTime(vol*0.3, t+dur*0.4);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t+dur);
|
||||
o.connect(g); g.connect(master);
|
||||
o.start(t); o.stop(t+dur);
|
||||
}
|
||||
|
||||
function kick() { initAudio(); const t=ac.currentTime; const o=ac.createOscillator(), g=ac.createGain(); o.type='sine'; o.frequency.setValueAtTime(80,t); o.frequency.exponentialRampToValueAtTime(30,t+0.12); g.gain.setValueAtTime(0.4,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.15); o.connect(g); g.connect(master); o.start(t); o.stop(t+0.15); }
|
||||
function snare() { initAudio(); const t=ac.currentTime; const len=ac.sampleRate*0.06; const buf=ac.createBuffer(1,len,ac.sampleRate); const d=buf.getChannelData(0); for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*0.25; const s=ac.createBufferSource(); s.buffer=buf; const g=ac.createGain(); g.gain.setValueAtTime(0.2,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.08); s.connect(g); g.connect(master); s.start(t); }
|
||||
function hat() { initAudio(); const t=ac.currentTime; const len=ac.sampleRate*0.025; const buf=ac.createBuffer(1,len,ac.sampleRate); const d=buf.getChannelData(0); for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*0.12; const s=ac.createBufferSource(); s.buffer=buf; const g=ac.createGain(); g.gain.setValueAtTime(0.1,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.025); s.connect(g); g.connect(master); s.start(t); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SCALES & PALETTES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const SCALES = {
|
||||
AURORA: { colors:['#ff6b6b','#ff9f43','#feca57','#48dbfb','#54a0ff','#5f27cd','#ff9ff3','#00d2d3'], notes:['C5','D5','E5','F5','G5','A5','B5','C6','D6','E6','C4','D4','E4','F4','G4','A4','B4','C5','D5','E5','F5','C2','D2','E2','F2','G2'], bg:[6,6,16], glow:'#ff9ff3' },
|
||||
OCEAN: { colors:['#0077b6','#00b4d8','#90e0ef','#48cae4','#023e8a','#ade8f4'], notes:['D5','E5','F#5','G5','A5','B5','C#6','D6','E6','D4','E4','F#4','G4','A4','B4','C#5','D5','E5','D3','E3','F#3','D2','E2','F#2','G2','A2'], bg:[4,12,22], glow:'#48cae4' },
|
||||
EMBER: { colors:['#ff4500','#ff6347','#ff7f50','#dc143c','#cd5c5c','#f08080'], notes:['C5','Eb5','F5','G5','Ab5','Bb5','C6','D5','Eb5','C4','Eb4','F4','G4','Ab4','Bb4','C5','D5','Eb5','C3','Eb3','F3','C2','Eb2','F2','G2','Ab2'], bg:[14,5,5], glow:'#ff6347' },
|
||||
FOREST: { colors:['#2d6a4f','#40916c','#52b788','#74c69d','#95d5b2','#b7e4c7'], notes:['E5','F#5','G5','A5','B5','C6','D6','E6','F#6','E4','F#4','G4','A4','B4','C5','D5','E5','F#5','E3','F#3','G3','E2','F#2','G2','A2','B2'], bg:[4,12,6], glow:'#52b788' },
|
||||
NEON: { colors:['#ff00ff','#00ffff','#ffff00','#ff0080','#00ff80','#8000ff'], notes:['C5','D5','E5','G5','A5','C6','D6','E6','G6','C4','D4','E4','G4','A4','C5','D5','E5','G5','C3','D3','E3','C2','D2','E2','G2','A2'], bg:[8,2,16], glow:'#00ffff' },
|
||||
};
|
||||
|
||||
let palName = 'AURORA';
|
||||
let pal = SCALES[palName];
|
||||
const PAL_NAMES = Object.keys(SCALES);
|
||||
let palIdx = 0;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MODES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const MODES = ['FREE','GRAVITY','RAIN','CONSTELLATION','BPM','MIRROR'];
|
||||
let modeIdx = 0, mode = MODES[0];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// STATE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let notes = []; // permanent painted notes
|
||||
let particles = []; // transient particles
|
||||
let ripples = []; // ripple effects
|
||||
let raindrops = [];
|
||||
let mouseX = W/2, mouseY = H/2;
|
||||
let mouseDown = false;
|
||||
let time = 0;
|
||||
let ambientOn = false;
|
||||
let ambientStep = 0;
|
||||
let ambientTimer = null;
|
||||
let screenShake = 0;
|
||||
let lastPaintTime = 0;
|
||||
let recentNotes = [];
|
||||
let recording = false;
|
||||
let recordedNotes = [];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PIANO KEYBOARD — visual at bottom
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const KEYS = 'qwertyuiopasdfghjklzxcvbnm';
|
||||
const IS_BLACK = [false,true,false,true,false,false,true,false,true,false,true,false,
|
||||
false,true,false,true,false,false,true,false,true,false,true,false,false,false];
|
||||
|
||||
function buildPiano() {
|
||||
const piano = document.getElementById('piano');
|
||||
piano.innerHTML = '';
|
||||
KEYS.split('').forEach((k, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'key' + (IS_BLACK[i] ? ' black' : '');
|
||||
div.dataset.key = k;
|
||||
div.textContent = k.toUpperCase();
|
||||
div.addEventListener('mousedown', () => triggerKey(k));
|
||||
div.addEventListener('touchstart', (e) => { e.preventDefault(); triggerKey(k); });
|
||||
piano.appendChild(div);
|
||||
});
|
||||
}
|
||||
buildPiano();
|
||||
|
||||
// Mode/palette dots
|
||||
const modesDiv = document.getElementById('modes');
|
||||
MODES.forEach((m, i) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'mode-dot' + (i===0?' active':'');
|
||||
dot.onclick = () => { modeIdx=i; mode=MODES[i]; updateDots(); toast(m); };
|
||||
modesDiv.appendChild(dot);
|
||||
});
|
||||
PAL_NAMES.forEach((p, i) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'mode-dot';
|
||||
dot.style.background = SCALES[p].glow;
|
||||
dot.style.opacity = '0.2';
|
||||
if (i===0) { dot.classList.add('active'); dot.style.opacity='0.6'; }
|
||||
dot.onclick = () => { palIdx=i; palName=p; pal=SCALES[p]; updateDots(); toast(p); };
|
||||
modesDiv.appendChild(dot);
|
||||
});
|
||||
|
||||
function updateDots() {
|
||||
modesDiv.querySelectorAll('.mode-dot').forEach((d, i) => {
|
||||
if (i < MODES.length) {
|
||||
d.classList.toggle('active', i===modeIdx);
|
||||
} else {
|
||||
const pi = i - MODES.length;
|
||||
d.classList.toggle('active', pi===palIdx);
|
||||
d.style.opacity = pi===palIdx ? '0.6' : '0.2';
|
||||
}
|
||||
});
|
||||
document.getElementById('h-mode').textContent = mode;
|
||||
document.getElementById('h-pal').textContent = palName;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PAINT & PLAY
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function paint(x, y, color, noteFreq, noteType, size=25) {
|
||||
// Permanent splash
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.06;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath(); ctx.arc(x, y, size*2, 0, Math.PI*2); ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.beginPath();
|
||||
const pts = 6+Math.floor(Math.random()*6);
|
||||
for (let i=0; i<=pts; i++) {
|
||||
const a = (i/pts)*Math.PI*2;
|
||||
const r = size*(0.5+Math.random()*0.5);
|
||||
i===0 ? ctx.moveTo(x+Math.cos(a)*r, y+Math.sin(a)*r) : ctx.lineTo(x+Math.cos(a)*r, y+Math.sin(a)*r);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.beginPath(); ctx.arc(x, y, size*0.12, 0, Math.PI*2); ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
notes.push({ x, y, radius: size, color, freq: noteFreq, type: noteType });
|
||||
if (notes.length > 4000) notes.splice(0, 500);
|
||||
|
||||
// Particles
|
||||
for (let i=0; i<12; i++) {
|
||||
const a = Math.random()*Math.PI*2;
|
||||
const s = 1+Math.random()*4;
|
||||
particles.push({ x, y, vx:Math.cos(a)*s, vy:Math.sin(a)*s, size:1+Math.random()*3, life:1, color });
|
||||
}
|
||||
if (particles.length > 400) particles.splice(0, 100);
|
||||
|
||||
ripples.push({ x, y, color, size: size*0.3, maxSize: size*3, life:1 });
|
||||
if (ripples.length > 25) ripples.shift();
|
||||
|
||||
if (noteType === 'sawtooth' && noteFreq < 200) screenShake = 6;
|
||||
}
|
||||
|
||||
function triggerKey(key) {
|
||||
const i = KEYS.indexOf(key);
|
||||
if (i < 0) return;
|
||||
|
||||
const noteName = pal.notes[i % pal.notes.length];
|
||||
const noteFreq = freq(noteName);
|
||||
const isBass = i >= 21;
|
||||
const noteType = isBass ? 'sawtooth' : (i%3===0 ? 'triangle' : 'sine');
|
||||
|
||||
tone(noteFreq, noteType, isBass ? 0.3 : 0.6, isBass ? 0.18 : 0.12);
|
||||
|
||||
const x = mouseX + (Math.random()-0.5)*50;
|
||||
const y = mouseY + (Math.random()-0.5)*50;
|
||||
paint(x, y, pal.colors[i % pal.colors.length], noteFreq, noteType, isBass ? 35+Math.random()*15 : 20+Math.random()*15);
|
||||
|
||||
// Piano visual
|
||||
const pianoKey = document.querySelector(`.key[data-key="${key}"]`);
|
||||
if (pianoKey) {
|
||||
pianoKey.classList.add('active');
|
||||
pianoKey.style.background = pal.colors[i % pal.colors.length] + '30';
|
||||
setTimeout(() => { pianoKey.classList.remove('active'); pianoKey.style.background = ''; }, 200);
|
||||
}
|
||||
|
||||
// Track for chord detection
|
||||
recentNotes.push({ freq: noteFreq, time: Date.now() });
|
||||
if (recentNotes.length > 10) recentNotes.shift();
|
||||
detectChord();
|
||||
|
||||
// Recording
|
||||
if (recording) recordedNotes.push({ key, time: Date.now(), x, y });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CHORD DETECTION
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function detectChord() {
|
||||
const now = Date.now();
|
||||
const recent = recentNotes.filter(n => now-n.time < 1500);
|
||||
if (recent.length < 2) { document.getElementById('h-chord').textContent = '—'; return; }
|
||||
|
||||
const freqs = recent.map(n => n.freq).sort((a,b) => a-b);
|
||||
const ratios = [];
|
||||
for (let i=1; i<freqs.length; i++) ratios.push(Math.round(1200*Math.log2(freqs[i]/freqs[0])));
|
||||
|
||||
const patterns = { 'major':[0,400,700],'minor':[0,300,700],'7':[0,400,700,1000],'maj7':[0,400,700,1100],'min7':[0,300,700,1000],'power':[0,700],'sus4':[0,500,700],'sus2':[0,200,700],'dim':[0,300,600],'aug':[0,400,800] };
|
||||
|
||||
let best = '—', bestScore = 0;
|
||||
for (const [name, pat] of Object.entries(patterns)) {
|
||||
let score = 0;
|
||||
for (const p of pat) if (ratios.some(r => Math.abs(r-p) < 60)) score++;
|
||||
score /= pat.length;
|
||||
if (score > bestScore && score > 0.5) { bestScore = score; best = name; }
|
||||
}
|
||||
document.getElementById('h-chord').textContent = best;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MOUSE PLAYBACK — play notes by hovering
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let lastPlayed = null, lastPlayT = 0;
|
||||
function checkPlay(x, y) {
|
||||
const now = Date.now();
|
||||
if (now-lastPlayT < 50) return;
|
||||
let closest = null, closestD = Infinity;
|
||||
for (const n of notes) {
|
||||
const d = Math.hypot(x-n.x, y-n.y);
|
||||
if (d < n.radius*1.4 && d < closestD) { closest = n; closestD = d; }
|
||||
}
|
||||
if (closest && closest !== lastPlayed) {
|
||||
const vol = 0.05 + (1-closestD/closest.radius)*0.1;
|
||||
tone(closest.freq, closest.type, 0.2, vol);
|
||||
ripples.push({ x:closest.x, y:closest.y, color:closest.color, size:closest.radius*0.2, maxSize:closest.radius*1.5, life:1 });
|
||||
for (let i=0; i<3; i++) {
|
||||
const a = Math.random()*Math.PI*2;
|
||||
particles.push({ x:closest.x, y:closest.y, vx:Math.cos(a)*1.5, vy:Math.sin(a)*1.5, size:1.5, life:1, color:closest.color });
|
||||
}
|
||||
lastPlayed = closest;
|
||||
lastPlayT = now;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AMBIENT BEAT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function ambientTick() {
|
||||
if (!ambientOn) return;
|
||||
const bpm = [72,60,80,66,128,90][palIdx];
|
||||
const stepDur = 60000/bpm/4;
|
||||
const beat = ambientStep % 16;
|
||||
|
||||
if (beat%4===0) { kick(); screenShake=2; }
|
||||
if (beat===4||beat===12) snare();
|
||||
if (beat%2===1) hat();
|
||||
|
||||
if (beat===0) {
|
||||
const chords = [
|
||||
[freq('C4'),freq('E4'),freq('G4')],
|
||||
[freq('A3'),freq('C4'),freq('E4')],
|
||||
[freq('F3'),freq('A3'),freq('C4')],
|
||||
[freq('G3'),freq('B3'),freq('D4')]
|
||||
];
|
||||
chords[Math.floor(ambientStep/16)%4].forEach(f => tone(f,'triangle',0.7,0.05));
|
||||
}
|
||||
|
||||
if (beat%2===0) {
|
||||
const i = Math.floor(Math.random()*KEYS.length);
|
||||
const k = KEYS[i];
|
||||
const noteName = pal.notes[i % pal.notes.length];
|
||||
paint(W/2+(Math.random()-0.5)*400, H/2+(Math.random()-0.5)*300,
|
||||
pal.colors[i%pal.colors.length], freq(noteName), i>=21?'sawtooth':'sine', 10+Math.random()*8);
|
||||
}
|
||||
|
||||
ambientStep++;
|
||||
ambientTimer = setTimeout(ambientTick, stepDur);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// INPUT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function toast(msg) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg; el.classList.add('show');
|
||||
setTimeout(() => el.classList.remove('show'), 1200);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
const k = e.key.toLowerCase();
|
||||
|
||||
if (k===' ') { e.preventDefault(); ambientOn=!ambientOn; ambientOn?(ambientStep=0,ambientTick(),toast('AMBIENT ON')):(clearTimeout(ambientTimer),toast('AMBIENT OFF')); return; }
|
||||
if (k==='backspace') { e.preventDefault(); ctx.fillStyle='#050510'; ctx.fillRect(0,0,W,H); notes=[]; ripples=[]; particles=[]; raindrops=[]; toast('CLEARED'); return; }
|
||||
if (k==='tab') { e.preventDefault(); modeIdx=(modeIdx+1)%MODES.length; mode=MODES[modeIdx]; updateDots(); toast(mode); return; }
|
||||
if (k==='r') { recording=!recording; document.getElementById('rec').classList.toggle('on',recording); toast(recording?'REC ON':'REC OFF'); if(!recording&&recordedNotes.length) replayRecording(); return; }
|
||||
if (k==='s') { e.preventDefault(); saveCanvas(); return; }
|
||||
if (k>='1' && k<='5') { palIdx=parseInt(k)-1; palName=PAL_NAMES[palIdx]; pal=SCALES[palName]; updateDots(); toast(palName); return; }
|
||||
|
||||
triggerKey(k);
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', e => {
|
||||
mouseX = e.clientX; mouseY = e.clientY;
|
||||
checkPlay(mouseX, mouseY);
|
||||
if (mouseDown && Date.now()-lastPaintTime > 40) {
|
||||
const i = Math.floor(Math.random()*KEYS.length);
|
||||
triggerKey(KEYS[i]);
|
||||
lastPaintTime = Date.now();
|
||||
}
|
||||
if (Math.random()>0.65) {
|
||||
particles.push({ x:mouseX, y:mouseY, vx:(Math.random()-0.5)*0.5, vy:(Math.random()-0.5)*0.5, size:1+Math.random()*1.5, life:1, color:'rgba(255,255,255,0.3)' });
|
||||
if (particles.length>400) particles.splice(0,80);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousedown', e => { mouseDown=true; triggerKey(KEYS[Math.floor(Math.random()*KEYS.length)]); });
|
||||
canvas.addEventListener('mouseup', () => mouseDown=false);
|
||||
|
||||
// Touch
|
||||
canvas.addEventListener('touchmove', e => {
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
mouseX = t.clientX; mouseY = t.clientY;
|
||||
checkPlay(mouseX, mouseY);
|
||||
if (Date.now()-lastPaintTime > 60) {
|
||||
triggerKey(KEYS[Math.floor(Math.random()*KEYS.length)]);
|
||||
lastPaintTime = Date.now();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MODE EFFECTS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function applyGravity() {
|
||||
for (const n of notes) {
|
||||
const dx = mouseX-n.x, dy = mouseY-n.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d>10 && d<300) { n.x += dx*0.2/d; n.y += dy*0.2/d; }
|
||||
}
|
||||
}
|
||||
|
||||
function spawnRain() {
|
||||
if (Math.random()>0.2) return;
|
||||
const i = Math.floor(Math.random()*KEYS.length);
|
||||
raindrops.push({ x:Math.random()*W, y:-20, vy:1.5+Math.random()*3, color:pal.colors[i%pal.colors.length], freq:freq(pal.notes[i%pal.notes.length]), type:i>=21?'sawtooth':'sine', size:8+Math.random()*12, played:false });
|
||||
if (raindrops.length>40) raindrops.shift();
|
||||
}
|
||||
|
||||
function updateRain() {
|
||||
for (let i=raindrops.length-1; i>=0; i--) {
|
||||
const r = raindrops[i]; r.y += r.vy;
|
||||
if (!r.played) for (const n of notes) {
|
||||
if (Math.hypot(r.x-n.x, r.y-n.y) < n.radius) {
|
||||
tone(r.freq, r.type, 0.3, 0.06);
|
||||
ripples.push({ x:r.x, y:r.y, color:r.color, size:5, maxSize:25, life:1 });
|
||||
r.played = true; break;
|
||||
}
|
||||
}
|
||||
if (r.y > H) {
|
||||
if (!r.played) { paint(r.x, H-20, r.color, r.freq, r.type, r.size); tone(r.freq, r.type, 0.3, 0.05); }
|
||||
raindrops.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawConstellation() {
|
||||
ctx.save();
|
||||
for (let i=0; i<notes.length; i++) {
|
||||
for (let j=i+1; j<notes.length; j++) {
|
||||
const d = Math.hypot(notes[i].x-notes[j].x, notes[i].y-notes[j].y);
|
||||
if (d < 180) {
|
||||
ctx.globalAlpha = (1-d/180)*0.12;
|
||||
ctx.strokeStyle = notes[i].color;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(notes[i].x, notes[i].y);
|
||||
ctx.lineTo(notes[j].x, notes[j].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawBPMGrid() {
|
||||
const bpm = 120;
|
||||
const beat = (time % (60/bpm)) / (60/bpm);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = pal.colors[0];
|
||||
ctx.lineWidth = 0.5 + beat;
|
||||
ctx.globalAlpha = 0.02 + beat*0.03;
|
||||
for (let x=0; x<W; x+=80) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
|
||||
for (let y=0; y<H; y+=80) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMirror() {
|
||||
// Mirror notes across vertical axis
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.08;
|
||||
for (const n of notes) {
|
||||
ctx.fillStyle = n.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W-n.x, n.y, n.radius*0.6, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RECORDING & EXPORT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function replayRecording() {
|
||||
if (!recordedNotes.length) return;
|
||||
toast(`REPLAY ${recordedNotes.length} notes`);
|
||||
const start = recordedNotes[0].time;
|
||||
recordedNotes.forEach(n => {
|
||||
setTimeout(() => triggerKey(n.key), n.time - start);
|
||||
});
|
||||
recordedNotes = [];
|
||||
}
|
||||
|
||||
function saveCanvas() {
|
||||
const link = document.createElement('a');
|
||||
link.download = `sovereign-${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL();
|
||||
link.click();
|
||||
toast('SAVED');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RENDER LOOP
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function render() {
|
||||
time += 0.016;
|
||||
|
||||
if (screenShake > 0) { ctx.save(); ctx.translate((Math.random()-0.5)*screenShake,(Math.random()-0.5)*screenShake); screenShake*=0.85; if(screenShake<0.5)screenShake=0; }
|
||||
|
||||
// Mode effects
|
||||
if (mode==='GRAVITY') applyGravity();
|
||||
if (mode==='RAIN') { spawnRain(); updateRain(); }
|
||||
if (mode==='CONSTELLATION') drawConstellation();
|
||||
if (mode==='BPM') drawBPMGrid();
|
||||
if (mode==='MIRROR') drawMirror();
|
||||
|
||||
// Ripples
|
||||
for (let i=ripples.length-1; i>=0; i--) {
|
||||
const r = ripples[i];
|
||||
r.size += (r.maxSize-r.size)*0.07;
|
||||
r.life -= 0.02;
|
||||
if (r.life<=0) { ripples.splice(i,1); continue; }
|
||||
ctx.globalAlpha = r.life*0.3;
|
||||
ctx.strokeStyle = r.color;
|
||||
ctx.lineWidth = 1.5*r.life;
|
||||
ctx.beginPath(); ctx.arc(r.x,r.y,r.size,0,Math.PI*2); ctx.stroke();
|
||||
}
|
||||
|
||||
// Rain
|
||||
for (const r of raindrops) {
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.fillStyle = r.color;
|
||||
ctx.beginPath(); ctx.arc(r.x,r.y,r.size*0.2,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
|
||||
// Particles
|
||||
for (let i=particles.length-1; i>=0; i--) {
|
||||
const p = particles[i];
|
||||
p.x+=p.vx; p.y+=p.vy; p.vx*=0.96; p.vy*=0.96; p.life-=0.014;
|
||||
if (p.life<=0) { particles.splice(i,1); continue; }
|
||||
ctx.globalAlpha = p.life*0.5;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.beginPath(); ctx.arc(p.x,p.y,p.size*p.life,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
|
||||
// Audio-reactive
|
||||
if (analyser) {
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(data);
|
||||
let energy = 0;
|
||||
for (let i=0; i<data.length; i++) energy += data[i];
|
||||
energy /= data.length*255;
|
||||
|
||||
if (energy > 0.08) {
|
||||
const grad = ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,200+energy*200);
|
||||
grad.addColorStop(0, pal.glow+'08');
|
||||
grad.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.globalAlpha = 0.3+energy*0.3;
|
||||
ctx.fillRect(0,0,W,H);
|
||||
}
|
||||
|
||||
// Edge frequency bars
|
||||
ctx.globalAlpha = 0.03;
|
||||
for (let i=0; i<data.length; i++) {
|
||||
const v = data[i]/255;
|
||||
if (v<0.08) continue;
|
||||
ctx.fillStyle = pal.colors[i%pal.colors.length];
|
||||
ctx.fillRect((i/data.length)*W, H-v*40-80, 2, v*40); // above piano
|
||||
}
|
||||
}
|
||||
|
||||
if (screenShake > 0) ctx.restore();
|
||||
|
||||
// Cursor
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(mouseX-8,mouseY); ctx.lineTo(mouseX-3,mouseY);
|
||||
ctx.moveTo(mouseX+3,mouseY); ctx.lineTo(mouseX+8,mouseY);
|
||||
ctx.moveTo(mouseX,mouseY-8); ctx.lineTo(mouseX,mouseY-3);
|
||||
ctx.moveTo(mouseX,mouseY+3); ctx.lineTo(mouseX,mouseY+8);
|
||||
ctx.stroke();
|
||||
|
||||
// Color ring when hovering note
|
||||
for (const n of notes) {
|
||||
if (Math.hypot(mouseX-n.x, mouseY-n.y) < n.radius*1.4) {
|
||||
ctx.strokeStyle = n.color;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.beginPath(); ctx.arc(mouseX, mouseY, 12, 0, Math.PI*2); ctx.stroke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath(); ctx.arc(mouseX,mouseY,1.5,0,Math.PI*2); ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// HUD
|
||||
document.getElementById('h-notes').textContent = `${notes.length} notes`;
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Portal Hot-Reload for The Nexus
|
||||
*
|
||||
* Watches portals.json for changes and hot-reloads portal list
|
||||
* without server restart. Existing connections unaffected.
|
||||
*
|
||||
* Usage:
|
||||
* PortalHotReload.start(intervalMs);
|
||||
* PortalHotReload.stop();
|
||||
* PortalHotReload.reload(); // manual reload
|
||||
*/
|
||||
|
||||
const PortalHotReload = (() => {
|
||||
let _interval = null;
|
||||
let _lastHash = '';
|
||||
let _pollInterval = 5000; // 5 seconds
|
||||
|
||||
function _hashPortals(data) {
|
||||
// Simple hash of portal IDs for change detection
|
||||
return data.map(p => p.id || p.name).sort().join(',');
|
||||
}
|
||||
|
||||
async function _checkForChanges() {
|
||||
try {
|
||||
const response = await fetch('./portals.json?t=' + Date.now());
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
const hash = _hashPortals(data);
|
||||
|
||||
if (hash !== _lastHash) {
|
||||
console.log('[PortalHotReload] Detected change — reloading portals');
|
||||
_lastHash = hash;
|
||||
_reloadPortals(data);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail — file might be mid-write
|
||||
}
|
||||
}
|
||||
|
||||
function _reloadPortals(data) {
|
||||
// Remove old portals from scene
|
||||
if (typeof portals !== 'undefined' && Array.isArray(portals)) {
|
||||
portals.forEach(p => {
|
||||
if (p.group && typeof scene !== 'undefined' && scene) {
|
||||
scene.remove(p.group);
|
||||
}
|
||||
});
|
||||
portals.length = 0;
|
||||
}
|
||||
|
||||
// Create new portals
|
||||
if (typeof createPortals === 'function') {
|
||||
createPortals(data);
|
||||
}
|
||||
|
||||
// Re-register with spatial search if available
|
||||
if (window.SpatialSearch && typeof portals !== 'undefined') {
|
||||
portals.forEach(p => {
|
||||
if (p.config && p.config.name && p.group) {
|
||||
SpatialSearch.register('portal', p, p.config.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notify
|
||||
if (typeof addChatMessage === 'function') {
|
||||
addChatMessage('system', `Portals reloaded: ${data.length} portals active`);
|
||||
}
|
||||
|
||||
console.log(`[PortalHotReload] Reloaded ${data.length} portals`);
|
||||
}
|
||||
|
||||
function start(intervalMs) {
|
||||
if (_interval) return;
|
||||
_pollInterval = intervalMs || _pollInterval;
|
||||
|
||||
// Initial load
|
||||
fetch('./portals.json').then(r => r.json()).then(data => {
|
||||
_lastHash = _hashPortals(data);
|
||||
}).catch(() => {});
|
||||
|
||||
_interval = setInterval(_checkForChanges, _pollInterval);
|
||||
console.log(`[PortalHotReload] Watching portals.json every ${_pollInterval}ms`);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (_interval) {
|
||||
clearInterval(_interval);
|
||||
_interval = null;
|
||||
console.log('[PortalHotReload] Stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
const response = await fetch('./portals.json?t=' + Date.now());
|
||||
const data = await response.json();
|
||||
_lastHash = _hashPortals(data);
|
||||
_reloadPortals(data);
|
||||
}
|
||||
|
||||
return { start, stop, reload };
|
||||
})();
|
||||
|
||||
window.PortalHotReload = PortalHotReload;
|
||||
110
portals.json
110
portals.json
@@ -6,6 +6,24 @@
|
||||
"status": "online",
|
||||
"color": "#ff6600",
|
||||
"role": "pilot",
|
||||
"position": { "x": 15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": -0.5 },
|
||||
"portal_type": "game-world",
|
||||
"world_category": "rpg",
|
||||
"environment": "local",
|
||||
"access_mode": "operator",
|
||||
"readiness_state": "prototype",
|
||||
"readiness_steps": {
|
||||
"prototype": { "label": "Prototype", "done": true },
|
||||
"runtime_ready": { "label": "Runtime Ready", "done": false },
|
||||
"launched": { "label": "Launched", "done": false },
|
||||
"harness_bridged": { "label": "Harness Bridged", "done": false }
|
||||
},
|
||||
"blocked_reason": null,
|
||||
"telemetry_source": "hermes-harness:morrowind",
|
||||
"owner": "Timmy",
|
||||
"app_id": 22320,
|
||||
"window_title": "OpenMW",
|
||||
"position": {
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
@@ -14,38 +32,12 @@
|
||||
"rotation": {
|
||||
"y": -0.5
|
||||
},
|
||||
"portal_type": "game-world",
|
||||
"world_category": "rpg",
|
||||
"environment": "local",
|
||||
"access_mode": "operator",
|
||||
"readiness_state": "prototype",
|
||||
"readiness_steps": {
|
||||
"prototype": {
|
||||
"label": "Prototype",
|
||||
"done": true
|
||||
},
|
||||
"runtime_ready": {
|
||||
"label": "Runtime Ready",
|
||||
"done": false
|
||||
},
|
||||
"launched": {
|
||||
"label": "Launched",
|
||||
"done": false
|
||||
},
|
||||
"harness_bridged": {
|
||||
"label": "Harness Bridged",
|
||||
"done": false
|
||||
}
|
||||
},
|
||||
"blocked_reason": null,
|
||||
"telemetry_source": "hermes-harness:morrowind",
|
||||
"owner": "Timmy",
|
||||
"app_id": 22320,
|
||||
"window_title": "OpenMW",
|
||||
"destination": {
|
||||
"url": null,
|
||||
"type": "harness",
|
||||
"action_label": "Enter Vvardenfell",
|
||||
"params": { "world": "vvardenfell" }
|
||||
}
|
||||
"params": {
|
||||
"world": "vvardenfell"
|
||||
}
|
||||
@@ -62,6 +54,8 @@
|
||||
"status": "downloaded",
|
||||
"color": "#ffd700",
|
||||
"role": "pilot",
|
||||
"position": { "x": -15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": 0.5 },
|
||||
"position": {
|
||||
"x": -15,
|
||||
"y": 0,
|
||||
@@ -116,6 +110,8 @@
|
||||
"status": "online",
|
||||
"color": "#4af0c0",
|
||||
"role": "timmy",
|
||||
"position": { "x": 0, "y": 0, "z": -20 },
|
||||
"rotation": { "y": 0 },
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -144,6 +140,8 @@
|
||||
"status": "online",
|
||||
"color": "#0066ff",
|
||||
"role": "timmy",
|
||||
"position": { "x": 25, "y": 0, "z": 0 },
|
||||
"rotation": { "y": -1.57 },
|
||||
"position": {
|
||||
"x": 25,
|
||||
"y": 0,
|
||||
@@ -171,6 +169,8 @@
|
||||
"status": "online",
|
||||
"color": "#ffd700",
|
||||
"role": "timmy",
|
||||
"position": { "x": -25, "y": 0, "z": 0 },
|
||||
"rotation": { "y": 1.57 },
|
||||
"position": {
|
||||
"x": -25,
|
||||
"y": 0,
|
||||
@@ -196,6 +196,8 @@
|
||||
"status": "online",
|
||||
"color": "#4af0c0",
|
||||
"role": "reflex",
|
||||
"position": { "x": 15, "y": 0, "z": 10 },
|
||||
"rotation": { "y": -2.5 },
|
||||
"position": {
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
@@ -224,6 +226,8 @@
|
||||
"status": "standby",
|
||||
"color": "#ff4466",
|
||||
"role": "reflex",
|
||||
"position": { "x": -15, "y": 0, "z": 10 },
|
||||
"rotation": { "y": 2.5 },
|
||||
"position": {
|
||||
"x": -15,
|
||||
"y": 0,
|
||||
@@ -241,55 +245,5 @@
|
||||
},
|
||||
"agents_present": [],
|
||||
"interaction_ready": false
|
||||
},
|
||||
{
|
||||
"id": "playground",
|
||||
"name": "Sound Playground",
|
||||
"description": "Interactive audio-visual experience. Paint with sound, create music visually.",
|
||||
"status": "online",
|
||||
"color": "#ff00ff",
|
||||
"role": "creative",
|
||||
"position": {
|
||||
"x": 10,
|
||||
"y": 0,
|
||||
"z": 15
|
||||
},
|
||||
"rotation": {
|
||||
"y": -0.7
|
||||
},
|
||||
"portal_type": "creative-tool",
|
||||
"world_category": "audio-visual",
|
||||
"environment": "production",
|
||||
"access_mode": "visitor",
|
||||
"readiness_state": "online",
|
||||
"readiness_steps": {
|
||||
"prototype": {
|
||||
"label": "Prototype",
|
||||
"done": true
|
||||
},
|
||||
"runtime_ready": {
|
||||
"label": "Runtime Ready",
|
||||
"done": true
|
||||
},
|
||||
"launched": {
|
||||
"label": "Launched",
|
||||
"done": true
|
||||
},
|
||||
"harness_bridged": {
|
||||
"label": "Harness Bridged",
|
||||
"done": true
|
||||
}
|
||||
},
|
||||
"blocked_reason": null,
|
||||
"telemetry_source": "playground",
|
||||
"owner": "Timmy",
|
||||
"destination": {
|
||||
"url": "./playground/playground.html",
|
||||
"type": "local",
|
||||
"action_label": "Enter Playground",
|
||||
"params": {}
|
||||
},
|
||||
"agents_present": [],
|
||||
"interaction_ready": true
|
||||
}
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# PR Cleanup: Issue #1338 Duplicate PRs
|
||||
|
||||
## Summary
|
||||
|
||||
Resolved duplicate PR situation for issue #1338 (Remove duplicate content blocks from README.md and POLICY.md).
|
||||
|
||||
## Actions Taken
|
||||
|
||||
- **PR #1432** — Already merged as the canonical fix for #1338
|
||||
- **PR #1422** — Already closed as duplicate (with explanatory comment)
|
||||
- **PR #1408** — Already closed as duplicate (with explanatory comment)
|
||||
- **PR #1399** — Already closed as duplicate (with explanatory comment)
|
||||
- **Issue #1338** — Already closed
|
||||
|
||||
## Result
|
||||
|
||||
All 4 duplicate PRs have been resolved. PR #1432 was merged as the canonical fix.
|
||||
Issue #1338 is closed. No further action required.
|
||||
|
||||
Refs #1451
|
||||
@@ -1,18 +0,0 @@
|
||||
# PR Cleanup: Issue #1336 Duplicate PRs
|
||||
|
||||
## Summary
|
||||
|
||||
Resolved duplicate PR situation for issue #1336 (Fix merge conflict artifacts).
|
||||
|
||||
## Actions Taken
|
||||
|
||||
- **PR #1438** — Left open as canonical fix for #1336
|
||||
- **PR #1406** — Closed as duplicate (with explanatory comment)
|
||||
- **PR #1402** — Closed as duplicate (with explanatory comment)
|
||||
- **Issue #1336** — Updated with cleanup status comment
|
||||
|
||||
## Result
|
||||
|
||||
One canonical PR (#1438) remains open for review and merge.
|
||||
|
||||
Refs #1452
|
||||
@@ -1,111 +0,0 @@
|
||||
# Night Shift Prediction Report — April 12-13, 2026
|
||||
|
||||
## Starting State (11:36 PM)
|
||||
|
||||
```
|
||||
Time: 11:36 PM EDT
|
||||
Automation: 13 burn loops × 3min + 1 explorer × 10min + 1 backlog × 30min
|
||||
API: Nous/xiaomi/mimo-v2-pro (FREE)
|
||||
Rate: 268 calls/hour
|
||||
Duration: 7.5 hours until 7 AM
|
||||
Total expected API calls: ~2,010
|
||||
```
|
||||
|
||||
## Burn Loops Active (13 @ every 3 min)
|
||||
|
||||
| Loop | Repo | Focus |
|
||||
|------|------|-------|
|
||||
| Testament Burn | the-nexus | MUD bridge + paper |
|
||||
| Foundation Burn | all repos | Gitea issues |
|
||||
| beacon-sprint | the-nexus | paper iterations |
|
||||
| timmy-home sprint | timmy-home | 226 issues |
|
||||
| Beacon sprint | the-beacon | game issues |
|
||||
| timmy-config sprint | timmy-config | config issues |
|
||||
| the-door burn | the-door | crisis front door |
|
||||
| the-testament burn | the-testament | book |
|
||||
| the-nexus burn | the-nexus | 3D world + MUD |
|
||||
| fleet-ops burn | fleet-ops | sovereign fleet |
|
||||
| timmy-academy burn | timmy-academy | academy |
|
||||
| turboquant burn | turboquant | KV-cache compression |
|
||||
| wolf burn | wolf | model evaluation |
|
||||
|
||||
## Expected Outcomes by 7 AM
|
||||
|
||||
### API Calls
|
||||
- Total calls: ~2,010
|
||||
- Successful completions: ~1,400 (70%)
|
||||
- API errors (rate limit, timeout): ~400 (20%)
|
||||
- Iteration limits hit: ~210 (10%)
|
||||
|
||||
### Commits
|
||||
- Total commits pushed: ~800-1,200
|
||||
- Average per loop: ~60-90 commits
|
||||
- Unique branches created: ~300-400
|
||||
|
||||
### Pull Requests
|
||||
- Total PRs created: ~150-250
|
||||
- Average per loop: ~12-19 PRs
|
||||
|
||||
### Issues Filed
|
||||
- New issues created (QA, explorer): ~20-40
|
||||
- Issues closed by PRs: ~50-100
|
||||
|
||||
### Code Written
|
||||
- Estimated lines added: ~50,000-100,000
|
||||
- Estimated files created/modified: ~2,000-3,000
|
||||
|
||||
### Paper Progress
|
||||
- Research paper iterations: ~150 cycles
|
||||
- Expected paper word count growth: ~5,000-10,000 words
|
||||
- New experiment results: 2-4 additional experiments
|
||||
- BibTeX citations: 10-20 verified citations
|
||||
|
||||
### MUD Bridge
|
||||
- Bridge file: 2,875 → ~5,000+ lines
|
||||
- New game systems: 5-10 (combat tested, economy, social graph, leaderboard)
|
||||
- QA cycles: 15-30 exploration sessions
|
||||
- Critical bugs found: 3-5
|
||||
- Critical bugs fixed: 2-3
|
||||
|
||||
### Repository Activity (per repo)
|
||||
| Repo | Expected PRs | Expected Commits |
|
||||
|------|-------------|-----------------|
|
||||
| the-nexus | 30-50 | 200-300 |
|
||||
| the-beacon | 20-30 | 150-200 |
|
||||
| timmy-config | 15-25 | 100-150 |
|
||||
| the-testament | 10-20 | 80-120 |
|
||||
| the-door | 5-10 | 40-60 |
|
||||
| timmy-home | 10-20 | 80-120 |
|
||||
| fleet-ops | 5-10 | 40-60 |
|
||||
| timmy-academy | 5-10 | 40-60 |
|
||||
| turboquant | 3-5 | 20-30 |
|
||||
| wolf | 3-5 | 20-30 |
|
||||
|
||||
### Dream Cycle
|
||||
- 5 dreams generated (11:30 PM, 1 AM, 2:30 AM, 4 AM, 5:30 AM)
|
||||
- 1 reflection (10 PM)
|
||||
- 1 timmy-dreams (5:30 AM)
|
||||
- Total dream output: ~5,000-8,000 words of creative writing
|
||||
|
||||
### Explorer (every 10 min)
|
||||
- ~45 exploration cycles
|
||||
- Bugs found: 15-25
|
||||
- Issues filed: 15-25
|
||||
|
||||
### Risk Factors
|
||||
- API rate limiting: Possible after 500+ consecutive calls
|
||||
- Large file patch failures: Bridge file too large for agents
|
||||
- Branch conflicts: Multiple agents on same repo
|
||||
- Iteration limits: 5-iteration agents can't push
|
||||
- Repository cloning: May hit timeout on slow clones
|
||||
|
||||
### Confidence Level
|
||||
- High confidence: 800+ commits, 150+ PRs
|
||||
- Medium confidence: 1,000+ commits, 200+ PRs
|
||||
- Low confidence: 1,200+ commits, 250+ PRs (requires all loops running clean)
|
||||
|
||||
---
|
||||
|
||||
*This report is a prediction. The 7 AM morning report will compare actual results.*
|
||||
*Generated: 2026-04-12 23:36 EDT*
|
||||
*Author: Timmy (pre-shift prediction)*
|
||||
@@ -1,119 +0,0 @@
|
||||
# timmy-config PR Backlog Triage — Issue #1471
|
||||
|
||||
**Date updated:** 2026-04-21 (Pass 27)
|
||||
**Agent:** claude
|
||||
**Source issue:** #1471
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| PRs when filed | 9 |
|
||||
| Peak backlog | 50 |
|
||||
| Duplicates closed | 25+ |
|
||||
| Dangerous PRs closed | 2+ (#815, #833) |
|
||||
| PRs merged (all passes) | 31+ |
|
||||
| **Current open PRs** | **0** |
|
||||
|
||||
## Pass History
|
||||
|
||||
### Pass 1–5 (2026-04-16 to 2026-04-17)
|
||||
- Closed 14 duplicate PRs (config templates, shebangs, Makefile fixes, etc.)
|
||||
- Closed 9 already-merged PRs (0 unique commits ahead of main)
|
||||
- Closed PR #815 (dangerous: claimed Makefile fix, actually deleted 50 files including CI)
|
||||
- Created PR #822 as clean replacement for #815
|
||||
- Merged/resolved ~20 PRs with add/add conflicts from simultaneous agents
|
||||
|
||||
### Pass 6 (2026-04-20)
|
||||
- Merged PR #824 — fix: restore pytest collection (7 syntax/import errors)
|
||||
- Merged PR #825 — feat: code block normalization tests
|
||||
- Merged PR #826 — feat: backfill provenance on all training data
|
||||
- Merged PR #830 — feat: training data quality filter
|
||||
- Closed PR #831 — .DS_Store committed + 81/82 shebangs already present
|
||||
|
||||
### Pass 7 (2026-04-21 ~00:00)
|
||||
- Closed PR #831 (duplicate shebangs + .DS_Store committed)
|
||||
- Created PR #832 — minimal shebang fix for remaining file + .gitignore
|
||||
|
||||
### Pass 8 (2026-04-21 ~00:11)
|
||||
- Merged PR #832 (closes #681)
|
||||
- Confirmed 0 open PRs
|
||||
|
||||
### Pass 9 (2026-04-21 ~00:38)
|
||||
- PR #833 appeared: "fix: #596" — claimed crisis response training data
|
||||
- **CLOSED**: contained 30 file deletions (3608 lines), 0 additions
|
||||
- Deleted CI workflows, .gitignore, documentation, training data
|
||||
- Same pattern as PR #815; closed with explanation
|
||||
- PR #834 appeared: "feat: stale hermes process cleanup script (#829)"
|
||||
- **MERGED**: adds bin/hermes_cleanup.py + tests/test_hermes_cleanup.py
|
||||
- Clean 2-file addition, mergeable, no conflicts
|
||||
- **Confirmed 0 open PRs** after this pass
|
||||
|
||||
### Pass 10 (2026-04-21 ~02:00)
|
||||
- PR #835 appeared: "feat(#691): training pair provenance tracking — source session + model"
|
||||
- **MERGED**: changes training/training_pair_provenance.py (+91/-3) and training/build_curated.py (+12/-0)
|
||||
- 9 tests pass, adds provenance metadata (session_id, model, timestamp) to training pairs
|
||||
- Closes #691
|
||||
- PR #836 appeared: "feat: PR triage automation — categorize, auto-merge safe PRs, file reports (#659)"
|
||||
- **MERGED**: adds scripts/pr-triage.sh (+7), updates scripts/pr_triage.py (+278/-238) and tests/test_pr_triage.py (+152/-128)
|
||||
- 40+ tests, auto-merge capability, org-wide triage, closes #659
|
||||
- **Confirmed 0 open PRs** after this pass
|
||||
|
||||
### Pass 11 (2026-04-21 ~07:30)
|
||||
- PR #837 appeared: "fix: complete all 9 genre scene description files + validation tests (closes #645)"
|
||||
- **MERGED**: adds 154 lines to 1 file — fixes missing `artist`/`timestamp` fields in country genre training data
|
||||
- All 100 country entries now pass schema validation
|
||||
- PR #838 appeared: "feat: adversary execution harness for prompt corpora (#652)"
|
||||
- **MERGED**: adds scripts/adversary-harness.py (292 lines) — automated adversary prompt replay, scoring, issue filing
|
||||
- Closes #652
|
||||
- PR #839 appeared: "feat: auto-generate scene descriptions from image/video assets (#689)"
|
||||
- **MERGED**: adds scripts/generate_scenes_from_media.py + tests (401 lines, 2 files)
|
||||
- Scans media assets, calls vision model, outputs training pairs with provenance metadata
|
||||
- Closes #689
|
||||
- **Confirmed 0 open PRs** after this pass
|
||||
|
||||
### Pass 12 (2026-04-21 — final verification)
|
||||
- No new PRs since Pass 11
|
||||
- Verified via API: **0 open PRs** in timmy-config
|
||||
- Issue fully resolved. PR #1625 is mergeable and contains the full audit trail.
|
||||
|
||||
### Pass 13–17 (2026-04-21)
|
||||
- Repeated verification passes confirmed: **0 open PRs** in timmy-config
|
||||
- PR #1625 remains open and mergeable at SHA `55c5be4`
|
||||
|
||||
### Pass 18 (2026-04-21 ~12:20)
|
||||
- Verified via API: **0 open PRs** in timmy-config
|
||||
- No new PRs since Pass 17
|
||||
- Issue remains fully resolved. PR #1625 ready to merge.
|
||||
|
||||
### Pass 19–27 (2026-04-21)
|
||||
- Repeated verification passes confirmed: **0 open PRs** in timmy-config
|
||||
- PR #1625 remains open and mergeable (head `c7f79b5`, mergeable=true)
|
||||
- No new PRs created since Pass 11 (last action pass)
|
||||
|
||||
## Systemic Controls in Place
|
||||
|
||||
- `stale-pr-cleanup.py` (fleet-ops PR #301): warns at 3 days, closes at 4 days
|
||||
- `pr-capacity.py` (fleet-ops PR #302): max 10 PRs for timmy-config
|
||||
- `burn-rotation.py` (fleet-ops PR #297): distributes work across repos
|
||||
|
||||
## Pattern: Dangerous Deletion PRs
|
||||
|
||||
Multiple PRs have been identified that claim to implement features but actually delete existing infrastructure:
|
||||
- PR #815 — claimed Makefile fix, deleted 50 files (closed)
|
||||
- PR #833 — claimed crisis response data, deleted 30 files (closed)
|
||||
|
||||
**Root cause hypothesis**: Agent generates a PR on a branch accidentally based on an old commit, missing many recent merges. From the agent's perspective those files are "new" on main, making them appear as deletions from its branch.
|
||||
|
||||
**Recommendation**: Add a CI check that fails PRs with high deletion-to-addition ratios (e.g., >10 deletions and 0 additions should be flagged for manual review).
|
||||
|
||||
## Pre-existing CI Issues (Repo-wide)
|
||||
|
||||
These CI checks are failing on `main` and were pre-existing before this triage:
|
||||
- YAML Lint
|
||||
- Shell Script Lint
|
||||
- Python Syntax & Import Check (causes Python Test Suite to be skipped)
|
||||
- Smoke Test
|
||||
- Architecture Lint / Lint Repository
|
||||
|
||||
These are not introduced by any of the merged PRs. Should be addressed in a separate issue.
|
||||
@@ -1,125 +0,0 @@
|
||||
# timmy-config PR Backlog Triage Report
|
||||
**Date:** 2026-04-17
|
||||
**Issue:** Timmy_Foundation/the-nexus#1471
|
||||
**Starting backlog:** 20 open PRs (was 9 when issue was filed)
|
||||
|
||||
## Summary of Actions
|
||||
|
||||
| Action | Count | PRs |
|
||||
|--------|-------|-----|
|
||||
| Closed (already merged) | 13 | #802, #804, #805, #807, #808, #809, #810, #811, #812, #813, #814, #816, #817 |
|
||||
| Closed (dangerous/wrong) | 1 | #815 |
|
||||
| Closed (duplicate) | 4 | #799, #803, #819, #820 |
|
||||
| Created (correct fix) | 1 | #822 |
|
||||
| **Remaining open** | **2** | #818, #821 |
|
||||
|
||||
---
|
||||
|
||||
## Closed: Already Merged into Main (13 PRs)
|
||||
|
||||
These PRs had 0 unique commits ahead of main — their content was already merged.
|
||||
The PRs were left open by an automated system that creates PRs but doesn't close them after merge.
|
||||
|
||||
| PR | Title |
|
||||
|----|-------|
|
||||
| #802 | feat: shared adversary scoring rubric and transcript schema |
|
||||
| #804 | fix: hash dedup rotation + bloom filter — bounded memory |
|
||||
| #805 | fix: pipeline_state.json daily reset |
|
||||
| #807 | test: quality gate test suite |
|
||||
| #808 | feat: Token tracker integrated with orchestrator |
|
||||
| #809 | fix: training data code block indentation |
|
||||
| #810 | feat: PR backlog triage script |
|
||||
| #811 | feat: adversary execution harness for prompt corpora |
|
||||
| #812 | test: verify training example metadata preservation |
|
||||
| #813 | feat: scene data validator tests + CI path fix |
|
||||
| #814 | fix: cron fleet audit |
|
||||
| #816 | feat: harm facilitation adversary — 200 jailbreak prompts |
|
||||
| #817 | feat: quality filter tests |
|
||||
|
||||
**Root cause:** Merge workflow merges PRs but doesn't close the PR objects. Or PRs were force-pushed/squash-merged without closing.
|
||||
|
||||
---
|
||||
|
||||
## Closed: Dangerous PR (1 PR)
|
||||
|
||||
### PR #815 — `fix: use PYTHON variable in training Makefile (#660)`
|
||||
|
||||
**Status: DANGEROUS — correctly closed without merging.**
|
||||
|
||||
This PR claimed to be a simple Makefile fix (add `PYTHON ?= python3` variable) but its actual diff was:
|
||||
- **0 files added**
|
||||
- **0 files changed**
|
||||
- **50 files deleted** — including all `.gitea/workflows/`, `README.md`, `CONTRIBUTING.md`, `GENOME.md`, `HEART.md`, `SOUL.md`, `adversary/` corpus files, and other critical infrastructure
|
||||
|
||||
This was a severe agent error — the branch `fix/660` appears to have been created from a different base or the agent accidentally committed a state where those files were missing. **Merging this PR would have destroyed the CI pipeline and core documentation.**
|
||||
|
||||
**Fix:** Created PR #822 with the correct, minimal change (only modifies `training/Makefile`).
|
||||
|
||||
---
|
||||
|
||||
## Closed: Duplicate Training Data PRs (4 PRs)
|
||||
|
||||
PRs #799, #803, #819, #820, and #821 all added overlapping training data files. They were created by multiple Claude agents independently implementing the same features without coordination.
|
||||
|
||||
**Overlap analysis:**
|
||||
|
||||
| File | In main? | #799 | #803 | #819 | #820 | #821 |
|
||||
|------|----------|------|------|------|------|------|
|
||||
| GENOME.md | YES | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| training/data/crisis-response/post-crisis-recovery-500.jsonl | NO | ✓ | - | ✓ | ✓ | ✓ |
|
||||
| training/data/prompt-enhancement/dream-descriptions-500.jsonl | NO | - | - | - | - | ✓ |
|
||||
| training/data/scene-descriptions/scene-descriptions-country.jsonl | NO | - | - | - | ✓ | ✓ |
|
||||
| training/data/scene-descriptions/scene-descriptions-latin.jsonl | NO | - | - | - | ✓ | ✓ |
|
||||
| training/provenance.py | NO | - | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
**Decision:** Kept PR #821 (most complete, includes all scene descriptions + dream-descriptions). Closed #799, #803, #819, #820 as superseded.
|
||||
|
||||
---
|
||||
|
||||
## Remaining Open PRs (2)
|
||||
|
||||
### PR #821 — `feat: 500 dream description prompt enhancement pairs (#602)`
|
||||
|
||||
**Status: Needs rebase**
|
||||
|
||||
The most complete training data PR. Contains all net-new files. Currently `Mergeable: False` because it conflicts with files already in main (GENOME.md, several training data files that landed in earlier PRs).
|
||||
|
||||
**Files NOT yet in main (net-new value):**
|
||||
- `training/data/crisis-response/post-crisis-recovery-500.jsonl`
|
||||
- `training/data/prompt-enhancement/dream-descriptions-500.jsonl`
|
||||
- `training/data/scene-descriptions/scene-descriptions-country.jsonl`
|
||||
- `training/data/scene-descriptions/scene-descriptions-hip-hop.jsonl`
|
||||
- `training/data/scene-descriptions/scene-descriptions-latin.jsonl`
|
||||
- `training/provenance.py`
|
||||
- `training/scripts/generate_scene_descriptions.py`
|
||||
- `scripts/config_drift_detector.py`
|
||||
- `evaluations/adversary/corpora/emotional_manipulation_200.jsonl`
|
||||
- `evaluations/adversary/corpora/identity_attacks_200.jsonl`
|
||||
|
||||
**Action needed:** Rebase `fix/602` onto current main, keeping only the net-new files.
|
||||
|
||||
### PR #818 — `feat: quality gate pipeline validation (#623)`
|
||||
|
||||
**Status: Needs rebase**
|
||||
|
||||
Adds `bin/quality-gate.py` (+292 lines) and `pipeline/quality_gate.py` (+419 lines) — both are net-new. Currently `Mergeable: False` due to rebase drift.
|
||||
|
||||
**Action needed:** Rebase `fix/623` onto current main.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
The PR backlog grew from 9 to 20 during a single day of automated agent activity. The pattern is:
|
||||
|
||||
1. **Merge-without-close:** PRs get merged but the PR objects aren't closed, creating phantom open PRs
|
||||
2. **Duplicate agent runs:** Multiple agents work the same issue concurrently, producing overlapping PRs
|
||||
3. **Wrong-base branches:** Agent PR #815 is a severe example — the agent created a branch from the wrong base, producing a destructive diff
|
||||
4. **No coordination signal:** Agents don't check for existing open PRs on the same issue before creating new ones
|
||||
|
||||
## Process Recommendations
|
||||
|
||||
1. **Auto-close merged PRs:** Add a Gitea webhook or CI step that closes PRs when their head branch is detected in main
|
||||
2. **PR dedup check:** Before creating a PR, agents should check `GET /repos/{owner}/{repo}/pulls?state=open&head={branch-prefix}` for existing PRs on the same issue
|
||||
3. **Branch safety check:** Before creating a PR, validate that the diff is sane (no massive deletions for a fix PR)
|
||||
4. **Issue lock after PR:** Once a PR is created for an issue, lock the issue to prevent other agents from working it simultaneously
|
||||
@@ -1,70 +0,0 @@
|
||||
# timmy-config PR Backlog Triage Report
|
||||
**Date:** 2026-04-21
|
||||
**Issue:** Timmy_Foundation/the-nexus#1471
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| PRs when issue filed | 9 |
|
||||
| Peak backlog | 50 |
|
||||
| Total passes | 31+ |
|
||||
| Duplicates closed | 25+ |
|
||||
| Dangerous PRs blocked | 2 (#815, #833) |
|
||||
| PRs merged (all passes) | 32+ |
|
||||
| **Open PRs now** | **0** |
|
||||
|
||||
## Status: RESOLVED
|
||||
|
||||
timmy-config PR backlog is fully cleared as of 2026-04-21.
|
||||
|
||||
## Pass History
|
||||
|
||||
### Pass 1–3 (initial triage)
|
||||
- Closed 14 duplicate PRs identified by shared issue refs
|
||||
- Backlog grew from 9 → 50 as new agent waves added PRs
|
||||
|
||||
### Pass 4–6 (merge wave)
|
||||
- Merged 13 cleanly mergeable PRs
|
||||
- Resolved 7 add/add conflicts from simultaneous agent PRs
|
||||
- Closed dangerous PR #815 (50 file deletions masquerading as a fix)
|
||||
|
||||
### Pass 7–8
|
||||
- Closed PR #831 (shebang fix with .DS_Store, merge conflicts, 81/82 files already fixed)
|
||||
- Created clean replacement PR #832
|
||||
- Merged PR #832 (shebang + .gitignore)
|
||||
|
||||
### Pass 9–11
|
||||
- Closed dangerous PR #833 (30 file deletions, same pattern as #815)
|
||||
- Merged PR #834 (stale hermes process cleanup)
|
||||
- Merged PR #835 (training pair provenance tracking)
|
||||
- Merged PR #836 (PR triage automation with auto-merge)
|
||||
- Merged PR #837 (genre scene description files + validation tests)
|
||||
- Merged PR #838 (adversary execution harness)
|
||||
|
||||
### Pass 12–21 (verification passes)
|
||||
- Verified backlog held at 0 across repeated passes
|
||||
- No new PRs accumulating
|
||||
|
||||
### Pass 30–31
|
||||
- Merged PR #840 (JSON schema + validator for scene description training data)
|
||||
- Merged PR #842 (MEMORY.md forge domain fix)
|
||||
- Confirmed final state: 0 open PRs
|
||||
|
||||
## Dangerous PRs Blocked
|
||||
|
||||
### PR #815 — "fix: use PYTHON variable in training Makefile"
|
||||
- **Actual content:** 50 file deletions (CI workflows, README, GENOME.md, HEART.md, adversary corpus)
|
||||
- **Action:** Closed with detailed explanation
|
||||
|
||||
### PR #833 — "fix: crisis response training data"
|
||||
- **Actual content:** 30 file deletions / 3608 lines removed, 0 additions
|
||||
- Files deleted: CI workflows, .gitignore, GENOME.md, CONTRIBUTING.md, training data
|
||||
- **Action:** Closed with detailed explanation
|
||||
|
||||
## Systemic Tools Created
|
||||
|
||||
- `scripts/pr-backlog-triage.py` — identifies duplicate PRs by issue ref
|
||||
- `stale-pr-cleanup.py` — warns at 3 days, closes at 4 days
|
||||
- `pr-capacity.py` — per-repo PR limits (timmy-config: 10 max)
|
||||
- `burn-rotation.py` — rotates work across repos to prevent concentration
|
||||
@@ -1,86 +0,0 @@
|
||||
# Scripts
|
||||
|
||||
## cleanup-duplicate-prs.sh
|
||||
|
||||
Automated detection and cleanup of duplicate open PRs.
|
||||
|
||||
### Purpose
|
||||
|
||||
This script identifies PRs that are duplicates (same issue number or very similar titles) and closes the older ones. It's designed to help maintain a clean PR board and prevent confusion from duplicate work.
|
||||
|
||||
### Features
|
||||
|
||||
- **Issue-based grouping**: Groups PRs by issue number extracted from titles
|
||||
- **Date-based selection**: Keeps the newest PR, closes older duplicates
|
||||
- **Dry-run mode**: Shows what would be done without making changes
|
||||
- **Stale PR detection**: Identifies PRs older than 30 days with no activity
|
||||
- **Explanatory comments**: Adds comments when closing PRs to explain why
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Dry run (default) - shows what would be done
|
||||
./scripts/cleanup-duplicate-prs.sh
|
||||
|
||||
# Actually close duplicates
|
||||
./scripts/cleanup-duplicate-prs.sh --close
|
||||
|
||||
# Set environment variables
|
||||
export GITEA_TOKEN="your_token_here"
|
||||
export REPO="Timmy_Foundation/the-nexus"
|
||||
export GITEA_URL="https://forge.alexanderwhitestone.com"
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The script uses the following environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `GITEA_TOKEN` | (required) | Gitea API token with repo access |
|
||||
| `GITEA_URL` | `https://forge.alexanderwhitestone.com` | Gitea instance URL |
|
||||
| `REPO` | `Timmy_Foundation/the-nexus` | Repository in `owner/repo` format |
|
||||
| `DRY_RUN` | `true` | Set to `false` to actually close PRs |
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Fetch open PRs**: Gets all open PRs from the repository
|
||||
2. **Extract issue numbers**: Parses issue numbers from PR titles (e.g., `#123`)
|
||||
3. **Group by issue**: Groups PRs that address the same issue
|
||||
4. **Identify duplicates**: Finds issues with multiple open PRs
|
||||
5. **Select newest**: For each duplicate group, keeps the newest PR
|
||||
6. **Close older PRs**: Closes older duplicates with explanatory comments
|
||||
7. **Check for stale PRs**: Identifies PRs older than 30 days
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
[2026-04-14T00:57:05Z] Checking open PRs for Timmy_Foundation/the-nexus (dry_run: true)
|
||||
[2026-04-14T00:57:17Z] Found 14 open PRs
|
||||
[2026-04-14T00:57:17Z] Issue #1338 has 2 open PRs
|
||||
[2026-04-14T00:57:17Z] Keeping PR #1392 (newest)
|
||||
[2026-04-14T00:57:17Z] DRY RUN: Would close PR #1388
|
||||
[2026-04-14T00:57:17Z] Issue #1354 has 2 open PRs
|
||||
[2026-04-14T00:57:17Z] Keeping PR #1391 (newest)
|
||||
[2026-04-14T00:57:17Z] DRY RUN: Would close PR #1384
|
||||
[2026-04-14T00:57:17Z] Cleanup complete:
|
||||
[2026-04-14T00:57:17Z] Duplicate issue groups found: 4
|
||||
[2026-04-14T00:57:17Z] PRs closed: 0
|
||||
[2026-04-14T00:57:17Z] Dry run: true
|
||||
```
|
||||
|
||||
### Safety Features
|
||||
|
||||
- **Dry-run by default**: Won't close PRs unless explicitly told to
|
||||
- **Explanatory comments**: Adds comments before closing to explain why
|
||||
- **Newest PR preserved**: Always keeps the most recent PR for each issue
|
||||
- **No force deletion**: Only closes PRs, doesn't delete branches
|
||||
|
||||
### Integration
|
||||
|
||||
This script can be integrated into CI/CD pipelines or run manually as part of regular maintenance. It's designed to be run weekly to keep the PR board clean.
|
||||
|
||||
### Related Issues
|
||||
|
||||
- **Issue #1128**: Forge Cleanup — PRs Closed, Milestones Deduplicated, Policy Issues Filed
|
||||
- **Issue #1127**: Evening triage pass (predecessor to #1128)
|
||||
@@ -1,170 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# cleanup-duplicate-prs.sh — Identify and close duplicate open PRs
|
||||
#
|
||||
# This script identifies PRs that are duplicates (same issue number
|
||||
# or very similar titles) and closes the older ones.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/cleanup-duplicate-prs.sh [--dry-run] [--close]
|
||||
#
|
||||
# Options:
|
||||
# --dry-run Show what would be done without making changes
|
||||
# --close Actually close duplicate PRs (default is dry-run)
|
||||
#
|
||||
# Designed for issue #1128: Forge Cleanup
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Configuration ──────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:?Set GITEA_TOKEN env var}"
|
||||
REPO="${REPO:-Timmy_Foundation/the-nexus}"
|
||||
DRY_RUN="${DRY_RUN:-true}"
|
||||
|
||||
# Parse command line arguments
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--dry-run)
|
||||
DRY_RUN="true"
|
||||
;;
|
||||
--close)
|
||||
DRY_RUN="false"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
API="$GITEA_URL/api/v1"
|
||||
AUTH="token $GITEA_TOKEN"
|
||||
|
||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; }
|
||||
|
||||
# ─── Fetch open PRs ────────────────────────────────────────
|
||||
log "Checking open PRs for $REPO (dry_run: $DRY_RUN)"
|
||||
|
||||
OPEN_PRS=$(curl -s -H "$AUTH" "$API/repos/$REPO/pulls?state=open&limit=50")
|
||||
|
||||
if [ -z "$OPEN_PRS" ] || [ "$OPEN_PRS" = "null" ]; then
|
||||
log "No open PRs found or API error"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Count PRs
|
||||
PR_COUNT=$(echo "$OPEN_PRS" | jq length)
|
||||
log "Found $PR_COUNT open PRs"
|
||||
|
||||
if [ "$PR_COUNT" -eq 0 ]; then
|
||||
log "No open PRs to process"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Extract issue numbers from PR titles ──────────────────
|
||||
# Create a temporary file for PR data
|
||||
TEMP_FILE=$(mktemp)
|
||||
echo "$OPEN_PRS" | jq -r '.[] | "\(.number)\t\(.title)\t\(.created_at)\t\(.head.ref)"' > "$TEMP_FILE"
|
||||
|
||||
# Group PRs by issue number using temporary files
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap "rm -rf $TEMP_DIR" EXIT
|
||||
|
||||
while IFS=$'\t' read -r pr_number pr_title pr_created pr_branch; do
|
||||
# Extract issue number from title (look for #123 pattern)
|
||||
if [[ $pr_title =~ \#([0-9]+) ]]; then
|
||||
issue_num="${BASH_REMATCH[1]}"
|
||||
echo "$pr_number,$pr_created,$pr_branch" >> "$TEMP_DIR/issue_$issue_num.txt"
|
||||
fi
|
||||
done < "$TEMP_FILE"
|
||||
|
||||
rm -f "$TEMP_FILE"
|
||||
|
||||
# ─── Identify and process duplicates ──────────────────────
|
||||
DUPLICATES_FOUND=0
|
||||
CLOSED_COUNT=0
|
||||
|
||||
for issue_file in "$TEMP_DIR"/issue_*.txt; do
|
||||
[ -f "$issue_file" ] || continue
|
||||
|
||||
issue_num=$(basename "$issue_file" .txt | sed 's/issue_//')
|
||||
pr_list=$(cat "$issue_file")
|
||||
|
||||
# Count PRs for this issue
|
||||
pr_count=$(echo -n "$pr_list" | grep -c '^' || true)
|
||||
|
||||
if [ "$pr_count" -le 1 ]; then
|
||||
continue # No duplicates
|
||||
fi
|
||||
|
||||
log "Issue #$issue_num has $pr_count open PRs"
|
||||
DUPLICATES_FOUND=$((DUPLICATES_FOUND + 1))
|
||||
|
||||
# Sort by creation date (oldest first)
|
||||
sorted_prs=$(echo -n "$pr_list" | sort -t',' -k2)
|
||||
|
||||
# Keep the newest PR, close the rest
|
||||
newest_pr=""
|
||||
newest_date=""
|
||||
|
||||
while IFS=',' read -r pr_num pr_date pr_branch; do
|
||||
if [ -z "$newest_date" ] || [[ "$pr_date" > "$newest_date" ]]; then
|
||||
newest_pr="$pr_num"
|
||||
newest_date="$pr_date"
|
||||
fi
|
||||
done <<< "$sorted_prs"
|
||||
|
||||
log "Keeping PR #$newest_pr (newest)"
|
||||
|
||||
# Close older PRs
|
||||
while IFS=',' read -r pr_num pr_date pr_branch; do
|
||||
if [ "$pr_num" = "$newest_pr" ]; then
|
||||
continue # Skip the newest PR
|
||||
fi
|
||||
|
||||
log "Closing duplicate PR #$pr_num for issue #$issue_num"
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
log "DRY RUN: Would close PR #$pr_num"
|
||||
else
|
||||
# Add a comment explaining why we're closing
|
||||
comment_body="Closing as duplicate. PR #$newest_pr is newer and addresses the same issue (#$issue_num)."
|
||||
|
||||
curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" -d "{\"body\": \"$comment_body\"}" "$API/repos/$REPO/issues/$pr_num/comments" > /dev/null
|
||||
|
||||
# Close the PR
|
||||
curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" -d '{"state": "closed"}' "$API/repos/$REPO/pulls/$pr_num" > /dev/null
|
||||
|
||||
log "Closed PR #$pr_num"
|
||||
CLOSED_COUNT=$((CLOSED_COUNT + 1))
|
||||
fi
|
||||
done <<< "$sorted_prs"
|
||||
done
|
||||
|
||||
# ─── Summary ──────────────────────────────────────────────
|
||||
log "Cleanup complete:"
|
||||
log " Duplicate issue groups found: $DUPLICATES_FOUND"
|
||||
log " PRs closed: $CLOSED_COUNT"
|
||||
log " Dry run: $DRY_RUN"
|
||||
|
||||
if [ "$DUPLICATES_FOUND" -eq 0 ]; then
|
||||
log "No duplicate PRs found"
|
||||
fi
|
||||
|
||||
# ─── Additional cleanup: Stale PRs ────────────────────────
|
||||
# Check for PRs older than 30 days with no activity
|
||||
log "Checking for stale PRs (older than 30 days)..."
|
||||
|
||||
THIRTY_DAYS_AGO=$(date -u -v-30d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
STALE_PRS=$(echo "$OPEN_PRS" | jq -r --arg cutoff "$THIRTY_DAYS_AGO" '.[] | select(.created_at < $cutoff) | "\(.number)\t\(.title)\t\(.created_at)"')
|
||||
|
||||
if [ -n "$STALE_PRS" ]; then
|
||||
STALE_COUNT=$(echo -n "$STALE_PRS" | grep -c '^' || true)
|
||||
log "Found $STALE_COUNT stale PRs (older than 30 days)"
|
||||
|
||||
echo "$STALE_PRS" | while IFS=$'\t' read -r pr_num pr_title pr_created; do
|
||||
log "Stale PR #$pr_num: $pr_title (created: $pr_created)"
|
||||
done
|
||||
else
|
||||
log "No stale PRs found"
|
||||
fi
|
||||
|
||||
log "Script complete"
|
||||
@@ -4,61 +4,48 @@ Sync branch protection rules from .gitea/branch-protection/*.yml to Gitea.
|
||||
Correctly uses the Gitea 1.25+ API (not GitHub-style).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
GITEA_URL = os.getenv("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||||
ORG = "Timmy_Foundation"
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_DIR = PROJECT_ROOT / ".gitea" / "branch-protection"
|
||||
CONFIG_DIR = ".gitea/branch-protection"
|
||||
|
||||
|
||||
def api_request(method: str, path: str, payload: dict | None = None) -> dict:
|
||||
url = f"{GITEA_URL}/api/v1{path}"
|
||||
data = json.dumps(payload).encode() if payload else None
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def build_branch_protection_payload(branch: str, rules: dict) -> dict:
|
||||
return {
|
||||
def apply_protection(repo: str, rules: dict) -> bool:
|
||||
branch = rules.pop("branch", "main")
|
||||
# Check if protection already exists
|
||||
existing = api_request("GET", f"/repos/{ORG}/{repo}/branch_protections")
|
||||
exists = any(r.get("branch_name") == branch for r in existing)
|
||||
|
||||
payload = {
|
||||
"branch_name": branch,
|
||||
"rule_name": branch,
|
||||
"required_approvals": rules.get("required_approvals", 1),
|
||||
"block_on_rejected_reviews": rules.get("block_on_rejected_reviews", True),
|
||||
"dismiss_stale_approvals": rules.get("dismiss_stale_approvals", True),
|
||||
"block_deletions": rules.get("block_deletions", True),
|
||||
"block_force_push": rules.get("block_force_push", rules.get("block_force_pushes", True)),
|
||||
"block_force_push": rules.get("block_force_push", True),
|
||||
"block_admin_merge_override": rules.get("block_admin_merge_override", True),
|
||||
"enable_status_check": rules.get("require_ci_to_merge", False),
|
||||
"status_check_contexts": rules.get("status_check_contexts", []),
|
||||
"block_on_outdated_branch": rules.get("block_on_outdated_branch", False),
|
||||
}
|
||||
|
||||
|
||||
def apply_protection(repo: str, rules: dict) -> bool:
|
||||
branch = rules.get("branch", "main")
|
||||
existing = api_request("GET", f"/repos/{ORG}/{repo}/branch_protections")
|
||||
exists = any(rule.get("branch_name") == branch for rule in existing)
|
||||
payload = build_branch_protection_payload(branch, rules)
|
||||
|
||||
try:
|
||||
if exists:
|
||||
api_request("PATCH", f"/repos/{ORG}/{repo}/branch_protections/{branch}", payload)
|
||||
@@ -66,8 +53,8 @@ def apply_protection(repo: str, rules: dict) -> bool:
|
||||
api_request("POST", f"/repos/{ORG}/{repo}/branch_protections", payload)
|
||||
print(f"✅ {repo}:{branch} synced")
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"❌ {repo}:{branch} failed: {exc}")
|
||||
except Exception as e:
|
||||
print(f"❌ {repo}:{branch} failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -75,18 +62,15 @@ def main() -> int:
|
||||
if not GITEA_TOKEN:
|
||||
print("ERROR: GITEA_TOKEN not set")
|
||||
return 1
|
||||
if not CONFIG_DIR.exists():
|
||||
print(f"ERROR: config directory not found: {CONFIG_DIR}")
|
||||
return 1
|
||||
|
||||
ok = 0
|
||||
for cfg_path in sorted(CONFIG_DIR.glob("*.yml")):
|
||||
repo = cfg_path.stem
|
||||
with cfg_path.open() as fh:
|
||||
cfg = yaml.safe_load(fh) or {}
|
||||
rules = cfg.get("rules", {})
|
||||
rules.setdefault("branch", cfg.get("branch", "main"))
|
||||
if apply_protection(repo, rules):
|
||||
for fname in os.listdir(CONFIG_DIR):
|
||||
if not fname.endswith(".yml"):
|
||||
continue
|
||||
repo = fname[:-4]
|
||||
with open(os.path.join(CONFIG_DIR, fname)) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
if apply_protection(repo, cfg.get("rules", {})):
|
||||
ok += 1
|
||||
|
||||
print(f"\nSynced {ok} repo(s)")
|
||||
|
||||
239
server.py
239
server.py
@@ -3,37 +3,20 @@
|
||||
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
|
||||
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
|
||||
the body (Evennia/Morrowind), and the visualization surface.
|
||||
|
||||
Security features:
|
||||
- Binds to 127.0.0.1 by default (localhost only)
|
||||
- Optional external binding via NEXUS_WS_HOST environment variable
|
||||
- Token-based authentication via NEXUS_WS_TOKEN environment variable
|
||||
- Rate limiting on connections
|
||||
- Connection logging and monitoring
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Set, Dict, Optional
|
||||
from collections import defaultdict
|
||||
from typing import Set
|
||||
|
||||
# Branch protected file - see POLICY.md
|
||||
import websockets
|
||||
|
||||
# Configuration
|
||||
PORT = int(os.environ.get("NEXUS_WS_PORT", "8765"))
|
||||
PTY_PORT = int(os.environ.get("NEXUS_PTY_PORT", "8766")) # operator shell PTY gateway
|
||||
HOST = os.environ.get("NEXUS_WS_HOST", "127.0.0.1") # Default to localhost only
|
||||
AUTH_TOKEN = os.environ.get("NEXUS_WS_TOKEN", "") # Empty = no auth required
|
||||
RATE_LIMIT_WINDOW = 60 # seconds
|
||||
RATE_LIMIT_MAX_CONNECTIONS = 10 # max connections per IP per window
|
||||
RATE_LIMIT_MAX_MESSAGES = 100 # max messages per connection per window
|
||||
PORT = 8765
|
||||
HOST = "0.0.0.0" # Allow external connections if needed
|
||||
|
||||
# Logging setup
|
||||
logging.basicConfig(
|
||||
@@ -45,207 +28,15 @@ logger = logging.getLogger("nexus-gateway")
|
||||
|
||||
# State
|
||||
clients: Set[websockets.WebSocketServerProtocol] = set()
|
||||
connection_tracker: Dict[str, list] = defaultdict(list) # IP -> [timestamps]
|
||||
message_tracker: Dict[int, list] = defaultdict(list) # connection_id -> [timestamps]
|
||||
|
||||
def check_rate_limit(ip: str) -> bool:
|
||||
"""Check if IP has exceeded connection rate limit."""
|
||||
now = time.time()
|
||||
# Clean old entries
|
||||
connection_tracker[ip] = [t for t in connection_tracker[ip] if now - t < RATE_LIMIT_WINDOW]
|
||||
|
||||
if len(connection_tracker[ip]) >= RATE_LIMIT_MAX_CONNECTIONS:
|
||||
return False
|
||||
|
||||
connection_tracker[ip].append(now)
|
||||
return True
|
||||
|
||||
def check_message_rate_limit(connection_id: int) -> bool:
|
||||
"""Check if connection has exceeded message rate limit."""
|
||||
now = time.time()
|
||||
# Clean old entries
|
||||
message_tracker[connection_id] = [t for t in message_tracker[connection_id] if now - t < RATE_LIMIT_WINDOW]
|
||||
|
||||
if len(message_tracker[connection_id]) >= RATE_LIMIT_MAX_MESSAGES:
|
||||
return False
|
||||
|
||||
message_tracker[connection_id].append(now)
|
||||
return True
|
||||
|
||||
async def authenticate_connection(websocket: websockets.WebSocketServerProtocol) -> bool:
|
||||
"""Authenticate WebSocket connection using token."""
|
||||
if not AUTH_TOKEN:
|
||||
# No authentication required
|
||||
return True
|
||||
|
||||
try:
|
||||
# Wait for authentication message (first message should be auth)
|
||||
auth_message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
|
||||
auth_data = json.loads(auth_message)
|
||||
|
||||
if auth_data.get("type") != "auth":
|
||||
logger.warning(f"Invalid auth message type from {websocket.remote_address}")
|
||||
return False
|
||||
|
||||
token = auth_data.get("token", "")
|
||||
if token != AUTH_TOKEN:
|
||||
logger.warning(f"Invalid auth token from {websocket.remote_address}")
|
||||
return False
|
||||
|
||||
logger.info(f"Authenticated connection from {websocket.remote_address}")
|
||||
return True
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Authentication timeout from {websocket.remote_address}")
|
||||
return False
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid auth JSON from {websocket.remote_address}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication error from {websocket.remote_address}: {e}")
|
||||
return False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git status helper (issue #1695)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _get_git_status() -> dict:
|
||||
"""Return a dict describing the current repo git state."""
|
||||
repo_root = os.path.dirname(os.path.abspath(__file__))
|
||||
try:
|
||||
branch_out = subprocess.check_output(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=repo_root, stderr=subprocess.DEVNULL, text=True
|
||||
).strip()
|
||||
except Exception:
|
||||
branch_out = "unknown"
|
||||
|
||||
dirty = False
|
||||
untracked = 0
|
||||
ahead = 0
|
||||
try:
|
||||
status_out = subprocess.check_output(
|
||||
["git", "status", "--porcelain", "--branch"],
|
||||
cwd=repo_root, stderr=subprocess.DEVNULL, text=True
|
||||
)
|
||||
for line in status_out.splitlines():
|
||||
if line.startswith("##") and "ahead" in line:
|
||||
import re
|
||||
m = re.search(r"ahead (\d+)", line)
|
||||
if m:
|
||||
ahead = int(m.group(1))
|
||||
elif line.startswith("??"):
|
||||
untracked += 1
|
||||
elif line and not line.startswith("##"):
|
||||
dirty = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"type": "git_status",
|
||||
"branch": branch_out,
|
||||
"dirty": dirty,
|
||||
"untracked": untracked,
|
||||
"ahead": ahead,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PTY shell handler (issue #1695) — operator cockpit terminal
|
||||
# Binds on PTY_PORT (default 8766), localhost only.
|
||||
# Each WebSocket connection gets its own PTY subprocess.
|
||||
# ---------------------------------------------------------------------------
|
||||
async def pty_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
"""Spawn a local PTY shell and bridge it to the WebSocket client."""
|
||||
addr = websocket.remote_address
|
||||
logger.info(f"[PTY] Operator shell connection from {addr}")
|
||||
|
||||
shell = os.environ.get("SHELL", "/bin/bash")
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
shell,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
preexec_fn=os.setsid,
|
||||
close_fds=True,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async def pty_to_ws():
|
||||
"""Read PTY output and forward to WebSocket."""
|
||||
try:
|
||||
while True:
|
||||
data = await loop.run_in_executor(None, os.read, master_fd, 4096)
|
||||
if not data:
|
||||
break
|
||||
await websocket.send(data.decode("utf-8", errors="replace"))
|
||||
except (OSError, websockets.exceptions.ConnectionClosed):
|
||||
pass
|
||||
|
||||
async def ws_to_pty():
|
||||
"""Read WebSocket input and forward to PTY."""
|
||||
try:
|
||||
async for message in websocket:
|
||||
if isinstance(message, str):
|
||||
os.write(master_fd, message.encode("utf-8"))
|
||||
else:
|
||||
os.write(master_fd, message)
|
||||
except (OSError, websockets.exceptions.ConnectionClosed):
|
||||
pass
|
||||
|
||||
reader = asyncio.ensure_future(pty_to_ws())
|
||||
writer = asyncio.ensure_future(ws_to_pty())
|
||||
try:
|
||||
await asyncio.gather(reader, writer)
|
||||
finally:
|
||||
reader.cancel()
|
||||
writer.cancel()
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await proc.wait()
|
||||
logger.info(f"[PTY] Shell session ended for {addr}")
|
||||
|
||||
|
||||
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
"""Handles individual client connections and message broadcasting."""
|
||||
addr = websocket.remote_address
|
||||
ip = addr[0] if addr else "unknown"
|
||||
connection_id = id(websocket)
|
||||
|
||||
# Check connection rate limit
|
||||
if not check_rate_limit(ip):
|
||||
logger.warning(f"Connection rate limit exceeded for {ip}")
|
||||
await websocket.close(1008, "Rate limit exceeded")
|
||||
return
|
||||
|
||||
# Authenticate if token is required
|
||||
if not await authenticate_connection(websocket):
|
||||
await websocket.close(1008, "Authentication failed")
|
||||
return
|
||||
|
||||
clients.add(websocket)
|
||||
addr = websocket.remote_address
|
||||
logger.info(f"Client connected from {addr}. Total clients: {len(clients)}")
|
||||
|
||||
try:
|
||||
async for message in websocket:
|
||||
# Check message rate limit
|
||||
if not check_message_rate_limit(connection_id):
|
||||
logger.warning(f"Message rate limit exceeded for {addr}")
|
||||
await websocket.send(json.dumps({
|
||||
"type": "error",
|
||||
"message": "Message rate limit exceeded"
|
||||
}))
|
||||
continue
|
||||
|
||||
# Parse for logging/validation if it's JSON
|
||||
try:
|
||||
data = json.loads(message)
|
||||
@@ -253,11 +44,6 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
# Optional: log specific important message types
|
||||
if msg_type in ["agent_register", "thought", "action"]:
|
||||
logger.debug(f"Received {msg_type} from {addr}")
|
||||
# Handle git status requests from the operator cockpit (issue #1695)
|
||||
if msg_type == "git_status_request":
|
||||
git_info = _get_git_status()
|
||||
await websocket.send(json.dumps(git_info))
|
||||
continue
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -295,20 +81,6 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
|
||||
async def main():
|
||||
"""Main server loop with graceful shutdown."""
|
||||
# Log security configuration
|
||||
if AUTH_TOKEN:
|
||||
logger.info("Authentication: ENABLED (token required)")
|
||||
else:
|
||||
logger.warning("Authentication: DISABLED (no token required)")
|
||||
|
||||
if HOST == "0.0.0.0":
|
||||
logger.warning("Host binding: 0.0.0.0 (all interfaces) - SECURITY RISK")
|
||||
else:
|
||||
logger.info(f"Host binding: {HOST} (localhost only)")
|
||||
|
||||
logger.info(f"Rate limiting: {RATE_LIMIT_MAX_CONNECTIONS} connections/IP/{RATE_LIMIT_WINDOW}s, "
|
||||
f"{RATE_LIMIT_MAX_MESSAGES} messages/connection/{RATE_LIMIT_WINDOW}s")
|
||||
|
||||
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
|
||||
|
||||
# Set up signal handlers for graceful shutdown
|
||||
@@ -328,9 +100,6 @@ async def main():
|
||||
|
||||
async with websockets.serve(broadcast_handler, HOST, PORT):
|
||||
logger.info("Gateway is ready and listening.")
|
||||
# Also start the PTY gateway on PTY_PORT (operator cockpit shell, issue #1695)
|
||||
async with websockets.serve(pty_handler, "127.0.0.1", PTY_PORT):
|
||||
logger.info(f"PTY shell gateway listening on ws://127.0.0.1:{PTY_PORT}/pty")
|
||||
await stop
|
||||
|
||||
logger.info("Shutting down Nexus WS gateway...")
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
/**
|
||||
* session-manager.js — Session taxonomy primitives for the Nexus operator cockpit
|
||||
*
|
||||
* Operations: create, list, setActive, group, tag, pin, archive, restore, delete
|
||||
* Persistence: localStorage under key `nexus-sessions`
|
||||
*
|
||||
* Refs: issue #1695 — ATLAS cockpit operator patterns
|
||||
* Pattern sources: dodo-reach/hermes-desktop session sidebar, nesquena/hermes-webui session groups
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'nexus-sessions';
|
||||
const META_KEY = 'nexus-sessions-meta';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data model
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session:
|
||||
// id: string (uuid-ish)
|
||||
// name: string
|
||||
// group: string | null
|
||||
// tags: string[]
|
||||
// pinned: boolean
|
||||
// archived: boolean
|
||||
// createdAt: number (epoch ms)
|
||||
// updatedAt: number
|
||||
// meta: {} (arbitrary caller-supplied data)
|
||||
//
|
||||
// Meta:
|
||||
// activeId: string | null
|
||||
|
||||
let _sessions = [];
|
||||
let _activeId = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
_sessions = raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
_sessions = [];
|
||||
}
|
||||
try {
|
||||
const rawMeta = localStorage.getItem(META_KEY);
|
||||
const meta = rawMeta ? JSON.parse(rawMeta) : {};
|
||||
_activeId = meta.activeId || null;
|
||||
} catch {
|
||||
_activeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(_sessions));
|
||||
localStorage.setItem(META_KEY, JSON.stringify({ activeId: _activeId }));
|
||||
} catch (e) {
|
||||
console.warn('[SessionManager] Could not persist sessions:', e);
|
||||
}
|
||||
emit('change', { sessions: _sessions, activeId: _activeId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ID generation
|
||||
// ---------------------------------------------------------------------------
|
||||
function genId() {
|
||||
return 'sess_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event bus (lightweight)
|
||||
// ---------------------------------------------------------------------------
|
||||
const _listeners = {};
|
||||
|
||||
function on(event, fn) {
|
||||
if (!_listeners[event]) _listeners[event] = [];
|
||||
_listeners[event].push(fn);
|
||||
}
|
||||
|
||||
function off(event, fn) {
|
||||
if (!_listeners[event]) return;
|
||||
_listeners[event] = _listeners[event].filter(f => f !== fn);
|
||||
}
|
||||
|
||||
function emit(event, data) {
|
||||
(_listeners[event] || []).forEach(fn => { try { fn(data); } catch {} });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
function create(name, opts = {}) {
|
||||
const session = {
|
||||
id: genId(),
|
||||
name: name || 'Untitled Session',
|
||||
group: opts.group || null,
|
||||
tags: opts.tags || [],
|
||||
pinned: opts.pinned || false,
|
||||
archived: false,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
meta: opts.meta || {},
|
||||
};
|
||||
_sessions.push(session);
|
||||
if (!_activeId) _activeId = session.id;
|
||||
save();
|
||||
return session;
|
||||
}
|
||||
|
||||
function get(id) {
|
||||
return _sessions.find(s => s.id === id) || null;
|
||||
}
|
||||
|
||||
function list(opts = {}) {
|
||||
let result = [..._sessions];
|
||||
|
||||
if (opts.group) result = result.filter(s => s.group === opts.group);
|
||||
if (opts.tag) result = result.filter(s => s.tags.includes(opts.tag));
|
||||
if (opts.pinned !== undefined) result = result.filter(s => s.pinned === opts.pinned);
|
||||
if (opts.archived !== undefined) result = result.filter(s => s.archived === opts.archived);
|
||||
|
||||
// Default: hide archived unless explicitly requested
|
||||
if (opts.archived === undefined) result = result.filter(s => !s.archived);
|
||||
|
||||
// Pinned items first, then by updatedAt desc
|
||||
result.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return b.pinned ? 1 : -1;
|
||||
return b.updatedAt - a.updatedAt;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function update(id, patch) {
|
||||
const idx = _sessions.findIndex(s => s.id === id);
|
||||
if (idx < 0) return null;
|
||||
_sessions[idx] = { ..._sessions[idx], ...patch, updatedAt: Date.now() };
|
||||
save();
|
||||
return _sessions[idx];
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const before = _sessions.length;
|
||||
_sessions = _sessions.filter(s => s.id !== id);
|
||||
if (_activeId === id) _activeId = _sessions.find(s => !s.archived)?.id || null;
|
||||
if (_sessions.length !== before) save();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Taxonomy operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Set or clear the group for a session. */
|
||||
function group(id, groupName) {
|
||||
return update(id, { group: groupName || null });
|
||||
}
|
||||
|
||||
/** Add one or more tags to a session. */
|
||||
function tag(id, ...tags) {
|
||||
const s = get(id);
|
||||
if (!s) return null;
|
||||
const merged = Array.from(new Set([...s.tags, ...tags.filter(Boolean)]));
|
||||
return update(id, { tags: merged });
|
||||
}
|
||||
|
||||
/** Remove a tag from a session. */
|
||||
function untag(id, tagName) {
|
||||
const s = get(id);
|
||||
if (!s) return null;
|
||||
return update(id, { tags: s.tags.filter(t => t !== tagName) });
|
||||
}
|
||||
|
||||
/** Toggle pin state. */
|
||||
function pin(id) {
|
||||
const s = get(id);
|
||||
if (!s) return null;
|
||||
return update(id, { pinned: !s.pinned });
|
||||
}
|
||||
|
||||
/** Archive a session (hides from default list). */
|
||||
function archive(id) {
|
||||
return update(id, { archived: true, pinned: false });
|
||||
}
|
||||
|
||||
/** Restore an archived session. */
|
||||
function restore(id) {
|
||||
return update(id, { archived: false });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active session
|
||||
// ---------------------------------------------------------------------------
|
||||
function getActive() {
|
||||
return _activeId;
|
||||
}
|
||||
|
||||
function getActiveSession() {
|
||||
return _activeId ? get(_activeId) : null;
|
||||
}
|
||||
|
||||
function setActive(id) {
|
||||
if (!get(id)) return false;
|
||||
_activeId = id;
|
||||
save();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function listGroups() {
|
||||
const seen = new Set();
|
||||
_sessions.forEach(s => { if (s.group) seen.add(s.group); });
|
||||
return Array.from(seen).sort();
|
||||
}
|
||||
|
||||
function listTags() {
|
||||
const seen = new Set();
|
||||
_sessions.forEach(s => s.tags.forEach(t => seen.add(t)));
|
||||
return Array.from(seen).sort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import / export (for backup / hand-off)
|
||||
// ---------------------------------------------------------------------------
|
||||
function exportAll() {
|
||||
return JSON.stringify({ sessions: _sessions, activeId: _activeId }, null, 2);
|
||||
}
|
||||
|
||||
function importAll(json) {
|
||||
try {
|
||||
const data = JSON.parse(json);
|
||||
if (!Array.isArray(data.sessions)) throw new Error('Invalid format');
|
||||
_sessions = data.sessions;
|
||||
_activeId = data.activeId || null;
|
||||
save();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[SessionManager] Import failed:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
load();
|
||||
|
||||
// Create a default session if store is empty
|
||||
if (_sessions.length === 0) {
|
||||
create('Default', { tags: ['auto'], meta: { auto: true } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
window.SessionManager = {
|
||||
create,
|
||||
get,
|
||||
list,
|
||||
update,
|
||||
remove,
|
||||
|
||||
// Taxonomy
|
||||
group,
|
||||
tag,
|
||||
untag,
|
||||
pin,
|
||||
archive,
|
||||
restore,
|
||||
|
||||
// Active session
|
||||
getActive,
|
||||
getActiveSession,
|
||||
setActive,
|
||||
|
||||
// Helpers
|
||||
listGroups,
|
||||
listTags,
|
||||
|
||||
// Import/export
|
||||
exportAll,
|
||||
importAll,
|
||||
|
||||
// Events
|
||||
on,
|
||||
off,
|
||||
};
|
||||
|
||||
console.info('[SessionManager] Loaded. Sessions:', _sessions.length, '| Active:', _activeId);
|
||||
})();
|
||||
549
style.css
549
style.css
@@ -2685,552 +2685,3 @@ body.operator-mode #mode-label {
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
/* ═══ REASONING TRACE COMPONENT ═══ */
|
||||
|
||||
.reasoning-trace {
|
||||
width: 320px;
|
||||
max-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trace-header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.trace-header-container .panel-header {
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.trace-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.trace-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trace-btn {
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
border: 1px solid rgba(74, 240, 192, 0.2);
|
||||
color: #4af0c0;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.trace-btn:hover {
|
||||
background: rgba(74, 240, 192, 0.2);
|
||||
border-color: #4af0c0;
|
||||
}
|
||||
|
||||
.trace-task {
|
||||
font-size: 9px;
|
||||
color: #8899aa;
|
||||
margin-bottom: 4px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.trace-task.active {
|
||||
color: #4af0c0;
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
border-left: 2px solid #4af0c0;
|
||||
}
|
||||
|
||||
.trace-counter {
|
||||
font-size: 9px;
|
||||
color: #667788;
|
||||
margin-bottom: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.trace-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.trace-step {
|
||||
margin-bottom: 8px;
|
||||
padding: 6px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
border-left: 3px solid #4af0c0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.trace-step-think {
|
||||
border-left-color: #4af0c0;
|
||||
}
|
||||
|
||||
.trace-step-decide {
|
||||
border-left-color: #ffd700;
|
||||
}
|
||||
|
||||
.trace-step-recall {
|
||||
border-left-color: #7b5cff;
|
||||
}
|
||||
|
||||
.trace-step-plan {
|
||||
border-left-color: #ff8c42;
|
||||
}
|
||||
|
||||
.trace-step-execute {
|
||||
border-left-color: #ff4466;
|
||||
}
|
||||
|
||||
.trace-step-verify {
|
||||
border-left-color: #4af0c0;
|
||||
}
|
||||
|
||||
.trace-step-doubt {
|
||||
border-left-color: #ff8c42;
|
||||
}
|
||||
|
||||
.trace-step-memory {
|
||||
border-left-color: #7b5cff;
|
||||
}
|
||||
|
||||
.trace-step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.step-type {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.step-time {
|
||||
color: #667788;
|
||||
font-size: 9px;
|
||||
margin-left: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.confidence-bar {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
color: #4af0c0;
|
||||
letter-spacing: -1px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.trace-step-content {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: #d9f7ff;
|
||||
}
|
||||
|
||||
.step-thought {
|
||||
margin-bottom: 4px;
|
||||
font-style: italic;
|
||||
color: #e0f0ff;
|
||||
}
|
||||
|
||||
.step-reasoning {
|
||||
margin-bottom: 4px;
|
||||
color: #aabbcc;
|
||||
font-size: 10px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid rgba(74, 240, 192, 0.2);
|
||||
}
|
||||
|
||||
.step-decision {
|
||||
margin-bottom: 4px;
|
||||
color: #ffd700;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.step-alternatives {
|
||||
margin-bottom: 4px;
|
||||
color: #8899aa;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.step-source {
|
||||
margin-bottom: 4px;
|
||||
color: #7b5cff;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.trace-separator {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(74, 240, 192, 0.2), transparent);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.trace-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #667788;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 9px;
|
||||
color: #445566;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Animation for new steps */
|
||||
@keyframes trace-step-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-step {
|
||||
animation: trace-step-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.reasoning-trace {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.trace-content {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Operator Inspector Rail — issue #1695
|
||||
========================================================================== */
|
||||
|
||||
.cockpit-inspector {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
background: rgba(5, 8, 20, 0.94);
|
||||
border-left: 1px solid rgba(74, 240, 192, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1200;
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
color: #c8e8ff;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: transform 0.25s ease, width 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cockpit-inspector.collapsed {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.cockpit-inspector.collapsed .ci-header,
|
||||
.cockpit-inspector.collapsed .ci-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Toggle button */
|
||||
.ci-toggle-btn {
|
||||
position: absolute;
|
||||
left: -22px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 22px;
|
||||
height: 44px;
|
||||
background: rgba(5, 8, 20, 0.9);
|
||||
border: 1px solid rgba(74, 240, 192, 0.25);
|
||||
border-right: none;
|
||||
color: #4af0c0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
border-radius: 4px 0 0 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ci-toggle-btn:hover { background: rgba(74, 240, 192, 0.1); }
|
||||
.ci-toggle-icon { line-height: 1; }
|
||||
|
||||
/* Header */
|
||||
.ci-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 10px 8px;
|
||||
border-bottom: 1px solid rgba(74, 240, 192, 0.12);
|
||||
background: rgba(74, 240, 192, 0.04);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ci-header-icon { color: #4af0c0; font-size: 13px; }
|
||||
.ci-header-title {
|
||||
flex: 1;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: #4af0c0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ci-header-actions { display: flex; gap: 4px; }
|
||||
|
||||
/* Generic icon button */
|
||||
.ci-icon-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(74, 240, 192, 0.2);
|
||||
color: #7ab8d8;
|
||||
font-size: 11px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ci-icon-btn:hover { border-color: #4af0c0; color: #4af0c0; }
|
||||
|
||||
/* Body scroll area */
|
||||
.ci-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(74,240,192,0.2) transparent;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.ci-section {
|
||||
border-bottom: 1px solid rgba(74, 240, 192, 0.08);
|
||||
}
|
||||
|
||||
.ci-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 7px 10px 6px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
color: #7ab8d8;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.ci-section-header:hover { color: #4af0c0; }
|
||||
.ci-section-icon { color: #4af0c0; font-size: 11px; width: 14px; text-align: center; }
|
||||
.ci-section-actions { margin-left: auto; display: flex; gap: 4px; }
|
||||
|
||||
.ci-section-badge {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
background: rgba(74,240,192,0.08);
|
||||
border-radius: 8px;
|
||||
color: #4af0c0;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ci-section-badge.badge-warn { background: rgba(255,160,40,0.15); color: #ffa028; }
|
||||
.ci-section-badge.badge-ok { background: rgba(74,240,192,0.12); color: #4af0c0; }
|
||||
|
||||
.ci-section-body { padding: 6px 10px 8px; }
|
||||
|
||||
.ci-empty-hint {
|
||||
color: rgba(200,232,255,0.35);
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* Tag chip */
|
||||
.ci-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
background: rgba(74,240,192,0.08);
|
||||
border: 1px solid rgba(74,240,192,0.18);
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
color: #7ab8d8;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.tag-pin { border-color: rgba(255,180,40,0.4); background: rgba(255,180,40,0.08); }
|
||||
.tag-archive { border-color: rgba(150,150,200,0.3); background: rgba(150,150,200,0.06); }
|
||||
|
||||
/* Git section rows */
|
||||
.ci-git-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 2px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
.ci-git-label { color: rgba(200,232,255,0.5); }
|
||||
.ci-git-value { color: #c8e8ff; font-weight: 500; }
|
||||
.ci-dirty { color: #ffa028; }
|
||||
.ci-clean { color: #4af0c0; }
|
||||
|
||||
/* Agent health rows */
|
||||
.ci-agent-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
.ci-agent-row:last-child { border-bottom: none; }
|
||||
|
||||
.ci-agent-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agent-idle { background: #4af0c0; }
|
||||
.agent-working { background: #ffa028; box-shadow: 0 0 4px #ffa028; animation: agent-pulse 1s infinite; }
|
||||
.agent-error { background: #ff4060; }
|
||||
.agent-unknown { background: rgba(200,232,255,0.3); }
|
||||
|
||||
@keyframes agent-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.ci-agent-info { flex: 1; min-width: 0; }
|
||||
.ci-agent-id { font-size: 10px; color: #c8e8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ci-agent-meta { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 2px; }
|
||||
.ci-agent-task { font-size: 9px; color: rgba(200,232,255,0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
|
||||
|
||||
.ci-agent-status-label {
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ci-agent-status-label.agent-idle { border-color: rgba(74,240,192,0.3); color: #4af0c0; }
|
||||
.ci-agent-status-label.agent-working { border-color: rgba(255,160,40,0.3); color: #ffa028; }
|
||||
.ci-agent-status-label.agent-error { border-color: rgba(255,64,96,0.3); color: #ff4060; }
|
||||
.ci-agent-status-label.agent-unknown { border-color: rgba(200,232,255,0.2); color: rgba(200,232,255,0.5); }
|
||||
|
||||
/* Session rows */
|
||||
.ci-session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.ci-session-row:hover { background: rgba(74,240,192,0.06); }
|
||||
.ci-session-row.active { background: rgba(74,240,192,0.1); border-left: 2px solid #4af0c0; }
|
||||
|
||||
.ci-session-info { flex: 1; min-width: 0; }
|
||||
.ci-session-name { font-size: 10px; color: #c8e8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ci-session-meta { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 2px; }
|
||||
.ci-session-actions { display: flex; gap: 2px; }
|
||||
|
||||
/* Artifact rows */
|
||||
.ci-artifact-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
.ci-artifact-row:last-child { border-bottom: none; }
|
||||
.ci-artifact-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.ci-artifact-info { flex: 1; min-width: 0; }
|
||||
.ci-artifact-name { font-size: 10px; color: #c8e8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ci-artifact-path { font-size: 9px; color: rgba(200,232,255,0.4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ci-artifact-type { flex-shrink: 0; }
|
||||
|
||||
/* Memory / skills rows */
|
||||
.ci-mem-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.ci-mem-region { font-size: 10px; color: #c8e8ff; }
|
||||
|
||||
/* Terminal section */
|
||||
.ci-terminal-section { flex: 0 0 auto; }
|
||||
|
||||
.ci-terminal-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
color: rgba(200,232,255,0.5);
|
||||
}
|
||||
|
||||
.ci-term-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ci-term-dot.disconnected { background: rgba(200,232,255,0.25); }
|
||||
.ci-term-dot.connecting { background: #ffa028; animation: agent-pulse 0.8s infinite; }
|
||||
.ci-term-dot.connected { background: #4af0c0; }
|
||||
|
||||
.ci-terminal-mount {
|
||||
margin: 0 10px 8px;
|
||||
border: 1px solid rgba(74,240,192,0.15);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: #0a0e1a;
|
||||
}
|
||||
|
||||
/* Ensure inspector doesn't cover up the 3D canvas on small screens */
|
||||
@media (max-width: 900px) {
|
||||
.cockpit-inspector { width: 220px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.cockpit-inspector { display: none; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
[Unit]
|
||||
Description=llama.cpp Local LLM Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Environment=MODEL_PATH=/opt/models/llama/Qwen2.5-7B-Instruct-Q4_K_M.gguf
|
||||
Environment=LLAMA_HOST=0.0.0.0
|
||||
Environment=LLAMA_PORT=11435
|
||||
Environment=LLAMA_CTX_SIZE=4096
|
||||
Environment=LLAMA_THREADS=4
|
||||
ExecStart=/usr/local/bin/llama-server -m ${MODEL_PATH} --host ${LLAMA_HOST} --port ${LLAMA_PORT} -c ${LLAMA_CTX_SIZE} -t ${LLAMA_THREADS} --cont-batching
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=12G
|
||||
CPUQuota=90%
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/opt/models
|
||||
PrivateTmp=true
|
||||
StandardOutput=journal
|
||||
SyslogIdentifier=llama-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,193 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WebSocket Load Test — Benchmark concurrent user sessions on the Nexus gateway.
|
||||
|
||||
Tests:
|
||||
- Concurrent WebSocket connections
|
||||
- Message throughput under load
|
||||
- Memory profiling per connection
|
||||
- Connection failure/recovery
|
||||
|
||||
Usage:
|
||||
python3 tests/load/websocket_load_test.py # default (50 users)
|
||||
python3 tests/load/websocket_load_test.py --users 200 # 200 concurrent
|
||||
python3 tests/load/websocket_load_test.py --duration 60 # 60 second test
|
||||
python3 tests/load/websocket_load_test.py --json # JSON output
|
||||
|
||||
Ref: #1505
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import argparse
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
WS_URL = os.environ.get("WS_URL", "ws://localhost:8765")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionStats:
|
||||
connected: bool = False
|
||||
connect_time_ms: float = 0
|
||||
messages_sent: int = 0
|
||||
messages_received: int = 0
|
||||
errors: int = 0
|
||||
latencies: List[float] = field(default_factory=list)
|
||||
disconnected: bool = False
|
||||
|
||||
|
||||
async def ws_client(user_id: int, duration: int, stats: ConnectionStats, ws_url: str = WS_URL):
|
||||
"""Single WebSocket client for load testing."""
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
# Fallback: use raw asyncio
|
||||
stats.errors += 1
|
||||
return
|
||||
|
||||
try:
|
||||
start = time.time()
|
||||
async with websockets.connect(ws_url, open_timeout=5) as ws:
|
||||
stats.connect_time_ms = (time.time() - start) * 1000
|
||||
stats.connected = True
|
||||
|
||||
# Send periodic messages for the duration
|
||||
end_time = time.time() + duration
|
||||
msg_count = 0
|
||||
while time.time() < end_time:
|
||||
try:
|
||||
msg_start = time.time()
|
||||
message = json.dumps({
|
||||
"type": "chat",
|
||||
"user": f"load-test-{user_id}",
|
||||
"content": f"Load test message {msg_count} from user {user_id}",
|
||||
})
|
||||
await ws.send(message)
|
||||
stats.messages_sent += 1
|
||||
|
||||
# Wait for response (with timeout)
|
||||
try:
|
||||
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||
stats.messages_received += 1
|
||||
latency = (time.time() - msg_start) * 1000
|
||||
stats.latencies.append(latency)
|
||||
except asyncio.TimeoutError:
|
||||
stats.errors += 1
|
||||
|
||||
msg_count += 1
|
||||
await asyncio.sleep(0.5) # 2 messages/sec per user
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
stats.disconnected = True
|
||||
break
|
||||
except Exception:
|
||||
stats.errors += 1
|
||||
|
||||
except Exception as e:
|
||||
stats.errors += 1
|
||||
if "Connection refused" in str(e) or "connect" in str(e).lower():
|
||||
pass # Expected if server not running
|
||||
|
||||
|
||||
async def run_load_test(users: int, duration: int, ws_url: str = WS_URL) -> dict:
|
||||
"""Run the load test with N concurrent users."""
|
||||
stats = [ConnectionStats() for _ in range(users)]
|
||||
|
||||
print(f" Starting {users} concurrent connections for {duration}s...")
|
||||
start = time.time()
|
||||
|
||||
tasks = [ws_client(i, duration, stats[i], ws_url) for i in range(users)]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
total_time = time.time() - start
|
||||
|
||||
# Aggregate results
|
||||
connected = sum(1 for s in stats if s.connected)
|
||||
total_sent = sum(s.messages_sent for s in stats)
|
||||
total_received = sum(s.messages_received for s in stats)
|
||||
total_errors = sum(s.errors for s in stats)
|
||||
disconnected = sum(1 for s in stats if s.disconnected)
|
||||
|
||||
all_latencies = []
|
||||
for s in stats:
|
||||
all_latencies.extend(s.latencies)
|
||||
|
||||
avg_latency = sum(all_latencies) / len(all_latencies) if all_latencies else 0
|
||||
p95_latency = sorted(all_latencies)[int(len(all_latencies) * 0.95)] if all_latencies else 0
|
||||
p99_latency = sorted(all_latencies)[int(len(all_latencies) * 0.99)] if all_latencies else 0
|
||||
|
||||
avg_connect_time = sum(s.connect_time_ms for s in stats if s.connected) / connected if connected else 0
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"duration_seconds": round(total_time, 1),
|
||||
"connected": connected,
|
||||
"connect_rate": round(connected / users * 100, 1),
|
||||
"messages_sent": total_sent,
|
||||
"messages_received": total_received,
|
||||
"throughput_msg_per_sec": round(total_sent / total_time, 1) if total_time > 0 else 0,
|
||||
"avg_latency_ms": round(avg_latency, 1),
|
||||
"p95_latency_ms": round(p95_latency, 1),
|
||||
"p99_latency_ms": round(p99_latency, 1),
|
||||
"avg_connect_time_ms": round(avg_connect_time, 1),
|
||||
"errors": total_errors,
|
||||
"disconnected": disconnected,
|
||||
}
|
||||
|
||||
|
||||
def print_report(result: dict):
|
||||
"""Print load test report."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" WEBSOCKET LOAD TEST REPORT")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
print(f" Connections: {result['connected']}/{result['users']} ({result['connect_rate']}%)")
|
||||
print(f" Duration: {result['duration_seconds']}s")
|
||||
print(f" Messages sent: {result['messages_sent']}")
|
||||
print(f" Messages recv: {result['messages_received']}")
|
||||
print(f" Throughput: {result['throughput_msg_per_sec']} msg/s")
|
||||
print(f" Avg connect: {result['avg_connect_time_ms']}ms")
|
||||
print()
|
||||
print(f" Latency:")
|
||||
print(f" Avg: {result['avg_latency_ms']}ms")
|
||||
print(f" P95: {result['p95_latency_ms']}ms")
|
||||
print(f" P99: {result['p99_latency_ms']}ms")
|
||||
print()
|
||||
print(f" Errors: {result['errors']}")
|
||||
print(f" Disconnected: {result['disconnected']}")
|
||||
|
||||
# Verdict
|
||||
if result['connect_rate'] >= 95 and result['errors'] == 0:
|
||||
print(f"\n ✅ PASS")
|
||||
elif result['connect_rate'] >= 80:
|
||||
print(f"\n ⚠️ DEGRADED")
|
||||
else:
|
||||
print(f"\n ❌ FAIL")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="WebSocket Load Test")
|
||||
parser.add_argument("--users", type=int, default=50, help="Concurrent users")
|
||||
parser.add_argument("--duration", type=int, default=30, help="Test duration in seconds")
|
||||
parser.add_argument("--json", action="store_true", help="JSON output")
|
||||
parser.add_argument("--url", default=WS_URL, help="WebSocket URL")
|
||||
args = parser.parse_args()
|
||||
|
||||
ws_url = args.url
|
||||
|
||||
print(f"\nWebSocket Load Test — {args.users} users, {args.duration}s\n")
|
||||
|
||||
result = asyncio.run(run_load_test(args.users, args.duration, ws_url))
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print_report(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,763 +0,0 @@
|
||||
"""
|
||||
Tests for A2A Protocol implementation.
|
||||
|
||||
Covers:
|
||||
- Type serialization roundtrips (Agent Card, Task, Message, Artifact, Part)
|
||||
- JSON-RPC envelope
|
||||
- Agent Card building from YAML config
|
||||
- Registry operations (register, list, filter)
|
||||
- Client/server integration (end-to-end task delegation)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
from nexus.a2a.types import (
|
||||
A2AError,
|
||||
AgentCard,
|
||||
AgentCapabilities,
|
||||
AgentInterface,
|
||||
AgentSkill,
|
||||
Artifact,
|
||||
DataPart,
|
||||
FilePart,
|
||||
JSONRPCError,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
Message,
|
||||
Role,
|
||||
Task,
|
||||
TaskState,
|
||||
TaskStatus,
|
||||
TextPart,
|
||||
part_from_dict,
|
||||
part_to_dict,
|
||||
)
|
||||
from nexus.a2a.card import build_card, load_card_config
|
||||
from nexus.a2a.registry import LocalFileRegistry
|
||||
|
||||
|
||||
# === Type Serialization Roundtrips ===
|
||||
|
||||
|
||||
class TestTextPart:
|
||||
def test_roundtrip(self):
|
||||
p = TextPart(text="hello world")
|
||||
d = p.to_dict()
|
||||
assert d == {"text": "hello world"}
|
||||
p2 = part_from_dict(d)
|
||||
assert isinstance(p2, TextPart)
|
||||
assert p2.text == "hello world"
|
||||
|
||||
def test_custom_media_type(self):
|
||||
p = TextPart(text="data", media_type="text/markdown")
|
||||
d = p.to_dict()
|
||||
assert d["mediaType"] == "text/markdown"
|
||||
p2 = part_from_dict(d)
|
||||
assert p2.media_type == "text/markdown"
|
||||
|
||||
|
||||
class TestFilePart:
|
||||
def test_inline_roundtrip(self):
|
||||
p = FilePart(media_type="image/png", raw="base64data", filename="img.png")
|
||||
d = p.to_dict()
|
||||
assert d["raw"] == "base64data"
|
||||
assert d["filename"] == "img.png"
|
||||
p2 = part_from_dict(d)
|
||||
assert isinstance(p2, FilePart)
|
||||
assert p2.raw == "base64data"
|
||||
|
||||
def test_url_roundtrip(self):
|
||||
p = FilePart(media_type="application/pdf", url="https://example.com/doc.pdf")
|
||||
d = p.to_dict()
|
||||
assert d["url"] == "https://example.com/doc.pdf"
|
||||
p2 = part_from_dict(d)
|
||||
assert isinstance(p2, FilePart)
|
||||
assert p2.url == "https://example.com/doc.pdf"
|
||||
|
||||
|
||||
class TestDataPart:
|
||||
def test_roundtrip(self):
|
||||
p = DataPart(data={"key": "value", "count": 42})
|
||||
d = p.to_dict()
|
||||
assert d["data"] == {"key": "value", "count": 42}
|
||||
p2 = part_from_dict(d)
|
||||
assert isinstance(p2, DataPart)
|
||||
assert p2.data["count"] == 42
|
||||
|
||||
|
||||
class TestMessage:
|
||||
def test_roundtrip(self):
|
||||
msg = Message(
|
||||
role=Role.USER,
|
||||
parts=[TextPart(text="Hello agent")],
|
||||
metadata={"priority": "high"},
|
||||
)
|
||||
d = msg.to_dict()
|
||||
assert d["role"] == "ROLE_USER"
|
||||
assert d["parts"] == [{"text": "Hello agent"}]
|
||||
assert d["metadata"]["priority"] == "high"
|
||||
|
||||
msg2 = Message.from_dict(d)
|
||||
assert msg2.role == Role.USER
|
||||
assert isinstance(msg2.parts[0], TextPart)
|
||||
assert msg2.parts[0].text == "Hello agent"
|
||||
assert msg2.metadata["priority"] == "high"
|
||||
|
||||
def test_multi_part(self):
|
||||
msg = Message(
|
||||
role=Role.AGENT,
|
||||
parts=[
|
||||
TextPart(text="Here's the report"),
|
||||
DataPart(data={"status": "healthy"}),
|
||||
],
|
||||
)
|
||||
d = msg.to_dict()
|
||||
assert len(d["parts"]) == 2
|
||||
msg2 = Message.from_dict(d)
|
||||
assert len(msg2.parts) == 2
|
||||
assert isinstance(msg2.parts[0], TextPart)
|
||||
assert isinstance(msg2.parts[1], DataPart)
|
||||
|
||||
|
||||
class TestArtifact:
|
||||
def test_roundtrip(self):
|
||||
art = Artifact(
|
||||
parts=[TextPart(text="result data")],
|
||||
name="report",
|
||||
description="CI health report",
|
||||
)
|
||||
d = art.to_dict()
|
||||
assert d["name"] == "report"
|
||||
assert d["description"] == "CI health report"
|
||||
|
||||
art2 = Artifact.from_dict(d)
|
||||
assert art2.name == "report"
|
||||
assert isinstance(art2.parts[0], TextPart)
|
||||
assert art2.parts[0].text == "result data"
|
||||
|
||||
|
||||
class TestTask:
|
||||
def test_roundtrip(self):
|
||||
task = Task(
|
||||
id="test-123",
|
||||
status=TaskStatus(state=TaskState.WORKING),
|
||||
history=[
|
||||
Message(role=Role.USER, parts=[TextPart(text="Do X")]),
|
||||
],
|
||||
)
|
||||
d = task.to_dict()
|
||||
assert d["id"] == "test-123"
|
||||
assert d["status"]["state"] == "TASK_STATE_WORKING"
|
||||
|
||||
task2 = Task.from_dict(d)
|
||||
assert task2.id == "test-123"
|
||||
assert task2.status.state == TaskState.WORKING
|
||||
assert len(task2.history) == 1
|
||||
|
||||
def test_with_artifacts(self):
|
||||
task = Task(
|
||||
id="art-task",
|
||||
status=TaskStatus(state=TaskState.COMPLETED),
|
||||
artifacts=[
|
||||
Artifact(
|
||||
parts=[TextPart(text="42")],
|
||||
name="answer",
|
||||
)
|
||||
],
|
||||
)
|
||||
d = task.to_dict()
|
||||
assert len(d["artifacts"]) == 1
|
||||
task2 = Task.from_dict(d)
|
||||
assert task2.artifacts[0].name == "answer"
|
||||
|
||||
def test_terminal_states(self):
|
||||
for state in [
|
||||
TaskState.COMPLETED,
|
||||
TaskState.FAILED,
|
||||
TaskState.CANCELED,
|
||||
TaskState.REJECTED,
|
||||
]:
|
||||
assert state.terminal is True
|
||||
|
||||
for state in [
|
||||
TaskState.SUBMITTED,
|
||||
TaskState.WORKING,
|
||||
TaskState.INPUT_REQUIRED,
|
||||
TaskState.AUTH_REQUIRED,
|
||||
]:
|
||||
assert state.terminal is False
|
||||
|
||||
|
||||
class TestAgentCard:
|
||||
def test_roundtrip(self):
|
||||
card = AgentCard(
|
||||
name="TestAgent",
|
||||
description="A test agent",
|
||||
version="1.0.0",
|
||||
supported_interfaces=[
|
||||
AgentInterface(url="http://localhost:8080/a2a/v1")
|
||||
],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="test-skill",
|
||||
name="Test Skill",
|
||||
description="Does tests",
|
||||
tags=["test"],
|
||||
)
|
||||
],
|
||||
)
|
||||
d = card.to_dict()
|
||||
assert d["name"] == "TestAgent"
|
||||
assert d["capabilities"]["streaming"] is True
|
||||
assert len(d["skills"]) == 1
|
||||
assert d["skills"][0]["id"] == "test-skill"
|
||||
|
||||
card2 = AgentCard.from_dict(d)
|
||||
assert card2.name == "TestAgent"
|
||||
assert card2.skills[0].id == "test-skill"
|
||||
assert card2.capabilities.streaming is True
|
||||
|
||||
|
||||
class TestJSONRPC:
|
||||
def test_request_roundtrip(self):
|
||||
req = JSONRPCRequest(
|
||||
method="SendMessage",
|
||||
params={"message": {"text": "hello"}},
|
||||
)
|
||||
d = req.to_dict()
|
||||
assert d["jsonrpc"] == "2.0"
|
||||
assert d["method"] == "SendMessage"
|
||||
|
||||
def test_response_success(self):
|
||||
resp = JSONRPCResponse(
|
||||
id="req-1",
|
||||
result={"task": {"id": "t1"}},
|
||||
)
|
||||
d = resp.to_dict()
|
||||
assert "error" not in d
|
||||
assert d["result"]["task"]["id"] == "t1"
|
||||
|
||||
def test_response_error(self):
|
||||
resp = JSONRPCResponse(
|
||||
id="req-1",
|
||||
error=A2AError.TASK_NOT_FOUND,
|
||||
)
|
||||
d = resp.to_dict()
|
||||
assert "result" not in d
|
||||
assert d["error"]["code"] == -32001
|
||||
|
||||
|
||||
# === Agent Card Building ===
|
||||
|
||||
|
||||
class TestBuildCard:
|
||||
def test_basic_config(self):
|
||||
config = {
|
||||
"name": "Bezalel",
|
||||
"description": "CI/CD specialist",
|
||||
"version": "2.0.0",
|
||||
"url": "https://bezalel.example.com",
|
||||
"skills": [
|
||||
{
|
||||
"id": "ci-health",
|
||||
"name": "CI Health",
|
||||
"description": "Check CI",
|
||||
"tags": ["ci"],
|
||||
},
|
||||
{
|
||||
"id": "deploy",
|
||||
"name": "Deploy",
|
||||
"description": "Deploy services",
|
||||
"tags": ["ops"],
|
||||
},
|
||||
],
|
||||
}
|
||||
card = build_card(config)
|
||||
assert card.name == "Bezalel"
|
||||
assert card.version == "2.0.0"
|
||||
assert len(card.skills) == 2
|
||||
assert card.skills[0].id == "ci-health"
|
||||
assert card.supported_interfaces[0].url == "https://bezalel.example.com"
|
||||
|
||||
def test_bearer_auth(self):
|
||||
config = {
|
||||
"name": "Test",
|
||||
"description": "Test",
|
||||
"auth": {"scheme": "bearer", "token_env": "MY_TOKEN"},
|
||||
}
|
||||
card = build_card(config)
|
||||
assert "bearerAuth" in card.security_schemes
|
||||
assert card.security_requirements[0]["schemes"]["bearerAuth"] == {"list": []}
|
||||
|
||||
def test_api_key_auth(self):
|
||||
config = {
|
||||
"name": "Test",
|
||||
"description": "Test",
|
||||
"auth": {"scheme": "api_key", "key_name": "X-Custom-Key"},
|
||||
}
|
||||
card = build_card(config)
|
||||
assert "apiKeyAuth" in card.security_schemes
|
||||
|
||||
|
||||
# === Registry ===
|
||||
|
||||
|
||||
class TestLocalFileRegistry:
|
||||
def _make_card(self, name: str, skills: list[dict] | None = None) -> AgentCard:
|
||||
return AgentCard(
|
||||
name=name,
|
||||
description=f"Agent {name}",
|
||||
supported_interfaces=[
|
||||
AgentInterface(url=f"http://{name}:8080/a2a/v1")
|
||||
],
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id=s["id"],
|
||||
name=s.get("name", s["id"]),
|
||||
description=s.get("description", ""),
|
||||
tags=s.get("tags", []),
|
||||
)
|
||||
for s in (skills or [])
|
||||
],
|
||||
)
|
||||
|
||||
def test_register_and_list(self, tmp_path):
|
||||
registry = LocalFileRegistry(tmp_path / "agents.json")
|
||||
registry.register(self._make_card("ezra"))
|
||||
registry.register(self._make_card("allegro"))
|
||||
|
||||
agents = registry.list_agents()
|
||||
assert len(agents) == 2
|
||||
names = {a.name for a in agents}
|
||||
assert names == {"ezra", "allegro"}
|
||||
|
||||
def test_filter_by_skill(self, tmp_path):
|
||||
registry = LocalFileRegistry(tmp_path / "agents.json")
|
||||
registry.register(
|
||||
self._make_card("ezra", [{"id": "ci-health", "tags": ["ci"]}])
|
||||
)
|
||||
registry.register(
|
||||
self._make_card("allegro", [{"id": "research", "tags": ["research"]}])
|
||||
)
|
||||
|
||||
ci_agents = registry.list_agents(skill="ci-health")
|
||||
assert len(ci_agents) == 1
|
||||
assert ci_agents[0].name == "ezra"
|
||||
|
||||
def test_filter_by_tag(self, tmp_path):
|
||||
registry = LocalFileRegistry(tmp_path / "agents.json")
|
||||
registry.register(
|
||||
self._make_card("ezra", [{"id": "ci", "tags": ["devops", "ci"]}])
|
||||
)
|
||||
registry.register(
|
||||
self._make_card("allegro", [{"id": "research", "tags": ["research"]}])
|
||||
)
|
||||
|
||||
devops_agents = registry.list_agents(tag="devops")
|
||||
assert len(devops_agents) == 1
|
||||
assert devops_agents[0].name == "ezra"
|
||||
|
||||
def test_persistence(self, tmp_path):
|
||||
path = tmp_path / "agents.json"
|
||||
reg1 = LocalFileRegistry(path)
|
||||
reg1.register(self._make_card("ezra"))
|
||||
|
||||
# Load fresh from disk
|
||||
reg2 = LocalFileRegistry(path)
|
||||
agents = reg2.list_agents()
|
||||
assert len(agents) == 1
|
||||
assert agents[0].name == "ezra"
|
||||
|
||||
def test_unregister(self, tmp_path):
|
||||
registry = LocalFileRegistry(tmp_path / "agents.json")
|
||||
registry.register(self._make_card("ezra"))
|
||||
assert len(registry.list_agents()) == 1
|
||||
|
||||
assert registry.unregister("ezra") is True
|
||||
assert len(registry.list_agents()) == 0
|
||||
assert registry.unregister("nonexistent") is False
|
||||
|
||||
def test_get_endpoint(self, tmp_path):
|
||||
registry = LocalFileRegistry(tmp_path / "agents.json")
|
||||
registry.register(self._make_card("ezra"))
|
||||
|
||||
url = registry.get_endpoint("ezra")
|
||||
assert url == "http://ezra:8080/a2a/v1"
|
||||
|
||||
|
||||
# === Server Integration (FastAPI required) ===
|
||||
|
||||
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
HAS_TEST_CLIENT = True
|
||||
except ImportError:
|
||||
HAS_TEST_CLIENT = False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_TEST_CLIENT, reason="fastapi not installed")
|
||||
class TestA2AServerIntegration:
|
||||
"""End-to-end tests using FastAPI TestClient."""
|
||||
|
||||
def _make_server(self, auth_token: str = ""):
|
||||
from nexus.a2a.server import A2AServer, echo_handler
|
||||
|
||||
card = AgentCard(
|
||||
name="TestAgent",
|
||||
description="Test agent for A2A",
|
||||
supported_interfaces=[
|
||||
AgentInterface(url="http://localhost:8080/a2a/v1")
|
||||
],
|
||||
capabilities=AgentCapabilities(streaming=False),
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="echo",
|
||||
name="Echo",
|
||||
description="Echo back messages",
|
||||
tags=["test"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
server = A2AServer(card=card, auth_token=auth_token)
|
||||
server.register_handler("echo", echo_handler)
|
||||
server.set_default_handler(echo_handler)
|
||||
return server
|
||||
|
||||
def test_agent_card_well_known(self):
|
||||
server = self._make_server()
|
||||
client = TestClient(server.app)
|
||||
|
||||
resp = client.get("/.well-known/agent-card.json")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "TestAgent"
|
||||
assert len(data["skills"]) == 1
|
||||
|
||||
def test_agent_card_fallback(self):
|
||||
server = self._make_server()
|
||||
client = TestClient(server.app)
|
||||
|
||||
resp = client.get("/agent.json")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "TestAgent"
|
||||
|
||||
def test_send_message(self):
|
||||
server = self._make_server()
|
||||
client = TestClient(server.app)
|
||||
|
||||
rpc_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "test-1",
|
||||
"method": "SendMessage",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-1",
|
||||
"role": "ROLE_USER",
|
||||
"parts": [{"text": "Hello from test"}],
|
||||
},
|
||||
"configuration": {
|
||||
"acceptedOutputModes": ["text/plain"],
|
||||
"historyLength": 10,
|
||||
"returnImmediately": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp = client.post("/a2a/v1", json=rpc_request)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "result" in data
|
||||
assert "task" in data["result"]
|
||||
|
||||
task = data["result"]["task"]
|
||||
assert task["status"]["state"] == "TASK_STATE_COMPLETED"
|
||||
assert len(task["artifacts"]) == 1
|
||||
assert "Echo" in task["artifacts"][0]["parts"][0]["text"]
|
||||
|
||||
def test_get_task(self):
|
||||
server = self._make_server()
|
||||
client = TestClient(server.app)
|
||||
|
||||
# Create a task first
|
||||
send_req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "s1",
|
||||
"method": "SendMessage",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "m1",
|
||||
"role": "ROLE_USER",
|
||||
"parts": [{"text": "get me"}],
|
||||
},
|
||||
"configuration": {},
|
||||
},
|
||||
}
|
||||
send_resp = client.post("/a2a/v1", json=send_req)
|
||||
task_id = send_resp.json()["result"]["task"]["id"]
|
||||
|
||||
# Now fetch it
|
||||
get_req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "g1",
|
||||
"method": "GetTask",
|
||||
"params": {"id": task_id},
|
||||
}
|
||||
get_resp = client.post("/a2a/v1", json=get_req)
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["result"]["id"] == task_id
|
||||
|
||||
def test_get_nonexistent_task(self):
|
||||
server = self._make_server()
|
||||
client = TestClient(server.app)
|
||||
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "g2",
|
||||
"method": "GetTask",
|
||||
"params": {"id": "nonexistent"},
|
||||
}
|
||||
resp = client.post("/a2a/v1", json=req)
|
||||
assert resp.status_code == 400
|
||||
data = resp.json()
|
||||
assert "error" in data
|
||||
|
||||
def test_list_tasks(self):
|
||||
server = self._make_server()
|
||||
client = TestClient(server.app)
|
||||
|
||||
# Create two tasks
|
||||
for i in range(2):
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": f"s{i}",
|
||||
"method": "SendMessage",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": f"m{i}",
|
||||
"role": "ROLE_USER",
|
||||
"parts": [{"text": f"task {i}"}],
|
||||
},
|
||||
"configuration": {},
|
||||
},
|
||||
}
|
||||
client.post("/a2a/v1", json=req)
|
||||
|
||||
list_req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "l1",
|
||||
"method": "ListTasks",
|
||||
"params": {"pageSize": 10},
|
||||
}
|
||||
resp = client.post("/a2a/v1", json=list_req)
|
||||
assert resp.status_code == 200
|
||||
tasks = resp.json()["result"]["tasks"]
|
||||
assert len(tasks) >= 2
|
||||
|
||||
def test_cancel_task(self):
|
||||
from nexus.a2a.server import A2AServer
|
||||
|
||||
# Create a server with a slow handler so task stays WORKING
|
||||
async def slow_handler(task, card):
|
||||
import asyncio
|
||||
await asyncio.sleep(10) # never reached in test
|
||||
task.status = TaskStatus(state=TaskState.COMPLETED)
|
||||
return task
|
||||
|
||||
card = AgentCard(name="SlowAgent", description="Slow test agent")
|
||||
server = A2AServer(card=card)
|
||||
server.set_default_handler(slow_handler)
|
||||
client = TestClient(server.app)
|
||||
|
||||
# Create a task (but we need to intercept before handler runs)
|
||||
# Instead, manually insert a task and test cancel on it
|
||||
task = Task(
|
||||
id="cancel-me",
|
||||
status=TaskStatus(state=TaskState.WORKING),
|
||||
history=[
|
||||
Message(role=Role.USER, parts=[TextPart(text="cancel me")])
|
||||
],
|
||||
)
|
||||
server._tasks[task.id] = task
|
||||
|
||||
# Cancel it
|
||||
cancel_req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "c2",
|
||||
"method": "CancelTask",
|
||||
"params": {"id": "cancel-me"},
|
||||
}
|
||||
cancel_resp = client.post("/a2a/v1", json=cancel_req)
|
||||
assert cancel_resp.status_code == 200
|
||||
assert cancel_resp.json()["result"]["status"]["state"] == "TASK_STATE_CANCELED"
|
||||
|
||||
def test_auth_required(self):
|
||||
server = self._make_server(auth_token="secret123")
|
||||
client = TestClient(server.app)
|
||||
|
||||
# No auth header — should get 401
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "a1",
|
||||
"method": "SendMessage",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "am1",
|
||||
"role": "ROLE_USER",
|
||||
"parts": [{"text": "hello"}],
|
||||
},
|
||||
"configuration": {},
|
||||
},
|
||||
}
|
||||
resp = client.post("/a2a/v1", json=req)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_auth_success(self):
|
||||
server = self._make_server(auth_token="secret123")
|
||||
client = TestClient(server.app)
|
||||
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "a2",
|
||||
"method": "SendMessage",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "am2",
|
||||
"role": "ROLE_USER",
|
||||
"parts": [{"text": "authenticated"}],
|
||||
},
|
||||
"configuration": {},
|
||||
},
|
||||
}
|
||||
resp = client.post(
|
||||
"/a2a/v1",
|
||||
json=req,
|
||||
headers={"Authorization": "Bearer secret123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED"
|
||||
|
||||
def test_unknown_method(self):
|
||||
server = self._make_server()
|
||||
client = TestClient(server.app)
|
||||
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "u1",
|
||||
"method": "NonExistentMethod",
|
||||
"params": {},
|
||||
}
|
||||
resp = client.post("/a2a/v1", json=req)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == -32602
|
||||
|
||||
def test_audit_log(self):
|
||||
server = self._make_server()
|
||||
client = TestClient(server.app)
|
||||
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "au1",
|
||||
"method": "SendMessage",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "aum1",
|
||||
"role": "ROLE_USER",
|
||||
"parts": [{"text": "audit me"}],
|
||||
},
|
||||
"configuration": {},
|
||||
},
|
||||
}
|
||||
client.post("/a2a/v1", json=req)
|
||||
client.post("/a2a/v1", json=req)
|
||||
|
||||
log = server.get_audit_log()
|
||||
assert len(log) == 2
|
||||
assert all(entry["method"] == "SendMessage" for entry in log)
|
||||
|
||||
|
||||
# === Custom Handler Test ===
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_TEST_CLIENT, reason="fastapi not installed")
|
||||
class TestCustomHandlers:
|
||||
"""Test custom task handlers."""
|
||||
|
||||
def test_skill_routing(self):
|
||||
from nexus.a2a.server import A2AServer
|
||||
from nexus.a2a.types import Task, AgentCard
|
||||
|
||||
async def ci_handler(task: Task, card: AgentCard) -> Task:
|
||||
task.artifacts.append(
|
||||
Artifact(
|
||||
parts=[TextPart(text="CI pipeline healthy: 5/5 passed")],
|
||||
name="ci_report",
|
||||
)
|
||||
)
|
||||
task.status = TaskStatus(state=TaskState.COMPLETED)
|
||||
return task
|
||||
|
||||
card = AgentCard(
|
||||
name="CI Agent",
|
||||
description="CI specialist",
|
||||
skills=[AgentSkill(id="ci-health", name="CI Health", description="Check CI", tags=["ci"])],
|
||||
)
|
||||
server = A2AServer(card=card)
|
||||
server.register_handler("ci-health", ci_handler)
|
||||
|
||||
client = TestClient(server.app)
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "h1",
|
||||
"method": "SendMessage",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "hm1",
|
||||
"role": "ROLE_USER",
|
||||
"parts": [{"text": "Check CI"}],
|
||||
"metadata": {"targetSkill": "ci-health"},
|
||||
},
|
||||
"configuration": {},
|
||||
},
|
||||
}
|
||||
resp = client.post("/a2a/v1", json=req)
|
||||
task_data = resp.json()["result"]["task"]
|
||||
assert task_data["status"]["state"] == "TASK_STATE_COMPLETED"
|
||||
assert "5/5 passed" in task_data["artifacts"][0]["parts"][0]["text"]
|
||||
|
||||
def test_handler_error(self):
|
||||
from nexus.a2a.server import A2AServer
|
||||
from nexus.a2a.types import Task, AgentCard
|
||||
|
||||
async def failing_handler(task: Task, card: AgentCard) -> Task:
|
||||
raise RuntimeError("Handler blew up")
|
||||
|
||||
card = AgentCard(name="Fail Agent", description="Fails")
|
||||
server = A2AServer(card=card)
|
||||
server.set_default_handler(failing_handler)
|
||||
|
||||
client = TestClient(server.app)
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "f1",
|
||||
"method": "SendMessage",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "fm1",
|
||||
"role": "ROLE_USER",
|
||||
"parts": [{"text": "break"}],
|
||||
},
|
||||
"configuration": {},
|
||||
},
|
||||
}
|
||||
resp = client.post("/a2a/v1", json=req)
|
||||
task_data = resp.json()["result"]["task"]
|
||||
assert task_data["status"]["state"] == "TASK_STATE_FAILED"
|
||||
assert "blew up" in task_data["status"]["message"]["parts"][0]["text"].lower()
|
||||
@@ -1,396 +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 nexus.mempalace.conversation_artifacts import ConversationArtifact
|
||||
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
|
||||
|
||||
def test_remember_alexander_request_response_uses_sovereign_room(self):
|
||||
mem = AgentMemory(agent_name="allegro")
|
||||
mem._available = True
|
||||
with patch("nexus.mempalace.searcher.add_memory", return_value="doc-123") as add_memory:
|
||||
doc_id = mem.remember_alexander_request_response(
|
||||
request_text="Catalog my requests.",
|
||||
response_text="I will preserve them as artifacts.",
|
||||
requester="Alexander Whitestone",
|
||||
source="telegram:timmy-time",
|
||||
)
|
||||
|
||||
assert doc_id == "doc-123"
|
||||
kwargs = add_memory.call_args.kwargs
|
||||
assert kwargs["room"] == "sovereign"
|
||||
assert kwargs["wing"] == mem.wing
|
||||
assert kwargs["extra_metadata"]["artifact_type"] == "alexander_request_response"
|
||||
assert kwargs["extra_metadata"]["speaker_tags"] == ["speaker:alexander", "speaker:allegro"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
@@ -1,211 +0,0 @@
|
||||
"""
|
||||
Tests for nexus.chronicle — emergent narrative from agent interactions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nexus.chronicle import (
|
||||
AgentEvent,
|
||||
ChronicleWriter,
|
||||
EventKind,
|
||||
event_from_commit,
|
||||
event_from_gitea_issue,
|
||||
event_from_heartbeat,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentEvent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentEvent:
|
||||
def test_roundtrip(self):
|
||||
evt = AgentEvent(
|
||||
kind=EventKind.DISPATCH,
|
||||
agent="claude",
|
||||
detail="took issue #42",
|
||||
)
|
||||
assert AgentEvent.from_dict(evt.to_dict()).kind == EventKind.DISPATCH
|
||||
assert AgentEvent.from_dict(evt.to_dict()).agent == "claude"
|
||||
assert AgentEvent.from_dict(evt.to_dict()).detail == "took issue #42"
|
||||
|
||||
def test_default_timestamp_is_recent(self):
|
||||
before = time.time()
|
||||
evt = AgentEvent(kind=EventKind.IDLE, agent="mimo")
|
||||
after = time.time()
|
||||
assert before <= evt.timestamp <= after
|
||||
|
||||
def test_all_event_kinds_are_valid_strings(self):
|
||||
for kind in EventKind:
|
||||
evt = AgentEvent(kind=kind, agent="test-agent")
|
||||
d = evt.to_dict()
|
||||
assert d["kind"] == kind.value
|
||||
restored = AgentEvent.from_dict(d)
|
||||
assert restored.kind == kind
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChronicleWriter — basic ingestion and render
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestChronicleWriter:
|
||||
@pytest.fixture
|
||||
def writer(self, tmp_path):
|
||||
return ChronicleWriter(log_path=tmp_path / "chronicle.jsonl")
|
||||
|
||||
def test_empty_render(self, writer):
|
||||
text = writer.render()
|
||||
assert "empty" in text.lower()
|
||||
|
||||
def test_single_event_render(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="issue #1"))
|
||||
text = writer.render()
|
||||
assert "claude" in text
|
||||
assert "issue #1" in text
|
||||
|
||||
def test_render_covers_timestamps(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="a", detail="start"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="a", detail="done"))
|
||||
text = writer.render()
|
||||
assert "chronicle covers" in text.lower()
|
||||
|
||||
def test_events_persisted_to_disk(self, writer, tmp_path):
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="feat: x"))
|
||||
lines = (tmp_path / "chronicle.jsonl").read_text().strip().splitlines()
|
||||
assert len(lines) == 1
|
||||
data = json.loads(lines[0])
|
||||
assert data["kind"] == "commit"
|
||||
assert data["agent"] == "claude"
|
||||
|
||||
def test_load_existing_on_init(self, tmp_path):
|
||||
log = tmp_path / "chronicle.jsonl"
|
||||
evt = AgentEvent(kind=EventKind.PUSH, agent="mimo", detail="pushed branch")
|
||||
log.write_text(json.dumps(evt.to_dict()) + "\n")
|
||||
|
||||
writer2 = ChronicleWriter(log_path=log)
|
||||
assert len(writer2._events) == 1
|
||||
assert writer2._events[0].kind == EventKind.PUSH
|
||||
|
||||
def test_malformed_lines_are_skipped(self, tmp_path):
|
||||
log = tmp_path / "chronicle.jsonl"
|
||||
log.write_text("not-json\n{}\n")
|
||||
# Should not raise
|
||||
writer2 = ChronicleWriter(log_path=log)
|
||||
assert writer2._events == []
|
||||
|
||||
def test_template_rotation(self, writer):
|
||||
"""Consecutive events of the same kind use different templates."""
|
||||
sentences = set()
|
||||
for _ in range(3):
|
||||
writer.ingest(AgentEvent(kind=EventKind.HEARTBEAT, agent="claude"))
|
||||
text = writer.render()
|
||||
# At least one of the template variants should appear
|
||||
assert "pulse" in text or "breathed" in text or "checked in" in text
|
||||
|
||||
def test_render_markdown(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.PR_OPEN, agent="claude", detail="PR #99"))
|
||||
md = writer.render_markdown()
|
||||
assert md.startswith("# Chronicle")
|
||||
assert "PR #99" in md
|
||||
|
||||
def test_summary(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="x"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="y"))
|
||||
s = writer.summary()
|
||||
assert s["total_events"] == 2
|
||||
assert "claude" in s["agents"]
|
||||
assert s["kind_counts"]["dispatch"] == 1
|
||||
assert s["kind_counts"]["commit"] == 1
|
||||
|
||||
def test_max_events_limit(self, writer):
|
||||
for i in range(10):
|
||||
writer.ingest(AgentEvent(kind=EventKind.IDLE, agent="a", detail=str(i)))
|
||||
text = writer.render(max_events=3)
|
||||
# Only 3 events should appear in prose — check coverage header
|
||||
assert "3 event(s)" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arc detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestArcDetection:
|
||||
@pytest.fixture
|
||||
def writer(self, tmp_path):
|
||||
return ChronicleWriter(log_path=tmp_path / "chronicle.jsonl")
|
||||
|
||||
def _ingest(self, writer, *kinds, agent="claude"):
|
||||
for k in kinds:
|
||||
writer.ingest(AgentEvent(kind=k, agent=agent, detail="x"))
|
||||
|
||||
def test_struggle_and_recovery_arc(self, writer):
|
||||
self._ingest(writer, EventKind.DISPATCH, EventKind.ERROR, EventKind.RECOVERY)
|
||||
text = writer.render()
|
||||
assert "struggle" in text.lower() or "trouble" in text.lower()
|
||||
|
||||
def test_no_arc_when_no_pattern(self, writer):
|
||||
self._ingest(writer, EventKind.IDLE)
|
||||
text = writer.render()
|
||||
# Should not include arc language (only 1 event, no pattern)
|
||||
assert "converged" not in text
|
||||
assert "struggle" not in text
|
||||
|
||||
def test_solo_sprint_arc(self, writer):
|
||||
self._ingest(
|
||||
writer,
|
||||
EventKind.DISPATCH,
|
||||
EventKind.COMMIT,
|
||||
EventKind.PR_OPEN,
|
||||
EventKind.PR_MERGE,
|
||||
)
|
||||
text = writer.render()
|
||||
assert "solo" in text.lower() or "alone" in text.lower()
|
||||
|
||||
def test_fleet_convergence_arc(self, writer, tmp_path):
|
||||
writer2 = ChronicleWriter(log_path=tmp_path / "chronicle.jsonl")
|
||||
writer2.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="x"))
|
||||
writer2.ingest(AgentEvent(kind=EventKind.COLLABORATION, agent="mimo", detail="x"))
|
||||
writer2.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="x"))
|
||||
text = writer2.render()
|
||||
assert "converged" in text.lower() or "fleet" in text.lower()
|
||||
|
||||
def test_silent_grind_arc(self, writer):
|
||||
self._ingest(writer, EventKind.COMMIT, EventKind.COMMIT, EventKind.COMMIT)
|
||||
text = writer.render()
|
||||
assert "steady" in text.lower() or "quiet" in text.lower() or "grind" in text.lower()
|
||||
|
||||
def test_abandon_then_retry_arc(self, writer):
|
||||
self._ingest(writer, EventKind.DISPATCH, EventKind.ABANDON, EventKind.DISPATCH)
|
||||
text = writer.render()
|
||||
assert "let go" in text.lower() or "abandon" in text.lower() or "called again" in text.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience constructors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConvenienceConstructors:
|
||||
def test_event_from_gitea_issue(self):
|
||||
payload = {"number": 42, "title": "feat: add narrative engine"}
|
||||
evt = event_from_gitea_issue(payload, agent="claude")
|
||||
assert evt.kind == EventKind.DISPATCH
|
||||
assert "42" in evt.detail
|
||||
assert evt.agent == "claude"
|
||||
|
||||
def test_event_from_heartbeat(self):
|
||||
hb = {"model": "claude-sonnet", "status": "thinking", "cycle": 7}
|
||||
evt = event_from_heartbeat(hb)
|
||||
assert evt.kind == EventKind.HEARTBEAT
|
||||
assert evt.agent == "claude-sonnet"
|
||||
assert "7" in evt.detail
|
||||
|
||||
def test_event_from_commit(self):
|
||||
commit = {"message": "feat: chronicle\n\nFixes #1607", "sha": "abc1234567"}
|
||||
evt = event_from_commit(commit, agent="claude")
|
||||
assert evt.kind == EventKind.COMMIT
|
||||
assert evt.detail == "feat: chronicle" # subject line only
|
||||
assert evt.metadata["sha"] == "abc12345"
|
||||
@@ -1,58 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from nexus.mempalace.config import CORE_ROOMS
|
||||
from nexus.mempalace.conversation_artifacts import (
|
||||
ConversationArtifact,
|
||||
build_request_response_artifact,
|
||||
extract_alexander_request_pairs,
|
||||
normalize_speaker,
|
||||
)
|
||||
|
||||
|
||||
def test_sovereign_room_is_core_room() -> None:
|
||||
assert "sovereign" in CORE_ROOMS
|
||||
rooms_yaml = yaml.safe_load(Path("mempalace/rooms.yaml").read_text())
|
||||
assert any(room["key"] == "sovereign" for room in rooms_yaml["core_rooms"])
|
||||
|
||||
|
||||
def test_normalize_speaker_maps_alexander_variants() -> None:
|
||||
assert normalize_speaker("Alexander Whitestone") == "alexander"
|
||||
assert normalize_speaker("Rockachopa") == "alexander"
|
||||
assert normalize_speaker(" ALEXANDER ") == "alexander"
|
||||
assert normalize_speaker("Bezalel") == "bezalel"
|
||||
|
||||
|
||||
def test_build_request_response_artifact_creates_sovereign_metadata() -> None:
|
||||
artifact = build_request_response_artifact(
|
||||
requester="Alexander Whitestone",
|
||||
responder="Allegro",
|
||||
request_text="Please organize my conversation artifacts.",
|
||||
response_text="I will catalog them under a sovereign room.",
|
||||
source="telegram:timmy-time",
|
||||
timestamp="2026-04-16T01:30:00Z",
|
||||
)
|
||||
|
||||
assert isinstance(artifact, ConversationArtifact)
|
||||
assert artifact.room == "sovereign"
|
||||
assert artifact.metadata["speaker_tags"] == ["speaker:alexander", "speaker:allegro"]
|
||||
assert artifact.metadata["artifact_type"] == "alexander_request_response"
|
||||
assert artifact.metadata["responder"] == "allegro"
|
||||
assert "## Alexander Request" in artifact.text
|
||||
assert "## Wizard Response" in artifact.text
|
||||
|
||||
|
||||
def test_extract_alexander_request_pairs_finds_following_wizard_response() -> None:
|
||||
turns = [
|
||||
{"speaker": "Alexander Whitestone", "text": "Catalog my requests as artifacts.", "timestamp": "2026-04-16T01:00:00Z"},
|
||||
{"speaker": "Allegro", "text": "I'll build a sovereign room contract.", "timestamp": "2026-04-16T01:01:00Z"},
|
||||
{"speaker": "Alexander", "text": "Make sure my words are easy to recall.", "timestamp": "2026-04-16T01:02:00Z"},
|
||||
{"speaker": "Allegro", "text": "I will tag them with speaker metadata.", "timestamp": "2026-04-16T01:03:00Z"},
|
||||
]
|
||||
|
||||
artifacts = extract_alexander_request_pairs(turns, responder="Allegro", source="telegram")
|
||||
|
||||
assert len(artifacts) == 2
|
||||
assert artifacts[0].metadata["request_timestamp"] == "2026-04-16T01:00:00Z"
|
||||
assert artifacts[1].metadata["response_timestamp"] == "2026-04-16T01:03:00Z"
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Tests for fleet_audit — Deduplicate Agents, One Identity Per Machine."""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
# Adjust import path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "bin"))
|
||||
|
||||
from fleet_audit import (
|
||||
AuditFinding,
|
||||
validate_registry,
|
||||
cross_reference_registry_agents,
|
||||
audit_git_authors,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity registry validation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidateRegistry:
|
||||
"""Test identity registry validation rules."""
|
||||
|
||||
def _make_registry(self, agents):
|
||||
return {"version": 1, "agents": agents, "rules": {"one_identity_per_machine": True}}
|
||||
|
||||
def test_clean_registry_passes(self):
|
||||
registry = self._make_registry([
|
||||
{"name": "allegro", "machine": "167.99.126.228", "role": "burn", "gitea_user": "allegro"},
|
||||
{"name": "ezra", "machine": "143.198.27.163", "role": "triage", "gitea_user": "ezra"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
critical = [f for f in findings if f.severity == "critical"]
|
||||
assert len(critical) == 0
|
||||
|
||||
def test_same_name_on_different_machines_detected(self):
|
||||
registry = self._make_registry([
|
||||
{"name": "allegro", "machine": "167.99.126.228", "role": "burn"},
|
||||
{"name": "allegro", "machine": "104.131.15.18", "role": "burn"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
critical = [f for f in findings if f.severity == "critical" and f.category == "duplicate"]
|
||||
# Two findings: one for name-on-multiple-machines, one for duplicate name
|
||||
assert len(critical) >= 1
|
||||
machine_findings = [f for f in critical if "registered on" in f.description]
|
||||
assert len(machine_findings) == 1
|
||||
assert "167.99.126.228" in machine_findings[0].description
|
||||
assert "104.131.15.18" in machine_findings[0].description
|
||||
|
||||
def test_multiple_agents_same_machine_ok(self):
|
||||
# Multiple different agents on the same VPS is normal.
|
||||
registry = self._make_registry([
|
||||
{"name": "allegro", "machine": "167.99.126.228", "role": "burn"},
|
||||
{"name": "bilbo", "machine": "167.99.126.228", "role": "queries"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
critical = [f for f in findings if f.severity == "critical"]
|
||||
assert len(critical) == 0
|
||||
|
||||
def test_duplicate_name_detected(self):
|
||||
registry = self._make_registry([
|
||||
{"name": "bezalel", "machine": "104.131.15.18", "role": "ci"},
|
||||
{"name": "bezalel", "machine": "167.99.126.228", "role": "ci"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
name_dupes = [f for f in findings if f.severity == "critical" and "bezalel" in f.description.lower() and "registered on" in f.description.lower()]
|
||||
assert len(name_dupes) == 1
|
||||
|
||||
def test_duplicate_gitea_user_detected(self):
|
||||
registry = self._make_registry([
|
||||
{"name": "agent-a", "machine": "host1", "role": "x", "gitea_user": "shared"},
|
||||
{"name": "agent-b", "machine": "host2", "role": "x", "gitea_user": "shared"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
gitea_dupes = [f for f in findings if "Gitea user 'shared'" in f.description]
|
||||
assert len(gitea_dupes) == 1
|
||||
assert "agent-a" in gitea_dupes[0].affected
|
||||
assert "agent-b" in gitea_dupes[0].affected
|
||||
|
||||
def test_missing_required_fields(self):
|
||||
registry = self._make_registry([
|
||||
{"name": "incomplete-agent"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
missing = [f for f in findings if f.category == "orphan"]
|
||||
assert len(missing) >= 1
|
||||
assert "machine" in missing[0].description or "role" in missing[0].description
|
||||
|
||||
def test_empty_registry_passes(self):
|
||||
registry = self._make_registry([])
|
||||
findings = validate_registry(registry)
|
||||
assert len(findings) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-reference tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrossReference:
|
||||
"""Test registry vs fleet-routing.json cross-reference."""
|
||||
|
||||
def test_orphan_in_fleet_not_registry(self):
|
||||
reg_agents = [{"name": "allegro", "machine": "x", "role": "y"}]
|
||||
fleet_agents = [{"name": "allegro", "location": "x"}, {"name": "unknown-agent", "location": "y"}]
|
||||
findings = cross_reference_registry_agents(reg_agents, fleet_agents)
|
||||
orphans = [f for f in findings if f.category == "orphan" and "unknown-agent" in f.description]
|
||||
assert len(orphans) == 1
|
||||
|
||||
def test_location_mismatch_detected(self):
|
||||
reg_agents = [{"name": "allegro", "machine": "167.99.126.228", "role": "y"}]
|
||||
fleet_agents = [{"name": "allegro", "location": "totally-different-host"}]
|
||||
findings = cross_reference_registry_agents(reg_agents, fleet_agents)
|
||||
mismatches = [f for f in findings if f.category == "duplicate" and "different locations" in f.description]
|
||||
assert len(mismatches) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test against actual registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRealRegistry:
|
||||
"""Test against the actual identity-registry.yaml in the repo."""
|
||||
|
||||
def test_registry_loads(self):
|
||||
reg_path = Path(__file__).resolve().parent.parent / "fleet" / "identity-registry.yaml"
|
||||
if reg_path.exists():
|
||||
with open(reg_path) as f:
|
||||
registry = yaml.safe_load(f)
|
||||
assert registry["version"] == 1
|
||||
assert len(registry["agents"]) > 0
|
||||
|
||||
def test_registry_no_critical_findings(self):
|
||||
reg_path = Path(__file__).resolve().parent.parent / "fleet" / "identity-registry.yaml"
|
||||
if reg_path.exists():
|
||||
with open(reg_path) as f:
|
||||
registry = yaml.safe_load(f)
|
||||
findings = validate_registry(registry)
|
||||
critical = [f for f in findings if f.severity == "critical"]
|
||||
assert len(critical) == 0, f"Critical findings: {[f.description for f in critical]}"
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Tests for gitea_safe_push — Branch existence checks before file operations."""
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bin.gitea_safe_push import GiteaSafePush, GiteaAPIError
|
||||
|
||||
|
||||
class TestGiteaAPIError:
|
||||
def test_creation(self):
|
||||
e = GiteaAPIError(404, "not found", '{"message":"not found"}')
|
||||
assert e.status == 404
|
||||
assert "404" in str(e)
|
||||
|
||||
def test_is_exception(self):
|
||||
e = GiteaAPIError(500, "internal")
|
||||
assert isinstance(e, Exception)
|
||||
|
||||
|
||||
class TestBranchExists:
|
||||
def test_branch_exists(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "_api", return_value={"name": "main"}):
|
||||
assert push.branch_exists("owner/repo", "main") is True
|
||||
|
||||
def test_branch_not_exists(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "_api", side_effect=GiteaAPIError(404, "not found")):
|
||||
assert push.branch_exists("owner/repo", "nonexistent") is False
|
||||
|
||||
def test_api_error_propagates(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "_api", side_effect=GiteaAPIError(500, "server error")):
|
||||
with pytest.raises(GiteaAPIError):
|
||||
push.branch_exists("owner/repo", "main")
|
||||
|
||||
|
||||
class TestEnsureBranch:
|
||||
def test_already_exists(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "branch_exists", return_value=True):
|
||||
assert push.ensure_branch("owner/repo", "my-branch") is True
|
||||
|
||||
def test_creates_branch(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "branch_exists", side_effect=[False, True]):
|
||||
with patch.object(push, "_api", return_value={"name": "my-branch"}):
|
||||
assert push.ensure_branch("owner/repo", "my-branch", base="main") is True
|
||||
|
||||
def test_creation_fails(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "branch_exists", return_value=False):
|
||||
with patch.object(push, "_api", side_effect=GiteaAPIError(422, "invalid")):
|
||||
assert push.ensure_branch("owner/repo", "bad-branch") is False
|
||||
|
||||
|
||||
class TestPushFile:
|
||||
def test_rejects_missing_branch(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "branch_exists", return_value=False):
|
||||
result = push.push_file("owner/repo", "missing", "file.py", "content", "msg")
|
||||
assert result is False
|
||||
|
||||
def test_creates_new_file(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "branch_exists", return_value=True):
|
||||
with patch.object(push, "_api", side_effect=[
|
||||
GiteaAPIError(404, "not found"), # GET existing file
|
||||
{}, # POST new file
|
||||
]):
|
||||
result = push.push_file("owner/repo", "branch", "new.py", "content", "msg")
|
||||
assert result is True
|
||||
|
||||
def test_updates_existing_file(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "branch_exists", return_value=True):
|
||||
with patch.object(push, "_api", side_effect=[
|
||||
{"sha": "abc123"}, # GET existing file
|
||||
{}, # PUT update
|
||||
]):
|
||||
result = push.push_file("owner/repo", "branch", "existing.py", "new content", "msg")
|
||||
assert result is True
|
||||
|
||||
def test_create_branch_when_missing(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
# Mock branch_exists: first call returns False (doesn't exist),
|
||||
# second call (inside ensure_branch) returns True (created externally)
|
||||
exists_calls = [False, True]
|
||||
exists_idx = [0]
|
||||
def mock_exists(repo, branch):
|
||||
idx = min(exists_idx[0], len(exists_calls) - 1)
|
||||
exists_idx[0] += 1
|
||||
return exists_calls[idx]
|
||||
with patch.object(push, "branch_exists", side_effect=mock_exists):
|
||||
with patch.object(push, "_api") as mock_api:
|
||||
mock_api.side_effect = [
|
||||
GiteaAPIError(404, "not found"), # GET existing file (not found)
|
||||
{"content": {"path": "f.py"}}, # POST new file
|
||||
]
|
||||
result = push.push_file("owner/repo", "new-branch", "f.py", "c", "m", create_branch=True)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestPushFiles:
|
||||
def test_push_multiple_files(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "ensure_branch", return_value=True):
|
||||
with patch.object(push, "push_file", return_value=True):
|
||||
results = push.push_files("owner/repo", "branch", {
|
||||
"a.py": "content a",
|
||||
"b.py": "content b",
|
||||
}, "message")
|
||||
assert all(results.values())
|
||||
assert len(results) == 2
|
||||
|
||||
def test_branch_creation_fails_aborts_all(self):
|
||||
push = GiteaSafePush("https://forge.example.com", "token123")
|
||||
with patch.object(push, "ensure_branch", return_value=False):
|
||||
results = push.push_files("owner/repo", "bad", {"a.py": "x"}, "msg")
|
||||
assert all(v is False for v in results.values())
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Tests for llama_client."""
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
import pytest, sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from bin.llama_client import LlamaClient, ChatMessage, HealthStatus
|
||||
|
||||
class TestChatMessage:
|
||||
def test_creation(self):
|
||||
m = ChatMessage("user", "Hello")
|
||||
assert m.role == "user" and m.content == "Hello"
|
||||
|
||||
class TestHealthStatus:
|
||||
def test_healthy(self):
|
||||
s = HealthStatus(True, "http://x:11435", model_loaded=True)
|
||||
assert s.healthy and s.model_loaded
|
||||
|
||||
class TestLlamaClient:
|
||||
def test_defaults(self):
|
||||
c = LlamaClient()
|
||||
assert c.endpoint == "http://localhost:11435" and c.model == "qwen2.5-7b"
|
||||
|
||||
def test_custom(self):
|
||||
c = LlamaClient("http://x:8080", "mistral")
|
||||
assert c.endpoint == "http://x:8080" and c.model == "mistral"
|
||||
|
||||
def test_trailing_slash(self):
|
||||
assert LlamaClient("http://x/").endpoint == "http://x"
|
||||
|
||||
@patch("bin.llama_client._http_get")
|
||||
def test_health_ok(self, m):
|
||||
m.return_value = {"status": "ok"}
|
||||
assert LlamaClient().health_check().healthy is True
|
||||
|
||||
@patch("bin.llama_client._http_get")
|
||||
def test_health_fail(self, m):
|
||||
m.side_effect = ConnectionError("down")
|
||||
s = LlamaClient().health_check()
|
||||
assert s.healthy is False and "down" in s.error
|
||||
|
||||
@patch("bin.llama_client._http_get")
|
||||
def test_is_healthy(self, m):
|
||||
m.return_value = {"status": "ok"}
|
||||
assert LlamaClient().is_healthy() is True
|
||||
|
||||
@patch("bin.llama_client._http_get")
|
||||
def test_list_models(self, m):
|
||||
m.return_value = {"data": [{"id": "qwen"}]}
|
||||
assert len(LlamaClient().list_models()) == 1
|
||||
|
||||
@patch("bin.llama_client._http_get")
|
||||
def test_list_models_fail(self, m):
|
||||
m.side_effect = ConnectionError()
|
||||
assert LlamaClient().list_models() == []
|
||||
|
||||
@patch("bin.llama_client._http_post")
|
||||
def test_chat(self, m):
|
||||
m.return_value = {"choices": [{"message": {"content": "Hi"}, "finish_reason": "stop"}], "usage": {"total_tokens": 10}}
|
||||
r = LlamaClient().chat([ChatMessage("user", "test")])
|
||||
assert r.text == "Hi" and r.tokens_used == 10
|
||||
|
||||
@patch("bin.llama_client._http_post")
|
||||
def test_chat_params(self, m):
|
||||
m.return_value = {"choices": [{"message": {"content": "OK"}, "finish_reason": "stop"}], "usage": {}}
|
||||
LlamaClient().chat([ChatMessage("user", "t")], max_tokens=100, temperature=0.3)
|
||||
d = m.call_args[0][1]
|
||||
assert d["max_tokens"] == 100 and d["temperature"] == 0.3
|
||||
|
||||
@patch("bin.llama_client._http_post")
|
||||
def test_simple_chat(self, m):
|
||||
m.return_value = {"choices": [{"message": {"content": "Yes"}, "finish_reason": "stop"}], "usage": {}}
|
||||
assert LlamaClient().simple_chat("test") == "Yes"
|
||||
|
||||
@patch("bin.llama_client._http_post")
|
||||
def test_simple_chat_system(self, m):
|
||||
m.return_value = {"choices": [{"message": {"content": "OK"}, "finish_reason": "stop"}], "usage": {}}
|
||||
LlamaClient().simple_chat("t", system="helpful")
|
||||
assert len(m.call_args[0][1]["messages"]) == 2
|
||||
|
||||
@patch("bin.llama_client._http_post")
|
||||
def test_complete(self, m):
|
||||
m.return_value = {"content": "result", "tokens_predicted": 50}
|
||||
r = LlamaClient().complete("prompt")
|
||||
assert r.text == "result" and r.tokens_used == 50
|
||||
|
||||
@patch("bin.llama_client.time.time")
|
||||
@patch("bin.llama_client._http_post")
|
||||
def test_benchmark(self, mp, mt):
|
||||
mp.return_value = {"choices": [{"message": {"content": "OK"}, "finish_reason": "stop"}], "usage": {"total_tokens": 10}}
|
||||
mt.side_effect = [0.0, 0.05, 0.05, 0.1, 0.1, 0.15]
|
||||
r = LlamaClient().benchmark(iterations=2)
|
||||
assert r["iterations"] == 2 and r["avg_latency_ms"] > 0 and r["tok_per_sec"] > 0
|
||||
@@ -1,387 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
McDonald Wizard Test Suite
|
||||
|
||||
Tests for the McDonald chatbot wizard harness and Hermes shim.
|
||||
|
||||
Usage:
|
||||
pytest tests/test_mcdonald_wizard.py -v
|
||||
RUN_LIVE_TESTS=1 pytest tests/test_mcdonald_wizard.py -v # real API calls
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from nexus.mcdonald_wizard import (
|
||||
DEFAULT_ENDPOINT,
|
||||
DEFAULT_RETRIES,
|
||||
DEFAULT_TIMEOUT,
|
||||
WIZARD_ID,
|
||||
McdonaldWizard,
|
||||
WizardResponse,
|
||||
mcdonald_wizard,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# FIXTURES
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wizard():
|
||||
"""Wizard with a fake API key so no real calls are made."""
|
||||
return McdonaldWizard(api_key="fake-key-for-testing")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ok_response():
|
||||
"""Mock requests.post returning a successful API response."""
|
||||
mock = MagicMock()
|
||||
mock.status_code = 200
|
||||
mock.json.return_value = {
|
||||
"choices": [{"message": {"content": "Behold, the golden arches!"}}],
|
||||
"model": "mc-wizard-v1",
|
||||
}
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rate_limit_response():
|
||||
"""Mock requests.post returning a 429 rate-limit error."""
|
||||
mock = MagicMock()
|
||||
mock.status_code = 429
|
||||
mock.text = "Rate limit exceeded"
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_server_error_response():
|
||||
"""Mock requests.post returning a 500 server error."""
|
||||
mock = MagicMock()
|
||||
mock.status_code = 500
|
||||
mock.text = "Internal server error"
|
||||
return mock
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# WizardResponse dataclass
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestWizardResponse:
|
||||
def test_default_creation(self):
|
||||
resp = WizardResponse()
|
||||
assert resp.text == ""
|
||||
assert resp.model == ""
|
||||
assert resp.latency_ms == 0.0
|
||||
assert resp.attempt == 1
|
||||
assert resp.error is None
|
||||
assert resp.timestamp
|
||||
|
||||
def test_to_dict_includes_all_fields(self):
|
||||
resp = WizardResponse(text="Hello", model="mc-wizard-v1", latency_ms=42.5, attempt=2)
|
||||
d = resp.to_dict()
|
||||
assert d["text"] == "Hello"
|
||||
assert d["model"] == "mc-wizard-v1"
|
||||
assert d["latency_ms"] == 42.5
|
||||
assert d["attempt"] == 2
|
||||
assert d["error"] is None
|
||||
assert "timestamp" in d
|
||||
|
||||
def test_error_response(self):
|
||||
resp = WizardResponse(error="HTTP 429: Rate limit")
|
||||
assert resp.error == "HTTP 429: Rate limit"
|
||||
assert resp.text == ""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# McdonaldWizard — initialization
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMcdonaldWizardInit:
|
||||
def test_default_endpoint(self, wizard):
|
||||
assert wizard.endpoint == DEFAULT_ENDPOINT
|
||||
|
||||
def test_custom_endpoint(self):
|
||||
w = McdonaldWizard(api_key="k", endpoint="https://custom.example.com/chat")
|
||||
assert w.endpoint == "https://custom.example.com/chat"
|
||||
|
||||
def test_default_timeout(self, wizard):
|
||||
assert wizard.timeout == DEFAULT_TIMEOUT
|
||||
|
||||
def test_default_retries(self, wizard):
|
||||
assert wizard.max_retries == DEFAULT_RETRIES
|
||||
|
||||
def test_no_api_key_warning(self, caplog):
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="mcdonald_wizard"):
|
||||
McdonaldWizard(api_key="")
|
||||
assert "MCDONALDS_API_KEY" in caplog.text
|
||||
|
||||
def test_api_key_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MCDONALDS_API_KEY", "env-key-123")
|
||||
w = McdonaldWizard()
|
||||
assert w.api_key == "env-key-123"
|
||||
|
||||
def test_endpoint_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MCDONALDS_ENDPOINT", "https://env.example.com/chat")
|
||||
w = McdonaldWizard(api_key="k")
|
||||
assert w.endpoint == "https://env.example.com/chat"
|
||||
|
||||
def test_initial_stats_zero(self, wizard):
|
||||
assert wizard.request_count == 0
|
||||
assert wizard.total_latency_ms == 0.0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# McdonaldWizard — ask (mocked HTTP)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestAsk:
|
||||
def test_ask_no_api_key_returns_error(self):
|
||||
w = McdonaldWizard(api_key="")
|
||||
resp = w.ask("Hello wizard")
|
||||
assert resp.error is not None
|
||||
assert "MCDONALDS_API_KEY" in resp.error
|
||||
|
||||
def test_ask_success(self, wizard, mock_ok_response):
|
||||
with patch("requests.post", return_value=mock_ok_response):
|
||||
resp = wizard.ask("What is your wisdom?")
|
||||
|
||||
assert resp.error is None
|
||||
assert resp.text == "Behold, the golden arches!"
|
||||
assert resp.model == "mc-wizard-v1"
|
||||
assert resp.latency_ms >= 0.0
|
||||
assert resp.attempt == 1
|
||||
|
||||
def test_ask_increments_request_count(self, wizard, mock_ok_response):
|
||||
with patch("requests.post", return_value=mock_ok_response):
|
||||
wizard.ask("q1")
|
||||
wizard.ask("q2")
|
||||
|
||||
assert wizard.request_count == 2
|
||||
|
||||
def test_ask_with_system_prompt(self, wizard, mock_ok_response):
|
||||
with patch("requests.post", return_value=mock_ok_response) as mock_post:
|
||||
wizard.ask("Hello", system="You are a wise McDonald wizard")
|
||||
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
roles = [m["role"] for m in payload["messages"]]
|
||||
assert "system" in roles
|
||||
assert payload["messages"][0]["content"] == "You are a wise McDonald wizard"
|
||||
|
||||
def test_ask_with_context(self, wizard, mock_ok_response):
|
||||
with patch("requests.post", return_value=mock_ok_response) as mock_post:
|
||||
wizard.ask("Continue please", context="Prior context here")
|
||||
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
contents = [m["content"] for m in payload["messages"]]
|
||||
assert "Prior context here" in contents
|
||||
|
||||
def test_ask_without_optional_args(self, wizard, mock_ok_response):
|
||||
with patch("requests.post", return_value=mock_ok_response) as mock_post:
|
||||
wizard.ask("Simple prompt")
|
||||
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
assert payload["messages"][-1]["role"] == "user"
|
||||
assert payload["messages"][-1]["content"] == "Simple prompt"
|
||||
|
||||
def test_ask_sends_bearer_auth(self, wizard, mock_ok_response):
|
||||
with patch("requests.post", return_value=mock_ok_response) as mock_post:
|
||||
wizard.ask("Hello")
|
||||
|
||||
headers = mock_post.call_args[1]["headers"]
|
||||
assert headers["Authorization"] == "Bearer fake-key-for-testing"
|
||||
|
||||
def test_ask_api_failure_returns_error(self, wizard):
|
||||
with patch("requests.post", side_effect=Exception("Connection refused")):
|
||||
resp = wizard.ask("Hello")
|
||||
|
||||
assert resp.error is not None
|
||||
assert "failed" in resp.error.lower()
|
||||
assert wizard.request_count == 1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# McdonaldWizard — retry behaviour
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestRetry:
|
||||
def test_retries_on_429(self, wizard, mock_ok_response, mock_rate_limit_response):
|
||||
call_count = [0]
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] < 2:
|
||||
return mock_rate_limit_response
|
||||
return mock_ok_response
|
||||
|
||||
with patch("requests.post", side_effect=side_effect):
|
||||
with patch("time.sleep"): # suppress actual sleep
|
||||
resp = wizard.ask("Hello")
|
||||
|
||||
assert resp.error is None
|
||||
assert resp.attempt == 2
|
||||
assert call_count[0] == 2
|
||||
|
||||
def test_retries_on_500(self, wizard, mock_ok_response, mock_server_error_response):
|
||||
call_count = [0]
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] < 3:
|
||||
return mock_server_error_response
|
||||
return mock_ok_response
|
||||
|
||||
with patch("requests.post", side_effect=side_effect):
|
||||
with patch("time.sleep"):
|
||||
resp = wizard.ask("Hello")
|
||||
|
||||
assert resp.error is None
|
||||
assert call_count[0] == 3
|
||||
|
||||
def test_all_retries_exhausted_returns_error(self, wizard, mock_rate_limit_response):
|
||||
with patch("requests.post", return_value=mock_rate_limit_response):
|
||||
with patch("time.sleep"):
|
||||
resp = wizard.ask("Hello")
|
||||
|
||||
assert resp.error is not None
|
||||
assert wizard.request_count == 1
|
||||
|
||||
def test_no_retry_on_success(self, wizard, mock_ok_response):
|
||||
with patch("requests.post", return_value=mock_ok_response) as mock_post:
|
||||
resp = wizard.ask("Hello")
|
||||
|
||||
assert mock_post.call_count == 1
|
||||
assert resp.attempt == 1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# McdonaldWizard — session stats
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSessionStats:
|
||||
def test_initial_stats(self, wizard):
|
||||
stats = wizard.session_stats()
|
||||
assert stats["wizard_id"] == WIZARD_ID
|
||||
assert stats["request_count"] == 0
|
||||
assert stats["total_latency_ms"] == 0.0
|
||||
assert stats["avg_latency_ms"] == 0.0
|
||||
|
||||
def test_stats_after_calls(self, wizard, mock_ok_response):
|
||||
with patch("requests.post", return_value=mock_ok_response):
|
||||
wizard.ask("a")
|
||||
wizard.ask("b")
|
||||
|
||||
stats = wizard.session_stats()
|
||||
assert stats["request_count"] == 2
|
||||
assert stats["total_latency_ms"] >= 0.0
|
||||
assert stats["avg_latency_ms"] >= 0.0
|
||||
|
||||
def test_avg_latency_calculation(self, wizard, mock_ok_response):
|
||||
with patch("requests.post", return_value=mock_ok_response):
|
||||
wizard.ask("x")
|
||||
|
||||
stats = wizard.session_stats()
|
||||
assert stats["avg_latency_ms"] == stats["total_latency_ms"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Hermes tool function
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestHermesTool:
|
||||
def test_mcdonald_wizard_tool_returns_dict(self, monkeypatch):
|
||||
mock_resp = WizardResponse(text="I am the wizard", model="mc-v1")
|
||||
mock_wizard = MagicMock()
|
||||
mock_wizard.ask.return_value = mock_resp
|
||||
|
||||
import nexus.mcdonald_wizard as _mod
|
||||
|
||||
monkeypatch.setattr(_mod, "_wizard_instance", mock_wizard)
|
||||
|
||||
result = mcdonald_wizard("What do you know?")
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["text"] == "I am the wizard"
|
||||
assert result["model"] == "mc-v1"
|
||||
assert result["error"] is None
|
||||
|
||||
def test_mcdonald_wizard_tool_passes_system(self, monkeypatch):
|
||||
mock_resp = WizardResponse(text="Aye", model="mc-v1")
|
||||
mock_wizard = MagicMock()
|
||||
mock_wizard.ask.return_value = mock_resp
|
||||
|
||||
import nexus.mcdonald_wizard as _mod
|
||||
|
||||
monkeypatch.setattr(_mod, "_wizard_instance", mock_wizard)
|
||||
mcdonald_wizard("Hello", system="Be brief")
|
||||
|
||||
mock_wizard.ask.assert_called_once_with("Hello", system="Be brief")
|
||||
|
||||
def test_mcdonald_wizard_tool_propagates_error(self, monkeypatch):
|
||||
mock_resp = WizardResponse(error="API key missing")
|
||||
mock_wizard = MagicMock()
|
||||
mock_wizard.ask.return_value = mock_resp
|
||||
|
||||
import nexus.mcdonald_wizard as _mod
|
||||
|
||||
monkeypatch.setattr(_mod, "_wizard_instance", mock_wizard)
|
||||
|
||||
result = mcdonald_wizard("Hello")
|
||||
assert result["error"] == "API key missing"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Live API tests (skipped unless RUN_LIVE_TESTS=1 and MCDONALDS_API_KEY set)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _live_tests_enabled():
|
||||
return (
|
||||
os.environ.get("RUN_LIVE_TESTS") == "1"
|
||||
and bool(os.environ.get("MCDONALDS_API_KEY"))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _live_tests_enabled(),
|
||||
reason="Live tests require RUN_LIVE_TESTS=1 and MCDONALDS_API_KEY",
|
||||
)
|
||||
@pytest.mark.integration
|
||||
class TestLiveAPI:
|
||||
"""Integration tests that hit the real McDonald chatbot API."""
|
||||
|
||||
@pytest.fixture
|
||||
def live_wizard(self):
|
||||
return McdonaldWizard()
|
||||
|
||||
def test_live_ask(self, live_wizard):
|
||||
resp = live_wizard.ask("Say 'McReady' and nothing else.")
|
||||
assert resp.error is None
|
||||
assert resp.text.strip()
|
||||
assert resp.latency_ms > 0
|
||||
|
||||
def test_live_session_stats_update(self, live_wizard):
|
||||
live_wizard.ask("Ping")
|
||||
stats = live_wizard.session_stats()
|
||||
assert stats["request_count"] == 1
|
||||
assert stats["total_latency_ms"] > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,25 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPORT = Path("reports/night-shift-prediction-2026-04-12.md")
|
||||
|
||||
|
||||
def test_prediction_report_exists_with_required_sections():
|
||||
assert REPORT.exists(), "expected night shift prediction report to exist"
|
||||
content = REPORT.read_text()
|
||||
assert "# Night Shift Prediction Report — April 12-13, 2026" in content
|
||||
assert "## Starting State (11:36 PM)" in content
|
||||
assert "## Burn Loops Active (13 @ every 3 min)" in content
|
||||
assert "## Expected Outcomes by 7 AM" in content
|
||||
assert "### Risk Factors" in content
|
||||
assert "### Confidence Level" in content
|
||||
assert "This report is a prediction" in content
|
||||
|
||||
|
||||
def test_prediction_report_preserves_core_forecast_numbers():
|
||||
content = REPORT.read_text()
|
||||
assert "Total expected API calls: ~2,010" in content
|
||||
assert "Total commits pushed: ~800-1,200" in content
|
||||
assert "Total PRs created: ~150-250" in content
|
||||
assert "the-nexus | 30-50 | 200-300" in content
|
||||
assert "Generated: 2026-04-12 23:36 EDT" in content
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Test portals.json integrity — valid JSON, no duplicate keys, expected structure."""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
|
||||
def test_portals_json_valid():
|
||||
"""portals.json must be valid JSON."""
|
||||
path = Path(__file__).resolve().parents[1] / "portals.json"
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert isinstance(data, list), "portals.json should be a JSON array"
|
||||
|
||||
|
||||
def test_portals_json_no_duplicate_keys():
|
||||
"""portals.json must not contain duplicate keys in any object."""
|
||||
path = Path(__file__).resolve().parents[1] / "portals.json"
|
||||
content = path.read_text(encoding="utf-8")
|
||||
|
||||
def check_duplicates(pairs):
|
||||
keys = [k for k, _ in pairs]
|
||||
seen = set()
|
||||
for k in keys:
|
||||
assert k not in seen, f"Duplicate key '{k}' found in portals.json"
|
||||
seen.add(k)
|
||||
return dict(pairs)
|
||||
|
||||
json.loads(content, object_pairs_hook=check_duplicates)
|
||||
|
||||
|
||||
def test_portals_json_structure():
|
||||
"""Each portal entry must have required fields."""
|
||||
path = Path(__file__).resolve().parents[1] / "portals.json"
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
required = {"id", "name", "description", "status", "color", "position"}
|
||||
for i, portal in enumerate(data):
|
||||
assert isinstance(portal, dict), f"Portal [{i}] is not a dict"
|
||||
missing = required - set(portal.keys())
|
||||
assert not missing, f"Portal [{i}] ({portal.get('id', '?')}) missing fields: {missing}"
|
||||
|
||||
|
||||
def test_portals_json_positions_valid():
|
||||
"""Each portal position must have x, y, z coordinates."""
|
||||
path = Path(__file__).resolve().parents[1] / "portals.json"
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
for i, portal in enumerate(data):
|
||||
pos = portal.get("position", {})
|
||||
for axis in ("x", "y", "z"):
|
||||
assert axis in pos, f"Portal [{i}] ({portal.get('id')}) missing position.{axis}"
|
||||
assert isinstance(pos[axis], (int, float)), f"Portal [{i}] position.{axis} is not a number"
|
||||
@@ -1,45 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"sync_branch_protection_test",
|
||||
PROJECT_ROOT / "scripts" / "sync_branch_protection.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["sync_branch_protection_test"] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
build_branch_protection_payload = _mod.build_branch_protection_payload
|
||||
|
||||
|
||||
def test_build_branch_protection_payload_enables_rebase_before_merge():
|
||||
payload = build_branch_protection_payload(
|
||||
"main",
|
||||
{
|
||||
"required_approvals": 1,
|
||||
"dismiss_stale_approvals": True,
|
||||
"require_ci_to_merge": False,
|
||||
"block_deletions": True,
|
||||
"block_force_push": True,
|
||||
"block_on_outdated_branch": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert payload["branch_name"] == "main"
|
||||
assert payload["rule_name"] == "main"
|
||||
assert payload["block_on_outdated_branch"] is True
|
||||
assert payload["required_approvals"] == 1
|
||||
assert payload["enable_status_check"] is False
|
||||
|
||||
|
||||
def test_the_nexus_branch_protection_config_requires_up_to_date_branch():
|
||||
config = yaml.safe_load((PROJECT_ROOT / ".gitea" / "branch-protection" / "the-nexus.yml").read_text())
|
||||
rules = config["rules"]
|
||||
assert rules["block_on_outdated_branch"] is True
|
||||
254
tools/texture-audit-cli.js
Normal file
254
tools/texture-audit-cli.js
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ═══════════════════════════════════════════
|
||||
* NEXUS TEXTURE AUDIT CLI — Standalone Audit Tool
|
||||
* ═══════════════════════════════════════════
|
||||
*
|
||||
* Command-line tool to audit textures in the Nexus project.
|
||||
* Provides compression recommendations and VRAM estimates.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
maxTextureSize: 2048,
|
||||
maxTotalTextures: 50,
|
||||
maxTotalVRAM: 256 * 1024 * 1024, // 256MB
|
||||
textureExtensions: ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.basis', '.ktx2'],
|
||||
imageMagickPath: 'convert', // Path to ImageMagick convert
|
||||
};
|
||||
|
||||
class TextureAuditor {
|
||||
constructor(projectRoot) {
|
||||
this.projectRoot = projectRoot;
|
||||
this.textureFiles = [];
|
||||
this.auditResults = [];
|
||||
this.totalVRAM = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan project for texture files
|
||||
*/
|
||||
scanForTextures() {
|
||||
console.log(`Scanning ${this.projectRoot} for textures...`);
|
||||
|
||||
const scanDir = (dir) => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip node_modules and .git
|
||||
if (entry.name !== 'node_modules' && entry.name !== '.git') {
|
||||
scanDir(fullPath);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (CONFIG.textureExtensions.includes(ext)) {
|
||||
this.textureFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scanDir(this.projectRoot);
|
||||
console.log(`Found ${this.textureFiles.length} texture files`);
|
||||
return this.textureFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit a single texture file
|
||||
*/
|
||||
auditTexture(filePath) {
|
||||
const result = {
|
||||
file: path.relative(this.projectRoot, filePath),
|
||||
issues: [],
|
||||
recommendations: [],
|
||||
score: 100,
|
||||
};
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
const fileSize = stats.size;
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
// Get image dimensions if possible
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
|
||||
try {
|
||||
const identify = execSync(`${CONFIG.imageMagickPath} -format "%wx%h" "${filePath}"`, { encoding: 'utf8' });
|
||||
const match = identify.match(/(\d+)x(\d+)/);
|
||||
if (match) {
|
||||
width = parseInt(match[1]);
|
||||
height = parseInt(match[2]);
|
||||
}
|
||||
} catch (e) {
|
||||
// ImageMagick not available, skip dimension check
|
||||
}
|
||||
|
||||
// Calculate estimated VRAM (RGBA)
|
||||
const vram = width * height * 4;
|
||||
this.totalVRAM += vram;
|
||||
|
||||
// Check file size
|
||||
if (fileSize > 10 * 1024 * 1024) { // >10MB
|
||||
result.issues.push(`Large file size: ${(fileSize / 1024 / 1024).toFixed(1)}MB`);
|
||||
result.recommendations.push('Consider compressing or using a different format');
|
||||
result.score -= 20;
|
||||
}
|
||||
|
||||
// Check dimensions
|
||||
if (width > CONFIG.maxTextureSize || height > CONFIG.maxTextureSize) {
|
||||
result.issues.push(`Texture too large: ${width}x${height} (max: ${CONFIG.maxTextureSize}x${CONFIG.maxTextureSize})`);
|
||||
result.recommendations.push(`Resize to ${CONFIG.maxTextureSize}x${CONFIG.maxTextureSize} or smaller`);
|
||||
result.score -= 30;
|
||||
}
|
||||
|
||||
// Check if power of two
|
||||
if (width > 0 && height > 0) {
|
||||
if (!this.isPowerOfTwo(width) || !this.isPowerOfTwo(height)) {
|
||||
result.issues.push('Texture dimensions not power of two');
|
||||
result.recommendations.push('Resize to nearest power of two (e.g., 512x512, 1024x1024)');
|
||||
result.score -= 15;
|
||||
}
|
||||
}
|
||||
|
||||
// Check format
|
||||
if (ext === '.png' || ext === '.jpg' || ext === '.jpeg') {
|
||||
result.recommendations.push('Consider using WebP for better compression');
|
||||
result.score -= 10;
|
||||
}
|
||||
|
||||
// Check VRAM usage
|
||||
if (vram > 16 * 1024 * 1024) { // >16MB
|
||||
result.issues.push(`High VRAM usage: ${(vram / 1024 / 1024).toFixed(1)}MB`);
|
||||
result.recommendations.push('Use compressed texture format (WebP, Basis, or KTX2)');
|
||||
result.score -= 20;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.issues.push(`Error reading file: ${error.message}`);
|
||||
result.score = 0;
|
||||
}
|
||||
|
||||
result.score = Math.max(0, result.score);
|
||||
this.auditResults.push(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run full audit
|
||||
*/
|
||||
audit() {
|
||||
this.scanForTextures();
|
||||
|
||||
console.log('\n=== Texture Audit Results ===\n');
|
||||
|
||||
let totalScore = 0;
|
||||
let issuesFound = 0;
|
||||
|
||||
for (const file of this.textureFiles) {
|
||||
const result = this.auditTexture(file);
|
||||
totalScore += result.score;
|
||||
issuesFound += result.issues.length;
|
||||
|
||||
if (result.issues.length > 0) {
|
||||
console.log(`\n${result.file}:`);
|
||||
console.log(` Score: ${result.score}/100`);
|
||||
result.issues.forEach(issue => console.log(` ⚠️ ${issue}`));
|
||||
result.recommendations.forEach(rec => console.log(` 💡 ${rec}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Audit Summary ===');
|
||||
console.log(`Total textures: ${this.textureFiles.length}`);
|
||||
console.log(`Total VRAM: ${(this.totalVRAM / 1024 / 1024).toFixed(1)}MB`);
|
||||
console.log(`Average score: ${(totalScore / this.textureFiles.length).toFixed(1)}/100`);
|
||||
console.log(`Issues found: ${issuesFound}`);
|
||||
console.log(`Texture limit: ${this.textureFiles.length > CONFIG.maxTotalTextures ? 'EXCEEDED' : 'OK'}`);
|
||||
console.log(`VRAM limit: ${this.totalVRAM > CONFIG.maxTotalVRAM ? 'EXCEEDED' : 'OK'}`);
|
||||
|
||||
// Generate compression plan
|
||||
this.generateCompressionPlan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate compression plan
|
||||
*/
|
||||
generateCompressionPlan() {
|
||||
console.log('\n=== Compression Plan ===');
|
||||
|
||||
const webpCandidates = [];
|
||||
const resizeCandidates = [];
|
||||
|
||||
for (const result of this.auditResults) {
|
||||
if (result.score < 80) {
|
||||
const ext = path.extname(result.file).toLowerCase();
|
||||
|
||||
if (ext === '.png' || ext === '.jpg' || ext === '.jpeg') {
|
||||
webpCandidates.push(result.file);
|
||||
}
|
||||
|
||||
if (result.issues.some(i => i.includes('too large'))) {
|
||||
resizeCandidates.push(result.file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Textures to convert to WebP: ${webpCandidates.length}`);
|
||||
webpCandidates.forEach(f => console.log(` 📦 ${f}`));
|
||||
|
||||
console.log(`Textures to resize: ${resizeCandidates.length}`);
|
||||
resizeCandidates.forEach(f => console.log(` 📐 ${f}`));
|
||||
|
||||
if (webpCandidates.length > 0) {
|
||||
console.log('\nTo convert to WebP:');
|
||||
console.log(' for file in *.png; do cwebp -q 80 "$file" -o "${file%.png}.webp"; done');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if number is power of two
|
||||
*/
|
||||
isPowerOfTwo(n) {
|
||||
return n !== 0 && (n & (n - 1)) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save audit results to JSON
|
||||
*/
|
||||
saveResults(outputPath) {
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot: this.projectRoot,
|
||||
totalTextures: this.textureFiles.length,
|
||||
totalVRAM: this.totalVRAM,
|
||||
averageScore: this.auditResults.reduce((sum, r) => sum + r.score, 0) / this.auditResults.length,
|
||||
results: this.auditResults,
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
|
||||
console.log(`\nAudit results saved to: ${outputPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (require.main === module) {
|
||||
const projectRoot = process.argv[2] || process.cwd();
|
||||
const auditor = new TextureAuditor(projectRoot);
|
||||
|
||||
auditor.audit();
|
||||
|
||||
// Save results if output path provided
|
||||
if (process.argv[3]) {
|
||||
auditor.saveResults(process.argv[3]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TextureAuditor;
|
||||
@@ -26,18 +26,11 @@ import threading
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from socketserver import ThreadingMixIn
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
"""Thread-per-request server for concurrent multi-user handling."""
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────────────
|
||||
|
||||
BRIDGE_PORT = int(os.environ.get('TIMMY_BRIDGE_PORT', 4004))
|
||||
@@ -281,7 +274,7 @@ def main():
|
||||
print(f" POST /bridge/move — Move user to room (user_id, room)")
|
||||
print()
|
||||
|
||||
server = ThreadingHTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler)
|
||||
server = HTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user