diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 786873eb0..c174d2b4b 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -229,15 +229,19 @@ def _get_available_providers() -> list: continue except Exception: continue - # Override description with setup hint + schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else [] has_secrets = any(f.get("secret") for f in schema) - if has_secrets: + has_non_secrets = any(not f.get("secret") for f in schema) + if has_secrets and has_non_secrets: + setup_hint = "API key / local" + elif has_secrets: setup_hint = "requires API key" elif not schema: setup_hint = "no setup needed" else: setup_hint = "local" + results.append((name, setup_hint, provider)) return results @@ -246,6 +250,42 @@ def _get_available_providers() -> list: # Setup wizard # --------------------------------------------------------------------------- +def cmd_setup_provider(provider_name: str) -> None: + """Run memory setup for a specific provider, skipping the picker.""" + from hermes_cli.config import load_config, save_config + + providers = _get_available_providers() + match = None + for name, desc, provider in providers: + if name == provider_name: + match = (name, desc, provider) + break + + if not match: + print(f"\n Memory provider '{provider_name}' not found.") + print(" Run 'hermes memory setup' to see available providers.\n") + return + + name, _, provider = match + + _install_dependencies(name) + + config = load_config() + if not isinstance(config.get("memory"), dict): + config["memory"] = {} + + if hasattr(provider, "post_setup"): + hermes_home = str(Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))) + provider.post_setup(hermes_home, config) + return + + # Fallback: generic schema-based setup (same as cmd_setup) + config["memory"]["provider"] = name + save_config(config) + print(f"\n Memory provider: {name}") + print(f" Activation saved to config.yaml\n") + + def cmd_setup(args) -> None: """Interactive memory provider setup wizard.""" from hermes_cli.config import load_config, save_config @@ -283,6 +323,13 @@ def cmd_setup(args) -> None: # Install pip dependencies if declared in plugin.yaml _install_dependencies(name) + # If the provider has a post_setup hook, delegate entirely to it. + # The hook handles its own config, connection test, and activation. + if hasattr(provider, "post_setup"): + hermes_home = str(Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))) + provider.post_setup(hermes_home, config) + return + schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else [] provider_config = config["memory"].get(name, {}) @@ -358,18 +405,18 @@ def cmd_setup(args) -> None: try: provider.save_config(provider_config, hermes_home) except Exception as e: - print(f" ⚠ Failed to write provider config: {e}") + print(f" Failed to write provider config: {e}") # Write secrets to .env if env_writes: _write_env_vars(env_path, env_writes) - print(f"\n ✓ Memory provider: {name}") - print(f" ✓ Activation saved to config.yaml") + print(f"\n Memory provider: {name}") + print(f" Activation saved to config.yaml") if provider_config: - print(f" ✓ Provider config saved") + print(f" Provider config saved") if env_writes: - print(f" ✓ API keys saved to .env") + print(f" API keys saved to .env") print(f"\n Start a new session to activate.\n") diff --git a/optional-skills/autonomous-ai-agents/honcho/SKILL.md b/optional-skills/autonomous-ai-agents/honcho/SKILL.md new file mode 100644 index 000000000..174eaa5d4 --- /dev/null +++ b/optional-skills/autonomous-ai-agents/honcho/SKILL.md @@ -0,0 +1,243 @@ +--- +name: honcho +description: Configure and use Honcho memory with Hermes -- cross-session user modeling, multi-profile peer isolation, observation config, and dialectic reasoning. Use when setting up Honcho, troubleshooting memory, managing profiles with Honcho peers, or tuning observation and recall settings. +version: 1.0.0 +author: Hermes Agent +license: MIT +metadata: + hermes: + tags: [Honcho, Memory, Profiles, Observation, Dialectic, User-Modeling] + homepage: https://docs.honcho.dev + related_skills: [hermes-agent] +prerequisites: + pip: [honcho-ai] +--- + +# Honcho Memory for Hermes + +Honcho provides AI-native cross-session user modeling. It learns who the user is across conversations and gives every Hermes profile its own peer identity while sharing a unified view of the user. + +## When to Use + +- Setting up Honcho (cloud or self-hosted) +- Troubleshooting memory not working / peers not syncing +- Creating multi-profile setups where each agent has its own Honcho peer +- Tuning observation, recall, or write frequency settings +- Understanding what the 4 Honcho tools do and when to use them + +## Setup + +### Cloud (app.honcho.dev) + +```bash +hermes honcho setup +# select "cloud", paste API key from https://app.honcho.dev +``` + +### Self-hosted + +```bash +hermes honcho setup +# select "local", enter base URL (e.g. http://localhost:8000) +``` + +See: https://docs.honcho.dev/v3/guides/integrations/hermes#running-honcho-locally-with-hermes + +### Verify + +```bash +hermes honcho status # shows resolved config, connection test, peer info +``` + +## Architecture + +### Peers + +Honcho models conversations as interactions between **peers**. Hermes creates two peers per session: + +- **User peer** (`peerName`): represents the human. Honcho builds a user representation from observed messages. +- **AI peer** (`aiPeer`): represents this Hermes instance. Each profile gets its own AI peer so agents develop independent views. + +### Observation + +Each peer has two observation toggles that control what Honcho learns from: + +| Toggle | What it does | +|--------|-------------| +| `observeMe` | Peer's own messages are observed (builds self-representation) | +| `observeOthers` | Other peers' messages are observed (builds cross-peer understanding) | + +Default: all four toggles **on** (full bidirectional observation). + +Configure per-peer in `honcho.json`: + +```json +{ + "observation": { + "user": { "observeMe": true, "observeOthers": true }, + "ai": { "observeMe": true, "observeOthers": true } + } +} +``` + +Or use the shorthand presets: + +| Preset | User | AI | Use case | +|--------|------|----|----------| +| `"directional"` (default) | me:on, others:on | me:on, others:on | Multi-agent, full memory | +| `"unified"` | me:on, others:off | me:off, others:on | Single agent, user-only modeling | + +Settings changed in the [Honcho dashboard](https://app.honcho.dev) are synced back on session init -- server-side config wins over local defaults. + +### Sessions + +Honcho sessions scope where messages and observations land. Strategy options: + +| Strategy | Behavior | +|----------|----------| +| `per-directory` (default) | One session per working directory | +| `per-repo` | One session per git repository root | +| `per-session` | New Honcho session each Hermes run | +| `global` | Single session across all directories | + +Manual override: `hermes honcho map my-project-name` + +### Recall Modes + +How the agent accesses Honcho memory: + +| Mode | Auto-inject context? | Tools available? | Use case | +|------|---------------------|-----------------|----------| +| `hybrid` (default) | Yes | Yes | Agent decides when to use tools vs auto context | +| `context` | Yes | No (hidden) | Minimal token cost, no tool calls | +| `tools` | No | Yes | Agent controls all memory access explicitly | + +## Multi-Profile Setup + +Each Hermes profile gets its own Honcho AI peer while sharing the same workspace (user context). This means: + +- All profiles see the same user representation +- Each profile builds its own AI identity and observations +- Conclusions written by one profile are visible to others via the shared workspace + +### Create a profile with Honcho peer + +```bash +hermes profile create coder --clone +# creates host block hermes.coder, AI peer "coder", inherits config from default +``` + +What `--clone` does for Honcho: +1. Creates a `hermes.coder` host block in `honcho.json` +2. Sets `aiPeer: "coder"` (the profile name) +3. Inherits `workspace`, `peerName`, `writeFrequency`, `recallMode`, etc. from default +4. Eagerly creates the peer in Honcho so it exists before first message + +### Backfill existing profiles + +```bash +hermes honcho sync # creates host blocks for all profiles that don't have one yet +``` + +### Per-profile config + +Override any setting in the host block: + +```json +{ + "hosts": { + "hermes.coder": { + "aiPeer": "coder", + "recallMode": "tools", + "observation": { + "user": { "observeMe": true, "observeOthers": false }, + "ai": { "observeMe": true, "observeOthers": true } + } + } + } +} +``` + +## Tools + +The agent has 4 Honcho tools (hidden in `context` recall mode): + +### `honcho_profile` +Quick factual snapshot of the user -- name, role, preferences, patterns. No LLM call, minimal cost. Use at conversation start or for fast lookups. + +### `honcho_search` +Semantic search over stored context. Returns raw excerpts ranked by relevance, no LLM synthesis. Default 800 tokens, max 2000. Use when you want specific past facts to reason over yourself. + +### `honcho_context` +Natural language question answered by Honcho's dialectic reasoning (LLM call on Honcho's backend). Higher cost, higher quality. Can query about user (default) or the AI peer. + +### `honcho_conclude` +Write a persistent fact about the user. Conclusions build the user's profile over time. Use when the user states a preference, corrects you, or shares something to remember. + +## Config Reference + +Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). + +### Key settings + +| Key | Default | Description | +|-----|---------|-------------| +| `apiKey` | -- | API key ([get one](https://app.honcho.dev)) | +| `baseUrl` | -- | Base URL for self-hosted Honcho | +| `peerName` | -- | User peer identity | +| `aiPeer` | host key | AI peer identity | +| `workspace` | host key | Shared workspace ID | +| `recallMode` | `hybrid` | `hybrid`, `context`, or `tools` | +| `observation` | all on | Per-peer `observeMe`/`observeOthers` booleans | +| `writeFrequency` | `async` | `async`, `turn`, `session`, or integer N | +| `sessionStrategy` | `per-directory` | `per-directory`, `per-repo`, `per-session`, `global` | +| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` | +| `dialecticDynamic` | `true` | Auto-bump reasoning by query length. `false` = fixed level | +| `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) | +| `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input | + +### Cost-awareness (advanced, root config only) + +| Key | Default | Description | +|-----|---------|-------------| +| `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` | +| `contextCadence` | `1` | Min turns between context API calls | +| `dialecticCadence` | `1` | Min turns between dialectic API calls | + +## Troubleshooting + +### "Honcho not configured" +Run `hermes honcho setup`. Ensure `memory.provider: honcho` is in `~/.hermes/config.yaml`. + +### Memory not persisting across sessions +Check `hermes honcho status` -- verify `saveMessages: true` and `writeFrequency` isn't `session` (which only writes on exit). + +### Profile not getting its own peer +Use `--clone` when creating: `hermes profile create --clone`. For existing profiles: `hermes honcho sync`. + +### Observation changes in dashboard not reflected +Observation config is synced from the server on each session init. Start a new session after changing settings in the Honcho UI. + +### Messages truncated +Messages over `messageMaxChars` (default 25k) are automatically chunked with `[continued]` markers. If you're hitting this often, check if tool results or skill content is inflating message size. + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `hermes honcho setup` | Interactive setup wizard (cloud/local, identity, observation, recall, sessions) | +| `hermes honcho status` | Show resolved config, connection test, peer info for active profile | +| `hermes honcho enable` | Enable Honcho for the active profile (creates host block if needed) | +| `hermes honcho disable` | Disable Honcho for the active profile | +| `hermes honcho peer` | Show or update peer names (`--user `, `--ai `, `--reasoning `) | +| `hermes honcho peers` | Show peer identities across all profiles | +| `hermes honcho mode` | Show or set recall mode (`hybrid`, `context`, `tools`) | +| `hermes honcho tokens` | Show or set token budgets (`--context `, `--dialectic `) | +| `hermes honcho sessions` | List known directory-to-session-name mappings | +| `hermes honcho map ` | Map current working directory to a Honcho session name | +| `hermes honcho identity` | Seed AI peer identity or show both peer representations | +| `hermes honcho sync` | Create host blocks for all Hermes profiles that don't have one yet | +| `hermes honcho migrate` | Step-by-step migration guide from OpenClaw native memory to Hermes + Honcho | +| `hermes memory setup` | Generic memory provider picker (selecting "honcho" runs the same wizard) | +| `hermes memory status` | Show active memory provider and config | +| `hermes memory off` | Disable external memory provider | diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index f5378caec..80cc5a70a 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -2,15 +2,18 @@ AI-native cross-session user modeling with dialectic Q&A, semantic search, peer cards, and persistent conclusions. +> **Honcho docs:** + ## Requirements - `pip install honcho-ai` -- Honcho API key from [app.honcho.dev](https://app.honcho.dev) +- Honcho API key from [app.honcho.dev](https://app.honcho.dev), or a self-hosted instance ## Setup ```bash -hermes memory setup # select "honcho" +hermes honcho setup # full interactive wizard (cloud or local) +hermes memory setup # generic picker, also works ``` Or manually: @@ -19,17 +22,199 @@ hermes config set memory.provider honcho echo "HONCHO_API_KEY=your-key" >> ~/.hermes/.env ``` -## Config +## Config Resolution -Config file: `$HERMES_HOME/honcho.json` (or `~/.honcho/config.json` legacy) +Config is read from the first file that exists: -Existing Honcho users: your config and data are preserved. Just set `memory.provider: honcho`. +| Priority | Path | Scope | +|----------|------|-------| +| 1 | `$HERMES_HOME/honcho.json` | Profile-local (isolated Hermes instances) | +| 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) | +| 3 | `~/.honcho/config.json` | Global (cross-app interop) | + +Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.`. ## Tools -| Tool | Description | -|------|-------------| -| `honcho_profile` | User's peer card — key facts, no LLM | -| `honcho_search` | Semantic search over stored context | -| `honcho_context` | LLM-synthesized answer from memory | -| `honcho_conclude` | Write a fact about the user to memory | +| Tool | LLM call? | Description | +|------|-----------|-------------| +| `honcho_profile` | No | User's peer card -- key facts snapshot | +| `honcho_search` | No | Semantic search over stored context (800 tok default, 2000 max) | +| `honcho_context` | Yes | LLM-synthesized answer via dialectic reasoning | +| `honcho_conclude` | No | Write a persistent fact about the user | + +Tool availability depends on `recallMode`: hidden in `context` mode, always present in `tools` and `hybrid`. + +## Full Configuration Reference + +### Identity & Connection + +| Key | Type | Default | Scope | Description | +|-----|------|---------|-------|-------------| +| `apiKey` | string | -- | root / host | API key. Falls back to `HONCHO_API_KEY` env var | +| `baseUrl` | string | -- | root | Base URL for self-hosted Honcho. Local URLs (`localhost`, `127.0.0.1`, `::1`) auto-skip API key auth | +| `environment` | string | `"production"` | root / host | SDK environment mapping | +| `enabled` | bool | auto | root / host | Master toggle. Auto-enables when `apiKey` or `baseUrl` present | +| `workspace` | string | host key | root / host | Honcho workspace ID | +| `peerName` | string | -- | root / host | User peer identity | +| `aiPeer` | string | host key | root / host | AI peer identity | + +### Memory & Recall + +| Key | Type | Default | Scope | Description | +|-----|------|---------|-------|-------------| +| `recallMode` | string | `"hybrid"` | root / host | `"hybrid"` (auto-inject + tools), `"context"` (auto-inject only, tools hidden), `"tools"` (tools only, no injection). Legacy `"auto"` normalizes to `"hybrid"` | +| `observationMode` | string | `"directional"` | root / host | Shorthand preset: `"directional"` (all on) or `"unified"` (shared pool). Use `observation` object for granular control | +| `observation` | object | -- | root / host | Per-peer observation config (see below) | + +#### Observation (granular) + +Maps 1:1 to Honcho's per-peer `SessionPeerConfig`. Set at root or per host block -- each profile can have different observation settings. When present, overrides `observationMode` preset. + +```json +"observation": { + "user": { "observeMe": true, "observeOthers": true }, + "ai": { "observeMe": true, "observeOthers": true } +} +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `user.observeMe` | `true` | User peer self-observation (Honcho builds user representation) | +| `user.observeOthers` | `true` | User peer observes AI messages | +| `ai.observeMe` | `true` | AI peer self-observation (Honcho builds AI representation) | +| `ai.observeOthers` | `true` | AI peer observes user messages (enables cross-peer dialectic) | + +Presets for `observationMode`: +- `"directional"` (default): all four booleans `true` +- `"unified"`: user `observeMe=true`, AI `observeOthers=true`, rest `false` + +Per-profile example -- coder profile observes the user but user doesn't observe coder: + +```json +"hosts": { + "hermes.coder": { + "observation": { + "user": { "observeMe": true, "observeOthers": false }, + "ai": { "observeMe": true, "observeOthers": true } + } + } +} +``` + +Settings changed in the [Honcho dashboard](https://app.honcho.dev) are synced back on session init. + +### Write Behavior + +| Key | Type | Default | Scope | Description | +|-----|------|---------|-------|-------------| +| `writeFrequency` | string or int | `"async"` | root / host | `"async"` (background thread), `"turn"` (sync per turn), `"session"` (batch on end), or integer N (every N turns) | +| `saveMessages` | bool | `true` | root / host | Whether to persist messages to Honcho API | + +### Session Resolution + +| Key | Type | Default | Scope | Description | +|-----|------|---------|-------|-------------| +| `sessionStrategy` | string | `"per-directory"` | root / host | `"per-directory"`, `"per-session"` (new each run), `"per-repo"` (git root name), `"global"` (single session) | +| `sessionPeerPrefix` | bool | `false` | root / host | Prepend peer name to session keys | +| `sessions` | object | `{}` | root | Manual directory-to-session-name mappings: `{"/path/to/project": "my-session"}` | + +### Token Budgets & Dialectic + +| Key | Type | Default | Scope | Description | +|-----|------|---------|-------|-------------| +| `contextTokens` | int | SDK default | root / host | Token budget for `context()` API calls. Also gates prefetch truncation (tokens x 4 chars) | +| `dialecticReasoningLevel` | string | `"low"` | root / host | Base reasoning level for `peer.chat()`: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` | +| `dialecticDynamic` | bool | `true` | root / host | Auto-bump reasoning based on query length: `<120` chars = base level, `120-400` = +1, `>400` = +2 (capped at `"high"`). Set `false` to always use `dialecticReasoningLevel` as-is | +| `dialecticMaxChars` | int | `600` | root / host | Max chars of dialectic result injected into system prompt | +| `dialecticMaxInputChars` | int | `10000` | root / host | Max chars for dialectic query input to `peer.chat()`. Honcho cloud limit: 10k | +| `messageMaxChars` | int | `25000` | root / host | Max chars per message sent via `add_messages()`. Messages exceeding this are chunked with `[continued]` markers. Honcho cloud limit: 25k | + +### Cost Awareness (Advanced) + +These are read from the root config object, not the host block. Must be set manually in `honcho.json`. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject context only on turn 0) | +| `contextCadence` | int | `1` | Minimum turns between `context()` API calls | +| `dialecticCadence` | int | `1` | Minimum turns between `peer.chat()` API calls | +| `reasoningLevelCap` | string | -- | Hard cap on auto-bumped reasoning: `"minimal"`, `"low"`, `"mid"`, `"high"` | + +### Hardcoded Limits (Not Configurable) + +| Limit | Value | Location | +|-------|-------|----------| +| Search tool max tokens | 2000 (hard cap), 800 (default) | `__init__.py` handle_tool_call | +| Peer card fetch tokens | 200 | `session.py` get_peer_card | + +## Config Precedence + +For every key, resolution order is: **host block > root > env var > default**. + +Host key derivation: `HERMES_HONCHO_HOST` env > active profile (`hermes.`) > `"hermes"`. + +## Environment Variables + +| Variable | Fallback for | +|----------|-------------| +| `HONCHO_API_KEY` | `apiKey` | +| `HONCHO_BASE_URL` | `baseUrl` | +| `HONCHO_ENVIRONMENT` | `environment` | +| `HERMES_HONCHO_HOST` | Host key override | + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `hermes honcho setup` | Full interactive setup wizard | +| `hermes honcho status` | Show resolved config for active profile | +| `hermes honcho enable` / `disable` | Toggle Honcho for active profile | +| `hermes honcho mode ` | Change recall or observation mode | +| `hermes honcho peer --user ` | Update user peer name | +| `hermes honcho peer --ai ` | Update AI peer name | +| `hermes honcho tokens --context ` | Set context token budget | +| `hermes honcho tokens --dialectic ` | Set dialectic max chars | +| `hermes honcho map ` | Map current directory to a session name | +| `hermes honcho sync` | Create host blocks for all Hermes profiles | + +## Example Config + +```json +{ + "apiKey": "your-key", + "workspace": "hermes", + "peerName": "eri", + "hosts": { + "hermes": { + "enabled": true, + "aiPeer": "hermes", + "workspace": "hermes", + "peerName": "eri", + "recallMode": "hybrid", + "observation": { + "user": { "observeMe": true, "observeOthers": true }, + "ai": { "observeMe": true, "observeOthers": true } + }, + "writeFrequency": "async", + "sessionStrategy": "per-directory", + "dialecticReasoningLevel": "low", + "dialecticMaxChars": 600, + "saveMessages": true + }, + "hermes.coder": { + "enabled": true, + "aiPeer": "coder", + "workspace": "hermes", + "peerName": "eri", + "observation": { + "user": { "observeMe": true, "observeOthers": false }, + "ai": { "observeMe": true, "observeOthers": true } + } + } + }, + "sessions": { + "/home/user/myproject": "myproject-main" + } +} +``` diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index 83298edaf..336cf353d 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -144,10 +144,6 @@ class HonchoMemoryProvider(MemoryProvider): self._last_context_turn = -999 self._last_dialectic_turn = -999 - # B2: peer_memory_mode gating (stub) - self._suppress_memory = False - self._suppress_user_profile = False - # Port #1957: lazy session init for tools-only mode self._session_initialized = False self._lazy_init_kwargs: Optional[dict] = None @@ -187,9 +183,15 @@ class HonchoMemoryProvider(MemoryProvider): def get_config_schema(self): return [ {"key": "api_key", "description": "Honcho API key", "secret": True, "env_var": "HONCHO_API_KEY", "url": "https://app.honcho.dev"}, - {"key": "base_url", "description": "Honcho base URL", "default": "https://api.honcho.dev"}, + {"key": "baseUrl", "description": "Honcho base URL (for self-hosted)"}, ] + def post_setup(self, hermes_home: str, config: dict) -> None: + """Run the full Honcho setup wizard after provider selection.""" + import types + from plugins.memory.honcho.cli import cmd_setup + cmd_setup(types.SimpleNamespace()) + def initialize(self, session_id: str, **kwargs) -> None: """Initialize Honcho session manager. @@ -233,48 +235,10 @@ class HonchoMemoryProvider(MemoryProvider): except Exception as e: logger.debug("Honcho cost-awareness config parse error: %s", e) - # ----- Port #1969: aiPeer sync from SOUL.md ----- - try: - hermes_home = kwargs.get("hermes_home", "") - if hermes_home and not cfg.raw.get("aiPeer"): - soul_path = Path(hermes_home) / "SOUL.md" - if soul_path.exists(): - soul_text = soul_path.read_text(encoding="utf-8").strip() - if soul_text: - # Try YAML frontmatter: "name: Foo" - first_line = soul_text.split("\n")[0].strip() - if first_line.startswith("---"): - # Look for name: in frontmatter - for line in soul_text.split("\n")[1:]: - line = line.strip() - if line == "---": - break - if line.lower().startswith("name:"): - name_val = line.split(":", 1)[1].strip().strip("\"'") - if name_val: - cfg.ai_peer = name_val - logger.debug("Honcho ai_peer set from SOUL.md: %s", name_val) - break - elif first_line.startswith("# "): - # Markdown heading: "# AgentName" - name_val = first_line[2:].strip() - if name_val: - cfg.ai_peer = name_val - logger.debug("Honcho ai_peer set from SOUL.md heading: %s", name_val) - except Exception as e: - logger.debug("Honcho SOUL.md ai_peer sync failed: %s", e) - - # ----- B2: peer_memory_mode gating (stub) ----- - try: - ai_mode = cfg.peer_memory_mode(cfg.ai_peer) - user_mode = cfg.peer_memory_mode(cfg.peer_name or "user") - # "honcho" means Honcho owns memory; suppress built-in - self._suppress_memory = (ai_mode == "honcho") - self._suppress_user_profile = (user_mode == "honcho") - logger.debug("Honcho peer_memory_mode: ai=%s (suppress_memory=%s), user=%s (suppress_user_profile=%s)", - ai_mode, self._suppress_memory, user_mode, self._suppress_user_profile) - except Exception as e: - logger.debug("Honcho peer_memory_mode check failed: %s", e) + # ----- Port #1969: aiPeer sync from SOUL.md — REMOVED ----- + # SOUL.md is persona content, not identity config. aiPeer should + # only come from honcho.json (host block or root) or the default. + # See scratch/memory-plugin-ux-specs.md #10 for rationale. # ----- Port #1957: lazy session init for tools-only mode ----- if self._recall_mode == "tools": @@ -547,19 +511,71 @@ class HonchoMemoryProvider(MemoryProvider): """Track turn count for cadence and injection_frequency logic.""" self._turn_count = turn_number + @staticmethod + def _chunk_message(content: str, limit: int) -> list[str]: + """Split content into chunks that fit within the Honcho message limit. + + Splits at paragraph boundaries when possible, falling back to + sentence boundaries, then word boundaries. Each continuation + chunk is prefixed with "[continued] " so Honcho's representation + engine can reconstruct the full message. + """ + if len(content) <= limit: + return [content] + + prefix = "[continued] " + prefix_len = len(prefix) + chunks = [] + remaining = content + first = True + while remaining: + effective = limit if first else limit - prefix_len + if len(remaining) <= effective: + chunks.append(remaining if first else prefix + remaining) + break + + segment = remaining[:effective] + + # Try paragraph break, then sentence, then word + cut = segment.rfind("\n\n") + if cut < effective * 0.3: + cut = segment.rfind(". ") + if cut >= 0: + cut += 2 # include the period and space + if cut < effective * 0.3: + cut = segment.rfind(" ") + if cut < effective * 0.3: + cut = effective # hard cut + + chunk = remaining[:cut].rstrip() + remaining = remaining[cut:].lstrip() + if not first: + chunk = prefix + chunk + chunks.append(chunk) + first = False + + return chunks + def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None: - """Record the conversation turn in Honcho (non-blocking).""" + """Record the conversation turn in Honcho (non-blocking). + + Messages exceeding the Honcho API limit (default 25k chars) are + split into multiple messages with continuation markers. + """ if self._cron_skipped: return if not self._manager or not self._session_key: return + msg_limit = self._config.message_max_chars if self._config else 25000 + def _sync(): try: session = self._manager.get_or_create(self._session_key) - session.add_message("user", user_content[:4000]) - session.add_message("assistant", assistant_content[:4000]) - # Flush to Honcho API + for chunk in self._chunk_message(user_content, msg_limit): + session.add_message("user", chunk) + for chunk in self._chunk_message(assistant_content, msg_limit): + session.add_message("assistant", chunk) self._manager._flush_session(session) except Exception as e: logger.debug("Honcho sync_turn failed: %s", e) diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 8a38ded4c..a413c8dbe 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -41,9 +41,10 @@ def clone_honcho_for_profile(profile_name: str) -> bool: # Clone settings from default block, override identity fields new_block = {} - for key in ("memoryMode", "recallMode", "writeFrequency", "sessionStrategy", + for key in ("recallMode", "writeFrequency", "sessionStrategy", "sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel", - "dialecticMaxChars", "saveMessages"): + "dialecticDynamic", "dialecticMaxChars", "messageMaxChars", + "dialecticMaxInputChars", "saveMessages", "observation"): val = default_block.get(key) if val is not None: new_block[key] = val @@ -106,8 +107,10 @@ def cmd_enable(args) -> None: # If this is a new profile host block with no settings, clone from default if not block.get("aiPeer"): default_block = cfg.get("hosts", {}).get(HOST, {}) - for key in ("memoryMode", "recallMode", "writeFrequency", "sessionStrategy", - "contextTokens", "dialecticReasoningLevel", "dialecticMaxChars"): + for key in ("recallMode", "writeFrequency", "sessionStrategy", + "contextTokens", "dialecticReasoningLevel", "dialecticDynamic", + "dialecticMaxChars", "messageMaxChars", "dialecticMaxInputChars", + "saveMessages", "observation"): val = default_block.get(key) if val is not None and key not in block: block[key] = val @@ -337,91 +340,135 @@ def cmd_setup(args) -> None: if not _ensure_sdk_installed(): return - # All writes go to the active host block — root keys are managed by - # the user or the honcho CLI only. hosts = cfg.setdefault("hosts", {}) hermes_host = hosts.setdefault(_host_key(), {}) - # API key — shared credential, lives at root so all hosts can read it - current_key = cfg.get("apiKey", "") - masked = f"...{current_key[-8:]}" if len(current_key) > 8 else ("set" if current_key else "not set") - print(f" Current API key: {masked}") - new_key = _prompt("Honcho API key (leave blank to keep current)", secret=True) - if new_key: - cfg["apiKey"] = new_key + # --- 1. Cloud or local? --- + print(" Deployment:") + print(" cloud -- Honcho cloud (api.honcho.dev)") + print(" local -- self-hosted Honcho server") + current_deploy = "local" if any( + h in (cfg.get("baseUrl") or cfg.get("base_url") or "") + for h in ("localhost", "127.0.0.1", "::1") + ) else "cloud" + deploy = _prompt("Cloud or local?", default=current_deploy) + is_local = deploy.lower() in ("local", "l") - effective_key = cfg.get("apiKey", "") - if not effective_key: - print("\n No API key configured. Get your API key at https://app.honcho.dev") - print(" Run 'hermes honcho setup' again once you have a key.\n") - return + # Clean up legacy snake_case key + cfg.pop("base_url", None) - # Peer name + if is_local: + # --- Local: ask for base URL, skip or clear API key --- + current_url = cfg.get("baseUrl") or "" + new_url = _prompt("Base URL", default=current_url or "http://localhost:8000") + if new_url: + cfg["baseUrl"] = new_url + + # For local no-auth, the SDK must not send an API key. + # We keep the key in config (for cloud switching later) but + # the client should skip auth when baseUrl is local. + current_key = cfg.get("apiKey", "") + if current_key: + print(f"\n API key present in config (kept for cloud/hybrid use).") + print(" Local connections will skip auth automatically.") + else: + print("\n No API key set. Local no-auth ready.") + else: + # --- Cloud: set default base URL, require API key --- + cfg.pop("baseUrl", None) # cloud uses SDK default + + current_key = cfg.get("apiKey", "") + masked = f"...{current_key[-8:]}" if len(current_key) > 8 else ("set" if current_key else "not set") + print(f"\n Current API key: {masked}") + new_key = _prompt("Honcho API key (leave blank to keep current)", secret=True) + if new_key: + cfg["apiKey"] = new_key + + if not cfg.get("apiKey"): + print("\n No API key configured. Get yours at https://app.honcho.dev") + print(" Run 'hermes honcho setup' again once you have a key.\n") + return + + # --- 3. Identity --- current_peer = hermes_host.get("peerName") or cfg.get("peerName", "") new_peer = _prompt("Your name (user peer)", default=current_peer or os.getenv("USER", "user")) if new_peer: hermes_host["peerName"] = new_peer + current_ai = hermes_host.get("aiPeer") or cfg.get("aiPeer", "hermes") + new_ai = _prompt("AI peer name", default=current_ai) + if new_ai: + hermes_host["aiPeer"] = new_ai + current_workspace = hermes_host.get("workspace") or cfg.get("workspace", "hermes") new_workspace = _prompt("Workspace ID", default=current_workspace) if new_workspace: hermes_host["workspace"] = new_workspace - hermes_host.setdefault("aiPeer", _host_key()) - - # Memory mode - current_mode = hermes_host.get("memoryMode") or cfg.get("memoryMode", "hybrid") - print("\n Memory mode options:") - print(" hybrid — write to both Honcho and local MEMORY.md (default)") - print(" honcho — Honcho only, skip MEMORY.md writes") - new_mode = _prompt("Memory mode", default=current_mode) - if new_mode in ("hybrid", "honcho"): - hermes_host["memoryMode"] = new_mode + # --- 4. Observation mode --- + current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional") + print("\n Observation mode:") + print(" directional -- all observations on, each AI peer builds its own view (default)") + print(" unified -- shared pool, user observes self, AI observes others only") + new_obs = _prompt("Observation mode", default=current_obs) + if new_obs in ("unified", "directional"): + hermes_host["observationMode"] = new_obs else: - hermes_host["memoryMode"] = "hybrid" + hermes_host["observationMode"] = "directional" - # Write frequency + # --- 5. Write frequency --- current_wf = str(hermes_host.get("writeFrequency") or cfg.get("writeFrequency", "async")) - print("\n Write frequency options:") - print(" async — background thread, no token cost (recommended)") - print(" turn — sync write after every turn") - print(" session — batch write at session end only") - print(" N — write every N turns (e.g. 5)") + print("\n Write frequency:") + print(" async -- background thread, no token cost (recommended)") + print(" turn -- sync write after every turn") + print(" session -- batch write at session end only") + print(" N -- write every N turns (e.g. 5)") new_wf = _prompt("Write frequency", default=current_wf) try: hermes_host["writeFrequency"] = int(new_wf) except (ValueError, TypeError): hermes_host["writeFrequency"] = new_wf if new_wf in ("async", "turn", "session") else "async" - # Recall mode + # --- 6. Recall mode --- _raw_recall = hermes_host.get("recallMode") or cfg.get("recallMode", "hybrid") current_recall = "hybrid" if _raw_recall not in ("hybrid", "context", "tools") else _raw_recall - print("\n Recall mode options:") - print(" hybrid — auto-injected context + Honcho tools available (default)") - print(" context — auto-injected context only, Honcho tools hidden") - print(" tools — Honcho tools only, no auto-injected context") + print("\n Recall mode:") + print(" hybrid -- auto-injected context + Honcho tools available (default)") + print(" context -- auto-injected context only, Honcho tools hidden") + print(" tools -- Honcho tools only, no auto-injected context") new_recall = _prompt("Recall mode", default=current_recall) if new_recall in ("hybrid", "context", "tools"): hermes_host["recallMode"] = new_recall - # Session strategy + # --- 7. Session strategy --- current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-directory") - print("\n Session strategy options:") - print(" per-directory — one session per working directory (default)") - print(" per-session — new Honcho session each run, named by Hermes session ID") - print(" per-repo — one session per git repository (uses repo root name)") - print(" global — single session across all directories") + print("\n Session strategy:") + print(" per-directory -- one session per working directory (default)") + print(" per-session -- new Honcho session each run") + print(" per-repo -- one session per git repository") + 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"): hermes_host["sessionStrategy"] = new_strat - hermes_host.setdefault("enabled", True) + hermes_host["enabled"] = True hermes_host.setdefault("saveMessages", True) _write_config(cfg) print(f"\n Config written to {write_path}") - # Test connection + # --- Auto-enable Honcho as memory provider in config.yaml --- + try: + from hermes_cli.config import load_config, save_config + hermes_config = load_config() + hermes_config.setdefault("memory", {})["provider"] = "honcho" + save_config(hermes_config) + print(" Memory provider set to 'honcho' in config.yaml") + except Exception as e: + print(f" Could not auto-enable in config.yaml: {e}") + print(" Run: hermes config set memory.provider honcho") + + # --- Test connection --- print(" Testing connection... ", end="", flush=True) try: from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client, reset_honcho_client @@ -436,24 +483,23 @@ def cmd_setup(args) -> None: print("\n Honcho is ready.") print(f" Session: {hcfg.resolve_session_name()}") print(f" Workspace: {hcfg.workspace_id}") - print(f" Peer: {hcfg.peer_name}") - _mode_str = hcfg.memory_mode - if hcfg.peer_memory_modes: - overrides = ", ".join(f"{k}={v}" for k, v in hcfg.peer_memory_modes.items()) - _mode_str = f"{hcfg.memory_mode} (peers: {overrides})" - print(f" Mode: {_mode_str}") + print(f" User: {hcfg.peer_name}") + print(f" AI peer: {hcfg.ai_peer}") + print(f" Observe: {hcfg.observation_mode}") print(f" Frequency: {hcfg.write_frequency}") + print(f" Recall: {hcfg.recall_mode}") + print(f" Sessions: {hcfg.session_strategy}") print("\n Honcho tools available in chat:") - print(" honcho_context — ask Honcho a question about you (LLM-synthesized)") - print(" honcho_search — semantic search over your history (no LLM)") - print(" honcho_profile — your peer card, key facts (no LLM)") - print(" honcho_conclude — persist a user fact to Honcho memory (no LLM)") + print(" honcho_context -- ask Honcho about the user (LLM-synthesized)") + print(" honcho_search -- semantic search over history (no LLM)") + print(" honcho_profile -- peer card, key facts (no LLM)") + print(" honcho_conclude -- persist a user fact to memory (no LLM)") print("\n Other commands:") - print(" hermes honcho status — show full config") - print(" hermes honcho mode — show or change memory mode") - print(" hermes honcho tokens — show or set token budgets") - print(" hermes honcho identity — seed or show AI peer identity") - print(" hermes honcho map — map this directory to a session name\n") + print(" hermes honcho status -- show full config") + print(" hermes honcho mode -- change recall/observation mode") + print(" hermes honcho tokens -- tune context and dialectic budgets") + print(" hermes honcho peer -- update peer names") + print(" hermes honcho map -- map this directory to a session name\n") def _active_profile_name() -> str: @@ -546,11 +592,7 @@ def cmd_status(args) -> None: print(f" User peer: {hcfg.peer_name or 'not set'}") print(f" Session key: {hcfg.resolve_session_name()}") print(f" Recall mode: {hcfg.recall_mode}") - print(f" Memory mode: {hcfg.memory_mode}") - if hcfg.peer_memory_modes: - print(" Per-peer modes:") - for peer, mode in hcfg.peer_memory_modes.items(): - print(f" {peer}: {mode}") + print(f" Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others})") print(f" Write freq: {hcfg.write_frequency}") if hcfg.enabled and (hcfg.api_key or hcfg.base_url): @@ -611,24 +653,22 @@ def _cmd_status_all() -> None: cfg = _read_config() active = _active_profile_name() - print(f"\nHoncho profiles ({len(rows)})\n" + "─" * 60) - print(f" {'Profile':<14} {'Host':<22} {'Enabled':<9} {'Mode':<9} {'Recall':<9} {'Write'}") - print(f" {'─' * 14} {'─' * 22} {'─' * 9} {'─' * 9} {'─' * 9} {'─' * 9}") + print(f"\nHoncho profiles ({len(rows)})\n" + "─" * 55) + print(f" {'Profile':<14} {'Host':<22} {'Enabled':<9} {'Recall':<9} {'Write'}") + print(f" {'─' * 14} {'─' * 22} {'─' * 9} {'─' * 9} {'─' * 9}") for name, host, block in rows: enabled = block.get("enabled", cfg.get("enabled")) if enabled is None: - # Auto-enable check: any credentials? has_creds = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY")) enabled = has_creds if block else False enabled_str = "yes" if enabled else "no" - mode = block.get("memoryMode") or cfg.get("memoryMode", "hybrid") recall = block.get("recallMode") or cfg.get("recallMode", "hybrid") write = block.get("writeFrequency") or cfg.get("writeFrequency", "async") marker = " *" if name == active else "" - print(f" {name + marker:<14} {host:<22} {enabled_str:<9} {mode:<9} {recall:<9} {write}") + print(f" {name + marker:<14} {host:<22} {enabled_str:<9} {recall:<9} {write}") print(f"\n * active profile\n") @@ -751,25 +791,26 @@ def cmd_peer(args) -> None: def cmd_mode(args) -> None: - """Show or set the memory mode.""" + """Show or set the recall mode.""" MODES = { - "hybrid": "write to both Honcho and local MEMORY.md (default)", - "honcho": "Honcho only — MEMORY.md writes disabled", + "hybrid": "auto-injected context + Honcho tools available (default)", + "context": "auto-injected context only, Honcho tools hidden", + "tools": "Honcho tools only, no auto-injected context", } cfg = _read_config() mode_arg = getattr(args, "mode", None) if mode_arg is None: current = ( - (cfg.get("hosts") or {}).get(_host_key(), {}).get("memoryMode") - or cfg.get("memoryMode") + (cfg.get("hosts") or {}).get(_host_key(), {}).get("recallMode") + or cfg.get("recallMode") or "hybrid" ) - print("\nHoncho memory mode\n" + "─" * 40) + print("\nHoncho recall mode\n" + "─" * 40) for m, desc in MODES.items(): - marker = " ←" if m == current else "" - print(f" {m:<8} {desc}{marker}") - print("\n Set with: hermes honcho mode [hybrid|honcho]\n") + marker = " <-" if m == current else "" + print(f" {m:<10} {desc}{marker}") + print(f"\n Set with: hermes honcho mode [hybrid|context|tools]\n") return if mode_arg not in MODES: @@ -778,9 +819,9 @@ def cmd_mode(args) -> None: host = _host_key() label = f"[{host}] " if host != "hermes" else "" - cfg.setdefault("hosts", {}).setdefault(host, {})["memoryMode"] = mode_arg + cfg.setdefault("hosts", {}).setdefault(host, {})["recallMode"] = mode_arg _write_config(cfg) - print(f" {label}Memory mode -> {mode_arg} ({MODES[mode_arg]})\n") + print(f" {label}Recall mode -> {mode_arg} ({MODES[mode_arg]})\n") def cmd_tokens(args) -> None: diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 211272142..8ca1e8d1d 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -85,6 +85,15 @@ def _normalize_recall_mode(val: str) -> str: return val if val in _VALID_RECALL_MODES else "hybrid" +def _resolve_bool(host_val, root_val, *, default: bool) -> bool: + """Resolve a bool config field: host wins, then root, then default.""" + if host_val is not None: + return bool(host_val) + if root_val is not None: + return bool(root_val) + return default + + _VALID_OBSERVATION_MODES = {"unified", "directional"} _OBSERVATION_MODE_ALIASES = {"shared": "unified", "separate": "directional", "cross": "directional"} @@ -92,31 +101,52 @@ _OBSERVATION_MODE_ALIASES = {"shared": "unified", "separate": "directional", "cr def _normalize_observation_mode(val: str) -> str: """Normalize observation mode values.""" val = _OBSERVATION_MODE_ALIASES.get(val, val) - return val if val in _VALID_OBSERVATION_MODES else "unified" + return val if val in _VALID_OBSERVATION_MODES else "directional" -def _resolve_memory_mode( - global_val: str | dict, - host_val: str | dict | None, +# Observation presets — granular booleans derived from legacy string mode. +# Explicit per-peer config always wins over presets. +_OBSERVATION_PRESETS = { + "directional": { + "user_observe_me": True, "user_observe_others": True, + "ai_observe_me": True, "ai_observe_others": True, + }, + "unified": { + "user_observe_me": True, "user_observe_others": False, + "ai_observe_me": False, "ai_observe_others": True, + }, +} + + +def _resolve_observation( + mode: str, + observation_obj: dict | None, ) -> dict: - """Parse memoryMode (string or object) into memory_mode + peer_memory_modes. + """Resolve per-peer observation booleans. - Resolution order: host-level wins over global. - String form: applies as the default for all peers. - Object form: { "default": "hybrid", "hermes": "honcho", ... } - "default" key sets the fallback; other keys are per-peer overrides. + Config forms: + String shorthand: ``"observationMode": "directional"`` + Granular object: ``"observation": {"user": {"observeMe": true, "observeOthers": true}, + "ai": {"observeMe": true, "observeOthers": false}}`` + + Granular fields override preset defaults. """ - # Pick the winning value (host beats global) - val = host_val if host_val is not None else global_val + preset = _OBSERVATION_PRESETS.get(mode, _OBSERVATION_PRESETS["directional"]) + if not observation_obj or not isinstance(observation_obj, dict): + return dict(preset) + + user_block = observation_obj.get("user") or {} + ai_block = observation_obj.get("ai") or {} + + return { + "user_observe_me": user_block.get("observeMe", preset["user_observe_me"]), + "user_observe_others": user_block.get("observeOthers", preset["user_observe_others"]), + "ai_observe_me": ai_block.get("observeMe", preset["ai_observe_me"]), + "ai_observe_others": ai_block.get("observeOthers", preset["ai_observe_others"]), + } + - if isinstance(val, dict): - default = val.get("default", "hybrid") - overrides = {k: v for k, v in val.items() if k != "default"} - else: - default = str(val) if val else "hybrid" - overrides = {} - return {"memory_mode": default, "peer_memory_modes": overrides} @dataclass @@ -132,22 +162,9 @@ class HonchoClientConfig: # 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 - # memoryMode: default for all peers. "hybrid" / "honcho" - memory_mode: str = "hybrid" - # Per-peer overrides — any named Honcho peer. Override memory_mode when set. - # Config object form: "memoryMode": { "default": "hybrid", "hermes": "honcho" } - peer_memory_modes: dict[str, str] = field(default_factory=dict) - - def peer_memory_mode(self, peer_name: str) -> str: - """Return the effective memory mode for a named peer. - - Resolution: per-peer override → global memory_mode default. - """ - return self.peer_memory_modes.get(peer_name, self.memory_mode) # Write frequency: "async" (background thread), "turn" (sync per turn), # "session" (flush on session end), or int (every N turns) write_frequency: str | int = "async" @@ -155,19 +172,32 @@ class HonchoClientConfig: context_tokens: int | None = None # Dialectic (peer.chat) settings # reasoning_level: "minimal" | "low" | "medium" | "high" | "max" - # Used as the default; prefetch_dialectic may bump it dynamically. dialectic_reasoning_level: str = "low" + # dynamic: auto-bump reasoning level based on query length + # true — low->medium (120+ chars), low->high (400+ chars), capped at "high" + # false — always use dialecticReasoningLevel as-is + dialectic_dynamic: bool = True # Max chars of dialectic result to inject into Hermes system prompt dialectic_max_chars: int = 600 + # Honcho API limits — configurable for self-hosted instances + # Max chars per message sent via add_messages() (Honcho cloud: 25000) + message_max_chars: int = 25000 + # Max chars for dialectic query input to peer.chat() (Honcho cloud: 10000) + dialectic_max_input_chars: int = 10000 # Recall mode: how memory retrieval works when Honcho is active. # "hybrid" — auto-injected context + Honcho tools available (model decides) # "context" — auto-injected context only, Honcho tools removed # "tools" — Honcho tools only, no auto-injected context recall_mode: str = "hybrid" - # Observation mode: how Honcho peers observe each other. - # "unified" — user peer observes self; all agents share one observation pool - # "directional" — AI peer observes user; each agent keeps its own view - observation_mode: str = "unified" + # Observation mode: legacy string shorthand ("directional" or "unified"). + # Kept for backward compat; granular per-peer booleans below are preferred. + observation_mode: str = "directional" + # Per-peer observation booleans — maps 1:1 to Honcho's SessionPeerConfig. + # Resolved from "observation" object in config, falling back to observation_mode preset. + user_observe_me: bool = True + user_observe_others: bool = True + ai_observe_me: bool = True + ai_observe_others: bool = True # Session resolution session_strategy: str = "per-directory" session_peer_prefix: bool = False @@ -238,8 +268,6 @@ class HonchoClientConfig: or raw.get("aiPeer") or resolved_host ) - linked_hosts = host_block.get("linkedHosts", []) - api_key = ( host_block.get("apiKey") or raw.get("apiKey") @@ -253,6 +281,7 @@ class HonchoClientConfig: base_url = ( raw.get("baseUrl") + or raw.get("base_url") or os.environ.get("HONCHO_BASE_URL", "").strip() or None ) @@ -303,13 +332,8 @@ class HonchoClientConfig: base_url=base_url, peer_name=host_block.get("peerName") or raw.get("peerName"), ai_peer=ai_peer, - linked_hosts=linked_hosts, enabled=enabled, save_messages=save_messages, - **_resolve_memory_mode( - raw.get("memoryMode", "hybrid"), - host_block.get("memoryMode"), - ), write_frequency=write_frequency, context_tokens=host_block.get("contextTokens") or raw.get("contextTokens"), dialectic_reasoning_level=( @@ -317,11 +341,26 @@ class HonchoClientConfig: or raw.get("dialecticReasoningLevel") or "low" ), + dialectic_dynamic=_resolve_bool( + host_block.get("dialecticDynamic"), + raw.get("dialecticDynamic"), + default=True, + ), dialectic_max_chars=int( host_block.get("dialecticMaxChars") or raw.get("dialecticMaxChars") or 600 ), + message_max_chars=int( + host_block.get("messageMaxChars") + or raw.get("messageMaxChars") + or 25000 + ), + dialectic_max_input_chars=int( + host_block.get("dialecticMaxInputChars") + or raw.get("dialecticMaxInputChars") + or 10000 + ), recall_mode=_normalize_recall_mode( host_block.get("recallMode") or raw.get("recallMode") @@ -330,7 +369,15 @@ class HonchoClientConfig: observation_mode=_normalize_observation_mode( host_block.get("observationMode") or raw.get("observationMode") - or "unified" + or "directional" + ), + **_resolve_observation( + _normalize_observation_mode( + host_block.get("observationMode") + or raw.get("observationMode") + or "directional" + ), + host_block.get("observation") or raw.get("observation"), ), session_strategy=session_strategy, session_peer_prefix=session_peer_prefix, @@ -412,17 +459,6 @@ class HonchoClientConfig: # global: single session across all directories return self.workspace_id - 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 @@ -478,12 +514,22 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: # Local Honcho instances don't require an API key, but the SDK # expects a non-empty string. Use a placeholder for local URLs. + # For local: only use config.api_key if the host block explicitly + # sets apiKey (meaning the user wants local auth). Otherwise skip + # the stored key -- it's likely a cloud key that would break local. _is_local = resolved_base_url and ( "localhost" in resolved_base_url or "127.0.0.1" in resolved_base_url or "::1" in resolved_base_url ) - effective_api_key = config.api_key or ("local" if _is_local else None) + if _is_local: + # Check if the host block has its own apiKey (explicit local auth) + _raw = config.raw or {} + _host_block = (_raw.get("hosts") or {}).get(config.host, {}) + _host_has_key = bool(_host_block.get("apiKey")) + effective_api_key = config.api_key if _host_has_key else "local" + else: + effective_api_key = config.api_key kwargs: dict = { "workspace_id": config.workspace_id, diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index 438c62a95..2cd4c5bd2 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -86,7 +86,7 @@ class HonchoSessionManager: 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, - write_frequency, memory_mode, etc.). + write_frequency, observation, etc.). """ self._honcho = honcho self._context_tokens = context_tokens @@ -107,11 +107,25 @@ class HonchoSessionManager: self._dialectic_reasoning_level: str = ( config.dialectic_reasoning_level if config else "low" ) + self._dialectic_dynamic: bool = ( + config.dialectic_dynamic if config else True + ) self._dialectic_max_chars: int = ( config.dialectic_max_chars if config else 600 ) self._observation_mode: str = ( - config.observation_mode if config else "unified" + config.observation_mode if config else "directional" + ) + # Per-peer observation booleans (granular, from config) + self._user_observe_me: bool = config.user_observe_me if config else True + self._user_observe_others: bool = config.user_observe_others if config else True + self._ai_observe_me: bool = config.ai_observe_me if config else True + self._ai_observe_others: bool = config.ai_observe_others if config else True + self._message_max_chars: int = ( + config.message_max_chars if config else 25000 + ) + self._dialectic_max_input_chars: int = ( + config.dialectic_max_input_chars if config else 10000 ) # Async write queue — started lazily on first enqueue @@ -162,20 +176,43 @@ class HonchoSessionManager: session = self.honcho.session(session_id) - # Configure peer observation settings based on observation_mode. - # Unified: user peer observes self, AI peer passive — all agents share - # one observation pool via user self-observations. - # Directional: AI peer observes user — each agent keeps its own view. + # Configure per-peer observation from granular booleans. + # These map 1:1 to Honcho's SessionPeerConfig toggles. try: from honcho.session import SessionPeerConfig - if self._observation_mode == "directional": - user_config = SessionPeerConfig(observe_me=True, observe_others=False) - ai_config = SessionPeerConfig(observe_me=False, observe_others=True) - else: # unified (default) - user_config = SessionPeerConfig(observe_me=True, observe_others=False) - ai_config = SessionPeerConfig(observe_me=False, observe_others=False) + user_config = SessionPeerConfig( + observe_me=self._user_observe_me, + observe_others=self._user_observe_others, + ) + ai_config = SessionPeerConfig( + observe_me=self._ai_observe_me, + observe_others=self._ai_observe_others, + ) session.add_peers([(user_peer, user_config), (assistant_peer, ai_config)]) + + # Sync back: server-side config (set via Honcho UI) wins over + # local defaults. Read the effective config after add_peers. + # Note: observation booleans are manager-scoped, not per-session. + # Last session init wins. Fine for CLI; gateway should scope per-session. + try: + server_user = session.get_peer_configuration(user_peer) + server_ai = session.get_peer_configuration(assistant_peer) + if server_user.observe_me is not None: + self._user_observe_me = server_user.observe_me + if server_user.observe_others is not None: + self._user_observe_others = server_user.observe_others + if server_ai.observe_me is not None: + self._ai_observe_me = server_ai.observe_me + if server_ai.observe_others is not None: + self._ai_observe_others = server_ai.observe_others + logger.debug( + "Honcho observation synced from server: user(me=%s,others=%s) ai(me=%s,others=%s)", + self._user_observe_me, self._user_observe_others, + self._ai_observe_me, self._ai_observe_others, + ) + except Exception as e: + logger.debug("Honcho get_peer_configuration failed (using local config): %s", e) except Exception as e: logger.warning( "Honcho session '%s' add_peers failed (non-fatal): %s", @@ -451,17 +488,22 @@ class HonchoSessionManager: def _dynamic_reasoning_level(self, query: str) -> str: """ - Pick a reasoning level based on message complexity. + Pick a reasoning level for a dialectic query. - Uses the configured default as a floor; bumps up for longer or - more complex messages so Honcho applies more inference where it matters. + When dialecticDynamic is true (default), auto-bumps based on query + length so Honcho applies more inference where it matters: - < 120 chars → default (typically "low") - 120–400 chars → one level above default (cap at "high") - > 400 chars → two levels above default (cap at "high") + < 120 chars -> configured default (typically "low") + 120-400 chars -> +1 level above default (cap at "high") + > 400 chars -> +2 levels above default (cap at "high") - "max" is never selected automatically — reserve it for explicit config. + "max" is never selected automatically -- reserve it for explicit config. + + When dialecticDynamic is false, always returns the configured level. """ + if not self._dialectic_dynamic: + return self._dialectic_reasoning_level + levels = self._REASONING_LEVELS default_idx = levels.index(self._dialectic_reasoning_level) if self._dialectic_reasoning_level in levels else 1 n = len(query) @@ -501,11 +543,15 @@ class HonchoSessionManager: if not session: return "" + # Guard: truncate query to Honcho's dialectic input limit + if len(query) > self._dialectic_max_input_chars: + query = query[:self._dialectic_max_input_chars].rsplit(" ", 1)[0] + level = reasoning_level or self._dynamic_reasoning_level(query) try: - if self._observation_mode == "directional": - # AI peer queries about the user (cross-observation) + if self._ai_observe_others: + # AI peer can observe user — use cross-observation routing if peer == "ai": ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id) result = ai_peer_obj.chat(query, reasoning_level=level) or "" @@ -517,7 +563,7 @@ class HonchoSessionManager: reasoning_level=level, ) or "" else: - # Unified: user peer queries self, or AI peer queries self + # AI can't observe others — each peer queries self peer_id = session.assistant_peer_id if peer == "ai" else session.user_peer_id target_peer = self._get_or_create_peer(peer_id) result = target_peer.chat(query, reasoning_level=level) or "" @@ -618,35 +664,19 @@ class HonchoSessionManager: if not session: return {} - honcho_session = self._sessions_cache.get(session.honcho_session_id) - if not honcho_session: - return {} - result: dict[str, str] = {} try: - ctx = honcho_session.context( - summary=False, - tokens=self._context_tokens, - peer_target=session.user_peer_id, - peer_perspective=session.assistant_peer_id, - ) - card = ctx.peer_card or [] - result["representation"] = ctx.peer_representation or "" - result["card"] = "\n".join(card) if isinstance(card, list) else str(card) + user_ctx = self._fetch_peer_context(session.user_peer_id) + result["representation"] = user_ctx["representation"] + result["card"] = "\n".join(user_ctx["card"]) except Exception as e: logger.warning("Failed to fetch user context from Honcho: %s", e) # Also fetch AI peer's own representation so Hermes knows itself. try: - ai_ctx = honcho_session.context( - summary=False, - tokens=self._context_tokens, - peer_target=session.assistant_peer_id, - peer_perspective=session.user_peer_id, - ) - ai_card = ai_ctx.peer_card or [] - result["ai_representation"] = ai_ctx.peer_representation or "" - result["ai_card"] = "\n".join(ai_card) if isinstance(ai_card, list) else str(ai_card) + ai_ctx = self._fetch_peer_context(session.assistant_peer_id) + result["ai_representation"] = ai_ctx["representation"] + result["ai_card"] = "\n".join(ai_ctx["card"]) except Exception as e: logger.debug("Failed to fetch AI peer context from Honcho: %s", e) @@ -823,6 +853,64 @@ class HonchoSessionManager: return uploaded + @staticmethod + def _normalize_card(card: Any) -> list[str]: + """Normalize Honcho card payloads into a plain list of strings.""" + if not card: + return [] + if isinstance(card, list): + return [str(item) for item in card if item] + return [str(card)] + + def _fetch_peer_card(self, peer_id: str) -> list[str]: + """Fetch a peer card directly from the peer object. + + This avoids relying on session.context(), which can return an empty + peer_card for per-session messaging sessions even when the peer itself + has a populated card. + """ + peer = self._get_or_create_peer(peer_id) + getter = getattr(peer, "get_card", None) + if callable(getter): + return self._normalize_card(getter()) + + legacy_getter = getattr(peer, "card", None) + if callable(legacy_getter): + return self._normalize_card(legacy_getter()) + + return [] + + def _fetch_peer_context(self, peer_id: str, search_query: str | None = None) -> dict[str, Any]: + """Fetch representation + peer card directly from a peer object.""" + peer = self._get_or_create_peer(peer_id) + representation = "" + card: list[str] = [] + + try: + ctx = peer.context(search_query=search_query) if search_query else peer.context() + representation = ( + getattr(ctx, "representation", None) + or getattr(ctx, "peer_representation", None) + or "" + ) + card = self._normalize_card(getattr(ctx, "peer_card", None)) + except Exception as e: + logger.debug("Direct peer.context() failed for '%s': %s", peer_id, e) + + if not representation: + try: + representation = peer.representation() or "" + except Exception as e: + logger.debug("Direct peer.representation() failed for '%s': %s", peer_id, e) + + if not card: + try: + card = self._fetch_peer_card(peer_id) + except Exception as e: + logger.debug("Direct peer card fetch failed for '%s': %s", peer_id, e) + + return {"representation": representation, "card": card} + def get_peer_card(self, session_key: str) -> list[str]: """ Fetch the user peer's card — a curated list of key facts. @@ -835,19 +923,8 @@ class HonchoSessionManager: 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=200, - peer_target=session.user_peer_id, - peer_perspective=session.assistant_peer_id, - ) - card = ctx.peer_card or [] - return card if isinstance(card, list) else [str(card)] + return self._fetch_peer_card(session.user_peer_id) except Exception as e: logger.debug("Failed to fetch peer card from Honcho: %s", e) return [] @@ -872,25 +949,14 @@ class HonchoSessionManager: 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=max_tokens, - peer_target=session.user_peer_id, - peer_perspective=session.assistant_peer_id, - search_query=query, - ) + ctx = self._fetch_peer_context(session.user_peer_id, search_query=query) parts = [] - if ctx.peer_representation: - parts.append(ctx.peer_representation) - card = ctx.peer_card or [] + if ctx["representation"]: + parts.append(ctx["representation"]) + card = ctx["card"] or [] if card: - facts = card if isinstance(card, list) else [str(card)] - parts.append("\n".join(f"- {f}" for f in facts)) + parts.append("\n".join(f"- {f}" for f in card)) return "\n\n".join(parts) except Exception as e: logger.debug("Honcho search_context failed: %s", e) @@ -919,12 +985,12 @@ class HonchoSessionManager: return False try: - if self._observation_mode == "directional": + if self._ai_observe_others: # AI peer creates conclusion about user (cross-observation) assistant_peer = self._get_or_create_peer(session.assistant_peer_id) conclusions_scope = assistant_peer.conclusions_of(session.user_peer_id) else: - # Unified: user peer creates self-conclusion + # AI can't observe others — user peer creates self-conclusion user_peer = self._get_or_create_peer(session.user_peer_id) conclusions_scope = user_peer.conclusions_of(session.user_peer_id) @@ -994,21 +1060,11 @@ class HonchoSessionManager: if not session: return {"representation": "", "card": ""} - honcho_session = self._sessions_cache.get(session.honcho_session_id) - if not honcho_session: - return {"representation": "", "card": ""} - try: - ctx = honcho_session.context( - summary=False, - tokens=self._context_tokens, - peer_target=session.assistant_peer_id, - peer_perspective=session.user_peer_id, - ) - ai_card = ctx.peer_card or [] + ctx = self._fetch_peer_context(session.assistant_peer_id) return { - "representation": ctx.peer_representation or "", - "card": "\n".join(ai_card) if isinstance(ai_card, list) else str(ai_card), + "representation": ctx["representation"] or "", + "card": "\n".join(ctx["card"]), } except Exception as e: logger.debug("Failed to fetch AI representation: %s", e) diff --git a/tests/honcho_plugin/test_async_memory.py b/tests/honcho_plugin/test_async_memory.py index 22c688717..936f47884 100644 --- a/tests/honcho_plugin/test_async_memory.py +++ b/tests/honcho_plugin/test_async_memory.py @@ -2,13 +2,11 @@ Covers: - write_frequency parsing (async / turn / session / int) - - memory_mode parsing - resolve_session_name with session_title - HonchoSessionManager.save() routing per write_frequency - async writer thread lifecycle and retry - flush_all() drains pending messages - shutdown() joins the thread - - memory_mode gating helpers (unit-level) """ import json @@ -42,10 +40,9 @@ def _make_session(**kwargs) -> HonchoSession: ) -def _make_manager(write_frequency="turn", memory_mode="hybrid") -> HonchoSessionManager: +def _make_manager(write_frequency="turn") -> HonchoSessionManager: cfg = HonchoClientConfig( write_frequency=write_frequency, - memory_mode=memory_mode, api_key="test-key", enabled=True, ) @@ -106,77 +103,6 @@ class TestWriteFrequencyParsing: assert cfg.write_frequency == "async" -# --------------------------------------------------------------------------- -# memory_mode parsing from config file -# --------------------------------------------------------------------------- - -class TestMemoryModeParsing: - def test_hybrid(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({"apiKey": "k", "memoryMode": "hybrid"})) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.memory_mode == "hybrid" - - def test_honcho_only(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({"apiKey": "k", "memoryMode": "honcho"})) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.memory_mode == "honcho" - - def test_defaults_to_hybrid(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({"apiKey": "k"})) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.memory_mode == "hybrid" - - def test_host_block_overrides_root(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({ - "apiKey": "k", - "memoryMode": "hybrid", - "hosts": {"hermes": {"memoryMode": "honcho"}}, - })) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.memory_mode == "honcho" - - def test_object_form_sets_default_and_overrides(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({ - "apiKey": "k", - "hosts": {"hermes": {"memoryMode": { - "default": "hybrid", - "hermes": "honcho", - }}}, - })) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.memory_mode == "hybrid" - assert cfg.peer_memory_mode("hermes") == "honcho" - assert cfg.peer_memory_mode("unknown") == "hybrid" # falls through to default - - def test_object_form_no_default_falls_back_to_hybrid(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({ - "apiKey": "k", - "hosts": {"hermes": {"memoryMode": {"hermes": "honcho"}}}, - })) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.memory_mode == "hybrid" - assert cfg.peer_memory_mode("hermes") == "honcho" - assert cfg.peer_memory_mode("other") == "hybrid" - - def test_global_string_host_object_override(self, tmp_path): - """Host object form overrides global string.""" - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({ - "apiKey": "k", - "memoryMode": "honcho", - "hosts": {"hermes": {"memoryMode": {"default": "hybrid", "hermes": "honcho"}}}, - })) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.memory_mode == "hybrid" # host default wins over global "honcho" - assert cfg.peer_memory_mode("hermes") == "honcho" - - # --------------------------------------------------------------------------- # resolve_session_name with session_title # --------------------------------------------------------------------------- @@ -519,27 +445,10 @@ class TestNewConfigFieldDefaults: cfg = HonchoClientConfig() assert cfg.write_frequency == "async" - def test_memory_mode_default(self): - cfg = HonchoClientConfig() - assert cfg.memory_mode == "hybrid" - def test_write_frequency_set(self): cfg = HonchoClientConfig(write_frequency="turn") assert cfg.write_frequency == "turn" - def test_memory_mode_set(self): - cfg = HonchoClientConfig(memory_mode="honcho") - assert cfg.memory_mode == "honcho" - - def test_peer_memory_mode_falls_back_to_global(self): - cfg = HonchoClientConfig(memory_mode="honcho") - assert cfg.peer_memory_mode("any-peer") == "honcho" - - def test_peer_memory_mode_override(self): - cfg = HonchoClientConfig(memory_mode="hybrid", peer_memory_modes={"hermes": "honcho"}) - assert cfg.peer_memory_mode("hermes") == "honcho" - assert cfg.peer_memory_mode("other") == "hybrid" - class TestPrefetchCacheAccessors: def test_set_and_pop_context_result(self): diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 1fa89d4eb..6a49ce514 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -30,7 +30,6 @@ class TestHonchoClientConfigDefaults: assert config.session_strategy == "per-directory" assert config.recall_mode == "hybrid" assert config.session_peer_prefix is False - assert config.linked_hosts == [] assert config.sessions == {} @@ -106,7 +105,6 @@ class TestFromGlobalConfig: "hermes": { "workspace": "override-ws", "aiPeer": "override-ai", - "linkedHosts": ["cursor"], } } })) @@ -116,7 +114,6 @@ class TestFromGlobalConfig: # Host block workspace overrides root workspace assert config.workspace_id == "override-ws" assert config.ai_peer == "override-ai" - assert config.linked_hosts == ["cursor"] assert config.environment == "staging" assert config.peer_name == "alice" assert config.enabled is True @@ -297,41 +294,6 @@ class TestResolveSessionName: assert result == "custom-session" -class TestGetLinkedWorkspaces: - def test_resolves_linked_hosts(self): - config = HonchoClientConfig( - workspace_id="hermes-ws", - linked_hosts=["cursor", "windsurf"], - raw={ - "hosts": { - "cursor": {"workspace": "cursor-ws"}, - "windsurf": {"workspace": "windsurf-ws"}, - } - }, - ) - workspaces = config.get_linked_workspaces() - assert "cursor-ws" in workspaces - assert "windsurf-ws" in workspaces - - def test_excludes_own_workspace(self): - config = HonchoClientConfig( - workspace_id="hermes-ws", - linked_hosts=["other"], - raw={"hosts": {"other": {"workspace": "hermes-ws"}}}, - ) - workspaces = config.get_linked_workspaces() - assert workspaces == [] - - def test_uses_host_key_as_fallback(self): - config = HonchoClientConfig( - workspace_id="hermes-ws", - linked_hosts=["cursor"], - raw={"hosts": {"cursor": {}}}, # no workspace field - ) - workspaces = config.get_linked_workspaces() - assert "cursor" in workspaces - - class TestResolveConfigPath: def test_prefers_hermes_home_when_exists(self, tmp_path): hermes_home = tmp_path / "hermes" @@ -346,14 +308,22 @@ class TestResolveConfigPath: def test_falls_back_to_global_when_no_local(self, tmp_path): hermes_home = tmp_path / "hermes" hermes_home.mkdir() - # No honcho.json in HERMES_HOME + # No honcho.json in HERMES_HOME — also isolate ~/.hermes so + # the default-profile fallback doesn't hit the real filesystem. + fake_home = tmp_path / "fakehome" + fake_home.mkdir() - with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \ + patch.object(Path, "home", return_value=fake_home): result = resolve_config_path() assert result == GLOBAL_CONFIG_PATH - def test_falls_back_to_global_without_hermes_home_env(self): - with patch.dict(os.environ, {}, clear=False): + def test_falls_back_to_global_without_hermes_home_env(self, tmp_path): + fake_home = tmp_path / "fakehome" + fake_home.mkdir() + + with patch.dict(os.environ, {}, clear=False), \ + patch.object(Path, "home", return_value=fake_home): os.environ.pop("HERMES_HOME", None) result = resolve_config_path() assert result == GLOBAL_CONFIG_PATH diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index 67c6dc219..e3452cf6c 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -1,12 +1,14 @@ """Tests for plugins/memory/honcho/session.py — HonchoSession and helpers.""" from datetime import datetime +from types import SimpleNamespace from unittest.mock import MagicMock from plugins.memory.honcho.session import ( HonchoSession, HonchoSessionManager, ) +from plugins.memory.honcho import HonchoMemoryProvider # --------------------------------------------------------------------------- @@ -187,3 +189,175 @@ class TestManagerCacheOps: assert keys == {"k1", "k2"} s1_info = next(s for s in sessions if s["key"] == "k1") assert s1_info["message_count"] == 1 + + +class TestPeerLookupHelpers: + def _make_cached_manager(self): + mgr = HonchoSessionManager() + session = HonchoSession( + key="telegram:123", + user_peer_id="robert", + assistant_peer_id="hermes", + honcho_session_id="telegram-123", + ) + mgr._cache[session.key] = session + return mgr, session + + def test_get_peer_card_uses_direct_peer_lookup(self): + mgr, session = self._make_cached_manager() + user_peer = MagicMock() + user_peer.get_card.return_value = ["Name: Robert"] + mgr._get_or_create_peer = MagicMock(return_value=user_peer) + + assert mgr.get_peer_card(session.key) == ["Name: Robert"] + user_peer.get_card.assert_called_once_with() + + def test_search_context_uses_peer_context_response(self): + mgr, session = self._make_cached_manager() + user_peer = MagicMock() + user_peer.context.return_value = SimpleNamespace( + representation="Robert runs neuralancer", + peer_card=["Location: Melbourne"], + ) + mgr._get_or_create_peer = MagicMock(return_value=user_peer) + + result = mgr.search_context(session.key, "neuralancer") + + assert "Robert runs neuralancer" in result + assert "- Location: Melbourne" in result + user_peer.context.assert_called_once_with(search_query="neuralancer") + + def test_get_prefetch_context_fetches_user_and_ai_from_peer_api(self): + mgr, session = self._make_cached_manager() + user_peer = MagicMock() + user_peer.context.return_value = SimpleNamespace( + representation="User representation", + peer_card=["Name: Robert"], + ) + ai_peer = MagicMock() + ai_peer.context.return_value = SimpleNamespace( + representation="AI representation", + peer_card=["Owner: Robert"], + ) + mgr._get_or_create_peer = MagicMock(side_effect=[user_peer, ai_peer]) + + result = mgr.get_prefetch_context(session.key) + + assert result == { + "representation": "User representation", + "card": "Name: Robert", + "ai_representation": "AI representation", + "ai_card": "Owner: Robert", + } + user_peer.context.assert_called_once_with() + ai_peer.context.assert_called_once_with() + + def test_get_ai_representation_uses_peer_api(self): + mgr, session = self._make_cached_manager() + ai_peer = MagicMock() + ai_peer.context.return_value = SimpleNamespace( + representation="AI representation", + peer_card=["Owner: Robert"], + ) + mgr._get_or_create_peer = MagicMock(return_value=ai_peer) + + result = mgr.get_ai_representation(session.key) + + assert result == { + "representation": "AI representation", + "card": "Owner: Robert", + } + ai_peer.context.assert_called_once_with() + + +# --------------------------------------------------------------------------- +# Message chunking +# --------------------------------------------------------------------------- + + +class TestChunkMessage: + def test_short_message_single_chunk(self): + result = HonchoMemoryProvider._chunk_message("hello world", 100) + assert result == ["hello world"] + + def test_exact_limit_single_chunk(self): + msg = "x" * 100 + result = HonchoMemoryProvider._chunk_message(msg, 100) + assert result == [msg] + + def test_splits_at_paragraph_boundary(self): + msg = "first paragraph.\n\nsecond paragraph." + # limit=30: total is 35, forces split; second chunk with prefix is 29, fits + result = HonchoMemoryProvider._chunk_message(msg, 30) + assert len(result) == 2 + assert result[0] == "first paragraph." + assert result[1] == "[continued] second paragraph." + + def test_splits_at_sentence_boundary(self): + msg = "First sentence. Second sentence. Third sentence is here." + result = HonchoMemoryProvider._chunk_message(msg, 35) + assert len(result) >= 2 + # First chunk should end at a sentence boundary (rstripped) + assert result[0].rstrip().endswith(".") + + def test_splits_at_word_boundary(self): + msg = "word " * 20 # 100 chars + result = HonchoMemoryProvider._chunk_message(msg, 30) + assert len(result) >= 2 + # No words should be split mid-word + for chunk in result: + clean = chunk.replace("[continued] ", "") + assert not clean.startswith(" ") + + def test_continuation_prefix(self): + msg = "a" * 200 + result = HonchoMemoryProvider._chunk_message(msg, 50) + assert len(result) >= 2 + assert not result[0].startswith("[continued]") + for chunk in result[1:]: + assert chunk.startswith("[continued] ") + + def test_empty_message(self): + result = HonchoMemoryProvider._chunk_message("", 100) + assert result == [""] + + def test_large_message_many_chunks(self): + msg = "word " * 10000 # 50k chars + result = HonchoMemoryProvider._chunk_message(msg, 25000) + assert len(result) >= 2 + for chunk in result: + assert len(chunk) <= 25000 + + +# --------------------------------------------------------------------------- +# Dialectic input guard +# --------------------------------------------------------------------------- + + +class TestDialecticInputGuard: + def test_long_query_truncated(self): + """Queries exceeding dialectic_max_input_chars are truncated.""" + from plugins.memory.honcho.client import HonchoClientConfig + + cfg = HonchoClientConfig(dialectic_max_input_chars=100) + mgr = HonchoSessionManager(config=cfg) + mgr._dialectic_max_input_chars = 100 + + # Create a cached session so dialectic_query doesn't bail early + session = HonchoSession( + key="test", user_peer_id="u", assistant_peer_id="a", + honcho_session_id="s", + ) + mgr._cache["test"] = session + + # Mock the peer to capture the query + mock_peer = MagicMock() + mock_peer.chat.return_value = "answer" + mgr._get_or_create_peer = MagicMock(return_value=mock_peer) + + long_query = "word " * 100 # 500 chars, exceeds 100 limit + mgr.dialectic_query("test", long_query) + + # The query passed to chat() should be truncated + actual_query = mock_peer.chat.call_args[0][0] + assert len(actual_query) <= 100 diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index d0ca25db2..3c4150ffd 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -44,27 +44,158 @@ AI-native cross-session user modeling with dialectic Q&A, semantic search, and p | | | |---|---| -| **Best for** | Teams using Honcho's user modeling platform | -| **Requires** | `pip install honcho-ai` + API key | -| **Data storage** | Honcho Cloud | -| **Cost** | Honcho pricing | +| **Best for** | Multi-agent systems with cross-session context, user-agent alignment | +| **Requires** | `pip install honcho-ai` + [API key](https://app.honcho.dev) or self-hosted instance | +| **Data storage** | Honcho Cloud or self-hosted | +| **Cost** | Honcho pricing (cloud) / free (self-hosted) | **Tools:** `honcho_profile` (peer card), `honcho_search` (semantic search), `honcho_context` (LLM-synthesized), `honcho_conclude` (store facts) -**Setup:** +**Setup Wizard:** ```bash -hermes memory setup # select "honcho" -# Or manually: -hermes config set memory.provider honcho -echo "HONCHO_API_KEY=your-key" >> ~/.hermes/.env +hermes honcho setup # (legacy command) +# or +hermes memory setup # select "honcho" ``` -**Config:** `$HERMES_HOME/honcho.json` — existing Honcho users' configuration and data are fully preserved. +**Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/hermes-ai/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes). + +
+Key config options + +| Key | Default | Description | +|-----|---------|-------------| +| `apiKey` | -- | API key from [app.honcho.dev](https://app.honcho.dev) | +| `baseUrl` | -- | Base URL for self-hosted Honcho | +| `peerName` | -- | User peer identity | +| `aiPeer` | host key | AI peer identity (one per profile) | +| `workspace` | host key | Shared workspace ID | +| `recallMode` | `hybrid` | `hybrid` (auto-inject + tools), `context` (inject only), `tools` (tools only) | +| `observation` | all on | Per-peer `observeMe`/`observeOthers` booleans | +| `writeFrequency` | `async` | `async`, `turn`, `session`, or integer N | +| `sessionStrategy` | `per-directory` | `per-directory`, `per-repo`, `per-session`, `global` | +| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` | +| `dialecticDynamic` | `true` | Auto-bump reasoning by query length | +| `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) | + +
+ +
+Minimal honcho.json (cloud) + +```json +{ + "apiKey": "your-key-from-app.honcho.dev", + "hosts": { + "hermes": { + "enabled": true, + "aiPeer": "hermes", + "peerName": "your-name", + "workspace": "hermes" + } + } +} +``` + +
+ +
+Minimal honcho.json (self-hosted) + +```json +{ + "baseUrl": "http://localhost:8000", + "hosts": { + "hermes": { + "enabled": true, + "aiPeer": "hermes", + "peerName": "your-name", + "workspace": "hermes" + } + } +} +``` + +
:::tip Migrating from `hermes honcho` -If you previously used `hermes honcho setup`, your config and all server-side data are intact. Just set `memory.provider: honcho` to reactivate via the new system. +If you previously used `hermes honcho setup`, your config and all server-side data are intact. Just re-enable through the setup wizard again or manually set `memory.provider: honcho` to reactivate via the new system. ::: +**Multi-agent / Profiles:** + +Each Hermes profile gets its own Honcho AI peer while sharing the same workspace -- all profiles see the same user representation, but each agent builds its own identity and observations. + +```bash +hermes profile create coder --clone # creates honcho peer "coder", inherits config from default +``` + +What `--clone` does: creates a `hermes.coder` host block in `honcho.json` with `aiPeer: "coder"`, shared `workspace`, inherited `peerName`, `recallMode`, `writeFrequency`, `observation`, etc. The peer is eagerly created in Honcho so it exists before first message. + +For profiles created before Honcho was set up: + +```bash +hermes honcho sync # scans all profiles, creates host blocks for any missing ones +``` + +This inherits settings from the default `hermes` host block and creates new AI peers for each profile. Idempotent -- skips profiles that already have a host block. + +
+Full honcho.json example (multi-profile) + +```json +{ + "apiKey": "your-key", + "workspace": "hermes", + "peerName": "eri", + "hosts": { + "hermes": { + "enabled": true, + "aiPeer": "hermes", + "workspace": "hermes", + "peerName": "eri", + "recallMode": "hybrid", + "writeFrequency": "async", + "sessionStrategy": "per-directory", + "observation": { + "user": { "observeMe": true, "observeOthers": true }, + "ai": { "observeMe": true, "observeOthers": true } + }, + "dialecticReasoningLevel": "low", + "dialecticDynamic": true, + "dialecticMaxChars": 600, + "messageMaxChars": 25000, + "saveMessages": true + }, + "hermes.coder": { + "enabled": true, + "aiPeer": "coder", + "workspace": "hermes", + "peerName": "eri", + "recallMode": "tools", + "observation": { + "user": { "observeMe": true, "observeOthers": false }, + "ai": { "observeMe": true, "observeOthers": true } + } + }, + "hermes.writer": { + "enabled": true, + "aiPeer": "writer", + "workspace": "hermes", + "peerName": "eri" + } + }, + "sessions": { + "/home/user/myproject": "myproject-main" + } +} +``` + +
+ +See the [config reference](https://github.com/hermes-ai/hermes-agent/blob/main/plugins/memory/honcho/README.md) and [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes). + + --- ### OpenViking diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index 5da6d8ab2..67609564f 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -54,6 +54,10 @@ Copies **everything** — config, API keys, personality, all memories, full sess hermes profile create work --clone --clone-from coder ``` +:::tip Honcho memory + profiles +When Honcho is enabled, `--clone` automatically creates a dedicated AI peer for the new profile while sharing the same user workspace. Each profile builds its own observations and identity. See [Honcho -- Multi-agent / Profiles](./features/memory-providers.md#honcho) for details. +::: + ## Using profiles ### Command aliases