Rings when the session nears a limit: context fill, rate-limit windows, the auto-memory index

Rings, inside a Claude Code session, when the session nears a limit.
On each session.measure (the engine pushes one after every turn, and when a rate-limit window moves a point):
5h, 7d), with the time it resets; none off a subscription.And for the auto-memory index (MEMORY.md, found in the session's own context breakdown at start): read at start and after each Write or Edit into its folder. The engine cuts it at load past 200 lines or 25,000 bytes (2.1.288); the bell rings at 90 % of either, and again once it is past.
Each threshold rings once on the way up; a value that falls 5 points under it (a compaction, /clear, a window that reset) arms it again.
The readings ride dim at the end of the hint line under the prompt, ctx 62% · 5h 41% · 7d 12% · mem 145/200, rather than as a pinned status line, which the engine draws with a warning sign; /usage7 prints them against their thresholds.
With avatar7 loaded, the avatar announces each toast in its own voice, in amber. Neither mod depends on the other. /usage7 test rings a sample toast, to hear that voice without waiting for a threshold.
Two kinds, never added up:
$.model.complete: avatar7's Haiku, jukebox7's intents) goes through the subscription. The bell counts their tokens per model and prices them as the API would, mods 41k tok ≈$0.06: what they take from the plan, not dollars billed.GET /spend on 127.0.0.1:8710, asked every five minutes. ops7 alone holds the organization's Admin key; the bell sees amounts only: api $6.20 today, and in /usage7 yesterday, the month and each workspace. A day rings at $5, $10 and $20. Without ops7 the bell says so and stays quiet.hooks/register.ts 270 lines1import type { Register, SessionRateLimit } from 'claude-code'
2
3// Each threshold rings once on the way up; a value that falls REARM points
4// under it (a compaction, /clear, a window that reset) arms it again.
5const CONTEXT_STEPS = [70, 85, 95]
6const LIMIT_STEPS = [80, 95]
7const REARM = 5
8
9// The engine cuts the auto-memory index past these when it loads it (2.1.288);
10// the bell rings at 90 % of either, and again once it is cut.
11const MEMORY_MAX_LINES = 200
12const MEMORY_MAX_BYTES = 25_000
13const MEMORY_NEAR = 0.9
14const MEMORY_POLL_MS = 2_000
15
16const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
17
18// The mods' own model calls ($.model, avatar7's Haiku): they go through the
19// subscription, so no dollar is billed; their worth at API prices says how
20// much of the plan they take. $ per million tokens, input and output; a cache
21// read is a tenth of the input.
22const PRICES: [string, number, number][] = [
23 ['haiku', 1, 5],
24 ['sonnet', 2, 10],
25 ['opus', 4, 20],
26 ['fable', 10, 50],
27]
28export type ModelSpend = { calls: number; input: number; output: number; cacheRead: number; usd: number }
29export const priced = (model: string, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number }): number => {
30 const [, pin, pout] = PRICES.find(([name]) => model.includes(name)) ?? ['', 0, 0]
31 return (u.input_tokens * pin + (u.cache_read_input_tokens ?? 0) * pin * 0.1 + u.output_tokens * pout) / 1e6
32}
33
34// The API keys' spend (hoshi7, bayes, the agents...), real dollars, from ops7,
35// which alone holds the Admin key: asked every five minutes, as ops7 caches it.
36const SPEND_URL = 'http://127.0.0.1:8710/spend'
37const SPEND_POLL_MS = 300_000
38// A day's API spend rings at these dollars, once each on the way up.
39const API_DAY_STEPS = [5, 10, 20]
40export type ApiSpend = {
41 ok: boolean
42 error?: string
43 total?: { today: number; yesterday: number; month: number }
44 workspaces?: Record<string, { today: number; yesterday: number; month: number }>
45}
46export const parseSpend = (stdout: string): ApiSpend | undefined => {
47 try {
48 const j = JSON.parse(stdout) as ApiSpend
49 return typeof j === 'object' && j !== null && typeof j.ok === 'boolean' ? j : undefined
50 } catch {
51 return undefined
52 }
53}
54// One more call on a model's tally.
55export const addUsage = (was: ModelSpend | undefined, model: string, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number }): ModelSpend => {
56 const w = was ?? { calls: 0, input: 0, output: 0, cacheRead: 0, usd: 0 }
57 return {
58 calls: w.calls + 1,
59 input: w.input + u.input_tokens,
60 output: w.output + u.output_tokens,
61 cacheRead: w.cacheRead + (u.cache_read_input_tokens ?? 0),
62 usd: w.usd + priced(model, u),
63 }
64}
65const usd = (v: number): string => (v < 10 ? `$${v.toFixed(2)}` : `$${Math.round(v)}`)
66const ktok = (n: number): string => (n < 1000 ? String(n) : `${Math.round(n / 1000)}k`)
67
68const label = (kind: string): string => LIMIT_LABEL[kind] ?? kind
69
70const hhmm = (iso: string | undefined): string => {
71 if (iso === undefined) return ''
72 const d = new Date(iso)
73 if (Number.isNaN(d.getTime())) return ''
74 const days = Math.floor((d.getTime() - Date.now()) / 86_400_000)
75 const at = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
76 return days >= 1 ? `${d.toLocaleDateString('en-GB', { weekday: 'short' })} ${at}` : at
77}
78
79// The highest step at or under `value`, 0 when none.
80const stepOf = (steps: number[], value: number): number => steps.filter(s => value >= s).at(-1) ?? 0
81
82// The step still held once `value` has fallen: each step it fell REARM under
83// is released.
84const held = (steps: number[], rung: number, value: number): number =>
85 steps.filter(s => s <= rung && value > s - REARM).at(-1) ?? 0
86
87export const register: Register = on => {
88 // What each gauge last rang at, by key (`context`, `limit:five_hour`, `memory`).
89 const rung = new Map<string, number>()
90 let context: number | undefined
91 let limits: SessionRateLimit[] = []
92 let memory: { path: string; lines: number; bytes: number } | undefined
93 let memoryPath: string | undefined
94 let memoryDirty = false
95 // This session's mods' calls, by model; and the API's day, month, workspaces.
96 const mods = new Map<string, ModelSpend>()
97 let api: ApiSpend | undefined
98
99 // The rings a new reading raises; `rung` follows it.
100 const ring = (key: string, steps: number[], value: number): number | undefined => {
101 const before = held(steps, rung.get(key) ?? 0, value)
102 const now = Math.max(before, stepOf(steps, value))
103 rung.set(key, now)
104 return now > before ? now : undefined
105 }
106
107 // The readings, dim at the end of the hint line under the prompt: a pinned
108 // status line would come with the engine's warning sign, and these are not
109 // warnings; the toasts are.
110 let tail = ''
111 const statusLine = (): string | undefined => {
112 const parts: string[] = []
113 if (context !== undefined) parts.push(`ctx ${Math.round(context)}%`)
114 for (const l of limits) parts.push(`${label(l.kind)} ${Math.round(l.percentUsed)}%`)
115 if (memory !== undefined) parts.push(`mem ${memory.lines}/${MEMORY_MAX_LINES}`)
116 const m = [...mods.values()].reduce((a, b) => ({ tok: a.tok + b.input + b.output + b.cacheRead, usd: a.usd + b.usd }), { tok: 0, usd: 0 })
117 if (m.tok > 0) parts.push(`mods ${ktok(m.tok)} tok ≈${usd(m.usd)}`)
118 if (api?.ok === true && api.total !== undefined) parts.push(`api ${usd(api.total.today)} today`)
119 return parts.length === 0 ? undefined : parts.join(' · ')
120 }
121
122 on('session.start', async ($, e, next) => {
123 // For avatar7, when loaded: a limit near is a warning, in amber.
124 await $.state.set({ plugin: 'usage-bell', key: 'announce' }, { mood: 'error', event: 'the session is nearing a limit' })
125 await $.command.register({ name: 'usage7', description: 'Context, rate limits, memory index, the mods\' model calls and the API spend (test: a sample ring)' })
126 // A status line pinned by an earlier version stays until cleared.
127 $.ui.status(undefined)
128
129 // The auto-memory index this session loaded, from the free local estimate.
130 try {
131 const usage = await $.session.usage({ breakdown: 'summary' })
132 memoryPath = usage.context.breakdown?.memoryFiles.find(
133 f => f.type === 'AutoMem' && f.path.endsWith('/MEMORY.md'),
134 )?.path
135 memoryDirty = memoryPath !== undefined
136 } catch {
137 // No breakdown here: the bell watches context and limits only.
138 }
139
140 // The memory index is read here, the only hook that keeps the $; a write
141 // into its folder only raises the flag.
142 $.clock.every(MEMORY_POLL_MS, async () => {
143 if (!memoryDirty || memoryPath === undefined) return
144 memoryDirty = false
145 try {
146 const text = await $.fs.read(memoryPath)
147 const lines = text.trim().split('\n').length
148 const bytes = new TextEncoder().encode(text).length
149 memory = { path: memoryPath, lines, bytes }
150 const fill = Math.max(lines / MEMORY_MAX_LINES, bytes / MEMORY_MAX_BYTES) * 100
151 const step = ring('memory', [MEMORY_NEAR * 100, 100], fill)
152 if (step === 100) {
153 $.ui.toast(`memory index is cut at load: ${lines} lines, ${bytes} bytes (limit ${MEMORY_MAX_LINES} / ${MEMORY_MAX_BYTES})`)
154 } else if (step !== undefined) {
155 $.ui.toast(`memory index near its limit: ${lines}/${MEMORY_MAX_LINES} lines, ${Math.round(bytes / 1000)}/${MEMORY_MAX_BYTES / 1000} kB`)
156 }
157 tail = statusLine() ?? ''
158 $.ui.invalidate('ui.render')
159 } catch {
160 // Unreadable for now: try again at the next write.
161 }
162 })
163
164 // The API spend, from ops7 (absent when ops7 or its key is not there).
165 const askSpend = async (): Promise<void> => {
166 const r = await $.process.run(['curl', '-s', '--max-time', '5', SPEND_URL]).catch(() => undefined)
167 const got = r?.exitCode === 0 ? parseSpend(r.stdout) : undefined
168 api = got ?? { ok: false, error: 'ops7 did not answer on :8710' }
169 if (api.ok && api.total !== undefined) {
170 const step = ring('api:today', API_DAY_STEPS, api.total.today)
171 if (step !== undefined) $.ui.toast(`API spend today ${usd(api.total.today)}, past $${step} (month ${usd(api.total.month)})`)
172 }
173 tail = statusLine() ?? ''
174 $.ui.invalidate('ui.render')
175 }
176 void askSpend()
177 $.clock.every(SPEND_POLL_MS, askSpend)
178
179 return next(e)
180 })
181
182 // Each model call a mod publishes (avatar7's lines and journals, jukebox7's
183 // intents): counted, and priced as the API would, though the subscription
184 // bills none of it. A hook on model.complete does not see another mod's call.
185 for (const plugin of ['avatar7', 'jukebox7'] as const) {
186 on('state.set', { plugin, key: 'modelUse' }, async ($, e, next) => {
187 const done = await next(e)
188 const u = e.value
189 mods.set(u.model, addUsage(mods.get(u.model), u.model, {
190 input_tokens: u.input, output_tokens: u.output, cache_read_input_tokens: u.cacheRead,
191 }))
192 tail = statusLine() ?? ''
193 $.ui.invalidate('ui.render')
194 return done
195 })
196 }
197
198 on('session.measure', async ($, e, next) => {
199 if (e.context.percent !== undefined) {
200 context = e.context.percent
201 const step = ring('context', CONTEXT_STEPS, context)
202 if (step !== undefined) {
203 const tokens = e.context.tokens === undefined ? '' : ` (${Math.round(e.context.tokens / 1000)}k of ${Math.round(e.context.window / 1000)}k)`
204 $.ui.toast(`context ${Math.round(context)}% full${tokens}${step >= 95 ? ', compaction is close' : ''}`)
205 }
206 }
207
208 limits = e.rateLimits
209 for (const l of limits) {
210 const step = ring(`limit:${l.kind}`, LIMIT_STEPS, l.percentUsed)
211 if (step !== undefined) {
212 const reset = hhmm(l.resetsAt)
213 $.ui.toast(`${label(l.kind)} rate limit ${Math.round(l.percentUsed)}% used${reset === '' ? '' : `, resets ${reset}`}`)
214 }
215 }
216
217 tail = statusLine() ?? ''
218 $.ui.invalidate('ui.render')
219 return next(e)
220 })
221
222 on('ui.render', { component: 'PromptHint' }, async ($, e, next) =>
223 tail === '' ? next(e) : next({ ...e, props: { ...e.props, tail: `${e.props.tail ?? ''} · ${tail}` } }),
224 )
225
226 // A write into the memory folder: the clock rereads the index.
227 for (const tool of ['Write', 'Edit'] as const) {
228 on('tool.call', { tool }, async ($, e, next) => {
229 const done = await next(e)
230 const dir = memoryPath?.slice(0, memoryPath.lastIndexOf('/') + 1)
231 if (dir !== undefined && e.file_path.startsWith(dir)) memoryDirty = true
232 return done
233 })
234 }
235
236 // `/usage7 test`: a sample ring, so the voice avatar7 lends the bell can
237 // be heard without waiting for a real threshold.
238 on('command.run', { command: 'usage7' }, async ($, e) => {
239 if (e.args.trim() === 'test') {
240 $.ui.toast('test ring: context 72% full, a sample, no real limit is near')
241 return { text: 'test ring sent.' }
242 }
243 const rows: string[] = []
244 rows.push(context === undefined ? 'context: no reading yet' : `context: ${Math.round(context)}%, rings at ${CONTEXT_STEPS.join(' / ')}`)
245 if (limits.length === 0) rows.push('rate limits: no reading (not on a subscription, or no response yet)')
246 for (const l of limits) {
247 const reset = hhmm(l.resetsAt)
248 rows.push(`${label(l.kind)}: ${Math.round(l.percentUsed)}%${reset === '' ? '' : `, resets ${reset}`}, rings at ${LIMIT_STEPS.join(' / ')}`)
249 }
250 rows.push(
251 memory === undefined
252 ? 'memory index: not found in this session'
253 : `memory index: ${memory.lines}/${MEMORY_MAX_LINES} lines, ${memory.bytes}/${MEMORY_MAX_BYTES} bytes (${memory.path})`,
254 )
255 if (mods.size === 0) rows.push('mods (subscription): no model call yet this session')
256 for (const [model, m] of mods) {
257 rows.push(`mods ${model}: ${m.calls} calls, ${ktok(m.input)} in, ${ktok(m.output)} out, ${ktok(m.cacheRead)} cached; ≈${usd(m.usd)} at API prices, billed to the plan, not in dollars`)
258 }
259 if (api === undefined) rows.push('api: not asked yet')
260 else if (!api.ok || api.total === undefined) rows.push(`api: ${api.error ?? 'no answer'}`)
261 else {
262 rows.push(`api: ${usd(api.total.today)} today, ${usd(api.total.yesterday)} yesterday, ${usd(api.total.month)} this month (UTC days), rings at $${API_DAY_STEPS.join(' / $')} a day`)
263 for (const [ws, v] of Object.entries(api.workspaces ?? {})) {
264 rows.push(` ${ws}: ${usd(v.today)} today, ${usd(v.month)} month`)
265 }
266 }
267 return { text: rows.join('\n') }
268 })
269}
270types/index.d.ts 16 lines1// What this mod asks of avatar7 when it toasts, if avatar7 is loaded: the
2// face (calm, or amber for a warning) and what happened. avatar7 hears the
3// write; nothing here depends on it.
4export type Announce = { mood: 'watch' | 'error'; event: string }
5
6// A model call another mod published (avatar7, jukebox7): read only here.
7export type ModelUse = { model: string; input: number; output: number; cacheRead: number; at: number }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'usage-bell': { announce: Announce }
12 avatar7: { modelUse: ModelUse }
13 jukebox7: { modelUse: ModelUse }
14 }
15}
16