diff --git a/AGENTS.md b/AGENTS.md index c379092..42cd84b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,15 +161,19 @@ get wrong. ops-warden shares a **phase-memory** store across worker ticks, coding agent sessions, and operator CLI use. -**At session start** (Claude Code, Codex, Grok, or future agents): +**Default:** phase-memory loads automatically on every `warden` command when +`phase-memory` is available. No separate activation step is required. + +**Agent sessions** should set runtime identity once per session: ```bash export WARDEN_AGENT_ID=grok # or claude, codex -warden memory activate --json ``` -**During work:** use normal `warden route` / `warden access` / `warden sign`; -episodes are recorded automatically unless `WARDEN_MEMORY=0`. +Then use normal `warden route` / `warden access` / `warden sign` / `warden worker`. +Episodes are recorded automatically unless `WARDEN_MEMORY=0`. + +`warden memory activate` is optional introspection/refresh, not a prerequisite. **Store:** `~/.local/share/warden/memory/` (override: `WARDEN_MEMORY_STORE`). diff --git a/src/warden/cli.py b/src/warden/cli.py index db68823..eb1c120 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -54,6 +54,19 @@ console = Console() err = Console(stderr=True) +@app.callback() +def _bootstrap_memory(ctx: typer.Context) -> None: + """Implicitly load phase-memory for every warden command (opt-out: WARDEN_MEMORY=0).""" + if ctx.invoked_subcommand is None: + return + try: + from warden import memory as warden_memory + + warden_memory.ensure_memory_context(implicit=True) + except Exception: # noqa: BLE001 — memory must never block warden commands + return + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -783,6 +796,12 @@ def route_find( limit: Annotated[int, typer.Option("--limit", help="Max matches")] = 5, ) -> None: """Rank routing scenarios by keyword overlap with the query.""" + try: + from warden import memory as warden_memory + + warden_memory.ensure_memory_context(need=query, implicit=True) + except Exception: # noqa: BLE001 + pass catalog = _load_catalog() matches = catalog.find(query, include_draft=all_entries, limit=limit) @@ -1026,6 +1045,12 @@ def access( """ from warden.access import expand_handoff, policy_gate_status + try: + from warden import memory as warden_memory + + warden_memory.ensure_memory_context(need=need, implicit=True) + except Exception: # noqa: BLE001 + pass catalog = _load_catalog() matches = catalog.find(need, include_draft=all_entries, limit=1) if not matches: @@ -1408,14 +1433,14 @@ def memory_activate( ] = None, output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False, ) -> None: - """Activate bounded coordination memory for worker, operator, or agent sessions.""" + """Inspect or refresh coordination memory (optional — memory loads by default).""" from warden import memory as warden_memory if not warden_memory.memory_available(): err.print(f"[red]{warden_memory._PHASE_MEMORY_ERROR}[/red]") raise typer.Exit(2) try: - payload = warden_memory.activate(need=need, agent=agent) + payload = warden_memory.activate(need=need, agent=agent, implicit=False) except RuntimeError as e: err.print(f"[red]{e}[/red]") raise typer.Exit(2) diff --git a/src/warden/memory.py b/src/warden/memory.py index 4f71b34..d8e5cfc 100644 --- a/src/warden/memory.py +++ b/src/warden/memory.py @@ -10,6 +10,17 @@ _PHASE_MEMORY_ERROR = ( "Install with: pip install phase-memory (or set PYTHONPATH to phase-memory/src)." ) +# In-process cache: implicit activation is default; no separate `warden memory activate` +# is required for normal route/access/worker/sign use within one CLI invocation tree. +_CONTEXT_CACHE: dict[str, Any] | None = None +_CONTEXT_CACHE_KEY: tuple[str, str] = ("", "") + + +def _invalidate_context_cache() -> None: + global _CONTEXT_CACHE, _CONTEXT_CACHE_KEY + _CONTEXT_CACHE = None + _CONTEXT_CACHE_KEY = ("", "") + def _phase_memory(): try: @@ -46,24 +57,76 @@ def status(environ: Mapping[str, str] | None = None) -> dict[str, Any]: return pm.OpsWardenMemoryStore.open(environ=environ).status() +def memory_context_summary(activation: dict[str, Any] | None) -> dict[str, Any]: + if not activation: + return {"enabled": False} + return { + "enabled": True, + "implicit": bool(activation.get("implicit")), + "session_kind": activation.get("session_kind", ""), + "episode_count": activation.get("episode_count", 0), + "stabilized_route_id": (activation.get("stabilized_route") or {}).get("route_id", ""), + "llm_calls_avoided": bool(activation.get("llm_calls_avoided")), + "selected_episode_count": len(activation.get("selected_episodes") or ()), + } + + +def ensure_memory_context( + need: str = "", + *, + agent: Optional[str] = None, + session_id: str = "", + environ: Mapping[str, str] | None = None, + implicit: bool = True, +) -> dict[str, Any] | None: + """Load coordination memory for the current session (default, no extra command).""" + global _CONTEXT_CACHE, _CONTEXT_CACHE_KEY + + if not enabled(environ): + return None + if not memory_available(): + return None + pm = _phase_memory() + env = dict(environ or os.environ) + if agent: + env["WARDEN_AGENT_ID"] = agent + kind = pm.resolve_session_kind(env) + fingerprint = pm.need_fingerprint(need) if need else "" + cache_key = (kind, fingerprint) + if _CONTEXT_CACHE is not None and _CONTEXT_CACHE_KEY == cache_key: + return _CONTEXT_CACHE + try: + activation = activate(need=need, agent=agent, session_id=session_id, environ=env) + except RuntimeError: + return None + activation = {**activation, "implicit": implicit} + _CONTEXT_CACHE = activation + _CONTEXT_CACHE_KEY = cache_key + return activation + + def activate( *, need: str = "", agent: Optional[str] = None, session_id: str = "", environ: Mapping[str, str] | None = None, + implicit: bool = False, ) -> dict[str, Any]: pm = _phase_memory() env = dict(environ or os.environ) if agent: env["WARDEN_AGENT_ID"] = agent kind = pm.resolve_session_kind(env) - return pm.activate_ops_warden_memory( + activation = pm.activate_ops_warden_memory( pm.OpsWardenMemoryStore.open(environ=env), session_kind=kind, need=need, session_id=session_id, ) + if implicit: + activation = {**activation, "implicit": True} + return activation def record_command_episode( @@ -91,13 +154,16 @@ def record_command_episode( diagnostic_codes=diagnostic_codes, metadata=metadata, ) - return pm.record_session_event(pm.OpsWardenMemoryStore.open(environ=env), event) + result = pm.record_session_event(pm.OpsWardenMemoryStore.open(environ=env), event) + if result.get("valid"): + _invalidate_context_cache() + return result def worker_activation_context(need: str = "", environ: Mapping[str, str] | None = None) -> dict[str, Any]: env = dict(environ or os.environ) env["WARDEN_SESSION_KIND"] = "warden.worker" - return activate(need=need, environ=env) + return ensure_memory_context(need=need, environ=env, implicit=True) or activate(need=need, environ=env, implicit=True) def stabilized_route_for_need(need: str, environ: Mapping[str, str] | None = None) -> Optional[dict[str, Any]]: diff --git a/src/warden/worker.py b/src/warden/worker.py index d605e79..e1caf01 100644 --- a/src/warden/worker.py +++ b/src/warden/worker.py @@ -602,7 +602,9 @@ def _memory_activation_for_message(message: dict) -> tuple[Optional[dict], str]: return None, "" query = str(message.get("subject", "") or message.get("body", "")) try: - activation = warden_memory.worker_activation_context(query) + activation = warden_memory.ensure_memory_context(need=query, implicit=True) + if activation is None: + activation = warden_memory.worker_activation_context(query) except RuntimeError: return None, "" from warden.memory import format_activation_summary diff --git a/tests/test_memory.py b/tests/test_memory.py index cedcb08..cb83247 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -126,4 +126,18 @@ def test_memory_disabled_skips_recording(monkeypatch) -> None: def test_default_store_path_uses_xdg(monkeypatch) -> None: monkeypatch.delenv("WARDEN_MEMORY_STORE", raising=False) - assert str(store_path()).endswith("warden/memory") \ No newline at end of file + assert str(store_path()).endswith("warden/memory") + + +def test_route_find_implicitly_activates_memory_without_explicit_command(tmp_path, monkeypatch) -> None: + from warden.memory import ensure_memory_context + + monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory")) + monkeypatch.delenv("WARDEN_AGENT_ID", raising=False) + result = runner.invoke(app, ["route", "find", "ssh tunnel", "--json"]) + + assert result.exit_code == 0 + activation = ensure_memory_context(need="ssh tunnel", implicit=True) + assert activation is not None + assert activation.get("implicit") is True + assert status()["episode_count"] >= 1 \ No newline at end of file diff --git a/wiki/OpsWardenMemory.md b/wiki/OpsWardenMemory.md index 89adf62..2e86a16 100644 --- a/wiki/OpsWardenMemory.md +++ b/wiki/OpsWardenMemory.md @@ -19,13 +19,18 @@ ticks, coding agent sessions, and operator CLI use. | Coding agent | `export WARDEN_AGENT_ID=claude` (or `codex`, `grok`, future ids) | | Operator CLI | default `warden.operator` when `WARDEN_AGENT_ID` is unset | +## Default Behavior + +phase-memory is **on by default** (`WARDEN_MEMORY=1`). Every `warden` command +implicitly loads the canonical store before route/access/worker/sign work. You do +not need a separate activation command for normal use. + ## Agent Session Orientation -At the start of a Claude Code, Codex, or Grok session that will call warden: +For Claude Code, Codex, Grok, or future agents, set runtime identity once: ```bash export WARDEN_AGENT_ID=grok # or claude, codex -warden memory activate --json ``` Then use normal `warden route` / `warden access` commands. Episodes are recorded