# dsh-session-usage

[English](README.md) | 中文

会话用量视图：会话标签页里的一个「用量」，一张会话总计加两张表。

1. **模型用量统计**（默认）：每个用过的模型一行，按总量降序。
2. **消息用量统计**：每**轮**一行，可点表头按任意数字列排序。

这是一个**独立安装到 profile 的插件**，不修改 `deepseek-harness` 的任何源码（`packages/` 未改动）。

## 两个叶子

![「用量」视图，选中「模型用量统计」叶子](doc/usage1-zh.png)

![「用量」视图，选中「消息用量统计」叶子](doc/usage2-zh.png)

| | 模型用量统计 | 消息用量统计 |
|---|---|---|
| 一行 = | 一个模型 | **一轮**（不是一条消息） |
| 列 | 模型 · 轮次 · 输入 · 输出 · 用时 · 缓存命中 | 时间 · 消息 · 模型 · 输入 · 输出 · 用时 · 缓存命中 |
| 排序 | 固定，总量降序 | 点表头切换 |
| 覆盖 | 已加载窗口内的轮次 | 同左 |

**一行是一轮，不是一条消息。** 一条助手回复与提出它的那句提示词属于同一轮、共用同一份用量，
逐条列出来会把同一笔 token 在每轮里数两遍——这是用量表最不能犯的那个算术错误。所以行由**发起
该轮的那句提示词**标识。一轮里换过模型时，整轮记在**产出回复的那个模型**上。

会话总计那一行的三个胶囊来自 `ISession.projections`（宿主按**整条日志**算好），所以翻页、压缩
窗口都不会让它们跳动。

## 数字从哪来

| 数字 | 来源 |
|---|---|
| 会话用量 / 用时 / 缓存命中 | 客户端投影 `tokenUsage`、`sessionStats`——**整条日志**口径 |
| 每轮用量 | 会话自己的**事件窗口**，交给宿主的 `deriveTurnTokenUsage`（`@deepseek-ai/dsh-token-meter/client`）折叠 |
| 每轮模型 | 该轮事件里的路由元数据，回退到 `assistant/message` 的 `message.source` |
| 每轮用时 | 该轮 `turn/start` 与 `turn/end` 两个事件自己的时刻之差（不读时钟） |
| 模型显示名 | `remote.session.modelCatalog()`，与输入框选择器同一写法 |

**用量不自己聚合。** 哪些事件算数、最终消息如何覆盖流式样本、重试如何叠加、不完整的轮次返回
空——这些规则很细，宿主已经在 `deriveTurnTokenUsage` 里定死了（ui-chat 建轮尾用的就是它）。
自己再实现一遍，第一次偏差会一直隐形，直到有人比较两个界面。

**缓存命中率是移植，不是重写。** `format.ts` 里的 `formatCacheHitPercent` 是 ui-chat
`token-format.ts` 那一份的移植：同一个投影、同一份算法，所以本插件、输入框那两个胶囊与底部
状态栏印出的百分比逐字相同。宿主那条规则值得说明——**部分命中接近满分时不夹紧成 `99.9`，
而是加小数位**（`99.6` / `99.95` 这种形状），既真实、又明显不是满分。

## 三条刻意的口径

**① 输入 = 三个不相交桶之和。** `uncachedInputTokens + cacheReadTokens + cacheWriteTokens`。
宿主把 `inputTokens` 只算作**未缓存**的那部分，把它当成总量会让折叠直接拒绝整轮。

**② 算不出来就留「—」，并且照常留行。** 隐藏该行会让行数与覆盖度行报出的轮次数不符；而破折号
**不是一个小数字**，所以按任意列排序时，它**在两个方向上都排最后**——否则降序时一列「—」会占据
表头，读起来像"这些最贵"。

**③ 并列与都未知时按 `seq`。** 于是排序永远不会任意打乱相等的行；把它写成 `seq` 而不是
"输入顺序"，这条规则在本插件之外也成立。

## 覆盖范围与「载入全部历史」

明细只能折叠**已加载的事件窗口**，所以覆盖度行同时报出两个数：

```text
已统计 12 / 22 轮 · 其中 10 轮不在已加载窗口
```

「载入全部历史」走**宿主自己的跳转加载器**（`binding.session.loadThrough(SessionSeq(0))`）：
它自带无进展守卫（空页仍然声称还有历史时结束循环，而不是空转），每页 200 条消息——是本插件
自己翻页的四倍，所以整段会话的往返次数只有四分之一。代价写在按钮的提示里：**载入不回缩**，
刷新前一直有效，且无法中途取消。

## 时间范围

表格上方有一排范围按钮，**默认「本会话」**——也就是之前的行为，完全不变：

| 档 | 范围 |
|---|---|
| 本会话 | 没有边界：整场会话，读宿主投影 |
| 今天 | **本地 0 点** → 此刻 |
| 24 小时 | **滚动 24 小时**：此刻往前一天 |
| 昨天 | **昨天 0 点 → 今天 0 点**（左闭右开） |
| 3 天 | 三天前**本地 0 点** → 此刻 |
| 7 天 | 七天前**本地 0 点** → 此刻 |
| 自定义 | **你选的两端**（本地时区；结束留空 = 此刻） |

两种形状是**刻意不同**的，而且都是你自己会用的说法：「今天」是一个日历日；而「最近一天」念出来的
是「昨天此刻到此刻」——**不是**昨天 0 点，后者会让清晨的读数少算掉大半天。

**「昨天」是固定档里唯一两端都定的那个。** 其余各档都延伸到此刻（「最近三天」包含今天），而「昨天」是**一个
闭合的日历日**——只给下界的话，它会把今天也吞进来。上界是**开**的：昨天 0 点到今天 0 点整，所以
**今天 0 点整开始的那一轮归今天**，两档在零点相接而不重叠。

**「自定义」由你定两端**，用的是两个原生日期时间输入，读写的都是**本地时区**（用 UTC 会让字段
显示的时间和你选的时间差几个小时）。结束留空 = 到此刻。**未填起始时表格是空的**，而不是显示全部——
半填状态下显示全部会读成「自定义被忽略了」。它也是唯一记住**具体时刻**的档：其余各档都是相对此刻
算出来的，只有它存着两个瞬间。

选定范围后，顶部那三个胶囊**不再是投影**。没有任何投影带时间维度，所以它们改为从**范围内的轮次**
求和，命中率取**和的比**（先把缓存读取与计费输入分别求和，再套用宿主那份格式化）。**和的比不是比
的平均**：按轮平均会让 3 个 token 的一轮与 30 万的一轮等权。

**范围是会话内的。** 本插件只读当前会话的投影与事件窗，客户端够不到跨会话的日志（宿主侧的会话
查询没有对应的 remote endpoint），所以「今天」= **这个会话今天用了多少**。

**点一个范围不会自动载入任何东西。** 窗口只装得下最近一段，所以在跑了一个月的会话上点「7 天」，
起初只会看到窗口碰巧覆盖的那部分——覆盖度行会明确写出缺口（`· 更早的轮次还没载入`），因为在范围内
「已统计 N / N 轮」这句话本身看不出还有更早的。

**要补上缺口，由你点那个按钮**（「载入范围内更早的历史」）。它是精确的：逐页往前拉（每页 50 条消息，
Session Controller 自己的 pager），**窗口最早一轮一早已于范围起点就停**，而不是一路拖到会话开头；
上限 40 页（2000 条），超过则由旁边的「载入全部历史」一次到位。

**为什么不让它自动**：一页拉进来的是**消息本体**，而宿主**无法卸载**——窗口只会变大，真正的代价是
「看 7 天」所触发的载入，而不是它画出来的表。所以这个代价由选择它的人来付。

范围同样是记住的（见下），而它是五个偏好里唯一**含义会移动**的一个：「今天」永远是打开时的今天，
而不是选中它那天。

## 导出

范围行下面有四个按钮，两两一组：前两个给**数字**，后两个给**对话**。

| 按钮 | 内容 |
|---|---|
| 统计 CSV | 与表同样的列；**紧凑格式**（`8.2K`、`1分12秒`），末尾一行合计 |
| 统计 JSON | **原始数字**，含 `seq`、完整 usage 分桶、路由来源——给程序用 |
| 对话 MD | 每轮的**完整**提示词与回复（Markdown） |
| 对话 JSONL | 范围内每轮的**原始事件**，一行一个 JSON，与宿主日志同形 |

三处刻意的取舍：

- **CSV 用紧凑数字，JSON 用原始数字。** 前者是人在表格里读的（`8.2K` 比 `8214` 好扫），
  后者是程序读的（它没法把格式化逆回去）。缓存那一列在 CSV 里**不带 `%`**——带后缀的数字
  在求和前还得先剪掉尾巴，那不是数字，是标签。
- **CSV 的合计行不假装分桶。** 范围内"输入 / 输出"的拆分这个视图从没算过，编一个出来就等于
  在导出里放一个任何界面都不认同的数；合计行只写四处都对得上的：轮数、总量、用时、缓存命中。
- **Markdown 用的是全文。** 表里那行是截断的预览，而**截断是视图的事**（在表里）——点了导出的人
  要的是对话本身，不是"能塞进一格的那部分"。

**导出的就是屏幕上那些**：同一个范围、同一个顺序、同样的轮次——缺口也一并如实，
未载入的更早历史不会出现在文件里（与表一致）。空范围时四个按钮**禁用**：
一个只有表头的文件，不是比"没有什么可写"更好的回答。

CSV 的字段会按 RFC 4180 转义（含逗号、引号或换行时加引号、内部引号翻倍）——
提示词里这些字符是常态，不转义不是"看起来乱"，而是**整行串列**：表格会安静地读出一组
和界面不一样的数字。

## 记住你的位置

五个读者偏好，都存在 `localStorage`（键带 `dsh-session-usage.` 前缀）：

| 键 | 记住什么 |
|---|---|
| `…message-sort` | 消息表的排序列与方向 |
| `…leaf` | 停在哪个子标签 |
| `…scroll` | 页面滚动到哪儿 |
| `…range` | 停在哪个时间范围 |
| `…custom-range` | 自定义区间的两端 |

两层，两层都承重：一个**模块级值**覆盖卸载（两个叶子是条件渲染的，切换叶子会卸载表格，而
`useState` 会静默把排序清回窗口自身顺序），`localStorage` 覆盖刷新。

- **存储是可选的**：每次访问都兜住异常（宿主有 profile 以 `--no-webstorage` 运行），此时内存层
  仍然覆盖本次页面——那里"忘了排序"是少个便利，不是一张坏掉的表。
- **存进去的值都要校验**：手改过、或由列不同的版本写入的值会被认出来并当作"没有偏好"。认不出
  的排序列会让比较全变成 `NaN`，行序乱成一片——**看起来像排序 bug，其实不是**。
- 这些偏好是**全局的**：切会话不会重置它们（它们描述读者，不描述会话）。

## 形态：composer-overlay

本视图声明宿主的 `data-conversation-composer-overlay`，也就是 trajectory 视图用的那套模式。
一个属性换来三件事：

- **转录的宽度把手不再渲染。** 它们锚在内容列上、命中带宽达 40px，正好落在用量表右列的数字上，
  一个只想看数字的指针会不断把它招出来，一拖还会改掉内容列宽。
- **输入框抬成底部浮层**，所以表格底部按宿主发布的 `--dsh-composer-height` 留白——否则长表的
  最后几行会压在输入框下面。
- **滚动交给视图自己**，由根元素承担（一个滚动区管住胶囊、两个叶子与覆盖度行）。

这个模式是**声明式**的：属性随视图一起消失，宿主自己收尾。

## 语言

插件自己的文案提供 `zh` 与 `en` 两种（宿主自带的两种），全部在 `src/client/locales.ts`。
两本字典都标注为 `Record<MessagesKey, string>`：往 `MessagesKey` 加一个键，两本补齐之前
**编译不过**，所以不会出现「某个键只是忘了翻译、于是静默退回英文」。这个保证比回退链本身更重要。

数字与时长同样走本插件的字典（`12.2K` / `1.2M`、`45.2秒` / `2分42秒`），因为**每一种语言有
自己的写法**，它们不是可以照抄的常量。

## 目录

```text
session-usage-plugin/
  package.json        dsh.bundle + dsh.client 声明、exports 映射
  cordis.patch.yml    层补丁：一行 insert，把自己登记为 Loader 条目（无 config 块）
  build.mjs           构建脚本（tsdown 编程接口）
  tsconfig.json       类型解析：给 IDE 与 npx tsc 用（noEmit，只读）
  src/
    index.ts                  Node 半：不提供服务、不提供配置，只让 Loader 行能解析
    client/
      index.ts                浏览器半：注册「用量」视图与模型名查找
      UsageView.tsx           容器：会话总计、两个叶子、覆盖度行、偏好与形态
      ModelUsageTable.tsx     按模型表；模型名回退到原始 id
      MessageUsageTable.tsx   按轮表；排序规则与未知值归位
      turn-facts.ts           事件窗 → 每轮事实（模型、用量、缓存、用时、提示词）
      usage-by-model.ts       每轮事实 → 每个模型一行
      format.ts               紧凑数字/时长 + 移植宿主的缓存命中格式
      preferences.ts          五个读者偏好（排序 / 叶子 / 滚动 / 范围 / 自定义区间）
      model-names.ts          模型显示名：宿主目录 → id 的查找表
      Coverage.tsx            覆盖度行与「载入全部历史」
      table-styles.ts         两张表共用的表格 chrome 与页面根样式
      time-range.ts           固定范围、其本地日历边界，以及自定义区间的本地时间换算
      locales.ts              zh / en 两本字典
  lib/                构建产物（index.js / client.js）
```

## 安装

从 npm 装（发布的包里已带 `lib/`）：

```sh
pnpm dsh plugin --profile web add dsh-session-usage
```

或者从本仓库装——这时构建由你自己跑，且改了 `src` 就要重跑：

```sh
# ① 构建（改了 src 就要重跑）—— 全程在仓库根执行
cd session-usage-plugin && npm run build && cd ..

# ② 安装到 profile（此时已回到仓库根）
pnpm dsh plugin --profile web add ./session-usage-plugin
```

构建也可用 `node_modules/.bin/tsx session-usage-plugin/build.mjs`（在仓库根执行，不必进子目录）。
注意这条依赖 `node_modules/.bin/` 下的 shell 桩，Windows 上没有它，请用上面的 `npm run build`。

然后**重启** `pnpm dsh web`（新增 bundle 层、以及 bundle 内容变化，都需要重启）。

卸载：

```sh
pnpm dsh plugin --profile web remove dsh-session-usage
```

## 开发

```sh
cd session-usage-plugin
npx tsc -p tsconfig.json                     # 类型检查（无输出 = 0 错误）
npm run build                                # 产出 lib/index.js 与 lib/client.js
```

`typescript` 是 devDependency，所以类型检查在插件目录内就能跑，不需要宿主仓库。
`oxlint` 是宿主仓库自己的 linter，本插件没有装它；把插件放进 dsh 仓库下时才可用：

```sh
cd .. && node_modules/.bin/oxlint session-usage-plugin
```

重启后即可在会话标签页里看到「用量」。会话视图的标签是**标签页**，不是弹窗——它和「对话」
「轨迹」并排，占据会话区整块。

## 已知限制

- **明细只覆盖已加载的事件窗口。** 覆盖度行会把这件事说出来（`已统计 N / M 轮`），更早的部分
  由「载入全部历史」翻入——**载入不回缩**，刷新前一直有效，且无法取消。正在进行的轮次要等它
  结束后才会出现用量。
- **缓存命中按轮取**，与每轮对话框同一精度；进行中的轮次拿不到（宿主对该情形 fail closed），
  此时该格留「—」。
- **模型显示名依赖 `remote` 层。** 组合里没有它时，表格退回打印原始 id（如 `deepseek-flash`），
  其余行为不变。
- **只有 `zh` 与 `en`。** 语言包让其他语言可选时，本插件的文案会走 `en` 回退。
- **偏好是全局的**，不按会话区分：滚动位置在内容更短的会话里会被浏览器夹回范围内。
- 输入框在用量视图里被置为 inert 并给出原因（`conversation.blocks` 是外壳自己的单槽登记处，
  视图无法隐藏它，这是契约提供的唯一杠杆）。

## 实现要点

- **一次折叠供两个叶子。** 容器持有事件窗的折叠（按窗口自身 revision 记忆化）、覆盖度与总计，
  两张表是纯展示的——所以两张表不可能各自描述会话的不同切片。
- **轮询而非订阅**：空闲会话上一次 tick 只花一次整数比较，而仪表盘没有任何交互能靠实时推送改善。
- **两种状态各归其位**：每轮事实来自事件窗（会话数据），五个偏好来自 store（读者数据）。
  它们不共用存储，也不共用生命周期。
- **Node 半是空的，且是刻意的**：`dsh.bundle` 层按包名插入一行 Loader 条目，那一行必须能解析，
  而这个插件的全部工作都在另一半。
