pi-harness-runtime 0.2.0 → 0.3.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.
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Quota Manager — RFC-0003
3
+ *
4
+ * Collects quota signals from API responses, provider status,
5
+ * Playwright, and local estimates. Produces provider availability state.
6
+ */
7
+
8
+ import type {
9
+ QuotaSignal,
10
+ QuotaState,
11
+ } from "../../packages/types/src/runtime-types.ts";
12
+
13
+ export interface QuotaSignalInput {
14
+ provider: string;
15
+ source: "api_response" | "provider_status" | "playwright" | "local_estimate";
16
+ windowType: "5h" | "daily" | "weekly" | "monthly";
17
+ usedPct?: number;
18
+ exhausted?: boolean;
19
+ resetsAt?: string;
20
+ retryAfterMs?: number;
21
+ }
22
+
23
+ export class QuotaManager {
24
+ private signals: Map<string, QuotaSignal[]> = new Map();
25
+ private readonly staleThresholdMs: number;
26
+
27
+ constructor(staleThresholdMs: number = 30 * 60 * 1000) {
28
+ this.staleThresholdMs = staleThresholdMs;
29
+ }
30
+
31
+ /**
32
+ * Record a quota signal
33
+ */
34
+ recordSignal(input: QuotaSignalInput): void {
35
+ const signal: QuotaSignal = {
36
+ provider: input.provider,
37
+ windowType: input.windowType,
38
+ usedPct: input.usedPct ?? 0,
39
+ remainingPct: 100 - (input.usedPct ?? 0),
40
+ exhausted: input.exhausted ?? false,
41
+ source: input.source,
42
+ capturedAt: new Date().toISOString(),
43
+ resetsAt: input.resetsAt,
44
+ };
45
+
46
+ const key = `${input.provider}:${input.windowType}`;
47
+ const existing = this.signals.get(key) ?? [];
48
+ this.signals.set(key, [...existing, signal]);
49
+ }
50
+
51
+ /**
52
+ * Get the latest signal for a provider
53
+ */
54
+ getLatestSignal(
55
+ provider: string,
56
+ windowType?: "5h" | "daily" | "weekly" | "monthly",
57
+ ): QuotaSignal | null {
58
+ const key = windowType ? `${provider}:${windowType}` : provider;
59
+
60
+ if (windowType) {
61
+ const signals = this.signals.get(key) ?? [];
62
+ return (
63
+ signals.sort(
64
+ (a, b) => Date.parse(b.capturedAt) - Date.parse(a.capturedAt),
65
+ )[0] ?? null
66
+ );
67
+ }
68
+
69
+ // Return most recent across all window types
70
+ let latest: QuotaSignal | null = null;
71
+ let latestTs = 0;
72
+
73
+ for (const [k, sigs] of this.signals.entries()) {
74
+ if (k.startsWith(`${provider}:`)) {
75
+ const mostRecent = sigs.sort(
76
+ (a, b) => Date.parse(b.capturedAt) - Date.parse(a.capturedAt),
77
+ )[0];
78
+ if (mostRecent) {
79
+ const ts = Date.parse(mostRecent.capturedAt);
80
+ if (ts > latestTs) {
81
+ latestTs = ts;
82
+ latest = mostRecent;
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ return latest;
89
+ }
90
+
91
+ /**
92
+ * Get quota state for a provider
93
+ */
94
+ getProviderState(provider: string): QuotaState {
95
+ const signals = this.getSignals(provider);
96
+ const exhausted = signals.some(
97
+ (s) => s.exhausted || (s.usedPct !== undefined && s.usedPct >= 100),
98
+ );
99
+ const limited = signals.some(
100
+ (s) => s.usedPct !== undefined && s.usedPct >= 90,
101
+ );
102
+
103
+ // Find next reset time
104
+ let nextAvailableAt: string | undefined;
105
+ for (const signal of signals) {
106
+ if (signal.resetsAt) {
107
+ if (
108
+ !nextAvailableAt ||
109
+ Date.parse(signal.resetsAt) < Date.parse(nextAvailableAt)
110
+ ) {
111
+ nextAvailableAt = signal.resetsAt;
112
+ }
113
+ }
114
+ }
115
+
116
+ return {
117
+ provider,
118
+ available: !exhausted && !limited,
119
+ limited,
120
+ exhausted,
121
+ signals,
122
+ nextAvailableAt,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Get signals for a provider
128
+ */
129
+ getSignals(provider: string): QuotaSignal[] {
130
+ const result: QuotaSignal[] = [];
131
+ const cutoff = Date.now() - this.staleThresholdMs;
132
+
133
+ for (const [key, sigs] of this.signals.entries()) {
134
+ if (key.startsWith(`${provider}:`)) {
135
+ for (const signal of sigs) {
136
+ if (Date.parse(signal.capturedAt) >= cutoff) {
137
+ result.push(signal);
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ return result;
144
+ }
145
+
146
+ /**
147
+ * Check if provider is available
148
+ */
149
+ isAvailable(provider: string): boolean {
150
+ return this.getProviderState(provider).available;
151
+ }
152
+
153
+ /**
154
+ * Check if provider is exhausted
155
+ */
156
+ isExhausted(provider: string): boolean {
157
+ return this.getProviderState(provider).exhausted;
158
+ }
159
+
160
+ /**
161
+ * Get time until provider is available again
162
+ */
163
+ getWaitTime(provider: string): number | null {
164
+ const state = this.getProviderState(provider);
165
+ if (!state.nextAvailableAt) return null;
166
+
167
+ const waitMs = Date.parse(state.nextAvailableAt) - Date.now();
168
+ return waitMs > 0 ? waitMs : null;
169
+ }
170
+
171
+ /**
172
+ * Get best available provider from a list
173
+ */
174
+ selectBestProvider(providers: string[]): string | null {
175
+ const available = providers.filter((p) => this.isAvailable(p));
176
+
177
+ if (available.length === 0) return null;
178
+
179
+ // Sort by remaining quota (prefer those with more remaining)
180
+ available.sort((a, b) => {
181
+ const signalA = this.getLatestSignal(a);
182
+ const signalB = this.getLatestSignal(b);
183
+ const remA = signalA?.remainingPct ?? 100;
184
+ const remB = signalB?.remainingPct ?? 100;
185
+ return remB - remA;
186
+ });
187
+
188
+ return available[0];
189
+ }
190
+
191
+ /**
192
+ * Clear stale signals
193
+ */
194
+ clearStale(): void {
195
+ const cutoff = Date.now() - this.staleThresholdMs;
196
+
197
+ for (const [key, sigs] of this.signals.entries()) {
198
+ const fresh = sigs.filter((s) => Date.parse(s.capturedAt) >= cutoff);
199
+ if (fresh.length === 0) {
200
+ this.signals.delete(key);
201
+ } else {
202
+ this.signals.set(key, fresh);
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Generate quota report
209
+ */
210
+ generateReport(providers: string[]): string {
211
+ const lines = ["Quota Status Report", "=".repeat(40), ""];
212
+
213
+ for (const provider of providers) {
214
+ const state = this.getProviderState(provider);
215
+ const latest = this.getLatestSignal(provider);
216
+
217
+ const status = state.exhausted
218
+ ? "EXHAUSTED"
219
+ : state.limited
220
+ ? "LIMITED"
221
+ : "AVAILABLE";
222
+
223
+ lines.push(`${provider}: ${status}`);
224
+
225
+ if (latest) {
226
+ lines.push(` Latest: ${latest.usedPct ?? 0}% used`);
227
+ if (latest.resetsAt) {
228
+ const wait = Date.parse(latest.resetsAt) - Date.now();
229
+ if (wait > 0) {
230
+ lines.push(` Resets: in ${Math.ceil(wait / 60000)} minutes`);
231
+ }
232
+ }
233
+ lines.push(` Source: ${latest.source}`);
234
+ }
235
+
236
+ lines.push("");
237
+ }
238
+
239
+ return lines.join("\n");
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Parse quota signal from MiniMax error
245
+ */
246
+ export function parseMiniMaxError(error: unknown): QuotaSignal | null {
247
+ const e = error as Record<string, unknown>;
248
+ const msg = String(e.message ?? e.error ?? "");
249
+
250
+ // MiniMax quota error: "Error code: 2056 - Rate limit exceeded. Retry after..."
251
+ if (
252
+ msg.includes("2056") ||
253
+ msg.includes("quota") ||
254
+ msg.includes("rate limit")
255
+ ) {
256
+ // Try to extract retry time
257
+ const retryMatch = msg.match(/retry after (\d+) (seconds?|minutes?)/i);
258
+ let retryAfterMs: number | undefined;
259
+
260
+ if (retryMatch) {
261
+ const value = parseInt(retryMatch[1], 10);
262
+ const unit = retryMatch[2].toLowerCase();
263
+ retryAfterMs = unit.startsWith("minute")
264
+ ? value * 60 * 1000
265
+ : value * 1000;
266
+ }
267
+
268
+ return {
269
+ provider: "minimax",
270
+ windowType: "5h",
271
+ usedPct: 100,
272
+ remainingPct: 0,
273
+ exhausted: true,
274
+ source: "api_response",
275
+ capturedAt: new Date().toISOString(),
276
+ retryAfterMs,
277
+ };
278
+ }
279
+
280
+ return null;
281
+ }
282
+
283
+ /**
284
+ * Parse quota signal from OpenAI error
285
+ */
286
+ export function parseOpenAIError(error: unknown): QuotaSignal | null {
287
+ const e = error as Record<string, unknown>;
288
+ const code = String(e.code ?? "");
289
+ const msg = String(e.message ?? e.error ?? "");
290
+
291
+ if (code === "insufficient_quota" || code === "context_length_exceeded") {
292
+ return {
293
+ provider: "openai",
294
+ windowType: "daily",
295
+ usedPct: 100,
296
+ remainingPct: 0,
297
+ exhausted: true,
298
+ source: "api_response",
299
+ capturedAt: new Date().toISOString(),
300
+ };
301
+ }
302
+
303
+ if (msg.includes("429")) {
304
+ const retryMatch = msg.match(/retry after (\d+) (seconds?|minutes?)/i);
305
+ let retryAfterMs: number | undefined;
306
+
307
+ if (retryMatch) {
308
+ const value = parseInt(retryMatch[1], 10);
309
+ const unit = retryMatch[2].toLowerCase();
310
+ retryAfterMs = unit.startsWith("minute")
311
+ ? value * 60 * 1000
312
+ : value * 1000;
313
+ }
314
+
315
+ return {
316
+ provider: "openai",
317
+ windowType: "5h",
318
+ usedPct: 100,
319
+ remainingPct: 0,
320
+ exhausted: true,
321
+ source: "api_response",
322
+ capturedAt: new Date().toISOString(),
323
+ retryAfterMs,
324
+ };
325
+ }
326
+
327
+ return null;
328
+ }
@@ -0,0 +1,3 @@
1
+ # packages/runtime
2
+
3
+ Loop Runtime and Harness Runtime implementation will live here.
@@ -0,0 +1,3 @@
1
+ # packages/scheduler
2
+
3
+ Local scheduler and resume timers will live here.
@@ -0,0 +1,50 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ export interface ScheduledJob {
5
+ jobId: string;
6
+ status: "scheduled" | "cancelled";
7
+ reason: string;
8
+ resumeAt: string;
9
+ }
10
+
11
+ export class JsonRuntimeScheduler {
12
+ constructor(private readonly rootDir: string) {}
13
+
14
+ private schedulePath(): string {
15
+ return join(this.rootDir, "schedule.json");
16
+ }
17
+
18
+ async readAll(): Promise<ScheduledJob[]> {
19
+ try {
20
+ const text = await readFile(this.schedulePath(), "utf-8");
21
+ return JSON.parse(text) as ScheduledJob[];
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+
27
+ async writeAll(jobs: ScheduledJob[]): Promise<void> {
28
+ await mkdir(this.rootDir, { recursive: true });
29
+ await writeFile(this.schedulePath(), JSON.stringify(jobs, null, 2) + "\n", "utf-8");
30
+ }
31
+
32
+ async scheduleResume(jobId: string, resumeAt: string, reason: string): Promise<void> {
33
+ const jobs = (await this.readAll()).filter((j) => j.jobId !== jobId);
34
+ jobs.push({ jobId, status: "scheduled", resumeAt, reason });
35
+ await this.writeAll(jobs);
36
+ }
37
+
38
+ async dueJobs(now: Date = new Date()): Promise<string[]> {
39
+ const nowMs = now.getTime();
40
+ return (await this.readAll())
41
+ .filter((j) => j.status === "scheduled")
42
+ .filter((j) => Date.parse(j.resumeAt) <= nowMs)
43
+ .map((j) => j.jobId);
44
+ }
45
+
46
+ async cancel(jobId: string): Promise<void> {
47
+ const jobs = await this.readAll();
48
+ await this.writeAll(jobs.map((j) => j.jobId === jobId ? { ...j, status: "cancelled" } : j));
49
+ }
50
+ }
@@ -0,0 +1,3 @@
1
+ # packages/shared-context
2
+
3
+ Shared state/context between agents and tasks will live here.
@@ -0,0 +1,42 @@
1
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export class FileSharedContextStore {
5
+ constructor(private readonly rootDir: string) {}
6
+
7
+ private jobPath(jobId: string, file: string): string {
8
+ return join(this.rootDir, "jobs", jobId, file);
9
+ }
10
+
11
+ private async write(jobId: string, file: string, text: string): Promise<void> {
12
+ const path = this.jobPath(jobId, file);
13
+ await mkdir(dirname(path), { recursive: true });
14
+ await writeFile(path, text.endsWith("\n") ? text : text + "\n", "utf-8");
15
+ }
16
+
17
+ private async read(jobId: string, file: string): Promise<string | null> {
18
+ try {
19
+ return await readFile(this.jobPath(jobId, file), "utf-8");
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ async writeRequirement(jobId: string, text: string): Promise<void> {
26
+ await this.write(jobId, "requirement.md", text);
27
+ }
28
+
29
+ async readRequirement(jobId: string): Promise<string | null> {
30
+ return await this.read(jobId, "requirement.md");
31
+ }
32
+
33
+ async writeResumePrompt(jobId: string, text: string): Promise<void> {
34
+ await this.write(jobId, "resume_prompt.md", text);
35
+ }
36
+
37
+ async appendDecision(jobId: string, text: string): Promise<void> {
38
+ const path = this.jobPath(jobId, "decisions.md");
39
+ await mkdir(dirname(path), { recursive: true });
40
+ await appendFile(path, `\n## ${new Date().toISOString()}\n\n${text}\n`, "utf-8");
41
+ }
42
+ }
@@ -0,0 +1,3 @@
1
+ # packages/tui
2
+
3
+ pi.dev extension commands and TUI integration will live here.