Composer context meter with heuristic composition breakdown
composer 上下文占用圆环与启发式组成明细
The Web chat's stats line showed context occupancy as one inline figure (`Context N% of X`) among its billing groups. That answers "how full" but not "what fills it": nothing showed how the window divides between the system prompt, tool schemas, and conversation, and the one-line row has no room for that detail. The available numbers also live in two vocabularies — the provider-exact billed prompt size from `contextP
English
Problem
The Web chat's stats line showed context occupancy as one inline figure (Context N% of X) among its billing groups. That answers "how full" but not "what fills it": nothing showed how the window divides between the system prompt, tool schemas, and conversation, and the one-line row has no room for that detail. The available numbers also live in two vocabularies — the provider-exact billed prompt size from contextPressure versus the token-meter's fixed character heuristic — and no existing surface could present composition without conflating them.
Decision
Three cooperating pieces, one per package boundary:
dsh-session exports the pure deriveEventMessage(event) (previously reachable only as a Session method, which now delegates to it) so a host-side fold can price surface nodes without a Session instance.
dsh-token-meter extracts its pricing heuristic into src/estimate.ts and its positional surface fold into src/surface-fold.ts — both shared verbatim with the measurement service — and registers a third session projection, contextBreakdown, carrying systemTokens / toolsTokens / messageTokens. Envelope figures reprice last-wins on each request/header through canonicalHeader; the message figure replays foldSurfaceTokens over a per-node {seq, tokens} list, so it equals measure().surfaceTokens at every event boundary by construction and compaction shrinks it the way it shrinks the next request. The shared fold is total and allocation-fresh — it returns the next surface rather than mutating one — which keeps the service's validate-before-commit replay transaction intact: a throw leaves the replay cursor unmoved and the same malformed event fails identically on retry. A replace range absent from the folded surface throws: committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event.
ui-conversation moves context occupancy off the stats line (one home per fact) onto a composer-trailing ContextMeter: a 14px occupancy ring after the model seat fed by contextPressure, click-opening a panel that pairs the provider-exact percent and ~used / capacity header with a 4px color-segmented bar and ~-prefixed composition rows. The two vocabularies deliberately never reconcile — the heuristic shares only proportion the bar's colored segments and rows, each marked ~ because the fixed 4-chars-per-token heuristic systematically underprices CJK text and code. (The ring, header, and bar length were provider-exact as shipped here; they now read the provider-anchored projectedTokens instead, because the bare sample could not see a compaction — see the meter's compaction blindness.) The header is one localized sentence (context.aria, shared with the ring's accessible name) split around its {percent} slot, so each locale owns the reading's position — English leads with it, Chinese trails it — while the reading keeps its own tone; a bar part whose width computes to zero is dropped rather than rendered, because .segment's min-width would otherwise paint a filled sliver at 0% occupancy.
Alternatives considered
Deriving composition client-side from the loaded window. The window is a contiguous log suffix: the request/header events carrying the system prompt and tool schemas may sit outside it, and paging would silently change the figures. Only a durable host-side projection survives paging and compaction, which is why the data crosses the wire as a third projection rather than a chat-window fold.
Scaling the heuristic rows to sum to pressureTokens. Forced reconciliation fabricates precision: pressure lags one request, includes provider envelope overhead the estimator never models, and would make the rows move when nothing in the composition changed. Showing the estimator's real vocabulary with an explicit ~ was chosen instead.
Finer categories (rules, skills, MCP tools) as in Claude Code's /context. Not separable here: the harness folds those contributions into the system text and the tools list before the request header exists, so three categories are the honest resolution.
Consequences
Token-meter now registers three projection keys; unloading removes all three, and contextBreakdown restores from JSON checkpoints (stateVersion 1). The stats line dropped its Context group and the ring is the sole context UI. The panel's heuristic rows visibly disagree with the provider-exact header — accepted and signposted by the ~ prefix; improving estimate accuracy (for example CJK-aware weighting) is localized to estimate.ts and changes no seam. The legend's purple segment tint is a literal color because the design platform ships no purple static token.
中文
问题
Web 聊天的统计行把上下文占用率作为一个行内数字(Context N% of X)挤在计费分组之间。它回答了「有多满」,却回答不了「被什么占满」:没有任何地方展示窗口在系统提示词、工具 schema 与对话之间如何分配,而单行统计行也容纳不下这种明细。可用的数字还分属两套口径——来自 contextPressure 的提供方精确计费的提示词规模,与 token-meter 的固定字符启发式——没有任何既有界面能在不混淆两者的前提下展示组成。
决定
三个协作部分,每个包边界一个:
dsh-session 导出纯函数 deriveEventMessage(event)(此前只能通过 Session 方法访问,该方法现在委托给它),使 host 侧 fold 无需 Session 实例即可为表层节点计价。
dsh-token-meter 把计价启发式抽取到 src/estimate.ts、把位置表层折叠抽取到 src/surface-fold.ts(两者都与测量服务逐字共享),并注册第三个会话投影 contextBreakdown,携带 systemTokens / toolsTokens / messageTokens。envelope 数字在每条 request/header 上经 canonicalHeader 按后者胜重新计价;消息数字在逐节点 {seq, tokens} 列表上重放 foldSurfaceTokens,因此它在每个事件边界上按构造等于 measure().surfaceTokens,压缩(compaction)会像缩小下一个请求那样缩小它。这份共享折叠是全函数且总是新建数组——返回下一个表层而不是原地改写——从而保留了服务侧「先校验再提交」的重放事务:抛出时重放游标不前进,同一条畸形事件在重试时报同样的错。折叠表层中不存在的替换范围会直接抛出:已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。
ui-conversation 把上下文占用率从统计行移走(一个事实一个家),放到 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 contextPressure 供数,点击弹出的面板把提供方精确的百分比与 ~已用 / 容量 标题与 4px 分色分段进度条及带 ~ 前缀的组成明细行并列。两套口径刻意永不对账——启发式数字只决定进度条各彩色分段之间的相对比例,并原样显示在明细行中;每个数字都标有 ~,因为固定的「4 字符≈1 token」启发式会系统性低估 CJK 文本与代码。(本记录落地时,圆环、标题与进度条总长取的是提供方精确值;它们现在改读锚定在提供方读数上的 projectedTokens,因为裸样本看不见压缩——见仪表对压缩的失明。)标题是一整句本地化文案(context.aria,与圆环的无障碍名共用),在 {percent} 槽位处切开渲染,于是读数的位置由各语言自己决定——英文在前、中文在后——同时读数保留自身独立的强调样式;宽度算出为零的分段直接不渲染,否则 .segment 的 min-width 会在 0% 占用时画出一段填充色。
备选方案
在客户端从已加载窗口推导组成。 窗口是日志的连续后缀:携带系统提示词与工具 schema 的 request/header 事件可能在窗口之外,翻页还会让数字悄悄变化。只有持久的 host 侧投影能在翻页与压缩后幸存,这正是数据以第三个投影而非聊天窗口 fold 的形式过线的原因。
把启发式明细行按比例缩放,使其总和等于 pressureTokens。 强行对账是在捏造精度:压力滞后一个请求,还包含估算器从不建模的提供方封装开销,会让明细行在组成毫无变化时也跟着变动。最终选择以显式 ~ 展示估算器的真实口径。
更细的类别(rules、skill(技能)、MCP 工具,如 Claude Code 的 /context)。 在这里不可分:harness 在请求标头存在之前就把这些贡献折入系统文本与工具列表,因此三个类别是诚实的分辨率。
后果
token-meter 现在注册三个投影键;卸载会移除全部三个,contextBreakdown 可从 JSON 检查点恢复(stateVersion 为 1)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 ~ 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 estimate.ts,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。