pi-lxmf 0.1.0
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/CHANGELOG.md +111 -0
- package/README.md +202 -0
- package/SPEC.md +469 -0
- package/package.json +52 -0
- package/src/bin.js +220 -0
- package/src/bridge.js +622 -0
- package/src/bz2.js +46 -0
- package/src/commands.js +288 -0
- package/src/config.js +393 -0
- package/src/identity.js +93 -0
- package/src/lxmf.js +380 -0
- package/src/quota.js +512 -0
- package/src/rpc.js +627 -0
- package/src/text.js +97 -0
package/src/quota.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file quota.js
|
|
3
|
+
*
|
|
4
|
+
* z.ai (GLM Coding Plan) quota watcher + peak-hours warning (work doc #3).
|
|
5
|
+
*
|
|
6
|
+
* Two related behaviours, both gated on the active model being a z.ai GLM
|
|
7
|
+
* model (`provider === "zai"`):
|
|
8
|
+
*
|
|
9
|
+
* 1. **Quota-recovery notification.** When a run fails with a z.ai
|
|
10
|
+
* quota-exhausted error, poll the z.ai quota endpoint and push exactly
|
|
11
|
+
* one LXMF message to the owner the moment the 5h bucket becomes
|
|
12
|
+
* available again, so the owner can resume work without babysitting it.
|
|
13
|
+
*
|
|
14
|
+
* 2. **Peak-hours warning.** z.ai charges 3× tokens during peak hours
|
|
15
|
+
* (Mon–Fri 14:00–18:00 Singapore Standard Time, UTC+8). Warn the owner
|
|
16
|
+
* when an owner-triggered run starts inside that window, and when the
|
|
17
|
+
* window opens mid-run, so they can decide whether to stop or continue.
|
|
18
|
+
*
|
|
19
|
+
* The quota endpoint and auth-file layout mirror `pi-glm-usage`
|
|
20
|
+
* (`https://api.z.ai/api/monitor/usage/quota/limit`, Bearer
|
|
21
|
+
* `~/.pi/agent/auth.json` → `zai.key`). Missing key disables the watcher
|
|
22
|
+
* gracefully (logged once, never thrown).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
/** z.ai quota endpoint (same one `pi-glm-usage` calls). */
|
|
30
|
+
const QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
|
|
31
|
+
/** Fetch timeout for the quota endpoint (ms). */
|
|
32
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
33
|
+
/** Poll cadence while the 5h bucket is exhausted (ms). */
|
|
34
|
+
const POLL_INTERVAL_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* z.ai peak window: Monday–Friday, 14:00–18:00 Singapore Standard Time
|
|
38
|
+
* (UTC+8) → 06:00–10:00 UTC. Token usage costs 3× during this window.
|
|
39
|
+
*
|
|
40
|
+
* `START_UTC_HOUR` inclusive, `END_UTC_HOUR` exclusive.
|
|
41
|
+
*/
|
|
42
|
+
const PEAK_START_UTC_HOUR = 6;
|
|
43
|
+
const PEAK_END_UTC_HOUR = 10;
|
|
44
|
+
/** Days the peak window applies (0=Sun … 6=Sat), in UTC (the window is
|
|
45
|
+
* 06:00–10:00 UTC, which is the same calendar day in SGT). */
|
|
46
|
+
const PEAK_DAYS = new Set([1, 2, 3, 4, 5]); // Mon–Fri
|
|
47
|
+
|
|
48
|
+
/** Quota limit `unit` values (mirrors `pi-glm-usage`'s mapping). */
|
|
49
|
+
const Unit = {
|
|
50
|
+
/** 5 Hours Quota (TOKENS_LIMIT). */
|
|
51
|
+
FIVE_HOUR: 3,
|
|
52
|
+
/** Weekly Quota (TOKENS_LIMIT). */
|
|
53
|
+
WEEKLY: 6,
|
|
54
|
+
/** Monthly Web Search/Reader/Zread (TIME_LIMIT) — parsed, not watched. */
|
|
55
|
+
MONTHLY: 5,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The 5h bucket is treated as exhausted at-or-above this percentage.
|
|
60
|
+
*/
|
|
61
|
+
const EXHAUSTED_PERCENTAGE = 100;
|
|
62
|
+
|
|
63
|
+
/** z.ai provider id (and its `z.ai` alias, defensively). */
|
|
64
|
+
const ZAI_PROVIDERS = new Set(["zai", "z.ai"]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {any} model - The active model object from `get_state`/`get_available_models`.
|
|
68
|
+
* @returns {boolean} `true` when the model is a z.ai GLM model.
|
|
69
|
+
*/
|
|
70
|
+
export function isZaiModel(model) {
|
|
71
|
+
const provider =
|
|
72
|
+
model && typeof model === "object" ? model.provider : undefined;
|
|
73
|
+
return (
|
|
74
|
+
typeof provider === "string" &&
|
|
75
|
+
ZAI_PROVIDERS.has(provider.trim().toLowerCase())
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reads the z.ai API key from the configured auth file, honouring
|
|
81
|
+
* `PI_AUTH_DIR` (like `pi-glm-usage`), falling back to
|
|
82
|
+
* `~/.pi/agent/auth.json`.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} [authDir] - Override directory (defaults to `$PI_AUTH_DIR`
|
|
85
|
+
* then `~/.pi/agent`).
|
|
86
|
+
* @returns {string|null} The API key, or `null` when absent/unreadable.
|
|
87
|
+
*/
|
|
88
|
+
export function readZaiKey(authDir) {
|
|
89
|
+
const dir =
|
|
90
|
+
authDir ||
|
|
91
|
+
(typeof process !== "undefined" && process.env?.PI_AUTH_DIR) ||
|
|
92
|
+
join(homedir(), ".pi", "agent");
|
|
93
|
+
const file = join(dir, "auth.json");
|
|
94
|
+
try {
|
|
95
|
+
if (!existsSync(file)) return null;
|
|
96
|
+
const auth = JSON.parse(readFileSync(file, "utf8"));
|
|
97
|
+
const key = auth?.zai?.key;
|
|
98
|
+
return typeof key === "string" && key ? key : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fetches and parses quota data from the z.ai API.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} apiKey
|
|
108
|
+
* @param {{ fetchImpl?: typeof fetch, signal?: AbortSignal }} [options]
|
|
109
|
+
* @returns {Promise<{fiveHour: {percentage: number, nextResetMs: number}|null, weekly: {percentage: number, nextResetMs: number}|null, level: string|null}>}
|
|
110
|
+
* @throws {Error} on network/HTTP/structural failure.
|
|
111
|
+
*/
|
|
112
|
+
export async function fetchQuota(apiKey, options = {}) {
|
|
113
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
116
|
+
try {
|
|
117
|
+
const response = await fetchImpl(QUOTA_URL, {
|
|
118
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
119
|
+
signal: options.signal ?? controller.signal,
|
|
120
|
+
});
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
123
|
+
}
|
|
124
|
+
const data = await response.json();
|
|
125
|
+
if (data?.code !== 200 || !Array.isArray(data?.data?.limits)) {
|
|
126
|
+
throw new Error("invalid quota response");
|
|
127
|
+
}
|
|
128
|
+
/** @type {any} */
|
|
129
|
+
let fiveHour = null;
|
|
130
|
+
/** @type {any} */
|
|
131
|
+
let weekly = null;
|
|
132
|
+
for (const limit of data.data.limits) {
|
|
133
|
+
if (limit.unit === Unit.FIVE_HOUR) fiveHour = limit;
|
|
134
|
+
else if (limit.unit === Unit.WEEKLY) weekly = limit;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
fiveHour: fiveHour
|
|
138
|
+
? {
|
|
139
|
+
percentage: Number(fiveHour.percentage) || 0,
|
|
140
|
+
nextResetMs: Number(fiveHour.nextResetTime) || 0,
|
|
141
|
+
}
|
|
142
|
+
: null,
|
|
143
|
+
weekly: weekly
|
|
144
|
+
? {
|
|
145
|
+
percentage: Number(weekly.percentage) || 0,
|
|
146
|
+
nextResetMs: Number(weekly.nextResetTime) || 0,
|
|
147
|
+
}
|
|
148
|
+
: null,
|
|
149
|
+
level: typeof data.data.level === "string" ? data.data.level : null,
|
|
150
|
+
};
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timeoutId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Returns `true` when the given error message looks like a z.ai
|
|
158
|
+
* quota-exhausted failure (the signal that arms the watcher).
|
|
159
|
+
*
|
|
160
|
+
* Deliberately permissive: this is a "maybe exhausted, go check" signal,
|
|
161
|
+
* not an admission decision — the authoritative answer comes from
|
|
162
|
+
* {@link fetchQuota}, and a false positive costs only one quota fetch.
|
|
163
|
+
* Rate-limit-only messages (no quota wording) are excluded: those are
|
|
164
|
+
* transient and pi already retries them automatically.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} message
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
export function looksLikeQuotaError(message) {
|
|
170
|
+
const m = String(message ?? "").toLowerCase();
|
|
171
|
+
if (!m) return false;
|
|
172
|
+
return /\bquota\b|usage[\s-]?limit|\bbalance\b|insufficient/.test(m);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Reports whether a point in time falls inside the z.ai peak window
|
|
177
|
+
* (Mon–Fri 14:00–18:00 SGT / 06:00–10:00 UTC).
|
|
178
|
+
*
|
|
179
|
+
* @param {number} epochMs - Unix epoch milliseconds.
|
|
180
|
+
* @returns {boolean}
|
|
181
|
+
*/
|
|
182
|
+
export function isPeakTime(epochMs) {
|
|
183
|
+
const d = new Date(epochMs);
|
|
184
|
+
const dayUtc = d.getUTCDay();
|
|
185
|
+
if (!PEAK_DAYS.has(dayUtc)) return false;
|
|
186
|
+
const hourUtc = d.getUTCHours();
|
|
187
|
+
return hourUtc >= PEAK_START_UTC_HOUR && hourUtc < PEAK_END_UTC_HOUR;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Milliseconds until the next peak-window open (or 0 when already inside
|
|
192
|
+
* the window). Used to schedule a "run ran into peak" warning.
|
|
193
|
+
*
|
|
194
|
+
* @param {number} fromMs - Unix epoch milliseconds.
|
|
195
|
+
* @returns {number} ms until the window opens, or 0 if inside it.
|
|
196
|
+
*/
|
|
197
|
+
export function msUntilPeakOpen(fromMs) {
|
|
198
|
+
if (isPeakTime(fromMs)) return 0;
|
|
199
|
+
const from = new Date(fromMs);
|
|
200
|
+
// Walk forward hour by hour (max ~7 days) to the first peak hour.
|
|
201
|
+
for (let h = 0; h < 24 * 7; h++) {
|
|
202
|
+
const probe = new Date(fromMs + h * 3_600_000);
|
|
203
|
+
const d = new Date(
|
|
204
|
+
Date.UTC(
|
|
205
|
+
probe.getUTCFullYear(),
|
|
206
|
+
probe.getUTCMonth(),
|
|
207
|
+
probe.getUTCDate(),
|
|
208
|
+
probe.getUTCHours(),
|
|
209
|
+
0,
|
|
210
|
+
0,
|
|
211
|
+
0,
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
if (
|
|
215
|
+
PEAK_DAYS.has(d.getUTCDay()) &&
|
|
216
|
+
d.getUTCHours() >= PEAK_START_UTC_HOUR &&
|
|
217
|
+
d.getUTCHours() < PEAK_END_UTC_HOUR
|
|
218
|
+
) {
|
|
219
|
+
return d.getTime() - fromMs;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return Number.POSITIVE_INFINITY;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The z.ai quota watcher + peak-hours warner. Construct one per bridge;
|
|
227
|
+
* drive it with {@link GlmQuotaWatcher.setEnabled} (gated on the active
|
|
228
|
+
* model) and {@link GlmQuotaWatcher.onAgentStart} / {@link
|
|
229
|
+
* GlmQuotaWatcher.onAgentSettled} / {@link GlmQuotaWatcher.onError}.
|
|
230
|
+
*/
|
|
231
|
+
export class GlmQuotaWatcher {
|
|
232
|
+
/**
|
|
233
|
+
* @param {object} options
|
|
234
|
+
* @param {string} options.ownerDestinationHash - Owner's `lxmf.delivery` hex.
|
|
235
|
+
* @param {(destHex: string, text: string) => Promise<void>} options.sendText - LXMF delivery sink (best-effort).
|
|
236
|
+
* @param {{log: (msg: string) => void, error: (msg: string) => void}} [options.log]
|
|
237
|
+
* @param {string|null} [options.apiKey] - z.ai key; `null` disables the watcher.
|
|
238
|
+
* @param {typeof fetch} [options.fetchImpl] - Injectable fetch (tests).
|
|
239
|
+
* @param {() => number} [options.now] - Injectable clock (tests).
|
|
240
|
+
* @param {number} [options.pollIntervalMs]
|
|
241
|
+
*/
|
|
242
|
+
constructor(options) {
|
|
243
|
+
this.ownerDestinationHash = options.ownerDestinationHash;
|
|
244
|
+
this.sendText = options.sendText;
|
|
245
|
+
this.log = options.log || { log: () => {}, error: () => {} };
|
|
246
|
+
this.apiKey = options.apiKey ?? null;
|
|
247
|
+
this.fetchImpl = options.fetchImpl || fetch;
|
|
248
|
+
this.now = options.now || (() => Date.now());
|
|
249
|
+
this.pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
250
|
+
|
|
251
|
+
/** Whether the active model is a z.ai GLM model (the master gate). */
|
|
252
|
+
this.enabled = false;
|
|
253
|
+
/** A run is currently in progress (between agent_start and settled). */
|
|
254
|
+
this.runActive = false;
|
|
255
|
+
/** Whether the current run is owner-triggered (not recovery). */
|
|
256
|
+
this.ownerTriggered = false;
|
|
257
|
+
/** Single active quota poller (idempotent start). */
|
|
258
|
+
/** @type {NodeJS.Timeout|null} */
|
|
259
|
+
this.pollTimer = null;
|
|
260
|
+
/** Whether the 5h bucket was exhausted when the poller last sampled. */
|
|
261
|
+
this.wasExhausted = false;
|
|
262
|
+
/** Whether a recovery notification has already been sent for this episode. */
|
|
263
|
+
this.notifiedThisEpisode = false;
|
|
264
|
+
/** Epoch (ms) the exhaustion episode started, for the human-readable delta. */
|
|
265
|
+
this.exhaustedSinceMs = 0;
|
|
266
|
+
/** Whether we've already warned about peak for the current run. */
|
|
267
|
+
this.peakWarnedThisRun = false;
|
|
268
|
+
/** Timer for the "run ran into peak" boundary warning. */
|
|
269
|
+
/** @type {NodeJS.Timeout|null} */
|
|
270
|
+
this.peakOpenTimer = null;
|
|
271
|
+
|
|
272
|
+
if (!this.apiKey) {
|
|
273
|
+
this.log.log("pi-lxmf: GLM quota watcher disabled (no zai key)");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Master gate: enable/disable based on whether the active model is z.ai.
|
|
279
|
+
* Disabling stops any active poller and clears run state.
|
|
280
|
+
*
|
|
281
|
+
* @param {boolean} enabled
|
|
282
|
+
*/
|
|
283
|
+
setEnabled(enabled) {
|
|
284
|
+
if (enabled === this.enabled) return;
|
|
285
|
+
this.enabled = enabled;
|
|
286
|
+
if (!enabled) {
|
|
287
|
+
this.stopPoller();
|
|
288
|
+
this.clearPeakOpenTimer();
|
|
289
|
+
this.runActive = false;
|
|
290
|
+
this.ownerTriggered = false;
|
|
291
|
+
this.peakWarnedThisRun = false;
|
|
292
|
+
} else {
|
|
293
|
+
// Gated on at startup: do one quota fetch so a daemon that restarted
|
|
294
|
+
// mid-outage arms the watcher immediately.
|
|
295
|
+
void this.checkAndMaybeArm(false);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Called on `agent_start`. `ownerTriggered` is false for the internal
|
|
301
|
+
* empty-reply recovery run.
|
|
302
|
+
*
|
|
303
|
+
* @param {boolean} ownerTriggered
|
|
304
|
+
*/
|
|
305
|
+
onAgentStart(ownerTriggered) {
|
|
306
|
+
this.runActive = true;
|
|
307
|
+
this.ownerTriggered = ownerTriggered;
|
|
308
|
+
this.peakWarnedThisRun = false;
|
|
309
|
+
if (this.enabled && ownerTriggered) this.maybeWarnPeak("run started");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Called on `agent_settled`. */
|
|
313
|
+
onAgentSettled() {
|
|
314
|
+
this.runActive = false;
|
|
315
|
+
this.ownerTriggered = false;
|
|
316
|
+
this.peakWarnedThisRun = false;
|
|
317
|
+
this.clearPeakOpenTimer();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Called when a Pi error event (`auto_retry_end`/`compaction_end`) looks
|
|
322
|
+
* like a quota failure. Arms the poller (idempotent) when enabled.
|
|
323
|
+
*
|
|
324
|
+
* @param {string} errorMessage
|
|
325
|
+
*/
|
|
326
|
+
onError(errorMessage) {
|
|
327
|
+
if (!this.enabled || !this.apiKey) return;
|
|
328
|
+
if (!looksLikeQuotaError(errorMessage)) return;
|
|
329
|
+
this.log.log(
|
|
330
|
+
`pi-lxmf: GLM quota error detected, polling for recovery: ${errorMessage}`,
|
|
331
|
+
);
|
|
332
|
+
void this.checkAndMaybeArm(true);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Stops all timers. Call on daemon shutdown.
|
|
337
|
+
*/
|
|
338
|
+
stop() {
|
|
339
|
+
this.stopPoller();
|
|
340
|
+
this.clearPeakOpenTimer();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Fetches the quota once and arms the poller if the 5h bucket is
|
|
345
|
+
* exhausted. When `fromError` is true (we were tipped off by a Pi error),
|
|
346
|
+
* arm even if the first fetch is inconclusive (transient API failure).
|
|
347
|
+
*
|
|
348
|
+
* @param {boolean} fromError
|
|
349
|
+
* @returns {Promise<void>}
|
|
350
|
+
*/
|
|
351
|
+
async checkAndMaybeArm(fromError) {
|
|
352
|
+
if (!this.enabled || !this.apiKey) return;
|
|
353
|
+
let quota = null;
|
|
354
|
+
try {
|
|
355
|
+
quota = await fetchQuota(this.apiKey, { fetchImpl: this.fetchImpl });
|
|
356
|
+
} catch (e) {
|
|
357
|
+
if (fromError) {
|
|
358
|
+
// A Pi error said quota-exhausted; trust it and arm, polling will
|
|
359
|
+
// confirm the recovery transition.
|
|
360
|
+
this.log.log(
|
|
361
|
+
`pi-lxmf: GLM quota fetch failed (${e instanceof Error ? e.message : e}); arming watcher on Pi error signal`,
|
|
362
|
+
);
|
|
363
|
+
this.armWatcher(0);
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const pct = quota.fiveHour?.percentage ?? 0;
|
|
368
|
+
if (pct >= EXHAUSTED_PERCENTAGE) {
|
|
369
|
+
this.armWatcher(pct);
|
|
370
|
+
} else if (this.wasExhausted) {
|
|
371
|
+
// Recovered between fetches (e.g. daemon was away): notify now.
|
|
372
|
+
this.notifyRecovered(quota);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Arms the single poller (idempotent). Records the episode start time on
|
|
378
|
+
* the first arm of an episode and resets the per-episode notification flag.
|
|
379
|
+
*
|
|
380
|
+
* @param {number} percentage
|
|
381
|
+
*/
|
|
382
|
+
armWatcher(percentage) {
|
|
383
|
+
if (!this.wasExhausted) {
|
|
384
|
+
this.wasExhausted = true;
|
|
385
|
+
this.exhaustedSinceMs = this.now();
|
|
386
|
+
this.notifiedThisEpisode = false;
|
|
387
|
+
this.log.log(
|
|
388
|
+
`pi-lxmf: GLM 5h quota exhausted (${percentage}%) — will notify on recovery`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
this.startPoller();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Starts the poller if not already running. */
|
|
395
|
+
startPoller() {
|
|
396
|
+
if (this.pollTimer || !this.enabled || !this.apiKey) return;
|
|
397
|
+
const apiKey = this.apiKey;
|
|
398
|
+
const tick = async () => {
|
|
399
|
+
try {
|
|
400
|
+
const quota = await fetchQuota(apiKey, {
|
|
401
|
+
fetchImpl: this.fetchImpl,
|
|
402
|
+
});
|
|
403
|
+
const pct = quota.fiveHour?.percentage ?? 0;
|
|
404
|
+
if (pct < EXHAUSTED_PERCENTAGE && this.wasExhausted) {
|
|
405
|
+
this.notifyRecovered(quota);
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
/* transient — retry on the next tick */
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
this.pollTimer = setInterval(() => void tick(), this.pollIntervalMs);
|
|
412
|
+
if (typeof this.pollTimer.unref === "function") this.pollTimer.unref();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Stops the poller. */
|
|
416
|
+
stopPoller() {
|
|
417
|
+
if (this.pollTimer) {
|
|
418
|
+
clearInterval(this.pollTimer);
|
|
419
|
+
this.pollTimer = null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Delivers the one-shot recovery notification and stops the poller.
|
|
425
|
+
*
|
|
426
|
+
* @param {{fiveHour?: {percentage: number}|null, weekly?: {percentage: number}|null, level?: string|null}} quota
|
|
427
|
+
*/
|
|
428
|
+
async notifyRecovered(quota) {
|
|
429
|
+
if (this.notifiedThisEpisode) return;
|
|
430
|
+
this.notifiedThisEpisode = true;
|
|
431
|
+
this.wasExhausted = false;
|
|
432
|
+
this.stopPoller();
|
|
433
|
+
const elapsed = this.exhaustedSinceMs
|
|
434
|
+
? this.now() - this.exhaustedSinceMs
|
|
435
|
+
: 0;
|
|
436
|
+
const weeklyPct = quota.weekly?.percentage;
|
|
437
|
+
const level = quota.level ? `GLM ${quota.level}` : "GLM";
|
|
438
|
+
const parts = [`${level} 5h quota available again`];
|
|
439
|
+
if (elapsed > 0) {
|
|
440
|
+
parts.push(`(was exhausted for ~${formatElapsed(elapsed)})`);
|
|
441
|
+
}
|
|
442
|
+
if (typeof weeklyPct === "number") {
|
|
443
|
+
parts.push(`Weekly: ${weeklyPct}%`);
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
await this.sendText(this.ownerDestinationHash, parts.join(" "));
|
|
447
|
+
} catch (e) {
|
|
448
|
+
this.log.error(
|
|
449
|
+
`pi-lxmf: GLM quota notification delivery failed: ${e instanceof Error ? e.message : e}`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Sends a peak-hours warning if currently in the window (once per run).
|
|
456
|
+
* Also schedules the "run ran into peak" boundary warning.
|
|
457
|
+
*
|
|
458
|
+
* @param {string} reason - Short reason for the log line.
|
|
459
|
+
*/
|
|
460
|
+
maybeWarnPeak(reason) {
|
|
461
|
+
if (!this.enabled) return;
|
|
462
|
+
const nowMs = this.now();
|
|
463
|
+
if (isPeakTime(nowMs)) {
|
|
464
|
+
if (this.peakWarnedThisRun) return;
|
|
465
|
+
this.peakWarnedThisRun = true;
|
|
466
|
+
this.log.log(`pi-lxmf: GLM peak-hours warning (${reason})`);
|
|
467
|
+
void this.sendText(
|
|
468
|
+
this.ownerDestinationHash,
|
|
469
|
+
"⚠️ z.ai peak hours (Mon–Fri 14:00–18:00 SGT): token usage costs 3×. Stop or continue?",
|
|
470
|
+
);
|
|
471
|
+
} else if (this.runActive) {
|
|
472
|
+
// Schedule a warning for when the window opens, if this run is still
|
|
473
|
+
// active then.
|
|
474
|
+
this.clearPeakOpenTimer();
|
|
475
|
+
const ms = msUntilPeakOpen(nowMs);
|
|
476
|
+
if (Number.isFinite(ms) && ms > 0) {
|
|
477
|
+
const fire = () => {
|
|
478
|
+
this.peakOpenTimer = null;
|
|
479
|
+
if (!this.runActive || this.peakWarnedThisRun) return;
|
|
480
|
+
this.peakWarnedThisRun = true;
|
|
481
|
+
this.log.log(
|
|
482
|
+
"pi-lxmf: GLM peak-hours warning (window opened mid-run)",
|
|
483
|
+
);
|
|
484
|
+
void this.sendText(
|
|
485
|
+
this.ownerDestinationHash,
|
|
486
|
+
"⚠️ z.ai peak hours now (Mon–Fri 14:00–18:00 SGT): token usage costs 3×. Stop or continue?",
|
|
487
|
+
);
|
|
488
|
+
};
|
|
489
|
+
this.peakOpenTimer = setTimeout(fire, ms);
|
|
490
|
+
if (typeof this.peakOpenTimer.unref === "function")
|
|
491
|
+
this.peakOpenTimer.unref();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Clears the scheduled peak-open timer. */
|
|
497
|
+
clearPeakOpenTimer() {
|
|
498
|
+
if (this.peakOpenTimer) {
|
|
499
|
+
clearTimeout(this.peakOpenTimer);
|
|
500
|
+
this.peakOpenTimer = null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** @param {number} ms */
|
|
506
|
+
function formatElapsed(ms) {
|
|
507
|
+
const m = Math.floor(ms / 60000);
|
|
508
|
+
if (m < 60) return `${m}m`;
|
|
509
|
+
const h = Math.floor(m / 60);
|
|
510
|
+
const rem = m % 60;
|
|
511
|
+
return rem ? `${h}h ${rem}m` : `${h}h`;
|
|
512
|
+
}
|