Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to `pepper` (distribution `vycari-pepper`). Newest first. Th

### Added

- `pepper graph entity ID` and `pepper graph catalog ID | --scheme S --value V`: an entity's page (edges by
type, follows included, its catalog entry) and a catalog entry with the caller's linked entities, both
with `--history` and `--json`, over `GET /api/v1/graph/entities/{id}` and `/catalog` (Vycari/pepper#6407;
refs Vycari/pepper#6365). Needs that server change deployed.
- `pepper graph list | search | show | upcoming`: read-only views of the entity graph (people, ties,
last contact, past meetings, and attendee briefs for an event or the next meetings), over
`GET /api/v1/graph/*` (Vycari/pepper#6321). 404 until the `entity-graph` rollout reaches the
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ Every command except the interactive REPL takes `--json`, and also switches to J
| `pepper graph search QUERY [--limit N] [--json]` | One person by exact email address, else by part of a name or address. |
| `pepper graph show EVENT_ID [--json]` | A card for each attendee of one calendar event the graph holds. An event the graph lacks says so. |
| `pepper graph upcoming [--limit N] [--json]` | The next meetings the graph holds (30 days ahead), each with its attendees' cards. A `status` of `timeout` or `error` means the server returned what it had (often nothing); the read is fail-soft and never a 5xx. |
| `pepper graph entity ID [--history] [--json]` | One of your entities: its name and kind, the catalog entry it points at, and its edges grouped by type (follows show their follow ref). A merged-away id shows the entity it became. `--history` adds closed edges, marked with when they closed. 404 means unknown, not yours, or the `entity-graph` rollout is off (one message for all three). A `status` of `timeout` or `error` prints a plain note and exits 1. |
| `pepper graph catalog ID [--history] [--json]` or `pepper graph catalog --scheme S --value V` | A catalog entry (external ids, providers, neighbours) and the entities of yours that link to it. `S` is one of `espn`, `tvmaze`, `feed_url`, `site_host`, `domain`, `feeds_topic`, `wikidata`. Only an entry your own graph links to is readable. Same errors as `graph entity`. |
| `pepper calendar publish set [--add-calendar C]… [--remove-calendar C]… [--add-follow F]… [--remove-follow F]… [--titles-off C]… [--titles-on C]… [--detail busy\|full] [--meals\|--no-meals] [--workouts\|--no-workouts] [--todos\|--no-todos] [--consent] [--json]` | Change only what you name. `C` and `F` are a label (exact, or a unique prefix, any case) or an id; the CLI looks them up in the publishing status and sends back the whole list, so the ones you did not name stay as they were. `--titles-off C` / `--titles-on C` hide or show the titles of a calendar someone shared with you in Full details (it then appears as busy blocks); the server refuses a calendar that has no titles switch. Which events are published is the server's call. Adding your first Google calendar publishes its events at the address, so the server asks for consent: on a terminal `set` prints its disclosure and asks y/N (a no changes nothing and exits 1); without one it refuses and prints the disclosure, and you rerun with `--consent` only after the person has agreed. `--consent` is never implied. Prints the new status. |
| `pepper drafts send DRAFT_ID [--json]` | Send a draft Pepper staged in chat (an email, or a suggested change to an event). `pepper chat` prints each unsent draft with this command under it, and the account it goes from when you have several; the terminal UI boxes it under the reply and `s` sends it after a confirmation. Nothing is sent until you do. |
| `pepper apps show SLUG [--room R] [--json]` | An app's data document, `{"rev", "data"}`, yours or the room's. |
Expand Down
7 changes: 7 additions & 0 deletions help/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ ever want to look back at a specific conversation in detail.
you have never mailed or met are marked "new to you". It only reads, and the
data comes from the headers of your own mail and calendar attendees, never
message text. If a read is slow it shows what it had and says so.
- **One entity, or one catalog entry:** `pepper graph entity <id>` shows one of
your entities with what it is linked to, grouped by kind of link (a follow
shows what you follow), and the shared catalog entry it points at. `pepper
graph catalog <id>` shows a catalog entry and which of your entities link to
it; `pepper graph catalog --scheme espn --value <id>` finds one by an outside
id. Both take `--history` to include links that have ended. If one says it is
not found, the id is unknown or not in your own graph.
<!-- /rollout -->
- **What's next:** `pepper next` shows what's on now (with the time left) and
what's after it, the rest of today's calendar, and your top ten open to-dos
Expand Down
22 changes: 21 additions & 1 deletion src/pepper_cli/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from collections.abc import Callable, Generator, Iterator
from dataclasses import dataclass, field
from typing import Any, NoReturn, Self, cast
from urllib.parse import urlencode
from urllib.parse import quote, urlencode

import httpx

Expand Down Expand Up @@ -2567,6 +2567,26 @@ def graph_upcoming(self, *, limit: int = 5) -> dict[str, Any]:
"""``GET /api/v1/graph/meetings/upcoming``: the next meetings, each with attendee cards."""
return self._get_object(f"/api/v1/graph/meetings/upcoming?{urlencode({'limit': limit})}")

def graph_entity(self, entity_id: str, *, history: bool = False) -> dict[str, Any]:
"""``GET /api/v1/graph/entities/{id}``: one of the caller's entities with its edges.

404 for an unknown id, another owner's id, or the ``entity-graph`` rollout being off.
"""
q = urlencode({"history": "true" if history else "false"})
return self._get_object(f"/api/v1/graph/entities/{quote(entity_id, safe='')}?{q}")

def graph_catalog(self, catalog_id: str, *, history: bool = False) -> dict[str, Any]:
"""``GET /api/v1/graph/catalog/{id}``: a catalog entry and the caller's entities on it."""
q = urlencode({"history": "true" if history else "false"})
return self._get_object(f"/api/v1/graph/catalog/{quote(catalog_id, safe='')}?{q}")

def graph_catalog_lookup(
self, scheme: str, value: str, *, history: bool = False
) -> dict[str, Any]:
"""``GET /api/v1/graph/catalog?scheme=&value=``: the same page, by one external id."""
q = urlencode({"scheme": scheme, "value": value, "history": "true" if history else "false"})
return self._get_object(f"/api/v1/graph/catalog?{q}")

def _get_object(self, path: str) -> dict[str, Any]:
resp = self._request("GET", path)
body = resp.json()
Expand Down
91 changes: 90 additions & 1 deletion src/pepper_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,36 @@ def _build_parser() -> tuple[argparse.ArgumentParser, frozenset[str]]:
"--limit", type=int, default=5, metavar="N", help="How many meetings (default 5, max 50)."
)
p.set_defaults(handler=cmd_graph_upcoming)
p = sub_graph.add_parser(
"entity",
help="One of your entities: its edges by type, and its catalog entry.",
description=(
"An entity page: the entity, the shared catalog entry it points at, and its edges "
"listed by type (follows included). A merged-away id shows the entity it became."
),
parents=[_json_parent],
)
p.add_argument("entity_id", metavar="ID", help="An entity id (see 'graph list').")
p.add_argument(
"--history", action="store_true", help="Include closed edges, marked with when they closed."
)
p.set_defaults(handler=cmd_graph_entity)
p = sub_graph.add_parser(
"catalog",
help="A catalog entry and the entities of yours that link to it.",
description=(
"A catalog page by id, or by one external id with --scheme and --value. Only an "
"entry your own graph links to is readable."
),
parents=[_json_parent],
)
p.add_argument("catalog_id", nargs="?", metavar="ID", help="A catalog entry id.")
p.add_argument("--scheme", choices=_GRAPH_SCHEMES, help="Look up by this kind of external id.")
p.add_argument("--value", help="The external id to look up (with --scheme).")
p.add_argument(
"--history", action="store_true", help="Include closed edges, marked with when they closed."
)
p.set_defaults(handler=cmd_graph_catalog)

p_todos = sub.add_parser(
"todos",
Expand Down Expand Up @@ -9631,7 +9661,8 @@ def _report_graph_error(args: argparse.Namespace, exc: api_client.ApiError) -> i
_emit_error(
args,
"not_found",
"The entity graph is not available for your account (or this server is too old).",
"Not found: the entity or catalog entry is unknown, is not in your graph, or the "
"entity graph is not available for your account (or this server is too old).",
status=404,
)
return 1
Expand Down Expand Up @@ -9681,6 +9712,64 @@ def run(api: api_client.ApiClient) -> int:
return _run_personal(args, run, _report_graph_error)


#: The external-id schemes ``GET /api/v1/graph/catalog`` accepts.
_GRAPH_SCHEMES = ("espn", "tvmaze", "feed_url", "site_host", "domain", "feeds_topic", "wikidata")


def _graph_page(
args: argparse.Namespace, body: dict[str, Any], key: str, human: Callable[[], None]
) -> int:
"""Print a page read, or say plainly that a fail-soft read gave nothing (exit 1)."""
status = body.get("status")
if status != "ok" or not isinstance(body.get(key), dict):
what = "timed out" if status == "timeout" else "failed"
if _json_mode(args):
_print_json(body)
_emit_error(args, "server", f"The graph read {what}; nothing to show. Try again.")
return 1
_emit(body, args, human)
return 0


def cmd_graph_entity(args: argparse.Namespace) -> int:
"""``pepper graph entity ID [--history] [--json]``: ``GET /api/v1/graph/entities/{id}``."""
entity_id = args.entity_id.strip()
if not entity_id:
_emit_error(args, "usage", "ID must not be blank.")
return 2

def run(api: api_client.ApiClient) -> int:
body = api.graph_entity(entity_id, history=args.history)
return _graph_page(
args, body, "entity", lambda: graph_view.entity_page(body, history=args.history)
)

return _run_personal(args, run, _report_graph_error)


def cmd_graph_catalog(args: argparse.Namespace) -> int:
"""``pepper graph catalog ID | --scheme S --value V [--history] [--json]``."""
catalog_id = (args.catalog_id or "").strip()
scheme, value = args.scheme, (args.value or "").strip()
if catalog_id and (scheme or args.value is not None):
_emit_error(args, "usage", "Give a catalog ID, or --scheme with --value, not both.")
return 2
if not catalog_id and not (scheme and value):
_emit_error(args, "usage", "Give a catalog ID, or both --scheme and --value.")
return 2

def run(api: api_client.ApiClient) -> int:
if catalog_id:
body = api.graph_catalog(catalog_id, history=args.history)
else:
body = api.graph_catalog_lookup(scheme, value, history=args.history)
return _graph_page(
args, body, "page", lambda: graph_view.catalog_page(body, history=args.history)
)

return _run_personal(args, run, _report_graph_error)


def cmd_graph_upcoming(args: argparse.Namespace) -> int:
"""``pepper graph upcoming [--limit N] [--json]``: ``GET /api/v1/graph/meetings/upcoming``."""
if (limit := _graph_limit(args)) is None:
Expand Down
2 changes: 1 addition & 1 deletion src/pepper_cli/data/agent-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ is changed. That covers:
every other kind of artifact (images, links, files) are out of reach too.
- Calendar publishing (`pepper calendar publish`, the private address another calendar app subscribes to, including the per-calendar titles switch that `set` offers for calendars shared with the person) and every other
calendar command. They act on a person's own account, so only a person's own token reaches them.
- `pepper graph list`, `pepper graph search`, `pepper graph show` and `pepper graph upcoming`: a person's own entity graph of the people in their mail and meetings. Read-only, and only a person's own token reaches them.
- `pepper graph list`, `pepper graph search`, `pepper graph show` and `pepper graph upcoming`: a person's own entity graph of the people in their mail and meetings. Read-only. `pepper graph entity ID` and `pepper graph catalog ID` (or `--scheme S --value V`) show one entity or catalog entry with its edges (`--history` adds closed ones); a timeout/error status exits 1, and only a person's own token reaches them.
- Every other `rooms`, `topics`, `messages`, `attachments`, `members`, `sharing` or `apps` command, which are the ones that change a room, its topics, its members
or its apps, or that read or search across rooms.
- Every command outside those nouns that talks to the server, chat, `run` and `tui` included
Expand Down
94 changes: 94 additions & 0 deletions src/pepper_cli/graph_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,97 @@ def upcoming(body: dict[str, Any]) -> None:
)
for row in m.get("attendees") or []:
card(row, indent=" ")


def _ref(e: object) -> str:
"""``name (kind/subtype)`` for a ``{id, kind, subtype, name}`` entry."""
if not isinstance(e, dict):
return "?"
kind = "/".join(_s(e[k]) for k in ("kind", "subtype") if e.get(k))
return f"{_s(e.get('name'))} ({kind})" if kind else _s(e.get("name"))


def _edge(e: dict[str, Any], *, history: bool) -> str:
other = e.get("other")
if isinstance(other, dict):
arrow = "<-" if e.get("direction") == "in" else "->"
who = f"{arrow} {_ref(other)}"
elif e.get("follow_ref"):
who = f"follow {_s(e.get('follow_ref'))}"
else:
who = "follow"
bits = [who]
if e.get("source"):
bits.append(f"source {_s(e.get('source'))}")
since = _stamp(e.get("valid_from"))
if since:
bits.append(f"since {since}")
if history and e.get("valid_to"):
bits.append(f"[red]closed {_stamp(e.get('valid_to'))}[/red]")
elif history:
bits.append("open")
return " " + " · ".join(bits)


def _edges(edges: list[dict[str, Any]], *, history: bool, indent: str = "") -> None:
"""Edges grouped by type, in the order the server sent them."""
groups: dict[str, list[dict[str, Any]]] = {}
for e in edges:
groups.setdefault(_s(e.get("type")), []).append(e)
for kind, rows in groups.items():
_line(f"{indent}[bold]{kind}[/bold]")
for e in rows:
_line(f"{indent}{_edge(e, history=history)}")


def _catalog_card(c: dict[str, Any], *, indent: str = "") -> None:
sensitive = " [yellow](sensitive)[/yellow]" if c.get("sensitive") else ""
_line(f"{indent}[bold]{_ref(c)}[/bold] [dim]{_s(c.get('id'))}[/dim]{sensitive}")
ids = ", ".join(
f"{_s(i.get('scheme'))}:{_s(i.get('value'))}" for i in c.get("external_ids") or []
)
if ids:
_line(f"{indent} external ids: {ids}")
provs = ", ".join(
f"{_s(p.get('provider'))}:{_s(p.get('ref'))}" for p in c.get("providers") or []
)
if provs:
_line(f"{indent} providers: {provs}")
for n in c.get("neighbours") or []:
arrow = "<-" if n.get("direction") == "in" else "->"
_line(f"{indent} {_s(n.get('type'))} {arrow} {_ref(n.get('entry'))}")


def entity_page(body: dict[str, Any], *, history: bool) -> None:
"""One of the caller's entities: header, catalog card, then its edges by type."""
page = body.get("entity") or {}
ent = page.get("entity") or {}
sens = " [yellow](sensitive)[/yellow]" if page.get("sensitive") else ""
_line(f"[bold]{_ref(ent)}[/bold] [dim]{_s(ent.get('id'))}[/dim]{sens}")
requested = page.get("requested_id")
if requested and requested != ent.get("id"):
_line(f" [dim]merged: {_s(requested)} is now this entity[/dim]")
cat = page.get("catalog")
if isinstance(cat, dict):
_line(f" catalog: {_ref(cat)} [dim]{_s(cat.get('id'))}[/dim]")
_line(f" [dim]pepper graph catalog {_s(cat.get('id'))}[/dim]")
edges = page.get("edges") or []
if not edges:
_line("[dim](no edges)[/dim]")
return
_edges(edges, history=history)


def catalog_page(body: dict[str, Any], *, history: bool) -> None:
"""A catalog entry, then the caller's own entities that point at it."""
page = body.get("page") or {}
_catalog_card(page.get("catalog") or {})
rows = page.get("entities") or []
if not rows:
_line("[dim](none of your entities link here)[/dim]")
return
_line("[bold]Your entities[/bold]")
for row in rows:
ent = row.get("entity") or {}
_line(f" [bold]{_ref(ent)}[/bold] [dim]{_s(ent.get('id'))}[/dim]")
_edges(row.get("edges") or [], history=history, indent=" ")
25 changes: 25 additions & 0 deletions tests/test_cli_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@
# `graph upcoming` is the next meetings the graph holds: a bounded list, but of
# meetings, which `graph list` (people) is not.
"upcoming",
# `graph entity` and `graph catalog` read one page by id: the entity page and the shared
# catalog entry, which are the graph's two kinds of object (not a `show` of a list row).
"entity",
"catalog",
# Research projects (IA §3.1): `steer` queues guidance for an object's next run;
# `take` and `reconsider` mark a ledger item.
"steer",
Expand Down Expand Up @@ -195,6 +199,8 @@
"graph search": "the graph is your own, never a room's (D12 keeps a room's separate)",
"graph show": "the graph is your own, never a room's (D12 keeps a room's separate)",
"graph upcoming": "the graph is your own, never a room's (D12 keeps a room's separate)",
"graph entity": "the graph is your own, never a room's (D12 keeps a room's separate)",
"graph catalog": "the graph is your own, never a room's (D12 keeps a room's separate)",
"brief": "your own morning brief; the server exposes no room's brief to a PAT here",
"artifacts show": "reads any artifact the viewer can see; the id carries the room",
"artifacts download": "reads any artifact the viewer can see; the id carries the room",
Expand Down Expand Up @@ -714,6 +720,23 @@ def test_pending_lists_name_real_leaves() -> None:
"attendees": [],
},
"/api/v1/graph/meetings/upcoming": {"status": "ok", "meetings": []},
"/api/v1/graph/entities/e1": {
"status": "ok",
"entity": {
"entity": {"id": "e1", "kind": "topic", "subtype": "team", "name": "Kraken"},
"sensitive": False,
"requested_id": "e1",
"catalog": None,
"edges": [],
},
},
"/api/v1/graph/catalog/c1": {
"status": "ok",
"page": {
"catalog": {"id": "c1", "kind": "topic", "subtype": "team", "name": "Kraken"},
"entities": [],
},
},
"/api/v1/calendars/schedule-plan": {
"now": "2026-10-02T16:00:00+00:00",
"active": "urgent",
Expand Down Expand Up @@ -1133,6 +1156,8 @@ def fake_watch(self, *, since=0, last_event_id=None, **_kw):
"graph search": ["graph", "search", "casey"],
"graph show": ["graph", "show", "e1"],
"graph upcoming": ["graph", "upcoming"],
"graph entity": ["graph", "entity", "e1"],
"graph catalog": ["graph", "catalog", "c1"],
"todos list": ["todos", "list"],
"rooms list": ["rooms", "list"],
"rooms show": ["rooms", "show", _G],
Expand Down
Loading
Loading