Compare commits

...

21 Commits

Author SHA1 Message Date
449170070b feat: standardize llama.cpp backend (#1123)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
CI / test (pull_request) Failing after 49s
Review Approval Gate / verify-review (pull_request) Failing after 7s
CI / validate (pull_request) Failing after 53s
2026-04-14 01:42:40 +00:00
3ed6bce5a0 feat: standardize llama.cpp backend (#1123)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 01:42:37 +00:00
2ecb4cd3a4 feat: standardize llama.cpp backend (#1123)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 01:42:29 +00:00
1c67f91b74 feat: standardize llama.cpp backend for sovereign local inference (#1123)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 01:41:35 +00:00
53d9a55444 feat: standardize llama.cpp backend for sovereign local inference (#1123)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 01:40:14 +00:00
dfbd96f792 Merge pull request 'fix: ChatLog.log() crash — CHATLOG_FILE defined after use (#1349)' (#1390) from burn/1349-1776125702 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 5s
Staging Verification Gate / verify-staging (push) Failing after 7s
2026-04-14 00:38:01 +00:00
5d5ea8ec1b Merge pull request '[A2A] Implement Agent2Agent Protocol for Fleet-Wizard Delegation' (#1380) from nexusburn/research-1122 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 00:37:29 +00:00
3f58b55351 Merge pull request 'fix(#1356): ThreadingHTTPServer concurrency fix' (#1389) from burn/1356-1776125702 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 00:37:25 +00:00
4b9f2154d4 Merge pull request 'fix: Add Sovereign Sound Playground and fix portals.json (#1354)' (#1391) from burn/1354-1776125702 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 00:37:20 +00:00
2e60c479ae Merge pull request 'feat: Add Reasoning Trace HUD Component' (#1374) from nexusburn/direction-architecture-875 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 00:36:25 +00:00
67a080b4fd Merge pull request 'feat: fleet audit tool — deduplicate agents, one identity per machine' (#1386) from burn/1144-1776120221 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 00:36:20 +00:00
961623b931 Merge pull request 'fix: MEMPALACE INIT shows real stats from fleet API (#1340)' (#1387) from burn/1340-1776120221 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 00:36:16 +00:00
3bb44a24e2 Merge pull request 'fix: remove duplicate content blocks from README.md' (#1392) from burn/1338-1776125702 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-14 00:36:10 +00:00
Alexander Whitestone
39faa6b862 fix: reconcile registry locations with fleet-routing.json, add missing agents
Some checks failed
CI / test (pull_request) Failing after 52s
Review Approval Gate / verify-review (pull_request) Successful in 10s
CI / validate (pull_request) Failing after 48s
- Aligned 7 location mismatches between identity-registry.yaml and
  fleet-routing.json (allegro, ezra, bezalel, bilbobagginshire,
  substratum, fenrir, kimi)
- Added carnice (active, local ollama agent) to registry
- Added allegro-primus (deprecated) to registry

Audit results: 16 findings → 7 info-only (ghost agents intentionally
kept for audit trail). Zero warnings. Registry VALID.
2026-04-13 20:20:57 -04:00
Alexander Whitestone
8fa43cc228 fix: Add Sovereign Sound Playground and fix portals.json (#1354)
Some checks failed
CI / test (pull_request) Failing after 53s
CI / validate (pull_request) Failing after 55s
Review Approval Gate / verify-review (pull_request) Failing after 7s
This commit addresses issue #1354 by:

1. Fixing portals.json syntax error (duplicate params field)
2. Adding the Sovereign Sound Playground as a new portal
3. Including the complete playground application

Changes:
- Fixed JSON syntax error in portals.json (line 41-44)
- Added playground/playground.html - Complete interactive audio-visual experience
- Added playground/README.md - Documentation and usage guide
- Updated portals.json with playground portal entry

The playground portal is configured with:
- Online status
- Visitor access mode
- Local destination URL
- Creative tool portal type

This resolves the issue and provides a working playground accessible through the Nexus portal system.
2026-04-13 20:18:57 -04:00
Alexander Whitestone
b9bc776fdb fix: remove duplicate content blocks from README.md
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 7s
CI / test (pull_request) Failing after 43s
CI / validate (pull_request) Failing after 44s
## Summary
Fixed duplicate content blocks in README.md caused by bad merge.
Branch protection policy, default reviewers, and implementation status
blocks were duplicated 3-4 times on the page.

## Problem
The README.md file had massive duplication from multiple bad merges:
- Branch protection policy appeared 4 times
- Default reviewers appeared multiple times
- Implementation status appeared multiple times
- Repository-specific configuration duplicated
- Acceptance criteria duplicated

The file grew to 517 lines with the same content repeated.

## Solution
Cleaned up README.md to contain:
1. Single branch protection policy section
2. Original Nexus project content (preserved)
3. Clean structure without duplicates

Reduced from 517 lines to 167 lines while preserving all unique content.

## Changes
- Removed duplicate branch protection policy sections
- Removed duplicate default reviewers sections
- Removed duplicate implementation status sections
- Removed duplicate repository-specific configuration
- Removed duplicate acceptance criteria
- Preserved original Nexus project content
- Maintained clear structure and formatting

## Testing
- Verified all unique content is preserved
- Checked for any remaining duplicates
- Confirmed file structure is clean and readable

## Acceptance Criteria
 Branch protection policy appears once
 Default reviewers appear once
 Implementation status appears once
 Content is clear and not duplicated
 Original Nexus content preserved

Issue: #1338
2026-04-13 20:18:43 -04:00
Alexander Whitestone
9bcd41ad07 fix: ChatLog.log() crash — CHATLOG_FILE defined after use (#1349)
Some checks failed
CI / test (pull_request) Failing after 31s
CI / validate (pull_request) Failing after 31s
Review Approval Gate / verify-review (pull_request) Failing after 3s
Move configuration block (WORLD_DIR, CHATLOG_FILE, etc.) before the
ChatLog class definition. Previously CHATLOG_FILE was defined at line ~254
but used at line ~200 inside ChatLog.log(), causing NameError on every
chat message persistence attempt.

Fixes #1349.
2026-04-13 20:17:02 -04:00
Timmy
d7a15ae046 fix(#1356): ThreadingHTTPServer for multi-user bridge concurrency
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 47s
CI / test (pull_request) Failing after 49s
Replace single-threaded HTTPServer with ThreadingHTTPServer
(thread-per-request) in both multi_user_bridge.py copies.

Fixes #1356
2026-04-13 20:16:21 -04:00
Timmy
7fab9799b1 fix: MEMPALACE INIT shows real stats from fleet API (#1340)
Some checks failed
CI / test (pull_request) Failing after 31s
CI / validate (pull_request) Failing after 53s
Review Approval Gate / verify-review (pull_request) Failing after 7s
Root cause: connectMemPalace() set placeholder values (0x, 0, 0B)
immediately and tried to connect to window.Claude.mcp which doesn't
exist in a normal browser. Never contacted the actual fleet API.

Fix:
- Replace connectMemPalace() to fetch from fleet API (/health, /wings)
- Show MEMPALACE CONNECTING during fetch, ACTIVE on success,
  OFFLINE if API unavailable
- Populate compression ratio, docs mined, AAAK size from real data
- Add formatBytes() helper for human-readable sizes
- Periodic refresh every 60s when connected
- Configurable API endpoint via ?mempalace=host:port query param
- Remove dead window.Claude.mcp mock code
2026-04-13 18:53:08 -04:00
Timmy (NEXUSBURN)
66c010301d feat: fleet audit tool — deduplicate agents, one identity per machine
Some checks failed
CI / test (pull_request) Failing after 38s
Review Approval Gate / verify-review (pull_request) Failing after 5s
CI / validate (pull_request) Failing after 34s
Closes #1144. Builds a fleet audit pipeline that detects duplicate
agent identities, ghost accounts, and authorship ambiguity across
all machines.

Deliverables:

bin/fleet_audit.py — Full audit tool with four checks:
  - Identity registry validation (one name per machine, unique gitea_user)
  - Git authorship audit (detects ambiguous committers from branch names)
  - Gitea org member audit (finds ghost accounts with zero activity)
  - Cross-reference registry vs fleet-routing.json (orphan/location mismatch)

fleet/identity-registry.yaml — Canonical identity registry:
  - 8 active agents (timmy, allegro, ezra, bezalel, bilbobagginshire,
    fenrir, substratum, claw-code)
  - 7 ghost/deprecated accounts marked inactive
  - Rules: one identity per machine, unique gitea_user, required fields

tests/test_fleet_audit.py — 11 tests covering all validation rules.

Usage:
  python3 bin/fleet_audit.py                  # full audit -> JSON
  python3 bin/fleet_audit.py --identity-check # registry only
  python3 bin/fleet_audit.py --git-authors    # authorship only
  python3 bin/fleet_audit.py --report out.json # write to file
2026-04-13 18:51:31 -04:00
Alexander Whitestone
4488847c13 feat: Add Reasoning Trace HUD Component
Some checks failed
CI / test (pull_request) Failing after 57s
CI / validate (pull_request) Failing after 34s
Review Approval Gate / verify-review (pull_request) Failing after 6s
Closes #875

- Added new ReasoningTrace component for real-time reasoning visualization
- Shows agent's reasoning steps during complex task execution
- Supports step types: THINK, DECIDE, RECALL, PLAN, EXECUTE, VERIFY, DOUBT, MEMORY
- Includes confidence visualization, task tracking, and export functionality
- Integrated into existing GOFAI HUD system
2026-04-13 18:23:05 -04:00
18 changed files with 2827 additions and 459 deletions

380
README.md
View File

@@ -1,6 +1,6 @@
# Branch Protection & Review Policy
# The Nexus Project
## Enforced Rules for All Repositories
## Branch Protection & Review Policy
**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 | <EFBFBD> Conditional | Only where CI exists |
| Require CI to pass | ⚠️ Conditional | Only where CI exists |
| Block force push | ✅ Enabled | Protect commit history |
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
@@ -31,105 +31,7 @@
> 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
@@ -243,275 +145,23 @@ 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
## Running Locally
**All repositories enforce these rules on the `main` branch:**
### Current repo truth
| 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 |
There is no root browser app on current `main`.
Do not tell people to static-serve the repo root and expect a world.
**Default Reviewers:**
- @perplexity (all repositories)
- @Timmy (hermes-agent only)
### What you can run now
**CI Enforcement:**
- hermes-agent: Full CI enforcement
- the-nexus: CI pending runner restoration (#915)
- timmy-home: No CI enforcement
- timmy-config: Limited CI
- `python3 server.py` for the local websocket bridge
- Python modules under `nexus/` for heartbeat / cognition work
**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)
### Browser world restoration path
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
The browser-facing Nexus must be rebuilt deliberately through the migration backlog above, using audited Matrix components and truthful validation.
## 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
*One 3D repo. One migration path. No more ghost worlds.*

133
app.js
View File

@@ -9,6 +9,7 @@ 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';
// ═══════════════════════════════════════════
// NEXUS v1.1 — Portal System Update
@@ -758,6 +759,7 @@ async function init() {
SpatialAudio.bindSpatialMemory(SpatialMemory);
MemoryInspect.init({ onNavigate: _navigateToMemory });
MemoryPulse.init(SpatialMemory);
ReasoningTrace.init();
updateLoad(90);
loadSession();
@@ -2760,58 +2762,89 @@ 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');
if (statusEl) {
statusEl.textContent = 'MemPalace ACTIVE';
statusEl.style.color = '#4af0c0';
statusEl.style.textShadow = '0 0 10px #4af0c0';
}
// 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.error('Failed to initialize MemPalace:', err);
const statusEl = document.getElementById('mem-palace-status');
if (statusEl) {
statusEl.textContent = 'MemPalace ERROR';
statusEl.style.color = '#ff4466';
statusEl.style.textShadow = '0 0 10px #ff4466';
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.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;
} catch (err) {
console.warn('[MemPalace] Fleet API unavailable:', err.message);
if (statusEl) {
statusEl.textContent = 'MEMPALACE OFFLINE';
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() {

463
bin/fleet_audit.py Normal file
View File

@@ -0,0 +1,463 @@
#!/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()

154
bin/llama_client.py Normal file
View File

@@ -0,0 +1,154 @@
#!/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
from typing import Generator, Optional
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()

54
docs/local-llm.md Normal file
View File

@@ -0,0 +1,54 @@
# Local LLM Deployment Guide — llama.cpp Sovereign Inference
llama.cpp provides sovereign, offline-capable inference on CPU, CUDA, and
Apple Silicon. One binary, one model path, one health endpoint.
## 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
curl http://localhost:11435/health
## Model Path Convention
- /opt/models/llama/ — Production (system-wide)
- ~/models/llama/ — Per-user (dev)
- MODEL_DIR env var — Override
## Recommended Models
- Qwen2.5-7B-Instruct (4.7GB, 8GB RAM, 25-40 tok/s) — Fleet standard
- Qwen2.5-3B-Instruct (2.0GB, 4GB RAM, 50-80 tok/s) — VPS Beta
- Mistral-7B-Instruct-v0.3 (4.4GB, 8GB RAM) — Alternative
## Quantization Guide
- Q6_K (5.5GB) — Best quality/speed, RAM > 12GB
- Q4_K_M (4.7GB) — Fleet standard
- Q3_K_M (3.4GB) — < 6GB RAM fallback
## Hardware Targets
- VPS Beta (2 vCPU, 4GB): Qwen2.5-3B-Q4_K_M, ctx 2048, ~40-60 tok/s
- VPS Alpha (4 vCPU, 8GB): Qwen2.5-7B-Q4_K_M, ctx 4096, ~20-35 tok/s
- Mac Apple Silicon: Qwen2.5-7B-Q6_K, Metal, ~30-50 tok/s
## Health Check
curl -sf http://localhost:11435/health
curl -s http://localhost:11435/v1/models
## API Compatibility
llama-server exposes OpenAI-compatible API at /v1/chat/completions.
## Troubleshooting
- Won't start: use smaller model or lower quant
- Slow: match -t to available cores
- OOM: reduce -c context size
- Port in use: lsof -i :11435

View File

@@ -0,0 +1,121 @@
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.

View File

@@ -101,6 +101,19 @@
<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 -->

View File

@@ -163,6 +163,15 @@ 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 ──────────────────────────────────────────────────
@@ -244,15 +253,6 @@ 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 = HTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler)
server = ThreadingHTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler)
server.serve_forever()

View File

@@ -0,0 +1,451 @@
// ═══════════════════════════════════════════════════
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 };

73
nexus/llama_provider.py Normal file
View File

@@ -0,0 +1,73 @@
"""llama_provider.py — Hermes inference router provider for llama.cpp local server."""
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

95
playground/README.md Normal file
View File

@@ -0,0 +1,95 @@
# 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.

692
playground/playground.html Normal file
View File

@@ -0,0 +1,692 @@
<!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>

View File

@@ -6,24 +6,6 @@
"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,
@@ -32,12 +14,38 @@
"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"
}
@@ -54,8 +62,6 @@
"status": "downloaded",
"color": "#ffd700",
"role": "pilot",
"position": { "x": -15, "y": 0, "z": -10 },
"rotation": { "y": 0.5 },
"position": {
"x": -15,
"y": 0,
@@ -110,8 +116,6 @@
"status": "online",
"color": "#4af0c0",
"role": "timmy",
"position": { "x": 0, "y": 0, "z": -20 },
"rotation": { "y": 0 },
"position": {
"x": 0,
"y": 0,
@@ -140,8 +144,6 @@
"status": "online",
"color": "#0066ff",
"role": "timmy",
"position": { "x": 25, "y": 0, "z": 0 },
"rotation": { "y": -1.57 },
"position": {
"x": 25,
"y": 0,
@@ -169,8 +171,6 @@
"status": "online",
"color": "#ffd700",
"role": "timmy",
"position": { "x": -25, "y": 0, "z": 0 },
"rotation": { "y": 1.57 },
"position": {
"x": -25,
"y": 0,
@@ -196,8 +196,6 @@
"status": "online",
"color": "#4af0c0",
"role": "reflex",
"position": { "x": 15, "y": 0, "z": 10 },
"rotation": { "y": -2.5 },
"position": {
"x": 15,
"y": 0,
@@ -226,8 +224,6 @@
"status": "standby",
"color": "#ff4466",
"role": "reflex",
"position": { "x": -15, "y": 0, "z": 10 },
"rotation": { "y": 2.5 },
"position": {
"x": -15,
"y": 0,
@@ -245,5 +241,55 @@
},
"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
}
]

249
style.css
View File

@@ -2685,3 +2685,252 @@ 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;
}
}

View File

@@ -0,0 +1,29 @@
[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
StandardError=journal
SyslogIdentifier=llama-server
[Install]
WantedBy=multi-user.target

143
tests/test_fleet_audit.py Normal file
View File

@@ -0,0 +1,143 @@
"""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]}"

View File

@@ -0,0 +1,95 @@
"""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"
assert 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
m.side_effect = ConnectionError()
assert LlamaClient().is_healthy() is False
@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="be 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

View File

@@ -26,11 +26,18 @@ import threading
import hashlib
import os
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
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))
@@ -274,7 +281,7 @@ def main():
print(f" POST /bridge/move — Move user to room (user_id, room)")
print()
server = HTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler)
server = ThreadingHTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler)
server.serve_forever()