@w32191/just-loop 0.1.5 → 0.1.7
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/README.md +7 -12
- package/dist/src/host-adapter/opencode-host-adapter.js +3 -0
- package/dist/src/host-adapter/types.d.ts +16 -0
- package/dist/src/plugin/create-plugin.js +8 -0
- package/dist/src/ralph-loop/constants.d.ts +2 -0
- package/dist/src/ralph-loop/constants.js +2 -0
- package/dist/src/ralph-loop/loop-core.d.ts +1 -0
- package/dist/src/ralph-loop/loop-core.js +145 -13
- package/dist/src/ralph-loop/state-store.js +9 -0
- package/dist/src/ralph-loop/types.d.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,23 +6,18 @@ OpenCode plugin package for just-loop.
|
|
|
6
6
|
|
|
7
7
|
- Node 20+
|
|
8
8
|
|
|
9
|
-
## Install
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
npm install @w32191/just-loop
|
|
13
|
-
```
|
|
14
|
-
|
|
15
9
|
## Usage
|
|
16
10
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
```ts
|
|
20
|
-
import justLoop from "@w32191/just-loop"
|
|
11
|
+
在 `opencode.json` 的 `plugin` 欄位加入 plugin 名稱即可載入:
|
|
21
12
|
|
|
22
|
-
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"$schema": "https://opencode.ai/config.json",
|
|
16
|
+
"plugin": ["@w32191/just-loop"]
|
|
17
|
+
}
|
|
23
18
|
```
|
|
24
19
|
|
|
25
|
-
|
|
20
|
+
依官方文件,`opencode.json` 放在專案根目錄(專案設定)或 `~/.config/opencode/opencode.json`(全域設定)皆可,OpenCode 會在啟動時自動安裝與載入 npm 套件型 plugin。
|
|
26
21
|
|
|
27
22
|
## Publishing notes
|
|
28
23
|
|
|
@@ -8,6 +8,12 @@ export interface HostAdapter {
|
|
|
8
8
|
prompt(sessionID: string, text: string): Promise<void>;
|
|
9
9
|
abortSession(sessionID: string): Promise<void>;
|
|
10
10
|
sessionExists(sessionID: string): Promise<boolean>;
|
|
11
|
+
showToast?(toast: {
|
|
12
|
+
title: string;
|
|
13
|
+
message: string;
|
|
14
|
+
variant?: "info" | "warning" | "error" | "success";
|
|
15
|
+
duration?: number;
|
|
16
|
+
}): Promise<void>;
|
|
11
17
|
}
|
|
12
18
|
export type OpenCodeHostAdapterContext = {
|
|
13
19
|
directory: string;
|
|
@@ -38,5 +44,15 @@ export type OpenCodeHostAdapterContext = {
|
|
|
38
44
|
directory?: string;
|
|
39
45
|
}) => Promise<unknown>;
|
|
40
46
|
};
|
|
47
|
+
tui?: {
|
|
48
|
+
showToast?: (input: {
|
|
49
|
+
body: {
|
|
50
|
+
title: string;
|
|
51
|
+
message: string;
|
|
52
|
+
variant?: "info" | "warning" | "error" | "success";
|
|
53
|
+
duration?: number;
|
|
54
|
+
};
|
|
55
|
+
}) => Promise<unknown>;
|
|
56
|
+
};
|
|
41
57
|
};
|
|
42
58
|
};
|
|
@@ -38,12 +38,20 @@ export async function createPlugin(ctx, deps = {}) {
|
|
|
38
38
|
prompt: async ({ sessionID, directory, parts }) => ctx.client.session.prompt({ path: { id: sessionID }, body: { parts }, query: { directory } }),
|
|
39
39
|
abort: async ({ sessionID }) => ctx.client.session.abort({ path: { id: sessionID } }),
|
|
40
40
|
},
|
|
41
|
+
tui: ctx.client.tui?.showToast
|
|
42
|
+
? { showToast: async ({ body }) => ctx.client.tui?.showToast?.({ body: body }) }
|
|
43
|
+
: undefined,
|
|
41
44
|
},
|
|
42
45
|
});
|
|
43
46
|
const core = createCore({
|
|
44
47
|
rootDir: ctx.directory,
|
|
45
48
|
adapter,
|
|
46
49
|
getConfig: () => resolvedConfig,
|
|
50
|
+
wait: async (ms) => {
|
|
51
|
+
await new Promise((resolve) => {
|
|
52
|
+
setTimeout(resolve, ms);
|
|
53
|
+
});
|
|
54
|
+
},
|
|
47
55
|
});
|
|
48
56
|
return {
|
|
49
57
|
config: async (input) => {
|
|
@@ -2,3 +2,5 @@ export declare const DEFAULT_STATE_PATH = ".loop/ralph-loop.local.md";
|
|
|
2
2
|
export declare const DEFAULT_COMPLETION_PROMISE = "<promise>DONE</promise>";
|
|
3
3
|
export declare const DEFAULT_MAX_ITERATIONS_FALLBACK = 100;
|
|
4
4
|
export declare const DEFAULT_STRATEGY = "continue";
|
|
5
|
+
export declare const CONTINUATION_COUNTDOWN_SECONDS = 10;
|
|
6
|
+
export declare const CONTINUATION_COUNTDOWN_STEP_MS = 1000;
|
|
@@ -2,3 +2,5 @@ export const DEFAULT_STATE_PATH = ".loop/ralph-loop.local.md";
|
|
|
2
2
|
export const DEFAULT_COMPLETION_PROMISE = "<promise>DONE</promise>";
|
|
3
3
|
export const DEFAULT_MAX_ITERATIONS_FALLBACK = 100;
|
|
4
4
|
export const DEFAULT_STRATEGY = "continue";
|
|
5
|
+
export const CONTINUATION_COUNTDOWN_SECONDS = 10;
|
|
6
|
+
export const CONTINUATION_COUNTDOWN_STEP_MS = 1000;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS_FALLBACK, DEFAULT_STRATEGY } from "./constants.js";
|
|
1
|
+
import { CONTINUATION_COUNTDOWN_SECONDS, CONTINUATION_COUNTDOWN_STEP_MS, DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS_FALLBACK, DEFAULT_STRATEGY, } from "./constants.js";
|
|
2
2
|
import { buildContinuationPrompt } from "./continuation-prompt.js";
|
|
3
3
|
import { detectCompletion } from "./completion-detector.js";
|
|
4
4
|
import { clearState, readState, writeState } from "./state-store.js";
|
|
@@ -12,6 +12,16 @@ export function createLoopCore(deps) {
|
|
|
12
12
|
defaultStrategy: DEFAULT_STRATEGY,
|
|
13
13
|
};
|
|
14
14
|
const getToken = (state) => state.incarnation_token ?? state.started_at;
|
|
15
|
+
const wait = deps.wait;
|
|
16
|
+
function showToast(title, message, variant = "info") {
|
|
17
|
+
void deps.adapter.showToast?.({ title, message, variant, duration: 1000 }).catch(() => { });
|
|
18
|
+
}
|
|
19
|
+
async function readCurrentState(sessionID, token) {
|
|
20
|
+
const state = await readState(deps.rootDir);
|
|
21
|
+
if (!state || !state.active || state.session_id !== sessionID || getToken(state) !== token)
|
|
22
|
+
return null;
|
|
23
|
+
return state;
|
|
24
|
+
}
|
|
15
25
|
const runStateMutation = async (mutation) => {
|
|
16
26
|
const run = stateMutationQueue.then(mutation, mutation);
|
|
17
27
|
stateMutationQueue = run.then(() => undefined, () => undefined);
|
|
@@ -63,6 +73,8 @@ export function createLoopCore(deps) {
|
|
|
63
73
|
const state = await readState(deps.rootDir);
|
|
64
74
|
if (!state || !state.active)
|
|
65
75
|
return;
|
|
76
|
+
if (state.pending_continuation?.cancelled)
|
|
77
|
+
return;
|
|
66
78
|
const observedSessionID = state.session_id;
|
|
67
79
|
const observedToken = getToken(state);
|
|
68
80
|
// active-loop-scoped only: the host interrupt hook does not provide sessionID,
|
|
@@ -74,6 +86,23 @@ export function createLoopCore(deps) {
|
|
|
74
86
|
getToken(currentState) !== observedToken) {
|
|
75
87
|
return;
|
|
76
88
|
}
|
|
89
|
+
if (currentState.pending_continuation) {
|
|
90
|
+
if (currentState.pending_continuation.dispatch_token) {
|
|
91
|
+
await writeState(deps.rootDir, {
|
|
92
|
+
...currentState,
|
|
93
|
+
skip_next_continuation: true,
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (currentState.pending_continuation.cancelled)
|
|
98
|
+
return;
|
|
99
|
+
await writeState(deps.rootDir, {
|
|
100
|
+
...currentState,
|
|
101
|
+
pending_continuation: { ...currentState.pending_continuation, cancelled: true },
|
|
102
|
+
});
|
|
103
|
+
showToast("Ralph Loop", "Cancelled the pending Ralph Loop continuation.", "info");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
77
106
|
await writeState(deps.rootDir, {
|
|
78
107
|
...currentState,
|
|
79
108
|
skip_next_continuation: true,
|
|
@@ -124,20 +153,12 @@ export function createLoopCore(deps) {
|
|
|
124
153
|
.some((message) => message.role === "assistant");
|
|
125
154
|
if (!hasNewAssistantMessages)
|
|
126
155
|
return;
|
|
127
|
-
const liveState = await
|
|
128
|
-
if (!liveState
|
|
129
|
-
!liveState.active ||
|
|
130
|
-
liveState.session_id !== event.sessionID ||
|
|
131
|
-
getToken(liveState) !== incarnationToken) {
|
|
156
|
+
const liveState = await readCurrentState(event.sessionID, incarnationToken);
|
|
157
|
+
if (!liveState)
|
|
132
158
|
return;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!continuationState ||
|
|
136
|
-
!continuationState.active ||
|
|
137
|
-
continuationState.session_id !== event.sessionID ||
|
|
138
|
-
getToken(continuationState) !== incarnationToken) {
|
|
159
|
+
const continuationState = await readCurrentState(event.sessionID, incarnationToken);
|
|
160
|
+
if (!continuationState)
|
|
139
161
|
return;
|
|
140
|
-
}
|
|
141
162
|
if (detectCompletion(messages, continuationState.completion_promise, continuationState.message_count_at_start)) {
|
|
142
163
|
await runStateMutation(async () => {
|
|
143
164
|
const currentState = await readState(deps.rootDir);
|
|
@@ -187,6 +208,116 @@ export function createLoopCore(deps) {
|
|
|
187
208
|
});
|
|
188
209
|
return;
|
|
189
210
|
}
|
|
211
|
+
if (wait) {
|
|
212
|
+
await runStateMutation(async () => {
|
|
213
|
+
const currentState = await readCurrentState(event.sessionID, incarnationToken);
|
|
214
|
+
if (!currentState || currentState.pending_continuation)
|
|
215
|
+
return;
|
|
216
|
+
await writeState(deps.rootDir, {
|
|
217
|
+
...currentState,
|
|
218
|
+
pending_continuation: {
|
|
219
|
+
started_at: new Date().toISOString(),
|
|
220
|
+
countdown_seconds_remaining: CONTINUATION_COUNTDOWN_SECONDS,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
showToast("Ralph Loop", `Injecting next continuation in ${CONTINUATION_COUNTDOWN_SECONDS}s. Use interrupt to cancel once.`, "warning");
|
|
225
|
+
for (let remaining = CONTINUATION_COUNTDOWN_SECONDS; remaining > 0; remaining -= 1) {
|
|
226
|
+
await wait(CONTINUATION_COUNTDOWN_STEP_MS);
|
|
227
|
+
const currentState = await readCurrentState(event.sessionID, incarnationToken);
|
|
228
|
+
if (!currentState?.pending_continuation)
|
|
229
|
+
return;
|
|
230
|
+
if (currentState.pending_continuation.cancelled) {
|
|
231
|
+
await runStateMutation(async () => {
|
|
232
|
+
const latest = await readCurrentState(event.sessionID, incarnationToken);
|
|
233
|
+
if (!latest?.pending_continuation?.cancelled)
|
|
234
|
+
return;
|
|
235
|
+
const nextState = {
|
|
236
|
+
...latest,
|
|
237
|
+
last_message_count_processed: batchMessageCount,
|
|
238
|
+
};
|
|
239
|
+
delete nextState.pending_continuation;
|
|
240
|
+
await writeState(deps.rootDir, nextState);
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await runStateMutation(async () => {
|
|
245
|
+
const latest = await readCurrentState(event.sessionID, incarnationToken);
|
|
246
|
+
if (!latest?.pending_continuation || latest.pending_continuation.cancelled)
|
|
247
|
+
return;
|
|
248
|
+
await writeState(deps.rootDir, {
|
|
249
|
+
...latest,
|
|
250
|
+
pending_continuation: {
|
|
251
|
+
...latest.pending_continuation,
|
|
252
|
+
countdown_seconds_remaining: remaining - 1,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
if (remaining > 1) {
|
|
257
|
+
showToast("Ralph Loop", `Injecting next continuation in ${remaining - 1}s. Use interrupt to cancel once.`, "warning");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const promptGate = await runStateMutation(async () => {
|
|
261
|
+
const currentState = await readCurrentState(event.sessionID, incarnationToken);
|
|
262
|
+
if (!currentState?.pending_continuation)
|
|
263
|
+
return { kind: "aborted" };
|
|
264
|
+
if (currentState.pending_continuation.cancelled) {
|
|
265
|
+
const nextState = {
|
|
266
|
+
...currentState,
|
|
267
|
+
last_message_count_processed: batchMessageCount,
|
|
268
|
+
};
|
|
269
|
+
delete nextState.pending_continuation;
|
|
270
|
+
await writeState(deps.rootDir, nextState);
|
|
271
|
+
return { kind: "cancelled" };
|
|
272
|
+
}
|
|
273
|
+
const dispatchToken = randomUUID();
|
|
274
|
+
const prompt = buildContinuationPrompt({
|
|
275
|
+
iteration: nextIteration,
|
|
276
|
+
prompt: currentState.prompt,
|
|
277
|
+
completionPromise: currentState.completion_promise,
|
|
278
|
+
maxIterations: currentState.max_iterations,
|
|
279
|
+
});
|
|
280
|
+
await writeState(deps.rootDir, {
|
|
281
|
+
...currentState,
|
|
282
|
+
pending_continuation: {
|
|
283
|
+
...currentState.pending_continuation,
|
|
284
|
+
countdown_seconds_remaining: 0,
|
|
285
|
+
dispatch_token: dispatchToken,
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
try {
|
|
289
|
+
await deps.adapter.prompt(event.sessionID, prompt);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
const latest = await readCurrentState(event.sessionID, incarnationToken);
|
|
293
|
+
if (latest?.pending_continuation?.dispatch_token === dispatchToken) {
|
|
294
|
+
const nextState = { ...latest };
|
|
295
|
+
delete nextState.pending_continuation;
|
|
296
|
+
await writeState(deps.rootDir, nextState);
|
|
297
|
+
}
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
const latest = await readCurrentState(event.sessionID, incarnationToken);
|
|
301
|
+
if (!latest?.pending_continuation)
|
|
302
|
+
return { kind: "aborted" };
|
|
303
|
+
if (latest.pending_continuation.dispatch_token !== dispatchToken)
|
|
304
|
+
return { kind: "aborted" };
|
|
305
|
+
const nextState = {
|
|
306
|
+
...latest,
|
|
307
|
+
iteration: nextIteration,
|
|
308
|
+
last_message_count_processed: batchMessageCount,
|
|
309
|
+
};
|
|
310
|
+
delete nextState.pending_continuation;
|
|
311
|
+
await writeState(deps.rootDir, nextState);
|
|
312
|
+
return { kind: "dispatched" };
|
|
313
|
+
});
|
|
314
|
+
if (promptGate.kind !== "dispatched")
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (wait) {
|
|
318
|
+
showToast("Ralph Loop", "Injected Ralph Loop continuation.", "success");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
190
321
|
await deps.adapter.prompt(event.sessionID, buildContinuationPrompt({
|
|
191
322
|
iteration: nextIteration,
|
|
192
323
|
prompt: continuationState.prompt,
|
|
@@ -208,6 +339,7 @@ export function createLoopCore(deps) {
|
|
|
208
339
|
};
|
|
209
340
|
await writeState(deps.rootDir, nextState);
|
|
210
341
|
});
|
|
342
|
+
showToast("Ralph Loop", "Injected Ralph Loop continuation.", "success");
|
|
211
343
|
}
|
|
212
344
|
finally {
|
|
213
345
|
const currentToken = inFlight.get(event.sessionID);
|
|
@@ -18,6 +18,15 @@ function isRalphLoopState(value) {
|
|
|
18
18
|
typeof record.message_count_at_start === "number" &&
|
|
19
19
|
(record.last_message_count_processed === undefined || typeof record.last_message_count_processed === "number") &&
|
|
20
20
|
(record.skip_next_continuation === undefined || typeof record.skip_next_continuation === "boolean") &&
|
|
21
|
+
(record.pending_continuation === undefined ||
|
|
22
|
+
(typeof record.pending_continuation === "object" &&
|
|
23
|
+
record.pending_continuation !== null &&
|
|
24
|
+
typeof record.pending_continuation.started_at === "string" &&
|
|
25
|
+
typeof record.pending_continuation.countdown_seconds_remaining === "number" &&
|
|
26
|
+
(record.pending_continuation.cancelled === undefined ||
|
|
27
|
+
typeof record.pending_continuation.cancelled === "boolean") &&
|
|
28
|
+
(record.pending_continuation.dispatch_token === undefined ||
|
|
29
|
+
typeof record.pending_continuation.dispatch_token === "string"))) &&
|
|
21
30
|
(record.incarnation_token === undefined || typeof record.incarnation_token === "string") &&
|
|
22
31
|
typeof record.started_at === "string");
|
|
23
32
|
}
|
|
@@ -8,6 +8,12 @@ export type RalphLoopState = {
|
|
|
8
8
|
message_count_at_start: number;
|
|
9
9
|
last_message_count_processed?: number;
|
|
10
10
|
skip_next_continuation?: boolean;
|
|
11
|
+
pending_continuation?: {
|
|
12
|
+
started_at: string;
|
|
13
|
+
countdown_seconds_remaining: number;
|
|
14
|
+
cancelled?: boolean;
|
|
15
|
+
dispatch_token?: string;
|
|
16
|
+
};
|
|
11
17
|
incarnation_token?: string;
|
|
12
18
|
started_at: string;
|
|
13
19
|
};
|