@@ -1805,6 +1805,263 @@ def cmd_list(args):
18051805 _print_list_table (data )
18061806
18071807
1808+ # ── mempalace graph ───────────────────────────────────────────────────
1809+ #
1810+ # Pre-aggregated structural snapshot: wings, rooms, passive tunnels,
1811+ # plus a KG slice (top-N entities + sample RELATION/MENTIONS triples +
1812+ # global kg_stats). Wraps the daemon's ``GET /graph?limit=`` endpoint.
1813+ # Read-only, safe to run during backfill — the daemon assembles the
1814+ # snapshot from pre-aggregated tables.
1815+ #
1816+ # Recall-preserving by design: this is a *structural* snapshot of the
1817+ # palace shape and KG, not a drawer search. It never excludes content;
1818+ # the limit only caps how many KG entities (and 2×limit MENTIONS) ship
1819+ # back. Operators querying for more granularity hit AGE Cypher
1820+ # directly via ``POST /cypher`` (per the daemon openapi note).
1821+
1822+
1823+ _GRAPH_LIMIT_MAX = 50000 # matches the daemon's hard ceiling on /graph
1824+
1825+
1826+ def _print_graph_table (data : dict ) -> None :
1827+ """Human-readable summary: palace structure + KG snapshot."""
1828+ wings = data .get ("wings" ) or {}
1829+ rooms = data .get ("rooms" ) or []
1830+ tunnels = data .get ("tunnels" ) or []
1831+ kg_stats = data .get ("kg_stats" ) or {}
1832+ kg_entities = data .get ("kg_entities" ) or []
1833+ kg_triples = data .get ("kg_triples" ) or []
1834+ kg_mentions = data .get ("kg_mentions" ) or []
1835+
1836+ total_drawers = sum (int (v or 0 ) for v in wings .values ())
1837+ print ()
1838+ print (" Palace structure" )
1839+ print (f" wings: { len (wings ):>10} " )
1840+ print (f" rooms: { sum (len (r .get ('rooms' ) or {}) for r in rooms ):>10} " )
1841+ print (f" tunnels: { len (tunnels ):>10} " )
1842+ print (f" drawers: { total_drawers :>10} " )
1843+
1844+ if wings :
1845+ print ()
1846+ print (" Top wings by drawer count" )
1847+ top_wings = sorted (wings .items (), key = lambda kv : int (kv [1 ] or 0 ), reverse = True )[:10 ]
1848+ for name , count in top_wings :
1849+ print (f" { int (count or 0 ):>8} { name } " )
1850+
1851+ print ()
1852+ print (" Knowledge graph" )
1853+ print (f" entities: { int (kg_stats .get ('entities' , 0 ) or 0 ):>10} " )
1854+ print (f" triples: { int (kg_stats .get ('triples' , 0 ) or 0 ):>10} " )
1855+ print (f" mentions: { int (kg_stats .get ('mentions' , 0 ) or 0 ):>10} " )
1856+ rel_types = kg_stats .get ("relationship_types" ) or []
1857+ if rel_types :
1858+ print (f" rel-types: { ', ' .join (rel_types )} " )
1859+ print (f" sample entities (capped at --limit): { len (kg_entities )} " )
1860+ print (f" sample triples: { len (kg_triples )} " )
1861+ print (f" sample mentions: { len (kg_mentions )} " )
1862+
1863+ if kg_entities :
1864+ print ()
1865+ print (" Sample entities" )
1866+ for ent in kg_entities [:10 ]:
1867+ name = ent .get ("name" ) or ent .get ("id" ) or "(unnamed)"
1868+ etype = ent .get ("type" ) or ""
1869+ suffix = f" [{ etype } ]" if etype else ""
1870+ print (f" { name } { suffix } " )
1871+
1872+ if kg_triples :
1873+ print ()
1874+ print (" Sample triples" )
1875+ for tr in kg_triples [:10 ]:
1876+ subj = tr .get ("subject" ) or "?"
1877+ pred = tr .get ("predicate" ) or "?"
1878+ obj = tr .get ("object" ) or "?"
1879+ print (f" { subj } —[{ pred } ]→ { obj } " )
1880+
1881+ print ()
1882+
1883+
1884+ def _print_graph_full (data : dict ) -> None :
1885+ """Labelled sections, no truncation — every wing, every triple, every mention."""
1886+ wings = data .get ("wings" ) or {}
1887+ rooms = data .get ("rooms" ) or []
1888+ tunnels = data .get ("tunnels" ) or []
1889+ kg_stats = data .get ("kg_stats" ) or {}
1890+ kg_entities = data .get ("kg_entities" ) or []
1891+ kg_triples = data .get ("kg_triples" ) or []
1892+ kg_mentions = data .get ("kg_mentions" ) or []
1893+ sep = " " + "─" * 70
1894+
1895+ print ()
1896+ print (sep )
1897+ print (" WINGS" )
1898+ for name in sorted (wings ):
1899+ print (f" { int (wings [name ] or 0 ):>8} { name } " )
1900+
1901+ print (sep )
1902+ print (" ROOMS (per wing)" )
1903+ for entry in rooms :
1904+ wing = entry .get ("wing" ) or "(no-wing)"
1905+ breakdown = entry .get ("rooms" ) or {}
1906+ cells = ", " .join (f"{ r } :{ int (c or 0 )} " for r , c in sorted (breakdown .items ()))
1907+ print (f" { wing } : { cells } " )
1908+
1909+ print (sep )
1910+ print (" TUNNELS (rooms appearing in 2+ wings)" )
1911+ for tun in tunnels :
1912+ room = tun .get ("room" ) or "(no-room)"
1913+ wings_list = tun .get ("wings" ) or []
1914+ print (f" { room } : { len (wings_list )} wings → { ', ' .join (wings_list )} " )
1915+
1916+ print (sep )
1917+ print (" KG STATS" )
1918+ print (f" entities: { int (kg_stats .get ('entities' , 0 ) or 0 )} " )
1919+ print (f" triples: { int (kg_stats .get ('triples' , 0 ) or 0 )} " )
1920+ print (f" mentions: { int (kg_stats .get ('mentions' , 0 ) or 0 )} " )
1921+ rel_types = kg_stats .get ("relationship_types" ) or []
1922+ if rel_types :
1923+ print (f" rel-types: { ', ' .join (rel_types )} " )
1924+
1925+ print (sep )
1926+ print (f" KG ENTITIES (sample, n={ len (kg_entities )} )" )
1927+ for ent in kg_entities :
1928+ eid = ent .get ("id" ) or ""
1929+ name = ent .get ("name" ) or "(unnamed)"
1930+ etype = ent .get ("type" ) or ""
1931+ props = ent .get ("properties" ) or {}
1932+ suffix = f" [{ etype } ]" if etype else ""
1933+ print (f" { name } { suffix } id={ eid } " )
1934+ if props :
1935+ print (f" properties: { props } " )
1936+
1937+ print (sep )
1938+ print (f" KG TRIPLES (sample, n={ len (kg_triples )} )" )
1939+ for tr in kg_triples :
1940+ subj = tr .get ("subject" ) or "?"
1941+ pred = tr .get ("predicate" ) or "?"
1942+ obj = tr .get ("object" ) or "?"
1943+ conf = tr .get ("confidence" )
1944+ vf = tr .get ("valid_from" )
1945+ vt = tr .get ("valid_to" )
1946+ meta_bits = []
1947+ if conf is not None :
1948+ meta_bits .append (f"conf={ conf } " )
1949+ if vf :
1950+ meta_bits .append (f"from={ vf } " )
1951+ if vt :
1952+ meta_bits .append (f"to={ vt } " )
1953+ meta = f" ({ ', ' .join (meta_bits )} )" if meta_bits else ""
1954+ print (f" { subj } —[{ pred } ]→ { obj } { meta } " )
1955+
1956+ print (sep )
1957+ print (f" KG MENTIONS (sample, n={ len (kg_mentions )} )" )
1958+ for mn in kg_mentions :
1959+ subj = mn .get ("subject" ) or "?"
1960+ obj = mn .get ("object" ) or "?"
1961+ src = mn .get ("source_file" ) or ""
1962+ suffix = f" [{ src } ]" if src else ""
1963+ print (f" { subj } → { obj } { suffix } " )
1964+
1965+ print (sep )
1966+ print ()
1967+
1968+
1969+ def _resolve_graph_format (args ) -> str :
1970+ """Pick ``mempalace graph`` output format. ``--format`` wins, then ``--json``."""
1971+ fmt = getattr (args , "format" , None )
1972+ if fmt :
1973+ return fmt
1974+ if getattr (args , "json" , False ):
1975+ return "json"
1976+ return "table"
1977+
1978+
1979+ def cmd_graph (args ):
1980+ """Fast direct-to-daemon KG + palace structural snapshot (issue #191).
1981+
1982+ Pure read — wraps ``GET /graph?limit=`` on the palace daemon, which
1983+ returns pre-aggregated wing/room/tunnel counts plus a KG slice
1984+ (top-N entities, sample RELATION/MENTIONS triples, global kg_stats).
1985+ Output formats: ``table`` (default summary), ``full`` (every wing,
1986+ every sampled triple, no truncation), ``json`` (pass-through shape).
1987+ Daemon unreachable → stderr error + exit 1; inner-error payload → exit 2.
1988+ """
1989+ fmt = _resolve_graph_format (args )
1990+ want_json = fmt == "json"
1991+
1992+ raw_limit = getattr (args , "limit" , 500 )
1993+ if raw_limit is None :
1994+ raw_limit = 500
1995+ limit = max (1 , min (int (raw_limit ), _GRAPH_LIMIT_MAX ))
1996+ params : dict = {"limit" : limit }
1997+
1998+ try :
1999+ data = _call_daemon_rest ("/graph" , params )
2000+ except DaemonError as e :
2001+ # Match cmd_list / cmd_status daemon-down fallback. JSON callers
2002+ # get a structured error on stdout; humans get the standard
2003+ # "daemon unreachable" line on stderr.
2004+ if want_json :
2005+ _emit_json ({"error" : str (e ), "source" : "daemon" })
2006+ else :
2007+ print (
2008+ f"palace daemon unreachable at { _daemon_url ()} — "
2009+ f"see mempalace status for diagnostics ({ e } )" ,
2010+ file = sys .stderr ,
2011+ )
2012+ sys .exit (1 )
2013+
2014+ if data is None :
2015+ # _call_daemon_rest returns None on 404/401/403 — endpoint
2016+ # missing on an older daemon, or auth mismatch. Treat the same
2017+ # as unreachable so scripts get one failure shape.
2018+ if want_json :
2019+ _emit_json ({"error" : "daemon /graph unavailable" , "source" : "daemon" })
2020+ else :
2021+ print (
2022+ f"palace daemon unreachable at { _daemon_url ()} — "
2023+ "see mempalace status for diagnostics" ,
2024+ file = sys .stderr ,
2025+ )
2026+ sys .exit (1 )
2027+
2028+ # Daemon may surface an inner error envelope (palace unreachable
2029+ # from inside the daemon) — match cmd_list's exit-2 contract.
2030+ if (
2031+ "error" in data
2032+ and not data .get ("kg_stats" )
2033+ and not data .get ("kg_entities" )
2034+ and not data .get ("kg_triples" )
2035+ and not data .get ("wings" )
2036+ ):
2037+ if want_json :
2038+ _emit_json (data )
2039+ else :
2040+ print (f"\n { data ['error' ]} " , file = sys .stderr )
2041+ sys .exit (2 )
2042+
2043+ if want_json :
2044+ # Stable top-level shape — pass through daemon keys unchanged so
2045+ # scripts can rely on them. Defaults make missing keys explicit
2046+ # rather than KeyError-ing downstream consumers.
2047+ out = {
2048+ "wings" : data .get ("wings" ) or {},
2049+ "rooms" : data .get ("rooms" ) or [],
2050+ "tunnels" : data .get ("tunnels" ) or [],
2051+ "kg_entities" : data .get ("kg_entities" ) or [],
2052+ "kg_triples" : data .get ("kg_triples" ) or [],
2053+ "kg_mentions" : data .get ("kg_mentions" ) or [],
2054+ "kg_stats" : data .get ("kg_stats" ) or {},
2055+ }
2056+ _emit_json (out )
2057+ return
2058+
2059+ if fmt == "full" :
2060+ _print_graph_full (data )
2061+ else :
2062+ _print_graph_table (data )
2063+
2064+
18082065def cmd_wakeup (args ):
18092066 """Show L0 (identity) + L1 (essential story) — the wake-up context."""
18102067 from .layers import MemoryStack
@@ -3655,6 +3912,31 @@ def main():
36553912 ),
36563913 )
36573914
3915+ # graph
3916+ p_graph = sub .add_parser (
3917+ "graph" ,
3918+ help = "KG + palace structural snapshot (wings, rooms, tunnels, kg_stats)" ,
3919+ )
3920+ p_graph .add_argument (
3921+ "--limit" ,
3922+ type = int ,
3923+ default = 500 ,
3924+ help = (
3925+ "Cap on KG entity count (and 2x this for MENTIONS triples). "
3926+ "Default: 500, max: 50000 — matches the daemon's hard ceiling."
3927+ ),
3928+ )
3929+ p_graph .add_argument (
3930+ "--format" ,
3931+ choices = ("table" , "full" , "json" ),
3932+ default = None ,
3933+ help = (
3934+ "Output format: table (default, summary + top wings + sample), "
3935+ "full (every wing, every sampled triple, no truncation), "
3936+ "json (machine-readable; same as --json)"
3937+ ),
3938+ )
3939+
36583940 # compress
36593941 p_compress = sub .add_parser (
36603942 "compress" , help = "Compress drawers using AAAK Dialect (~30x reduction)"
@@ -4055,6 +4337,7 @@ def _nonneg_int(value: str) -> int:
40554337 "split" : cmd_split ,
40564338 "search" : cmd_search ,
40574339 "list" : cmd_list ,
4340+ "graph" : cmd_graph ,
40584341 "export" : cmd_export ,
40594342 "sweep" : cmd_sweep ,
40604343 "sync" : cmd_sync ,
0 commit comments