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/names.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Humanized name generation for subagents
3
+ * Inspired by pi-messenger's generateMemorableName
4
+ */
5
+
6
+ const ADJECTIVES = [
7
+ "swift", "bright", "clever", "steady", "sharp", "keen", "bold", "calm",
8
+ "rapid", "silent", "fierce", "gentle", "brave", "wise", "nimble", "quick",
9
+ "sage", "witty", "brisk", "lively", "eager", "alert", "daring", "smooth",
10
+ "crisp", "fresh", "grand", "noble", "proud", "tough", "warm", "cool",
11
+ "neat", "tidy", "vivid", "zesty", "agile", "prime", "slick", "snappy"
12
+ ];
13
+
14
+ const NOUNS = [
15
+ "fox", "owl", "bear", "wolf", "hawk", "lynx", "puma", "stag",
16
+ "eagle", "raven", "crow", "swan", "crane", "falcon", "badger", "otter",
17
+ "seal", "orca", "shark", "tiger", "panda", "moose", "elk", "bison",
18
+ "cobra", "viper", "gecko", "ibex", "koala", "lemur", "llama", "macaw",
19
+ "newt", "quail", "robin", "snake", "tapir", "urial", "vole", "wren"
20
+ ];
21
+
22
+ /**
23
+ * Generate a memorable name like "swift-fox" or "bright-owl"
24
+ */
25
+ export function generateMemorableName(): string {
26
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
27
+ const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
28
+ return `${adj}-${noun}`;
29
+ }
30
+
31
+ /**
32
+ * Generate a name based on task ID (for consistency)
33
+ */
34
+ export function generateNameForTask(taskId: string): string {
35
+ // Use hash of taskId to pick consistent name
36
+ let hash = 0;
37
+ for (let i = 0; i < taskId.length; i++) {
38
+ hash = ((hash << 5) - hash) + taskId.charCodeAt(i);
39
+ hash = hash & hash; // Convert to 32bit integer
40
+ }
41
+ const adjIndex = Math.abs(hash) % ADJECTIVES.length;
42
+ const nounIndex = Math.abs(hash >> 8) % NOUNS.length;
43
+ return `${ADJECTIVES[adjIndex]}-${NOUNS[nounIndex]}`;
44
+ }
45
+
46
+ /**
47
+ * Create a display label for a subagent
48
+ * Combines humanized name with task ID for clarity
49
+ */
50
+ export function formatAgentLabel(taskId: string, useHumanized: boolean = true): string {
51
+ if (!useHumanized) return taskId;
52
+
53
+ // If taskId is already short and readable, use it directly
54
+ if (taskId.length <= 20 && !taskId.includes("/") && !taskId.includes("\\")) {
55
+ return taskId;
56
+ }
57
+
58
+ // Otherwise, generate a memorable name and append short task hint
59
+ const name = generateNameForTask(taskId);
60
+ const shortId = taskId.split(/[/\\]/).pop()?.slice(0, 15) || taskId.slice(0, 15);
61
+ return `${name} (${shortId})`;
62
+ }
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "pi-recurse",
3
+ "version": "0.1.0",
4
+ "description": "Recursive agent extension for Pi — spawn subagents programmatically with depth guardrails",
5
+ "type": "module",
6
+ "author": "Tom X Nguyen",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/monotykamary/pi-recurse.git"
11
+ },
12
+ "homepage": "https://github.com/monotykamary/pi-recurse#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/monotykamary/pi-recurse/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "pi-coding-agent",
20
+ "extension",
21
+ "recursion",
22
+ "subagent",
23
+ "parallel",
24
+ "divide-and-conquer"
25
+ ],
26
+ "files": [
27
+ "*.ts",
28
+ "*.js",
29
+ "*.d.ts",
30
+ "lib/**",
31
+ "tests/**",
32
+ "README.md",
33
+ "CHANGELOG.md"
34
+ ],
35
+ "scripts": {
36
+ "lint:dead": "knip --no-gitignore",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "test:coverage": "vitest run --coverage",
40
+ "typecheck": "tsc --noEmit",
41
+ "build": "tsc",
42
+ "release": "standard-version",
43
+ "release:dry-run": "standard-version --dry-run",
44
+ "postinstall": "simple-git-hooks 2>/dev/null || true"
45
+ },
46
+ "devDependencies": {
47
+ "@commitlint/cli": "21.0.1",
48
+ "@commitlint/config-conventional": "21.0.1",
49
+ "@types/node": "25.9.1",
50
+ "@vitest/coverage-v8": "4.1.7",
51
+ "knip": "6.14.1",
52
+ "lint-staged": "17.0.5",
53
+ "prettier": "3.8.3",
54
+ "simple-git-hooks": "2.13.1",
55
+ "standard-version": "9.5.0",
56
+ "typescript": "6.0.3",
57
+ "vitest": "4.1.7"
58
+ },
59
+ "peerDependencies": {
60
+ "@earendil-works/pi-coding-agent": ">=0.74.0",
61
+ "@earendil-works/pi-tui": ">=0.74.0",
62
+ "@sinclair/typebox": "0.34.49"
63
+ },
64
+ "pi": {
65
+ "extensions": [
66
+ "./index.ts"
67
+ ],
68
+ "skills": [
69
+ "./skills"
70
+ ]
71
+ },
72
+ "simple-git-hooks": {
73
+ "pre-commit": "npx lint-staged",
74
+ "pre-push": "npm run typecheck && npm run test",
75
+ "commit-msg": "npx commitlint --edit ${1}"
76
+ },
77
+ "lint-staged": {
78
+ "*.{ts,js,json,md}": [
79
+ "prettier --write"
80
+ ]
81
+ },
82
+ "overrides": {
83
+ "brace-expansion": "5.0.6",
84
+ "fast-xml-builder": "1.2.0",
85
+ "protobufjs": "8.4.0",
86
+ "ws": "8.20.1"
87
+ }
88
+ }
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import {
4
+ getCurrentDepth,
5
+ getMaxDepth,
6
+ getCallCount,
7
+ checkDepthGuard,
8
+ checkCallGuard,
9
+ checkTimeoutGuard,
10
+ checkBudgetGuard,
11
+ buildChildEnvironment,
12
+ generateTraceId,
13
+ getRecursiveSystemPrompt,
14
+ DEFAULTS,
15
+ } from '../lib.js';
16
+
17
+ describe('Guardrail utilities', () => {
18
+ beforeEach(() => {
19
+ // Clear environment
20
+ delete process.env.RLM_DEPTH;
21
+ delete process.env.RLM_MAX_DEPTH;
22
+ delete process.env.RLM_CALL_COUNT;
23
+ delete process.env.RLM_MAX_CALLS;
24
+ delete process.env.RLM_TIMEOUT;
25
+ delete process.env.RLM_START_TIME;
26
+ delete process.env.RLM_BUDGET;
27
+ });
28
+
29
+ describe('getCurrentDepth', () => {
30
+ it('returns 0 when RLM_DEPTH is not set', () => {
31
+ expect(getCurrentDepth()).toBe(0);
32
+ });
33
+
34
+ it('returns parsed integer from RLM_DEPTH', () => {
35
+ process.env.RLM_DEPTH = '3';
36
+ expect(getCurrentDepth()).toBe(3);
37
+ });
38
+
39
+ it('handles negative values', () => {
40
+ process.env.RLM_DEPTH = '-1';
41
+ expect(getCurrentDepth()).toBe(-1);
42
+ });
43
+ });
44
+
45
+ describe('getMaxDepth', () => {
46
+ it('returns DEFAULTS.MAX_DEPTH when not set', () => {
47
+ expect(getMaxDepth()).toBe(DEFAULTS.MAX_DEPTH);
48
+ });
49
+
50
+ it('returns parsed RLM_MAX_DEPTH', () => {
51
+ process.env.RLM_MAX_DEPTH = '5';
52
+ expect(getMaxDepth()).toBe(5);
53
+ });
54
+ });
55
+
56
+ describe('checkDepthGuard', () => {
57
+ it('allows when depth < max', () => {
58
+ process.env.RLM_DEPTH = '1';
59
+ process.env.RLM_MAX_DEPTH = '3';
60
+ const result = checkDepthGuard();
61
+ expect(result.allowed).toBe(true);
62
+ expect(result.reason).toBeUndefined();
63
+ });
64
+
65
+ it('blocks when depth >= max', () => {
66
+ process.env.RLM_DEPTH = '3';
67
+ process.env.RLM_MAX_DEPTH = '3';
68
+ const result = checkDepthGuard();
69
+ expect(result.allowed).toBe(false);
70
+ expect(result.reason).toContain('Max depth exceeded');
71
+ });
72
+ });
73
+
74
+ describe('checkCallGuard', () => {
75
+ it('allows when calls < limit', () => {
76
+ process.env.RLM_CALL_COUNT = '50';
77
+ process.env.RLM_MAX_CALLS = '100';
78
+ const result = checkCallGuard();
79
+ expect(result.allowed).toBe(true);
80
+ });
81
+
82
+ it('blocks when calls >= limit', () => {
83
+ process.env.RLM_CALL_COUNT = '100';
84
+ process.env.RLM_MAX_CALLS = '100';
85
+ const result = checkCallGuard();
86
+ expect(result.allowed).toBe(false);
87
+ expect(result.reason).toContain('Max calls exceeded');
88
+ });
89
+
90
+ it('uses env RLM_MAX_CALLS when not provided', () => {
91
+ process.env.RLM_CALL_COUNT = '99';
92
+ process.env.RLM_MAX_CALLS = '100';
93
+ expect(checkCallGuard().allowed).toBe(true);
94
+ });
95
+ });
96
+
97
+ describe('checkTimeoutGuard', () => {
98
+ it('allows when elapsed < timeout', () => {
99
+ process.env.RLM_START_TIME = String(Date.now() - 5000); // 5 seconds ago
100
+ process.env.RLM_TIMEOUT = '600'; // 10 minutes
101
+ const result = checkTimeoutGuard();
102
+ expect(result.allowed).toBe(true);
103
+ });
104
+
105
+ it('blocks when elapsed > timeout', () => {
106
+ process.env.RLM_START_TIME = String(Date.now() - 700000); // 700 seconds ago
107
+ process.env.RLM_TIMEOUT = '600'; // 10 minutes
108
+ const result = checkTimeoutGuard();
109
+ expect(result.allowed).toBe(false);
110
+ expect(result.reason).toContain('Timeout exceeded');
111
+ });
112
+ });
113
+
114
+ describe('checkBudgetGuard', () => {
115
+ it('allows with unlimited budget when RLM_BUDGET not set', () => {
116
+ const result = checkBudgetGuard();
117
+ expect(result.allowed).toBe(true);
118
+ expect(result.remaining).toBe(Infinity);
119
+ });
120
+
121
+ it('calculates remaining correctly', () => {
122
+ process.env.RLM_BUDGET = '1.00';
123
+ // Note: loadAccumulatedCost will return 0 since no file set
124
+ const result = checkBudgetGuard();
125
+ expect(result.allowed).toBe(true);
126
+ expect(result.remaining).toBeCloseTo(1.0);
127
+ });
128
+
129
+ it('blocks when budget exhausted', () => {
130
+ process.env.RLM_BUDGET = '0.01';
131
+ // Mock spent amount by setting cost file via env
132
+ const costFile = '/tmp/test-cost-' + Date.now();
133
+ process.env.RLM_COST_FILE = costFile;
134
+ fs.writeFileSync(costFile, '0.02', 'utf-8');
135
+
136
+ try {
137
+ const result = checkBudgetGuard();
138
+ expect(result.allowed).toBe(false);
139
+ } finally {
140
+ // Cleanup
141
+ try {
142
+ fs.unlinkSync(costFile);
143
+ } catch {
144
+ // Ignore cleanup errors
145
+ }
146
+ }
147
+ });
148
+ });
149
+
150
+ describe('buildChildEnvironment', () => {
151
+ it('increments depth', () => {
152
+ process.env.RLM_DEPTH = '2';
153
+ const env = buildChildEnvironment();
154
+ expect(env.RLM_DEPTH).toBe('3');
155
+ });
156
+
157
+ it('increments call count', () => {
158
+ process.env.RLM_CALL_COUNT = '10';
159
+ const env = buildChildEnvironment();
160
+ expect(env.RLM_CALL_COUNT).toBe('11');
161
+ });
162
+
163
+ it('preserves trace ID', () => {
164
+ process.env.RLM_TRACE_ID = 'abc123';
165
+ const env = buildChildEnvironment();
166
+ expect(env.RLM_TRACE_ID).toBe('abc123');
167
+ });
168
+
169
+ it('generates new trace ID if not set', () => {
170
+ delete process.env.RLM_TRACE_ID;
171
+ const env = buildChildEnvironment();
172
+ expect(env.RLM_TRACE_ID).toBeDefined();
173
+ expect(env.RLM_TRACE_ID!.length).toBeGreaterThan(0);
174
+ });
175
+ });
176
+
177
+ describe('generateTraceId', () => {
178
+ it('returns a non-empty string', () => {
179
+ const id = generateTraceId();
180
+ expect(typeof id).toBe('string');
181
+ expect(id.length).toBeGreaterThan(0);
182
+ });
183
+
184
+ it('returns different values on subsequent calls', () => {
185
+ const id1 = generateTraceId();
186
+ const id2 = generateTraceId();
187
+ expect(id1).not.toBe(id2);
188
+ });
189
+ });
190
+
191
+ describe('getRecursiveSystemPrompt', () => {
192
+ it('includes depth information', () => {
193
+ process.env.RLM_MAX_DEPTH = '5';
194
+ const basePrompt = 'You are an AI assistant.';
195
+ const result = getRecursiveSystemPrompt(basePrompt, 2);
196
+
197
+ expect(result).toContain('depth 2');
198
+ expect(result).toContain('RLM_MAX_DEPTH=5');
199
+ expect(result).toContain(basePrompt);
200
+ });
201
+
202
+ it('includes sub-agent guidance for depth > 0', () => {
203
+ const result = getRecursiveSystemPrompt('Base.', 1);
204
+ expect(result).toContain('sub-agents');
205
+ expect(result).toContain('Prefer **direct answers**');
206
+ });
207
+
208
+ it('includes root-agent guidance for depth 0', () => {
209
+ const result = getRecursiveSystemPrompt('Base.', 0);
210
+ expect(result).toContain('root agents');
211
+ expect(result).toContain('Decompose large tasks');
212
+ });
213
+ });
214
+ });
package/types.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Type definitions for pi-recurse extension
3
+ */
4
+
5
+ import type { Static } from '@sinclair/typebox';
6
+
7
+ export interface GuardrailConfig {
8
+ /** Maximum recursion depth (0 = root) */
9
+ maxDepth: number;
10
+ /** Maximum total recurse invocations across entire tree */
11
+ maxCalls?: number;
12
+ /** Maximum wall-clock seconds for entire recursive tree */
13
+ timeout?: number;
14
+ /** Maximum dollar spend for entire tree (e.g., 0.50) */
15
+ budget?: number;
16
+ /** Disable recurse tool when depth exceeds this threshold */
17
+ disableToolAtDepth?: number;
18
+ }
19
+
20
+ export interface RecurseSingleParams {
21
+ /** The task/prompt to send to the subagent */
22
+ prompt: string;
23
+ /** Optional context data to pipe to subagent */
24
+ context?: string;
25
+ /** Use session fork to carry conversation history */
26
+ fork?: boolean;
27
+ }
28
+
29
+ export interface RecurseParallelParams {
30
+ /** Multiple tasks to run in parallel */
31
+ tasks: Array<{
32
+ /** Unique id for this task */
33
+ id: string;
34
+ /** Prompt for this subagent */
35
+ prompt: string;
36
+ /** Optional context for this specific task */
37
+ context?: string;
38
+ }>;
39
+ /** Maximum concurrent subagents (default: 4) */
40
+ concurrency?: number;
41
+ /** Timeout per task in seconds */
42
+ timeoutPerTask?: number;
43
+ }
44
+
45
+ export interface RecurseChainParams {
46
+ /** Sequential tasks where each receives output from previous */
47
+ chain: Array<{
48
+ id: string;
49
+ prompt: string;
50
+ /** Template placeholder {previous} will be replaced with prior output */
51
+ }>;
52
+ }
53
+
54
+ export type RecurseParams =
55
+ | ({ mode: 'single' } & RecurseSingleParams)
56
+ | ({ mode: 'parallel' } & RecurseParallelParams)
57
+ | ({ mode: 'chain' } & RecurseChainParams);
58
+
59
+ export interface SubagentProgress {
60
+ /** Current status */
61
+ status: 'running' | 'completed' | 'failed';
62
+ /** Current tool being executed (if any) */
63
+ currentTool?: string;
64
+ /** Arguments for current tool */
65
+ currentToolArgs?: string;
66
+ /** Recent output lines (last 50) */
67
+ recentOutput: string[];
68
+ /** Recent tools executed */
69
+ recentTools: Array<{ tool: string; args: string; endMs?: number }>;
70
+ /** Tool call count */
71
+ toolCount: number;
72
+ /** Token count (input + output) */
73
+ tokens: number;
74
+ /** Duration in milliseconds */
75
+ durationMs: number;
76
+ }
77
+
78
+ export interface SubagentUsage {
79
+ input: number;
80
+ output: number;
81
+ cacheRead?: number;
82
+ cacheWrite?: number;
83
+ cost?: number;
84
+ turns?: number;
85
+ }
86
+
87
+ export interface SubagentResult {
88
+ /** Task id (for parallel/chain modes) */
89
+ id: string;
90
+ /** Exit status */
91
+ success: boolean;
92
+ /** Subagent response text */
93
+ output: string;
94
+ /** Any error message */
95
+ error?: string;
96
+ /** Why the subagent stopped (for debugging) - aligns with pi terminology */
97
+ stopReason?: 'completed' | 'output-stabilization' | 'timeout' | 'error' | 'stopped';
98
+ /** Usage statistics (if available in JSON mode) */
99
+ usage?: SubagentUsage;
100
+ /** Time taken in milliseconds */
101
+ durationMs: number;
102
+ /** Progress information (available during streaming) */
103
+ progress?: SubagentProgress;
104
+ /** Nested recurse results if this subagent called recurse */
105
+ children?: RecurseResult;
106
+ /** Model used for this subagent */
107
+ model?: string;
108
+ }
109
+
110
+ /** Tree node for recursive agent visualization */
111
+ export interface RecurseTreeNode {
112
+ id: string;
113
+ mode: 'single' | 'parallel' | 'chain';
114
+ depth: number;
115
+ status: 'running' | 'completed' | 'failed';
116
+ stats: {
117
+ total: number;
118
+ succeeded: number;
119
+ failed: number;
120
+ totalDurationMs: number;
121
+ totalCost?: number;
122
+ };
123
+ children: RecurseTreeNode[];
124
+ parentId?: string;
125
+ }
126
+
127
+ export interface RecurseResult {
128
+ /** Results from all subagents */
129
+ results: SubagentResult[];
130
+ /** Aggregated statistics */
131
+ stats: {
132
+ total: number;
133
+ succeeded: number;
134
+ failed: number;
135
+ totalDurationMs: number;
136
+ totalCost?: number;
137
+ };
138
+ /** Current recursion depth */
139
+ depth: number;
140
+ /** Mode used for this recurse call */
141
+ mode?: 'single' | 'parallel' | 'chain';
142
+ /** Parent recurse result (for tree traversal) */
143
+ parent?: RecurseResult;
144
+ /** Unique ID for this recurse invocation */
145
+ invocationId?: string;
146
+ }
147
+
148
+ export interface RecurseState {
149
+ /** Current depth (0 = root agent) */
150
+ depth: number;
151
+ /** Call count tracker for this session */
152
+ callCount: number;
153
+ /** Trace ID linking all recursive sessions */
154
+ traceId: string;
155
+ /** Epoch timestamp when root call started */
156
+ startTime: number;
157
+ /** Accumulated cost tracking */
158
+ accumulatedCost: number;
159
+ }
160
+
161
+ export interface RecurseEnvironment {
162
+ RLM_DEPTH: string;
163
+ RLM_MAX_DEPTH: string;
164
+ RLM_CALL_COUNT: string;
165
+ RLM_MAX_CALLS?: string;
166
+ RLM_TIMEOUT?: string;
167
+ RLM_START_TIME?: string;
168
+ RLM_BUDGET?: string;
169
+ RLM_COST_FILE?: string;
170
+ RLM_TRACE_ID?: string;
171
+ RLM_CHILD_MODEL?: string;
172
+ RLM_CHILD_PROVIDER?: string;
173
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['tests/**/*.test.ts'],
8
+ },
9
+ });