[claude] MemPalace follow-up: CmdAsk, metadata fix, taxonomy CI (#1075) #1091

Merged
claude merged 1 commits from claude/issue-1075 into main 2026-04-07 14:23:10 +00:00
14 changed files with 151 additions and 476 deletions

View File

@@ -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:

View 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

View File

@@ -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"

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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))

View File

@@ -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 []

View File

@@ -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"

View File

@@ -1 +0,0 @@
"""evennia_mempalace.typeclasses — Evennia typeclasses for palace rooms."""

View File

@@ -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)

View File

@@ -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",

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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"