From 1c9dd632d9a478e3c241c6bb672509f10532e029 Mon Sep 17 00:00:00 2001 From: Chris Favre Date: Sun, 24 May 2026 20:32:28 -0500 Subject: [PATCH] feat: Add agent lessons log and niagara agent playbook for incident learnings - Introduced `agent-lessons.md` to document incident learnings and corrective actions. - Created `niagara-agent-playbook.md` to provide command patterns and payload templates for nMCP workflow. - Enhanced `agent.py` with validation for wiresheet payloads and improved error handling for path discovery. - Implemented UI components for about, help, and plan review dialogs to improve user experience. - Added toggles for Plan Mode and Strict Paths in the chat widget to enhance user control over agent behavior. --- README.md | 29 ++ config.py | 18 + scripts/build_windows.ps1 | 47 ++ src/agent.py | 95 +++- src/memory/__init__.py | 6 + src/memory/manager.py | 814 +++++++++++++++++++++++++++++++++ src/ui/app.py | 160 +++++++ src/ui/chat_widget.py | 57 +++ src/ui/memory_health_widget.py | 105 +++++ 9 files changed, 1325 insertions(+), 6 deletions(-) create mode 100644 scripts/build_windows.ps1 create mode 100644 src/memory/__init__.py create mode 100644 src/memory/manager.py create mode 100644 src/ui/memory_health_widget.py diff --git a/README.md b/README.md index ad5ba8d..c83ef4a 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,35 @@ Recommended maintenance workflow: --- +## Packaging SQLite For Executables + +For novice-friendly installs, keep SQLite writable and outside the executable bundle: + +* Runtime DB path is under per-user app data (for example `%APPDATA%/nMCP-client/memory/memory.sqlite` on Windows). +* On first run, the app bootstraps schema automatically. +* If a bundled seed DB exists at `assets/memory_seed.sqlite`, it is copied to the writable runtime path before schema checks. + +This design works for both one-file and one-folder builds and avoids write failures inside bundled executables. + +### Windows Build Script + +Use `scripts/build_windows.ps1`: + +```powershell +# One-folder build (recommended for field deployments) +powershell -ExecutionPolicy Bypass -File scripts/build_windows.ps1 -Mode onedir + +# One-file build +powershell -ExecutionPolicy Bypass -File scripts/build_windows.ps1 -Mode onefile +``` + +The script automatically includes: + +* `.private/Candy` memory guidance docs +* `assets/memory_seed.sqlite` when present + +--- + ## License MIT diff --git a/config.py b/config.py index cc37bf8..82de538 100644 --- a/config.py +++ b/config.py @@ -41,6 +41,16 @@ def _default_config_path() -> Path: CONFIG_PATH = _default_config_path() +def _default_memory_root() -> str: + """Return the default local memory root directory.""" + return str(CONFIG_PATH.parent / "memory") + + +def _default_candy_docs_dir() -> str: + """Return the default Candy docs directory in the repository.""" + return str((Path(__file__).resolve().parent / ".private" / "Candy").resolve()) + + class ConnectionConfig(BaseModel): mcp_url: str = Field( default_factory=lambda: os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp") @@ -68,9 +78,17 @@ class LLMConfig(BaseModel): base_url: str = "" # optional override (e.g. local proxy) +class MemoryConfig(BaseModel): + enabled: bool = True + prompt_token_budget: int = 1400 + memory_root: str = Field(default_factory=_default_memory_root) + candy_docs_dir: str = Field(default_factory=_default_candy_docs_dir) + + class AppConfig(BaseModel): connection: ConnectionConfig = Field(default_factory=ConnectionConfig) llm: LLMConfig = Field(default_factory=LLMConfig) + memory: MemoryConfig = Field(default_factory=MemoryConfig) def load_config() -> AppConfig: diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 new file mode 100644 index 0000000..1a63e53 --- /dev/null +++ b/scripts/build_windows.ps1 @@ -0,0 +1,47 @@ +param( + [ValidateSet("onedir", "onefile")] + [string]$Mode = "onedir", + + [string]$AppName = "nMCP-client" +) + +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +$python = Join-Path $root ".venv\Scripts\python.exe" +if (-not (Test-Path $python)) { + $python = "python" +} + +$distPath = if ($Mode -eq "onefile") { "dist_onefile" } else { "dist_release" } +$workPath = if ($Mode -eq "onefile") { "build_onefile" } else { "build_release" } + +$commonArgs = @( + "-m", "PyInstaller", + "--noconfirm", + "--windowed", + "--name", $AppName, + "--distpath", $distPath, + "--workpath", $workPath, + "--add-data", ".private/Candy;.private/Candy" +) + +# Optional seed database for memory bootstrap. +if (Test-Path "assets\memory_seed.sqlite") { + $commonArgs += @("--add-data", "assets/memory_seed.sqlite;assets") +} + +if ($Mode -eq "onefile") { + $commonArgs += "--onefile" +} + +$commonArgs += "main.py" + +Write-Host "Building $AppName ($Mode)..." +Write-Host "$python $($commonArgs -join ' ')" + +& $python @commonArgs + +Write-Host "Build complete. Output folder: $distPath" diff --git a/src/agent.py b/src/agent.py index 1402711..a18d0fa 100644 --- a/src/agent.py +++ b/src/agent.py @@ -10,7 +10,8 @@ import asyncio import json import logging -from typing import Any +import re +from typing import Any, Callable from PySide6.QtCore import QObject, Signal @@ -65,6 +66,8 @@ ) _MAX_ITERATIONS = 20 # safety cap to prevent infinite loops +_LLM_RATE_LIMIT_MAX_RETRIES = 3 +_MAX_TOOL_RESULT_CHARS = 8000 _WIRESHEET_OPERATION_TYPES = { "createComponent", "setSlot", @@ -99,12 +102,16 @@ def __init__( planning_mode: bool = False, writes_permitted: bool = True, strict_paths: bool = True, + memory_context: str = "", + tool_observer: Callable[[str, dict[str, Any], str], None] | None = None, ) -> None: self._mcp = mcp_client self._llm = llm_provider self._planning_mode = planning_mode self._writes_permitted = writes_permitted self._strict_paths = strict_paths + self._memory_context = memory_context.strip() + self._tool_observer = tool_observer self.signals = AgentSignals() self._loop: asyncio.AbstractEventLoop | None = None @@ -132,6 +139,8 @@ async def run(self, user_message: str, tools: list[Any]) -> None: """Execute one user request end-to-end.""" self._loop = asyncio.get_event_loop() system_prompt = _SYSTEM_PROMPT + if self._memory_context: + system_prompt += "\n\n" + self._memory_context if self._planning_mode: system_prompt += ( " You are currently in PLAN MODE. Do not execute tools. " @@ -156,11 +165,35 @@ async def run(self, user_message: str, tools: list[Any]) -> None: self.signals.status_changed.emit("Thinking…") logger.debug("Agent iteration %d", iteration + 1) - try: - response = await self._llm.get_response(tools) - except Exception as exc: - self.signals.error_occurred.emit(f"LLM error: {exc}") - logger.exception("LLM error on iteration %d", iteration + 1) + response = None + for attempt in range(_LLM_RATE_LIMIT_MAX_RETRIES + 1): + try: + response = await self._llm.get_response(tools) + break + except Exception as exc: + wait_seconds = _parse_rate_limit_wait_seconds(str(exc)) + is_last_attempt = attempt >= _LLM_RATE_LIMIT_MAX_RETRIES + if wait_seconds is None or is_last_attempt: + self.signals.error_occurred.emit(f"LLM error: {exc}") + logger.exception("LLM error on iteration %d", iteration + 1) + return + + wait_seconds = min(max(wait_seconds, 0.5), 12.0) + self.signals.status_changed.emit( + f"Rate limited by provider; retrying in {wait_seconds:.1f}s…" + ) + logger.warning( + "Rate limited on iteration %d, attempt %d/%d. Retrying in %.2fs", + iteration + 1, + attempt + 1, + _LLM_RATE_LIMIT_MAX_RETRIES + 1, + wait_seconds, + ) + await asyncio.sleep(wait_seconds) + + if response is None: + self.signals.error_occurred.emit("LLM error: no response returned.") + self.signals.status_changed.emit("Ready") return # Emit any intermediate text alongside tool calls @@ -220,12 +253,23 @@ async def _execute_tool(self, tc: ToolCall) -> str: raw_result = await self._mcp.call_tool(tc.name, tc.arguments) result_text = _format_tool_result(raw_result) result_text = _augment_path_error(result_text, tc.arguments) + result_text = _balance_tool_result_text(result_text) + if self._tool_observer: + try: + self._tool_observer(tc.name, tc.arguments, result_text) + except Exception as obs_exc: + logger.warning("Tool observer failed for %s: %s", tc.name, obs_exc) preview = result_text[:300] + ("…" if len(result_text) > 300 else "") self.signals.tool_executed.emit(tc.name, preview) logger.info("Tool %s → %s", tc.name, result_text[:500]) return result_text except Exception as exc: error = f"Tool execution error: {exc}" + if self._tool_observer: + try: + self._tool_observer(tc.name, tc.arguments, error) + except Exception as obs_exc: + logger.warning("Tool observer failed for %s: %s", tc.name, obs_exc) self.signals.status_changed.emit("Ready") self.signals.error_occurred.emit(error) logger.exception("Tool %s failed", tc.name) @@ -266,6 +310,45 @@ def _format_tool_result(result: Any) -> str: "station:|slot:/", ] +_RATE_LIMIT_WAIT_PATTERN = re.compile(r"try again in\s+([0-9]+(?:\.[0-9]+)?)s", re.IGNORECASE) + + +def _parse_rate_limit_wait_seconds(error_text: str) -> float | None: + """Extract provider-suggested retry delay (seconds) from a rate-limit message.""" + lowered = error_text.lower() + if "rate limit" not in lowered and "429" not in lowered: + return None + + match = _RATE_LIMIT_WAIT_PATTERN.search(error_text) + if not match: + return 2.0 + + try: + return float(match.group(1)) + except ValueError: + return 2.0 + + +def _balance_tool_result_text(result_text: str) -> str: + """Cap tool result size before feeding it back to the LLM to reduce TPM spikes.""" + if len(result_text) <= _MAX_TOOL_RESULT_CHARS: + return result_text + + head_len = 5000 + tail_len = 2200 + omitted = len(result_text) - head_len - tail_len + if omitted < 0: + omitted = len(result_text) - _MAX_TOOL_RESULT_CHARS + + head = result_text[:head_len].rstrip() + tail = result_text[-tail_len:].lstrip() + return ( + f"{head}\n\n" + f"[TRUNCATED TOOL RESULT: omitted {omitted} characters to control token usage. " + f"If you need more detail, call the tool again with tighter filters/limits.]\n\n" + f"{tail}" + ) + def _augment_path_error(result_text: str, tool_args: dict[str, Any] | None = None) -> str: """Append a discovery hint when the server returns a path-not-allowlisted error.""" diff --git a/src/memory/__init__.py b/src/memory/__init__.py new file mode 100644 index 0000000..d02205e --- /dev/null +++ b/src/memory/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2026 Chris Favre. All rights reserved. +"""Memory package for local context and station profile persistence.""" + +from src.memory.manager import MemoryHealthSnapshot, MemoryManager + +__all__ = ["MemoryManager", "MemoryHealthSnapshot"] diff --git a/src/memory/manager.py b/src/memory/manager.py new file mode 100644 index 0000000..646ae33 --- /dev/null +++ b/src/memory/manager.py @@ -0,0 +1,814 @@ +# Copyright (c) 2026 Chris Favre. All rights reserved. +"""Local memory manager for prompt context and station profile persistence.""" + +from __future__ import annotations + +import json +import logging +import re +import shutil +import sqlite3 +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_CANDY_FILES = [ + "NMCP_OPERATING_MANUAL.md", + "HVAC_NIAGARA_PRIMER.md", + "LOCAL_CLOUD_MEMORY_SPLIT.md", +] + +_ORD_KEYS = ("ord", "root", "parentOrd", "componentOrd") +_SLOT_ENDPOINT_KEYS = ("from", "to") + + +@dataclass +class StationProfile: + station_key: str + station_name: str + endpoint_url: str + station_info_text: str + + +@dataclass +class MemoryHealthSnapshot: + db_path: str + exists: bool + size_bytes: int + station_profile_rows: int + episode_rows: int + tool_lesson_rows: int + latest_station_updated_at: str + latest_station_key: str + + +@dataclass +class ConversationThread: + conversation_id: str + station_key: str + title: str + created_at: str + updated_at: str + + +@dataclass +class ConversationMessage: + role: str + content: str + created_at: str + + +class MemoryManager: + """Build compact memory blocks and persist minimal local station profile state.""" + + def __init__( + self, + enabled: bool, + prompt_token_budget: int, + memory_root: Path, + candy_docs_dir: Path, + ) -> None: + self._enabled = enabled + self._prompt_token_budget = max(200, prompt_token_budget) + self._memory_root = memory_root + self._candy_docs_dir = candy_docs_dir + self._memory_root.mkdir(parents=True, exist_ok=True) + self._db_path = self._memory_root / "memory.sqlite" + self._cached_global_context: str = "" + if self._enabled: + self._ensure_db_initialized() + + @classmethod + def from_config(cls, app_config: Any) -> "MemoryManager": + """Create a MemoryManager from AppConfig without tight config coupling.""" + memory_cfg = getattr(app_config, "memory", None) + if memory_cfg is None: + # Backward compatibility for old config snapshots. + return cls( + enabled=True, + prompt_token_budget=1400, + memory_root=Path.home() / ".config" / "nMCP-client" / "memory", + candy_docs_dir=Path.cwd() / ".private" / "Candy", + ) + + return cls( + enabled=bool(memory_cfg.enabled), + prompt_token_budget=int(memory_cfg.prompt_token_budget), + memory_root=Path(memory_cfg.memory_root).expanduser(), + candy_docs_dir=Path(memory_cfg.candy_docs_dir).expanduser(), + ) + + @property + def enabled(self) -> bool: + return self._enabled + + @property + def db_path(self) -> Path: + return self._db_path + + def get_health_snapshot(self) -> MemoryHealthSnapshot: + """Return a lightweight health summary for UI diagnostics.""" + exists = self._db_path.exists() + size_bytes = self._db_path.stat().st_size if exists else 0 + + station_rows = 0 + episode_rows = 0 + lesson_rows = 0 + latest_updated = "" + latest_key = "" + + if exists: + try: + with sqlite3.connect(self._db_path) as conn: + station_rows = int( + conn.execute("SELECT COUNT(*) FROM station_profile").fetchone()[0] + ) + episode_rows = int( + conn.execute("SELECT COUNT(*) FROM episode").fetchone()[0] + ) + lesson_rows = int( + conn.execute("SELECT COUNT(*) FROM tool_lesson").fetchone()[0] + ) + latest = conn.execute( + """ + SELECT station_key, updated_at + FROM station_profile + ORDER BY updated_at DESC + LIMIT 1 + """ + ).fetchone() + if latest is not None: + latest_key = str(latest[0] or "") + latest_updated = str(latest[1] or "") + except Exception as exc: + logger.warning("Could not read memory health snapshot: %s", exc) + + return MemoryHealthSnapshot( + db_path=str(self._db_path), + exists=exists, + size_bytes=size_bytes, + station_profile_rows=station_rows, + episode_rows=episode_rows, + tool_lesson_rows=lesson_rows, + latest_station_updated_at=latest_updated, + latest_station_key=latest_key, + ) + + def create_conversation( + self, + station_name: str, + endpoint_url: str, + title: str | None = None, + ) -> ConversationThread: + """Create and return a conversation thread scoped to the current station.""" + station_key = self._build_station_key(station_name, endpoint_url) + now = datetime.now(timezone.utc).isoformat() + conversation_id = f"conv_{now.replace(':', '').replace('-', '').replace('.', '')}" + + final_title = (title or "").strip() + if not final_title: + final_title = f"Conversation {now[:19].replace('T', ' ')}" + + with sqlite3.connect(self._db_path) as conn: + conn.execute( + """ + INSERT INTO conversation ( + conversation_id, + station_key, + title, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?) + """, + (conversation_id, station_key, final_title, now, now), + ) + conn.commit() + + return ConversationThread( + conversation_id=conversation_id, + station_key=station_key, + title=final_title, + created_at=now, + updated_at=now, + ) + + def ensure_default_conversation( + self, + station_name: str, + endpoint_url: str, + ) -> ConversationThread: + """Return latest conversation for station or create one if none exists.""" + station_key = self._build_station_key(station_name, endpoint_url) + existing = self.list_conversations(station_name, endpoint_url, limit=1) + if existing: + return existing[0] + return self.create_conversation(station_name, endpoint_url, title="New Conversation") + + def list_conversations( + self, + station_name: str, + endpoint_url: str, + limit: int = 100, + ) -> list[ConversationThread]: + """List conversations for the current station ordered by recent activity.""" + station_key = self._build_station_key(station_name, endpoint_url) + with sqlite3.connect(self._db_path) as conn: + rows = conn.execute( + """ + SELECT conversation_id, station_key, title, created_at, updated_at + FROM conversation + WHERE station_key = ? + ORDER BY updated_at DESC + LIMIT ? + """, + (station_key, max(1, limit)), + ).fetchall() + + return [ + ConversationThread( + conversation_id=str(row[0]), + station_key=str(row[1]), + title=str(row[2]), + created_at=str(row[3]), + updated_at=str(row[4]), + ) + for row in rows + ] + + def append_conversation_message( + self, + conversation_id: str, + role: str, + content: str, + ) -> None: + """Persist one message and touch the parent conversation timestamp.""" + if not conversation_id or not content.strip(): + return + + now = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(self._db_path) as conn: + conn.execute( + """ + INSERT INTO conversation_message ( + conversation_id, + role, + content, + created_at + ) VALUES (?, ?, ?, ?) + """, + (conversation_id, role.strip() or "system", content.strip(), now), + ) + conn.execute( + """ + UPDATE conversation + SET updated_at = ? + WHERE conversation_id = ? + """, + (now, conversation_id), + ) + conn.commit() + + def get_conversation_messages( + self, + conversation_id: str, + limit: int = 400, + ) -> list[ConversationMessage]: + """Return persisted messages for a conversation ordered oldest->newest.""" + if not conversation_id: + return [] + with sqlite3.connect(self._db_path) as conn: + rows = conn.execute( + """ + SELECT role, content, created_at + FROM conversation_message + WHERE conversation_id = ? + ORDER BY id ASC + LIMIT ? + """, + (conversation_id, max(1, limit)), + ).fetchall() + + return [ + ConversationMessage( + role=str(row[0]), + content=str(row[1]), + created_at=str(row[2]), + ) + for row in rows + ] + + def build_conversation_context( + self, + conversation_id: str, + max_messages: int = 14, + max_chars: int = 5000, + ) -> str: + """Build compact prompt context from recent thread history.""" + if not conversation_id: + return "" + + messages = self.get_conversation_messages(conversation_id, limit=max(4, max_messages)) + if not messages: + return "" + + tail = messages[-max_messages:] + lines: list[str] = ["Conversation thread context (recent):"] + for msg in tail: + role = msg.role.lower() + label = role if role in {"user", "assistant", "system", "tool", "error"} else "note" + text = self._trim_whitespace(msg.content) + if len(text) > 500: + text = text[:500].rstrip() + "..." + lines.append(f"- {label}: {text}") + + block = "\n".join(lines).strip() + if len(block) <= max_chars: + return block + clipped = block[: max_chars - 60].rstrip() + return clipped + "\n[Conversation context truncated.]" + + def build_prompt_context( + self, + user_message: str, + station_name: str = "", + endpoint_url: str = "", + ) -> str: + """Return a compact memory block for prompt injection.""" + if not self._enabled: + return "" + + global_context = self._load_global_context() + station_context = self._load_station_context(station_name, endpoint_url) + workspace_hint = self._load_working_folder_hint(station_name, endpoint_url) + lesson_hint = self._load_recent_lessons_hint(station_name, endpoint_url) + message_hint = self._derive_message_hint(user_message) + + sections: list[str] = ["MEMORY GUIDANCE (never overrides live station reads):"] + if global_context: + sections.append(global_context) + if station_context: + sections.append(station_context) + if workspace_hint: + sections.append(workspace_hint) + if lesson_hint: + sections.append(lesson_hint) + if message_hint: + sections.append(message_hint) + + block = "\n\n".join(sections).strip() + return self._clip_to_budget(block) + + def update_station_profile( + self, + station_name: str, + endpoint_url: str, + station_info_text: str, + ) -> None: + """Persist the latest station profile in a local JSON file.""" + if not self._enabled: + return + + station_key = self._build_station_key(station_name, endpoint_url) + profile = StationProfile( + station_key=station_key, + station_name=station_name or "unknown_station", + endpoint_url=endpoint_url or "unknown_endpoint", + station_info_text=station_info_text.strip(), + ) + + station_dir = self._memory_root / "stations" / station_key + station_dir.mkdir(parents=True, exist_ok=True) + profile_path = station_dir / "station_profile.json" + profile_path.write_text( + json.dumps(profile.__dict__, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + self._upsert_station_profile(profile) + + def learn_from_tool_result( + self, + station_name: str, + endpoint_url: str, + tool_name: str, + arguments: dict[str, Any], + result_text: str, + ) -> None: + """Persist reusable lessons and current working-folder hints from tool activity.""" + if not self._enabled: + return + + station_key = self._build_station_key(station_name, endpoint_url) + working_ord = self._extract_working_ord(arguments) + if working_ord: + self._set_preference(station_key, "last_working_ord", working_ord) + self._set_preference(station_key, "last_working_tool", tool_name) + + lesson = self._derive_tool_lesson(tool_name, arguments, result_text) + if lesson: + self._insert_tool_lesson( + scope="station", + tool_name=tool_name, + lesson=lesson, + ) + + def _load_global_context(self) -> str: + if self._cached_global_context: + return self._cached_global_context + + chunks: list[str] = [] + for name in _CANDY_FILES: + file_path = self._candy_docs_dir / name + if not file_path.exists(): + continue + try: + text = file_path.read_text(encoding="utf-8", errors="ignore") + except Exception: + logger.warning("Could not read memory seed file: %s", file_path) + continue + + # Keep only compact operational snippets to avoid prompt bloat. + chunks.append(self._compact_markdown(text, max_lines=14)) + + self._cached_global_context = "\n\n".join(chunk for chunk in chunks if chunk).strip() + return self._cached_global_context + + def _load_station_context(self, station_name: str, endpoint_url: str) -> str: + station_key = self._build_station_key(station_name, endpoint_url) + db_profile = self._read_station_profile_from_db(station_key) + if db_profile is not None: + compact_info = self._trim_whitespace(db_profile.station_info_text) + compact_info = compact_info[:1200] + return ( + "Current station profile (cached, verify live before writes):\n" + f"- station_key: {db_profile.station_key}\n" + f"- station_name: {db_profile.station_name}\n" + f"- endpoint: {db_profile.endpoint_url}\n" + f"- station_info: {compact_info}" + ) + + profile_path = self._memory_root / "stations" / station_key / "station_profile.json" + if not profile_path.exists(): + return "" + + try: + raw = json.loads(profile_path.read_text(encoding="utf-8")) + except Exception: + logger.warning("Could not read station profile: %s", profile_path) + return "" + + station_info_text = str(raw.get("station_info_text", "")).strip() + if not station_info_text: + return "" + + compact_info = self._trim_whitespace(station_info_text) + compact_info = compact_info[:1200] + return ( + "Current station profile (cached, verify live before writes):\n" + f"- station_key: {station_key}\n" + f"- station_info: {compact_info}" + ) + + def _load_working_folder_hint(self, station_name: str, endpoint_url: str) -> str: + station_key = self._build_station_key(station_name, endpoint_url) + working_ord = self._get_preference(station_key, "last_working_ord") + working_tool = self._get_preference(station_key, "last_working_tool") + if not working_ord: + return "" + + tool_suffix = f" (from {working_tool})" if working_tool else "" + return ( + "Remembered operator context:\n" + f"- Last working folder/path: {working_ord}{tool_suffix}\n" + "- Use this as the default working path unless the user specifies a different folder." + ) + + def _load_recent_lessons_hint(self, station_name: str, endpoint_url: str) -> str: + station_key = self._build_station_key(station_name, endpoint_url) + lessons: list[str] = [] + + if not self._db_path.exists(): + return "" + + try: + with sqlite3.connect(self._db_path) as conn: + rows = conn.execute( + """ + SELECT lesson + FROM tool_lesson + WHERE scope IN ('global', 'station') + ORDER BY id DESC + LIMIT 3 + """ + ).fetchall() + lessons = [str(row[0]).strip() for row in rows if row and str(row[0]).strip()] + except Exception as exc: + logger.warning("Could not load recent lessons for %s: %s", station_key, exc) + return "" + + if not lessons: + return "" + return "Recent tool lessons:\n" + "\n".join(f"- {lesson}" for lesson in lessons) + + def _ensure_db_initialized(self) -> None: + """Initialize local SQLite memory database if it does not exist.""" + if self._db_path.exists(): + self._create_schema_if_needed() + return + + seed_path = self._resolve_seed_db_path() + if seed_path is not None: + try: + self._db_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(seed_path, self._db_path) + logger.info("Initialized memory SQLite from seed: %s", seed_path) + except Exception as exc: + logger.warning("Could not copy memory seed DB (%s): %s", seed_path, exc) + + self._create_schema_if_needed() + + def _resolve_seed_db_path(self) -> Path | None: + """Return optional bundled seed DB path when present.""" + candidates: list[Path] = [] + + # Development path. + candidates.append(Path.cwd() / "assets" / "memory_seed.sqlite") + + # PyInstaller onefile/onedir extraction path. + meipass = getattr(sys, "_MEIPASS", "") + if meipass: + candidates.append(Path(str(meipass)) / "assets" / "memory_seed.sqlite") + + for candidate in candidates: + if candidate.exists() and candidate.is_file(): + return candidate + return None + + def _create_schema_if_needed(self) -> None: + """Create required SQLite tables for memory operations.""" + self._db_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(self._db_path) as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS station_profile ( + station_key TEXT PRIMARY KEY, + station_name TEXT NOT NULL, + endpoint_url TEXT NOT NULL, + station_info_text TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS tool_lesson ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL, + tool_name TEXT NOT NULL, + lesson TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS episode ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + station_key TEXT NOT NULL, + summary TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS operator_preference ( + station_key TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (station_key, key) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS conversation ( + conversation_id TEXT PRIMARY KEY, + station_key TEXT NOT NULL, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS conversation_message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id) + ) + """ + ) + conn.commit() + + def _upsert_station_profile(self, profile: StationProfile) -> None: + timestamp = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(self._db_path) as conn: + conn.execute( + """ + INSERT INTO station_profile ( + station_key, + station_name, + endpoint_url, + station_info_text, + updated_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(station_key) DO UPDATE SET + station_name=excluded.station_name, + endpoint_url=excluded.endpoint_url, + station_info_text=excluded.station_info_text, + updated_at=excluded.updated_at + """, + ( + profile.station_key, + profile.station_name, + profile.endpoint_url, + profile.station_info_text, + timestamp, + ), + ) + conn.commit() + + def _read_station_profile_from_db(self, station_key: str) -> StationProfile | None: + if not self._db_path.exists(): + return None + with sqlite3.connect(self._db_path) as conn: + row = conn.execute( + """ + SELECT station_key, station_name, endpoint_url, station_info_text + FROM station_profile + WHERE station_key = ? + """, + (station_key,), + ).fetchone() + + if row is None: + return None + + return StationProfile( + station_key=str(row[0]), + station_name=str(row[1]), + endpoint_url=str(row[2]), + station_info_text=str(row[3]), + ) + + def _set_preference(self, station_key: str, key: str, value: str) -> None: + if not value: + return + timestamp = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(self._db_path) as conn: + conn.execute( + """ + INSERT INTO operator_preference (station_key, key, value, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(station_key, key) DO UPDATE SET + value=excluded.value, + updated_at=excluded.updated_at + """, + (station_key, key, value, timestamp), + ) + conn.commit() + + def _get_preference(self, station_key: str, key: str) -> str: + if not self._db_path.exists(): + return "" + with sqlite3.connect(self._db_path) as conn: + row = conn.execute( + """ + SELECT value + FROM operator_preference + WHERE station_key = ? AND key = ? + """, + (station_key, key), + ).fetchone() + if row is None: + return "" + return str(row[0] or "").strip() + + def _insert_tool_lesson(self, scope: str, tool_name: str, lesson: str) -> None: + if not lesson: + return + timestamp = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(self._db_path) as conn: + conn.execute( + """ + INSERT INTO tool_lesson (scope, tool_name, lesson, created_at) + VALUES (?, ?, ?, ?) + """, + (scope, tool_name, lesson, timestamp), + ) + conn.commit() + + def _extract_working_ord(self, arguments: dict[str, Any]) -> str: + for key in _ORD_KEYS: + value = arguments.get(key) + if isinstance(value, str) and ":|slot:/" in value: + return value.strip() + + for key in _SLOT_ENDPOINT_KEYS: + value = arguments.get(key) + if not isinstance(value, str): + continue + text = value.strip() + idx = text.find(":|slot:/") + if idx < 0: + continue + if "/" in text[idx + 8 :]: + # For slot endpoints, keep the component part only. + head, _, _tail = text.rpartition("/") + return head + return text + + return "" + + def _derive_tool_lesson( + self, + tool_name: str, + arguments: dict[str, Any], + result_text: str, + ) -> str: + text = (result_text or "").lower() + if "path not in allowlisted roots" in text or "nmcp_path_not_allowlisted" in text: + ord_hint = self._extract_working_ord(arguments) + if ord_hint: + return ( + "Allowlist blocked requested path. Confirm exact allowlisted base path with user " + f"before retrying. Last attempted path: {ord_hint}" + ) + return "Allowlist blocked requested path. Ask user for exact allowlisted base path before retrying." + + if "invalid wiresheet payload" in text and "type" in text: + return "Wiresheet operations require explicit type on each operation object." + + if "invalid wiresheet payload" in text and "componentord" in text: + return "Wiresheet setSlot requires absolute componentOrd and non-empty slot/value fields." + + if "tool call rejected by user" in text: + return f"{tool_name} was rejected by user. Provide a clearer risk and rollback explanation next time." + + return "" + + def _derive_message_hint(self, user_message: str) -> str: + lowered = user_message.lower() + hints: list[str] = [] + if any(token in lowered for token in ("wiresheet", "link", "setslot", "facets")): + hints.append("- For wiresheet edits: plan -> diff -> apply dryRun=true before approval.") + if any(token in lowered for token in ("alarm", "fault", "offline", "stale")): + hints.append("- For faults: confirm status and device/network health before changing control logic.") + if any(token in lowered for token in ("write", "override", "command")): + hints.append("- For writes: include target ORD, expected effect, risk, and release plan.") + + if not hints: + return "" + return "Task-relevant memory hints:\n" + "\n".join(hints) + + def _build_station_key(self, station_name: str, endpoint_url: str) -> str: + base = f"{station_name or 'station'}__{endpoint_url or 'endpoint'}" + key = re.sub(r"[^a-zA-Z0-9_.-]+", "_", base).strip("_") + return key[:120] or "station_unknown" + + def _clip_to_budget(self, text: str) -> str: + # Approximate token budget conservatively by 4 chars/token. + max_chars = self._prompt_token_budget * 4 + if len(text) <= max_chars: + return text + clipped = text[: max_chars - 40].rstrip() + return clipped + "\n\n[Memory truncated to fit budget.]" + + def _compact_markdown(self, text: str, max_lines: int) -> str: + compact = self._trim_whitespace(text) + lines = compact.splitlines() + if len(lines) <= max_lines: + return "\n".join(lines) + head = lines[:max_lines] + return "\n".join(head) + + def _trim_whitespace(self, text: str) -> str: + lines = [line.rstrip() for line in text.splitlines()] + output: list[str] = [] + previous_blank = False + for line in lines: + blank = not line.strip() + if blank and previous_blank: + continue + output.append(line) + previous_blank = blank + return "\n".join(output).strip() diff --git a/src/ui/app.py b/src/ui/app.py index 2108c11..2d6cacd 100644 --- a/src/ui/app.py +++ b/src/ui/app.py @@ -27,12 +27,14 @@ from src.agent import AgentLoop from src.async_runner import AsyncRunner from src.llm.base import BaseLLMProvider +from src.memory import MemoryManager from src.mcp_client import NiagaraMCPClient, build_headers from src.ui.about_dialog import AboutDialog from src.ui.approval_dialog import ApprovalDialog from src.ui.chat_widget import ChatWidget from src.ui.connection_widget import ConnectionWidget from src.ui.help_dialog import HelpDialog +from src.ui.memory_health_widget import MemoryHealthWidget from src.ui.plan_review_dialog import PlanReviewDialog from src.ui.tools_widget import ToolsWidget @@ -154,6 +156,21 @@ def _load_project_version() -> str: return "0.0.0" +def _format_tool_result_to_text(result: Any) -> str: + """Convert an MCP tool result object to plain text.""" + if hasattr(result, "content"): + parts: list[str] = [] + for block in result.content: + if hasattr(block, "text"): + parts.append(str(block.text)) + elif hasattr(block, "data"): + parts.append(f"[binary data, {len(block.data)} bytes]") + else: + parts.append(str(block)) + return "\n".join(parts).strip() + return str(result).strip() + + class MainWindow(QMainWindow): """Primary application window.""" @@ -163,6 +180,9 @@ class MainWindow(QMainWindow): _sig_connected = Signal(list) # tools list _sig_conn_error = Signal(str) # error message _sig_agent_done = Signal() # agent loop finished + _sig_memory_health = Signal(object) + _sig_conversations_loaded = Signal(list, str) + _sig_conversation_history_loaded = Signal(list) def __init__(self) -> None: super().__init__() @@ -230,10 +250,12 @@ def __init__(self) -> None: """) self._config: AppConfig = load_config() + self._memory = MemoryManager.from_config(self._config) self._tools: list[Any] = [] self._current_agent: AgentLoop | None = None self._pending_message: str | None = None self._last_assistant_message: str = "" + self._active_conversation_id: str = "" self._agent_phase: Literal["idle", "planning", "executing"] = "idle" self._app_version = _load_project_version() @@ -246,6 +268,9 @@ def __init__(self) -> None: self._sig_connected.connect(self._handle_connected) self._sig_conn_error.connect(self._handle_conn_error) self._sig_agent_done.connect(self._handle_agent_done) + self._sig_memory_health.connect(self._on_memory_health_snapshot) + self._sig_conversations_loaded.connect(self._on_conversations_loaded) + self._sig_conversation_history_loaded.connect(self._on_conversation_history_loaded) self._build_ui() self._load_config_into_widgets() @@ -275,13 +300,18 @@ def _build_ui(self) -> None: self._conn_widget.disconnect_requested.connect(self._on_disconnect_requested) self._tools_widget = ToolsWidget() + self._memory_health_widget = MemoryHealthWidget() + self._memory_health_widget.refresh_requested.connect(self._refresh_memory_health_ui) left_layout.addWidget(self._conn_widget) left_layout.addWidget(self._tools_widget, stretch=1) + left_layout.addWidget(self._memory_health_widget) # Right panel — chat self._chat_widget = ChatWidget() self._chat_widget.message_submitted.connect(self._on_message_submitted) + self._chat_widget.conversation_selected.connect(self._on_conversation_selected) + self._chat_widget.conversation_create_requested.connect(self._on_new_conversation_requested) splitter.addWidget(left) splitter.addWidget(self._chat_widget) @@ -306,6 +336,8 @@ def _build_ui(self) -> None: status_bar.addPermanentWidget(attribution) self._set_status("Not connected", "disconnected") + self._refresh_memory_health_ui() + self._initialize_conversations_ui() def _build_menu(self) -> None: help_menu: QMenu = self.menuBar().addMenu("Help") @@ -335,6 +367,62 @@ def _load_config_into_widgets(self) -> None: self._conn_widget.load_config(self._config) self._chat_widget.load_config(self._config) + @Slot(object) + def _on_memory_health_snapshot(self, snapshot: object) -> None: + self._memory_health_widget.set_snapshot(snapshot) + + def _refresh_memory_health_ui(self) -> None: + snapshot = self._memory.get_health_snapshot() + self._memory_health_widget.set_snapshot(snapshot) + + def _initialize_conversations_ui(self) -> None: + station = self._config.connection.station_name + endpoint = self._config.connection.mcp_url + thread = self._memory.ensure_default_conversation(station, endpoint) + self._active_conversation_id = thread.conversation_id + self._load_conversations_ui() + self._load_active_conversation_history() + + def _load_conversations_ui(self) -> None: + station = self._config.connection.station_name + endpoint = self._mcp.endpoint_url or self._config.connection.mcp_url + threads = self._memory.list_conversations(station, endpoint) + items = [(t.conversation_id, t.title) for t in threads] + self._sig_conversations_loaded.emit(items, self._active_conversation_id) + + def _load_active_conversation_history(self) -> None: + if not self._active_conversation_id: + return + messages = self._memory.get_conversation_messages(self._active_conversation_id) + payload = [(m.role, m.content) for m in messages] + self._sig_conversation_history_loaded.emit(payload) + + @Slot(list, str) + def _on_conversations_loaded(self, items: list, active_id: str) -> None: + normalized = [(str(item[0]), str(item[1])) for item in items] + self._chat_widget.set_conversations(normalized, active_id) + + @Slot(list) + def _on_conversation_history_loaded(self, messages: list) -> None: + normalized = [(str(item[0]), str(item[1])) for item in messages] + self._chat_widget.load_history(normalized) + + @Slot(str) + def _on_conversation_selected(self, conversation_id: str) -> None: + if not conversation_id or conversation_id == self._active_conversation_id: + return + self._active_conversation_id = conversation_id + self._load_active_conversation_history() + + @Slot() + def _on_new_conversation_requested(self) -> None: + station = self._config.connection.station_name + endpoint = self._mcp.endpoint_url or self._config.connection.mcp_url + thread = self._memory.create_conversation(station, endpoint) + self._active_conversation_id = thread.conversation_id + self._load_conversations_ui() + self._load_active_conversation_history() + # ------------------------------------------------------------------ # Slots — connection # ------------------------------------------------------------------ @@ -356,6 +444,7 @@ def _on_connect_requested(self, settings: dict) -> None: l.base_url = settings.get("base_url", "") save_config(self._config) + self._initialize_conversations_ui() headers = build_headers( username=c.username, @@ -415,6 +504,31 @@ def _handle_connected(self, tools: list) -> None: ) logger.info("Connected — %d tools", len(tools)) + if self._memory.enabled: + profile_future = self._async_runner.submit(self._refresh_station_profile()) + profile_future.add_done_callback(self._cb_after_station_profile_refresh) + self._refresh_memory_health_ui() + + async def _refresh_station_profile(self) -> None: + """Fetch station.info and persist a lightweight local station profile.""" + station_info_result = await self._mcp.call_tool("nmcp.station.info", {}) + station_info_text = _format_tool_result_to_text(station_info_result) + self._memory.update_station_profile( + station_name=self._config.connection.station_name, + endpoint_url=self._mcp.endpoint_url or self._config.connection.mcp_url, + station_info_text=station_info_text, + ) + + def _cb_after_station_profile_refresh(self, future) -> None: + """Log station profile refresh outcomes without interrupting the user flow.""" + try: + future.result() + logger.info("Memory station profile refreshed") + self._sig_memory_health.emit(self._memory.get_health_snapshot()) + except Exception as exc: + logger.warning("Could not refresh memory station profile: %s", exc) + self._sig_memory_health.emit(self._memory.get_health_snapshot()) + @Slot(str) def _handle_conn_error(self, error: str) -> None: """Runs in Qt main thread.""" @@ -431,6 +545,7 @@ def _on_disconnect_requested(self) -> None: self._conn_widget.set_connected(False) self._chat_widget.append_system_message("Disconnected.") self._set_status("Not connected", "disconnected") + self._refresh_memory_health_ui() # ------------------------------------------------------------------ # Slots — chat @@ -455,6 +570,7 @@ def _on_message_submitted(self, message: str) -> None: self._chat_widget.append_user_message(message) self._pending_message = message + self._persist_chat_message("user", message) if self._chat_widget.is_plan_mode_enabled(): self._chat_widget.append_system_message( @@ -504,6 +620,14 @@ def _start_agent_run( planning_mode=planning_mode, writes_permitted=writes_permitted, strict_paths=strict_paths, + memory_context=self._memory.build_prompt_context( + user_message=message, + station_name=self._config.connection.station_name, + endpoint_url=self._mcp.endpoint_url or self._config.connection.mcp_url, + ) + + "\n\n" + + self._memory.build_conversation_context(self._active_conversation_id), + tool_observer=self._observe_tool_outcome, ) agent.set_event_loop(self._async_runner.get_loop()) self._current_agent = agent @@ -519,6 +643,25 @@ def _start_agent_run( future = self._async_runner.submit(agent.run(message, tools)) future.add_done_callback(lambda _f: self._sig_agent_done.emit()) + def _observe_tool_outcome( + self, + tool_name: str, + arguments: dict[str, Any], + result_text: str, + ) -> None: + """Capture repeatable lessons and working-folder context from tool activity.""" + try: + self._memory.learn_from_tool_result( + station_name=self._config.connection.station_name, + endpoint_url=self._mcp.endpoint_url or self._config.connection.mcp_url, + tool_name=tool_name, + arguments=arguments, + result_text=result_text, + ) + self._sig_memory_health.emit(self._memory.get_health_snapshot()) + except Exception as exc: + logger.warning("Could not learn from tool outcome for %s: %s", tool_name, exc) + @Slot(str, str, str) def _on_approval_requested( self, tool_name: str, args_json: str, explanation: str @@ -531,15 +674,18 @@ def _on_approval_requested( @Slot(str, str) def _on_tool_executed(self, tool_name: str, result_preview: str) -> None: self._chat_widget.append_tool_result(tool_name, result_preview) + self._persist_chat_message("tool", f"{tool_name}: {result_preview}") @Slot(str) def _on_message_complete(self, text: str) -> None: self._last_assistant_message = text self._chat_widget.complete_assistant_message(text) + self._persist_chat_message("assistant", text) @Slot(str) def _on_agent_error(self, error: str) -> None: self._chat_widget.append_error_message(error) + self._persist_chat_message("error", error) @Slot() def _handle_agent_done(self) -> None: @@ -615,6 +761,20 @@ def _show_help_dialog(self) -> None: dialog = HelpDialog(self) dialog.exec() + def _persist_chat_message(self, role: str, content: str) -> None: + if not self._active_conversation_id: + return + try: + self._memory.append_conversation_message( + conversation_id=self._active_conversation_id, + role=role, + content=content, + ) + self._load_conversations_ui() + self._sig_memory_health.emit(self._memory.get_health_snapshot()) + except Exception as exc: + logger.warning("Could not persist chat message (%s): %s", role, exc) + # ------------------------------------------------------------------ # Window close # ------------------------------------------------------------------ diff --git a/src/ui/chat_widget.py b/src/ui/chat_widget.py index 24051c4..798dc9c 100644 --- a/src/ui/chat_widget.py +++ b/src/ui/chat_widget.py @@ -9,6 +9,7 @@ from PySide6.QtGui import QTextCursor from PySide6.QtWidgets import ( QCheckBox, + QComboBox, QHBoxLayout, QLabel, QPushButton, @@ -48,6 +49,8 @@ class ChatWidget(QWidget): """Displays the conversation and accepts user input.""" message_submitted = Signal(str) + conversation_selected = Signal(str) + conversation_create_requested = Signal() def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -61,6 +64,37 @@ def __init__(self, parent: QWidget | None = None) -> None: def load_config(self, _config: AppConfig) -> None: """Reserved for future config-dependent chat settings.""" + def set_conversations(self, items: list[tuple[str, str]], active_id: str = "") -> None: + """Populate conversation picker with (conversation_id, title) tuples.""" + self._conversation_combo.blockSignals(True) + self._conversation_combo.clear() + for conv_id, title in items: + self._conversation_combo.addItem(title, conv_id) + + if active_id: + idx = self._conversation_combo.findData(active_id) + if idx >= 0: + self._conversation_combo.setCurrentIndex(idx) + self._conversation_combo.blockSignals(False) + + def load_history(self, messages: list[tuple[str, str]]) -> None: + """Render stored conversation messages in chronological order.""" + self.clear() + for role, text in messages: + lowered = role.lower() + if lowered == "user": + self.append_user_message(text) + elif lowered == "assistant": + self.complete_assistant_message(text) + elif lowered == "tool": + self._append_block( + f'
🔧 {_esc(text)}
' + ) + elif lowered == "error": + self.append_error_message(text) + else: + self.append_system_message(text) + @Slot(str) def append_user_message(self, text: str) -> None: self._append_block( @@ -134,6 +168,22 @@ def _build_ui(self) -> None: root.setContentsMargins(4, 4, 4, 4) root.setSpacing(4) + thread_row = QHBoxLayout() + thread_label = QLabel("Conversation:") + thread_label.setStyleSheet("color:#334155; font-size:11px;") + thread_row.addWidget(thread_label) + + self._conversation_combo = QComboBox() + self._conversation_combo.currentIndexChanged.connect(self._on_conversation_changed) + thread_row.addWidget(self._conversation_combo, stretch=1) + + self._new_conversation_btn = QPushButton("New") + self._new_conversation_btn.setFixedWidth(60) + self._new_conversation_btn.clicked.connect(self.conversation_create_requested) + thread_row.addWidget(self._new_conversation_btn) + + root.addLayout(thread_row) + mode_row = QHBoxLayout() self._plan_mode_toggle = QCheckBox("Plan Mode") self._plan_mode_toggle.setToolTip( @@ -214,6 +264,13 @@ def _on_send(self) -> None: self._send_btn.setEnabled(False) self.message_submitted.emit(text) + def _on_conversation_changed(self, index: int) -> None: + if index < 0: + return + conv_id = str(self._conversation_combo.itemData(index) or "") + if conv_id: + self.conversation_selected.emit(conv_id) + def enable_input(self) -> None: self._input.setEnabled(True) self._send_btn.setEnabled(True) diff --git a/src/ui/memory_health_widget.py b/src/ui/memory_health_widget.py new file mode 100644 index 0000000..30cfdda --- /dev/null +++ b/src/ui/memory_health_widget.py @@ -0,0 +1,105 @@ +# Copyright (c) 2026 Chris Favre. All rights reserved. +"""Widget for displaying local memory/SQLite health information.""" + +from __future__ import annotations + +from typing import Any + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QFormLayout, QGroupBox, QLabel, QPushButton, QVBoxLayout, QWidget + + +class MemoryHealthWidget(QWidget): + """Displays memory SQLite status and key counters for operator visibility.""" + + refresh_requested = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._build_ui() + self.set_snapshot(None) + + def set_snapshot(self, snapshot: Any) -> None: + """Update labels using a MemoryHealthSnapshot-like object.""" + if snapshot is None: + self._db_path_value.setText("Not initialized") + self._db_exists_value.setText("No") + self._db_size_value.setText("0 B") + self._station_rows_value.setText("0") + self._episode_rows_value.setText("0") + self._lesson_rows_value.setText("0") + self._last_update_value.setText("-") + self._last_station_key_value.setText("-") + return + + self._db_path_value.setText(str(getattr(snapshot, "db_path", "-"))) + self._db_exists_value.setText("Yes" if bool(getattr(snapshot, "exists", False)) else "No") + self._db_size_value.setText(self._format_bytes(int(getattr(snapshot, "size_bytes", 0)))) + self._station_rows_value.setText(str(getattr(snapshot, "station_profile_rows", 0))) + self._episode_rows_value.setText(str(getattr(snapshot, "episode_rows", 0))) + self._lesson_rows_value.setText(str(getattr(snapshot, "tool_lesson_rows", 0))) + + updated = str(getattr(snapshot, "latest_station_updated_at", "") or "-") + key = str(getattr(snapshot, "latest_station_key", "") or "-") + self._last_update_value.setText(updated) + self._last_station_key_value.setText(key) + + def _build_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(4, 4, 4, 4) + root.setSpacing(4) + + group = QGroupBox("Memory Health") + inner = QVBoxLayout(group) + + form = QFormLayout() + form.setSpacing(4) + + self._db_path_value = QLabel() + self._db_path_value.setWordWrap(True) + self._db_path_value.setStyleSheet("font-size:11px; color:#334155;") + form.addRow("DB Path:", self._db_path_value) + + self._db_exists_value = QLabel() + form.addRow("DB Exists:", self._db_exists_value) + + self._db_size_value = QLabel() + form.addRow("DB Size:", self._db_size_value) + + self._station_rows_value = QLabel() + form.addRow("Stations:", self._station_rows_value) + + self._episode_rows_value = QLabel() + form.addRow("Episodes:", self._episode_rows_value) + + self._lesson_rows_value = QLabel() + form.addRow("Tool Lessons:", self._lesson_rows_value) + + self._last_update_value = QLabel() + self._last_update_value.setWordWrap(True) + self._last_update_value.setStyleSheet("font-size:11px; color:#334155;") + form.addRow("Last Update:", self._last_update_value) + + self._last_station_key_value = QLabel() + self._last_station_key_value.setWordWrap(True) + self._last_station_key_value.setStyleSheet("font-size:11px; color:#334155;") + form.addRow("Last Station Key:", self._last_station_key_value) + + inner.addLayout(form) + + refresh_btn = QPushButton("Refresh") + refresh_btn.clicked.connect(self.refresh_requested) + inner.addWidget(refresh_btn) + + root.addWidget(group) + + def _format_bytes(self, size_bytes: int) -> str: + units = ["B", "KB", "MB", "GB"] + value = float(size_bytes) + idx = 0 + while value >= 1024 and idx < len(units) - 1: + value /= 1024 + idx += 1 + if idx == 0: + return f"{int(value)} {units[idx]}" + return f"{value:.1f} {units[idx]}"