diff --git a/CHANGELOG.md b/CHANGELOG.md index 836f2a23..aea6203e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,12 +25,20 @@ Status of the `main` branch. Changes prior to the next official version change w - Project server: - `query_project`: Support use of project root instead of project name #1388 - `list_queryable_projects`: Return both project names and project roots + - Fix: `search_for_pattern` tool returned 1-based line numbers (in contrast to all other tools); cause: implementation of `text_utils.search_text` + - Serena's system prompt (a.k.a. the 'Serena Instructions Manual') is now provided lazily. + At MCP connection time, only a one-sentence bootstrap prompt is provided. + The `initial_instructions` tool provides the full prompt on demand, keeping the initial context lean. + - Add `serena_info` tool for on-demand retrieval of usage information * JetBrains: - - `Move` and `SafeDelete` tools: transform empty string to None (counteracts client errors) + - Add `debug` tool: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow + by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility. + - `move` and `safe_delete` tools: transform empty string to None (counteracts client errors) * Dependencies: - `pywebview`: Switch back to official release (new version 6.2) #1253 + - `mcp`: Update from `1.26.0` to `1.27.0` * Evaluations: - Added new evaluations for Junie Plugin with Opus 4.6 and GLM 5.1 in Claude Code. @@ -39,7 +47,13 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: clangd capability checks now tolerate valid initialize response shape differences and invalidate cached C++ document symbols when clangd/compile commands context changes #1359 - Fix: `rename_symbol` for Vue files now correctly propagates edits to the TypeScript server, enabling cross-file renames in `.vue` files - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 + - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391 + - Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397 +Dashboard: + - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): + Three modes (browser, native app with tray, tray manager for aggregating multiple instances) are supported, depending on the OS + - Fix: Memory leaks in frontend when using Chromium-based browsers/Windows webview #1389 # v1.1.2 (2026-04-14) diff --git a/README.md b/README.md index d26bdad8..0c819fb4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@
-* Serena provides essential **semantic code retrieval, editing and refactoring tools** that are akin to an IDE's capabilities, +* Serena provides essential **semantic code retrieval, editing, refactoring and debugging tools** that are akin to an IDE's capabilities, operating at the symbol level and exploiting relational structure. * It integrates with any client/LLM via the model context protocol (**MCP**). @@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language The underlying language servers are typically open-source projects or at least freely available for use. When using Serena's language server backend, we provide **support for over 40 programming languages**, including -AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. ### The Serena JetBrains Plugin @@ -171,6 +171,12 @@ Serena's symbolic editing tools are less error-prone and much more token-efficie | insert before symbol | yes | yes | | safe delete | yes | yes | +### Interactive Debugging + +Exclusive to the JetBrains plugin, Serena supports a highly general debugging tool, +which allows an agent to set breakpoints, inspect variables, evaluate expressions and control execution flow +via a persistent REPL-style interface. + ### Basic Features Beyond its semantic capabilities, Serena includes a set of basic utilities for completeness. diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 999782ee..dff0a228 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -115,6 +115,8 @@ Some languages require additional installations or setup steps, as noted. * **Vue** (3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection) * **YAML** +* **JSON** + (experimental; must be explicitly added to the languages list; requires Node.js and npm) * **Zig** (requires installation of ZLS - Zig Language Server) diff --git a/docs/02-usage/025_jetbrains_plugin.md b/docs/02-usage/025_jetbrains_plugin.md index 1995ca34..4eb08688 100644 --- a/docs/02-usage/025_jetbrains_plugin.md +++ b/docs/02-usage/025_jetbrains_plugin.md @@ -43,6 +43,8 @@ There are multiple features that are only available when using the JetBrains plu * **Enhanced retrieval & refactoring capabilities**: The plugin adds additional [tools](../01-about/035_tools) (e.g. type hierarchy retrieval, move, find declaration, inline symbol, etc.) and transforms the underlying mechanisms of shared tools to build upon the IDE's capabilities. +* **Interactive debugging**: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow + by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility. * **Improved multi-agent support**: A single IDE instance naturally serves arbitrarily many agent sessions without requiring additional resources. * **Enhanced performance**: Faster tool execution thanks to optimized IDE integration. * **Multi-language excellence** and **framework support**: First-class support for polyglot projects with multiple languages. diff --git a/docs/02-usage/060_dashboard.md b/docs/02-usage/060_dashboard.md index 8e828aec..ae11fd4d 100644 --- a/docs/02-usage/060_dashboard.md +++ b/docs/02-usage/060_dashboard.md @@ -19,10 +19,13 @@ Both can be configured in Serena's [configuration](050_configuration) file (`ser ## The Serena Dashboard -The dashboard is a web application, which is opened via a native application wrapper on supported platforms (e.g. Windows), -where it will appear in the system tray. **Click on the tray icon** to open the application window. +The dashboard is a web application, which is opened in one of three ways: -On other platforms, where native app support is difficult to provide (e.g. Linux), the dashboard will be opened in your default web browser instead. + * via a native application wrapper with accompanying tray icon (on supported platforms) + * via a tray manager that aggregates multiple instances (on supported platforms) + * via your default web browser (supported on all platforms) + +Configure your preferred interface via the setting `web_dashoard_interface` in [Serena's global configuration file](global-config) (see below). By default, the dashboard can be accessed at `http://localhost:24282/dashboard/index.html`, but a higher port may be used if the default port is unavailable/multiple instances are running. @@ -45,6 +48,7 @@ The dashboard provides ... You can configure settings for the dashboard in the [global configuration file](global-config). In particular, you can configure + * the interface through which the dashboard is accessed (see above) * whether the dashboard is enabled at all. We highly recommend keeping it enabled, as it provides valuable insights into Serena's operation and allows you to adjust configuration conveniently. * whether the dashboard window is opened automatically when Serena is started (see below). @@ -54,11 +58,12 @@ In particular, you can configure When Serena is started, the Dashboard window is opened by default in order to make users aware of its existence. -If you prefer not to have this happen (e.g., to avoid focus stealing), you can prevent it from opening +If you prefer not to have this happen, you can prevent it from opening by setting `web_dashboard_open_on_launch: False` in the [global configuration file](global-config) or by passing `--open-web-dashboard False` to the `start-mcp-server` CLI command. -On platforms supporting the tray icon, you can conveniently open the dashboard at any time by clicking on the tray icon, so automatic +On platforms supporting the tray icon (see also configuration option `web_dashboard_interface`), +you can conveniently open the dashboard at any time by clicking on the tray icon, so automatic opening is not a requirement to be able to access the dashboard on these platforms. On other platforms, you may still access it by diff --git a/pyproject.toml b/pyproject.toml index e09bf469..837800e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,8 @@ dependencies = [ "pyright==1.1.403", "fortls==3.2.2", "overrides==7.7.0", - "python-dotenv==1.2.1", - "mcp==1.26.0", + "python-dotenv==1.2.2", + "mcp==1.27.0", "flask==3.1.3", # bumped from 3.1.1 for CVE fix (also fixes werkzeug alert) "sensai-utils==1.5.0", "pydantic==2.12.5", @@ -88,13 +88,11 @@ dev = [ "sphinx_book_theme==1.1.4", "sphinx-toolbox==3.7.0", "jupyter-book==1.0.4.post1", - "nbsphinx==0.9.7", "pyinstrument==5.1.1", "pytest-timeout==2.4.0", # Transitive dev deps pinned for security (dependabot alerts). # Exact pins because uvx installs from git, ignoring the lock file. "tornado==6.5.5", - "nbconvert==7.17.0", "wheel==0.46.3", "pyasn1==0.6.3", "PyJWT==2.12.0", @@ -338,6 +336,7 @@ markers = [ "haskell: Haskell language server tests", "haxe: Haxe language server tests", "yaml: language server running for YAML", + "json: language server running for JSON (uses vscode-json-languageserver)", "powershell: language server running for PowerShell", "pascal: language server running for Pascal (Free Pascal/Lazarus)", "cpp: language server running for C/C++", diff --git a/src/serena/__init__.py b/src/serena/__init__.py index 77077e5c..ce2b1abf 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -21,3 +21,15 @@ def serena_version() -> str: except: pass return version + + +def _init_log_configuration() -> None: + from sensai.util import logging + + def _configure() -> None: + logging.getLogger("PIL").setLevel(logging.WARNING) + + logging.set_configure_callback(_configure) + + +_init_log_configuration() diff --git a/src/serena/agent.py b/src/serena/agent.py index 9331b26d..2daf0b4e 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -7,12 +7,13 @@ import multiprocessing import os import platform import signal -import subprocess -import sys +import threading from collections import defaultdict from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from dataclasses import dataclass +from datetime import datetime +from enum import Enum from logging import Logger from typing import TYPE_CHECKING, Optional, TypeVar @@ -36,7 +37,7 @@ from serena.config.serena_config import ( SerenaPaths, ToolInclusionDefinition, ) -from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer +from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser from serena.ls_manager import LanguageServerManager from serena.project import MemoriesManager, Project from serena.prompt_factory import SerenaPromptFactory @@ -321,6 +322,178 @@ class ProjectPromptProvisionStatus: return self._get_session_status(session_id).project_activation_message_provided +class DashboardManager: + class Mode(Enum): + BROWSER = "browser" + """ + Open the dashboard in the default browser; supported on all platforms. + """ + WEBVIEW = "app" + """ + Open the dashboard via a native window (using pywebview) which minimises to the tray; + supported on Windows and macOS (but on macOS, tray apps for multiple instances accumulate + in the top bar, which users may not want) + """ + TRAY_MANAGER = "tray_manager" + """ + Register dashboard instance with a central manager tray app (single tray icon for all instances), + spawning the tray manager if not already running; supported on macOS and Windows. + """ + + @classmethod + def from_platform(cls) -> "DashboardManager.Mode": + match platform.system(): + case "Windows": + return cls.WEBVIEW + case "Darwin": + # TODO: Switch to TRAY_MANAGER once support is tested + return cls.BROWSER + case _: + return cls.BROWSER + + def is_supported(self) -> bool: + """ + :return: whether the mode is supported on the current platform + """ + if self == DashboardManager.Mode.WEBVIEW: + return SerenaDashboardViewer.is_current_platform_supported() + elif self == DashboardManager.Mode.TRAY_MANAGER: + return SerenaDashboardTrayManager.is_current_platform_supported() + else: + return True + + def __init__( + self, + port: int, + host_listen_address: str, + open_dashboard_on_launch: bool, + active_project: Project | None = None, + mode_str: str | None = None, + ): + # determine requested mode + if mode_str is not None: + try: + mode = self.Mode(mode_str) + except ValueError: + mode = self.Mode.from_platform() + log.warning(f"Invalid dashboard interface mode '{mode_str}' specified; falling back to platform default '{mode.value}'.") + else: + mode = self.Mode.from_platform() + + # check for mode compatibility + if not mode.is_supported(): + fallback_mode = self.Mode.from_platform() + log.warning( + f"Dashboard interface mode '{mode.value}' is not supported on the current platform; " + "falling back to '{fallback_mode.value}'." + ) + mode = fallback_mode + + self._port = port + self._mode = mode + self._dashboard_viewer_process: multiprocessing.Process | None = None + self._tray_manager_lock = threading.Lock() + + dashboard_host = host_listen_address + if dashboard_host == "0.0.0.0": + dashboard_host = "localhost" + self.url = f"http://{dashboard_host}:{port}/dashboard/index.html" + + # handle startup + match self._mode: + case self.Mode.WEBVIEW: + self._start_dashboard_viewer(minimized=not open_dashboard_on_launch) + case self.Mode.TRAY_MANAGER: + init_fn = lambda: self._tray_manager_register(open_on_launch=open_dashboard_on_launch, active_project=active_project) + threading.Thread(target=init_fn, name="init-DashboardTrayManager", daemon=True).start() + case self.Mode.BROWSER: + if open_dashboard_on_launch: + if not system_has_usable_display(): + log.info("Not opening the Serena dashboard because no usable display was detected.") + else: + self.open_dashboard_in_browser() + + def open_dashboard_in_browser(self) -> None: + open_url_in_browser(self.url, use_subprocess=True) + + @staticmethod + def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None: + """ + Main function of the subprocess for starting the dashboard viewer + """ + try: + SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run() + except webview.errors.WebViewException as e: + log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}") + # Fall back to opening the browser window if the window was supposed to be shown directly + if not minimized: + open_url_in_browser(url, use_subprocess=True) + + def _start_dashboard_viewer(self, minimized: bool) -> None: + """ + Starts the dashboard viewer (in a separate process) or, if the current platform does not support it, + opens the dashboard in the default web browser. + + :param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform). + If the viewer is not supported on the current platform, then this controls whether to open the browser window. + """ + self._dashboard_viewer_process = multiprocessing.Process( + target=self._start_dashboard_viewer_process_function, args=(self.url, minimized, os.getpid()), daemon=True + ) + self._dashboard_viewer_process.start() + + def _tray_manager_register(self, open_on_launch: bool, active_project: Project | None) -> None: + """ + Ensure the tray manager is running and register this dashboard instance with it. + + If the current platform supports the native tray manager, this method starts + the manager (if not already running) and registers the instance. Otherwise, + it falls back to opening the dashboard in the default web browser. + + :param open_on_launch: whether the dashboard should be opened immediately + """ + with LogTime("Dashboard tray manager initialisation"): + with self._tray_manager_lock: + # ensure the singleton tray manager process is running + SerenaDashboardTrayManager.ensure_running() + + # determine the current project name (if any) + project_name = active_project.project_name if active_project is not None else None + + # register this instance with the tray manager + SerenaDashboardTrayManager.register_instance( + port=self._port, + dashboard_url=self.url, + project=project_name, + started_at=datetime.now().isoformat(timespec="seconds"), + open_viewer=open_on_launch, + ) + + def shutdown(self) -> None: + """ + Frees resources, terminating the dashboard viewer process (if any) and unregistering from the tray manager (if applicable). + """ + if self._dashboard_viewer_process is not None: + log.info("Stopping the dashboard viewer process ...") + self._dashboard_viewer_process.terminate() + self._dashboard_viewer_process = None + + if self._mode == self.Mode.TRAY_MANAGER: + with self._tray_manager_lock: + SerenaDashboardTrayManager.unregister_instance(port=self._port) + + def update_active_project(self, active_project: Project | None) -> None: + """ + Updates the active project (where applicable). + + :param active_project: the currently active project or None if no project is active + """ + if self._mode == self.Mode.TRAY_MANAGER: + with self._tray_manager_lock: + project_name = active_project.project_name if active_project is not None else None + SerenaDashboardTrayManager.update_project(port=self._port, project=project_name) + + class SerenaAgent: def __init__( self, @@ -344,8 +517,9 @@ class SerenaAgent: if necessary. """ self._active_project: Project | None = None + self._project_activation_callback = project_activation_callback self._gui_log_viewer: Optional["GuiLogViewer"] = None - self._dashboard_viewer_process: multiprocessing.Process | None = None + self._dashboard_manager: DashboardManager | None = None self._project_prompt_status = ProjectPromptProvisionStatus() self._mode_overrides = modes self.version = serena_version() @@ -365,9 +539,6 @@ class SerenaAgent: self.serena_config.get_registered_project(project, autoregister=True) if project is not None else None ) - # dashboard URL (set when dashboard is started) - self._dashboard_url: str | None = None - # adjust log level serena_log_level = self.serena_config.log_level if Logger.root.level != serena_log_level: @@ -445,7 +616,6 @@ class SerenaAgent: # Initialize the prompt factory self.prompt_factory = SerenaPromptFactory() - self._project_activation_callback = project_activation_callback # activate the given project (if any), also updating the active modes # Note: We cannot update the active tools yet, because the base toolset has not been computed yet @@ -475,16 +645,17 @@ class SerenaAgent: self._dashboard_thread, port = SerenaDashboardAPI( get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats ).run_in_thread(host=self.serena_config.web_dashboard_listen_address) - dashboard_host = self.serena_config.web_dashboard_listen_address - if dashboard_host == "0.0.0.0": - dashboard_host = "localhost" - dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html" - self._dashboard_url = dashboard_url - log.info("Serena web dashboard started at %s", dashboard_url) - self._start_dashboard_viewer(minimized=not self.serena_config.web_dashboard_open_on_launch) + self._dashboard_manager = DashboardManager( + port, + self.serena_config.web_dashboard_listen_address, + self.serena_config.web_dashboard_open_on_launch, + self._active_project, + mode_str=self.serena_config.web_dashboard_interface, + ) + log.info("Serena web dashboard started at %s", self._dashboard_manager.url) # inform the GUI window (if any) if self._gui_log_viewer is not None: - self._gui_log_viewer.set_dashboard_url(dashboard_url) + self._gui_log_viewer.set_dashboard_url(self._dashboard_manager.url) self._send_usage_info() @@ -652,44 +823,9 @@ class SerenaAgent: """ :return: the URL of the web dashboard, or None if the dashboard is not running """ - return self._dashboard_url - - @staticmethod - def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None: - """ - Main function of the subprocess for starting the dashboard viewer - """ - try: - SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run() - except webview.errors.WebViewException as e: - log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}") - # Fall back to opening the browser window if the window was supposed to be shown directly - if not minimized: - SerenaAgent._open_dashboard_in_browser(url) - - def _start_dashboard_viewer(self, minimized: bool) -> None: - """ - Starts the dashboard viewer (in a separate process) or, if the current platform does not support it, - opens the dashboard in the default web browser. - - :param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform). - If the viewer is not supported on the current platform, then this controls whether to open the browser window. - """ - if not system_has_usable_display(): - log.info("Not starting the Serena dashboard viewer because no usable display was detected.") - return - - url = self.get_dashboard_url() - assert url is not None - if SerenaDashboardViewer.is_current_platform_supported(): - self._dashboard_viewer_process = multiprocessing.Process( - target=self._start_dashboard_viewer_process_function, args=(url, minimized, os.getpid()), daemon=True - ) - self._dashboard_viewer_process.start() - else: - log.info("Not starting Serena dashboard viewer because the current platform does not support it; using browser-based fallback") - if not minimized: - self._open_dashboard_in_browser(url) + if self._dashboard_manager is None: + return None + return self._dashboard_manager.url def open_dashboard(self) -> bool: """ @@ -697,27 +833,16 @@ class SerenaAgent: :return: True if the dashboard was opened, False if it could not be opened """ - if self._dashboard_url is None: + if self._dashboard_manager is None: raise Exception("Dashboard is not running.") if not system_has_usable_display(): log.warning("Not opening the Serena web dashboard because no usable display was detected.") return False - self._open_dashboard_in_browser(self._dashboard_url) + self._dashboard_manager.open_dashboard_in_browser() return True - @staticmethod - def _open_dashboard_in_browser(url: str) -> None: - # Use a subprocess to avoid any output from webbrowser.open being written to stdout - subprocess.Popen( - [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=False, - ) - def get_exposed_tool_instances(self) -> list["Tool"]: """ :return: the tool instances which are exposed (e.g. to the MCP client). @@ -754,8 +879,18 @@ class SerenaAgent: template = JinjaTemplate(prompt_template) return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names) + def create_connection_prompt(self) -> str: + """ + Returns the bootstrap prompt to be sent at MCP connection time. + + :return: the prompt + """ + return self.prompt_factory.create_connection_prompt() + def create_system_prompt(self, session_id: str = "global") -> str: """ + Returns the 'Serena Instructions Manual', i.e. Serena's system prompt. + :param session_id: the client session ID for the case where this is run from a tool; "global" for the connection time case :return: the prompt """ @@ -788,7 +923,6 @@ class SerenaAgent: if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(session_id): system_prompt += "\n\n" + self.get_project_activation_message(session_id) - log.info("System prompt:\n%s", system_prompt) return system_prompt def get_project_activation_message(self, session_id: str) -> str: @@ -971,6 +1105,10 @@ class SerenaAgent: if self._project_activation_callback is not None: self._project_activation_callback() + # notify the dashboard manager of the project change (if applicable) + if self._dashboard_manager: + self._dashboard_manager.update_active_project(self._active_project) + return True def activate_project_from_path_or_name( @@ -1114,10 +1252,9 @@ class SerenaAgent: log.info("Stopping the GUI log window ...") self._gui_log_viewer.stop() self._gui_log_viewer = None - if self._dashboard_viewer_process: - log.info("Stopping the dashboard viewer process ...") - self._dashboard_viewer_process.terminate() - self._dashboard_viewer_process = None + if self._dashboard_manager: + self._dashboard_manager.shutdown() + self._dashboard_manager = None def shutdown(self) -> None: """ diff --git a/src/serena/cli.py b/src/serena/cli.py index eb8e91fc..5e87fa2e 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -479,11 +479,10 @@ class TopLevelCommands(AutoRegisteringGroup): @click.argument("url", type=str) @click.option("--width", type=int, default=1400, show_default=True, help="Window width.") @click.option("--height", type=int, default=900, show_default=True, help="Window height.") - @click.option("--minimized", is_flag=True, default=False, help="Whether to start minimized/in tray.") - def dashboard_viewer(url: str, width: int, height: int, minimized: bool) -> None: + def dashboard_viewer(url: str, width: int, height: int) -> None: from serena.dashboard import SerenaDashboardViewer - viewer = SerenaDashboardViewer(url, start_minimized=minimized, width=width, height=height) + viewer = SerenaDashboardViewer(url, width=width, height=height) viewer.run() diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 457e0705..5c8e4e10 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -692,6 +692,7 @@ class SerenaConfig(SharedConfig): trace_lsp_communication: bool = False web_dashboard: bool = True web_dashboard_open_on_launch: bool = True + web_dashboard_interface: str | None = None web_dashboard_listen_address: str = "127.0.0.1" jetbrains_plugin_server_address: str = "127.0.0.1" tool_timeout: float = DEFAULT_TOOL_TIMEOUT diff --git a/src/serena/constants.py b/src/serena/constants.py index 4d96bcff..db4d1b35 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -32,3 +32,10 @@ SERENA_LOG_FORMAT = "%(levelname)-5s %(asctime)-15s [%(threadName)s] %(name)s:%( LOG_MESSAGES_BUFFER_SIZE = 2500 """The maximum number of log messages to keep in the buffer (for the dashboard).""" + + +class SerenaPorts: + TRAY_MANAGER_PORT = 0x5EA0 + PROJECT_SERVER_PORT = 0x5EA1 + JETBRAINS_PLUGIN_SERVER_BASE_PORT = 0x5EA2 + DASHBOARD_API_BASE_PORT = 0x5EDA diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 569b17a2..9a35806c 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -1,22 +1,29 @@ import json +import multiprocessing import os import socket +import subprocess import sys import threading +import time import urllib.error import urllib.request +from collections.abc import Callable +from dataclasses import dataclass from html import escape from pathlib import Path -from typing import TYPE_CHECKING, Any, Self +from typing import TYPE_CHECKING, Any, Optional, Self +import psutil from flask import Flask, Response, redirect, request, send_from_directory +from PIL import Image from pydantic import BaseModel from sensai.util import logging from sensai.util.pickle import dump_pickle, load_pickle from serena.analytics import ToolUsageStats from serena.config.serena_config import SerenaConfig, SerenaPaths -from serena.constants import SERENA_DASHBOARD_DIR +from serena.constants import SERENA_DASHBOARD_DIR, SerenaPorts from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler from serena.util.pywebview import WebViewWithTray @@ -180,6 +187,8 @@ class ReadNews: class SerenaDashboardAPI: + BASE_PORT = SerenaPorts.DASHBOARD_API_BASE_PORT + log = logging.getLogger(__qualname__) def __init__( @@ -773,16 +782,45 @@ class SerenaDashboardAPI: return port def run_in_thread(self, host: str) -> tuple[threading.Thread, int]: - port = self._find_first_free_port(0x5EDA, host) + port = self._find_first_free_port(self.BASE_PORT, host) log.info("Starting dashboard (listen_address=%s, port=%d)", host, port) thread = threading.Thread(target=lambda: self.run(host=host, port=port), daemon=True) thread.start() return thread, port +def open_url_in_browser(url: str, use_subprocess: bool = False) -> None: + """ + Opens the given URL in the user's default web browser, + optionally using a subprocess to ensure that no output is written to stdout + (highly problematic when run within a stdio MCP server context) + + :param url: the URL to open + :param use_subprocess: whether to use a subprocess to opening the URL, making stdio contamination impossible + """ + if use_subprocess: + # Use a subprocess to avoid any output from webbrowser.open being written to stdout + try: + subprocess.Popen( + [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=False, + ) + except Exception as e: + # Subprocess creation can fail in rare cases (e.g. on some Linux systems; possibly subprocess/glibc bug) + # See #1363 + log.error("Failed to open URL (%s) in subprocess; %s", url, e) + else: + import webbrowser + + webbrowser.open(url) + + class SerenaDashboardViewer(WebViewWithTray): """ - Minimal pywebview wrapper with optional system tray. + Minimal pywebview wrapper that opens a dashboard in a native window with optional system tray. """ DEBUG = False @@ -797,6 +835,15 @@ class SerenaDashboardViewer(WebViewWithTray): parent_process_id: int | None = None, tray: bool = True, ): + """ + :param url: the URL to open + :param width: the width of the window + :param height: the height of the window + :param start_minimized: whether to start the window minimized (to the tray if tray is enabled) + :param parent_process_id: the process ID of the parent Serena agent process, which is monitored for termination, + automatically closing the dashboard when the parent process dies + :param tray: whether to use a system tray icon (which the app minimizes to when the window is closed) + """ dashboard_path = Path(SERENA_DASHBOARD_DIR) # .ico is Windows-only; macOS expects a PNG for the window/dock icon. @@ -822,21 +869,426 @@ class SerenaDashboardViewer(WebViewWithTray): @staticmethod def is_current_platform_supported() -> bool: """ - :return: whether the current platform supports the dashboard viewer + :return: whether the current platform supports the native dashboard viewer (and tray manager). """ - # The dashboard viewer (with system tray) is technically supported only on Windows and macOS. - # Linux support is problematic; see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943 + # supported on Windows and macOS; Linux support is problematic + # (see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943) supported_platforms = [ "win32", - # NOTE: Disabling macOS support for now, because the tray behaviour is suboptimal (too many icons when - # subagents are spawned, etc.) - # "darwin" + "darwin", ] return sys.platform in supported_platforms def run(self) -> None: if self.DEBUG: - logging.configure(level=logging.DEBUG) + logging.configure(level=logging.DEBUG, stream=sys.stderr) logging.add_file_logger(SerenaPaths().get_next_log_file_path("dashboard-viewer")) super().run() + + +@dataclass +class TrayManagedInstance: + """A registered Serena dashboard instance managed by the tray manager.""" + + port: int + """the port on which the dashboard API is listening""" + + parent_process: psutil.Process + """the process of the Serena agent owning this dashboard instance""" + + dashboard_url: str + """the full URL to the dashboard frontend""" + + project: str | None + """the name of the active project, or None if no project is activated""" + + started_at: str + """ISO 8601 timestamp of when the agent instance was started""" + + +class SerenaDashboardTrayManager: + """ + Singleton process managing a system tray icon for all Serena dashboard instances. + + Runs a Flask backend on a fixed port and displays a single tray icon that + aggregates all running Serena instances. Individual dashboard viewers are + spawned on demand when the user clicks a menu item. + + The manager is started as a detached process by the first Serena agent that + needs it and terminates automatically when no dashboard instances remain. + """ + + DEBUG = False + + PORT = SerenaPorts.TRAY_MANAGER_PORT + + HOST = "127.0.0.1" + """listen address (local only)""" + + ALIVE_CHECK_INTERVAL_SECONDS = 3 + """interval in seconds between alive checks of registered instances""" + + def __init__(self, use_pywebview: bool = False, alive_check_use_pid: bool = True) -> None: + """ + :param use_pywebview: whether to use pywebview-based viewer applications (separate child processes) + for opening dashboards; if False, open them directly in the user's default web browser. + :param alive_check_use_pid: whether to use the process ID for alive checks of registered instances. + If True, the manager will check whether the process with the registered PID is still running. + If False, the manager will perform an HTTP request to the instance's heartbeat endpoint to check + if it's alive. + """ + import pystray + + self._instances: dict[int, TrayManagedInstance] = {} + self._lock = threading.Lock() + self._tray_icon: Optional["pystray.Icon"] = None + self._alive_check_use_pid = alive_check_use_pid + self._app = Flask(__name__) + self._setup_routes() + self._use_pywebview = use_pywebview + + def _setup_routes(self) -> None: + @self._app.route("/health", methods=["GET"]) + def health() -> dict[str, str]: + return {"status": "alive"} + + @self._app.route("/register", methods=["POST"]) + def register() -> dict[str, str]: + data = request.get_json() + instance = TrayManagedInstance( + port=data["port"], + parent_process=psutil.Process(data["pid"]), + dashboard_url=data["dashboard_url"], + project=data.get("project"), + started_at=data["started_at"], + ) + log.info("Registering instance on port %d (project=%s)", instance.port, instance.project) + with self._lock: + self._instances[instance.port] = instance + self._update_menu() + + # open a viewer immediately if requested + if data.get("open_viewer", False): + self._open_dashboard(instance) + + return {"status": "registered"} + + @self._app.route("/update_project", methods=["POST"]) + def update_project() -> dict[str, str]: + data = request.get_json() + port = data["port"] + project = data.get("project") + with self._lock: + if port in self._instances: + self._instances[port].project = project + self._update_menu() + log.info("Updated project for instance on port %d to '%s'", port, project) + return {"status": "updated"} + + @self._app.route("/unregister", methods=["POST"]) + def unregister() -> dict[str, str]: + data = request.get_json() + port = data["port"] + with self._lock: + self._instances.pop(port, None) + self._update_menu() + log.info("Unregistered instance on port %d", port) + return {"status": "unregistered"} + + def _update_menu(self) -> None: + if self._tray_icon: + self._tray_icon.update_menu() + + def _build_menu_items(self) -> tuple[Any, ...]: + """ + Callable that returns the current tray menu items. + + Invoked dynamically by pystray each time the menu is shown. + When there is exactly one instance, it is marked as the default action + so that a left-click on the tray icon opens the viewer immediately. + When there are multiple instances, a hidden default item forces the menu + to appear on left-click (Windows only; macOS always shows the menu). + """ + from pystray import MenuItem as Item + + with self._lock: + instances = list(self._instances.values()) + + if not instances: + return (Item("No instances", None, enabled=False),) + + # determine whether a single-instance shortcut applies + is_single = len(instances) == 1 + + items: list[Any] = [] + + # for multi-instance: add a hidden default item that opens the menu on left-click + if not is_single: + + def _force_show_menu(icon: Any, _item: Any) -> None: + if hasattr(icon, "_show_menu"): + icon._show_menu() + + items.append(Item("Instances", _force_show_menu, default=True, visible=False)) + + for inst in sorted(instances, key=lambda i: i.started_at): + label = f"{inst.project or 'SerenaAgent'} ({inst.started_at})" + + # closure to capture the current instance + def _make_callback(instance: TrayManagedInstance) -> Callable: + def _callback(_icon: Any, _item: Any) -> None: + self._open_dashboard(instance) + + return _callback + + items.append(Item(label, _make_callback(inst), default=is_single)) + + return tuple(items) + + def _open_dashboard(self, instance: TrayManagedInstance) -> None: + """Opens the dashboard of the given instance.""" + if self._use_pywebview: + # spawn pywebview process showing the dashboard + process = multiprocessing.Process( + target=self._run_viewer, + args=(instance.dashboard_url,), + daemon=True, + ) + process.start() + else: + open_url_in_browser(instance.dashboard_url) + + @staticmethod + def _run_viewer(url: str) -> None: + """Process target for running a dashboard viewer window.""" + SerenaDashboardViewer(url, tray=False, start_minimized=False).run() + + def _alive_check_loop(self) -> None: + """Periodically check whether registered instances are still reachable. + + Removes unreachable instances and terminates the manager when none remain. + """ + time.sleep(max(1, 5 - self.ALIVE_CHECK_INTERVAL_SECONDS)) # initial delay to allow instances to start and register + + while True: + time.sleep(self.ALIVE_CHECK_INTERVAL_SECONDS) + + try: + dead_ports: list[int] = [] + + if self._alive_check_use_pid: + with self._lock: + ports_and_processes = [(port, inst.parent_process) for port, inst in self._instances.items()] + + dead_ports = [] + for port, process in ports_and_processes: + is_dead = False + try: + if not process.is_running(): + is_dead = True + except psutil.NoSuchProcess: + # parent process already exited + is_dead = True + if is_dead: + dead_ports.append(port) + else: + # collect ports to check + with self._lock: + ports_to_check = list(self._instances.keys()) + + # probe each instance + for port in ports_to_check: + try: + url = f"http://127.0.0.1:{port}/heartbeat" + req = urllib.request.Request(url, method="GET") + urllib.request.urlopen(req, timeout=1) + except Exception: + dead_ports.append(port) + + # remove dead instances + if dead_ports: + with self._lock: + for port in dead_ports: + self._instances.pop(port, None) + log.info("Removed unreachable instance on port %d", port) + + # terminate if no instances remain + with self._lock: + remaining = len(self._instances) + if remaining == 0: + log.info("No dashboard instances remaining; shutting down tray manager") + if self._tray_icon is not None: + self._tray_icon.stop() + return + + except Exception as e: + log.error("Error during alive check loop: %s", e, exc_info=e) + + def run(self) -> None: + """Run the tray manager (blocking). Starts Flask, alive-check thread, and tray icon.""" + import pystray + + if self.DEBUG: + logging.configure(level=logging.DEBUG, stream=sys.stderr) + logging.add_file_logger(SerenaPaths().get_next_log_file_path("tray-manager")) + + log.info("Starting tray manager") + + dashboard_path = Path(SERENA_DASHBOARD_DIR) + + # select the appropriate icon for the platform + icon_filename = "serena-icon-tray-mac.png" if sys.platform == "darwin" else "serena-icon-48.png" + icon_img = Image.open(dashboard_path / icon_filename) + + # start Flask in a background thread + flask_thread = threading.Thread( + target=lambda: self._app.run(host=self.HOST, port=self.PORT, debug=False, use_reloader=False, threaded=True), + daemon=True, + ) + flask_thread.start() + + # start alive-check in a background thread + alive_thread = threading.Thread(target=self._alive_check_loop, daemon=True) + alive_thread.start() + + # set up tray icon with a dynamic menu (callable returns items on each open) + kwargs: dict[str, Any] = {} + if sys.platform == "darwin": + from AppKit import NSApplication + + kwargs["darwin_nsapplication"] = NSApplication.sharedApplication() + + self._tray_icon = pystray.Icon( + "serena_tray_manager", + icon_img, + "Serena", + menu=pystray.Menu(self._build_menu_items), + **kwargs, + ) + + # blocks until stop() is called + self._tray_icon.run() + + # ------------------------------------------------------------------ + # Class-level helpers (used by agents to interact with the manager) + # ------------------------------------------------------------------ + + @classmethod + def is_current_platform_supported(cls) -> bool: + """ + :return: whether the current platform supports the tray manager + """ + # supported_platforms = ["win32", "darwin"] + # return sys.platform in supported_platforms + # TODO: Enable all platforms for the time being for users to try out (only Windows tested) + return True + + @classmethod + def is_running(cls) -> bool: + """ + :return: True if a tray manager process is already listening on the fixed port + """ + try: + url = f"http://{cls.HOST}:{cls.PORT}/health" + req = urllib.request.Request(url, method="GET") + urllib.request.urlopen(req, timeout=2) + return True + except Exception: + return False + + @classmethod + def ensure_running(cls) -> None: + """Ensure a tray manager process is running, starting one if necessary.""" + log.info("Ensuring dashboard tray manager availability") + if cls.is_running(): + log.info("Dashboard tray manager is already running") + return + + # spawn a detached process + log.info("Starting new dashboard tray manager process") + cmd = [ + sys.executable, + "-c", + "from serena.dashboard import SerenaDashboardTrayManager; SerenaDashboardTrayManager().run()", + ] + kwargs: dict[str, Any] = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if sys.platform == "win32": + # CREATE_NO_WINDOW suppresses the console; CREATE_NEW_PROCESS_GROUP + # isolates the child from the parent's Ctrl+C group. + # Note: DETACHED_PROCESS must NOT be combined with CREATE_NO_WINDOW. + CREATE_NEW_PROCESS_GROUP = 0x00000200 + CREATE_NO_WINDOW = 0x08000000 + kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW + else: + kwargs["start_new_session"] = True + + subprocess.Popen(cmd, **kwargs) + + # wait for the manager to become available + for _ in range(30): + time.sleep(0.1) + if cls.is_running(): + log.info("Dashboard tray manager started successfully") + return + log.warning("Dashboard tray manager did not start within the expected time") + + @classmethod + def register_instance(cls, port: int, dashboard_url: str, project: str | None, started_at: str, open_viewer: bool = False) -> None: + """Register a dashboard instance with the running tray manager. + + :param port: the port of the dashboard API (used for alive checks) + :param dashboard_url: the full URL to the dashboard frontend + :param project: the currently active project name, or None + :param started_at: ISO 8601 timestamp of when the agent was started + :param open_viewer: whether the tray manager should immediately open a viewer for this instance + """ + url = f"http://{cls.HOST}:{cls.PORT}/register" + data = json.dumps( + { + "port": port, + "pid": os.getpid(), + "dashboard_url": dashboard_url, + "project": project, + "started_at": started_at, + "open_viewer": open_viewer, + } + ).encode() + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + try: + urllib.request.urlopen(req, timeout=2) + except Exception as e: + log.warning("Failed to register with tray manager: %s", e) + + @classmethod + def update_project(cls, port: int, project: str | None) -> None: + """Notify the tray manager of a project change for the given instance. + + :param port: the port of the dashboard API + :param project: the new active project name, or None + """ + url = f"http://{cls.HOST}:{cls.PORT}/update_project" + data = json.dumps({"port": port, "project": project}).encode() + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + try: + urllib.request.urlopen(req, timeout=2) + except Exception as e: + log.warning("Failed to update project with tray manager: %s", e) + + @classmethod + def unregister_instance(cls, port: int) -> None: + """Unregister a dashboard instance from the tray manager. + + :param port: the port of the dashboard API to unregister + """ + url = f"http://{cls.HOST}:{cls.PORT}/unregister" + data = json.dumps({"port": port}).encode() + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + try: + urllib.request.urlopen(req, timeout=2) + except Exception as e: + log.warning("Failed to unregister from tray manager: %s", e) diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index 9129318c..43f866d7 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -14,23 +14,14 @@ class PromptFactory(PromptFactoryBase): A class for retrieving and rendering prompt templates and prompt lists. """ + def create_info_jet_brains_debug_repl(self) -> str: + return self._render_prompt("info_jet_brains_debug_repl", locals()) + def create_onboarding_prompt(self, *, system: Any) -> str: return self._render_prompt("onboarding_prompt", locals()) - def create_think_about_collected_information(self) -> str: - return self._render_prompt("think_about_collected_information", locals()) - - def create_think_about_task_adherence(self) -> str: - return self._render_prompt("think_about_task_adherence", locals()) - - def create_think_about_whether_you_are_done(self) -> str: - return self._render_prompt("think_about_whether_you_are_done", locals()) - - def create_summarize_changes(self) -> str: - return self._render_prompt("summarize_changes", locals()) - - def create_prepare_for_new_conversation(self) -> str: - return self._render_prompt("prepare_for_new_conversation", locals()) + def create_connection_prompt(self) -> str: + return self._render_prompt("connection_prompt", locals()) def create_system_prompt( self, diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 810618ba..263bc8a9 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -18,6 +18,7 @@ from sensai.util.string import ToStringMixin import serena.jetbrains.jetbrains_types as jb from serena.config.serena_config import RegisteredProject +from serena.constants import SerenaPorts from serena.jetbrains.jetbrains_types import PluginStatusDTO from serena.project import Project from serena.util.class_decorators import singleton @@ -174,7 +175,7 @@ class JetBrainsPluginClient(ToStringMixin): Provides simple methods to interact with all available endpoints. """ - BASE_PORT = 0x5EA2 + BASE_PORT = SerenaPorts.JETBRAINS_PLUGIN_SERVER_BASE_PORT PLUGIN_REQUEST_TIMEOUT = 300 """ the timeout used for request handling within the plugin (a constant in the plugin) @@ -632,6 +633,34 @@ class JetBrainsPluginClient(ToStringMixin): self._postprocess_symbol_collection_response(symbol_collection) return symbol_collection + def debug_eval(self, repl_key: str, expression: str) -> dict[str, Any]: + """ + Evaluates a Groovy expression in the persistent debug REPL. + + :param repl_key: the session key identifying the REPL instance + :param expression: the Groovy expression to evaluate + :return: the response containing REPL key and result + """ + self._require_version_at_least(2023, 2, 16) + request_data = { + "replKey": repl_key, + "expression": expression, + } + return self._make_request("POST", "/debugReplEval", request_data) + + def debug_close(self, repl_key: str) -> dict[str, Any]: + """ + Closes the debug REPL for the given session key, clearing all state. + + :param repl_key: the key identifying the REPL instance to close + :return: the status response + """ + self._require_version_at_least(2023, 2, 16) + request_data = { + "replKey": repl_key, + } + return self._make_request("POST", "/debugReplClose", request_data) + def close(self) -> None: self._session.close() diff --git a/src/serena/mcp.py b/src/serena/mcp.py index a8252846..13b9a00c 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -332,6 +332,7 @@ class SerenaMCPFactory: # retain only FASTMCP_ prefix for already set environment variables. Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_") instructions = self._get_initial_instructions() + log.info("MCP server initial instructions:\n%s", instructions) mcp = FastMCP( name="Serena", lifespan=self.server_lifespan, @@ -357,4 +358,4 @@ class SerenaMCPFactory: def _get_initial_instructions(self) -> str: assert self.agent is not None - return self.agent.create_system_prompt() + return self.agent.create_connection_prompt() diff --git a/src/serena/project_server.py b/src/serena/project_server.py index a227dc3b..bd6c7a79 100644 --- a/src/serena/project_server.py +++ b/src/serena/project_server.py @@ -8,7 +8,7 @@ from pydantic import BaseModel from sensai.util.logging import LogTime from serena.config.serena_config import LanguageBackend, SerenaConfig -from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient +from serena.constants import SerenaPorts if TYPE_CHECKING: from serena.project import Project @@ -42,7 +42,7 @@ class ProjectServer: :class:`~serena.tools.query_project_tools.QueryProjectTool`. """ - PORT = JetBrainsPluginClient.BASE_PORT - 1 + PORT = SerenaPorts.PROJECT_SERVER_PORT def __init__(self) -> None: from serena.agent import SerenaAgent diff --git a/src/serena/resources/config/contexts/claude-code.yml b/src/serena/resources/config/contexts/claude-code.yml index b6d632a7..fe63878b 100644 --- a/src/serena/resources/config/contexts/claude-code.yml +++ b/src/serena/resources/config/contexts/claude-code.yml @@ -7,16 +7,15 @@ prompt: | **IMPORTANT**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them right now. For exploring and reading code in a token-efficient manner, use Serena's code-centric overview and symbolic search tools. - For edits, where it makes sense, use Serena's editing tools since they are more token efficient. - Use your own editing tools only if Serena's editing tools would be suboptimal. - **IMPORTANT**: Never use a tool before reading its schema via the tool-search tool! + For edits, prefer Serena's editing tools since they are more token efficient. + Especially when editing code you have read through Serena, ALWAYS use Serena's tools for editing, because your built-in tools will deny such edits (they will assume you haven't read the content). + **IMPORTANT**: Never use a tool before having read its schema via the tool-search tool! excluded_tools: - create_text_file - read_file - execute_shell_command - - replace_content - find_file - list_dir - search_for_pattern diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml index a08b189f..34baf6ce 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -23,3 +23,5 @@ included_optional_tools: - jet_brains_type_hierarchy - jet_brains_rename - jet_brains_safe_delete + - jet_brains_debug + - serena_info diff --git a/src/serena/resources/config/prompt_templates/info_prompts.yml b/src/serena/resources/config/prompt_templates/info_prompts.yml new file mode 100644 index 00000000..4c896429 --- /dev/null +++ b/src/serena/resources/config/prompt_templates/info_prompts.yml @@ -0,0 +1,64 @@ +# Defines prompts the SerenaInfoTool +prompts: + info_jet_brains_debug_repl: | + Note: The debugger uses 0-based line numbers like other Serena tools (while humans typically communicate 1-based ones). + You rely on the user to provide suitable run configurations for you to work with. + + State (objects, variables assigned) persists across calls within the same repl_key. + + Pre-bound variables: ``debug`` (debugger interface) + + Run configurations: + debug.listRunConfigs() + session = debug.startDebug("MyTestRun") # returns a DebugSession + debug.startRun("MyApp") # non-debug run, fire-and-forget + + Session discovery: + debug.sessions() + session = debug.currentSession() + session = debug.session(id) + + Breakpoints (project-global, set before or during debugging): + debug.addBreakpoint(file, line) + debug.addBreakpoint(file, line, condition) + debug.addLogpoint(file, line, logExpression) + debug.listBreakpoints() + debug.removeBreakpointAt(file, line) + + Execution control (on a DebugSession, e.g. ``session``):: + session.stepOver() | .stepInto() | .stepOut() + session.resume() | .pause() | .stop() + session.runToLine(file, line) + + Wait for pause (blocks until the session pauses or times out): + session.waitForPause(timeoutSeconds) + session.stepOverAndWait(timeoutSeconds) # step + wait in one call + session.resumeAndWait(timeoutSeconds) + + Inspection (when paused): + session.status() # location + source + variable values in one call + session.variables() # overview of locals/args (values may be truncated) + session.variable(name) # single variable (VariableInfo instance) + session.frames() | .frame() # stack trace | current frame + session.sourceContext(lines) # source around current line + session.threads() + + VariableInfo members: name, value (String as presented by the debugger), type + + Evaluation & modification: + session.eval("expr") # evaluate in current frame + session.setVar("x", "42") # set variable via assignment + + Example sequence of expressions one might execute in a REPL: + debug.addBreakpoint("src/main/java/Foo.java", 42) + session = debug.startDebug("MyTest") + session.waitForPause(30) + session.status() + + You can pass multiple statements at once; the result of the final expression is returned: + session = debug.startDebug("MyTest"); session.waitForPause(30); session.status() + + Control flow statements are also possible: + while (session.variable("count").value.equals("0")) { session.stepOverAndWait(5) } + + When done, close the REPL by passing an empty expression. diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index debefcd2..426ba0be 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -2,6 +2,8 @@ # in which case this prompt will be given as a regular message on the call of a simple tool which the agent # is encouraged (via the tool description) to call at the beginning of the conversation. prompts: + connection_prompt: | + CRITICAL: Before starting to work on a coding task, call the `initial_instructions` tool to read the 'Serena Instructions Manual'. system_prompt: | You have access to semantic coding tools upon which you rely heavily for all your work. You operate in a resource-efficient and intelligent manner, always keeping in mind to not read or generate @@ -41,7 +43,9 @@ prompts: The following global (not project-specific) memories are available to you: {{ global_memories_list }} {%- endif -%} {%- endif %} - + + Line numbers returned by Serena's tools are 0-based! + The context and modes of operation are described below. These determine how to interact with your user and which kinds of interactions are expected of you. diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index df53868e..e48ff3c2 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -485,32 +485,9 @@ class Dashboard { // Initialize the application this.loadToolNames().then(function () { - // Start on overview page self.loadNews(); - self.loadConfigOverview(); - self.startConfigPolling(); - self.startExecutionsPolling(); - }); - // Initialize heartbeat interval - setInterval(this.heartbeat.bind(this), 250); - } - - heartbeat() { - let self = this; - $.ajax({ - url: '/heartbeat', - type: 'GET', - success: function (response) { - self.heartbeatFailureCount = 0; - }, - error: function (xhr, status, error) { - self.heartbeatFailureCount++; - console.error('Heartbeat failure; count = ', self.heartbeatFailureCount); - if (self.heartbeatFailureCount >= 1) { - console.log('Server appears to be down, closing tab'); - window.close(); - } - }, + // start on overview page + self.navigateToPage("overview"); }); } @@ -541,7 +518,6 @@ class Dashboard { // Start appropriate polling for the page if (page === 'overview') { this.loadNews(); - this.loadConfigOverview(); this.startConfigPolling(); this.startExecutionsPolling(); } else if (page === 'logs') { @@ -599,7 +575,8 @@ class Dashboard { } else { console.log('Config unchanged, skipping display update'); } - }, error: function (xhr, status, error) { + }, + error: function (xhr, status, error) { console.error('Error loading config overview:', error); self.$configDisplay.html('
Error loading configuration
'); self.$basicStatsDisplay.html('
Error loading stats
'); @@ -607,24 +584,23 @@ class Dashboard { self.$availableToolsDisplay.html('
Error loading tools
'); self.$availableModesDisplay.html('
Error loading modes
'); self.$availableContextsDisplay.html('
Error loading contexts
'); - }, complete: function () { + }, + complete: function () { self.waitingForConfigPollingResult = false; } }); } startConfigPolling() { + this.loadConfigOverview(); this.configPollInterval = setInterval(this.loadConfigOverview.bind(this), 1000); } startExecutionsPolling() { + this.loadExecutions() // Poll every 1 second for executions (independent of config polling) // This ensures stuck executions can still be cancelled even if config polling is blocked - this.loadExecutions() - this.executionsPollInterval = setInterval(() => { - this.loadQueuedExecutions(); - this.loadLastExecution(); - }, 1000); + this.executionsPollInterval = setInterval(this.loadExecutions.bind(this), 1000); } displayConfig(config) { @@ -905,26 +881,30 @@ class Dashboard { // ===== Executions Methods ===== - loadQueuedExecutions() { + loadQueuedExecutions(onComplete) { let self = this; $.ajax({ - url: '/queued_task_executions', type: 'GET', success: function (response) { + url: '/queued_task_executions', type: 'GET', + success: function (response) { if (response.status === 'success') { self.displayActiveExecutionsQueue(response.queued_executions || []); } else { console.error('Error loading executions:', response.message); } - }, error: function (xhr, status, error) { + }, + error: function (xhr, status, error) { console.error('Error loading executions:', error); self.$activeExecutionQueueDisplay.html('
Error loading executions
'); - } + }, + complete: onComplete }); } - loadLastExecution() { + loadLastExecution(onComplete) { let self = this; $.ajax({ - url: '/last_execution', type: 'GET', success: function (response) { + url: '/last_execution', type: 'GET', + success: function (response) { if (response.status === 'success') { if (response.last_execution !== null && response.last_execution.logged) { self.displayLastExecution(response.last_execution); @@ -932,21 +912,28 @@ class Dashboard { } else { console.error('Error loading last execution:', response.message); } - }, error: function (xhr, status, error) { + }, + error: function (xhr, status, error) { console.error('Error loading last execution:', error); self.$lastExecutionDisplay.html('
Error loading last execution
'); - } + }, + complete: onComplete }); } loadExecutions() { + const self = this; if (this.waitingForExecutionsPollingResult) { console.log('Still waiting for previous executions poll result, skipping this poll'); - } else { + } + else { this.waitingForExecutionsPollingResult = true; console.log('Polling for executions...'); - this.loadQueuedExecutions(); - this.loadLastExecution(); + this.loadQueuedExecutions(function() { + self.loadLastExecution(function() { + self.waitingForExecutionsPollingResult = false; + }); + }); } } diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index a823c743..18974358 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -46,6 +46,21 @@ web_dashboard: True # See also: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard_open_on_launch: True +# defines the interface (application mode) used for the web dashboard (if enabled). +# If empty/null, use platform-dependent default. Otherwise, possible values: +# * browser: the dashboard is opened in the default browser (if `web_dashboard_open_on_launch` is true) +# This is supported on all platforms. +# * app: the dashboard is opened in a separate native-like app window with accompanying tray icon, whose +# lifecycle is tied to the Serena process. +# If `web_dashboard_open_on_launch` is false, the dashboard can be conveniently accessed via the tray icon. +# This is supported on Windows and macOS, but note that on macOS, where tray icons are very visible, +# this may result in too many icons being displayed when using multi-agent setups. +# * tray_manager: use a global tray icon to provide access to the dashboards of all running Serena instances, +# opening the dashboard in browser tabs when selected from the tray menu. +# This is EXPERIMENTAL. It is tested on Windows only. We will establish macOS support, but it is yet untested. +# On Linux, this cannot be universally supported, but it may work in some desktop environments. +web_dashboard_interface: + # the address the web dashboard will listen on (bind address). web_dashboard_listen_address: 127.0.0.1 diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 5f27ddf9..4e7268e0 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -347,7 +347,6 @@ class SearchForPatternTool(Tool): is used to further restrict the search). Smartly combining the various restrictions allows you to perform very targeted searches. - :param substring_pattern: Regular expression for a substring pattern to search for :param context_lines_before: Number of lines of context to include before each match :param context_lines_after: Number of lines of context to include after each match @@ -373,7 +372,7 @@ class SearchForPatternTool(Tool): For example, for finding classes or methods from a name pattern. Setting to False is a better choice if you also want to search in non-code files, like in html or yaml files, which is why it is the default. - :return: A mapping of file paths to lists of matched consecutive lines. + :return: A mapping from file paths to matched consecutive lines (0-based line numbers). """ abs_path = os.path.join(self.get_project_root(), relative_path) if not os.path.exists(abs_path): @@ -411,6 +410,7 @@ class SearchForPatternTool(Tool): paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, ) + # group matches by file file_to_matches: dict[str, list[str]] = defaultdict(list) for match in matches: diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 00d0adbd..0664fb03 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -565,3 +565,33 @@ class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional): rename_in_text_occurrences=rename_in_text_occurrences, ) return self._to_json(result) + + +class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta): + """ + Provides debugging functionality (run configs, breakpoints, stepping, inspection, and evaluation) + via a persistent debug REPL connected to the JetBrains IDE. + """ + + def apply( + self, + expression: str, + repl_key: str = "default", + ) -> str: + """ + Debug code interactively by evaluating Groovy expressions in a persistent REPL. + Important: Debugging should only be applied if the user has requested it! + + Use the `serena_info` tool with topic `jet_brains_debug_repl` for usage information. + + :param expression: a Groovy/Java expression/statement to evaluate in the REPL. + If empty/null, closes the REPL with the given key. + :param repl_key: identifier for the REPL instance. State persists across calls with the same key. + :return: string representation of the result + """ + with JetBrainsPluginClient.from_project(self.project) as client: + if expression: + response = client.debug_eval(repl_key=repl_key, expression=expression) + else: + response = client.debug_close(repl_key=repl_key) + return response.get("result", str(response)) diff --git a/src/serena/tools/workflow_tools.py b/src/serena/tools/workflow_tools.py index e50ec9b5..461da9ea 100644 --- a/src/serena/tools/workflow_tools.py +++ b/src/serena/tools/workflow_tools.py @@ -4,7 +4,7 @@ Tools supporting the general workflow of the agent import platform -from serena.tools import ReadMemoryTool, Tool, ToolMarkerDoesNotRequireActiveProject, WriteMemoryTool +from serena.tools import ReadMemoryTool, Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional, WriteMemoryTool class CheckOnboardingPerformedTool(Tool): @@ -72,3 +72,20 @@ class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject): as it will critically inform you! """ return self.agent.create_system_prompt(session_id=session_id) + + +class SerenaInfoTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): + """ + Provides information about an advanced topic on demand, facilitating context-efficiency. + """ + + def apply(self, topic: str) -> str: + """ + Retrieves Serena-specific information + :param topic: the topic, which you must have been given explicitly + """ + match topic: + case "jet_brains_debug_repl": + return self.agent.prompt_factory.create_info_jet_brains_debug_repl() + case _: + raise ValueError("Invalid topic: " + topic) diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 20c7b0b7..523ba294 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -188,17 +188,16 @@ def search_text( end_pos = match.end() # Find the line numbers for the start and end positions - start_line_num = content[:start_pos].count("\n") + 1 - end_line_num = content[:end_pos].count("\n") + 1 + start_line_num = content[:start_pos].count("\n") + end_line_num = content[:end_pos].count("\n") # Calculate the range of lines to include in the context - context_start = max(1, start_line_num - context_lines_before) - context_end = min(total_lines, end_line_num + context_lines_after) + context_start = max(0, start_line_num - context_lines_before) + context_end = min(total_lines - 1, end_line_num + context_lines_after) # Create TextLine objects for the context context_lines = [] - for i in range(context_start - 1, context_end): - line_num = i + 1 + for line_num in range(context_start, context_end + 1): if context_start <= line_num < start_line_num: match_type = LineType.BEFORE_MATCH elif end_line_num < line_num <= context_end: @@ -206,7 +205,7 @@ def search_text( else: match_type = LineType.MATCH - context_lines.append(TextLine(line_number=line_num, line_content=lines[i], match_type=match_type)) + context_lines.append(TextLine(line_number=line_num, line_content=lines[line_num], match_type=match_type)) matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) else: @@ -215,7 +214,6 @@ def search_text( # Search line by line, normal compile without DOTALL compiled_pattern = re.compile(pattern) for i, line in enumerate(lines): - line_num = i + 1 if compiled_pattern.search(line): # Calculate the range of lines to include in the context context_start = max(0, i - context_lines_before) @@ -224,7 +222,6 @@ def search_text( # Create TextLine objects for the context context_lines = [] for j in range(context_start, context_end + 1): - context_line_num = j + 1 if j < i: match_type = LineType.BEFORE_MATCH elif j > i: @@ -232,7 +229,7 @@ def search_text( else: match_type = LineType.MATCH - context_lines.append(TextLine(line_number=context_line_num, line_content=lines[j], match_type=match_type)) + context_lines.append(TextLine(line_number=j, line_content=lines[j], match_type=match_type)) matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 688da5e8..5ca86256 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -360,6 +360,28 @@ class ElixirTools(SolidLanguageServer): self.server.notify.initialized({}) + # Expert's build pipeline is triggered by a textDocument/didOpen notification. + # Without it Expert sits idle and never emits the $/progress signals we wait for, + # causing a deadlock. Opening mix.exs is the minimal trigger; we close it again + # once the server is ready so it does not linger in the open-file set. + mix_exs_path = os.path.join(self.repository_root_path, "mix.exs") + mix_exs_uri = pathlib.Path(mix_exs_path).as_uri() if os.path.exists(mix_exs_path) else None + + if mix_exs_uri is not None: + with open(mix_exs_path, encoding="utf-8") as f: + mix_exs_content = f.read() + self.server.notify.did_open_text_document( + { + "textDocument": { + "uri": mix_exs_uri, + "languageId": "elixir", + "version": 1, + "text": mix_exs_content, + } + } + ) + log.debug("Opened mix.exs to trigger Expert's build pipeline") + # Expert needs time to compile the project and build indexes on first run. # This can take 2-3+ minutes for mid-sized codebases. # After the first run, subsequent startups are much faster. @@ -370,3 +392,7 @@ class ElixirTools(SolidLanguageServer): else: log.warning(f"Expert did not signal readiness within {ready_timeout}s. Proceeding with requests anyway.") self.server_ready.set() # Mark as ready anyway to allow requests + + if mix_exs_uri is not None: + self.server.notify.did_close_text_document({"textDocument": {"uri": mix_exs_uri}}) + log.debug("Closed mix.exs after Expert is ready") diff --git a/src/solidlsp/language_servers/json_language_server.py b/src/solidlsp/language_servers/json_language_server.py new file mode 100644 index 00000000..bf46992a --- /dev/null +++ b/src/solidlsp/language_servers/json_language_server.py @@ -0,0 +1,164 @@ +""" +Provides JSON specific instantiation of the LanguageServer class using vscode-json-languageserver. +Contains various configurations and settings specific to JSON files. +""" + +import logging +import os +import pathlib +import shutil +from typing import Any + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class JsonLanguageServer(SolidLanguageServer): + """ + Provides JSON specific instantiation of the LanguageServer class using vscode-json-languageserver. + Contains various configurations and settings specific to JSON files. + + Note: Cross-file references are not supported for JSON (the language server only provides + document symbols and hover). JSON is useful for getting a structured overview of JSON files + and navigating their contents. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a JsonLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + "json", + solidlsp_settings, + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for JSON Language Server and return the path to the executable. + """ + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + json_language_server_version = self._custom_settings.get("json_language_server_version", "1.3.4") + npm_registry = self._custom_settings.get("npm_registry") + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="vscode-json-languageserver", + description="vscode-json-languageserver package (Microsoft)", + command=build_npm_install_command("vscode-json-languageserver", json_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + + json_ls_dir = os.path.join(self._ls_resources_dir, "json-lsp") + json_executable_path = os.path.join(json_ls_dir, "node_modules", ".bin", "vscode-json-languageserver") + + if os.name == "nt": + json_executable_path += ".cmd" + + if not os.path.exists(json_executable_path): + log.info(f"JSON Language Server executable not found at {json_executable_path}. Installing...") + deps.install(json_ls_dir) + log.info("JSON language server dependencies installed successfully") + + if not os.path.exists(json_executable_path): + raise FileNotFoundError( + f"vscode-json-languageserver executable not found at {json_executable_path}, something went wrong with the installation." + ) + + return json_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the JSON Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params # type: ignore + + def _start_server(self) -> None: + """ + Starts the JSON Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + + def register_capability_handler(params: Any) -> None: + return + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting JSON server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from JSON server: {init_response}") + + if "documentSymbolProvider" in init_response.get("capabilities", {}): + log.info("JSON server supports document symbols") + else: + log.warning("Warning: JSON server does not report document symbol support") + + self.server.notify.initialized({}) + + log.info("JSON server initialization complete") diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index ff137894..eb527dc3 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -122,6 +122,12 @@ class Language(str, Enum): """YAML language server (experimental). Must be explicitly specified as the main language, not auto-detected. """ + JSON = "json" + """JSON language server using vscode-json-languageserver (experimental). + Provides document symbol navigation and hover for JSON files. + Must be explicitly specified as the main language, not auto-detected. + Requires Node.js and npm. + """ TOML = "toml" """TOML language server using Taplo. Supports TOML validation, formatting, and schema support. @@ -174,6 +180,7 @@ class Language(str, Enum): self.PHP_PHPACTOR, self.MARKDOWN, self.YAML, + self.JSON, self.TOML, self.GROOVY, self.CPP_CCLS, @@ -252,6 +259,8 @@ class Language(str, Enum): return FilenameMatcher("*.cr") case self.YAML: return FilenameMatcher("*.yaml", "*.yml") + case self.JSON: + return FilenameMatcher("*.json", "*.jsonc") case self.TOML: return FilenameMatcher("*.toml") case self.ZIG: @@ -446,6 +455,10 @@ class Language(str, Enum): from solidlsp.language_servers.yaml_language_server import YamlLanguageServer return YamlLanguageServer + case self.JSON: + from solidlsp.language_servers.json_language_server import JsonLanguageServer + + return JsonLanguageServer case self.TOML: from solidlsp.language_servers.taplo_server import TaploServer diff --git a/test/resources/repos/json/test_repo/config.json b/test/resources/repos/json/test_repo/config.json new file mode 100644 index 00000000..78ec641a --- /dev/null +++ b/test/resources/repos/json/test_repo/config.json @@ -0,0 +1,24 @@ +{ + "app": { + "name": "test-application", + "version": "1.0.0", + "port": 8080, + "debug": true + }, + "database": { + "host": "localhost", + "port": 5432, + "name": "testdb", + "username": "admin" + }, + "logging": { + "level": "info", + "format": "json", + "outputs": ["console", "file"] + }, + "features": { + "authentication": true, + "caching": false, + "monitoring": true + } +} diff --git a/test/resources/repos/json/test_repo/data.json b/test/resources/repos/json/test_repo/data.json new file mode 100644 index 00000000..329491ba --- /dev/null +++ b/test/resources/repos/json/test_repo/data.json @@ -0,0 +1,30 @@ +{ + "users": [ + { + "id": 1, + "name": "Alice", + "email": "alice@example.com", + "roles": ["admin", "user"] + }, + { + "id": 2, + "name": "Bob", + "email": "bob@example.com", + "roles": ["user"] + } + ], + "projects": [ + { + "id": 101, + "name": "Project Alpha", + "status": "active", + "tags": ["backend", "api"] + }, + { + "id": 102, + "name": "Project Beta", + "status": "planning", + "tags": ["frontend"] + } + ] +} diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 8a315895..e89ccf71 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -19,8 +19,8 @@ class TestSearchText: assert len(matches) == 1 assert matches[0].num_matched_lines == 1 - assert matches[0].start_line == 3 - assert matches[0].end_line == 3 + assert matches[0].start_line == 2 + assert matches[0].end_line == 2 assert matches[0].lines[0].line_content.strip() == 'print("Hello, World!")' def test_search_text_with_regex_pattern(self): diff --git a/test/solidlsp/elixir/test_elixir_startup.py b/test/solidlsp/elixir/test_elixir_startup.py new file mode 100644 index 00000000..49a84be7 --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_startup.py @@ -0,0 +1,104 @@ +"""Unit tests for Elixir Expert language server startup behavior.""" + +import threading +from unittest.mock import MagicMock + +from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools + + +def _make_elixir_tools(tmp_path): + language_server = object.__new__(ElixirTools) + language_server.repository_root_path = str(tmp_path) + language_server.server_ready = threading.Event() + language_server.request_id = 0 + return language_server + + +def _make_mock_server(): + server = MagicMock() + server.send.initialize.return_value = {"capabilities": {"textDocumentSync": {}}} + return server + + +class TestElixirToolsStartup: + """Tests for ElixirTools._start_server() startup behavior — the Expert deadlock fix.""" + + def test_sends_did_open_for_mix_exs_after_initialized(self, tmp_path): + """Expert blocks waiting for a didOpen notification to trigger its build pipeline.""" + (tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8") + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + calls = server.notify.did_open_text_document.call_args_list + assert len(calls) == 1, f"Expected one didOpen notification for mix.exs, got {len(calls)}" + text_doc = calls[0][0][0]["textDocument"] + assert text_doc["uri"].endswith("mix.exs"), f"Expected mix.exs URI, got {text_doc['uri']}" + assert text_doc["languageId"] == "elixir" + + def test_did_open_contains_mix_exs_content(self, tmp_path): + """The didOpen notification must include the file content so Expert can parse it.""" + content = "defmodule App.MixProject do\n use Mix.Project\nend\n" + (tmp_path / "mix.exs").write_text(content, encoding="utf-8") + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + calls = server.notify.did_open_text_document.call_args_list + assert len(calls) == 1 + text_doc = calls[0][0][0]["textDocument"] + assert text_doc["text"] == content + + def test_closes_mix_exs_after_server_ready(self, tmp_path): + """mix.exs is closed after Expert signals readiness so it does not linger open.""" + (tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8") + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + close_calls = server.notify.did_close_text_document.call_args_list + assert len(close_calls) == 1, f"Expected one didClose for mix.exs, got {len(close_calls)}" + text_doc = close_calls[0][0][0]["textDocument"] + assert text_doc["uri"].endswith("mix.exs"), f"Expected mix.exs URI, got {text_doc['uri']}" + + def test_startup_event_order_initialized_then_didopen_then_didclose(self, tmp_path): + """Initialized → didOpen(mix.exs) → [ready] → didClose(mix.exs) is the required sequence.""" + (tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8") + + events: list[str] = [] + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + server.notify.initialized.side_effect = lambda *_: events.append("initialized") + server.notify.did_open_text_document.side_effect = lambda *_: events.append("didOpen") + server.notify.did_close_text_document.side_effect = lambda *_: events.append("didClose") + + language_server._start_server() + + assert events == ["initialized", "didOpen", "didClose"], f"Unexpected startup event order: {events}" + + def test_no_crash_when_mix_exs_missing(self, tmp_path): + """_start_server must not crash if mix.exs is absent (non-standard project layout).""" + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + server.notify.did_open_text_document.assert_not_called() diff --git a/test/solidlsp/json_ls/__init__.py b/test/solidlsp/json_ls/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/json_ls/test_json_basic.py b/test/solidlsp/json_ls/test_json_basic.py new file mode 100644 index 00000000..c2e8031a --- /dev/null +++ b/test/solidlsp/json_ls/test_json_basic.py @@ -0,0 +1,78 @@ +""" +Basic integration tests for the JSON language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the JSON test repository. +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.json +class TestJsonLanguageServerBasics: + """Test basic functionality of the JSON language server.""" + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True) + def test_json_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that JSON language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.JSON + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True) + def test_json_config_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test document symbols detection in config.json with specific symbol verification.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.json").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for config.json" + assert len(all_symbols) > 0, f"Should find symbols in config.json, found {len(all_symbols)}" + + symbol_names = [sym.get("name") for sym in all_symbols] + assert "app" in symbol_names, "Should detect 'app' key in config.json" + assert "database" in symbol_names, "Should detect 'database' key in config.json" + assert "logging" in symbol_names, "Should detect 'logging' key in config.json" + assert "features" in symbol_names, "Should detect 'features' key in config.json" + + # Verify nested symbols + assert "name" in symbol_names, "Should detect nested 'name' key" + assert "port" in symbol_names, "Should detect nested 'port' key" + assert "debug" in symbol_names, "Should detect nested 'debug' key" + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True) + def test_json_data_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test symbol detection in data.json with array structures.""" + all_symbols, root_symbols = language_server.request_document_symbols("data.json").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for data.json" + assert len(all_symbols) > 0, f"Should find symbols in data.json, found {len(all_symbols)}" + + symbol_names = [sym.get("name") for sym in all_symbols] + assert "users" in symbol_names, "Should detect 'users' array" + assert "projects" in symbol_names, "Should detect 'projects' array" + assert "name" in symbol_names, "Should detect 'name' fields" + assert "email" in symbol_names, "Should detect 'email' fields" + assert "id" in symbol_names, "Should detect 'id' fields" + + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + """Test that symbol names do not contain malformed characters.""" + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, period_allowed=True): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/uv.lock b/uv.lock index 793e80cb..659b9257 100644 --- a/uv.lock +++ b/uv.lock @@ -190,23 +190,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] -[[package]] -name = "bleach" -version = "6.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, -] - -[package.optional-dependencies] -css = [ - { name = "tinycss2" }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -571,15 +554,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] -[[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, -] - [[package]] name = "dict2css" version = "0.3.0.post1" @@ -1274,15 +1248,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, ] -[[package]] -name = "jupyterlab-pygments" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, -] - [[package]] name = "latexcodec" version = "3.0.1" @@ -1417,7 +1382,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1435,9 +1400,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, ] [[package]] @@ -1461,15 +1426,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mistune" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, -] - [[package]] name = "more-itertools" version = "10.8.0" @@ -1636,31 +1592,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, ] -[[package]] -name = "nbconvert" -version = "7.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "bleach", extra = ["css"] }, - { name = "defusedxml" }, - { name = "jinja2" }, - { name = "jupyter-core" }, - { name = "jupyterlab-pygments" }, - { name = "markupsafe" }, - { name = "mistune" }, - { name = "nbclient" }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "pandocfilters" }, - { name = "pygments" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" }, -] - [[package]] name = "nbformat" version = "5.10.4" @@ -1676,23 +1607,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, ] -[[package]] -name = "nbsphinx" -version = "0.9.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "jinja2" }, - { name = "nbconvert" }, - { name = "nbformat" }, - { name = "sphinx" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/84/b1856b7651ac34e965aa567a158714c7f3bd42a1b1ce76bf423ffb99872c/nbsphinx-0.9.7.tar.gz", hash = "sha256:abd298a686d55fa894ef697c51d44f24e53aa312dadae38e82920f250a5456fe", size = 180479, upload-time = "2025-03-03T19:46:08.069Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/2d/8c8e635bcc6757573d311bb3c5445426382f280da32b8cd6d82d501ef4a4/nbsphinx-0.9.7-py3-none-any.whl", hash = "sha256:7292c3767fea29e405c60743eee5393682a83982ab202ff98f5eb2db02629da8", size = 31660, upload-time = "2025-03-03T19:46:06.581Z" }, -] - [[package]] name = "nest-asyncio" version = "1.6.0" @@ -1729,15 +1643,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] -[[package]] -name = "pandocfilters" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, -] - [[package]] name = "parso" version = "0.8.6" @@ -2412,11 +2317,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -2999,8 +2904,6 @@ dev = [ { name = "jinja2" }, { name = "jupyter-book" }, { name = "mypy" }, - { name = "nbconvert" }, - { name = "nbsphinx" }, { name = "poethepoet" }, { name = "pyasn1" }, { name = "pyinstrument" }, @@ -3041,10 +2944,8 @@ requires-dist = [ { name = "joblib", specifier = "==1.5.1" }, { name = "jupyter-book", marker = "extra == 'dev'", specifier = "==1.0.4.post1" }, { name = "lsprotocol", specifier = "==2025.0.0" }, - { name = "mcp", specifier = "==1.26.0" }, + { name = "mcp", specifier = "==1.27.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.0" }, - { name = "nbconvert", marker = "extra == 'dev'", specifier = "==7.17.0" }, - { name = "nbsphinx", marker = "extra == 'dev'", specifier = "==0.9.7" }, { name = "overrides", specifier = "==7.7.0" }, { name = "pathspec", specifier = "==0.12.1" }, { name = "poethepoet", marker = "extra == 'dev'", specifier = "==0.36.0" }, @@ -3059,7 +2960,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = "==2.4.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, - { name = "python-dotenv", specifier = "==1.2.1" }, + { name = "python-dotenv", specifier = "==1.2.2" }, { name = "python-multipart", specifier = "==0.0.26" }, { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = "==3.1.0rc0" }, { name = "pywebview", specifier = "==6.2" }, @@ -3647,18 +3548,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] -[[package]] -name = "tinycss2" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, -] - [[package]] name = "toml-sort" version = "0.24.2"