induscode 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.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,455 @@
1
+ """publish_transcript — render a session transcript to a standalone,
2
+ self-contained HTML document.
3
+
4
+ Port of TS ``src/transcript-export/publish.ts``, with the two third-party
5
+ renderers swapped for their Python counterparts: ``marked`` →
6
+ **markdown-it-py** (CommonMark with the GFM-ish ``table`` + ``strikethrough``
7
+ rules enabled) and ``highlight.js`` → **Pygments** (a small custom emitter
8
+ maps Pygments token families onto the page's existing ``xhl-*`` class
9
+ family). Everything else — the SGR painting, the widget splice, the base64
10
+ payload, the shell fill — is the TS pipeline verbatim.
11
+
12
+ The publisher takes a list of transcript entries (each carrying a framework
13
+ message) and produces one HTML string with no external dependencies: prose is
14
+ rendered to HTML with markdown-it-py, fenced code is syntax-highlighted with
15
+ Pygments, terminal-styled tool output is painted to spans by
16
+ :func:`~induscode.transcript_export.sgr.paint_sgr`, and all colors come from a
17
+ :class:`~induscode.transcript_export.contract.ThemeBridge` over an
18
+ :class:`~induscode.transcript_export.contract.ExportTheme`. Markdown and
19
+ highlighting are run *here*, at publish time, so the rendered HTML is baked
20
+ into the page payload and the page needs no client-side parser to display —
21
+ the page ships ready to read.
22
+
23
+ The two third-party libraries this layer leans on are MIT (markdown-it-py)
24
+ and BSD-2-Clause (Pygments); their license notices are preserved verbatim in
25
+ the emitted document (the ``MARKED_LIB`` / ``HIGHLIGHT_LIB`` shell slots) as
26
+ the licenses require, while the libraries themselves are used, never
27
+ reimplemented.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import base64
33
+ import json
34
+ import re
35
+ from collections.abc import Mapping, MutableMapping, Sequence
36
+ from dataclasses import dataclass
37
+ from typing import Any, Final, Literal
38
+
39
+ from markdown_it import MarkdownIt
40
+ from pygments.lexer import Lexer
41
+ from pygments.lexers import get_lexer_by_name, guess_lexer
42
+ from pygments.token import Comment, Keyword
43
+ from pygments.token import Literal as LiteralToken
44
+ from pygments.token import Number, String, _TokenType
45
+ from pygments.util import ClassNotFound
46
+
47
+ from .contract import (
48
+ FALLBACK_EXPORT_THEME,
49
+ SHELL_SLOTS,
50
+ ImageContent,
51
+ MessagePart,
52
+ PublishEntry,
53
+ PublishOptions,
54
+ PublishRole,
55
+ TextContent,
56
+ ThemeBridge,
57
+ WidgetRender,
58
+ briefing_fault,
59
+ )
60
+ from .sgr import paint_sgr
61
+ from .template import CLIENT_SCRIPT, PAGE_SHELL, PAGE_STYLES, SlotValues, fill
62
+ from .theme_bridge import create_theme_bridge
63
+
64
+ __all__ = [
65
+ "HIGHLIGHT_LICENSE",
66
+ "MARKDOWN_LICENSE",
67
+ "publish_transcript",
68
+ ]
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Preserved third-party license notices
73
+ # ---------------------------------------------------------------------------
74
+
75
+ #: The MIT notice for ``markdown-it-py``, embedded so the export honors the
76
+ #: library's license. Copyright line preserved verbatim; not paraphrased.
77
+ #: (Replaces the TS build's ``marked`` notice — the Python port renders
78
+ #: markdown with markdown-it-py.)
79
+ MARKDOWN_LICENSE: Final[str] = "\n".join(
80
+ [
81
+ "markdown-it-py — https://github.com/executablebooks/markdown-it-py",
82
+ "Copyright (c) 2020 ExecutableBookProject",
83
+ "Released under the MIT License.",
84
+ ]
85
+ )
86
+
87
+ #: The BSD-2-Clause notice for ``Pygments``, embedded so the export honors
88
+ #: the library's license. Copyright lines preserved verbatim; not paraphrased.
89
+ #: (Replaces the TS build's ``highlight.js`` notice — the Python port
90
+ #: highlights code with Pygments.)
91
+ HIGHLIGHT_LICENSE: Final[str] = "\n".join(
92
+ [
93
+ "Pygments — https://github.com/pygments/pygments",
94
+ "Copyright (c) 2006-2022 by the respective authors (see AUTHORS file).",
95
+ "All rights reserved.",
96
+ "Released under the BSD-2-Clause License.",
97
+ ]
98
+ )
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Turn model (the rendered payload)
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ @dataclass(frozen=True, slots=True, kw_only=True)
107
+ class _RenderedTurn:
108
+ """A rendered transcript turn in the page payload. The client script
109
+ injects each turn's already-built HTML; the publisher does all rendering
110
+ here so the page stays parser-free.
111
+ """
112
+
113
+ # Either a styled card ("turn") or a standalone informational "note".
114
+ kind: Literal["turn", "note"]
115
+ # Attribution role, used for the card's accent.
116
+ role: PublishRole
117
+ # Heading label for the card.
118
+ label: str
119
+ # The pre-rendered inner HTML for the turn body.
120
+ html: str
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Escaping helpers
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def _escape_html(text: str) -> str:
129
+ """HTML-escape the five significant characters in element/attribute
130
+ text."""
131
+ return (
132
+ text.replace("&", "&")
133
+ .replace("<", "&lt;")
134
+ .replace(">", "&gt;")
135
+ .replace('"', "&quot;")
136
+ .replace("'", "&#39;")
137
+ )
138
+
139
+
140
+ def _has_ansi(text: str) -> bool:
141
+ """True when a string carries an ANSI escape introducer worth painting."""
142
+ return "\x1b[" in text
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Markdown + code rendering
147
+ # ---------------------------------------------------------------------------
148
+
149
+ #: Pygments token families mapped onto the page's own ``xhl-*`` classes (the
150
+ #: family the stylesheet declares: keyword / string / comment / number /
151
+ #: literal). The narrower ``String`` / ``Number`` families come before their
152
+ #: ``Literal`` parent so a string literal classes as ``xhl-string``, not
153
+ #: ``xhl-literal``; token types outside every family are emitted as plain
154
+ #: escaped text, exactly as the TS build left unrecognized hljs spans
155
+ #: unstyled.
156
+ _XHL_FAMILIES: Final[tuple[tuple[_TokenType, str], ...]] = (
157
+ (String, "xhl-string"),
158
+ (Number, "xhl-number"),
159
+ (Comment, "xhl-comment"),
160
+ (Keyword, "xhl-keyword"),
161
+ (LiteralToken, "xhl-literal"),
162
+ )
163
+
164
+
165
+ def _xhl_class_for(token_type: _TokenType) -> str | None:
166
+ """Resolve a Pygments token type to its ``xhl-*`` class, or ``None`` when
167
+ the type belongs to no styled family."""
168
+ for family, css_class in _XHL_FAMILIES:
169
+ if token_type in family:
170
+ return css_class
171
+ return None
172
+
173
+
174
+ def _emit_xhl(lexer: Lexer, code: str) -> str:
175
+ """Run a Pygments lexer over a code string and emit the inner HTML of a
176
+ code block: each token's text escaped, tokens in a styled family wrapped
177
+ in a ``<span class="xhl-*">``. The Python analogue of hljs's ``.value``
178
+ (no wrapping ``<pre>``/``<code>``)."""
179
+ pieces: list[tuple[_TokenType, str]] = list(lexer.get_tokens(code))
180
+ # Pygments guarantees the lexed source ends with a newline, appending one
181
+ # when the input lacked it; trim that synthetic newline so the emitted
182
+ # block mirrors the input exactly.
183
+ if not code.endswith("\n") and pieces and pieces[-1][1].endswith("\n"):
184
+ last_type, last_text = pieces[-1]
185
+ pieces[-1] = (last_type, last_text[:-1])
186
+
187
+ out: list[str] = []
188
+ for token_type, value in pieces:
189
+ if value == "":
190
+ continue
191
+ safe = _escape_html(value)
192
+ css_class = _xhl_class_for(token_type)
193
+ out.append(f'<span class="{css_class}">{safe}</span>' if css_class else safe)
194
+ return "".join(out)
195
+
196
+
197
+ def _highlight_code(code: str, language: str | None) -> str:
198
+ """Highlight a fenced code block to HTML using Pygments. A recognized
199
+ language is highlighted under that grammar; otherwise the guesser picks
200
+ one. Highlighting failures fall back to plain escaped code so a published
201
+ page never breaks on an exotic snippet.
202
+ """
203
+ try:
204
+ lexer: Lexer | None = None
205
+ if language:
206
+ try:
207
+ lexer = get_lexer_by_name(language)
208
+ except ClassNotFound:
209
+ lexer = None
210
+ if lexer is None:
211
+ try:
212
+ lexer = guess_lexer(code)
213
+ except ClassNotFound:
214
+ return _escape_html(code)
215
+ return _emit_xhl(lexer, code)
216
+ except Exception:
217
+ return _escape_html(code)
218
+
219
+
220
+ def _render_code_rule(self: Any, tokens: Any, idx: int, options: Any, env: Any) -> str:
221
+ """The markdown-it render rule routing fenced (and indented) code through
222
+ :func:`_highlight_code` — the Python analogue of the TS ``marked``
223
+ renderer override. The fence's info string is trimmed and used whole as
224
+ the language tag, exactly as the TS build did."""
225
+ token = tokens[idx]
226
+ lang = (token.info or "").strip() or None
227
+ text = token.content
228
+ # The fence token's content carries the block's trailing newline; the TS
229
+ # marked token did not — trim it so the highlighted block matches.
230
+ if text.endswith("\n"):
231
+ text = text[:-1]
232
+ highlighted = _highlight_code(text, lang)
233
+ cls = f' class="language-{_escape_html(lang)}"' if lang else ""
234
+ return f"<pre><code{cls}>{highlighted}</code></pre>\n"
235
+
236
+
237
+ def _configured_markdown() -> MarkdownIt:
238
+ """A markdown-it-py instance configured GFM-ish (CommonMark plus the
239
+ ``table`` and ``strikethrough`` rules — the table/strikethrough surface
240
+ the TS build's ``gfm: true`` provided) with fenced code routed through
241
+ :func:`_highlight_code`. Built once and reused across every turn in a
242
+ publish."""
243
+ md = MarkdownIt("commonmark").enable("table").enable("strikethrough")
244
+ md.add_render_rule("fence", _render_code_rule)
245
+ md.add_render_rule("code_block", _render_code_rule)
246
+ return md
247
+
248
+
249
+ def _render_markdown(md: MarkdownIt, source: str) -> str:
250
+ """Render a markdown string to an HTML fragment synchronously."""
251
+ out = md.render(source)
252
+ return out if isinstance(out, str) else ""
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # Content-part rendering
257
+ # ---------------------------------------------------------------------------
258
+
259
+
260
+ def _render_image(part: ImageContent) -> str:
261
+ """Render one image content part as an inline data-URI ``<img>``."""
262
+ src = f"data:{_escape_html(part.mimeType)};base64,{part.data}"
263
+ return f'<img alt="attached image" src="{src}" />'
264
+
265
+
266
+ def _render_tool_call(name: str, arguments: Mapping[str, object]) -> str:
267
+ """Render a tool-call part as a labeled invocation frame."""
268
+ try:
269
+ payload = json.dumps(dict(arguments), indent=2, ensure_ascii=False, default=str)
270
+ except Exception:
271
+ payload = str(arguments)
272
+ return (
273
+ f'<div class="tool-frame"><strong>{_escape_html(name)}</strong>\n'
274
+ f"{_escape_html(payload)}</div>"
275
+ )
276
+
277
+
278
+ def _render_tool_text(text: str) -> str:
279
+ """Render terminal-styled tool output: ANSI-bearing text is painted to
280
+ styled spans inside a ``<pre>``; plain text is escaped into the same
281
+ frame. The result is always a self-contained fragment safe to inject.
282
+ """
283
+ inner = paint_sgr(text) if _has_ansi(text) else _escape_html(text)
284
+ return f'<pre class="tool-frame">{inner}</pre>'
285
+
286
+
287
+ def _parts_of(content: str | Sequence[MessagePart]) -> Sequence[MessagePart]:
288
+ """Coerce a message's content into a list of parts. A bare string becomes
289
+ a single text part; an existing list is returned as-is.
290
+ """
291
+ if isinstance(content, str):
292
+ return (TextContent(text=content),)
293
+ return content
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Turn rendering
298
+ # ---------------------------------------------------------------------------
299
+
300
+ #: Human-facing heading for each role.
301
+ _ROLE_LABEL: Final[Mapping[PublishRole, str]] = {
302
+ "user": "You",
303
+ "assistant": "Assistant",
304
+ "tool": "Tool",
305
+ "system": "System",
306
+ "condense": "Summary",
307
+ "note": "Note",
308
+ }
309
+
310
+
311
+ def _render_entry(
312
+ entry: PublishEntry,
313
+ md: MarkdownIt,
314
+ widgets: Mapping[str, WidgetRender],
315
+ ) -> _RenderedTurn:
316
+ """Render one transcript entry into a :class:`_RenderedTurn`.
317
+
318
+ User/assistant prose runs through markdown; images inline as data URIs;
319
+ assistant tool-call parts render as invocation frames. A tool-result entry
320
+ first tries its pre-rendered
321
+ :class:`~induscode.transcript_export.contract.WidgetRender` (looked up by
322
+ ``toolCallId``); absent that, its text parts render through the SGR-aware
323
+ tool frame. System and condense entries become standalone notes.
324
+
325
+ :param entry: the transcript node to render
326
+ :param md: the configured markdown renderer
327
+ :param widgets: pre-rendered custom-tool blocks, keyed by tool-call id
328
+ """
329
+ role = entry.role
330
+ label = _ROLE_LABEL.get(role, role)
331
+
332
+ # A tool result may have a pre-rendered widget block keyed to its call.
333
+ if role == "tool" and entry.message.toolCallId is not None:
334
+ widget = widgets.get(entry.message.toolCallId)
335
+ if widget is not None:
336
+ return _RenderedTurn(kind="turn", role=role, label=label, html=widget.html)
337
+
338
+ fragments: list[str] = []
339
+ for part in _parts_of(entry.message.content):
340
+ match getattr(part, "type", None):
341
+ case "text":
342
+ if role == "tool":
343
+ fragments.append(_render_tool_text(part.text))
344
+ else:
345
+ fragments.append(_render_markdown(md, part.text))
346
+ case "image":
347
+ fragments.append(_render_image(part))
348
+ case "thinking":
349
+ # Internal reasoning is rendered as a muted note, not prose.
350
+ fragments.append(
351
+ f'<div class="aside-note">{_escape_html(part.thinking)}</div>'
352
+ )
353
+ case "toolCall":
354
+ fragments.append(_render_tool_call(part.name, part.arguments))
355
+ case _:
356
+ pass
357
+
358
+ html = "\n".join(fragments)
359
+ kind: Literal["turn", "note"] = "note" if role in ("system", "condense") else "turn"
360
+ return _RenderedTurn(kind=kind, role=role, label=label, html=html)
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # Payload + assembly
365
+ # ---------------------------------------------------------------------------
366
+
367
+
368
+ def _to_base64(text: str) -> str:
369
+ """Encode a UTF-8 string to base64."""
370
+ return base64.b64encode(text.encode("utf-8")).decode("ascii")
371
+
372
+
373
+ #: Uppercase run the camelCase→hyphen projection rewrites.
374
+ _UPPERCASE = re.compile(r"[A-Z]")
375
+
376
+
377
+ def _theme_vars_block(bridge: ThemeBridge) -> str:
378
+ """Build the CSS custom-property block for the ``THEME_VARS`` slot from a
379
+ :class:`~induscode.transcript_export.contract.ThemeBridge`, mapping each
380
+ camelCase token to a hyphenated ``--x-*`` variable the stylesheet reads.
381
+ """
382
+ css_vars = bridge.to_css_vars()
383
+
384
+ def hyphenate(name: str) -> str:
385
+ return _UPPERCASE.sub(lambda m: f"-{m.group(0).lower()}", name)
386
+
387
+ return "\n".join(
388
+ f" --x-{hyphenate(token)}: {value};" for token, value in css_vars.items()
389
+ )
390
+
391
+
392
+ def publish_transcript(
393
+ entries: Sequence[PublishEntry],
394
+ opts: PublishOptions | None = None,
395
+ ) -> str:
396
+ """Render a session transcript to a standalone HTML document.
397
+
398
+ Configures the markdown + highlight pipeline, renders every entry to a
399
+ turn, serializes the turns into a base64 payload, resolves the theme
400
+ through a :class:`~induscode.transcript_export.contract.ThemeBridge`, and
401
+ fills the page shell. The returned string is a complete
402
+ ``<!doctype html>`` document with all styling, content, and the preserved
403
+ library notices inlined — it can be written to disk and opened directly.
404
+
405
+ :param entries: the transcript nodes to publish, in display order
406
+ :param opts: optional theme, title, output directory, and widget overrides
407
+ :returns: the rendered standalone HTML document
408
+ :raises BriefingFault: of kind ``"publish"`` if assembly fails
409
+ """
410
+ options = opts if opts is not None else PublishOptions()
411
+ try:
412
+ theme = options.theme if options.theme is not None else FALLBACK_EXPORT_THEME
413
+ bridge = create_theme_bridge(theme)
414
+ md = _configured_markdown()
415
+
416
+ widget_index: dict[str, WidgetRender] = {
417
+ w.callId: w for w in (options.widgets if options.widgets is not None else ())
418
+ }
419
+
420
+ turns = [_render_entry(entry, md, widget_index) for entry in entries]
421
+
422
+ title = options.title if options.title is not None else "Session Transcript"
423
+ payload_turns: list[MutableMapping[str, str]] = []
424
+ for t in turns:
425
+ row: dict[str, str] = {}
426
+ if t.kind == "note":
427
+ row["kind"] = "note"
428
+ row["role"] = t.role
429
+ row["label"] = t.label
430
+ row["html"] = t.html
431
+ payload_turns.append(row)
432
+ payload_json = json.dumps(
433
+ {"title": title, "turns": payload_turns},
434
+ ensure_ascii=False,
435
+ separators=(",", ":"),
436
+ )
437
+ payload = _to_base64(payload_json)
438
+
439
+ slots: SlotValues = {
440
+ SHELL_SLOTS["themeVars"]: _theme_vars_block(bridge),
441
+ SHELL_SLOTS["pageSurface"]: theme.pageSurface,
442
+ SHELL_SLOTS["cardSurface"]: theme.cardSurface,
443
+ SHELL_SLOTS["noteSurface"]: theme.noteSurface,
444
+ SHELL_SLOTS["styles"]: PAGE_STYLES,
445
+ SHELL_SLOTS["script"]: CLIENT_SCRIPT,
446
+ SHELL_SLOTS["payload"]: payload,
447
+ SHELL_SLOTS["markedLib"]: MARKDOWN_LICENSE,
448
+ SHELL_SLOTS["highlightLib"]: HIGHLIGHT_LICENSE,
449
+ }
450
+
451
+ return fill(PAGE_SHELL, slots)
452
+ except Exception as cause:
453
+ raise briefing_fault(
454
+ "publish", "Failed to render the HTML transcript.", cause
455
+ ) from cause