@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 +86 -0
- package/index.ts +548 -0
- package/package.json +46 -0
- package/ralph-loop.ts +431 -0
- package/tools.ts +156 -0
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
|
+
}
|