diff --git a/.serena/project.yml b/.serena/project.yml index a9218e45..62e6c5c8 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -96,17 +96,16 @@ initial_prompt: | rather than mere functions (i.e. use the strategy pattern, for example). You avoid the use of low-level data structures in all cases where an object-oriented abstraction would be more appropriate. For simple data storage, you use dataclasses instead of dictionaries or tuples. - + You structure function implementations into functional blocks that are separated by blank lines. Atop each functional block, you write an elliptical phrase (starting with lower-case letter) that describes the purpose of the block in a concise manner. - + Docstrings: You consistently use reStructuredText. - + Comments: When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase clearly defines *what* it is. Any details then follow in subsequent sentences. - # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: utf-8 @@ -143,3 +142,7 @@ symbol_info_budget: # Note: the backend is fixed at startup. If a project with a different backend # is activated post-init, an error will be returned. language_backend: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] diff --git a/src/serena/agent.py b/src/serena/agent.py index d2b0d614..8e096481 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -13,7 +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 sensai.util.string import TextBuilder, dict_string from interprompt.jinja_template import JinjaTemplate from serena import serena_version @@ -603,17 +603,17 @@ class SerenaAgent: def create_system_prompt(self) -> str: available_tools = self._active_tools available_markers = available_tools.tool_marker_names - global_memory_names = MemoriesManager( + global_memories = MemoriesManager( serena_data_folder=None, read_only_memory_patterns=self.serena_config.read_only_memory_patterns ).list_global_memories() - global_memories_list = list_string(global_memory_names) if global_memory_names else "" + global_memories_str = dict_string(global_memories.to_dict()) if len(global_memories) > 0 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, + global_memories_list=global_memories_str, ) # If a project is active at startup, append its activation message diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 7c944a72..3e372de8 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -501,7 +501,7 @@ class SerenaDashboardAPI: # Get available memories if ReadMemoryTool is active available_memories = None if self._agent.tool_is_active("read_memory") and project is not None: - available_memories = project.memories_manager.list_memories() + available_memories = project.memories_manager.list_memories().get_full_list() # Get list of languages for the active project languages = [] diff --git a/src/serena/project.py b/src/serena/project.py index fa0e6691..713c23b4 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -45,8 +45,8 @@ class MemoriesManager: self._read_only_memory_patterns = [re.compile(pattern) for pattern in set(read_only_memory_patterns)] def _is_read_only_memory(self, name: str) -> bool: - for read_only_memory in self._read_only_memory_patterns: - if read_only_memory.match(name): + for pattern in self._read_only_memory_patterns: + if pattern.fullmatch(name): return True return False @@ -87,7 +87,7 @@ class MemoriesManager: 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 + # in tool context, memories can be read-only if is_tool_context and self._is_read_only_memory(name): raise PermissionError(f"Attempted to write to read_only memory: '{name}')") @@ -105,38 +105,64 @@ class MemoriesManager: f.write(content) return f"Memory {name} written." - def _list_memories(self, search_dir: Path, base_dir: Path, prefix: str = "") -> list[str]: + class MemoriesList: + def __init__(self) -> None: + self.memories: list[str] = [] + self.read_only_memories: list[str] = [] + + def __len__(self) -> int: + return len(self.memories) + len(self.read_only_memories) + + def add(self, memory_name: str, is_read_only: bool) -> None: + if is_read_only: + self.read_only_memories.append(memory_name) + else: + self.memories.append(memory_name) + + def extend(self, other: "MemoriesManager.MemoriesList") -> None: + self.memories.extend(other.memories) + self.read_only_memories.extend(other.read_only_memories) + + def to_dict(self) -> dict[str, list[str]]: + result = {} + if self.memories: + result["memories"] = sorted(self.memories) + if self.read_only_memories: + result["read_only_memories"] = sorted(self.read_only_memories) + return result + + def get_full_list(self) -> list[str]: + return sorted(self.memories + self.read_only_memories) + + def _list_memories(self, search_dir: Path, base_dir: Path, prefix: str = "") -> MemoriesList: + result = self.MemoriesList() if not search_dir.exists(): - return [] - results = [] - read_only_postfix = " (read only)" + return result for md_file in search_dir.rglob("*.md"): rel = str(md_file.relative_to(base_dir).with_suffix("")).replace(os.sep, "/") memory_name = prefix + rel - if self._is_read_only_memory(memory_name): - memory_name += read_only_postfix - results.append(memory_name) - return results + result.add(memory_name, is_read_only=self._is_read_only_memory(memory_name)) + return result - def list_global_memories(self, subtopic: str = "") -> list[str]: + def list_global_memories(self, subtopic: str = "") -> MemoriesList: dir_path = self._global_memory_dir if subtopic: dir_path = dir_path / subtopic.replace("/", os.sep) return self._list_memories(dir_path, self._global_memory_dir, self.GLOBAL_TOPIC + "/") - def list_project_memories(self, topic: str = "") -> list[str]: + def list_project_memories(self, topic: str = "") -> MemoriesList: assert self._project_memory_dir is not None, "Project dir was not passed at initialization" 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]: + def list_memories(self, topic: str = "") -> MemoriesList: """ Lists all memories, optionally filtered by topic. If the topic is omitted, both global and project-specific memories are returned. """ - memories: list[str] + memories: MemoriesManager.MemoriesList if topic: if self._is_global(topic): @@ -146,9 +172,10 @@ class MemoriesManager: else: memories = self.list_project_memories(topic=topic) else: - memories = self.list_project_memories() + self.list_global_memories() + memories = self.list_project_memories() + memories.extend(self.list_global_memories()) - return sorted(memories) + return memories def delete_memory(self, name: str, is_tool_context: bool) -> str: self._check_write_access(name, is_tool_context) @@ -219,7 +246,7 @@ class Project(ToStringMixin): self._serena_data_folder = serena_config.get_project_serena_folder(self.project_root) log.info("Serena project data folder: %s", self._serena_data_folder) - read_only_memory_patterns = project_config.read_only_memory_patterns + read_only_memory_patterns = serena_config.read_only_memory_patterns + project_config.read_only_memory_patterns self.memories_manager = MemoriesManager(self._serena_data_folder, read_only_memory_patterns=read_only_memory_patterns) self.language_server_manager: LanguageServerManager | None = None @@ -320,7 +347,7 @@ class Project(ToStringMixin): project_memories = self.memories_manager.list_project_memories() if project_memories: msg += ( - f"\nAvailable project memories: {json.dumps(project_memories)}\n" + f"\nAvailable project memories: {json.dumps(project_memories.to_dict())}\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/project.template.yml b/src/serena/resources/project.template.yml index 935273d4..7d508412 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -124,5 +124,6 @@ initial_prompt: "" # If null or missing, use the setting from the global configuration. symbol_info_budget: -# Extends the same setting from the global configuration, merging the two lists. +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. read_only_memory_patterns: [] \ No newline at end of file diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 1c934640..c6549807 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -68,8 +68,8 @@ ls_specific_settings: {} # These patterns are merged additively with each project's own ignored_paths. ignored_paths: [] -# Mark memories as read-only through regex patterns. -# For example, "global/.*" will mark global memories as read-only. +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# For example, "global/.*" will mark all global memories as read-only. # You can extend the list on a per-project basis in the project.yml configuration file. read_only_memory_patterns: [] diff --git a/src/serena/tools/memory_tools.py b/src/serena/tools/memory_tools.py index ded27053..9ecb0c0c 100644 --- a/src/serena/tools/memory_tools.py +++ b/src/serena/tools/memory_tools.py @@ -51,15 +51,11 @@ class ListMemoriesTool(Tool): List available memories. Any memory can be read using the `read_memory` tool. """ - def list_memories(self, topic: str = "") -> list[str]: - return self.memories_manager.list_memories(topic) - def apply(self, topic: str = "") -> str: """ - List available memories, optionally filtered by topic. Memories marked as read-only cannot be - edited or deleted. + Lists available memories, optionally filtered by topic. """ - return self._to_json(self.memories_manager.list_memories(topic)) + return self._to_json(self.memories_manager.list_memories(topic).to_dict()) class DeleteMemoryTool(Tool, ToolMarkerCanEdit): diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 0e018c50..d875ea28 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -517,4 +517,4 @@ class TestMemoriesManagerCustomPath: manager.save_memory("topic_a", "content a", is_tool_context=False) manager.save_memory("topic_b", "content b", is_tool_context=False) memories = manager.list_project_memories() - assert sorted(memories) == ["topic_a", "topic_b"] + assert sorted(memories.get_full_list()) == ["topic_a", "topic_b"]