From 7697857ee9946ca36e44695bfb06f9c0db40f7a1 Mon Sep 17 00:00:00 2001 From: Shri Date: Wed, 18 Feb 2026 14:32:35 -0800 Subject: [PATCH 1/3] Add optional Letta memory integration for cross-session recall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in session memory feature powered by Letta that records session lifecycle events (created, ended, errors, state changes) and lets users query past context via natural language. New files: - tame/integrations/letta/ — client wrapper, event bridge, agent prompt - tame/ui/widgets/memory_dialogs.py — enable, recall, and clear dialogs Integration points: - app.py: conditional bridge init, session lifecycle hooks, 3 new actions (toggle_memory, recall_memory, clear_memory) - command_palette.py: y/a/j keys for memory commands - status_bar.py: [Memory: On/Off] indicator - config/defaults.py: [letta] section (enabled, server_url) - pyproject.toml: optional "memory" extra (letta-client) The feature is completely inert when letta-client is not installed. When installed but not enabled, only a status bar indicator appears. --- pyproject.toml | 3 + tame/app.py | 175 +++++++++++++++++- tame/config/defaults.py | 4 + tame/integrations/__init__.py | 0 tame/integrations/letta/__init__.py | 5 + tame/integrations/letta/bridge.py | 139 ++++++++++++++ tame/integrations/letta/client.py | 91 +++++++++ tame/integrations/letta/prompts.py | 19 ++ tame/ui/widgets/__init__.py | 4 + tame/ui/widgets/command_palette.py | 3 + tame/ui/widgets/memory_dialogs.py | 275 ++++++++++++++++++++++++++++ tame/ui/widgets/status_bar.py | 12 +- tests/test_config_manager.py | 1 + 13 files changed, 729 insertions(+), 2 deletions(-) create mode 100644 tame/integrations/__init__.py create mode 100644 tame/integrations/letta/__init__.py create mode 100644 tame/integrations/letta/bridge.py create mode 100644 tame/integrations/letta/client.py create mode 100644 tame/integrations/letta/prompts.py create mode 100644 tame/ui/widgets/memory_dialogs.py diff --git a/pyproject.toml b/pyproject.toml index daf2062..d4df048 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,9 @@ audio = [ "pygame>=2.5.0", "simpleaudio>=1.0.4", ] +memory = [ + "letta-client>=0.1.0", +] [dependency-groups] dev = [ diff --git a/tame/app.py b/tame/app.py index 4d4afe4..7258008 100644 --- a/tame/app.py +++ b/tame/app.py @@ -6,7 +6,7 @@ import re import shutil import subprocess -from datetime import datetime +from datetime import datetime, timezone from textual import events from textual.app import App, ComposeResult @@ -39,6 +39,9 @@ GroupDialog, HeaderBar, HistoryPicker, + MemoryClearDialog, + MemoryEnableDialog, + MemoryRecallDialog, NameDialog, NotificationPanel, SearchDialog, @@ -146,6 +149,9 @@ class TAMEApp(App): "x": "clear_notifications", "w": "set_group", "v": "show_diff", + "y": "toggle_memory", + "a": "recall_memory", + "j": "clear_memory", "q": "quit", } @@ -287,6 +293,10 @@ def __init__( else self._default_working_dir ) + # Letta memory integration (optional) + self._memory_bridge = self._init_memory_bridge(cfg) + self._memory_ever_enabled = bool(cfg.get("letta", {}).get("enabled", False)) + self._active_session_id: str | None = None self._pending_status_updates: set[str] = set() self._status_update_scheduled: bool = False @@ -302,6 +312,33 @@ def __init__( # Easter egg: triggers once per app run self._easter_egg_shown: bool = False + @staticmethod + def _letta_available() -> bool: + """Check if the letta-client package is installed.""" + try: + import letta_client # noqa: F401 + + return True + except ImportError: + return False + + def _init_memory_bridge(self, cfg: dict): # noqa: ANN201 + """Conditionally create the memory bridge if letta-client is installed.""" + if not self._letta_available(): + return None + from tame.integrations.letta import MemoryBridge + + letta_cfg = cfg.get("letta", {}) + server_url = str(letta_cfg.get("server_url", "http://localhost:8283")) + bridge = MemoryBridge(server_url) + if letta_cfg.get("enabled", False): + ok, msg = bridge.enable() + if ok: + log.info("Letta memory bridge enabled: %s", msg) + else: + log.warning("Letta memory bridge failed to connect: %s", msg) + return bridge + def _get_patterns_from_config(self, cfg: dict) -> dict[str, list[str]]: patterns_cfg = cfg.get("patterns", {}) if not patterns_cfg: @@ -350,6 +387,7 @@ def on_mount(self) -> None: self.call_later(self._restore_tmux_sessions_async) self._start_resource_poll() self._start_tmux_health_check() + self._update_memory_status() log.info("TAME started") # ------------------------------------------------------------------ @@ -367,6 +405,19 @@ def _handle_status_change( self.post_message( SessionStatusChanged(session_id, old_state.value, new_state.value) ) + # Record to Letta memory + if self._memory_bridge: + try: + session = self._session_manager.get_session(session_id) + session_name = session.name + except KeyError: + session_name = session_id + if new_state == SessionState.ERROR: + self._memory_bridge.record_error(session_name, matched_text) + else: + self._memory_bridge.record_status_change( + session_name, old_state.value, new_state.value, matched_text + ) event_type = EVENT_TYPE_FOR_STATE.get(new_state) if event_type: session = self._session_manager.get_session(session_id) @@ -597,6 +648,8 @@ def _create_session(self, result) -> None: sidebar.add_session(session) self._select_session(session.id) self._update_status_bar() + if self._memory_bridge: + self._memory_bridge.record_session_created(session.name, working_dir) log.info("Created session %s (%s)", session.name, session.id) def action_toggle_sidebar(self) -> None: @@ -691,6 +744,19 @@ def _confirm_kill_session(self, confirmed: bool | None) -> None: except KeyError: pass + # Record session end to Letta memory + if self._memory_bridge: + try: + session = self._session_manager.get_session(session_id) + duration = ( + datetime.now(timezone.utc) - session.created_at + ).total_seconds() + self._memory_bridge.record_session_ended( + session.name, session.exit_code, duration + ) + except KeyError: + pass + try: self._session_manager.delete_session(session_id) except KeyError: @@ -945,6 +1011,113 @@ def action_show_diff(self) -> None: result = git_diff(session.working_dir) self.push_screen(DiffViewer(result, title=f"Diff: {session.name}")) + # ------------------------------------------------------------------ + # Letta memory actions + # ------------------------------------------------------------------ + + def action_toggle_memory(self) -> None: + """Toggle session memory on/off.""" + if not self._letta_available(): + self._show_toast( + "Memory", + "letta-client not installed. Run: pip install tame[memory]", + ) + return + if self._memory_bridge is None: + # First-time init (shouldn't normally happen if letta is installed) + cfg = self._config_manager.config + from tame.integrations.letta import MemoryBridge + + letta_cfg = cfg.get("letta", {}) + server_url = str(letta_cfg.get("server_url", "http://localhost:8283")) + self._memory_bridge = MemoryBridge(server_url) + + if not self._memory_ever_enabled: + # First time — show onboarding dialog + server_url = self._memory_bridge._server_url + self.push_screen( + MemoryEnableDialog(server_url), + callback=self._handle_memory_enable, + ) + else: + # Subsequent toggle — quick on/off + new_state, msg = self._memory_bridge.toggle() + self._show_toast("Memory", msg) + self._update_memory_status() + + def _handle_memory_enable(self, confirmed: bool | None) -> None: + if not confirmed or self._memory_bridge is None: + return + ok, msg = self._memory_bridge.enable() + self._show_toast("Memory", msg) + if ok: + self._memory_ever_enabled = True + # Persist enabled state to config + cfg = self._config_manager.config + cfg.setdefault("letta", {})["enabled"] = True + self._config_manager.save(cfg) + self._update_memory_status() + + def action_recall_memory(self) -> None: + """Open the memory recall dialog to query past session events.""" + if not self._letta_available() or self._memory_bridge is None: + self._show_toast( + "Memory", + "Memory not available. Toggle memory on first.", + ) + return + if not self._memory_bridge.is_connected: + self._show_toast( + "Memory", + "Memory not connected. Toggle memory on first.", + ) + return + self.push_screen(MemoryRecallDialog()) + + def action_clear_memory(self) -> None: + """Open confirmation dialog to clear all session memory.""" + if not self._letta_available() or self._memory_bridge is None: + self._show_toast("Memory", "Memory not available.") + return + if not self._memory_bridge.is_connected: + self._show_toast("Memory", "Memory not connected.") + return + self.push_screen( + MemoryClearDialog(), + callback=self._handle_memory_clear, + ) + + async def _handle_memory_clear(self, confirmed: bool | None) -> None: + if not confirmed or self._memory_bridge is None: + return + ok, msg = await self._memory_bridge.clear() + self._show_toast("Memory", msg) + + def _update_memory_status(self) -> None: + """Update the status bar memory indicator.""" + if self._memory_bridge is None: + status = "" + else: + raw = self._memory_bridge.status + if raw == "on": + status = "On" + elif raw == "err": + status = "\u26a0" + else: + status = "Off" + try: + bar = self.query_one(StatusBar) + bar.set_memory_status(status) + except Exception: + pass + + def _show_toast(self, title: str, message: str) -> None: + try: + toast = self.query_one(ToastOverlay) + toast.show_toast(title=title, message=message) + except Exception: + pass + def action_session_search(self) -> None: """Toggle the in-session search bar.""" if isinstance(self.screen, (NameDialog, ConfirmDialog, CommandPalette)): diff --git a/tame/config/defaults.py b/tame/config/defaults.py index 3880ac1..363a1f7 100644 --- a/tame/config/defaults.py +++ b/tame/config/defaults.py @@ -246,6 +246,10 @@ def get_default_patterns_flat() -> dict[str, list[str]]: "worktrees_enabled": False, "repo_dir": "", }, + "letta": { + "enabled": False, + "server_url": "http://localhost:8283", + }, "keybindings": { "new_session": "f2", "rename_session": "f9", diff --git a/tame/integrations/__init__.py b/tame/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tame/integrations/letta/__init__.py b/tame/integrations/letta/__init__.py new file mode 100644 index 0000000..875472e --- /dev/null +++ b/tame/integrations/letta/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .bridge import MemoryBridge + +__all__ = ["MemoryBridge"] diff --git a/tame/integrations/letta/bridge.py b/tame/integrations/letta/bridge.py new file mode 100644 index 0000000..6f4154a --- /dev/null +++ b/tame/integrations/letta/bridge.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +from .client import LettaClient + +log = logging.getLogger("tame.letta") + + +class MemoryBridge: + """Bridge between TAME session events and Letta's memory agent. + + All public methods are safe to call regardless of connection state — + they silently no-op when the bridge is disabled or disconnected. + """ + + def __init__(self, server_url: str = "http://localhost:8283") -> None: + self._client = LettaClient(server_url) + self._enabled = False + self._server_url = server_url + + @property + def enabled(self) -> bool: + return self._enabled + + @property + def is_connected(self) -> bool: + return self._client.is_connected + + @property + def status(self) -> str: + """Return a status string for the status bar.""" + if not self._enabled: + return "off" + if self._client.is_connected: + return "on" + return "err" + + def enable(self) -> tuple[bool, str]: + """Try to connect and enable the bridge. + + Returns (success, message). + """ + if self._client.connect(): + self._enabled = True + return True, "Memory enabled. Session events will be recorded." + return False, ( + f"Letta server not found at {self._server_url}. " + "Run 'letta server' in another terminal, then try again." + ) + + def disable(self) -> None: + """Pause recording (keeps connection alive for queries).""" + self._enabled = False + + def toggle(self) -> tuple[bool, str]: + """Toggle the bridge on/off. Returns (new_enabled_state, message).""" + if self._enabled: + self.disable() + return False, "Memory paused" + return self.enable() + + # ------------------------------------------------------------------ + # Event recording (called from app/session hooks) + # ------------------------------------------------------------------ + + def record_session_created(self, name: str, working_dir: str) -> None: + if not self._enabled or not self._client.is_connected: + return + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + self._send_async( + f"[EVENT] Session '{name}' created at {ts}. " + f"Working directory: {working_dir}" + ) + + def record_session_ended( + self, name: str, exit_code: int | None, duration_seconds: float + ) -> None: + if not self._enabled or not self._client.is_connected: + return + mins = duration_seconds / 60 + self._send_async( + f"[EVENT] Session '{name}' ended with exit code {exit_code} " + f"after {mins:.1f} minutes." + ) + + def record_error(self, name: str, error_text: str) -> None: + if not self._enabled or not self._client.is_connected: + return + self._send_async(f"[EVENT] Error in session '{name}': {error_text[:500]}") + + def record_status_change( + self, name: str, old_state: str, new_state: str, matched_text: str + ) -> None: + if not self._enabled or not self._client.is_connected: + return + msg = f"[EVENT] Session '{name}' changed from {old_state} to {new_state}." + if matched_text: + msg += f" Matched: {matched_text[:200]}" + self._send_async(msg) + + # ------------------------------------------------------------------ + # Querying (works even when recording is paused) + # ------------------------------------------------------------------ + + async def query(self, question: str) -> str: + """Ask the memory agent a question. Returns the response text.""" + if not self._client.is_connected: + return "Memory is not connected. Enable memory first." + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self._client.send_message, question) + + # ------------------------------------------------------------------ + # Memory management + # ------------------------------------------------------------------ + + async def clear(self) -> tuple[bool, str]: + """Clear all memory. Returns (success, message).""" + if not self._client.is_connected: + return False, "Memory is not connected." + loop = asyncio.get_running_loop() + success = await loop.run_in_executor(None, self._client.clear_memory) + if success: + return True, "All session memory cleared." + return False, "Failed to clear memory." + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _send_async(self, text: str) -> None: + """Fire-and-forget message send to Letta (non-blocking).""" + try: + loop = asyncio.get_running_loop() + loop.run_in_executor(None, self._client.send_message, text) + except RuntimeError: + pass diff --git a/tame/integrations/letta/client.py b/tame/integrations/letta/client.py new file mode 100644 index 0000000..b995ac2 --- /dev/null +++ b/tame/integrations/letta/client.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from letta_client import Letta + +from .prompts import AGENT_NAME, SYSTEM_PROMPT + +log = logging.getLogger("tame.letta") + + +class LettaClient: + """Thin wrapper around the Letta SDK for TAME's memory agent.""" + + def __init__(self, server_url: str = "http://localhost:8283") -> None: + self._server_url = server_url + self._client: Letta | None = None + self._agent_id: str | None = None + + def connect(self) -> bool: + """Attempt to connect to the Letta server and ensure the agent exists. + + Returns True on success, False on failure. + """ + try: + from letta_client import Letta + + self._client = Letta(base_url=self._server_url) + self._agent_id = self._get_or_create_agent() + return True + except Exception: + log.exception("Failed to connect to Letta server at %s", self._server_url) + self._client = None + self._agent_id = None + return False + + @property + def is_connected(self) -> bool: + return self._client is not None and self._agent_id is not None + + def _get_or_create_agent(self) -> str: + """Find existing tame-memory agent or create a new one.""" + assert self._client is not None + agents = self._client.agents.list() + for agent in agents: + if agent.name == AGENT_NAME: + log.info("Found existing Letta agent: %s", agent.id) + return agent.id + + agent = self._client.agents.create( + name=AGENT_NAME, + system=SYSTEM_PROMPT, + ) + log.info("Created Letta agent: %s", agent.id) + return agent.id + + def send_message(self, text: str) -> str: + """Send a message to the memory agent and return the response text.""" + if not self.is_connected: + return "" + assert self._client is not None and self._agent_id is not None + try: + response = self._client.agents.messages.create( + agent_id=self._agent_id, + messages=[{"role": "user", "content": text}], + ) + # Extract text from response messages + parts: list[str] = [] + for msg in response.messages: + if hasattr(msg, "content") and msg.content: + parts.append(msg.content) + return "\n".join(parts) if parts else "(no response)" + except Exception: + log.exception("Failed to send message to Letta agent") + return "(error communicating with Letta)" + + def clear_memory(self) -> bool: + """Delete and recreate the agent to clear all memory.""" + if not self.is_connected: + return False + assert self._client is not None and self._agent_id is not None + try: + self._client.agents.delete(agent_id=self._agent_id) + self._agent_id = self._get_or_create_agent() + log.info("Cleared Letta memory (agent recreated)") + return True + except Exception: + log.exception("Failed to clear Letta memory") + return False diff --git a/tame/integrations/letta/prompts.py b/tame/integrations/letta/prompts.py new file mode 100644 index 0000000..dca28be --- /dev/null +++ b/tame/integrations/letta/prompts.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +AGENT_NAME = "tame-memory" + +SYSTEM_PROMPT = """\ +You are TAME's memory assistant. You observe terminal sessions and remember \ +what happened across sessions. When asked, recall relevant past events — \ +errors, fixes, patterns, and context. Be concise. Reference specific sessions \ +by name when possible. + +You receive structured events about session lifecycle (created, ended, errors, \ +user commands). Store important details in your archival memory so you can \ +retrieve them later. + +When answering questions: +- Cite session names and approximate times when relevant. +- If you don't have information, say so clearly. +- Keep answers under 3 sentences unless more detail is requested. +""" diff --git a/tame/ui/widgets/__init__.py b/tame/ui/widgets/__init__.py index 4dc2aac..ae6ab2b 100644 --- a/tame/ui/widgets/__init__.py +++ b/tame/ui/widgets/__init__.py @@ -6,6 +6,7 @@ from .easter_egg import EasterEgg from .group_dialog import GroupDialog from .header_bar import HeaderBar +from .memory_dialogs import MemoryClearDialog, MemoryEnableDialog, MemoryRecallDialog from .history_picker import HistoryPicker from .name_dialog import NameDialog from .notification_panel import NotificationPanel @@ -24,6 +25,9 @@ "EasterEgg", "GroupDialog", "HeaderBar", + "MemoryClearDialog", + "MemoryEnableDialog", + "MemoryRecallDialog", "HistoryPicker", "NameDialog", "NotificationPanel", diff --git a/tame/ui/widgets/command_palette.py b/tame/ui/widgets/command_palette.py index 5bd0866..03be31f 100644 --- a/tame/ui/widgets/command_palette.py +++ b/tame/ui/widgets/command_palette.py @@ -27,6 +27,9 @@ ("x", "clear_notifications", "Clear Notifications"), ("w", "set_group", "Set Group"), ("v", "show_diff", "Git Diff"), + ("y", "toggle_memory", "Toggle Memory"), + ("a", "recall_memory", "Ask Memory"), + ("j", "clear_memory", "Clear Memory"), ("q", "quit", "Quit"), ] diff --git a/tame/ui/widgets/memory_dialogs.py b/tame/ui/widgets/memory_dialogs.py new file mode 100644 index 0000000..e3b4bc4 --- /dev/null +++ b/tame/ui/widgets/memory_dialogs.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +from textual import events, work +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Input, Label, Static + + +class MemoryEnableDialog(ModalScreen[bool]): + """First-time onboarding dialog for enabling session memory.""" + + DEFAULT_CSS = """ + MemoryEnableDialog { + align: center middle; + } + + MemoryEnableDialog #mem-enable-box { + width: 50; + height: auto; + padding: 1 2; + background: $surface; + border: thick $primary; + } + + MemoryEnableDialog .mem-title { + text-align: center; + text-style: bold; + margin-bottom: 1; + } + + MemoryEnableDialog .mem-desc { + margin-bottom: 1; + } + + MemoryEnableDialog .mem-server { + color: $text-muted; + margin-bottom: 1; + } + + MemoryEnableDialog #mem-enable-buttons { + margin-top: 1; + height: auto; + align: center middle; + } + + MemoryEnableDialog .mem-btn { + margin: 0 1; + } + """ + + def __init__(self, server_url: str) -> None: + super().__init__() + self._server_url = server_url + + def compose(self) -> ComposeResult: + with Vertical(id="mem-enable-box"): + yield Label("[bold]Enable Session Memory?[/bold]", classes="mem-title") + yield Label( + "TAME can remember what happens across your sessions " + "— errors, fixes, patterns.", + classes="mem-desc", + ) + yield Label( + "This uses Letta to store session events locally on your machine.", + classes="mem-desc", + ) + yield Label( + f"Server: {self._server_url}\n" + "(configure in ~/.config/tame/config.toml)", + classes="mem-server", + ) + from textual.containers import Horizontal + + with Horizontal(id="mem-enable-buttons"): + yield Button( + "Enable", id="mem-enable-yes", variant="success", classes="mem-btn" + ) + yield Button( + "Cancel", id="mem-enable-no", variant="primary", classes="mem-btn" + ) + + def on_mount(self) -> None: + self.query_one("#mem-enable-yes", Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.dismiss(event.button.id == "mem-enable-yes") + + def on_key(self, event: events.Key) -> None: + if event.key == "enter": + return # let button handle it + if event.key == "escape": + event.stop() + self.dismiss(False) + + +class MemoryRecallDialog(ModalScreen[None]): + """Dialog for querying session memory.""" + + DEFAULT_CSS = """ + MemoryRecallDialog { + align: center middle; + } + + MemoryRecallDialog #mem-recall-box { + width: 70; + height: auto; + max-height: 80%; + padding: 1 2; + background: $surface; + border: thick $primary; + } + + MemoryRecallDialog .mem-title { + text-align: center; + text-style: bold; + margin-bottom: 1; + } + + MemoryRecallDialog #mem-recall-input { + margin-bottom: 1; + } + + MemoryRecallDialog #mem-recall-response { + height: auto; + max-height: 20; + padding: 1; + background: $background; + overflow-y: auto; + } + + MemoryRecallDialog #mem-recall-footer { + margin-top: 1; + height: auto; + align: center middle; + } + + MemoryRecallDialog .mem-btn { + margin: 0 1; + } + """ + + def __init__(self) -> None: + super().__init__() + + def compose(self) -> ComposeResult: + with Vertical(id="mem-recall-box"): + yield Label("[bold]Ask Memory[/bold]", classes="mem-title") + yield Input( + placeholder="What fixed the timeout error?", id="mem-recall-input" + ) + yield Static("", id="mem-recall-response") + from textual.containers import Horizontal + + with Horizontal(id="mem-recall-footer"): + yield Button( + "Close", id="mem-recall-close", variant="primary", classes="mem-btn" + ) + + def on_mount(self) -> None: + self.query_one("#mem-recall-input", Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + question = event.value.strip() + if not question: + return + response_widget = self.query_one("#mem-recall-response", Static) + response_widget.update("[dim]Thinking...[/dim]") + self._do_query(question) + + @work(thread=True) + def _do_query(self, question: str) -> None: + """Run the Letta query in a background thread.""" + try: + from tame.integrations.letta import MemoryBridge + + bridge: MemoryBridge | None = getattr(self.app, "_memory_bridge", None) + if bridge is None: + self._show_response("Memory is not available.") + return + import asyncio + + loop = asyncio.new_event_loop() + try: + result = loop.run_until_complete(bridge.query(question)) + finally: + loop.close() + self._show_response(result) + except Exception as e: + self._show_response(f"Error: {e}") + + def _show_response(self, text: str) -> None: + self.app.call_from_thread(self._update_response, text) + + def _update_response(self, text: str) -> None: + response_widget = self.query_one("#mem-recall-response", Static) + response_widget.update(text) + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.dismiss(None) + + def on_key(self, event: events.Key) -> None: + if event.key == "escape": + event.stop() + self.dismiss(None) + + +class MemoryClearDialog(ModalScreen[bool]): + """Confirmation dialog for clearing all session memory.""" + + DEFAULT_CSS = """ + MemoryClearDialog { + align: center middle; + } + + MemoryClearDialog #mem-clear-box { + width: 50; + height: auto; + padding: 1 2; + background: $surface; + border: thick $primary; + } + + MemoryClearDialog .mem-title { + text-align: center; + text-style: bold; + margin-bottom: 1; + } + + MemoryClearDialog .mem-warn { + margin-bottom: 1; + } + + MemoryClearDialog #mem-clear-buttons { + margin-top: 1; + height: auto; + align: center middle; + } + + MemoryClearDialog .mem-btn { + margin: 0 1; + } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="mem-clear-box"): + yield Label("[bold]Clear Session Memory?[/bold]", classes="mem-title") + yield Label( + "Clear all session memory? This cannot be undone.", + classes="mem-warn", + ) + from textual.containers import Horizontal + + with Horizontal(id="mem-clear-buttons"): + yield Button( + "Clear", id="mem-clear-yes", variant="error", classes="mem-btn" + ) + yield Button( + "Cancel", id="mem-clear-no", variant="primary", classes="mem-btn" + ) + + def on_mount(self) -> None: + self.query_one("#mem-clear-no", Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.dismiss(event.button.id == "mem-clear-yes") + + def on_key(self, event: events.Key) -> None: + if event.key == "escape": + event.stop() + self.dismiss(False) diff --git a/tame/ui/widgets/status_bar.py b/tame/ui/widgets/status_bar.py index f4e3028..45cc567 100644 --- a/tame/ui/widgets/status_bar.py +++ b/tame/ui/widgets/status_bar.py @@ -23,6 +23,7 @@ def __init__(self) -> None: self._active: int = 0 self._waiting: int = 0 self._errors: int = 0 + self._memory_status: str = "" self._refresh_display() def update_stats(self, total: int, active: int, waiting: int, errors: int) -> None: @@ -33,6 +34,11 @@ def update_stats(self, total: int, active: int, waiting: int, errors: int) -> No self._errors = errors self._refresh_display() + def set_memory_status(self, status: str) -> None: + """Update the memory indicator. Empty string hides it.""" + self._memory_status = status + self._refresh_display() + def _refresh_display(self) -> None: """Re-render the status bar text.""" stats = ( @@ -43,4 +49,8 @@ def _refresh_display(self) -> None: "F2 New | F3/F4 \u2190\u2192 | F6 Sidebar | F7/F8 \u25b6/\u23f8" " | F9 Rename | C-SPC Cmd | F12 Quit" ) - self.update(f"{stats} {keys}") + parts = [stats] + if self._memory_status: + parts.append(f"[Memory: {self._memory_status}]") + parts.append(keys) + self.update(" ".join(parts)) diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 18301b9..5525346 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -17,6 +17,7 @@ def test_default_config_has_all_sections() -> None: "keybindings", "profiles", "git", + "letta", } assert expected == set(DEFAULT_CONFIG.keys()) From 46e53d85c63f804b62fe38f579340f9312934c30 Mon Sep 17 00:00:00 2001 From: Shri Date: Wed, 18 Feb 2026 14:44:48 -0800 Subject: [PATCH 2/3] Auto-setup: install letta-client and start server on first enable When user toggles memory on for the first time, TAME now handles the full setup automatically: 1. Installs letta-client via uv if not present 2. Starts letta server as a background subprocess 3. Waits for server readiness (health endpoint polling) 4. Connects and creates the memory agent On subsequent TAME launches, auto-connects to the running server. On TAME exit, the background server is cleaned up. The onboarding dialog adapts its text based on whether letta-client needs to be installed or is already present. --- tame/app.py | 63 ++++----- tame/integrations/letta/bridge.py | 204 ++++++++++++++++++++++++++++-- tame/ui/widgets/memory_dialogs.py | 21 ++- 3 files changed, 229 insertions(+), 59 deletions(-) diff --git a/tame/app.py b/tame/app.py index 7258008..aeddae2 100644 --- a/tame/app.py +++ b/tame/app.py @@ -312,20 +312,8 @@ def __init__( # Easter egg: triggers once per app run self._easter_egg_shown: bool = False - @staticmethod - def _letta_available() -> bool: - """Check if the letta-client package is installed.""" - try: - import letta_client # noqa: F401 - - return True - except ImportError: - return False - def _init_memory_bridge(self, cfg: dict): # noqa: ANN201 - """Conditionally create the memory bridge if letta-client is installed.""" - if not self._letta_available(): - return None + """Create the memory bridge. Auto-connects if previously enabled.""" from tame.integrations.letta import MemoryBridge letta_cfg = cfg.get("letta", {}) @@ -1017,26 +1005,19 @@ def action_show_diff(self) -> None: def action_toggle_memory(self) -> None: """Toggle session memory on/off.""" - if not self._letta_available(): - self._show_toast( - "Memory", - "letta-client not installed. Run: pip install tame[memory]", - ) - return if self._memory_bridge is None: - # First-time init (shouldn't normally happen if letta is installed) - cfg = self._config_manager.config - from tame.integrations.letta import MemoryBridge - - letta_cfg = cfg.get("letta", {}) - server_url = str(letta_cfg.get("server_url", "http://localhost:8283")) - self._memory_bridge = MemoryBridge(server_url) + return if not self._memory_ever_enabled: # First time — show onboarding dialog - server_url = self._memory_bridge._server_url + from tame.integrations.letta.bridge import _is_letta_installed + + needs_install = not _is_letta_installed() self.push_screen( - MemoryEnableDialog(server_url), + MemoryEnableDialog( + self._memory_bridge._server_url, + needs_install=needs_install, + ), callback=self._handle_memory_enable, ) else: @@ -1048,11 +1029,18 @@ def action_toggle_memory(self) -> None: def _handle_memory_enable(self, confirmed: bool | None) -> None: if not confirmed or self._memory_bridge is None: return - ok, msg = self._memory_bridge.enable() + self._show_toast("Memory", "Setting up memory...") + self._update_memory_status() + self._run_memory_setup() + + async def _run_memory_setup(self) -> None: + """Run the full setup (install + server + connect) in an executor.""" + assert self._memory_bridge is not None + loop = asyncio.get_running_loop() + ok, msg = await loop.run_in_executor(None, self._memory_bridge.setup) self._show_toast("Memory", msg) if ok: self._memory_ever_enabled = True - # Persist enabled state to config cfg = self._config_manager.config cfg.setdefault("letta", {})["enabled"] = True self._config_manager.save(cfg) @@ -1060,13 +1048,7 @@ def _handle_memory_enable(self, confirmed: bool | None) -> None: def action_recall_memory(self) -> None: """Open the memory recall dialog to query past session events.""" - if not self._letta_available() or self._memory_bridge is None: - self._show_toast( - "Memory", - "Memory not available. Toggle memory on first.", - ) - return - if not self._memory_bridge.is_connected: + if self._memory_bridge is None or not self._memory_bridge.is_connected: self._show_toast( "Memory", "Memory not connected. Toggle memory on first.", @@ -1076,10 +1058,7 @@ def action_recall_memory(self) -> None: def action_clear_memory(self) -> None: """Open confirmation dialog to clear all session memory.""" - if not self._letta_available() or self._memory_bridge is None: - self._show_toast("Memory", "Memory not available.") - return - if not self._memory_bridge.is_connected: + if self._memory_bridge is None or not self._memory_bridge.is_connected: self._show_toast("Memory", "Memory not connected.") return self.push_screen( @@ -1725,3 +1704,5 @@ def _tmux_session_alive(tmux_name: str) -> bool: def on_unmount(self) -> None: self._session_manager.close_all() + if self._memory_bridge is not None: + self._memory_bridge.stop_server() diff --git a/tame/integrations/letta/bridge.py b/tame/integrations/letta/bridge.py index 6f4154a..7db8d55 100644 --- a/tame/integrations/letta/bridge.py +++ b/tame/integrations/letta/bridge.py @@ -1,13 +1,100 @@ from __future__ import annotations import asyncio +import importlib import logging +import shutil +import subprocess +import sys +import time from datetime import datetime, timezone -from .client import LettaClient - log = logging.getLogger("tame.letta") +# Maximum seconds to wait for the Letta server to become ready. +_SERVER_STARTUP_TIMEOUT = 30 + + +def _is_letta_installed() -> bool: + """Check if letta-client is importable.""" + try: + importlib.import_module("letta_client") + return True + except ImportError: + return False + + +def _install_letta() -> tuple[bool, str]: + """Install letta-client via uv into the current environment. + + Returns (success, message). + """ + uv = shutil.which("uv") + if uv is None: + return False, "uv not found. Run: uv pip install letta-client" + try: + proc = subprocess.run( + [uv, "pip", "install", "letta-client"], + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + stderr = proc.stderr.strip() + log.warning("Failed to install letta-client: %s", stderr) + return False, f"Install failed: {stderr[:200]}" + # Force Python to re-scan site-packages so the new package is importable. + importlib.invalidate_caches() + return True, "letta-client installed." + except Exception as exc: + log.exception("Error installing letta-client") + return False, f"Install error: {exc}" + + +def _start_letta_server() -> subprocess.Popen | None: + """Start ``letta server`` as a background process. + + Returns the Popen handle, or None on failure. + """ + letta_bin = shutil.which("letta") + if letta_bin is None: + # After a fresh install the PATH entry for the venv Scripts/bin dir + # is already present — but ``shutil.which`` may not find a brand-new + # entry. Fall back to invoking via ``python -m letta``. + letta_bin = None + + try: + if letta_bin: + cmd = [letta_bin, "server"] + else: + cmd = [sys.executable, "-m", "letta", "server"] + proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return proc + except Exception: + log.exception("Failed to start letta server") + return None + + +def _wait_for_server(url: str, timeout: float = _SERVER_STARTUP_TIMEOUT) -> bool: + """Poll the Letta server health endpoint until it responds.""" + import urllib.request + import urllib.error + + health_url = f"{url}/v1/health" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + req = urllib.request.Request(health_url, method="GET") + with urllib.request.urlopen(req, timeout=2): + return True + except Exception: + time.sleep(0.5) + return False + class MemoryBridge: """Bridge between TAME session events and Letta's memory agent. @@ -17,9 +104,16 @@ class MemoryBridge: """ def __init__(self, server_url: str = "http://localhost:8283") -> None: - self._client = LettaClient(server_url) + self._client = None # LettaClient, created after install self._enabled = False self._server_url = server_url + self._server_proc: subprocess.Popen | None = None + + # Eagerly create client if letta-client is already installed. + if _is_letta_installed(): + from .client import LettaClient + + self._client = LettaClient(server_url) @property def enabled(self) -> bool: @@ -27,22 +121,88 @@ def enabled(self) -> bool: @property def is_connected(self) -> bool: - return self._client.is_connected + return self._client is not None and self._client.is_connected @property def status(self) -> str: """Return a status string for the status bar.""" if not self._enabled: return "off" - if self._client.is_connected: + if self.is_connected: return "on" return "err" + # ------------------------------------------------------------------ + # Full auto-setup: install → start server → connect + # ------------------------------------------------------------------ + + def setup(self) -> tuple[bool, str]: + """Install letta-client if needed, start the server, and connect. + + This is a blocking call meant to be run in an executor. + Returns (success, message). + """ + # Step 1: install if missing + if not _is_letta_installed(): + log.info("Installing letta-client...") + ok, msg = _install_letta() + if not ok: + return False, msg + # Verify import works after install + if not _is_letta_installed(): + return False, ( + "letta-client was installed but cannot be imported. " + "Restart TAME and try again." + ) + + # Step 2: create client if we haven't yet + if self._client is None: + from .client import LettaClient + + self._client = LettaClient(self._server_url) + + # Step 3: try connecting (server may already be running) + if self._client.connect(): + self._enabled = True + return True, "Memory enabled." + + # Step 4: server not running — start it + log.info("Starting Letta server...") + self._server_proc = _start_letta_server() + if self._server_proc is None: + return False, "Failed to start Letta server." + + # Step 5: wait for server to be ready + if not _wait_for_server(self._server_url): + self.stop_server() + return False, ( + "Letta server started but did not become ready " + f"within {_SERVER_STARTUP_TIMEOUT}s." + ) + + # Step 6: connect + if self._client.connect(): + self._enabled = True + return True, "Memory enabled." + + return False, "Server is running but connection failed." + + # ------------------------------------------------------------------ + # Simple enable/disable (assumes letta-client is already installed) + # ------------------------------------------------------------------ + def enable(self) -> tuple[bool, str]: """Try to connect and enable the bridge. Returns (success, message). """ + if self._client is None: + if not _is_letta_installed(): + return False, "letta-client is not installed." + from .client import LettaClient + + self._client = LettaClient(self._server_url) + if self._client.connect(): self._enabled = True return True, "Memory enabled. Session events will be recorded." @@ -62,12 +222,30 @@ def toggle(self) -> tuple[bool, str]: return False, "Memory paused" return self.enable() + # ------------------------------------------------------------------ + # Server lifecycle + # ------------------------------------------------------------------ + + def stop_server(self) -> None: + """Stop the Letta server if we started it.""" + if self._server_proc is not None: + try: + self._server_proc.terminate() + self._server_proc.wait(timeout=5) + except Exception: + try: + self._server_proc.kill() + except Exception: + pass + self._server_proc = None + log.info("Letta server stopped.") + # ------------------------------------------------------------------ # Event recording (called from app/session hooks) # ------------------------------------------------------------------ def record_session_created(self, name: str, working_dir: str) -> None: - if not self._enabled or not self._client.is_connected: + if not self._enabled or not self.is_connected: return ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") self._send_async( @@ -78,7 +256,7 @@ def record_session_created(self, name: str, working_dir: str) -> None: def record_session_ended( self, name: str, exit_code: int | None, duration_seconds: float ) -> None: - if not self._enabled or not self._client.is_connected: + if not self._enabled or not self.is_connected: return mins = duration_seconds / 60 self._send_async( @@ -87,14 +265,14 @@ def record_session_ended( ) def record_error(self, name: str, error_text: str) -> None: - if not self._enabled or not self._client.is_connected: + if not self._enabled or not self.is_connected: return self._send_async(f"[EVENT] Error in session '{name}': {error_text[:500]}") def record_status_change( self, name: str, old_state: str, new_state: str, matched_text: str ) -> None: - if not self._enabled or not self._client.is_connected: + if not self._enabled or not self.is_connected: return msg = f"[EVENT] Session '{name}' changed from {old_state} to {new_state}." if matched_text: @@ -107,8 +285,9 @@ def record_status_change( async def query(self, question: str) -> str: """Ask the memory agent a question. Returns the response text.""" - if not self._client.is_connected: + if not self.is_connected: return "Memory is not connected. Enable memory first." + assert self._client is not None loop = asyncio.get_running_loop() return await loop.run_in_executor(None, self._client.send_message, question) @@ -118,8 +297,9 @@ async def query(self, question: str) -> str: async def clear(self) -> tuple[bool, str]: """Clear all memory. Returns (success, message).""" - if not self._client.is_connected: + if not self.is_connected: return False, "Memory is not connected." + assert self._client is not None loop = asyncio.get_running_loop() success = await loop.run_in_executor(None, self._client.clear_memory) if success: @@ -132,6 +312,8 @@ async def clear(self) -> tuple[bool, str]: def _send_async(self, text: str) -> None: """Fire-and-forget message send to Letta (non-blocking).""" + if self._client is None: + return try: loop = asyncio.get_running_loop() loop.run_in_executor(None, self._client.send_message, text) diff --git a/tame/ui/widgets/memory_dialogs.py b/tame/ui/widgets/memory_dialogs.py index e3b4bc4..6e9b32f 100644 --- a/tame/ui/widgets/memory_dialogs.py +++ b/tame/ui/widgets/memory_dialogs.py @@ -49,9 +49,10 @@ class MemoryEnableDialog(ModalScreen[bool]): } """ - def __init__(self, server_url: str) -> None: + def __init__(self, server_url: str, needs_install: bool = False) -> None: super().__init__() self._server_url = server_url + self._needs_install = needs_install def compose(self) -> ComposeResult: with Vertical(id="mem-enable-box"): @@ -61,13 +62,19 @@ def compose(self) -> ComposeResult: "— errors, fixes, patterns.", classes="mem-desc", ) + if self._needs_install: + yield Label( + "This will install letta-client and start a local " + "memory server automatically.", + classes="mem-desc", + ) + else: + yield Label( + "This uses Letta to store session events locally on your machine.", + classes="mem-desc", + ) yield Label( - "This uses Letta to store session events locally on your machine.", - classes="mem-desc", - ) - yield Label( - f"Server: {self._server_url}\n" - "(configure in ~/.config/tame/config.toml)", + f"Server: {self._server_url}", classes="mem-server", ) from textual.containers import Horizontal From a56941ed630308d3e64dcf6b6d715ed20fb7f0b5 Mon Sep 17 00:00:00 2001 From: Shri Date: Wed, 18 Feb 2026 15:17:55 -0800 Subject: [PATCH 3/3] Fix unused coroutine: schedule async setup via call_later mypy caught that _run_memory_setup() (async) was called without await from the sync callback _handle_memory_enable. Use self.call_later() to schedule it on the Textual event loop instead. --- tame/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tame/app.py b/tame/app.py index aeddae2..4b2cfcb 100644 --- a/tame/app.py +++ b/tame/app.py @@ -1031,7 +1031,7 @@ def _handle_memory_enable(self, confirmed: bool | None) -> None: return self._show_toast("Memory", "Setting up memory...") self._update_memory_status() - self._run_memory_setup() + self.call_later(self._run_memory_setup) async def _run_memory_setup(self) -> None: """Run the full setup (install + server + connect) in an executor."""