@nijaru/pi-peak-hours 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,13 @@
2
2
 
3
3
  Correct pi's recorded cost for providers that bill by time of day, and show the rate in force in the footer.
4
4
 
5
- DeepSeek is the current example. Its published prices are the **off-peak** rates, and a **2x** multiplier applies during peak windows — `01:00–04:00` and `06:00–10:00` UTC, Monday to Friday, with every other hour (including all weekend) off-peak. pi has no notion of time-of-day pricing: `usage.cost` is computed once from the model's flat rates when usage is finalized, so a session running during a peak window understates its cost by up to 2x.
5
+ DeepSeek is the current example. Its peak windows are `01:00–04:00` and `06:00–10:00` UTC, Monday to Friday, and off-peak is half the peak rate. pi has no notion of time-of-day pricing: `usage.cost` is computed once from the model's flat rates when usage is finalized, so a session is over- or understated by up to 2x depending on which side of the schedule the recorded rate sits.
6
6
 
7
- This extension rewrites `usage.cost` on the finalized assistant message, so the footer total, the per-model breakdown, and the HTML export all reflect the billed rate. While peak is in force the status shows `▲ peak`; off-peak it clears. The indicator stays generic because the multiplier differs per service — `/peak-hours status` names the schedule and the rate in force.
7
+ Both built-in entries follow what DeepSeek advertises: ×2 inside those windows and ×1 outside, because the published rate is the off-peak one and peak is a surcharge on it. Since the extension scales whatever rate pi recorded, an entry is right only where that rate really is the off-peak one — true of the OpenRouter route, which pi rates at `0.15/0.60`, and of the direct provider wherever `models.json` pins it to off-peak. Stock pi.dev rates direct as `deepseek-flash` at `0.30/1.20`, its peak number, so the configuration below inverts that one entry; the pair exists because each entry is scoped, covering the DeepSeek models on both routes without claiming unrelated OpenRouter models. Naming your own providers replaces both.
8
+
9
+ This extension rewrites `usage.cost` on the finalized assistant message, so the footer total, the per-model breakdown, and the HTML export all reflect the billed rate. The status line quotes the rate in force for the selected model — `· ▲ peak $0.30/$1.20/M` during a peak window, `· $0.15/$0.60/M` off it — with `▲ peak` or `▼ off-peak` added when the hour costs more or less than the rate pi recorded. `/peak-hours status` names the schedule behind it.
10
+
11
+ The quoted rate is pi's recorded rate scaled by the schedule, so it always agrees with the cost written to the session. It describes the hour in force rather than a particular request, though: a request that started before a boundary is billed by its start time while the footer has already moved to the new hour. Where pi's rate table is stale or wrong for your route, the quoted rate is stale in the same way — the extension corrects the shape of the bill, not the base rate.
8
12
 
9
13
  ## Install
10
14
 
@@ -21,14 +25,17 @@ Or copy `extensions/index.ts` to `~/.pi/agent/extensions/` for a single-machine
21
25
  /peak-hours on|off # toggle correction; persists to pi-peak-hours.json
22
26
  ```
23
27
 
28
+ The footer status is a separate line under the stats bar. It shows the model's current rate while a schedule covers it, and clears when correction is off, no schedule applies, or pi records no cost for the model. It re-evaluates on model switch, on every turn, and when a message is finalized — and it arms a timer for the next instant the rate it quotes actually changes, so a session sitting idle across 04:00 UTC flips to the new rate on time instead of at its next turn.
29
+
24
30
  ## Configuration
25
31
 
26
- Optional. `~/.pi/agent/extensions/pi-peak-hours.json` overlays the built-in DeepSeek schedule; anything you leave out keeps its default.
32
+ Optional. `~/.pi/agent/extensions/pi-peak-hours.json` overlays the DeepSeek schedule; anything you leave out keeps its default. The OpenRouter route for the same models is covered out of the box, and setting `providers` yourself replaces both built-in schedules with the one you describe.
27
33
 
28
34
  ```json
29
35
  {
30
36
  "active": true,
31
37
  "multiplier": 2,
38
+ "outside": 1,
32
39
  "windows": [["01:00", "04:00"], ["06:00", "10:00"]],
33
40
  "days": ["mon", "tue", "wed", "thu", "fri"],
34
41
  "providers": ["deepseek"],
@@ -36,20 +43,71 @@ Optional. `~/.pi/agent/extensions/pi-peak-hours.json` overlays the built-in Deep
36
43
  }
37
44
  ```
38
45
 
39
- Times are **UTC**. `days` accepts names or `0`–`6` (`0` is Sunday). `providers` and `models` accept `*` globs, so an OpenRouter route (`"providers": ["openrouter"], "models": ["deepseek/*"]`) picks up the same schedule.
46
+ Times are **UTC**. `days` accepts names or `0`–`6` (`0` is Sunday). `providers` and `models` accept `*` globs, so a self-hosted gateway or a reseller (`"providers": ["tokenrouter"], "models": ["deepseek/*"]`) can pick up the same schedule.
47
+
48
+ `multiplier` applies **inside** `windows` and `outside` applies everywhere else. Either may be below 1, because not every provider prices the same way round. A window may carry its own `multiplier`, which wins for that window:
49
+
50
+ ```json
51
+ "windows": [{ "start": "22:00", "end": "08:00", "multiplier": 0.4 }]
52
+ ```
53
+
54
+ **Pick the side that matches your own rate card.** The extension applies the difference from the rate pi recorded; it does not guess which side that is. The built-in pair encodes today's pi.dev catalog — DeepSeek direct at its **peak** rate, the same model through OpenRouter at its **off-peak** rate — and if your catalog changes, or you override rates in `models.json` as below, say so in the config instead of relying on the default. `/peak-hours status` reports what it would charge right now, and the numbers are in the provider's docs.
55
+
56
+ ```json
57
+ // Your models.json pins DeepSeek direct to the off-peak rate: scale up at peak.
58
+ { "multiplier": 2, "outside": 1 }
59
+
60
+ // Stock pi.dev lists DeepSeek direct at the peak rate: scale down off-peak.
61
+ { "providers": ["deepseek"], "multiplier": 1, "outside": 0.5 }
62
+ ```
63
+
64
+ ### Other providers
65
+
66
+ A `schedules` list replaces the built-in schedules with one entry per service. Each entry starts from a clean slate rather than inheriting DeepSeek's windows.
67
+
68
+ **Z.ai coding plan** — the standard credit rate during the weekday peak window, half of it everywhere else (docs.z.ai/devpack). This is inverted relative to DeepSeek: `06:00–10:00` UTC Monday–Friday is the *expensive* side, so the windows match and the multiplier lands below 1.
69
+
70
+ ```json
71
+ {
72
+ "schedules": [
73
+ { "providers": ["zai"], "windows": [["06:00", "10:00"]], "days": ["mon", "tue", "wed", "thu", "fri"],
74
+ "multiplier": 1, "outside": 0.5 }
75
+ ]
76
+ }
77
+ ```
78
+
79
+ Note that a coding plan is billed in credits against a subscription, so pi's cost for it is a list-price estimate either way.
80
+
81
+ **Alibaba Model Studio (Qwen)** — a limited-time night discount: night is `22:00–08:00` UTC+8 (`14:00–00:00` UTC) and the percentage is quoted per model, so the schedule is per model too. The example below is the published `night 60% off / daytime 20% off` shape.
82
+
83
+ ```json
84
+ {
85
+ "schedules": [
86
+ { "providers": ["qwen"], "models": ["qwen3.8-max"],
87
+ "windows": [
88
+ { "start": "14:00", "end": "00:00", "multiplier": 0.4 },
89
+ { "start": "00:00", "end": "14:00", "multiplier": 0.8 }
90
+ ], "outside": 1 }
91
+ ]
92
+ }
93
+ ```
94
+
95
+ OpenRouter publishes the same windows machine-readably for models it hosts (`pricing.overrides` with `utc_days` / `utc_start` / `utc_end` on `https://openrouter.ai/api/v1/models`), but this extension reads its schedule from config rather than the network: the correction is a rate difference, not a price lookup, and a fetch would add a failure mode to a handler that must never throw.
40
96
 
41
- A malformed field falls back to its default rather than disabling the extension; a malformed file warns once and resolves to defaults.
97
+ A malformed field falls back to its default rather than disabling the extension; a malformed file warns and resolves to defaults.
42
98
 
43
99
  ## Design notes
44
100
 
101
+ - **Only the selected model drives the indicator.** Schedules are provider- and model-scoped, so the footer never shows a rate for a service that does not bill by time of day. A rate above the recorded one reads `peak`, below it `off-peak`, and a discount is never labelled a peak. The quoted rate and the written cost come from the same multiplication, so they cannot disagree.
102
+ - **The footer follows the window it quotes.** pi has no render tick and fires no event while a session is idle, and a rate that changes at 04:00 would otherwise stay on screen until the next turn. The extension arms a single timer for the next instant the quoted rate changes and re-arms it after each fire, from the same clock reading as the status it is keeping fresh. It arms nothing when there is no status on screen, and the timer is unreferenced and cleared on session shutdown, so it neither holds pi open nor outlives its session.
45
103
  - **Rate by request start.** DeepSeek bills by arrival time, so the multiplier is chosen from the `message_start` timestamp rather than `message_end`. A request spanning a window boundary would otherwise land on the wrong side.
46
- - **Scaled once.** Retries and overflow recovery can re-deliver the same message object, so adjustments are guarded by identity.
104
+ - **Scaled once.** Retries and overflow recovery can re-deliver the same message object, so adjustments are guarded by identity; the replacement returned to pi is registered too, so a replayed replacement cannot be scaled twice.
47
105
  - **Only cost moves.** The four cost fields are scaled and `total` is recomputed from them, matching pi's own invariant. Token counts are untouched, so rate-from-cost derivations stay self-consistent — including pi's cache-miss accounting, which derives its paid and read rates from the same message and scales with it.
48
106
  - **Coverage.** Assistant messages carry the bulk of a session's spend. Compaction and branch-summary usage, plus token usage reported by tools, also feed the footer but cannot be adjusted after the fact, so totals stay an estimate rather than an invoice.
49
107
 
50
108
  ## Stack
51
109
 
52
- TypeScript, Bun. Pi extension API (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`). No build step — pi loads the extension directly.
110
+ TypeScript, Bun. Pi extension API (`@earendil-works/pi-coding-agent`). `@earendil-works/pi-ai` is a dev dependency only — the handlers use pi's own `Model` and usage shapes structurally, so nothing from it ships. No build step — pi loads the extension directly.
53
111
 
54
112
  ## Testing
55
113
 
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * pi-peak-hours — correct recorded cost for providers that bill by time of day.
3
3
  *
4
- * DeepSeek is the clearest example: its published prices are the off-peak
5
- * rates, and a 2x multiplier applies during peak windows (01:00-04:00 and
6
- * 06:00-10:00 UTC, Monday-Friday, as of September 2026). pi has no notion of
7
- * time-of-day pricing — `usage.cost` is computed once from the model's flat
8
- * rates when usage is finalized — so the footer understates such sessions by
9
- * up to 2x while a peak window is open.
4
+ * DeepSeek is the clearest example: a 2x multiplier applies during peak windows
5
+ * (01:00-04:00 and 06:00-10:00 UTC, Monday-Friday, as of September 2026) and the
6
+ * off-peak rate is half of it. pi has no notion of time-of-day pricing —
7
+ * `usage.cost` is computed once from the model's flat rates when usage is
8
+ * finalized — so a session is over- or understated by up to 2x, in whichever
9
+ * direction the recorded rate sits from the billed one.
10
10
  *
11
11
  * This extension rewrites `usage.cost` on the finalized assistant message in
12
12
  * `message_end`, the documented hook for replacing a message. The footer
@@ -14,12 +14,36 @@
14
14
  * corrected number appears in the footer, the per-model breakdown, and the
15
15
  * HTML export without further plumbing.
16
16
  *
17
+ * The footer indicator is scoped to the selected model and quotes the rate that
18
+ * applies to it right now, in dollars per million tokens: `· ▲ peak
19
+ * $0.30/$1.20/M` at peak, `· $0.15/$0.60/M` off it. The rate is the one pi
20
+ * recorded scaled by the schedule, so it always agrees with the cost this
21
+ * extension writes. A session on a provider or model no schedule covers never
22
+ * shows a rate, and switching models re-evaluates it.
23
+ *
24
+ * The indicator is a snapshot of the current hour, and pi fires no event while a
25
+ * session sits idle — so a timer armed at the next window boundary keeps it from
26
+ * quoting a rate that has already expired. See `arm`.
27
+ *
28
+ * Schedules are provider-scoped and there can be several, because the shape of
29
+ * the correction differs: DeepSeek's list price is its off-peak rate, so peak is
30
+ * a surcharge (×2), while Z.ai's coding plan and Alibaba's night discount are
31
+ * priced the other way round and land below the recorded rate. A multiplier
32
+ * above 1 reads as `peak`, below 1 as `off-peak`, and the footer names the
33
+ * direction so a discount is never reported as a peak.
34
+ *
17
35
  * Design constraints:
18
36
  *
19
37
  * - The rate is chosen from the request's START time, not its end. DeepSeek
20
38
  * bills by when a request arrives, so a request spanning a window boundary
21
39
  * would otherwise be billed on the wrong side of it. `message_start` gives
22
40
  * the start; `message_end` gives the end.
41
+ * - A schedule declares the multiplier INSIDE its windows and `outside` for
42
+ * every other hour. Which side pi's recorded rate sits on is not assumed:
43
+ * encode it in the config, and only the difference is applied. pi's own
44
+ * catalog disagrees with itself here — it lists DeepSeek direct at peak rates
45
+ * and the same model through OpenRouter at off-peak rates — so a schedule that
46
+ * guesses would double-charge one of them.
23
47
  * - A message is never scaled twice. Retries and overflow recovery can deliver
24
48
  * the same message object again, so adjustments are guarded by identity.
25
49
  * - Only the four cost fields are touched, and `total` is recomputed from
@@ -37,7 +61,6 @@
37
61
  */
38
62
 
39
63
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
40
- import type { AssistantMessage } from "@earendil-works/pi-ai";
41
64
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
42
65
  import { dirname, join } from "node:path";
43
66
 
@@ -48,6 +71,10 @@ export const STATUS_KEY = "peak-hours";
48
71
  * `/peak-hours status`, which names the schedule and the rate in force.
49
72
  */
50
73
  export const STATUS_PEAK = "▲ peak";
74
+ /** Shown when the rate in force is below the recorded one, e.g. an off-peak discount. */
75
+ export const STATUS_OFF_PEAK = "▼ off-peak";
76
+ /** Rates are quoted per million tokens, like every provider rate card. */
77
+ export const RATE_UNIT = "/M";
51
78
  export const COMMAND_PEAK_HOURS = "peak-hours";
52
79
  export const CONFIG_BASENAME = "pi-peak-hours.json";
53
80
  export const CONFIG_PATH = join(getAgentDir(), "extensions", CONFIG_BASENAME);
@@ -56,10 +83,17 @@ export const CONFIG_PATH = join(getAgentDir(), "extensions", CONFIG_BASENAME);
56
83
  export interface ClockWindow {
57
84
  start: number;
58
85
  end: number;
86
+ /** Overrides the schedule multiplier inside this window. */
87
+ multiplier?: number;
59
88
  }
60
89
 
90
+ /**
91
+ * The rate in force inside `windows` versus `outside` them. Either side may be
92
+ * the cheaper one: above 1 is a surcharge, below 1 a discount.
93
+ */
61
94
  export interface Schedule {
62
95
  multiplier: number;
96
+ outside: number;
63
97
  windows: ClockWindow[];
64
98
  /** 0 = Sunday, matching Date#getUTCDay. */
65
99
  days: number[];
@@ -67,20 +101,28 @@ export interface Schedule {
67
101
  models: string[];
68
102
  }
69
103
 
70
- export interface ConfigFile {
71
- active?: boolean;
104
+ /** Everything a single schedule accepts. */
105
+ export interface ScheduleConfig {
72
106
  multiplier?: number;
73
- windows?: [string, string][];
107
+ outside?: number;
108
+ windows?: (readonly [string, string] | { start: string; end: string; multiplier?: number })[];
74
109
  days?: (string | number)[];
75
110
  providers?: string[];
76
111
  models?: string[];
77
112
  }
78
113
 
114
+ export interface ConfigFile extends ScheduleConfig {
115
+ active?: boolean;
116
+ /** Provider-scoped schedules, each resolved on its own rather than over the DeepSeek default. */
117
+ schedules?: ScheduleConfig[];
118
+ }
119
+
79
120
  export const DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
80
121
 
81
122
  /** DeepSeek's published schedule: peak mornings on UTC weekdays, everything else off-peak. */
82
123
  export const DEFAULT_SCHEDULE: Schedule = {
83
124
  multiplier: 2,
125
+ outside: 1,
84
126
  windows: [
85
127
  { start: 60, end: 240 },
86
128
  { start: 360, end: 600 },
@@ -90,8 +132,51 @@ export const DEFAULT_SCHEDULE: Schedule = {
90
132
  models: ["*"],
91
133
  };
92
134
 
135
+ /**
136
+ * The same model resold by OpenRouter, which pi's catalog rates at the off-peak
137
+ * price while its own `deepseek` provider is rated at peak. Without this the
138
+ * route silently keeps pi's flat rate.
139
+ */
140
+ export const OPENROUTER_SCHEDULE: Schedule = {
141
+ multiplier: 2,
142
+ outside: 1,
143
+ windows: [
144
+ { start: 60, end: 240 },
145
+ { start: 360, end: 600 },
146
+ ],
147
+ days: [1, 2, 3, 4, 5],
148
+ providers: ["openrouter"],
149
+ models: ["deepseek/*"],
150
+ };
151
+
152
+ /** What a config that says nothing about schedules resolves to. */
153
+ export const DEFAULT_SCHEDULES: Schedule[] = [DEFAULT_SCHEDULE, OPENROUTER_SCHEDULE];
154
+
155
+ /**
156
+ * Base for schedules listed explicitly in `schedules`: no windows, no
157
+ * correction, every provider and model. An entry supplies its own shape rather
158
+ * than inheriting DeepSeek's, which would be a surprising default for a
159
+ * different service.
160
+ */
161
+ export const NEUTRAL_SCHEDULE: Schedule = {
162
+ multiplier: 1,
163
+ outside: 1,
164
+ windows: [],
165
+ days: [0, 1, 2, 3, 4, 5, 6],
166
+ providers: ["*"],
167
+ models: ["*"],
168
+ };
169
+
93
170
  export const DEFAULT_ACTIVE = true;
94
171
 
172
+ /**
173
+ * Only an explicit boolean is a toggle. A malformed `active` falls back like
174
+ * every other field, so a typo disables nothing silently.
175
+ */
176
+ export function parseActive(value: unknown): boolean {
177
+ return typeof value === "boolean" ? value : DEFAULT_ACTIVE;
178
+ }
179
+
95
180
  /** "HH:MM" in UTC to minutes from midnight, or undefined when unparseable. */
96
181
  export function parseClock(value: unknown): number | undefined {
97
182
  if (typeof value !== "string") return undefined;
@@ -116,69 +201,154 @@ function parseWindows(value: unknown): ClockWindow[] | undefined {
116
201
  if (!Array.isArray(value)) return undefined;
117
202
  const windows: ClockWindow[] = [];
118
203
  for (const entry of value) {
119
- if (!Array.isArray(entry) || entry.length !== 2) return undefined;
120
- const start = parseClock(entry[0]);
121
- const end = parseClock(entry[1]);
122
- if (start === undefined || end === undefined || start === end) return undefined;
123
- windows.push({ start, end });
204
+ let start: unknown;
205
+ let end: unknown;
206
+ let multiplier: unknown;
207
+ if (Array.isArray(entry) && entry.length === 2) {
208
+ [start, end] = entry;
209
+ } else if (isRecord(entry)) {
210
+ start = entry.start;
211
+ end = entry.end;
212
+ multiplier = entry.multiplier;
213
+ } else {
214
+ return undefined;
215
+ }
216
+ const startMinutes = parseClock(start);
217
+ const endMinutes = parseClock(end);
218
+ if (startMinutes === undefined || endMinutes === undefined || startMinutes === endMinutes) return undefined;
219
+ const window: ClockWindow = { start: startMinutes, end: endMinutes };
220
+ if (multiplier !== undefined) {
221
+ const parsed = parseMultiplier(multiplier);
222
+ if (parsed === undefined) return undefined;
223
+ window.multiplier = parsed;
224
+ }
225
+ windows.push(window);
124
226
  }
125
227
  return windows;
126
228
  }
127
229
 
230
+ /** A positive finite multiplier: above 1 for a surcharge, below 1 for a discount. */
231
+ export function parseMultiplier(value: unknown): number | undefined {
232
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
233
+ return value;
234
+ }
235
+
128
236
  function parseStringList(value: unknown): string[] | undefined {
129
237
  if (!Array.isArray(value)) return undefined;
130
238
  const items = value.filter((item): item is string => typeof item === "string" && item.trim() !== "");
131
239
  return items.length === value.length ? items.map((item) => item.trim()) : undefined;
132
240
  }
133
241
 
134
- /** Overlay a parsed config file onto the defaults. Invalid fields fall back to the default. */
135
- export function resolveSchedule(config: ConfigFile): Schedule {
136
- const schedule: Schedule = {
137
- multiplier: DEFAULT_SCHEDULE.multiplier,
138
- windows: DEFAULT_SCHEDULE.windows,
139
- days: DEFAULT_SCHEDULE.days,
140
- providers: DEFAULT_SCHEDULE.providers,
141
- models: DEFAULT_SCHEDULE.models,
242
+ /** Overlay a parsed config onto `base`. Invalid fields keep the base value. */
243
+ export function resolveSchedule(config: ScheduleConfig, base: Schedule = DEFAULT_SCHEDULE): Schedule {
244
+ return {
245
+ multiplier: parseMultiplier(config.multiplier) ?? base.multiplier,
246
+ outside: parseMultiplier(config.outside) ?? base.outside,
247
+ // Copied, so a resolved schedule never aliases an exported default.
248
+ windows: parseWindows(config.windows) ?? base.windows.map((window) => ({ ...window })),
249
+ days: parseDays(config.days) ?? [...base.days],
250
+ providers: parseStringList(config.providers) ?? [...base.providers],
251
+ models: parseStringList(config.models) ?? [...base.models],
142
252
  };
253
+ }
143
254
 
144
- if (typeof config.multiplier === "number" && Number.isFinite(config.multiplier) && config.multiplier >= 1) {
145
- schedule.multiplier = config.multiplier;
146
- }
147
- const windows = parseWindows(config.windows);
148
- if (windows) schedule.windows = windows;
149
- if (Array.isArray(config.days)) {
150
- const days = config.days.map(parseDay);
151
- if (days.every((day): day is number => day !== undefined)) schedule.days = days;
152
- }
153
- const providers = parseStringList(config.providers);
154
- if (providers) schedule.providers = providers;
155
- const models = parseStringList(config.models);
156
- if (models) schedule.models = models;
255
+ /** Day names or numbers, or undefined when the list is absent or partly invalid. */
256
+ function parseDays(value: unknown): number[] | undefined {
257
+ if (!Array.isArray(value)) return undefined;
258
+ const days = value.map(parseDay);
259
+ return days.every((day): day is number => day !== undefined) ? days : undefined;
260
+ }
157
261
 
158
- return schedule;
262
+ /**
263
+ * Every schedule the config describes. An explicit `schedules` list replaces the
264
+ * built-in schedules; a flat config overlays the DeepSeek one and drops the
265
+ * built-in OpenRouter route once it names its own providers.
266
+ */
267
+ export function resolveSchedules(config: ConfigFile): Schedule[] {
268
+ if (Array.isArray(config.schedules)) {
269
+ const entries = config.schedules.filter(isRecord).map((entry) => resolveSchedule(entry, NEUTRAL_SCHEDULE));
270
+ if (entries.length > 0) return entries;
271
+ }
272
+ const schedule = resolveSchedule(config);
273
+ return config.providers === undefined ? [schedule, OPENROUTER_SCHEDULE] : [schedule];
159
274
  }
160
275
 
161
- /** Whether a request that started at `startedAt` falls inside a peak window. */
162
- export function isPeakAt(startedAt: Date, schedule: Schedule): boolean {
276
+ /** Whether a window covers the given instant, including windows that wrap midnight. */
277
+ function windowCovers(window: ClockWindow, startedAt: Date, days: number[]): boolean {
163
278
  const minutes = startedAt.getUTCHours() * 60 + startedAt.getUTCMinutes();
164
279
  const day = startedAt.getUTCDay();
165
280
 
281
+ if (window.end > window.start) return days.includes(day) && minutes >= window.start && minutes < window.end;
282
+ // A window that runs past midnight started on the previous UTC day.
283
+ const previous = (day + 6) % 7;
284
+ return (days.includes(day) && minutes >= window.start) || (days.includes(previous) && minutes < window.end);
285
+ }
286
+
287
+ /** The multiplier in force at `startedAt`: a window's own, the schedule's, or `outside`. */
288
+ export function multiplierAt(startedAt: Date, schedule: Schedule): number {
166
289
  for (const window of schedule.windows) {
167
- if (window.end > window.start) {
168
- if (schedule.days.includes(day) && minutes >= window.start && minutes < window.end) return true;
169
- continue;
290
+ if (windowCovers(window, startedAt, schedule.days)) return window.multiplier ?? schedule.multiplier;
291
+ }
292
+ return schedule.outside;
293
+ }
294
+
295
+ /** Whether the rate in force costs more than the one pi recorded. */
296
+ export function isPeakAt(startedAt: Date, schedule: Schedule): boolean {
297
+ return multiplierAt(startedAt, schedule) > 1;
298
+ }
299
+
300
+ const MS_PER_MINUTE = 60_000;
301
+
302
+ /**
303
+ * Every instant the multiplier could move: the edges of each window, over a week
304
+ * of days plus a day of slack so a window that wraps midnight always contributes
305
+ * its closing edge and every weekday appears at least once. Sorted, and only
306
+ * ahead of `now`.
307
+ */
308
+ function candidateEdges(now: Date, schedule: Schedule): number[] {
309
+ const nowMs = now.getTime();
310
+ const edges: number[] = [];
311
+
312
+ for (let offset = 0; offset <= 8; offset++) {
313
+ const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offset);
314
+ const day = new Date(midnight).getUTCDay();
315
+ const previousDay = (day + 6) % 7;
316
+
317
+ for (const window of schedule.windows) {
318
+ // A window opens on its own day and closes on that day, or on the next one
319
+ // when it runs past midnight.
320
+ const closesOn = window.end > window.start ? day : previousDay;
321
+ if (schedule.days.includes(day)) edges.push(midnight + window.start * MS_PER_MINUTE);
322
+ if (schedule.days.includes(closesOn)) edges.push(midnight + window.end * MS_PER_MINUTE);
170
323
  }
171
- // A window that runs past midnight started on the previous UTC day.
172
- const previous = (day + 6) % 7;
173
- if (schedule.days.includes(day) && minutes >= window.start) return true;
174
- if (schedule.days.includes(previous) && minutes < window.end) return true;
175
324
  }
176
325
 
177
- return false;
326
+ return edges.filter((edge) => edge > nowMs).sort((left, right) => left - right);
178
327
  }
179
328
 
180
- export function multiplierAt(startedAt: Date, schedule: Schedule): number {
181
- return isPeakAt(startedAt, schedule) ? schedule.multiplier : 1;
329
+ /**
330
+ * The earliest instant after `now` at which the schedule's multiplier changes,
331
+ * or undefined when it never does.
332
+ *
333
+ * A window edge is not enough: windows can overlap, so the edge that opens one
334
+ * may land inside another and leave the rate where it was. Testing the
335
+ * multiplier on both sides of each edge keeps the footer's trigger and the
336
+ * footer's value on the same function, and leaves a schedule whose multiplier
337
+ * never moves — a `multiplier` equal to its `outside`, say — with no timer at
338
+ * all.
339
+ */
340
+ export function nextTransitionAt(now: Date, schedule: Schedule): Date | undefined {
341
+ if (schedule.windows.length === 0) return undefined;
342
+
343
+ // Edges are ordered, so the multiplier is constant between consecutive ones:
344
+ // the first edge that moves it is the first change after `now`.
345
+ for (const edge of candidateEdges(now, schedule)) {
346
+ if (multiplierAt(new Date(edge), schedule) !== multiplierAt(new Date(edge - 1), schedule)) {
347
+ return new Date(edge);
348
+ }
349
+ }
350
+
351
+ return undefined;
182
352
  }
183
353
 
184
354
  /** Glob match supporting `*` only, which is all a provider/model filter needs. */
@@ -195,11 +365,91 @@ export function matchesSchedule(schedule: Schedule, provider: string, model: str
195
365
  );
196
366
  }
197
367
 
198
- export interface CostBreakdown {
368
+ /** The provider/model pair a request belongs to, as pi reports it. */
369
+ export interface ModelKey {
370
+ provider: string;
371
+ id: string;
372
+ }
373
+
374
+ /** Per-million-token rates, in the shape pi records them. */
375
+ export interface RateCard {
199
376
  input: number;
200
377
  output: number;
201
378
  cacheRead: number;
202
379
  cacheWrite: number;
380
+ }
381
+
382
+ /** A selected model with the rates pi would charge it at, which the schedule scales. */
383
+ export interface ModelRate extends ModelKey {
384
+ cost: RateCard;
385
+ }
386
+
387
+ /** The first schedule covering a provider/model pair. */
388
+ export function scheduleFor(schedules: Schedule[], provider: string, model: string): Schedule | undefined {
389
+ return schedules.find((schedule) => matchesSchedule(schedule, provider, model));
390
+ }
391
+
392
+ /** Whether the rate in force costs more (`peak`), less (`off-peak`), or the same (`none`). */
393
+ export type RateState = "peak" | "off-peak" | "none";
394
+
395
+ export function rateStateAt(when: Date, schedule: Schedule): RateState {
396
+ const multiplier = multiplierAt(when, schedule);
397
+ if (multiplier > 1) return "peak";
398
+ if (multiplier < 1) return "off-peak";
399
+ return "none";
400
+ }
401
+
402
+ /**
403
+ * The rate in force for a model, e.g. `$0.30/$1.20/M`. Empty when the rate in
404
+ * force is free, which keeps free models out of the footer instead of printing a
405
+ * zero rate.
406
+ */
407
+ export function describeRate(cost: RateCard, multiplier: number): string {
408
+ const input = cost.input * multiplier;
409
+ const output = cost.output * multiplier;
410
+ if (input <= 0 && output <= 0) return "";
411
+ return `$${formatRate(input)}/$${formatRate(output)}${RATE_UNIT}`;
412
+ }
413
+
414
+ /** Up to three decimals without trailing zeros, and finer for a smaller rate. */
415
+ function formatRate(value: number): string {
416
+ for (const decimals of [3, 6]) {
417
+ const text = value.toFixed(decimals).replace(/\.?0+$/, "");
418
+ if (text !== "" && text !== "0") return text;
419
+ }
420
+ return "0";
421
+ }
422
+
423
+ /**
424
+ * The footer indicator for a selected model: the rate in force, prefixed with
425
+ * `peak` or `off-peak` when it differs from the rate pi recorded. Undefined when
426
+ * correction is disabled, no model is selected, no schedule covers it, or it has
427
+ * no recorded cost to report.
428
+ */
429
+ export function statusIndicator(
430
+ schedules: Schedule[],
431
+ active: boolean,
432
+ model: ModelRate | undefined,
433
+ now: Date = new Date(),
434
+ ): string | undefined {
435
+ if (!active || !model) return undefined;
436
+ const schedule = scheduleFor(schedules, model.provider, model.id);
437
+ if (!schedule) return undefined;
438
+
439
+ const rate = describeRate(model.cost, multiplierAt(now, schedule));
440
+ if (!rate) return undefined;
441
+
442
+ switch (rateStateAt(now, schedule)) {
443
+ case "peak":
444
+ return `· ${STATUS_PEAK} ${rate}`;
445
+ case "off-peak":
446
+ return `· ${STATUS_OFF_PEAK} ${rate}`;
447
+ default:
448
+ return `· ${rate}`;
449
+ }
450
+ }
451
+
452
+ export interface CostBreakdown extends RateCard {
203
453
  total: number;
204
454
  }
205
455
 
@@ -250,50 +500,132 @@ export function writeActive(path: string, active: boolean): void {
250
500
  }
251
501
  }
252
502
 
503
+ function formatClock(minutes: number): string {
504
+ return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
505
+ }
506
+
253
507
  function describeSchedule(schedule: Schedule): string {
254
508
  const windows = schedule.windows
255
509
  .map((window) => {
256
- const fmt = (minutes: number) =>
257
- `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
258
- return `${fmt(window.start)}-${fmt(window.end)}`;
510
+ const multiplier = window.multiplier !== undefined ? ` ×${window.multiplier}` : "";
511
+ return `${formatClock(window.start)}-${formatClock(window.end)}${multiplier}`;
259
512
  })
260
513
  .join(", ");
261
514
  const days = schedule.days.map((day) => DAY_NAMES[day]).join(",");
262
- return `${windows} UTC (${days}) ×${schedule.multiplier} for ${schedule.providers.join(",")}`;
515
+ const inside = windows ? `×${schedule.multiplier} in ${windows}` : `×${schedule.multiplier} always`;
516
+ return `${inside}, ×${schedule.outside} outside (${days} UTC) for ${schedule.providers.join(",")}`;
517
+ }
518
+
519
+ function describeAll(schedules: Schedule[]): string {
520
+ const configured = schedules.filter(
521
+ (schedule) => schedule.windows.length > 0 || schedule.multiplier !== 1 || schedule.outside !== 1,
522
+ );
523
+ if (configured.length === 0) return "no schedule configured";
524
+ return configured.map(describeSchedule).join(" | ");
525
+ }
526
+
527
+ export interface PeakHoursOptions {
528
+ /** Config file to read; defaults to the one in pi's agent directory. */
529
+ configPath?: string;
530
+ }
531
+
532
+ /**
533
+ * Messages already scaled. Retries re-deliver the same object, and pi applies a
534
+ * returned replacement by copying it onto that same object in place, so object
535
+ * identity is what identifies a message here.
536
+ *
537
+ * Shared across extension instances through a global: with the npm package and a
538
+ * local checkout both loaded, each would otherwise keep its own set and scale
539
+ * every message twice.
540
+ */
541
+ const ADJUSTED_KEY = Symbol.for("pi-peak-hours.adjusted");
542
+ function adjustedMessages(): WeakSet<object> {
543
+ const registry = globalThis as unknown as Record<symbol, WeakSet<object> | undefined>;
544
+ registry[ADJUSTED_KEY] ??= new WeakSet<object>();
545
+ return registry[ADJUSTED_KEY];
263
546
  }
264
547
 
265
- export default function peakHours(pi: ExtensionAPI): void {
266
- let config = readConfig(CONFIG_PATH) ?? {};
267
- let schedule = resolveSchedule(config);
268
- let active = config.active ?? DEFAULT_ACTIVE;
269
- /** Messages already scaled; retries can re-deliver the same object. */
270
- const adjusted = new WeakSet<AssistantMessage>();
548
+ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions = {}): void {
549
+ const configPath = options.configPath ?? CONFIG_PATH;
550
+ let config = readConfig(configPath) ?? {};
551
+ let schedules = resolveSchedules(config);
552
+ let active = parseActive(config.active);
553
+ const adjusted = adjustedMessages();
271
554
  let pendingStart: number | undefined;
555
+ let timer: ReturnType<typeof setTimeout> | undefined;
272
556
 
273
557
  function refresh(): void {
274
- config = readConfig(CONFIG_PATH) ?? {};
275
- schedule = resolveSchedule(config);
276
- active = config.active ?? DEFAULT_ACTIVE;
558
+ config = readConfig(configPath) ?? {};
559
+ schedules = resolveSchedules(config);
560
+ active = parseActive(config.active);
277
561
  }
278
562
 
279
- function updateStatus(ctx: ExtensionContext): void {
280
- if (!active) {
281
- ctx.ui.setStatus(STATUS_KEY, undefined);
282
- return;
283
- }
284
- const multiplier = multiplierAt(new Date(), schedule);
285
- ctx.ui.setStatus(STATUS_KEY, multiplier > 1 ? `· ${STATUS_PEAK}` : undefined);
563
+ function disarm(): void {
564
+ if (timer === undefined) return;
565
+ clearTimeout(timer);
566
+ timer = undefined;
567
+ }
568
+
569
+ /**
570
+ * Keep the footer in step with the window it quotes. pi fires no event while a
571
+ * session sits idle, so a session idle across a boundary would keep describing
572
+ * a rate that has expired. Armed only for a `status` that is on screen, and
573
+ * from the instant that status was computed: `now` is the one clock reading
574
+ * shared by the rate and the boundary that ends it, so the last millisecond of
575
+ * a window cannot arm the timer past its own edge.
576
+ */
577
+ function arm(ctx: ExtensionContext, model: ModelRate | undefined, status: string | undefined, now: Date): void {
578
+ disarm();
579
+ // Nothing to keep fresh, or no footer to keep fresh in.
580
+ if (status === undefined || !ctx.hasUI || !model) return;
581
+ const schedule = scheduleFor(schedules, model.provider, model.id);
582
+ if (!schedule) return;
583
+
584
+ const next = nextTransitionAt(now, schedule);
585
+ if (!next) return;
586
+ // Floored, so a boundary crossed between reading the clock and arming the
587
+ // timer cannot spin it; unreferenced, so a pending window never keeps pi
588
+ // alive after its work is done.
589
+ timer = setTimeout(() => {
590
+ timer = undefined;
591
+ updateStatus(ctx);
592
+ }, Math.max(1000, next.getTime() - now.getTime()));
593
+ timer.unref?.();
286
594
  }
287
595
 
288
- function notifyStatus(ctx: ExtensionContext): void {
596
+ function updateStatus(ctx: ExtensionContext, model: ModelRate | undefined = ctx.model): void {
597
+ const now = new Date();
598
+ const status = statusIndicator(schedules, active, model, now);
599
+ ctx.ui.setStatus(STATUS_KEY, status);
600
+ arm(ctx, model, status, now);
601
+ }
602
+
603
+ function notifyStatus(ctx: ExtensionContext, model: ModelRate | undefined = ctx.model): void {
289
604
  if (!active) {
290
605
  ctx.ui.notify(`Peak hours: off.`, "info");
291
606
  return;
292
607
  }
293
- const now = new Date();
294
- const multiplier = multiplierAt(now, schedule);
295
- const state = multiplier > 1 ? `peak ×${multiplier} right now` : "off-peak right now";
296
- ctx.ui.notify(`Peak hours: ${state}. ${describeSchedule(schedule)}`, "info");
608
+ // Schedules are provider-scoped, so name why nothing applies to a model none
609
+ // of them cover instead of reporting a rate that will never be charged.
610
+ if (!model) {
611
+ ctx.ui.notify(`Peak hours: no model selected. ${describeAll(schedules)}`, "info");
612
+ return;
613
+ }
614
+ const schedule = scheduleFor(schedules, model.provider, model.id);
615
+ if (!schedule) {
616
+ ctx.ui.notify(`Peak hours: not applied to ${model.provider}/${model.id}. ${describeAll(schedules)}`, "info");
617
+ return;
618
+ }
619
+ const multiplier = multiplierAt(new Date(), schedule);
620
+ const rate = describeRate(model.cost, multiplier);
621
+ const state =
622
+ multiplier > 1
623
+ ? `peak ×${multiplier} right now`
624
+ : multiplier < 1
625
+ ? `off-peak ×${multiplier} right now`
626
+ : "no correction right now";
627
+ const detail = rate ? `${state} (${rate})` : state;
628
+ ctx.ui.notify(`Peak hours: ${detail}. ${describeSchedule(schedule)}`, "info");
297
629
  }
298
630
 
299
631
  pi.on("session_start", async (_event, ctx) => {
@@ -303,10 +635,17 @@ export default function peakHours(pi: ExtensionAPI): void {
303
635
 
304
636
  pi.on("session_shutdown", async (_event, ctx) => {
305
637
  pendingStart = undefined;
638
+ disarm();
306
639
  ctx.ui.setStatus(STATUS_KEY, undefined);
307
640
  });
308
641
 
309
- pi.on("model_select", async (_event, ctx) => {
642
+ pi.on("model_select", async (event, ctx) => {
643
+ updateStatus(ctx, event.model);
644
+ });
645
+
646
+ // The window boundary can pass between messages, and the footer quotes a price
647
+ // that changes with it, so re-evaluate whenever a turn starts.
648
+ pi.on("turn_start", async (_event, ctx) => {
310
649
  updateStatus(ctx);
311
650
  });
312
651
 
@@ -325,7 +664,12 @@ export default function peakHours(pi: ExtensionAPI): void {
325
664
  const startedAt = pendingStart ?? message.timestamp ?? Date.now();
326
665
  pendingStart = undefined;
327
666
 
328
- if (!active || !matchesSchedule(schedule, message.provider, message.model)) {
667
+ if (!active) {
668
+ updateStatus(ctx);
669
+ return;
670
+ }
671
+ const schedule = scheduleFor(schedules, message.provider, message.model);
672
+ if (!schedule) {
329
673
  updateStatus(ctx);
330
674
  return;
331
675
  }
@@ -338,15 +682,18 @@ export default function peakHours(pi: ExtensionAPI): void {
338
682
 
339
683
  adjusted.add(message);
340
684
  updateStatus(ctx);
341
- return {
342
- message: {
343
- ...message,
344
- usage: {
345
- ...message.usage,
346
- cost: applyMultiplier(message.usage.cost, multiplier),
347
- },
685
+ const replacement = {
686
+ ...message,
687
+ usage: {
688
+ ...message.usage,
689
+ cost: applyMultiplier(message.usage.cost, multiplier),
348
690
  },
349
691
  };
692
+ // pi copies this onto the original, so the original's identity already
693
+ // carries the guard; registering the replacement covers a caller that
694
+ // replays the returned value instead.
695
+ adjusted.add(replacement);
696
+ return { message: replacement };
350
697
  });
351
698
 
352
699
  pi.registerCommand(COMMAND_PEAK_HOURS, {
@@ -368,7 +715,7 @@ export default function peakHours(pi: ExtensionAPI): void {
368
715
  ctx.ui.notify("Usage: /peak-hours [on|off|status]", "error");
369
716
  return;
370
717
  }
371
- writeActive(CONFIG_PATH, arg === "on");
718
+ writeActive(configPath, arg === "on");
372
719
  refresh();
373
720
  updateStatus(ctx);
374
721
  notifyStatus(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/pi-peak-hours",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "description": "Correct pi's recorded cost for providers that bill by time of day, with a peak multiplier in the footer status.",
6
6
  "keywords": [
@@ -41,7 +41,6 @@
41
41
  "typescript": "^6.0.3"
42
42
  },
43
43
  "peerDependencies": {
44
- "@earendil-works/pi-ai": "*",
45
44
  "@earendil-works/pi-coding-agent": "*"
46
45
  },
47
46
  "files": [