fix(honcho): plugin drift overhaul -- observation config, chunking, setup wizard, docs, dead code cleanup

Salvaged from PR #5045 by erosika.

- Replace memoryMode/peer_memory_modes with granular per-peer observation config
- Add message chunking for Honcho API limits (25k chars default)
- Add dialectic input guard (10k chars default)
- Add dialecticDynamic toggle for reasoning level auto-bump
- Rewrite setup wizard with cloud/local deployment picker
- Switch peer card/profile/search from session.context() to direct peer APIs
- Add server-side observation sync via get_peer_configuration()
- Fix base_url/baseUrl config mismatch for self-hosted setups
- Fix local auth leak (cloud API keys no longer sent to local instances)
- Remove dead code: memoryMode, peer_memory_modes, linkedHosts, suppress flags, SOUL.md aiPeer sync
- Add post_setup hook to memory_setup.py for provider-specific setup wizards
- Comprehensive README rewrite with full config reference
- New optional skill: autonomous-ai-agents/honcho
- Expanded memory-providers.md with multi-profile docs
- 9 new tests (chunking, dialectic guard, peer lookups), 14 dead tests removed
- Fix 2 pre-existing TestResolveConfigPath filesystem isolation failures
This commit is contained in:
erosika
2026-04-05 12:03:15 -07:00
committed by Teknium
parent 12724e6295
commit c02c3dc723
12 changed files with 1265 additions and 443 deletions

View File

@@ -2,15 +2,18 @@
AI-native cross-session user modeling with dialectic Q&A, semantic search, peer cards, and persistent conclusions.
> **Honcho docs:** <https://docs.honcho.dev/v3/guides/integrations/hermes>
## 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.<profile>`.
## 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.<profile>`) > `"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 <mode>` | Change recall or observation mode |
| `hermes honcho peer --user <name>` | Update user peer name |
| `hermes honcho peer --ai <name>` | Update AI peer name |
| `hermes honcho tokens --context <N>` | Set context token budget |
| `hermes honcho tokens --dialectic <N>` | Set dialectic max chars |
| `hermes honcho map <name>` | 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"
}
}
```

View File

@@ -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)

View File

@@ -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 <name> 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 <name> -- 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:

View File

@@ -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,

View File

@@ -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")
120400 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)