const fs = require("fs"); const path = require("path"); const { resolveData } = require("./paths"); const { registerManagedTool } = require("./tool_registry"); class ToolLoader { constructor(options = {}) { this.registry = options.registry; this.installer = options.installer; this.settings = options.settings; this.lumiAiVersion = options.lumiAiVersion || "0.0.0"; this.lumiVersion = options.lumiVersion || "0.0.0"; this.stateFile = options.stateFile || resolveData("tools", "enabled.json"); this.loaded = new Map(); this.statuses = new Map(); } async loadEnabled() { const state = this.readState(); for (const local of this.installer.scanLocal()) { if (state.enabled[local.tool_id] === true) { try { await this.enable(local.tool_id, { persist: false }); } catch (error) { this.setStatus(local.tool_id, "unavailable", error.message); } } } } async enable(toolId, options = {}) { const local = this.installer.local(toolId); if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid."); const dependencies = this.inspectDependencies(local.metadata, local.dir); if (dependencies.blocking.length) { const message = `Unavailable: ${dependencies.blocking.join("; ")}`; this.setStatus(toolId, "unavailable", message, dependencies); if (options.persist !== false) this.setEnabled(toolId, true); return { loaded: false, unavailable: true, message, dependencies }; } await this.disable(toolId, { persist: false }); const backend = backendEntrypoint(local.metadata, local.dir); const registered = []; let cleanup = null; if (backend) { clearRequireCache(local.dir); try { const module = require(backend); const register = module?.register || module?.init; if (typeof register !== "function") throw new Error("Backend entrypoint must export register() or init()."); const context = { metadata: Object.freeze({ ...local.metadata }), registerTool: (definition) => { const unregister = registerManagedTool(this.registry, local.metadata, definition); registered.push({ id: definition.tool_id, unregister }); return unregister; }, paths: Object.freeze({ root: local.dir, data: path.join(local.dir, "data"), config: path.join(local.dir, "config") }), assetUrl: (relative = "") => `/plugins/lumi_ai/tools/${toolId}/assets/${String(relative).replace(/^\/+/, "")}` }; if (typeof module.checkAvailability === "function") { const availability = await module.checkAvailability(context); if (availability?.available === false) { this.loaded.set(toolId, { cleanup: null, registered, metadata: local.metadata, dir: local.dir }); this.setStatus(toolId, "unavailable", String(availability.message || "Tool configuration is incomplete."), dependencies); if (options.persist !== false) this.setEnabled(toolId, true); return { loaded: false, unavailable: true, message: availability.message, dependencies }; } } const result = await register(context); cleanup = typeof result === "function" ? result : typeof result?.stop === "function" ? () => result.stop() : null; } catch (error) { this.registry.unregisterOwner(toolId); this.setStatus(toolId, "unavailable", error.message, dependencies); if (options.persist !== false) this.setEnabled(toolId, true); return { loaded: false, unavailable: true, message: error.message, dependencies }; } } this.loaded.set(toolId, { cleanup, registered, metadata: local.metadata, dir: local.dir }); this.setStatus(toolId, "enabled", dependencies.optional.length ? `Enabled with limitations: ${dependencies.optional.join("; ")}` : "", dependencies); if (options.persist !== false) this.setEnabled(toolId, true); return { loaded: true, registered: registered.map((entry) => entry.id), dependencies }; } async disable(toolId, options = {}) { const loaded = this.loaded.get(toolId); if (loaded) { try { await loaded.cleanup?.(); } catch {} this.registry.unregisterOwner(toolId); this.loaded.delete(toolId); } else { this.registry.unregisterOwner(toolId); } if (options.persist !== false) this.setEnabled(toolId, false); this.setStatus(toolId, "disabled", ""); return { disabled: true }; } async stopAll() { for (const toolId of [...this.loaded.keys()]) await this.disable(toolId, { persist: false }); } isEnabled(toolId) { return this.readState().enabled[toolId] === true; } status(toolId) { return this.statuses.get(toolId) || { state: this.isEnabled(toolId) ? "pending" : "disabled", message: "" }; } inspectDependencies(metadata, toolDir) { const blocking = []; const optional = []; for (const pluginId of metadata.required_plugins || []) { if (/^lumi_ai_.+/i.test(pluginId)) { blocking.push(`AI tool plugins cannot depend on AI tool plugin ${pluginId}`); } else if (!["core", "lumi_ai"].includes(pluginId) && !fs.existsSync(path.resolve(toolDir, "..", pluginId))) { blocking.push(`required plugin ${pluginId} is missing`); } } for (const platform of metadata.required_platforms || []) { if (this.settings?.getSetting?.(`platform_${platform}_enabled`, false) !== true) { blocking.push(`required platform ${platform} is disabled`); } } for (const dependency of metadata.dependencies || []) { try { require.resolve(dependency, { paths: [toolDir] }); } catch { optional.push(`optional dependency ${dependency} is missing`); } } if (metadata.minimum_lumi_ai_version && compareVersions(this.lumiAiVersion, metadata.minimum_lumi_ai_version) < 0) { blocking.push(`requires Lumi AI ${metadata.minimum_lumi_ai_version}`); } if (metadata.minimum_lumi_version && compareVersions(this.lumiVersion, metadata.minimum_lumi_version) < 0) { blocking.push(`requires Lumi ${metadata.minimum_lumi_version}`); } return { blocking, optional }; } resolveAsset(toolId, relative) { const loaded = this.loaded.get(toolId); if (!loaded || !this.isEnabled(toolId)) return null; const roots = assetRoots(loaded.metadata, loaded.dir); for (const root of roots) { const candidate = path.resolve(root, String(relative || "")); if ((candidate === root || candidate.startsWith(`${root}${path.sep}`)) && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { return candidate; } } return null; } setStatus(toolId, state, message, dependencies = null) { this.statuses.set(toolId, { state, message: String(message || ""), dependencies }); } readState() { try { const value = JSON.parse(fs.readFileSync(this.stateFile, "utf8")); return { enabled: value.enabled && typeof value.enabled === "object" ? value.enabled : {} }; } catch { return { enabled: {} }; } } setEnabled(toolId, enabled) { const state = this.readState(); state.enabled[toolId] = Boolean(enabled); fs.mkdirSync(path.dirname(this.stateFile), { recursive: true }); const temporary = `${this.stateFile}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`); fs.renameSync(temporary, this.stateFile); } } function backendEntrypoint(metadata, toolDir) { const configured = typeof metadata.entrypoints === "string" ? metadata.entrypoints : metadata.entrypoints?.backend; const relative = configured || (fs.existsSync(path.join(toolDir, "index.js")) ? "index.js" : ""); if (!relative) return null; const target = path.resolve(toolDir, relative); if (!target.startsWith(`${path.resolve(toolDir)}${path.sep}`) || !fs.existsSync(target)) { throw new Error("Backend entrypoint is missing or unsafe."); } return target; } function assetRoots(metadata, toolDir) { const entries = Array.isArray(metadata.frontend_assets) ? metadata.frontend_assets : metadata.frontend_assets ? [metadata.frontend_assets] : ["public"]; return entries.map((entry) => path.resolve(toolDir, entry)) .filter((entry) => entry.startsWith(`${path.resolve(toolDir)}${path.sep}`) && fs.existsSync(entry)); } function clearRequireCache(directory) { const prefix = `${path.resolve(directory)}${path.sep}`; for (const key of Object.keys(require.cache)) if (key.startsWith(prefix)) delete require.cache[key]; } function compareVersions(left, right) { const parse = (value) => String(value || "0.0.0").split(/[+-]/)[0].split(".").map((part) => Number.parseInt(part, 10) || 0); const a = parse(left); const b = parse(right); for (let index = 0; index < Math.max(a.length, b.length, 3); index += 1) { if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) > (b[index] || 0) ? 1 : -1; } return 0; } module.exports = { ToolLoader, assetRoots, backendEntrypoint, clearRequireCache, compareVersions };