@pi-unipi/ralph 2.4.2 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -30,6 +30,7 @@ const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
30
30
  /** Current loop manager instance (recreated on session reload) */
31
31
  let manager: RalphLoopManager | null = null;
32
32
 
33
+
33
34
  /**
34
35
  * Get or create the loop manager for the current context.
35
36
  */
@@ -42,9 +43,14 @@ function getManager(ctx: ExtensionContext, pi: ExtensionAPI): RalphLoopManager {
42
43
  return manager;
43
44
  }
44
45
 
46
+ export { buildRalphLoopReminder } from "./reminder.js";
47
+ import { buildRalphLoopReminder, latestRalphReminder, RALPH_REMINDER_TYPE } from "./reminder.js";
48
+
45
49
  export default function (pi: ExtensionAPI) {
46
- // Register tools
47
- // (Manager will be created lazily on first use)
50
+ // Register static tool definitions at extension load. Their executors resolve
51
+ // the session-scoped manager lazily, so schemas never arrive late or reorder
52
+ // the provider tool array during session_start.
53
+ registerRalphTools(pi, (ctx) => getManager(ctx, pi));
48
54
 
49
55
  // Register commands
50
56
  registerCommands(pi);
@@ -157,20 +163,13 @@ export default function (pi: ExtensionAPI) {
157
163
  const state = mgr.loadState(currentLoop);
158
164
  if (!state || state.status !== "active") return;
159
165
 
160
- const iterStr = `${state.iteration}${state.maxIterations > 0 ? `/${state.maxIterations}` : ""}`;
161
-
162
- let instructions = `You are in a Ralph loop working on: ${state.taskFile}\n`;
163
- if (state.itemsPerIteration > 0) {
164
- instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
165
- }
166
- instructions += `- Update the task file as you progress\n`;
167
- instructions += `- When FULLY COMPLETE: ${RALPH_COMPLETE_MARKER}\n`;
168
- instructions += `- Otherwise, call ralph_done tool to proceed to next iteration`;
166
+ const content = buildRalphLoopReminder(state);
167
+ if (latestRalphReminder(ctx) === content) return;
169
168
 
170
169
  return {
171
170
  message: {
172
- customType: "unipi-ralph-loop-reminder",
173
- content: `[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`,
171
+ customType: RALPH_REMINDER_TYPE,
172
+ content,
174
173
  display: false,
175
174
  },
176
175
  };
@@ -187,11 +186,6 @@ export default function (pi: ExtensionAPI) {
187
186
  manager = null;
188
187
  });
189
188
 
190
- // Register tools after manager setup
191
- pi.on("session_start", async (_event, ctx) => {
192
- const mgr = getManager(ctx, pi);
193
- registerRalphTools(pi, mgr);
194
- });
195
189
  }
196
190
 
197
191
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ralph",
3
- "version": "2.4.2",
3
+ "version": "2.6.0",
4
4
  "description": "Long-running iterative development loops for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -26,9 +26,12 @@
26
26
  "publishConfig": {
27
27
  "access": "public"
28
28
  },
29
+ "scripts": {
30
+ "test": "npx tsx --test reminder.test.ts"
31
+ },
29
32
  "dependencies": {
30
- "@pi-unipi/core": "2.4.1",
31
- "@pi-unipi/info-screen": "2.4.1"
33
+ "@pi-unipi/core": "2.6.0",
34
+ "@pi-unipi/info-screen": "2.6.0"
32
35
  },
33
36
  "peerDependencies": {
34
37
  "@earendil-works/pi-ai": "^0.80.0",
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ buildRalphLoopReminder,
5
+ latestRalphReminder,
6
+ RALPH_REMINDER_TYPE,
7
+ } from "./reminder.ts";
8
+
9
+ const state = {
10
+ name: "cache-rollout",
11
+ iteration: 3,
12
+ maxIterations: 10,
13
+ taskFile: ".unipi/ralph/cache-rollout.md",
14
+ itemsPerIteration: 2,
15
+ };
16
+
17
+ function context(branch: any[]) {
18
+ return { sessionManager: { getBranch: () => branch } } as any;
19
+ }
20
+
21
+ function custom(content: string) {
22
+ return {
23
+ type: "custom_message",
24
+ customType: RALPH_REMINDER_TYPE,
25
+ content,
26
+ id: "r1",
27
+ parentId: null,
28
+ timestamp: "2026-08-14T00:00:00.000Z",
29
+ display: false,
30
+ };
31
+ }
32
+
33
+ describe("Ralph append-only reminder snapshots", () => {
34
+ it("is deterministic and explicitly supersedes older reminders", () => {
35
+ const first = buildRalphLoopReminder(state);
36
+ const second = buildRalphLoopReminder({ ...state });
37
+ assert.equal(first, second);
38
+ assert.match(first, /supersedes all earlier Ralph loop reminders/);
39
+ });
40
+
41
+ it("finds an unchanged retained snapshot for deduplication", () => {
42
+ const content = buildRalphLoopReminder(state);
43
+ assert.equal(latestRalphReminder(context([custom(content)])), content);
44
+ });
45
+
46
+ it("returns the newest retained snapshot when state changes", () => {
47
+ const old = buildRalphLoopReminder({ ...state, iteration: 2 });
48
+ const current = buildRalphLoopReminder(state);
49
+ assert.equal(latestRalphReminder(context([custom(old), { ...custom(current), id: "r2" }])), current);
50
+ assert.notEqual(old, current);
51
+ });
52
+
53
+ it("stops at compaction so current state is reinjected once in the new epoch", () => {
54
+ const old = buildRalphLoopReminder({ ...state, iteration: 2 });
55
+ const compaction = {
56
+ type: "compaction",
57
+ id: "c1",
58
+ parentId: "r1",
59
+ timestamp: "2026-08-14T00:00:01.000Z",
60
+ summary: `folded: ${old}`,
61
+ firstKeptEntryId: "r1",
62
+ tokensBefore: 1000,
63
+ };
64
+ assert.equal(latestRalphReminder(context([custom(old), compaction])), null);
65
+ });
66
+ });
package/reminder.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import { RALPH_COMPLETE_MARKER } from "@pi-unipi/core";
3
+
4
+ export const RALPH_REMINDER_TYPE = "unipi-ralph-loop-reminder";
5
+
6
+ export interface RalphReminderInput {
7
+ name: string;
8
+ iteration: number;
9
+ maxIterations: number;
10
+ taskFile: string;
11
+ itemsPerIteration: number;
12
+ }
13
+
14
+ /** Build the exact deterministic hidden reminder used by the live hook. */
15
+ export function buildRalphLoopReminder(state: RalphReminderInput): string {
16
+ const iterStr = `${state.iteration}${state.maxIterations > 0 ? `/${state.maxIterations}` : ""}`;
17
+ let instructions = "This snapshot supersedes all earlier Ralph loop reminders.\n";
18
+ instructions += `You are in a Ralph loop working on: ${state.taskFile}\n`;
19
+ if (state.itemsPerIteration > 0) {
20
+ instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
21
+ }
22
+ instructions += "- Update the task file as you progress\n";
23
+ instructions += `- When FULLY COMPLETE: ${RALPH_COMPLETE_MARKER}\n`;
24
+ instructions += "- Otherwise, call ralph_done tool to proceed to next iteration";
25
+ return `[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`;
26
+ }
27
+
28
+ export function latestRalphReminder(ctx: Pick<ExtensionContext, "sessionManager">): string | null {
29
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
30
+ for (let index = branch.length - 1; index >= 0; index--) {
31
+ const entry = branch[index];
32
+ if (entry.type === "custom_message" && entry.customType === RALPH_REMINDER_TYPE) {
33
+ return typeof entry.content === "string" ? entry.content : null;
34
+ }
35
+ // A compacted summary may contain the old reminder but no longer retains a
36
+ // dedicated custom entry. Inject the current snapshot once in the new epoch.
37
+ if (entry.type === "compaction") break;
38
+ }
39
+ return null;
40
+ }
package/tools.ts CHANGED
@@ -9,10 +9,12 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
9
9
  import { RALPH_COMPLETE_MARKER, RALPH_DEFAULTS, RALPH_TOOLS } from "@pi-unipi/core";
10
10
  import { RalphLoopManager, DEFAULT_REFLECT_INSTRUCTIONS } from "./ralph-loop.js";
11
11
 
12
+ type ManagerProvider = (ctx: ExtensionContext) => RalphLoopManager;
13
+
12
14
  /**
13
15
  * Register ralph_start and ralph_done tools.
14
16
  */
15
- export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager): void {
17
+ export function registerRalphTools(pi: ExtensionAPI, getManager: ManagerProvider): void {
16
18
  // --- ralph_start tool ---
17
19
  pi.registerTool({
18
20
  name: RALPH_TOOLS.START,
@@ -44,6 +46,7 @@ export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager):
44
46
  ),
45
47
  }),
46
48
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
49
+ const manager = getManager(ctx);
47
50
  const taskFile = `.unipi/ralph/${params.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`;
48
51
 
49
52
  if (manager.loadState(params.name)?.status === "active") {
@@ -92,6 +95,7 @@ export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager):
92
95
  ],
93
96
  parameters: Type.Object({}),
94
97
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
98
+ const manager = getManager(ctx);
95
99
  if (!manager.getCurrentLoop()) {
96
100
  return {
97
101
  content: [{ type: "text", text: "No active Ralph loop." }],