[claude] MemPalace follow-up: CmdAsk, metadata fix, taxonomy CI (#1075) #1091
@@ -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:
|
||||
|
||||
22
docs/mempalace/bezalel_example.yaml
Normal file
22
docs/mempalace/bezalel_example.yaml
Normal file
@@ -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
|
||||
@@ -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 <query>` / `recall <query> --fleet`
|
||||
- CmdEnterRoom — `enter room <topic>` — 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"
|
||||
@@ -1,14 +0,0 @@
|
||||
"""
|
||||
evennia_mempalace.commands — In-world commands for MemPalace fleet memory.
|
||||
|
||||
Commands:
|
||||
CmdRecall — recall <query> [--fleet]
|
||||
CmdEnterRoom — enter room <topic>
|
||||
|
||||
Refs: #1077, #1075
|
||||
"""
|
||||
|
||||
from .recall import CmdRecall
|
||||
from .enter_room import CmdEnterRoom
|
||||
|
||||
__all__ = ["CmdRecall", "CmdEnterRoom"]
|
||||
@@ -1,86 +0,0 @@
|
||||
"""
|
||||
CmdEnterRoom — teleport to a MemPalace room inside Evennia.
|
||||
|
||||
Usage:
|
||||
enter room <topic>
|
||||
|
||||
Searches for a MemPalaceRoom whose key matches <topic> 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 <topic>
|
||||
|
||||
Finds a MemPalaceRoom matching <topic> 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 <topic> (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)
|
||||
@@ -1,93 +0,0 @@
|
||||
"""
|
||||
CmdRecall — search the caller's MemPalace wing from inside Evennia.
|
||||
|
||||
Usage:
|
||||
recall <query>
|
||||
recall <query> --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 <query>
|
||||
recall <query> --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 <query> (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 <query> (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))
|
||||
@@ -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 []
|
||||
@@ -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"
|
||||
@@ -1 +0,0 @@
|
||||
"""evennia_mempalace.typeclasses — Evennia typeclasses for palace rooms."""
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <npc-name> about <topic>
|
||||
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 <npc-name> about <topic>")
|
||||
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 <npc-name> about <topic>")
|
||||
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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user