pi-harness-runtime 0.3.2-beta.2 → 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.
- package/CHANGELOG.md +18 -4
- package/README.md +20 -0
- package/harness/auto-compact.ts +326 -0
- package/harness/e2e/index.ts +44 -0
- package/harness/e2e/playwright-runner.ts +92 -53
- package/harness/e2e/test-engine.ts +9 -13
- package/harness/index.ts +105 -0
- package/harness/notification-events.ts +299 -0
- package/harness/output-limit-handler.ts +316 -0
- package/harness/partial-recovery.ts +407 -0
- package/package.json +5 -2
- package/packages/auth/src/minimax-browser-auth.ts +830 -180
- package/packages/auth/src/run-minimax-auth.ts +55 -7
- package/packages/notification/adapters/email-adapter.ts +126 -0
- package/packages/notification/adapters/ntfy-adapter.ts +100 -0
- package/packages/notification/adapters/telegram-adapter.ts +107 -0
- package/packages/notification/adapters/webhook-adapter.ts +75 -0
- package/packages/notification/base-adapter.ts +75 -0
- package/packages/notification/index.ts +51 -0
- package/packages/notification/notification-center.ts +286 -0
- package/packages/notification/types.ts +89 -0
- package/packages/providers/adapters.ts +102 -1
- package/packages/types/src/runtime-types.ts +3 -1
- package/skills/harness-runtime/SKILL.md +5 -1
|
@@ -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,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-harness-runtime",
|
|
3
|
-
"version": "0.
|
|
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": "
|
|
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",
|
|
@@ -71,5 +71,8 @@
|
|
|
71
71
|
"@types/node": "^20.14.0",
|
|
72
72
|
"playwright": "^1.61.1",
|
|
73
73
|
"standard-version": "^9.5.0"
|
|
74
|
+
},
|
|
75
|
+
"dependencies": {
|
|
76
|
+
"pi-harness-runtime": "^0.3.2-beta.1"
|
|
74
77
|
}
|
|
75
78
|
}
|