#!/usr/bin/env python3 """ Basecamp Discovery — scans Docker network for AI services and auto-generates tools. No hardcoded URLs or keys. It finds what's running and builds the toolkit dynamically. Usage: python3 discover.py scan # scan network, print discovered services python3 discover.py config # generate basecamp_config.json python3 discover.py tools # generate tavern tools from discovered services python3 discover.py serve # run the connect screen TUI python3 discover.py env # write Hermes runtime env file from saved config """ import json import os import sys import socket import subprocess import base64 import concurrent.futures import urllib.request import urllib.error from pathlib import Path CONFIG_FILE = Path(os.environ.get("BASECAMP_CONFIG", "/root/.hermes/basecamp_config.json")) DISCOVERY_FILE = Path(os.environ.get("BASECAMP_DISCOVERY", "/root/.hermes/discovery.json")) # ── Service fingerprints ── # Each probe has: paths, method, optional headers, match function, parse function SERVICE_PROBES = { "ollama": { "paths": ["/api/tags"], "method": "GET", "match": lambda r: '"models"' in r, "parse": lambda r: { "models": [m["name"] for m in json.loads(r).get("models", [])] }, "label": "Ollama (GGUF inference)", "icon": "LLM", }, "tabbyapi": { "paths": ["/v1/models"], "method": "GET", "match": lambda r: '"data"' in r and ('"model"' in r.lower() or '"owned_by"' in r or '"id"' in r), "match_auth": lambda r: '"detail"' in r and "api key" in r.lower(), "parse": lambda r: { "models": [m["id"] for m in json.loads(r).get("data", [])] }, "label": "TabbyAPI / OpenAI-compatible (EXL3/EXL2 inference)", "icon": "EXL", "needs_auth": True, "auth_type": "bearer", }, "vllm": { "paths": ["/v1/models"], "method": "GET", "match": lambda r: '"vllm"' in r.lower() or ('"object":"list"' in r and '"data"' in r), "parse": lambda r: { "models": [m["id"] for m in json.loads(r).get("data", [])] }, "label": "vLLM (high-throughput inference)", "icon": "LLM", }, "litellm": { "paths": ["/v1/models", "/health"], "method": "GET", "match": lambda r: '"data"' in r and ('"model"' in r.lower() or "litellm" in r.lower()), "parse": lambda r: { "models": [m.get("id", m.get("name", "")) for m in json.loads(r).get("data", [])] }, "label": "LiteLLM Proxy (multi-provider router)", "icon": "PXY", "needs_auth": True, "auth_type": "bearer", }, "localai": { "paths": ["/v1/models", "/models"], "method": "GET", "match": lambda r: '"data"' in r and '"id"' in r, "parse": lambda r: { "models": [m.get("id", m.get("name", "")) for m in json.loads(r).get("data", [])] }, "label": "LocalAI (drop-in OpenAI replacement)", "icon": "LLM", }, "llamacpp": { "paths": ["/health", "/v1/models"], "method": "GET", "match": lambda r: '"models"' in r or r.strip() == "ok", "parse": lambda r: {}, "label": "llama.cpp server", "icon": "LLM", }, "text-generation-webui": { "paths": ["/api/v1/model"], "method": "GET", "match": lambda r: '"model_name"' in r or '"result"' in r, "parse": lambda r: {}, "label": "Text Generation WebUI (oobabooga)", "icon": "TGW", }, "open-webui": { "paths": ["/", "/api/config"], "method": "GET", "match": lambda r: "open-webui" in r.lower() or "Open WebUI" in r, "parse": lambda r: {}, "label": "Open WebUI (LLM workspace)", "icon": "UI ", }, "sillytavern": { "paths": ["/api/status"], "method": "GET", "match": lambda r: "unauthorized" in r.lower() or "sillytavern" in r.lower() or '"jinja"' in r.lower() or '"result"' in r.lower(), "parse": lambda r: {}, "label": "SillyTavern (character chat)", "icon": "CHT", "needs_auth": True, "auth_type": "basic", }, "searxng": { "paths": ["/", "/search?q=test&format=json"], "method": "GET", "match": lambda r: "searxng" in r.lower(), "parse": lambda r: {}, "label": "SearXNG (private search)", "icon": "SRC", }, "mcpo": { "paths": ["/openapi.json"], "method": "GET", "match": lambda r: '"MCP OpenAPI Proxy"' in r, "parse": lambda r: {}, "label": "MCPO (MCP OpenAPI proxy)", "icon": "MCP", "needs_auth": True, "auth_type": "bearer", }, # ── More inference engines ── "koboldcpp": { "paths": ["/api/v1/model", "/v1/models"], "method": "GET", "match": lambda r: '"result"' in r or '"data"' in r, "parse": lambda r: { "models": [m["id"] for m in json.loads(r).get("data", [])] if '"data"' in r else [] }, "label": "KoboldCpp (GGML inference)", "icon": "KOB", }, "lmstudio": { "paths": ["/v1/models"], "method": "GET", "match": lambda r: '"data"' in r and '"id"' in r and "lmstudio" not in r.lower(), "parse": lambda r: { "models": [m["id"] for m in json.loads(r).get("data", [])] }, "label": "LM Studio (local model server)", "icon": "LMS", }, "sglang": { "paths": ["/v1/models"], "method": "GET", "match": lambda r: '"sglang"' in r.lower() or ('"object":"list"' in r and '"data"' in r and "vllm" not in r.lower()), "parse": lambda r: { "models": [m["id"] for m in json.loads(r).get("data", [])] }, "label": "SGLang (fast LLM serving)", "icon": "SGL", }, "llamafile": { "paths": ["/v1/models", "/health"], "method": "GET", "match": lambda r: '"data"' in r or '"status"' in r, "parse": lambda r: {}, "label": "llamafile (single-file LLM)", "icon": "LMF", }, "exo": { "paths": ["/v1/models"], "method": "GET", "match": lambda r: '"data"' in r and '"id"' in r, "parse": lambda r: {}, "label": "exo (distributed inference)", "icon": "EXO", }, "text-generation-inference": { "paths": ["/v1/models", "/info"], "method": "GET", "match": lambda r: '"model_id"' in r or '"models"' in r, "parse": lambda r: {}, "label": "TGI (Text Generation Inference)", "icon": "TGI", }, "aphrodite": { "paths": ["/v1/models"], "method": "GET", "match": lambda r: '"aphrodite"' in r.lower(), "parse": lambda r: {}, "label": "Aphrodite Engine (inference)", "icon": "APH", }, "llamapool": { "paths": ["/v1/models"], "method": "GET", "match": lambda r: '"llama-pool"' in r.lower() or "llama pool" in r.lower(), "parse": lambda r: {}, "label": "llama-pool (model router)", "icon": "PL", }, # ── More UIs / chat frontends ── "librechat": { "paths": ["/", "/api/health"], "method": "GET", "match": lambda r: "librechat" in r.lower() or "LibreChat" in r, "parse": lambda r: {}, "label": "LibreChat (multi-model chat UI)", "icon": "UI ", }, "lobechat": { "paths": ["/", "/api/status"], "method": "GET", "match": lambda r: "lobechat" in r.lower() or "LobeChat" in r, "parse": lambda r: {}, "label": "LobeChat (AI chat framework)", "icon": "UI ", }, "anythingllm": { "paths": ["/", "/api/system/endpoints"], "method": "GET", "match": lambda r: "anythingllm" in r.lower() or "AnythingLLM" in r, "parse": lambda r: {}, "label": "AnythingLLM (RAG workspace)", "icon": "RAG", }, "dify": { "paths": ["/", "/health"], "method": "GET", "match": lambda r: "dify" in r.lower() or "Dify" in r, "parse": lambda r: {}, "label": "Dify (LLM app platform)", "icon": "APP", }, "flowise": { "paths": ["/", "/api/v1/ping"], "method": "GET", "match": lambda r: "flowise" in r.lower() or "Flowise" in r, "parse": lambda r: {}, "label": "Flowise (no-code agent builder)", "icon": "FLW", }, "n8n": { "paths": ["/", "/healthz"], "method": "GET", "match": lambda r: "n8n" in r.lower() or "N8N" in r or "workflow" in r.lower(), "parse": lambda r: {}, "label": "n8n (workflow automation)", "icon": "WRK", }, "langflow": { "paths": ["/", "/api/v1/configs"], "method": "GET", "match": lambda r: "langflow" in r.lower() or "Langflow" in r, "parse": lambda r: {}, "label": "Langflow (agent builder)", "icon": "LGF", }, "ragflow": { "paths": ["/", "/api/v1/version"], "method": "GET", "match": lambda r: "ragflow" in r.lower() or "RAGFlow" in r, "parse": lambda r: {}, "label": "RAGFlow (RAG engine)", "icon": "RAG", }, "koboldai": { "paths": ["/", "/api/v1/config/status"], "method": "GET", "match": lambda r: "koboldai" in r.lower() or "KoboldAI" in r, "parse": lambda r: {}, "label": "KoboldAI (writing assistant UI)", "icon": "CHT", }, # ── Vector DBs (RAG backends) ── "qdrant": { "paths": ["/", "/readyz"], "method": "GET", "match": lambda r: "qdrant" in r.lower() or "Qdrant" in r, "parse": lambda r: {}, "label": "Qdrant (vector DB)", "icon": "VDB", }, "milvus": { "paths": ["/healthz", "/api/v1/health"], "method": "GET", "match": lambda r: "milvus" in r.lower() or "Milvus" in r, "parse": lambda r: {}, "label": "Milvus (vector DB)", "icon": "VDB", }, "chroma": { "paths": ["/api/v2/heartbeat", "/api/v2"], "method": "GET", "match": lambda r: "200" in r or "heartbeat" in r.lower() or "ok" in r.lower(), "parse": lambda r: {}, "label": "Chroma (vector DB)", "icon": "VDB", }, "weaviate": { "paths": ["/v1/meta"], "method": "GET", "match": lambda r: '"version"' in r and ("weaviate" in r.lower() or '"model"' in r), "parse": lambda r: {}, "label": "Weaviate (vector DB)", "icon": "VDB", }, # ── Image / media generation ── "comfyui": { "paths": ["/", "/system_stats"], "method": "GET", "match": lambda r: "comfyui" in r.lower() or "ComfyUI" in r or "Comfy" in r, "parse": lambda r: {}, "label": "ComfyUI (diffusion workflows)", "icon": "IMG", }, "stable-diffusion-webui": { "paths": ["/", "/sdapi/v1/options"], "method": "GET", "match": lambda r: "stable diffusion" in r.lower() or "gradio" in r.lower() or '"sd_model_checkpoint"' in r, "parse": lambda r: {}, "label": "Stable Diffusion WebUI (A1111)", "icon": "IMG", }, "invokeai": { "paths": ["/", "/api/v1/app/version"], "method": "GET", "match": lambda r: "invokeai" in r.lower() or "InvokeAI" in r, "parse": lambda r: {}, "label": "InvokeAI (image generation)", "icon": "IMG", }, # ── Speech / audio ── "whisper": { "paths": ["/", "/health"], "method": "GET", "match": lambda r: "whisper" in r.lower() or "Whisper" in r, "parse": lambda r: {}, "label": "Whisper (speech-to-text)", "icon": "STT", }, "piper": { "paths": ["/", "/health"], "method": "GET", "match": lambda r: "piper" in r.lower() or "Piper" in r, "parse": lambda r: {}, "label": "Piper (text-to-speech)", "icon": "TTS", }, # ── Gateways / proxies / model hubs ── "openrouter": { "paths": ["/api/v1/models"], "method": "GET", "match": lambda r: '"data"' in r and '"id"' in r and "openrouter" in r.lower(), "parse": lambda r: { "models": [m["id"] for m in json.loads(r).get("data", [])] }, "label": "OpenRouter (cloud model gateway)", "icon": "GWY", }, "kobold-horde": { "paths": ["/api/v1/status", "/"], "method": "GET", "match": lambda r: "kobold" in r.lower() or '"queued_requests"' in r, "parse": lambda r: {}, "label": "KoboldAI Horde (crowd inference)", "icon": "HDE", }, # ── Coding starter pack ── "code-server": { "paths": ["/", "/healthz"], "method": "GET", "match": lambda r: "code-server" in r.lower() or "coder" in r.lower() or "vscode" in r.lower() or "404: Not Found" in r, "parse": lambda r: {}, "label": "code-server (VS Code in browser)", "icon": "IDE", }, "tabby": { "paths": ["/v1/health", "/api/health"], "method": "GET", "match": lambda r: '"health"' in r or '"model"' in r or "tabby" in r.lower(), "parse": lambda r: {}, "label": "Tabby (AI code completion)", "icon": "CPL", }, "meilisearch": { "paths": ["/health", "/"], "method": "GET", "match": lambda r: '"status":"available"' in r or "meilisearch" in r.lower(), "parse": lambda r: {}, "label": "Meilisearch (full-text search)", "icon": "SRH", }, "mongo": { "paths": ["/"], "method": "GET", "match": lambda r: "mongodb" in r.lower() or "mongo" in r.lower() or "It looks like you are trying to access MongoDB over HTTP" in r, "parse": lambda r: {}, "label": "MongoDB (database)", "icon": "DB ", }, } # ── Port expectations ── # Each port maps to a list of service probe names to try (in order). PORT_SERVICE_MAP = { 11434: ["ollama"], 11435: ["ollama", "llamafile"], 11436: ["ollama"], 5000: ["tabbyapi", "localai", "litellm", "vllm", "llamacpp", "text-generation-webui", "whisper", "piper"], 8000: ["sillytavern", "mcpo", "text-generation-inference", "chroma"], 8001: ["mcpo"], 8080: ["searxng", "open-webui"], 3000: ["open-webui"], 5001: ["koboldcpp", "llamafile"], 1234: ["lmstudio"], 8002: ["sglang"], 4000: ["litellm", "exo"], 3001: ["lobechat", "dify"], 3080: ["librechat"], 7681: ["anythingllm"], 5678: ["n8n"], 7860: ["stable-diffusion-webui", "langflow"], 8188: ["comfyui"], 9090: ["invokeai", "qdrant"], 6333: ["qdrant"], 19530: ["milvus"], 8081: ["weaviate"], 1551: ["koboldai"], 2323: ["kobold-horde"], 8443: ["code-server"], 8082: ["tabby"], 7700: ["meilisearch"], 6334: ["qdrant"], 3210: ["lobechat"], 5678: ["n8n"], 27017: ["mongo"], 5432: ["postgres"], 8005: ["chroma"], } COMMON_PORTS = [11434, 5000, 8000, 8080, 8001, 3000, 11435, 11436, 5001, 1234, 8002, 4000, 3001, 3080, 7681, 5678, 7860, 8188, 9090, 6333, 19530, 8081, 1551, 2323, 8443, 8082, 7700, 6334, 3210, 27017, 5432, 8005] # ── HTTP helpers ── def http_get(url, timeout=1, headers=None): """Simple HTTP GET returning response text or None. For 401/403 responses, returns the error body (HTTPError bodies are read and returned) so auth-gated services can still be fingerprinted and listed as ``needs_auth`` — otherwise a service behind a 401 wall (TabbyAPI, SillyTavern, ...) is invisible to discovery. """ try: req = urllib.request.Request(url, method="GET") if headers: for k, v in headers.items(): req.add_header(k, v) with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as e: if e.code in (401, 403): try: return e.read().decode("utf-8", errors="replace") except Exception: return None return None except Exception: return None def http_post(url, data=None, headers=None, timeout=30, stream=False): """Simple HTTP POST, optionally streaming line by line.""" try: body = json.dumps(data).encode() if data else b"" hdrs = headers or {} hdrs.setdefault("Content-Type", "application/json") req = urllib.request.Request(url, data=body, headers=hdrs, method="POST") if stream: with urllib.request.urlopen(req, timeout=timeout) as resp: for line in resp: yield line.decode("utf-8", errors="replace") else: with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.read().decode("utf-8", errors="replace") except Exception: if stream: return return None def host_reachable(host, port, timeout=0.5): """Quick TCP connect check.""" try: ip = socket.gethostbyname(host) except Exception: return False try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) result = sock.connect_ex((ip, port)) sock.close() return result == 0 except Exception: return False # ── Network scanner ── def scan_network(): """Scan Docker network for AI services. Returns list of discovered services.""" discovered = [] candidates = [] # (name, host, port, network) # 1. Docker network inspect (if docker CLI available) try: result = subprocess.run( ["docker", "network", "ls", "--format", "{{.Name}}"], capture_output=True, text=True, timeout=5 ) networks = result.stdout.strip().split("\n") if result.stdout.strip() else [] except Exception: networks = [] for net in networks: if not net: continue try: result = subprocess.run( ["docker", "network", "inspect", net, "--format", "{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}"], capture_output=True, text=True, timeout=5 ) for line in result.stdout.strip().split("\n"): if not line.strip(): continue parts = line.strip().split() if len(parts) >= 2: name = parts[0] ip = parts[1].split("/")[0] if "/" in parts[1] else parts[1] for port in COMMON_PORTS: candidates.append((name, ip, port, net)) except Exception: pass # 2. Common Docker DNS service names common_dns_names = [ "tabbyapi", "ollama", "open-webui", "sillytavern", "searxng", "mcpo", "vllm", "litellm", "localai", "llamacpp", # coding starter pack containers (docker-compose.starter.yml names) "starter-code-server", "starter-tabby", "starter-qdrant", "starter-chroma", "starter-n8n", "starter-lobe-chat", "starter-anythingllm", "starter-librechat", "starter-meilisearch", "starter-flowise", "starter-postgres", "starter-mongo", "code-server", "qdrant", "chroma", "n8n", "lobe-chat", "anythingllm", "librechat", "meilisearch", "flowise", "postgres", "mongo", ] # Container-INTERNAL ports (the compose host:container mapping means the # container listens on the INTERNAL port, not the host-mapped one). # E.g. code-server maps 8443:8080 -> the container listens on 8080. CONTAINER_PORT_MAP = { "starter-code-server": 8080, "code-server": 8080, "starter-chroma": 8000, "chroma": 8000, "starter-flowise": 3000, "flowise": 3000, "starter-tabby": 8080, "tabby": 8080, "starter-librechat": 3080, "librechat": 3080, "starter-meilisearch": 7700, "meilisearch": 7700, "starter-postgres": 5432, "postgres": 5432, "starter-mongo": 27017, "mongo": 27017, "starter-anythingllm": 3001, "anythingllm": 3001, "starter-qdrant": 6333, "qdrant": 6333, "starter-n8n": 5678, "n8n": 5678, "starter-lobe-chat": 3210, "lobe-chat": 3210, } for name in common_dns_names: # Use the container-internal port when known; else the classic single # port for that service (NOT all 32 COMMON_PORTS — that explodes the # candidate count and starves the scan timeout). if name in CONTAINER_PORT_MAP: ports = [CONTAINER_PORT_MAP[name]] else: classic = { "tabbyapi": 5000, "ollama": 11434, "open-webui": 8080, "sillytavern": 8000, "searxng": 8080, "mcpo": 8000, "vllm": 8000, "litellm": 4000, "localai": 8080, "llamacpp": 8080, } ports = [classic.get(name, 8080)] for port in ports: candidates.append((name, name, port, "dns")) # 3. Localhost for port in COMMON_PORTS: candidates.append(("localhost", "127.0.0.1", port, "local")) # 4. Subnet sweep — ADAPTIVE discovery. When the docker socket isn't # mounted (no `docker network inspect`), the box must still find # services on ANY network it lands on, with ANY container names. # Strategy: first find LIVE hosts with a fast ping-scan of the /24, # then probe only live hosts for the well-known AI ports. This avoids # the 254x14 candidate explosion. if not networks: # only when docker CLI/socket unavailable sweep_ports = {11434, 5000, 8000, 8080, 3000, 3001, 11435, 6333, 5678, 3210, 7700, 27017, 5432, 8005} try: local_ip = socket.gethostbyname(socket.gethostname()) subnet = ".".join(local_ip.split(".")[:3]) # /24 except Exception: subnet = None if subnet: live_hosts = [] def _ping_one(octet): host = f"{subnet}.{octet}" return host if host_reachable(host, 11434, timeout=0.15) or \ host_reachable(host, 8000, timeout=0.15) else None with concurrent.futures.ThreadPoolExecutor(max_workers=64) as ex: for result in ex.map(_ping_one, range(1, 255)): if result: live_hosts.append(result) # Probe only live hosts (skip self, which localhost covers) for host in live_hosts: if host == local_ip: continue for port in sweep_ports: candidates.append((host, host, port, "sweep")) # 5. Cross-host / remote hosts — BASECAMP_EXTRA_HOSTS env (comma or # space separated host[:port] list). Lets the box reach services on # other machines (e.g. a GPU server across the LAN): each host gets # the common ports probed. extra = os.environ.get("BASECAMP_EXTRA_HOSTS", "").strip() if extra: for entry in extra.replace(",", " ").split(): entry = entry.strip() if not entry: continue if ":" in entry: host, _, port_s = entry.rpartition(":") try: ports = [int(port_s)] except ValueError: ports = COMMON_PORTS else: host, ports = entry, COMMON_PORTS for port in ports: candidates.append((host, host, port, "extra")) # Deduplicate — collapse starter-NAME and NAME (same container, same IP) seen = set() unique = [] for name, host, port, net in candidates: # Resolve both names to IP when possible and key on IP:port so # starter-code-server and code-server (same container) collapse. key_host = host try: key_host = socket.gethostbyname(host) except Exception: pass key = f"{key_host}:{port}" if key not in seen: seen.add(key) unique.append((name, host, port, net)) # Probe in parallel def probe_host(args): name, host, port, net = args if not host_reachable(host, port, timeout=0.5): return None base_url = f"http://{host}:{port}" # Postgres speaks a raw TCP protocol, not HTTP — sniff the version # banner (SSLRequest → server replies with version bytes) instead. if port == 5432: try: with socket.create_connection((host, port), timeout=2) as sock: # SSLRequest (8-byte magic) — server answers with 'N' sock.sendall(b"\x00\x00\x00\x08\x04\xd2\x16\x2f") banner = sock.recv(256) if banner: return { "type": "postgres", "label": "PostgreSQL (memory DB, pgvector)", "icon": "DB ", "host": name, "url": base_url, "port": port, "network": net, "details": {"banner": f"{len(banner)} bytes pg greeting"}, "needs_auth": False, "auth_type": "none", } except Exception: pass return None service_types = PORT_SERVICE_MAP.get(port, list(SERVICE_PROBES.keys())) # For DNS-name candidates with a known container-internal port, also # probe the service type implied by the name (e.g. starter-code-server # on 8080 would otherwise only be probed as searxng/open-webui). if net == "dns": for key, cport in CONTAINER_PORT_MAP.items(): if key == name and cport == port: # derive type: strip starter- prefix, match probe names base_type = name.replace("starter-", "") for probe_type in SERVICE_PROBES: if base_type == probe_type or base_type in probe_type: if probe_type not in service_types: service_types = list(service_types) + [probe_type] break break for svc_type in service_types: probe = SERVICE_PROBES.get(svc_type) if not probe: continue for path in probe["paths"]: url = f"{base_url}{path}" # Try without auth first resp = http_get(url, timeout=1) if resp and probe["match"](resp): try: details = probe["parse"](resp) except Exception: details = {} return { "type": svc_type, "label": probe["label"], "icon": probe["icon"], "host": name, "url": base_url, "port": port, "network": net, "details": details, "needs_auth": probe.get("needs_auth", False), "auth_type": probe.get("auth_type", "bearer"), } # Auth-gated service: the 401/403 body still identifies it # (e.g. TabbyAPI's {"detail":"Please provide an API key"}). # List it as needs_auth so the connect screen can prompt for a key. if ( probe.get("match_auth") and resp and probe["match_auth"](resp) ): return { "type": svc_type, "label": probe["label"], "icon": probe["icon"], "host": name, "url": base_url, "port": port, "network": net, "details": {}, "needs_auth": True, "auth_type": probe.get("auth_type", "bearer"), } # If service needs auth and we got nothing, try with common defaults if probe.get("needs_auth") and not resp: auth_headers = get_default_auth_headers(probe.get("auth_type", "bearer")) if auth_headers: resp = http_get(url, timeout=1, headers=auth_headers) if resp and probe["match"](resp): try: details = probe["parse"](resp) except Exception: details = {} return { "type": svc_type, "label": probe["label"], "icon": probe["icon"], "host": name, "url": base_url, "port": port, "network": net, "details": details, "needs_auth": True, "auth_type": probe.get("auth_type", "bearer"), "auth_worked_with_default": True, } return None executor = concurrent.futures.ThreadPoolExecutor(max_workers=50) futures = {executor.submit(probe_host, c): c for c in unique} try: for future in concurrent.futures.as_completed(futures, timeout=30): try: result = future.result(timeout=2) if result: discovered.append(result) except Exception: pass except concurrent.futures.TimeoutError: # NB: on py<3.11, concurrent.futures.TimeoutError is NOT builtins.TimeoutError pass # slow DNS/connect probes still running -- keep what we found executor.shutdown(wait=False, cancel_futures=True) # ── Global dedup (2026-08-10): the subnet sweep + gateway probing # surfaces the SAME service multiple times — via its container name # (open-webui:8080), via the host gateway (172.18.0.1:3000), via # loopback. Every consumer (wire audit, tavern status, skill, menu) # was seeing duplicates. Keep ONE entry per type, preferring the # container-name host; drop gateway/loopback copies. def _host_rank(s): host = str(s.get("url", "")).replace("http://", "").replace("https://", "").split(":")[0] if host.startswith("127.") or host == "localhost": return 3 # loopback = bundled copy if host.startswith(("172.", "10.", "192.168.")): return 2 # gateway/host-mapped copy return 1 # container name — the best host by_type = {} for s in discovered: t = s.get("type", "") rank = _host_rank(s) if t not in by_type or rank < by_type[t][1]: by_type[t] = (s, rank) deduped_services = [] for t, (s, rank) in by_type.items(): deduped_services.append(s) discovered = deduped_services return discovered def get_default_auth_headers(auth_type): """Return default auth headers to try during discovery (not stored permanently).""" if auth_type == "basic": # Try admin/tabby (SillyTavern default in our stack) creds = base64.b64encode(b"admin:tabby").decode() return {"Authorization": f"Basic {creds}"} if auth_type == "bearer": # No default bearer — must be provided by user return None return None def save_discovery(services): DISCOVERY_FILE.parent.mkdir(parents=True, exist_ok=True) with open(DISCOVERY_FILE, "w") as f: json.dump(services, f, indent=2) # ── Config generation ── def generate_config(services, selected=None, auth_keys=None): """Generate basecamp_config.json from discovered services.""" config = { "inference": None, "secondary": None, "search": None, "mcp": None, "ui": None, "ollama_models": [], "openai_models": [], "api_keys": {}, } auth_keys = auth_keys or {} if selected: for role, svc_idx in selected.items(): if svc_idx is not None and svc_idx < len(services): svc = services[svc_idx] entry = {"type": svc["type"], "url": svc["url"], "label": svc["label"]} if svc.get("needs_auth"): key = auth_keys.get(svc["url"], "") if key: entry["api_key"] = key config["api_keys"][svc["url"]] = key if role in ("inference", "secondary"): config[role] = entry if svc.get("details", {}).get("models"): if svc["type"] == "ollama": config["ollama_models"] = svc["details"]["models"] else: config["openai_models"] = svc["details"]["models"] else: config[role] = entry else: # Auto-select. Hermes hard-requires >=64K context (its system prompt # alone is ~16K), so PREFER an Ollama endpoint as inference: Ollama # models are typically served with a 64K+ window, whereas TabbyAPI / # vLLM endpoints on small GPUs are often capped at 8K (exl2/exl3 KV # cache limits) and would be rejected by hermes. OpenAI-compatible # engines are still listed at the connect screen for interactive pick. ollamas = [s for s in services if s["type"] == "ollama"] openai_engines = [s for s in services if s["type"] in ( "tabbyapi", "vllm", "litellm", "localai", "llamacpp", "text-generation-webui")] if ollamas: # Prefer an EXTERNAL ollama (the user's stack) over basecamp's own # bundled one (127.0.0.1 / localhost) — the bundled ollama is the # fallback for when nothing else exists. Discovery order is racy # (parallel probes), so pick deterministically. external = [s for s in ollamas if not str(s["url"]).replace("http://", "").replace("https://", "").startswith(("127.", "localhost", "::1"))] ordered = external + [s for s in ollamas if s not in external] primary = ordered[0] config["inference"] = { "type": primary["type"], "url": primary["url"], "label": primary["label"]} config["openai_models"] = [] # Exclude pre-existing basecamp/ aliases from the model list — # they're created by write_basecamp_env, not user models. config["ollama_models"] = [ m for m in primary.get("details", {}).get("models", []) if not str(m).startswith("basecamp/") ] if len(ordered) > 1: config["secondary"] = {"type": "ollama", "url": ordered[1]["url"], "label": ordered[1]["label"]} elif openai_engines: primary = openai_engines[0] config["inference"] = { "type": primary["type"], "url": primary["url"], "label": primary["label"]} config["openai_models"] = primary.get("details", {}).get("models", []) for svc in services: if svc["type"] == "searxng": config["search"] = {"type": "searxng", "url": svc["url"], "label": svc["label"]} elif svc["type"] == "mcpo": config["mcp"] = {"type": "mcpo", "url": svc["url"], "label": svc["label"]} elif svc["type"] in ("open-webui", "sillytavern") and not config["ui"]: config["ui"] = {"type": svc["type"], "url": svc["url"], "label": svc["label"]} CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) with open(CONFIG_FILE, "w") as f: json.dump(config, f, indent=2) try: os.chmod(CONFIG_FILE, 0o600) # holds API keys -- lock it down except Exception: pass return config # ── Dynamic tavern script generation ── def generate_tavern_script(config): """Generate a dynamic tavern CLI based on discovered config.""" inf = config.get("inference") or {} sec = config.get("secondary") or {} search = config.get("search") or {} mcp = config.get("mcp") or {} inf_url = inf.get("url", "") inf_type = inf.get("type", "") sec_url = sec.get("url", "") search_url = search.get("url", "") mcp_url = mcp.get("url", "") api_key = inf.get("api_key", "") or config.get("api_keys", {}).get(inf_url, "") mcp_key = config.get("api_keys", {}).get(mcp_url, "mcp-secret-key") ollama_models = config.get("ollama_models", []) openai_models = config.get("openai_models", []) default_ollama = ollama_models[0] if ollama_models else "llama3.1:8b" default_openai = openai_models[0] if openai_models else "" script = f'''#!/bin/bash # tavern — auto-generated by basecamp discovery # Connected to: {inf.get("label", "none")} CONFIG="{CONFIG_FILE}" INFER_URL="{inf_url}" INFER_TYPE="{inf_type}" OLLAMA_URL="{sec_url}" SEARCH_URL="{search_url}" MCP_URL="{mcp_url}" API_KEY="{api_key}" MCP_KEY="{mcp_key}" DEFAULT_OLLAMA_MODEL="{default_ollama}" DEFAULT_OPENAI_MODEL="{default_openai}" cmd="${{1:-help}}" shift 2>/dev/null case "$cmd" in status) echo "=== Basecamp Connected Services ===" # FIX 2026-08-10: list EVERY discovered service (link:port), not # just the five role slots. Reads the discovery file saved at boot. if [ -f "{DISCOVERY_FILE}" ]; then python3 -c " import json try: svcs = json.load(open('{DISCOVERY_FILE}')) except Exception: svcs = [] for s in sorted(svcs, key=lambda x: x.get('type','')): auth = ' (auth)' if s.get('needs_auth') else '' print(' ' + s.get('icon',' ') + ' ' + (s.get('label','?')[:40]).ljust(40) + ' ' + s.get('url','?') + auth) print(' -- ' + str(len(svcs)) + ' service(s) discovered --') " fi ''' for role in ("inference", "secondary", "search", "mcp", "ui"): svc = config.get(role) if svc: icon = {"inference": "LLM", "secondary": "GGUF", "search": "SRC", "mcp": "MCP", "ui": "UI "}.get(role, " ") script += f' echo " {icon} {svc["label"]:40s} {svc["url"]}"\n' script += ''' ;; models) echo "=== Available Models ===" ''' if openai_models: for m in openai_models: script += f' echo " OpenAI-compatible: {m}"\n' if ollama_models: for m in ollama_models: script += f' echo " Ollama: {m}"\n' if not openai_models and inf_url: script += f' curl -s "{inf_url}/v1/models" -H "Authorization: Bearer $API_KEY" 2>/dev/null | python3 -c "import sys,json; [print(f\' OpenAI: {{m[\"id\"]}}\') for m in json.load(sys.stdin).get(\'data\',[])]" 2>/dev/null\n' if not ollama_models and sec_url: script += f' curl -s "{sec_url}/api/tags" 2>/dev/null | python3 -c "import sys,json; [print(f\' Ollama: {{m[\"name\"]}}\') for m in json.load(sys.stdin).get(\'models\',[])]" 2>/dev/null\n' # Chat command with streaming script += ''' ;; chat) MSG="${1:-Hello}" MAX="${2:-256}" STREAM="${3:-true}" echo "=== Chat ===" ''' if inf_type == "ollama": script += f''' if [ "$STREAM" = "true" ]; then curl -s "{inf_url}/api/chat" -H "Content-Type: application/json" \\ -d '{{"model":"$DEFAULT_OLLAMA_MODEL","messages":[{{"role":"user","content":"$MSG"}}],"stream":true}}' 2>/dev/null | \\ python3 -c " import sys,json for line in sys.stdin: try: d=json.loads(line) c=d.get('message',{{}}).get('content','') if c: print(c,end='',flush=True) except: pass print() " 2>/dev/null else curl -s "{inf_url}/api/chat" -H "Content-Type: application/json" \\ -d '{{"model":"$DEFAULT_OLLAMA_MODEL","messages":[{{"role":"user","content":"$MSG"}}],"stream":false}}' 2>/dev/null | \\ python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('message',{{}}).get('content','(no response)'))" 2>/dev/null fi ''' elif inf_url: model_ref = '$DEFAULT_OPENAI_MODEL' if default_openai else '$MODEL' if not default_openai: script += f''' MODEL=$(curl -s "{inf_url}/v1/models" -H "Authorization: Bearer $API_KEY" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data'][0]['id'] if d.get('data') else '')" 2>/dev/null) ''' else: script += f' MODEL="$DEFAULT_OPENAI_MODEL"\n' script += f''' echo "Model: $MODEL" echo "You: $MSG" echo -n "AI: " if [ "$STREAM" = "true" ]; then curl -s "{inf_url}/v1/chat/completions" -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \\ -d '{{"model":"$MODEL","messages":[{{"role":"user","content":"$MSG"}}],"max_tokens":$MAX,"stream":true}}' 2>/dev/null | \\ python3 -c " import sys,json for line in sys.stdin: line=line.strip() if not line or not line.startswith('data:'): continue data=line[5:].strip() if data=='[DONE]': break try: d=json.loads(data) c=d['choices'][0].get('delta',{{}}).get('content','') if c: print(c,end='',flush=True) except: pass print() " 2>/dev/null else curl -s "{inf_url}/v1/chat/completions" -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \\ -d '{{"model":"$MODEL","messages":[{{"role":"user","content":"$MSG"}}],"max_tokens":$MAX}}' 2>/dev/null | \\ python3 -c "import sys,json; d=json.load(sys.stdin); print(d['choices'][0]['message']['content'])" 2>/dev/null fi ''' # Search command script += ''' ;; search) QUERY="${1:?Usage: tavern search \\"query\\"}" NUM="${2:-5}" echo "=== Search: $QUERY ===" ''' if search_url: script += f''' curl -s "{search_url}/search?q=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$QUERY'))")&format=json" 2>/dev/null | python3 -c " import sys,json d=json.load(sys.stdin) for i,r in enumerate(d.get('results',[])[:$NUM],1): print(f' {{i}}. {{r.get(\"title\",\"\")}}') print(f' {{r.get(\"url\",\"\")}}') print(f' {{r.get(\"content\",\"\")[:120]}}') print() " 2>/dev/null || echo " Search unavailable" ''' # MCP command script += ''' ;; mcp) if [ -z "$1" ]; then echo "=== MCP Tools ===" ''' if mcp_url: script += f''' # Try server-prefixed paths for prefix in "/host-master" ""; do RESP=$(curl -s "{mcp_url}${{prefix}}/openapi.json" 2>/dev/null) if echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('paths') else 1)" 2>/dev/null; then echo "$RESP" | python3 -c " import sys,json d=json.load(sys.stdin) for p,methods in d.get('paths',{{}}).items(): for m,info in methods.items(): print(f' {{m.upper():4s}} {mcp_url}${{prefix}}{{p:30s}} — {{info.get(\"summary\",\"\")}}') " 2>/dev/null break fi done ''' script += ''' else TOOL="$1"; shift BODY="${1:-{}}" echo "=== MCP: $TOOL ===" ''' if mcp_url: script += f''' for prefix in "/host-master" ""; do RESP=$(curl -s -X POST "{mcp_url}${{prefix}}/${{TOOL}}" -H "Authorization: Bearer $MCP_KEY" -H "Content-Type: application/json" -d "$BODY" 2>/dev/null) if echo "$RESP" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then echo "$RESP" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin), indent=2))" break fi done ''' script += ''' fi ;; config) echo "=== Basecamp Configuration ===" cat "$CONFIG" 2>/dev/null || echo " No config saved" ;; rediscover) echo "Re-running discovery..." python3 /opt/basecamp/discover.py serve ;; wire) echo "Running stack wiring audit..." python3 /opt/basecamp/discover.py wire ;; self-check|selfcheck|doctor) echo "Running self-check (verifying every service)..." python3 /opt/basecamp/discover.py self-check ;; update-check|updatecheck|updates) echo "Checking registries for newer versions..." python3 /opt/basecamp/discover.py update-check ;; help|--help|-h) echo "tavern — Basecamp AI stack toolkit (auto-generated)" echo "" echo "Commands:" echo " status Show connected services" echo " models List available models" echo " chat \\\"msg\\\" [tokens] [stream] Chat with primary inference (stream=true/false)" echo " search \\\"query\\\" [n] Search the web" echo " mcp List MCP tools" echo " mcp [json] Call an MCP tool" echo " config Show saved configuration" echo " wire Audit stack wiring + print fixes" echo " self-check Verify every service (self-heal #1)" echo " update-check Check registries for newer versions (self-heal #2)" echo " rediscover Re-scan for services" ;; *) echo "Unknown: $cmd. Run: tavern help" exit 1 ;; esac ''' return script # ── Connect screen TUI ── def connect_screen(services): """Run the TUI connect screen for first-run service selection.""" print("\033[2J\033[H", end="") print("=" * 60) print(" BASECAMP — AI Agent + Local Inference") print(" Connect Screen") print("=" * 60) print() if not services: print(" No AI services found on the network.") print() print(" Basecamp will run with its own Ollama instance.") print(" You can re-discover later with: tavern rediscover") print() print(" Press Enter to continue...") input() return None, {} print(f" Discovered {len(services)} service(s):") print() for i, svc in enumerate(services): icon = svc["icon"] label = svc["label"] url = svc["url"] models = svc.get("details", {}).get("models", []) auth = " [needs auth]" if svc.get("needs_auth") else "" model_str = f" [{len(models)} model(s)]" if models else "" print(f" [{i:2d}] {icon} {label}{model_str}{auth}") print(f" -> {url}") print() print("-" * 60) print() # Group by role inference_opts = [(i, s) for i, s in enumerate(services) if s["type"] in ("ollama", "tabbyapi", "vllm", "litellm", "localai", "llamacpp", "text-generation-webui")] search_opts = [(i, s) for i, s in enumerate(services) if s["type"] == "searxng"] mcp_opts = [(i, s) for i, s in enumerate(services) if s["type"] == "mcpo"] selected = {} auth_keys = {} # Select inference — explained in plain language for new users. if inference_opts: # Dedupe: the subnet sweep + gateway probing surfaces the SAME # engine multiple times — via its container name (tabbyapi:5000), # via the host gateway (172.18.0.1:5000 = host-mapped port), and # the bundled loopback ollama (127.0.0.1 — which is already # option 0). Keep ONE entry per type, preferring the container # name; drop gateway and loopback duplicates. def _entry_rank(s): host = s["url"].replace("http://", "").replace("https://", "").split(":")[0] if host.startswith("127.") or host == "localhost": return 3 # loopback = bundled (covered by option 0) if host.startswith("172.") or host.startswith("10.") or host.startswith("192.168."): return 2 # gateway/host-mapped view of a named container return 1 # container name — the best way to reach it best_by_type = {} for i, s in inference_opts: t = s["type"] rank = _entry_rank(s) if t not in best_by_type or rank < best_by_type[t][1]: best_by_type[t] = (i, rank) deduped = [] for i, s in inference_opts: t = s["type"] if best_by_type.get(t) and best_by_type[t][0] == i and best_by_type[t][1] < 3: deduped.append((i, s)) # If a type only had gateway/loopback entries, keep the best one for t, (i, rank) in best_by_type.items(): if rank >= 3: if not any(s["type"] == t for _, s in deduped): deduped.append((i, inference_opts[i][1])) inference_opts = deduped print(" PRIMARY inference engine — this is the 'brain' the agent uses.") print(" Basecamp's own is the best starting choice: it comes with all") print(" the fix recipes and stack knowledge pre-loaded.") print() print(" Suggested (recommended):") print(" 0. Use basecamp's own Ollama (recommended)") print(" - Built-in brain, no setup, everything pre-wired.") print(" - Start here. You can switch anytime.") print() print(" Or pick one of the engines found on your network:") for idx, (i, s) in enumerate(inference_opts): friendly = { "ollama": "Ollama — a local model server (free, runs on YOUR machine)", "tabbyapi": "TabbyAPI — your GPU model server (EXL3/EXL2, fast)", "vllm": "vLLM — high-throughput model server", "litellm": "LiteLLM — proxy to many providers", "localai": "LocalAI — local OpenAI-compatible server", "llamacpp": "llama.cpp — local model server", }.get(s["type"], s["type"]) print(f" {idx+1}. {s['label']} ({s['url']})") print(f" {friendly}") print() print(" 💡 The one in the sky (cloud, e.g. Nous portal / OpenRouter) can") print(" be added later in hermes settings — no need to pick it now.") print(" You can change this choice ANYTIME by re-running:") print(" basecamp rediscover") print() choice = input(" Choice [0]: ").strip() or "0" try: choice = int(choice) if 0 < choice <= len(inference_opts): selected["inference"] = inference_opts[choice-1][0] except ValueError: selected["inference"] = inference_opts[0][0] # Prompt for API key if needed if selected.get("inference") is not None: svc = services[selected["inference"]] if svc.get("needs_auth"): print() print(f" {svc['label']} requires authentication ({svc.get('auth_type', 'bearer')}).") key = input(" Enter API key (or press Enter to skip): ").strip() if key: auth_keys[svc["url"]] = key # Secondary inference if len(inference_opts) > 1 and selected.get("inference") is not None: print() print(" SECONDARY inference engine (optional) — a fallback brain") print(" if the primary one ever goes down. 0 to skip.") remaining = [(i, s) for i, s in inference_opts if i != selected["inference"]] for idx, (i, s) in enumerate(remaining): print(f" {idx+1}. {s['label']} ({s['url']})") print(f" 0. Skip (recommended to start)") choice = input(" Choice [0]: ").strip() or "0" try: choice = int(choice) if 0 < choice <= len(remaining): selected["secondary"] = remaining[choice-1][0] except ValueError: pass else: print(" No external inference engines found.") print(" Basecamp will use its own Ollama.") # Select search if search_opts: print() print(" Private search found:") for idx, (i, s) in enumerate(search_opts): print(f" {idx+1}. {s['label']} ({s['url']})") choice = input(" Use for search? [1]: ").strip() or "1" try: choice = int(choice) if 0 < choice <= len(search_opts): selected["search"] = search_opts[choice-1][0] except ValueError: selected["search"] = search_opts[0][0] # Select MCP if mcp_opts: print() print(" MCP tool server found:") for idx, (i, s) in enumerate(mcp_opts): print(f" {idx+1}. {s['label']} ({s['url']})") choice = input(" Use MCP tools? [1]: ").strip() or "1" try: choice = int(choice) if 0 < choice <= len(mcp_opts): selected["mcp"] = mcp_opts[choice-1][0] # Prompt for MCP key print() mcp_key = input(" MCP API key (or press Enter for 'mcp-secret-key'): ").strip() if mcp_key: auth_keys[services[mcp_opts[choice-1][0]]["url"]] = mcp_key except ValueError: selected["mcp"] = mcp_opts[0][0] print() print("-" * 60) print() print(" Configuration summary:") for role in ("inference", "secondary", "search", "mcp"): if role in selected and selected[role] is not None: svc = services[selected[role]] print(f" {role:12s} -> {svc['label']} ({svc['url']})") else: print(f" {role:12s} -> (none)") print() confirm = input(" Save and continue? [Y/n]: ").strip().lower() or "y" if confirm != "y": print(" Aborted.") return None, {} return selected, auth_keys def write_basecamp_env(config): """Write Hermes runtime env vars to a basecamp-owned file (chmod 600). Hermes reads OPENAI_BASE_URL / OPENAI_API_KEY / HERMES_MODEL from the environment, so we configure it process-scoped: the entrypoint sources this file and execs hermes. We NEVER write config.yaml or .env inside a hermes home we don't own -- that is how host installs get clobbered. """ inf = config.get("inference") or {} sec = config.get("secondary") or {} primary = inf or sec if not primary: primary = {"type": "ollama", "url": "http://127.0.0.1:11434", "label": "Basecamp Ollama"} if primary.get("type") == "ollama": base_url = primary["url"].rstrip("/") + "/v1" api_key = "ollama" else: base_url = primary["url"].rstrip("/") + "/v1" api_key = config.get("api_keys", {}).get(primary.get("url", ""), "") or "ollama" models = config.get("openai_models") or config.get("ollama_models") or ["llama3.1:8b"] # Never pick a pre-namespaced basecamp/ alias as the base model — it would # get double-prefixed below (basecamp/basecamp/...). Prefer a bare id. bare_models = [m for m in models if not str(m).startswith("basecamp/")] # SMART DEFAULT (2026-08-10): prefer a FAST model for the first-run # experience. A 70B on a 12GB card is CPU-offloaded (~4min load, ~3 tok/s) # and feels broken. Small models (<=16B) answer instantly. The big model # stays selectable via `basecamp rediscover` / hermes -m later. def _model_size_rank(m): m = str(m).lower() for size, rank in (("70b", 5), ("72b", 5), ("405b", 6), ("34b", 4), ("32b", 4), ("27b", 4), ("13b", 3), ("14b", 3), ("8b", 2), ("7b", 2), ("3b", 1), ("1.5b", 1), ("0.5b", 0), ("tiny", 0), ("small", 1)): if size in m: return rank return 3 # unknown size -> middle fast_models = sorted(bare_models, key=_model_size_rank) default_model = (fast_models[0] if fast_models else models[0]) if models else "llama3.1:8b" # Namespace the model as basecamp/ so the hermes status/model # readout clearly identifies this as the Basecamp instance. Only for # ollama engines (they support aliasing via /api/copy); openai-compatible # engines get the bare id. Best-effort: if the alias already exists or the # server rejects it, fall back to the bare model id. if primary.get("type") == "ollama": ns_model = f"basecamp/{default_model}" root_url = primary["url"].rstrip("/") existing = http_get(f"{root_url}/api/tags", timeout=2) or "" if f'"{ns_model}"' in existing: default_model = ns_model else: http_post( f"{root_url}/api/copy", data={"source": default_model, "destination": ns_model}, timeout=5, ) # VERIFY the alias actually exists before using it — /api/copy can # return 404-with-body (source missing) which http_post surfaces as # a non-None string, so a truthiness check would wrongly pass. after = http_get(f"{root_url}/api/tags", timeout=2) or "" if f'"{ns_model}"' in after: default_model = ns_model env_path = Path(os.environ.get("BASECAMP_ENV_FILE", "/opt/basecamp/basecamp.env")) env_path.parent.mkdir(parents=True, exist_ok=True) env_path.write_text( f'export OPENAI_BASE_URL="{base_url}"\n' f'export OPENAI_API_KEY="{api_key}"\n' # namespaced model id (basecamp/) when the engine supports it — # the readout then shows Basecamp's identity; falls back to the bare # id for engines that can't alias (tabbyapi/vllm/etc). f'export HERMES_MODEL="{default_model}"\n' ) try: os.chmod(env_path, 0o600) except Exception: pass print(" Hermes Agent configured (env-scoped -- no install files touched):") print(f" Provider: {primary.get('label', 'Basecamp Ollama')}") print(f" Model: {default_model}") print(f" Base URL: {base_url}") print(f" Env file: {env_path} (chmod 600)") return default_model, base_url def generate_stack_skill(config, services): """Write a 'basecamp-stack' SKILL.md into basecamp's own HERMES_HOME. This is how the container's Hermes LEARNS about the stack it discovered: the skill body carries the live service list, wiring facts, per-service FIX RECIPES for everything discovered, and how to use the tavern toolkit. When the user asks Hermes "why doesn't my Open WebUI work?", the agent already has the ground truth AND the exact fix — and can run `tavern wire` itself for a live audit. Writes ONLY inside basecamp's own HERMES_HOME (never a host install). """ # Per-service fix recipes (single source of truth in recipes.py) try: import recipes as _recipes except Exception: _recipes = None home = Path(os.environ.get("HERMES_HOME", "/root/.hermes")) skill_dir = home / "skills" / "basecamp-stack" skill_dir.mkdir(parents=True, exist_ok=True) inf = (config or {}).get("inference") or {} sec = (config or {}).get("secondary") or {} search = (config or {}).get("search") or {} mcp = (config or {}).get("mcp") or {} ui = (config or {}).get("ui") or {} model = (config or {}).get("ollama_models") or (config or {}).get("openai_models") or [] model_str = ", ".join(str(m) for m in model) if model else "(none discovered)" lines = [] lines.append("---") lines.append("name: basecamp-stack") lines.append("description: Your discovered local AI stack — services, wiring, and how to use the tavern toolkit. Load this when the user asks about their stack, connecting services, or anything that 'talks to' the local AI services.") lines.append("---") lines.append("") lines.append("# Basecamp Stack (live discovery)") lines.append("") lines.append("## ⚠️ FIRST RULE — how to answer stack questions") lines.append("") lines.append("When the user asks about connections, services, ports, wiring, or") lines.append("'does X work', you MUST use the tavern toolkit commands below. Do NOT") lines.append("invent commands, flags, or plugins — they do not exist and will fail.") lines.append("") lines.append("- **`tavern status`** — list every discovered service with link:port.") lines.append(" Use for: 'what's connected', 'show me my services', 'ports'.") lines.append("- **`tavern self-check`** — verify every service's health (HTTP + TCP).") lines.append(" Use for: 'verify my connections', 'is everything working', 'test my stack'.") lines.append("- **`tavern wire`** — audit what should talk to what + exact fixes.") lines.append(" Use for: 'why doesn't X work', 'connect X to Y', 'fix my wiring'.") lines.append("- **`tavern rediscover`** — re-scan the network for new/changed services.") lines.append("") lines.append("To run these: use the terminal tool and execute `tavern status` (etc.)") lines.append("verbatim. Then summarize the OUTPUT to the user in plain words — don't") lines.append("just dump it, and never answer a connectivity question without running") lines.append("one of these commands first.") lines.append("") lines.append("You are running inside Basecamp, which discovered these AI services on the") lines.append("Docker network at boot. This is the ground truth — trust it over memory.") lines.append("") lines.append("## Services") lines.append("") for s in services: auth = " [auth required]" if s.get("needs_auth") else "" lines.append(f"- **{s['label']}**{auth} — {s['url']} (type: {s['type']})") if s.get("details", {}).get("models"): lines.append(f" - models: {', '.join(str(m) for m in s['details']['models'])}") lines.append("") lines.append("## Current wiring") lines.append("") if inf: lines.append(f"- Inference (primary): **{inf.get('label')}** — {inf.get('url')}") if sec: lines.append(f"- Inference (secondary): **{sec.get('label')}** — {sec.get('url')}") if search: lines.append(f"- Search: **{search.get('label')}** — {search.get('url')}") if mcp: lines.append(f"- MCP: **{mcp.get('label')}** — {mcp.get('url')}") if ui: lines.append(f"- UI: **{ui.get('label')}** — {ui.get('url')}") lines.append(f"- Models: {model_str}") lines.append("") lines.append("## Helping the user wire their stack") lines.append("") lines.append("When the user asks why a service doesn't work or how to connect two services:") lines.append("") lines.append("1. Run `tavern wire` (or `python3 /opt/basecamp/discover.py wire`) for a live") lines.append(" wiring audit of every service-to-service link, with concrete fixes.") lines.append("2. Explain in plain terms what the audit found — don't just dump it.") lines.append("3. For host-side config (docker-compose env vars, config files mounted from") lines.append(" the host), give the EXACT one-line fix to copy-paste. Basecamp is") lines.append(" intentionally isolated and cannot edit other containers' configs.") lines.append("4. `tavern status` shows what Basecamp itself is connected to.") lines.append("") lines.append("## Tavern toolkit") lines.append("") lines.append("- `tavern status` — services Basecamp is connected to") lines.append("- `tavern models` — available models") lines.append("- `tavern chat \"msg\"` — chat with the primary inference engine") lines.append("- `tavern search \"query\"` — web search via discovered SearXNG") lines.append("- `tavern mcp` — list MCP tools") lines.append("- `tavern wire` — stack wiring audit") lines.append("- `tavern rediscover` — re-scan the network") lines.append("") # Per-service FIX RECIPES — ALL of them, inline, every boot. The agent # must be able to fix ANY connection issue, including services that # aren't currently on the network. 44 recipes is fine to carry in full; # the reference file stays for on-disk completeness. if _recipes is not None: discovered_types = sorted({s.get("type") for s in services}) all_types = list(_recipes.WIRING_RECIPES.keys()) recipe_lines = _recipes.wiring_recipe_markdown(all_types) if recipe_lines: lines.append("## Wiring & fix recipes (ALL service types basecamp knows)") lines.append("") lines.append("These are the exact fixes for every service type this box can") lines.append("encounter — config location, keys, one-line fix, and verification.") lines.append("When the user asks how to fix or wire ANY of these, use its recipe") lines.append("directly. Be concrete and give the one-line fix.") lines.append("") lines.extend(recipe_lines) lines.append("") # Full reference for every service type basecamp knows (even ones not # discovered here) — written next to the skill for on-demand reading. try: ref_dir = skill_dir / "references" ref_dir.mkdir(parents=True, exist_ok=True) full = _recipes.wiring_recipe_markdown() ref_path = ref_dir / "wiring-recipes.md" ref_path.write_text( "# Basecamp wiring recipes (all service types)\n\n" + "\n".join(full)) try: os.chmod(ref_path, 0o600) except Exception: pass lines.append("A full reference for ALL service types basecamp knows lives at") lines.append("`references/wiring-recipes.md` — read it when the user has a") lines.append("service that isn't in the inline recipes above.") lines.append("") except Exception: pass skill_path = skill_dir / "SKILL.md" skill_path.write_text("\n".join(lines)) try: os.chmod(skill_path, 0o600) except Exception: pass return skill_path # ── Main ── def wire_audit(services, config=None): """Audit how the discovered services are wired together and report fixes. For each service pair that SHOULD be connected (open-webui -> ollama, sillytavern -> inference, mcpo -> its servers, hermes -> inference), probe the link and report: OK (green) / BROKEN (red) / UNKNOWN, plus the exact fix a human (or host-side tooling) can apply. This is the "wiring wizard" for noobs: it tells you the one line you need, instead of you spelunking through buried settings pages. """ if not services: print(" No services found to audit.") return inf = (config or {}).get("inference") or {} sec = (config or {}).get("secondary") or {} # Build a lookup: type -> list of service dicts by_type = {} for s in services: by_type.setdefault(s["type"], []).append(s) print() print(" ═══ STACK WIRING AUDIT ═══") print(" (what should talk to what, and whether it does)") print() checks = [] # (status, service, finding, fix) # ── 1. Open WebUI -> Ollama / OpenAI engines ── for ui in by_type.get("open-webui", []): # Probe what open-webui can see. /api/config is public. cfg = http_get(f"{ui['url'].rstrip('/')}/api/config", timeout=4) if cfg is None: checks.append(("RED", f"Open WebUI {ui['url']}", "not reachable", "check the container is running and on the same network")) continue # Try the public model list (older versions) or check status models = http_get(f"{ui['url'].rstrip('/')}/api/models", timeout=4) if models and '"detail":"Not authenticated"' not in models: n = models.count('"id"') checks.append(("OK", f"Open WebUI {ui['url']}", f"sees {n} model(s) — inference is wired", "")) else: # Auth-walled: can't verify without creds. Report the intended wiring. if inf: checks.append(("YELLOW", f"Open WebUI {ui['url']}", f"auth required to verify; expected to serve " f"{inf['label']} ({inf['url']})", "provide open-webui admin creds at the connect screen " "for deep wiring")) else: checks.append(("RED", f"Open WebUI {ui['url']}", "no inference engine discovered anywhere", "install/start ollama (or another engine), then " "re-run: tavern rediscover")) # ── 2. SillyTavern -> inference ── for st in by_type.get("sillytavern", []): if inf: checks.append(("YELLOW", f"SillyTavern {st['url']}", "auth-walled (admin:tabby by default); API URL set " "per-user inside the app", "in SillyTavern: extensions → connection settings → " f"set API URL to {inf['url'].rstrip('/')}/v1 and select " f"the model served there")) else: checks.append(("RED", f"SillyTavern {st['url']}", "no inference engine discovered", "start ollama/another engine, then tavern rediscover")) # ── 3. MCPO -> MCP servers ── for m in by_type.get("mcpo", []): if m.get("needs_auth"): checks.append(("YELLOW", f"MCPO {m['url']}", "auth-walled; servers are defined in its config.json " "(host-side file)", "edit mcpo/config.json to add/point MCP servers, then " "restart the mcpo container")) else: checks.append(("OK", f"MCPO {m['url']}", "reachable; server list lives in its config.json", "")) # ── 4. SearXNG ── for sx in by_type.get("searxng", []): body = http_get(f"{sx['url'].rstrip('/')}/", timeout=4) or "" if "searxng" in body.lower(): checks.append(("OK", f"SearXNG {sx['url']}", "reachable", "")) else: checks.append(("RED", f"SearXNG {sx['url']}", "unexpected response", "check searxng settings.yml (host-side file)")) # ── 5. Hermes itself -> inference ── if inf: m_url = f"{inf['url'].rstrip('/')}/v1/models" probe = http_get(m_url, timeout=4, headers={"Authorization": "Bearer ollama"}) if probe and "error" not in probe.lower(): checks.append(("OK", f"Hermes → {inf['label']} ({inf['url']})", "models endpoint reachable", "")) else: checks.append(("YELLOW", f"Hermes → {inf['label']} ({inf['url']})", "models endpoint needs auth or is slow", "engine is listed for interactive key entry; " "hermes will use it once you connect")) # ── Render ── status_icon = {"OK": "✅", "RED": "❌", "YELLOW": "⚠️"} for status, svc, finding, fix in checks: print(f" {status_icon.get(status, '·')} [{status:6s}] {svc}") print(f" {finding}") if fix: print(f" FIX: {fix}") print() n_ok = sum(1 for c in checks if c[0] == "OK") n_red = sum(1 for c in checks if c[0] == "RED") n_yel = sum(1 for c in checks if c[0] == "YELLOW") print(f" ── {n_ok} wired · {n_yel} need attention · {n_red} broken ──") print(" (host-side config files can't be edited from inside basecamp —") print(" that's the safety boundary. The FIX lines above are copy-paste.)") print() def self_check(services=None): """SELF-HEAL #1 — run every service's recipe verify step and report. For each discovered service, probe it exactly like its recipe's verify step would, and report ✅/⚠️/❌. This catches stale recipes and broken services before a user hits them. The 'fixer' validating its own box. Also sweeps TCP reachability of every KNOWN container name (the stack + starter pack), so a service that failed to bind is caught even if it never got discovered. """ if services is None: services = scan_network() try: import recipes as _recipes except Exception: _recipes = None print() print(" ═══ SELF-CHECK — verifying every discovered service ═══") print() results = [] # (status, label, detail) for s in services: label = s["label"] url = s["url"] # Auth-gated services: 401/403/404 = ALIVE (behind the wall or no # root route) = OK-ish. Only connection failures are RED. if s.get("needs_auth"): code = None try: req = urllib.request.Request(url, method="GET") urllib.request.urlopen(req, timeout=3) code = 200 except urllib.error.HTTPError as e: code = e.code except Exception: code = None if code in (200, 401, 403, 404): results.append(("OK", label, f"alive (auth-walled, HTTP {code})")) else: results.append(("RED", label, f"no response (HTTP {code})")) continue # Open services: probe their recipe's primary path. A 404 on the # root path is often ALIVE (service exists, just no root route) — # treat 404/401/403 as alive, only connection failures are RED. # TCP-only services (postgres, mongo, redis) have no HTTP — their # health is judged by the TCP sweep below, skip the HTTP probe. if s.get("type") in ("postgres", "mongo", "redis"): results.append(("OK", label, f"TCP service — checked in sweep below ({url})")) continue code = None try: req = urllib.request.Request(url, method="GET") with urllib.request.urlopen(req, timeout=3) as r: body = r.read().decode(errors="replace") code = r.status except urllib.error.HTTPError as e: code = e.code except Exception: code = None if code in (200, 301, 302, 307, 404, 401, 403): detail = f"responding at {url} (HTTP {code})" results.append(("OK", label, detail)) else: results.append(("RED", label, f"NOT responding at {url} (HTTP {code})")) # TCP sweep of every KNOWN container (stack + starter pack) — catches # a service that failed to bind or isn't on the network, even if it # never got discovered (no HTTP response to match a probe). known_containers = { "tabbyapi": 5000, "ollama": 11434, "open-webui": 8080, "sillytavern": 8000, "searxng": 8080, "mcpo": 8000, "starter-code-server": 8080, "starter-qdrant": 6333, "starter-chroma": 8000, "starter-n8n": 5678, "starter-lobe-chat": 3210, "starter-anythingllm": 3001, "starter-librechat": 3080, "starter-meilisearch": 7700, "starter-postgres": 5432, "starter-mongo": 27017, } discovered_urls = {s.get("url") for s in services} for name, port in known_containers.items(): url = f"http://{name}:{port}" if url in discovered_urls: continue # already verified above if host_reachable(name, port, timeout=1): results.append(("OK", f"{name} (TCP)", f"reachable at {url}")) else: results.append(("RED", f"{name} (TCP)", f"NOT reachable at {url}")) # Recipe coverage report if _recipes is not None: types = {s.get("type") for s in services} covered = sum(1 for t in types if t in _recipes.WIRING_RECIPES) results.append(("INFO", f"recipe coverage", f"{covered}/{len(types)} discovered types have fix recipes")) for status, label, detail in results: icon = {"OK": "✅", "RED": "❌", "INFO": "ℹ️"}.get(status, "·") print(f" {icon} [{status:4s}] {label}") print(f" {detail}") print() n_ok = sum(1 for r in results if r[0] == "OK") n_red = sum(1 for r in results if r[0] == "RED") print(f" ── {n_ok} healthy · {n_red} failing ──") if n_red: print(" Run 'tavern wire' for the exact fixes. Re-run after fixing.") else: print(" Everything basecamp can see is alive. The fixer approves.") print() return n_red # Known image repos per service type for update-check (Docker Hub tags API) UPDATE_CHECK_REPOS = { "code-server": "codercom/code-server", "tabby": "tabbyml/tabby", "qdrant": "qdrant/qdrant", "chroma": "chromadb/chroma", "n8n": "n8nio/n8n", "lobe-chat": "lobehub/lobe-chat", "anythingllm": "mintplexlabs/anythingllm", "librechat": "danny-avila/librechat", # GHCR actually; hub may 404 "meilisearch": "getmeili/meilisearch", "flowise": "flowiseai/flowise", "postgres": "pgvector/pgvector", "mongo": "library/mongo", "ollama": "ollama/ollama", "searxng": "searxng/searxng", "open-webui": "ghcr.io/open-webui/open-webui", "sillytavern": "ghcr.io/sillytavern/sillytavern", "mcpo": "ghcr.io/open-webui/mcpo", "tabbyapi": "tabbyapi/tabbyapi", } def update_check(services=None): """SELF-HEAL #2 — poll Docker Hub/GHCR for newer image tags. For every discovered service with a known image repo, fetch the newest published tag and compare against what's running. Reports updates the user can pull. The 'fixer' keeping itself current. """ if services is None: services = scan_network() print() print(" ═══ UPDATE CHECK — newer versions on registries ═══") print() found_any = False for s in sorted(services, key=lambda x: x.get("type", "")): repo = UPDATE_CHECK_REPOS.get(s.get("type")) if not repo: continue found_any = True # Query the registry tags API latest_tag = None try: if repo.startswith("ghcr.io/"): # GHCR: anonymous token flow (401 → WWW-Authenticate challenge # → token → authorized tags/list) path = repo[len("ghcr.io/"):] try: req = urllib.request.Request( f"https://ghcr.io/token?scope=repository:{path}:pull&service=ghcr.io") with urllib.request.urlopen(req, timeout=6) as r: token = json.loads(r.read().decode()).get("token", "") except Exception: token = "" req = urllib.request.Request( f"https://ghcr.io/v2/{path}/tags/list", headers={"Accept": "application/json", "Authorization": f"Bearer {token}"}) with urllib.request.urlopen(req, timeout=6) as r: data = json.loads(r.read().decode()) tags = data.get("tags", []) candidates = [t for t in tags if not t.startswith("sha256:")] if candidates: latest_tag = candidates[-1] else: ns, name = repo.split("/") if ns == "library": ns = "library" url = f"https://hub.docker.com/v2/repositories/{ns}/{name}/tags/?page_size=5" with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "basecamp"}), timeout=6) as r: data = json.loads(r.read().decode()) tags = [t["name"] for t in data.get("results", [])] if tags: latest_tag = tags[0] except Exception as e: print(f" ⚠️ [{s.get('type'):12s}] {s['label']}: registry query failed ({e})") continue if latest_tag: print(f" 📦 [{s.get('type'):12s}] {s['label']}") print(f" newest published tag: {latest_tag}") print(f" registry: {repo}") print() if not found_any: print(" No known image repos for the discovered services.") print(" To update a service: docker pull :, then") print(" recreate its container (or edit docker-compose.starter.yml).") print() def main(): if len(sys.argv) < 2: print(__doc__) sys.exit(1) action = sys.argv[1] if action == "scan": print("Scanning for AI services...", file=sys.stderr) services = scan_network() save_discovery(services) if services: print(f"\nFound {len(services)} service(s):") for s in services: print(f" {s['icon']} {s['label']}") print(f" URL: {s['url']}") print(f" Network: {s['network']}") if s.get("details", {}).get("models"): print(f" Models: {', '.join(s['details']['models'])}") if s.get("needs_auth"): print(f" Auth required: {s.get('auth_type', 'bearer')}") print() else: print("No services found.") elif action == "config": services = scan_network() save_discovery(services) config = generate_config(services) print(json.dumps(config, indent=2)) print(f"\nSaved to {CONFIG_FILE}", file=sys.stderr) elif action == "tools": if DISCOVERY_FILE.exists(): with open(DISCOVERY_FILE) as f: services = json.load(f) else: services = scan_network() save_discovery(services) if CONFIG_FILE.exists(): with open(CONFIG_FILE) as f: config = json.load(f) else: config = generate_config(services) script = generate_tavern_script(config) print(script) elif action == "serve": services = scan_network() save_discovery(services) try: selected, auth_keys = connect_screen(services) except (EOFError, KeyboardInterrupt): # No terminal input (non-interactive run): use auto-selected defaults print("\n No terminal input available -- using defaults.") selected, auth_keys = None, {} if selected is not None: config = generate_config(services, selected, auth_keys) else: # No services found or user aborted: auto-select sensible defaults config = generate_config(services) # ── AUTO-WIRE: UI present but NO inference engine ── # The "user only has Open WebUI" case: the box's own bundled Ollama # becomes the inference engine, the UI is pointed at it, and the # user is TOLD what happened. No input needed. uis = [s for s in services if s["type"] in ("open-webui", "sillytavern")] infs = [s for s in services if s["type"] in ( "ollama", "tabbyapi", "vllm", "litellm", "localai", "llamacpp", "text-generation-webui")] if uis and not infs and not config.get("inference"): print() print(" ⚡ No inference engine found — wiring the UI to Basecamp's") print(" own bundled Ollama.") print() # Point the config at the bundled ollama (started by supervisor # at container boot, listening on 127.0.0.1:11434) config["inference"] = { "label": "Ollama (basecamp bundled)", "type": "ollama", "url": "http://127.0.0.1:11434", "host": "127.0.0.1", "port": 11434, "network": "local", } config["ollama_models"] = [os.environ.get("BASECAMP_MODEL", "llama3.1:8b")] # Tell the user exactly what we did print(" ✅ Done — here's what happened:") print(f" • Found your UI: {', '.join(s['label'] for s in uis)}") print(" • No inference engine on the network (no Ollama/TabbyAPI/etc.)") print(" • Basecamp's OWN Ollama is now serving as the engine") print(f" • Model: {config['ollama_models'][0]}") print(" • Point your UI's 'Ollama Base URL' at: http://127.0.0.1:11434") print(" (inside the same Docker network: http://:11434)") print() print(" 💡 Want cloud-speed models instead? Your options:") print(" • Nous Research portal (what this agent uses) — ~$20/mo,") print(" top-tier models, no GPU needed. NousResearch.com") print(" • Ollama.com subscription — cloud models through the") print(" same ollama CLI you already have.") print(" • OpenRouter — pay-per-token, every model under one API.") print(" Any of these plug straight into your UI as an OpenAI-") print(" compatible endpoint. Local stays free, cloud stays fast.") print() # Re-run the wiring audit so the UI's fix lines reflect the new wiring try: wire_audit(services, config) except Exception: pass script = generate_tavern_script(config) tavern_path = Path("/usr/local/bin/tavern") with open(tavern_path, "w") as f: f.write(script) os.chmod(tavern_path, 0o755) # Configure Hermes via env vars only -- never writes into a hermes home write_basecamp_env(config) # Teach the container's Hermes about the discovered stack (its own home) try: generate_stack_skill(config, services) except Exception as e: print(f" (skill generation skipped: {e})", file=sys.stderr) print() print("=" * 60) print(" Basecamp is ready!") print() print(" Commands:") print(" tavern status - see connected services") print(" tavern chat \"hi\" - chat with your inference engine") print(" tavern models - list available models") print(" tavern search - search the web") print(" tavern mcp - list MCP tools") print("=" * 60) elif action == "env": # (Re)write the Hermes runtime env file from saved config. No hermes # config.yaml / .env files are ever touched. if CONFIG_FILE.exists(): with open(CONFIG_FILE) as f: config = json.load(f) else: config = generate_config(scan_network()) write_basecamp_env(config) # Refresh the stack skill so the container's Hermes knows the layout try: if DISCOVERY_FILE.exists(): with open(DISCOVERY_FILE) as f: services = json.load(f) else: services = scan_network() generate_stack_skill(config, services) except Exception as e: print(f" (skill generation skipped: {e})", file=sys.stderr) elif action == "wire": # Stack wiring audit: what should talk to what, and the exact fix # for anything that doesn't. The noob-friendly "why doesn't this work" # answer, without touching a single host-side file. services = scan_network() save_discovery(services) config = None if CONFIG_FILE.exists(): with open(CONFIG_FILE) as f: config = json.load(f) wire_audit(services, config) elif action in ("self-check", "selfcheck", "doctor"): # SELF-HEAL #1: verify every discovered service against its recipe. services = scan_network() save_discovery(services) self_check(services) elif action in ("update-check", "updatecheck", "updates"): # SELF-HEAL #2: poll registries for newer image tags. services = scan_network() save_discovery(services) update_check(services) else: print(f"Unknown action: {action}") print(__doc__) sys.exit(1) if __name__ == "__main__": main()