pi-harness-runtime 0.3.2-beta.1 → 0.4.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,407 @@
1
+ /**
2
+ * Partial Response Recovery — RFC-0021
3
+ *
4
+ * Persist and recover incomplete agent outputs.
5
+ * Never loses partial response text.
6
+ *
7
+ * Artifact Layout:
8
+ * harness/partial/
9
+ * task_004/
10
+ * partial_001.md
11
+ * partial_002.md
12
+ * merged.md
13
+ * recovery_status.json
14
+ * files.json
15
+ */
16
+
17
+ import {
18
+ existsSync,
19
+ mkdirSync,
20
+ writeFileSync,
21
+ readFileSync,
22
+ readdirSync,
23
+ unlinkSync,
24
+ } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { homedir } from "node:os";
27
+
28
+ export interface PartialResponse {
29
+ id: string;
30
+ timestamp: string;
31
+ taskId: string;
32
+ content: string;
33
+ source: "output_limit" | "compaction" | "interrupt" | "error";
34
+ metadata?: Record<string, unknown>;
35
+ }
36
+
37
+ export interface RecoveryStatus {
38
+ taskId: string;
39
+ status: "pending" | "continuing" | "completed" | "escalated" | "failed";
40
+ partials: string[];
41
+ mergedOutput?: string;
42
+ attempts: number;
43
+ lastError?: string;
44
+ completedAt?: string;
45
+ }
46
+
47
+ export interface MergeOptions {
48
+ method: "markdown_sections" | "code_blocks" | "json_concat" | "patch_merge";
49
+ removeDuplicates?: boolean;
50
+ preserveOrder?: boolean;
51
+ }
52
+
53
+ export class PartialRecovery {
54
+ private readonly rootDir: string;
55
+ private readonly taskId: string;
56
+ private partials: PartialResponse[] = [];
57
+
58
+ constructor(jobId: string, taskId: string, rootDir?: string) {
59
+ this.rootDir =
60
+ rootDir ?? join(homedir(), ".pi", "harness", jobId, "partial", taskId);
61
+ this.taskId = taskId;
62
+ this.ensureDir();
63
+ this.loadExistingPartials();
64
+ }
65
+
66
+ /**
67
+ * Save a partial response
68
+ */
69
+ savePartial(
70
+ content: string,
71
+ source: PartialResponse["source"] = "output_limit",
72
+ metadata?: Record<string, unknown>,
73
+ ): PartialResponse {
74
+ const partial: PartialResponse = {
75
+ id: this.generateId(),
76
+ timestamp: new Date().toISOString(),
77
+ taskId: this.taskId,
78
+ content,
79
+ source,
80
+ metadata,
81
+ };
82
+
83
+ this.partials.push(partial);
84
+
85
+ // Save to disk
86
+ const path = join(this.rootDir, `${partial.id}.md`);
87
+ writeFileSync(path, content, "utf-8");
88
+
89
+ // Update files manifest
90
+ this.saveFilesManifest();
91
+
92
+ // Update recovery status
93
+ this.updateStatus("continuing");
94
+
95
+ return partial;
96
+ }
97
+
98
+ /**
99
+ * Get all partial responses
100
+ */
101
+ getPartials(): PartialResponse[] {
102
+ return [...this.partials];
103
+ }
104
+
105
+ /**
106
+ * Get the count of partials
107
+ */
108
+ getCount(): number {
109
+ return this.partials.length;
110
+ }
111
+
112
+ /**
113
+ * Merge partials using the specified strategy
114
+ */
115
+ merge(options: MergeOptions = { method: "markdown_sections" }): string {
116
+ if (this.partials.length === 0) {
117
+ return "";
118
+ }
119
+
120
+ let merged: string;
121
+
122
+ switch (options.method) {
123
+ case "markdown_sections":
124
+ merged = this.mergeAsMarkdownSections();
125
+ break;
126
+ case "code_blocks":
127
+ merged = this.mergeCodeBlocks();
128
+ break;
129
+ case "json_concat":
130
+ merged = this.mergeJson();
131
+ break;
132
+ case "patch_merge":
133
+ merged = this.mergePatchBased();
134
+ break;
135
+ default:
136
+ merged = this.mergeAsMarkdownSections();
137
+ }
138
+
139
+ // Remove duplicates if requested
140
+ if (options.removeDuplicates) {
141
+ merged = this.removeDuplicates(merged);
142
+ }
143
+
144
+ // Save merged output
145
+ const mergedPath = join(this.rootDir, "merged.md");
146
+ writeFileSync(mergedPath, merged, "utf-8");
147
+
148
+ return merged;
149
+ }
150
+
151
+ /**
152
+ * Load partials from a continuation prompt
153
+ */
154
+ loadFromContinuationPrompt(prompt: string): void {
155
+ // Extract code blocks and content from continue_prompt.md
156
+ const codeBlockMatch = prompt.match(/```[\s\S]*?```/g);
157
+ if (codeBlockMatch) {
158
+ const content = codeBlockMatch.join("\n\n");
159
+ this.savePartial(content, "output_limit", { fromContinuation: true });
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Check if recovery is needed
165
+ */
166
+ hasPartials(): boolean {
167
+ return this.partials.length > 0;
168
+ }
169
+
170
+ /**
171
+ * Check if we should escalate (too many partials)
172
+ */
173
+ shouldEscalate(maxPartials: number = 10): boolean {
174
+ return this.partials.length >= maxPartials;
175
+ }
176
+
177
+ /**
178
+ * Mark recovery as completed
179
+ */
180
+ markCompleted(): void {
181
+ this.updateStatus("completed");
182
+ }
183
+
184
+ /**
185
+ * Mark recovery as failed
186
+ */
187
+ markFailed(error: string): void {
188
+ this.updateStatus("failed", error);
189
+ }
190
+
191
+ /**
192
+ * Mark recovery as escalated
193
+ */
194
+ markEscalated(): void {
195
+ this.updateStatus("escalated");
196
+ }
197
+
198
+ /**
199
+ * Get recovery status
200
+ */
201
+ getStatus(): RecoveryStatus {
202
+ const statusPath = join(this.rootDir, "recovery_status.json");
203
+ if (existsSync(statusPath)) {
204
+ try {
205
+ return JSON.parse(readFileSync(statusPath, "utf-8")) as RecoveryStatus;
206
+ } catch {
207
+ // Fall through to default
208
+ }
209
+ }
210
+
211
+ return {
212
+ taskId: this.taskId,
213
+ status: "pending",
214
+ partials: [],
215
+ attempts: 0,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Clean up partials (after successful completion)
221
+ */
222
+ cleanup(keepMerged: boolean = true): void {
223
+ if (!existsSync(this.rootDir)) {
224
+ return;
225
+ }
226
+
227
+ const files = readdirSync(this.rootDir);
228
+ for (const file of files) {
229
+ if (file === "merged.md" && keepMerged) {
230
+ continue;
231
+ }
232
+ if (file === "recovery_status.json") {
233
+ continue;
234
+ }
235
+ try {
236
+ unlinkSync(join(this.rootDir, file));
237
+ } catch {
238
+ // Ignore errors
239
+ }
240
+ }
241
+ }
242
+
243
+ // ─── Private Methods ────────────────────────────────────────────────
244
+
245
+ private ensureDir(): void {
246
+ if (!existsSync(this.rootDir)) {
247
+ mkdirSync(this.rootDir, { recursive: true });
248
+ }
249
+ }
250
+
251
+ private loadExistingPartials(): void {
252
+ if (!existsSync(this.rootDir)) {
253
+ return;
254
+ }
255
+
256
+ const files = readdirSync(this.rootDir);
257
+ for (const file of files) {
258
+ if (file.endsWith(".md") && file !== "merged.md") {
259
+ const path = join(this.rootDir, file);
260
+ const content = readFileSync(path, "utf-8");
261
+ const id = file.replace(".md", "");
262
+
263
+ this.partials.push({
264
+ id,
265
+ timestamp: new Date().toISOString(),
266
+ taskId: this.taskId,
267
+ content,
268
+ source: "output_limit",
269
+ });
270
+ }
271
+ }
272
+
273
+ // Sort by id
274
+ this.partials.sort((a, b) => a.id.localeCompare(b.id));
275
+ }
276
+
277
+ private generateId(): string {
278
+ const count = this.partials.length + 1;
279
+ return `partial_${String(count).padStart(3, "0")}`;
280
+ }
281
+
282
+ private saveFilesManifest(): void {
283
+ const manifestPath = join(this.rootDir, "files.json");
284
+ const files = this.partials.map((p) => ({
285
+ id: p.id,
286
+ file: `${p.id}.md`,
287
+ timestamp: p.timestamp,
288
+ source: p.source,
289
+ }));
290
+ writeFileSync(manifestPath, JSON.stringify(files, null, 2), "utf-8");
291
+ }
292
+
293
+ private updateStatus(status: RecoveryStatus["status"], error?: string): void {
294
+ const statusPath = join(this.rootDir, "recovery_status.json");
295
+ const current = this.getStatus();
296
+
297
+ const updated: RecoveryStatus = {
298
+ taskId: this.taskId,
299
+ status,
300
+ partials: this.partials.map((p) => `${p.id}.md`),
301
+ mergedOutput: existsSync(join(this.rootDir, "merged.md"))
302
+ ? "merged.md"
303
+ : undefined,
304
+ attempts: current.attempts + 1,
305
+ lastError: error ?? current.lastError,
306
+ completedAt:
307
+ status === "completed" ? new Date().toISOString() : undefined,
308
+ };
309
+
310
+ writeFileSync(statusPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
311
+ }
312
+
313
+ private mergeAsMarkdownSections(): string {
314
+ return this.partials
315
+ .map((p, i) => `## Partial ${i + 1} (${p.source})\n\n${p.content}`)
316
+ .join("\n\n---\n\n");
317
+ }
318
+
319
+ private mergeCodeBlocks(): string {
320
+ const codeBlocks: string[] = [];
321
+
322
+ for (const partial of this.partials) {
323
+ const matches = partial.content.match(/```[\s\S]*?```/g);
324
+ if (matches) {
325
+ codeBlocks.push(...matches);
326
+ }
327
+ }
328
+
329
+ // Remove duplicates
330
+ const seen = new Set<string>();
331
+ const unique: string[] = [];
332
+ for (const block of codeBlocks) {
333
+ if (!seen.has(block)) {
334
+ seen.add(block);
335
+ unique.push(block);
336
+ }
337
+ }
338
+ return unique.join("\n\n");
339
+ }
340
+
341
+ private mergeJson(): string {
342
+ const results: unknown[] = [];
343
+
344
+ for (const partial of this.partials) {
345
+ try {
346
+ const parsed = JSON.parse(partial.content);
347
+ if (Array.isArray(parsed)) {
348
+ results.push(...parsed);
349
+ } else {
350
+ results.push(parsed);
351
+ }
352
+ } catch {
353
+ // Not JSON, include as-is
354
+ results.push({ _raw: partial.content });
355
+ }
356
+ }
357
+
358
+ return JSON.stringify(results, null, 2);
359
+ }
360
+
361
+ private mergePatchBased(): string {
362
+ // Simple approach: concatenate non-overlapping parts
363
+ const parts: string[] = [];
364
+
365
+ for (const partial of this.partials) {
366
+ // Look for new content after last marker
367
+ const lines = partial.content.split("\n");
368
+ const newLines: string[] = [];
369
+
370
+ for (const line of lines) {
371
+ // Skip if it looks like a duplicate header
372
+ if (
373
+ !parts.some(
374
+ (p) =>
375
+ p.includes(line) ||
376
+ line.startsWith("#") ||
377
+ line.startsWith("---"),
378
+ )
379
+ ) {
380
+ newLines.push(line);
381
+ }
382
+ }
383
+
384
+ if (newLines.length > 0) {
385
+ parts.push(newLines.join("\n"));
386
+ }
387
+ }
388
+
389
+ return parts.join("\n\n---\n\n");
390
+ }
391
+
392
+ private removeDuplicates(text: string): string {
393
+ const lines = text.split("\n");
394
+ const seen = new Set<string>();
395
+ const unique: string[] = [];
396
+
397
+ for (const line of lines) {
398
+ const trimmed = line.trim();
399
+ if (!seen.has(trimmed) && trimmed.length > 0) {
400
+ seen.add(trimmed);
401
+ unique.push(line);
402
+ }
403
+ }
404
+
405
+ return unique.join("\n");
406
+ }
407
+ }
package/package.json CHANGED
@@ -1,15 +1,21 @@
1
1
  {
2
2
  "name": "pi-harness-runtime",
3
- "version": "0.3.2-beta.1",
3
+ "version": "0.4.0",
4
4
  "description": "[BETA] Codex-style /usage status + autonomous coding harness for pi. Not production ready — expect breaking changes.",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test",
7
+ "test": "bun test",
8
8
  "release": "standard-version",
9
9
  "release:patch": "standard-version --release-as patch",
10
10
  "release:minor": "standard-version --release-as minor",
11
11
  "release:major": "standard-version --release-as major",
12
- "release:dry-run": "standard-version --dry-run"
12
+ "release:dry-run": "standard-version --dry-run",
13
+ "auth:minimax": "bun packages/auth/src/run-minimax-auth.ts auth",
14
+ "auth:minimax:check": "bun packages/auth/src/run-minimax-auth.ts check",
15
+ "auth:minimax:scrape": "bun packages/auth/src/run-minimax-auth.ts scrape"
16
+ },
17
+ "bin": {
18
+ "harness-auth": "packages/auth/src/run-minimax-auth.ts"
13
19
  },
14
20
  "files": [
15
21
  "index.ts",
@@ -63,6 +69,10 @@
63
69
  },
64
70
  "devDependencies": {
65
71
  "@types/node": "^20.14.0",
72
+ "playwright": "^1.61.1",
66
73
  "standard-version": "^9.5.0"
74
+ },
75
+ "dependencies": {
76
+ "pi-harness-runtime": "^0.3.2-beta.1"
67
77
  }
68
78
  }
@@ -0,0 +1,60 @@
1
+ # Auth Package
2
+
3
+ Safe browser authentication for pi-harness-runtime.
4
+
5
+ ## Purpose
6
+
7
+ Provides a secure way to authenticate with provider websites (like MiniMax) using a Playwright-managed browser profile. The human owns the authentication - the agent never sees credentials.
8
+
9
+ ## Security Rules
10
+
11
+ - Human logs in manually in the browser
12
+ - No username, password, raw cookies, or session tokens stored
13
+ - Only a safe status file is written
14
+ - Agent can only check if authentication succeeded
15
+
16
+ ## Files
17
+
18
+ - `src/minimax-browser-auth.ts` - Main auth module
19
+ - `src/run-minimax-auth.ts` - CLI runner
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ # Authenticate (opens browser for manual login)
25
+ bun packages/auth/src/run-minimax-auth.ts auth
26
+
27
+ # Check authentication status
28
+ bun packages/auth/src/run-minimax-auth.ts check
29
+ ```
30
+
31
+ ## Storage
32
+
33
+ - **Browser profile**: `~/.pi-harness-runtime/browser-profiles/minimax/`
34
+ - **Status file**: `~/.pi-harness-runtime/auth/minimax-auth-status.json`
35
+
36
+ ## Status File Format
37
+
38
+ ```json
39
+ {
40
+ "provider": "minimax",
41
+ "authenticated": true,
42
+ "checked_at": "2026-07-04T08:00:00.000Z",
43
+ "page_url": "https://platform.minimax.io/console/usage",
44
+ "detected_text_sample": "Usage Plan Credits Reset"
45
+ }
46
+ ```
47
+
48
+ ## What is NOT stored
49
+
50
+ - Passwords
51
+ - Raw cookies
52
+ - Session tokens
53
+ - LocalStorage values
54
+ - API keys
55
+
56
+ ## Limitations
57
+
58
+ - Requires a display (headed browser mode)
59
+ - Works on Mac/Linux with display
60
+ - Cannot run in truly headless environments without Xvfb