diff --git a/cli.py b/cli.py
index 10d43ea7..e09a0112 100755
--- a/cli.py
+++ b/cli.py
@@ -960,6 +960,7 @@ class HermesCLI:
platform="cli",
session_db=self._session_db,
clarify_callback=self._clarify_callback,
+ honcho_session_key=self.session_id,
)
return True
except Exception as e:
diff --git a/gateway/run.py b/gateway/run.py
index 030c1098..ac8c141e 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -1444,6 +1444,7 @@ class GatewayRunner:
session_id=session_id,
tool_progress_callback=progress_callback if tool_progress_enabled else None,
platform=platform_key,
+ honcho_session_key=session_key,
)
# Store agent reference for interrupt support
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 0b2868fa..162f956a 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -127,6 +127,11 @@ DEFAULT_CONFIG = {
# Never saved to sessions, logs, or trajectories.
"prefill_messages_file": "",
+ # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
+ # This section is only needed for hermes-specific overrides; everything else
+ # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.
+ "honcho": {},
+
# Permanently allowed dangerous command patterns (added via "always" approval)
"command_allowlist": [],
@@ -229,6 +234,16 @@ OPTIONAL_ENV_VARS = {
"category": "tool",
},
+ # ── Honcho ──
+ "HONCHO_API_KEY": {
+ "description": "Honcho API key for AI-native persistent memory",
+ "prompt": "Honcho API key",
+ "url": "https://app.honcho.dev",
+ "tools": ["query_user_context"],
+ "password": True,
+ "category": "tool",
+ },
+
# ── Messaging platforms ──
"TELEGRAM_BOT_TOKEN": {
"description": "Telegram bot token from @BotFather",
@@ -769,7 +784,7 @@ def set_config_value(key: str, value: str):
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
- 'GITHUB_TOKEN',
+ 'GITHUB_TOKEN', 'HONCHO_API_KEY',
]
if key.upper() in api_keys or key.upper().startswith('TERMINAL_SSH'):
diff --git a/honcho_integration/__init__.py b/honcho_integration/__init__.py
new file mode 100644
index 00000000..9330ac29
--- /dev/null
+++ b/honcho_integration/__init__.py
@@ -0,0 +1,9 @@
+"""Honcho integration for AI-native memory.
+
+This package is only active when honcho.enabled=true in config and
+HONCHO_API_KEY is set. All honcho-ai imports are deferred to avoid
+ImportError when the package is not installed.
+
+Named ``honcho_integration`` (not ``honcho``) to avoid shadowing the
+``honcho`` package installed by the ``honcho-ai`` SDK.
+"""
diff --git a/honcho_integration/client.py b/honcho_integration/client.py
new file mode 100644
index 00000000..bfa0bbdd
--- /dev/null
+++ b/honcho_integration/client.py
@@ -0,0 +1,191 @@
+"""Honcho client initialization and configuration.
+
+Reads the global ~/.honcho/config.json when available, falling back
+to environment variables.
+
+Resolution order for host-specific settings:
+ 1. Explicit host block fields (always win)
+ 2. Flat/global fields from config root
+ 3. Defaults (host name as workspace/peer)
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import logging
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from honcho import Honcho
+
+logger = logging.getLogger(__name__)
+
+GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json"
+HOST = "hermes"
+
+
+@dataclass
+class HonchoClientConfig:
+ """Configuration for Honcho client, resolved for a specific host."""
+
+ host: str = HOST
+ workspace_id: str = "hermes"
+ api_key: str | None = None
+ environment: str = "production"
+ # Identity
+ peer_name: str | None = None
+ ai_peer: str = "hermes"
+ linked_hosts: list[str] = field(default_factory=list)
+ # Toggles
+ enabled: bool = False
+ save_messages: bool = True
+ # Session resolution
+ 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
+ raw: dict[str, Any] = field(default_factory=dict)
+
+ @classmethod
+ def from_env(cls, workspace_id: str = "hermes") -> HonchoClientConfig:
+ """Create config from environment variables (fallback)."""
+ return cls(
+ workspace_id=workspace_id,
+ api_key=os.environ.get("HONCHO_API_KEY"),
+ environment=os.environ.get("HONCHO_ENVIRONMENT", "production"),
+ enabled=True,
+ )
+
+ @classmethod
+ def from_global_config(
+ cls,
+ host: str = HOST,
+ config_path: Path | None = None,
+ ) -> HonchoClientConfig:
+ """Create config from ~/.honcho/config.json.
+
+ Falls back to environment variables if the file doesn't exist.
+ """
+ path = config_path or GLOBAL_CONFIG_PATH
+ if not path.exists():
+ logger.debug("No global Honcho config at %s, falling back to env", path)
+ return cls.from_env()
+
+ try:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, OSError) as e:
+ logger.warning("Failed to read %s: %s, falling back to env", path, e)
+ return cls.from_env()
+
+ host_block = (raw.get("hosts") or {}).get(host, {})
+
+ # Explicit host block fields win, then flat/global, then defaults
+ workspace = (
+ host_block.get("workspace")
+ or raw.get("workspace")
+ or host
+ )
+ ai_peer = (
+ host_block.get("aiPeer")
+ or raw.get("aiPeer")
+ or host
+ )
+ linked_hosts = host_block.get("linkedHosts", [])
+
+ return cls(
+ host=host,
+ workspace_id=workspace,
+ api_key=raw.get("apiKey") or os.environ.get("HONCHO_API_KEY"),
+ environment=raw.get("environment", "production"),
+ peer_name=raw.get("peerName"),
+ ai_peer=ai_peer,
+ linked_hosts=linked_hosts,
+ enabled=raw.get("enabled", False),
+ save_messages=raw.get("saveMessages", True),
+ session_strategy=raw.get("sessionStrategy", "per-directory"),
+ session_peer_prefix=raw.get("sessionPeerPrefix", False),
+ sessions=raw.get("sessions", {}),
+ raw=raw,
+ )
+
+ def resolve_session_name(self, cwd: str | None = None) -> str | None:
+ """Resolve session name for a directory.
+
+ Checks manual overrides first, then derives from directory name.
+ """
+ if not cwd:
+ cwd = os.getcwd()
+
+ # Manual override
+ manual = self.sessions.get(cwd)
+ if manual:
+ return manual
+
+ # Derive from directory basename
+ base = Path(cwd).name
+ if self.session_peer_prefix and self.peer_name:
+ return f"{self.peer_name}-{base}"
+ return base
+
+ def get_linked_workspaces(self) -> list[str]:
+ """Resolve linked host keys to workspace names."""
+ hosts = self.raw.get("hosts", {})
+ workspaces = []
+ for host_key in self.linked_hosts:
+ block = hosts.get(host_key, {})
+ ws = block.get("workspace") or host_key
+ if ws != self.workspace_id:
+ workspaces.append(ws)
+ return workspaces
+
+
+_honcho_client: Honcho | None = None
+
+
+def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
+ """Get or create the Honcho client singleton.
+
+ When no config is provided, attempts to load ~/.honcho/config.json
+ first, falling back to environment variables.
+ """
+ global _honcho_client
+
+ if _honcho_client is not None:
+ return _honcho_client
+
+ if config is None:
+ config = HonchoClientConfig.from_global_config()
+
+ if not config.api_key:
+ raise ValueError(
+ "Honcho API key not found. Set it in ~/.honcho/config.json "
+ "or the HONCHO_API_KEY environment variable. "
+ "Get an API key from https://app.honcho.dev"
+ )
+
+ try:
+ from honcho import Honcho
+ except ImportError:
+ raise ImportError(
+ "honcho-ai is required for Honcho integration. "
+ "Install it with: pip install honcho-ai"
+ )
+
+ logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id)
+
+ _honcho_client = Honcho(
+ workspace_id=config.workspace_id,
+ api_key=config.api_key,
+ environment=config.environment,
+ )
+
+ return _honcho_client
+
+
+def reset_honcho_client() -> None:
+ """Reset the Honcho client singleton (useful for testing)."""
+ global _honcho_client
+ _honcho_client = None
diff --git a/honcho_integration/session.py b/honcho_integration/session.py
new file mode 100644
index 00000000..11e28b76
--- /dev/null
+++ b/honcho_integration/session.py
@@ -0,0 +1,538 @@
+"""Honcho-based session management for conversation history."""
+
+from __future__ import annotations
+
+import re
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any, TYPE_CHECKING
+
+from honcho_integration.client import get_honcho_client
+
+if TYPE_CHECKING:
+ from honcho import Honcho
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class HonchoSession:
+ """
+ A conversation session backed by Honcho.
+
+ Provides a local message cache that syncs to Honcho's
+ AI-native memory system for user modeling.
+ """
+
+ key: str # channel:chat_id
+ user_peer_id: str # Honcho peer ID for the user
+ assistant_peer_id: str # Honcho peer ID for the assistant
+ honcho_session_id: str # Honcho session ID
+ messages: list[dict[str, Any]] = field(default_factory=list)
+ created_at: datetime = field(default_factory=datetime.now)
+ updated_at: datetime = field(default_factory=datetime.now)
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def add_message(self, role: str, content: str, **kwargs: Any) -> None:
+ """Add a message to the local cache."""
+ msg = {
+ "role": role,
+ "content": content,
+ "timestamp": datetime.now().isoformat(),
+ **kwargs,
+ }
+ self.messages.append(msg)
+ self.updated_at = datetime.now()
+
+ def get_history(self, max_messages: int = 50) -> list[dict[str, Any]]:
+ """Get message history for LLM context."""
+ recent = (
+ self.messages[-max_messages:]
+ if len(self.messages) > max_messages
+ else self.messages
+ )
+ return [{"role": m["role"], "content": m["content"]} for m in recent]
+
+ def clear(self) -> None:
+ """Clear all messages in the session."""
+ self.messages = []
+ self.updated_at = datetime.now()
+
+
+class HonchoSessionManager:
+ """
+ Manages conversation sessions using Honcho.
+
+ Runs alongside hermes' existing SQLite state and file-based memory,
+ adding persistent cross-session user modeling via Honcho's AI-native memory.
+ """
+
+ def __init__(
+ self,
+ honcho: Honcho | None = None,
+ context_tokens: int | None = None,
+ config: Any | None = None,
+ ):
+ """
+ Initialize the session manager.
+
+ Args:
+ honcho: Optional Honcho client. If not provided, uses the singleton.
+ context_tokens: Max tokens for context() calls (None = Honcho default).
+ config: HonchoClientConfig from global config (provides peer_name, ai_peer, etc.).
+ """
+ self._honcho = honcho
+ self._context_tokens = context_tokens
+ self._config = config
+ self._cache: dict[str, HonchoSession] = {}
+ self._peers_cache: dict[str, Any] = {}
+ self._sessions_cache: dict[str, Any] = {}
+
+ @property
+ def honcho(self) -> Honcho:
+ """Get the Honcho client, initializing if needed."""
+ if self._honcho is None:
+ self._honcho = get_honcho_client()
+ return self._honcho
+
+ def _get_or_create_peer(self, peer_id: str) -> Any:
+ """
+ Get or create a Honcho peer.
+
+ Peers are lazy -- no API call until first use.
+ Observation settings are controlled per-session via SessionPeerConfig.
+ """
+ if peer_id in self._peers_cache:
+ return self._peers_cache[peer_id]
+
+ peer = self.honcho.peer(peer_id)
+ self._peers_cache[peer_id] = peer
+ return peer
+
+ def _get_or_create_honcho_session(
+ self, session_id: str, user_peer: Any, assistant_peer: Any
+ ) -> tuple[Any, list]:
+ """
+ Get or create a Honcho session with peers configured.
+
+ Returns:
+ Tuple of (honcho_session, existing_messages).
+ """
+ if session_id in self._sessions_cache:
+ logger.debug("Honcho session '%s' retrieved from cache", session_id)
+ return self._sessions_cache[session_id], []
+
+ session = self.honcho.session(session_id)
+
+ # Configure peer observation settings
+ from honcho.session import SessionPeerConfig
+ user_config = SessionPeerConfig(observe_me=True, observe_others=True)
+ ai_config = SessionPeerConfig(observe_me=False, observe_others=True)
+
+ session.add_peers([(user_peer, user_config), (assistant_peer, ai_config)])
+
+ # Load existing messages via context() - single call for messages + metadata
+ existing_messages = []
+ try:
+ ctx = session.context(summary=True, tokens=self._context_tokens)
+ existing_messages = ctx.messages or []
+
+ # Verify chronological ordering
+ if existing_messages and len(existing_messages) > 1:
+ timestamps = [m.created_at for m in existing_messages if m.created_at]
+ if timestamps and timestamps != sorted(timestamps):
+ logger.warning(
+ "Honcho messages not chronologically ordered for session '%s', sorting",
+ session_id,
+ )
+ existing_messages = sorted(
+ existing_messages,
+ key=lambda m: m.created_at or datetime.min,
+ )
+
+ if existing_messages:
+ logger.info(
+ "Honcho session '%s' retrieved (%d existing messages)",
+ session_id, len(existing_messages),
+ )
+ else:
+ logger.info("Honcho session '%s' created (new)", session_id)
+ except Exception as e:
+ logger.warning(
+ "Honcho session '%s' loaded (failed to fetch context: %s)",
+ session_id, e,
+ )
+
+ self._sessions_cache[session_id] = session
+ return session, existing_messages
+
+ def _sanitize_id(self, id_str: str) -> str:
+ """Sanitize an ID to match Honcho's pattern: ^[a-zA-Z0-9_-]+"""
+ return re.sub(r'[^a-zA-Z0-9_-]', '-', id_str)
+
+ def get_or_create(self, key: str) -> HonchoSession:
+ """
+ Get an existing session or create a new one.
+
+ Args:
+ key: Session key (usually channel:chat_id).
+
+ Returns:
+ The session.
+ """
+ if key in self._cache:
+ logger.debug("Local session cache hit: %s", key)
+ return self._cache[key]
+
+ # Use peer names from global config when available
+ if self._config and self._config.peer_name:
+ user_peer_id = self._sanitize_id(self._config.peer_name)
+ else:
+ # Fallback: derive from session key
+ parts = key.split(":", 1)
+ channel = parts[0] if len(parts) > 1 else "default"
+ chat_id = parts[1] if len(parts) > 1 else key
+ user_peer_id = self._sanitize_id(f"user-{channel}-{chat_id}")
+
+ assistant_peer_id = (
+ self._config.ai_peer if self._config else "hermes-assistant"
+ )
+
+ # Sanitize session ID for Honcho
+ honcho_session_id = self._sanitize_id(key)
+
+ # Get or create peers
+ user_peer = self._get_or_create_peer(user_peer_id)
+ assistant_peer = self._get_or_create_peer(assistant_peer_id)
+
+ # Get or create Honcho session
+ honcho_session, existing_messages = self._get_or_create_honcho_session(
+ honcho_session_id, user_peer, assistant_peer
+ )
+
+ # Convert Honcho messages to local format
+ local_messages = []
+ for msg in existing_messages:
+ role = "assistant" if msg.peer_id == assistant_peer_id else "user"
+ local_messages.append({
+ "role": role,
+ "content": msg.content,
+ "timestamp": msg.created_at.isoformat() if msg.created_at else "",
+ "_synced": True, # Already in Honcho
+ })
+
+ # Create local session wrapper with existing messages
+ session = HonchoSession(
+ key=key,
+ user_peer_id=user_peer_id,
+ assistant_peer_id=assistant_peer_id,
+ honcho_session_id=honcho_session_id,
+ messages=local_messages,
+ )
+
+ self._cache[key] = session
+ return session
+
+ def save(self, session: HonchoSession) -> None:
+ """
+ Save messages to Honcho.
+
+ Syncs only new (unsynced) messages from the local cache.
+ """
+ if not session.messages:
+ return
+
+ # Get the Honcho session and peers
+ user_peer = self._get_or_create_peer(session.user_peer_id)
+ assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
+ honcho_session = self._sessions_cache.get(session.honcho_session_id)
+
+ if not honcho_session:
+ honcho_session, _ = self._get_or_create_honcho_session(
+ session.honcho_session_id, user_peer, assistant_peer
+ )
+
+ # Only send new messages (those without a '_synced' flag)
+ new_messages = [m for m in session.messages if not m.get("_synced")]
+
+ if not new_messages:
+ return
+
+ honcho_messages = []
+ for msg in new_messages:
+ peer = user_peer if msg["role"] == "user" else assistant_peer
+ honcho_messages.append(peer.message(msg["content"]))
+
+ try:
+ honcho_session.add_messages(honcho_messages)
+ for msg in new_messages:
+ msg["_synced"] = True
+ logger.debug("Synced %d messages to Honcho for %s", len(honcho_messages), session.key)
+ except Exception as e:
+ for msg in new_messages:
+ msg["_synced"] = False
+ logger.error("Failed to sync messages to Honcho: %s", e)
+
+ # Update cache
+ self._cache[session.key] = session
+
+ def delete(self, key: str) -> bool:
+ """Delete a session from local cache."""
+ if key in self._cache:
+ del self._cache[key]
+ return True
+ return False
+
+ def new_session(self, key: str) -> HonchoSession:
+ """
+ Create a new session, preserving the old one for user modeling.
+
+ Creates a fresh session with a new ID while keeping the old
+ session's data in Honcho for continued user modeling.
+ """
+ import time
+
+ # Remove old session from caches (but don't delete from Honcho)
+ old_session = self._cache.pop(key, None)
+ if old_session:
+ self._sessions_cache.pop(old_session.honcho_session_id, None)
+
+ # Create new session with timestamp suffix
+ timestamp = int(time.time())
+ new_key = f"{key}:{timestamp}"
+
+ # get_or_create will create a fresh session
+ session = self.get_or_create(new_key)
+
+ # Cache under both original key and timestamped key
+ self._cache[key] = session
+ self._cache[new_key] = session
+
+ logger.info("Created new session for %s (honcho: %s)", key, session.honcho_session_id)
+ return session
+
+ def get_user_context(self, session_key: str, query: str) -> str:
+ """
+ Query Honcho's dialectic chat for user context.
+
+ Args:
+ session_key: The session key to get context for.
+ query: Natural language question about the user.
+
+ Returns:
+ Honcho's response about the user.
+ """
+ session = self._cache.get(session_key)
+ if not session:
+ return "No session found for this context."
+
+ user_peer = self._get_or_create_peer(session.user_peer_id)
+
+ try:
+ return user_peer.chat(query)
+ except Exception as e:
+ logger.error("Failed to get user context from Honcho: %s", e)
+ return f"Unable to retrieve user context: {e}"
+
+ def get_prefetch_context(self, session_key: str, user_message: str | None = None) -> dict[str, str]:
+ """
+ Pre-fetch user context using Honcho's context() method.
+
+ Single API call that returns the user's representation
+ and peer card, using semantic search based on the user's message.
+
+ Args:
+ session_key: The session key to get context for.
+ user_message: The user's message for semantic search.
+
+ Returns:
+ Dictionary with 'representation' and 'card' keys.
+ """
+ session = self._cache.get(session_key)
+ if not session:
+ return {}
+
+ honcho_session = self._sessions_cache.get(session.honcho_session_id)
+ if not honcho_session:
+ return {}
+
+ try:
+ ctx = honcho_session.context(
+ summary=False,
+ tokens=self._context_tokens,
+ peer_target=session.user_peer_id,
+ search_query=user_message,
+ )
+ # peer_card is list[str] in SDK v2, join for prompt injection
+ card = ctx.peer_card or []
+ card_str = "\n".join(card) if isinstance(card, list) else str(card)
+ return {
+ "representation": ctx.peer_representation or "",
+ "card": card_str,
+ }
+ except Exception as e:
+ logger.warning("Failed to fetch context from Honcho: %s", e)
+ return {}
+
+ def migrate_local_history(self, session_key: str, messages: list[dict[str, Any]]) -> bool:
+ """
+ Upload local session history to Honcho as a file.
+
+ Used when Honcho activates mid-conversation to preserve prior context.
+
+ Args:
+ session_key: The session key (e.g., "telegram:123456").
+ messages: Local messages (dicts with role, content, timestamp).
+
+ Returns:
+ True if upload succeeded, False otherwise.
+ """
+ sanitized = self._sanitize_id(session_key)
+ honcho_session = self._sessions_cache.get(sanitized)
+ if not honcho_session:
+ logger.warning("No Honcho session cached for '%s', skipping migration", session_key)
+ return False
+
+ # Resolve user peer for attribution
+ parts = session_key.split(":", 1)
+ channel = parts[0] if len(parts) > 1 else "default"
+ chat_id = parts[1] if len(parts) > 1 else session_key
+ user_peer_id = self._sanitize_id(f"user-{channel}-{chat_id}")
+ user_peer = self._peers_cache.get(user_peer_id)
+ if not user_peer:
+ logger.warning("No user peer cached for '%s', skipping migration", user_peer_id)
+ return False
+
+ content_bytes = self._format_migration_transcript(session_key, messages)
+ first_ts = messages[0].get("timestamp") if messages else None
+
+ try:
+ honcho_session.upload_file(
+ file=("prior_history.txt", content_bytes, "text/plain"),
+ peer=user_peer,
+ metadata={"source": "local_jsonl", "count": len(messages)},
+ created_at=first_ts,
+ )
+ logger.info("Migrated %d local messages to Honcho for %s", len(messages), session_key)
+ return True
+ except Exception as e:
+ logger.error("Failed to upload local history to Honcho for %s: %s", session_key, e)
+ return False
+
+ @staticmethod
+ def _format_migration_transcript(session_key: str, messages: list[dict[str, Any]]) -> bytes:
+ """Format local messages as an XML transcript for Honcho file upload."""
+ timestamps = [m.get("timestamp", "") for m in messages]
+ time_range = f"{timestamps[0]} to {timestamps[-1]}" if timestamps else "unknown"
+
+ lines = [
+ "",
+ "",
+ "This conversation history occurred BEFORE the Honcho memory system was activated.",
+ "These messages are the preceding elements of this conversation session and should",
+ "be treated as foundational context for all subsequent interactions. The user and",
+ "assistant have already established rapport through these exchanges.",
+ "",
+ "",
+ f'',
+ "",
+ ]
+ for msg in messages:
+ ts = msg.get("timestamp", "?")
+ role = msg.get("role", "unknown")
+ content = msg.get("content", "")
+ lines.append(f"[{ts}] {role}: {content}")
+
+ lines.append("")
+ lines.append("")
+ lines.append("")
+
+ return "\n".join(lines).encode("utf-8")
+
+ def migrate_memory_files(self, session_key: str, memory_dir: str) -> bool:
+ """
+ Upload MEMORY.md and USER.md to Honcho as files.
+
+ Used when Honcho activates on an instance that already has locally
+ consolidated memory. Backwards compatible -- skips if files don't exist.
+
+ Args:
+ session_key: The session key to associate files with.
+ memory_dir: Path to the memories directory (~/.hermes/memories/).
+
+ Returns:
+ True if at least one file was uploaded, False otherwise.
+ """
+ from pathlib import Path
+ memory_path = Path(memory_dir)
+
+ if not memory_path.exists():
+ return False
+
+ sanitized = self._sanitize_id(session_key)
+ honcho_session = self._sessions_cache.get(sanitized)
+ if not honcho_session:
+ logger.warning("No Honcho session cached for '%s', skipping memory migration", session_key)
+ return False
+
+ # Resolve user peer for attribution
+ parts = session_key.split(":", 1)
+ channel = parts[0] if len(parts) > 1 else "default"
+ chat_id = parts[1] if len(parts) > 1 else session_key
+ user_peer_id = self._sanitize_id(f"user-{channel}-{chat_id}")
+ user_peer = self._peers_cache.get(user_peer_id)
+ if not user_peer:
+ logger.warning("No user peer cached for '%s', skipping memory migration", user_peer_id)
+ return False
+
+ uploaded = False
+ files = [
+ ("MEMORY.md", "consolidated_memory.md", "Long-term agent notes and preferences"),
+ ("USER.md", "user_profile.md", "User profile and preferences"),
+ ]
+
+ for filename, upload_name, description in files:
+ filepath = memory_path / filename
+ if not filepath.exists():
+ continue
+ content = filepath.read_text(encoding="utf-8").strip()
+ if not content:
+ continue
+
+ wrapped = (
+ f"\n"
+ f"\n"
+ f"This file was consolidated from local conversations BEFORE Honcho was activated.\n"
+ f"{description}. Treat as foundational context for this user.\n"
+ f"\n"
+ f"\n"
+ f"{content}\n"
+ f"\n"
+ )
+
+ try:
+ honcho_session.upload_file(
+ file=(upload_name, wrapped.encode("utf-8"), "text/plain"),
+ peer=user_peer,
+ metadata={"source": "local_memory", "original_file": filename},
+ )
+ logger.info("Uploaded %s to Honcho for %s", filename, session_key)
+ uploaded = True
+ except Exception as e:
+ logger.error("Failed to upload %s to Honcho: %s", filename, e)
+
+ return uploaded
+
+ def list_sessions(self) -> list[dict[str, Any]]:
+ """List all cached sessions."""
+ return [
+ {
+ "key": s.key,
+ "created_at": s.created_at.isoformat(),
+ "updated_at": s.updated_at.isoformat(),
+ "message_count": len(s.messages),
+ }
+ for s in self._cache.values()
+ ]
diff --git a/model_tools.py b/model_tools.py
index 1113fdeb..036bb34b 100644
--- a/model_tools.py
+++ b/model_tools.py
@@ -93,6 +93,7 @@ def _discover_tools():
"tools.delegate_tool",
"tools.process_registry",
"tools.send_message_tool",
+ "tools.honcho_tools",
]
import importlib
for mod_name in _modules:
diff --git a/pyproject.toml b/pyproject.toml
index fdb13cbf..152b4730 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,7 @@ slack = ["slack-bolt>=1.18.0", "slack-sdk>=3.27.0"]
cli = ["simple-term-menu"]
tts-premium = ["elevenlabs"]
pty = ["ptyprocess>=0.7.0"]
+honcho = ["honcho-ai>=2.0.1"]
all = [
"hermes-agent[modal]",
"hermes-agent[messaging]",
@@ -55,6 +56,7 @@ all = [
"hermes-agent[tts-premium]",
"hermes-agent[slack]",
"hermes-agent[pty]",
+ "hermes-agent[honcho]",
]
[project.scripts]
@@ -65,7 +67,7 @@ hermes-agent = "run_agent:main"
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants"]
[tool.setuptools.packages.find]
-include = ["tools", "hermes_cli", "gateway", "cron"]
+include = ["tools", "hermes_cli", "gateway", "cron", "honcho_integration"]
[tool.pytest.ini_options]
testpaths = ["tests"]
diff --git a/run_agent.py b/run_agent.py
index 3b7d6e3b..3a22c033 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -131,6 +131,7 @@ class AIAgent:
skip_context_files: bool = False,
skip_memory: bool = False,
session_db=None,
+ honcho_session_key: str = None,
):
"""
Initialize the AI Agent.
@@ -168,6 +169,8 @@ class AIAgent:
skip_context_files (bool): If True, skip auto-injection of SOUL.md, AGENTS.md, and .cursorrules
into the system prompt. Use this for batch processing and data generation to avoid
polluting trajectories with user-specific persona or project instructions.
+ honcho_session_key (str): Session key for Honcho integration (e.g., "telegram:123456" or CLI session_id).
+ When provided and Honcho is enabled in config, enables persistent cross-session user modeling.
"""
self.model = model
self.max_iterations = max_iterations
@@ -418,6 +421,45 @@ class AIAgent:
except Exception:
pass # Memory is optional -- don't break agent init
+ # Honcho AI-native memory (cross-session user modeling)
+ # Reads ~/.honcho/config.json as the single source of truth.
+ self._honcho = None # HonchoSessionManager | None
+ self._honcho_session_key = honcho_session_key
+ if not skip_memory:
+ try:
+ from honcho_integration.client import HonchoClientConfig, get_honcho_client
+ hcfg = HonchoClientConfig.from_global_config()
+ if hcfg.enabled and hcfg.api_key:
+ from honcho_integration.session import HonchoSessionManager
+ client = get_honcho_client(hcfg)
+ self._honcho = HonchoSessionManager(
+ honcho=client,
+ config=hcfg,
+ )
+ # Resolve session key: explicit arg > global sessions map > fallback
+ if not self._honcho_session_key:
+ self._honcho_session_key = (
+ hcfg.resolve_session_name()
+ or "hermes-default"
+ )
+ # Ensure session exists in Honcho
+ self._honcho.get_or_create(self._honcho_session_key)
+ # Inject session context into the honcho tool module
+ from tools.honcho_tools import set_session_context
+ set_session_context(self._honcho, self._honcho_session_key)
+ logger.info(
+ "Honcho active (session: %s, user: %s, workspace: %s)",
+ self._honcho_session_key, hcfg.peer_name, hcfg.workspace_id,
+ )
+ else:
+ if not hcfg.enabled:
+ logger.debug("Honcho disabled in global config")
+ elif not hcfg.api_key:
+ logger.debug("Honcho enabled but no API key configured")
+ except Exception as e:
+ logger.debug("Honcho init failed (non-fatal): %s", e)
+ self._honcho = None
+
# Skills config: nudge interval for skill creation reminders
self._skill_nudge_interval = 15
try:
@@ -1056,7 +1098,46 @@ class AIAgent:
def is_interrupted(self) -> bool:
"""Check if an interrupt has been requested."""
return self._interrupt_requested
-
+
+ # ── Honcho integration helpers ──
+
+ def _honcho_prefetch(self, user_message: str) -> str:
+ """Fetch user context from Honcho for system prompt injection.
+
+ Returns a formatted context block, or empty string if unavailable.
+ """
+ if not self._honcho or not self._honcho_session_key:
+ return ""
+ try:
+ ctx = self._honcho.get_prefetch_context(self._honcho_session_key, user_message)
+ if not ctx:
+ return ""
+ parts = []
+ rep = ctx.get("representation", "")
+ card = ctx.get("card", "")
+ if rep:
+ parts.append(rep)
+ if card:
+ parts.append(card)
+ if not parts:
+ return ""
+ return "# Honcho User Context\n" + "\n\n".join(parts)
+ except Exception as e:
+ logger.debug("Honcho prefetch failed (non-fatal): %s", e)
+ return ""
+
+ def _honcho_sync(self, user_content: str, assistant_content: str) -> None:
+ """Sync the user/assistant message pair to Honcho."""
+ if not self._honcho or not self._honcho_session_key:
+ return
+ try:
+ session = self._honcho.get_or_create(self._honcho_session_key)
+ session.add_message("user", user_content)
+ session.add_message("assistant", assistant_content)
+ self._honcho.save(session)
+ except Exception as e:
+ logger.debug("Honcho sync failed (non-fatal): %s", e)
+
def _build_system_prompt(self, system_message: str = None) -> str:
"""
Assemble the full system prompt from all layers.
@@ -1711,6 +1792,10 @@ class AIAgent:
# Track user turns for memory flush and periodic nudge logic
self._user_turn_count += 1
+ # Preserve the original user message before nudge injection.
+ # Honcho should receive the actual user input, not system nudges.
+ original_user_message = user_message
+
# Periodic memory nudge: remind the model to consider saving memories.
# Counter resets whenever the memory tool is actually used.
if (self._memory_nudge_interval > 0
@@ -1735,6 +1820,14 @@ class AIAgent:
)
self._iters_since_skill = 0
+ # Honcho prefetch: retrieve user context for system prompt injection
+ self._honcho_context = ""
+ if self._honcho and self._honcho_session_key:
+ try:
+ self._honcho_context = self._honcho_prefetch(user_message)
+ except Exception as e:
+ logger.debug("Honcho prefetch failed (non-fatal): %s", e)
+
# Add user message
user_msg = {"role": "user", "content": user_message}
messages.append(user_msg)
@@ -1813,6 +1906,8 @@ class AIAgent:
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_context:
+ effective_system = (effective_system + "\n\n" + self._honcho_context).strip()
if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages
@@ -2471,7 +2566,11 @@ class AIAgent:
# Persist session to both JSON log and SQLite
self._persist_session(messages, conversation_history)
-
+
+ # Sync conversation to Honcho for user modeling
+ if final_response and not interrupted:
+ self._honcho_sync(original_user_message, final_response)
+
# Build result with interrupt info if applicable
result = {
"final_response": final_response,
diff --git a/tools/honcho_tools.py b/tools/honcho_tools.py
new file mode 100644
index 00000000..a701c646
--- /dev/null
+++ b/tools/honcho_tools.py
@@ -0,0 +1,102 @@
+"""Honcho tool for querying user context via dialectic reasoning.
+
+Registers ``query_user_context`` -- an LLM-callable tool that asks Honcho
+about the current user's history, preferences, goals, and communication
+style. The session key is injected at runtime by the agent loop via
+``set_session_context()``.
+"""
+
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+# ── Module-level state (injected by AIAgent at init time) ──
+
+_session_manager = None # HonchoSessionManager instance
+_session_key: str | None = None # Current session key (e.g., "telegram:123456")
+
+
+def set_session_context(session_manager, session_key: str) -> None:
+ """Register the active Honcho session manager and key.
+
+ Called by AIAgent.__init__ when Honcho is enabled.
+ """
+ global _session_manager, _session_key
+ _session_manager = session_manager
+ _session_key = session_key
+
+
+def clear_session_context() -> None:
+ """Clear session context (for testing or shutdown)."""
+ global _session_manager, _session_key
+ _session_manager = None
+ _session_key = None
+
+
+# ── Tool schema ──
+
+HONCHO_TOOL_SCHEMA = {
+ "name": "query_user_context",
+ "description": (
+ "Query Honcho to retrieve relevant context about the user based on their "
+ "history and preferences. Use this when you need to understand the user's "
+ "background, preferences, past interactions, or goals. This helps you "
+ "personalize your responses and provide more relevant assistance."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": (
+ "A natural language question about the user. Examples: "
+ "'What are this user's main goals?', "
+ "'What communication style does this user prefer?', "
+ "'What topics has this user discussed recently?', "
+ "'What is this user's technical expertise level?'"
+ ),
+ }
+ },
+ "required": ["query"],
+ },
+}
+
+
+# ── Tool handler ──
+
+def _handle_query_user_context(args: dict, **kw) -> str:
+ """Execute the Honcho context query."""
+ query = args.get("query", "")
+ if not query:
+ return json.dumps({"error": "Missing required parameter: query"})
+
+ if not _session_manager or not _session_key:
+ return json.dumps({"error": "Honcho is not active for this session."})
+
+ try:
+ result = _session_manager.get_user_context(_session_key, query)
+ return json.dumps({"result": result})
+ except Exception as e:
+ logger.error("Error querying Honcho user context: %s", e)
+ return json.dumps({"error": f"Failed to query user context: {e}"})
+
+
+# ── Availability check ──
+
+def _check_honcho_available() -> bool:
+ """Tool is only available when Honcho is active."""
+ return _session_manager is not None and _session_key is not None
+
+
+# ── Registration ──
+
+from tools.registry import registry
+
+registry.register(
+ name="query_user_context",
+ toolset="honcho",
+ schema=HONCHO_TOOL_SCHEMA,
+ handler=_handle_query_user_context,
+ check_fn=_check_honcho_available,
+)
diff --git a/toolsets.py b/toolsets.py
index ad787932..6090068a 100644
--- a/toolsets.py
+++ b/toolsets.py
@@ -60,6 +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)
+ "query_user_context",
]
@@ -185,6 +187,12 @@ TOOLSETS = {
"tools": ["delegate_task"],
"includes": []
},
+
+ "honcho": {
+ "description": "Honcho AI-native memory for persistent cross-session user modeling",
+ "tools": ["query_user_context"],
+ "includes": []
+ },
# Scenario-specific toolsets