feat(desktop): openExternalFileForIpc opens files via OS handler

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-17 22:28:07 +08:00
3398 changed files with 379040 additions and 22836 deletions
+4 -3
View File
@@ -62,12 +62,13 @@ def _get_project_plugins_dir() -> Optional[Path]:
def _is_memory_provider_dir(path: Path) -> bool:
"""Cheap text heuristic (no import): ``__init__.py`` mentions the memory provider contract."""
init_file = path / "__init__.py"
if not init_file.exists():
return False
try:
if not init_file.exists():
return False
source = init_file.read_text(errors="replace", encoding="utf-8")[:8192]
return "register_memory_provider" in source or "MemoryProvider" in source
except Exception:
except OSError as exc: # one mode-000 / ACL-denied child must not abort discovery
logger.warning("Skipping unreadable plugin directory %s: %s", path, exc)
return False
+28 -3
View File
@@ -17,7 +17,7 @@ import threading
from pathlib import Path
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from agent.memory_provider import MemoryProvider, spawn_context_thread
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -76,6 +76,31 @@ def _resolve_brv_path() -> Optional[str]:
return _cached_brv_path or None
def _brv_child_env(brv_path: str) -> Dict[str, str]:
"""Env for the ``brv`` child. Under gateway.multiplex_profiles ``os.environ`` holds the
LAUNCH profile's ``.env``; the served profile's ``BRV_*`` (cloud identity) live only in its
secret scope, so they are resolved through the scope — a miss means no cloud key, never
another profile's — and the launch profile's ``.env`` residue is stripped. Outside multiplex
the process env IS this profile's own and is passed through unchanged."""
from agent.secret_scope import UnscopedSecretError, get_secret, is_multiplex_active
from hermes_constants import get_hermes_home_override
from tools.environments.local import build_subprocess_env, strip_launch_profile_env
env = build_subprocess_env(scrub_secrets=False)
if is_multiplex_active():
env = strip_launch_profile_env(env, get_hermes_home_override())
for key in [k for k in env if k.startswith("BRV_")]:
env.pop(key, None)
try:
api_key = get_secret("BRV_API_KEY", "")
except UnscopedSecretError:
api_key = ""
if api_key:
env["BRV_API_KEY"] = api_key
env["PATH"] = str(Path(brv_path).parent) + os.pathsep + env.get("PATH", "")
return env
def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT, cwd: str = None) -> dict:
"""Run a brv CLI command. Returns {success, output, error}."""
global _cached_brv_path
@@ -84,7 +109,7 @@ def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT, cwd: str = None) ->
return {"success": False, "error": "brv CLI not found. Install: npm install -g byterover-cli"}
effective_cwd = cwd or str(_get_brv_cwd())
Path(effective_cwd).mkdir(parents=True, exist_ok=True)
env = {**os.environ, "PATH": str(Path(brv_path).parent) + os.pathsep + os.environ.get("PATH", "")}
env = _brv_child_env(brv_path)
try:
result = subprocess.run(
[brv_path] + args, capture_output=True, text=True, encoding='utf-8', errors='replace',
@@ -171,7 +196,7 @@ class ByteRoverMemoryProvider(MemoryProvider):
except Exception as e:
logger.debug("ByteRover %s failed: %s", what, e)
t = threading.Thread(target=_work, daemon=True, name=name)
t = spawn_context_thread(_work, name=name)
t.start()
return t
+40 -28
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import asyncio
import atexit
import contextlib
import contextvars
import json
import logging
import os
@@ -25,12 +24,13 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from agent.memory_provider import MemoryProvider, RecallStatus
from agent.secret_scope import get_secret
from agent.memory_provider import MemoryProvider, RecallStatus, spawn_context_thread
from agent.secret_scope import UnscopedSecretError, get_secret
from hermes_cli.config import cfg_get
from hermes_constants import get_hermes_home
from hermes_time import now as _hermes_now
from tools.registry import tool_error
from utils import read_json_or_empty
from .embedded import (
_RETRIABLE_CONNECTION_MARKERS, _build_embedded_profile_env,
@@ -63,6 +63,30 @@ def _ensure_client_dependency() -> None:
raise ImportError(str(exc)) from exc
def _scoped_setting(name: str, default: str = "") -> str:
"""Profile-scoped read of a retain SHAPING value, with the provider's own default on a miss.
Under ``gateway.multiplex_profiles`` ``os.environ`` holds the DEFAULT profile's ``.env``, so a miss
is a miss — never ``os.environ`` (same rule as the daemon's key and base URL in ``embedded.py``).
Single-profile deployments are unchanged: with no scope installed ``get_secret`` still reads the
process env, where the value IS this profile's own.
Deliberately narrower than a bare ``get_secret``: this helper is only for presentation shaping
(retain source label, speaker prefixes, tags). The isolation-critical values — ``mode``,
``apiKey`` and the ``bankId`` data partition — read through bare ``get_secret`` above and so
still fail loud on a scopeless multiplexed read, matching the other scoped credential readers.
In ``_load_config`` that read happens FIRST, so a missing scope raises on ``HINDSIGHT_MODE``
before this helper is ever reached; swallowing here therefore cannot mask an isolation failure.
What it does avoid is losing the whole memory provider (``initialize`` failing, and the manager
logging + dropping it) because a speaker prefix could not be resolved.
"""
try:
value = get_secret(name, default)
except UnscopedSecretError:
return default
return default if value is None else value
def _cloud_api_key(config: dict) -> str:
return config.get("apiKey") or config.get("api_key") or get_secret("HINDSIGHT_API_KEY", "")
@@ -185,14 +209,6 @@ def _run_sync(coro, timeout: float = _DEFAULT_TIMEOUT):
return future.result(timeout=timeout)
def _context_thread(target, name: str) -> threading.Thread:
"""Daemon thread running *target* in a snapshot of the spawner's contextvars.
Threads start with an EMPTY Context; under multiplex_profiles get_secret fails
closed without the profile's secret scope + HERMES_HOME override. (The shared
loop needs no wrap: run_coroutine_threadsafe inherits the submitter's context.)"""
return threading.Thread(target=contextvars.copy_context().run, args=(target,), daemon=True, name=name)
RETAIN_SCHEMA = {
"name": "hindsight_retain",
"description": (
@@ -243,9 +259,9 @@ def _load_config() -> dict:
"""$HERMES_HOME/hindsight/config.json (profile-scoped), else ~/.hindsight/config.json
(legacy, shared), else environment variables."""
for path in (get_hermes_home() / "hindsight" / "config.json", Path.home() / ".hindsight" / "config.json"):
if path.exists():
with contextlib.suppress(Exception):
return json.loads(path.read_text(encoding="utf-8"))
# A corrupt (or empty) file falls through to the next source, as before the dedup.
if path.exists() and (data := read_json_or_empty(path)):
return data
# Mode, bank (the data partition), endpoint and retain shaping are per-profile .env values like
# the key beside them: read through the secret scope so a multiplexed secondary never inherits
# the default profile's bank/mode. Tuning knobs (timeouts, budget) stay process-global.
@@ -256,9 +272,9 @@ def _load_config() -> dict:
"idle_timeout": _parse_int_setting(os.environ.get("HINDSIGHT_IDLE_TIMEOUT"), _DEFAULT_IDLE_TIMEOUT),
"retain_tags": get_secret("HINDSIGHT_RETAIN_TAGS", "") or "",
"observation_scopes": get_secret("HINDSIGHT_RETAIN_OBSERVATION_SCOPES", "") or "",
"retain_source": os.environ.get("HINDSIGHT_RETAIN_SOURCE", _DEFAULT_RETAIN_SOURCE),
"retain_user_prefix": os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User"),
"retain_assistant_prefix": os.environ.get("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant"),
"retain_source": _scoped_setting("HINDSIGHT_RETAIN_SOURCE", _DEFAULT_RETAIN_SOURCE),
"retain_user_prefix": _scoped_setting("HINDSIGHT_RETAIN_USER_PREFIX", "User"),
"retain_assistant_prefix": _scoped_setting("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant"),
"banks": {"hermes": {"bankId": get_secret("HINDSIGHT_BANK_ID", "") or "hermes",
"budget": os.environ.get("HINDSIGHT_BUDGET", "mid"), "enabled": True}},
}
@@ -389,13 +405,7 @@ class HindsightMemoryProvider(MemoryProvider):
"""Merge *values* into $HERMES_HOME/hindsight/config.json."""
from utils import atomic_json_write
config_path = Path(hermes_home) / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
existing = {}
if config_path.exists():
with contextlib.suppress(Exception):
existing = json.loads(config_path.read_text(encoding="utf-8"))
existing.update(values)
atomic_json_write(config_path, existing, mode=0o600)
atomic_json_write(config_path, {**read_json_or_empty(config_path), **values}, mode=0o600)
def post_setup(self, hermes_home: str, config: dict) -> None:
"""Custom setup wizard — installs only the deps needed for the selected mode."""
@@ -519,7 +529,7 @@ class HindsightMemoryProvider(MemoryProvider):
return
# A previous writer may have exited after shutdown(); allow the fresh one to drain.
self._shutting_down.clear()
thread = _context_thread(self._writer_loop, "hindsight-writer")
thread = spawn_context_thread(self._writer_loop, name="hindsight-writer")
self._writer_thread = self._sync_thread = thread
thread.start()
@@ -738,7 +748,9 @@ class HindsightMemoryProvider(MemoryProvider):
def _apply_retain_settings(self, cfg: dict) -> None:
def _cfg_or_env(key: str, env_var: str, default: str = "") -> Any:
return cfg.get(key) or os.environ.get(env_var, default)
# The env half is the same per-profile value ``_load_config`` resolves through the scope;
# a raw read here handed a multiplexed secondary the DEFAULT profile's retain shaping back.
return cfg.get(key) or _scoped_setting(env_var, default)
self._retain_tags = _normalize_retain_tags(_cfg_or_env("retain_tags", "HINDSIGHT_RETAIN_TAGS"))
self._tags = self._retain_tags or None
@@ -805,7 +817,7 @@ class HindsightMemoryProvider(MemoryProvider):
print(f" ⚠ {msg}", file=sys.stderr, flush=True)
self._mode = "disabled"
return
_context_thread(self._daemon_start_worker, "hindsight-daemon-start").start()
spawn_context_thread(self._daemon_start_worker, name="hindsight-daemon-start").start()
def _daemon_start_worker(self) -> None:
import traceback
@@ -951,7 +963,7 @@ class HindsightMemoryProvider(MemoryProvider):
with self._prefetch_lock:
self._prefetch_result, self._prefetch_count = text, count
self._prefetch_thread = _context_thread(_run, "hindsight-prefetch")
self._prefetch_thread = spawn_context_thread(_run, name="hindsight-prefetch")
self._prefetch_thread.start()
# -- retain ------------------------------------------------------------------
+1 -1
View File
@@ -168,7 +168,7 @@ def run_setup(provider, hermes_home: str, config: dict) -> None:
materialized_config = dict(provider_config)
with contextlib.suppress(Exception):
materialized_config = json.loads(
(Path(hermes_home) / "hindsight" / "config.json").read_text(encoding="utf-8")
(Path(hermes_home) / "hindsight" / "config.json").read_text(encoding="utf-8-sig")
)
llm_api_key = (
env_writes.get("HINDSIGHT_LLM_API_KEY", "")
+4 -10
View File
@@ -125,16 +125,10 @@ class HolographicMemoryProvider(MemoryProvider):
def save_config(self, values, hermes_home):
"""Write config to config.yaml under plugins.hermes-memory-store."""
config_path = Path(hermes_home) / "config.yaml"
try:
import yaml
from hermes_cli.config import read_user_config_raw # raw read: merged defaults must not be persisted
existing = read_user_config_raw(config_path)
existing.setdefault("plugins", {})["hermes-memory-store"] = values
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(existing, f, default_flow_style=False)
except Exception:
pass
# The canonical writer: config lock, managed-mode refusal, default stripping, atomic replace.
# ``merge_existing`` keeps every other section; *hermes_home* is the active profile already.
from hermes_cli.config import save_config
save_config({"plugins": {"hermes-memory-store": dict(values)}}, merge_existing=True)
def get_config_schema(self):
from hermes_constants import display_hermes_home
+2 -1
View File
@@ -129,7 +129,8 @@ class MemoryStore:
apply_wal_with_fallback(self._conn, db_label="memory_store.db (holographic)")
self._conn.executescript(_SCHEMA)
if "hrr_vector" not in {row[1] for row in self._conn.execute("PRAGMA table_info(facts)").fetchall()}:
self._conn.execute("ALTER TABLE facts ADD COLUMN hrr_vector BLOB")
from hermes_cli.sqlite_util import add_column_if_missing
add_column_if_missing(self._conn, "facts", "hrr_vector", "hrr_vector BLOB")
self._conn.commit()
def _one(self, sql: str, params=()):
+32 -11
View File
@@ -203,6 +203,8 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"7654321": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer |
| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_7654321`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape |
A dashboard login is a runtime identity too. The desktop passes `<provider>:<user id>` (for example `basic:alice` or `oidc:google-oauth2|1183…`) as the runtime user, so a logged-in session resolves like a gateway user: alias, then prefix, else the raw id. Enabling dashboard login on an install that ran on `peerName` moves new sessions to that peer. Keep the old history with `pinUserPeer: true` or a `userPeerAliases` entry for the login id. Without a login the desktop still uses `peerName`.
> **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it.
**Resolver ladder** (first match wins):
@@ -214,9 +216,11 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
4. runtimePeerPrefix + runtime_id → namespaced peer, with sha256 collision escalation
5. raw sanitized runtime_id → fallback peer
6. peerName → no runtime ID at all (CLI/TUI)
7. session-key fallback → no config either
7. neither → session init fails with a one-time notice; no peer is minted
```
Step 7 used to derive a peer from the session key (`user-default-<dir>`). That put a desktop or CLI session with no `peerName` on a phantom peer per directory, so its turns and memory never reached the operator's own peer (#93326). Set `peerName` (`hermes honcho peer --user <name>`) or run under a gateway that supplies a user ID.
**Why no `pinAiPeer`?** The AI peer is already pinned by construction — `aiPeer` is the only AI-side identity setting and the resolver never overrides it. Only the user-side peer has the runtime-vs-config tension that `pinUserPeer` resolves.
**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`.
@@ -229,6 +233,12 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
Pick **[e]** at the prompt to set the three keys directly instead of going through the tree.
**Interactive mapping — `hermes honcho peers map`.** The setup tree covers the common shapes; `hermes honcho peers map` is the full identity view for a gateway with many users and agents. It joins two sources: the workspace's peers fetched from the Honcho API (labeled from local config — your peer, each profile's AI peer, alias targets, runtime peers of seen accounts, `user-*` fallback peers, honestly `unrecognized` otherwise), and the gateway accounts recorded in the local session store (platform, runtime ID, name, and what each currently resolves to, with `✓` when that peer already exists and `○ new` when it would be created on first message).
Mapping targets are picked from the workspace list (`p3`) rather than typed, so a typo cannot silently create a fresh peer; typing a name stays available for the deliberate new-peer case, and `-` clears an alias. Every assignment states its consequence: aliases move future messages only, and a runtime peer left behind keeps its history. `p<N>` alone peeks at a peer's card. `w` lists every workspace the key can reach — the wrong-workspace fallback when the peers shown aren't yours — and lets you browse one and, on explicit confirmation, repoint the profile's `workspace` at it. For a standalone workspace browser beyond mapping, [honcho-cli](https://pypi.org/project/honcho-cli/) is an optional companion (`uv tool install honcho-cli`).
With multiple profiles: saving a root-cascading map asks whether the edit applies to all profiles (root) or forks this profile's host block; a root write with profiles on other workspaces warns that picked peers may not exist there; and the accounts table marks siblings that resolve the same account to a different peer (`≠ dreamer→bob`). Offline, the command degrades to typed targets over the local account list. `hermes honcho peers` without `map` stays a read-only view.
**Un-pinning (single → per-user).** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, choose the **pooled** path — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The wizard offers this steer automatically when it detects you're un-pinning a previously pinned profile.
### Memory & Recall
@@ -252,8 +262,9 @@ Pick **[e]** at the prompt to set the three keys directly instead of going throu
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `sessionStrategy` | string | `"per-directory"` | `"per-directory"`, `"per-session"`, `"per-repo"` (git root), `"global"` |
| `sessionPeerPrefix` | bool | `false` | Prepend peer name to session keys |
| `a2aSessions` | bool | `true` | Write DMs from other bots into their own session per sender bot. `false` skips bot-authored turns |
| `sessionPeerPrefix` | bool | `false` | Prepend the user peer name to session keys |
| `sessionAiPeerPrefix` | bool | `false` | Prepend the AI peer (`aiPeer`) to session keys — keeps sessions disjoint when multiple AI peers share a workspace |
| `sessions` | object | `{}` | Manual directory-to-session-name mappings |
#### Session Name Resolution
@@ -262,17 +273,25 @@ The Honcho session name determines which conversation bucket memory lands in. Re
| Priority | Source | Example session name |
|----------|--------|---------------------|
| 1 | Manual map (`sessions` config) | `"myproject-main"` |
| 2 | `/title` command (mid-session rename) | `"refactor-auth"` |
| 3 | Gateway session key (Telegram, Discord, etc.) | `"agent-main-telegram-dm-8439114563"` |
| 4 | `per-session` strategy | Hermes session ID (`20260415_a3f2b1`) |
| 1 | Gateway session key (Telegram, Discord, etc.) | `"agent-main-telegram-dm-8439114563"` |
| 2 | `per-session` strategy | Hermes session ID (`20260415_a3f2b1`) |
| 3 | Manual map (`sessions` config) | `"myproject-main"` |
| 4 | Explicit `/title` command (non-automatic title) | `"refactor-auth"` |
| 5 | `per-repo` strategy | Git root directory name (`hermes-agent`) |
| 6 | `per-directory` strategy | Current directory basename (`src`) |
| 7 | `global` strategy | Workspace name (`hermes`) |
| 6 | `per-directory` strategy | Directory basename (`my-project`) |
| 7 | `global` strategy | Workspace name |
Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions.
Messaging gateway platforms always resolve via priority 1 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting controls non-gateway sessions such as CLI and Desktop.
If `sessionPeerPrefix` is `true`, the peer name is prepended: `alice-hermes-agent`.
Directory strategies and manual mappings use the logical session workspace, not the backend process's launch directory. Desktop/TUI and ACP pass the workspace during agent construction; deferred Desktop/TUI builds use the same session cwd. With no non-empty construction cwd, Honcho uses the runtime resolver: session cwd context, scoped `terminal.cwd`, then the launch directory. No process-wide `chdir` is needed.
Automatically generated Hermes titles (`derived` or `llm`) are display metadata and do not override `sessionStrategy`. An explicit user title remains an intentional session-name override for non-gateway, non-`per-session` sessions.
Sessions created before title provenance was recorded retain legacy behavior: because an old automatic title cannot be distinguished from an old user title, a title with no source is treated as an explicit override.
If `sessionPeerPrefix` is `true`, the user peer name is prepended: `alice-hermes-agent`.
If `sessionAiPeerPrefix` is `true`, the AI peer (`aiPeer`) is prepended to the final name on **every** path — including priority 3. This is the symmetric counterpart to `sessionPeerPrefix` and exists because the gateway session key is AI-peer-agnostic: when several AI peers share one workspace, peer name, and gateway chat key, they would otherwise collide on a single session. With `aiPeer: ivy`, priority 3 becomes `ivy-agent-main-telegram-dm-8439114563`.
#### Bot DMs (`a2aSessions`)
@@ -280,7 +299,7 @@ In bot mode another Hermes profile can DM this agent. The relay marks that turn
#### What each strategy produces
- **`per-directory`** — basename of `$PWD`. Opening hermes in `~/code/myapp` and `~/code/other` gives two separate sessions. Same directory = same session across runs.
- **`per-directory`** — basename of the logical session working directory. Opening Hermes in `~/code/myapp` and `~/code/other` gives two separate sessions. Same directory = same session across runs.
- **`per-repo`** — git root directory name. All subdirectories within a repo share one session. Falls back to `per-directory` if not inside a git repo.
- **`per-session`** — Hermes session ID (timestamp + hex). Every `hermes` invocation starts a fresh Honcho session. Falls back to `per-directory` if no session ID is available.
- **`global`** — workspace name. One session for everything. Memory accumulates across all directories and runs.
@@ -340,6 +359,8 @@ Host key is derived from the active Hermes profile: `hermes` (default) or `herme
| `contextCadence` | int | `1` | Minimum turns between base context refreshes (session summary + representation + card) |
| `dialecticCadence` | int | `1` | Minimum turns between dialectic `.chat()` firings |
| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject base context on the first user message only; the dialectic supplement keeps its own cadence) |
| `injection.sessionStart` | list | unset | Which base-context sections to inject: any of `summary`, `peerRepresentation`, `peerCard`, `aiRepresentation`, `aiCard`. Unset injects all of them in that order. `[]` injects nothing. A host block with an `injection` object replaces the root pin wholesale |
| `logging` | bool | `false` | Append one JSON record per turn to `~/.honcho/injection.log` saying what was injected and why. The file is owner-only and holds the user's representation verbatim. `HONCHO_LOGGING=1` also switches it on and `HONCHO_INJECTION_LOG=<path>` moves it |
| `queryRewrite` | bool | `false` | Rewrite the latest message into a retrieval query before dialectic (one extra auxiliary LLM call per cycle) |
| `firstTurnBaseWait` | float | `3.0` | Max seconds turn 1 waits for base context / session init. `0` disables the wait (fully async; context surfaces on later turns). Turns 2+ never wait on a stalled init |
| `firstTurnDialecticWait` | float | `2.0` | Max seconds turn 1 waits for a dialectic result. `0` disables |
+190 -46
View File
@@ -11,6 +11,7 @@ import contextlib
import hashlib
import json
import logging
import os
import re
import threading
import time
@@ -18,10 +19,14 @@ 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, spawn_context_thread
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
@@ -79,13 +84,26 @@ _PROMPT_HEADERS = {
),
}
# (context key, section header) for the injected base-context block, in display order.
_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", "Session Summary"),
("representation", "User Representation"),
("card", "User Peer Card"),
("ai_representation", "AI Self-Representation"),
("ai_card", "AI Identity Card"),
("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."
@@ -127,6 +145,11 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
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
@@ -151,6 +174,10 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
# 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
@@ -166,16 +193,15 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
return False
def save_config(self, values, hermes_home):
"""Merge ``values`` into $HERMES_HOME/honcho.json (Honcho SDK native format)."""
"""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.client import _read_config
from plugins.memory.honcho.oauth import _config_refresh_lock, _read_config_strict, _refresh_lock
config_path = Path(hermes_home) / "honcho.json"
try:
existing = _read_config(config_path)
except Exception:
existing = {}
atomic_json_write(config_path, {**existing, **values}, mode=0o600)
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 [
@@ -213,6 +239,9 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
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"):
@@ -246,8 +275,13 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
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"
@@ -256,8 +290,9 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
def _run_session_init(self, label: str) -> bool:
"""Run _do_session_init with the deferred kwargs; on failure discard the manager
and (for auth failures) keep the detail for the one-time notice."""
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
@@ -272,6 +307,11 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
# 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
@@ -292,7 +332,8 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
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")
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)
@@ -374,9 +415,37 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
# ----- 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:
"""Format the prefetch context dict into a readable system prompt block."""
return "\n\n".join(f"## {header}\n{ctx.get(key, '')}" for key, header in _CONTEXT_SECTIONS if ctx.get(key, ""))
"""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).
@@ -385,6 +454,42 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
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)
@@ -456,13 +561,13 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
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 ""
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()
result = prefetch_sync(self, query)
return "\n\n".join(part for part in (notice, result) if part)
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)
@@ -473,13 +578,13 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
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 auth init still owes the user the one-time notice.
return self._pop_auth_notice()
# 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._truncate_to_budget(ready) if ready else ""
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()]
@@ -490,7 +595,9 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
# 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()]
return self._truncate_to_budget("\n\n".join(parts)) if parts else ""
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."""
@@ -508,6 +615,26 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
"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:
@@ -581,7 +708,7 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
"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)],
"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}
@@ -717,8 +844,7 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
return tool_error("Honcho memory writes are off during a bot-to-bot turn. Conclusions and profile edits describe the human.")
return None
@staticmethod
def _spawn_write(fn: Callable[[], None], name: str, fail_msg: str) -> threading.Thread:
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:
@@ -726,7 +852,7 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
except Exception as e:
logger.debug(fail_msg, e)
thread = spawn_context_thread(_run, name=name)
thread = spawn_context_thread(_run, name=name, owner=self)
thread.start()
return thread
@@ -838,9 +964,9 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
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."})
parts = [f"## {header}\n{ctx[key]}"
for key, header in (("summary", "Summary"), ("representation", "Representation"), ("card", "Card"))
if ctx.get(key)]
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."})
@@ -886,8 +1012,11 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
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():
return tool_error(f"Honcho memory authentication failed: {self._init_auth_failure}"
if self._init_auth_failure else "Honcho session could not be initialized.")
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:
@@ -902,21 +1031,36 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
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()
for t in (self._prefetch_thread, self._sync_thread, self._memwrite_thread):
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=5.0)
t.join(timeout=max(0.0, deadline - time.monotonic()))
manager = self._manager
if not manager or (self._init_thread and self._init_thread.is_alive() and not self._session_initialized):
return
# saveMessages: false skips persistence, but the async-writer thread must still
# be joined so daemon threads aren't left blocked in httpx I/O at interpreter exit.
with contextlib.suppress(Exception):
if getattr(self._config, "save_messages", True):
manager.shutdown() # flush_all() + join the writer
else:
manager.stop_async_writer()
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:
+614 -53
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import copy
import json
import os
import sys
@@ -9,7 +10,9 @@ from pathlib import Path
from hermes_constants import get_hermes_home
from plugins.memory.honcho.client import _first_parsed, _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST
from plugins.memory.honcho.session_peers import sanitize_peer_id
from hermes_cli.config import cfg_get
from utils import read_json_or_empty
RULE = "─" * 40
REASONING_LEVELS = ("minimal", "low", "medium", "high", "max")
@@ -23,7 +26,7 @@ _INHERITED_KEYS = (
"recallSync",
)
# clone_honcho_for_profile also carries the operator's runtime-to-peer routing intent.
_CLONE_KEYS = _INHERITED_KEYS[:3] + ("sessionPeerPrefix",) + _INHERITED_KEYS[3:] + (
_CLONE_KEYS = _INHERITED_KEYS[:3] + ("sessionPeerPrefix", "sessionAiPeerPrefix") + _INHERITED_KEYS[3:] + (
"pinUserPeer", "userPeerAliases", "runtimePeerPrefix",
)
_IDENTITY_MAPPING_KEYS = ("pinPeerName", "pinUserPeer", "userPeerAliases", "runtimePeerPrefix")
@@ -65,18 +68,92 @@ def _local_config_path() -> Path:
return get_hermes_home() / "honcho.json"
class _ReadConfig(dict):
"""A command's config, with the ``snapshot`` and ``path`` _write_config() needs to apply only its edits."""
def __init__(self, raw: dict, path: Path):
super().__init__(raw)
self.snapshot, self.path = copy.deepcopy(raw), path
def _read_config() -> dict:
path = _config_path()
return _ReadConfig(read_json_or_empty(path), path)
class ConfigWriteRefused(Exception):
"""honcho.json exists on disk but does not parse, so no command may overwrite it."""
def _refuse_unparseable(path: Path) -> dict:
"""Return ``path``'s parsed content ({} when absent); raise ConfigWriteRefused when it exists but cannot
be parsed, since writing back ``{}`` would drop every host."""
from plugins.memory.honcho.oauth import _read_config_strict
try:
return json.loads(_config_path().read_text(encoding="utf-8"))
except Exception:
return {}
return _read_config_strict(path)
except (OSError, ValueError) as e:
raise ConfigWriteRefused(f"{path} exists but could not be read as JSON ({e}). Nothing was written. "
"Fix or move the file, then re-run.") from e
def _apply_edits(base: dict, edited: dict, current: dict) -> dict:
"""Return ``current`` with the root keys and ``hosts.<h>.<k>`` the command changed (``base`` to ``edited``)
applied. An untouched key keeps its on-disk value, so a rotation that landed while the command ran survives."""
out = copy.deepcopy(current)
for key in (set(base) | set(edited)) - {"hosts"}:
if key in edited and edited[key] != base.get(key):
out[key] = copy.deepcopy(edited[key])
elif key not in edited and key in base:
out.pop(key, None)
base_hosts, edited_hosts = base.get("hosts") or {}, edited.get("hosts") or {}
out_hosts = out.setdefault("hosts", {}) if (base_hosts or edited_hosts or "hosts" in current) else None
for host in set(base_hosts) | set(edited_hosts):
if host not in edited_hosts:
out_hosts.pop(host, None)
continue
b, e = base_hosts.get(host) or {}, edited_hosts[host]
block = out_hosts.setdefault(host, {})
for key in set(b) | set(e):
if key in e and e[key] != b.get(key):
block[key] = copy.deepcopy(e[key])
elif key not in e and key in b:
block.pop(key, None)
return out
def _overlay_local(seed: dict, local: dict) -> dict:
"""Return ``seed`` with ``local``'s root keys and ``hosts.<h>.<k>`` keys on top. The local file wins,
so a grant or rotation it already holds is what the command's edits land on."""
out = copy.deepcopy(seed)
for key, value in local.items():
if key != "hosts":
out[key] = copy.deepcopy(value)
for host, block in (local.get("hosts") or {}).items():
out.setdefault("hosts", {}).setdefault(host, {}).update(copy.deepcopy(block))
return out
def _write_config(cfg: dict, path: Path | None = None) -> None:
path = path or _local_config_path()
path.parent.mkdir(parents=True, exist_ok=True)
"""Persist ``cfg`` under the token refresh's cross-process lock. The object _read_config() returned
has only its edits applied onto a fresh read of disk; a plain dict is written whole. A read that
resolved to a seed file (~/.honcho or a profile) is written whole only while ``path`` does not exist."""
from plugins.memory.honcho.oauth import _config_refresh_lock, _refresh_lock
from utils import atomic_json_write
atomic_json_write(path, cfg, mode=0o600)
path = path or _local_config_path()
# The file lock is best-effort; _refresh_lock is what keeps an in-process refresh thread out.
with _refresh_lock, _config_refresh_lock(path):
disk = _refuse_unparseable(path)
out = cfg
if isinstance(cfg, _ReadConfig):
if cfg.path == path:
out = _apply_edits(cfg.snapshot, cfg, disk)
elif path.exists():
out = _apply_edits(cfg.snapshot, cfg, _overlay_local(cfg.snapshot, disk))
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
atomic_json_write(path, out, mode=0o600)
if isinstance(cfg, _ReadConfig): # a later write on the same object applies only edits made after this one
cfg.snapshot, cfg.path = copy.deepcopy(dict(cfg)), path
def _label(host: str) -> str:
@@ -113,15 +190,16 @@ def _default_block_and_key(cfg: dict) -> tuple[dict, bool]:
return cfg_get(cfg, "hosts", HOST, default={}), bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY"))
def _resolve_api_key(cfg: dict) -> str:
"""API key with host -> root -> env fallback. A self-hosted ``baseUrl`` without a key
yields ``"local"`` so credential guards accept it: the URL must be http/https (so
``baseUrl: true`` can't pass) or a schemeless host:port (legacy ``localhost:8000``;
the SDK rejects those itself)."""
key = _host_block(cfg, _host_key()).get("apiKey") or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
def _resolve_api_key(cfg: dict, block: dict | None = None, *, env: bool = True) -> str:
"""API key for ``block`` (default: the active host's block), host -> root -> env. A self-hosted
http(s) or host:port ``baseUrl`` without a key yields "local" so credential guards accept it.
``env=False`` counts only what is on disk: a variable can vanish from the next process."""
block = _host_block(cfg, _host_key()) if block is None else block
key = (block.get("apiKey") or cfg.get("apiKey", "") or (os.environ.get("HONCHO_API_KEY", "") if env else ""))
if key:
return key
base_url = (cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "") or "").strip()
base_url = (block.get("baseUrl") or block.get("base_url") or cfg.get("baseUrl") or cfg.get("base_url")
or (os.environ.get("HONCHO_BASE_URL", "") if env else "") or "").strip()
if not base_url:
return key
from urllib.parse import urlparse
@@ -217,8 +295,10 @@ def clone_honcho_for_profile(profile_name: str) -> bool:
new_block["pinUserPeer"] = default_block["pinPeerName"]
# AI peer is profile-specific (bare profile name: Honcho peer IDs allow no dots);
# workspace is shared so all profiles see the same context.
new_block.update(aiPeer=profile_name, workspace=_pref(default_block, cfg, "workspace") or HOST,
enabled=default_block.get("enabled", True))
new_block.update(aiPeer=profile_name, workspace=_pref(default_block, cfg, "workspace") or HOST)
# The default host's apiKey is not inherited; the block stays unenabled until this profile signs in.
if _resolve_api_key(cfg, new_block, env=False):
new_block["enabled"] = default_block.get("enabled", True)
cfg.setdefault("hosts", {})[new_host] = new_block
_write_config(cfg)
_ensure_peer_exists(new_host) # eager so the peer exists before first message
@@ -242,7 +322,11 @@ def _sync_profiles(verbose: bool) -> int:
created = skipped = 0
for p in (p for p in profiles if p.name != "default"):
if clone_honcho_for_profile(p.name):
try:
cloned = clone_honcho_for_profile(p.name)
except ConfigWriteRefused as e:
return say(f" {e}\n") or created
if cloned:
say(f" + {p.name} -> {profile_host_key(p.name)}")
created += 1
else:
@@ -265,11 +349,16 @@ def sync_honcho_profiles_quiet() -> int:
def cmd_enable(args) -> None:
"""Enable Honcho for the active profile."""
"""Enable Honcho for the active profile; refuses a block that cannot authenticate."""
cfg = _read_config()
host = _host_key()
label = _label(host)
block = cfg.setdefault("hosts", {}).setdefault(host, {})
if not _resolve_api_key(cfg, block, env=False):
profile = _active_profile_name()
setup = "hermes honcho setup" + (f" --target-profile {profile}" if profile != "default" else "")
return print(f" {label}Honcho stays disabled: no API key or base URL is configured for this profile, and the default "
f"profile's key is not shared.\n Run '{setup}' to sign in, or set apiKey on hosts.{host} in {_config_path()}.\n")
if block.get("enabled") is True:
return print(f" {label}Honcho is already enabled.\n")
block["enabled"] = True
@@ -395,7 +484,7 @@ def _configure_raw_identity_mapping(hermes_host, current_pin, current_aliases, c
"runtimePeerPrefix — namespace for unknown IDs (blank for none)")
def _setup_identity_mapping(cfg: dict, hermes_host: dict, current_peer: str) -> None:
def _setup_identity_mapping(cfg: dict, hermes_host: dict, current_peer: str, new_host: bool) -> None:
"""Gateway identity mapping step. Only the gateway supplies a runtime user ID (CLI/TUI/
desktop fall through to peerName), so the step is gated on gateway detection."""
current_pin, current_aliases, current_prefix, aliases_from_root, prefix_from_root = (
@@ -407,34 +496,50 @@ def _setup_identity_mapping(cfg: dict, hermes_host: dict, current_peer: str) ->
print(f"\n Gateway platforms detected: {', '.join(gw_platforms)}")
else:
notice, question = (
("\n Gateway identity mapping routes platform users to memory peers.",
("\n Each gateway account (a Telegram user, a Discord user, ...)\n"
" resolves to a peer. Honcho builds one representation per peer.",
"Running the Hermes gateway (Telegram/Discord/etc.)? (y/N)") if gw_platforms is None else
("\n No gateway platforms connected — identity mapping only affects\n"
" gateway users, so this step doesn't apply here.", "Configure gateway mapping anyway? (y/N)"))
("\n No gateway platforms connected — nothing to map.", "Configure anyway? (y/N)"))
print(notice)
if not _yes(_prompt(question, default="n")):
return
peer_target = hermes_host.get("peerName") or current_peer or "user"
default_choice = {"single": "1", "hybrid": "2"}.get(current_shape, "3")
print("\n How should gateway users map to memory peers?\n"
" [1] just me — every non-agent user collapses to your peer\n"
" [2] me + other people — keep mine pooled, others separate\n"
" [3] only other people — everyone gets their own peer\n"
" [s] skip (leave untouched) [e] edit raw keys")
ai_peer_label = hermes_host.get("aiPeer") or cfg.get("aiPeer") or "hermes"
# Fresh configs default to the personal shape; configured ones keep their detected shape.
identity_configured = not new_host or any(k in cfg for k in _IDENTITY_MAPPING_KEYS)
default_choice = {"single": "1", "hybrid": "2", "multi": "3"}[current_shape] if identity_configured else "1"
print("\n This step covers the HUMAN mapping only. Each account using the\n"
" gateway resolves to a peer — the entity Honcho reasons about over\n"
f" time. This agent is already its own peer ('{ai_peer_label}'), and each\n"
" Hermes profile brings its own AI peer to the gateway.\n"
"\n How should accounts resolve?\n"
" [1] single peer — one person uses this agent; every account\n"
f" resolves to '{peer_target}'. The common personal setup.\n"
" Never for a gateway serving other people — their memory\n"
" would merge into yours\n"
" [2] your peer + one per other account — your accounts are\n"
f" aliased to '{peer_target}'; each other account gets its own\n"
" peer until you alias it. For a gateway you share\n"
" [3] one peer per account — no aliases; every account is its\n"
" own peer. For agents serving other people\n"
" [s] skip (leave untouched) [e] edit raw keys\n"
f"\n Tip: alias your Telegram UID and your Discord ID to '{peer_target}' —\n"
" both accounts then resolve to one peer.")
choice = _prompt("Choice", default=default_choice).strip().lower()
if choice in {"2", "me+others", "both"}:
pooled = _prompt(" Keep my own memory pooled across platforms? (Y/n)", default="y").strip().lower()
pooled = _prompt(" Resolve all YOUR accounts to one peer? (Y/n)", default="y").strip().lower()
shape = "hybrid" if pooled in {"y", "yes", ""} else "multi"
else:
shape = _SHAPE_CHOICES.get(choice, "skip")
# Un-pinning without aliasing strands the pooled peerName history; steer toward pooling.
if current_pin and shape == "multi":
print(f"\n ⚠ Un-pinning will orphan memory accumulated under peer\n"
f" '{peer_target}'. Existing gateway users resolve to fresh,\n empty peers.")
if _prompt(" Pool my own memory instead (alias my IDs to peerName)? (Y/n)", default="y").strip().lower() in {"y", "yes", ""}:
print(f"\n ⚠ The peer '{peer_target}' already has a representation built\n"
f" from your messages. One peer per account means your accounts\n"
f" resolve to new peers with no history.")
if _prompt(f" Keep your accounts resolving to '{peer_target}' instead? (Y/n)", default="y").strip().lower() in {"y", "yes", ""}:
shape = "hybrid"
if shape == "skip":
@@ -451,7 +556,7 @@ def _setup_identity_mapping(cfg: dict, hermes_host: dict, current_peer: str) ->
_scrub_identity_mapping(hermes_host) # each shape starts from a clean slate
hermes_host["pinUserPeer"] = shape == "single"
if shape == "single":
print(f" All non-agent gateway users route to '{peer_target}' (pin overrides aliases).")
print(f" Every gateway account resolves to peer '{peer_target}'.")
else:
aliases = prior_aliases if shape == "multi" else _collect_operator_aliases(prior_aliases, peer_target)
if aliases:
@@ -459,8 +564,8 @@ def _setup_identity_mapping(cfg: dict, hermes_host: dict, current_peer: str) ->
_apply_runtime_prefix(hermes_host, current_prefix, prefix_from_root,
"Runtime peer prefix (e.g. 'telegram_', blank for none)" if shape == "multi" else
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)")
print(" Each gateway user → own peer." if shape == "multi" else
f" Your runtime IDs → '{peer_target}', others → own peer.")
print(" Each gateway account resolves to its own peer." if shape == "multi" else
f" Your accounts resolve to '{peer_target}'; each other account to its own peer.")
_echo_identity_mapping(hermes_host)
@@ -506,10 +611,13 @@ def _headless() -> tuple[bool, bool]:
return False, True
def _apply_grant_to_host(hermes_host: dict, cred) -> None:
"""Store an OAuth grant on the host block; the wizard's final save persists it."""
def _apply_grant_to_host(cfg: dict, hermes_host: dict, cred) -> None:
"""Store an OAuth grant on the host block and in ``cfg``'s snapshot. install_grant already wrote it to disk,
so the final save must not copy it over a rotation that lands during the later prompts."""
hermes_host["apiKey"] = cred.access_token
hermes_host["oauth"] = cred.oauth_block()
if (snapshot := getattr(cfg, "snapshot", None)) is not None:
snapshot.setdefault("hosts", {}).setdefault(_host_key(), {}).update(apiKey=cred.access_token, oauth=cred.oauth_block())
if cred.consent_peer_name: # default the peer prompt to the consent name
hermes_host["peerName"] = cred.consent_peer_name
print(" Authorized — token saved. Let's finish configuring.\n")
@@ -537,7 +645,7 @@ def _setup_local_auth(cfg: dict, hermes_host: dict) -> None:
print("\n No local JWT set. Local no-auth ready.")
def _setup_device_login(hermes_host: dict, write_path: Path, *, open_browser: bool) -> bool:
def _setup_device_login(cfg: dict, hermes_host: dict, write_path: Path, *, open_browser: bool) -> bool:
"""RFC 8628 device-code sign-in. Returns False if setup must abort."""
from plugins.memory.honcho.oauth_flow import (
AccessDenied, AuthorizationTimeout, DeviceCode, DeviceCodeExpired, DeviceFlowError, authorize_via_device_code,
@@ -567,12 +675,12 @@ def _setup_device_login(hermes_host: dict, write_path: Path, *, open_browser: bo
if isinstance(e, DeviceFlowError) and e.error == "http_429" else f"\n Device sign-in failed: {e}\n" + _RETRY_HINT)
else:
print(" approved")
_apply_grant_to_host(hermes_host, cred)
_apply_grant_to_host(cfg, hermes_host, cred)
return True
return False
def _setup_browser_login(hermes_host: dict, write_path: Path) -> bool:
def _setup_browser_login(cfg: dict, hermes_host: dict, write_path: Path) -> bool:
"""Loopback OAuth sign-in. Tokens merge into the in-memory cfg so the wizard's final save
keeps them; settings stay wizard-owned (apply_config=False). Returns False on abort."""
from plugins.memory.honcho.oauth_flow import authorize_via_loopback
@@ -588,14 +696,14 @@ def _setup_browser_login(hermes_host: dict, write_path: Path) -> bool:
except Exception as e:
print(f" OAuth sign-in failed: {e}\n" + _RETRY_HINT)
return False
_apply_grant_to_host(hermes_host, cred)
_apply_grant_to_host(cfg, hermes_host, cred)
return True
def _setup_cloud_auth(cfg: dict, hermes_host: dict, write_path: Path) -> bool:
"""Cloud auth: OAuth (browser), device code, or API key. Returns False on abort."""
cfg.pop("baseUrl", None) # cloud uses SDK default
from plugins.memory.honcho.oauth import OAuthCredential
from plugins.memory.honcho.oauth import OAuthCredential, is_oauth_access_token
existing_oauth = OAuthCredential.from_host_block(hermes_host)
device_available = _device_login_available()
is_remote, can_browse = _headless()
@@ -617,17 +725,23 @@ def _setup_cloud_auth(cfg: dict, hermes_host: dict, write_path: Path) -> bool:
default=default_method).strip().lower()
if device_available and method in {"device", "d"}:
return _setup_device_login(hermes_host, write_path, open_browser=can_browse and not is_remote)
return _setup_device_login(cfg, hermes_host, write_path, open_browser=can_browse and not is_remote)
if method in {"oauth", "o"}:
return _setup_browser_login(hermes_host, write_path)
print(f"\n Current API key: {_mask(cfg.get('apiKey', ''))}")
return _setup_browser_login(cfg, hermes_host, write_path)
# A leftover grant on the host block would shadow the pasted key.
stale_grant = existing_oauth is not None or is_oauth_access_token(hermes_host.get("apiKey"))
current = ("" if stale_grant else hermes_host.get("apiKey", "")) or cfg.get("apiKey", "")
print(f"\n Current API key: {_mask(current)}")
if new_key := _prompt("Honcho API key (leave blank to keep current)", secret=True):
cfg["apiKey"] = new_key
if cfg.get("apiKey"):
return True
print("\n No API key configured. Get yours at https://app.honcho.dev\n"
" Run 'hermes honcho setup' again once you have a key.\n")
return False
key = new_key or current
if not key:
print("\n No API key configured. Get yours at https://app.honcho.dev\n"
" Run 'hermes honcho setup' again once you have a key.\n")
return False
hermes_host.pop("oauth", None)
hermes_host["apiKey"] = key
return True
def _menu(header: str, *lines: str) -> None:
@@ -704,8 +818,16 @@ def _setup_tuning(cfg: dict, hermes_host: dict) -> None:
def cmd_setup(args) -> None:
"""Interactive Honcho setup wizard."""
try:
_setup_wizard(args)
except ConfigWriteRefused as e:
print(f" {e}\n")
def _setup_wizard(args) -> None:
cfg = _read_config()
write_path, read_path = _local_config_path(), _config_path()
_refuse_unparseable(write_path) # before the questions, not after them
print(f"\nHoncho memory setup\n{RULE}\n Honcho gives Hermes persistent cross-session memory.\n Config: {write_path}")
if read_path != write_path and read_path.exists():
print(f" (seeding from existing config at {read_path})")
@@ -716,6 +838,8 @@ def cmd_setup(args) -> None:
hermes_host = cfg.setdefault("hosts", {}).setdefault(_host_key(), {})
_migrate_pin_key(cfg) # canonicalize legacy pinPeerName before detection/writes
_migrate_pin_key(hermes_host)
# Taken before the prompts populate the block: an existing install must not default to pinning every account.
new_host = not any(k in hermes_host or k in cfg for k in (*_IDENTITY_MAPPING_KEYS, "peerName", "workspace", "enabled"))
# --- 1. Cloud or local? ---
print(" Deployment:\n cloud -- Honcho cloud (api.honcho.dev)\n local -- self-hosted Honcho server")
@@ -738,7 +862,9 @@ def cmd_setup(args) -> None:
if new := _prompt(label, default=default):
hermes_host[key] = new
_setup_identity_mapping(cfg, hermes_host, current_peer)
_setup_identity_mapping(cfg, hermes_host, current_peer, new_host)
print("\n For a gateway with many users and agents, run\n"
" 'hermes honcho peers map' to map accounts interactively.")
_setup_tuning(cfg, hermes_host)
hermes_host["enabled"] = True
@@ -924,8 +1050,437 @@ def _cmd_status_all() -> None:
print("\n * active profile\n")
def _state_db_path() -> Path:
"""Return the state.db path for the targeted profile."""
if _profile_override and _profile_override not in {"default", "custom"}:
try:
from hermes_cli.profiles import get_profile_dir
return get_profile_dir(_profile_override) / "state.db"
except Exception:
pass
return get_hermes_home() / "state.db"
def _seen_gateway_accounts(db_path: Path) -> list[dict]:
"""Gateway accounts recorded in state.db, most recent first.
A session row keeps only its last routing peer, so a shared thread contributes its most recent
author and not every participant. The row's origin does not record whether the author was a bot.
"""
if not db_path.exists():
return []
import sqlite3
from contextlib import closing
# profile_name marks which profile a multiplexing gateway routed the
# session to; older state.db files predate the column.
query = """SELECT source, user_id,
MAX(COALESCE(display_name, '')),
MAX(COALESCE(origin_json, '')),
COUNT(*){profiles_col}
FROM sessions
WHERE user_id IS NOT NULL AND user_id != ''
GROUP BY source, user_id
ORDER BY MAX(COALESCE(started_at, 0)) DESC"""
try:
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
try:
rows = conn.execute(query.format(
profiles_col=", GROUP_CONCAT(DISTINCT COALESCE(profile_name, 'default'))",
)).fetchall()
except sqlite3.OperationalError:
rows = [r + (None,) for r in conn.execute(query.format(profiles_col="")).fetchall()]
except sqlite3.Error as e:
print(f" (state.db unreadable: {e}; the accounts list is unavailable)", file=sys.stderr)
return []
accounts = []
for source, user_id, display_name, origin_json, count, profiles in rows:
try:
origin = dict(json.loads(origin_json)) if origin_json else {}
except Exception:
origin = {}
accounts.append({
"platform": source or "?",
"user_id": str(user_id),
"user_id_alt": str(origin.get("user_id_alt") or ""),
"label": origin.get("user_name") or display_name or "",
"sessions": count,
"profiles": sorted(profiles.split(",")) if profiles else [],
})
return accounts
def _preview_peer_resolution(
user_id: str, *, pin: bool, aliases: dict, prefix: str, peer_name: str,
user_id_alt: str = "",
) -> str:
"""Resolve through the runtime resolver and label the rung that decided: pin, alias, prefix, raw.
Only the runtime knows when a prefixed id gets a hash suffix, so the CLI must not recompute it."""
from plugins.memory.honcho.client import HonchoClientConfig
from plugins.memory.honcho.session import HonchoSessionManager
config = HonchoClientConfig(peer_name=peer_name or None, pin_peer_name=bool(pin),
user_peer_aliases=aliases, runtime_peer_prefix=prefix)
manager = HonchoSessionManager(config=config, runtime_user_peer_name=user_id,
runtime_user_peer_name_alt=user_id_alt or None)
resolved = manager._resolve_user_peer_id("preview")
if pin and peer_name:
return f"{resolved} (pinned)"
# The runtime resolver tries the alt ID (Signal UUID, Feishu union_id) after the primary.
aliased = any(isinstance(aliases.get(rid), str) and aliases[rid].strip() for rid in (user_id, user_id_alt) if rid)
if not aliased and prefix.strip():
return f"{resolved} (prefixed)"
return resolved
def _resolution_base(resolved: str) -> str:
"""Strip display suffixes so the name can be checked against peer IDs."""
return resolved.removesuffix(" (pinned)").removesuffix(" (prefixed)")
# Workspaces holding thousands of peers (public bots) must not stall the CLI.
_PEERS_MAP_FETCH_CAP = 200
def _peers_map_client(workspace: str | None = None):
"""(client, config) for the active host, or (None, None) offline. ``workspace`` overrides the configured one."""
try:
from dataclasses import replace
from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client
hcfg = HonchoClientConfig.from_global_config(host=_host_key())
if not (hcfg.api_key or hcfg.base_url):
return None, None
if workspace and workspace != hcfg.workspace_id:
hcfg = replace(hcfg, workspace_id=workspace)
return get_honcho_client(hcfg), hcfg
except Exception:
return None, None
def _api_workspace_peers(client) -> list[str] | None:
"""Workspace peer IDs, at most _PEERS_MAP_FETCH_CAP. None = API unavailable."""
if client is None:
return None
try:
peers: list[str] = []
page = client.peers(page=1, size=50)
# Iterating a SyncPage walks every following page; .items is the one page asked for.
while True:
peers += [str(p.id) for p in page.items]
if len(peers) >= _PEERS_MAP_FETCH_CAP or not page.has_next_page():
return peers[:_PEERS_MAP_FETCH_CAP]
page = page.get_next_page()
except Exception:
return None
def _api_workspaces(client) -> list[str] | None:
"""Workspace IDs this key can reach. None = API unavailable."""
if client is None:
return None
try:
return [str(w) for w in client.workspaces(size=50).items]
except Exception:
return None
def _api_peer_detail(client, peer_id: str) -> str:
"""Short peek at a peer: its card, or a clear absence note."""
try:
card = client.peer(peer_id).get_card()
if card:
text = str(card).strip()
return text[:400] + ("…" if len(text) > 400 else "")
return "(no peer card yet)"
except Exception as e:
return f"(peer detail unavailable: {e})"
def _classify_workspace_peers(
peer_ids: list[str], cfg: dict, accounts: list[dict],
aliases: dict, prefix: str, profile_rows: list[tuple[str, str, dict]],
) -> dict[str, str]:
"""Label workspace peers from local config; 'unrecognized' when honest."""
labels: dict[str, str] = {}
active_host = _host_key()
root_peer = cfg.get("peerName") or ""
hermes_hosts = {hostk for _, hostk, _ in profile_rows}
for name, hostk, block in profile_rows:
pn = block.get("peerName") or root_peer
ai = block.get("aiPeer") or cfg.get("aiPeer") or hostk
if pn:
labels.setdefault(
sanitize_peer_id(pn),
"your peer (peerName)" if hostk == active_host
else f"peerName of profile {name}",
)
who = "this profile" if hostk == active_host else f"profile {name}"
labels.setdefault(sanitize_peer_id(ai), f"AI peer · {who}")
# Host blocks that are not Hermes profiles: other apps sharing the config.
for hostk, block in (cfg.get("hosts") or {}).items():
if hostk in hermes_hosts or not isinstance(block, dict):
continue
for key, kind in (("peerName", "peer"), ("aiPeer", "AI peer")):
val = block.get(key)
if isinstance(val, str) and val.strip():
labels.setdefault(sanitize_peer_id(val.strip()), f"{kind} of app '{hostk}'")
for target in aliases.values():
if isinstance(target, str) and target.strip():
labels.setdefault(sanitize_peer_id(target.strip()), "alias target")
for acct in accounts:
rid = acct["user_id"]
for candidate in ([rid, prefix + rid] if prefix else [rid]):
labels.setdefault(sanitize_peer_id(candidate), f"runtime peer · {acct['platform']} {rid}")
return {
pid: labels.get(pid) or ("fallback peer (pre-identity traffic)" if pid.startswith("user-") else "unrecognized")
for pid in peer_ids
}
def _sibling_resolutions(cfg: dict, acct: dict, profile_rows: list[tuple[str, str, dict]]) -> dict[str, str]:
"""Resolved peer per profile for one account (profile name → peer)."""
out = {}
for name, _hostk, block in profile_rows:
pin, aliases, prefix, _, _ = _resolve_effective_identity_mapping(cfg, block)
out[name] = _resolution_base(_preview_peer_resolution(
acct["user_id"], pin=pin, aliases=aliases, prefix=prefix,
peer_name=block.get("peerName") or cfg.get("peerName") or "",
user_id_alt=acct["user_id_alt"],
))
return out
def _render_peers_map_view(
workspace: str, ws_peers: list[str] | None, labels: dict,
accounts: list[dict], cfg: dict, profile_rows: list[tuple[str, str, dict]], *,
pin: bool, working: dict, prefix: str, peer_name: str,
) -> None:
if ws_peers is None:
print(f"\nWorkspace '{workspace}' — peers unavailable (offline or not configured)")
print(" Mapping still works; target peers are typed instead of picked.")
else:
print(f"\nWorkspace '{workspace}' — {len(ws_peers)} peers\n" + "─" * 62)
if not ws_peers:
print(" No peers here yet — peers appear after the first conversation.")
print(" Wrong workspace? 'w' lists the workspaces this key can see.")
for i, pid in enumerate(ws_peers, 1):
print(f" p{i:<4} {pid:<30} {labels.get(pid, '')}")
if len(ws_peers) >= _PEERS_MAP_FETCH_CAP:
print(f" … listing capped at {_PEERS_MAP_FETCH_CAP} peers.")
if ws_peers and not any(v.startswith(("your peer", "AI peer")) for v in labels.values()):
print(f"\n None of these match your configured identity ('{peer_name or workspace}').")
print(" Wrong workspace? 'w' lists the workspaces this key can see.")
active_profile = _active_profile_name()
print(f"\nGateway accounts seen on this machine ({len(accounts)})\n" + "─" * 62)
if not accounts:
print(" None recorded yet. Accounts appear here after the gateway")
print(" handles a message from them. You can still map a runtime ID")
print(" by typing it at the prompt below.")
return
print(f" {'#':<4} {'Platform':<10} {'Runtime ID':<22} {'Name':<14} {'Resolves to'}")
known = set(ws_peers or [])
for idx, acct in enumerate(accounts, 1):
resolved = _preview_peer_resolution(
acct["user_id"], pin=pin, aliases=working, prefix=prefix,
peer_name=peer_name, user_id_alt=acct["user_id_alt"],
)
mine = _resolution_base(resolved)
marker = "" if ws_peers is None else (" ✓" if mine in known else " ○ new")
diverging = {
n: v for n, v in _sibling_resolutions(cfg, acct, profile_rows).items()
if n != active_profile and v != mine
}
div = " ≠ " + ", ".join(f"{n}→{v}" for n, v in sorted(diverging.items())) if diverging else ""
profiles = acct.get("profiles") or []
via = f" (traffic → {', '.join(profiles)})" if profiles and active_profile not in profiles else ""
print(
f" {idx:<4} {acct['platform']:<10} {acct['user_id']:<22} "
f"{acct['label'][:13]:<14} {resolved}{marker}{div}{via}"
)
def _workspaces_flow(client, current_ws: str, cfg: dict, host: str):
"""List reachable workspaces; browse one; optionally repoint the profile.
Returns (workspace, client, ws_peers) after a confirmed switch, else None.
"""
ws_list = _api_workspaces(client)
if not ws_list:
print(" Workspace list unavailable (offline or not configured).")
return None
print(f"\n Workspaces this key can see ({len(ws_list)}):")
for i, w in enumerate(ws_list, 1):
print(f" {i:<4} {w}{' ← current' if w == current_ws else ''}")
print("\n Tip: for a full workspace browser, install honcho-cli")
print(" (uv tool install honcho-cli).")
sel = _prompt("Browse a workspace (number, blank to go back)", default="").strip()
if not (sel.isdigit() and 1 <= int(sel) <= len(ws_list)):
return None
target_ws = ws_list[int(sel) - 1]
b_client, _ = _peers_map_client(workspace=target_ws)
b_peers = _api_workspace_peers(b_client)
if b_peers is None:
print(f" Could not list peers of '{target_ws}'.")
return None
print(f"\n Workspace '{target_ws}' — {len(b_peers)} peers")
for pid in b_peers[:30]:
print(f" {pid}")
if len(b_peers) > 30:
print(f" … and {len(b_peers) - 30} more")
if target_ws == current_ws:
return None
if not _yes(_prompt(
f"Point this profile at '{target_ws}'? Existing memory stays in '{current_ws}'. (y/N)",
default="n",
)):
return None
cfg.setdefault("hosts", {}).setdefault(host, {})["workspace"] = target_ws
_write_config(cfg)
print(f" workspace → '{target_ws}' (written to host block [{host}])")
return target_ws, b_client, b_peers
def _save_alias_map(cfg: dict, host: str, working: dict, aliases_from_root: bool) -> None:
"""Persist the edited alias map, asking for scope when it is shared."""
profiles = _all_profile_host_configs()
write_root = aliases_from_root
if aliases_from_root and len(profiles) > 1:
scope = _prompt("Apply to all profiles (root) or only this profile? (all/this)", default="all")
if scope.strip().lower() in {"this", "t", "host", "only"}:
write_root = False
print(f" This forks [{host}] from the shared root map — future root")
print(" edits no longer reach this profile.")
target = cfg if write_root else cfg.setdefault("hosts", {}).setdefault(host, {})
# An empty host map is an explicit override; popping the key would re-inherit the root aliases.
target["userPeerAliases"] = working
target_desc = f"host block [{host}]"
if write_root:
target_desc = "root config (shared by all profiles)"
active_ws = _host_block(cfg, host).get("workspace") or cfg.get("workspace") or host
other_ws: dict[str, list[str]] = {}
for name, hostk, block in profiles:
ws = block.get("workspace") or cfg.get("workspace") or hostk
if ws != active_ws:
other_ws.setdefault(ws, []).append(name)
for ws, names in sorted(other_ws.items()):
print(f" ⚠ root aliases also apply in workspace '{ws}' (profile")
print(f" {', '.join(names)}) — picked peers may not exist there.")
_write_config(cfg)
print(f"\n userPeerAliases = {working}")
if not working and not write_root and cfg.get("userPeerAliases"):
print(f" (empty host map: root aliases no longer apply to [{host}])")
print(f" written to {target_desc} in {_local_config_path()}\n")
def cmd_peers_map(args) -> None:
"""Interactively map gateway accounts to Honcho user peers."""
cfg = _read_config()
host = _host_key()
hermes_host = _host_block(cfg, host)
pin, aliases, prefix, aliases_from_root, _ = _resolve_effective_identity_mapping(cfg, hermes_host)
peer_name = hermes_host.get("peerName") or cfg.get("peerName") or ""
if pin:
print("\n pinUserPeer is on: every gateway account resolves to peer")
print(f" '{peer_name or '(peerName not set)'}' and aliases have no effect.")
print(" Turn the pin off with 'hermes honcho setup' to use per-account peers.")
if not _yes(_prompt("Edit aliases anyway? (y/N)", default="n")):
print(" Nothing changed.\n")
return
accounts = _seen_gateway_accounts(_state_db_path())
working = dict(aliases) if isinstance(aliases, dict) else {}
client, client_cfg = _peers_map_client()
workspace = (
getattr(client_cfg, "workspace_id", None)
or hermes_host.get("workspace") or cfg.get("workspace") or host
)
ws_peers = _api_workspace_peers(client)
# list_profiles() parses every profile's config.yaml; one scan serves every row and re-render.
profile_rows = _all_profile_host_configs()
def show() -> dict[str, str]:
labels = _classify_workspace_peers(ws_peers or [], cfg, accounts, working, prefix, profile_rows)
_render_peers_map_view(workspace, ws_peers, labels, accounts, cfg, profile_rows,
pin=pin, working=working, prefix=prefix, peer_name=peer_name)
return labels
labels = show()
print("\n Map: account number or a runtime ID · pN inspects a peer ·")
print(" w lists workspaces · blank finishes.")
changed = repointed = False
while True:
sel = _prompt("Account (blank to finish)", default="").strip()
if not sel:
break
low = sel.lower()
if low == "w":
switched = _workspaces_flow(client, workspace, cfg, host)
if switched:
workspace, client, ws_peers = switched
labels = show()
repointed = True
continue
if low.startswith("p") and low[1:].isdigit() and ws_peers:
n = int(low[1:])
if 1 <= n <= len(ws_peers):
pid = ws_peers[n - 1]
print(f"\n {pid} — {labels.get(pid, '')}")
print(f" {_api_peer_detail(client, pid)}\n")
continue
if sel.isdigit() and 1 <= int(sel) <= len(accounts):
acct = accounts[int(sel) - 1]
rid, alt = acct["user_id"], acct["user_id_alt"]
label = f"{acct['platform']} {rid}" + (f" ({acct['label']})" if acct["label"] else "")
else:
rid, alt, label = sel, "", sel
prev_resolved = _resolution_base(_preview_peer_resolution(
rid, pin=pin, aliases=working, prefix=prefix, peer_name=peer_name, user_id_alt=alt,
))
current = working.get(rid, "")
hint = " (pN from the peers table, a name, '-' clears)" if ws_peers else ""
entered = _prompt(f"Peer for {label}{hint}", default=current).strip()
if entered == "-":
if rid in working:
del working[rid]
changed = True
print(f" cleared: {rid}")
continue
if ws_peers and entered[:1].lower() == "p" and entered[1:].isdigit() and 0 < int(entered[1:]) <= len(ws_peers):
entered = ws_peers[int(entered[1:]) - 1]
if entered and entered != current:
working[rid] = entered
changed = True
print(f" {rid} → {entered} — future messages resolve to '{entered}'")
if ws_peers is not None and sanitize_peer_id(entered) not in ws_peers:
print(f" '{entered}' is a new peer — created on first message.")
if prev_resolved in (ws_peers or ()) and prev_resolved != sanitize_peer_id(entered):
print(f" peer '{prev_resolved}' keeps its existing history.")
if not changed:
print(" Aliases unchanged.\n" if repointed else " Nothing changed.\n")
return
_save_alias_map(cfg, host, working, aliases_from_root)
def cmd_peers(args) -> None:
"""Show peer identities across all profiles."""
if getattr(args, "peers_action", None) == "map":
cmd_peers_map(args)
return
rows = _all_profile_host_configs()
cfg = _read_config()
print(f"\nHoncho peer identities ({len(rows)} profiles)\n{'─' * 50}\n"
@@ -1294,7 +1849,10 @@ _SUBCOMMANDS = (
("status", "Show current Honcho config and connection status", cmd_status, (
("--all", dict(action="store_true", help="Show config overview across all profiles")),
)),
("peers", "Show peer identities across all profiles", cmd_peers, ()),
("peers", "Show peer identities across all profiles ('peers map' to map gateway accounts)", cmd_peers, (
("peers_action", dict(nargs="?", default=None, choices=("map",), metavar="map",
help="'map': interactively map gateway accounts to user peers")),
)),
("sessions", "List known Honcho session mappings", cmd_sessions, ()),
("map", "Map current directory to a Honcho session name (no arg = list mappings)", cmd_map, (
("session_name", dict(nargs="?", default=None,
@@ -1343,7 +1901,10 @@ def honcho_command(args) -> None:
if handler is None:
return print(f" Unknown honcho command: {sub}\n"
" Available: status, sessions, map, peer, mode, strategy, tokens, identity, migrate, enable, disable, sync\n")
handler(args)
try:
handler(args)
except ConfigWriteRefused as e:
print(f" {e}\n")
def register_cli(subparser) -> None:
+92 -14
View File
@@ -7,25 +7,30 @@ flat/global fields, which win over defaults.
from __future__ import annotations
import atexit
import contextlib
import hashlib
import ipaddress
import json
import logging
import os
import threading as _threading
import time
import weakref
# --- per-identity client cache ------------------------------------------- One slot per client identity,
# replacing the single process-wide slot that pinned the first profile's workspace and bearer for every
# later profile in multi-profile processes (#69123 multiplexed gateway, #74065 dashboard). The legacy names
# above are retained only for reset bookkeeping.
import threading as _threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from urllib.parse import urlparse
from agent.memory_provider import spawn_context_thread as _spawn_context_thread
from agent.secret_scope import get_secret
from hermes_cli.profiles import _get_default_hermes_home
from hermes_constants import get_hermes_home
from hermes_state_common import TITLE_SOURCE_DERIVED, TITLE_SOURCE_LLM
from plugins.memory.honcho.client_cache import (
_DEFAULT_HTTP_TIMEOUT, _client_cache_key, _client_slots, _client_slots_lock,
@@ -38,6 +43,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
HOST = "hermes"
_AUTOMATIC_SESSION_TITLE_SOURCES = frozenset({TITLE_SOURCE_DERIVED, TITLE_SOURCE_LLM})
def _sanitize_url(url: str | None) -> str | None:
@@ -91,7 +97,7 @@ def resolve_active_host() -> str:
def _read_config(path: Path) -> dict:
"""Parse a honcho.json; {} when absent (parse/OS errors propagate)."""
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
return json.loads(path.read_text(encoding="utf-8-sig")) if path.exists() else {}
def resolve_global_config_path() -> Path:
@@ -326,6 +332,7 @@ def _behavior_fields(look: _HostLookup, explicitly_configured: bool) -> dict[str
"session_strategy": look.pick("sessionStrategy", "per-directory"),
"session_peer_prefix": look.pick_set("sessionPeerPrefix", False),
"a2a_sessions": look.flag("a2aSessions", default=True),
"session_ai_peer_prefix": look.pick_set("sessionAiPeerPrefix", False),
}
@@ -390,6 +397,9 @@ class HonchoClientConfig:
session_peer_prefix: bool = False
# Bot-authored DMs write into their own session per sender bot.
a2a_sessions: bool = True
# Prefixes every resolved session name with ``{ai_peer}-``. The gateway_session_key branch is
# AI-peer-agnostic, so several AI peers sharing one workspace + peerName + chat key would collide.
session_ai_peer_prefix: bool = False
sessions: dict[str, str] = field(default_factory=dict)
raw: dict[str, Any] = field(default_factory=dict)
# A hosts.<host> block or explicit enabled flag, vs auto-enabled from a stray env key.
@@ -480,11 +490,31 @@ class HonchoClientConfig:
def resolve_session_name(
self, cwd: str | None = None, session_title: str | None = None,
session_id: str | None = None, gateway_session_key: str | None = None,
session_title_source: str | None = None,
) -> str | None:
"""Resolve the Honcho session name. Order: gateway session key (per-chat isolation no
cwd/strategy gives) -> per-session strategy's session_id (authoritative, so a generated
title never remaps a live conversation) -> sessions map override -> /title ->
per-repo (git root name) -> per-directory (basename) -> global (workspace)."""
"""Resolve the Honcho session name; with ``session_ai_peer_prefix`` the result is prefixed
``{ai_peer}-`` on every path, including the AI-peer-agnostic gateway session key."""
import re
result = self._resolve_session_name_base(cwd=cwd, session_title=session_title,
session_id=session_id, gateway_session_key=gateway_session_key,
session_title_source=session_title_source)
if result and self.session_ai_peer_prefix and self.ai_peer:
ai = re.sub(r'[^a-zA-Z0-9_-]+', '-', self.ai_peer).strip('-')
if ai:
prefixed = f"{ai}-{result}"
return self._enforce_session_id_limit(prefixed, prefixed)
return result
def _resolve_session_name_base(
self, cwd: str | None = None, session_title: str | None = None,
session_id: str | None = None, gateway_session_key: str | None = None,
session_title_source: str | None = None,
) -> str | None:
"""Order: gateway session key (per-chat isolation no cwd/strategy gives) -> per-session
strategy's session_id (authoritative, so a generated title never remaps a live conversation)
-> sessions map override -> /title -> per-repo (git root name) -> per-directory (basename)
-> global (workspace)."""
import re
def _slug(text: str) -> str:
@@ -498,7 +528,9 @@ class HonchoClientConfig:
manual = self.sessions.get(cwd)
if manual:
return manual
if session_title and _slug(session_title):
# Absent provenance retains the legacy explicit-title override. Generated
# display titles must not change a strategy-selected memory identity.
if session_title and session_title_source not in _AUTOMATIC_SESSION_TITLE_SOURCES and _slug(session_title):
return self._with_peer_prefix(_slug(session_title))
if self.session_strategy == "per-repo":
return self._with_peer_prefix(self._git_repo_name(cwd) or Path(cwd).name)
@@ -507,14 +539,59 @@ class HonchoClientConfig:
return self.workspace_id
def spawn_context_thread(target, *, name: str, daemon: bool = True, args: tuple = ()) -> "_threading.Thread":
"""Thread that inherits the caller's contextvars: profile isolation is a ContextVar
(set_hermes_home_override) and a plain Thread starts EMPTY, so ambient resolution on it
would silently land on the default profile."""
import contextvars
# Threads keyed by the provider or manager that owns them, so shutdown never waits on another agent's work.
_plugin_threads: "weakref.WeakKeyDictionary[Any, weakref.WeakSet]" = weakref.WeakKeyDictionary()
_plugin_threads_lock = _threading.Lock()
ctx = contextvars.copy_context()
return _threading.Thread(target=lambda: ctx.run(target, *args), name=name, daemon=daemon)
def spawn_context_thread(
target, *, name: str, daemon: bool = True, args: tuple = (), owner: Any = None,
) -> "_threading.Thread":
"""agent.memory_provider.spawn_context_thread plus an ``owner``: the thread is registered so
join_plugin_threads can wait on exactly the threads this provider or manager spawned."""
thread = _spawn_context_thread(target, name=name, daemon=daemon, args=args)
if owner is not None:
with _plugin_threads_lock:
_plugin_threads.setdefault(owner, weakref.WeakSet()).add(thread)
return thread
def join_plugin_threads(owners, timeout: float) -> list[str]:
"""Join every live thread spawned for ``owners`` within one shared ``timeout``. Returns the names
of the threads still running when the budget ran out."""
deadline = time.monotonic() + max(0.0, timeout)
me = _threading.current_thread()
with _plugin_threads_lock:
threads = [t for owner in owners if owner is not None
for t in list(_plugin_threads.get(owner, ())) if t.is_alive() and t is not me]
for thread in threads:
thread.join(timeout=max(0.0, deadline - time.monotonic()))
return [t.name for t in threads if t.is_alive()]
def close_honcho_clients() -> None:
"""Close the HTTP pool of every cached client and drop the slots. Process exit only: one client
serves every manager with the same identity, so a per-agent shutdown must never call this."""
with _client_slots_lock:
slots = list(_client_slots.values())
_client_slots.clear()
for slot in slots:
close = getattr(getattr(slot.peek(), "_http", None), "close", None)
if callable(close):
with contextlib.suppress(Exception):
close()
_exit_close_registered = False
def _register_exit_close() -> None:
global _exit_close_registered
with _client_slots_lock:
if _exit_close_registered:
return
_exit_close_registered = True
atexit.register(close_honcho_clients)
def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
@@ -594,6 +671,7 @@ def _build_client(config: HonchoClientConfig) -> "Honcho":
# strip a trailing version segment from any base_url to avoid "/v3/v3/...".
import re
kwargs["base_url"] = re.sub(r"/v\d+/*$", "", base_url).rstrip("/")
_register_exit_close()
return Honcho(**kwargs)
+1 -1
View File
@@ -75,7 +75,7 @@ def _ambient_host_block() -> tuple[dict | None, dict]:
path = resolve_config_path()
if not path.exists():
return None, {}
raw = json.loads(path.read_text(encoding="utf-8"))
raw = json.loads(path.read_text(encoding="utf-8-sig"))
return raw, _host_block(raw, resolve_active_host())
+11
View File
@@ -69,11 +69,17 @@ CONFIG_SCHEMA = ProviderConfigSchema(
_field("a2aSessions", "Bot DM sessions", KIND_BOOL,
"Write DMs from other bots into their own Honcho session per sender. Off skips bot-authored turns.",
default="true", group="Session"),
_field("sessionAiPeerPrefix", "Session AI peer prefix", KIND_BOOL,
"Prefix session names with the AI peer. Keeps sessions disjoint when several AI peers share a workspace.",
default="false", group="Session"),
_field("sessions", "Session overrides", KIND_JSON, "Explicit session ID overrides keyed by resolver.",
placeholder='{"key": "session-id"}', group="Session", scope="root"),
# — Message writing —
_field("saveMessages", "Save messages", KIND_BOOL, "Persist conversation messages to Honcho.",
default="true", group="Message writing"),
_field("logging", "Injection audit log", KIND_BOOL,
"Append what each turn injected, and why, to ~/.honcho/injection.log. The record holds the user's "
"representation verbatim.", default="false", group="Recall"),
_field("writeFrequency", "Write frequency", KIND_TEXT, "When to flush messages: async, turn, session, or every N turns.",
default="async", info=_WRITE_FREQUENCY_INFO, placeholder="async | turn | session | N", group="Message writing"),
# — Dialectic —
@@ -102,6 +108,11 @@ CONFIG_SCHEMA = ProviderConfigSchema(
default="false", group="Recall"),
_field("contextTokens", "Context token cap", KIND_NUMBER, "Cap on auto-injected context tokens. Blank leaves it uncapped.",
placeholder="(uncapped)", group="Recall"),
# The plugin reads `injection` as one object, so the panel edits the whole block rather than a nested key.
_field("injection", "Session-start injection", KIND_JSON,
"Pin which base-context sections the first turn injects: summary, peerRepresentation, peerCard, "
"aiRepresentation, aiCard. Blank injects all of them; an empty list injects nothing.",
placeholder='{"sessionStart": ["summary", "peerCard"]}', group="Recall"),
_field("initOnSessionStart", "Eager init", KIND_BOOL, "Initialize the session eagerly in tools mode instead of on first tool call.",
default="false", group="Recall"),
# — Limits —
+1 -1
View File
@@ -85,7 +85,7 @@ class DialecticMixin:
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
thread = spawn_context_thread(_run, name=thread_name)
thread = spawn_context_thread(_run, name=thread_name, owner=self)
thread.start()
self._prefetch_thread = thread
return thread
+59 -34
View File
@@ -13,7 +13,6 @@ import hashlib
import json
import logging
import os
import re
import threading
import time
from contextlib import contextmanager, suppress
@@ -21,6 +20,9 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from agent.redact import redact_sensitive_text, register_redaction_patterns
from utils import read_json_or_empty
logger = logging.getLogger(__name__)
ACCESS_TOKEN_PREFIX = "hch-at-"
@@ -36,12 +38,10 @@ _REFRESH_TOTAL_BUDGET_SECONDS = 20.0
_REFRESH_FAILURE_COOLDOWN_SECONDS = 30.0
# OAuth error codes a retry can never fix — the grant itself is dead.
_PERMANENT_OAUTH_ERRORS = frozenset({"invalid_grant", "invalid_client", "unauthorized_client"})
# Derived from the canonical prefixes so a prefix change can't silently break redaction.
_TOKEN_VALUE_RE = re.compile(rf"({re.escape(ACCESS_TOKEN_PREFIX)}|{re.escape(REFRESH_TOKEN_PREFIX)})[A-Za-z0-9._~+/=-]+")
def redact_tokens(text: str) -> str:
"""Replace any embedded token values with their prefix plus a placeholder."""
return _TOKEN_VALUE_RE.sub(lambda m: f"{m.group(1)}[redacted]", text)
# Registered with the shared redactor so EVERY log/tool-output surface masks Honcho tokens, not only
# this module's own error strings. Built from the constants so a prefix rename can't split them.
register_redaction_patterns([f"{p}[A-Za-z0-9._~+/=-]{{8,}}" for p in (ACCESS_TOKEN_PREFIX, REFRESH_TOKEN_PREFIX)],
source="plugin:honcho")
class OAuthRefreshError(Exception):
"""Token endpoint rejected the refresh. ``permanent`` means re-login is required."""
@@ -107,15 +107,20 @@ def _mark_grant_dead(key: tuple[str, str], cred: OAuthCredential) -> None:
_dead_grants[key] = hashlib.sha256(cred.refresh_token.encode("utf-8")).hexdigest()
_reauth_check_cache.pop(key, None) # verdict changed without a config rewrite
def _read_config(path: Path) -> dict[str, Any]:
def _read_config_strict(path: Path) -> dict[str, Any]:
"""Reader for the write paths: a missing file is ``{}``, an unreadable one raises. A write seeded
from ``{}`` would replace every other host's credentials."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return json.loads(path.read_text(encoding="utf-8-sig"))
except FileNotFoundError:
return {}
except (OSError, ValueError) as exc:
logger.warning("Honcho config at %s exists but could not be read (%s); refusing to overwrite it.", path, exc)
raise
def _load_cred(path: Path, host: str, raw: dict[str, Any] | None = None) -> OAuthCredential | None:
"""Credential from ``host``'s block in ``raw`` (or the file at ``path``)."""
source = raw if raw is not None else _read_config(path)
source = raw if raw is not None else read_json_or_empty(path)
return OAuthCredential.from_host_block((source.get("hosts") or {}).get(host) or {})
def reauth_required(path: Path, host: str) -> bool:
@@ -229,7 +234,7 @@ def _exchange_refresh_token(
if status >= 400:
error, description = str(body.get("error") or ""), str(body.get("error_description") or "")
detail = " — ".join(p for p in (error, description) if p) or "no error body"
message = redact_tokens(f"token endpoint returned HTTP {status}: {detail}")
message = redact_sensitive_text(f"token endpoint returned HTTP {status}: {detail}", force=True)
raise OAuthRefreshError(message, error=error, permanent=error in _PERMANENT_OAUTH_ERRORS)
return OAuthCredential.from_token_response(
body, now=now, client_id=cred.client_id, token_endpoint=cred.token_endpoint,
@@ -249,7 +254,7 @@ def _exchange_with_retry(cred: OAuthCredential, *, now: float) -> OAuthCredentia
remaining = deadline - time.monotonic() - _REFRESH_RETRY_DELAY_SECONDS
if remaining <= 0:
raise first
logger.warning("Honcho OAuth token exchange failed, retrying once: %s", redact_tokens(str(first)))
logger.warning("Honcho OAuth token exchange failed, retrying once: %s", redact_sensitive_text(str(first), force=True))
time.sleep(_REFRESH_RETRY_DELAY_SECONDS)
return _exchange_refresh_token(cred, now=now, timeout=min(remaining, _REFRESH_TIMEOUT_SECONDS))
@@ -258,18 +263,32 @@ def _rotate_and_persist(
) -> OAuthCredential | None:
"""Exchange ``cred`` and persist the rotation; ``None`` on failure (logged). A permanent OAuth error
marks the grant dead so later calls skip the endpoint until a new login rotates the refresh token."""
try:
# Read before spending the single-use refresh token: an exchange that cannot be persisted loses the grant.
raw = _read_config_strict(path)
except (OSError, ValueError):
_refresh_failure_at[key] = time.monotonic()
return None
try:
rotated = _exchange_with_retry(cred, now=now)
except Exception as exc:
if isinstance(exc, OAuthRefreshError) and exc.permanent:
# The file lock is best-effort: a sibling may have rotated first, so adopt what it wrote instead.
disk = _load_cred(path, host)
if disk is not None and disk.refresh_token != cred.refresh_token:
_dead_grants.pop(key, None)
_expiry_cache[key] = (disk.expires_at, disk.access_token)
logger.info("Honcho OAuth %s for host %s lost a rotation race; "
"adopted the newer on-disk grant", op_label, host)
return disk
_mark_grant_dead(key, cred)
logger.error("Honcho OAuth grant for host %s is no longer valid (%s); "
"run 'hermes honcho setup' to re-authenticate", host, exc)
return None
_refresh_failure_at[key] = time.monotonic()
logger.warning("Honcho OAuth %s failed for host %s: %s", op_label, host, redact_tokens(str(exc)))
logger.warning("Honcho OAuth %s failed for host %s: %s", op_label, host, redact_sensitive_text(str(exc), force=True))
return None
_persist_credential(path, host, rotated)
_persist_credential(path, host, rotated, raw=raw)
return rotated
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
@@ -281,11 +300,11 @@ def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]
return base
def _persist_credential(path: Path, host: str, cred: OAuthCredential, raw: dict[str, Any] | None = None) -> None:
"""Write ``cred`` into ``host``'s block (apiKey + oauth) of ``raw`` (default:
the file's current content), leaving the rest intact; marks the grant live."""
"""Write ``cred`` into ``host``'s block of ``raw`` (default: a strict read of the file) and mark
the grant live."""
from utils import atomic_json_write
raw = _read_config(path) if raw is None else raw
raw = _read_config_strict(path) if raw is None else raw
block = raw.setdefault("hosts", {}).setdefault(host, {})
block["apiKey"], block["oauth"] = cred.access_token, cred.oauth_block()
atomic_json_write(path, raw, mode=0o600)
@@ -329,9 +348,10 @@ def ensure_fresh_token(
logger.info("Honcho OAuth token refreshed for host %s", host)
return (rotated.access_token, True) if rotated is not None else (current.access_token, False)
def force_refresh_token(path: Path, host: str) -> str | None:
"""Rotate ``host``'s token now, ignoring local expiry (recovers a 401 on a
token the local clock still thinks is valid)."""
def force_refresh_token(path: Path, host: str, *, failed_access_token: str | None = None) -> str | None:
"""Rotate ``host``'s token now, ignoring local expiry (recovers a 401 on a token the local clock
still thinks is valid). When disk no longer holds ``failed_access_token`` a sibling rotated first:
adopt its grant, since replaying the single-use refresh token can revoke the whole grant."""
now = time.time()
key = (str(path), host)
with _refresh_lock, _config_refresh_lock(path):
@@ -339,14 +359,17 @@ def force_refresh_token(path: Path, host: str) -> str | None:
if cred is None:
_expiry_cache.pop(key, None)
return None
# Adopting a sibling's rotation is a disk read, so it comes before the exchange gates: disk, not
# the expiry cache, is the signal (the cache is empty in sibling processes).
cached = _expiry_cache.get(key)
moved_off_failed = failed_access_token and cred.access_token != failed_access_token
moved_off_cached = cached is not None and cred.access_token != cached[1]
if (moved_off_failed or moved_off_cached) and not cred.is_expired(now=now):
_expiry_cache[key] = (cred.expires_at, cred.access_token)
return cred.access_token
# Dead grant, or an exchange just failed transiently: callers fail open.
if _grant_is_dead(key, cred) or _in_failure_cooldown(key):
return None
cached = _expiry_cache.get(key)
# Another thread or process already rotated: adopt the newer on-disk token.
if cached is not None and cred.access_token != cached[1] and not cred.is_expired(now=now):
_expiry_cache[key] = (cred.expires_at, cred.access_token)
return cred.access_token
rotated = _rotate_and_persist(path, host, key, cred, now=now, op_label="forced refresh")
if rotated is not None:
logger.info("Honcho OAuth token force-refreshed for host %s after an auth failure", host)
@@ -358,16 +381,18 @@ def install_grant(
) -> OAuthCredential:
"""Apply a fresh OAuth grant (an OAuthTokenResponse dict) to ``path`` for ``host``: deep-merge the
grant's ``config`` into the file root (preserving other hosts and root keys), then write the host's
``apiKey`` and ``oauth`` block. ``apply_config=False`` stores tokens only."""
``apiKey`` and ``oauth`` block. ``apply_config=False`` stores tokens only. Holds the refresh locks so a
login cannot interleave with a sibling's read-modify-write."""
now = time.time() if now is None else now
cred = OAuthCredential.from_token_response(grant, now=now, client_id=client_id, token_endpoint=token_endpoint)
raw = _read_config(path)
granted_config = grant.get("config")
if isinstance(granted_config, dict):
cred.consent_peer_name = granted_config.get("peerName")
if apply_config:
_deep_merge(raw, granted_config)
_persist_credential(path, host, cred, raw)
with _refresh_lock, _config_refresh_lock(path):
raw = _read_config_strict(path)
granted_config = grant.get("config")
if isinstance(granted_config, dict):
cred.consent_peer_name = granted_config.get("peerName")
if apply_config:
_deep_merge(raw, granted_config)
_persist_credential(path, host, cred, raw)
return cred
def apply_token_to_client(client: Any, token: str) -> bool:
+2 -2
View File
@@ -34,7 +34,7 @@ def prefetch_sync(provider, query: str) -> str:
if provider._init_thread is not None:
provider._init_thread.join(timeout=max(0.0, deadline - time.monotonic()))
if not provider._session_ready():
return provider._pop_auth_notice()
return provider._pop_auth_notice() or provider._pop_peer_notice()
manager = provider._manager
if (generation is not provider._recall_generation or session != provider._session_key
or turn != provider._turn_count or time.monotonic() >= deadline):
@@ -92,7 +92,7 @@ def prefetch_sync(provider, query: str) -> str:
except Exception as exc:
logger.debug("Honcho synchronous recall failed: %s", exc)
worker = spawn_context_thread(retrieve, name="honcho-recall-sync")
worker = spawn_context_thread(retrieve, name="honcho-recall-sync", owner=provider)
provider._recall_sync_thread = worker
worker.start()
worker.join(timeout=max(0.0, deadline - time.monotonic()))
+299 -69
View File
@@ -10,7 +10,8 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, TYPE_CHECKING
from plugins.memory.honcho.client import get_honcho_client, spawn_context_thread
from plugins.memory.honcho.client import spawn_context_thread
from plugins.memory.honcho.client import get_honcho_client
from plugins.memory.honcho.session_auth import HonchoAuthError, SessionAuthMixin
from plugins.memory.honcho.session_context import SessionContextMixin
from plugins.memory.honcho.session_migration import SessionMigrationMixin
@@ -24,8 +25,14 @@ logger = logging.getLogger(__name__)
# Sentinel to signal the async writer thread to shut down
_ASYNC_SHUTDOWN = object()
# Sessions remembered in _joined_author_peers; the oldest is dropped past this and its authors rejoin on their next write.
# Honcho persists every message and get_or_create() re-hydrates on a miss, so the local copies stay bounded.
_SESSION_MESSAGE_RETENTION = 200
_SESSION_IDLE_TTL_SECONDS = 3600
_SESSION_SWEEP_INTERVAL_SECONDS = 300
# Hard caps for a burst of distinct sessions inside one TTL window; the dicts evict least recently used,
# and _joined_author_peers drops its oldest session so its authors rejoin on their next write.
_SESSION_CACHE_MAX_SIZE = 128
_PEERS_CACHE_MAX_SIZE = 512
@dataclass
@@ -40,6 +47,8 @@ class HonchoSession:
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict)
# Held by _flush_session across select, send and the _synced flip. It lives with the message list it guards.
_flush_lock: threading.RLock = field(default_factory=threading.RLock, repr=False, compare=False)
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the local cache."""
@@ -64,11 +73,14 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
self._runtime_user_peer_name = runtime_user_peer_name
self._runtime_user_peer_name_alt = runtime_user_peer_name_alt
self._cache: dict[str, HonchoSession] = {}
# Sessions whose flush failed after a newer object took their cache key; flush_all() retries them.
self._retry_sessions: list[HonchoSession] = []
self._cache_lock = threading.RLock()
self._peers_cache: dict[str, Any] = {}
# honcho_session_id -> author peer IDs already joined to that session.
self._joined_author_peers: dict[str, set[str]] = {}
self._sessions_cache: dict[str, Any] = {}
self._last_idle_sweep_ts: float = 0.0
# Bumped (under _cache_lock) whenever _force_reauth rebuilds the client, so an
# in-flight resolver never stores an object bound to the discarded client.
self._client_generation = 0
@@ -87,6 +99,8 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
):
setattr(self, f"_{name}", getattr(config, name) if config else default)
self._turn_counter: int = 0
# honcho session id -> observation booleans. Whole dicts are swapped in one assignment, so readers never see a partial one.
self._session_observation: dict[str, dict[str, bool]] = {}
# Prefetch cache: session_key -> last context result (consumed once per turn).
# Dialectic results are cached on the plugin side (HonchoMemoryProvider._prefetch_result)
@@ -95,11 +109,13 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
self._prefetch_cache_lock = threading.Lock()
# Async write queue — the writer thread starts lazily on first enqueue
# (_ensure_async_writer): constructing a manager must not spawn background
# (_ensure_async_writer_locked): constructing a manager must not spawn background
# work or touch the network (unit tests build managers with mocked clients).
self._async_queue: queue.Queue | None = queue.Queue() if self._write_frequency == "async" else None
self._async_thread: threading.Thread | None = None
self._async_thread_lock = threading.Lock()
# Set by shutdown/stop_async_writer: no new threads after the join, late saves flush inline.
self._shutting_down = False
@property
def honcho(self) -> Honcho:
@@ -120,6 +136,7 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
while True:
with self._cache_lock:
if key in cache:
cache[key] = cache.pop(key) # dict order is the LRU order the caps evict from
return cache[key]
generation = self._client_generation
obj = fetch()
@@ -128,6 +145,15 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
return cache.setdefault(key, obj)
# Client rebuilt mid-resolve: this object holds the discarded transport. Retry.
def _cached_session(self, session_key: str) -> HonchoSession | None:
"""The locally cached session, stamped as used so recall alone keeps it out of the idle sweep."""
with self._cache_lock:
session = self._cache.get(session_key)
if session is not None:
session.updated_at = datetime.now()
self._cache[session_key] = self._cache.pop(session_key)
return session
def _sdk_session(self, session_id: str) -> Any:
"""Get or create the SDK session (cached until a client rebuild clears the cache)."""
return self._cached_sdk_object(self._sessions_cache, session_id, lambda: self.honcho.session(session_id))
@@ -137,19 +163,36 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
return self._cached_sdk_object(
self._peers_cache, peer_id, lambda: self._authed_call("peer setup", lambda: self.honcho.peer(peer_id)))
# ----- Observation config (per session) -----
def _observation_flags(self, honcho_session_id: str) -> dict[str, bool]:
"""The four observation booleans for one session: the values synced from that session's
server config once setup ran, else the config snapshot held on the manager."""
synced = self._session_observation.get(honcho_session_id)
if synced is not None:
return dict(synced)
return {f"{kind}_{name}": getattr(self, f"_{kind}_{name}")
for kind in ("user", "ai") for name in ("observe_me", "observe_others")}
def _ai_observes_others(self, session: HonchoSession) -> bool:
"""Whether the AI peer observes other peers in this session; this picks the recall observer."""
synced = self._session_observation.get(session.honcho_session_id)
return synced["ai_observe_others"] if synced is not None else self._ai_observe_others
# ----- Session creation -----
def _configure_session_peers(self, session_id: str, user_peer: Any, assistant_peer: Any) -> bool:
"""add_peers with the local observation config, then adopt the server's effective
config (set via the Honcho UI, it wins over local defaults). Observation booleans are
manager-scoped, so the last session init wins. Returns False when auth died mid-way
(already recorded by _authed_call)."""
def _configure_session_peers(self, session_id: str, user_peer: Any, assistant_peer: Any) -> dict[str, bool] | None:
"""add_peers with this session's observation config, then adopt the server's effective
config (set via the Honcho UI, it wins over local defaults). Returns the effective flags for
get_or_create to store next to the cache entry, or None when auth died mid-way (already
recorded by _authed_call)."""
peers = (("user", user_peer), ("ai", assistant_peer))
flags = self._observation_flags(session_id)
synced = dict(flags)
try:
from honcho.session import SessionPeerConfig
peer_entries = [
(peer, SessionPeerConfig(observe_me=getattr(self, f"_{kind}_observe_me"),
observe_others=getattr(self, f"_{kind}_observe_others")))
(peer, SessionPeerConfig(observe_me=flags[f"{kind}_observe_me"], observe_others=flags[f"{kind}_observe_others"]))
for kind, peer in peers
]
self._authed_call("session peer setup", lambda: self._sdk_session(session_id).add_peers(peer_entries))
@@ -163,17 +206,18 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
for field_name in ("observe_me", "observe_others"):
value = getattr(server_cfg, field_name)
if value is not None:
setattr(self, f"_{kind}_{field_name}", value)
logger.debug("Honcho observation synced from server: user(me=%s,others=%s) ai(me=%s,others=%s)",
self._user_observe_me, self._user_observe_others, self._ai_observe_me, self._ai_observe_others)
synced[f"{kind}_{field_name}"] = value
logger.debug("Honcho observation synced from server for session '%s': user(me=%s,others=%s) ai(me=%s,others=%s)",
session_id, synced["user_observe_me"], synced["user_observe_others"],
synced["ai_observe_me"], synced["ai_observe_others"])
self._guarded(_adopt_server_config, None, logging.DEBUG,
"Honcho get_peer_configuration failed (using local config): %s")
except HonchoAuthError:
return False
return None
except Exception as e:
logger.warning("Honcho session '%s' add_peers failed (non-fatal): %s", session_id, e)
return True
return synced
def _load_existing_messages(self, session_id: str) -> list:
"""Load prior messages via context() (one call for messages + metadata), oldest first."""
@@ -198,32 +242,95 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
logger.warning("Honcho session '%s' loaded (failed to fetch context: %s)", session_id, e)
return []
def _get_or_create_honcho_session(self, session_id: str, user_peer: Any, assistant_peer: Any) -> tuple[Any, list]:
"""(honcho_session, existing_messages) with peers configured; a cached session yields no messages."""
def _get_or_create_honcho_session(self, session_id: str, user_peer: Any, assistant_peer: Any) -> tuple[Any, list, dict[str, bool] | None]:
"""(honcho_session, existing_messages, observation flags) with peers configured; a cached session
yields no messages and the flags stored when it was configured."""
with self._cache_lock:
if session_id in self._sessions_cache:
logger.debug("Honcho session '%s' retrieved from cache", session_id)
return self._sessions_cache[session_id], []
return self._sessions_cache[session_id], [], self._session_observation.get(session_id)
self._authed_call("session setup", lambda: self._sdk_session(session_id))
existing_messages: list = (self._load_existing_messages(session_id)
if self._configure_session_peers(session_id, user_peer, assistant_peer) else [])
observation = self._configure_session_peers(session_id, user_peer, assistant_peer)
existing_messages: list = self._load_existing_messages(session_id) if observation is not None else []
with self._cache_lock:
honcho_session = self._sessions_cache.get(session_id)
if honcho_session is None:
# A mid-init client rebuild dropped the cached session; resolve a fresh one.
honcho_session = self._authed_call("session setup", lambda: self._sdk_session(session_id))
return honcho_session, existing_messages
return honcho_session, existing_messages, observation
@staticmethod
def _has_unsynced(session: HonchoSession) -> bool:
return any(not m.get("_synced") for m in list(session.messages))
def _evict_session_locked(self, key: str, session: HonchoSession) -> None:
"""Drop one session and every entry keyed to it. Caller holds _cache_lock."""
del self._cache[key]
self._sessions_cache.pop(session.honcho_session_id, None)
self._session_observation.pop(session.honcho_session_id, None)
with self._prefetch_cache_lock:
self._context_cache.pop(key, None)
def _enforce_cache_caps_locked(self) -> None:
"""Evict least recently used entries above the hard caps. A session with unsynced messages
is never evicted: it is the only copy until the flush lands. Caller holds _cache_lock."""
for key, session in list(self._cache.items()):
if len(self._cache) <= _SESSION_CACHE_MAX_SIZE:
break
if not self._has_unsynced(session):
self._evict_session_locked(key, session)
live_ids = {s.honcho_session_id for s in self._cache.values()}
for session_id in list(self._sessions_cache):
if len(self._sessions_cache) <= _SESSION_CACHE_MAX_SIZE:
break
if session_id not in live_ids:
del self._sessions_cache[session_id]
for session_id in [sid for sid in self._session_observation if sid not in live_ids]:
del self._session_observation[session_id]
live_peers = {p for s in self._cache.values() for p in (s.user_peer_id, s.assistant_peer_id)}
for peer_id in list(self._peers_cache):
if len(self._peers_cache) <= _PEERS_CACHE_MAX_SIZE:
break
if peer_id not in live_peers:
del self._peers_cache[peer_id]
with self._prefetch_cache_lock:
for key in [k for k in self._context_cache if k not in self._cache]:
del self._context_cache[key]
def _sweep_idle_sessions_locked(self) -> int:
"""Evict sessions idle beyond _SESSION_IDLE_TTL_SECONDS together with their SDK session,
observation flags and prefetch entries, then enforce the caps. Caller holds _cache_lock."""
cutoff = time.time() - _SESSION_IDLE_TTL_SECONDS
evicted = 0
for key, session in list(self._cache.items()):
if session.updated_at.timestamp() >= cutoff or self._has_unsynced(session):
continue
self._evict_session_locked(key, session)
evicted += 1
self._enforce_cache_caps_locked()
return evicted
def _maybe_sweep_idle_sessions(self) -> None:
"""Rate-limited idle sweep, run from get_or_create so no watcher thread is needed."""
now = time.time()
with self._cache_lock:
if now - self._last_idle_sweep_ts < _SESSION_SWEEP_INTERVAL_SECONDS:
return
self._last_idle_sweep_ts = now
evicted = self._sweep_idle_sessions_locked()
if evicted:
logger.info("Honcho session cache idle sweep evicted %d session(s)", evicted)
def get_or_create(self, key: str, *, user_peer_id: str | None = None) -> HonchoSession:
"""Get an existing session or create a new one for ``key`` (usually channel:chat_id).
``user_peer_id`` replaces the resolved user peer when the session's participant is not the
runtime user, e.g. the sender bot of an a2a session."""
with self._cache_lock:
if key in self._cache:
logger.debug("Local session cache hit: %s", key)
return self._cache[key]
self._maybe_sweep_idle_sessions()
if (cached := self._cached_session(key)) is not None:
logger.debug("Local session cache hit: %s", key)
return cached
# Gateway sessions normally use the platform-native runtime identity so multi-user
# bots scope memory per user; config can alias/prefix it, or pinPeerName pins all
@@ -236,7 +343,7 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
honcho_session_id = self._sanitize_id(key)
user_peer = self._get_or_create_peer(user_peer_id)
assistant_peer = self._get_or_create_peer(assistant_peer_id)
_, existing_messages = self._get_or_create_honcho_session(honcho_session_id, user_peer, assistant_peer)
_, existing_messages, observation = self._get_or_create_honcho_session(honcho_session_id, user_peer, assistant_peer)
session = HonchoSession(
key=key, user_peer_id=user_peer_id, assistant_peer_id=assistant_peer_id, honcho_session_id=honcho_session_id,
@@ -248,14 +355,19 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
)
with self._cache_lock:
self._cache[key] = session
# Stored with the cache entry and dropped with it, so the dict can hold no orphan ids.
if observation is not None:
self._session_observation[honcho_session_id] = observation
self._enforce_cache_caps_locked()
return session
# ----- Writes -----
def _join_observation_flags(self, honcho_session_id: str) -> tuple[bool, bool]:
"""(observe_me, observe_others) for an author peer joining ``honcho_session_id``."""
# Manager-wide today. #103889 stores the effective flags per session and plugs in here.
return self._user_observe_me, self._user_observe_others
"""(observe_me, observe_others) for an author peer joining ``honcho_session_id``: the session's
server-synced values once setup ran, else the manager's config snapshot."""
flags = self._observation_flags(honcho_session_id)
return flags["user_observe_me"], flags["user_observe_others"]
def _author_peer_for_session(self, honcho_session: Any, honcho_session_id: str, author_peer_id: str) -> Any:
"""The author's peer, joined to the session the first time it writes.
@@ -279,9 +391,38 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
while len(self._joined_author_peers) > _SESSION_CACHE_MAX_SIZE:
self._joined_author_peers.pop(next(iter(self._joined_author_peers)))
return peer
@staticmethod
def _trim_synced_messages(session: HonchoSession) -> None:
"""Drop the oldest synced messages beyond _SESSION_MESSAGE_RETENTION. Messages are appended in
order, so trimming from the front stops at the first unsynced one and never drops it."""
excess = len(session.messages) - _SESSION_MESSAGE_RETENTION
while excess > 0 and session.messages and session.messages[0].get("_synced"):
session.messages.pop(0)
excess -= 1
def _flush_session(self, session: HonchoSession) -> bool:
"""Write unsynced messages to Honcho synchronously."""
"""Write unsynced messages to Honcho synchronously. The session's lock keeps the async writer and an
exit-time flush_all() from posting the same batch."""
with session._flush_lock:
return self._flush_session_locked(session)
def _flush_session_before(self, session: HonchoSession, deadline: float | None) -> bool:
"""_flush_session that starts only while ``deadline`` has time left and waits for the session's lock no
longer than that; False, with nothing sent, otherwise. An upload that has started is not interrupted: the
SDK has no per-call timeout, so it runs to the client's HTTP timeout."""
if deadline is None:
self._flush_session(session)
return True
remaining = deadline - time.monotonic()
if remaining <= 0 or not session._flush_lock.acquire(timeout=remaining):
return False
try:
self._flush_session(session) # re-enters the RLock already held above
finally:
session._flush_lock.release()
return True
def _flush_session_locked(self, session: HonchoSession) -> bool:
new_messages = [m for m in session.messages if not m.get("_synced")]
if not new_messages:
return True
@@ -292,7 +433,11 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
honcho_session = self._sessions_cache.get(session.honcho_session_id)
if honcho_session is None:
honcho_session, _ = self._get_or_create_honcho_session(session.honcho_session_id, user_peer, assistant_peer)
honcho_session, _, observation = self._get_or_create_honcho_session(
session.honcho_session_id, user_peer, assistant_peer)
if observation is not None:
with self._cache_lock:
self._session_observation[session.honcho_session_id] = observation
honcho_messages = []
for m in new_messages:
if m["role"] != "user":
@@ -313,8 +458,10 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
ok = False
for msg in new_messages:
msg["_synced"] = ok
with self._cache_lock:
self._cache[session.key] = session
if ok:
# Under the cache lock so the eviction check never iterates a list being trimmed.
with self._cache_lock:
self._trim_synced_messages(session)
return ok
def _try_flush(self, session: HonchoSession, level: int, msg: str) -> bool:
@@ -335,6 +482,10 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
if item is _ASYNC_SHUTDOWN:
break
if not self._try_flush(item, logging.WARNING, "Honcho async write failed, retrying once"):
if self._shutting_down:
# The shutdown flush is already attempting this session within its own budget.
logger.error("Honcho async write failed while shutting down, so the batch stays unsynced")
continue
time.sleep(2)
self._try_flush(item, logging.ERROR, "Honcho async write retry failed, dropping batch")
except queue.Empty:
@@ -342,68 +493,147 @@ class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMi
except Exception as e:
logger.error("Honcho async writer error: %s", e)
def _reclaim_key_locked(self, session: HonchoSession) -> HonchoSession | None:
"""Put a session with unsynced messages back where flush_all() looks. Returns the newer object that
owns the key when there is one (this session's batch still needs a home), None otherwise."""
if not self._has_unsynced(session):
return None
current = self._cache.get(session.key)
if current is None:
self._cache[session.key] = session
return None if current is None or current is session else current
def _retain_for_retry(self, session: HonchoSession) -> None:
"""An evicted key takes the session back; a key a newer object owns keeps that object, and this one
waits in the retry list."""
with self._cache_lock:
if self._reclaim_key_locked(session) is not None and not any(s is session for s in self._retry_sessions):
self._retry_sessions.append(session)
def _keep_until_flushed(self, session: HonchoSession) -> None:
"""Like _retain_for_retry, but when a newer object owns the key this batch is written now."""
with self._cache_lock:
owner = self._reclaim_key_locked(session)
if owner is not None:
self._flush_now(session)
def _flush_now(self, session: HonchoSession) -> None:
"""A save-time flush whose failed batch stays reachable for flush_all(), even after an eviction."""
if not self._flush_session(session):
self._retain_for_retry(session)
def save(self, session: HonchoSession) -> None:
"""Save messages per write_frequency: "async" enqueues for the background thread; "turn"
flushes now; "session" defers until flush_all(); int N flushes every N turns."""
"""Save messages per write_frequency: "async" enqueues for the background thread, "turn"
flushes now, "session" defers until flush_all(), int N flushes every N turns."""
self._turn_counter += 1
wf = self._write_frequency
if wf == "async":
if self._async_queue is not None:
self._ensure_async_writer()
self._async_queue.put(session)
elif wf == "turn" or (isinstance(wf, int) and wf > 0 and self._turn_counter % wf == 0):
self._flush_session(session)
if wf == "async" and self._async_queue is not None:
# Under the writer lock, so a put cannot slip in after stop_async_writer() drained the queue.
with self._async_thread_lock:
if not self._shutting_down:
self._ensure_async_writer_locked()
self._async_queue.put(session)
return
self._flush_now(session)
elif self._shutting_down or wf == "turn" or (isinstance(wf, int) and wf > 0 and self._turn_counter % wf == 0):
self._flush_now(session)
else:
self._keep_until_flushed(session)
def flush_all(self) -> None:
"""Flush unsynced messages for all cached sessions, then drain the async queue inline."""
def flush_all(self, timeout: float | None = None) -> None:
"""Flush unsynced messages for all cached sessions, then drain the async queue inline. ``timeout`` bounds
when a flush may start and how long it waits for a session's lock, not an upload already in flight, which
runs to the client's HTTP timeout. A session skipped keeps its messages and is counted in one warning."""
deadline = None if timeout is None else time.monotonic() + timeout
skipped = self._flush_cached_before(deadline)
skipped.extend(self._drain_async_queue(deadline))
self._warn_unsynced(skipped, timeout)
def _flush_cached_before(self, deadline: float | None) -> list[HonchoSession]:
"""Flush every cached and retry-listed session that ``deadline`` allows; returns the ones it did not."""
with self._cache_lock:
sessions = list(self._cache.values())
sessions += [s for s in self._retry_sessions if not any(s is c for c in sessions)]
skipped: list[HonchoSession] = []
for session in sessions:
try:
self._flush_session(session)
if not self._flush_session_before(session, deadline):
skipped.append(session)
except Exception as e:
logger.error("Honcho flush_all error for %s: %s", session.key, e)
with self._cache_lock:
self._retry_sessions = [s for s in self._retry_sessions if self._has_unsynced(s)]
return skipped
if self._async_queue is not None:
while not self._async_queue.empty():
try:
item = self._async_queue.get_nowait()
if item is not _ASYNC_SHUTDOWN:
self._flush_session(item)
except queue.Empty:
break
def _warn_unsynced(self, skipped: list[HonchoSession], timeout: float | None) -> None:
left = [s for s in {id(s): s for s in skipped}.values() if self._has_unsynced(s)]
if left:
unsynced = sum(1 for s in left for m in list(s.messages) if not m.get("_synced"))
logger.warning("Honcho flush ran out of time after %.1fs with %d message(s) in %d session(s) still unsynced",
timeout or 0.0, unsynced, len(left))
def _ensure_async_writer(self) -> None:
"""Start the async writer on first enqueue (idempotent, thread-safe)."""
if self._async_thread is not None and self._async_thread.is_alive():
return
def _drain_async_queue(self, deadline: float | None = None) -> list[HonchoSession]:
"""Flush every queued session inline. Returns the sessions ``deadline`` left unflushed."""
skipped: list[HonchoSession] = []
if self._async_queue is None:
return skipped
while not self._async_queue.empty():
try:
item = self._async_queue.get_nowait()
except queue.Empty:
break
if item is not _ASYNC_SHUTDOWN and not self._flush_session_before(item, deadline):
skipped.append(item)
return skipped
def _ensure_async_writer_locked(self) -> None:
if self._async_thread is None or not self._async_thread.is_alive():
self._async_thread = spawn_context_thread(self._async_writer_loop, name="honcho-async-writer", owner=self)
self._async_thread.start()
def stop_async_writer(self, timeout: float = 10.0) -> None:
"""Join the async writer, then drain whatever was queued before the join, both within ``timeout``.
saveMessages: false never enqueues, so the drain is a no-op there and the exit stays clean."""
self._warn_unsynced(self._stop_async_writer_before(time.monotonic() + timeout), timeout)
def _stop_async_writer_before(self, deadline: float) -> list[HonchoSession]:
"""Join the writer for what is left of ``deadline``, then drain the queue under the same deadline. A writer
still inside an upload keeps running to the client's HTTP timeout; it holds the shared client, so it is
joined, never abandoned."""
with self._async_thread_lock:
if self._async_thread is None or not self._async_thread.is_alive():
self._async_thread = spawn_context_thread(self._async_writer_loop, name="honcho-async-writer")
self._async_thread.start()
def stop_async_writer(self) -> None:
"""Join the async writer WITHOUT flushing (saveMessages: false must still exit cleanly)."""
self._shutting_down = True
if self._async_queue is not None and self._async_thread is not None and self._async_thread.is_alive():
self._async_queue.put(_ASYNC_SHUTDOWN)
self._async_thread.join(timeout=10)
self._async_thread.join(timeout=max(0.0, deadline - time.monotonic()))
return self._drain_async_queue(deadline)
def shutdown(self) -> None:
"""Flush everything, then stop the async writer thread."""
def shutdown(self, timeout: float = 10.0) -> None:
"""Flush everything, then stop the async writer thread, within ``timeout``. The budget stops new uploads
from starting and bounds the lock waits and the join; an upload already in flight runs to the client's
HTTP timeout. Whatever stayed unsynced is counted in one warning."""
with self._async_thread_lock:
self._shutting_down = True
if self._async_queue is not None:
self.flush_all()
self.stop_async_writer()
deadline = time.monotonic() + timeout
skipped = self._flush_cached_before(deadline)
skipped.extend(self._drain_async_queue(deadline))
skipped.extend(self._stop_async_writer_before(deadline))
self._warn_unsynced(skipped, timeout)
# ----- Prefetch cache -----
def prefetch_context(self, session_key: str, user_message: str | None = None) -> None:
"""Fire get_prefetch_context in a background thread; consumed next turn via pop_context_result()."""
"""Fire get_prefetch_context in a background thread; consumed next turn via pop_context_result().
No-op once shutdown began, so nothing is left running after the join."""
if self._shutting_down:
return
def _run():
result = self.get_prefetch_context(session_key, user_message)
if result:
self.set_context_result(session_key, result)
spawn_context_thread(_run, name="honcho-context-prefetch").start()
spawn_context_thread(_run, name="honcho-context-prefetch", owner=self).start()
def set_context_result(self, session_key: str, result: dict[str, str]) -> None:
"""Store a prefetched context result in a thread-safe way."""
+14 -8
View File
@@ -7,7 +7,7 @@ import re
from pathlib import Path
from typing import Any, Callable
from plugins.memory.honcho.oauth import redact_tokens as _redact_tokens
from agent.redact import redact_sensitive_text as _redact_sensitive_text
logger = logging.getLogger("plugins.memory.honcho.session")
@@ -48,7 +48,7 @@ _REAUTH_REQUIRED_MESSAGE = (
def _auth_error_message(exc: BaseException) -> str:
return (f"Honcho rejected our credentials and a forced token refresh did not recover: {_redact_tokens(str(exc))}. "
return (f"Honcho rejected our credentials and a forced token refresh did not recover: {_redact_sensitive_text(str(exc), force=True)}. "
"Re-authenticate with 'hermes honcho setup'.")
@@ -56,7 +56,7 @@ class SessionAuthMixin:
"""Auth state + ``_authed_call`` for HonchoSessionManager (state lives in __init__)."""
def _record_auth_failure(self, exc: BaseException) -> None:
detail = _redact_tokens(str(exc))
detail = _redact_sensitive_text(str(exc), force=True)
if self._auth_failure is None:
logger.error("Honcho authentication failed and token refresh did not recover; "
"memory sync and recall are paused until the user re-authenticates: %s", detail)
@@ -94,9 +94,10 @@ class SessionAuthMixin:
except Exception:
return False
def _force_reauth(self) -> bool:
def _force_reauth(self, failed_access_token: str | None = None) -> bool:
"""Rotate the token after a 401 and rebind the client. False for a static API key, a dead
grant, or a failed exchange."""
grant, or a failed exchange. ``failed_access_token`` is the bearer the operation sent; sibling
waiters rotate the live client's ``api_key`` in place, so it cannot be read back here."""
try:
from plugins.memory.honcho import oauth
from plugins.memory.honcho.client import reset_honcho_client
@@ -104,7 +105,7 @@ class SessionAuthMixin:
host = getattr(self._config, "host", "") or ""
if not host:
return False
token = oauth.force_refresh_token(self._bound_config_path(), host)
token = oauth.force_refresh_token(self._bound_config_path(), host, failed_access_token=failed_access_token)
if not token:
return False
if not oauth.apply_token_to_client(self.honcho, token):
@@ -127,6 +128,11 @@ class SessionAuthMixin:
exc = HonchoAuthError(_REAUTH_REQUIRED_MESSAGE)
self._record_auth_failure(exc)
raise exc
# Snapshot the bearer first: after a 401 the client's api_key may already hold a sibling's rotation.
try:
failed_token = getattr(getattr(self.honcho, "_http", None), "api_key", None)
except Exception:
failed_token = None
try:
result = operation()
except HonchoAuthError:
@@ -135,8 +141,8 @@ class SessionAuthMixin:
if not _is_auth_error(e):
raise
logger.warning("Honcho %s hit an auth error; forcing token refresh and retrying once: %s",
op_name, _redact_tokens(str(e)))
if not self._force_reauth():
op_name, _redact_sensitive_text(str(e), force=True))
if not self._force_reauth(failed_access_token=failed_token):
self._record_auth_failure(e)
raise HonchoAuthError(_auth_error_message(e)) from e
try:
+48 -15
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import re
from typing import Any, Callable
from plugins.memory.honcho.session_auth import HonchoAuthError
@@ -11,6 +12,36 @@ logger = logging.getLogger("plugins.memory.honcho.session")
_FAILED = object() # sentinel: a guarded call raised (distinct from a legitimately empty/None result)
# Reasoning-channel markers a summarizer model can leave inside a persisted session summary.
_THINK_BLOCK_RE = re.compile(
r"<\s*(?:think|thinking|reasoning|thought|reasoning_scratchpad)\s*>.*?"
r"<\s*/\s*(?:think|thinking|reasoning|thought|reasoning_scratchpad)\s*>",
re.IGNORECASE | re.DOTALL,
)
_THINK_CLOSE_RE = re.compile(r"</\s*think\s*>", re.IGNORECASE)
_PLANNING_HEAD_RE = re.compile(
r"^(?:i need to create a thorough|the instruction says to focus on capturing key facts|first, let me review)",
re.IGNORECASE,
)
def usable_honcho_summary(text: object) -> str | None:
"""A session summary safe to inject, or None to omit it. Honcho summarizers can persist the
model's planning text and a trailing ``</think>`` as the summary body; the text after that
close survives, planning-only or still-tagged text is dropped."""
raw = "" if text is None else str(text)
if not raw.strip():
return None
# The observed payload has no opening tag: planning prose, then ``</think>``, then the summary.
close = _THINK_CLOSE_RE.search(raw)
if close:
raw = raw[close.end():]
cleaned = _THINK_BLOCK_RE.sub("", raw).strip()
lowered = cleaned.lower()
if not cleaned or "<think" in lowered or "</think" in lowered or _PLANNING_HEAD_RE.search(cleaned):
return None
return cleaned
class SessionContextMixin:
"""Peer context / card / search / conclusion / dialectic operations against Honcho."""
@@ -33,7 +64,7 @@ class SessionContextMixin:
self, session_key: str, fn: Callable[[Any], Any], default: Any, level: int, msg: str, *args: Any,
) -> Any:
"""``_guarded`` over ``fn(session)`` for the cached session; ``default`` when no session is cached."""
session = self._cache.get(session_key)
session = self._cached_session(session_key)
return self._guarded(lambda: fn(session), default, level, msg, *args) if session else default
@staticmethod
@@ -95,7 +126,7 @@ class SessionContextMixin:
"""Pre-fetch user + AI peer context (representation, card) plus the session summary.
``user_message`` is passed as search_query so Honcho returns topic-relevant conclusions.
Stops early (returning what it has) once auth is dead."""
session = self._cache.get(session_key)
session = self._cached_session(session_key)
if not session:
return {}
result: dict[str, str] = {}
@@ -104,10 +135,11 @@ class SessionContextMixin:
if session.honcho_session_id not in self._sessions_cache:
return
ctx = self._authed_call(
"session summary fetch", lambda: self._sdk_session(session.honcho_session_id).context(summary=True),
"session summary fetch",
lambda: self._sdk_session(session.honcho_session_id).context(summary=True, tokens=self._context_tokens),
)
if ctx.summary and getattr(ctx.summary, "content", None):
result["summary"] = ctx.summary.content
if ctx.summary and (summary := usable_honcho_summary(getattr(ctx.summary, "content", None))):
result["summary"] = summary
def _user() -> None:
observer_peer_id, target_peer_id = self._resolve_observer_target(session, "user")
@@ -148,7 +180,7 @@ class SessionContextMixin:
def get_session_context(self, session_key: str, peer: str = "user") -> dict[str, Any]:
"""Fetch session-level context (summary, representation, card, recent messages).
Raises HonchoAuthError so callers can tell rejected credentials from no context."""
session = self._cache.get(session_key)
session = self._cached_session(session_key)
if not session:
return {}
if session.honcho_session_id not in self._sessions_cache:
@@ -161,12 +193,13 @@ class SessionContextMixin:
ctx = self._authed_call(
"session context fetch",
lambda: self._sdk_session(session.honcho_session_id).context(
summary=True, peer_target=target_peer_id or observer_peer_id, peer_perspective=observer_peer_id,
summary=True, tokens=self._context_tokens,
peer_target=target_peer_id or observer_peer_id, peer_perspective=observer_peer_id,
),
)
result: dict[str, Any] = {}
if ctx.summary:
result["summary"] = ctx.summary.content
if ctx.summary and (summary := usable_honcho_summary(getattr(ctx.summary, "content", ctx.summary))):
result["summary"] = summary
if ctx.peer_representation:
result["representation"] = ctx.peer_representation
if ctx.peer_card:
@@ -192,7 +225,7 @@ class SessionContextMixin:
"""Hybrid search over raw messages visible from ``peer``'s perspective, all sessions. Snippets
accumulate until ``max_tokens`` (~4 chars/token) is exhausted. Returns "" when nothing matches;
raises HonchoAuthError on rejected credentials."""
session = self._cache.get(session_key)
session = self._cached_session(session_key)
q = (query or "").strip()[:4000] # Honcho caps query length for the embedding model.
if not session or not q:
return ""
@@ -236,7 +269,7 @@ class SessionContextMixin:
def _conclusions_scope(self, session: Any, target_peer_id: str) -> Any:
"""ConclusionScope for observing target_peer_id; shared by create/delete/list."""
ai_observes = target_peer_id == session.assistant_peer_id or self._ai_observe_others
ai_observes = target_peer_id == session.assistant_peer_id or self._ai_observes_others(session)
observer = self._get_or_create_peer(session.assistant_peer_id if ai_observes else target_peer_id)
return observer.conclusions_of(target_peer_id)
@@ -244,7 +277,7 @@ class SessionContextMixin:
"""Write a conclusion (durable fact) about ``peer`` back to Honcho."""
if not content or not content.strip():
return False
session = self._cache.get(session_key)
session = self._cached_session(session_key)
if not session:
logger.warning("No session cached for '%s', skipping conclusion", session_key)
return False
@@ -308,7 +341,7 @@ class SessionContextMixin:
operations, auth failures are logged and swallowed here too."""
if not content or not content.strip():
return False
session = self._cache.get(session_key)
session = self._cached_session(session_key)
if not session:
logger.warning("No session cached for '%s', skipping AI seed", session_key)
return False
@@ -357,7 +390,7 @@ class SessionContextMixin:
server error surfaces as an error, not as "no result" (#36098 issue 4: collapsing failures to ""
made auth errors, timeouts, and genuinely-empty answers indistinguishable).
"""
session = self._cache.get(session_key)
session = self._cached_session(session_key)
target_peer_id = self._resolve_peer_id(session, peer) if session else None
if target_peer_id is None:
return ""
@@ -367,7 +400,7 @@ class SessionContextMixin:
def _chat_once() -> str:
# The AI peer observes others when allowed; otherwise each peer queries its own context.
if self._ai_observe_others and target_peer_id != session.assistant_peer_id:
if self._ai_observes_others(session) and target_peer_id != session.assistant_peer_id:
observer = self._get_or_create_peer(session.assistant_peer_id)
return observer.chat(query, target=target_peer_id, reasoning_level=level) or ""
return self._get_or_create_peer(target_peer_id).chat(query, reasoning_level=level) or ""
+4 -7
View File
@@ -25,7 +25,7 @@ class SessionMigrationMixin:
if not memory_path.exists():
return False
session = self._cache.get(session_key)
session = self._cached_session(session_key)
if not session:
logger.warning("No local session cached for '%s', skipping memory migration", session_key)
return False
@@ -35,13 +35,10 @@ class SessionMigrationMixin:
# Owner-scoped: these files describe the install owner; uploading them under another
# human's peer would make Honcho attribute the owner's facts to that person. The owner is
# the CONFIG peerName — never a re-resolution of the session's own peer (that would compare
# the triggering user to themselves). No declared owner: single-operator only when there is
# no runtime identity; with one, nobody can be proven to be the owner.
# the CONFIG peerName, never a re-resolution of the session's own peer (that would compare
# the triggering user to themselves). No declared owner: nobody can be proven to be the owner.
owner_peer_id = self._declared_owner_peer_id()
session_is_owner = (session.user_peer_id == owner_peer_id if owner_peer_id is not None
else not self._runtime_user_ids())
if not session_is_owner:
if owner_peer_id is None or session.user_peer_id != owner_peer_id:
logger.info("Skipping memory-file migration: session user peer '%s' is not the declared owner (peerName=%s)",
session.user_peer_id, owner_peer_id or "unset")
return False
+13 -5
View File
@@ -27,6 +27,11 @@ def assistant_peer_id_for(config: Any) -> str:
return sanitize_peer_id(getattr(config, "ai_peer", None) or "hermes-assistant")
class HonchoPeerUnresolvedError(RuntimeError):
"""No user peer can be named: the transport supplied no runtime identity and honcho.json
declares no peerName. Raised instead of minting a session-derived peer nobody declared."""
class SessionPeersMixin:
"""Resolve user/assistant/observer peer IDs. Reads ``self._config`` and runtime identities only."""
@@ -75,8 +80,10 @@ class SessionPeersMixin:
def _resolve_user_peer_id(self, key: str) -> str:
"""Honcho user peer ID for this manager/session. Order: pinned peerName -> alias of a
runtime identity -> (prefixed) runtime identity -> configured peerName -> session-key fallback."""
peer_name = self._cfg("peer_name")
runtime identity -> (prefixed) runtime identity -> configured peerName. Raises
HonchoPeerUnresolvedError when none applies: every peer must be one the operator declared
or one the transport supplied, never a name derived from the session key."""
peer_name = str(self._cfg("peer_name") or "").strip()
if peer_name and self._cfg("pin_peer_name", False) is True:
return self._sanitize_id(peer_name)
@@ -95,8 +102,9 @@ class SessionPeersMixin:
if peer_name:
return self._sanitize_id(peer_name)
channel, sep, chat_id = key.partition(":")
return self._sanitize_id(f"user-{channel}-{chat_id}" if sep else f"user-default-{key}")
raise HonchoPeerUnresolvedError(
f"Honcho has no user peer for session '{key}': the transport supplied no user identity and "
"honcho.json declares no peerName.")
def _resolve_peer_id(self, session: HonchoSession, peer: str | None) -> str:
"""Resolve a peer alias ('user'/'ai') or explicit peer ID to a concrete, non-empty peer ID."""
@@ -108,7 +116,7 @@ class SessionPeersMixin:
target_peer_id = self._resolve_peer_id(session, peer)
if target_peer_id == session.assistant_peer_id:
return session.assistant_peer_id, session.assistant_peer_id
if self._ai_observe_others:
if self._ai_observes_others(session):
return session.assistant_peer_id, target_peer_id
return target_peer_id, None
+16 -30
View File
@@ -18,9 +18,10 @@ from contextlib import suppress
from pathlib import Path
from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider
from agent.secret_scope import UnscopedSecretError, get_secret
from agent.memory_provider import MemoryProvider, spawn_context_thread
from agent.secret_scope import get_secret
from tools.registry import tool_error
from utils import atomic_json_write, read_json_or_empty
logger = logging.getLogger(__name__)
@@ -72,23 +73,6 @@ def _is_client_error(exc: Exception) -> bool:
return type(exc).__name__ in _CLIENT_ERROR_TYPES or any(s in err_str for s in ("404", "not found", "valid uuid"))
def _read_mem0_json(config_path: Path) -> dict:
"""Best-effort read of mem0.json; missing/corrupt file -> {}."""
if config_path.exists():
with suppress(Exception):
return json.loads(config_path.read_text(encoding="utf-8"))
return {}
def _scoped_env(name: str) -> str:
"""Profile-scoped read of a non-secret mem0 setting; no scope under multiplex = unset (never
``os.environ``). Only the API key may fail closed — OSS mode has none to read (#99121)."""
try:
return get_secret(name, "") or ""
except UnscopedSecretError:
return ""
def _load_config() -> dict:
"""Env vars provide defaults; $HERMES_HOME/mem0.json overrides individual keys.
Layering avoids a silent failure when the JSON file exists but lacks fields
@@ -96,16 +80,19 @@ def _load_config() -> dict:
from hermes_constants import get_hermes_home
# Identity (user/agent id), host and mode are .env values like the key: read them through the
# profile scope too, or a secondary profile's memories land in the default profile's account.
config = {"mode": _scoped_env("MEM0_MODE") or "platform", "host": _scoped_env("MEM0_HOST"),
"agent_id": _scoped_env("MEM0_AGENT_ID") or "hermes", "oss": {}}
if user_id := _scoped_env("MEM0_USER_ID"): # only when explicitly configured, so initialize() can fall back to the gateway-native id
# A scope-less multiplex caller raises here on purpose — that is a spawn-site bug, and
# swallowing it would silently route the turn's memories to the default profile.
config = {"mode": get_secret("MEM0_MODE", "") or "platform", "host": get_secret("MEM0_HOST", "") or "",
"agent_id": get_secret("MEM0_AGENT_ID", "") or "hermes", "oss": {}}
if user_id := get_secret("MEM0_USER_ID", ""): # only when explicitly configured, so initialize() can fall back to the gateway-native id
config["user_id"] = user_id
file_cfg = _read_mem0_json(get_hermes_home() / "mem0.json")
file_cfg = read_json_or_empty(get_hermes_home() / "mem0.json")
config.update({k: v for k, v in file_cfg.items() if v is not None and v != ""})
# MEM0_API_KEY authenticates the Platform and self-hosted HTTP backends; pure OSS mode builds its
# backend from the local ``oss`` config and has no platform credential to resolve. Decide after
# mem0.json overrode the env fallback so a scope-less multiplex caller can load an OSS config
# without weakening fail-closed reads for credentialed modes.
# backend from the local ``oss`` config and has no platform credential to resolve, so a profile
# scope WITHOUT the key must still load an OSS config (#99121 as it stands today: the caller is
# scoped, the scope is just empty). Decided after mem0.json overrode the env defaults because
# the file may be what selects ``oss``. Scope-less callers already raised above.
if config.get("mode", "platform") == "oss":
config.setdefault("api_key", "")
elif not config.get("api_key"):
@@ -161,9 +148,8 @@ class Mem0MemoryProvider(MemoryProvider):
def save_config(self, values, hermes_home):
"""Merge-write config to $HERMES_HOME/mem0.json."""
from utils import atomic_json_write
config_path = Path(hermes_home) / "mem0.json"
atomic_json_write(config_path, {**_read_mem0_json(config_path), **values}, mode=0o600)
atomic_json_write(config_path, {**read_json_or_empty(config_path), **values}, mode=0o600)
def get_config_schema(self):
api_key_required = _load_config().get("mode", "platform") != "oss"
@@ -299,7 +285,7 @@ class Mem0MemoryProvider(MemoryProvider):
if self._prefetch_query == query and (self._prefetch_done or (self._prefetch_thread and self._prefetch_thread.is_alive())):
return
self._prefetch_query, self._prefetch_result, self._prefetch_done = query, "", False
self._prefetch_thread = t = threading.Thread(target=_run, daemon=True, name="mem0-prefetch")
self._prefetch_thread = t = spawn_context_thread(_run, name="mem0-prefetch")
t.start()
def prefetch(self, query: str, *, session_id: str = "") -> str:
@@ -332,7 +318,7 @@ class Mem0MemoryProvider(MemoryProvider):
prev.join(timeout=5.0)
if prev.is_alive(): # still busy after the wait: skip to avoid duplicate ingestion
return
self._sync_thread = threading.Thread(target=_sync, daemon=True, name="mem0-sync")
self._sync_thread = spawn_context_thread(_sync, name="mem0-sync")
self._sync_thread.start()
def get_tool_schemas(self) -> List[Dict[str, Any]]:
+10 -1
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import os
from typing import Any
from hermes_constants import get_hermes_home
LLM_PROVIDERS: dict[str, dict[str, Any]] = {
"openai": {"label": "OpenAI", "needs_key": True, "env_var": "OPENAI_API_KEY", "default_model": "gpt-5-mini", "base_url_key": "openai_base_url"},
"ollama": {"label": "Ollama (local)", "needs_key": False, "default_model": "llama3.1:8b", "default_url": "http://localhost:11434", "base_url_key": "ollama_base_url", "pip_dep": "ollama"},
@@ -16,7 +18,9 @@ EMBEDDER_PROVIDERS: dict[str, dict[str, Any]] = {
}
VECTOR_PROVIDERS: dict[str, dict[str, Any]] = {
"qdrant": {"label": "Qdrant", "default_config": {"path": os.path.expanduser("~/.hermes/mem0_qdrant")}, "pip_dep": "qdrant-client"},
# Resolved lazily (see ``vector_default_config``): the profile home is a ContextVar at call time,
# not an import-time constant, and ``~/.hermes`` is wrong on Windows and under profiles.
"qdrant": {"label": "Qdrant", "default_config": {"path": lambda: str(get_hermes_home() / "mem0_qdrant")}, "pip_dep": "qdrant-client"},
"pgvector": {
"label": "PGVector",
"default_config": {"host": "localhost", "port": 5432, "user": os.getenv("USER", "postgres"), "dbname": "postgres"},
@@ -26,6 +30,11 @@ VECTOR_PROVIDERS: dict[str, dict[str, Any]] = {
KNOWN_DIMS: dict[str, int] = {"text-embedding-3-small": 1536, "text-embedding-3-large": 3072, "text-embedding-ada-002": 1536, "nomic-embed-text": 768}
def vector_default_config(provider_id: str) -> dict[str, Any]:
"""A vector store's ``default_config`` with callable defaults resolved for the active profile."""
return {k: (v() if callable(v) else v) for k, v in VECTOR_PROVIDERS[provider_id]["default_config"].items()}
SECTION_REGISTRIES = (("llm", LLM_PROVIDERS), ("embedder", EMBEDDER_PROVIDERS), ("vector_store", VECTOR_PROVIDERS))
+13 -13
View File
@@ -18,7 +18,7 @@ from typing import Any
from hermes_constants import get_hermes_home # noqa: F401 — patched by tests
from ._oss_providers import EMBEDDER_PROVIDERS, KNOWN_DIMS, LLM_PROVIDERS, SECTION_REGISTRIES, VECTOR_PROVIDERS, validate_oss_config
from ._oss_providers import EMBEDDER_PROVIDERS, KNOWN_DIMS, LLM_PROVIDERS, SECTION_REGISTRIES, VECTOR_PROVIDERS, validate_oss_config, vector_default_config
_OLLAMA_URL = "http://localhost:11434"
_PGVECTOR_CONTAINER, _PGVECTOR_IMAGE, _PGVECTOR_PASSWORD = "hermes-pgvector", "pgvector/pgvector:pg17", "hermes"
@@ -52,10 +52,10 @@ def _http_get(url: str, path: str, timeout: int):
def _prompt_api_key(label: str, env_var: str, hermes_home: str) -> str:
"""Prompt for API key, showing masked existing value if found."""
existing = os.environ.get(env_var, "")
env_path = Path(hermes_home) / ".env"
if not existing and env_path.exists(): # utf-8-sig: a Notepad BOM on line 1 would otherwise defeat the key match
lines = env_path.read_text(encoding="utf-8-sig", errors="replace").splitlines()
existing = next((line.split("=", 1)[1].strip() for line in lines if line.startswith(f"{env_var}=")), "")
if not existing:
from agent.secret_scope import load_env_file
existing = load_env_file(Path(hermes_home) / ".env").get(env_var, "")
hint = f" (current: {_masked(existing)}, blank to keep)" if existing else ""
return getpass.getpass(f" {label} API key{hint}: ").strip()
@@ -123,7 +123,7 @@ def build_oss_config(flags: dict[str, str]) -> tuple[dict, dict[str, str]]:
if dims:
embedder_config["embedding_dims"] = dims
vector_id = flags.get("oss_vector", "qdrant")
vector_config = dict(VECTOR_PROVIDERS[vector_id]["default_config"])
vector_config = vector_default_config(vector_id)
for key in _VECTOR_FLAG_KEYS.get(vector_id, ()):
if val := flags.get(f"oss_vector_{key}"):
vector_config[key] = int(val) if key == "port" else val
@@ -169,8 +169,8 @@ def _persist_provider_config(hermes_home: str, config: dict, provider_config: di
def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
"""Platform mode setup — prompts for API key (secret -> .env), user/agent ids and rerank (-> mem0.json)."""
from . import _read_mem0_json
provider_config = _read_mem0_json(Path(hermes_home) / "mem0.json")
from utils import read_json_or_empty
provider_config = read_json_or_empty(Path(hermes_home) / "mem0.json")
print("\n Configuring mem0:\n")
env_writes = _api_key_writes(flags, "Mem0 Platform API key", url="https://app.mem0.ai")
for key, desc, default in (("user_id", "User identifier", "hermes-user"), ("agent_id", "Agent identifier", "hermes")):
@@ -205,8 +205,8 @@ def _check_selfhosted_server(host: str) -> None:
def _setup_selfhosted(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
"""Self-hosted mode — point at an existing Mem0 server: URL -> mem0.json, key -> .env (MEM0_API_KEY)."""
from . import _read_mem0_json
provider_config = _read_mem0_json(Path(hermes_home) / "mem0.json")
from utils import read_json_or_empty
provider_config = read_json_or_empty(Path(hermes_home) / "mem0.json")
print("\n Configuring mem0 (self-hosted server):\n")
host = flags.get("host") or _prompt("Mem0 server URL (e.g. http://localhost:8888)", default=provider_config.get("host") or None)
if not host:
@@ -238,11 +238,11 @@ def _print_oss_summary(oss_config: dict, env_writes: dict, dry_run: bool = False
def _finish_oss(hermes_home: str, config: dict, oss_config: dict, env_writes: dict[str, str], user_id: str, agent_id: str, pgvector_config: dict | None = None) -> None:
"""Shared OSS tail: write secrets + mem0.json, install deps, activate, check, summarize."""
from . import _read_mem0_json
from utils import read_json_or_empty
if env_writes:
_write_env(Path(hermes_home) / ".env", env_writes)
config_path = Path(hermes_home) / "mem0.json" # merge-write, plain text (platform path uses save_config's 0600 atomic write)
config_path.write_text(json.dumps({**_read_mem0_json(config_path), "mode": "oss", "user_id": user_id, "agent_id": agent_id, "oss": oss_config}, indent=2) + "\n", encoding="utf-8")
config_path.write_text(json.dumps({**read_json_or_empty(config_path), "mode": "oss", "user_id": user_id, "agent_id": agent_id, "oss": oss_config}, indent=2) + "\n", encoding="utf-8")
_install_provider_deps(oss_config["llm"]["provider"], oss_config["embedder"]["provider"], oss_config["vector_store"]["provider"])
if pgvector_config:
_ensure_pgvector_extension(pgvector_config)
@@ -404,7 +404,7 @@ def _setup_oss_interactive(hermes_home: str, config: dict) -> None:
env_writes: dict[str, str] = {}
llm_id, llm_def, llm_model, llm_url = _configure_model_provider("LLM", LLM_PROVIDERS, hermes_home, env_writes)
embedder_id, _, embedder_model, embedder_url = _configure_model_provider("Embedder", EMBEDDER_PROVIDERS, hermes_home, env_writes, llm=(llm_id, llm_def))
vector_items = [(v["label"], _VECTOR_DESCRIPTIONS.get(pid, lambda cfg: pid)(v.get("default_config", {}))) for pid, v in VECTOR_PROVIDERS.items()]
vector_items = [(v["label"], _VECTOR_DESCRIPTIONS.get(pid, lambda cfg: pid)(vector_default_config(pid))) for pid, v in VECTOR_PROVIDERS.items()]
vector_id = list(VECTOR_PROVIDERS)[_curses_select("Vector Store", vector_items, 0)]
# Auto-setup: ensure Ollama is running and models are pulled; ensure pgvector is reachable (offer Docker if not).
ollama_models = [m for pid, m in ((llm_id, llm_model), (embedder_id, embedder_model)) if pid == "ollama"]
+11 -18
View File
@@ -35,10 +35,11 @@ from urllib.parse import quote, unquote, urlparse
from urllib.request import url2pathname
from agent.message_content import flatten_message_text
from agent.memory_provider import MemoryProvider
from agent.memory_provider import MemoryProvider, spawn_context_thread
from agent.secret_scope import get_secret
from agent.skill_commands import extract_user_instruction_from_skill_message
from hermes_cli import __version__ as _HERMES_VERSION
from hermes_constants import get_hermes_home
from tools.registry import tool_error
from utils import atomic_json_write, env_var_enabled
@@ -559,7 +560,7 @@ def _load_ovcli_config(path: Optional[Path] = None) -> dict:
config_path = path or _resolve_ovcli_config_path()
if not config_path.exists():
return {}
data = json.loads(config_path.read_text(encoding="utf-8"))
data = json.loads(config_path.read_text(encoding="utf-8-sig"))
if not isinstance(data, dict):
raise ValueError(f"OpenViking CLI config must be a JSON object: {config_path}")
return data
@@ -904,15 +905,6 @@ def _local_openviking_bind(endpoint: str) -> tuple[str, int]:
return parsed.hostname or "127.0.0.1", parsed.port or 1933
def _hermes_home_path() -> Path:
try:
from hermes_constants import get_hermes_home
return get_hermes_home()
except Exception:
env_home = os.environ.get("HERMES_HOME")
return Path(env_home).expanduser() if env_home else Path.home() / ".hermes"
def _local_openviking_port_is_open(host: str, port: int) -> bool:
"""Pre-spawn guard: a successful connect proves a listener owns the port (so a
second openviking-server would lose the data-dir lock); says nothing about health."""
@@ -976,7 +968,7 @@ def _start_local_openviking_server(endpoint: str) -> tuple[str, str]:
server_cmd = shutil.which("openviking-server")
if not server_cmd:
return _LOCAL_SERVER_FAILED, "openviking-server was not found on PATH. Start it manually, then retry."
log_path = _hermes_home_path() / _OPENVIKING_SERVER_LOG_RELATIVE_PATH
log_path = get_hermes_home() / _OPENVIKING_SERVER_LOG_RELATIVE_PATH
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
# Strip PYTHONPATH: the Desktop backend puts the Hermes venv on it, which
@@ -1316,9 +1308,9 @@ class OpenVikingMemoryProvider(MemoryProvider):
# Caller holds _runtime_start_lock and reserved ownership via _runtime_start_pending.
if self._runtime_start_thread and self._runtime_start_thread.is_alive():
return
self._runtime_start_thread = threading.Thread(
target=self._finish_runtime_openviking_start, daemon=True, name="openviking-runtime-start",
kwargs={"endpoint": endpoint, "status_callback": status_callback, "warning_callback": warning_callback})
self._runtime_start_thread = spawn_context_thread(
lambda: self._finish_runtime_openviking_start(endpoint=endpoint, status_callback=status_callback, warning_callback=warning_callback),
name="openviking-runtime-start")
self._runtime_start_thread.start()
def _settings_tuple(self, endpoint: Optional[str] = None) -> tuple:
@@ -1411,7 +1403,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
self._env_refresh_enabled = True
self._session_id = session_id
self._turn_count = 0
self._hermes_home = str(kwargs.get("hermes_home") or "").strip() or str(_hermes_home_path())
self._hermes_home = str(kwargs.get("hermes_home") or "").strip() or str(get_hermes_home())
self._acquire_run_lock()
self._profile_prefetched_sessions.clear()
@@ -2078,7 +2070,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
if after_discard is not None:
after_discard()
thread = threading.Thread(target=_run, daemon=True, name=name)
thread = spawn_context_thread(_run, name=name)
with lock:
if skip_if is not None and skip_if():
return
@@ -2224,7 +2216,8 @@ class OpenVikingMemoryProvider(MemoryProvider):
logger.debug("Could not safely mark OpenViking session %s pending without a run lock", sid)
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
atomic_json_write(path, {"session_id": sid, "owner_run_id": self._run_id}, mode=0o600)
self._pending_marked_sids.add(sid)
except Exception as e:
+2 -1
View File
@@ -321,7 +321,8 @@ def _mirror_manual_config_to_openviking_store(*, prompt, select, cancelled, valu
return _SETUP_CANCELLED
if replace is False:
continue
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
# atomic_json_write creates the temp file 0600 and os.replace()s it: no
# half-written config on crash, no chmod-after-write window for the keys.
ov.atomic_json_write(path, ov._ovcli_data_from_connection_values(values), mode=0o600)
+4 -4
View File
@@ -22,7 +22,7 @@ from pathlib import Path
from typing import Any, Callable
from urllib.parse import quote
from agent.memory_provider import MemoryProvider
from agent.memory_provider import MemoryProvider, spawn_context_thread
from agent.secret_scope import get_secret
from agent.file_safety import raise_if_read_blocked
from tools.registry import tool_error
@@ -186,7 +186,7 @@ class _WriteQueue:
def __init__(self, client: _Client, db_path: Path):
self._client, self._db_path, self._q = client, db_path, queue.Queue()
self._thread = threading.Thread(target=self._loop, name="retaindb-writer", daemon=True)
self._thread = spawn_context_thread(self._loop, name="retaindb-writer")
db_path.parent.mkdir(parents=True, exist_ok=True)
self._local = threading.local() # one cached connection per thread, all tracked in _connections
self._connections: set[sqlite3.Connection] = set()
@@ -346,7 +346,7 @@ class RetainDBMemoryProvider(MemoryProvider):
soul = (home / "SOUL.md").read_text(encoding="utf-8", errors="replace").strip() if (home / "SOUL.md").exists() else ""
if soul: # seed agent identity from SOUL.md in background
seed = lambda: self._client.seed_agent_identity(self._agent_id, soul, source="soul_md") # noqa: E731
threading.Thread(target=_quiet, args=("soul seed", seed), name="retaindb-soul-seed", daemon=True).start()
spawn_context_thread(_quiet, args=("soul seed", seed), name="retaindb-soul-seed").start()
def system_prompt_block(self) -> str:
project = self._client.project if self._client else "retaindb"
@@ -368,7 +368,7 @@ class RetainDBMemoryProvider(MemoryProvider):
self._client.ask_user(self._user_id, query, reasoning_level=self._reasoning_level(query)).get("answer") or "") or None),
("retaindb-agent-model", "agent model", "_agent_model", lambda: self._agent_model_or_none(self._client.get_agent_model(self._agent_id))),
)
self._prefetch_threads = [threading.Thread(target=self._store, args=(label, attr, fetch), name=name, daemon=True)
self._prefetch_threads = [spawn_context_thread(self._store, args=(label, attr, fetch), name=name)
for name, label, attr, fetch in jobs]
for t in self._prefetch_threads:
t.start()
+7 -8
View File
@@ -1,6 +1,6 @@
# Supermemory Memory Provider
Semantic long-term memory with profile recall, semantic search, explicit memory tools, and full-session conversation ingest (one ingest per session) for richer profiles.
Semantic long-term memory with profile recall, semantic search, explicit memory tools, and per-turn conversation capture into one document per session per 4-hour window for richer profiles.
## Requirements
@@ -55,7 +55,7 @@ Config file: `$HERMES_HOME/supermemory.json`
| `capture_mode` | `all` | Skip tiny or trivial turns by default |
| `search_mode` | `hybrid` | Search mode: `hybrid` (profile + memories), `memories` (memories only), `documents` (documents only) |
| `entity_context` | built-in default | Extraction guidance passed to Supermemory |
| `api_timeout` | `5.0` | Timeout for SDK and ingest requests |
| `api_timeout` | `5.0` | Timeout for SDK requests |
### Environment Variables
@@ -67,7 +67,7 @@ Config file: `$HERMES_HOME/supermemory.json`
Base URL precedence is `supermemory.json` → `SUPERMEMORY_BASE_URL` →
`https://api.supermemory.ai`. Hermes resolves it once and uses the same endpoint
for SDK operations, setup/status probes, and full-session conversation ingest.
for SDK operations and setup/status probes.
## Tools
@@ -93,12 +93,11 @@ Supermemory app, so you can filter, browse, and bulk-manage them per source agen
When enabled, Hermes can:
- prefetch relevant memory context before each turn
- buffer the full conversation and ingest it as **one session** at session end (or on `/reset`, branch, compression, or shutdown)
- ingest the full session to the conversations endpoint for richer profile/graph updates
- route every SDK, probe, and conversation-ingest request through the configured hosted or self-hosted endpoint
- write each completed user/assistant turn to **one document per session per 4-hour window** (`customId` = `<session>_<date>_b<0-5>`, so the API appends deltas), matching the capture shape of the other Supermemory agent integrations
- retry failed turn writes on the next turn, session end, `/reset`, or shutdown (at-least-once: if the API accepted a write but the response was lost, the same turn is appended again)
- route every SDK and probe request through the configured hosted or self-hosted endpoint
- expose explicit tools for search, store, forget, and profile access
The session is written once via the conversations endpoint, which drives Supermemory's entity extraction and profile building while keeping a clean, retrievable full transcript.
## Profile-Scoped Containers
@@ -128,7 +127,7 @@ For advanced setups (e.g. OpenClaw-style multi-workspace), you can enable custom
When enabled:
- `supermemory-search`, `supermemory-save`, `supermemory-forget`, and `supermemory-profile` accept an optional `container_tag` parameter
- The tag must be in the whitelist: primary container + `custom_containers`
- Automatic operations (turn sync, prefetch, memory write mirroring, session ingest) always use the **primary** container only
- Automatic operations (turn capture, prefetch, memory write mirroring) always use the **primary** container only
- Custom container instructions are injected into the system prompt
## Support
+84 -45
View File
@@ -1,4 +1,4 @@
"""Supermemory memory plugin (MemoryProvider): profile recall, semantic search, memory tools, turn capture, session ingest."""
"""Supermemory memory plugin (MemoryProvider): profile recall, semantic search, memory tools, per-turn capture."""
from __future__ import annotations
@@ -8,12 +8,11 @@ import logging
import os
import re
import threading
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from agent.memory_provider import MemoryProvider, spawn_context_thread
from agent.secret_scope import get_secret, is_multiplex_active
from tools.registry import tool_error
@@ -25,6 +24,11 @@ _DEFAULT_BASE_URL = "https://api.supermemory.ai"
_API_KEY_URL = "http://app.supermemory.ai/integrations?connect=hermes"
# Strips injected <supermemory-context> / <supermemory-containers> blocks before capture.
_INJECTED_BLOCK_RE = re.compile(r"<supermemory-(context|containers)>[\s\S]*?</supermemory-\1>\s*", re.DOTALL)
_DATA_URI_RE = re.compile(r"data:[^;,\s]+;base64,[A-Za-z0-9+/=]+") # pasted inline images are useless as memory text
_CAPTURE_BUCKET_HOURS = 4 # one capture document per session per 4h window (matches the other Supermemory agent integrations)
_FAILED = object() # _quietly default for capture writes: an explicit failure marker (a client returning None still counts as success)
_MAX_PENDING_TURNS = 50 # a down service must not accumulate an unbounded retry buffer
_MAX_PENDING_BYTES = 256 * 1024
_DEFAULT_ENTITY_CONTEXT = (
"User-assistant conversation. Format: [role: user]...[user:end] and [role: assistant]...[assistant:end].\n\n"
"Only extract things useful in future conversations. Most messages are not worth remembering.\n\n"
@@ -95,7 +99,7 @@ _CONFIG_SPEC: Dict[str, tuple] = {
def _read_json_dict(path: Path) -> dict:
raw = _quietly(lambda: json.loads(path.read_text(encoding="utf-8")), "Failed to parse %s", path) if path.exists() else None
raw = _quietly(lambda: json.loads(path.read_text(encoding="utf-8-sig")), "Failed to parse %s", path) if path.exists() else None
return raw if isinstance(raw, dict) else {}
@@ -161,7 +165,7 @@ def _format_prefetch_context(static_facts: list, dynamic_facts: list, search_res
def _clean_text_for_capture(text: str) -> str:
return _INJECTED_BLOCK_RE.sub("", text or "").strip()
return _DATA_URI_RE.sub("[image]", _INJECTED_BLOCK_RE.sub("", text or "")).strip()
def _memory_fields(item: Any, *keys: str) -> dict:
@@ -231,14 +235,16 @@ class _SupermemoryClient:
self.forget_memory(memory_id, container_tag=container_tag)
return {"success": True, "message": f'Forgot: "{(results[0].get("memory") or "")[:100]}"', "id": memory_id}
def ingest_conversation(self, session_id: str, messages: list[dict], metadata: dict | None = None) -> None:
payload: dict = {"conversationId": session_id, "messages": messages, "containerTags": [self._container_tag],
**({"metadata": self._merge_metadata(metadata)} if metadata else {})}
req = urllib.request.Request(f"{self._base_url}/v4/conversations", data=json.dumps(payload).encode("utf-8"), method="POST",
headers={"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json",
"x-sm-source": "hermes"})
with urllib.request.urlopen(req, timeout=self._timeout + 3):
return
def _format_turn(user: str, assistant: str) -> str:
"""Render one turn in the [role: x]...[x:end] layout the entity context describes."""
return "\n".join(f"[role: {role}]\n{text}\n[{role}:end]" for role, text in (("user", user), ("assistant", assistant)) if text)
def _capture_custom_id(session_id: str, now: Optional[datetime] = None) -> str:
"""<session>_<YYYY-MM-DD>_b<0..5>: same id within a 4h window, so the API appends turns to one document."""
now = now or datetime.now(timezone.utc)
return f"{_sanitize_tag(session_id)}_{now:%Y-%m-%d}_b{now.hour // _CAPTURE_BUCKET_HOURS}"
def _build_client(api_key: str, config: dict, container_tag: str) -> _SupermemoryClient:
@@ -312,7 +318,8 @@ class SupermemoryMemoryProvider(MemoryProvider):
self._client: Optional[_SupermemoryClient] = None
self._container_tag, self._turn_count, self._write_enabled, self._active = _DEFAULT_CONTAINER_TAG, 0, True, False
self._prefetch_thread = self._sync_thread = self._write_thread = None # only _write_thread is ever started
self._session_turns: List[Dict[str, str]] = []
self._pending_turns: List[Dict[str, str]] = [] # failed writes, each tagged with its session_id; retried on next write/end/switch/shutdown
self._capture_lock = threading.Lock() # sync_turn (worker) vs on_session_switch/shutdown (caller thread) both touch _pending_turns
self._apply_config(_load_supermemory_config())
self._base_url, self._allowed_containers = _DEFAULT_BASE_URL, [] # env var is only consulted in initialize()
@@ -373,7 +380,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
def initialize(self, session_id: str, **kwargs) -> None:
from hermes_constants import get_hermes_home
self._hermes_home = kwargs.get("hermes_home") or str(get_hermes_home())
self._session_id, self._turn_count, self._session_turns = session_id, 0, []
self._session_id, self._turn_count, self._pending_turns = session_id, 0, []
config = _load_supermemory_config(self._hermes_home)
self._api_key = get_secret("SUPERMEMORY_API_KEY", "") or ""
self._container_tag = _resolve_container_tag(config["container_tag"], kwargs.get("agent_identity", "default"))
@@ -408,58 +415,90 @@ class SupermemoryMemoryProvider(MemoryProvider):
profile["search_results"], self._max_recall_results)
return _quietly(_recall, "Supermemory prefetch failed", default="")
def _write_turns(self, mode: str, new_turn: Optional[Dict[str, str]] = None) -> None:
"""Write pending turns (+ ``new_turn``) as one documents.add per session id; custom_id = session + 4h bucket, so the
API appends deltas. Failed batches stay pending under their own session id, so a switch never re-homes them.
Retries are at-least-once: a write the API accepted but whose response was lost is re-sent and appended again.
The lock serializes the snapshot/write/replace sequence across the worker and caller threads."""
with self._capture_lock:
turns = self._pending_turns + ([new_turn] if new_turn else [])
if not turns:
return
failed: List[Dict[str, str]] = []
for sid in dict.fromkeys(t["session_id"] for t in turns):
batch = [t for t in turns if t["session_id"] == sid]
now = datetime.now(timezone.utc)
content = "\n\n".join(_format_turn(t["user"], t["assistant"]) for t in batch)
metadata = {"type": "conversation", "session_id": sid, "timestamp": now.isoformat()} # no sm_capture_mode: Hermes policy
result = _quietly(lambda: self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context,
custom_id=_capture_custom_id(sid, now)),
"Supermemory capture failed (%s, session=%s, %d turns pending)", mode, sid, len(batch),
level=logging.WARNING if mode != "turn" else logging.DEBUG, default=_FAILED)
if result is _FAILED: # only a raised exception re-queues the batch
failed += batch
self._pending_turns = failed
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
# Host runs this on a worker thread, so the blocking write is fine here.
if not self._can_write() or not self._auto_capture:
return
turn = {"user": _clean_text_for_capture(user_content), "assistant": _clean_text_for_capture(assistant_content)}
if any(turn.values()): # buffered for the single full-session document written at end/switch/shutdown
self._session_turns.append(turn)
turn = {"user": _clean_text_for_capture(user_content), "assistant": _clean_text_for_capture(assistant_content),
"session_id": session_id or self._session_id}
if turn["user"] or turn["assistant"]:
self._write_turns("turn", turn)
self._bound_pending_turns()
def _ingest(self, session_id: str, messages: list[dict], metadata: dict, fail_msg: str, level: int = logging.DEBUG) -> None:
metadata = {"type": "full_session", "session_id": session_id, **metadata}
_quietly(lambda: self._client.ingest_conversation(session_id, messages, metadata=metadata), fail_msg, level=level)
def _flush_pending(self, mode: str) -> None:
if self._can_write():
self._write_turns(mode)
self._bound_pending_turns()
def _flush_turns(self, session_id: str, *, partial: bool, fail_msg: str) -> None:
turns = self._session_turns # message_count reports 2 per buffered turn regardless of empty sides
messages = [{"role": role, "content": t[role]} for t in turns for role in ("user", "assistant") if t.get(role)]
self._ingest(session_id, messages, {"message_count": len(turns) * 2, "partial": partial}, fail_msg)
def _bound_pending_turns(self) -> None:
"""Keep the retry buffer bounded: drop OLDEST entries past the turn/byte caps.
Without this, a persistently failing service accumulates one entry per turn for the
process lifetime (gateway runs never re-initialize) and every retry re-sends the
whole accumulated payload."""
with self._capture_lock:
if len(self._pending_turns) <= _MAX_PENDING_TURNS and \
sum(len(t["user"]) + len(t["assistant"]) for t in self._pending_turns) <= _MAX_PENDING_BYTES:
return
kept: List[Dict[str, str]] = list(self._pending_turns)
total = sum(len(t["user"]) + len(t["assistant"]) for t in kept)
while len(kept) > _MAX_PENDING_TURNS or total > _MAX_PENDING_BYTES:
if not kept:
break
dropped = kept.pop(0)
total -= len(dropped["user"]) + len(dropped["assistant"])
if len(kept) != len(self._pending_turns):
logger.warning("Supermemory: dropped %d oldest pending turn(s) to keep the retry buffer bounded",
len(self._pending_turns) - len(kept))
self._pending_turns = kept
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
if not self._can_write() or not self._session_id:
return
cleaned = [{"role": m.get("role"), "content": content} for m in messages or []
if m.get("role") in {"user", "assistant"} and (content := _clean_text_for_capture(str(m.get("content", ""))))]
if not cleaned or (len(cleaned) == 1 and len(cleaned[0]["content"]) < 20):
return
self._ingest(self._session_id, cleaned, {"message_count": len(cleaned)}, "Supermemory session ingest failed", level=logging.WARNING)
self._session_turns = [] # so shutdown() doesn't duplicate on normal exit
# Turns were already written as they completed; only retry what failed.
self._flush_pending("session_end")
def on_session_switch(self, new_session_id: str, *, parent_session_id: str = "", reset: bool = False, **kwargs) -> None:
"""Flush any buffered turns from the old session as one document, then reset for the new session."""
old_session_id = self._session_id
# Pending turns survive the switch: they carry their own session_id, so a later retry still lands on the old session.
self._flush_pending("session_switch")
if self._can_write():
if self._session_turns and old_session_id:
self._flush_turns(old_session_id, partial=not reset, fail_msg="Supermemory session-switch ingest failed")
self._turn_count = 0
self._session_id = str(new_session_id or "").strip() or old_session_id
self._session_turns = []
self._session_id = str(new_session_id or "").strip() or self._session_id
def on_memory_write(self, action: str, target: str, content: str) -> None:
if not self._can_write() or action != "add" or not (content or "").strip():
return
if self._write_thread and self._write_thread.is_alive():
self._write_thread.join(timeout=2.0)
self._write_thread = threading.Thread(
target=_quietly, daemon=False, name="supermemory-memory-write",
self._write_thread = spawn_context_thread(
_quietly, daemon=False, name="supermemory-memory-write",
args=(lambda: self._client.add_memory(content.strip(), metadata={"target": target, "type": "explicit_memory"},
entity_context=self._entity_context), "Supermemory on_memory_write failed"))
self._write_thread.start()
def shutdown(self) -> None:
# Emergency fallback (crashes only). Buffer is cleared on normal on_session_end().
if self._can_write() and self._session_turns and self._session_id:
logger.warning("Supermemory: Saving session via shutdown (session=%s, turns=%d)", self._session_id, len(self._session_turns))
self._flush_turns(self._session_id, partial=True, fail_msg="Supermemory shutdown ingest failed")
self._flush_pending("shutdown")
if self._write_thread and self._write_thread.is_alive():
self._write_thread.join(timeout=5.0)
self._prefetch_thread = self._sync_thread = self._write_thread = None
+1 -1
View File
@@ -1,5 +1,5 @@
name: supermemory
version: 1.0.1
description: "Supermemory semantic long-term memory with profile recall, semantic search, explicit memory tools, and session ingest."
description: "Supermemory semantic long-term memory with profile recall, semantic search, explicit memory tools, and per-turn conversation capture."
pip_dependencies:
- supermemory