@pi-unipi/ralph 0.1.1

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/SKILL.md ADDED
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: ralph
3
+ description: >
4
+ Long-running iterative development loops. Run arbitrarily-long tasks without
5
+ diluting model attention. Triggers: ralph, ralph loop, iterative loop,
6
+ long-running task, development loop.
7
+ ---
8
+
9
+ # Ralph Loop
10
+
11
+ Long-running iterative development loops. Run complex tasks across multiple iterations with reflection checkpoints.
12
+
13
+ ## Commands
14
+
15
+ | Command | Description |
16
+ |---------|-------------|
17
+ | `/unipi:ralph start <name> [options]` | Start a new loop |
18
+ | `/unipi:ralph stop` | Pause current loop |
19
+ | `/unipi:ralph resume <name>` | Resume a paused loop |
20
+ | `/unipi:ralph status` | Show all loops |
21
+ | `/unipi:ralph cancel <name>` | Delete loop state |
22
+ | `/unipi:ralph archive <name>` | Move loop to archive |
23
+ | `/unipi:ralph clean [--all]` | Clean completed loops |
24
+ | `/unipi:ralph list --archived` | Show archived loops |
25
+ | `/unipi:ralph nuke [--yes]` | Delete all ralph data |
26
+
27
+ ## Tools
28
+
29
+ | Tool | Description |
30
+ |------|-------------|
31
+ | `ralph_start` | Start a ralph loop (LLM-callable) |
32
+ | `ralph_done` | Signal iteration complete, request next |
33
+
34
+ ## Options
35
+
36
+ - `--max-iterations N` — Stop after N iterations (default: 50)
37
+ - `--items-per-iteration N` — Process N items per iteration
38
+ - `--reflect-every N` — Reflection checkpoint every N iterations
39
+
40
+ ## How It Works
41
+
42
+ 1. **Start** — Create a task file with goals and checklist
43
+ 2. **Iterate** — LLM works on task, updates checklist, calls `ralph_done`
44
+ 3. **Reflect** — Every N iterations, pause and assess progress
45
+ 4. **Complete** — When done, LLM outputs `COMPLETE` marker
46
+
47
+ ## Integration with Workflow
48
+
49
+ When `@unipi/workflow` is present:
50
+ - `/unipi:work` suggests ralph for large tasks
51
+ - Ralph loops emit events that workflow can track
52
+ - Ralph state is available to info-screen (when present)
53
+
54
+ ## Task File Format
55
+
56
+ ```markdown
57
+ # Task
58
+
59
+ {Description}
60
+
61
+ ## Goals
62
+ - Goal 1
63
+ - Goal 2
64
+
65
+ ## Checklist
66
+ - [ ] Item 1
67
+ - [ ] Item 2
68
+ - [x] Completed item
69
+
70
+ ## Notes
71
+ {Progress notes}
72
+ ```
73
+
74
+ ## State
75
+
76
+ Loop state stored in `.unipi/ralph/` directory:
77
+ - `.unipi/ralph/{name}.md` — Task file
78
+ - `.unipi/ralph/{name}.state.json` — Loop state
79
+ - `.unipi/ralph/archive/` — Completed loops
80
+
81
+ ## Tips
82
+
83
+ - Use `--items-per-iteration` for large checklists
84
+ - Use `--reflect-every 10` for long-running tasks
85
+ - Press ESC to pause, send message to resume
86
+ - Task file is updated each iteration — check it for progress
package/index.ts ADDED
@@ -0,0 +1,548 @@
1
+ /**
2
+ * @unipi/ralph — Long-running iterative development loops
3
+ *
4
+ * Adapted from pi-ralph-wiggum with unipi event integration.
5
+ * Emits MODULE_READY, RALPH_LOOP_START/END events.
6
+ */
7
+
8
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
9
+ import {
10
+ UNIPI_EVENTS,
11
+ MODULES,
12
+ RALPH_COMPLETE_MARKER,
13
+ RALPH_TOOLS,
14
+ emitEvent,
15
+ getPackageVersion,
16
+ } from "@pi-unipi/core";
17
+
18
+ // Get info registry from global
19
+ function getInfoRegistry() {
20
+ const g = globalThis as any;
21
+ return g.__unipi_info_registry;
22
+ }
23
+ import { RalphLoopManager } from "./ralph-loop.js";
24
+ import { registerRalphTools } from "./tools.js";
25
+
26
+ /** Package version */
27
+ const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
28
+
29
+ /** Current loop manager instance (recreated on session reload) */
30
+ let manager: RalphLoopManager | null = null;
31
+
32
+ /**
33
+ * Get or create the loop manager for the current context.
34
+ */
35
+ function getManager(ctx: ExtensionContext, pi: ExtensionAPI): RalphLoopManager {
36
+ if (!manager) {
37
+ manager = new RalphLoopManager(ctx, (event, payload) =>
38
+ emitEvent(pi, event, payload),
39
+ );
40
+ }
41
+ return manager;
42
+ }
43
+
44
+ export default function (pi: ExtensionAPI) {
45
+ // Register tools
46
+ // (Manager will be created lazily on first use)
47
+
48
+ // Register commands
49
+ registerCommands(pi);
50
+
51
+ // Session lifecycle
52
+ pi.on("session_start", async (_event, ctx) => {
53
+ manager = null; // Force re-creation with new context
54
+ const mgr = getManager(ctx, pi);
55
+
56
+ // Rehydrate from disk
57
+ mgr.rehydrate();
58
+
59
+ // Announce module
60
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
61
+ name: MODULES.RALPH,
62
+ version: VERSION,
63
+ commands: [
64
+ "unipi:ralph-start",
65
+ "unipi:ralph-stop",
66
+ "unipi:ralph-resume",
67
+ "unipi:ralph-status",
68
+ "unipi:ralph-cancel",
69
+ "unipi:ralph-archive",
70
+ "unipi:ralph-clean",
71
+ "unipi:ralph-list",
72
+ "unipi:ralph-nuke",
73
+ ],
74
+ tools: [RALPH_TOOLS.START, RALPH_TOOLS.DONE],
75
+ });
76
+
77
+ // Register info group
78
+ const registry = getInfoRegistry();
79
+ if (registry) {
80
+ registry.registerGroup({
81
+ id: "ralph",
82
+ name: "Ralph Loops",
83
+ icon: "🔄",
84
+ priority: 70,
85
+ config: {
86
+ showByDefault: true,
87
+ stats: [
88
+ { id: "activeLoops", label: "Active Loops", show: true },
89
+ { id: "totalIterations", label: "Total Iterations", show: true },
90
+ { id: "status", label: "Status", show: true },
91
+ ],
92
+ },
93
+ dataProvider: async () => {
94
+ const currentLoop = mgr.getCurrentLoop();
95
+ if (!currentLoop) {
96
+ return {
97
+ activeLoops: { value: "0" },
98
+ totalIterations: { value: "0" },
99
+ status: { value: "idle" },
100
+ };
101
+ }
102
+
103
+ const state = mgr.loadState(currentLoop);
104
+ return {
105
+ activeLoops: { value: "1" },
106
+ totalIterations: { value: String(state?.iteration ?? 0) },
107
+ status: { value: state?.status ?? "unknown" },
108
+ };
109
+ },
110
+ });
111
+ }
112
+ });
113
+
114
+ // Agent lifecycle — check for completion marker
115
+ pi.on("agent_end", async (event, ctx) => {
116
+ const mgr = getManager(ctx, pi);
117
+ const currentLoop = mgr.getCurrentLoop();
118
+ if (!currentLoop) return;
119
+
120
+ const state = mgr.loadState(currentLoop);
121
+ if (!state || state.status !== "active") return;
122
+
123
+ // Check for completion marker in last assistant message
124
+ const lastAssistant = [...event.messages]
125
+ .reverse()
126
+ .find((m) => m.role === "assistant");
127
+ const text =
128
+ lastAssistant && Array.isArray(lastAssistant.content)
129
+ ? lastAssistant.content
130
+ .filter(
131
+ (c): c is { type: "text"; text: string } => c.type === "text",
132
+ )
133
+ .map((c) => c.text)
134
+ .join("\n")
135
+ : "";
136
+
137
+ if (text.includes(RALPH_COMPLETE_MARKER)) {
138
+ mgr.completeLoop(
139
+ state,
140
+ `───────────────────────────────────────────────────────────────────────
141
+ ✅ RALPH LOOP COMPLETE: ${state.name} | ${state.iteration} iterations
142
+ ───────────────────────────────────────────────────────────────────────`,
143
+ );
144
+ }
145
+ });
146
+
147
+ // Inject ralph instructions when loop is active
148
+ pi.on("before_agent_start", async (event, ctx) => {
149
+ const mgr = getManager(ctx, pi);
150
+ const currentLoop = mgr.getCurrentLoop();
151
+ if (!currentLoop) return;
152
+
153
+ const state = mgr.loadState(currentLoop);
154
+ if (!state || state.status !== "active") return;
155
+
156
+ const iterStr = `${state.iteration}${state.maxIterations > 0 ? `/${state.maxIterations}` : ""}`;
157
+
158
+ let instructions = `You are in a Ralph loop working on: ${state.taskFile}\n`;
159
+ if (state.itemsPerIteration > 0) {
160
+ instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
161
+ }
162
+ instructions += `- Update the task file as you progress\n`;
163
+ instructions += `- When FULLY COMPLETE: ${RALPH_COMPLETE_MARKER}\n`;
164
+ instructions += `- Otherwise, call ralph_done tool to proceed to next iteration`;
165
+
166
+ return {
167
+ systemPrompt:
168
+ event.systemPrompt +
169
+ `\n[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`,
170
+ };
171
+ });
172
+
173
+ // Save state on shutdown
174
+ pi.on("session_shutdown", async (_event, ctx) => {
175
+ if (!manager) return;
176
+ const currentLoop = manager.getCurrentLoop();
177
+ if (currentLoop) {
178
+ const state = manager.loadState(currentLoop);
179
+ if (state) manager.saveState(state);
180
+ }
181
+ manager = null;
182
+ });
183
+
184
+ // Register tools after manager setup
185
+ pi.on("session_start", async (_event, ctx) => {
186
+ const mgr = getManager(ctx, pi);
187
+ registerRalphTools(pi, mgr);
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Register ralph commands.
193
+ */
194
+ function registerCommands(pi: ExtensionAPI): void {
195
+ const HELP = `Ralph Loop — Long-running development loops
196
+
197
+ Commands:
198
+ /unipi:ralph start <name|path> [options] Start a new loop
199
+ /unipi:ralph stop Pause current loop
200
+ /unipi:ralph resume <name> Resume a paused loop
201
+ /unipi:ralph status Show all loops
202
+ /unipi:ralph cancel <name> Delete loop state
203
+ /unipi:ralph archive <name> Move loop to archive
204
+ /unipi:ralph clean [--all] Clean completed loops
205
+ /unipi:ralph list --archived Show archived loops
206
+ /unipi:ralph nuke [--yes] Delete all ralph data
207
+
208
+ Options:
209
+ --items-per-iteration N Suggest N items per turn (prompt hint)
210
+ --reflect-every N Reflect every N iterations
211
+ --max-iterations N Stop after N iterations (default 50)
212
+
213
+ To stop: press ESC to interrupt, then run /unipi:ralph-stop when idle`;
214
+
215
+ pi.registerCommand("unipi:ralph", {
216
+ description: "Ralph loop commands (start, stop, resume, status, etc.)",
217
+ handler: async (args, ctx) => {
218
+ const parts = args.trim().split(/\s+/);
219
+ const cmd = parts[0];
220
+ const rest = parts.slice(1).join(" ");
221
+
222
+ // We need manager for most commands
223
+ // For 'start', we'll handle it specially
224
+ if (cmd === "start") {
225
+ handleStart(rest, ctx, pi);
226
+ } else if (cmd === "stop") {
227
+ handleStop(ctx, pi);
228
+ } else if (cmd === "resume") {
229
+ handleResume(rest, ctx, pi);
230
+ } else if (cmd === "status" || cmd === "list") {
231
+ handleList(rest, ctx, pi);
232
+ } else if (cmd === "cancel") {
233
+ handleCancel(rest, ctx, pi);
234
+ } else if (cmd === "archive") {
235
+ handleArchive(rest, ctx, pi);
236
+ } else if (cmd === "clean") {
237
+ handleClean(rest, ctx, pi);
238
+ } else if (cmd === "nuke") {
239
+ handleNuke(rest, ctx, pi);
240
+ } else {
241
+ if (ctx.hasUI) ctx.ui.notify(HELP, "info");
242
+ }
243
+ },
244
+ });
245
+
246
+ // Dedicated stop command for idle-only use
247
+ pi.registerCommand("unipi:ralph-stop", {
248
+ description: "Stop active Ralph loop (idle only)",
249
+ handler: async (_args, ctx) => {
250
+ handleStop(ctx, pi);
251
+ },
252
+ });
253
+ }
254
+
255
+ function handleStart(rest: string, ctx: ExtensionContext, pi: ExtensionAPI): void {
256
+ const tokens = rest.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
257
+ let name = "";
258
+ let maxIterations = 50;
259
+ let itemsPerIteration = 0;
260
+ let reflectEvery = 0;
261
+
262
+ for (let i = 0; i < tokens.length; i++) {
263
+ const tok = tokens[i];
264
+ const next = tokens[i + 1];
265
+ if (tok === "--max-iterations" && next) {
266
+ maxIterations = parseInt(next, 10) || 0;
267
+ i++;
268
+ } else if (tok === "--items-per-iteration" && next) {
269
+ itemsPerIteration = parseInt(next, 10) || 0;
270
+ i++;
271
+ } else if (tok === "--reflect-every" && next) {
272
+ reflectEvery = parseInt(next, 10) || 0;
273
+ i++;
274
+ } else if (!tok.startsWith("--")) {
275
+ name = tok.replace(/^"|"$/g, "");
276
+ }
277
+ }
278
+
279
+ if (!name) {
280
+ if (ctx.hasUI)
281
+ ctx.ui.notify(
282
+ "Usage: /unipi:ralph start <name|path> [--items-per-iteration N] [--reflect-every N] [--max-iterations N]",
283
+ "warning",
284
+ );
285
+ return;
286
+ }
287
+
288
+ const mgr = getManager(ctx, pi);
289
+ const isPath = name.includes("/") || name.includes("\\");
290
+ const loopName = isPath
291
+ ? name.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_")
292
+ : name;
293
+ const taskFile = isPath ? name : `.unipi/ralph/${loopName}.md`;
294
+
295
+ const existing = mgr.loadState(loopName);
296
+ if (existing?.status === "active") {
297
+ if (ctx.hasUI)
298
+ ctx.ui.notify(
299
+ `Loop "${loopName}" is already active. Use /unipi:ralph resume ${loopName}`,
300
+ "warning",
301
+ );
302
+ return;
303
+ }
304
+
305
+ // Check if task file exists, create if not
306
+ const fullPath = require("node:path").resolve(ctx.cwd, taskFile);
307
+ const fs = require("node:fs");
308
+ if (!fs.existsSync(fullPath)) {
309
+ const { ensureDir } = require("@pi-unipi/core");
310
+ ensureDir(fullPath);
311
+ fs.writeFileSync(
312
+ fullPath,
313
+ `# Task\n\nDescribe your task here.\n\n## Goals\n- Goal 1\n\n## Checklist\n- [ ] Item 1\n\n## Notes\n(Update this as you work)\n`,
314
+ "utf-8",
315
+ );
316
+ if (ctx.hasUI) ctx.ui.notify(`Created task file: ${taskFile}`, "info");
317
+ }
318
+
319
+ const { tryRead } = require("@pi-unipi/core");
320
+ const content = tryRead(fullPath);
321
+ if (!content) {
322
+ if (ctx.hasUI) ctx.ui.notify(`Could not read task file: ${taskFile}`, "error");
323
+ return;
324
+ }
325
+
326
+ const state = mgr.startLoop(loopName, taskFile, content, {
327
+ maxIterations,
328
+ itemsPerIteration,
329
+ reflectEvery,
330
+ });
331
+
332
+ pi.sendUserMessage(mgr.buildPrompt(state, content, false));
333
+ }
334
+
335
+ function handleStop(ctx: ExtensionContext, pi: ExtensionAPI): void {
336
+ if (!ctx.isIdle()) {
337
+ if (ctx.hasUI) {
338
+ ctx.ui.notify(
339
+ "Agent is busy. Press ESC to interrupt, then run /unipi:ralph-stop.",
340
+ "warning",
341
+ );
342
+ }
343
+ return;
344
+ }
345
+
346
+ const mgr = getManager(ctx, pi);
347
+ let currentLoop = mgr.getCurrentLoop();
348
+ let state = currentLoop ? mgr.loadState(currentLoop) : null;
349
+
350
+ if (!state) {
351
+ const active = mgr.listLoops().find((l) => l.status === "active");
352
+ if (!active) {
353
+ if (ctx.hasUI) ctx.ui.notify("No active Ralph loop", "warning");
354
+ return;
355
+ }
356
+ state = active;
357
+ }
358
+
359
+ if (state.status !== "active") {
360
+ if (ctx.hasUI)
361
+ ctx.ui.notify(`Loop "${state.name}" is not active`, "warning");
362
+ return;
363
+ }
364
+
365
+ mgr.stopLoop(
366
+ state,
367
+ `Stopped Ralph loop: ${state.name} (iteration ${state.iteration})`,
368
+ );
369
+ }
370
+
371
+ function handleResume(
372
+ rest: string,
373
+ ctx: ExtensionContext,
374
+ pi: ExtensionAPI,
375
+ ): void {
376
+ const loopName = rest.trim();
377
+ if (!loopName) {
378
+ if (ctx.hasUI) ctx.ui.notify("Usage: /unipi:ralph resume <name>", "warning");
379
+ return;
380
+ }
381
+
382
+ const mgr = getManager(ctx, pi);
383
+ const state = mgr.resumeLoop(loopName);
384
+ if (!state) {
385
+ if (ctx.hasUI) ctx.ui.notify(`Loop "${loopName}" not found or completed`, "error");
386
+ return;
387
+ }
388
+
389
+ if (ctx.hasUI)
390
+ ctx.ui.notify(
391
+ `Resumed: ${loopName} (iteration ${state.iteration})`,
392
+ "info",
393
+ );
394
+
395
+ const content = mgr.tryReadTask(state);
396
+ if (!content) {
397
+ if (ctx.hasUI)
398
+ ctx.ui.notify(`Could not read task file: ${state.taskFile}`, "error");
399
+ return;
400
+ }
401
+
402
+ const needsReflection =
403
+ state.reflectEvery > 0 &&
404
+ state.iteration > 1 &&
405
+ (state.iteration - 1) % state.reflectEvery === 0;
406
+ pi.sendUserMessage(mgr.buildPrompt(state, content, needsReflection));
407
+ }
408
+
409
+ function handleList(
410
+ rest: string,
411
+ ctx: ExtensionContext,
412
+ pi: ExtensionAPI,
413
+ ): void {
414
+ const archived = rest.trim() === "--archived";
415
+ const mgr = getManager(ctx, pi);
416
+ const loops = mgr.listLoops(archived);
417
+
418
+ if (loops.length === 0) {
419
+ if (ctx.hasUI)
420
+ ctx.ui.notify(
421
+ archived
422
+ ? "No archived loops"
423
+ : "No loops found. Use /unipi:ralph list --archived for archived.",
424
+ "info",
425
+ );
426
+ return;
427
+ }
428
+
429
+ const label = archived ? "Archived loops" : "Ralph loops";
430
+ if (ctx.hasUI)
431
+ ctx.ui.notify(
432
+ `${label}:\n${loops.map((l) => mgr.formatLoop(l)).join("\n")}`,
433
+ "info",
434
+ );
435
+ }
436
+
437
+ function handleCancel(
438
+ rest: string,
439
+ ctx: ExtensionContext,
440
+ pi: ExtensionAPI,
441
+ ): void {
442
+ const loopName = rest.trim();
443
+ if (!loopName) {
444
+ if (ctx.hasUI) ctx.ui.notify("Usage: /unipi:ralph cancel <name>", "warning");
445
+ return;
446
+ }
447
+
448
+ const mgr = getManager(ctx, pi);
449
+ const state = mgr.loadState(loopName);
450
+ if (!state) {
451
+ if (ctx.hasUI) ctx.ui.notify(`Loop "${loopName}" not found`, "error");
452
+ return;
453
+ }
454
+
455
+ if (mgr.getCurrentLoop() === loopName) mgr.setCurrentLoop(null);
456
+ const { tryDelete } = require("@pi-unipi/core");
457
+ tryDelete(
458
+ require("node:path").resolve(
459
+ ctx.cwd,
460
+ `.unipi/ralph/${loopName.replace(/[^a-zA-Z0-9_-]/g, "_")}.state.json`,
461
+ ),
462
+ );
463
+ if (ctx.hasUI) ctx.ui.notify(`Cancelled: ${loopName}`, "info");
464
+ mgr.updateUI();
465
+ }
466
+
467
+ function handleArchive(
468
+ rest: string,
469
+ ctx: ExtensionContext,
470
+ pi: ExtensionAPI,
471
+ ): void {
472
+ const loopName = rest.trim();
473
+ if (!loopName) {
474
+ if (ctx.hasUI) ctx.ui.notify("Usage: /unipi:ralph archive <name>", "warning");
475
+ return;
476
+ }
477
+
478
+ const mgr = getManager(ctx, pi);
479
+ if (mgr.archiveLoop(loopName)) {
480
+ if (ctx.hasUI) ctx.ui.notify(`Archived: ${loopName}`, "info");
481
+ } else {
482
+ if (ctx.hasUI)
483
+ ctx.ui.notify(
484
+ `Cannot archive "${loopName}" — not found or still active`,
485
+ "warning",
486
+ );
487
+ }
488
+ }
489
+
490
+ function handleClean(
491
+ rest: string,
492
+ ctx: ExtensionContext,
493
+ pi: ExtensionAPI,
494
+ ): void {
495
+ const mgr = getManager(ctx, pi);
496
+ const cleaned = mgr.cleanCompleted(rest.trim() === "--all");
497
+
498
+ if (cleaned.length === 0) {
499
+ if (ctx.hasUI) ctx.ui.notify("No completed loops to clean", "info");
500
+ return;
501
+ }
502
+
503
+ const suffix = rest.trim() === "--all" ? " (all files)" : " (state only)";
504
+ if (ctx.hasUI)
505
+ ctx.ui.notify(
506
+ `Cleaned ${cleaned.length} loop(s)${suffix}:\n${cleaned.map((n) => ` • ${n}`).join("\n")}`,
507
+ "info",
508
+ );
509
+ }
510
+
511
+ function handleNuke(
512
+ rest: string,
513
+ ctx: ExtensionContext,
514
+ pi: ExtensionAPI,
515
+ ): void {
516
+ const force = rest.trim() === "--yes";
517
+
518
+ const run = () => {
519
+ const mgr = getManager(ctx, pi);
520
+ if (mgr.nukeAll()) {
521
+ if (ctx.hasUI) ctx.ui.notify("Removed .unipi/ralph directory.", "info");
522
+ } else {
523
+ if (ctx.hasUI) ctx.ui.notify("No .unipi/ralph directory found.", "info");
524
+ }
525
+ };
526
+
527
+ if (!force) {
528
+ if (ctx.hasUI) {
529
+ void ctx.ui
530
+ .confirm(
531
+ "Delete all Ralph loop files?",
532
+ "This deletes all .unipi/ralph state, task, and archive files.",
533
+ )
534
+ .then((confirmed) => {
535
+ if (confirmed) run();
536
+ });
537
+ } else {
538
+ if (ctx.hasUI)
539
+ ctx.ui.notify(
540
+ "Run /unipi:ralph nuke --yes to confirm. This deletes all .unipi/ralph data.",
541
+ "warning",
542
+ );
543
+ }
544
+ return;
545
+ }
546
+
547
+ run();
548
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@pi-unipi/ralph",
3
+ "version": "0.1.1",
4
+ "description": "Long-running iterative development loops for Pi coding agent",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Neuron Mr White",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
11
+ "directory": "packages/ralph"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi-extension",
16
+ "unipi",
17
+ "ralph",
18
+ "loops"
19
+ ],
20
+ "files": [
21
+ "*.ts",
22
+ "SKILL.md",
23
+ "README.md"
24
+ ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./index.ts"
28
+ ],
29
+ "skills": [
30
+ "./SKILL.md"
31
+ ]
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "@pi-unipi/core": "^0.1.0",
38
+ "@pi-unipi/info-screen": "*"
39
+ },
40
+ "peerDependencies": {
41
+ "@mariozechner/pi-ai": "*",
42
+ "@mariozechner/pi-coding-agent": "*",
43
+ "@mariozechner/pi-tui": "*",
44
+ "@sinclair/typebox": "*"
45
+ }
46
+ }
package/ralph-loop.ts ADDED
@@ -0,0 +1,431 @@
1
+ /**
2
+ * @unipi/ralph — Ralph loop state management
3
+ *
4
+ * Manages loop state, file I/O, and loop lifecycle.
5
+ * Adapted from pi-ralph-wiggum.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
11
+ import {
12
+ RALPH_DIR,
13
+ RALPH_COMPLETE_MARKER,
14
+ RALPH_DEFAULTS,
15
+ RALPH_STATUS_ICONS,
16
+ UNIPI_EVENTS,
17
+ type UnipiRalphLoopEvent,
18
+ sanitize,
19
+ ensureDir,
20
+ tryDelete,
21
+ tryRead,
22
+ safeMtimeMs,
23
+ tryRemoveDir,
24
+ readJson,
25
+ writeJson,
26
+ now,
27
+ } from "@pi-unipi/core";
28
+
29
+ /** Loop status */
30
+ export type LoopStatus = "active" | "paused" | "completed";
31
+
32
+ /** Loop state persisted to disk */
33
+ export interface LoopState {
34
+ name: string;
35
+ taskFile: string;
36
+ iteration: number;
37
+ maxIterations: number;
38
+ itemsPerIteration: number;
39
+ reflectEvery: number;
40
+ reflectInstructions: string;
41
+ active: boolean; // Backwards compat
42
+ status: LoopStatus;
43
+ startedAt: string;
44
+ completedAt?: string;
45
+ lastReflectionAt: number;
46
+ }
47
+
48
+ /** Default reflection instructions */
49
+ export const DEFAULT_REFLECT_INSTRUCTIONS = `REFLECTION CHECKPOINT
50
+
51
+ Pause and reflect on your progress:
52
+ 1. What has been accomplished so far?
53
+ 2. What's working well?
54
+ 3. What's not working or blocking progress?
55
+ 4. Should the approach be adjusted?
56
+ 5. What are the next priorities?
57
+
58
+ Update the task file with your reflection, then continue working.`;
59
+
60
+ /** Default task template */
61
+ export const DEFAULT_TEMPLATE = `# Task
62
+
63
+ Describe your task here.
64
+
65
+ ## Goals
66
+ - Goal 1
67
+ - Goal 2
68
+
69
+ ## Checklist
70
+ - [ ] Item 1
71
+ - [ ] Item 2
72
+
73
+ ## Notes
74
+ (Update this as you work)
75
+ `;
76
+
77
+ /** Ralph loop manager */
78
+ export class RalphLoopManager {
79
+ private currentLoop: string | null = null;
80
+ private ctx: ExtensionContext;
81
+ private emitFn: (event: string, payload: unknown) => void;
82
+
83
+ constructor(ctx: ExtensionContext, emitFn: (event: string, payload: unknown) => void) {
84
+ this.ctx = ctx;
85
+ this.emitFn = emitFn;
86
+ }
87
+
88
+ // --- File helpers ---
89
+
90
+ private ralphDir(): string {
91
+ return path.resolve(this.ctx.cwd, RALPH_DIR);
92
+ }
93
+
94
+ private archiveDir(): string {
95
+ return path.join(this.ralphDir(), "archive");
96
+ }
97
+
98
+ private getPath(name: string, ext: string, archived = false): string {
99
+ const dir = archived ? this.archiveDir() : this.ralphDir();
100
+ return path.join(dir, `${sanitize(name)}${ext}`);
101
+ }
102
+
103
+ // --- State management ---
104
+
105
+ private migrateState(raw: Partial<LoopState> & { name: string }): LoopState {
106
+ if (!raw.status) raw.status = raw.active ? "active" : "paused";
107
+ raw.active = raw.status === "active";
108
+ return raw as LoopState;
109
+ }
110
+
111
+ loadState(name: string, archived = false): LoopState | null {
112
+ const content = tryRead(this.getPath(name, ".state.json", archived));
113
+ return content ? this.migrateState(JSON.parse(content)) : null;
114
+ }
115
+
116
+ saveState(state: LoopState, archived = false): void {
117
+ state.active = state.status === "active";
118
+ const filePath = this.getPath(state.name, ".state.json", archived);
119
+ ensureDir(filePath);
120
+ writeJson(filePath, state);
121
+ }
122
+
123
+ listLoops(archived = false): LoopState[] {
124
+ const dir = archived ? this.archiveDir() : this.ralphDir();
125
+ if (!fs.existsSync(dir)) return [];
126
+ return fs
127
+ .readdirSync(dir)
128
+ .filter((f) => f.endsWith(".state.json"))
129
+ .map((f) => {
130
+ const content = tryRead(path.join(dir, f));
131
+ return content ? this.migrateState(JSON.parse(content)) : null;
132
+ })
133
+ .filter((s): s is LoopState => s !== null);
134
+ }
135
+
136
+ // --- Loop state transitions ---
137
+
138
+ pauseLoop(state: LoopState, message?: string): void {
139
+ state.status = "paused";
140
+ state.active = false;
141
+ this.saveState(state);
142
+ this.currentLoop = null;
143
+ this.updateUI();
144
+ if (message && this.ctx.hasUI) this.ctx.ui.notify(message, "info");
145
+ }
146
+
147
+ completeLoop(state: LoopState, banner: string): void {
148
+ state.status = "completed";
149
+ state.completedAt = now();
150
+ state.active = false;
151
+ this.saveState(state);
152
+
153
+ // Emit event
154
+ this.emitFn(UNIPI_EVENTS.RALPH_LOOP_END, {
155
+ name: state.name,
156
+ iteration: state.iteration,
157
+ maxIterations: state.maxIterations,
158
+ status: "completed",
159
+ reason: "completed",
160
+ } satisfies UnipiRalphLoopEvent);
161
+
162
+ this.currentLoop = null;
163
+ this.updateUI();
164
+ }
165
+
166
+ stopLoop(state: LoopState, message?: string): void {
167
+ state.status = "completed";
168
+ state.completedAt = now();
169
+ state.active = false;
170
+ this.saveState(state);
171
+
172
+ // Emit event
173
+ this.emitFn(UNIPI_EVENTS.RALPH_LOOP_END, {
174
+ name: state.name,
175
+ iteration: state.iteration,
176
+ maxIterations: state.maxIterations,
177
+ status: "completed",
178
+ reason: "cancelled",
179
+ } satisfies UnipiRalphLoopEvent);
180
+
181
+ this.currentLoop = null;
182
+ this.updateUI();
183
+ if (message && this.ctx.hasUI) this.ctx.ui.notify(message, "info");
184
+ }
185
+
186
+ // --- UI ---
187
+
188
+ formatLoop(l: LoopState): string {
189
+ const status = `${RALPH_STATUS_ICONS[l.status]} ${l.status}`;
190
+ const iter = l.maxIterations > 0 ? `${l.iteration}/${l.maxIterations}` : `${l.iteration}`;
191
+ return `${l.name}: ${status} (iteration ${iter})`;
192
+ }
193
+
194
+ updateUI(): void {
195
+ if (!this.ctx.hasUI) return;
196
+
197
+ const state = this.currentLoop ? this.loadState(this.currentLoop) : null;
198
+ if (!state) {
199
+ this.ctx.ui.setStatus("ralph", undefined);
200
+ this.ctx.ui.setWidget("ralph", undefined);
201
+ return;
202
+ }
203
+
204
+ const { theme } = this.ctx.ui;
205
+ const maxStr = state.maxIterations > 0 ? `/${state.maxIterations}` : "";
206
+
207
+ this.ctx.ui.setStatus("ralph", theme.fg("accent", `🔄 ${state.name} (${state.iteration}${maxStr})`));
208
+
209
+ const lines = [
210
+ theme.fg("accent", theme.bold("Ralph Loop")),
211
+ theme.fg("muted", `Loop: ${state.name}`),
212
+ theme.fg("dim", `Status: ${RALPH_STATUS_ICONS[state.status]} ${state.status}`),
213
+ theme.fg("dim", `Iteration: ${state.iteration}${maxStr}`),
214
+ theme.fg("dim", `Task: ${state.taskFile}`),
215
+ ];
216
+ if (state.reflectEvery > 0) {
217
+ const next = state.reflectEvery - ((state.iteration - 1) % state.reflectEvery);
218
+ lines.push(theme.fg("dim", `Next reflection in: ${next} iterations`));
219
+ }
220
+ lines.push("");
221
+ lines.push(theme.fg("warning", "ESC pauses the assistant"));
222
+ lines.push(theme.fg("warning", "Send a message to resume; /unipi:ralph-stop ends the loop"));
223
+ this.ctx.ui.setWidget("ralph", lines);
224
+ }
225
+
226
+ // --- Prompt building ---
227
+
228
+ buildPrompt(state: LoopState, taskContent: string, isReflection: boolean): string {
229
+ const maxStr = state.maxIterations > 0 ? `/${state.maxIterations}` : "";
230
+ const header = `───────────────────────────────────────────────────────────────────────
231
+ 🔄 RALPH LOOP: ${state.name} | Iteration ${state.iteration}${maxStr}${isReflection ? " | 🪞 REFLECTION" : ""}
232
+ ───────────────────────────────────────────────────────────────────────`;
233
+
234
+ const parts = [header, ""];
235
+ if (isReflection) parts.push(state.reflectInstructions, "\n---\n");
236
+
237
+ parts.push(`## Current Task (from ${state.taskFile})\n\n${taskContent}\n\n---`);
238
+ parts.push(`\n## Instructions\n`);
239
+ parts.push("User controls: ESC pauses the assistant. Send a message to resume. Run /unipi:ralph-stop when idle to stop the loop.\n");
240
+ parts.push(
241
+ `You are in a Ralph loop (iteration ${state.iteration}${state.maxIterations > 0 ? ` of ${state.maxIterations}` : ""}).\n`,
242
+ );
243
+
244
+ if (state.itemsPerIteration > 0) {
245
+ parts.push(`**THIS ITERATION: Process approximately ${state.itemsPerIteration} items, then call ralph_done.**\n`);
246
+ parts.push(`1. Work on the next ~${state.itemsPerIteration} items from your checklist`);
247
+ } else {
248
+ parts.push(`1. Continue working on the task`);
249
+ }
250
+ parts.push(`2. Update the task file (${state.taskFile}) with your progress`);
251
+ parts.push(`3. When FULLY COMPLETE, respond with: ${RALPH_COMPLETE_MARKER}`);
252
+ parts.push(`4. Otherwise, call the ralph_done tool to proceed to next iteration`);
253
+
254
+ return parts.join("\n");
255
+ }
256
+
257
+ // --- Public API ---
258
+
259
+ getCurrentLoop(): string | null {
260
+ return this.currentLoop;
261
+ }
262
+
263
+ setCurrentLoop(name: string | null): void {
264
+ this.currentLoop = name;
265
+ }
266
+
267
+ getTaskFilePath(state: LoopState): string {
268
+ return path.resolve(this.ctx.cwd, state.taskFile);
269
+ }
270
+
271
+ tryReadTask(state: LoopState): string | null {
272
+ return tryRead(this.getTaskFilePath(state));
273
+ }
274
+
275
+ rehydrate(): void {
276
+ const active = this.listLoops().filter((l) => l.status === "active");
277
+ if (!this.currentLoop && active.length > 0) {
278
+ const mostRecent = active.reduce((best, candidate) => {
279
+ const bestMtime = safeMtimeMs(this.getPath(best.name, ".state.json"));
280
+ const candidateMtime = safeMtimeMs(this.getPath(candidate.name, ".state.json"));
281
+ return candidateMtime > bestMtime ? candidate : best;
282
+ });
283
+ this.currentLoop = mostRecent.name;
284
+ }
285
+
286
+ if (active.length > 0 && this.ctx.hasUI) {
287
+ const lines = active.map(
288
+ (l) => ` • ${l.name} (iteration ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""})`,
289
+ );
290
+ this.ctx.ui.notify(`Active Ralph loops:\n${lines.join("\n")}\n\nUse /unipi:ralph-resume <name> to continue`, "info");
291
+ }
292
+ this.updateUI();
293
+ }
294
+
295
+ // --- Loop operations ---
296
+
297
+ startLoop(name: string, taskFile: string, taskContent: string, options: {
298
+ maxIterations?: number;
299
+ itemsPerIteration?: number;
300
+ reflectEvery?: number;
301
+ reflectInstructions?: string;
302
+ } = {}): LoopState {
303
+ const loopName = sanitize(name);
304
+ const fullPath = path.resolve(this.ctx.cwd, taskFile);
305
+ ensureDir(fullPath);
306
+ fs.writeFileSync(fullPath, taskContent, "utf-8");
307
+
308
+ const state: LoopState = {
309
+ name: loopName,
310
+ taskFile,
311
+ iteration: 1,
312
+ maxIterations: options.maxIterations ?? RALPH_DEFAULTS.MAX_ITERATIONS,
313
+ itemsPerIteration: options.itemsPerIteration ?? RALPH_DEFAULTS.ITEMS_PER_ITERATION,
314
+ reflectEvery: options.reflectEvery ?? RALPH_DEFAULTS.REFLECT_EVERY,
315
+ reflectInstructions: options.reflectInstructions ?? DEFAULT_REFLECT_INSTRUCTIONS,
316
+ active: true,
317
+ status: "active",
318
+ startedAt: now(),
319
+ lastReflectionAt: 0,
320
+ };
321
+
322
+ this.saveState(state);
323
+ this.currentLoop = loopName;
324
+ this.updateUI();
325
+
326
+ // Emit event
327
+ this.emitFn(UNIPI_EVENTS.RALPH_LOOP_START, {
328
+ name: loopName,
329
+ iteration: 1,
330
+ maxIterations: state.maxIterations,
331
+ status: "active",
332
+ } satisfies UnipiRalphLoopEvent);
333
+
334
+ return state;
335
+ }
336
+
337
+ resumeLoop(name: string): LoopState | null {
338
+ const state = this.loadState(name);
339
+ if (!state) return null;
340
+ if (state.status === "completed") return null;
341
+
342
+ // Pause current loop if different
343
+ if (this.currentLoop && this.currentLoop !== name) {
344
+ const curr = this.loadState(this.currentLoop);
345
+ if (curr) this.pauseLoop(curr);
346
+ }
347
+
348
+ state.status = "active";
349
+ state.active = true;
350
+ state.iteration++;
351
+ this.saveState(state);
352
+ this.currentLoop = name;
353
+ this.updateUI();
354
+
355
+ return state;
356
+ }
357
+
358
+ advanceIteration(): { state: LoopState; needsReflection: boolean } | null {
359
+ if (!this.currentLoop) return null;
360
+
361
+ const state = this.loadState(this.currentLoop);
362
+ if (!state || state.status !== "active") return null;
363
+
364
+ state.iteration++;
365
+
366
+ // Check max iterations
367
+ if (state.maxIterations > 0 && state.iteration > state.maxIterations) {
368
+ this.completeLoop(
369
+ state,
370
+ `───────────────────────────────────────────────────────────────────────
371
+ ⚠️ RALPH LOOP STOPPED: ${state.name} | Max iterations (${state.maxIterations}) reached
372
+ ───────────────────────────────────────────────────────────────────────`,
373
+ );
374
+ return null;
375
+ }
376
+
377
+ const needsReflection =
378
+ state.reflectEvery > 0 && (state.iteration - 1) % state.reflectEvery === 0;
379
+ if (needsReflection) state.lastReflectionAt = state.iteration;
380
+
381
+ this.saveState(state);
382
+ this.updateUI();
383
+
384
+ return { state, needsReflection };
385
+ }
386
+
387
+ archiveLoop(name: string): boolean {
388
+ const state = this.loadState(name);
389
+ if (!state) return false;
390
+ if (state.status === "active") return false;
391
+
392
+ if (this.currentLoop === name) this.currentLoop = null;
393
+
394
+ const srcState = this.getPath(name, ".state.json");
395
+ const dstState = this.getPath(name, ".state.json", true);
396
+ ensureDir(dstState);
397
+ if (fs.existsSync(srcState)) fs.renameSync(srcState, dstState);
398
+
399
+ const srcTask = path.resolve(this.ctx.cwd, state.taskFile);
400
+ if (srcTask.startsWith(this.ralphDir()) && !srcTask.startsWith(this.archiveDir())) {
401
+ const dstTask = this.getPath(name, ".md", true);
402
+ if (fs.existsSync(srcTask)) fs.renameSync(srcTask, dstTask);
403
+ }
404
+
405
+ this.updateUI();
406
+ return true;
407
+ }
408
+
409
+ cleanCompleted(all = false): string[] {
410
+ const completed = this.listLoops().filter((l) => l.status === "completed");
411
+ const cleaned: string[] = [];
412
+
413
+ for (const loop of completed) {
414
+ tryDelete(this.getPath(loop.name, ".state.json"));
415
+ if (all) tryDelete(this.getPath(loop.name, ".md"));
416
+ if (this.currentLoop === loop.name) this.currentLoop = null;
417
+ cleaned.push(loop.name);
418
+ }
419
+
420
+ this.updateUI();
421
+ return cleaned;
422
+ }
423
+
424
+ nukeAll(): boolean {
425
+ const dir = this.ralphDir();
426
+ if (!fs.existsSync(dir)) return false;
427
+ this.currentLoop = null;
428
+ this.updateUI();
429
+ return tryRemoveDir(dir);
430
+ }
431
+ }
package/tools.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @unipi/ralph — Ralph tools (ralph_start, ralph_done)
3
+ *
4
+ * Tools for the LLM to control ralph loops.
5
+ */
6
+
7
+ import { Type } from "@sinclair/typebox";
8
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
9
+ import { RALPH_COMPLETE_MARKER, RALPH_DEFAULTS, RALPH_TOOLS } from "@pi-unipi/core";
10
+ import { RalphLoopManager, DEFAULT_REFLECT_INSTRUCTIONS } from "./ralph-loop.js";
11
+
12
+ /**
13
+ * Register ralph_start and ralph_done tools.
14
+ */
15
+ export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager): void {
16
+ // --- ralph_start tool ---
17
+ pi.registerTool({
18
+ name: RALPH_TOOLS.START,
19
+ label: "Start Ralph Loop",
20
+ description:
21
+ "Start a long-running development loop. Use for complex multi-iteration tasks.",
22
+ promptSnippet:
23
+ "Start a persistent multi-iteration development loop with pacing and reflection controls.",
24
+ promptGuidelines: [
25
+ "Use ralph_start when the user explicitly wants an iterative loop, autonomous repeated passes, or paced multi-step execution.",
26
+ "After starting a loop, continue each finished iteration with ralph_done unless the completion marker has already been emitted.",
27
+ ],
28
+ parameters: Type.Object({
29
+ name: Type.String({ description: "Loop name (e.g., 'refactor-auth')" }),
30
+ taskContent: Type.String({
31
+ description: "Task in markdown with goals and checklist",
32
+ }),
33
+ itemsPerIteration: Type.Optional(
34
+ Type.Number({ description: "Suggest N items per turn (0 = no limit)" }),
35
+ ),
36
+ reflectEvery: Type.Optional(
37
+ Type.Number({ description: "Reflect every N iterations" }),
38
+ ),
39
+ maxIterations: Type.Optional(
40
+ Type.Number({
41
+ description: "Max iterations (default: 50)",
42
+ default: RALPH_DEFAULTS.MAX_ITERATIONS,
43
+ }),
44
+ ),
45
+ }),
46
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
47
+ const taskFile = `.unipi/ralph/${params.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`;
48
+
49
+ if (manager.loadState(params.name)?.status === "active") {
50
+ return {
51
+ content: [
52
+ { type: "text", text: `Loop "${params.name}" already active.` },
53
+ ],
54
+ details: {},
55
+ };
56
+ }
57
+
58
+ const state = manager.startLoop(params.name, taskFile, params.taskContent, {
59
+ maxIterations: params.maxIterations,
60
+ itemsPerIteration: params.itemsPerIteration,
61
+ reflectEvery: params.reflectEvery,
62
+ });
63
+
64
+ pi.sendUserMessage(
65
+ manager.buildPrompt(state, params.taskContent, false),
66
+ { deliverAs: "followUp" },
67
+ );
68
+
69
+ return {
70
+ content: [
71
+ {
72
+ type: "text",
73
+ text: `Started loop "${params.name}" (max ${state.maxIterations} iterations).`,
74
+ },
75
+ ],
76
+ details: {},
77
+ };
78
+ },
79
+ });
80
+
81
+ // --- ralph_done tool ---
82
+ pi.registerTool({
83
+ name: RALPH_TOOLS.DONE,
84
+ label: "Ralph Iteration Done",
85
+ description:
86
+ "Signal that you've completed this iteration of the Ralph loop. Call this after making progress to get the next iteration prompt. Do NOT call this if you've output the completion marker.",
87
+ promptSnippet:
88
+ "Advance an active Ralph loop after completing the current iteration.",
89
+ promptGuidelines: [
90
+ "Call ralph_done after making real iteration progress so Ralph can queue the next prompt.",
91
+ "Do not call ralph_done if there is no active loop, if pending messages are already queued, or if the completion marker has already been emitted.",
92
+ ],
93
+ parameters: Type.Object({}),
94
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
95
+ if (!manager.getCurrentLoop()) {
96
+ return {
97
+ content: [{ type: "text", text: "No active Ralph loop." }],
98
+ details: {},
99
+ };
100
+ }
101
+
102
+ if (ctx.hasPendingMessages()) {
103
+ return {
104
+ content: [
105
+ {
106
+ type: "text",
107
+ text: "Pending messages already queued. Skipping ralph_done.",
108
+ },
109
+ ],
110
+ details: {},
111
+ };
112
+ }
113
+
114
+ const result = manager.advanceIteration();
115
+ if (!result) {
116
+ return {
117
+ content: [
118
+ { type: "text", text: "Ralph loop completed or not active." },
119
+ ],
120
+ details: {},
121
+ };
122
+ }
123
+
124
+ const { state, needsReflection } = result;
125
+ const content = manager.tryReadTask(state);
126
+ if (!content) {
127
+ manager.pauseLoop(state);
128
+ return {
129
+ content: [
130
+ {
131
+ type: "text",
132
+ text: `Error: Could not read task file: ${state.taskFile}`,
133
+ },
134
+ ],
135
+ details: {},
136
+ };
137
+ }
138
+
139
+ // Queue next iteration
140
+ pi.sendUserMessage(
141
+ manager.buildPrompt(state, content, needsReflection),
142
+ { deliverAs: "followUp" },
143
+ );
144
+
145
+ return {
146
+ content: [
147
+ {
148
+ type: "text",
149
+ text: `Iteration ${state.iteration - 1} complete. Next iteration queued.`,
150
+ },
151
+ ],
152
+ details: {},
153
+ };
154
+ },
155
+ });
156
+ }