Download packages/coding-agent/src/core/extensions/loader.ts from SaylorTwift/pi: direct link, hf CLI and curl.
- Browser
- Download file 25.9 kB
-
https://huggingface.co/SaylorTwift/pi/resolve/main/packages/coding-agent/src/core/extensions/loader.ts
- Command line
-
hf download hf://SaylorTwift/pi/packages/coding-agent/src/core/extensions/loader.ts
-
curl -L -o loader.ts https://huggingface.co/SaylorTwift/pi/resolve/main/packages/coding-agent/src/core/extensions/loader.ts
25.9 kB
| /** | |
| * Extension loader - loads TypeScript extension modules using jiti. | |
| * | |
| */ | |
| import * as fs from "node:fs"; | |
| import { createRequire } from "node:module"; | |
| import * as path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core"; | |
| import type { Provider } from "@earendil-works/pi-ai"; | |
| import * as _bundledPiAiCompat from "@earendil-works/pi-ai/compat"; | |
| import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth"; | |
| import * as _bundledPiAiProviders from "@earendil-works/pi-ai/providers/all"; | |
| import type { KeyId } from "@earendil-works/pi-tui"; | |
| import * as _bundledPiTui from "@earendil-works/pi-tui"; | |
| import { createJiti } from "jiti/static"; | |
| // Static imports of packages that extensions may use. | |
| // These MUST be static so Bun bundles them into the compiled binary. | |
| // The virtualModules option then makes them available to extensions. | |
| import * as _bundledTypebox from "typebox"; | |
| import * as _bundledTypeboxCompile from "typebox/compile"; | |
| import * as _bundledTypeboxValue from "typebox/value"; | |
| import { CONFIG_DIR_NAME, getAgentDir, isBunBinary, isBundledNode } from "../../config.ts"; | |
| // NOTE: This import works because loader.ts exports are NOT re-exported from index.ts, | |
| // avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent. | |
| import * as _bundledPiCodingAgent from "../../index.ts"; | |
| import { resolvePath } from "../../utils/paths.ts"; | |
| import { createEventBus, type EventBus } from "../event-bus.ts"; | |
| import type { ExecOptions } from "../exec.ts"; | |
| import { execCommand } from "../exec.ts"; | |
| import { readPiManifest } from "../pi-manifest.ts"; | |
| import { createSyntheticSourceInfo } from "../source-info.ts"; | |
| import { time } from "../timings.ts"; | |
| import type { | |
| EntryRenderer, | |
| Extension, | |
| ExtensionAPI, | |
| ExtensionFactory, | |
| ExtensionRuntime, | |
| LoadExtensionsResult, | |
| MarkdownTransformer, | |
| MessageRenderer, | |
| ProviderConfig, | |
| RegisteredCommand, | |
| ToolDefinition, | |
| } from "./types.ts"; | |
| /** Modules available to extensions via virtualModules (for compiled binaries) */ | |
| const VIRTUAL_MODULES: Record<string, unknown> = { | |
| typebox: _bundledTypebox, | |
| "typebox/compile": _bundledTypeboxCompile, | |
| "typebox/value": _bundledTypeboxValue, | |
| "@sinclair/typebox": _bundledTypebox, | |
| "@sinclair/typebox/compile": _bundledTypeboxCompile, | |
| "@sinclair/typebox/value": _bundledTypeboxValue, | |
| "@earendil-works/pi-agent-core": _bundledPiAgentCore, | |
| "@earendil-works/pi-tui": _bundledPiTui, | |
| // Extensions resolve the pi-ai root to the compat entrypoint (a strict | |
| // superset of the core entrypoint): existing extensions using the old | |
| // global API keep working at runtime until compat is removed. | |
| "@earendil-works/pi-ai": _bundledPiAiCompat, | |
| "@earendil-works/pi-ai/compat": _bundledPiAiCompat, | |
| "@earendil-works/pi-ai/oauth": _bundledPiAiOauth, | |
| "@earendil-works/pi-ai/providers/all": _bundledPiAiProviders, | |
| "@earendil-works/pi-coding-agent": _bundledPiCodingAgent, | |
| "@mariozechner/pi-agent-core": _bundledPiAgentCore, | |
| "@mariozechner/pi-tui": _bundledPiTui, | |
| "@mariozechner/pi-ai": _bundledPiAiCompat, | |
| "@mariozechner/pi-ai/compat": _bundledPiAiCompat, | |
| "@mariozechner/pi-ai/oauth": _bundledPiAiOauth, | |
| "@mariozechner/pi-ai/providers/all": _bundledPiAiProviders, | |
| "@mariozechner/pi-coding-agent": _bundledPiCodingAgent, | |
| }; | |
| const require = createRequire(import.meta.url); | |
| const isNodeSeaBinary = | |
| ("sea" in process.features && process.features.sea === true) || | |
| process.getBuiltinModule("node:sea")?.isSea() === true; | |
| const isTypeScriptSourceRuntime = !isBunBinary && path.extname(fileURLToPath(import.meta.url)) === ".ts"; | |
| /** | |
| * Get aliases for jiti (used in built Node.js mode). | |
| * In compiled binary mode, virtualModules is used instead. | |
| */ | |
| let _aliases: Record<string, string> | null = null; | |
| function getAliases(): Record<string, string> { | |
| if (_aliases) return _aliases; | |
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| const packageIndex = path.resolve(__dirname, "../..", "index.js"); | |
| const typeboxEntry = require.resolve("typebox"); | |
| const typeboxCompileEntry = require.resolve("typebox/compile"); | |
| const typeboxValueEntry = require.resolve("typebox/value"); | |
| const packagesRoot = path.resolve(__dirname, "../../../../"); | |
| const resolveWorkspaceOrImport = (workspaceRelativePath: string, specifier: string): string => { | |
| const workspacePath = path.join(packagesRoot, workspaceRelativePath); | |
| if (fs.existsSync(workspacePath)) { | |
| return workspacePath; | |
| } | |
| return fileURLToPath(import.meta.resolve(specifier)); | |
| }; | |
| const piCodingAgentEntry = packageIndex; | |
| const piAgentCoreEntry = resolveWorkspaceOrImport("agent/dist/index.js", "@earendil-works/pi-agent-core"); | |
| const piTuiEntry = resolveWorkspaceOrImport("tui/dist/index.js", "@earendil-works/pi-tui"); | |
| // Extensions resolve the pi-ai root to the compat entrypoint (a strict | |
| // superset of the core entrypoint): existing extensions using the old | |
| // global API keep working at runtime until compat is removed. | |
| const piAiCompatEntry = resolveWorkspaceOrImport("ai/dist/compat.js", "@earendil-works/pi-ai/compat"); | |
| const piAiOauthEntry = resolveWorkspaceOrImport("ai/dist/oauth.js", "@earendil-works/pi-ai/oauth"); | |
| const piAiProvidersEntry = resolveWorkspaceOrImport( | |
| "ai/dist/providers/all.js", | |
| "@earendil-works/pi-ai/providers/all", | |
| ); | |
| _aliases = { | |
| "@earendil-works/pi-coding-agent": piCodingAgentEntry, | |
| "@earendil-works/pi-agent-core": piAgentCoreEntry, | |
| "@earendil-works/pi-tui": piTuiEntry, | |
| "@earendil-works/pi-ai/providers/all": piAiProvidersEntry, | |
| "@earendil-works/pi-ai/compat": piAiCompatEntry, | |
| "@earendil-works/pi-ai/oauth": piAiOauthEntry, | |
| "@earendil-works/pi-ai": piAiCompatEntry, | |
| "@mariozechner/pi-coding-agent": piCodingAgentEntry, | |
| "@mariozechner/pi-agent-core": piAgentCoreEntry, | |
| "@mariozechner/pi-tui": piTuiEntry, | |
| "@mariozechner/pi-ai/providers/all": piAiProvidersEntry, | |
| "@mariozechner/pi-ai/compat": piAiCompatEntry, | |
| "@mariozechner/pi-ai/oauth": piAiOauthEntry, | |
| "@mariozechner/pi-ai": piAiCompatEntry, | |
| typebox: typeboxEntry, | |
| "typebox/compile": typeboxCompileEntry, | |
| "typebox/value": typeboxValueEntry, | |
| "@sinclair/typebox": typeboxEntry, | |
| "@sinclair/typebox/compile": typeboxCompileEntry, | |
| "@sinclair/typebox/value": typeboxValueEntry, | |
| }; | |
| return _aliases; | |
| } | |
| type HandlerFn = (...args: unknown[]) => Promise<unknown>; | |
| let extensionCacheCwd: string | undefined; | |
| let extensionCacheGeneration = 0; | |
| const extensionCache = new Map<string, ExtensionFactory>(); | |
| interface ExtensionCacheToken { | |
| cwd: string; | |
| generation: number; | |
| } | |
| export function clearExtensionCache(): void { | |
| extensionCache.clear(); | |
| extensionCacheCwd = undefined; | |
| extensionCacheGeneration++; | |
| } | |
| function useExtensionCacheCwd(cwd: string): ExtensionCacheToken { | |
| const resolvedCwd = resolvePath(cwd); | |
| if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) { | |
| clearExtensionCache(); | |
| } | |
| extensionCacheCwd = resolvedCwd; | |
| return { cwd: resolvedCwd, generation: extensionCacheGeneration }; | |
| } | |
| /** | |
| * Create a runtime with throwing stubs for action methods. | |
| * Runner.bindCore() replaces these with real implementations. | |
| */ | |
| export function createExtensionRuntime(): ExtensionRuntime { | |
| const notInitialized = () => { | |
| throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading."); | |
| }; | |
| const state: { staleMessage?: string } = {}; | |
| const eventBusUnsubscribers = new Set<() => void>(); | |
| const assertActive = () => { | |
| if (state.staleMessage) { | |
| throw new Error(state.staleMessage); | |
| } | |
| }; | |
| const runtime: ExtensionRuntime = { | |
| sendMessage: notInitialized, | |
| sendUserMessage: notInitialized, | |
| appendEntry: notInitialized, | |
| setSessionName: notInitialized, | |
| getSessionName: notInitialized, | |
| setLabel: notInitialized, | |
| getActiveTools: notInitialized, | |
| getAllTools: notInitialized, | |
| setActiveTools: notInitialized, | |
| // registerTool() is valid during extension load; refresh is only needed post-bind. | |
| refreshTools: () => {}, | |
| getCommands: notInitialized, | |
| setModel: () => Promise.reject(new Error("Extension runtime not initialized")), | |
| getThinkingLevel: notInitialized, | |
| setThinkingLevel: notInitialized, | |
| flagValues: new Map(), | |
| pendingProviderRegistrations: [], | |
| pendingNativeProviderRegistrations: [], | |
| assertActive, | |
| invalidate: (message) => { | |
| if (state.staleMessage) return; | |
| state.staleMessage = | |
| message ?? | |
| "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload()."; | |
| for (const unsubscribe of eventBusUnsubscribers) unsubscribe(); | |
| eventBusUnsubscribers.clear(); | |
| }, | |
| trackEventBusSubscription: (unsubscribe) => { | |
| let active = true; | |
| const trackedUnsubscribe = () => { | |
| if (!active) return; | |
| active = false; | |
| eventBusUnsubscribers.delete(trackedUnsubscribe); | |
| unsubscribe(); | |
| }; | |
| eventBusUnsubscribers.add(trackedUnsubscribe); | |
| return trackedUnsubscribe; | |
| }, | |
| // Pre-bind: queue registrations so bindCore() can flush them once the | |
| // model registry is available. bindCore() replaces both with direct calls. | |
| registerProvider: (name, config, extensionPath = "<unknown>") => { | |
| runtime.pendingProviderRegistrations.push({ name, config, extensionPath }); | |
| }, | |
| registerNativeProvider: (provider, extensionPath = "<unknown>") => { | |
| runtime.pendingNativeProviderRegistrations.push({ provider, extensionPath }); | |
| }, | |
| unregisterProvider: (name) => { | |
| runtime.pendingProviderRegistrations = runtime.pendingProviderRegistrations.filter((r) => r.name !== name); | |
| runtime.pendingNativeProviderRegistrations = runtime.pendingNativeProviderRegistrations.filter( | |
| (r) => r.provider.id !== name, | |
| ); | |
| }, | |
| }; | |
| return runtime; | |
| } | |
| /** | |
| * Create the ExtensionAPI for an extension. | |
| * Registration methods write to the extension object. | |
| * Action methods delegate to the shared runtime. | |
| */ | |
| function createExtensionAPI( | |
| extension: Extension, | |
| runtime: ExtensionRuntime, | |
| cwd: string, | |
| eventBus: EventBus, | |
| ): { api: ExtensionAPI; commit: () => void; discard: () => void } { | |
| const pendingFlagValues = new Map<string, boolean | string>(); | |
| const pendingRuntimeChanges: Array<() => void> = []; | |
| const loadingUnsubscribers: Array<() => void> = []; | |
| let state: "loading" | "active" | "failed" = "loading"; | |
| const assertActive = () => { | |
| if (state === "failed") { | |
| throw new Error(`Extension "${extension.path}" failed to load and its API is no longer active.`); | |
| } | |
| runtime.assertActive(); | |
| }; | |
| const applyRuntimeChange = (change: () => void) => { | |
| if (state === "loading") pendingRuntimeChanges.push(change); | |
| else change(); | |
| }; | |
| const clearPending = () => { | |
| pendingFlagValues.clear(); | |
| pendingRuntimeChanges.length = 0; | |
| loadingUnsubscribers.length = 0; | |
| }; | |
| const api = { | |
| // Registration methods - write to extension | |
| on(event: string, handler: HandlerFn): void { | |
| assertActive(); | |
| const list = extension.handlers.get(event) ?? []; | |
| list.push(handler); | |
| extension.handlers.set(event, list); | |
| }, | |
| registerTool(tool: ToolDefinition): void { | |
| assertActive(); | |
| if (typeof tool.parameters !== "object" || tool.parameters === null || Array.isArray(tool.parameters)) { | |
| throw new Error( | |
| `Tool "${tool.name}" registered by extension "${extension.path}" must define an object parameter schema.`, | |
| ); | |
| } | |
| extension.tools.set(tool.name, { | |
| definition: tool, | |
| sourceInfo: extension.sourceInfo, | |
| }); | |
| runtime.refreshTools(); | |
| }, | |
| registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "sourceInfo">): void { | |
| assertActive(); | |
| extension.commands.set(name, { | |
| name, | |
| sourceInfo: extension.sourceInfo, | |
| ...options, | |
| }); | |
| }, | |
| registerShortcut( | |
| shortcut: KeyId, | |
| options: { | |
| description?: string; | |
| handler: (ctx: import("./types.ts").ExtensionContext) => Promise<void> | void; | |
| }, | |
| ): void { | |
| assertActive(); | |
| extension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options }); | |
| }, | |
| registerFlag( | |
| name: string, | |
| options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, | |
| ): void { | |
| assertActive(); | |
| if (options.default !== undefined && typeof options.default !== options.type) { | |
| throw new Error( | |
| `Invalid default for flag "${name}": expected ${options.type}, got ${typeof options.default}`, | |
| ); | |
| } | |
| extension.flags.set(name, { name, extensionPath: extension.path, ...options }); | |
| if (options.default !== undefined && !runtime.flagValues.has(name)) { | |
| if (state === "loading") { | |
| if (!pendingFlagValues.has(name)) pendingFlagValues.set(name, options.default); | |
| } else { | |
| runtime.flagValues.set(name, options.default); | |
| } | |
| } | |
| }, | |
| registerMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void { | |
| assertActive(); | |
| extension.messageRenderers.set(customType, renderer as MessageRenderer); | |
| }, | |
| registerMarkdownTransformer(transformer: MarkdownTransformer): void { | |
| assertActive(); | |
| extension.markdownTransformer = transformer; | |
| }, | |
| registerEntryRenderer<T>(customType: string, renderer: EntryRenderer<T>): void { | |
| assertActive(); | |
| extension.entryRenderers ??= new Map(); | |
| extension.entryRenderers.set(customType, renderer as EntryRenderer); | |
| }, | |
| // Flag access - checks extension registered it, reads from runtime | |
| getFlag(name: string): boolean | string | undefined { | |
| assertActive(); | |
| if (!extension.flags.has(name)) return undefined; | |
| return runtime.flagValues.has(name) ? runtime.flagValues.get(name) : pendingFlagValues.get(name); | |
| }, | |
| // Action methods - delegate to shared runtime | |
| sendMessage(message, options): void { | |
| assertActive(); | |
| runtime.sendMessage(message, options); | |
| }, | |
| sendUserMessage(content, options): void { | |
| assertActive(); | |
| runtime.sendUserMessage(content, options); | |
| }, | |
| appendEntry(customType: string, data?: unknown): void { | |
| assertActive(); | |
| runtime.appendEntry(customType, data); | |
| }, | |
| setSessionName(name: string): void { | |
| assertActive(); | |
| runtime.setSessionName(name); | |
| }, | |
| getSessionName(): string | undefined { | |
| assertActive(); | |
| return runtime.getSessionName(); | |
| }, | |
| setLabel(entryId: string, label: string | undefined): void { | |
| assertActive(); | |
| runtime.setLabel(entryId, label); | |
| }, | |
| exec(command: string, args: string[], options?: ExecOptions) { | |
| assertActive(); | |
| return execCommand(command, args, options?.cwd ?? cwd, options); | |
| }, | |
| getActiveTools(): string[] { | |
| assertActive(); | |
| return runtime.getActiveTools(); | |
| }, | |
| getAllTools() { | |
| assertActive(); | |
| return runtime.getAllTools(); | |
| }, | |
| setActiveTools(toolNames: string[]): void { | |
| assertActive(); | |
| runtime.setActiveTools(toolNames); | |
| }, | |
| getCommands() { | |
| assertActive(); | |
| return runtime.getCommands(); | |
| }, | |
| setModel(model) { | |
| assertActive(); | |
| return runtime.setModel(model); | |
| }, | |
| getThinkingLevel() { | |
| assertActive(); | |
| return runtime.getThinkingLevel(); | |
| }, | |
| setThinkingLevel(level) { | |
| assertActive(); | |
| runtime.setThinkingLevel(level); | |
| }, | |
| registerProvider(providerOrName: Provider | string, config?: ProviderConfig) { | |
| assertActive(); | |
| if (typeof providerOrName === "string") { | |
| if (!config) throw new Error("Provider config is required when registering by name"); | |
| applyRuntimeChange(() => runtime.registerProvider(providerOrName, config, extension.path)); | |
| return; | |
| } | |
| applyRuntimeChange(() => runtime.registerNativeProvider(providerOrName, extension.path)); | |
| }, | |
| unregisterProvider(name: string) { | |
| assertActive(); | |
| applyRuntimeChange(() => runtime.unregisterProvider(name, extension.path)); | |
| }, | |
| events: { | |
| emit(channel, data) { | |
| assertActive(); | |
| eventBus.emit(channel, data); | |
| }, | |
| on(channel, handler) { | |
| assertActive(); | |
| const unsubscribe = runtime.trackEventBusSubscription(eventBus.on(channel, handler)); | |
| if (state === "loading") loadingUnsubscribers.push(unsubscribe); | |
| return unsubscribe; | |
| }, | |
| }, | |
| } as ExtensionAPI; | |
| return { | |
| api, | |
| commit: () => { | |
| if (state !== "loading") return; | |
| runtime.assertActive(); | |
| for (const [name, value] of pendingFlagValues) { | |
| if (!runtime.flagValues.has(name)) runtime.flagValues.set(name, value); | |
| } | |
| for (const apply of pendingRuntimeChanges) apply(); | |
| state = "active"; | |
| clearPending(); | |
| }, | |
| discard: () => { | |
| if (state !== "loading") return; | |
| state = "failed"; | |
| for (const unsubscribe of loadingUnsubscribers) unsubscribe(); | |
| clearPending(); | |
| }, | |
| }; | |
| } | |
| function isCurrentCacheToken(cacheToken: ExtensionCacheToken | undefined): cacheToken is ExtensionCacheToken { | |
| return ( | |
| cacheToken !== undefined && | |
| extensionCacheCwd === cacheToken.cwd && | |
| extensionCacheGeneration === cacheToken.generation | |
| ); | |
| } | |
| async function loadExtensionModule(extensionPath: string, cacheToken?: ExtensionCacheToken) { | |
| if (isCurrentCacheToken(cacheToken)) { | |
| const cachedFactory = extensionCache.get(extensionPath); | |
| if (cachedFactory) { | |
| return cachedFactory; | |
| } | |
| } | |
| const jiti = createJiti(import.meta.url, { | |
| moduleCache: false, | |
| // Compiled binaries and the bundled Node distribution use embedded modules. | |
| // Source TypeScript reuses host modules and root tsconfig paths. Unbundled | |
| // Node builds use dist aliases. | |
| ...(isBunBinary || isNodeSeaBinary || isBundledNode | |
| ? { virtualModules: VIRTUAL_MODULES, tryNative: false } | |
| : isTypeScriptSourceRuntime | |
| ? { virtualModules: VIRTUAL_MODULES, tsconfigPaths: true } | |
| : { alias: getAliases() }), | |
| }); | |
| const module = await jiti.import(extensionPath, { default: true }); | |
| const factory = module as ExtensionFactory; | |
| if (typeof factory !== "function") { | |
| return undefined; | |
| } | |
| if (isCurrentCacheToken(cacheToken)) { | |
| extensionCache.set(extensionPath, factory); | |
| } | |
| return factory; | |
| } | |
| /** | |
| * Create an Extension object with empty collections. | |
| */ | |
| function createExtension(extensionPath: string, resolvedPath: string): Extension { | |
| const source = | |
| extensionPath.startsWith("<") && extensionPath.endsWith(">") | |
| ? extensionPath.slice(1, -1).split(":")[0] || "temporary" | |
| : "local"; | |
| const baseDir = extensionPath.startsWith("<") ? undefined : path.dirname(resolvedPath); | |
| return { | |
| path: extensionPath, | |
| resolvedPath, | |
| sourceInfo: createSyntheticSourceInfo(extensionPath, { source, baseDir }), | |
| handlers: new Map(), | |
| tools: new Map(), | |
| messageRenderers: new Map(), | |
| entryRenderers: new Map(), | |
| commands: new Map(), | |
| flags: new Map(), | |
| shortcuts: new Map(), | |
| }; | |
| } | |
| async function initializeExtension( | |
| factory: ExtensionFactory, | |
| extensionPath: string, | |
| resolvedPath: string, | |
| cwd: string, | |
| eventBus: EventBus, | |
| runtime: ExtensionRuntime, | |
| ): Promise<Extension> { | |
| const extension = createExtension(extensionPath, resolvedPath); | |
| const load = createExtensionAPI(extension, runtime, cwd, eventBus); | |
| try { | |
| await factory(load.api); | |
| load.commit(); | |
| } catch (error) { | |
| load.discard(); | |
| throw error; | |
| } | |
| time(`${extensionPath} factory`, "extensions"); | |
| return extension; | |
| } | |
| async function loadExtension( | |
| extensionPath: string, | |
| cwd: string, | |
| eventBus: EventBus, | |
| runtime: ExtensionRuntime, | |
| cacheToken?: ExtensionCacheToken, | |
| ): Promise<{ extension: Extension | null; error: string | null }> { | |
| const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true }); | |
| try { | |
| const factory = await loadExtensionModule(resolvedPath, cacheToken); | |
| time(`${extensionPath} module import`, "extensions"); | |
| if (!factory) { | |
| return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` }; | |
| } | |
| const extension = await initializeExtension(factory, extensionPath, resolvedPath, cwd, eventBus, runtime); | |
| return { extension, error: null }; | |
| } catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| return { extension: null, error: `Failed to load extension: ${message}` }; | |
| } | |
| } | |
| /** | |
| * Create an Extension from an inline factory function. | |
| */ | |
| export async function loadExtensionFromFactory( | |
| factory: ExtensionFactory, | |
| cwd: string, | |
| eventBus: EventBus, | |
| runtime: ExtensionRuntime, | |
| extensionPath = "<inline>", | |
| ): Promise<Extension> { | |
| const resolvedCwd = resolvePath(cwd); | |
| return initializeExtension(factory, extensionPath, extensionPath, resolvedCwd, eventBus, runtime); | |
| } | |
| /** | |
| * Load extensions from paths. | |
| */ | |
| async function loadExtensionsInternal( | |
| paths: string[], | |
| cwd: string, | |
| eventBus?: EventBus, | |
| runtime?: ExtensionRuntime, | |
| useCache = false, | |
| ): Promise<LoadExtensionsResult> { | |
| const extensions: Extension[] = []; | |
| const errors: Array<{ path: string; error: string }> = []; | |
| const cacheToken = useCache ? useExtensionCacheCwd(cwd) : undefined; | |
| const resolvedCwd = cacheToken?.cwd ?? resolvePath(cwd); | |
| const resolvedEventBus = eventBus ?? createEventBus(); | |
| const resolvedRuntime = runtime ?? createExtensionRuntime(); | |
| for (const extPath of paths) { | |
| const { extension, error } = await loadExtension( | |
| extPath, | |
| resolvedCwd, | |
| resolvedEventBus, | |
| resolvedRuntime, | |
| cacheToken, | |
| ); | |
| if (error) { | |
| errors.push({ path: extPath, error }); | |
| continue; | |
| } | |
| if (extension) { | |
| extensions.push(extension); | |
| } | |
| } | |
| return { | |
| extensions, | |
| errors, | |
| runtime: resolvedRuntime, | |
| }; | |
| } | |
| export async function loadExtensions( | |
| paths: string[], | |
| cwd: string, | |
| eventBus?: EventBus, | |
| runtime?: ExtensionRuntime, | |
| ): Promise<LoadExtensionsResult> { | |
| return loadExtensionsInternal(paths, cwd, eventBus, runtime); | |
| } | |
| export async function loadExtensionsCached( | |
| paths: string[], | |
| cwd: string, | |
| eventBus?: EventBus, | |
| runtime?: ExtensionRuntime, | |
| ): Promise<LoadExtensionsResult> { | |
| return loadExtensionsInternal(paths, cwd, eventBus, runtime, true); | |
| } | |
| function isExtensionFile(name: string): boolean { | |
| return name.endsWith(".ts") || name.endsWith(".js"); | |
| } | |
| /** | |
| * Resolve extension entry points from a directory. | |
| * | |
| * Checks for: | |
| * 1. package.json with "pi.extensions" field -> returns declared paths | |
| * 2. index.ts or index.js -> returns the index file | |
| * | |
| * Returns resolved paths or null if no entry points found. | |
| */ | |
| function resolveExtensionEntries(dir: string): string[] | null { | |
| // Check for package.json with "pi" field first | |
| const packageJsonPath = path.join(dir, "package.json"); | |
| if (fs.existsSync(packageJsonPath)) { | |
| const manifest = readPiManifest(packageJsonPath); | |
| if (manifest?.extensions?.length) { | |
| const entries: string[] = []; | |
| for (const extPath of manifest.extensions) { | |
| const resolvedExtPath = path.resolve(dir, extPath); | |
| if (fs.existsSync(resolvedExtPath)) { | |
| entries.push(resolvedExtPath); | |
| } | |
| } | |
| if (entries.length > 0) { | |
| return entries; | |
| } | |
| } | |
| } | |
| // Check for index.ts or index.js | |
| const indexTs = path.join(dir, "index.ts"); | |
| const indexJs = path.join(dir, "index.js"); | |
| if (fs.existsSync(indexTs)) { | |
| return [indexTs]; | |
| } | |
| if (fs.existsSync(indexJs)) { | |
| return [indexJs]; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Discover extensions in a directory. | |
| * | |
| * Discovery rules: | |
| * 1. Direct files: `extensions/*.ts` or `*.js` → load | |
| * 2. Subdirectory with index: `extensions/* /index.ts` or `index.js` → load | |
| * 3. Subdirectory with package.json: `extensions/* /package.json` with "pi" field → load what it declares | |
| * | |
| * No recursion beyond one level. Complex packages must use package.json manifest. | |
| */ | |
| function discoverExtensionsInDir(dir: string): string[] { | |
| if (!fs.existsSync(dir)) { | |
| return []; | |
| } | |
| const discovered: string[] = []; | |
| try { | |
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const entryPath = path.join(dir, entry.name); | |
| // 1. Direct files: *.ts or *.js | |
| if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) { | |
| discovered.push(entryPath); | |
| continue; | |
| } | |
| // 2 & 3. Subdirectories | |
| if (entry.isDirectory() || entry.isSymbolicLink()) { | |
| const entries = resolveExtensionEntries(entryPath); | |
| if (entries) { | |
| discovered.push(...entries); | |
| } | |
| } | |
| } | |
| } catch { | |
| return []; | |
| } | |
| return discovered; | |
| } | |
| /** | |
| * Discover and load extensions from standard locations. | |
| */ | |
| export async function discoverAndLoadExtensions( | |
| configuredPaths: string[], | |
| cwd: string, | |
| agentDir: string = getAgentDir(), | |
| eventBus?: EventBus, | |
| ): Promise<LoadExtensionsResult> { | |
| const resolvedCwd = resolvePath(cwd); | |
| const resolvedAgentDir = resolvePath(agentDir); | |
| const allPaths: string[] = []; | |
| const seen = new Set<string>(); | |
| const addPaths = (paths: string[]) => { | |
| for (const p of paths) { | |
| const resolved = path.resolve(p); | |
| if (!seen.has(resolved)) { | |
| seen.add(resolved); | |
| allPaths.push(p); | |
| } | |
| } | |
| }; | |
| // 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/ | |
| const localExtDir = path.join(resolvedCwd, CONFIG_DIR_NAME, "extensions"); | |
| addPaths(discoverExtensionsInDir(localExtDir)); | |
| // 2. Global extensions: agentDir/extensions/ | |
| const globalExtDir = path.join(resolvedAgentDir, "extensions"); | |
| addPaths(discoverExtensionsInDir(globalExtDir)); | |
| // 3. Explicitly configured paths | |
| for (const p of configuredPaths) { | |
| const resolved = resolvePath(p, resolvedCwd, { normalizeUnicodeSpaces: true }); | |
| if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { | |
| // Check for package.json with pi manifest or index.ts | |
| const entries = resolveExtensionEntries(resolved); | |
| if (entries) { | |
| addPaths(entries); | |
| continue; | |
| } | |
| // No explicit entries - discover individual files in directory | |
| addPaths(discoverExtensionsInDir(resolved)); | |
| continue; | |
| } | |
| addPaths([resolved]); | |
| } | |
| return loadExtensions(allPaths, resolvedCwd, eventBus); | |
| } | |