diff --git a/src/serena/project.py b/src/serena/project.py index faf52ef30..8319ea4fd 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -20,6 +20,7 @@ from serena.constants import SERENA_FILE_ENCODING from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.util.file_system import GitignoreParser, match_path +from serena.util.frontmatter import parse_frontmatter from serena.util.text_utils import ContentReplacer, MatchedConsecutiveLines, search_files from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @@ -96,7 +97,11 @@ def load_memory(self, name: str) -> str: if not memory_file_path.exists(): return f"Memory file {name} not found, consider creating it with the `write_memory` tool if you need it." with open(memory_file_path, encoding=self._encoding) as f: - return f.read() + raw = f.read() + + # Strip optional frontmatter block if present (used by frontmatter tools) + _frontmatter, body = parse_frontmatter(raw) + return body def save_memory(self, name: str, content: str, is_tool_context: bool) -> str: self._check_write_access(name, is_tool_context) diff --git a/src/serena/tools/memory_tools.py b/src/serena/tools/memory_tools.py index 9ecb0c0c3..1f23ba388 100644 --- a/src/serena/tools/memory_tools.py +++ b/src/serena/tools/memory_tools.py @@ -1,6 +1,7 @@ from typing import Literal -from serena.tools import Tool, ToolMarkerCanEdit +from serena.tools import Tool, ToolMarkerCanEdit, ToolMarkerOptional +from serena.util.frontmatter import parse_frontmatter class WriteMemoryTool(Tool, ToolMarkerCanEdit): @@ -58,6 +59,40 @@ def apply(self, topic: str = "") -> str: return self._to_json(self.memories_manager.list_memories(topic).to_dict()) +class MemoryGetFrontmatterTool(Tool, ToolMarkerOptional): + """ + OPTIONAL. Reads and returns the frontmatter of a memory file (if present). + + The frontmatter is a YAML-like block at the very top of a memory file: + + --- + key: "value" + another_key: "value2" + --- + Body content... + + Use this tool only when the metadata is useful for the current task. + Avoid storing long summaries in frontmatter, as it can waste tokens. + """ + + def apply(self, memory_name: str) -> str: + """ + Returns the frontmatter as JSON (a dict). If the memory has no frontmatter, + returns an empty dict. + + :param memory_name: memory name (may include "/") + """ + memory_file_path = self.memories_manager.get_memory_file_path(memory_name) + if not memory_file_path.exists(): + return self._to_json({"error": f"Memory {memory_name} not found"}) + + with open(memory_file_path, encoding=self.memories_manager._encoding) as f: + raw = f.read() + + frontmatter, _ = parse_frontmatter(raw) + return self._to_json(frontmatter) + + class DeleteMemoryTool(Tool, ToolMarkerCanEdit): """ Delete a memory file. Should only happen if a user asks for it explicitly, diff --git a/src/serena/util/frontmatter.py b/src/serena/util/frontmatter.py new file mode 100644 index 000000000..cca800cdb --- /dev/null +++ b/src/serena/util/frontmatter.py @@ -0,0 +1,80 @@ +""" +Minimal frontmatter parsing utilities for memory files. + +Supports a simple YAML-like frontmatter block at the top of a file. + +Example: + +--- +summary: Some short text +author: Mehdi +priority: high +--- + +Body content... + +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FrontmatterParseResult: + frontmatter: dict[str, str] + body: str + + +class FrontmatterParser: + """ + Minimal YAML-like frontmatter parser. + + It extracts key/value pairs from a frontmatter block at the top of a file and + returns the remaining body content. + """ + + @staticmethod + def parse(content: str) -> FrontmatterParseResult: + frontmatter: dict[str, str] = {} + + if not content.startswith("---"): + return FrontmatterParseResult(frontmatter=frontmatter, body=content) + + lines = content.splitlines() + if len(lines) < 3: + return FrontmatterParseResult(frontmatter=frontmatter, body=content) + + if lines[0].strip() != "---": + return FrontmatterParseResult(frontmatter=frontmatter, body=content) + + closing_index = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + closing_index = i + break + + if closing_index is None: + return FrontmatterParseResult(frontmatter=frontmatter, body=content) + + for line in lines[1:closing_index]: + if ":" not in line: + continue + + key, value = line.split(":", 1) + key = key.strip() + value = value.strip().strip('"') + frontmatter[key] = value + + body = "\n".join(lines[closing_index + 1 :]) + return FrontmatterParseResult(frontmatter=frontmatter, body=body) + + +def parse_frontmatter(content: str) -> tuple[dict[str, str], str]: + """ + Backwards-compatible functional wrapper. + + :return: (frontmatter_dict, body_content) + """ + result = FrontmatterParser.parse(content) + return result.frontmatter, result.body