memorytalk 0.4.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.
Files changed (71) hide show
  1. memorytalk/__init__.py +0 -0
  2. memorytalk/__main__.py +4 -0
  3. memorytalk/adapters/__init__.py +4 -0
  4. memorytalk/adapters/base.py +33 -0
  5. memorytalk/adapters/claude_code.py +119 -0
  6. memorytalk/api/__init__.py +100 -0
  7. memorytalk/api/cards.py +20 -0
  8. memorytalk/api/links.py +20 -0
  9. memorytalk/api/log.py +33 -0
  10. memorytalk/api/rebuild.py +21 -0
  11. memorytalk/api/search.py +18 -0
  12. memorytalk/api/sessions.py +18 -0
  13. memorytalk/api/status.py +25 -0
  14. memorytalk/api/tags.py +30 -0
  15. memorytalk/api/view.py +33 -0
  16. memorytalk/cli/__init__.py +21 -0
  17. memorytalk/cli/_format.py +435 -0
  18. memorytalk/cli/_http.py +73 -0
  19. memorytalk/cli/_render.py +71 -0
  20. memorytalk/cli/_setup_helpers.py +157 -0
  21. memorytalk/cli/card.py +41 -0
  22. memorytalk/cli/link.py +45 -0
  23. memorytalk/cli/log.py +32 -0
  24. memorytalk/cli/rebuild.py +31 -0
  25. memorytalk/cli/search.py +40 -0
  26. memorytalk/cli/server.py +146 -0
  27. memorytalk/cli/setup.py +429 -0
  28. memorytalk/cli/sync.py +57 -0
  29. memorytalk/cli/tag.py +51 -0
  30. memorytalk/cli/view.py +32 -0
  31. memorytalk/config.py +150 -0
  32. memorytalk/provider/__init__.py +0 -0
  33. memorytalk/provider/embedding.py +165 -0
  34. memorytalk/provider/lancedb.py +182 -0
  35. memorytalk/provider/storage.py +89 -0
  36. memorytalk/repository/__init__.py +18 -0
  37. memorytalk/repository/cards.py +124 -0
  38. memorytalk/repository/links.py +101 -0
  39. memorytalk/repository/schema.py +77 -0
  40. memorytalk/repository/search_log.py +105 -0
  41. memorytalk/repository/sessions.py +240 -0
  42. memorytalk/repository/store.py +50 -0
  43. memorytalk/schemas/__init__.py +51 -0
  44. memorytalk/schemas/cards.py +21 -0
  45. memorytalk/schemas/links.py +20 -0
  46. memorytalk/schemas/log.py +22 -0
  47. memorytalk/schemas/rebuild.py +12 -0
  48. memorytalk/schemas/search.py +44 -0
  49. memorytalk/schemas/sessions.py +37 -0
  50. memorytalk/schemas/shared.py +48 -0
  51. memorytalk/schemas/status.py +17 -0
  52. memorytalk/schemas/tags.py +14 -0
  53. memorytalk/schemas/view.py +36 -0
  54. memorytalk/service/__init__.py +25 -0
  55. memorytalk/service/cards.py +264 -0
  56. memorytalk/service/events.py +34 -0
  57. memorytalk/service/links.py +128 -0
  58. memorytalk/service/rebuild.py +130 -0
  59. memorytalk/service/search.py +182 -0
  60. memorytalk/service/sessions.py +309 -0
  61. memorytalk/util/__init__.py +0 -0
  62. memorytalk/util/dsl.py +327 -0
  63. memorytalk/util/ids.py +66 -0
  64. memorytalk/util/snippet.py +82 -0
  65. memorytalk/util/ttl.py +60 -0
  66. memorytalk-0.4.0.dist-info/METADATA +215 -0
  67. memorytalk-0.4.0.dist-info/RECORD +71 -0
  68. memorytalk-0.4.0.dist-info/WHEEL +5 -0
  69. memorytalk-0.4.0.dist-info/entry_points.txt +2 -0
  70. memorytalk-0.4.0.dist-info/licenses/LICENSE +201 -0
  71. memorytalk-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,435 @@
1
+ """Markdown formatters for v2 CLI.
2
+
3
+ Each function takes a response dict (typically straight from the API) and
4
+ returns a Markdown string. The shapes are pinned to ``docs/cli/v2/<cmd>.md``
5
+ — update those docs in lockstep when changing what's emitted here.
6
+
7
+ Direction labels (`TO` / `FROM`) on link lines are intentionally not
8
+ emitted yet — see the TODO(code) callouts in `docs/cli/v2/search.md` and
9
+ `view.md`.
10
+ """
11
+ from __future__ import annotations
12
+ from typing import Any
13
+
14
+
15
+ # ---------- helpers ----------
16
+
17
+ def _join(*lines: str) -> str:
18
+ """Join lines with '\n', collapse 3+ blanks to 2, ensure trailing newline."""
19
+ text = "\n".join(lines)
20
+ while "\n\n\n" in text:
21
+ text = text.replace("\n\n\n", "\n\n")
22
+ if not text.endswith("\n"):
23
+ text += "\n"
24
+ return text
25
+
26
+
27
+ def _link_line(link: dict) -> str:
28
+ """`<peer_id>` (type[ · expired]) [· comment]"""
29
+ peer = link.get("target_id", "")
30
+ ptype = link.get("target_type", "")
31
+ ttl = link.get("ttl")
32
+ comment = link.get("comment")
33
+
34
+ type_inner = ptype
35
+ if isinstance(ttl, (int, float)) and ttl < 0:
36
+ type_inner = f"{ptype} · expired"
37
+
38
+ parts = [f"`{peer}` ({type_inner})"]
39
+ if comment:
40
+ parts.append(comment)
41
+ return " · ".join(parts)
42
+
43
+
44
+ def _links_section(links: list[dict], max_show: int = 3) -> list[str]:
45
+ if not links:
46
+ return []
47
+ out = ["**Links:**", ""]
48
+ for link in links[:max_show]:
49
+ out.append(f"- {_link_line(link)}")
50
+ if len(links) > max_show:
51
+ out.append(f"- +{len(links) - max_show} more")
52
+ out.append("")
53
+ return out
54
+
55
+
56
+ # ---------- error ----------
57
+
58
+ def fmt_error(message: str) -> str:
59
+ return f"**error:** {message}\n"
60
+
61
+
62
+ # ---------- search ----------
63
+
64
+ def fmt_search(resp: dict) -> str:
65
+ out: list[str] = []
66
+ query = resp.get("query") or "*(empty)*"
67
+ out.append(f"# search: {query}")
68
+ out.append("")
69
+ out.append(f"`search_id={resp.get('search_id', '')}`")
70
+ out.append("")
71
+
72
+ cards = resp.get("cards") or {"count": 0, "results": []}
73
+ out.append(f"## cards ({cards.get('count', 0)})")
74
+ out.append("")
75
+ for r in cards.get("results") or []:
76
+ out.append(f"### {r['rank']}. CARD `{r['card_id']}`")
77
+ out.append("")
78
+ if r.get("summary"):
79
+ out.append(f"**Summary:** {r['summary']}")
80
+ out.append("")
81
+ snippets = r.get("snippets") or []
82
+ if snippets:
83
+ out.append("**Snippets:**")
84
+ out.append("")
85
+ for s in snippets:
86
+ out.append(f"- {s}")
87
+ out.append("")
88
+ out.extend(_links_section(r.get("links") or []))
89
+
90
+ sessions = resp.get("sessions") or {"count": 0, "results": []}
91
+ out.append(f"## sessions ({sessions.get('count', 0)})")
92
+ out.append("")
93
+ for r in sessions.get("results") or []:
94
+ out.append(f"### {r['rank']}. SESSION `{r['session_id']}`")
95
+ out.append("")
96
+ tags = r.get("tags") or []
97
+ if tags:
98
+ out.append("**Tags:** " + ", ".join(f"`{t}`" for t in tags))
99
+ out.append("")
100
+ snippets = r.get("snippets") or []
101
+ if snippets:
102
+ out.append("**Snippets:**")
103
+ out.append("")
104
+ for s in snippets:
105
+ out.append(f"- {s}")
106
+ out.append("")
107
+ out.extend(_links_section(r.get("links") or []))
108
+ if r.get("source"):
109
+ out.append(f"**Source:** {r['source']}")
110
+ out.append("")
111
+
112
+ return _join(*out)
113
+
114
+
115
+ # ---------- view ----------
116
+
117
+ def _session_round_body(content: list[dict]) -> str:
118
+ """Render the text/code blocks of a session round as Markdown.
119
+
120
+ Non-text types (thinking, tool_use, tool_result, ...) are not included
121
+ in the body — their presence is announced via `+<type>` markers in the
122
+ round header instead.
123
+ """
124
+ parts = []
125
+ for b in content or []:
126
+ t = b.get("type")
127
+ text = b.get("text") or ""
128
+ if t == "text" and text:
129
+ parts.append(text)
130
+ elif t == "code" and text:
131
+ lang = b.get("language") or ""
132
+ parts.append(f"```{lang}\n{text}\n```")
133
+ return "\n\n".join(parts)
134
+
135
+
136
+ def fmt_view_card(resp: dict) -> str:
137
+ card = resp.get("card") or {}
138
+ out: list[str] = []
139
+ out.append(f"# CARD `{card.get('card_id', '')}`")
140
+ out.append("")
141
+ if card.get("summary"):
142
+ out.append(f"**Summary:** {card['summary']}")
143
+ out.append("")
144
+
145
+ links = resp.get("links") or []
146
+ out.append(f"## links ({len(links)})")
147
+ out.append("")
148
+ for link in links:
149
+ out.append(f"- {_link_line(link)}")
150
+ out.append("")
151
+
152
+ rounds = card.get("rounds") or []
153
+ out.append(f"## rounds ({len(rounds)})")
154
+ out.append("")
155
+ for i, r in enumerate(rounds):
156
+ if i > 0:
157
+ out.append("---")
158
+ out.append("")
159
+ sid = r.get("session_id", "")
160
+ idx = r.get("index", "")
161
+ role = r.get("role") or ""
162
+ thinking_marker = " +thinking" if r.get("thinking") else ""
163
+ out.append(f"**[`{sid}`#{idx} {role}{thinking_marker}]**")
164
+ out.append("")
165
+ body = (r.get("text") or "").rstrip()
166
+ if body:
167
+ out.append(body)
168
+ out.append("")
169
+
170
+ return _join(*out)
171
+
172
+
173
+ def fmt_view_session(resp: dict) -> str:
174
+ sess = resp.get("session") or {}
175
+ out: list[str] = []
176
+ out.append(f"# SESSION `{sess.get('session_id', '')}`")
177
+ out.append("")
178
+ if sess.get("created_at"):
179
+ out.append(f"**Created:** `{sess['created_at']}`")
180
+ out.append("")
181
+ tags = sess.get("tags") or []
182
+ if tags:
183
+ out.append("**Tags:** " + ", ".join(f"`{t}`" for t in tags))
184
+ out.append("")
185
+ metadata = sess.get("metadata") or {}
186
+ if metadata:
187
+ out.append("**Metadata:**")
188
+ out.append("")
189
+ for k, v in metadata.items():
190
+ out.append(f"- {k}: `{v}`")
191
+ out.append("")
192
+ if sess.get("source"):
193
+ out.append(f"**Source:** {sess['source']}")
194
+ out.append("")
195
+
196
+ links = resp.get("links") or []
197
+ out.append(f"## links ({len(links)})")
198
+ out.append("")
199
+ for link in links:
200
+ out.append(f"- {_link_line(link)}")
201
+ out.append("")
202
+
203
+ rounds = sess.get("rounds") or []
204
+ out.append(f"## rounds ({len(rounds)})")
205
+ out.append("")
206
+ for i, r in enumerate(rounds):
207
+ if i > 0:
208
+ out.append("---")
209
+ out.append("")
210
+ idx = r.get("index", "")
211
+ role = r.get("role") or ""
212
+ content = r.get("content") or []
213
+ extras: list[str] = []
214
+ for b in content:
215
+ t = b.get("type")
216
+ if t and t not in ("text", "code") and t not in extras:
217
+ extras.append(t)
218
+ extras_marker = "".join(f" +{e}" for e in extras)
219
+ out.append(f"**[#{idx} {role}{extras_marker}]**")
220
+ out.append("")
221
+ body = _session_round_body(content).rstrip()
222
+ if body:
223
+ out.append(body)
224
+ out.append("")
225
+
226
+ return _join(*out)
227
+
228
+
229
+ def fmt_view(resp: dict) -> str:
230
+ if resp.get("type") == "card":
231
+ return fmt_view_card(resp)
232
+ if resp.get("type") == "session":
233
+ return fmt_view_session(resp)
234
+ return fmt_error(f"unknown view type: {resp.get('type')!r}")
235
+
236
+
237
+ # ---------- log ----------
238
+
239
+ def _detail_summary(kind: str, detail: dict) -> str:
240
+ if kind == "imported":
241
+ return f"source={detail.get('source', '')} · round_count={detail.get('round_count', '')}"
242
+ if kind == "rounds_appended":
243
+ return (
244
+ f"indexes {detail.get('from_index', '?')}-{detail.get('to_index', '?')} "
245
+ f"(+{detail.get('added_count', '?')})"
246
+ )
247
+ if kind == "rounds_overwrite_skipped":
248
+ idxs = detail.get("indexes", [])
249
+ return f"indexes={','.join(str(i) for i in idxs)}"
250
+ if kind == "tag_added" or kind == "tag_removed":
251
+ return f"`{detail.get('tag', '')}`"
252
+ if kind == "card_extracted":
253
+ return f"`{detail.get('card_id', '')}` · indexes={detail.get('indexes', '')}"
254
+ if kind == "linked":
255
+ direction = detail.get("direction") or ""
256
+ peer = detail.get("peer_id") or ""
257
+ comment = detail.get("comment") or ""
258
+ arrow = "←incoming" if direction == "incoming" else "→outgoing"
259
+ parts = [f"{arrow} `{peer}`"]
260
+ if comment:
261
+ parts.append(f"({comment})")
262
+ return " ".join(parts)
263
+ if kind == "created":
264
+ summary = detail.get("summary") or ""
265
+ rounds = detail.get("rounds") or []
266
+ rounds_part = ", ".join(f"`{r['session_id']}`/{r['indexes']}" for r in rounds) or "(none)"
267
+ n_default = len(detail.get("default_links") or [])
268
+ from_search = detail.get("from_search_id")
269
+ bits = [summary, f"rounds={rounds_part}", f"{n_default} default_link" + ("s" if n_default != 1 else "")]
270
+ if from_search:
271
+ bits.append(f"from `{from_search}`")
272
+ return " · ".join(bits)
273
+ # Fallback: compact key=val
274
+ if isinstance(detail, dict) and detail:
275
+ return " · ".join(f"{k}={v}" for k, v in detail.items())
276
+ return ""
277
+
278
+
279
+ def _fmt_log_table(events: list[dict]) -> list[str]:
280
+ out = [
281
+ "| at | kind | detail |",
282
+ "|---|---|---|",
283
+ ]
284
+ for e in events:
285
+ at = e.get("at", "")
286
+ kind = e.get("kind", "")
287
+ detail = _detail_summary(kind, e.get("detail") or {})
288
+ # Escape pipes inside detail to keep table valid.
289
+ detail = detail.replace("|", "\\|")
290
+ out.append(f"| `{at}` | {kind} | {detail} |")
291
+ return out
292
+
293
+
294
+ def fmt_log_card(resp: dict) -> str:
295
+ events = resp.get("events") or []
296
+ out = [
297
+ f"# CARD `{resp.get('card_id', '')}` · {len(events)} events",
298
+ "",
299
+ ]
300
+ out.extend(_fmt_log_table(events))
301
+ out.append("")
302
+ return _join(*out)
303
+
304
+
305
+ def fmt_log_session(resp: dict) -> str:
306
+ events = resp.get("events") or []
307
+ out = [
308
+ f"# SESSION `{resp.get('session_id', '')}` · {len(events)} events",
309
+ "",
310
+ ]
311
+ out.extend(_fmt_log_table(events))
312
+ out.append("")
313
+ return _join(*out)
314
+
315
+
316
+ def fmt_log(resp: dict) -> str:
317
+ if resp.get("type") == "card":
318
+ return fmt_log_card(resp)
319
+ if resp.get("type") == "session":
320
+ return fmt_log_session(resp)
321
+ return fmt_error(f"unknown log type: {resp.get('type')!r}")
322
+
323
+
324
+ # ---------- write commands (single line ok) ----------
325
+
326
+ def fmt_card_create(resp: dict) -> str:
327
+ return f"ok: created `{resp.get('card_id', '')}`\n"
328
+
329
+
330
+ def fmt_link_create(resp: dict) -> str:
331
+ return f"ok: linked `{resp.get('link_id', '')}`\n"
332
+
333
+
334
+ def fmt_tag(resp: dict) -> str:
335
+ tags = resp.get("tags") or []
336
+ if not tags:
337
+ return "ok: tags = *(empty)*\n"
338
+ return "ok: tags = " + ", ".join(f"`{t}`" for t in tags) + "\n"
339
+
340
+
341
+ # ---------- sync / rebuild ----------
342
+
343
+ def fmt_sync(resp: dict) -> str:
344
+ out = [
345
+ f"# sync · **{resp.get('status', 'ok')}**",
346
+ "",
347
+ "| field | count |",
348
+ "|---|---|",
349
+ ]
350
+ for key in ("discovered", "imported", "skipped", "appended", "overwrite_warnings", "errors"):
351
+ if key in resp:
352
+ out.append(f"| {key} | {resp[key]} |")
353
+ out.append("")
354
+ return _join(*out)
355
+
356
+
357
+ def fmt_rebuild(resp: dict) -> str:
358
+ out = [
359
+ f"# rebuild · **{resp.get('status', 'ok')}**",
360
+ "",
361
+ "| field | count |",
362
+ "|---|---|",
363
+ ]
364
+ for key, label in (
365
+ ("sessions", "sessions"),
366
+ ("cards", "cards"),
367
+ ("searches_replayed", "searches_replayed"),
368
+ ("events_replayed", "events_replayed"),
369
+ ("errors_count", "errors"),
370
+ ):
371
+ if key in resp:
372
+ out.append(f"| {label} | {resp[key]} |")
373
+ out.append("")
374
+ return _join(*out)
375
+
376
+
377
+ # ---------- server ----------
378
+
379
+ def fmt_server_start(payload: dict) -> str:
380
+ status = payload.get("status", "")
381
+ if status == "started":
382
+ return f"**started** · pid `{payload.get('pid', '')}` · port `{payload.get('port', '')}`\n"
383
+ if status == "already_running":
384
+ return f"**already_running** · pid `{payload.get('pid', '')}` · port `{payload.get('port', '')}`\n"
385
+ if status == "failed":
386
+ # Render as an error block; caller handles exit code.
387
+ err = payload.get("error", "")
388
+ ec = payload.get("exit_code", "")
389
+ return _join(
390
+ f"**error:** server failed to start (exit_code={ec})",
391
+ "",
392
+ "```",
393
+ err,
394
+ "```",
395
+ )
396
+ return f"{status}\n"
397
+
398
+
399
+ def fmt_server_stop(payload: dict) -> str:
400
+ status = payload.get("status", "")
401
+ if status == "stopped":
402
+ return f"**stopped** · pid `{payload.get('pid', '')}`\n"
403
+ if status == "not_running":
404
+ return "**not_running**\n"
405
+ return f"{status}\n"
406
+
407
+
408
+ def fmt_status(payload: dict) -> str:
409
+ status = payload.get("status", "")
410
+ if status == "running":
411
+ out = [
412
+ f"# memory-talk · **running**",
413
+ "",
414
+ "| field | value |",
415
+ "|---|---|",
416
+ f"| data_root | `{payload.get('data_root', '')}` |",
417
+ f"| settings | `{payload.get('settings_path', '')}` |",
418
+ f"| sessions | {payload.get('sessions_total', 0)} |",
419
+ f"| cards | {payload.get('cards_total', 0)} |",
420
+ f"| links | {payload.get('links_total', 0)} |",
421
+ f"| searches | {payload.get('searches_total', 0)} |",
422
+ f"| embedding | {payload.get('embedding_provider', '')} |",
423
+ f"| vector | {payload.get('vector_provider', '')} |",
424
+ f"| relation | {payload.get('relation_provider', '')} |",
425
+ "",
426
+ ]
427
+ return _join(*out)
428
+ out = [
429
+ f"# memory-talk · **{status or 'unknown'}**",
430
+ "",
431
+ f"- data_root: `{payload.get('data_root', '')}`",
432
+ f"- settings: `{payload.get('settings_path', '')}`",
433
+ "",
434
+ ]
435
+ return _join(*out)
@@ -0,0 +1,73 @@
1
+ """HTTP client helpers for CLI commands.
2
+
3
+ Tests can override `_make_client` to route requests through an in-process
4
+ ASGI transport instead of a real TCP socket — this lets test cases exercise
5
+ the full CLI code path without spawning uvicorn.
6
+ """
7
+ from __future__ import annotations
8
+ from typing import Any, Callable, Optional
9
+
10
+ import httpx
11
+
12
+ from memorytalk.config import Config
13
+
14
+
15
+ class ApiError(RuntimeError):
16
+ def __init__(self, status_code: int, payload: Any):
17
+ self.status_code = status_code
18
+ self.payload = payload
19
+ super().__init__(f"API {status_code}: {payload}")
20
+
21
+
22
+ def extract_error_message(payload: Any) -> str:
23
+ """Pull a single human-readable message string out of whatever shape the
24
+ server (or the CLI) handed us as an error payload.
25
+
26
+ FastAPI tends to return ``{"detail": "..."}``. Our own services return
27
+ ``{"error": "..."}``. The rebuild gate returns ``{"error": "rebuilding"}``.
28
+ Some paths produce nested dicts. Plain strings come through too.
29
+ """
30
+ if isinstance(payload, str):
31
+ return payload
32
+ if isinstance(payload, dict):
33
+ for key in ("error", "detail", "message"):
34
+ v = payload.get(key)
35
+ if isinstance(v, str) and v:
36
+ return v
37
+ if isinstance(v, dict):
38
+ # one level of unwrap, e.g. {"error": {"detail": "..."}}
39
+ return extract_error_message(v)
40
+ # Fall back to a compact JSON-ish dump
41
+ try:
42
+ import json as _json
43
+ return _json.dumps(payload, ensure_ascii=False)
44
+ except Exception:
45
+ return str(payload)
46
+ return str(payload)
47
+
48
+
49
+ def _default_client(cfg: Config) -> httpx.Client:
50
+ base = f"http://127.0.0.1:{cfg.settings.server.port}"
51
+ return httpx.Client(base_url=base, timeout=30.0)
52
+
53
+
54
+ # Test hook: set to a callable(Config) -> httpx.Client to override transport
55
+ # (e.g. route to an in-process ASGI app instead of 127.0.0.1:<port>).
56
+ _make_client: Optional[Callable[[Config], httpx.Client]] = None
57
+
58
+
59
+ def api(method: str, path: str, config: Config,
60
+ json_body: dict | None = None, timeout: float = 30.0) -> dict:
61
+ factory = _make_client or _default_client
62
+ client = factory(config)
63
+ # No `with` — ASGI test transport has no context-manager support, and
64
+ # the CLI is a short-lived process where leaked TCP sockets get reaped
65
+ # at exit. Tests share a long-lived ASGI client across calls.
66
+ resp = client.request(method, path, json=json_body, timeout=timeout)
67
+ if resp.status_code >= 400:
68
+ try:
69
+ payload = resp.json()
70
+ except Exception:
71
+ payload = resp.text
72
+ raise ApiError(resp.status_code, payload)
73
+ return resp.json()
@@ -0,0 +1,71 @@
1
+ """Output rendering for v2 CLI.
2
+
3
+ Two contracts:
4
+ - **Markdown** (default): emit Markdown text. If the target stream is a TTY,
5
+ render with rich; otherwise emit the raw Markdown so pipes / scripts /
6
+ LLMs see clean text.
7
+ - **JSON** (`--json`): always emit raw JSON (UTF-8, ensure_ascii=False).
8
+
9
+ Errors:
10
+ - Markdown mode → `**error:** <msg>` to stderr, exit 1
11
+ - JSON mode → `{"error": ...}` to stdout, exit 1
12
+ """
13
+ from __future__ import annotations
14
+ import json
15
+ import sys
16
+ from typing import Any
17
+
18
+
19
+ def _render_md_to(stream, text: str) -> None:
20
+ if stream.isatty():
21
+ # Lazy-import rich so non-TTY scripts don't pay the import cost.
22
+ from rich.console import Console
23
+ from rich.markdown import Heading, Markdown
24
+
25
+ class _FlatHeading(Heading):
26
+ """Left-aligned bold headings.
27
+
28
+ rich's default Heading hard-codes H1 as a centered Panel and H2 as
29
+ centered text. With long ULID-bearing titles like
30
+ "CARD card_01KQ12E6R43JXMFDEKZ0292ZKZ", centering pushes the
31
+ content way off the left margin. Override to keep all headings
32
+ flush-left; H2 picks up an underline so the visual hierarchy is
33
+ still legible.
34
+ """
35
+ def __rich_console__(self, console, options):
36
+ t = self.text
37
+ t.justify = "left"
38
+ t.stylize("bold")
39
+ if self.tag == "h2":
40
+ t.stylize("underline")
41
+ yield t
42
+
43
+ class _FlatMarkdown(Markdown):
44
+ elements = {**Markdown.elements, "heading_open": _FlatHeading}
45
+
46
+ Console(file=stream).print(_FlatMarkdown(text))
47
+ else:
48
+ stream.write(text)
49
+ if not text.endswith("\n"):
50
+ stream.write("\n")
51
+
52
+
53
+ def emit_md(text: str) -> None:
54
+ """Markdown to stdout — rendered when TTY, raw otherwise."""
55
+ _render_md_to(sys.stdout, text)
56
+
57
+
58
+ def emit_md_err(text: str) -> None:
59
+ """Markdown to stderr — rendered when stderr is a TTY, raw otherwise."""
60
+ _render_md_to(sys.stderr, text)
61
+
62
+
63
+ def emit_json(data: Any) -> None:
64
+ """JSON to stdout."""
65
+ sys.stdout.write(json.dumps(data, ensure_ascii=False) + "\n")
66
+
67
+
68
+ def emit_json_err(payload: Any) -> None:
69
+ """JSON error envelope to stdout (per the docs contract: --json errors
70
+ use the same stream as --json successes)."""
71
+ sys.stdout.write(json.dumps({"error": payload}, ensure_ascii=False) + "\n")