@aliou/pi-synthetic 0.10.2 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-synthetic",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -17,7 +17,8 @@
17
17
  "extensions": [
18
18
  "./src/extensions/provider/index.ts",
19
19
  "./src/extensions/web-search/index.ts",
20
- "./src/extensions/command-quotas/index.ts"
20
+ "./src/extensions/command-quotas/index.ts",
21
+ "./src/extensions/quota-warnings/index.ts"
21
22
  ],
22
23
  "video": "https://assets.aliou.me/pi-extensions/demos/pi-synthetic.mp4"
23
24
  },
@@ -19,10 +19,19 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
19
19
 
20
20
  const result = await ctx.ui.custom<null>((tui, theme, _kb, done) => {
21
21
  const controller = new AbortController();
22
- const component = new QuotasComponent(theme, tui, () => {
23
- controller.abort();
24
- done(null);
25
- });
22
+ const component = new QuotasComponent(
23
+ theme,
24
+ tui,
25
+ () => {
26
+ controller.abort();
27
+ done(null);
28
+ },
29
+ () => {
30
+ component.setState({ type: "loading" });
31
+ tui.requestRender();
32
+ void loadQuotas();
33
+ },
34
+ );
26
35
 
27
36
  async function loadQuotas(): Promise<void> {
28
37
  const fetchResult = await fetchQuotas(key, controller.signal);
@@ -1,196 +1,32 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
3
  import type { Component, TUI } from "@mariozechner/pi-tui";
4
- import {
5
- Loader,
6
- matchesKey,
7
- truncateToWidth,
8
- visibleWidth,
9
- } from "@mariozechner/pi-tui";
4
+ import { Loader, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
10
5
  import type { QuotasResponse } from "../../../types/quotas";
6
+ import {
7
+ assessWindow,
8
+ formatTimeRemaining,
9
+ getSeverityColor,
10
+ type QuotaWindow,
11
+ toWindows,
12
+ } from "../../../utils/quotas-severity";
11
13
 
12
14
  type QuotasState =
13
15
  | { type: "loading" }
14
16
  | { type: "error"; message: string }
15
17
  | { type: "loaded"; quotas: QuotasResponse };
16
18
 
17
- interface QuotaWindow {
18
- label: string;
19
- usedPercent: number;
20
- resetsAt: Date;
21
- windowSeconds: number;
22
- usedValue: number;
23
- limitValue: number;
24
- isCredits?: boolean;
25
- isLimited?: boolean;
26
- tickPercent?: number;
27
- nextRegenCredits?: string;
28
- }
29
-
30
- /** Safely compute percentage, guarding against division by zero */
31
- function safePercent(used: number, limit: number): number {
32
- if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) return 0;
33
- return Math.max(0, Math.min(100, (used / limit) * 100));
34
- }
35
-
36
- /** Parse currency string like "$1,234.56" to number */
37
- function parseCurrency(value: string): number {
38
- const n = Number(value.replace(/[^0-9.-]/g, ""));
39
- return Number.isFinite(n) ? n : 0;
40
- }
41
-
42
- function toWindows(quotas: QuotasResponse): QuotaWindow[] {
43
- const windows: QuotaWindow[] = [];
44
-
45
- // Weekly token limit (credits-based)
46
- if (quotas.weeklyTokenLimit) {
47
- const { weeklyTokenLimit } = quotas;
48
- const limitValue = parseCurrency(weeklyTokenLimit.maxCredits);
49
- const remainingValue = parseCurrency(weeklyTokenLimit.remainingCredits);
50
- windows.push({
51
- label: "Credits",
52
- usedPercent: Math.max(
53
- 0,
54
- Math.min(100, 100 - weeklyTokenLimit.percentRemaining),
55
- ),
56
- resetsAt: new Date(weeklyTokenLimit.nextRegenAt),
57
- windowSeconds: 7 * 24 * 60 * 60,
58
- usedValue: limitValue - remainingValue,
59
- limitValue,
60
- isCredits: true,
61
- nextRegenCredits: weeklyTokenLimit.nextRegenCredits,
62
- });
63
- }
64
-
65
- // Rolling 5-hour limit (request-based)
66
- if (quotas.rollingFiveHourLimit && quotas.rollingFiveHourLimit.max > 0) {
67
- const { rollingFiveHourLimit } = quotas;
68
- windows.push({
69
- label: "5h",
70
- usedPercent: safePercent(
71
- rollingFiveHourLimit.max - rollingFiveHourLimit.remaining,
72
- rollingFiveHourLimit.max,
73
- ),
74
- resetsAt: new Date(rollingFiveHourLimit.nextTickAt),
75
- windowSeconds: 5 * 60 * 60,
76
- usedValue: rollingFiveHourLimit.max - rollingFiveHourLimit.remaining,
77
- limitValue: rollingFiveHourLimit.max,
78
- isLimited: rollingFiveHourLimit.limited,
79
- tickPercent: rollingFiveHourLimit.tickPercent,
80
- });
81
- }
82
-
83
- // Legacy subscription (fallback if rollingFiveHourLimit not available)
84
- if (
85
- !quotas.rollingFiveHourLimit &&
86
- quotas.subscription?.limit &&
87
- quotas.subscription.limit > 0
88
- ) {
89
- windows.push({
90
- label: "Completions",
91
- usedPercent: safePercent(
92
- quotas.subscription.requests,
93
- quotas.subscription.limit,
94
- ),
95
- resetsAt: new Date(quotas.subscription.renewsAt),
96
- windowSeconds: 5 * 60 * 60,
97
- usedValue: quotas.subscription.requests,
98
- limitValue: quotas.subscription.limit,
99
- });
100
- }
101
-
102
- if (quotas.search?.hourly?.limit && quotas.search.hourly.limit > 0) {
103
- windows.push({
104
- label: "Search",
105
- usedPercent: safePercent(
106
- quotas.search.hourly.requests,
107
- quotas.search.hourly.limit,
108
- ),
109
- resetsAt: new Date(quotas.search.hourly.renewsAt),
110
- windowSeconds: 60 * 60,
111
- usedValue: quotas.search.hourly.requests,
112
- limitValue: quotas.search.hourly.limit,
113
- });
114
- }
115
-
116
- if (quotas.freeToolCalls?.limit && quotas.freeToolCalls.limit > 0) {
117
- windows.push({
118
- label: "Free Tool Calls",
119
- usedPercent: safePercent(
120
- quotas.freeToolCalls.requests,
121
- quotas.freeToolCalls.limit,
122
- ),
123
- resetsAt: new Date(quotas.freeToolCalls.renewsAt),
124
- windowSeconds: 24 * 60 * 60,
125
- usedValue: quotas.freeToolCalls.requests,
126
- limitValue: quotas.freeToolCalls.limit,
127
- });
128
- }
129
-
130
- return windows;
131
- }
132
-
133
- function getPacePercent(window: QuotaWindow): number | null {
134
- const totalMs = window.windowSeconds * 1000;
135
- if (totalMs <= 0) return null;
136
- const remainingMs = window.resetsAt.getTime() - Date.now();
137
- const elapsedMs = totalMs - remainingMs;
138
- return Math.max(0, Math.min(100, (elapsedMs / totalMs) * 100));
139
- }
140
-
141
- function getProjectedPercent(
142
- usedPercent: number,
143
- pacePercent: number | null,
144
- ): number {
145
- if (pacePercent === null) return usedPercent;
146
- const effectivePace = Math.max(5, pacePercent);
147
- return Math.max(0, (usedPercent / effectivePace) * 100);
148
- }
149
-
150
- function getSeverity(
151
- projectedPercent: number,
152
- pacePercent: number | null,
153
- ): "success" | "warning" | "error" {
154
- if (pacePercent === null) {
155
- if (projectedPercent >= 100) return "error";
156
- if (projectedPercent >= 90) return "warning";
157
- return "success";
158
- }
159
- // Dynamic thresholds based on window progress
160
- const progress = pacePercent / 100;
161
- const warnThreshold = 260 - (260 - 120) * progress;
162
- const highThreshold = 320 - (320 - 145) * progress;
163
- const criticalThreshold = 400 - (400 - 170) * progress;
164
-
165
- if (projectedPercent >= criticalThreshold) return "error";
166
- if (projectedPercent >= highThreshold) return "error";
167
- if (projectedPercent >= warnThreshold) return "warning";
168
- return "success";
169
- }
170
-
171
- function formatResetDateTime(date: Date): string {
172
- const now = new Date();
173
- const isToday =
174
- date.getDate() === now.getDate() &&
175
- date.getMonth() === now.getMonth() &&
176
- date.getFullYear() === now.getFullYear();
177
-
178
- const timeStr = date.toLocaleTimeString("en-US", {
179
- hour: "numeric",
180
- minute: "2-digit",
181
- hour12: true,
182
- });
183
-
184
- if (isToday) {
185
- return `today ${timeStr}`;
186
- }
187
-
188
- const dateStr = date.toLocaleDateString("en-US", {
189
- month: "short",
190
- day: "numeric",
191
- });
192
-
193
- return `${dateStr} ${timeStr}`;
19
+ /**
20
+ * Convert a foreground ANSI escape to its background equivalent.
21
+ * Handles truecolor (38;2), 256-color (38;5), and basic (3X) escapes.
22
+ */
23
+ function fgAnsiToBg(fgAnsi: string): string {
24
+ // Convert fg escape sequences to bg equivalents by replacing the
25
+ // discriminating digit: 38 (truecolor/256) → 48, 3X (basic) → 4X.
26
+ return fgAnsi
27
+ .split("[38;")
28
+ .join("[48;")
29
+ .replace(/\[3([0-9])m/g, "[4$1m");
194
30
  }
195
31
 
196
32
  function renderProgressBar(
@@ -202,45 +38,40 @@ function renderProgressBar(
202
38
  ): string {
203
39
  const clamped = Math.max(0, Math.min(100, Math.round(percent)));
204
40
  const filled = Math.round((clamped / 100) * width);
205
- const paceIndex =
206
- pacePercent === null || pacePercent === undefined || pacePercent <= percent
207
- ? null
208
- : Math.round((Math.max(0, Math.min(100, pacePercent)) / 100) * width);
209
41
 
210
- const parts: string[] = [];
211
- for (let idx = 0; idx < width; idx++) {
212
- if (idx < filled) {
213
- parts.push(theme.fg(fillColor, "█"));
214
- } else if (paceIndex !== null && idx < paceIndex) {
215
- parts.push(theme.fg(fillColor, "▓"));
216
- } else {
217
- parts.push(theme.fg("dim", "░"));
218
- }
219
- }
42
+ const showPace =
43
+ pacePercent !== null &&
44
+ pacePercent !== undefined &&
45
+ pacePercent >= 5 &&
46
+ Math.abs(pacePercent - percent) >= 5;
47
+ const paceIndex = showPace
48
+ ? Math.min(
49
+ width - 1,
50
+ Math.round(
51
+ (Math.max(0, Math.min(100, pacePercent ?? 0)) / 100) * width,
52
+ ),
53
+ )
54
+ : null;
220
55
 
221
- return parts.join("");
222
- }
56
+ const reset = "\x1b[0m";
223
57
 
224
- function renderSimpleIndicatorBar(
225
- usedPercent: number,
226
- width: number,
227
- theme: Theme,
228
- severity: "success" | "warning" | "error",
229
- ): string {
230
- const clampedPercent = Math.max(0, Math.min(100, usedPercent));
231
- // Clamp to width - 1 to avoid off-by-one when usedPercent === 100
232
- const usedIndex = Math.min(
233
- Math.round((clampedPercent / 100) * width),
234
- width - 1,
235
- );
236
58
  const parts: string[] = [];
237
-
238
- // Hide marker when within 5% of edges
239
- const showMarker = clampedPercent >= 5 && clampedPercent <= 95;
240
-
241
59
  for (let idx = 0; idx < width; idx++) {
242
- if (showMarker && idx === usedIndex) {
243
- parts.push(theme.fg(severity, "|"));
60
+ if (paceIndex !== null && idx === paceIndex) {
61
+ // Inside fill = ahead of pace: accent. Outside = behind pace: severity.
62
+ const markerColor = idx < filled ? "accent" : fillColor;
63
+ // Inside fill: set bg to fill color so `|` doesn't expose the panel bg
64
+ // through the thin character. Outside fill: ░ uses terminal bg naturally,
65
+ // so leave bg unset to match.
66
+ if (idx < filled) {
67
+ const bgAnsi = fgAnsiToBg(theme.getFgAnsi(fillColor));
68
+ const fgAnsi = theme.getFgAnsi(markerColor);
69
+ parts.push(`${bgAnsi}${fgAnsi}|${reset}`);
70
+ } else {
71
+ parts.push(theme.fg(markerColor, "|"));
72
+ }
73
+ } else if (idx < filled) {
74
+ parts.push(theme.fg(fillColor, "█"));
244
75
  } else {
245
76
  parts.push(theme.fg("dim", "░"));
246
77
  }
@@ -254,12 +85,19 @@ export class QuotasComponent implements Component {
254
85
  private theme: Theme;
255
86
  private tui: TUI;
256
87
  private onClose: () => void;
88
+ private onRefetch: () => void;
257
89
  private loader: Loader | null = null;
258
90
 
259
- constructor(theme: Theme, tui: TUI, onClose: () => void) {
91
+ constructor(
92
+ theme: Theme,
93
+ tui: TUI,
94
+ onClose: () => void,
95
+ onRefetch: () => void,
96
+ ) {
260
97
  this.theme = theme;
261
98
  this.tui = tui;
262
99
  this.onClose = onClose;
100
+ this.onRefetch = onRefetch;
263
101
  this.startLoader();
264
102
  }
265
103
 
@@ -278,7 +116,10 @@ export class QuotasComponent implements Component {
278
116
  }
279
117
 
280
118
  setState(state: QuotasState): void {
281
- if (this.state.type === "loading" && state.type !== "loading") {
119
+ if (state.type === "loading") {
120
+ this.loader?.stop();
121
+ this.startLoader();
122
+ } else if (this.state.type === "loading") {
282
123
  this.loader?.stop();
283
124
  this.loader = null;
284
125
  }
@@ -290,6 +131,10 @@ export class QuotasComponent implements Component {
290
131
  this.onClose();
291
132
  return true;
292
133
  }
134
+ if (data === "r") {
135
+ this.onRefetch();
136
+ return true;
137
+ }
293
138
  return false;
294
139
  }
295
140
 
@@ -326,7 +171,7 @@ export class QuotasComponent implements Component {
326
171
  }
327
172
 
328
173
  lines.push("");
329
- lines.push(this.theme.fg("dim", " q/Esc to close"));
174
+ lines.push(this.theme.fg("dim", " r to refresh q/Esc to close"));
330
175
  lines.push(...border.render(width));
331
176
 
332
177
  return lines;
@@ -362,134 +207,44 @@ export class QuotasComponent implements Component {
362
207
  const lines: string[] = [];
363
208
  const theme = this.theme;
364
209
 
365
- const pacePercent = getPacePercent(window);
366
- const projectedPercent = getProjectedPercent(
367
- window.usedPercent,
368
- pacePercent,
369
- );
370
- const severity = getSeverity(projectedPercent, pacePercent);
210
+ const assessment = assessWindow(window);
211
+ const color = getSeverityColor(assessment.severity);
371
212
 
372
213
  // Label
373
214
  lines.push(
374
215
  truncateToWidth(` ${theme.fg("accent", window.label)}`, maxWidth),
375
216
  );
376
217
 
377
- // Progress bar + usage (or indicator for new quota types)
378
- if (window.isCredits || window.tickPercent !== undefined) {
379
- // Show simple indicator bar for new quota types
380
- const bar = renderSimpleIndicatorBar(
381
- window.usedPercent,
382
- barWidth,
383
- theme,
384
- severity,
385
- );
386
- const usedStr = window.isCredits
387
- ? `$${window.usedValue.toFixed(2)}/$${window.limitValue.toFixed(2)} (${Math.round(window.usedPercent)}%)`
388
- : `${window.usedValue.toFixed(0)}/${window.limitValue.toFixed(0)} (${Math.round(window.usedPercent)}%)`;
389
- const limitedBadge = window.isLimited
390
- ? theme.fg("error", " LIMITED")
391
- : "";
392
- lines.push(
393
- truncateToWidth(
394
- ` ${bar} ${theme.fg(severity, usedStr)}${limitedBadge}`,
395
- maxWidth,
396
- ),
397
- );
398
- } else {
399
- // Traditional progress bar for legacy quota types
400
- const bar = renderProgressBar(
401
- window.usedPercent,
402
- barWidth,
403
- theme,
404
- severity,
405
- pacePercent,
406
- );
407
- const usedStr = `${window.usedValue.toLocaleString()}/${window.limitValue.toLocaleString()} (${Math.round(window.usedPercent)}%)`;
408
- lines.push(
409
- truncateToWidth(` ${bar} ${theme.fg(severity, usedStr)}`, maxWidth),
410
- );
411
- }
412
-
413
- // Metadata: estimated + pace left, reset time right
414
- const leftParts: string[] = [];
415
-
416
- // Show tick info for rolling window
417
- if (window.tickPercent !== undefined) {
418
- const now = Date.now();
419
- const remainingMs = window.resetsAt.getTime() - now;
420
- const remainingMins = Math.ceil(remainingMs / (1000 * 60));
421
- const remainingSecs = Math.ceil(remainingMs / 1000);
422
- const timeStr =
423
- remainingMs <= 0
424
- ? "now"
425
- : remainingMins >= 1
426
- ? `${remainingMins}m`
427
- : `${remainingSecs}s`;
428
- const tickValue = (window.tickPercent / 100) * window.limitValue;
429
- const tickStr = `+${tickValue.toFixed(1)} in ${timeStr}`;
430
- leftParts.push(theme.fg("dim", tickStr));
431
- }
432
-
433
- // Show next regen credits for weekly token limit
434
- if (window.nextRegenCredits !== undefined) {
435
- const now = Date.now();
436
- const remainingMs = window.resetsAt.getTime() - now;
437
- const remainingHours = Math.ceil(remainingMs / (1000 * 60 * 60));
438
- const remainingMins = Math.ceil(remainingMs / (1000 * 60));
439
- const timeStr =
440
- remainingMs <= 0
441
- ? "now"
442
- : remainingHours >= 1
443
- ? `${remainingHours}h`
444
- : `${remainingMins}m`;
445
- const regenStr = `+${window.nextRegenCredits} in ${timeStr}`;
446
- leftParts.push(theme.fg("dim", regenStr));
447
- }
218
+ // Bar + usage
219
+ const bar = renderProgressBar(
220
+ window.usedPercent,
221
+ barWidth,
222
+ theme,
223
+ color,
224
+ assessment.pacePercent,
225
+ );
226
+ const usedStr = window.isCurrency
227
+ ? `${Math.round(window.usedPercent)}%/$${window.limitValue.toFixed(2)}`
228
+ : `${Math.round(window.usedPercent)}%/${window.limitValue}`;
229
+ const limitedBadge = window.limited ? theme.fg("error", " LIMITED") : "";
230
+ lines.push(
231
+ truncateToWidth(
232
+ ` ${bar} ${theme.fg(color, usedStr)}${limitedBadge}`,
233
+ maxWidth,
234
+ ),
235
+ );
448
236
 
449
- if (
450
- projectedPercent > 0 &&
451
- window.tickPercent === undefined &&
452
- window.nextRegenCredits === undefined
453
- ) {
454
- const estStr = `est ${Math.round(projectedPercent)}%`;
455
- leftParts.push(
456
- severity !== "success"
457
- ? theme.fg(severity, estStr)
458
- : theme.fg("dim", estStr),
237
+ // Subtitle: next event info
238
+ if (window.nextLabel) {
239
+ const timeStr = formatTimeRemaining(window.resetsAt);
240
+ const subtitleStr = window.nextAmount
241
+ ? `${window.nextAmount} in ${timeStr}`
242
+ : `${window.nextLabel} in ${timeStr}`;
243
+ lines.push(
244
+ truncateToWidth(` ${theme.fg("dim", subtitleStr)}`, maxWidth),
459
245
  );
460
246
  }
461
247
 
462
- if (
463
- pacePercent !== null &&
464
- window.tickPercent === undefined &&
465
- window.nextRegenCredits === undefined
466
- ) {
467
- const paceDiff = window.usedPercent - pacePercent;
468
- if (Math.abs(paceDiff) > 5) {
469
- if (paceDiff > 0) {
470
- leftParts.push(
471
- theme.fg("warning", `${Math.round(Math.abs(paceDiff))}% ahead`),
472
- );
473
- } else {
474
- leftParts.push(
475
- theme.fg("success", `${Math.round(Math.abs(paceDiff))}% behind`),
476
- );
477
- }
478
- }
479
- }
480
-
481
- const leftStr = leftParts.join(" ");
482
- const resetStr = formatResetDateTime(window.resetsAt);
483
- const rightStr = theme.fg("dim", resetStr);
484
-
485
- const leftW = visibleWidth(leftStr);
486
- const rightW = visibleWidth(rightStr);
487
- const gap = Math.max(2, barWidth - leftW - rightW);
488
-
489
- lines.push(
490
- truncateToWidth(` ${leftStr}${" ".repeat(gap)}${rightStr}`, maxWidth),
491
- );
492
-
493
248
  return lines;
494
249
  }
495
250
 
@@ -36,7 +36,7 @@ const SYNTHETIC_REASONING_EFFORT_MAP = {
36
36
  } as const;
37
37
 
38
38
  export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
39
- // API: hf:zai-org/GLM-4.7 → ctx=202752, multimodal
39
+ // API: hf:zai-org/GLM-4.7 → ctx=202752
40
40
  {
41
41
  id: "hf:zai-org/GLM-4.7",
42
42
  name: "zai-org/GLM-4.7",
@@ -45,11 +45,11 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
45
45
  supportsReasoningEffort: true,
46
46
  reasoningEffortMap: SYNTHETIC_REASONING_EFFORT_MAP,
47
47
  },
48
- input: ["text", "image"],
48
+ input: ["text"],
49
49
  cost: {
50
- input: 2.19,
50
+ input: 0.45,
51
51
  output: 2.19,
52
- cacheRead: 2.19,
52
+ cacheRead: 0.45,
53
53
  cacheWrite: 0,
54
54
  },
55
55
  contextWindow: 202752,
@@ -0,0 +1,22 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { clearAlertState, triggerCheck } from "./notifier";
3
+
4
+ export default async function (pi: ExtensionAPI) {
5
+ // Session start: reset local warning state and run an immediate check
6
+ pi.on("session_start", async (_event, ctx) => {
7
+ if (ctx.model?.provider !== "synthetic") return;
8
+ clearAlertState();
9
+ triggerCheck(ctx, ctx.model, false);
10
+ });
11
+
12
+ // Check after agent turn - only warn for newly crossed thresholds
13
+ pi.on("agent_end", async (_event, ctx) => {
14
+ if (ctx.model?.provider !== "synthetic") return;
15
+ triggerCheck(ctx, ctx.model, true);
16
+ });
17
+
18
+ // Clear state on shutdown
19
+ pi.on("session_shutdown", async () => {
20
+ clearAlertState();
21
+ });
22
+ }