pi-recurse 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/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-03-24
9
+
10
+ ### Added
11
+ - Initial implementation of `pi-recurse` extension
12
+ - Three execution modes: `single`, `parallel`, and `chain`
13
+ - Programmatic parallel spawning (no LLM involvement in loop)
14
+ - Depth-based guardrails (RLM_MAX_DEPTH, RLM_DEPTH)
15
+ - Call count tracking (RLM_MAX_CALLS)
16
+ - Timeout enforcement (RLM_TIMEOUT)
17
+ - Budget tracking (RLM_BUDGET)
18
+ - Tool disabling at configurable depth threshold
19
+ - System prompt injection via `before_agent_start`
20
+ - Status bar indicator showing current depth
21
+ - Custom tool rendering for TUI
22
+ - `/recurse-status` command for debugging
23
+ - Comprehensive test suite
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tom X Nguyen
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,226 @@
1
+ <div align="center">
2
+
3
+ # 🔁 pi-recurse
4
+
5
+ **Programmatic recursive subagent spawning for [pi](https://github.com/earendil-works/pi-coding-agent)**
6
+
7
+ _LLM makes ONE tool call, extension code handles parallel spawning and result aggregation._
8
+
9
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ This project is the spiritual successor to **[ypi](https://github.com/rawwerks/ypi)** by [@rawwerks](https://github.com/rawwerks), originally created as a bash wrapper around Pi that enabled recursive LLM calls with guardrails.
17
+
18
+ **Why the conversion?** The original `ypi` proved that recursive subagents could work effectively, but required the LLM to generate bash scripts for parallelization. By converting to a proper Pi extension, `pi-recurse` achieves:
19
+
20
+ - **True programmatic looping** — parallel execution happens in TypeScript code, not bash generated by the LLM
21
+ - **Better integration** — hot-reload with `/reload`, automatic tool discovery, native Pi packaging
22
+ - **Structured results** — full TypeScript types instead of bash string manipulation
23
+ - **Single entry point** — just `pi`, no wrapper scripts needed
24
+
25
+ Thank you to the original `ypi` for pioneering the concept of depth-limited recursive LLM calls in a coding agent context.
26
+
27
+ ---
28
+
29
+ ## Installation
30
+
31
+ ### Via Git URL (Recommended for now)
32
+
33
+ ```bash
34
+ pi install https://github.com/monotykamary/pi-recurse
35
+ ```
36
+
37
+ ### Via npm (when published)
38
+
39
+ ```bash
40
+ pi install pi-recurse
41
+ ```
42
+
43
+ ### Local Development
44
+
45
+ ```bash
46
+ git clone https://github.com/monotykamary/pi-recurse.git
47
+ cd pi-recurse
48
+ pi install .
49
+ ```
50
+
51
+ The extension will be auto-discovered by Pi on next start, or use `/reload` to load it immediately.
52
+
53
+ ---
54
+
55
+ ## Usage
56
+
57
+ The extension adds a `recurse` tool to Pi, enabling three execution modes:
58
+
59
+ ### Single Mode — One-off delegation
60
+
61
+ ```typescript
62
+ recurse({
63
+ mode: 'single',
64
+ prompt: 'Analyze the error handling in src/auth.ts',
65
+ context: fileContent, // optional context to pipe
66
+ });
67
+ ```
68
+
69
+ ### Parallel Mode — Batch processing
70
+
71
+ ```typescript
72
+ recurse({
73
+ mode: 'parallel',
74
+ tasks: [
75
+ { id: 'auth', prompt: 'Review auth module' },
76
+ { id: 'db', prompt: 'Review database layer' },
77
+ { id: 'api', prompt: 'Review API routes' },
78
+ ],
79
+ concurrency: 3,
80
+ timeoutPerTask: 120,
81
+ });
82
+ ```
83
+
84
+ **Key advantage:** All 3 subagents spawn from a **single LLM tool call**. The extension handles `Promise.all()` style concurrency internally — no autoregressive steps between spawns.
85
+
86
+ ### Chain Mode — Sequential dependencies
87
+
88
+ ```typescript
89
+ recurse({
90
+ mode: 'chain',
91
+ chain: [
92
+ { id: 'read', prompt: 'Read README.md and summarize' },
93
+ { id: 'analyze', prompt: 'Given this summary: {previous} — identify risks' },
94
+ { id: 'plan', prompt: 'Given these risks: {previous} — suggest mitigations' },
95
+ ],
96
+ });
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Guardrails
102
+
103
+ Environment variables control recursion limits across the entire call tree:
104
+
105
+ | Variable | Default | Description |
106
+ | --------------------- | ------- | ------------------------------------ |
107
+ | `RLM_MAX_DEPTH` | 3 | Maximum recursion depth (0 = root) |
108
+ | `RLM_MAX_CALLS` | 100 | Maximum total recurse invocations |
109
+ | `RLM_TIMEOUT` | 600 | Wall-clock seconds for entire tree |
110
+ | `RLM_BUDGET` | — | Max dollar spend (e.g., `0.50`) |
111
+ | `RLM_CHILD_MODEL` | — | Model override for depth > 0 |
112
+ | `RLM_DISABLE_TOOL_AT` | 3 | Disable recurse tool at this depth |
113
+ | `RLM_TRACE_ID` | auto | Links all sessions in recursive tree |
114
+
115
+ At deep depths (≥ `RLM_DISABLE_TOOL_AT`), the recurse tool automatically disables itself and guides agents to work directly instead of delegating.
116
+
117
+ ---
118
+
119
+ ## Architecture: From Bash to Extension
120
+
121
+ ### Original ypi (Bash Wrapper)
122
+
123
+ ```
124
+ ypi "analyze this codebase"
125
+
126
+ Launcher sets env, execs pi with custom system prompt
127
+
128
+ LLM generates bash loop for parallel tasks:
129
+ for f in $(find src -name "*.ts"); do
130
+ rlm_query --async "Review $f"
131
+ done
132
+
133
+ Wait on sentinel files, aggregate in bash
134
+ ```
135
+
136
+ **Limitation:** The LLM had to generate the loop structure, and parallelization required autoregressive generation of bash code.
137
+
138
+ ### pi-recurse (Pi Extension)
139
+
140
+ ```
141
+ pi (with pi-recurse extension loaded)
142
+
143
+ LLM calls once: recurse({mode: "parallel", tasks: [...]})
144
+
145
+ Extension execute() runs in Node.js:
146
+ ├── Check guardrails (depth, budget, timeout)
147
+ ├── runParallel(tasks, spawnFn, concurrency)
148
+ │ └── Promise.all(concurrent batches)
149
+ ├── Aggregate structured results
150
+ └── Return {results, stats, depth}
151
+
152
+ LLM receives aggregated data, continues reasoning
153
+ ```
154
+
155
+ **Advantage:** Parallel execution happens in **code**, not in LLM-generated bash. One tool call → many subagents → aggregated result.
156
+
157
+ ---
158
+
159
+ ## Comparison: ypi vs pi-recurse
160
+
161
+ | Feature | ypi (Wrapper) | pi-recurse (Extension) |
162
+ | --------------------- | ------------------------ | --------------------------------- |
163
+ | Entry point | `ypi "prompt"` | `pi` (extension auto-loaded) |
164
+ | Parallel spawning | LLM writes bash loop | **Code executes `Promise.all()`** |
165
+ | Result aggregation | Bash string manipulation | TypeScript structured objects |
166
+ | Hot reload | Restart ypi process | `/reload` in pi |
167
+ | Distribution | npm global bin | `pi install` / git URL |
168
+ | Extension integration | Manual env/PATH setup | Native auto-discovery |
169
+ | Tool disabling | Manual depth checks | Automatic at configured depth |
170
+ | Status visibility | Footer via ypi.ts | Native Pi status bar |
171
+
172
+ ---
173
+
174
+ ## Development
175
+
176
+ ```bash
177
+ # Clone and install dependencies
178
+ git clone https://github.com/monotykamary/pi-recurse.git
179
+ cd pi-recurse
180
+ npm install
181
+
182
+ # Run tests
183
+ npm test
184
+
185
+ # Type check
186
+ npm run typecheck
187
+
188
+ # Build (optional - jiti handles TypeScript at runtime)
189
+ npm run build
190
+ ```
191
+
192
+ ### Testing the Extension
193
+
194
+ ```bash
195
+ # Install locally
196
+ pi install .
197
+
198
+ # Or test without installing
199
+ pi -e ./index.ts
200
+
201
+ # In pi, test recursion
202
+ /recurse-status
203
+ recurse({mode: "single", prompt: "What is 2+2?"})
204
+ ```
205
+
206
+ ---
207
+
208
+ ## Design Principles
209
+
210
+ 1. **Single tool call, parallel execution** — The LLM describes intent; code handles concurrency
211
+ 2. **Depth-aware degradation** — At deep levels, disable recursion to prevent runaway costs
212
+ 3. **Structured over stringly** — Full TypeScript types for inputs and outputs
213
+ 4. **Guardrails in code** — Budget, timeout, depth checked programmatically
214
+ 5. **Pi-native integration** — Use Pi's extension API, not wrapper scripts
215
+
216
+ ---
217
+
218
+ ## Related Projects
219
+
220
+ - **[ypi](https://github.com/rawwerks/ypi)** — The original bash wrapper that inspired this extension
221
+ - **[pi-messenger](https://github.com/monotykamary/pi-messenger-swarm)** — Multi-agent coordination for Pi (another extension)
222
+ - **[pi](https://github.com/badlogic/pi-mono)** — The Pi coding agent itself
223
+
224
+ ## License
225
+
226
+ MIT — See [LICENSE](LICENSE) for details.
package/formatters.ts ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Formatting helpers for subagent progress display
3
+ * Following pi-subagents patterns
4
+ */
5
+
6
+ /**
7
+ * Format token count with k/M suffixes
8
+ */
9
+ export function formatTokens(tokens: number): string {
10
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
11
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
12
+ return `${tokens}`;
13
+ }
14
+
15
+ /**
16
+ * Format duration in ms to human readable
17
+ */
18
+ export function formatDuration(ms: number): string {
19
+ if (ms < 1000) return `${ms}ms`;
20
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
21
+ const mins = Math.floor(ms / 60_000);
22
+ const secs = ((ms % 60_000) / 1000).toFixed(0);
23
+ return `${mins}m${secs}s`;
24
+ }
25
+
26
+ /**
27
+ * Format usage info line
28
+ */
29
+ export function formatUsage(usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: number }): string {
30
+ if (!usage) return "";
31
+ const parts: string[] = [];
32
+ if (usage.input || usage.output) {
33
+ parts.push(`${formatTokens(usage.input || 0)}↑ ${formatTokens(usage.output || 0)}↓`);
34
+ }
35
+ if (usage.cacheRead) parts.push(`${formatTokens(usage.cacheRead)}↺`);
36
+ if (usage.cacheWrite) parts.push(`${formatTokens(usage.cacheWrite)}↻`);
37
+ if (usage.cost !== undefined && usage.cost > 0) {
38
+ parts.push(`$${usage.cost.toFixed(4)}`);
39
+ }
40
+ return parts.join(" · ") || "";
41
+ }
42
+
43
+ import { formatAgentLabel } from "./names.js";
44
+ import type { RecurseResult, RecurseTreeNode, SubagentResult } from "./types.js";
45
+
46
+ /**
47
+ * Truncate text to max length with ellipsis
48
+ * Uses Intl.Segmenter for proper Unicode handling
49
+ */
50
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
51
+
52
+ export function truncLine(text: string, maxWidth: number): string {
53
+ if (text.length <= maxWidth) return text;
54
+
55
+ let result = "";
56
+ let count = 0;
57
+ for (const seg of segmenter.segment(text)) {
58
+ if (count >= maxWidth - 1) {
59
+ return result + "…";
60
+ }
61
+ result += seg.segment;
62
+ count++;
63
+ }
64
+ return result;
65
+ }
66
+
67
+ /**
68
+ * Get status icon - uses distinct Unicode symbols instead of confusing "..."
69
+ */
70
+ export function getStatusIcon(status: "running" | "completed" | "failed" | undefined): string {
71
+ switch (status) {
72
+ case "running": return "▶"; // Play icon - clearly indicates active
73
+ case "completed": return "✓"; // Checkmark
74
+ case "failed": return "✗"; // X mark
75
+ default: return "○"; // Circle for pending/unknown
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Build a tree structure from a RecurseResult for visualization
81
+ */
82
+ export function buildRecurseTree(result: RecurseResult, mode: "single" | "parallel" | "chain", parentId?: string): RecurseTreeNode {
83
+ const invocationId = result.invocationId || Math.random().toString(36).substring(2, 8);
84
+
85
+ const node: RecurseTreeNode = {
86
+ id: invocationId,
87
+ mode,
88
+ depth: result.depth,
89
+ status: result.stats.failed === 0 ? "completed" : (result.stats.failed < result.stats.total ? "running" : "failed"),
90
+ stats: result.stats,
91
+ children: [],
92
+ parentId,
93
+ };
94
+
95
+ // Build child nodes from subagent results that have their own recurse calls
96
+ for (const subagent of result.results) {
97
+ if (subagent.children) {
98
+ const childNode = buildRecurseTree(subagent.children, subagent.children.mode || "single", invocationId);
99
+ node.children.push(childNode);
100
+ }
101
+ }
102
+
103
+ return node;
104
+ }
105
+
106
+ /**
107
+ * Render a recurse tree with ASCII/Unicode tree drawing characters
108
+ */
109
+ export function renderRecurseTree(
110
+ node: RecurseTreeNode,
111
+ maxWidth: number = 100,
112
+ prefix: string = "",
113
+ isLast: boolean = true,
114
+ isRoot: boolean = true
115
+ ): string[] {
116
+ const lines: string[] = [];
117
+
118
+ // Build the status line
119
+ const icon = getStatusIcon(node.status);
120
+ const modeLabel = node.mode;
121
+ const stats = `${node.stats.succeeded}/${node.stats.total}`;
122
+ const cost = node.stats.totalCost ? ` · $${node.stats.totalCost.toFixed(4)}` : "";
123
+ const duration = formatDuration(node.stats.totalDurationMs);
124
+
125
+ // Tree drawing characters
126
+ const branch = isRoot ? "" : (isLast ? "└─ " : "├─ ");
127
+ const indent = isRoot ? "" : prefix;
128
+
129
+ const line = `${indent}${branch}${icon} ${modeLabel} [${stats}]${cost} · ${duration} (depth ${node.depth})`;
130
+ lines.push(truncLine(line, maxWidth));
131
+
132
+ // Render children
133
+ if (node.children.length > 0) {
134
+ const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ ");
135
+
136
+ for (let i = 0; i < node.children.length; i++) {
137
+ const child = node.children[i];
138
+ const isLastChild = i === node.children.length - 1;
139
+ const childLines = renderRecurseTree(child, maxWidth, childPrefix, isLastChild, false);
140
+ lines.push(...childLines);
141
+ }
142
+ }
143
+
144
+ return lines;
145
+ }
146
+
147
+ /**
148
+ * Format a flat list of recurse results into a forest (multiple trees)
149
+ */
150
+ export function renderRecurseForest(
151
+ results: RecurseResult[],
152
+ modes: ("single" | "parallel" | "chain")[],
153
+ maxWidth: number = 100
154
+ ): string {
155
+ const allLines: string[] = [];
156
+
157
+ for (let i = 0; i < results.length; i++) {
158
+ const tree = buildRecurseTree(results[i], modes[i]);
159
+ const lines = renderRecurseTree(tree, maxWidth);
160
+ allLines.push(...lines);
161
+
162
+ // Add separator between trees
163
+ if (i < results.length - 1) {
164
+ allLines.push("");
165
+ }
166
+ }
167
+
168
+ return allLines.join("\n");
169
+ }
170
+
171
+ /**
172
+ * Count total nodes in a recurse tree (for stats)
173
+ */
174
+ export function countTreeNodes(node: RecurseTreeNode): number {
175
+ let count = 1; // This node
176
+ for (const child of node.children) {
177
+ count += countTreeNodes(child);
178
+ }
179
+ return count;
180
+ }
181
+
182
+ /**
183
+ * Find the deepest depth in a recurse tree
184
+ */
185
+ export function getTreeMaxDepth(node: RecurseTreeNode): number {
186
+ if (node.children.length === 0) {
187
+ return node.depth;
188
+ }
189
+ let maxChildDepth = node.depth;
190
+ for (const child of node.children) {
191
+ maxChildDepth = Math.max(maxChildDepth, getTreeMaxDepth(child));
192
+ }
193
+ return maxChildDepth;
194
+ }
195
+
196
+ /**
197
+ * Render subagent progress like pi-subagents
198
+ */
199
+ export interface ProgressData {
200
+ status: "running" | "completed" | "failed";
201
+ currentTool?: string;
202
+ currentToolArgs?: string;
203
+ recentTools?: Array<{ tool: string; args: string; endMs?: number }>;
204
+ recentOutput?: string[];
205
+ toolCount: number;
206
+ tokens: number;
207
+ durationMs: number;
208
+ }
209
+
210
+ /**
211
+ * Format progress line: "... | 5 tools, 12.3k tok, 2.4s"
212
+ */
213
+ export function formatProgressLine(data: ProgressData): string {
214
+ const parts: string[] = [];
215
+ if (data.toolCount > 0) parts.push(`${data.toolCount} tools`);
216
+ if (data.tokens > 0) parts.push(`${formatTokens(data.tokens)} tok`);
217
+ if (data.durationMs > 0) parts.push(`${formatDuration(data.durationMs)}`);
218
+ return parts.join(", ");
219
+ }
220
+
221
+ /**
222
+ * Render full subagent status with multi-line output
223
+ * Mimics pi-subagents render.ts output
224
+ */
225
+ export function renderSubagentStatus(
226
+ id: string,
227
+ data: { output: string; progress?: ProgressData },
228
+ maxWidth: number = 100,
229
+ useHumanizedName: boolean = true
230
+ ): string[] {
231
+ const lines: string[] = [];
232
+ const p = data.progress;
233
+
234
+ // Use humanized name for display
235
+ const displayName = formatAgentLabel(id, useHumanizedName);
236
+
237
+ // Status line: "▶ swift-fox (package.json) | 5 tools, 12.3k tok, 2.4s"
238
+ const icon = getStatusIcon(p?.status);
239
+ const metrics = p ? formatProgressLine(p) : "";
240
+ const header = metrics ? `${icon} ${displayName} | ${metrics}` : `${icon} ${displayName}`;
241
+ lines.push(truncLine(header, maxWidth));
242
+
243
+ if (p?.status === "running") {
244
+ // Current tool line: "> read: path: "file.ts"..."
245
+ if (p.currentTool) {
246
+ const args = p.currentToolArgs || "";
247
+ const toolLine = args ? `> ${p.currentTool}: ${truncLine(args, maxWidth - 20)}` : `> ${p.currentTool}`;
248
+ lines.push(toolLine);
249
+ }
250
+
251
+ // Recent tools (last 3)
252
+ if (p.recentTools?.length) {
253
+ for (const t of p.recentTools.slice(-3)) {
254
+ const argsPreview = truncLine(t.args, maxWidth - 30);
255
+ lines.push(` ${t.tool}: ${argsPreview}`);
256
+ }
257
+ }
258
+
259
+ // Recent output (last 5 lines)
260
+ if (p.recentOutput?.length) {
261
+ for (const line of p.recentOutput.slice(-5)) {
262
+ lines.push(` ${truncLine(line, maxWidth - 4)}`);
263
+ }
264
+ }
265
+ }
266
+
267
+ // If no progress detail but have output, show output preview
268
+ if ((!p || p.status !== "running") && data.output) {
269
+ const outputLines = data.output.split("\n").filter(l => l.trim()).slice(-3);
270
+ for (const line of outputLines) {
271
+ lines.push(` ${truncLine(line, maxWidth - 4)}`);
272
+ }
273
+ }
274
+
275
+ return lines;
276
+ }
277
+
278
+ /**
279
+ * Render parallel task status list
280
+ */
281
+ export function renderParallelStatus(
282
+ taskProgress: Map<string, { output: string; progress?: ProgressData }>,
283
+ maxWidth: number = 100,
284
+ useHumanizedNames: boolean = true
285
+ ): string {
286
+ const allLines: string[] = [];
287
+
288
+ for (const [id, data] of taskProgress) {
289
+ const taskLines = renderSubagentStatus(id, data, maxWidth, useHumanizedNames);
290
+ allLines.push(...taskLines);
291
+ allLines.push(""); // Blank line between tasks
292
+ }
293
+
294
+ return allLines.join("\n").trim();
295
+ }