@@ -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+
19292134def 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