pi-rlm 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/README.md +110 -0
- package/index.ts +262 -0
- package/package.json +53 -0
- package/src/backends.ts +356 -0
- package/src/engine.ts +462 -0
- package/src/prompts.ts +83 -0
- package/src/runs.ts +128 -0
- package/src/schema.ts +29 -0
- package/src/types.ts +80 -0
- package/src/utils.ts +128 -0
package/src/backends.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
SessionManager,
|
|
7
|
+
createAgentSession,
|
|
8
|
+
createCodingTools,
|
|
9
|
+
createReadOnlyTools,
|
|
10
|
+
DefaultResourceLoader,
|
|
11
|
+
type ExtensionContext
|
|
12
|
+
} from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import { RlmBackend, RlmToolsProfile } from "./types";
|
|
14
|
+
import { parseModelPattern, shellQuote, sleep, toErrorMessage } from "./utils";
|
|
15
|
+
|
|
16
|
+
export interface CompletionRequest {
|
|
17
|
+
backend: RlmBackend;
|
|
18
|
+
prompt: string;
|
|
19
|
+
cwd: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
toolsProfile: RlmToolsProfile;
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ProcessResult {
|
|
27
|
+
code: number | null;
|
|
28
|
+
stdout: string;
|
|
29
|
+
stderr: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const defaultCliFlags = [
|
|
33
|
+
"-p",
|
|
34
|
+
"--no-session",
|
|
35
|
+
"--no-extensions",
|
|
36
|
+
"--no-skills",
|
|
37
|
+
"--no-prompt-templates",
|
|
38
|
+
"--no-themes"
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export async function completeWithBackend(
|
|
42
|
+
request: CompletionRequest,
|
|
43
|
+
ctx: ExtensionContext
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
switch (request.backend) {
|
|
46
|
+
case "sdk":
|
|
47
|
+
return completeWithSdk(request, ctx);
|
|
48
|
+
case "cli":
|
|
49
|
+
return completeWithCli(request);
|
|
50
|
+
case "tmux":
|
|
51
|
+
return completeWithTmux(request);
|
|
52
|
+
default:
|
|
53
|
+
return completeWithSdk(request, ctx);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function profileToTools(profile: RlmToolsProfile): string {
|
|
58
|
+
if (profile === "read-only") {
|
|
59
|
+
return "read,grep,find,ls";
|
|
60
|
+
}
|
|
61
|
+
return "read,bash,edit,write";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function completeWithSdk(request: CompletionRequest, ctx: ExtensionContext): Promise<string> {
|
|
65
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
66
|
+
cwd: request.cwd,
|
|
67
|
+
noExtensions: true,
|
|
68
|
+
noSkills: true,
|
|
69
|
+
noPromptTemplates: true,
|
|
70
|
+
noThemes: true,
|
|
71
|
+
agentsFilesOverride: () => ({ agentsFiles: [] }),
|
|
72
|
+
appendSystemPromptOverride: () => []
|
|
73
|
+
});
|
|
74
|
+
await resourceLoader.reload();
|
|
75
|
+
|
|
76
|
+
let model = ctx.model;
|
|
77
|
+
let thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined;
|
|
78
|
+
|
|
79
|
+
const parsedModel = parseModelPattern(request.model);
|
|
80
|
+
if (parsedModel) {
|
|
81
|
+
const found = ctx.modelRegistry.find(parsedModel.provider, parsedModel.id);
|
|
82
|
+
if (found) {
|
|
83
|
+
model = found;
|
|
84
|
+
}
|
|
85
|
+
thinkingLevel = parsedModel.thinkingLevel;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tools =
|
|
89
|
+
request.toolsProfile === "read-only"
|
|
90
|
+
? createReadOnlyTools(request.cwd)
|
|
91
|
+
: createCodingTools(request.cwd);
|
|
92
|
+
|
|
93
|
+
const { session } = await createAgentSession({
|
|
94
|
+
cwd: request.cwd,
|
|
95
|
+
model,
|
|
96
|
+
thinkingLevel,
|
|
97
|
+
tools,
|
|
98
|
+
resourceLoader,
|
|
99
|
+
sessionManager: SessionManager.inMemory()
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let output = "";
|
|
103
|
+
const unsubscribe = session.subscribe((event) => {
|
|
104
|
+
if (
|
|
105
|
+
event.type === "message_update" &&
|
|
106
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
107
|
+
) {
|
|
108
|
+
output += event.assistantMessageEvent.delta;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const abortHandler = () => {
|
|
113
|
+
void session.abort();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (request.signal) {
|
|
117
|
+
request.signal.addEventListener("abort", abortHandler, { once: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await withTimeout(session.prompt(request.prompt), request.timeoutMs, () => {
|
|
122
|
+
void session.abort();
|
|
123
|
+
});
|
|
124
|
+
} finally {
|
|
125
|
+
unsubscribe();
|
|
126
|
+
if (request.signal) {
|
|
127
|
+
request.signal.removeEventListener("abort", abortHandler);
|
|
128
|
+
}
|
|
129
|
+
session.dispose();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const trimmed = output.trim();
|
|
133
|
+
if (trimmed) return trimmed;
|
|
134
|
+
|
|
135
|
+
return extractLastAssistantText(session.messages) || "(no response)";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function completeWithCli(request: CompletionRequest): Promise<string> {
|
|
139
|
+
const args = [...defaultCliFlags, "--tools", profileToTools(request.toolsProfile)];
|
|
140
|
+
|
|
141
|
+
if (request.model) {
|
|
142
|
+
args.push("--model", request.model);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
args.push(request.prompt);
|
|
146
|
+
|
|
147
|
+
const result = await runProcess("pi", args, {
|
|
148
|
+
cwd: request.cwd,
|
|
149
|
+
timeoutMs: request.timeoutMs,
|
|
150
|
+
signal: request.signal,
|
|
151
|
+
env: {
|
|
152
|
+
...process.env,
|
|
153
|
+
PI_OFFLINE: "1"
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (result.code !== 0) {
|
|
158
|
+
throw new Error(`pi subprocess failed (${result.code ?? "unknown"}): ${result.stderr || result.stdout}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const text = result.stdout.trim() || result.stderr.trim();
|
|
162
|
+
return text || "(no response)";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function completeWithTmux(request: CompletionRequest): Promise<string> {
|
|
166
|
+
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
167
|
+
const promptPath = join(tmpdir(), `pi-rlm-${stamp}.prompt.txt`);
|
|
168
|
+
const outputPath = join(tmpdir(), `pi-rlm-${stamp}.output.log`);
|
|
169
|
+
const sessionName = `pi-rlm-${stamp}`;
|
|
170
|
+
const tools = profileToTools(request.toolsProfile);
|
|
171
|
+
|
|
172
|
+
await fs.writeFile(promptPath, request.prompt, "utf8");
|
|
173
|
+
|
|
174
|
+
const modelPart = request.model ? ` --model ${shellQuote(request.model)}` : "";
|
|
175
|
+
const command = [
|
|
176
|
+
`PROMPT_CONTENT=$(cat ${shellQuote(promptPath)})`,
|
|
177
|
+
`PI_OFFLINE=1 pi ${defaultCliFlags.join(" ")} --tools ${shellQuote(tools)}${modelPart} \"$PROMPT_CONTENT\" > ${shellQuote(outputPath)} 2>&1`
|
|
178
|
+
].join("; ");
|
|
179
|
+
|
|
180
|
+
const startResult = await runProcess(
|
|
181
|
+
"tmux",
|
|
182
|
+
["new-session", "-d", "-s", sessionName, command],
|
|
183
|
+
{
|
|
184
|
+
cwd: request.cwd,
|
|
185
|
+
timeoutMs: 10000,
|
|
186
|
+
signal: request.signal
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
if (startResult.code !== 0) {
|
|
191
|
+
await safeUnlink(promptPath);
|
|
192
|
+
throw new Error(`tmux backend failed to start: ${startResult.stderr || startResult.stdout}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const deadline = Date.now() + request.timeoutMs;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
while (Date.now() < deadline) {
|
|
199
|
+
if (request.signal?.aborted) {
|
|
200
|
+
await killTmuxSession(sessionName);
|
|
201
|
+
throw new Error("RLM request aborted");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const alive = await hasTmuxSession(sessionName);
|
|
205
|
+
if (!alive) break;
|
|
206
|
+
await sleep(250);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (await hasTmuxSession(sessionName)) {
|
|
210
|
+
await killTmuxSession(sessionName);
|
|
211
|
+
throw new Error(`tmux backend timed out after ${request.timeoutMs}ms`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const output = await fs.readFile(outputPath, "utf8").catch(() => "");
|
|
215
|
+
return output.trim() || "(no response)";
|
|
216
|
+
} finally {
|
|
217
|
+
await safeUnlink(promptPath);
|
|
218
|
+
await safeUnlink(outputPath);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function extractLastAssistantText(messages: unknown[]): string {
|
|
223
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
224
|
+
const message = messages[i] as { role?: string; content?: unknown };
|
|
225
|
+
if (message.role !== "assistant") continue;
|
|
226
|
+
if (!Array.isArray(message.content)) continue;
|
|
227
|
+
|
|
228
|
+
const text = message.content
|
|
229
|
+
.filter((chunk): chunk is { type: string; text?: string } =>
|
|
230
|
+
typeof chunk === "object" && chunk !== null && "type" in chunk
|
|
231
|
+
)
|
|
232
|
+
.filter((chunk) => chunk.type === "text" && typeof chunk.text === "string")
|
|
233
|
+
.map((chunk) => chunk.text as string)
|
|
234
|
+
.join("")
|
|
235
|
+
.trim();
|
|
236
|
+
|
|
237
|
+
if (text) return text;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout?: () => void): Promise<T> {
|
|
244
|
+
let timer: NodeJS.Timeout | undefined;
|
|
245
|
+
try {
|
|
246
|
+
return await Promise.race([
|
|
247
|
+
promise,
|
|
248
|
+
new Promise<T>((_, reject) => {
|
|
249
|
+
timer = setTimeout(() => {
|
|
250
|
+
onTimeout?.();
|
|
251
|
+
reject(new Error(`Timed out after ${timeoutMs}ms`));
|
|
252
|
+
}, timeoutMs);
|
|
253
|
+
})
|
|
254
|
+
]);
|
|
255
|
+
} finally {
|
|
256
|
+
if (timer) clearTimeout(timer);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function hasTmuxSession(sessionName: string): Promise<boolean> {
|
|
261
|
+
const result = await runProcess("tmux", ["has-session", "-t", sessionName], {
|
|
262
|
+
timeoutMs: 5000
|
|
263
|
+
});
|
|
264
|
+
return result.code === 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function killTmuxSession(sessionName: string): Promise<void> {
|
|
268
|
+
await runProcess("tmux", ["kill-session", "-t", sessionName], {
|
|
269
|
+
timeoutMs: 5000
|
|
270
|
+
}).catch(() => undefined);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function safeUnlink(path: string): Promise<void> {
|
|
274
|
+
try {
|
|
275
|
+
await fs.unlink(path);
|
|
276
|
+
} catch {
|
|
277
|
+
// ignore
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function runProcess(
|
|
282
|
+
command: string,
|
|
283
|
+
args: string[],
|
|
284
|
+
options: {
|
|
285
|
+
cwd?: string;
|
|
286
|
+
env?: NodeJS.ProcessEnv;
|
|
287
|
+
timeoutMs?: number;
|
|
288
|
+
signal?: AbortSignal;
|
|
289
|
+
}
|
|
290
|
+
): Promise<ProcessResult> {
|
|
291
|
+
return new Promise<ProcessResult>((resolve, reject) => {
|
|
292
|
+
const child = spawn(command, args, {
|
|
293
|
+
cwd: options.cwd,
|
|
294
|
+
env: options.env,
|
|
295
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
let stdout = "";
|
|
299
|
+
let stderr = "";
|
|
300
|
+
let settled = false;
|
|
301
|
+
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
302
|
+
|
|
303
|
+
const finish = (fn: () => void): void => {
|
|
304
|
+
if (settled) return;
|
|
305
|
+
settled = true;
|
|
306
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
307
|
+
if (options.signal) {
|
|
308
|
+
options.signal.removeEventListener("abort", abortHandler);
|
|
309
|
+
}
|
|
310
|
+
fn();
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const abortHandler = (): void => {
|
|
314
|
+
try {
|
|
315
|
+
child.kill("SIGTERM");
|
|
316
|
+
} catch {
|
|
317
|
+
// ignore
|
|
318
|
+
}
|
|
319
|
+
finish(() => reject(new Error("Process aborted")));
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
if (options.signal) {
|
|
323
|
+
if (options.signal.aborted) {
|
|
324
|
+
abortHandler();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
331
|
+
timeoutHandle = setTimeout(() => {
|
|
332
|
+
try {
|
|
333
|
+
child.kill("SIGTERM");
|
|
334
|
+
} catch {
|
|
335
|
+
// ignore
|
|
336
|
+
}
|
|
337
|
+
finish(() => reject(new Error(`Process timed out after ${options.timeoutMs}ms`)));
|
|
338
|
+
}, options.timeoutMs);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
342
|
+
stdout += chunk.toString();
|
|
343
|
+
});
|
|
344
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
345
|
+
stderr += chunk.toString();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
child.on("error", (error) => {
|
|
349
|
+
finish(() => reject(new Error(`Failed to execute '${command}': ${toErrorMessage(error)}`)));
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
child.on("close", (code) => {
|
|
353
|
+
finish(() => resolve({ code, stdout, stderr }));
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
}
|