# dsh-token-ledger

为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 提供透明、可审计的 token 记账。

[![CI](https://github.com/chenmiao8563/dsh-token-ledger/actions/workflows/ci.yml/badge.svg)](https://github.com/chenmiao8563/dsh-token-ledger/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/@chenmiao8563/dsh-token-ledger.svg)](https://www.npmjs.com/package/@chenmiao8563/dsh-token-ledger)
[![license](https://img.shields.io/npm/l/@chenmiao8563/dsh-token-ledger.svg)](./LICENSE)

[English](README.md) | 中文

---

## 这是什么

这是一本**账本**，不是一块仪表盘。它把 DSH 的持久会话日志折叠成 token 用量，保证重启不丢，
而且——这是关键——允许你**从原始日志重算同一批数字并与之对账**。

```
$ dsh-token-ledger audit
scanned 139 session log(s), 344203 events, 29 forked session(s), 0 unreadable

  stored      6901 calls  1205685663 tokens
  recomputed  6901 calls  1205685663 tokens

  audit: match — the stored ledger equals a fresh fold of the raw logs
```

只想看漂亮的图表，市面上有十几个同类插件。这个插件是给你**需要为这个数字辩护**的时候用的。

从 0.5 起，设置页有三个页签：**概览**（账本本身，含每档区间的预计花费）、**费率**（各厂商
最新模型的价格表与实时美元汇率）、**账单**（把同一批用量折算成钱——按工作区 / 会话 / 模型 /
供应商四个分组自上而下展开，可整页导出 CSV 或 JSON）。账单页会写明价格来源、汇率，
以及每一个没能套上价格的模型，所以这个数字是可以核对的；概览页只数 Token，
费用是拿账单那套算法算出来的估算值。详见[设置页](#设置页)。

## 界面截图

设置页各页签的实机截图——数字来自这台机器：

**概览**：本月总计、今日实时，以及一年的活跃度：

![概览页：本月总计、今日实时与年度活跃度热力图](assets/screenshots/01-overview.jpg)

**日历视图**：一个月的形状在这里——最忙的一天、最轻松的一天、工作到最晚的一天，
下面是同一批用量按模型拆分：

![日历视图：本月热力图与最忙、最轻松、最晚的一天，以及按模型的用量](assets/screenshots/02-calendar.jpg)

**近 7 天**的柱状图，以及同样的按模型拆分：

![近 7 天 Token 柱状图与按模型的用量](assets/screenshots/03-weekly.jpg)

**费率**：实时美元汇率与各厂商公布的价目表，联网不可用时每一项都能手改：

![费率页：美元汇率与各厂商模型价格](assets/screenshots/04-rates.jpg)

## 为什么别的插件装不上时它能装上

| 特性 | 为什么重要 |
| --- | --- |
| **没有任何东西会被装给你** | 无运行时依赖；声明的 peer 全都是可选宿主包——DSH 内部包的版本漂移不可能把第二份 harness 拖进你的 profile。 |
| **零安装脚本** | 直接用 git URL `dsh plugin add` 即可——pnpm 没有构建要拦，你也不必去 `allowBuilds` 里加白名单。 |
| **只 import `node:`** | 宿主端可以从任意 profile（web / desktop / headless / TUI）加载，不需要解析任何包。 |
| **不碰模型可见面** | 它不注册任何提示词段、消息或工具，因此不会改变请求前缀，也不会损害 KV cache 复用。 |
| **故障降级** | 每个钩子都有保护。账本出问题只记一条警告，绝不让会话失败。 |

## 安装

```bash
# 从 npm
dsh plugin --profile web add @chenmiao8563/dsh-token-ledger

# 从 git URL（不涉及任何构建步骤）
dsh plugin --profile web add github:chenmiao8563/dsh-token-ledger

# 从本地仓库
dsh plugin --profile web add /absolute/path/to/dsh-token-ledger
```

npm 包名带 scope，是因为 npm 会把分隔符归一化后比较，无 scope 的
`dsh-token-ledger` 被判为与已有包过于相似而拒绝发布。CLI 命令名仍然是
`dsh-token-ledger`。

重启 DSH，然后确认那一行进去了：

```bash
dsh --profile web --dump-config | grep token-ledger
```

在对话里用 `/tokens`：

```
/tokens
/tokens export     # 把 CSV 与 JSON 写到 <DSH_HOME>/token-ledger/exports/
/tokens json       # 原始快照
/tokens path       # 账本文件位置
```

账本写在 `<DSH_HOME>/token-ledger/ledger.json`。

### 需要的 DSH 版本

**DSH `^0.2.0-rc.1`** —— 当前实测针对的是 0.2.0-rc.2；区间下界写成 `0.2.0-rc.1`，
是因为这个生态用 rc 段来拼预发布版本区间。`^` 把上界放在 `0.3.0`。

这个区间是有实际约束力的，不是装饰：DSH 在启动时会拿它与正在运行的宿主比对，
**区间不覆盖当前版本就直接跳过该插件**。1.0.0 带的 `^0.1.2-rc.1` 不满足 `0.2.0-rc.2`，
于是 1.0.0 在所有 0.2 宿主上被静默跳过——1.0.1 修的就是这件事。`0.1.x` 宿主现在落在
声明区间之外。

同一个区间也写成 `engines.dsh`——这是插件市场的兼容性预检会读取并展示的字段：`dshmarket`
用它与宿主 peers 一起推导结论，并展示所有声明的**交集**。两处写的是同一个字符串，所以市场
上显示的是一个区间、而不是同一件事的两种写法的交集；也不会在没人同时改两处的情况下各自漂移。

这个要求按生态里真正会被读取的方式声明——写成对两半各自绑定到的五个宿主包的
`peerDependencies`：

| Peer | 为什么要写它 |
| --- | --- |
| `@deepseek-ai/dsh-session-persistence` | 它提供 `sessionPersistence`，账本的用量数据来自这里。 |
| `@deepseek-ai/dsh-commands` | 它提供 `commands`，`/tokens` 靠它注册。 |
| `@deepseek-ai/dsh-host-webserver` | 它提供 `webServer`，设置页那三个路由靠它提供。 |
| `@deepseek-ai/dsh-client-locale` | 浏览器半边注入它来拿翻译后的文案。 |
| `@deepseek-ai/dsh-client-ui-settings-general` | 浏览器半边注入它，把分区加进设置侧边栏。 |

五个都是**可选**（optional），这是刻意的而不是含糊其辞：DSH 不会把宿主包提升进 profile 的
`node_modules`，所以写成必需 peer 只会在每次安装时报告一个"未满足"、却什么也不说明。可选也正是
插件自身行为的如实描述——没有 web 服务器的 profile 少掉设置页，`/tokens` 与 CLI 照常可用；
headless 或 TUI profile 则根本没有那两个客户端包。

这个声明之所以有用，是因为 DSH 会拿**实际在跑的那套安装**去解析它：profile 检查器先看插件自己的
`node_modules`，再看 profile 目录树，最后**回落到 DSH 安装目录本身**
（`node_modules/dshmarket/lib/check.js`），把解析到的版本与上面的区间比对——于是不受支持的
harness 会被报出来，而不是悄悄挂载。早先的版本声明的是 `dsh.compatibility.dsh`，而 DSH 里
没有任何代码读它；1.0 把它删掉，而不是留一个看起来像承诺、实际不是的字段。

这些声明对消费者零成本：可选 peer 不会被安装，包本身依旧没有运行时依赖、没有安装脚本。

## 计数规则

只有知道它到底在数什么，这些数字才有用。

| 规则 | 行为 |
| --- | --- |
| **只认成功锚点** | 用量取自 `assistant/message`（已完成的步）与 `compaction/summary`（一次压缩调用）。失败或被取消的尝试不会追加这两种事件，因此永远不计入。 |
| **流式样本是替换，不是相加** | 同一步先出 `usage` chunk、后出最终消息时，最终值**替换**早期样本——无论哪种情况都算一次调用。 |
| **`totalTokens` 是推导出来的** | 它是四个桶之和，绝不采信提供方自己的总量字段。在 15,778 份真实用量报告上两者完全一致，而推导能让桶与总量在构造上永远自洽。 |
| **推理 token 是子集** | 单独报告，绝不相加进总量，因为它本来就在 `outputTokens` 里面。 |
| **按本地日历日** | 是你所在时区的日，不是 UTC 日。 |
| **fork 切、resume 不切** | 见下。 |

### fork 与 resume 的区别

存储的日志可能以一段"已经记录过的历史"开头。有两种完全不同的情况会产生这种前缀，
把它们搞混是用量插件静默算错的最常见原因：

- **fork**（有 `parentSession`）：前缀是**父会话**的历史，已经在父会话自己的日志里计过。
  在这里再计一次就是重复计入——必须切掉。
- **resume**（没有父会话）：前缀是**这个会话自己**更早的历史，只存了一次。
  切掉就是漏计——不能切。

这不是猜的。对照真实日志：所有父日志仍在磁盘上的 fork 会话，其边界标记之前的用量指纹
都包含在父会话里；而带同样标记但没有父会话的日志，其前缀在文件后半段从未重复出现。
在一个真实的 139 会话 home 上，这个区别意味着 **7,992 万 token** 的重复计入——那是
"把每个日志都完整计一遍"的天真做法会报出来的数字。

## 命令行

不需要 DSH 在运行——它直接读原始日志。

```
dsh-token-ledger [summary] [选项]     打印已存账本（默认）
dsh-token-ledger audit   [选项]       从原始日志重算并对比
dsh-token-ledger rebuild [选项]       从原始日志重算
dsh-token-ledger export  [选项]       导出 CSV 与 JSON

--home <path>    DSH home（默认读 DSH_HOME 环境变量，其次 ~/.dsh）
--ledger <path>  要读写的账本文件
--out <path>     导出目录
--days <n>       摘要显示天数（默认 7）
--models <n>     摘要显示模型数（默认 5）
--write          配合 rebuild：覆盖已存账本
--json           机器可读输出
--quiet          抑制人类可读摘要，只保留退出码
```

退出码：`0` 成功或审计通过，`1` 审计发现真实差异，`2` 用法错误或输入不可读。
因此它可以挂进定时任务。

### 审计能区分两类差异

运行中的宿主是按防抖写入账本的，所以活跃会话比文件"新一点"是常态。
把这种情况报成数据损坏，审计就废了。账本自己的 `updatedAt` 可以裁决：

- 有差异的会话，其最新事件**比账本更新** → 只是还在跑 → **通过**，并报出尚未落盘的量；
- 有差异的会话，其最新事件**早于账本**，或者某条日/模型行与它本该汇总的折叠结果矛盾
  → **不通过**，退出码 1。

```
  audit: match — 1 session(s) advanced after the ledger was written
         (1 calls, 5100 tokens not yet flushed)
```

## 设置页

浏览器端会在设置侧边栏注册一个 **用量账本** 分区，顶部有 **概览 / 费率 / 账单** 三个按钮。

### 概览

- **区间总计**：本月 / 本年 / 近 7 天 / 全部四档可切换，每档显示 Token 总计、缓存命中率、
  调用次数、**预计花费**，以及背后的四桶明细。「全部」就是账本里所有天数，起止跟着第一条记录走。
  缓存命中率定义为
  `缓存读入 / (缓存读入 + 未命中输入)`——即输入中被缓存吸收的比例，因此完全不缓存的
  路由读数是 0%，而不是空白。
- **预计花费用的就是账单那套算法**：由宿主针对概览的每个区间各算一次，因此「本月」这一个
  数字和账单页本月那一行是同一个数，而不是两套算法今天恰好对得上。以人民币显示、保留两位
  小数，并注明换算用的汇率。宿主还没拿到价格时显示的是短横线并说明原因，而不是一个理直气壮的
  `¥0.00`；账单算不出价的那部分 Token 会写在它缺席的那个数字下面。
- **今日实时**：今天的 Token、命中率、调用次数与预计花费，每分钟刷新一次。
- **用量热力图**：年 / 月 / 一周三档可切换；年和月是热力图，切到一周改为每天一行
  的横向条形图。
  热力档位相对窗口内最忙的一天取平方根，避免某一天特别大把其余全部压成最淡档。
  月视图右侧另给本月小结：最忙的一天、最轻松的一天（仅工作日）与工作到最晚的一天
  （仅工作日，按当天最后一次调用的时刻比较）。
- **按模型**：各模型的用量、各自命中率，以及一条展示用量构成的堆叠条，下方图例只列出真正画出来的
  分段。某个桶在整个 payload 里都是 0（比如厂商不公布缓存写价格时的缓存写入），它既不会出现在条里，
  也不会出现在图例里——图例里有一个条形图中永远找不到的颜色，那不是信息而是谜题。

### 费率

- **美元汇率**单独一个框，保留四位小数，并标注来源与获取时间。这一页是价目表：
  它只报价格、并按汇率换算显示；把价格乘上用量做成费用的是**账单**页，页面上也写明了这一点。
- **各厂商最新模型**：每个厂商只列最新的 2~3 个，给出输入 / 输出 / 缓存读 / 缓存写
  每百万 Token 的价格。价格是**各家自己公布的价目**，取自一份按厂商整理的价格清单，并按
  上方汇率换算成人民币显示；每个换算后的单元格悬停时仍能看到原始的美元报价。没取到汇率时
  表格回退成美元并在列头标明单位，而不是硬凑一个自己都站不住的数字。厂商没有公布的价格显示为
  短横线（「没有标价」和「免费」是两回事）；公布为 `0` 的会额外标注「不一定是免费」，因为有些
  平台按 GPU 小时计费、根本不按 Token 标价。
- **价格来自厂商自己公布的地方**：这些厂商**没有任何一家提供价格 API**——它们的模型列表接口
  只返回模型 id、不含价格，价格只存在于官网 pricing 页面。**能直接读的就去读**：DeepSeek、
  Z.ai、腾讯三家的价格是当场从他们自己的定价页解析出来的，分组前面带「官方」标记；其余来自一份
  按厂商整理的公开数据集（[models.dev](https://models.dev)）。配置 `rates.source: openrouter`
  可把整张表切成该网关自己的报价——覆盖模型更多，但不是各家官方价目。页面会写明哪家是哪种来源。
- **DeepSeek 区分高峰与空闲时段**：它家页面按人民币报价，分缓存命中输入 / 缓存未命中输入 / 输出
  三条线，每条都有高峰与空闲两列——空闲时段价格为高峰的一半，高峰时段是北京时间周一至周五
  9:00-12:00、14:00-18:00，其余为空闲。因此每个时段各占一行并带标签，时段说明直接引用厂商原文。
  用人民币标价的厂商就按人民币显示，不做换算。
- **只列这 12 家，并用官方标识**：OpenAI、Anthropic、Google、DeepSeek、Qwen、xAI、Z.ai、Kimi、
  MiniMax、腾讯、小米、字节跳动。图形取自各家官网或 Simple Icons 的官方标识，内联进包里、页面
  加载时不联网；它们是各家的商标，仅用于标识价格属于谁。名单外的厂商若数据源里有也会照常出现，
  `rates.vendors: 0` 则列出全部厂商。
- **手动填写**：任何一项价格、以及汇率本身，都可以直接改写——按表格当前显示的币种填写。
  手动值优先级最高，不会被后续刷新覆盖，确实改动了数值的行在表格里带「手动」标记，也可以一键
  「恢复自动」或「清除」；若填入的值与自动获取的相同，则不带标记——此时刷新本来就不会改变这一行。
  另有一行自由录入，用于自动获取没覆盖到的模型。
- **随时刷新，逐个确认**：价格卡片上有刷新按钮，立即执行一次与定时任务相同的抓取，不必再等半小时；
  某个源没连上时页面会说明这次刷新失败，而不是报「已保存」。当你手填过价的模型官方价变了，页面会
  单独列出该模型、并排显示两个价格和一个问题——用官方价，还是保留你的。选「用官方价」会删掉这个
  手填值，把该字段交还给数据源，而不是把它冻结在今天的价格上；选「保留我的」则记录下当时看到的
  官方价，同样的分歧不会再问，直到官价真的再次变动。
- **离线是一种状态，不是错误**：宿主每 30 分钟刷新一次价格与汇率。刷新失败不会清空
  已有结果，而是把这次尝试标记为失败，因此被防火墙挡住时看到的是「上次已知价格 +
  可见的陈旧时间」，而不是一片空白。从未联网的宿主会明确说明并引导到手动录入；
  配置 `rates: false` 的宿主完全不发请求，页面就是一份你自己维护的价目表。

### 账单

同一批用量，按账单的读法分组。**四个分组直接自上而下展开**——工作区、会话、模型、供应商——
而不是藏在切换按钮后面，因为账单要回答的通常是「比较」类问题；**每个分组各有自己的时间段**，
所以「本月的按工作区」可以和「今日的按模型」同时摆在页面上。

- **每行显示**：分组名、**实际花费**、**缓存命中输入**、**未命中输入**、输出、缓存命中率、
  调用次数。两个输入列分开列，是因为凡是给它们定价的厂商，两者价格都不一样。缓存写入
  放在每张表下面一行说明，而不是做成一个几乎处处是短横线的列。
- **名字是有意义的**：工作区一行就是账本记录的 `cwd`；会话一行显示
  `工作区/会话名`，用的正是 DSH 自己给这个会话起的名字（DSH 没起过名的回退成会话 id，
  完整 id 与路径放在悬停提示里）；模型一行显示 `提供商/模型`——就是账本记录的那条路由，
  与概览页「按模型」列出的名字完全一致——具体按哪份价目计价放在悬停提示里。
- **供应商一行是「你接入的提供商」，不是一份价目表**：`bos`、`qwen-plan`、
  `deepseek-official`、`zai` 是接入的端点，`deepseek`、`qwen`、`z-ai` 是这些 Token 按谁家的
  价目计价。所以这一行显示的是提供商——用设置页给它的显示名（`BOS-API` 而不是 `bos`，
  这个名字是从 harness 的 settings 文件里**读**出来的，绝不写入），下面再写明计价来源
  （`计价来源 deepseek`）。因此同一个模型经由两个端点调用，就是两行——这正是供应商账单要回答的问题。
- **每个分组五个时间段**：本月 / 本年 / 7 天 / 今日 / 全部，与概览页同一套定义。
- **会话列表在页面上汇总，导出的文件不汇总**：只问了一句就归档的会话不值得单独占一行，而一台机器会攒下几百个，
  所以**花费很少或几乎没用过的会话——低于 ¥1，或调用少于 10 次**——在页面上合并成一行
  （`其余 N 个低频会话`，带「汇总」标记，各列是它们的合计）。**总额一分不动**：各组行之和仍等于分组合计。
  表格下方会写明汇总了多少个会话、规则是什么，并说明**导出永远是完整明细**——`format=csv` 里每个会话都在。
  两种情况永不折叠：算不出价的会话，以及整张表每一行都符合条件时（那种情况下列表本来就短，折叠反而把列表藏起来）。
  `bill.smallSessionCost`、`bill.smallSessionCalls`、`bill.foldSmallSessions: false` 可改规则或关掉。
  只有「会话」这一维度会折叠，工作区 / 模型 / 供应商本来就只有几行。
- **右上角一个导出，导出全部**：CSV 或 JSON，包含四个分组 × 五个时间段的全部信息，
  每行带币种，**每个分组各自一行 `TOTAL`**，而且刻意不给跨时间段的总计——这些时间段互相重叠，
  把今日加进本周再加进本月会把同一批 Token 数四遍。CSV 文件开头带 **UTF-8 BOM**：
  没有这三个字节，中文版 Excel 会按系统代码页打开它，会话名「编写统计」会显示成
  「缂栧啓缁熻」；JSON 导出不带 BOM，因为 JSON 解析器会直接拒绝开头有 BOM 的文件。
- **按套餐计费且不重复计费**：配置里的 `subscriptions` 填月度套餐（
  `{ vendor, plan, amount, currency, startedAt?, endedAt?, note? }`），月费会按账单覆盖的
  天数摊分——¥199 的套餐算到 1 日至 10 日就是 ¥66.33，不是 ¥199。上了套餐的厂商按套餐计费，
  套餐覆盖掉的用量在它旁边单独显示而不是相加：这两个数分开正是为了不把同一批调用算两遍。
  套餐是一笔钱，所以在「同一厂商出现在多行」的分组里（工作区 / 会话 / 模型），它按各行用量
  占比**摊分**到这些行上，这样每个分组的各行之和就等于该分组的合计；被套餐摊到的行会在费用
  下面写明「其中套餐摊分 ¥…」。区间内完全没有用量、但确实买了套餐的厂商照样计入，因为钱确实花了。
  套餐的 `vendor` 既可以写提供商、也可以写计价厂商（`bos` 或 `deepseek`），两种都会落到它覆盖的用量上。
- **算不出价的不按 0 计**：每个未定价的模型会连同 Token 数、调用次数和原因一起列出——
  该模型没有价格、同名模型被两家同时公布（不敢猜是哪家）、或价格币种无法换算。
  靠名字（而非 id）匹配上的价格也会列出，方便核对而不是盲信。
- **按时段报价的厂商按时段结算**：DeepSeek 高峰与空闲价格不同，账本记录了每次调用落在
  哪一侧——北京时间周一至周五 9:00-12:00、14:00-18:00 为高峰——于是两边各按自己的价格算，
  而不是统统按其中一边。
- **账单是估算，并且写在页面上**：它是「会话日志记下的 Token × 路由真正指向的那个模型
  的公开价目」。它不代表厂商最终开给你的账单——失败重试、部分返回的请求都会不一样；
  套餐自带的额度也只按你在配置里填的来。

页面读三个仅限回环的路由：概览用 `GET /api/token-ledger/summary`，价格与汇率用
`GET|POST /api/token-ledger/rates`，账单用 `GET /api/token-ledger/bill`。
概览每分钟轮询一次，价格与账单只在切到各自页面时才拉取。
三个路由都会拒绝非回环来源，因此即使 web 服务器绑定到 `0.0.0.0` 也不会外泄；
写入那半边还额外要求 JSON 内容类型（跨站表单发不出这种类型），并把请求体限制在 256 KiB。

它需要带 web 服务器的 profile（`web` 或 `desktop`）。没有的话 `/tokens` 与 CLI
照常可用，分区会明确说明而不是直接失败。注意手工填价格是在**那个页面上**填的：目前
还没有等价命令行入口，所以 headless profile 能读用量账本，但没法录入价格。

三个视图都**刻意不**通过设置命名空间下发：那需要 schema（一个真实依赖，而本包零依赖），
而且每次防抖都会用可推导、可重放的数据重写一遍 `settings.yaml`。
账本文件始终是唯一的记录来源，价格存在它旁边的 `rates.json` 里。

## 配置

按 `id` 覆盖组合条目：

```yaml
- id: token-ledger
  config:
    ledgerPath: 'D:/dsh/ledger.json'   # 默认 <DSH_HOME>/token-ledger/ledger.json
    backfill: false                     # 默认 true——启动时折叠已存历史
    backfillLogs: false                 # 默认 true——同时读取磁盘上的会话日志
    rates: false                        # 默认 true——false 时完全不发网络请求
    # rates 也可以写成对象：
    # rates:
    #   source: modelsdev                # 默认按厂商数据集；改成 openrouter 则用网关报价
    #   refreshIntervalMs: 1800000       # 默认 30 分钟
    #   perVendor: 3                     # 默认每厂商取最新 3 个
    #   vendors: 15                      # 默认最多列几家厂商；填 0 则列出全部
    #   modelsUrl: 'https://…'           # 默认随 source——https://models.dev/api.json
    #   fxUrl: 'https://…/latest/USD'    # 默认 open.er-api.com
    # 月度套餐，供账单页的「按套餐」分组使用。每个套餐按账单覆盖的天数摊分，
    # 因此不满一个月就只计一部分。
    subscriptions:
      - vendor: deepseek                 # 这个套餐覆盖哪家厂商的用量
        plan: 'DeepSeek 包月'            # 账单上显示的套餐名
        amount: 199                      # 每月费用
        currency: CNY                    # 该费用的币种
        startedAt: '2026-09-01'          # 可选：这天之前不计
        endedAt: null                    # 可选：这天之后不计
        note: null                       # 可选：备注，随套餐一起显示
    # 页面如何缩短「会话」列表。导出从不缩短，所以这三项只影响页面上显示什么。
    # bill:
    #   foldSmallSessions: true          # 默认 true；设为 false 则页面上也列出每一个会话
    #   smallSessionCost: 1              # 默认 1，按账单当前币种
    #   smallSessionCalls: 10            # 默认 10
```

`rates: false` 会彻底关掉定价功能的联网，只保留手动填写的值——这正是严格离线环境
需要的配置。费率页本身照常可用。

### 历史从哪里来

全新安装看到的是**你已有的账**，而不是从今天开始记。启动时会折叠两个来源，每个会话从被谁
先折叠那一刻起就**永久归谁所有**。

- **磁盘上的会话日志**：`<DSH_HOME>/sessions/<项目>/<会话>/session.jsonl.zstd`。这是持久记录，
  内容是完整的，而且**完全不需要宿主**——`rebuild` 与 `audit` 读的就是同一批文件。凡是还没有
  被折叠过的会话都从这里来，这正是全新安装能立刻显示历史、而不是一张空表的原因。
- **`sessionPersistence`**，宿主服务。它覆盖日志覆盖不到的部分——日志文件尚未落盘的会话——
  也是实时事件流折叠过的那些会话的增量通道。

两者交出来的**不是同一个数组**：日志是宿主的 compact row 形式，下标含义与逻辑事件列表不同。
因此一条游标只会被"设置它的那个来源"推进，另一个来源会拒绝而不是重复计数。`backfillLogs:
false` 关闭启动时的磁盘折叠；`backfill: false` 则两个都关，账本从空开始。

磁盘折叠每轮事件循环只读一个会话，所以首次运行也不会卡住宿主；每一轮之后**只用一个 `stat`**
跳过自上次以来没被写过的日志。已经跑过的主机在历史上不花任何代价——只有首次运行、或者某个
会话在本宿主之外被续跑过之后，才会真正打开文件。

## 它刻意不做的事

- **概览页给出写明口径的预计花费，账单页给出逐行明细。** 概览页在区间总计与今日实时里各显示
  一个预计花费（人民币、两位小数），用的就是账单那套算法；账单页则给出每个分组、每个时间段
  的逐行费用。两者都会写明价格来源、换算用的汇率、每一个靠名字匹配上的价格，以及每一个没能
  定价的模型，因此这些数字是用来核对的，不是用来信的。它们都不是厂商开给你的账单，
  也不声称是——具体漏了什么见上面的「账单」一节。
- **不内置价目表。** 价格要么从一个可选的源联网取（默认是各家官方价目），要么你自己填。
  内置一份价目表几周内就会过期，而且每次更新都得发一个版本。
- **不注册面向模型的工具。** 工具 schema 会在每次请求上花提示词 token 并改变缓存前缀——
  对一个 token 记账插件来说这很荒唐。人类场景由 `/tokens` 与 CLI 覆盖。

## 权限

它对运行它的机器做了什么——写清楚，好让它可以被**核对**，而不是被**猜测**：

| 能力 | 实际行为 |
| --- | --- |
| **文件** | 以只读方式读取 `<DSH_HOME>/sessions/` 下的会话日志；读取 `<DSH_HOME>/settings.yaml` 只为拿到账单上显示的供应商名称。写入只发生在它自己的目录里——`<DSH_HOME>/token-ledger/ledger.json` 与 `rates.json`——以及你主动要求的导出。该目录之外既不写也不删；代码里唯一的 `unlink` 是原子写失败后清理它自己产生的临时文件。 |
| **网络** | 只在费率功能开启时（默认开启）发起普通 HTTPS `GET`：价格目录（`models.dev`，若选择该来源则为 `openrouter`）、美元汇率，以及最多 3 个目录未自行定价的厂商定价页。`rates: false` 会去掉全部请求，只保留手动填写的价格。抓取失败时保留上次已存结果，绝不清空。浏览器半边不发起任何外部请求：它那 4 处 `fetch` 都是同源请求，打向本插件自己的 `/api/token-ledger/*` 路由。 |
| **命令** | 没有 shell、没有子进程、没有 spawn：运行时代码不 import `child_process`，也不调用 `exec`、`spawn` 或 `fork`——安装时与运行时都没有。它对外提供的是通过宿主自身 `commands` 服务注册的 `/tokens` 斜杠命令，以及你自己运行的 `dsh-token-ledger` 可执行文件。 |
| **凭据** | 不读取任何凭据。`process.env` 只在未显式传入 home 时用来解析 `DSH_HOME`；不读任何提供方 API key、不读 `.credentials.yaml`、不碰 keychain、不做 OAuth。你的任何信息都不会离开这台机器：唯一的对外请求就是上面那些价格地址，只带 `accept` 与 user-agent，别无其他。 |
| **安装** | 没有 `preinstall`、`install`、`postinstall` 或 `prepare` 脚本；无运行时依赖；无原生二进制；无符号链接与子模块；不禁用、不替换、不重复安装任何 `@deepseek-ai/*` 官方组件。 |

以上每一条都可以对着固定 Commit 核对——import、URL 与状态目录都在 `lib/` 里，
而保证"安装侧"成立的那些打包不变式由 `npm run verify` 断言。

## 兼容性

- **Node：** ≥ 22.15.0（CLI 需要解码 Zstandard 帧）。宿主端本身没有版本相关要求。
- **DSH：** `^0.2.0-rc.1`，以对五个宿主包的可选 peer 形式声明——见
  [需要的 DSH 版本](#需要的-dsh-版本)。已对照 `0.2.0-rc.2` 核过：DSH 自带的兼容性判定
  接受本清单，且两半用到的每个接口——`ctx.on`、`ctx.inject`、`ctx.get`、`ctx.effect`、
  `commands.register`、`sessionPersistence.list()/inspect()`，以及 `settings.section`
  槽位配合 `ctx.slots.inject`/`ctx.slots.register`——在 `0.2.0` 线上形状未变。
  [`docs/VERIFICATION.md`](docs/VERIFICATION.md) 记录的是上一次完整的应用内实测，
  那一次跑在 `0.1.2-rc.1` 上。
- **Profile：** 任意。没有 profile 相关代码。
- **网络：** 费率页会通过 HTTPS 取价格与美元汇率，但这完全是可选的：没有外网时仍然
  提供本机缓存的上次结果，手动填写的值照常可用，`rates: false` 则连请求都省掉。

## 卸载

```bash
dsh plugin --profile web remove @chenmiao8563/dsh-token-ledger
```

账本文件是**故意**不删的——要清空历史请自行删除 `<DSH_HOME>/token-ledger/`。

## 开发

```bash
npm install         # 两个 devDependency：react 与 react-dom，供渲染测试使用
npm test            # 297 个测试，14 个文件
npm run verify      # 打包不变式（零依赖、无安装脚本、无裸模块说明符）
```

`npm test` 使用 Node 内置测试运行器。在禁止逐文件 spawn 子进程的受限环境里，
改用 `npm run test:single-process`。

React **只是 devDependency**，消费者永远不会安装它：本包不带任何运行时依赖、不带任何
安装脚本，声明的 peer 全是可选声明、没有任何东西会去抓取，而 pnpm 不会为依赖安装其
devDependencies。它在这里的作用是让
浏览器端能用**真库**渲染并断言——这能抓到替身抓不到的东西：hook 顺序违规与非法
DOM 属性，React 会报出来，而手写的 `createElement` 会默默接受。没装它时这些渲染
测试会带明确原因**跳过而不是失败**，所以全新克隆、无网络也能跑 `npm test`。

**改动的生效方式。** 两半都是在插件**挂载时**读取的，所以改 `lib/index.js` **或**
`lib/client.js` 都需要重启 DSH（或重载插件）——刷新页面不够，因为 bundle 的 `rev`
在挂载时算定，URL 没变浏览器就继续用缓存那份。这是量出来的不是猜的：在隔离 host
运行时，改 `lib/client.js` 之后被服务的 bundle 与它的 `rev`（`d683dd523466`）都纹丝不动；
重启后 `rev` 变成 `f55ee321db50` 并提供了新内容。导出的是文件，所以如果你手上的那份
来自某次修复之前，看时间戳就知道。

实际验证了什么、怎么验证的（包括 fork 规则背后的证据）见
[docs/VERIFICATION.md](docs/VERIFICATION.md)。

## 发布

**打一个 tag 就是发布。不需要在任何地方存放 token。**

```bash
git tag v1.0.0 && git push origin v1.0.0
```

`release.yml` 会跑测试与打包检查、确认 tag 与 `package.json` 一致、用 OIDC
**trusted publishing** 发布、把 tarball 挂到 GitHub Release 上，并在报告成功之前
**断言该版本确实已在 registry 上**——所以绿色意味着已发布，而不只是尝试过。

最后这条断言不是装饰。这条流水线从 v0.5.0 到 v0.8.0 每个 tag 都失败，报的是
`404 Not Found - PUT`——它说的是"包不存在"，而真正的问题是缺凭据；而且失败之后
registry 会有几分钟看起来是空的，尽管发布其实已经成功。两个原因都已在 workflow 里
加了防线：

- **npm CLI 必须 ≥ 11.5.1。** Node 22 自带 npm 10，根本做不了 OIDC 交换。workflow
  跑在 Node 24 上，并把版本打进日志。
- **不能用 `actions/setup-node` 的 `registry-url` 输入。** 它会往 `.npmrc` 写
  `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}`，npm 看到这一行就认定
  "已有凭据"，直接跳过 trusted publishing，报 `ENEEDAUTH`——在一个本就不持有凭据的
  任务里，这读起来像"你忘了登录"。发布前现在有一道步骤：只要 `.npmrc` 里出现
  `_authToken` 就大声失败。

npm 那边要接受它，需要在 npmjs.com 上给包配 **Trusted Publisher**（包 → Settings →
Trusted Publisher → GitHub Actions），仓库填 `chenmiao8563/dsh-token-ledger`、
workflow 填 `release.yml`、**Environment 留空**——填了一个 workflow 没有声明的
environment 就匹配不上，registry 只会回一句
`OIDC token exchange error - package not found`。

在 trusted publishing 出现之前，一个版本的首发是用
`npm publish --access public --otp=<认证器里的 6 位数字>` 手动发的，因为 OIDC 没法在包
存在之前配置。那条路现在依然可用，也依然需要带 2FA bypass 的 granular token，
但它**不产生 provenance 证明**，而且 v0.8.0 那次排查正是从这条路走起——从一个过期的
token 开始。优先用打 tag。

重复运行是安全的：版本已在 registry 上时跳过发布步骤，GitHub Release 只在不存在时创建。

## 许可证

MIT
