@nijaru/pi-peak-hours 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nick Russo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # pi-peak-hours
2
+
3
+ Correct pi's recorded cost for providers that bill by time of day, and show the rate in force in the footer.
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.
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.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pi install npm:@nijaru/pi-peak-hours
13
+ ```
14
+
15
+ Or copy `extensions/index.ts` to `~/.pi/agent/extensions/` for a single-machine install.
16
+
17
+ ## Usage
18
+
19
+ ```
20
+ /peak-hours # show the effective schedule and the current rate
21
+ /peak-hours on|off # toggle correction; persists to pi-peak-hours.json
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ Optional. `~/.pi/agent/extensions/pi-peak-hours.json` overlays the built-in DeepSeek schedule; anything you leave out keeps its default.
27
+
28
+ ```json
29
+ {
30
+ "active": true,
31
+ "multiplier": 2,
32
+ "windows": [["01:00", "04:00"], ["06:00", "10:00"]],
33
+ "days": ["mon", "tue", "wed", "thu", "fri"],
34
+ "providers": ["deepseek"],
35
+ "models": ["*"]
36
+ }
37
+ ```
38
+
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.
40
+
41
+ A malformed field falls back to its default rather than disabling the extension; a malformed file warns once and resolves to defaults.
42
+
43
+ ## Design notes
44
+
45
+ - **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.
47
+ - **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
+ - **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
+
50
+ ## Stack
51
+
52
+ TypeScript, Bun. Pi extension API (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`). No build step — pi loads the extension directly.
53
+
54
+ ## Testing
55
+
56
+ ```bash
57
+ bun run check
58
+ ```
@@ -0,0 +1,377 @@
1
+ /**
2
+ * pi-peak-hours — correct recorded cost for providers that bill by time of day.
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.
10
+ *
11
+ * This extension rewrites `usage.cost` on the finalized assistant message in
12
+ * `message_end`, the documented hook for replacing a message. The footer
13
+ * recomputes session totals from persisted entries on every render, so the
14
+ * corrected number appears in the footer, the per-model breakdown, and the
15
+ * HTML export without further plumbing.
16
+ *
17
+ * Design constraints:
18
+ *
19
+ * - The rate is chosen from the request's START time, not its end. DeepSeek
20
+ * bills by when a request arrives, so a request spanning a window boundary
21
+ * would otherwise be billed on the wrong side of it. `message_start` gives
22
+ * the start; `message_end` gives the end.
23
+ * - A message is never scaled twice. Retries and overflow recovery can deliver
24
+ * the same message object again, so adjustments are guarded by identity.
25
+ * - Only the four cost fields are touched, and `total` is recomputed from
26
+ * them, preserving pi's own invariant. Token counts are real and never
27
+ * modified, so any rate derived from cost/tokens stays self-consistent
28
+ * (cache-miss accounting derives paid and read rates from the same message
29
+ * and scales with it).
30
+ * - Unknown or malformed configuration leaves the extension inert after a
31
+ * single warning; it never throws inside a message handler.
32
+ *
33
+ * Coverage limit: assistant messages carry the bulk of a session's spend.
34
+ * Compaction and branch-summary usage and token usage reported by tools also
35
+ * feed footer totals, but pi exposes no hook that can adjust them afterwards,
36
+ * so those stay at list rates and the footer stays an estimate, not an invoice.
37
+ */
38
+
39
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
40
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
41
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
42
+ import { dirname, join } from "node:path";
43
+
44
+ export const STATUS_KEY = "peak-hours";
45
+ /**
46
+ * Status text is deliberately generic: the footer already carries the session
47
+ * cost, and the multiplier differs per service. Detail lives in
48
+ * `/peak-hours status`, which names the schedule and the rate in force.
49
+ */
50
+ export const STATUS_PEAK = "▲ peak";
51
+ export const COMMAND_PEAK_HOURS = "peak-hours";
52
+ export const CONFIG_BASENAME = "pi-peak-hours.json";
53
+ export const CONFIG_PATH = join(getAgentDir(), "extensions", CONFIG_BASENAME);
54
+
55
+ /** Minutes from UTC midnight; `end` is exclusive. */
56
+ export interface ClockWindow {
57
+ start: number;
58
+ end: number;
59
+ }
60
+
61
+ export interface Schedule {
62
+ multiplier: number;
63
+ windows: ClockWindow[];
64
+ /** 0 = Sunday, matching Date#getUTCDay. */
65
+ days: number[];
66
+ providers: string[];
67
+ models: string[];
68
+ }
69
+
70
+ export interface ConfigFile {
71
+ active?: boolean;
72
+ multiplier?: number;
73
+ windows?: [string, string][];
74
+ days?: (string | number)[];
75
+ providers?: string[];
76
+ models?: string[];
77
+ }
78
+
79
+ export const DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
80
+
81
+ /** DeepSeek's published schedule: peak mornings on UTC weekdays, everything else off-peak. */
82
+ export const DEFAULT_SCHEDULE: Schedule = {
83
+ multiplier: 2,
84
+ windows: [
85
+ { start: 60, end: 240 },
86
+ { start: 360, end: 600 },
87
+ ],
88
+ days: [1, 2, 3, 4, 5],
89
+ providers: ["deepseek"],
90
+ models: ["*"],
91
+ };
92
+
93
+ export const DEFAULT_ACTIVE = true;
94
+
95
+ /** "HH:MM" in UTC to minutes from midnight, or undefined when unparseable. */
96
+ export function parseClock(value: unknown): number | undefined {
97
+ if (typeof value !== "string") return undefined;
98
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
99
+ if (!match) return undefined;
100
+ const hours = Number(match[1]);
101
+ const minutes = Number(match[2]);
102
+ if (hours > 23 || minutes > 59) return undefined;
103
+ return hours * 60 + minutes;
104
+ }
105
+
106
+ export function parseDay(value: unknown): number | undefined {
107
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 6) return value;
108
+ if (typeof value === "string") {
109
+ const index = DAY_NAMES.indexOf(value.trim().toLowerCase().slice(0, 3) as (typeof DAY_NAMES)[number]);
110
+ if (index >= 0) return index;
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ function parseWindows(value: unknown): ClockWindow[] | undefined {
116
+ if (!Array.isArray(value)) return undefined;
117
+ const windows: ClockWindow[] = [];
118
+ 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 });
124
+ }
125
+ return windows;
126
+ }
127
+
128
+ function parseStringList(value: unknown): string[] | undefined {
129
+ if (!Array.isArray(value)) return undefined;
130
+ const items = value.filter((item): item is string => typeof item === "string" && item.trim() !== "");
131
+ return items.length === value.length ? items.map((item) => item.trim()) : undefined;
132
+ }
133
+
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,
142
+ };
143
+
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;
157
+
158
+ return schedule;
159
+ }
160
+
161
+ /** Whether a request that started at `startedAt` falls inside a peak window. */
162
+ export function isPeakAt(startedAt: Date, schedule: Schedule): boolean {
163
+ const minutes = startedAt.getUTCHours() * 60 + startedAt.getUTCMinutes();
164
+ const day = startedAt.getUTCDay();
165
+
166
+ 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;
170
+ }
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
+ }
176
+
177
+ return false;
178
+ }
179
+
180
+ export function multiplierAt(startedAt: Date, schedule: Schedule): number {
181
+ return isPeakAt(startedAt, schedule) ? schedule.multiplier : 1;
182
+ }
183
+
184
+ /** Glob match supporting `*` only, which is all a provider/model filter needs. */
185
+ export function matchesPattern(pattern: string, value: string): boolean {
186
+ if (pattern === "*") return true;
187
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
188
+ return new RegExp(`^${escaped}$`).test(value);
189
+ }
190
+
191
+ export function matchesSchedule(schedule: Schedule, provider: string, model: string): boolean {
192
+ return (
193
+ schedule.providers.some((pattern) => matchesPattern(pattern, provider)) &&
194
+ schedule.models.some((pattern) => matchesPattern(pattern, model))
195
+ );
196
+ }
197
+
198
+ export interface CostBreakdown {
199
+ input: number;
200
+ output: number;
201
+ cacheRead: number;
202
+ cacheWrite: number;
203
+ total: number;
204
+ }
205
+
206
+ /** Scale every rate field and recompute `total` from them, as pi itself does. */
207
+ export function applyMultiplier(cost: CostBreakdown, multiplier: number): CostBreakdown {
208
+ const scaled: CostBreakdown = {
209
+ input: cost.input * multiplier,
210
+ output: cost.output * multiplier,
211
+ cacheRead: cost.cacheRead * multiplier,
212
+ cacheWrite: cost.cacheWrite * multiplier,
213
+ total: 0,
214
+ };
215
+ scaled.total = scaled.input + scaled.output + scaled.cacheRead + scaled.cacheWrite;
216
+ return scaled;
217
+ }
218
+
219
+ function isRecord(value: unknown): value is Record<string, unknown> {
220
+ return typeof value === "object" && value !== null && !Array.isArray(value);
221
+ }
222
+
223
+ /** Read the config file; undefined means missing or unreadable, which stays inert. */
224
+ export function readConfig(path: string): ConfigFile | undefined {
225
+ if (!existsSync(path)) return {};
226
+ try {
227
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
228
+ if (!isRecord(parsed)) {
229
+ console.warn(`[${STATUS_KEY}] ${path} is not a JSON object; using defaults`);
230
+ return {};
231
+ }
232
+ return parsed as ConfigFile;
233
+ } catch (error) {
234
+ const message = error instanceof Error ? error.message : String(error);
235
+ console.warn(`[${STATUS_KEY}] Failed to read ${path}: ${message}; using defaults`);
236
+ return {};
237
+ }
238
+ }
239
+
240
+ /** Persist `active` while preserving keys this version does not model. */
241
+ export function writeActive(path: string, active: boolean): void {
242
+ try {
243
+ const existing = existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as unknown) : {};
244
+ const merged = isRecord(existing) ? { ...existing, active } : { active };
245
+ mkdirSync(dirname(path), { recursive: true });
246
+ writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
247
+ } catch (error) {
248
+ const message = error instanceof Error ? error.message : String(error);
249
+ console.warn(`[${STATUS_KEY}] Failed to write ${path}: ${message}`);
250
+ }
251
+ }
252
+
253
+ function describeSchedule(schedule: Schedule): string {
254
+ const windows = schedule.windows
255
+ .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)}`;
259
+ })
260
+ .join(", ");
261
+ const days = schedule.days.map((day) => DAY_NAMES[day]).join(",");
262
+ return `${windows} UTC (${days}) ×${schedule.multiplier} for ${schedule.providers.join(",")}`;
263
+ }
264
+
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>();
271
+ let pendingStart: number | undefined;
272
+
273
+ function refresh(): void {
274
+ config = readConfig(CONFIG_PATH) ?? {};
275
+ schedule = resolveSchedule(config);
276
+ active = config.active ?? DEFAULT_ACTIVE;
277
+ }
278
+
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);
286
+ }
287
+
288
+ function notifyStatus(ctx: ExtensionContext): void {
289
+ if (!active) {
290
+ ctx.ui.notify(`Peak hours: off.`, "info");
291
+ return;
292
+ }
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");
297
+ }
298
+
299
+ pi.on("session_start", async (_event, ctx) => {
300
+ refresh();
301
+ updateStatus(ctx);
302
+ });
303
+
304
+ pi.on("session_shutdown", async (_event, ctx) => {
305
+ pendingStart = undefined;
306
+ ctx.ui.setStatus(STATUS_KEY, undefined);
307
+ });
308
+
309
+ pi.on("model_select", async (_event, ctx) => {
310
+ updateStatus(ctx);
311
+ });
312
+
313
+ pi.on("message_start", async (event) => {
314
+ if (event.message.role === "assistant") pendingStart = Date.now();
315
+ });
316
+
317
+ pi.on("message_end", async (event, ctx) => {
318
+ const message = event.message;
319
+ if (message.role !== "assistant") return;
320
+ if (adjusted.has(message)) return;
321
+ if (!message.usage?.cost) return;
322
+
323
+ // The request's start decides its rate; fall back to the message clock when
324
+ // no start was observed (resumed sessions, extension reloads mid-turn).
325
+ const startedAt = pendingStart ?? message.timestamp ?? Date.now();
326
+ pendingStart = undefined;
327
+
328
+ if (!active || !matchesSchedule(schedule, message.provider, message.model)) {
329
+ updateStatus(ctx);
330
+ return;
331
+ }
332
+
333
+ const multiplier = multiplierAt(new Date(startedAt), schedule);
334
+ if (multiplier === 1) {
335
+ updateStatus(ctx);
336
+ return;
337
+ }
338
+
339
+ adjusted.add(message);
340
+ updateStatus(ctx);
341
+ return {
342
+ message: {
343
+ ...message,
344
+ usage: {
345
+ ...message.usage,
346
+ cost: applyMultiplier(message.usage.cost, multiplier),
347
+ },
348
+ },
349
+ };
350
+ });
351
+
352
+ pi.registerCommand(COMMAND_PEAK_HOURS, {
353
+ description: "Show or toggle time-of-day cost correction; /peak-hours [on|off|status]",
354
+ getArgumentCompletions: (prefix) => {
355
+ const values = ["on", "off", "status"];
356
+ const items = values.filter((value) => value.startsWith(prefix.trim().toLowerCase()));
357
+ return items.length ? items.map((value) => ({ value, label: value })) : null;
358
+ },
359
+ handler: async (args, ctx) => {
360
+ const arg = args.trim().toLowerCase();
361
+ if (!arg || arg === "status") {
362
+ refresh();
363
+ updateStatus(ctx);
364
+ notifyStatus(ctx);
365
+ return;
366
+ }
367
+ if (arg !== "on" && arg !== "off") {
368
+ ctx.ui.notify("Usage: /peak-hours [on|off|status]", "error");
369
+ return;
370
+ }
371
+ writeActive(CONFIG_PATH, arg === "on");
372
+ refresh();
373
+ updateStatus(ctx);
374
+ notifyStatus(ctx);
375
+ },
376
+ });
377
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@nijaru/pi-peak-hours",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Correct pi's recorded cost for providers that bill by time of day, with a peak multiplier in the footer status.",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-extension",
10
+ "cost",
11
+ "pricing",
12
+ "peak",
13
+ "off-peak",
14
+ "deepseek"
15
+ ],
16
+ "license": "MIT",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "author": "Nick Naru <nick@nijaru.com>",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/nijaru/pi-peak-hours.git"
24
+ },
25
+ "pi": {
26
+ "extensions": [
27
+ "./extensions"
28
+ ]
29
+ },
30
+ "scripts": {
31
+ "test": "bun test",
32
+ "lint": "oxlint extensions/ tests/",
33
+ "typecheck": "tsc --noEmit",
34
+ "check": "bun test && oxlint extensions/ tests/ && tsc --noEmit"
35
+ },
36
+ "devDependencies": {
37
+ "@earendil-works/pi-ai": "0.85.1",
38
+ "@earendil-works/pi-coding-agent": "0.85.1",
39
+ "@types/bun": "latest",
40
+ "oxlint": "latest",
41
+ "typescript": "^6.0.3"
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-ai": "*",
45
+ "@earendil-works/pi-coding-agent": "*"
46
+ },
47
+ "files": [
48
+ "extensions",
49
+ "README.md",
50
+ "LICENSE"
51
+ ]
52
+ }