Lumi/plugins/lumi_ai/backend/tool_loader.js
2026-06-13 21:32:36 +02:00

219 lines
9.0 KiB
JavaScript

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
};