sidekick-shared 0.21.0 → 0.21.2

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.
@@ -0,0 +1,260 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ZaiQuotaWatcher = void 0;
4
+ /**
5
+ * Deprecated event-driven watcher that derives a z.ai coding-plan
6
+ * `QuotaState` from observed OpenCode assistant turns.
7
+ *
8
+ * This watcher is retained for compatibility. Product code now uses
9
+ * `zaiQuotaApi.ts` to call z.ai's authoritative quota endpoint. The legacy
10
+ * watcher maintains rolling 5-hour / 7-day windows in memory and recomputes a
11
+ * derived `QuotaState` whenever a new turn arrives. The consumer is
12
+ * responsible for feeding it z.ai-routed assistant turns (and any trapped
13
+ * error events) — typically by subscribing to `SessionMonitor.onSessionEvent`
14
+ * and filtering on `providerID ∈ {zai, zai-coding-plan}`.
15
+ *
16
+ * Optionally writes snapshots + history samples for cross-session cache
17
+ * and trend visualisation, mirroring `CodexQuotaWatcher`.
18
+ */
19
+ const quotaHistory_1 = require("./quotaHistory");
20
+ const quotaSnapshots_1 = require("./quotaSnapshots");
21
+ const zaiQuota_1 = require("./zaiQuota");
22
+ const DEFAULT_ACCOUNT_ID = 'default';
23
+ const DEFAULT_RECOMPUTE_DEBOUNCE_MS = 1_000;
24
+ const DEFAULT_PRUNE_INTERVAL_MS = 60_000;
25
+ const SEVEN_DAY_MS = 7 * 86_400_000;
26
+ function enrichZaiState(state) {
27
+ return {
28
+ ...state,
29
+ runtimeProvider: 'zai',
30
+ providerId: 'zai',
31
+ };
32
+ }
33
+ /**
34
+ * Watches z.ai-routed OpenCode activity and emits derived `QuotaState`.
35
+ *
36
+ * The watcher is event-driven (not a poller): call `ingestAssistantTurn`
37
+ * for every observed z.ai assistant turn, and `ingestError` whenever a
38
+ * z.ai business error is observed. The watcher debounces recompute and
39
+ * emits via `onUpdate` / `onQuotaUpdate`.
40
+ */
41
+ class ZaiQuotaWatcher {
42
+ tier;
43
+ resolveTier;
44
+ readSnapshot;
45
+ writeSnapshot;
46
+ appendHistorySample;
47
+ workspaceId;
48
+ accountId;
49
+ recomputeDebounceMs;
50
+ pruneIntervalMs;
51
+ now;
52
+ listeners = [];
53
+ /** In-memory rolling 7-day buffer of observed turns. */
54
+ turns = [];
55
+ /** Most recent authoritative 5h reset (parsed from a z.ai error event). */
56
+ authoritativeFiveHourResetAt;
57
+ /** Most recent authoritative weekly reset (parsed from a z.ai error event). */
58
+ authoritativeWeeklyResetAt;
59
+ /** Most recent trapped error (so consumers can show "rate-limited until X"). */
60
+ lastError = null;
61
+ lastEmissionKey = null;
62
+ pendingRecomputeTimer;
63
+ pruneTimer;
64
+ running = false;
65
+ constructor(options = {}) {
66
+ this.tier = options.tier ?? 'auto';
67
+ this.resolveTier = options.resolveTier ?? zaiQuota_1.resolveZaiTier;
68
+ this.readSnapshot = options.readSnapshot ?? quotaSnapshots_1.readQuotaSnapshot;
69
+ this.writeSnapshot = options.writeSnapshot ?? quotaSnapshots_1.writeQuotaSnapshot;
70
+ this.appendHistorySample = options.appendHistorySample ?? quotaHistory_1.appendQuotaHistorySample;
71
+ this.workspaceId = options.workspaceId;
72
+ this.accountId = options.accountId ?? DEFAULT_ACCOUNT_ID;
73
+ this.recomputeDebounceMs = options.recomputeDebounceMs ?? DEFAULT_RECOMPUTE_DEBOUNCE_MS;
74
+ this.pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;
75
+ this.now = options.now ?? Date.now;
76
+ }
77
+ start() {
78
+ if (this.running)
79
+ return;
80
+ this.running = true;
81
+ // Emit cached state immediately so consumers don't show "unavailable"
82
+ // during the gap before the first z.ai turn arrives.
83
+ this.emitCachedOrUnavailable();
84
+ this.pruneTimer = setInterval(() => this.pruneOldTurns(), this.pruneIntervalMs);
85
+ }
86
+ stop() {
87
+ if (!this.running)
88
+ return;
89
+ this.running = false;
90
+ if (this.pendingRecomputeTimer) {
91
+ clearTimeout(this.pendingRecomputeTimer);
92
+ this.pendingRecomputeTimer = undefined;
93
+ }
94
+ if (this.pruneTimer) {
95
+ clearInterval(this.pruneTimer);
96
+ this.pruneTimer = undefined;
97
+ }
98
+ }
99
+ dispose() {
100
+ this.stop();
101
+ this.listeners.splice(0, this.listeners.length);
102
+ }
103
+ onUpdate(cb) {
104
+ this.listeners.push(cb);
105
+ return {
106
+ dispose: () => {
107
+ const index = this.listeners.indexOf(cb);
108
+ if (index >= 0)
109
+ this.listeners.splice(index, 1);
110
+ },
111
+ };
112
+ }
113
+ onQuotaUpdate(cb) {
114
+ return this.onUpdate(cb);
115
+ }
116
+ /** Feed an observed z.ai assistant turn. Schedules a debounced recompute. */
117
+ ingestAssistantTurn(turn) {
118
+ this.turns.push(turn);
119
+ this.scheduleRecompute();
120
+ }
121
+ /**
122
+ * Feed multiple turns at once (used for cold-start seeding from the DB).
123
+ * Schedules a single debounced recompute.
124
+ */
125
+ ingestAssistantTurns(turns) {
126
+ for (const turn of turns)
127
+ this.turns.push(turn);
128
+ this.scheduleRecompute();
129
+ }
130
+ /** Feed a z.ai error event. Triggers an immediate recompute + emit. */
131
+ ingestError(error) {
132
+ const parsed = (0, zaiQuota_1.parseZaiQuotaError)(error);
133
+ if (!parsed)
134
+ return false;
135
+ this.lastError = parsed;
136
+ if (parsed.resetsAt) {
137
+ // The first observed error after the user crosses a threshold tells
138
+ // us the authoritative window boundary. Apply to whichever window
139
+ // the kind maps to.
140
+ if (parsed.kind === 'exhausted') {
141
+ this.authoritativeFiveHourResetAt = parsed.resetsAt;
142
+ // 1310 is "weekly/monthly exhausted" — also pin the weekly window.
143
+ if (String(parsed.code) === '1310') {
144
+ this.authoritativeWeeklyResetAt = parsed.resetsAt;
145
+ }
146
+ }
147
+ }
148
+ this.scheduleRecompute();
149
+ return true;
150
+ }
151
+ /** Force a recompute+emit now (skipping the debounce). Used by tests. */
152
+ refresh() {
153
+ this.recomputeAndEmit();
154
+ }
155
+ /** Current in-memory turn count (testing/diagnostics). */
156
+ get bufferedTurnCount() {
157
+ return this.turns.length;
158
+ }
159
+ // ── internals ──
160
+ scheduleRecompute() {
161
+ if (!this.running)
162
+ return;
163
+ if (this.pendingRecomputeTimer)
164
+ clearTimeout(this.pendingRecomputeTimer);
165
+ this.pendingRecomputeTimer = setTimeout(() => {
166
+ this.pendingRecomputeTimer = undefined;
167
+ this.recomputeAndEmit();
168
+ }, this.recomputeDebounceMs);
169
+ }
170
+ recomputeAndEmit() {
171
+ const nowMs = this.now();
172
+ const accumulated = (0, zaiQuota_1.accumulateZaiUsage)(this.turns, nowMs);
173
+ const tier = this.resolveTier(this.tier, accumulated);
174
+ const state = accumulated.fiveHourTurns > 0 || accumulated.weeklyTurns > 0
175
+ ? (0, zaiQuota_1.inferZaiQuotaState)(accumulated, tier, {
176
+ capturedAt: new Date(nowMs).toISOString(),
177
+ stale: false,
178
+ authoritativeFiveHourResetAt: this.authoritativeFiveHourResetAt,
179
+ authoritativeWeeklyResetAt: this.authoritativeWeeklyResetAt,
180
+ })
181
+ : (0, zaiQuota_1.makeUnavailableZaiQuotaState)(undefined, tier, new Date(nowMs).toISOString());
182
+ // If we recently saw an error, override availability so the UI shows it.
183
+ if (this.lastError) {
184
+ state.error = this.lastError.message;
185
+ state.failureKind = this.lastError.kind === 'expired' ? 'auth' : 'rate_limit';
186
+ state.rateLimitReachedType = this.lastError.kind;
187
+ }
188
+ this.writeSnapshot('zai', this.accountId, state);
189
+ this.maybeAppendHistory(state);
190
+ this.emitState(enrichZaiState(state));
191
+ }
192
+ maybeAppendHistory(state) {
193
+ if (!this.workspaceId)
194
+ return;
195
+ const sample = {
196
+ timestamp: state.capturedAt ?? new Date(this.now()).toISOString(),
197
+ runtimeProvider: 'zai',
198
+ providerId: this.accountId,
199
+ workspaceId: this.workspaceId,
200
+ fiveHour: { utilization: state.fiveHour.utilization, resetsAt: state.fiveHour.resetsAt },
201
+ sevenDay: { utilization: state.sevenDay.utilization, resetsAt: state.sevenDay.resetsAt },
202
+ available: state.available,
203
+ error: state.error,
204
+ source: 'session',
205
+ stale: state.stale,
206
+ };
207
+ try {
208
+ const result = this.appendHistorySample(sample);
209
+ if (result && typeof result.catch === 'function') {
210
+ result.catch(() => {
211
+ // History append must never break the live emission path.
212
+ });
213
+ }
214
+ }
215
+ catch {
216
+ // Synchronous errors swallowed for the same reason.
217
+ }
218
+ }
219
+ emitCachedOrUnavailable() {
220
+ const cached = this.readSnapshot('zai', this.accountId);
221
+ if (cached) {
222
+ this.emitState(enrichZaiState({
223
+ ...cached,
224
+ providerId: 'zai',
225
+ source: 'cache',
226
+ stale: true,
227
+ fiveHourLabel: cached.fiveHourLabel ?? '5-Hour',
228
+ sevenDayLabel: cached.sevenDayLabel ?? 'Weekly',
229
+ }));
230
+ return;
231
+ }
232
+ // Initial state with no observations yet.
233
+ const tier = this.tier === 'auto' ? 'lite' : this.tier;
234
+ this.emitState(enrichZaiState((0, zaiQuota_1.makeUnavailableZaiQuotaState)(undefined, tier)));
235
+ }
236
+ emitState(state) {
237
+ const nextKey = JSON.stringify(state);
238
+ if (this.lastEmissionKey === nextKey)
239
+ return;
240
+ this.lastEmissionKey = nextKey;
241
+ for (const listener of this.listeners) {
242
+ try {
243
+ listener(state);
244
+ }
245
+ catch {
246
+ // Listener failures must not stop the watcher.
247
+ }
248
+ }
249
+ }
250
+ pruneOldTurns() {
251
+ const cutoff = this.now() - SEVEN_DAY_MS;
252
+ if (this.turns.length === 0)
253
+ return;
254
+ const next = this.turns.filter(turn => turn.timestampMs >= cutoff);
255
+ if (next.length !== this.turns.length) {
256
+ this.turns = next;
257
+ }
258
+ }
259
+ }
260
+ exports.ZaiQuotaWatcher = ZaiQuotaWatcher;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-shared",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "description": "Shared data access layer for Sidekick — readers, types, providers, credentials, quota",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",