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,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
|
+
}
|