zettcode-commands 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """The slash commands that act on the application, offered to the terminal as one plugin: see :mod:`.plugin`."""
@@ -0,0 +1,95 @@
1
+ """``/context``: what the next request carries, by source and token share."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from zettcode.app.plugins.groups import require_tui
9
+ from zettcode.app.ui.config_form import whole_if_whole
10
+ from zettcode.plugin_api.commands import CommandContext, CommandResult
11
+ from zettcode.plugin_api.plugin import TuiPlugin
12
+ from zettcode.sdk.translation import context_report_from
13
+
14
+ from .context_page import ContextPage
15
+ from .handlers import COMMAND_ERRORS
16
+
17
+ if TYPE_CHECKING: # pragma: no cover - annotations only
18
+ from zettcode.plugin_api import PluginContext
19
+
20
+
21
+ class ContextPagePlugin(TuiPlugin):
22
+ """The `/context` page, beside the data it draws."""
23
+
24
+ name = "context"
25
+ description = "What is filling the context window"
26
+
27
+ def activate(self, ctx: PluginContext) -> None:
28
+ tui = require_tui(ctx)
29
+
30
+ async def show(context: CommandContext) -> CommandResult:
31
+ """Open the context page, or say why there is nothing to measure yet."""
32
+ host = context.require_host()
33
+ if host.client.active_model is None:
34
+ return CommandResult(
35
+ message="No model is configured yet \u2014 `/config` adds one; `/context` has something to "
36
+ "measure after the first request."
37
+ )
38
+ service = ctx.agent.context_report_service()
39
+ if service is None:
40
+ report = await host.client.session.context()
41
+ else:
42
+ report = context_report_from(await service.report(host.agent.session_id))
43
+ if report is None:
44
+ return CommandResult(
45
+ message="No request yet \u2014 send one (or resume a session), then `/context` shows the breakdown."
46
+ )
47
+
48
+ page_service = ctx.agent.context_page_service()
49
+ if page_service is None:
50
+ model = host.client.model
51
+ model_name, path = model.name, host.client.session.path
52
+ trigger, compact = model.compaction_max_tokens, model.compact_percent
53
+ window = model.context_window
54
+ else:
55
+ snapshot = await page_service.snapshot(host.agent.session_id)
56
+ model_name, path = snapshot.model_name, Path(snapshot.session_path)
57
+ trigger, compact, window = (
58
+ snapshot.compaction_max_tokens,
59
+ snapshot.compact_percent,
60
+ snapshot.context_window,
61
+ )
62
+
63
+ def apply(percent: float) -> None:
64
+ """Write the chosen compaction point and report what happened."""
65
+ host.close_page()
66
+ try:
67
+ if page_service is None:
68
+ model, unwritten = host.client.use_compact_percent(percent)
69
+ name = model.name
70
+ else:
71
+ name, unwritten = page_service.set_compact_percent(percent)
72
+ except COMMAND_ERRORS as error:
73
+ host.transcript.error(str(error))
74
+ return
75
+ said = f"{name}: compacts at {whole_if_whole(percent)}% of the window"
76
+ said += f" ({round(window * percent / 100):,} tokens)"
77
+ if unwritten is None:
78
+ host.transcript.notice(said)
79
+ else:
80
+ host.transcript.notice(f"{said} for this run; not remembered: {unwritten}")
81
+ host.request_layout()
82
+
83
+ return CommandResult(
84
+ widget=ContextPage(
85
+ report,
86
+ path=path,
87
+ model=model_name,
88
+ trigger=trigger,
89
+ compact_percent=compact,
90
+ on_apply=apply,
91
+ on_cancel=host.close_page,
92
+ )
93
+ )
94
+
95
+ tui.register_command("context", "what is filling the context window", show, when_busy="now")
@@ -0,0 +1,283 @@
1
+ """The drawings behind ``/context``: how full the window is, and where the tokens in it went.
2
+
3
+ Like :mod:`~zettcode.presenters.usage_charts`, everything here turns a report into *lines of
4
+ roles* and never into colours, so the page decides what a role looks like in the active theme and a
5
+ test can read the result as plain strings.
6
+
7
+ The page answers four questions, each with a different ratio, and draws each in its own way so that
8
+ none is mistaken for another:
9
+
10
+ * **How full is the window?** ``used / window``: one gauge across the page, coloured by how close the
11
+ request is to being compacted, with a mark where that happens.
12
+ * **How much room is left?** Three numbers, each with its share of the window: what is used, what
13
+ remains before compaction, and what remains before the window is full.
14
+ * **Where did the tokens go?** ``source / counted``: a single bar divided between the sources, and a
15
+ row for each with the same bar on its own scale — the first shows the whole at a glance, the
16
+ second lets a small source be read.
17
+ * **Whose number is it?** The provider reports the total and only a local tokenizer can divide it,
18
+ and the two disagree about what a token is. So the tokenizer supplies *proportions* only: each
19
+ source is shown as its share of the provider's total, and the rows add up to it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import textwrap
25
+ from collections.abc import Sequence
26
+
27
+ from zettcode.budget import CHARS_PER_TOKEN, SOURCES
28
+ from zettcode.presenters.usage_charts import Line, fit, meter
29
+ from zettcode.sdk.types import ContextReport
30
+ from zettcode.tui import BAR_FULL
31
+ from zettcode.tui.render.text import display_width
32
+
33
+ #: The widest the gauge and the stacked bar are drawn: past this a bar stops being read as a
34
+ #: shape, and the page would be a long strip on a wide terminal.
35
+ BAR_MAX = 72
36
+
37
+ #: The mark under the gauge where the request is summarized, and the swatch before a source.
38
+ MARK = "\u25b2"
39
+ SWATCH = "\u25a0"
40
+
41
+ #: Columns of a source's name, a token count, a share, and an item count in the rows.
42
+ NAME = 18
43
+ TOKENS = 9
44
+ SHARE = 6
45
+ ITEMS = 5
46
+
47
+ #: Where the gauge turns from calm to warm: this fraction of the way to the compaction point.
48
+ WARM = 0.75
49
+
50
+ #: A source's role, one colour for each, in the order the request is assembled.
51
+ SOURCE_ROLES = {name: f"source{index}" for index, name in enumerate(SOURCES)}
52
+
53
+ #: The sources only an assembled request carries (see ``ContextReport.partial``).
54
+ FROM_THE_REQUEST = ("Environment notes", "Tool schemas")
55
+
56
+
57
+ def percent(tokens: int, whole: int) -> str:
58
+ """Return ``tokens`` as a share of ``whole`` with one decimal, or a dash for an empty whole."""
59
+ return "\u2014" if whole <= 0 else f"{tokens / whole * 100:.1f}%"
60
+
61
+
62
+ def gauge_role(used: int, limit: int) -> str:
63
+ """Return how alarming ``used`` is against ``limit``: calm, warm from three quarters, hot at it."""
64
+ if used >= limit:
65
+ return "gauge_hot"
66
+ return "gauge_warm" if used >= limit * WARM else "gauge_calm"
67
+
68
+
69
+ def allocate(values: Sequence[int], cells: int) -> list[int]:
70
+ """Divide ``cells`` between ``values`` in proportion, so the parts add up to exactly ``cells``.
71
+
72
+ Rounding each part alone leaves the bar a cell short or long. This gives every part its whole
73
+ cells, hands the remainder to the parts that lost most to rounding, and lets a part that is
74
+ above zero keep one cell however small it is: a source that is there should be seen. Those
75
+ cells are paid for by the largest parts.
76
+ """
77
+ total = sum(values)
78
+ if cells <= 0 or total <= 0:
79
+ return [0] * len(values)
80
+ exact = [value * cells / total for value in values]
81
+ given = [max(1, int(share)) if value > 0 else 0 for share, value in zip(exact, values, strict=True)]
82
+ while sum(given) < cells:
83
+ given[max(range(len(values)), key=lambda index: exact[index] - given[index])] += 1
84
+ while sum(given) > cells:
85
+ payers = [index for index, count in enumerate(given) if count > (1 if values[index] > 0 else 0)]
86
+ if not payers:
87
+ break
88
+ given[max(payers, key=lambda index: given[index] - exact[index])] -= 1
89
+ return given
90
+
91
+
92
+ def _fitted(width: int, candidates: Sequence[Line]) -> Line:
93
+ """Return the first of ``candidates`` that fits ``width`` columns, else the last, which is the shortest."""
94
+ for line in candidates:
95
+ if display_width("".join(text for text, _ in line)) <= width:
96
+ return line
97
+ return candidates[-1]
98
+
99
+
100
+ def window_lines(report: ContextReport, trigger: int | None, width: int) -> list[Line]:
101
+ """Return the gauge, the mark where compaction starts, and the three numbers under it.
102
+
103
+ Args:
104
+ report: What the request carries.
105
+ trigger: Tokens at which a request is compacted, or ``None`` when it never is; the gauge
106
+ is coloured by how near that is, and the window itself when there is no such point.
107
+ width: Columns the page has.
108
+ """
109
+ cells = max(1, min(width, BAR_MAX))
110
+ used = report.used
111
+ limit = min(trigger, report.window) if trigger else report.window
112
+ role = gauge_role(used, limit)
113
+ lines: list[Line] = [
114
+ _fitted(
115
+ width,
116
+ [
117
+ [
118
+ ("Window", "heading"),
119
+ (f" {used:,} / {report.window:,} tokens", "text"),
120
+ (f" {percent(used, report.window)} full", role),
121
+ ],
122
+ [
123
+ ("Window", "heading"),
124
+ (f" {used:,} / {report.window:,}", "text"),
125
+ (f" {percent(used, report.window)}", role),
126
+ ],
127
+ [(f"{used:,} / {report.window:,}", "text"), (f" {percent(used, report.window)}", role)],
128
+ ],
129
+ ),
130
+ meter(used, report.window, cells, role),
131
+ ]
132
+ if trigger:
133
+ position = min(cells - 1, max(0, round(limit / report.window * cells) - 1))
134
+ share = percent(limit, report.window)
135
+ for label in (f"compacts at {limit:,} tokens ({share})", f"compacts at {limit:,}", share):
136
+ reach = display_width(label)
137
+ if position + 2 + reach <= cells:
138
+ lines.append([(" " * position + MARK + " ", "muted"), (label, "muted")])
139
+ break
140
+ if reach + 2 <= position + 1:
141
+ lines.append([(" " * (position + 1 - reach - 2), "muted"), (label + " ", "muted"), (MARK, "muted")])
142
+ break
143
+ else:
144
+ lines.append([(" " * position + MARK, "muted")])
145
+ lines.append([])
146
+ numbers = [("Used", used, role)]
147
+ if trigger:
148
+ numbers.append(("Until compaction", max(0, limit - used), "text"))
149
+ numbers.append(("Free in window", max(0, report.window - used), "text"))
150
+ digits = len(f"{report.window:,}")
151
+ names = max(4, min(17, width - digits - 2 - SHARE - 1))
152
+ for label, tokens, colour in numbers:
153
+ lines.append(
154
+ [
155
+ (fit(label, names) + " ", "muted"),
156
+ (f"{tokens:>{digits},}", "text"),
157
+ (f" {percent(tokens, report.window):>{SHARE}}", colour if label == "Used" else "muted"),
158
+ ]
159
+ )
160
+ return lines
161
+
162
+
163
+ def divides_the_total(report: ContextReport) -> bool:
164
+ """Return whether the rows are shares of the provider's total, and so add up to it.
165
+
166
+ That needs every source to be there. The provider's number is for the whole request, tool schemas
167
+ and environment notes included; while those are unknown (``partial``) dividing it between the
168
+ sources that are known would hand their tokens to rows that did not send them.
169
+ """
170
+ return report.from_provider and not report.partial and report.measured > 0
171
+
172
+
173
+ def tokens_by_source(report: ContextReport) -> dict[str, int]:
174
+ """Return the tokens each source takes, in the units of the total the page reports.
175
+
176
+ The local count is only good for proportions: it uses one tokenizer, and the model bills by
177
+ its own. So when the provider reported the request's size, each source gets its share of
178
+ *that* number, divided so the rows add up to it exactly; without one, or while a source is
179
+ still unknown, the local counts are all there is and are shown as counted.
180
+ """
181
+ shares = report.shares
182
+ if not divides_the_total(report):
183
+ return {share.name: share.tokens for share in shares}
184
+ parts = allocate([share.tokens for share in shares], report.used)
185
+ return {share.name: count for share, count in zip(shares, parts, strict=True)}
186
+
187
+
188
+ def makeup_lines(report: ContextReport, width: int) -> list[Line]:
189
+ """Return the bar divided between the sources, and a row for each source under it."""
190
+ cells = max(1, min(width, BAR_MAX))
191
+ measured = report.measured
192
+ tokens = tokens_by_source(report)
193
+ present = [share for share in report.shares if share.tokens > 0]
194
+ total = report.used if divides_the_total(report) else report.measured
195
+ lines: list[Line] = [
196
+ _fitted(
197
+ width,
198
+ [
199
+ [("Where it goes", "heading"), (f" how the {total:,} tokens divide between sources", "muted")],
200
+ [("Where it goes", "heading"), (f" {total:,} tokens", "muted")],
201
+ [("Where it goes", "heading")],
202
+ ],
203
+ ),
204
+ [
205
+ (BAR_FULL * count, SOURCE_ROLES[share.name])
206
+ for share, count in zip(present, allocate([share.tokens for share in present], cells), strict=True)
207
+ if count
208
+ ]
209
+ or [(" " * cells, "track")],
210
+ [],
211
+ ]
212
+ items_shown = width >= 44
213
+ others = 2 + 2 + TOKENS + 2 + SHARE + (2 + ITEMS if items_shown else 0)
214
+ name_width = max(8, min(NAME, width - others))
215
+ track = min(32, width - (others + name_width + 2 + 2))
216
+ track = track if track >= 6 else 0
217
+ head = " " * 2 + fit("Source", name_width) + " "
218
+ head += (" " * (track + 2)) if track else ""
219
+ head += fit("Tokens", TOKENS, right=True) + " " + fit("Share", SHARE, right=True)
220
+ head += " " + fit("Items", ITEMS, right=True) if items_shown else ""
221
+ lines.append([(head, "muted")])
222
+ counted = {share.name: share for share in report.shares}
223
+ for name in SOURCES:
224
+ share = counted.get(name)
225
+ if share is None and not (report.partial and name in FROM_THE_REQUEST):
226
+ continue
227
+ role = SOURCE_ROLES[name]
228
+ line: Line = [(SWATCH if share is not None else "\u00b7", role if share is not None else "muted")]
229
+ line.append((" ", "text"))
230
+ line.append((fit(name, name_width), "text" if share is not None else "muted"))
231
+ line.append((" ", "text"))
232
+ if share is None:
233
+ if track:
234
+ line.append((" " * (track + 2), "text"))
235
+ line.append((fit("pending", TOKENS, right=True), "muted"))
236
+ line.append((" " + fit("\u2014", SHARE, right=True), "muted"))
237
+ else:
238
+ if track:
239
+ line.extend(meter(share.tokens, measured, track, role))
240
+ line.append((" ", "text"))
241
+ line.append((f"{tokens[name]:>{TOKENS},}", "text"))
242
+ line.append((f" {percent(share.tokens, measured):>{SHARE}}", "muted"))
243
+ if items_shown:
244
+ line.append((f" {share.items:>{ITEMS}}", "muted"))
245
+ lines.append(line)
246
+ return lines
247
+
248
+
249
+ def note_lines(report: ContextReport, width: int = BAR_MAX) -> list[Line]:
250
+ """Return what the numbers are and are not: whose total this is, and whether the split is an estimate.
251
+
252
+ The counter's name is not said: a reader cannot act on it. What they can use is whose total this
253
+ is and that the division of it between sources is worked out locally, which is why it is a
254
+ proportion and not a bill; and, when the counts are a rule of thumb, that they are.
255
+ """
256
+ if report.from_provider and not report.partial:
257
+ words = (
258
+ "Total from the provider, which counts what it bills. "
259
+ "Each source gets its share of that total, worked out from a local count."
260
+ )
261
+ elif report.from_provider:
262
+ words = (
263
+ "Total from the provider, which counts what it bills. The rows are counted here and do not "
264
+ "add up to it: the environment notes and tool schemas are in the total but cannot be seen "
265
+ "until a request has been sent."
266
+ )
267
+ elif report.partial:
268
+ words = (
269
+ "Counted here; the provider has not reported this request yet. The environment notes and "
270
+ "tool schemas are not counted until a request has been sent."
271
+ )
272
+ else:
273
+ words = "Counted here; the provider has not reported this request yet."
274
+ if report.estimated:
275
+ words += f" Counts are estimates, about {CHARS_PER_TOKEN} characters to a token."
276
+ return [[(row, "muted")] for row in textwrap.wrap(words, max(10, width))]
277
+
278
+
279
+ def context_lines(report: ContextReport, trigger: int | None, width: int) -> list[Line]:
280
+ """Return the whole page body: the window, where the tokens went, and what to make of them."""
281
+ lines = window_lines(report, trigger, width)
282
+ lines.extend(([], *makeup_lines(report, width), [], *note_lines(report, width)))
283
+ return lines