Prepare dependency generations before selecting them. Keep shipped tool bytes separate from writable additions, and store facts beside their entries. Validate proposed plugin sets before config publication. Restore the previous config if the facts write fails. Consolidate duplicate updater, backup, setup, and voice helpers. Repair launcher selection, dependency consumers, download ownership, update feeds, and native Windows process and file handling. Verification: 206 changed/prior-failing Python files reported 4630 passed, one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary. The final targeted rerun reported 234 passed and two skipped. The store review regression batch reported 83 passed and one skipped. Desktop TypeScript checks, 56 selected Electron tests, 24 release tests, and the removed-import/compatibility guards passed. This is an integration checkpoint, not full audit acceptance. The complete Python suite has not run on this fixed tree. Crash-atomic plugin publication, generation cleanup, receipt correlation, and packaged lifecycle acceptance remain open in docs/pm-audit-status.md.
136 lines
6.3 KiB
Python
136 lines
6.3 KiB
Python
"""Local-embedded Hindsight runtime seam: side-env runtime probe, install hint, and
|
|
the per-profile env file the standalone ``hindsight-embed`` daemon consumes. The
|
|
heavy stack itself lives in the isolated side env owned by ``embedded_runtime`` —
|
|
nothing here imports it in-process."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
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
|
|
|
|
# Stale embedded-daemon connection markers (client recreated, operation retried once).
|
|
_RETRIABLE_CONNECTION_MARKERS = (
|
|
"cannot connect to host",
|
|
# Connection-establishment / DNS failure message patterns. These surface when the exception TYPE is
|
|
# generic (RuntimeError/Exception from a local shim, MCP bridge, subprocess wrapper, or an SDK that
|
|
# re-raises without chaining) so the _TRANSPORT_ERROR_TYPES check never fires, and the error carries no
|
|
# HTTP status. Without message-level matching they fall through to FailoverReason.unknown, which misses
|
|
# the transport eager-fallback path in the retry loop (unknown retries the same dead endpoint for the
|
|
# full budget before fallback). Ported from anomalyco/opencode#40707, which hit the same bug shape:
|
|
# serialized midstream errors matched by type only. Deliberately EXCLUDES mid-stream disconnect strings
|
|
# ("connection reset by peer", "peer closed connection", "unexpected eof", "socket hang up") — those
|
|
# belong to _SERVER_DISCONNECT_PATTERNS, whose classification step runs later and routes large sessions
|
|
# to context-overflow compression. A connection that was never established cannot be a server-side
|
|
# overflow rejection, so these are safe to classify as plain retryable transport.
|
|
"connection refused",
|
|
"connect call failed",
|
|
"clientconnectorerror",
|
|
)
|
|
|
|
|
|
def _check_local_runtime() -> tuple[bool, str | None]:
|
|
"""Whether the isolated side-env runtime imports cleanly (probed in the side
|
|
interpreter — older CPUs: NumPy can raise at import, so Hermes degrades
|
|
instead of retrying a broken backend; ``sentence_transformers`` is probed
|
|
too: ``hindsight`` imports fine with a broken embedding stack, and the
|
|
daemon would then abort on every retain/recall)."""
|
|
from .embedded_runtime import check_local_runtime
|
|
|
|
return check_local_runtime()
|
|
|
|
|
|
def _local_runtime_hint(reason: str | None) -> str:
|
|
"""Install/reinstall guidance when the local_embedded side runtime is missing
|
|
or broken (probe reason appended when present)."""
|
|
from .embedded_runtime import _local_runtime_hint as _hint
|
|
|
|
return _hint(reason)
|
|
|
|
|
|
def _load_simple_env(path) -> dict[str, str]:
|
|
"""Parse a KEY=VALUE env file (comments/blank lines ignored). utf-8-sig: also used
|
|
on the Hermes .env during post_setup, where a Notepad BOM would stick to the first key."""
|
|
if not path.exists():
|
|
return {}
|
|
pairs = (line.split("=", 1) for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines()
|
|
if line and not line.startswith("#") and "=" in line)
|
|
return {key.strip(): value.strip() for key, value in pairs}
|
|
|
|
|
|
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 _embedded_llm_api_key(config: dict[str, Any]) -> str:
|
|
return config.get("llmApiKey") or config.get("llm_api_key") or get_secret("HINDSIGHT_LLM_API_KEY", "")
|
|
|
|
|
|
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 = _embedded_llm_api_key(config)
|
|
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)
|
|
if (idle_timeout := config.get("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); a pre-existing file is
|
|
tightened BEFORE the plaintext LLM API key is written."""
|
|
if profile_env.exists():
|
|
with contextlib.suppress(OSError):
|
|
os.chmod(profile_env, 0o600)
|
|
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: owner-only on POSIX (Windows ACLs aren't mode bits; skipped)."""
|
|
if os.name != "posix":
|
|
return
|
|
import stat
|
|
|
|
if stat.S_IMODE(profile_env.stat().st_mode) != 0o600:
|
|
with contextlib.suppress(OSError):
|
|
os.chmod(profile_env, 0o600)
|
|
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 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:
|
|
with contextlib.suppress(OSError):
|
|
profile_env.unlink()
|
|
raise
|
|
return profile_env
|