Skip to content

Commit 499f42d

Browse files
jpheinclaude
andcommitted
feat(cli): mempalace graph — fast direct-to-daemon KG structural snapshot (#191)
Adds ``mempalace graph`` as the structural counterpart to ``mempalace list``: a thin wrapper around the daemon's ``GET /graph?limit=`` endpoint, which returns pre-aggregated palace shape (wings, rooms, passive tunnels) plus a KG slice (top-N entities, sample RELATION/MENTIONS triples, kg_stats with totals). Read-only, safe to run during backfill — the daemon assembles the snapshot from pre-aggregated tables. Recall-preserving by design: limit only caps the KG entity sample (and 2x for MENTIONS triples); wings, rooms, and tunnels always ship in full. Flags: - ``--limit N`` (default 500, sanity-clamped to [1, 50000] to match the daemon's hard ceiling per its openapi spec) - ``--format table|full|json`` — ``table`` is the default summary block (palace counts + top-10 wings + KG stats + sample entities and triples). ``full`` enumerates every wing, every room breakdown, every tunnel, and every sampled entity/triple/mention with no truncation. ``json`` mirrors the daemon shape (wings, rooms, tunnels, kg_entities, kg_triples, kg_mentions, kg_stats). Daemon-unreachable (``DaemonError`` from timeout/network failure, or ``_call_daemon_rest`` returning None on 404/401/403) prints a stderr hint and exits 1, matching the cmd_list / cmd_status fallback. With ``--format json`` the failure surfaces as a structured envelope on stdout for machine callers. An ``error`` payload with no structural keys (e.g. ``palace_unavailable``) exits 2 to match the same contract. No new MCP tool is added — the AI path can already shell out via ``mempalace_status`` or query AGE directly via ``POST /cypher`` for finer-grained walks. This bridges operators and scripts to the same pre-aggregated snapshot. Slice of the polished-CLI umbrella issue #191. Tests: 16 in tests/test_cli_graph.py covering flag propagation, the three output formats, JSON shape stability, --json interop, empty palace, daemon-down (None + DaemonError, both human and json), and the inner-error → exit 2 contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a6626c7 commit 499f42d

2 files changed

Lines changed: 692 additions & 0 deletions

File tree

mempalace/cli.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
18082065
def 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

Comments
 (0)