Skip to content

Commit 798cf14

Browse files
jpheinclaude
andauthored
feat(palace): honor ~/.mempalace/RETIRED marker — refuse default palace, surface retire message (#111)
Recurring confusion source on this fork: any code path that opens mempalace without ``PALACE_DAEMON_URL`` set silently falls through to the default chroma palace at ``~/.mempalace/palace`` and reports a smaller, stale drawer count. Agents see "24,920 drawers" instead of an error, conclude they're looking at the palace, and proceed with wrong data. When a user has retired their local palace in favor of a daemon- routed setup, they can drop a ``~/.mempalace/RETIRED`` text file explaining the situation. This change makes three layers honor that marker: 1. ``mempalace/mcp_server.py:_check_local_palace_retired`` — ``_get_collection_chroma`` calls this at entry. If the marker exists AND the configured palace path resolves to the default, the open is refused and ``_no_palace`` returns a new error type ``palace.local_retired`` with the marker contents as the message. ``MEMPALACE_ALLOW_RETIRED_PALACE=1`` is the escape hatch. 2. ``mempalace/palace.py:_open_collection_or_explain`` — the four CLI-facing "No palace found" emit sites route through a new inner ``_emit_palace_missing()`` helper that checks the marker first and prints its content if present. 3. ``mempalace/cli.py:_print_retired_local_palace_or_default`` — four cmd_*-internal paths that used to print ``"No palace found at ... Run: mempalace init <dir>"`` directly now route through the helper. Tests (tests/test_local_palace_retired.py): 4 cases — - default-path + marker → emits retired text, no init hint - missing palace + no marker → emits legacy init hint - non-default explicit --palace → marker NOT triggered - MEMPALACE_ALLOW_RETIRED_PALACE=1 → check skipped Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7133eee commit 798cf14

4 files changed

Lines changed: 193 additions & 7 deletions

File tree

mempalace/cli.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,32 @@ class DaemonError(RuntimeError):
6262
"""Raised when a daemon HTTP call fails or returns a JSON-RPC error."""
6363

6464

65+
def _print_retired_local_palace_or_default(palace_path: str) -> None:
66+
"""If the user's default palace is missing AND a RETIRED marker
67+
exists, print the marker's content as the not-found message — so
68+
agents see "set PALACE_DAEMON_URL" instead of "Run: mempalace init".
69+
70+
Falls through to the legacy "Run: mempalace init" message in every
71+
other case (palace literally absent on a fresh install, etc.).
72+
"""
73+
palace_root = os.path.expanduser("~/.mempalace")
74+
marker = os.path.join(palace_root, "RETIRED")
75+
default_path = os.path.join(palace_root, "palace")
76+
is_default = os.path.abspath(palace_path) == os.path.abspath(default_path)
77+
if is_default and os.path.exists(marker):
78+
try:
79+
with open(marker) as f:
80+
note = f.read().rstrip()
81+
except OSError:
82+
note = "(marker unreadable)"
83+
print(f"\n Local palace at {default_path} is RETIRED.\n")
84+
for line in note.splitlines():
85+
print(f" {line}")
86+
return
87+
print(f"\n No palace found at {palace_path}")
88+
print(" Run: mempalace init <dir> then mempalace mine <dir>")
89+
90+
6591
def _daemon_strict() -> bool:
6692
"""True when daemon routing is on and strict mode is enabled.
6793
@@ -872,7 +898,7 @@ def cmd_sync(args):
872898
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
873899

874900
if not os.path.isdir(palace_path):
875-
print(f"\n No palace found at {palace_path}")
901+
_print_retired_local_palace_or_default(palace_path)
876902
return
877903
if not os.path.isfile(os.path.join(palace_path, "chroma.sqlite3")):
878904
print(f"\n Palace dir at {palace_path} exists but has no chroma.sqlite3 yet.")
@@ -1197,7 +1223,7 @@ def cmd_purge(args):
11971223
)
11981224

11991225
if not os.path.isdir(palace_path) or not contains_palace_database(palace_path):
1200-
print(f"\n No palace found at {palace_path}")
1226+
_print_retired_local_palace_or_default(palace_path)
12011227
return
12021228

12031229
source_file = getattr(args, "source_file", None)
@@ -1351,7 +1377,7 @@ def cmd_mined(args):
13511377
)
13521378

13531379
if not os.path.isdir(palace_path) or not contains_palace_database(palace_path):
1354-
print(f"\n No palace found at {palace_path}")
1380+
_print_retired_local_palace_or_default(palace_path)
13551381
return
13561382

13571383
backend = ChromaBackend()
@@ -1535,7 +1561,7 @@ def cmd_repair(args):
15351561
db_path = os.path.join(palace_path, "chroma.sqlite3")
15361562

15371563
if not os.path.isdir(palace_path):
1538-
print(f"\n No palace found at {palace_path}")
1564+
_print_retired_local_palace_or_default(palace_path)
15391565
return
15401566
if not contains_palace_database(palace_path):
15411567
print(f"\n No palace database found at {db_path}")

mempalace/mcp_server.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,44 @@ def _get_collection_postgres(create=False):
693693
return None
694694

695695

696+
def _check_local_palace_retired(palace_path: str) -> None:
697+
"""Refuse to open the default local palace when a RETIRED marker exists.
698+
699+
Recurring confusion source: an agent / CLI invocation that doesn't set
700+
``PALACE_DAEMON_URL`` silently falls through to the default chroma
701+
palace at ``~/.mempalace/palace`` and reports a smaller, stale drawer
702+
count instead of erroring. When a user has retired their local
703+
palace in favor of a daemon-routed setup, they can drop a
704+
``~/.mempalace/RETIRED`` text file. This check refuses to open ANY
705+
palace whose path resolves to the default location while that
706+
marker is present, and surfaces the marker's content as the error
707+
message.
708+
709+
Escape hatch: ``MEMPALACE_ALLOW_RETIRED_PALACE=1`` lets forensic
710+
reads of the archived palace proceed. Useful when explicitly
711+
passing ``--palace <archived-dir>``.
712+
"""
713+
if os.environ.get("MEMPALACE_ALLOW_RETIRED_PALACE", "").strip():
714+
return
715+
palace_root = os.path.expanduser("~/.mempalace")
716+
marker_path = os.path.join(palace_root, "RETIRED")
717+
if not os.path.exists(marker_path):
718+
return
719+
default_palace = os.path.join(palace_root, "palace")
720+
if os.path.abspath(palace_path) != os.path.abspath(default_palace):
721+
return # explicit non-default path; let the caller through
722+
try:
723+
with open(marker_path) as f:
724+
note = f.read().strip()
725+
except OSError:
726+
note = ""
727+
raise RuntimeError(
728+
f"local palace at {default_palace} is retired (see "
729+
f"~/.mempalace/RETIRED).\n\n{note}\n\n"
730+
"Set MEMPALACE_ALLOW_RETIRED_PALACE=1 to override (forensic only)."
731+
)
732+
733+
696734
def _get_collection_chroma(create=False):
697735
"""ChromaDB-backed branch of ``_get_collection``.
698736
@@ -702,6 +740,19 @@ def _get_collection_chroma(create=False):
702740
inline comments below.
703741
"""
704742
global _client_cache, _collection_cache, _metadata_cache, _metadata_cache_time
743+
global _last_backend_error
744+
# Honor the ~/.mempalace/RETIRED marker — refuse to silently open a
745+
# default-path palace when the user has retired it. Surfaces a clear
746+
# error via _no_palace() instead of returning a stale drawer count.
747+
try:
748+
_check_local_palace_retired(_config.palace_path)
749+
except RuntimeError as e:
750+
_last_backend_error = {
751+
"type": "LocalPalaceRetired",
752+
"message": str(e),
753+
"ts": time.time(),
754+
}
755+
return None
705756
for attempt in range(2):
706757
try:
707758
client = _get_client()
@@ -797,6 +848,15 @@ def _no_palace():
797848
here so the response carries the actual cause.
798849
"""
799850
err = _last_backend_error
851+
if err and err.get("type") == "LocalPalaceRetired":
852+
# User has explicitly retired the local palace (RETIRED marker
853+
# under ~/.mempalace/). Tell them exactly what to do — usually
854+
# set PALACE_DAEMON_URL — rather than the legacy "init" hint.
855+
return {
856+
"error": "palace.local_retired",
857+
"message": err.get("message", "local palace retired"),
858+
"hint": "Set PALACE_DAEMON_URL to use the daemon, or MEMPALACE_ALLOW_RETIRED_PALACE=1 for forensic local reads.",
859+
}
800860
if err and err.get("type") == "OperationalError":
801861
return {
802862
"error": "palace.backend_unreachable",

mempalace/palace.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,31 @@ def _open_collection_or_explain(
156156
"""
157157
emit = out if out is not None else print
158158

159-
if not os.path.isdir(palace_path):
159+
def _emit_palace_missing() -> None:
160+
"""Emit either the RETIRED marker content (if the default local
161+
palace has been retired) or the legacy "No palace found / Run:
162+
mempalace init" message. Single source of truth for the four
163+
not-found paths below.
164+
"""
165+
palace_root = os.path.expanduser("~/.mempalace")
166+
marker = os.path.join(palace_root, "RETIRED")
167+
default_path = os.path.join(palace_root, "palace")
168+
is_default = os.path.abspath(palace_path) == os.path.abspath(default_path)
169+
if is_default and os.path.exists(marker):
170+
try:
171+
with open(marker) as f:
172+
note = f.read().rstrip()
173+
except OSError:
174+
note = "(marker unreadable)"
175+
emit(f"\n Local palace at {default_path} is RETIRED.\n")
176+
for line in note.splitlines():
177+
emit(f" {line}")
178+
return
160179
emit(f"\n No palace found at {palace_path}")
161180
emit(" Run: mempalace init <dir> then mempalace mine <dir>")
181+
182+
if not os.path.isdir(palace_path):
183+
_emit_palace_missing()
162184
return None
163185
if not os.path.isfile(os.path.join(palace_path, "chroma.sqlite3")):
164186
emit(f"\n Palace dir at {palace_path} exists but has no chroma.sqlite3 yet.")
@@ -171,8 +193,7 @@ def _open_collection_or_explain(
171193
emit(" Run: mempalace mine <dir>")
172194
return None
173195
except PalaceNotFoundError:
174-
emit(f"\n No palace found at {palace_path}")
175-
emit(" Run: mempalace init <dir> then mempalace mine <dir>")
196+
_emit_palace_missing()
176197
return None
177198
except BackendClosedError:
178199
# Surface this as a programmer error, not a palace-state UX message:

tests/test_local_palace_retired.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Tests for the RETIRED-marker check in _open_collection_or_explain
2+
and _check_local_palace_retired."""
3+
4+
import os
5+
6+
7+
def test_open_collection_emits_retired_marker_when_default_path_missing(
8+
tmp_path, monkeypatch, capsys
9+
):
10+
"""If ~/.mempalace/RETIRED exists and palace_path is the default
11+
(~/.mempalace/palace) and the dir is absent, the helper emits the
12+
marker content instead of 'Run: mempalace init'."""
13+
from mempalace import palace
14+
15+
fake_home = tmp_path / "home"
16+
fake_home.mkdir()
17+
(fake_home / ".mempalace").mkdir()
18+
(fake_home / ".mempalace" / "RETIRED").write_text(
19+
"local palace retired 2026-05-14\nset PALACE_DAEMON_URL\n"
20+
)
21+
22+
monkeypatch.setattr(os.path, "expanduser", lambda p: p.replace("~", str(fake_home)))
23+
24+
default_palace = str(fake_home / ".mempalace" / "palace")
25+
out_lines: list[str] = []
26+
result = palace._open_collection_or_explain(default_palace, out=out_lines.append)
27+
assert result is None
28+
joined = "\n".join(out_lines)
29+
assert "RETIRED" in joined
30+
assert "retired 2026-05-14" in joined
31+
assert "set PALACE_DAEMON_URL" in joined
32+
assert "Run: mempalace init" not in joined
33+
34+
35+
def test_open_collection_falls_through_to_init_hint_when_no_marker(tmp_path):
36+
"""When no RETIRED marker exists, the helper emits the legacy
37+
'No palace found / Run: mempalace init' message."""
38+
from mempalace import palace
39+
40+
missing = str(tmp_path / "nope")
41+
out_lines: list[str] = []
42+
result = palace._open_collection_or_explain(missing, out=out_lines.append)
43+
assert result is None
44+
joined = "\n".join(out_lines)
45+
assert "Run: mempalace init" in joined
46+
assert "RETIRED" not in joined
47+
48+
49+
def test_open_collection_ignores_marker_for_non_default_path(tmp_path, monkeypatch):
50+
"""The RETIRED marker only gates the DEFAULT path. An explicit
51+
--palace <other-dir> bypasses it (used for forensic reads of the
52+
archived palace)."""
53+
from mempalace import palace
54+
55+
fake_home = tmp_path / "home"
56+
(fake_home / ".mempalace").mkdir(parents=True)
57+
(fake_home / ".mempalace" / "RETIRED").write_text("retired")
58+
monkeypatch.setattr(os.path, "expanduser", lambda p: p.replace("~", str(fake_home)))
59+
60+
explicit = str(tmp_path / "some-other-palace")
61+
out_lines: list[str] = []
62+
palace._open_collection_or_explain(explicit, out=out_lines.append)
63+
joined = "\n".join(out_lines)
64+
assert "RETIRED" not in joined # marker NOT triggered for explicit path
65+
assert "Run: mempalace init" in joined
66+
67+
68+
def test_check_retired_palace_skipped_with_escape_hatch(tmp_path, monkeypatch):
69+
"""MEMPALACE_ALLOW_RETIRED_PALACE=1 lets forensic reads through."""
70+
from mempalace.mcp_server import _check_local_palace_retired
71+
72+
fake_home = tmp_path / "home"
73+
(fake_home / ".mempalace").mkdir(parents=True)
74+
(fake_home / ".mempalace" / "RETIRED").write_text("retired")
75+
monkeypatch.setattr(os.path, "expanduser", lambda p: p.replace("~", str(fake_home)))
76+
monkeypatch.setenv("MEMPALACE_ALLOW_RETIRED_PALACE", "1")
77+
78+
default_palace = str(fake_home / ".mempalace" / "palace")
79+
_check_local_palace_retired(default_palace) # must not raise

0 commit comments

Comments
 (0)