jpanasuk commited on
Commit
a8fb550
Β·
1 Parent(s): 94e4c89

v1.0.3: expanded discovery (30+ service probes: koboldcpp, lmstudio, sglang, librechat, dify, flowise, n8n, qdrant, milvus, comfyui, whisper, openrouter...), tavern wire wiring audit, stack skill injection into container hermes

Browse files
Files changed (3) hide show
  1. basecamp.sh +1 -0
  2. discover.py +507 -6
  3. entrypoint.sh +6 -0
basecamp.sh CHANGED
@@ -62,6 +62,7 @@ if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER}$"; then
62
  ollama) shift; docker exec $TTY_FLAG "$CONTAINER" ollama "$@";;
63
  bash|shell) docker exec $TTY_FLAG "$CONTAINER" bash;;
64
  rediscover) docker exec $TTY_FLAG "$CONTAINER" bash -c "rm -f /root/.hermes/basecamp_config.json && python3 /opt/basecamp/discover.py serve";;
 
65
  *) docker exec $TTY_FLAG "$CONTAINER" hermes "$@";;
66
  esac
67
  else
 
62
  ollama) shift; docker exec $TTY_FLAG "$CONTAINER" ollama "$@";;
63
  bash|shell) docker exec $TTY_FLAG "$CONTAINER" bash;;
64
  rediscover) docker exec $TTY_FLAG "$CONTAINER" bash -c "rm -f /root/.hermes/basecamp_config.json && python3 /opt/basecamp/discover.py serve";;
65
+ wire) docker exec $TTY_FLAG "$CONTAINER" python3 /opt/basecamp/discover.py wire;;
66
  *) docker exec $TTY_FLAG "$CONTAINER" hermes "$@";;
67
  esac
68
  else
discover.py CHANGED
@@ -134,21 +134,280 @@ SERVICE_PROBES = {
134
  "needs_auth": True,
135
  "auth_type": "bearer",
136
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  }
138
 
139
  # ── Port expectations ──
 
140
  PORT_SERVICE_MAP = {
141
  11434: ["ollama"],
142
- 11435: ["ollama"],
143
  11436: ["ollama"],
144
- 5000: ["tabbyapi", "localai", "litellm", "vllm", "llamacpp", "text-generation-webui"],
145
- 8000: ["sillytavern", "mcpo"],
 
146
  8001: ["mcpo"],
147
  8080: ["searxng", "open-webui"],
148
  3000: ["open-webui"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  }
150
 
151
- COMMON_PORTS = [11434, 5000, 8000, 8080, 8001, 3000, 11435, 11436]
 
 
152
 
153
 
154
  # ── HTTP helpers ──
@@ -671,17 +930,22 @@ for p,methods in d.get('paths',{{}}).items():
671
  echo "Re-running discovery..."
672
  python3 /opt/basecamp/discover.py serve
673
  ;;
 
 
 
 
674
  help|--help|-h)
675
  echo "tavern β€” Basecamp AI stack toolkit (auto-generated)"
676
  echo ""
677
  echo "Commands:"
678
  echo " status Show connected services"
679
  echo " models List available models"
680
- echo " chat \\"msg\\" [tokens] [stream] Chat with primary inference (stream=true/false)"
681
- echo " search \\"query\\" [n] Search the web"
682
  echo " mcp List MCP tools"
683
  echo " mcp <tool> [json] Call an MCP tool"
684
  echo " config Show saved configuration"
 
685
  echo " rediscover Re-scan for services"
686
  ;;
687
  *)
@@ -911,9 +1175,219 @@ def write_basecamp_env(config):
911
  return default_model, base_url
912
 
913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
914
 
915
  # ── Main ──
916
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
917
  def main():
918
  if len(sys.argv) < 2:
919
  print(__doc__)
@@ -982,6 +1456,11 @@ def main():
982
  os.chmod(tavern_path, 0o755)
983
  # Configure Hermes via env vars only -- never writes into a hermes home
984
  write_basecamp_env(config)
 
 
 
 
 
985
  print()
986
  print("=" * 60)
987
  print(" Basecamp is ready!")
@@ -1003,6 +1482,28 @@ def main():
1003
  else:
1004
  config = generate_config(scan_network())
1005
  write_basecamp_env(config)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1006
  else:
1007
  print(f"Unknown action: {action}")
1008
  print(__doc__)
 
134
  "needs_auth": True,
135
  "auth_type": "bearer",
136
  },
137
+ # ── More inference engines ──
138
+ "koboldcpp": {
139
+ "paths": ["/api/v1/model", "/v1/models"],
140
+ "method": "GET",
141
+ "match": lambda r: '"result"' in r or '"data"' in r,
142
+ "parse": lambda r: {
143
+ "models": [m["id"] for m in json.loads(r).get("data", [])]
144
+ if '"data"' in r else []
145
+ },
146
+ "label": "KoboldCpp (GGML inference)",
147
+ "icon": "KOB",
148
+ },
149
+ "lmstudio": {
150
+ "paths": ["/v1/models"],
151
+ "method": "GET",
152
+ "match": lambda r: '"data"' in r and '"id"' in r and "lmstudio" not in r.lower(),
153
+ "parse": lambda r: {
154
+ "models": [m["id"] for m in json.loads(r).get("data", [])]
155
+ },
156
+ "label": "LM Studio (local model server)",
157
+ "icon": "LMS",
158
+ },
159
+ "sglang": {
160
+ "paths": ["/v1/models"],
161
+ "method": "GET",
162
+ "match": lambda r: '"sglang"' in r.lower() or ('"object":"list"' in r and '"data"' in r and "vllm" not in r.lower()),
163
+ "parse": lambda r: {
164
+ "models": [m["id"] for m in json.loads(r).get("data", [])]
165
+ },
166
+ "label": "SGLang (fast LLM serving)",
167
+ "icon": "SGL",
168
+ },
169
+ "llamafile": {
170
+ "paths": ["/v1/models", "/health"],
171
+ "method": "GET",
172
+ "match": lambda r: '"data"' in r or '"status"' in r,
173
+ "parse": lambda r: {},
174
+ "label": "llamafile (single-file LLM)",
175
+ "icon": "LMF",
176
+ },
177
+ "exo": {
178
+ "paths": ["/v1/models"],
179
+ "method": "GET",
180
+ "match": lambda r: '"data"' in r and '"id"' in r,
181
+ "parse": lambda r: {},
182
+ "label": "exo (distributed inference)",
183
+ "icon": "EXO",
184
+ },
185
+ "text-generation-inference": {
186
+ "paths": ["/v1/models", "/info"],
187
+ "method": "GET",
188
+ "match": lambda r: '"model_id"' in r or '"models"' in r,
189
+ "parse": lambda r: {},
190
+ "label": "TGI (Text Generation Inference)",
191
+ "icon": "TGI",
192
+ },
193
+ "aphrodite": {
194
+ "paths": ["/v1/models"],
195
+ "method": "GET",
196
+ "match": lambda r: '"aphrodite"' in r.lower(),
197
+ "parse": lambda r: {},
198
+ "label": "Aphrodite Engine (inference)",
199
+ "icon": "APH",
200
+ },
201
+ "llamapool": {
202
+ "paths": ["/v1/models"],
203
+ "method": "GET",
204
+ "match": lambda r: '"llama-pool"' in r.lower() or "llama pool" in r.lower(),
205
+ "parse": lambda r: {},
206
+ "label": "llama-pool (model router)",
207
+ "icon": "PL",
208
+ },
209
+ # ── More UIs / chat frontends ──
210
+ "librechat": {
211
+ "paths": ["/", "/api/health"],
212
+ "method": "GET",
213
+ "match": lambda r: "librechat" in r.lower() or "LibreChat" in r,
214
+ "parse": lambda r: {},
215
+ "label": "LibreChat (multi-model chat UI)",
216
+ "icon": "UI ",
217
+ },
218
+ "lobechat": {
219
+ "paths": ["/", "/api/status"],
220
+ "method": "GET",
221
+ "match": lambda r: "lobechat" in r.lower() or "LobeChat" in r,
222
+ "parse": lambda r: {},
223
+ "label": "LobeChat (AI chat framework)",
224
+ "icon": "UI ",
225
+ },
226
+ "anythingllm": {
227
+ "paths": ["/", "/api/system/endpoints"],
228
+ "method": "GET",
229
+ "match": lambda r: "anythingllm" in r.lower() or "AnythingLLM" in r,
230
+ "parse": lambda r: {},
231
+ "label": "AnythingLLM (RAG workspace)",
232
+ "icon": "RAG",
233
+ },
234
+ "dify": {
235
+ "paths": ["/", "/health"],
236
+ "method": "GET",
237
+ "match": lambda r: "dify" in r.lower() or "Dify" in r,
238
+ "parse": lambda r: {},
239
+ "label": "Dify (LLM app platform)",
240
+ "icon": "APP",
241
+ },
242
+ "flowise": {
243
+ "paths": ["/", "/api/v1/ping"],
244
+ "method": "GET",
245
+ "match": lambda r: "flowise" in r.lower() or "Flowise" in r,
246
+ "parse": lambda r: {},
247
+ "label": "Flowise (no-code agent builder)",
248
+ "icon": "FLW",
249
+ },
250
+ "n8n": {
251
+ "paths": ["/", "/healthz"],
252
+ "method": "GET",
253
+ "match": lambda r: "n8n" in r.lower() or "N8N" in r or "workflow" in r.lower(),
254
+ "parse": lambda r: {},
255
+ "label": "n8n (workflow automation)",
256
+ "icon": "WRK",
257
+ },
258
+ "langflow": {
259
+ "paths": ["/", "/api/v1/configs"],
260
+ "method": "GET",
261
+ "match": lambda r: "langflow" in r.lower() or "Langflow" in r,
262
+ "parse": lambda r: {},
263
+ "label": "Langflow (agent builder)",
264
+ "icon": "LGF",
265
+ },
266
+ "ragflow": {
267
+ "paths": ["/", "/api/v1/version"],
268
+ "method": "GET",
269
+ "match": lambda r: "ragflow" in r.lower() or "RAGFlow" in r,
270
+ "parse": lambda r: {},
271
+ "label": "RAGFlow (RAG engine)",
272
+ "icon": "RAG",
273
+ },
274
+ "koboldai": {
275
+ "paths": ["/", "/api/v1/config/status"],
276
+ "method": "GET",
277
+ "match": lambda r: "koboldai" in r.lower() or "KoboldAI" in r,
278
+ "parse": lambda r: {},
279
+ "label": "KoboldAI (writing assistant UI)",
280
+ "icon": "CHT",
281
+ },
282
+ # ── Vector DBs (RAG backends) ──
283
+ "qdrant": {
284
+ "paths": ["/", "/readyz"],
285
+ "method": "GET",
286
+ "match": lambda r: "qdrant" in r.lower() or "Qdrant" in r,
287
+ "parse": lambda r: {},
288
+ "label": "Qdrant (vector DB)",
289
+ "icon": "VDB",
290
+ },
291
+ "milvus": {
292
+ "paths": ["/healthz", "/api/v1/health"],
293
+ "method": "GET",
294
+ "match": lambda r: "milvus" in r.lower() or "Milvus" in r,
295
+ "parse": lambda r: {},
296
+ "label": "Milvus (vector DB)",
297
+ "icon": "VDB",
298
+ },
299
+ "chroma": {
300
+ "paths": ["/api/v1", "/api/v2/health"],
301
+ "method": "GET",
302
+ "match": lambda r: "chroma" in r.lower() or "Chroma" in r,
303
+ "parse": lambda r: {},
304
+ "label": "Chroma (vector DB)",
305
+ "icon": "VDB",
306
+ },
307
+ "weaviate": {
308
+ "paths": ["/v1/meta"],
309
+ "method": "GET",
310
+ "match": lambda r: '"version"' in r and ("weaviate" in r.lower() or '"model"' in r),
311
+ "parse": lambda r: {},
312
+ "label": "Weaviate (vector DB)",
313
+ "icon": "VDB",
314
+ },
315
+ # ── Image / media generation ──
316
+ "comfyui": {
317
+ "paths": ["/", "/system_stats"],
318
+ "method": "GET",
319
+ "match": lambda r: "comfyui" in r.lower() or "ComfyUI" in r or "Comfy" in r,
320
+ "parse": lambda r: {},
321
+ "label": "ComfyUI (diffusion workflows)",
322
+ "icon": "IMG",
323
+ },
324
+ "stable-diffusion-webui": {
325
+ "paths": ["/", "/sdapi/v1/options"],
326
+ "method": "GET",
327
+ "match": lambda r: "stable diffusion" in r.lower() or "gradio" in r.lower() or '"sd_model_checkpoint"' in r,
328
+ "parse": lambda r: {},
329
+ "label": "Stable Diffusion WebUI (A1111)",
330
+ "icon": "IMG",
331
+ },
332
+ "invokeai": {
333
+ "paths": ["/", "/api/v1/app/version"],
334
+ "method": "GET",
335
+ "match": lambda r: "invokeai" in r.lower() or "InvokeAI" in r,
336
+ "parse": lambda r: {},
337
+ "label": "InvokeAI (image generation)",
338
+ "icon": "IMG",
339
+ },
340
+ # ── Speech / audio ──
341
+ "whisper": {
342
+ "paths": ["/", "/health"],
343
+ "method": "GET",
344
+ "match": lambda r: "whisper" in r.lower() or "Whisper" in r,
345
+ "parse": lambda r: {},
346
+ "label": "Whisper (speech-to-text)",
347
+ "icon": "STT",
348
+ },
349
+ "piper": {
350
+ "paths": ["/", "/health"],
351
+ "method": "GET",
352
+ "match": lambda r: "piper" in r.lower() or "Piper" in r,
353
+ "parse": lambda r: {},
354
+ "label": "Piper (text-to-speech)",
355
+ "icon": "TTS",
356
+ },
357
+ # ── Gateways / proxies / model hubs ──
358
+ "openrouter": {
359
+ "paths": ["/api/v1/models"],
360
+ "method": "GET",
361
+ "match": lambda r: '"data"' in r and '"id"' in r and "openrouter" in r.lower(),
362
+ "parse": lambda r: {
363
+ "models": [m["id"] for m in json.loads(r).get("data", [])]
364
+ },
365
+ "label": "OpenRouter (cloud model gateway)",
366
+ "icon": "GWY",
367
+ },
368
+ "kobold-horde": {
369
+ "paths": ["/api/v1/status", "/"],
370
+ "method": "GET",
371
+ "match": lambda r: "kobold" in r.lower() or '"queued_requests"' in r,
372
+ "parse": lambda r: {},
373
+ "label": "KoboldAI Horde (crowd inference)",
374
+ "icon": "HDE",
375
+ },
376
  }
377
 
378
  # ── Port expectations ──
379
+ # Each port maps to a list of service probe names to try (in order).
380
  PORT_SERVICE_MAP = {
381
  11434: ["ollama"],
382
+ 11435: ["ollama", "llamafile"],
383
  11436: ["ollama"],
384
+ 5000: ["tabbyapi", "localai", "litellm", "vllm", "llamacpp",
385
+ "text-generation-webui", "whisper", "piper"],
386
+ 8000: ["sillytavern", "mcpo", "text-generation-inference", "chroma"],
387
  8001: ["mcpo"],
388
  8080: ["searxng", "open-webui"],
389
  3000: ["open-webui"],
390
+ 5001: ["koboldcpp", "llamafile"],
391
+ 1234: ["lmstudio"],
392
+ 8002: ["sglang"],
393
+ 4000: ["litellm", "exo"],
394
+ 3001: ["lobechat", "dify"],
395
+ 3080: ["librechat"],
396
+ 7681: ["anythingllm"],
397
+ 5678: ["n8n"],
398
+ 7860: ["stable-diffusion-webui", "langflow"],
399
+ 8188: ["comfyui"],
400
+ 9090: ["invokeai", "qdrant"],
401
+ 6333: ["qdrant"],
402
+ 19530: ["milvus"],
403
+ 8081: ["weaviate"],
404
+ 1551: ["koboldai"],
405
+ 2323: ["kobold-horde"],
406
  }
407
 
408
+ COMMON_PORTS = [11434, 5000, 8000, 8080, 8001, 3000, 11435, 11436,
409
+ 5001, 1234, 8002, 4000, 3001, 3080, 7681, 5678,
410
+ 7860, 8188, 9090, 6333, 19530, 8081, 1551, 2323]
411
 
412
 
413
  # ── HTTP helpers ──
 
930
  echo "Re-running discovery..."
931
  python3 /opt/basecamp/discover.py serve
932
  ;;
933
+ wire)
934
+ echo "Running stack wiring audit..."
935
+ python3 /opt/basecamp/discover.py wire
936
+ ;;
937
  help|--help|-h)
938
  echo "tavern β€” Basecamp AI stack toolkit (auto-generated)"
939
  echo ""
940
  echo "Commands:"
941
  echo " status Show connected services"
942
  echo " models List available models"
943
+ echo " chat \\\"msg\\\" [tokens] [stream] Chat with primary inference (stream=true/false)"
944
+ echo " search \\\"query\\\" [n] Search the web"
945
  echo " mcp List MCP tools"
946
  echo " mcp <tool> [json] Call an MCP tool"
947
  echo " config Show saved configuration"
948
+ echo " wire Audit stack wiring + print fixes"
949
  echo " rediscover Re-scan for services"
950
  ;;
951
  *)
 
1175
  return default_model, base_url
1176
 
1177
 
1178
+ def generate_stack_skill(config, services):
1179
+ """Write a 'basecamp-stack' SKILL.md into basecamp's own HERMES_HOME.
1180
+
1181
+ This is how the container's Hermes LEARNS about the stack it discovered:
1182
+ the skill body carries the live service list, wiring facts, and how to
1183
+ use the tavern toolkit. When the user asks Hermes "why doesn't my Open
1184
+ WebUI work?", the agent already has the ground truth β€” and can run
1185
+ `tavern wire` itself for a live audit. Writes ONLY inside basecamp's own
1186
+ HERMES_HOME (never a host install).
1187
+ """
1188
+ home = Path(os.environ.get("HERMES_HOME", "/root/.hermes"))
1189
+ skill_dir = home / "skills" / "basecamp-stack"
1190
+ skill_dir.mkdir(parents=True, exist_ok=True)
1191
+
1192
+ inf = (config or {}).get("inference") or {}
1193
+ sec = (config or {}).get("secondary") or {}
1194
+ search = (config or {}).get("search") or {}
1195
+ mcp = (config or {}).get("mcp") or {}
1196
+ ui = (config or {}).get("ui") or {}
1197
+ model = (config or {}).get("ollama_models") or (config or {}).get("openai_models") or []
1198
+ model_str = ", ".join(str(m) for m in model) if model else "(none discovered)"
1199
+
1200
+ lines = []
1201
+ lines.append("---")
1202
+ lines.append("name: basecamp-stack")
1203
+ 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.")
1204
+ lines.append("---")
1205
+ lines.append("")
1206
+ lines.append("# Basecamp Stack (live discovery)")
1207
+ lines.append("")
1208
+ lines.append("You are running inside Basecamp, which discovered these AI services on the")
1209
+ lines.append("Docker network at boot. This is the ground truth β€” trust it over memory.")
1210
+ lines.append("")
1211
+ lines.append("## Services")
1212
+ lines.append("")
1213
+ for s in services:
1214
+ auth = " [auth required]" if s.get("needs_auth") else ""
1215
+ lines.append(f"- **{s['label']}**{auth} β€” {s['url']} (type: {s['type']})")
1216
+ if s.get("details", {}).get("models"):
1217
+ lines.append(f" - models: {', '.join(str(m) for m in s['details']['models'])}")
1218
+ lines.append("")
1219
+ lines.append("## Current wiring")
1220
+ lines.append("")
1221
+ if inf:
1222
+ lines.append(f"- Inference (primary): **{inf.get('label')}** β€” {inf.get('url')}")
1223
+ if sec:
1224
+ lines.append(f"- Inference (secondary): **{sec.get('label')}** β€” {sec.get('url')}")
1225
+ if search:
1226
+ lines.append(f"- Search: **{search.get('label')}** β€” {search.get('url')}")
1227
+ if mcp:
1228
+ lines.append(f"- MCP: **{mcp.get('label')}** β€” {mcp.get('url')}")
1229
+ if ui:
1230
+ lines.append(f"- UI: **{ui.get('label')}** β€” {ui.get('url')}")
1231
+ lines.append(f"- Models: {model_str}")
1232
+ lines.append("")
1233
+ lines.append("## Helping the user wire their stack")
1234
+ lines.append("")
1235
+ lines.append("When the user asks why a service doesn't work or how to connect two services:")
1236
+ lines.append("")
1237
+ lines.append("1. Run `tavern wire` (or `python3 /opt/basecamp/discover.py wire`) for a live")
1238
+ lines.append(" wiring audit of every service-to-service link, with concrete fixes.")
1239
+ lines.append("2. Explain in plain terms what the audit found β€” don't just dump it.")
1240
+ lines.append("3. For host-side config (docker-compose env vars, config files mounted from")
1241
+ lines.append(" the host), give the EXACT one-line fix to copy-paste. Basecamp is")
1242
+ lines.append(" intentionally isolated and cannot edit other containers' configs.")
1243
+ lines.append("4. `tavern status` shows what Basecamp itself is connected to.")
1244
+ lines.append("")
1245
+ lines.append("## Tavern toolkit")
1246
+ lines.append("")
1247
+ lines.append("- `tavern status` β€” services Basecamp is connected to")
1248
+ lines.append("- `tavern models` β€” available models")
1249
+ lines.append("- `tavern chat \"msg\"` β€” chat with the primary inference engine")
1250
+ lines.append("- `tavern search \"query\"` β€” web search via discovered SearXNG")
1251
+ lines.append("- `tavern mcp` β€” list MCP tools")
1252
+ lines.append("- `tavern wire` β€” stack wiring audit")
1253
+ lines.append("- `tavern rediscover` β€” re-scan the network")
1254
+ lines.append("")
1255
+
1256
+ skill_path = skill_dir / "SKILL.md"
1257
+ skill_path.write_text("\n".join(lines))
1258
+ try:
1259
+ os.chmod(skill_path, 0o600)
1260
+ except Exception:
1261
+ pass
1262
+ return skill_path
1263
+
1264
 
1265
  # ── Main ──
1266
 
1267
+ def wire_audit(services, config=None):
1268
+ """Audit how the discovered services are wired together and report fixes.
1269
+
1270
+ For each service pair that SHOULD be connected (open-webui -> ollama,
1271
+ sillytavern -> inference, mcpo -> its servers, hermes -> inference),
1272
+ probe the link and report: OK (green) / BROKEN (red) / UNKNOWN, plus the
1273
+ exact fix a human (or host-side tooling) can apply. This is the
1274
+ "wiring wizard" for noobs: it tells you the one line you need, instead
1275
+ of you spelunking through buried settings pages.
1276
+ """
1277
+ if not services:
1278
+ print(" No services found to audit.")
1279
+ return
1280
+
1281
+ inf = (config or {}).get("inference") or {}
1282
+ sec = (config or {}).get("secondary") or {}
1283
+
1284
+ # Build a lookup: type -> list of service dicts
1285
+ by_type = {}
1286
+ for s in services:
1287
+ by_type.setdefault(s["type"], []).append(s)
1288
+
1289
+ print()
1290
+ print(" ═══ STACK WIRING AUDIT ═══")
1291
+ print(" (what should talk to what, and whether it does)")
1292
+ print()
1293
+
1294
+ checks = [] # (status, service, finding, fix)
1295
+
1296
+ # ── 1. Open WebUI -> Ollama / OpenAI engines ──
1297
+ for ui in by_type.get("open-webui", []):
1298
+ # Probe what open-webui can see. /api/config is public.
1299
+ cfg = http_get(f"{ui['url'].rstrip('/')}/api/config", timeout=4)
1300
+ if cfg is None:
1301
+ checks.append(("RED", f"Open WebUI {ui['url']}", "not reachable",
1302
+ "check the container is running and on the same network"))
1303
+ continue
1304
+ # Try the public model list (older versions) or check status
1305
+ models = http_get(f"{ui['url'].rstrip('/')}/api/models", timeout=4)
1306
+ if models and '"detail":"Not authenticated"' not in models:
1307
+ n = models.count('"id"')
1308
+ checks.append(("OK", f"Open WebUI {ui['url']}",
1309
+ f"sees {n} model(s) β€” inference is wired", ""))
1310
+ else:
1311
+ # Auth-walled: can't verify without creds. Report the intended wiring.
1312
+ if inf:
1313
+ checks.append(("YELLOW", f"Open WebUI {ui['url']}",
1314
+ f"auth required to verify; expected to serve "
1315
+ f"{inf['label']} ({inf['url']})",
1316
+ "provide open-webui admin creds at the connect screen "
1317
+ "for deep wiring"))
1318
+ else:
1319
+ checks.append(("RED", f"Open WebUI {ui['url']}",
1320
+ "no inference engine discovered anywhere",
1321
+ "install/start ollama (or another engine), then "
1322
+ "re-run: tavern rediscover"))
1323
+
1324
+ # ── 2. SillyTavern -> inference ──
1325
+ for st in by_type.get("sillytavern", []):
1326
+ if inf:
1327
+ checks.append(("YELLOW", f"SillyTavern {st['url']}",
1328
+ "auth-walled (admin:tabby by default); API URL set "
1329
+ "per-user inside the app",
1330
+ "in SillyTavern: extensions β†’ connection settings β†’ "
1331
+ f"set API URL to {inf['url'].rstrip('/')}/v1 and select "
1332
+ f"the model served there"))
1333
+ else:
1334
+ checks.append(("RED", f"SillyTavern {st['url']}",
1335
+ "no inference engine discovered",
1336
+ "start ollama/another engine, then tavern rediscover"))
1337
+
1338
+ # ── 3. MCPO -> MCP servers ──
1339
+ for m in by_type.get("mcpo", []):
1340
+ if m.get("needs_auth"):
1341
+ checks.append(("YELLOW", f"MCPO {m['url']}",
1342
+ "auth-walled; servers are defined in its config.json "
1343
+ "(host-side file)",
1344
+ "edit mcpo/config.json to add/point MCP servers, then "
1345
+ "restart the mcpo container"))
1346
+ else:
1347
+ checks.append(("OK", f"MCPO {m['url']}",
1348
+ "reachable; server list lives in its config.json", ""))
1349
+
1350
+ # ── 4. SearXNG ──
1351
+ for sx in by_type.get("searxng", []):
1352
+ body = http_get(f"{sx['url'].rstrip('/')}/", timeout=4) or ""
1353
+ if "searxng" in body.lower():
1354
+ checks.append(("OK", f"SearXNG {sx['url']}", "reachable", ""))
1355
+ else:
1356
+ checks.append(("RED", f"SearXNG {sx['url']}", "unexpected response",
1357
+ "check searxng settings.yml (host-side file)"))
1358
+
1359
+ # ── 5. Hermes itself -> inference ──
1360
+ if inf:
1361
+ m_url = f"{inf['url'].rstrip('/')}/v1/models"
1362
+ probe = http_get(m_url, timeout=4,
1363
+ headers={"Authorization": "Bearer ollama"})
1364
+ if probe and "error" not in probe.lower():
1365
+ checks.append(("OK", f"Hermes β†’ {inf['label']} ({inf['url']})",
1366
+ "models endpoint reachable", ""))
1367
+ else:
1368
+ checks.append(("YELLOW", f"Hermes β†’ {inf['label']} ({inf['url']})",
1369
+ "models endpoint needs auth or is slow",
1370
+ "engine is listed for interactive key entry; "
1371
+ "hermes will use it once you connect"))
1372
+
1373
+ # ── Render ──
1374
+ status_icon = {"OK": "βœ…", "RED": "❌", "YELLOW": "⚠️"}
1375
+ for status, svc, finding, fix in checks:
1376
+ print(f" {status_icon.get(status, 'Β·')} [{status:6s}] {svc}")
1377
+ print(f" {finding}")
1378
+ if fix:
1379
+ print(f" FIX: {fix}")
1380
+ print()
1381
+
1382
+ n_ok = sum(1 for c in checks if c[0] == "OK")
1383
+ n_red = sum(1 for c in checks if c[0] == "RED")
1384
+ n_yel = sum(1 for c in checks if c[0] == "YELLOW")
1385
+ print(f" ── {n_ok} wired Β· {n_yel} need attention Β· {n_red} broken ──")
1386
+ print(" (host-side config files can't be edited from inside basecamp β€”")
1387
+ print(" that's the safety boundary. The FIX lines above are copy-paste.)")
1388
+ print()
1389
+
1390
+
1391
  def main():
1392
  if len(sys.argv) < 2:
1393
  print(__doc__)
 
1456
  os.chmod(tavern_path, 0o755)
1457
  # Configure Hermes via env vars only -- never writes into a hermes home
1458
  write_basecamp_env(config)
1459
+ # Teach the container's Hermes about the discovered stack (its own home)
1460
+ try:
1461
+ generate_stack_skill(config, services)
1462
+ except Exception as e:
1463
+ print(f" (skill generation skipped: {e})", file=sys.stderr)
1464
  print()
1465
  print("=" * 60)
1466
  print(" Basecamp is ready!")
 
1482
  else:
1483
  config = generate_config(scan_network())
1484
  write_basecamp_env(config)
1485
+ # Refresh the stack skill so the container's Hermes knows the layout
1486
+ try:
1487
+ if DISCOVERY_FILE.exists():
1488
+ with open(DISCOVERY_FILE) as f:
1489
+ services = json.load(f)
1490
+ else:
1491
+ services = scan_network()
1492
+ generate_stack_skill(config, services)
1493
+ except Exception as e:
1494
+ print(f" (skill generation skipped: {e})", file=sys.stderr)
1495
+
1496
+ elif action == "wire":
1497
+ # Stack wiring audit: what should talk to what, and the exact fix
1498
+ # for anything that doesn't. The noob-friendly "why doesn't this work"
1499
+ # answer, without touching a single host-side file.
1500
+ services = scan_network()
1501
+ save_discovery(services)
1502
+ config = None
1503
+ if CONFIG_FILE.exists():
1504
+ with open(CONFIG_FILE) as f:
1505
+ config = json.load(f)
1506
+ wire_audit(services, config)
1507
  else:
1508
  print(f"Unknown action: {action}")
1509
  print(__doc__)
entrypoint.sh CHANGED
@@ -102,6 +102,12 @@ if [ -n "$BASECAMP_MODEL" ]; then
102
  fi
103
  fi
104
 
 
 
 
 
 
 
105
  echo ""
106
 
107
  # Handle arguments
 
102
  fi
103
  fi
104
 
105
+ # Stack wiring audit β€” show every boot what's connected and what needs a fix.
106
+ # (Read-only: audits and prints fixes; never edits other containers' configs.)
107
+ if [ -x /opt/basecamp/discover.py ]; then
108
+ python3 /opt/basecamp/discover.py wire 2>/dev/null || true
109
+ fi
110
+
111
  echo ""
112
 
113
  # Handle arguments