diff --git a/.env.example b/.env.example index 054a829..4e8f678 100644 --- a/.env.example +++ b/.env.example @@ -64,9 +64,14 @@ TELEGRAM_ALLOWED_IDS="your_id,spouse_id,kid_id" LUMIKIT_WHISPER_DIR=".vendor/whisper.cpp" LUMIKIT_WHISPER_BIN=".vendor/whisper.cpp/build/bin/whisper-cli" LUMIKIT_WHISPER_MODEL=".vendor/whisper.cpp/models/ggml-base.en.bin" +LUMIKIT_EDGE_TTS_PYTHON=".venv/bin/python" LUMIKIT_TTS_VOICE="en-US-AvaNeural" LUMIKIT_TTS_FORMAT="mp3" +# LumaBot hardware daemon (local when LumaKit runs on the robot). +LUMABOT_URL="http://127.0.0.1:8971" +LUMABOT_ACTIVITY_INDICATOR="0" + # Lumi's own email account (Gmail recommended — use an App Password) # Generate one at https://myaccount.google.com/apppasswords # Email tools are owner-only (only the first TELEGRAM_ALLOWED_IDS can trigger them) diff --git a/agent.py b/agent.py index baa64b2..4bf6e16 100644 --- a/agent.py +++ b/agent.py @@ -23,6 +23,7 @@ from core.summarizer import apply_summary, build_summary_request, needs_summarization from core.storage import StorageManager from tools.code_intel.code_index import LazyCodeIndex, update_index_after_tool +from tools.lumabot.activity import LumaBotActivityLease # Tools that modify files — require diff preview + confirmation @@ -37,6 +38,9 @@ "git_add", "git_commit", "git_push", + "lumabot_reboot", + "lumabot_poweroff", + "lumabot_start_autonomy", } # Tools that have a built-in preview/confirm flow — always preview first @@ -132,6 +136,8 @@ def __init__(self, verbose=False, status_callback=None, check_interrupt=None, di self._tools_schema_cache = {} self._system_prompt_cache = {} self._system_message_cache = {} + self.runtime_profile = None + self._active_tool_groups = None # Initialize the LLM provider client (Ollama/Anthropic/OpenAI/xAI). # Kept on `self.ollama` for backwards compatibility — every client @@ -516,15 +522,71 @@ def _apply_pending_guidance(self) -> None: }) ) + def set_runtime_profile(self, profile=None): + """Select a focused prompt/tool profile for the next turn.""" + if profile not in {None, "lumabot", "lumabot_remote"}: + raise ValueError(f"Unknown runtime profile: {profile}") + if self.runtime_profile == profile: + return + self.runtime_profile = profile + if profile == "lumabot": + self._active_tool_groups = ("lumabot",) + elif profile == "lumabot_remote": + self._active_tool_groups = ("__remote_direct_only__",) + else: + self._active_tool_groups = None + self._system_prompt_cache.clear() + self._system_message_cache.clear() + + def _lumabot_system_prompt(self): + tool_names = ", ".join( + sorted(tool["name"] for tool in self.registry.list(groups={"lumabot"})) + ) + return ( + "You are Lumi operating the owner's physical LumaBot.\n" + f"Your tools: {tool_names}\n" + "ONLY use the listed LumaBot tools. Never invent tool names.\n\n" + "Interpret the user's natural-language intent yourself and call the appropriate " + "structured tool; there is no phrase parser. Use lumabot_drive once for one " + "continuous movement, lumabot_sequence once for an ordered multi-step request, " + "lumabot_start_autonomy only for an explicit roaming request, lumabot_stop to stop " + "manual or autonomous movement, and lumabot_status for hardware or battery questions. " + "Use lumabot_reboot or lumabot_poweroff only for the owner's explicit whole-robot " + "power request; those actions require confirmation. " + "Never repeat movement after a result says entire_request_scheduled=true. " + "Treat returned safety and readiness fields as authoritative and never claim " + "obstacle protection is active when it is not. Autonomous patrol is unavailable " + "until a patrol tool is exposed and the distance sensor is ready; never imitate " + "patrol with an indefinite drive command. After every tool result, give a " + "brief natural response. Successful movement replies should be one playful " + "sentence under 12 words unless the user asks for details." + ) + + @staticmethod + def _lumabot_remote_system_prompt(): + return ( + "LumaBot Remote mode is active. Direct structured controls are handled " + "outside the language model. No tools are available in this profile. " + "Tell the user to use the visible controls or /lumabot help." + ) + def build_system_prompt(self, extra_instructions=None, context_instructions=None): extra = (extra_instructions or "").strip() context = (context_instructions or "").strip() - cache_key = (extra, context) + cache_key = (self.runtime_profile, extra, context) cached = self._system_prompt_cache.get(cache_key) if cached is not None: return cached - prompt = self._system_prompt_prefix + prompt = ( + self._lumabot_system_prompt() + if self.runtime_profile == "lumabot" + else ( + self._lumabot_remote_system_prompt() + if self.runtime_profile == "lumabot_remote" + else self._system_prompt_prefix + ) + ) if extra: prompt += ( "\n\nPersonality override for this Telegram user:\n" @@ -545,7 +607,7 @@ def build_system_prompt(self, extra_instructions=None, context_instructions=None def build_system_message(self, extra_instructions=None, context_instructions=None): extra = (extra_instructions or "").strip() context = (context_instructions or "").strip() - cache_key = (extra, context) + cache_key = (self.runtime_profile, extra, context) cached = self._system_message_cache.get(cache_key) if cached is not None: return dict(cached) @@ -606,20 +668,29 @@ def get_available_tools(self): return self.registry.list() def execute_tool(self, tool_name, inputs): + if self._active_tool_groups: + tool = self.registry.get(tool_name) + if not tool or tool.get("group") not in self._active_tool_groups: + return { + "success": False, + "error": f"{tool_name} is unavailable in {self.runtime_profile} mode", + "toolName": tool_name, + } return self.registry.execute(tool_name, inputs) def get_code_index_status(self): return self.code_index.status() def get_tools_for_llm(self, groups=None): - group_key = tuple(sorted(groups or [])) + effective_groups = self._active_tool_groups if groups is None else groups + group_key = tuple(sorted(effective_groups or [])) cache_key = (self.registry.version, group_key) cached = self._tools_schema_cache.get(cache_key) if cached is not None: return self._filter_role_denied_tools(cached) result = [] - group_filter = set(groups or []) + group_filter = set(effective_groups or []) for tool_name in self.registry.tools.keys(): tool = self.registry.get(tool_name) if not tool.get("llm_exposed", True): @@ -867,6 +938,8 @@ def ask_llm(self, prompt, image_data=None, image_path=None): prompt or ("Image analysis" if has_image else ""), kind="vision" if has_image else "chat", ) + activity_lease = LumaBotActivityLease() + activity_lease.start() watchdog = StallWatchdog( self.run_controller, notify=lambda text: self.display.status(text), @@ -1179,6 +1252,8 @@ def _finish(response, *, state="completed", final_message="", error=""): watchdog.stop() self.run_controller.finish_run("failed", error=str(exc)) raise + finally: + activity_lease.close() SUPPORTED_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} diff --git a/core/approval_policy.py b/core/approval_policy.py index db4a1ba..a8baca4 100644 --- a/core/approval_policy.py +++ b/core/approval_policy.py @@ -33,6 +33,9 @@ "execute_shell", "execute_python", "run_command", + "lumabot_reboot", + "lumabot_poweroff", + "lumabot_start_autonomy", }) # Tools an autonomous task may never execute (it has no way to ask). @@ -41,6 +44,9 @@ "git_add", "git_commit", "git_push", + "lumabot_reboot", + "lumabot_poweroff", + "lumabot_start_autonomy", }) # Screens shell commands issued from inside autonomous tasks. This is a @@ -133,12 +139,19 @@ def tool_always_requires_approval(tool_name: str, tool_inputs: dict) -> bool: # system control / destructive maintenance "reboot_system", "restart_service", + "lumabot_reboot", + "lumabot_poweroff", "clear_storage", # secrets manager "lumalok_list_secrets", "lumalok_get_secret", "lumalok_add_secret", "lumalok_update_secret", + # physical robot control + "lumabot_drive", + "lumabot_sequence", + "lumabot_stop", + "lumabot_start_autonomy", # tasks run autonomously with broader powers — creating/deleting them is # an escalation path for non-owners "create_task", diff --git a/core/chat_store.py b/core/chat_store.py index 4deaeb3..3b85d02 100644 --- a/core/chat_store.py +++ b/core/chat_store.py @@ -49,6 +49,26 @@ def _connect(): updated_at TEXT NOT NULL ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS chat_runtime_modes ( + chat_id TEXT PRIMARY KEY, + lumabot_enabled INTEGER NOT NULL DEFAULT 0, + profile TEXT NOT NULL DEFAULT 'off', + updated_at TEXT NOT NULL + ) + """) + mode_columns = { + row["name"] for row in conn.execute("PRAGMA table_info(chat_runtime_modes)") + } + if "profile" not in mode_columns: + conn.execute( + "ALTER TABLE chat_runtime_modes " + "ADD COLUMN profile TEXT NOT NULL DEFAULT 'off'" + ) + conn.execute( + "UPDATE chat_runtime_modes SET profile = " + "CASE WHEN lumabot_enabled = 1 THEN 'agent' ELSE 'off' END" + ) columns = {row["name"] for row in conn.execute("PRAGMA table_info(conversations)")} if "owner_id" not in columns: conn.execute("ALTER TABLE conversations ADD COLUMN owner_id TEXT") @@ -214,6 +234,7 @@ def delete_chat(chat_id: str, owner_id: str | None = None) -> bool: ) if cursor.rowcount > 0: conn.execute("DELETE FROM chat_workspaces WHERE chat_id = ?", (chat_id,)) + conn.execute("DELETE FROM chat_runtime_modes WHERE chat_id = ?", (chat_id,)) conn.commit() conn.close() return cursor.rowcount > 0 @@ -251,6 +272,59 @@ def get_chat_workspace(chat_id: str, owner_id: str | None = None) -> str | None: return row["workspace_path"] if row else None +def set_chat_lumabot_mode(chat_id: str, enabled: bool) -> None: + """Backward-compatible agent/off toggle.""" + set_chat_lumabot_profile(chat_id, "agent" if enabled else "off") + + +def get_chat_lumabot_mode(chat_id: str | None) -> bool: + """Backward-compatible check for Agent mode.""" + return get_chat_lumabot_profile(chat_id) == "agent" + + +def set_chat_lumabot_profile(chat_id: str, profile: str) -> None: + """Persist off, agent, or direct remote control for one conversation.""" + if not chat_id: + return + profile = str(profile or "").lower() + if profile not in {"off", "agent", "remote"}: + raise ValueError("LumaBot profile must be off, agent, or remote") + conn = _connect() + conn.execute( + "INSERT INTO chat_runtime_modes " + "(chat_id, lumabot_enabled, profile, updated_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(chat_id) DO UPDATE SET " + "lumabot_enabled = excluded.lumabot_enabled, " + "profile = excluded.profile, updated_at = excluded.updated_at", + ( + str(chat_id), + int(profile == "agent"), + profile, + datetime.now().isoformat(), + ), + ) + conn.commit() + conn.close() + + +def get_chat_lumabot_profile(chat_id: str | None) -> str: + """Return off, agent, or remote for this conversation.""" + if not chat_id: + return "off" + conn = _connect() + row = conn.execute( + "SELECT profile, lumabot_enabled FROM chat_runtime_modes WHERE chat_id = ?", + (str(chat_id),), + ).fetchone() + conn.close() + if not row: + return "off" + profile = str(row["profile"] or "").lower() + if profile in {"off", "agent", "remote"}: + return profile + return "agent" if row["lumabot_enabled"] else "off" + + def list_known_workspaces(limit: int = 10) -> list[str]: """Distinct workspace paths any chat has used, most recently used first.""" conn = _connect() diff --git a/core/commands.py b/core/commands.py index 838de54..46cad0f 100644 --- a/core/commands.py +++ b/core/commands.py @@ -5,11 +5,23 @@ import sys from pathlib import Path -from core.chat_store import delete_chat, list_chats, load_chat, make_title, new_chat_id, save_chat, set_active_chat +from core.chat_store import ( + delete_chat, + get_chat_lumabot_profile, + list_chats, + load_chat, + make_title, + new_chat_id, + save_chat, + set_active_chat, + set_chat_lumabot_profile, +) from core.app_runtime_config import get_app_runtime_config, save_app_runtime_config from core.identity import CLI_USER_ID +from core.runtime_config import apply_user_runtime from core.cli import BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW, _c, render_storage_meter from core.menu import select_menu +from tools.lumabot.remote import REMOTE_HELP, execute_remote_command def handle_command(command: str, agent, session: dict) -> bool: @@ -25,6 +37,7 @@ def handle_command(command: str, agent, session: dict) -> bool: "/status": cmd_status, "/config": cmd_config, "/clear": cmd_clear, + "/lumabot": cmd_lumabot, } handler = handlers.get(cmd) @@ -52,6 +65,9 @@ def cmd_help(args: str, agent, session: dict): {_c(CYAN, '/config')} View current configuration {_c(CYAN, '/config set ')} Update a config value {_c(CYAN, '/clear')} Clear the screen + {_c(CYAN, '/lumabot agent')} Natural-language robot control + {_c(CYAN, '/lumabot remote')} Instant structured robot controls + {_c(CYAN, '/lumabot off')} Restore full LumaKit """) @@ -96,7 +112,7 @@ def _chats_resume(chat_id: str, agent, session: dict): _auto_save(agent, session) # Load the resumed conversation - agent.messages = agent.apply_runtime_overrides(messages=chat["messages"]) + agent.messages = chat["messages"] session["chat_id"] = chat["id"] session["title"] = chat["title"] session["first_message_sent"] = True @@ -105,6 +121,7 @@ def _chats_resume(chat_id: str, agent, session: dict): session["chat_id"], scope=session.get("active_chat_scope"), ) + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") print(_c(GREEN, f" Resumed: {chat['title']}")) print(_c(DIM, f" {len(chat['messages'])} messages loaded.\n")) @@ -135,6 +152,7 @@ def cmd_new(args: str, agent, session: dict): session["chat_id"], scope=session.get("active_chat_scope"), ) + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") print(_c(GREEN, " New conversation started.\n")) @@ -270,6 +288,38 @@ def cmd_clear(args: str, agent, session: dict): os.system("cls" if sys.platform == "win32" else "clear") +def cmd_lumabot(args: str, agent, session: dict): + """Switch profiles or execute one deterministic remote command.""" + parts = args.strip().split(maxsplit=1) + action = parts[0].lower() if parts else "" + current = get_chat_lumabot_profile(session.get("chat_id")) + if not action: + print(_c(CYAN, f" LumaBot mode: {current.upper()}\n\n{REMOTE_HELP}\n")) + return + + if action in {"on", "agent", "remote", "off"}: + profile = "agent" if action == "on" else action + set_chat_lumabot_profile(session.get("chat_id"), profile) + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") + labels = { + "agent": "AGENT — natural language uses the configured LLM.", + "remote": "REMOTE — structured commands bypass the LLM.", + "off": "OFF — full LumaKit restored.", + } + print(_c(GREEN, f" LumaBot mode {labels[profile]}\n")) + if profile == "remote": + print(f"{REMOTE_HELP}\n") + return + + if action not in {"stop", "park", "status", "help"} and current != "remote": + print(_c(RED, " Switch to Remote mode first with /lumabot remote.\n")) + return + result = execute_remote_command(args) + prefix = f"LumaBot mode: {current.upper()}\n" if action == "status" else "" + color = GREEN if result.get("ok") else RED + print(_c(color, f" {prefix}{result['text']}\n")) + + def _auto_save(agent, session: dict): """Save the current conversation if it has content.""" if not session.get("first_message_sent"): diff --git a/core/runtime_config.py b/core/runtime_config.py index 88c39de..e0685b2 100644 --- a/core/runtime_config.py +++ b/core/runtime_config.py @@ -5,6 +5,7 @@ import os from core.app_runtime_config import get_app_runtime_config +from core.chat_store import get_chat_lumabot_profile from core.telegram_state import OWNER_CONFIG, OWNER_ID, _get_user_config @@ -68,13 +69,23 @@ def apply_user_runtime(agent, session, user_id, surface=None): refresh_client() user_cfg = _get_user_config(user_id) personality_prompt = user_cfg.get("personality_prompt") or None - context_instructions = _surface_instructions(surface) + lumabot_profile = get_chat_lumabot_profile(session.get("chat_id")) + context_instructions = ( + "" if lumabot_profile != "off" else _surface_instructions(surface) + ) config = get_effective_config_for_user( user_id=user_id, default_model=agent.default_model, default_fallback=agent.default_fallback_model, local_model=agent.local_model, ) + set_profile = getattr(agent, "set_runtime_profile", None) + if callable(set_profile): + runtime_profile = { + "agent": "lumabot", + "remote": "lumabot_remote", + }.get(lumabot_profile) + set_profile(runtime_profile) agent.apply_runtime_overrides( messages=agent.messages, @@ -85,6 +96,7 @@ def apply_user_runtime(agent, session, user_id, surface=None): ) session["messages"] = agent.messages + session["lumabot_mode"] = lumabot_profile def _surface_instructions(surface): diff --git a/core/telegram_api.py b/core/telegram_api.py index aea5168..6320193 100644 --- a/core/telegram_api.py +++ b/core/telegram_api.py @@ -27,12 +27,19 @@ def telegram_api(method: str, params: dict[str, Any] | None = None) -> dict[str, return payload -def send_message(text: str, chat_id: str | int): +def send_message( + text: str, + chat_id: str | int, + reply_markup: dict[str, Any] | None = None, +): """Send a text message, splitting it into Telegram-sized chunks.""" first_payload = None while text: chunk, text = text[:4096], text[4096:] - payload = telegram_api("sendMessage", {"chat_id": chat_id, "text": chunk}) + params = {"chat_id": chat_id, "text": chunk} + if reply_markup is not None and first_payload is None: + params["reply_markup"] = reply_markup + payload = telegram_api("sendMessage", params) if first_payload is None: first_payload = payload return first_payload diff --git a/core/telegram_commands.py b/core/telegram_commands.py index 7c72283..52fa049 100644 --- a/core/telegram_commands.py +++ b/core/telegram_commands.py @@ -5,6 +5,7 @@ from pathlib import Path from core.chat_store import ( + get_chat_lumabot_profile, list_chats, list_known_workspaces, load_chat, @@ -12,6 +13,7 @@ new_chat_id, save_chat, set_active_chat, + set_chat_lumabot_profile, ) from core.app_runtime_config import get_app_runtime_config, save_app_runtime_config from core.identity import chat_owner_id @@ -30,6 +32,52 @@ _sessions, _show_tools, ) +from tools.lumabot.remote import ( + REMOTE_HELP, + execute_remote_action, + execute_remote_command, +) + + +def lumabot_remote_keyboard(): + """Inline buttons with structured callback payloads, not language.""" + return { + "inline_keyboard": [ + [{"text": "▲ Forward", "callback_data": "lbot:drive:forward"}], + [ + {"text": "↶ Left", "callback_data": "lbot:turn:left"}, + {"text": "STOP", "callback_data": "lbot:stop"}, + {"text": "Right ↷", "callback_data": "lbot:turn:right"}, + ], + [{"text": "▼ Reverse", "callback_data": "lbot:drive:backward"}], + [ + {"text": "Turn 180°", "callback_data": "lbot:turn_around"}, + {"text": "Park", "callback_data": "lbot:park"}, + {"text": "Status", "callback_data": "lbot:status"}, + ], + ] + } + + +def handle_lumabot_callback(data: str, session: dict) -> dict: + """Execute one structured Telegram button callback without an LLM.""" + parts = str(data or "").split(":") + if not parts or parts[0] != "lbot": + return {"ok": False, "text": "Unknown LumaBot control."} + profile = get_chat_lumabot_profile(session.get("chat_id")) + action = parts[1] if len(parts) > 1 else "" + if profile != "remote" and action not in {"stop", "park"}: + return {"ok": False, "text": "LumaBot Remote mode is off."} + try: + if action == "drive" and len(parts) == 3: + return execute_remote_action("drive", direction=parts[2], continuous=True) + if action == "turn" and len(parts) == 3: + return execute_remote_action("turn", direction=parts[2]) + if action in {"turn_around", "stop", "park", "status"} and len(parts) == 2: + return execute_remote_action(action) + except ValueError as error: + return {"ok": False, "text": str(error)} + return {"ok": False, "text": "Unknown LumaBot control."} # --------------------------------------------------------------------------- @@ -295,6 +343,7 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): lines.append("/model - choose the owner's Telegram model settings") lines.append("/workspace - pick or set the working directory (alias /dir)") lines.append("/safemode - toggle full machine access (approvals + file sandbox)") + lines.append("/lumabot - agent or instant remote control") lines.append("/users - list authorized users") lines.append("/personality - view or change your Telegram personality override") send_message("\n".join(lines)) @@ -339,6 +388,7 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): agent.messages = [system_msg] if system_msg else [] session["messages"] = agent.messages set_active_chat(owner_id, session["chat_id"]) + apply_chat_runtime(agent, session, chat_id) send_message("New conversation started.") return True @@ -364,6 +414,7 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): f"\nLocal model: {owner_cfg['local_model'] or 'not set'}" f"\nWorkspace: {agent.workspace_root}" f"\nSafe mode: {'on' if get_app_runtime_config().get('safe_mode', True) else 'off'}" + f"\nLumaBot mode: {get_chat_lumabot_profile(session.get('chat_id'))}" ) user_cfg = _get_user_config(chat_id) send_message( @@ -385,10 +436,47 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): ) return True - if cmd in {"/adduser", "/removeuser", "/users", "/model", "/role", "/approve", "/deny", "/workspace", "/dir", "/safemode"} and str(chat_id) != str(OWNER_ID): + if cmd in {"/adduser", "/removeuser", "/users", "/model", "/role", "/approve", "/deny", "/workspace", "/dir", "/safemode", "/lumabot"} and str(chat_id) != str(OWNER_ID): send_message("This command is owner-only.") return True + if cmd == "/lumabot" and str(chat_id) == str(OWNER_ID): + command_parts = args.strip().split(maxsplit=1) + action = command_parts[0].lower() if command_parts else "" + current = get_chat_lumabot_profile(session.get("chat_id")) + if not action: + send_message( + f"LumaBot mode: {current.upper()}\n\n{REMOTE_HELP}", + reply_markup=lumabot_remote_keyboard() if current == "remote" else None, + ) + return True + + if action in {"on", "agent", "remote", "off"}: + profile = "agent" if action == "on" else action + set_chat_lumabot_profile(session.get("chat_id"), profile) + apply_chat_runtime(agent, session, chat_id) + if profile == "agent": + send_message("LumaBot Agent mode ON. Natural language uses the configured LLM.") + elif profile == "remote": + send_message( + "LumaBot Remote mode ON. These controls bypass the LLM.", + reply_markup=lumabot_remote_keyboard(), + ) + else: + send_message("LumaBot mode OFF. Full LumaKit is restored.") + return True + + if action not in {"stop", "park", "status", "help"} and current != "remote": + send_message("Switch to Remote mode first with /lumabot remote.") + return True + result = execute_remote_command(args) + prefix = f"LumaBot mode: {current.upper()}\n" if action == "status" else "" + send_message( + prefix + result["text"], + reply_markup=lumabot_remote_keyboard() if current == "remote" else None, + ) + return True + if cmd == "/safemode" and str(chat_id) == str(OWNER_ID): cfg = get_app_runtime_config().copy() current = bool(cfg.get("safe_mode", True)) diff --git a/core/telegram_io.py b/core/telegram_io.py index 7e22834..bb66a41 100644 --- a/core/telegram_io.py +++ b/core/telegram_io.py @@ -42,9 +42,9 @@ def _strip_emojis(text: str) -> str: # Outbound # --------------------------------------------------------------------------- -def send_message(text, chat_id=None): +def send_message(text, chat_id=None, reply_markup=None): chat_id = chat_id or _active_chat_id["value"] - return telegram_send_message(text, chat_id) + return telegram_send_message(text, chat_id, reply_markup=reply_markup) def edit_message_text(text, chat_id, message_id): diff --git a/deploy/lumabot-power.sudoers b/deploy/lumabot-power.sudoers new file mode 100644 index 0000000..5f72e0a --- /dev/null +++ b/deploy/lumabot-power.sudoers @@ -0,0 +1,5 @@ +# Exact commands used by tools/lumabot/power.py. Do not grant arbitrary systemctl. +Cmnd_Alias LUMABOT_POWER = /usr/bin/systemd-run --quiet --collect --unit=lumabot-reboot --on-active=15s /usr/bin/systemctl reboot, \ + /usr/bin/systemd-run --quiet --collect --unit=lumabot-poweroff --on-active=15s /usr/bin/systemctl poweroff + +lumabot21 ALL=(root) NOPASSWD: LUMABOT_POWER diff --git a/docs/lumabot_pi_setup.md b/docs/lumabot_pi_setup.md new file mode 100644 index 0000000..88a5fde --- /dev/null +++ b/docs/lumabot_pi_setup.md @@ -0,0 +1,375 @@ +# LumaKit on LumaBot + +This is the reproducible factory and developer setup for the open-source +LumaBot agent. It targets a Raspberry Pi 5 with 2 GB RAM and a hosted LLM. + +Tested on: + +- Debian 13 (Trixie), ARM64 +- Python 3.13 +- LumaKit upstream base `a439e264429ca49ac81f307d193606f68e852b58` +- whisper.cpp `4523d0ce373ee4b2176b3251fff29fd4864fcf38` +- edge-tts `7.2.8` +- xAI with LumaKit's default `grok-4` model + +## Deployment profile + +Use the main LumaKit repository with the LumaBot compatibility changes. Do +not create a separate "LumaKit Light" repository. + +Install the core package and, when Telegram voice is wanted, only the +`speech` extra. Do not install Ollama, Playwright, Chromium, the `browser` +extra, the `desktop` extra, or the `all` extra. + +Expected layout: + +```text +/home/lumabot21/ +├── lumabot/ +└── lumakit/ +``` + +## LumaBot hardware daemon and agent tools + +Clone both repositories into the layout above. In the LumaBot checkout, +create its virtual environment and install only its Pi hardware dependencies. +Do not install Playwright or Ollama. + +Install and start the supplied hardware service: + +```bash +cd /home/lumabot21/lumabot +sudo cp lumabot.service /etc/systemd/system/lumabot.service +sudo systemctl daemon-reload +sudo systemctl enable --now lumabot.service +curl -fsS http://127.0.0.1:8971/status +``` + +The unit enables the Adafruit Motor Bonnet, X1200 battery gauge, NeoSlider, +VL53L1X, and MSA311. Verify the robot is raised on a stand before testing +movement. The present mapping is Motor 1 = left (software-inverted) and Motor 4 += right. + +The LumaKit `lumabot_status`, `lumabot_drive`, `lumabot_sequence`, +`lumabot_stop`, `lumabot_start_autonomy`, `lumabot_reboot`, and +`lumabot_poweroff` tools control the +robot. Movement and whole-Pi power tools are owner-only. Reboot and poweroff +also always require interactive confirmation and are refused in autonomous +tasks. Natural-language intent and the final acknowledgement remain part of +LumaKit's normal LLM tool-result cycle; there is no phrase parser. + +Autonomous driving runs locally in the LumaBot daemon and continues without an +LLM round trip. Starting it through LumaKit requires owner confirmation; the +physical MSA311 double-tap gesture can also toggle it. `lumabot_stop`, manual +movement, stale distance data, excessive tilt, low battery, and daemon shutdown +all cancel or block autonomous motor output. + +Install the exact power-command policy and validate it before restarting +LumaKit: + +```bash +sudo install -o root -g root -m 0440 \ + deploy/lumabot-power.sudoers /etc/sudoers.d/lumabot-power +sudo visudo -cf /etc/sudoers.d/lumabot-power +``` + +These tools stop the motors first, then use a fixed `systemd-run` command to +schedule the requested action 15 seconds later. The LLM-provided reason is +never included in the command. The policy deliberately does not grant general +`systemctl`, shell, or arbitrary `sudo` access. + +Set `LUMABOT_ACTIVITY_INDICATOR=1` on the Pi service to let interactive Agent +turns renew the LumaBot NeoSlider's purple thinking lease. Remote mode makes no +LLM call and therefore does not activate the thinking indicator. + +Choose a robot-control profile for an individual conversation: + +```text +Telegram/CLI: /lumabot agent +Telegram/CLI: /lumabot remote +Telegram/CLI: /lumabot off +Web: choose Off, Agent, or Remote in the top bar +``` + +Agent mode replaces the full agent prompt and 98-tool catalog with a compact +robot prompt and only the four LumaBot tools. Natural-language and voice +requests still use the configured hosted model. + +Remote mode makes no LLM calls. The web UI shows a D-pad, Telegram shows +inline buttons, and CLI/Telegram accept explicit commands such as: + +```text +/lumabot drive forward +/lumabot drive backward +/lumabot drive forward 2 0.3 +/lumabot turn around +/lumabot stop +/lumabot park +/lumabot status +``` + +The setting follows the saved conversation. Direct movement uses the same +three-second hardware watchdog as Agent mode. In Remote mode, the Forward and +Reverse buttons—and drive commands without a duration—stay active until STOP +or another movement control replaces them. LumaKit renews a three-second lease +while they are active, so the daemon still coasts the motors if LumaKit exits +or loses contact. Supplying a duration keeps the drive timed; the web duration +selector applies to turns. The web STOP button and +`/lumabot stop` bypass the LLM and interrupt an active Agent turn before it +can issue another movement. Free-form text, photos, and voice are deliberately +not interpreted in Remote mode. “Park” cancels scheduled movement and coasts +both motors. The 180-degree control scales the one-second full-speed bench +calibration for the selected throttle, but remains approximate without wheel +encoders. Autonomous patrol remains unavailable until the distance sensor is +connected and verified. + +After updating either checkout: + +```bash +sudo systemctl restart lumabot.service +sudo systemctl restart lumakit.service +sudo systemctl is-active lumabot.service lumakit.service +``` + +## Factory installation + +Clone LumaKit and create an isolated environment: + +```bash +cd /home/lumabot21 +git clone https://github.com/patmakesapps/LumaKit.git lumakit +cd lumakit +python3 -m venv .venv +.venv/bin/python -m pip install -e . +``` + +For a developer image, install the test runner and run the shipped tests: + +```bash +.venv/bin/python -m pip install pytest +.venv/bin/python -m pytest -q +``` + +Confirm that optional desktop and browser packages were not installed: + +```bash +.venv/bin/python -m pip show playwright +.venv/bin/python -m pip show pyautogui +``` + +Both commands should report that the package was not found. + +## Two-way Telegram voice + +The tested low-memory voice stack is: + +- Incoming voice notes: local `whisper.cpp` with the English-only + `tiny.en` model. +- Outgoing voice replies: Edge TTS using `en-US-AvaNeural`. + +Whisper runs only while a voice note is being transcribed; it is not a +resident model server. Edge TTS also does not remain loaded, but it does +require an internet connection to synthesize each reply. + +Install only the speech extra and native build tools: + +```bash +sudo apt-get update +sudo apt-get install -y build-essential cmake ffmpeg +cd /home/lumabot21/lumakit +.venv/bin/python -m pip install -e '.[speech]' +``` + +Build the tested whisper.cpp revision with two jobs to control build-time +memory: + +```bash +mkdir -p .vendor +git clone https://github.com/ggml-org/whisper.cpp.git .vendor/whisper.cpp +git -C .vendor/whisper.cpp checkout 4523d0ce373ee4b2176b3251fff29fd4864fcf38 +cmake -S .vendor/whisper.cpp \ + -B .vendor/whisper.cpp/build \ + -DCMAKE_BUILD_TYPE=Release +cmake --build .vendor/whisper.cpp/build -j2 +.vendor/whisper.cpp/models/download-ggml-model.sh tiny.en +``` + +Add these non-secret settings to `/home/lumabot21/.lumakit/config.env`: + +```dotenv +LUMIKIT_WHISPER_DIR="/home/lumabot21/lumakit/.vendor/whisper.cpp" +LUMIKIT_WHISPER_BIN="/home/lumabot21/lumakit/.vendor/whisper.cpp/build/bin/whisper-cli" +LUMIKIT_WHISPER_MODEL="/home/lumabot21/lumakit/.vendor/whisper.cpp/models/ggml-tiny.en.bin" +LUMIKIT_EDGE_TTS_PYTHON="/home/lumabot21/lumakit/.venv/bin/python" +LUMIKIT_TTS_VOICE="en-US-AvaNeural" +LUMIKIT_TTS_FORMAT="mp3" +``` + +After LumaKit is running, the owner can enable replies and choose the voice +from Telegram: + +```text +/voice on +/voice set ava +/voice status +``` + +Voice notes are still transcribed when outgoing voice replies are off. +The initial round-trip test synthesized a short MP3 in 1.77 seconds and +transcribed it in 2.12 seconds, with a measured peak child-process RSS of +about 175 MB. The `tiny.en` model file is about 75 MB on disk. + +## Python 3.13 ARM compatibility + +The original upstream pin `tree-sitter-languages==1.10.2` has no compatible +Python 3.13 ARM64 distribution. The tested LumaBot branch replaces it with: + +```text +tree-sitter==0.26.0 +tree-sitter-language-pack==1.13.2 +``` + +The parser uses the modern `Query` and `QueryCursor` API. Keep this +compatibility change when updating from upstream until upstream adopts an +equivalent fix. + +## Developer configuration + +Never put real credentials in the repository. Store them in: + +```text +/home/lumabot21/.lumakit/config.env +``` + +The minimal hosted-xAI and Telegram configuration is: + +```dotenv +LLM_PROVIDER="xai" +LLM_API_KEY="replace-with-developer-key" +TELEGRAM_BOT_TOKEN="replace-with-bot-token" +TELEGRAM_ALLOWED_IDS="replace-with-owner-chat-id" +``` + +The first Telegram ID is the owner. Keep LumaKit safe mode enabled. Do not +send API keys or bot tokens through Telegram messages. + +Protect the configuration: + +```bash +chmod 600 /home/lumabot21/.lumakit/config.env +``` + +On a shipped device, collect these values through a local first-run setup +page rather than asking the developer to edit a file. + +## Provider validation + +Start LumaKit temporarily: + +```bash +cd /home/lumabot21/lumakit +.venv/bin/lumakit serve +``` + +Confirm that startup reports: + +```text +Web UI: http://localhost:7865 +Telegram: enabled +Telegram bridge running. 1 authorized user(s). +``` + +Send the bot a short message and confirm that it replies through the hosted +provider. Stop the foreground process with `Ctrl+C` before installing the +service. + +## Always-on service + +Generate a unit tied to the LumaKit virtual environment and private config: + +```bash +cd /home/lumabot21/lumakit +.venv/bin/lumakit service install --force \ + --env-file /home/lumabot21/.lumakit/config.env +``` + +Before installation, verify that `lumakit.service` contains: + +```text +ExecStart=/home/lumabot21/lumakit/.venv/bin/python -m lumakit serve +``` + +Install and start it: + +```bash +sudo cp lumakit.service /etc/systemd/system/lumakit.service +sudo systemctl daemon-reload +sudo systemctl enable --now lumakit.service +``` + +Validate the service: + +```bash +.venv/bin/lumakit status +sudo systemctl status lumakit.service +sudo journalctl -u lumakit.service -n 50 --no-pager +``` + +Expected status includes `running`, `telegram: configured`, and the selected +hosted model. + +## Resource check + +Record the steady-state process and system memory: + +```bash +pid=$(systemctl show -p MainPID --value lumakit.service) +ps -o pid,rss,vsz,%mem,etime,cmd -p "$pid" +free -h +swapon --show +``` + +On the initial 2 GB LumaBot, LumaKit used about 69 MB RSS while idle with +the voice stack configured. Whisper is transient, so it does not increase +idle RSS. Recheck idle and transcription-time memory after camera support +and `lumabotd` are running together. + +## Updating another LumaBot + +Before an update, stop the service and confirm the worktree is clean: + +```bash +sudo systemctl stop lumakit.service +cd /home/lumabot21/lumakit +git status +git fetch origin +``` + +Integrate upstream changes without dropping the LumaBot compatibility patch, +then reinstall and retest: + +```bash +.venv/bin/python -m pip install -e '.[speech]' +.venv/bin/python -m pytest -q +sudo systemctl restart lumakit.service +.venv/bin/lumakit status +``` + +Do not use an unreviewed update directly on a moving robot. + +## Recovery and secret rotation + +If startup fails: + +```bash +sudo journalctl -u lumakit.service -n 100 --no-pager +sudo systemctl restart lumakit.service +``` + +If a developer key, Telegram bot token, robot, or SD card is compromised, +revoke the affected credential at the provider, replace it in `config.env`, +restore mode `600`, and restart LumaKit. + +Factory reset must remove developer credentials, Telegram ownership, Wi-Fi +credentials, conversation history, and device-specific runtime data before +the robot changes owners. diff --git a/lumakit.py b/lumakit.py index 77b462d..b49aa70 100644 --- a/lumakit.py +++ b/lumakit.py @@ -595,7 +595,11 @@ def command_service_install(args) -> int: target = _service_install_target(args) working_dir = Path(args.working_dir).expanduser().resolve() if args.working_dir else REPO_ROOT env_file = Path(args.env_file).expanduser().resolve(strict=False) if args.env_file else working_dir / ".env" - python_executable = Path(args.python).expanduser().resolve() if args.python else Path(sys.executable).resolve() + python_executable = ( + Path(args.python).expanduser().absolute() + if args.python + else Path(sys.executable).absolute() + ) user = args.user or getpass.getuser() service_text = _render_systemd_service( user=user, diff --git a/requirements.txt b/requirements.txt index 557f7b5..25070b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,8 +7,8 @@ httpx==0.28.1 aiohttp==3.13.5 beautifulsoup4==4.14.3 Pillow==12.2.0 -tree-sitter==0.21.3 -tree-sitter-languages==1.10.2 +tree-sitter==0.26.0 +tree-sitter-language-pack==1.13.2 PyYAML==6.0.3 regex==2026.4.4 fastapi==0.136.1 diff --git a/surfaces/cli.py b/surfaces/cli.py index f295c66..3327159 100644 --- a/surfaces/cli.py +++ b/surfaces/cli.py @@ -10,13 +10,24 @@ import tempfile from agent import Agent -from core.chat_store import get_active_chat, load_chat, make_title, new_chat_id, save_chat, set_active_chat +from core.chat_store import ( + get_active_chat, + get_chat_lumabot_profile, + load_chat, + make_title, + new_chat_id, + save_chat, + set_active_chat, +) from core.cli import render_storage_meter from core.commands import handle_command from core.identity import CLI_USER_ID +from core.interface_context import set_interface from core.paths import get_repo_root +from core.runtime_config import apply_user_runtime from core.service import LumaKitService, Surface from tools.memory.memory_tools import set_active_user as set_memory_active_user +from tools.lumabot.remote import REMOTE_HELP def _workspace_scope() -> str: @@ -121,6 +132,8 @@ def main(argv: list[str] | None = None): "active_chat_scope": workspace_scope, } set_active_chat(CLI_USER_ID, session["chat_id"], scope=workspace_scope) + set_interface("cli", CLI_USER_ID) + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") print("\n=== LumaKit CLI ===") health = agent.storage.check_health() @@ -148,6 +161,11 @@ def main(argv: list[str] | None = None): if not user_input: continue + remote_mode = get_chat_lumabot_profile(session["chat_id"]) == "remote" + if remote_mode and user_input.lower().startswith(("/p", "/image")): + print(f"\n{REMOTE_HELP}\n") + continue + if user_input.startswith("/"): if user_input.lower().startswith("/p"): parts = user_input.split(maxsplit=1) @@ -192,7 +210,12 @@ def main(argv: list[str] | None = None): handle_command(user_input, agent, session) continue + if remote_mode: + print(f"\n{REMOTE_HELP}\n") + continue + try: + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") response = agent.ask_llm(user_input) content = response.get("message", {}).get("content", "") if content: diff --git a/surfaces/telegram.py b/surfaces/telegram.py index 7c5209e..c06ce4a 100644 --- a/surfaces/telegram.py +++ b/surfaces/telegram.py @@ -37,7 +37,13 @@ send_chat_action, telegram_api, ) -from core.telegram_commands import apply_chat_runtime, handle_telegram_command, swap_in +from core.telegram_commands import ( + apply_chat_runtime, + handle_lumabot_callback, + handle_telegram_command, + lumabot_remote_keyboard, + swap_in, +) from core.telegram_io import edit_message_text, send_message, send_tts_reply, telegram_confirm from core.telegram_speech import SpeechClient from core.telegram_state import ( @@ -53,6 +59,7 @@ _sessions, ) from tools.comms.react import set_react_context +from tools.lumabot.remote import execute_remote_command from tools.memory.memory_tools import set_active_user # --------------------------------------------------------------------------- @@ -307,6 +314,29 @@ def _poll_active_run_messages(agent: Agent) -> bool: for update in updates: _poll_offset["value"] = update["update_id"] + 1 + callback = update.get("callback_query", {}) + callback_data = callback.get("data", "") + callback_chat_id = str( + callback.get("message", {}).get("chat", {}).get("id", "") + ) + if callback_data.startswith("lbot:") and callback_chat_id == str(chat_id): + callback_session = _get_session(callback_chat_id) + result = handle_lumabot_callback(callback_data, callback_session) + try: + telegram_api( + "answerCallbackQuery", + { + "callback_query_id": callback.get("id"), + "text": result["text"][:200], + "show_alert": not result.get("ok", False), + }, + ) + except Exception: + pass + if callback_data in {"lbot:stop", "lbot:park"}: + agent.request_stop("LumaBot emergency stop requested.") + continue + msg = update.get("message", {}) msg_chat_id = str(msg.get("chat", {}).get("id", "")) text = msg.get("text", "").strip() @@ -320,6 +350,11 @@ def _poll_active_run_messages(agent: Agent) -> bool: # Slash commands are still handled by the command dispatcher. if text.startswith("/"): + if text.strip().lower() in {"/lumabot stop", "/lumabot park"}: + result = execute_remote_command(text.split(maxsplit=1)[1]) + send_message(result["text"], chat_id=msg_chat_id) + agent.request_stop("LumaBot emergency stop requested.") + continue _pending_updates.append(update) continue @@ -492,6 +527,43 @@ def _handle_pending_draft(text, chat_id): if _poll_offset["value"] is None or new_offset > _poll_offset["value"]: _poll_offset["value"] = new_offset + callback = update.get("callback_query", {}) + if callback: + callback_id = callback.get("id") + callback_data = callback.get("data", "") + callback_chat_id = str( + callback.get("message", {}).get("chat", {}).get("id", "") + ) + if callback_data.startswith("lbot:") and callback_chat_id: + if callback_chat_id != str(OWNER_ID): + result = {"ok": False, "text": "Owner-only control."} + else: + _active_chat_id["value"] = callback_chat_id + set_active_user(callback_chat_id) + auth.set_active_user(callback_chat_id) + set_interface("telegram", callback_chat_id) + service.notify_activity() + callback_session = _get_session(callback_chat_id) + swap_in(agent, callback_session) + apply_chat_runtime(agent, callback_session, callback_chat_id) + result = handle_lumabot_callback( + callback_data, + callback_session, + ) + try: + telegram_api( + "answerCallbackQuery", + { + "callback_query_id": callback_id, + "text": result["text"][:200], + "show_alert": not result.get("ok", False), + }, + ) + except Exception: + pass + print(f"[LumaBot remote] {callback_data}: {result['text']}") + continue + msg = update.get("message", {}) chat_id = str(msg.get("chat", {}).get("id", "")) text = msg.get("text", "").strip() @@ -529,9 +601,17 @@ def _handle_pending_draft(text, chat_id): session = _get_session(chat_id) swap_in(agent, session) apply_chat_runtime(agent, session, chat_id) + remote_mode = session.get("lumabot_mode") == "remote" # Photo if has_photo: + if remote_mode: + send_message( + "Remote mode does not send photos to an LLM. Use the controls " + "below or switch with /lumabot agent.", + reply_markup=lumabot_remote_keyboard(), + ) + continue print(f"[{user_name}] [photo] {caption or '(no caption)'}") file_id = photo_list[-1]["file_id"] image_data = download_telegram_photo(file_id) @@ -563,6 +643,13 @@ def _handle_pending_draft(text, chat_id): # Voice / audio if has_audio: + if remote_mode: + send_message( + "Remote mode does not transcribe voice commands. Use the controls " + "below or switch with /lumabot agent.", + reply_markup=lumabot_remote_keyboard(), + ) + continue label = "[voice]" if voice else "[audio]" print(f"[{user_name}] {label} {caption or '(no caption)'}") media = voice or audio or {} @@ -614,6 +701,14 @@ def _handle_pending_draft(text, chat_id): if handle_telegram_command(text, agent, session, chat_id, speech_client): continue + if remote_mode: + send_message( + "Remote mode only accepts the structured controls below. " + "Use /lumabot agent for natural language.", + reply_markup=lumabot_remote_keyboard(), + ) + continue + try: try: send_chat_action(chat_id, "typing") diff --git a/surfaces/web.py b/surfaces/web.py index 76045e9..ed83e97 100644 --- a/surfaces/web.py +++ b/surfaces/web.py @@ -37,6 +37,7 @@ from core.chat_store import ( delete_chat, get_active_chat, + get_chat_lumabot_profile, get_chat_workspace, list_chats, load_chat, @@ -44,6 +45,7 @@ new_chat_id, save_chat, set_active_chat, + set_chat_lumabot_profile, set_chat_workspace, ) from core import notifications as notification_log @@ -59,6 +61,7 @@ from ollama_client import OllamaClient from tools.comms.email import send_preapproved from tools.comms.react import set_react_context +from tools.lumabot.remote import REMOTE_HELP, execute_remote_action from tools.memory.memory_tools import set_active_user as set_memory_active_user PORT = int(os.getenv("LUMAKIT_WEB_PORT", "7865")) @@ -1222,11 +1225,13 @@ def send_sync(msg: dict): "chat_id": session["chat_id"], "title": session["title"], "messages": session["display_messages"], + "lumabot_mode": session.get("lumabot_mode", "off"), **_workspace_payload(session["workspace_path"]), }) else: await ws.send_json({ "type": "workspace_updated", + "lumabot_mode": session.get("lumabot_mode", "off"), **_workspace_payload(session["workspace_path"]), }) @@ -1287,6 +1292,7 @@ async def run_agent_request(text: str, image_data: bytes | None = None): "run_error": run_error, "streamed": bool(response.get("streamed")), "messages": session["display_messages"], + "lumabot_mode": session.get("lumabot_mode", "off"), **_workspace_payload(session["workspace_path"]), }) except Exception as e: @@ -1383,6 +1389,58 @@ async def run_agent_request(text: str, image_data: bytes | None = None): }) continue + if msg_type == "lumabot_mode": + if agent_task and not agent_task.done(): + await ws.send_json({ + "type": "error", + "text": "Finish or stop the current run before changing LumaBot mode.", + }) + continue + mode = str(data.get("mode", "")).lower() + if mode not in {"off", "agent", "remote"}: + await ws.send_json({"type": "error", "text": "Invalid LumaBot mode value."}) + continue + set_chat_lumabot_profile(session["chat_id"], mode) + _prepare_web_turn(agent, session) + await ws.send_json({ + "type": "lumabot_mode", + "mode": mode, + "text": { + "agent": "LumaBot Agent mode ON. Natural language uses the configured LLM.", + "remote": "LumaBot Remote mode ON. Controls now bypass the LLM.", + "off": "LumaBot mode OFF. Full LumaKit is restored.", + }[mode], + }) + continue + + if msg_type == "lumabot_control": + action = str(data.get("action", "")).lower() + profile = get_chat_lumabot_profile(session["chat_id"]) + if action not in {"stop", "park", "status"} and profile != "remote": + await ws.send_json({ + "type": "lumabot_control", + "ok": False, + "text": "Switch to LumaBot Remote mode first.", + }) + continue + try: + continuous = data.get("continuous", False) + if not isinstance(continuous, bool): + raise ValueError("continuous must be true or false") + result = execute_remote_action( + action, + direction=data.get("direction"), + duration_s=data.get("duration_s", 1.0), + speed=data.get("speed", 0.3), + continuous=continuous, + ) + except ValueError as error: + result = {"ok": False, "text": str(error)} + if action in {"stop", "park"} and agent_task and not agent_task.done(): + agent.request_stop("LumaBot emergency stop requested.") + await ws.send_json({"type": "lumabot_control", **result}) + continue + # Load a specific chat if msg_type == "load_chat": if agent_task and not agent_task.done(): @@ -1408,6 +1466,7 @@ async def run_agent_request(text: str, image_data: bytes | None = None): "chat_id": session["chat_id"], "title": session["title"], "messages": session["display_messages"], + "lumabot_mode": session.get("lumabot_mode", "off"), **_workspace_payload(session["workspace_path"]), }) else: @@ -1435,6 +1494,7 @@ async def run_agent_request(text: str, image_data: bytes | None = None): "chat_id": session["chat_id"], "title": "", "messages": [], + "lumabot_mode": session.get("lumabot_mode", "off"), **_workspace_payload(session["workspace_path"]), }) continue @@ -1452,6 +1512,14 @@ async def run_agent_request(text: str, image_data: bytes | None = None): if not text and not image_data: continue + if get_chat_lumabot_profile(session["chat_id"]) == "remote": + await ws.send_json({ + "type": "lumabot_control", + "ok": False, + "text": REMOTE_HELP, + }) + continue + normalized = text.lower() if not image_data and normalized in _EMAIL_AFFIRM and email_draft_store.get_latest_pending(): await ws.send_json(_handle_email_draft_action("approve")) diff --git a/tests/test_approval_policy.py b/tests/test_approval_policy.py index 703d38e..2156e24 100644 --- a/tests/test_approval_policy.py +++ b/tests/test_approval_policy.py @@ -20,6 +20,12 @@ def test_protected_write_tools_always_require_approval(): assert tool_always_requires_approval(tool, {}) +def test_robot_power_and_autonomy_tools_always_require_owner_approval(): + for tool in ("lumabot_reboot", "lumabot_poweroff", "lumabot_start_autonomy"): + assert tool_always_requires_approval(tool, {"reason": "owner requested"}) + assert autonomous_tool_refusal(tool, {"reason": "owner requested"}) is not None + + def test_read_tools_do_not_require_forced_approval(): assert not tool_always_requires_approval("read_file", {"path": "x"}) assert not tool_always_requires_approval("web_search", {"query": "x"}) @@ -68,6 +74,13 @@ def test_telegram_role_scoping(): assert surface_tool_denial("execute_shell") is not None assert surface_tool_denial("write_file") is not None assert surface_tool_denial("create_task") is not None + assert surface_tool_denial("lumabot_drive") is not None + assert surface_tool_denial("lumabot_sequence") is not None + assert surface_tool_denial("lumabot_stop") is not None + assert surface_tool_denial("lumabot_reboot") is not None + assert surface_tool_denial("lumabot_poweroff") is not None + assert surface_tool_denial("lumabot_start_autonomy") is not None + assert surface_tool_denial("lumabot_status") is None assert surface_tool_denial("read_file") is None # web/CLI are single-user owner surfaces — unrestricted here diff --git a/tests/test_lumabot_activity.py b/tests/test_lumabot_activity.py new file mode 100644 index 0000000..4fc7247 --- /dev/null +++ b/tests/test_lumabot_activity.py @@ -0,0 +1,126 @@ +"""LumaKit renews and clears LumaBot thinking leases safely.""" + +import threading + +from tools.lumabot.activity import LumaBotActivityLease + + +def test_disabled_activity_makes_no_requests(): + calls = [] + lease = LumaBotActivityLease( + enabled=False, + request_fn=lambda *args: calls.append(args), + ) + lease.start() + lease.close() + assert calls == [] + + +def test_activity_renews_and_clears_its_unique_lease(): + calls = [] + renewed = threading.Event() + + def record(lease_id, active, ttl_s): + calls.append((lease_id, active, ttl_s)) + if sum(1 for call in calls if call[1]) >= 2: + renewed.set() + + lease = LumaBotActivityLease( + enabled=True, + ttl_s=10, + renew_s=0.01, + request_fn=record, + lease_id="run-a", + ) + lease.start() + assert renewed.wait(1.0) + lease.close() + + assert calls[0] == ("run-a", True, 10) + assert calls[-1] == ("run-a", False, 10) + + +def test_agent_closes_activity_after_success(workspace, monkeypatch): + import agent as agent_module + + events = [] + + class FakeLease: + def start(self): + events.append("start") + + def close(self): + events.append("close") + + class FakeModel: + last_model_used = "fake" + + def chat(self, **kwargs): + return {"message": {"role": "assistant", "content": "Done."}} + + monkeypatch.setattr(agent_module, "LumaBotActivityLease", FakeLease) + agent = agent_module.Agent(enable_spinner=False) + agent.ollama = FakeModel() + agent.model = "fake" + + response = agent.ask_llm("hello") + + assert response["message"]["content"] == "Done." + assert events == ["start", "close"] + + +def test_agent_closes_activity_when_interrupted(workspace, monkeypatch): + import agent as agent_module + + events = [] + + class FakeLease: + def start(self): + events.append("start") + + def close(self): + events.append("close") + + class ModelMustNotRun: + def chat(self, **kwargs): + raise AssertionError("model should not run after an interrupt") + + monkeypatch.setattr(agent_module, "LumaBotActivityLease", FakeLease) + agent = agent_module.Agent( + check_interrupt=lambda: True, + enable_spinner=False, + ) + agent.ollama = ModelMustNotRun() + + response = agent.ask_llm("stop immediately") + + assert response["message"]["content"] == "Stopped." + assert events == ["start", "close"] + + +def test_agent_closes_activity_after_model_failure(workspace, monkeypatch): + import agent as agent_module + + events = [] + + class FakeLease: + def start(self): + events.append("start") + + def close(self): + events.append("close") + + class BrokenModel: + last_model_used = None + + def chat(self, **kwargs): + raise RuntimeError("model failed") + + monkeypatch.setattr(agent_module, "LumaBotActivityLease", FakeLease) + agent = agent_module.Agent(enable_spinner=False) + agent.ollama = BrokenModel() + + response = agent.ask_llm("hello") + + assert "model failed" in response["message"]["content"] + assert events == ["start", "close"] diff --git a/tests/test_lumabot_mode.py b/tests/test_lumabot_mode.py new file mode 100644 index 0000000..d442334 --- /dev/null +++ b/tests/test_lumabot_mode.py @@ -0,0 +1,250 @@ +"""Agent and no-LLM Remote modes remain isolated and deterministic.""" + +from agent import Agent +from tool_registry import ToolRegistry + + +def _minimal_agent(): + agent = Agent.__new__(Agent) + agent.registry = ToolRegistry() + agent.registry.register( + { + "name": "lumabot_drive", + "description": "Drive the robot.", + "inputSchema": {"type": "object", "properties": {}}, + "execute": lambda inputs: {"accepted": True}, + }, + group="lumabot", + ) + agent.registry.register( + { + "name": "read_file", + "description": "Read a file.", + "inputSchema": {"type": "object", "properties": {}}, + "execute": lambda inputs: {"content": ""}, + }, + group="repo", + ) + agent.runtime_profile = None + agent._active_tool_groups = None + agent._system_prompt_prefix = "FULL LUMAKIT PROMPT" + agent._system_prompt_cache = {} + agent._system_message_cache = {} + agent._tools_schema_cache = {} + agent._tools_schema_cache_version = None + return agent + + +def test_mode_persists_per_conversation(tmp_path, monkeypatch): + from core import chat_store + + monkeypatch.setattr(chat_store, "DB_PATH", tmp_path / "memory.db") + assert chat_store.get_chat_lumabot_profile("chat-a") == "off" + + chat_store.set_chat_lumabot_profile("chat-a", "agent") + assert chat_store.get_chat_lumabot_profile("chat-a") == "agent" + assert chat_store.get_chat_lumabot_profile("chat-b") == "off" + + chat_store.set_chat_lumabot_profile("chat-a", "remote") + assert chat_store.get_chat_lumabot_profile("chat-a") == "remote" + + chat_store.set_chat_lumabot_profile("chat-a", "off") + assert chat_store.get_chat_lumabot_profile("chat-a") == "off" + + +def test_mode_exposes_only_lumabot_tools_with_compact_prompt(): + agent = _minimal_agent() + agent.set_runtime_profile("lumabot") + + names = [tool["function"]["name"] for tool in agent.get_tools_for_llm()] + assert names == ["lumabot_drive"] + prompt = agent.build_system_prompt() + assert "physical LumaBot" in prompt + assert "natural-language intent yourself" in prompt + assert "FULL LUMAKIT PROMPT" not in prompt + + +def test_mode_blocks_hidden_tool_execution(): + agent = _minimal_agent() + agent.set_runtime_profile("lumabot") + + result = agent.execute_tool("read_file", {}) + assert result["success"] is False + assert "unavailable in lumabot mode" in result["error"] + + +def test_turning_mode_off_restores_full_tool_catalog(): + agent = _minimal_agent() + agent.set_runtime_profile("lumabot") + agent.set_runtime_profile(None) + + names = {tool["function"]["name"] for tool in agent.get_tools_for_llm()} + assert names == {"lumabot_drive", "read_file"} + assert agent.build_system_prompt() == "FULL LUMAKIT PROMPT" + + +def test_remote_profile_exposes_no_tools_even_if_agent_is_called(): + agent = _minimal_agent() + agent.set_runtime_profile("lumabot_remote") + + assert agent.get_tools_for_llm() == [] + assert "No tools are available" in agent.build_system_prompt() + result = agent.execute_tool("lumabot_drive", {}) + assert result["success"] is False + assert "lumabot_remote mode" in result["error"] + + +def test_cli_toggle_uses_shared_conversation_mode(monkeypatch, capsys): + from core import commands + + saved = [] + refreshed = [] + monkeypatch.setattr(commands, "get_chat_lumabot_profile", lambda chat_id: "off") + monkeypatch.setattr( + commands, + "set_chat_lumabot_profile", + lambda chat_id, profile: saved.append((chat_id, profile)), + ) + monkeypatch.setattr( + commands, + "apply_user_runtime", + lambda agent, session, user_id, surface=None: refreshed.append(surface), + ) + + commands.cmd_lumabot("remote", object(), {"chat_id": "cli-chat"}) + assert saved == [("cli-chat", "remote")] + assert refreshed == ["cli"] + assert "structured commands bypass the LLM" in capsys.readouterr().out + + +def test_telegram_toggle_is_owner_only_and_uses_shared_mode(monkeypatch): + from core import telegram_commands + + sent = [] + saved = [] + refreshed = [] + monkeypatch.setattr(telegram_commands, "OWNER_ID", "owner-chat") + monkeypatch.setattr( + telegram_commands, + "send_message", + lambda text, **kwargs: sent.append((text, kwargs)), + ) + monkeypatch.setattr( + telegram_commands, + "get_chat_lumabot_profile", + lambda chat_id: "off", + ) + monkeypatch.setattr( + telegram_commands, + "set_chat_lumabot_profile", + lambda chat_id, profile: saved.append((chat_id, profile)), + ) + monkeypatch.setattr( + telegram_commands, + "apply_chat_runtime", + lambda agent, session, chat_id: refreshed.append(chat_id), + ) + + session = {"chat_id": "shared-chat"} + handled = telegram_commands.handle_telegram_command( + "/lumabot remote", object(), session, "owner-chat", None + ) + assert handled is True + assert saved == [("shared-chat", "remote")] + assert refreshed == ["owner-chat"] + assert sent[-1][0].startswith("LumaBot Remote mode ON") + assert "inline_keyboard" in sent[-1][1]["reply_markup"] + + telegram_commands.handle_telegram_command( + "/lumabot remote", object(), session, "someone-else", None + ) + assert sent[-1][0] == "This command is owner-only." + + +def test_remote_command_dispatches_once_without_llm(monkeypatch): + from tools.lumabot import remote + + calls = [] + + class Scheduler: + def start(self, direction, speed, duration): + calls.append(("start", direction, speed, duration)) + return {"accepted": True, "entire_request_scheduled": True} + + def start_continuous(self, direction, speed): + calls.append(("continuous", direction, speed)) + return {"accepted": True, "continuous": True} + + def stop(self): + calls.append(("stop",)) + return {"stopped": True} + + monkeypatch.setattr(remote, "SCHEDULER", Scheduler()) + latched = remote.execute_remote_command("drive forward") + assert latched["ok"] is True + assert calls == [("continuous", "forward", 0.3)] + + result = remote.execute_remote_command("drive forward 2 0.4") + assert result["ok"] is True + assert calls[-1] == ("start", "forward", 0.4, 2.0) + + reversed_result = remote.execute_remote_command("drive reverse") + assert reversed_result["ok"] is True + assert calls[-1] == ("continuous", "backward", 0.3) + + stopped = remote.execute_remote_command("park") + assert stopped["ok"] is True + assert calls[-1] == ("stop",) + + around = remote.execute_remote_action("turn_around", speed=0.5) + assert around["ok"] is True + assert calls[-1] == ("start", "left", 0.5, 2.0) + + +def test_remote_command_rejects_free_form_and_bad_bounds(monkeypatch): + from tools.lumabot import remote + + monkeypatch.setattr( + remote, + "SCHEDULER", + type("Scheduler", (), {"start": lambda *args: {}})(), + ) + assert remote.execute_remote_command("come closer")["ok"] is False + result = remote.execute_remote_command("drive forward 60") + assert result["ok"] is False + assert "between 0.1 and 30" in result["text"] + + +def test_telegram_button_uses_structured_callback_without_llm(monkeypatch): + from core import telegram_commands + + calls = [] + monkeypatch.setattr( + telegram_commands, + "get_chat_lumabot_profile", + lambda chat_id: "remote", + ) + monkeypatch.setattr( + telegram_commands, + "execute_remote_action", + lambda action, **kwargs: calls.append((action, kwargs)) + or {"ok": True, "text": "accepted"}, + ) + + result = telegram_commands.handle_lumabot_callback( + "lbot:drive:forward", + {"chat_id": "robot-chat"}, + ) + assert result["ok"] is True + assert calls == [("drive", {"direction": "forward", "continuous": True})] + + +def test_remote_number_validation_rejects_boolean(): + from tools.lumabot.remote import execute_remote_action + + try: + execute_remote_action("drive", direction="forward", duration_s=True) + except ValueError as error: + assert "duration must be a number" in str(error) + else: + raise AssertionError("boolean duration should be rejected") diff --git a/tests/test_lumabot_photos.py b/tests/test_lumabot_photos.py new file mode 100644 index 0000000..2a3e51d --- /dev/null +++ b/tests/test_lumabot_photos.py @@ -0,0 +1,58 @@ +from pathlib import Path + +from core.interface_context import set_interface +from tools.lumabot import photos + + +def test_capture_is_adopted_into_private_user_directory(tmp_path, monkeypatch): + monkeypatch.setattr(photos, "PHOTO_ROOT", tmp_path) + set_interface("telegram", "pat-private-id") + source = tmp_path / "visitor-lx1-test.jpg" + source.write_bytes(b"jpeg") + + target = photos.adopt_capture(str(source)) + + assert target.read_bytes() == b"jpeg" + assert target.parent.parent.name == "users" + assert "pat-private-id" not in str(target) + assert target.stat().st_mode & 0o777 == 0o600 + + +def test_capture_outside_camera_inbox_is_rejected(tmp_path, monkeypatch): + monkeypatch.setattr(photos, "PHOTO_ROOT", tmp_path / "photos") + set_interface("web", "owner") + outside = tmp_path / "outside.jpg" + outside.write_bytes(b"jpeg") + + try: + photos.adopt_capture(str(outside)) + except ValueError as error: + assert "invalid photo path" in str(error) + else: + raise AssertionError("outside photo was accepted") + + +def test_photos_are_listed_and_moved_to_recoverable_trash(tmp_path, monkeypatch): + monkeypatch.setattr(photos, "PHOTO_ROOT", tmp_path) + set_interface("telegram", "pat") + source = tmp_path / "visitor-lx1-newest.jpg" + source.write_bytes(b"jpeg") + stored = photos.adopt_capture(str(source)) + + assert photos.list_photos()[0]["photo_id"] == stored.name + trashed = photos.trash_photo(stored.name) + assert trashed.parent.name == ".trash" + assert trashed.exists() + assert photos.list_photos() == [] + + +def test_photo_delete_rejects_path_traversal(tmp_path, monkeypatch): + monkeypatch.setattr(photos, "PHOTO_ROOT", tmp_path) + set_interface("web", "owner") + + try: + photos.trash_photo("../someone-elses-photo.jpg") + except ValueError as error: + assert "photo_id" in str(error) + else: + raise AssertionError("path traversal was accepted") diff --git a/tests/test_lumabot_power.py b/tests/test_lumabot_power.py new file mode 100644 index 0000000..1a20f63 --- /dev/null +++ b/tests/test_lumabot_power.py @@ -0,0 +1,92 @@ +"""LumaBot power tools schedule only fixed, delayed systemd actions.""" + +from subprocess import CompletedProcess + +import pytest + +from tool_registry import ToolRegistry +from tools.lumabot import power + + +@pytest.fixture() +def registry(): + result = ToolRegistry() + result.register(power.get_lumabot_reboot_tool(), group="lumabot") + result.register(power.get_lumabot_poweroff_tool(), group="lumabot") + return result + + +@pytest.mark.parametrize("action", ["reboot", "poweroff"]) +def test_power_tool_stops_motors_and_uses_fixed_command(registry, monkeypatch, action): + stopped = [] + calls = [] + monkeypatch.setattr(power.SCHEDULER, "stop", lambda: stopped.append(True) or {"stopped": True}) + monkeypatch.setattr( + power.subprocess, + "run", + lambda command, **kwargs: calls.append((command, kwargs)) + or CompletedProcess(command, 0, "", ""), + ) + + result = registry.execute(f"lumabot_{action}", {"reason": "owner requested"}) + + assert result["success"] is True + assert result["data"]["scheduled"] is True + assert result["data"]["delay_s"] == 15 + assert stopped == [True] + assert calls[0][0] == [ + "/usr/bin/sudo", + "-n", + "/usr/bin/systemd-run", + "--quiet", + "--collect", + f"--unit=lumabot-{action}", + "--on-active=15s", + "/usr/bin/systemctl", + action, + ] + assert calls[0][1] == { + "capture_output": True, + "text": True, + "timeout": 5, + "check": False, + } + + +def test_reason_never_changes_the_command(registry, monkeypatch): + calls = [] + monkeypatch.setattr(power.SCHEDULER, "stop", lambda: {"stopped": True}) + monkeypatch.setattr( + power.subprocess, + "run", + lambda command, **kwargs: calls.append(command) or CompletedProcess(command, 0, "", ""), + ) + + result = registry.execute("lumabot_poweroff", {"reason": "; arbitrary command"}) + + assert result["success"] is True + assert "; arbitrary command" not in calls[0] + + +def test_sudo_failure_is_a_tool_failure(registry, monkeypatch): + monkeypatch.setattr(power.SCHEDULER, "stop", lambda: {"stopped": True}) + monkeypatch.setattr( + power.subprocess, + "run", + lambda *args, **kwargs: CompletedProcess(args[0], 1, "", "not allowed"), + ) + + result = registry.execute("lumabot_reboot", {"reason": "owner requested"}) + + assert result["success"] is False + assert "not allowed" in result["error"] + + +def test_empty_reason_is_rejected_before_stopping_motors(registry, monkeypatch): + monkeypatch.setattr( + power.SCHEDULER, + "stop", + lambda: pytest.fail("motors should not be touched for invalid input"), + ) + result = registry.execute("lumabot_reboot", {"reason": " "}) + assert result["success"] is False diff --git a/tests/test_lumabot_tools.py b/tests/test_lumabot_tools.py new file mode 100644 index 0000000..f3702d5 --- /dev/null +++ b/tests/test_lumabot_tools.py @@ -0,0 +1,182 @@ +"""LumaBot tools use structured schemas and the normal tool-result cycle.""" + +import time + +import pytest + +from tool_registry import ToolRegistry +from tools.lumabot import client +from tools.lumabot.autonomy import get_lumabot_start_autonomy_tool +from tools.lumabot.motion import ( + MotionScheduler, + SCHEDULER, + get_lumabot_drive_tool, + get_lumabot_sequence_tool, + get_lumabot_stop_tool, +) +from tools.lumabot.status import get_lumabot_status_tool + + +@pytest.fixture() +def registry(): + result = ToolRegistry() + for tool in ( + get_lumabot_drive_tool(), + get_lumabot_sequence_tool(), + get_lumabot_stop_tool(), + get_lumabot_status_tool(), + get_lumabot_start_autonomy_tool(), + ): + result.register(tool, group="lumabot") + return result + + +def test_drive_schema_guides_llm_without_phrase_parsing(registry): + tool = registry.get("lumabot_drive") + direction = tool["inputSchema"]["properties"]["direction"] + duration = tool["inputSchema"]["properties"]["duration_s"] + assert direction["enum"] == ["forward", "backward", "left", "right"] + assert duration["maximum"] == 30.0 + assert "structured" in tool["description"].lower() + assert "renewed in the background" in tool["description"].lower() + assert "playful" in tool["description"].lower() + + +@pytest.mark.parametrize( + "inputs", + [ + {"direction": "sideways"}, + {"direction": "forward", "duration_s": 31}, + ], +) +def test_invalid_motion_never_reaches_daemon(registry, monkeypatch, inputs): + monkeypatch.setattr(client, "drive", lambda *args: pytest.fail("HTTP called")) + result = registry.execute("lumabot_drive", inputs) + assert not result["success"] + + +def test_offline_daemon_surfaces_as_tool_failure(registry, monkeypatch): + monkeypatch.setattr( + client, + "drive", + lambda *args: {"error": "LumaBot is offline — is the LumaBot daemon running?"}, + ) + result = registry.execute("lumabot_drive", {"direction": "forward"}) + assert not result["success"] + assert "offline" in result["error"] + + +def test_drive_returns_immediately_with_requested_duration(registry, monkeypatch): + calls = [] + monkeypatch.setattr( + client, + "drive", + lambda direction, speed, duration: calls.append((direction, speed, duration)) + or { + "accepted": True, + "direction": direction, + "speed": speed, + "duration_s": duration, + "watchdog_active": True, + "obstacle_safety_active": False, + }, + ) + monkeypatch.setattr(client, "stop", lambda: {"stopped": True}) + result = registry.execute( + "lumabot_drive", + {"direction": "left", "speed": 0.2, "duration_s": 7}, + ) + assert result["success"] + assert result["data"]["scheduled"] is True + assert result["data"]["direction"] == "left" + assert result["data"]["requested_duration_s"] == 7 + assert "under 12 words" in result["data"]["response_guidance"] + assert calls[0] == ("left", 0.2, 3.0) + SCHEDULER.cancel() + + +def test_sequence_runs_each_step_once_and_in_order(registry, monkeypatch): + calls = [] + monkeypatch.setattr( + client, + "drive", + lambda direction, speed, duration: calls.append((direction, duration)) + or {"accepted": True, "direction": direction}, + ) + result = registry.execute( + "lumabot_sequence", + { + "steps": [ + {"direction": "forward", "speed": 0.2, "duration_s": 0.1}, + {"direction": "left", "speed": 0.2, "duration_s": 0.1}, + {"direction": "backward", "speed": 0.2, "duration_s": 0.1}, + ] + }, + ) + assert result["success"] + assert result["data"]["entire_request_scheduled"] is True + assert result["data"]["step_count"] == 3 + time.sleep(0.35) + assert calls == [("forward", 0.1), ("left", 0.1), ("backward", 0.1)] + SCHEDULER.cancel() + + +def test_continuous_motion_renews_until_stopped(monkeypatch): + from tools.lumabot import motion + + calls = [] + monkeypatch.setattr(motion, "WATCHDOG_LEASE_S", 0.08) + monkeypatch.setattr(motion, "RENEW_MARGIN_S", 0.02) + monkeypatch.setattr( + client, + "drive", + lambda direction, speed, duration: calls.append( + ("drive", direction, speed, duration) + ) + or {"accepted": True, "direction": direction}, + ) + monkeypatch.setattr( + client, + "stop", + lambda: calls.append(("stop",)) or {"stopped": True}, + ) + + scheduler = MotionScheduler() + result = scheduler.start_continuous("forward", 0.3) + assert result["continuous"] is True + + deadline = time.monotonic() + 0.5 + while len(calls) < 2 and time.monotonic() < deadline: + time.sleep(0.01) + assert len(calls) >= 2 + assert all(call[1] == "forward" for call in calls if call[0] == "drive") + + scheduler.stop() + calls_after_stop = len(calls) + time.sleep(0.12) + assert len(calls) == calls_after_stop + assert calls[-1] == ("stop",) + + +def test_stop_and_status_return_daemon_results(registry, monkeypatch): + monkeypatch.setattr(client, "stop", lambda: {"stopped": True}) + monkeypatch.setattr(client, "get_status", lambda: {"battery_pct": 75.0}) + assert registry.execute("lumabot_stop", {})["data"]["stopped"] is True + assert registry.execute("lumabot_status", {})["data"]["battery_pct"] == 75.0 + assert "park" in registry.get("lumabot_stop")["description"].lower() + assert "human-friendly" in registry.get("lumabot_status")["description"] + + +def test_autonomy_uses_structured_daemon_action(registry, monkeypatch): + monkeypatch.setattr( + client, + "start_autonomy", + lambda: {"accepted": True, "status": {"autonomous": True}}, + ) + result = registry.execute( + "lumabot_start_autonomy", + {"reason": "owner asked the robot to explore"}, + ) + assert result["success"] is True + assert result["data"]["status"]["autonomous"] is True + assert "always requires approval" in registry.get("lumabot_start_autonomy")["description"] diff --git a/tools/code_intel/parsers.py b/tools/code_intel/parsers.py index 1375bb1..cdf6317 100644 --- a/tools/code_intel/parsers.py +++ b/tools/code_intel/parsers.py @@ -3,7 +3,8 @@ warnings.filterwarnings("ignore", category=FutureWarning, module="tree_sitter") -import tree_sitter_languages as tsl +from tree_sitter import Query, QueryCursor +import tree_sitter_language_pack as tsl from tools.code_intel.symbol_table import Reference, Symbol, SymbolTable @@ -89,6 +90,15 @@ def _get_language(language: str): return tsl.get_language(language) +def _query_captures(language, query: str, root_node): + captures = QueryCursor(Query(language, query)).captures(root_node) + return [ + (node, capture_name) + for capture_name, nodes in captures.items() + for node in nodes + ] + + def _extract_params_python(node) -> list[str]: """Extract parameter names from a Python function_definition node.""" for child in node.children: @@ -257,8 +267,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract classes --- if "classes" in queries: - q = lang.query(queries["classes"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["classes"], tree.root_node) for node, capture_name in captures: if capture_name != "definition": continue @@ -286,8 +295,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], for query_key, sym_kind in [("structs", "class"), ("interfaces", "class"), ("enums", "class"), ("traits", "class")]: if query_key in queries: - q = lang.query(queries[query_key]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries[query_key], tree.root_node) for node, capture_name in captures: if capture_name != "definition": continue @@ -315,8 +323,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract functions --- if "functions" in queries: - q = lang.query(queries["functions"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["functions"], tree.root_node) for node, capture_name in captures: if capture_name != "definition": continue @@ -352,8 +359,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract Go methods (with receiver) --- if "go_methods" in queries: - q = lang.query(queries["go_methods"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["go_methods"], tree.root_node) for node, capture_name in captures: if capture_name != "definition": continue @@ -383,8 +389,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract methods (JS/TS) --- if "methods" in queries: - q = lang.query(queries["methods"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["methods"], tree.root_node) for node, capture_name in captures: if capture_name != "definition": continue @@ -414,8 +419,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract arrow functions (JS/TS) --- if "arrows" in queries: - q = lang.query(queries["arrows"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["arrows"], tree.root_node) for node, capture_name in captures: if capture_name == "name": symbols.append(Symbol( @@ -433,8 +437,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], if isinstance(import_queries, str): import_queries = [import_queries] for iq in import_queries: - q = lang.query(iq) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, iq, tree.root_node) for node, capture_name in captures: line_num = node.start_point[0] + 1 text = node.text.decode("utf-8").strip() @@ -447,8 +450,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract top-level assignments (variables/constants) --- if "assignments" in queries: - q = lang.query(queries["assignments"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["assignments"], tree.root_node) for node, capture_name in captures: if capture_name == "name": # Only top-level assignments (parent is module/program) @@ -471,8 +473,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract Go/Rust vars, consts, statics --- for query_key in ("go_vars", "go_consts", "rust_consts", "rust_statics"): if query_key in queries: - q = lang.query(queries[query_key]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries[query_key], tree.root_node) for node, capture_name in captures: if capture_name == "name": var_name = node.text.decode("utf-8") diff --git a/tools/lumabot/__init__.py b/tools/lumabot/__init__.py new file mode 100644 index 0000000..0f1ef8b --- /dev/null +++ b/tools/lumabot/__init__.py @@ -0,0 +1 @@ +"""LumaBot HTTP-client tools.""" diff --git a/tools/lumabot/activity.py b/tools/lumabot/activity.py new file mode 100644 index 0000000..091f216 --- /dev/null +++ b/tools/lumabot/activity.py @@ -0,0 +1,74 @@ +"""Best-effort LumaBot thinking indicator for interactive LumaKit turns.""" + +from __future__ import annotations + +import os +import threading +import uuid + +from tools.lumabot import client + + +def _activity_enabled() -> bool: + return os.getenv("LUMABOT_ACTIVITY_INDICATOR", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +class LumaBotActivityLease: + """Renew a unique activity lease without blocking the agent's work.""" + + def __init__( + self, + *, + enabled: bool | None = None, + ttl_s: float = 10.0, + renew_s: float = 3.0, + request_fn=None, + lease_id: str | None = None, + ): + self.enabled = _activity_enabled() if enabled is None else enabled + self.ttl_s = ttl_s + self.renew_s = renew_s + self.lease_id = lease_id or uuid.uuid4().hex + self._request = request_fn or client.set_indicator_activity + self._stop_event = threading.Event() + self._thread = None + + def start(self) -> None: + if not self.enabled or (self._thread and self._thread.is_alive()): + return + self._thread = threading.Thread( + target=self._run, + name=f"lumabot-activity-{self.lease_id[:8]}", + daemon=True, + ) + try: + self._thread.start() + except RuntimeError: + self._thread = None + + def close(self) -> None: + self._stop_event.set() + thread = self._thread + self._thread = None + if thread and thread.is_alive(): + thread.join(timeout=1.25) + + def _send(self, active: bool) -> None: + try: + self._request(self.lease_id, active, self.ttl_s) + except Exception: + pass + + def _run(self) -> None: + try: + while not self._stop_event.is_set(): + self._send(True) + if self._stop_event.wait(self.renew_s): + break + finally: + self._send(False) diff --git a/tools/lumabot/autonomy.py b/tools/lumabot/autonomy.py new file mode 100644 index 0000000..9028308 --- /dev/null +++ b/tools/lumabot/autonomy.py @@ -0,0 +1,27 @@ +"""Approval-gated autonomous driving for the physical LumaBot.""" + +from tools.lumabot import client + + +def get_lumabot_start_autonomy_tool(): + return { + "name": "lumabot_start_autonomy", + "description": ( + "Start LumaBot's local autonomous obstacle-avoidance mode. Use only when the owner " + "explicitly asks the robot to roam or drive autonomously. This starts ongoing " + "physical movement and always requires approval. The hardware daemon refuses to " + "start unless motors, distance, motion, tilt, and battery safety checks pass. " + "Use lumabot_stop to cancel autonomy immediately." + ), + "inputSchema": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Why the owner requested autonomous driving.", + } + }, + "required": ["reason"], + }, + "execute": lambda inputs: client.start_autonomy(), + } diff --git a/tools/lumabot/client.py b/tools/lumabot/client.py new file mode 100644 index 0000000..5b85753 --- /dev/null +++ b/tools/lumabot/client.py @@ -0,0 +1,64 @@ +"""Thin HTTP client for the local LumaBot hardware daemon.""" + +from __future__ import annotations + +import os +from typing import Any + +import requests + + +DEFAULT_URL = "http://127.0.0.1:8971" + + +def _request( + method: str, + path: str, + body: dict[str, Any] | None = None, + *, + timeout: float = 2.0, +) -> dict: + base_url = os.getenv("LUMABOT_URL", DEFAULT_URL).rstrip("/") + try: + response = requests.request( + method, + f"{base_url}{path}", + json=body, + timeout=timeout, + ) + payload = response.json() + except (requests.RequestException, ValueError): + return {"error": "LumaBot is offline — is the LumaBot daemon running?"} + + if response.status_code >= 400: + return {"error": payload.get("error") or f"LumaBot returned HTTP {response.status_code}"} + return payload + + +def get_status() -> dict: + return _request("GET", "/status") + + +def drive(direction: str, speed: float, duration_s: float) -> dict: + return _request( + "POST", + "/drive", + {"direction": direction, "speed": speed, "duration_s": duration_s}, + ) + + +def stop() -> dict: + return _request("POST", "/stop") + + +def start_autonomy() -> dict: + return _request("POST", "/autonomy", {"active": True}) + + +def set_indicator_activity(lease_id: str, active: bool, ttl_s: float) -> dict: + return _request( + "POST", + "/indicator/activity", + {"lease_id": lease_id, "active": active, "ttl_s": ttl_s}, + timeout=0.5, + ) diff --git a/tools/lumabot/motion.py b/tools/lumabot/motion.py new file mode 100644 index 0000000..f26973b --- /dev/null +++ b/tools/lumabot/motion.py @@ -0,0 +1,365 @@ +"""LLM-selectable LumaBot movement tools.""" + +from __future__ import annotations + +import threading +import time + +from tools.lumabot import client + + +WATCHDOG_LEASE_S = 3.0 +RENEW_MARGIN_S = 0.25 + + +class MotionScheduler: + def __init__(self): + self._lock = threading.Lock() + self._send_lock = threading.Lock() + self._cancel_event: threading.Event | None = None + + def start(self, direction: str, speed: float, duration_s: float) -> dict: + step = {"direction": direction, "speed": speed, "duration_s": duration_s} + return self.start_sequence([step], single_drive=True) + + def start_continuous(self, direction: str, speed: float) -> dict: + """Keep renewing a short drive lease until another command cancels it.""" + cancel_event = self._replace_active_motion() + result = self._send_drive_if_active( + cancel_event, + direction, + speed, + WATCHDOG_LEASE_S, + ) + if result is None: + return {"error": "Movement was replaced before it started."} + if result.get("error"): + self._clear_active_motion(cancel_event) + return result + + worker = threading.Thread( + target=self._renew_continuous, + args=(cancel_event, direction, speed), + daemon=True, + name="lumabot-continuous-motion", + ) + worker.start() + return { + **result, + "continuous": True, + "watchdog_lease_s": WATCHDOG_LEASE_S, + "scheduled": True, + "entire_request_scheduled": True, + "direction": direction, + "speed": speed, + } + + def start_sequence(self, steps: list[dict], single_drive: bool = False) -> dict: + cancel_event = self._replace_active_motion() + + first = steps[0] + started_at = time.monotonic() + first_lease = min(WATCHDOG_LEASE_S, first["duration_s"]) + result = self._send_drive_if_active( + cancel_event, + first["direction"], + first["speed"], + first_lease, + ) + if result is None: + return {"error": "Movement was replaced before it started."} + if result.get("error"): + self._clear_active_motion(cancel_event) + return result + + if len(steps) > 1 or first["duration_s"] > first_lease: + worker = threading.Thread( + target=self._run_sequence, + args=(cancel_event, steps, started_at, first_lease), + daemon=True, + name="lumabot-motion-sequence", + ) + worker.start() + + return { + **result, + "requested_duration_s": sum(step["duration_s"] for step in steps), + "watchdog_lease_s": first_lease, + "scheduled": True, + "entire_request_scheduled": True, + "step_count": len(steps), + "steps": steps, + "single_drive": single_drive, + "response_guidance": ( + "Reply now with one playful sentence under 12 words. " + "Do not list movement parameters or repeat any movement tool." + ), + } + + def _run_sequence( + self, + cancel_event: threading.Event, + steps: list[dict], + started_at: float, + first_lease: float, + ) -> None: + first = steps[0] + if not self._finish_step( + cancel_event, + first, + started_at, + first_lease, + ): + return + + for step in steps[1:]: + if cancel_event.is_set(): + return + step_started = time.monotonic() + first_lease = min(WATCHDOG_LEASE_S, step["duration_s"]) + result = self._send_drive_if_active( + cancel_event, + step["direction"], + step["speed"], + first_lease, + ) + if result is None or result.get("error"): + return + if not self._finish_step(cancel_event, step, step_started, first_lease): + return + + def _finish_step( + self, + cancel_event: threading.Event, + step: dict, + started_at: float, + current_lease: float, + ) -> bool: + deadline = started_at + step["duration_s"] + lease_deadline = started_at + current_lease + while True: + now = time.monotonic() + remaining = deadline - now + if remaining <= 0: + return True + if lease_deadline >= deadline: + return not cancel_event.wait(remaining) + + renew_in = max(0.0, lease_deadline - now - RENEW_MARGIN_S) + if cancel_event.wait(renew_in): + return False + + remaining = deadline - time.monotonic() + if remaining <= 0: + return True + next_lease = min(WATCHDOG_LEASE_S, remaining) + result = self._send_drive_if_active( + cancel_event, + step["direction"], + step["speed"], + next_lease, + ) + if result is None or result.get("error"): + return False + lease_deadline = time.monotonic() + next_lease + + def _renew_continuous( + self, + cancel_event: threading.Event, + direction: str, + speed: float, + ) -> None: + renew_in = max(0.01, WATCHDOG_LEASE_S - RENEW_MARGIN_S) + while not cancel_event.wait(renew_in): + result = self._send_drive_if_active( + cancel_event, + direction, + speed, + WATCHDOG_LEASE_S, + ) + if result is None: + return + if result.get("error"): + self._clear_active_motion(cancel_event) + return + + def _replace_active_motion(self) -> threading.Event: + cancel_event = threading.Event() + with self._lock: + if self._cancel_event: + self._cancel_event.set() + self._cancel_event = cancel_event + return cancel_event + + def _clear_active_motion(self, cancel_event: threading.Event) -> None: + cancel_event.set() + with self._lock: + if self._cancel_event is cancel_event: + self._cancel_event = None + + def _send_drive(self, direction: str, speed: float, duration_s: float) -> dict: + with self._send_lock: + return client.drive(direction, speed, duration_s) + + def _send_drive_if_active( + self, + cancel_event: threading.Event, + direction: str, + speed: float, + duration_s: float, + ) -> dict | None: + # Hold the state lock through the send so an older renewal cannot race + # behind a newer direction and overwrite it at the daemon. + with self._lock: + if self._cancel_event is not cancel_event or cancel_event.is_set(): + return None + return self._send_drive(direction, speed, duration_s) + + def cancel(self) -> None: + with self._lock: + if self._cancel_event: + self._cancel_event.set() + self._cancel_event = None + + def stop(self) -> dict: + self.cancel() + with self._send_lock: + return client.stop() + + +SCHEDULER = MotionScheduler() + + +def _drive(inputs: dict) -> dict: + return SCHEDULER.start( + inputs["direction"], + inputs.get("speed", 0.3), + inputs.get("duration_s", 1.0), + ) + + +def _sequence(inputs: dict) -> dict: + raw_steps = inputs["steps"] + if not isinstance(raw_steps, list) or not 1 <= len(raw_steps) <= 10: + raise ValueError("steps must contain between 1 and 10 movement steps") + + steps = [] + for index, raw in enumerate(raw_steps): + if not isinstance(raw, dict): + raise ValueError(f"steps[{index}] must be an object") + direction = raw.get("direction") + if direction not in {"forward", "backward", "left", "right"}: + raise ValueError(f"steps[{index}].direction is invalid") + try: + speed = float(raw.get("speed", 0.3)) + duration_s = float(raw.get("duration_s", 1.0)) + except (TypeError, ValueError) as error: + raise ValueError(f"steps[{index}] speed and duration must be numbers") from error + if not 0.1 <= speed <= 1.0: + raise ValueError(f"steps[{index}].speed must be between 0.1 and 1.0") + if not 0.1 <= duration_s <= 30.0: + raise ValueError(f"steps[{index}].duration_s must be between 0.1 and 30") + steps.append({"direction": direction, "speed": speed, "duration_s": duration_s}) + + if sum(step["duration_s"] for step in steps) > 30.0: + raise ValueError("total sequence duration must not exceed 30 seconds") + return SCHEDULER.start_sequence(steps) + + +def get_lumabot_drive_tool(): + return { + "name": "lumabot_drive", + "description": ( + "Drive or rotate the physical LumaBot using structured direction, speed, and time. " + "Use forward/backward for translation and left/right to spin in place. Positive " + "direction is already calibrated to the robot; do not compensate for motor wiring. " + "The duration is the user's total requested movement time. The tool returns as soon " + "as movement is accepted, while short watchdog leases are renewed in the background " + "until that total duration is reached. Use this tool exactly once for one continuous " + "movement. For an ordered request containing then/after/multiple movements, use " + "lumabot_sequence exactly once instead. A new drive replaces an active drive. Never " + "repeat a movement after a result says entire_request_scheduled=true. The result " + "states whether obstacle safety was active; do not claim it was active when false. " + "After success, reply in one playful sentence under 12 words; do not mechanically " + "recite direction, speed, or duration unless the user asks." + ), + "inputSchema": { + "type": "object", + "properties": { + "direction": { + "type": "string", + "enum": ["forward", "backward", "left", "right"], + "description": "Physical movement direction. Left/right rotate in place.", + }, + "speed": { + "type": "number", + "minimum": 0.1, + "maximum": 1.0, + "description": "Normalized motor speed. Default 0.3; prefer low speeds indoors.", + }, + "duration_s": { + "type": "number", + "minimum": 0.1, + "maximum": 30.0, + "description": "Total requested movement time in seconds. Default 1, maximum 30.", + }, + }, + "required": ["direction"], + }, + "execute": _drive, + } + + +def get_lumabot_sequence_tool(): + return { + "name": "lumabot_sequence", + "description": ( + "Schedule one ordered physical LumaBot movement plan from a multi-step user request. " + "Use this once when movements must happen in order, such as drive, then turn, then " + "drive another direction. Each step fully runs before the next begins. The scheduler " + "returns immediately, renews short watchdog leases in the background, and executes " + "the supplied steps exactly once. Do not also call lumabot_drive for the same request " + "and never repeat the sequence after entire_request_scheduled=true. Left/right spin " + "in place; a 180-degree turn is approximate because the robot has no wheel encoders. " + "After acceptance, reply in one playful sentence under 12 words rather than " + "mechanically listing every step." + ), + "inputSchema": { + "type": "object", + "properties": { + "steps": { + "type": "array", + "description": "Ordered movement steps; maximum 10 and 30 seconds total.", + "items": { + "type": "object", + "properties": { + "direction": { + "type": "string", + "enum": ["forward", "backward", "left", "right"], + }, + "speed": {"type": "number", "minimum": 0.1, "maximum": 1.0}, + "duration_s": {"type": "number", "minimum": 0.1, "maximum": 30.0}, + }, + "required": ["direction"], + }, + } + }, + "required": ["steps"], + }, + "execute": _sequence, + } + + +def get_lumabot_stop_tool(): + return { + "name": "lumabot_stop", + "description": ( + "Immediately stop LumaBot and release both motors. Use whenever the user asks the " + "robot not to move, to stop now, to park, or to cancel current or autonomous " + "movement. Parking means stopping autonomy, cancelling scheduled motion, and " + "coasting both motors. " + "Confirm the stop briefly and naturally without reciting internal motor values." + ), + "inputSchema": {"type": "object", "properties": {}}, + "execute": lambda inputs: SCHEDULER.stop(), + } diff --git a/tools/lumabot/photos.py b/tools/lumabot/photos.py new file mode 100644 index 0000000..e6585a5 --- /dev/null +++ b/tools/lumabot/photos.py @@ -0,0 +1,66 @@ +"""Private per-user storage for VISITOR LX-1 photos.""" + +from hashlib import sha256 +from pathlib import Path + +from core.interface_context import get_interface, get_interface_user + + +PHOTO_ROOT = Path.home() / ".visitor-lx1" / "photos" + + +def owner_directory() -> Path: + """Return a private directory without exposing the surface user ID.""" + surface = get_interface() or "local" + user_id = get_interface_user() or "owner" + owner_key = sha256(f"{surface}:{user_id}".encode()).hexdigest()[:16] + directory = PHOTO_ROOT / "users" / owner_key + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + directory.chmod(0o700) + return directory + + +def adopt_capture(raw_path: str) -> Path: + """Move one daemon-created inbox JPEG into the current user's library.""" + source = Path(raw_path).resolve() + if source.parent != PHOTO_ROOT.resolve() or source.suffix.lower() != ".jpg": + raise ValueError("camera returned an invalid photo path") + target = owner_directory() / source.name + if target.exists(): + raise FileExistsError(f"photo already exists: {target.name}") + source.rename(target) + target.chmod(0o600) + return target + + +def list_photos() -> list[dict]: + """List the current user's captured photos, newest first.""" + results = [] + for path in sorted(owner_directory().glob("*.jpg"), reverse=True): + if path.is_file() and not path.is_symlink(): + results.append({ + "photo_id": path.name, + "captured_at": path.stat().st_mtime, + "bytes": path.stat().st_size, + "path": str(path), + }) + return results + + +def trash_photo(photo_id: str) -> Path: + """Move one current-user photo to private recoverable trash.""" + if not isinstance(photo_id, str) or Path(photo_id).name != photo_id: + raise ValueError("photo_id must be a filename from the photo list") + directory = owner_directory() + source = directory / photo_id + if source.suffix.lower() != ".jpg" or not source.is_file() or source.is_symlink(): + raise FileNotFoundError(f"photo not found: {photo_id}") + trash = directory / ".trash" + trash.mkdir(mode=0o700, exist_ok=True) + trash.chmod(0o700) + target = trash / source.name + if target.exists(): + raise FileExistsError(f"photo is already in trash: {photo_id}") + source.rename(target) + target.chmod(0o600) + return target diff --git a/tools/lumabot/power.py b/tools/lumabot/power.py new file mode 100644 index 0000000..8b69ac7 --- /dev/null +++ b/tools/lumabot/power.py @@ -0,0 +1,89 @@ +"""Explicit, approval-gated Raspberry Pi power controls for LumaBot.""" + +from __future__ import annotations + +import subprocess + +from tools.lumabot.motion import SCHEDULER + + +POWER_DELAY_S = 15 +SYSTEMD_RUN = "/usr/bin/systemd-run" +SYSTEMCTL = "/usr/bin/systemctl" +SUDO = "/usr/bin/sudo" + + +def _command(action: str) -> list[str]: + if action not in {"reboot", "poweroff"}: + raise ValueError("unsupported power action") + return [ + SUDO, + "-n", + SYSTEMD_RUN, + "--quiet", + "--collect", + f"--unit=lumabot-{action}", + f"--on-active={POWER_DELAY_S}s", + SYSTEMCTL, + action, + ] + + +def _schedule(action: str, inputs: dict) -> dict: + reason = str(inputs.get("reason", "")).strip() + if not reason: + raise ValueError("reason must not be empty") + if len(reason) > 500: + raise ValueError("reason must be 500 characters or fewer") + + motor_stop = SCHEDULER.stop() + result = subprocess.run( + _command(action), + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode != 0: + error = result.stderr.strip() or result.stdout.strip() or "sudo command failed" + return {"error": f"Could not schedule Raspberry Pi {action}: {error}"} + return { + "scheduled": True, + "action": action, + "delay_s": POWER_DELAY_S, + "motors_stopped": not bool(motor_stop.get("error")), + "message": f"Raspberry Pi {action} scheduled in {POWER_DELAY_S} seconds.", + "response_guidance": "Immediately acknowledge the action in one short sentence.", + } + + +def _tool(action: str) -> dict: + label = "reboot" if action == "reboot" else "shut down and fully power off" + return { + "name": f"lumabot_{action}", + "description": ( + f"{label.capitalize()} the entire physical Raspberry Pi running LumaBot. " + "This is not a LumaKit process restart. Use only when the owner explicitly asks " + f"to {label} the robot. The action always requires approval, first stops the " + "motors, and is delayed briefly so you can acknowledge it." + ), + "inputSchema": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Why the owner requested this power action.", + } + }, + "required": ["reason"], + }, + "execute": lambda inputs: _schedule(action, inputs), + } + + +def get_lumabot_reboot_tool(): + return _tool("reboot") + + +def get_lumabot_poweroff_tool(): + return _tool("poweroff") diff --git a/tools/lumabot/remote.py b/tools/lumabot/remote.py new file mode 100644 index 0000000..aa93061 --- /dev/null +++ b/tools/lumabot/remote.py @@ -0,0 +1,146 @@ +"""Deterministic LumaBot remote control; no language model involved.""" + +from __future__ import annotations + +import shlex + +from tools.lumabot import client +from tools.lumabot.motion import SCHEDULER + + +TURN_AROUND_FULL_SPEED_S = 1.0 + +REMOTE_HELP = ( + "LumaBot Remote commands\n\n" + "/lumabot drive forward (keep moving)\n" + "/lumabot drive backward (keep moving)\n" + "/lumabot drive forward 2 0.3 (timed drive)\n" + "/lumabot turn left 1 0.3\n" + "/lumabot turn right 1 0.3\n" + "/lumabot turn around\n" + "/lumabot stop\n" + "/lumabot park\n" + "/lumabot status\n" + "/lumabot agent\n" + "/lumabot off\n\n" + "Forward and backward continue until STOP or another control. " + "Numbers are duration in seconds and speed from 0.1 to 1.0." +) + + +def _number(value, label: str) -> float: + if isinstance(value, bool): + raise ValueError(f"{label} must be a number") + try: + return float(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{label} must be a number") from error + + +def _motion_values(duration_s=1.0, speed=0.3) -> tuple[float, float]: + duration = _number(duration_s, "duration") + throttle = _number(speed, "speed") + if not 0.1 <= duration <= 30.0: + raise ValueError("duration must be between 0.1 and 30 seconds") + if not 0.1 <= throttle <= 1.0: + raise ValueError("speed must be between 0.1 and 1.0") + return duration, throttle + + +def _status_text(status: dict) -> str: + if status.get("error"): + return status["error"] + battery = status.get("battery_pct") + battery_text = f", battery {battery:.0f}%" if isinstance(battery, (int, float)) else "" + return ( + f"LumaBot is {status.get('mode', 'unknown')}{battery_text}. " + f"Motors {'ready' if status.get('motors_ready') else 'not ready'}." + ) + + +def execute_remote_action( + action: str, + *, + direction: str | None = None, + duration_s=1.0, + speed=0.3, + continuous: bool = False, +) -> dict: + """Execute one explicit remote action through the watchdog-safe scheduler.""" + if not isinstance(continuous, bool): + raise ValueError("continuous must be true or false") + action = str(action or "").lower() + if action == "status": + data = client.get_status() + return {"ok": not bool(data.get("error")), "text": _status_text(data), "data": data} + + if action in {"stop", "park"}: + data = SCHEDULER.stop() + if data.get("error"): + return {"ok": False, "text": data["error"], "data": data} + text = "Parked. Motors released." if action == "park" else "Stopped." + return {"ok": True, "text": text, "data": data} + + duration, throttle = _motion_values(duration_s, speed) + if action == "turn_around": + direction = "left" + duration = min(5.0, TURN_AROUND_FULL_SPEED_S / throttle) + elif action == "turn": + if direction not in {"left", "right"}: + raise ValueError("turn direction must be left or right") + elif action == "drive": + if direction not in {"forward", "backward"}: + raise ValueError("drive direction must be forward or backward") + else: + raise ValueError("unknown LumaBot remote action") + + if action == "drive" and continuous: + data = SCHEDULER.start_continuous(direction, throttle) + else: + data = SCHEDULER.start(direction, throttle, duration) + if data.get("error"): + return {"ok": False, "text": data["error"], "data": data} + if action == "drive" and continuous: + label = f"{direction.capitalize()} latched. Press STOP or choose another control." + else: + label = "Turning around." if action == "turn_around" else f"Moving {direction}." + return {"ok": True, "text": label, "data": data} + + +def execute_remote_command(arguments: str) -> dict: + """Parse the documented slash-command grammar, never natural language.""" + try: + parts = shlex.split(arguments) + except ValueError as error: + return {"ok": False, "text": f"{error}\n\n{REMOTE_HELP}"} + if not parts or parts[0].lower() == "help": + return {"ok": True, "text": REMOTE_HELP} + + command = parts[0].lower() + try: + if command in {"status", "stop", "park"} and len(parts) == 1: + return execute_remote_action(command) + if command == "drive" and 2 <= len(parts) <= 4: + direction = parts[1].lower() + if direction == "reverse": + direction = "backward" + return execute_remote_action( + "drive", + direction=direction, + duration_s=parts[2] if len(parts) >= 3 else 1.0, + speed=parts[3] if len(parts) == 4 else 0.3, + continuous=len(parts) == 2, + ) + if command == "turn" and 2 <= len(parts) <= 4: + target = parts[1].lower() + if target == "around" and len(parts) == 2: + return execute_remote_action("turn_around") + return execute_remote_action( + "turn", + direction=target, + duration_s=parts[2] if len(parts) >= 3 else 1.0, + speed=parts[3] if len(parts) == 4 else 0.3, + ) + except ValueError as error: + return {"ok": False, "text": f"{error}\n\n{REMOTE_HELP}"} + return {"ok": False, "text": REMOTE_HELP} diff --git a/tools/lumabot/status.py b/tools/lumabot/status.py new file mode 100644 index 0000000..a58da67 --- /dev/null +++ b/tools/lumabot/status.py @@ -0,0 +1,20 @@ +"""Read-only LumaBot status tool.""" + +from tools.lumabot import client + + +def get_lumabot_status_tool(): + return { + "name": "lumabot_status", + "description": ( + "Read LumaBot's live distance freshness, autonomous mode and controller state, " + "motion/tilt/collision data, motor outputs and readiness, battery percentage and " + "voltage, camera availability, and daemon uptime. Use this for questions about " + "battery life, distance, autonomous driving, collisions, movement, or hardware " + "readiness. After reading status, answer in concise, human-friendly " + "language using only the fields relevant to the user's question; do not dump raw " + "JSON. Clearly say when a requested reading is unavailable." + ), + "inputSchema": {"type": "object", "properties": {}}, + "execute": lambda inputs: client.get_status(), + } diff --git a/web/css/style.css b/web/css/style.css index ba82fc4..4276a42 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -340,6 +340,131 @@ body { min-width: 0; } +.lumabot-mode-select { + height: 28px; + padding: 0 11px; + border: 1px solid rgba(176, 124, 216, 0.28); + border-radius: 999px; + background: rgba(26, 20, 40, 0.55); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + cursor: pointer; + outline: none; +} + +.lumabot-mode-select.agent { + color: #fff; + border-color: rgba(74, 222, 128, 0.65); + background: rgba(34, 197, 94, 0.18); +} + +.lumabot-mode-select.remote { + color: #fff; + border-color: rgba(251, 191, 36, 0.7); + background: rgba(245, 158, 11, 0.18); +} + +.lumabot-mode-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.lumabot-estop-btn { + height: 28px; + padding: 0 10px; + border: 1px solid rgba(248, 113, 113, 0.75); + border-radius: 999px; + background: rgba(220, 38, 38, 0.2); + color: #fecaca; + font-size: 10px; + font-weight: 800; + cursor: pointer; +} + +.lumabot-estop-btn:hover { + background: rgba(220, 38, 38, 0.4); + color: #fff; +} + +.lumabot-remote-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 18px; + width: min(760px, 100%); + margin: 0 auto 10px; + padding: 12px 16px; + border: 1px solid rgba(251, 191, 36, 0.35); + border-radius: 16px; + background: rgba(35, 27, 17, 0.92); +} + +.lumabot-remote-controls.hidden { + display: none; +} + +.remote-control-grid { + display: grid; + grid-template-columns: repeat(3, 42px); + grid-template-areas: + ". forward ." + "left stop right" + ". backward ."; + gap: 6px; +} + +.remote-control-grid button, +.remote-control-options button { + min-height: 36px; + border: 1px solid rgba(251, 191, 36, 0.35); + border-radius: 9px; + background: rgba(245, 158, 11, 0.12); + color: #fde68a; + font-weight: 700; + cursor: pointer; +} + +.remote-control-grid [data-direction="forward"] { grid-area: forward; } +.remote-control-grid [data-direction="left"] { grid-area: left; } +.remote-control-grid .remote-stop { grid-area: stop; } +.remote-control-grid [data-direction="right"] { grid-area: right; } +.remote-control-grid [data-direction="backward"] { grid-area: backward; } + +.remote-control-grid .remote-stop { + color: #fecaca; + border-color: rgba(248, 113, 113, 0.65); + background: rgba(220, 38, 38, 0.25); + font-size: 9px; +} + +.remote-control-options { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.remote-control-options label { + display: flex; + align-items: center; + gap: 5px; + color: var(--text-secondary); + font-size: 11px; +} + +.remote-control-options select { + height: 32px; + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 8px; + background: rgba(20, 16, 12, 0.8); + color: var(--text-primary); +} + +.remote-control-options button { + padding: 0 10px; +} + .workspace-form { display: flex; align-items: center; @@ -2926,6 +3051,21 @@ body { display: none; } + .lumabot-mode-select { + width: 82px; + padding: 0 5px; + } + + .lumabot-estop-btn { + padding: 0 7px; + } + + .lumabot-remote-controls { + flex-direction: column; + gap: 10px; + padding: 10px; + } + .message.user .bubble { max-width: 90%; } diff --git a/web/index.html b/web/index.html index a059eb3..4b76312 100644 --- a/web/index.html +++ b/web/index.html @@ -44,6 +44,14 @@ New Chat
+ +
@@ -75,6 +83,35 @@

Your local AI agent is ready.

+
diff --git a/web/js/app.js b/web/js/app.js index 390981b..f562877 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -22,6 +22,12 @@ const $modelBadge = document.getElementById('model-badge'); const $modelBadgeText = $modelBadge?.querySelector('.model-badge-text') || $modelBadge; const $statusLabel = document.getElementById('status-label'); const $statusDot = document.getElementById('status-dot'); +const $lumabotModeSelect = document.getElementById('lumabot-mode-select'); +const $lumabotEstopBtn = document.getElementById('lumabot-estop-btn'); +const $lumabotRemoteControls = document.getElementById('lumabot-remote-controls'); +const $lumabotDuration = document.getElementById('lumabot-duration'); +const $lumabotSpeed = document.getElementById('lumabot-speed'); +const $suggestionCards = document.getElementById('suggestion-cards'); const $workspaceForm = document.getElementById('workspace-form'); const $workspaceInput = document.getElementById('workspace-input'); const $workspaceBrowse = document.getElementById('workspace-browse'); @@ -50,6 +56,7 @@ let isWorking = false; let currentView = 'chat'; let currentChatId = null; let currentWorkspacePath = ''; +let lumabotMode = 'off'; let statusEl = null; let activityCardEl = null; let activityTitleEl = null; @@ -229,6 +236,7 @@ function setWorking(working) { if ($workspaceInput) $workspaceInput.disabled = working; if ($workspaceBrowse) $workspaceBrowse.disabled = working; if ($photoBtn) $photoBtn.disabled = working; + if ($lumabotModeSelect) $lumabotModeSelect.disabled = working; // Type /stop to interrupt — no UI toggle needed } @@ -249,6 +257,29 @@ function setWorkspace(path, displayPath) { } } +function setLumabotMode(mode) { + if (typeof mode === 'boolean') mode = mode ? 'agent' : 'off'; + lumabotMode = ['off', 'agent', 'remote'].includes(mode) ? mode : 'off'; + if ($lumabotModeSelect) { + $lumabotModeSelect.value = lumabotMode; + $lumabotModeSelect.classList.toggle('agent', lumabotMode === 'agent'); + $lumabotModeSelect.classList.toggle('remote', lumabotMode === 'remote'); + } + const remote = lumabotMode === 'remote'; + $lumabotRemoteControls?.classList.toggle('hidden', !remote); + $suggestionCards?.classList.toggle('hidden', remote); + $input.disabled = requiresModelSetup || remote; + $sendBtn.disabled = requiresModelSetup || remote; + if ($photoBtn) $photoBtn.disabled = requiresModelSetup || remote || isWorking; + if (!requiresModelSetup) { + $input.placeholder = remote + ? 'Remote mode uses the controls above — no LLM calls' + : lumabotMode === 'agent' + ? 'Tell LumaBot what to do...' + : 'Message Lumi... (type /stop to interrupt)'; + } +} + function showWorkspaceError(message) { if ($workspaceInput) { $workspaceInput.classList.add('error'); @@ -275,7 +306,7 @@ function applySetupState() { switchView('settings'); } } else { - $input.placeholder = 'Message Lumi... (type /stop to interrupt)'; + setLumabotMode(lumabotMode); $setupOverlay.classList.add('hidden'); } } @@ -2330,6 +2361,16 @@ const ws = new WS({ workspace_updated(data) { setWorkspace(data.workspace_path, data.workspace_display); + if (data.lumabot_mode != null) setLumabotMode(data.lumabot_mode); + }, + + lumabot_mode(data) { + setLumabotMode(data.mode); + if (data.text) showStatus(data.text); + }, + + lumabot_control(data) { + showStatus(data.text || (data.ok ? 'LumaBot command accepted.' : 'LumaBot command failed.')); }, workspace_error(data) { @@ -2349,6 +2390,7 @@ const ws = new WS({ }, chat_loaded(data) { + if (data.lumabot_mode != null) setLumabotMode(data.lumabot_mode); const previousChatId = currentChatId; if (data.chat_id === previousChatId && isWorking) { currentChatId = data.chat_id; @@ -2590,6 +2632,10 @@ function sendMessage() { switchView('settings'); return; } + if (lumabotMode === 'remote') { + showStatus('Use the LumaBot Remote controls above.'); + return; + } // Only reset the activity card when starting a fresh turn — if the agent // is still working, the user's new message is queued alongside the @@ -2609,6 +2655,30 @@ function sendMessage() { $sendBtn.onclick = sendMessage; +$lumabotModeSelect?.addEventListener('change', () => { + if (isWorking) return; + const requestedMode = $lumabotModeSelect.value; + $lumabotModeSelect.value = lumabotMode; + ws.send({ type: 'lumabot_mode', mode: requestedMode }); +}); + +$lumabotEstopBtn?.addEventListener('click', () => { + ws.send({ type: 'lumabot_control', action: 'stop' }); +}); + +$lumabotRemoteControls?.querySelectorAll('button[data-action]').forEach(button => { + button.addEventListener('click', () => { + ws.send({ + type: 'lumabot_control', + action: button.dataset.action, + direction: button.dataset.direction || null, + duration_s: Number($lumabotDuration?.value || 1), + speed: Number($lumabotSpeed?.value || 0.3), + continuous: button.dataset.action === 'drive', + }); + }); +}); + document.querySelectorAll('.suggestion-card').forEach(card => { card.addEventListener('click', () => { const prompt = card.getAttribute('data-prompt') || '';