pi-harness-runtime 0.3.2-beta.2 → 0.5.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,10 +1,10 @@
1
1
  {
2
2
  "name": "pi-harness-runtime",
3
- "version": "0.3.2-beta.2",
3
+ "version": "0.5.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",
@@ -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
  }