diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 6cd1c860b6..d870d8dcc7 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -28,6 +28,7 @@ Usage in run_agent.py: from __future__ import annotations +import json import logging import re from typing import Any, Dict, List, Optional @@ -43,6 +44,14 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- _FENCE_TAG_RE = re.compile(r'', re.IGNORECASE) +_INTERNAL_CONTEXT_RE = re.compile( + r'<\s*(?:memory-context|supermemory-context|supermemory-containers)\s*>[\s\S]*?', + re.IGNORECASE, +) +_INTERNAL_NOTE_RE = re.compile( + r'\[System note:\s*The following is recalled memory context,\s*NOT new user input\.\s*Treat as informational background data\.\]\s*', + re.IGNORECASE, +) def sanitize_context(text: str) -> str: diff --git a/extraction-queue.txt b/extraction-queue.txt new file mode 100644 index 0000000000..09bac8c9c1 --- /dev/null +++ b/extraction-queue.txt @@ -0,0 +1,81 @@ +# Design Pattern Extraction Queue +# Generated 2026-04-10 +# Total: ~74 sites + +## Eri's List (30 - skip kellychong.ca, done) +https://tahahossain.com/ +https://liam.cv/ +https://www.alicias.page/ +https://haleypark.design/ +https://eriks.design/ +https://www.trybehold.com/ +https://variant.ai/ +https://benji.org/ +https://marijanapav.com/stamps +https://tokyo.floguo.com/ +https://www.cosmos.so/public-work +https://ryo.lu/ +https://alistair.sh/ +https://tommytrinh.me/ +https://map.simonsarris.com/?utm_campaign=profile_chips +https://www.limjungyoon.com/portfolio/simplecomputer +https://graphicsf22.notion.site/reading-2f6157fd47778053bc3cd503d608cc81 +https://bradleyziffer.com/ +https://diana.lu/ +https://otherkind.studio/ +https://www.daybreak.studio/work +https://www.zegzulka.com/ +https://jakub.kr/ +https://www.nostalgicsnacks.com/ +https://www.jw.works/ +https://www.maxpotze.com/ +https://www.rachelchen.tech/ +https://www.goabstract.com/ +https://timobecker.com/ + +## Notion Page Links (45) +https://www.karststonepaper.com/ +https://www.thinkwithgoogle.com/feature/ml-fairness-for-marketers/ +https://brunoarizio.com/ +http://www.garysheng.com/ +https://www.oliverkiss.design/ +https://activetheory.net/ +https://bizar.ro/ +http://www.underforest.com/ +https://gavedikian.io/about/ +http://www.hemingwayapp.com/ +https://makestuffup.info/ +http://www.dennisadelmann.de/ +https://labs.jensimmons.com/2016/ +https://themessagetoukraine.obys.agency/ +https://www.felipe.design/ +https://emilkowal.ski/ +https://manuelmoreale.com/ +http://brianlovin.com/ +https://paco.me/ +https://rauno.me/ +https://flow.rest/ +https://grotesque.basement.studio/ +https://jonstuebe.com/notes +https://leerob.io/ +https://zenorocha.com/ +https://www.ryanstephen.co/ +https://www.lindaeliasen.com/ +https://www.patrickaltair.com/ +https://www.c-ehrlich.dev/ +https://marcbouchenoire.com/ +https://www.wtw.dev/ +https://jm.sv/not-work +https://twentyeight.studio/team +https://linusrogge.com/ +https://archive.withcompound.com/ +https://www.nan.fyi/ +https://www.inkandswitch.com/ +https://www.sj.land/ +https://tomasmaillo.com/ +https://jonmilner.com/ +https://danmall.com/ +https://www.nilseller.com/ +https://designwithtech.com/ +https://www.aaraalto.com/ +https://www.antonstallboerger.com/ diff --git a/gateway/run.py b/gateway/run.py index 222e28c3eb..1b001bd2f9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3442,6 +3442,7 @@ class GatewayRunner: model=_hyg_model, max_iterations=4, quiet_mode=True, + skip_memory=True, enabled_toolsets=["memory"], session_id=session_entry.session_id, ) @@ -5901,6 +5902,7 @@ class GatewayRunner: model=model, max_iterations=4, quiet_mode=True, + skip_memory=True, enabled_toolsets=["memory"], session_id=session_entry.session_id, ) @@ -8189,6 +8191,7 @@ class GatewayRunner: session_id=session_id, platform=platform_key, user_id=source.user_id, + gateway_session_key=session_key, session_db=self._session_db, fallback_model=self._fallback_model, ) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 80cc5a70aa..6c64bca591 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -125,7 +125,7 @@ Settings changed in the [Honcho dashboard](https://app.honcho.dev) are synced ba |-----|------|---------|-------|-------------| | `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 | +| `dialecticDynamic` | bool | `true` | root / host | When `true`, the model can override reasoning level per-call via the `honcho_reasoning` tool `reasoning_level` param (agentic). When `false`, always uses `dialecticReasoningLevel` and ignores model overrides | | `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 | @@ -139,7 +139,7 @@ These are read from the root config object, not the host block. Must be set manu | `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"` | +| `reasoningLevelCap` | string | -- | Hard cap on reasoning level: `"minimal"`, `"low"`, `"medium"`, `"high"` | ### Hardcoded Limits (Not Configurable) diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index 869fe788ae..a282af5078 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -33,20 +33,33 @@ logger = logging.getLogger(__name__) PROFILE_SCHEMA = { "name": "honcho_profile", "description": ( - "Retrieve the user's peer card from Honcho — a curated list of key facts " - "about them (name, role, preferences, communication style, patterns). " - "Fast, no LLM reasoning, minimal cost. " - "Use this at conversation start or when you need a quick factual snapshot." + "Retrieve or update a peer card from Honcho — a curated list of key facts " + "about that peer (name, role, preferences, communication style, patterns). " + "Pass `card` to update; omit `card` to read." ), - "parameters": {"type": "object", "properties": {}, "required": []}, + "parameters": { + "type": "object", + "properties": { + "peer": { + "type": "string", + "description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.", + }, + "card": { + "type": "array", + "items": {"type": "string"}, + "description": "New peer card as a list of fact strings. Omit to read the current card.", + }, + }, + "required": [], + }, } SEARCH_SCHEMA = { "name": "honcho_search", "description": ( - "Semantic search over Honcho's stored context about the user. " + "Semantic search over Honcho's stored context about a peer. " "Returns raw excerpts ranked by relevance — no LLM synthesis. " - "Cheaper and faster than honcho_context. " + "Cheaper and faster than honcho_reasoning. " "Good when you want to find specific past facts and reason over them yourself." ), "parameters": { @@ -60,17 +73,23 @@ SEARCH_SCHEMA = { "type": "integer", "description": "Token budget for returned context (default 800, max 2000).", }, + "peer": { + "type": "string", + "description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.", + }, }, "required": ["query"], }, } -CONTEXT_SCHEMA = { - "name": "honcho_context", +REASONING_SCHEMA = { + "name": "honcho_reasoning", "description": ( "Ask Honcho a natural language question and get a synthesized answer. " "Uses Honcho's LLM (dialectic reasoning) — higher cost than honcho_profile or honcho_search. " - "Can query about any peer: the user (default) or the AI assistant." + "Can query about any peer via alias or explicit peer ID. " + "Pass reasoning_level to control depth: minimal (fast/cheap), low (default), " + "medium, high, max (deep/expensive). Omit for configured default." ), "parameters": { "type": "object", @@ -79,37 +98,83 @@ CONTEXT_SCHEMA = { "type": "string", "description": "A natural language question.", }, + "reasoning_level": { + "type": "string", + "description": ( + "Override the default reasoning depth. " + "Omit to use the configured default (typically low). " + "Guide:\n" + "- minimal: quick factual lookups (name, role, simple preference)\n" + "- low: straightforward questions with clear answers\n" + "- medium: multi-aspect questions requiring synthesis across observations\n" + "- high: complex behavioral patterns, contradictions, deep analysis\n" + "- max: thorough audit-level analysis, leave no stone unturned" + ), + "enum": ["minimal", "low", "medium", "high", "max"], + }, "peer": { "type": "string", - "description": "Which peer to query about: 'user' (default) or 'ai'.", + "description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.", }, }, "required": ["query"], }, } +CONTEXT_SCHEMA = { + "name": "honcho_context", + "description": ( + "Retrieve full session context from Honcho — summary, peer representation, " + "peer card, and recent messages. No LLM synthesis. " + "Cheaper than honcho_reasoning. Use this to see what Honcho knows about " + "the current conversation and the specified peer." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional focus query to filter context. Omit for full session context snapshot.", + }, + "peer": { + "type": "string", + "description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.", + }, + }, + "required": [], + }, +} + CONCLUDE_SCHEMA = { "name": "honcho_conclude", "description": ( - "Write a conclusion about the user back to Honcho's memory. " - "Conclusions are persistent facts that build the user's profile. " - "Use when the user states a preference, corrects you, or shares " - "something to remember across sessions." + "Write or delete a conclusion about a peer in Honcho's memory. " + "Conclusions are persistent facts that build a peer's profile. " + "Pass `conclusion` to create. Pass `delete_id` to remove a conclusion " + "containing personal information — Honcho self-heals incorrect " + "conclusions over time, so deletion is only needed for PII removal." ), "parameters": { "type": "object", "properties": { "conclusion": { "type": "string", - "description": "A factual statement about the user to persist.", + "description": "A factual statement to persist. Omit when using delete_id.", + }, + "delete_id": { + "type": "string", + "description": "Conclusion ID to delete (for removing PII). Omit when creating.", + }, + "peer": { + "type": "string", + "description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.", } }, - "required": ["conclusion"], }, } -ALL_TOOL_SCHEMAS = [PROFILE_SCHEMA, SEARCH_SCHEMA, CONTEXT_SCHEMA, CONCLUDE_SCHEMA] +ALL_TOOL_SCHEMAS = [PROFILE_SCHEMA, SEARCH_SCHEMA, REASONING_SCHEMA, CONTEXT_SCHEMA, CONCLUDE_SCHEMA] # --------------------------------------------------------------------------- @@ -139,8 +204,8 @@ class HonchoMemoryProvider(MemoryProvider): self._turn_count = 0 self._injection_frequency = "every-turn" # or "first-turn" self._context_cadence = 1 # minimum turns between context API calls - self._dialectic_cadence = 1 # minimum turns between dialectic API calls - self._reasoning_level_cap: Optional[str] = None # "minimal", "low", "mid", "high" + self._dialectic_cadence = 3 # minimum turns between dialectic API calls + self._reasoning_level_cap: Optional[str] = None # "minimal", "low", "medium", "high" self._last_context_turn = -999 self._last_dialectic_turn = -999 @@ -236,9 +301,9 @@ class HonchoMemoryProvider(MemoryProvider): raw = cfg.raw or {} self._injection_frequency = raw.get("injectionFrequency", "every-turn") self._context_cadence = int(raw.get("contextCadence", 1)) - self._dialectic_cadence = int(raw.get("dialecticCadence", 1)) + self._dialectic_cadence = int(raw.get("dialecticCadence", 3)) cap = raw.get("reasoningLevelCap") - if cap and cap in ("minimal", "low", "mid", "high"): + if cap and cap in ("minimal", "low", "medium", "high"): self._reasoning_level_cap = cap except Exception as e: logger.debug("Honcho cost-awareness config parse error: %s", e) @@ -251,9 +316,7 @@ class HonchoMemoryProvider(MemoryProvider): # ----- Port #1957: lazy session init for tools-only mode ----- if self._recall_mode == "tools": if cfg.init_on_session_start: - # Eager init: create session now so sync_turn() works from turn 1. - # Does NOT enable auto-injection — prefetch() still returns empty. - logger.debug("Honcho tools-only mode — eager session init (initOnSessionStart=true)") + # Eager init even in tools mode (opt-in) self._do_session_init(cfg, session_id, **kwargs) return # Defer actual session creation until first tool call @@ -287,8 +350,13 @@ class HonchoMemoryProvider(MemoryProvider): # ----- B3: resolve_session_name ----- session_title = kwargs.get("session_title") + gateway_session_key = kwargs.get("gateway_session_key") self._session_key = ( - cfg.resolve_session_name(session_title=session_title, session_id=session_id) + cfg.resolve_session_name( + session_title=session_title, + session_id=session_id, + gateway_session_key=gateway_session_key, + ) or session_id or "hermes-default" ) @@ -299,12 +367,21 @@ class HonchoMemoryProvider(MemoryProvider): self._session_initialized = True # ----- B6: Memory file migration (one-time, for new sessions) ----- + # Skip under per-session strategy: every Hermes run creates a fresh + # Honcho session by design, so uploading MEMORY.md/USER.md/SOUL.md to + # each one would flood the backend with short-lived duplicates instead + # of performing a one-time migration. try: - if not session.messages: + if not session.messages and cfg.session_strategy != "per-session": from hermes_constants import get_hermes_home mem_dir = str(get_hermes_home() / "memories") self._manager.migrate_memory_files(self._session_key, mem_dir) logger.debug("Honcho memory file migration attempted for new session: %s", self._session_key) + elif cfg.session_strategy == "per-session": + logger.debug( + "Honcho memory file migration skipped: per-session strategy creates a fresh session per run (%s)", + self._session_key, + ) except Exception as e: logger.debug("Honcho memory file migration skipped: %s", e) @@ -382,7 +459,7 @@ class HonchoMemoryProvider(MemoryProvider): return ( "# Honcho Memory\n" "Active (tools-only mode). Use honcho_profile, honcho_search, " - "honcho_context, and honcho_conclude tools to access user memory." + "honcho_reasoning, honcho_context, and honcho_conclude tools to access user memory." ) return "" @@ -412,7 +489,8 @@ class HonchoMemoryProvider(MemoryProvider): header = ( "# Honcho Memory\n" "Active (tools-only mode). Use honcho_profile for a quick factual snapshot, " - "honcho_search for raw excerpts, honcho_context for synthesized answers, " + "honcho_search for raw excerpts, honcho_context for raw peer context, " + "honcho_reasoning for synthesized answers, " "honcho_conclude to save facts about the user. " "No automatic context injection — you must use tools to access memory." ) @@ -421,7 +499,8 @@ class HonchoMemoryProvider(MemoryProvider): "# Honcho Memory\n" "Active (hybrid mode). Relevant context is auto-injected AND memory tools are available. " "Use honcho_profile for a quick factual snapshot, " - "honcho_search for raw excerpts, honcho_context for synthesized answers, " + "honcho_search for raw excerpts, honcho_context for raw peer context, " + "honcho_reasoning for synthesized answers, " "honcho_conclude to save facts about the user." ) @@ -458,7 +537,7 @@ class HonchoMemoryProvider(MemoryProvider): # ----- Port #3265: token budget enforcement ----- result = self._truncate_to_budget(result) - return f"## Honcho Context\n{result}" + return result def _truncate_to_budget(self, text: str) -> str: """Truncate text to fit within context_tokens budget if set.""" @@ -659,7 +738,14 @@ class HonchoMemoryProvider(MemoryProvider): try: if tool_name == "honcho_profile": - card = self._manager.get_peer_card(self._session_key) + peer = args.get("peer", "user") + card_update = args.get("card") + if card_update: + result = self._manager.set_peer_card(self._session_key, card_update, peer=peer) + if result is None: + return tool_error("Failed to update peer card.") + return json.dumps({"result": f"Peer card updated ({len(result)} facts).", "card": result}) + card = self._manager.get_peer_card(self._session_key, peer=peer) if not card: return json.dumps({"result": "No profile facts available yet."}) return json.dumps({"result": card}) @@ -669,30 +755,62 @@ class HonchoMemoryProvider(MemoryProvider): if not query: return tool_error("Missing required parameter: query") max_tokens = min(int(args.get("max_tokens", 800)), 2000) + peer = args.get("peer", "user") result = self._manager.search_context( - self._session_key, query, max_tokens=max_tokens + self._session_key, query, max_tokens=max_tokens, peer=peer ) if not result: return json.dumps({"result": "No relevant context found."}) return json.dumps({"result": result}) - elif tool_name == "honcho_context": + elif tool_name == "honcho_reasoning": query = args.get("query", "") if not query: return tool_error("Missing required parameter: query") peer = args.get("peer", "user") + reasoning_level = args.get("reasoning_level") result = self._manager.dialectic_query( - self._session_key, query, peer=peer + self._session_key, query, + reasoning_level=reasoning_level, + peer=peer, ) return json.dumps({"result": result or "No result from Honcho."}) + elif tool_name == "honcho_context": + peer = args.get("peer", "user") + ctx = self._manager.get_session_context(self._session_key, peer=peer) + if not ctx: + return json.dumps({"result": "No context available yet."}) + parts = [] + if ctx.get("summary"): + parts.append(f"## Summary\n{ctx['summary']}") + if ctx.get("representation"): + parts.append(f"## Representation\n{ctx['representation']}") + if ctx.get("card"): + parts.append(f"## Card\n{ctx['card']}") + if ctx.get("recent_messages"): + msgs = ctx["recent_messages"] + msg_str = "\n".join( + f" [{m['role']}] {m['content'][:200]}" + for m in msgs[-5:] # last 5 for brevity + ) + parts.append(f"## Recent messages\n{msg_str}") + return json.dumps({"result": "\n\n".join(parts) or "No context available."}) + elif tool_name == "honcho_conclude": + delete_id = args.get("delete_id") + peer = args.get("peer", "user") + if delete_id: + ok = self._manager.delete_conclusion(self._session_key, delete_id, peer=peer) + if ok: + return json.dumps({"result": f"Conclusion {delete_id} deleted."}) + return tool_error(f"Failed to delete conclusion {delete_id}.") conclusion = args.get("conclusion", "") if not conclusion: - return tool_error("Missing required parameter: conclusion") - ok = self._manager.create_conclusion(self._session_key, conclusion) + return tool_error("Missing required parameter: conclusion or delete_id") + ok = self._manager.create_conclusion(self._session_key, conclusion, peer=peer) if ok: - return json.dumps({"result": f"Conclusion saved: {conclusion}"}) + return json.dumps({"result": f"Conclusion saved for {peer}: {conclusion}"}) return tool_error("Failed to save conclusion.") return tool_error(f"Unknown tool: {tool_name}") diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index dff4b386a5..536d34002d 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -440,11 +440,43 @@ def cmd_setup(args) -> None: if new_recall in ("hybrid", "context", "tools"): hermes_host["recallMode"] = new_recall - # --- 7. Session strategy --- - current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-directory") + # --- 7. Context token budget --- + current_ctx_tokens = hermes_host.get("contextTokens") or cfg.get("contextTokens") + current_display = str(current_ctx_tokens) if current_ctx_tokens else "uncapped" + print("\n Context injection per turn (hybrid/context recall modes only):") + print(" uncapped -- no limit (default)") + print(" N -- token limit per turn (e.g. 1200)") + new_ctx_tokens = _prompt("Context tokens", default=current_display) + if new_ctx_tokens.strip().lower() in ("none", "uncapped", "no limit"): + hermes_host.pop("contextTokens", None) + elif new_ctx_tokens.strip() == "": + pass # keep current + else: + try: + val = int(new_ctx_tokens) + if val >= 0: + hermes_host["contextTokens"] = val + except (ValueError, TypeError): + pass # keep current + + # --- 7b. Dialectic cadence --- + current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "3") + print("\n Dialectic cadence:") + print(" How often Honcho rebuilds its user model (LLM call on Honcho backend).") + print(" 1 = every turn (aggressive), 3 = every 3 turns (recommended), 5+ = sparse.") + new_dialectic = _prompt("Dialectic cadence", default=current_dialectic) + try: + val = int(new_dialectic) + if val >= 1: + hermes_host["dialecticCadence"] = val + except (ValueError, TypeError): + hermes_host["dialecticCadence"] = 3 + + # --- 8. Session strategy --- + current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session") print("\n Session strategy:") - print(" per-directory -- one session per working directory (default)") - print(" per-session -- new Honcho session each run") + print(" per-session -- each run starts clean, Honcho injects context automatically") + print(" per-directory -- reuses session per dir, prior context auto-injected 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) @@ -490,10 +522,11 @@ def cmd_setup(args) -> None: print(f" Recall: {hcfg.recall_mode}") print(f" Sessions: {hcfg.session_strategy}") print("\n Honcho tools available in chat:") - 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(" honcho_context -- session context: summary, representation, card, messages") + print(" honcho_search -- semantic search over history") + print(" honcho_profile -- peer card, key facts") + print(" honcho_reasoning -- ask Honcho a question, synthesized answer") + print(" honcho_conclude -- persist a user fact to memory") print("\n Other commands:") print(" hermes honcho status -- show full config") print(" hermes honcho mode -- change recall/observation mode") @@ -585,13 +618,26 @@ def cmd_status(args) -> None: print(f" Enabled: {hcfg.enabled}") print(f" API key: {masked}") print(f" Workspace: {hcfg.workspace_id}") - print(f" Config path: {active_path}") + + # Config paths — show where config was read from and where writes go + global_path = Path.home() / ".honcho" / "config.json" + print(f" Config: {active_path}") if write_path != active_path: - print(f" Write path: {write_path} (instance-local)") + print(f" Write to: {write_path} (profile-local)") + if active_path == global_path: + print(f" Fallback: (none — using global ~/.honcho/config.json)") + elif global_path.exists(): + print(f" Fallback: {global_path} (exists, cross-app interop)") + print(f" AI peer: {hcfg.ai_peer}") print(f" User peer: {hcfg.peer_name or 'not set'}") print(f" Session key: {hcfg.resolve_session_name()}") + print(f" Session strat: {hcfg.session_strategy}") print(f" Recall mode: {hcfg.recall_mode}") + print(f" Context budget: {hcfg.context_tokens or '(uncapped)'} tokens") + raw = getattr(hcfg, "raw", None) or {} + dialectic_cadence = raw.get("dialecticCadence") or 3 + print(f" Dialectic cad: every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}") 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}") @@ -599,8 +645,8 @@ def cmd_status(args) -> None: print("\n Connection... ", end="", flush=True) try: client = get_honcho_client(hcfg) - print("OK") _show_peer_cards(hcfg, client) + print("OK") except Exception as e: print(f"FAILED ({e})\n") else: @@ -824,6 +870,41 @@ def cmd_mode(args) -> None: print(f" {label}Recall mode -> {mode_arg} ({MODES[mode_arg]})\n") +def cmd_strategy(args) -> None: + """Show or set the session strategy.""" + STRATEGIES = { + "per-session": "each run starts clean, Honcho injects context automatically", + "per-directory": "reuses session per dir, prior context auto-injected each run", + "per-repo": "one session per git repository", + "global": "single session across all directories", + } + cfg = _read_config() + strat_arg = getattr(args, "strategy", None) + + if strat_arg is None: + current = ( + (cfg.get("hosts") or {}).get(_host_key(), {}).get("sessionStrategy") + or cfg.get("sessionStrategy") + or "per-session" + ) + print("\nHoncho session strategy\n" + "─" * 40) + for s, desc in STRATEGIES.items(): + marker = " <-" if s == current else "" + print(f" {s:<15} {desc}{marker}") + print(f"\n Set with: hermes honcho strategy [per-session|per-directory|per-repo|global]\n") + return + + if strat_arg not in STRATEGIES: + print(f" Invalid strategy '{strat_arg}'. Options: {', '.join(STRATEGIES)}\n") + return + + host = _host_key() + label = f"[{host}] " if host != "hermes" else "" + cfg.setdefault("hosts", {}).setdefault(host, {})["sessionStrategy"] = strat_arg + _write_config(cfg) + print(f" {label}Session strategy -> {strat_arg} ({STRATEGIES[strat_arg]})\n") + + def cmd_tokens(args) -> None: """Show or set token budget settings.""" cfg = _read_config() @@ -1143,10 +1224,11 @@ def cmd_migrate(args) -> None: print(" automatically. Files become the seed, not the live store.") print() print(" Honcho tools (available to the agent during conversation)") - print(" honcho_context — ask Honcho a question, get a synthesized answer (LLM)") - print(" honcho_search — semantic search over stored context (no LLM)") - print(" honcho_profile — fast peer card snapshot (no LLM)") - print(" honcho_conclude — write a conclusion/fact back to memory (no LLM)") + print(" honcho_context — session context: summary, representation, card, messages") + print(" honcho_search — semantic search over stored context") + print(" honcho_profile — fast peer card snapshot") + print(" honcho_reasoning — ask Honcho a question, synthesized answer") + print(" honcho_conclude — write a conclusion/fact back to memory") print() print(" Session naming") print(" OpenClaw: no persistent session concept — files are global.") @@ -1197,6 +1279,8 @@ def honcho_command(args) -> None: cmd_peer(args) elif sub == "mode": cmd_mode(args) + elif sub == "strategy": + cmd_strategy(args) elif sub == "tokens": cmd_tokens(args) elif sub == "identity": @@ -1211,7 +1295,7 @@ def honcho_command(args) -> None: cmd_sync(args) else: print(f" Unknown honcho command: {sub}") - print(" Available: status, sessions, map, peer, mode, tokens, identity, migrate, enable, disable, sync\n") + print(" Available: status, sessions, map, peer, mode, strategy, tokens, identity, migrate, enable, disable, sync\n") def register_cli(subparser) -> None: @@ -1270,6 +1354,15 @@ def register_cli(subparser) -> None: help="Recall mode to set (hybrid/context/tools). Omit to show current.", ) + strategy_parser = subs.add_parser( + "strategy", help="Show or set session strategy (per-session/per-directory/per-repo/global)", + ) + strategy_parser.add_argument( + "strategy", nargs="?", metavar="STRATEGY", + choices=("per-session", "per-directory", "per-repo", "global"), + help="Session strategy to set. Omit to show current.", + ) + tokens_parser = subs.add_parser( "tokens", help="Show or set token budget for context and dialectic", ) diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 3c779f64fe..6af7bfeb18 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -94,6 +94,35 @@ def _resolve_bool(host_val, root_val, *, default: bool) -> bool: return default +def _parse_context_tokens(host_val, root_val) -> int | None: + """Parse contextTokens: host wins, then root, then None (uncapped).""" + for val in (host_val, root_val): + if val is not None: + try: + return int(val) + except (ValueError, TypeError): + pass + return None + + +def _resolve_optional_float(*values: Any) -> float | None: + """Return the first non-empty value coerced to a positive float.""" + for value in values: + if value is None: + continue + if isinstance(value, str): + value = value.strip() + if not value: + continue + try: + parsed = float(value) + except (TypeError, ValueError): + continue + if parsed > 0: + return parsed + return None + + _VALID_OBSERVATION_MODES = {"unified", "directional"} _OBSERVATION_MODE_ALIASES = {"shared": "unified", "separate": "directional", "cross": "directional"} @@ -159,6 +188,8 @@ class HonchoClientConfig: environment: str = "production" # Optional base URL for self-hosted Honcho (overrides environment mapping) base_url: str | None = None + # Optional request timeout in seconds for Honcho SDK HTTP calls + timeout: float | None = None # Identity peer_name: str | None = None ai_peer: str = "hermes" @@ -168,14 +199,14 @@ class HonchoClientConfig: # Write frequency: "async" (background thread), "turn" (sync per turn), # "session" (flush on session end), or int (every N turns) write_frequency: str | int = "async" - # Prefetch budget + # Prefetch budget (None = no cap; set to an integer to bound auto-injected context) context_tokens: int | None = None # Dialectic (peer.chat) settings # reasoning_level: "minimal" | "low" | "medium" | "high" | "max" 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 + # When true, the model can override reasoning_level per-call via the + # honcho_reasoning tool param (agentic). When false, always uses + # dialecticReasoningLevel and ignores model-provided overrides. dialectic_dynamic: bool = True # Max chars of dialectic result to inject into Hermes system prompt dialectic_max_chars: int = 600 @@ -189,10 +220,8 @@ class HonchoClientConfig: # "context" — auto-injected context only, Honcho tools removed # "tools" — Honcho tools only, no auto-injected context recall_mode: str = "hybrid" - # When True and recallMode is "tools", create the Honcho session eagerly - # during initialize() instead of deferring to the first tool call. - # This ensures sync_turn() can write from the very first turn. - # Does NOT enable automatic context injection — only changes init timing. + # Eager init in tools mode — when true, initializes session during + # initialize() instead of deferring to first tool call init_on_session_start: bool = False # Observation mode: legacy string shorthand ("directional" or "unified"). # Kept for backward compat; granular per-peer booleans below are preferred. @@ -224,12 +253,14 @@ class HonchoClientConfig: resolved_host = host or resolve_active_host() api_key = os.environ.get("HONCHO_API_KEY") base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None + timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT")) return cls( host=resolved_host, workspace_id=workspace_id, api_key=api_key, environment=os.environ.get("HONCHO_ENVIRONMENT", "production"), base_url=base_url, + timeout=timeout, ai_peer=resolved_host, enabled=bool(api_key or base_url), ) @@ -290,6 +321,11 @@ class HonchoClientConfig: or os.environ.get("HONCHO_BASE_URL", "").strip() or None ) + timeout = _resolve_optional_float( + raw.get("timeout"), + raw.get("requestTimeout"), + os.environ.get("HONCHO_TIMEOUT"), + ) # Auto-enable when API key or base_url is present (unless explicitly disabled) # Host-level enabled wins, then root-level, then auto-enable if key/url exists. @@ -335,17 +371,22 @@ class HonchoClientConfig: api_key=api_key, environment=environment, base_url=base_url, + timeout=timeout, peer_name=host_block.get("peerName") or raw.get("peerName"), ai_peer=ai_peer, enabled=enabled, save_messages=save_messages, write_frequency=write_frequency, - context_tokens=host_block.get("contextTokens") or raw.get("contextTokens"), + context_tokens=_parse_context_tokens( + host_block.get("contextTokens"), + raw.get("contextTokens"), + ), dialectic_reasoning_level=( host_block.get("dialecticReasoningLevel") or raw.get("dialecticReasoningLevel") or "low" ), + dialectic_dynamic=_resolve_bool( host_block.get("dialecticDynamic"), raw.get("dialecticDynamic"), @@ -422,16 +463,18 @@ class HonchoClientConfig: cwd: str | None = None, session_title: str | None = None, session_id: str | None = None, + gateway_session_key: str | None = None, ) -> str | None: """Resolve Honcho session name. Resolution order: 1. Manual directory override from sessions map 2. Hermes session title (from /title command) - 3. per-session strategy — Hermes session_id ({timestamp}_{hex}) - 4. per-repo strategy — git repo root directory name - 5. per-directory strategy — directory basename - 6. global strategy — workspace name + 3. Gateway session key (stable per-chat identifier from gateway platforms) + 4. per-session strategy — Hermes session_id ({timestamp}_{hex}) + 5. per-repo strategy — git repo root directory name + 6. per-directory strategy — directory basename + 7. global strategy — workspace name """ import re @@ -451,6 +494,16 @@ class HonchoClientConfig: return f"{self.peer_name}-{sanitized}" return sanitized + # Gateway session key: stable per-chat identifier passed by the gateway + # (e.g. "agent:main:telegram:dm:8439114563"). Sanitize colons to hyphens + # for Honcho session ID compatibility. This takes priority over strategy- + # based resolution because gateway platforms need per-chat isolation that + # cwd-based strategies cannot provide. + if gateway_session_key: + sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', gateway_session_key).strip('-') + if sanitized: + return sanitized + # per-session: inherit Hermes session_id (new Honcho session each run) if self.session_strategy == "per-session" and session_id: if self.session_peer_prefix and self.peer_name: @@ -512,13 +565,20 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: # mapping, enabling remote self-hosted Honcho deployments without # requiring the server to live on localhost. resolved_base_url = config.base_url - if not resolved_base_url: + resolved_timeout = config.timeout + if not resolved_base_url or resolved_timeout is None: try: from hermes_cli.config import load_config hermes_cfg = load_config() honcho_cfg = hermes_cfg.get("honcho", {}) if isinstance(honcho_cfg, dict): - resolved_base_url = honcho_cfg.get("base_url", "").strip() or None + if not resolved_base_url: + resolved_base_url = honcho_cfg.get("base_url", "").strip() or None + if resolved_timeout is None: + resolved_timeout = _resolve_optional_float( + honcho_cfg.get("timeout"), + honcho_cfg.get("request_timeout"), + ) except Exception: pass @@ -553,6 +613,8 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: } if resolved_base_url: kwargs["base_url"] = resolved_base_url + if resolved_timeout is not None: + kwargs["timeout"] = resolved_timeout _honcho_client = Honcho(**kwargs) diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index 2cd4c5bd2f..2f3afef310 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -486,36 +486,9 @@ class HonchoSessionManager: _REASONING_LEVELS = ("minimal", "low", "medium", "high", "max") - def _dynamic_reasoning_level(self, query: str) -> str: - """ - Pick a reasoning level for a dialectic query. - - When dialecticDynamic is true (default), auto-bumps based on query - length so Honcho applies more inference where it matters: - - < 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. - - 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) - if n < 120: - bump = 0 - elif n < 400: - bump = 1 - else: - bump = 2 - # Cap at "high" (index 3) for auto-selection - idx = min(default_idx + bump, 3) - return levels[idx] + def _default_reasoning_level(self) -> str: + """Return the configured default reasoning level.""" + return self._dialectic_reasoning_level def dialectic_query( self, session_key: str, query: str, @@ -532,8 +505,9 @@ class HonchoSessionManager: Args: session_key: The session key to query against. query: Natural language question. - reasoning_level: Override the config default. If None, uses - _dynamic_reasoning_level(query). + reasoning_level: Override the configured default (dialecticReasoningLevel). + Only honored when dialecticDynamic is true. + If None or dialecticDynamic is false, uses the configured default. peer: Which peer to query — "user" (default) or "ai". Returns: @@ -543,29 +517,34 @@ class HonchoSessionManager: if not session: return "" + target_peer_id = self._resolve_peer_id(session, peer) + if target_peer_id is None: + 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) + if self._dialectic_dynamic and reasoning_level: + level = reasoning_level + else: + level = self._default_reasoning_level() try: 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) + # AI peer can observe other peers — use assistant as observer. + ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id) + if target_peer_id == session.assistant_peer_id: result = ai_peer_obj.chat(query, reasoning_level=level) or "" else: - ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id) result = ai_peer_obj.chat( query, - target=session.user_peer_id, + target=target_peer_id, reasoning_level=level, ) or "" else: - # 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) + # Without cross-observation, each peer queries its own context. + target_peer = self._get_or_create_peer(target_peer_id) result = target_peer.chat(query, reasoning_level=level) or "" # Apply Hermes-side char cap before caching @@ -666,7 +645,7 @@ class HonchoSessionManager: result: dict[str, str] = {} try: - user_ctx = self._fetch_peer_context(session.user_peer_id) + user_ctx = self._fetch_peer_context(session.user_peer_id, target=session.user_peer_id) result["representation"] = user_ctx["representation"] result["card"] = "\n".join(user_ctx["card"]) except Exception as e: @@ -674,7 +653,7 @@ class HonchoSessionManager: # Also fetch AI peer's own representation so Hermes knows itself. try: - ai_ctx = self._fetch_peer_context(session.assistant_peer_id) + ai_ctx = self._fetch_peer_context(session.assistant_peer_id, target=session.assistant_peer_id) result["ai_representation"] = ai_ctx["representation"] result["ai_card"] = "\n".join(ai_ctx["card"]) except Exception as e: @@ -862,7 +841,7 @@ class HonchoSessionManager: return [str(item) for item in card if item] return [str(card)] - def _fetch_peer_card(self, peer_id: str) -> list[str]: + def _fetch_peer_card(self, peer_id: str, *, target: str | None = None) -> list[str]: """Fetch a peer card directly from the peer object. This avoids relying on session.context(), which can return an empty @@ -872,22 +851,33 @@ class HonchoSessionManager: peer = self._get_or_create_peer(peer_id) getter = getattr(peer, "get_card", None) if callable(getter): - return self._normalize_card(getter()) + return self._normalize_card(getter(target=target) if target is not None else getter()) legacy_getter = getattr(peer, "card", None) if callable(legacy_getter): - return self._normalize_card(legacy_getter()) + return self._normalize_card(legacy_getter(target=target) if target is not None else legacy_getter()) return [] - def _fetch_peer_context(self, peer_id: str, search_query: str | None = None) -> dict[str, Any]: + def _fetch_peer_context( + self, + peer_id: str, + search_query: str | None = None, + *, + target: 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() + context_kwargs: dict[str, Any] = {} + if target is not None: + context_kwargs["target"] = target + if search_query is not None: + context_kwargs["search_query"] = search_query + ctx = peer.context(**context_kwargs) if context_kwargs else peer.context() representation = ( getattr(ctx, "representation", None) or getattr(ctx, "peer_representation", None) @@ -899,24 +889,106 @@ class HonchoSessionManager: if not representation: try: - representation = peer.representation() or "" + representation = ( + peer.representation(target=target) if target is not None else 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) + card = self._fetch_peer_card(peer_id, target=target) 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]: + def get_session_context(self, session_key: str, peer: str = "user") -> dict[str, Any]: + """Fetch full session context from Honcho including summary. + + Uses the session-level context() API which returns summary, + peer_representation, peer_card, and messages. """ - Fetch the user peer's card — a curated list of key facts. + session = self._cache.get(session_key) + if not session: + return {} + + honcho_session = self._sessions_cache.get(session.honcho_session_id) + if not honcho_session: + # Fall back to peer-level context + return self._fetch_peer_context(session.user_peer_id, target=session.user_peer_id) + + try: + peer_id = self._resolve_peer_id(session, peer) + ctx = honcho_session.context( + summary=True, + peer_target=peer_id, + peer_perspective=session.user_peer_id if peer == "user" else session.assistant_peer_id, + ) + + result: dict[str, Any] = {} + + # Summary + if ctx.summary: + result["summary"] = ctx.summary.content + + # Peer representation and card + if ctx.peer_representation: + result["representation"] = ctx.peer_representation + if ctx.peer_card: + result["card"] = "\n".join(ctx.peer_card) + + # Messages (last N for context) + if ctx.messages: + recent = ctx.messages[-10:] # last 10 messages + result["recent_messages"] = [ + {"role": m.role, "content": m.content[:500]} + for m in recent + ] + + return result + except Exception as e: + logger.warning("Session context fetch failed: %s", e) + return {} + + def _resolve_peer_id(self, session: HonchoSession, peer: str | None) -> str | None: + """Resolve a peer alias or explicit peer ID to a concrete Honcho peer ID.""" + candidate = (peer or "user").strip() + if not candidate: + return session.user_peer_id + + normalized = self._sanitize_id(candidate) + if normalized == self._sanitize_id("user"): + return session.user_peer_id + if normalized == self._sanitize_id("ai"): + return session.assistant_peer_id + + return normalized + + def _resolve_observer_target( + self, + session: HonchoSession, + peer: str | None, + ) -> tuple[str, str | None]: + """Resolve observer and target peer IDs for context/search/profile queries.""" + target_peer_id = self._resolve_peer_id(session, peer) + if target_peer_id is None: + return session.user_peer_id, None + + if target_peer_id == session.assistant_peer_id: + return session.assistant_peer_id, session.assistant_peer_id + + if self._ai_observe_others: + return session.assistant_peer_id, target_peer_id + + return target_peer_id, None + + def get_peer_card(self, session_key: str, peer: str = "user") -> list[str]: + """ + Fetch a peer card — a curated list of key facts. Fast, no LLM reasoning. Returns raw structured facts Honcho has - inferred about the user (name, role, preferences, patterns). + inferred about the target peer (name, role, preferences, patterns). Empty list if unavailable. """ session = self._cache.get(session_key) @@ -924,12 +996,19 @@ class HonchoSessionManager: return [] try: - return self._fetch_peer_card(session.user_peer_id) + observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer) + return self._fetch_peer_card(observer_peer_id, target=target_peer_id) except Exception as e: logger.debug("Failed to fetch peer card from Honcho: %s", e) return [] - def search_context(self, session_key: str, query: str, max_tokens: int = 800) -> str: + def search_context( + self, + session_key: str, + query: str, + max_tokens: int = 800, + peer: str = "user", + ) -> str: """ Semantic search over Honcho session context. @@ -941,6 +1020,7 @@ class HonchoSessionManager: session_key: Session to search against. query: Search query for semantic matching. max_tokens: Token budget for returned content. + peer: Peer alias or explicit peer ID to search about. Returns: Relevant context excerpts as a string, or empty string if none. @@ -950,7 +1030,13 @@ class HonchoSessionManager: return "" try: - ctx = self._fetch_peer_context(session.user_peer_id, search_query=query) + observer_peer_id, target = self._resolve_observer_target(session, peer) + + ctx = self._fetch_peer_context( + observer_peer_id, + search_query=query, + target=target, + ) parts = [] if ctx["representation"]: parts.append(ctx["representation"]) @@ -962,16 +1048,17 @@ class HonchoSessionManager: logger.debug("Honcho search_context failed: %s", e) return "" - def create_conclusion(self, session_key: str, content: str) -> bool: - """Write a conclusion about the user back to Honcho. + def create_conclusion(self, session_key: str, content: str, peer: str = "user") -> bool: + """Write a conclusion about a target peer back to Honcho. - Conclusions are facts the AI peer observes about the user — - preferences, corrections, clarifications, project context. - They feed into the user's peer card and representation. + Conclusions are facts a peer observes about another peer or itself — + preferences, corrections, clarifications, and project context. + They feed into the target peer's card and representation. Args: session_key: Session to associate the conclusion with. - content: The conclusion text (e.g. "User prefers dark mode"). + content: The conclusion text. + peer: Peer alias or explicit peer ID. "user" is the default alias. Returns: True on success, False on failure. @@ -985,25 +1072,87 @@ class HonchoSessionManager: return False try: - if self._ai_observe_others: - # AI peer creates conclusion about user (cross-observation) + target_peer_id = self._resolve_peer_id(session, peer) + if target_peer_id is None: + logger.warning("Could not resolve conclusion peer '%s' for session '%s'", peer, session_key) + return False + + if target_peer_id == session.assistant_peer_id: assistant_peer = self._get_or_create_peer(session.assistant_peer_id) - conclusions_scope = assistant_peer.conclusions_of(session.user_peer_id) + conclusions_scope = assistant_peer.conclusions_of(session.assistant_peer_id) + elif self._ai_observe_others: + assistant_peer = self._get_or_create_peer(session.assistant_peer_id) + conclusions_scope = assistant_peer.conclusions_of(target_peer_id) else: - # 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) + target_peer = self._get_or_create_peer(target_peer_id) + conclusions_scope = target_peer.conclusions_of(target_peer_id) conclusions_scope.create([{ "content": content.strip(), "session_id": session.honcho_session_id, }]) - logger.info("Created conclusion for %s: %s", session_key, content[:80]) + logger.info("Created conclusion about %s for %s: %s", target_peer_id, session_key, content[:80]) return True except Exception as e: logger.error("Failed to create conclusion: %s", e) return False + def delete_conclusion(self, session_key: str, conclusion_id: str, peer: str = "user") -> bool: + """Delete a conclusion by ID. Use only for PII removal. + + Args: + session_key: Session key for peer resolution. + conclusion_id: The conclusion ID to delete. + peer: Peer alias or explicit peer ID. + + Returns: + True on success, False on failure. + """ + session = self._cache.get(session_key) + if not session: + return False + try: + target_peer_id = self._resolve_peer_id(session, peer) + if target_peer_id == session.assistant_peer_id: + observer = self._get_or_create_peer(session.assistant_peer_id) + scope = observer.conclusions_of(session.assistant_peer_id) + elif self._ai_observe_others: + observer = self._get_or_create_peer(session.assistant_peer_id) + scope = observer.conclusions_of(target_peer_id) + else: + target_peer = self._get_or_create_peer(target_peer_id) + scope = target_peer.conclusions_of(target_peer_id) + scope.delete(conclusion_id) + logger.info("Deleted conclusion %s for %s", conclusion_id, session_key) + return True + except Exception as e: + logger.error("Failed to delete conclusion %s: %s", conclusion_id, e) + return False + + def set_peer_card(self, session_key: str, card: list[str], peer: str = "user") -> list[str] | None: + """Update a peer's card. + + Args: + session_key: Session key for peer resolution. + card: New peer card as list of fact strings. + peer: Peer alias or explicit peer ID. + + Returns: + Updated card on success, None on failure. + """ + session = self._cache.get(session_key) + if not session: + return None + try: + peer_id = self._resolve_peer_id(session, peer) + peer_obj = self._get_or_create_peer(peer_id) + result = peer_obj.set_card(card) + logger.info("Updated peer card for %s (%d facts)", peer_id, len(card)) + return result + except Exception as e: + logger.error("Failed to set peer card: %s", e) + return None + def seed_ai_identity(self, session_key: str, content: str, source: str = "manual") -> bool: """ Seed the AI peer's Honcho representation from text content. @@ -1061,7 +1210,7 @@ class HonchoSessionManager: return {"representation": "", "card": ""} try: - ctx = self._fetch_peer_context(session.assistant_peer_id) + ctx = self._fetch_peer_context(session.assistant_peer_id, target=session.assistant_peer_id) return { "representation": ctx["representation"] or "", "card": "\n".join(ctx["card"]), diff --git a/run_agent.py b/run_agent.py index 626951b276..1f51bf9c21 100644 --- a/run_agent.py +++ b/run_agent.py @@ -593,6 +593,7 @@ class AIAgent: prefill_messages: List[Dict[str, Any]] = None, platform: str = None, user_id: str = None, + gateway_session_key: str = None, skip_context_files: bool = False, skip_memory: bool = False, session_db=None, @@ -658,6 +659,7 @@ class AIAgent: self.ephemeral_system_prompt = ephemeral_system_prompt self.platform = platform # "cli", "telegram", "discord", "whatsapp", etc. self._user_id = user_id # Platform user identifier (gateway sessions) + self._gateway_session_key = gateway_session_key # Stable per-chat key (e.g. agent:main:telegram:dm:123) # Pluggable print function — CLI replaces this with _cprint so that # raw ANSI status lines are routed through prompt_toolkit's renderer # instead of going directly to stdout where patch_stdout's StdoutProxy @@ -1206,6 +1208,9 @@ class AIAgent: # Thread gateway user identity for per-user memory scoping if self._user_id: _init_kwargs["user_id"] = self._user_id + # Thread gateway session key for stable per-chat Honcho session isolation + if self._gateway_session_key: + _init_kwargs["gateway_session_key"] = self._gateway_session_key # Profile identity for per-profile provider scoping try: from hermes_cli.profiles import get_active_profile_name diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py new file mode 100644 index 0000000000..006d687dc1 --- /dev/null +++ b/tests/honcho_plugin/test_cli.py @@ -0,0 +1,56 @@ +"""Tests for plugins/memory/honcho/cli.py.""" + +from types import SimpleNamespace + + +class TestCmdStatus: + def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path): + import plugins.memory.honcho.cli as honcho_cli + + cfg_path = tmp_path / "honcho.json" + cfg_path.write_text("{}") + + class FakeConfig: + enabled = True + api_key = "root-key" + workspace_id = "hermes" + host = "hermes" + base_url = None + ai_peer = "hermes" + peer_name = "eri" + recall_mode = "hybrid" + user_observe_me = True + user_observe_others = False + ai_observe_me = False + ai_observe_others = True + write_frequency = "async" + session_strategy = "per-session" + context_tokens = 800 + + def resolve_session_name(self): + return "hermes" + + monkeypatch.setattr(honcho_cli, "_read_config", lambda: {"apiKey": "***"}) + monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_active_profile_name", lambda: "default") + monkeypatch.setattr( + "plugins.memory.honcho.client.HonchoClientConfig.from_global_config", + lambda host=None: FakeConfig(), + ) + monkeypatch.setattr( + "plugins.memory.honcho.client.get_honcho_client", + lambda cfg: object(), + ) + + def _boom(hcfg, client): + raise RuntimeError("Invalid API key") + + monkeypatch.setattr(honcho_cli, "_show_peer_cards", _boom) + monkeypatch.setitem(__import__("sys").modules, "honcho", SimpleNamespace()) + + honcho_cli.cmd_status(SimpleNamespace(all=False)) + + out = capsys.readouterr().out + assert "FAILED (Invalid API key)" in out + assert "Connection... OK" not in out \ No newline at end of file diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index cfb89482d0..7c653a5d29 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -25,6 +25,7 @@ class TestHonchoClientConfigDefaults: assert config.workspace_id == "hermes" assert config.api_key is None assert config.environment == "production" + assert config.timeout is None assert config.enabled is False assert config.save_messages is True assert config.session_strategy == "per-directory" @@ -76,6 +77,11 @@ class TestFromEnv: assert config.base_url == "http://localhost:8000" assert config.enabled is True + def test_reads_timeout_from_env(self): + with patch.dict(os.environ, {"HONCHO_TIMEOUT": "90"}, clear=True): + config = HonchoClientConfig.from_env() + assert config.timeout == 90.0 + class TestFromGlobalConfig: def test_missing_config_falls_back_to_env(self, tmp_path): @@ -87,10 +93,10 @@ class TestFromGlobalConfig: assert config.enabled is False assert config.api_key is None - def test_reads_full_config(self, tmp_path): + def test_reads_full_config(self, tmp_path, monkeypatch): config_file = tmp_path / "config.json" config_file.write_text(json.dumps({ - "apiKey": "my-honcho-key", + "apiKey": "***", "workspace": "my-workspace", "environment": "staging", "peerName": "alice", @@ -108,9 +114,11 @@ class TestFromGlobalConfig: } } })) + # Isolate from real ~/.hermes/honcho.json + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated")) config = HonchoClientConfig.from_global_config(config_path=config_file) - assert config.api_key == "my-honcho-key" + assert config.api_key == "***" # Host block workspace overrides root workspace assert config.workspace_id == "override-ws" assert config.ai_peer == "override-ai" @@ -154,10 +162,31 @@ class TestFromGlobalConfig: def test_session_strategy_default_from_global_config(self, tmp_path): """from_global_config with no sessionStrategy should match dataclass default.""" config_file = tmp_path / "config.json" - config_file.write_text(json.dumps({"apiKey": "key"})) + config_file.write_text(json.dumps({"apiKey": "***"})) config = HonchoClientConfig.from_global_config(config_path=config_file) assert config.session_strategy == "per-directory" + def test_context_tokens_default_is_none(self, tmp_path): + """Default context_tokens should be None (uncapped) unless explicitly set.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***"})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.context_tokens is None + + def test_context_tokens_explicit_sets_cap(self, tmp_path): + """Explicit contextTokens in config sets the cap.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***", "contextTokens": 1200})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.context_tokens == 1200 + + def test_context_tokens_explicit_overrides_default(self, tmp_path): + """Explicit contextTokens in config should override the default.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***", "contextTokens": 2000})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.context_tokens == 2000 + def test_context_tokens_host_block_wins(self, tmp_path): """Host block contextTokens should override root.""" config_file = tmp_path / "config.json" @@ -232,6 +261,20 @@ class TestFromGlobalConfig: config = HonchoClientConfig.from_global_config(config_path=config_file) assert config.base_url == "http://root:9000" + def test_timeout_from_config_root(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"timeout": 75})) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.timeout == 75.0 + + def test_request_timeout_alias_from_config_root(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"requestTimeout": "82.5"})) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.timeout == 82.5 + class TestResolveSessionName: def test_manual_override(self): @@ -333,13 +376,14 @@ class TestResolveConfigPath: hermes_home.mkdir() local_cfg = hermes_home / "honcho.json" local_cfg.write_text(json.dumps({ - "apiKey": "local-key", + "apiKey": "***", "workspace": "local-ws", })) - 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=tmp_path): config = HonchoClientConfig.from_global_config() - assert config.api_key == "local-key" + assert config.api_key == "***" assert config.workspace_id == "local-ws" @@ -500,46 +544,103 @@ class TestObservationModeMigration: assert cfg.ai_observe_others is True -class TestInitOnSessionStart: - """Tests for the initOnSessionStart config field.""" +class TestGetHonchoClient: + def teardown_method(self): + reset_honcho_client() - def test_default_is_false(self): + def test_passes_timeout_from_config(self): + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key="test-key", + timeout=91.0, + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho: + client = get_honcho_client(cfg) + + assert client is fake_honcho + mock_honcho.assert_called_once() + assert mock_honcho.call_args.kwargs["timeout"] == 91.0 + + def test_hermes_config_timeout_override_used_when_config_timeout_missing(self): + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key="test-key", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={"honcho": {"timeout": 88}}): + client = get_honcho_client(cfg) + + assert client is fake_honcho + mock_honcho.assert_called_once() + assert mock_honcho.call_args.kwargs["timeout"] == 88.0 + + def test_hermes_request_timeout_alias_used(self): + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key="test-key", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={"honcho": {"request_timeout": "77.5"}}): + client = get_honcho_client(cfg) + + assert client is fake_honcho + mock_honcho.assert_called_once() + assert mock_honcho.call_args.kwargs["timeout"] == 77.5 + + +class TestResolveSessionNameGatewayKey: + """Regression tests for gateway_session_key priority in resolve_session_name. + + Ensures gateway platforms get stable per-chat Honcho sessions even when + sessionStrategy=per-session would otherwise create ephemeral sessions. + Regression: plugin refactor 924bc67e dropped gateway key plumbing. + """ + + def test_gateway_key_overrides_per_session_strategy(self): + """gateway_session_key must win over per-session session_id.""" + config = HonchoClientConfig(session_strategy="per-session") + result = config.resolve_session_name( + session_id="20260412_171002_69bb38", + gateway_session_key="agent:main:telegram:dm:8439114563", + ) + assert result == "agent-main-telegram-dm-8439114563" + + def test_session_title_still_wins_over_gateway_key(self): + """Explicit /title remap takes priority over gateway_session_key.""" + config = HonchoClientConfig(session_strategy="per-session") + result = config.resolve_session_name( + session_title="my-custom-title", + session_id="20260412_171002_69bb38", + gateway_session_key="agent:main:telegram:dm:8439114563", + ) + assert result == "my-custom-title" + + def test_per_session_fallback_without_gateway_key(self): + """Without gateway_session_key, per-session returns session_id (CLI path).""" + config = HonchoClientConfig(session_strategy="per-session") + result = config.resolve_session_name( + session_id="20260412_171002_69bb38", + gateway_session_key=None, + ) + assert result == "20260412_171002_69bb38" + + def test_gateway_key_sanitizes_special_chars(self): + """Colons and other non-alphanumeric chars are replaced with hyphens.""" config = HonchoClientConfig() - assert config.init_on_session_start is False - - def test_root_level_true(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({ - "apiKey": "k", - "initOnSessionStart": True, - })) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.init_on_session_start is True - - def test_host_block_overrides_root(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({ - "apiKey": "k", - "initOnSessionStart": True, - "hosts": {"hermes": {"initOnSessionStart": False}}, - })) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.init_on_session_start is False - - def test_host_block_true_overrides_root_absent(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({ - "apiKey": "k", - "hosts": {"hermes": {"initOnSessionStart": True}}, - })) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.init_on_session_start is True - - def test_absent_everywhere_defaults_false(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.init_on_session_start is False + result = config.resolve_session_name( + gateway_session_key="agent:main:telegram:dm:8439114563", + ) + assert result == "agent-main-telegram-dm-8439114563" + assert ":" not in result class TestResetHonchoClient: diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index abf6dee007..cafda842af 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -205,27 +205,62 @@ class TestPeerLookupHelpers: 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) + assistant_peer = MagicMock() + assistant_peer.get_card.return_value = ["Name: Robert"] + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) assert mgr.get_peer_card(session.key) == ["Name: Robert"] - user_peer.get_card.assert_called_once_with() + assistant_peer.get_card.assert_called_once_with(target=session.user_peer_id) - def test_search_context_uses_peer_context_response(self): + def test_search_context_uses_assistant_perspective_with_target(self): mgr, session = self._make_cached_manager() - user_peer = MagicMock() - user_peer.context.return_value = SimpleNamespace( + assistant_peer = MagicMock() + assistant_peer.context.return_value = SimpleNamespace( representation="Robert runs neuralancer", peer_card=["Location: Melbourne"], ) - mgr._get_or_create_peer = MagicMock(return_value=user_peer) + mgr._get_or_create_peer = MagicMock(return_value=assistant_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") + assistant_peer.context.assert_called_once_with( + target=session.user_peer_id, + search_query="neuralancer", + ) + + def test_search_context_unified_mode_uses_user_self_context(self): + mgr, session = self._make_cached_manager() + mgr._ai_observe_others = False + user_peer = MagicMock() + user_peer.context.return_value = SimpleNamespace( + representation="Unified self context", + peer_card=["Name: Robert"], + ) + mgr._get_or_create_peer = MagicMock(return_value=user_peer) + + result = mgr.search_context(session.key, "self") + + assert "Unified self context" in result + user_peer.context.assert_called_once_with(search_query="self") + + def test_search_context_accepts_explicit_ai_peer_id(self): + mgr, session = self._make_cached_manager() + ai_peer = MagicMock() + ai_peer.context.return_value = SimpleNamespace( + representation="Assistant self context", + peer_card=["Role: Assistant"], + ) + mgr._get_or_create_peer = MagicMock(return_value=ai_peer) + + result = mgr.search_context(session.key, "assistant", peer=session.assistant_peer_id) + + assert "Assistant self context" in result + ai_peer.context.assert_called_once_with( + target=session.assistant_peer_id, + search_query="assistant", + ) def test_get_prefetch_context_fetches_user_and_ai_from_peer_api(self): mgr, session = self._make_cached_manager() @@ -235,9 +270,15 @@ class TestPeerLookupHelpers: peer_card=["Name: Robert"], ) ai_peer = MagicMock() - ai_peer.context.return_value = SimpleNamespace( - representation="AI representation", - peer_card=["Owner: Robert"], + ai_peer.context.side_effect = lambda **kwargs: SimpleNamespace( + representation=( + "AI representation" if kwargs.get("target") == session.assistant_peer_id + else "Mixed representation" + ), + peer_card=( + ["Role: Assistant"] if kwargs.get("target") == session.assistant_peer_id + else ["Name: Robert"] + ), ) mgr._get_or_create_peer = MagicMock(side_effect=[user_peer, ai_peer]) @@ -247,17 +288,23 @@ class TestPeerLookupHelpers: "representation": "User representation", "card": "Name: Robert", "ai_representation": "AI representation", - "ai_card": "Owner: Robert", + "ai_card": "Role: Assistant", } - user_peer.context.assert_called_once_with() - ai_peer.context.assert_called_once_with() + user_peer.context.assert_called_once_with(target=session.user_peer_id) + ai_peer.context.assert_called_once_with(target=session.assistant_peer_id) 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"], + ai_peer.context.side_effect = lambda **kwargs: SimpleNamespace( + representation=( + "AI representation" if kwargs.get("target") == session.assistant_peer_id + else "Mixed representation" + ), + peer_card=( + ["Role: Assistant"] if kwargs.get("target") == session.assistant_peer_id + else ["Name: Robert"] + ), ) mgr._get_or_create_peer = MagicMock(return_value=ai_peer) @@ -265,9 +312,152 @@ class TestPeerLookupHelpers: assert result == { "representation": "AI representation", - "card": "Owner: Robert", + "card": "Role: Assistant", } - ai_peer.context.assert_called_once_with() + ai_peer.context.assert_called_once_with(target=session.assistant_peer_id) + + def test_create_conclusion_defaults_to_user_target(self): + mgr, session = self._make_cached_manager() + assistant_peer = MagicMock() + scope = MagicMock() + assistant_peer.conclusions_of.return_value = scope + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) + + ok = mgr.create_conclusion(session.key, "User prefers dark mode") + + assert ok is True + assistant_peer.conclusions_of.assert_called_once_with(session.user_peer_id) + scope.create.assert_called_once_with([{ + "content": "User prefers dark mode", + "session_id": session.honcho_session_id, + }]) + + def test_create_conclusion_can_target_ai_peer(self): + mgr, session = self._make_cached_manager() + assistant_peer = MagicMock() + scope = MagicMock() + assistant_peer.conclusions_of.return_value = scope + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) + + ok = mgr.create_conclusion(session.key, "Assistant prefers terse summaries", peer="ai") + + assert ok is True + assistant_peer.conclusions_of.assert_called_once_with(session.assistant_peer_id) + scope.create.assert_called_once_with([{ + "content": "Assistant prefers terse summaries", + "session_id": session.honcho_session_id, + }]) + + def test_create_conclusion_accepts_explicit_user_peer_id(self): + mgr, session = self._make_cached_manager() + assistant_peer = MagicMock() + scope = MagicMock() + assistant_peer.conclusions_of.return_value = scope + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) + + ok = mgr.create_conclusion(session.key, "Robert prefers vinyl", peer=session.user_peer_id) + + assert ok is True + assistant_peer.conclusions_of.assert_called_once_with(session.user_peer_id) + scope.create.assert_called_once_with([{ + "content": "Robert prefers vinyl", + "session_id": session.honcho_session_id, + }]) + + +class TestConcludeToolDispatch: + def test_honcho_conclude_defaults_to_user_peer(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.create_conclusion.return_value = True + + result = provider.handle_tool_call( + "honcho_conclude", + {"conclusion": "User prefers dark mode"}, + ) + + assert "Conclusion saved for user" in result + provider._manager.create_conclusion.assert_called_once_with( + "telegram:123", + "User prefers dark mode", + peer="user", + ) + + def test_honcho_conclude_can_target_ai_peer(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.create_conclusion.return_value = True + + result = provider.handle_tool_call( + "honcho_conclude", + {"conclusion": "Assistant likes terse replies", "peer": "ai"}, + ) + + assert "Conclusion saved for ai" in result + provider._manager.create_conclusion.assert_called_once_with( + "telegram:123", + "Assistant likes terse replies", + peer="ai", + ) + + def test_honcho_profile_can_target_explicit_peer_id(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.get_peer_card.return_value = ["Role: Assistant"] + + result = provider.handle_tool_call( + "honcho_profile", + {"peer": "hermes"}, + ) + + assert "Role: Assistant" in result + provider._manager.get_peer_card.assert_called_once_with("telegram:123", peer="hermes") + + def test_honcho_search_can_target_explicit_peer_id(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.search_context.return_value = "Assistant self context" + + result = provider.handle_tool_call( + "honcho_search", + {"query": "assistant", "peer": "hermes"}, + ) + + assert "Assistant self context" in result + provider._manager.search_context.assert_called_once_with( + "telegram:123", + "assistant", + max_tokens=800, + peer="hermes", + ) + + def test_honcho_reasoning_can_target_explicit_peer_id(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.dialectic_query.return_value = "Assistant answer" + + result = provider.handle_tool_call( + "honcho_reasoning", + {"query": "who are you", "peer": "hermes"}, + ) + + assert "Assistant answer" in result + provider._manager.dialectic_query.assert_called_once_with( + "telegram:123", + "who are you", + reasoning_level=None, + peer="hermes", + ) # --------------------------------------------------------------------------- @@ -366,6 +556,54 @@ class TestToolsModeInitBehavior: assert cfg.peer_name == "8439114563" +class TestPerSessionMigrateGuard: + """Verify migrate_memory_files is skipped under per-session strategy. + + per-session creates a fresh Honcho session every Hermes run. Uploading + MEMORY.md/USER.md/SOUL.md to each short-lived session floods the backend + with duplicate content. The guard was added to prevent orphan sessions + containing only wrappers. + """ + + def _make_provider_with_strategy(self, strategy, init_on_session_start=True): + """Create a HonchoMemoryProvider and track migrate_memory_files calls.""" + from plugins.memory.honcho.client import HonchoClientConfig + from unittest.mock import patch, MagicMock + + cfg = HonchoClientConfig( + api_key="test-key", + enabled=True, + recall_mode="tools", + init_on_session_start=init_on_session_start, + session_strategy=strategy, + ) + + provider = HonchoMemoryProvider() + + mock_manager = MagicMock() + mock_session = MagicMock() + mock_session.messages = [] # empty = new session → triggers migration path + mock_manager.get_or_create.return_value = mock_session + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="test-session-001") + + return provider, mock_manager + + def test_migrate_skipped_for_per_session(self): + """per-session strategy must NOT call migrate_memory_files.""" + _, mock_manager = self._make_provider_with_strategy("per-session") + mock_manager.migrate_memory_files.assert_not_called() + + def test_migrate_runs_for_per_directory(self): + """per-directory strategy with empty session SHOULD call migrate_memory_files.""" + _, mock_manager = self._make_provider_with_strategy("per-directory") + mock_manager.migrate_memory_files.assert_called_once() + + class TestChunkMessage: def test_short_message_single_chunk(self): result = HonchoMemoryProvider._chunk_message("hello world", 100) @@ -420,6 +658,60 @@ class TestChunkMessage: assert len(chunk) <= 25000 +# --------------------------------------------------------------------------- +# Context token budget enforcement +# --------------------------------------------------------------------------- + + +class TestTruncateToBudget: + def test_truncates_oversized_context(self): + """Text exceeding context_tokens budget is truncated at a word boundary.""" + from plugins.memory.honcho.client import HonchoClientConfig + + provider = HonchoMemoryProvider() + provider._config = HonchoClientConfig(context_tokens=10) + + long_text = "word " * 200 # ~1000 chars, well over 10*4=40 char budget + result = provider._truncate_to_budget(long_text) + + assert len(result) <= 50 # budget_chars + ellipsis + word boundary slack + assert result.endswith(" …") + + def test_no_truncation_within_budget(self): + """Text within budget passes through unchanged.""" + from plugins.memory.honcho.client import HonchoClientConfig + + provider = HonchoMemoryProvider() + provider._config = HonchoClientConfig(context_tokens=1000) + + short_text = "Name: Robert, Location: Melbourne" + assert provider._truncate_to_budget(short_text) == short_text + + def test_no_truncation_when_context_tokens_none(self): + """When context_tokens is None (explicit opt-out), no truncation.""" + from plugins.memory.honcho.client import HonchoClientConfig + + provider = HonchoMemoryProvider() + provider._config = HonchoClientConfig(context_tokens=None) + + long_text = "word " * 500 + assert provider._truncate_to_budget(long_text) == long_text + + def test_context_tokens_cap_bounds_prefetch(self): + """With an explicit token budget, oversized prefetch is bounded.""" + from plugins.memory.honcho.client import HonchoClientConfig + + provider = HonchoMemoryProvider() + provider._config = HonchoClientConfig(context_tokens=1200) + + # Simulate a massive representation (10k chars) + huge_text = "x" * 10000 + result = provider._truncate_to_budget(huge_text) + + # 1200 tokens * 4 chars = 4800 chars + " …" + assert len(result) <= 4805 + + # --------------------------------------------------------------------------- # Dialectic input guard # --------------------------------------------------------------------------- @@ -452,3 +744,44 @@ class TestDialecticInputGuard: # The query passed to chat() should be truncated actual_query = mock_peer.chat.call_args[0][0] assert len(actual_query) <= 100 + + +# --------------------------------------------------------------------------- + + +class TestDialecticCadenceDefaults: + """Regression tests for dialectic_cadence default value.""" + + @staticmethod + def _make_provider(cfg_extra=None): + """Create a HonchoMemoryProvider with mocked dependencies.""" + from unittest.mock import patch, MagicMock + from plugins.memory.honcho.client import HonchoClientConfig + + defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid") + if cfg_extra: + defaults.update(cfg_extra) + cfg = HonchoClientConfig(**defaults) + provider = HonchoMemoryProvider() + mock_manager = MagicMock() + mock_session = MagicMock() + mock_session.messages = [] + mock_manager.get_or_create.return_value = mock_session + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="test-session-001") + + return provider + + def test_default_is_3(self): + """Default dialectic_cadence should be 3 to avoid per-turn LLM calls.""" + provider = self._make_provider() + assert provider._dialectic_cadence == 3 + + def test_config_override(self): + """dialecticCadence from config overrides the default.""" + provider = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 5}}) + assert provider._dialectic_cadence == 5 diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 2e054482f2..2b6a81656c 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -44,9 +44,6 @@ hermes [global-options] [subcommand/options] | `hermes webhook` | Manage dynamic webhook subscriptions for event-driven activation. | | `hermes doctor` | Diagnose config and dependency issues. | | `hermes dump` | Copy-pasteable setup summary for support/debugging. | -| `hermes debug` | Debug tools — upload logs and system info for support. | -| `hermes backup` | Back up Hermes home directory to a zip file. | -| `hermes import` | Restore a Hermes backup from a zip file. | | `hermes logs` | View, tail, and filter agent/gateway/error log files. | | `hermes config` | Show, edit, migrate, and query configuration files. | | `hermes pairing` | Approve or revoke messaging pairing codes. | @@ -60,10 +57,6 @@ hermes [global-options] [subcommand/options] | `hermes sessions` | Browse, export, prune, rename, and delete sessions. | | `hermes insights` | Show token/cost/activity analytics. | | `hermes claw` | OpenClaw migration helpers. | -| `hermes dashboard` | Launch the web dashboard for managing config, API keys, and sessions. | -| `hermes debug` | Debug tools — upload logs and system info for support. | -| `hermes backup` | Back up Hermes home directory to a zip file. | -| `hermes import` | Restore a Hermes backup from a zip file. | | `hermes profile` | Manage profiles — multiple isolated Hermes instances. | | `hermes completion` | Print shell completion scripts (bash/zsh). | | `hermes version` | Show version information. | @@ -83,11 +76,10 @@ Common options: | `-q`, `--query "..."` | One-shot, non-interactive prompt. | | `-m`, `--model ` | Override the model for this run. | | `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. | -| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `huggingface`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `kilocode`, `xiaomi`, `arcee`. | +| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `huggingface`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `deepseek`, `ai-gateway`, `opencode-zen`, `opencode-go`, `kilocode`, `alibaba`. | | `-s`, `--skills ` | Preload one or more skills for the session (can be repeated or comma-separated). | | `-v`, `--verbose` | Verbose output. | | `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. | -| `--image ` | Attach a local image to a single query. | | `--resume ` / `--continue [name]` | Resume a session directly from `chat`. | | `--worktree` | Create an isolated git worktree for this run. | | `--checkpoints` | Enable filesystem checkpoints before destructive file changes. | @@ -148,23 +140,19 @@ Subcommands: | Subcommand | Description | |------------|-------------| -| `run` | Run the gateway in the foreground. Recommended for WSL, Docker, and Termux. | -| `start` | Start the installed systemd/launchd background service. | -| `stop` | Stop the service (or foreground process). | +| `run` | Run the gateway in the foreground. | +| `start` | Start the installed gateway service. | +| `stop` | Stop the service. | | `restart` | Restart the service. | | `status` | Show service status. | -| `install` | Install as a systemd (Linux) or launchd (macOS) background service. | +| `install` | Install as a user service (`systemd` on Linux, `launchd` on macOS). | | `uninstall` | Remove the installed service. | | `setup` | Interactive messaging-platform setup. | -:::tip WSL users -Use `hermes gateway run` instead of `hermes gateway start` — WSL's systemd support is unreliable. Wrap it in tmux for persistence: `tmux new -s hermes 'hermes gateway run'`. See [WSL FAQ](/docs/reference/faq#wsl-gateway-keeps-disconnecting-or-hermes-gateway-start-fails) for details. -::: - ## `hermes setup` ```bash -hermes setup [model|tts|terminal|gateway|tools|agent] [--non-interactive] [--reset] +hermes setup [model|terminal|gateway|tools|agent] [--non-interactive] [--reset] ``` Use the full wizard or jump into one section: @@ -362,70 +350,6 @@ config_overrides: `hermes dump` is specifically designed for sharing. For interactive diagnostics, use `hermes doctor`. For a visual overview, use `hermes status`. ::: -## `hermes debug` - -```bash -hermes debug share [options] -``` - -Upload a debug report (system info + recent logs) to a paste service and get a shareable URL. Useful for quick support requests — includes everything a helper needs to diagnose your issue. - -| Option | Description | -|--------|-------------| -| `--lines ` | Number of log lines to include per log file (default: 200). | -| `--expire ` | Paste expiry in days (default: 7). | -| `--local` | Print the report locally instead of uploading. | - -The report includes system info (OS, Python version, Hermes version), recent agent and gateway logs (512 KB limit per file), and redacted API key status. Keys are always redacted — no secrets are uploaded. - -Paste services tried in order: paste.rs, dpaste.com. - -### Examples - -```bash -hermes debug share # Upload debug report, print URL -hermes debug share --lines 500 # Include more log lines -hermes debug share --expire 30 # Keep paste for 30 days -hermes debug share --local # Print report to terminal (no upload) -``` - -## `hermes backup` - -```bash -hermes backup [options] -``` - -Create a zip archive of your Hermes configuration, skills, sessions, and data. The backup excludes the hermes-agent codebase itself. - -| Option | Description | -|--------|-------------| -| `-o`, `--output ` | Output path for the zip file (default: `~/hermes-backup-.zip`). | -| `-q`, `--quick` | Quick snapshot: only critical state files (config.yaml, state.db, .env, auth, cron jobs). Much faster than a full backup. | -| `-l`, `--label ` | Label for the snapshot (only used with `--quick`). | - -The backup uses SQLite's `backup()` API for safe copying, so it works correctly even when Hermes is running (WAL-mode safe). - -### Examples - -```bash -hermes backup # Full backup to ~/hermes-backup-*.zip -hermes backup -o /tmp/hermes.zip # Full backup to specific path -hermes backup --quick # Quick state-only snapshot -hermes backup --quick --label "pre-upgrade" # Quick snapshot with label -``` - -## `hermes import` - -```bash -hermes import [options] -``` - -Restore a previously created Hermes backup into your Hermes home directory. - -| Option | Description | -|--------|-------------| -| `-f`, `--force` | Overwrite existing files without confirmation. | - ## `hermes logs` ```bash @@ -452,7 +376,6 @@ View, tail, and filter Hermes log files. All logs are stored in `~/.hermes/logs/ | `--level ` | Minimum log level to show: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. | | `--session ` | Filter lines containing a session ID substring. | | `--since