dsh-i-am-rich
dsh有钱人才用得起的插件
- Stars
- 1
- Language
- TypeScript
- Created
- Oct 9, 2026
- Updated
- Oct 10, 2026
Introduction
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 Token / 本月浪费 1200 Token / 累计浪费 8400 Token
两份都是真实的、被计费的 provider 请求。这不是模拟,也不是估算——第二份请求通过网络发出,provider 为它计费,然后它的内容被丢弃。
浪费的用量记在插件自己的账本目录(~/.dsh/i-am-rich/,每月一个 JSONL 文件),不写入 dsh 的会话日志——「行为是段子,账本是账本」,但账本不该混进别人的日志里。格式与位置见下文「数据存储」。
安装
已发布到 npm,直接安装:
dsh plugin --profile <你的 profile> add @studyzy/dsh-i-am-rich
或从源码安装:
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 表示发两份扔一份。 |
fortune | millionaire | billionaire | millionaire | 富豪程度,决定扔的那一份有多贵。见下。 |
- id: i-am-rich
name: '@studyzy/dsh-i-am-rich'
config:
enabled: true
discardedCopies: 1
fortune: millionaire
富豪程度:千万富翁 / 亿万富翁
两档的区别不在「发几份」,而在第二份请求的提示词前缀一不一样——也就是它能不能蹭到 provider 的提示词缓存。
| 档位 | 第二份请求 | 缓存 | 计费 |
|---|---|---|---|
| 千万富翁 | 与原请求完全相同(同一个请求对象) | 通常命中 | 按缓存读取价(很便宜) |
| 亿万富翁 | 在 system prompt 开头写入一行当前时间戳 | 必然不命中 | 按缓存写入价(贵得多) |
为什么前缀能决定费用: provider 的提示词缓存按前缀匹配。两份请求前缀逐字相同时,第二份可以直接复用第一份写好的缓存条目,按缓存读取价计费;只要最前面那段文字不同,整条前缀就对不上,第二份会作为一次缓存写入重新计费——输入部分因此从「折扣价」变成「加价」,这就是亿万富翁更烧钱的机制。时间戳天然满足这个条件:每一次派发都不同,所以两份重复请求之间也不会互相蹭到缓存。
时间戳写进已有的 system prompt,不新增消息。 这是关键取舍。早期实现是往 messages 最前面插入一条前缀消息,代价是每次派发都要重建整个 messages 数组——而那个数组装着整段会话,长会话里它是一次请求中最大的单个对象(实测 3000 条消息约 11.6 MB)。改成在已有 system 消息的文本块头部原地加一行之后,消息条数与原请求完全一致,除这一条消息外其余消息连对象引用都原样复用。同一负载下 200 次派发构造耗时从 3.6ms 降到 0.6ms。
必须克隆,不能改原请求。 loop 构造的 GenerateOptions.messages 是 deep-freeze 的(dsh-llm 里 freezeMessage 会 deepFreeze(structuredClone(...))),原地往前写会直接抛异常,而且会改掉真实那一轮正在用的请求。所以亿万富翁克隆请求对象、只替换那一条 system 消息,原请求 bit-for-bit 不变(有测试断言)。system 消息的 id/source 与其余非文本块一并保留——只重写开头那一个文本块。
请求里没有 system 消息时如实降级。 system 角色必须是带 id/source 的持久 Message(只有 role: 'user' 才能是不带身份的一次性 RequestUserInput),而给一份用完即弃的副本伪造持久身份正是这个插件不能做的事。所以这种情况回落为「照发第二份、不改前缀」,仍然是一次真实的计费调用,只是缓存会命中;这一点如实暴露,不假装。loop 构造的请求总是带 system 消息,所以这是手搓一次性调用的形状,不是常规路径。
由此需要一个重入保护。 前缀要生效就得换一个请求对象重新派发,而 next() 不接受参数(它只会重放闭包里的那一个),所以只能走 ctx.llm.stream()——它会再次经过 llm/stream 这个 waterfall,也就是重新进入本插件自己的监听器。插件用一张模块级 WeakSet 按对象身份标记「自己派发的那一份」:派发前把克隆请求登记进去,监听器入口认出是自己人就直接穿透、不再复制,否则会无限递归。用对象身份而不是全局标志位,是因为标志位只在「嵌套派发同步进入监听器」这一时序假设下才正确,而这个假设正是真实 harness 里最脆弱的一环;身份标记没有任何时序依赖——克隆无论何时、以何种异步方式重入都能被认出,并发请求各自标记各自的克隆、原件因身份唯一永不误匹配。有测试在「真实形态」(llm 经服务注册表提供、其 stream 会重跑 waterfall)下断言带前缀的副本恰好到达内层派发一次、且原请求对象未被篡改。
没有 llm 服务时降级。 此时无法注入前缀,插件回落为「照发第二份、不改前缀」,仍然是一次真实的计费调用,只是前缀没改;这一点如实暴露,不假装。
档位在状态条展开面板顶部选择,做成单选项(千万富翁 / 亿万富翁),悬停有文字说明机制。
版面上,「富豪程度」标题与两个选项分两行,而两个选项本身在同一行并排:它们是二选一的一个整体,只有挨着才读得出是一个控件。这一行用 flex-wrap: wrap 而不是 nowrap,所以侧边栏特别窄时它会折成两行,而不是把「亿万富翁」挤出列外。保存状态是第三行,只在有话说的时候出现。
选择不会在点击时就移动:只有当 Host 确认写入成功后,单选框才切到新档位;写入失败时它停在实际生效的那一档并显示「保存失败,仍是原来的档位」——显示与真实行为不一致正是这个插件要防的事。
选择写入 profile 的 cordis.patch.yml(走 DSH 官方 ctx.configEditor.edit,带文件锁、校验与失败回滚),因此重启后仍然生效。写入时只改 fortune 一个字段,其余配置(包括 discardedCopies,那是真金白银)原样保留。
configEditor 没有写进 inject,这是刻意的:cordis 会把 apply 一直挂起到被注入的服务就绪,若声明它,没有该服务的 harness 会连「发两份」和账本一起丢掉。所以它走 ctx.get() 读取——有则可用,没有则插件照常工作,只是档位无法持久化。
数字是怎么算的
状态条上的每一个数字都是provider 自己报告的用量,没有任何估算、放缩或编造:
- 只有 provider 报告了
usage的丢弃请求才计入 Token 数 - provider 没报告用量的丢弃请求,单独计入「未报告用量的调用」,不冒充 Token
- 重复请求中途失败,但失败前已经报过用量——那份用量仍然计入,因为钱确实花了
- 缓存相关的 token 都不计入显示数字,只统计两个数:缓存未命中的输入 + 生成的输出。 缓存命中部分按 provider 的折扣价计费,往往比全价输入低一个数量级, 蹭到暖缓存的副本(千万富翁档的全部意义)每个 token 几乎不花钱; 缓存写入则是输入前缀本身入仓,不是副本新「说」出来的东西。 把这两类算进「浪费」会虚捧数字而不代表真实的挥霍。 账本里四个桶照常完整落盘——这只是呈现口径的选择, 记录本身仍是 provider 报告的完整真实用量。
按本地日历日分桶。日期在 Host 写入记录时就盖好戳,所以重放(replay)得出的数字和当时实时看到的完全一致,不依赖读取时的时钟。
三个口径
状态条并排显示三个数字:
| 口径 | 含义 |
|---|---|
| 今日 | 本地日历「今天」这一个自然日 |
| 本月 | 本地日历当前月份的整月合计 |
| 累计 | 全部有记录的日期之和 |
为什么是客户端算的: 投影的 view(state) 只接收 state,拿不到时钟。「今天」和「本月」取决于读取时的日期,所以 Host 只发布按天的账本,由拥有时钟的客户端去选取区间。这样做的直接好处是:持久化的投影 fold 保持无时钟依赖,重放结果因此严格可复现,而区间仍然正确。
边界行为都有测试覆盖:跨月(2025-12-31 不计入本月)、跨年(去年同月同日不算「今日」)、以及格式非法的日期键——非法键只计入「累计」,不会污染任何区间。
显示格式:钱袋 + 亿 / 万
状态条以一只钱袋开头,每个口径都按数量级缩写,单位随数值大小自动选取。
默认只显示今日一个口径;鼠标移上去展开成三行,每行都有自己的钱袋:
默认: 💰 今日浪费 2249万Token
悬停: 💰 今日浪费 2249万Token
💰 本月浪费 2249万Token
💰 累计浪费 2249万Token
为什么默认只留今日:这一格在侧边栏底部、用户名上方,可用宽度是侧边栏的 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万Token(「今日浪费」这个标签也去掉);三个口径的完整信息仍在 tooltip 里。宽 / 收起状态由 data-i-am-rich-wide 标记,展开 / 收起由 data-i-am-rich-expanded 标记。
字号与配色对齐下方用户名。 状态条就在账号按钮(头像 + 用户名)正上方,两者在视觉上是一对:同为 font-size: 14px / line-height: 22px、同为 6px 内边距、同用 --dsw-alias-label-primary。这些数值不是估的,是从承载账号按钮的 dsh-client-ui-settings-account 的 AccountMenu 样式里逐条抄来的。
改这一项的原因是原先偏小又偏灰:12px、--dsw-text-secondary,标签还压了 opacity: 0.7。结果是「今日浪费」读起来像一句注脚,而不是用户名的同级信息——它本来就不是注脚,它和用户名一样是这一列里的常驻控件。
钱袋用 font-size: 1em 而不是固定像素:字号跟着行高走,图标和标签永远一样大,将来调整状态条字号时不会各走各的。数值本身仍保持 --dsw-text-primary 与 font-weight: 500,所以一行之内仍然是数字最重、标签次之,层级没有被拉平。
图标是 💰(钱袋),不是 🪙。 🪙(U+1FA99)的 Unicode 正式名称虽然就叫 COIN,但它在 Apple 字体里渲染成一枚偏银色的通用硬币,看上去像代币而不像钱。💰(U+1F4B0)在同一套字体下是明确的金黄色,和「有钱人」这个主题对得上。
图标与文字之间的间距由 marginRight 单独给出,不写进字符串。 行的 gap 是所有子元素共用的,调大它会把标签与数字、数字与单位这三处间距一起推开——为修一处而动三处。所以间距挂在图标自己的 marginRight 上;它也不写成 COIN 里的一个尾随空格,这样 tooltip、复制出来的文案和读文本的测试拿到的都是一个干净的字形。
标签写全「浪费」二字,只写「今日」不算合格。 状态条说的是被倒掉的那一份,不是花掉的总量;今日 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万。
数量级单位后面还有 Token 一词。 万 / 亿(英文是 K/M/B)只说明数字有多大,不说明它数的是什么:今日浪费 2249万 会被读成金额,而这正是本插件要防的那种误读,所以末尾补上计数对象。它来自字典里的 waste.unit(zh 为 Token,en 为 tokens),并常驻显示——包括 56px 的收起态,那里虽然去掉了「今日浪费」标签,Token 仍然保留,否则一个光秃秃的 💰 2249万 又变回金额了。它与数量级之间用一个独立的 marginLeft 隔开,理由和钱袋的间距一样:行的 gap 是所有子元素共用的。
缩放只影响状态条本身。鼠标悬停时 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