diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index 6489cd099..15d158696 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -157,11 +157,11 @@ def cmd_setup(args) -> None: cfg["recallMode"] = new_recall # Session strategy - current_strat = cfg.get("sessionStrategy", "per-session") + current_strat = cfg.get("sessionStrategy", "per-directory") print(f"\n Session strategy options:") - print(" per-session — new Honcho session each run, named by Hermes session ID (default)") + print(" per-directory — one session per working directory (default)") print(" per-repo — one session per git repository (uses repo root name)") - print(" per-directory — one session per working directory") + print(" per-session — new Honcho session each run, named by Hermes session ID") print(" global — single session across all directories") new_strat = _prompt("Session strategy", default=current_strat) if new_strat in ("per-session", "per-repo", "per-directory", "global"): @@ -199,6 +199,7 @@ def cmd_setup(args) -> None: print(f" honcho_context — ask Honcho a question about you (LLM-synthesized)") print(f" honcho_search — semantic search over your history (no LLM)") print(f" honcho_profile — your peer card, key facts (no LLM)") + print(f" honcho_conclude — persist a user fact to Honcho memory (no LLM)") print(f"\n Other commands:") print(f" hermes honcho status — show full config") print(f" hermes honcho mode — show or change memory mode") @@ -710,10 +711,11 @@ def cmd_migrate(args) -> None: print(" honcho_context — ask Honcho a question, get a synthesized answer (LLM)") print(" honcho_search — semantic search over stored context (no LLM)") print(" honcho_profile — fast peer card snapshot (no LLM)") + print(" honcho_conclude — write a conclusion/fact back to memory (no LLM)") print() print(" Session naming") print(" OpenClaw: no persistent session concept — files are global.") - print(" Hermes: per-session by default — each run gets a new Honcho session") + print(" Hermes: per-directory by default — each project gets its own session") print(" Map a custom name: hermes honcho map ") # ── Step 6: Next steps ──────────────────────────────────────────────────── diff --git a/honcho_integration/client.py b/honcho_integration/client.py index 3f3f174d1..729bb42ca 100644 --- a/honcho_integration/client.py +++ b/honcho_integration/client.py @@ -95,7 +95,7 @@ class HonchoClientConfig: # "tools" — no pre-loaded context, rely on tool calls only recall_mode: str = "hybrid" # Session resolution - session_strategy: str = "per-session" + session_strategy: str = "per-directory" session_peer_prefix: bool = False sessions: dict[str, str] = field(default_factory=dict) # Raw global config for anything else consumers need @@ -201,7 +201,7 @@ class HonchoClientConfig: or raw.get("recallMode") or "hybrid" ), - session_strategy=raw.get("sessionStrategy", "per-session"), + session_strategy=raw.get("sessionStrategy", "per-directory"), session_peer_prefix=raw.get("sessionPeerPrefix", False), sessions=raw.get("sessions", {}), raw=raw, diff --git a/honcho_integration/session.py b/honcho_integration/session.py index e671f1c8a..19c419899 100644 --- a/honcho_integration/session.py +++ b/honcho_integration/session.py @@ -103,6 +103,7 @@ class HonchoSessionManager: # Prefetch caches: session_key → last result (consumed once per turn) self._context_cache: dict[str, dict] = {} self._dialectic_cache: dict[str, str] = {} + self._prefetch_cache_lock = threading.Lock() self._dialectic_reasoning_level: str = ( config.dialectic_reasoning_level if config else "low" ) @@ -496,18 +497,26 @@ class HonchoSessionManager: def _run(): result = self.dialectic_query(session_key, query) if result: - self._dialectic_cache[session_key] = result + self.set_dialectic_result(session_key, result) t = threading.Thread(target=_run, name="honcho-dialectic-prefetch", daemon=True) t.start() + def set_dialectic_result(self, session_key: str, result: str) -> None: + """Store a prefetched dialectic result in a thread-safe way.""" + if not result: + return + with self._prefetch_cache_lock: + self._dialectic_cache[session_key] = result + def pop_dialectic_result(self, session_key: str) -> str: """ Return and clear the cached dialectic result for this session. Returns empty string if no result is ready yet. """ - return self._dialectic_cache.pop(session_key, "") + with self._prefetch_cache_lock: + return self._dialectic_cache.pop(session_key, "") def prefetch_context(self, session_key: str, user_message: str | None = None) -> None: """ @@ -519,18 +528,26 @@ class HonchoSessionManager: def _run(): result = self.get_prefetch_context(session_key, user_message) if result: - self._context_cache[session_key] = result + self.set_context_result(session_key, result) t = threading.Thread(target=_run, name="honcho-context-prefetch", daemon=True) t.start() + def set_context_result(self, session_key: str, result: dict[str, str]) -> None: + """Store a prefetched context result in a thread-safe way.""" + if not result: + return + with self._prefetch_cache_lock: + self._context_cache[session_key] = result + def pop_context_result(self, session_key: str) -> dict[str, str]: """ Return and clear the cached context result for this session. Returns empty dict if no result is ready yet (first turn). """ - return self._context_cache.pop(session_key, {}) + with self._prefetch_cache_lock: + return self._context_cache.pop(session_key, {}) def get_prefetch_context(self, session_key: str, user_message: str | None = None) -> dict[str, str]: """ diff --git a/run_agent.py b/run_agent.py index 9c9607af3..0115e8e3b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -20,6 +20,7 @@ Usage: response = agent.run_conversation("Tell me about the latest Python updates") """ +import atexit import copy import hashlib import json @@ -31,6 +32,7 @@ import re import sys import time import threading +import weakref from types import SimpleNamespace import uuid from typing import List, Dict, Any, Optional @@ -550,6 +552,7 @@ class AIAgent: self._honcho = None # HonchoSessionManager | None self._honcho_session_key = honcho_session_key self._honcho_config = None # HonchoClientConfig | None + self._honcho_exit_hook_registered = False if not skip_memory: try: if honcho_manager is not None: @@ -1427,28 +1430,46 @@ class AIAgent: try: ctx = self._honcho.get_prefetch_context(self._honcho_session_key) if ctx: - self._honcho._context_cache[self._honcho_session_key] = ctx + self._honcho.set_context_result(self._honcho_session_key, ctx) logger.debug("Honcho context pre-warmed for first turn") except Exception as exc: logger.debug("Honcho context prefetch failed (non-fatal): %s", exc) - import signal as _signal - import threading as _threading + self._register_honcho_exit_hook() - honcho_ref = self._honcho + def _register_honcho_exit_hook(self) -> None: + """Register a process-exit flush hook without clobbering signal handlers.""" + if self._honcho_exit_hook_registered or not self._honcho: + return - if _threading.current_thread() is _threading.main_thread(): - def _honcho_flush_handler(signum, frame): - try: - honcho_ref.flush_all() - except Exception: - pass - if signum == _signal.SIGINT: - raise KeyboardInterrupt - raise SystemExit(0) + honcho_ref = weakref.ref(self._honcho) - _signal.signal(_signal.SIGTERM, _honcho_flush_handler) - _signal.signal(_signal.SIGINT, _honcho_flush_handler) + def _flush_honcho_on_exit(): + manager = honcho_ref() + if manager is None: + return + try: + manager.flush_all() + except Exception as exc: + logger.debug("Honcho flush on exit failed (non-fatal): %s", exc) + + atexit.register(_flush_honcho_on_exit) + self._honcho_exit_hook_registered = True + + def _queue_honcho_prefetch(self, user_message: str) -> None: + """Queue turn-end Honcho prefetch so the next turn can consume cached results.""" + if not self._honcho or not self._honcho_session_key: + return + + recall_mode = (self._honcho_config.recall_mode if self._honcho_config else "hybrid") + if recall_mode == "tools": + return + + try: + self._honcho.prefetch_context(self._honcho_session_key, user_message) + self._honcho.prefetch_dialectic(self._honcho_session_key, user_message or "What were we working on?") + except Exception as exc: + logger.debug("Honcho background prefetch failed (non-fatal): %s", exc) def _honcho_prefetch(self, user_message: str) -> str: """Assemble the first-turn Honcho context from the pre-warmed cache.""" @@ -1472,6 +1493,10 @@ class AIAgent: if ai_card: parts.append(ai_card) + dialectic = self._honcho.pop_dialectic_result(self._honcho_session_key) + if dialectic: + parts.append(f"## Continuity synthesis\n{dialectic}") + if not parts: return "" header = ( @@ -3379,15 +3404,23 @@ class AIAgent: ) self._iters_since_skill = 0 - # Honcho: on the first turn only, read the pre-warmed context snapshot and - # bake it into the system prompt. We intentionally avoid per-turn refreshes - # here because changing the system prompt would destroy provider prompt-cache - # reuse for the rest of the session. + # Honcho prefetch consumption: + # - First turn: bake into cached system prompt (stable for the session). + # - Later turns: inject as ephemeral system context for this API call only. + # + # This keeps the persisted/cached prompt stable while still allowing + # turn N to consume background prefetch results from turn N-1. self._honcho_context = "" + self._honcho_turn_context = "" _recall_mode = (self._honcho_config.recall_mode if self._honcho_config else "hybrid") - if self._honcho and self._honcho_session_key and not conversation_history and _recall_mode != "tools": + if self._honcho and self._honcho_session_key and _recall_mode != "tools": try: - self._honcho_context = self._honcho_prefetch(user_message) + prefetched_context = self._honcho_prefetch(user_message) + if prefetched_context: + if not conversation_history: + self._honcho_context = prefetched_context + else: + self._honcho_turn_context = prefetched_context except Exception as e: logger.debug("Honcho prefetch failed (non-fatal): %s", e) @@ -3566,15 +3599,12 @@ class AIAgent: api_messages.append(api_msg) # Build the final system message: cached prompt + ephemeral system prompt. - # The ephemeral part is appended here (not baked into the cached prompt) - # so it stays out of the session DB and logs. - # Note: Honcho context is baked into _cached_system_prompt on the first - # turn and stored in the session DB, so it does NOT need to be injected - # here. This keeps the system message identical across all turns in a - # session, maximizing Anthropic prompt cache hits. + # Ephemeral additions are API-call-time only (not persisted to session DB). effective_system = active_system_prompt or "" if self.ephemeral_system_prompt: effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip() + if self._honcho_turn_context: + effective_system = (effective_system + "\n\n" + self._honcho_turn_context).strip() if effective_system: api_messages = [{"role": "system", "content": effective_system}] + api_messages @@ -4656,6 +4686,7 @@ class AIAgent: # Sync conversation to Honcho for user modeling if final_response and not interrupted: self._honcho_sync(original_user_message, final_response) + self._queue_honcho_prefetch(original_user_message) # Build result with interrupt info if applicable result = { diff --git a/tests/honcho_integration/test_async_memory.py b/tests/honcho_integration/test_async_memory.py index c8c4bf1b8..52a03ac25 100644 --- a/tests/honcho_integration/test_async_memory.py +++ b/tests/honcho_integration/test_async_memory.py @@ -487,3 +487,22 @@ class TestNewConfigFieldDefaults: cfg = HonchoClientConfig(memory_mode="hybrid", peer_memory_modes={"hermes": "local"}) assert cfg.peer_memory_mode("hermes") == "local" assert cfg.peer_memory_mode("other") == "hybrid" + + +class TestPrefetchCacheAccessors: + def test_set_and_pop_context_result(self): + mgr = _make_manager(write_frequency="turn") + payload = {"representation": "Known user", "card": "prefers concise replies"} + + mgr.set_context_result("cli:test", payload) + + assert mgr.pop_context_result("cli:test") == payload + assert mgr.pop_context_result("cli:test") == {} + + def test_set_and_pop_dialectic_result(self): + mgr = _make_manager(write_frequency="turn") + + mgr.set_dialectic_result("cli:test", "Resume with toolset cleanup") + + assert mgr.pop_dialectic_result("cli:test") == "Resume with toolset cleanup" + assert mgr.pop_dialectic_result("cli:test") == "" diff --git a/tests/honcho_integration/test_client.py b/tests/honcho_integration/test_client.py index d779d9a63..fb3d83739 100644 --- a/tests/honcho_integration/test_client.py +++ b/tests/honcho_integration/test_client.py @@ -25,7 +25,7 @@ class TestHonchoClientConfigDefaults: assert config.environment == "production" assert config.enabled is False assert config.save_messages is True - assert config.session_strategy == "per-session" + assert config.session_strategy == "per-directory" assert config.recall_mode == "hybrid" assert config.session_peer_prefix is False assert config.linked_hosts == [] @@ -140,7 +140,7 @@ class TestFromGlobalConfig: config_file = tmp_path / "config.json" config_file.write_text(json.dumps({"apiKey": "key"})) config = HonchoClientConfig.from_global_config(config_path=config_file) - assert config.session_strategy == "per-session" + assert config.session_strategy == "per-directory" def test_context_tokens_host_block_wins(self, tmp_path): """Host block contextTokens should override root.""" diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 91bb83ae5..f10be1b1b 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1192,17 +1192,15 @@ class TestSystemPromptStability: assert "User prefers Python over JavaScript" in agent._cached_system_prompt - def test_honcho_prefetch_skipped_on_continuing_session(self): - """Honcho prefetch should not be called when conversation_history - is non-empty (continuing session).""" + def test_honcho_prefetch_runs_on_continuing_session(self): + """Honcho prefetch is consumed on continuing sessions via ephemeral context.""" conversation_history = [ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi there"}, ] - - # The guard: `not conversation_history` is False when history exists - should_prefetch = not conversation_history - assert should_prefetch is False + recall_mode = "hybrid" + should_prefetch = bool(conversation_history) and recall_mode != "tools" + assert should_prefetch is True def test_honcho_prefetch_runs_on_first_turn(self): """Honcho prefetch should run when conversation_history is empty.""" @@ -1273,4 +1271,49 @@ class TestHonchoActivation: assert agent._honcho is manager manager.get_or_create.assert_called_once_with("gateway-session") manager.get_prefetch_context.assert_called_once_with("gateway-session") + manager.set_context_result.assert_called_once_with( + "gateway-session", + {"representation": "Known user", "card": ""}, + ) mock_client.assert_not_called() + + +class TestHonchoPrefetchScheduling: + def test_honcho_prefetch_includes_cached_dialectic(self, agent): + agent._honcho = MagicMock() + agent._honcho_session_key = "session-key" + agent._honcho.pop_context_result.return_value = {} + agent._honcho.pop_dialectic_result.return_value = "Continue with the migration checklist." + + context = agent._honcho_prefetch("what next?") + + assert "Continuity synthesis" in context + assert "migration checklist" in context + + def test_queue_honcho_prefetch_skips_tools_mode(self, agent): + agent._honcho = MagicMock() + agent._honcho_session_key = "session-key" + agent._honcho_config = HonchoClientConfig( + enabled=True, + api_key="honcho-key", + recall_mode="tools", + ) + + agent._queue_honcho_prefetch("what next?") + + agent._honcho.prefetch_context.assert_not_called() + agent._honcho.prefetch_dialectic.assert_not_called() + + def test_queue_honcho_prefetch_runs_when_context_enabled(self, agent): + agent._honcho = MagicMock() + agent._honcho_session_key = "session-key" + agent._honcho_config = HonchoClientConfig( + enabled=True, + api_key="honcho-key", + recall_mode="hybrid", + ) + + agent._queue_honcho_prefetch("what next?") + + agent._honcho.prefetch_context.assert_called_once_with("session-key", "what next?") + agent._honcho.prefetch_dialectic.assert_called_once_with("session-key", "what next?") diff --git a/tools/browser_tool.py b/tools/browser_tool.py index feee2e56d..dd44549b9 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1640,25 +1640,6 @@ def _cleanup_old_recordings(max_age_hours=72): logger.debug("Recording cleanup error (non-critical): %s", e) -def _cleanup_old_recordings(max_age_hours=72): - """Remove browser recordings older than max_age_hours to prevent disk bloat.""" - import time - try: - hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) - recordings_dir = hermes_home / "browser_recordings" - if not recordings_dir.exists(): - return - cutoff = time.time() - (max_age_hours * 3600) - for f in recordings_dir.glob("session_*.webm"): - try: - if f.stat().st_mtime < cutoff: - f.unlink() - except Exception: - pass - except Exception: - pass - - # ============================================================================ # Cleanup and Management Functions # ============================================================================ @@ -1764,7 +1745,7 @@ def cleanup_browser(task_id: Optional[str] = None) -> None: pid_file = os.path.join(socket_dir, f"{session_name}.pid") if os.path.isfile(pid_file): try: - daemon_pid = int(open(pid_file).read().strip()) + daemon_pid = int(Path(pid_file).read_text().strip()) os.kill(daemon_pid, signal.SIGTERM) logger.debug("Killed daemon pid %s for %s", daemon_pid, session_name) except (ProcessLookupError, ValueError, PermissionError, OSError): diff --git a/toolsets.py b/toolsets.py index 50ddf5f9b..dbf1d8874 100644 --- a/toolsets.py +++ b/toolsets.py @@ -60,8 +60,8 @@ _HERMES_CORE_TOOLS = [ "schedule_cronjob", "list_cronjobs", "remove_cronjob", # Cross-platform messaging (gated on gateway running via check_fn) "send_message", - # Honcho user context (gated on honcho being active via check_fn) - "honcho_context", + # Honcho memory tools (gated on honcho being active via check_fn) + "honcho_context", "honcho_profile", "honcho_search", "honcho_conclude", # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", ] @@ -192,7 +192,7 @@ TOOLSETS = { "honcho": { "description": "Honcho AI-native memory for persistent cross-session user modeling", - "tools": ["honcho_context"], + "tools": ["honcho_context", "honcho_profile", "honcho_search", "honcho_conclude"], "includes": [] },