dsh-i-am-rich 有钱人插件
每次模型请求都发送两份,把第二份直接扔掉,然后如实告诉你今天、本月、累计各浪费了多少 Token。
一个 DeepSeek Harness 插件。它的唯一功能就是真的花掉两倍的钱,并且诚实地把浪费掉的数字显示出来。
设计灵感
这个插件来自一个关于「有钱」的经典段子:
等咱有了钱,喝豆浆吃油条。
想蘸白糖蘸白糖,想蘸红糖蘸红糖。
豆浆买两碗,喝一碗,倒一碗!
油条买两根,吃一根,扔一根!
段子的笑点不在「有钱」,而在浪费被摆到明面上——买两碗就是为了倒一碗,倒掉的那碗才是重点。
dsh-i-am-rich 就是把这个段子原样搬进 LLM 调用里:
| 段子 | 插件 |
|---|
| 豆浆买两碗 | 每次模型请求真的发两份 |
| 喝一碗 | 第一份正常返回给 agent loop,是你真正在用的回复 |
| 倒一碗 | 第二份完整收完,然后直接扔掉 |
| 想蘸白糖蘸白糖,想蘸红糖蘸红糖 | discardedCopies 想扔几份扔几份 |
| —— | 状态条如实显示「倒掉了多少」,今日 / 本月 / 累计 |
| —— | enabled: false:今天不想摆阔,豆浆只买一碗 |
两处刻意的对应:
- 倒掉的那碗是真的倒了。 第二份请求真的通过网络发出、真的被 provider 计费、内容真的被丢弃。不模拟、不估算、不假装——段子的笑点建立在「真花了钱」之上,插件也必须是真花钱。
- 倒掉的那碗要数清楚。 段子里没人统计倒了多少,这个插件偏要统计,而且只用 provider 自己报告的
usage,一个 token 都不编。这是整个插件唯一严肃的地方:既然钱真的花了,账就得是真的。
换句话说:行为是段子,账本是账本。 花两倍的钱是玩笑,把这两倍的钱如实记下来不是。
它做什么
一次模型请求
├─ 第 1 份 ──> 正常返回给 agent loop(你真正在用的回复)
└─ 第 2 份 ──> 完整收到,然后扔掉
│
└─> 记录 provider 为此报告的真实用量
│
└─> 侧边栏左下角(用户名上方):🪙 今日浪费 120 / 本月浪费 1200 / 累计浪费 8400
两份都是真实的、被计费的 provider 请求。这不是模拟,也不是估算——第二份请求通过网络发出,provider 为它计费,然后它的内容被丢弃。
浪费的用量记在插件自己的账本目录(~/.dsh/i-am-rich/,每月一个 JSONL 文件),不写入 dsh 的会话日志——「行为是段子,账本是账本」,但账本不该混进别人的日志里。格式与位置见下文「数据存储」。
安装
这个包目前是 private: true,尚未发布到 npm。从源码安装:
git clone https://github.com/studyzy/dsh-i-am-rich.git
cd dsh-i-am-rich
pnpm install
pnpm run build
然后把它装进你的 DSH profile。dsh plugin 是 pnpm 的透传,所以用 add 加一个 link: 依赖指向仓库:
dsh plugin --profile <你的 profile> add link:/path/to/dsh-i-am-rich
注意:内置的 desktop profile 由 Electron 应用独占管理,dsh plugin --profile desktop ... 会被拒绝(profile "desktop" is managed exclusively by the Electron application)。要装进 desktop,请用应用自带的 runtime/cli/bin/dsh,或直接编辑 ~/.dsh/profiles/desktop/package.json 并跑一次收敛。
最后把 cordis.patch.yml 合进你的 profile:
- insert:
- id: i-am-rich
name: '@studyzy/dsh-i-am-rich'
id 必须是 i-am-rich:插件改名后运行时 id 变了,旧配置里的 rich-person 不会再匹配,禁用开关之类的配置会静默失效。
配置
| 字段 | 类型 | 默认值 | 含义 |
|---|
enabled | boolean | true | 是否重复发送请求。关掉后插件仍加载、状态条仍在,但不再花钱。 |
discardedCopies | number | 1 | 每次请求额外丢弃几份。1 表示发两份扔一份。 |
- id: i-am-rich
name: '@studyzy/dsh-i-am-rich'
config:
enabled: true
discardedCopies: 1
数字是怎么算的
状态条上的每一个数字都是provider 自己报告的用量,没有任何估算、放缩或编造:
- 只有 provider 报告了
usage 的丢弃请求才计入 Token 数
- provider 没报告用量的丢弃请求,单独计入「未报告用量的调用」,不冒充 Token
- 重复请求中途失败,但失败前已经报过用量——那份用量仍然计入,因为钱确实花了
按本地日历日分桶。日期在 Host 写入记录时就盖好戳,所以重放(replay)得出的数字和当时实时看到的完全一致,不依赖读取时的时钟。
三个口径
状态条并排显示三个数字:
| 口径 | 含义 |
|---|
| 今日 | 本地日历「今天」这一个自然日 |
| 本月 | 本地日历当前月份的整月合计 |
| 累计 | 全部有记录的日期之和 |
为什么是客户端算的: 投影的 view(state) 只接收 state,拿不到时钟。「今天」和「本月」取决于读取时的日期,所以 Host 只发布按天的账本,由拥有时钟的客户端去选取区间。这样做的直接好处是:持久化的投影 fold 保持无时钟依赖,重放结果因此严格可复现,而区间仍然正确。
边界行为都有测试覆盖:跨月(2025-12-31 不计入本月)、跨年(去年同月同日不算「今日」)、以及格式非法的日期键——非法键只计入「累计」,不会污染任何区间。
显示格式:金币 + 亿 / 万
状态条以一枚金币开头,每个口径都按数量级缩写,单位随数值大小自动选取。
默认只显示今日一个口径;鼠标移上去展开成三行,每行都有自己的金币:
默认: 🪙 今日浪费 2249万
悬停: 🪙 今日浪费 2249万
🪙 本月浪费 2249万
🪙 累计浪费 2249万
为什么默认只留今日:这一格在侧边栏底部、用户名上方,可用宽度是侧边栏的 264~420px(默认 280),而三个带标签的口径排在一行约需 330px——默认宽度下根本放不下。所以静止态只给一个口径,把详情交给悬停。
展开是竖排三行,不是一行三列,这是宽度决定的:每行只有一个口径,所以面板只需要「最长那一行」的宽度就够,能老实待在侧边栏列内。若并排成一行,就得按内容宽度铺开(约 330px)并压到侧边栏外面去。展开面板因此是 flex-direction: column + width: 100%,只是从文档流里抬起(position: relative + 背景 + 阴影)盖住下方的账号按钮,避免悬停时把它顶来顶去。
每行的金币是重复渲染的,不是整个面板共用一枚:三行各有各的金币,才读得出是三个独立数字,而不是一句被折行的句子。
每一行都设为 flex-wrap: nowrap,行的内部不会再断行——「三行」就是三行,不会变成四行。
悬停展开用 React state(onMouseEnter / onMouseLeave)而不是 CSS :hover:显示几个口径是渲染决定,CSS 选择器能改样式,但没法把本月与累计两个 span 变出来。这也和 shell 自己的底部控件(账号菜单)做法一致。
侧边栏收起时只有 56px,这是宽度约束、悬停也解不开,所以收起态始终只显示 🪙 2249万(标签也去掉);三个口径的完整信息仍在 tooltip 里。宽 / 收起状态由 data-i-am-rich-wide 标记,展开 / 收起由 data-i-am-rich-expanded 标记。
标签写全「浪费」二字,只写「今日」不算合格。 状态条说的是被倒掉的那一份,不是花掉的总量;今日 2249万 会被读成消费额,正好是这个插件要区分的反面。所以三个标签都是完整短语:今日浪费 / 本月浪费 / 累计浪费(英文对应 Wasted today / Wasted this month / Wasted all time)。有测试逐个口径断言这两个字必须在场,防止被简化回去。静止态也保留完整标签——变的只是口径个数,不是文案。收起态是唯一例外,那里连标签都放不下。
| 数值范围 | 显示 | 例子 |
|---|
| < 1 万 | 原样整数 | 8421 |
| 1 万 ~ 1 亿 | 万 | 2249万 |
| ≥ 1 亿 | 亿(两位小数) | 2.25亿 |
总是选「能装得下的最大单位」,而不是固定写成「亿 + 万」的组合——那样 2249 万会被写成读起来别扭的 0亿2249万。四舍五入在最后一步做一次,并且可能把一个数进位到上一级:99,999,999 显示为 1亿,而不是 10000万。
缩放只影响状态条本身。鼠标悬停时 tooltip 给出精确整数(含千分位),因为缩放是呈现选择,账本才是记录:
今日 31,114,724 · 本月 31,114,724 · 累计 31,114,724
英文环境不照搬「万 / 亿」——英文读者不按这两个单位分组,因此 en 走 K/M/B(22.49M),而 zh 走万/亿。当前采用的是哪套刻度可以从 DOM 上的 data-i-am-rich-scale(zh / en)读出来。
状态条常驻显示
状态条从插件挂载的那一刻起就显示,还没有任何浪费记录时显示三个 0。
这是刻意的:状态条是这个插件唯一可见的证据,而一个什么都不渲染的侧边栏条目,和一个加载失败的插件在界面上完全一样。如果「没有记录就不渲染」,就分不清「已正确挂载」和「没挂上」——这正是早期版本让人以为「装了没反应」的原因。空状态用 data-i-am-rich-waste="empty" 标记,数字仍然是 0 而不是隐藏。
状态条挂在 sidebar.footer.action(kind: list,由 ui-sidebar 声明),位于侧边栏底部、用户名(账号按钮)正上方。侧边栏在同一列里先渲染 footerActions、再渲染 settingsArea,而账号按钮(头像 + 用户名)正是 settingsArea 里的 sidebar.settings;所以这一格在版面上就落在用户名上面。槽位名是硬约束:slots.inject 只会对「已被声明的槽位」执行回调,注册到一个没有任何 bundle 声明的槽位既不报错也不渲染——这正是本插件曾经「装了但看不见」的真正原因。
数据不经过会话 store:状态条挂载时立刻 fetch 一次账本,之后每 15 秒轮询一次,标签页重新可见时也会补一次。轮询失败时状态条保留最后一次成功的数据并在 tooltip 里注明「账本暂不可用」,绝不静默清零——冻结的数字必须让人看出来是冻结的。
数据存储
浪费账本存放在独立目录,不写入 dsh 的会话日志:
$DSH_HOME/i-am-rich/waste-YYYY-MM.jsonl # DSH_HOME 缺省为 ~/.dsh
- 每月一个仅追加的 JSONL 文件,一行一次丢弃请求:
{"v":1,"wasteId":"…","provider":"deepseek","model":"…","outcome":"discarded","day":"2026-10-10","usage":{"inputTokens":100,"outputTokens":20}}
v 是行格式版本;参与折叠的只有 day 和 usage,wasteId/provider/model/outcome 是给人读的事实字段。
- Host 通过
GET /api/i-am-rich/waste 把按天折叠好的账本发布给浏览器(带 1 秒读缓存,多个轮询标签页共享一次磁盘读)。
- 崩溃最多留下末尾半行;读取时跳过解析失败的行,一个月里的一个坏行不会吞掉其余的账。
- 追加是单行
O_APPEND 写,本地文件系统上实际原子;账本按单实例设计,不做跨进程加锁。
- 旧会话日志里的历史 waste 记录不再读取:账本迁移到独立目录后,旧记录不迁移、不合并,数字从此从新文件开始。
已知代价
诚实地说清楚这个插件的代价:
- 账单是真的两倍。 这是插件的全部意义,不是 bug。
- 重复请求不在会话日志里。 浪费账本记在独立目录(见下文「数据存储」),会话日志完全干净——代价是「某个会话当时浪费了多少」无法按会话回溯,账本只有全局视角。
- 延迟与速率压力更高。 重复请求和原请求并发发出,会占用额外的并发额度。
版本要求
记账不再依赖任何 harness 的会话记录 API,只要求宿主提供:
- 可写的用户目录(
DSH_HOME 或 ~/.dsh,不存在时自动创建);
- Web UI 的
connection 服务(注册 /api 路由)。headless 环境没有它时,插件照常发重复请求、照常写账本文件,只是状态条没有数据可拉,并告警一次。
connection 是声明在 inject 里的,不是直接读取的:
export const inject = ['connection']
这一点曾经写错,代价是状态条永远显示 0。cordis 在一个已激活的插件 fiber 上读取没有声明 inject 的服务会抛异常
(cannot get property "connection" without inject),而不是返回 undefined。
当时的代码用 try/catch 把这个异常当成「宿主没有 Web 客户端」吞掉了——于是 /api/i-am-rich/waste 这条路由根本没注册,
可账本文件却被正常写入。两种情况的界面表现完全一样(状态条一个数字都不涨),所以这个 bug 静默了很久,
排查时一度误判为「客户端缓存」或「取数失败」。
声明 inject 同时解决两件事:读取变成合法操作,且 cordis 会等 connection 就绪后才调用 apply,
从而消除插件 insert 位置带来的启动时序竞争。回归由 tests/ledger-server.spec.ts 锁住,
它用一个真实的 cordis fiber 复现那条 without inject 异常,并断言路由确实抵达 connection.fetch.register。
为什么状态条只显示一份
discardedCopies: 2 时会发三份(一份真的 + 两份扔的),状态条只统计被扔掉的那两份。它统计的是「浪费」,不是「总量」——用掉的 Token 不算浪费。
开发
pnpm install
pnpm run check # lint + typecheck + typecheck:tests + test + build
pnpm test
测试里最关键的一条是 tests/i-am-rich.spec.ts 的 invokes the underlying adapter twice:它数的是 adapter 被调用的次数。只有这个断言能证明真的发出了第二份请求——测下游监听器数量是证明不了的。
完整的开发环境与约定见 CONTRIBUTING.md,版本变更见 CHANGELOG.md。
文件结构
src/index.ts Host 插件:挂 llm/stream,发重复请求,追加账本文件,注册 /api 路由
src/waste.ts 纯函数:把丢弃用量折叠成每天的总数与三个口径,并把数值缩放到亿/万
src/ledger-file.ts 独立账本文件:按月 JSONL 的唯一读写口(DSH_HOME/i-am-rich)
src/ledger-server.ts /api/i-am-rich/waste 路由:按天账本 + 1 秒读缓存
src/types.ts 账本行类型定义(non-surface)与行格式版本
src/brand.ts WasteId 名义化包装
src/client/index.ts 浏览器半边入口:注册 sidebar.footer.action 槽位与字典
src/client/StatusBar.tsx 状态条组件(无状态视图 + 悬停展开与轮询包装)
src/client/locales.ts zh(真值源)/ en 字典
src/client/contracts.ts 刻意收窄的浏览器内核类型面
tests/ 5 个 spec
cordis.patch.yml 插入插件行的 profile patch
tsdown.config.ts 双产物构建:lib/index.js(ESM,Node)+ lib/client.js(CJS,浏览器)
License
MIT