pi-lazy-extensions 0.1.1
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/LICENSE +21 -0
- package/README.md +214 -0
- package/config.ts +152 -0
- package/index.ts +249 -0
- package/package.json +59 -0
- package/proxy-modes.ts +319 -0
- package/registry.ts +466 -0
- package/types.ts +176 -0
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-lazy-extensions",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Lazy-load pi extensions on demand via a ToolSearch-style proxy tool",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Nico Bailon",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi",
|
|
11
|
+
"lazy",
|
|
12
|
+
"extension",
|
|
13
|
+
"coding-agent",
|
|
14
|
+
"tool-search"
|
|
15
|
+
],
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.ts",
|
|
23
|
+
"config.ts",
|
|
24
|
+
"registry.ts",
|
|
25
|
+
"proxy-modes.ts",
|
|
26
|
+
"types.ts",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"jiti": "2.7.0",
|
|
32
|
+
"typebox": "1.1.38"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"test:coverage": "vitest run --coverage",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"lint:dead": "knip --no-gitignore"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@commitlint/cli": "21.0.1",
|
|
46
|
+
"@commitlint/config-conventional": "21.0.1",
|
|
47
|
+
"@types/node": "25.9.1",
|
|
48
|
+
"@vitest/coverage-v8": "4.1.7",
|
|
49
|
+
"knip": "6.14.1",
|
|
50
|
+
"typescript": "6.0.3",
|
|
51
|
+
"vitest": "4.1.7"
|
|
52
|
+
},
|
|
53
|
+
"overrides": {
|
|
54
|
+
"brace-expansion": "5.0.6",
|
|
55
|
+
"fast-xml-builder": "1.2.0",
|
|
56
|
+
"protobufjs": "8.4.0",
|
|
57
|
+
"ws": "8.20.1"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/proxy-modes.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy tool execution modes.
|
|
3
|
+
*
|
|
4
|
+
* These are the actions the LLM can take through the `ext` proxy tool,
|
|
5
|
+
* analogous to pi-mcp-adapter's proxy-modes.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AgentToolResult, ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { LazyExtensionsState } from "./types.js";
|
|
10
|
+
import { activateExtension, touchExtension, getFailureAgeSeconds } from "./registry.js";
|
|
11
|
+
|
|
12
|
+
type ProxyToolResult = AgentToolResult<Record<string, unknown>>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Show status of all registered extensions.
|
|
16
|
+
*/
|
|
17
|
+
export function executeStatus(state: LazyExtensionsState): ProxyToolResult {
|
|
18
|
+
const entries: Array<{ name: string; status: string; lifecycle: string; toolCount: number; shortcutCount: number; flagCount: number; rendererCount: number; error?: string }> = [];
|
|
19
|
+
|
|
20
|
+
for (const [name, extState] of state.extensions) {
|
|
21
|
+
const lifecycle = extState.config.lifecycle ?? "lazy";
|
|
22
|
+
const toolCount = extState.registeredTools.length;
|
|
23
|
+
const shortcutCount = extState.registeredShortcuts.length;
|
|
24
|
+
const flagCount = extState.registeredFlags.length;
|
|
25
|
+
const rendererCount = extState.registeredRenderers.length;
|
|
26
|
+
let status = "inactive";
|
|
27
|
+
if (extState.loaded) {
|
|
28
|
+
status = "active";
|
|
29
|
+
} else if (extState.error) {
|
|
30
|
+
status = "failed";
|
|
31
|
+
} else if (getFailureAgeSeconds(state, name) !== null) {
|
|
32
|
+
status = "failed";
|
|
33
|
+
}
|
|
34
|
+
entries.push({ name, status, lifecycle, toolCount, shortcutCount, flagCount, rendererCount, error: extState.error });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const activeCount = entries.filter(e => e.status === "active").length;
|
|
38
|
+
const totalTools = entries.reduce((sum, e) => sum + e.toolCount, 0);
|
|
39
|
+
|
|
40
|
+
let text = `Lazy Extensions: ${activeCount}/${entries.length} active, ${totalTools} tools\n\n`;
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (entry.status === "active") {
|
|
43
|
+
const extras: string[] = [];
|
|
44
|
+
if (entry.shortcutCount > 0) extras.push(`${entry.shortcutCount} shortcuts`);
|
|
45
|
+
if (entry.flagCount > 0) extras.push(`${entry.flagCount} flags`);
|
|
46
|
+
if (entry.rendererCount > 0) extras.push(`${entry.rendererCount} renderers`);
|
|
47
|
+
const extraStr = extras.length > 0 ? `, ${extras.join(", ")}` : "";
|
|
48
|
+
text += `✓ ${entry.name} (${entry.toolCount} tools${extraStr}, ${entry.lifecycle})\n`;
|
|
49
|
+
} else if (entry.status === "failed") {
|
|
50
|
+
text += `✗ ${entry.name} (failed: ${entry.error ?? "unknown"})\n`;
|
|
51
|
+
} else {
|
|
52
|
+
const desc = state.extensions.get(entry.name)?.config.description ?? "";
|
|
53
|
+
text += `○ ${entry.name} (${entry.lifecycle})${desc ? ` - ${desc}` : ""}\n`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
text += `\next({ search: "..." }) to search, ext({ activate: "name" }) to load`;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: "text", text: text.trim() }],
|
|
61
|
+
details: { mode: "status", entries, totalTools, activeCount },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Search extensions by name, description, tags, or tool names.
|
|
67
|
+
*/
|
|
68
|
+
export function executeSearch(
|
|
69
|
+
state: LazyExtensionsState,
|
|
70
|
+
query: string,
|
|
71
|
+
regex?: boolean,
|
|
72
|
+
): ProxyToolResult {
|
|
73
|
+
let pattern: RegExp;
|
|
74
|
+
try {
|
|
75
|
+
if (regex) {
|
|
76
|
+
pattern = new RegExp(query, "i");
|
|
77
|
+
} else {
|
|
78
|
+
const terms = query.trim().split(/\s+/).filter(t => t.length > 0);
|
|
79
|
+
if (terms.length === 0) {
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: "Search query cannot be empty" }],
|
|
82
|
+
details: { mode: "search", error: "empty_query" },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
86
|
+
pattern = new RegExp(escaped.join("|"), "i");
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: "text", text: `Invalid regex: ${query}` }],
|
|
91
|
+
details: { mode: "search", error: "invalid_pattern", query },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const matches: Array<{ name: string; status: string; matchReason: string }> = [];
|
|
96
|
+
|
|
97
|
+
for (const [name, extState] of state.extensions) {
|
|
98
|
+
const matchReasons: string[] = [];
|
|
99
|
+
|
|
100
|
+
// Check name
|
|
101
|
+
if (pattern.test(name)) matchReasons.push("name");
|
|
102
|
+
|
|
103
|
+
// Check description
|
|
104
|
+
if (extState.config.description && pattern.test(extState.config.description)) {
|
|
105
|
+
matchReasons.push("description");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check tags
|
|
109
|
+
if (extState.config.tags?.some(tag => pattern.test(tag))) {
|
|
110
|
+
matchReasons.push("tags");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check tool summary
|
|
114
|
+
if (extState.config.toolSummary?.some(tool => pattern.test(tool))) {
|
|
115
|
+
matchReasons.push("tools");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check registered tool names (for already-loaded extensions)
|
|
119
|
+
if (extState.registeredTools.some(tool => pattern.test(tool))) {
|
|
120
|
+
matchReasons.push("registered tools");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (matchReasons.length > 0) {
|
|
124
|
+
matches.push({
|
|
125
|
+
name,
|
|
126
|
+
status: extState.loaded ? "active" : "inactive",
|
|
127
|
+
matchReason: matchReasons.join(", "),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (matches.length === 0) {
|
|
133
|
+
return {
|
|
134
|
+
content: [{ type: "text", text: `No extensions matching "${query}"` }],
|
|
135
|
+
details: { mode: "search", matches: [], count: 0, query },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let text = `Found ${matches.length} extension${matches.length === 1 ? "" : "s"} matching "${query}":\n\n`;
|
|
140
|
+
for (const match of matches) {
|
|
141
|
+
const extState = state.extensions.get(match.name)!;
|
|
142
|
+
const statusIcon = match.status === "active" ? "✓" : "○";
|
|
143
|
+
const desc = extState.config.description ?? "";
|
|
144
|
+
const tags = extState.config.tags?.length ? ` [${extState.config.tags.join(", ")}]` : "";
|
|
145
|
+
const tools = extState.config.toolSummary?.length
|
|
146
|
+
? `\n Tools: ${extState.config.toolSummary.join(", ")}`
|
|
147
|
+
: "";
|
|
148
|
+
|
|
149
|
+
text += `${statusIcon} ${match.name} (${match.status}, matched: ${match.matchReason})${tags}\n`;
|
|
150
|
+
if (desc) text += ` ${desc}\n`;
|
|
151
|
+
if (tools) text += `${tools}\n`;
|
|
152
|
+
text += "\n";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
content: [{ type: "text", text: text.trim() }],
|
|
157
|
+
details: { mode: "search", matches, count: matches.length, query },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Activate (load) an extension by name.
|
|
163
|
+
*/
|
|
164
|
+
export async function executeActivate(
|
|
165
|
+
state: LazyExtensionsState,
|
|
166
|
+
name: string,
|
|
167
|
+
pi: import("@earendil-works/pi-coding-agent").ExtensionAPI,
|
|
168
|
+
): Promise<ProxyToolResult> {
|
|
169
|
+
const extState = state.extensions.get(name);
|
|
170
|
+
if (!extState) {
|
|
171
|
+
return {
|
|
172
|
+
content: [{ type: "text", text: `Extension "${name}" not found. Use ext({}) to see available extensions.` }],
|
|
173
|
+
details: { mode: "activate", error: "not_found", name },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (extState.loaded) {
|
|
178
|
+
touchExtension(name, state);
|
|
179
|
+
const toolList = extState.registeredTools.length > 0
|
|
180
|
+
? ` Tools: ${extState.registeredTools.join(", ")}`
|
|
181
|
+
: "";
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: "text", text: `Extension "${name}" is already active.${toolList}` }],
|
|
184
|
+
details: { mode: "activate", name, alreadyActive: true, tools: extState.registeredTools },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const result = await activateExtension(name, state, pi);
|
|
189
|
+
|
|
190
|
+
if (!result.success) {
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: "text", text: `Failed to activate "${name}": ${result.error}` }],
|
|
193
|
+
details: { mode: "activate", error: "activation_failed", name, message: result.error },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const toolList = result.tools?.length
|
|
198
|
+
? ` Registered tools: ${result.tools.join(", ")}`
|
|
199
|
+
: "";
|
|
200
|
+
const nonToolExtras: string[] = [];
|
|
201
|
+
if (result.shortcuts?.length) nonToolExtras.push(`${result.shortcuts.length} shortcuts`);
|
|
202
|
+
if (result.flags?.length) nonToolExtras.push(`${result.flags.length} flags`);
|
|
203
|
+
if (result.renderers?.length) nonToolExtras.push(`${result.renderers.length} renderers`);
|
|
204
|
+
const nonToolStr = nonToolExtras.length > 0 ? ` Also registered: ${nonToolExtras.join(", ")} (these persist after idle-unload).` : "";
|
|
205
|
+
const desc = extState.config.description ?? "";
|
|
206
|
+
|
|
207
|
+
const warnings: string[] = [];
|
|
208
|
+
if (result.duplicateTools?.length) {
|
|
209
|
+
warnings.push(`⚠ Tool name collision: "${result.duplicateTools.join(", ")}" already registered by another extension (skipped).`);
|
|
210
|
+
}
|
|
211
|
+
if (result.sessionStartWarning) {
|
|
212
|
+
warnings.push(`⚠ This extension registered a session_start handler that won't fire until the next session/reload. If it depends on session_start for initialization, it may not work correctly when lazy-loaded.`);
|
|
213
|
+
}
|
|
214
|
+
const warningStr = warnings.length > 0 ? `\n\n${warnings.join("\n\n")}` : "";
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
content: [{
|
|
218
|
+
type: "text",
|
|
219
|
+
text: `✓ Activated "${name}"${desc ? ` - ${desc}` : ""}.${toolList}${nonToolStr}${warningStr}\n\nThe extension's tools are now directly available.`,
|
|
220
|
+
}],
|
|
221
|
+
details: { mode: "activate", name, tools: result.tools, shortcuts: result.shortcuts, flags: result.flags, renderers: result.renderers, duplicateTools: result.duplicateTools, sessionStartWarning: result.sessionStartWarning },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* List tools for a specific extension (or all active extensions).
|
|
227
|
+
*/
|
|
228
|
+
export function executeListTools(
|
|
229
|
+
state: LazyExtensionsState,
|
|
230
|
+
extensionName?: string,
|
|
231
|
+
getPiTools?: () => ToolInfo[],
|
|
232
|
+
): ProxyToolResult {
|
|
233
|
+
if (extensionName) {
|
|
234
|
+
const extState = state.extensions.get(extensionName);
|
|
235
|
+
if (extState?.loaded) {
|
|
236
|
+
touchExtension(extensionName, state);
|
|
237
|
+
}
|
|
238
|
+
if (!extState) {
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text", text: `Extension "${extensionName}" not found.` }],
|
|
241
|
+
details: { mode: "list", error: "not_found", extensionName },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!extState.loaded) {
|
|
246
|
+
// Show preserved tools if previously loaded, otherwise manifest toolSummary
|
|
247
|
+
const knownTools = extState.registeredTools.length > 0
|
|
248
|
+
? extState.registeredTools
|
|
249
|
+
: extState.config.toolSummary;
|
|
250
|
+
const summary = knownTools?.length
|
|
251
|
+
? ` Known tools: ${knownTools.join(", ")}`
|
|
252
|
+
: " Use ext({ activate: \"" + extensionName + "\" }) to load it first.";
|
|
253
|
+
return {
|
|
254
|
+
content: [{ type: "text", text: `Extension "${extensionName}" is not active.${summary}` }],
|
|
255
|
+
details: { mode: "list", extensionName, loaded: false, toolSummary: knownTools ?? extState.config.toolSummary },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (extState.registeredTools.length === 0) {
|
|
260
|
+
return {
|
|
261
|
+
content: [{ type: "text", text: `Extension "${extensionName}" is active but has no registered tools.` }],
|
|
262
|
+
details: { mode: "list", extensionName, tools: [], count: 0 },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let text = `${extensionName} (${extState.registeredTools.length} tools):\n\n`;
|
|
267
|
+
for (const toolName of extState.registeredTools) {
|
|
268
|
+
// Try to get description from pi's tool registry
|
|
269
|
+
let desc = "";
|
|
270
|
+
if (getPiTools) {
|
|
271
|
+
const piTool = getPiTools().find(t => t.name === toolName);
|
|
272
|
+
if (piTool?.description) desc = piTool.description;
|
|
273
|
+
}
|
|
274
|
+
const truncated = desc.length > 80 ? desc.slice(0, 77) + "..." : desc;
|
|
275
|
+
text += `- ${toolName}`;
|
|
276
|
+
if (truncated) text += ` - ${truncated}`;
|
|
277
|
+
text += "\n";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
content: [{ type: "text", text: text.trim() }],
|
|
282
|
+
details: { mode: "list", extensionName, tools: extState.registeredTools, count: extState.registeredTools.length },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// List tools across all active extensions
|
|
287
|
+
const allTools: Array<{ extension: string; tool: string; description: string }> = [];
|
|
288
|
+
for (const [name, extState] of state.extensions) {
|
|
289
|
+
if (!extState.loaded) continue;
|
|
290
|
+
for (const toolName of extState.registeredTools) {
|
|
291
|
+
let desc = "";
|
|
292
|
+
if (getPiTools) {
|
|
293
|
+
const piTool = getPiTools().find(t => t.name === toolName);
|
|
294
|
+
if (piTool?.description) desc = piTool.description;
|
|
295
|
+
}
|
|
296
|
+
allTools.push({ extension: name, tool: toolName, description: desc });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (allTools.length === 0) {
|
|
301
|
+
return {
|
|
302
|
+
content: [{ type: "text", text: "No extension tools currently active. Use ext({ activate: \"name\" }) to load an extension." }],
|
|
303
|
+
details: { mode: "list", tools: [], count: 0 },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let text = `Active extension tools (${allTools.length}):\n\n`;
|
|
308
|
+
for (const entry of allTools) {
|
|
309
|
+
const truncated = entry.description.length > 60 ? entry.description.slice(0, 57) + "..." : entry.description;
|
|
310
|
+
text += `- ${entry.tool} [${entry.extension}]`;
|
|
311
|
+
if (truncated) text += ` - ${truncated}`;
|
|
312
|
+
text += "\n";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
content: [{ type: "text", text: text.trim() }],
|
|
317
|
+
details: { mode: "list", tools: allTools, count: allTools.length },
|
|
318
|
+
};
|
|
319
|
+
}
|