Skip to content
Merged
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
24 changes: 13 additions & 11 deletions README.md

Large diffs are not rendered by default.

32 changes: 3 additions & 29 deletions mempalace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -843,40 +843,16 @@ def cmd_repair_status(args):


def cmd_repair(args):
"""Rebuild palace vector index, or reorganize derivative drawers."""
"""Rebuild palace vector index."""
import shutil
from .backends.chroma import ChromaBackend
from .migrate import (
confirm_destructive_action,
contains_palace_database,
migrate_checkpoints_to_recovery,
)
from .migrate import confirm_destructive_action, contains_palace_database
from .repair import TruncationDetected, check_extraction_safety
Comment on lines 845 to 850

palace_path = os.path.abspath(
os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
)

# mode=reorganize: move topic=checkpoint drawers from main → recovery.
# Non-destructive, idempotent. Designed to run on first daemon startup
# post-upgrade to land the checkpoint-collection split (phase D).
if getattr(args, "mode", "rebuild") == "reorganize":
if not os.path.isdir(palace_path) or not contains_palace_database(palace_path):
print(f"\n No palace database found at {palace_path}")
return
print(f"\n{'=' * 55}")
print(" MemPalace Reorganize — checkpoint → session-recovery")
print(f"{'=' * 55}\n")
print(f" Palace: {palace_path}")
moved = migrate_checkpoints_to_recovery(palace_path)
if moved == 0:
print(" Nothing to move — palace is already reorganized.")
else:
print(f" Moved {moved} checkpoint drawer(s) to mempalace_session_recovery.")
print(" mempalace_search now queries content-only.")
print(f"\n{'=' * 55}\n")
return

if getattr(args, "mode", "legacy") == "max-seq-id":
from .repair import repair_max_seq_id

Expand Down Expand Up @@ -1417,13 +1393,11 @@ def main():
)
p_repair.add_argument(
"--mode",
choices=["rebuild", "legacy", "reorganize", "max-seq-id"],
choices=["rebuild", "legacy", "max-seq-id"],
default="legacy",
help=(
"rebuild/legacy: full-palace HNSW rebuild via extract + re-upsert (default; "
"rebuild and legacy are synonyms). "
"reorganize: move existing topic=checkpoint drawers from the main "
"collection into mempalace_session_recovery (idempotent; safe to re-run). "
"max-seq-id: un-poison max_seq_id rows corrupted by the legacy 0.6.x shim."
),
)
Expand Down
195 changes: 4 additions & 191 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,7 @@
hnsw_capacity_status,
)
from .query_sanitizer import sanitize_query # noqa: E402
from .palace import _CHECKPOINT_TOPICS # noqa: E402
from .searcher import search_memories # noqa: E402
from .palace import _SESSION_RECOVERY_COLLECTION # noqa: E402
from .palace_graph import ( # noqa: E402
traverse,
find_tunnels,
Expand Down Expand Up @@ -115,7 +113,6 @@ def _parse_args():

_client_cache = None
_collection_cache = None
_recovery_collection_cache = None
_palace_db_inode = 0 # inode of chroma.sqlite3 at cache time
_palace_db_mtime = 0.0 # mtime of chroma.sqlite3 at cache time

Expand Down Expand Up @@ -234,7 +231,6 @@ def _get_client():
global \
_client_cache, \
_collection_cache, \
_recovery_collection_cache, \
_palace_db_inode, \
_palace_db_mtime, \
_metadata_cache, \
Expand Down Expand Up @@ -270,7 +266,6 @@ def _get_client():
_refresh_vector_disabled_flag()
_client_cache = ChromaBackend.make_client(_config.palace_path)
_collection_cache = None
_recovery_collection_cache = None
_metadata_cache = None
_metadata_cache_time = 0
_palace_db_inode = current_inode
Expand Down Expand Up @@ -343,43 +338,6 @@ def _get_collection(create=False):
return None


def _get_session_recovery_collection(create=False):
"""Return the session-recovery collection, caching between calls.

Stop-hook checkpoint diary entries route here instead of the main
``mempalace_drawers`` collection so they don't dominate
``mempalace_search`` results. Mirrors :func:`_get_collection`'s
shape (same client cache, same ``_pin_hnsw_threads`` retrofit,
same get-then-create / embedding_function= plumbing per #1262 /
#1289 / #1303).
"""
global _recovery_collection_cache
try:
client = _get_client()
if create:
ef = ChromaBackend._resolve_embedding_function()
ef_kwargs = {"embedding_function": ef} if ef is not None else {}
try:
raw = client.get_collection(_SESSION_RECOVERY_COLLECTION, **ef_kwargs)
except _ChromaNotFoundError:
raw = client.create_collection(
_SESSION_RECOVERY_COLLECTION,
metadata={"hnsw:space": "cosine", "hnsw:num_threads": 1},
**ef_kwargs,
)
_pin_hnsw_threads(raw)
_recovery_collection_cache = ChromaCollection(raw)
elif _recovery_collection_cache is None:
ef = ChromaBackend._resolve_embedding_function()
ef_kwargs = {"embedding_function": ef} if ef is not None else {}
raw = client.get_collection(_SESSION_RECOVERY_COLLECTION, **ef_kwargs)
_pin_hnsw_threads(raw)
_recovery_collection_cache = ChromaCollection(raw)
return _recovery_collection_cache
except Exception:
return None


def _no_palace():
return {
"error": "No palace found",
Expand Down Expand Up @@ -1229,11 +1187,9 @@ def tool_diary_write(
This is the agent's personal journal — observations, thoughts,
what it worked on, what it noticed, what it thinks matters.

When ``topic`` is a checkpoint topic (``checkpoint`` / ``auto-save``)
the entry is routed to the dedicated ``mempalace_session_recovery``
collection so it doesn't dominate ``mempalace_search`` results.
Pass ``session_id`` to enable filtering checkpoints by session via
``mempalace_session_recovery_read``.
All entries land in the main ``mempalace_drawers`` collection — the
earlier dedicated checkpoint collection has been retired (verbatim
transcripts already cover the recovery use case).

Note: ``agent_name`` is normalized to lowercase before storage so
that diary reads are case-insensitive (see #1243). "Claude",
Expand All @@ -1251,13 +1207,7 @@ def tool_diary_write(
else:
wing = f"wing_{agent_name.replace(' ', '_')}"
room = "diary"
# Stop-hook auto-save checkpoint entries land in the dedicated
# session-recovery collection so they don't dominate vector ranking
# in mempalace_search. Read via mempalace_session_recovery_read.
if topic in _CHECKPOINT_TOPICS:
col = _get_session_recovery_collection(create=True)
else:
col = _get_collection(create=True)
col = _get_collection(create=True)
if not col:
return _no_palace()

Expand Down Expand Up @@ -1383,103 +1333,6 @@ def tool_diary_read(agent_name: str, last_n: int = 10, wing: str = ""):
return {"error": "Failed to read diary entries"}


def tool_session_recovery_read(
session_id: str = "",
agent: str = "",
since: str = "",
until: str = "",
wing: str = "",
limit: int = 50,
):
"""
Read Stop-hook auto-save checkpoint entries from the dedicated
``mempalace_session_recovery`` collection. Used for session
recovery, hook auditing, and "what was I doing 2 hours ago" lookup.

All filters are optional — empty string / zero means "no filter":

- ``session_id``: only entries written under this Claude Code session
- ``agent``: only entries from this agent (typically ``session-hook``)
- ``since`` / ``until``: ISO date strings, inclusive bounds on filed_at
- ``wing``: only entries from this project wing
- ``limit``: maximum number of entries to return (default 50, max 500)

Entries are returned sorted by ``filed_at`` descending (newest first).
"""
try:
if agent:
agent = sanitize_name(agent, "agent")
if wing:
wing = sanitize_name(wing)
except ValueError as e:
return {"error": str(e), "entries": [], "total": 0}

limit = max(1, min(int(limit), 500))
col = _get_session_recovery_collection()
if not col:
# Recovery collection has never been created — no checkpoints
# have been written yet via the new routing. Return empty rather
# than erroring; caller's most likely interpretation is "no
# session-recovery data exists yet".
return {"entries": [], "total": 0}

# Build metadata where-clause from non-empty filters. ChromaDB needs
# a single condition or an explicit $and — we assemble accordingly.
conditions = []
if session_id:
conditions.append({"session_id": session_id})
if agent:
conditions.append({"agent": agent})
if wing:
conditions.append({"wing": wing})

where = None
if len(conditions) == 1:
where = conditions[0]
elif len(conditions) > 1:
where = {"$and": conditions}

try:
kwargs = {"include": ["documents", "metadatas"], "limit": 10000}
if where is not None:
kwargs["where"] = where
results = col.get(**kwargs)
except Exception:
logger.exception("session_recovery_read failed")
return {"error": "Failed to read recovery entries", "entries": [], "total": 0}

if not results.get("ids"):
return {"entries": [], "total": 0}

entries = []
for drawer_id, doc, meta in zip(results["ids"], results["documents"], results["metadatas"]):
# Defensive: ChromaDB may return None metadata for legacy /
# partial-write drawers (cf. #999, #1094, #1201). Coerce to {}.
meta = meta or {}
filed_at = meta.get("filed_at", "") or ""
if since and filed_at and filed_at < since:
continue
if until and filed_at and filed_at > until:
continue
entries.append(
{
"drawer_id": drawer_id,
"date": meta.get("date", ""),
"timestamp": filed_at,
"topic": meta.get("topic", ""),
"agent": meta.get("agent", ""),
"wing": meta.get("wing", ""),
"session_id": meta.get("session_id", ""),
"content": doc,
}
)

entries.sort(key=lambda x: x["timestamp"], reverse=True)
entries = entries[:limit]

return {"entries": entries, "total": len(entries)}


def tool_hook_settings(silent_save: bool = None, desktop_toast: bool = None):
"""
Get or set hook behavior settings.
Expand Down Expand Up @@ -2001,46 +1854,6 @@ def tool_reconnect():
},
"handler": tool_diary_read,
},
"mempalace_session_recovery_read": {
"description": (
"Read Stop-hook auto-save checkpoint entries from the dedicated "
"session-recovery collection. Use for session recovery, hook "
"auditing, or 'what was I doing 2 hours ago' lookup. Filters are "
"all optional; empty string / 0 means 'no filter'. Returns "
"entries newest-first."
),
"input_schema": {
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Filter by Claude Code session id.",
},
"agent": {
"type": "string",
"description": "Filter by agent name (typically 'session-hook').",
},
"since": {
"type": "string",
"description": "ISO datetime string — entries strictly newer than this.",
},
"until": {
"type": "string",
"description": "ISO datetime string — entries strictly older than this.",
},
"wing": {
"type": "string",
"description": "Filter by project wing.",
},
"limit": {
"type": "integer",
"description": "Max entries to return (default 50, max 500).",
},
},
"required": [],
},
"handler": tool_session_recovery_read,
},
"mempalace_hook_settings": {
"description": (
"Get or set hook behavior. silent_save: True = save directly "
Expand Down
Loading
Loading