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.
- zettcode/commands/__init__.py +1 -0
- zettcode/commands/context.py +95 -0
- zettcode/commands/context_charts.py +283 -0
- zettcode/commands/context_page.py +418 -0
- zettcode/commands/handlers.py +646 -0
- zettcode/commands/hub.py +1077 -0
- zettcode/commands/hub_graph.py +736 -0
- zettcode/commands/plugin.py +56 -0
- zettcode/commands/py.typed +0 -0
- zettcode/commands/usage.py +46 -0
- zettcode/commands/usage_page.py +290 -0
- zettcode_commands-0.4.2.dist-info/METADATA +23 -0
- zettcode_commands-0.4.2.dist-info/RECORD +15 -0
- zettcode_commands-0.4.2.dist-info/WHEEL +4 -0
- zettcode_commands-0.4.2.dist-info/entry_points.txt +4 -0
|
@@ -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
|