Instructions to use jpanasuk/basecamp with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- HERMES
How to use jpanasuk/basecamp with HERMES:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
| #!/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 <tool> [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/<model> 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/<model>) 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 <repo>:<newer-tag>, 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://<basecamp>: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() |