"""Honcho memory plugin — MemoryProvider for Honcho AI-native memory.

Cross-session user modeling with dialectic Q&A, semantic search, peer cards and
persistent conclusions; five tools (profile, search, reasoning, context, conclude).
Config chain: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars.
"""

from __future__ import annotations

import contextlib
import hashlib
import json
import logging
import os
import re
import threading
import time
from typing import Any, Callable, Dict, List, Optional

from agent.memory_manager import sanitize_context
from agent.memory_provider import MemoryProvider, is_trivial_prompt
from agent.coding_context import INTERACTIVE_CODING_PLATFORMS as _LOCAL_PLATFORMS
from agent.turn_author import a2a_key
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
from plugins.memory.honcho.client import _host_block, _HostLookup
from plugins.memory.honcho.client import join_plugin_threads, spawn_context_thread
from plugins.memory.honcho.dialectic import DialecticMixin
from plugins.memory.honcho.session_peers import assistant_peer_id_for, sanitize_peer_id
from plugins.memory.honcho.session_context import usable_honcho_summary
from plugins.memory.honcho.tool_schemas import ALL_TOOL_SCHEMAS
from tools.registry import tool_error

logger = logging.getLogger(__name__)


# Gateway-internal notifications arrive through the same user-role channel as genuine
# user messages; they are execution metadata and must never become durable memory.
# Deliberately anchored: a human discussing one of these strings mid-message is valid input.
_INTERNAL_GATEWAY_TURN_RE = re.compile(
    r"^\s*(?:"
    r"\[ASYNC (?:DELEGATION )?(?:BATCH )?COMPLETE[^\]]*\]|"
    r"\[CONTEXT COMPACTION[^\]]*\]|"
    r"\[CONTEXT SUMMARY\]:?|"
    r"\[PRIOR CONTEXT[^\]]*\]|"
    r"\[Your active task list was preserved across context compression\]|"
    r"\[IMPORTANT: Background process \d+ matched watch pattern[^\n]*|"
    r"A background fan-out of \d+ subagent\(s\) you dispatched earlier has finished\.|"
    r"A background subagent you dispatched earlier has finished\."
    r")",
    re.IGNORECASE,
)


def _is_internal_gateway_turn(text: str) -> bool:
    """Return True for machine-generated gateway/delegation notifications."""
    return bool(_INTERNAL_GATEWAY_TURN_RE.match(text or ""))


def _cfg_usable(cfg) -> bool:
    """Enabled with a credential or a self-hosted URL to talk to."""
    return bool(cfg.enabled and (cfg.api_key or cfg.base_url))


# Static per-mode system prompt text (prompt-cache friendly: never changes between turns).
_TOOL_GUIDE = (
    "Use honcho_profile for a quick factual snapshot, "
    "honcho_search for raw excerpts, honcho_context for raw peer context, "
    "honcho_reasoning for synthesized answers (pass reasoning_level "
    "minimal/low/medium/high/max — you pick the depth per call), "
    "honcho_conclude to save facts about the user."
)
_PROMPT_HEADERS = {
    "context": (
        "# Honcho Memory\nActive (context-injection mode). Relevant user context is automatically "
        "injected before each turn. No memory tools are available — context is managed automatically."
    ),
    "tools": (
        f"# Honcho Memory\nActive (tools-only mode). {_TOOL_GUIDE} "
        "No automatic context injection — you must use tools to access memory."
    ),
    "hybrid": (
        "# Honcho Memory\nActive (hybrid mode). Relevant context is auto-injected AND memory tools "
        f"are available. {_TOOL_GUIDE}"
    ),
}



_FLAG_WORDS = {"1": True, "true": True, "yes": True, "on": True,
               "0": False, "false": False, "no": False, "off": False, "": False}


def _as_flag(raw: Any, default: Optional[bool]) -> Optional[bool]:
    """A config or env value read as a boolean. Unrecognized strings keep ``default``."""
    if isinstance(raw, str):
        return _FLAG_WORDS.get(raw.strip().lower(), default)
    return default if raw is None else bool(raw)


# (injection.sessionStart name, context key, heading). Render order is fixed here, not by config order.
_CONTEXT_SECTIONS = (
    ("summary", "summary", "Session Summary"),
    ("peerRepresentation", "representation", "User Representation"),
    ("peerCard", "card", "User Peer Card"),
    ("aiRepresentation", "ai_representation", "AI Self-Representation"),
    ("aiCard", "ai_card", "AI Identity Card"),
)

_PREWARM_QUERY = "Summarize what you know about this user. Focus on preferences, current projects, and working style."


class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
    """Honcho AI-native memory with dialectic Q&A and persistent user modeling."""

    def backup_paths(self) -> List[str]:
        """Whole ~/.honcho dir (peer/session config when no profile-local honcho.json exists)."""
        try:
            from .client import resolve_global_config_path
            return [str(resolve_global_config_path().parent)]
        except Exception:
            return []

    def __init__(self, query_rewriter: Optional[Callable[[str], str]] = None):
        self._manager = None   # HonchoSessionManager
        self._config = None    # HonchoClientConfig
        self._session_key = ""
        self._query_rewriter = query_rewriter
        self._prefetch_result = ""
        self._prefetch_lock = threading.Lock()
        self._prefetch_thread: Optional[threading.Thread] = None
        self._sync_thread: Optional[threading.Thread] = None
        self._memwrite_thread: Optional[threading.Thread] = None
        self._recall_mode = "hybrid"  # "context", "tools", or "hybrid"
        self._recall_sync = False
        self._recall_generation = object()
        self._recall_sync_thread: Optional[threading.Thread] = None
        self._recall_sync_lock = threading.Lock()
        # Base context cache — refreshed on context_cadence, not frozen.
        self._base_context_cache: Optional[str] = None
        self._base_context_lock = threading.Lock()

        # Recall cadence state (overwritten from config in initialize()).
        self._turn_count = 0
        # Author of the turn in flight, refreshed by on_turn_start.
        self._turn_author: dict[str, Any] = {}
        # (config path, mtime_ns, size) -> identity_signature() values.
        self._identity_signature_memo: dict[tuple, dict[str, Any]] = {}
        # Injection audit. Off unless the logging key enables it: the record holds the user's representation.
        self._injection_log_path: Optional[str] = None
        self._injection_log_lock = threading.Lock()
        # Pinned injection.sessionStart names; None means unpinned and everything renders.
        self._session_start_components: Optional[frozenset] = None
        self._query_rewrite_enabled = False
        self._injection_frequency = "every-turn"  # or "first-turn"
        self._context_cadence = 1   # minimum turns between context API calls
        self._dialectic_cadence = 1  # backwards-compat fallback; wizard writes 2 on new configs
        self._dialectic_depth = 1   # .chat() calls per dialectic cycle (1-3)
        self._dialectic_depth_levels: list[str] | None = None  # per-pass reasoning levels
        self._reasoning_heuristic: bool = True  # scale base level by query length
        self._reasoning_level_cap: str = "high"  # ceiling for auto-selected level
        self._last_context_turn = self._last_dialectic_turn = -999
        # Liveness: monotonic start of the current prefetch thread, the turn the pending
        # result was fired at, and consecutive empty dialectic returns (drives backoff).
        self._prefetch_thread_started_at: float = 0.0
        self._prefetch_result_fired_at: int = -999
        self._dialectic_empty_streak: int = 0

        # Tools-only mode may defer session initialization until a tool call.
        self._session_initialized = False
        self._lazy_init_kwargs: Optional[dict] = None
        self._lazy_init_session_id: Optional[str] = None
        self._init_thread: Optional[threading.Thread] = None
        self._init_lock = threading.Lock()
        # Init auth failures live here because the failed manager is discarded.
        self._init_auth_failure: Optional[str] = None
        self._init_auth_notice_emitted = False
        # Set when no user peer could be named (no runtime identity, no peerName). Init is not retried.
        self._init_peer_failure: Optional[str] = None
        self._init_peer_platform: str = "cli"
        self._init_peer_notice_emitted = False
        self._cron_skipped = False  # cron and flush contexts disable the plugin entirely

    @property
    def name(self) -> str:
        return "honcho"

    def is_available(self) -> bool:
        """Check if Honcho is configured. No network calls."""
        try:
            from plugins.memory.honcho.client import HonchoClientConfig
            return _cfg_usable(HonchoClientConfig.from_global_config())
        except Exception:
            return False

    def save_config(self, values, hermes_home):
        """Merge ``values`` into $HERMES_HOME/honcho.json (Honcho SDK native format); a file that does not parse raises.
        Holds the token refresh locks so a rotation cannot land between the read and the write."""
        from pathlib import Path
        from utils import atomic_json_write
        from plugins.memory.honcho.oauth import _config_refresh_lock, _read_config_strict, _refresh_lock
        config_path = Path(hermes_home) / "honcho.json"
        with _refresh_lock, _config_refresh_lock(config_path):
            existing = _read_config_strict(config_path)
            atomic_json_write(config_path, {**existing, **values}, mode=0o600)

    def get_config_schema(self):
        return [
            {"key": "api_key", "description": "Honcho API key", "secret": True, "env_var": "HONCHO_API_KEY", "url": "https://app.honcho.dev"},
            {"key": "baseUrl", "description": "Honcho base URL (for self-hosted)"},
        ]

    def post_setup(self, hermes_home: str, config: dict) -> None:
        """Run the full Honcho setup wizard after provider selection."""
        import types
        from plugins.memory.honcho.cli import cmd_setup
        cmd_setup(types.SimpleNamespace())

    # ----- Session lifecycle -----

    def initialize(self, session_id: str, **kwargs) -> None:
        """Configure recall settings and start (or defer) Honcho session creation."""
        self._recall_generation = object()
        try:
            agent_context, platform = kwargs.get("agent_context", ""), kwargs.get("platform", "cli")
            if agent_context in {"cron", "flush"} or platform == "cron":
                logger.debug("Honcho skipped: cron/flush context (agent_context=%s, platform=%s)",
                             agent_context, platform)
                self._cron_skipped = True
                return

            from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client  # noqa: F401 — ImportError probe
            from plugins.memory.honcho.session import HonchoSessionManager  # noqa: F401

            cfg = HonchoClientConfig.from_global_config()
            if not _cfg_usable(cfg):
                logger.debug("Honcho not configured — plugin inactive")
                return

            self._config = cfg
            self._recall_mode = cfg.recall_mode
            self._recall_sync = getattr(cfg, "recall_sync", False)
            look = _HostLookup(_host_block(cfg.raw, cfg.host or ""), cfg.raw)
            self._injection_log_path = self._resolve_injection_log_path(look)
            self._session_start_components = self._resolve_session_start(look)
            logger.debug("Honcho recall_mode: %s", self._recall_mode)
            for name in ("injection_frequency", "context_cadence", "dialectic_cadence",
                         "dialectic_depth_levels", "reasoning_heuristic"):
                setattr(self, f"_{name}", getattr(cfg, name))
            self._query_rewrite_enabled = cfg.query_rewrite
            self._FIRST_TURN_BASE_TIMEOUT = cfg.first_turn_base_wait
            self._FIRST_TURN_DIALECTIC_CAP = cfg.first_turn_dialectic_wait
            self._dialectic_depth = max(1, min(cfg.dialectic_depth, 3))
            if cfg.reasoning_level_cap in self._LEVEL_ORDER:
                self._reasoning_level_cap = cfg.reasoning_level_cap

            # aiPeer comes from honcho.json only; SOUL.md is persona content, not identity config.
            self._lazy_init_kwargs = dict(kwargs)
            self._lazy_init_session_id = session_id
            self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)

            # Session creation can block on Honcho/DB outages, so context/hybrid startup
            # fails open in a background thread. Tools-only mode has an explicit contract:
            # init_on_session_start=False stays lazy until the first tool call, True is eager.
            if self._recall_mode != "tools":
                self._start_session_init_background(wait_timeout=0.1)
            elif cfg.init_on_session_start:
                self._ensure_session()
            else:
                logger.debug("Honcho tools-only mode — deferring session init until first tool call")
        except ImportError:
            logger.debug("honcho-ai package not installed — plugin inactive")
        except Exception as e:
            logger.warning("Honcho init failed: %s", e)
            self._manager = None

    def _resolve_session_key(self, cfg, session_id: str, **kwargs) -> str:
        """Resolve the Honcho session key without touching the network."""
        from agent.runtime_cwd import resolve_agent_cwd

        cwd = kwargs.get("cwd") or str(resolve_agent_cwd())
        return cfg.resolve_session_name(
            cwd=cwd,
            session_title=kwargs.get("session_title"), session_id=session_id,
            session_title_source=kwargs.get("session_title_source"),
            gateway_session_key=kwargs.get("gateway_session_key"),
        ) or session_id or "hermes-default"

    def _can_start_init(self) -> bool:
        return not (self._cron_skipped or self._session_initialized) and bool(self._config) and self._lazy_init_kwargs is not None

    def _run_session_init(self, label: str) -> bool:
        """Run _do_session_init with the deferred kwargs; on failure discard the manager
        and (for auth or unresolved-peer failures) keep the detail for the one-time notice."""
        from plugins.memory.honcho.session import HonchoAuthError
        from plugins.memory.honcho.session_peers import HonchoPeerUnresolvedError

        init_kwargs = self._lazy_init_kwargs
        if init_kwargs is None:  # another init path already consumed the deferred kwargs
            return self._manager is not None
        try:
            self._do_session_init(self._config, self._lazy_init_session_id or "hermes-default", **dict(init_kwargs))
        except Exception as e:
            self._manager = None
            self._session_initialized = False
            detail: object = e
            if isinstance(e, HonchoAuthError):
                # Keep the auth detail so the one-time notice survives the manager discard.
                self._init_auth_failure = str(e)
                detail = "authentication rejected"
            elif isinstance(e, HonchoPeerUnresolvedError):
                # A missing peerName does not heal mid-session, so drop the deferred kwargs and stop retrying.
                self._init_peer_failure = str(e)
                self._init_peer_platform = str(dict(init_kwargs).get("platform") or "cli")
                self._lazy_init_kwargs = self._lazy_init_session_id = None
            logger.warning("Honcho %s session init failed: %s", label, detail)
            return False
        self._lazy_init_kwargs = self._lazy_init_session_id = None
        if self._init_auth_failure is not None:
            self._init_auth_failure = None
            self._init_auth_notice_emitted = False
        return True

    def _start_session_init_background(self, *, wait_timeout: float = 0.0, blocking: bool = True) -> None:
        """Start session initialization in a daemon thread so a slow/down Honcho can't
        block agent construction or first prompt assembly. ``wait_timeout`` lets fast
        (mock) initializations finish before returning."""
        if not self._can_start_init():
            return
        if not blocking and not self._init_lock.acquire(blocking=False):
            return
        try:
            with self._init_lock if blocking else contextlib.nullcontext():
                if not self._can_start_init() or (self._init_thread and self._init_thread.is_alive()):
                    return
                self._init_thread = spawn_context_thread(lambda: self._run_session_init("background"),
                                                         name="honcho-session-init", owner=self)
                self._init_thread.start()
                if wait_timeout > 0:
                    self._init_thread.join(timeout=wait_timeout)
        finally:
            if not blocking:
                self._init_lock.release()

    def _ensure_session(self) -> bool:
        """Lazily initialize the Honcho session (tools-only mode). True when the manager is ready."""
        if self._manager and self._session_initialized:
            return True
        if not self._can_start_init() or (self._init_thread and self._init_thread.is_alive()):
            return False
        return self._run_session_init("lazy") and self._manager is not None

    def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:
        """Shared session initialization for both eager and lazy paths."""
        from plugins.memory.honcho.client import get_honcho_client
        from plugins.memory.honcho.session import HonchoSessionManager

        self._manager = HonchoSessionManager(
            honcho=get_honcho_client(cfg), config=cfg, context_tokens=cfg.context_tokens,
            runtime_user_peer_name=kwargs.get("user_id") or None,
            runtime_user_peer_name_alt=kwargs.get("user_id_alt") or None,
        )
        self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
        logger.debug("Honcho session key resolved: %s", self._session_key)

        # The provider is not "ready" until this method returns: background startup sets
        # _manager before get_or_create/migration/prewarm finish, and lifecycle hooks must
        # not treat that partially initialized state as usable.
        session = self._manager.get_or_create(self._session_key)

        # Per-session strategy creates a fresh Honcho session every run, so a per-run
        # MEMORY.md/USER.md/SOUL.md upload would flood the backend with duplicates.
        if cfg.session_strategy == "per-session":
            logger.debug("Honcho memory file migration skipped: per-session strategy creates a fresh session per run (%s)",
                         self._session_key)
        elif not session.messages:
            try:
                from hermes_constants import get_hermes_home
                self._manager.migrate_memory_files(self._session_key, str(get_hermes_home() / "memories"))
                logger.debug("Honcho memory file migration attempted for new session: %s", self._session_key)
            except Exception as e:
                logger.debug("Honcho memory file migration skipped: %s", e)

        # Generic dialectic prewarm is incompatible with latest-message query rewriting,
        # which needs the first substantive user message.
        if self._recall_mode in {"context", "hybrid"} and not self._recall_sync:
            if self._query_rewriter is None or not self._query_rewrite_enabled:
                self._spawn_dialectic(_PREWARM_QUERY, thread_name="honcho-prewarm-dialectic", fired_at=0,
                                      log_label="dialectic prewarm", use_query_rewrite=False)
                logger.debug("Honcho dialectic prewarm started for session: %s", self._session_key)
            else:
                logger.debug("Honcho generic dialectic prewarm skipped: awaiting first user message")

        self._session_initialized = True

    def _session_ready(self) -> bool:
        """Whether the manager/session key can be used safely. Background init sets
        ``_manager`` before get-or-create completes, so ``_session_initialized`` is the real
        guard; tests/legacy construction may inject a ready manager without the flag —
        allowed only with no init thread in flight."""
        if not self._manager or not self._session_key:
            return False
        return self._session_initialized or not (self._init_thread and self._init_thread.is_alive())

    def _writes_enabled(self) -> bool:
        """``saveMessages`` is the operator's hard write gate for every Honcho mutation path."""
        return not self._cron_skipped and getattr(self._config, "save_messages", True)

    def _ready_or_kick_init(self) -> bool:
        """True when writes may proceed; otherwise (outside tools mode) start background init."""
        if self._session_ready():
            return True
        if self._recall_mode != "tools":
            self._start_session_init_background()
        return False

    # ----- Prompt / prefetch -----

    @staticmethod
    def _resolve_session_start(look: _HostLookup) -> Optional[frozenset]:
        """The pinned ``injection.sessionStart`` list as a set, or None when unpinned.
        An explicit empty list means inject nothing and stays distinct from unset."""
        injection = look.present("injection")
        if not isinstance(injection, dict):
            return None
        listed = injection.get("sessionStart")
        if not isinstance(listed, (list, tuple)):
            return None
        return frozenset(str(x) for x in listed)

    def _format_first_turn_context(self, ctx: dict) -> str:
        """Render the prefetch context, keeping only the ``injection.sessionStart`` components when pinned.
        The summary passes usable_honcho_summary here, so a contaminated one never reaches _base_context_cache."""
        ctx = {**ctx, "summary": usable_honcho_summary(ctx.get("summary")) or ""}
        allowed = self._session_start_components
        parts, suppressed = [], []
        for name, key, header in _CONTEXT_SECTIONS:
            value = ctx.get(key, "")
            if not value:
                continue
            if allowed is not None and name not in allowed:
                suppressed.append(f"{name} ({len(value)}B)")
                continue
            parts.append(f"## {header}\n{value}")
        if suppressed:
            logger.debug("Honcho session-start injection filtered by config: kept %s, suppressed %s",
                         [n for n, k, _ in _CONTEXT_SECTIONS if ctx.get(k) and (allowed is None or n in allowed)],
                         suppressed)
        return "\n\n".join(parts)

    def system_prompt_block(self) -> str:
        """Static mode header + tool instructions (prompt-cache friendly).
        Live context (representation, card) is injected via prefetch()."""
        if self._cron_skipped or not (self._config or (self._manager and self._session_key)):
            return ""
        return _PROMPT_HEADERS.get(self._recall_mode, _PROMPT_HEADERS["hybrid"])

    @staticmethod
    def _resolve_injection_log_path(look: _HostLookup) -> Optional[str]:
        """Where to append the injection audit, or None to keep it off.
        The ``logging`` key or HONCHO_LOGGING switches it on. HONCHO_INJECTION_LOG overrides the destination."""
        explicit = os.environ.get("HONCHO_INJECTION_LOG")
        if explicit:
            return explicit
        enabled = _as_flag(look.pick_set("logging"), default=None)
        if enabled is None:
            enabled = _as_flag(os.environ.get("HONCHO_LOGGING"), default=False)
        if not enabled:
            return None
        return os.path.join(os.path.expanduser("~"), ".honcho", "injection.log")

    def _log_injection(self, reason: str, payload: str = "") -> str:
        """Append one record of what this turn injected and why, then return ``payload`` unchanged. Never raises.
        The reason matters because prefetch has several ways to return nothing and each needs a different fix."""
        path = self._injection_log_path
        if not path:
            return payload
        try:
            record = json.dumps({
                "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "turn": self._turn_count,
                "session_key": self._session_key or "", "recall_mode": self._recall_mode,
                "reason": reason, "bytes": len(payload.encode("utf-8")), "payload": payload,
            }, ensure_ascii=False)
            with self._injection_log_lock:
                os.makedirs(os.path.dirname(path), exist_ok=True)
                # The record holds the user's representation verbatim, so the file is owner-only.
                fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
                with os.fdopen(fd, "a", encoding="utf-8") as fh:
                    fh.write(record + "\n")
        except Exception as e:
            logger.debug("Honcho injection log write failed: %s", e)
        return payload

    def _first_turn_wait(self, base: float) -> float:
        """Turn-1 wait budget: a short request timeout may tighten, but never expand, it."""
        request_timeout = getattr(self._config, "timeout", None)
        return max(0.0, base if request_timeout is None else min(base, max(0.0, request_timeout)))

    def _fetch_base_context_layer(self, query: str, first_turn_base_deadline: float | None) -> str:
        """Layer 1: representation + card. The first fetch gets the remaining turn-1 budget;
        later turns consume the refresh queued by the previous turn."""
        with self._base_context_lock:
            first_base_fetch = self._base_context_cache is None
            if first_base_fetch:
                self._base_context_cache = ""
                self._last_context_turn = self._turn_count
            base_context = self._base_context_cache

        if not self._manager:
            return base_context

        def _adopt(ctx: dict) -> str:
            """Cache a fresh context dict's formatted block; keep the old text if it formats empty."""
            formatted = self._format_first_turn_context(ctx)
            if formatted:
                with self._base_context_lock:
                    self._base_context_cache = formatted
            return formatted or base_context

        if not first_base_fetch:
            fresh_ctx = self._manager.pop_context_result(self._session_key)
            return _adopt(fresh_ctx) if fresh_ctx else base_context

        ctx_holder: dict[str, dict] = {}

        def _fetch_base() -> None:
            ctx_holder["ctx"] = ctx = self._manager.get_prefetch_context(self._session_key, query or None) or {}
            if ctx:
                self._manager.set_context_result(self._session_key, ctx)

        bt = self._spawn_write(_fetch_base, "honcho-base-first", "Honcho first-turn base context failed: %s")
        base_wait = max(0.0, first_turn_base_deadline - time.monotonic()) if first_turn_base_deadline is not None else 0.0
        bt.join(timeout=base_wait)
        if ctx := ctx_holder.get("ctx"):
            self._manager.pop_context_result(self._session_key)
            return _adopt(ctx)
        if bt.is_alive():
            logger.debug("Honcho first-turn base context still running after %.1fs — will surface on next turn", base_wait)
        return base_context

    def _first_turn_dialectic_wait(self, query: str) -> None:
        """Turn 1 only: reuse an in-flight prewarm or start one dialectic, then wait briefly.
        Unfinished work stays async and surfaces on a later turn."""
        with self._prefetch_lock:
            prewarm_landed = bool(self._prefetch_result)
        if prewarm_landed and self._last_dialectic_turn == -999:
            self._last_dialectic_turn = self._turn_count
        if self._last_dialectic_turn != -999 or not query:
            return

        dia_wait = self._first_turn_wait(self._FIRST_TURN_DIALECTIC_CAP)
        if not self._thread_is_live():
            self._spawn_dialectic(query, thread_name="honcho-prefetch-first", fired_at=self._turn_count,
                                  log_label="first-turn dialectic")
        if (live := self._prefetch_thread) is not None:
            live.join(timeout=dia_wait)
        if self._prefetch_thread and self._prefetch_thread.is_alive():
            logger.debug("Honcho first-turn dialectic still running after %.1fs — will surface on next turn", dia_wait)

    def prefetch(self, query: str, *, session_id: str = "") -> str:
        """Base context (representation + card, refreshed on context_cadence) plus the
        dialectic supplement (refreshed on dialectic_cadence), within the context budget.
        Empty in tools-only mode."""
        if self._cron_skipped or self._recall_mode == "tools":
            return self._log_injection("cron-or-tools-mode")

        if self._recall_sync:
            from plugins.memory.honcho.recall_sync import prefetch_sync
            notice = self._pop_auth_notice() or self._pop_peer_notice()
            payload = "\n\n".join(part for part in (notice, prefetch_sync(self, query)) if part)
            return self._log_injection("injected" if payload else "recall-sync-empty", payload)

        first_turn_base_deadline = (time.monotonic() + self._first_turn_wait(self._FIRST_TURN_BASE_TIMEOUT)
                                    if self._turn_count <= 1 else None)

        if not self._session_ready():
            # Only turn 1 may wait for session init; later turns fail open.
            self._start_session_init_background()
            if first_turn_base_deadline is not None and self._init_thread is not None:
                self._init_thread.join(timeout=max(0.0, first_turn_base_deadline - time.monotonic()))
            if not self._session_ready():
                # A failed init still owes the user its one-time notice.
                return self._log_injection("session-not-ready", self._pop_auth_notice() or self._pop_peer_notice())

        # Trivial turns start no work, but may consume a ready pending result.
        if self._is_trivial_prompt(query):
            ready = self._consume_pending_dialectic()
            return self._log_injection("trivial-prompt", self._truncate_to_budget(ready) if ready else "")

        # One-time notice, relayed by the model, that auth is dead and memory is paused.
        parts = [self._pop_auth_notice()]
        # First-turn mode suppresses only the base layer; dialectic is independent.
        if not (self._injection_frequency == "first-turn" and self._turn_count > 1):
            parts.append(self._fetch_base_context_layer(query, first_turn_base_deadline))
        self._first_turn_dialectic_wait(query)
        # Consume only results that are already ready; later turns never wait.
        parts.append(self._consume_pending_dialectic())
        parts = [p for p in parts if p and p.strip()]
        if not parts:
            return self._log_injection("fetched-but-empty")
        return self._log_injection("injected", self._truncate_to_budget("\n\n".join(parts)))

    def _pop_auth_notice(self) -> str:
        """One-time model-facing notice that Honcho auth expired and memory is paused."""
        # getattr (not a direct call): test fakes install minimal managers without pop_auth_notice.
        pop = getattr(self._manager, "pop_auth_notice", None)
        msg = pop() if callable(pop) else None
        if not isinstance(msg, str) or not msg:
            # Init failures discard the manager; the provider kept the detail.
            if self._init_auth_failure is None or self._init_auth_notice_emitted:
                return ""
            self._init_auth_notice_emitted = True
            msg = self._init_auth_failure
        return ("[Honcho memory status] Authentication with the Honcho memory backend has expired and automatic "
                f"token refresh failed, so memory sync and recall are paused. Reason: {msg}\n"
                "Tell the user (once) that Honcho memory is paused and that running 'hermes honcho setup' "
                "to re-authenticate will restore it.")

    def _peer_failure_text(self) -> str:
        """The stored peer failure plus the fix that fits the session's platform. On a gateway the fix is a
        user id from the transport, never peerName: a shared peerName would merge every user onto one peer."""
        text = self._init_peer_failure or ""
        if self._init_peer_platform in _LOCAL_PLATFORMS:
            return f"{text} Set one with 'hermes honcho peer --user <name>'."
        return f"{text} This platform supplied no user id for the chat, so memory stays off here."

    def _pop_peer_notice(self) -> str:
        """One-time model-facing notice that no user peer could be named and memory is off."""
        if self._init_peer_failure is None or self._init_peer_notice_emitted:
            return ""
        self._init_peer_notice_emitted = True
        if self._init_peer_platform in _LOCAL_PLATFORMS:
            advice = "Tell the user (once) that Honcho memory is off until honcho.json names a user peer."
        else:
            advice = ("Tell the user (once) that Honcho memory is off for this chat. Do not suggest peerName: "
                      "on a shared gateway it would merge every user onto one peer.")
        return f"[Honcho memory status] Honcho memory is off for this session. {self._peer_failure_text()}\n{advice}"

    def _truncate_to_budget(self, text: str) -> str:
        """Truncate text to the context_tokens budget (≈4 chars/token) at a word boundary."""
        if not self._config or not self._config.context_tokens:
            return text
        budget_chars = self._config.context_tokens * 4
        if len(text) <= budget_chars:
            return text
        truncated = text[:budget_chars]
        last_space = truncated.rfind(" ")
        return (truncated[:last_space] if last_space > budget_chars * 0.8 else truncated) + " …"

    def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
        """Fire background prefetch threads for the upcoming turn.
        Context and dialectic refreshes have independent cadence controls."""
        if self._cron_skipped or self._recall_mode == "tools" or self._recall_sync:
            return
        if not self._session_ready() or not query:
            self._start_session_init_background()
            return
        # Trivial prompts don't warrant either a context refresh or a dialectic call.
        if self._is_trivial_prompt(query):
            return

        # First-turn-only base context never needs a later refresh.
        context_due = self._context_cadence <= 1 or (self._turn_count - self._last_context_turn) >= self._context_cadence
        if self._injection_frequency != "first-turn" and context_due:
            self._last_context_turn = self._turn_count
            try:
                self._manager.prefetch_context(self._session_key, query)
            except Exception as e:
                logger.debug("Honcho context prefetch failed: %s", e)

        # Dialectic layer: a hung call older than timeout × multiplier counts as dead.
        if self._thread_is_live():
            logger.debug("Honcho dialectic prefetch skipped: prior thread still running")
            return
        # Cadence gate, widened by the empty-streak backoff so a persistently silent
        # backend doesn't retry every turn forever.
        effective = self._effective_cadence()
        if (self._turn_count - self._last_dialectic_turn) < effective:
            logger.debug("Honcho dialectic prefetch skipped: effective cadence %d (base %d, empty streak %d), turns since last: %d",
                         effective, self._dialectic_cadence, self._dialectic_empty_streak,
                         self._turn_count - self._last_dialectic_turn)
            return
        self._spawn_dialectic(query, thread_name="honcho-prefetch", fired_at=self._turn_count, log_label="prefetch")

    # Shared with the core prefetch gate so the two classifiers can never drift apart.
    _is_trivial_prompt = staticmethod(is_trivial_prompt)

    def identity_signature(self) -> Dict[str, Any]:
        """Identity-mapping values from honcho.json that bust a cached gateway agent when they change.

        Memoized on the file's mtime and size, so the per-message call is one stat. ``{}`` when the
        config cannot be read."""
        try:
            path = resolve_config_path()
            try:
                stat = path.stat()
                memo_key = (str(path), stat.st_mtime_ns, stat.st_size)
            except OSError:
                memo_key = (str(path), None, None)
            cached = self._identity_signature_memo.get(memo_key)
            if cached is not None:
                return dict(cached)
            cfg = HonchoClientConfig.from_global_config(config_path=path)
            aliases = cfg.user_peer_aliases if isinstance(cfg.user_peer_aliases, dict) else {}
            values = {
                "workspace": cfg.workspace_id,
                "user_identity": cfg.peer_name,
                "agent_identity": cfg.ai_peer,
                "pin_user_identity": bool(cfg.pin_peer_name),
                "runtime_identity_prefix": cfg.runtime_peer_prefix or "",
                "user_identity_aliases": sorted(aliases.items()),
                "session_prefixing": [bool(cfg.session_peer_prefix), bool(cfg.session_ai_peer_prefix)],
                "a2a_sessions": bool(cfg.a2a_sessions),
            }
            self._identity_signature_memo = {memo_key: values}
            return dict(values)
        except Exception:
            return {}

    def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
        """Track turn count for cadence, and record who wrote this turn: a shared session carries
        several participants, and the peer resolved at session init only names whoever opened it."""
        self._recall_generation = object()
        self._turn_count = turn_number
        self._turn_author = {"id": kwargs.get("author_id") or None, "name": kwargs.get("author_name") or None,
                             "is_bot": bool(kwargs.get("author_is_bot"))}

    def on_session_switch(self, new_session_id: str, **kwargs) -> None:
        """Discard in-flight recall even when the configured backend session is pinned."""
        self._recall_generation = object()

    # ----- Writes -----

    @staticmethod
    def _chunk_message(content: str, limit: int) -> list[str]:
        """Split content to fit the Honcho message limit, cutting at paragraph, then
        sentence, then word boundaries; continuation chunks get a "[continued] " prefix
        so Honcho's representation engine can reconstruct the full message."""
        if len(content) <= limit:
            return [content]

        prefix = "[continued] "
        chunks: list[str] = []
        remaining, first = content, True
        while remaining:
            effective = limit if first else limit - len(prefix)
            if len(remaining) <= effective:
                chunks.append(remaining if first else prefix + remaining)
                break

            segment = remaining[:effective]
            # Paragraph, then sentence (keeping ". "), then word boundary; else a hard cut.
            for sep in ("\n\n", ". ", " "):
                cut = segment.rfind(sep)
                if cut >= 0 and sep == ". ":
                    cut += 2
                if cut >= effective * 0.3:
                    break
            else:
                cut = effective

            chunk = remaining[:cut].rstrip()
            remaining = remaining[cut:].lstrip()
            chunks.append(chunk if first else prefix + chunk)
            first = False

        return chunks

    def sync_turn(
        self, user_content: str, assistant_content: str, *, session_id: str = "",
        turn_author: Optional[Dict[str, Any]] = None,
    ) -> None:
        """Record the conversation turn in Honcho (non-blocking), chunking messages that
        exceed the Honcho API limit. Honors saveMessages: false. ``turn_author`` names who wrote
        the user side. The ``on_turn_start`` stash is the fallback for callers that never pass it.
        A bot author's turn is written into that bot's own a2a session, never the human's."""
        if not self._writes_enabled():
            return
        if _is_internal_gateway_turn(user_content):
            logger.debug("Honcho sync skipped machine-generated gateway turn")
            return
        if not self._ready_or_kick_init():
            return

        msg_limit = self._config.message_max_chars if self._config else 25000
        clean_user_content = sanitize_context(user_content or "").strip()
        clean_assistant_content = sanitize_context(assistant_content or "").strip()
        # Skip only when the whole turn is empty: an interrupted or tool-only turn can have
        # an empty assistant side, and the user's message must still be persisted.
        if not clean_user_content and not clean_assistant_content:
            return

        author = turn_author if isinstance(turn_author, dict) else self._turn_author
        session_kwargs: dict[str, str] = {}
        if author.get("is_bot"):
            # A bot's turn never lands in the human's session: its own a2a session or nothing.
            if not getattr(self._config, "a2a_sessions", True):
                logger.debug("Honcho sync skipped a bot-authored turn because a2aSessions is off")
                return
            author_id = str(author.get("id") or "").strip()
            if not author_id:
                logger.debug("Honcho sync skipped a bot-authored turn that named no author id")
                return
            bot_peer_id = self._manager.resolve_author_peer_id(
                self._session_key, author_id, author.get("name"), is_bot=True)
            if not bot_peer_id or bot_peer_id == self._manager.assistant_peer_id():
                logger.debug("Honcho sync skipped a bot-authored turn: author %s has no peer of its own", author_id)
                return
            session_key = self._a2a_session_key({**author, "id": author_id})
            # The bot is the a2a session's own user peer, so its messages need no per-message author.
            session_kwargs["user_peer_id"] = bot_peer_id
            author_peer_id = None
        else:
            session_key = self._session_key
            # Resolved before the thread starts so a following turn cannot retag a queued write.
            author_peer_id = self._manager.resolve_author_peer_id(session_key, author.get("id"), author.get("name"))

        def _sync():
            session = self._manager.get_or_create(session_key, **session_kwargs)
            for chunk in self._chunk_message(clean_user_content, msg_limit) if clean_user_content else ():
                session.add_message("user", chunk, author_peer_id=author_peer_id)
            for chunk in self._chunk_message(clean_assistant_content, msg_limit) if clean_assistant_content else ():
                session.add_message("assistant", chunk)
            # save() (not _flush_session) so writeFrequency batching is honored.
            self._manager.save(session)

        if self._sync_thread and self._sync_thread.is_alive():
            self._sync_thread.join(timeout=5.0)
        self._sync_thread = self._spawn_write(_sync, "honcho-sync", "Honcho sync_turn failed: %s")

    def _a2a_session_key(self, author: Dict[str, Any]) -> str:
        """Honcho session for one sender bot's turns into this agent, named from core's ``a2a_key``.

        This agent's ``aiPeer`` is in the key because two profiles can share a workspace and a session
        key. The digest keeps two ids apart when sanitizing would make them equal."""
        prefix, _, ident = (a2a_key(author) or "").partition(":")
        digest = hashlib.sha256(ident.encode("utf-8")).hexdigest()[:8]
        recipient = assistant_peer_id_for(self._config)
        key = f"{self._session_key}:{prefix}:{recipient}:{sanitize_peer_id(ident)}-{digest}"
        return HonchoClientConfig._enforce_session_id_limit(key, key)

    def _bot_turn_write_refusal(self) -> Optional[str]:
        """Refusal for memory writes while a bot-authored turn runs. Conclusions and cards describe the human."""
        if self._turn_author.get("is_bot"):
            return tool_error("Honcho memory writes are off during a bot-to-bot turn. Conclusions and profile edits describe the human.")
        return None

    def _spawn_write(self, fn: Callable[[], None], name: str, fail_msg: str) -> threading.Thread:
        """Run a Honcho write off-thread; failures are debug-logged, never raised into the turn."""
        def _run():
            try:
                fn()
            except Exception as e:
                logger.debug(fail_msg, e)

        thread = spawn_context_thread(_run, name=name, owner=self)
        thread.start()
        return thread

    def on_memory_write(
        self, action: str, target: str, content: str, metadata: Optional[Dict[str, Any]] = None,
    ) -> None:
        """Mirror built-in user-profile writes as Honcho conclusions (``metadata`` accepted
        for interface compatibility, not yet threaded into the conclusion payload)."""
        if action != "add" or target != "user" or not content:
            return
        if self._turn_author.get("is_bot"):
            logger.debug("Honcho memory mirror skipped during a bot-authored turn")
            return
        if not self._writes_enabled() or not self._ready_or_kick_init():
            return
        self._memwrite_thread = self._spawn_write(lambda: self._manager.create_conclusion(self._session_key, content),
                                                  "honcho-memwrite", "Honcho memory mirror failed: %s")

    def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
        """Flush all pending messages to Honcho on session end."""
        if not self._writes_enabled() or not self._manager:
            return
        if not self._session_initialized and self._init_thread and self._init_thread.is_alive():
            return
        if self._sync_thread and self._sync_thread.is_alive():
            self._sync_thread.join(timeout=10.0)
        try:
            self._manager.flush_all()
        except Exception as e:
            logger.debug("Honcho session-end flush failed: %s", e)

    # ----- Tools -----

    def get_tool_schemas(self) -> List[Dict[str, Any]]:
        """Tool schemas by recall_mode; context-only mode exposes no Honcho tools."""
        if self._cron_skipped or self._recall_mode == "context":
            return []
        return list(ALL_TOOL_SCHEMAS)

    def _empty_profile_hint(self, peer: str) -> Dict[str, Any]:
        """Diagnostic hint for an empty honcho_profile card, so the model can explain WHY
        instead of surfacing a cryptic "no facts" to the user. Likely causes, in order:
        observation disabled for the peer; card not accumulated yet (fresh peer / few
        dialectic cycles); self-hosted Honcho < 3.x without peer-card support."""
        cfg = self._config
        reasons: List[str] = []
        kind = "user" if peer == "user" else "ai"
        if cfg is not None and not (getattr(cfg, f"{kind}_observe_me", True) or getattr(cfg, f"{kind}_observe_others", True)):
            reasons.append(f"observation is disabled for peer '{peer}' (user_observe_me/ai_observe_me in config)")
        cadence, turn = self._dialectic_cadence, self._turn_count
        if turn < max(2, cadence):
            reasons.append(f"this session has only {turn} turn(s); peer cards accumulate as the dialectic "
                           f"layer reasons over conversation history (cadence every {cadence} turn(s))")
        if not reasons:
            reasons.append("peer card has no facts yet — Honcho's dialectic layer builds this over time from "
                           "observed turns; self-hosted Honcho < 3.x does not support peer cards at all")
        return {
            "result": "No profile facts available yet.",
            "hint": ("This is not an error.  " + "; ".join(reasons)
                     + ".  Try honcho_reasoning for a synthesized answer, or honcho_search to query raw conversation excerpts."),
        }

    def _tool_profile(self, args: dict) -> str:
        peer = args.get("peer", "user")
        if card_update := args.get("card"):
            if refusal := self._bot_turn_write_refusal():
                return refusal
            result = self._manager.set_peer_card(self._session_key, card_update, peer=peer)
            if result is None:
                return tool_error("Failed to update peer card.")
            return json.dumps({"result": f"Peer card updated ({len(result)} facts).", "card": result})
        card = self._manager.get_peer_card(self._session_key, peer=peer)
        return json.dumps({"result": card} if card else self._empty_profile_hint(peer))

    def _tool_search(self, args: dict) -> str:
        if not (query := (args.get("query") or "").strip()):
            return tool_error("Missing required parameter: query")
        max_tokens = min(int(args.get("max_tokens", 800)), 2000)
        result = self._manager.search_context(self._session_key, query, max_tokens=max_tokens, peer=args.get("peer", "user"))
        return json.dumps({"result": result or "No relevant context found."})

    def _tool_reasoning(self, args: dict) -> str:
        from plugins.memory.honcho.session import HonchoAuthError

        if not (query := (args.get("query") or "").strip()):
            return tool_error("Missing required parameter: query")
        try:
            # Explicit reasoning bypasses the automatic-injection cap, and surfaces
            # timeouts/server errors as errors rather than an indistinguishable "no result".
            result = self._manager.dialectic_query(
                self._session_key, query, reasoning_level=args.get("reasoning_level"),
                peer=args.get("peer", "user"), apply_injection_cap=False, raise_errors=True,
            )
        except HonchoAuthError:
            raise  # rendered by handle_tool_call's auth-specific handler
        except Exception as e:
            logger.warning("honcho_reasoning failed: %s", e)
            return tool_error(
                f"Honcho reasoning query failed ({e}). This is a backend error, not an empty result — "
                "the peer may still have relevant context. Slow dialectic calls at higher reasoning levels "
                "can exceed the configured timeout; consider a lower reasoning_level or raising the "
                "'timeout' value in honcho.json."
            )
        # Auto-injection respects the cadence gap after an explicit call.
        self._last_dialectic_turn = self._turn_count
        return json.dumps({"result": result or "No result from Honcho."})

    def _tool_context(self, args: dict) -> str:
        ctx = self._manager.get_session_context(self._session_key, peer=args.get("peer", "user"))
        if not ctx:
            return json.dumps({"result": "No context available yet."})
        sections = (("Summary", usable_honcho_summary(ctx.get("summary"))),
                    ("Representation", ctx.get("representation")), ("Card", ctx.get("card")))
        parts = [f"## {header}\n{value}" for header, value in sections if value]
        if recent := ctx.get("recent_messages"):
            parts.append("## Recent messages\n" + "\n".join(f"  [{m['role']}] {m['content'][:200]}" for m in recent[-5:]))
        return json.dumps({"result": "\n\n".join(parts) or "No context available."})

    def _tool_conclude(self, args: dict) -> str:
        delete_id = (args.get("delete_id") or "").strip()
        conclusion = args.get("conclusion", "").strip()
        list_mode = bool(args.get("list"))
        peer = args.get("peer", "user")
        if sum([bool(delete_id), bool(conclusion), list_mode]) != 1:
            return tool_error("Exactly one of conclusion, delete_id, or list must be provided.")
        query = (args.get("query") or "").strip()
        if query and not list_mode:
            return tool_error("query is only valid when list is true.")

        if list_mode:
            return json.dumps({"conclusions": self._manager.list_conclusions(self._session_key, query=query or None, peer=peer)})
        if refusal := self._bot_turn_write_refusal():
            return refusal
        if delete_id:
            if self._manager.delete_conclusion(self._session_key, delete_id, peer=peer):
                return json.dumps({"result": f"Conclusion {delete_id} deleted."})
            return tool_error(f"Failed to delete conclusion {delete_id}.")
        if self._manager.create_conclusion(self._session_key, conclusion, peer=peer):
            return json.dumps({"result": f"Conclusion saved for {peer}: {conclusion}"})
        return tool_error("Failed to save conclusion.")

    _TOOL_HANDLERS = {
        "honcho_profile": _tool_profile,
        "honcho_search": _tool_search,
        "honcho_reasoning": _tool_reasoning,
        "honcho_context": _tool_context,
        "honcho_conclude": _tool_conclude,
    }

    def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
        """Dispatch a Honcho tool call, lazily initializing the session in tools-only mode."""
        from plugins.memory.honcho.session import HonchoAuthError

        if self._cron_skipped:
            return tool_error("Honcho is not active (cron context).")
        if not self._session_initialized:
            if self._init_thread and self._init_thread.is_alive():
                return tool_error("Honcho session is still initializing; try again shortly.")
            if not self._ensure_session():
                if self._init_auth_failure:
                    return tool_error(f"Honcho memory authentication failed: {self._init_auth_failure}")
                if self._init_peer_failure:
                    return tool_error(self._peer_failure_text())
                return tool_error("Honcho session could not be initialized.")
        if not self._manager or not self._session_key:
            return tool_error("Honcho is not active for this session.")
        if (handler := self._TOOL_HANDLERS.get(tool_name)) is None:
            return tool_error(f"Unknown tool: {tool_name}")
        try:
            return handler(self, args)
        except HonchoAuthError as e:
            # Never report an auth failure as an empty result; the model would read it as "no memory".
            logger.error("Honcho tool %s failed: authentication rejected", tool_name)
            return tool_error(f"Honcho memory authentication failed: {e}")
        except Exception as e:
            logger.error("Honcho tool %s failed: %s", tool_name, e)
            return tool_error(f"Honcho {tool_name} failed: {e}")

    # Shutdown never joins for less than this. A thread blocked in httpx can hold the HTTP timeout.
    _SHUTDOWN_JOIN_FLOOR = 5.0

    def _shutdown_join_budget(self) -> float:
        """The floor, or the configured HTTP timeout when longer, so a thread blocked in a Honcho call can finish."""
        from plugins.memory.honcho.client_cache import _resolve_timeout_from_sources
        return max(self._SHUTDOWN_JOIN_FLOOR, _resolve_timeout_from_sources(self._config))

    def shutdown(self) -> None:
        """Join the write threads, flush and stop the manager, then join every other thread this provider or its
        manager spawned, all within one budget. A daemon thread still blocked in httpx I/O at exit aborts the process."""
        self._recall_generation = object()
        budget = self._shutdown_join_budget()
        deadline = time.monotonic() + budget
        for t in (self._sync_thread, self._memwrite_thread):
            if t and t.is_alive():
                t.join(timeout=max(0.0, deadline - time.monotonic()))
        manager = self._manager
        if manager and not (self._init_thread and self._init_thread.is_alive() and not self._session_initialized):
            # saveMessages: false skips persistence, but the async-writer thread must still be joined.
            with contextlib.suppress(Exception):
                remaining = max(0.0, deadline - time.monotonic())
                if getattr(self._config, "save_messages", True):
                    manager.shutdown(timeout=remaining)  # flush_all() + join the writer
                else:
                    manager.stop_async_writer(timeout=remaining)
        left = join_plugin_threads((self, manager), timeout=max(0.0, deadline - time.monotonic()))
        if left:
            logger.warning("Honcho shutdown timed out after %.1fs with %d thread(s) still running: %s",
                           budget, len(left), ", ".join(left))


def register(ctx) -> None:
    """Register Honcho as a memory provider plugin."""
    from plugins.memory.query_rewrite import rewrite_memory_query

    ctx.register_memory_provider(HonchoMemoryProvider(query_rewriter=rewrite_memory_query))
