diff --git a/docs/02-usage/045_memories.md b/docs/02-usage/045_memories.md index 49823d0a..2a896f08 100644 --- a/docs/02-usage/045_memories.md +++ b/docs/02-usage/045_memories.md @@ -31,8 +31,9 @@ Global memories use the top-level topic `global`, i.e. whenever a memory name st 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). +If you want to protect them from accidental modification by the agent, +you can add regex patterns to `read_only_memory_patterns` in your global or +project-level [configuration](050_configuration). For example, setting "global/.*" will mark all global memories as read-only. The agent will be informed which memories are read-only. 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) diff --git a/src/serena/agent.py b/src/serena/agent.py index daa87820..526eb222 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -611,7 +611,9 @@ 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_memory_names = MemoriesManager( + project_root=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 "" log.info("Generating system prompt with available_tools=(see active tools), available_markers=%s", available_markers) system_prompt = self.prompt_factory.create_system_prompt( diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 798ef1af..dec53299 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -183,6 +183,7 @@ class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMix symbol_info_budget: float | None = None language_backend: LanguageBackend | None = None + read_only_memory_patterns: list[str] = field(default_factory=list) class SerenaConfigError(Exception): @@ -376,6 +377,7 @@ class ProjectConfig(SharedConfig): fixed_tools=data["fixed_tools"], included_optional_tools=data["included_optional_tools"], read_only=data["read_only"], + read_only_memory_patterns=data.get("read_only_memory_patterns", []), ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"], initial_prompt=data["initial_prompt"], encoding=data["encoding"], @@ -555,9 +557,6 @@ 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 diff --git a/src/serena/project.py b/src/serena/project.py index ed4821cb..0d5b109b 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -1,8 +1,10 @@ import json import logging import os +import re import shutil import threading +from collections.abc import Sequence from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -34,15 +36,23 @@ class MemoriesManager: GLOBAL_TOPIC = "global" _global_memory_dir = SerenaPaths().global_memories_path - def __init__(self, project_root: str, global_memory_tool_write_access: bool = False): + def __init__(self, project_root: str | None, read_only_memory_patterns: Sequence[str] = ()): """ - :param project_root: the project's root directory - :param global_memory_tool_write_access: whether to allow writing global memories in tool execution contexts + :param project_root: the project's root directory. We allow + :param read_only_memory_patterns: 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._project_memory_dir: Path | None = None + if project_root is not None: + 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._encoding = SERENA_FILE_ENCODING + self._read_only_memory_patterns = [re.compile(pattern) for pattern in 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): + return True + return False def _is_global(self, name: str) -> bool: return name == self.GLOBAL_TOPIC or name.startswith(self.GLOBAL_TOPIC + "/") @@ -68,6 +78,7 @@ class MemoriesManager: return self._global_memory_dir / filename # Project-local memory + assert self._project_memory_dir is not None, "Project dir was not passed at initialization" parts = name.split("/") filename = f"{parts[-1]}.md" @@ -81,9 +92,8 @@ class MemoriesManager: 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}')") + if is_tool_context and self._is_read_only_memory(name): + raise PermissionError(f"Attempted to write to read_only memory: '{name}')") def load_memory(self, name: str) -> str: memory_file_path = self.get_memory_file_path(name) @@ -99,24 +109,27 @@ class MemoriesManager: f.write(content) return f"Memory {name} written." - @staticmethod - def _list_memories(search_dir: Path, base_dir: Path, prefix: str = "") -> list[str]: + def _list_memories(self, search_dir: Path, base_dir: Path, prefix: str = "") -> list[str]: if not search_dir.exists(): return [] results = [] + read_only_postfix = " (read only)" 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) + memory_name = prefix + rel + if self._is_read_only_memory(memory_name): + memory_name += read_only_postfix + results.append(memory_name) return results - @classmethod - def list_global_memories(cls, subtopic: str = "") -> list[str]: - dir_path = cls._global_memory_dir + def list_global_memories(self, subtopic: str = "") -> list[str]: + dir_path = self._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 + "/") + return self._list_memories(dir_path, self._global_memory_dir, self.GLOBAL_TOPIC + "/") def list_project_memories(self, topic: str = "") -> list[str]: + 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) @@ -204,9 +217,11 @@ class Project(ToStringMixin): ): self.project_root = project_root self.project_config = project_config - - global_memory_write_access = serena_config.edit_global_memories if serena_config else False - self.memories_manager = MemoriesManager(project_root, global_memory_write_access) + if serena_config is not None: + read_only_memory_patterns = serena_config.read_only_memory_patterns + project_config.read_only_memory_patterns + else: + read_only_memory_patterns = project_config.read_only_memory_patterns + self.memories_manager = MemoriesManager(project_root, read_only_memory_patterns=read_only_memory_patterns) self.language_server_manager: LanguageServerManager | None = None self._is_newly_created = is_newly_created diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index afbb0aca..935273d4 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -123,3 +123,6 @@ initial_prompt: "" # This overrides the corresponding setting in the global configuration; see the documentation there. # 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. +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 57ba3820..643993d9 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -68,11 +68,10 @@ ls_specific_settings: {} # These patterns are merged additively with each project's own ignored_paths. ignored_paths: [] -# whether global memories (the "global" topic) are allowed to be deleted or edited by the agent. -# Global memories are stored in ~/.serena/memories/global/ and shared across all projects. -# Set to false to protect global memories from accidental modification by agents. -# Note: We recommend tracking global memories with git. -edit_global_memories: True +# Mark memories as read-only through regex patterns. +# For example, "global/.*" will mark 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: [] # timeout, in seconds, after which tool executions are terminated tool_timeout: 240 diff --git a/src/serena/tools/memory_tools.py b/src/serena/tools/memory_tools.py index cd9a5cff..ded27053 100644 --- a/src/serena/tools/memory_tools.py +++ b/src/serena/tools/memory_tools.py @@ -56,7 +56,8 @@ class ListMemoriesTool(Tool): def apply(self, topic: str = "") -> str: """ - List available memories, optionally filtered by topic. + List available memories, optionally filtered by topic. Memories marked as read-only cannot be + edited or deleted. """ return self._to_json(self.memories_manager.list_memories(topic))