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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Continue Prompt Generator — RFC-0029
3
+ *
4
+ * Generates context-aware continue prompts after compaction.
5
+ * Based on the reference implementation's buildContinuePrompt() pattern.
6
+ */
7
+ // --- Continue Prompt Generator -----------------------------------------------
8
+ export class ContinuePromptGenerator {
9
+ /**
10
+ * Generate a comprehensive continue prompt for post-compact resume
11
+ */
12
+ generate(context) {
13
+ const parts = [
14
+ "# Continue Previous Task",
15
+ "",
16
+ "## Task",
17
+ context.requirement,
18
+ "",
19
+ ];
20
+ if (context.whatWasCompleted.length > 0) {
21
+ parts.push("## What Was Completed");
22
+ for (const item of context.whatWasCompleted) {
23
+ parts.push(`- ${item}`);
24
+ }
25
+ parts.push("");
26
+ }
27
+ if (context.partialFiles.length > 0) {
28
+ parts.push("## Partial Files Created");
29
+ parts.push("Review these files — they may contain incomplete work:");
30
+ for (const file of context.partialFiles) {
31
+ parts.push(`- ${file}`);
32
+ }
33
+ parts.push("");
34
+ }
35
+ if (context.decisions.length > 0) {
36
+ parts.push("## Key Decisions");
37
+ for (const decision of context.decisions) {
38
+ parts.push(`- ${decision}`);
39
+ }
40
+ parts.push("");
41
+ }
42
+ if (context.whatNeedsToBeDone.length > 0) {
43
+ parts.push("## What Needs To Be Done");
44
+ for (const item of context.whatNeedsToBeDone) {
45
+ parts.push(`- ${item}`);
46
+ }
47
+ parts.push("");
48
+ }
49
+ parts.push("## Instructions", "", "1. Review any partial files created above", "2. Continue from where the previous session ended", "3. Complete the remaining work listed in 'What Needs To Be Done'", "4. Ensure all tests pass before marking complete", "", "**Do not repeat work that was already completed.**", "");
50
+ if (context.nextStep) {
51
+ parts.push("## Suggested Next Step");
52
+ parts.push(context.nextStep);
53
+ }
54
+ if (context.workingDir) {
55
+ parts.push("", `Working directory: \`${context.workingDir}\``);
56
+ }
57
+ return parts.join("\n");
58
+ }
59
+ /**
60
+ * Generate a minimal continue message for quick resume
61
+ */
62
+ generateMinimal(context) {
63
+ const recent = context.recentMessages
64
+ .slice(-5)
65
+ .map((m) => `[${m.role}]\n${this.truncateContent(m.content, 500)}`)
66
+ .join("\n\n");
67
+ return [
68
+ "Continue from where you left off.",
69
+ "",
70
+ "## Summary of Earlier Work",
71
+ context.summary,
72
+ "",
73
+ "## Recent Context",
74
+ recent,
75
+ "",
76
+ "**Do not repeat work already done.** Focus on completing the remaining work.",
77
+ ].join("\n");
78
+ }
79
+ /**
80
+ * Generate a compact boundary message for message history
81
+ */
82
+ generateBoundary(options) {
83
+ return [
84
+ "## Earlier Conversation Summarized",
85
+ "",
86
+ `[Compacted due to: ${options.reason}]`,
87
+ `[${options.messagesCompacted} messages summarized]`,
88
+ "",
89
+ options.summary,
90
+ "",
91
+ "## Resume Point",
92
+ "Continue from the messages below.",
93
+ ].join("\n");
94
+ }
95
+ /**
96
+ * Extract continue context from compact result
97
+ */
98
+ static fromCompactResult(result, task) {
99
+ return {
100
+ taskId: task.id,
101
+ requirement: task.requirement,
102
+ whatWasCompleted: result.summary
103
+ ? ContinuePromptGenerator.extractCompletedWork(result.summary)
104
+ : [],
105
+ whatNeedsToBeDone: ["Continue from where the conversation was compacted"],
106
+ partialFiles: [],
107
+ decisions: result.summary
108
+ ? ContinuePromptGenerator.extractDecisions(result.summary)
109
+ : [],
110
+ };
111
+ }
112
+ /**
113
+ * Extract completed work items from summary text
114
+ */
115
+ static extractCompletedWork(summary) {
116
+ const completed = [];
117
+ // Look for patterns indicating completed work
118
+ const patterns = [
119
+ /completed?:?\s*([^\n.]+)/gi,
120
+ /created?:?\s*([^\n.]+)/gi,
121
+ /implemented?:?\s*([^\n.]+)/gi,
122
+ /fixed?:?\s*([^\n.]+)/gi,
123
+ /passed?:?\s*([^\n.]+)/gi,
124
+ ];
125
+ for (const pattern of patterns) {
126
+ for (const match of summary.matchAll(pattern)) {
127
+ if (match[1]) {
128
+ completed.push(match[1].trim());
129
+ }
130
+ }
131
+ }
132
+ return completed.slice(0, 5);
133
+ }
134
+ /**
135
+ * Extract decisions from summary text
136
+ */
137
+ static extractDecisions(summary) {
138
+ const decisions = [];
139
+ const patterns = [
140
+ /decided?:?\s*([^\n.]+)/gi,
141
+ /chose?:?\s*([^\n.]+)/gi,
142
+ /approach?:?\s*([^\n.]+)/gi,
143
+ /using?:?\s*([^\n.]+)/gi,
144
+ ];
145
+ for (const pattern of patterns) {
146
+ for (const match of summary.matchAll(pattern)) {
147
+ if (match[1]) {
148
+ decisions.push(match[1].trim());
149
+ }
150
+ }
151
+ }
152
+ return decisions.slice(0, 5);
153
+ }
154
+ /**
155
+ * Truncate content to max length
156
+ */
157
+ truncateContent(content, maxLength) {
158
+ if (content.length <= maxLength)
159
+ return content;
160
+ return content.substring(0, maxLength) + "... [truncated]";
161
+ }
162
+ }
163
+ // --- Singleton Instance ------------------------------------------------------
164
+ export const continuePromptGenerator = new ContinuePromptGenerator();
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Strip trailing timestamp / reset-window noise that flattened innerText
3
+ * concatenates onto values (e.g. "1 day 13 hr 00 UTC", "No credits yet 00 UTC").
4
+ */
5
+ function stripTrailingNoise(value) {
6
+ return value
7
+ .replace(/\s+\d{1,2}(?::\d{2})?\s+UTC.*$/i, "")
8
+ .replace(/\s+UTC.*$/i, "")
9
+ .replace(/\s+resets?\s+in\s+.*$/i, "")
10
+ .replace(/\s*\.{2,}.*$/, "")
11
+ .trim();
12
+ }
13
+ /**
14
+ * Parse quota data from visible MiniMax usage page text.
15
+ */
16
+ export function parseMiniMaxQuotaText(text) {
17
+ const data = {};
18
+ const normalized = text.replace(/\r/g, "");
19
+ const h5Match = normalized.match(/5h(?:\s+limit)?[\s\S]{0,200}?Used\s*(\d+(?:\.\d+)?)\s*%/i);
20
+ if (h5Match) {
21
+ data.h5UsedPct = parseFloat(h5Match[1]);
22
+ }
23
+ const h5ResetMatch = normalized.match(/5h(?:\s+limit)?[\s\S]{0,200}?Resets?\s+in\s+([^\n%]+)/i);
24
+ if (h5ResetMatch) {
25
+ data.h5ResetsAt = stripTrailingNoise(h5ResetMatch[1]);
26
+ }
27
+ const weeklyMatch = normalized.match(/week(?:ly)?(?:\s+limit)?[\s\S]{0,200}?Used\s*(\d+(?:\.\d+)?)\s*%/i);
28
+ if (weeklyMatch) {
29
+ data.weeklyUsedPct = parseFloat(weeklyMatch[1]);
30
+ }
31
+ const weeklyResetMatch = normalized.match(/week(?:ly)?(?:\s+limit)?[\s\S]{0,200}?Resets?\s+in\s+([^\n%]+)/i);
32
+ if (weeklyResetMatch) {
33
+ data.weeklyResetsAt = stripTrailingNoise(weeklyResetMatch[1]);
34
+ }
35
+ const creditMatch = normalized.match(/credit(?:\s+balance)?[^:\n]*:\s*([^\n]+)/i);
36
+ if (creditMatch) {
37
+ const cleaned = stripTrailingNoise(creditMatch[1]);
38
+ if (cleaned) {
39
+ data.creditBalance = cleaned;
40
+ }
41
+ }
42
+ const tokenSectionMatch = normalized.match(/token[^:]*:?\s*([\d.,]+\s*(?:M|B|K)?\s*tokens?)/gi);
43
+ if (tokenSectionMatch) {
44
+ data.tokenUsage = {
45
+ currentMonth: tokenSectionMatch[0] || "",
46
+ last7Days: tokenSectionMatch[1] || "",
47
+ last30Days: tokenSectionMatch[2] || "",
48
+ total: tokenSectionMatch[3] || "",
49
+ };
50
+ }
51
+ return data;
52
+ }
@@ -0,0 +1,475 @@
1
+ /**
2
+ * MiniMax Quota Scraper — RFC-0031
3
+ *
4
+ * Automatically fetches quota data from MiniMax console using Playwright.
5
+ * Uses cookie-based authentication to access the usage page.
6
+ *
7
+ * Based on the tactic in pi-harness-runtime/AGENTS.md:
8
+ * - Cookie-backed fetch returns static HTML only
9
+ * - Must use headless Playwright with injected cookies
10
+ * - Capture API responses for usage data
11
+ */
12
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { parseMiniMaxQuotaText } from "./minimax-quota-parser.js";
16
+ const DEFAULT_URL = "https://platform.minimax.io/console/usage?cycle_type=1";
17
+ const DEFAULT_COOKIE_FILE = join(homedir(), ".config", "minimax-cookies.txt");
18
+ // API endpoints to capture
19
+ const API_TERMS = [
20
+ "api",
21
+ "usage",
22
+ "billing",
23
+ "quota",
24
+ "consumption",
25
+ "recharge",
26
+ "resource",
27
+ "plan",
28
+ "subscription",
29
+ "token_plan",
30
+ ];
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
+ * Redact sensitive values from text
68
+ */
69
+ function redact(text) {
70
+ return text
71
+ .replace(/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/g, "[JWT_REDACTED]")
72
+ .replace(/api[_ -]?key|access[_ -]?token|refresh[_ -]?token|authorization|cookie|session|secret/gi, (match) => `${match}=[REDACTED]`)
73
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]");
74
+ }
75
+ /**
76
+ * Parse a percentage string like "50%" or "2%" into a number.
77
+ */
78
+ function parsePctStr(value) {
79
+ if (typeof value !== "string")
80
+ return undefined;
81
+ const m = value.match(/(\d+(?:\.\d+)?)/);
82
+ return m ? parseFloat(m[1]) : undefined;
83
+ }
84
+ /**
85
+ * Format remaining time until an epoch-ms deadline (e.g. "4 hr 37 min").
86
+ */
87
+ function formatRemainsMs(epochMs) {
88
+ if (typeof epochMs !== "number" || epochMs <= 0)
89
+ return undefined;
90
+ const ms = epochMs - Date.now();
91
+ if (ms <= 0)
92
+ return "soon";
93
+ const totalMin = Math.floor(ms / 60000);
94
+ const days = Math.floor(totalMin / 1440);
95
+ const hr = Math.floor((totalMin % 1440) / 60);
96
+ const min = totalMin % 60;
97
+ const parts = [];
98
+ if (days > 0)
99
+ parts.push(`${days} day${days > 1 ? "s" : ""}`);
100
+ if (hr > 0)
101
+ parts.push(`${hr} hr`);
102
+ if (min > 0)
103
+ parts.push(`${min} min`);
104
+ return parts.join(" ") || "0 min";
105
+ }
106
+ /**
107
+ * MiniMax Quota Scraper
108
+ *
109
+ * Uses headless Playwright to fetch quota data from the MiniMax console.
110
+ */
111
+ export class MiniMaxQuotaScraper {
112
+ config;
113
+ capturedEndpoints = [];
114
+ constructor(config = {}) {
115
+ this.config = {
116
+ cookieFile: config.cookieFile ?? DEFAULT_COOKIE_FILE,
117
+ url: config.url ?? DEFAULT_URL,
118
+ headless: config.headless ?? true,
119
+ timeout: config.timeout ?? 90000,
120
+ chromePath: config.chromePath ?? "",
121
+ quiet: config.quiet ?? false,
122
+ };
123
+ }
124
+ /**
125
+ * Set cookie file path
126
+ */
127
+ setCookieFile(path) {
128
+ this.config.cookieFile = path;
129
+ return this;
130
+ }
131
+ /**
132
+ * Enable/disable quiet mode
133
+ */
134
+ setQuiet(quiet) {
135
+ this.config.quiet = quiet;
136
+ return this;
137
+ }
138
+ /**
139
+ * Set MiniMax console URL
140
+ */
141
+ setUrl(url) {
142
+ this.config.url = url;
143
+ return this;
144
+ }
145
+ /**
146
+ * Scrape quota data. Tries the direct MiniMax API first (fast, no
147
+ * browser — ~200ms), then falls back to a headless browser scrape.
148
+ */
149
+ async scrape() {
150
+ try {
151
+ const apiData = await this.scrapeViaDirectApi();
152
+ if (apiData) {
153
+ if (!this.config.quiet) {
154
+ // console.log("[MiniMaxQuotaScraper] direct API success");
155
+ }
156
+ return apiData;
157
+ }
158
+ if (!this.config.quiet) {
159
+ // console.log(
160
+ // "[MiniMaxQuotaScraper] direct API unavailable, falling back to browser",
161
+ // );
162
+ }
163
+ }
164
+ catch (error) {
165
+ if (!this.config.quiet) {
166
+ console.warn("[MiniMaxQuotaScraper] direct API failed:", error instanceof Error ? error.message : String(error));
167
+ }
168
+ }
169
+ return this.scrapeViaBrowser();
170
+ }
171
+ /**
172
+ * Fetch quota directly from the MiniMax API using cookies (no browser).
173
+ * Uses the same cookie file as the browser path. Returns null when not
174
+ * authenticated so the caller can decide whether to fall back.
175
+ */
176
+ async scrapeViaDirectApi() {
177
+ const cookies = loadNetscapeCookies(this.config.cookieFile);
178
+ if (cookies.length === 0)
179
+ return null;
180
+ const cookieHeader = cookies
181
+ .map((c) => `${c.name}=${String(c.value).trim()}`)
182
+ .join("; ");
183
+ const headers = {
184
+ Cookie: cookieHeader,
185
+ Accept: "application/json, text/plain, */*",
186
+ "User-Agent": "Mozilla/5.0",
187
+ Referer: "https://platform.minimax.io/console/usage",
188
+ };
189
+ const base = "https://platform.minimax.io";
190
+ // Primary: remains_percent gives 5h + weekly used % and reset times
191
+ let general = null;
192
+ try {
193
+ const resp = await fetch(`${base}/backend/account/token_plan/remains_percent`, { headers });
194
+ if (resp.ok) {
195
+ const json = (await resp.json());
196
+ const arr = json?.model_remains;
197
+ if (Array.isArray(arr)) {
198
+ general =
199
+ arr.find((m) => m.model_name === "general") ?? arr[0] ?? null;
200
+ }
201
+ }
202
+ }
203
+ catch {
204
+ /* fall through to null */
205
+ }
206
+ // Not authenticated (or request failed) → no data
207
+ if (!general)
208
+ return null;
209
+ const h5UsedPct = parsePctStr(general.current_interval_used_percent);
210
+ const weeklyUsedPct = parsePctStr(general.current_weekly_used_percent);
211
+ const h5ResetsAtEpoch = general.end_time;
212
+ const h5ResetsAt = formatRemainsMs(h5ResetsAtEpoch);
213
+ const weeklyResetsAtEpoch = general.weekly_end_time;
214
+ const weeklyResetsAt = formatRemainsMs(weeklyResetsAtEpoch);
215
+ // Best-effort: token usage summary
216
+ let tokenUsage;
217
+ try {
218
+ const resp = await fetch(`${base}/backend/account/token_plan/usage_summary`, { headers });
219
+ if (resp.ok) {
220
+ const json = (await resp.json());
221
+ if (json?.total_token_consumed) {
222
+ tokenUsage = {
223
+ total: json.total_token_consumed,
224
+ last30Days: "",
225
+ last7Days: "",
226
+ currentMonth: "",
227
+ };
228
+ }
229
+ }
230
+ }
231
+ catch {
232
+ /* best-effort */
233
+ }
234
+ // Best-effort: credit balance. NOTE: the API response includes an
235
+ // `api_key` field — we deliberately do NOT read or persist it.
236
+ let creditBalance;
237
+ try {
238
+ const resp = await fetch(`${base}/backend/account/token_plan_credit`, {
239
+ headers,
240
+ });
241
+ if (resp.ok) {
242
+ const json = (await resp.json());
243
+ if (typeof json?.remaining_credits === "number" &&
244
+ typeof json?.total_credits === "number") {
245
+ creditBalance = `${json.remaining_credits} / ${json.total_credits}`;
246
+ }
247
+ }
248
+ }
249
+ catch {
250
+ /* best-effort */
251
+ }
252
+ return {
253
+ provider: "minimax",
254
+ h5UsedPct: h5UsedPct ?? 0,
255
+ h5ResetsAt,
256
+ h5ResetsAtEpoch,
257
+ weeklyUsedPct: weeklyUsedPct ?? 0,
258
+ weeklyResetsAt,
259
+ weeklyResetsAtEpoch,
260
+ creditBalance,
261
+ tokenUsage,
262
+ apiEndpoints: [
263
+ `${base}/backend/account/token_plan/remains_percent`,
264
+ `${base}/backend/account/token_plan/usage_summary`,
265
+ `${base}/backend/account/token_plan_credit`,
266
+ ],
267
+ scrapedAt: new Date().toISOString(),
268
+ };
269
+ }
270
+ /**
271
+ * Scrape quota data from MiniMax console using a headless browser.
272
+ */
273
+ async scrapeViaBrowser() {
274
+ // Dynamic import for Playwright
275
+ let playwright = null;
276
+ try {
277
+ playwright = await import("playwright");
278
+ }
279
+ catch {
280
+ const msg = "Playwright not installed. Install with: bun add playwright\nThen install browsers: bunx playwright install chromium";
281
+ if (!this.config.quiet)
282
+ console.error("[DEBUG MiniMaxQuotaScraper] " + msg);
283
+ throw new Error(msg);
284
+ }
285
+ // Pre-load the canonical cache so that the runtime-owned file
286
+ // (written by packages/cookie-sanitizer) takes precedence over
287
+ // whatever `this.config.cookieFile` points at. Both eventually
288
+ // resolve to the same standard Netscape format.
289
+ const cookies = loadNetscapeCookies(this.config.cookieFile);
290
+ if (cookies.length === 0) {
291
+ const dropHint = join(homedir(), ".pi-harness-runtime", "cookies");
292
+ const msg = `No cookies found.
293
+ Drop your platform.minimax.io cookies (Netscape or EditThisCookie JSON) into:
294
+ ${dropHint}
295
+ …or run: bun packages/auth/src/run-minimax-auth.ts auth`;
296
+ if (!this.config.quiet)
297
+ console.error(`[MiniMaxQuotaScraper] ${msg}`);
298
+ throw new Error(msg);
299
+ }
300
+ // Launch browser
301
+ const browser = await playwright.chromium.launch({
302
+ executablePath: this.config.chromePath,
303
+ headless: this.config.headless,
304
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
305
+ });
306
+ const context = await browser.newContext({
307
+ locale: "en-US",
308
+ viewport: { width: 1440, height: 1000 },
309
+ });
310
+ // Inject cookies
311
+ await context.addCookies(cookies);
312
+ const page = await context.newPage();
313
+ const capturedResponses = [];
314
+ // Capture API responses
315
+ page.on("response", (resp) => {
316
+ const url = resp.url();
317
+ if (API_TERMS.some((term) => url.toLowerCase().includes(term))) {
318
+ capturedResponses.push({
319
+ url,
320
+ body: "",
321
+ });
322
+ // We capture the URL, body will be fetched later
323
+ }
324
+ });
325
+ try {
326
+ // Navigate to usage page
327
+ await page.goto(this.config.url, {
328
+ waitUntil: "domcontentloaded",
329
+ timeout: this.config.timeout,
330
+ });
331
+ // Wait for content to load
332
+ try {
333
+ await page.waitForLoadState("networkidle", { timeout: 45000 });
334
+ }
335
+ catch {
336
+ // Network idle might not be achievable
337
+ }
338
+ // Additional wait for JS rendering
339
+ await page.waitForTimeout(5000);
340
+ // Extract visible text
341
+ const visibleText = redact(await page.locator("body").innerText({ timeout: 10000 }));
342
+ // Parse the visible text
343
+ const quotaData = parseMiniMaxQuotaText(visibleText);
344
+ // Capture API endpoint URLs
345
+ this.capturedEndpoints = capturedResponses.map((r) => r.url);
346
+ const result = {
347
+ provider: "minimax",
348
+ h5UsedPct: quotaData.h5UsedPct ?? 0,
349
+ h5ResetsAt: quotaData.h5ResetsAt,
350
+ weeklyUsedPct: quotaData.weeklyUsedPct ?? 0,
351
+ weeklyResetsAt: quotaData.weeklyResetsAt,
352
+ monthlyUsedPct: quotaData.monthlyUsedPct,
353
+ monthlyResetsAt: quotaData.monthlyResetsAt,
354
+ creditBalance: quotaData.creditBalance,
355
+ tokenUsage: quotaData.tokenUsage,
356
+ apiEndpoints: this.capturedEndpoints,
357
+ scrapedAt: new Date().toISOString(),
358
+ };
359
+ return result;
360
+ }
361
+ finally {
362
+ await browser.close();
363
+ }
364
+ }
365
+ /**
366
+ * Check if any cookie source exists.
367
+ *
368
+ * "Source" can be either:
369
+ * - the canonical cache (this.config.cookieFile) — runtime-owned,
370
+ * populated by packages/cookie-sanitizer
371
+ * - the user-facing drop folder — ~/.pi-harness-runtime/cookies/
372
+ * — populated by humans (any name, any format)
373
+ *
374
+ * Either is enough to attempt the scrape.
375
+ */
376
+ hasCookieFile() {
377
+ // Canonical cache present?
378
+ if (existsSync(this.config.cookieFile))
379
+ return true;
380
+ // Drop folder has anything readable? (Sync would normalize it on the next trigger.)
381
+ try {
382
+ const dropDir = join(homedir(), ".pi-harness-runtime", "cookies");
383
+ if (existsSync(dropDir)) {
384
+ const entries = readdirSync(dropDir);
385
+ if (entries.length > 0)
386
+ return true;
387
+ // One-level walk into subfolders.
388
+ for (const name of entries) {
389
+ try {
390
+ if (statSync(join(dropDir, name)).isDirectory()) {
391
+ const sub = readdirSync(join(dropDir, name));
392
+ if (sub.length > 0)
393
+ return true;
394
+ }
395
+ }
396
+ catch {
397
+ // ignore unreadable sub-entry
398
+ }
399
+ }
400
+ }
401
+ }
402
+ catch {
403
+ // best-effort; treat as no
404
+ }
405
+ return false;
406
+ }
407
+ /**
408
+ * Get instructions for setting up cookies
409
+ */
410
+ static getSetupInstructions() {
411
+ return `
412
+ MiniMax Quota Scraper Setup Instructions
413
+ ======================================
414
+
415
+ 1. Install Playwright:
416
+ bun add playwright
417
+ bunx playwright install chromium
418
+
419
+ 2. Export cookies from your browser:
420
+ - Install "EditThisCookie" Chrome extension
421
+ - Go to https://platform.minimax.io
422
+ - Click the extension icon
423
+ - Click "Export" → "Netscape"
424
+ - Save to ~/.config/minimax-cookies.txt
425
+
426
+ 3. Use the scraper:
427
+ const scraper = new MiniMaxQuotaScraper();
428
+ const quota = await scraper.scrape();
429
+ console.log("[DEBUG MiniMaxQuotaScraper] 5h usage:", quota.h5UsedPct, "%");
430
+ `;
431
+ }
432
+ }
433
+ // --- Quota Manager Integration -----------------------------------------------
434
+ /**
435
+ * Quota Manager that periodically fetches MiniMax quota data
436
+ */
437
+ export class MiniMaxQuotaManager {
438
+ scraper;
439
+ lastQuota;
440
+ lastFetchTime = 0;
441
+ cacheDurationMs;
442
+ constructor(config = {}) {
443
+ this.scraper = new MiniMaxQuotaScraper(config);
444
+ this.cacheDurationMs = config.cacheDurationMs ?? 5 * 60 * 1000; // 5 min default
445
+ }
446
+ /**
447
+ * Get current quota (uses cache)
448
+ */
449
+ async getQuota(forceRefresh = false) {
450
+ const now = Date.now();
451
+ if (!forceRefresh &&
452
+ this.lastQuota &&
453
+ now - this.lastFetchTime < this.cacheDurationMs) {
454
+ return this.lastQuota;
455
+ }
456
+ try {
457
+ this.lastQuota = await this.scraper.scrape();
458
+ this.lastFetchTime = now;
459
+ return this.lastQuota;
460
+ }
461
+ catch (error) {
462
+ // Return cached value if available
463
+ if (this.lastQuota) {
464
+ return this.lastQuota;
465
+ }
466
+ throw error;
467
+ }
468
+ }
469
+ /**
470
+ * Check if quota is available
471
+ */
472
+ isAvailable() {
473
+ return this.scraper.hasCookieFile();
474
+ }
475
+ }