@latentminds/pi-quotas 0.2.3 → 0.2.4

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 CHANGED
@@ -62,6 +62,7 @@ Use `/quotas:settings` to enable or disable:
62
62
  - Per-provider commands (`/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`, `/synthetic:quotas`)
63
63
  - Footer status widget
64
64
  - Quota warning notifications
65
+ - **Defer to Synthetic** — when both pi-quotas and [pi-synthetic](https://www.npmjs.com/package/@aliou/pi-synthetic) are loaded, pi-quotas hides its own Synthetic footer to avoid showing duplicate quota information. Enabled by default; disable if you prefer to see both footers.
65
66
 
66
67
  Settings can be saved globally (`~/.pi/agent/extensions/quotas.json`) or per-project (`.pi/quotas.json`). Run `/reload` after changing command visibility.
67
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@latentminds/pi-quotas",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
package/src/config.ts CHANGED
@@ -9,7 +9,8 @@ export type QuotasFeatureId =
9
9
  | "quotasCommand"
10
10
  | "providerCommands"
11
11
  | "usageStatus"
12
- | "quotaWarnings";
12
+ | "quotaWarnings"
13
+ | "deferToSynthetic";
13
14
 
14
15
  export const QUOTAS_EXTENSIONS_REQUEST_EVENT =
15
16
  "quotas:extensions:request" as const;
@@ -28,6 +29,8 @@ export interface QuotasConfig {
28
29
  providerCommands?: boolean;
29
30
  usageStatus?: boolean;
30
31
  quotaWarnings?: boolean;
32
+ /** When true and pi-synthetic's usage footer is active, hide pi-quotas' Synthetic footer. */
33
+ deferToSynthetic?: boolean;
31
34
  }
32
35
 
33
36
  export interface ResolvedQuotasConfig {
@@ -36,6 +39,7 @@ export interface ResolvedQuotasConfig {
36
39
  providerCommands: boolean;
37
40
  usageStatus: boolean;
38
41
  quotaWarnings: boolean;
42
+ deferToSynthetic: boolean;
39
43
  }
40
44
 
41
45
  const DEFAULT_CONFIG: ResolvedQuotasConfig = {
@@ -44,6 +48,7 @@ const DEFAULT_CONFIG: ResolvedQuotasConfig = {
44
48
  providerCommands: true,
45
49
  usageStatus: true,
46
50
  quotaWarnings: true,
51
+ deferToSynthetic: true,
47
52
  };
48
53
 
49
54
  let pendingMigrationNotice = false;
@@ -79,6 +84,7 @@ class QuotasConfigStore {
79
84
  providerCommands: input?.providerCommands ?? DEFAULT_CONFIG.providerCommands,
80
85
  usageStatus: input?.usageStatus ?? DEFAULT_CONFIG.usageStatus,
81
86
  quotaWarnings: input?.quotaWarnings ?? DEFAULT_CONFIG.quotaWarnings,
87
+ deferToSynthetic: input?.deferToSynthetic ?? DEFAULT_CONFIG.deferToSynthetic,
82
88
  };
83
89
  }
84
90
 
@@ -164,6 +170,11 @@ const FEATURE_META: Array<{
164
170
  label: "Quota warnings",
165
171
  description: "Toggle projected-usage warning notifications",
166
172
  },
173
+ {
174
+ id: "deferToSynthetic",
175
+ label: "Defer to Synthetic",
176
+ description: "When pi-synthetic is loaded, hide pi-quotas' Synthetic footer to avoid duplicates",
177
+ },
167
178
  ];
168
179
 
169
180
  export function registerQuotasSettings(
@@ -205,11 +216,11 @@ export function registerQuotasSettings(
205
216
 
206
217
  const feature = FEATURE_META.find((item) => selected.startsWith(item.label));
207
218
  if (!feature) continue;
208
- if (!getLoadedFeatures().has(feature.id)) {
219
+ if (feature.id !== "deferToSynthetic" && !getLoadedFeatures().has(feature.id)) {
209
220
  ctx.ui.notify(`${feature.label} is not loaded by Pi in this session.`, "warning");
210
221
  continue;
211
222
  }
212
- draft[feature.id] = !draft[feature.id];
223
+ (draft as unknown as Record<string, boolean>)[feature.id] = !(draft as unknown as Record<string, boolean>)[feature.id];
213
224
  }
214
225
  },
215
226
  });
@@ -37,6 +37,11 @@ const SHORT_LABELS: Record<string, string> = {
37
37
  "Daily": "daily",
38
38
  "Weekly": "weekly",
39
39
  "Monthly": "monthly",
40
+ // Synthetic labels (match pi-synthetic extension)
41
+ "Credits / week": "week",
42
+ "Requests / 5h": "5h",
43
+ "Search / hour": "search",
44
+ "Free Tool Calls / day": "tools",
40
45
  };
41
46
 
42
47
  /**
@@ -68,8 +73,19 @@ export function formatWindowStatus(theme: ThemeLike, w: WindowStatus): string {
68
73
  const labelColor = isAtRisk ? color : "dim";
69
74
  const labelText = theme.fg(labelColor, `${short}:`);
70
75
 
76
+ // Synthetic windows always use compact "remaining%" format
77
+ // to match the pi-synthetic extension display
78
+ const SYNTHETIC_LABELS = new Set([
79
+ "Credits / week", "Requests / 5h", "Search / hour", "Free Tool Calls / day",
80
+ ]);
81
+ const isSynthetic = SYNTHETIC_LABELS.has(w.label);
82
+
71
83
  let valueText: string;
72
- if (w.label === "Spend cap") {
84
+ if (isSynthetic) {
85
+ // Compact format matching pi-synthetic: just remaining%
86
+ const remaining = Math.max(0, Math.min(100, Math.round(100 - w.usedPercent)));
87
+ valueText = theme.fg(color, `${remaining}%`);
88
+ } else if (w.label === "Spend cap") {
73
89
  valueText = theme.fg(color, w.limited ? "REACHED" : "OK");
74
90
  } else if (w.isCurrency && w.usedValue != null && w.limitValue != null) {
75
91
  // Tracking-only windows have limitValue=0, show just usage
@@ -9,6 +9,12 @@ import {
9
9
  type QuotasConfigUpdatedPayload,
10
10
  configLoader,
11
11
  } from "../../config.js";
12
+
13
+ /** Event emitted by pi-synthetic when its usage-status extension registers. */
14
+ const SYNTHETIC_EXTENSIONS_REGISTER_EVENT = "synthetic:extensions:register";
15
+ interface SyntheticExtensionsRegisterPayload {
16
+ feature: string;
17
+ }
12
18
  import {
13
19
  fetchProviderQuotas,
14
20
  isSupportedProvider,
@@ -152,10 +158,28 @@ export default async function (pi: ExtensionAPI) {
152
158
  await configLoader.load();
153
159
  const refresher = createStatusRefresher();
154
160
  let enabled = configLoader.getConfig().usageStatus;
161
+ let deferToSynthetic = configLoader.getConfig().deferToSynthetic;
155
162
  let currentContext: ExtensionContext | undefined;
156
163
 
164
+ /** Whether pi-synthetic's usage footer is active in this session. */
165
+ let syntheticUsageActive = false;
166
+
167
+ pi.events.on(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, (data: unknown) => {
168
+ const { feature } = data as SyntheticExtensionsRegisterPayload;
169
+ if (feature === "usageStatus") {
170
+ syntheticUsageActive = true;
171
+ // If currently showing synthetic data, clear our footer
172
+ if (currentContext && enabled && deferToSynthetic && currentContext.model?.provider === "synthetic") {
173
+ currentContext.ui.setStatus(EXTENSION_ID, undefined);
174
+ refresher.stop();
175
+ }
176
+ }
177
+ });
178
+
157
179
  pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
158
- enabled = (data as QuotasConfigUpdatedPayload).config.usageStatus;
180
+ const config = (data as QuotasConfigUpdatedPayload).config;
181
+ enabled = config.usageStatus;
182
+ deferToSynthetic = config.deferToSynthetic;
159
183
  if (!enabled) {
160
184
  refresher.stop(currentContext);
161
185
  return;
@@ -166,9 +190,21 @@ export default async function (pi: ExtensionAPI) {
166
190
  }
167
191
  });
168
192
 
193
+ /**
194
+ * Whether to suppress our footer because pi-synthetic is showing
195
+ * the same data for the Synthetic provider.
196
+ */
197
+ function shouldDeferToSynthetic(provider: string | undefined): boolean {
198
+ return deferToSynthetic && syntheticUsageActive && provider === "synthetic";
199
+ }
200
+
169
201
  pi.on("session_start", async (_event, ctx) => {
170
202
  currentContext = ctx;
171
203
  if (!enabled) return;
204
+ if (shouldDeferToSynthetic(ctx.model?.provider)) {
205
+ ctx.ui.setStatus(EXTENSION_ID, undefined);
206
+ return;
207
+ }
172
208
  refresher.start();
173
209
  await refresher.refreshFor(ctx);
174
210
  });
@@ -176,6 +212,7 @@ export default async function (pi: ExtensionAPI) {
176
212
  pi.on("turn_end", async (_event, ctx) => {
177
213
  currentContext = ctx;
178
214
  if (!enabled) return;
215
+ if (shouldDeferToSynthetic(ctx.model?.provider)) return;
179
216
  await refresher.refreshFor(ctx);
180
217
  });
181
218
 
@@ -185,11 +222,16 @@ export default async function (pi: ExtensionAPI) {
185
222
  refresher.stop(ctx);
186
223
  return;
187
224
  }
225
+ if (shouldDeferToSynthetic(ctx.model?.provider)) {
226
+ ctx.ui.setStatus(EXTENSION_ID, undefined);
227
+ return;
228
+ }
188
229
  await refresher.refreshFor(ctx);
189
230
  });
190
231
 
191
232
  pi.on("session_shutdown", async (_event, ctx) => {
192
233
  currentContext = undefined;
234
+ syntheticUsageActive = false;
193
235
  refresher.stop(ctx);
194
236
  });
195
237
 
@@ -3,6 +3,7 @@ import { parseAnthropicUsage } from "./providers.js";
3
3
  import { parseCodexUsage } from "./providers.js";
4
4
  import { parseGitHubCopilotUsage } from "./providers.js";
5
5
  import { parseOpenRouterUsage } from "./providers.js";
6
+ import { parseSyntheticUsage } from "./providers.js";
6
7
 
7
8
  describe("parseAnthropicUsage", () => {
8
9
  it("maps oauth usage response into quota windows", () => {
@@ -410,3 +411,133 @@ describe("parseOpenRouterUsage", () => {
410
411
  expect(windows.find((w) => w.label === "Monthly")).toBeDefined();
411
412
  });
412
413
  });
414
+
415
+ describe("parseSyntheticUsage", () => {
416
+ it("parses a full API response with all windows", () => {
417
+ const windows = parseSyntheticUsage({
418
+ subscription: {
419
+ limit: 500,
420
+ requests: 0,
421
+ renewsAt: "2026-05-06T12:27:17.097Z",
422
+ },
423
+ search: {
424
+ hourly: {
425
+ limit: 250,
426
+ requests: 30,
427
+ renewsAt: "2026-05-06T08:27:17.097Z",
428
+ },
429
+ },
430
+ freeToolCalls: {
431
+ limit: 0,
432
+ requests: 0,
433
+ renewsAt: "2026-05-07T07:27:17.102Z",
434
+ },
435
+ weeklyTokenLimit: {
436
+ nextRegenAt: "2026-05-06T09:44:14.000Z",
437
+ percentRemaining: 96.39,
438
+ maxCredits: "$24.00",
439
+ remainingCredits: "$23.13",
440
+ nextRegenCredits: "$0.48",
441
+ },
442
+ rollingFiveHourLimit: {
443
+ nextTickAt: "2026-05-06T07:27:51.000Z",
444
+ tickPercent: 0.05,
445
+ remaining: 420,
446
+ max: 500,
447
+ limited: false,
448
+ },
449
+ });
450
+
451
+ // subscription is NOT a window (matching pi-synthetic extension)
452
+ expect(windows.find((w) => w.label === "Subscription")).toBeUndefined();
453
+
454
+ // weeklyTokenLimit: 100 - 96.39 = ~3.61%
455
+ const credits = windows.find((w) => w.label === "Credits / week");
456
+ expect(credits).toBeDefined();
457
+ expect(credits!.usedPercent).toBeCloseTo(3.61, 1);
458
+ expect(credits!.isCurrency).toBe(true);
459
+ expect(credits!.limitValue).toBe(24);
460
+ expect(credits!.usedValue).toBeCloseTo(0.87, 1);
461
+ expect(credits!.paceScale).toBe(1 / 7);
462
+ expect(credits!.nextAmount).toBe("+$0.48");
463
+
464
+ // rollingFiveHourLimit: (500-420)/500 = 16%
465
+ const fiveHour = windows.find((w) => w.label === "Requests / 5h");
466
+ expect(fiveHour).toBeDefined();
467
+ expect(fiveHour!.usedPercent).toBe(16);
468
+ expect(fiveHour!.usedValue).toBe(80);
469
+ expect(fiveHour!.limitValue).toBe(500);
470
+ expect(fiveHour!.limited).toBe(false);
471
+
472
+ // search hourly
473
+ const search = windows.find((w) => w.label === "Search / hour");
474
+ expect(search).toBeDefined();
475
+ expect(search!.usedPercent).toBeCloseTo(12, 0);
476
+ expect(search!.usedValue).toBe(30);
477
+ expect(search!.limitValue).toBe(250);
478
+
479
+ // freeToolCalls with limit=0 is NOT shown
480
+ expect(windows.find((w) => w.label === "Free Tool Calls / day")).toBeUndefined();
481
+ });
482
+
483
+ it("shows freeToolCalls when limit > 0", () => {
484
+ const windows = parseSyntheticUsage({
485
+ weeklyTokenLimit: {
486
+ nextRegenAt: "2026-05-06T09:44:14.000Z",
487
+ percentRemaining: 50,
488
+ maxCredits: "$10.00",
489
+ remainingCredits: "$5.00",
490
+ nextRegenCredits: "$0.50",
491
+ },
492
+ freeToolCalls: {
493
+ limit: 100,
494
+ requests: 25,
495
+ renewsAt: "2026-05-07T07:27:17.102Z",
496
+ },
497
+ });
498
+
499
+ const tools = windows.find((w) => w.label === "Free Tool Calls / day");
500
+ expect(tools).toBeDefined();
501
+ expect(tools!.usedPercent).toBe(25);
502
+ });
503
+
504
+ it("parses currency strings like $24.00 correctly", () => {
505
+ const windows = parseSyntheticUsage({
506
+ weeklyTokenLimit: {
507
+ nextRegenAt: "2026-05-06T09:44:14.000Z",
508
+ percentRemaining: 75,
509
+ maxCredits: "$1,234.56",
510
+ remainingCredits: "$925.92",
511
+ nextRegenCredits: "$12.34",
512
+ },
513
+ });
514
+
515
+ const credits = windows.find((w) => w.label === "Credits / week");
516
+ expect(credits).toBeDefined();
517
+ expect(credits!.limitValue).toBe(1234.56);
518
+ expect(credits!.usedValue).toBeCloseTo(308.64, 1);
519
+ expect(credits!.usedPercent).toBe(25); // 100 - 75
520
+ });
521
+
522
+ it("handles limited state", () => {
523
+ const windows = parseSyntheticUsage({
524
+ rollingFiveHourLimit: {
525
+ nextTickAt: "2026-05-06T07:27:51.000Z",
526
+ tickPercent: 100,
527
+ remaining: 0,
528
+ max: 500,
529
+ limited: true,
530
+ },
531
+ });
532
+
533
+ const fiveHour = windows.find((w) => w.label === "Requests / 5h");
534
+ expect(fiveHour).toBeDefined();
535
+ expect(fiveHour!.usedPercent).toBe(100);
536
+ expect(fiveHour!.limited).toBe(true);
537
+ });
538
+
539
+ it("returns empty array when no data", () => {
540
+ const windows = parseSyntheticUsage({});
541
+ expect(windows).toHaveLength(0);
542
+ });
543
+ });
@@ -353,26 +353,55 @@ export function parseOpenRouterUsage(data: any): QuotaWindow[] {
353
353
  return windows;
354
354
  }
355
355
 
356
+ /** Parse currency strings like "$24.00" to a number */
357
+ function parseCurrency(value: string): number {
358
+ const n = Number(value.replace(/[^0-9.-]/g, ""));
359
+ return Number.isFinite(n) ? n : 0;
360
+ }
361
+
356
362
  export function parseSyntheticUsage(data: any): QuotaWindow[] {
357
363
  const windows: QuotaWindow[] = [];
358
364
 
359
- // Subscription (requests)
360
- if (data?.subscription) {
365
+ // Weekly token/credit limit (primary window for paid plans)
366
+ if (data?.weeklyTokenLimit) {
367
+ const { weeklyTokenLimit } = data.weeklyTokenLimit;
368
+ const limitValue = parseCurrency(data.weeklyTokenLimit.maxCredits);
369
+ const remainingValue = parseCurrency(data.weeklyTokenLimit.remainingCredits);
361
370
  windows.push({
362
371
  provider: "synthetic",
363
- label: "Subscription",
364
- usedPercent: safePercent(data.subscription.requests, data.subscription.limit),
365
- resetsAt: parseDateish(data.subscription.renewsAt),
366
- windowSeconds: 30 * 24 * 60 * 60,
367
- usedValue: data.subscription.requests,
368
- limitValue: data.subscription.limit,
372
+ label: "Credits / week",
373
+ usedPercent: Math.max(0, Math.min(100, 100 - data.weeklyTokenLimit.percentRemaining)),
374
+ resetsAt: parseDateish(data.weeklyTokenLimit.nextRegenAt),
375
+ windowSeconds: 24 * 60 * 60,
376
+ usedValue: limitValue - remainingValue,
377
+ limitValue,
378
+ isCurrency: true,
369
379
  showPace: true,
370
- nextLabel: "Resets",
380
+ paceScale: 1 / 7,
381
+ nextAmount: `+${data.weeklyTokenLimit.nextRegenCredits}`,
382
+ nextLabel: "Next regen",
383
+ });
384
+ }
385
+
386
+ // Rolling 5-hour request limit
387
+ if (data?.rollingFiveHourLimit && data.rollingFiveHourLimit.max > 0) {
388
+ const used = data.rollingFiveHourLimit.max - data.rollingFiveHourLimit.remaining;
389
+ windows.push({
390
+ provider: "synthetic",
391
+ label: "Requests / 5h",
392
+ usedPercent: safePercent(used, data.rollingFiveHourLimit.max),
393
+ resetsAt: parseDateish(data.rollingFiveHourLimit.nextTickAt),
394
+ windowSeconds: 5 * 60 * 60,
395
+ usedValue: Math.round(used),
396
+ limitValue: data.rollingFiveHourLimit.max,
397
+ showPace: false,
398
+ limited: data.rollingFiveHourLimit.limited,
399
+ nextLabel: data.rollingFiveHourLimit.limited ? "Limited" : "Resets",
371
400
  });
372
401
  }
373
402
 
374
- // Search hourly
375
- if (data?.search?.hourly) {
403
+ // Search hourly (only if limit > 0)
404
+ if (data?.search?.hourly?.limit && data.search.hourly.limit > 0) {
376
405
  windows.push({
377
406
  provider: "synthetic",
378
407
  label: "Search / hour",
@@ -381,59 +410,27 @@ export function parseSyntheticUsage(data: any): QuotaWindow[] {
381
410
  windowSeconds: 60 * 60,
382
411
  usedValue: data.search.hourly.requests,
383
412
  limitValue: data.search.hourly.limit,
384
- showPace: false,
413
+ showPace: true,
414
+ paceScale: 1,
385
415
  nextLabel: "Resets",
386
416
  });
387
417
  }
388
418
 
389
- // Free tool calls
390
- if (data?.freeToolCalls) {
419
+ // Free tool calls (only if limit > 0)
420
+ if (data?.freeToolCalls?.limit && data.freeToolCalls.limit > 0) {
391
421
  windows.push({
392
422
  provider: "synthetic",
393
- label: "Free Tools",
423
+ label: "Free Tool Calls / day",
394
424
  usedPercent: safePercent(data.freeToolCalls.requests, data.freeToolCalls.limit),
395
425
  resetsAt: parseDateish(data.freeToolCalls.renewsAt),
396
- windowSeconds: 30 * 24 * 60 * 60,
426
+ windowSeconds: 24 * 60 * 60,
397
427
  usedValue: data.freeToolCalls.requests,
398
428
  limitValue: data.freeToolCalls.limit,
399
429
  showPace: true,
430
+ paceScale: 1,
400
431
  nextLabel: "Resets",
401
432
  });
402
433
  }
403
434
 
404
- // Weekly token limit
405
- if (data?.weeklyTokenLimit) {
406
- const maxCredits = parseFloat(data.weeklyTokenLimit.maxCredits) || 0;
407
- const remainingCredits = parseFloat(data.weeklyTokenLimit.remainingCredits) || 0;
408
- windows.push({
409
- provider: "synthetic",
410
- label: "Weekly Tokens",
411
- usedPercent: data.weeklyTokenLimit.percentRemaining ?? safePercent(remainingCredits, maxCredits),
412
- resetsAt: parseDateish(data.weeklyTokenLimit.nextRegenAt),
413
- windowSeconds: 7 * 24 * 60 * 60,
414
- usedValue: maxCredits - remainingCredits,
415
- limitValue: maxCredits,
416
- isCurrency: true,
417
- showPace: true,
418
- nextLabel: "Regen",
419
- });
420
- }
421
-
422
- // Rolling 5-hour limit
423
- if (data?.rollingFiveHourLimit) {
424
- windows.push({
425
- provider: "synthetic",
426
- label: "5h Limit",
427
- usedPercent: data.rollingFiveHourLimit.tickPercent,
428
- resetsAt: parseDateish(data.rollingFiveHourLimit.nextTickAt),
429
- windowSeconds: 5 * 60 * 60,
430
- usedValue: data.rollingFiveHourLimit.max - data.rollingFiveHourLimit.remaining,
431
- limitValue: data.rollingFiveHourLimit.max,
432
- limited: data.rollingFiveHourLimit.limited,
433
- showPace: false,
434
- nextLabel: data.rollingFiveHourLimit.limited ? "Limited" : "Resets",
435
- });
436
- }
437
-
438
435
  return windows;
439
436
  }