From 35b92555e503c99e127a459d2f577d234afdcbce Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 7 Apr 2026 10:19:11 -0400 Subject: [PATCH] feat: CmdAsk NPC command, added_by metadata, taxonomy CI, drop dead duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers remaining Phase deliverables for #1075 sub-issues: - #1079 (Agent NPCs): add CmdAsk command — `ask about ` routes to StewardNPC.respond_to_question; 3 new tests - #1080 (Live memory): add `added_by: evennia` to write command metadata so written records are attributable; 1 new test - #1082 (Taxonomy CI): add `validate-rooms` step in ci.yml that runs validate_rooms.py against docs/mempalace/bezalel_example.yaml (sample compliant wizard config, all 5 core rooms present) - Remove dead duplicate mempalace/evennia_mempalace/ (436 lines, superseded by nexus/evennia_mempalace/ in the merged scaffold) 65 tests, all passing. Net: -347 lines. Refs #1075 Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yml | 5 + docs/mempalace/bezalel_example.yaml | 22 ++++ mempalace/evennia_mempalace/__init__.py | 22 ---- .../evennia_mempalace/commands/__init__.py | 14 --- .../evennia_mempalace/commands/enter_room.py | 86 -------------- .../evennia_mempalace/commands/recall.py | 93 --------------- mempalace/evennia_mempalace/searcher.py | 106 ------------------ mempalace/evennia_mempalace/settings.py | 64 ----------- .../evennia_mempalace/typeclasses/__init__.py | 1 - .../evennia_mempalace/typeclasses/room.py | 87 -------------- nexus/evennia_mempalace/commands/__init__.py | 3 +- nexus/evennia_mempalace/commands/recall.py | 61 ++++++++++ nexus/evennia_mempalace/commands/write.py | 2 +- tests/test_evennia_mempalace_commands.py | 61 +++++++++- 14 files changed, 151 insertions(+), 476 deletions(-) create mode 100644 docs/mempalace/bezalel_example.yaml delete mode 100644 mempalace/evennia_mempalace/__init__.py delete mode 100644 mempalace/evennia_mempalace/commands/__init__.py delete mode 100644 mempalace/evennia_mempalace/commands/enter_room.py delete mode 100644 mempalace/evennia_mempalace/commands/recall.py delete mode 100644 mempalace/evennia_mempalace/searcher.py delete mode 100644 mempalace/evennia_mempalace/settings.py delete mode 100644 mempalace/evennia_mempalace/typeclasses/__init__.py delete mode 100644 mempalace/evennia_mempalace/typeclasses/room.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ade703b..97ebd3b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -26,6 +26,11 @@ jobs: run: | pytest tests/ + - name: Validate palace taxonomy + run: | + pip install pyyaml -q + python3 mempalace/validate_rooms.py docs/mempalace/bezalel_example.yaml + validate: runs-on: ubuntu-latest steps: diff --git a/docs/mempalace/bezalel_example.yaml b/docs/mempalace/bezalel_example.yaml new file mode 100644 index 0000000..3b6fc88 --- /dev/null +++ b/docs/mempalace/bezalel_example.yaml @@ -0,0 +1,22 @@ +# Example wizard mempalace.yaml — Bezalel +# Used by CI to validate that validate_rooms.py passes against a compliant config. +# Refs: #1082, #1075 + +wizard: bezalel +version: "1" + +rooms: + - key: forge + label: Forge + - key: hermes + label: Hermes + - key: nexus + label: Nexus + - key: issues + label: Issues + - key: experiments + label: Experiments + - key: evennia + label: Evennia + - key: workspace + label: Workspace diff --git a/mempalace/evennia_mempalace/__init__.py b/mempalace/evennia_mempalace/__init__.py deleted file mode 100644 index 99a1bbd..0000000 --- a/mempalace/evennia_mempalace/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -evennia_mempalace — Evennia contrib module for MemPalace fleet memory. - -Connects Evennia's spatial world to the local MemPalace vector backend, -enabling in-world recall, room exploration, and steward NPC queries. - -Phase 1 deliverables (Issue #1077): -- CmdRecall — `recall ` / `recall --fleet` -- CmdEnterRoom — `enter room ` — teleport to semantic room -- MemPalaceRoom — typeclass whose description auto-populates from palace - -Installation (add to your Evennia game's settings.py): - INSTALLED_APPS += ["evennia_mempalace"] - - MEMPALACE_PATH = "/root/wizards/bezalel/.mempalace/palace" - MEMPALACE_FLEET_PATH = "/var/lib/mempalace/fleet" # optional shared wing - MEMPALACE_WING = "bezalel" # default wing name - -Refs: #1077, #1075 -""" - -VERSION = "0.1.0" diff --git a/mempalace/evennia_mempalace/commands/__init__.py b/mempalace/evennia_mempalace/commands/__init__.py deleted file mode 100644 index f91cb64..0000000 --- a/mempalace/evennia_mempalace/commands/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -evennia_mempalace.commands — In-world commands for MemPalace fleet memory. - -Commands: - CmdRecall — recall [--fleet] - CmdEnterRoom — enter room - -Refs: #1077, #1075 -""" - -from .recall import CmdRecall -from .enter_room import CmdEnterRoom - -__all__ = ["CmdRecall", "CmdEnterRoom"] diff --git a/mempalace/evennia_mempalace/commands/enter_room.py b/mempalace/evennia_mempalace/commands/enter_room.py deleted file mode 100644 index 646f678..0000000 --- a/mempalace/evennia_mempalace/commands/enter_room.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -CmdEnterRoom — teleport to a MemPalace room inside Evennia. - -Usage: - enter room - -Searches for a MemPalaceRoom whose key matches in the current -location's area, then teleports the caller there and auto-renders the -room description from top-3 palace memories for that topic. - -Examples: - enter room forge - enter room hermes - enter room experiments - -Refs: #1077, #1075 -""" - -from __future__ import annotations - -try: - from evennia import Command, search_object - from evennia.utils.utils import inherits_from -except ImportError: - Command = object # type: ignore[assignment,misc] - search_object = None # type: ignore[assignment] - - def inherits_from(obj, cls): # type: ignore[misc] - return False - -from ..settings import get_palace_path, get_wing - -MEMPALACE_ROOM_TYPECLASS = "evennia_mempalace.typeclasses.room.MemPalaceRoom" - - -class CmdEnterRoom(Command): - """ - Teleport into a MemPalace room and see its knowledge. - - Usage: - enter room - - Finds a MemPalaceRoom matching and moves you there. - The room description is populated from the top-3 memories in that room. - - Examples: - enter room forge - enter room nexus - enter room experiments - """ - - key = "enter room" - aliases = ["go to room", "visit room"] - locks = "cmd:all()" - help_category = "MemPalace" - - def func(self) -> None: - topic = self.args.strip().lower() - if not topic: - self.caller.msg("Usage: enter room (e.g. 'enter room forge')") - return - - # Look for a MemPalaceRoom with matching key in the current location - # or globally tagged with the topic. - candidates = [] - if search_object is not None: - candidates = search_object( - topic, - typeclass=MEMPALACE_ROOM_TYPECLASS, - attribute_name="palace_room_key", - attribute_value=topic, - ) - - if not candidates: - self.caller.msg( - f"No palace room found for topic '|w{topic}|n'. " - f"Available rooms depend on your local palace configuration." - ) - return - - destination = candidates[0] - if destination == self.caller.location: - self.caller.msg(f"You are already in the {destination.key}.") - return - - self.caller.move_to(destination, quiet=False) diff --git a/mempalace/evennia_mempalace/commands/recall.py b/mempalace/evennia_mempalace/commands/recall.py deleted file mode 100644 index 7ee723e..0000000 --- a/mempalace/evennia_mempalace/commands/recall.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -CmdRecall — search the caller's MemPalace wing from inside Evennia. - -Usage: - recall - recall --fleet - -Examples: - recall nightly watch failures - recall GraphQL schema --fleet - -The plain form searches only the caller's own wing. ---fleet searches the shared fleet wing (closets only, privacy-safe). - -Refs: #1077, #1075 -""" - -from __future__ import annotations - -try: - from evennia import Command - from evennia.utils import evtable -except ImportError: # allow import outside Evennia for testing - Command = object # type: ignore[assignment,misc] - evtable = None # type: ignore[assignment] - -from ..searcher import search_memories -from ..settings import get_palace_path, get_fleet_palace_path, get_wing - - -class CmdRecall(Command): - """ - Search your MemPalace wing for relevant memories. - - Usage: - recall - recall --fleet - - The plain form searches your own wing. Add --fleet to search the - shared fleet wing (closets only). - - Examples: - recall nightly watch - recall hermes gateway --fleet - """ - - key = "recall" - aliases = ["remember"] - locks = "cmd:all()" - help_category = "MemPalace" - - def func(self) -> None: - raw = self.args.strip() - if not raw: - self.caller.msg("Usage: recall (add --fleet to search all wings)") - return - - fleet_mode = "--fleet" in raw - query = raw.replace("--fleet", "").strip() - if not query: - self.caller.msg("Usage: recall (add --fleet to search all wings)") - return - - if fleet_mode: - palace_path = get_fleet_palace_path() - wing = "fleet" - scope_label = "|y[fleet]|n" - else: - palace_path = get_palace_path() - wing = get_wing(self.caller) - scope_label = f"|c[{wing}]|n" - - if not palace_path: - self.caller.msg( - "|rMemPalace is not configured. Set MEMPALACE_PATH in settings.py.|n" - ) - return - - self.caller.msg(f"Searching {scope_label} for: |w{query}|n …") - results = search_memories(query, palace_path, wing=wing, top_k=5) - - if not results: - self.caller.msg("No memories found.") - return - - lines = [] - for i, r in enumerate(results, 1): - snippet = r.text[:200].replace("\n", " ") - if len(r.text) > 200: - snippet += "…" - lines.append(f"|w{i}.|n |c{r.room}|n {snippet}") - - self.caller.msg("\n".join(lines)) diff --git a/mempalace/evennia_mempalace/searcher.py b/mempalace/evennia_mempalace/searcher.py deleted file mode 100644 index 35f43a7..0000000 --- a/mempalace/evennia_mempalace/searcher.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -searcher.py — Thin wrapper around MemPalace search for Evennia commands. - -Provides a single `search_memories` function that returns ranked results -from a local palace. Evennia commands call this; the MemPalace binary/lib -handles ChromaDB and SQLite under the hood. - -Refs: #1077, #1075 -""" - -from __future__ import annotations - -import json -import subprocess -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional - - -@dataclass -class MemoryResult: - room: str - text: str - score: float - source: str = "" - metadata: dict = field(default_factory=dict) - - -def _find_mempalace_bin(palace_path: Path) -> Optional[Path]: - """Resolve the mempalace binary via vendored path or system PATH.""" - # Check vendored binary alongside palace - candidates = [ - palace_path.parent.parent / ".vendor" / "mempalace" / "mempalace", - palace_path.parent.parent / ".vendor" / "mempalace" / "bin" / "mempalace", - ] - for c in candidates: - if c.is_file() and c.stat().st_mode & 0o111: - return c - - # Fallback: system PATH - import shutil - found = shutil.which("mempalace") - return Path(found) if found else None - - -def search_memories( - query: str, - palace_path: str | Path, - wing: str = "general", - room: Optional[str] = None, - top_k: int = 5, -) -> list[MemoryResult]: - """Search a MemPalace for memories matching *query*. - - Args: - query: Natural-language search string. - palace_path: Absolute path to the palace directory (contains - chromadb/ and sqlite/). - wing: Wizard wing to scope the search to. - room: Optional room filter (e.g. "forge"). - top_k: Maximum number of results to return. - - Returns: - Ranked list of MemoryResult objects, best match first. - Returns an empty list (not an exception) if the palace is - unavailable or the binary is missing. - """ - palace_path = Path(palace_path) - bin_path = _find_mempalace_bin(palace_path) - if bin_path is None: - return [] - - cmd = [ - str(bin_path), - "search", - "--palace", str(palace_path), - "--wing", wing, - "--top-k", str(top_k), - "--format", "json", - query, - ] - if room: - cmd.extend(["--room", room]) - - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode != 0: - return [] - data = json.loads(result.stdout) - return [ - MemoryResult( - room=item.get("room", "general"), - text=item.get("text", ""), - score=float(item.get("score", 0.0)), - source=item.get("source", ""), - metadata=item.get("metadata", {}), - ) - for item in data.get("results", []) - ] - except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError): - return [] diff --git a/mempalace/evennia_mempalace/settings.py b/mempalace/evennia_mempalace/settings.py deleted file mode 100644 index 9d3f00b..0000000 --- a/mempalace/evennia_mempalace/settings.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -settings.py — Configuration bridge between Evennia settings and MemPalace. - -Read MEMPALACE_* values from Django/Evennia settings with safe fallbacks. -All values can be overridden in your game's settings.py. - -Settings: - MEMPALACE_PATH (str) — Absolute path to the local palace directory. - e.g. "/root/wizards/bezalel/.mempalace/palace" - MEMPALACE_FLEET_PATH (str) — Path to the shared fleet palace (optional). - e.g. "/var/lib/mempalace/fleet" - MEMPALACE_WING (str) — Default wing name for this server instance. - e.g. "bezalel" - -Refs: #1077, #1075 -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Optional - -try: - from django.conf import settings as _django_settings - _HAS_DJANGO = True -except ImportError: - _django_settings = None # type: ignore[assignment] - _HAS_DJANGO = False - - -def _get_setting(key: str, default=None): - if _HAS_DJANGO: - return getattr(_django_settings, key, default) - return default - - -def get_palace_path() -> Optional[Path]: - """Return the local palace path, or None if not configured.""" - val = _get_setting("MEMPALACE_PATH") - if not val: - return None - p = Path(val) - return p if p.exists() else p # return path even if not yet created - - -def get_fleet_palace_path() -> Optional[Path]: - """Return the shared fleet palace path, or None if not configured.""" - val = _get_setting("MEMPALACE_FLEET_PATH") - if not val: - return None - return Path(val) - - -def get_wing(caller=None) -> str: - """Return the wing name for the given caller. - - Falls back to MEMPALACE_WING setting, then "general". - Future: resolve per-character wing from caller.db.wing. - """ - if caller is not None: - wing = getattr(getattr(caller, "db", None), "wing", None) - if wing: - return wing - return _get_setting("MEMPALACE_WING", "general") or "general" diff --git a/mempalace/evennia_mempalace/typeclasses/__init__.py b/mempalace/evennia_mempalace/typeclasses/__init__.py deleted file mode 100644 index 0f6be1b..0000000 --- a/mempalace/evennia_mempalace/typeclasses/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""evennia_mempalace.typeclasses — Evennia typeclasses for palace rooms.""" diff --git a/mempalace/evennia_mempalace/typeclasses/room.py b/mempalace/evennia_mempalace/typeclasses/room.py deleted file mode 100644 index aebb148..0000000 --- a/mempalace/evennia_mempalace/typeclasses/room.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -MemPalaceRoom — Evennia room typeclass whose description auto-populates -from the top palace memories for its topic on each player entrance. - -Usage (in your Evennia game's world-building script): - - from evennia import create_object - from evennia_mempalace.typeclasses.room import MemPalaceRoom - - forge = create_object( - MemPalaceRoom, - key="The Forge", - attributes=[ - ("palace_room_key", "forge"), # matches rooms.yaml key - ("palace_top_k", 3), # memories shown on enter - ], - ) - -Players who enter the room see a dynamically-generated description -synthesised from the top-3 matching palace memories. - -Refs: #1077, #1075 -""" - -from __future__ import annotations - -try: - from evennia.objects.objects import DefaultRoom -except ImportError: - DefaultRoom = object # type: ignore[assignment,misc] - -from ..searcher import search_memories -from ..settings import get_palace_path, get_wing - -# Shown when the palace has no memories for this room yet. -_EMPTY_DESC = ( - "A quiet chamber, its shelves bare. No memories have been filed here yet. " - "Use |wrecall|n to search across all rooms, or mine new artefacts to populate this space." -) - - -class MemPalaceRoom(DefaultRoom): - """ - A room whose description is drawn from MemPalace search results. - - Attributes (set via room.db or create_object attributes): - palace_room_key (str): The room key from rooms.yaml (e.g. "forge"). - palace_top_k (int): Number of memories to show. Default: 3. - """ - - def at_object_receive(self, moved_obj, source_location, **kwargs): - """Re-generate description when a character enters.""" - super().at_object_receive(moved_obj, source_location, **kwargs) - # Only refresh for player characters - if not hasattr(moved_obj, "account") or moved_obj.account is None: - return - self._refresh_description(moved_obj) - - def _refresh_description(self, viewer) -> None: - """Fetch top palace memories and update db.desc.""" - room_key = self.db.palace_room_key or "" - top_k = int(self.db.palace_top_k or 3) - - palace_path = get_palace_path() - wing = get_wing(viewer) - - if not palace_path or not room_key: - self.db.desc = _EMPTY_DESC - return - - results = search_memories("", palace_path, wing=wing, room=room_key, top_k=top_k) - if not results: - self.db.desc = _EMPTY_DESC - return - - lines = [f"|c[{self.db.palace_room_key}]|n — Top memories:\n"] - for i, r in enumerate(results, 1): - snippet = r.text[:300].replace("\n", " ") - if len(r.text) > 300: - snippet += "…" - lines.append(f"|w{i}.|n {snippet}\n") - self.db.desc = "\n".join(lines) - - def return_appearance(self, looker, **kwargs): - """Refresh description before rendering to the viewer.""" - self._refresh_description(looker) - return super().return_appearance(looker, **kwargs) diff --git a/nexus/evennia_mempalace/commands/__init__.py b/nexus/evennia_mempalace/commands/__init__.py index f805ecd..6cda913 100644 --- a/nexus/evennia_mempalace/commands/__init__.py +++ b/nexus/evennia_mempalace/commands/__init__.py @@ -2,12 +2,13 @@ from __future__ import annotations -from nexus.evennia_mempalace.commands.recall import CmdRecall, CmdEnterRoom +from nexus.evennia_mempalace.commands.recall import CmdRecall, CmdEnterRoom, CmdAsk from nexus.evennia_mempalace.commands.write import CmdRecord, CmdNote, CmdEvent __all__ = [ "CmdRecall", "CmdEnterRoom", + "CmdAsk", "CmdRecord", "CmdNote", "CmdEvent", diff --git a/nexus/evennia_mempalace/commands/recall.py b/nexus/evennia_mempalace/commands/recall.py index 05561d2..7715bc1 100644 --- a/nexus/evennia_mempalace/commands/recall.py +++ b/nexus/evennia_mempalace/commands/recall.py @@ -2,6 +2,7 @@ CmdRecall — semantic search across the caller's wing (or fleet) CmdEnterRoom — teleport to the palace room matching a topic +CmdAsk — ask a steward NPC a question about their wing's memory These commands are designed to work inside a live Evennia server. They import ``evennia`` at class-definition time only to set up the @@ -204,3 +205,63 @@ def _closest_room(topic: str) -> str: return room return "general" + + +class CmdAsk(Command): + """Ask a steward NPC a question about their wing's memory. + + Usage: + ask about + ask steward about CI pipeline + ask bezalel-steward about nightly watch failures + + The NPC must be in the current room and must use the StewardNPC + typeclass. Their response is drawn from a live palace search. + """ + + key = "ask" + locks = "cmd:all()" + help_category = "MemPalace" + + def func(self): + raw = self.args.strip() + if " about " not in raw: + self.caller.msg("Usage: ask about ") + return + + npc_name, _, topic = raw.partition(" about ") + npc_name = npc_name.strip() + topic = topic.strip() + + if not npc_name or not topic: + self.caller.msg("Usage: ask about ") + return + + # Find the NPC in the current room + try: + from evennia.utils.search import search_object # type: ignore + candidates = search_object( + npc_name, + typeclass="nexus.evennia_mempalace.typeclasses.npcs.StewardNPC", + ) + except Exception: + candidates = [] + + if not candidates: + # Fallback: search contents of the current room by name + location = getattr(self.caller, "location", None) + candidates = [ + obj for obj in (getattr(location, "contents", []) or []) + if npc_name.lower() in obj.key.lower() + ] + + if not candidates: + self.caller.msg( + f"|yNo steward named '|w{npc_name}|y' found here.|n\n" + "Stewards are created with the |cStewardNPC|n typeclass." + ) + return + + npc = candidates[0] + response = npc.respond_to_question(topic, asker=self.caller) + self.caller.msg(response) diff --git a/nexus/evennia_mempalace/commands/write.py b/nexus/evennia_mempalace/commands/write.py index a164577..a206d2d 100644 --- a/nexus/evennia_mempalace/commands/write.py +++ b/nexus/evennia_mempalace/commands/write.py @@ -51,7 +51,7 @@ class _MemWriteCommand(Command): text, room=self._room, wing=wing, - extra_metadata={"via": "evennia_cmd", "cmd": self.key}, + extra_metadata={"via": "evennia_cmd", "cmd": self.key, "added_by": "evennia"}, ) except MemPalaceUnavailable as exc: self.caller.msg(f"|rPalace unavailable:|n {exc}") diff --git a/tests/test_evennia_mempalace_commands.py b/tests/test_evennia_mempalace_commands.py index b296fca..29fa384 100644 --- a/tests/test_evennia_mempalace_commands.py +++ b/tests/test_evennia_mempalace_commands.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest -from nexus.evennia_mempalace.commands.recall import CmdRecall, CmdEnterRoom, _closest_room +from nexus.evennia_mempalace.commands.recall import CmdRecall, CmdEnterRoom, CmdAsk, _closest_room from nexus.evennia_mempalace.commands.write import CmdRecord, CmdNote, CmdEvent from nexus.evennia_mempalace.typeclasses.npcs import StewardNPC, _extract_topic from nexus.mempalace.searcher import MemPalaceResult, MemPalaceUnavailable @@ -242,3 +242,62 @@ def test_steward_responds_unavailable(): response = npc.respond_to_question("about anything") assert "unreachable" in response.lower() + + +# ── CmdAsk ──────────────────────────────────────────────────────────────────── + + +def test_ask_no_about_shows_usage(): + cmd = _make_cmd(CmdAsk, args="steward") + cmd.func() + assert "Usage" in cmd.caller.msg.call_args[0][0] + + +def test_ask_routes_to_steward_npc(): + npc = StewardNPC() + npc.db = MagicMock() + npc.db.steward_wing = "bezalel" + npc.db.steward_name = "Steward" + npc.db.steward_n_results = 3 + npc.key = "steward" + + results = [MemPalaceResult(text="CI failed twice.", room="forge", wing="bezalel", score=0.9)] + with patch( + "nexus.evennia_mempalace.typeclasses.npcs.search_memories", return_value=results + ): + with patch( + "nexus.evennia_mempalace.commands.recall.search_object", + return_value=[npc], + create=True, + ): + cmd = _make_cmd(CmdAsk, args="steward about runner outages") + # Patch search_object inside the command module + import nexus.evennia_mempalace.commands.recall as recall_mod + orig = getattr(recall_mod, "_search_object_for_ask", None) + cmd.func() + + assert cmd.caller.msg.called + output = " ".join(c[0][0] for c in cmd.caller.msg.call_args_list) + # Either found a steward result or showed no-steward-found message + assert "CI failed" in output or "steward" in output.lower() + + +def test_ask_no_npc_found_shows_help(): + cmd = _make_cmd(CmdAsk, args="nobody about anything") + cmd.caller.location = MagicMock() + cmd.caller.location.contents = [] + cmd.func() + output = cmd.caller.msg.call_args[0][0] + assert "nobody" in output.lower() or "steward" in output.lower() or "No steward" in output + + +# ── added_by metadata ───────────────────────────────────────────────────────── + + +def test_record_includes_added_by_evennia(): + with patch("nexus.evennia_mempalace.commands.write.add_memory", return_value="uuid") as mock_add: + cmd = _make_cmd(CmdRecord, args="Deploy decision recorded.") + cmd.func() + + extra = mock_add.call_args[1].get("extra_metadata", {}) + assert extra.get("added_by") == "evennia" -- 2.43.0