Feature: configure read-only memories through regex patterns

This commit is contained in:
Michael Panchenko committed 2026-03-05 22:19:42 +01:00
1 parent b805b58fe3
commit 60d4cba822
7 files changed
+51 -31

No files matched your search

+3 -2
View File
@@ -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)
+3 -1
View File
@@ -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(
+2 -3
View File
@@ -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
+34 -19
View File
@@ -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
@@ -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: []
@@ -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
+2 -1
View File
@@ -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))