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 ADDED
@@ -0,0 +1,110 @@
1
+ # pi-rlm
2
+
3
+ Recursive Language Model (RLM) extension for [Pi coding agent](https://github.com/badlogic/pi-mono).
4
+
5
+ This extension adds an `rlm` tool that performs depth-limited recursive decomposition:
6
+
7
+ 1. planner node decides `solve` vs `decompose`
8
+ 2. child nodes recurse on subtasks
9
+ 3. synthesizer node merges child outputs
10
+
11
+ It includes guardrails for depth, node budget, branching, and cycle detection.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pi install /path/to/pi-rlm
17
+ ```
18
+
19
+ Or as a package:
20
+
21
+ ```bash
22
+ pi install npm:pi-rlm
23
+ ```
24
+
25
+ ## Tool API
26
+
27
+ The extension registers one tool: `rlm`.
28
+
29
+ ### `op=start` (default)
30
+
31
+ ```ts
32
+ rlm({
33
+ task: "Implement auth refactor and validate tests",
34
+ backend: "sdk", // sdk | cli | tmux
35
+ mode: "auto", // auto | solve | decompose
36
+ maxDepth: 2,
37
+ maxNodes: 24,
38
+ maxBranching: 3,
39
+ concurrency: 2,
40
+ toolsProfile: "coding", // coding | read-only
41
+ timeoutMs: 180000,
42
+ async: false
43
+ })
44
+ ```
45
+
46
+ ### `op=status`
47
+
48
+ ```ts
49
+ rlm({ op: "status", id: "a1b2c3d4" })
50
+ ```
51
+
52
+ If `id` is omitted, returns recent runs.
53
+
54
+ ### `op=wait`
55
+
56
+ ```ts
57
+ rlm({ op: "wait", id: "a1b2c3d4", waitTimeoutMs: 120000 })
58
+ ```
59
+
60
+ ### `op=cancel`
61
+
62
+ ```ts
63
+ rlm({ op: "cancel", id: "a1b2c3d4" })
64
+ ```
65
+
66
+ ## Backend Behavior
67
+
68
+ ### `backend: "sdk"` (default)
69
+
70
+ - Runs subcalls in-process via Pi SDK sessions
71
+ - No fresh `pi` CLI process per subcall
72
+ - Best default for low overhead and deterministic orchestration
73
+
74
+ ### `backend: "cli"`
75
+
76
+ - Runs each subcall as a fresh `pi -p` subprocess
77
+ - Good isolation, easier debugging in logs
78
+ - Slightly higher process overhead
79
+
80
+ ### `backend: "tmux"`
81
+
82
+ - Runs each subcall inside a detached tmux session
83
+ - Uses fresh `pi` process per subcall
84
+ - Useful when you specifically want tmux-level observability/control
85
+
86
+ ## Artifacts
87
+
88
+ Each run writes artifacts to:
89
+
90
+ ```text
91
+ /tmp/pi-rlm-runs/<runId>/
92
+ events.jsonl
93
+ tree.json
94
+ output.md
95
+ ```
96
+
97
+ ## Guardrails
98
+
99
+ - `maxDepth`: recursion depth cap
100
+ - `maxNodes`: total node budget
101
+ - `maxBranching`: child count cap per decomposition
102
+ - cycle detection by normalized task lineage
103
+ - cancellable runs (`op=cancel`)
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ npm install
109
+ npm run typecheck
110
+ ```
package/index.ts ADDED
@@ -0,0 +1,262 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { runRlmEngine } from "./src/engine";
3
+ import { rlmToolParamsSchema, type RlmToolParams } from "./src/schema";
4
+ import { RunStore } from "./src/runs";
5
+ import type { RunRecord, StartRunInput } from "./src/types";
6
+ import { truncateText } from "./src/utils";
7
+
8
+ const defaultWaitTimeoutMs = 120000;
9
+ const defaultNodeTimeoutMs = 180000;
10
+
11
+ export default function extension(pi: ExtensionAPI): void {
12
+ const runs = new RunStore();
13
+
14
+ pi.registerTool({
15
+ name: "rlm",
16
+ label: "RLM",
17
+ description:
18
+ "Recursive Language Model orchestration with depth-limited decomposition. Supports start/status/wait/cancel and sdk/cli/tmux backends.",
19
+ parameters: rlmToolParamsSchema,
20
+ async execute(_toolCallId, params: RlmToolParams, signal, onUpdate, ctx) {
21
+ const op = params.op ?? "start";
22
+
23
+ if (op === "start") {
24
+ if (!params.task || !params.task.trim()) {
25
+ throw new Error("'task' is required for op=start");
26
+ }
27
+
28
+ const input = resolveStartInput(params, ctx.cwd);
29
+ const progress = (line: string): void => {
30
+ onUpdate?.({
31
+ content: [{ type: "text", text: line }],
32
+ details: {}
33
+ });
34
+ };
35
+
36
+ const record = runs.start(
37
+ input,
38
+ (runId, runSignal) => runRlmEngine({ ...input, runId }, ctx, runSignal, progress),
39
+ signal
40
+ );
41
+
42
+ if (input.async) {
43
+ return {
44
+ content: [
45
+ {
46
+ type: "text",
47
+ text: [
48
+ `RLM run started in background.`,
49
+ `run_id: ${record.id}`,
50
+ `backend: ${input.backend}`,
51
+ `mode: ${input.mode}`,
52
+ `depth<=${input.maxDepth} nodes<=${input.maxNodes}`
53
+ ].join("\n")
54
+ }
55
+ ],
56
+ details: toRunDetails(record)
57
+ };
58
+ }
59
+
60
+ const result = await record.promise;
61
+ const summary = truncateText(result.final, 60000);
62
+
63
+ const lines = [
64
+ `RLM run completed.`,
65
+ `run_id: ${result.runId}`,
66
+ `backend: ${result.backend}`,
67
+ `stats: nodes=${result.stats.nodesVisited}, maxDepthSeen=${result.stats.maxDepthSeen}, durationMs=${result.stats.durationMs}`,
68
+ `artifacts: ${result.artifacts.dir}`,
69
+ "",
70
+ summary.text
71
+ ];
72
+
73
+ if (summary.truncated) {
74
+ lines.push("", `Full output saved to: ${result.artifacts.outputPath}`);
75
+ }
76
+
77
+ return {
78
+ content: [{ type: "text", text: lines.join("\n") }],
79
+ details: {
80
+ ...toRunDetails(record),
81
+ result
82
+ }
83
+ };
84
+ }
85
+
86
+ if (op === "status") {
87
+ if (params.id) {
88
+ const record = runs.get(params.id);
89
+ if (!record) {
90
+ throw new Error(`Unknown run id: ${params.id}`);
91
+ }
92
+
93
+ return {
94
+ content: [{ type: "text", text: describeRecord(record) }],
95
+ details: toRunDetails(record)
96
+ };
97
+ }
98
+
99
+ const list = runs.list();
100
+ if (list.length === 0) {
101
+ return {
102
+ content: [{ type: "text", text: "No RLM runs found." }],
103
+ details: { runs: [] }
104
+ };
105
+ }
106
+
107
+ const lines = ["Recent RLM runs:", ...list.slice(0, 20).map(formatRunLine)];
108
+ return {
109
+ content: [{ type: "text", text: lines.join("\n") }],
110
+ details: { runs: list.map(toRunDetails) }
111
+ };
112
+ }
113
+
114
+ if (!params.id) {
115
+ throw new Error(`'id' is required for op=${op}`);
116
+ }
117
+
118
+ if (op === "wait") {
119
+ const waitTimeoutMs = params.waitTimeoutMs ?? defaultWaitTimeoutMs;
120
+ const { record, done } = await runs.wait(params.id, waitTimeoutMs);
121
+
122
+ if (!done) {
123
+ return {
124
+ content: [
125
+ {
126
+ type: "text",
127
+ text: [
128
+ `Run ${record.id} is still running.`,
129
+ `status: ${record.status}`,
130
+ `wait timeout reached after ${waitTimeoutMs}ms`
131
+ ].join("\n")
132
+ }
133
+ ],
134
+ details: {
135
+ ...toRunDetails(record),
136
+ done: false,
137
+ wait_status: "timeout"
138
+ }
139
+ };
140
+ }
141
+
142
+ if (record.status === "completed" && record.result) {
143
+ const summary = truncateText(record.result.final, 60000);
144
+ const text = [
145
+ `Run ${record.id} completed.`,
146
+ `artifacts: ${record.result.artifacts.dir}`,
147
+ "",
148
+ summary.text,
149
+ summary.truncated ? `\nFull output: ${record.result.artifacts.outputPath}` : ""
150
+ ]
151
+ .filter(Boolean)
152
+ .join("\n");
153
+
154
+ return {
155
+ content: [{ type: "text", text }],
156
+ details: {
157
+ ...toRunDetails(record),
158
+ done: true,
159
+ wait_status: "completed"
160
+ }
161
+ };
162
+ }
163
+
164
+ return {
165
+ content: [{ type: "text", text: describeRecord(record) }],
166
+ details: {
167
+ ...toRunDetails(record),
168
+ done: true,
169
+ wait_status: record.status
170
+ }
171
+ };
172
+ }
173
+
174
+ if (op === "cancel") {
175
+ const record = runs.cancel(params.id);
176
+ return {
177
+ content: [
178
+ {
179
+ type: "text",
180
+ text: `Cancellation requested for run ${record.id}. Current status: ${record.status}`
181
+ }
182
+ ],
183
+ details: {
184
+ ...toRunDetails(record),
185
+ cancel_applied: record.status === "running"
186
+ }
187
+ };
188
+ }
189
+
190
+ throw new Error(`Unsupported op: ${op}`);
191
+ }
192
+ });
193
+ }
194
+
195
+ function resolveStartInput(params: RlmToolParams, cwd: string): StartRunInput {
196
+ return {
197
+ task: params.task ?? "",
198
+ backend: params.backend ?? "sdk",
199
+ mode: params.mode ?? "auto",
200
+ async: params.async ?? false,
201
+ model: params.model,
202
+ cwd: params.cwd ?? cwd,
203
+ toolsProfile: params.toolsProfile ?? "coding",
204
+ maxDepth: params.maxDepth ?? 2,
205
+ maxNodes: params.maxNodes ?? 24,
206
+ maxBranching: params.maxBranching ?? 3,
207
+ concurrency: params.concurrency ?? 2,
208
+ timeoutMs: params.timeoutMs ?? defaultNodeTimeoutMs
209
+ };
210
+ }
211
+
212
+ function formatRunLine(record: RunRecord): string {
213
+ const elapsed = (record.finishedAt ?? Date.now()) - record.startedAt;
214
+ return `- ${record.id} | ${record.status} | ${record.input.backend} | ${elapsed}ms | task=${shorten(
215
+ record.input.task,
216
+ 48
217
+ )}`;
218
+ }
219
+
220
+ function describeRecord(record: RunRecord): string {
221
+ const lines = [
222
+ `run_id: ${record.id}`,
223
+ `status: ${record.status}`,
224
+ `backend: ${record.input.backend}`,
225
+ `mode: ${record.input.mode}`,
226
+ `task: ${record.input.task}`,
227
+ `started_at: ${new Date(record.startedAt).toISOString()}`
228
+ ];
229
+
230
+ if (record.finishedAt) {
231
+ lines.push(`finished_at: ${new Date(record.finishedAt).toISOString()}`);
232
+ lines.push(`duration_ms: ${record.finishedAt - record.startedAt}`);
233
+ }
234
+
235
+ if (record.error) {
236
+ lines.push(`error: ${record.error}`);
237
+ }
238
+
239
+ if (record.result) {
240
+ lines.push(`artifacts: ${record.result.artifacts.dir}`);
241
+ }
242
+
243
+ return lines.join("\n");
244
+ }
245
+
246
+ function toRunDetails(record: RunRecord): Record<string, unknown> {
247
+ return {
248
+ contract_version: "rlm.v1",
249
+ run_id: record.id,
250
+ status: record.status,
251
+ input: record.input,
252
+ created_at: record.createdAt,
253
+ started_at: record.startedAt,
254
+ finished_at: record.finishedAt,
255
+ error: record.error
256
+ };
257
+ }
258
+
259
+ function shorten(text: string, maxChars: number): string {
260
+ if (text.length <= maxChars) return text;
261
+ return `${text.slice(0, maxChars - 3)}...`;
262
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "pi-rlm",
3
+ "version": "0.1.0",
4
+ "description": "Recursive Language Model (RLM) extension for Pi coding agent",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "index.ts",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi-coding-agent",
12
+ "rlm",
13
+ "recursive-language-model",
14
+ "subagent"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/manojlds/pi-rlm.git"
19
+ },
20
+ "homepage": "https://github.com/manojlds/pi-rlm",
21
+ "bugs": "https://github.com/manojlds/pi-rlm/issues",
22
+ "files": [
23
+ "index.ts",
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "provenance": true
30
+ },
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit",
33
+ "pack:check": "npm pack --dry-run",
34
+ "check": "npm run typecheck && npm run pack:check"
35
+ },
36
+ "peerDependencies": {
37
+ "@mariozechner/pi-ai": "*",
38
+ "@mariozechner/pi-coding-agent": "*",
39
+ "@sinclair/typebox": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@mariozechner/pi-ai": "^0.57.1",
43
+ "@mariozechner/pi-coding-agent": "^0.57.1",
44
+ "@sinclair/typebox": "^0.34.41",
45
+ "@types/node": "^24.0.0",
46
+ "typescript": "^5.8.3"
47
+ },
48
+ "pi": {
49
+ "extensions": [
50
+ "./index.ts"
51
+ ]
52
+ }
53
+ }