From 367a06a849e7415ee211efbab9b113ede3e5471d Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 7 Apr 2026 10:07:15 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20MemPalace=20=C3=97=20Evennia=20fleet=20?= =?UTF-8?q?memory=20scaffold=20(#1075)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add foundational architecture for the MemPalace × Evennia integration milestone. All sub-issues (Phase 1–4 and infra) can now build on top of this scaffold. **nexus/mempalace/** - `config.py` — MEMPALACE_PATH, FLEET_PALACE_PATH, FLEET_WING, CORE_ROOMS - `searcher.py` — `search_memories()`, `search_fleet()`, `add_memory()`, `MemPalaceResult`, `MemPalaceUnavailable`; ChromaDB imported lazily **nexus/evennia_mempalace/** Commands: - `CmdRecall` — `recall ` / `recall --fleet` - `CmdEnterRoom` — `enter room ` teleports to a palace room - `CmdRecord` / `CmdNote` / `CmdEvent` — write decisions, breakthroughs, and events into the palace (Phase 4) Typeclasses: - `MemPalaceRoom` — room whose description auto-populates from palace search - `StewardNPC` — answers player questions via wing memory search (Phase 3) All classes use a graceful Evennia stub so the module imports cleanly outside a live Evennia/Django environment. **docs/mempalace/rooms.yaml** (#1082 deliverable) Fleet-wide room taxonomy standard: 5 required core rooms, 3 write rooms, optional domain rooms, tunnel policy, and privacy rules. **tests/** - `test_mempalace_searcher.py` — 20 tests covering config, ChromaDB mocking, search/add, error paths - `test_evennia_mempalace_commands.py` — 20 tests covering all commands, `_closest_room`, `_extract_topic`, StewardNPC responses 40 new tests, all passing. Refs #1075 --- docs/mempalace/rooms.yaml | 183 +++++++++++++ nexus/evennia_mempalace/__init__.py | 49 ++++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1784 bytes nexus/evennia_mempalace/commands/__init__.py | 14 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 569 bytes .../__pycache__/recall.cpython-312.pyc | Bin 0 -> 8689 bytes .../__pycache__/write.cpython-312.pyc | Bin 0 -> 4740 bytes nexus/evennia_mempalace/commands/recall.py | 206 +++++++++++++++ nexus/evennia_mempalace/commands/write.py | 124 +++++++++ .../evennia_mempalace/typeclasses/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 262 bytes .../__pycache__/npcs.cpython-312.pyc | Bin 0 -> 6068 bytes .../__pycache__/rooms.cpython-312.pyc | Bin 0 -> 5510 bytes nexus/evennia_mempalace/typeclasses/npcs.py | 138 ++++++++++ nexus/evennia_mempalace/typeclasses/rooms.py | 99 +++++++ nexus/mempalace/__init__.py | 23 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 932 bytes .../__pycache__/config.cpython-312.pyc | Bin 0 -> 1663 bytes .../__pycache__/searcher.cpython-312.pyc | Bin 0 -> 7239 bytes nexus/mempalace/config.py | 46 ++++ nexus/mempalace/searcher.py | 200 ++++++++++++++ tests/test_evennia_mempalace_commands.py | 244 ++++++++++++++++++ tests/test_mempalace_searcher.py | 190 ++++++++++++++ 23 files changed, 1517 insertions(+) create mode 100644 docs/mempalace/rooms.yaml create mode 100644 nexus/evennia_mempalace/__init__.py create mode 100644 nexus/evennia_mempalace/__pycache__/__init__.cpython-312.pyc create mode 100644 nexus/evennia_mempalace/commands/__init__.py create mode 100644 nexus/evennia_mempalace/commands/__pycache__/__init__.cpython-312.pyc create mode 100644 nexus/evennia_mempalace/commands/__pycache__/recall.cpython-312.pyc create mode 100644 nexus/evennia_mempalace/commands/__pycache__/write.cpython-312.pyc create mode 100644 nexus/evennia_mempalace/commands/recall.py create mode 100644 nexus/evennia_mempalace/commands/write.py create mode 100644 nexus/evennia_mempalace/typeclasses/__init__.py create mode 100644 nexus/evennia_mempalace/typeclasses/__pycache__/__init__.cpython-312.pyc create mode 100644 nexus/evennia_mempalace/typeclasses/__pycache__/npcs.cpython-312.pyc create mode 100644 nexus/evennia_mempalace/typeclasses/__pycache__/rooms.cpython-312.pyc create mode 100644 nexus/evennia_mempalace/typeclasses/npcs.py create mode 100644 nexus/evennia_mempalace/typeclasses/rooms.py create mode 100644 nexus/mempalace/__init__.py create mode 100644 nexus/mempalace/__pycache__/__init__.cpython-312.pyc create mode 100644 nexus/mempalace/__pycache__/config.cpython-312.pyc create mode 100644 nexus/mempalace/__pycache__/searcher.cpython-312.pyc create mode 100644 nexus/mempalace/config.py create mode 100644 nexus/mempalace/searcher.py create mode 100644 tests/test_evennia_mempalace_commands.py create mode 100644 tests/test_mempalace_searcher.py diff --git a/docs/mempalace/rooms.yaml b/docs/mempalace/rooms.yaml new file mode 100644 index 00000000..8d8a80ce --- /dev/null +++ b/docs/mempalace/rooms.yaml @@ -0,0 +1,183 @@ +# MemPalace Fleet Room Taxonomy Standard +# ======================================= +# Version: 1.0 +# Milestone: MemPalace × Evennia — Fleet Memory (#1075) +# Issue: #1082 [Infra] Palace taxonomy standard +# +# Every wizard's palace MUST contain the five core rooms listed below. +# Domain rooms are optional and wizard-specific. +# +# Format: +# rooms: +# : +# required: true|false +# description: one-liner purpose +# example_topics: [list of things that belong here] +# tunnel: true if a cross-wizard tunnel should exist for this room + +rooms: + + # ── Core rooms (required in every wing) ──────────────────────────────────── + + forge: + required: true + description: "CI, builds, deployment, infra operations" + example_topics: + - "github actions failures" + - "docker build logs" + - "server deployment steps" + - "cron job setup" + tunnel: true + + hermes: + required: true + description: "Agent platform, gateway, CLI tooling, harness internals" + example_topics: + - "hermes session logs" + - "agent wake cycle" + - "MCP tool calls" + - "gateway configuration" + tunnel: true + + nexus: + required: true + description: "Reports, docs, knowledge transfer, SITREPs" + example_topics: + - "nightly watch report" + - "architecture docs" + - "handoff notes" + - "decision records" + tunnel: true + + issues: + required: true + description: "Gitea tickets, backlog items, bug reports, PR reviews" + example_topics: + - "issue triage" + - "PR feedback" + - "bug root cause" + - "milestone planning" + tunnel: true + + experiments: + required: true + description: "Prototypes, spikes, research, benchmarks" + example_topics: + - "spike results" + - "benchmark numbers" + - "proof of concept" + - "chromadb evaluation" + tunnel: true + + # ── Write rooms (created on demand by CmdRecord/CmdNote/CmdEvent) ────────── + + hall_facts: + required: false + description: "Decisions and facts recorded via 'record' command" + example_topics: + - "architectural decisions" + - "policy choices" + - "approved approaches" + tunnel: false + + hall_discoveries: + required: false + description: "Breakthroughs and key findings recorded via 'note' command" + example_topics: + - "performance breakthroughs" + - "algorithmic insights" + - "unexpected results" + tunnel: false + + hall_events: + required: false + description: "Significant events logged via 'event' command" + example_topics: + - "production deployments" + - "milestones reached" + - "incidents resolved" + tunnel: false + + # ── Optional domain rooms (wizard-specific) ──────────────────────────────── + + evennia: + required: false + description: "Evennia MUD world: rooms, commands, NPCs, world design" + example_topics: + - "command implementation" + - "typeclass design" + - "world building notes" + wizard: ["bezalel"] + tunnel: false + + game_portals: + required: false + description: "Portal/gameplay work: satflow, economy, portal registry" + example_topics: + - "portal specs" + - "satflow visualization" + - "economy rules" + wizard: ["bezalel", "timmy"] + tunnel: false + + workspace: + required: false + description: "General wizard workspace notes that don't fit elsewhere" + example_topics: + - "daily notes" + - "scratch work" + - "reference lookups" + tunnel: false + + general: + required: false + description: "Fallback room for unclassified memories" + example_topics: + - "uncategorized notes" + tunnel: false + + +# ── Tunnel policy ───────────────────────────────────────────────────────────── +# +# A tunnel is a cross-wing link that lets any wizard recall memories +# from an equivalent room in another wing. +# +# Rules: +# 1. Only CLOSETS (summaries) are synced through tunnels — never raw drawers. +# 2. Required rooms marked tunnel:true MUST have tunnels on Alpha. +# 3. Optional rooms are never tunnelled unless explicitly opted in. +# 4. Raw drawers (source_file metadata) never leave the local VPS. + +tunnels: + policy: closets_only + sync_schedule: "04:00 UTC nightly" + destination: "/var/lib/mempalace/fleet" + rooms_synced: + - forge + - hermes + - nexus + - issues + - experiments + + +# ── Privacy rules ───────────────────────────────────────────────────────────── +# +# See issue #1083 for the full privacy boundary design. +# +# Summary: +# - hall_facts, hall_discoveries, hall_events: LOCAL ONLY (never synced) +# - workspace, general: LOCAL ONLY +# - Domain rooms (evennia, game_portals): LOCAL ONLY unless tunnel:true +# - source_file paths MUST be stripped before sync + +privacy: + local_only_rooms: + - hall_facts + - hall_discoveries + - hall_events + - workspace + - general + strip_on_sync: + - source_file + retention_days: 90 + archive_flag: "archive: true" diff --git a/nexus/evennia_mempalace/__init__.py b/nexus/evennia_mempalace/__init__.py new file mode 100644 index 00000000..640f1658 --- /dev/null +++ b/nexus/evennia_mempalace/__init__.py @@ -0,0 +1,49 @@ +"""nexus.evennia_mempalace — Evennia plugin for MemPalace fleet memory. + +This contrib module provides: + +Commands (add to ``settings.CMDSETS_DEFAULT`` or a CmdSet): + CmdRecall — ``recall `` / ``recall --fleet`` + CmdEnterRoom — ``enter room `` teleports to a palace room + CmdRecord — ``record decision `` writes to hall_facts + CmdNote — ``note breakthrough `` writes to hall_discoveries + CmdEvent — ``event `` writes to hall_events + +Typeclasses (use in place of Evennia's default Room/Character): + MemPalaceRoom — Room whose description auto-populates from palace search + StewardNPC — Wizard steward that answers questions via palace search + +Usage example (in your Evennia game's ``mygame/server/conf/settings.py``):: + + MEMPALACE_PATH = "/root/wizards/bezalel/.mempalace/palace" + MEMPALACE_WING = "bezalel" + FLEET_PALACE_PATH = "/var/lib/mempalace/fleet" + +Then import commands into a CmdSet:: + + from nexus.evennia_mempalace.commands import ( + CmdRecall, CmdEnterRoom, CmdRecord, CmdNote, CmdEvent + ) +""" + +from __future__ import annotations + +from nexus.evennia_mempalace.commands import ( + CmdEnterRoom, + CmdEvent, + CmdNote, + CmdRecord, + CmdRecall, +) +from nexus.evennia_mempalace.typeclasses.rooms import MemPalaceRoom +from nexus.evennia_mempalace.typeclasses.npcs import StewardNPC + +__all__ = [ + "CmdRecall", + "CmdEnterRoom", + "CmdRecord", + "CmdNote", + "CmdEvent", + "MemPalaceRoom", + "StewardNPC", +] diff --git a/nexus/evennia_mempalace/__pycache__/__init__.cpython-312.pyc b/nexus/evennia_mempalace/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..94bdccc7547944845bffa511492acfb1d5741222 GIT binary patch literal 1784 zcma)7OK&4Z5T0@D*qJ;Ih|`{0;eah+&xR~y6NN>N?Eq2|MVmw``7+a)Dch|~PtSJu z#POcsKOk}E#&6-w#S#}daRXK>q&-pHvI&ob7H6O#BN+lTwdyzTqCa}E4sLh(>Nwt zkT6uBHA{!8)j926^e!)gZtu8#b#fVo1RYV*;qe9N#(_uh!_9MuXp)!>ekQyyl!fZ> zWe#%k3=RE%DM_=K^b^OZrS45W_aF}qC+5XM|p+>tz&)@ zk;{2*WJoz+f?>!fM)Va(EUk~c9-utqYc>hP#sQMr#Boi4rQ~O4Z%5>vhplQwQjM21;lEIm-p-^DYMeHEN*}O+JRCr6OG^((!_|=$v#goLOx?4_*0dS~v510T>IK0d*SUlskSlmNCI3~O0^CAcaO=i+ zoi-ja)yoEJAx*fl!A$G_=YE<+%7$%XHZvsn!Fi3|oVSU8g+x_8&FCZre=OvUmH^7f zUe04^?mXW6q`C97d$e=B_i3};K00bX+1=|l+q*~m&Et>vJG;I1{(krIk#FPh%{aLC zZ{P<3OPLOWRyKKCJ1oZgGxsV=y!|)8{xvrM literal 0 HcmV?d00001 diff --git a/nexus/evennia_mempalace/commands/__init__.py b/nexus/evennia_mempalace/commands/__init__.py new file mode 100644 index 00000000..f805ecdf --- /dev/null +++ b/nexus/evennia_mempalace/commands/__init__.py @@ -0,0 +1,14 @@ +"""MemPalace Evennia commands.""" + +from __future__ import annotations + +from nexus.evennia_mempalace.commands.recall import CmdRecall, CmdEnterRoom +from nexus.evennia_mempalace.commands.write import CmdRecord, CmdNote, CmdEvent + +__all__ = [ + "CmdRecall", + "CmdEnterRoom", + "CmdRecord", + "CmdNote", + "CmdEvent", +] diff --git a/nexus/evennia_mempalace/commands/__pycache__/__init__.cpython-312.pyc b/nexus/evennia_mempalace/commands/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..360a48bb98addbcce97b980ac46af85bda23ae41 GIT binary patch literal 569 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 LzaxFHM}Yc&7?-YB literal 0 HcmV?d00001 diff --git a/nexus/evennia_mempalace/commands/recall.py b/nexus/evennia_mempalace/commands/recall.py new file mode 100644 index 00000000..05561d29 --- /dev/null +++ b/nexus/evennia_mempalace/commands/recall.py @@ -0,0 +1,206 @@ +"""Evennia commands for querying the MemPalace. + +CmdRecall — semantic search across the caller's wing (or fleet) +CmdEnterRoom — teleport to the palace room matching a topic + +These commands are designed to work inside a live Evennia server. +They import ``evennia`` at class-definition time only to set up the +command skeleton; the actual search logic lives in ``nexus.mempalace`` +and is fully testable without a running Evennia instance. +""" + +from __future__ import annotations + +from nexus.mempalace.searcher import ( + MemPalaceUnavailable, + MemPalaceResult, + search_memories, + search_fleet, +) +from nexus.mempalace.config import FLEET_WING, CORE_ROOMS + +try: + from evennia import Command as _EvCommand # type: ignore + if _EvCommand is None: + raise ImportError("evennia.Command is None (Django not configured)") + Command = _EvCommand +except (ImportError, Exception): # outside a live Evennia environment + class Command: # type: ignore # minimal stub for import/testing + key = "" + aliases: list = [] + locks = "cmd:all()" + help_category = "MemPalace" + + def __init__(self): + self.caller = None + self.args = "" + self.switches: list[str] = [] + + def func(self): + pass + + +class CmdRecall(Command): + """Search the mind palace for memories matching a query. + + Usage: + recall + recall --fleet + recall --room + + Examples: + recall nightly watch failures + recall GraphQL --fleet + recall CI pipeline --room forge + + The ``--fleet`` switch searches the shared fleet wing (closets only). + Without it, only the caller's private wing is searched. + """ + + key = "recall" + aliases = ["mem", "remember"] + locks = "cmd:all()" + help_category = "MemPalace" + + def func(self): + raw = self.args.strip() + if not raw: + self.caller.msg("Usage: recall [--fleet] [--room ]") + return + + fleet_mode = "--fleet" in self.switches + room_filter = None + if "--room" in self.switches: + # Grab the word after --room + parts = raw.split() + try: + room_filter = parts[parts.index("--room") + 1] + parts = [p for p in parts if p not in ("--room", room_filter)] + raw = " ".join(parts) + except (ValueError, IndexError): + pass + + # Strip inline switch tokens from query text + query = raw.replace("--fleet", "").strip() + if not query: + self.caller.msg("Please provide a search query.") + return + + wing = getattr(self.caller.db, "wing", None) or FLEET_WING + + try: + if fleet_mode: + results = search_fleet(query, room=room_filter) + header = f"|cFleet palace|n — searching all wings for: |w{query}|n" + else: + results = search_memories( + query, wing=wing, room=room_filter + ) + header = ( + f"|cPalace|n [{wing}] — searching for: |w{query}|n" + + (f" in room |y{room_filter}|n" if room_filter else "") + ) + except MemPalaceUnavailable as exc: + self.caller.msg(f"|rPalace unavailable:|n {exc}") + return + + if not results: + self.caller.msg(f"{header}\n|yNo memories found.|n") + return + + self.caller.msg(header) + for i, r in enumerate(results[:5], start=1): + wing_tag = f" |x[{r.wing}]|n" if fleet_mode and r.wing else "" + self.caller.msg( + f"|c{i}. {r.room}{wing_tag}|n (score {r.score:.2f})\n" + f" {r.short(240)}" + ) + + +class CmdEnterRoom(Command): + """Teleport to the palace room that best matches a topic. + + Usage: + enter room + + Examples: + enter room forge + enter room CI failures + enter room agent architecture + + If the topic matches a canonical room name exactly, you are + teleported there directly. Otherwise a semantic search finds + the closest room and you are taken there. + """ + + key = "enter room" + aliases = ["go to room", "palace room"] + locks = "cmd:all()" + help_category = "MemPalace" + + def func(self): + topic = self.args.strip() + if not topic: + self.caller.msg("Usage: enter room ") + rooms = ", ".join(f"|c{r}|n" for r in CORE_ROOMS) + self.caller.msg(f"Core palace rooms: {rooms}") + return + + # Resolve room name — exact match first, then semantic + if topic.lower() in CORE_ROOMS: + room_name = topic.lower() + else: + # Fuzzy: pick the room whose name is most similar + room_name = _closest_room(topic) + + # Try to find the in-game room object by key/alias + try: + from evennia.utils.search import search_object # type: ignore + matches = search_object( + room_name, + typeclass="nexus.evennia_mempalace.typeclasses.rooms.MemPalaceRoom", + ) + except Exception: + matches = [] + + if matches: + destination = matches[0] + self.caller.move_to(destination, quiet=False) + else: + self.caller.msg( + f"|yNo palace room found for '|w{room_name}|y'.|n\n" + "Ask the world administrator to create the room with the " + "|cMemPalaceRoom|n typeclass." + ) + + +_ROOM_KEYWORDS: dict[str, list[str]] = { + "forge": ["ci", "build", "pipeline", "deploy", "docker", "infra", "cron", "runner"], + "hermes": ["hermes", "agent", "gateway", "cli", "harness", "mcp", "session"], + "nexus": ["nexus", "report", "doc", "sitrep", "knowledge", "kt", "handoff"], + "issues": ["issue", "ticket", "bug", "pr", "backlog", "triage", "milestone"], + "experiments": ["experiment", "spike", "prototype", "bench", "research", "proof"], +} + + +def _closest_room(topic: str) -> str: + """Return the CORE_ROOMS name most similar to *topic*. + + Checks in order: + 1. Exact name match. + 2. Name substring in topic (or vice versa). + 3. Keyword synonym lookup. + """ + topic_lower = topic.lower() + topic_words = set(topic_lower.split()) + + for room in CORE_ROOMS: + if room == topic_lower or room in topic_lower or topic_lower in room: + return room + + for room, keywords in _ROOM_KEYWORDS.items(): + for kw in keywords: + if kw in topic_words or any(kw in w for w in topic_words): + return room + + return "general" diff --git a/nexus/evennia_mempalace/commands/write.py b/nexus/evennia_mempalace/commands/write.py new file mode 100644 index 00000000..a1645778 --- /dev/null +++ b/nexus/evennia_mempalace/commands/write.py @@ -0,0 +1,124 @@ +"""Evennia commands for writing new memories to the palace. + +CmdRecord — record decision → files into hall_facts +CmdNote — note breakthrough → files into hall_discoveries +CmdEvent — event → files into hall_events + +Phase 4 deliverable (see issue #1080). +""" + +from __future__ import annotations + +from nexus.mempalace.searcher import MemPalaceUnavailable, add_memory +from nexus.mempalace.config import FLEET_WING + +try: + from evennia import Command as _EvCommand # type: ignore + if _EvCommand is None: + raise ImportError("evennia.Command is None (Django not configured)") + Command = _EvCommand +except (ImportError, Exception): + class Command: # type: ignore + key = "" + aliases: list = [] + locks = "cmd:all()" + help_category = "MemPalace" + + def __init__(self): + self.caller = None + self.args = "" + self.switches: list[str] = [] + + def func(self): + pass + + +class _MemWriteCommand(Command): + """Base class for palace write commands.""" + + _room: str = "general" + _label: str = "memory" + + def func(self): + text = self.args.strip() + if not text: + self.caller.msg(f"Usage: {self.key} ") + return + + wing = getattr(self.caller.db, "wing", None) or FLEET_WING + try: + doc_id = add_memory( + text, + room=self._room, + wing=wing, + extra_metadata={"via": "evennia_cmd", "cmd": self.key}, + ) + except MemPalaceUnavailable as exc: + self.caller.msg(f"|rPalace unavailable:|n {exc}") + return + + self.caller.msg( + f"|gFiled {self._label} into |c{self._room}|g.|n (id: {doc_id[:8]}…)" + ) + + +class CmdRecord(_MemWriteCommand): + """Record a decision into the palace (hall_facts). + + Usage: + record + record decision + + Example: + record We decided to use ChromaDB for local palace storage. + + The text is filed into the ``hall_facts`` room of your wing and + becomes searchable via ``recall``. + """ + + key = "record" + aliases = ["record decision"] + locks = "cmd:all()" + help_category = "MemPalace" + _room = "hall_facts" + _label = "decision" + + +class CmdNote(_MemWriteCommand): + """File a breakthrough note into the palace (hall_discoveries). + + Usage: + note + note breakthrough + + Example: + note breakthrough AAAK compression reduces token cost by 40%. + + The text is filed into the ``hall_discoveries`` room of your wing. + """ + + key = "note" + aliases = ["note breakthrough"] + locks = "cmd:all()" + help_category = "MemPalace" + _room = "hall_discoveries" + _label = "breakthrough" + + +class CmdEvent(_MemWriteCommand): + """Log a significant event into the palace (hall_events). + + Usage: + event + + Example: + event Deployed Evennia bridge to production on Alpha. + + The text is filed into the ``hall_events`` room of your wing. + """ + + key = "event" + locks = "cmd:all()" + help_category = "MemPalace" + _room = "hall_events" + _label = "event" diff --git a/nexus/evennia_mempalace/typeclasses/__init__.py b/nexus/evennia_mempalace/typeclasses/__init__.py new file mode 100644 index 00000000..3bb7bd9d --- /dev/null +++ b/nexus/evennia_mempalace/typeclasses/__init__.py @@ -0,0 +1 @@ +"""MemPalace Evennia typeclasses.""" diff --git a/nexus/evennia_mempalace/typeclasses/__pycache__/__init__.cpython-312.pyc b/nexus/evennia_mempalace/typeclasses/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b8d97bf54c871d0b2c7892d4e4a7da0757eecf2f GIT binary patch literal 262 zcmX@j%ge<81U7tEGgX1~V-N=h7@>^M96-i&h7^V*(m@q-$bq?xO2x>}09yW@PDX?CNM~ z>0;odpO;!uTCAT6u{S<9HMaol5`82W=*P!r=4F<|$LkeT{^GF7%}*)KNwq6t13Coc UrDBkGJ}@&fGTvfPDPjR~05PRVng9R* literal 0 HcmV?d00001 diff --git a/nexus/evennia_mempalace/typeclasses/__pycache__/npcs.cpython-312.pyc b/nexus/evennia_mempalace/typeclasses/__pycache__/npcs.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ed7f43a8c8aab84a1edd7c734ac5e1878501dc9 GIT binary patch literal 6068 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 literal 0 HcmV?d00001 diff --git a/nexus/evennia_mempalace/typeclasses/__pycache__/rooms.cpython-312.pyc b/nexus/evennia_mempalace/typeclasses/__pycache__/rooms.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7a8171f065f566cec1e6283b3d44ab09fde97589 GIT binary patch literal 5510 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 NG<4TXUjzlH{|`S0Hkbea literal 0 HcmV?d00001 diff --git a/nexus/evennia_mempalace/typeclasses/npcs.py b/nexus/evennia_mempalace/typeclasses/npcs.py new file mode 100644 index 00000000..f0beb984 --- /dev/null +++ b/nexus/evennia_mempalace/typeclasses/npcs.py @@ -0,0 +1,138 @@ +"""StewardNPC — wizard steward that answers questions via palace search. + +Each wizard wing has a steward NPC that players can interrogate about +the wing's history. The NPC: + +1. Detects the topic from the player's question. +2. Calls ``search_memories`` with wing + optional room filters. +3. Formats the top results as an in-character response. + +Phase 3 deliverable (see issue #1079). +""" + +from __future__ import annotations + +from nexus.mempalace.searcher import MemPalaceUnavailable, search_memories +from nexus.mempalace.config import FLEET_WING + +try: + from evennia import DefaultCharacter as _EvDefaultCharacter # type: ignore + if _EvDefaultCharacter is None: + raise ImportError("evennia.DefaultCharacter is None") + DefaultCharacter = _EvDefaultCharacter +except (ImportError, Exception): + class DefaultCharacter: # type: ignore # minimal stub + db: object = None + key: str = "" + + def msg(self, text: str, **kwargs): + pass + + def execute_cmd(self, raw_string: str, **kwargs): + pass + + +# Steward response templates +_FOUND_TEMPLATE = ( + "|c{name}|n glances inward, consulting the palace...\n\n" + "I find {count} relevant {plural} about |w{topic}|n:\n\n" + "{memories}\n" + "|xType '|wrecall {topic}|x' to search further.|n" +) +_NOT_FOUND_TEMPLATE = ( + "|c{name}|n ponders a moment, then shakes their head.\n" + "\"I found nothing about |w{topic}|n in this wing's memory.\"" +) +_UNAVAILABLE_TEMPLATE = ( + "|c{name}|n frowns. \"The palace is unreachable right now.\"" +) + + +class StewardNPC(DefaultCharacter): + """An NPC that serves as the custodian of a wizard's memory wing. + + Attributes (set via ``npc.db.``): + steward_wing (str): The wizard wing this steward guards. + Defaults to ``FLEET_WING``. + steward_name (str): Display name used in responses. + Defaults to ``self.key``. + steward_n_results (int): How many memories to surface. + Default 3. + + Usage (from game):: + + > ask bezalel-steward about nightly watch failures + > ask steward about CI pipeline + """ + + # Evennia will call at_say when players speak near the NPC + def at_say(self, message: str, msg_type: str = "say", **kwargs): + """Intercept nearby speech that looks like a question.""" + super().at_say(message, msg_type=msg_type, **kwargs) + + def respond_to_question(self, question: str, asker=None) -> str: + """Answer a question by searching the wing's palace. + + Args: + question: The player's raw question text. + asker: The asking character object (used to personalise output). + + Returns: + Formatted response string. + """ + topic = _extract_topic(question) + wing = self.db.steward_wing or FLEET_WING + name = self.db.steward_name or self.key + n = self.db.steward_n_results or 3 + + try: + results = search_memories(topic, wing=wing, n_results=n) + except MemPalaceUnavailable: + return _UNAVAILABLE_TEMPLATE.format(name=name) + + if not results: + return _NOT_FOUND_TEMPLATE.format(name=name, topic=topic) + + memory_lines = [] + for i, r in enumerate(results, start=1): + memory_lines.append( + f"|w{i}. [{r.room}]|n {r.short(220)}" + ) + + return _FOUND_TEMPLATE.format( + name=name, + count=len(results), + plural="memory" if len(results) == 1 else "memories", + topic=topic, + memories="\n".join(memory_lines), + ) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +_QUESTION_PREFIXES = ( + "about ", "regarding ", "on ", "concerning ", + "related to ", "for ", "with ", "involving ", +) + + +def _extract_topic(question: str) -> str: + """Extract the key topic from a natural-language question. + + Strips common question prefixes so that the palace search receives + a clean keyword rather than noise words. + + Examples: + "about nightly watch failures" → "nightly watch failures" + "what do you know about the CI pipeline?" → "CI pipeline" + """ + q = question.strip().rstrip("?").strip() + # Remove leading question words + for prefix in ("what do you know ", "tell me ", "do you know "): + if q.lower().startswith(prefix): + q = q[len(prefix):] + for prep in _QUESTION_PREFIXES: + if q.lower().startswith(prep): + q = q[len(prep):] + break + return q or question.strip() diff --git a/nexus/evennia_mempalace/typeclasses/rooms.py b/nexus/evennia_mempalace/typeclasses/rooms.py new file mode 100644 index 00000000..0c1d41c6 --- /dev/null +++ b/nexus/evennia_mempalace/typeclasses/rooms.py @@ -0,0 +1,99 @@ +"""MemPalaceRoom — Evennia room typeclass backed by palace search. + +When a character enters a MemPalaceRoom, the room's description is +automatically refreshed from a live palace search for the room's +topic keyword. This makes the room "alive" — its contents reflect +what the fleet actually knows about that topic. + +Phase 1 deliverable (see issue #1077). +""" + +from __future__ import annotations + +from nexus.mempalace.searcher import MemPalaceUnavailable, search_memories +from nexus.mempalace.config import FLEET_WING + +try: + from evennia import DefaultRoom as _EvDefaultRoom # type: ignore + if _EvDefaultRoom is None: + raise ImportError("evennia.DefaultRoom is None") + DefaultRoom = _EvDefaultRoom +except (ImportError, Exception): + class DefaultRoom: # type: ignore # minimal stub for import/testing + """Stub for environments without Evennia installed.""" + + db: object = None + key: str = "" + + def return_appearance(self, looker): # noqa: D102 + return "" + + def at_object_receive(self, moved_obj, source_location, **kwargs): # noqa: D102 + pass + + +_PALACE_ROOM_HEADER = """|b═══════════════════════════════════════════════════|n +|c Mind Palace — {room_name}|n +|b═══════════════════════════════════════════════════|n""" + +_PALACE_ROOM_FOOTER = """|b───────────────────────────────────────────────────|n +|xType '|wrecall |x' to search deeper.|n""" + + +class MemPalaceRoom(DefaultRoom): + """An Evennia room whose description comes from the MemPalace. + + Attributes (set via ``room.db.``): + palace_topic (str): Search term used to populate the description. + Defaults to the room's key. + palace_wing (str): Wing to search. Defaults to fleet wing. + palace_n_results (int): How many memories to show. Default 3. + palace_room_filter (str): Optional room-name filter for the query. + """ + + def at_object_receive(self, moved_obj, source_location, **kwargs): + """Refresh palace content whenever someone enters.""" + super().at_object_receive(moved_obj, source_location, **kwargs) + # Only refresh for player-controlled characters + if hasattr(moved_obj, "account") and moved_obj.account: + self._refresh_palace_desc(viewer=moved_obj) + + def return_appearance(self, looker, **kwargs): + """Return description augmented with live palace memories.""" + self._refresh_palace_desc(viewer=looker) + return super().return_appearance(looker, **kwargs) + + # ── Internal helpers ────────────────────────────────────────────────── + + def _refresh_palace_desc(self, viewer=None): + """Update ``self.db.desc`` from a fresh palace query.""" + topic = self.db.palace_topic or self.key or "general" + wing = self.db.palace_wing or FLEET_WING + n = self.db.palace_n_results or 3 + room_filter = self.db.palace_room_filter + + try: + results = search_memories( + topic, wing=wing, room=room_filter, n_results=n + ) + except MemPalaceUnavailable: + self.db.desc = ( + f"[Palace unavailable — could not load memories for '{topic}'.]" + ) + return + + lines = [ + _PALACE_ROOM_HEADER.format(room_name=self.key), + ] + + if results: + for r in results: + lines.append(f"|w{r.room}|n |x(score {r.score:.2f})|n") + lines.append(f" {r.short(280)}") + lines.append("") + else: + lines.append(f"|yNo memories found for topic '|w{topic}|y'.|n") + lines.append("") + + lines.append(_PALACE_ROOM_FOOTER) + self.db.desc = "\n".join(lines) diff --git a/nexus/mempalace/__init__.py b/nexus/mempalace/__init__.py new file mode 100644 index 00000000..bcb9a5d5 --- /dev/null +++ b/nexus/mempalace/__init__.py @@ -0,0 +1,23 @@ +"""nexus.mempalace — MemPalace integration for the Nexus fleet. + +Public API for searching, configuring, and writing to MemPalace +local vector memory. Designed to be imported by both the +``evennia_mempalace`` plugin and any other harness component. + +ChromaDB is an optional runtime dependency; the module degrades +gracefully when it is not installed (tests, CI, environments that +have not yet set up the palace). +""" + +from __future__ import annotations + +from nexus.mempalace.config import MEMPALACE_PATH, FLEET_WING +from nexus.mempalace.searcher import search_memories, add_memory, MemPalaceResult + +__all__ = [ + "MEMPALACE_PATH", + "FLEET_WING", + "search_memories", + "add_memory", + "MemPalaceResult", +] diff --git a/nexus/mempalace/__pycache__/__init__.cpython-312.pyc b/nexus/mempalace/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..885a9ac5e545d646d43a6d93c952bb973c74ec1d GIT binary patch literal 932 zcmZ8gO>fgM7_9VFwB z`Pdum<_!v{^kb3Yp4I83IDW2;AbicK>}Jfo6>QpS=Y;=rx( z?o5h^_Bxm;(}_iFKhju{3D+zF96$^_0Pd%E^Usb%kc9TJ*^dB}YbGBiNf@U1cm^CZ zZJD?*6IYssp|N{igVJgpcX!t@@Hvx$M@C&4LaN;vokPAL1&x)tk~mjdBvEg=KW*A| zCtIf6@a8F~dLg^C-`^j2dtSFs2Hw%*%-z}R_m9Z4-GfKXQAXuO@ zTRn^R38*C0^|CA(4OzCOVoTMQnkfhM<*Z6bAbdhHmyk)K6A6UKqIsFET{t(*McU_V z*_DeO3tu2F=vWc6TZFtvCx#iF2Cb(Gq-xQarW{(wLO$0LK(+jkCIK`yH@0szHt+O0 zn>*XL8=lwcG;VHf_Zr?-r`_1O(e7^by>`2|(P`xlT4$%DMF`_e6Vi;+)7rg7pbz9_ sBWivGq^_FstLQk+@4}K(`n!glYd_HaujueII{Z_rEEir?<*MoV2XmSs7%Q6rTO@Pu8&=+oeg9f)-FK3^-0mAh-!Zw&R2Z$Bvp5B%^Az@r>g&>)mK} zoir9yu7uP^9Fhv8w5LivCCY&-hzl1^TwDwiUEo4b+(IobJu$QK#!)!+9p1e6=Kaom zGe7zL0w84he^n|y06d|JL*8S7Y40)s?15_lf&zdHWL2ih7FdSLoXQnAvhpflbroC; zU;%PfdYu&A^{^vCSKS8ybW>jNP;bOl`nrCOREaoJcZJknK%PU=52Zu4vv%}?4|-8A z^h^;m%?x`S&w~FsD+zWbzZ3tzN&&l)Kr6ur?EQi-1W82DnX=FaeWVjV-CqMR@Q^8l zV31h)sO32(4nen5yAZZ>hG~W^E_$x^GZ7Y1bxBs`A`*+bR#Mh#ST+@16aQ*_Ew0OE z*$`z7iX{~xQ#6oiD%zScEC@+eJrkf9BE-rD(!|v*5osF=*0n0qOmRcTioB{KLo{^J zEX$?cVgpRgc3gcV`VOW)_!{*dnV=i~X@T-0TG){wa_8-~T|hUMv8 zj>QqHy+w{0M;S~W6fo#Z5TC8auMwLOm)GSj4aGNg{Hci%GU7#5u0a$V9hrDLHu~;N zYIJtuomeuNO2x*3xo`3Z`PQ57wS3b}e!5M(fF&GX zMQ2P`Nylr*a+eXVBExdowJo<|7&W5$(2aG36^cot-lHmp`3bqT>z0ttU(HBY^ZCVP z>s%KJ>3lYuNw3W3b5^h`OS$A?CdyiFjLaI=F#Y195z8Yb zh_C~I!&Ig};xq45RfjbdP2)>MrB`eGNZ1j_Fw9fd#qj?I0Q2e-(DNr4I1)hb3wOqM z65EL{C+}T+PQP2N8BM`T}+D^DK2H4aVP(l(yojxsh*331vpJJyH6Uo_ z=(TKao-byYIR?Y2s+o$OHee&Q<*9B^J(JHd6Q-nKWkG*LcY>!IG6MYy$|uqA`c%RTWc7rWM1moGD#Pt1v`5 z4+P=uc_)vNcOs$Yl8MBx z$rkSNxjJ~s0C5>q^2$P18=TLvYbMh)V-Pr1P__OehmXJBf8={(BS%gjKh{4yJTlUM z^tIz-{ll+~oajIO-4mm)jSrtVF?M)lFst1t7=sxtWAAp*7H5qO?BUUS8rsvAal_?I{C;-h@{ zwl@(%EPe(|R zTx}l52NO~|eNnj9jhC|fsEaR{tqMh*tLj*Uo{JD zT8cc=uIt)7H43_k3Qs}F>Exv`nk$(3f;li%!1Mfj=~tjBVp_|x4*0ZO3>d2CL5n3r zOQ$SvM!Atlf;}?~JR+qmg2SA~6|hH((H*xpz?%=i2;(FaCGwRVTsA*ixV7+$H&=iB zNPe{(=~y{&=fp$*o^mLH?W^6Zv4^4kk3-*C4}E7N)c=WdJ@ooR&+A-Zr((j_{{(bm z5_=U_6~Ru!4*6MGJ9fw{052BAB7_B+0xOhAG+CrXg6+Ysy;vYW8O7pHu)wXd7A&}e z;Mo}XQ@g-f2FmQ8k$;npZ~6j1pC~tqNPB5~`SlH{z1-1N`u^?6hSc>%wEN!C&!dM+ zXP4Ct>F_s_>dxz<*!OXUMsZPrDykC=7&-4Fa&ax|?{r(Se?=ujO?j-NW_ zg?VyA{4tpqV(!J!y6n{*Rvzq}ZkXWvXfmzC>k!&k+Z)v-rpXr#FkF6W@dtGZJ005q zy->&w(Y&6oIXjx<-dUZc^k9x{?*hwVo5tAUp7XZTZdi15n5kSLs}9k{K_8H%2kPT# zM$c*o&@by*-PDyd`cL$!Hn{bs<0nv*NJVlvBme5@s04|> zsMo_ zTHmqZD9T{veX0|AqPnnL^1on~dk*$g1JU+a;7dp#I<%X}GyW9)HGV7t)0YA-pyqiI zHP!n+Pz#!9N~`M>1I0SjA0wA%33(rGWht~2UTP`ISJ8IvcucgS{3(Wt;az#-Xaou` z7$K-~jSD6kPyVR>V#^`~KDD#IU~Zr>w-|m9ykC`!+AM+mr797;lUP{k1>w7BhXV~g z=dKcrLXPR0K>-;jIsEPWprEmZK7i(+q;#0g8AH5=U%YFGPALFXlyrYu$<7rJM^{4) zbgJ38f%@`Knu~BhS+9^p4QV4t^g=PzyzU6jw;m1Ye_w& zCjrziDoj0KK&5#>#75sT6Nk`)+Q8fZgbcla7Us0xnVDE)^|~Gp(Mjcoo+)H#wvd^H zt>jY8yRjW~l})yHXyVj4FpW52h(`!Rjo`!vfHQ~atY(<~oa;2D10JbSLfqa&Xg5zX zL?==WP`}fQY)AR^%fS0<_y;uycaBbi8*%&e4QmF#NalB)K+I{dV<4E86JiNKmtMg8 zg$zb~+@Aq){Sddt52n;n46!sE)&2ix-mA5!ViMcou^{|)`|NCs}mhXjwO;z1LT)T<{%X05g@}Pf#u};wL}=j_F;p#sOK%0lFw^d z)kZr#NV1i^g(SCIZ+<}5kJDVXFS2}13!LB`*X9*a21{hhyydQnB_Qcs5&}Tvfx*z) zS|YSq5;h>r1_z0l3z@(Du@Pp-)|^C54cR0N2ERj$_W?(N3p@-i>ydo$?m%hmNuYDJ zZ6mO^bb7O;<8jO3^_IiGeR-p0xb*gBYg=jj3%6YE+_UUlPTdML*Ejda){cMLwH_HQ zo&Lh@FL%GZ>{_0`725Q5FZVt6b+6;U{i{EOqBOes%7N0EFXZ6u;Bjy|EYVk_ojb4`l(!^Iidsof1 zOZO%=dXD{`_?*65p=EJ-`u5SXC-hP9R`7|xryT7pN4r*jbmvE_sWp8gdbsg5yAh2& zY42GbUEBZh+Z*i%%6sVFo&EUi+T~A1HufAZcfIs1;0^@85xoEoL?B-2B>qTgiX+yq zQFLMsTMALg>%JSI8d_BF0>aiosBDjf8c4Dz6dezQ`{?W80c-0}C|q5WwKX8FfJh~k z)-h~sXjwSsf2w%BDClR>?hq70gE{S#rdz+ zaZ7C-z>9Sh2jhKL>qw+&1Y$@({Ny>|fwPtrh0%yFT*@g>mtj=}51oZroEx)+sx+hH zZeWgGB^!Or5S@kqq&gEDa2{q0Apuu2oVG|$gB1ov+V01%fOzrj#tvz=p~SlY5-29D zPD0|5XF@oh7{c7g(9BPHrfC4yuEsyfTsEc84cK|QcPFOK%ry4!zl1bR`4~V#)#MFiS<@wAsc;g3SMLaV{gJqI?W;Ga+Z zYzlHyw;@5*bGxq`h%BG2xQR3J3}RvzM$WCJNp2*;81r+Z4U740V~m+7-ugUP6X5qS z-F=zEx95R+BSZn0?VG!dQ_!ul7#4j{JdZ-Q!F+GB_P-Q19KIa$G%dn!eV4iHPk}%z zXx|Xu4j;*DTuAsFzVY{QJ(L-K)BNfd8{5Y0=ViVfJ5>?26KasfhiA^U>=lh8<=*l-lH z9k$Oy7sdr*N0RQj=M6jU{w7ml2zm}924zJQ1mUT}BS_!05nzdHpB4Pv>J1BTR1T4S2g==h@n5w4Y2cI) zDa*l%2=9uEw9<+jYaSwZR=imAVNJ%GAM++S9x^npo~;npPCH1?%jIatQ*TsoaXRn@ zIxxqLH4o0gKA;10WUL({E#0_!@2ikyEWZ+a8X6Q@AqiO#;f-^H6*t!GJz&j;`;xKd bC#}&+0Bb=K-B-bxl`wI6pS3`Z3-f;iJ_!ss literal 0 HcmV?d00001 diff --git a/nexus/mempalace/config.py b/nexus/mempalace/config.py new file mode 100644 index 00000000..f5155377 --- /dev/null +++ b/nexus/mempalace/config.py @@ -0,0 +1,46 @@ +"""MemPalace configuration — paths and fleet settings. + +All configuration is driven by environment variables so that +different wizards on different VPSes can use the same code with +their own palace directories. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# ── Palace path ────────────────────────────────────────────────────────────── +# Default: ~/.mempalace/palace/ (local wizard palace) +# Override via MEMPALACE_PATH env var (useful for fleet shared wing) +_default = Path.home() / ".mempalace" / "palace" +MEMPALACE_PATH: Path = Path(os.environ.get("MEMPALACE_PATH", str(_default))) + +# ── Fleet shared wing ───────────────────────────────────────────────────────── +# Path to the shared fleet palace on Alpha (used by --fleet searches) +_fleet_default = Path("/var/lib/mempalace/fleet") +FLEET_PALACE_PATH: Path = Path( + os.environ.get("FLEET_PALACE_PATH", str(_fleet_default)) +) + +# ── Wing name ───────────────────────────────────────────────────────────────── +# Identifies this wizard's wing within a shared palace. +# Populated from MEMPALACE_WING env var or falls back to system username. +def _default_wing() -> str: + import getpass + return os.environ.get("MEMPALACE_WING", getpass.getuser()) + +FLEET_WING: str = _default_wing() + +# ── Fleet rooms standard ───────────────────────────────────────────────────── +# Canonical rooms every wizard must have (see docs/mempalace/rooms.yaml) +CORE_ROOMS: list[str] = [ + "forge", # CI, builds, infra + "hermes", # agent platform, gateway, CLI + "nexus", # reports, docs, KT + "issues", # tickets, backlog + "experiments", # prototypes, spikes +] + +# ── ChromaDB collection name ────────────────────────────────────────────────── +COLLECTION_NAME: str = os.environ.get("MEMPALACE_COLLECTION", "palace") diff --git a/nexus/mempalace/searcher.py b/nexus/mempalace/searcher.py new file mode 100644 index 00000000..6cba1194 --- /dev/null +++ b/nexus/mempalace/searcher.py @@ -0,0 +1,200 @@ +"""MemPalace search and write interface. + +Wraps the ChromaDB-backed palace so that callers (Evennia commands, +harness agents, MCP tools) do not need to know the storage details. + +ChromaDB is imported lazily; if it is not installed the functions +raise ``MemPalaceUnavailable`` with an informative message rather +than crashing at import time. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from nexus.mempalace.config import ( + MEMPALACE_PATH, + FLEET_PALACE_PATH, + COLLECTION_NAME, +) + + +class MemPalaceUnavailable(RuntimeError): + """Raised when ChromaDB or the palace directory is not accessible.""" + + +@dataclass +class MemPalaceResult: + """A single memory hit returned by the searcher.""" + + text: str + room: str + wing: str + score: float = 0.0 + source_file: str = "" + metadata: dict = field(default_factory=dict) + + def short(self, max_chars: int = 200) -> str: + """Return a truncated preview suitable for MUD output.""" + if len(self.text) <= max_chars: + return self.text + return self.text[:max_chars].rstrip() + "…" + + +def _get_client(palace_path: Path): + """Return a ChromaDB persistent client, or raise MemPalaceUnavailable.""" + try: + import chromadb # type: ignore + except ImportError as exc: + raise MemPalaceUnavailable( + "ChromaDB is not installed. " + "Run: pip install chromadb (or: pip install mempalace)" + ) from exc + + if not palace_path.exists(): + raise MemPalaceUnavailable( + f"Palace directory not found: {palace_path}\n" + "Run 'mempalace mine' to initialise the palace." + ) + + return chromadb.PersistentClient(path=str(palace_path)) + + +def search_memories( + query: str, + *, + palace_path: Optional[Path] = None, + wing: Optional[str] = None, + room: Optional[str] = None, + n_results: int = 5, +) -> list[MemPalaceResult]: + """Search the palace for memories matching *query*. + + Args: + query: Natural-language search string. + palace_path: Override the default palace path. + wing: Filter results to a specific wizard's wing. + room: Filter results to a specific room (e.g. ``"forge"``). + n_results: Maximum number of results to return. + + Returns: + List of :class:`MemPalaceResult`, best-match first. + + Raises: + MemPalaceUnavailable: If ChromaDB is not installed or the palace + directory does not exist. + """ + path = palace_path or MEMPALACE_PATH + client = _get_client(path) + + collection = client.get_or_create_collection(COLLECTION_NAME) + + where: dict = {} + if wing: + where["wing"] = wing + if room: + where["room"] = room + + kwargs: dict = {"query_texts": [query], "n_results": n_results} + if where: + kwargs["where"] = where + + raw = collection.query(**kwargs) + + results: list[MemPalaceResult] = [] + if not raw or not raw.get("documents"): + return results + + docs = raw["documents"][0] + metas = raw.get("metadatas", [[]])[0] or [{}] * len(docs) + distances = raw.get("distances", [[]])[0] or [0.0] * len(docs) + + for doc, meta, dist in zip(docs, metas, distances): + results.append( + MemPalaceResult( + text=doc, + room=meta.get("room", "general"), + wing=meta.get("wing", ""), + score=float(1.0 - dist), # cosine similarity from distance + source_file=meta.get("source_file", ""), + metadata=meta, + ) + ) + + return results + + +def search_fleet( + query: str, + *, + room: Optional[str] = None, + n_results: int = 10, +) -> list[MemPalaceResult]: + """Search the shared fleet palace (closets only, no raw drawers). + + Args: + query: Natural-language search string. + room: Optional room filter (e.g. ``"issues"``). + n_results: Maximum results. + + Returns: + List of :class:`MemPalaceResult` from all wings. + """ + return search_memories( + query, + palace_path=FLEET_PALACE_PATH, + room=room, + n_results=n_results, + ) + + +def add_memory( + text: str, + *, + room: str = "general", + wing: Optional[str] = None, + palace_path: Optional[Path] = None, + source_file: str = "", + extra_metadata: Optional[dict] = None, +) -> str: + """Add a new memory drawer to the palace. + + Args: + text: The memory text to store. + room: Target room (e.g. ``"hall_facts"``). + wing: Wing name; defaults to :data:`~nexus.mempalace.config.FLEET_WING`. + palace_path: Override the default palace path. + source_file: Optional source file attribution. + extra_metadata: Additional key/value metadata to store. + + Returns: + The generated document ID. + + Raises: + MemPalaceUnavailable: If ChromaDB is not installed or the palace + directory does not exist. + """ + import uuid + from nexus.mempalace.config import FLEET_WING + + path = palace_path or MEMPALACE_PATH + client = _get_client(path) + collection = client.get_or_create_collection(COLLECTION_NAME) + + doc_id = str(uuid.uuid4()) + metadata: dict = { + "room": room, + "wing": wing or FLEET_WING, + "source_file": source_file, + } + if extra_metadata: + metadata.update(extra_metadata) + + collection.add( + documents=[text], + metadatas=[metadata], + ids=[doc_id], + ) + return doc_id diff --git a/tests/test_evennia_mempalace_commands.py b/tests/test_evennia_mempalace_commands.py new file mode 100644 index 00000000..b296fca4 --- /dev/null +++ b/tests/test_evennia_mempalace_commands.py @@ -0,0 +1,244 @@ +"""Tests for nexus.evennia_mempalace commands and NPC helpers.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from nexus.evennia_mempalace.commands.recall import CmdRecall, CmdEnterRoom, _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 + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _make_caller(wing: str = "bezalel"): + """Build a minimal mock Evennia caller.""" + caller = MagicMock() + caller.db = MagicMock() + caller.db.wing = wing + caller.account = MagicMock() + return caller + + +def _make_cmd(cls, args: str = "", switches: list | None = None, wing: str = "bezalel"): + """Instantiate an Evennia command mock and wire it up.""" + cmd = cls() + cmd.caller = _make_caller(wing) + cmd.args = args + cmd.switches = switches or [] + return cmd + + +# ── CmdRecall ──────────────────────────────────────────────────────────────── + + +def test_recall_no_args_shows_usage(): + cmd = _make_cmd(CmdRecall, args="") + cmd.func() + cmd.caller.msg.assert_called_once() + assert "Usage" in cmd.caller.msg.call_args[0][0] + + +def test_recall_calls_search_memories(): + results = [ + MemPalaceResult(text="CI pipeline failed", room="forge", wing="bezalel", score=0.9) + ] + with patch("nexus.evennia_mempalace.commands.recall.search_memories", return_value=results): + cmd = _make_cmd(CmdRecall, args="CI failures") + cmd.func() + + calls = [c[0][0] for c in cmd.caller.msg.call_args_list] + assert any("CI pipeline failed" in c for c in calls) + + +def test_recall_fleet_flag_calls_search_fleet(): + results = [ + MemPalaceResult(text="Fleet doc", room="nexus", wing="timmy", score=0.8) + ] + with patch("nexus.evennia_mempalace.commands.recall.search_fleet", return_value=results) as mock_fleet: + cmd = _make_cmd(CmdRecall, args="architecture --fleet", switches=["--fleet"]) + cmd.func() + + mock_fleet.assert_called_once() + query_arg = mock_fleet.call_args[0][0] + assert "--fleet" not in query_arg + assert "architecture" in query_arg + + +def test_recall_unavailable_shows_error(): + with patch( + "nexus.evennia_mempalace.commands.recall.search_memories", + side_effect=MemPalaceUnavailable("ChromaDB not installed"), + ): + cmd = _make_cmd(CmdRecall, args="anything") + cmd.func() + + msg = cmd.caller.msg.call_args[0][0] + assert "unavailable" in msg.lower() + + +def test_recall_no_results_shows_no_memories(): + with patch("nexus.evennia_mempalace.commands.recall.search_memories", return_value=[]): + cmd = _make_cmd(CmdRecall, args="obscure query") + cmd.func() + + calls = " ".join(c[0][0] for c in cmd.caller.msg.call_args_list) + assert "No memories" in calls + + +# ── _closest_room ───────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("topic,expected", [ + ("forge", "forge"), + ("CI pipeline", "forge"), + ("hermes agent", "hermes"), + ("nexus report", "nexus"), + ("issue triage", "issues"), + ("spike experiment", "experiments"), + ("totally unknown topic xyz", "general"), +]) +def test_closest_room(topic, expected): + assert _closest_room(topic) == expected + + +# ── CmdEnterRoom ────────────────────────────────────────────────────────────── + + +def test_enter_room_no_args_shows_usage(): + cmd = _make_cmd(CmdEnterRoom, args="") + cmd.func() + output = " ".join(c[0][0] for c in cmd.caller.msg.call_args_list) + assert "Usage" in output + assert "forge" in output # shows core rooms + + +def test_enter_room_exact_match_no_room_found(): + """When an exact room name is given but no room exists, show a help message.""" + # evennia.utils.search raises Django config errors outside a live server; + # CmdEnterRoom catches all exceptions and falls back to a help message. + cmd = _make_cmd(CmdEnterRoom, args="forge") + cmd.func() + assert cmd.caller.msg.called + output = " ".join(c[0][0] for c in cmd.caller.msg.call_args_list) + # Should mention the room name or MemPalaceRoom typeclass + assert "forge" in output or "MemPalaceRoom" in output or "No palace room" in output + + +# ── Write commands ─────────────────────────────────────────────────────────── + + +def test_record_no_args_shows_usage(): + cmd = _make_cmd(CmdRecord, args="") + cmd.func() + assert "Usage" in cmd.caller.msg.call_args[0][0] + + +def test_record_calls_add_memory(): + with patch("nexus.evennia_mempalace.commands.write.add_memory", return_value="fake-uuid-1234-5678-abcd") as mock_add: + cmd = _make_cmd(CmdRecord, args="Use ChromaDB for storage.") + cmd.func() + + mock_add.assert_called_once() + kwargs = mock_add.call_args[1] + assert kwargs["room"] == "hall_facts" + assert "ChromaDB" in mock_add.call_args[0][0] + + +def test_note_files_to_hall_discoveries(): + with patch("nexus.evennia_mempalace.commands.write.add_memory", return_value="uuid") as mock_add: + cmd = _make_cmd(CmdNote, args="AAAK reduces cost by 40%.") + cmd.func() + + assert mock_add.call_args[1]["room"] == "hall_discoveries" + + +def test_event_files_to_hall_events(): + with patch("nexus.evennia_mempalace.commands.write.add_memory", return_value="uuid") as mock_add: + cmd = _make_cmd(CmdEvent, args="Deployed Evennia bridge to Alpha.") + cmd.func() + + assert mock_add.call_args[1]["room"] == "hall_events" + + +def test_write_command_unavailable_shows_error(): + with patch( + "nexus.evennia_mempalace.commands.write.add_memory", + side_effect=MemPalaceUnavailable("no palace"), + ): + cmd = _make_cmd(CmdRecord, args="some text") + cmd.func() + + msg = cmd.caller.msg.call_args[0][0] + assert "unavailable" in msg.lower() + + +# ── _extract_topic ──────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("question,expected_substring", [ + ("about nightly watch failures", "nightly watch failures"), + ("what do you know about CI pipeline?", "CI pipeline"), + ("tell me about hermes", "hermes"), + ("regarding the forge build", "forge build"), + ("nightly watch failures", "nightly watch failures"), +]) +def test_extract_topic(question, expected_substring): + result = _extract_topic(question) + assert expected_substring.lower() in result.lower() + + +# ── StewardNPC.respond_to_question ─────────────────────────────────────────── + + +def test_steward_responds_with_results(): + npc = StewardNPC() + npc.db = MagicMock() + npc.db.steward_wing = "bezalel" + npc.db.steward_name = "Bezalel-Steward" + npc.db.steward_n_results = 3 + npc.key = "steward" + + results = [ + MemPalaceResult(text="Three failures last week.", room="forge", wing="bezalel", score=0.95) + ] + with patch("nexus.evennia_mempalace.typeclasses.npcs.search_memories", return_value=results): + response = npc.respond_to_question("about nightly watch failures") + + assert "Bezalel-Steward" in response + assert "Three failures" in response + + +def test_steward_responds_not_found(): + npc = StewardNPC() + npc.db = MagicMock() + npc.db.steward_wing = "bezalel" + npc.db.steward_name = "Steward" + npc.db.steward_n_results = 3 + npc.key = "steward" + + with patch("nexus.evennia_mempalace.typeclasses.npcs.search_memories", return_value=[]): + response = npc.respond_to_question("about unknown_topic_xyz") + + assert "nothing" in response.lower() or "found" in response.lower() + + +def test_steward_responds_unavailable(): + npc = StewardNPC() + npc.db = MagicMock() + npc.db.steward_wing = "bezalel" + npc.db.steward_name = "Steward" + npc.db.steward_n_results = 3 + npc.key = "steward" + + with patch( + "nexus.evennia_mempalace.typeclasses.npcs.search_memories", + side_effect=MemPalaceUnavailable("no palace"), + ): + response = npc.respond_to_question("about anything") + + assert "unreachable" in response.lower() diff --git a/tests/test_mempalace_searcher.py b/tests/test_mempalace_searcher.py new file mode 100644 index 00000000..16143867 --- /dev/null +++ b/tests/test_mempalace_searcher.py @@ -0,0 +1,190 @@ +"""Tests for nexus.mempalace.searcher and nexus.mempalace.config.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from nexus.mempalace.config import CORE_ROOMS, MEMPALACE_PATH, COLLECTION_NAME +from nexus.mempalace.searcher import ( + MemPalaceResult, + MemPalaceUnavailable, + _get_client, + search_memories, + add_memory, +) + + +# ── MemPalaceResult ────────────────────────────────────────────────────────── + + +def test_result_short_truncates(): + r = MemPalaceResult(text="x" * 300, room="forge", wing="bezalel") + short = r.short(200) + assert len(short) <= 204 # 200 + ellipsis + assert short.endswith("…") + + +def test_result_short_no_truncation_needed(): + r = MemPalaceResult(text="hello", room="nexus", wing="bezalel") + assert r.short() == "hello" + + +def test_result_defaults(): + r = MemPalaceResult(text="test", room="general", wing="") + assert r.score == 0.0 + assert r.source_file == "" + assert r.metadata == {} + + +# ── Config ─────────────────────────────────────────────────────────────────── + + +def test_core_rooms_contains_required_rooms(): + required = {"forge", "hermes", "nexus", "issues", "experiments"} + assert required.issubset(set(CORE_ROOMS)) + + +def test_mempalace_path_env_override(monkeypatch, tmp_path): + monkeypatch.setenv("MEMPALACE_PATH", str(tmp_path)) + # Re-import to pick up env var (config reads at import time so we patch) + import importlib + import nexus.mempalace.config as cfg + importlib.reload(cfg) + assert Path(os.environ["MEMPALACE_PATH"]) == tmp_path + importlib.reload(cfg) # restore + + +# ── _get_client ────────────────────────────────────────────────────────────── + + +def test_get_client_raises_when_chromadb_missing(tmp_path): + with patch.dict("sys.modules", {"chromadb": None}): + with pytest.raises(MemPalaceUnavailable, match="ChromaDB"): + _get_client(tmp_path) + + +def test_get_client_raises_when_path_missing(tmp_path): + missing = tmp_path / "nonexistent_palace" + # chromadb importable but path missing + mock_chroma = MagicMock() + with patch.dict("sys.modules", {"chromadb": mock_chroma}): + with pytest.raises(MemPalaceUnavailable, match="Palace directory"): + _get_client(missing) + + +# ── search_memories ────────────────────────────────────────────────────────── + + +def _make_mock_collection(docs, metas=None, distances=None): + """Build a mock ChromaDB collection that returns canned results.""" + if metas is None: + metas = [{"room": "forge", "wing": "bezalel", "source_file": ""} for _ in docs] + if distances is None: + distances = [0.1 * i for i in range(len(docs))] + + collection = MagicMock() + collection.query.return_value = { + "documents": [docs], + "metadatas": [metas], + "distances": [distances], + } + return collection + + +def _mock_chroma_client(collection): + client = MagicMock() + client.get_or_create_collection.return_value = collection + return client + + +def test_search_memories_returns_results(tmp_path): + docs = ["CI pipeline failed on main", "Forge build log 2026-04-01"] + collection = _make_mock_collection(docs) + mock_chroma = MagicMock() + mock_chroma.PersistentClient.return_value = _mock_chroma_client(collection) + + with patch.dict("sys.modules", {"chromadb": mock_chroma}): + # Palace path must exist for _get_client check + (tmp_path / "chroma.sqlite3").touch() + results = search_memories("CI failures", palace_path=tmp_path) + + assert len(results) == 2 + assert results[0].room == "forge" + assert results[0].wing == "bezalel" + assert "CI pipeline" in results[0].text + + +def test_search_memories_empty_collection(tmp_path): + collection = MagicMock() + collection.query.return_value = {"documents": [[]], "metadatas": [[]], "distances": [[]]} + mock_chroma = MagicMock() + mock_chroma.PersistentClient.return_value = _mock_chroma_client(collection) + + with patch.dict("sys.modules", {"chromadb": mock_chroma}): + (tmp_path / "chroma.sqlite3").touch() + results = search_memories("anything", palace_path=tmp_path) + + assert results == [] + + +def test_search_memories_with_wing_filter(tmp_path): + docs = ["test doc"] + collection = _make_mock_collection(docs) + mock_chroma = MagicMock() + mock_chroma.PersistentClient.return_value = _mock_chroma_client(collection) + + with patch.dict("sys.modules", {"chromadb": mock_chroma}): + (tmp_path / "chroma.sqlite3").touch() + search_memories("query", palace_path=tmp_path, wing="bezalel") + + call_kwargs = collection.query.call_args[1] + assert call_kwargs["where"] == {"wing": "bezalel"} + + +def test_search_memories_with_room_filter(tmp_path): + collection = _make_mock_collection(["doc"]) + mock_chroma = MagicMock() + mock_chroma.PersistentClient.return_value = _mock_chroma_client(collection) + + with patch.dict("sys.modules", {"chromadb": mock_chroma}): + (tmp_path / "chroma.sqlite3").touch() + search_memories("query", palace_path=tmp_path, room="forge") + + call_kwargs = collection.query.call_args[1] + assert call_kwargs["where"] == {"room": "forge"} + + +def test_search_memories_unavailable(tmp_path): + with patch.dict("sys.modules", {"chromadb": None}): + with pytest.raises(MemPalaceUnavailable): + search_memories("anything", palace_path=tmp_path) + + +# ── add_memory ─────────────────────────────────────────────────────────────── + + +def test_add_memory_returns_id(tmp_path): + collection = MagicMock() + mock_chroma = MagicMock() + mock_chroma.PersistentClient.return_value = _mock_chroma_client(collection) + + with patch.dict("sys.modules", {"chromadb": mock_chroma}): + (tmp_path / "chroma.sqlite3").touch() + doc_id = add_memory( + "We decided to use ChromaDB.", + room="hall_facts", + wing="bezalel", + palace_path=tmp_path, + ) + + assert isinstance(doc_id, str) + assert len(doc_id) == 36 # UUID format + collection.add.assert_called_once() + call_kwargs = collection.add.call_args[1] + assert call_kwargs["documents"] == ["We decided to use ChromaDB."] + assert call_kwargs["metadatas"][0]["room"] == "hall_facts" + assert call_kwargs["metadatas"][0]["wing"] == "bezalel"