diff --git a/assets/sourceos/bin/turtle-install-launchd b/assets/sourceos/bin/turtle-install-launchd
index 2bbee4ae043..4242e2a4018 100755
--- a/assets/sourceos/bin/turtle-install-launchd
+++ b/assets/sourceos/bin/turtle-install-launchd
@@ -5,10 +5,14 @@
# com.sourceos.turtle-mesh-push — GCS mesh sync every 5 minutes
# com.sourceos.turtle-mesh-serve — local mesh dashboard on :7788
#
+# Optionally installs (requires --with-searxng):
+# com.sourceos.searxng — start sovereign SearXNG container at login
+#
# Usage:
-# turtle-install-launchd # install both
-# turtle-install-launchd --unload # stop + unload both
-# turtle-install-launchd --status # show launchctl status
+# turtle-install-launchd # install mesh agents
+# turtle-install-launchd --with-searxng # also install SearXNG agent
+# turtle-install-launchd --unload # stop + unload all loaded agents
+# turtle-install-launchd --status # show launchctl status
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -18,20 +22,26 @@ LAUNCH_AGENTS="$HOME/Library/LaunchAgents"
LABELS=(com.sourceos.turtle-mesh-push com.sourceos.turtle-mesh-serve)
usage() {
- echo "Usage: turtle-install-launchd [--unload|--status]"
+ echo "Usage: turtle-install-launchd [--with-searxng] [--unload|--status]"
}
status_mode=false
unload_mode=false
+with_searxng=false
for arg in "$@"; do
case "$arg" in
- --status) status_mode=true ;;
- --unload) unload_mode=true ;;
+ --status) status_mode=true ;;
+ --unload) unload_mode=true ;;
+ --with-searxng) with_searxng=true ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $arg" >&2; usage >&2; exit 1 ;;
esac
done
+if $with_searxng; then
+ LABELS+=(com.sourceos.searxng)
+fi
+
if $status_mode; then
for label in "${LABELS[@]}"; do
echo -n " $label: "
@@ -72,3 +82,6 @@ done
echo ""
echo " Mesh dashboard → http://localhost:7788"
echo " GCS sync → every 5 minutes (requires SOURCEOS_GCS_BUCKET env in ~/.zshrc)"
+if $with_searxng; then
+ echo " SearXNG → http://localhost:8888 (container must exist; run sourceos-searxng-setup.sh first)"
+fi
diff --git a/assets/sourceos/bin/turtle-mesh-serve b/assets/sourceos/bin/turtle-mesh-serve
index d2c29d4b01b..10015716d4d 100755
--- a/assets/sourceos/bin/turtle-mesh-serve
+++ b/assets/sourceos/bin/turtle-mesh-serve
@@ -26,6 +26,7 @@ import queue
import sys
import threading
import time
+import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
@@ -60,6 +61,23 @@ def load_jsonl_tail(path: Path, n: int = 50) -> list[dict]:
return items[-n:]
+def _searxng_alive() -> bool:
+ state_file = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "searxng-url"
+ candidates = []
+ if state_file.exists():
+ url = state_file.read_text().strip().rstrip("/")
+ if url:
+ candidates.append(url)
+ candidates += ["http://localhost:8888", "http://localhost:8080"]
+ for url in candidates:
+ try:
+ urllib.request.urlopen(f"{url}/healthz", timeout=1)
+ return True
+ except Exception:
+ pass
+ return False
+
+
def gather_state() -> dict:
mesh = load_jsonl_tail(MESH_DIR / "context.jsonl", 60)
active = load_json(MESH_DIR / "active.json")
@@ -67,6 +85,7 @@ def gather_state() -> dict:
pr = load_json(STATUS_DIR / "pr.json")
noetica= load_json(STATUS_DIR / "noetica.json")
board = load_json(STATUS_DIR / "board.json")
+ searxng_ok = _searxng_alive()
bb_cands: list[dict] = []
cand_path = BB_SUPPORT / "memory" / "candidates.jsonl"
@@ -84,15 +103,16 @@ def gather_state() -> dict:
notes.append({"name": p.name, "mtime": p.stat().st_mtime})
return {
- "ts": datetime.datetime.now().isoformat(),
- "active": active,
- "mesh": mesh,
- "ci": ci,
- "pr": pr,
- "noetica": noetica,
- "board": board,
- "bb_cands": bb_cands,
- "notes": notes,
+ "ts": datetime.datetime.now().isoformat(),
+ "active": active,
+ "mesh": mesh,
+ "ci": ci,
+ "pr": pr,
+ "noetica": noetica,
+ "board": board,
+ "bb_cands": bb_cands,
+ "notes": notes,
+ "searxng_ok": searxng_ok,
}
@@ -227,6 +247,7 @@ h2{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em
Noetica …
+
Search …
CI —
PRs —
Board —
@@ -273,6 +294,10 @@ function render(state) {
? `
Noetica
up`
: `
Noetica
down`
+ document.getElementById('searxng-badge').innerHTML = state.searxng_ok
+ ? `
Search
up`
+ : `
Search
down`
+
const ci = state.ci || {}
const ciClass = ci.conclusion === 'success' ? 'badge-green' : ci.conclusion === 'failure' ? 'badge-red' : 'badge-yellow'
document.getElementById('ci-badge').innerHTML = ci.status
diff --git a/assets/sourceos/bin/turtle-web-search b/assets/sourceos/bin/turtle-web-search
index 2175def5edd..6a80a708aef 100755
--- a/assets/sourceos/bin/turtle-web-search
+++ b/assets/sourceos/bin/turtle-web-search
@@ -15,14 +15,46 @@ import sys
import urllib.error
import urllib.parse
import urllib.request
+from pathlib import Path
-_DEFAULT_SEARXNG = "https://searx.be"
_USER_AGENT = "TurtleTerm/1.0 (sovereign; +https://sourceos.io)"
+def _searxng_url() -> str:
+ """Resolve the SearXNG base URL with a four-level priority chain:
+
+ 1. SEARXNG_URL environment variable (explicit override)
+ 2. Persisted URL written by sourceos-searxng-setup.sh
+ 3. Auto-probe localhost:8888 (Docker) then localhost:8080 (Homebrew)
+ 4. Public fallback (https://searx.be)
+ """
+ # 1. Explicit env var wins
+ if url := os.environ.get("SEARXNG_URL", ""):
+ return url.rstrip("/")
+ # 2. Persisted local instance URL
+ state_file = (
+ Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state")))
+ / "sourceos"
+ / "searxng-url"
+ )
+ if state_file.exists():
+ url = state_file.read_text().strip()
+ if url:
+ return url.rstrip("/")
+ # 3. Try localhost:8888 (Docker), then 8080 (Homebrew)
+ for local_url in ("http://localhost:8888", "http://localhost:8080"):
+ try:
+ urllib.request.urlopen(f"{local_url}/healthz", timeout=1)
+ return local_url
+ except Exception: # noqa: BLE001
+ pass
+ # 4. Public fallback
+ return "https://searx.be"
+
+
def search(query: str, count: int = 5) -> list[dict]:
"""Query SearXNG and return a ranked list of results."""
- base = os.environ.get("SEARXNG_URL", _DEFAULT_SEARXNG).rstrip("/")
+ base = _searxng_url()
params = urllib.parse.urlencode({
"q": query,
"format": "json",
diff --git a/assets/sourceos/launchd/com.sourceos.searxng.plist b/assets/sourceos/launchd/com.sourceos.searxng.plist
new file mode 100644
index 00000000000..a39f55315e5
--- /dev/null
+++ b/assets/sourceos/launchd/com.sourceos.searxng.plist
@@ -0,0 +1,30 @@
+
+
+
+
+
+ Label
+ com.sourceos.searxng
+
+ ProgramArguments
+
+ /usr/local/bin/docker
+ start
+ sourceos-searxng
+
+
+ RunAtLoad
+
+
+ StandardOutPath
+ /tmp/sourceos-searxng.log
+
+ StandardErrorPath
+ /tmp/sourceos-searxng.err
+
+
diff --git a/scripts/sourceos-searxng-setup.sh b/scripts/sourceos-searxng-setup.sh
new file mode 100755
index 00000000000..cc7f448598d
--- /dev/null
+++ b/scripts/sourceos-searxng-setup.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+# MIT License — https://sourceos.io
+# sourceos-searxng-setup.sh — provision a sovereign SearXNG instance for TurtleTerm.
+#
+# Option A (preferred): Docker on 127.0.0.1:8888
+# Option B (fallback): Homebrew searxng on 127.0.0.1:8080
+#
+# Usage:
+# sourceos-searxng-setup.sh
+set -euo pipefail
+
+STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos"
+STATE_FILE="$STATE_DIR/searxng-url"
+CONTAINER_NAME="sourceos-searxng"
+DOCKER_PORT="8888"
+BREW_PORT="8080"
+
+# ── helpers ────────────────────────────────────────────────────────────────────
+
+log() { echo " [searxng] $*"; }
+warn() { echo " [searxng] WARN: $*" >&2; }
+
+persist_url() {
+ mkdir -p "$STATE_DIR"
+ printf '%s' "$1" > "$STATE_FILE"
+ log "URL persisted → $STATE_FILE"
+}
+
+test_instance() {
+ local url="$1"
+ local test_url="${url}/search?q=test&format=json"
+ log "Testing $url ..."
+ result=$(curl -sf --max-time 10 "$test_url" 2>/dev/null || true)
+ if [[ -z "$result" ]]; then
+ warn "No response from $url — instance may need a moment to start."
+ return 1
+ fi
+ count=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(len(d.get('results',[])))" <<< "$result" 2>/dev/null || echo "?")
+ log "SearXNG OK: ${count} results"
+}
+
+# ── Option A — Docker ──────────────────────────────────────────────────────────
+
+setup_docker() {
+ log "Docker available — using container path (port $DOCKER_PORT)."
+
+ if docker inspect "$CONTAINER_NAME" &>/dev/null; then
+ log "Container '$CONTAINER_NAME' already exists — starting if not running."
+ docker start "$CONTAINER_NAME" &>/dev/null || true
+ else
+ log "Creating container '$CONTAINER_NAME' ..."
+ docker run -d \
+ --name "$CONTAINER_NAME" \
+ --restart unless-stopped \
+ -p "127.0.0.1:${DOCKER_PORT}:8080" \
+ -e SEARXNG_BASE_URL="http://localhost:${DOCKER_PORT}" \
+ -e SEARXNG_LIMITER=false \
+ -e SEARXNG_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" \
+ searxng/searxng:latest
+ log "Container started."
+ fi
+
+ # Brief wait for the HTTP stack to bind
+ sleep 3
+
+ local url="http://localhost:${DOCKER_PORT}"
+ test_instance "$url" || warn "Run 'docker logs $CONTAINER_NAME' if the instance stays unreachable."
+ persist_url "$url"
+
+ echo ""
+ echo " export SEARXNG_URL=http://localhost:${DOCKER_PORT}"
+ echo ""
+ log "Done (Docker). Add the export above to ~/.zshrc if desired."
+}
+
+# ── Option B — Homebrew ────────────────────────────────────────────────────────
+
+setup_brew() {
+ log "Docker not available — falling back to Homebrew."
+
+ brew install searxng 2>/dev/null || true
+
+ SEARXNG_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/searxng/settings.yml"
+ if [[ ! -f "$SEARXNG_CFG" ]]; then
+ mkdir -p "$(dirname "$SEARXNG_CFG")"
+ cat > "$SEARXNG_CFG" <
/dev/null 2>&1; then
+ setup_docker
+else
+ setup_brew
+fi