diff --git a/navi/core/registry.py b/navi/core/registry.py index db2b53f..0364f97 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -11,9 +11,7 @@ FilesystemTool, HttpRequestTool, ImageViewTool, - MemoryForgetTool, - MemorySaveTool, - MemorySearchTool, + MemoryTool, SpawnAgentTool, SshExecTool, ScratchpadTool, @@ -110,15 +108,13 @@ write_tool = WriteToolTool(registry=tools) list_tool = ListToolsTool(registry=tools) manual_tool = ToolManualTool(registry=tools) - memory_search = MemorySearchTool(memory_store) if memory_store else None - memory_forget = MemoryForgetTool(memory_store) if memory_store else None - memory_save = MemorySaveTool(memory_store) if memory_store else None + memory_tool = MemoryTool(memory_store) if memory_store else None builtins = [WebSearchTool(), FilesystemTool(), HttpRequestTool(), WebViewTool(), CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), ShareFileTool(), TodoTool(), ScratchpadTool(), reload_tool, write_tool, list_tool, manual_tool] - if memory_search: - builtins.extend([memory_search, memory_forget, memory_save]) + if memory_tool: + builtins.append(memory_tool) for builtin in builtins: tools.register(builtin, builtin=True) diff --git a/navi/profiles/secretary.py b/navi/profiles/secretary.py index b84436b..fdc7c1e 100644 --- a/navi/profiles/secretary.py +++ b/navi/profiles/secretary.py @@ -30,7 +30,7 @@ "todo", "scratchpad", "switch_profile", "web_search", "web_view", "http_request", "filesystem", "code_exec", "image_view", - "memory_search", "memory_forget", "memory_save", + "memory", "list_tools", "tool_manual", "spawn_agent", "share_file", diff --git a/navi/profiles/server_admin.py b/navi/profiles/server_admin.py index 7c3cbe4..1c741ae 100644 --- a/navi/profiles/server_admin.py +++ b/navi/profiles/server_admin.py @@ -40,7 +40,7 @@ "todo", "scratchpad", "switch_profile", "web_search", "web_view", "http_request", "filesystem", "code_exec", "terminal", "ssh_exec", "image_view", - "memory_search", "memory_forget", "memory_save", + "memory", "reload_tools", "write_tool", "list_tools", "tool_manual", "spawn_agent", "share_file", diff --git a/navi/profiles/smart_home.py b/navi/profiles/smart_home.py index dbf03a3..ba00665 100644 --- a/navi/profiles/smart_home.py +++ b/navi/profiles/smart_home.py @@ -34,7 +34,7 @@ "todo", "scratchpad", "switch_profile", "web_search", "web_view", "http_request", "filesystem", "code_exec", "ssh_exec", "image_view", - "memory_search", "memory_forget", "memory_save", + "memory", "reload_tools", "write_tool", "list_tools", "tool_manual", "spawn_agent", "share_file", diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index 839eaf3..f82756d 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -6,9 +6,7 @@ from .ssh_exec import SshExecTool from .spawn_agent import SpawnAgentTool from .terminal import TerminalTool -from .memory_forget import MemoryForgetTool -from .memory_save import MemorySaveTool -from .memory_search import MemorySearchTool +from .memory import MemoryTool from .todo import TodoTool from .scratchpad import ScratchpadTool from .switch_profile import SwitchProfileTool @@ -26,9 +24,7 @@ "SshExecTool", "ImageViewTool", "WebViewTool", - "MemorySearchTool", - "MemoryForgetTool", - "MemorySaveTool", + "MemoryTool", "SpawnAgentTool", "TodoTool", "ScratchpadTool", diff --git a/navi/tools/memory.py b/navi/tools/memory.py new file mode 100644 index 0000000..1a47fbd --- /dev/null +++ b/navi/tools/memory.py @@ -0,0 +1,128 @@ +"""Memory tool — search, save, and forget facts about the user.""" + +from navi.memory.store import MemoryStore +from navi.tools.base import current_session_id + +from .base import Tool, ToolResult + +_VALID_CATEGORIES = {"profile", "preferences", "technical", "projects", "other"} + + +class MemoryTool(Tool): + name = "memory" + description = ( + "Manage long-term memory about the user — facts that survive across sessions. " + "Actions: save (upsert a fact), search (find facts by query), forget (delete by key), list (all facts)." + ) + parameters = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["save", "search", "forget", "list"], + "description": ( + "save — upsert a fact (overwrites existing key). " + "search — find facts by keyword query. " + "forget — delete a fact by key. " + "list — return all stored facts." + ), + }, + "query": { + "type": "string", + "description": "search only: keywords describing what to look for.", + }, + "category": { + "type": "string", + "enum": ["profile", "preferences", "technical", "projects", "other"], + "description": ( + "save/forget: fact category. " + "profile=who they are, preferences=likes/dislikes, " + "technical=OS/tools/servers, projects=ongoing work, other=anything else." + ), + }, + "key": { + "type": "string", + "description": ( + "save/forget: snake_case identifier unique within the category. " + "Examples: name, primary_os, home_server_ip, response_language." + ), + }, + "value": { + "type": "string", + "description": "save only: the fact as a concise plain-text statement.", + }, + }, + "required": ["action"], + } + + def __init__(self, memory_store: MemoryStore) -> None: + self._store = memory_store + + async def execute(self, params: dict) -> ToolResult: + action = params.get("action", "") + + if action == "save": + return await self._save(params) + if action == "search": + return await self._search(params) + if action == "forget": + return await self._forget(params) + if action == "list": + return await self._list() + return ToolResult(success=False, output=f"Unknown action '{action}'.", error="invalid action") + + async def _save(self, params: dict) -> ToolResult: + category = (params.get("category") or "").strip().lower() + key = (params.get("key") or "").strip() + value = (params.get("value") or "").strip() + + if not category: + return ToolResult(success=False, output="category is required for save.", error="missing category") + if category not in _VALID_CATEGORIES: + return ToolResult( + success=False, + output=f"Invalid category '{category}'. Must be one of: {', '.join(sorted(_VALID_CATEGORIES))}", + error="invalid category", + ) + if not key: + return ToolResult(success=False, output="key is required for save.", error="missing key") + if not value: + return ToolResult(success=False, output="value is required for save.", error="missing value") + + session_id = current_session_id.get(None) + await self._store.upsert_fact(category, key, value, session_id) + return ToolResult(success=True, output=f"Saved [{category}] {key}: {value}") + + async def _search(self, params: dict) -> ToolResult: + query = (params.get("query") or "").strip() + if not query: + return ToolResult(success=False, output="query is required for search.", error="missing query") + + facts = await self._store.search_facts(query, limit=15) + if not facts: + return ToolResult(success=True, output="No matching facts found in memory.") + + lines = [f"[{f['category']}] {f['key']}: {f['value']}" for f in facts] + return ToolResult(success=True, output=f"Found {len(facts)} fact(s):\n" + "\n".join(lines)) + + async def _forget(self, params: dict) -> ToolResult: + key = (params.get("key") or "").strip() + category = (params.get("category") or "").strip() or None + + if not key: + return ToolResult(success=False, output="key is required for forget.", error="missing key") + + deleted = await self._store.delete_fact(key, category) + if deleted == 0: + return ToolResult(success=False, output=f"No fact found with key '{key}'.", error="not found") + + noun = "fact" if deleted == 1 else "facts" + return ToolResult(success=True, output=f"Deleted {deleted} {noun} with key '{key}'.") + + async def _list(self) -> ToolResult: + facts = await self._store.get_all_facts() + if not facts: + return ToolResult(success=True, output="Memory is empty.") + + lines = [f"[{f['category']}] {f['key']}: {f['value']}" for f in facts] + return ToolResult(success=True, output=f"{len(facts)} fact(s) in memory:\n" + "\n".join(lines)) diff --git a/persona.txt b/persona.txt index 1e8ceda..1c8e293 100644 --- a/persona.txt +++ b/persona.txt @@ -115,21 +115,21 @@ LONG-TERM MEMORY: You have a persistent memory system that survives across sessions. A summary of what you know about the user may be injected above under "What I remember about the user" — read it at the start of each session if present. -Call memory_search when: +Use memory(action="search", ...) when: - The user mentions something personal (location, project, preference, recurring task) and you want to check what you already know. - You're about to make an assumption about the user's environment or preferences — verify it first. - The user asks about something you've helped with before. -Do NOT call memory_search as a reflex at the start of every session — only when the context genuinely calls for it. +Do NOT call memory search as a reflex at the start of every session — only when the context genuinely calls for it. -Call memory_save immediately when: +Use memory(action="save", ...) immediately when: - The user tells you anything stable and reusable: their name, location, employer, family, devices, server IPs, recurring projects, language preference, habits, or explicit instructions about how they want to be helped. -- You notice an existing fact is wrong or outdated — correct it with a fresh memory_save call (no need to memory_forget first; save overwrites by key). +- You notice an existing fact is wrong or outdated — save overwrites by key, no need to forget first. - The user says "remember that..." or similar. Do NOT save ephemeral facts: temporary states, one-off tasks, or things specific to this session only. -Call memory_forget only when the user explicitly asks you to forget something that memory_save cannot replace (e.g., "delete everything about X"), or when a key no longer exists in any useful form. +Use memory(action="forget", ...) only when the user explicitly asks you to forget something, or when a key should be removed entirely rather than updated. DOCUMENTATION: Project docs live in docs/ (architecture, agent loop, tools, API reference, profiles, etc.).