diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index c51534017..098ef523a 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. - -**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. + +In general, **memories** provide a way for Serena to store and retrieve +information about the project, relevant conventions, and other relevant aspects. + +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 000000000..e564d3c24 --- /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/src/serena/agent.py b/src/serena/agent.py index 83f9694bc..daa878208 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -12,7 +12,7 @@ from sensai.util import logging from sensai.util.logging import LogTime -from sensai.util.string import TextBuilder +from sensai.util.string import TextBuilder, list_string from interprompt.jinja_template import JinjaTemplate from serena import serena_version @@ -29,7 +29,7 @@ ) from serena.dashboard import SerenaDashboardAPI from serena.ls_manager import LanguageServerManager, LanguageServerManagerInitialisationError -from serena.project import Project +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 @@ -611,12 +611,15 @@ def _format_prompt(self, prompt_template: str) -> str: 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 diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 432e8a659..9273d31fb 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -85,6 +85,12 @@ def __init__(self) -> None: """ 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 @@ -549,6 +555,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 +568,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 a5c91a04f..26ec8f560 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 @@ def delete_memory() -> dict[str, str]: 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 @@ def run() -> None: 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 @@ def run() -> None: 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 3a5e14421..9129318c0 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -33,6 +33,12 @@ def create_prepare_for_new_conversation(self) -> str: 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/project.py b/src/serena/project.py index 59b89ec42..ed4821cbd 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 @@ -12,12 +13,13 @@ from serena.config.serena_config import ( DEFAULT_TOOL_TIMEOUT, 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 @@ -29,26 +31,59 @@ 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) @@ -57,47 +92,70 @@ def load_memory(self, name: str) -> str: 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 - - # 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) + memories = self.list_project_memories() + self.list_global_memories() - # 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) @@ -106,13 +164,35 @@ def rename_memory(self, old_name: str, new_name: str) -> str: 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__( @@ -124,7 +204,10 @@ def __init__( ): self.project_root = project_root self.project_config = project_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 @@ -220,10 +303,10 @@ def get_activation_message(self) -> str: 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 7a5228759..08602141c 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 13064c14f..125a6818b 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 662fed574..ec22d61e1 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 f59dbb6a9..5819cec3a 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 c8bf75d78..874aecf91 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -275,7 +275,17 @@

Add Language