9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
475 lines
22 KiB
Python
Executable File
475 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Generate ``data/persona/SOUL.md`` from the static persona template plus a
|
|
detected snapshot of *this* ODS install — what GPU backend it has,
|
|
which model is loaded, which services are running, what URLs are reachable.
|
|
|
|
The goal is for ODS (the agent) to answer accurately when the operator
|
|
asks "what can you do here?" or "is comfyui running?" or "what model are you
|
|
using?" — instead of inventing capabilities it doesn't have, or denying
|
|
ones it does. The dynamic context block is bounded to a few hundred tokens
|
|
so it doesn't bloat Hermes's 16k-token system prompt.
|
|
|
|
Idempotent. Safe to re-run after enabling/disabling services. Designed to
|
|
be invoked from:
|
|
|
|
* installer phase 11 (after services come up — so docker ps reports the
|
|
real state, not the pre-launch one)
|
|
* ``ods restart hermes`` (so the persona reflects current state on every
|
|
container recreate)
|
|
* the operator manually when they want to refresh
|
|
|
|
Reads only — never modifies SOUL.md.template, never touches anything other
|
|
than the output path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Service-id → (display name, one-line "what it does and how the user
|
|
# reaches it"). Tuned so the model gets actionable, specific info — port
|
|
# numbers + intended use — not just service names. The agent itself often
|
|
# can't directly call these (its tools are web_search / files / code /
|
|
# memory / etc.), but it can point the user at the right surface.
|
|
_SERVICE_CAPABILITIES: dict[str, tuple[str, str]] = {
|
|
"llama-server": ("Local LLM inference", "the engine running my chat model (port 8080 internal)"),
|
|
"open-webui": ("Open WebUI", "a desktop-style chat surface, reachable at chat.{device}.local"),
|
|
"dashboard": ("ODS dashboard", "the operator's local web UI for managing services, models, and extensions — `{device}.local`"),
|
|
"hermes": ("Hermes Agent", "that's me — the agent runtime, port 9119 internal"),
|
|
"searxng": ("SearXNG", "privacy-respecting metasearch the operator can hit at port 8888; this is what my `web_search` tool uses under the hood"),
|
|
"brave-search": ("Brave Search backend", "alternative paid web-search backend (when an API key is set)"),
|
|
"perplexica": ("Perplexica", "web-augmented Q&A UI on top of SearXNG"),
|
|
"comfyui": ("ComfyUI", "image + video generation (SDXL Lightning, LTX-2.3). UI on port 8188. The operator can ask me to suggest a prompt for it but I don't generate images directly — I'd point them at ComfyUI"),
|
|
"tts": ("Kokoro TTS", "text-to-speech (port 8880). My replies in ODS Talk can be spoken aloud through this"),
|
|
"whisper": ("Whisper STT", "speech-to-text (port 8000). Used for voice messages in ODS Talk"),
|
|
"embeddings": ("Embeddings server", "vector embeddings (port 8090) — feeds the RAG path"),
|
|
"qdrant": ("Qdrant", "vector database (port 6333) for semantic recall and RAG"),
|
|
"n8n": ("n8n", "visual no-code workflow + automation engine on port 5678. Operators wire scheduled jobs and integrations here"),
|
|
"ape": ("APE", "agentic prompt engineering surface"),
|
|
"opencode": ("OpenCode", "coding agent (an alternative to me for code work)"),
|
|
"openclaw": ("OpenClaw", "older Claude-style agent (being deprecated in favor of me)"),
|
|
"privacy-shield": ("Privacy Shield", "PII scrubber that can sit in front of LLM calls"),
|
|
"token-spy": ("Token Spy", "inference traffic introspection"),
|
|
"tailscale": ("Tailscale", "mesh VPN — the operator can reach this whole stack remotely without exposing ports to the public internet"),
|
|
"langfuse": ("Langfuse", "LLM call tracing + analytics (port 3010)"),
|
|
}
|
|
|
|
# Where the dynamic block gets inserted in SOUL.md.template. The template
|
|
# has the literal marker line; this script replaces it.
|
|
_INSERT_MARKER = "<!-- INSTALLATION_CONTEXT -->"
|
|
|
|
|
|
def _read_env(env_path: Path) -> dict[str, str]:
|
|
"""Parse a .env file into a dict. Ignores comments / blank lines / quotes."""
|
|
out: dict[str, str] = {}
|
|
if not env_path.exists():
|
|
return out
|
|
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
k, _, v = line.partition("=")
|
|
v = v.strip()
|
|
# Strip surrounding quotes if present
|
|
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
|
v = v[1:-1]
|
|
out[k.strip()] = v
|
|
return out
|
|
|
|
|
|
def _container_to_service_map(repo_root: Path) -> dict[str, str]:
|
|
"""Build a {container_name: service_id} index from extension manifests.
|
|
|
|
Manifests declare both ``service.id`` and an explicit ``container_name``
|
|
(often ``ods-<id>`` but sometimes different, e.g. open-webui →
|
|
ods-webui). We need both directions: incoming docker ps names are
|
|
container names, the capability mapping below keys on service-id.
|
|
|
|
Cheap regex over manifest.yaml — avoiding a YAML dep keeps this script
|
|
runnable with Python's stdlib only.
|
|
"""
|
|
index: dict[str, str] = {}
|
|
services_dir = repo_root / "extensions" / "services"
|
|
if not services_dir.is_dir():
|
|
return index
|
|
id_re = re.compile(r"^\s*id:\s*([A-Za-z0-9_-]+)\s*(?:#.*)?$")
|
|
cname_re = re.compile(r"^\s*container_name:\s*([A-Za-z0-9_-]+)\s*(?:#.*)?$")
|
|
for manifest in sorted(services_dir.glob("*/manifest.yaml")):
|
|
sid: str | None = None
|
|
cnames: list[str] = []
|
|
in_service = False
|
|
for raw in manifest.read_text(encoding="utf-8").splitlines():
|
|
if raw.startswith("service:"):
|
|
in_service = True
|
|
continue
|
|
if in_service and not raw.startswith((" ", "\t")) and raw.strip():
|
|
in_service = False
|
|
if not in_service:
|
|
continue
|
|
m = id_re.match(raw)
|
|
if m and sid is None:
|
|
sid = m.group(1)
|
|
m = cname_re.match(raw)
|
|
if m:
|
|
cnames.append(m.group(1))
|
|
if sid:
|
|
# Always include the default `ods-<id>` convention too —
|
|
# some manifests omit container_name when it matches.
|
|
cnames.append(f"ods-{sid}")
|
|
for cname in cnames:
|
|
index[cname] = sid
|
|
return index
|
|
|
|
|
|
def _running_services(repo_root: Path) -> set[str]:
|
|
"""Set of service-ids currently up per ``docker ps``. Cross-references
|
|
container names against the manifest-built {container_name: service_id}
|
|
map so e.g. ``ods-webui`` resolves to service id ``open-webui``.
|
|
Children of compound services (``ods-langfuse-clickhouse`` etc.) get
|
|
collapsed to their parent service-id via prefix match. Containers that
|
|
don't match any manifest are skipped — they're either non-ODS sidecars
|
|
or post-install user additions we shouldn't claim as ours.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", "ps", "--format", "{{.Names}}", "--filter", "name=ods-"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
return set()
|
|
|
|
cmap = _container_to_service_map(repo_root)
|
|
known_sids = set(cmap.values())
|
|
running: set[str] = set()
|
|
for line in result.stdout.splitlines():
|
|
cname = line.strip()
|
|
if not cname:
|
|
continue
|
|
# Exact manifest match first
|
|
if cname in cmap:
|
|
running.add(cmap[cname])
|
|
continue
|
|
# Compound-service collapse: ods-langfuse-clickhouse → "langfuse"
|
|
# if the latter exists as a known service id.
|
|
if cname.startswith("ods-"):
|
|
stem = cname[len("ods-"):]
|
|
parts = stem.split("-")
|
|
for i in range(len(parts), 0, -1):
|
|
candidate = "-".join(parts[:i])
|
|
if candidate in known_sids:
|
|
running.add(candidate)
|
|
break
|
|
return running
|
|
|
|
|
|
def _loaded_model(llm_port: int = 8080) -> str | None:
|
|
"""Best-effort: ask llama-server / Lemonade what's currently loaded.
|
|
Returns the model id, or None on any failure (network, no service)."""
|
|
# Try Lemonade health first — has structured per-model state
|
|
for path in ("/api/v1/health", "/v1/models"):
|
|
try:
|
|
import urllib.request
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{llm_port}{path}", timeout=3) as resp:
|
|
data = json.load(resp)
|
|
except Exception:
|
|
continue
|
|
loaded = data.get("all_models_loaded") if isinstance(data, dict) else None
|
|
if isinstance(loaded, list) and loaded:
|
|
return loaded[0].get("model_name")
|
|
models = data.get("data") if isinstance(data, dict) else None
|
|
if isinstance(models, list) and models:
|
|
return models[0].get("id")
|
|
return None
|
|
|
|
|
|
def _humanize_gpu(env: dict[str, str]) -> str:
|
|
"""One-line GPU/backend description from .env."""
|
|
backend = (env.get("GPU_BACKEND") or "cpu").lower()
|
|
mode = env.get("ODS_MODE", "").lower()
|
|
if backend == "amd":
|
|
if mode == "lemonade":
|
|
return "AMD GPU (ROCm/Vulkan via Lemonade)"
|
|
return "AMD GPU"
|
|
if backend == "nvidia":
|
|
return "NVIDIA GPU (CUDA via llama.cpp)"
|
|
if backend == "apple":
|
|
return "Apple Silicon (Metal via llama.cpp)"
|
|
if backend == "cpu":
|
|
return "CPU-only (no GPU acceleration)"
|
|
return backend
|
|
|
|
|
|
def _capabilities_block(running: set[str], device: str) -> list[str]:
|
|
"""Render the user-facing 'what's installed and reachable' bullets.
|
|
Only lists services that are *actually running* — not just defined.
|
|
Substitutes ``{device}`` into descriptions so the agent gets the live
|
|
LAN hostname instead of a placeholder."""
|
|
bullets: list[str] = []
|
|
seen = set()
|
|
# Display order: search → vision/voice → storage → automation → other.
|
|
priority = [
|
|
"searxng", "perplexica", "brave-search",
|
|
"comfyui",
|
|
"tts", "whisper",
|
|
"qdrant", "embeddings",
|
|
"n8n",
|
|
"ape", "opencode", "privacy-shield", "token-spy",
|
|
"langfuse",
|
|
"open-webui",
|
|
"tailscale",
|
|
]
|
|
for sid in priority:
|
|
if sid in running and sid in _SERVICE_CAPABILITIES:
|
|
label, desc = _SERVICE_CAPABILITIES[sid]
|
|
bullets.append(f"- **{label}** — {desc.replace('{device}', device)}")
|
|
seen.add(sid)
|
|
# Anything running we didn't enumerate gets a generic line so future
|
|
# services don't silently vanish from the agent's knowledge.
|
|
for sid in sorted(running - seen):
|
|
if sid in {"llama-server", "hermes", "hermes-proxy", "dashboard",
|
|
"dashboard-api", "ods-proxy", "litellm"}:
|
|
continue # internal plumbing — agent doesn't need to mention these
|
|
if sid in _SERVICE_CAPABILITIES:
|
|
label, desc = _SERVICE_CAPABILITIES[sid]
|
|
bullets.append(f"- **{label}** — {desc.replace('{device}', device)}")
|
|
else:
|
|
bullets.append(f"- **{sid}** — service running on this host")
|
|
return bullets
|
|
|
|
|
|
def build_context_block(env_path: Path) -> str:
|
|
"""Render the dynamic 'About this installation' Markdown block."""
|
|
env = _read_env(env_path)
|
|
repo_root = env_path.resolve().parent
|
|
running = _running_services(repo_root)
|
|
|
|
device = env.get("ODS_DEVICE_NAME") or socket.gethostname() or "this machine"
|
|
gpu = _humanize_gpu(env)
|
|
model_hint = env.get("LLM_MODEL") or env.get("GGUF_FILE") or "the locally-served model"
|
|
live_model = _loaded_model()
|
|
if live_model:
|
|
model_hint = live_model
|
|
|
|
ctx_size = env.get("CTX_SIZE") or env.get("MAX_CONTEXT") or "?"
|
|
if ctx_size.isdigit() and int(ctx_size) >= 1024:
|
|
ctx_pretty = f"{int(ctx_size) // 1024}k"
|
|
else:
|
|
ctx_pretty = ctx_size
|
|
|
|
talk_host = f"talk.{device}.local"
|
|
dash_host = f"{device}.local"
|
|
chat_host = f"chat.{device}.local"
|
|
|
|
bullets = _capabilities_block(running, device)
|
|
|
|
# Lead with the install identity — hard factual claims phrased so the
|
|
# model doesn't drift into running tool calls to "verify." Hermes's
|
|
# own system prompt advertises a Hermes dashboard at port 9119; the
|
|
# model has a strong prior to mention that when asked about
|
|
# "dashboard," which is wrong on a ODS install. We explicitly
|
|
# distinguish below.
|
|
lines: list[str] = [
|
|
"## About this ODS install — read this BEFORE answering questions about your environment",
|
|
"",
|
|
"**You are running inside ODS, a fully-local AI stack the operator installed on their own hardware.** Hermes Agent (what you run on) is one component of that stack — not the whole thing. When the operator asks about \"the dashboard,\" \"the system,\" \"what's running,\" \"what you can do,\" or \"your hardware,\" they mean the ODS install around you, not Hermes Agent in isolation.",
|
|
"",
|
|
"**These facts are authoritative. Don't run tool calls (terminal, file probes, GPU queries) to second-guess them — they are auto-generated from this exact install and refreshed when services change. Quote them directly:**",
|
|
"",
|
|
f"- **Host**: `{device}` (LAN hostname `{dash_host}`)",
|
|
f"- **GPU / backend**: {gpu}",
|
|
f"- **Chat model serving you right now**: `{model_hint}` (context window: {ctx_pretty})",
|
|
f"- **ODS admin dashboard** (model management, extensions catalog, system status): `http://{dash_host}` — this is what the operator means by \"the dashboard.\" NOT Hermes's internal `:9119` endpoint, which is just where you live.",
|
|
f"- **Mobile chat portal (ODS Talk)**: `http://{talk_host}` — the operator scans an owner-card QR code to land here on their phone",
|
|
f"- **Open WebUI** (desktop-style chat surface, separate from ODS Talk): `http://{chat_host}`",
|
|
"",
|
|
"## Services running on this install right now",
|
|
"",
|
|
"ODS has an **extensions system** — services bundled with the stack that the operator can enable/disable from the dashboard's Extensions page without reinstalling. The list below is what's currently running. Some are tools you can call directly through your own tool-calling layer; others are surfaces you can point the operator at.",
|
|
"",
|
|
"**When asked verbally about this list, summarize it conversationally — don't recite the bullets.** A good answer is one or two sentences that names a few of the most relevant services for the question, not the whole catalog. Example: *\"Web search through SearXNG, image generation in ComfyUI, voice via Kokoro and Whisper, plus workflows in n8n — and there's more on the dashboard.\"* The bullets below are your reference; speak them as natural sentences.",
|
|
"",
|
|
]
|
|
if bullets:
|
|
lines.extend(bullets)
|
|
else:
|
|
lines.append("- (no extension services running — only the core chat path is up)")
|
|
|
|
lines.extend([
|
|
"",
|
|
"## Your direct capabilities vs. what the operator reaches separately",
|
|
"",
|
|
"**You can directly invoke these** via your tool-calling layer:",
|
|
"- `web_search` — backed by the SearXNG service above (privacy-respecting, no rate limits)",
|
|
"- `web_extract` — fetch a specific URL the operator names",
|
|
"- `read_file` / `write_file` / `search_files` — in your sandboxed agent workspace",
|
|
"- `execute_code` — sandboxed Python",
|
|
"- `memory` — save / recall facts about the operator across sessions",
|
|
"- `text_to_speech` — your replies on ODS Talk can be spoken aloud",
|
|
"- A `skills` catalog with dozens of pluggable skill modules for specific domains (github, notion, kanban, research, etc.)",
|
|
"",
|
|
"**You can't directly invoke these but can refer the operator to them** when they ask:",
|
|
"- **Image / video generation** → ComfyUI (if running above). The operator opens it from the ODS dashboard. You can help by suggesting prompts, but you don't generate the image yourself.",
|
|
"- **Automated workflows / scheduled jobs / third-party integrations** → n8n (if running above). For simple recurring agent tasks you have your own `cronjob` tool.",
|
|
"- **Enable more extensions** → Extensions page on the ODS dashboard (`http://" + dash_host + "/extensions`). Catalog includes Aider, OpenCode, Privacy Shield, Tailscale, Perplexica, etc.",
|
|
"- **Swap or download a different LLM** → Models page on the ODS dashboard. Operators can pull from Hugging Face directly.",
|
|
"- **Remote access from outside the LAN** → Tailscale (if running above)",
|
|
"",
|
|
"When the operator names a service you don't see in the list above, say so honestly — \"that's not running on this install\" — rather than invent it. The list reflects what `docker ps` reports as of the last regeneration.",
|
|
])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def build_compact_soul(env_path: Path) -> str:
|
|
"""Render a short local profile for backends with tight prompt/schema limits."""
|
|
env = _read_env(env_path)
|
|
repo_root = env_path.resolve().parent
|
|
running = _running_services(repo_root)
|
|
|
|
device = env.get("ODS_DEVICE_NAME") or socket.gethostname() or "this machine"
|
|
gpu = _humanize_gpu(env)
|
|
model = _loaded_model() or env.get("LLM_MODEL") or env.get("GGUF_FILE") or "the locally-served model"
|
|
ctx_size = env.get("CTX_SIZE") or env.get("MAX_CONTEXT") or "?"
|
|
service_names: list[str] = []
|
|
for sid in sorted(running):
|
|
if sid in {"llama-server", "hermes", "hermes-proxy", "dashboard",
|
|
"dashboard-api", "ods-proxy", "litellm"}:
|
|
continue
|
|
label = _SERVICE_CAPABILITIES.get(sid, (sid, ""))[0]
|
|
service_names.append(label)
|
|
service_line = ", ".join(service_names) if service_names else "core local chat services"
|
|
|
|
return "\n".join([
|
|
"# ODS - compact local profile",
|
|
"",
|
|
"You are ODS, the resident assistant on this ODS install. "
|
|
"Keep answers brief, natural, and accurate. Use tools when the task needs them.",
|
|
"",
|
|
"## Install facts",
|
|
f"- Host: `{device}`",
|
|
f"- GPU/backend: {gpu}",
|
|
f"- Local model: `{model}` (context window: {ctx_size})",
|
|
f"- Dashboard: `http://{device}.local`",
|
|
f"- ODS Talk: `http://talk.{device}.local`",
|
|
f"- Open WebUI: `http://chat.{device}.local`",
|
|
f"- Running services/extensions: {service_line}",
|
|
"",
|
|
"## Direct tools",
|
|
"- Use `web_search` for current or external facts.",
|
|
"- Use `web_extract` for specific URLs.",
|
|
"- Use file tools for reading, writing, and searching workspace files.",
|
|
"- Use `execute_code` for calculations, snippets, and small scripts.",
|
|
"",
|
|
"When asked about this environment, answer from these install facts. "
|
|
"Do not invent services that are not listed. If the operator asks for "
|
|
"image/video generation, workflows, model downloads, or extensions, point "
|
|
"them to the ODS dashboard unless that service is listed above.",
|
|
"",
|
|
])
|
|
|
|
|
|
def build_soul(
|
|
template_path: Path,
|
|
env_path: Path,
|
|
output_path: Path,
|
|
profile: str = "full",
|
|
) -> bool:
|
|
"""Render the assembled SOUL.md. Returns True if the file actually
|
|
changed (so callers can decide whether to bounce Hermes)."""
|
|
template = template_path.read_text(encoding="utf-8")
|
|
context = build_context_block(env_path)
|
|
|
|
if _INSERT_MARKER in template:
|
|
assembled = template.replace(_INSERT_MARKER, context)
|
|
else:
|
|
# Template doesn't have the marker yet — append the block so
|
|
# operators upgrading from an older template still get the
|
|
# installation-context behaviour.
|
|
assembled = template.rstrip() + "\n\n" + context + "\n"
|
|
|
|
if profile == "local-lemonade":
|
|
assembled = build_compact_soul(env_path)
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# Self-heal a pathological state: Docker's bind-mount engine auto-creates
|
|
# the source path as a *directory* when the path doesn't exist at
|
|
# compose-up time. If a previous install ran compose-up before this
|
|
# script generated the file, ``data/persona/SOUL.md`` is now an empty
|
|
# directory, and the next compose-up keeps failing with "not a directory:
|
|
# Are you trying to mount a directory onto a file." Remove that directory
|
|
# so we can write the real file in its place. Caught when re-running
|
|
# /ods-fleet-test on mac-mini after a prior failed install.
|
|
if output_path.exists() and not output_path.is_file():
|
|
import shutil
|
|
shutil.rmtree(output_path)
|
|
previous = output_path.read_text(encoding="utf-8") if output_path.is_file() else ""
|
|
if previous == assembled:
|
|
return False
|
|
output_path.write_text(assembled, encoding="utf-8")
|
|
return True
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
repo_root = Path(__file__).resolve().parent.parent
|
|
parser = argparse.ArgumentParser(
|
|
description="Build data/persona/SOUL.md from the static persona + detected install state.",
|
|
)
|
|
parser.add_argument(
|
|
"--template",
|
|
type=Path,
|
|
default=repo_root / "extensions" / "services" / "hermes" / "SOUL.md.template",
|
|
)
|
|
parser.add_argument(
|
|
"--env",
|
|
type=Path,
|
|
default=repo_root / ".env",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
# Keep this on the host-user side of the data directory tree —
|
|
# data/hermes/ is owned by uid 10000 (HERMES_UID) so the host's
|
|
# operator can't write into it. data/persona/ is a separate
|
|
# operator-owned directory bind-mounted ro into the Hermes
|
|
# container at /opt/hermes/docker/SOUL.md.
|
|
default=repo_root / "data" / "persona" / "SOUL.md",
|
|
)
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="Print the assembled file to stdout instead of writing it.",
|
|
)
|
|
parser.add_argument(
|
|
"--profile",
|
|
choices=["full", "local-lemonade"],
|
|
default="full",
|
|
help="Prompt profile to render. local-lemonade keeps Windows AMD prompts compact.",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.check:
|
|
ctx = build_compact_soul(args.env) if args.profile == "local-lemonade" else build_context_block(args.env)
|
|
sys.stdout.write(ctx)
|
|
sys.stdout.write("\n")
|
|
return 0
|
|
|
|
if not args.template.exists():
|
|
print(f"ERROR: template not found at {args.template}", file=sys.stderr)
|
|
return 2
|
|
|
|
changed = build_soul(args.template, args.env, args.output, profile=args.profile)
|
|
print("changed" if changed else "unchanged")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|