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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- 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("<", "<")
|
|
134
|
+
.replace(">", ">")
|
|
135
|
+
.replace('"', """)
|
|
136
|
+
.replace("'", "'")
|
|
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
|