Cost ledger across all chats: after every answer it records the chat cost (as /cost), tokens per model and model calls made by other mods. /ledger shows today…

A cost ledger across all your chats. After every answer, cost-ledger records what the chat has cost so far (the same value as /cost), plus the model calls made by other mods (sidekick, quick-replies …). /ledger shows today, 7 days, 30 days and all time, the history of the last 14 days, your projects and your most expensive chats in an overview styled like Claude Code (theme colors). The history shows 14 days or 14 weeks, each bar split by model; model calls by other mods appear as their own parts (e.g. “Sonnet 5.5 · mods”). On a subscription, /ledger limits shows how much API value went into the current 5-hour and weekly window and into your subscription month. It is a pure observer: no budget, no intervention, no model calls of its own.
Texts are English by default; set
languagetodefor German.
Tested with Claude Code v2.1.295 · Plugin version 0.6.0 · Requires Claude Code v2.1.271 or later (setting options)
| Input | Effect | ||
|---|---|---|---|
/ledger | Overview: today, 7 days, 30 days, all time; history of the last 14 days, each bar split by model (with legend, chat and mod calls separate); projects, most expensive chats, mods | ||
/ledger weeks | The same overview with the last 14 calendar weeks instead of days | ||
| `/ledger chats [7\ | 30\ | all]` | The 20 most expensive chats in the period (default 30 days; any number of days from 1 to 3650 works) |
| `/ledger projects [7\ | 30\ | all]` | All projects in the period |
| `/ledger models [7\ | 30\ | all]` | Models: share of the estimated API value, answers, input, output and cache tokens (including subagents and mod calls) |
/ledger limits | Subscription limits: the current 5-hour and weekly window (% used, API value, reset time with countdown, projection “100 % ≈ $X”), your subscription month (API value against the plan price, factor, next renewal) and the last 10 five-hour windows with their average | ||
| `/ledger plan <plan> <day\ | today> [price]` | Set your plan and billing day: pro, max5 (5x), max20 (20x), team or enterprise; day 1–31, or today if your plan renewed today; monthly price in $ (comma or dot). Without a price, the list price is used: Pro $20, Max 5x $100, Max 20x $200; Team and Enterprise have none. Example: /ledger plan max20 14 | |
/ledger plan | Show the plan setting and the syntax; /ledger plan off deletes it | ||
/ledger reset | Delete everything, after a confirmation (Cancel is recommended and listed first). The plan setting stays | ||
/ledger help | Help as a table: all commands, the current state (plan, storage used, recording since) and your settings with their values, plus how to change them and how to turn the mod off. Also /ledger ? |
Older German arguments still work as aliases: hilfe for help, alle for all, heute for today, aus for off. An unknown command word (the first word after /ledger) answers with a pointer to /ledger help. /ledger days is gone; it only showed the overview.
Help: /ledger help draws a table in the terminal and the desktop app (accent green), with the state as of the moment you ran it. Run it again to see a change. Elsewhere (claude -p, VS Code) you get a short Markdown version, which is also what Claude reads.
On a subscription, /ledger also shows a compact line at the top: 5 h 62 % · $18.40 · reset 18:00 │ week 98 % · $212.10 │ plan $812.40 = 4.1× · /ledger limits. It is left out when there is neither a limit window nor a plan.
Why /ledger plan? Claude Code does not tell mods which plan you have or when it renews, and cost-ledger never reads your login data. So you set it once. Until then the subscription month shows “Plan not set”.
What gets counted:
usage().cost.usd, booked to today. Subagents are included (verified). Remaining costs are booked at session end and before /ledger.$.model.complete / fork / classify call by another mod, with count and amount, priced with cost-ledger's own price table (Haiku 5.5 and Sonnet 5.5 as of 2026-10-07, the other models as of 2026-09-25). classify only counts the number of calls. These costs are not part of /cost and are added on top (verified)..claude/worktrees/ count toward the main folder. Runs with claude -p are listed as Script runs.-p script runs). These 50 characters are kept in the local plugin store. Chats without a message are called Chat from Oct 6 14:05 (date and time of the first booking).usage().rateLimits: % used and reset time). A window is identified by its reset time. If the window has already reset and no new answer has reported the next one, the amount waits and goes into the next window. % used is the highest value seen in the window./ledger plan), only from chats that saw subscription limits (runs with an API key are left out). Factor = API value ÷ plan price.host/owner/repo)./cost value. Not included in the model view: compaction and Claude Code's internal calls, so the sum over models is slightly below /cost.All amounts are API values. On a subscription they count toward your usage limits, not toward a bill.
Runs in: terminal and the desktop Code tab (its own rendering at the command line, colors from the theme). Terminal verified with 0.3.x; desktop verified with 0.3.1 on 2026-10-06 (0.3.0 fell back to the Markdown summary there, fixed in 0.3.1). The 0.5.0 views (/ledger limits, the limit line) are covered by rendering tests for both surfaces; a visual check is pending. The help table (0.6.0) was checked in the terminal. In claude -p and other surfaces you get the Markdown summary. Claude only reads that summary (at most 10 lines for the views, about 15 for the help), not the rendering.
/plugin → cost-ledger → settings (userConfig):
| Key | Title in /config | Meaning | Default |
|---|---|---|---|
language | Language / Sprache | Language of the overview, summary, help and confirmation: en or de | en |
keepDays | Retention (days) | Chats with no new bookings for this long are deleted by cost-ledger on the next /ledger | 365 |
dayYellow | Daily amount yellow from ($) | Daily amount (chat + mods) from which the day's value in the history turns yellow | 3 |
dayRed | Daily amount red from ($) | Daily amount from which it turns red | 8 |
language switches all texts the mod shows: the /ledger overview and its views, the Markdown summary Claude reads, the help, the reset confirmation, and the formats (en: $14.44, Oct 6, W41; de: 14,44 $, 06.10., KW 41). The default is en; set it to de for German. A change applies to the next /ledger. Stored data does not depend on the language.
claude plugin validate shows:
hooks: session.start, classic.SessionStart, classic.UserPromptSubmit, turn.complete, model.complete, model.fork, model.classify, session.end, command.run{command=ledger}, ui.render{component=CommandOutput, props has {command=ledger}}
calls: $.clock.now, $.command.register, $.session.repo, $.session.root, $.session.surfaces, $.session.usage, $.store.delete, $.store.get, $.store.keys, $.store.set, $.ui.ask, $.ui.log
In plain language, the hooks:
session.start: sets the starting point of the chat's cost counter, reads the project, registers /ledger.classic.SessionStart: the chat's ID and title; /clear, /resume and branching switch the chat without a new start.classic.UserPromptSubmit: names a chat after the start of your first own message when Claude Code reports no title (at most 50 characters, not for commands or -p runs), and finds the chat's ID again after the mod reloads. The prompt itself is neither changed nor stored beyond those 50 characters.turn.complete: books the chat's cost after every answer.model.complete, model.fork, model.classify: see the calls other mods make, to count them (below).session.end: books the rest when the chat ends.command.run{command=ledger}: the /ledger command.ui.render{component=CommandOutput, props has {command=ledger}}: draws the overview, the other views and the help in place of the command's text line.And the calls:
$.command.register: registers /ledger.$.session.usage: reads the chat's cost, without breakdown, so no extra request; from the same call the context fill and the rate-limit windows (% used, reset time) for /ledger limits. Mod calls use the last reading and do not call it again.$.session.surfaces: tells desktop, terminal and script runs apart.$.session.repo, $.session.root: the chat's project name; also the Git remote, stored only as host/owner/repo, never with credentials.$.store.get/set/keys/delete: the ledger in the mod's own plugin store, one entry per chat; cleanup and reset; the plan setting from /ledger plan (plan, billing day, price). /ledger help only reads it (storage used, plan, recording since).$.ui.ask: confirmation before /ledger reset.$.ui.log: errors go to the debug log ({to:'debug'}), never into the transcript.$.clock.now: current date and timestamps.The model.* hooks only read the result of a call (usage, origin) and pass it on unchanged. No model calls, no files, processes or network targets.
Add the marketplace once, then install the mod:
claude plugin marketplace add FynnXland/fynn-mods
claude plugin install cost-ledger@fynn-mods
Inside a session the same works with /plugin marketplace add FynnXland/fynn-mods and /plugin install cost-ledger@fynn-mods. The mod loads in the next session, or after /reload-plugins.
Check: /plugin shows … mod active · cost-ledger.
Update: claude plugin update cost-ledger@fynn-mods, or turn on auto-update for fynn-mods under Marketplaces in /plugin.
Remove: disable it under Installed in /plugin, or run claude plugin uninstall cost-ledger@fynn-mods.
Try it for one session without installing (from a clone of the repo):
claude --plugin-dir <path-to-clone>/mods/cost-ledger
Install in user scope (the default), so the mod records in every project. It only books chats in which it is loaded, and only chats started after installation.
The plugin store is tied to the plugin ID. What gets recorded while the mod is loaded via --plugin-dir (cost-ledger_inline-…) is not visible to the marketplace install (cost-ledger_fynn-mods-…), and vice versa.
session.end), the remainder since the last answer is missing. If the chat is resumed later, cost-ledger books that remainder then./resume in the middle of a session is only covered by a test, not verified in a real session (it cannot be triggered in -p). Assumption: usage().cost afterwards reflects the resumed chat (as when starting with --resume, which is verified). If the counter instead kept running per process, the previous chat's cost would be booked twice onto the resumed one.deny) do not count. Haiku 5.5 is priced by prompt length: above 100,000 prompt tokens (input + cache reads + cache writes) the whole call costs five times as much. cost-ledger applies this only to single mod calls (model.complete). A chat turn and a fork report their responses summed, so their per-model estimate always uses the lower Haiku 5.5 rate and can be too low when a single request in them was over 100,000 tokens.haiku: a mod that calls $.model.complete({ model: 'haiku' }) gets Claude Haiku 5.5 on Claude Code v2.1.293 and later (verified with v2.1.295), Haiku 4.5 before. The call's result does not say which model answered, so cost-ledger prices haiku as Haiku 5.5. On an older Claude Code such calls are booked about ten times too low, and if you point haiku elsewhere with ANTHROPIC_DEFAULT_HAIKU_MODEL, they are still priced as Haiku 5.5./cost value. Claude Code v2.1.291 still priced Haiku 5.5 like Haiku 4.5 (about ten times too much); v2.1.295 prices it correctly.cleanupPeriodDays. A 365-day retention assumes chats with the mod loaded keep running.$.store has 4 MiB in total. With the data collected (including the limit windows since 0.5.0), a chat needs about 0.7–1.1 KB per day on which it runs. That is enough for roughly 4,000–6,000 chat-days; with very many -p runs, lower keepDays. /ledger warns from 75 % and reports a write error./ledger lines only show the Markdown summary after the session restarts (the rendering's data lives only in the process memory).--plugin-dir) and from the marketplace, each copy keeps its own ledger, because the plugin store is tied to the plugin ID. Chats recorded by one copy do not appear in the other.turn.complete reports it).% used covers your whole account, including claude.ai, the mobile app and chats where cost-ledger is not loaded. The dollar amounts only come from chats with cost-ledger. The projection “100 % ≈ $X” (API value ÷ % used × 100) and the average over past windows are therefore estimates: they come out too low when you also use Claude elsewhere.% used./ledger limits shows “reset passed” until the next answer. Countdowns are as of the moment you ran the command; the view does not refresh by itself.hooks/register.ts 572 lines1// cost-ledger: Kostenbuch über alle Chats. Reiner Beobachter: bucht nach jedem Turn die Differenz von `usage().cost.usd`,
2// dazu die Modellaufrufe anderer Mods, und zeigt per /ledger eine Übersicht (Sprache aus userConfig `language`).
3// Fail-open: Jeder Erfassungs-Hook gibt das Ergebnis von `next` unverändert zurück, Fehler gehen nur ins Debug-Log; kein `.catch`.
4// Ein $.store-Schlüssel pro Session (`s:<sessionId>`), den nur diese Session schreibt: $.store ist nicht atomar (CHEATSHEET).
5import type { EngineInterface, On } from 'claude-code'
6import { helpMarkdown, helpTree } from './help.ts'
7import type { HelpData } from './help.ts'
8import { T, dateTime, shortDate, usd } from './i18n.ts'
9import {
10 HELP_WORDS,
11 NO_FOLDER,
12 OFF_WORDS,
13 PLANS,
14 VIEW_WORDS,
15 ledgerHelp,
16 aggregate,
17 baselineFor,
18 bookChat,
19 bookLim,
20 bookLimAt,
21 bookMod,
22 bookModel,
23 bookRate,
24 bookTurn,
25 cleanPlan,
26 cleanRemote,
27 callCost,
28 cleanRec,
29 cleanSettings,
30 dayKey,
31 DAY,
32 deltaOf,
33 limitsOf,
34 newRec,
35 parsePlan,
36 parseRange,
37 planLabel,
38 planMonth,
39 pluginName,
40 projectOf,
41 summaryText,
42 titleFromPrompt,
43 tokensOf,
44} from './logic.ts'
45import type { Carry, HelpInfo, Kind, Limit, Plan, Rec, Settings, TurnInfo, Usage } from './logic.ts'
46import { ledgerTree } from './view.ts'
47import type { View } from './view.ts'
48
49const SELF = 'cost-ledger'
50
51let settings: Settings = cleanSettings(undefined)
52let sessionId = '' // aus classic.SessionStart (kommt vor session.start), nach einem Modul-Reload aus UserPromptSubmit
53let rec: Rec | null = null // eigener Datensatz, im Speicher führend; geschrieben wird er immer ganz
54let seen = 0 // Baseline: letzter gebuchter Stand des Zählers in diesem Prozess
55let rebase = false // Sessionwechsel mitten im Prozess (Resume, Branch): Baseline beim nächsten Messen neu setzen
56let started = false
57let hasCost = true
58let lastModel = '' // Modell der Hauptschleife, für model.fork
59let title = ''
60let titleDirty = false
61let titleFromSession = false // Titel kam als session_title (gewinnt immer) statt aus der ersten Nachricht
62const meta: { project: string; root: string; kind: Kind; remote: string } = { project: NO_FOLDER, root: '', kind: 'terminal', remote: '' }
63// Kennung im Text → Daten der Zeichnung, höchstens 10: eine Ansicht von /ledger oder die Hilfe, dazu die Argumente des
64// Aufrufs. Der Render-Hook zeichnet nur, wenn die Zeile dieselben Argumente hat: `/ledger #<kennung>` gibt „Unbekannt: …“
65// mit der Kennung in der ersten Zeile zurück und darf trotzdem nicht als Tabelle erscheinen (Review worklist 0.7.0, K2).
66type Drawn = ({ kind: 'ledger'; view: View } | { kind: 'help'; data: HelpData }) & { args: string }
67const reports = new Map<string, Drawn>()
68let reportNo = 0
69// Akzent der Hilfe als Theme-Key: passt sich hell und dunkel an (docs/HELP-SPEC.md §4, Fynn 2026-10-09)
70const ACCENT = 'success'
71
72/** Argumente vergleichbar machen: ohne Rand, klein, einfache Leerzeichen. */
73const normArgs = (s: string | undefined) => String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ')
74
75/** Neue, eindeutige Kennung `#…` für die erste Textzeile; ui.render findet darüber die Daten der Zeichnung. */
76function remember(now: number, d: Drawn): string {
77 const tag = `#${(++reportNo).toString(36)}${now.toString(36).slice(-5)}`
78 reports.set(tag, d)
79 while (reports.size > 10) reports.delete(reports.keys().next().value as string)
80 return tag
81}
82let chain: Promise<unknown> = Promise.resolve() // Messen und Schreiben dieser Session nacheinander
83let writeError = '' // letzter Schreibfehler (z. B. Speicher voll), /ledger zeigt ihn
84// Limit-Fenster der letzten Messung: Mod-Aufrufe buchen hierhin, ohne eigenes usage() (SPEC Nachtrag 0.5.0, Verhalten 2)
85let limits: Limit[] = []
86let carry: Carry = {} // Beträge, die auf das nächste gültige Fenster warten
87let carryAt = 0 // seit wann `carry` Beträge hält; ein späterer Reset (auch aus einem anderen Chat) verwirft sie
88let limSinceChecked = false // `meta:limSince` in diesem Prozess schon geprüft
89let pendingGap = 0 // Nachbuchung beim Start (Resume): gehört ins Fenster der letzten Buchung, nicht ins laufende
90let gapAt: number | undefined // Zeitpunkt der letzten Buchung beim Start; spätere Mod-Aufrufe verschieben ihn nicht
91
92/** `carryAt` nach einer Fenster-Buchung nachführen. */
93function noteCarry(now: number) {
94 if (!Object.keys(carry).length) carryAt = 0
95 else if (!carryAt) carryAt = now
96}
97
98/** `fn` nach allem, was schon in der Kette steht; ein Fehler bricht die Kette nicht. */
99function serial<T>(fn: () => Promise<T>): Promise<T> {
100 const p = chain.then(fn, fn)
101 chain = p.catch(() => undefined)
102 return p
103}
104
105const msg = (err: unknown) => String((err as Error)?.message ?? err).slice(0, 200)
106
107function log($: EngineInterface, text: string) {
108 try {
109 $.ui.log(`cost-ledger: ${text}`, { to: 'debug' })
110 } catch {
111 // Debug-Log ist Beiwerk
112 }
113}
114
115/** Desktop, Terminal oder Skript-Lauf (`-p`: keine Surface, API-DETAILS.md:303-304). */
116function kindOf(surfaces: readonly string[]): Kind {
117 if (surfaces.includes('desktop')) return 'desktop'
118 return surfaces.length ? 'terminal' : 'script'
119}
120
121async function load($: EngineInterface, id: string): Promise<Rec | null> {
122 return cleanRec(await $.store.get(`s:${id}`))
123}
124
125async function save($: EngineInterface) {
126 if (!rec || !sessionId) return
127 try {
128 await $.store.set(`s:${sessionId}`, rec)
129 writeError = ''
130 } catch (err) {
131 writeError = msg(err)
132 throw err
133 }
134}
135
136/**
137 * Hat eine andere Session `/ledger reset` ausgeführt, seit dieser Datensatz zuletzt gebucht wurde, beginnt er leer;
138 * sonst schriebe dieser Chat die gelöschten Beträge zurück ($.store ist geteilt, interface.md:821-826).
139 */
140async function honorReset($: EngineInterface) {
141 const held = Object.keys(carry).length > 0
142 if (!rec && !held) return
143 const at = await $.store.get('meta:resetAt')
144 if (typeof at !== 'number') return
145 // Wartende Fenster-Beträge von vor dem Reset, auch ohne Datensatz (z. B. nach /clear in eine neue Session)
146 if (held && carryAt < at) {
147 carry = {}
148 carryAt = 0
149 }
150 if (rec && rec.lastAt < at) {
151 rec.days = {}
152 rec.mods = {}
153 rec.models = {}
154 rec.modModels = {}
155 rec.act = {}
156 rec.hours = {}
157 rec.rl = {}
158 rec.lim = {}
159 }
160}
161
162/**
163 * An eine Session binden. Mitten im Prozess (`started`): bei /clear beginnt der Zähler bei 0 (types:11148-11151), sonst
164 * (Resume, Branch, Reload) wird die Baseline beim nächsten Messen aus dem Datensatz gesetzt.
165 */
166async function bind($: EngineInterface, id: string, source: string | undefined, newTitle: string | undefined) {
167 sessionId = id
168 rec = await load($, id)
169 pendingGap = 0 // gehört zur bisherigen Session
170 gapAt = undefined
171 title = newTitle || rec?.title || ''
172 titleDirty = !!newTitle && newTitle !== rec?.title
173 titleFromSession = !!newTitle
174 if (!started) return
175 if (source === 'clear') seen = (await $.session.usage()).cost?.usd ?? 0
176 else rebase = true
177}
178
179/**
180 * Messpunkt (SPEC Verhalten 4): Zähler lesen, Differenz auf heute buchen, dazu Tokens, Aktivität und Limits des Turns.
181 * `final`: am Session-Ende nur das Nötigste (Budget 1,5 s, CHEATSHEET Limits).
182 */
183async function measure($: EngineInterface, final = false, usage?: Usage & { model?: string }, turn?: TurnInfo): Promise<void> {
184 // Ein Aufruf ohne `breakdown` (kostenlos): Kosten, dazu vorsorglich Kontext-Füllung und Limit-Stände
185 const u = await $.session.usage()
186 const cost = u.cost?.usd
187 const counted = typeof cost === 'number' && Number.isFinite(cost)
188 hasCost = counted // Host ohne Kostenbuch: keine Chat-Buchung, /ledger nennt den Grund; Tokens je Modell trotzdem
189 // Fenster der letzten API-Antwort (types:11386-11390); leer ohne Abo oder vor der ersten Antwort des Prozesses
190 limits = limitsOf(u.rateLimits)
191 if (!sessionId) return
192 let delta = 0
193 if (counted) {
194 if (rebase) {
195 rebase = false
196 seen = baselineFor(rec, cost)
197 }
198 const d = deltaOf(seen, cost)
199 seen = d.seen
200 delta = d.delta
201 }
202 const hasUsage = tokensOf(usage) > 0
203 if (!(delta > 0) && !(titleDirty && rec) && !hasUsage && !turn) return
204 const now = await $.clock.now()
205 if (!final && meta.kind === 'script') meta.kind = kindOf(await $.session.surfaces()) // Desktop meldet sich evtl. erst später
206 await honorReset($)
207 const r = rec ?? newRec({ ...meta, title }, now, 0)
208 if (r.kind === 'script' && meta.kind !== 'script') r.kind = meta.kind
209 if (titleDirty) r.title = title
210 if (counted) bookChat(r, dayKey(now), delta, now, cost)
211 if (hasUsage) bookModel(r, usage?.model ?? '', dayKey(now), usage, now)
212 if (turn) bookTurn(r, dayKey(now), turn, delta, u.context?.percent)
213 let windowed = false
214 if (turn || delta > 0) {
215 bookRate(r, dayKey(now), u.rateLimits)
216 // Der Rest, der beim Start schon auf dem Zähler stand, fiel vor diesem Prozess an: ins Fenster der letzten Buchung
217 const gap = Math.min(pendingGap, delta)
218 pendingGap = 0
219 windowed = bookLim(r, limits, now, 'chat', delta - gap, carry)
220 if (gap > 0 && gapAt !== undefined) bookLimAt(r, gapAt, gap)
221 gapAt = undefined
222 noteCarry(now)
223 }
224 rec = r
225 titleDirty = false
226 await save($)
227 // Beginn der Fenster-Daten für „unvollständig“; einmal je Prozess, nicht im knappen Budget von session.end
228 if (windowed && !final && !limSinceChecked) {
229 limSinceChecked = true
230 if ((await $.store.get('meta:limSince')) === undefined) await $.store.set('meta:limSince', now)
231 }
232}
233
234/**
235 * Modellaufruf eines anderen Mods buchen (SPEC Verhalten 5). `usage` steht auf jedem Zweig des Ergebnisses (types ModelCompleteResult).
236 * `single`: `usage` ist genau eine Anfrage (nur `model.complete`), dann gilt eine Preisstufe nach Prompt-Länge (Haiku 5.5).
237 */
238async function bookCall($: EngineInterface, origin: string | undefined, model: string, usage: unknown, countOnly: boolean, single = false) {
239 const name = pluginName(origin ?? '')
240 if (!name || name === 'engine' || name === SELF || !sessionId) return
241 const now = await $.clock.now()
242 const amount = countOnly ? 0 : callCost(usage as Parameters<typeof callCost>[0], model, single)
243 await honorReset($)
244 rec ??= newRec({ ...meta, title }, now, seen)
245 bookMod(rec, name, dayKey(now), amount, now, countOnly ? undefined : (usage as Usage | undefined))
246 if (!countOnly) {
247 bookModel(rec, model, dayKey(now), usage as Usage | undefined, now, 'modModels', single)
248 // Fenster aus der letzten Messung, kein eigenes usage(): Mod-Aufrufe sollen nicht warten (Verhalten 5)
249 bookLim(rec, limits, now, 'mod', amount, carry)
250 noteCarry(now)
251 }
252 await save($)
253}
254
255/**
256 * Alle Datensätze lesen; dabei alte löschen (älter als `keepDays` seit der letzten Buchung). Das Aufräumen liegt hier und
257 * nicht im awaiteten session.start, weil `/ledger` ohnehin jeden Datensatz liest. `bytes` schätzt die Größe des Speichers
258 * (4 MiB JSON insgesamt, docs/raw/en/reference.md:294).
259 */
260async function collect($: EngineInterface, now: number) {
261 const recs: { id: string; rec: Rec }[] = []
262 let unreadable = 0
263 let bytes = 0
264 let plan: Plan | null = null
265 let limSince: number | null = null
266 const cutoff = now - settings.keepDays * DAY
267 await honorReset($) // Reset aus einem anderen Chat auch ohne neue Buchung beachten
268 for (const key of await $.store.keys()) {
269 const value = await $.store.get(key)
270 bytes += key.length + (JSON.stringify(value ?? null)?.length ?? 0)
271 if (key === 'meta:plan') plan = cleanPlan(value)
272 if (key === 'meta:limSince' && typeof value === 'number') limSince = value
273 if (!key.startsWith('s:')) continue
274 const id = key.slice(2)
275 // Eigener Datensatz aus dem Speicher: der ist mindestens so frisch wie der gespeicherte
276 const r = id === sessionId && rec ? rec : cleanRec(value)
277 if (!r) unreadable++
278 else if (id !== sessionId && r.lastAt < cutoff) await $.store.delete(key)
279 else recs.push({ id, rec: r })
280 }
281 const hasData = (r: Rec) => [r.days, r.mods, r.models, r.modModels, r.act, r.lim].some((x) => Object.keys(x).length > 0)
282 if (rec && sessionId && hasData(rec) && !recs.some((x) => x.id === sessionId)) recs.push({ id: sessionId, rec })
283 const since = await $.store.get('meta:since')
284 return { recs, unreadable, bytes, since: typeof since === 'number' ? since : null, plan, limSince }
285}
286
287/**
288 * `/ledger help` (docs/HELP-SPEC.md §3): Schnappschuss mit Plan, Speicherbelegung und Beginn der Erfassung, nur lesend
289 * (kein Aufräumen, kein Buchen). Scheitert das Lesen, zeigt die Hilfe trotzdem Befehle und Einstellungen.
290 */
291async function helpCommand($: EngineInterface, args: string): Promise<string> {
292 const now = await $.clock.now()
293 const info: HelpInfo = { plan: null, storeBytes: 0, since: null }
294 try {
295 for (const key of await $.store.keys()) {
296 const value = await $.store.get(key)
297 info.storeBytes += key.length + (JSON.stringify(value ?? null)?.length ?? 0)
298 if (key === 'meta:plan') info.plan = cleanPlan(value)
299 if (key === 'meta:since' && typeof value === 'number') info.since = value
300 }
301 } catch (err) {
302 log($, `/ledger help: ${msg(err)}`)
303 }
304 const data = ledgerHelp(settings, info)
305 return helpMarkdown(data, remember(now, { kind: 'help', data, args: normArgs(args) }))
306}
307
308/**
309 * `/ledger plan …`: Abo und Abrechnungstag in `meta:plan` (SPEC Nachtrag 0.5.0, Bedienung). Den Plan kann ein Mod nicht
310 * abfragen (kein Feld in der Mods-API, Login-Daten sind tabu: CLAUDE.md Grundregel 3), deshalb stellt der Nutzer ihn ein.
311 */
312async function planCommand($: EngineInterface, args: string[]): Promise<string> {
313 const t = T[settings.lang]
314 const lang = settings.lang
315 const now = await $.clock.now()
316 const price = (p: Plan) => {
317 const v = p.price ?? PLANS[p.plan]?.price
318 return v === undefined ? t.noPrice : `${t.perMonth(usd(v, lang))}${p.price === undefined ? ` (${t.listPrice})` : ''}`
319 }
320 const first = (args[0] ?? '').toLowerCase()
321 if (!first) {
322 const p = cleanPlan(await $.store.get('meta:plan'))
323 return `${p ? t.planCurrent(planLabel(p.plan), p.day, price(p), dateTime(p.at, lang)) : t.planNone}\n${t.planHelp}`
324 }
325 if (OFF_WORDS.includes(first)) {
326 await $.store.delete('meta:plan')
327 return t.planDeleted
328 }
329 const p = parsePlan(args, now)
330 if (!p) return `${t.planInvalid(args.join(' '))}\n${t.planHelp}`
331 await $.store.set('meta:plan', p)
332 const m = planMonth(p.day, now)
333 return t.planSaved(planLabel(p.plan), p.day, price(p), shortDate(m.start, lang), shortDate(m.next, lang))
334}
335
336async function reset($: EngineInterface): Promise<string> {
337 const t = T[settings.lang]
338 let answer: string
339 try {
340 // Empfohlene Antwort auf Platz 1 (Fynns Regel für Rückfragen); in -p wird ask abgelehnt (API-DETAILS.md:149-154)
341 answer = await $.ui.ask(t.askQuestion, [t.askCancel, t.askDelete])
342 } catch {
343 return t.notDeletedNoAsk
344 }
345 // Beide Sprachen gelten, falls die Einstellung zwischen Frage und Antwort wechselt
346 if (answer !== T.en.askDelete && answer !== T.de.askDelete) return t.notDeleted
347 const now = await $.clock.now()
348 // Zuerst die Marke: Wer ab jetzt bucht oder /ledger zeigt, verwirft seinen alten Stand (honorReset)
349 await $.store.set('meta:resetAt', now)
350 await serial(async () => {
351 rec = null // Die bisherigen Kosten dieser Session werden nicht neu gebucht: `seen` bleibt
352 carry = {}
353 carryAt = 0
354 pendingGap = 0
355 gapAt = undefined
356 limSinceChecked = false
357 reports.clear()
358 })
359 // Löschen außerhalb der Kette: Mod-Aufrufe warten nur auf das eigene Schreiben (SPEC Verhalten 5).
360 // `meta:plan` bleibt: eine Einstellung, keine Buchung (SPEC Nachtrag 0.5.0, Verhalten 5)
361 let n = 0
362 let hadLimits = false
363 for (const key of await $.store.keys()) {
364 if (key === 'meta:resetAt' || key === 'meta:plan' || (!key.startsWith('s:') && !key.startsWith('meta:'))) continue
365 if (key === 'meta:limSince') hadLimits = true
366 await $.store.delete(key)
367 if (key.startsWith('s:')) n++
368 }
369 await $.store.set('meta:since', now)
370 // Die Fenster-Daten beginnen mit dem Reset neu. Gleich setzen statt löschen: Sonst setzte der nächste Prozess, der ein
371 // Fenster bucht, einen späteren Beginn, und Fenster anderer Chats seit dem Reset gälten als unvollständig (Review 0.5.0)
372 if (hadLimits) await $.store.set('meta:limSince', now)
373 return t.cleared(n)
374}
375
376export function register(on: On, options: Readonly<Record<string, string | number | boolean | readonly string[]>>) {
377 settings = cleanSettings(options)
378
379 on('session.start', async ($, e, next) => {
380 try {
381 await serial(async () => {
382 const root = await $.session.root()
383 const repo = await $.session.repo()
384 meta.root = root
385 meta.project = projectOf(repo?.name, root)
386 meta.remote = cleanRemote(repo?.remote) // nur host/owner/repo, nie Zugangsdaten
387 meta.kind = kindOf(await $.session.surfaces())
388 if (sessionId && !rec) rec = await load($, sessionId)
389 const cost = (await $.session.usage()).cost?.usd
390 hasCost = typeof cost === 'number'
391 // Resume zählt `cost` weiter: Baseline = gespeicherter Stand, sonst der aktuelle → keine Doppelbuchung
392 seen = typeof cost === 'number' ? baselineFor(rec, cost) : 0
393 // Was zwischen letzter Buchung und Prozessende anfiel (hartes Ende), bucht der nächste Messpunkt nach
394 pendingGap = typeof cost === 'number' ? Math.max(0, cost - seen) : 0
395 gapAt = pendingGap > 0 ? rec?.lastAt : undefined
396 rebase = false
397 started = true
398 if ((await $.store.get('meta:since')) === undefined) await $.store.set('meta:since', await $.clock.now())
399 })
400 } catch (err) {
401 log($, `Start: ${msg(err)}`)
402 }
403 try {
404 await $.command.register({
405 name: 'ledger',
406 description: T[settings.lang].commandDescription,
407 argumentHint: '[weeks|chats|projects|models|limits|plan|reset|help]',
408 })
409 } catch (err) {
410 log($, `/ledger nicht registriert: ${msg(err)}`)
411 }
412 return next(e)
413 })
414
415 // Session-ID und Titel; /clear, /resume und /branch wechseln die Session ohne neues session.start (interface.md:786)
416 on('classic.SessionStart', async ($, e, next) => {
417 try {
418 await serial(async () => {
419 if (e.session_id && e.session_id !== sessionId) await bind($, e.session_id, e.source, e.session_title)
420 else if (e.session_title && e.session_title !== title) {
421 title = e.session_title
422 titleDirty = true
423 titleFromSession = true
424 }
425 })
426 } catch (err) {
427 log($, `SessionStart: ${msg(err)}`)
428 }
429 return next(e)
430 })
431
432 // Titel nur merken; geschrieben wird beim nächsten Buchen. Nach einem Modul-Reload feuert nur session.start, nicht
433 // classic.SessionStart (types:4173-4178, interface.md:789): dann bindet die Session-ID von hier (BaseHookInput)
434 on('classic.UserPromptSubmit', async ($, e, next) => {
435 try {
436 await serial(async () => {
437 if (e.session_id && e.session_id !== sessionId) await bind($, e.session_id, undefined, e.session_title)
438 else if (e.session_title && e.session_title !== title) {
439 title = e.session_title
440 titleDirty = true
441 titleFromSession = true
442 }
443 // Ohne Titel vom Host: die erste eigene Nachricht benennt den Chat (keine System- oder Weck-Prompts). Nicht bei
444 // Skript-Läufen (-p): Skripte schicken oft fremden Text, der sonst in /ledger bei Claude landet
445 const own = e.source === undefined || e.source === 'user' || e.source === 'sdk'
446 if (!title && !titleFromSession && own && meta.kind !== 'script') {
447 const t = titleFromPrompt(e.prompt)
448 if (t) {
449 title = t
450 titleDirty = true
451 }
452 }
453 })
454 } catch (err) {
455 log($, `UserPromptSubmit: ${msg(err)}`)
456 }
457 return next(e)
458 })
459
460 on('turn.complete', async ($, e, next) => {
461 const r = await next(e)
462 try {
463 if (!e.agentId && e.usage?.model) lastModel = e.usage.model
464 // Tokens je Modell aus der Usage des Turns (TurnUsage: Summe seiner Anfragen, Modell der letzten), auch Subagents
465 await serial(() => measure($, false, e.usage, { agentId: e.agentId, durationMs: e.durationMs, reason: e.reason, isAborted: e.isAborted }))
466 } catch (err) {
467 log($, `Buchen: ${msg(err)}`)
468 }
469 return r
470 })
471
472 // Modellaufrufe anderer Mods: `next(e)` liefert `{ value }` oder `{ deny }` (types OpEventResult). Nie ändern, nie verzögern
473 // außer um das eigene Schreiben; den eigenen Hook überspringt die Engine ohnehin (types OpEventOf).
474 on('model.complete', async ($, e, next) => {
475 const r = await next(e)
476 try {
477 if ('value' in r) await serial(() => bookCall($, next.origin?.plugin, e.model, r.value.usage, false, true))
478 } catch (err) {
479 log($, `Mod-Aufruf: ${msg(err)}`)
480 }
481 return r
482 })
483
484 on('model.fork', async ($, e, next) => {
485 const r = await next(e)
486 try {
487 // Der Fork läuft auf dem Modell der Hauptschleife; „nothing-to-fork“ hat keine Usage und kostet nichts
488 if ('value' in r && 'usage' in r.value) await serial(() => bookCall($, next.origin?.plugin, lastModel, r.value.usage, false))
489 } catch (err) {
490 log($, `Mod-Aufruf: ${msg(err)}`)
491 }
492 return r
493 })
494
495 // classify liefert nur einen String ohne Usage: nur die Anzahl zählt (types:6539-6546, :6871)
496 on('model.classify', async ($, e, next) => {
497 const r = await next(e)
498 try {
499 if ('value' in r) await serial(() => bookCall($, next.origin?.plugin, '', undefined, true))
500 } catch (err) {
501 log($, `Mod-Aufruf: ${msg(err)}`)
502 }
503 return r
504 })
505
506 // Letzte Buchung; ein Schreibvorgang, kein Warten auf anderes (Budget 1,5 s)
507 on('session.end', async ($, e, next) => {
508 try {
509 await serial(() => measure($, true))
510 } catch (err) {
511 log($, `Ende: ${msg(err)}`)
512 }
513 return next(e)
514 })
515
516 on('command.run', { command: 'ledger' }, async ($, e) => {
517 const t = T[settings.lang]
518 const [sub = '', ...rest] = e.args.trim().split(/\s+/)
519 const arg = rest[0]
520 const what = sub.toLowerCase()
521 // Hilfe: nur das erste Wort, ohne weitere Wörter (docs/HELP-SPEC.md §2)
522 if (HELP_WORDS.includes(what)) return { text: rest.length ? t.unknownArg(e.args.trim()) : await helpCommand($, e.args) }
523 // Speicher voll o. Ä.: ein lesbarer Hinweis statt einer Fehlerzeile (Review 0.5.0 K1)
524 if (what === 'reset' || what === 'plan') {
525 try {
526 return { text: what === 'reset' ? await reset($) : await planCommand($, rest) }
527 } catch (err) {
528 log($, `/ledger ${what}: ${msg(err)}`)
529 return { text: t.writeFailed(msg(err)) }
530 }
531 }
532 if (what && !VIEW_WORDS.includes(what)) return { text: t.unknownArg(sub) }
533 try {
534 await serial(() => measure($)) // den laufenden Chat mitzählen
535 } catch (err) {
536 log($, `Buchen vor /ledger: ${msg(err)}`)
537 }
538 const views: Record<string, View['view']> = { chats: 'chats', projects: 'projects', models: 'models', weeks: 'weeks', limits: 'limits' }
539 const view = views[what] ?? 'overview'
540 const range = view === 'overview' || view === 'weeks' || view === 'limits' ? 30 : parseRange(arg)
541 const now = await $.clock.now()
542 const data = await collect($, now)
543 const report = aggregate(data.recs, now, {
544 range,
545 unreadable: data.unreadable,
546 hasCost,
547 since: data.since,
548 storeBytes: data.bytes,
549 writeError,
550 plan: data.plan,
551 limSince: data.limSince,
552 live: limits,
553 })
554 // Eindeutige Kennung im Text: ui.render findet darüber die Daten der Zeichnung (SPEC Verhalten 7)
555 const tag = remember(now, { kind: 'ledger', view: { report, view, range }, args: normArgs(e.args) })
556 return { text: summaryText(report, view, range, tag, settings.lang) }
557 })
558
559 // Die Übersicht und die Hilfe: ein eigener Baum statt der Markdown-Zeile; „a hook's own tree draws in the row's place“
560 // (types CommandOutput). `command` gehört zu den Props, daher `props: { command }` im Matcher (oben auf `e` feuert er nie).
561 on('ui.render', { component: 'CommandOutput', props: { command: 'ledger' } }, async ($, e, next) => {
562 if (e.props.isErrored || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
563 // Kennung irgendwo in der ersten Zeile: in -p steht davor „cost-ledger: “ (HELP-SPEC §8 Punkt 1)
564 const tag = /#[0-9a-z]{5,}/.exec(e.props.text.split('\n')[0] ?? '')?.[0]
565 const d = tag ? reports.get(tag) : undefined
566 // Unbekannt (nach Neustart, alte Zeile, reset, plan): die Engine zeichnet den Markdown-Text
567 if (!d || d.args !== normArgs(e.props.args)) return next(e)
568 const columns = e.viewport?.columns ?? 100
569 return d.kind === 'help' ? helpTree(d.data, columns, e.surface, ACCENT) : ledgerTree(d.view, settings, columns, e.surface)
570 })
571}
572hooks/help.ts 205 lines1// help.ts: die gezeichnete Hilfe-Tabelle für `/<befehl> help` (docs/HELP-SPEC.md §3-§4). Allgemein gehalten: Eine Mod
2// liefert nur `HelpData` und ihre Akzentfarbe; Aufbau, Spalten, Farben der Schalter und die Markdown-Fassung stehen hier.
3// Vorlage für alle Mods: templates/help/ (README dort). Ohne `$`, nur Daten → Baum bzw. Text, deshalb ohne Engine testbar.
4//
5// Baum aus reinen Daten {type, props, children} (types RenderElement), nur Box und Text mit erlaubten Props. Desktop:
6// Spaltenbreiten nur als ganzzahlige Prozent, sonst verwirft er den ganzen Baum (cost-ledger 0.3.1). Unter 60 Spalten
7// stehen die Spalten untereinander.
8import type { RenderElement, RenderNode } from 'claude-code'
9
10export type HelpLang = 'en' | 'de'
11export type HelpSurface = 'terminal' | 'desktop'
12
13/** Eine Zeile unter BEFEHLE bzw. BEDIENUNG: Befehl oder Bedienelement (Akzentfarbe) und seine Wirkung. */
14export type HelpCommand = { cmd: string; does: string }
15/**
16 * Zustand einer Funktion: Schalter (`on` → „● an“ in `success`, `off` → „○ aus“ in `inactive`) oder ein Wert als Text, mit
17 * „(Standard)“, wenn `isDefault`. `text` ersetzt „an“/„aus“; bei `on` steht er dann in der normalen Schriftfarbe (lange
18 * Texte bleiben lesbar), nur der Punkt ist grün.
19 */
20export type HelpState = { kind: 'on' | 'off'; text?: string } | { kind: 'value'; text: string; isDefault?: boolean }
21/** Eine Zeile unter FUNKTIONEN; `toggle` ist der Befehl, der den Zustand ändert, sonst z. B. „Einstellung“ oder „nur Info“. */
22export type HelpFeature = { name: string; state: HelpState; toggle: string }
23/** Eine Zeile unter EINSTELLUNGEN: Titel des userConfig-Felds (übersetzt) und sein aktueller Wert. */
24export type HelpSetting = { title: string; value: string; isDefault?: boolean }
25/** Schnappschuss beim Aufruf von `help`; die Zeichnung schreibt sich danach nicht um (HELP-SPEC §3 Punkt 3). */
26export type HelpData = {
27 /** Name der Mod im Titel */
28 mod: string
29 lang: HelpLang
30 /** Ein Satz, was die Mod macht */
31 intro: string
32 commands: HelpCommand[]
33 /** Gedimmte Zeilen unter den Befehlen (z. B. Aliase) */
34 notes?: string[]
35 /** BEDIENUNG: nur, wenn es Klicks oder Tasten gibt */
36 controls?: HelpCommand[]
37 features: HelpFeature[]
38 settings: HelpSetting[]
39 /** Weg zum Ändern der Einstellungen und zum Abschalten der Mod; Terminal und Desktop brauchen verschiedene Wege */
40 footer: { terminal: string; desktop: string }
41}
42
43const LABELS = {
44 en: {
45 help: 'Help',
46 commands: 'COMMANDS',
47 controls: 'CONTROLS',
48 features: 'FEATURES',
49 status: 'STATUS',
50 toggle: 'TOGGLE',
51 settings: 'SETTINGS (/plugin)',
52 value: 'VALUE',
53 on: 'on',
54 off: 'off',
55 isDefault: '(default)',
56 },
57 de: {
58 help: 'Hilfe',
59 commands: 'BEFEHLE',
60 controls: 'BEDIENUNG',
61 features: 'FUNKTIONEN',
62 status: 'STATUS',
63 toggle: 'UMSCHALTEN',
64 settings: 'EINSTELLUNGEN (/plugin)',
65 value: 'WERT',
66 on: 'an',
67 off: 'aus',
68 isDefault: '(Standard)',
69 },
70} as const
71
72export function helpLabels(lang: HelpLang) {
73 return LABELS[lang]
74}
75
76type Props = Record<string, string | number | boolean>
77const el = (type: 'Box' | 'Text', props: Props, children: RenderNode[]): RenderElement => ({ type, props, children })
78const text = (s: string, props: Props = {}) => el('Text', props, [s])
79const dim = (s: string) => text(s, { dimColor: true })
80const row = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'row', ...props }, kids)
81const col = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'column', ...props }, kids)
82const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
83
84/** Zustand als Text (für die Markdown-Fassung und zum Messen der Spaltenbreite). */
85export function stateText(s: HelpState, lang: HelpLang): string {
86 const L = LABELS[lang]
87 if (s.kind === 'value') return s.isDefault ? `${s.text} ${L.isDefault}` : s.text
88 return `${s.kind === 'on' ? '●' : '○'} ${s.text ?? (s.kind === 'on' ? L.on : L.off)}`
89}
90
91/**
92 * Zustand gezeichnet: „● an“ in `success`, „○ aus“ in `inactive`; mit eigenem Text bei `on` nur der Punkt grün. Werte in
93 * der normalen Schriftfarbe, „(Standard)“ gedimmt.
94 */
95function stateNode(s: HelpState, lang: HelpLang): RenderElement {
96 const L = LABELS[lang]
97 if (s.kind === 'value') return el('Text', {}, [text(s.text), ...(s.isDefault ? [dim(` ${L.isDefault}`)] : [])])
98 const on = s.kind === 'on'
99 const label = s.text ?? (on ? L.on : L.off)
100 const labelProps: Props = on ? (s.text === undefined ? { color: 'success' } : {}) : { color: 'inactive' }
101 return el('Text', {}, [text(on ? '● ' : '○ ', { color: on ? 'success' : 'inactive' }), text(label, labelProps)])
102}
103
104/**
105 * Spalten einer Zeile. Terminal: feste Zellen, die letzte füllt den Rest. Desktop: ganzzahlige Prozent mit Summe 100
106 * (`share` = gewünschte Breite in Zeichen, daraus die Anteile).
107 */
108function columns(sf: HelpSurface, inner: number, widths: number[], kids: RenderNode[], props: Props = {}): RenderElement {
109 if (sf === 'desktop') {
110 const pct = widths.slice(0, -1).map((w) => clamp(Math.round((w / inner) * 100), 10, 60))
111 const rest = Math.max(10, 100 - pct.reduce((a, b) => a + b, 0))
112 const all = [...pct, rest]
113 // Summe genau 100: Überhang vom größten Anteil abziehen
114 const over = all.reduce((a, b) => a + b, 0) - 100
115 if (over > 0) all[all.indexOf(Math.max(...all))]! -= over
116 return row(props, kids.map((k, i) => el('Box', { width: `${all[i]}%`, paddingRight: 1 }, [k])))
117 }
118 return row(
119 props,
120 kids.map((k, i) => (i < kids.length - 1 ? el('Box', { width: widths[i]!, flexShrink: 0 }, [k]) : el('Box', { flexGrow: 1, flexShrink: 1 }, [k]))),
121 )
122}
123
124const heading = (s: string, accent: string) => text(s, { color: accent, bold: true })
125
126/**
127 * Der ganze Baum für eine `CommandOutput`-Zeile. `columns` ist `e.viewport?.columns` (begrenzt auf 30-140), `accent` die
128 * Akzentfarbe der Mod (Hex oder Theme-Key), nur für Titel, Überschriften und Befehle.
129 */
130export function helpTree(d: HelpData, columnsHint: number, surface: HelpSurface, accent: string): RenderElement {
131 const L = LABELS[d.lang]
132 const cols = clamp(columnsHint || 100, 30, 140)
133 const inner = cols - 4 // Rahmen und paddingX
134 const narrow = cols < 60
135 const kids: RenderNode[] = [heading(`${d.mod} · ${L.help}`, accent), text(d.intro)]
136
137 const commandRows = (title: string, list: HelpCommand[]) => {
138 if (!list.length) return
139 kids.push(el('Box', { marginTop: 1 }, [heading(title, accent)]))
140 const cmdW = clamp(Math.max(...list.map((c) => c.cmd.length)) + 2, 12, Math.floor(inner * 0.5))
141 for (const c of list)
142 kids.push(
143 narrow
144 ? col({}, [text(c.cmd, { color: accent }), el('Box', { paddingLeft: 2 }, [text(c.does)])])
145 : columns(surface, inner, [cmdW, inner - cmdW], [text(c.cmd, { color: accent }), text(c.does)]),
146 )
147 }
148 commandRows(L.commands, d.commands)
149 for (const n of d.notes ?? []) kids.push(dim(n))
150 commandRows(L.controls, d.controls ?? [])
151
152 if (d.features.length) {
153 const nameW = clamp(Math.max(L.features.length, ...d.features.map((f) => f.name.length)) + 2, 12, Math.floor(inner * 0.34))
154 const stateW = clamp(Math.max(L.status.length, ...d.features.map((f) => stateText(f.state, d.lang).length)) + 2, 10, Math.floor(inner * 0.4))
155 if (narrow) {
156 kids.push(el('Box', { marginTop: 1 }, [heading(L.features, accent)]))
157 // Zustand und Umschalten in einem Text, damit sie als ein Absatz umbrechen statt als zwei schmale Spalten
158 for (const f of d.features)
159 kids.push(col({}, [text(f.name), el('Box', { paddingLeft: 2 }, [el('Text', {}, [stateNode(f.state, d.lang), dim(` · ${f.toggle}`)])])]))
160 } else {
161 const widths = [nameW, stateW, inner - nameW - stateW]
162 kids.push(columns(surface, inner, widths, [heading(L.features, accent), heading(L.status, accent), heading(L.toggle, accent)], { marginTop: 1 }))
163 for (const f of d.features) kids.push(columns(surface, inner, widths, [text(f.name), stateNode(f.state, d.lang), dim(f.toggle)]))
164 }
165 }
166
167 if (d.settings.length) {
168 const titleW = clamp(Math.max(L.settings.length, ...d.settings.map((s) => s.title.length)) + 2, 12, Math.floor(inner * 0.5))
169 const value = (s: HelpSetting) => stateNode({ kind: 'value', text: s.value, isDefault: s.isDefault }, d.lang)
170 if (narrow) {
171 kids.push(el('Box', { marginTop: 1 }, [heading(L.settings, accent)]))
172 for (const s of d.settings) kids.push(col({}, [text(s.title), el('Box', { paddingLeft: 2 }, [value(s)])]))
173 } else {
174 const widths = [titleW, inner - titleW]
175 kids.push(columns(surface, inner, widths, [heading(L.settings, accent), heading(L.value, accent)], { marginTop: 1 }))
176 for (const s of d.settings) kids.push(columns(surface, inner, widths, [text(s.title), value(s)]))
177 }
178 }
179
180 kids.push(el('Box', { marginTop: 1 }, [dim(surface === 'desktop' ? d.footer.desktop : d.footer.terminal)]))
181 return col({ borderStyle: 'round', borderDimColor: true, paddingX: 1, width: '100%', key: `${d.mod}-help` }, kids)
182}
183
184/**
185 * Kompakte Markdown-Fassung: was Claude mitliest und was `-p`, das SDK und VS Code zeigen. `tag` (Kennung `#…`) steht in
186 * der ersten Zeile, darüber findet der Render-Hook den Schnappschuss. Fußzeile mit dem Terminal-Weg. Leerzeilen zwischen
187 * den Blöcken: Sonst hängt Markdown (CommonMark) alles nach einer Liste an deren letzten Punkt.
188 */
189export function helpMarkdown(d: HelpData, tag: string): string {
190 const L = LABELS[d.lang]
191 const lines = [`**${d.mod} · ${L.help}**${tag ? ` · ${tag}` : ''}`, '', d.intro]
192 const list = (title: string, items: HelpCommand[]) => {
193 if (!items.length) return
194 lines.push('', `**${title}**`, ...items.map((c) => `- \`${c.cmd}\`: ${c.does}`))
195 }
196 list(L.commands, d.commands)
197 if (d.notes?.length) lines.push('', ...d.notes)
198 list(L.controls, d.controls ?? [])
199 if (d.features.length) lines.push('', `**${L.features}:** ${d.features.map((f) => `${f.name} ${stateText(f.state, d.lang)} (${f.toggle})`).join(' · ')}`)
200 if (d.settings.length)
201 lines.push('', `**${L.settings}:** ${d.settings.map((s) => `${s.title} ${s.value}${s.isDefault ? ` ${L.isDefault}` : ''}`).join(' · ')}`)
202 lines.push('', d.footer.terminal)
203 return lines.join('\n')
204}
205hooks/i18n.ts 379 lines1// cost-ledger: Texte und Formatierer für Englisch und Deutsch (release/I18N.md). Ohne `$`, ohne `Intl`: selbst formatiert.
2// Sprache aus userConfig `language`; Standard Englisch.
3
4export type Lang = 'en' | 'de'
5
6export function langOf(v: unknown): Lang {
7 return v === 'de' ? 'de' : 'en'
8}
9
10const pad = (n: number) => String(n).padStart(2, '0')
11const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
12
13/** en `$1,234.50`, `< $0.01`, `$0.00`; de `1.234,50 $`, `< 0,01 $`, `0,00 $`. */
14export function usd(v: number, lang: Lang): string {
15 if (lang === 'de') {
16 if (!(v > 0)) return '0,00 $'
17 if (v < 0.005) return '< 0,01 $'
18 return `${v.toFixed(2).replace('.', ',').replace(/\B(?=(\d{3})+(?!\d),)/g, '.')} $`
19 }
20 if (!(v > 0)) return '$0.00'
21 if (v < 0.005) return '< $0.01'
22 return `$${v.toFixed(2).replace(/\B(?=(\d{3})+(?!\d)\.)/g, ',')}`
23}
24
25/** Tag `YYYY-MM-DD` kurz: en `Oct 6`, de `06.10.` */
26export function shortDate(key: string, lang: Lang): string {
27 const [, m = '1', d = '1'] = key.split('-')
28 return lang === 'de' ? `${d}.${m}.` : `${MONTHS[Number(m) - 1]} ${Number(d)}`
29}
30
31/** Zeitpunkt mit Uhrzeit: en `Oct 6 14:05`, de `06.10. 14:05` */
32export function dateTime(ms: number, lang: Lang): string {
33 const d = new Date(ms)
34 const day = lang === 'de' ? `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.` : `${MONTHS[d.getMonth()]} ${d.getDate()}`
35 return `${day} ${pad(d.getHours())}:${pad(d.getMinutes())}`
36}
37
38/** Datum mit Jahr: en `Oct 6, 2026`, de `06.10.2026` */
39export function fullDate(ms: number, lang: Lang): string {
40 const d = new Date(ms)
41 return lang === 'de' ? `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}` : `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`
42}
43
44/** Kalenderwoche: en `W41`, de `KW 41` */
45export function weekLabel(week: number, lang: Lang): string {
46 return lang === 'de' ? `KW ${week}` : `W${week}`
47}
48
49/** Tokens kurz: `412k`, en `1.2M`, de `1,2M` */
50export function tokens(n: number, lang: Lang): string {
51 const v = Math.max(0, Math.round(n || 0))
52 const dec = (x: number) => (lang === 'de' ? x.toFixed(1).replace('.', ',') : x.toFixed(1))
53 if (v >= 1e6) return `${dec(v / 1e6)}M`
54 if (v >= 1e4) return `${Math.round(v / 1e3)}k`
55 if (v >= 1e3) return `${dec(v / 1e3)}k`
56 return String(v)
57}
58
59const WEEKDAYS: Record<Lang, string[]> = { en: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], de: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] }
60
61/** Uhrzeit `HH:MM` (lokal) */
62export function clock(ms: number): string {
63 const d = new Date(ms)
64 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
65}
66
67/** Reset-Zeitpunkt: am selben Tag nur die Uhrzeit, sonst en `Mon Oct 12 04:00`, de `Mo 12.10. 04:00` */
68export function resetTime(ms: number, now: number, lang: Lang): string {
69 const d = new Date(ms)
70 const n = new Date(now)
71 if (d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()) return clock(ms)
72 return `${WEEKDAYS[lang][d.getDay()]} ${dateTime(ms, lang)}`
73}
74
75/** Dauer, abgerundet: `3 h 50 min`, `12 min`, en `3 d 10 h`, de `3 T 10 h`; unter einer Minute `< 1 min` */
76export function duration(ms: number, lang: Lang): string {
77 const m = Math.floor(Math.max(0, ms) / 60000)
78 if (m < 1) return '< 1 min'
79 const d = Math.floor(m / 1440)
80 const h = Math.floor((m % 1440) / 60)
81 if (d > 0) return `${d} ${lang === 'de' ? 'T' : 'd'} ${h} h`
82 return h > 0 ? `${h} h ${m % 60} min` : `${m} min`
83}
84
85/** Auslastung ganzzahlig: `62 %` */
86export function pct(v: number): string {
87 return `${Math.round(v || 0)} %`
88}
89
90/** Faktor mit einer Stelle: en `4.1`, de `4,1` */
91export function factor(v: number, lang: Lang): string {
92 const s = (Number.isFinite(v) ? v : 0).toFixed(1)
93 return lang === 'de' ? s.replace('.', ',') : s
94}
95
96/**
97 * Fenster von–bis mit dem Datum des Beginns: de `08.10. 13–18`, en `Oct 8 16:10–21:10`; volle Stunden ohne Minuten,
98 * ein Ende um Mitternacht als `24`.
99 */
100export function span(start: number, end: number, lang: Lang): string {
101 const s = new Date(start)
102 const e = new Date(end)
103 const hours = s.getMinutes() === 0 && e.getMinutes() === 0
104 const endH = e.getHours() === 0 && e.getMinutes() === 0 && e.getDate() !== s.getDate() ? 24 : e.getHours()
105 const fmt = (h: number, m: number) => (hours ? pad(h) : `${pad(h)}:${pad(m)}`)
106 const day = lang === 'de' ? `${pad(s.getDate())}.${pad(s.getMonth() + 1)}.` : `${MONTHS[s.getMonth()]} ${s.getDate()}`
107 return `${day} ${fmt(s.getHours(), s.getMinutes())}–${fmt(endH, e.getMinutes())}`
108}
109
110/** Zeitraum in Tagen, 0 = alles: en `7 days`/`all time`, de `7 Tage`/`gesamt` */
111export function rangeLabel(range: number, lang: Lang): string {
112 if (range <= 0) return lang === 'de' ? 'gesamt' : 'all time'
113 return lang === 'de' ? `${range} Tage` : `${range} days`
114}
115
116const en = {
117 asOf: (t: string) => `as of ${t}`,
118 since: (d: string) => `since ${d}`,
119 reload: (cmd: string) => `reload: ${cmd}`,
120 empty: 'No entries yet. Counting starts now, after every answer.',
121 today: 'Today',
122 days7: '7 days',
123 days30: '30 days',
124 allTime: 'All time',
125 chats: (n: number) => `${n} ${n === 1 ? 'chat' : 'chats'}`,
126 last14Days: 'Last 14 days',
127 last14Weeks: 'Last 14 weeks',
128 highest: '← highest',
129 noData: 'no data',
130 modsPart: (model: string) => `${model} · mods`,
131 modsNoData: 'mods, no data',
132 unknownModel: 'Unknown',
133 projectsIn: (range: string) => `Projects · ${range}`,
134 chatsIn: (range: string) => `Most expensive chats · ${range}`,
135 modelsIn: (range: string) => `Models · ${range}`,
136 modsIn: (range: string) => `Mods · ${range}`,
137 noCosts: 'no costs in this period',
138 more: (n: number) => `+${n} more`,
139 noModCalls: 'no model calls by other mods in this period',
140 modsExtra: 'adds to the chat costs, not included in /cost',
141 tokenLine: (i: string, o: string, cr: string, cw: string) => `Input ${i} · Output ${o} · Cache ${cr} read, ${cw} written`,
142 noTokens: 'No answers with token data in this period yet.',
143 modelsNote: 'Amount estimated from tokens × price table; includes model calls by other mods.',
144 foot: 'API value; on a subscription it counts against your usage limits · /ledger weeks · chats · projects · models · help',
145 noCost: 'This host reports no chat costs; only model calls by mods are recorded.',
146 unreadable: (n: number) => `${n} ${n === 1 ? 'entry' : 'entries'} unreadable.`,
147 storeFull: (pct: number) => `Storage ${pct} % full (4 MiB): lower the retention (keepDays).`,
148 writeFailed: (err: string) => `Saving failed last time: ${err}`,
149 scriptRuns: 'Script runs',
150 noFolder: '(no folder)',
151 chatFrom: (t: string) => `Chat from ${t}`,
152 sumTitle: (t: string, tag: string) => `**Cost ledger** · as of ${t} · ${tag}`,
153 sumChats: (today: string, n: number, d7: string, d30: string, all: string) => `Chats: today ${today} (${n}) · 7 days ${d7} · 30 days ${d30} · all time ${all}`,
154 sumMods: (today: string, d30: string, all: string) => `Mods: today ${today} · 30 days ${d30} · all time ${all}`,
155 sumProjects: (list: string) => `Projects (30 days): ${list}`,
156 sumTopChats: (list: string) => `Most expensive chats (30 days): ${list}`,
157 sumModsList: (list: string) => `Mods (30 days): ${list}`,
158 sumWeeks: (list: string) => `Weeks (chat + mods): ${list}`,
159 sumChatsHead: (range: string, shown: number, total: number) => `Most expensive chats (${range}), ${shown} of ${total}:`,
160 sumModelsHead: (range: string) => `Models (${range}), estimated API value from tokens, incl. mod calls:`,
161 sumModelLine: (name: string, amt: string, pct: number, n: number, line: string) => `${name}: ${amt} (${pct} %) · ${n}× · ${line}`,
162 sumMoreModels: (n: number) => `+${n} more models`,
163 sumProjectsHead: (range: string) => `Projects (${range}):`,
164 sumProjectRow: (name: string, amt: string, n: number) => `${name} ${amt} (${n} ${n === 1 ? 'chat' : 'chats'})`,
165 sumFoot: 'Model calls by mods add to the chat costs (not included in /cost). API value; on a subscription it counts against your usage limits.',
166 unknownArg: (s: string) => `Unknown: “${s}”. All commands: /ledger help`,
167 // /ledger help (0.6.0, docs/HELP-SPEC.md)
168 helpIntro: 'Records what your chats and other mods cost (API value) after every answer and shows it by day, project, chat, model and limit window.',
169 helpOverview: 'Overview: today, 7 and 30 days, all time, last 14 days, projects, most expensive chats, mods',
170 helpWeeks: 'The overview with the last 14 calendar weeks',
171 helpLists: 'Most expensive chats · all projects · models with tokens; default 30 days, any number of days from 1 to 3650',
172 helpLimits: '5-hour and weekly window, subscription month, last 5-hour windows',
173 helpPlanShow: 'Show the plan setting and its syntax',
174 helpPlanSet: 'Set plan (pro, max5 or 5x/max5x, max20 or 20x/max20x, team, enterprise), billing day 1–31 and monthly price in $',
175 helpPlanOff: 'Delete the plan setting',
176 helpReset: 'Delete all entries (asks first; the plan stays)',
177 helpHelp: 'This help (also: ?)',
178 helpAliases: 'German words work too: hilfe, alle, heute, aus.',
179 helpPlanName: 'Subscription plan',
180 helpDay: (d: number) => `day ${d}`,
181 helpNoPlan: 'not set',
182 helpStorageName: 'Storage used',
183 helpStorage: (pct: number) => `${pct} % of 4 MiB`,
184 helpSinceName: 'Recording since',
185 helpInfo: 'info only',
186 setLanguage: 'Language',
187 setKeepDays: 'Retention (days)',
188 setDayYellow: 'Daily amount yellow from ($)',
189 setDayRed: 'Daily amount red from ($)',
190 helpFooterTerminal: 'Change settings: /plugin configure cost-ledger · Turn the mod off: /plugin disable cost-ledger',
191 // Desktop: Plugins nur an/aus über + → Plugins → Manage plugins (rel/desktop.md:478); Einstellungen gibt es dort nicht,
192 // Terminal-Dialogbefehle laufen im Desktop nicht (rel/desktop.md:1039-1041) → Einstellungen im Terminal
193 helpFooterDesktop: 'Turn the mod off: + → Plugins → Manage plugins · Change settings: /plugin configure cost-ledger in a terminal',
194 askQuestion: 'Delete all entries in the cost ledger?',
195 askCancel: 'Cancel (recommended)',
196 askDelete: 'Delete',
197 notDeletedNoAsk: 'Not deleted: the question cannot be asked here (e.g. in `-p`).',
198 notDeleted: 'Not deleted.',
199 cleared: (n: number) => `Cost ledger cleared (${n} ${n === 1 ? 'entry' : 'entries'}).`,
200 commandDescription: 'Cost ledger: what chats, mods and models have cost',
201 // Limits und Abo (0.5.0)
202 limitsHead: 'cost-ledger · limits',
203 fiveHours: '5 hours',
204 week: 'Week',
205 fiveShort: '5 h',
206 weekShort: 'week',
207 planShort: 'plan',
208 resetAt: (when: string) => `reset ${when}`,
209 inTime: (d: string) => `in ${d}`,
210 projection: (amt: string) => `100 % ≈ ${amt} (estimate)`,
211 projectionLater: 'projection from 5 %',
212 projectionPartial: 'projection from the next window',
213 projectionNone: 'no projection without a recorded amount',
214 partialFrom: (t: string) => `from ${t}`,
215 running: 'running',
216 resetPassed: 'reset passed, no new answer yet',
217 resetPassedShort: 'reset passed',
218 noWindowYet: 'no reading yet',
219 noLimitData: 'No limit data: no subscription detected, or no answer since the update yet.',
220 subMonth: 'Subscription month',
221 planNotSet: 'Plan not set: /ledger plan max20 14',
222 planValue: (amt: string, price: string, f: string) => `${amt} API value for a ${price} plan = ${f}×`,
223 planValueNoPrice: (amt: string) => `${amt} API value (no price set)`,
224 listPrice: 'list price',
225 renews: (d: string, n: number) => `renews ${d} (in ${n} ${n === 1 ? 'day' : 'days'})`,
226 lastWindows: 'Last 5-hour windows',
227 noWindows: 'No 5-hour window recorded yet.',
228 avg: (amt: string, n: number) => `Ø 100 % ≈ ${amt} from ${n} ${n === 1 ? 'window' : 'windows'} (≥ 20 %, estimate)`,
229 avgNone: 'Ø 100 %: no fully recorded, completed window with ≥ 20 % yet',
230 limitsFoot: '% applies to the whole account (incl. claude.ai) · $ only from chats with cost-ledger · projections are estimates · /ledger plan',
231 sumLimitsTitle: (t: string, tag: string) => `**Cost ledger · limits** · as of ${t} · ${tag}`,
232 sumLimitsLine: (list: string) => `Limits: ${list} · /ledger limits`,
233 sumLimitsFoot: '% applies to the whole account (incl. claude.ai); $ only counts chats with cost-ledger; projections are estimates.',
234 perMonth: (price: string) => `${price}/month`,
235 noPrice: 'no price',
236 planSaved: (label: string, day: number, price: string, start: string, next: string) =>
237 `Plan saved: ${label}, billing day ${day}, ${price}. Subscription month since ${start}, renews ${next} · details: /ledger limits`,
238 planCurrent: (label: string, day: number, price: string, at: string) => `Plan: ${label}, billing day ${day}, ${price} (set ${at}).`,
239 planNone: 'No plan set.',
240 planDeleted: 'Plan setting deleted.',
241 planInvalid: (s: string) => `Not saved: “${s}” is not valid.`,
242 planHelp: [
243 '**/ledger plan <plan> <day|today> [price]**: `pro`, `max5` (`5x`), `max20` (`20x`), `team` or `enterprise`; billing day 1–31 or `today`; monthly price in $, default list price (Pro $20, Max 5x $100, Max 20x $200).',
244 'Examples: `/ledger plan max20 14` · `/ledger plan max20 today 180` · `/ledger plan off` deletes the setting. Claude Code does not tell mods your plan, so it is set here.',
245 ].join('\n'),
246}
247
248export type Texts = typeof en
249
250const de: Texts = {
251 asOf: (t) => `Stand ${t}`,
252 since: (d) => `seit ${d}`,
253 reload: (cmd) => `neu laden: ${cmd}`,
254 empty: 'Noch keine Einträge. Gezählt wird ab jetzt, nach jeder Antwort.',
255 today: 'Heute',
256 days7: '7 Tage',
257 days30: '30 Tage',
258 allTime: 'Gesamt',
259 chats: (n) => `${n} ${n === 1 ? 'Chat' : 'Chats'}`,
260 last14Days: 'Letzte 14 Tage',
261 last14Weeks: 'Letzte 14 Wochen',
262 highest: '← höchster',
263 noData: 'ohne Angabe',
264 modsPart: (model) => `${model} · Mods`,
265 modsNoData: 'Mods ohne Angabe',
266 unknownModel: 'Unbekannt',
267 projectsIn: (range) => `Projekte · ${range}`,
268 chatsIn: (range) => `Teuerste Chats · ${range}`,
269 modelsIn: (range) => `Modelle · ${range}`,
270 modsIn: (range) => `Mods · ${range}`,
271 noCosts: 'keine Kosten im Zeitraum',
272 more: (n) => `+${n} weitere`,
273 noModCalls: 'keine Modellaufrufe anderer Mods im Zeitraum',
274 modsExtra: 'kommt zu den Chat-Kosten hinzu, steckt nicht in /cost',
275 tokenLine: (i, o, cr, cw) => `Input ${i} · Output ${o} · Cache ${cr} gelesen, ${cw} geschrieben`,
276 noTokens: 'Noch keine Antworten mit Token-Angaben im Zeitraum.',
277 modelsNote: 'Betrag geschätzt aus Tokens × Preistabelle; enthält die Modellaufrufe anderer Mods.',
278 foot: 'API-Wert, im Abo zählt es aufs Kontingent · /ledger weeks · chats · projects · models · help',
279 noCost: 'Dieser Host liefert keine Chat-Kosten; gebucht werden nur Mod-Aufrufe.',
280 unreadable: (n) => `${n} ${n === 1 ? 'Eintrag' : 'Einträge'} unlesbar.`,
281 storeFull: (pct) => `Speicher zu ${pct} % voll (4 MiB): Aufbewahrung (keepDays) senken.`,
282 writeFailed: (err) => `Speichern scheiterte zuletzt: ${err}`,
283 scriptRuns: 'Skript-Läufe',
284 noFolder: '(ohne Ordner)',
285 chatFrom: (t) => `Chat vom ${t}`,
286 sumTitle: (t, tag) => `**Kostenbuch** · Stand ${t} · ${tag}`,
287 sumChats: (today, n, d7, d30, all) => `Chats: heute ${today} (${n}) · 7 Tage ${d7} · 30 Tage ${d30} · gesamt ${all}`,
288 sumMods: (today, d30, all) => `Mods: heute ${today} · 30 Tage ${d30} · gesamt ${all}`,
289 sumProjects: (list) => `Projekte (30 Tage): ${list}`,
290 sumTopChats: (list) => `Teuerste Chats (30 Tage): ${list}`,
291 sumModsList: (list) => `Mods (30 Tage): ${list}`,
292 sumWeeks: (list) => `Wochen (Chat + Mods): ${list}`,
293 sumChatsHead: (range, shown, total) => `Teuerste Chats (${range}), ${shown} von ${total}:`,
294 sumModelsHead: (range) => `Modelle (${range}), geschätzter API-Wert nach Tokens, inkl. Mod-Aufrufe:`,
295 sumModelLine: (name, amt, pct, n, line) => `${name}: ${amt} (${pct} %) · ${n}× · ${line}`,
296 sumMoreModels: (n) => `+${n} weitere Modelle`,
297 sumProjectsHead: (range) => `Projekte (${range}):`,
298 sumProjectRow: (name, amt, n) => `${name} ${amt} (${n} Chats)`,
299 sumFoot: 'Mod-Aufrufe kommen zu den Chat-Kosten hinzu (sie stecken nicht in /cost). API-Wert; im Abo zählt es aufs Kontingent.',
300 unknownArg: (s) => `Unbekannt: „${s}“. Alle Befehle: /ledger help`,
301 helpIntro: 'Bucht nach jeder Antwort, was Chats und andere Mods kosten (API-Wert), und zeigt es nach Tag, Projekt, Chat, Modell und Limit-Fenster.',
302 helpOverview: 'Übersicht: heute, 7 und 30 Tage, gesamt, letzte 14 Tage, Projekte, teuerste Chats, Mods',
303 helpWeeks: 'Die Übersicht mit den letzten 14 Kalenderwochen',
304 helpLists: 'Teuerste Chats · alle Projekte · Modelle mit Tokens; Standard 30 Tage, beliebige Tage von 1 bis 3650',
305 helpLimits: '5-Stunden- und Wochenfenster, Abo-Monat, letzte 5-Stunden-Fenster',
306 helpPlanShow: 'Abo-Einstellung und Syntax anzeigen',
307 helpPlanSet: 'Abo einstellen (pro, max5 oder 5x/max5x, max20 oder 20x/max20x, team, enterprise), Abrechnungstag 1–31 und Monatspreis in $',
308 helpPlanOff: 'Abo-Einstellung löschen',
309 helpReset: 'Alle Einträge löschen (mit Rückfrage; das Abo bleibt)',
310 helpHelp: 'Diese Hilfe (auch: ?)',
311 helpAliases: 'Deutsche Wörter gehen auch: hilfe, alle, heute, aus.',
312 helpPlanName: 'Abo',
313 helpDay: (d) => `Tag ${d}`,
314 helpNoPlan: 'kein Abo',
315 helpStorageName: 'Speicher belegt',
316 helpStorage: (pct) => `${pct} % von 4 MiB`,
317 helpSinceName: 'Erfasst seit',
318 helpInfo: 'nur Info',
319 setLanguage: 'Sprache',
320 setKeepDays: 'Aufbewahrung (Tage)',
321 setDayYellow: 'Tagesbetrag gelb ab ($)',
322 setDayRed: 'Tagesbetrag rot ab ($)',
323 helpFooterTerminal: 'Einstellungen ändern: /plugin configure cost-ledger · Mod abschalten: /plugin disable cost-ledger',
324 helpFooterDesktop: 'Mod abschalten: + → Plugins → Manage plugins · Einstellungen ändern: im Terminal /plugin configure cost-ledger',
325 askQuestion: 'Alle Einträge im Kostenbuch löschen?',
326 askCancel: 'Abbrechen (empfohlen)',
327 askDelete: 'Löschen',
328 notDeletedNoAsk: 'Nicht gelöscht: Die Rückfrage ist hier nicht möglich (z. B. in `-p`).',
329 notDeleted: 'Nicht gelöscht.',
330 cleared: (n) => `Kostenbuch geleert (${n} ${n === 1 ? 'Eintrag' : 'Einträge'}).`,
331 commandDescription: 'Kostenbuch: was Chats, Mods und Modelle gekostet haben',
332 limitsHead: 'cost-ledger · Limits',
333 fiveHours: '5 Stunden',
334 week: 'Woche',
335 fiveShort: '5 Std.',
336 weekShort: 'Woche',
337 planShort: 'Abo',
338 resetAt: (when) => `Reset ${when}`,
339 inTime: (d) => `in ${d}`,
340 projection: (amt) => `100 % ≈ ${amt} (Schätzung)`,
341 projectionLater: 'Hochrechnung ab 5 %',
342 projectionPartial: 'Hochrechnung ab dem nächsten Fenster',
343 projectionNone: 'keine Hochrechnung ohne gebuchten Betrag',
344 partialFrom: (t) => `ab ${t}`,
345 running: 'läuft',
346 resetPassed: 'Reset vorbei, noch keine neue Antwort',
347 resetPassedShort: 'Reset vorbei',
348 noWindowYet: 'noch kein Messwert',
349 noLimitData: 'Keine Limit-Daten: kein Abo erkannt oder seit dem Update noch keine Antwort.',
350 subMonth: 'Abo-Monat',
351 planNotSet: 'Abo nicht eingestellt: /ledger plan max20 14',
352 planValue: (amt, price, f) => `${amt} API-Wert für ${price} Abo = ${f}×`,
353 planValueNoPrice: (amt) => `${amt} API-Wert (kein Preis eingestellt)`,
354 listPrice: 'Listenpreis',
355 renews: (d, n) => `erneuert ${d} (in ${n} ${n === 1 ? 'Tag' : 'Tagen'})`,
356 lastWindows: 'Letzte 5-Stunden-Fenster',
357 noWindows: 'Noch kein 5-Stunden-Fenster erfasst.',
358 avg: (amt, n) => `Ø 100 % ≈ ${amt} aus ${n} ${n === 1 ? 'Fenster' : 'Fenstern'} (≥ 20 %, Schätzung)`,
359 avgNone: 'Ø 100 %: noch kein vollständig erfasstes, abgeschlossenes Fenster mit ≥ 20 %',
360 limitsFoot: '% gilt fürs ganze Konto (auch claude.ai) · $ nur aus Chats mit cost-ledger · Hochrechnungen sind Schätzungen · /ledger plan',
361 sumLimitsTitle: (t, tag) => `**Kostenbuch · Limits** · Stand ${t} · ${tag}`,
362 sumLimitsLine: (list) => `Limits: ${list} · /ledger limits`,
363 sumLimitsFoot: '% gilt fürs ganze Konto (auch claude.ai); $ zählt nur Chats mit cost-ledger; Hochrechnungen sind Schätzungen.',
364 perMonth: (price) => `${price} im Monat`,
365 noPrice: 'ohne Preis',
366 planSaved: (label, day, price, start, next) =>
367 `Abo gespeichert: ${label}, Abrechnungstag ${day}, ${price}. Abo-Monat seit ${start}, erneuert ${next} · Details: /ledger limits`,
368 planCurrent: (label, day, price, at) => `Abo: ${label}, Abrechnungstag ${day}, ${price} (eingestellt ${at}).`,
369 planNone: 'Kein Abo eingestellt.',
370 planDeleted: 'Abo-Einstellung gelöscht.',
371 planInvalid: (s) => `Nicht gespeichert: „${s}“ ist ungültig.`,
372 planHelp: [
373 '**/ledger plan <plan> <tag|heute> [preis]**: `pro`, `max5` (`5x`), `max20` (`20x`), `team` oder `enterprise`; Abrechnungstag 1–31 oder `heute`; Monatspreis in $, Standard ist der Listenpreis (Pro 20 $, Max 5x 100 $, Max 20x 200 $).',
374 'Beispiele: `/ledger plan max20 14` · `/ledger plan max20 heute 180` · `/ledger plan off` löscht die Einstellung. Claude Code verrät Mods den Plan nicht, deshalb wird er hier eingestellt.',
375 ].join('\n'),
376}
377
378export const T: Record<Lang, Texts> = { en, de }
379hooks/logic.ts 1151 lines1// cost-ledger: reine Logik ohne $ (Buchen, Aggregieren, Preise, Kurzfassung). Alles hier ist ohne Engine testbar.
2import { T, clock, dateTime, duration, factor, fullDate, langOf, pct, rangeLabel, resetTime, shortDate, span, tokens, usd, weekLabel } from './i18n.ts'
3import type { Lang } from './i18n.ts'
4import type { HelpData } from './help.ts'
5
6export const DAY = 24 * 60 * 60 * 1000
7
8export type Kind = 'desktop' | 'terminal' | 'script'
9/** Je Mod und Tag; ältere Einträge haben nur usd/calls, keine Tokens. */
10export type ModDay = { usd: number; calls: number; in?: number; out?: number; cr?: number; cw?: number }
11/**
12 * Aktivität je Tag (vorsorglich gesammelt): Antworten der Hauptschleife, Subagent-Turns, Arbeitszeit in ms, Abbrüche,
13 * Fehler/Ablehnungen, teuerste Antwort in $, höchste Kontext-Füllung in %.
14 */
15export type ActDay = { turns: number; sub: number; ms: number; abort: number; err: number; maxTurn: number; ctx: number }
16/** Tokens und geschätzter API-Wert je Modell und Tag; `n` = Antworten bzw. Mod-Aufrufe. */
17export type ModelDay = { in: number; out: number; cr: number; cw: number; usd: number; n: number }
18/** Ein Datensatz pro Session unter `s:<sessionId>`; nur diese Session schreibt ihn (SPEC Zustand). */
19export type Rec = {
20 v: 1
21 project: string
22 root: string
23 title: string
24 kind: Kind
25 firstAt: number
26 lastAt: number
27 /** Letzter gelesener Stand von `usage().cost.usd`; Baseline nach Resume (Resume zählt weiter). */
28 c: number
29 days: Record<string, number>
30 mods: Record<string, { days: Record<string, ModDay> }>
31 /** Je Modell (normalisierte ID, z. B. `opus-5-5`) die Antworten des Chats, auch Subagents. */
32 models: Record<string, { days: Record<string, ModelDay> }>
33 /** Je Modell die Modellaufrufe anderer Mods. */
34 modModels: Record<string, { days: Record<string, ModelDay> }>
35 /** Vorsorglich gesammelt: Aktivität je Tag, Chat-Kosten je Tag und Stunde (`HH`), Limit-Stände je Tag. */
36 act: Record<string, ActDay>
37 hours: Record<string, Record<string, number>>
38 /** Höchster gesehener `percentUsed` je Limit-Fenster (`kind`, z. B. `five_hour`) und Tag. */
39 rl: Record<string, Record<string, number>>
40 /**
41 * Je Limit-Fenster (`five_hour`, `seven_day`) und Reset-Zeitpunkt (ms, auf die Minute gerundet, als String) der API-Wert,
42 * der in dieses Fenster fiel (SPEC Nachtrag 0.5.0).
43 */
44 lim: Record<string, Record<string, LimWin>>
45 /** Git-Remote des Projekts als `host/owner/repo`, ohne Zugangsdaten. */
46 remote: string
47}
48/** Ein Limit-Fenster im Datensatz: API-Wert aus Chat und Mods, höchster `percentUsed`, erste und letzte Buchung. */
49export type LimWin = { chat: number; mod: number; pct: number; first: number; last: number }
50
51export type Settings = { keepDays: number; dayYellow: number; dayRed: number; lang: Lang }
52const DEFAULTS: Settings = { keepDays: 365, dayYellow: 3, dayRed: 8, lang: 'en' }
53
54// Wörter, die `/ledger` annimmt. Parser, Hilfe und ein Test nutzen dieselben Listen: Was der Parser neu lernt, muss in
55// der Hilfe stehen, sonst scheitert der Test (docs/HELP-SPEC.md §6 Punkt 5). Deutsche Wörter sind Aliase (release/I18N.md §3).
56export const HELP_WORDS: readonly string[] = ['help', 'hilfe', '?']
57export const VIEW_WORDS: readonly string[] = ['weeks', 'chats', 'projects', 'models', 'limits']
58export const RANGE_WORDS: readonly string[] = ['all', 'alle']
59export const TODAY_WORDS: readonly string[] = ['today', 'heute']
60export const OFF_WORDS: readonly string[] = ['off', 'aus']
61
62function num(v: unknown, def: number, min: number): number {
63 const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() ? Number(v) : NaN
64 return Number.isFinite(n) && n >= min ? n : def
65}
66
67/** userConfig aus `register(on, options)`; unbrauchbare Werte fallen auf den Standard zurück. */
68export function cleanSettings(o: Readonly<Record<string, unknown>> | undefined): Settings {
69 const s = {
70 keepDays: Math.round(num(o?.keepDays, DEFAULTS.keepDays, 1)),
71 dayYellow: num(o?.dayYellow, DEFAULTS.dayYellow, 0),
72 dayRed: num(o?.dayRed, DEFAULTS.dayRed, 0),
73 lang: langOf(o?.language),
74 }
75 if (s.dayRed < s.dayYellow) s.dayRed = s.dayYellow
76 return s
77}
78
79const pad = (n: number) => String(n).padStart(2, '0')
80
81/** Lokales Datum `YYYY-MM-DD` zu einem Zeitpunkt aus `$.clock.now()`. */
82export function dayKey(ms: number): string {
83 const d = new Date(ms)
84 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
85}
86
87/** Tag `n` Tage vor `ms` (lokal, über Mittag gerechnet, damit Zeitumstellungen nicht verrutschen). */
88export function dayBefore(ms: number, n: number): string {
89 const d = new Date(ms)
90 return dayKey(new Date(d.getFullYear(), d.getMonth(), d.getDate() - n, 12).getTime())
91}
92
93// Dollar je Million Tokens. haiku-5-5 und sonnet-5-5: Stand 2026-10-07 (platform.claude.com/docs/en/about-claude/pricing,
94// SPEC Nachtrag 0.4.3); übrige Zeilen aus mods/sidekick/hooks/cache.ts, Stand 2026-09-25.
95// Längere IDs zuerst: 'opus-5' darf 'opus-5-5' nicht schlucken. `long`: ab `above` Prompt-Tokens gilt das `factor`-Fache für
96// den ganzen Aufruf (Haiku 5.5: zweite Preiszeile „for prompts over 100,000 tokens“, alle Spalten ×5).
97type Price = { input: number; output: number; read: number; long?: { above: number; factor: number } }
98const TABLE: readonly [string, Price][] = [
99 ['fable-5-1', { input: 10, output: 50, read: 0.25 }],
100 ['mythos-5-1', { input: 10, output: 50, read: 0.25 }],
101 ['fable-5', { input: 10, output: 50, read: 1 }],
102 ['opus-5-5', { input: 4, output: 20, read: 0.2 }],
103 ['opus-5', { input: 5, output: 25, read: 0.5 }],
104 ['opus-4-8', { input: 5, output: 25, read: 0.5 }],
105 ['opus-4-7', { input: 5, output: 25, read: 0.5 }],
106 ['opus-4-6', { input: 5, output: 25, read: 0.5 }],
107 ['sonnet-5-5', { input: 2, output: 10, read: 0.1 }],
108 ['sonnet-5', { input: 2, output: 10, read: 0.2 }],
109 ['sonnet-4-6', { input: 3, output: 15, read: 0.3 }],
110 ['haiku-5-5', { input: 0.1, output: 0.5, read: 0.01, long: { above: 100_000, factor: 5 } }],
111 ['haiku-4-5', { input: 1, output: 5, read: 0.1 }],
112]
113// Alias → Eintrag. `haiku` ist seit Claude Code 2.1.293 Haiku 5.5 (Probe 2.1.295: `claude -p --model haiku` →
114// `claude-haiku-5-5`, SPEC Nachtrag 0.6.0); bis 2.1.291 war es Haiku 4.5. Das Ergebnis eines Mod-Aufrufs nennt das
115// aufgelöste Modell nicht (types ModelUsage), deshalb gilt die Zuordnung der getesteten Version.
116const FAMILY: readonly [string, string][] = [
117 ['fable', 'fable-5-1'],
118 ['mythos', 'mythos-5-1'],
119 ['opus', 'opus-5-5'],
120 ['sonnet', 'sonnet-5-5'],
121 ['haiku', 'haiku-5-5'],
122]
123
124/** `claude-opus-5-5-20260101` → `opus-5-5` (ohne Präfix, Kontext-Zusatz und Datum). */
125function bareId(model: string): string {
126 return String(model || '')
127 .toLowerCase()
128 .replace(/^claude-/, '')
129 .replace(/\[.*?\]/g, '')
130 .replace(/-\d{8}$/, '')
131 .trim()
132}
133
134/** Preis je Million Tokens für eine Modell-ID oder einen Alias (`haiku`, `claude-opus-5-5`, `opus[1m]` …). */
135export function priceFor(model: string): { id: string } & Price {
136 const id = bareId(model)
137 for (const [key, p] of TABLE) if (id === key || id.startsWith(key)) return { id: key, ...p }
138 for (const [fam, key] of FAMILY) {
139 const hit = TABLE.find(([k]) => k === key)
140 if (id.includes(fam) && hit) return { id: key, ...hit[1] }
141 }
142 return { id: 'opus-5-5', input: 4, output: 20, read: 0.2 }
143}
144
145export type Usage = {
146 input_tokens?: number
147 output_tokens?: number
148 cache_read_input_tokens?: number
149 cache_creation_input_tokens?: number
150}
151
152/**
153 * API-Wert in $; Cache-Schreiben wie 5-min-TTL (1,25 × Input), wie sidekick completeCost.
154 * Preisstufe (`long`) nur bei `single`, also wenn `u` genau eine Anfrage ist (`model.complete`, types:2525-2528). Turn- und
155 * Fork-Usage sind Summen über mehrere Antworten (types:13262, :6079); dort wäre die Summe kein Prompt, also Faktor 1.
156 * Prompt = `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`. Dass Cache-Tokens mitzählen, ist ein Schluss
157 * aus zwei Seiten, wörtlich steht es nirgends: die Preisseite (…/about-claude/pricing) nennt nur „prompt“, die Seite
158 * …/build-with-claude/context-windows sagt „all three count toward the window“.
159 */
160export function callCost(u: Usage | undefined, model: string, single = false): number {
161 if (!u) return 0
162 const p = priceFor(model)
163 const prompt = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
164 const factor = single && p.long && prompt > p.long.above ? p.long.factor : 1
165 return (
166 (factor *
167 ((u.input_tokens || 0) * p.input +
168 (u.cache_read_input_tokens || 0) * p.read +
169 (u.cache_creation_input_tokens || 0) * p.input * 1.25 +
170 (u.output_tokens || 0) * p.output)) /
171 1e6
172 )
173}
174
175export function tokensOf(u: Usage | undefined): number {
176 return u ? (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : 0
177}
178
179/** Schlüssel für Beträge ohne Modell-Daten (alte Tage). */
180export const UNKNOWN_MODEL = 'unbekannt'
181
182/**
183 * Modell-Schlüssel: `claude-opus-5-5-20260101` → `opus-5-5`. Ein Alias ohne Version (`haiku`, `opus[1m]`, wie ihn Mods
184 * übergeben) wird über die Preistabelle zur aktuellen Version (`haiku-5-5`, `FAMILY`), damit Turns und Mod-Aufrufe in einer Zeile
185 * landen; leer → `UNKNOWN_MODEL`.
186 */
187export function modelKey(model: string): string {
188 const id = bareId(model)
189 if (!id) return UNKNOWN_MODEL
190 return /^[a-z]+$/.test(id) && FAMILY.some(([fam]) => fam === id) ? priceFor(id).id : id
191}
192
193/** `opus-5-5` → `Opus 5.5`, `haiku` → `Haiku`; `UNKNOWN_MODEL` in der eingestellten Sprache. */
194export function modelName(key: string, lang: Lang): string {
195 if (key === UNKNOWN_MODEL) return T[lang].unknownModel
196 const [fam = '', ...ver] = key.split('-')
197 const name = fam ? fam[0]!.toUpperCase() + fam.slice(1) : key
198 return ver.length && ver.every((x) => /^\d+$/.test(x)) ? `${name} ${ver.join('.')}` : [name, ...ver].join(' ')
199}
200
201/** `sidekick@inline` → `sidekick` */
202export function pluginName(p: string): string {
203 return String(p || '').replace(/@.*$/, '')
204}
205
206/**
207 * Git-Remote ohne Zugangsdaten: nur `host/owner/repo`. `https://user:token@github.com/a/b.git` und `git@github.com:a/b.git`
208 * werden zu `github.com/a/b`; Query und Fragment fallen weg. Tokens werden nie gespeichert (CLAUDE.md Grundregel 3).
209 */
210export function cleanRemote(url: string | null | undefined): string {
211 let s = String(url ?? '').trim()
212 if (!s) return ''
213 s = s.replace(/[?#].*$/, '')
214 const scp = /^[^@\s/]+@([^:/\s]+):(.+)$/.exec(s)
215 if (scp) s = `${scp[1]}/${scp[2]}`
216 else s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[^@/]*@/, '')
217 return s.replace(/\.git$/, '').replace(/\/+$/, '').slice(0, 200)
218}
219
220/** Gespeicherter Projektname, wenn es weder Repo noch Ordner gibt (bleibt aus Kompatibilität deutsch, Anzeige übersetzt). */
221export const NO_FOLDER = '(ohne Ordner)'
222
223/** Projektname: Repo-Name, sonst letzter Ordner; Worktrees unter `.claude/worktrees/` zählen zum Hauptordner. */
224export function projectOf(repoName: string | null | undefined, root: string): string {
225 const parts = String(root || '').split(/[\\/]+/).filter(Boolean)
226 const wt = parts.findIndex((p, i) => p === '.claude' && parts[i + 1] === 'worktrees')
227 const folder = wt > 0 ? parts[wt - 1] : parts[parts.length - 1]
228 return (repoName && repoName.trim()) || folder || NO_FOLDER
229}
230
231/**
232 * Name eines Chats aus der ersten eigenen Nachricht (der Desktop meldet den Seitenleisten-Titel nicht). Keine Befehle
233 * (`/…`), keine eingespielten Blöcke (`<…>`), Leerraum zusammengezogen, höchstens 50 Zeichen.
234 */
235export function titleFromPrompt(prompt: string | undefined): string {
236 const t = String(prompt ?? '').replace(/\s+/g, ' ').trim()
237 if (!t || /^[/<!]/.test(t)) return ''
238 return t.length <= 50 ? t : `${t.slice(0, 49).trimEnd()}…`
239}
240
241export function newRec(meta: { project: string; root: string; title: string; kind: Kind; remote?: string }, now: number, c: number): Rec {
242 return {
243 v: 1,
244 project: meta.project,
245 root: meta.root,
246 title: meta.title,
247 kind: meta.kind,
248 firstAt: now,
249 lastAt: now,
250 c,
251 days: {},
252 mods: {},
253 models: {},
254 modModels: {},
255 act: {},
256 hours: {},
257 rl: {},
258 lim: {},
259 remote: meta.remote ?? '',
260 }
261}
262
263const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
264const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
265
266function numMap(src: unknown): Record<string, number> {
267 const out: Record<string, number> = {}
268 if (isObj(src)) for (const [k, x] of Object.entries(src)) if (isNum(x)) out[k] = x
269 return out
270}
271
272function mapOfMaps(src: unknown): Record<string, Record<string, number>> {
273 const out: Record<string, Record<string, number>> = {}
274 if (isObj(src)) for (const [k, x] of Object.entries(src)) if (isObj(x)) out[k] = numMap(x)
275 return out
276}
277
278function cleanModels(src: unknown): Rec['models'] {
279 const out: Rec['models'] = {}
280 if (isObj(src))
281 for (const [name, m] of Object.entries(src)) {
282 if (!isObj(m) || !isObj(m.days)) continue
283 const md: Record<string, ModelDay> = {}
284 for (const [k, x] of Object.entries(m.days))
285 if (isObj(x) && [x.in, x.out, x.cr, x.cw, x.usd, x.n].every(isNum))
286 md[k] = { in: x.in as number, out: x.out as number, cr: x.cr as number, cw: x.cw as number, usd: x.usd as number, n: x.n as number }
287 out[name] = { days: md }
288 }
289 return out
290}
291
292function cleanLim(src: unknown): Rec['lim'] {
293 const out: Rec['lim'] = {}
294 if (isObj(src))
295 for (const [kind, m] of Object.entries(src)) {
296 if (!LIMIT_KINDS.includes(kind) || !isObj(m)) continue
297 const wins: Record<string, LimWin> = {}
298 for (const [k, x] of Object.entries(m))
299 if (/^\d+$/.test(k) && isObj(x) && [x.chat, x.mod, x.pct, x.first, x.last].every(isNum))
300 wins[k] = { chat: Math.max(0, x.chat as number), mod: Math.max(0, x.mod as number), pct: x.pct as number, first: x.first as number, last: x.last as number }
301 out[kind] = wins
302 }
303 return out
304}
305
306/** Gelesenen Datensatz prüfen; `null` bei unbekannter Version oder kaputter Form. Fehlende Felder älterer Fassungen sind leer. */
307export function cleanRec(v: unknown): Rec | null {
308 if (!isObj(v) || v.v !== 1 || !isObj(v.days) || !isNum(v.firstAt) || !isNum(v.lastAt)) return null
309 const days: Record<string, number> = {}
310 for (const [k, x] of Object.entries(v.days)) if (/^\d{4}-\d\d-\d\d$/.test(k) && isNum(x) && x >= 0) days[k] = x
311 const mods: Rec['mods'] = {}
312 if (isObj(v.mods))
313 for (const [name, m] of Object.entries(v.mods)) {
314 if (!isObj(m) || !isObj(m.days)) continue
315 const md: Record<string, ModDay> = {}
316 for (const [k, x] of Object.entries(m.days))
317 if (isObj(x) && isNum(x.usd) && isNum(x.calls)) {
318 const t = numMap(x)
319 md[k] = { usd: Math.max(0, x.usd), calls: Math.max(0, x.calls), ...(isNum(t.in) ? { in: t.in, out: t.out ?? 0, cr: t.cr ?? 0, cw: t.cw ?? 0 } : {}) }
320 }
321 mods[name] = { days: md }
322 }
323 const act: Record<string, ActDay> = {}
324 if (isObj(v.act))
325 for (const [k, x] of Object.entries(v.act)) {
326 const n = numMap(x)
327 act[k] = { turns: n.turns ?? 0, sub: n.sub ?? 0, ms: n.ms ?? 0, abort: n.abort ?? 0, err: n.err ?? 0, maxTurn: n.maxTurn ?? 0, ctx: n.ctx ?? 0 }
328 }
329 return {
330 v: 1,
331 project: typeof v.project === 'string' && v.project ? v.project : NO_FOLDER,
332 root: typeof v.root === 'string' ? v.root : '',
333 title: typeof v.title === 'string' ? v.title : '',
334 kind: v.kind === 'desktop' || v.kind === 'script' ? v.kind : 'terminal',
335 firstAt: v.firstAt,
336 lastAt: v.lastAt,
337 c: isNum(v.c) ? v.c : 0,
338 days,
339 mods,
340 models: cleanModels(v.models),
341 modModels: cleanModels(v.modModels),
342 act,
343 hours: mapOfMaps(v.hours),
344 rl: mapOfMaps(v.rl),
345 lim: cleanLim(v.lim),
346 remote: typeof v.remote === 'string' ? cleanRemote(v.remote) : '',
347 }
348}
349
350// ---------- Buchen ----------
351
352/** Neue Baseline nach Start oder Resume: der gespeicherte Stand, wenn er zum Zähler passt, sonst der Zähler selbst. */
353export function baselineFor(rec: Rec | null, cost: number): number {
354 return rec && rec.c > 0 && rec.c <= cost ? rec.c : cost
355}
356
357/** Differenz zum letzten Stand; fällt der Zähler, beginnt er neu bei 0. */
358export function deltaOf(seen: number, cost: number): { delta: number; seen: number } {
359 const base = cost < seen ? 0 : seen
360 return { delta: Math.max(0, cost - base), seen: cost }
361}
362
363/** Auf 1e-8 $ runden: hält die Datensätze kurz (Gleitkomma-Reste wie 0.13945199999999998), ohne sichtbaren Fehler. */
364const r8 = (v: number) => Math.round(v * 1e8) / 1e8
365
366export function bookChat(rec: Rec, day: string, delta: number, now: number, cost: number): void {
367 if (delta > 0) {
368 rec.days[day] = r8((rec.days[day] ?? 0) + delta)
369 const h = (rec.hours[day] ??= {})
370 const hour = pad(new Date(now).getHours())
371 h[hour] = r8((h[hour] ?? 0) + delta)
372 }
373 rec.c = cost
374 rec.lastAt = now
375}
376
377export function bookMod(rec: Rec, name: string, day: string, amount: number, now: number, u?: Usage): void {
378 const m = (rec.mods[name] ??= { days: {} })
379 const d = (m.days[day] ??= { usd: 0, calls: 0 })
380 d.usd = r8(d.usd + Math.max(0, amount))
381 d.calls += 1
382 if (u) {
383 d.in = (d.in ?? 0) + (u.input_tokens || 0)
384 d.out = (d.out ?? 0) + (u.output_tokens || 0)
385 d.cr = (d.cr ?? 0) + (u.cache_read_input_tokens || 0)
386 d.cw = (d.cw ?? 0) + (u.cache_creation_input_tokens || 0)
387 }
388 rec.lastAt = now
389}
390
391/** Eine Antwort (`models`) oder einen Mod-Aufruf (`modModels`) je Modell buchen; Betrag geschätzt nach Preistabelle. */
392export function bookModel(rec: Rec, model: string, day: string, u: Usage | undefined, now: number, into: 'models' | 'modModels' = 'models', single = false): void {
393 if (!u) return
394 const m = (rec[into][modelKey(model)] ??= { days: {} })
395 const d = (m.days[day] ??= { in: 0, out: 0, cr: 0, cw: 0, usd: 0, n: 0 })
396 d.in += u.input_tokens || 0
397 d.out += u.output_tokens || 0
398 d.cr += u.cache_read_input_tokens || 0
399 d.cw += u.cache_creation_input_tokens || 0
400 d.usd = r8(d.usd + callCost(u, model, single))
401 d.n += 1
402 rec.lastAt = now
403}
404
405export type TurnInfo = { agentId?: string; durationMs?: number; reason?: string; isAborted?: boolean }
406
407/** Aktivität eines Turns: Antwort oder Subagent, Dauer, Abbruch/Fehler, teuerste Antwort, Kontext-Spitze. */
408export function bookTurn(rec: Rec, day: string, t: TurnInfo, delta: number, ctxPct: number | undefined): void {
409 const a = (rec.act[day] ??= { turns: 0, sub: 0, ms: 0, abort: 0, err: 0, maxTurn: 0, ctx: 0 })
410 if (t.agentId) a.sub += 1
411 else {
412 a.turns += 1
413 a.ms += Math.max(0, Math.round(t.durationMs || 0))
414 if (delta > a.maxTurn) a.maxTurn = r8(delta)
415 }
416 if (t.isAborted || t.reason === 'aborted') a.abort += 1
417 if (t.reason === 'error' || t.reason === 'refusal') a.err += 1
418 if (isNum(ctxPct) && ctxPct > a.ctx) a.ctx = Math.round(ctxPct)
419}
420
421/** Höchster Limit-Stand des Tages je Fenster (5 Stunden, Woche …), für spätere Auswertungen. */
422export function bookRate(rec: Rec, day: string, limits: readonly { kind: string; percentUsed: number }[] | undefined): void {
423 for (const l of limits ?? []) {
424 if (!l || typeof l.kind !== 'string' || !isNum(l.percentUsed)) continue
425 const d = (rec.rl[day] ??= {})
426 const v = Math.round(l.percentUsed * 10) / 10
427 if (!(d[l.kind]! >= v)) d[l.kind] = v
428 }
429}
430
431// ---------- Limit-Fenster und Abo (SPEC Nachtrag 0.5.0) ----------
432
433/** Fenster, die gebucht werden; `spend_limit` (Gateway) und Unbekanntes bleiben außen vor (types:10953). */
434export const LIMIT_KINDS: readonly string[] = ['five_hour', 'seven_day']
435const MINUTE = 60 * 1000
436const HOUR = 60 * MINUTE
437/**
438 * Je Fenster: Länge (aus dem Namen abgeleitet, nur für Beschriftung und „unvollständig“), Toleranz beim Zusammenlegen in
439 * der Aggregation (Absicherung, Phase 0: `resetsAt` war über Antworten und parallele Sessions gleich) und wie viele
440 * Einträge ein Datensatz höchstens behält.
441 */
442const LIM: Record<string, { span: number; tol: number; keep: number }> = {
443 five_hour: { span: 5 * HOUR, tol: 10 * MINUTE, keep: 60 },
444 seven_day: { span: 7 * DAY, tol: 60 * MINUTE, keep: 12 },
445}
446
447/** Ein Fenster, wie `usage().rateLimits` es zuletzt meldete; `resetsAt` in ms. */
448export type Limit = { kind: string; pct: number; resetsAt: number }
449/** Beträge, die auf das nächste gültige Fenster warten (Fenster abgelaufen oder noch unbekannt). */
450export type Carry = Record<string, { chat: number; mod: number }>
451
452/** `five_hour` und `seven_day` mit Zahl und parsebarem `resetsAt` (types:10948-10966); sonst nichts. */
453export function limitsOf(rl: readonly { kind: string; percentUsed: number; resetsAt?: string }[] | undefined): Limit[] {
454 const out: Limit[] = []
455 for (const l of rl ?? []) {
456 if (!l || !LIMIT_KINDS.includes(l.kind) || !isNum(l.percentUsed) || out.some((x) => x.kind === l.kind)) continue
457 const at = typeof l.resetsAt === 'string' ? Date.parse(l.resetsAt) : NaN
458 if (Number.isFinite(at)) out.push({ kind: l.kind, pct: l.percentUsed, resetsAt: at })
459 }
460 return out
461}
462
463/** Schlüssel eines Fensters: `resetsAt` auf die volle Minute gerundet, als String (ms). */
464export function limKey(resetsAt: number): string {
465 return String(Math.round(resetsAt / MINUTE) * MINUTE)
466}
467
468/**
469 * Betrag (`chat`: Delta wie in `bookChat`, `mod`: Mod-Aufruf) dem laufenden Fenster je Art gutschreiben und dessen
470 * `percentUsed` als Höchstwert merken. Ist das Fenster abgelaufen (`now ≥ resetsAt`) oder unbekannt, wartet der Betrag in
471 * `carry` und kommt beim nächsten gültigen Fenster dazu. Je Datensatz bleiben die neuesten 60 bzw. 12 Fenster.
472 * Gibt zurück, ob ein Fenster gebucht wurde.
473 */
474export function bookLim(rec: Rec, limits: readonly Limit[], now: number, part: 'chat' | 'mod', amount: number, carry: Carry): boolean {
475 const v = Math.max(0, amount || 0)
476 let booked = false
477 for (const kind of LIMIT_KINDS) {
478 const l = limits.find((x) => x.kind === kind)
479 if (!l || now >= l.resetsAt) {
480 if (v > 0) {
481 const c = (carry[kind] ??= { chat: 0, mod: 0 })
482 c[part] = r8(c[part] + v)
483 }
484 continue
485 }
486 const wins = (rec.lim[kind] ??= {})
487 const e = (wins[limKey(l.resetsAt)] ??= { chat: 0, mod: 0, pct: 0, first: now, last: now })
488 const c = carry[kind]
489 if (c) {
490 e.chat = r8(e.chat + c.chat)
491 e.mod = r8(e.mod + c.mod)
492 delete carry[kind]
493 }
494 e[part] = r8(e[part] + v)
495 if (l.pct > e.pct) e.pct = l.pct
496 e.last = now
497 booked = true
498 const keys = Object.keys(wins).sort((a, b) => Number(a) - Number(b))
499 for (const k of keys.slice(0, Math.max(0, keys.length - LIM[kind]!.keep))) delete wins[k]
500 }
501 return booked
502}
503
504/**
505 * Nachbuchung beim Start (Rest nach der letzten Buchung, z. B. nach hartem Ende und `--resume`): Sie gehört in das Fenster,
506 * in das die letzte Buchung `at` fiel, nicht ins laufende. Gibt es dort keinen Eintrag (Buchung vor 0.5.0, ohne Abo),
507 * bleibt der Betrag nur im Tag.
508 */
509export function bookLimAt(rec: Rec, at: number, amount: number): void {
510 if (!(amount > 0)) return
511 for (const kind of LIMIT_KINDS) {
512 const wins = rec.lim[kind]
513 if (!wins) continue
514 let hit: string | null = null
515 for (const [k, e] of Object.entries(wins)) if (Number(k) > at && e.first <= at && (hit === null || Number(k) < Number(hit))) hit = k
516 if (hit !== null) wins[hit]!.chat = r8(wins[hit]!.chat + amount)
517 }
518}
519
520/** Abo-Pläne mit Listenpreis in $ je Monat: Pro claude.com/pricing (monatlich), Max support.claude.com Artikel 11049741 (2026-10-08). */
521export const PLANS: Record<string, { label: string; price?: number }> = {
522 pro: { label: 'Pro', price: 20 },
523 max5: { label: 'Max 5x', price: 100 },
524 max20: { label: 'Max 20x', price: 200 },
525 team: { label: 'Team' },
526 enterprise: { label: 'Enterprise' },
527}
528const PLAN_ALIASES: Record<string, string> = { pro: 'pro', max5: 'max5', max5x: 'max5', '5x': 'max5', max20: 'max20', max20x: 'max20', '20x': 'max20', team: 'team', enterprise: 'enterprise' }
529/** Nur eigene Schlüssel: `constructor` oder `__proto__` sind kein Plan. */
530const own = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k)
531
532/** Alle Schreibweisen eines Plans, die `/ledger plan` annimmt (für Hilfe und Vollständigkeitstest). */
533export function planWords(): string[] {
534 return Object.keys(PLAN_ALIASES)
535}
536
537/** `meta:plan`: Plan, Abrechnungstag, eigener Preis (ohne: Listenpreis), Zeitpunkt der Einstellung. */
538export type Plan = { v: 1; plan: string; day: number; price?: number; at: number }
539
540export function planLabel(plan: string): string {
541 return own(PLANS, plan) ? PLANS[plan]!.label : plan
542}
543
544/** Gespeicherte Einstellung prüfen; `null` bei fehlender oder kaputter. */
545export function cleanPlan(v: unknown): Plan | null {
546 if (!isObj(v) || v.v !== 1 || typeof v.plan !== 'string' || !own(PLANS, v.plan) || !isNum(v.day) || !isNum(v.at)) return null
547 if (!Number.isInteger(v.day) || v.day < 1 || v.day > 31) return null
548 return { v: 1, plan: v.plan, day: v.day, ...(isNum(v.price) && v.price > 0 ? { price: v.price } : {}), at: v.at }
549}
550
551/**
552 * `/ledger plan <plan> <tag|heute> [preis]` → Einstellung, sonst `null` (dann wird nichts gespeichert). Plan in beliebiger
553 * Schreibweise samt Aliasen, Tag 1–31 oder `today`/`heute`, Preis mit Komma oder Punkt, optional mit `$`.
554 */
555export function parsePlan(args: readonly string[], now: number): Plan | null {
556 const [p = '', d = '', price, ...rest] = args.map((a) => a.trim().toLowerCase())
557 const plan = own(PLAN_ALIASES, p) ? PLAN_ALIASES[p] : undefined
558 if (!plan || rest.length) return null
559 const day = TODAY_WORDS.includes(d) ? new Date(now).getDate() : /^\d{1,2}$/.test(d) ? Number(d) : NaN
560 if (!Number.isInteger(day) || day < 1 || day > 31) return null
561 if (price === undefined) return { v: 1, plan, day, at: now }
562 const s = price.replace(/^\$|\$$/g, '').replace(',', '.')
563 const n = /^\d{1,6}(\.\d{1,2})?$/.test(s) ? Number(s) : NaN
564 return n > 0 ? { v: 1, plan, day, price: n, at: now } : null
565}
566
567/** Tag `day` im Monat (Jahr, Monat 0–11), gekürzt auf die Länge des Monats (31. → 30.09.). */
568function billingDay(y: number, m: number, day: number): Date {
569 const len = new Date(y, m + 1, 0, 12).getDate()
570 return new Date(y, m, Math.min(day, len), 12)
571}
572
573/**
574 * Laufender Abo-Monat zum Abrechnungstag: Beginn = letzter Tag ≤ heute mit dem Tag `min(day, Länge des Monats)`,
575 * nächste Erneuerung einen Monat später, Resttage bis dahin (lokal, über Mittag gerechnet).
576 */
577export function planMonth(day: number, now: number): { start: string; next: string; daysLeft: number } {
578 const t = new Date(now)
579 let s = billingDay(t.getFullYear(), t.getMonth(), day)
580 if (t.getDate() < s.getDate()) s = billingDay(t.getFullYear(), t.getMonth() - 1, day)
581 const n = billingDay(s.getFullYear(), s.getMonth() + 1, day)
582 const noon = new Date(t.getFullYear(), t.getMonth(), t.getDate(), 12).getTime()
583 return { start: dayKey(s.getTime()), next: dayKey(n.getTime()), daysLeft: Math.round((n.getTime() - noon) / DAY) }
584}
585
586/** Ein Fenster in der Auswertung, über alle Datensätze summiert und zusammengelegt. */
587export type LimWindow = {
588 kind: string
589 resetsAt: number
590 /** `resetsAt` minus Fensterlänge (aus dem Namen abgeleitet) */
591 start: number
592 usd: number
593 pct: number
594 running: boolean
595 /** Begann vor der ersten Fenster-Buchung (`meta:limSince`): der Betrag ist unvollständig */
596 partial: boolean
597 /** Hochrechnung `usd ÷ pct × 100`, erst ab 5 % und nur für vollständige Fenster */
598 proj: number | null
599}
600export type PlanReport = {
601 plan: string
602 day: number
603 price: number | null
604 /** Kein eigener Preis: Listenpreis des Plans */
605 listPrice: boolean
606 start: string
607 next: string
608 daysLeft: number
609 /** API-Wert seit Beginn des Abo-Monats aus Datensätzen, die Abo-Limits gesehen haben */
610 usd: number
611 factor: number | null
612 /** Beginn liegt vor `meta:since`: gezählt ab diesem Tag */
613 from: string | null
614}
615export type LimitsReport = {
616 five: LimWindow | null
617 week: LimWindow | null
618 seenFive: boolean
619 seenWeek: boolean
620 /** Letzte 10 Fünf-Stunden-Fenster, neueste zuerst */
621 history: LimWindow[]
622 /** Ø „100 % ≈ usd“ über abgeschlossene, vollständige Fünf-Stunden-Fenster ab 20 % */
623 avg: { usd: number; n: number } | null
624 plan: PlanReport | null
625 limSince: number | null
626}
627
628/** Hat der Datensatz je Abo-Limits gesehen? Läufe mit API-Schlüssel (ohne Limits) zählen nicht zum Abo-Monat. */
629function sawLimits(rec: Rec): boolean {
630 return Object.values(rec.rl).some((d) => LIMIT_KINDS.some((k) => k in d)) || Object.values(rec.lim).some((w) => Object.keys(w).length > 0)
631}
632
633/**
634 * Limits und Abo für `/ledger limits` und die Zeile in der Übersicht. Je Fenster werden `chat + mod` über alle Datensätze
635 * summiert, `pct` ist das Maximum (innerhalb eines Fensters steigt der Wert nur). Schlüssel, die näher als die Toleranz
636 * beieinanderliegen, werden zusammengelegt. `live`: die Fenster der letzten Messung dieses Prozesses (nur für `pct`).
637 */
638export function limitsReport(
639 recs: { rec: Rec }[],
640 now: number,
641 o: { plan?: Plan | null; limSince?: number | null; since?: number | null; live?: readonly Limit[] } = {},
642): LimitsReport {
643 const byKind = new Map<string, Map<number, { usd: number; pct: number }>>(LIMIT_KINDS.map((k) => [k, new Map()]))
644 let first = Infinity
645 for (const { rec } of recs)
646 for (const [kind, wins] of Object.entries(rec.lim)) {
647 const m = byKind.get(kind)
648 if (m)
649 for (const [k, w] of Object.entries(wins)) {
650 const x = m.get(Number(k)) ?? { usd: 0, pct: 0 }
651 x.usd += w.chat + w.mod
652 x.pct = Math.max(x.pct, w.pct)
653 m.set(Number(k), x)
654 first = Math.min(first, w.first)
655 }
656 }
657 for (const l of o.live ?? []) {
658 const m = byKind.get(l.kind)
659 if (!m || l.resetsAt <= now) continue
660 const at = Number(limKey(l.resetsAt))
661 const x = m.get(at) ?? { usd: 0, pct: 0 }
662 x.pct = Math.max(x.pct, l.pct)
663 m.set(at, x)
664 }
665 // Beginn der Fenster-Daten: `meta:limSince`, aber nie später als die früheste vorhandene Fenster-Buchung (setzt ein
666 // Prozess den Wert erst nach Buchungen anderer Chats, z. B. nach einem Reset, würden deren Fenster sonst rückwirkend
667 // unvollständig; Review 0.5.0). Ohne `meta:limSince` gilt die früheste Buchung.
668 const firstAt = Number.isFinite(first) ? first : null
669 const limSince = typeof o.limSince === 'number' && firstAt !== null ? Math.min(o.limSince, firstAt) : (o.limSince ?? firstAt)
670 const windows = (kind: string): LimWindow[] => {
671 const spec = LIM[kind]!
672 const merged: { resetsAt: number; usd: number; pct: number }[] = []
673 for (const [at, x] of [...byKind.get(kind)!.entries()].sort((a, b) => a[0] - b[0])) {
674 const last = merged[merged.length - 1]
675 if (last && at - last.resetsAt < spec.tol) {
676 last.resetsAt = at
677 last.usd += x.usd
678 last.pct = Math.max(last.pct, x.pct)
679 } else merged.push({ resetsAt: at, usd: x.usd, pct: x.pct })
680 }
681 // Hochrechnung nur für vollständige Fenster: Bei einem unvollständigen fehlt der API-Wert vor `limSince`, die Prozent
682 // zählen aber das ganze Fenster; die Zahl wäre systematisch zu niedrig (Smoke-Test 2026-10-08: Woche „100 % ≈ 2,37 $“).
683 // Ohne gebuchten Betrag (Host ohne Kostenbuch) gibt es ebenfalls keine.
684 return merged.map((w) => {
685 const start = w.resetsAt - spec.span
686 const partial = limSince === null || start < limSince
687 return { kind, ...w, start, running: w.resetsAt > now, partial, proj: w.pct >= 5 && w.usd > 0 && !partial ? (w.usd / w.pct) * 100 : null }
688 })
689 }
690 const five = windows('five_hour')
691 const week = windows('seven_day')
692 // Ø nur über die neuesten 60 Fenster: Ein Datensatz behält höchstens 60 (`bookLim`); in älteren Fenstern fehlte sonst
693 // der gekürzte Betrag eines langen Chats, während andere Chats das volle `pct` liefern (Review 0.5.0)
694 // Fenster ohne gebuchten Betrag (Host ohne Kostenbuch) zählen nicht
695 const done = five.slice(-LIM.five_hour!.keep).filter((w) => !w.running && !w.partial && w.pct >= 20 && w.usd > 0)
696 const pctSum = done.reduce((a, w) => a + w.pct, 0)
697
698 let plan: PlanReport | null = null
699 if (o.plan) {
700 const pm = planMonth(o.plan.day, now)
701 const today = dayKey(now)
702 const inMonth = (d: string) => d >= pm.start && d <= today
703 let usd = 0
704 for (const { rec } of recs) {
705 if (!sawLimits(rec)) continue
706 for (const [d, v] of Object.entries(rec.days)) if (inMonth(d)) usd += v
707 for (const m of Object.values(rec.mods)) for (const [d, v] of Object.entries(m.days)) if (inMonth(d)) usd += v.usd
708 }
709 const price = o.plan.price ?? PLANS[o.plan.plan]?.price ?? null
710 const sinceDay = typeof o.since === 'number' ? dayKey(o.since) : null
711 plan = {
712 plan: o.plan.plan,
713 day: o.plan.day,
714 price,
715 listPrice: o.plan.price === undefined && price !== null,
716 ...pm,
717 usd,
718 factor: price ? usd / price : null,
719 from: sinceDay && pm.start < sinceDay ? sinceDay : null,
720 }
721 }
722 return {
723 five: five.find((w) => w.running) ?? null,
724 week: week.find((w) => w.running) ?? null,
725 seenFive: five.length > 0,
726 seenWeek: week.length > 0,
727 history: five.slice(-10).reverse(),
728 avg: pctSum > 0 ? { usd: (done.reduce((a, w) => a + w.usd, 0) / pctSum) * 100, n: done.length } : null,
729 plan,
730 limSince,
731 }
732}
733
734// ---------- Aggregation für /ledger ----------
735
736/** Gruppen-Schlüssel der Skript-Läufe in Projekten (Anzeige übersetzt). */
737export const SCRIPT_PROJECT = ':script'
738
739export type Window = { usd: number; chats: number; mods: number }
740/** `title` leer = kein Name bekannt; die Anzeige nimmt dann „Chat vom …“ mit `firstAt`. */
741export type ChatRow = { id: string; title: string; firstAt: number; project: string; usd: number; lastDay: string; kind: Kind }
742export type ProjectRow = { name: string; usd: number; chats: number }
743export type ModRow = { name: string; usd: number; calls: number }
744export type ModelRow = { key: string; in: number; out: number; cr: number; cw: number; usd: number; n: number }
745/** Ein Balken im Verlauf: Tag oder Woche (Montag), Chat- und Mod-Kosten und ihre Aufteilung nach Modell (`MOD_PART`). */
746export type Bucket = { key: string; usd: number; parts: { key: string; usd: number }[] }
747export type Report = {
748 now: number
749 since: number | null
750 windows: { today: Window; d7: Window; d30: Window; all: Window }
751 projects: ProjectRow[]
752 chats: ChatRow[]
753 mods: ModRow[]
754 models: ModelRow[]
755 /** Verlauf: 14 Tage bzw. 14 Wochen, jeweils neueste zuerst */
756 series: { days: Bucket[]; weeks: Bucket[] }
757 unreadable: number
758 hasCost: boolean
759 total: number
760 /** Geschätzte Größe des Plugin-Speichers in Byte (Grenze 4 MiB, docs/raw/en/reference.md:294) */
761 storeBytes: number
762 writeError: string
763 limits: LimitsReport
764}
765
766export const STORE_LIMIT = 4 * 1024 * 1024
767
768/** `range`: Anzahl Tage einschließlich heute, oder `0` = alles. */
769export function aggregate(
770 recs: { id: string; rec: Rec }[],
771 now: number,
772 opts: {
773 range?: number
774 unreadable?: number
775 hasCost?: boolean
776 since?: number | null
777 storeBytes?: number
778 writeError?: string
779 plan?: Plan | null
780 limSince?: number | null
781 live?: readonly Limit[]
782 },
783): Report {
784 const today = dayKey(now)
785 const inRange = (d: string, n: number) => (n <= 0 ? true : d >= dayBefore(now, n - 1) && d <= today)
786 const range = opts.range ?? 30
787 const win = (n: number): Window => {
788 let sum = 0
789 let chats = 0
790 let mods = 0
791 for (const { rec } of recs) {
792 let mine = 0
793 for (const [d, v] of Object.entries(rec.days)) if (inRange(d, n)) mine += v
794 for (const m of Object.values(rec.mods)) for (const [d, v] of Object.entries(m.days)) if (inRange(d, n)) mods += v.usd
795 sum += mine
796 if (mine > 0) chats++
797 }
798 return { usd: sum, chats, mods }
799 }
800
801 const proj = new Map<string, ProjectRow>()
802 const chats: ChatRow[] = []
803 const mods = new Map<string, ModRow>()
804 const models = new Map<string, ModelRow>()
805 for (const { id, rec } of recs) {
806 for (const [key, m] of [...Object.entries(rec.models), ...Object.entries(rec.modModels)])
807 for (const [d, v] of Object.entries(m.days))
808 if (inRange(d, range)) {
809 const row = models.get(key) ?? { key, in: 0, out: 0, cr: 0, cw: 0, usd: 0, n: 0 }
810 row.in += v.in
811 row.out += v.out
812 row.cr += v.cr
813 row.cw += v.cw
814 row.usd += v.usd
815 row.n += v.n
816 models.set(key, row)
817 }
818 let mine = 0
819 let lastDay = ''
820 for (const [d, v] of Object.entries(rec.days))
821 if (inRange(d, range) && v > 0) {
822 mine += v
823 if (d > lastDay) lastDay = d
824 }
825 if (mine > 0) {
826 const name = rec.kind === 'script' ? SCRIPT_PROJECT : rec.project
827 const p = proj.get(name) ?? { name, usd: 0, chats: 0 }
828 p.usd += mine
829 p.chats++
830 proj.set(name, p)
831 chats.push({ id, title: rec.title.trim(), firstAt: rec.firstAt, project: name, usd: mine, lastDay, kind: rec.kind })
832 }
833 for (const [name, m] of Object.entries(rec.mods))
834 for (const [d, v] of Object.entries(m.days))
835 if (inRange(d, range)) {
836 const row = mods.get(name) ?? { name, usd: 0, calls: 0 }
837 row.usd += v.usd
838 row.calls += v.calls
839 mods.set(name, row)
840 }
841 }
842 return {
843 now,
844 since: opts.since ?? null,
845 windows: { today: win(1), d7: win(7), d30: win(30), all: win(0) },
846 projects: [...proj.values()].sort((a, b) => b.usd - a.usd || a.name.localeCompare(b.name)),
847 chats: chats.sort((a, b) => b.usd - a.usd || b.lastDay.localeCompare(a.lastDay)),
848 mods: [...mods.values()].sort((a, b) => b.usd - a.usd || b.calls - a.calls),
849 models: [...models.values()].sort((a, b) => b.usd - a.usd || b.n - a.n),
850 series: seriesOf(recs, now),
851 unreadable: opts.unreadable ?? 0,
852 hasCost: opts.hasCost ?? true,
853 total: recs.length,
854 storeBytes: opts.storeBytes ?? 0,
855 writeError: opts.writeError ?? '',
856 limits: limitsReport(recs, now, { plan: opts.plan, limSince: opts.limSince, since: opts.since, live: opts.live }),
857 }
858}
859
860/** Montag der Woche zu `YYYY-MM-DD` (lokal). */
861export function weekStart(day: string): string {
862 const [y, m, d] = day.split('-').map(Number) as [number, number, number]
863 const dt = new Date(y, m - 1, d, 12)
864 dt.setDate(dt.getDate() - ((dt.getDay() + 6) % 7))
865 return dayKey(dt.getTime())
866}
867
868/** ISO-Kalenderwoche zu einem Montag `YYYY-MM-DD`. */
869export function isoWeek(monday: string): number {
870 const [y, m, d] = monday.split('-').map(Number) as [number, number, number]
871 const thu = new Date(y, m - 1, d + 3, 12) // Donnerstag derselben Woche bestimmt das Jahr
872 const week1 = new Date(thu.getFullYear(), 0, 4, 12) // 4. Januar liegt immer in KW 1
873 return 1 + Math.round(((thu.getTime() - week1.getTime()) / DAY - 3 + ((week1.getDay() + 6) % 7)) / 7)
874}
875
876/** Teil eines Verlaufsbalkens, der aus Mod-Aufrufen stammt: `mod:sonnet-5-5`, ohne Modell-Daten `mod:unbekannt`. */
877export const MOD_PART = 'mod:'
878
879/** Legendentext eines Verlaufsteils: `opus-5-5` → `Opus 5.5`, `mod:sonnet-5-5` → `Sonnet 5.5 · Mods`. */
880export function partLabel(key: string, lang: Lang): string {
881 const t = T[lang]
882 if (!key.startsWith(MOD_PART)) return key === UNKNOWN_MODEL ? t.noData : modelName(key, lang)
883 const model = key.slice(MOD_PART.length)
884 return model === UNKNOWN_MODEL ? t.modsNoData : t.modsPart(modelName(model, lang))
885}
886
887/**
888 * Verlauf je Tag und je Woche. Die echten Chat-Kosten eines Tages werden je Chat nach den geschätzten Modell-Anteilen
889 * dieses Tages aufgeteilt; ohne Modell-Daten fällt der Betrag unter `UNKNOWN_MODEL`. Dazu kommen die Mod-Aufrufe je
890 * Modell als eigene Teile (`MOD_PART`); was in `mods` steht, aber nicht in `modModels` (Daten vor 0.3.0), wird
891 * `mod:unbekannt`. Der Balken zeigt damit Chat + Mods, wie die Kacheln zusammen.
892 */
893function seriesOf(recs: { rec: Rec }[], now: number): { days: Bucket[]; weeks: Bucket[] } {
894 const days = new Map(Array.from({ length: 14 }, (_, i) => [dayBefore(now, i), new Map<string, number>()] as const))
895 // Gleicher Wochentag i Wochen zurück → Montag jener Woche
896 const weeks = new Map(Array.from({ length: 14 }, (_, i) => [weekStart(dayBefore(now, i * 7)), new Map<string, number>()] as const))
897 const add = (bucket: Map<string, number> | undefined, key: string, v: number) => {
898 if (bucket && v > 0) bucket.set(key, (bucket.get(key) ?? 0) + v)
899 }
900 for (const { rec } of recs)
901 for (const [d, chat] of Object.entries(rec.days)) {
902 if (!(chat > 0)) continue
903 const shares: [string, number][] = []
904 for (const [k, m] of Object.entries(rec.models)) {
905 const v = m.days[d]?.usd ?? 0
906 if (v > 0) shares.push([k, v])
907 }
908 const sum = shares.reduce((a, [, v]) => a + v, 0)
909 const parts: [string, number][] = sum > 0 ? shares.map(([k, v]) => [k, (chat * v) / sum]) : [[UNKNOWN_MODEL, chat]]
910 for (const [k, v] of parts) {
911 add(days.get(d), k, v)
912 add(weeks.get(weekStart(d)), k, v)
913 }
914 }
915 for (const { rec } of recs) {
916 const modDays = new Map<string, number>()
917 for (const m of Object.values(rec.mods)) for (const [d, v] of Object.entries(m.days)) modDays.set(d, (modDays.get(d) ?? 0) + v.usd)
918 for (const [k, m] of Object.entries(rec.modModels))
919 for (const [d, v] of Object.entries(m.days)) {
920 add(days.get(d), MOD_PART + k, v.usd)
921 add(weeks.get(weekStart(d)), MOD_PART + k, v.usd)
922 modDays.set(d, (modDays.get(d) ?? 0) - v.usd)
923 }
924 // Rest ohne Modell; Rundungsreste aus r8 nicht als eigenen Teil zeigen
925 for (const [d, rest] of modDays)
926 if (rest > 1e-6) {
927 add(days.get(d), MOD_PART + UNKNOWN_MODEL, rest)
928 add(weeks.get(weekStart(d)), MOD_PART + UNKNOWN_MODEL, rest)
929 }
930 }
931 const toBuckets = (m: Map<string, Map<string, number>>): Bucket[] =>
932 [...m.entries()].map(([key, parts]) => {
933 const list = [...parts.entries()].map(([k, v]) => ({ key: k, usd: v })).sort((a, b) => b.usd - a.usd)
934 return { key, usd: list.reduce((a, p) => a + p.usd, 0), parts: list }
935 })
936 return { days: toBuckets(days), weeks: toBuckets(weeks) }
937}
938
939/** Argument `7|30|all` → Tage (0 = alles); sonst Standard 30. `alle` bleibt als Alias gültig. */
940export function parseRange(a: string | undefined): number {
941 const s = (a ?? '').trim().toLowerCase()
942 if (RANGE_WORDS.includes(s)) return 0
943 const n = Number(s)
944 return Number.isInteger(n) && n > 0 && n <= 3650 ? n : 30
945}
946
947// ---------- Anzeige-Bausteine, die Text und Zeichnung teilen ----------
948
949/** Projektname zur Anzeige: Skript-Läufe und „ohne Ordner“ in der eingestellten Sprache. */
950export function projectLabel(name: string, lang: Lang): string {
951 if (name === SCRIPT_PROJECT) return T[lang].scriptRuns
952 if (name === NO_FOLDER) return T[lang].noFolder
953 return name
954}
955
956/** Name eines Chats zur Anzeige; ohne Titel „Chat vom …“ bzw. „Chat from …“. */
957export function chatLabel(c: { title: string; firstAt: number }, lang: Lang): string {
958 return c.title || T[lang].chatFrom(dateTime(c.firstAt, lang))
959}
960
961/** Hinweise für Text und Zeichnung: Host ohne Kosten, unlesbare Einträge, Speicher fast voll, Schreibfehler. */
962export function notesOf(r: Report, lang: Lang): string[] {
963 const t = T[lang]
964 const out: string[] = []
965 if (!r.hasCost) out.push(t.noCost)
966 if (r.unreadable) out.push(t.unreadable(r.unreadable))
967 if (r.storeBytes >= STORE_LIMIT * 0.75) out.push(t.storeFull(Math.round((r.storeBytes / STORE_LIMIT) * 100)))
968 if (r.writeError) out.push(t.writeFailed(r.writeError))
969 return out
970}
971
972export type ViewName = 'overview' | 'chats' | 'projects' | 'models' | 'weeks' | 'limits'
973
974/** Seit wann ein unvollständiges Fenster zählt: 5 Stunden mit Uhrzeit, Woche mit Datum. */
975export function partialLabel(w: LimWindow, limSince: number | null, lang: Lang): string {
976 if (!w.partial || limSince === null) return ''
977 return T[lang].partialFrom(w.kind === 'five_hour' ? clock(limSince) : shortDate(dayKey(limSince), lang))
978}
979
980/** Hochrechnung „100 % ≈ …“ (als Schätzung beschriftet), sonst warum es noch keine gibt. */
981export function projectionText(w: LimWindow, lang: Lang): string {
982 const t = T[lang]
983 if (w.proj !== null) return t.projection(usd(w.proj, lang))
984 if (w.partial) return t.projectionPartial
985 return w.pct >= 5 ? t.projectionNone : t.projectionLater
986}
987
988/** Teile der kompakten Limit-Zeile (Übersicht); leer, wenn es weder ein Fenster noch einen Plan gibt. */
989export function limitsLineParts(l: LimitsReport, now: number, lang: Lang): string[] {
990 const t = T[lang]
991 const out: string[] = []
992 if (l.five) out.push(`${t.fiveShort} ${pct(l.five.pct)} · ${usd(l.five.usd, lang)} · ${t.resetAt(resetTime(l.five.resetsAt, now, lang))}`)
993 else if (l.seenFive) out.push(`${t.fiveShort}: ${t.resetPassedShort}`)
994 if (l.week) out.push(`${t.weekShort} ${pct(l.week.pct)} · ${usd(l.week.usd, lang)}`)
995 if (l.plan) out.push(`${t.planShort} ${usd(l.plan.usd, lang)}${l.plan.factor !== null ? ` = ${factor(l.plan.factor, lang)}×` : ''}`)
996 return out
997}
998
999/** Abo-Monat in einer Zeile: Plan, Beginn, Erneuerung, API-Wert gegen Preis (ohne Preis kein Faktor). */
1000export function planLine(p: PlanReport, lang: Lang): { head: string; value: string; note: string } {
1001 const t = T[lang]
1002 const money = (v: number) => usd(v, lang)
1003 const value = p.price !== null && p.factor !== null ? t.planValue(money(p.usd), money(p.price), factor(p.factor, lang)) : t.planValueNoPrice(money(p.usd))
1004 const note = [p.listPrice ? t.listPrice : '', p.from ? t.partialFrom(shortDate(p.from, lang)) : ''].filter(Boolean).join(' · ')
1005 return { head: `${planLabel(p.plan)} · ${t.since(shortDate(p.start, lang))} · ${t.renews(shortDate(p.next, lang), p.daysLeft)}`, value, note }
1006}
1007
1008/** `/ledger limits` als Text: 5 Stunden, Woche, Abo-Monat, Verlauf, Ø (ohne Kopf- und Fußzeile). */
1009function limitsLines(l: LimitsReport, now: number, lang: Lang): string[] {
1010 const t = T[lang]
1011 const money = (v: number) => usd(v, lang)
1012 const out: string[] = []
1013 const hasLim = l.seenFive || l.seenWeek
1014 if (!hasLim) out.push(t.noLimitData)
1015 else
1016 for (const [w, seen, label] of [[l.five, l.seenFive, t.fiveHours], [l.week, l.seenWeek, t.week]] as const) {
1017 if (!w) {
1018 out.push(`${label}: ${seen ? t.resetPassed : t.noWindowYet}`)
1019 continue
1020 }
1021 const bits = [`${label}: ${pct(w.pct)}`, money(w.usd), `${t.resetAt(resetTime(w.resetsAt, now, lang))} (${t.inTime(duration(w.resetsAt - now, lang))})`]
1022 bits.push(projectionText(w, lang))
1023 const from = partialLabel(w, l.limSince, lang)
1024 if (from) bits.push(from)
1025 out.push(bits.join(' · '))
1026 }
1027 if (l.plan) {
1028 const p = planLine(l.plan, lang)
1029 out.push(`${t.subMonth}: ${p.head} · ${p.value}${p.note ? ` (${p.note})` : ''}`)
1030 } else out.push(t.planNotSet)
1031 if (hasLim) {
1032 const rows = l.history.slice(0, 4).map((w) => {
1033 const from = partialLabel(w, l.limSince, lang)
1034 return `${span(w.start, w.resetsAt, lang)} ${money(w.usd)} ${pct(w.pct)}${w.running ? ` ${t.running}` : ''}${from ? ` ${from}` : ''}`
1035 })
1036 if (rows.length) out.push(`${t.lastWindows}: ${rows.join(' · ')}${l.history.length > 4 ? ` · ${t.more(l.history.length - 4)}` : ''}`)
1037 out.push(l.avg ? t.avg(money(l.avg.usd), l.avg.n) : t.avgNone)
1038 }
1039 return out
1040}
1041
1042/**
1043 * Kurze Markdown-Fassung für Claude und für `-p` (höchstens 10 Zeilen, SPEC Verhalten 7). `tag` macht den Text eindeutig,
1044 * damit `ui.render` die passende Zeichnung findet.
1045 */
1046export function summaryText(r: Report, view: ViewName, range: number, tag: string, lang: Lang): string {
1047 const t = T[lang]
1048 const money = (v: number) => usd(v, lang)
1049 const join = <X>(rows: X[], fmt: (x: X) => string, max: number) => {
1050 const shown = rows.slice(0, max).map(fmt).join(' · ')
1051 return rows.length > max ? `${shown} · ${t.more(rows.length - max)}` : shown
1052 }
1053 const notes = notesOf(r, lang)
1054 // Hinweise und Fußzeile immer am Ende, auch wenn Listen gekürzt werden müssen (höchstens 10 Zeilen)
1055 const capped = (lines: string[], foot: string) => {
1056 const tail = [...notes, foot]
1057 return [...lines.slice(0, 10 - tail.length), ...tail].slice(0, 10).join('\n')
1058 }
1059 // Limits gibt es auch ohne Buchung (Plan eingestellt, Messung dieses Prozesses)
1060 if (view === 'limits') return capped([t.sumLimitsTitle(dateTime(r.now, lang), tag), ...limitsLines(r.limits, r.now, lang)], t.sumLimitsFoot)
1061 const lines = [t.sumTitle(dateTime(r.now, lang), tag)]
1062 // Die Limit-Zeile auch im Leerzustand (z. B. direkt nach /ledger reset: Plan und Messung sind noch da)
1063 const lim = view === 'overview' || view === 'weeks' ? limitsLineParts(r.limits, r.now, lang) : []
1064 if (lim.length) lines.push(t.sumLimitsLine(lim.join(' | ')))
1065 if (r.total === 0) return [...lines, ...notes, t.empty].join('\n')
1066 const w = r.windows
1067 if (view === 'overview' || view === 'weeks') {
1068 lines.push(t.sumChats(money(w.today.usd), w.today.chats, money(w.d7.usd), money(w.d30.usd), money(w.all.usd)))
1069 lines.push(t.sumMods(money(w.today.mods), money(w.d30.mods), money(w.all.mods)))
1070 if (r.projects.length) lines.push(t.sumProjects(join(r.projects, (p) => `${projectLabel(p.name, lang)} ${money(p.usd)}`, 5)))
1071 if (r.chats.length) lines.push(t.sumTopChats(join(r.chats, (c) => `${chatLabel(c, lang)} ${money(c.usd)}`, 3)))
1072 if (r.mods.length) lines.push(t.sumModsList(join(r.mods, (m) => `${m.name} ${money(m.usd)} (${m.calls}×)`, 4)))
1073 if (view === 'weeks') lines.push(t.sumWeeks(r.series.weeks.slice(0, 6).map((b) => `${weekLabel(isoWeek(b.key), lang)} ${money(b.usd)}`).join(' · ')))
1074 } else if (view === 'chats') {
1075 const rows = r.chats.slice(0, 20)
1076 lines.push(t.sumChatsHead(rangeLabel(range, lang), rows.length, r.chats.length))
1077 for (let i = 0; i < rows.length; i += 5)
1078 lines.push(rows.slice(i, i + 5).map((c, j) => `${i + j + 1}. ${chatLabel(c, lang)} (${projectLabel(c.project, lang)}) ${money(c.usd)}`).join(' · '))
1079 } else if (view === 'models') {
1080 const sum = r.models.reduce((a, m) => a + m.usd, 0)
1081 lines.push(t.sumModelsHead(rangeLabel(range, lang)))
1082 if (!r.models.length) lines.push(t.noTokens)
1083 for (const m of r.models.slice(0, 6)) {
1084 const tok = t.tokenLine(tokens(m.in, lang), tokens(m.out, lang), tokens(m.cr, lang), tokens(m.cw, lang))
1085 lines.push(t.sumModelLine(modelName(m.key, lang), money(m.usd), sum > 0 ? Math.round((m.usd / sum) * 100) : 0, m.n, tok))
1086 }
1087 if (r.models.length > 6) lines.push(t.sumMoreModels(r.models.length - 6))
1088 } else {
1089 lines.push(t.sumProjectsHead(rangeLabel(range, lang)))
1090 // Höchstens 4 Zeilen à 6; der Rest als „+N weitere“
1091 const shown = r.projects.slice(0, 24)
1092 for (let i = 0; i < shown.length; i += 6)
1093 lines.push(shown.slice(i, i + 6).map((p) => t.sumProjectRow(projectLabel(p.name, lang), money(p.usd), p.chats)).join(' · '))
1094 if (r.projects.length > shown.length) lines[lines.length - 1] += ` · ${t.more(r.projects.length - shown.length)}`
1095 }
1096 return capped(lines, t.sumFoot)
1097}
1098
1099// ---------- /ledger help (docs/HELP-SPEC.md §5 „cost-ledger 0.6.0“) ----------
1100
1101/** Was die Hilfe außer den Einstellungen braucht: Plan, Belegung des Speichers, Beginn der Erfassung. */
1102export type HelpInfo = { plan: Plan | null; storeBytes: number; since: number | null }
1103
1104/** Zahl einer Einstellung in der Sprache: en `2.5`, de `2,5`. */
1105const settingNum = (v: number, lang: Lang) => (lang === 'de' ? String(v).replace('.', ',') : String(v))
1106
1107/**
1108 * Schnappschuss für `/ledger help`: Befehle genau so, wie der Parser sie annimmt (Wörter aus `HELP_WORDS` & Co.),
1109 * Funktionen mit Zustand beim Aufruf, Einstellungen mit ihrem wirksamen Wert (nach `cleanSettings`).
1110 */
1111export function ledgerHelp(s: Settings, info: HelpInfo): HelpData {
1112 const lang = s.lang
1113 const t = T[lang]
1114 const money = (v: number) => usd(v, lang)
1115 const p = info.plan
1116 const price = p ? (p.price ?? (own(PLANS, p.plan) ? PLANS[p.plan]!.price : undefined)) : undefined
1117 const priceText = p ? (price === undefined ? t.noPrice : `${t.perMonth(money(price))}${p.price === undefined ? ` (${t.listPrice})` : ''}`) : ''
1118 const pctUsed = Math.round((Math.max(0, info.storeBytes) / STORE_LIMIT) * 100)
1119 return {
1120 mod: 'cost-ledger',
1121 lang,
1122 intro: t.helpIntro,
1123 commands: [
1124 { cmd: '/ledger', does: t.helpOverview },
1125 { cmd: '/ledger weeks', does: t.helpWeeks },
1126 { cmd: '/ledger chats|projects|models [7|30|all]', does: t.helpLists },
1127 { cmd: '/ledger limits', does: t.helpLimits },
1128 { cmd: '/ledger plan', does: t.helpPlanShow },
1129 { cmd: '/ledger plan <plan> <day|today> [price]', does: t.helpPlanSet },
1130 { cmd: '/ledger plan off', does: t.helpPlanOff },
1131 { cmd: '/ledger reset', does: t.helpReset },
1132 { cmd: '/ledger help', does: t.helpHelp },
1133 ],
1134 notes: [t.helpAliases],
1135 features: [
1136 p
1137 ? { name: t.helpPlanName, state: { kind: 'on', text: `${planLabel(p.plan)} · ${t.helpDay(p.day)} · ${priceText}` }, toggle: '/ledger plan off' }
1138 : { name: t.helpPlanName, state: { kind: 'off', text: t.helpNoPlan }, toggle: '/ledger plan max20 14' },
1139 { name: t.helpStorageName, state: { kind: 'value', text: t.helpStorage(pctUsed) }, toggle: t.helpInfo },
1140 { name: t.helpSinceName, state: { kind: 'value', text: info.since === null ? '–' : fullDate(info.since, lang) }, toggle: t.helpInfo },
1141 ],
1142 settings: [
1143 { title: t.setLanguage, value: lang, isDefault: lang === DEFAULTS.lang },
1144 { title: t.setKeepDays, value: String(s.keepDays), isDefault: s.keepDays === DEFAULTS.keepDays },
1145 { title: t.setDayYellow, value: settingNum(s.dayYellow, lang), isDefault: s.dayYellow === DEFAULTS.dayYellow },
1146 { title: t.setDayRed, value: settingNum(s.dayRed, lang), isDefault: s.dayRed === DEFAULTS.dayRed },
1147 ],
1148 footer: { terminal: t.helpFooterTerminal, desktop: t.helpFooterDesktop },
1149 }
1150}
1151hooks/view.ts 347 lines1// cost-ledger: die Übersicht als reiner Datenbaum {type, props, children} (types RenderElement), ohne $.ui.resolve.
2// Nur Box und Text mit Props aus der Allowlist (API-DETAILS.md:1043-1080), sonst zeichnet die Engine ihr Original.
3//
4// Stil wie limit-bars: Claude-Orange nur für Überschriften, Beträge in normaler Schrift, Nebensachen gedimmt; Farbe tragen
5// nur die Balken. Kein festes Außenmaß: Der Desktop meldet mehr Spalten, als er zeigt, und zeichnet Blockzeichen breiter
6// als eine Zelle. Spalten sind deshalb Boxen mit fester Breite; im Desktop sind Balken Boxen mit Hintergrundfarbe und
7// ganzzahliger Prozentbreite (Kommaprozente verwirft er), im Terminal dünne `▄`. Der leere Teil zeigt den Hintergrund.
8import type { RenderElement, RenderNode } from 'claude-code'
9import { T, dateTime, duration, factor, fullDate, pct, rangeLabel, resetTime, shortDate, span, tokens, usd, weekLabel } from './i18n.ts'
10import type { Lang } from './i18n.ts'
11import { MOD_PART, UNKNOWN_MODEL, chatLabel, isoWeek, modelName, notesOf, partLabel, partialLabel, planLabel, planLine, projectLabel, projectionText } from './logic.ts'
12import type { Bucket, LimWindow, LimitsReport, Report, Settings, ViewName } from './logic.ts'
13
14export type View = { report: Report; view: ViewName; range: number }
15type Surface = 'terminal' | 'desktop'
16
17// Theme-Keys statt fester Hex-Werte: Sie folgen dem Theme des Nutzers, hell wie dunkel (types Color/ThemeKey).
18const ORANGE = 'claude'
19const YELLOW = 'warning'
20const RED = 'error'
21// Farben je Modell, nach Anteil vergeben; ohne Modell-Daten gedimmt
22const MODEL_COLORS = ['claude', 'suggestion', 'success', 'permission', 'warning', 'planMode', 'ide', 'remember']
23const UNKNOWN_COLOR = 'inactive'
24
25type Props = Record<string, string | number | boolean>
26
27function el(type: 'Box' | 'Text', props: Props, children: RenderNode[]): RenderElement {
28 return { type, props, children }
29}
30const text = (s: string, props: Props = {}) => el('Text', props, [s])
31const dim = (s: string) => text(s, { dimColor: true })
32const row = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'row', ...props }, kids)
33const col = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'column', ...props }, kids)
34/** Feste Spalte; `right` richtet den Inhalt rechts aus (Beträge). */
35const cell = (width: number, kid: RenderNode, right = false) =>
36 el('Box', { width, flexShrink: 0, ...(right ? { justifyContent: 'flex-end' } : {}) }, [kid])
37const heading = (s: string) => el('Box', { marginTop: 1 }, [text(s, { color: ORANGE, bold: true })])
38
39/** `total` ganze Einheiten nach Anteilen verteilen: erst abrunden, den Rest an die größten Nachkommareste (Summe = total). */
40function largestRemainder(shares: number[], total: number): number[] {
41 const sum = shares.reduce((a, b) => a + b, 0)
42 if (!(sum > 0) || total <= 0) return shares.map(() => 0)
43 const raw = shares.map((x) => (x / sum) * total)
44 const got = raw.map((x) => Math.floor(x))
45 let left = total - got.reduce((a, b) => a + b, 0)
46 const order = raw.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
47 for (const [, i] of order) {
48 if (left <= 0) break
49 got[i]! += 1
50 left -= 1
51 }
52 return got
53}
54
55/**
56 * Balken der Länge `ratio` (0–1), aufgeteilt in `parts` (Anteile) mit je einer Farbe; ein einfarbiger Balken hat einen Teil.
57 * Desktop: Boxen in ganzzahliger Prozentbreite (der Desktop verwirft Kommaprozente), Summe genau 100. Terminal: ganze
58 * Zellen `▄`, verteilt nach größtem Rest; ein Teil unter einer halben Zelle kann dort fehlen.
59 */
60export function stackedBar(surface: Surface, ratio: number, cells: number, parts: { color: string; share: number }[]): RenderElement {
61 const r = Math.max(0, Math.min(1, ratio || 0))
62 const list = parts.filter((p) => p.share > 0)
63 if (surface === 'desktop') {
64 const pct = r > 0 ? Math.max(1, Math.round(r * 100)) : 0
65 const widths = largestRemainder(list.map((p) => p.share), 100)
66 const segs = list.flatMap((p, i) => (widths[i]! > 0 ? [el('Box', { width: `${widths[i]}%`, backgroundColor: p.color }, [' '])] : []))
67 const fill = pct > 0 && segs.length ? [el('Box', { width: `${pct}%`, flexDirection: 'row' }, segs)] : [' ']
68 return el('Box', { flexGrow: 1, minWidth: 6, marginRight: 1 }, fill)
69 }
70 const n = Math.max(1, cells)
71 const on = r > 0 ? Math.max(1, Math.round(r * n)) : 0
72 const got = largestRemainder(list.map((p) => p.share), on)
73 const kids: RenderNode[] = list.flatMap((p, i) => (got[i]! > 0 ? [text('▄'.repeat(got[i]!), { color: p.color })] : []))
74 return el('Box', { width: n + 1, flexShrink: 0 }, [kids.length ? el('Text', {}, kids) : ' '])
75}
76
77const bar = (sf: Surface, ratio: number, cells: number, color: string) => stackedBar(sf, ratio, cells, [{ color, share: 1 }])
78
79/**
80 * Farbe je Verlaufsteil: erst die Chat-Modelle nach Gesamtbetrag, danach die Mod-Teile (`mod:…`), damit sie in der
81 * Legende hinter dem Chat stehen. Beträge ohne Modell-Daten (Chat wie Mods) immer gedimmt.
82 */
83export function modelColors(buckets: Bucket[]): Map<string, string> {
84 const tot = new Map<string, number>()
85 for (const b of buckets) for (const p of b.parts) tot.set(p.key, (tot.get(p.key) ?? 0) + p.usd)
86 const unknown = (k: string) => k === UNKNOWN_MODEL || k === MOD_PART + UNKNOWN_MODEL
87 const isMod = (k: string) => (k.startsWith(MOD_PART) ? 1 : 0)
88 const keys = [...tot.entries()]
89 .filter(([k]) => !unknown(k))
90 .sort((a, b) => isMod(a[0]) - isMod(b[0]) || b[1] - a[1])
91 .map(([k]) => k)
92 const out = new Map(keys.map((k, i) => [k, MODEL_COLORS[i % MODEL_COLORS.length]!]))
93 for (const k of [UNKNOWN_MODEL, MOD_PART + UNKNOWN_MODEL]) if (tot.has(k)) out.set(k, UNKNOWN_COLOR)
94 return out
95}
96
97/** Ampel für Tagesbeträge (`dayYellow`/`dayRed`); darunter die normale Schriftfarbe des Themes (`text`). */
98function ampel(v: number, s: Settings): Props {
99 return { color: v >= s.dayRed ? RED : v >= s.dayYellow ? YELLOW : 'text' }
100}
101
102function cut(s: string, n: number): string {
103 return s.length <= n ? s : `${s.slice(0, Math.max(1, n - 1))}…`
104}
105
106/** Eine Kennzahl: Überschrift gedimmt, Betrag fett, darunter Chats und Mods. */
107function figure(label: string, w: { usd: number; chats: number; mods: number }, width: string, lang: Lang): RenderElement {
108 return col({ width, paddingRight: 2 }, [dim(label), text(usd(w.usd, lang), { bold: true }), dim(`${T[lang].chats(w.chats)} · Mods ${usd(w.mods, lang)}`)])
109}
110
111/**
112 * Verlauf der letzten 14 Tage oder Wochen: je Zeile ein Balken, geteilt nach Modell, darunter die Legende. Der Betrag ist
113 * der echte Chat-Betrag plus die Mod-Aufrufe; die Aufteilung folgt den geschätzten Modell-Anteilen, Mod-Teile stehen in
114 * der Legende als „Modell · Mods“. Die Ampel färbt bei Tagen den Betrag.
115 */
116function historyBlock(r: Report, s: Settings, sf: Surface, inner: number, weeks: boolean): RenderElement[] {
117 const lang = s.lang
118 const t = T[lang]
119 const buckets = weeks ? r.series.weeks : r.series.days
120 const colors = modelColors(buckets)
121 const max = Math.max(0, ...buckets.map((b) => b.usd))
122 const top = buckets.find((b) => b.usd === max && max > 0)?.key
123 const labelW = 7
124 // Terminal: Beschriftung, Betrag 10, Marke 12 → der Rest ist Balken
125 const cells = Math.max(6, Math.min(32, inner - labelW - 10 - 12 - 1))
126 const rows = buckets.map((b) =>
127 row({}, [
128 cell(labelW, dim(weeks ? weekLabel(isoWeek(b.key), lang) : shortDate(b.key, lang))),
129 stackedBar(sf, max > 0 ? b.usd / max : 0, cells, b.parts.map((p) => ({ color: colors.get(p.key) ?? UNKNOWN_COLOR, share: b.usd > 0 ? p.usd / b.usd : 0 }))),
130 cell(10, b.usd > 0 ? text(usd(b.usd, lang), weeks ? {} : ampel(b.usd, s)) : dim('–'), true),
131 cell(12, b.key === top ? dim(` ${t.highest}`) : ''),
132 ]),
133 )
134 const legend: RenderNode[] = [...colors.entries()].map(([k, c]) =>
135 el('Box', { marginRight: 2 }, [el('Text', {}, [text('▄ ', { color: c }), dim(partLabel(k, lang))])]),
136 )
137 return [heading(weeks ? t.last14Weeks : t.last14Days), ...rows, ...(legend.length ? [row({ flexWrap: 'wrap', marginTop: 1 }, legend)] : [])]
138}
139
140function projectsBlock(r: Report, title: string, sf: Surface, width: number, limit: number, basis: string, lang: Lang): RenderElement {
141 const list = r.projects.slice(0, limit).map((p) => ({ ...p, label: projectLabel(p.name, lang) }))
142 const max = Math.max(0, ...list.map((p) => p.usd))
143 const nameW = Math.min(18, Math.max(8, ...list.map((p) => p.label.length + 1)))
144 const cells = Math.max(4, Math.min(24, width - nameW - 10 - 1 - 2)) // 2 = paddingRight
145 const kids: RenderNode[] = list.length
146 ? list.map((p) => row({}, [cell(nameW, text(cut(p.label, nameW - 1))), bar(sf, max > 0 ? p.usd / max : 0, cells, ORANGE), cell(10, text(usd(p.usd, lang)), true)]))
147 : [dim(T[lang].noCosts)]
148 if (r.projects.length > limit) kids.push(dim(T[lang].more(r.projects.length - limit)))
149 return col({ width: basis, paddingRight: 2 }, [heading(title), ...kids])
150}
151
152function chatsBlock(r: Report, title: string, width: number, limit: number, basis: string, lang: Lang): RenderElement {
153 const list = r.chats.slice(0, limit)
154 const nameW = Math.max(12, Math.min(48, width - 3 - 10))
155 const kids: RenderNode[] = list.length
156 ? list.flatMap((c, i) => [
157 row({}, [
158 cell(3, dim(`${i + 1}`)),
159 el('Box', { flexGrow: 1, minWidth: 8 }, [text(cut(chatLabel(c, lang), nameW), { wrap: 'truncate-end' })]),
160 cell(10, text(usd(c.usd, lang)), true),
161 ]),
162 row({}, [cell(3, ''), dim(`${projectLabel(c.project, lang)} · ${shortDate(c.lastDay, lang)}`)]),
163 ])
164 : [dim(T[lang].noCosts)]
165 return col({ width: basis }, [heading(title), ...kids])
166}
167
168/** `/ledger models`: je Modell Anteil am geschätzten API-Wert als Balken, Betrag, Anzahl und darunter die Tokens. */
169function modelsBlock(r: Report, title: string, sf: Surface, inner: number, lang: Lang): RenderElement {
170 const t = T[lang]
171 const sum = r.models.reduce((a, m) => a + m.usd, 0)
172 const names = r.models.map((m) => modelName(m.key, lang))
173 const nameW = Math.min(16, Math.max(10, ...names.map((n) => n.length + 1)))
174 const cells = Math.max(6, Math.min(32, inner - nameW - 6 - 10 - 8 - 1))
175 const kids: RenderNode[] = r.models.length
176 ? r.models.flatMap((m, i) => [
177 row({ marginTop: 1 }, [
178 cell(nameW, text(names[i]!, { bold: true })),
179 bar(sf, sum > 0 ? m.usd / sum : 0, cells, ORANGE),
180 cell(6, dim(`${sum > 0 ? Math.round((m.usd / sum) * 100) : 0} %`), true),
181 cell(10, text(usd(m.usd, lang)), true),
182 cell(8, dim(`${m.n}×`), true),
183 ]),
184 dim(t.tokenLine(tokens(m.in, lang), tokens(m.out, lang), tokens(m.cr, lang), tokens(m.cw, lang))),
185 ])
186 : [dim(t.noTokens)]
187 kids.push(el('Box', { marginTop: 1 }, [dim(t.modelsNote)]))
188 return col({}, [heading(title), ...kids])
189}
190
191function modsBlock(r: Report, title: string, lang: Lang): RenderElement {
192 const t = T[lang]
193 const line = r.mods.length ? r.mods.map((m) => `${m.name} ${usd(m.usd, lang)} (${m.calls}×)`).join(' · ') : t.noModCalls
194 return col({}, [heading(title), text(line, r.mods.length ? {} : { dimColor: true }), dim(t.modsExtra)])
195}
196
197/**
198 * Ampel für die Auslastung eines Limit-Fensters: grün unter 70 %, gelb unter 90 %, rot ab 90 % (SPEC Nachtrag 0.5.0).
199 * Nach der angezeigten, gerundeten Zahl, damit „70 %“ nie grün ist (`percentUsed` hat eine Nachkommastelle).
200 */
201function limColor(p: number): string {
202 const v = Math.round(p || 0)
203 return v >= 90 ? RED : v >= 70 ? YELLOW : 'success'
204}
205
206/**
207 * Ein Limit-Fenster (5 Stunden oder Woche): Überschrift mit Reset und Countdown (Stand beim Aufruf), rechts „ab …“, falls
208 * das Fenster vor der ersten Fenster-Buchung begann; darunter Balken = % genutzt, %, API-Wert und die Hochrechnung.
209 */
210function windowBlock(w: LimWindow | null, seen: boolean, title: string, l: LimitsReport, now: number, sf: Surface, inner: number, lang: Lang): RenderNode[] {
211 const t = T[lang]
212 if (!w) return [heading(title), dim(seen ? t.resetPassed : t.noWindowYet)]
213 const from = partialLabel(w, l.limSince, lang)
214 const head = row({ justifyContent: 'space-between', flexWrap: 'wrap', marginTop: 1 }, [
215 text(`${title} · ${t.resetAt(resetTime(w.resetsAt, now, lang))} (${t.inTime(duration(w.resetsAt - now, lang))})`, { color: ORANGE, bold: true }),
216 ...(from ? [dim(from)] : []),
217 ])
218 // Terminal: Balken, % 7, Betrag 12; die Hochrechnung bricht bei schmaler Breite in die nächste Zeile um
219 const cells = Math.max(6, Math.min(32, inner - 7 - 12 - 1))
220 const line = row({ flexWrap: 'wrap' }, [
221 bar(sf, w.pct / 100, cells, limColor(w.pct)),
222 cell(7, text(pct(w.pct), { color: limColor(w.pct) }), true),
223 cell(12, text(usd(w.usd, lang), { bold: true }), true),
224 el('Box', { paddingLeft: 3 }, [dim(projectionText(w, lang))]),
225 ])
226 return [head, line]
227}
228
229function planBlock(l: LimitsReport, lang: Lang): RenderNode[] {
230 const t = T[lang]
231 if (!l.plan) return [heading(t.subMonth), dim(t.planNotSet)]
232 const p = planLine(l.plan, lang)
233 return [heading(`${t.subMonth} · ${p.head}`), el('Text', {}, [text(p.value), ...(p.note ? [dim(` · ${p.note}`)] : [])])]
234}
235
236/** Die letzten 10 Fünf-Stunden-Fenster: Balken = API-Wert relativ zum teuersten, Farbe nach % wie oben; darunter das Ø. */
237function windowsHistory(l: LimitsReport, sf: Surface, inner: number, lang: Lang): RenderNode[] {
238 const t = T[lang]
239 const max = Math.max(0, ...l.history.map((w) => w.usd))
240 const labelW = 19
241 // Beschriftung 19, Betrag 10, % 7, Marke 12 → der Rest ist Balken. Reicht die Breite nicht, brechen die Zellen um:
242 // Der kleinste Balken braucht im Terminal 5 Zellen, im Desktop minWidth 6 + marginRight 1 (stackedBar)
243 const cells = Math.max(4, Math.min(24, inner - labelW - 10 - 7 - 12 - 1))
244 const narrow = inner < labelW + (sf === 'desktop' ? 7 : cells + 1) + 10 + 7 + 12
245 const rows = l.history.map((w) =>
246 row(narrow ? { flexWrap: 'wrap' } : {}, [
247 cell(labelW, dim(span(w.start, w.resetsAt, lang))),
248 bar(sf, max > 0 ? w.usd / max : 0, cells, limColor(w.pct)),
249 cell(10, text(usd(w.usd, lang)), true),
250 cell(7, dim(pct(w.pct)), true),
251 cell(12, dim(w.running ? ` ${t.running}` : w.partial ? ` ${partialLabel(w, l.limSince, lang)}` : '')),
252 ]),
253 )
254 return [
255 heading(t.lastWindows),
256 ...(rows.length ? rows : [dim(t.noWindows)]),
257 el('Box', { marginTop: 1 }, [l.avg ? text(t.avg(usd(l.avg.usd, lang), l.avg.n)) : dim(t.avgNone)]),
258 ]
259}
260
261/** `/ledger limits`: 5 Stunden, Woche, Abo-Monat, Verlauf; ohne Limit-Daten ein Hinweis statt der Fenster. */
262function limitsBlocks(r: Report, sf: Surface, inner: number, lang: Lang): RenderNode[] {
263 const t = T[lang]
264 const l = r.limits
265 if (!l.seenFive && !l.seenWeek)
266 return [el('Box', { marginTop: 1 }, [text(t.noLimitData)]), ...(l.plan ? planBlock(l, lang) : [dim(t.planNotSet)])]
267 return [
268 ...windowBlock(l.five, l.seenFive, t.fiveHours, l, r.now, sf, inner, lang),
269 ...windowBlock(l.week, l.seenWeek, t.week, l, r.now, sf, inner, lang),
270 ...planBlock(l, lang),
271 ...windowsHistory(l, sf, inner, lang),
272 ]
273}
274
275/** Kompakte Limit-Zeile oben in der Übersicht; bricht bei schmaler Breite um. Entfällt ohne Fenster und ohne Plan. */
276function limitsLine(r: Report, lang: Lang): RenderElement | null {
277 const t = T[lang]
278 const l = r.limits
279 const money = (v: number) => usd(v, lang)
280 const parts: RenderNode[][] = []
281 if (l.five)
282 parts.push([text(`${t.fiveShort} `), text(pct(l.five.pct), { color: limColor(l.five.pct) }), dim(' · '), text(money(l.five.usd)), dim(` · ${t.resetAt(resetTime(l.five.resetsAt, r.now, lang))}`)])
283 else if (l.seenFive) parts.push([dim(`${t.fiveShort}: ${t.resetPassedShort}`)])
284 if (l.week) parts.push([text(`${t.weekShort} `), text(pct(l.week.pct), { color: limColor(l.week.pct) }), dim(' · '), text(money(l.week.usd))])
285 if (l.plan) parts.push([text(`${t.planShort} ${money(l.plan.usd)}`), ...(l.plan.factor !== null ? [dim(' = '), text(`${factor(l.plan.factor, lang)}×`)] : [])])
286 if (!parts.length) return null
287 const kids: RenderNode[] = parts.map((p, i) => el('Box', { marginRight: 1 }, [el('Text', {}, [...(i ? [dim('│ ')] : []), ...p])]))
288 kids.push(dim('· /ledger limits'))
289 return row({ flexWrap: 'wrap', marginTop: 1 }, kids)
290}
291
292/** Der Befehl, der dieselbe Ansicht mit frischen Zahlen zeichnet (samt Zeitraum). */
293function reloadCommand(v: View): string {
294 if (v.view === 'overview') return '/ledger'
295 if (v.view === 'weeks' || v.view === 'limits') return `/ledger ${v.view}`
296 return `/ledger ${v.view}${v.range === 30 ? '' : v.range <= 0 ? ' all' : ` ${v.range}`}`
297}
298
299/** Der ganze Baum für eine `CommandOutput`-Zeile von `/ledger`; `columns` dient nur als Richtwert (Terminal-Balken, Umbruch). */
300export function ledgerTree(v: View, s: Settings, columns: number, surface: Surface = 'terminal'): RenderElement {
301 const lang = s.lang
302 const t = T[lang]
303 const r = v.report
304 const cols = Math.max(30, Math.min(columns || 100, 140))
305 const inner = cols - 4 // Rahmen und paddingX
306 const limits = v.view === 'limits'
307 // Ohne Knopf (er bräuchte neue Rechte): oben rechts Stand und wie man neu lädt; bei den Limits statt „seit“ der Plan
308 const first = limits ? (r.limits.plan ? `${planLabel(r.limits.plan.plan)} · ` : '') : r.since ? `${t.since(fullDate(r.since, lang))} · ` : ''
309 const stand = `${first}${t.asOf(dateTime(r.now, lang))} · ${t.reload(reloadCommand(v))}`
310 const head = row({ justifyContent: 'space-between', flexWrap: 'wrap' }, [text(limits ? t.limitsHead : 'cost-ledger', { color: ORANGE, bold: true }), dim(stand)])
311 const notes: RenderNode[] = notesOf(r, lang).map((n) => text(n, { color: YELLOW }))
312 const foot = el('Box', { marginTop: 1 }, [dim(limits ? t.limitsFoot : t.foot)])
313 const frame = (kids: RenderNode[]) => col({ borderStyle: 'round', borderDimColor: true, paddingX: 1, width: '100%' }, [head, ...notes, ...kids, foot])
314
315 // Limits gibt es auch ohne Buchung (Plan eingestellt, Messung dieses Prozesses)
316 if (limits) return frame(limitsBlocks(r, surface, inner, lang))
317 // Die Limit-Zeile entfällt nur ohne Fenster und ohne Plan, auch im Leerzustand (z. B. direkt nach /ledger reset)
318 const lim = v.view === 'overview' || v.view === 'weeks' ? limitsLine(r, lang) : null
319 if (r.total === 0) return frame([...(lim ? [lim] : []), el('Box', { marginTop: 1 }, [text(t.empty)])])
320 const range = rangeLabel(v.range, lang)
321 if (v.view === 'chats') return frame([chatsBlock(r, t.chatsIn(range), inner, 20, '100%', lang)])
322 if (v.view === 'projects') return frame([projectsBlock(r, t.projectsIn(range), surface, inner, 40, '100%', lang)])
323 if (v.view === 'models') return frame([modelsBlock(r, t.modelsIn(range), surface, inner, lang)])
324
325 const w = r.windows
326 const d30 = rangeLabel(30, lang)
327 // Spalten als Prozent der Zeile (nie breiter als die Zeile): zwei Blöcke ab etwa 90 Zeichen, vier Kennzahlen ab 80
328 const side = inner >= 90
329 const half = side ? Math.floor(inner / 2) - 2 : inner
330 const fw = inner >= 80 ? '25%' : inner >= 40 ? '50%' : '100%'
331 return frame([
332 ...(lim ? [lim] : []),
333 row({ flexWrap: 'wrap', marginTop: 1 }, [
334 figure(t.today, w.today, fw, lang),
335 figure(t.days7, w.d7, fw, lang),
336 figure(t.days30, w.d30, fw, lang),
337 figure(t.allTime, w.all, fw, lang),
338 ]),
339 ...historyBlock(r, s, surface, inner, v.view === 'weeks'),
340 row({ flexWrap: 'wrap' }, [
341 projectsBlock(r, t.projectsIn(d30), surface, half, 8, side ? '50%' : '100%', lang),
342 chatsBlock(r, t.chatsIn(d30), half, 5, side ? '50%' : '100%', lang),
343 ]),
344 modsBlock(r, t.modsIn(d30), lang),
345 ])
346}
347