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/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 89a4f838..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,15 +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.list_global_memories() - global_memories_list = list_string(global_memory_names) if global_memory_names else "" + global_memories = MemoriesManager( + serena_data_folder=None, read_only_memory_patterns=self.serena_config.read_only_memory_patterns + ).list_global_memories() + 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/config/serena_config.py b/src/serena/config/serena_config.py index b348c64d..74077c96 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -191,6 +191,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): @@ -395,6 +396,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"], @@ -579,9 +581,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.""" - project_serena_folder_location: str = DEFAULT_PROJECT_SERENA_FOLDER_LOCATION """ Template for the location of the per-project .serena data folder (memories, caches, etc.). @@ -754,6 +753,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") 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 a6d5c3c2..1500dbcc 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 Any, Literal @@ -30,15 +32,23 @@ class MemoriesManager: GLOBAL_TOPIC = "global" _global_memory_dir = SerenaPaths().global_memories_path - def __init__(self, serena_data_folder: str | Path, global_memory_tool_write_access: bool = False): + def __init__(self, serena_data_folder: str | Path | None, read_only_memory_patterns: Sequence[str] = ()): """ :param serena_data_folder: the absolute path to the project's .serena data folder - :param global_memory_tool_write_access: whether to allow writing global memories in tool execution contexts + :param read_only_memory_patterns: whether to allow writing global memories in tool execution contexts """ - self._project_memory_dir = Path(serena_data_folder) / "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 serena_data_folder is not None: + self._project_memory_dir = Path(serena_data_folder) / "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 set(read_only_memory_patterns)] + + def _is_read_only_memory(self, name: str) -> bool: + for pattern in self._read_only_memory_patterns: + if pattern.fullmatch(name): + return True + return False def _is_global(self, name: str) -> bool: return name == self.GLOBAL_TOPIC or name.startswith(self.GLOBAL_TOPIC + "/") @@ -64,6 +74,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" @@ -76,10 +87,9 @@ 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 - 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}')") + # 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}')") def load_memory(self, name: str) -> str: memory_file_path = self.get_memory_file_path(name) @@ -95,35 +105,64 @@ class MemoriesManager: f.write(content) return f"Memory {name} written." - @staticmethod - def _list_memories(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 = [] + return result 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) - return results + memory_name = prefix + rel + result.add(memory_name, is_read_only=self._is_read_only_memory(memory_name)) + return result - @classmethod - def list_global_memories(cls, subtopic: str = "") -> list[str]: - dir_path = cls._global_memory_dir + 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 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]: + 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): @@ -133,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) @@ -206,8 +246,8 @@ 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) - global_memory_write_access = serena_config.edit_global_memories if serena_config else False - self.memories_manager = MemoriesManager(self._serena_data_folder, global_memory_write_access) + 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 self._is_newly_created = is_newly_created @@ -308,7 +348,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 afbb0aca..7d508412 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -123,3 +123,7 @@ 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: + +# 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 234928ce..c6549807 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 +# 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: [] # 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..9ecb0c0c 100644 --- a/src/serena/tools/memory_tools.py +++ b/src/serena/tools/memory_tools.py @@ -51,14 +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. + 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 9d7a261f..f6a107a5 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -519,4 +519,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"]