From bbc648909b53772feb02fed3b9db50942518e5e3 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Mon, 9 Feb 2026 20:49:41 -0800 Subject: [PATCH] refactor: drop memory/ prefix from git memory repo file paths and update core memory rendering [LET-7356] (#9395) --- letta/schemas/memory.py | 79 +++++++++++++------ letta/server/rest_api/routers/v1/git_http.py | 8 +- letta/services/block_manager_git.py | 4 +- .../services/memory_repo/memfs_client_base.py | 15 ++-- 4 files changed, 70 insertions(+), 36 deletions(-) diff --git a/letta/schemas/memory.py b/letta/schemas/memory.py index 30150864..79c9cb5f 100644 --- a/letta/schemas/memory.py +++ b/letta/schemas/memory.py @@ -190,11 +190,49 @@ class Memory(BaseModel, validate_assignment=True): s.write("\n") s.write("\n") + def _render_memory_blocks_git(self, s: StringIO): + """Render memory blocks as individual file tags with YAML frontmatter. + + Each block is rendered as ---frontmatter---value, + matching the format stored in the git repo. Labels without a 'system/' + prefix get one added automatically. + """ + renderable = self._get_renderable_blocks() + if not renderable: + return + + for idx, block in enumerate(renderable): + label = block.label or "block" + # Ensure system/ prefix + if not label.startswith("system/"): + label = f"system/{label}" + tag = f"{label}.md" + value = block.value or "" + + s.write(f"\n\n<{tag}>\n") + + # Build frontmatter (same fields as serialize_block) + front_lines = [] + if block.description: + front_lines.append(f"description: {block.description}") + if block.limit is not None: + front_lines.append(f"limit: {block.limit}") + if getattr(block, "read_only", False): + front_lines.append("read_only: true") + + if front_lines: + s.write("---\n") + s.write("\n".join(front_lines)) + s.write("\n---\n") + + s.write(f"{value}\n") + s.write(f"") + def _render_memory_filesystem(self, s: StringIO): """Render a filesystem tree view of all memory blocks. - Only rendered for git-memory-enabled agents. Shows all blocks - (system and non-system) as a tree with char counts and descriptions. + Only rendered for git-memory-enabled agents. Uses box-drawing + characters (├──, └──, │) like the Unix `tree` command. """ if not self.blocks: return @@ -211,26 +249,23 @@ class Memory(BaseModel, validate_assignment=True): node = node.setdefault(part, {}) node[parts[-1]] = block - s.write("\n\n\nmemory/\n") + s.write("\n\n\n") - def _render_tree(node: dict, indent: int = 1): - prefix = " " * indent + def _render_tree(node: dict, prefix: str = ""): # Sort: directories first, then files dirs = sorted(k for k, v in node.items() if isinstance(v, dict)) files = sorted(k for k, v in node.items() if not isinstance(v, dict)) + entries = [(d, True) for d in dirs] + [(f, False) for f in files] - for d in dirs: - s.write(f"{prefix}{d}/\n") - _render_tree(node[d], indent + 1) - - for f in files: - block = node[f] - chars = len(block.value or "") - desc = block.description or "" - line = f"{prefix}{f}.md ({chars} chars)" - if desc: - line += f" - {desc}" - s.write(f"{line}\n") + for i, (name, is_dir) in enumerate(entries): + is_last = i == len(entries) - 1 + connector = "└── " if is_last else "├── " + if is_dir: + s.write(f"{prefix}{connector}{name}/\n") + extension = " " if is_last else "│ " + _render_tree(node[name], prefix + extension) + else: + s.write(f"{prefix}{connector}{name}.md\n") _render_tree(tree) s.write("") @@ -353,15 +388,15 @@ class Memory(BaseModel, validate_assignment=True): # Memory blocks (not for react/workflow). Always include wrapper for preview/tests. if not is_react: - if is_line_numbered: + if self.git_enabled: + # Git-enabled: filesystem tree + file-style block rendering + self._render_memory_filesystem(s) + self._render_memory_blocks_git(s) + elif is_line_numbered: self._render_memory_blocks_line_numbered(s) else: self._render_memory_blocks_standard(s) - # For git-memory-enabled agents, render a filesystem tree of all blocks - if self.git_enabled: - self._render_memory_filesystem(s) - if tool_usage_rules is not None: desc = getattr(tool_usage_rules, "description", None) or "" val = getattr(tool_usage_rules, "value", None) or "" diff --git a/letta/server/rest_api/routers/v1/git_http.py b/letta/server/rest_api/routers/v1/git_http.py index db8cd658..d935e4bf 100644 --- a/letta/server/rest_api/routers/v1/git_http.py +++ b/letta/server/rest_api/routers/v1/git_http.py @@ -498,10 +498,10 @@ async def _sync_after_push(actor_id: str, agent_id: str) -> None: synced = 0 for file_path, content in files.items(): - if not file_path.startswith("memory/") or not file_path.endswith(".md"): + if not file_path.endswith(".md"): continue - label = file_path[len("memory/") : -3] + label = file_path[:-3] expected_labels.add(label) # Parse frontmatter to extract metadata alongside value @@ -524,12 +524,12 @@ async def _sync_after_push(actor_id: str, agent_id: str) -> None: logger.exception("Failed to sync block %s to PostgreSQL (agent=%s)", label, agent_id) if synced == 0: - logger.warning("No memory/*.md files found in repo HEAD during post-push sync (agent=%s)", agent_id) + logger.warning("No *.md files found in repo HEAD during post-push sync (agent=%s)", agent_id) else: # Detach blocks that were removed in git. # # We treat git as the source of truth for which blocks are attached to - # this agent. If a memory/*.md file disappears from HEAD, detach the + # this agent. If a *.md file disappears from HEAD, detach the # corresponding block from the agent in Postgres. try: existing_blocks = await _server_instance.agent_manager.list_agent_blocks_async( diff --git a/letta/services/block_manager_git.py b/letta/services/block_manager_git.py index 22d97999..1fc4424a 100644 --- a/letta/services/block_manager_git.py +++ b/letta/services/block_manager_git.py @@ -389,7 +389,7 @@ class GitEnabledBlockManager(BlockManager): # Check which blocks are missing from repo missing_blocks = [] for block in blocks: - expected_path = f"memory/{block.label}.md" + expected_path = f"{block.label}.md" if expected_path not in repo_files: missing_blocks.append(block) @@ -552,7 +552,7 @@ class GitEnabledBlockManager(BlockManager): if self.memory_repo_manager is None: raise ValueError("Memory repo manager not configured") - path = f"memory/{label}.md" if label else None + path = f"{label}.md" if label else None return await self.memory_repo_manager.get_history_async( agent_id=agent_id, actor=actor, diff --git a/letta/services/memory_repo/memfs_client_base.py b/letta/services/memory_repo/memfs_client_base.py index b5122bd3..08f61da6 100644 --- a/letta/services/memory_repo/memfs_client_base.py +++ b/letta/services/memory_repo/memfs_client_base.py @@ -26,8 +26,7 @@ from letta.utils import enforce_types logger = get_logger(__name__) -# File paths within the memory repository -MEMORY_DIR = "memory" +# File paths within the memory repository (blocks stored at repo root as {label}.md) # Default local storage path DEFAULT_LOCAL_PATH = os.path.expanduser("~/.letta/memfs") @@ -88,7 +87,7 @@ class MemfsClient: initial_files = {} for block in initial_blocks: - file_path = f"{MEMORY_DIR}/{block.label}.md" + file_path = f"{block.label}.md" initial_files[file_path] = serialize_block( value=block.value or "", description=block.description, @@ -137,8 +136,8 @@ class MemfsClient: # Convert block files to PydanticBlock (metadata is in frontmatter) blocks = [] for file_path, content in files.items(): - if file_path.startswith(f"{MEMORY_DIR}/") and file_path.endswith(".md"): - label = file_path[len(f"{MEMORY_DIR}/") : -3] + if file_path.endswith(".md"): + label = file_path[:-3] parsed = parse_block_markdown(content) @@ -235,7 +234,7 @@ class MemfsClient: await self._ensure_repo_exists(agent_id, actor) - file_path = f"{MEMORY_DIR}/{label}.md" + file_path = f"{label}.md" file_content = serialize_block( value=value, description=description, @@ -289,7 +288,7 @@ class MemfsClient: changes = [ FileChange( - path=f"{MEMORY_DIR}/{block.label}.md", + path=f"{block.label}.md", content=file_content, change_type="add", ), @@ -333,7 +332,7 @@ class MemfsClient: changes = [ FileChange( - path=f"{MEMORY_DIR}/{label}.md", + path=f"{label}.md", content=None, change_type="delete", ),