@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 +65 -7
- package/extensions/index.ts +432 -85
- package/package.json +1 -2
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
71
|
-
|
|
104
|
+
/** Everything a single schedule accepts. */
|
|
105
|
+
export interface ScheduleConfig {
|
|
72
106
|
multiplier?: number;
|
|
73
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
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
|
|
135
|
-
export function resolveSchedule(config:
|
|
136
|
-
|
|
137
|
-
multiplier:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
162
|
-
|
|
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.
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
326
|
+
return edges.filter((edge) => edge > nowMs).sort((left, right) => left - right);
|
|
178
327
|
}
|
|
179
328
|
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
let
|
|
268
|
-
let
|
|
269
|
-
|
|
270
|
-
const adjusted =
|
|
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(
|
|
275
|
-
|
|
276
|
-
active = config.active
|
|
558
|
+
config = readConfig(configPath) ?? {};
|
|
559
|
+
schedules = resolveSchedules(config);
|
|
560
|
+
active = parseActive(config.active);
|
|
277
561
|
}
|
|
278
562
|
|
|
279
|
-
function
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
342
|
-
message
|
|
343
|
-
|
|
344
|
-
usage
|
|
345
|
-
|
|
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(
|
|
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.
|
|
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": [
|