From 7abd65da264a8ad01aec6f5a5c7dd15bf8b525f0 Mon Sep 17 00:00:00 2001 From: Tarun Suresh Date: Mon, 16 Mar 2026 17:45:55 +0000 Subject: [PATCH] feat: add MemoryManageTool and UserProfileManageTool for persistent personalization Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openjarvis/core/config.py | 11 ++ src/openjarvis/tools/__init__.py | 9 ++ src/openjarvis/tools/memory_manage.py | 141 ++++++++++++++++++++ src/openjarvis/tools/user_profile_manage.py | 141 ++++++++++++++++++++ tests/tools/test_memory_manage.py | 49 +++++++ tests/tools/test_user_profile_manage.py | 38 ++++++ 6 files changed, 389 insertions(+) create mode 100644 src/openjarvis/tools/memory_manage.py create mode 100644 src/openjarvis/tools/user_profile_manage.py create mode 100644 tests/tools/test_memory_manage.py create mode 100644 tests/tools/test_user_profile_manage.py diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 1067c1a4..97f631e6 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1055,6 +1055,16 @@ class AgentManagerConfig: db_path: str = str(DEFAULT_CONFIG_DIR / "agents.db") +@dataclass(slots=True) +class MemoryFilesConfig: + """Persistent memory-file paths and nudge settings.""" + + soul_path: str = "~/.openjarvis/SOUL.md" + memory_path: str = "~/.openjarvis/MEMORY.md" + user_path: str = "~/.openjarvis/USER.md" + nudge_interval: int = 10 + + @dataclass class JarvisConfig: """Top-level configuration for OpenJarvis.""" @@ -1079,6 +1089,7 @@ class JarvisConfig: speech: SpeechConfig = field(default_factory=SpeechConfig) optimize: OptimizeConfig = field(default_factory=OptimizeConfig) agent_manager: AgentManagerConfig = field(default_factory=AgentManagerConfig) + memory_files: MemoryFilesConfig = field(default_factory=MemoryFilesConfig) @property def memory(self) -> StorageConfig: diff --git a/src/openjarvis/tools/__init__.py b/src/openjarvis/tools/__init__.py index b95c9090..cfddc61b 100644 --- a/src/openjarvis/tools/__init__.py +++ b/src/openjarvis/tools/__init__.py @@ -77,4 +77,13 @@ try: except ImportError: pass +try: + import openjarvis.tools.memory_manage # noqa: F401 +except ImportError: + pass +try: + import openjarvis.tools.user_profile_manage # noqa: F401 +except ImportError: + pass + __all__ = ["BaseTool", "ToolExecutor", "ToolSpec"] diff --git a/src/openjarvis/tools/memory_manage.py b/src/openjarvis/tools/memory_manage.py new file mode 100644 index 00000000..ff2731fb --- /dev/null +++ b/src/openjarvis/tools/memory_manage.py @@ -0,0 +1,141 @@ +"""Manage persistent agent memory (MEMORY.md).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from openjarvis.core.registry import ToolRegistry +from openjarvis.core.types import ToolResult +from openjarvis.tools._stubs import BaseTool, ToolSpec + + +@ToolRegistry.register("memory_manage") +class MemoryManageTool(BaseTool): + """Manage persistent agent memory (MEMORY.md).""" + + def __init__(self, memory_path: Path | str = "~/.openjarvis/MEMORY.md") -> None: + self._memory_path = Path(memory_path).expanduser() + + @property + def spec(self) -> ToolSpec: + return ToolSpec( + name="memory_manage", + description=( + "Read, add, update, or remove entries in persistent agent memory." + ), + parameters={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["read", "add", "update", "remove"], + "description": "Action to perform on memory.", + }, + "entry": { + "type": "string", + "description": ( + "The memory entry content (for add/update/remove)." + ), + }, + "new_entry": { + "type": "string", + "description": ( + "Replacement content (for update action only)." + ), + }, + }, + "required": ["action"], + }, + category="memory", + ) + + def execute(self, **params: Any) -> ToolResult: + action = params.get("action", "read") + entry = params.get("entry", "") + new_entry = params.get("new_entry", "") + if action == "read": + return self._read() + elif action == "add": + return self._add(entry) + elif action == "update": + return self._update(entry, new_entry) + elif action == "remove": + return self._remove(entry) + return ToolResult( + tool_name=self.spec.name, + success=False, + content=f"Unknown action: {action}", + ) + + def _read(self) -> ToolResult: + content = "" + if self._memory_path.exists(): + content = self._memory_path.read_text() + return ToolResult( + tool_name=self.spec.name, + success=True, + content=content or "(empty)", + ) + + def _add(self, entry: str) -> ToolResult: + if not entry: + return ToolResult( + tool_name=self.spec.name, + success=False, + content="Entry cannot be empty.", + ) + self._memory_path.parent.mkdir(parents=True, exist_ok=True) + existing = ( + self._memory_path.read_text() if self._memory_path.exists() else "" + ) + self._memory_path.write_text(existing.rstrip() + f"\n- {entry}\n") + return ToolResult( + tool_name=self.spec.name, + success=True, + content=f"Added: {entry}", + ) + + def _update(self, old: str, new: str) -> ToolResult: + if not self._memory_path.exists(): + return ToolResult( + tool_name=self.spec.name, + success=False, + content="Memory file does not exist.", + ) + text = self._memory_path.read_text() + if old not in text: + return ToolResult( + tool_name=self.spec.name, + success=False, + content=f"Entry not found: {old}", + ) + self._memory_path.write_text(text.replace(old, new, 1)) + return ToolResult( + tool_name=self.spec.name, + success=True, + content=f"Updated: {old} -> {new}", + ) + + def _remove(self, entry: str) -> ToolResult: + if not self._memory_path.exists(): + return ToolResult( + tool_name=self.spec.name, + success=False, + content="Memory file does not exist.", + ) + text = self._memory_path.read_text() + lines = text.split("\n") + new_lines = [ln for ln in lines if entry not in ln] + if len(new_lines) == len(lines): + return ToolResult( + tool_name=self.spec.name, + success=False, + content=f"Entry not found: {entry}", + ) + self._memory_path.write_text("\n".join(new_lines)) + return ToolResult( + tool_name=self.spec.name, + success=True, + content=f"Removed: {entry}", + ) diff --git a/src/openjarvis/tools/user_profile_manage.py b/src/openjarvis/tools/user_profile_manage.py new file mode 100644 index 00000000..67199a23 --- /dev/null +++ b/src/openjarvis/tools/user_profile_manage.py @@ -0,0 +1,141 @@ +"""Manage persistent user profile (USER.md).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from openjarvis.core.registry import ToolRegistry +from openjarvis.core.types import ToolResult +from openjarvis.tools._stubs import BaseTool, ToolSpec + + +@ToolRegistry.register("user_profile_manage") +class UserProfileManageTool(BaseTool): + """Manage persistent user profile (USER.md).""" + + def __init__(self, user_path: Path | str = "~/.openjarvis/USER.md") -> None: + self._user_path = Path(user_path).expanduser() + + @property + def spec(self) -> ToolSpec: + return ToolSpec( + name="user_profile_manage", + description=( + "Read, add, update, or remove entries in user profile." + ), + parameters={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["read", "add", "update", "remove"], + "description": "Action to perform on user profile.", + }, + "entry": { + "type": "string", + "description": ( + "The profile entry content (for add/update/remove)." + ), + }, + "new_entry": { + "type": "string", + "description": ( + "Replacement content (for update action only)." + ), + }, + }, + "required": ["action"], + }, + category="memory", + ) + + def execute(self, **params: Any) -> ToolResult: + action = params.get("action", "read") + entry = params.get("entry", "") + new_entry = params.get("new_entry", "") + if action == "read": + return self._read() + elif action == "add": + return self._add(entry) + elif action == "update": + return self._update(entry, new_entry) + elif action == "remove": + return self._remove(entry) + return ToolResult( + tool_name=self.spec.name, + success=False, + content=f"Unknown action: {action}", + ) + + def _read(self) -> ToolResult: + content = "" + if self._user_path.exists(): + content = self._user_path.read_text() + return ToolResult( + tool_name=self.spec.name, + success=True, + content=content or "(empty)", + ) + + def _add(self, entry: str) -> ToolResult: + if not entry: + return ToolResult( + tool_name=self.spec.name, + success=False, + content="Entry cannot be empty.", + ) + self._user_path.parent.mkdir(parents=True, exist_ok=True) + existing = ( + self._user_path.read_text() if self._user_path.exists() else "" + ) + self._user_path.write_text(existing.rstrip() + f"\n- {entry}\n") + return ToolResult( + tool_name=self.spec.name, + success=True, + content=f"Added: {entry}", + ) + + def _update(self, old: str, new: str) -> ToolResult: + if not self._user_path.exists(): + return ToolResult( + tool_name=self.spec.name, + success=False, + content="User profile file does not exist.", + ) + text = self._user_path.read_text() + if old not in text: + return ToolResult( + tool_name=self.spec.name, + success=False, + content=f"Entry not found: {old}", + ) + self._user_path.write_text(text.replace(old, new, 1)) + return ToolResult( + tool_name=self.spec.name, + success=True, + content=f"Updated: {old} -> {new}", + ) + + def _remove(self, entry: str) -> ToolResult: + if not self._user_path.exists(): + return ToolResult( + tool_name=self.spec.name, + success=False, + content="User profile file does not exist.", + ) + text = self._user_path.read_text() + lines = text.split("\n") + new_lines = [ln for ln in lines if entry not in ln] + if len(new_lines) == len(lines): + return ToolResult( + tool_name=self.spec.name, + success=False, + content=f"Entry not found: {entry}", + ) + self._user_path.write_text("\n".join(new_lines)) + return ToolResult( + tool_name=self.spec.name, + success=True, + content=f"Removed: {entry}", + ) diff --git a/tests/tools/test_memory_manage.py b/tests/tools/test_memory_manage.py new file mode 100644 index 00000000..27c83c7e --- /dev/null +++ b/tests/tools/test_memory_manage.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pytest +from pathlib import Path + + +@pytest.fixture +def memory_file(tmp_path: Path) -> Path: + p = tmp_path / "MEMORY.md" + p.write_text("## Knowledge\n\n- User prefers dark mode\n") + return p + + +def test_memory_read(memory_file: Path): + from openjarvis.tools.memory_manage import MemoryManageTool + + tool = MemoryManageTool(memory_path=memory_file) + result = tool.execute(action="read") + assert "dark mode" in result.content + + +def test_memory_add(memory_file: Path): + from openjarvis.tools.memory_manage import MemoryManageTool + + tool = MemoryManageTool(memory_path=memory_file) + result = tool.execute(action="add", entry="User works at Acme Corp") + assert result.success + assert "Acme Corp" in memory_file.read_text() + + +def test_memory_remove(memory_file: Path): + from openjarvis.tools.memory_manage import MemoryManageTool + + tool = MemoryManageTool(memory_path=memory_file) + tool.execute(action="add", entry="temporary fact") + result = tool.execute(action="remove", entry="temporary fact") + assert result.success + assert "temporary fact" not in memory_file.read_text() + + +def test_memory_create_if_missing(tmp_path: Path): + from openjarvis.tools.memory_manage import MemoryManageTool + + path = tmp_path / "MEMORY.md" + tool = MemoryManageTool(memory_path=path) + result = tool.execute(action="add", entry="new fact") + assert result.success + assert path.exists() + assert "new fact" in path.read_text() diff --git a/tests/tools/test_user_profile_manage.py b/tests/tools/test_user_profile_manage.py new file mode 100644 index 00000000..94ff51fb --- /dev/null +++ b/tests/tools/test_user_profile_manage.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import pytest +from pathlib import Path + + +@pytest.fixture +def user_file(tmp_path: Path) -> Path: + p = tmp_path / "USER.md" + p.write_text("## User Profile\n\n- Name: Alice\n") + return p + + +def test_user_read(user_file: Path): + from openjarvis.tools.user_profile_manage import UserProfileManageTool + + tool = UserProfileManageTool(user_path=user_file) + result = tool.execute(action="read") + assert "Alice" in result.content + + +def test_user_add(user_file: Path): + from openjarvis.tools.user_profile_manage import UserProfileManageTool + + tool = UserProfileManageTool(user_path=user_file) + result = tool.execute(action="add", entry="Role: Engineer") + assert result.success + assert "Engineer" in user_file.read_text() + + +def test_user_update(user_file: Path): + from openjarvis.tools.user_profile_manage import UserProfileManageTool + + tool = UserProfileManageTool(user_path=user_file) + result = tool.execute(action="update", entry="Name: Alice", new_entry="Name: Bob") + assert result.success + assert "Bob" in user_file.read_text() + assert "Alice" not in user_file.read_text()