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/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
+ }