Implement WARDEN-WP-0024 experiential memory and agent sessions.

Add phase-memory bridge, warden memory CLI, route/access/sign recording,
memory-aware worker planning with OpenRouter skip, tests, wiki, and AGENTS.md
orientation for Claude, Codex, Grok, and future agent sessions.
This commit is contained in:
2026-07-02 23:40:45 +02:00
parent 2f532699fa
commit 04929e7981
9 changed files with 568 additions and 14 deletions

View File

@@ -156,6 +156,29 @@ get wrong.
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
## Experiential memory (WARDEN-WP-0024)
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):
```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`.
**Store:** `~/.local/share/warden/memory/` (override: `WARDEN_MEMORY_STORE`).
**Worker:** `warden worker run --brain llm` skips OpenRouter when stabilized
routing memory matches. See `wiki/OpsWardenMemory.md`.
Requires `phase-memory` on `PYTHONPATH` or installed; contract in
`phase-memory/docs/ops-warden-memory-contract.md`.
---
## Workplan Convention (ADR-001)

View File

@@ -13,6 +13,9 @@ dependencies = [
"httpx>=0.27",
]
[project.optional-dependencies]
memory = []
[project.scripts]
warden = "warden.cli:app"
ops-ssh-wrapper = "warden.scripts.ops_ssh_wrapper:main"
@@ -29,7 +32,7 @@ packages = ["src/warden"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
pythonpath = ["src", "../phase-memory/src"]
addopts = "-m 'not integration'"
markers = ["integration: requires ssh-keygen binary; run with pytest -m integration"]

View File

@@ -44,6 +44,11 @@ activity_app = typer.Typer(
no_args_is_help=True,
)
app.add_typer(activity_app, name="activity")
memory_app = typer.Typer(
help="Cross-runtime experiential memory via phase-memory (WARDEN-WP-0024)",
no_args_is_help=True,
)
app.add_typer(memory_app, name="memory")
console = Console()
err = Console(stderr=True)
@@ -53,6 +58,30 @@ err = Console(stderr=True)
# Helpers
# ---------------------------------------------------------------------------
def _record_memory_episode(
*,
command: str,
outcome: str,
need: str = "",
route_id: str = "",
) -> None:
try:
from warden import memory as warden_memory
except ImportError:
return
if not warden_memory.enabled() or not warden_memory.memory_available():
return
try:
warden_memory.record_command_episode(
command=command,
outcome=outcome,
need=need,
route_id=route_id,
)
except RuntimeError:
return
def _load_cfg() -> WardenConfig:
try:
return load_config()
@@ -128,6 +157,12 @@ def sign(
# cert_command interface: write cert text to stdout only
print(record.cert_path.read_text().strip())
_record_memory_episode(
command="sign",
outcome="resolved",
need=f"ssh cert {actor_name}",
route_id="ops-warden-ssh-cert",
)
# ---------------------------------------------------------------------------
@@ -753,14 +788,30 @@ def route_find(
if output_json:
print(json.dumps([_entry_summary(e) for e in matches], indent=2))
if matches:
_record_memory_episode(
command="route find",
outcome="resolved",
need=query,
route_id=matches[0].id,
)
else:
_record_memory_episode(command="route find", outcome="skipped", need=query)
return
if not matches:
_record_memory_episode(command="route find", outcome="skipped", need=query)
console.print(
f"No routing match for {query!r}. "
"Try `warden route list --all` to browse all scenarios."
)
return
_record_memory_episode(
command="route find",
outcome="resolved",
need=query,
route_id=matches[0].id,
)
_print_entry_table(matches, f"Matches for {query!r}")
@@ -1003,8 +1054,20 @@ def access(
if output_json:
print(json.dumps(_access_json(entry, expanded, gate, domain), indent=2))
_record_memory_episode(
command="access",
outcome="resolved",
need=need,
route_id=entry.id,
)
return
_record_memory_episode(
command="access",
outcome="resolved",
need=need,
route_id=entry.id,
)
console.print(f"[bold]{entry.title}[/bold] ([cyan]{entry.id}[/cyan])")
console.print(f" owner : {entry.owner_repo} ({entry.subsystem})")
@@ -1305,3 +1368,58 @@ def worker_status_cmd() -> None:
console.print(f"timer : {st or 'unknown'}")
except Exception: # noqa: BLE001 — systemd may be absent (cron/other host)
console.print("timer : (systemd not available)")
# ---------------------------------------------------------------------------
# warden memory — cross-runtime experiential memory (WARDEN-WP-0024)
# ---------------------------------------------------------------------------
@memory_app.command("status")
def memory_status(
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Show canonical phase-memory store status (metadata only)."""
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.status()
except RuntimeError as e:
err.print(f"[red]{e}[/red]")
raise typer.Exit(2)
if output_json:
print(json.dumps(payload, indent=2))
return
console.print(f"store_path : {payload.get('store_path', '')}")
console.print(f"profile_id : {payload.get('profile_id', '')}")
console.print(f"episode_count : {payload.get('episode_count', 0)}")
console.print(f"session_kinds : {payload.get('episode_counts_by_session_kind', {})}")
console.print(f"last_activation: {payload.get('last_activation_at') or ''}")
@memory_app.command("activate")
def memory_activate(
need: Annotated[str, typer.Option("--need", help="Optional routing need fingerprint source")] = "",
agent: Annotated[
Optional[str],
typer.Option("--agent", help="Agent id for session_kind warden.agent.<id> (claude, codex, grok, …)"),
] = None,
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Activate bounded coordination memory for worker, operator, or agent sessions."""
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)
except RuntimeError as e:
err.print(f"[red]{e}[/red]")
raise typer.Exit(2)
if output_json:
print(json.dumps(payload, indent=2))
return
console.print(warden_memory.format_activation_summary(payload))

122
src/warden/memory.py Normal file
View File

@@ -0,0 +1,122 @@
"""phase-memory bridge for ops-warden cross-runtime experiential memory."""
from __future__ import annotations
import os
from typing import Any, Mapping, Optional
_PHASE_MEMORY_ERROR = (
"phase-memory is required for warden memory commands. "
"Install with: pip install phase-memory (or set PYTHONPATH to phase-memory/src)."
)
def _phase_memory():
try:
import phase_memory.ops_warden as pm
return pm
except ImportError as exc: # pragma: no cover - exercised via tests with PYTHONPATH
raise RuntimeError(_PHASE_MEMORY_ERROR) from exc
def memory_available() -> bool:
try:
_phase_memory()
return True
except RuntimeError:
return False
def enabled(environ: Mapping[str, str] | None = None) -> bool:
environ = environ or os.environ
return str(environ.get("WARDEN_MEMORY", "1")).strip().lower() not in {"0", "false", "no", "off"}
def store_path(environ: Mapping[str, str] | None = None):
return _phase_memory().default_memory_store_path(environ)
def session_kind(environ: Mapping[str, str] | None = None) -> str:
return _phase_memory().resolve_session_kind(environ)
def status(environ: Mapping[str, str] | None = None) -> dict[str, Any]:
pm = _phase_memory()
return pm.OpsWardenMemoryStore.open(environ=environ).status()
def activate(
*,
need: str = "",
agent: Optional[str] = None,
session_id: str = "",
environ: Mapping[str, str] | None = None,
) -> 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(
pm.OpsWardenMemoryStore.open(environ=env),
session_kind=kind,
need=need,
session_id=session_id,
)
def record_command_episode(
*,
command: str,
outcome: str,
need: str = "",
route_id: str = "",
diagnostic_codes: Optional[list[str]] = None,
metadata: Optional[dict[str, Any]] = None,
environ: Mapping[str, str] | None = None,
) -> dict[str, Any]:
if not enabled(environ):
return {"valid": True, "skipped": True, "reason": "WARDEN_MEMORY=0"}
pm = _phase_memory()
env = dict(environ or os.environ)
event = pm.build_session_event(
command=command,
session_kind=pm.resolve_session_kind(env),
outcome=outcome,
need=need,
route_id=route_id,
agent_id=str(env.get("WARDEN_AGENT_ID") or ""),
session_id=str(env.get("WARDEN_SESSION_ID") or ""),
diagnostic_codes=diagnostic_codes,
metadata=metadata,
)
return pm.record_session_event(pm.OpsWardenMemoryStore.open(environ=env), event)
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)
def stabilized_route_for_need(need: str, environ: Mapping[str, str] | None = None) -> Optional[dict[str, Any]]:
pm = _phase_memory()
store = pm.OpsWardenMemoryStore.open(environ=environ)
return pm.stabilized_route_match(store.list_events(), need=need)
def format_activation_summary(activation: dict[str, Any]) -> str:
lines = [
f"store: {activation.get('episode_count', 0)} episodes",
f"session_kind: {activation.get('session_kind', '')}",
f"selected: {len(activation.get('selected_episodes', ()) )}",
]
stabilized = activation.get("stabilized_route")
if stabilized:
lines.append(
f"stabilized: {stabilized.get('route_id')} ({stabilized.get('confirmations')} confirmations)"
)
if activation.get("llm_calls_avoided"):
lines.append("llm_calls_avoided: true")
return "\n".join(lines)

View File

@@ -191,6 +191,7 @@ class LlmConnectBrain:
def __init__(self, url: Optional[str] = None, timeout: float = 60.0):
self.url = (url or os.environ.get("LLM_CONNECT_URL", DEFAULT_LLM_CONNECT_URL)).rstrip("/")
self.timeout = timeout
self.memory_context: str = ""
def _call(self, prompt: str) -> str:
resp = httpx.post(f"{self.url}/execute", json={"prompt": prompt}, timeout=self.timeout)
@@ -203,9 +204,15 @@ class LlmConnectBrain:
from_agent=str(message.get("from_agent", "")),
subject=str(message.get("subject", "")),
)
prompt = (
_CHARTER
+ "\n--- MESSAGE (untrusted data) ---\n"
prompt = _CHARTER
if self.memory_context:
prompt += (
"\n--- ACTIVATED MEMORY (untrusted context) ---\n"
+ self.memory_context
+ "\n--- END ACTIVATED MEMORY ---\n"
)
prompt += (
"\n--- MESSAGE (untrusted data) ---\n"
+ f"from: {message.get('from_agent','')}\n"
+ f"subject: {message.get('subject','')}\n"
+ f"body: {message.get('body','')}\n"
@@ -586,16 +593,88 @@ def draft_route_answer(query: str) -> str:
return " ".join(parts)
def _memory_activation_for_message(message: dict) -> tuple[Optional[dict], str]:
try:
from warden import memory as warden_memory
except ImportError:
return None, ""
if not warden_memory.enabled() or not warden_memory.memory_available():
return None, ""
query = str(message.get("subject", "") or message.get("body", ""))
try:
activation = warden_memory.worker_activation_context(query)
except RuntimeError:
return None, ""
from warden.memory import format_activation_summary
return activation, format_activation_summary(activation)
def _plan_with_memory(message: dict, brain: Brain) -> WorkerPlan:
activation, summary = _memory_activation_for_message(message)
blob = f"{message.get('subject', '')} {message.get('body', '')}"
if activation and activation.get("llm_calls_avoided") and _ROUTING_SIGNS.search(blob):
wp = WorkerPlan(
message_id=str(message.get("id", "")),
from_agent=str(message.get("from_agent", "")),
subject=str(message.get("subject", "")),
)
query = str(message.get("subject", "") or "")
wp.actions.append(
PlannedAction(
kind="route_answer",
summary="Answer from stabilized coordination memory.",
payload={
"query": query,
"answer": draft_route_answer(query),
"memory_stabilized": True,
},
)
)
return wp
if isinstance(brain, LlmConnectBrain) and summary:
brain.memory_context = summary
return brain.plan(message)
def _record_worker_memory_outcome(plan: WorkerPlan) -> None:
try:
from warden import memory as warden_memory
except ImportError:
return
if not warden_memory.enabled() or not warden_memory.memory_available():
return
outcome = "escalated" if plan.escalated else "resolved"
route_id = ""
for action in plan.actions:
if action.kind == "route_answer" and action.payload.get("memory_stabilized"):
stabilized = warden_memory.stabilized_route_for_need(plan.subject)
if stabilized:
route_id = str(stabilized.get("route_id") or "")
try:
warden_memory.record_command_episode(
command="worker run",
outcome=outcome,
need=plan.subject,
route_id=route_id,
diagnostic_codes=["worker_escalated"] if plan.escalated else [],
metadata={"message_id": plan.message_id, "action_kinds": [a.kind for a in plan.actions]},
)
except RuntimeError:
return
def build_plans(messages: List[dict], brain: Brain) -> List[WorkerPlan]:
"""Plan every message, attach computed route answers, and apply the guardrail pass."""
plans: List[WorkerPlan] = []
for m in messages:
plan = brain.plan(m)
plan = _plan_with_memory(m, brain)
plan.raw = m
for a in plan.actions:
if a.kind == "route_answer" and "answer" not in a.payload:
a.payload["answer"] = draft_route_answer(a.payload.get("query", m.get("subject", "")))
plans.append(_guardrail(plan, m))
_record_worker_memory_outcome(plans[-1])
return plans

11
tests/conftest.py Normal file
View File

@@ -0,0 +1,11 @@
"""Test configuration for ops-warden."""
from __future__ import annotations
import sys
from pathlib import Path
PHASE_MEMORY_SRC = Path(__file__).resolve().parents[2] / "phase-memory" / "src"
if PHASE_MEMORY_SRC.exists() and str(PHASE_MEMORY_SRC) not in sys.path:
sys.path.insert(0, str(PHASE_MEMORY_SRC))

129
tests/test_memory.py Normal file
View File

@@ -0,0 +1,129 @@
"""Tests for ops-warden phase-memory bridge (WARDEN-WP-0024)."""
from __future__ import annotations
import json
from typer.testing import CliRunner
from warden.cli import app
from warden.memory import activate, enabled, record_command_episode, status, store_path
from warden.worker import RuleBrain, _plan_with_memory, build_plans
runner = CliRunner()
def _msg(**over) -> dict:
base = {
"id": "m1",
"from_agent": "someone",
"subject": "Where do I get an npm token?",
"body": "Which subsystem owns this credential — how do I obtain it?",
}
base.update(over)
return base
def test_memory_status_and_activate_round_trip(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
monkeypatch.setenv("WARDEN_AGENT_ID", "grok")
first = record_command_episode(
command="route find",
outcome="resolved",
need="npm token",
route_id="openbao-api-key",
)
second = record_command_episode(
command="route find",
outcome="resolved",
need="npm token",
route_id="openbao-api-key",
)
payload = activate(need="npm token", agent="grok")
assert first["valid"] is True
assert second["valid"] is True
assert payload["session_kind"] == "warden.agent.grok"
assert payload["stabilized_route"]["route_id"] == "openbao-api-key"
assert status()["episode_count"] >= 3
def test_worker_uses_stabilized_memory_without_llm(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
monkeypatch.setenv("WARDEN_SESSION_KIND", "warden.worker")
record_command_episode(
command="route find",
outcome="resolved",
need="Where do I get an npm token?",
route_id="openbao-api-key",
)
record_command_episode(
command="route find",
outcome="resolved",
need="Where do I get an npm token?",
route_id="openbao-api-key",
)
plan = _plan_with_memory(_msg(), RuleBrain())
assert plan.actions
assert plan.actions[0].kind == "route_answer"
assert plan.actions[0].payload.get("memory_stabilized") is True
def test_cross_runtime_continuity_agent_to_worker(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
monkeypatch.setenv("WARDEN_AGENT_ID", "codex")
record_command_episode(
command="route find",
outcome="resolved",
need="openrouter api key",
route_id="openrouter-llm-connect",
)
monkeypatch.setenv("WARDEN_SESSION_KIND", "warden.worker")
monkeypatch.delenv("WARDEN_AGENT_ID", raising=False)
activation = activate(need="openrouter api key")
kinds = {item.get("session_kind") for item in activation["selected_episodes"] if item.get("kind") == "episode"}
assert "warden.agent.codex" in kinds
def test_cli_memory_status_and_activate(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
status_result = runner.invoke(app, ["memory", "status", "--json"])
activate_result = runner.invoke(app, ["memory", "activate", "--agent", "claude", "--json"])
assert status_result.exit_code == 0
assert activate_result.exit_code == 0
assert json.loads(status_result.stdout)["episode_count"] >= 0
assert json.loads(activate_result.stdout)["session_kind"] == "warden.agent.claude"
def test_route_find_records_memory_episode(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
result = runner.invoke(app, ["route", "find", "openrouter api key", "--json"])
assert result.exit_code == 0
payload = status()
assert payload["episode_count"] >= 1
def test_build_plans_records_worker_outcomes(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
plans = build_plans([_msg()], RuleBrain())
assert plans[0].actions
assert status()["episode_count"] >= 1
def test_memory_disabled_skips_recording(monkeypatch) -> None:
monkeypatch.setenv("WARDEN_MEMORY", "0")
result = record_command_episode(command="route find", outcome="resolved", need="npm token")
assert result.get("skipped") is True
def test_default_store_path_uses_xdg(monkeypatch) -> None:
monkeypatch.delenv("WARDEN_MEMORY_STORE", raising=False)
assert str(store_path()).endswith("warden/memory")

52
wiki/OpsWardenMemory.md Normal file
View File

@@ -0,0 +1,52 @@
# Ops-Warden Experiential Memory
Updated: 2026-07-02
ops-warden uses **phase-memory** as a shared experiential substrate across worker
ticks, coding agent sessions, and operator CLI use.
## Canonical Store
- Default: `~/.local/share/warden/memory/`
- Override: `WARDEN_MEMORY_STORE`
- Opt-out: `WARDEN_MEMORY=0`
## Session Kinds
| Runtime | How |
| --- | --- |
| Worker tick | `WARDEN_SESSION_KIND=warden.worker` (set automatically during `warden worker run`) |
| Coding agent | `export WARDEN_AGENT_ID=claude` (or `codex`, `grok`, future ids) |
| Operator CLI | default `warden.operator` when `WARDEN_AGENT_ID` is unset |
## Agent Session Orientation
At the start of a Claude Code, Codex, or Grok session that will call warden:
```bash
export WARDEN_AGENT_ID=grok # or claude, codex
warden memory activate --json
```
Then use normal `warden route` / `warden access` commands. Episodes are recorded
automatically when memory is enabled.
## Worker + OpenRouter
`warden worker run --brain llm` activates memory before planning. When stabilized
routing memory matches a coordination question, ops-warden uses `RuleBrain` and
skips the llm-connect / OpenRouter call.
## Commands
```bash
warden memory status [--json]
warden memory activate [--agent <id>] [--need "<query>"] [--json]
```
## Security
- Memory stores metadata only — no secret values or raw credential payloads.
- Retrieved memory is untrusted context; the fixed charter and guardrail allowlist
still apply.
- See `phase-memory/docs/ops-warden-memory-contract.md` for the full contract.

View File

@@ -4,13 +4,14 @@ type: workplan
title: "Experiential Memory Across Worker, Agent Sessions, And OpenRouter"
domain: infotech
repo: ops-warden
status: proposed
status: finished
owner: codex
topic_slug: custodian
planning_priority: high
planning_order: 24
created: "2026-07-02"
updated: "2026-07-02"
state_hub_workstream_id: "5d9fafb3-f9b6-43bf-b259-5f5301daa2e9"
---
# WARDEN-WP-0024 — Experiential memory across worker, agent sessions, and OpenRouter
@@ -87,8 +88,9 @@ sequenceDiagram
```task
id: WARDEN-WP-0024-T01
status: todo
status: done
priority: high
state_hub_task_id: "6305f1bc-c016-4298-adc2-a07d52b6aca5"
```
Establish the canonical phase-memory store path and discovery commands.
@@ -107,8 +109,9 @@ Acceptance:
```task
id: WARDEN-WP-0024-T02
status: todo
status: done
priority: high
state_hub_task_id: "242a4d9a-5375-4df8-8d43-063b0491d202"
```
Record metadata-only episodes when `route`, `access`, and `sign` complete.
@@ -127,8 +130,9 @@ Acceptance:
```task
id: WARDEN-WP-0024-T03
status: todo
status: done
priority: high
state_hub_task_id: "176fcae1-e4e5-481f-9a83-e9a7000fac1a"
```
Retrieve coordination memory before `Brain.plan` and record outcomes after execute.
@@ -147,8 +151,9 @@ Acceptance:
```task
id: WARDEN-WP-0024-T04
status: todo
status: done
priority: high
state_hub_task_id: "16501557-6cde-44ea-bc6f-1726cb7ec070"
```
Expose memory activation for coding agent sessions across Claude, Codex, Grok, and
@@ -167,8 +172,9 @@ Acceptance:
```task
id: WARDEN-WP-0024-T05
status: todo
status: done
priority: high
state_hub_task_id: "55ae679c-c08f-4afd-8646-9f5f3019f86e"
```
Ensure worker ticks see agent session episodes and vice versa.
@@ -187,8 +193,9 @@ Acceptance:
```task
id: WARDEN-WP-0024-T06
status: todo
status: done
priority: medium
state_hub_task_id: "fc2dffcf-7184-4f3d-8653-d26dc18a9afc"
```
Skip or downgrade OpenRouter calls when stabilized memory provides a verified match.
@@ -206,8 +213,9 @@ Acceptance:
```task
id: WARDEN-WP-0024-T07
status: todo
status: done
priority: medium
state_hub_task_id: "ca2aaf23-833f-49a6-a49b-a0b659208f5f"
```
Document the memory workflow for operators and coding agents.
@@ -249,4 +257,13 @@ Acceptance:
## Closure Review
Pending implementation.
Integrated phase-memory across all ops-warden runtime surfaces:
- `warden memory status` and `warden memory activate` with canonical XDG store.
- CLI recording hooks on `route find`, `access`, and `sign` (opt-out via
`WARDEN_MEMORY=0`).
- Memory-aware worker planning with stabilized-route RuleBrain short-circuit
and LlmConnectBrain context injection.
- Cross-runtime continuity verified in tests (agent session → worker activation).
- `wiki/OpsWardenMemory.md` and AGENTS.md session orientation for Claude,
Codex, Grok, and future agents.