refactor(plugins/memory): hindsight — split settings/embedded/setup modules, unify config parsing, remove dead helpers
This commit is contained in:
+717
-1710
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
"""Local-embedded Hindsight runtime helpers: import probe, install hint, the
|
||||
per-profile env file the standalone ``hindsight-embed`` daemon consumes, and the
|
||||
daemon health-grace env export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agent.secret_scope import get_secret
|
||||
|
||||
from .settings import _DEFAULT_IDLE_TIMEOUT, _daemon_llm_provider, _parse_int_setting
|
||||
|
||||
logger = logging.getLogger(__name__.rpartition(".")[0])
|
||||
|
||||
# Read by hindsight_embed.daemon_embed_manager AT IMPORT TIME (module-level
|
||||
# constant): how long to wait for a slow /health before declaring the daemon
|
||||
# stale and killing it. On resource-contended hosts a busy daemon can exceed
|
||||
# the upstream 2s check and get needlessly restarted, so it's plugin config.
|
||||
_PORT_HEALTH_GRACE_ENV = "HINDSIGHT_EMBED_PORT_HEALTH_GRACE_TIMEOUT"
|
||||
|
||||
# Markers of a stale embedded-daemon connection (the client is recreated and
|
||||
# the operation retried once).
|
||||
_RETRIABLE_CONNECTION_MARKERS = (
|
||||
"cannot connect to host",
|
||||
"connection refused",
|
||||
"connect call failed",
|
||||
"clientconnectorerror",
|
||||
)
|
||||
|
||||
|
||||
def _export_port_health_grace_timeout(config: dict[str, Any]) -> None:
|
||||
"""Export the daemon health grace timeout to the process env.
|
||||
|
||||
Must run BEFORE ``hindsight_embed.daemon_embed_manager`` is imported. Only
|
||||
set when the user configured a value; ``setdefault`` so an explicit env
|
||||
override always wins.
|
||||
"""
|
||||
raw = config.get("port_health_grace_timeout")
|
||||
if raw is None or raw == "":
|
||||
return
|
||||
try:
|
||||
seconds = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid Hindsight port_health_grace_timeout %r; ignoring.", raw)
|
||||
return
|
||||
if seconds < 0:
|
||||
logger.warning("Negative Hindsight port_health_grace_timeout %r; ignoring.", raw)
|
||||
return
|
||||
os.environ.setdefault(_PORT_HEALTH_GRACE_ENV, repr(seconds))
|
||||
|
||||
|
||||
def _check_local_runtime() -> tuple[bool, str | None]:
|
||||
"""Return whether the local embedded Hindsight stack imports cleanly.
|
||||
|
||||
On older CPUs NumPy can raise at import before the daemon starts; report
|
||||
"unavailable" so Hermes degrades instead of retrying a broken backend.
|
||||
``sentence_transformers`` is imported too: ``hindsight``/``hindsight_embed``
|
||||
import fine even when the embedding stack is broken, and without this the
|
||||
probe (and ``hermes memory status``) would stay green while the daemon
|
||||
aborts on every retain/recall.
|
||||
"""
|
||||
try:
|
||||
importlib.import_module("hindsight")
|
||||
importlib.import_module("hindsight_embed.daemon_embed_manager")
|
||||
importlib.import_module("sentence_transformers")
|
||||
return True, None
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _local_runtime_hint(reason: str | None) -> str:
|
||||
"""Install guidance when the local_embedded runtime is missing.
|
||||
|
||||
The top-level ``hindsight`` module ships only with ``hindsight-all``;
|
||||
``plugin.yaml`` declares just ``hindsight-client`` (cloud/local_external),
|
||||
so a hand-written config, the legacy ``"mode": "local"`` alias, or a
|
||||
restored backup hits ``ModuleNotFoundError: No module named 'hindsight'``.
|
||||
"""
|
||||
text = (reason or "").lower()
|
||||
if "no module named" in text and ("hindsight'" in text or 'hindsight"' in text
|
||||
or "hindsight_embed" in text):
|
||||
return (
|
||||
f" Install the embedded runtime with: uv pip install --python "
|
||||
f"{sys.executable} hindsight-all — or run 'hermes memory setup'. "
|
||||
"(local_embedded needs the 'hindsight-all' package, which provides the "
|
||||
"top-level 'hindsight' module; 'hindsight-client' alone only covers "
|
||||
"cloud / local_external.)"
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _load_simple_env(path) -> dict[str, str]:
|
||||
"""Parse a KEY=VALUE env file, ignoring comments and blank lines.
|
||||
|
||||
utf-8-sig, not utf-8: also used on the Hermes .env during post_setup, where
|
||||
a Notepad BOM would otherwise stick to the first key.
|
||||
"""
|
||||
if not path.exists():
|
||||
return {}
|
||||
values: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def _embedded_profile_env_path(config: dict[str, Any]) -> Path:
|
||||
profile = str(config.get("profile", "hermes") or "hermes")
|
||||
return Path.home() / ".hindsight" / "profiles" / f"{profile}.env"
|
||||
|
||||
|
||||
def _build_embedded_profile_env(config: dict[str, Any], *, llm_api_key: str | None = None) -> dict[str, str]:
|
||||
"""Build the profile-scoped env that standalone hindsight-embed consumes."""
|
||||
if llm_api_key is None:
|
||||
llm_api_key = (
|
||||
config.get("llmApiKey")
|
||||
or config.get("llm_api_key")
|
||||
or get_secret("HINDSIGHT_LLM_API_KEY", "")
|
||||
)
|
||||
env_values = {
|
||||
"HINDSIGHT_API_LLM_PROVIDER": str(_daemon_llm_provider(config.get("llm_provider", ""))),
|
||||
"HINDSIGHT_API_LLM_API_KEY": str(llm_api_key or ""),
|
||||
"HINDSIGHT_API_LLM_MODEL": str(config.get("llm_model", "")),
|
||||
"HINDSIGHT_API_LOG_LEVEL": "info",
|
||||
}
|
||||
base_url = config.get("llm_base_url") or os.environ.get("HINDSIGHT_API_LLM_BASE_URL", "")
|
||||
if base_url:
|
||||
env_values["HINDSIGHT_API_LLM_BASE_URL"] = str(base_url)
|
||||
idle_timeout = config.get("idle_timeout")
|
||||
if idle_timeout is None:
|
||||
idle_timeout = os.environ.get("HINDSIGHT_IDLE_TIMEOUT")
|
||||
if idle_timeout is not None and idle_timeout != "":
|
||||
env_values["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(
|
||||
_parse_int_setting(idle_timeout, _DEFAULT_IDLE_TIMEOUT)
|
||||
)
|
||||
return env_values
|
||||
|
||||
|
||||
def _secure_write_profile_env(profile_env: Path, content: str) -> None:
|
||||
"""Create/overwrite *profile_env* owner-only (0600). The file carries the
|
||||
daemon's plaintext LLM API key, so a pre-existing file is tightened BEFORE
|
||||
the new secret bytes are written."""
|
||||
if profile_env.exists():
|
||||
try:
|
||||
os.chmod(profile_env, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
fd = os.open(str(profile_env), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
|
||||
|
||||
def _validate_profile_env_permissions(profile_env: Path) -> None:
|
||||
"""Post-write check: the secret file must be owner-only on POSIX (Windows
|
||||
ACLs aren't modelled by mode bits, so skipped there)."""
|
||||
if os.name != "posix":
|
||||
return
|
||||
import stat
|
||||
|
||||
mode = stat.S_IMODE(profile_env.stat().st_mode)
|
||||
if mode != 0o600:
|
||||
try:
|
||||
os.chmod(profile_env, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
if stat.S_IMODE(profile_env.stat().st_mode) != 0o600:
|
||||
raise PermissionError(
|
||||
f"Embedded Hindsight profile environment is not owner-only: {profile_env}"
|
||||
)
|
||||
|
||||
|
||||
def _materialize_embedded_profile_env(config: dict[str, Any], *, llm_api_key: str | None = None) -> Path:
|
||||
"""Write the profile env file; never leave a plaintext key behind in a file
|
||||
whose permissions could not be verified."""
|
||||
profile_env = _embedded_profile_env_path(config)
|
||||
profile_env.parent.mkdir(parents=True, exist_ok=True)
|
||||
env_values = _build_embedded_profile_env(config, llm_api_key=llm_api_key)
|
||||
content = "".join(f"{key}={value}\n" for key, value in env_values.items())
|
||||
try:
|
||||
_secure_write_profile_env(profile_env, content)
|
||||
_validate_profile_env_permissions(profile_env)
|
||||
except BaseException:
|
||||
try:
|
||||
profile_env.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return profile_env
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Hindsight plugin constants and pure config normalizers (no I/O, no origin imports)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, List
|
||||
|
||||
# Log under the plugin package's own logger name (loader-path independent).
|
||||
logger = logging.getLogger(__name__.rpartition(".")[0])
|
||||
|
||||
_DEFAULT_API_URL = "https://api.hindsight.vectorize.io"
|
||||
_DEFAULT_LOCAL_URL = "http://localhost:8888"
|
||||
# Keep in sync with tools/lazy_deps.py ("memory.hindsight") and plugin.yaml.
|
||||
_MIN_CLIENT_VERSION = "0.6.1"
|
||||
_DEFAULT_TIMEOUT = 120 # seconds — cloud API can take 30-40s per request
|
||||
_DEFAULT_IDLE_TIMEOUT = 300 # seconds — Hindsight embedded daemon default
|
||||
# ``metadata.source`` stamped on retained memories — OPT-IN, empty by default:
|
||||
# AGENTS.md forbids on-by-default third-party attribution tags. Set via the
|
||||
# ``retain_source`` config key or HINDSIGHT_RETAIN_SOURCE.
|
||||
_DEFAULT_RETAIN_SOURCE = ""
|
||||
# Hindsight brand mark (eye ringed by graph nodes) for the recall/retain indicators.
|
||||
_HINDSIGHT_GLYPH = "👁️"
|
||||
# Hindsight 0.5.0 added ``update_mode='append'`` on retain. Without it, reusing a
|
||||
# stable session-scoped document_id silently overwrites prior turns server-side,
|
||||
# so older APIs keep the per-process unique document_id fallback.
|
||||
_MIN_VERSION_FOR_UPDATE_MODE_APPEND = "0.5.0"
|
||||
_VALID_BUDGETS = {"low", "mid", "high"}
|
||||
_PROVIDER_DEFAULT_MODELS = {
|
||||
"openai": "gpt-4o-mini",
|
||||
"anthropic": "claude-haiku-4-5",
|
||||
"gemini": "gemini-3.6-flash",
|
||||
"groq": "openai/gpt-oss-120b",
|
||||
"openrouter": "qwen/qwen3.5-9b",
|
||||
"minimax": "MiniMax-M2.7",
|
||||
"ollama": "gemma3:12b",
|
||||
"lmstudio": "local-model",
|
||||
"openai_compatible": "your-model-name",
|
||||
}
|
||||
# The embedded daemon speaks OpenAI wire format for these providers.
|
||||
_OPENAI_WIRE_PROVIDERS = {"openai_compatible", "openrouter"}
|
||||
_OBSERVATION_SCOPE_KEYWORDS = {"per_tag", "combined", "all_combinations"}
|
||||
|
||||
|
||||
def _parse_int_setting(value: Any, default: int) -> int:
|
||||
"""Parse an integer config/env value, falling back on invalid input."""
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid integer Hindsight setting %r; using default %s", value, default)
|
||||
return default
|
||||
|
||||
|
||||
def _daemon_llm_provider(provider: str) -> str:
|
||||
return "openai" if provider in _OPENAI_WIRE_PROVIDERS else provider
|
||||
|
||||
|
||||
def _normalize_retain_tags(value: Any) -> List[str]:
|
||||
"""Normalize tag config/tool values to a deduplicated list of strings."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
raw_items = value
|
||||
elif isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return []
|
||||
parsed = None
|
||||
if text.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except Exception:
|
||||
parsed = None
|
||||
raw_items = parsed if isinstance(parsed, list) else text.split(",")
|
||||
else:
|
||||
raw_items = [value]
|
||||
normalized: list[str] = []
|
||||
for item in raw_items:
|
||||
tag = str(item).strip()
|
||||
if tag and tag not in normalized:
|
||||
normalized.append(tag)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_observation_scopes(value: Any) -> Any:
|
||||
"""Normalize an observation_scopes value to a Hindsight-accepted form.
|
||||
|
||||
Returns ``None`` (nothing configured; Hindsight applies its ``combined``
|
||||
default), a keyword string, or ``list[list[str]]`` (one inner list per
|
||||
consolidation pass). Accepts a keyword, a JSON-encoded list, a flat list of
|
||||
tags (one scope), or a list of tag-lists. Anything unrecognized yields
|
||||
``None`` so we never send an invalid payload.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if text in _OBSERVATION_SCOPE_KEYWORDS:
|
||||
return text
|
||||
if text.startswith("["):
|
||||
try:
|
||||
return _normalize_observation_scopes(json.loads(text))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
if isinstance(value, (list, tuple)):
|
||||
if all(isinstance(entry, str) for entry in value):
|
||||
inner = [entry.strip() for entry in value if entry.strip()]
|
||||
return [inner] if inner else None
|
||||
scopes: list[list[str]] = []
|
||||
for entry in value:
|
||||
if isinstance(entry, (list, tuple)):
|
||||
inner = [str(tag).strip() for tag in entry if str(tag).strip()]
|
||||
if inner:
|
||||
scopes.append(inner)
|
||||
elif isinstance(entry, str) and entry.strip():
|
||||
scopes.append([entry.strip()])
|
||||
return scopes or None
|
||||
return None
|
||||
|
||||
|
||||
def _sanitize_bank_segment(value: str) -> str:
|
||||
"""Make a bank_id placeholder URL/filesystem safe: non ``[A-Za-z0-9_-]``
|
||||
runs become a single dash; leading/trailing dashes and underscores are stripped."""
|
||||
if not value:
|
||||
return ""
|
||||
out = []
|
||||
prev_dash = False
|
||||
for ch in str(value):
|
||||
if ch.isalnum() or ch in "-_":
|
||||
out.append(ch)
|
||||
prev_dash = False
|
||||
elif not prev_dash:
|
||||
out.append("-")
|
||||
prev_dash = True
|
||||
return "".join(out).strip("-_")
|
||||
|
||||
|
||||
def _resolve_bank_id_template(template: str, fallback: str, **placeholders: str) -> str:
|
||||
"""Render a bank_id template ({profile}, {workspace}, {platform}, {user},
|
||||
{session}); each placeholder is sanitized first. Empty placeholders render
|
||||
as "" and the dash/underscore runs they leave are collapsed, e.g.
|
||||
``hermes-{user}`` with no user becomes ``hermes``. Empty template or an
|
||||
invalid placeholder falls back to *fallback*."""
|
||||
if not template:
|
||||
return fallback
|
||||
sanitized = {k: _sanitize_bank_segment(v) for k, v in placeholders.items()}
|
||||
try:
|
||||
rendered = template.format(**sanitized)
|
||||
except (KeyError, IndexError) as exc:
|
||||
logger.warning("Invalid bank_id_template %r: %s — using fallback %r",
|
||||
template, exc, fallback)
|
||||
return fallback
|
||||
while "--" in rendered:
|
||||
rendered = rendered.replace("--", "-")
|
||||
while "__" in rendered:
|
||||
rendered = rendered.replace("__", "_")
|
||||
return rendered.strip("-_") or fallback
|
||||
@@ -0,0 +1,215 @@
|
||||
"""`hermes memory setup` wizard for the Hindsight provider (``post_setup``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from agent.secret_scope import get_secret
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
from . import templates as _hs_templates
|
||||
from .embedded import (
|
||||
_embedded_profile_env_path,
|
||||
_load_simple_env,
|
||||
_materialize_embedded_profile_env,
|
||||
)
|
||||
from .settings import (
|
||||
_DEFAULT_API_URL,
|
||||
_DEFAULT_IDLE_TIMEOUT,
|
||||
_DEFAULT_LOCAL_URL,
|
||||
_DEFAULT_TIMEOUT,
|
||||
_MIN_CLIENT_VERSION,
|
||||
_PROVIDER_DEFAULT_MODELS,
|
||||
)
|
||||
|
||||
_MODE_VALUES = ["cloud", "local_embedded", "local_external"]
|
||||
_MODE_ITEMS = [
|
||||
("Cloud", "Hindsight Cloud API (lightweight, just needs an API key)"),
|
||||
("Local Embedded", "Run Hindsight locally (downloads ~200MB, needs LLM key)"),
|
||||
("Local External", "Connect to an existing Hindsight instance"),
|
||||
]
|
||||
|
||||
|
||||
def _secret_prompt(label: str) -> str:
|
||||
"""Masked prompt on a TTY; plain readline when stdin is piped."""
|
||||
sys.stdout.write(label)
|
||||
sys.stdout.flush()
|
||||
return masked_secret_prompt("") if sys.stdin.isatty() else sys.stdin.readline().strip()
|
||||
|
||||
|
||||
def _select(title: str, items: list, values: list, current) -> str | None:
|
||||
"""Curses pick from *values*, defaulting to *current*; None when cancelled."""
|
||||
from hermes_cli.memory_setup import _CANCELLED, _curses_select, _print_cancelled_setup
|
||||
|
||||
default = values.index(current) if current in values else 0
|
||||
idx = _curses_select(title, items, default=default, cancel_returns=_CANCELLED)
|
||||
if idx == _CANCELLED:
|
||||
_print_cancelled_setup()
|
||||
return None
|
||||
return values[idx]
|
||||
|
||||
|
||||
def _write_env(env_path: Path, env_writes: dict) -> None:
|
||||
"""Update keys in place (BOM-tolerant read so a Notepad BOM can't glue
|
||||
U+FEFF onto the first key and cause a duplicate line), append the rest."""
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = env_path.read_text(encoding="utf-8-sig").splitlines() if env_path.exists() else []
|
||||
updated = set()
|
||||
new_lines = []
|
||||
for line in existing:
|
||||
key = line.split("=", 1)[0].strip() if "=" in line and not line.startswith("#") else None
|
||||
if key in env_writes:
|
||||
new_lines.append(f"{key}={env_writes[key]}")
|
||||
updated.add(key)
|
||||
else:
|
||||
new_lines.append(line)
|
||||
new_lines.extend(f"{k}={v}" for k, v in env_writes.items() if k not in updated)
|
||||
env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _offer_starter_template(mode: str, provider_config: dict, env_writes: dict) -> None:
|
||||
"""Seed the bank with a Hermes starter template (best-effort)."""
|
||||
import os
|
||||
|
||||
from hermes_cli.memory_setup import _CANCELLED, _curses_select
|
||||
|
||||
default_url = _DEFAULT_LOCAL_URL if mode == "local_external" else _DEFAULT_API_URL
|
||||
_hs_templates.run_template_step(
|
||||
api_url=provider_config.get("api_url") or default_url,
|
||||
bank_id=provider_config.get("bank_id", "hermes"),
|
||||
api_key=env_writes.get("HINDSIGHT_API_KEY") or os.environ.get("HINDSIGHT_API_KEY", "") or None,
|
||||
select=_curses_select,
|
||||
cancelled=_CANCELLED,
|
||||
)
|
||||
|
||||
|
||||
def run_setup(provider, hermes_home: str, config: dict) -> None:
|
||||
"""Interactive wizard — installs only the deps the selected mode needs."""
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
from . import _load_config
|
||||
|
||||
print("\n Configuring Hindsight memory:\n")
|
||||
|
||||
existing_config = provider._config if isinstance(provider._config, dict) else _load_config()
|
||||
if not isinstance(existing_config, dict):
|
||||
existing_config = {}
|
||||
|
||||
mode = _select(" Select mode", _MODE_ITEMS, _MODE_VALUES, existing_config.get("mode"))
|
||||
if mode is None:
|
||||
return
|
||||
provider_config: dict = dict(existing_config, mode=mode)
|
||||
env_writes: dict = {}
|
||||
|
||||
llm_provider = ""
|
||||
if mode == "local_embedded":
|
||||
providers = list(_PROVIDER_DEFAULT_MODELS)
|
||||
llm_items = [(p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}") for p in providers]
|
||||
llm_provider = _select(" Select LLM provider", llm_items, providers, provider_config.get("llm_provider"))
|
||||
if llm_provider is None:
|
||||
return
|
||||
provider_config["llm_provider"] = llm_provider
|
||||
|
||||
print("\n Checking dependencies...")
|
||||
# Environment-aware install: sealed hosted venvs redirect to the durable
|
||||
# data-volume target instead of writing to /opt/hermes.
|
||||
from tools.lazy_deps import install_specs
|
||||
|
||||
deps = ["hindsight-all"] if mode == "local_embedded" else [f"hindsight-client>={_MIN_CLIENT_VERSION}"]
|
||||
outcome = install_specs(deps, timeout=120)
|
||||
if outcome.ok:
|
||||
print(" ✓ Dependencies up to date")
|
||||
elif outcome.blocked:
|
||||
print(f" ⚠ Cannot install dependencies: {outcome.reason}")
|
||||
else:
|
||||
print(f" ⚠ Install failed:\n{(outcome.stderr or '').strip()}")
|
||||
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(deps)}")
|
||||
|
||||
if mode == "cloud":
|
||||
print("\n Get your API key at https://ui.hindsight.vectorize.io\n")
|
||||
existing_key = get_secret("HINDSIGHT_API_KEY", "") or ""
|
||||
if existing_key:
|
||||
masked = f"...{existing_key[-4:]}" if len(existing_key) > 4 else "set"
|
||||
api_key = _secret_prompt(f" API key (current: {masked}, blank to keep): ")
|
||||
else:
|
||||
api_key = _secret_prompt(" API key: ")
|
||||
if api_key:
|
||||
env_writes["HINDSIGHT_API_KEY"] = api_key
|
||||
val = input(f" API URL [{_DEFAULT_API_URL}]: ").strip()
|
||||
if val:
|
||||
provider_config["api_url"] = val
|
||||
|
||||
elif mode == "local_external":
|
||||
val = input(f" Hindsight API URL [{_DEFAULT_LOCAL_URL}]: ").strip()
|
||||
provider_config["api_url"] = val or _DEFAULT_LOCAL_URL
|
||||
api_key = _secret_prompt(" API key (optional, blank to skip): ")
|
||||
if api_key:
|
||||
env_writes["HINDSIGHT_API_KEY"] = api_key
|
||||
|
||||
else: # local_embedded
|
||||
if llm_provider == "openai_compatible":
|
||||
existing_base_url = provider_config.get("llm_base_url", "")
|
||||
prompt = " LLM endpoint URL (e.g. http://192.168.1.10:8080/v1)"
|
||||
if existing_base_url:
|
||||
prompt += f" [{existing_base_url}]"
|
||||
val = input(prompt + ": ").strip()
|
||||
if val:
|
||||
provider_config["llm_base_url"] = val
|
||||
elif llm_provider == "openrouter":
|
||||
provider_config["llm_base_url"] = "https://openrouter.ai/api/v1"
|
||||
|
||||
current_model = provider_config.get("llm_model") or _PROVIDER_DEFAULT_MODELS.get(llm_provider, "gpt-4o-mini")
|
||||
val = input(f" LLM model [{current_model}]: ").strip()
|
||||
provider_config["llm_model"] = val or current_model
|
||||
|
||||
llm_key = _secret_prompt(" LLM API key: ")
|
||||
env_writes["HINDSIGHT_LLM_API_KEY"] = (
|
||||
llm_key or _load_simple_env(Path(hermes_home) / ".env").get("HINDSIGHT_LLM_API_KEY", "")
|
||||
)
|
||||
|
||||
provider_config.setdefault("bank_id", "hermes")
|
||||
provider_config.setdefault("recall_budget", "mid")
|
||||
# Preserve explicit 0 timeouts instead of treating them as blank.
|
||||
timeout_val = provider_config.get("timeout")
|
||||
if timeout_val is None:
|
||||
timeout_val = _DEFAULT_TIMEOUT
|
||||
provider_config["timeout"] = timeout_val
|
||||
env_writes["HINDSIGHT_TIMEOUT"] = str(timeout_val)
|
||||
if mode == "local_embedded":
|
||||
idle_timeout_val = provider_config.get("idle_timeout")
|
||||
if idle_timeout_val is None:
|
||||
idle_timeout_val = _DEFAULT_IDLE_TIMEOUT
|
||||
provider_config["idle_timeout"] = idle_timeout_val
|
||||
env_writes["HINDSIGHT_IDLE_TIMEOUT"] = str(idle_timeout_val)
|
||||
config["memory"]["provider"] = "hindsight"
|
||||
save_config(config)
|
||||
provider.save_config(provider_config, hermes_home)
|
||||
if env_writes:
|
||||
_write_env(Path(hermes_home) / ".env", env_writes)
|
||||
|
||||
# Starter template only for cloud / local_external — the API is reachable
|
||||
# now; local_embedded's daemon isn't up during setup.
|
||||
if _hs_templates.supported_for_mode(mode):
|
||||
_offer_starter_template(mode, provider_config, env_writes)
|
||||
|
||||
if mode == "local_embedded":
|
||||
materialized_config = dict(provider_config)
|
||||
try:
|
||||
materialized_config = json.loads(
|
||||
(Path(hermes_home) / "hindsight" / "config.json").read_text(encoding="utf-8")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
llm_api_key = (
|
||||
env_writes.get("HINDSIGHT_LLM_API_KEY", "")
|
||||
or _load_simple_env(Path(hermes_home) / ".env").get("HINDSIGHT_LLM_API_KEY", "")
|
||||
or _load_simple_env(_embedded_profile_env_path(materialized_config)).get("HINDSIGHT_API_LLM_API_KEY", "")
|
||||
)
|
||||
_materialize_embedded_profile_env(materialized_config, llm_api_key=llm_api_key or None)
|
||||
|
||||
print(f"\n ✓ Hindsight memory configured ({mode} mode)")
|
||||
if env_writes:
|
||||
print(" API keys saved to .env")
|
||||
print("\n Start a new session to activate.\n")
|
||||
@@ -65,12 +65,12 @@ def test_rewrite_tightens_existing_world_readable_profile_env():
|
||||
def test_secret_file_removed_when_permission_validation_fails(monkeypatch):
|
||||
"""If the post-write permission check cannot verify 0600, the plaintext
|
||||
key file must not be left behind."""
|
||||
import plugins.memory.hindsight as hs
|
||||
import plugins.memory.hindsight.embedded as hs_embedded
|
||||
|
||||
def _fail_validation(profile_env):
|
||||
raise PermissionError(f"not owner-only: {profile_env}")
|
||||
|
||||
monkeypatch.setattr(hs, "_validate_profile_env_permissions", _fail_validation)
|
||||
monkeypatch.setattr(hs_embedded, "_validate_profile_env_permissions", _fail_validation)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
_materialize_embedded_profile_env(_CONFIG, llm_api_key="sk-doomed")
|
||||
|
||||
@@ -1413,7 +1413,7 @@ class TestAvailability:
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.hindsight.importlib.import_module",
|
||||
"importlib.import_module",
|
||||
_raise,
|
||||
)
|
||||
p = HindsightMemoryProvider()
|
||||
@@ -1432,7 +1432,7 @@ class TestAvailability:
|
||||
raise RuntimeError("x86_64-v2 unsupported")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.hindsight.importlib.import_module",
|
||||
"importlib.import_module",
|
||||
_raise,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user