pi-lxmf 0.1.2 → 0.2.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 +52 -0
- package/README.md +12 -2
- package/SPEC.md +24 -13
- package/package.json +1 -1
- package/src/bridge.js +48 -9
- package/src/lxmf.js +265 -36
- package/src/quota.js +214 -103
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-09-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Quota exhaustion and 90% warnings** (work doc #3): the GLM quota
|
|
15
|
+
watcher now samples the z.ai quota API continuously while a GLM model
|
|
16
|
+
is active (not just after a run fails) and notifies the owner when a
|
|
17
|
+
bucket (5h or weekly) hits 100% — including at startup, when the daemon
|
|
18
|
+
starts mid-outage — and when one crosses 90%. The startup check runs
|
|
19
|
+
immediately on enable, so no error is needed to detect an exhausted
|
|
20
|
+
bucket.
|
|
21
|
+
- The startup notification now names the active model (fresh `get_state`
|
|
22
|
+
observation) — the model isn't visible anywhere else over LXMF.
|
|
23
|
+
- `/model` and `/think` re-observe state right away, so the quota watcher
|
|
24
|
+
gate follows the switch immediately instead of on the next prompt.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- A run that failed after delivering partial output silently trailed
|
|
29
|
+
off: the error is now reported as a "run ended early" message and no
|
|
30
|
+
longer leaks into a later exchange's failure reply.
|
|
31
|
+
|
|
32
|
+
## [0.1.3] - 2026-09-27
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- The successful `/cd` reply is now a visually distinct banner (divider
|
|
37
|
+
line, 📂/🔁/✨ emoji) so project change boundaries are easy to spot when
|
|
38
|
+
scrolling back through the message history.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Startup notification lost to the announce race: reticulum-js keeps the
|
|
43
|
+
destination→identity mapping in memory, so right after a daemon restart
|
|
44
|
+
the owner's `lxmf.delivery` hash is unknown and the router fails the
|
|
45
|
+
"🟢 ready" send instantly (its path request only happens once the
|
|
46
|
+
identity is known). `sendWithRetry` now recognises that failure, sends a
|
|
47
|
+
path request (which solicits an announce from the peer or a node holding
|
|
48
|
+
its path) and waits up to 30s for the announce before retrying — instead
|
|
49
|
+
of burning two hopeless immediate retries and parking the text in the
|
|
50
|
+
next reply's delivery-failure note.
|
|
51
|
+
- Configured propagation node was never used for outbound: reticulum-js's
|
|
52
|
+
`lxmf.send` never consults the outbound propagation node on its own
|
|
53
|
+
(unlike Python's `LXMRouter`), so despite `setOutboundPropagationNode`
|
|
54
|
+
being called, replies to an off-mesh owner were simply lost. The retry
|
|
55
|
+
chain now escalates to `submitToPropagationNode` (store-and-forward,
|
|
56
|
+
delivered on the owner's next sync) after direct and opportunistic
|
|
57
|
+
delivery both fail — including waiting for the node's own announce on a
|
|
58
|
+
fresh start. The chain lives in the exported `createRetrySender`
|
|
59
|
+
(unit-tested against a fake router) instead of a closure inside
|
|
60
|
+
`startLxmf`.
|
|
61
|
+
|
|
10
62
|
## [0.1.2] - 2026-09-27
|
|
11
63
|
|
|
12
64
|
### Added
|
package/README.md
CHANGED
|
@@ -88,9 +88,10 @@ node src/bin.js --help
|
|
|
88
88
|
|---|---|
|
|
89
89
|
| plain text | a prompt to the agent (steered into a running turn by default) |
|
|
90
90
|
| `/help` | bridge commands + Pi commands available via prompt |
|
|
91
|
-
| `/status` | model, thinking, session, uptime, node + owner identity hashes |
|
|
91
|
+
| `/status` | model, thinking, session, uptime, cwd + workdir, node + owner identity hashes |
|
|
92
92
|
| `/session` | message counts, tokens, cost, context usage |
|
|
93
93
|
| `/new` | fresh Pi session |
|
|
94
|
+
| `/cd [path]` | switch the supervised Pi to another repo under `workdir`; bare `/cd` lists current + recent repos |
|
|
94
95
|
| `/name [name]` | show / set the session display name |
|
|
95
96
|
| `/compact [instructions]` | compact the conversation context |
|
|
96
97
|
| `/model [query]` | list models, or switch (`/model sonnet`) |
|
|
@@ -105,6 +106,15 @@ Replies are delivered per finished assistant message, chunked to fit
|
|
|
105
106
|
then a `✅ done (no reply)` nudge. Extension dialogs raised inside Pi are
|
|
106
107
|
auto-declined (nobody is at a terminal) and reported to you.
|
|
107
108
|
|
|
109
|
+
`/cd` makes one bridge serve every repo under `workdir`: switching is a
|
|
110
|
+
supervised respawn in the target directory (messages queue during the switch),
|
|
111
|
+
each repo keeps its own Pi session — revisiting one resumes its conversation —
|
|
112
|
+
and the active repo is remembered across daemon restarts. Targets must resolve
|
|
113
|
+
under `workdir` (no `..` traversal, nothing outside the tree); anything else
|
|
114
|
+
is refused without touching the child. `/status` shows the current `cwd` and
|
|
115
|
+
`workdir`, and each switched-to project must be trusted once (see step 4
|
|
116
|
+
above) for its local `.pi` resources to load.
|
|
117
|
+
|
|
108
118
|
## Configuration
|
|
109
119
|
|
|
110
120
|
`~/.config/pi-lxmf/config.json` (XDG env vars respected). A missing file runs
|
|
@@ -114,7 +124,7 @@ on defaults.
|
|
|
114
124
|
|---|---|---|
|
|
115
125
|
| `owner` | *(required)* | the owner's 32-hex **Reticulum identity hash** (not the LXMF address); the daemon derives the `lxmf.delivery` destination hash for wire comparison |
|
|
116
126
|
| `name` | `pi-lxmf <version>` | announce display name |
|
|
117
|
-
| `workdir` | daemon cwd | project directory Pi runs in (also where `AGENTS.md` is found) |
|
|
127
|
+
| `workdir` | daemon cwd | project directory Pi runs in (also where `AGENTS.md` is found); the trust root `/cd` cannot escape |
|
|
118
128
|
| `model` | Pi default | `--model` pattern passed to Pi |
|
|
119
129
|
| `piBin` | `pi` | Pi binary |
|
|
120
130
|
| `dataDir` | `~/.local/share/pi-lxmf` | state root (see below) |
|
package/SPEC.md
CHANGED
|
@@ -308,19 +308,30 @@ the recently used ones (derived from the per-workdir session pointers);
|
|
|
308
308
|
### 6.7 z.ai GLM quota watcher and peak-hours warning
|
|
309
309
|
|
|
310
310
|
When the active model is a z.ai GLM model (`provider === "zai"`), the bridge
|
|
311
|
-
runs a `GlmQuotaWatcher` (`src/quota.js`)
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
311
|
+
runs a `GlmQuotaWatcher` (`src/quota.js`), gated on the active model being
|
|
312
|
+
GLM — nothing fires for non-z.ai providers (e.g. Cortecs, Anthropic). The
|
|
313
|
+
gate is (re-)evaluated on every `get_state` observation: prompts, the
|
|
314
|
+
startup observation, and after `/model`/`/think` commands.
|
|
315
|
+
|
|
316
|
+
- **Continuous quota sampling.** While enabled, the watcher polls the
|
|
317
|
+
same z.ai quota endpoint `pi-glm-usage` uses
|
|
318
|
+
(`https://api.z.ai/api/monitor/usage/quota/limit`, Bearer
|
|
319
|
+
`~/.pi/agent/auth.json` → `zai.key`, honouring `PI_AUTH_DIR`) every 60s
|
|
320
|
+
(first sample immediately on enable — so a daemon that starts mid-outage
|
|
321
|
+
detects and reports it right away). From those samples the owner is
|
|
322
|
+
notified of:
|
|
323
|
+
- **Exhaustion** — a bucket (5h or weekly) reaching 100%: once per
|
|
324
|
+
episode, with the reset time when the API reports one.
|
|
325
|
+
- **90% warning** — a bucket at or above 90% while below 100%: once
|
|
326
|
+
per window (a dip below the threshold re-arms it).
|
|
327
|
+
- **Recovery** — the 5h bucket dropping below 100% after an exhausted
|
|
328
|
+
episode: once per episode.
|
|
329
|
+
A quota-looking Pi error (`auto_retry_end`/`compaction_end`) forces an
|
|
330
|
+
immediate fresh sample; rate-limit-only errors (transient, retried by
|
|
331
|
+
Pi) are ignored. Per-sample notices are joined into one LXMF message
|
|
332
|
+
and — when an owner-triggered run is live — deferred to `agent_settled`
|
|
333
|
+
so they never interleave with a reply. A missing `zai.key` disables the
|
|
334
|
+
watcher gracefully (logged once).
|
|
324
335
|
- **Peak-hours warning.** z.ai charges 3× tokens Mon–Fri 14:00–18:00
|
|
325
336
|
Singapore Standard Time (UTC+8). The owner is warned when an
|
|
326
337
|
owner-triggered run starts inside that window, and when the window
|
package/package.json
CHANGED
package/src/bridge.js
CHANGED
|
@@ -315,6 +315,18 @@ export class Bridge {
|
|
|
315
315
|
this.log.log(`pi-lxmf: inbound from owner: command /${parsed.name}`);
|
|
316
316
|
try {
|
|
317
317
|
const result = await command.run(this.commandContext(), parsed.args);
|
|
318
|
+
// /model and /think change pi state the bridge tracks through
|
|
319
|
+
// `get_state` observations — re-observe so the active model (and
|
|
320
|
+
// the GLM watcher gate) follow immediately instead of on the
|
|
321
|
+
// next prompt. Other commands manage their own observations
|
|
322
|
+
// (e.g. /cd) or don't affect tracked state.
|
|
323
|
+
if (parsed.name === "model" || parsed.name === "think") {
|
|
324
|
+
try {
|
|
325
|
+
this.observeState(await this.rpc.getState());
|
|
326
|
+
} catch {
|
|
327
|
+
/* keep stale observations; next prompt re-observes */
|
|
328
|
+
}
|
|
329
|
+
}
|
|
318
330
|
const text = typeof result === "string" ? result : result?.text;
|
|
319
331
|
if (text) await this.deliver(text);
|
|
320
332
|
if (result && typeof result === "object" && result.shutdown) {
|
|
@@ -466,6 +478,14 @@ export class Bridge {
|
|
|
466
478
|
|
|
467
479
|
if (sent > 0) {
|
|
468
480
|
this.recovering = false;
|
|
481
|
+
// The owner already got (partial) output. A trailing failure means
|
|
482
|
+
// the run died mid-reply: say so — otherwise the exchange just
|
|
483
|
+
// trails off — and never let the error leak into a later exchange.
|
|
484
|
+
if (this.lastError) {
|
|
485
|
+
const error = this.lastError;
|
|
486
|
+
this.lastError = null;
|
|
487
|
+
await this.deliver(`⚠️ run ended early: ${error}`);
|
|
488
|
+
}
|
|
469
489
|
return;
|
|
470
490
|
}
|
|
471
491
|
if (this.lastError) {
|
|
@@ -612,9 +632,16 @@ export class Bridge {
|
|
|
612
632
|
/* switch reply still goes out; the next prompt re-observes */
|
|
613
633
|
}
|
|
614
634
|
const rel = relative(this.config.workdir, absPath) || ".";
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
635
|
+
// A visually loud banner: project changes are the main boundaries
|
|
636
|
+
// in the message history, so they must be easy to spot while
|
|
637
|
+
// scrolling back.
|
|
638
|
+
const lines = ["📂 ───────────────────", `📂 Switched to ${rel}`];
|
|
639
|
+
lines.push(
|
|
640
|
+
pointer
|
|
641
|
+
? `🔁 Resuming session ${basename(pointer.sessionFile)}`
|
|
642
|
+
: "✨ Fresh session",
|
|
643
|
+
);
|
|
644
|
+
return lines.join("\n");
|
|
618
645
|
}
|
|
619
646
|
|
|
620
647
|
/**
|
|
@@ -648,17 +675,29 @@ export class Bridge {
|
|
|
648
675
|
* Tells the owner the bridge has started and is accepting messages
|
|
649
676
|
* (the startup case of SPEC §13 proactive notifications). Called by the
|
|
650
677
|
* daemon once the mesh side is announcing and the RPC child is ready.
|
|
651
|
-
*
|
|
652
|
-
* the
|
|
678
|
+
* The active model is included (fresh `get_state` observation) — the
|
|
679
|
+
* owner can't see the TUI footer over LXMF, so the startup message is
|
|
680
|
+
* the only place the model is announced proactively. Best-effort via
|
|
681
|
+
* {@link deliver}: a failure is noted and carried by the next
|
|
682
|
+
* successful delivery instead of being lost.
|
|
653
683
|
*
|
|
654
684
|
* @param {string|null} [resumedSessionFile] - Absolute path of the
|
|
655
685
|
* session resumed from the persisted pointer, when one exists.
|
|
656
686
|
*/
|
|
657
687
|
async notifyStartup(resumedSessionFile = null) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
688
|
+
try {
|
|
689
|
+
this.observeState(await this.rpc.getState());
|
|
690
|
+
} catch {
|
|
691
|
+
/* RPC child is ready (checked by the caller); keep the old model */
|
|
692
|
+
}
|
|
693
|
+
const lines = ["🟢 pi-lxmf ready — listening for messages."];
|
|
694
|
+
if (typeof this.activeModel?.name === "string" && this.activeModel.name) {
|
|
695
|
+
lines.push(`Model: ${this.activeModel.name}`);
|
|
696
|
+
}
|
|
697
|
+
if (resumedSessionFile) {
|
|
698
|
+
lines.push(`Resuming session ${basename(resumedSessionFile)}.`);
|
|
699
|
+
}
|
|
700
|
+
await this.deliver(lines.join("\n"));
|
|
662
701
|
}
|
|
663
702
|
|
|
664
703
|
/**
|
package/src/lxmf.js
CHANGED
|
@@ -22,6 +22,244 @@ import {
|
|
|
22
22
|
import { createBz2 } from "./bz2.js";
|
|
23
23
|
import { chunkText } from "./text.js";
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The LXMRouter's failure when the destination's identity has not been
|
|
27
|
+
* learned yet (no announce heard): `send` declines instantly — no link can
|
|
28
|
+
* be established and opportunistic encryption is impossible without the
|
|
29
|
+
* recipient's public key.
|
|
30
|
+
*/
|
|
31
|
+
const UNKNOWN_IDENTITY_MESSAGE =
|
|
32
|
+
/^Cannot deliver: identity for [0-9a-f]+ is unknown$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How long {@link waitForPeerIdentity} waits for a solicited announce
|
|
36
|
+
* before giving up (and `sendWithRetry` falling back to its plain retries).
|
|
37
|
+
* Generous on purpose: the peer may be several slow mesh hops away, and the
|
|
38
|
+
* common trigger (the startup notification racing the owner's first
|
|
39
|
+
* announce after a daemon restart) is worth waiting for — the alternative
|
|
40
|
+
* parks the message in the bridge's `failedNote` until the *next* reply.
|
|
41
|
+
*/
|
|
42
|
+
const PEER_DISCOVERY_WAIT_MS = 30_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether `e` is the router's unknown-destination failure — the caller
|
|
46
|
+
* should solicit the peer (path request + announce) instead of retrying
|
|
47
|
+
* blind, since the retry cannot succeed until the announce lands.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} e
|
|
50
|
+
* @returns {e is Error}
|
|
51
|
+
*/
|
|
52
|
+
export function isUnknownIdentityError(e) {
|
|
53
|
+
return e instanceof Error && UNKNOWN_IDENTITY_MESSAGE.test(e.message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Waits until `transport` can recall the identity for `destinationHash`,
|
|
58
|
+
* soliciting it first: a path request makes the destination itself (or any
|
|
59
|
+
* transport node holding its path) announce, and the ingested announce
|
|
60
|
+
* populates the destination→identity mapping. Resolves early once an
|
|
61
|
+
* announce for the exact destination arrives, `false` on timeout.
|
|
62
|
+
*
|
|
63
|
+
* Closes the restart gap the router leaves open: `_establishDirectLink`
|
|
64
|
+
* only requests-and-awaits a path once the identity is *known*, so an
|
|
65
|
+
* unknown identity fails the whole `send` without any mesh solicitation.
|
|
66
|
+
* reticulum-js keeps `knownDestinations` in memory, so every daemon restart
|
|
67
|
+
* re-enters that state until the owner's next announce.
|
|
68
|
+
*
|
|
69
|
+
* @param {any} transport - `rns.transport` (EventTarget with
|
|
70
|
+
* `recallIdentity`, `requestPath`; tolerates missing methods for test
|
|
71
|
+
* doubles).
|
|
72
|
+
* @param {Uint8Array} destinationHash
|
|
73
|
+
* @param {number} timeoutMs
|
|
74
|
+
* @returns {Promise<boolean>} `true` when the identity is recallable on return.
|
|
75
|
+
*/
|
|
76
|
+
export async function waitForPeerIdentity(
|
|
77
|
+
transport,
|
|
78
|
+
destinationHash,
|
|
79
|
+
timeoutMs,
|
|
80
|
+
) {
|
|
81
|
+
const destHex = toHex(destinationHash);
|
|
82
|
+
const recall = () =>
|
|
83
|
+
Promise.resolve()
|
|
84
|
+
.then(() => transport?.recallIdentity(destinationHash))
|
|
85
|
+
.catch(() => null);
|
|
86
|
+
if (await recall()) return true;
|
|
87
|
+
try {
|
|
88
|
+
await transport?.requestPath?.(destinationHash);
|
|
89
|
+
} catch {
|
|
90
|
+
/* best effort — a late announce still has the timeout window */
|
|
91
|
+
}
|
|
92
|
+
if (await recall()) return true;
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
/** @type {NodeJS.Timeout|null} */
|
|
96
|
+
let timer = null;
|
|
97
|
+
const finish = (/** @type {boolean} */ ok) => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
if (timer) clearTimeout(timer);
|
|
101
|
+
transport.removeEventListener("announce", onAnnounce);
|
|
102
|
+
resolve(ok);
|
|
103
|
+
};
|
|
104
|
+
// The transport dispatches "announce" only after `rememberIdentity`
|
|
105
|
+
// completed, so a matching event implies a recallable identity; the
|
|
106
|
+
// re-check is belt-and-braces against half-fakes in tests.
|
|
107
|
+
const onAnnounce = (/** @type {any} */ ev) => {
|
|
108
|
+
const announced = ev?.detail?.destinationHash;
|
|
109
|
+
if (!announced || toHex(announced) !== destHex) return;
|
|
110
|
+
void recall().then((identity) => finish(Boolean(identity)));
|
|
111
|
+
};
|
|
112
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
113
|
+
transport.addEventListener("announce", onAnnounce);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Builds the outbound retry chain behind `sendText`/`sendReaction`:
|
|
119
|
+
*
|
|
120
|
+
* 1. `lxmf.send` over the given link (DIRECT; the router falls back to an
|
|
121
|
+
* opportunistic packet internally when no link can be established),
|
|
122
|
+
* 2. on the router's unknown-identity failure: solicit the destination
|
|
123
|
+
* (path request → announce) and wait for its announce — the restart
|
|
124
|
+
* race, since reticulum-js keeps the destination→identity map in
|
|
125
|
+
* memory and an immediate retry cannot succeed,
|
|
126
|
+
* 3. retry over the same link, then once more without it (the arrival
|
|
127
|
+
* link is usually gone by reply time on battery-conscious clients),
|
|
128
|
+
* 4. store-and-forward via the configured propagation node — the owner is
|
|
129
|
+
* likely off-mesh entirely; their next sync picks the message up.
|
|
130
|
+
*
|
|
131
|
+
* The same `LXMessage` object flows through every attempt so all wire
|
|
132
|
+
* copies share one message id and a deduplicating client renders the
|
|
133
|
+
* reply once. Factored out of `startLxmf` with injected dependencies so
|
|
134
|
+
* the chain is testable against a fake router.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} deps
|
|
137
|
+
* @param {LXMRouter} deps.lxmf - Initialised router.
|
|
138
|
+
* @param {Identity} deps.identity - The node's LXMF identity (signs sends).
|
|
139
|
+
* @param {string|null} [deps.propagationNodeHex] - Configured propagation
|
|
140
|
+
* node's `lxmf.propagation` hash; enables the store-and-forward fallback
|
|
141
|
+
* (reticulum-js's `send` never consults the outbound node on its own).
|
|
142
|
+
* @param {(msg: string) => void} [deps.log] - Diagnostic sink.
|
|
143
|
+
* @param {number} [deps.peerWaitMs] - Per-peer announce wait (overridable in tests).
|
|
144
|
+
* @returns {{sendWithRetry: (message: LXMessage, link?: any) => Promise<void>}}
|
|
145
|
+
*/
|
|
146
|
+
export function createRetrySender({
|
|
147
|
+
lxmf,
|
|
148
|
+
identity,
|
|
149
|
+
propagationNodeHex = null,
|
|
150
|
+
log = () => {},
|
|
151
|
+
peerWaitMs = PEER_DISCOVERY_WAIT_MS,
|
|
152
|
+
}) {
|
|
153
|
+
const propagationNodeHash = propagationNodeHex
|
|
154
|
+
? fromHex(propagationNodeHex)
|
|
155
|
+
: null;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Last-resort store-and-forward through the configured propagation
|
|
159
|
+
* node, reached from `sendWithRetry` after direct and opportunistic
|
|
160
|
+
* delivery both failed — typically the owner being off-mesh entirely
|
|
161
|
+
* (the mobile case). The propagated form is encrypted to the *recipient's*
|
|
162
|
+
* public key (`dest_hash ‖ E(src‖sig‖payload)`), so it needs their
|
|
163
|
+
* identity (by then known — the earlier sends failed on reachability,
|
|
164
|
+
* not identity) but **no live path**: the node holds the message until
|
|
165
|
+
* the owner's next sync. A node whose announce hasn't been heard yet
|
|
166
|
+
* (fresh start) is solicited and waited for like unknown recipients are.
|
|
167
|
+
*
|
|
168
|
+
* @param {LXMessage} message
|
|
169
|
+
* @param {Uint8Array} nodeHash - The configured node's `lxmf.propagation`
|
|
170
|
+
* hash (callers guarantee it is set).
|
|
171
|
+
*/
|
|
172
|
+
async function submitViaPropagationNode(message, nodeHash) {
|
|
173
|
+
const describe = (/** @type {unknown} */ e) =>
|
|
174
|
+
e instanceof Error ? e.message : String(e);
|
|
175
|
+
const nodeHex = toHex(nodeHash);
|
|
176
|
+
try {
|
|
177
|
+
try {
|
|
178
|
+
await lxmf.submitToPropagationNode(message, identity);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if (!/Propagation node identity unknown/.test(describe(e))) throw e;
|
|
181
|
+
log(
|
|
182
|
+
`pi-lxmf: propagation node ${nodeHex} unknown — requesting path, ` +
|
|
183
|
+
`waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
|
|
184
|
+
);
|
|
185
|
+
const learned = await waitForPeerIdentity(
|
|
186
|
+
lxmf.rns.transport,
|
|
187
|
+
nodeHash,
|
|
188
|
+
peerWaitMs,
|
|
189
|
+
);
|
|
190
|
+
if (!learned) throw e;
|
|
191
|
+
await lxmf.submitToPropagationNode(message, identity);
|
|
192
|
+
}
|
|
193
|
+
log(
|
|
194
|
+
"pi-lxmf: owner unreachable directly — submitted via propagation " +
|
|
195
|
+
"node (delivered on their next sync)",
|
|
196
|
+
);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
log(`pi-lxmf: propagation submit failed (${describe(e)})`);
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {LXMessage} message
|
|
205
|
+
* @param {any} [link]
|
|
206
|
+
*/
|
|
207
|
+
async function sendWithRetry(message, link) {
|
|
208
|
+
try {
|
|
209
|
+
await lxmf.send(message, identity, link);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
log(
|
|
212
|
+
`pi-lxmf: LXMF send failed (${e instanceof Error ? e.message : e}), retrying once`,
|
|
213
|
+
);
|
|
214
|
+
// The destination's identity is unknown (typically: the startup
|
|
215
|
+
// notification racing the owner's first announce after a restart —
|
|
216
|
+
// `knownDestinations` is in-memory in reticulum-js, so every restart
|
|
217
|
+
// forgets it). An immediate retry cannot succeed; solicit the peer
|
|
218
|
+
// and give its announce time to land first.
|
|
219
|
+
if (isUnknownIdentityError(e)) {
|
|
220
|
+
const destHex = toHex(message.destinationHash);
|
|
221
|
+
log(
|
|
222
|
+
`pi-lxmf: identity for ${destHex} unknown — requesting path, waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
|
|
223
|
+
);
|
|
224
|
+
const learned = await waitForPeerIdentity(
|
|
225
|
+
lxmf.rns.transport,
|
|
226
|
+
message.destinationHash,
|
|
227
|
+
peerWaitMs,
|
|
228
|
+
);
|
|
229
|
+
log(
|
|
230
|
+
learned
|
|
231
|
+
? `pi-lxmf: learned ${destHex} — retrying delivery`
|
|
232
|
+
: `pi-lxmf: no announce from ${destHex} in ${Math.round(peerWaitMs / 1000)}s — retrying anyway`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
await lxmf.send(message, identity, link);
|
|
237
|
+
} catch (e2) {
|
|
238
|
+
// The arrival link is likely gone (the peer closed it after its
|
|
239
|
+
// message was acknowledged). Retry without it: `LXMRouter.send`
|
|
240
|
+
// then establishes a fresh DIRECT link, falling back to an
|
|
241
|
+
// opportunistic packet. Same message object → same message id, so
|
|
242
|
+
// a deduplicating client renders the reply once.
|
|
243
|
+
log(
|
|
244
|
+
`pi-lxmf: link retry failed (${e2 instanceof Error ? e2.message : e2}), retrying without link`,
|
|
245
|
+
);
|
|
246
|
+
try {
|
|
247
|
+
await lxmf.send(message, identity, null);
|
|
248
|
+
} catch (e3) {
|
|
249
|
+
// Direct and opportunistic both failed: the owner is likely
|
|
250
|
+
// off-mesh. Store-and-forward via the configured propagation
|
|
251
|
+
// node instead of losing the reply (their next sync picks it
|
|
252
|
+
// up); without a configured node the failure stands.
|
|
253
|
+
if (!propagationNodeHash) throw e3;
|
|
254
|
+
await submitViaPropagationNode(message, propagationNodeHash);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { sendWithRetry };
|
|
261
|
+
}
|
|
262
|
+
|
|
25
263
|
/**
|
|
26
264
|
* Attaches diagnostic logging to the inbound LXMF choke points that the
|
|
27
265
|
* bridge itself can't see: packets that decrypt but never dispatch.
|
|
@@ -216,10 +454,17 @@ export async function startLxmf(config, options = {}) {
|
|
|
216
454
|
log(`pi-lxmf: announcing as "${config.name}"`);
|
|
217
455
|
|
|
218
456
|
// Optional propagation-node integration: outbound submits go through the
|
|
219
|
-
// node when a direct link
|
|
220
|
-
// pulls messages that arrived while
|
|
221
|
-
|
|
222
|
-
|
|
457
|
+
// node when neither a direct link nor opportunistic delivery can be
|
|
458
|
+
// established, and a periodic sync pulls messages that arrived while
|
|
459
|
+
// this daemon was down. (reticulum-js's `send` never consults the
|
|
460
|
+
// outbound node on its own — `submitToPropagationNode` is an explicit
|
|
461
|
+
// call — so the store-and-forward fallback in `sendWithRetry` below is
|
|
462
|
+
// what makes the config effective.)
|
|
463
|
+
const propagationNodeHash = config.propagationNode
|
|
464
|
+
? fromHex(config.propagationNode)
|
|
465
|
+
: null;
|
|
466
|
+
if (propagationNodeHash) {
|
|
467
|
+
lxmf.setOutboundPropagationNode(propagationNodeHash);
|
|
223
468
|
log(`pi-lxmf: outbound propagation node ${config.propagationNode}`);
|
|
224
469
|
}
|
|
225
470
|
/** @type {NodeJS.Timeout|null} */
|
|
@@ -243,15 +488,26 @@ export async function startLxmf(config, options = {}) {
|
|
|
243
488
|
log(`pi-lxmf: propagation sync every ${config.syncIntervalSec}s`);
|
|
244
489
|
}
|
|
245
490
|
|
|
491
|
+
// The outbound retry chain shared by sendText/sendReaction (see
|
|
492
|
+
// createRetrySender for the escalation order).
|
|
493
|
+
const { sendWithRetry } = createRetrySender({
|
|
494
|
+
lxmf,
|
|
495
|
+
identity,
|
|
496
|
+
propagationNodeHex: config.propagationNode ?? null,
|
|
497
|
+
log,
|
|
498
|
+
});
|
|
499
|
+
|
|
246
500
|
/**
|
|
247
501
|
* Sends `text` to `destinationHex` (a 32-hex lxmf.delivery source hash),
|
|
248
502
|
* chunked to `chunkChars`, titled on the first chunk. A failed send is
|
|
249
503
|
* retried once over the same path, then once more opportunistically
|
|
250
|
-
* (without the link)
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* reply
|
|
504
|
+
* (without the link), and finally submitted to the configured
|
|
505
|
+
* propagation node for store-and-forward — see {@link createRetrySender}
|
|
506
|
+
* for the full escalation order. Battery-conscious mobile clients tear
|
|
507
|
+
* their link down right after their message is acknowledged, so the
|
|
508
|
+
* arrival link can be gone by reply time; the same `LXMessage` object is
|
|
509
|
+
* re-sent so all wire copies share one message id and a deduplicating
|
|
510
|
+
* client shows the reply once (learned in signalk-reticulum's deliverer).
|
|
255
511
|
*
|
|
256
512
|
* @param {string} destinationHex
|
|
257
513
|
* @param {string} text
|
|
@@ -312,33 +568,6 @@ export async function startLxmf(config, options = {}) {
|
|
|
312
568
|
await sendWithRetry(message, sendOptions.link);
|
|
313
569
|
}
|
|
314
570
|
|
|
315
|
-
/**
|
|
316
|
-
* @param {LXMessage} message
|
|
317
|
-
* @param {any} [link]
|
|
318
|
-
*/
|
|
319
|
-
async function sendWithRetry(message, link) {
|
|
320
|
-
try {
|
|
321
|
-
await lxmf.send(message, identity, link);
|
|
322
|
-
} catch (e) {
|
|
323
|
-
log(
|
|
324
|
-
`pi-lxmf: LXMF send failed (${e instanceof Error ? e.message : e}), retrying once`,
|
|
325
|
-
);
|
|
326
|
-
try {
|
|
327
|
-
await lxmf.send(message, identity, link);
|
|
328
|
-
} catch (e2) {
|
|
329
|
-
// The arrival link is likely gone (the peer closed it after its
|
|
330
|
-
// message was acknowledged). Retry without it: `LXMRouter.send`
|
|
331
|
-
// then establishes a fresh DIRECT link, falling back to an
|
|
332
|
-
// opportunistic packet. Same message object → same message id, so
|
|
333
|
-
// a deduplicating client renders the reply once.
|
|
334
|
-
log(
|
|
335
|
-
`pi-lxmf: link retry failed (${e2 instanceof Error ? e2.message : e2}), retrying without link`,
|
|
336
|
-
);
|
|
337
|
-
await lxmf.send(message, identity, null);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
571
|
/**
|
|
343
572
|
* Verifies the signature of an inbound `message` against the sender's
|
|
344
573
|
* recalled identity. The router verifies signatures on the direct-delivery
|
package/src/quota.js
CHANGED
|
@@ -3,15 +3,27 @@
|
|
|
3
3
|
*
|
|
4
4
|
* z.ai (GLM Coding Plan) quota watcher + peak-hours warning (work doc #3).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Three related behaviours, all gated on the active model being a z.ai GLM
|
|
7
7
|
* model (`provider === "zai"`):
|
|
8
8
|
*
|
|
9
|
-
* 1. **
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* 1. **Continuous quota sampling.** While a GLM model is active, the same
|
|
10
|
+
* z.ai quota endpoint `pi-glm-usage` uses is polled every 60s. From
|
|
11
|
+
* those samples the owner is notified of:
|
|
12
|
+
* - **Exhaustion** (once per episode): a bucket (5h or weekly) reaches
|
|
13
|
+
* 100% — including at startup, when a daemon restarts mid-outage.
|
|
14
|
+
* - **90% warning** (once per bucket window): a bucket crosses 90%,
|
|
15
|
+
* while still below 100%.
|
|
16
|
+
* - **Recovery** (once per episode): the 5h bucket drops below 100%
|
|
17
|
+
* after an exhausted episode.
|
|
18
|
+
* Per-sample notifications are joined into a single LXMF message and
|
|
19
|
+
* deferred to `agent_settled` while an owner-triggered run is live.
|
|
13
20
|
*
|
|
14
|
-
* 2. **
|
|
21
|
+
* 2. **Quota-error arming.** A Pi error that looks like a z.ai
|
|
22
|
+
* quota-exhausted failure (`auto_retry_end`/`compaction_end`) arms the
|
|
23
|
+
* poller immediately, even when the live fetch is momentarily
|
|
24
|
+
* inconclusive — the authoritative answer is the next sample.
|
|
25
|
+
*
|
|
26
|
+
* 3. **Peak-hours warning.** z.ai charges 3× tokens during peak hours
|
|
15
27
|
* (Mon–Fri 14:00–18:00 Singapore Standard Time, UTC+8). Warn the owner
|
|
16
28
|
* when an owner-triggered run starts inside that window, and when the
|
|
17
29
|
* window opens mid-run, so they can decide whether to stop or continue.
|
|
@@ -30,7 +42,7 @@ import { join } from "node:path";
|
|
|
30
42
|
const QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
|
|
31
43
|
/** Fetch timeout for the quota endpoint (ms). */
|
|
32
44
|
const FETCH_TIMEOUT_MS = 5000;
|
|
33
|
-
/**
|
|
45
|
+
/** Sampling cadence while a GLM model is active (ms). */
|
|
34
46
|
const POLL_INTERVAL_MS = 60_000;
|
|
35
47
|
|
|
36
48
|
/**
|
|
@@ -56,9 +68,11 @@ const Unit = {
|
|
|
56
68
|
};
|
|
57
69
|
|
|
58
70
|
/**
|
|
59
|
-
*
|
|
71
|
+
* A bucket is treated as exhausted at-or-above this percentage.
|
|
60
72
|
*/
|
|
61
73
|
const EXHAUSTED_PERCENTAGE = 100;
|
|
74
|
+
/** Warning threshold (crossing upward, while not exhausted). */
|
|
75
|
+
const WARN_PERCENTAGE = 90;
|
|
62
76
|
|
|
63
77
|
/** z.ai provider id (and its `z.ai` alias, defensively). */
|
|
64
78
|
const ZAI_PROVIDERS = new Set(["zai", "z.ai"]);
|
|
@@ -196,7 +210,6 @@ export function isPeakTime(epochMs) {
|
|
|
196
210
|
*/
|
|
197
211
|
export function msUntilPeakOpen(fromMs) {
|
|
198
212
|
if (isPeakTime(fromMs)) return 0;
|
|
199
|
-
const from = new Date(fromMs);
|
|
200
213
|
// Walk forward hour by hour (max ~7 days) to the first peak hour.
|
|
201
214
|
for (let h = 0; h < 24 * 7; h++) {
|
|
202
215
|
const probe = new Date(fromMs + h * 3_600_000);
|
|
@@ -222,11 +235,35 @@ export function msUntilPeakOpen(fromMs) {
|
|
|
222
235
|
return Number.POSITIVE_INFINITY;
|
|
223
236
|
}
|
|
224
237
|
|
|
238
|
+
/**
|
|
239
|
+
* A live or pinned quota sample for one bucket. `percentage` may exceed
|
|
240
|
+
* `WARN_PERCENTAGE`/`EXHAUSTED_PERCENTAGE` by z.ai's rounding; comparisons
|
|
241
|
+
* are inclusive.
|
|
242
|
+
*
|
|
243
|
+
* @typedef {{percentage: number, nextResetMs: number}} BucketSample
|
|
244
|
+
*/
|
|
245
|
+
|
|
225
246
|
/**
|
|
226
247
|
* The z.ai quota watcher + peak-hours warner. Construct one per bridge;
|
|
227
248
|
* drive it with {@link GlmQuotaWatcher.setEnabled} (gated on the active
|
|
228
|
-
* model) and {@link GlmQuotaWatcher.onAgentStart} /
|
|
229
|
-
* GlmQuotaWatcher.onAgentSettled} / {@link GlmQuotaWatcher.onError}.
|
|
249
|
+
* model) and {@link GlmQuotaWatcher.onAgentStart} /
|
|
250
|
+
* {@link GlmQuotaWatcher.onAgentSettled} / {@link GlmQuotaWatcher.onError}.
|
|
251
|
+
*
|
|
252
|
+
* While enabled, a single sampler polls the quota endpoint every
|
|
253
|
+
* `pollIntervalMs` (first sample immediately). Each sample can queue at
|
|
254
|
+
* most one joined notice:
|
|
255
|
+
* - a bucket crossing `WARN_PERCENTAGE` (once per bucket window) queues
|
|
256
|
+
* a "90%" warning line;
|
|
257
|
+
* - a bucket reaching `EXHAUSTED_PERCENTAGE` (once per bucket episode)
|
|
258
|
+
* queues an "exhausted" line with the reset time;
|
|
259
|
+
* - the 5h bucket recovering from an exhausted episode (once per episode)
|
|
260
|
+
* queues a "recovered" line.
|
|
261
|
+
*
|
|
262
|
+
* A run-triggered error ({@link GlmQuotaWatcher.onError}) additionally
|
|
263
|
+
* forces an immediate sample (fresh state over stale-sampler lag).
|
|
264
|
+
* Notices are delivered as one LXMF message per emission — immediately
|
|
265
|
+
* when the bridge is idle, or when the live owner-triggered run settles
|
|
266
|
+
* (so they never interleave with a reply mid-run).
|
|
230
267
|
*/
|
|
231
268
|
export class GlmQuotaWatcher {
|
|
232
269
|
/**
|
|
@@ -238,6 +275,7 @@ export class GlmQuotaWatcher {
|
|
|
238
275
|
* @param {typeof fetch} [options.fetchImpl] - Injectable fetch (tests).
|
|
239
276
|
* @param {() => number} [options.now] - Injectable clock (tests).
|
|
240
277
|
* @param {number} [options.pollIntervalMs]
|
|
278
|
+
* @param {number} [options.warnPercentage] - Warning threshold (tests).
|
|
241
279
|
*/
|
|
242
280
|
constructor(options) {
|
|
243
281
|
this.ownerDestinationHash = options.ownerDestinationHash;
|
|
@@ -247,6 +285,7 @@ export class GlmQuotaWatcher {
|
|
|
247
285
|
this.fetchImpl = options.fetchImpl || fetch;
|
|
248
286
|
this.now = options.now || (() => Date.now());
|
|
249
287
|
this.pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
288
|
+
this.warnPercentage = options.warnPercentage ?? WARN_PERCENTAGE;
|
|
250
289
|
|
|
251
290
|
/** Whether the active model is a z.ai GLM model (the master gate). */
|
|
252
291
|
this.enabled = false;
|
|
@@ -254,15 +293,28 @@ export class GlmQuotaWatcher {
|
|
|
254
293
|
this.runActive = false;
|
|
255
294
|
/** Whether the current run is owner-triggered (not recovery). */
|
|
256
295
|
this.ownerTriggered = false;
|
|
257
|
-
/** Single active
|
|
296
|
+
/** Single active sampler (idempotent start). */
|
|
258
297
|
/** @type {NodeJS.Timeout|null} */
|
|
259
298
|
this.pollTimer = null;
|
|
260
|
-
/** Whether the 5h bucket was exhausted
|
|
299
|
+
/** Whether the 5h bucket was exhausted in the last sample. */
|
|
261
300
|
this.wasExhausted = false;
|
|
262
|
-
/** Whether a recovery notification has already been sent for this episode. */
|
|
263
|
-
this.notifiedThisEpisode = false;
|
|
264
301
|
/** Epoch (ms) the exhaustion episode started, for the human-readable delta. */
|
|
265
302
|
this.exhaustedSinceMs = 0;
|
|
303
|
+
/**
|
|
304
|
+
* Whether the 90% warning already fired for each bucket in its current
|
|
305
|
+
* window (a window is any contiguous below-100% stretch — a reset to
|
|
306
|
+
* below-warn clears it, an exhausted episode ends it).
|
|
307
|
+
* @type {{fiveHour: boolean, weekly: boolean}}
|
|
308
|
+
*/
|
|
309
|
+
this.warned = { fiveHour: false, weekly: false };
|
|
310
|
+
/** Whether an exhausted episode per bucket already notified (its
|
|
311
|
+
* "exhausted" notice is once per episode).
|
|
312
|
+
* @type {{fiveHour: boolean, weekly: boolean}}
|
|
313
|
+
*/
|
|
314
|
+
this.exhaustionNotified = { fiveHour: false, weekly: false };
|
|
315
|
+
/** Notices queued while a run is live, delivered on `onAgentSettled`. */
|
|
316
|
+
/** @type {string[]} */
|
|
317
|
+
this.pendingNotices = [];
|
|
266
318
|
/** Whether we've already warned about peak for the current run. */
|
|
267
319
|
this.peakWarnedThisRun = false;
|
|
268
320
|
/** Timer for the "run ran into peak" boundary warning. */
|
|
@@ -276,7 +328,12 @@ export class GlmQuotaWatcher {
|
|
|
276
328
|
|
|
277
329
|
/**
|
|
278
330
|
* Master gate: enable/disable based on whether the active model is z.ai.
|
|
279
|
-
*
|
|
331
|
+
* Enabling starts the sampler immediately (first sample right away), so
|
|
332
|
+
* an exhausted state at startup or model switch is detected and
|
|
333
|
+
* notified without waiting for a run or an error. Disabling stops all
|
|
334
|
+
* timers, clears run state, and (best-effort) delivers anything already
|
|
335
|
+
* queued — losing queued notices on shutdown is acceptable, but losing
|
|
336
|
+
* them on a model switch is not.
|
|
280
337
|
*
|
|
281
338
|
* @param {boolean} enabled
|
|
282
339
|
*/
|
|
@@ -289,10 +346,9 @@ export class GlmQuotaWatcher {
|
|
|
289
346
|
this.runActive = false;
|
|
290
347
|
this.ownerTriggered = false;
|
|
291
348
|
this.peakWarnedThisRun = false;
|
|
349
|
+
this.flushNotices();
|
|
292
350
|
} else {
|
|
293
|
-
|
|
294
|
-
// mid-outage arms the watcher immediately.
|
|
295
|
-
void this.checkAndMaybeArm(false);
|
|
351
|
+
this.startPoller(true);
|
|
296
352
|
}
|
|
297
353
|
}
|
|
298
354
|
|
|
@@ -315,11 +371,14 @@ export class GlmQuotaWatcher {
|
|
|
315
371
|
this.ownerTriggered = false;
|
|
316
372
|
this.peakWarnedThisRun = false;
|
|
317
373
|
this.clearPeakOpenTimer();
|
|
374
|
+
this.flushNotices();
|
|
318
375
|
}
|
|
319
376
|
|
|
320
377
|
/**
|
|
321
378
|
* Called when a Pi error event (`auto_retry_end`/`compaction_end`) looks
|
|
322
|
-
* like a quota failure.
|
|
379
|
+
* like a quota failure. Ensures the sampler is running (idempotent) and
|
|
380
|
+
* forces an immediate sample so the exhaustion notice is driven by the
|
|
381
|
+
* authoritative API state, not the sampler's cadence.
|
|
323
382
|
*
|
|
324
383
|
* @param {string} errorMessage
|
|
325
384
|
*/
|
|
@@ -329,7 +388,8 @@ export class GlmQuotaWatcher {
|
|
|
329
388
|
this.log.log(
|
|
330
389
|
`pi-lxmf: GLM quota error detected, polling for recovery: ${errorMessage}`,
|
|
331
390
|
);
|
|
332
|
-
|
|
391
|
+
this.startPoller();
|
|
392
|
+
void this.tick();
|
|
333
393
|
}
|
|
334
394
|
|
|
335
395
|
/**
|
|
@@ -341,114 +401,144 @@ export class GlmQuotaWatcher {
|
|
|
341
401
|
}
|
|
342
402
|
|
|
343
403
|
/**
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
404
|
+
* Starts the sampler if not already running. `immediate` also fires one
|
|
405
|
+
* sample right away (startup, model switch, quota error) instead of
|
|
406
|
+
* waiting a full interval.
|
|
347
407
|
*
|
|
348
|
-
* @param {boolean}
|
|
349
|
-
* @
|
|
408
|
+
* @param {boolean} [immediate]
|
|
409
|
+
* @private
|
|
350
410
|
*/
|
|
351
|
-
|
|
411
|
+
startPoller(immediate) {
|
|
412
|
+
if (this.pollTimer || !this.enabled || !this.apiKey) return;
|
|
413
|
+
this.pollTimer = setInterval(() => void this.tick(), this.pollIntervalMs);
|
|
414
|
+
if (typeof this.pollTimer.unref === "function") this.pollTimer.unref();
|
|
415
|
+
if (immediate) void this.tick();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* One sampler tick: fetch quota, evaluate bucket transitions, queue
|
|
420
|
+
* notices for anything new.
|
|
421
|
+
*
|
|
422
|
+
* @private
|
|
423
|
+
*/
|
|
424
|
+
async tick() {
|
|
352
425
|
if (!this.enabled || !this.apiKey) return;
|
|
353
426
|
let quota = null;
|
|
354
427
|
try {
|
|
355
428
|
quota = await fetchQuota(this.apiKey, { fetchImpl: this.fetchImpl });
|
|
356
|
-
} catch
|
|
357
|
-
|
|
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
|
-
}
|
|
429
|
+
} catch {
|
|
430
|
+
/* transient — retry on the next tick */
|
|
365
431
|
return;
|
|
366
432
|
}
|
|
367
|
-
|
|
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
|
-
}
|
|
433
|
+
this.evaluateSample(quota);
|
|
374
434
|
}
|
|
375
435
|
|
|
376
436
|
/**
|
|
377
|
-
*
|
|
378
|
-
*
|
|
437
|
+
* Turns a fresh quota sample into (at most one) queued notice, based on
|
|
438
|
+
* per-bucket window/episode transitions.
|
|
379
439
|
*
|
|
380
|
-
* @param {
|
|
440
|
+
* @param {{fiveHour?: BucketSample|null, weekly?: BucketSample|null}} quota
|
|
441
|
+
* @private
|
|
381
442
|
*/
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
443
|
+
evaluateSample(quota) {
|
|
444
|
+
const pct = quota.fiveHour?.percentage ?? 0;
|
|
445
|
+
const weeklyPct = quota.weekly?.percentage ?? 0;
|
|
446
|
+
const exhausted = pct >= EXHAUSTED_PERCENTAGE;
|
|
447
|
+
const weeklyExhausted = weeklyPct >= EXHAUSTED_PERCENTAGE;
|
|
448
|
+
|
|
449
|
+
/** @type {string[]} */
|
|
450
|
+
const lines = [];
|
|
451
|
+
|
|
452
|
+
// --- 5h bucket ------------------------------------------------------
|
|
453
|
+
if (exhausted) {
|
|
454
|
+
if (!this.wasExhausted) {
|
|
455
|
+
// New exhausted episode: stamp it, re-arm its notices.
|
|
456
|
+
this.wasExhausted = true;
|
|
457
|
+
this.exhaustedSinceMs = this.now();
|
|
458
|
+
this.exhaustionNotified.fiveHour = false;
|
|
459
|
+
// Suppress the 90% warning for the rest of this window: the
|
|
460
|
+
// exhaustion (and its recovery) notices say everything already.
|
|
461
|
+
this.warned.fiveHour = true;
|
|
462
|
+
}
|
|
463
|
+
if (!this.exhaustionNotified.fiveHour) {
|
|
464
|
+
this.exhaustionNotified.fiveHour = true;
|
|
465
|
+
lines.push(
|
|
466
|
+
`⚠️ GLM 5h quota exhausted (100%)${resetSuffix(quota.fiveHour?.nextResetMs, this.now())}`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
if (this.wasExhausted) {
|
|
471
|
+
// Recovered below 100% after an exhausted episode (once per episode).
|
|
472
|
+
this.wasExhausted = false;
|
|
473
|
+
const elapsed = this.exhaustedSinceMs
|
|
474
|
+
? this.now() - this.exhaustedSinceMs
|
|
475
|
+
: 0;
|
|
476
|
+
lines.push(
|
|
477
|
+
`✅ GLM 5h quota available again${elapsed > 0 ? ` (was exhausted for ~${formatElapsed(elapsed)})` : ""}. Weekly: ${weeklyPct}%.`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
if (pct < this.warnPercentage) {
|
|
481
|
+
// Below the threshold: re-arm the warning for the next window.
|
|
482
|
+
this.warned.fiveHour = false;
|
|
483
|
+
} else if (!this.warned.fiveHour) {
|
|
484
|
+
// At-or-above the threshold but not exhausted: warn once per window
|
|
485
|
+
// (a dip below the threshold re-arms the warning).
|
|
486
|
+
this.warned.fiveHour = true;
|
|
487
|
+
lines.push(`⚠️ GLM 5h quota at ${pct}% — getting close.`);
|
|
488
|
+
}
|
|
390
489
|
}
|
|
391
|
-
this.startPoller();
|
|
392
|
-
}
|
|
393
490
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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 */
|
|
491
|
+
// --- weekly bucket ----------------------------------------------------
|
|
492
|
+
if (weeklyExhausted) {
|
|
493
|
+
if (!this.exhaustionNotified.weekly) {
|
|
494
|
+
this.exhaustionNotified.weekly = true;
|
|
495
|
+
this.warned.weekly = false;
|
|
496
|
+
lines.push(
|
|
497
|
+
`⚠️ GLM weekly quota exhausted (100%)${resetSuffix(quota.weekly?.nextResetMs, this.now())}`,
|
|
498
|
+
);
|
|
409
499
|
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
500
|
+
} else if (weeklyPct >= this.warnPercentage && !this.warned.weekly) {
|
|
501
|
+
this.warned.weekly = true;
|
|
502
|
+
lines.push(`⚠️ GLM weekly quota at ${weeklyPct}% — getting close.`);
|
|
503
|
+
} else if (weeklyPct < this.warnPercentage) {
|
|
504
|
+
this.warned.weekly = false;
|
|
505
|
+
}
|
|
414
506
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
clearInterval(this.pollTimer);
|
|
419
|
-
this.pollTimer = null;
|
|
507
|
+
if (lines.length > 0) {
|
|
508
|
+
this.log.log(`pi-lxmf: GLM quota notice: ${lines.join(" | ")}`);
|
|
509
|
+
this.queueNotice(lines.join("\n"));
|
|
420
510
|
}
|
|
421
511
|
}
|
|
422
512
|
|
|
423
513
|
/**
|
|
424
|
-
*
|
|
514
|
+
* Queues a notice; queued notices are delivered when the bridge is idle
|
|
515
|
+
* or, during a live owner-triggered run, on `agent_settled` (never
|
|
516
|
+
* interleaving with a reply mid-run).
|
|
425
517
|
*
|
|
426
|
-
* @param {
|
|
518
|
+
* @param {string} text
|
|
519
|
+
* @private
|
|
427
520
|
*/
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
this.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
try {
|
|
446
|
-
await this.sendText(this.ownerDestinationHash, parts.join(" "));
|
|
447
|
-
} catch (e) {
|
|
521
|
+
queueNotice(text) {
|
|
522
|
+
this.pendingNotices.push(text);
|
|
523
|
+
this.flushNotices();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Delivers queued notices as one message, when no owner-triggered run
|
|
528
|
+
* is live. Delivery is best-effort; a failure is logged once.
|
|
529
|
+
*
|
|
530
|
+
* @private
|
|
531
|
+
*/
|
|
532
|
+
flushNotices() {
|
|
533
|
+
if (this.pendingNotices.length === 0) return;
|
|
534
|
+
if (this.runActive && this.ownerTriggered) return;
|
|
535
|
+
const text = this.pendingNotices.join("\n\n");
|
|
536
|
+
this.pendingNotices = [];
|
|
537
|
+
this.sendText(this.ownerDestinationHash, text).catch((e) => {
|
|
448
538
|
this.log.error(
|
|
449
539
|
`pi-lxmf: GLM quota notification delivery failed: ${e instanceof Error ? e.message : e}`,
|
|
450
540
|
);
|
|
451
|
-
}
|
|
541
|
+
});
|
|
452
542
|
}
|
|
453
543
|
|
|
454
544
|
/**
|
|
@@ -500,6 +590,27 @@ export class GlmQuotaWatcher {
|
|
|
500
590
|
this.peakOpenTimer = null;
|
|
501
591
|
}
|
|
502
592
|
}
|
|
593
|
+
|
|
594
|
+
/** Stops the sampler. */
|
|
595
|
+
stopPoller() {
|
|
596
|
+
if (this.pollTimer) {
|
|
597
|
+
clearInterval(this.pollTimer);
|
|
598
|
+
this.pollTimer = null;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Human-readable "resets in" suffix from a bucket's `nextResetMs`.
|
|
605
|
+
*
|
|
606
|
+
* @param {number|undefined} nextResetMs
|
|
607
|
+
* @param {number} nowMs
|
|
608
|
+
* @returns {string}
|
|
609
|
+
*/
|
|
610
|
+
function resetSuffix(nextResetMs, nowMs) {
|
|
611
|
+
if (!nextResetMs || nextResetMs <= nowMs) return "";
|
|
612
|
+
const ms = nextResetMs - nowMs;
|
|
613
|
+
return ` — resets in ~${formatElapsed(ms)}`;
|
|
503
614
|
}
|
|
504
615
|
|
|
505
616
|
/** @param {number} ms */
|