[READ-ONLY] Mirror of https://github.com/just-cameron/note. External storage tool for Letta agents to manage persistent notes
0

Configure Feed

Select the types of activity you want to include in your feed.

note / note_tool.py
17 kB 387 lines
1from typing import Literal, Optional 2import re 3 4 5def note( 6 command: str, 7 path: Optional[str] = None, 8 content: Optional[str] = None, 9 old_str: Optional[str] = None, 10 new_str: Optional[str] = None, 11 new_path: Optional[str] = None, 12 insert_line: Optional[int] = None, 13 query: Optional[str] = None, 14 search_type: str = "label", 15) -> str: 16 """ 17 Manage notes in your vault. All notes are automatically scoped to your agent. 18 19 Commands: 20 create <path> <content> - create new note (not attached) 21 view <path> - read note contents 22 attach <path> [content] - load into context (supports /folder/*) 23 detach <path> - remove from context (supports /folder/*) 24 insert <path> <content> [line] - insert before line (0-indexed) or append 25 append <path> <content> - add content to end of note 26 replace <path> <old_str> <new_str> - find/replace, shows diff 27 rename <path> <new_path> - move/rename note to new path 28 copy <path> <new_path> - duplicate note to new path 29 delete <path> - permanently remove 30 list [query] - list notes (prefix filter, * for all) 31 search <query> [label|content] - grep notes by label or content 32 attached - show notes currently in context 33 34 Args: 35 command: The operation to perform 36 path: Path to the note (e.g., /projects/webapp, /todo) 37 content: Content to insert or initial content when creating 38 old_str: Text to find (for replace) 39 new_str: Text to replace with (for replace) 40 new_path: Destination path (for rename/copy) 41 insert_line: Line number to insert before (0-indexed, omit to append) 42 query: Search query (for list/search) 43 search_type: Search by "label" or "content" 44 45 Returns: 46 str: Result of the operation 47 """ 48 import os 49 50 agent_id = os.environ.get("LETTA_AGENT_ID") 51 52 # Check enabled commands ("all" or "*" enables everything) 53 all_commands = ["create", "view", "attach", "detach", "insert", "append", "replace", "rename", "copy", "delete", "list", "search", "attached"] 54 enabled_env = os.environ.get("ENABLED_COMMANDS", "all") 55 enabled = all_commands if enabled_env in ("all", "*") else enabled_env.split(",") 56 if command not in enabled: 57 return f"Error: '{command}' is disabled. Enabled: {enabled}" 58 59 # Pattern to filter out legacy UUID paths 60 uuid_pattern = re.compile(r'/\[?agent-[a-f0-9-]+\]?/') 61 62 # Parameter validation 63 path_required = ["create", "view", "attach", "detach", "insert", "append", "replace", "rename", "copy", "delete"] 64 if command in path_required and not path: 65 return f"Error: '{command}' requires path parameter" 66 67 if command == "replace" and (not old_str or new_str is None): 68 return "Error: 'replace' requires old_str and new_str parameters" 69 70 if command in ["create", "insert", "append"] and not content: 71 return f"Error: '{command}' requires content parameter" 72 73 if command in ["rename", "copy"] and not new_path: 74 return f"Error: '{command}' requires new_path parameter" 75 76 if command == "search" and not query: 77 return "Error: 'search' requires query parameter" 78 79 # Track if directory needs updating 80 update_directory = False 81 result = None 82 83 try: 84 if command == "create": 85 # Check for existing note with same path 86 existing = list(client.blocks.list(label=path, description_search=agent_id).items) 87 if existing: 88 return f"Error: Note already exists: {path}" 89 90 client.blocks.create( 91 label=path, 92 value=content, 93 description=f"owner:{agent_id}" 94 ) 95 update_directory = True 96 result = f"Created: {path}" 97 98 elif command == "view": 99 blocks = list(client.blocks.list(label=path, description_search=agent_id).items) 100 if not blocks: 101 return f"Note not found: {path}" 102 return blocks[0].value 103 104 elif command == "attach": 105 # Get currently attached block IDs to avoid duplicate attach errors 106 agent = client.agents.retrieve(agent_id=agent_id) 107 attached_ids = {b.id for b in agent.memory.blocks} 108 109 # Handle bulk wildcard: /folder/* 110 if path.endswith("/*"): 111 prefix = path[:-1] # "/folder/*" → "/folder/" 112 all_blocks = list(client.blocks.list(description_search=agent_id).items) 113 blocks = [b for b in all_blocks if b.label and b.label.startswith(prefix) 114 and not uuid_pattern.search(b.label)] 115 if not blocks: 116 return f"No notes matching: {path}" 117 118 to_attach = [b for b in blocks if b.id not in attached_ids] 119 skipped = len(blocks) - len(to_attach) 120 121 for block in to_attach: 122 client.agents.blocks.attach(agent_id=agent_id, block_id=block.id) 123 124 msg = f"Attached {len(to_attach)} notes matching {path}" 125 if skipped: 126 msg += f" ({skipped} already attached)" 127 return msg 128 129 # Single note attach 130 existing = list(client.blocks.list(label=path, description_search=agent_id).items) 131 if existing: 132 block_id = existing[0].id 133 if block_id in attached_ids: 134 return f"Already attached: {path}" 135 else: 136 new_block = client.blocks.create( 137 label=path, 138 value=content or "", 139 description=f"owner:{agent_id}" 140 ) 141 block_id = new_block.id 142 update_directory = True # New note created 143 144 client.agents.blocks.attach(agent_id=agent_id, block_id=block_id) 145 result = f"Attached: {path}" 146 147 elif command == "detach": 148 # Handle bulk wildcard: /folder/* 149 if path.endswith("/*"): 150 prefix = path[:-1] 151 # Get currently attached block IDs 152 agent = client.agents.retrieve(agent_id=agent_id) 153 attached_ids = {b.id for b in agent.memory.blocks} 154 155 all_blocks = list(client.blocks.list(description_search=agent_id).items) 156 blocks = [b for b in all_blocks if b.label and b.label.startswith(prefix) 157 and not uuid_pattern.search(b.label) 158 and b.id in attached_ids] # Only detach if actually attached 159 if not blocks: 160 return f"No attached notes matching: {path}" 161 for block in blocks: 162 client.agents.blocks.detach(agent_id=agent_id, block_id=block.id) 163 return f"Detached {len(blocks)} notes matching {path}" 164 165 # Single note detach 166 blocks = list(client.blocks.list(label=path, description_search=agent_id).items) 167 if not blocks: 168 return f"Note not found: {path}" 169 170 client.agents.blocks.detach(agent_id=agent_id, block_id=blocks[0].id) 171 return f"Detached: {path}" 172 173 elif command == "insert": 174 blocks = list(client.blocks.list(label=path, description_search=agent_id).items) 175 if not blocks: 176 return f"Note not found: {path}. Use 'attach' first." 177 178 block = blocks[0] 179 lines = block.value.split("\n") if block.value else [] 180 181 if insert_line is not None: 182 lines.insert(insert_line, content) 183 line_info = f"line {insert_line}" 184 else: 185 lines.append(content) 186 line_info = "end" 187 188 client.blocks.update(block_id=block.id, value="\n".join(lines)) 189 190 preview = content[:80] + "..." if len(content) > 80 else content 191 return f"Inserted at {line_info} in {path}:\n + {preview}" 192 193 elif command == "append": 194 blocks = list(client.blocks.list(label=path, description_search=agent_id).items) 195 if not blocks: 196 return f"Note not found: {path}. Use 'attach' first." 197 198 block = blocks[0] 199 if block.value: 200 new_value = block.value + "\n" + content 201 else: 202 new_value = content 203 204 client.blocks.update(block_id=block.id, value=new_value) 205 206 preview = content[:80] + "..." if len(content) > 80 else content 207 return f"Appended to {path}:\n + {preview}" 208 209 elif command == "rename": 210 # Check source exists 211 blocks = list(client.blocks.list(label=path, description_search=agent_id).items) 212 if not blocks: 213 return f"Note not found: {path}" 214 215 # Check destination doesn't exist 216 dest_blocks = list(client.blocks.list(label=new_path, description_search=agent_id).items) 217 if dest_blocks: 218 return f"Error: Destination already exists: {new_path}" 219 220 # Update the label 221 block = blocks[0] 222 client.blocks.update(block_id=block.id, label=new_path) 223 update_directory = True 224 result = f"Renamed: {path}{new_path}" 225 226 elif command == "copy": 227 # Check source exists 228 blocks = list(client.blocks.list(label=path, description_search=agent_id).items) 229 if not blocks: 230 return f"Note not found: {path}" 231 232 # Check destination doesn't exist 233 dest_blocks = list(client.blocks.list(label=new_path, description_search=agent_id).items) 234 if dest_blocks: 235 return f"Error: Destination already exists: {new_path}" 236 237 # Create copy (not attached) 238 source = blocks[0] 239 client.blocks.create( 240 label=new_path, 241 value=source.value, 242 description=f"owner:{agent_id}" 243 ) 244 update_directory = True 245 result = f"Copied: {path}{new_path}" 246 247 elif command == "replace": 248 blocks = list(client.blocks.list(label=path, description_search=agent_id).items) 249 if not blocks: 250 return f"Note not found: {path}" 251 252 block = blocks[0] 253 if old_str not in block.value: 254 return f"Error: old_str not found in note. Exact match required." 255 256 new_value = block.value.replace(old_str, new_str, 1) 257 client.blocks.update(block_id=block.id, value=new_value) 258 259 return f"Replaced in {path}:\n - {old_str}\n + {new_str}" 260 261 elif command == "delete": 262 blocks = list(client.blocks.list(label=path, description_search=agent_id).items) 263 if not blocks: 264 return f"Note not found: {path}" 265 266 client.blocks.delete(block_id=blocks[0].id) 267 update_directory = True 268 result = f"Deleted: {path}" 269 270 elif command == "list": 271 all_blocks = list(client.blocks.list(description_search=agent_id).items) 272 273 # Filter to path-like labels, exclude legacy UUID paths 274 blocks = [b for b in all_blocks 275 if b.label and b.label.startswith("/") 276 and not uuid_pattern.search(b.label)] 277 278 # Apply prefix filter if query provided 279 if query and query != "*": 280 blocks = [b for b in blocks if b.label.startswith(query)] 281 282 if not blocks: 283 return "No notes found" if not query or query == "*" else f"No notes matching: {query}" 284 285 # Deduplicate and sort 286 labels = sorted(set(b.label for b in blocks)) 287 return "\n".join(labels) 288 289 elif command == "search": 290 all_blocks = list(client.blocks.list(description_search=agent_id).items) 291 292 # Filter out legacy UUID paths 293 all_blocks = [b for b in all_blocks 294 if b.label and b.label.startswith("/") 295 and not uuid_pattern.search(b.label)] 296 297 if search_type == "label": 298 blocks = [b for b in all_blocks if query in b.label] 299 else: 300 blocks = [b for b in all_blocks if b.value and query in b.value] 301 302 if not blocks: 303 return f"No notes matching: {query}" 304 305 results = [] 306 for b in blocks: 307 preview = b.value[:100].replace("\n", " ") if b.value else "" 308 if len(b.value or "") > 100: 309 preview += "..." 310 results.append(f"{b.label}: {preview}") 311 312 return "\n".join(results) 313 314 elif command == "attached": 315 agent = client.agents.retrieve(agent_id=agent_id) 316 note_blocks = [b for b in agent.memory.blocks 317 if b.label and b.label.startswith("/") 318 and not uuid_pattern.search(b.label)] 319 320 if not note_blocks: 321 return "No notes currently attached" 322 323 return "\n".join(sorted(b.label for b in note_blocks)) 324 325 else: 326 return f"Error: Unknown command '{command}'" 327 328 # Update note_directory if needed 329 if update_directory: 330 dir_label = "/note_directory" 331 # Get all notes 332 all_blocks = list(client.blocks.list(description_search=agent_id).items) 333 notes = [b for b in all_blocks 334 if b.label and b.label.startswith("/") 335 and b.label != dir_label 336 and not uuid_pattern.search(b.label)] 337 338 # Header for the directory 339 header = "External storage. Attach to load into context, detach when done.\nFolders are also notes (e.g., /projects and /projects/task1 can both have content).\nCommands: view, attach, detach, insert, append, replace, rename, copy, delete, list, search\nBulk: attach /folder/*, detach /folder/*" 340 341 if notes: 342 # Group notes by folder 343 folders = {} 344 for b in sorted(notes, key=lambda x: x.label): 345 parts = b.label.rsplit("/", 1) 346 if len(parts) == 2: 347 folder, name = parts[0] + "/", parts[1] 348 else: 349 folder, name = "/", b.label[1:] # Root level 350 if folder not in folders: 351 folders[folder] = [] 352 first_line = (b.value or "").split("\n")[0][:80] 353 if len((b.value or "").split("\n")[0]) > 80: 354 first_line += "..." 355 folders[folder].append((name, first_line)) 356 357 # Build tree view 358 lines = [] 359 for folder in sorted(folders.keys()): 360 lines.append(folder) 361 items = folders[folder] 362 max_name_len = max(len(name) for name, _ in items) 363 for name, summary in items: 364 lines.append(f" {name.ljust(max_name_len)} | {summary}") 365 366 dir_content = header + "\n\n" + "\n".join(lines) 367 else: 368 dir_content = header + "\n\n(no notes)" 369 370 # Find or create directory block 371 dir_blocks = list(client.blocks.list(label=dir_label, description_search=agent_id).items) 372 if dir_blocks: 373 client.blocks.update(block_id=dir_blocks[0].id, value=dir_content) 374 else: 375 # Create and attach directory block 376 dir_block = client.blocks.create( 377 label=dir_label, 378 value=dir_content, 379 description=f"owner:{agent_id}" 380 ) 381 client.agents.blocks.attach(agent_id=agent_id, block_id=dir_block.id) 382 383 if result: 384 return result 385 386 except Exception as e: 387 return f"Error executing '{command}': {str(e)}"