@w32191/just-loop 0.1.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/LICENSE +21 -0
- package/README.md +43 -0
- package/dist/src/commands/parse-ralph-loop-command.d.ts +11 -0
- package/dist/src/commands/parse-ralph-loop-command.js +58 -0
- package/dist/src/host-adapter/opencode-host-adapter.d.ts +2 -0
- package/dist/src/host-adapter/opencode-host-adapter.js +80 -0
- package/dist/src/host-adapter/types.d.ts +42 -0
- package/dist/src/host-adapter/types.js +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/plugin/chat-message-handler.d.ts +9 -0
- package/dist/src/plugin/chat-message-handler.js +16 -0
- package/dist/src/plugin/create-plugin.d.ts +33 -0
- package/dist/src/plugin/create-plugin.js +52 -0
- package/dist/src/plugin/event-handler.d.ts +15 -0
- package/dist/src/plugin/event-handler.js +21 -0
- package/dist/src/ralph-loop/completion-detector.d.ts +5 -0
- package/dist/src/ralph-loop/completion-detector.js +4 -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/continuation-prompt.d.ts +7 -0
- package/dist/src/ralph-loop/continuation-prompt.js +8 -0
- package/dist/src/ralph-loop/loop-core.d.ts +24 -0
- package/dist/src/ralph-loop/loop-core.js +122 -0
- package/dist/src/ralph-loop/state-store.d.ts +4 -0
- package/dist/src/ralph-loop/state-store.js +58 -0
- package/dist/src/ralph-loop/types.d.ts +12 -0
- package/dist/src/ralph-loop/types.js +1 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sam Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @w32191/just-loop
|
|
2
|
+
|
|
3
|
+
OpenCode plugin package for just-loop.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node 20+
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @w32191/just-loop
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Put this in your OpenCode plugin entry or config file:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import justLoop from "@w32191/just-loop"
|
|
21
|
+
|
|
22
|
+
export default justLoop
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This package is intended for OpenCode plugin usage.
|
|
26
|
+
|
|
27
|
+
## Publishing notes
|
|
28
|
+
|
|
29
|
+
Bun is only a maintenance and release prerequisite. It is not a runtime dependency for normal installers.
|
|
30
|
+
|
|
31
|
+
## Release
|
|
32
|
+
|
|
33
|
+
Prerequisites: Node 20+, Bun, and a valid npm login/token.
|
|
34
|
+
|
|
35
|
+
1. Run `npm login`.
|
|
36
|
+
2. Run `npm whoami`.
|
|
37
|
+
3. Confirm `@w32191` scope access with `npm access list packages @w32191` (or equivalent access check).
|
|
38
|
+
4. Run `npm view @w32191/just-loop version`.
|
|
39
|
+
- For the first publish, a 404 is expected until the package exists.
|
|
40
|
+
- The real unblock condition is: `npm whoami` succeeds and scope access checks pass.
|
|
41
|
+
5. Bump the package version.
|
|
42
|
+
6. Run `npm run verify:publish`.
|
|
43
|
+
7. Publish with `npm publish --access public`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type RalphLoopStartCommand = {
|
|
2
|
+
kind: "start";
|
|
3
|
+
prompt: string;
|
|
4
|
+
maxIterations?: number;
|
|
5
|
+
completionPromise: string;
|
|
6
|
+
};
|
|
7
|
+
export type RalphLoopCancelCommand = {
|
|
8
|
+
kind: "cancel";
|
|
9
|
+
};
|
|
10
|
+
export type ParsedRalphLoopCommand = RalphLoopStartCommand | RalphLoopCancelCommand;
|
|
11
|
+
export declare function parseRalphLoopCommand(input: string): ParsedRalphLoopCommand | null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DEFAULT_COMPLETION_PROMISE } from "../ralph-loop/constants.js";
|
|
2
|
+
export function parseRalphLoopCommand(input) {
|
|
3
|
+
const trimmed = input.trim();
|
|
4
|
+
if (trimmed === "/cancel-ralph") {
|
|
5
|
+
return { kind: "cancel" };
|
|
6
|
+
}
|
|
7
|
+
if (!/^\/ralph-loop(?:\s|$)/.test(trimmed)) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
let rest = trimmed.slice("/ralph-loop".length);
|
|
11
|
+
let maxIterations;
|
|
12
|
+
let completionPromise = DEFAULT_COMPLETION_PROMISE;
|
|
13
|
+
while (true) {
|
|
14
|
+
rest = rest.replace(/^\s+/, "");
|
|
15
|
+
if (!rest.startsWith("--")) {
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
if (/^--max(?:\s|$)/.test(rest)) {
|
|
19
|
+
const valueMatch = rest.match(/^--max(?:\s+(\S+))(?:\s+|$)/);
|
|
20
|
+
if (!valueMatch || !/^[0-9]+$/.test(valueMatch[1])) {
|
|
21
|
+
throw new Error("invalid --max value");
|
|
22
|
+
}
|
|
23
|
+
maxIterations = Number(valueMatch[1]);
|
|
24
|
+
rest = rest.slice(valueMatch[0].length);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (/^--promise(?:\s|$)/.test(rest)) {
|
|
28
|
+
const promiseMatch = rest.match(/^--promise\s+"([^"]+)"(?:\s+|$)/);
|
|
29
|
+
if (!promiseMatch) {
|
|
30
|
+
throw new Error("invalid --promise format");
|
|
31
|
+
}
|
|
32
|
+
completionPromise = promiseMatch[1];
|
|
33
|
+
rest = rest.slice(promiseMatch[0].length);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (/^--strategy(?:\s|$)/.test(rest)) {
|
|
37
|
+
const strategyMatch = rest.match(/^--strategy(?:\s+(\S+))(?:\s+|$)/);
|
|
38
|
+
if (!strategyMatch) {
|
|
39
|
+
throw new Error("invalid --strategy value");
|
|
40
|
+
}
|
|
41
|
+
if (strategyMatch[1] === "reset") {
|
|
42
|
+
throw new Error("reset strategy is not supported in v1");
|
|
43
|
+
}
|
|
44
|
+
throw new Error("invalid --strategy value");
|
|
45
|
+
}
|
|
46
|
+
throw new Error("unknown flag");
|
|
47
|
+
}
|
|
48
|
+
const prompt = rest.trim().replace(/\s+/g, " ");
|
|
49
|
+
if (!prompt) {
|
|
50
|
+
throw new Error("prompt is required");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
kind: "start",
|
|
54
|
+
prompt,
|
|
55
|
+
maxIterations,
|
|
56
|
+
completionPromise,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
function buildSessionInput(directory, sessionID) {
|
|
2
|
+
return {
|
|
3
|
+
sessionID,
|
|
4
|
+
directory,
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
function buildPromptInput(directory, sessionID, text) {
|
|
8
|
+
return {
|
|
9
|
+
sessionID,
|
|
10
|
+
directory,
|
|
11
|
+
parts: [{ type: "text", text }],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function extractMessages(response) {
|
|
15
|
+
const records = Array.isArray(response)
|
|
16
|
+
? response
|
|
17
|
+
: response && typeof response === "object" && Array.isArray(response.data)
|
|
18
|
+
? response.data
|
|
19
|
+
: [];
|
|
20
|
+
return records.map((item) => {
|
|
21
|
+
const record = item;
|
|
22
|
+
const role = typeof record.info?.role === "string" ? record.info.role : "unknown";
|
|
23
|
+
const text = Array.isArray(record.parts)
|
|
24
|
+
? record.parts
|
|
25
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
26
|
+
.map((part) => part.text)
|
|
27
|
+
.join("\n")
|
|
28
|
+
: "";
|
|
29
|
+
return { role, text };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function isNotFoundError(error) {
|
|
33
|
+
if (!(error instanceof Error))
|
|
34
|
+
return false;
|
|
35
|
+
const record = error;
|
|
36
|
+
return (record.code === "ENOENT" ||
|
|
37
|
+
record.status === 404 ||
|
|
38
|
+
error.message.toLowerCase().includes("not found") ||
|
|
39
|
+
error.message.toLowerCase().includes("missing"));
|
|
40
|
+
}
|
|
41
|
+
export function createOpenCodeHostAdapter(ctx) {
|
|
42
|
+
return {
|
|
43
|
+
async getMessageCount(sessionID) {
|
|
44
|
+
const response = await ctx.client.session.messages(buildSessionInput(ctx.directory, sessionID));
|
|
45
|
+
if (Array.isArray(response))
|
|
46
|
+
return response.length;
|
|
47
|
+
if (response && typeof response === "object" && Array.isArray(response.data)) {
|
|
48
|
+
return response.data.length;
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
},
|
|
52
|
+
async getMessages(sessionID) {
|
|
53
|
+
const response = await ctx.client.session.messages(buildSessionInput(ctx.directory, sessionID));
|
|
54
|
+
return extractMessages(response);
|
|
55
|
+
},
|
|
56
|
+
async prompt(sessionID, text) {
|
|
57
|
+
const input = buildPromptInput(ctx.directory, sessionID, text);
|
|
58
|
+
if (ctx.client.session.promptAsync) {
|
|
59
|
+
await ctx.client.session.promptAsync(input);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await ctx.client.session.prompt(input);
|
|
63
|
+
},
|
|
64
|
+
async abortSession(sessionID) {
|
|
65
|
+
await ctx.client.session.abort({ sessionID, directory: ctx.directory });
|
|
66
|
+
},
|
|
67
|
+
async sessionExists(sessionID) {
|
|
68
|
+
try {
|
|
69
|
+
await ctx.client.session.messages(buildSessionInput(ctx.directory, sessionID));
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (isNotFoundError(error)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type HostMessage = {
|
|
2
|
+
role: string;
|
|
3
|
+
text: string;
|
|
4
|
+
};
|
|
5
|
+
export interface HostAdapter {
|
|
6
|
+
getMessageCount(sessionID: string): Promise<number>;
|
|
7
|
+
getMessages(sessionID: string): Promise<Array<HostMessage>>;
|
|
8
|
+
prompt(sessionID: string, text: string): Promise<void>;
|
|
9
|
+
abortSession(sessionID: string): Promise<void>;
|
|
10
|
+
sessionExists(sessionID: string): Promise<boolean>;
|
|
11
|
+
}
|
|
12
|
+
export type OpenCodeHostAdapterContext = {
|
|
13
|
+
directory: string;
|
|
14
|
+
client: {
|
|
15
|
+
session: {
|
|
16
|
+
messages: (input: {
|
|
17
|
+
sessionID: string;
|
|
18
|
+
directory: string;
|
|
19
|
+
}) => Promise<unknown>;
|
|
20
|
+
promptAsync?: (input: {
|
|
21
|
+
sessionID: string;
|
|
22
|
+
directory: string;
|
|
23
|
+
parts: Array<{
|
|
24
|
+
type: "text";
|
|
25
|
+
text: string;
|
|
26
|
+
}>;
|
|
27
|
+
}) => Promise<unknown>;
|
|
28
|
+
prompt: (input: {
|
|
29
|
+
sessionID: string;
|
|
30
|
+
directory: string;
|
|
31
|
+
parts: Array<{
|
|
32
|
+
type: "text";
|
|
33
|
+
text: string;
|
|
34
|
+
}>;
|
|
35
|
+
}) => Promise<unknown>;
|
|
36
|
+
abort: (input: {
|
|
37
|
+
sessionID: string;
|
|
38
|
+
directory?: string;
|
|
39
|
+
}) => Promise<unknown>;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type LoopCore = {
|
|
2
|
+
startLoop: (sessionID: string, prompt: string, options: {
|
|
3
|
+
maxIterations?: number;
|
|
4
|
+
completionPromise?: string;
|
|
5
|
+
}) => Promise<unknown>;
|
|
6
|
+
cancelLoop: (sessionID: string) => Promise<unknown>;
|
|
7
|
+
};
|
|
8
|
+
export declare function handleChatMessage(input: string, core: LoopCore, sessionID: string): Promise<void>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { parseRalphLoopCommand } from "../commands/parse-ralph-loop-command.js";
|
|
2
|
+
export async function handleChatMessage(input, core, sessionID) {
|
|
3
|
+
if (typeof input !== "string")
|
|
4
|
+
return;
|
|
5
|
+
const command = parseRalphLoopCommand(input);
|
|
6
|
+
if (!command)
|
|
7
|
+
return;
|
|
8
|
+
if (command.kind === "cancel") {
|
|
9
|
+
await core.cancelLoop(sessionID);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
await core.startLoop(sessionID, command.prompt, {
|
|
13
|
+
maxIterations: command.maxIterations,
|
|
14
|
+
completionPromise: command.completionPromise,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createOpenCodeHostAdapter } from "../host-adapter/opencode-host-adapter.js";
|
|
2
|
+
import { createLoopCore } from "../ralph-loop/loop-core.js";
|
|
3
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
4
|
+
export type CreatePluginDeps = {
|
|
5
|
+
createOpenCodeHostAdapter?: typeof createOpenCodeHostAdapter;
|
|
6
|
+
createLoopCore?: typeof createLoopCore;
|
|
7
|
+
};
|
|
8
|
+
type PluginInput = Parameters<Plugin>[0];
|
|
9
|
+
type TextPart = {
|
|
10
|
+
type?: unknown;
|
|
11
|
+
text?: unknown;
|
|
12
|
+
};
|
|
13
|
+
type ChatMessageInput = {
|
|
14
|
+
sessionID?: unknown;
|
|
15
|
+
properties?: {
|
|
16
|
+
sessionID?: unknown;
|
|
17
|
+
};
|
|
18
|
+
path?: {
|
|
19
|
+
id?: unknown;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
type ChatMessageOutput = {
|
|
23
|
+
parts?: Array<TextPart>;
|
|
24
|
+
};
|
|
25
|
+
type EventInput = {
|
|
26
|
+
event?: unknown;
|
|
27
|
+
};
|
|
28
|
+
export type PluginHooks = {
|
|
29
|
+
"chat.message": (input: ChatMessageInput, output: ChatMessageOutput) => Promise<void>;
|
|
30
|
+
event: (input: EventInput) => Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
export declare function createPlugin(ctx?: PluginInput, deps?: CreatePluginDeps): Promise<PluginHooks>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createOpenCodeHostAdapter } from "../host-adapter/opencode-host-adapter.js";
|
|
2
|
+
import { createLoopCore } from "../ralph-loop/loop-core.js";
|
|
3
|
+
import { handleChatMessage } from "./chat-message-handler.js";
|
|
4
|
+
import { handleEvent } from "./event-handler.js";
|
|
5
|
+
function extractSessionID(input) {
|
|
6
|
+
if (typeof input.sessionID === "string")
|
|
7
|
+
return input.sessionID;
|
|
8
|
+
if (typeof input.properties?.sessionID === "string")
|
|
9
|
+
return input.properties.sessionID;
|
|
10
|
+
if (typeof input.path?.id === "string")
|
|
11
|
+
return input.path.id;
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
function extractChatText(parts) {
|
|
15
|
+
return (parts ?? [])
|
|
16
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
17
|
+
.map((part) => part.text)
|
|
18
|
+
.join("\n");
|
|
19
|
+
}
|
|
20
|
+
export async function createPlugin(ctx, deps = {}) {
|
|
21
|
+
if (!ctx)
|
|
22
|
+
throw new Error("plugin context is required");
|
|
23
|
+
const createAdapter = deps.createOpenCodeHostAdapter ?? createOpenCodeHostAdapter;
|
|
24
|
+
const createCore = deps.createLoopCore ?? createLoopCore;
|
|
25
|
+
const adapter = createAdapter({
|
|
26
|
+
directory: ctx.directory,
|
|
27
|
+
client: {
|
|
28
|
+
session: {
|
|
29
|
+
messages: async ({ sessionID, directory }) => ctx.client.session.messages({ path: { id: sessionID }, query: { directory } }),
|
|
30
|
+
promptAsync: ctx.client.session.promptAsync
|
|
31
|
+
? async ({ sessionID, directory, parts }) => ctx.client.session.promptAsync?.({ path: { id: sessionID }, body: { parts }, query: { directory } })
|
|
32
|
+
: undefined,
|
|
33
|
+
prompt: async ({ sessionID, directory, parts }) => ctx.client.session.prompt({ path: { id: sessionID }, body: { parts }, query: { directory } }),
|
|
34
|
+
abort: async ({ sessionID }) => ctx.client.session.abort({ path: { id: sessionID } }),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const core = createCore({ rootDir: ctx.directory, adapter });
|
|
39
|
+
return {
|
|
40
|
+
"chat.message": async (input, output) => {
|
|
41
|
+
const sessionID = extractSessionID(input);
|
|
42
|
+
if (!sessionID)
|
|
43
|
+
return;
|
|
44
|
+
await handleChatMessage(extractChatText(output.parts), core, sessionID);
|
|
45
|
+
},
|
|
46
|
+
event: async (input) => {
|
|
47
|
+
if (!input.event)
|
|
48
|
+
return;
|
|
49
|
+
await handleEvent(input.event, core);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type LoopEvent = {
|
|
2
|
+
type: "session.idle";
|
|
3
|
+
sessionID: string;
|
|
4
|
+
} | {
|
|
5
|
+
type: "session.deleted";
|
|
6
|
+
sessionID: string;
|
|
7
|
+
} | {
|
|
8
|
+
type: "session.error";
|
|
9
|
+
sessionID: string;
|
|
10
|
+
};
|
|
11
|
+
type LoopCore = {
|
|
12
|
+
handleEvent: (event: LoopEvent) => Promise<unknown>;
|
|
13
|
+
};
|
|
14
|
+
export declare function handleEvent(input: unknown, core: LoopCore): Promise<void>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function getSessionID(input) {
|
|
2
|
+
if (input.type === "session.deleted") {
|
|
3
|
+
return typeof input.properties?.info?.id === "string" ? input.properties.info.id : null;
|
|
4
|
+
}
|
|
5
|
+
if (input.type === "session.idle" || input.type === "session.error") {
|
|
6
|
+
return typeof input.properties?.sessionID === "string" ? input.properties.sessionID : null;
|
|
7
|
+
}
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
export async function handleEvent(input, core) {
|
|
11
|
+
if (!input || typeof input !== "object")
|
|
12
|
+
return;
|
|
13
|
+
const record = input;
|
|
14
|
+
if (record.type !== "session.idle" && record.type !== "session.deleted" && record.type !== "session.error") {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const sessionID = getSessionID(record);
|
|
18
|
+
if (!sessionID)
|
|
19
|
+
return;
|
|
20
|
+
await core.handleEvent({ type: record.type, sessionID });
|
|
21
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function buildContinuationPrompt(input) {
|
|
2
|
+
return [
|
|
3
|
+
`Continue Ralph Loop iteration ${input.iteration}.`,
|
|
4
|
+
...(input.maxIterations === undefined ? [] : [`Max iterations: ${input.maxIterations}`]),
|
|
5
|
+
`Original task: ${input.prompt}`,
|
|
6
|
+
`Emit ${input.completionPromise} when complete.`,
|
|
7
|
+
].join("\n");
|
|
8
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HostAdapter } from "../host-adapter/types.js";
|
|
2
|
+
export type LoopEvent = {
|
|
3
|
+
type: "session.idle";
|
|
4
|
+
sessionID: string;
|
|
5
|
+
} | {
|
|
6
|
+
type: "session.deleted";
|
|
7
|
+
sessionID: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: "session.error";
|
|
10
|
+
sessionID: string;
|
|
11
|
+
};
|
|
12
|
+
export type CreateLoopCoreDeps = {
|
|
13
|
+
rootDir: string;
|
|
14
|
+
adapter: HostAdapter;
|
|
15
|
+
};
|
|
16
|
+
export type StartLoopOptions = {
|
|
17
|
+
maxIterations?: number;
|
|
18
|
+
completionPromise?: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function createLoopCore(deps: CreateLoopCoreDeps): {
|
|
21
|
+
startLoop(sessionID: string, prompt: string, options?: StartLoopOptions): Promise<void>;
|
|
22
|
+
cancelLoop(sessionID: string): Promise<void>;
|
|
23
|
+
handleEvent(event: LoopEvent): Promise<void>;
|
|
24
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { DEFAULT_COMPLETION_PROMISE } from "./constants.js";
|
|
2
|
+
import { buildContinuationPrompt } from "./continuation-prompt.js";
|
|
3
|
+
import { detectCompletion } from "./completion-detector.js";
|
|
4
|
+
import { clearState, readState, writeState } from "./state-store.js";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
export function createLoopCore(deps) {
|
|
7
|
+
const inFlight = new Map();
|
|
8
|
+
const getToken = (state) => state.incarnation_token ?? state.started_at;
|
|
9
|
+
return {
|
|
10
|
+
async startLoop(sessionID, prompt, options = {}) {
|
|
11
|
+
const existing = await readState(deps.rootDir);
|
|
12
|
+
if (existing?.active) {
|
|
13
|
+
const stillExists = await deps.adapter.sessionExists(existing.session_id);
|
|
14
|
+
if (stillExists) {
|
|
15
|
+
throw new Error("an active Ralph Loop already exists; use /cancel-ralph first");
|
|
16
|
+
}
|
|
17
|
+
await clearState(deps.rootDir);
|
|
18
|
+
}
|
|
19
|
+
else if (existing) {
|
|
20
|
+
await clearState(deps.rootDir);
|
|
21
|
+
}
|
|
22
|
+
const messageCountAtStart = await deps.adapter.getMessageCount(sessionID);
|
|
23
|
+
const incarnationToken = randomUUID();
|
|
24
|
+
const state = {
|
|
25
|
+
active: true,
|
|
26
|
+
session_id: sessionID,
|
|
27
|
+
prompt,
|
|
28
|
+
iteration: 0,
|
|
29
|
+
max_iterations: options.maxIterations,
|
|
30
|
+
completion_promise: options.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
|
|
31
|
+
message_count_at_start: messageCountAtStart,
|
|
32
|
+
incarnation_token: incarnationToken,
|
|
33
|
+
started_at: new Date().toISOString(),
|
|
34
|
+
};
|
|
35
|
+
await writeState(deps.rootDir, state);
|
|
36
|
+
},
|
|
37
|
+
async cancelLoop(sessionID) {
|
|
38
|
+
const state = await readState(deps.rootDir);
|
|
39
|
+
if (!state || !state.active || state.session_id !== sessionID)
|
|
40
|
+
return;
|
|
41
|
+
await deps.adapter.abortSession(sessionID);
|
|
42
|
+
await clearState(deps.rootDir);
|
|
43
|
+
},
|
|
44
|
+
async handleEvent(event) {
|
|
45
|
+
if (event.type === "session.deleted" || event.type === "session.error") {
|
|
46
|
+
const state = await readState(deps.rootDir);
|
|
47
|
+
if (state && state.active && state.session_id === event.sessionID) {
|
|
48
|
+
await clearState(deps.rootDir);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const state = await readState(deps.rootDir);
|
|
54
|
+
if (!state || !state.active || state.session_id !== event.sessionID)
|
|
55
|
+
return;
|
|
56
|
+
const incarnationToken = getToken(state);
|
|
57
|
+
const inFlightToken = inFlight.get(event.sessionID);
|
|
58
|
+
if (inFlightToken === incarnationToken)
|
|
59
|
+
return;
|
|
60
|
+
inFlight.set(event.sessionID, incarnationToken);
|
|
61
|
+
const messages = await deps.adapter.getMessages(event.sessionID);
|
|
62
|
+
const batchMessageCount = messages.length;
|
|
63
|
+
const baselineMessageCount = state.last_message_count_processed ?? state.message_count_at_start;
|
|
64
|
+
if (batchMessageCount <= baselineMessageCount)
|
|
65
|
+
return;
|
|
66
|
+
const hasNewAssistantMessages = messages
|
|
67
|
+
.slice(baselineMessageCount)
|
|
68
|
+
.some((message) => message.role === "assistant");
|
|
69
|
+
if (!hasNewAssistantMessages)
|
|
70
|
+
return;
|
|
71
|
+
const liveState = await readState(deps.rootDir);
|
|
72
|
+
if (!liveState ||
|
|
73
|
+
!liveState.active ||
|
|
74
|
+
liveState.session_id !== event.sessionID ||
|
|
75
|
+
getToken(liveState) !== incarnationToken) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const currentLiveState = liveState;
|
|
79
|
+
if (detectCompletion(messages, currentLiveState.completion_promise, currentLiveState.message_count_at_start)) {
|
|
80
|
+
await clearState(deps.rootDir);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const nextIteration = currentLiveState.iteration + 1;
|
|
84
|
+
if (typeof currentLiveState.max_iterations === "number" &&
|
|
85
|
+
nextIteration > currentLiveState.max_iterations) {
|
|
86
|
+
await clearState(deps.rootDir);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await deps.adapter.prompt(event.sessionID, buildContinuationPrompt({
|
|
90
|
+
iteration: nextIteration,
|
|
91
|
+
prompt: currentLiveState.prompt,
|
|
92
|
+
completionPromise: currentLiveState.completion_promise,
|
|
93
|
+
maxIterations: currentLiveState.max_iterations,
|
|
94
|
+
}));
|
|
95
|
+
const currentState = await readState(deps.rootDir);
|
|
96
|
+
if (!currentState ||
|
|
97
|
+
!currentState.active ||
|
|
98
|
+
currentState.session_id !== event.sessionID ||
|
|
99
|
+
getToken(currentState) !== incarnationToken) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const persistedState = currentState;
|
|
103
|
+
const nextState = {
|
|
104
|
+
...persistedState,
|
|
105
|
+
iteration: nextIteration,
|
|
106
|
+
last_message_count_processed: batchMessageCount,
|
|
107
|
+
};
|
|
108
|
+
await writeState(deps.rootDir, nextState);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
const currentToken = inFlight.get(event.sessionID);
|
|
112
|
+
if (currentToken !== undefined) {
|
|
113
|
+
const state = await readState(deps.rootDir);
|
|
114
|
+
const stateToken = state?.session_id === event.sessionID && state?.active ? getToken(state) : undefined;
|
|
115
|
+
if (currentToken === stateToken) {
|
|
116
|
+
inFlight.delete(event.sessionID);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RalphLoopState } from "./types.js";
|
|
2
|
+
export declare function writeState(root: string, state: RalphLoopState): Promise<void>;
|
|
3
|
+
export declare function readState(root: string): Promise<RalphLoopState | null>;
|
|
4
|
+
export declare function clearState(root: string): Promise<void>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { DEFAULT_STATE_PATH } from "./constants.js";
|
|
5
|
+
function getStateFilePath(root) {
|
|
6
|
+
return join(root, DEFAULT_STATE_PATH);
|
|
7
|
+
}
|
|
8
|
+
function isRalphLoopState(value) {
|
|
9
|
+
if (typeof value !== "object" || value === null)
|
|
10
|
+
return false;
|
|
11
|
+
const record = value;
|
|
12
|
+
return (typeof record.active === "boolean" &&
|
|
13
|
+
typeof record.session_id === "string" &&
|
|
14
|
+
typeof record.prompt === "string" &&
|
|
15
|
+
typeof record.iteration === "number" &&
|
|
16
|
+
(record.max_iterations === undefined || typeof record.max_iterations === "number") &&
|
|
17
|
+
typeof record.completion_promise === "string" &&
|
|
18
|
+
typeof record.message_count_at_start === "number" &&
|
|
19
|
+
(record.last_message_count_processed === undefined || typeof record.last_message_count_processed === "number") &&
|
|
20
|
+
(record.incarnation_token === undefined || typeof record.incarnation_token === "string") &&
|
|
21
|
+
typeof record.started_at === "string");
|
|
22
|
+
}
|
|
23
|
+
export async function writeState(root, state) {
|
|
24
|
+
const filePath = getStateFilePath(root);
|
|
25
|
+
const tempFilePath = `${filePath}.${randomUUID()}.tmp`;
|
|
26
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
27
|
+
try {
|
|
28
|
+
await writeFile(tempFilePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
29
|
+
await rename(tempFilePath, filePath);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
await rm(tempFilePath, { force: true });
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function readState(root) {
|
|
37
|
+
const filePath = getStateFilePath(root);
|
|
38
|
+
try {
|
|
39
|
+
const content = await readFile(filePath, "utf8");
|
|
40
|
+
const parsed = JSON.parse(content);
|
|
41
|
+
if (!isRalphLoopState(parsed)) {
|
|
42
|
+
throw new Error("invalid RalphLoopState shape");
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof SyntaxError) {
|
|
48
|
+
throw new Error("invalid RalphLoopState JSON", { cause: error });
|
|
49
|
+
}
|
|
50
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function clearState(root) {
|
|
57
|
+
await rm(getStateFilePath(root), { force: true });
|
|
58
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type RalphLoopState = {
|
|
2
|
+
active: boolean;
|
|
3
|
+
session_id: string;
|
|
4
|
+
prompt: string;
|
|
5
|
+
iteration: number;
|
|
6
|
+
max_iterations?: number;
|
|
7
|
+
completion_promise: string;
|
|
8
|
+
message_count_at_start: number;
|
|
9
|
+
last_message_count_processed?: number;
|
|
10
|
+
incarnation_token?: string;
|
|
11
|
+
started_at: string;
|
|
12
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@w32191/just-loop",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "OpenCode plugin package for just-loop.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/src/index.js",
|
|
8
|
+
"types": "./dist/src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/src/index.d.ts",
|
|
12
|
+
"import": "./dist/src/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist/", "README.md", "LICENSE"],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "bun run build && bun test",
|
|
26
|
+
"smoke:pack": "node scripts/smoke-pack.mjs",
|
|
27
|
+
"verify:publish": "npm run build && npm run test && node scripts/verify-publish.mjs",
|
|
28
|
+
"prepublishOnly": "npm run verify:publish"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@opencode-ai/plugin": "1.3.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"bun-types": "1.3.11",
|
|
35
|
+
"typescript": "^5.7.0"
|
|
36
|
+
}
|
|
37
|
+
}
|