[claude] MemPalace follow-up: CmdAsk, metadata fix, taxonomy CI (#1075) #1091
@@ -26,6 +26,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pytest tests/
|
pytest tests/
|
||||||
|
|
||||||
|
- name: Validate palace taxonomy
|
||||||
|
run: |
|
||||||
|
pip install pyyaml -q
|
||||||
|
python3 mempalace/validate_rooms.py docs/mempalace/bezalel_example.yaml
|
||||||
|
|
||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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 __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
|
from nexus.evennia_mempalace.commands.write import CmdRecord, CmdNote, CmdEvent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CmdRecall",
|
"CmdRecall",
|
||||||
"CmdEnterRoom",
|
"CmdEnterRoom",
|
||||||
|
"CmdAsk",
|
||||||
"CmdRecord",
|
"CmdRecord",
|
||||||
"CmdNote",
|
"CmdNote",
|
||||||
"CmdEvent",
|
"CmdEvent",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
CmdRecall — semantic search across the caller's wing (or fleet)
|
CmdRecall — semantic search across the caller's wing (or fleet)
|
||||||
CmdEnterRoom — teleport to the palace room matching a topic
|
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.
|
These commands are designed to work inside a live Evennia server.
|
||||||
They import ``evennia`` at class-definition time only to set up the
|
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 room
|
||||||
|
|
||||||
return "general"
|
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,
|
text,
|
||||||
room=self._room,
|
room=self._room,
|
||||||
wing=wing,
|
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:
|
except MemPalaceUnavailable as exc:
|
||||||
self.caller.msg(f"|rPalace unavailable:|n {exc}")
|
self.caller.msg(f"|rPalace unavailable:|n {exc}")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
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.commands.write import CmdRecord, CmdNote, CmdEvent
|
||||||
from nexus.evennia_mempalace.typeclasses.npcs import StewardNPC, _extract_topic
|
from nexus.evennia_mempalace.typeclasses.npcs import StewardNPC, _extract_topic
|
||||||
from nexus.mempalace.searcher import MemPalaceResult, MemPalaceUnavailable
|
from nexus.mempalace.searcher import MemPalaceResult, MemPalaceUnavailable
|
||||||
@@ -242,3 +242,62 @@ def test_steward_responds_unavailable():
|
|||||||
response = npc.respond_to_question("about anything")
|
response = npc.respond_to_question("about anything")
|
||||||
|
|
||||||
assert "unreachable" in response.lower()
|
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