pi-harness-runtime 0.10.13 → 0.10.14
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/.versionrc.js +32 -0
- package/cli.js +112 -0
- package/footer-status.js +155 -0
- package/harness/agent-handoff.js +123 -0
- package/harness/auto-compact.js +243 -0
- package/harness/auto-quota-resume.js +104 -0
- package/harness/blackboard.js +258 -0
- package/harness/context-compact-orchestrator.js +365 -0
- package/harness/context-window-manager.js +330 -0
- package/harness/continue-prompt.js +164 -0
- package/harness/e2e/minimax-quota-parser.js +52 -0
- package/harness/e2e/minimax-quota-scraper.js +475 -0
- package/harness/e2e/openai-quota-scraper.js +332 -0
- package/harness/e2e/playwright-runner.js +165 -0
- package/harness/e2e/quota-status.js +140 -0
- package/harness/e2e/test-engine.js +290 -0
- package/harness/forked-summarizer.js +212 -0
- package/harness/index.js +54 -0
- package/harness/job-state-machine.js +277 -0
- package/harness/loop-runtime.js +531 -0
- package/harness/master-planner.js +229 -0
- package/harness/notification-events.js +233 -0
- package/harness/output-limit-handler.js +233 -0
- package/harness/partial-recovery.js +413 -0
- package/harness/project-detector/detector.js +283 -0
- package/harness/repair-engine.js +256 -0
- package/harness/session-memory.js +293 -0
- package/harness/task-graph.js +87 -0
- package/index.js +1121 -0
- package/mirror.js +205 -0
- package/package.json +6 -4
- package/proactive-compact.js +42 -0
- package/renderer.js +134 -0
- package/status-parsers.js +63 -0
- package/tracker.js +49 -0
- package/windows.js +90 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Quota Scraper — RFC-0031+
|
|
3
|
+
*
|
|
4
|
+
* Scrapes quota data from ChatGPT Codex analytics page using Playwright.
|
|
5
|
+
* Uses cookie-based authentication to access the usage API.
|
|
6
|
+
*
|
|
7
|
+
* Target: https://chatgpt.com/backend-api/wham/usage
|
|
8
|
+
*
|
|
9
|
+
* Response shape:
|
|
10
|
+
* {
|
|
11
|
+
* "rate_limit": {
|
|
12
|
+
* "primary_window": {
|
|
13
|
+
* "used_percent": 7, // Weekly usage percentage (0-100)
|
|
14
|
+
* "limit_window_seconds": 604800, // 7-day window
|
|
15
|
+
* "reset_after_seconds": 491924, // Seconds until reset
|
|
16
|
+
* "reset_at": 1785649269 // Unix timestamp
|
|
17
|
+
* }
|
|
18
|
+
* },
|
|
19
|
+
* "credits": {
|
|
20
|
+
* "has_credits": false,
|
|
21
|
+
* "balance": "0"
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
const DEFAULT_COOKIE_FILE = join(homedir(), ".config", "openai-cookies.txt");
|
|
29
|
+
const USAGE_API = "https://chatgpt.com/backend-api/wham/usage";
|
|
30
|
+
const ANALYTICS_URL = "https://chatgpt.com/codex/cloud/settings/analytics#usage";
|
|
31
|
+
/**
|
|
32
|
+
* Load Netscape-format cookies from file
|
|
33
|
+
*/
|
|
34
|
+
function loadNetscapeCookies(path) {
|
|
35
|
+
const cookies = [];
|
|
36
|
+
if (!existsSync(path)) {
|
|
37
|
+
return cookies;
|
|
38
|
+
}
|
|
39
|
+
const lines = readFileSync(path, "utf-8").split("\n");
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (!line || (line.startsWith("#") && !line.startsWith("#HttpOnly_"))) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
let httpOnly = false;
|
|
45
|
+
let trimmed = line;
|
|
46
|
+
if (trimmed.startsWith("#HttpOnly_")) {
|
|
47
|
+
httpOnly = true;
|
|
48
|
+
trimmed = trimmed.slice("#HttpOnly_".length);
|
|
49
|
+
}
|
|
50
|
+
const parts = trimmed.split("\t");
|
|
51
|
+
if (parts.length < 7)
|
|
52
|
+
continue;
|
|
53
|
+
const [domain, _flag, cookiePath, secure, expires, name, value] = parts;
|
|
54
|
+
cookies.push({
|
|
55
|
+
name,
|
|
56
|
+
value,
|
|
57
|
+
domain,
|
|
58
|
+
path: cookiePath || "/",
|
|
59
|
+
secure: secure.toUpperCase() === "TRUE",
|
|
60
|
+
httpOnly,
|
|
61
|
+
expires: parseInt(expires, 10) || undefined,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return cookies;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Format remaining seconds into human-readable string
|
|
68
|
+
*/
|
|
69
|
+
function formatRemainsSeconds(seconds) {
|
|
70
|
+
if (seconds <= 0)
|
|
71
|
+
return "soon";
|
|
72
|
+
const days = Math.floor(seconds / 86400);
|
|
73
|
+
const hr = Math.floor((seconds % 86400) / 3600);
|
|
74
|
+
const min = Math.floor((seconds % 3600) / 60);
|
|
75
|
+
const parts = [];
|
|
76
|
+
if (days > 0)
|
|
77
|
+
parts.push(`${days} days`);
|
|
78
|
+
if (hr > 0)
|
|
79
|
+
parts.push(`${hr} hr`);
|
|
80
|
+
if (min > 0 && days === 0)
|
|
81
|
+
parts.push(`${min} min`);
|
|
82
|
+
return parts.join(" ") || "0 min";
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* OpenAI Quota Scraper
|
|
86
|
+
*
|
|
87
|
+
* Uses Playwright to fetch quota data from ChatGPT Codex analytics.
|
|
88
|
+
*/
|
|
89
|
+
export class OpenAIQuotaScraper {
|
|
90
|
+
config;
|
|
91
|
+
constructor(config = {}) {
|
|
92
|
+
this.config = {
|
|
93
|
+
cookieFile: config.cookieFile ?? DEFAULT_COOKIE_FILE,
|
|
94
|
+
headless: config.headless ?? true,
|
|
95
|
+
timeout: config.timeout ?? 60000,
|
|
96
|
+
chromePath: config.chromePath ?? "",
|
|
97
|
+
quiet: config.quiet ?? false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Set cookie file path
|
|
102
|
+
*/
|
|
103
|
+
setCookieFile(path) {
|
|
104
|
+
this.config.cookieFile = path;
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Enable/disable quiet mode
|
|
109
|
+
*/
|
|
110
|
+
setQuiet(quiet) {
|
|
111
|
+
this.config.quiet = quiet;
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Check if any cookie source exists
|
|
116
|
+
*/
|
|
117
|
+
hasCookieFile() {
|
|
118
|
+
return existsSync(this.config.cookieFile);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Scrape quota data using Playwright
|
|
122
|
+
*/
|
|
123
|
+
async scrape() {
|
|
124
|
+
const { chromium } = await import("playwright");
|
|
125
|
+
const cookies = loadNetscapeCookies(this.config.cookieFile);
|
|
126
|
+
if (cookies.length === 0) {
|
|
127
|
+
const dropHint = join(homedir(), ".pi-harness-runtime", "cookies");
|
|
128
|
+
const msg = `No OpenAI cookies found.
|
|
129
|
+
Drop your chatgpt.com cookies (Netscape or EditThisCookie JSON) into:
|
|
130
|
+
${dropHint}
|
|
131
|
+
Then run: bun run packages/cookie-sanitizer/src/sync.ts`;
|
|
132
|
+
if (!this.config.quiet)
|
|
133
|
+
console.error(`[DEBUG OpenAIQuotaScraper] ${msg}`);
|
|
134
|
+
throw new Error(msg);
|
|
135
|
+
}
|
|
136
|
+
// Launch browser
|
|
137
|
+
if (!this.config.quiet) {
|
|
138
|
+
console.log("[DEBUG OpenAIQuotaScraper] Launching browser...");
|
|
139
|
+
}
|
|
140
|
+
const browser = await chromium.launch({
|
|
141
|
+
executablePath: this.config.chromePath || undefined,
|
|
142
|
+
headless: this.config.headless,
|
|
143
|
+
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
144
|
+
});
|
|
145
|
+
const context = await browser.newContext({
|
|
146
|
+
locale: "en-US",
|
|
147
|
+
viewport: { width: 1440, height: 900 },
|
|
148
|
+
userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
149
|
+
});
|
|
150
|
+
// Inject cookies
|
|
151
|
+
await context.addCookies(cookies.map((c) => ({
|
|
152
|
+
name: c.name,
|
|
153
|
+
value: c.value,
|
|
154
|
+
domain: c.domain,
|
|
155
|
+
path: c.path,
|
|
156
|
+
secure: c.secure,
|
|
157
|
+
httpOnly: c.httpOnly,
|
|
158
|
+
expires: c.expires,
|
|
159
|
+
})));
|
|
160
|
+
const page = await context.newPage();
|
|
161
|
+
// Capture API responses
|
|
162
|
+
let usageData = null;
|
|
163
|
+
page.on("response", async (response) => {
|
|
164
|
+
const url = response.url();
|
|
165
|
+
if (url.includes("/wham/usage")) {
|
|
166
|
+
try {
|
|
167
|
+
usageData = await response.json().catch(() => null);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Ignore parse errors
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
// Navigate to analytics page
|
|
176
|
+
if (!this.config.quiet) {
|
|
177
|
+
console.log("[DEBUG OpenAIQuotaScraper] Navigating to analytics page...");
|
|
178
|
+
}
|
|
179
|
+
await page.goto(ANALYTICS_URL, {
|
|
180
|
+
waitUntil: "domcontentloaded",
|
|
181
|
+
timeout: this.config.timeout,
|
|
182
|
+
});
|
|
183
|
+
// Wait for JS to render
|
|
184
|
+
await page.waitForTimeout(5000);
|
|
185
|
+
// Wait for network to settle
|
|
186
|
+
try {
|
|
187
|
+
await page.waitForLoadState("networkidle", { timeout: 10000 });
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Network idle might not be achievable
|
|
191
|
+
}
|
|
192
|
+
// Check if redirected to login
|
|
193
|
+
const currentUrl = page.url();
|
|
194
|
+
if (currentUrl.includes("login") || currentUrl.includes("auth")) {
|
|
195
|
+
const msg = "OpenAI cookies are expired or insufficient. Please re-export cookies from chatgpt.com.";
|
|
196
|
+
if (!this.config.quiet)
|
|
197
|
+
console.error("[DEBUG OpenAIQuotaScraper] " + msg);
|
|
198
|
+
throw new Error(msg);
|
|
199
|
+
}
|
|
200
|
+
// Parse the usage data from the captured API response
|
|
201
|
+
if (!usageData) {
|
|
202
|
+
const msg = "Failed to capture usage data from API";
|
|
203
|
+
if (!this.config.quiet)
|
|
204
|
+
console.error("[DEBUG OpenAIQuotaScraper] " + msg);
|
|
205
|
+
throw new Error(msg);
|
|
206
|
+
}
|
|
207
|
+
// Extract weekly usage from primary_window
|
|
208
|
+
const primaryWindow = usageData?.rate_limit?.primary_window;
|
|
209
|
+
const weeklyUsedPct = primaryWindow?.used_percent ?? 0;
|
|
210
|
+
const resetAfterSeconds = primaryWindow?.reset_after_seconds ?? 0;
|
|
211
|
+
const resetAtEpoch = primaryWindow?.reset_at;
|
|
212
|
+
const weeklyResetsAt = formatRemainsSeconds(resetAfterSeconds);
|
|
213
|
+
// Extract credits if available
|
|
214
|
+
const credits = usageData?.credits;
|
|
215
|
+
const creditBalance = credits?.has_credits ? credits.balance : undefined;
|
|
216
|
+
if (!this.config.quiet) {
|
|
217
|
+
console.log(`[DEBUG OpenAIQuotaScraper] Weekly usage: ${weeklyUsedPct}%, resets in ${weeklyResetsAt}`);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
provider: "openai",
|
|
221
|
+
weeklyUsedPct,
|
|
222
|
+
weeklyResetsAt,
|
|
223
|
+
weeklyResetsAtEpoch: resetAtEpoch ? resetAtEpoch * 1000 : undefined,
|
|
224
|
+
resetAfterSeconds,
|
|
225
|
+
creditBalance,
|
|
226
|
+
apiEndpoint: USAGE_API,
|
|
227
|
+
scrapedAt: new Date().toISOString(),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await browser.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Quick scrape using direct API fetch (faster, but may fail without browser context)
|
|
236
|
+
*/
|
|
237
|
+
async scrapeDirect() {
|
|
238
|
+
const cookies = loadNetscapeCookies(this.config.cookieFile);
|
|
239
|
+
if (cookies.length === 0)
|
|
240
|
+
return null;
|
|
241
|
+
const cookieHeader = cookies
|
|
242
|
+
.map((c) => `${c.name}=${encodeURIComponent(c.value)}`)
|
|
243
|
+
.join("; ");
|
|
244
|
+
try {
|
|
245
|
+
const response = await fetch(USAGE_API, {
|
|
246
|
+
headers: {
|
|
247
|
+
Cookie: cookieHeader,
|
|
248
|
+
Accept: "application/json",
|
|
249
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
|
250
|
+
Referer: "https://chatgpt.com/codex/cloud/settings/analytics",
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
if (!response.ok) {
|
|
254
|
+
if (!this.config.quiet) {
|
|
255
|
+
console.log(`[DEBUG OpenAIQuotaScraper] Direct API failed: ${response.status}`);
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const data = (await response.json());
|
|
260
|
+
const primaryWindow = data?.rate_limit?.primary_window;
|
|
261
|
+
const weeklyUsedPct = primaryWindow?.used_percent ?? 0;
|
|
262
|
+
const resetAfterSeconds = primaryWindow?.reset_after_seconds ?? 0;
|
|
263
|
+
const resetAtEpoch = primaryWindow?.reset_at;
|
|
264
|
+
const weeklyResetsAt = formatRemainsSeconds(resetAfterSeconds);
|
|
265
|
+
return {
|
|
266
|
+
provider: "openai",
|
|
267
|
+
weeklyUsedPct,
|
|
268
|
+
weeklyResetsAt,
|
|
269
|
+
weeklyResetsAtEpoch: resetAtEpoch ? resetAtEpoch * 1000 : undefined,
|
|
270
|
+
resetAfterSeconds,
|
|
271
|
+
apiEndpoint: USAGE_API,
|
|
272
|
+
scrapedAt: new Date().toISOString(),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (!this.config.quiet) {
|
|
277
|
+
console.warn("[DEBUG OpenAIQuotaScraper] Direct API error:", error instanceof Error ? error.message : String(error));
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Integration helper for index.ts
|
|
285
|
+
*/
|
|
286
|
+
export class OpenAIQuotaManager {
|
|
287
|
+
scraper;
|
|
288
|
+
lastQuota;
|
|
289
|
+
lastFetchTime = 0;
|
|
290
|
+
cacheDurationMs;
|
|
291
|
+
constructor(config = {}) {
|
|
292
|
+
this.scraper = new OpenAIQuotaScraper(config);
|
|
293
|
+
this.cacheDurationMs = config.cacheDurationMs ?? 5 * 60 * 1000; // 5 min default
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Get current quota (uses cache)
|
|
297
|
+
*/
|
|
298
|
+
async getQuota(forceRefresh = false) {
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
if (!forceRefresh &&
|
|
301
|
+
this.lastQuota &&
|
|
302
|
+
now - this.lastFetchTime < this.cacheDurationMs) {
|
|
303
|
+
return this.lastQuota;
|
|
304
|
+
}
|
|
305
|
+
// Try direct API first (faster)
|
|
306
|
+
const directResult = await this.scraper.scrapeDirect();
|
|
307
|
+
if (directResult) {
|
|
308
|
+
this.lastQuota = directResult;
|
|
309
|
+
this.lastFetchTime = now;
|
|
310
|
+
return this.lastQuota;
|
|
311
|
+
}
|
|
312
|
+
// Fall back to browser scrape
|
|
313
|
+
try {
|
|
314
|
+
this.lastQuota = await this.scraper.scrape();
|
|
315
|
+
this.lastFetchTime = now;
|
|
316
|
+
return this.lastQuota;
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
// Return cached value if available
|
|
320
|
+
if (this.lastQuota) {
|
|
321
|
+
return this.lastQuota;
|
|
322
|
+
}
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Check if quota is available
|
|
328
|
+
*/
|
|
329
|
+
isAvailable() {
|
|
330
|
+
return this.scraper.hasCookieFile();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright Adapter — RFC-0004
|
|
3
|
+
*
|
|
4
|
+
* Uses a persistent browser profile to read provider console information
|
|
5
|
+
* when no official usage API exists. Initial target: MiniMax usage console.
|
|
6
|
+
*
|
|
7
|
+
* Also used as the E2E runner for testing workflows.
|
|
8
|
+
*/
|
|
9
|
+
// Dynamic import for Playwright (optional dependency)
|
|
10
|
+
let playwrightModule = null;
|
|
11
|
+
async function getPlaywright() {
|
|
12
|
+
if (!playwrightModule) {
|
|
13
|
+
try {
|
|
14
|
+
playwrightModule = await import("playwright");
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
console.warn("[PlaywrightRunner] Playwright not installed. E2E tests will be skipped.");
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return playwrightModule;
|
|
22
|
+
}
|
|
23
|
+
export { MiniMaxQuotaScraper } from "./minimax-quota-scraper.js";
|
|
24
|
+
/**
|
|
25
|
+
* Playwright E2E Runner for testing workflows
|
|
26
|
+
*
|
|
27
|
+
* Real implementation using Playwright library.
|
|
28
|
+
*/
|
|
29
|
+
export class PlaywrightE2ERunner {
|
|
30
|
+
config;
|
|
31
|
+
browser = null;
|
|
32
|
+
context = null;
|
|
33
|
+
page = null;
|
|
34
|
+
constructor(config = {}) {
|
|
35
|
+
this.config = {
|
|
36
|
+
headless: true,
|
|
37
|
+
slowMo: 0,
|
|
38
|
+
timeout: 30000,
|
|
39
|
+
browser: "chromium",
|
|
40
|
+
...config,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Start the browser
|
|
45
|
+
*/
|
|
46
|
+
async start() {
|
|
47
|
+
const pw = await getPlaywright();
|
|
48
|
+
if (!pw) {
|
|
49
|
+
throw new Error("Playwright not available. Install with: bun add playwright");
|
|
50
|
+
}
|
|
51
|
+
const browserType = this.config.browser === "firefox"
|
|
52
|
+
? pw.firefox
|
|
53
|
+
: this.config.browser === "webkit"
|
|
54
|
+
? pw.webkit
|
|
55
|
+
: pw.chromium;
|
|
56
|
+
this.browser = await browserType.launch({
|
|
57
|
+
headless: this.config.headless,
|
|
58
|
+
slowMo: this.config.slowMo,
|
|
59
|
+
});
|
|
60
|
+
this.context = await this.browser.newContext({
|
|
61
|
+
viewport: this.config.viewport ?? { width: 1280, height: 720 },
|
|
62
|
+
});
|
|
63
|
+
this.page = await this.context.newPage();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Stop the browser
|
|
67
|
+
*/
|
|
68
|
+
async stop() {
|
|
69
|
+
if (this.page) {
|
|
70
|
+
await this.page.close();
|
|
71
|
+
this.page = null;
|
|
72
|
+
}
|
|
73
|
+
if (this.context) {
|
|
74
|
+
await this.context.close();
|
|
75
|
+
this.context = null;
|
|
76
|
+
}
|
|
77
|
+
if (this.browser) {
|
|
78
|
+
await this.browser.close();
|
|
79
|
+
this.browser = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Navigate to a URL
|
|
84
|
+
*/
|
|
85
|
+
async navigate(url) {
|
|
86
|
+
if (!this.page)
|
|
87
|
+
throw new Error("Browser not started");
|
|
88
|
+
await this.page.goto(url, { timeout: this.config.timeout });
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Click an element
|
|
92
|
+
*/
|
|
93
|
+
async click(selector) {
|
|
94
|
+
if (!this.page)
|
|
95
|
+
throw new Error("Browser not started");
|
|
96
|
+
await this.page.waitForSelector(selector, { timeout: this.config.timeout });
|
|
97
|
+
await this.page.click(selector);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Type text into an input
|
|
101
|
+
*/
|
|
102
|
+
async type(selector, text) {
|
|
103
|
+
if (!this.page)
|
|
104
|
+
throw new Error("Browser not started");
|
|
105
|
+
await this.page.waitForSelector(selector, { timeout: this.config.timeout });
|
|
106
|
+
await this.page.fill(selector, text);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Wait for selector
|
|
110
|
+
*/
|
|
111
|
+
async wait(selector, timeoutMs) {
|
|
112
|
+
if (!this.page)
|
|
113
|
+
throw new Error("Browser not started");
|
|
114
|
+
await this.page.waitForSelector(selector, {
|
|
115
|
+
timeout: timeoutMs ?? this.config.timeout,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Take a screenshot
|
|
120
|
+
*/
|
|
121
|
+
async screenshot(path) {
|
|
122
|
+
if (!this.page)
|
|
123
|
+
throw new Error("Browser not started");
|
|
124
|
+
await this.page.screenshot({ path, fullPage: true });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Assert a condition
|
|
128
|
+
*/
|
|
129
|
+
async assert(condition, message) {
|
|
130
|
+
if (!this.page)
|
|
131
|
+
throw new Error("Browser not started");
|
|
132
|
+
const result = await this.page.evaluate((cond) => {
|
|
133
|
+
// eslint-disable-next-line no-eval
|
|
134
|
+
return eval(String(cond)); // eslint-disable-line
|
|
135
|
+
}, condition);
|
|
136
|
+
if (!result && message) {
|
|
137
|
+
throw new Error(message);
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Get the underlying page for advanced operations
|
|
143
|
+
*/
|
|
144
|
+
getPage() {
|
|
145
|
+
return this.page;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Start tracing for debugging
|
|
149
|
+
*/
|
|
150
|
+
async startTracing(_outputPath) {
|
|
151
|
+
if (!this.page)
|
|
152
|
+
throw new Error("Browser not started");
|
|
153
|
+
await this.page
|
|
154
|
+
.context()
|
|
155
|
+
.tracing.start({ screenshots: true, snapshots: true });
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Stop tracing and save
|
|
159
|
+
*/
|
|
160
|
+
async stopTracing(outputPath) {
|
|
161
|
+
if (!this.page)
|
|
162
|
+
throw new Error("Browser not started");
|
|
163
|
+
await this.page.context().tracing.stop({ path: outputPath });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota Status Display — RFC-0031
|
|
3
|
+
*
|
|
4
|
+
* Integrates MiniMax quota scraper with harness status display.
|
|
5
|
+
* Provides real-time quota data for the status bar.
|
|
6
|
+
*/
|
|
7
|
+
import { MiniMaxQuotaManager, } from "./minimax-quota-scraper.js";
|
|
8
|
+
/**
|
|
9
|
+
* Format quota data for display
|
|
10
|
+
*/
|
|
11
|
+
export function formatQuotaStatus(data) {
|
|
12
|
+
const h5Left = Math.max(0, 100 - data.h5UsedPct);
|
|
13
|
+
const weeklyLeft = Math.max(0, 100 - data.weeklyUsedPct);
|
|
14
|
+
const short = `5h: ${h5Left.toFixed(0)}% left`;
|
|
15
|
+
const extendedParts = [short, `week: ${weeklyLeft.toFixed(0)}% left`];
|
|
16
|
+
return {
|
|
17
|
+
short,
|
|
18
|
+
extended: extendedParts.join(" · "),
|
|
19
|
+
isCritical: h5Left < 20,
|
|
20
|
+
isExhausted: h5Left <= 0,
|
|
21
|
+
h5ResetsAt: data.h5ResetsAt,
|
|
22
|
+
weeklyResetsAt: data.weeklyResetsAt,
|
|
23
|
+
data,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Quota Status Manager
|
|
28
|
+
*
|
|
29
|
+
* Manages quota display with automatic refresh.
|
|
30
|
+
*/
|
|
31
|
+
export class QuotaStatusManager {
|
|
32
|
+
manager;
|
|
33
|
+
lastStatus;
|
|
34
|
+
refreshTimer;
|
|
35
|
+
config;
|
|
36
|
+
constructor(config) {
|
|
37
|
+
this.config = {
|
|
38
|
+
refreshIntervalMs: 5 * 60 * 1000, // 5 min default
|
|
39
|
+
...config,
|
|
40
|
+
};
|
|
41
|
+
if (config.provider === "minimax") {
|
|
42
|
+
this.manager = new MiniMaxQuotaManager({
|
|
43
|
+
cookieFile: config.cookieFile,
|
|
44
|
+
cacheDurationMs: config.refreshIntervalMs,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Check if quota tracking is available
|
|
50
|
+
*/
|
|
51
|
+
isAvailable() {
|
|
52
|
+
if (this.config.provider === "minimax") {
|
|
53
|
+
return this.manager?.isAvailable() ?? false;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get current quota status (async refresh if needed)
|
|
59
|
+
*/
|
|
60
|
+
async getStatus(forceRefresh = false) {
|
|
61
|
+
if (!this.manager) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const data = await this.manager.getQuota(forceRefresh);
|
|
66
|
+
this.lastStatus = this.config.formatter
|
|
67
|
+
? this.config.formatter(data)
|
|
68
|
+
: formatQuotaStatus(data);
|
|
69
|
+
return this.lastStatus;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
console.warn("[QuotaStatus] Failed to fetch quota:", error);
|
|
73
|
+
return this.lastStatus ?? null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get last cached status (synchronous)
|
|
78
|
+
*/
|
|
79
|
+
getCachedStatus() {
|
|
80
|
+
return this.lastStatus ?? null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Start automatic refresh
|
|
84
|
+
*/
|
|
85
|
+
startAutoRefresh(callback) {
|
|
86
|
+
if (this.refreshTimer) {
|
|
87
|
+
return; // Already running
|
|
88
|
+
}
|
|
89
|
+
// Initial fetch
|
|
90
|
+
this.getStatus().then((status) => {
|
|
91
|
+
if (status && callback) {
|
|
92
|
+
callback(status);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
// Periodic refresh
|
|
96
|
+
this.refreshTimer = setInterval(async () => {
|
|
97
|
+
const status = await this.getStatus();
|
|
98
|
+
if (status && callback) {
|
|
99
|
+
callback(status);
|
|
100
|
+
}
|
|
101
|
+
}, this.config.refreshIntervalMs);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Stop automatic refresh
|
|
105
|
+
*/
|
|
106
|
+
stopAutoRefresh() {
|
|
107
|
+
if (this.refreshTimer) {
|
|
108
|
+
clearInterval(this.refreshTimer);
|
|
109
|
+
this.refreshTimer = undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Generate status bar string
|
|
114
|
+
*/
|
|
115
|
+
async getStatusBarString() {
|
|
116
|
+
const status = await this.getStatus();
|
|
117
|
+
if (!status) {
|
|
118
|
+
return `${this.config.provider}: no quota data`;
|
|
119
|
+
}
|
|
120
|
+
const icon = status.isExhausted ? "🚫" : status.isCritical ? "⚠️" : "✅";
|
|
121
|
+
return `${icon} ${this.config.provider} ${status.extended}`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Create a quota status manager from environment variables
|
|
126
|
+
*/
|
|
127
|
+
export function createQuotaStatusManagerFromEnv(provider = process.env.QUOTA_PROVIDER ?? "minimax") {
|
|
128
|
+
// Check if quota tracking is enabled
|
|
129
|
+
if (process.env.QUOTA_AUTO_FETCH !== "true") {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
// Check if cookie file exists
|
|
133
|
+
const cookieFile = process.env.QUOTA_COOKIE_FILE ??
|
|
134
|
+
`${process.env.HOME ?? process.env.USERPROFILE}/.config/minimax-cookies.txt`;
|
|
135
|
+
return new QuotaStatusManager({
|
|
136
|
+
provider,
|
|
137
|
+
cookieFile,
|
|
138
|
+
refreshIntervalMs: parseInt(process.env.QUOTA_REFRESH_MS ?? "300000", 10),
|
|
139
|
+
});
|
|
140
|
+
}
|