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.
@@ -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]"