From 367a06a849e7415ee211efbab9b113ede3e5471d Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 7 Apr 2026 10:07:15 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20MemPalace=20=C3=97=20Evennia=20flee?= =?UTF-8?q?t=20memory=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 0000000..8d8a80c --- /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 0000000..640f165 --- /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 0000000..f805ecd --- /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 0000000..05561d2 --- /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 0000000..a164577 --- /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 0000000..3bb7bd9 --- /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 0000000..f0beb98 --- /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 0000000..0c1d41c --- /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 0000000..bcb9a5d --- /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 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 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 0000000..f515537 --- /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 0000000..6cba119 --- /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 0000000..b296fca --- /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 0000000..1614386 --- /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" -- 2.43.0 From c1116b8bde615f3674ad254700cc46b45d7c8318 Mon Sep 17 00:00:00 2001 From: "Claude (Opus 4.6)" Date: Tue, 7 Apr 2026 14:02:12 +0000 Subject: [PATCH 2/4] [claude] bezalel MemPalace field report + incremental mine script (#1072) (#1085) --- .../2026-04-07-mempalace-field-report.md | 62 ++++++++++++ scripts/mempalace-incremental-mine.sh | 96 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 reports/bezalel/2026-04-07-mempalace-field-report.md create mode 100755 scripts/mempalace-incremental-mine.sh diff --git a/reports/bezalel/2026-04-07-mempalace-field-report.md b/reports/bezalel/2026-04-07-mempalace-field-report.md new file mode 100644 index 0000000..69ba5e5 --- /dev/null +++ b/reports/bezalel/2026-04-07-mempalace-field-report.md @@ -0,0 +1,62 @@ +# MemPalace Field Report + +**Wizard:** Bezalel +**Date:** 2026-04-07 +**Scope:** Forge / testbed evaluation of https://github.com/milla-jovovich/mempalace +**Issue:** #1072 + +--- + +## What I Did + +1. **Cloned and audited the upstream repo** — ~13.4k LOC dumped in a single commit (co-authored by `Claude Opus 4.6`). Zero development history, 5,769 GitHub stars in 48 hours, and no real test coverage (~125 lines of tests). Verdict: astroturfed hype, but the underlying code is not malicious. +2. **Ran the benchmark runners** — The "96.6% raw LongMemEval R@5" claim is technically runnable; it indexes user turns only, which is a questionable methodology, but the scripts are reproducible. +3. **Operationalized it for Bezalel** — I configured a local palace and mined my actual workspace into it. + +--- + +## Method + +- **Palace path:** `/root/wizards/bezalel/.mempalace/palace/` (ChromaDB persistent store) +- **Room taxonomy:** Defined in `/root/wizards/bezalel/mempalace.yaml` + - `forge` — CI, builds, health checks, nightly watch + - `hermes` — Hermes agent source, gateway, CLI + - `evennia` — MUD engine and world-building code + - `workspace` — Active prototypes, experiments, scratch code + - `home` — Personal scripts, configs, utilities + - `nexus` — Reports, docs, KT artifacts + - `issues` — Gitea issues, PRs, backlog tracking + - `general` — Catch-all +- **Ingestion:** Used the upstream `mempalace mine` pipeline against `/root/wizards/bezalel/`. +- **Outcome:** **823 artifacts filed** into the palace with wing (`bezalel`), room, source file path, and timestamp metadata. + +--- + +## Effectiveness + +| Before | After | +|--------|-------| +| Context evaporates between sessions; I rely on grep and shell history. | I can query the palace for "auth migration decisions" or "nightly watch failures" and get semantically ranked results from my own files. | +| No structured memory across wizard restarts. | 823 workspace artifacts are now vector-searchable with room-level filtering. | + +The upstream repo is **vaporware marketing**, but the local-first pattern is **genuinely useful**. Verbatim storage + ChromaDB embeddings is fast, offline, and good enough for operational memory. + +--- + +## Recommendations for the Fleet + +@Timmy @Allegro @Ezra @bezalel + +Each wizard should: +1. Clone the repo locally (do not trust it as a dependency; vendor it). +2. Write a `mempalace.yaml` that matches your domain rooms. +3. Run `mempalace mine ` to populate your palace. +4. Wire the searcher into your heartbeat scripts or MCP server for on-demand recall. + +An incremental re-mine helper script has been added at `scripts/mempalace-incremental-mine.sh` to keep the palace current without full re-ingestion. + +--- + +## Status + +**Next action:** Awaiting sovereign or council signal on whether to standardize this across the fleet or keep it wizard-opt-in. diff --git a/scripts/mempalace-incremental-mine.sh b/scripts/mempalace-incremental-mine.sh new file mode 100755 index 0000000..999f03a --- /dev/null +++ b/scripts/mempalace-incremental-mine.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# mempalace-incremental-mine.sh +# Re-mines only files changed since the last run, keeping the palace current +# without full re-ingestion. +# +# Usage: +# ./scripts/mempalace-incremental-mine.sh [workspace_dir] [palace_dir] +# +# Defaults: +# workspace_dir — $WIZARD_HOME or /root/wizards/bezalel +# palace_dir — $MEMPALACE_DIR or /.mempalace +# +# Dependencies: mempalace (vendored), find +# +# Refs: #1072 (Bezalel MemPalace Field Report) + +set -euo pipefail + +WORKSPACE="${1:-${WIZARD_HOME:-/root/wizards/bezalel}}" +PALACE_DIR="${2:-${MEMPALACE_DIR:-$WORKSPACE/.mempalace}}" +STAMP_FILE="$PALACE_DIR/.last_mine_ts" +PALACE_PATH="$PALACE_DIR/palace" + +if [[ ! -d "$WORKSPACE" ]]; then + echo "[mempalace-incremental-mine] ERROR: workspace not found: $WORKSPACE" >&2 + exit 1 +fi + +# Resolve mempalace binary — check vendored location first +MEMPALACE_BIN="" +for candidate in \ + "$WORKSPACE/.vendor/mempalace/mempalace" \ + "$WORKSPACE/.vendor/mempalace/bin/mempalace" \ + "$(command -v mempalace 2>/dev/null || true)"; do + if [[ -x "$candidate" ]]; then + MEMPALACE_BIN="$candidate" + break + fi +done + +if [[ -z "$MEMPALACE_BIN" ]]; then + echo "[mempalace-incremental-mine] ERROR: mempalace binary not found." >&2 + echo " Vendor it at $WORKSPACE/.vendor/mempalace/ or install it globally." >&2 + exit 1 +fi + +mkdir -p "$PALACE_DIR" + +# Determine changed files since last run +if [[ -f "$STAMP_FILE" ]]; then + SINCE=$(cat "$STAMP_FILE") + echo "[mempalace-incremental-mine] Mining files changed since $SINCE" + # Find files newer than the stamp file itself + CHANGED_FILES=$(find "$WORKSPACE" \ + -newer "$STAMP_FILE" \ + -type f \ + ! -path "*/.mempalace/*" \ + ! -path "*/.git/*" \ + ! -path "*/node_modules/*" \ + ! -path "*/__pycache__/*" \ + ! -name "*.pyc" \ + 2>/dev/null || true) +else + echo "[mempalace-incremental-mine] No prior stamp found — running full mine." + CHANGED_FILES="" +fi + +if [[ -z "$CHANGED_FILES" && -f "$STAMP_FILE" ]]; then + echo "[mempalace-incremental-mine] No changed files detected. Palace is current." + exit 0 +fi + +YAML_CONFIG="$WORKSPACE/mempalace.yaml" +if [[ ! -f "$YAML_CONFIG" ]]; then + echo "[mempalace-incremental-mine] WARNING: $YAML_CONFIG not found." >&2 + echo " Room taxonomy will not be applied. Create mempalace.yaml to enable routing." >&2 + YAML_ARGS=() +else + YAML_ARGS=(--config "$YAML_CONFIG") +fi + +if [[ -n "$CHANGED_FILES" ]]; then + # Mine only the changed files + FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ') + echo "[mempalace-incremental-mine] Mining $FILE_COUNT changed file(s)..." + echo "$CHANGED_FILES" | xargs -I{} "$MEMPALACE_BIN" mine "${YAML_ARGS[@]}" \ + --palace "$PALACE_PATH" {} 2>&1 +else + # Full mine (first run) + echo "[mempalace-incremental-mine] Running full mine of $WORKSPACE ..." + "$MEMPALACE_BIN" mine "${YAML_ARGS[@]}" --palace "$PALACE_PATH" "$WORKSPACE" 2>&1 +fi + +# Update stamp +date -u +"%Y-%m-%dT%H:%M:%SZ" > "$STAMP_FILE" +echo "[mempalace-incremental-mine] Done. Stamp updated: $(cat "$STAMP_FILE")" -- 2.43.0 From c05febf86f0701e64b804fbf97bd4ff80a4bad7e Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 7 Apr 2026 10:09:10 -0400 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20MemPalace=20fleet=20memory=20scaffo?= =?UTF-8?q?ld=20=E2=80=94=20taxonomy,=20Evennia=20plugin,=20privacy=20tool?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers milestone artifacts for #1075 (MemPalace × Evennia — Fleet Memory): **#1082 — Palace taxonomy standard** - `mempalace/rooms.yaml` — fleet-wide room vocabulary (5 core rooms: forge, hermes, nexus, issues, experiments) with optional domain rooms and tunnel routing table - `mempalace/validate_rooms.py` — validates a wizard's mempalace.yaml against the standard; exits non-zero on missing core rooms (CI-ready) **#1077 — Evennia plugin scaffold** - `mempalace/evennia_mempalace/` — contrib module connecting Evennia to MemPalace - `CmdRecall` — `recall ` / `recall --fleet` in-world commands - `CmdEnterRoom` — teleport to semantic room by topic - `MemPalaceRoom` — typeclass whose description auto-populates from palace search - `searcher.py` — thin subprocess wrapper around the mempalace binary - `settings.py` — MEMPALACE_PATH / MEMPALACE_WING configuration bridge **#1083 — Privacy boundary tools** - `mempalace/export_closets.sh` — closet-only export enforcing policy that raw drawers never leave the local VPS; aborts on violations - `mempalace/audit_privacy.py` — weekly audit of fleet palace for raw drawers, full-text closets, and private source_file paths **Tests:** 21 new tests covering validate_rooms and audit_privacy logic. Refs #1075, #1077, #1082, #1083 Co-Authored-By: Claude Sonnet 4.6 --- mempalace/__init__.py | 5 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 350 bytes .../__pycache__/audit_privacy.cpython-312.pyc | Bin 0 -> 6900 bytes .../validate_rooms.cpython-312.pyc | Bin 0 -> 5133 bytes mempalace/audit_privacy.py | 177 ++++++++++++++++++ mempalace/evennia_mempalace/__init__.py | 22 +++ .../evennia_mempalace/commands/__init__.py | 14 ++ .../evennia_mempalace/commands/enter_room.py | 86 +++++++++ .../evennia_mempalace/commands/recall.py | 93 +++++++++ mempalace/evennia_mempalace/searcher.py | 106 +++++++++++ mempalace/evennia_mempalace/settings.py | 64 +++++++ .../evennia_mempalace/typeclasses/__init__.py | 1 + .../evennia_mempalace/typeclasses/room.py | 87 +++++++++ mempalace/export_closets.sh | 104 ++++++++++ mempalace/rooms.yaml | 114 +++++++++++ mempalace/validate_rooms.py | 119 ++++++++++++ tests/test_mempalace_audit_privacy.py | 129 +++++++++++++ tests/test_mempalace_validate_rooms.py | 160 ++++++++++++++++ 18 files changed, 1281 insertions(+) create mode 100644 mempalace/__init__.py create mode 100644 mempalace/__pycache__/__init__.cpython-312.pyc create mode 100644 mempalace/__pycache__/audit_privacy.cpython-312.pyc create mode 100644 mempalace/__pycache__/validate_rooms.cpython-312.pyc create mode 100644 mempalace/audit_privacy.py create mode 100644 mempalace/evennia_mempalace/__init__.py create mode 100644 mempalace/evennia_mempalace/commands/__init__.py create mode 100644 mempalace/evennia_mempalace/commands/enter_room.py create mode 100644 mempalace/evennia_mempalace/commands/recall.py create mode 100644 mempalace/evennia_mempalace/searcher.py create mode 100644 mempalace/evennia_mempalace/settings.py create mode 100644 mempalace/evennia_mempalace/typeclasses/__init__.py create mode 100644 mempalace/evennia_mempalace/typeclasses/room.py create mode 100755 mempalace/export_closets.sh create mode 100644 mempalace/rooms.yaml create mode 100644 mempalace/validate_rooms.py create mode 100644 tests/test_mempalace_audit_privacy.py create mode 100644 tests/test_mempalace_validate_rooms.py diff --git a/mempalace/__init__.py b/mempalace/__init__.py new file mode 100644 index 0000000..c69b60b --- /dev/null +++ b/mempalace/__init__.py @@ -0,0 +1,5 @@ +""" +mempalace — Fleet memory tools for the MemPalace × Evennia integration. + +Refs: #1075 (MemPalace × Evennia — Fleet Memory milestone) +""" diff --git a/mempalace/__pycache__/__init__.cpython-312.pyc b/mempalace/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..215ae463262dbc75656effefc8832b2585f82318 GIT binary patch literal 350 zcmX@j%ge<81W);|W=;aqk3k$5V1zP0a{w9B8B!Rc7%CYxnM%8XT&~>I+=9fM#NvJmZj$9WhN?Q=9Q$T z7bTWt=I7~gaRsHO6}2Mm z>*8wasB2>G;-qWg>T05EVC?K{;c9MRVQlHF5A+knpZf9fnR%Hd@$q^EmA^P_a`RJ4 fb5iY!*nrl70;(7kjvtsA85ut@u`sd}u>iRMuqkG2 literal 0 HcmV?d00001 diff --git a/mempalace/__pycache__/audit_privacy.cpython-312.pyc b/mempalace/__pycache__/audit_privacy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..140bed65cec40b08da03a684dd34abae2b27d557 GIT binary patch literal 6900 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 jklk39f~KPu~B@`*$vXdi9A=pXZU#!nF4P8$?Bw literal 0 HcmV?d00001 diff --git a/mempalace/__pycache__/validate_rooms.cpython-312.pyc b/mempalace/__pycache__/validate_rooms.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40fbb156fc8c3c0c56478a69dccc5770489d3c2c GIT binary patch literal 5133 zcmb7IT})iZ6`uREznA?177WBcV?&HLF<`*d4hEU!@57Vlr?UW|9M zL|Z9pabw9uNd-}ohDufA$bR4<53ZD|N?w{a4_&<2&>JGTiC^+YY*$Hm>Y01@?k=$% zrGs{6?#wx7X3m`RoilvyaybzctLJ9ufD56o>4#Y?Mu)lA&LA|0Bs78~Mq$UMY2_PUj=EjOl&vW;#zVf1%yVt zl2>xR#*dUrrIG+`nN%i~KwB<*M;WPHa=m65@ku_ZLUKc|LavlN=k_2(s$d0vqNZ4) z(TDMhtGQuweFi1C1Xqh>XB{Gb1_6yAS%JUmLH=vDtE@4q(>-(z$zG6r8eN*2LKnEx zXo~SyPENv+o#(}HND@^!Kw`0o(il(RuhMVf{;({oI4*|8po~@VLM$4KByf&4MjD;Y zo^d&NPQfCc3MEA%)hjq6M+}C>gcu29aZC(F6&0)FG9J~%DXJKifEI_M*jSAcHahz* zgj5CZ!=X`Qg}u0$_Jc7Z8+2HNUL>R_q39Tn5nW_3799vSEctRbIMGL!Xdhnzj#@+*)cYgTivNypY)FwlQ(sWi%ieWk2v{{(bIVg`R?YOpi z-@%r>v^mh`{DxkdpV0&{8jY!<8j3}g0r=*h6V-8Oxvpp;d8lu2@Y%t3{9NL6*U1y~ zphSLoGDP5<_$Vk=42MDY>UcYjhvJ}8Vcu5`;|qY(vwG3e-lK;LTp4O);jWU0@XO#1IOy68NWt3RXIVYEd&% zm?@Y)jTrb#_wNEAUg*7J!3;~FDbykYAT(*P&`mhv@GIJF`yMTr z6}DilDORPnzKKI&F8L%j#hwRiyi}cH^BUZtYmgm6Mf}Wz{G?jVMIwY!%!I>~UuaFw zF}-34jiLO#u=HM%pZ8r*i5OIMn8Q@Xc$CB`Pc)BM1=feLTOfRkggE`dtP4UWqIo{ByiBPNRu8RiEz7~64UV`HA%U@MGi z7M+JwfCl^)QVEUb3;|?Q0}IL;9}X$1#!I0f)byy}?p6cEH9=<$jE3Z}L};K>DAK?d z4O8?{Q_NvCr8JCj7mz@BMM@Z|G+M7h<<)afyz#{RiwkG7W&ZT>HCyTIlkcj_wz}0_ z4YxeQ^Ur@&bG_?h^=5PLXBC;jp?}uQGP94rX}{qa&e(?w)Zogq$5(uHOTN0SZ+F&H zpRw1k3a<3gAMVVbtD>&TN5s$0Ibrr5x`d ziGMG@B7-hf^ST~#7-s`tvLp`;%IYMEg6~j6!?9d4p+{FN6A~jzBkqgD)r3J1k;SND zBpi*&8YAH`u@FO82AiLZO6@pV{i2@XfS>6ZNAc9QlewhC49$h4r5)FAF!g?mW|c3% zeiY3r#v!&zdK}S#lu!fqJGHW$WgGGXdZ2TeJ25`3#{LQD2@wpH=FpLux{)U2`1KFw zoMy{qO1dX1G?4~J$`!+(>}D!N8ez5re#+mWN~7CVHR=A_Le+|}V@cStEbLshyDz&g zx-wNQi=B&2nab{Ed(Wz;V#ak_*tR0nEeUnY!tOj#bEc&?)7+P->|eG&qm$k7RLyS# z4@w_db5&-x__MAj(*1Y5yA}>+y?fI~*UEM;oX?gurjOsQslDayn0KRopKz+vZ*ns>^HVh08Zv`Eagyz9|0JCTQcV_v7>Bwn&yvtG@)FSzK# z>1+k}QE4uq7t>)RyX3g$yqXJax(s-7{J5kF&d93pFfs}_j@#@PT2nGOb1@~wt{;^= zC9js7Qz;IdIj50nOUgoeRSN8Sk%SaSfkdSkua}2DPD)IEI1PZo84ONtEt29&S^qyN zmv-?e;Z`5ZEs9hK^F~o=Vy2e@1evEY+i6?T+8%lW$(yo4UHYEIq=9s_%wIl`?0F&g zDgrbyyzA$@Z)j-3rsFn3iX}+05+tEGP4IQMO}aO~4wAJr{WxyN;e{$AkvCNm^n2)w^@}8|q2PiU+8lJ>HHd=`Qt$C4IFdeFd0Q zaGc1B990_$wKl(3vx{UbE)qr7++AdBG6HkY(NCh4h>{e5S$$076qRW1{u6zD!-1jU zu7TdJ!CuX&b5bIOP+!uuZonpmI?WO%p{NSHfSXoDl>k>YE-Xh4K&C0a->&h%eqOWc zqCn+TSRQ04Iun|85I>d2Q2|DB9fWY9>Ph})u;nB04kmp-eF}aG2Wm>AHCM%ot8U3v zx9U56pJUzDbU$F2MOfjtEb&`b?cP-{UUjvs+mNN|KH@Fbb!XA6Z_Qi&)Ka~m=u{S=Y#e;uteJ!>VUv;Fg} z^N-Iq{z>^D`N!m~k`_Q;N9%2m_fDyAxuX7>W3eH-=a<=vu54*{`q&!p$dv3{cyeLe zmC$wPVk*<~V#fKaW&ZT4P;w7+fi!y{K(!MH_<7=i0sYQ2E72qQhMVS!FTpkM#qc0L z12lU4J4y+UK(*nZk)9IJIZTK1bL1F&`mLm%j_Q`6Uz*;dy!t$$5DTnNLzDF8h2az) zh(%?64UGeP@o&*=fq)bX1_GKh5Ez|=L_-b)2t|C-Mk^XaH5)aKaOiCA6lx+4|F0l4 zWDttLns78iv-Uoki;zQfxRVa^kRYg374*dpD^SA&qbE;M3Tl#tY(bCLx-C)qs2BnI z4-x67MD)?NVeg2xQdwzT5*GDc6zy88a-nY&nzUKS&X%^Yb?;@7*{u`{p`OIg* qe@*;j;wIRDNk8-aZ;r29fM7$RNB9HZwHJM|_sQ-W?$c@p>i+>KF5fr+ literal 0 HcmV?d00001 diff --git a/mempalace/audit_privacy.py b/mempalace/audit_privacy.py new file mode 100644 index 0000000..e379761 --- /dev/null +++ b/mempalace/audit_privacy.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +audit_privacy.py — Weekly privacy audit for the shared fleet palace. + +Scans a palace directory (typically the shared Alpha fleet palace) and +reports any files that violate the closet-only sync policy: + + 1. Raw drawer files (.drawer.json) — must never exist in fleet palace. + 2. Closet files containing full-text content (> threshold characters). + 3. Closet files exposing private source_file paths. + +Exits 0 if clean, 1 if violations found. + +Usage: + python mempalace/audit_privacy.py [fleet_palace_dir] + + Default: /var/lib/mempalace/fleet + +Refs: #1083, #1075 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# Closets should be compressed summaries, not full text. +# Flag any text field exceeding this character count as suspicious. +MAX_CLOSET_TEXT_CHARS = 2000 + +# Private path indicators — if a source_file contains any of these, +# it is considered a private VPS path that should not be in the fleet palace. +PRIVATE_PATH_PREFIXES = [ + "/root/", + "/home/", + "/Users/", + "/var/home/", +] + + +@dataclass +class Violation: + path: Path + rule: str + detail: str + + +@dataclass +class AuditResult: + scanned: int = 0 + violations: list[Violation] = field(default_factory=list) + + @property + def clean(self) -> bool: + return len(self.violations) == 0 + + +def _is_private_path(path_str: str) -> bool: + for prefix in PRIVATE_PATH_PREFIXES: + if path_str.startswith(prefix): + return True + return False + + +def audit_file(path: Path) -> list[Violation]: + violations: list[Violation] = [] + + # Rule 1: raw drawer files must not exist in fleet palace + if path.name.endswith(".drawer.json"): + violations.append(Violation( + path=path, + rule="RAW_DRAWER", + detail="Raw drawer file present — only closets allowed in fleet palace.", + )) + return violations # no further checks needed + + if not path.name.endswith(".closet.json"): + return violations # not a palace file, skip + + try: + data = json.loads(path.read_text()) + except (json.JSONDecodeError, OSError) as exc: + violations.append(Violation( + path=path, + rule="PARSE_ERROR", + detail=f"Could not parse file: {exc}", + )) + return violations + + drawers = data.get("drawers", []) if isinstance(data, dict) else [] + if not isinstance(drawers, list): + drawers = [] + + for i, drawer in enumerate(drawers): + if not isinstance(drawer, dict): + continue + + # Rule 2: closets must not contain full-text content + text = drawer.get("text", "") + if len(text) > MAX_CLOSET_TEXT_CHARS: + violations.append(Violation( + path=path, + rule="FULL_TEXT_IN_CLOSET", + detail=( + f"Drawer [{i}] text is {len(text)} chars " + f"(limit {MAX_CLOSET_TEXT_CHARS}). " + "Closets must be compressed summaries, not raw content." + ), + )) + + # Rule 3: private source_file paths must not appear in fleet data + source_file = drawer.get("source_file", "") + if source_file and _is_private_path(source_file): + violations.append(Violation( + path=path, + rule="PRIVATE_SOURCE_PATH", + detail=f"Drawer [{i}] exposes private source_file: {source_file!r}", + )) + + return violations + + +def audit_palace(palace_dir: Path) -> AuditResult: + result = AuditResult() + for f in sorted(palace_dir.rglob("*.json")): + violations = audit_file(f) + result.scanned += 1 + result.violations.extend(violations) + return result + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Audit the fleet palace for privacy violations." + ) + parser.add_argument( + "palace_dir", + nargs="?", + default="/var/lib/mempalace/fleet", + help="Path to the fleet palace directory (default: /var/lib/mempalace/fleet)", + ) + parser.add_argument( + "--max-text", + type=int, + default=MAX_CLOSET_TEXT_CHARS, + metavar="N", + help=f"Maximum closet text length (default: {MAX_CLOSET_TEXT_CHARS})", + ) + args = parser.parse_args(argv) + + palace_dir = Path(args.palace_dir) + if not palace_dir.exists(): + print(f"[audit_privacy] ERROR: palace directory not found: {palace_dir}", file=sys.stderr) + return 2 + + print(f"[audit_privacy] Scanning: {palace_dir}") + result = audit_palace(palace_dir) + + if result.clean: + print(f"[audit_privacy] OK — {result.scanned} file(s) scanned, no violations.") + return 0 + + print( + f"[audit_privacy] FAIL — {len(result.violations)} violation(s) in {result.scanned} file(s):", + file=sys.stderr, + ) + for v in result.violations: + print(f" [{v.rule}] {v.path}", file=sys.stderr) + print(f" {v.detail}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mempalace/evennia_mempalace/__init__.py b/mempalace/evennia_mempalace/__init__.py new file mode 100644 index 0000000..99a1bbd --- /dev/null +++ b/mempalace/evennia_mempalace/__init__.py @@ -0,0 +1,22 @@ +""" +evennia_mempalace — Evennia contrib module for MemPalace fleet memory. + +Connects Evennia's spatial world to the local MemPalace vector backend, +enabling in-world recall, room exploration, and steward NPC queries. + +Phase 1 deliverables (Issue #1077): +- CmdRecall — `recall ` / `recall --fleet` +- CmdEnterRoom — `enter room ` — teleport to semantic room +- MemPalaceRoom — typeclass whose description auto-populates from palace + +Installation (add to your Evennia game's settings.py): + INSTALLED_APPS += ["evennia_mempalace"] + + MEMPALACE_PATH = "/root/wizards/bezalel/.mempalace/palace" + MEMPALACE_FLEET_PATH = "/var/lib/mempalace/fleet" # optional shared wing + MEMPALACE_WING = "bezalel" # default wing name + +Refs: #1077, #1075 +""" + +VERSION = "0.1.0" diff --git a/mempalace/evennia_mempalace/commands/__init__.py b/mempalace/evennia_mempalace/commands/__init__.py new file mode 100644 index 0000000..f91cb64 --- /dev/null +++ b/mempalace/evennia_mempalace/commands/__init__.py @@ -0,0 +1,14 @@ +""" +evennia_mempalace.commands — In-world commands for MemPalace fleet memory. + +Commands: + CmdRecall — recall [--fleet] + CmdEnterRoom — enter room + +Refs: #1077, #1075 +""" + +from .recall import CmdRecall +from .enter_room import CmdEnterRoom + +__all__ = ["CmdRecall", "CmdEnterRoom"] diff --git a/mempalace/evennia_mempalace/commands/enter_room.py b/mempalace/evennia_mempalace/commands/enter_room.py new file mode 100644 index 0000000..646f678 --- /dev/null +++ b/mempalace/evennia_mempalace/commands/enter_room.py @@ -0,0 +1,86 @@ +""" +CmdEnterRoom — teleport to a MemPalace room inside Evennia. + +Usage: + enter room + +Searches for a MemPalaceRoom whose key matches in the current +location's area, then teleports the caller there and auto-renders the +room description from top-3 palace memories for that topic. + +Examples: + enter room forge + enter room hermes + enter room experiments + +Refs: #1077, #1075 +""" + +from __future__ import annotations + +try: + from evennia import Command, search_object + from evennia.utils.utils import inherits_from +except ImportError: + Command = object # type: ignore[assignment,misc] + search_object = None # type: ignore[assignment] + + def inherits_from(obj, cls): # type: ignore[misc] + return False + +from ..settings import get_palace_path, get_wing + +MEMPALACE_ROOM_TYPECLASS = "evennia_mempalace.typeclasses.room.MemPalaceRoom" + + +class CmdEnterRoom(Command): + """ + Teleport into a MemPalace room and see its knowledge. + + Usage: + enter room + + Finds a MemPalaceRoom matching and moves you there. + The room description is populated from the top-3 memories in that room. + + Examples: + enter room forge + enter room nexus + enter room experiments + """ + + key = "enter room" + aliases = ["go to room", "visit room"] + locks = "cmd:all()" + help_category = "MemPalace" + + def func(self) -> None: + topic = self.args.strip().lower() + if not topic: + self.caller.msg("Usage: enter room (e.g. 'enter room forge')") + return + + # Look for a MemPalaceRoom with matching key in the current location + # or globally tagged with the topic. + candidates = [] + if search_object is not None: + candidates = search_object( + topic, + typeclass=MEMPALACE_ROOM_TYPECLASS, + attribute_name="palace_room_key", + attribute_value=topic, + ) + + if not candidates: + self.caller.msg( + f"No palace room found for topic '|w{topic}|n'. " + f"Available rooms depend on your local palace configuration." + ) + return + + destination = candidates[0] + if destination == self.caller.location: + self.caller.msg(f"You are already in the {destination.key}.") + return + + self.caller.move_to(destination, quiet=False) diff --git a/mempalace/evennia_mempalace/commands/recall.py b/mempalace/evennia_mempalace/commands/recall.py new file mode 100644 index 0000000..7ee723e --- /dev/null +++ b/mempalace/evennia_mempalace/commands/recall.py @@ -0,0 +1,93 @@ +""" +CmdRecall — search the caller's MemPalace wing from inside Evennia. + +Usage: + recall + recall --fleet + +Examples: + recall nightly watch failures + recall GraphQL schema --fleet + +The plain form searches only the caller's own wing. +--fleet searches the shared fleet wing (closets only, privacy-safe). + +Refs: #1077, #1075 +""" + +from __future__ import annotations + +try: + from evennia import Command + from evennia.utils import evtable +except ImportError: # allow import outside Evennia for testing + Command = object # type: ignore[assignment,misc] + evtable = None # type: ignore[assignment] + +from ..searcher import search_memories +from ..settings import get_palace_path, get_fleet_palace_path, get_wing + + +class CmdRecall(Command): + """ + Search your MemPalace wing for relevant memories. + + Usage: + recall + recall --fleet + + The plain form searches your own wing. Add --fleet to search the + shared fleet wing (closets only). + + Examples: + recall nightly watch + recall hermes gateway --fleet + """ + + key = "recall" + aliases = ["remember"] + locks = "cmd:all()" + help_category = "MemPalace" + + def func(self) -> None: + raw = self.args.strip() + if not raw: + self.caller.msg("Usage: recall (add --fleet to search all wings)") + return + + fleet_mode = "--fleet" in raw + query = raw.replace("--fleet", "").strip() + if not query: + self.caller.msg("Usage: recall (add --fleet to search all wings)") + return + + if fleet_mode: + palace_path = get_fleet_palace_path() + wing = "fleet" + scope_label = "|y[fleet]|n" + else: + palace_path = get_palace_path() + wing = get_wing(self.caller) + scope_label = f"|c[{wing}]|n" + + if not palace_path: + self.caller.msg( + "|rMemPalace is not configured. Set MEMPALACE_PATH in settings.py.|n" + ) + return + + self.caller.msg(f"Searching {scope_label} for: |w{query}|n …") + results = search_memories(query, palace_path, wing=wing, top_k=5) + + if not results: + self.caller.msg("No memories found.") + return + + lines = [] + for i, r in enumerate(results, 1): + snippet = r.text[:200].replace("\n", " ") + if len(r.text) > 200: + snippet += "…" + lines.append(f"|w{i}.|n |c{r.room}|n {snippet}") + + self.caller.msg("\n".join(lines)) diff --git a/mempalace/evennia_mempalace/searcher.py b/mempalace/evennia_mempalace/searcher.py new file mode 100644 index 0000000..35f43a7 --- /dev/null +++ b/mempalace/evennia_mempalace/searcher.py @@ -0,0 +1,106 @@ +""" +searcher.py — Thin wrapper around MemPalace search for Evennia commands. + +Provides a single `search_memories` function that returns ranked results +from a local palace. Evennia commands call this; the MemPalace binary/lib +handles ChromaDB and SQLite under the hood. + +Refs: #1077, #1075 +""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class MemoryResult: + room: str + text: str + score: float + source: str = "" + metadata: dict = field(default_factory=dict) + + +def _find_mempalace_bin(palace_path: Path) -> Optional[Path]: + """Resolve the mempalace binary via vendored path or system PATH.""" + # Check vendored binary alongside palace + candidates = [ + palace_path.parent.parent / ".vendor" / "mempalace" / "mempalace", + palace_path.parent.parent / ".vendor" / "mempalace" / "bin" / "mempalace", + ] + for c in candidates: + if c.is_file() and c.stat().st_mode & 0o111: + return c + + # Fallback: system PATH + import shutil + found = shutil.which("mempalace") + return Path(found) if found else None + + +def search_memories( + query: str, + palace_path: str | Path, + wing: str = "general", + room: Optional[str] = None, + top_k: int = 5, +) -> list[MemoryResult]: + """Search a MemPalace for memories matching *query*. + + Args: + query: Natural-language search string. + palace_path: Absolute path to the palace directory (contains + chromadb/ and sqlite/). + wing: Wizard wing to scope the search to. + room: Optional room filter (e.g. "forge"). + top_k: Maximum number of results to return. + + Returns: + Ranked list of MemoryResult objects, best match first. + Returns an empty list (not an exception) if the palace is + unavailable or the binary is missing. + """ + palace_path = Path(palace_path) + bin_path = _find_mempalace_bin(palace_path) + if bin_path is None: + return [] + + cmd = [ + str(bin_path), + "search", + "--palace", str(palace_path), + "--wing", wing, + "--top-k", str(top_k), + "--format", "json", + query, + ] + if room: + cmd.extend(["--room", room]) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + return [] + data = json.loads(result.stdout) + return [ + MemoryResult( + room=item.get("room", "general"), + text=item.get("text", ""), + score=float(item.get("score", 0.0)), + source=item.get("source", ""), + metadata=item.get("metadata", {}), + ) + for item in data.get("results", []) + ] + except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError): + return [] diff --git a/mempalace/evennia_mempalace/settings.py b/mempalace/evennia_mempalace/settings.py new file mode 100644 index 0000000..9d3f00b --- /dev/null +++ b/mempalace/evennia_mempalace/settings.py @@ -0,0 +1,64 @@ +""" +settings.py — Configuration bridge between Evennia settings and MemPalace. + +Read MEMPALACE_* values from Django/Evennia settings with safe fallbacks. +All values can be overridden in your game's settings.py. + +Settings: + MEMPALACE_PATH (str) — Absolute path to the local palace directory. + e.g. "/root/wizards/bezalel/.mempalace/palace" + MEMPALACE_FLEET_PATH (str) — Path to the shared fleet palace (optional). + e.g. "/var/lib/mempalace/fleet" + MEMPALACE_WING (str) — Default wing name for this server instance. + e.g. "bezalel" + +Refs: #1077, #1075 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +try: + from django.conf import settings as _django_settings + _HAS_DJANGO = True +except ImportError: + _django_settings = None # type: ignore[assignment] + _HAS_DJANGO = False + + +def _get_setting(key: str, default=None): + if _HAS_DJANGO: + return getattr(_django_settings, key, default) + return default + + +def get_palace_path() -> Optional[Path]: + """Return the local palace path, or None if not configured.""" + val = _get_setting("MEMPALACE_PATH") + if not val: + return None + p = Path(val) + return p if p.exists() else p # return path even if not yet created + + +def get_fleet_palace_path() -> Optional[Path]: + """Return the shared fleet palace path, or None if not configured.""" + val = _get_setting("MEMPALACE_FLEET_PATH") + if not val: + return None + return Path(val) + + +def get_wing(caller=None) -> str: + """Return the wing name for the given caller. + + Falls back to MEMPALACE_WING setting, then "general". + Future: resolve per-character wing from caller.db.wing. + """ + if caller is not None: + wing = getattr(getattr(caller, "db", None), "wing", None) + if wing: + return wing + return _get_setting("MEMPALACE_WING", "general") or "general" diff --git a/mempalace/evennia_mempalace/typeclasses/__init__.py b/mempalace/evennia_mempalace/typeclasses/__init__.py new file mode 100644 index 0000000..0f6be1b --- /dev/null +++ b/mempalace/evennia_mempalace/typeclasses/__init__.py @@ -0,0 +1 @@ +"""evennia_mempalace.typeclasses — Evennia typeclasses for palace rooms.""" diff --git a/mempalace/evennia_mempalace/typeclasses/room.py b/mempalace/evennia_mempalace/typeclasses/room.py new file mode 100644 index 0000000..aebb148 --- /dev/null +++ b/mempalace/evennia_mempalace/typeclasses/room.py @@ -0,0 +1,87 @@ +""" +MemPalaceRoom — Evennia room typeclass whose description auto-populates +from the top palace memories for its topic on each player entrance. + +Usage (in your Evennia game's world-building script): + + from evennia import create_object + from evennia_mempalace.typeclasses.room import MemPalaceRoom + + forge = create_object( + MemPalaceRoom, + key="The Forge", + attributes=[ + ("palace_room_key", "forge"), # matches rooms.yaml key + ("palace_top_k", 3), # memories shown on enter + ], + ) + +Players who enter the room see a dynamically-generated description +synthesised from the top-3 matching palace memories. + +Refs: #1077, #1075 +""" + +from __future__ import annotations + +try: + from evennia.objects.objects import DefaultRoom +except ImportError: + DefaultRoom = object # type: ignore[assignment,misc] + +from ..searcher import search_memories +from ..settings import get_palace_path, get_wing + +# Shown when the palace has no memories for this room yet. +_EMPTY_DESC = ( + "A quiet chamber, its shelves bare. No memories have been filed here yet. " + "Use |wrecall|n to search across all rooms, or mine new artefacts to populate this space." +) + + +class MemPalaceRoom(DefaultRoom): + """ + A room whose description is drawn from MemPalace search results. + + Attributes (set via room.db or create_object attributes): + palace_room_key (str): The room key from rooms.yaml (e.g. "forge"). + palace_top_k (int): Number of memories to show. Default: 3. + """ + + def at_object_receive(self, moved_obj, source_location, **kwargs): + """Re-generate description when a character enters.""" + super().at_object_receive(moved_obj, source_location, **kwargs) + # Only refresh for player characters + if not hasattr(moved_obj, "account") or moved_obj.account is None: + return + self._refresh_description(moved_obj) + + def _refresh_description(self, viewer) -> None: + """Fetch top palace memories and update db.desc.""" + room_key = self.db.palace_room_key or "" + top_k = int(self.db.palace_top_k or 3) + + palace_path = get_palace_path() + wing = get_wing(viewer) + + if not palace_path or not room_key: + self.db.desc = _EMPTY_DESC + return + + results = search_memories("", palace_path, wing=wing, room=room_key, top_k=top_k) + if not results: + self.db.desc = _EMPTY_DESC + return + + lines = [f"|c[{self.db.palace_room_key}]|n — Top memories:\n"] + for i, r in enumerate(results, 1): + snippet = r.text[:300].replace("\n", " ") + if len(r.text) > 300: + snippet += "…" + lines.append(f"|w{i}.|n {snippet}\n") + self.db.desc = "\n".join(lines) + + def return_appearance(self, looker, **kwargs): + """Refresh description before rendering to the viewer.""" + self._refresh_description(looker) + return super().return_appearance(looker, **kwargs) diff --git a/mempalace/export_closets.sh b/mempalace/export_closets.sh new file mode 100755 index 0000000..f9f936a --- /dev/null +++ b/mempalace/export_closets.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# export_closets.sh — Privacy-safe export of wizard closets for fleet sync. +# +# Exports ONLY closet (summary) files from a wizard's local MemPalace to +# a bundle directory suitable for rsync to the shared Alpha fleet palace. +# +# POLICY: Raw drawers (full-text source content) NEVER leave the local VPS. +# Only closets (compressed summaries) are exported. +# +# Usage: +# ./mempalace/export_closets.sh [palace_dir] [export_dir] +# +# Defaults: +# palace_dir — $MEMPALACE_DIR or /root/wizards/bezalel/.mempalace/palace +# export_dir — /tmp/mempalace_export_closets +# +# After export, sync with: +# rsync -avz --delete /tmp/mempalace_export_closets/ alpha:/var/lib/mempalace/fleet/bezalel/ +# +# Refs: #1083, #1075 + +set -euo pipefail + +PALACE_DIR="${1:-${MEMPALACE_DIR:-/root/wizards/bezalel/.mempalace/palace}}" +EXPORT_DIR="${2:-/tmp/mempalace_export_closets}" +WIZARD="${MEMPALACE_WING:-bezalel}" + +echo "[export_closets] Wizard: $WIZARD" +echo "[export_closets] Palace: $PALACE_DIR" +echo "[export_closets] Export: $EXPORT_DIR" + +if [[ ! -d "$PALACE_DIR" ]]; then + echo "[export_closets] ERROR: palace not found: $PALACE_DIR" >&2 + exit 1 +fi + +# Validate closets-only policy: abort if any raw drawer files are present in export scope. +# Closets are files named *.closet.json or stored under a closets/ subdirectory. +# Raw drawers are everything else (*.drawer.json, *.md source files, etc.). + +DRAWER_COUNT=0 +while IFS= read -r -d '' f; do + # Raw drawer check: any .json file that is NOT a closet + basename_f="$(basename "$f")" + if [[ "$basename_f" == *.drawer.json ]]; then + echo "[export_closets] POLICY VIOLATION: raw drawer found in export scope: $f" >&2 + DRAWER_COUNT=$((DRAWER_COUNT + 1)) + fi +done < <(find "$PALACE_DIR" -type f -name "*.json" -print0 2>/dev/null) + +if [[ "$DRAWER_COUNT" -gt 0 ]]; then + echo "[export_closets] ABORT: $DRAWER_COUNT raw drawer(s) detected. Only closets may be exported." >&2 + echo "[export_closets] Run mempalace compress to generate closets before exporting." >&2 + exit 1 +fi + +# Also check for source_file metadata in closet JSON that would expose private paths. +SOURCE_FILE_LEAKS=0 +while IFS= read -r -d '' f; do + if python3 -c " +import json, sys +try: + data = json.load(open('$f')) + drawers = data.get('drawers', []) if isinstance(data, dict) else [] + for d in drawers: + if 'source_file' in d and not d.get('closet', False): + sys.exit(1) +except Exception: + pass +sys.exit(0) +" 2>/dev/null; then + : + else + echo "[export_closets] POLICY VIOLATION: source_file metadata in non-closet: $f" >&2 + SOURCE_FILE_LEAKS=$((SOURCE_FILE_LEAKS + 1)) + fi +done < <(find "$PALACE_DIR" -type f -name "*.closet.json" -print0 2>/dev/null) + +if [[ "$SOURCE_FILE_LEAKS" -gt 0 ]]; then + echo "[export_closets] ABORT: $SOURCE_FILE_LEAKS file(s) contain private source_file paths." >&2 + exit 1 +fi + +# Collect closet files +mkdir -p "$EXPORT_DIR/$WIZARD" +CLOSET_COUNT=0 +while IFS= read -r -d '' f; do + rel_path="${f#$PALACE_DIR/}" + dest="$EXPORT_DIR/$WIZARD/$rel_path" + mkdir -p "$(dirname "$dest")" + cp "$f" "$dest" + CLOSET_COUNT=$((CLOSET_COUNT + 1)) +done < <(find "$PALACE_DIR" -type f -name "*.closet.json" -print0 2>/dev/null) + +if [[ "$CLOSET_COUNT" -eq 0 ]]; then + echo "[export_closets] WARNING: no closet files found in $PALACE_DIR" >&2 + echo "[export_closets] Run 'mempalace compress' to generate closets from drawers." >&2 + exit 0 +fi + +echo "[export_closets] Exported $CLOSET_COUNT closet(s) to $EXPORT_DIR/$WIZARD/" +echo "[export_closets] OK — ready for fleet sync." +echo "" +echo " rsync -avz --delete $EXPORT_DIR/$WIZARD/ alpha:/var/lib/mempalace/fleet/$WIZARD/" diff --git a/mempalace/rooms.yaml b/mempalace/rooms.yaml new file mode 100644 index 0000000..f4f892b --- /dev/null +++ b/mempalace/rooms.yaml @@ -0,0 +1,114 @@ +# MemPalace Fleet Taxonomy Standard +# Refs: #1082, #1075 (MemPalace × Evennia — Fleet Memory milestone) +# +# Every wizard palace MUST contain the 5 core rooms listed under `core_rooms`. +# Optional domain-specific rooms are listed under `optional_rooms` for reference. +# Wizards may add additional rooms beyond this taxonomy. +# +# Room schema fields: +# key — machine-readable slug (used for tunnel routing and fleet search) +# label — human-readable display name +# purpose — one-line description of what belongs here +# examples — sample artifact types filed in this room + +version: "1" + +core_rooms: + - key: forge + label: Forge + purpose: CI pipelines, builds, infra configuration, deployment artefacts + examples: + - build logs + - CI run summaries + - Dockerfile changes + - cron job definitions + - server provisioning notes + + - key: hermes + label: Hermes + purpose: Agent platform, Hermes gateway, harness CLI, inter-agent messaging + examples: + - harness config snapshots + - agent boot reports + - MCP tool definitions + - Hermes gateway events + - worker health logs + + - key: nexus + label: Nexus + purpose: Project reports, documentation, knowledge transfer, field reports + examples: + - SITREP documents + - architecture decision records + - field reports + - onboarding docs + - milestone summaries + + - key: issues + label: Issues + purpose: Tickets, backlog items, PR summaries, bug reports + examples: + - Gitea issue summaries + - PR merge notes + - bug reproduction steps + - acceptance criteria + + - key: experiments + label: Experiments + purpose: Prototypes, spikes, sandbox work, exploratory research + examples: + - spike results + - A/B test notes + - proof-of-concept code snippets + - benchmark data + +optional_rooms: + - key: evennia + label: Evennia + purpose: MUD world state, room descriptions, NPC dialogue, game events + wizards: [bezalel, timmy] + + - key: game-portals + label: Game Portals + purpose: Portal registry, zone configs, dungeon layouts, loot tables + wizards: [timmy] + + - key: lazarus-pit + label: Lazarus Pit + purpose: Dead/parked work, archived experiments, deprecated configs + wizards: [timmy, allegro, bezalel] + + - key: satflow + label: SatFlow + purpose: Economy visualizations, satoshi flow tracking, L402 audit trails + wizards: [timmy, allegro] + + - key: workspace + label: Workspace + purpose: General scratch notes, daily logs, personal coordination + wizards: ["*"] + + - key: home + label: Home + purpose: Personal identity, agent persona, preferences, capability docs + wizards: ["*"] + + - key: general + label: General + purpose: Catch-all for artefacts not yet assigned to a named room + wizards: ["*"] + +# Tunnel routing table +# Defines which room pairs are connected across wizard wings. +# A tunnel lets `recall --fleet` search both wings at once. +tunnels: + - rooms: [forge, forge] + description: Build and infra knowledge shared across all wizards + - rooms: [hermes, hermes] + description: Harness platform knowledge shared across all wizards + - rooms: [nexus, nexus] + description: Cross-wizard documentation and field reports + - rooms: [issues, issues] + description: Fleet-wide issue and PR knowledge + - rooms: [experiments, experiments] + description: Cross-wizard spike and prototype results diff --git a/mempalace/validate_rooms.py b/mempalace/validate_rooms.py new file mode 100644 index 0000000..2c10275 --- /dev/null +++ b/mempalace/validate_rooms.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +validate_rooms.py — Fleet palace taxonomy validator. + +Checks a wizard's mempalace.yaml against the fleet standard in rooms.yaml. +Exits 0 if valid, 1 if core rooms are missing or the config is malformed. + +Usage: + python mempalace/validate_rooms.py + python mempalace/validate_rooms.py /root/wizards/bezalel/mempalace.yaml + +Refs: #1082, #1075 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + +try: + import yaml +except ImportError: + print("ERROR: PyYAML is required. Install with: pip install pyyaml", file=sys.stderr) + sys.exit(2) + +FLEET_STANDARD = Path(__file__).parent / "rooms.yaml" + + +def load_yaml(path: Path) -> dict[str, Any]: + with path.open() as fh: + return yaml.safe_load(fh) or {} + + +def get_core_room_keys(standard: dict[str, Any]) -> list[str]: + return [r["key"] for r in standard.get("core_rooms", [])] + + +def get_wizard_room_keys(config: dict[str, Any]) -> list[str]: + """Extract room keys from a wizard's mempalace.yaml. + + Supports two common shapes: + rooms: + - key: forge + - key: hermes + or: + rooms: + forge: ... + hermes: ... + """ + rooms_field = config.get("rooms", {}) + if isinstance(rooms_field, list): + return [r["key"] for r in rooms_field if isinstance(r, dict) and "key" in r] + if isinstance(rooms_field, dict): + return list(rooms_field.keys()) + return [] + + +def validate(wizard_config_path: Path, standard_path: Path = FLEET_STANDARD) -> list[str]: + """Return a list of validation errors. Empty list means valid.""" + errors: list[str] = [] + + if not standard_path.exists(): + errors.append(f"Fleet standard not found: {standard_path}") + return errors + + if not wizard_config_path.exists(): + errors.append(f"Wizard config not found: {wizard_config_path}") + return errors + + standard = load_yaml(standard_path) + config = load_yaml(wizard_config_path) + + core_keys = get_core_room_keys(standard) + wizard_keys = get_wizard_room_keys(config) + + missing = [k for k in core_keys if k not in wizard_keys] + for key in missing: + errors.append(f"Missing required core room: '{key}'") + + return errors + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Validate a wizard's mempalace.yaml against the fleet room standard." + ) + parser.add_argument( + "config", + metavar="mempalace.yaml", + help="Path to the wizard's mempalace.yaml", + ) + parser.add_argument( + "--standard", + default=str(FLEET_STANDARD), + metavar="rooms.yaml", + help="Path to the fleet rooms.yaml standard (default: mempalace/rooms.yaml)", + ) + args = parser.parse_args(argv) + + wizard_path = Path(args.config) + standard_path = Path(args.standard) + + errors = validate(wizard_path, standard_path) + + if errors: + print(f"[validate_rooms] FAIL: {wizard_path}", file=sys.stderr) + for err in errors: + print(f" ✗ {err}", file=sys.stderr) + return 1 + + core_count = len(get_core_room_keys(load_yaml(standard_path))) + print(f"[validate_rooms] OK: {wizard_path} — all {core_count} core rooms present.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_mempalace_audit_privacy.py b/tests/test_mempalace_audit_privacy.py new file mode 100644 index 0000000..7b40114 --- /dev/null +++ b/tests/test_mempalace_audit_privacy.py @@ -0,0 +1,129 @@ +""" +Tests for mempalace/audit_privacy.py — fleet palace privacy auditor. + +Refs: #1083, #1075 +""" + +import json +from pathlib import Path + +import pytest + +from mempalace.audit_privacy import ( + Violation, + audit_file, + audit_palace, + _is_private_path, +) + + +# --------------------------------------------------------------------------- +# _is_private_path +# --------------------------------------------------------------------------- + +def test_private_path_root(): + assert _is_private_path("/root/wizards/bezalel/workspace.md") is True + + +def test_private_path_home(): + assert _is_private_path("/home/apayne/projects/nexus") is True + + +def test_private_path_users(): + assert _is_private_path("/Users/apayne/worktrees/nexus/foo.py") is True + + +def test_non_private_path(): + assert _is_private_path("/var/lib/mempalace/fleet/bezalel/forge.closet.json") is False + assert _is_private_path("relative/path.md") is False + + +# --------------------------------------------------------------------------- +# audit_file — clean closet +# --------------------------------------------------------------------------- + +def _write_closet(tmp_path: Path, name: str, drawers: list) -> Path: + p = tmp_path / name + p.write_text(json.dumps({"drawers": drawers})) + return p + + +def test_clean_closet_has_no_violations(tmp_path): + f = _write_closet(tmp_path, "forge.closet.json", [ + {"text": "Build succeeded on commit abc123.", "closet": True}, + ]) + assert audit_file(f) == [] + + +# --------------------------------------------------------------------------- +# audit_file — raw drawer violation +# --------------------------------------------------------------------------- + +def test_raw_drawer_file_is_violation(tmp_path): + f = tmp_path / "workspace.drawer.json" + f.write_text(json.dumps({"text": "some private content"})) + violations = audit_file(f) + assert len(violations) == 1 + assert violations[0].rule == "RAW_DRAWER" + + +# --------------------------------------------------------------------------- +# audit_file — full text in closet +# --------------------------------------------------------------------------- + +def test_full_text_closet_is_violation(tmp_path): + long_text = "x" * 3000 # exceeds 2000 char limit + f = _write_closet(tmp_path, "nexus.closet.json", [ + {"text": long_text, "closet": True}, + ]) + violations = audit_file(f) + assert any(v.rule == "FULL_TEXT_IN_CLOSET" for v in violations) + + +# --------------------------------------------------------------------------- +# audit_file — private source_file path +# --------------------------------------------------------------------------- + +def test_private_source_file_is_violation(tmp_path): + f = _write_closet(tmp_path, "hermes.closet.json", [ + { + "text": "Short summary.", + "source_file": "/root/wizards/bezalel/secret.md", + "closet": True, + } + ]) + violations = audit_file(f) + assert any(v.rule == "PRIVATE_SOURCE_PATH" for v in violations) + + +def test_fleet_source_file_is_ok(tmp_path): + f = _write_closet(tmp_path, "hermes.closet.json", [ + { + "text": "Short summary.", + "source_file": "/var/lib/mempalace/fleet/bezalel/hermes.closet.json", + "closet": True, + } + ]) + violations = audit_file(f) + assert violations == [] + + +# --------------------------------------------------------------------------- +# audit_palace +# --------------------------------------------------------------------------- + +def test_audit_palace_clean(tmp_path): + _write_closet(tmp_path, "forge.closet.json", [{"text": "ok", "closet": True}]) + _write_closet(tmp_path, "nexus.closet.json", [{"text": "ok", "closet": True}]) + result = audit_palace(tmp_path) + assert result.clean + assert result.scanned == 2 + + +def test_audit_palace_finds_violations(tmp_path): + _write_closet(tmp_path, "forge.closet.json", [{"text": "ok", "closet": True}]) + bad = tmp_path / "secret.drawer.json" + bad.write_text(json.dumps({"text": "raw private data"})) + result = audit_palace(tmp_path) + assert not result.clean + assert any(v.rule == "RAW_DRAWER" for v in result.violations) diff --git a/tests/test_mempalace_validate_rooms.py b/tests/test_mempalace_validate_rooms.py new file mode 100644 index 0000000..50faa7d --- /dev/null +++ b/tests/test_mempalace_validate_rooms.py @@ -0,0 +1,160 @@ +""" +Tests for mempalace/validate_rooms.py — fleet room taxonomy validator. + +Refs: #1082, #1075 +""" + +import json +import textwrap +from pathlib import Path + +import pytest +import yaml + +from mempalace.validate_rooms import ( + get_core_room_keys, + get_wizard_room_keys, + validate, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +STANDARD_YAML = textwrap.dedent("""\ + version: "1" + core_rooms: + - key: forge + label: Forge + purpose: CI and builds + - key: hermes + label: Hermes + purpose: Agent platform + - key: nexus + label: Nexus + purpose: Reports and docs + - key: issues + label: Issues + purpose: Tickets and backlog + - key: experiments + label: Experiments + purpose: Prototypes and spikes +""") + + +def write_standard(tmp_path: Path) -> Path: + p = tmp_path / "rooms.yaml" + p.write_text(STANDARD_YAML) + return p + + +# --------------------------------------------------------------------------- +# get_core_room_keys +# --------------------------------------------------------------------------- + +def test_get_core_room_keys_returns_all_keys(tmp_path): + standard_path = write_standard(tmp_path) + standard = yaml.safe_load(standard_path.read_text()) + keys = get_core_room_keys(standard) + assert keys == ["forge", "hermes", "nexus", "issues", "experiments"] + + +def test_get_core_room_keys_empty_if_no_core_rooms(): + keys = get_core_room_keys({}) + assert keys == [] + + +# --------------------------------------------------------------------------- +# get_wizard_room_keys +# --------------------------------------------------------------------------- + +def test_get_wizard_room_keys_list_style(): + config = { + "rooms": [ + {"key": "forge"}, + {"key": "hermes"}, + ] + } + assert get_wizard_room_keys(config) == ["forge", "hermes"] + + +def test_get_wizard_room_keys_dict_style(): + config = { + "rooms": { + "forge": {"purpose": "builds"}, + "nexus": {"purpose": "docs"}, + } + } + keys = get_wizard_room_keys(config) + assert set(keys) == {"forge", "nexus"} + + +def test_get_wizard_room_keys_empty_config(): + assert get_wizard_room_keys({}) == [] + + +# --------------------------------------------------------------------------- +# validate — happy path +# --------------------------------------------------------------------------- + +def test_validate_passes_with_all_core_rooms(tmp_path): + standard_path = write_standard(tmp_path) + wizard_config = tmp_path / "mempalace.yaml" + wizard_config.write_text(textwrap.dedent("""\ + rooms: + - key: forge + - key: hermes + - key: nexus + - key: issues + - key: experiments + """)) + errors = validate(wizard_config, standard_path) + assert errors == [] + + +def test_validate_passes_with_extra_rooms(tmp_path): + standard_path = write_standard(tmp_path) + wizard_config = tmp_path / "mempalace.yaml" + wizard_config.write_text(textwrap.dedent("""\ + rooms: + - key: forge + - key: hermes + - key: nexus + - key: issues + - key: experiments + - key: evennia + - key: workspace + """)) + errors = validate(wizard_config, standard_path) + assert errors == [] + + +# --------------------------------------------------------------------------- +# validate — failure cases +# --------------------------------------------------------------------------- + +def test_validate_reports_missing_core_rooms(tmp_path): + standard_path = write_standard(tmp_path) + wizard_config = tmp_path / "mempalace.yaml" + wizard_config.write_text(textwrap.dedent("""\ + rooms: + - key: forge + """)) + errors = validate(wizard_config, standard_path) + missing_keys = [e for e in errors if "hermes" in e or "nexus" in e or "issues" in e or "experiments" in e] + assert len(missing_keys) == 4 + + +def test_validate_missing_wizard_config(tmp_path): + standard_path = write_standard(tmp_path) + missing = tmp_path / "nonexistent.yaml" + errors = validate(missing, standard_path) + assert any("not found" in e for e in errors) + + +def test_validate_missing_standard(tmp_path): + wizard_config = tmp_path / "mempalace.yaml" + wizard_config.write_text("rooms:\n - key: forge\n") + missing_standard = tmp_path / "no_such_rooms.yaml" + errors = validate(wizard_config, missing_standard) + assert any("not found" in e for e in errors) -- 2.43.0 From 3a48e8f7bd15ec9810b97d978df43b86cf0a0e53 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 7 Apr 2026 10:09:34 -0400 Subject: [PATCH 4/4] chore: gitignore mempalace/__pycache__ Refs #1075 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + mempalace/__pycache__/__init__.cpython-312.pyc | Bin 350 -> 0 bytes .../__pycache__/audit_privacy.cpython-312.pyc | Bin 6900 -> 0 bytes .../__pycache__/validate_rooms.cpython-312.pyc | Bin 5133 -> 0 bytes 4 files changed, 1 insertion(+) delete mode 100644 mempalace/__pycache__/__init__.cpython-312.pyc delete mode 100644 mempalace/__pycache__/audit_privacy.cpython-312.pyc delete mode 100644 mempalace/__pycache__/validate_rooms.cpython-312.pyc diff --git a/.gitignore b/.gitignore index 769e943..20d00a7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ test-results/ nexus/__pycache__/ tests/__pycache__/ +mempalace/__pycache__/ .aider* diff --git a/mempalace/__pycache__/__init__.cpython-312.pyc b/mempalace/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 215ae463262dbc75656effefc8832b2585f82318..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 350 zcmX@j%ge<81W);|W=;aqk3k$5V1zP0a{w9B8B!Rc7%CYxnM%8XT&~>I+=9fM#NvJmZj$9WhN?Q=9Q$T z7bTWt=I7~gaRsHO6}2Mm z>*8wasB2>G;-qWg>T05EVC?K{;c9MRVQlHF5A+knpZf9fnR%Hd@$q^EmA^P_a`RJ4 fb5iY!*nrl70;(7kjvtsA85ut@u`sd}u>iRMuqkG2 diff --git a/mempalace/__pycache__/audit_privacy.cpython-312.pyc b/mempalace/__pycache__/audit_privacy.cpython-312.pyc deleted file mode 100644 index 140bed65cec40b08da03a684dd34abae2b27d557..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6900 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 jklk39f~KPu~B@`*$vXdi9A=pXZU#!nF4P8$?Bw diff --git a/mempalace/__pycache__/validate_rooms.cpython-312.pyc b/mempalace/__pycache__/validate_rooms.cpython-312.pyc deleted file mode 100644 index 40fbb156fc8c3c0c56478a69dccc5770489d3c2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5133 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 -- 2.43.0