Skip to content

Commit 257137b

Browse files
committed
feat(cli): mempalace list — fast direct-to-daemon drawer browser (#191)
A new top-level subcommand that wraps the daemon's ``GET /list`` REST route (which itself wraps the existing ``mempalace_list_drawers`` MCP tool). Pure metadata browse — no ranking, no embedding, no exclusion. Recall-preserving by design: every drawer matching the wing/room filter is reachable via offset, and no drawer is dropped. This is the human/script counterpart to ``mempalace_list_drawers``; the AI path keeps using that MCP tool. No new MCP tool was added. Flags: --wing W limit to one wing --room R limit to one room --limit N default 20, sanity-capped at 1000 --offset N pagination offset --format table | compact | full | json Daemon-unreachable (``DaemonError`` or ``_call_daemon_rest`` returns None) → stderr message + exit 1, matching the cmd_status fallback and the graceful 401/403 handling added in 850e08c. JSON output emits a structured error envelope. An ``error`` payload from the daemon (palace_unavailable etc.) exits 2 to match cmd_status. Slice of issue #191 (polished CLI umbrella). Read-only, safe to run during backfill.
1 parent 433d23c commit 257137b

2 files changed

Lines changed: 552 additions & 0 deletions

File tree

mempalace/cli.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,6 +1636,175 @@ def _emit_local_search_json(
16361636
sys.exit(0 if (result.get("results") or []) else 1)
16371637

16381638

1639+
# ── mempalace list — fast direct-to-daemon drawer browser (#191) ────────
1640+
#
1641+
# Pure metadata browse: no ranking, no exclusion, no embedding. Wraps
1642+
# the daemon's GET /list endpoint (which itself wraps the
1643+
# ``mempalace_list_drawers`` MCP tool). Read-only, safe to run during
1644+
# backfill — it just paginates the metadata table.
1645+
#
1646+
# Recall-preserving by design: every drawer matching the wing/room
1647+
# filter is reachable via offset, and no drawer is dropped. This is the
1648+
# human/script counterpart to the existing ``mempalace_list_drawers``
1649+
# MCP tool (which serves the AI path).
1650+
1651+
1652+
_LIST_LIMIT_MAX = 1000 # sanity ceiling; the daemon clamps to 100 anyway
1653+
1654+
1655+
def _print_list_table(data: dict) -> None:
1656+
"""Human-readable multi-line render — drawer_id (short) + wing/room + preview."""
1657+
drawers = data.get("drawers") or []
1658+
total = data.get("total", len(drawers))
1659+
offset = data.get("offset", 0)
1660+
limit = data.get("limit", len(drawers))
1661+
if not drawers:
1662+
print("\n No drawers found.")
1663+
return
1664+
end = offset + len(drawers)
1665+
print(f"\n Drawers {offset + 1}{end} of {total} (limit {limit})\n")
1666+
for d in drawers:
1667+
did = d.get("drawer_id", "")
1668+
short = did[:12] if did else "(no-id)"
1669+
wing = d.get("wing") or "(no-wing)"
1670+
room = d.get("room") or "(no-room)"
1671+
preview = (d.get("content_preview") or "").replace("\n", " ").strip()
1672+
if len(preview) > 80:
1673+
preview = preview[:77] + "..."
1674+
print(f" {short} {wing}/{room}")
1675+
if preview:
1676+
print(f" {preview}")
1677+
print()
1678+
1679+
1680+
def _print_list_compact(data: dict) -> None:
1681+
"""One line per drawer: ``<id12> <wing>/<room>: <preview[:120]>``."""
1682+
for d in data.get("drawers") or []:
1683+
did = (d.get("drawer_id") or "")[:12]
1684+
wing = d.get("wing") or "-"
1685+
room = d.get("room") or "-"
1686+
preview = (d.get("content_preview") or "").replace("\n", " ").strip()
1687+
if len(preview) > 120:
1688+
preview = preview[:117] + "..."
1689+
print(f"{did} {wing}/{room}: {preview}")
1690+
1691+
1692+
def _print_list_full(data: dict) -> None:
1693+
"""Labelled sections, no truncation; separators between drawers."""
1694+
drawers = data.get("drawers") or []
1695+
total = data.get("total", len(drawers))
1696+
offset = data.get("offset", 0)
1697+
if not drawers:
1698+
print("\n No drawers found.")
1699+
return
1700+
print(f"\n Drawers {offset + 1}{offset + len(drawers)} of {total}\n")
1701+
sep = " " + "─" * 70
1702+
for d in drawers:
1703+
print(sep)
1704+
print(f" drawer_id: {d.get('drawer_id', '')}")
1705+
print(f" wing: {d.get('wing') or ''}")
1706+
print(f" room: {d.get('room') or ''}")
1707+
tags = d.get("tags") or []
1708+
if tags:
1709+
print(f" tags: {', '.join(tags)}")
1710+
print(" content:")
1711+
for line in (d.get("content_preview") or "").splitlines() or [""]:
1712+
print(f" {line}")
1713+
print(sep)
1714+
print()
1715+
1716+
1717+
def _resolve_list_format(args) -> str:
1718+
"""Pick ``mempalace list`` output format. ``--format`` wins, then ``--json``."""
1719+
fmt = getattr(args, "format", None)
1720+
if fmt:
1721+
return fmt
1722+
if getattr(args, "json", False):
1723+
return "json"
1724+
return "table"
1725+
1726+
1727+
def cmd_list(args):
1728+
"""Fast direct-to-daemon drawer browser (issue #191).
1729+
1730+
Pure metadata listing — wraps ``GET /list?wing=&room=&limit=&offset=``
1731+
on the palace daemon. Output formats: ``table`` (default), ``compact``,
1732+
``full``, ``json``. Daemon unreachable → stderr error + exit 1.
1733+
"""
1734+
fmt = _resolve_list_format(args)
1735+
want_json = fmt == "json"
1736+
1737+
limit = max(1, min(int(getattr(args, "limit", 20) or 20), _LIST_LIMIT_MAX))
1738+
offset = max(0, int(getattr(args, "offset", 0) or 0))
1739+
1740+
params: dict = {"limit": limit, "offset": offset}
1741+
if getattr(args, "wing", None):
1742+
params["wing"] = args.wing
1743+
if getattr(args, "room", None):
1744+
params["room"] = args.room
1745+
1746+
try:
1747+
data = _call_daemon_rest("/list", params)
1748+
except DaemonError as e:
1749+
# Match cmd_status's daemon-down fallback (line 2230) and the
1750+
# graceful 401/403 + unreachable handling added in 850e08c. On
1751+
# JSON output, emit a structured error so machine callers see
1752+
# the same shape as other failure paths.
1753+
if want_json:
1754+
_emit_json({"error": str(e), "source": "daemon"})
1755+
else:
1756+
print(
1757+
f"palace daemon unreachable at {_daemon_url()} — "
1758+
f"see mempalace status for diagnostics ({e})",
1759+
file=sys.stderr,
1760+
)
1761+
sys.exit(1)
1762+
1763+
if data is None:
1764+
# _call_daemon_rest returns None on 404/401/403 — endpoint
1765+
# missing on an older daemon, or auth mismatch. Same exit code
1766+
# as the unreachable case so scripts can treat "no daemon list"
1767+
# uniformly without parsing the message.
1768+
if want_json:
1769+
_emit_json({"error": "daemon /list unavailable", "source": "daemon"})
1770+
else:
1771+
print(
1772+
f"palace daemon unreachable at {_daemon_url()} — "
1773+
"see mempalace status for diagnostics",
1774+
file=sys.stderr,
1775+
)
1776+
sys.exit(1)
1777+
1778+
# Daemon /list mirrors mempalace_list_drawers' shape: error key when
1779+
# the underlying palace is unreachable from inside the daemon.
1780+
if "error" in data and not data.get("drawers"):
1781+
if want_json:
1782+
_emit_json(data)
1783+
else:
1784+
print(f"\n {data['error']}", file=sys.stderr)
1785+
sys.exit(2)
1786+
1787+
if want_json:
1788+
# Stable top-level shape — drawers/total/count/offset/limit pass
1789+
# through unchanged so scripts can rely on the keys.
1790+
out = {
1791+
"drawers": data.get("drawers") or [],
1792+
"total": data.get("total", 0),
1793+
"count": data.get("count", len(data.get("drawers") or [])),
1794+
"offset": data.get("offset", offset),
1795+
"limit": data.get("limit", limit),
1796+
}
1797+
_emit_json(out)
1798+
return
1799+
1800+
if fmt == "compact":
1801+
_print_list_compact(data)
1802+
elif fmt == "full":
1803+
_print_list_full(data)
1804+
else:
1805+
_print_list_table(data)
1806+
1807+
16391808
def cmd_wakeup(args):
16401809
"""Show L0 (identity) + L1 (essential story) — the wake-up context."""
16411810
from .layers import MemoryStack
@@ -3456,6 +3625,36 @@ def main():
34563625
),
34573626
)
34583627

3628+
# list — fast direct-to-daemon drawer browser (#191)
3629+
p_list = sub.add_parser(
3630+
"list",
3631+
help="Browse drawers by wing/room metadata (no ranking, no embedding)",
3632+
)
3633+
p_list.add_argument("--wing", default=None, help="Limit to one wing")
3634+
p_list.add_argument("--room", default=None, help="Limit to one room")
3635+
p_list.add_argument(
3636+
"--limit",
3637+
type=int,
3638+
default=20,
3639+
help="Max drawers to return (default: 20, max: 1000)",
3640+
)
3641+
p_list.add_argument(
3642+
"--offset",
3643+
type=int,
3644+
default=0,
3645+
help="Pagination offset (default: 0)",
3646+
)
3647+
p_list.add_argument(
3648+
"--format",
3649+
choices=("table", "compact", "full", "json"),
3650+
default=None,
3651+
help=(
3652+
"Output format: table (default, multi-line preview), "
3653+
"compact (one line per drawer), full (labelled sections, "
3654+
"no truncation), json (machine-readable; same as --json)"
3655+
),
3656+
)
3657+
34593658
# compress
34603659
p_compress = sub.add_parser(
34613660
"compress", help="Compress drawers using AAAK Dialect (~30x reduction)"
@@ -3855,6 +4054,7 @@ def _nonneg_int(value: str) -> int:
38554054
"mine": cmd_mine,
38564055
"split": cmd_split,
38574056
"search": cmd_search,
4057+
"list": cmd_list,
38584058
"export": cmd_export,
38594059
"sweep": cmd_sweep,
38604060
"sync": cmd_sync,

0 commit comments

Comments
 (0)