From 4146daed765b52b5685ebf7315e2bb1bfc74365b Mon Sep 17 00:00:00 2001 From: Sami Rusani Date: Sat, 20 Jun 2026 18:11:49 +0200 Subject: [PATCH] Fix headless bootstrap and Hermes capture config --- docs/alpha/headless-ubuntu-install.md | 16 +++ docs/alpha/hermes-dogfood-ubuntu.md | 19 +++ docs/integrations/hermes-memory-provider.md | 4 +- .../plugins/memory/alice/README.md | 7 +- .../plugins/memory/alice/__init__.py | 69 +++++---- scripts/install-ubuntu.sh | 44 ++++++ .../install_hermes_alice_memory_provider.py | 5 +- tests/unit/test_hermes_memory_provider.py | 136 ++++++++++++++++++ tests/unit/test_vnext_release_polish.py | 6 + 9 files changed, 276 insertions(+), 30 deletions(-) diff --git a/docs/alpha/headless-ubuntu-install.md b/docs/alpha/headless-ubuntu-install.md index 9bbbf4c4..bfc18812 100644 --- a/docs/alpha/headless-ubuntu-install.md +++ b/docs/alpha/headless-ubuntu-install.md @@ -75,6 +75,7 @@ Default paths: The installer renders [packaging/ubuntu/alicebot.env.example](../../packaging/ubuntu/alicebot.env.example) into `~/.config/alicebot/.env` if the file does not already exist. Existing config is preserved. For local Postgres installs, it detects the server major version, installs the matching `postgresql--pgvector` package, and creates the `vector` extension before migrations. +After migrations, it seeds the configured local Alice user row when missing so CLI, scheduler, MCP, and headless doctor checks have a valid continuity owner even when `/vnext` has not been opened yet. Important config keys: @@ -86,6 +87,7 @@ ALICE_API_HOST=127.0.0.1 ALICE_API_PORT=8000 ALICE_WEB_HOST=127.0.0.1 ALICE_WEB_PORT=3000 +ALICEBOT_AUTH_USER_ID=00000000-0000-0000-0000-000000000001 ALICE_SECRET_PROVIDER=encrypted_local MODEL_PROVIDER=deterministic_local CORS_ALLOWED_ORIGINS=http://127.0.0.1:3000,http://localhost:3000 @@ -119,6 +121,20 @@ scripts/validate_env.sh ~/.config/alicebot/.env .env.lite apps/web/.env.local # Create the alicebot database/roles to match ~/.config/alicebot/.env before this step. sudo -u postgres psql -d alicebot -c 'CREATE EXTENSION IF NOT EXISTS vector;' ./.venv/bin/python -m alembic -c apps/api/alembic.ini upgrade head +set -a +. ~/.config/alicebot/.env +set +a +psql "$DATABASE_ADMIN_URL" <<'SQL' +INSERT INTO users (id, email, display_name) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'local-alpha-00000000-0000-0000-0000-000000000001@alicebot.local', + 'Local Alpha User' +) +ON CONFLICT (id) DO UPDATE +SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name; +SQL ./.venv/bin/alicebot vnext doctor --fix-safe --ci ./.venv/bin/alicebot vnext alpha check --headless --skip-smokes ``` diff --git a/docs/alpha/hermes-dogfood-ubuntu.md b/docs/alpha/hermes-dogfood-ubuntu.md index d3110c9a..bb5f33d6 100644 --- a/docs/alpha/hermes-dogfood-ubuntu.md +++ b/docs/alpha/hermes-dogfood-ubuntu.md @@ -23,6 +23,25 @@ project_scope: Use `trusted_local_agent` only for a local operator-owned Hermes runtime on the same host. For narrower project work, use `project_scoped_agent`. +## Memory Provider Capture Config + +For the Alice Hermes memory provider, `bridge_mode: auto` or `bridge_mode: assist` +now enables post-turn `sync_turn` capture when `sync_turn_capture_enabled` is +omitted. Keep the flag explicit in production dogfood configs so the posture is +obvious: + +```json +{ + "base_url": "http://127.0.0.1:8000", + "user_id": "00000000-0000-0000-0000-000000000001", + "bridge_mode": "auto", + "sync_turn_capture_enabled": true +} +``` + +Set `sync_turn_capture_enabled` to `false` when Hermes should use Alice recall +and prefetch without committing post-turn capture candidates. + ## MCP Config Example ```yaml diff --git a/docs/integrations/hermes-memory-provider.md b/docs/integrations/hermes-memory-provider.md index f99d81d0..80ecbdd5 100644 --- a/docs/integrations/hermes-memory-provider.md +++ b/docs/integrations/hermes-memory-provider.md @@ -160,11 +160,13 @@ Practical default: - `prefetch_max_recent_changes` (int) - `prefetch_max_open_loops` (int) - `prefetch_include_non_promotable_facts` (bool) -- `sync_turn_capture_enabled` (bool, default `false`) +- `sync_turn_capture_enabled` (bool, default `false`; when omitted, an explicitly configured `bridge_mode: assist` or `bridge_mode: auto` enables `sync_turn` capture) - `memory_write_capture_enabled` (bool, default `false`) - `bridge_mode` (string enum: `manual`, `assist`, `auto`; default `assist`) - `session_end_flush_timeout_seconds` (float, default `5.0`) +`sync_turn_capture_enabled: false` always wins. Use that when you want bridge recall/prefetch behavior without post-turn capture, even if `bridge_mode` is `assist` or `auto`. + Legacy compatibility keys still accepted for shipped configs: - `prefetch_limit` diff --git a/docs/integrations/hermes-memory-provider/plugins/memory/alice/README.md b/docs/integrations/hermes-memory-provider/plugins/memory/alice/README.md index 63db6b0e..b1b6e75b 100644 --- a/docs/integrations/hermes-memory-provider/plugins/memory/alice/README.md +++ b/docs/integrations/hermes-memory-provider/plugins/memory/alice/README.md @@ -32,9 +32,14 @@ The provider reads and writes: - `max_recent_changes` (int, default `5`) - `max_open_loops` (int, default `5`) - `include_non_promotable_facts` (bool, default `false`) -- `auto_capture` (bool, default `false`) +- `sync_turn_capture_enabled` (bool, default `false`; when omitted, an explicit `bridge_mode` of `assist` or `auto` enables turn capture) +- `bridge_mode` (string enum: `manual`, `assist`, `auto`; default `assist`) - `mirror_memory_writes` (bool, default `false`) +Legacy `auto_capture` and `capture_mode` keys are still accepted. Explicit +`sync_turn_capture_enabled: false` disables turn capture even when `bridge_mode` +is `assist` or `auto`. + ## Transport and Identity Safety - non-loopback `base_url` values must use `https://` diff --git a/docs/integrations/hermes-memory-provider/plugins/memory/alice/__init__.py b/docs/integrations/hermes-memory-provider/plugins/memory/alice/__init__.py index 32b9669b..02f26686 100644 --- a/docs/integrations/hermes-memory-provider/plugins/memory/alice/__init__.py +++ b/docs/integrations/hermes-memory-provider/plugins/memory/alice/__init__.py @@ -184,6 +184,12 @@ def _parse_bridge_mode(value: Any, *, default: str) -> str: return default +def _bridge_mode_implies_sync_capture(_bridge_mode: str, raw_bridge_mode: Any) -> bool: + if not isinstance(raw_bridge_mode, str): + return False + return raw_bridge_mode.strip().lower() in {"assist", "auto"} + + def _normalize_base_url(value: str) -> str: normalized = value.strip().rstrip("/") if normalized.endswith("/v0"): @@ -251,7 +257,15 @@ def _resolve_config_value(values: Dict[str, Any], key: str) -> tuple[Any, str]: def _default_config() -> Dict[str, Any]: - return { + bridge_mode_value = _first_non_empty( + os.environ.get("ALICE_MEMORY_BRIDGE_MODE"), + os.environ.get("ALICE_MEMORY_CAPTURE_MODE"), + ) + sync_turn_capture_value = _first_non_empty( + os.environ.get("ALICE_MEMORY_SYNC_TURN_CAPTURE_ENABLED"), + os.environ.get("ALICE_MEMORY_AUTO_CAPTURE"), + ) + config = { "base_url": os.environ.get("ALICE_API_BASE_URL", _DEFAULT_BASE_URL), "user_id": os.environ.get("ALICE_MEMORY_USER_ID", os.environ.get("ALICEBOT_AUTH_USER_ID", "")), "timeout_seconds": _parse_float( @@ -294,13 +308,6 @@ def _default_config() -> Dict[str, Any]: ), default=False, ), - "sync_turn_capture_enabled": _parse_bool( - _first_non_empty( - os.environ.get("ALICE_MEMORY_SYNC_TURN_CAPTURE_ENABLED"), - os.environ.get("ALICE_MEMORY_AUTO_CAPTURE"), - ), - default=False, - ), "memory_write_capture_enabled": _parse_bool( _first_non_empty( os.environ.get("ALICE_MEMORY_MEMORY_WRITE_CAPTURE_ENABLED"), @@ -308,13 +315,6 @@ def _default_config() -> Dict[str, Any]: ), default=False, ), - "bridge_mode": _parse_bridge_mode( - _first_non_empty( - os.environ.get("ALICE_MEMORY_BRIDGE_MODE"), - os.environ.get("ALICE_MEMORY_CAPTURE_MODE"), - ), - default=_DEFAULT_BRIDGE_MODE, - ), "session_end_flush_timeout_seconds": _parse_float( os.environ.get("ALICE_MEMORY_SESSION_END_FLUSH_TIMEOUT_SECONDS"), default=_DEFAULT_SESSION_END_FLUSH_TIMEOUT_SECONDS, @@ -322,6 +322,14 @@ def _default_config() -> Dict[str, Any]: max_value=30.0, ), } + if sync_turn_capture_value is not None: + config["sync_turn_capture_enabled"] = _parse_bool( + sync_turn_capture_value, + default=False, + ) + if bridge_mode_value is not None: + config["bridge_mode"] = bridge_mode_value + return config def _load_config_with_status(hermes_home: str) -> Dict[str, Any]: @@ -490,12 +498,21 @@ def get_config_schema(self) -> List[Dict[str, Any]]: ] def save_config(self, values: Dict[str, Any], hermes_home: str) -> None: - cfg = _load_config(hermes_home) + cfg = _default_config() + path = _config_path(hermes_home) + if path.exists(): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + for key, value in raw.items(): + if value is not None and value != "": + cfg[key] = value + except Exception: + logger.debug("Failed to parse %s before saving config", path, exc_info=True) cfg.update(values) cfg, errors, _ = _load_config_dict_from_values(cfg) if errors: raise ValueError("; ".join(errors)) - path = _config_path(hermes_home) path.write_text(json.dumps(cfg, indent=2, sort_keys=True) + "\n", encoding="utf-8") def system_prompt_block(self) -> str: @@ -1021,12 +1038,20 @@ def _load_config_dict_from_values(values: Dict[str, Any]) -> tuple[Dict[str, Any default=False, ) + bridge_mode_value, bridge_mode_source = _resolve_config_value(values, "bridge_mode") + if bridge_mode_source != "bridge_mode": + legacy_config_keys.append(bridge_mode_source) + config["bridge_mode"] = _parse_bridge_mode( + bridge_mode_value, + default=_DEFAULT_BRIDGE_MODE, + ) + sync_turn_capture_value, sync_turn_capture_source = _resolve_config_value(values, "sync_turn_capture_enabled") if sync_turn_capture_source != "sync_turn_capture_enabled": legacy_config_keys.append(sync_turn_capture_source) config["sync_turn_capture_enabled"] = _parse_bool( sync_turn_capture_value, - default=False, + default=_bridge_mode_implies_sync_capture(config["bridge_mode"], bridge_mode_value), ) memory_write_capture_value, memory_write_capture_source = _resolve_config_value( @@ -1040,14 +1065,6 @@ def _load_config_dict_from_values(values: Dict[str, Any]) -> tuple[Dict[str, Any default=False, ) - bridge_mode_value, bridge_mode_source = _resolve_config_value(values, "bridge_mode") - if bridge_mode_source != "bridge_mode": - legacy_config_keys.append(bridge_mode_source) - config["bridge_mode"] = _parse_bridge_mode( - bridge_mode_value, - default=_DEFAULT_BRIDGE_MODE, - ) - config["session_end_flush_timeout_seconds"] = _parse_float( values.get("session_end_flush_timeout_seconds"), default=_DEFAULT_SESSION_END_FLUSH_TIMEOUT_SECONDS, diff --git a/scripts/install-ubuntu.sh b/scripts/install-ubuntu.sh index 4db744e5..68411b6f 100755 --- a/scripts/install-ubuntu.sh +++ b/scripts/install-ubuntu.sh @@ -366,6 +366,49 @@ SQL sudo -u postgres psql -d alicebot -c "CREATE EXTENSION IF NOT EXISTS vector;" >/dev/null } +seed_default_user_from_env() { + if [ "${DRY_RUN}" -eq 1 ]; then + log "+ seed configured local Alice user row if missing" + return + fi + "${INSTALL_DIR}/.venv/bin/python" - <<'PY' +import os +from uuid import UUID + +import psycopg + + +user_id_raw = os.environ.get("ALICEBOT_AUTH_USER_ID", "").strip() +if not user_id_raw: + raise SystemExit("ALICEBOT_AUTH_USER_ID is required before seeding the local Alice user") + +try: + user_id = UUID(user_id_raw) +except ValueError as exc: + raise SystemExit("ALICEBOT_AUTH_USER_ID must be a valid UUID") from exc + +conninfo = os.environ.get("DATABASE_ADMIN_URL") or os.environ.get("DATABASE_URL") +if not conninfo: + raise SystemExit("DATABASE_ADMIN_URL or DATABASE_URL is required before seeding the local Alice user") + +email = f"local-alpha-{user_id}@alicebot.local" +display_name = "Local Alpha User" + +with psycopg.connect(conninfo) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO users (id, email, display_name) + VALUES (%s, %s, %s) + ON CONFLICT (id) DO UPDATE + SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name + """, + (user_id, email, display_name), + ) +PY +} + install_project_dependencies() { run python3 -m venv "${INSTALL_DIR}/.venv" run "${INSTALL_DIR}/.venv/bin/python" -m pip install --upgrade pip @@ -382,6 +425,7 @@ run_migrations_and_checks() { set +a fi run_in_install_dir "${INSTALL_DIR}/.venv/bin/python" -m alembic -c apps/api/alembic.ini upgrade head + seed_default_user_from_env run "${INSTALL_DIR}/.venv/bin/alicebot" vnext doctor --fix-safe --ci if [ "${RUN_ALPHA_CHECK}" -eq 1 ]; then run "${INSTALL_DIR}/.venv/bin/alicebot" vnext alpha check --headless --skip-smokes diff --git a/scripts/install_hermes_alice_memory_provider.py b/scripts/install_hermes_alice_memory_provider.py index 1e48dc91..beed5bae 100755 --- a/scripts/install_hermes_alice_memory_provider.py +++ b/scripts/install_hermes_alice_memory_provider.py @@ -105,16 +105,17 @@ def main() -> int: print(" 3) hermes config set memory.provider alice") print(" 4) ./.venv/bin/python scripts/run_hermes_memory_provider_smoke.py") print() - print("Bridge B1 config keys:") + print("Bridge B2 config keys:") print(" - prefetch_recall_limit") print(" - prefetch_max_recent_changes") print(" - prefetch_max_open_loops") print(" - prefetch_include_non_promotable_facts") print(" - sync_turn_capture_enabled") print(" - memory_write_capture_enabled") + print(" - bridge_mode") print(" - session_end_flush_timeout_seconds") print("Legacy compatibility keys still accepted: prefetch_limit, max_recent_changes,") - print("max_open_loops, include_non_promotable_facts, auto_capture, mirror_memory_writes") + print("max_open_loops, include_non_promotable_facts, auto_capture, mirror_memory_writes, capture_mode") return 0 diff --git a/tests/unit/test_hermes_memory_provider.py b/tests/unit/test_hermes_memory_provider.py index d185ec36..5c777368 100644 --- a/tests/unit/test_hermes_memory_provider.py +++ b/tests/unit/test_hermes_memory_provider.py @@ -203,6 +203,142 @@ def test_bridge_status_reports_legacy_config_compatibility( ] +def test_default_bridge_mode_does_not_enable_sync_capture( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_provider_module(monkeypatch) + config_path = tmp_path / "alice_memory_provider.json" + config_path.write_text( + json.dumps( + { + "base_url": "http://127.0.0.1:8000", + "user_id": "00000000-0000-0000-0000-000000000001", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + provider = module.AliceMemoryProvider() + provider.initialize(session_id="bridge-default", hermes_home=str(tmp_path), agent_context="primary") + status = provider.get_status(hermes_home=str(tmp_path)) + + assert status["ready"] is True + assert status["config"]["bridge_mode"] == "assist" + assert status["config"]["sync_turn_capture_enabled"] is False + assert status["lifecycle_hooks"]["sync_turn"] is False + + +def test_bridge_mode_auto_implies_sync_capture_when_flag_is_omitted( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_provider_module(monkeypatch) + config_path = tmp_path / "alice_memory_provider.json" + config_path.write_text( + json.dumps( + { + "base_url": "http://127.0.0.1:8000", + "user_id": "00000000-0000-0000-0000-000000000001", + "bridge_mode": "auto", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + provider = module.AliceMemoryProvider() + provider.initialize(session_id="bridge-auto", hermes_home=str(tmp_path), agent_context="primary") + status = provider.get_status(hermes_home=str(tmp_path)) + + assert status["ready"] is True + assert status["config"]["bridge_mode"] == "auto" + assert status["config"]["sync_turn_capture_enabled"] is True + assert status["lifecycle_hooks"]["sync_turn"] is True + + +def test_bridge_mode_auto_respects_explicit_sync_capture_false( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_provider_module(monkeypatch) + config_path = tmp_path / "alice_memory_provider.json" + config_path.write_text( + json.dumps( + { + "base_url": "http://127.0.0.1:8000", + "user_id": "00000000-0000-0000-0000-000000000001", + "bridge_mode": "auto", + "sync_turn_capture_enabled": False, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + provider = module.AliceMemoryProvider() + provider.initialize(session_id="bridge-auto-disabled", hermes_home=str(tmp_path), agent_context="primary") + status = provider.get_status(hermes_home=str(tmp_path)) + + assert status["ready"] is True + assert status["config"]["bridge_mode"] == "auto" + assert status["config"]["sync_turn_capture_enabled"] is False + assert status["lifecycle_hooks"]["sync_turn"] is False + + +def test_invalid_bridge_mode_does_not_imply_sync_capture( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_provider_module(monkeypatch) + config_path = tmp_path / "alice_memory_provider.json" + config_path.write_text( + json.dumps( + { + "base_url": "http://127.0.0.1:8000", + "user_id": "00000000-0000-0000-0000-000000000001", + "bridge_mode": "automatic", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + provider = module.AliceMemoryProvider() + status = provider.get_status(hermes_home=str(tmp_path)) + + assert status["ready"] is True + assert status["config"]["bridge_mode"] == "assist" + assert status["config"]["sync_turn_capture_enabled"] is False + assert status["lifecycle_hooks"]["sync_turn"] is False + + +def test_save_config_preserves_bridge_mode_sync_capture_omission( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_provider_module(monkeypatch) + provider = module.AliceMemoryProvider() + + provider.save_config( + { + "base_url": "http://127.0.0.1:8000", + "user_id": "00000000-0000-0000-0000-000000000001", + "bridge_mode": "auto", + }, + str(tmp_path), + ) + + saved = json.loads((tmp_path / "alice_memory_provider.json").read_text(encoding="utf-8")) + assert saved["bridge_mode"] == "auto" + assert saved["sync_turn_capture_enabled"] is True + + def test_bridge_status_reports_invalid_config_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: module = _load_provider_module(monkeypatch) config_path = tmp_path / "alice_memory_provider.json" diff --git a/tests/unit/test_vnext_release_polish.py b/tests/unit/test_vnext_release_polish.py index a2e6e1c5..1401a7e7 100644 --- a/tests/unit/test_vnext_release_polish.py +++ b/tests/unit/test_vnext_release_polish.py @@ -239,9 +239,15 @@ def test_installation_issue_regressions_are_guarded() -> None: assert '"${ALICE_RUNTIME_DIR}/vnext-scheduler"' in installer assert "run_in_install_dir" in installer assert "-c apps/api/alembic.ini" in installer + assert "seed_default_user_from_env" in installer + assert "INSERT INTO users (id, email, display_name)" in installer + assert "ON CONFLICT (id) DO UPDATE" in installer assert "write_lite_env_if_missing" in installer assert "write_web_env_if_missing" in installer assert "validate_env_files" in installer + migrations_section = installer.split("run_migrations_and_checks()", 1)[1].split("install_systemd_units()", 1)[0] + assert migrations_section.index("alembic") < migrations_section.index("seed_default_user_from_env") + assert migrations_section.index("seed_default_user_from_env") < migrations_section.index("vnext doctor") for script in (dev_up, api_dev, lite_up, migrate): assert "scripts/validate_env.sh" in script