feat(desktop): openExternalFileForIpc opens files via OS handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ------------------------------------------------------------------
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=()):
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user