Merge remote-tracking branch 'origin/worktree/context-source-cards' into worktree/context-forms-remaining

This commit is contained in:
creatixchu
2026-08-06 12:03:50 +08:00
90 changed files with 2795 additions and 440 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md
2026-08-05-context-meter-blind-to-compaction.md: ab39ae4e109f238960fd60de5e5b61075344f525
2026-08-05-context-meter-blind-to-compaction.zh.md: c93aa509530e48acf906b18ba85bab4c7d355d69

View File

@@ -0,0 +1,46 @@
# Agent Note: the context meter could not see a compaction
Status: implemented
English | [中文](2026-08-05-context-meter-blind-to-compaction.zh.md)
## Problem
The composer's [context meter](../feature/2026-08-05-composer-context-meter-breakdown.md) took its ring, percentage, and `~used / capacity` header from `contextPressure.pressureTokens`, the newest provider-reported prompt size. That number moves only when a request reports usage, and compaction reports none: `compact-basic` summarizes through a direct `ctx.llm.stream()` call and appends `compact/start`, `compact/summary`, the replacement `user/message`, and `compact/end` — no `assistant/message`, no usage chunk.
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=[system 18, tools 0, messages 4365]
AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messages 286]
```
The composition rows, which fold the surface, dropped by 93%. The ring — the primary affordance, and the reason a user opens the panel right after compacting — did not move at all, and would not until an entire further turn completed. The panel then showed a header and rows disagreeing by more than an order of magnitude, at exactly the moment a reader was most likely to add the rows up.
## Decision
`contextPressure` publishes a second numerator, `projectedTokens`: the provider sample plus the heuristic repricing of everything the surface gained or lost since that sample was taken, clamped at zero. The fold carries the priced surface through the shared `surface-fold.ts` and stamps `sampledSurfaceTokens` when a usage sample lands — **before** the same event joins the surface, so an `assistant/message` anchors against the surface its own request actually carried. `stateVersion` moves to 3.
Only the delta is estimated. The anchor stays provider-exact, which keeps the estimator's systematic CJK and JSON-schema underpricing out of the occupancy figure while still letting the number react the moment content lands or a span is shadowed. `contextOccupancy` reads `projectedTokens` and falls back to the bare sample, so a projection restored from a pre-field checkpoint degrades to the old behavior instead of vanishing.
This reverses the "the ring, header, and bar length stay provider-exact" half of the [context meter decision](../feature/2026-08-05-composer-context-meter-breakdown.md). What that decision was protecting — not fabricating precision by scaling heuristic rows to a provider total — is preserved: the rows are still unscaled, and the header still does not equal their sum. What changed is the recognition that "provider-exact but describing a request two compactions ago" is not the more truthful figure.
## Alternatives considered
**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk provenance (`session.events[seq]`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold.
**Emit a synthetic usage record at the end of compaction.** Would move `pressureTokens` itself, but the only usage compaction holds is the summarization request's own — a different prompt entirely. Recording it as the conversation's prompt size would be a lie in the durable log rather than in one display.
**Let the UI subtract, exposing `sampledSurfaceTokens` and reading `contextBreakdown.messageTokens`.** Splits one figure's arithmetic across two projections and the client. The host owns the vocabulary; it should publish the whole value.
## Consequences
Occupancy now advances with every surface event rather than once per turn, so the ring creeps up as a turn produces tool results instead of jumping at its end — and drops the instant a compaction lands. That is more projection frames on the wire: one per surface event for `contextPressure`, the rate `contextBreakdown` already ran at.
The panel's composition rows still do not sum to the header, and now for one clearly-stated reason instead of two: the rows carry the estimator's error, the header's anchor does not. The remaining lever is estimator accuracy (CJK-aware weighting in `estimate.ts`), which changes no seam.
`sampledSurfaceTokens` assumes nothing joins the surface between a step's request and its usage report. The loop admits steering and context before `buildRequest` and drains tool results after `assistant/message`, so that holds; if it ever stops holding, the error is bounded by one message and self-corrects at the next sample.
## Testing
`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.spec.tsx` pins the ring reading the projected figure, and `chat-stats-bash-sample.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted.

View File

@@ -0,0 +1,46 @@
# Agent Note上下文仪表看不见压缩
Status: implemented
[English](2026-08-05-context-meter-blind-to-compaction.md) | 中文
## 问题
composer 的[上下文仪表](../feature/2026-08-05-composer-context-meter-breakdown.md)的圆环、百分比与 `~已用 / 容量` 标题都取自 `contextPressure.pressureTokens`,即提供方报告的最新提示词规模。这个数字只在某个请求报告用量时才会移动,而压缩不报告用量:`compact-basic` 通过直连的 `ctx.llm.stream()` 调用生成摘要,只追加 `compact/start``compact/summary`、用作替换的 `user/message``compact/end`——没有 `assistant/message`,也没有用量分片。
于是在唯一一个专门用来改变它的操作面前,这块仪表纹丝不动。通过真实 agent loop 驱动一次 `compactNow`
```
BEFORE compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messages 4365]
AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messages 286]
```
折叠表层得出的组成明细行下降了 93%。而圆环——那个主要的可操作元素,也正是用户压缩完立刻会去点开面板的理由——完全没动,而且要等到又跑完一整轮才会动。此时面板上的标题与明细行相差一个数量级以上,恰恰发生在读者最可能去把明细行加总的时刻。
## 决策
`contextPressure` 发布第二个分子 `projectedTokens`:在提供方样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零。该折叠通过共享的 `surface-fold.ts` 携带已计价的表层,并在用量样本落地时记下 `sampledSurfaceTokens`——记录时机在同一条事件加入表层**之前**,因此 `assistant/message` 锚定的正是它自己那次请求实际携带的表层。`stateVersion` 提升到 3。
只有增量部分是估算的。锚点保持提供方精确值,从而把估算器对 CJK 文本与 JSON schema 的系统性低估挡在占用率数字之外,同时又让这个数字能在内容落地或某段区间被遮蔽的瞬间做出反应。`contextOccupancy` 读取 `projectedTokens`,并回退到裸样本,因此从不含该字段的检查点恢复出来的投影会退化为旧行为,而不是直接消失。
这推翻了[上下文仪表决策](../feature/2026-08-05-composer-context-meter-breakdown.md)中「圆环、标题与进度条总长保持提供方精确值」的那一半。那条决策真正想守住的东西——不要把启发式明细行按比例缩放到提供方总量、从而伪造精度——依然守住了:明细行仍未被缩放,标题仍不等于它们之和。改变的是这样一个认识:「提供方精确、但描述的是两次压缩之前那个请求」并不是更真实的数字。
## 备选方案
**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务不是纯折叠投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对分片来源的随机访问(`session.events[seq]`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。
**在压缩结束时补写一条合成的用量记录。** 这确实能推动 `pressureTokens` 本身,但压缩手上唯一的用量是摘要请求自己的用量——那是完全另一个提示词。把它记成本对话的提示词规模,等于把谎言写进持久日志,而不只是写进某一处展示。
**让 UI 自己做减法:暴露 `sampledSurfaceTokens`,再读 `contextBreakdown.messageTokens`。** 这会把一个数字的算术拆散到两个投影和客户端三处。词汇的所有者是宿主,就应当由它发布完整值。
## 影响
占用率现在随每个表层事件推进,而不再是每轮跳一次,因此一轮中产生工具结果时圆环会持续爬升,而不是等到轮次结束才跳变——压缩落地的瞬间它也会掉下来。代价是线路上多了投影帧:`contextPressure` 每个表层事件推一帧,也就是 `contextBreakdown` 本来就在跑的频率。
面板的组成明细行仍然加不出标题数字,但现在只剩一个能讲清楚的原因,而不是两个:明细行带着估算器的误差,标题的锚点不带。剩下的抓手是估算精度(在 `estimate.ts` 里做 CJK 感知加权),它不改动任何 seam。
`sampledSurfaceTokens` 依赖一个前提:在某个步骤的请求与它的用量报告之间,不会有新内容加入表层。循环在 `buildRequest` 之前接纳 steering 与 context`assistant/message` 之后才排空工具结果,因此该前提成立;即便将来不再成立,误差也被限制在一条消息以内,并在下一个样本处自行纠正。
## 测试
`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了样本在表层增长与一次压缩上的推进(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats-bash-sample.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md
2026-08-04-web-latency-throughput-metrics.md: 4d7627a9a38127259f1ba07121cea7282f794f2e
2026-08-04-web-latency-throughput-metrics.zh.md: 09e59242b799a3f4d03b9e0259f1a5a0d85f6a53

View File

@@ -0,0 +1,31 @@
# Agent Note: Web turn and window latency/throughput metrics
Status: implemented
English | [中文](2026-08-04-web-latency-throughput-metrics.zh.md)
## Problem
The Web chat records per-step LLM timing (`stepStartTime` / `firstTokenTime` / `completedTime`) and per-step usage, and the trajectory view exposes them per step, but the chat surface answers neither "how responsive was this turn" nor "how fast is this session going": the assistant footer shows only the turn wall time, and the stats line folds only wall-time totals.
## Decision
A package-local fold, `ui-conversation`'s `chat/turn-metrics.ts`, is the single derivation from assistant nodes to latency/throughput readings. `assistantStepReading` turns one node into a step reading: TTFT needs both `stepStartTime` and `firstTokenTime`, decode span needs `firstTokenTime`, negative spans clamp to zero, and output tokens come from the untrusted `usage` value only when they are finite and non-negative. `deriveTurnMetrics` folds readings per turn: the lowest-numbered step owns the turn's TTFT slot, and throughput divides the summed output tokens by the summed decode spans over exactly the steps carrying both, so an unsampled step drops out instead of skewing the ratio; a turn with neither figure emits no entry.
The assistant footer appends the readings to the existing hover-revealed time chrome after `Ran for`, as `TTFT {s}s · {tps} tok/s`, each omitted independently when unrecorded. ChatView shows a turn's readings only when that turn's `turnTimings` entry has an `endTime`: the loaded window is a contiguous log suffix, so an in-window settled turn carries every one of its steps and the first-step TTFT is genuine rather than a window artifact. `formatLatencySeconds` is unit-less so each locale template owns its second suffix (`TTFT {seconds}s` / `首 token {seconds}秒`).
The stats line reuses the same step reading in its window fold: `deriveStats` accumulates TTFT sum/count and decode span/tokens, rendering a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English) beside the LLM/tool wall times. The turn-count, step-count, duration, cache, and token labels use the same namespace. Like those wall times the group is window-scoped and folds no billing; token accounting stays on the token-meter projections.
## Alternatives considered
**A durable session projection (token-meter shape).** A `ProjectionDefinition` folding step timings host-side would survive compaction and window paging and cover the whole log. Deferred, not rejected: projection state must stay O(1) (averages, not percentiles), it needs a host change plus a schema, and the chat stats line is already documented as window-scoped for its duration facts — the new group joins that scope. A later PR can add the durable projection without moving these readings.
**Per-step footer chrome.** Showing each assistant message its own TTFT would attach chrome to mid-turn narration nodes, which the footer design deliberately keeps chrome-free; the trajectory view already exposes per-step timing detail.
**Gating footer metrics on node presence instead of `turn/end` timing.** Rendering whatever steps happen to be loaded would show a plausible-looking TTFT that is actually the first *loaded* step after paging. The `endTime` gate plus the suffix-window invariant makes the displayed figure the turn's true first-step latency or nothing.
## Consequences
A settled in-window turn's footer reveals `TTFT`/`tok/s` on hover after the wall time, and the stats line shows window-average latency and throughput with localized labels beside its wall times, all without new session events or host changes. Metrics degrade by omission: providers or steps without timing or usage samples drop individual figures rather than rendering zeros. Older history outside the loaded window stays uncounted, recorded in the package README's stats-line limitation.
Both readings divide by measured wall time, so neither is reproducible: the same replayed scenario yielded 69 and 70 tok/s on consecutive local runs, and a 3 ms replayed stream reads 26333 tok/s. The Web aria goldens therefore normalize throughput to `{{throughput}}` beside the existing `{{duration}}`, and the footer's decorative separators gained flanking spaces — without them the readings concatenate into one accessible string (`Ran for 13sTTFT 0.2s12 tok/s`), which both loses the reading boundaries a screen reader needs and denies `{{duration}}` the word boundary it matches on.

View File

@@ -0,0 +1,31 @@
# Agent Note: Web 轮次与窗口级延迟/吞吐指标
Status: implemented
[English](2026-08-04-web-latency-throughput-metrics.md) | 中文
## 问题
Web 聊天已经记录了逐步骤的 LLM 计时(`stepStartTime``firstTokenTime``completedTime`)和逐步骤 usagetrajectory 视图也按步骤展示它们但聊天界面既回答不了「这一轮响应有多快」也回答不了「这个会话跑得有多快」assistant 页脚只显示轮次实际耗时,统计行也只折算墙钟时间总量。
## 决策
包内折算 `ui-conversation``chat/turn-metrics.ts` 是从 assistant 节点推导延迟/吞吐读数的唯一位置。`assistantStepReading` 把一个节点转成一次步骤读数TTFT首 token 延迟)需要 `stepStartTime``firstTokenTime` 同时存在,解码时长需要 `firstTokenTime`,负时长收敛为零,输出 token 数只在不可信的 `usage` 值有限且非负时才采纳。`deriveTurnMetrics` 按轮次折算读数:编号最小的步骤拥有该轮次的 TTFT 槽位,吞吐用「同时携带两者的那些步骤」的输出 token 总和除以解码时长总和,因此缺采样的步骤直接退出而不是让比值失真;两个数字都没有的轮次不产生条目。
assistant 页脚把读数追加到既有 hover 显示的时间附属元素中、`用时` 之后,形如 `首 token {s}秒 · {tps} tok/s`未记录的数字各自省略。ChatView 仅在该轮次的 `turnTimings` 条目带有 `endTime` 时才显示读数:已加载窗口是日志的连续后缀,因此窗口内已结算的轮次必然带着它的全部步骤,首步 TTFT 是真实值而非窗口截断的产物。`formatLatencySeconds` 不带单位,各语言模板各自拥有秒后缀(`TTFT {seconds}s``首 token {seconds}秒`)。
统计行在其窗口折算中复用同一份步骤读数:`deriveStats` 累计 TTFT 总和计数与解码时长token 数,在 LLM工具墙钟时间旁渲染经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`)。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。与那些墙钟时间一样该分组是窗口作用域的不折算任何计费token 账目仍归 token-meter 投影。
## 考虑过的替代方案
**持久的会话投影token-meter 形态)。** 在 host 侧用 `ProjectionDefinition` 折算步骤计时可以跨越压缩与窗口分页、覆盖整个日志。是暂缓而非否决:投影状态必须保持 O(1)(只能均值,不能分位数),它需要 host 改动加 schema而聊天统计行的耗时事实本就被记录为窗口作用域——新分组沿用该作用域。后续 PR 可以在不挪动这些读数的情况下补上持久投影。
**逐步骤页脚附属元素。** 让每条 assistant 消息显示自己的 TTFT会给轮次中段的叙述节点挂上附属元素而页脚设计刻意让它们保持无 chrometrajectory 视图已经暴露逐步骤计时细节。
**用节点是否在场而非 `turn/end` 计时做页脚门控。** 直接渲染碰巧加载到的步骤,会展示一个貌似合理、实为分页后「首个已加载步骤」的 TTFT。`endTime` 门控加上后缀窗口不变量,使显示的数字要么是该轮次真实的首步延迟,要么什么都不显示。
## 后果
窗口内已结算轮次的页脚在 hover 时于实际耗时之后显示 `首 token``tok/s`,统计行在墙钟时间旁以本地化标签显示窗口平均延迟与吞吐,全程不新增会话事件、不改 host。指标以省略的方式退化没有计时或 usage 采样的提供方或步骤只是丢掉对应数字,而不会渲染成零。已加载窗口之外的更早历史仍不计入,已记录在包 README 的统计行限制中。
两个读数都以实测墙钟时间作分母,因此都不可复现:同一个回放场景在本机连续两次跑出 69 与 70 tok/s而一段 3 毫秒的回放流会读成 26333 tok/s。因此 Web aria golden 在既有的 `{{duration}}` 之外,把吞吐归一化为 `{{throughput}}`;页脚的装饰性分隔符也补上了两侧空格——没有它们,这些读数会连成一整串无障碍文本(`Ran for 13sTTFT 0.2s12 tok/s`),既让屏幕阅读器失去读数之间的边界,也让 `{{duration}}` 失去它赖以匹配的词边界。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md
2026-08-05-composer-context-meter-breakdown.md: a757bcbc8bc57f4c3a16f663a9c922155b8bb575
2026-08-05-composer-context-meter-breakdown.zh.md: 441fd3013f2db721527838955b5ce227865615f1

View File

@@ -0,0 +1,31 @@
# Agent Note: Composer context meter with heuristic composition breakdown
Status: implemented
English | [中文](2026-08-05-composer-context-meter-breakdown.zh.md)
## 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](../bug-fix/2026-08-05-context-meter-blind-to-compaction.md).) 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.

View File

@@ -0,0 +1,31 @@
# Agent Note: composer 上下文占用圆环与启发式组成明细
Status: implemented
[English](2026-08-05-composer-context-meter-breakdown.md) | 中文
## 问题
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`,因为裸样本看不见压缩——见[仪表对压缩的失明](../bug-fix/2026-08-05-context-meter-blind-to-compaction.md)。)标题是一整句本地化文案(`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。