Lumi/plugins/lumi_ai/backend/tool_manager.js
2026-06-14 05:01:13 +02:00

300 lines
11 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const ejs = require("ejs");
const { assetRoots, compareVersions } = require("./tool_loader");
class ToolManager {
constructor(options = {}) {
this.repoClient = options.repoClient;
this.installer = options.installer;
this.loader = options.loader;
this.settings = options.settings;
}
async list({ force = false } = {}) {
await this.loader.reconcile({ reload: force });
const remoteResult = await this.repoClient.discover({ force });
const localRows = this.installer.scanLocal();
const remoteMap = new Map(remoteResult.tools.map((metadata) => [metadata.tool_id, metadata]));
const localMap = new Map(localRows.map((entry) => [entry.tool_id, entry]));
const ids = [...new Set([...remoteMap.keys(), ...localMap.keys()])].sort();
return {
repository: remoteResult.repository,
branch: remoteResult.branch,
checked_at: remoteResult.checked_at,
cached: remoteResult.cached,
stale: remoteResult.stale,
error: remoteResult.error || null,
tools: ids.map((toolId) => {
const remote = remoteMap.get(toolId) || null;
const local = localMap.get(toolId) || null;
const metadata = local?.metadata || remote;
const installed = Boolean(local);
const remoteMissing = installed && !remote;
const enabled = installed && this.loader.isEnabled(toolId);
const runtime = this.loader.status(toolId);
const updateAvailable = Boolean(
installed && remote && local.valid && !remote.remote_invalid &&
compareVersions(remote.version, local.metadata.version) > 0
);
const dependencies = local?.valid ? this.loader.inspectDependencies(local.metadata, local.dir) : { blocking: [], optional: [] };
return {
...metadata,
installed,
enabled,
local_version: installed ? local.metadata.version : null,
remote_version: remote?.version || null,
update_available: updateAvailable,
update_enabled: installed && Boolean(remote) && !remote?.remote_invalid,
remote_missing: remoteMissing,
local_only: remoteMissing,
local_valid: local?.valid !== false,
local_error: local?.error || null,
remote_invalid: remote?.remote_invalid === true,
remote_error: remote?.remote_error || null,
runtime_state: runtime.state,
runtime_message: runtime.message,
registered_tools: [...this.loader.registry.tools.values()]
.filter((definition) => definition.owning_plugin === toolId)
.map((definition) => definition.tool_id),
dependency_status: dependencies,
primary_type: metadata?.tool_type || "general",
primary_scope: displayScope(metadata?.scope),
has_settings: installed && Boolean(metadata?.settings_schema && Object.keys(metadata.settings_schema).length)
};
})
};
}
async enable(toolId) {
let installed = this.installer.local(toolId);
if (!installed) {
const remote = await this.repoClient.discover();
if (!remote.tools.some((entry) => entry.tool_id === toolId && !entry.remote_invalid)) {
throw new Error("Remote AI tool plugin is unavailable.");
}
await this.installer.install(toolId);
installed = this.installer.local(toolId);
}
return this.loader.enable(toolId);
}
async disable(toolId) {
return this.loader.disable(toolId);
}
async update(toolId) {
const local = this.installer.local(toolId);
if (!local) throw new Error("Install the AI tool plugin before updating it.");
const remote = await this.repoClient.discover({ force: true });
if (!remote.tools.some((entry) => entry.tool_id === toolId && !entry.remote_invalid)) {
throw new Error("This installed tool is missing from the configured repository.");
}
const wasEnabled = this.loader.isEnabled(toolId);
if (wasEnabled) await this.loader.disable(toolId, { persist: false });
try {
const result = await this.installer.update(toolId);
if (wasEnabled) await this.loader.enable(toolId, { persist: false });
this.loader.setEnabled(toolId, wasEnabled);
return result;
} catch (error) {
if (wasEnabled && this.installer.local(toolId)) {
try { await this.loader.enable(toolId, { persist: false }); } catch {}
this.loader.setEnabled(toolId, true);
}
throw error;
}
}
async delete(toolId) {
await this.loader.disable(toolId);
const deleted = this.installer.delete(toolId);
this.loader.setEnabled(toolId, false);
return { deleted };
}
async readme(toolId) {
const local = this.installer.local(toolId);
if (local) {
const file = path.join(local.dir, "readme.md");
if (!fs.existsSync(file)) throw new Error("Installed tool readme.md is missing.");
return { markdown: fs.readFileSync(file, "utf8"), source: "local" };
}
return { markdown: await this.repoClient.readReadme(toolId), source: "remote" };
}
settingsFor(toolId) {
const described = this.settings.describe(toolId);
const local = this.installer.local(toolId);
const ui = local?.valid ? settingsUi(local.metadata, local.dir, toolId) : null;
const status = local?.valid ? settingsStatus(local.metadata, local.dir) : null;
return { ...described, ui, status };
}
resetSettings(toolId) {
this.settings.reset(toolId);
return this.loader.isEnabled(toolId)
? this.loader.enable(toolId, { persist: false }).then(() => this.settingsFor(toolId))
: Promise.resolve(this.settingsFor(toolId));
}
async saveSettings(toolId, values) {
const saved = this.settings.save(toolId, values);
if (this.loader.isEnabled(toolId)) await this.loader.enable(toolId, { persist: false });
return saved;
}
async diagnostics({ role, user, context }) {
await this.loader.reconcile();
const exposure = this.loader.registry.inspect({ role, user, context });
const decisionsByOwner = new Map();
for (const decision of exposure.considered) {
const owner = decision.tool.owning_plugin;
if (!decisionsByOwner.has(owner)) decisionsByOwner.set(owner, []);
decisionsByOwner.get(owner).push(decision);
}
const plugins = this.loader.diagnostics().map((plugin) => {
const decisions = decisionsByOwner.get(plugin.tool_id) || [];
const local = this.installer.local(plugin.tool_id);
const declared = Array.isArray(local?.metadata?.registered_capabilities)
? local.metadata.registered_capabilities
: [];
let rawSettings = {};
try { rawSettings = this.settings.readRaw(plugin.tool_id); } catch {}
const configuredDetails = Object.fromEntries(
(Array.isArray(local?.metadata?.diagnostic_settings)
? local.metadata.diagnostic_settings
: []
).filter((key) => Object.hasOwn(rawSettings, key)).map((key) => [key, rawSettings[key]])
);
const persistedStatus = local?.valid ? settingsStatus(local.metadata, local.dir) : {};
const capabilityDecisions = declared.map((capability) => {
const registered = decisions.find((decision) => decision.tool.tool_id === capability.tool_id);
if (registered) return registered;
const enabled = capability.enabled_setting ? rawSettings[capability.enabled_setting] !== false : true;
return {
tool: {
tool_id: capability.tool_id,
description: capability.description || "",
owning_plugin: plugin.tool_id
},
exposed: false,
reason: enabled ? "unavailable" : "disabled",
message: enabled
? "The capability is enabled but not registered."
: "The capability is disabled in tool settings."
};
});
const allDecisions = [
...decisions,
...capabilityDecisions.filter((candidate) =>
!decisions.some((decision) => decision.tool.tool_id === candidate.tool.tool_id)
)
];
let hiddenReason = null;
if (!plugin.valid) hiddenReason = "schema_invalid";
else if (!plugin.enabled) hiddenReason = "disabled";
else if (plugin.dependencies.blocking.length) hiddenReason = "dependency_failed";
else if (plugin.state === "unavailable") hiddenReason = "unavailable";
else if (!allDecisions.some((decision) => decision.exposed)) {
hiddenReason = allDecisions[0]?.reason || "unavailable";
}
return {
...plugin,
runtime_details: {
...configuredDetails,
...persistedStatus,
...(plugin.runtime_details || {})
},
prompt_exposed: allDecisions.some((decision) => decision.exposed),
hidden_reason: hiddenReason,
decisions: allDecisions
};
});
return {
role,
origin: context?.origin || context?.platform || "other",
considered_tools: plugins.flatMap((plugin) =>
plugin.decisions.map((decision) => decision.tool.tool_id)
),
exposed_tools: exposure.exposed.map((tool) => tool.tool_id),
prompt_tools: exposure.exposed,
plugins
};
}
async loadEnabled() {
return this.loader.loadEnabled();
}
async stopAll() {
return this.loader.stopAll();
}
resolveAsset(toolId, relative, options = {}) {
if (options.allowInstalled) {
const local = this.installer.local(toolId);
if (local?.valid) {
for (const root of assetRoots(local.metadata, local.dir)) {
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 this.loader.resolveAsset(toolId, relative);
}
}
function displayScope(scope) {
if (typeof scope === "string") return scope;
if (Array.isArray(scope)) return scope.join(", ");
if (scope && typeof scope === "object") return scope.label || scope.required_role || JSON.stringify(scope);
return "unspecified";
}
function settingsUi(metadata, toolDir, toolId) {
const config = metadata.settings_ui;
if (!config || typeof config !== "object") return null;
const view = safeToolPath(toolDir, config.view);
let html = "";
if (view && fs.existsSync(view)) {
html = ejs.render(fs.readFileSync(view, "utf8"), {
tool: metadata,
tool_id: toolId
}, { filename: view });
}
return {
html,
scripts: safeAssetList(config.scripts, toolId),
styles: safeAssetList(config.styles, toolId)
};
}
function safeToolPath(root, relative) {
if (!relative) return null;
const target = path.resolve(root, String(relative));
return target.startsWith(`${path.resolve(root)}${path.sep}`) ? target : null;
}
function safeAssetList(values, toolId) {
return (Array.isArray(values) ? values : [])
.map((value) => String(value || "").replace(/^\/+/, ""))
.filter((value) => value && !value.split("/").includes(".."))
.map((value) => `/plugins/lumi_ai/tools/${toolId}/assets/${value}`);
}
function settingsStatus(metadata, toolDir) {
const file = safeToolPath(toolDir, metadata.status_file);
if (!file || !fs.existsSync(file)) return {};
try {
const value = JSON.parse(fs.readFileSync(file, "utf8"));
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
} catch {
return {};
}
}
module.exports = { ToolManager, displayScope, safeAssetList, settingsStatus, settingsUi };