mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-11 03:13:51 +00:00
Feature/global memories (#1091)
Support for global (cross-project) memories, adds a news item --------- Co-authored-by: Ariadne[bot] (butterflysky-ai) <good.town5863@butterflysky.dev> Co-authored-by: Dominik Jain <dominik.jain@oraios-ai.de>
This commit is contained in:
19 files changed
+466
-104
No files matched your search
@@ -103,22 +103,12 @@ By default, Serena will perform an **onboarding process** when
|
||||
it is started for the first time for a project.
|
||||
The goal of the onboarding is for Serena to get familiar with the project
|
||||
and to store memories, which it can then draw upon in future interactions.
|
||||
If an LLM should fail to complete the onboarding and does not actually write the
|
||||
respective memories to disk, you may need to ask it to do so explicitly.
|
||||
|
||||
The onboarding will usually read a lot of content from the project, thus filling
|
||||
up the context. It can therefore be advisable to switch to another conversation
|
||||
once the onboarding is complete.
|
||||
After the onboarding, we recommend that you have a quick look at the memories and,
|
||||
if necessary, edit them or add additional ones.
|
||||
In general, **memories** provide a way for Serena to store and retrieve
|
||||
information about the project, relevant conventions, and other relevant aspects.
|
||||
|
||||
**Memories** are files stored in `.serena/memories/` in the project directory,
|
||||
which the agent can choose to read in subsequent interactions.
|
||||
Feel free to read and adjust them as needed; you can also add new ones manually.
|
||||
Every file in the `.serena/memories/` directory is a memory file.
|
||||
Whenever Serena starts working on a project, the list of memories is
|
||||
provided, and the agent can decide to read them.
|
||||
We found that memories can significantly improve the user experience with Serena.
|
||||
For more information on this, including how to manage
|
||||
or disable these features, see [Memories & Onboarding](045-memories).
|
||||
|
||||
|
||||
## Preparing Your Project
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Memories & Onboarding
|
||||
|
||||
Serena provides the functionality of a fully featured agent, and a useful aspect of this is Serena's memory system.
|
||||
Despite its simplicity, we received positive feedback from many users who tend to combine it with their
|
||||
agent's internal memory management (e.g., `AGENTS.md` files).
|
||||
|
||||
## Memories
|
||||
|
||||
Memories are simple, human-readable Markdown files that both you and
|
||||
your agent can create, read, and edit.
|
||||
|
||||
Serena differentiates between
|
||||
* **project-specific memories**, which are stored in the `.serena/memories/` directory within your project folder, and
|
||||
* **global memories**, which are shared across all projects and, by default, are stored in `~/.serena/memories/global/`
|
||||
|
||||
The LLM is informed about the existence of memories and instructed to read them when appropriate,
|
||||
inferring appropriateness from the file name.
|
||||
When the agent starts working on a project, it receives the list of available memories.
|
||||
The agent should be instructed to update memories by the user when appropriate.
|
||||
|
||||
### Organizing Memories
|
||||
|
||||
Memories can be organized into **topics** by using `/` in the memory name (e.g. `modules/frontend`).
|
||||
The structure is mapped to the file system, where topics correspond to subdirectories.
|
||||
The `list_memories` tool can filter by topic, allowing the agent to explore even large numbers of memories in a structured way.
|
||||
|
||||
(global-memories)=
|
||||
### Global Memories
|
||||
|
||||
Global memories use the top-level topic `global`, i.e. whenever a memory name starts with `global/`,
|
||||
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).
|
||||
|
||||
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)
|
||||
in order to have a history of changes and the possibility to revert them if needed.
|
||||
|
||||
### Manually Editing Memories
|
||||
|
||||
You may edit memories directly in the file system, using your preferred text editor or IDE.
|
||||
Alternatively, access them via the [Serena Dashboard](060_dashboard), which provides a graphical interface for
|
||||
viewing, creating, editing, and deleting memories while Serena is running.
|
||||
|
||||
(onboarding)=
|
||||
## Onboarding
|
||||
|
||||
By default, Serena performs an **onboarding process** when it encounters a project
|
||||
for the first time (i.e., when no project memories exist yet).
|
||||
The goal of the onboarding is for Serena to get familiar with the project —
|
||||
its structure, build system, testing setup, and other essential aspects —
|
||||
and to store this knowledge as memories for future interactions.
|
||||
|
||||
In further project activations, Serena will check whether onboarding was already
|
||||
performed by looking for existing project memories and will skip the onboarding
|
||||
process if memories are found.
|
||||
|
||||
### How Onboarding Works
|
||||
|
||||
1. When a project is activated, Serena checks whether onboarding was already
|
||||
performed (by checking if any memories exist).
|
||||
2. If no memories are found, Serena triggers the onboarding process, which
|
||||
reads key files and directories to understand the project.
|
||||
3. The gathered information is written into project-specific memory files (see above).
|
||||
|
||||
### Tips for Onboarding
|
||||
|
||||
- **Context usage**: The onboarding process will read a lot of content from the project,
|
||||
filling up the context window. It is therefore advisable to **switch to a new conversation**
|
||||
once the onboarding is complete.
|
||||
- **LLM failures**: If an LLM fails to complete the onboarding and does not actually
|
||||
write the respective memories to disk, you may need to ask it to do so explicitly.
|
||||
- **Review the results**: After onboarding, we recommend having a quick look at the
|
||||
generated memories and editing them or adding new ones as needed.
|
||||
|
||||
## Disabling Memories and Onboarding
|
||||
|
||||
If you do not require the functionality described in this section, you can selectively disable it.
|
||||
|
||||
* To disable all memory related tools (including onboarding), adding `no-memories` to the `base_modes`
|
||||
in Serena's [global configuration](050-configuration).
|
||||
* Similarly, to disable only onboarding, add `no-onboarding` to the `base_modes`.
|
||||
+5
-2
@@ -12,7 +12,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
|
||||
from sensai.util.string import TextBuilder, list_string
|
||||
|
||||
from interprompt.jinja_template import JinjaTemplate
|
||||
from serena import serena_version
|
||||
@@ -29,7 +29,7 @@ from serena.config.serena_config import (
|
||||
)
|
||||
from serena.dashboard import SerenaDashboardAPI
|
||||
from serena.ls_manager import LanguageServerManager, LanguageServerManagerInitialisationError
|
||||
from serena.project import Project
|
||||
from serena.project import MemoriesManager, Project
|
||||
from serena.prompt_factory import SerenaPromptFactory
|
||||
from serena.task_executor import TaskExecutor
|
||||
from serena.tools import ActivateProjectTool, GetCurrentConfigTool, OpenDashboardTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry
|
||||
@@ -611,12 +611,15 @@ 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 ""
|
||||
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,
|
||||
)
|
||||
|
||||
# If a project is active at startup, append its activation message
|
||||
|
||||
@@ -85,6 +85,12 @@ class SerenaPaths:
|
||||
"""
|
||||
file containing the ID of the last read news snippet
|
||||
"""
|
||||
global_memories_path = Path(os.path.join(self.serena_user_home_dir, "memories", "global"))
|
||||
global_memories_path.mkdir(parents=True, exist_ok=True)
|
||||
self.global_memories_path = global_memories_path
|
||||
"""
|
||||
directory where global memories are stored, i.e. memories that are available across all projects
|
||||
"""
|
||||
self.last_returned_log_file_path: str | None = None
|
||||
"""
|
||||
the path to the last log file returned by `get_next_log_file_path`. If this is not None, the logs
|
||||
@@ -549,6 +555,9 @@ 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
|
||||
@@ -559,7 +568,6 @@ class SerenaConfig(SharedConfig):
|
||||
If the budget is exceeded, Serena stops issuing further requests and returns partial info results.
|
||||
0 disables the budget (no early stopping). Negative values are invalid.
|
||||
"""
|
||||
|
||||
# *** fields that are NOT mapped to/from the configuration file ***
|
||||
|
||||
_loaded_commented_yaml: CommentedMap | None = None
|
||||
|
||||
+31
-4
@@ -89,6 +89,11 @@ class RequestDeleteMemory(BaseModel):
|
||||
memory_name: str
|
||||
|
||||
|
||||
class RequestRenameMemory(BaseModel):
|
||||
old_name: str
|
||||
new_name: str
|
||||
|
||||
|
||||
class ResponseGetSerenaConfig(BaseModel):
|
||||
content: str
|
||||
|
||||
@@ -269,6 +274,18 @@ class SerenaDashboardAPI:
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@self._app.route("/rename_memory", methods=["POST"])
|
||||
def rename_memory() -> dict[str, str]:
|
||||
request_data = request.get_json()
|
||||
if not request_data:
|
||||
return {"status": "error", "message": "No data provided"}
|
||||
request_rename_memory = RequestRenameMemory.model_validate(request_data)
|
||||
try:
|
||||
result_message = self._rename_memory(request_rename_memory)
|
||||
return {"status": "success", "message": result_message}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@self._app.route("/get_serena_config", methods=["GET"])
|
||||
def get_serena_config() -> dict[str, Any]:
|
||||
try:
|
||||
@@ -556,8 +573,7 @@ class SerenaDashboardAPI:
|
||||
project = self._agent.get_active_project()
|
||||
if project is None:
|
||||
raise ValueError("No active project")
|
||||
|
||||
project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content)
|
||||
project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content, is_tool_context=False)
|
||||
|
||||
self._agent.execute_task(run, logged=True, name="SaveMemory")
|
||||
|
||||
@@ -566,11 +582,22 @@ class SerenaDashboardAPI:
|
||||
project = self._agent.get_active_project()
|
||||
if project is None:
|
||||
raise ValueError("No active project")
|
||||
|
||||
project.memories_manager.delete_memory(request_delete_memory.memory_name)
|
||||
project.memories_manager.delete_memory(request_delete_memory.memory_name, is_tool_context=False)
|
||||
|
||||
self._agent.execute_task(run, logged=True, name="DeleteMemory")
|
||||
|
||||
def _rename_memory(self, request_rename_memory: RequestRenameMemory) -> str:
|
||||
def run() -> str:
|
||||
project = self._agent.get_active_project()
|
||||
if project is None:
|
||||
raise ValueError("No active project")
|
||||
|
||||
return project.memories_manager.move_memory(
|
||||
request_rename_memory.old_name, request_rename_memory.new_name, is_tool_context=False
|
||||
)
|
||||
|
||||
return self._agent.execute_task(run, logged=True, name="RenameMemory")
|
||||
|
||||
def _get_serena_config(self) -> ResponseGetSerenaConfig:
|
||||
config_path = self._agent.serena_config.config_file_path
|
||||
if config_path is None or not os.path.exists(config_path):
|
||||
|
||||
@@ -33,6 +33,12 @@ class PromptFactory(PromptFactoryBase):
|
||||
return self._render_prompt("prepare_for_new_conversation", locals())
|
||||
|
||||
def create_system_prompt(
|
||||
self, *, available_markers: Any, available_tools: Any, context_system_prompt: Any, mode_system_prompts: Any
|
||||
self,
|
||||
*,
|
||||
available_markers: Any,
|
||||
available_tools: Any,
|
||||
context_system_prompt: Any,
|
||||
global_memories_list: Any,
|
||||
mode_system_prompts: Any,
|
||||
) -> str:
|
||||
return self._render_prompt("system_prompt", locals())
|
||||
+116
-33
@@ -1,9 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import pathspec
|
||||
from sensai.util.logging import LogTime
|
||||
@@ -12,12 +13,13 @@ from sensai.util.string import ToStringMixin
|
||||
from serena.config.serena_config import (
|
||||
DEFAULT_TOOL_TIMEOUT,
|
||||
ProjectConfig,
|
||||
SerenaPaths,
|
||||
get_serena_managed_in_project_dir,
|
||||
)
|
||||
from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_NAME
|
||||
from serena.ls_manager import LanguageServerFactory, LanguageServerManager
|
||||
from serena.util.file_system import GitignoreParser, match_path
|
||||
from serena.util.text_utils import MatchedConsecutiveLines, search_files
|
||||
from serena.util.text_utils import ContentReplacer, MatchedConsecutiveLines, search_files
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.ls_utils import FileUtils
|
||||
@@ -29,26 +31,59 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MemoriesManager:
|
||||
def __init__(self, project_root: str):
|
||||
self._memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories"
|
||||
self._memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
GLOBAL_TOPIC = "global"
|
||||
_global_memory_dir = SerenaPaths().global_memories_path
|
||||
|
||||
def __init__(self, project_root: str, global_memory_tool_write_access: bool = False):
|
||||
"""
|
||||
:param project_root: the project's root directory
|
||||
:param global_memory_tool_write_access: 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._encoding = SERENA_FILE_ENCODING
|
||||
|
||||
def _is_global(self, name: str) -> bool:
|
||||
return name == self.GLOBAL_TOPIC or name.startswith(self.GLOBAL_TOPIC + "/")
|
||||
|
||||
def get_memory_file_path(self, name: str) -> Path:
|
||||
# Strip .md extension if present
|
||||
name = name.replace(".md", "")
|
||||
|
||||
# Split by "/" to handle subdirectories
|
||||
if self._is_global(name):
|
||||
if name == self.GLOBAL_TOPIC:
|
||||
raise ValueError(
|
||||
f'Bare "{self.GLOBAL_TOPIC}" is not a valid memory name. '
|
||||
f'Use "{self.GLOBAL_TOPIC}/<name>" to address a global memory.'
|
||||
)
|
||||
# Strip "global/" prefix and resolve against global dir
|
||||
sub_name = name[len(self.GLOBAL_TOPIC) + 1 :]
|
||||
parts = sub_name.split("/")
|
||||
filename = f"{parts[-1]}.md"
|
||||
if len(parts) > 1:
|
||||
subdir = self._global_memory_dir / "/".join(parts[:-1])
|
||||
subdir.mkdir(parents=True, exist_ok=True)
|
||||
return subdir / filename
|
||||
return self._global_memory_dir / filename
|
||||
|
||||
# Project-local memory
|
||||
parts = name.split("/")
|
||||
filename = f"{parts[-1]}.md"
|
||||
|
||||
if len(parts) > 1:
|
||||
# Create subdirectory path
|
||||
subdir = self._memory_dir / "/".join(parts[:-1])
|
||||
subdir = self._project_memory_dir / "/".join(parts[:-1])
|
||||
subdir.mkdir(parents=True, exist_ok=True)
|
||||
return subdir / filename
|
||||
|
||||
return self._memory_dir / filename
|
||||
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}')")
|
||||
|
||||
def load_memory(self, name: str) -> str:
|
||||
memory_file_path = self.get_memory_file_path(name)
|
||||
@@ -57,47 +92,70 @@ class MemoriesManager:
|
||||
with open(memory_file_path, encoding=self._encoding) as f:
|
||||
return f.read()
|
||||
|
||||
def save_memory(self, name: str, content: str) -> str:
|
||||
def save_memory(self, name: str, content: str, is_tool_context: bool) -> str:
|
||||
self._check_write_access(name, is_tool_context)
|
||||
memory_file_path = self.get_memory_file_path(name)
|
||||
with open(memory_file_path, "w", encoding=self._encoding) as f:
|
||||
f.write(content)
|
||||
return f"Memory {name} written."
|
||||
|
||||
@staticmethod
|
||||
def _list_memories(search_dir: Path, base_dir: Path, prefix: str = "") -> list[str]:
|
||||
if not search_dir.exists():
|
||||
return []
|
||||
results = []
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def list_global_memories(cls, subtopic: str = "") -> list[str]:
|
||||
dir_path = cls._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 + "/")
|
||||
|
||||
def list_project_memories(self, topic: str = "") -> list[str]:
|
||||
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]:
|
||||
"""
|
||||
List memories, optionally filtered by topic.
|
||||
Lists all memories, optionally filtered by topic.
|
||||
If the topic is omitted, both global and project-specific memories are returned.
|
||||
"""
|
||||
memories = []
|
||||
memories: list[str]
|
||||
|
||||
if topic:
|
||||
# Only list memories in specified subdirectory
|
||||
search_dir = self._memory_dir / topic.replace("/", os.sep)
|
||||
if not search_dir.exists():
|
||||
return []
|
||||
if self._is_global(topic):
|
||||
topic_parts = topic.split("/")
|
||||
subtopic = "/".join(topic_parts[1:])
|
||||
memories = self.list_global_memories(subtopic=subtopic)
|
||||
else:
|
||||
memories = self.list_project_memories(topic=topic)
|
||||
else:
|
||||
search_dir = self._memory_dir
|
||||
memories = self.list_project_memories() + self.list_global_memories()
|
||||
|
||||
# Recursively find all .md files
|
||||
for md_file in search_dir.rglob("*.md"):
|
||||
# Calculate relative path as memory name
|
||||
rel_path = md_file.relative_to(self._memory_dir)
|
||||
name = str(rel_path.with_suffix("")).replace(os.sep, "/")
|
||||
memories.append(name)
|
||||
|
||||
# Sort alphabetically by name
|
||||
return sorted(memories)
|
||||
|
||||
def delete_memory(self, name: str) -> str:
|
||||
def delete_memory(self, name: str, is_tool_context: bool) -> str:
|
||||
self._check_write_access(name, is_tool_context)
|
||||
memory_file_path = self.get_memory_file_path(name)
|
||||
if not memory_file_path.exists():
|
||||
return f"Memory {name} not found."
|
||||
memory_file_path.unlink()
|
||||
return f"Memory {name} deleted."
|
||||
|
||||
def rename_memory(self, old_name: str, new_name: str) -> str:
|
||||
def move_memory(self, old_name: str, new_name: str, is_tool_context: bool) -> str:
|
||||
"""
|
||||
Rename or move a memory file.
|
||||
Moving between global and project scope (e.g. "global/foo" -> "bar") is supported.
|
||||
"""
|
||||
self._check_write_access(new_name, is_tool_context)
|
||||
|
||||
old_path = self.get_memory_file_path(old_name)
|
||||
new_path = self.get_memory_file_path(new_name)
|
||||
|
||||
@@ -106,13 +164,35 @@ class MemoriesManager:
|
||||
if new_path.exists():
|
||||
raise FileExistsError(f"Memory {new_name} already exists.")
|
||||
|
||||
# Ensure target directory exists
|
||||
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(old_path, new_path)
|
||||
|
||||
# Move/rename the file
|
||||
old_path.rename(new_path)
|
||||
return f"Memory renamed from {old_name} to {new_name}."
|
||||
|
||||
def edit_memory(
|
||||
self, name: str, needle: str, repl: str, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool, is_tool_context: bool
|
||||
) -> str:
|
||||
"""
|
||||
Edit a memory by replacing content matching a pattern.
|
||||
|
||||
:param name: the memory name
|
||||
:param needle: the string or regex to search for
|
||||
:param repl: the replacement string
|
||||
:param mode: "literal" or "regex"
|
||||
:param allow_multiple_occurrences:
|
||||
"""
|
||||
self._check_write_access(name, is_tool_context)
|
||||
memory_file_path = self.get_memory_file_path(name)
|
||||
if not memory_file_path.exists():
|
||||
raise FileNotFoundError(f"Memory {name} not found.")
|
||||
with open(memory_file_path, encoding=self._encoding) as f:
|
||||
original_content = f.read()
|
||||
replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences)
|
||||
updated_content = replacer.replace(original_content, needle, repl)
|
||||
with open(memory_file_path, "w", encoding=self._encoding) as f:
|
||||
f.write(updated_content)
|
||||
return f"Memory {name} edited successfully."
|
||||
|
||||
|
||||
class Project(ToStringMixin):
|
||||
def __init__(
|
||||
@@ -124,7 +204,10 @@ class Project(ToStringMixin):
|
||||
):
|
||||
self.project_root = project_root
|
||||
self.project_config = project_config
|
||||
self.memories_manager = MemoriesManager(project_root)
|
||||
|
||||
global_memory_write_access = serena_config.edit_global_memories if serena_config else False
|
||||
self.memories_manager = MemoriesManager(project_root, global_memory_write_access)
|
||||
|
||||
self.language_server_manager: LanguageServerManager | None = None
|
||||
self._is_newly_created = is_newly_created
|
||||
|
||||
@@ -220,10 +303,10 @@ class Project(ToStringMixin):
|
||||
msg = f"The project with name '{self.project_name}' at {self.project_root} is activated."
|
||||
languages_str = ", ".join([lang.value for lang in self.project_config.languages])
|
||||
msg += f"\nProgramming languages: {languages_str}; file encoding: {self.project_config.encoding}"
|
||||
memories = self.memories_manager.list_memories()
|
||||
if memories:
|
||||
project_memories = self.memories_manager.list_project_memories()
|
||||
if project_memories:
|
||||
msg += (
|
||||
f"\nAvailable project memories: {json.dumps(memories)}\n"
|
||||
f"\nAvailable project memories: {json.dumps(project_memories)}\n"
|
||||
+ "Use the `read_memory` tool to read these memories later if they are relevant to the task."
|
||||
)
|
||||
if self.project_config.initial_prompt:
|
||||
|
||||
@@ -6,6 +6,7 @@ excluded_tools:
|
||||
- read_memory
|
||||
- delete_memory
|
||||
- edit_memory
|
||||
- rename_memory
|
||||
- list_memories
|
||||
- onboarding
|
||||
- check_onboarding_performed
|
||||
@@ -35,10 +35,13 @@ prompts:
|
||||
You can understand relationships between symbols by using the `find_referencing_symbols` tool.
|
||||
{% endif %}
|
||||
|
||||
{% if 'read_memory' in available_tools %}
|
||||
{% if 'read_memory' in available_tools -%}
|
||||
You generally have access to memories and it may be useful for you to read them.
|
||||
You infer whether memories are relevant based on their names.
|
||||
{% endif %}
|
||||
{% if global_memories_list -%}
|
||||
The following global (not project-specific) memories are available to you: {{ global_memories_list }}
|
||||
{%- endif -%}
|
||||
{%- endif %}
|
||||
|
||||
The context and modes of operation are described below. These determine how to interact with your user
|
||||
and which kinds of interactions are expected of you.
|
||||
|
||||
@@ -1308,6 +1308,42 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Memory Rename Styles */
|
||||
.memory-rename-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.memory-rename-btn:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.memory-rename-input {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--btn-primary);
|
||||
outline: none;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
/* Log Action Buttons (Save, Copy, Clear) */
|
||||
.log-action-buttons {
|
||||
position: absolute;
|
||||
|
||||
@@ -307,6 +307,8 @@ class Dashboard {
|
||||
this.$modalCloseRemove = $('.modal-close-remove');
|
||||
this.$editMemoryModal = $('#edit-memory-modal');
|
||||
this.$editMemoryName = $('#edit-memory-name');
|
||||
this.$editMemoryRenameBtn = $('#edit-memory-rename-btn');
|
||||
this.$editMemoryRenameInput = $('#edit-memory-rename-input');
|
||||
this.$editMemoryContent = $('#edit-memory-content');
|
||||
this.$editMemorySaveBtn = $('#edit-memory-save-btn');
|
||||
this.$editMemoryCancelBtn = $('#edit-memory-cancel-btn');
|
||||
@@ -365,6 +367,19 @@ class Dashboard {
|
||||
this.$editMemoryCancelBtn.click(this.closeEditMemoryModal.bind(this));
|
||||
this.$modalCloseEditMemory.click(this.closeEditMemoryModal.bind(this));
|
||||
this.$editMemoryContent.on('input', this.trackMemoryChanges.bind(this));
|
||||
this.$editMemoryRenameBtn.click(this.startMemoryRename.bind(this));
|
||||
this.$editMemoryRenameInput.keydown(function (e) {
|
||||
if (e.which === 13) { // Enter key
|
||||
e.preventDefault();
|
||||
self.commitMemoryRename();
|
||||
} else if (e.which === 27) { // Escape key
|
||||
e.preventDefault();
|
||||
self.cancelMemoryRename();
|
||||
}
|
||||
});
|
||||
this.$editMemoryRenameInput.on('blur', function () {
|
||||
self.cancelMemoryRename();
|
||||
});
|
||||
this.$deleteMemoryOkBtn.click(this.confirmDeleteMemoryOk.bind(this));
|
||||
this.$deleteMemoryCancelBtn.click(this.closeDeleteMemoryModal.bind(this));
|
||||
this.$modalCloseDeleteMemory.click(this.closeDeleteMemoryModal.bind(this));
|
||||
@@ -1887,6 +1902,62 @@ class Dashboard {
|
||||
});
|
||||
}
|
||||
|
||||
startMemoryRename() {
|
||||
this.$editMemoryName.hide();
|
||||
this.$editMemoryRenameBtn.hide();
|
||||
this.$editMemoryRenameInput.val(this.currentMemoryName).show().focus().select();
|
||||
}
|
||||
|
||||
cancelMemoryRename() {
|
||||
this.$editMemoryRenameInput.hide();
|
||||
this.$editMemoryName.show();
|
||||
this.$editMemoryRenameBtn.show();
|
||||
}
|
||||
|
||||
commitMemoryRename() {
|
||||
const newName = this.$editMemoryRenameInput.val().trim();
|
||||
const oldName = this.currentMemoryName;
|
||||
|
||||
// If name unchanged, just cancel
|
||||
if (!newName || newName === oldName) {
|
||||
this.cancelMemoryRename();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate memory name (alphanumeric, underscores, and slashes for subdirectories)
|
||||
if (!/^[a-zA-Z0-9_]+(?:\/[a-zA-Z0-9_]+)*$/.test(newName)) {
|
||||
alert('Memory name can only contain letters, numbers, underscores, and "/" for subdirectories (e.g., "topic/memory_name")');
|
||||
this.$editMemoryRenameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
this.$editMemoryRenameInput.prop('disabled', true);
|
||||
|
||||
$.ajax({
|
||||
url: '/rename_memory', type: 'POST', contentType: 'application/json', data: JSON.stringify({
|
||||
old_name: oldName, new_name: newName
|
||||
}), success: function (response) {
|
||||
if (response.status === 'success') {
|
||||
self.currentMemoryName = newName;
|
||||
self.$editMemoryName.text(newName);
|
||||
self.cancelMemoryRename();
|
||||
// Reload config to reflect the rename in the memory list
|
||||
self.loadConfigOverview();
|
||||
} else {
|
||||
alert('Error: ' + response.message);
|
||||
self.$editMemoryRenameInput.focus();
|
||||
}
|
||||
}, error: function (xhr, status, error) {
|
||||
console.error('Error renaming memory:', error);
|
||||
alert('Error renaming memory: ' + (xhr.responseJSON ? xhr.responseJSON.message : error));
|
||||
self.$editMemoryRenameInput.focus();
|
||||
}, complete: function () {
|
||||
self.$editMemoryRenameInput.prop('disabled', false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
confirmDeleteMemory(memoryName) {
|
||||
// Set memory name to delete
|
||||
this.memoryToDelete = memoryName;
|
||||
@@ -1960,9 +2031,9 @@ class Dashboard {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate memory name (alphanumeric and underscores only)
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(memoryName)) {
|
||||
alert('Memory name can only contain letters, numbers, and underscores');
|
||||
// Validate memory name (alphanumeric, underscores, and slashes for subdirectories)
|
||||
if (!/^[a-zA-Z0-9_]+(?:\/[a-zA-Z0-9_]+)*$/.test(memoryName)) {
|
||||
alert('Memory name can only contain letters, numbers, underscores, and "/" for subdirectories (e.g., "topic/memory_name")');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -275,7 +275,17 @@
|
||||
<div id="edit-memory-modal" class="modal" style="display:none">
|
||||
<div class="modal-content modal-content-large">
|
||||
<span class="modal-close modal-close-edit-memory">×</span>
|
||||
<h3 style="margin-bottom: 20px;">Memory: <span id="edit-memory-name"></span></h3>
|
||||
<h3 style="margin-bottom: 20px; display: flex; align-items: center; gap: 8px;">
|
||||
Memory:
|
||||
<span id="edit-memory-name" class="memory-name-display"></span>
|
||||
<span id="edit-memory-rename-btn" class="memory-rename-btn" title="Rename memory">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<input type="text" id="edit-memory-rename-input" class="memory-rename-input" style="display: none;">
|
||||
</h3>
|
||||
<textarea id="edit-memory-content" class="memory-editor" rows="20"
|
||||
style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary); font-family: 'Courier New', monospace; font-size: 13px; resize: vertical;"></textarea>
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
||||
@@ -312,13 +322,14 @@
|
||||
</p>
|
||||
<p style="margin: 15px 0; color: var(--text-muted); font-size: 13px; line-height: 1.5;">
|
||||
Memory names should be descriptive and use underscores instead of spaces (e.g., "api_architecture",
|
||||
"testing_guidelines").
|
||||
"testing_guidelines"). Use "/" to organize memories into subdirectories (e.g., "architecture/api_design"),
|
||||
use the "global/" prefix to write memories that are shared across projects (e.g., "global/java/style_guide").
|
||||
</p>
|
||||
<div style="margin: 20px 0;">
|
||||
<label for="create-memory-name-input" style="display: block; margin-bottom: 8px; font-weight: 500;">Memory
|
||||
Name:</label>
|
||||
<input type="text" id="create-memory-name-input" class="memory-name-input"
|
||||
placeholder="e.g., project_overview"
|
||||
placeholder="e.g., project_overview or topic/memory_name"
|
||||
style="width: 100%; padding: 8px 10px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); font-size: 14px;">
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="news-item">
|
||||
<h3>Nested and Global Memories</h3>
|
||||
<p class="date">March 03, 2026</p>
|
||||
<p>
|
||||
Serena's memory system has been significantly extended in functionality.
|
||||
It keeps the simplicity that made it popular but now supports structuring memories
|
||||
into topics and sharing them across projects.
|
||||
</p>
|
||||
<p>
|
||||
For more detailed information, see the <a href="https://oraios.github.io/serena/02-usage/045_memories.html">documentation</a>.
|
||||
</p>
|
||||
</div>
|
||||
@@ -68,6 +68,12 @@ 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
|
||||
|
||||
# timeout, in seconds, after which tool executions are terminated
|
||||
tool_timeout: 240
|
||||
|
||||
|
||||
@@ -192,10 +192,8 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit):
|
||||
If mode is "regex", the string can contain backreferences to matched groups in the needle regex,
|
||||
specified using the syntax $!1, $!2, etc. for groups 1, 2, etc.
|
||||
:param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted.
|
||||
:param allow_multiple_occurrences: if True, the regex may match multiple occurrences in the file
|
||||
and all of them will be replaced.
|
||||
If this is set to False and the regex matches multiple occurrences, an error will be returned
|
||||
(and you may retry with a revised, more specific regex).
|
||||
:param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences.
|
||||
If false and multiple occurrences are found, an error will be returned
|
||||
"""
|
||||
return self.replace_content(
|
||||
relative_path, needle, repl, mode=mode, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from typing import Literal
|
||||
|
||||
from serena.tools import ReplaceContentTool, Tool, ToolMarkerCanEdit
|
||||
from serena.tools import Tool, ToolMarkerCanEdit
|
||||
|
||||
|
||||
class WriteMemoryTool(Tool, ToolMarkerCanEdit):
|
||||
"""
|
||||
Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
|
||||
The memory name should be meaningful.
|
||||
"""
|
||||
|
||||
def apply(self, memory_name: str, content: str, max_chars: int = -1) -> str:
|
||||
"""
|
||||
Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
|
||||
Write information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
|
||||
The memory name should be meaningful and can include "/" to organize into topics (e.g., "auth/login/logic").
|
||||
If explicitly instructed, use the "global/" prefix for writing a memory that is shared across projects
|
||||
(e.g., "global/java/style_guide")
|
||||
|
||||
:param max_chars: the maximum number of characters to write. By default, determined by the config,
|
||||
change only if instructed to do so.
|
||||
@@ -24,28 +27,33 @@ class WriteMemoryTool(Tool, ToolMarkerCanEdit):
|
||||
f"Content for {memory_name} is too long. Max length is {max_chars} characters. " + "Please make the content shorter."
|
||||
)
|
||||
|
||||
return self.memories_manager.save_memory(memory_name, content)
|
||||
return self.memories_manager.save_memory(memory_name, content, is_tool_context=True)
|
||||
|
||||
|
||||
class ReadMemoryTool(Tool):
|
||||
"""
|
||||
Reads the memory with the given name from Serena's project-specific memory store.
|
||||
Read the content of a memory file. This tool should only be used if the information
|
||||
is relevant to the current task. You can infer whether the information
|
||||
is relevant from the memory file name.
|
||||
You should not read the same memory file multiple times in the same conversation.
|
||||
"""
|
||||
|
||||
def apply(self, memory_name: str) -> str:
|
||||
"""
|
||||
Read the content of a memory. Should only be used if the information
|
||||
is relevant to the current task, with relevance inferred from the memory name.
|
||||
You should not read the same memory file multiple times in the same conversation.
|
||||
Reads the contents of a memory. Should only be used if the information
|
||||
is likely to be relevant to the current task, inferring relevance from the memory name.
|
||||
"""
|
||||
return self.memories_manager.load_memory(memory_name)
|
||||
|
||||
|
||||
class ListMemoriesTool(Tool):
|
||||
"""
|
||||
Lists memories in Serena's project-specific memory store.
|
||||
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.
|
||||
@@ -55,35 +63,44 @@ class ListMemoriesTool(Tool):
|
||||
|
||||
class DeleteMemoryTool(Tool, ToolMarkerCanEdit):
|
||||
"""
|
||||
Deletes a memory from Serena's project-specific memory store.
|
||||
Delete a memory file. Should only happen if a user asks for it explicitly,
|
||||
for example by saying that the information retrieved from a memory file is no longer correct
|
||||
or no longer relevant for the project.
|
||||
"""
|
||||
|
||||
def apply(self, memory_name: str) -> str:
|
||||
"""
|
||||
Delete a memory, only call if instructed explicitly or permission was granted by the user.
|
||||
"""
|
||||
return self.memories_manager.delete_memory(memory_name)
|
||||
return self.memories_manager.delete_memory(memory_name, is_tool_context=True)
|
||||
|
||||
|
||||
class RenameMemoryTool(Tool, ToolMarkerCanEdit):
|
||||
"""
|
||||
Renames or moves a memory in Serena's project-specific memory store.
|
||||
Renames or moves a memory. Moving between project and global scope is supported
|
||||
(e.g., renaming "global/foo" to "bar" moves it from global to project scope).
|
||||
"""
|
||||
|
||||
def apply(self, old_name: str, new_name: str) -> str:
|
||||
"""
|
||||
Rename or move a memory, use "/" in the name to organize into topics.
|
||||
The "global" topic should only be used if explicitly instructed.
|
||||
"""
|
||||
return self.memories_manager.rename_memory(old_name, new_name)
|
||||
return self.memories_manager.move_memory(old_name, new_name, is_tool_context=True)
|
||||
|
||||
|
||||
class EditMemoryTool(Tool, ToolMarkerCanEdit):
|
||||
"""
|
||||
Replaces content matching a regular expression in a memory.
|
||||
"""
|
||||
|
||||
def apply(
|
||||
self,
|
||||
memory_name: str,
|
||||
needle: str,
|
||||
repl: str,
|
||||
mode: Literal["literal", "regex"],
|
||||
allow_multiple_occurrences: bool = False,
|
||||
) -> str:
|
||||
r"""
|
||||
Replaces content matching a regular expression in a memory.
|
||||
@@ -95,7 +112,7 @@ class EditMemoryTool(Tool, ToolMarkerCanEdit):
|
||||
with flags DOTALL and MULTILINE enabled).
|
||||
:param repl: the replacement string (verbatim).
|
||||
:param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted.
|
||||
:param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences.
|
||||
If false and multiple occurrences are found, an error will be returned.
|
||||
"""
|
||||
replace_content_tool = self.agent.get_tool(ReplaceContentTool)
|
||||
rel_path = self.memories_manager.get_memory_file_path(memory_name).relative_to(self.get_project_root())
|
||||
return replace_content_tool.replace_content(str(rel_path), needle, repl, mode=mode, require_not_ignored=False)
|
||||
return self.memories_manager.edit_memory(memory_name, needle, repl, mode, allow_multiple_occurrences, is_tool_context=True)
|
||||
@@ -382,7 +382,8 @@ tool_packages = ["serena.tools"]
|
||||
class ToolRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._tool_dict: dict[str, RegisteredTool] = {}
|
||||
for cls in iter_subclasses(Tool):
|
||||
inclusion_predicate = lambda c: "apply" in c.__dict__ # include only concrete tool classes that implement apply
|
||||
for cls in iter_subclasses(Tool, inclusion_predicate=inclusion_predicate):
|
||||
if not any(cls.__module__.startswith(pkg) for pkg in tool_packages):
|
||||
continue
|
||||
is_optional = issubclass(cls, ToolMarkerOptional)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Tools supporting the general workflow of the agent
|
||||
"""
|
||||
|
||||
import json
|
||||
import platform
|
||||
|
||||
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional
|
||||
@@ -18,23 +17,21 @@ class CheckOnboardingPerformedTool(Tool):
|
||||
Checks whether project onboarding was already performed.
|
||||
You should always call this tool before beginning to actually work on the project/after activating a project.
|
||||
"""
|
||||
from .memory_tools import ListMemoriesTool
|
||||
|
||||
list_memories_tool = self.agent.get_tool(ListMemoriesTool)
|
||||
memories = json.loads(list_memories_tool.apply())
|
||||
if len(memories) == 0:
|
||||
return (
|
||||
project_memories = self.memories_manager.list_project_memories()
|
||||
if len(project_memories) == 0:
|
||||
msg = (
|
||||
"Onboarding not performed yet (no memories available). "
|
||||
+ "You should perform onboarding by calling the `onboarding` tool before proceeding with the task."
|
||||
"You should perform onboarding by calling the `onboarding` tool before proceeding with the task. "
|
||||
)
|
||||
else:
|
||||
return f"""The onboarding was already performed, below is the list of available memories.
|
||||
Do not read them immediately, just remember that they exist and that you can read them later, if it is necessary
|
||||
for the current task.
|
||||
Some memories may be based on previous conversations, others may be general for the current project.
|
||||
You should be able to tell which one you need based on the name of the memory.
|
||||
|
||||
{memories}"""
|
||||
# Not reporting the list of memories here, as they were already reported at project activation
|
||||
# (with the system prompt if the project was activated at startup)
|
||||
msg = (
|
||||
f"Onboarding was already performed: {len(project_memories)} project memories are available. "
|
||||
"Consider reading memories if they appear relevant to the task at hand."
|
||||
)
|
||||
msg += " If you have not read the 'Serena Instructions Manual', do so now."
|
||||
return msg
|
||||
|
||||
|
||||
class OnboardingTool(Tool):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import TypeVar
|
||||
|
||||
from serena.util.file_system import find_all_non_ignored_files
|
||||
@@ -11,12 +11,20 @@ T = TypeVar("T")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def iter_subclasses(cls: type[T], recursive: bool = True) -> Generator[type[T], None, None]:
|
||||
"""Iterate over all subclasses of a class. If recursive is True, also iterate over all subclasses of all subclasses."""
|
||||
def iter_subclasses(
|
||||
cls: type[T], recursive: bool = True, inclusion_predicate: Callable[[type[T]], bool] = lambda t: True
|
||||
) -> Iterator[type[T]]:
|
||||
"""Iterate over all subclasses of a class.
|
||||
|
||||
:param cls: The class whose subclasses to iterate over.
|
||||
:param recursive: If True, also iterate over all subclasses of all subclasses.
|
||||
:param inclusion_predicate: a predicate function to decide whether to include a subclass in the result
|
||||
"""
|
||||
for subclass in cls.__subclasses__():
|
||||
yield subclass
|
||||
if inclusion_predicate(subclass):
|
||||
yield subclass
|
||||
if recursive:
|
||||
yield from iter_subclasses(subclass, recursive)
|
||||
yield from iter_subclasses(subclass, recursive, inclusion_predicate)
|
||||
|
||||
|
||||
def determine_programming_language_composition(repo_path: str) -> dict[Language, float]:
|
||||
|
||||
Reference in new issue
Block a user