cogrind-workshop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cogrind_workshop/__init__.py +17 -0
- cogrind_workshop/__main__.py +10 -0
- cogrind_workshop/client.py +146 -0
- cogrind_workshop/formatters.py +236 -0
- cogrind_workshop/main.py +357 -0
- cogrind_workshop/repl.py +344 -0
- cogrind_workshop-0.1.0.dist-info/METADATA +58 -0
- cogrind_workshop-0.1.0.dist-info/RECORD +11 -0
- cogrind_workshop-0.1.0.dist-info/WHEEL +4 -0
- cogrind_workshop-0.1.0.dist-info/entry_points.txt +2 -0
- cogrind_workshop-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Gary Frattarola <garyf@parkviewlab.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""cogrind-workshop — human-facing MCP client for a `cobalt-grinding` server.
|
|
6
|
+
|
|
7
|
+
Equivalent in role to Claude Desktop or Claude Code: one MCP client
|
|
8
|
+
among many that can drive a running `cobalt-grinding` daemon. No
|
|
9
|
+
business logic; pure transport + rendering.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
__version__: str = version("cogrind-workshop")
|
|
16
|
+
except PackageNotFoundError: # editable install before first build
|
|
17
|
+
__version__ = "0.0.0+local"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Gary Frattarola <garyf@parkviewlab.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""`python -m cogrind_workshop` entry point. Delegates to the CLI's main()."""
|
|
6
|
+
|
|
7
|
+
from cogrind_workshop.main import main
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__":
|
|
10
|
+
main()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Gary Frattarola <garyf@parkviewlab.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""MCP-over-HTTP client used by the `cogrind-workshop` CLI.
|
|
6
|
+
|
|
7
|
+
Wraps the official `mcp` SDK's `streamable_http_client` + `ClientSession`
|
|
8
|
+
in a small synchronous-feeling helper so the CLI's flag handlers don't
|
|
9
|
+
each have to drive an asyncio loop.
|
|
10
|
+
|
|
11
|
+
This module is pure transport — no rendering, no wiki-specific logic.
|
|
12
|
+
The CLI in `cogrind_workshop.main` consumes it; tests can target this
|
|
13
|
+
module against any MCP server (stub or real).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
from collections.abc import AsyncIterator, Callable
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from mcp import ClientSession
|
|
26
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DaemonUnreachable(RuntimeError):
|
|
30
|
+
"""Raised when no `cobalt-grinding` daemon is reachable at the configured URL."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _mcp_url(host: str, port: int) -> str:
|
|
34
|
+
return f"http://{host}:{port}/mcp"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_connection_error(exc: BaseException) -> bool:
|
|
38
|
+
"""Recognize the various ways 'daemon not listening' surfaces.
|
|
39
|
+
|
|
40
|
+
httpx wraps low-level connection errors; anyio (used by streamable-http)
|
|
41
|
+
can group them inside a BaseExceptionGroup. We walk the tree to check.
|
|
42
|
+
"""
|
|
43
|
+
if isinstance(exc, httpx.ConnectError | httpx.ConnectTimeout | ConnectionError | OSError):
|
|
44
|
+
return True
|
|
45
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
46
|
+
return any(_is_connection_error(e) for e in exc.exceptions)
|
|
47
|
+
cause = exc.__cause__ or exc.__context__
|
|
48
|
+
return cause is not None and _is_connection_error(cause)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@asynccontextmanager
|
|
52
|
+
async def _session(host: str, port: int) -> AsyncIterator[ClientSession]:
|
|
53
|
+
"""Open an MCP session over streamable-HTTP and yield the ClientSession."""
|
|
54
|
+
url = _mcp_url(host, port)
|
|
55
|
+
try:
|
|
56
|
+
async with (
|
|
57
|
+
streamable_http_client(url) as (read, write, _),
|
|
58
|
+
ClientSession(read, write) as session,
|
|
59
|
+
):
|
|
60
|
+
await session.initialize()
|
|
61
|
+
yield session
|
|
62
|
+
except BaseException as e:
|
|
63
|
+
if _is_connection_error(e):
|
|
64
|
+
raise DaemonUnreachable(
|
|
65
|
+
f"could not reach cobalt-grinding at {url} — start one with: cobalt-grinding"
|
|
66
|
+
) from e
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def call_tool(
|
|
71
|
+
name: str,
|
|
72
|
+
arguments: dict[str, Any] | None = None,
|
|
73
|
+
*,
|
|
74
|
+
host: str = "127.0.0.1",
|
|
75
|
+
port: int = 7474,
|
|
76
|
+
) -> Any:
|
|
77
|
+
"""Synchronous helper: connect, call one tool, decode the result, disconnect.
|
|
78
|
+
|
|
79
|
+
The MCP `call_tool` returns content blocks; for cobalt-grinding's tools the
|
|
80
|
+
first block is JSON text. This function extracts that and parses it.
|
|
81
|
+
|
|
82
|
+
Note: each call opens a fresh MCP session (HTTP connect + initialize
|
|
83
|
+
handshake). For one-shot CLI invocations (`cogrind-workshop --status`)
|
|
84
|
+
this is fine — startup cost dominates anyway. A future REPL form will
|
|
85
|
+
keep a single session open across many tool calls.
|
|
86
|
+
"""
|
|
87
|
+
return asyncio.run(_call_tool_async(name, arguments or {}, host=host, port=port))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _call_tool_async(name: str, arguments: dict[str, Any], *, host: str, port: int) -> Any:
|
|
91
|
+
async with _session(host, port) as session:
|
|
92
|
+
result = await session.call_tool(name, arguments)
|
|
93
|
+
return _decode(result)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _decode(result: Any) -> Any:
|
|
97
|
+
"""Pull the first text content block out of a tool-call result and JSON-decode it."""
|
|
98
|
+
content = getattr(result, "content", None)
|
|
99
|
+
if not content:
|
|
100
|
+
return None
|
|
101
|
+
first = content[0]
|
|
102
|
+
text = getattr(first, "text", None)
|
|
103
|
+
if text is None:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
return json.loads(text)
|
|
107
|
+
except json.JSONDecodeError:
|
|
108
|
+
return text
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---- streaming helper for tail-progress style flows ----
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def stream_task(
|
|
115
|
+
submit_tool: str,
|
|
116
|
+
submit_args: dict[str, Any],
|
|
117
|
+
*,
|
|
118
|
+
on_update: Callable[[dict[str, Any]], None],
|
|
119
|
+
poll_interval: float = 0.25,
|
|
120
|
+
timeout_seconds: float | None = None,
|
|
121
|
+
host: str = "127.0.0.1",
|
|
122
|
+
port: int = 7474,
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
"""Submit a long-running tool, poll status, return final task dict.
|
|
125
|
+
|
|
126
|
+
`on_update` is called every poll iteration with the current task dict
|
|
127
|
+
so the caller can render progress.
|
|
128
|
+
"""
|
|
129
|
+
async with _session(host, port) as session:
|
|
130
|
+
submit_result = _decode(await session.call_tool(submit_tool, submit_args))
|
|
131
|
+
task_id = submit_result.get("task_id") if isinstance(submit_result, dict) else None
|
|
132
|
+
if not task_id:
|
|
133
|
+
raise RuntimeError(f"{submit_tool} did not return a task_id; got: {submit_result!r}")
|
|
134
|
+
|
|
135
|
+
elapsed = 0.0
|
|
136
|
+
while True:
|
|
137
|
+
status = _decode(await session.call_tool("wiki.task_status", {"task_id": task_id}))
|
|
138
|
+
if not isinstance(status, dict):
|
|
139
|
+
raise RuntimeError(f"unexpected task_status payload: {status!r}")
|
|
140
|
+
on_update(status)
|
|
141
|
+
if status.get("status") in ("succeeded", "failed", "cancelled"):
|
|
142
|
+
return status
|
|
143
|
+
await asyncio.sleep(poll_interval)
|
|
144
|
+
elapsed += poll_interval
|
|
145
|
+
if timeout_seconds is not None and elapsed > timeout_seconds:
|
|
146
|
+
raise TimeoutError(f"task {task_id} did not finish within {timeout_seconds}s")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Gary Frattarola <garyf@parkviewlab.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Terminal output rendering for the cogrind-workshop CLI.
|
|
6
|
+
|
|
7
|
+
The CLI is just an MCP client; what makes it *feel* like a CLI lives here
|
|
8
|
+
— Rich tables, progress lines, colored status badges. Anything that
|
|
9
|
+
takes a tool-call result dict and prints something pretty.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_status(status: dict[str, Any], console: Console) -> None:
|
|
22
|
+
"""Pretty-print the result of `wiki.status`."""
|
|
23
|
+
table = Table(title=f"cobalt-grinding — {status.get('wiki_dir', '?')}", show_header=False)
|
|
24
|
+
table.add_column("key")
|
|
25
|
+
table.add_column("value")
|
|
26
|
+
|
|
27
|
+
table.add_row("version", str(status.get("version", "?")))
|
|
28
|
+
table.add_row("milestone", str(status.get("milestone", "?")))
|
|
29
|
+
# Be defensive: if a future / older daemon returns null for uptime, we
|
|
30
|
+
# don't want the CLI to crash with a TypeError on `:.1f` format.
|
|
31
|
+
uptime = status.get("daemon", {}).get("uptime_seconds")
|
|
32
|
+
table.add_row("uptime (s)", f"{uptime:.1f}" if isinstance(uptime, int | float) else "?")
|
|
33
|
+
table.add_row("wiki exists", _yes_no(bool(status.get("wiki_exists"))))
|
|
34
|
+
table.add_row("pages indexed", str(status.get("pages_indexed", 0)))
|
|
35
|
+
table.add_row("lance tables", ", ".join(status.get("lance_tables", []) or []))
|
|
36
|
+
|
|
37
|
+
embed = status.get("embedding", {}) or {}
|
|
38
|
+
table.add_row("embedding", f"{embed.get('provider')}:{embed.get('model')} (dim={embed.get('dim')})")
|
|
39
|
+
|
|
40
|
+
mutex = status.get("corpus_mutex", {}) or {}
|
|
41
|
+
if mutex.get("locked"):
|
|
42
|
+
table.add_row("corpus mutex", f"[yellow]locked by {mutex.get('holder')}[/yellow]")
|
|
43
|
+
else:
|
|
44
|
+
table.add_row("corpus mutex", "[green]free[/green]")
|
|
45
|
+
|
|
46
|
+
tasks = status.get("tasks", {}) or {}
|
|
47
|
+
summary = ", ".join(f"{k}={v}" for k, v in tasks.items())
|
|
48
|
+
table.add_row("tasks", summary)
|
|
49
|
+
|
|
50
|
+
console.print(table)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def format_index_result(task: dict[str, Any], console: Console) -> None:
|
|
54
|
+
"""Pretty-print a finished `wiki.index` task."""
|
|
55
|
+
final_status = task.get("status", "?")
|
|
56
|
+
result = task.get("result", {}) or {}
|
|
57
|
+
|
|
58
|
+
if final_status == "succeeded":
|
|
59
|
+
console.print(
|
|
60
|
+
f"\n[green]done[/green] in {result.get('duration_seconds', 0):.2f}s — "
|
|
61
|
+
f"scanned {result.get('scanned', 0)}, "
|
|
62
|
+
f"inserted {result.get('inserted', 0)}, "
|
|
63
|
+
f"updated {result.get('updated', 0)}, "
|
|
64
|
+
f"skipped {result.get('skipped', 0)}, "
|
|
65
|
+
f"deleted {result.get('deleted', 0)}, "
|
|
66
|
+
f"failed {result.get('failed', 0)}"
|
|
67
|
+
f"{', cancelled' if result.get('cancelled') else ''}"
|
|
68
|
+
)
|
|
69
|
+
for path, msg in result.get("failures", []) or []:
|
|
70
|
+
console.print(f" [red]fail[/red] {path}: {msg}")
|
|
71
|
+
elif final_status == "cancelled":
|
|
72
|
+
console.print("[yellow]cancelled[/yellow]")
|
|
73
|
+
else:
|
|
74
|
+
console.print(f"[red]task {final_status}[/red]: {task.get('error', '?')}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def format_progress_line(task: dict[str, Any], console: Console) -> None:
|
|
78
|
+
"""One-line progress update suitable for printing as a task is running."""
|
|
79
|
+
status = task.get("status", "?")
|
|
80
|
+
progress = task.get("progress") or {}
|
|
81
|
+
if status == "running" and progress:
|
|
82
|
+
bits = ", ".join(f"{k}={v}" for k, v in progress.items())
|
|
83
|
+
console.print(f" [dim]running[/dim] — {bits}")
|
|
84
|
+
elif status == "queued":
|
|
85
|
+
console.print(" [dim]queued[/dim]")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_ingest_result(result: dict[str, Any], console: Console) -> None:
|
|
89
|
+
"""Pretty-print a finished `wiki.ingest` response.
|
|
90
|
+
|
|
91
|
+
The orchestrator returns `IngestResult.to_dict()` — source page id,
|
|
92
|
+
section pages (multi-file), entity/glossary pages, link count, and
|
|
93
|
+
skipped/error flags. We render a compact summary; full payload is
|
|
94
|
+
available via `console.print_json(data=result)` if a user wants it.
|
|
95
|
+
"""
|
|
96
|
+
if "error" in result:
|
|
97
|
+
console.print(f"[red]ingest_error[/red]: {result.get('message', '?')}")
|
|
98
|
+
return
|
|
99
|
+
if result.get("skipped"):
|
|
100
|
+
console.print(
|
|
101
|
+
f"[yellow]skipped[/yellow] — {result.get('skip_reason', 'no-change')}: "
|
|
102
|
+
f"{result.get('source_id', '?')}"
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
table = Table(title="wiki.ingest", show_header=False)
|
|
107
|
+
table.add_column("key")
|
|
108
|
+
table.add_column("value")
|
|
109
|
+
table.add_row("source id", str(result.get("source_id", "?")))
|
|
110
|
+
table.add_row("page path", str(result.get("page_path", "?")))
|
|
111
|
+
table.add_row("location", str(result.get("location_uri", "?")))
|
|
112
|
+
table.add_row("kind", str(result.get("location_kind", "?")))
|
|
113
|
+
table.add_row("files ingested", str(result.get("files_ingested", 0)))
|
|
114
|
+
section_pages = result.get("section_pages_written") or []
|
|
115
|
+
if section_pages:
|
|
116
|
+
table.add_row("section pages", str(len(section_pages)))
|
|
117
|
+
entities = result.get("entity_pages_written") or []
|
|
118
|
+
if entities:
|
|
119
|
+
table.add_row("entities written", str(len(entities)))
|
|
120
|
+
glossary = result.get("glossary_pages_written") or []
|
|
121
|
+
if glossary:
|
|
122
|
+
table.add_row("glossary written", str(len(glossary)))
|
|
123
|
+
table.add_row("links added", str(result.get("links_added", 0)))
|
|
124
|
+
ignored = result.get("files_ignored") or []
|
|
125
|
+
if ignored:
|
|
126
|
+
truncated_marker = " (truncated)" if result.get("truncated") else ""
|
|
127
|
+
table.add_row(
|
|
128
|
+
f"ignored files{truncated_marker}",
|
|
129
|
+
", ".join(ignored[:6]) + (f", +{len(ignored) - 6} more" if len(ignored) > 6 else ""),
|
|
130
|
+
)
|
|
131
|
+
console.print(table)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def format_search_result(result: dict[str, Any], console: Console) -> None:
|
|
135
|
+
"""Pretty-print `wiki.search` response: ranked hits + optional expansion."""
|
|
136
|
+
if "error" in result:
|
|
137
|
+
console.print(f"[red]retrieve_error[/red]: {result.get('message', '?')}")
|
|
138
|
+
return
|
|
139
|
+
query = result.get("query", "")
|
|
140
|
+
hits = result.get("hits") or []
|
|
141
|
+
if not hits:
|
|
142
|
+
console.print(f"[yellow]no hits[/yellow] for {query!r} — gap_detected")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
table = Table(title=f"wiki.search — {query!r}")
|
|
146
|
+
table.add_column("score", width=7)
|
|
147
|
+
table.add_column("type", width=10)
|
|
148
|
+
table.add_column("id")
|
|
149
|
+
table.add_column("title")
|
|
150
|
+
table.add_column("snippet", overflow="ellipsis", max_width=60)
|
|
151
|
+
for h in hits:
|
|
152
|
+
score = h.get("score", 0.0)
|
|
153
|
+
table.add_row(
|
|
154
|
+
f"{score:.2f}" if isinstance(score, int | float) else str(score),
|
|
155
|
+
str(h.get("type", "?")),
|
|
156
|
+
str(h.get("id", "?")),
|
|
157
|
+
str(h.get("title", "?")),
|
|
158
|
+
(h.get("snippet") or "").replace("\n", " "),
|
|
159
|
+
)
|
|
160
|
+
console.print(table)
|
|
161
|
+
|
|
162
|
+
expanded = result.get("expanded_node_ids") or []
|
|
163
|
+
if expanded:
|
|
164
|
+
console.print(
|
|
165
|
+
f"[dim]+ {len(expanded)} expanded node(s):[/dim] "
|
|
166
|
+
+ ", ".join(expanded[:6])
|
|
167
|
+
+ (f", +{len(expanded) - 6} more" if len(expanded) > 6 else "")
|
|
168
|
+
)
|
|
169
|
+
if result.get("truncated_expansion"):
|
|
170
|
+
console.print("[dim](expansion truncated at smalt's per-hop cap)[/dim]")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def format_ask_result(result: dict[str, Any], console: Console) -> None:
|
|
174
|
+
"""Pretty-print `wiki.ask` response: cited answer + validation flags."""
|
|
175
|
+
if "error" in result:
|
|
176
|
+
console.print(f"[red]converse_error[/red]: {result.get('message', '?')}")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
question = result.get("question", "")
|
|
180
|
+
answer = result.get("answer", "")
|
|
181
|
+
# `question` is user input and `answer` is LLM output — both opaque
|
|
182
|
+
# text that may contain `[...]` patterns (notably `[page:<id>]`
|
|
183
|
+
# citation tokens). Escape so rich doesn't interpret them as markup.
|
|
184
|
+
console.print(f"[bold]Q:[/bold] {escape(question)}\n")
|
|
185
|
+
console.print(f"[bold]A:[/bold] {escape(answer)}\n")
|
|
186
|
+
|
|
187
|
+
citations = result.get("citations") or []
|
|
188
|
+
if citations:
|
|
189
|
+
console.print("[dim]citations:[/dim]")
|
|
190
|
+
for c in citations:
|
|
191
|
+
pid = c.get("page_id", "?")
|
|
192
|
+
valid = c.get("valid", False)
|
|
193
|
+
mark = "[green]✓[/green]" if valid else "[red]✗[/red]"
|
|
194
|
+
console.print(f" {mark} {escape(f'[page:{pid}]')}")
|
|
195
|
+
invalid = result.get("invalid_citations") or []
|
|
196
|
+
if invalid:
|
|
197
|
+
joined = ", ".join(invalid)
|
|
198
|
+
console.print(
|
|
199
|
+
f"[red]warning[/red]: LLM cited {len(invalid)} page id(s) not in the "
|
|
200
|
+
f"corpus — likely hallucination: {escape(joined)}"
|
|
201
|
+
)
|
|
202
|
+
if result.get("gap_detected"):
|
|
203
|
+
console.print(
|
|
204
|
+
"[yellow]gap_detected[/yellow] — the corpus didn't help. "
|
|
205
|
+
'Consider `cogrind-workshop --report-gap "..."` to flag it for Research.'
|
|
206
|
+
)
|
|
207
|
+
if result.get("truncated_context"):
|
|
208
|
+
console.print("[dim](some page bodies were truncated for the LLM context)[/dim]")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def format_gaps_list(result: dict[str, Any], console: Console) -> None:
|
|
212
|
+
"""Pretty-print `wiki.find_gaps` response: list of open knowledge gaps."""
|
|
213
|
+
if "error" in result:
|
|
214
|
+
console.print(f"[red]retrieve_error[/red]: {result.get('message', '?')}")
|
|
215
|
+
return
|
|
216
|
+
gaps = result.get("gaps") or []
|
|
217
|
+
if not gaps:
|
|
218
|
+
console.print("[green]no open gaps[/green]")
|
|
219
|
+
return
|
|
220
|
+
table = Table(title=f"open gaps ({len(gaps)})")
|
|
221
|
+
table.add_column("id")
|
|
222
|
+
table.add_column("query")
|
|
223
|
+
table.add_column("created", overflow="ellipsis", max_width=20)
|
|
224
|
+
table.add_column("why", overflow="ellipsis", max_width=30)
|
|
225
|
+
for g in gaps:
|
|
226
|
+
table.add_row(
|
|
227
|
+
str(g.get("id", "?")),
|
|
228
|
+
str(g.get("query", "?")),
|
|
229
|
+
str(g.get("created_at", "?")),
|
|
230
|
+
str(g.get("why", "") or ""),
|
|
231
|
+
)
|
|
232
|
+
console.print(table)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _yes_no(b: bool) -> str:
|
|
236
|
+
return "[green]yes[/green]" if b else "[red]no[/red]"
|