Skip to content

Commit 6f994fb

Browse files
jpheinclaude
andcommitted
feat(cli): mempalace stats — palace analytics dashboard (#191)
Composes mempalace_status + mempalace_kg_stats + mempalace_graph_stats (and optionally mempalace_list_tags) into a single read-only view of corpus health. Renders wings with visual bars, KG entity/triple counts with relationship-type preview, graph room/tunnel/edge counts with the top cross-wing tunnel rooms, and an opt-in tag breakdown. Daemon-only — the KG and graph data lives in the daemon's postgres + AGE store, so a local fallback would surface misleading partial views from a stale chromadb. Aborts with the same "set PALACE_DAEMON_URL" hint as the rest of the CLI's daemon-strict surfaces when the URL is unset. Standard --json/--quiet/--top flags. Sub-call failures (KG offline, graph unreachable) inline the error in the relevant section instead of blanking the whole dashboard, so a partial daemon outage still surfaces the data that survived. 13 tests in test_cli_stats.py — dashboard render, --json payload shape, --tags toggle, --top=0 unbounded mode, missing-daemon abort, daemon unreachable, partial KG failure, and parser wiring for both ``mempalace stats --json`` and ``mempalace --json stats``. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 110a8b5 commit 6f994fb

2 files changed

Lines changed: 616 additions & 0 deletions

File tree

mempalace/cli.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1926,6 +1926,211 @@ def cmd_mined(args):
19261926
print(f"{'=' * 55}\n")
19271927

19281928

1929+
def _stats_bar(count: int, total: int, width: int = 24) -> str:
1930+
"""Render a horizontal bar proportional to ``count`` against ``total``.
1931+
1932+
Uses Unicode block-element fills so a row at half the max draws to
1933+
roughly half the bar width. Empty when ``total`` is zero so we never
1934+
divide by zero on a freshly-initialised palace.
1935+
"""
1936+
if total <= 0:
1937+
return ""
1938+
ratio = max(0.0, min(1.0, count / total))
1939+
filled = int(round(ratio * width))
1940+
return "█" * filled + "░" * (width - filled)
1941+
1942+
1943+
def _gather_daemon_stats(want_tags: bool) -> dict:
1944+
"""Fan out daemon calls for the ``stats`` dashboard.
1945+
1946+
Each section is independent — KG/graph failures don't blank the whole
1947+
dashboard. We catch :class:`DaemonError` per section and inline a
1948+
``"_error"`` key so the renderer can surface "(KG unavailable: ...)"
1949+
next to that block instead of bailing out entirely. The daemon's
1950+
overall reachability is owned by the caller via ``mempalace_status``;
1951+
if that one fails the whole command is aborted upstream.
1952+
"""
1953+
bundle: dict = {}
1954+
1955+
status_data = _call_daemon_tool("mempalace_status", {})
1956+
bundle["status"] = status_data
1957+
1958+
try:
1959+
bundle["kg"] = _call_daemon_tool("mempalace_kg_stats", {})
1960+
except DaemonError as e:
1961+
bundle["kg"] = {"_error": str(e)}
1962+
1963+
try:
1964+
bundle["graph"] = _call_daemon_tool("mempalace_graph_stats", {})
1965+
except DaemonError as e:
1966+
bundle["graph"] = {"_error": str(e)}
1967+
1968+
if want_tags:
1969+
try:
1970+
bundle["tags"] = _call_daemon_tool("mempalace_list_tags", {"min_count": 1})
1971+
except DaemonError as e:
1972+
bundle["tags"] = {"_error": str(e)}
1973+
1974+
return bundle
1975+
1976+
1977+
def _print_stats_dashboard(bundle: dict, top: int) -> None:
1978+
"""Render the stats bundle as a human-friendly dashboard.
1979+
1980+
Mirrors ``_print_daemon_status``'s 55-char rules + two-space indent
1981+
style so ``status`` and ``stats`` look like siblings to a human
1982+
skimming the terminal.
1983+
"""
1984+
status = bundle.get("status") or {}
1985+
total = status.get("total_drawers", 0)
1986+
wings = status.get("wings") or {}
1987+
1988+
print(f"\n{'=' * 60}")
1989+
print(f" MemPalace Stats — {total} drawers")
1990+
print(f" via palace-daemon @ {_daemon_url()}")
1991+
print(f"{'=' * 60}\n")
1992+
1993+
print(" WINGS")
1994+
print(f" {'-' * 56}")
1995+
if isinstance(wings, dict) and wings:
1996+
items = sorted(
1997+
((w, c if isinstance(c, int) else (c or {}).get("total", 0)) for w, c in wings.items()),
1998+
key=lambda kv: kv[1],
1999+
reverse=True,
2000+
)
2001+
max_count = items[0][1] if items else 0
2002+
shown = items[:top] if top else items
2003+
for wing, count in shown:
2004+
bar = _stats_bar(count, max_count)
2005+
print(f" {wing:<28} {count:>7} {bar}")
2006+
remaining = len(items) - len(shown)
2007+
if remaining > 0:
2008+
tail = sum(c for _, c in items[len(shown):])
2009+
print(f" ... {remaining} more wings ({tail} drawers; --top 0 shows all)")
2010+
elif "error" in status:
2011+
print(f" (status error: {status.get('error')})")
2012+
else:
2013+
print(" (no wings)")
2014+
print()
2015+
2016+
kg = bundle.get("kg") or {}
2017+
print(" KNOWLEDGE GRAPH")
2018+
print(f" {'-' * 56}")
2019+
if "_error" in kg:
2020+
print(f" (unavailable: {kg['_error']})")
2021+
elif "error" in kg:
2022+
print(f" (error: {kg.get('error')})")
2023+
else:
2024+
print(f" entities : {kg.get('entities', 0):>7}")
2025+
print(f" triples : {kg.get('triples', 0):>7}")
2026+
print(f" current facts : {kg.get('current_facts', 0):>7}")
2027+
print(f" expired facts : {kg.get('expired_facts', 0):>7}")
2028+
rels = kg.get("relationship_types") or []
2029+
if rels:
2030+
preview = ", ".join(rels[:8])
2031+
suffix = f" (+{len(rels) - 8} more)" if len(rels) > 8 else ""
2032+
print(f" relations ({len(rels):>2}) : {preview}{suffix}")
2033+
print()
2034+
2035+
graph = bundle.get("graph") or {}
2036+
print(" GRAPH")
2037+
print(f" {'-' * 56}")
2038+
if "_error" in graph:
2039+
print(f" (unavailable: {graph['_error']})")
2040+
elif "error" in graph:
2041+
print(f" (error: {graph.get('error')})")
2042+
else:
2043+
print(f" total rooms : {graph.get('total_rooms', 0):>7}")
2044+
print(f" tunnel rooms : {graph.get('tunnel_rooms', 0):>7} (rooms shared by 2+ wings)")
2045+
print(f" edges : {graph.get('total_edges', 0):>7}")
2046+
top_tunnels = graph.get("top_tunnels") or []
2047+
if top_tunnels:
2048+
print(" top tunnels :")
2049+
for t in top_tunnels[: min(5, top) if top else 5]:
2050+
wing_list = ", ".join(t.get("wings") or [])
2051+
print(f" - {t.get('room', '?'):<22} [{wing_list}]")
2052+
print()
2053+
2054+
if "tags" in bundle:
2055+
tags = bundle["tags"] or {}
2056+
print(" TAGS")
2057+
print(f" {'-' * 56}")
2058+
if "_error" in tags:
2059+
print(f" (unavailable: {tags['_error']})")
2060+
elif "error" in tags:
2061+
print(f" (error: {tags.get('error')})")
2062+
else:
2063+
items = tags.get("tags") or []
2064+
if not items:
2065+
print(" (no tags)")
2066+
else:
2067+
max_count = items[0].get("count", 0) if items else 0
2068+
shown = items[:top] if top else items
2069+
for entry in shown:
2070+
tag = entry.get("tag", "?")
2071+
count = entry.get("count", 0)
2072+
bar = _stats_bar(count, max_count, width=18)
2073+
print(f" {tag:<28} {count:>5} {bar}")
2074+
remaining = len(items) - len(shown)
2075+
if remaining > 0:
2076+
print(f" ... {remaining} more tags (--top 0 shows all)")
2077+
print()
2078+
2079+
print(f"{'=' * 60}\n")
2080+
2081+
2082+
def cmd_stats(args):
2083+
"""Palace analytics dashboard (#191).
2084+
2085+
Composes ``mempalace_status`` + ``mempalace_kg_stats`` +
2086+
``mempalace_graph_stats`` (and optionally ``mempalace_list_tags``)
2087+
into a single read-only view of corpus health. Daemon-only — there is
2088+
no local fallback today because the KG/graph data lives in the
2089+
daemon's postgres + AGE store; surfacing a misleading partial view
2090+
from a stale local chromadb would re-introduce the split-brain
2091+
``status`` already warns against. When the daemon URL is unset, we
2092+
abort with the same "set PALACE_DAEMON_URL" hint as the rest of the
2093+
CLI's daemon-strict surfaces.
2094+
"""
2095+
want_json = getattr(args, "json", False)
2096+
want_tags = getattr(args, "tags", False)
2097+
top = max(0, getattr(args, "top", 10) or 0)
2098+
2099+
if not _daemon_url():
2100+
msg = (
2101+
"stats requires the palace-daemon. Set PALACE_DAEMON_URL "
2102+
"(or daemon_url in ~/.mempalace/config.json) and retry."
2103+
)
2104+
if want_json:
2105+
_emit_json({"error": "daemon_required", "hint": msg})
2106+
else:
2107+
print(f"\n ERROR: {msg}", file=sys.stderr)
2108+
sys.exit(2)
2109+
2110+
try:
2111+
bundle = _gather_daemon_stats(want_tags=want_tags)
2112+
except DaemonError as e:
2113+
if want_json:
2114+
_emit_json({"error": str(e), "source": "daemon"})
2115+
else:
2116+
print(f"\n ERROR: {e}", file=sys.stderr)
2117+
sys.exit(2)
2118+
2119+
if want_json:
2120+
payload = {
2121+
"total_drawers": (bundle.get("status") or {}).get("total_drawers", 0),
2122+
"wings": (bundle.get("status") or {}).get("wings") or {},
2123+
"kg": bundle.get("kg") or {},
2124+
"graph": bundle.get("graph") or {},
2125+
}
2126+
if want_tags:
2127+
payload["tags"] = bundle.get("tags") or {}
2128+
_emit_json(payload)
2129+
return
2130+
2131+
_print_stats_dashboard(bundle, top=top)
2132+
2133+
19292134
def cmd_repair_status(args):
19302135
"""Read-only HNSW capacity health check (#1222)."""
19312136
from .repair import status as repair_status
@@ -2926,6 +3131,23 @@ def _nonneg_int(value: str) -> int:
29263131
help="Show at most this many sources per wing (default 50; 0 means show all)",
29273132
)
29283133

3134+
# stats — palace analytics dashboard (#191)
3135+
p_stats = sub.add_parser(
3136+
"stats",
3137+
help="Palace analytics dashboard (wings, knowledge graph, tunnels, tags)",
3138+
)
3139+
p_stats.add_argument(
3140+
"--top",
3141+
type=_nonneg_int,
3142+
default=10,
3143+
help="Show at most this many rows per section (default 10; 0 means show all)",
3144+
)
3145+
p_stats.add_argument(
3146+
"--tags",
3147+
action="store_true",
3148+
help="Include the tag-count breakdown (extra daemon call)",
3149+
)
3150+
29293151
# ── Propagate --json/--quiet to every subparser (issue #44) ─────
29303152
# argparse parses pre-subcommand flags into ``args.json`` /
29313153
# ``args.quiet`` only if they appear BEFORE the subcommand. To let
@@ -3012,6 +3234,7 @@ def _nonneg_int(value: str) -> int:
30123234
"rename-wing": cmd_rename_wing,
30133235
"rooms": cmd_rooms,
30143236
"status": cmd_status,
3237+
"stats": cmd_stats,
30153238
"mined": cmd_mined,
30163239
"replay": cmd_replay,
30173240
}

0 commit comments

Comments
 (0)