Skip to content

Commit 22ef562

Browse files
jpheinclaude
andcommitted
feat(cli): daemon-route status, search, mine when PALACE_DAEMON_URL is set
Completes the desktop-side migration: with mcp_server already daemon-routed in 41359ba, the CLI was the last consumer that opened the local chromadb client. Adds the same _daemon_strict gate from hooks_cli/mcp_server, gates cmd_status/cmd_search/cmd_mine, and prints daemon-sourced output formatted to mirror the local commands. Helpers: _call_daemon_tool(name, args) → JSON-RPC tools/call against /mcp; raises DaemonError on network or JSON-RPC error (no silent fallback — strict mode means strict). _post_daemon_mine_cli(...) → POST /mine with CLI-friendly errors on stderr (hooks_cli's variant logs silently because a missed-mine shouldn't crash a hook; here the user invoked `mempalace mine` and wants to see failures). _print_daemon_status / _print_daemon_search → format the JSON responses to match the local miner.status / searcher.search output the user already knows. --palace <path> always overrides routing: explicit path means the user asked for THAT palace, not the canonical one. Local-only commands (init, repair, export, sweep, purge, mined, wakeup) stay local — they need on-host filesystem access (HNSW rebuild, palace dump, sweeper deduplication state). When mempalace-data/ is archived they'll fail with "no palace found" until pointed elsewhere with --palace, which is the right "your data is at the daemon, not local" signpost. 14 new tests in tests/test_cli_daemon.py — gate semantics, _call_daemon_tool body shape + JSON-RPC error surfacing, mine routing in both projects and convos modes, and falls-through-locally when the env var is unset. Live smoke against disks.jphe.in:8085 confirms `mempalace status` returns 160,351 drawers and `mempalace search` returns properly-formatted hits. Suite 1591 passed (1577 + 14 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 41359ba commit 22ef562

2 files changed

Lines changed: 595 additions & 0 deletions

File tree

mempalace/cli.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
import os
3333
import sys
34+
import json
3435
import shlex
3536
import argparse
3637
from pathlib import Path
@@ -41,6 +42,215 @@
4142
from .version import __version__
4243

4344

45+
# ==================== DAEMON ROUTING ====================
46+
# When ``PALACE_DAEMON_URL`` is set, palace-daemon is the single writer
47+
# for the canonical palace and high-traffic CLI subcommands route there
48+
# instead of opening a local chromadb client. Mirrors the gate already
49+
# in ``mempalace.hooks_cli`` (mining side) and
50+
# ``mempalace.mcp_server`` (MCP dispatch). Currently routes ``status``,
51+
# ``search``, and ``mine``; the remaining subcommands (``repair``,
52+
# ``export``, ``sweep``, ``init``) still need on-host filesystem access
53+
# and stay local. When the daemon URL is unset, all paths run locally
54+
# unchanged.
55+
56+
_DAEMON_TIMEOUT_DEFAULT = 120 # seconds; tune via PALACE_MCP_TIMEOUT
57+
58+
59+
class DaemonError(RuntimeError):
60+
"""Raised when a daemon HTTP call fails or returns a JSON-RPC error."""
61+
62+
63+
def _daemon_strict() -> bool:
64+
"""True when ``PALACE_DAEMON_URL`` is set and strict mode is enabled.
65+
66+
Set ``PALACE_DAEMON_STRICT=0`` to opt out and force the local-palace
67+
path even when the daemon URL is configured.
68+
"""
69+
return (
70+
os.environ.get("PALACE_DAEMON_URL", "").strip() != ""
71+
and os.environ.get("PALACE_DAEMON_STRICT", "1") != "0"
72+
)
73+
74+
75+
def _daemon_url() -> str:
76+
return os.environ.get("PALACE_DAEMON_URL", "").strip().rstrip("/")
77+
78+
79+
def _daemon_timeout() -> int:
80+
raw = os.environ.get("PALACE_MCP_TIMEOUT", str(_DAEMON_TIMEOUT_DEFAULT))
81+
try:
82+
return int(raw)
83+
except ValueError:
84+
return _DAEMON_TIMEOUT_DEFAULT
85+
86+
87+
def _call_daemon_tool(name: str, arguments: dict) -> dict:
88+
"""JSON-RPC ``tools/call`` against the daemon's ``/mcp`` endpoint.
89+
90+
Returns the inner tool result already parsed from the JSON text
91+
payload (the ``content[0].text`` envelope MCP wraps every tool
92+
response in). Raises :class:`DaemonError` on network failure or a
93+
JSON-RPC error response — the CLI must surface failures to the
94+
caller, never silently fall back to local (that would re-introduce
95+
the split-brain that daemon-strict was created to prevent).
96+
"""
97+
import urllib.error
98+
import urllib.request
99+
100+
request = {
101+
"jsonrpc": "2.0",
102+
"id": 1,
103+
"method": "tools/call",
104+
"params": {"name": name, "arguments": arguments},
105+
}
106+
headers = {"content-type": "application/json"}
107+
api_key = os.environ.get("PALACE_API_KEY", "").strip()
108+
if api_key:
109+
headers["x-api-key"] = api_key
110+
req = urllib.request.Request(
111+
f"{_daemon_url()}/mcp",
112+
data=json.dumps(request).encode("utf-8"),
113+
headers=headers,
114+
method="POST",
115+
)
116+
try:
117+
with urllib.request.urlopen(req, timeout=_daemon_timeout()) as resp:
118+
body = resp.read()
119+
envelope = json.loads(body.decode("utf-8", errors="replace"))
120+
except (urllib.error.URLError, ConnectionError, OSError, json.JSONDecodeError) as e:
121+
raise DaemonError(f"daemon unreachable at {_daemon_url()}: {e}") from e
122+
if "error" in envelope:
123+
err = envelope["error"]
124+
raise DaemonError(f"daemon error {err.get('code')}: {err.get('message')}")
125+
content = (envelope.get("result") or {}).get("content") or []
126+
if not content:
127+
return {}
128+
text = content[0].get("text") or ""
129+
try:
130+
return json.loads(text)
131+
except json.JSONDecodeError:
132+
# Non-JSON tool output (rare). Return as-is for the caller to
133+
# decide what to do.
134+
return {"_raw": text}
135+
136+
137+
def _post_daemon_mine_cli(directory: str, wing: str, mode: str = "convos") -> bool:
138+
"""POST a mine request to the daemon's ``/mine`` endpoint.
139+
140+
CLI-shaped variant of :func:`mempalace.hooks_cli._post_daemon_mine`:
141+
on failure, prints to stderr and returns ``False`` so the caller can
142+
``sys.exit(1)``. Hooks_cli's version logs to a file and swallows
143+
silently because a missed-mine isn't worth crashing a hook over;
144+
here, the user invoked `mempalace mine` and expects to see errors.
145+
"""
146+
import urllib.error
147+
import urllib.request
148+
149+
headers = {"content-type": "application/json"}
150+
api_key = os.environ.get("PALACE_API_KEY", "").strip()
151+
if api_key:
152+
headers["x-api-key"] = api_key
153+
req = urllib.request.Request(
154+
f"{_daemon_url()}/mine",
155+
data=json.dumps({"dir": directory, "wing": wing, "mode": mode}).encode("utf-8"),
156+
headers=headers,
157+
method="POST",
158+
)
159+
try:
160+
with urllib.request.urlopen(req, timeout=_daemon_timeout()) as resp:
161+
body = resp.read().decode("utf-8", errors="replace")
162+
print(f" Daemon mine accepted: {body[:200]}")
163+
return True
164+
except (urllib.error.URLError, ConnectionError, OSError) as e:
165+
print(f" ERROR: daemon mine failed: {e}", file=sys.stderr)
166+
return False
167+
168+
169+
def _print_daemon_status(data: dict) -> None:
170+
"""Format ``mempalace_status`` JSON for human reading.
171+
172+
The daemon's status tool returns a flat ``wings: {name: count}``
173+
dict (not the wing×room nested shape that ``miner.status`` builds
174+
from raw metadata). We print the daemon's shape rather than
175+
over-fetching to reconstruct the local layout — the daemon URL is
176+
visible in the header so the reader knows which view they're
177+
looking at.
178+
"""
179+
import json as _json
180+
181+
total = data.get("total_drawers", 0)
182+
wings = data.get("wings") or {}
183+
print(f"\n{'=' * 55}")
184+
print(f" MemPalace Status — {total} drawers")
185+
print(f" via palace-daemon @ {_daemon_url()}")
186+
print(f"{'=' * 55}\n")
187+
if isinstance(wings, dict) and wings:
188+
for wing, count in sorted(wings.items(), key=lambda kv: kv[1], reverse=True):
189+
if isinstance(count, dict):
190+
# Defensive: if a future daemon returns wing×room nested,
191+
# still render something useful.
192+
inner = count.get("total", sum((count.get("rooms") or {}).values()))
193+
print(f" WING: {wing:30} {inner:>6} drawers")
194+
else:
195+
print(f" WING: {wing:30} {count:>6} drawers")
196+
elif "error" in data:
197+
print(f" daemon reported error: {data['error']}")
198+
else:
199+
# Surface unexpected shapes verbatim.
200+
print(_json.dumps(data, indent=2))
201+
print(f"\n{'=' * 55}\n")
202+
203+
204+
def _print_daemon_search(query: str, data: dict, wing: str = None, room: str = None) -> None:
205+
"""Format ``mempalace_search`` JSON; mirrors ``searcher.search`` output."""
206+
if "error" in data and not data.get("results"):
207+
print(f"\n {data['error']}")
208+
if "hint" in data:
209+
print(f" {data['hint']}")
210+
raise DaemonError(data["error"])
211+
212+
hits = data.get("results") or []
213+
warnings = data.get("warnings") or []
214+
215+
if not hits:
216+
print(f'\n No results found for: "{query}"')
217+
for w in warnings:
218+
print(f" ! {w}")
219+
return
220+
221+
print(f"\n{'=' * 60}")
222+
print(f' Results for: "{query}"')
223+
if wing:
224+
print(f" Wing: {wing}")
225+
if room:
226+
print(f" Room: {room}")
227+
if data.get("available_in_scope") is not None:
228+
print(f" Scope has: {data['available_in_scope']} drawers matching filter")
229+
if warnings:
230+
for w in warnings:
231+
print(f" ! {w}")
232+
print(f" via palace-daemon @ {_daemon_url()}")
233+
print(f"{'=' * 60}\n")
234+
235+
for i, hit in enumerate(hits, 1):
236+
print(f" [{i}] {hit.get('wing', '?')} / {hit.get('room', '?')}")
237+
print(f" Source: {hit.get('source_file', '?')}")
238+
sim = hit.get("similarity")
239+
bm25 = hit.get("bm25_score")
240+
if sim is not None and bm25 is not None:
241+
print(f" Match: cosine={sim} bm25={bm25}")
242+
elif sim is not None:
243+
print(f" Match: {sim}")
244+
elif bm25 is not None:
245+
print(f" BM25: {bm25} (matched_via: {hit.get('matched_via', 'drawer')})")
246+
print()
247+
for line in (hit.get("text") or "").strip().split("\n"):
248+
print(f" {line}")
249+
print()
250+
print(f" {'─' * 56}")
251+
print()
252+
253+
44254
_MEMPALACE_PROJECT_FILES = ("mempalace.yaml", "entities.json")
45255

46256
# Pass 0 corpus-origin sampling caps. Tier 1 reads FULL file content (no
@@ -487,6 +697,39 @@ def _maybe_run_mine_after_init(args, cfg) -> None:
487697

488698

489699
def cmd_mine(args):
700+
if _daemon_strict() and not args.palace:
701+
# Daemon-strict: route to /mine. The daemon owns the canonical
702+
# palace and its filesystem layout, and translates client-side
703+
# paths via PALACE_DAEMON_PATH_MAP. Flags that only make sense
704+
# on the local FS (--redetect-origin, --include-ignored,
705+
# --no-gitignore, --dry-run) are warned about but not passed —
706+
# the daemon's /mine endpoint does not expose them.
707+
ignored_flags = []
708+
if getattr(args, "redetect_origin", False):
709+
ignored_flags.append("--redetect-origin")
710+
if getattr(args, "dry_run", False):
711+
ignored_flags.append("--dry-run")
712+
if getattr(args, "no_gitignore", False):
713+
ignored_flags.append("--no-gitignore")
714+
if getattr(args, "include_ignored", None):
715+
ignored_flags.append("--include-ignored")
716+
if ignored_flags:
717+
print(
718+
f" WARN: daemon-strict mode ignores these local-only flags: {', '.join(ignored_flags)}",
719+
file=sys.stderr,
720+
)
721+
722+
directory = os.path.abspath(os.path.expanduser(args.dir))
723+
wing = args.wing
724+
if not wing:
725+
# Match local-mine semantics: derive wing from directory name
726+
# the same way miner / convo_miner do when --wing is omitted.
727+
from .config import normalize_wing_name
728+
729+
wing = normalize_wing_name(Path(directory).name)
730+
ok = _post_daemon_mine_cli(directory, wing=wing, mode=args.mode)
731+
sys.exit(0 if ok else 1)
732+
490733
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
491734
include_ignored = []
492735
for raw in args.include_ignored or []:
@@ -572,6 +815,20 @@ def cmd_sweep(args):
572815

573816

574817
def cmd_search(args):
818+
if _daemon_strict() and not args.palace:
819+
arguments = {"query": args.query, "max_results": args.results}
820+
if args.wing:
821+
arguments["wing"] = args.wing
822+
if args.room:
823+
arguments["room"] = args.room
824+
try:
825+
data = _call_daemon_tool("mempalace_search", arguments)
826+
_print_daemon_search(args.query, data, wing=args.wing, room=args.room)
827+
except DaemonError as e:
828+
print(f"\n ERROR: {e}", file=sys.stderr)
829+
sys.exit(1)
830+
return
831+
575832
from .searcher import search, SearchError
576833

577834
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
@@ -743,6 +1000,17 @@ def cmd_purge(args):
7431000

7441001

7451002
def cmd_status(args):
1003+
if _daemon_strict() and not args.palace:
1004+
# --palace overrides routing: an explicit local-path argument
1005+
# means the user wants to inspect THAT palace, not the daemon.
1006+
try:
1007+
data = _call_daemon_tool("mempalace_status", {})
1008+
except DaemonError as e:
1009+
print(f"\n ERROR: {e}", file=sys.stderr)
1010+
sys.exit(1)
1011+
_print_daemon_status(data)
1012+
return
1013+
7461014
from .miner import status
7471015

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

0 commit comments

Comments
 (0)