Records rate-limit use across sessions and hands the 5-hour and weekly forecasts to statusline.ts, which draws context, use and when each window would reach…

depends on chezmoi
sh -c "$(curl -fsLS get.chezmoi.io)" -- -b $HOME/.local/binwinget install twpayne.chezmoiSet-ExecutionPolicy -ExecutionPolicy ByPass -Scope Processchezmoi init --apply berlysia~/.local/bin will be added to $PATH~/.local/.bin will be added to $PATH , and overwrite this directory with symlinkTailscale経由のSSHと、公開リポジトリに端末情報を置かない鍵管理はSSH設定の手順を参照。
Local workflow verification uses actrun, managed by this project's mise config.
mise install
pnpm ci:local:typescript
pnpm ci:local:codex
pnpm ci:local:shell
If your shell is not activated by mise, run commands through mise exec -- ... instead.
Claude workflows use environment-scoped secrets instead of repository-wide secrets.
claude-manual: used by .github/workflows/claude.ymlclaude-autofix: used by .github/workflows/auto-fix-dependencies.ymlAdd CLAUDE_CODE_OAUTH_TOKEN to both environments. If you want unattended automation, leave approvals and wait timers disabled.
This dotfiles project includes tools for analyzing Claude Code hook decision logs:
home/dot_claude/scripts/update-auto-approve.tsAutomated permission pattern analysis and management tool:
# Interactive review of all patterns
bun home/dot_claude/scripts/update-auto-approve.ts
# Preview changes without applying
bun home/dot_claude/scripts/update-auto-approve.ts --dry-run
# Analyze last 7 days only
bun home/dot_claude/scripts/update-auto-approve.ts --since 7d
# Auto-approve safe patterns without interaction
bun home/dot_claude/scripts/update-auto-approve.ts --auto-approve-safe
# Show detailed analysis information
bun home/dot_claude/scripts/update-auto-approve.ts --verbose
Features:
~/.claude/logs/decisions.jsonl for permission patternsThis repository includes comprehensive Claude Code configuration with:
.skills/ directory)~/.claude/settings.json is managed by splitting it into multiple files:
.settings.base.json.tmpl: Core settings (model, language, statusLine, alwaysThinkingEnabled, etc.).settings.permissions.json: Permission settings (allow/deny).settings.hooks.json.tmpl: Hooks configuration (dynamically generated with chezmoi variables)These files are automatically merged by run_onchange_update-settings-json.sh.tmpl during chezmoi apply.
Important notes:
.settings.hooks.json.tmpl and run chezmoi applyenabledPlugins is managed by the claude plugin CLI; existing values are preserved on merge (see Plugin Management)~/.claude.json (MCP servers configuration) is automatically managed by run_onchange_update-claude-json.sh.tmpl:
How it works:
package.json dependencies~/.claude.jsonmcpServers, preferredNotifChannel, and defaultModeThe managed MCP servers are defined in the template of run_onchange_update-claude-json.sh.tmpl.
To update versions: Edit package.json dependencies and run chezmoi apply
This project uses a two-layer approach for managing Claude Code skills:
.skills/)${projectRoot}/.skills/ for unified management (at repo root, outside home/)~/.claude/skills/ and ~/.codex/skills/ via run_after_sync-skills.sh.tmplhome/dot_apm/apm.yml)apm.yml)apm install -g from GitHub repositoriesapm.lock.yamlBoth layers coexist in ~/.claude/skills/ directory.
Plugins and marketplaces are declared in home/.chezmoidata/claude_plugins.yaml. chezmoi apply installs and removes them to match the declaration (run_onchange_install-claude-plugins-8.sh.tmpl).
Auditing and syncing:
# Compare declared plugins (YAML) with the actual state of the claude CLI
~/.claude/scripts/show-missing-plugins.sh
# Print YAML generated from the installed plugins and diff it against the declaration
~/.claude/scripts/sync-enabled-plugins.sh
To use these skills in another project:
cd /path/to/your/project
curl -fsSL https://raw.githubusercontent.com/berlysia/dotfiles/master/scripts/setup-claude-skills.sh | bash
This creates a simple .claude/settings.json with inline SessionStart command - no hook files needed!
Skills are installed to ~/.claude/ and auto-update on Claude Code startup.
See docs/external-usage.md for detailed documentation.
See .skills/ directory for available skills.
hooks/register.tsx 226 lines1import type { EngineInterface, Register } from "claude-code";
2import type { Timer } from "claude-code";
3import { type RateLimitSample, type RateLimitWindow } from "./forecast";
4import { buildLineInput } from "./line";
5import { lineFilePath } from "./line-file";
6import type { ForecastFile } from "./status-input";
7import {
8 collectWindows,
9 decode,
10 encode,
11 isRecordDue,
12 mergeAll,
13 nextWakeAt,
14 pruneOwn,
15 selectKeysToDelete,
16} from "./samples";
17
18const KEY_PREFIX = "samples:v1:";
19// Other sessions' samples are re-read at most this often: reading 12 keys took
20// 75-157 ms in the field test, too slow to pay on every recorded measure.
21const REREAD_INTERVAL_MS = 600_000;
22
23type UsageState = {
24 /** Every session's samples; the own key is the memory copy, which wins. */
25 byKey: Map<string, RateLimitSample[]>;
26 /** Survives a session id change, so the same reading is not recorded twice. */
27 lastRecorded: Partial<Record<RateLimitWindow, RateLimitSample>>;
28 timer: Timer | null;
29 loggedReject: boolean;
30 loggedWriteReject: boolean;
31 loaded: boolean;
32 lastRereadAt: number | null;
33};
34
35function createState(): UsageState {
36 return {
37 byKey: new Map(),
38 lastRecorded: {},
39 timer: null,
40 loggedReject: false,
41 loggedWriteReject: false,
42 loaded: false,
43 lastRereadAt: null,
44 };
45}
46
47// The engine follows `$` only into functions declared at the top level of the
48// module (a closure inside `register` makes the module fail to load), so every
49// helper below takes `$` and the state as arguments.
50
51/**
52 * Hands the forecasts to statusline.ts, which draws the line. Only windows
53 * that have a latest sample are written; the file is written every time (its
54 * `at` moves) so the reader can tell the mod is alive.
55 */
56async function writeForecastFile(
57 $: EngineInterface,
58 state: UsageState,
59 input: ReturnType<typeof buildLineInput>,
60 now: number,
61): Promise<void> {
62 const path = lineFilePath(
63 {
64 XDG_RUNTIME_DIR: await $.env.get("XDG_RUNTIME_DIR"),
65 TMPDIR: await $.env.get("TMPDIR"),
66 },
67 await $.session.id(),
68 );
69 if (path === null) return;
70 const windows: ForecastFile["windows"] = {};
71 for (const window of ["five_hour", "seven_day"] as const) {
72 const { latest, forecast } = input.windows[window];
73 if (latest === null) continue;
74 windows[window] = { resetsAt: latest.resetsAt, forecast };
75 }
76 const file: ForecastFile = { v: 1, at: now, windows };
77 try {
78 await $.fs.write(path, JSON.stringify(file));
79 } catch (error) {
80 if (!state.loggedWriteReject) {
81 state.loggedWriteReject = true;
82 $.ui.log(
83 `usage-line: could not write forecast file: ${error instanceof Error ? error.message : String(error)}`,
84 { to: "debug" },
85 );
86 }
87 }
88}
89
90/**
91 * Re-reads the store: deletes expired keys and rebuilds `byKey`. The own key
92 * keeps its memory copy whenever it has one, because the store may be behind
93 * (a rejected `set`) or this call may have raced a `session.measure` that
94 * recorded while the reads were in flight.
95 */
96async function loadAll(
97 $: EngineInterface,
98 state: UsageState,
99 now: number,
100): Promise<void> {
101 const ownKey = `${KEY_PREFIX}${await $.session.id()}`;
102 const keys = (await $.store.keys()).filter((k) => k.startsWith(KEY_PREFIX));
103 const entries = await Promise.all(
104 keys.map(async (key): Promise<[string, unknown]> => [
105 key,
106 await $.store.get(key),
107 ]),
108 );
109 const doomed = new Set(
110 selectKeysToDelete(entries, now).filter((key) => key !== ownKey),
111 );
112 await Promise.all([...doomed].map((key) => $.store.delete(key)));
113
114 // From here to the assignment nothing is awaited: `state.byKey` is read at
115 // the moment it is replaced, so a concurrent measure's samples are not lost.
116 const rebuilt = new Map<string, RateLimitSample[]>();
117 const ownInMemory = state.byKey.get(ownKey);
118 if (ownInMemory !== undefined) rebuilt.set(ownKey, ownInMemory);
119 for (const [key, value] of entries) {
120 if (doomed.has(key) || rebuilt.has(key)) continue;
121 rebuilt.set(key, decode(value, now));
122 }
123 state.byKey = rebuilt;
124 state.lastRereadAt = now;
125}
126
127function scheduleWake(
128 $: EngineInterface,
129 state: UsageState,
130 waitMs: number,
131): Timer {
132 return $.clock.after(waitMs, () => {
133 void rebuildLine($, state).catch(() => undefined);
134 });
135}
136
137async function rebuildLine(
138 $: EngineInterface,
139 state: UsageState,
140): Promise<void> {
141 const now = await $.clock.now();
142 const merged = mergeAll(state.byKey);
143 await writeForecastFile($, state, buildLineInput(merged, null, now), now);
144 state.timer?.cancel();
145 state.timer = null;
146 const wakeAt = nextWakeAt(merged, now);
147 if (wakeAt !== null) state.timer = scheduleWake($, state, wakeAt - now);
148}
149
150async function recordMeasure(
151 $: EngineInterface,
152 state: UsageState,
153 e: { rateLimits: unknown },
154): Promise<void> {
155 const now = await $.clock.now();
156 let didLoad = false;
157 if (!state.loaded) {
158 // A hot reload, or a measure that arrived before session.start finished.
159 await loadAll($, state, now);
160 state.loaded = true;
161 didLoad = true;
162 }
163
164 const due = collectWindows(e.rateLimits).filter((observed) =>
165 isRecordDue(state.lastRecorded[observed.window], observed, now),
166 );
167 if (due.length > 0) {
168 const added: RateLimitSample[] = due.map((observed) => ({
169 ...observed,
170 sampledAt: now,
171 }));
172 const ownKey = `${KEY_PREFIX}${await $.session.id()}`;
173 const own = pruneOwn([...(state.byKey.get(ownKey) ?? []), ...added], now);
174 state.byKey.set(ownKey, own);
175 try {
176 await $.store.set(ownKey, encode(own));
177 for (const sample of added) state.lastRecorded[sample.window] = sample;
178 const isRereadDue =
179 state.lastRereadAt === null ||
180 now - state.lastRereadAt >= REREAD_INTERVAL_MS;
181 if (!didLoad && isRereadDue) await loadAll($, state, now);
182 } catch (error) {
183 // Drop what was added so the next measure records it again.
184 state.byKey.set(
185 ownKey,
186 (state.byKey.get(ownKey) ?? []).filter((s) => !added.includes(s)),
187 );
188 if (!state.loggedReject) {
189 state.loggedReject = true;
190 $.ui.log(
191 `usage-line: could not save samples: ${error instanceof Error ? error.message : String(error)}`,
192 { to: "debug" },
193 );
194 }
195 }
196 }
197 await rebuildLine($, state);
198}
199
200export const register: Register = (on) => {
201 // Made here, synchronously: a measure can arrive before session.start ends.
202 const state = createState();
203
204 on("session.start", async ($, e, next) => {
205 const result = await next(e);
206 try {
207 await loadAll($, state, await $.clock.now());
208 state.loaded = true;
209 await rebuildLine($, state);
210 } catch {
211 // The forecast file is a convenience; the session goes on without it.
212 }
213 return result;
214 });
215
216 on("session.measure", async ($, e, next) => {
217 const result = await next(e);
218 try {
219 await recordMeasure($, state, e);
220 } catch {
221 // As above.
222 }
223 return result;
224 });
225};
226hooks/forecast.ts 145 lines1// Ported from ~/workspace/llm-dashboard/src/usageForecast.ts at commit
2// cc79b3bfdb46561f3d849143c1165728dc70bab7. Averaging periods are fixed (5h window: 1h,
3// weekly window: 24h) and the per-service checks are dropped: this mod only sees Claude.
4const HOUR = 3_600_000;
5export const MAX_OBSERVATION_AGE_MS = 25 * 60_000;
6
7export function windowDurationMs(window: RateLimitWindow): number {
8 return window === "five_hour" ? 5 * HOUR : 7 * 24 * HOUR;
9}
10
11export type RateLimitWindow = "five_hour" | "seven_day";
12
13/** One recorded reading of a window: `sampledAt` in epoch ms, `resetsAt` in epoch seconds. */
14export type RateLimitSample = {
15 window: RateLimitWindow;
16 sampledAt: number;
17 usedPercent: number;
18 resetsAt: number;
19};
20
21/** A window as `session.measure` reported it, before it is stamped with a time. */
22export type ObservedWindow = {
23 window: RateLimitWindow;
24 usedPercent: number;
25 resetsAt: number;
26};
27
28/** The allowed values live in arrays so a reader of a stored forecast validates against them. */
29export const FORECAST_STATUSES = [
30 "unknown",
31 "exhausted",
32 "runs_out",
33 "spare",
34] as const;
35export const FORECAST_REASONS = [
36 "no_data",
37 "expired",
38 "stale",
39 "insufficient_data",
40] as const;
41
42export type UsageForecast = {
43 status: (typeof FORECAST_STATUSES)[number];
44 reason: (typeof FORECAST_REASONS)[number] | null;
45 averagePeriodMs: number;
46 observedMs: number;
47 ratePerHour: number | null;
48 projectedUsedPercent: number | null;
49 exhaustionAt: number | null;
50};
51
52export type WindowLine = {
53 latest: RateLimitSample | null;
54 forecast: UsageForecast;
55};
56
57export type LineInput = {
58 contextPercent: number | null;
59 windows: Record<RateLimitWindow, WindowLine>;
60};
61
62/** Trailing, time-weighted average of quota increments, in percentage points/hour. */
63export function forecastUsage(
64 rows: RateLimitSample[],
65 window: RateLimitWindow,
66 now: number,
67): UsageForecast {
68 const averagePeriodMs = window === "five_hour" ? HOUR : 24 * HOUR;
69 const minimumObservationMs = Math.min(
70 averagePeriodMs,
71 window === "five_hour" ? 10 * 60_000 : HOUR,
72 );
73 const forecast: UsageForecast = {
74 status: "unknown",
75 reason: "no_data",
76 averagePeriodMs,
77 observedMs: 0,
78 ratePerHour: null,
79 projectedUsedPercent: null,
80 exhaustionAt: null,
81 };
82 const ordered = rows
83 .filter((row) => row.window === window && row.sampledAt <= now)
84 .toSorted((a, b) => a.sampledAt - b.sampledAt);
85 const latest = ordered.at(-1);
86 if (!latest) return forecast;
87 const resetsAt = latest.resetsAt * 1000;
88 if (resetsAt <= now) return { ...forecast, reason: "expired" };
89 if (latest.usedPercent === 100) {
90 return {
91 ...forecast,
92 status: "exhausted",
93 reason: null,
94 projectedUsedPercent: 100,
95 };
96 }
97 if (now - latest.sampledAt > MAX_OBSERVATION_AGE_MS)
98 return { ...forecast, reason: "stale" };
99
100 const startsAt = resetsAt - windowDurationMs(window);
101 const cutoff = Math.max(startsAt, now - averagePeriodMs);
102 let observedMs = 0;
103 let increase = 0;
104 // Walk backwards only through the current uninterrupted quota window. A correction,
105 // reset, or unobserved gap starts a new segment; none is counted as zero use.
106 for (let index = ordered.length - 1; index > 0; index--) {
107 const right = ordered[index]!;
108 const left = ordered[index - 1]!;
109 const duration = right.sampledAt - left.sampledAt;
110 if (right.sampledAt <= cutoff) break;
111 if (
112 left.resetsAt !== latest.resetsAt ||
113 right.usedPercent < left.usedPercent ||
114 duration > MAX_OBSERVATION_AGE_MS ||
115 duration > averagePeriodMs
116 )
117 break;
118 if (duration <= 0) continue;
119 const overlap = right.sampledAt - Math.max(left.sampledAt, cutoff);
120 observedMs += overlap;
121 increase += ((right.usedPercent - left.usedPercent) * overlap) / duration;
122 }
123 if (observedMs < minimumObservationMs)
124 return { ...forecast, reason: "insufficient_data", observedMs };
125 const ratePerHour = (increase / observedMs) * HOUR;
126 // The unobserved tail is not added to the average's denominator. Extrapolation
127 // begins at the last actual observation and is explicitly shown as a prediction.
128 const projectedUsedPercent =
129 latest.usedPercent + (ratePerHour * (resetsAt - latest.sampledAt)) / HOUR;
130 const exhaustionAt =
131 ratePerHour > 0
132 ? latest.sampledAt + ((100 - latest.usedPercent) / ratePerHour) * HOUR
133 : null;
134 return {
135 status: projectedUsedPercent >= 100 ? "runs_out" : "spare",
136 reason: null,
137 averagePeriodMs,
138 observedMs,
139 ratePerHour,
140 projectedUsedPercent,
141 exhaustionAt:
142 exhaustionAt !== null && exhaustionAt <= resetsAt ? exhaustionAt : null,
143 };
144}
145hooks/line.ts 28 lines1import {
2 forecastUsage,
3 type LineInput,
4 type RateLimitSample,
5} from "./forecast";
6import { latestUsage } from "./samples";
7
8/** Builds the displayed latest value and the forecast from one merged series. */
9export function buildLineInput(
10 merged: RateLimitSample[],
11 contextPercent: number | null,
12 now: number,
13): LineInput {
14 return {
15 contextPercent,
16 windows: {
17 five_hour: {
18 latest: latestUsage(merged, "five_hour", now),
19 forecast: forecastUsage(merged, "five_hour", now),
20 },
21 seven_day: {
22 latest: latestUsage(merged, "seven_day", now),
23 forecast: forecastUsage(merged, "seven_day", now),
24 },
25 },
26 };
27}
28hooks/line-file.ts 29 lines1// Where the forecast file of one session lives. Both the mod (writer) and
2// statusline.ts (reader) derive the path here, so it must import neither
3// `node:*` nor `$`: the mod engine and bun both load this module.
4
5type Env = { XDG_RUNTIME_DIR?: string; TMPDIR?: string };
6
7const SESSION_ID = /^[A-Za-z0-9-]{1,128}$/;
8// Only the per-user temp directory of macOS. A TMPDIR such as /tmp is shared:
9// another user could plant a symlink first, and `$.fs.write` cannot tell.
10const MACOS_USER_TMP = "/var/folders/";
11
12function usableDir(value: string | undefined): string | null {
13 if (value === undefined || value === "" || !value.startsWith("/"))
14 return null;
15 if (value.split("/").includes("..")) return null;
16 const trimmed = value.replace(/\/+$/, "");
17 return trimmed === "" ? null : trimmed;
18}
19
20export function lineFilePath(env: Env, sessionId: string): string | null {
21 if (!SESSION_ID.test(sessionId)) return null;
22 const runtime = usableDir(env.XDG_RUNTIME_DIR);
23 if (runtime !== null) return `${runtime}/usage-line/${sessionId}.json`;
24 const tmp = usableDir(env.TMPDIR);
25 if (tmp !== null && tmp.startsWith(MACOS_USER_TMP))
26 return `${tmp}/usage-line/${sessionId}.json`;
27 return null;
28}
29hooks/status-input.ts 206 lines1// The contract between the mod (writer of the forecast file) and statusline.ts
2// (reader), plus how the reader turns the status line's stdin and that file
3// into the input of `formatLine`. Shared by both sides, so it must import
4// neither `node:*` nor `$`.
5//
6// Version rule: adding a field does not bump `v` and readers ignore unknown
7// keys; only a change of meaning or a removal bumps it. A reader neither uses
8// nor deletes a file whose `v` differs.
9import {
10 FORECAST_REASONS,
11 FORECAST_STATUSES,
12 MAX_OBSERVATION_AGE_MS,
13 type LineInput,
14 type RateLimitWindow,
15 type UsageForecast,
16 type WindowLine,
17} from "./forecast";
18
19export type ForecastFile = {
20 v: 1;
21 /** Epoch ms, when the mod wrote the file. */
22 at: number;
23 windows: Partial<
24 Record<
25 RateLimitWindow,
26 {
27 /** Epoch seconds, the same unit as the status line's `resets_at`. */
28 resetsAt: number;
29 forecast: UsageForecast;
30 }
31 >
32 >;
33};
34
35const DAY_MS = 86_400_000;
36// A file stamped further in the future than this is a clock skew or garbage.
37const MAX_FUTURE_SKEW_MS = 60_000;
38export const MAX_FORECAST_FILE_BYTES = 64 * 1024;
39const WINDOW_KEYS: readonly RateLimitWindow[] = ["five_hour", "seven_day"];
40
41type Obj = Record<string, unknown>;
42
43function isObject(value: unknown): value is Obj {
44 return typeof value === "object" && value !== null && !Array.isArray(value);
45}
46
47function isFiniteNumber(value: unknown): value is number {
48 return typeof value === "number" && Number.isFinite(value);
49}
50
51function isPercent(value: unknown): value is number {
52 return isFiniteNumber(value) && value >= 0 && value <= 100;
53}
54
55function isNullOr(
56 value: unknown,
57 test: (v: unknown) => boolean,
58): value is null {
59 return value === null || test(value);
60}
61
62function isNonNegative(value: unknown): boolean {
63 return isFiniteNumber(value) && value >= 0;
64}
65
66function parseForecast(value: unknown, now: number): UsageForecast | null {
67 if (!isObject(value)) return null;
68 const {
69 status,
70 reason,
71 averagePeriodMs,
72 observedMs,
73 ratePerHour,
74 projectedUsedPercent,
75 exhaustionAt,
76 } = value;
77 const statusOk = FORECAST_STATUSES.find((s) => s === status);
78 if (statusOk === undefined) return null;
79 const reasonOk =
80 reason === null ? null : FORECAST_REASONS.find((r) => r === reason);
81 if (reasonOk === undefined) return null;
82 if (!isNonNegative(averagePeriodMs) || !isNonNegative(observedMs))
83 return null;
84 if (!isNullOr(ratePerHour, isNonNegative)) return null;
85 // Not capped at 100: a runs_out projection goes past it (e.g. 600).
86 if (!isNullOr(projectedUsedPercent, isNonNegative)) return null;
87 const exhaustionOk = (v: unknown) =>
88 isFiniteNumber(v) && v >= now - DAY_MS && v <= now + 8 * DAY_MS;
89 if (!isNullOr(exhaustionAt, exhaustionOk)) return null;
90 return {
91 status: statusOk,
92 reason: reasonOk,
93 averagePeriodMs: averagePeriodMs as number,
94 observedMs: observedMs as number,
95 ratePerHour: ratePerHour as number | null,
96 projectedUsedPercent: projectedUsedPercent as number | null,
97 exhaustionAt: exhaustionAt as number | null,
98 };
99}
100
101/**
102 * Validates the parsed JSON of a forecast file. Returns null when the file as
103 * a whole is unusable (wrong version, bad or out-of-range `at`); a window
104 * whose entry is malformed is dropped on its own.
105 */
106export function parseForecastFile(
107 value: unknown,
108 now: number,
109): ForecastFile | null {
110 if (!isObject(value) || value.v !== 1) return null;
111 const { at, windows } = value;
112 if (!isFiniteNumber(at)) return null;
113 const age = now - at;
114 if (age < -MAX_FUTURE_SKEW_MS || age > MAX_OBSERVATION_AGE_MS) return null;
115 if (!isObject(windows)) return null;
116 const parsed: ForecastFile["windows"] = {};
117 for (const key of WINDOW_KEYS) {
118 const entry = windows[key];
119 if (!isObject(entry) || !isFiniteNumber(entry.resetsAt)) continue;
120 const forecast = parseForecast(entry.forecast, now);
121 if (forecast === null) continue;
122 parsed[key] = { resetsAt: entry.resetsAt, forecast };
123 }
124 return { v: 1, at, windows: parsed };
125}
126
127/** Whether a file's stat says it may be read: a regular file of ours, not huge. */
128export function isForecastFileReadable(
129 stat: { isFile: boolean; uid: number; size: number },
130 uid: number,
131): boolean {
132 return (
133 stat.isFile && stat.uid === uid && stat.size <= MAX_FORECAST_FILE_BYTES
134 );
135}
136
137function noForecast(): UsageForecast {
138 return {
139 status: "unknown",
140 reason: "no_data",
141 averagePeriodMs: 0,
142 observedMs: 0,
143 ratePerHour: null,
144 projectedUsedPercent: null,
145 exhaustionAt: null,
146 };
147}
148
149function readWindow(
150 stdin: Obj,
151 window: RateLimitWindow,
152 now: number,
153): { usedPercent: number; resetsAt: number } | null {
154 const limits = stdin.rate_limits;
155 if (!isObject(limits)) return null;
156 const entry = limits[window];
157 if (!isObject(entry)) return null;
158 const { used_percentage: used, resets_at: resets } = entry;
159 if (!isPercent(used) || !isFiniteNumber(resets)) return null;
160 const resetsMs = resets * 1000;
161 if (resetsMs < now - DAY_MS || resetsMs > now + 8 * DAY_MS) return null;
162 return { usedPercent: used, resetsAt: Math.trunc(resets) };
163}
164
165/**
166 * Builds the line's input: usage and context from the status line's stdin
167 * (always current), forecasts from the mod's file when it is fresh and was
168 * written for the same reset. Never throws.
169 */
170export function buildStatusInput(
171 stdin: unknown,
172 file: unknown,
173 now: number,
174): LineInput {
175 const empty: Record<RateLimitWindow, WindowLine> = {
176 five_hour: { latest: null, forecast: noForecast() },
177 seven_day: { latest: null, forecast: noForecast() },
178 };
179 try {
180 if (!isObject(stdin)) return { contextPercent: null, windows: empty };
181 const context = isObject(stdin.context_window)
182 ? stdin.context_window.used_percentage
183 : null;
184 const parsedFile = parseForecastFile(file, now);
185 const windows = { ...empty };
186 for (const window of WINDOW_KEYS) {
187 const observed = readWindow(stdin, window, now);
188 if (observed === null) continue;
189 const stored = parsedFile?.windows[window];
190 windows[window] = {
191 latest: { window, sampledAt: now, ...observed },
192 forecast:
193 stored !== undefined && stored.resetsAt === observed.resetsAt
194 ? stored.forecast
195 : noForecast(),
196 };
197 }
198 return {
199 contextPercent: isPercent(context) ? context : null,
200 windows,
201 };
202 } catch {
203 return { contextPercent: null, windows: empty };
204 }
205}
206hooks/samples.ts 173 lines1import {
2 MAX_OBSERVATION_AGE_MS,
3 type ObservedWindow,
4 type RateLimitSample,
5 type RateLimitWindow,
6} from "./forecast";
7
8const WINDOW_KINDS = [
9 "five_hour",
10 "seven_day",
11] as const satisfies readonly RateLimitWindow[];
12const RECORD_INTERVAL_MS = 600_000;
13const RETENTION_MS = 7 * 24 * 3_600_000;
14const MAX_SAMPLES_PER_KEY = 10_000;
15const MAX_WAKE_AHEAD_MS = 8 * 24 * 3_600_000;
16const KEY_PREFIX = "samples:v1:";
17
18type EncodedSample = [
19 sampledAt: number,
20 window: 0 | 1,
21 usedPercent: number,
22 resetsAt: number,
23];
24
25/** The only place the engine's `percentUsed` becomes `usedPercent`. */
26export function collectWindows(rateLimits: unknown): ObservedWindow[] {
27 if (!Array.isArray(rateLimits)) return [];
28 const windows: ObservedWindow[] = [];
29 for (const kind of WINDOW_KINDS) {
30 for (const entry of rateLimits as unknown[]) {
31 if (typeof entry !== "object" || entry === null) continue;
32 const {
33 kind: entryKind,
34 percentUsed,
35 resetsAt,
36 } = entry as Record<string, unknown>;
37 if (entryKind !== kind) continue;
38 if (typeof percentUsed !== "number" || !Number.isFinite(percentUsed))
39 continue;
40 if (percentUsed < 0 || percentUsed > 100) continue;
41 if (typeof resetsAt !== "string") continue;
42 const resetsAtMs = Date.parse(resetsAt);
43 if (!Number.isFinite(resetsAtMs)) continue;
44 windows.push({
45 window: kind,
46 usedPercent: percentUsed,
47 resetsAt: Math.trunc(resetsAtMs / 1000),
48 });
49 break;
50 }
51 }
52 return windows;
53}
54
55export function isRecordDue(
56 last: RateLimitSample | undefined,
57 observed: ObservedWindow,
58 now: number,
59): boolean {
60 return (
61 last === undefined ||
62 last.usedPercent !== observed.usedPercent ||
63 last.resetsAt !== observed.resetsAt ||
64 now - last.sampledAt >= RECORD_INTERVAL_MS
65 );
66}
67
68export function encode(samples: RateLimitSample[]): EncodedSample[] {
69 return samples.map((s) => [
70 s.sampledAt,
71 s.window === "five_hour" ? 0 : 1,
72 s.usedPercent,
73 s.resetsAt,
74 ]);
75}
76
77/** Store values are untrusted: anything malformed is dropped, never thrown on. */
78export function decode(value: unknown, now: number): RateLimitSample[] {
79 if (!Array.isArray(value)) return [];
80 const samples: RateLimitSample[] = [];
81 for (const item of value as unknown[]) {
82 if (!Array.isArray(item) || item.length !== 4) continue;
83 const [sampledAt, window, usedPercent, resetsAt] = item as unknown[];
84 if (
85 typeof sampledAt !== "number" ||
86 typeof window !== "number" ||
87 typeof usedPercent !== "number" ||
88 typeof resetsAt !== "number"
89 )
90 continue;
91 if (![sampledAt, window, usedPercent, resetsAt].every(Number.isFinite))
92 continue;
93 if (window !== 0 && window !== 1) continue;
94 if (usedPercent < 0 || usedPercent > 100) continue;
95 if (sampledAt > now) continue;
96 samples.push({
97 window: window === 0 ? "five_hour" : "seven_day",
98 sampledAt,
99 usedPercent,
100 resetsAt,
101 });
102 }
103 if (samples.length <= MAX_SAMPLES_PER_KEY) return samples;
104 return samples
105 .toSorted((a, b) => a.sampledAt - b.sampledAt)
106 .slice(-MAX_SAMPLES_PER_KEY);
107}
108
109export function mergeAll(
110 byKey: Map<string, RateLimitSample[]>,
111): RateLimitSample[] {
112 const keys = [...byKey.keys()].toSorted();
113 return keys
114 .flatMap((key) => byKey.get(key) ?? [])
115 .toSorted((a, b) => a.sampledAt - b.sampledAt);
116}
117
118/** Uses the same `sampledAt <= now` cut as `forecastUsage`, so both read the same sample. */
119export function latestUsage(
120 merged: RateLimitSample[],
121 window: RateLimitWindow,
122 now: number,
123): RateLimitSample | null {
124 return (
125 merged.filter((s) => s.window === window && s.sampledAt <= now).at(-1) ??
126 null
127 );
128}
129
130export function pruneOwn(
131 samples: RateLimitSample[],
132 now: number,
133): RateLimitSample[] {
134 return samples.filter((s) => now - s.sampledAt <= RETENTION_MS);
135}
136
137export function selectKeysToDelete(
138 entries: [string, unknown][],
139 now: number,
140): string[] {
141 const doomed: string[] = [];
142 for (const [key, value] of entries) {
143 if (!key.startsWith(KEY_PREFIX)) continue;
144 const samples = decode(value, now);
145 const newest = samples.reduce(
146 (max, s) => Math.max(max, s.sampledAt),
147 -Infinity,
148 );
149 if (samples.length === 0 || now - newest > RETENTION_MS) doomed.push(key);
150 }
151 return doomed;
152}
153
154/** The next instant at which the displayed line changes without a new measure. */
155export function nextWakeAt(
156 merged: RateLimitSample[],
157 now: number,
158): number | null {
159 const candidates: number[] = [];
160 for (const window of WINDOW_KINDS) {
161 const latest = latestUsage(merged, window, now);
162 if (latest === null) continue;
163 candidates.push(
164 latest.sampledAt + MAX_OBSERVATION_AGE_MS + 1,
165 latest.resetsAt * 1000,
166 );
167 }
168 const upcoming = candidates.filter((at) => at > now);
169 if (upcoming.length === 0) return null;
170 const earliest = Math.min(...upcoming);
171 return earliest > now + MAX_WAKE_AHEAD_MS ? null : earliest;
172}
173