From bc9bd9085e2aabb5899392a5cc04bea9a126d344 Mon Sep 17 00:00:00 2001 From: by22Jy <122969909+by22Jy@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:56:34 +0800 Subject: [PATCH] feat: Add topic grouping support for memories (#1058) --- src/serena/project.py | 66 +++++++++++++++++++++++++++++--- src/serena/tools/memory_tools.py | 59 ++++++++++++++++------------ 2 files changed, 96 insertions(+), 29 deletions(-) diff --git a/src/serena/project.py b/src/serena/project.py index c176dddb..e66a8732 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -9,7 +9,11 @@ import pathspec from sensai.util.logging import LogTime from sensai.util.string import ToStringMixin -from serena.config.serena_config import DEFAULT_TOOL_TIMEOUT, ProjectConfig, get_serena_managed_in_project_dir +from serena.config.serena_config import ( + DEFAULT_TOOL_TIMEOUT, + ProjectConfig, + 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.text_utils import MatchedConsecutiveLines, search_files @@ -31,9 +35,19 @@ class MemoriesManager: self._encoding = SERENA_FILE_ENCODING def get_memory_file_path(self, name: str) -> Path: - # strip all .md from the name. Models tend to get confused, sometimes passing the .md extension and sometimes not. + # Strip .md extension if present name = name.replace(".md", "") - filename = f"{name}.md" + + # Split by "/" to handle subdirectories + parts = name.split("/") + filename = f"{parts[-1]}.md" + + if len(parts) > 1: + # Create subdirectory path + subdir = self._memory_dir / "/".join(parts[:-1]) + subdir.mkdir(parents=True, exist_ok=True) + return subdir / filename + return self._memory_dir / filename def load_memory(self, name: str) -> str: @@ -49,14 +63,56 @@ class MemoriesManager: f.write(content) return f"Memory {name} written." - def list_memories(self) -> list[str]: - return [f.name.replace(".md", "") for f in self._memory_dir.iterdir() if f.is_file()] + def list_memories(self, topic: str = "") -> list[str]: + """ + List memories, optionally filtered by topic. + """ + memories = [] + + if topic: + # Only list memories in specified subdirectory + search_dir = self._memory_dir / topic.replace("/", os.sep) + if not search_dir.exists(): + return [] + 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) + + # Sort alphabetically by name + return sorted(memories) def delete_memory(self, name: str) -> str: 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: + """ + Rename or move a memory file. + """ + old_path = self.get_memory_file_path(old_name) + new_path = self.get_memory_file_path(new_name) + + if not old_path.exists(): + raise FileNotFoundError(f"Memory {old_name} not found.") + 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) + + # Move/rename the file + old_path.rename(new_path) + return f"Memory renamed from {old_name} to {new_name}." + class Project(ToStringMixin): def __init__( diff --git a/src/serena/tools/memory_tools.py b/src/serena/tools/memory_tools.py index 46555937..dccf3c16 100644 --- a/src/serena/tools/memory_tools.py +++ b/src/serena/tools/memory_tools.py @@ -8,21 +8,23 @@ class WriteMemoryTool(Tool, ToolMarkerCanEdit): Writes a named memory (for future reference) to Serena's project-specific memory store. """ - def apply(self, memory_file_name: str, content: str, max_answer_chars: int = -1) -> str: + def apply(self, memory_name: str, content: str, max_chars: int = -1) -> str: """ Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. - The memory name should be meaningful. + The memory name should be meaningful and can include "/" to organize into topics (e.g., "auth/login/logic"). + + :param max_chars: the maximum number of characters to write. By default, determined by the config, + change only if instructed to do so. """ # NOTE: utf-8 encoding is configured in the MemoriesManager - if max_answer_chars == -1: - max_answer_chars = self.agent.serena_config.default_max_tool_answer_chars - if len(content) > max_answer_chars: + if max_chars == -1: + max_chars = self.agent.serena_config.default_max_tool_answer_chars + if len(content) > max_chars: raise ValueError( - f"Content for {memory_file_name} is too long. Max length is {max_answer_chars} characters. " - + "Please make the content shorter." + f"Content for {memory_name} is too long. Max length is {max_chars} characters. " + "Please make the content shorter." ) - return self.memories_manager.save_memory(memory_file_name, content) + return self.memories_manager.save_memory(memory_name, content) class ReadMemoryTool(Tool): @@ -30,14 +32,13 @@ class ReadMemoryTool(Tool): Reads the memory with the given name from Serena's project-specific memory store. """ - def apply(self, memory_file_name: str, max_answer_chars: int = -1) -> str: + def apply(self, memory_name: str) -> str: """ - Read the content of a memory file. This tool should only be used if the information - is relevant to the current task. You can infer whether the information - is relevant from the memory file name. + Read the content of a memory. Should only be used if the information + is relevant to the current task, with relevance inferred from the memory name. You should not read the same memory file multiple times in the same conversation. """ - return self.memories_manager.load_memory(memory_file_name) + return self.memories_manager.load_memory(memory_name) class ListMemoriesTool(Tool): @@ -45,11 +46,11 @@ class ListMemoriesTool(Tool): Lists memories in Serena's project-specific memory store. """ - def apply(self) -> str: + def apply(self, topic: str = "") -> str: """ - List available memories. Any memory can be read using the `read_memory` tool. + List available memories, optionally filtered by topic. """ - return self._to_json(self.memories_manager.list_memories()) + return self._to_json(self.memories_manager.list_memories(topic)) class DeleteMemoryTool(Tool, ToolMarkerCanEdit): @@ -57,19 +58,29 @@ class DeleteMemoryTool(Tool, ToolMarkerCanEdit): Deletes a memory from Serena's project-specific memory store. """ - def apply(self, memory_file_name: str) -> str: + def apply(self, memory_name: str) -> str: """ - Delete a memory file. Should only happen if a user asks for it explicitly, - for example by saying that the information retrieved from a memory file is no longer correct - or no longer relevant for the project. + Delete a memory, only call if instructed explicitly or permission was granted by the user. """ - return self.memories_manager.delete_memory(memory_file_name) + return self.memories_manager.delete_memory(memory_name) + + +class RenameMemoryTool(Tool, ToolMarkerCanEdit): + """ + Renames or moves a memory in Serena's project-specific memory store. + """ + + def apply(self, old_name: str, new_name: str) -> str: + """ + Rename or move a memory, use "/" in the name to organize into topics. + """ + return self.memories_manager.rename_memory(old_name, new_name) class EditMemoryTool(Tool, ToolMarkerCanEdit): def apply( self, - memory_file_name: str, + memory_name: str, needle: str, repl: str, mode: Literal["literal", "regex"], @@ -77,7 +88,7 @@ class EditMemoryTool(Tool, ToolMarkerCanEdit): r""" Replaces content matching a regular expression in a memory. - :param memory_file_name: the name of the memory + :param memory_name: the name of the memory :param needle: the string or regex pattern to search for. If `mode` is "literal", this string will be matched exactly. If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module, @@ -86,5 +97,5 @@ class EditMemoryTool(Tool, ToolMarkerCanEdit): :param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted. """ replace_content_tool = self.agent.get_tool(ReplaceContentTool) - rel_path = self.memories_manager.get_memory_file_path(memory_file_name).relative_to(self.get_project_root()) + rel_path = self.memories_manager.get_memory_file_path(memory_name).relative_to(self.get_project_root()) return replace_content_tool.replace_content(str(rel_path), needle, repl, mode=mode, require_not_ignored=False)