diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 058ecbea..77f241fe 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -139,7 +139,10 @@ jobs: - name: Setup .NET SDK (for F# and C# languages) uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' + - name: List .NET runtimes + shell: bash + run: dotnet --list-runtimes - name: Install Terraform uses: hashicorp/setup-terraform@v3 with: @@ -196,6 +199,23 @@ jobs: - name: Install Ruby language server shell: bash run: gem install ruby-lsp + - name: Install OCaml and opam + uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: ${{ runner.os == 'Windows' && '4.14' || '5.3.x' }} + dune-cache: true + opam-repositories: | + ${{ runner.os == 'Windows' && 'opam-repository-mingw: https://github.com/ocaml-opam/opam-repository-mingw.git#sunset' || '' }} + default: https://github.com/ocaml/opam-repository.git + - name: Install OCaml packages + shell: bash + run: | + if [ "$RUNNER_OS" = "Windows" ]; then + opam install -y dune ocaml-lsp-server + else + # Require ocaml-lsp-server >= 1.23.0 for cross-file reference support + opam install -y dune 'ocaml-lsp-server>=1.23.0' + fi - name: Install R uses: r-lib/actions/setup-r@v2 with: @@ -394,7 +414,7 @@ jobs: - name: Install Regal (Rego Language Server) shell: bash run: | - REGAL_VERSION="0.36.1" + REGAL_VERSION="0.39.0" if [[ "${{ runner.os }}" == "Linux" ]]; then if [[ "$(uname -m)" == "x86_64" ]]; then diff --git a/.gitignore b/.gitignore index ec2ca442..f156e0f6 100644 --- a/.gitignore +++ b/.gitignore @@ -226,6 +226,9 @@ tmp/ # Exception: Don't ignore Nix test repository lib directory (contains source code) !/test/resources/repos/nix/test_repo/lib +# Exception: Don't ignore OCaml test repository lib directory (contains source code) +!/test/resources/repos/ocaml/test_repo/lib + # Exception: Don't ignore Julia test repository lib directory (contains source code) !/test/resources/repos/julia/test_repo/lib @@ -233,6 +236,9 @@ tmp/ /test/resources/repos/swift/test_repo/.build /test/resources/repos/swift/test_repo/.swiftpm +# OCaml +/test/resources/repos/ocaml/test_repo/_build + # Elm /test/resources/repos/elm/test_repo/.elm/ /test/resources/repos/elm/test_repo/elm-stuff/ @@ -254,4 +260,4 @@ cabal.project.local* .ghc.environment.* zz-misc/ -vue-implementation/ \ No newline at end of file +vue-implementation/ diff --git a/CHANGELOG.md b/CHANGELOG.md index dad0e4c6..90d7039b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Status of the `main` branch. Changes prior to the next official version change w * Language support: + * **Add support for OCaml** via ocaml-lsp-server with cross-file reference support on OCaml 5.2+ (requires opam; see [setup guide](docs/03-special-guides/ocaml_setup_guide_for_serena.md)) * **Add Phpactor as alternative PHP language server** (specify `php_phpactor` as language; requires PHP 8.1+) * **Add support for Fortran** via fortls language server (requires `pip install fortls`) * **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 177762c9..a59b63be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,9 @@ The following types of contributions can be submitted directly via pull requests For other changes, please open an issue first to discuss your ideas with the maintainers. +When submitting a PR, ensure a well-defined scope. +Every PR should cover a single logical change or a set of closely related changes. + ### Adding Support for a New Language Server See the corresponding [memory](.serena/memories/adding_new_language_support_guide.md). diff --git a/README.md b/README.md index b03b07c1..be511138 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ that implement the language server protocol (LSP). The underlying language servers are typically open-source projects (like Serena) or at least freely available for use. With Serena's LSP library, we provide **support for over 30 programming languages**, including -AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig. +AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, WGSL, YAML, and Zig. > [!IMPORTANT] > Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details. @@ -90,14 +90,13 @@ AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Groovy (pa ### The Serena JetBrains Plugin As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/) -leverages the powerful code analysis capabilities of your JetBrains IDE. -The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs, -including IntelliJ IDEA, PyCharm, Android Studio, WebStorm, PhpStorm, RubyMine, GoLand, CLion, and others. -Only Rider is not supported. +leverages the powerful code analysis capabilities of your JetBrains IDE. +The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs, +including IntelliJ IDEA, PyCharm, Android Studio, WebStorm, PhpStorm, RubyMine, GoLand, and potentially others (Rider and CLion are unsupported though). -The plugin offers the most robust and most powerful Serena experience. +The plugin offers the most robust and most powerful Serena experience. See our [documentation page](https://oraios.github.io/serena/02-usage/025_jetbrains_plugin.html) for further details and instructions. ## Quick Start diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 97c7fc40..c53784fb 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -30,7 +30,7 @@ Some languages require additional installations or setup steps, as noted. * **AL** * **Bash** * **C#** - (by default, uses the Roslyn language server (language `csharp`), requires `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)) on Windows; + (by default, uses the Roslyn language server (language `csharp`), requiring [.NET v10+](https://dotnet.microsoft.com/en-us/download/dotnet) and, on Windows, `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)); set language to `csharp_omnisharp` to use OmiSharp instead) * **C/C++** (by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`); @@ -45,15 +45,19 @@ Some languages require additional installations or setup steps, as noted. * **Erlang** (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls); experimental, might be slow or hang) * **F#** - (requires .NET SDK 8.0+; uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) + (requires [.NET v8.0+](https://dotnet.microsoft.com/en-us/download/dotnet); uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) * **Fortran** (requires installation of fortls: `pip install fortls`) * **Go** (requires installation of `gopls`) * **Groovy** (requires local groovy-language-server.jar setup via `GROOVY_LS_JAR_PATH` or configuration) -* **Haskell** +* **Haskell** (automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects) +* **HLSL / GLSL / WGSL** + (uses [shader-language-server](https://github.com/antaalt/shader-sense) (language `hlsl`); automatically downloaded; + on macOS, requires Rust toolchain for building from source; + note: reference search is not supported by this language server) * **Java** * **JavaScript** * **Julia** @@ -64,6 +68,8 @@ Some languages require additional installations or setup steps, as noted. (must explicitly enable language `markdown`, primarily useful for documentation-heavy projects) * **Nix** (requires nixd installation) +* **OCaml** + (requires opam and ocaml-lsp-server to be installed manually; see the [OCaml Setup Guide](../03-special-guides/ocaml_setup_guide_for_serena.md)) * **Pascal** (uses Pascal/Lazarus, which is automatically downloaded; set `PP` and `FPCDIR` environment variables for source navigation) * **Perl** @@ -96,7 +102,8 @@ see Serena's [memory on that](https://github.com/oraios/serena/blob/main/.serena As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/) leverages the powerful code analysis capabilities of JetBrains IDEs. The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs, -including IntelliJ IDEA, PyCharm, Android Studio, AppCode, WebStorm, PhpStorm, RubyMine, GoLand, AppCode, CLion, and others. +including IntelliJ IDEA, PyCharm, Android Studio, WebStorm, PhpStorm, RubyMine, GoLand, and potentially others +(Rider and CLion are unsupported though). When using the plugin, Serena connects to an instance of your JetBrains IDE via the plugin. For users who already work in a JetBrains IDE, this means Serena seamlessly integrates with the IDE instance you typically have open anyway, diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index e52560ea..1b73e622 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -103,22 +103,12 @@ By default, Serena will perform an **onboarding process** when it is started for the first time for a project. The goal of the onboarding is for Serena to get familiar with the project and to store memories, which it can then draw upon in future interactions. -If an LLM should fail to complete the onboarding and does not actually write the -respective memories to disk, you may need to ask it to do so explicitly. -The onboarding will usually read a lot of content from the project, thus filling -up the context. It can therefore be advisable to switch to another conversation -once the onboarding is complete. -After the onboarding, we recommend that you have a quick look at the memories and, -if necessary, edit them or add additional ones. +In general, **memories** provide a way for Serena to store and retrieve +information about the project, relevant conventions, and other relevant aspects. -**Memories** are files stored in `.serena/memories/` in the project directory, -which the agent can choose to read in subsequent interactions. -Feel free to read and adjust them as needed; you can also add new ones manually. -Every file in the `.serena/memories/` directory is a memory file. -Whenever Serena starts working on a project, the list of memories is -provided, and the agent can decide to read them. -We found that memories can significantly improve the user experience with Serena. +For more information on this, including how to manage +or disable these features, see [Memories & Onboarding](045_memories). ## Preparing Your Project diff --git a/docs/02-usage/045_memories.md b/docs/02-usage/045_memories.md new file mode 100644 index 00000000..49823d0a --- /dev/null +++ b/docs/02-usage/045_memories.md @@ -0,0 +1,84 @@ +# Memories & Onboarding + +Serena provides the functionality of a fully featured agent, and a useful aspect of this is Serena's memory system. +Despite its simplicity, we received positive feedback from many users who tend to combine it with their +agent's internal memory management (e.g., `AGENTS.md` files). + +## Memories + +Memories are simple, human-readable Markdown files that both you and +your agent can create, read, and edit. + +Serena differentiates between + * **project-specific memories**, which are stored in the `.serena/memories/` directory within your project folder, and + * **global memories**, which are shared across all projects and, by default, are stored in `~/.serena/memories/global/` + +The LLM is informed about the existence of memories and instructed to read them when appropriate, +inferring appropriateness from the file name. +When the agent starts working on a project, it receives the list of available memories. +The agent should be instructed to update memories by the user when appropriate. + +### Organizing Memories + +Memories can be organized into **topics** by using `/` in the memory name (e.g. `modules/frontend`). +The structure is mapped to the file system, where topics correspond to subdirectories. +The `list_memories` tool can filter by topic, allowing the agent to explore even large numbers of memories in a structured way. + +(global-memories)= +### Global Memories + +Global memories use the top-level topic `global`, i.e. whenever a memory name starts with `global/`, +it is stored in the global memories directory and is shared across all projects. + +By default, deletion and editing of global memories is allowed. +If you want to primarily manage such memories yourself and protect them from accidental modification by the agent, +set `edit_global_memories: False` in Serena's [global configuration](050_configuration). + +Since global memories are not versioned alongside your project files, +it can be helpful to track global memories with git (i.e. to make `~/.serena/memories/` a git repository) +in order to have a history of changes and the possibility to revert them if needed. + +### Manually Editing Memories + +You may edit memories directly in the file system, using your preferred text editor or IDE. +Alternatively, access them via the [Serena Dashboard](060_dashboard), which provides a graphical interface for +viewing, creating, editing, and deleting memories while Serena is running. + +(onboarding)= +## Onboarding + +By default, Serena performs an **onboarding process** when it encounters a project +for the first time (i.e., when no project memories exist yet). +The goal of the onboarding is for Serena to get familiar with the project — +its structure, build system, testing setup, and other essential aspects — +and to store this knowledge as memories for future interactions. + +In further project activations, Serena will check whether onboarding was already +performed by looking for existing project memories and will skip the onboarding +process if memories are found. + +### How Onboarding Works + +1. When a project is activated, Serena checks whether onboarding was already + performed (by checking if any memories exist). +2. If no memories are found, Serena triggers the onboarding process, which + reads key files and directories to understand the project. +3. The gathered information is written into project-specific memory files (see above). + +### Tips for Onboarding + +- **Context usage**: The onboarding process will read a lot of content from the project, + filling up the context window. It is therefore advisable to **switch to a new conversation** + once the onboarding is complete. +- **LLM failures**: If an LLM fails to complete the onboarding and does not actually + write the respective memories to disk, you may need to ask it to do so explicitly. +- **Review the results**: After onboarding, we recommend having a quick look at the + generated memories and editing them or adding new ones as needed. + +## Disabling Memories and Onboarding + +If you do not require the functionality described in this section, you can selectively disable it. + + * To disable all memory related tools (including onboarding), adding `no-memories` to the `base_modes` + in Serena's [global configuration](050_configuration). + * Similarly, to disable only onboarding, add `no-onboarding` to the `base_modes`. diff --git a/docs/03-special-guides/ocaml_setup_guide_for_serena.md b/docs/03-special-guides/ocaml_setup_guide_for_serena.md new file mode 100644 index 00000000..068fd057 --- /dev/null +++ b/docs/03-special-guides/ocaml_setup_guide_for_serena.md @@ -0,0 +1,88 @@ +# OCaml Setup Guide for Serena + +This guide explains how to set up an OCaml project so that Serena can provide code intelligence via ocaml-lsp-server (ocamllsp). + +Unlike some other languages, Serena does not download the OCaml language server automatically. You must install it yourself via opam, as OCaml tooling is compiled from source against your specific environment. + +--- +## Prerequisites + +Install the following on your system and ensure they are available on `PATH`: + +- **opam** (OCaml package manager) + - macOS: `brew install opam` + - Ubuntu/Debian: `sudo apt install opam` + - Fedora: `sudo dnf install opam` + - Other: https://opam.ocaml.org/doc/Install.html +- **OCaml compiler** (via opam) + - OCaml < 5.1 or >= 5.1.1 (OCaml 5.1.0 is **not supported** by ocaml-lsp-server) + - Recommended: OCaml 4.14.x (stable) or 5.2+ (for cross-file references) +- **ocaml-lsp-server** (via opam) +- **dune** (build system, via opam) + +--- +## Installation + +1. Initialize opam if you haven't already: + ```bash + opam init + eval $(opam env) + ``` + +2. Create an opam switch with a compatible OCaml version: + ```bash + # For cross-file reference support (recommended) + opam switch create serena-ocaml ocaml-base-compiler.5.2.1 + eval $(opam env) + + # Or for stable OCaml 4.14.x + opam switch create serena-ocaml ocaml-base-compiler.4.14.2 + eval $(opam env) + ``` + +3. Install the language server and build tools: + ```bash + opam install ocaml-lsp-server dune + ``` + +4. Verify the installation: + ```bash + opam exec -- ocamllsp --version + opam exec -- ocaml -version + ``` + +--- +## Cross-File References + +Cross-file reference support (finding all usages of a symbol across your project) requires: + +- OCaml >= 5.2 +- ocaml-lsp-server >= 1.23.0 +- dune >= 3.16.0 + +When these requirements are met, Serena automatically builds the cross-file index during startup via `dune build @ocaml-index`. Without these versions, references are limited to the current file. + +--- +## Using Serena with OCaml + +- Serena automatically detects OCaml files (`*.ml`, `*.mli`) and Reason files (`*.re`, `*.rei`). +- The language server is started via `opam exec -- ocamllsp`, so your opam environment must be configured. +- Ensure your project builds successfully with `dune build` before using Serena for best results. + +--- +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| "opam not found" | Install opam and add it to PATH | +| "OCaml 5.1.0 is incompatible" | Create a new switch: `opam switch create ocaml-base-compiler.5.2.1` | +| "ocaml-lsp-server not found" | `opam install ocaml-lsp-server` | +| Cross-file refs not working | Ensure OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0; run `dune build` first | +| Stale index | Rebuild with `dune build @ocaml-index` | + +--- +## Reference + +- opam: https://opam.ocaml.org +- ocaml-lsp-server: https://github.com/ocaml/ocaml-lsp +- Project-wide occurrences: https://discuss.ocaml.org/t/ann-project-wide-occurrences-in-merlin-and-lsp/14847 diff --git a/pyproject.toml b/pyproject.toml index 587bbf2c..b1f9448e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dev = [ "poethepoet>=0.20.0", "pytest>=8.0.2", "pytest-xdist>=3.5.0", - "ruff>=0.0.285", + "ruff==0.12.5", "toml-sort>=0.24.2", "types-pyyaml>=6.0.12.20241230", "syrupy>=4.9.1", @@ -310,6 +310,7 @@ markers = [ "nix: language server running for Nix", "dart: language server running for Dart", "erlang: language server running for Erlang", + "ocaml: language server running for OCaml and Reason", "scala: language server running for Scala", "al: language server running for AL (Microsoft Dynamics 365 Business Central)", "fsharp: language server running for F#", @@ -326,11 +327,12 @@ markers = [ "toml: language server running for TOML", "matlab: language server running for MATLAB (requires MATLAB R2021b+)", "systemverilog: language server running for SystemVerilog (uses verible-verilog-ls)", + "hlsl: language server running for HLSL shaders (uses shader-language-server)", ] [tool.codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file skip = '.git*,*.svg,*.lock,*.min.*' check-hidden = true -# ignore-regex = '' +ignore-regex = '\.\w+' ignore-words-list = 'paket' diff --git a/src/serena/agent.py b/src/serena/agent.py index 5b779ed4..89a4f838 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Optional, TypeVar from sensai.util import logging from sensai.util.logging import LogTime +from sensai.util.string import TextBuilder, list_string from interprompt.jinja_template import JinjaTemplate from serena import serena_version @@ -24,11 +25,12 @@ from serena.config.serena_config import ( NamedToolInclusionDefinition, RegisteredProject, SerenaConfig, + SerenaPaths, ToolInclusionDefinition, ) from serena.dashboard import SerenaDashboardAPI -from serena.ls_manager import LanguageServerManager -from serena.project import Project +from serena.ls_manager import LanguageServerManager, LanguageServerManagerInitialisationError +from serena.project import MemoriesManager, Project from serena.prompt_factory import SerenaPromptFactory from serena.task_executor import TaskExecutor from serena.tools import ActivateProjectTool, GetCurrentConfigTool, OpenDashboardTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry @@ -253,6 +255,7 @@ class SerenaAgent: # project-specific instances, which will be initialized upon project activation self._active_project: Project | None = None + self._lsp_init_error: LanguageServerManagerInitialisationError | None = None # determine registered project to be activated (if any) registered_project_to_activate: RegisteredProject | None = ( @@ -344,11 +347,10 @@ class SerenaAgent: self._mode_overrides = modes if project is not None: try: - self.activate_project_from_path_or_name(project, update_active_modes=True, update_active_tools=False) + self.activate_project_from_path_or_name(project, update_active_modes=False, update_active_tools=False) except Exception as e: log.error(f"Error activating project '{project}' at startup: {e}", exc_info=e) - else: - self._update_active_modes() + self._update_active_modes() # determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see), self._base_toolset = self._create_base_toolset( @@ -475,13 +477,26 @@ class SerenaAgent: def get_language_server_manager_or_raise(self) -> LanguageServerManager: language_server_manager = self.get_language_server_manager() if language_server_manager is None: - raise Exception( - "The language server manager is not initialized, indicating a problem during project activation. " - "Inform the user, telling them to inspect Serena's logs in order to determine the issue. " - "IMPORTANT: Wait for further instructions before you continue!" + msg = TextBuilder("The language server manager is not initialized, indicating a problem during project initialisation.") + if self._lsp_init_error is not None: + msg.with_text(str(self._lsp_init_error)) + msg.with_text("For details, please check the logs. " + self.get_log_inspection_instructions()) + msg.with_text( + "IMPORTANT: Stop, do not attempt workarounds. Inform the user and wait for further instructions before you continue!" ) + raise Exception(msg.build()) return language_server_manager + def get_log_inspection_instructions(self) -> str: + if self.serena_config.web_dashboard: + return f"Live logs can be inspected via the dashboard at {self.get_dashboard_url()}" + else: + log_path = SerenaPaths().last_returned_log_file_path + if log_path is not None: + return f"Find the current log file here: f{log_path}" + else: + return "Unfortunately, logs are not available. We recommend enabling the web dashboard/logging in general." + def get_context(self) -> SerenaAgentContext: return self._context @@ -588,12 +603,15 @@ class SerenaAgent: def create_system_prompt(self) -> str: available_tools = self._active_tools available_markers = available_tools.tool_marker_names + global_memory_names = MemoriesManager.list_global_memories() + global_memories_list = list_string(global_memory_names) if global_memory_names else "" log.info("Generating system prompt with available_tools=(see active tools), available_markers=%s", available_markers) system_prompt = self.prompt_factory.create_system_prompt( context_system_prompt=self._format_prompt(self._context.prompt), mode_system_prompts=[self._format_prompt(mode.prompt) for mode in self.get_active_modes()], available_tools=available_tools.tool_names, available_markers=available_markers, + global_memories_list=global_memories_list, ) # If a project is active at startup, append its activation message @@ -800,7 +818,12 @@ class SerenaAgent: """ Starts/resets the language server manager for the current project """ - self.get_active_project_or_raise().create_language_server_manager() + try: + self._lsp_init_error = None + self.get_active_project_or_raise().create_language_server_manager() + except LanguageServerManagerInitialisationError as e: + self._lsp_init_error = e + raise def add_language(self, language: Language) -> None: """ diff --git a/src/serena/cli.py b/src/serena/cli.py index 9fdfc728..ef14d17b 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -806,6 +806,9 @@ class ProjectCommands(AutoRegisteringGroup): logging.configure(level=logging.INFO) project_path = os.path.abspath(project) serena_config = SerenaConfig.from_config_file() + serena_config.language_backend = LanguageBackend.LSP + serena_config.gui_log_window = False + serena_config.web_dashboard = False proj = Project.load(project_path, serena_config=serena_config) # Create log file with timestamp @@ -820,9 +823,7 @@ class ProjectCommands(AutoRegisteringGroup): try: # Create SerenaAgent with dashboard disabled log.info("Creating SerenaAgent with disabled dashboard...") - serena_config = SerenaConfig.from_config_file() - serena_config.gui_log_window = False - serena_config.web_dashboard = False + agent = SerenaAgent(project=project_path, serena_config=serena_config) log.info("SerenaAgent created successfully") @@ -856,7 +857,7 @@ class ProjectCommands(AutoRegisteringGroup): # Test 1: Get symbols overview log.info("Testing GetSymbolsOverviewTool on file: %s", target_file) overview_data = agent.execute_task(lambda: overview_tool.get_symbol_overview(target_file)) - log.info("GetSymbolsOverviewTool returned %d symbols", len(overview_data)) + log.info(f"GetSymbolsOverviewTool returned: {overview_data}") if not overview_data: log.error("No symbols found in file %s", target_file) @@ -877,8 +878,8 @@ class ProjectCommands(AutoRegisteringGroup): selected_symbol = overview_data[0] log.info("No class or function found, using first available symbol") - symbol_name = selected_symbol.get("name_path", "unknown") - symbol_kind = selected_symbol.get("kind", "unknown") + symbol_name = selected_symbol["name"] + symbol_kind = selected_symbol["kind"] log.info("Using symbol for testing: %s (kind: %s)", symbol_name, symbol_kind) # Test 2: FindSymbolTool diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 8ab33baa..83ad206f 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -85,6 +85,17 @@ class SerenaPaths: """ file containing the ID of the last read news snippet """ + global_memories_path = Path(os.path.join(self.serena_user_home_dir, "memories", "global")) + global_memories_path.mkdir(parents=True, exist_ok=True) + self.global_memories_path = global_memories_path + """ + directory where global memories are stored, i.e. memories that are available across all projects + """ + self.last_returned_log_file_path: str | None = None + """ + the path to the last log file returned by `get_next_log_file_path`. If this is not None, the logs + are currently being written to this file + """ def get_next_log_file_path(self, prefix: str) -> str: """ @@ -93,7 +104,8 @@ class SerenaPaths: """ log_dir = os.path.join(self.serena_user_home_dir, "logs", datetime.now().strftime("%Y-%m-%d")) os.makedirs(log_dir, exist_ok=True) - return os.path.join(log_dir, prefix + "_" + datetime_tag() + ".txt") + self.last_returned_log_file_path = os.path.join(log_dir, prefix + "_" + datetime_tag() + f"_{os.getpid()}" + ".txt") + return self.last_returned_log_file_path # TODO: Paths from constants.py should be moved here @@ -549,6 +561,9 @@ class SerenaConfig(SharedConfig): """List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **. These patterns are merged additively with each project's own ignored_paths.""" + edit_global_memories: bool = True + """Whether global memories are allowed to be deleted or edited.""" + # settings with overridden defaults default_modes: Sequence[str] | None = ("interactive", "editing") symbol_info_budget: float = 10.0 @@ -559,7 +574,6 @@ class SerenaConfig(SharedConfig): If the budget is exceeded, Serena stops issuing further requests and returns partial info results. 0 disables the budget (no early stopping). Negative values are invalid. """ - # *** fields that are NOT mapped to/from the configuration file *** _loaded_commented_yaml: CommentedMap | None = None diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index d835861f..7c944a72 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -89,6 +89,11 @@ class RequestDeleteMemory(BaseModel): memory_name: str +class RequestRenameMemory(BaseModel): + old_name: str + new_name: str + + class ResponseGetSerenaConfig(BaseModel): content: str @@ -269,6 +274,18 @@ class SerenaDashboardAPI: except Exception as e: return {"status": "error", "message": str(e)} + @self._app.route("/rename_memory", methods=["POST"]) + def rename_memory() -> dict[str, str]: + request_data = request.get_json() + if not request_data: + return {"status": "error", "message": "No data provided"} + request_rename_memory = RequestRenameMemory.model_validate(request_data) + try: + result_message = self._rename_memory(request_rename_memory) + return {"status": "success", "message": result_message} + except Exception as e: + return {"status": "error", "message": str(e)} + @self._app.route("/get_serena_config", methods=["GET"]) def get_serena_config() -> dict[str, Any]: try: @@ -556,8 +573,7 @@ class SerenaDashboardAPI: project = self._agent.get_active_project() if project is None: raise ValueError("No active project") - - project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content) + project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content, is_tool_context=False) self._agent.execute_task(run, logged=True, name="SaveMemory") @@ -566,11 +582,22 @@ class SerenaDashboardAPI: project = self._agent.get_active_project() if project is None: raise ValueError("No active project") - - project.memories_manager.delete_memory(request_delete_memory.memory_name) + project.memories_manager.delete_memory(request_delete_memory.memory_name, is_tool_context=False) self._agent.execute_task(run, logged=True, name="DeleteMemory") + def _rename_memory(self, request_rename_memory: RequestRenameMemory) -> str: + def run() -> str: + project = self._agent.get_active_project() + if project is None: + raise ValueError("No active project") + + return project.memories_manager.move_memory( + request_rename_memory.old_name, request_rename_memory.new_name, is_tool_context=False + ) + + return self._agent.execute_task(run, logged=True, name="RenameMemory") + def _get_serena_config(self) -> ResponseGetSerenaConfig: config_path = self._agent.serena_config.config_file_path if config_path is None or not os.path.exists(config_path): diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index 3a5e1442..9129318c 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -33,6 +33,12 @@ class PromptFactory(PromptFactoryBase): return self._render_prompt("prepare_for_new_conversation", locals()) def create_system_prompt( - self, *, available_markers: Any, available_tools: Any, context_system_prompt: Any, mode_system_prompts: Any + self, + *, + available_markers: Any, + available_tools: Any, + context_system_prompt: Any, + global_memories_list: Any, + mode_system_prompts: Any, ) -> str: return self._render_prompt("system_prompt", locals()) diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 1cb8f3b5..1ef862f8 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -14,6 +14,11 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +class LanguageServerManagerInitialisationError(Exception): + def __init__(self, message: str): + super().__init__(message) + + class LanguageServerFactory: def __init__( self, @@ -122,14 +127,14 @@ class LanguageServerManager: # If any server failed to start up, raise an exception and stop all started language servers. # We intentionally fail fast here. The user's intention is to work with all the specified languages, - # so if any of them is not available, it is better to fail immediately and bring the failure to the + # so if any of them is not available, it is better to make symbolic tool calls fail, bringing the issue to the # user's attention instead of silently continuing with a subset of the language servers and potentially # causing suboptimal agent behaviour. if exceptions: for ls in language_servers.values(): ls.stop() failure_messages = "\n".join([f"{lang.value}: {e}" for lang, e in exceptions.items()]) - raise Exception(f"Failed to start language servers:\n{failure_messages}") + raise LanguageServerManagerInitialisationError(f"Failed to start {len(exceptions)} language server(s):\n{failure_messages}") return LanguageServerManager(language_servers, factory) @@ -142,16 +147,20 @@ class LanguageServerManager: ls = self.restart_language_server(ls.language) return ls + def _get_suitable_language_server(self, relative_path: str) -> SolidLanguageServer | None: + """:param relative_path: relative path to a file""" + for candidate in self._language_servers.values(): + if not candidate.is_ignored_path(relative_path, ignore_unsupported_files=True): + return candidate + return None + def get_language_server(self, relative_path: str) -> SolidLanguageServer: """:param relative_path: relative path to a file""" ls: SolidLanguageServer | None = None if len(self._language_servers) > 1: if os.path.isdir(relative_path): raise ValueError(f"Expected a file path, but got a directory: {relative_path}") - for candidate in self._language_servers.values(): - if not candidate.is_ignored_path(relative_path, ignore_unsupported_files=True): - ls = candidate - break + ls = self._get_suitable_language_server(relative_path) if ls is None: ls = self._default_language_server return self._ensure_functional_ls(ls) @@ -236,3 +245,6 @@ class LanguageServerManager: for ls in self.iter_language_servers(): if ls.is_running(): ls.save_cache() + + def has_suitable_ls_for_file(self, relative_file_path: str) -> bool: + return self._get_suitable_language_server(relative_file_path) is not None diff --git a/src/serena/project.py b/src/serena/project.py index 59cb8098..dd9a4d3e 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -1,9 +1,10 @@ import json import logging import os +import shutil import threading from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import pathspec from sensai.util.logging import LogTime @@ -11,12 +12,13 @@ from sensai.util.string import ToStringMixin from serena.config.serena_config import ( ProjectConfig, + SerenaPaths, get_serena_managed_in_project_dir, ) from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_NAME from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.util.file_system import GitignoreParser, match_path -from serena.util.text_utils import MatchedConsecutiveLines, search_files +from serena.util.text_utils import ContentReplacer, MatchedConsecutiveLines, search_files from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import FileUtils @@ -28,26 +30,59 @@ log = logging.getLogger(__name__) class MemoriesManager: - def __init__(self, project_root: str): - self._memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories" - self._memory_dir.mkdir(parents=True, exist_ok=True) + GLOBAL_TOPIC = "global" + _global_memory_dir = SerenaPaths().global_memories_path + + def __init__(self, project_root: str, global_memory_tool_write_access: bool = False): + """ + :param project_root: the project's root directory + :param global_memory_tool_write_access: whether to allow writing global memories in tool execution contexts + """ + self._project_memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories" + self._project_memory_dir.mkdir(parents=True, exist_ok=True) + self._global_memory_tool_write_access = global_memory_tool_write_access self._encoding = SERENA_FILE_ENCODING + def _is_global(self, name: str) -> bool: + return name == self.GLOBAL_TOPIC or name.startswith(self.GLOBAL_TOPIC + "/") + def get_memory_file_path(self, name: str) -> Path: # Strip .md extension if present name = name.replace(".md", "") - # Split by "/" to handle subdirectories + if self._is_global(name): + if name == self.GLOBAL_TOPIC: + raise ValueError( + f'Bare "{self.GLOBAL_TOPIC}" is not a valid memory name. ' + f'Use "{self.GLOBAL_TOPIC}/" to address a global memory.' + ) + # Strip "global/" prefix and resolve against global dir + sub_name = name[len(self.GLOBAL_TOPIC) + 1 :] + parts = sub_name.split("/") + filename = f"{parts[-1]}.md" + if len(parts) > 1: + subdir = self._global_memory_dir / "/".join(parts[:-1]) + subdir.mkdir(parents=True, exist_ok=True) + return subdir / filename + return self._global_memory_dir / filename + + # Project-local memory parts = name.split("/") filename = f"{parts[-1]}.md" if len(parts) > 1: # Create subdirectory path - subdir = self._memory_dir / "/".join(parts[:-1]) + subdir = self._project_memory_dir / "/".join(parts[:-1]) subdir.mkdir(parents=True, exist_ok=True) return subdir / filename - return self._memory_dir / filename + return self._project_memory_dir / filename + + def _check_write_access(self, name: str, is_tool_context: bool) -> None: + # in tool context, global memory write access can be disabled + if is_tool_context: + if self._is_global(name) and not self._global_memory_tool_write_access: + raise PermissionError(f"Writing to global memories is not allowed (attempted to write to '{name}')") def load_memory(self, name: str) -> str: memory_file_path = self.get_memory_file_path(name) @@ -56,47 +91,70 @@ class MemoriesManager: with open(memory_file_path, encoding=self._encoding) as f: return f.read() - def save_memory(self, name: str, content: str) -> str: + def save_memory(self, name: str, content: str, is_tool_context: bool) -> str: + self._check_write_access(name, is_tool_context) memory_file_path = self.get_memory_file_path(name) with open(memory_file_path, "w", encoding=self._encoding) as f: f.write(content) return f"Memory {name} written." + @staticmethod + def _list_memories(search_dir: Path, base_dir: Path, prefix: str = "") -> list[str]: + if not search_dir.exists(): + return [] + results = [] + for md_file in search_dir.rglob("*.md"): + rel = str(md_file.relative_to(base_dir).with_suffix("")).replace(os.sep, "/") + results.append(prefix + rel) + return results + + @classmethod + def list_global_memories(cls, subtopic: str = "") -> list[str]: + dir_path = cls._global_memory_dir + if subtopic: + dir_path = dir_path / subtopic.replace("/", os.sep) + return cls._list_memories(dir_path, cls._global_memory_dir, cls.GLOBAL_TOPIC + "/") + + def list_project_memories(self, topic: str = "") -> list[str]: + dir_path = self._project_memory_dir + if topic: + dir_path = dir_path / topic.replace("/", os.sep) + return self._list_memories(dir_path, self._project_memory_dir) + def list_memories(self, topic: str = "") -> list[str]: """ - List memories, optionally filtered by topic. + Lists all memories, optionally filtered by topic. + If the topic is omitted, both global and project-specific memories are returned. """ - memories = [] + memories: list[str] if topic: - # Only list memories in specified subdirectory - search_dir = self._memory_dir / topic.replace("/", os.sep) - if not search_dir.exists(): - return [] + if self._is_global(topic): + topic_parts = topic.split("/") + subtopic = "/".join(topic_parts[1:]) + memories = self.list_global_memories(subtopic=subtopic) + else: + memories = self.list_project_memories(topic=topic) else: - search_dir = self._memory_dir + memories = self.list_project_memories() + self.list_global_memories() - # Recursively find all .md files - for md_file in search_dir.rglob("*.md"): - # Calculate relative path as memory name - rel_path = md_file.relative_to(self._memory_dir) - name = str(rel_path.with_suffix("")).replace(os.sep, "/") - memories.append(name) - - # Sort alphabetically by name return sorted(memories) - def delete_memory(self, name: str) -> str: + def delete_memory(self, name: str, is_tool_context: bool) -> str: + self._check_write_access(name, is_tool_context) memory_file_path = self.get_memory_file_path(name) if not memory_file_path.exists(): return f"Memory {name} not found." memory_file_path.unlink() return f"Memory {name} deleted." - def rename_memory(self, old_name: str, new_name: str) -> str: + def move_memory(self, old_name: str, new_name: str, is_tool_context: bool) -> str: """ Rename or move a memory file. + Moving between global and project scope (e.g. "global/foo" -> "bar") is supported. """ + self._check_write_access(new_name, is_tool_context) + old_path = self.get_memory_file_path(old_name) new_path = self.get_memory_file_path(new_name) @@ -105,13 +163,35 @@ class MemoriesManager: if new_path.exists(): raise FileExistsError(f"Memory {new_name} already exists.") - # Ensure target directory exists new_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(old_path, new_path) - # Move/rename the file - old_path.rename(new_path) return f"Memory renamed from {old_name} to {new_name}." + def edit_memory( + self, name: str, needle: str, repl: str, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool, is_tool_context: bool + ) -> str: + """ + Edit a memory by replacing content matching a pattern. + + :param name: the memory name + :param needle: the string or regex to search for + :param repl: the replacement string + :param mode: "literal" or "regex" + :param allow_multiple_occurrences: + """ + self._check_write_access(name, is_tool_context) + memory_file_path = self.get_memory_file_path(name) + if not memory_file_path.exists(): + raise FileNotFoundError(f"Memory {name} not found.") + with open(memory_file_path, encoding=self._encoding) as f: + original_content = f.read() + replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) + updated_content = replacer.replace(original_content, needle, repl) + with open(memory_file_path, "w", encoding=self._encoding) as f: + f.write(updated_content) + return f"Memory {name} edited successfully." + class Project(ToStringMixin): def __init__( @@ -126,7 +206,10 @@ class Project(ToStringMixin): self.project_root = project_root self.project_config = project_config self._serena_config = serena_config - self.memories_manager = MemoriesManager(project_root) + + global_memory_write_access = serena_config.edit_global_memories if serena_config else False + self.memories_manager = MemoriesManager(project_root, global_memory_write_access) + self.language_server_manager: LanguageServerManager | None = None self._is_newly_created = is_newly_created @@ -221,10 +304,10 @@ class Project(ToStringMixin): msg = f"The project with name '{self.project_name}' at {self.project_root} is activated." languages_str = ", ".join([lang.value for lang in self.project_config.languages]) msg += f"\nProgramming languages: {languages_str}; file encoding: {self.project_config.encoding}" - memories = self.memories_manager.list_memories() - if memories: + project_memories = self.memories_manager.list_project_memories() + if project_memories: msg += ( - f"\nAvailable project memories: {json.dumps(memories)}\n" + f"\nAvailable project memories: {json.dumps(project_memories)}\n" + "Use the `read_memory` tool to read these memories later if they are relevant to the task." ) if self.project_config.initial_prompt: diff --git a/src/serena/resources/config/modes/no-memories.yml b/src/serena/resources/config/modes/no-memories.yml index 7a522875..08602141 100644 --- a/src/serena/resources/config/modes/no-memories.yml +++ b/src/serena/resources/config/modes/no-memories.yml @@ -6,6 +6,7 @@ excluded_tools: - read_memory - delete_memory - edit_memory + - rename_memory - list_memories - onboarding - check_onboarding_performed diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index 13064c14..125a6818 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -35,10 +35,13 @@ prompts: You can understand relationships between symbols by using the `find_referencing_symbols` tool. {% endif %} - {% if 'read_memory' in available_tools %} + {% if 'read_memory' in available_tools -%} You generally have access to memories and it may be useful for you to read them. You infer whether memories are relevant based on their names. - {% endif %} + {% if global_memories_list -%} + The following global (not project-specific) memories are available to you: {{ global_memories_list }} + {%- endif -%} + {%- endif %} 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.css b/src/serena/resources/dashboard/dashboard.css index 662fed57..ec22d61e 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -1308,6 +1308,42 @@ body { gap: 8px; } +/* Memory Rename Styles */ +.memory-rename-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); + opacity: 0.5; + transition: opacity 0.2s ease, background-color 0.2s ease; +} + +.memory-rename-btn:hover { + opacity: 1; + background-color: var(--border-color); +} + +.memory-rename-input { + font-size: inherit; + font-weight: inherit; + font-family: inherit; + color: var(--text-primary); + background: transparent; + border: none; + border-bottom: 1px solid var(--btn-primary); + outline: none; + padding: 0; + flex: 1; + min-width: 200px; + max-width: 80%; +} + /* Log Action Buttons (Save, Copy, Clear) */ .log-action-buttons { position: absolute; diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index f59dbb6a..5819cec3 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -307,6 +307,8 @@ class Dashboard { this.$modalCloseRemove = $('.modal-close-remove'); this.$editMemoryModal = $('#edit-memory-modal'); this.$editMemoryName = $('#edit-memory-name'); + this.$editMemoryRenameBtn = $('#edit-memory-rename-btn'); + this.$editMemoryRenameInput = $('#edit-memory-rename-input'); this.$editMemoryContent = $('#edit-memory-content'); this.$editMemorySaveBtn = $('#edit-memory-save-btn'); this.$editMemoryCancelBtn = $('#edit-memory-cancel-btn'); @@ -365,6 +367,19 @@ class Dashboard { this.$editMemoryCancelBtn.click(this.closeEditMemoryModal.bind(this)); this.$modalCloseEditMemory.click(this.closeEditMemoryModal.bind(this)); this.$editMemoryContent.on('input', this.trackMemoryChanges.bind(this)); + this.$editMemoryRenameBtn.click(this.startMemoryRename.bind(this)); + this.$editMemoryRenameInput.keydown(function (e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + self.commitMemoryRename(); + } else if (e.which === 27) { // Escape key + e.preventDefault(); + self.cancelMemoryRename(); + } + }); + this.$editMemoryRenameInput.on('blur', function () { + self.cancelMemoryRename(); + }); this.$deleteMemoryOkBtn.click(this.confirmDeleteMemoryOk.bind(this)); this.$deleteMemoryCancelBtn.click(this.closeDeleteMemoryModal.bind(this)); this.$modalCloseDeleteMemory.click(this.closeDeleteMemoryModal.bind(this)); @@ -1887,6 +1902,62 @@ class Dashboard { }); } + startMemoryRename() { + this.$editMemoryName.hide(); + this.$editMemoryRenameBtn.hide(); + this.$editMemoryRenameInput.val(this.currentMemoryName).show().focus().select(); + } + + cancelMemoryRename() { + this.$editMemoryRenameInput.hide(); + this.$editMemoryName.show(); + this.$editMemoryRenameBtn.show(); + } + + commitMemoryRename() { + const newName = this.$editMemoryRenameInput.val().trim(); + const oldName = this.currentMemoryName; + + // If name unchanged, just cancel + if (!newName || newName === oldName) { + this.cancelMemoryRename(); + return; + } + + // Validate memory name (alphanumeric, underscores, and slashes for subdirectories) + if (!/^[a-zA-Z0-9_]+(?:\/[a-zA-Z0-9_]+)*$/.test(newName)) { + alert('Memory name can only contain letters, numbers, underscores, and "/" for subdirectories (e.g., "topic/memory_name")'); + this.$editMemoryRenameInput.focus(); + return; + } + + const self = this; + this.$editMemoryRenameInput.prop('disabled', true); + + $.ajax({ + url: '/rename_memory', type: 'POST', contentType: 'application/json', data: JSON.stringify({ + old_name: oldName, new_name: newName + }), success: function (response) { + if (response.status === 'success') { + self.currentMemoryName = newName; + self.$editMemoryName.text(newName); + self.cancelMemoryRename(); + // Reload config to reflect the rename in the memory list + self.loadConfigOverview(); + } else { + alert('Error: ' + response.message); + self.$editMemoryRenameInput.focus(); + } + }, error: function (xhr, status, error) { + console.error('Error renaming memory:', error); + alert('Error renaming memory: ' + (xhr.responseJSON ? xhr.responseJSON.message : error)); + self.$editMemoryRenameInput.focus(); + }, complete: function () { + self.$editMemoryRenameInput.prop('disabled', false); + } + }); + } + confirmDeleteMemory(memoryName) { // Set memory name to delete this.memoryToDelete = memoryName; @@ -1960,9 +2031,9 @@ class Dashboard { return; } - // Validate memory name (alphanumeric and underscores only) - if (!/^[a-zA-Z0-9_]+$/.test(memoryName)) { - alert('Memory name can only contain letters, numbers, and underscores'); + // Validate memory name (alphanumeric, underscores, and slashes for subdirectories) + if (!/^[a-zA-Z0-9_]+(?:\/[a-zA-Z0-9_]+)*$/.test(memoryName)) { + alert('Memory name can only contain letters, numbers, underscores, and "/" for subdirectories (e.g., "topic/memory_name")'); return; } diff --git a/src/serena/resources/dashboard/index.html b/src/serena/resources/dashboard/index.html index c8bf75d7..874aecf9 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -275,7 +275,17 @@