Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mempalace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""MemPalace — Give your AI a memory. No API key required."""

__version__ = "2.0.0"
__version__ = "3.0.0"

from .cli import main

Expand Down
25 changes: 19 additions & 6 deletions mempalace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
from .config import MempalaceConfig


def _error(msg: str) -> None:
"""Print an error message to stderr."""
print(f"error: {msg}", file=sys.stderr)


def cmd_init(args):
import json
from pathlib import Path
Expand Down Expand Up @@ -173,8 +178,8 @@ def cmd_compress(args):
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
except Exception:
print(f"\n No palace found at {palace_path}")
print(" Run: mempalace init <dir> then mempalace mine <dir>")
_error(f"No palace found at {palace_path}")
_error("Run: mempalace init <dir> then mempalace mine <dir>")
sys.exit(1)

# Query drawers in the wing
Expand All @@ -185,7 +190,7 @@ def cmd_compress(args):
kwargs["where"] = where
results = col.get(**kwargs)
except Exception as e:
print(f"\n Error reading drawers: {e}")
_error(f"Error reading drawers: {e}")
sys.exit(1)

docs = results["documents"]
Expand Down Expand Up @@ -245,7 +250,7 @@ def cmd_compress(args):
f" Stored {len(compressed_entries)} compressed drawers in 'mempalace_compressed' collection."
)
except Exception as e:
print(f" Error storing compressed drawers: {e}")
_error(f"Error storing compressed drawers: {e}")
sys.exit(1)

# Summary
Expand Down Expand Up @@ -357,7 +362,7 @@ def main():

if not args.command:
parser.print_help()
return
sys.exit(0)

dispatch = {
"init": cmd_init,
Expand All @@ -368,7 +373,15 @@ def main():
"wake-up": cmd_wakeup,
"status": cmd_status,
}
dispatch[args.command](args)

try:
dispatch[args.command](args)
except KeyboardInterrupt:
_error("interrupted")
sys.exit(130)
except Exception as e:
_error(str(e))
sys.exit(1)


if __name__ == "__main__":
Expand Down
4 changes: 2 additions & 2 deletions mempalace/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def hall_keywords(self):
"""Mapping of hall names to keyword lists."""
return self._file_config.get("hall_keywords", DEFAULT_HALL_KEYWORDS)

def init(self):
def init(self) -> Path:
"""Create config directory and write default config.json if it doesn't exist."""
self._config_dir.mkdir(parents=True, exist_ok=True)
if not self._config_file.exists():
Expand All @@ -137,7 +137,7 @@ def init(self):
json.dump(default_config, f, indent=2)
return self._config_file

def save_people_map(self, people_map):
def save_people_map(self, people_map: dict) -> Path:
"""Write people_map.json to config directory.

Args:
Expand Down
54 changes: 54 additions & 0 deletions mempalace/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
Shared constants for MemPalace.

Centralizes magic numbers that control chunking, retrieval limits,
layer sizing, and similarity thresholds across the codebase.
"""

# ---------------------------------------------------------------------------
# Chunking
# ---------------------------------------------------------------------------
PROJECT_CHUNK_SIZE = 800 # chars per drawer for project files
PROJECT_CHUNK_OVERLAP = 100 # overlap between consecutive chunks
PROJECT_MIN_CHUNK_SIZE = 50 # skip tiny chunks from project files
CONVO_MIN_CHUNK_SIZE = 30 # minimum chars for conversation chunks

# ---------------------------------------------------------------------------
# Layer 1 — Essential Story
# ---------------------------------------------------------------------------
L1_MAX_DRAWERS = 15 # at most 15 moments in wake-up
L1_MAX_CHARS = 3200 # hard cap on total L1 text (~800 tokens)

# ---------------------------------------------------------------------------
# Search & retrieval defaults
# ---------------------------------------------------------------------------
DEFAULT_SEARCH_RESULTS = 5 # default n_results for searches
DEFAULT_L2_RESULTS = 10 # default results for Layer 2 on-demand retrieval
DUPLICATE_THRESHOLD = 0.9 # similarity threshold for duplicate detection
DUPLICATE_CANDIDATES = 5 # how many candidates to check for duplicates

# ---------------------------------------------------------------------------
# Graph traversal
# ---------------------------------------------------------------------------
DEFAULT_MAX_HOPS = 2 # BFS depth for palace graph traversal
GRAPH_BATCH_SIZE = 1000 # batch size when loading room data from ChromaDB
GRAPH_MAX_RESULTS = 50 # cap on traversal / tunnel results

# ---------------------------------------------------------------------------
# Entity detection
# ---------------------------------------------------------------------------
ENTITY_MAX_BYTES_PER_FILE = 5000 # first N bytes scanned per file
ENTITY_DEFAULT_MAX_FILES = 10 # max files to scan for entities

# ---------------------------------------------------------------------------
# Snippet truncation
# ---------------------------------------------------------------------------
L1_SNIPPET_MAX = 200 # max chars for a single L1 snippet
L2_SNIPPET_MAX = 300 # max chars for L2/L3 snippets
DUPLICATE_PREVIEW_MAX = 200 # max chars shown in duplicate preview

# ---------------------------------------------------------------------------
# Diary
# ---------------------------------------------------------------------------
DIARY_DEFAULT_LAST_N = 10 # default diary entries to return

15 changes: 8 additions & 7 deletions mempalace/convo_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import chromadb

from .normalize import normalize
from .constants import CONVO_MIN_CHUNK_SIZE


# File types that might contain conversations
Expand All @@ -41,15 +42,15 @@
".mempalace",
}

MIN_CHUNK_SIZE = 30
MIN_CHUNK_SIZE = CONVO_MIN_CHUNK_SIZE


# =============================================================================
# CHUNKING — exchange pairs for conversations
# =============================================================================


def chunk_exchanges(content: str) -> list:
def chunk_exchanges(content: str) -> list[dict]:
"""
Chunk by exchange pair: one > turn + AI response = one unit.
Falls back to paragraph chunking if no > markers.
Expand All @@ -63,7 +64,7 @@ def chunk_exchanges(content: str) -> list:
return _chunk_by_paragraph(content)


def _chunk_by_exchange(lines: list) -> list:
def _chunk_by_exchange(lines: list) -> list[dict]:
"""One user turn (>) + the AI response that follows = one chunk."""
chunks = []
i = 0
Expand Down Expand Up @@ -99,7 +100,7 @@ def _chunk_by_exchange(lines: list) -> list:
return chunks


def _chunk_by_paragraph(content: str) -> list:
def _chunk_by_paragraph(content: str) -> list[dict]:
"""Fallback: chunk by paragraph breaks."""
chunks = []
paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()]
Expand Down Expand Up @@ -209,7 +210,7 @@ def detect_convo_room(content: str) -> str:
# =============================================================================


def get_collection(palace_path: str):
def get_collection(palace_path: str) -> chromadb.Collection:
os.makedirs(palace_path, exist_ok=True)
client = chromadb.PersistentClient(path=palace_path)
try:
Expand All @@ -231,7 +232,7 @@ def file_already_mined(collection, source_file: str) -> bool:
# =============================================================================


def scan_convos(convo_dir: str) -> list:
def scan_convos(convo_dir: str) -> list[Path]:
"""Find all potential conversation files."""
convo_path = Path(convo_dir).expanduser().resolve()
files = []
Expand All @@ -257,7 +258,7 @@ def mine_convos(
limit: int = 0,
dry_run: bool = False,
extract_mode: str = "exchange",
):
) -> None:
"""Mine a directory of conversation files into the palace.

extract_mode:
Expand Down
61 changes: 41 additions & 20 deletions mempalace/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@
import chromadb

from .config import MempalaceConfig
from .constants import (
L1_MAX_DRAWERS,
L1_MAX_CHARS,
L1_SNIPPET_MAX,
L2_SNIPPET_MAX,
DEFAULT_L2_RESULTS,
DEFAULT_SEARCH_RESULTS,
GRAPH_BATCH_SIZE,
)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -80,8 +89,8 @@ class Layer1:
Groups by room, picks the top N moments, compresses to a compact summary.
"""

MAX_DRAWERS = 15 # at most 15 moments in wake-up
MAX_CHARS = 3200 # hard cap on total L1 text (~800 tokens)
MAX_DRAWERS = L1_MAX_DRAWERS
MAX_CHARS = L1_MAX_CHARS

def __init__(self, palace_path: str = None, wing: str = None):
cfg = MempalaceConfig()
Expand All @@ -96,19 +105,31 @@ def generate(self) -> str:
except Exception:
return "## L1 — No palace found. Run: mempalace mine <dir>"

# Fetch all drawers (with optional wing filter)
kwargs = {"include": ["documents", "metadatas"]}
if self.wing:
kwargs["where"] = {"wing": self.wing}

# Fetch drawers in batches (with optional wing filter)
docs = []
metas = []
total = col.count()
offset = 0
try:
results = col.get(**kwargs)
while offset < total:
kwargs = {
"include": ["documents", "metadatas"],
"limit": GRAPH_BATCH_SIZE,
"offset": offset,
}
if self.wing:
kwargs["where"] = {"wing": self.wing}
batch = col.get(**kwargs)
batch_docs = batch.get("documents", [])
batch_metas = batch.get("metadatas", [])
if not batch_docs:
break
docs.extend(batch_docs)
metas.extend(batch_metas)
offset += len(batch["ids"])
except Exception:
return "## L1 — No drawers found."

docs = results.get("documents", [])
metas = results.get("metadatas", [])

if not docs:
return "## L1 — No memories yet."

Expand Down Expand Up @@ -151,8 +172,8 @@ def generate(self) -> str:

# Truncate doc to keep L1 compact
snippet = doc.strip().replace("\n", " ")
if len(snippet) > 200:
snippet = snippet[:197] + "..."
if len(snippet) > L1_SNIPPET_MAX:
snippet = snippet[:L1_SNIPPET_MAX - 3] + "..."

entry_line = f" - {snippet}"
if source:
Expand Down Expand Up @@ -184,7 +205,7 @@ def __init__(self, palace_path: str = None):
cfg = MempalaceConfig()
self.palace_path = palace_path or cfg.palace_path

def retrieve(self, wing: str = None, room: str = None, n_results: int = 10) -> str:
def retrieve(self, wing: str = None, room: str = None, n_results: int = DEFAULT_L2_RESULTS) -> str:
"""Retrieve drawers filtered by wing and/or room."""
try:
client = chromadb.PersistentClient(path=self.palace_path)
Expand Down Expand Up @@ -223,8 +244,8 @@ def retrieve(self, wing: str = None, room: str = None, n_results: int = 10) -> s
room_name = meta.get("room", "?")
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
snippet = doc.strip().replace("\n", " ")
if len(snippet) > 300:
snippet = snippet[:297] + "..."
if len(snippet) > L2_SNIPPET_MAX:
snippet = snippet[:L2_SNIPPET_MAX - 3] + "..."
entry = f" [{room_name}] {snippet}"
if source:
entry += f" ({source})"
Expand All @@ -248,7 +269,7 @@ def __init__(self, palace_path: str = None):
cfg = MempalaceConfig()
self.palace_path = palace_path or cfg.palace_path

def search(self, query: str, wing: str = None, room: str = None, n_results: int = 5) -> str:
def search(self, query: str, wing: str = None, room: str = None, n_results: int = DEFAULT_SEARCH_RESULTS) -> str:
"""Semantic search, returns compact result text."""
try:
client = chromadb.PersistentClient(path=self.palace_path)
Expand Down Expand Up @@ -292,8 +313,8 @@ def search(self, query: str, wing: str = None, room: str = None, n_results: int
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""

snippet = doc.strip().replace("\n", " ")
if len(snippet) > 300:
snippet = snippet[:297] + "..."
if len(snippet) > L2_SNIPPET_MAX:
snippet = snippet[:L2_SNIPPET_MAX - 3] + "..."

lines.append(f" [{i}] {wing_name}/{room_name} (sim={similarity})")
lines.append(f" {snippet}")
Expand Down Expand Up @@ -402,7 +423,7 @@ def recall(self, wing: str = None, room: str = None, n_results: int = 10) -> str
"""On-demand L2 retrieval filtered by wing/room."""
return self.l2.retrieve(wing=wing, room=room, n_results=n_results)

def search(self, query: str, wing: str = None, room: str = None, n_results: int = 5) -> str:
def search(self, query: str, wing: str = None, room: str = None, n_results: int = DEFAULT_SEARCH_RESULTS) -> str:
"""Deep L3 semantic search."""
return self.l3.search(query, wing=wing, room=room, n_results=n_results)

Expand Down
Loading