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.
@@ -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
+ }