Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ Status of the `main` branch. Changes prior to the next official version change w
* JetBrains:
- `jet_brains_find_symbol`: Disallow wildcard-only search, delegating to overview tool if request is for file

* Memories:
- Add opt-in tools for reading and updating scalar frontmatter metadata while keeping `read_memory`
focused on memory body content

* Language Servers:
- `typescript`: Fix: on large projects, the first `find_referencing_symbols`/`request_references` call
could silently race tsserver's project load and return incomplete results, because the fixed 2s
Expand Down
73 changes: 73 additions & 0 deletions src/serena/memories/frontmatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class FrontmatterParseResult:
frontmatter: dict[str, str]
body: str


class FrontmatterParser:
"""Parser and renderer for simple scalar frontmatter fields."""

DELIMITER = "---"

@staticmethod
def _validate_field(key: str, value: str) -> None:
if not key:
raise ValueError("Frontmatter key must not be empty")
if ":" in key:
raise ValueError("Frontmatter key must not contain ':'")
if "\n" in key or "\r" in key:
raise ValueError("Frontmatter key must be a single line")
if "\n" in value or "\r" in value:
raise ValueError("Frontmatter value must be a single line")

@classmethod
def parse(cls, content: str) -> FrontmatterParseResult:
lines = content.splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != cls.DELIMITER:
return FrontmatterParseResult(frontmatter={}, body=content)

closing_index: int | None = None
for index, line in enumerate(lines[1:], start=1):
if line.rstrip("\r\n") == cls.DELIMITER:
closing_index = index
break

if closing_index is None:
return FrontmatterParseResult(frontmatter={}, body=content)

frontmatter: dict[str, str] = {}
for line in lines[1:closing_index]:
field = line.rstrip("\r\n")
if not field:
continue
if ":" not in field:
return FrontmatterParseResult(frontmatter={}, body=content)

key, value = field.split(":", 1)
key = key.strip()
if not key:
return FrontmatterParseResult(frontmatter={}, body=content)

value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
frontmatter[key] = value

body = "".join(lines[closing_index + 1 :])
return FrontmatterParseResult(frontmatter=frontmatter, body=body)

@classmethod
def render(cls, frontmatter: dict[str, str], body: str) -> str:
if not frontmatter:
return body

for key, value in frontmatter.items():
cls._validate_field(key, value)

fields = "\n".join(f"{key}: {value}" for key, value in frontmatter.items())
return f"{cls.DELIMITER}\n{fields}\n{cls.DELIMITER}\n{body}"
47 changes: 42 additions & 5 deletions src/serena/memories/memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from serena.constants import SERENA_FILE_ENCODING
from serena.util.text_utils import ContentReplacer

from .frontmatter import FrontmatterParser
from .memory_reference_analysis import (
MEMORY_REF_PREFIX,
AutofixReport,
Expand Down Expand Up @@ -201,16 +202,16 @@ def _check_write_access(self, name: str, is_tool_context: bool) -> None:
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:
def _load_memory_raw(self, name: str) -> tuple[str, str]:
name = self._sanitize_name(name)
self._check_not_ignored(name)
memory_file_path = self.get_memory_file_path(name)
if not memory_file_path.exists():
raise FileNotFoundError(f"Memory named '{name}' not found")
with open(memory_file_path, encoding=self._encoding) as f:
return f.read()
return name, f.read()

def save_memory(self, name: str, content: str, is_tool_context: bool) -> str:
def _save_memory_raw(self, name: str, content: str, is_tool_context: bool) -> str:
name = self._sanitize_name(name)
self._check_not_ignored(name)
self._check_write_access(name, is_tool_context)
Expand All @@ -219,6 +220,42 @@ def save_memory(self, name: str, content: str, is_tool_context: bool) -> str:
f.write(content)
return f"Memory {name} written."

def load_memory(self, name: str) -> str:
_, raw = self._load_memory_raw(name)
return FrontmatterParser.parse(raw).body

def save_memory(self, name: str, content: str, is_tool_context: bool) -> str:
"""
Saves memory body content while retaining any existing frontmatter.

New memories may still be created from raw content containing a frontmatter
block. Once frontmatter exists, callers that only know about the body cannot
accidentally discard it.
"""
name = self._sanitize_name(name)
self._check_not_ignored(name)
self._check_write_access(name, is_tool_context)
memory_file_path = self.get_memory_file_path(name)
if memory_file_path.exists():
with open(memory_file_path, encoding=self._encoding) as f:
parsed = FrontmatterParser.parse(f.read())
if parsed.frontmatter:
content = FrontmatterParser.render(parsed.frontmatter, content)
return self._save_memory_raw(name, content, is_tool_context)

def get_memory_frontmatter(self, name: str) -> dict[str, str]:
_, raw = self._load_memory_raw(name)
return dict(FrontmatterParser.parse(raw).frontmatter)

def add_memory_frontmatter(self, name: str, key: str, value: str, is_tool_context: bool) -> str:
name, raw = self._load_memory_raw(name)
self._check_write_access(name, is_tool_context)
parsed = FrontmatterParser.parse(raw)
frontmatter = dict(parsed.frontmatter)
frontmatter[key] = value
content = FrontmatterParser.render(frontmatter, parsed.body)
return self._save_memory_raw(name, content, is_tool_context)

class MemoriesList:
def __init__(self) -> None:
self.memories: list[str] = []
Expand All @@ -237,8 +274,8 @@ def extend(self, other: "MemoryManager.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 = {}
def to_dict(self) -> dict[str, object]:
result: dict[str, object] = {}
if self.memories:
result["memories"] = sorted(self.memories)
if self.read_only_memories:
Expand Down
49 changes: 47 additions & 2 deletions src/serena/tools/memory_tools.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
from typing import Literal

from serena.tools import Tool, ToolMarkerCanEdit
from serena.tools import Tool, ToolMarkerCanEdit, ToolMarkerOptional

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -55,8 +55,53 @@ class ListMemoriesTool(Tool):
def apply(self, topic: str = "") -> str:
"""
Lists available memories, optionally filtered by topic.
Includes frontmatter metadata when the optional memory_get_frontmatter
tool is active.
"""
return self._to_json(self.memory_manager.list_memories(topic).to_dict())
memories = self.memory_manager.list_memories(topic)
result = memories.to_dict()

if MemoryGetFrontmatterTool.get_name_from_cls() in self.agent.get_active_tool_names():
frontmatter = {}
for memory_name in memories.get_full_list():
metadata = self.memory_manager.get_memory_frontmatter(memory_name)
if metadata:
frontmatter[memory_name] = metadata
if frontmatter:
result["frontmatter"] = frontmatter

return self._to_json(result)


class MemoryGetFrontmatterTool(Tool, ToolMarkerOptional):
"""
Reads scalar frontmatter metadata from a memory.

The metadata is stored in an optional block delimited by ``---`` lines at
the beginning of the memory file.
"""

def apply(self, memory_name: str) -> str:
"""
Return the memory's frontmatter as JSON, or an empty object when absent.

:param memory_name: memory name
"""
return self._to_json(self.memory_manager.get_memory_frontmatter(memory_name))


class MemoryAddFrontmatterTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
"""Adds or updates one scalar frontmatter field in a memory."""

def apply(self, memory_name: str, key: str, value: str) -> str:
"""
Add or update a frontmatter field without changing the memory body.

:param memory_name: memory name
:param key: frontmatter field name
:param value: scalar frontmatter value
"""
return self.memory_manager.add_memory_frontmatter(memory_name, key, value, is_tool_context=True)


class DeleteMemoryTool(Tool, ToolMarkerCanEdit):
Expand Down
77 changes: 77 additions & 0 deletions test/serena/test_frontmatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import pytest

from serena.memories.frontmatter import FrontmatterParser


def test_parse_without_frontmatter_returns_content_unchanged() -> None:
content = "# Memory\n\nBody\n"

result = FrontmatterParser.parse(content)

assert result.frontmatter == {}
assert result.body == content


def test_parse_frontmatter_preserves_body_whitespace() -> None:
content = '---\nsummary: "Short description"\nurl: https://example.com:443/docs\n---\n\n# Memory\n\nBody\n'

result = FrontmatterParser.parse(content)

assert result.frontmatter == {
"summary": "Short description",
"url": "https://example.com:443/docs",
}
assert result.body == "\n# Memory\n\nBody\n"


def test_parse_empty_frontmatter() -> None:
result = FrontmatterParser.parse("---\n---\nBody\n")

assert result.frontmatter == {}
assert result.body == "Body\n"


@pytest.mark.parametrize(
"content",
[
"---\nsummary: missing closing delimiter\n",
"---\nnot a scalar field\n---\nBody\n",
"---\n: missing key\n---\nBody\n",
" ---\nsummary: indented opening delimiter\n---\nBody\n",
],
)
def test_malformed_frontmatter_is_plain_content(content: str) -> None:
result = FrontmatterParser.parse(content)

assert result.frontmatter == {}
assert result.body == content


def test_render_round_trip_is_stable() -> None:
frontmatter = {
"summary": "Short description",
"custom": "value:with:colons",
}
body = "\n# Memory\n\nBody with trailing whitespace.\n\n"

rendered = FrontmatterParser.render(frontmatter, body)
parsed = FrontmatterParser.parse(rendered)

assert parsed.frontmatter == frontmatter
assert parsed.body == body
assert FrontmatterParser.render(parsed.frontmatter, parsed.body) == rendered


@pytest.mark.parametrize(
("frontmatter", "error"),
[
({"": "value"}, "must not be empty"),
({"bad:key": "value"}, "must not contain"),
({"bad\nkey": "value"}, "single line"),
({"summary": "bad\nvalue"}, "single line"),
({"summary": "bad\rvalue"}, "single line"),
],
)
def test_render_rejects_non_scalar_fields(frontmatter: dict[str, str], error: str) -> None:
with pytest.raises(ValueError, match=error):
FrontmatterParser.render(frontmatter, "Body\n")
79 changes: 79 additions & 0 deletions test/serena/test_memories_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,85 @@ def _write(manager: MemoryManager, name: str, content: str) -> None:
manager.save_memory(name, content, is_tool_context=False)


class TestMemoryFrontmatter:
def test_load_hides_frontmatter_and_preserves_body_whitespace(self, fs_manager: MemoryManager) -> None:
content = "---\nsummary: Notes\ncustom: value:with:colons\n---\n\n# Body\n\nTail\n"
_write(fs_manager, "notes", content)

assert fs_manager.get_memory_frontmatter("notes") == {
"summary": "Notes",
"custom": "value:with:colons",
}
assert fs_manager.load_memory("notes") == "\n# Body\n\nTail\n"

def test_add_and_update_frontmatter_without_changing_body(self, fs_manager: MemoryManager) -> None:
body = "# Body\n\nTail\n"
_write(fs_manager, "notes", body)

fs_manager.add_memory_frontmatter("notes", "summary", "First", is_tool_context=True)
fs_manager.add_memory_frontmatter("notes", "summary", "Updated", is_tool_context=True)
fs_manager.add_memory_frontmatter("notes", "owner", "team:core", is_tool_context=True)

assert fs_manager.get_memory_frontmatter("notes") == {
"summary": "Updated",
"owner": "team:core",
}
assert fs_manager.load_memory("notes") == body
assert fs_manager.get_memory_file_path("notes").read_text(encoding="utf-8") == (
"---\nsummary: Updated\nowner: team:core\n---\n# Body\n\nTail\n"
)

def test_save_memory_retains_existing_frontmatter(self, fs_manager: MemoryManager) -> None:
_write(fs_manager, "notes", "---\nsummary: Keep me\n---\nOriginal\n")

fs_manager.save_memory("notes", "Updated\n", is_tool_context=False)

assert fs_manager.get_memory_frontmatter("notes") == {"summary": "Keep me"}
assert fs_manager.load_memory("notes") == "Updated\n"

def test_missing_frontmatter_memory_raises(self, fs_manager: MemoryManager) -> None:
with pytest.raises(FileNotFoundError):
fs_manager.get_memory_frontmatter("missing")
with pytest.raises(FileNotFoundError):
fs_manager.add_memory_frontmatter("missing", "summary", "Missing", is_tool_context=True)

def test_ignored_memory_frontmatter_is_inaccessible(self, tmp_path) -> None:
manager = MemoryManager(serena_data_folder=tmp_path, ignored_memory_patterns=[r"secret"])
manager.get_memory_file_path("secret").write_text("---\nsummary: Hidden\n---\nBody\n", encoding="utf-8")

with pytest.raises(ValueError, match="ignored_memory_patterns"):
manager.get_memory_frontmatter("secret")
with pytest.raises(ValueError, match="ignored_memory_patterns"):
manager.add_memory_frontmatter("secret", "owner", "private", is_tool_context=True)

def test_read_only_memory_rejects_frontmatter_writes(self, tmp_path) -> None:
manager = MemoryManager(serena_data_folder=tmp_path, read_only_memory_patterns=[r"frozen/.*"])
_write(manager, "frozen/notes", "Body\n")

with pytest.raises(PermissionError):
manager.add_memory_frontmatter("frozen/notes", "summary", "Frozen", is_tool_context=True)

def test_global_topic_frontmatter(self, fs_manager: MemoryManager, tmp_path, monkeypatch) -> None:
global_dir = tmp_path / "global"
monkeypatch.setattr(fs_manager, "_global_memory_dir", global_dir)
_write(fs_manager, "global/topic/notes", "Body\n")

fs_manager.add_memory_frontmatter("global/topic/notes", "summary", "Global", is_tool_context=True)

assert fs_manager.get_memory_frontmatter("global/topic/notes") == {"summary": "Global"}
assert fs_manager.load_memory("global/topic/notes") == "Body\n"

def test_reference_autofix_retains_frontmatter(self, fs_manager: MemoryManager) -> None:
_write(fs_manager, "auth/login", "# Login\n")
_write(fs_manager, "docs", "---\nsummary: Authentication docs\n---\nSee auth/login for details.\n")

report = fs_manager.auto_prefix_bare_references()

assert report.total_replacements == 1
assert fs_manager.get_memory_frontmatter("docs") == {"summary": "Authentication docs"}
assert fs_manager.load_memory("docs") == "See mem:auth/login for details.\n"


class TestListMemoriesFollowsSymlinks:
"""Regression: memories reachable only through a directory symlink (e.g. a monorepo whose
``.serena/memories`` symlinks each submodule's memory folder, making them addressable as
Expand Down
Loading
Loading