Skip to content
Merged
21 changes: 21 additions & 0 deletions mempalace/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,27 @@ def hall_keywords(self):
"""Mapping of hall names to keyword lists."""
return self._file_config.get("hall_keywords", DEFAULT_HALL_KEYWORDS)

@property
def hook_silent_save(self):
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
return self._file_config.get("hooks", {}).get("silent_save", True)

@property
def hook_desktop_toast(self):
"""Whether the stop hook shows a desktop notification via notify-send."""
return self._file_config.get("hooks", {}).get("desktop_toast", False)

def set_hook_setting(self, key: str, value: bool):
"""Update a hook setting and write config to disk."""
if "hooks" not in self._file_config:
self._file_config["hooks"] = {}
self._file_config["hooks"][key] = value
try:
with open(self._config_file, "w", encoding="utf-8") as f:
json.dump(self._file_config, f, indent=2, ensure_ascii=False)
except OSError:
pass

def init(self):
"""Create config directory and write default config.json if it doesn't exist."""
self._config_dir.mkdir(parents=True, exist_ok=True)
Expand Down
153 changes: 153 additions & 0 deletions mempalace/exporter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""
exporter.py — Export the palace as a browsable folder of markdown files.

Produces:
output_dir/
index.md — table of contents
wing_name/
room_name.md — one file per room, drawers as sections

Streams drawers in paginated batches so memory usage stays bounded
regardless of palace size.
"""

import os
import re
from collections import defaultdict
from datetime import datetime

from .palace import get_collection


def _safe_path_component(name: str) -> str:
"""Sanitize a string for use as a directory/file name component."""
name = re.sub(r'[/\\:*?"<>|]', '_', name)
name = name.strip('. ')
return name or 'unknown'


def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -> dict:
"""Export all palace drawers as markdown files organized by wing/room.

Streams drawers in batches of 1000 and writes each wing/room file
incrementally, keeping memory usage proportional to batch size rather
than total palace size.

Args:
palace_path: Path to the ChromaDB palace directory.
output_dir: Where to write the exported markdown tree.
format: Output format (currently only "markdown").

Returns:
Stats dict: {"wings": N, "rooms": N, "drawers": N}
"""
col = get_collection(palace_path)
total = col.count()

if total == 0:
print(" Palace is empty — nothing to export.")
return {"wings": 0, "rooms": 0, "drawers": 0}

os.makedirs(output_dir, exist_ok=True)

# Track which room files have been opened (so we can append vs overwrite)
opened_rooms: set[tuple[str, str]] = set()
# Track stats per wing: {wing: {room: count}}
wing_stats: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
total_drawers = 0

print(f" Streaming {total} drawers...")
offset = 0
while offset < total:
batch = col.get(limit=1000, offset=offset, include=["documents", "metadatas"])
if not batch["ids"]:
break

# Group this batch by wing/room so we do one file write per room per batch
batch_grouped: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list))
for doc_id, doc, meta in zip(batch["ids"], batch["documents"], batch["metadatas"]):
wing = meta.get("wing", "unknown")
room = meta.get("room", "general")
batch_grouped[wing][room].append({
"id": doc_id,
"content": doc,
"source": meta.get("source_file", ""),
"filed_at": meta.get("filed_at", ""),
"added_by": meta.get("added_by", ""),
})

# Write/append each room file
for wing, rooms in batch_grouped.items():
safe_wing = _safe_path_component(wing)
wing_dir = os.path.join(output_dir, safe_wing)
os.makedirs(wing_dir, exist_ok=True)

for room, drawers in rooms.items():
safe_room = _safe_path_component(room)
room_path = os.path.join(wing_dir, f"{safe_room}.md")
key = (wing, room)
is_new = key not in opened_rooms

with open(room_path, "a" if not is_new else "w", encoding="utf-8") as f:
if is_new:
f.write(f"# {wing} / {room}\n\n")
opened_rooms.add(key)

for drawer in drawers:
source = drawer["source"] or "unknown"
filed = drawer["filed_at"] or "unknown"
added_by = drawer["added_by"] or "unknown"

f.write(
f"## {drawer['id']}\n"
f"\n"
f"> {_quote_content(drawer['content'])}\n"
f"\n"
f"| Field | Value |\n"
f"|-------|-------|\n"
f"| Source | {source} |\n"
f"| Filed | {filed} |\n"
f"| Added by | {added_by} |\n"
f"\n"
f"---\n\n"
)

wing_stats[wing][room] += len(drawers)
total_drawers += len(drawers)

offset += len(batch["ids"])

# Build and print stats
index_rows = []
for wing in sorted(wing_stats):
rooms = wing_stats[wing]
wing_drawer_count = sum(rooms.values())
index_rows.append((wing, len(rooms), wing_drawer_count))
print(f" {wing}: {len(rooms)} rooms, {wing_drawer_count} drawers")

# Write index.md
today = datetime.now().strftime("%Y-%m-%d")
index_lines = [
f"# Palace Export — {today}\n",
"",
"| Wing | Rooms | Drawers |",
"|------|-------|---------|",
]
for wing, room_count, drawer_count in index_rows:
index_lines.append(f"| [{wing}]({wing}/) | {room_count} | {drawer_count} |")
index_lines.append("")

index_path = os.path.join(output_dir, "index.md")
with open(index_path, "w", encoding="utf-8") as f:
f.write("\n".join(index_lines))

stats = {"wings": len(wing_stats), "rooms": sum(r for _, r, _ in index_rows), "drawers": total_drawers}
print(f"\n Exported {stats['drawers']} drawers across {stats['wings']} wings, {stats['rooms']} rooms")
print(f" Output: {output_dir}")
return stats


def _quote_content(text: str) -> str:
"""Format content for a markdown blockquote, handling multiline."""
lines = text.rstrip("\n").split("\n")
return "\n> ".join(lines)
28 changes: 6 additions & 22 deletions mempalace/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from .config import MempalaceConfig
from .palace import get_collection as _get_collection
from .searcher import build_where_filter


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -81,6 +82,7 @@ class Layer1:

MAX_DRAWERS = 15 # at most 15 moments in wake-up
MAX_CHARS = 3200 # hard cap on total L1 text (~800 tokens)
MAX_SCAN = 2000 # don't scan more than this for L1 generation

def __init__(self, palace_path: str = None, wing: str = None):
cfg = MempalaceConfig()
Expand Down Expand Up @@ -113,7 +115,7 @@ def generate(self) -> str:
docs.extend(batch_docs)
metas.extend(batch_metas)
offset += len(batch_docs)
if len(batch_docs) < _BATCH:
if len(batch_docs) < _BATCH or len(docs) >= self.MAX_SCAN:
break

if not docs:
Expand Down Expand Up @@ -198,13 +200,7 @@ def retrieve(self, wing: str = None, room: str = None, n_results: int = 10) -> s
except Exception:
return "No palace found."

where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
where = build_where_filter(wing, room)

kwargs = {"include": ["documents", "metadatas"], "limit": n_results}
if where:
Expand Down Expand Up @@ -261,13 +257,7 @@ def search(self, query: str, wing: str = None, room: str = None, n_results: int
except Exception:
return "No palace found."

where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
where = build_where_filter(wing, room)

kwargs = {
"query_texts": [query],
Expand Down Expand Up @@ -316,13 +306,7 @@ def search_raw(
except Exception:
return []

where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
where = build_where_filter(wing, room)

kwargs = {
"query_texts": [query],
Expand Down
Loading