zettcode-engine 0.3.4__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.
- zettcode/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Render a stored session as a self-contained HTML trace.
|
|
2
|
+
|
|
3
|
+
The page follows the shape a reader wants when they open a session: a sidebar of
|
|
4
|
+
turns on the left, the selected turn's events on the right, each event a card
|
|
5
|
+
holding the raw text it carried, the time it was written, and what it cost. The
|
|
6
|
+
request the next turn would send is the last entry in the sidebar, so the file
|
|
7
|
+
shows both what was said and what the model is carrying now.
|
|
8
|
+
|
|
9
|
+
The document is one template: nothing is fetched, and every string reaches it
|
|
10
|
+
through :func:`html.escape`. The only script is the handful of lines that switch
|
|
11
|
+
the sidebar selection, and the page reads fine without it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import html
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass, field, replace
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
from importlib.resources import files
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from string import Template
|
|
25
|
+
|
|
26
|
+
from zett_agent.messages import AnyMessage, AssistantMessage, SystemMessage, ToolMessage, UserMessage
|
|
27
|
+
|
|
28
|
+
from zettcode.relay import relay_of
|
|
29
|
+
|
|
30
|
+
from ..dialogue.side import is_side_line
|
|
31
|
+
from ..extensions.context import ContextReport
|
|
32
|
+
from ..storage import MessageLine, Session
|
|
33
|
+
|
|
34
|
+
#: Characters a block keeps inline before it collapses. The system prompt and
|
|
35
|
+
#: long tool output are the only things that usually pass it.
|
|
36
|
+
COLLAPSE_AFTER = 220
|
|
37
|
+
|
|
38
|
+
#: Characters of a turn's opening user line the sidebar shows.
|
|
39
|
+
TITLE_CHARS = 64
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Event:
|
|
44
|
+
"""One message as the trace shows it.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
number: ``#N`` position in the whole session, in file order.
|
|
48
|
+
kind: What the message is, which decides its heading and its colour.
|
|
49
|
+
title: Heading the card prints.
|
|
50
|
+
text: Raw text the message carried; markup is escaped, never rendered.
|
|
51
|
+
reasoning: Reasoning that came with an assistant message, if any.
|
|
52
|
+
calls: Tool calls the message asked for, as ``(name, arguments)``.
|
|
53
|
+
ok: Whether a tool result succeeded; ``None`` for other messages.
|
|
54
|
+
created_at: When the message was written.
|
|
55
|
+
duration_ns: Wall time of the call that produced it, when one is known.
|
|
56
|
+
duplicate_of: Number of an earlier event with the same text, so a
|
|
57
|
+
repeated system instruction is pointed at rather than reprinted.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
number: int
|
|
61
|
+
kind: str
|
|
62
|
+
title: str
|
|
63
|
+
text: str
|
|
64
|
+
reasoning: str = ""
|
|
65
|
+
calls: tuple[tuple[str, str], ...] = ()
|
|
66
|
+
ok: bool | None = None
|
|
67
|
+
created_at: datetime | None = None
|
|
68
|
+
duration_ns: int | None = None
|
|
69
|
+
duplicate_of: int | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class Section:
|
|
74
|
+
"""One entry in the sidebar and the panel it opens."""
|
|
75
|
+
|
|
76
|
+
label: str
|
|
77
|
+
title: str
|
|
78
|
+
stats: str
|
|
79
|
+
note: str = ""
|
|
80
|
+
events: tuple[Event, ...] = field(default=())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class Trace:
|
|
85
|
+
"""The whole page: where it came from, and what it holds."""
|
|
86
|
+
|
|
87
|
+
title: str
|
|
88
|
+
subtitle: str
|
|
89
|
+
meta: tuple[tuple[str, str], ...]
|
|
90
|
+
sections: tuple[Section, ...]
|
|
91
|
+
tags: tuple[str, ...] = ()
|
|
92
|
+
exported_at: str = ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_trace(
|
|
96
|
+
session: Session,
|
|
97
|
+
*,
|
|
98
|
+
title: str = "",
|
|
99
|
+
subtitle: str = "",
|
|
100
|
+
meta: Sequence[tuple[str, str]] = (),
|
|
101
|
+
tags: Sequence[str] = (),
|
|
102
|
+
context_messages: Sequence[AnyMessage] = (),
|
|
103
|
+
tools: Sequence[str] = (),
|
|
104
|
+
report: ContextReport | None = None,
|
|
105
|
+
) -> Trace:
|
|
106
|
+
"""Group a stored branch into turns, and put the live context after them."""
|
|
107
|
+
events: list[Event] = []
|
|
108
|
+
seen: dict[str, int] = {}
|
|
109
|
+
for line in session.branch():
|
|
110
|
+
if is_side_line(line):
|
|
111
|
+
# A side question never joined the conversation, so it is not part of
|
|
112
|
+
# the record a reader is handed either.
|
|
113
|
+
continue
|
|
114
|
+
event = _event(len(events) + 1, line)
|
|
115
|
+
if event.text:
|
|
116
|
+
first = seen.setdefault(event.text, event.number)
|
|
117
|
+
if first != event.number:
|
|
118
|
+
event = replace(event, duplicate_of=first)
|
|
119
|
+
events.append(event)
|
|
120
|
+
sections = tuple(_sections(events)) + (_context_section(context_messages, tools, report),)
|
|
121
|
+
stored = session.header.session_id if session.header is not None else "session"
|
|
122
|
+
return Trace(
|
|
123
|
+
title=title or stored,
|
|
124
|
+
subtitle=subtitle,
|
|
125
|
+
meta=tuple(meta),
|
|
126
|
+
sections=tuple(section for section in sections if section.events),
|
|
127
|
+
tags=tuple(tags),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@lru_cache(maxsize=1)
|
|
132
|
+
def _assets() -> tuple[Template, str, str]:
|
|
133
|
+
"""Read the document, its stylesheet, and its script, once per process.
|
|
134
|
+
|
|
135
|
+
They live beside this module as ordinary files — a page is easier to work on
|
|
136
|
+
as markup and CSS than as a Python string — and are folded back into one
|
|
137
|
+
document here, so what is exported still fetches nothing.
|
|
138
|
+
"""
|
|
139
|
+
root = files("zettcode.engine.export").joinpath("trace")
|
|
140
|
+
template = Template(root.joinpath("template.html").read_text(encoding="utf-8"))
|
|
141
|
+
style = root.joinpath("style.css").read_text(encoding="utf-8")
|
|
142
|
+
script = root.joinpath("script.js").read_text(encoding="utf-8")
|
|
143
|
+
return template, style, script
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render_html(trace: Trace) -> str:
|
|
147
|
+
"""Return the whole page for one trace."""
|
|
148
|
+
template, style, script = _assets()
|
|
149
|
+
return template.substitute(
|
|
150
|
+
style=style,
|
|
151
|
+
script=script,
|
|
152
|
+
title=html.escape(trace.title),
|
|
153
|
+
subtitle=html.escape(trace.subtitle),
|
|
154
|
+
meta=_render_meta(trace.meta),
|
|
155
|
+
tags=_render_tags(trace.tags),
|
|
156
|
+
turns=str(len(trace.sections)),
|
|
157
|
+
summary=html.escape(_summary(trace)),
|
|
158
|
+
sidebar=_render_sidebar(trace.sections),
|
|
159
|
+
panels=_render_panels(trace.sections, _render_tags(trace.tags)),
|
|
160
|
+
exported_at=html.escape(trace.exported_at),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def write_export(path: str | Path, content: str) -> Path:
|
|
165
|
+
"""Write the page and return the absolute path that was written."""
|
|
166
|
+
target = Path(path).expanduser()
|
|
167
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
target.write_text(content, encoding="utf-8")
|
|
169
|
+
return target.resolve()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _user_label(message: UserMessage) -> str:
|
|
173
|
+
"""Return what a user message is called: a message another session sent says whose it is."""
|
|
174
|
+
relayed = relay_of(message.attributes, message.text)
|
|
175
|
+
return "User message" if relayed is None else f"Message from session {relayed.sender}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _event(number: int, line: MessageLine) -> Event:
|
|
179
|
+
"""Build the event one stored message becomes."""
|
|
180
|
+
message = line.message[0]
|
|
181
|
+
timing = line.timing
|
|
182
|
+
match message:
|
|
183
|
+
case SystemMessage():
|
|
184
|
+
return Event(number, "system", "System instruction", message.content, created_at=line.created_at)
|
|
185
|
+
case UserMessage():
|
|
186
|
+
return Event(number, "user", _user_label(message), message.text, created_at=line.created_at)
|
|
187
|
+
case AssistantMessage():
|
|
188
|
+
calls = tuple(
|
|
189
|
+
(call.name, json.dumps(call.arguments, ensure_ascii=False, sort_keys=True))
|
|
190
|
+
for call in message.tool_calls
|
|
191
|
+
)
|
|
192
|
+
return Event(
|
|
193
|
+
number,
|
|
194
|
+
"assistant",
|
|
195
|
+
"Model response",
|
|
196
|
+
message.content,
|
|
197
|
+
reasoning=message.reasoning or "",
|
|
198
|
+
calls=calls,
|
|
199
|
+
created_at=line.created_at,
|
|
200
|
+
duration_ns=timing.duration_ns,
|
|
201
|
+
)
|
|
202
|
+
case ToolMessage():
|
|
203
|
+
return Event(
|
|
204
|
+
number,
|
|
205
|
+
"tool",
|
|
206
|
+
"Tool result",
|
|
207
|
+
message.text,
|
|
208
|
+
ok=message.success,
|
|
209
|
+
created_at=line.created_at,
|
|
210
|
+
duration_ns=timing.duration_ns,
|
|
211
|
+
)
|
|
212
|
+
case _:
|
|
213
|
+
return Event(number, "other", type(message).__name__, repr(message), created_at=line.created_at)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _sections(events: Sequence[Event]) -> list[Section]:
|
|
217
|
+
"""Split the session's events into turns, one per user message."""
|
|
218
|
+
turns: list[list[Event]] = []
|
|
219
|
+
for event in events:
|
|
220
|
+
if event.kind == "user" or not turns:
|
|
221
|
+
turns.append([])
|
|
222
|
+
turns[-1].append(event)
|
|
223
|
+
sections: list[Section] = []
|
|
224
|
+
for index, turn in enumerate(turns, start=1):
|
|
225
|
+
duration = sum(event.duration_ns or 0 for event in turn) / 1_000_000_000
|
|
226
|
+
opening = next((event for event in turn if event.kind == "user"), turn[0])
|
|
227
|
+
sections.append(
|
|
228
|
+
Section(
|
|
229
|
+
label=f"Turn {index}",
|
|
230
|
+
title=_title(opening.text),
|
|
231
|
+
stats=f"{len(turn)} events · {duration:.1f} s",
|
|
232
|
+
events=tuple(turn),
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
return sections
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _context_section(
|
|
239
|
+
messages: Sequence[AnyMessage],
|
|
240
|
+
tools: Sequence[str],
|
|
241
|
+
report: ContextReport | None,
|
|
242
|
+
) -> Section:
|
|
243
|
+
"""Build the sidebar entry for the request the next turn would send."""
|
|
244
|
+
events: list[Event] = []
|
|
245
|
+
seen: dict[str, int] = {}
|
|
246
|
+
number = 0
|
|
247
|
+
for message in messages:
|
|
248
|
+
number += 1
|
|
249
|
+
event = _context_event(number, message)
|
|
250
|
+
first = seen.setdefault(event.text, event.number) if event.text else event.number
|
|
251
|
+
events.append(replace(event, duplicate_of=first) if first != event.number else event)
|
|
252
|
+
if tools:
|
|
253
|
+
number += 1
|
|
254
|
+
events.append(
|
|
255
|
+
Event(number, "tools", "Tools offered", "\n".join(tools)),
|
|
256
|
+
)
|
|
257
|
+
stats = f"{len(events)} messages"
|
|
258
|
+
note = f"{report.used:,} tokens" if report is not None else ""
|
|
259
|
+
if report is not None:
|
|
260
|
+
stats += f" · {report.used:,} tokens"
|
|
261
|
+
return Section(label="Context", title="Current request", stats=stats, note=note, events=tuple(events))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _context_event(number: int, message: AnyMessage) -> Event:
|
|
265
|
+
"""Build the event one model-facing message becomes."""
|
|
266
|
+
match message:
|
|
267
|
+
case SystemMessage():
|
|
268
|
+
return Event(number, "system", "System instruction", message.content)
|
|
269
|
+
case AssistantMessage():
|
|
270
|
+
calls = tuple(
|
|
271
|
+
(call.name, json.dumps(call.arguments, ensure_ascii=False, sort_keys=True))
|
|
272
|
+
for call in message.tool_calls
|
|
273
|
+
)
|
|
274
|
+
return Event(
|
|
275
|
+
number, "assistant", "Model response", message.content, reasoning=message.reasoning or "", calls=calls
|
|
276
|
+
)
|
|
277
|
+
case ToolMessage():
|
|
278
|
+
return Event(number, "tool", "Tool result", message.text, ok=message.success)
|
|
279
|
+
case UserMessage():
|
|
280
|
+
return Event(number, "user", _user_label(message), message.text)
|
|
281
|
+
case _:
|
|
282
|
+
return Event(number, "other", type(message).__name__, repr(message))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _title(text: str) -> str:
|
|
286
|
+
"""Return the one line a sidebar entry shows for a turn."""
|
|
287
|
+
line = next((part.strip() for part in text.splitlines() if part.strip()), "")
|
|
288
|
+
return line[:TITLE_CHARS] + ("…" if len(line) > TITLE_CHARS else "")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _summary(trace: Trace) -> str:
|
|
292
|
+
"""Return the counts the top bar prints."""
|
|
293
|
+
events = sum(len(section.events) for section in trace.sections)
|
|
294
|
+
return f"{len(trace.sections)} sections · {events} events"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _render_meta(meta: Sequence[tuple[str, str]]) -> str:
|
|
298
|
+
"""Return the sidebar's footer: one small label over each value."""
|
|
299
|
+
return "".join(f"<div><dt>{html.escape(label)}</dt><dd>{html.escape(value)}</dd></div>" for label, value in meta)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _render_sidebar(sections: Sequence[Section]) -> str:
|
|
303
|
+
"""Return the turn buttons, the first one selected."""
|
|
304
|
+
buttons = []
|
|
305
|
+
for index, section in enumerate(sections):
|
|
306
|
+
note = f'<span class="note">{html.escape(section.note)}</span>' if section.note else ""
|
|
307
|
+
current = " is-current" if index == 0 else ""
|
|
308
|
+
buttons.append(
|
|
309
|
+
f'<button class="turn{current}" data-panel="panel-{index}" type="button">'
|
|
310
|
+
f'<span class="turn-top"><span class="label">{html.escape(section.label)}</span>{note}</span>'
|
|
311
|
+
f'<span class="turn-title">{html.escape(section.title)}</span>'
|
|
312
|
+
f'<span class="turn-stats">{html.escape(section.stats)}</span>'
|
|
313
|
+
"</button>"
|
|
314
|
+
)
|
|
315
|
+
return "".join(buttons)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _render_panels(sections: Sequence[Section], tags: str) -> str:
|
|
319
|
+
"""Return one panel per section, the first one selected."""
|
|
320
|
+
panels = []
|
|
321
|
+
for index, section in enumerate(sections):
|
|
322
|
+
current = " is-current" if index == 0 else ""
|
|
323
|
+
panels.append(
|
|
324
|
+
f'<section class="panel{current}" id="panel-{index}">'
|
|
325
|
+
f'<header class="panel-head"><h2>{html.escape(section.label)}</h2>{tags}'
|
|
326
|
+
f'<span class="panel-stats">{html.escape(section.stats)}</span></header>'
|
|
327
|
+
f'<ol class="timeline">{"".join(_render_event(event) for event in section.events)}</ol>'
|
|
328
|
+
"</section>"
|
|
329
|
+
)
|
|
330
|
+
return "".join(panels)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _render_tags(tags: Sequence[str]) -> str:
|
|
334
|
+
"""Return the model and effort chips every panel head carries."""
|
|
335
|
+
return "".join(f'<span class="chip">{html.escape(tag)}</span>' for tag in tags)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _render_event(event: Event) -> str:
|
|
339
|
+
"""Return one event card: heading, metadata, then the text it carried."""
|
|
340
|
+
meta = [f"#{event.number}"]
|
|
341
|
+
if event.created_at is not None:
|
|
342
|
+
meta.append(event.created_at.astimezone().strftime("%H:%M:%S"))
|
|
343
|
+
if event.duration_ns:
|
|
344
|
+
meta.append(f"{event.duration_ns / 1_000_000_000:.2f} s")
|
|
345
|
+
if event.ok is not None:
|
|
346
|
+
meta.append("ok" if event.ok else "failed")
|
|
347
|
+
# A duplicate points at the text instead of repeating it, so it has none to copy.
|
|
348
|
+
copy = (
|
|
349
|
+
""
|
|
350
|
+
if event.duplicate_of is not None
|
|
351
|
+
else f'<button class="copy" type="button" data-copy="{html.escape(event.text, quote=True)}">Copy</button>'
|
|
352
|
+
)
|
|
353
|
+
return (
|
|
354
|
+
'<li class="event">'
|
|
355
|
+
'<span class="dot"></span>'
|
|
356
|
+
'<div class="event-card">'
|
|
357
|
+
f'<div class="event-head"><h3 class="kind-{html.escape(event.kind)}">{html.escape(event.title)}</h3>{copy}</div>'
|
|
358
|
+
f'<p class="event-meta">{html.escape(" · ".join(meta))}</p>'
|
|
359
|
+
f"{_render_text(event)}"
|
|
360
|
+
f"{_render_reasoning(event)}"
|
|
361
|
+
f"{_render_calls(event)}"
|
|
362
|
+
"</div></li>"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _render_calls(event: Event) -> str:
|
|
367
|
+
"""Return the tool calls an assistant message asked for."""
|
|
368
|
+
if not event.calls:
|
|
369
|
+
return ""
|
|
370
|
+
rows = "".join(
|
|
371
|
+
f'<div class="call"><span class="arrow">→</span>{html.escape(name)}({html.escape(arguments)})</div>'
|
|
372
|
+
for name, arguments in event.calls
|
|
373
|
+
)
|
|
374
|
+
return f'<div class="calls">{rows}</div>'
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _render_text(event: Event) -> str:
|
|
378
|
+
"""Return the event's text: inline when short, a collapsible block when long."""
|
|
379
|
+
if not event.text:
|
|
380
|
+
return ""
|
|
381
|
+
if event.duplicate_of is not None:
|
|
382
|
+
return f'<p class="duplicate">Same text as #{event.duplicate_of}.</p>'
|
|
383
|
+
if _is_block(event):
|
|
384
|
+
note = _block_note(event)
|
|
385
|
+
return (
|
|
386
|
+
f'<details class="block"><summary><span class="block-title">{html.escape(event.title)}</span>'
|
|
387
|
+
f'<span class="block-note">{html.escape(note)}</span></summary>'
|
|
388
|
+
f"<pre>{html.escape(event.text)}</pre></details>"
|
|
389
|
+
)
|
|
390
|
+
return f'<div class="body">{_render_inline(event.text)}</div>'
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _render_reasoning(event: Event) -> str:
|
|
394
|
+
"""Return the reasoning an assistant message carried, collapsed."""
|
|
395
|
+
if not event.reasoning:
|
|
396
|
+
return ""
|
|
397
|
+
note = f"{len(event.reasoning)} chars"
|
|
398
|
+
return (
|
|
399
|
+
f'<details class="block reasoning"><summary><span class="block-title">Reasoning</span>'
|
|
400
|
+
f'<span class="block-note">{note}</span></summary><pre>{html.escape(event.reasoning)}</pre></details>'
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _is_block(event: Event) -> bool:
|
|
405
|
+
"""Return whether the event's text starts collapsed.
|
|
406
|
+
|
|
407
|
+
The system prompt is furniture a reader skims past, and a wall of tool
|
|
408
|
+
output hides the rest of the turn; an answer with line breaks in it is what
|
|
409
|
+
the reader came for and stays open.
|
|
410
|
+
"""
|
|
411
|
+
return event.kind == "system" or len(event.text) > COLLAPSE_AFTER
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _block_note(event: Event) -> str:
|
|
415
|
+
"""Return what a collapsed block shows beside its title."""
|
|
416
|
+
return f"{len(event.text)} chars"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _render_inline(text: str) -> str:
|
|
420
|
+
"""Return short text with its line breaks kept and its markup escaped."""
|
|
421
|
+
return html.escape(text).replace("\n", "<br>")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
document.documentElement.classList.add("js");
|
|
2
|
+
var buttons = Array.prototype.slice.call(document.querySelectorAll(".turn"));
|
|
3
|
+
var panels = Array.prototype.slice.call(document.querySelectorAll(".panel"));
|
|
4
|
+
function show(id) {
|
|
5
|
+
buttons.forEach(function (button) { button.classList.toggle("is-current", button.dataset.panel === id); });
|
|
6
|
+
panels.forEach(function (panel) { panel.classList.toggle("is-current", panel.id === id); });
|
|
7
|
+
}
|
|
8
|
+
buttons.forEach(function (button) {
|
|
9
|
+
button.addEventListener("click", function () { show(button.dataset.panel); });
|
|
10
|
+
});
|
|
11
|
+
Array.prototype.slice.call(document.querySelectorAll(".copy")).forEach(function (button) {
|
|
12
|
+
button.addEventListener("click", function () {
|
|
13
|
+
navigator.clipboard.writeText(button.dataset.copy).then(function () {
|
|
14
|
+
button.textContent = "Copied";
|
|
15
|
+
setTimeout(function () { button.textContent = "Copy"; }, 1200);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
--page: #f6f6f7; --card: #ffffff; --text: #1d1d1f; --muted: #86868b;
|
|
4
|
+
--line: #e6e6e8; --soft: #f1f1f3; --accent: #0071e3; --radius: 16px;
|
|
5
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
6
|
+
}
|
|
7
|
+
@media (prefers-color-scheme: dark) {
|
|
8
|
+
:root { --page: #0d0d0f; --card: #17171a; --text: #f5f5f7; --muted: #98989d; --line: #2a2a2e; --soft: #202024; }
|
|
9
|
+
}
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
html, body { height: 100%; }
|
|
12
|
+
body {
|
|
13
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
14
|
+
margin: 0; background: var(--page); color: var(--text);
|
|
15
|
+
font: 400 14px/1.55 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
16
|
+
-webkit-font-smoothing: antialiased;
|
|
17
|
+
}
|
|
18
|
+
.topbar {
|
|
19
|
+
display: flex; align-items: center; gap: 12px; flex: none; height: 52px; padding: 0 22px;
|
|
20
|
+
background: var(--card); border-bottom: 1px solid var(--line);
|
|
21
|
+
}
|
|
22
|
+
.brand { font-weight: 600; }
|
|
23
|
+
.crumb, .count { color: var(--muted); font-size: 12.5px; }
|
|
24
|
+
.count { font-variant-numeric: tabular-nums; }
|
|
25
|
+
.count:first-of-type { margin-left: auto; }
|
|
26
|
+
.when { padding-left: 12px; border-left: 1px solid var(--line); }
|
|
27
|
+
.app {
|
|
28
|
+
display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 22px; flex: 1; min-height: 0;
|
|
29
|
+
width: 100%; max-width: 1440px; margin: 0 auto; padding: 22px 22px 22px;
|
|
30
|
+
}
|
|
31
|
+
.sidebar { display: flex; flex-direction: column; min-height: 0; }
|
|
32
|
+
.turns { flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; scrollbar-width: thin; }
|
|
33
|
+
.turns::-webkit-scrollbar { width: 10px; }
|
|
34
|
+
.turns::-webkit-scrollbar-track { background: transparent; }
|
|
35
|
+
.turns::-webkit-scrollbar-thumb {
|
|
36
|
+
background: var(--line); border: 3px solid transparent; border-radius: 999px; background-clip: content-box;
|
|
37
|
+
}
|
|
38
|
+
.sidebar-head { flex: none; padding: 2px 6px 14px; }
|
|
39
|
+
.kicker { color: var(--muted); font-size: 11px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; }
|
|
40
|
+
.sidebar-head h1 { margin: 6px 0 4px; font-size: 15px; font-weight: 600; }
|
|
41
|
+
.sidebar-head p { margin: 0; color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; }
|
|
42
|
+
.turn {
|
|
43
|
+
display: flex; flex-direction: column; gap: 4px; width: 100%; margin: 0 0 6px; padding: 11px 13px;
|
|
44
|
+
text-align: left; font: inherit; color: var(--text); background: transparent;
|
|
45
|
+
border: 1px solid transparent; border-radius: 12px; cursor: pointer;
|
|
46
|
+
}
|
|
47
|
+
.turn:hover { background: var(--soft); }
|
|
48
|
+
.turn.is-current { background: var(--card); border-color: var(--line); box-shadow: 0 1px 2px rgba(0,0,0,.05); }
|
|
49
|
+
.turn-top { display: flex; align-items: center; gap: 8px; }
|
|
50
|
+
.label { color: var(--muted); font-size: 11px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; }
|
|
51
|
+
.note { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
|
52
|
+
.turn-title { font-weight: 600; overflow-wrap: anywhere; }
|
|
53
|
+
.turn-stats { color: var(--muted); font-size: 12px; }
|
|
54
|
+
.sidebar-foot { flex: none; margin: 14px 6px 0; padding-top: 14px; border-top: 1px solid var(--line); }
|
|
55
|
+
.sidebar-foot dt { color: var(--muted); font-size: 10.5px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; }
|
|
56
|
+
.sidebar-foot dd { margin: 3px 0 10px; font-size: 12.5px; overflow-wrap: anywhere; }
|
|
57
|
+
.trace { display: flex; flex-direction: column; min-height: 0; }
|
|
58
|
+
.panel {
|
|
59
|
+
display: flex; flex-direction: column; min-height: 0; flex: 1;
|
|
60
|
+
padding: 20px 24px 24px; background: var(--card);
|
|
61
|
+
border: 1px solid var(--line); border-radius: var(--radius);
|
|
62
|
+
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
|
63
|
+
}
|
|
64
|
+
.js .panel { display: none; }
|
|
65
|
+
.js .panel.is-current { display: flex; }
|
|
66
|
+
.panel-head { flex: none; display: flex; align-items: center; gap: 10px; margin: 0 0 18px; }
|
|
67
|
+
.panel-head h2 { margin: 0; font-size: 15px; font-weight: 600; }
|
|
68
|
+
.chip { padding: 2px 9px; border-radius: 999px; background: var(--soft); color: var(--muted); font-size: 11px; }
|
|
69
|
+
.panel-stats { margin-left: auto; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
70
|
+
.timeline {
|
|
71
|
+
flex: 1; min-height: 0; overflow-y: auto; margin: 0; padding: 0 6px 0 0; list-style: none;
|
|
72
|
+
scrollbar-width: thin;
|
|
73
|
+
}
|
|
74
|
+
.timeline::-webkit-scrollbar { width: 10px; }
|
|
75
|
+
.timeline::-webkit-scrollbar-track { background: transparent; }
|
|
76
|
+
.timeline::-webkit-scrollbar-thumb {
|
|
77
|
+
background: var(--line); border: 3px solid transparent; border-radius: 999px; background-clip: content-box;
|
|
78
|
+
}
|
|
79
|
+
.event { position: relative; padding: 0 0 20px 22px; border-left: 1px solid var(--line); }
|
|
80
|
+
.event:last-child { border-left-color: transparent; padding-bottom: 0; }
|
|
81
|
+
.dot {
|
|
82
|
+
position: absolute; left: -4.5px; top: 6px; width: 8px; height: 8px;
|
|
83
|
+
background: var(--card); border: 1.5px solid var(--muted); border-radius: 50%;
|
|
84
|
+
}
|
|
85
|
+
.event-head { display: flex; align-items: center; gap: 10px; }
|
|
86
|
+
.event-head h3 { margin: 0; font-size: 13.5px; font-weight: 600; }
|
|
87
|
+
.kind-system { color: var(--muted); }
|
|
88
|
+
.kind-tool { color: var(--accent); }
|
|
89
|
+
.copy {
|
|
90
|
+
margin-left: auto; padding: 2px 9px; font: inherit; font-size: 11.5px; color: var(--muted);
|
|
91
|
+
background: var(--card); border: 1px solid var(--line); border-radius: 999px; cursor: pointer;
|
|
92
|
+
}
|
|
93
|
+
.copy:hover { color: var(--text); }
|
|
94
|
+
.event-meta { margin: 3px 0 8px; color: var(--muted); font-size: 11.5px; font-family: var(--mono); }
|
|
95
|
+
.body {
|
|
96
|
+
padding: 11px 13px; background: var(--soft); border-radius: 10px;
|
|
97
|
+
white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12.5px;
|
|
98
|
+
}
|
|
99
|
+
.duplicate { margin: 0; color: var(--muted); font-size: 12.5px; }
|
|
100
|
+
.calls { margin: 0 0 8px; font-family: var(--mono); font-size: 12.5px; }
|
|
101
|
+
.call { padding: 2px 0; overflow-wrap: anywhere; }
|
|
102
|
+
.arrow { color: var(--muted); margin-right: 6px; }
|
|
103
|
+
.block { margin: 0 0 8px; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
104
|
+
.block summary {
|
|
105
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer;
|
|
106
|
+
list-style: none; background: var(--soft);
|
|
107
|
+
}
|
|
108
|
+
.block summary::-webkit-details-marker { display: none; }
|
|
109
|
+
.block-title { font-size: 12.5px; font-weight: 500; }
|
|
110
|
+
.block-note { margin-left: auto; color: var(--muted); font-size: 11.5px; font-family: var(--mono); }
|
|
111
|
+
.block pre {
|
|
112
|
+
margin: 0; padding: 12px; overflow: auto; overscroll-behavior: contain;
|
|
113
|
+
max-height: 22rem; scrollbar-width: thin; scrollbar-color: var(--line) transparent;
|
|
114
|
+
font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; overflow-wrap: anywhere;
|
|
115
|
+
}
|
|
116
|
+
.block pre::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
117
|
+
.block pre::-webkit-scrollbar-track { background: transparent; }
|
|
118
|
+
.block pre::-webkit-scrollbar-thumb {
|
|
119
|
+
background: var(--line); border: 3px solid transparent; border-radius: 999px; background-clip: content-box;
|
|
120
|
+
}
|
|
121
|
+
.reasoning summary { background: transparent; }
|
|
122
|
+
.empty { padding: 40px 0; color: var(--muted); text-align: center; }
|
|
123
|
+
@media (max-width: 900px) {
|
|
124
|
+
/* Stacked: the page scrolls again, and the lists stop clipping themselves. */
|
|
125
|
+
html, body { height: auto; }
|
|
126
|
+
body { overflow: auto; }
|
|
127
|
+
.app { grid-template-columns: minmax(0, 1fr); padding-bottom: 40px; }
|
|
128
|
+
.turns, .timeline { overflow: visible; }
|
|
129
|
+
}
|
|
130
|
+
@media print {
|
|
131
|
+
html, body { height: auto; }
|
|
132
|
+
body { overflow: visible; }
|
|
133
|
+
.topbar, .sidebar, .copy { display: none; }
|
|
134
|
+
.js .panel { display: block !important; }
|
|
135
|
+
.timeline, .turns { overflow: visible; }
|
|
136
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="generator" content="ZettCode">
|
|
7
|
+
<title>$title · ZettCode trace</title>
|
|
8
|
+
<style>$style</style>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header class="topbar">
|
|
12
|
+
<span class="brand">✦ ZettCode</span>
|
|
13
|
+
<span class="crumb">$title</span>
|
|
14
|
+
<span class="count">$summary</span>
|
|
15
|
+
<span class="count when">$exported_at</span>
|
|
16
|
+
</header>
|
|
17
|
+
<div class="app">
|
|
18
|
+
<aside class="sidebar">
|
|
19
|
+
<div class="sidebar-head">
|
|
20
|
+
<span class="kicker">Raw message storage</span>
|
|
21
|
+
<h1>LLM interaction trace</h1>
|
|
22
|
+
<p>$subtitle</p>
|
|
23
|
+
</div>
|
|
24
|
+
<nav class="turns">$sidebar</nav>
|
|
25
|
+
<dl class="sidebar-foot">$meta</dl>
|
|
26
|
+
</aside>
|
|
27
|
+
<main class="trace">$panels</main>
|
|
28
|
+
</div>
|
|
29
|
+
<script>$script</script>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""What the runtime plugs into zett-agent that is ZettCode's own.
|
|
2
|
+
|
|
3
|
+
Each module here is one extension, profile, or accounting piece the runtime
|
|
4
|
+
(:mod:`zettcode.engine.core`) composes into a request: the tools a model can
|
|
5
|
+
use, an on-demand compaction, the request the next turn carries, MCP, subagents, and
|
|
6
|
+
token usage. They are separate modules so the runtime imports only the ones a
|
|
7
|
+
configuration turns on.
|
|
8
|
+
"""
|