diff --git a/site/beacon/data.js b/site/beacon/data.js
index e8da82227..21abc35a9 100644
--- a/site/beacon/data.js
+++ b/site/beacon/data.js
@@ -803,6 +803,66 @@ export const CONTRACT_STATE_OPACITY = {
// ============================================================
export const REGION_RADIUS = 120;
+/**
+ * Find agents by identity and visible Atlas metadata.
+ *
+ * Every whitespace-delimited query token must match somewhere in the agent's
+ * searchable fields. Exact identities and name-prefix matches rank ahead of
+ * broader role, capability, provider, source, status, or city matches.
+ */
+export function searchAgents(query, agents = AGENTS, limit = 8) {
+ const normalizedQuery = String(query ?? '').trim().toLowerCase();
+ if (!normalizedQuery) return [];
+
+ const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
+ const safeLimit = Number.isFinite(Number(limit))
+ ? Math.min(20, Math.max(1, Math.trunc(Number(limit))))
+ : 8;
+
+ return agents
+ .map((agent, index) => {
+ const city = CITIES.find(candidate => candidate.id === agent.city);
+ const name = String(agent.name ?? '').toLowerCase();
+ const id = String(agent.id ?? '').toLowerCase();
+ const beacon = String(agent.beacon ?? agent.relay_agent_id ?? '').toLowerCase();
+ const cityName = String(city?.name ?? '').toLowerCase();
+ const searchable = [
+ name,
+ id,
+ beacon,
+ agent.role,
+ agent.provider,
+ agent.model_id,
+ agent.status,
+ agent.grade,
+ agent.city,
+ cityName,
+ ...(Array.isArray(agent.capabilities) ? agent.capabilities : []),
+ ...(Array.isArray(agent.sources) ? agent.sources : []),
+ ].map(value => String(value ?? '').toLowerCase()).join(' ');
+
+ if (!tokens.every(token => searchable.includes(token))) return null;
+
+ let rank = 6;
+ if (id === normalizedQuery || beacon === normalizedQuery || name === normalizedQuery) rank = 0;
+ else if (name.startsWith(normalizedQuery)) rank = 1;
+ else if (id.startsWith(normalizedQuery) || beacon.startsWith(normalizedQuery)) rank = 2;
+ else if (name.includes(normalizedQuery)) rank = 3;
+ else if (cityName.startsWith(normalizedQuery)) rank = 4;
+ else if (cityName.includes(normalizedQuery)) rank = 5;
+
+ return { agent, index, rank, name };
+ })
+ .filter(Boolean)
+ .sort((left, right) => (
+ left.rank - right.rank
+ || left.name.localeCompare(right.name)
+ || left.index - right.index
+ ))
+ .slice(0, safeLimit)
+ .map(result => result.agent);
+}
+
export function regionPosition(region) {
const rad = (region.angle * Math.PI) / 180;
return { x: Math.cos(rad) * REGION_RADIUS, z: Math.sin(rad) * REGION_RADIUS };
diff --git a/site/beacon/index.html b/site/beacon/index.html
index 25415c738..b5937aebb 100644
--- a/site/beacon/index.html
+++ b/site/beacon/index.html
@@ -68,12 +68,28 @@
[NEW CONTRACT]
[BOUNTIES]
+
+
+
+
+
DRAG rotate | SCROLL zoom | RIGHT-DRAG pan
- CLICK agent/city for details | ESC close
+ CLICK agent/city for details | / search | ESC close
diff --git a/site/beacon/styles.css b/site/beacon/styles.css
index 05b060fc1..d93d2820d 100644
--- a/site/beacon/styles.css
+++ b/site/beacon/styles.css
@@ -90,6 +90,115 @@ html, body {
color: var(--green);
}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.agent-search {
+ position: relative;
+ width: min(320px, calc(100vw - 32px));
+ margin-top: 10px;
+ pointer-events: auto;
+ font-family: var(--font-mono);
+ text-shadow: none;
+}
+
+.agent-search-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 8px 10px;
+ border: 1px solid var(--green-dim);
+ border-radius: 3px;
+ outline: none;
+ background: rgba(0, 12, 0, 0.92);
+ color: var(--green);
+ caret-color: var(--amber);
+ font: 12px/1.4 var(--font-mono);
+ letter-spacing: 1px;
+ box-shadow: inset 0 0 12px rgba(0, 255, 0, 0.03);
+}
+
+.agent-search-input::placeholder {
+ color: var(--text-dim);
+ opacity: 0.8;
+}
+
+.agent-search-input:focus {
+ border-color: var(--green);
+ box-shadow: 0 0 10px rgba(0, 255, 0, 0.14), inset 0 0 12px rgba(0, 255, 0, 0.05);
+}
+
+.agent-search-results {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ max-height: 280px;
+ overflow-y: auto;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: rgba(0, 10, 0, 0.97);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.55), 0 0 12px rgba(0, 255, 0, 0.06);
+}
+
+.agent-search-results[hidden] {
+ display: none;
+}
+
+.agent-search-result {
+ display: flex;
+ width: 100%;
+ padding: 8px 10px;
+ border: 0;
+ border-bottom: 1px solid rgba(0, 255, 0, 0.08);
+ background: transparent;
+ color: var(--green);
+ cursor: pointer;
+ font-family: var(--font-mono);
+ text-align: left;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.agent-search-result:last-child {
+ border-bottom: 0;
+}
+
+.agent-search-result:hover,
+.agent-search-result.active {
+ background: rgba(0, 255, 0, 0.09);
+ color: #8cff8c;
+}
+
+.agent-search-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+}
+
+.agent-search-meta,
+.agent-search-empty {
+ color: var(--text-dim);
+ font-size: 10px;
+ white-space: nowrap;
+}
+
+.agent-search-empty {
+ padding: 9px 10px;
+ letter-spacing: 1px;
+}
+
/* --- Controls hint (bottom-left) --- */
.controls-hint {
position: fixed;
@@ -753,6 +862,10 @@ html, body {
.hud-title { font-size: 22px; }
.hud-stats { font-size: 14px; }
+ .agent-search {
+ width: min(300px, calc(100vw - 16px));
+ }
+
.controls-hint { display: none; }
}
diff --git a/site/beacon/ui.js b/site/beacon/ui.js
index bcc27d986..e3ffa5898 100644
--- a/site/beacon/ui.js
+++ b/site/beacon/ui.js
@@ -4,7 +4,7 @@
import {
AGENTS, CITIES, CONTRACTS, CALIBRATIONS,
- GRADE_COLORS, cityRegion, addContract, getProviderColor, resolveAgentId,
+ GRADE_COLORS, cityRegion, addContract, getProviderColor, resolveAgentId, searchAgents,
} from './data.js';
import { lerpCameraTo, resetCamera, setClickHandler, setMissHandler, setHoverHandler } from './scene.js';
import { getAgentPosition, highlightAgent } from './agents.js';
@@ -20,6 +20,10 @@ let panel, panelContent, panelPath, tooltip;
let selectedAgent = null;
let selectedCity = null;
let hoveredId = null;
+let searchInput = null;
+let searchResults = null;
+let searchMatches = [];
+let activeSearchIndex = -1;
function escapeHtml(value) {
return String(value ?? '')
@@ -111,6 +115,7 @@ export function initUI() {
// HUD stats
updateHUD();
+ initAgentSearch();
// Click handlers
setClickHandler(onObjectClick);
@@ -137,6 +142,145 @@ export function initUI() {
window.addEventListener('hashchange', handleDeepLink);
}
+function initAgentSearch() {
+ searchInput = document.getElementById('agent-search-input');
+ searchResults = document.getElementById('agent-search-results');
+ if (!searchInput || !searchResults) return;
+
+ const refreshResults = () => {
+ const query = searchInput.value.trim();
+ searchMatches = searchAgents(query);
+ activeSearchIndex = searchMatches.length > 0 ? 0 : -1;
+ renderAgentSearchResults(query);
+ };
+
+ searchInput.addEventListener('input', refreshResults);
+ searchInput.addEventListener('focus', refreshResults);
+ searchInput.addEventListener('keydown', event => {
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ if (searchMatches.length === 0) return;
+ event.preventDefault();
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
+ const nextIndex = (activeSearchIndex + direction + searchMatches.length) % searchMatches.length;
+ setActiveSearchIndex(nextIndex);
+ return;
+ }
+
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ chooseSearchResult(activeSearchIndex);
+ return;
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ searchInput.value = '';
+ searchMatches = [];
+ hideAgentSearchResults();
+ searchInput.blur();
+ }
+ });
+
+ document.addEventListener('keydown', event => {
+ const target = event.target;
+ const isTyping = target instanceof HTMLElement
+ && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));
+ if (event.key === '/' && !isTyping && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ searchInput.focus();
+ }
+ });
+
+ document.addEventListener('pointerdown', event => {
+ const target = event.target;
+ if (!(target instanceof Element) || !target.closest('.agent-search')) {
+ hideAgentSearchResults();
+ }
+ });
+}
+
+function renderAgentSearchResults(query) {
+ searchResults.replaceChildren();
+ searchInput.setAttribute('aria-expanded', query ? 'true' : 'false');
+
+ if (!query) {
+ hideAgentSearchResults();
+ return;
+ }
+
+ searchResults.hidden = false;
+
+ if (searchMatches.length === 0) {
+ const empty = document.createElement('div');
+ empty.className = 'agent-search-empty';
+ empty.textContent = 'NO MATCHING AGENTS';
+ searchResults.append(empty);
+ return;
+ }
+
+ searchMatches.forEach((agent, index) => {
+ const city = CITIES.find(candidate => candidate.id === agent.city);
+ const option = document.createElement('button');
+ option.type = 'button';
+ option.className = 'agent-search-result';
+ option.id = `agent-search-result-${index}`;
+ option.dataset.index = String(index);
+ option.setAttribute('aria-current', index === activeSearchIndex ? 'true' : 'false');
+
+ const name = document.createElement('span');
+ name.className = 'agent-search-name';
+ name.textContent = agent.name || agent.id;
+
+ const meta = document.createElement('span');
+ meta.className = 'agent-search-meta';
+ const status = agent.status ? ` ยท ${String(agent.status).toUpperCase()}` : '';
+ meta.textContent = `${city?.name || agent.city || 'Unknown city'}${status}`;
+
+ option.append(name, meta);
+ option.addEventListener('pointerenter', () => setActiveSearchIndex(index));
+ option.addEventListener('click', () => chooseSearchResult(index));
+ searchResults.append(option);
+ });
+
+ setActiveSearchIndex(activeSearchIndex, false);
+}
+
+function setActiveSearchIndex(index, scroll = true) {
+ activeSearchIndex = index;
+ const options = [...searchResults.querySelectorAll('.agent-search-result')];
+ options.forEach((option, optionIndex) => {
+ const active = optionIndex === activeSearchIndex;
+ option.classList.toggle('active', active);
+ option.setAttribute('aria-current', active ? 'true' : 'false');
+ });
+
+ const activeOption = options[activeSearchIndex];
+ if (activeOption) {
+ searchInput.setAttribute('aria-activedescendant', activeOption.id);
+ if (scroll) activeOption.scrollIntoView({ block: 'nearest' });
+ } else {
+ searchInput.removeAttribute('aria-activedescendant');
+ }
+}
+
+function chooseSearchResult(index) {
+ const agent = searchMatches[index];
+ if (!agent) return;
+ searchInput.value = agent.name || agent.id;
+ hideAgentSearchResults();
+ searchInput.blur();
+ selectAgent(agent.id);
+}
+
+function hideAgentSearchResults() {
+ if (!searchResults || !searchInput) return;
+ searchResults.hidden = true;
+ searchInput.setAttribute('aria-expanded', 'false');
+ searchInput.removeAttribute('aria-activedescendant');
+ activeSearchIndex = -1;
+}
+
function updateHUD() {
const el = document.querySelector('.hud-stats');
if (el) {
diff --git a/tests/test_beacon_atlas.py b/tests/test_beacon_atlas.py
index 034ae1e53..025b05a8f 100644
--- a/tests/test_beacon_atlas.py
+++ b/tests/test_beacon_atlas.py
@@ -8,9 +8,13 @@
import time
import sys
import os
+import pathlib
+import subprocess
+import textwrap
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
class TestBeaconAtlasAPI(unittest.TestCase):
@@ -231,6 +235,79 @@ def test_state_opacity_mapping(self):
self.assertLessEqual(opacity, 1.0, "Opacity must be <= 1")
+class TestBeaconAtlasAgentSearch(unittest.TestCase):
+ """Test the browser-independent agent search/filter behavior."""
+
+ def test_searches_identity_metadata_and_city_with_stable_ranking(self):
+ script = textwrap.dedent(
+ """
+ import { searchAgents } from './site/beacon/data.js';
+
+ const agents = [
+ {
+ id: 'bcn_sophia_elya', name: 'Sophia Elya', role: 'Inference Orchestrator',
+ city: 'compiler_heights', provider: 'elyan', status: 'active',
+ capabilities: ['coding', 'automation'], sources: ['beacon'],
+ },
+ {
+ id: 'bcn_doc_clint', name: 'Doc Clint Otis', role: 'Research Physician',
+ city: 'tensor_valley', provider: 'anthropic', status: 'active',
+ capabilities: ['research', 'documentation'], sources: ['beacon', 'bottube'],
+ },
+ {
+ id: 'bcn_silent_builder', name: 'Builder Zero', role: 'Code Agent',
+ city: 'compiler_heights', provider: 'openai', status: 'silent',
+ capabilities: ['coding'], sources: ['beacon'],
+ },
+ ];
+
+ const result = {
+ exact: searchAgents('bcn_sophia_elya', agents).map(agent => agent.id),
+ name: searchAgents('sophia', agents).map(agent => agent.id),
+ metadata: searchAgents('anthropic active', agents).map(agent => agent.id),
+ cityAndRole: searchAgents('tensor research', agents).map(agent => agent.id),
+ bounded: searchAgents('compiler', agents, 1).map(agent => agent.id),
+ empty: searchAgents(' ', agents).map(agent => agent.id),
+ missing: searchAgents('no-such-agent', agents).map(agent => agent.id),
+ };
+ console.log(JSON.stringify(result));
+ """
+ )
+ completed = subprocess.run(
+ [
+ "node",
+ "--experimental-default-type=module",
+ "--input-type=module",
+ "-e",
+ script,
+ ],
+ cwd=REPO_ROOT,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ result = json.loads(completed.stdout)
+
+ self.assertEqual(result["exact"], ["bcn_sophia_elya"])
+ self.assertEqual(result["name"], ["bcn_sophia_elya"])
+ self.assertEqual(result["metadata"], ["bcn_doc_clint"])
+ self.assertEqual(result["cityAndRole"], ["bcn_doc_clint"])
+ self.assertEqual(result["bounded"], ["bcn_silent_builder"])
+ self.assertEqual(result["empty"], [])
+ self.assertEqual(result["missing"], [])
+
+ def test_search_ui_is_accessible_and_uses_safe_dom_rendering(self):
+ index = (REPO_ROOT / "site/beacon/index.html").read_text(encoding="utf-8")
+ ui = (REPO_ROOT / "site/beacon/ui.js").read_text(encoding="utf-8")
+
+ self.assertIn('id="agent-search-input"', index)
+ self.assertIn('role="combobox"', index)
+ self.assertIn('id="agent-search-results"', index)
+ self.assertIn("searchResults.replaceChildren()", ui)
+ self.assertIn("name.textContent = agent.name || agent.id", ui)
+ self.assertIn("selectAgent(agent.id)", ui)
+
+
class TestBeaconAtlasDataIntegrity(unittest.TestCase):
"""Test data integrity and consistency."""
@@ -377,6 +454,7 @@ def run_tests():
# Add test classes
suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasAPI))
suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasVisualization))
+ suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasAgentSearch))
suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasDataIntegrity))
suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasIntegration))