{"repo":"NousResearch/hermes-agent","public_id":"11199","display_label":"Honcho memory gateway scoping and lifecycle fixes","summary":"These issues and pull requests concern bugs and fixes related to how Honcho memory scopes sessions by gateway user ID, particularly when peer_name is configured, as well as dialectic lifecycle behavior including defaults, retry, and prewarm handling.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12419},"resolution":{"state":"partially_resolved","by":"pr-12419"},"freshness":{"state":"stale","last_verified_at":"2026-04-19T05:50:56+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":11199,"pr_number":12419,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":11199,"title":"[Bug]: Honcho memory scoping ignores gateway user_id when peer_name is configured","url":null,"state":"closed"},{"kind":"issue","number":14401,"title":"Honcho memory plugin ignores gateway user_id when peer_name is configured","url":null,"state":"closed"},{"kind":"pr","number":11434,"title":"fix(honcho): scope gateway sessions by runtime user id","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12419","number":12419},{"kind":"pr","value":"#12160","number":12160}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":12160,"title":"fix(honcho): dialectic lifecycle — defaults, retry, prewarm consumption","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12419","number":12419},{"kind":"pr","value":"#11434","number":11434}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":12419,"title":"fix(honcho): dialectic lifecycle, gateway scoping, provider opt-in","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["optional-skills/autonomous-ai-agents/honcho/SKILL.md","plugins/memory/honcho/__init__.py","plugins/memory/honcho/cli.py","plugins/memory/honcho/client.py","plugins/memory/honcho/session.py","run_agent.py","scripts/release.py","tests/agent/test_memory_provider.py","tests/agent/test_memory_user_id.py","tests/honcho_plugin/test_async_memory.py","tests/honcho_plugin/test_cli.py","tests/honcho_plugin/test_session.py","tests/run_agent/test_memory_provider_init.py","website/docs/user-guide/features/honcho.md","website/docs/user-guide/features/memory-providers.md"],"fix_hunks":[{"file":"optional-skills/autonomous-ai-agents/honcho/SKILL.md","hunks":["@@ -145,10 +145,10 @@ Controls **how often** dialectic and context calls happen.\n | Key | Default | Description |\n |-----|---------|-------------|\n | `contextCadence` | `1` | Min turns between context API calls |\n-| `dialecticCadence` | `3` | Min turns between dialectic API calls |\n+| `dialecticCadence` | `2` | Min turns between dialectic API calls. Recommended 1–5 |\n | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` for base context injection |\n \n-Higher cadence values reduce API calls and cost. `dialecticCadence: 3` (default) means the dialectic engine fires at most every 3rd turn.\n+Higher cadence values fire the dialectic LLM less often. `dialecticCadence: 2` means the engine fires every other turn. Setting it to `1` fires every turn.\n \n ### Depth (how many)\n ","@@ -180,6 +180,8 @@ If `dialecticDepthLevels` is omitted, rounds use **proportional levels** derived\n \n This keeps earlier passes cheap while using full depth on the final synthesis.\n \n+**Depth at session start.** The session-start prewarm runs the full configured `dialecticDepth` in the background before turn 1. A single-pass prewarm on a cold peer often returns thin output — multi-pass depth runs the audit/reconcile cycle before the user ever speaks. Turn 1 consumes the prewarm result directly; if prewarm hasn't landed in time, turn 1 falls back to a synchronous call with a bounded timeout.\n+\n ### Level (how hard)\n \n Controls the **intensity** of each dialectic reasoning round.","@@ -368,7 +370,7 @@ Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.jso\n | `contextTokens` | uncapped | Max tokens for the combined base context injection (summary + representation + card). Opt-in cap — omit to leave uncapped, set to an integer to bound injection size. |\n | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` |\n | `contextCadence` | `1` | Min turns between context API calls |\n-| `dialecticCadence` | `3` | Min turns between dialectic LLM calls |\n+| `dialecticCadence` | `2` | Min turns between dialectic LLM calls (recommended 1–5) |\n \n The `contextTokens` budget is enforced at injection time. If the session summary + representation + card exceed the budget, Honcho trims the summary first, then the representation, preserving the card. This prevents context blowup in long sessions.\n "]},{"file":"plugins/memory/honcho/__init__.py","hunks":["@@ -19,6 +19,7 @@\n import logging\n import re\n import threading\n+import time\n from typing import Any, Dict, List, Optional\n \n from agent.memory_provider import MemoryProvider","@@ -206,13 +207,19 @@ def __init__(self):\n         self._turn_count = 0\n         self._injection_frequency = \"every-turn\"  # or \"first-turn\"\n         self._context_cadence = 1   # minimum turns between context API calls\n-        self._dialectic_cadence = 3  # minimum turns between dialectic API calls\n+        self._dialectic_cadence = 1  # backwards-compat fallback; wizard writes 2 on new configs\n         self._dialectic_depth = 1   # how many .chat() calls per dialectic cycle (1-3)\n         self._dialectic_depth_levels: list[str] | None = None  # per-pass reasoning levels\n-        self._reasoning_level_cap: Optional[str] = None  # \"minimal\", \"low\", \"medium\", \"high\"\n+        self._reasoning_heuristic: bool = True  # scale base level by query length\n+        self._reasoning_level_cap: str = \"high\"  # ceiling for auto-selected level\n         self._last_context_turn = -999\n         self._last_dialectic_turn = -999\n \n+        # Liveness + observability state\n+        self._prefetch_thread_started_at: float = 0.0   # monotonic ts of current thread\n+        self._prefetch_result_fired_at: int = -999      # turn the pending result was fired at\n+        self._dialectic_empty_streak: int = 0           # consecutive empty returns\n+\n         # Port #1957: lazy session init for tools-only mode\n         self._session_initialized = False\n         self._lazy_init_kwargs: Optional[dict] = None","@@ -286,14 +293,6 @@ def initialize(self, session_id: str, **kwargs) -> None:\n                 logger.debug(\"Honcho not configured — plugin inactive\")\n                 return\n \n-            # Override peer_name with gateway user_id for per-user memory scoping.\n-            # Only when no explicit peerName was configured — an explicit peerName\n-            # means the user chose their identity; a raw user_id (e.g. Telegram\n-            # chat ID) should not silently replace it.\n-            _gw_user_id = kwargs.get(\"user_id\")\n-            if _gw_user_id and not cfg.peer_name:\n-                cfg.peer_name = _gw_user_id\n-\n             self._config = cfg\n \n             # ----- B1: recall_mode from config -----","@@ -305,12 +304,16 @@ def initialize(self, session_id: str, **kwargs) -> None:\n                 raw = cfg.raw or {}\n                 self._injection_frequency = raw.get(\"injectionFrequency\", \"every-turn\")\n                 self._context_cadence = int(raw.get(\"contextCadence\", 1))\n-                self._dialectic_cadence = int(raw.get(\"dialecticCadence\", 3))\n+                # Backwards-compat: unset dialecticCadence falls back to 1\n+                # (every turn) so existing honcho.json configs without the key\n+                # behave as they did before. New setups via `hermes honcho setup`\n+                # get dialecticCadence=2 written explicitly by the wizard.\n+                self._dialectic_cadence = int(raw.get(\"dialecticCadence\", 1))\n                 self._dialectic_depth = max(1, min(cfg.dialectic_depth, 3))\n                 self._dialectic_depth_levels = cfg.dialectic_depth_levels\n-                cap = raw.get(\"reasoningLevelCap\")\n-                if cap and cap in (\"minimal\", \"low\", \"medium\", \"high\"):\n-                    self._reasoning_level_cap = cap\n+                self._reasoning_heuristic = cfg.reasoning_heuristic\n+                if cfg.reasoning_level_cap in self._LEVEL_ORDER:\n+                    self._reasoning_level_cap = cfg.reasoning_level_cap\n             except Exception as e:\n                 logger.debug(\"Honcho cost-awareness config parse error: %s\", e)\n ","@@ -352,6 +355,7 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:\n             honcho=client,\n             config=cfg,\n             context_tokens=[redacted:secret-assignment]\n+            runtime_user_peer_name=kwargs.get(\"user_id\") or None,\n         )\n \n         # ----- B3: resolve_session_name -----","@@ -391,14 +395,45 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:\n         except Exception as e:\n             logger.debug(\"Honcho memory file migration skipped: %s\", e)\n \n-        # ----- B7: Pre-warming context at init -----\n+        # ----- B7: Pre-warming at init -----\n+        # Context prewarm warms peer.context() (base layer), consumed via\n+        # pop_context_result() in prefetch(). Dialectic prewarm runs the\n+        # full configured depth and writes into _prefetch_result so turn 1\n+        # consumes the result directly.\n         if self._recall_mode in (\"context\", \"hybrid\"):\n             try:\n                 self._manager.prefetch_context(self._session_key)\n-                self._manager.prefetch_dialectic(self._session_key, \"What should I know about this user?\")\n-                logger.debug(\"Honcho pre-warm threads started for session: %s\", self._session_key)\n             except Exception as e:\n-                logger.debug(\"Honcho pre-warm failed: %s\", e)\n+                logger.debug(\"Honcho context prewarm failed: %s\", e)\n+\n+            _prewarm_query = (\n+                \"Summarize what you know about this user. \"\n+                \"Focus on preferences, current projects, and working style.\"\n+            )\n+\n+            def _prewarm_dialectic() -> None:\n+                try:\n+                    r = self._run_dialectic_depth(_prewarm_query)\n+                except Exception as exc:\n+                    logger.debug(\"Honcho dialectic prewarm failed: %s\", exc)\n+                    self._dialectic_empty_streak += 1\n+                    return\n+                if r and r.strip():\n+                    with self._prefetch_lock:\n+                        self._prefetch_result = r\n+                        self._prefetch_result_fired_at = 0\n+                    # Treat prewarm as turn 0 so cadence gating starts clean.\n+                    self._last_dialectic_turn = 0\n+                    self._dialectic_empty_streak = 0\n+                else:\n+                    self._dialectic_empty_streak += 1\n+\n+            self._prefetch_thread_started_at = time.monotonic()\n+            self._prefetch_thread = threading.Thread(\n+                target=_prewarm_dialectic, daemon=True, name=\"honcho-prewarm-dialectic\"\n+            )\n+            self._prefetch_thread.start()\n+            logger.debug(\"Honcho pre-warm started for session: %s\", self._session_key)\n \n     def _ensure_session(self) -> bool:\n         \"\"\"Lazily initialize the Honcho session (for tools-only mode).","@@ -487,7 +522,8 @@ def system_prompt_block(self) -> str:\n                 \"# Honcho Memory\\n\"\n                 \"Active (tools-only mode). Use honcho_profile for a quick factual snapshot, \"\n                 \"honcho_search for raw excerpts, honcho_context for raw peer context, \"\n-                \"honcho_reasoning for synthesized answers, \"\n+                \"honcho_reasoning for synthesized answers (pass reasoning_level \"\n+                \"minimal/low/medium/high/max — you pick the depth per call), \"\n                 \"honcho_conclude to save facts about the user. \"\n                 \"No automatic context injection — you must use tools to access memory.\"\n             )","@@ -497,7 +533,8 @@ def system_prompt_block(self) -> str:\n                 \"Active (hybrid mode). Relevant context is auto-injected AND memory tools are available. \"\n                 \"Use honcho_profile for a quick factual snapshot, \"\n                 \"honcho_search for raw excerpts, honcho_context for raw peer context, \"\n-                \"honcho_reasoning for synthesized answers, \"\n+                \"honcho_reasoning for synthesized answers (pass reasoning_level \"\n+                \"minimal/low/medium/high/max — you pick the depth per call), \"\n                 \"honcho_conclude to save facts about the user.\"\n             )\n ","@@ -526,6 +563,10 @@ def prefetch(self, query: str, *, session_id: str = \"\") -> str:\n         if self._injection_frequency == \"first-turn\" and self._turn_count > 1:\n             return \"\"\n \n+        # Trivial prompts (\"ok\", \"yes\", slash commands) carry no semantic signal.\n+        if self._is_trivial_prompt(query):\n+            return \"\"\n+\n         parts = []\n \n         # ----- Layer 1: Base context (representation + card) -----","@@ -560,43 +601,72 @@ def prefetch(self, query: str, *, session_id: str = \"\") -> str:\n         # On the very first turn, no queue_prefetch() has run yet so the\n         # dialectic result is empty.  Run with a bounded timeout so a slow\n         # Honcho connection doesn't block the first response indefinitely.\n-        # On timeout the result is skipped and queue_prefetch() will pick it\n-        # up at the next cadence-allowed turn.\n+        # On timeout we let the thread keep running and write its result into\n+        # _prefetch_result under the lock, so the next turn picks it up.\n+        #\n+        # Skip if the session-start prewarm already filled _prefetch_result —\n+        # firing another .chat() would be duplicate work.\n+        with self._prefetch_lock:\n+            _prewarm_landed = bool(self._prefetch_result)\n+        if _prewarm_landed and self._last_dialectic_turn == -999:\n+            self._last_dialectic_turn = self._turn_count\n+\n         if self._last_dialectic_turn == -999 and query:\n             _first_turn_timeout = (\n                 self._config.timeout if self._config and self._config.timeout else 8.0\n             )\n-            _result_holder: list[str] = []\n+            _fired_at = self._turn_count\n \n             def _run_first_turn() -> None:\n                 try:\n-                    _result_holder.append(self._run_dialectic_depth(query))\n+                    r = self._run_dialectic_depth(query)\n                 except Exception as exc:\n                     logger.debug(\"Honcho first-turn dialectic failed: %s\", exc)\n-\n-            _t = threading.Thread(target=_run_first_turn, daemon=True)\n-            _t.start()\n-            _t.join(timeout=_first_turn_timeout)\n-            if not _t.is_alive():\n-                first_turn_dialectic = _result_holder[0] if _result_holder else \"\"\n-                if first_turn_dialectic and first_turn_dialectic.strip():\n+                    self._dialectic_empty_streak += 1\n+                    return\n+                if r and r.strip():\n                     with self._prefetch_lock:\n-                        self._prefetch_result = first_turn_dialectic\n-                self._last_dialectic_turn = self._turn_count\n-            else:\n+                        self._prefetch_result = r\n+                        self._prefetch_result_fired_at = _fired_at\n+                    # Advance cadence only on a non-empty result so the next\n+                    # turn retries when the call returned nothing.\n+                    self._last_dialectic_turn = _fired_at\n+                    self._dialectic_empty_streak = 0\n+                else:\n+                    self._dialectic_empty_streak += 1\n+\n+            self._prefetch_thread_started_at = time.monotonic()\n+            self._prefetch_thread = threading.Thread(\n+                target=_run_first_turn, daemon=True, name=\"honcho-prefetch-first\"\n+            )\n+            self._prefetch_thread.start()\n+            self._prefetch_thread.join(timeout=_first_turn_timeout)\n+            if self._prefetch_thread.is_alive():\n                 logger.debug(\n-                    \"Honcho first-turn dialectic timed out (%.1fs) — \"\n-                    \"will inject at next cadence-allowed turn\",\n+                    \"Honcho first-turn dialectic still running after %.1fs — \"\n+                    \"will surface on next turn\",\n                     _first_turn_timeout,\n                 )\n-                # Don't update _last_dialectic_turn: queue_prefetch() will\n-                # retry at the next cadence-allowed turn via the async path.\n \n         if self._prefetch_thread and self._prefetch_thread.is_alive():\n             self._prefetch_thread.join(timeout=3.0)\n         with self._prefetch_lock:\n             dialectic_result = self._prefetch_result\n+            fired_at = self._prefetch_result_fired_at\n             self._prefetch_result = \"\"\n+            self._prefetch_result_fired_at = -999\n+\n+        # Discard stale pending results: if the fire happened more than\n+        # cadence × multiplier turns ago (e.g. a run of trivial-prompt turns\n+        # passed without consumption), the content likely no longer tracks\n+        # the current conversational pivot.\n+        stale_limit = self._dialectic_cadence * self._STALE_RESULT_MULTIPLIER\n+        if dialectic_result and fired_at >= 0 and (self._turn_count - fired_at) > stale_limit:\n+            logger.debug(\n+                \"Honcho pending dialectic discarded as stale: fired_at=%d, \"\n+                \"turn=%d, limit=%d\", fired_at, self._turn_count, stale_limit,\n+            )\n+            dialectic_result = \"\"\n \n         if dialectic_result and dialectic_result.strip():\n             parts.append(dialectic_result)","@@ -641,6 +711,10 @@ def queue_prefetch(self, query: str, *, session_id: str = \"\") -> None:\n         if self._recall_mode == \"tools\":\n             return\n \n+        # Trivial prompts don't warrant either a context refresh or a dialectic call.\n+        if self._is_trivial_prompt(query):\n+            return\n+\n         # ----- Context refresh (base layer) — independent cadence -----\n         if self._context_cadence <= 1 or (self._turn_count - self._last_context_turn) >= self._context_cadence:\n             self._last_context_turn = self._turn_count","@@ -650,24 +724,46 @@ def queue_prefetch(self, query: str, *, session_id: str = \"\") -> None:\n                 logger.debug(\"Honcho context prefetch failed: %s\", e)\n \n         # ----- Dialectic prefetch (supplement layer) -----\n-        # B5: cadence check — skip if too soon since last dialectic call\n-        if self._dialectic_cadence > 1:\n-            if (self._turn_count - self._last_dialectic_turn) < self._dialectic_cadence:\n-                logger.debug(\"Honcho dialectic prefetch skipped: cadence %d, turns since last: %d\",\n-                             self._dialectic_cadence, self._turn_count - self._last_dialectic_turn)\n-                return\n+        # Thread-alive guard with stale-thread recovery: a hung Honcho call\n+        # older than timeout × multiplier is treated as dead so it can't\n+        # block subsequent fires.\n+        if self._thread_is_live():\n+            logger.debug(\"Honcho dialectic prefetch skipped: prior thread still running\")\n+            return\n \n-        self._last_dialectic_turn = self._turn_count\n+        # Cadence gate, widened by the empty-streak backoff so a persistently\n+        # silent backend doesn't retry every turn forever.\n+        effective = self._effective_cadence()\n+        if (self._turn_count - self._last_dialectic_turn) < effective:\n+            logger.debug(\n+                \"Honcho dialectic prefetch skipped: effective cadence %d \"\n+                \"(base %d, empty streak %d), turns since last: %d\",\n+                effective, self._dialectic_cadence, self._dialectic_empty_streak,\n+                self._turn_count - self._last_dialectic_turn,\n+            )\n+            return\n+\n+        # Cadence advances only on a non-empty result so empty returns\n+        # (transient API error, sparse representation) retry next turn.\n+        _fired_at = self._turn_count\n \n         def _run():\n             try:\n                 result = self._run_dialectic_depth(query)\n-                if result and result.strip():\n-                    with self._prefetch_lock:\n-                        self._prefetch_result = result\n             except Exception as e:\n                 logger.debug(\"Honcho prefetch failed: %s\", e)\n+                self._dialectic_empty_streak += 1\n+                return\n+            if result and result.strip():\n+                with self._prefetch_lock:\n+                    self._prefetch_result = result\n+                    self._prefetch_result_fired_at = _fired_at\n+                self._last_dialectic_turn = _fired_at\n+                self._dialectic_empty_streak = 0\n+            else:\n+                self._dialectic_empty_streak += 1\n \n+        self._prefetch_thread_started_at = time.monotonic()\n         self._prefetch_thread = threading.Thread(\n             target=_run, daemon=True, name=\"honcho-prefetch\"\n         )","@@ -692,11 +788,91 @@ def _run():\n \n     _LEVEL_ORDER = (\"minimal\", \"low\", \"medium\", \"high\", \"max\")\n \n-    def _resolve_pass_level(self, pass_idx: int) -> str:\n+    # Char-count thresholds for the query-length reasoning heuristic.\n+    _HEURISTIC_LENGTH_MEDIUM = 120\n+    _HEURISTIC_LENGTH_HIGH = 400\n+\n+    # Liveness constants. A thread older than timeout × multiplier is treated\n+    # as dead so a hung Honcho call can't block future retries indefinitely.\n+    _STALE_THREAD_MULTIPLIER = 2.0\n+    # Pending result whose fire-turn is older than cadence × multiplier is\n+    # discarded on read so we don't inject context for a stale conversational\n+    # pivot after a gap of trivial-prompt turns.\n+    _STALE_RESULT_MULTIPLIER = 2\n+    # Cap on the empty-streak backoff so a persistently silent backend\n+    # eventually settles on a ceiling instead of unbounded widening.\n+    _BACKOFF_MAX = 8\n+\n+    def _thread_is_live(self) -> bool:\n+        \"\"\"Thread-alive guard that treats threads older than the stale\n+        threshold as dead, so a hung Honcho request can't block new fires.\"\"\"\n+        if not self._prefetch_thread or not self._prefetch_thread.is_alive():\n+            return False\n+        timeout = (self._config.timeout if self._config and self._config.timeout else 8.0)\n+        age = time.monotonic() - self._prefetch_thread_started_at\n+        if age > timeout * self._STALE_THREAD_MULTIPLIER:\n+            logger.debug(\n+                \"Honcho prefetch thread age %.1fs exceeds stale threshold \"\n+                \"%.1fs — treating as dead\", age, timeout * self._STALE_THREAD_MULTIPLIER,\n+            )\n+            return False\n+        return True\n+\n+    def _effective_cadence(self) -> int:\n+        \"\"\"Cadence plus empty-streak backoff, capped at _BACKOFF_MAX × base.\"\"\"\n+        if self._dialectic_empty_streak <= 0:\n+            return self._dialectic_cadence\n+        widened = self._dialectic_cadence + self._dialectic_empty_streak\n+        ceiling = self._dialectic_cadence * self._BACKOFF_MAX\n+        return min(widened, ceiling)\n+\n+    def liveness_snapshot(self) -> dict:\n+        \"\"\"In-process snapshot of dialectic liveness state for diagnostics.\n+\n+        Returns current turn, last successful dialectic turn, pending-result\n+        fire turn, empty streak, effective cadence, and thread status.\n+        \"\"\"\n+        thread_age = None\n+        if self._prefetch_thread and self._prefetch_thread.is_alive():\n+            thread_age = time.monotonic() - self._prefetch_thread_started_at\n+        return {\n+            \"turn_count\": self._turn_count,\n+            \"last_dialectic_turn\": self._last_dialectic_turn,\n+            \"pending_result_fired_at\": self._prefetch_result_fired_at,\n+            \"empty_streak\": self._dialectic_empty_streak,\n+            \"effective_cadence\": self._effective_cadence(),\n+            \"thread_alive\": thread_age is not None,\n+            \"thread_age_seconds\": thread_age,\n+        }\n+\n+    def _apply_reasoning_heuristic(self, base: str, query: str) -> str:\n+        \"\"\"Scale `base` up by query length, clamped at reasoning_level_cap.\n+\n+        Char-count heuristic: +1 at >=120 chars, +2 at >=400.\n+        \"\"\"\n+        if not self._reasoning_heuristic or not query:\n+            return base\n+        if base not in self._LEVEL_ORDER:\n+            return base\n+        n = len(query)\n+        if n < self._HEURISTIC_LENGTH_MEDIUM:\n+            bump = 0\n+        elif n < self._HEURISTIC_LENGTH_HIGH:\n+            bump = 1\n+        else:\n+            bump = 2\n+        base_idx = self._LEVEL_ORDER.index(base)\n+        cap_idx = self._LEVEL_ORDER.index(self._reasoning_level_cap)\n+        return self._LEVEL_ORDER[min(base_idx + bump, cap_idx)]\n+\n+    def _resolve_pass_level(self, pass_idx: int, query: str = \"\") -> str:\n         \"\"\"Resolve reasoning level for a given pass index.\n \n-        Uses dialecticDepthLevels if configured, otherwise proportional\n-        defaults relative to dialecticReasoningLevel.\n+        Precedence:\n+          1. dialecticDepthLevels (explicit per-pass) — wins absolutely\n+          2. _PROPORTIONAL_LEVELS table (depth>1 lighter-early passes)\n+          3. Base level = dialecticReasoningLevel, optionally scaled by the\n+             reasoning heuristic when the mapping falls through to 'base'\n         \"\"\"\n         if self._dialectic_depth_levels and pass_idx < len(self._dialectic_depth_levels):\n             return self._dialectic_depth_levels[pass_idx]","@@ -704,7 +880,7 @@ def _resolve_pass_level(self, pass_idx: int) -> str:\n         base = (self._config.dialectic_reasoning_level if self._config else \"low\")\n         mapping = self._PROPORTIONAL_LEVELS.get((self._dialectic_depth, pass_idx))\n         if mapping is None or mapping == \"base\":\n-            return base\n+            return self._apply_reasoning_heuristic(base, query)\n         return mapping\n \n     def _build_dialectic_prompt(self, pass_idx: int, prior_results: list[str], is_cold: bool) -> str:","@@ -791,7 +967,7 @@ def _run_dialectic_depth(self, query: str) -> str:\n                     break\n                 prompt = self._build_dialectic_prompt(i, results, is_cold)\n \n-            level = self._resolve_pass_level(i)\n+            level = self._resolve_pass_level(i, query=query)\n             logger.debug(\"Honcho dialectic depth %d: pass %d, level=%s, cold=%s\",\n                          self._dialectic_depth, i, level, is_cold)\n ","@@ -808,6 +984,29 @@ def _run_dialectic_depth(self, query: str) -> str:\n                 return r\n         return \"\"\n \n+    # Prompts that carry no semantic signal — trivial acknowledgements, slash\n+    # commands, empty input. Skipping injection here saves tokens and prevents\n+    # stale user-model context from derailing one-word replies.\n+    _TRIVIAL_PROMPT_RE = re.compile(\n+        r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|'\n+        r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)$',\n+        re.IGNORECASE,\n+    )\n+\n+    @classmethod\n+    def _is_trivial_prompt(cls, text: str) -> bool:\n+        \"\"\"Return True if the prompt is too trivial to warrant context injection.\"\"\"\n+        if not text:\n+            return True\n+        stripped = text.strip()\n+        if not stripped:\n+            return True\n+        if stripped.startswith(\"/\"):\n+            return True\n+        if cls._TRIVIAL_PROMPT_RE.match(stripped):\n+            return True\n+        return False\n+\n     def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:\n         \"\"\"Track turn count for cadence and injection_frequency logic.\"\"\"\n         self._turn_count = turn_number"]},{"file":"plugins/memory/honcho/cli.py","hunks":["@@ -460,17 +460,37 @@ def cmd_setup(args) -> None:\n             pass  # keep current\n \n     # --- 7b. Dialectic cadence ---\n-    current_dialectic = str(hermes_host.get(\"dialecticCadence\") or cfg.get(\"dialecticCadence\") or \"3\")\n+    current_dialectic = str(hermes_host.get(\"dialecticCadence\") or cfg.get(\"dialecticCadence\") or \"2\")\n     print(\"\\n  Dialectic cadence:\")\n     print(\"    How often Honcho rebuilds its user model (LLM call on Honcho backend).\")\n-    print(\"    1 = every turn (aggressive), 3 = every 3 turns (recommended), 5+ = sparse.\")\n+    print(\"    1 = every turn, 2 = every other turn, 3+ = sparser.\")\n+    print(\"    Recommended: 1-5.\")\n     new_dialectic = _prompt(\"Dialectic cadence\", default=current_dialectic)\n     try:\n         val = int(new_dialectic)\n         if val >= 1:\n             hermes_host[\"dialecticCadence\"] = val\n     except (ValueError, TypeError):\n-        hermes_host[\"dialecticCadence\"] = 3\n+        hermes_host[\"dialecticCadence\"] = 2\n+\n+    # --- 7c. Dialectic reasoning level ---\n+    current_reasoning = (\n+        hermes_host.get(\"dialecticReasoningLevel\")\n+        or cfg.get(\"dialecticReasoningLevel\")\n+        or \"low\"\n+    )\n+    print(\"\\n  Dialectic reasoning level:\")\n+    print(\"    Depth Honcho uses when synthesizing user context on auto-injected calls.\")\n+    print(\"    minimal  -- quick factual lookups\")\n+    print(\"    low      -- straightforward questions (default)\")\n+    print(\"    medium   -- multi-aspect synthesis\")\n+    print(\"    high     -- complex behavioral patterns\")\n+    print(\"    max      -- thorough audit-level analysis\")\n+    new_reasoning = _prompt(\"Reasoning level\", default=current_reasoning)\n+    if new_reasoning in (\"minimal\", \"low\", \"medium\", \"high\", \"max\"):\n+        hermes_host[\"dialecticReasoningLevel\"] = new_reasoning\n+    else:\n+        hermes_host[\"dialecticReasoningLevel\"] = \"low\"\n \n     # --- 8. Session strategy ---\n     current_strat = hermes_host.get(\"sessionStrategy\") or cfg.get(\"sessionStrategy\", \"per-session\")","@@ -636,8 +656,11 @@ def cmd_status(args) -> None:\n     print(f\"  Recall mode:    {hcfg.recall_mode}\")\n     print(f\"  Context budget: {hcfg.context_tokens or '(uncapped)'} tokens\")\n     raw = getattr(hcfg, \"raw\", None) or {}\n-    dialectic_cadence = raw.get(\"dialecticCadence\") or 3\n+    dialectic_cadence = raw.get(\"dialecticCadence\") or 1\n     print(f\"  Dialectic cad:  every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}\")\n+    reasoning_cap = raw.get(\"reasoningLevelCap\") or hcfg.reasoning_level_cap\n+    heuristic_on = \"on\" if hcfg.reasoning_heuristic else \"off\"\n+    print(f\"  Reasoning:      base={hcfg.dialectic_reasoning_level}, cap={reasoning_cap}, heuristic={heuristic_on}\")\n     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})\")\n     print(f\"  Write freq:     {hcfg.write_frequency}\")\n "]},{"file":"plugins/memory/honcho/client.py","hunks":["@@ -251,6 +251,11 @@ class HonchoClientConfig:\n     # matching dialectic_depth length. When None, uses proportional defaults\n     # derived from dialectic_reasoning_level.\n     dialectic_depth_levels: list[str] | None = None\n+    # When true, the auto-injected dialectic scales reasoning level up on\n+    # longer queries. See HonchoMemoryProvider for thresholds.\n+    reasoning_heuristic: bool = True\n+    # Ceiling for the heuristic-selected reasoning level.\n+    reasoning_level_cap: str = \"high\"\n     # Honcho API limits — configurable for self-hosted instances\n     # Max chars per message sent via add_messages() (Honcho cloud: 25000)\n     message_max_chars: int = 25000","@@ -446,6 +451,16 @@ def from_global_config(\n                 raw.get(\"dialecticDepthLevels\"),\n                 depth=_parse_dialectic_depth(host_block.get(\"dialecticDepth\"), raw.get(\"dialecticDepth\")),\n             ),\n+            reasoning_heuristic=_resolve_bool(\n+                host_block.get(\"reasoningHeuristic\"),\n+                raw.get(\"reasoningHeuristic\"),\n+                default=True,\n+            ),\n+            reasoning_level_cap=(\n+                host_block.get(\"reasoningLevelCap\")\n+                or raw.get(\"reasoningLevelCap\")\n+                or \"high\"\n+            ),\n             message_max_chars=int(\n                 host_block.get(\"messageMaxChars\")\n                 or raw.get(\"messageMaxChars\")"]},{"file":"plugins/memory/honcho/session.py","hunks":["@@ -78,6 +78,7 @@ def __init__(\n         honcho: Honcho | None = None,\n         context_tokens: int | None = None,\n         config: Any | None = None,\n+        runtime_user_peer_name: str | None = None,\n     ):\n         \"\"\"\n         Initialize the session manager.","@@ -87,10 +88,12 @@ def __init__(\n             context_tokens: Max tokens for context() calls (None = Honcho default).\n             config: HonchoClientConfig from global config (provides peer_name, ai_peer,\n                     write_frequency, observation, etc.).\n+            runtime_user_peer_name: Gateway user identity for per-user memory scoping.\n         \"\"\"\n         self._honcho = honcho\n         self._context_tokens = [redacted:secret-assignment]\n         self._config = config\n+        self._runtime_user_peer_name = runtime_user_peer_name\n         self._cache: dict[str, HonchoSession] = {}\n         self._peers_cache: dict[str, Any] = {}\n         self._sessions_cache: dict[str, Any] = {}","@@ -100,9 +103,11 @@ def __init__(\n         self._write_frequency = write_frequency\n         self._turn_counter: int = 0\n \n-        # Prefetch caches: session_key → last result (consumed once per turn)\n+        # Prefetch cache: session_key → last context result (consumed once per turn).\n+        # Dialectic results are cached on the plugin side (HonchoMemoryProvider\n+        # ._prefetch_result) so session-start prewarm and turn-driven fires share\n+        # one source of truth; see __init__.py _do_session_init for the prewarm.\n         self._context_cache: dict[str, dict] = {}\n-        self._dialectic_cache: dict[str, str] = {}\n         self._prefetch_cache_lock = threading.Lock()\n         self._dialectic_reasoning_level: str = (\n             config.dialectic_reasoning_level if config else \"low\"","@@ -272,8 +277,10 @@ def get_or_create(self, key: str) -> HonchoSession:\n             logger.debug(\"Local session cache hit: %s\", key)\n             return self._cache[key]\n \n-        # Use peer names from global config when available\n-        if self._config and self._config.peer_name:\n+        # Gateway sessions should use the runtime user identity when available.\n+        if self._runtime_user_peer_name:\n+            user_peer_id = self._sanitize_id(self._runtime_user_peer_name)\n+        elif self._config and self._config.peer_name:\n             user_peer_id = self._sanitize_id(self._config.peer_name)\n         else:\n             # Fallback: derive from session key","@@ -499,8 +506,8 @@ def dialectic_query(\n         Query Honcho's dialectic endpoint about a peer.\n \n         Runs an LLM on Honcho's backend against the target peer's full\n-        representation. Higher latency than context() — call async via\n-        prefetch_dialectic() to avoid blocking the response.\n+        representation. Higher latency than context() — callers run this in\n+        a background thread (see HonchoMemoryProvider) to avoid blocking.\n \n         Args:\n             session_key: The session key to query against.","@@ -555,42 +562,6 @@ def dialectic_query(\n             logger.warning(\"Honcho dialectic query failed: %s\", e)\n             return \"\"\n \n-    def prefetch_dialectic(self, session_key: str, query: str) -> None:\n-        \"\"\"\n-        Fire a dialectic_query in a background thread, caching the result.\n-\n-        Non-blocking. The result is available via pop_dialectic_result()\n-        on the next call (typically the following turn). Reasoning level\n-        is selected dynamically based on query complexity.\n-\n-        Args:\n-            session_key: The session key to query against.\n-            query: The user's current message, used as the query.\n-        \"\"\"\n-        def _run():\n-            result = self.dialectic_query(session_key, query)\n-            if result:\n-                self.set_dialectic_result(session_key, result)\n-\n-        t = threading.Thread(target=_run, name=\"honcho-dialectic-prefetch\", daemon=True)\n-        t.start()\n-\n-    def set_dialectic_result(self, session_key: str, result: str) -> None:\n-        \"\"\"Store a prefetched dialectic result in a thread-safe way.\"\"\"\n-        if not result:\n-            return\n-        with self._prefetch_cache_lock:\n-            self._dialectic_cache[session_key] = result\n-\n-    def pop_dialectic_result(self, session_key: str) -> str:\n-        \"\"\"\n-        Return and clear the cached dialectic result for this session.\n-\n-        Returns empty string if no result is ready yet.\n-        \"\"\"\n-        with self._prefetch_cache_lock:\n-            return self._dialectic_cache.pop(session_key, \"\")\n-\n     def prefetch_context(self, session_key: str, user_message: str | None = None) -> None:\n         \"\"\"\n         Fire get_prefetch_context in a background thread, caching the result."]},{"file":"run_agent.py","hunks":["@@ -1306,31 +1306,6 @@ def __init__(\n             try:\n                 _mem_provider_name = mem_config.get(\"provider\", \"\") if mem_config else \"\"\n \n-                # Auto-migrate: if Honcho was actively configured (enabled +\n-                # credentials) but memory.provider is not set, activate the\n-                # honcho plugin automatically.  Just having the config file\n-                # is not enough — the user may have disabled Honcho or the\n-                # file may be from a different tool.\n-                if not _mem_provider_name:\n-                    try:\n-                        from plugins.memory.honcho.client import HonchoClientConfig as _HCC\n-                        _hcfg = _HCC.from_global_config()\n-                        if _hcfg.enabled and (_hcfg.api_key or _hcfg.base_url):\n-                            _mem_provider_name = \"honcho\"\n-                            # Persist so this only auto-migrates once\n-                            try:\n-                                from hermes_cli.config import load_config as _lc, save_config as _sc\n-                                _cfg = _lc()\n-                                _cfg.setdefault(\"memory\", {})[\"provider\"] = \"honcho\"\n-                                _sc(_cfg)\n-                            except Exception:\n-                                pass\n-                            if not self.quiet_mode:\n-                                print(\"  ✓ Auto-migrated Honcho to memory provider plugin.\")\n-                                print(\"    Your config and data are preserved.\\n\")\n-                    except Exception:\n-                        pass\n-\n                 if _mem_provider_name:\n                     from agent.memory_manager import MemoryManager as _MemoryManager\n                     from plugins.memory import load_memory_provider as _load_mem"]},{"file":"scripts/release.py","hunks":["@@ -215,6 +215,7 @@\n     \"[redacted:email]\": \"ziliangpeng\",\n     \"[redacted:email]\": \"centripetal-star\",\n     \"[redacted:email]\": \"LeonSGP43\",\n+    \"[redacted:email]\": \"LeonSGP43\",\n     \"[redacted:email]\": \"Lubrsy706\",\n     \"[redacted:email]\": \"spniyant\",\n     \"[redacted:email]\": \"olafthiele\","]},{"file":"tests/agent/test_memory_provider.py","hunks":["@@ -971,8 +971,6 @@ def test_queue_prefetch_respects_dialectic_cadence(self):\n         class FakeManager:\n             def prefetch_context(self, key, query=None):\n                 pass\n-            def prefetch_dialectic(self, key, query):\n-                pass\n \n         p._manager = FakeManager()\n "]},{"file":"tests/agent/test_memory_user_id.py","hunks":["@@ -208,34 +208,81 @@ def test_different_users_get_different_ids(self):\n \n \n class TestHonchoUserIdScoping:\n-    \"\"\"Verify Honcho plugin uses gateway user_id for peer_name when provided.\"\"\"\n+    \"\"\"Verify Honcho plugin keeps runtime user scoping separate from config peer_name.\"\"\"\n \n-    def test_gateway_user_id_overrides_peer_name(self):\n-        \"\"\"When user_id is in kwargs and no explicit peer_name, user_id should be used.\"\"\"\n+    def test_gateway_user_id_is_passed_as_runtime_peer(self):\n+        \"\"\"Gateway user_id should scope Honcho sessions without mutating config peer_name.\"\"\"\n         from plugins.memory.honcho import HonchoMemoryProvider\n \n         provider = HonchoMemoryProvider()\n \n-        # Create a mock config with NO explicit peer_name\n         mock_cfg = MagicMock()\n         mock_cfg.enabled = True\n         mock_cfg.api_key = \"[redacted:secret-assignment]\"\n         mock_cfg.base_url = None\n-        mock_cfg.peer_name = \"\"  # No explicit peer_name — user_id should fill it\n-        mock_cfg.recall_mode = \"tools\"  # Use tools mode to defer session init\n+        mock_cfg.peer_name = \"static-user\"\n+        mock_cfg.recall_mode = \"context\"\n+        mock_cfg.context_tokens = None\n+        mock_cfg.raw = {}\n+        mock_cfg.dialectic_depth = 1\n+        mock_cfg.dialectic_depth_levels = None\n+        mock_cfg.init_on_session_start = False\n+        mock_cfg.ai_peer = \"hermes\"\n+        mock_cfg.resolve_session_name.return_value = \"test-sess\"\n+        mock_cfg.session_strategy = \"shared\"\n \n         with patch(\n             \"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\",\n             return_value=mock_cfg,\n-        ):\n+        ), patch(\n+            \"plugins.memory.honcho.client.get_honcho_client\",\n+            return_value=MagicMock(),\n+        ), patch(\n+            \"plugins.memory.honcho.session.HonchoSessionManager\",\n+        ) as mock_manager_cls:\n+            mock_manager = MagicMock()\n+            mock_manager.get_or_create.return_value = MagicMock(messages=[])\n+            mock_manager_cls.return_value = mock_manager\n             provider.initialize(\n                 session_id=\"test-sess\",\n                 user_id=\"discord_user_789\",\n                 platform=\"discord\",\n             )\n \n-        # The config's peer_name should have been overridden with the user_id\n-        assert mock_cfg.peer_name == \"discord_user_789\"\n+        assert mock_cfg.peer_name == \"static-user\"\n+        assert mock_manager_cls.call_args.kwargs[\"runtime_user_peer_name\"] == \"discord_user_789\"\n+\n+    def test_session_manager_prefers_runtime_user_id_over_config_peer_name(self):\n+        \"\"\"Session manager should isolate gateway users even when config peer_name is static.\"\"\"\n+        from plugins.memory.honcho.session import HonchoSessionManager\n+\n+        mock_cfg = MagicMock()\n+        mock_cfg.peer_name = \"static-user\"\n+        mock_cfg.ai_peer = \"hermes\"\n+        mock_cfg.write_frequency = \"sync\"\n+        mock_cfg.dialectic_reasoning_level = \"low\"\n+        mock_cfg.dialectic_dynamic = True\n+        mock_cfg.dialectic_max_chars = 600\n+        mock_cfg.observation_mode = \"directional\"\n+        mock_cfg.user_observe_me = True\n+        mock_cfg.user_observe_others = True\n+        mock_cfg.ai_observe_me = True\n+        mock_cfg.ai_observe_others = True\n+\n+        manager = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=mock_cfg,\n+            runtime_user_peer_name=\"discord_user_789\",\n+        )\n+\n+        with patch.object(manager, \"_get_or_create_peer\", return_value=MagicMock()), patch.object(\n+            manager,\n+            \"_get_or_create_honcho_session\",\n+            return_value=(MagicMock(), []),\n+        ):\n+            session = manager.get_or_create(\"discord:channel-1\")\n+\n+        assert session.user_peer_id == \"discord_user_789\"\n \n     def test_no_user_id_preserves_config_peer_name(self):\n         \"\"\"Without user_id, the config peer_name should be preserved.\"\"\""]},{"file":"tests/honcho_plugin/test_async_memory.py","hunks":["@@ -460,10 +460,3 @@ def test_set_and_pop_context_result(self):\n         assert mgr.pop_context_result(\"cli:test\") == payload\n         assert mgr.pop_context_result(\"cli:test\") == {}\n \n-    def test_set_and_pop_dialectic_result(self):\n-        mgr = _make_manager(write_frequency=\"turn\")\n-\n-        mgr.set_dialectic_result(\"cli:test\", \"Resume with toolset cleanup\")\n-\n-        assert mgr.pop_dialectic_result(\"cli:test\") == \"Resume with toolset cleanup\"\n-        assert mgr.pop_dialectic_result(\"cli:test\") == \"\""]},{"file":"tests/honcho_plugin/test_cli.py","hunks":["@@ -26,6 +26,9 @@ class FakeConfig:\n             write_frequency = \"async\"\n             session_strategy = \"per-session\"\n             context_tokens = 800\n+            dialectic_reasoning_level = \"low\"\n+            reasoning_level_cap = \"high\"\n+            reasoning_heuristic = True\n \n             def resolve_session_name(self):\n                 return \"hermes\""]},{"file":"tests/honcho_plugin/test_session.py","hunks":["@@ -568,15 +568,15 @@ def _make_provider_with_config(self, recall_mode=\"tools\", init_on_session_start=\n \n         with patch(\"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\", return_value=cfg), \\\n              patch(\"plugins.memory.honcho.client.get_honcho_client\", return_value=MagicMock()), \\\n-             patch(\"plugins.memory.honcho.session.HonchoSessionManager\", return_value=mock_manager), \\\n+             patch(\"plugins.memory.honcho.session.HonchoSessionManager\", return_value=mock_manager) as mock_manager_cls, \\\n              patch(\"hermes_constants.get_hermes_home\", return_value=MagicMock()):\n             provider.initialize(session_id=\"test-session-001\", **init_kwargs)\n \n-        return provider, cfg\n+        return provider, cfg, mock_manager_cls\n \n     def test_tools_lazy_default(self):\n         \"\"\"tools + initOnSessionStart=false → session NOT initialized after initialize().\"\"\"\n-        provider, _ = self._make_provider_with_config(\n+        provider, _, _ = self._make_provider_with_config(\n             recall_mode=\"tools\", init_on_session_start=False,\n         )\n         assert provider._session_initialized is False","@@ -585,7 +585,7 @@ def test_tools_lazy_default(self):\n \n     def test_tools_eager_init(self):\n         \"\"\"tools + initOnSessionStart=true → session IS initialized after initialize().\"\"\"\n-        provider, _ = self._make_provider_with_config(\n+        provider, _, _ = self._make_provider_with_config(\n             recall_mode=\"tools\", init_on_session_start=True,\n         )\n         assert provider._session_initialized is True","@@ -593,33 +593,34 @@ def test_tools_eager_init(self):\n \n     def test_tools_eager_prefetch_still_empty(self):\n         \"\"\"tools mode with eager init still returns empty from prefetch() (no auto-injection).\"\"\"\n-        provider, _ = self._make_provider_with_config(\n+        provider, _, _ = self._make_provider_with_config(\n             recall_mode=\"tools\", init_on_session_start=True,\n         )\n         assert provider.prefetch(\"test query\") == \"\"\n \n     def test_tools_lazy_prefetch_empty(self):\n         \"\"\"tools mode with lazy init also returns empty from prefetch().\"\"\"\n-        provider, _ = self._make_provider_with_config(\n+        provider, _, _ = self._make_provider_with_config(\n             recall_mode=\"tools\", init_on_session_start=False,\n         )\n         assert provider.prefetch(\"test query\") == \"\"\n \n     def test_explicit_peer_name_not_overridden_by_user_id(self):\n         \"\"\"Explicit peerName in config must not be replaced by gateway user_id.\"\"\"\n-        _, cfg = self._make_provider_with_config(\n+        _, cfg, _ = self._make_provider_with_config(\n             recall_mode=\"tools\", init_on_session_start=True,\n             peer_name=\"Kathie\", user_id=\"8439114563\",\n         )\n         assert cfg.peer_name == \"Kathie\"\n \n     def test_user_id_used_when_no_peer_name(self):\n-        \"\"\"Gateway user_id is used as peer_name when no explicit peerName configured.\"\"\"\n-        _, cfg = self._make_provider_with_config(\n+        \"\"\"Gateway user_id is passed separately from config peer_name.\"\"\"\n+        _, cfg, mock_manager_cls = self._make_provider_with_config(\n             recall_mode=\"tools\", init_on_session_start=True,\n             peer_name=None, user_id=\"8439114563\",\n         )\n-        assert cfg.peer_name == \"8439114563\"\n+        assert cfg.peer_name is None\n+        assert mock_manager_cls.call_args.kwargs[\"runtime_user_peer_name\"] == \"8439114563\"\n \n \n class TestPerSessionMigrateGuard:","@@ -815,6 +816,27 @@ def test_long_query_truncated(self):\n # ---------------------------------------------------------------------------\n \n \n+def _settle_prewarm(provider):\n+    \"\"\"Wait for the session-start prewarm dialectic thread, then return the\n+    provider to a clean 'nothing fired yet' state so cadence/first-turn/\n+    trivial-prompt tests can assert from a known baseline.\"\"\"\n+    if provider._prefetch_thread:\n+        provider._prefetch_thread.join(timeout=3.0)\n+    with provider._prefetch_lock:\n+        provider._prefetch_result = \"\"\n+        provider._prefetch_result_fired_at = -999\n+    provider._prefetch_thread = None\n+    provider._prefetch_thread_started_at = 0.0\n+    provider._last_dialectic_turn = -999\n+    provider._dialectic_empty_streak = 0\n+    if getattr(provider, \"_manager\", None) is not None:\n+        try:\n+            provider._manager.dialectic_query.reset_mock()\n+            provider._manager.prefetch_context.reset_mock()\n+        except AttributeError:\n+            pass\n+\n+\n class TestDialecticCadenceDefaults:\n     \"\"\"Regression tests for dialectic_cadence default value.\"\"\"\n ","@@ -840,12 +862,15 @@ def _make_provider(cfg_extra=None):\n              patch(\"hermes_constants.get_hermes_home\", return_value=MagicMock()):\n             provider.initialize(session_id=\"test-session-001\")\n \n+        _settle_prewarm(provider)\n         return provider\n \n-    def test_default_is_3(self):\n-        \"\"\"Default dialectic_cadence should be 3 to avoid per-turn LLM calls.\"\"\"\n+    def test_unset_falls_back_to_1(self):\n+        \"\"\"Unset dialecticCadence falls back to 1 (every turn) for backwards\n+        compatibility with existing configs that predate the setting. The\n+        setup wizard writes 2 explicitly on new configs.\"\"\"\n         provider = self._make_provider()\n-        assert provider._dialectic_cadence == 3\n+        assert provider._dialectic_cadence == 1\n \n     def test_config_override(self):\n         \"\"\"dialecticCadence from config overrides the default.\"\"\"","@@ -908,6 +933,7 @@ def _make_provider(cfg_extra=None):\n              patch(\"hermes_constants.get_hermes_home\", return_value=MagicMock()):\n             provider.initialize(session_id=\"test-session-001\")\n \n+        _settle_prewarm(provider)\n         return provider\n \n     def test_default_depth_is_1(self):","@@ -1027,60 +1053,598 @@ def test_run_dialectic_depth_two_passes(self):\n         assert provider._manager.dialectic_query.call_count == 2\n         assert \"Synthesis\" in result\n \n-    def test_first_turn_runs_dialectic_synchronously(self):\n-        \"\"\"First turn should fire the dialectic synchronously (cold start).\"\"\"\n-        from unittest.mock import MagicMock, patch\n-        provider = self._make_provider(cfg_extra={\"dialectic_depth\": 1})\n+    def test_run_dialectic_depth_bails_early_on_strong_signal(self):\n+        \"\"\"Depth 2 skips pass 1 when pass 0 returns strong signal.\"\"\"\n+        from unittest.mock import MagicMock\n+        provider = self._make_provider(cfg_extra={\"dialectic_depth\": 2})\n         provider._manager = MagicMock()\n-        provider._manager.dialectic_query.return_value = \"cold start synthesis\"\n-        provider._manager.get_prefetch_context.return_value = None\n-        provider._manager.pop_context_result.return_value = None\n+        provider._manager.dialectic_query.return_value = (\n+            \"## Full Assessment\\n- Strong structured response\\n- With evidence\\n\" + \"x\" * 200\n+        )\n         provider._session_key = \"test\"\n-        provider._base_context_cache = \"\"  # cold start\n-        provider._last_dialectic_turn = -999  # never fired\n+        provider._base_context_cache = \"existing context\"\n \n-        result = provider.prefetch(\"hello world\")\n-        assert \"cold start synthesis\" in result\n+        result = provider._run_dialectic_depth(\"test query\")\n+        # Only 1 call because pass 0 had sufficient signal\n         assert provider._manager.dialectic_query.call_count == 1\n-        # After first-turn sync, _last_dialectic_turn should be updated\n-        assert provider._last_dialectic_turn != -999\n \n-    def test_first_turn_dialectic_does_not_double_fire(self):\n-        \"\"\"After first-turn sync dialectic, queue_prefetch should skip (cadence).\"\"\"\n-        from unittest.mock import MagicMock\n-        provider = self._make_provider(cfg_extra={\"dialectic_depth\": 1})\n-        provider._manager = MagicMock()\n-        provider._manager.dialectic_query.return_value = \"cold start synthesis\"\n-        provider._manager.get_prefetch_context.return_value = None\n-        provider._manager.pop_context_result.return_value = None\n+\n+# ---------------------------------------------------------------------------\n+# Trivial-prompt heuristic + dialectic cadence silent-failure guards\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestTrivialPromptHeuristic:\n+    \"\"\"Trivial prompts ('ok', 'y', slash commands) must short-circuit injection.\"\"\"\n+\n+    @staticmethod\n+    def _make_provider():\n+        from unittest.mock import patch, MagicMock\n+        from plugins.memory.honcho.client import HonchoClientConfig\n+\n+        cfg = HonchoClientConfig(api_key=\"[redacted:secret-assignment]\", enabled=True, recall_mode=\"hybrid\")\n+        provider = HonchoMemoryProvider()\n+        mock_manager = MagicMock()\n+        mock_session = MagicMock()\n+        mock_session.messages = []\n+        mock_manager.get_or_create.return_value = mock_session\n+\n+        with patch(\"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\", return_value=cfg), \\\n+             patch(\"plugins.memory.honcho.client.get_honcho_client\", return_value=MagicMock()), \\\n+             patch(\"plugins.memory.honcho.session.HonchoSessionManager\", return_value=mock_manager), \\\n+             patch(\"hermes_constants.get_hermes_home\", return_value=MagicMock()):\n+            provider.initialize(session_id=\"test-session-trivial\")\n+        _settle_prewarm(provider)\n+        return provider\n+\n+    def test_classifier_catches_common_trivial_forms(self):\n+        for t in (\"ok\", \"OK\", \" ok \", \"y\", \"yes\", \"sure\", \"thanks\", \"lgtm\", \"/help\", \"\", \"   \"):\n+            assert HonchoMemoryProvider._is_trivial_prompt(t), f\"expected trivial: {t!r}\"\n+\n+    def test_classifier_lets_substantive_prompts_through(self):\n+        for t in (\"hello world\", \"what's my name\", \"explain this\", \"ok so what's next\"):\n+            assert not HonchoMemoryProvider._is_trivial_prompt(t), f\"expected non-trivial: {t!r}\"\n+\n+    def test_prefetch_skips_on_trivial_prompt(self):\n+        provider = self._make_provider()\n         provider._session_key = \"test\"\n-        provider._base_context_cache = \"\"\n-        provider._last_dialectic_turn = -999\n-        provider._turn_count = 0\n+        provider._base_context_cache = \"cached base\"\n+        provider._last_dialectic_turn = 0\n+        provider._turn_count = 5\n \n-        # First turn fires sync dialectic\n-        provider.prefetch(\"hello\")\n-        assert provider._manager.dialectic_query.call_count == 1\n+        assert provider.prefetch(\"ok\") == \"\"\n+        assert provider.prefetch(\"/help\") == \"\"\n+        # Dialectic should not have fired\n+        assert provider._manager.dialectic_query.call_count == 0\n \n-        # Now queue_prefetch on same turn should skip (cadence: 0 - 0 < 3)\n+    def test_queue_prefetch_skips_on_trivial_prompt(self):\n+        provider = self._make_provider()\n+        provider._session_key = \"test\"\n+        provider._turn_count = 10\n+        provider._last_dialectic_turn = -999  # would otherwise fire\n+        # initialize() pre-warms; clear call counts before the assertion.\n+        provider._manager.prefetch_context.reset_mock()\n         provider._manager.dialectic_query.reset_mock()\n-        provider.queue_prefetch(\"hello\")\n+\n+        provider.queue_prefetch(\"y\")\n+        # Trivial prompts short-circuit both context refresh and dialectic fire.\n+        assert provider._manager.prefetch_context.call_count == 0\n         assert provider._manager.dialectic_query.call_count == 0\n \n-    def test_run_dialectic_depth_bails_early_on_strong_signal(self):\n-        \"\"\"Depth 2 skips pass 1 when pass 0 returns strong signal.\"\"\"\n-        from unittest.mock import MagicMock\n-        provider = self._make_provider(cfg_extra={\"dialectic_depth\": 2})\n-        provider._manager = MagicMock()\n-        provider._manager.dialectic_query.return_value = (\n-            \"## Full Assessment\\n- Strong structured response\\n- With evidence\\n\" + \"x\" * 200\n+\n+class TestDialecticCadenceAdvancesOnSuccess:\n+    \"\"\"Cadence tracker advances only when the dialectic call returns a\n+    non-empty result. Empty results (transient API error, sparse representation)\n+    must retry on the next eligible turn instead of waiting the full cadence.\"\"\"\n+\n+    @staticmethod\n+    def _make_provider():\n+        from unittest.mock import patch, MagicMock\n+        from plugins.memory.honcho.client import HonchoClientConfig\n+\n+        cfg = HonchoClientConfig(\n+            api_key=\"[redacted:secret-assignment]\", enabled=True, recall_mode=\"hybrid\", dialectic_depth=1,\n         )\n+        provider = HonchoMemoryProvider()\n+        mock_manager = MagicMock()\n+        mock_session = MagicMock()\n+        mock_session.messages = []\n+        mock_manager.get_or_create.return_value = mock_session\n+\n+        with patch(\"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\", return_value=cfg), \\\n+             patch(\"plugins.memory.honcho.client.get_honcho_client\", return_value=MagicMock()), \\\n+             patch(\"plugins.memory.honcho.session.HonchoSessionManager\", return_value=mock_manager), \\\n+             patch(\"hermes_constants.get_hermes_home\", return_value=MagicMock()):\n+            provider.initialize(session_id=\"test-session-retry\")\n+        _settle_prewarm(provider)\n+        return provider\n+\n+    def test_empty_dialectic_result_does_not_advance_cadence(self):\n+        import time as _time\n+        provider = self._make_provider()\n         provider._session_key = \"test\"\n-        provider._base_context_cache = \"existing context\"\n+        provider._manager.dialectic_query.return_value = \"\"  # silent failure\n+        provider._turn_count = 5\n+        provider._last_dialectic_turn = 0  # would fire (5 - 0 = 5 ≥ 3)\n \n-        result = provider._run_dialectic_depth(\"test query\")\n-        # Only 1 call because pass 0 had sufficient signal\n+        provider.queue_prefetch(\"hello\")\n+        # wait for the background thread to settle\n+        if provider._prefetch_thread:\n+            provider._prefetch_thread.join(timeout=2.0)\n+\n+        # Dialectic call was attempted\n         assert provider._manager.dialectic_query.call_count == 1\n+        # But cadence tracker did NOT advance — next turn should retry\n+        assert provider._last_dialectic_turn == 0\n+\n+    def test_non_empty_dialectic_result_advances_cadence(self):\n+        provider = self._make_provider()\n+        provider._session_key = \"test\"\n+        provider._manager.dialectic_query.return_value = \"real synthesis output\"\n+        provider._turn_count = 5\n+        provider._last_dialectic_turn = 0\n+\n+        provider.queue_prefetch(\"hello\")\n+        if provider._prefetch_thread:\n+            provider._prefetch_thread.join(timeout=2.0)\n+\n+        assert provider._last_dialectic_turn == 5\n+\n+    def test_in_flight_thread_is_not_stacked(self):\n+        import threading as _threading\n+        import time as _time\n+        provider = self._make_provider()\n+        provider._session_key = \"test\"\n+        provider._turn_count = 10\n+        provider._last_dialectic_turn = 0\n+\n+        # Simulate a prior thread still running (fresh, not stale)\n+        hold = _threading.Event()\n+\n+        def _block():\n+            hold.wait(timeout=5.0)\n+\n+        fresh = _threading.Thread(target=_block, daemon=True)\n+        fresh.start()\n+        provider._prefetch_thread = fresh\n+        provider._prefetch_thread_started_at = _time.monotonic()  # fresh start\n+\n+        provider.queue_prefetch(\"hello\")\n+        # Should have short-circuited — no new dialectic call\n+        assert provider._manager.dialectic_query.call_count == 0\n+        hold.set()\n+        fresh.join(timeout=2.0)\n+\n+\n+class TestSessionStartDialecticPrewarm:\n+    \"\"\"Session-start prewarm fires a depth-aware dialectic whose result is\n+    consumed by turn 1 — no duplicate .chat() and no dead-cache orphaning.\"\"\"\n+\n+    @staticmethod\n+    def _make_provider(cfg_extra=None, dialectic_result=\"prewarm synthesis\"):\n+        from unittest.mock import patch, MagicMock\n+        from plugins.memory.honcho.client import HonchoClientConfig\n+\n+        defaults = dict(api_key=\"[redacted:secret-assignment]\", enabled=True, recall_mode=\"hybrid\")\n+        if cfg_extra:\n+            defaults.update(cfg_extra)\n+        cfg = HonchoClientConfig(**defaults)\n+        provider = HonchoMemoryProvider()\n+        mock_manager = MagicMock()\n+        mock_manager.get_or_create.return_value = MagicMock(messages=[])\n+        mock_manager.get_prefetch_context.return_value = None\n+        mock_manager.pop_context_result.return_value = None\n+        mock_manager.dialectic_query.return_value = dialectic_result\n+\n+        with patch(\"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\", return_value=cfg), \\\n+             patch(\"plugins.memory.honcho.client.get_honcho_client\", return_value=MagicMock()), \\\n+             patch(\"plugins.memory.honcho.session.HonchoSessionManager\", return_value=mock_manager), \\\n+             patch(\"hermes_constants.get_hermes_home\", return_value=MagicMock()):\n+            provider.initialize(session_id=\"test-prewarm\")\n+        return provider\n+\n+    def test_prewarm_populates_prefetch_result(self):\n+        p = self._make_provider()\n+        # Wait for prewarm thread to land\n+        if p._prefetch_thread:\n+            p._prefetch_thread.join(timeout=3.0)\n+        with p._prefetch_lock:\n+            assert p._prefetch_result == \"prewarm synthesis\"\n+        assert p._last_dialectic_turn == 0\n+\n+    def test_turn1_consumes_prewarm_without_duplicate_dialectic(self):\n+        \"\"\"With prewarm result already in _prefetch_result, turn 1 prefetch\n+        should NOT fire another dialectic.\"\"\"\n+        p = self._make_provider()\n+        if p._prefetch_thread:\n+            p._prefetch_thread.join(timeout=3.0)\n+        p._manager.dialectic_query.reset_mock()\n+        p._session_key = \"test-prewarm\"\n+        p._base_context_cache = \"\"\n+        p._turn_count = 1\n+\n+        result = p.prefetch(\"hello world\")\n+        assert \"prewarm synthesis\" in result\n+        # The sync first-turn path must NOT have fired another .chat()\n+        assert p._manager.dialectic_query.call_count == 0\n+\n+    def test_turn1_falls_back_to_sync_when_prewarm_missing(self):\n+        \"\"\"If the prewarm produced nothing (empty graph, API blip), turn 1\n+        still fires its own sync dialectic.\"\"\"\n+        p = self._make_provider(dialectic_result=\"\")  # prewarm returns empty\n+        if p._prefetch_thread:\n+            p._prefetch_thread.join(timeout=3.0)\n+        with p._prefetch_lock:\n+            assert p._prefetch_result == \"\"  # prewarm landed nothing\n+        # Switch dialectic_query to return something on the sync first-turn call\n+        p._manager.dialectic_query.return_value = \"sync recovery\"\n+        p._manager.dialectic_query.reset_mock()\n+        p._session_key = \"test-prewarm\"\n+        p._base_context_cache = \"\"\n+        p._turn_count = 1\n+\n+        result = p.prefetch(\"hello world\")\n+        assert \"sync recovery\" in result\n+        assert p._manager.dialectic_query.call_count == 1\n+\n+\n+class TestDialecticLiveness:\n+    \"\"\"Liveness + observability: stale-thread recovery, stale-result discard,\n+    empty-streak backoff, and the snapshot method used for diagnostics.\"\"\"\n+\n+    @staticmethod\n+    def _make_provider(cfg_extra=None):\n+        from unittest.mock import patch, MagicMock\n+        from plugins.memory.honcho.client import HonchoClientConfig\n+\n+        defaults = dict(api_key=\"[redacted:secret-assignment]\", enabled=True, recall_mode=\"hybrid\", timeout=2.0)\n+        if cfg_extra:\n+            defaults.update(cfg_extra)\n+        cfg = HonchoClientConfig(**defaults)\n+        provider = HonchoMemoryProvider()\n+        mock_manager = MagicMock()\n+        mock_manager.get_or_create.return_value = MagicMock(messages=[])\n+        mock_manager.get_prefetch_context.return_value = None\n+        mock_manager.pop_context_result.return_value = None\n+        mock_manager.dialectic_query.return_value = \"\"  # default: silent\n+\n+        with patch(\"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\", return_value=cfg), \\\n+             patch(\"plugins.memory.honcho.client.get_honcho_client\", return_value=MagicMock()), \\\n+             patch(\"plugins.memory.honcho.session.HonchoSessionManager\", return_value=mock_manager), \\\n+             patch(\"hermes_constants.get_hermes_home\", return_value=MagicMock()):\n+            provider.initialize(session_id=\"test-liveness\")\n+        _settle_prewarm(provider)\n+        return provider\n+\n+    def test_stale_thread_is_treated_as_dead(self):\n+        \"\"\"A thread older than timeout × multiplier no longer blocks new fires.\"\"\"\n+        import threading as _threading\n+        p = self._make_provider()\n+        p._session_key = \"test\"\n+        p._turn_count = 10\n+        p._last_dialectic_turn = 0\n+        p._manager.dialectic_query.return_value = \"fresh synthesis\"\n+\n+        # Plant an alive thread with an old timestamp (stale)\n+        hold = _threading.Event()\n+        stuck = _threading.Thread(target=lambda: hold.wait(timeout=10.0), daemon=True)\n+        stuck.start()\n+        p._prefetch_thread = stuck\n+        # timeout=2.0, multiplier=2.0, so anything older than 4s is stale\n+        p._prefetch_thread_started_at = 0.0  # very old (1970 monotonic baseline)\n+\n+        p.queue_prefetch(\"hello\")\n+        # New thread should have been spawned since stuck one is stale\n+        assert p._prefetch_thread is not stuck, \"stale thread must be recycled\"\n+        if p._prefetch_thread:\n+            p._prefetch_thread.join(timeout=2.0)\n+        assert p._manager.dialectic_query.call_count == 1\n+        hold.set()\n+        stuck.join(timeout=2.0)\n+\n+    def test_stale_pending_result_is_discarded_on_read(self):\n+        \"\"\"A pending dialectic result from many turns ago is discarded\n+        instead of injected against a fresh conversational pivot.\"\"\"\n+        p = self._make_provider(cfg_extra={\"raw\": {\"dialecticCadence\": 2}})\n+        p._session_key = \"test\"\n+        p._base_context_cache = \"base ctx\"\n+        with p._prefetch_lock:\n+            p._prefetch_result = \"ancient synthesis\"\n+            p._prefetch_result_fired_at = 1\n+        # cadence=2, multiplier=2 → stale after 4 turns since fire\n+        p._turn_count = 10\n+        p._last_dialectic_turn = 1  # prevents sync first-turn path\n+\n+        result = p.prefetch(\"what's new\")\n+        assert \"ancient synthesis\" not in result, \"stale pending must be discarded\"\n+        # Cache slot cleared\n+        with p._prefetch_lock:\n+            assert p._prefetch_result == \"\"\n+            assert p._prefetch_result_fired_at == -999\n+\n+    def test_fresh_pending_result_is_kept(self):\n+        \"\"\"A pending result within the staleness window is injected normally.\"\"\"\n+        p = self._make_provider(cfg_extra={\"raw\": {\"dialecticCadence\": 3}})\n+        p._session_key = \"test\"\n+        p._base_context_cache = \"\"\n+        with p._prefetch_lock:\n+            p._prefetch_result = \"recent synthesis\"\n+            p._prefetch_result_fired_at = 8\n+        p._turn_count = 9  # 1 turn since fire, well within cadence × 2 = 6\n+        p._last_dialectic_turn = 8\n+\n+        result = p.prefetch(\"what's new\")\n+        assert \"recent synthesis\" in result\n+\n+    def test_empty_streak_widens_effective_cadence(self):\n+        \"\"\"After N empty returns, the gate waits cadence + N turns.\"\"\"\n+        p = self._make_provider(cfg_extra={\"raw\": {\"dialecticCadence\": 1}})\n+        p._dialectic_empty_streak = 3\n+        # cadence=1, streak=3 → effective = 4\n+        assert p._effective_cadence() == 4\n+\n+    def test_backoff_is_capped(self):\n+        \"\"\"Effective cadence is capped at cadence × _BACKOFF_MAX.\"\"\"\n+        p = self._make_provider(cfg_extra={\"raw\": {\"dialecticCadence\": 2}})\n+        p._dialectic_empty_streak = 100\n+        # cadence=2, ceiling = 2 × 8 = 16\n+        assert p._effective_cadence() == 16\n+\n+    def test_success_resets_empty_streak(self):\n+        \"\"\"A non-empty result zeroes the streak so healthy operation restores\n+        the base cadence immediately.\"\"\"\n+        p = self._make_provider(cfg_extra={\"raw\": {\"dialecticCadence\": 1}})\n+        p._session_key = \"test\"\n+        p._dialectic_empty_streak = 5\n+        p._turn_count = 10\n+        p._last_dialectic_turn = 0\n+        p._manager.dialectic_query.return_value = \"real output\"\n+\n+        p.queue_prefetch(\"hello\")\n+        if p._prefetch_thread:\n+            p._prefetch_thread.join(timeout=2.0)\n+        assert p._dialectic_empty_streak == 0\n+        assert p._last_dialectic_turn == 10\n+\n+    def test_empty_result_increments_streak(self):\n+        p = self._make_provider(cfg_extra={\"raw\": {\"dialecticCadence\": 1}})\n+        p._session_key = \"test\"\n+        p._turn_count = 5\n+        p._last_dialectic_turn = 0\n+        p._manager.dialectic_query.return_value = \"\"  # empty\n+\n+        p.queue_prefetch(\"hello\")\n+        if p._prefetch_thread:\n+            p._prefetch_thread.join(timeout=2.0)\n+        assert p._dialectic_empty_streak == 1\n+        assert p._last_dialectic_turn == 0  # cadence not advanced\n+\n+    def test_liveness_snapshot_shape(self):\n+        p = self._make_provider()\n+        snap = p.liveness_snapshot()\n+        for key in (\n+            \"turn_count\", \"last_dialectic_turn\", \"pending_result_fired_at\",\n+            \"empty_streak\", \"effective_cadence\", \"thread_alive\", \"thread_age_seconds\",\n+        ):\n+            assert key in snap\n+\n+\n+class TestDialecticLifecycleSmoke:\n+    \"\"\"End-to-end smoke walking a multi-turn session through prewarm,\n+    turn 1 consume, trivial skip, cadence fire, empty-result retry,\n+    heuristic bump, and session-end flush.\"\"\"\n+\n+    @staticmethod\n+    def _make_provider(cfg_extra=None):\n+        from unittest.mock import patch, MagicMock\n+        from plugins.memory.honcho.client import HonchoClientConfig\n+\n+        defaults = dict(\n+            api_key=\"[redacted:secret-assignment]\", enabled=True, recall_mode=\"hybrid\",\n+            dialectic_reasoning_level=\"low\", reasoning_heuristic=True,\n+            reasoning_level_cap=\"high\", dialectic_depth=1,\n+        )\n+        if cfg_extra:\n+            defaults.update(cfg_extra)\n+        cfg = HonchoClientConfig(**defaults)\n+        provider = HonchoMemoryProvider()\n+        mock_manager = MagicMock()\n+        mock_session = MagicMock()\n+        mock_session.messages = []\n+        mock_manager.get_or_create.return_value = mock_session\n+        mock_manager.get_prefetch_context.return_value = None\n+        mock_manager.pop_context_result.return_value = None\n+\n+        with patch(\"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\", return_value=cfg), \\\n+             patch(\"plugins.memory.honcho.client.get_honcho_client\", return_value=MagicMock()), \\\n+             patch(\"plugins.memory.honcho.session.HonchoSessionManager\", return_value=mock_manager), \\\n+             patch(\"hermes_constants.get_hermes_home\", return_value=MagicMock()):\n+            return provider, mock_manager, cfg\n+\n+    def _await_thread(self, provider):\n+        if provider._prefetch_thread:\n+            provider._prefetch_thread.join(timeout=3.0)\n+\n+    def test_full_multi_turn_session(self):\n+        \"\"\"Walks init → turns 1..8 → session end. Asserts at every step that\n+        the plugin did exactly what it should and nothing more.\n+\n+        Uses dialecticCadence=3 so we can exercise skip-turns between fires\n+        and the silent-failure retry path without their gates tripping each\n+        other. Trivial + slash skips apply independent of cadence.\n+        \"\"\"\n+        from unittest.mock import patch, MagicMock\n+        provider, mgr, cfg = self._make_provider(\n+            cfg_extra={\"raw\": {\"dialecticCadence\": 3}}\n+        )\n+\n+        # Program the dialectic responses in the exact order they'll be requested.\n+        # An extra or missing call fails the test — strong smoke signal.\n+        responses = iter([\n+            \"prewarm: user is eri, works on hermes\",      # session-start prewarm\n+            \"cadence fire: long query synthesis\",         # turn 4 queue_prefetch\n+            \"\",                                           # turn 7 fire: silent failure\n+            \"retry success: fresh synthesis\",             # turn 8 queue_prefetch retry\n+        ])\n+        mgr.dialectic_query.side_effect = lambda *a, **kw: next(responses)\n+\n+        # ---- init: prewarm fires ----\n+        with patch(\"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\", return_value=cfg), \\\n+             patch(\"plugins.memory.honcho.client.get_honcho_client\", return_value=MagicMock()), \\\n+             patch(\"plugins.memory.honcho.session.HonchoSessionManager\", return_value=mgr), \\\n+             patch(\"hermes_constants.get_hermes_home\", return_value=MagicMock()):\n+            provider.initialize(session_id=\"smoke-test\")\n+\n+        self._await_thread(provider)\n+        with provider._prefetch_lock:\n+            assert provider._prefetch_result.startswith(\"prewarm\"), \\\n+                \"session-start prewarm must land in _prefetch_result\"\n+        assert provider._last_dialectic_turn == 0, \"prewarm marks turn 0\"\n+        assert mgr.dialectic_query.call_count == 1\n+\n+        # ---- turn 1: consume prewarm, no duplicate dialectic ----\n+        provider.on_turn_start(1, \"hey\")\n+        inject1 = provider.prefetch(\"hey\")\n+        assert \"prewarm\" in inject1, \"turn 1 must surface prewarm\"\n+        provider.sync_turn(\"hey\", \"hi there\")\n+        provider.queue_prefetch(\"hey\")  # cadence gate: (1-0)<3 → skip\n+        self._await_thread(provider)\n+        assert mgr.dialectic_query.call_count == 1, \\\n+            \"turn 1 must not fire — prewarm covered it and cadence skips\"\n+\n+        # ---- turn 2: trivial 'ok' → skip everything ----\n+        mgr.prefetch_context.reset_mock()\n+        provider.on_turn_start(2, \"ok\")\n+        assert provider.prefetch(\"ok\") == \"\", \"trivial prompt must short-circuit injection\"\n+        provider.sync_turn(\"ok\", \"cool\")\n+        provider.queue_prefetch(\"ok\")\n+        self._await_thread(provider)\n+        assert mgr.dialectic_query.call_count == 1, \"trivial must not fire dialectic\"\n+        assert mgr.prefetch_context.call_count == 0, \"trivial must not fire context refresh\"\n+\n+        # ---- turn 3: slash '/help' → also skip ----\n+        provider.on_turn_start(3, \"/help\")\n+        assert provider.prefetch(\"/help\") == \"\"\n+        provider.queue_prefetch(\"/help\")\n+        assert mgr.dialectic_query.call_count == 1\n+\n+        # ---- turn 4: long query → cadence fires + heuristic bumps ----\n+        long_q = \"walk me through \" + (\"x \" * 100)  # ~200 chars → heuristic +1\n+        provider.on_turn_start(4, long_q)\n+        provider.prefetch(long_q)\n+        provider.sync_turn(long_q, \"sure\")\n+        provider.queue_prefetch(long_q)  # (4-0)≥3 → fires\n+        self._await_thread(provider)\n+        assert mgr.dialectic_query.call_count == 2, \"turn 4 cadence fire\"\n+        _, kwargs = mgr.dialectic_query.call_args\n+        assert kwargs.get(\"reasoning_level\") in (\"medium\", \"high\"), \\\n+            f\"long query must bump reasoning level above 'low'; got {kwargs.get('reasoning_level')}\"\n+        assert provider._last_dialectic_turn == 4, \"cadence tracker advances on success\"\n+\n+        # ---- turns 5–6: cadence cooldown, no fires ----\n+        for t in (5, 6):\n+            provider.on_turn_start(t, \"tell me more\")\n+            provider.queue_prefetch(\"tell me more\")\n+            self._await_thread(provider)\n+        assert mgr.dialectic_query.call_count == 2, \"turns 5–6 blocked by cadence window\"\n+\n+        # ---- turn 7: fires but silent failure (empty dialectic) ----\n+        provider.on_turn_start(7, \"and then what\")\n+        provider.queue_prefetch(\"and then what\")  # (7-4)≥3 → fires\n+        self._await_thread(provider)\n+        assert mgr.dialectic_query.call_count == 3, \"turn 7 fires\"\n+        assert provider._last_dialectic_turn == 4, \\\n+            \"silent failure must NOT burn the cadence window\"\n+\n+        # ---- turn 8: retries because cadence didn't advance ----\n+        provider.on_turn_start(8, \"try again\")\n+        provider.queue_prefetch(\"try again\")  # (8-4)≥3 → fires again\n+        self._await_thread(provider)\n+        assert mgr.dialectic_query.call_count == 4, \\\n+            \"turn 8 retries because turn 7's empty result didn't advance cadence\"\n+        assert provider._last_dialectic_turn == 8, \"retry success advances\"\n+\n+        # ---- session end: flush messages ----\n+        provider.on_session_end([])\n+        mgr.flush_all.assert_called()\n+\n+\n+class TestReasoningHeuristic:\n+    \"\"\"Char-count heuristic that scales the auto-injected reasoning level by\n+    query length, clamped at reasoning_level_cap.\"\"\"\n+\n+    @staticmethod\n+    def _make_provider(cfg_extra=None):\n+        from unittest.mock import patch, MagicMock\n+        from plugins.memory.honcho.client import HonchoClientConfig\n+\n+        defaults = dict(\n+            api_key=\"[redacted:secret-assignment]\", enabled=True, recall_mode=\"hybrid\",\n+            dialectic_reasoning_level=\"low\", reasoning_heuristic=True,\n+            reasoning_level_cap=\"high\",\n+        )\n+        if cfg_extra:\n+            defaults.update(cfg_extra)\n+        cfg = HonchoClientConfig(**defaults)\n+        provider = HonchoMemoryProvider()\n+        mock_manager = MagicMock()\n+        mock_manager.get_or_create.return_value = MagicMock(messages=[])\n+        with patch(\"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\", return_value=cfg), \\\n+             patch(\"plugins.memory.honcho.client.get_honcho_client\", return_value=MagicMock()), \\\n+             patch(\"plugins.memory.honcho.session.HonchoSessionManager\", return_value=mock_manager), \\\n+             patch(\"hermes_constants.get_hermes_home\", return_value=MagicMock()):\n+            provider.initialize(session_id=\"test-heuristic\")\n+        _settle_prewarm(provider)\n+        return provider\n+\n+    def test_short_query_stays_at_base(self):\n+        p = self._make_provider()\n+        assert p._apply_reasoning_heuristic(\"low\", \"hey\") == \"low\"\n+\n+    def test_medium_query_bumps_one_level(self):\n+        p = self._make_provider()\n+        q = \"x\" * 150\n+        assert p._apply_reasoning_heuristic(\"low\", q) == \"medium\"\n+\n+    def test_long_query_bumps_two_levels(self):\n+        p = self._make_provider()\n+        q = \"x\" * 500\n+        assert p._apply_reasoning_heuristic(\"low\", q) == \"high\"\n+\n+    def test_bump_respects_cap(self):\n+        p = self._make_provider(cfg_extra={\"reasoning_level_cap\": \"medium\"})\n+        q = \"x\" * 500  # would hit 'high' without the cap\n+        assert p._apply_reasoning_heuristic(\"low\", q) == \"medium\"\n+\n+    def test_max_never_auto_selected_with_default_cap(self):\n+        p = self._make_provider(cfg_extra={\"dialectic_reasoning_level\": \"high\"})\n+        q = \"x\" * 500  # base=high, bump would push to 'max'\n+        assert p._apply_reasoning_heuristic(\"high\", q) == \"high\"\n+\n+    def test_heuristic_disabled_returns_base(self):\n+        p = self._make_provider(cfg_extra={\"reasoning_heuristic\": False})\n+        q = \"x\" * 500\n+        assert p._apply_reasoning_heuristic(\"low\", q) == \"low\"\n+\n+    def test_resolve_pass_level_applies_heuristic_at_base_mapping(self):\n+        \"\"\"Depth=1, pass 0 maps to 'base' → heuristic applies.\"\"\"\n+        p = self._make_provider()\n+        q = \"x\" * 150\n+        assert p._resolve_pass_level(0, query=q) == \"medium\"\n+\n+    def test_resolve_pass_level_does_not_touch_explicit_per_pass(self):\n+        \"\"\"dialecticDepthLevels wins absolutely — no heuristic scaling.\"\"\"\n+        p = self._make_provider(cfg_extra={\"dialectic_depth_levels\": [\"minimal\"]})\n+        q = \"x\" * 500  # heuristic would otherwise bump to 'high'\n+        assert p._resolve_pass_level(0, query=q) == \"minimal\"\n+\n+    def test_resolve_pass_level_does_not_touch_lighter_passes(self):\n+        \"\"\"Depth 3 pass 0 is hardcoded 'minimal' — heuristic must not bump it.\"\"\"\n+        p = self._make_provider(cfg_extra={\"dialectic_depth\": 3})\n+        q = \"x\" * 500\n+        assert p._resolve_pass_level(0, query=q) == \"minimal\"\n+        # But the 'base' pass (idx 1 for depth 3) does get heuristic\n+        assert p._resolve_pass_level(1, query=q) == \"high\"\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/run_agent/test_memory_provider_init.py","hunks":["@@ -0,0 +1,39 @@\n+\"\"\"Regression tests for memory provider selection during AIAgent init.\"\"\"\n+\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+\n+def test_blank_memory_provider_does_not_auto_enable_honcho():\n+    \"\"\"Blank memory.provider should remain opt-out even if Honcho fallback looks configured.\"\"\"\n+    cfg = {\"memory\": {\"provider\": \"\"}, \"agent\": {}}\n+    honcho_cfg = SimpleNamespace(enabled=True, api_key=\"[redacted:secret-assignment]\", base_url=None)\n+\n+    with (\n+        patch(\"hermes_cli.config.load_config\", return_value=cfg),\n+        patch(\"hermes_cli.config.save_config\") as save_config,\n+        patch(\n+            \"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\",\n+            return_value=honcho_cfg,\n+        ) as from_global_config,\n+        patch(\"plugins.memory.load_memory_provider\") as load_memory_provider,\n+        patch(\"agent.model_metadata.get_model_context_length\", return_value=204_800),\n+        patch(\"run_agent.get_tool_definitions\", return_value=[]),\n+        patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+        patch(\"run_agent.OpenAI\"),\n+    ):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=False,\n+        )\n+\n+    assert agent._memory_manager is None\n+    from_global_config.assert_not_called()\n+    load_memory_provider.assert_not_called()\n+    save_config.assert_not_called()\n+"]},{"file":"website/docs/user-guide/features/honcho.md","hunks":["@@ -77,7 +77,7 @@ Cost and depth are controlled by three independent knobs:\n | Knob | Controls | Default |\n |------|----------|---------|\n | `contextCadence` | Turns between `context()` API calls (base layer refresh) | `1` |\n-| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `3` |\n+| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `2` (recommended 1–5) |\n | `dialecticDepth` | Number of `.chat()` passes per dialectic invocation (1–3) | `1` |\n \n These are orthogonal — you can have frequent context refreshes with infrequent dialectic, or deep multi-pass dialectic at low frequency. Example: `contextCadence: 1, dialecticCadence: 5, dialecticDepth: 2` refreshes base context every turn, runs dialectic every 5 turns, and each dialectic run makes 2 passes.","@@ -94,6 +94,14 @@ Each pass uses a proportional reasoning level (lighter early passes, base level\n \n Passes bail out early if the prior pass returned strong signal (long, structured output), so depth 3 doesn't always mean 3 LLM calls.\n \n+### Session-Start Prewarm\n+\n+On session init, Honcho fires a dialectic call in the background at the full configured `dialecticDepth` and hands the result directly to turn 1's context assembly. A single-pass prewarm on a cold peer often returns thin output — multi-pass depth runs the audit/reconcile cycle before the user ever speaks. If prewarm hasn't landed by turn 1, turn 1 falls back to a synchronous call with a bounded timeout.\n+\n+### Query-Adaptive Reasoning Level\n+\n+The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1 level at ≥120 chars, +2 at ≥400, clamped at `reasoningLevelCap` (default `\"high\"`). Disable with `reasoningHeuristic: false` to pin every auto call to `dialecticReasoningLevel`. Available levels: `minimal`, `low`, `medium`, `high`, `max`.\n+\n ## Configuration Options\n \n Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho.json` (profile-local). The setup wizard handles this for you.","@@ -104,7 +112,7 @@ Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho\n |-----|---------|-------------|\n | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Set to an integer (e.g. 1200) to cap. Truncates at word boundaries |\n | `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) |\n-| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). In `tools` mode, irrelevant — model calls explicitly |\n+| `dialecticCadence` | `2` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). Recommended 1–5. In `tools` mode, irrelevant — model calls explicitly |\n | `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped to 1–3 |\n | `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `[\"minimal\", \"low\", \"medium\"]`. Overrides proportional defaults |\n | `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` |","@@ -142,6 +150,41 @@ Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho\n \n In `tools` mode, the model is fully in control — it calls `honcho_reasoning` when it wants, at whatever `reasoning_level` it picks. Cadence and budget settings only apply to modes with auto-injection (`hybrid` and `context`).\n \n+## Observation (Directional vs. Unified)\n+\n+Honcho models a conversation as peers exchanging messages. Each peer has two observation toggles that map 1:1 to Honcho's `SessionPeerConfig`:\n+\n+| Toggle | Effect |\n+|--------|--------|\n+| `observeMe` | Honcho builds a representation of this peer from its own messages |\n+| `observeOthers` | This peer observes the other peer's messages (feeds cross-peer reasoning) |\n+\n+Two peers × two toggles = four flags. `observationMode` is a shorthand preset:\n+\n+| Preset | User flags | AI flags | Semantics |\n+|--------|-----------|----------|-----------|\n+| `\"directional\"` (default) | me: on, others: on | me: on, others: on | Full mutual observation. Enables cross-peer dialectic — \"what does the AI know about the user, based on what the user said and the AI replied.\" |\n+| `\"unified\"` | me: on, others: off | me: off, others: on | Shared-pool semantics — the AI observes the user's messages only, the user peer only self-models. Single-observer pool. |\n+\n+Override the preset with an explicit `observation` block for per-peer control:\n+\n+```json\n+\"observation\": {\n+  \"user\": { \"observeMe\": true,  \"observeOthers\": true },\n+  \"ai\":   { \"observeMe\": true,  \"observeOthers\": false }\n+}\n+```\n+\n+Common patterns:\n+\n+| Intent | Config |\n+|--------|--------|\n+| Full observation (most users) | `\"observationMode\": \"directional\"` |\n+| AI shouldn't re-model the user from its own replies | `\"ai\": {\"observeMe\": true, \"observeOthers\": false}` |\n+| Strong persona the AI peer shouldn't update from self-observation | `\"ai\": {\"observeMe\": false, \"observeOthers\": true}` |\n+\n+Server-side toggles set via the [Honcho dashboard](https://app.honcho.dev) win over local defaults — Hermes syncs them back at session init.\n+\n ## Tools\n \n When Honcho is active as the memory provider, five tools become available:"]},{"file":"website/docs/user-guide/features/memory-providers.md","hunks":["@@ -82,7 +82,7 @@ hermes memory setup        # select \"honcho\"\n | `workspace` | host key | Shared workspace ID |\n | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Truncates at word boundaries |\n | `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) |\n-| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls. Only applies to `hybrid`/`context` modes |\n+| `dialecticCadence` | `2` | Minimum turns between `peer.chat()` LLM calls. Recommended 1–5. Only applies to `hybrid`/`context` modes |\n | `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped 1–3. Pass 0: cold/warm prompt, pass 1: self-audit, pass 2: reconciliation |\n | `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `[\"minimal\", \"low\", \"medium\"]`. Overrides proportional defaults |\n | `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` |","@@ -140,23 +140,64 @@ hermes memory setup        # select \"honcho\"\n If you previously used `hermes honcho setup`, your config and all server-side data are intact. Just re-enable through the setup wizard again or manually set `memory.provider: honcho` to reactivate via the new system.\n :::\n \n-**Multi-agent / Profiles:**\n+**Multi-peer setup:**\n \n-Each Hermes profile gets its own Honcho AI peer while sharing the same workspace -- all profiles see the same user representation, but each agent builds its own identity and observations.\n+Honcho models conversations as peers exchanging messages — one user peer plus one AI peer per Hermes profile, all sharing a workspace. The workspace is the shared environment: the user peer is global across profiles, each AI peer is its own identity. Every AI peer builds an independent representation / card from its own observations, so a `coder` profile stays code-oriented while a `writer` profile stays editorial against the same user.\n+\n+The mapping:\n+\n+| Concept | What it is |\n+|---------|-----------|\n+| **Workspace** | Shared environment. All Hermes profiles under one workspace see the same user identity. |\n+| **User peer** (`peerName`) | The human. Shared across profiles in the workspace. |\n+| **AI peer** (`aiPeer`) | One per Hermes profile. Host key `hermes` → default; `hermes.<profile>` for others. |\n+| **Observation** | Per-peer toggles controlling what Honcho models from whose messages. `directional` (default, all four on) or `unified` (single-observer pool). |\n+\n+### New profile, fresh Honcho peer\n \n ```bash\n-hermes profile create coder --clone   # creates honcho peer \"coder\", inherits config from default\n+hermes profile create coder --clone\n ```\n \n-What `--clone` does: creates a `hermes.coder` host block in `honcho.json` with `aiPeer: \"coder\"`, shared `workspace`, inherited `peerName`, `recallMode`, `writeFrequency`, `observation`, etc. The peer is eagerly created in Honcho so it exists before first message.\n+`--clone` creates a `hermes.coder` host block in `honcho.json` with `aiPeer: \"coder\"`, shared `workspace`, inherited `peerName`, `recallMode`, `writeFrequency`, `observation`, etc. The AI peer is eagerly created in Honcho so it exists before the first message.\n \n-For profiles created before Honcho was set up:\n+### Existing profiles, backfill Honcho peers\n \n ```bash\n-hermes honcho sync   # scans all profiles, creates host blocks for any missing ones\n+hermes honcho sync\n+```\n+\n+Scans every Hermes profile, creates host blocks for any profile without one, inherits settings from the default `hermes` block, and creates the new AI peers eagerly. Idempotent — skips profiles that already have a host block.\n+\n+### Per-profile observation\n+\n+Each host block can override the observation config independently. Example: a code-focused profile where the AI peer observes the user but doesn't self-model:\n+\n+```json\n+\"hermes.coder\": {\n+  \"aiPeer\": \"coder\",\n+  \"observation\": {\n+    \"user\": { \"observeMe\": true, \"observeOthers\": true },\n+    \"ai\":   { \"observeMe\": false, \"observeOthers\": true }\n+  }\n+}\n ```\n \n-This inherits settings from the default `hermes` host block and creates new AI peers for each profile. Idempotent -- skips profiles that already have a host block.\n+**Observation toggles (one set per peer):**\n+\n+| Toggle | Effect |\n+|--------|--------|\n+| `observeMe` | Honcho builds a representation of this peer from its own messages |\n+| `observeOthers` | This peer observes the other peer's messages (feeds cross-peer reasoning) |\n+\n+Presets via `observationMode`:\n+\n+- **`\"directional\"`** (default) — all four flags on. Full mutual observation; enables cross-peer dialectic.\n+- **`\"unified\"`** — user `observeMe: true`, AI `observeOthers: true`, rest false. Single-observer pool; AI models the user but not itself, user peer only self-models.\n+\n+Server-side toggles set via the [Honcho dashboard](https://app.honcho.dev) win over local defaults — synced back at session init.\n+\n+See the [Honcho page](./honcho.md#observation-directional-vs-unified) for the full observation reference.\n \n <details>\n <summary>Full honcho.json example (multi-profile)</summary>","@@ -181,7 +222,7 @@ This inherits settings from the default `hermes` host block and creates new AI p\n       },\n       \"dialecticReasoningLevel\": \"low\",\n       \"dialecticDynamic\": true,\n-      \"dialecticCadence\": 3,\n+      \"dialecticCadence\": 2,\n       \"dialecticDepth\": 1,\n       \"dialecticMaxChars\": 600,\n       \"contextCadence\": 1,\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}