The composer ring, percentage, and `~used / capacity` header read
`contextPressure.pressureTokens`, which moves only when a request reports
usage. Compaction reports none — compact-basic summarizes through a direct
`ctx.llm.stream()` call and appends only its own `compact/*` records plus the
replacement `user/message` — so the meter was frozen across the one action
taken to change it. Driving a real `compactNow` through the agent loop:
BEFORE compact: ring=4% header=~4227/100000 rows=[18, 0, 4365]
AFTER compact: ring=4% header=~4227/100000 rows=[18, 0, 286]
The composition rows fell 93%; the ring did not move, and would not until an
entire further turn completed. The panel then contradicted itself by more than
an order of magnitude at exactly the moment a reader opens it.
`contextPressure` now also publishes `projectedTokens`: the provider sample
plus the heuristic repricing of everything the surface gained or lost since
that sample, clamped at zero, folded through the shared `surface-fold.ts`. The
sample is stamped before the same event joins the surface, so an
`assistant/message` anchors against the surface its own request carried. Only
the delta is estimated, so the figure stays provider-anchored — the estimator's
CJK and JSON-schema underpricing stays out of the occupancy number — while
reacting the moment content lands or a span is shadowed. Same run after:
BEFORE compact: ring=4% header=~4323/100000 (pressure=4227, projected=4323)
AFTER compact: ring=0% header=~ 244/100000 (pressure=4227, projected= 244)
`contextOccupancy` prefers the projected figure and falls back to the bare
sample, so a projection restored from a pre-field checkpoint degrades to the
old behavior rather than disappearing. `stateVersion` moves to 3.
4.6 KiB
Agent Note: composer 上下文占用圆环与启发式组成明细
Status: implemented
English | 中文
问题
Web 聊天的统计行把上下文占用率作为一个行内数字(Context N% of X)挤在计费分组之间。它回答了「有多满」,却回答不了「被什么占满」:没有任何地方展示窗口在系统提示词、工具 schema 与对话之间如何分配,而单行统计行也容纳不下这种明细。可用的数字还分属两套口径——来自 contextPressure 的提供方精确计费 prompt 规模,与 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,压缩会像缩小下一个请求那样缩小它。这份共享折叠是全函数且总是新建数组——返回下一个表层而不是原地改写——从而保留了服务侧「先校验再提交」的重放事务:抛出时重放游标不前进,同一条畸形事件在重试时报同样的错。折叠表层中不存在的替换范围会直接抛出:已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。
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、skills、MCP 工具,如 Claude Code 的 /context)。 在这里不可分:harness 在请求标头存在之前就把这些贡献折入系统文本与工具列表,因此三个类别是诚实的分辨率。
后果
token-meter 现在注册三个投影键;卸载会移除全部三个,contextBreakdown 可从 JSON 检查点恢复(stateVersion 为 1)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 ~ 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 estimate.ts,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。