diff --git a/.env.example b/.env.example index ba747d0..19aeabf 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,13 @@ TELEGRAM_BOT_TOKEN= # Leave empty on first run, message the bot, and it will reply with your chat ID. TELEGRAM_ALLOWED_USERS= +# --- optional: the LLM (assistant) layer --- +# claude = free-text chat is handled by Claude Code (the default; needs the claude CLI). +# off = command-only mode: the bot runs its predefined commands only, no LLM at all. +# When set to claude but the claude binary is missing, the gateway falls back to +# command-only mode instead of refusing to start. +# OGMA_LLM=claude + # --- optional: paths / runtime --- # Path to the claude CLI (default: ~/.local/bin/claude) # CLAUDE_BIN=/home/youruser/.local/bin/claude diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e45039..7f9edba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: python-version: "3.11" - name: Python syntax (py_compile) - run: python3 -m py_compile gateway.py hooks/*.py bin/news-fetch + run: python3 -m py_compile gateway.py llm_claude.py hooks/*.py bin/news-fetch - name: Shell syntax (bash -n) run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6c3a2..d79298a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project aims to follow [Semantic Versioning](https://semver.org/). +## [Unreleased] — feature/optional-llm + +Phase 1 of the repositioning roadmap (docs/roadmap.md): Ogma's core is the secure +remote command runner; Claude Code becomes an optional assistant layer. + +### Added +- **Command-only mode.** `OGMA_LLM=off` (or simply a missing `claude` binary) runs the + gateway as a pure command runner: whitelisted ogmactl commands only, free text gets a + refusal, LLM commands (`/new`, `/model`, `/effort`, `/fallback`, `/briefing`, `/dream`, + `/search`) are refused and dropped from the Telegram menu and `/help`. Python stdlib + is then the only dependency. +- `bin/setup` gained an `llm` section: choose "assistant layer" vs. "commands only" on + first run (and via `--reconfigure llm`); command-only installs skip the + persona/model/overlays/skills sections. `--check` and the summary are mode-aware. +- `docs/roadmap.md` — the 4-phase plan this comes from. + +### Changed +- Everything Claude-specific moved out of `gateway.py` into the new `llm_claude.py` + (headless invocation, session persistence, model/effort/fallback handling). The + gateway imports it only when the LLM layer is on and talks to it through returned + reply strings — groundwork for the Phase 3 transport abstraction. +- A missing `claude` binary no longer aborts gateway startup (was `sys.exit`); it logs + and falls back to command-only mode. + ## [1.3.0] — 2026-07-02 One release for the rest of the 2026-07-01 code-review findings. diff --git a/README.md b/README.md index fd1de76..aa2abc7 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,17 @@ [![CI](https://github.com/eric-wien/ogma/actions/workflows/ci.yml/badge.svg)](https://github.com/eric-wien/ogma/actions/workflows/ci.yml) -A minimal personal-assistant gateway: talk to **Claude Code** from **Telegram**, from anywhere. +A secure remote command runner for your own machine, driven from **Telegram** — with an optional +**Claude Code** assistant layer on top. Define the commands your box exposes (a strict whitelist, +no shell), run them from your phone from anywhere; optionally let free-text chat go to a resumable +headless `claude` session for a conversation partner that can perform tasks and analyze. Inspired by [Nous Research's Hermes Agent](https://github.com/NousResearch/hermes-agent), rebuilt on Claude Code's native skills + memory + subagents. Named for Ogma, the Celtic god of eloquence and the inventor of writing. -One always-on Python process (stdlib only) long-polls Telegram and bridges each chat to a -resumable headless `claude` session. No inbound ports, no pip installs, no API key plumbing — it -reuses your existing Claude Code auth on the box. +One always-on Python process (stdlib only) long-polls Telegram. No inbound ports, no pip installs, +no API key plumbing — the optional LLM layer reuses your existing Claude Code auth on the box, and +without it there is no Claude dependency at all (`OGMA_LLM=off`). ``` gateway.py the bridge (Telegram long-poll <-> `claude -p`) @@ -31,6 +34,15 @@ something (edit files, run code), it files a **ticket** instead of faking it; yo full session with the `tickets` skill, and shared memory + skills carry the learning forward. The full design — and why the bot is intentionally limited — is in **[docs/workflow.md](docs/workflow.md)**. +### Command-only mode (no Claude required) +The command runner works entirely without the LLM: set `OGMA_LLM=off` (or simply don't install the +`claude` CLI) and the gateway runs your whitelisted `ogmactl` commands and nothing else — free text +gets a polite refusal, and the LLM commands (`/new`, `/model`, `/briefing`, `/search`, …) disappear +from the menu. This is the core of Ogma: a chat-driven remote terminal restricted to commands **you** +predefined. Add your own via `bin/ogmactl.local` + `config/commands.local.json` (see +[Self-management](#self-management-ogmactl)) — no LLM, no fork, no gateway edits. The direction from +here is sketched in **[docs/roadmap.md](docs/roadmap.md)**. + > **Self-host model.** Ogma is meant to be run by you, on your own always-on machine, talking to > your own Telegram bot, using your own Claude Code auth. There is no hosted service and nothing > phones home. @@ -67,7 +79,7 @@ install without changing anything, run `bin/setup --check` — it validates your model/effort/fallback, the `claude` CLI, the service, and installed skills. **Re-running on an existing install.** First run does the full interview. When `.env` already exists, -`bin/setup` instead lets you pick **which sections to revisit** — `env`, `persona`, `model`, +`bin/setup` instead lets you pick **which sections to revisit** — `env`, `llm`, `persona`, `model`, `overlays`, `skills`, `systemd`, `auth` — so a small tweak doesn't walk the whole flow. Pick from the menu, or go non-interactive: `bin/setup --reconfigure systemd,skills` (or `--all` for the classic full run). This is the easiest way to **install a newly-added systemd unit after a `git pull`**: @@ -138,18 +150,26 @@ See [`skills/README.md`](skills/README.md) for details and how to write your own > 216/GROUP for a user service). ## Commands +Always available (the command runner — deterministic, no LLM call): +- `/status` `/health` `/logs` `/restart` `/backup` `/remember` `/ticket` `/tickets` — see + [Self-management](#self-management-ogmactl) +- your own host-local commands from `config/commands.local.json` +- `/help` — usage + +Only with the LLM layer (`OGMA_LLM=claude`, the default when the `claude` CLI is installed): - `/new` — start a fresh Claude session for this chat - `/model [name]` — show or change the model live (`sonnet`, `haiku`, `opus`, a full id, or `default`); persists to `.env` - `/effort [level]` — show or change reasoning effort live (`low`/`medium`/`high`/`xhigh`/`max`/`default`); persists to `.env` - `/fallback [name]` — model used automatically if the main one is unavailable (`none` to clear); persists to `.env` -- `/help` — usage -- anything else — sent to Claude +- `/briefing` `/dream` `/search` — the assistant routines +- anything else — sent to Claude (in command-only mode free text gets a refusal instead) ## Configuration reference (`.env`) | Variable | Purpose | Default | |---|---|---| | `TELEGRAM_BOT_TOKEN` | BotFather token (**required**) | — | | `TELEGRAM_ALLOWED_USERS` | comma-separated allowed chat IDs (**required**) | — | +| `OGMA_LLM` | `claude` = assistant layer on; `off` = command-only mode (no LLM) | `claude` if the CLI exists | | `CLAUDE_BIN` | path to the `claude` CLI | `~/.local/bin/claude` | | `CLAUDE_TIMEOUT` | per-message timeout (seconds) | `300` | | `OGMA_MAX_CONCURRENT` | max concurrent Claude runs across chats (raise only on a roomy host) | `1` | diff --git a/bin/setup b/bin/setup index 5bd1fd6..42b16f7 100755 --- a/bin/setup +++ b/bin/setup @@ -42,18 +42,23 @@ PY # Non-interactive health/config check — `bin/setup --check`. Changes nothing. do_check() { - local tok allowed cb u problems=0 + local tok allowed llm cb u problems=0 step "Ogma config check ${c_dim}($OGMA_DIR)${c_rst}" tok="$(grep -E '^TELEGRAM_BOT_TOKEN=' "$ENV" 2>/dev/null | cut -d= -f2-)" allowed="$(grep -E '^TELEGRAM_ALLOWED_USERS=' "$ENV" 2>/dev/null | cut -d= -f2-)" + llm="$(grep -E '^OGMA_LLM=' "$ENV" 2>/dev/null | cut -d= -f2-)" cb="$(command -v claude || echo "$HOME/.local/bin/claude")" [ -f "$ENV" ] && ok ".env present" || { err ".env missing — run bin/setup"; problems=1; } [ -n "$tok" ] && ok "token set" || { err "token MISSING"; problems=1; } [ -n "$allowed" ] && ok "allowed chats: $allowed" || { err "allowed chats MISSING"; problems=1; } - printf ' model : %s\n' "$(grep -E '^OGMA_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- | sed 's/^$/(Claude Code default)/')" - printf ' effort : %s\n' "$(grep -E '^OGMA_EFFORT=' "$ENV" 2>/dev/null | cut -d= -f2- | sed 's/^$/(default)/')" - printf ' fallback : %s\n' "$(grep -E '^OGMA_FALLBACK_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- | sed 's/^$/(none)/')" - [ -x "$cb" ] && ok "claude CLI: $cb" || { err "claude CLI missing ($cb)"; problems=1; } + if [ "$llm" = "off" ]; then + ok "LLM layer: off (command-only mode — no claude CLI needed)" + else + printf ' model : %s\n' "$(grep -E '^OGMA_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- | sed 's/^$/(Claude Code default)/')" + printf ' effort : %s\n' "$(grep -E '^OGMA_EFFORT=' "$ENV" 2>/dev/null | cut -d= -f2- | sed 's/^$/(default)/')" + printf ' fallback : %s\n' "$(grep -E '^OGMA_FALLBACK_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- | sed 's/^$/(none)/')" + [ -x "$cb" ] && ok "claude CLI: $cb" || warn "claude CLI missing ($cb) — gateway will run command-only until it's installed" + fi if [ -n "$tok" ] && command -v curl >/dev/null; then # Token goes to curl via --config on a private fd, not argv (world-readable in /proc). u="$(curl -sS -m 12 --config <(printf 'url = "https://api.telegram.org/bot%s/getMe"\n' "$tok") 2>/dev/null | "$PY" -c 'import sys,json @@ -66,9 +71,11 @@ print(("OK "+((d.get("result") or {}).get("username") or "?")) if d.get("ok") el printf ' service : %s\n' "$(systemctl --user is-active ogma-gateway 2>/dev/null || echo inactive)" printf ' backup : %s\n' "$(systemctl --user is-active ogma-backup.timer 2>/dev/null || echo inactive)" fi - for s in tickets session-search daily-briefing; do - [ -e "$HOME/.claude/skills/$s" ] && ok "skill: $s" || warn "skill not installed: $s" - done + if [ "$llm" != "off" ]; then + for s in tickets session-search daily-briefing; do + [ -e "$HOME/.claude/skills/$s" ] && ok "skill: $s" || warn "skill not installed: $s" + done + fi say "" [ "$problems" = 0 ] && say "${c_grn}All good.${c_rst}" || say "${c_yel}Issues found — see above.${c_rst}" return "$problems" @@ -90,7 +97,7 @@ Usage: bin/setup [--check] [--all] [--reconfigure LIST] --check non-interactive config/health check; changes nothing. --all reconfigure every section (the classic full interview). --reconfigure LIST reconfigure only these sections (comma/space separated). - Sections: env persona model overlays skills systemd auth + Sections: env llm persona model overlays skills systemd auth e.g. bin/setup --reconfigure systemd,skills EOF exit 0 ;; @@ -107,14 +114,14 @@ command -v "$PY" >/dev/null || { err "python3 not found — install it first."; ok "python3: $($PY --version 2>&1)" command -v curl >/dev/null && ok "curl present" || warn "curl not found — Telegram delivery (tg-send/briefing) needs it." CLAUDE_BIN="$(command -v claude || true)"; [ -n "$CLAUDE_BIN" ] || CLAUDE_BIN="$HOME/.local/bin/claude" -if [ -x "$CLAUDE_BIN" ]; then ok "claude CLI: $CLAUDE_BIN"; else warn "claude CLI not found at $CLAUDE_BIN — set CLAUDE_BIN in .env, or install Claude Code."; fi +if [ -x "$CLAUDE_BIN" ]; then ok "claude CLI: $CLAUDE_BIN"; else warn "claude CLI not found at $CLAUDE_BIN — that's fine for command-only mode; install Claude Code (or set CLAUDE_BIN in .env) to enable the assistant layer."; fi HAVE_SYSTEMD=0; if command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; then HAVE_SYSTEMD=1; ok "systemd --user available"; else warn "systemd --user not available — you'll run the gateway manually."; fi # --- Decide what to (re)configure ------------------------------------------- # First run (no .env) does the full interview. Re-running on an existing install # lets you pick just the sections you want, so a routine `git pull` + small tweak # (or installing a newly-added systemd unit) doesn't walk the whole flow again. -SECTIONS="env persona model overlays skills systemd auth" +SECTIONS="env llm persona model overlays skills systemd auth" declare -A WANT if [ -f "$ENV" ]; then chmod 600 "$ENV" 2>/dev/null || true @@ -124,12 +131,13 @@ if [ -f "$ENV" ]; then step "What do you want to (re)configure?" say ".env already exists — pick the sections to revisit (the rest stay as-is)." say " 1) env Telegram token / your name / weather location" - say " 2) persona assistant name / style / language" - say " 3) model model / reasoning effort / fallback" - say " 4) overlays regenerate host notes + Claude settings overlay" - say " 5) skills (re)install skills into ~/.claude/skills" - say " 6) systemd (re)install systemd --user units (e.g. after a git pull adds one)" - say " 7) auth authorize a Telegram chat" + say " 2) llm assistant layer on/off (Claude Code vs. command-only)" + say " 3) persona assistant name / style / language" + say " 4) model model / reasoning effort / fallback" + say " 5) overlays regenerate host notes + Claude settings overlay" + say " 6) skills (re)install skills into ~/.claude/skills" + say " 7) systemd (re)install systemd --user units (e.g. after a git pull adds one)" + say " 8) auth authorize a Telegram chat" say "${c_dim} Enter numbers or names (comma/space separated), or 'all'. Blank = all.${c_rst}" sel="$(ask "Sections" "all")" fi @@ -138,12 +146,13 @@ if [ -f "$ENV" ]; then case "$tok" in all) for s in $SECTIONS; do WANT[$s]=1; done ;; 1|env) WANT[env]=1 ;; - 2|persona) WANT[persona]=1 ;; - 3|model) WANT[model]=1 ;; - 4|overlays) WANT[overlays]=1 ;; - 5|skills) WANT[skills]=1 ;; - 6|systemd) WANT[systemd]=1 ;; - 7|auth) WANT[auth]=1 ;; + 2|llm) WANT[llm]=1 ;; + 3|persona) WANT[persona]=1 ;; + 4|model) WANT[model]=1 ;; + 5|overlays) WANT[overlays]=1 ;; + 6|skills) WANT[skills]=1 ;; + 7|systemd) WANT[systemd]=1 ;; + 8|auth) WANT[auth]=1 ;; *) warn "ignoring unknown section '$tok'" ;; esac done @@ -157,7 +166,7 @@ want() { [ -n "${WANT[$1]:-}" ]; } # offer to restart it at the end so the change actually takes effect. (skills/auth # don't need a gateway restart; systemd's daemon-reload doesn't either.) NEED_RESTART=0 -for s in env persona model overlays; do want "$s" && NEED_RESTART=1; done +for s in env llm persona model overlays; do want "$s" && NEED_RESTART=1; done # --- .env (token / your name / weather) ------------------------------------- if want env; then @@ -174,6 +183,27 @@ if want env; then [ -n "$loc" ] && { set_env OGMA_WEATHER_LOC "$loc"; ok "Weather location: $loc"; } fi +# --- Assistant layer: Claude Code or command-only ---------------------------- +# The command runner (whitelisted ogmactl commands over Telegram) always works. +# The assistant layer (free-text chat via Claude Code) is optional — without it +# the persona/model/overlays/skills sections have nothing to configure, so +# turning it off also drops them from THIS run. +if want llm; then +step "Assistant layer (optional)" +say "Ogma always runs your predefined commands. The assistant layer adds free-text" +say "chat handled by Claude Code (needs the claude CLI + your Claude auth on this box)." +cur_llm="$(grep -E '^OGMA_LLM=' "$ENV" 2>/dev/null | cut -d= -f2-)" +def_llm="y"; { [ "$cur_llm" = "off" ] || [ ! -x "$CLAUDE_BIN" ]; } && def_llm="n" +if yes "Enable the Claude Code assistant layer?" "$def_llm"; then + set_env OGMA_LLM "claude"; ok "Assistant layer enabled (OGMA_LLM=claude)." + [ -x "$CLAUDE_BIN" ] || warn "claude CLI still missing — the gateway will run command-only until it's installed." +else + set_env OGMA_LLM "off"; ok "Command-only mode (OGMA_LLM=off) — no LLM, no Claude dependency." + for s in persona model overlays skills; do unset "WANT[$s]"; done + say "${c_dim}Skipping persona/model/overlays/skills — they only apply to the assistant layer.${c_rst}" +fi +fi + # --- Persona (optional: name, style, language) ------------------------------- if want persona; then step "Persona (optional)" @@ -490,15 +520,22 @@ step "Done" tok="$(grep -E '^TELEGRAM_BOT_TOKEN=' "$ENV" | cut -d= -f2-)" allowed="$(grep -E '^TELEGRAM_ALLOWED_USERS=' "$ENV" | cut -d= -f2-)" +llm_mode="$(grep -E '^OGMA_LLM=' "$ENV" | cut -d= -f2-)" say "${c_bold}Config summary${c_rst}" printf ' token : %s\n' "$([ -n "$tok" ] && echo set || echo MISSING)" printf ' allowed chats : %s\n' "${allowed:-MISSING}" -printf ' model : %s\n' "$(grep -E '^OGMA_MODEL=' "$ENV" | cut -d= -f2- | sed 's/^$/(Claude Code default)/')" -printf ' effort : %s\n' "$(grep -E '^OGMA_EFFORT=' "$ENV" | cut -d= -f2- | sed 's/^$/(default)/')" -printf ' fallback : %s\n' "$(grep -E '^OGMA_FALLBACK_MODEL=' "$ENV" | cut -d= -f2- | sed 's/^$/(none)/')" -printf ' claude CLI : %s\n' "$([ -x "$CLAUDE_BIN" ] && echo "$CLAUDE_BIN" || echo MISSING)" +if [ "$llm_mode" = "off" ]; then + printf ' llm : off (command-only mode)\n' +else + printf ' model : %s\n' "$(grep -E '^OGMA_MODEL=' "$ENV" | cut -d= -f2- | sed 's/^$/(Claude Code default)/')" + printf ' effort : %s\n' "$(grep -E '^OGMA_EFFORT=' "$ENV" | cut -d= -f2- | sed 's/^$/(default)/')" + printf ' fallback : %s\n' "$(grep -E '^OGMA_FALLBACK_MODEL=' "$ENV" | cut -d= -f2- | sed 's/^$/(none)/')" + printf ' claude CLI : %s\n' "$([ -x "$CLAUDE_BIN" ] && echo "$CLAUDE_BIN" || echo MISSING)" +fi { [ -n "$tok" ] && [ -n "$allowed" ]; } || warn "Token and/or allowed chats missing — Ogma won't respond until both are set (re-run setup)." -[ -x "$CLAUDE_BIN" ] || warn "claude CLI not found — set CLAUDE_BIN in .env, or install Claude Code." +if [ "$llm_mode" != "off" ] && [ ! -x "$CLAUDE_BIN" ]; then + warn "claude CLI not found — the gateway runs command-only until you install Claude Code (or set CLAUDE_BIN)." +fi if [ "$HAVE_SYSTEMD" = 1 ] && [ -n "$tok" ] && [ -n "$allowed" ]; then if systemctl --user is-active --quiet ogma-gateway 2>/dev/null; then diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..7051d1e --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,91 @@ +# Ogma roadmap — from "Claude bridge" to "secure remote command runner" + +*Drafted 2026-07-02. Direction: the core product is secure execution of a +predefined set of commands on a remote machine through a chat interface. +The Claude Code integration becomes an optional layer on top — a nice +conversation partner that can perform tasks and analyze, but not a +prerequisite. Anyone should be able to run Ogma commands-only and code all +functionality themselves.* + +The architecture is already most of the way there: the +`gateway → whitelisted ogmactl subcommands → commands.local.json` chain **is** +the secure command runner — deterministic, shell-free, double-gated, and +LLM-free. What has to change is the framing: today the code treats Claude as +the foundation (`gateway.py` refuses to start without the `claude` binary, and +every unrecognized message falls through to `ask_claude()`). The phases below +invert that. + +## Phase 1 — Invert the dependency (make Claude optional) + +Make the gateway boot and run in **command-only mode** when Claude is absent +or disabled. + +- `OGMA_LLM=claude|off` setting. Default: `claude` when the binary exists, so + existing installs change nothing. A missing binary becomes a log line + + command-only mode instead of `sys.exit`. +- Extract everything Claude-specific into one module (`llm_claude.py`): + `ask_claude()`, sessions.json handling, `/new`, `/model`, `/effort`, + `/fallback`, the `/search` prompt rewrite. The gateway only uses it when the + mode is on. This seam is what makes "optional" real. +- Free-text fallback in command-only mode: reply with help + "this instance + runs commands only". Menu and `/help` are built conditionally so a + command-only bot doesn't advertise `/model`. +- `bin/setup` offers two paths: "with Claude Code" vs. "commands only". +- README repositioned around the command runner; Claude becomes the optional + assistant layer. + +## Phase 2 — Harden the command runner into the actual product + +Features the LLM used to paper over, now first-class: + +- **Confirmation for destructive commands** — `confirm: true` in + commands.local.json requires a second step (reply/inline keyboard) before + running. `/restart` or a future reboot command shouldn't fire on a typo. +- **Argument validation per command** — `args` is currently only a help hint; + add optional per-arg regex/enum so validation happens in the gateway, not in + every ogmactl.local case. +- **Audit log** — append-only `state/audit.log`: timestamp, chat_id, command, + args, exit code. Cheap, and it's what lets us say "secure" with a straight + face. +- **Roles** — admin vs. read-only chat IDs, per-command `role` field. Matters + the moment a second person uses an instance. +- **Output handling** — send long output as a document instead of many + 4000-char chunks. +- Keep the two-layer design (JSON declares, ogmactl gates) — it is the + security story. Document "write your own ogmactl.local subcommand" as *the* + extension mechanism (tutorial in docs/): that's the answer for people who + want to code everything themselves. + +## Phase 3 — Transport abstraction (the Signal on-ramp) + +Pull Telegram specifics (`tg()`, `send()`, typing, the `getUpdates` loop, +menu registration) behind a small transport interface: `poll() → messages`, +`send(chat, text)`, capability flags for typing/menus/files. Then a +`signal-cli` backend is a second implementation of a small interface instead +of a rewrite. Cut the seam first; build the Signal backend later. + +## Phase 4 — Reclassify the assistant features + +`briefing`, `dream`, `search`, persona, and the memory-persist hook belong to +the optional LLM layer. The twofold system and ticketing stay. End state, +three tiers: + +1. **Core** — gateway + command runner. Python stdlib only, no other + dependencies. +2. **LLM layer** — Claude Code, sessions, search. +3. **Assistant layer** — briefing, dream, memory, persona. + +## Order rationale + +Phase 1 before 2 because the module seam determines where Phase 2 features +live. Phase 2 before 3 because hardening delivers user value now; Signal is a +horizon goal. Phase 3 before writing the Signal backend so the migration is +an implementation, not a refactor. Nothing breaks running instances: +command-only mode is opt-in-by-absence. + +## Security note (pre-existing) + +The gateway authorizes on `chat.id`. Fine for private chats, but if a group +chat is ever allow-listed, every member of that group can run commands. +Check `from.id` as well before the "secure" claim goes into the README +(candidate for Phase 2). diff --git a/gateway.py b/gateway.py index 9ad7937..0366a28 100755 --- a/gateway.py +++ b/gateway.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 """ -Ogma gateway — a minimal Telegram <-> Claude Code bridge. +Ogma gateway — a secure remote command runner over Telegram, with an optional +Claude Code assistant layer. One always-on process. Long-polls Telegram (no inbound ports needed, works behind -NAT/Tailscale), and for each message from an allow-listed chat it invokes the local -`claude` CLI in headless mode, keeping one resumable Claude session per chat. - -Zero third-party dependencies: Python standard library + the `claude` binary only. +NAT/Tailscale) and, for each message from an allow-listed chat, either runs a +whitelisted ogmactl command (deterministic, no LLM) or — when the LLM layer is +enabled (OGMA_LLM=claude, the default when the `claude` binary exists) — hands +free text to a resumable headless Claude Code session (see llm_claude.py). +Without the LLM layer this is a pure command runner: Python stdlib only. """ from __future__ import annotations @@ -67,28 +69,11 @@ ALLOWED = { CLAUDE_BIN = os.environ.get( "CLAUDE_BIN", str(Path.home() / ".local/bin/claude") ) -WORKDIR = cfg("WORKDIR", str(BASE / "workspace")) -PERMISSION_MODE = cfg("PERMISSION_MODE") # e.g. acceptEdits -ALLOWED_TOOLS = cfg("ALLOWED_TOOLS") # e.g. "Read WebSearch" -MODEL = cfg("MODEL") -FALLBACK_MODEL = cfg("FALLBACK_MODEL") # auto-fallback when the primary is unavailable -EFFORT = cfg("EFFORT").lower() # low|medium|high|xhigh|max (empty = CLI default) -CLAUDE_TIMEOUT = int(os.environ.get("CLAUDE_TIMEOUT", "300")) -SESSIONS_FILE = BASE / "sessions.json" -ENV_FILE = BASE / ".env" -EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max") -# What a model alias/id may look like (/model, /fallback). Anything outside this — -# especially whitespace/newlines — is refused before it reaches .env or the CLI. -MODEL_NAME_RE = re.compile(r"^[A-Za-z0-9._:-]{1,64}$") -# Max concurrent Claude runs. Default 1 — small boxes (e.g. a Pi) OOM if several run at once. -MAX_CONCURRENT = max(1, int(cfg("MAX_CONCURRENT", "1") or "1")) _inflight: set = set() # chat_ids with a message currently being handled _inflight_lock = threading.Lock() DENY_COOLDOWN = 600 # seconds between replies to a non-allowed chat _denied: dict[str, float] = {} # chat_id -> when we last answered its denial -_sessions_lock = threading.Lock() # guards the shared sessions dict + file -_run_sem = threading.Semaphore(MAX_CONCURRENT) # bounds concurrent `claude` invocations API = f"https://api.telegram.org/bot{TOKEN}" TG_MAX = 4000 # Telegram hard limit is 4096; leave headroom @@ -99,49 +84,24 @@ def log(*a: object) -> None: # --------------------------------------------------------------------------- -# Session persistence (chat_id -> claude session_id) +# Optional LLM layer. OGMA_LLM=claude (default) enables it when the `claude` +# binary exists; OGMA_LLM=off runs a pure command runner. LLM stays None in +# command-only mode and every LLM feature checks it — llm_claude is only +# imported (and its config only read) when the layer is actually on. # --------------------------------------------------------------------------- -def load_sessions() -> dict[str, str]: - if SESSIONS_FILE.exists(): - try: - return json.loads(SESSIONS_FILE.read_text()) - except json.JSONDecodeError: - return {} - return {} - - -def save_sessions(s: dict[str, str]) -> None: - # Atomic replace — a crash mid-write must not corrupt the file (a corrupt - # sessions.json silently drops every chat's session on the next start). - tmp = SESSIONS_FILE.with_name(SESSIONS_FILE.name + ".tmp") - tmp.write_text(json.dumps(s, indent=2)) - tmp.replace(SESSIONS_FILE) - - -def set_env_var(key: str, value: str) -> None: - """Persist KEY=value into .env (updating an existing/commented line or appending). - - Lets runtime changes (e.g. /model, /effort) survive a restart. Best-effort. - """ - # A line break in the value would inject arbitrary .env lines (e.g. CLAUDE_BIN=…), - # so collapse CR/LF unconditionally — callers validate, this is the backstop. - value = value.replace("\r", " ").replace("\n", " ").strip() - try: - lines = ENV_FILE.read_text().splitlines() if ENV_FILE.exists() else [] - except OSError: - lines = [] - pat = re.compile(rf"^#?\s*{re.escape(key)}=") - repl, found = f"{key}={value}", False - for i, ln in enumerate(lines): - if pat.match(ln): - lines[i], found = repl, True - break - if not found: - lines.append(repl) - try: - ENV_FILE.write_text("\n".join(lines) + "\n") - except OSError as e: # noqa: BLE001 - log("set_env_var failed:", e) +LLM_MODE = (cfg("LLM", "claude").lower() or "claude") +LLM = None +if LLM_MODE in ("off", "none", "0", "false"): + log("LLM layer disabled (OGMA_LLM=off) — command-only mode") +elif LLM_MODE == "claude": + if Path(CLAUDE_BIN).exists(): + from llm_claude import ClaudeLLM + LLM = ClaudeLLM(BASE) + else: + log(f"claude binary not found at {CLAUDE_BIN} — running in command-only " + "mode (install Claude Code or set CLAUDE_BIN to enable the LLM layer)") +else: + log(f"unknown OGMA_LLM={LLM_MODE!r} (use 'claude' or 'off') — command-only mode") # --------------------------------------------------------------------------- @@ -199,58 +159,6 @@ def keep_typing(chat_id: str, stop: threading.Event) -> None: stop.wait(4) -# --------------------------------------------------------------------------- -# Claude headless invocation -# --------------------------------------------------------------------------- -def ask_claude(prompt: str, session_id: str | None) -> tuple[str, str | None]: - """Run `claude -p`. Returns (reply_text, new_session_id).""" - cmd = [CLAUDE_BIN, "-p", prompt, "--output-format", "json", "--add-dir", WORKDIR] - if session_id: - cmd += ["--resume", session_id] - if PERMISSION_MODE: - cmd += ["--permission-mode", PERMISSION_MODE] - if ALLOWED_TOOLS: - cmd += ["--allowedTools", *ALLOWED_TOOLS.split()] - if MODEL: - cmd += ["--model", MODEL] - if FALLBACK_MODEL: - cmd += ["--fallback-model", FALLBACK_MODEL] - if EFFORT: - cmd += ["--effort", EFFORT] - try: - proc = subprocess.run( - cmd, cwd=WORKDIR, capture_output=True, text=True, timeout=CLAUDE_TIMEOUT - ) - except subprocess.TimeoutExpired: - return ("⏱️ That took too long and timed out. Try a smaller ask?", session_id) - if proc.returncode != 0: - log("claude exited", proc.returncode, proc.stderr[:500]) - # Retry fresh ONLY when the resume itself failed (the CLI says "No conversation - # found with session ID: …"). A blanket retry would silently drop the chat's - # context whenever a transient error cleared on the second attempt. - if session_id and "no conversation found" in (proc.stderr or "").lower(): - log("stale session id — retrying with a fresh session") - return ask_claude(prompt, None) - # Surface the actual reason (e.g. unknown model / bad flag) instead of a bare code. - hint = next((ln.strip() for ln in (proc.stderr or "").splitlines() if ln.strip()), "") - msg = f"⚠️ Claude error (exit {proc.returncode})." - if hint: - msg = f"{msg} {hint[:200]}".rstrip() - if session_id: - msg += " (Your session is kept — if this persists, /new starts fresh.)" - return (msg, session_id) - try: - out = json.loads(proc.stdout) - except json.JSONDecodeError: - return (proc.stdout.strip() or "⚠️ Empty response.", session_id) - if out.get("is_error"): - return (f"⚠️ {out.get('result', 'error')}", out.get("session_id", session_id)) - return (out.get("result", "").strip() or "(no reply)", out.get("session_id", session_id)) - - -# --------------------------------------------------------------------------- -# Message handling -# --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Slash commands — generic CORE (this file, public) merged at runtime with a # host-LOCAL extension (config/commands.local.json, gitignored). Same core+local @@ -270,21 +178,37 @@ CORE_OGMACTL_CMDS: dict[str, list[str]] = { "/restart": ["restart"], "/backup": ["backup"], "/remember": ["remember"], "/ticket": ["ticket"], "/tickets": ["tickets"], } -CORE_MENU_COMMANDS: list[tuple[str, str]] = [ - ("new", "Start a fresh session"), - ("help", "Show commands"), - ("model", "Show or set the model"), - ("effort", "Show or set reasoning effort"), - ("status", "Ogma service status"), - ("health", "Host health snapshot"), - ("logs", "Recent gateway logs"), - ("briefing", "Generate my briefing now"), - ("search", "Search past conversations"), - ("tickets", "List open tickets"), - ("remember", "Save a memory"), - ("backup", "Back up host-local files"), -] -CORE_HELP = ( +# Commands that only exist with the LLM layer: sessions, model tuning, and the +# scripts that are themselves claude invocations (briefing/dream/search). +LLM_ONLY_CMDS = ("/new", "/model", "/effort", "/fallback", "/briefing", "/dream", "/search") +NO_LLM_MSG = ("🔒 This Ogma runs in command-only mode — it executes its predefined " + "commands, there is no AI assistant behind it. /help lists what's available.") + + +def _core_menu() -> list[tuple[str, str]]: + """The built-in / menu; LLM entries only when the layer is on.""" + llm = LLM is not None + items: list[tuple[str, str]] = [] + if llm: + items.append(("new", "Start a fresh session")) + items.append(("help", "Show commands")) + if llm: + items += [("model", "Show or set the model"), + ("effort", "Show or set reasoning effort")] + items += [("status", "Ogma service status"), + ("health", "Host health snapshot"), + ("logs", "Recent gateway logs")] + if llm: + items += [("briefing", "Generate my briefing now"), + ("search", "Search past conversations")] + items += [("tickets", "List open tickets"), + ("remember", "Save a memory"), + ("backup", "Back up host-local files")] + return items + + +CORE_MENU_COMMANDS: list[tuple[str, str]] = _core_menu() +_HELP_LLM = ( "Ogma here — just talk to me, or use a command:\n" "\n" "Session:\n" @@ -299,6 +223,14 @@ CORE_HELP = ( "/briefing — make my briefing now\n" "/search — search past chats" ) +_HELP_CMD_ONLY = ( + "Ogma here — command-only mode (no AI layer). Available commands:\n" + "\n" + "Ogma & host:\n" + "/status /health /logs [src] [N] /restart /backup\n" + "/remember /ticket /tickets" +) +CORE_HELP = _HELP_LLM if LLM else _HELP_CMD_ONLY def load_local_commands() -> list[dict]: @@ -367,7 +299,8 @@ def script_busy(script: str) -> bool: """True if bin/