diff --git a/CHANGELOG.md b/CHANGELOG.md index 855457b..ab6a0e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The format is based on Keep a Changelog, and this project currently tracks chang - Compaction now detects llama.cpp/OpenAI-compatible context overflow errors, accounts for image blocks in auto-compact token estimates, and strips image payloads from summarizer-only compaction requests. - Large tool results are now bounded in conversation history: oversized outputs are saved under `tool_artifacts`, old MCP results become microcompactable, and context collapse trims stale tool-result payloads. +- ohmo now keeps personal memory isolated from OpenHarness project memory: `/memory` in ohmo sessions targets the ohmo workspace memory store, and ohmo runtime prompt refreshes no longer inject project memory unless explicitly requested. - Fixed `glob` and `grep` tools hanging indefinitely when the `rg` subprocess produced enough stderr output to fill the OS pipe buffer. `stderr` is now redirected to `DEVNULL` so it is discarded rather than blocking the child process. - Fixed `bash_tool` hanging after a timed-out command when the subprocess stdout stream stayed open. `_read_remaining_output` now applies a 2-second `asyncio.wait_for` timeout so the tool always returns promptly. - Fixed `session_runner` background task deadlock caused by an unread `stderr=PIPE` stream. The subprocess now uses `stderr=STDOUT` so all output merges into the single readable stdout pipe. diff --git a/ohmo/gateway/runtime.py b/ohmo/gateway/runtime.py index bf832b5..24557e4 100644 --- a/ohmo/gateway/runtime.py +++ b/ohmo/gateway/runtime.py @@ -33,6 +33,7 @@ from openharness.prompts import build_runtime_system_prompt from openharness.ui.runtime import RuntimeBundle, _last_user_text, build_runtime, close_runtime, start_runtime from ohmo.gateway.config import load_gateway_config +from ohmo.memory import create_memory_command_backend from ohmo.prompts import build_ohmo_system_prompt from ohmo.session_storage import OhmoSessionBackend from ohmo.workspace import get_plugins_dir, get_skills_dir, initialize_workspace @@ -138,6 +139,8 @@ class OhmoSessionRuntimePool: restore_tool_metadata=snapshot.get("tool_metadata") if snapshot else None, extra_skill_dirs=(str(get_skills_dir(self._workspace)),), extra_plugin_roots=(str(get_plugins_dir(self._workspace)),), + memory_backend=create_memory_command_backend(self._workspace), + include_project_memory=False, ) if snapshot and snapshot.get("session_id"): bundle.session_id = str(snapshot["session_id"]) @@ -206,6 +209,8 @@ class OhmoSessionRuntimePool: session_id=bundle.session_id, extra_skill_dirs=bundle.extra_skill_dirs, extra_plugin_roots=bundle.extra_plugin_roots, + memory_backend=create_memory_command_backend(self._workspace), + include_project_memory=False, ), ) async for update in self._stream_command_result( @@ -508,6 +513,8 @@ class OhmoSessionRuntimePool: restore_tool_metadata=getattr(bundle.engine, "tool_metadata", {}) or {}, extra_skill_dirs=(str(get_skills_dir(self._workspace)),), extra_plugin_roots=(str(get_plugins_dir(self._workspace)),), + memory_backend=create_memory_command_backend(self._workspace), + include_project_memory=False, ) refreshed.session_id = prior_session_id await start_runtime(refreshed) @@ -533,6 +540,7 @@ class OhmoSessionRuntimePool: latest_user_prompt=latest_user_prompt, extra_skill_dirs=getattr(bundle, "extra_skill_dirs", ()), extra_plugin_roots=getattr(bundle, "extra_plugin_roots", ()), + include_project_memory=False, ) diff --git a/ohmo/memory.py b/ohmo/memory.py index 6051b9a..cf3057a 100644 --- a/ohmo/memory.py +++ b/ohmo/memory.py @@ -5,6 +5,8 @@ from __future__ import annotations from pathlib import Path from re import sub +from openharness.commands import MemoryCommandBackend + from ohmo.workspace import get_memory_dir, get_memory_index_path @@ -67,3 +69,16 @@ def load_memory_prompt(workspace: str | Path | None = None, *, max_files: int = lines.extend(["", f"## {path.name}", "```md", content[:4000], "```"]) return "\n".join(lines) + + +def create_memory_command_backend(workspace: str | Path | None = None) -> MemoryCommandBackend: + """Return a ``/memory`` backend bound to ohmo's personal memory store.""" + + return MemoryCommandBackend( + label="ohmo personal memory", + get_memory_dir=lambda: get_memory_dir(workspace), + get_entrypoint=lambda: get_memory_index_path(workspace), + list_files=lambda: list_memory_files(workspace), + add_entry=lambda title, content: add_memory_entry(workspace, title, content), + remove_entry=lambda name: remove_memory_entry(workspace, name), + ) diff --git a/ohmo/runtime.py b/ohmo/runtime.py index fe17722..7dd550d 100644 --- a/ohmo/runtime.py +++ b/ohmo/runtime.py @@ -14,6 +14,7 @@ from openharness.ui.backend_host import run_backend_host from openharness.ui.runtime import build_runtime, close_runtime, handle_line, start_runtime from openharness.ui.react_launcher import _resolve_npm, _resolve_tsx, get_frontend_dir +from ohmo.memory import create_memory_command_backend from ohmo.prompts import build_ohmo_system_prompt from ohmo.session_storage import OhmoSessionBackend from ohmo.workspace import get_plugins_dir, get_skills_dir, initialize_workspace @@ -54,6 +55,8 @@ async def run_ohmo_backend( session_backend=OhmoSessionBackend(workspace_root), extra_skill_dirs=extra_skill_dirs, extra_plugin_roots=extra_plugin_roots, + memory_backend=create_memory_command_backend(workspace_root), + include_project_memory=False, ) @@ -160,6 +163,8 @@ async def run_ohmo_print_mode( enforce_max_turns=max_turns is not None, extra_skill_dirs=extra_skill_dirs, extra_plugin_roots=extra_plugin_roots, + memory_backend=create_memory_command_backend(workspace_root), + include_project_memory=False, ) await start_runtime(bundle) diff --git a/src/openharness/commands/__init__.py b/src/openharness/commands/__init__.py index 27633b7..15b6e51 100644 --- a/src/openharness/commands/__init__.py +++ b/src/openharness/commands/__init__.py @@ -4,6 +4,7 @@ from openharness.commands.registry import ( CommandContext, CommandRegistry, CommandResult, + MemoryCommandBackend, SlashCommand, create_default_command_registry, ) @@ -12,6 +13,7 @@ __all__ = [ "CommandContext", "CommandRegistry", "CommandResult", + "MemoryCommandBackend", "SlashCommand", "create_default_command_registry", ] diff --git a/src/openharness/commands/registry.py b/src/openharness/commands/registry.py index 69dfdf5..a24182c 100644 --- a/src/openharness/commands/registry.py +++ b/src/openharness/commands/registry.py @@ -75,6 +75,18 @@ class CommandResult: submit_model: str | None = None +@dataclass(frozen=True) +class MemoryCommandBackend: + """Storage backend used by the generic ``/memory`` slash command.""" + + label: str + get_memory_dir: Callable[[], Path] + get_entrypoint: Callable[[], Path] + list_files: Callable[[], list[Path]] + add_entry: Callable[[str, str], Path] + remove_entry: Callable[[str], bool] + + @dataclass class CommandContext: """Context available to command handlers.""" @@ -90,6 +102,8 @@ class CommandContext: session_id: str | None = None extra_skill_dirs: Iterable[str | Path] | None = None extra_plugin_roots: Iterable[str | Path] | None = None + memory_backend: MemoryCommandBackend | None = None + include_project_memory: bool = True CommandHandler = Callable[[str, CommandContext], Awaitable[CommandResult]] @@ -286,7 +300,11 @@ def create_default_command_registry( async def _context_handler(_: str, context: CommandContext) -> CommandResult: settings = load_settings() - prompt = build_runtime_system_prompt(settings, cwd=context.cwd) + prompt = build_runtime_system_prompt( + settings, + cwd=context.cwd, + include_project_memory=context.include_project_memory, + ) return CommandResult(message=prompt) async def _summary_handler(args: str, context: CommandContext) -> CommandResult: @@ -360,7 +378,8 @@ def create_default_command_registry( async def _stats_handler(_: str, context: CommandContext) -> CommandResult: settings = load_settings() - memory_count = len(list_memory_files(context.cwd)) + memory_backend = _memory_backend_for_context(context) + memory_count = len(memory_backend.list_files()) task_count = len(get_task_manager().list_tasks()) tool_count = len(context.tool_registry.list_tools()) if context.tool_registry is not None else 0 style = settings.output_style @@ -380,25 +399,28 @@ def create_default_command_registry( ) async def _memory_handler(args: str, context: CommandContext) -> CommandResult: + backend = _memory_backend_for_context(context) tokens = args.split(maxsplit=1) if not tokens: - memory_dir = get_project_memory_dir(context.cwd) - entrypoint = get_memory_entrypoint(context.cwd) return CommandResult( - message=f"Memory directory: {memory_dir}\nEntrypoint: {entrypoint}" + message=( + f"Memory store: {backend.label}\n" + f"Memory directory: {backend.get_memory_dir()}\n" + f"Entrypoint: {backend.get_entrypoint()}" + ) ) action = tokens[0] rest = tokens[1] if len(tokens) == 2 else "" if action == "list": - memory_files = list_memory_files(context.cwd) + memory_files = backend.list_files() if not memory_files: return CommandResult(message="No memory files.") return CommandResult(message="\n".join(path.name for path in memory_files)) if action == "show" and rest: - memory_dir = get_project_memory_dir(context.cwd) + memory_dir = backend.get_memory_dir() path, invalid = _resolve_memory_entry_path(memory_dir, rest) if invalid: - return CommandResult(message="Memory entry path must stay within the project memory directory.") + return CommandResult(message="Memory entry path must stay within the configured memory directory.") if path is None: return CommandResult(message=f"Memory entry not found: {rest}") if not path.exists(): @@ -408,10 +430,10 @@ def create_default_command_registry( title, separator, content = rest.partition("::") if not separator or not title.strip() or not content.strip(): return CommandResult(message="Usage: /memory add TITLE :: CONTENT") - path = add_memory_entry(context.cwd, title.strip(), content.strip()) + path = backend.add_entry(title.strip(), content.strip()) return CommandResult(message=f"Added memory entry {path.name}") if action == "remove" and rest: - if remove_memory_entry(context.cwd, rest.strip()): + if backend.remove_entry(rest.strip()): return CommandResult(message=f"Removed memory entry {rest.strip()}") return CommandResult(message=f"Memory entry not found: {rest.strip()}") return CommandResult(message="Usage: /memory [list|show NAME|add TITLE :: CONTENT|remove NAME]") @@ -508,7 +530,11 @@ def create_default_command_registry( snapshot_path = context.session_backend.save_snapshot( cwd=context.cwd, model=context.app_state.get().model if context.app_state is not None else load_settings().model, - system_prompt=build_runtime_system_prompt(load_settings(), cwd=context.cwd), + system_prompt=build_runtime_system_prompt( + load_settings(), + cwd=context.cwd, + include_project_memory=context.include_project_memory, + ), messages=context.engine.messages, usage=context.engine.total_usage, ) @@ -853,7 +879,13 @@ def create_default_command_registry( return CommandResult(message="Usage: /effort [show|low|medium|high]") settings.effort = value save_settings(settings) - context.engine.set_system_prompt(build_runtime_system_prompt(settings, cwd=context.cwd)) + context.engine.set_system_prompt( + build_runtime_system_prompt( + settings, + cwd=context.cwd, + include_project_memory=context.include_project_memory, + ) + ) if context.app_state is not None: context.app_state.set(effort=value) return CommandResult(message=f"Reasoning effort set to {value}.") @@ -870,7 +902,13 @@ def create_default_command_registry( return CommandResult(message="Usage: /passes [show|COUNT]") settings.passes = passes save_settings(settings) - context.engine.set_system_prompt(build_runtime_system_prompt(settings, cwd=context.cwd)) + context.engine.set_system_prompt( + build_runtime_system_prompt( + settings, + cwd=context.cwd, + include_project_memory=context.include_project_memory, + ) + ) if context.app_state is not None: context.app_state.set(passes=passes) return CommandResult(message=f"Pass count set to {passes}.") @@ -1983,6 +2021,22 @@ def _resolve_memory_entry_path(memory_dir: Path, candidate: str) -> tuple[Path | return None, False +def _memory_backend_for_context(context: CommandContext) -> MemoryCommandBackend: + """Return the active slash-command memory backend for this command context.""" + + if context.memory_backend is not None: + return context.memory_backend + cwd = context.cwd + return MemoryCommandBackend( + label="OpenHarness project memory", + get_memory_dir=lambda: get_project_memory_dir(cwd), + get_entrypoint=lambda: get_memory_entrypoint(cwd), + list_files=lambda: list_memory_files(cwd), + add_entry=lambda title, content: add_memory_entry(cwd, title, content), + remove_entry=lambda name: remove_memory_entry(cwd, name), + ) + + def _resolve_memory_candidate(memory_dir: Path, candidate: str) -> tuple[Path | None, bool]: path = Path(candidate).expanduser() if not path.is_absolute(): diff --git a/src/openharness/prompts/context.py b/src/openharness/prompts/context.py index 05f0d85..d8a43d3 100644 --- a/src/openharness/prompts/context.py +++ b/src/openharness/prompts/context.py @@ -78,6 +78,7 @@ def build_runtime_system_prompt( latest_user_prompt: str | None = None, extra_skill_dirs: Iterable[str | Path] | None = None, extra_plugin_roots: Iterable[str | Path] | None = None, + include_project_memory: bool = True, ) -> str: """Build the runtime system prompt with project instructions and memory.""" if is_coordinator_mode(): @@ -130,7 +131,7 @@ def build_runtime_system_prompt( if content: sections.append(f"# {title}\n\n```md\n{content[:12000]}\n```") - if settings.memory.enabled: + if include_project_memory and settings.memory.enabled: memory_section = load_memory_prompt( cwd, max_entrypoint_lines=settings.memory.max_entrypoint_lines, diff --git a/src/openharness/ui/backend_host.py b/src/openharness/ui/backend_host.py index 15a78d6..5c10d40 100644 --- a/src/openharness/ui/backend_host.py +++ b/src/openharness/ui/backend_host.py @@ -15,6 +15,7 @@ from uuid import uuid4 from openharness.api.client import SupportsStreamingMessages from openharness.auth.manager import AuthManager +from openharness.commands import MemoryCommandBackend from openharness.config.settings import CLAUDE_MODEL_ALIAS_OPTIONS, resolve_model_setting from openharness.bridge import get_bridge_manager from openharness.coordinator.coordinator_mode import is_coordinator_mode @@ -63,6 +64,8 @@ class BackendHostConfig: session_backend: SessionBackend | None = None extra_skill_dirs: tuple[str, ...] = () extra_plugin_roots: tuple[str, ...] = () + memory_backend: MemoryCommandBackend | None = None + include_project_memory: bool = True class ReactBackendHost: @@ -102,6 +105,8 @@ class ReactBackendHost: session_backend=self._config.session_backend, extra_skill_dirs=self._config.extra_skill_dirs, extra_plugin_roots=self._config.extra_plugin_roots, + memory_backend=self._config.memory_backend, + include_project_memory=self._config.include_project_memory, ) await start_runtime(self._bundle) await self._emit( @@ -800,6 +805,8 @@ async def run_backend_host( session_backend: SessionBackend | None = None, extra_skill_dirs: tuple[str | Path, ...] = (), extra_plugin_roots: tuple[str | Path, ...] = (), + memory_backend: MemoryCommandBackend | None = None, + include_project_memory: bool = True, ) -> int: """Run the structured React backend host.""" if cwd: @@ -822,6 +829,8 @@ async def run_backend_host( session_backend=session_backend, extra_skill_dirs=tuple(str(Path(path).expanduser().resolve()) for path in extra_skill_dirs), extra_plugin_roots=tuple(str(Path(path).expanduser().resolve()) for path in extra_plugin_roots), + memory_backend=memory_backend, + include_project_memory=include_project_memory, ) ) return await host.run() diff --git a/src/openharness/ui/coordinator_drain.py b/src/openharness/ui/coordinator_drain.py index fd9efeb..a5b079d 100644 --- a/src/openharness/ui/coordinator_drain.py +++ b/src/openharness/ui/coordinator_drain.py @@ -134,6 +134,7 @@ async def submit_follow_up( latest_user_prompt=prompt_seed, extra_skill_dirs=bundle.extra_skill_dirs, extra_plugin_roots=bundle.extra_plugin_roots, + include_project_memory=bundle.include_project_memory, ) bundle.engine.set_system_prompt(system_prompt) try: diff --git a/src/openharness/ui/runtime.py b/src/openharness/ui/runtime.py index db1a638..b40df0f 100644 --- a/src/openharness/ui/runtime.py +++ b/src/openharness/ui/runtime.py @@ -14,7 +14,7 @@ from openharness.api.copilot_client import CopilotClient from openharness.api.openai_client import OpenAICompatibleClient from openharness.api.provider import auth_status, detect_provider from openharness.bridge import get_bridge_manager -from openharness.commands import CommandContext, CommandResult, create_default_command_registry +from openharness.commands import CommandContext, CommandResult, MemoryCommandBackend, create_default_command_registry from openharness.config import get_config_file_path, load_settings from openharness.engine import QueryEngine from openharness.engine.messages import ( @@ -63,6 +63,8 @@ class RuntimeBundle: session_backend: SessionBackend = DEFAULT_SESSION_BACKEND extra_skill_dirs: tuple[str, ...] = () extra_plugin_roots: tuple[str, ...] = () + memory_backend: MemoryCommandBackend | None = None + include_project_memory: bool = True def current_settings(self): """Return the effective settings for this session. @@ -188,6 +190,8 @@ async def build_runtime( permission_mode: str | None = None, extra_skill_dirs: Iterable[str | Path] | None = None, extra_plugin_roots: Iterable[str | Path] | None = None, + memory_backend: MemoryCommandBackend | None = None, + include_project_memory: bool = True, ) -> RuntimeBundle: """Build the shared runtime for an OpenHarness session.""" settings_overrides: dict[str, Any] = { @@ -260,6 +264,7 @@ async def build_runtime( latest_user_prompt=prompt, extra_skill_dirs=normalized_skill_dirs, extra_plugin_roots=normalized_plugin_roots, + include_project_memory=include_project_memory, ) from uuid import uuid4 @@ -348,6 +353,8 @@ async def build_runtime( session_backend=session_backend or DEFAULT_SESSION_BACKEND, extra_skill_dirs=normalized_skill_dirs, extra_plugin_roots=normalized_plugin_roots, + memory_backend=memory_backend, + include_project_memory=include_project_memory, ) @@ -515,6 +522,8 @@ async def handle_line( session_id=bundle.session_id, extra_skill_dirs=bundle.extra_skill_dirs, extra_plugin_roots=bundle.extra_plugin_roots, + memory_backend=bundle.memory_backend, + include_project_memory=bundle.include_project_memory, ), ) if result.refresh_runtime: @@ -532,6 +541,7 @@ async def handle_line( latest_user_prompt=submit_prompt, extra_skill_dirs=bundle.extra_skill_dirs, extra_plugin_roots=bundle.extra_plugin_roots, + include_project_memory=bundle.include_project_memory, ) bundle.engine.set_system_prompt(system_prompt) try: @@ -564,6 +574,7 @@ async def handle_line( latest_user_prompt=_last_user_text(bundle.engine.messages), extra_skill_dirs=bundle.extra_skill_dirs, extra_plugin_roots=bundle.extra_plugin_roots, + include_project_memory=bundle.include_project_memory, ) bundle.engine.set_system_prompt(system_prompt) turns = result.continue_turns if result.continue_turns is not None else bundle.engine.max_turns @@ -596,6 +607,7 @@ async def handle_line( latest_user_prompt=line, extra_skill_dirs=bundle.extra_skill_dirs, extra_plugin_roots=bundle.extra_plugin_roots, + include_project_memory=bundle.include_project_memory, ) bundle.engine.set_system_prompt(system_prompt) try: diff --git a/tests/test_commands/test_registry.py b/tests/test_commands/test_registry.py index 909c3f6..29c3336 100644 --- a/tests/test_commands/test_registry.py +++ b/tests/test_commands/test_registry.py @@ -158,7 +158,7 @@ async def test_memory_show_rejects_path_traversal(tmp_path: Path, monkeypatch): result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path))) - assert result.message == "Memory entry path must stay within the project memory directory." + assert result.message == "Memory entry path must stay within the configured memory directory." @pytest.mark.asyncio diff --git a/tests/test_ohmo/test_gateway.py b/tests/test_ohmo/test_gateway.py index 86dd24e..6ceedb0 100644 --- a/tests/test_ohmo/test_gateway.py +++ b/tests/test_ohmo/test_gateway.py @@ -14,14 +14,19 @@ from openharness.channels.bus.events import InboundMessage from openharness.channels.bus.queue import MessageBus from openharness.commands import CommandResult from openharness.commands.registry import SlashCommand, create_default_command_registry +from openharness.config.settings import Settings from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock, ToolUseBlock from openharness.engine.stream_events import AssistantTextDelta, CompactProgressEvent, ToolExecutionStarted +from openharness.memory import add_memory_entry as add_project_memory_entry +from openharness.memory import list_memory_files as list_project_memory_files from ohmo.gateway.bridge import OhmoGatewayBridge, _format_gateway_error from ohmo.gateway.config import save_gateway_config from ohmo.gateway.models import GatewayConfig, GatewayState from ohmo.gateway.runtime import OhmoSessionRuntimePool, _build_inbound_user_message, _format_channel_progress from ohmo.gateway.service import OhmoGatewayService, gateway_status, stop_gateway_process +from ohmo.memory import add_memory_entry as add_ohmo_memory_entry +from ohmo.memory import list_memory_files as list_ohmo_memory_files from ohmo.gateway.router import session_key_for_message from ohmo.session_storage import save_session_snapshot from ohmo.workspace import get_gateway_restart_notice_path, initialize_workspace @@ -236,6 +241,7 @@ async def test_runtime_pool_stream_message_emits_progress_and_tool_hint(tmp_path return SimpleNamespace( engine=FakeEngine(), + cwd=str(tmp_path), session_id="sess123", current_settings=lambda: SimpleNamespace(model="gpt-5.4"), commands=SimpleNamespace(lookup=lambda raw: None), @@ -279,6 +285,7 @@ async def test_runtime_pool_stream_message_formats_auto_compact_status_for_feish return SimpleNamespace( engine=FakeEngine(), + cwd=str(tmp_path), session_id="sess123", current_settings=lambda: SimpleNamespace(model="gpt-5.4"), commands=SimpleNamespace(lookup=lambda raw: None), @@ -536,6 +543,7 @@ async def test_runtime_pool_blocks_registered_bridge_spawn_without_shelling_out( return SimpleNamespace( engine=FakeEngine(), + cwd=str(tmp_path), session_id="sess123", current_settings=lambda: SimpleNamespace(model="gpt-5.4"), commands=registry, @@ -559,6 +567,97 @@ async def test_runtime_pool_blocks_registered_bridge_spawn_without_shelling_out( assert marker.exists() is False +@pytest.mark.asyncio +async def test_runtime_pool_memory_command_uses_ohmo_personal_memory(tmp_path, monkeypatch): + workspace = tmp_path / ".ohmo-home" + initialize_workspace(workspace) + registry = create_default_command_registry() + + async def fake_build_runtime(**kwargs): + class FakeEngine: + messages = [] + total_usage = UsageSnapshot() + + def set_system_prompt(self, prompt): + self.system_prompt = prompt + + return SimpleNamespace( + engine=FakeEngine(), + cwd=str(tmp_path), + session_id="sess123", + current_settings=lambda: SimpleNamespace(model="gpt-5.4"), + commands=registry, + tool_registry=None, + app_state=None, + session_backend=None, + extra_skill_dirs=(), + extra_plugin_roots=(), + hook_summary=lambda: "", + mcp_summary=lambda: "", + plugin_summary=lambda: "", + ) + + async def fake_start_runtime(bundle): + return None + + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) + monkeypatch.setattr("ohmo.gateway.runtime.build_runtime", fake_build_runtime) + monkeypatch.setattr("ohmo.gateway.runtime.start_runtime", fake_start_runtime) + + pool = OhmoSessionRuntimePool(cwd=tmp_path, workspace=workspace, provider_profile="codex") + message = InboundMessage( + channel="feishu", + sender_id="u1", + chat_id="c1", + content="/memory add Profile :: prefers concise answers", + ) + updates = [u async for u in pool.stream_message(message, "feishu:c1")] + + assert updates[-1].text == "Added memory entry profile.md" + assert [path.name for path in list_ohmo_memory_files(workspace)] == ["profile.md"] + assert "prefers concise answers" in (workspace / "memory" / "profile.md").read_text(encoding="utf-8") + assert list_project_memory_files(tmp_path) == [] + + +@pytest.mark.asyncio +async def test_runtime_pool_prompt_excludes_project_memory(tmp_path, monkeypatch): + workspace = tmp_path / ".ohmo-home" + initialize_workspace(workspace) + add_ohmo_memory_entry(workspace, "personal", "ohmo-only personal fact") + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) + add_project_memory_entry(tmp_path, "project", "project memory should not leak") + + async def fake_build_runtime(**kwargs): + class FakeEngine: + messages = [] + total_usage = UsageSnapshot() + + def set_system_prompt(self, prompt): + self.system_prompt = prompt + + engine = FakeEngine() + return SimpleNamespace( + engine=engine, + session_id="sess123", + current_settings=lambda: Settings(system_prompt=kwargs["system_prompt"]), + commands=create_default_command_registry(), + ) + + async def fake_start_runtime(bundle): + return None + + monkeypatch.setattr("ohmo.gateway.runtime.build_runtime", fake_build_runtime) + monkeypatch.setattr("ohmo.gateway.runtime.start_runtime", fake_start_runtime) + + pool = OhmoSessionRuntimePool(cwd=tmp_path, workspace=workspace, provider_profile="codex") + bundle = await pool.get_bundle("feishu:c1", latest_user_prompt="hello") + + assert "ohmo-only personal fact" in bundle.engine.system_prompt + assert "project memory should not leak" not in bundle.engine.system_prompt + + @pytest.mark.asyncio async def test_runtime_pool_allows_opted_in_remote_admin_commands(tmp_path, monkeypatch, caplog): workspace = tmp_path / ".ohmo-home" diff --git a/tests/test_ohmo/test_prompts.py b/tests/test_ohmo/test_prompts.py index 7e14bf5..7496eef 100644 --- a/tests/test_ohmo/test_prompts.py +++ b/tests/test_ohmo/test_prompts.py @@ -1,6 +1,10 @@ from pathlib import Path -from ohmo.memory import add_memory_entry +from openharness.config.settings import Settings +from openharness.memory import add_memory_entry as add_project_memory_entry +from openharness.prompts import build_runtime_system_prompt + +from ohmo.memory import add_memory_entry as add_ohmo_memory_entry from ohmo.prompts import build_ohmo_system_prompt from ohmo.workspace import ( get_bootstrap_path, @@ -18,7 +22,7 @@ def test_ohmo_prompt_includes_persona_and_memory(tmp_path: Path): get_identity_path(workspace).write_text("# identity\nName: ohmo\n", encoding="utf-8") get_user_path(workspace).write_text("# user\nPrefers terse answers.\n", encoding="utf-8") get_bootstrap_path(workspace).write_text("# bootstrap\nAsk a few high-value questions.\n", encoding="utf-8") - add_memory_entry(workspace, "timezone", "The user prefers UTC timestamps.") + add_ohmo_memory_entry(workspace, "timezone", "The user prefers UTC timestamps.") prompt = build_ohmo_system_prompt(tmp_path, workspace=workspace) @@ -29,3 +33,22 @@ def test_ohmo_prompt_includes_persona_and_memory(tmp_path: Path): assert "Ask a few high-value questions." in prompt assert "timezone.md" in prompt assert "UTC timestamps" in prompt + + +def test_ohmo_runtime_prompt_can_exclude_project_memory(tmp_path: Path, monkeypatch): + monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) + workspace = tmp_path / ".ohmo-home" + initialize_workspace(workspace) + add_ohmo_memory_entry(workspace, "personal", "ohmo-only personal fact") + add_project_memory_entry(tmp_path, "project", "project memory should not leak") + + base_prompt = build_ohmo_system_prompt(tmp_path, workspace=workspace) + runtime_prompt = build_runtime_system_prompt( + Settings(system_prompt=base_prompt), + cwd=tmp_path, + latest_user_prompt="hello", + include_project_memory=False, + ) + + assert "ohmo-only personal fact" in runtime_prompt + assert "project memory should not leak" not in runtime_prompt