SLOPSHOPPER

usage-line

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…

newtimer
★ 9v0.1.0no licenseupdated 2026-10-22berlysia/dotfiles/mods/usage-line
A shopper browsing a rack in a slop shop
README

dotfiles

depends on chezmoi

How to use(for me)

  1. install chezmoi
  2. sh -c "$(curl -fsLS get.chezmoi.io)" -- -b $HOME/.local/bin
  3. winget install twpayne.chezmoi
  4. if you are in PowerShell, Set-ExecutionPolicy -ExecutionPolicy ByPass -Scope Process
  5. chezmoi init --apply berlysia

note

  • ~/.local/bin will be added to $PATH
  • ~/.local/.bin will be added to $PATH , and overwrite this directory with symlink

Tailscale経由のSSHと、公開リポジトリに端末情報を置かない鍵管理はSSH設定の手順を参照。

Local CI

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.

GitHub Environments

Claude workflows use environment-scoped secrets instead of repository-wide secrets.

Add CLAUDE_CODE_OAUTH_TOKEN to both environments. If you want unattended automation, leave approvals and wait timers disabled.

list of something will be set up

  • homebrew (only mac)
  • mise
  • direnv
  • fzf
  • ripgrep
  • bat
  • WSL2 ssh-agent (only WSL)
  • my custom zsh prompt

Claude Decision Log Analysis

This dotfiles project includes tools for analyzing Claude Code hook decision logs:

home/dot_claude/scripts/update-auto-approve.ts

Automated 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:

  • Analyzes ~/.claude/logs/decisions.jsonl for permission patterns
  • Provides risk scoring and confidence levels
  • Interactive review interface for pattern approval
  • Automatic backup of permission files
  • Supports time-based filtering of logs

Claude Code Configuration

This repository includes comprehensive Claude Code configuration with:

  • Custom skills (see .skills/ directory)
  • Global development guidelines (CLAUDE.md)
  • Development rules (debugging, external-review, TypeScript standards)

Settings.json Split Management

~/.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:

  • To modify hooks configuration, edit .settings.hooks.json.tmpl and run chezmoi apply
  • enabledPlugins is managed by the claude plugin CLI; existing values are preserved on merge (see Plugin Management)

~/.claude.json Automatic Management

~/.claude.json (MCP servers configuration) is automatically managed by run_onchange_update-claude-json.sh.tmpl:

How it works:

  • Reads MCP server versions from package.json dependencies
  • Merges template content with existing ~/.claude.json
  • Auto-updates mcpServers, preferredNotifChannel, and defaultMode
  • Creates backup before changes and shows diff

The 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

Dual-Layer Skills Management

This project uses a two-layer approach for managing Claude Code skills:

1. Handcrafted Skills (.skills/)
  • Stored in ${projectRoot}/.skills/ for unified management (at repo root, outside home/)
  • Synced to both ~/.claude/skills/ and ~/.codex/skills/ via run_after_sync-skills.sh.tmpl
  • Used by both Claude Code and Codex
  • Always preserved during updates
2. External Skills (home/dot_apm/apm.yml)
  • Declaratively managed in APM manifest (apm.yml)
  • Installed via apm install -g from GitHub repositories
  • Tracked via apm.lock.yaml
  • Auto-removed when deleted from manifest (handcrafted skills are protected)

Both layers coexist in ~/.claude/skills/ directory.

Plugin Management

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

Using in Other Projects

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.

Skills Included

See .skills/ directory for available skills.

Source 6 files
hooks/register.tsx 226 lines
1import 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};
226
hooks/forecast.ts 145 lines
1// 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}
145
hooks/line.ts 28 lines
1import {
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}
28
hooks/line-file.ts 29 lines
1// 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}
29
hooks/status-input.ts 206 lines
1// 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}
206
hooks/samples.ts 173 lines
1import {
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