From 60d4cba82297d24aae159ca3bcb2a634df6f72fa Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 5 Mar 2026 22:19:42 +0100 Subject: [PATCH 1/3] Feature: configure read-only memories through regex patterns --- docs/02-usage/045_memories.md | 5 +- src/serena/agent.py | 4 +- src/serena/config/serena_config.py | 5 +- src/serena/project.py | 53 ++++++++++++------- src/serena/resources/project.template.yml | 3 ++ .../resources/serena_config.template.yml | 9 ++-- src/serena/tools/memory_tools.py | 3 +- 7 files changed, 51 insertions(+), 31 deletions(-) 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)) From 4dc617ae6bc50cd25eec3871d6000db0ac7be143 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 6 Mar 2026 23:23:46 +0100 Subject: [PATCH 2/3] Improve read-only memory handling, introducing MemoriesList --- .serena/project.yml | 11 ++-- src/serena/agent.py | 8 +-- src/serena/dashboard.py | 2 +- src/serena/project.py | 65 +++++++++++++------ src/serena/resources/project.template.yml | 3 +- .../resources/serena_config.template.yml | 4 +- src/serena/tools/memory_tools.py | 8 +-- test/serena/config/test_serena_config.py | 2 +- 8 files changed, 65 insertions(+), 38 deletions(-) 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"] From eee2f12a74e488372b75ebe047aba55391a4ecc7 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 6 Mar 2026 23:52:27 +0100 Subject: [PATCH 3/3] Add migration of 'edit_global_memories' setting --- src/serena/config/serena_config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 62d15a8c..f00915ef 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -748,6 +748,14 @@ class SerenaConfig(SharedConfig): instance.log_level = loaded_commented_yaml["gui_log_level"] del loaded_commented_yaml["gui_log_level"] + # migrate "edit_global_memories" + if "edit_global_memories" in loaded_commented_yaml: + num_migrations += 1 + edit_global_memories = loaded_commented_yaml["edit_global_memories"] + if not edit_global_memories: + instance.read_only_memory_patterns.append("global/.*") + del loaded_commented_yaml["edit_global_memories"] + # re-save the configuration file if any migrations were performed if num_migrations > 0: log.info("Legacy configuration was migrated; re-saving configuration file")