Add Lumi AI tool plugin manager
This commit is contained in:
parent
3c0cd409a2
commit
9a3091e410
@ -1,5 +1,38 @@
|
|||||||
# Lumi AI
|
# Lumi AI
|
||||||
|
|
||||||
|
## AI tool plugins
|
||||||
|
|
||||||
|
Administrators can open **Tools** from the Lumi AI settings title bar. The manager combines installed tools with remote `plugins/lumi_ai_*` directories from the core-configured Git repository and branch. Remote metadata is cached under `data/tools/` for five minutes.
|
||||||
|
|
||||||
|
Each AI tool plugin lives directly under `plugins/lumi_ai_{name}/` and requires:
|
||||||
|
|
||||||
|
- `tool_info.json` with `tool_id`, `display_name`, `version`, `description`, `scope`, `permissions`, `capabilities`, and `limitations`
|
||||||
|
- `readme.md` for the separate documentation inspector
|
||||||
|
|
||||||
|
Optional backend code is configured with `entrypoints.backend` and exports `register()` or `init()`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports.register = ({ registerTool, metadata, paths, assetUrl }) => {
|
||||||
|
registerTool({
|
||||||
|
tool_id: `${metadata.tool_id}.lookup`,
|
||||||
|
display_name: "Example lookup",
|
||||||
|
description: "Runs a read-only lookup.",
|
||||||
|
required_role: "user",
|
||||||
|
required_permission: "example.lookup",
|
||||||
|
audit_category: "lookup",
|
||||||
|
confirmation_required: false,
|
||||||
|
risk_level: "low",
|
||||||
|
schema: { query: "string" },
|
||||||
|
permission_check: ({ user }) => Boolean(user?.id),
|
||||||
|
workflow_handler: async ({ arguments: args }) => ({ query: args.query })
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The loader exposes no generic shell, SQL, filesystem, network, or code-execution API. Tool IDs must use the owning plugin namespace. Metadata roles can only make backend roles stricter, and sensitive or mutating definitions always require the existing Lumi AI confirmation flow.
|
||||||
|
|
||||||
|
Enable installs remote tools atomically and registers valid definitions. Disable unregisters them while retaining files. Update preserves `data/` and `config/` by default and rolls back to the previous directory if validation or swapping fails. Delete uses the shared three-second destructive confirmation and removes only the selected `plugins/lumi_ai_*` directory.
|
||||||
|
|
||||||
## Improvement Center
|
## Improvement Center
|
||||||
|
|
||||||
The Improvement Center at `/plugins/lumi_ai/improvement_center` stores end-user response feedback, supports moderator verification with an administrator-managed trusted reviewer list, and reserves approval, editing, deletion, promotion, eval runs, and exports for administrators.
|
The Improvement Center at `/plugins/lumi_ai/improvement_center` stores end-user response feedback, supports moderator verification with an administrator-managed trusted reviewer list, and reserves approval, editing, deletion, promotion, eval runs, and exports for administrators.
|
||||||
@ -130,6 +163,7 @@ Run:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
node plugins/lumi_ai/tests/verify.js
|
node plugins/lumi_ai/tests/verify.js
|
||||||
|
node plugins/lumi_ai/tests/verify-tools.js
|
||||||
```
|
```
|
||||||
|
|
||||||
The verification covers path confinement, size formatting, GPU intent and actual allocation, pagination, model and log deletion safety, assistant role access, Improvement Center permissions and activation, approved-only exports, tool schema and permission checks, queue limits, refusal behavior, and runtime resume persistence.
|
The verification covers path confinement, size formatting, GPU intent and actual allocation, pagination, model and log deletion safety, assistant role access, Improvement Center permissions and activation, approved-only exports, tool schema and permission checks, queue limits, refusal behavior, and runtime resume persistence.
|
||||||
|
|||||||
@ -5,7 +5,7 @@ const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
|||||||
const PLUGIN_DATA = path.join(PLUGIN_ROOT, "data");
|
const PLUGIN_DATA = path.join(PLUGIN_ROOT, "data");
|
||||||
const DIRS = [
|
const DIRS = [
|
||||||
"config", "models", "runtime", "logs", "metrics", "rag", "repo_index", "cache", "tmp",
|
"config", "models", "runtime", "logs", "metrics", "rag", "repo_index", "cache", "tmp",
|
||||||
"diagnostics", "feedback", "corrections", "evals", "exports"
|
"diagnostics", "feedback", "corrections", "evals", "exports", "tools"
|
||||||
];
|
];
|
||||||
|
|
||||||
function ensureDataDirs() {
|
function ensureDataDirs() {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const CATEGORY_DIRS = {
|
|||||||
corrections: "corrections",
|
corrections: "corrections",
|
||||||
evals: "evals",
|
evals: "evals",
|
||||||
exports: "exports",
|
exports: "exports",
|
||||||
|
tools: "tools",
|
||||||
tmp: "tmp"
|
tmp: "tmp"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
209
plugins/lumi_ai/backend/tool_installer.js
Normal file
209
plugins/lumi_ai/backend/tool_installer.js
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { resolveData } = require("./paths");
|
||||||
|
const { assertToolId } = require("./tool_repo_client");
|
||||||
|
|
||||||
|
const REQUIRED_FIELDS = Object.freeze([
|
||||||
|
"tool_id",
|
||||||
|
"display_name",
|
||||||
|
"version",
|
||||||
|
"description",
|
||||||
|
"scope",
|
||||||
|
"permissions",
|
||||||
|
"capabilities",
|
||||||
|
"limitations"
|
||||||
|
]);
|
||||||
|
|
||||||
|
class ToolInstaller {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.repoClient = options.repoClient;
|
||||||
|
this.pluginsDir = options.pluginsDir || path.resolve(__dirname, "..", "..");
|
||||||
|
this.stagingRoot = options.stagingRoot || resolveData("tools", "staging");
|
||||||
|
}
|
||||||
|
|
||||||
|
scanLocal() {
|
||||||
|
if (!fs.existsSync(this.pluginsDir)) return [];
|
||||||
|
return fs.readdirSync(this.pluginsDir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory() && /^lumi_ai_[a-z0-9_-]+$/i.test(entry.name))
|
||||||
|
.map((entry) => {
|
||||||
|
const dir = path.join(this.pluginsDir, entry.name);
|
||||||
|
try {
|
||||||
|
return { tool_id: entry.name, dir, metadata: validateToolDirectory(dir, entry.name), valid: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { tool_id: entry.name, dir, metadata: fallbackMetadata(entry.name), valid: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
local(toolId) {
|
||||||
|
assertToolId(toolId);
|
||||||
|
return this.scanLocal().find((entry) => entry.tool_id === toolId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async install(toolId) {
|
||||||
|
if (this.local(toolId)) throw new Error("AI tool plugin is already installed.");
|
||||||
|
return this.stageAndSwap(toolId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(toolId) {
|
||||||
|
if (!this.local(toolId)) throw new Error("AI tool plugin is not installed.");
|
||||||
|
return this.stageAndSwap(toolId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stageAndSwap(toolId, updating) {
|
||||||
|
assertToolId(toolId);
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const stageBase = path.join(this.stagingRoot, id);
|
||||||
|
const stageDir = path.join(stageBase, toolId);
|
||||||
|
const target = path.join(this.pluginsDir, toolId);
|
||||||
|
const backup = path.join(this.pluginsDir, `.${toolId}.backup-${id}`);
|
||||||
|
fs.mkdirSync(stageDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
await this.repoClient.downloadTool(toolId, stageDir);
|
||||||
|
const remoteMetadata = validateToolDirectory(stageDir, toolId);
|
||||||
|
if (updating) preserveConfiguredPaths(target, stageDir, remoteMetadata);
|
||||||
|
let movedCurrent = false;
|
||||||
|
try {
|
||||||
|
if (updating) {
|
||||||
|
fs.renameSync(target, backup);
|
||||||
|
movedCurrent = true;
|
||||||
|
}
|
||||||
|
fs.renameSync(stageDir, target);
|
||||||
|
} catch (error) {
|
||||||
|
fs.rmSync(target, { recursive: true, force: true });
|
||||||
|
if (movedCurrent && fs.existsSync(backup)) fs.renameSync(backup, target);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
fs.rmSync(backup, { recursive: true, force: true });
|
||||||
|
return { tool_id: toolId, metadata: remoteMetadata, installed: true, updated: updating };
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(stageBase, { recursive: true, force: true });
|
||||||
|
if (fs.existsSync(backup) && !fs.existsSync(target)) fs.renameSync(backup, target);
|
||||||
|
else fs.rmSync(backup, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(toolId) {
|
||||||
|
assertToolId(toolId);
|
||||||
|
const target = path.resolve(this.pluginsDir, toolId);
|
||||||
|
if (path.dirname(target) !== path.resolve(this.pluginsDir)) throw new Error("Tool deletion path is unsafe.");
|
||||||
|
if (!fs.existsSync(target)) return false;
|
||||||
|
fs.rmSync(target, { recursive: true, force: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateToolDirectory(directory, folderName = path.basename(directory)) {
|
||||||
|
const metadataFile = path.join(directory, "tool_info.json");
|
||||||
|
const readmeFile = path.join(directory, "readme.md");
|
||||||
|
if (!fs.existsSync(metadataFile)) throw new Error("tool_info.json is required.");
|
||||||
|
if (!fs.existsSync(readmeFile)) throw new Error("readme.md is required.");
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = JSON.parse(fs.readFileSync(metadataFile, "utf8"));
|
||||||
|
} catch {
|
||||||
|
throw new Error("tool_info.json is invalid JSON.");
|
||||||
|
}
|
||||||
|
for (const field of REQUIRED_FIELDS) {
|
||||||
|
if (metadata[field] == null || metadata[field] === "") throw new Error(`tool_info.json is missing ${field}.`);
|
||||||
|
}
|
||||||
|
assertToolId(metadata.tool_id);
|
||||||
|
if (metadata.tool_id !== folderName) throw new Error("tool_id must match the plugin folder name.");
|
||||||
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(metadata.version))) {
|
||||||
|
throw new Error("tool_info.json version must be semantic version text.");
|
||||||
|
}
|
||||||
|
for (const field of ["capabilities", "limitations"]) {
|
||||||
|
if (!Array.isArray(metadata[field])) throw new Error(`${field} must be an array.`);
|
||||||
|
}
|
||||||
|
if (!["string", "object"].includes(typeof metadata.permissions)) throw new Error("permissions must be a string, array, or object.");
|
||||||
|
validateRelativeEntrypoints(metadata.entrypoints);
|
||||||
|
return normalizeMetadata(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMetadata(metadata) {
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
tool_type: String(metadata.tool_type || "general"),
|
||||||
|
owning_plugin: String(metadata.owning_plugin || metadata.tool_id),
|
||||||
|
capabilities: metadata.capabilities.map(String),
|
||||||
|
limitations: metadata.limitations.map(String),
|
||||||
|
required_plugins: arrayOfStrings(metadata.required_plugins),
|
||||||
|
required_platforms: arrayOfStrings(metadata.required_platforms),
|
||||||
|
dependencies: arrayOfStrings(metadata.dependencies),
|
||||||
|
data_paths: arrayOfStrings(metadata.data_paths),
|
||||||
|
risk_level: String(metadata.risk_level || "sensitive"),
|
||||||
|
confirmation_required: metadata.confirmation_required !== false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserveConfiguredPaths(source, destination, remoteMetadata) {
|
||||||
|
if (!fs.existsSync(source) || remoteMetadata.preserve_on_update === false) return;
|
||||||
|
let paths = ["data", "config"];
|
||||||
|
try {
|
||||||
|
const localMetadata = validateToolDirectory(source, path.basename(source));
|
||||||
|
paths.push(...localMetadata.data_paths);
|
||||||
|
if (Array.isArray(localMetadata.preserve_on_update)) paths.push(...localMetadata.preserve_on_update);
|
||||||
|
} catch {}
|
||||||
|
if (Array.isArray(remoteMetadata.preserve_on_update)) paths.push(...remoteMetadata.preserve_on_update);
|
||||||
|
for (const relative of [...new Set(paths.map(safeRelativePath).filter(Boolean))]) {
|
||||||
|
const current = path.join(source, relative);
|
||||||
|
const staged = path.join(destination, relative);
|
||||||
|
if (!fs.existsSync(current)) continue;
|
||||||
|
fs.rmSync(staged, { recursive: true, force: true });
|
||||||
|
copyPath(current, staged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPath(source, destination) {
|
||||||
|
const stat = fs.statSync(source);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(source)) copyPath(path.join(source, entry), path.join(destination, entry));
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||||
|
fs.copyFileSync(source, destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRelativeEntrypoints(entrypoints) {
|
||||||
|
if (!entrypoints) return;
|
||||||
|
const values = typeof entrypoints === "string" ? [entrypoints] : Object.values(entrypoints).flat();
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value !== "string" || !safeRelativePath(value)) throw new Error("Entrypoints must be safe relative paths.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRelativePath(value) {
|
||||||
|
const normalized = String(value || "").replaceAll("\\", "/").replace(/^\/+/, "");
|
||||||
|
if (!normalized || normalized.split("/").includes("..") || path.isAbsolute(normalized)) return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayOfStrings(value) {
|
||||||
|
return Array.isArray(value) ? value.map(String).map((entry) => entry.trim()).filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackMetadata(toolId) {
|
||||||
|
return {
|
||||||
|
tool_id: toolId,
|
||||||
|
display_name: toolId,
|
||||||
|
version: "0.0.0",
|
||||||
|
description: "",
|
||||||
|
scope: "unavailable",
|
||||||
|
permissions: [],
|
||||||
|
capabilities: [],
|
||||||
|
limitations: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
REQUIRED_FIELDS,
|
||||||
|
ToolInstaller,
|
||||||
|
arrayOfStrings,
|
||||||
|
fallbackMetadata,
|
||||||
|
normalizeMetadata,
|
||||||
|
preserveConfiguredPaths,
|
||||||
|
safeRelativePath,
|
||||||
|
validateToolDirectory
|
||||||
|
};
|
||||||
208
plugins/lumi_ai/backend/tool_loader.js
Normal file
208
plugins/lumi_ai/backend/tool_loader.js
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
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 result = await register({
|
||||||
|
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(/^\/+/, "")}`
|
||||||
|
});
|
||||||
|
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
|
||||||
|
};
|
||||||
139
plugins/lumi_ai/backend/tool_manager.js
Normal file
139
plugins/lumi_ai/backend/tool_manager.js
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { compareVersions } = require("./tool_loader");
|
||||||
|
|
||||||
|
class ToolManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.repoClient = options.repoClient;
|
||||||
|
this.installer = options.installer;
|
||||||
|
this.loader = options.loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list({ force = false } = {}) {
|
||||||
|
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,
|
||||||
|
dependency_status: dependencies,
|
||||||
|
primary_type: metadata?.tool_type || "general",
|
||||||
|
primary_scope: displayScope(metadata?.scope)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadEnabled() {
|
||||||
|
return this.loader.loadEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopAll() {
|
||||||
|
return this.loader.stopAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAsset(toolId, relative) {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ToolManager, displayScope };
|
||||||
73
plugins/lumi_ai/backend/tool_registry.js
Normal file
73
plugins/lumi_ai/backend/tool_registry.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
const { roleAllows } = require("./permissions");
|
||||||
|
|
||||||
|
const ROLE_RANK = Object.freeze({ user: 1, mod: 2, admin: 3 });
|
||||||
|
const FORBIDDEN_DEFINITION_KEYS = Object.freeze([
|
||||||
|
"shell",
|
||||||
|
"sql",
|
||||||
|
"filesystem",
|
||||||
|
"network",
|
||||||
|
"code_execution",
|
||||||
|
"eval"
|
||||||
|
]);
|
||||||
|
|
||||||
|
function registerManagedTool(registry, metadata, definition) {
|
||||||
|
validateManagedDefinition(metadata, definition);
|
||||||
|
const metadataRole = metadataRequiredRole(metadata.permissions);
|
||||||
|
const backendRole = normalizeRole(definition.required_role);
|
||||||
|
const requiredRole = stricterRole(metadataRole, backendRole);
|
||||||
|
const backendPermissionCheck = definition.permission_check;
|
||||||
|
const mutating = definition.mutating === true ||
|
||||||
|
["sensitive", "high", "destructive"].includes(String(definition.risk_level || metadata.risk_level || "").toLowerCase());
|
||||||
|
return registry.register({
|
||||||
|
...definition,
|
||||||
|
owning_plugin: metadata.tool_id,
|
||||||
|
required_role: requiredRole,
|
||||||
|
required_permission: String(definition.required_permission || `${metadata.tool_id}.use`),
|
||||||
|
audit_category: String(definition.audit_category || metadata.tool_type || "ai_tool"),
|
||||||
|
confirmation_required: mutating || metadata.confirmation_required === true
|
||||||
|
? true
|
||||||
|
: definition.confirmation_required !== false,
|
||||||
|
permission_check: (input) => {
|
||||||
|
const actualRole = input.user?.isAdmin ? "admin" : input.user?.isMod ? "mod" : "user";
|
||||||
|
if (!roleAllows(actualRole, requiredRole)) return false;
|
||||||
|
return backendPermissionCheck(input) === true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManagedDefinition(metadata, definition) {
|
||||||
|
if (!definition || typeof definition !== "object") throw new Error("AI tool definition is required.");
|
||||||
|
if (!String(definition.tool_id || "").startsWith(`${metadata.tool_id}.`)) {
|
||||||
|
throw new Error(`Registered tool IDs must use the ${metadata.tool_id}. namespace.`);
|
||||||
|
}
|
||||||
|
if (typeof definition.permission_check !== "function" || typeof definition.workflow_handler !== "function") {
|
||||||
|
throw new Error("Managed AI tools require backend permission and workflow handlers.");
|
||||||
|
}
|
||||||
|
for (const key of FORBIDDEN_DEFINITION_KEYS) {
|
||||||
|
if (definition[key] != null) throw new Error(`Managed AI tools cannot request generic ${key} access.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataRequiredRole(permissions) {
|
||||||
|
if (typeof permissions === "string") return normalizeRole(permissions);
|
||||||
|
if (Array.isArray(permissions)) {
|
||||||
|
return permissions.map(normalizeRole).sort((a, b) => ROLE_RANK[b] - ROLE_RANK[a])[0] || "user";
|
||||||
|
}
|
||||||
|
return normalizeRole(permissions?.required_role || permissions?.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRole(value) {
|
||||||
|
return Object.hasOwn(ROLE_RANK, value) ? value : "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stricterRole(left, right) {
|
||||||
|
return ROLE_RANK[left] >= ROLE_RANK[right] ? left : right;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
FORBIDDEN_DEFINITION_KEYS,
|
||||||
|
registerManagedTool,
|
||||||
|
validateManagedDefinition,
|
||||||
|
metadataRequiredRole,
|
||||||
|
stricterRole
|
||||||
|
};
|
||||||
271
plugins/lumi_ai/backend/tool_repo_client.js
Normal file
271
plugins/lumi_ai/backend/tool_repo_client.js
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { spawnSync } = require("child_process");
|
||||||
|
const { resolveData } = require("./paths");
|
||||||
|
|
||||||
|
const DEFAULT_REPOSITORY = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi";
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
class ToolRepoClient {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.settings = options.settings;
|
||||||
|
this.fetch = options.fetch || global.fetch;
|
||||||
|
this.repoRoot = options.repoRoot || path.resolve(__dirname, "..", "..", "..");
|
||||||
|
this.cacheFile = options.cacheFile || resolveData("tools", "remote-metadata.json");
|
||||||
|
this.now = options.now || Date.now;
|
||||||
|
this.runGit = options.runGit || ((args) => spawnSync("git", args, {
|
||||||
|
cwd: this.repoRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 10000
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
source() {
|
||||||
|
const configured = String(this.settings?.getSetting?.("git_remote", "origin") || "origin").trim();
|
||||||
|
const branch = String(this.settings?.getSetting?.("git_branch", "main") || "main").trim() || "main";
|
||||||
|
let repository = looksLikeRepositoryUrl(configured) ? configured : "";
|
||||||
|
if (!repository) {
|
||||||
|
const result = this.runGit(["remote", "get-url", configured]);
|
||||||
|
if (result?.status === 0) repository = String(result.stdout || "").trim();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
repository = normalizeRepositoryUrl(repository || DEFAULT_REPOSITORY);
|
||||||
|
} catch {
|
||||||
|
repository = normalizeRepositoryUrl(DEFAULT_REPOSITORY);
|
||||||
|
}
|
||||||
|
return { repository, branch, ...parseRepositoryUrl(repository) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async discover({ force = false } = {}) {
|
||||||
|
const source = this.source();
|
||||||
|
const cached = this.readCache();
|
||||||
|
const cacheMatches = cached?.repository === source.repository && cached?.branch === source.branch;
|
||||||
|
if (!force && cacheMatches && this.now() - Number(cached.checked_at_ms) < CACHE_TTL_MS) {
|
||||||
|
return { ...cached, cached: true, stale: false };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rows = await this.requestJson(this.contentsUrl(source, "plugins"));
|
||||||
|
const directories = (Array.isArray(rows) ? rows : [])
|
||||||
|
.filter((row) => row.type === "dir" && /^lumi_ai_[a-z0-9_-]+$/i.test(row.name));
|
||||||
|
const tools = await Promise.all(directories.map(async (row) => {
|
||||||
|
try {
|
||||||
|
const metadata = await this.readRemoteJson(row.name, "tool_info.json", source);
|
||||||
|
validateRemoteMetadata(metadata, row.name);
|
||||||
|
return { ...metadata, repository_path: metadata.repository_path || `plugins/${row.name}` };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
tool_id: row.name,
|
||||||
|
display_name: row.name,
|
||||||
|
version: "0.0.0",
|
||||||
|
description: "",
|
||||||
|
scope: "unavailable",
|
||||||
|
permissions: [],
|
||||||
|
capabilities: [],
|
||||||
|
limitations: [],
|
||||||
|
remote_invalid: true,
|
||||||
|
remote_error: error.message,
|
||||||
|
repository_path: `plugins/${row.name}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const payload = {
|
||||||
|
repository: source.repository,
|
||||||
|
branch: source.branch,
|
||||||
|
checked_at: new Date(this.now()).toISOString(),
|
||||||
|
checked_at_ms: this.now(),
|
||||||
|
tools
|
||||||
|
};
|
||||||
|
this.writeCache(payload);
|
||||||
|
return { ...payload, cached: false, stale: false };
|
||||||
|
} catch (error) {
|
||||||
|
if (cacheMatches) {
|
||||||
|
return { ...cached, cached: true, stale: true, error: error.message };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
repository: source.repository,
|
||||||
|
branch: source.branch,
|
||||||
|
checked_at: null,
|
||||||
|
checked_at_ms: 0,
|
||||||
|
tools: [],
|
||||||
|
cached: false,
|
||||||
|
stale: true,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readReadme(toolId) {
|
||||||
|
return this.readRemoteText(toolId, "readme.md", this.source());
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadTool(toolId, destination) {
|
||||||
|
assertToolId(toolId);
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
await this.downloadDirectory(`plugins/${toolId}`, destination, this.source());
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadDirectory(repositoryPath, destination, source) {
|
||||||
|
const entries = await this.requestJson(this.contentsUrl(source, repositoryPath));
|
||||||
|
if (!Array.isArray(entries)) throw new Error(`Remote path ${repositoryPath} is not a directory.`);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const target = safeChild(destination, entry.name);
|
||||||
|
if (entry.type === "dir") {
|
||||||
|
fs.mkdirSync(target, { recursive: true });
|
||||||
|
await this.downloadDirectory(entry.path, target, source);
|
||||||
|
} else if (entry.type === "file") {
|
||||||
|
const file = await this.requestJson(this.contentsUrl(source, entry.path));
|
||||||
|
const content = await this.readFileContent(file, source);
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readRemoteJson(toolId, filename, source = this.source()) {
|
||||||
|
return JSON.parse(await this.readRemoteText(toolId, filename, source));
|
||||||
|
}
|
||||||
|
|
||||||
|
async readRemoteText(toolId, filename, source = this.source()) {
|
||||||
|
assertToolId(toolId);
|
||||||
|
const file = await this.requestJson(this.contentsUrl(source, `plugins/${toolId}/${filename}`));
|
||||||
|
return (await this.readFileContent(file, source)).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFileContent(file, source) {
|
||||||
|
if (typeof file?.content === "string") return decodeContent(file);
|
||||||
|
if (!file?.download_url) throw new Error("Remote file content is unavailable.");
|
||||||
|
const download = new URL(file.download_url);
|
||||||
|
if (download.origin !== new URL(source.repository).origin) {
|
||||||
|
throw new Error("Remote file download URL is outside the configured repository host.");
|
||||||
|
}
|
||||||
|
return this.requestBuffer(download.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
contentsUrl(source, repositoryPath) {
|
||||||
|
const encodedPath = repositoryPath.split("/").map(encodeURIComponent).join("/");
|
||||||
|
return `${source.api_base}/contents/${encodedPath}?ref=${encodeURIComponent(source.branch)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestJson(url) {
|
||||||
|
if (typeof this.fetch !== "function") throw new Error("Remote repository access is unavailable.");
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 10000);
|
||||||
|
timer.unref?.();
|
||||||
|
try {
|
||||||
|
const response = await this.fetch(url, {
|
||||||
|
headers: { Accept: "application/json", "User-Agent": "Lumi-AI-Tool-Manager" },
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Repository request failed (${response.status}).`);
|
||||||
|
return response.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestBuffer(url) {
|
||||||
|
if (typeof this.fetch !== "function") throw new Error("Remote repository access is unavailable.");
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 10000);
|
||||||
|
timer.unref?.();
|
||||||
|
try {
|
||||||
|
const response = await this.fetch(url, {
|
||||||
|
headers: { Accept: "application/octet-stream", "User-Agent": "Lumi-AI-Tool-Manager" },
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Repository download failed (${response.status}).`);
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readCache() {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(fs.readFileSync(this.cacheFile, "utf8"));
|
||||||
|
return Array.isArray(value.tools) ? value : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeCache(value) {
|
||||||
|
fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true });
|
||||||
|
const temporary = `${this.cacheFile}.${process.pid}.tmp`;
|
||||||
|
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
fs.renameSync(temporary, this.cacheFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRepositoryUrl(value) {
|
||||||
|
let url = String(value || "").trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
||||||
|
const ssh = url.match(/^git@([^:]+):(.+)$/);
|
||||||
|
if (ssh) url = `https://${ssh[1]}/${ssh[2]}`;
|
||||||
|
if (url.startsWith("ssh://git@")) {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
url = `https://${parsed.hostname}${parsed.pathname}`;
|
||||||
|
}
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Configured Git repository must use HTTP(S) or a convertible SSH URL.");
|
||||||
|
parsed.username = "";
|
||||||
|
parsed.password = "";
|
||||||
|
return parsed.href.replace(/\/$/, "").replace(/\.git$/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRepositoryUrl(repository) {
|
||||||
|
const parsed = new URL(repository);
|
||||||
|
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||||
|
if (parts.length < 2) throw new Error("Configured repository URL must include owner and repository.");
|
||||||
|
const owner = parts.at(-2);
|
||||||
|
const repo = parts.at(-1);
|
||||||
|
return {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
api_base: `${parsed.origin}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeRepositoryUrl(value) {
|
||||||
|
return /^(?:https?:\/\/|ssh:\/\/|git@)/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeContent(file) {
|
||||||
|
if (!file || file.type !== "file" || typeof file.content !== "string") {
|
||||||
|
throw new Error("Remote file content is unavailable.");
|
||||||
|
}
|
||||||
|
if (file.encoding && file.encoding !== "base64") throw new Error(`Unsupported repository encoding: ${file.encoding}.`);
|
||||||
|
return Buffer.from(file.content.replace(/\s+/g, ""), "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertToolId(toolId) {
|
||||||
|
if (!/^lumi_ai_[a-z0-9_-]+$/i.test(String(toolId || ""))) throw new Error("Invalid Lumi AI tool plugin ID.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeChild(parent, name) {
|
||||||
|
if (!name || name !== path.basename(name)) throw new Error("Remote tool path is unsafe.");
|
||||||
|
const target = path.resolve(parent, name);
|
||||||
|
if (!target.startsWith(`${path.resolve(parent)}${path.sep}`)) throw new Error("Remote tool path escapes staging.");
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRemoteMetadata(metadata, folderName) {
|
||||||
|
for (const field of [
|
||||||
|
"tool_id", "display_name", "version", "description", "scope", "permissions", "capabilities", "limitations"
|
||||||
|
]) {
|
||||||
|
if (metadata?.[field] == null || metadata[field] === "") throw new Error(`tool_info.json is missing ${field}.`);
|
||||||
|
}
|
||||||
|
if (metadata.tool_id !== folderName) throw new Error("tool_id must match the remote folder name.");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CACHE_TTL_MS,
|
||||||
|
DEFAULT_REPOSITORY,
|
||||||
|
ToolRepoClient,
|
||||||
|
assertToolId,
|
||||||
|
decodeContent,
|
||||||
|
looksLikeRepositoryUrl,
|
||||||
|
normalizeRepositoryUrl,
|
||||||
|
parseRepositoryUrl,
|
||||||
|
validateRemoteMetadata
|
||||||
|
};
|
||||||
@ -5,7 +5,20 @@ class ToolRegistry {
|
|||||||
constructor(audit){ this.tools=new Map(); this.confirmations=new Map(); this.audit=audit; }
|
constructor(audit){ this.tools=new Map(); this.confirmations=new Map(); this.audit=audit; }
|
||||||
register(def){
|
register(def){
|
||||||
if(!def?.tool_id || !def.display_name || !def.description || !def.owning_plugin || !def.required_permission || !def.audit_category || typeof def.workflow_handler!=="function" || typeof def.permission_check!=="function" || !def.schema) throw new Error("Invalid AI tool definition.");
|
if(!def?.tool_id || !def.display_name || !def.description || !def.owning_plugin || !def.required_permission || !def.audit_category || typeof def.workflow_handler!=="function" || typeof def.permission_check!=="function" || !def.schema) throw new Error("Invalid AI tool definition.");
|
||||||
|
if(this.tools.has(def.tool_id)) throw new Error(`AI tool ${def.tool_id} is already registered.`);
|
||||||
this.tools.set(def.tool_id,{required_role:"user",confirmation_required:true,risk_level:"sensitive",...def});
|
this.tools.set(def.tool_id,{required_role:"user",confirmation_required:true,risk_level:"sensitive",...def});
|
||||||
|
return () => this.unregister(def.tool_id, def.owning_plugin);
|
||||||
|
}
|
||||||
|
unregister(toolId, owner = null){
|
||||||
|
const def=this.tools.get(toolId); if(!def || (owner && def.owning_plugin!==owner)) return false;
|
||||||
|
this.tools.delete(toolId);
|
||||||
|
for(const [id,pending] of this.confirmations){if(pending.def?.tool_id===toolId)this.confirmations.delete(id);}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
unregisterOwner(owner){
|
||||||
|
let removed=0;
|
||||||
|
for(const [id,def] of this.tools){if(def.owning_plugin===owner && this.unregister(id,owner))removed+=1;}
|
||||||
|
return removed;
|
||||||
}
|
}
|
||||||
list(role){ return [...this.tools.values()].filter(t=>roleAllows(role,t.required_role)).map(({workflow_handler,permission_check,...t})=>t); }
|
list(role){ return [...this.tools.values()].filter(t=>roleAllows(role,t.required_role)).map(({workflow_handler,permission_check,...t})=>t); }
|
||||||
validate(tool,args,role){
|
validate(tool,args,role){
|
||||||
|
|||||||
1
plugins/lumi_ai/data/tools/.gitkeep
Normal file
1
plugins/lumi_ai/data/tools/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
@ -29,6 +29,10 @@ const { FeedbackStore, FEEDBACK_TAGS, improvementAccess } = require("./backend/f
|
|||||||
const { CorrectionStore, PROMOTION_TARGETS } = require("./backend/corrections");
|
const { CorrectionStore, PROMOTION_TARGETS } = require("./backend/corrections");
|
||||||
const { EvalStore } = require("./backend/evals");
|
const { EvalStore } = require("./backend/evals");
|
||||||
const { TrainingExporter } = require("./backend/training_export");
|
const { TrainingExporter } = require("./backend/training_export");
|
||||||
|
const { ToolRepoClient } = require("./backend/tool_repo_client");
|
||||||
|
const { ToolInstaller } = require("./backend/tool_installer");
|
||||||
|
const { ToolLoader } = require("./backend/tool_loader");
|
||||||
|
const { ToolManager } = require("./backend/tool_manager");
|
||||||
const storage = require("./backend/storage");
|
const storage = require("./backend/storage");
|
||||||
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
|
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
|
||||||
|
|
||||||
@ -63,6 +67,20 @@ module.exports = {
|
|||||||
const queue = new RequestQueue(() => config);
|
const queue = new RequestQueue(() => config);
|
||||||
const requestJobs = new AssistantRequestJobs();
|
const requestJobs = new AssistantRequestJobs();
|
||||||
const tools = new ToolRegistry((entry) => metrics.record(entry));
|
const tools = new ToolRegistry((entry) => metrics.record(entry));
|
||||||
|
const toolRepoClient = new ToolRepoClient({ settings });
|
||||||
|
const toolInstaller = new ToolInstaller({ repoClient: toolRepoClient });
|
||||||
|
const toolLoader = new ToolLoader({
|
||||||
|
registry: tools,
|
||||||
|
installer: toolInstaller,
|
||||||
|
settings,
|
||||||
|
lumiAiVersion: require("./plugin.json").version,
|
||||||
|
lumiVersion: require("../../package.json").version
|
||||||
|
});
|
||||||
|
const toolManager = new ToolManager({
|
||||||
|
repoClient: toolRepoClient,
|
||||||
|
installer: toolInstaller,
|
||||||
|
loader: toolLoader
|
||||||
|
});
|
||||||
const contextProviders = new Map();
|
const contextProviders = new Map();
|
||||||
const frontendVisibility = new Map();
|
const frontendVisibility = new Map();
|
||||||
const getSafeContext = (role) => [...contextProviders.values()].flatMap((fn) => {
|
const getSafeContext = (role) => [...contextProviders.values()].flatMap((fn) => {
|
||||||
@ -188,6 +206,7 @@ module.exports = {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
registerTool: (definition) => tools.register(definition),
|
registerTool: (definition) => tools.register(definition),
|
||||||
|
unregisterToolOwner: (owner) => tools.unregisterOwner(owner),
|
||||||
registerContext: (id, factory) => {
|
registerContext: (id, factory) => {
|
||||||
if (!id || typeof factory !== "function") throw new Error("Invalid AI context provider.");
|
if (!id || typeof factory !== "function") throw new Error("Invalid AI context provider.");
|
||||||
contextProviders.set(id, factory);
|
contextProviders.set(id, factory);
|
||||||
@ -982,6 +1001,77 @@ module.exports = {
|
|||||||
res.json({ success: cancelled });
|
res.json({ success: cancelled });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/api/tools", async (req, res) => {
|
||||||
|
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
||||||
|
try {
|
||||||
|
return res.json(await toolManager.list({ force: req.query.refresh === "1" }));
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(503).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/tools/:id/readme", async (req, res) => {
|
||||||
|
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
||||||
|
try {
|
||||||
|
return res.json(await toolManager.readme(req.params.id));
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(404).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/tools/:id/assets/*", (req, res) => {
|
||||||
|
const permission = canUseAssistant({
|
||||||
|
user: req.session.user,
|
||||||
|
config,
|
||||||
|
origin: "webui",
|
||||||
|
platform: "webui",
|
||||||
|
requestedSurface: "webui_chat"
|
||||||
|
});
|
||||||
|
if (!permission.allowed) return res.status(403).end();
|
||||||
|
const file = toolManager.resolveAsset(req.params.id, req.params[0]);
|
||||||
|
return file ? res.sendFile(file) : res.status(404).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/tools/:id/enable", async (req, res) => {
|
||||||
|
if (!req.session.user?.isAdmin) return toolDenied(req, res);
|
||||||
|
try {
|
||||||
|
const result = await toolManager.enable(req.params.id);
|
||||||
|
return toolActionResponse(req, res, "AI tool enabled.", result);
|
||||||
|
} catch (error) {
|
||||||
|
return toolActionError(req, res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/tools/:id/disable", async (req, res) => {
|
||||||
|
if (!req.session.user?.isAdmin) return toolDenied(req, res);
|
||||||
|
try {
|
||||||
|
const result = await toolManager.disable(req.params.id);
|
||||||
|
return toolActionResponse(req, res, "AI tool disabled.", result);
|
||||||
|
} catch (error) {
|
||||||
|
return toolActionError(req, res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/tools/:id/update", async (req, res) => {
|
||||||
|
if (!req.session.user?.isAdmin) return toolDenied(req, res);
|
||||||
|
try {
|
||||||
|
const result = await toolManager.update(req.params.id);
|
||||||
|
return toolActionResponse(req, res, "AI tool updated.", result);
|
||||||
|
} catch (error) {
|
||||||
|
return toolActionError(req, res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/tools/:id/delete", async (req, res) => {
|
||||||
|
if (!req.session.user?.isAdmin) return toolDenied(req, res);
|
||||||
|
try {
|
||||||
|
const result = await toolManager.delete(req.params.id);
|
||||||
|
return toolActionResponse(req, res, "AI tool deleted.", result);
|
||||||
|
} catch (error) {
|
||||||
|
return toolActionError(req, res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/improvement_center", (req, res) => {
|
router.get("/improvement_center", (req, res) => {
|
||||||
const access = improvementAccess(req.session.user, config);
|
const access = improvementAccess(req.session.user, config);
|
||||||
if (!access.allowed) return deniedImprovement(res);
|
if (!access.allowed) return deniedImprovement(res);
|
||||||
@ -1222,6 +1312,9 @@ module.exports = {
|
|||||||
getBaseUrl: () => configuredBaseUrl(settings)
|
getBaseUrl: () => configuredBaseUrl(settings)
|
||||||
});
|
});
|
||||||
writeCommandsManifest(plugin?.dir || __dirname, config);
|
writeCommandsManifest(plugin?.dir || __dirname, config);
|
||||||
|
setImmediate(() => toolManager.loadEnabled().catch((error) =>
|
||||||
|
console.error("Lumi AI tool loader failed", error)
|
||||||
|
));
|
||||||
|
|
||||||
if (config.enabled) {
|
if (config.enabled) {
|
||||||
setImmediate(() => ensureGateRuntime().catch((error) =>
|
setImmediate(() => ensureGateRuntime().catch((error) =>
|
||||||
@ -1237,6 +1330,7 @@ module.exports = {
|
|||||||
clearInterval(gateMonitor);
|
clearInterval(gateMonitor);
|
||||||
removeAssistantPanel();
|
removeAssistantPanel();
|
||||||
commandRouter?.clearCommands?.(PLUGIN_ID);
|
commandRouter?.clearCommands?.(PLUGIN_ID);
|
||||||
|
await toolManager.stopAll();
|
||||||
await stopRuntimes({ manual: false, reason: "bot_shutdown" });
|
await stopRuntimes({ manual: false, reason: "bot_shutdown" });
|
||||||
if (global.lumiFrameworks?.ai === api) delete global.lumiFrameworks.ai;
|
if (global.lumiFrameworks?.ai === api) delete global.lumiFrameworks.ai;
|
||||||
if (global.lumiFrameworks?.lumi_ai === api) delete global.lumiFrameworks.lumi_ai;
|
if (global.lumiFrameworks?.lumi_ai === api) delete global.lumiFrameworks.lumi_ai;
|
||||||
@ -1267,6 +1361,20 @@ function flash(req, res, type, message) {
|
|||||||
req.session.flash = { type, message };
|
req.session.flash = { type, message };
|
||||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||||
}
|
}
|
||||||
|
function toolActionResponse(req, res, message, result = {}) {
|
||||||
|
if (req.accepts(["json", "html"]) === "json") return res.json({ success: true, message, result });
|
||||||
|
req.session.flash = { type: "success", message };
|
||||||
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||||
|
}
|
||||||
|
function toolActionError(req, res, error) {
|
||||||
|
if (req.accepts(["json", "html"]) === "json") return res.status(400).json({ error: error.message });
|
||||||
|
req.session.flash = { type: "error", message: error.message };
|
||||||
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||||
|
}
|
||||||
|
function toolDenied(req, res) {
|
||||||
|
if (req.accepts(["json", "html"]) === "json") return res.status(403).json({ error: "Access denied." });
|
||||||
|
return denied(res);
|
||||||
|
}
|
||||||
function improvementFlash(req, res, type, message) {
|
function improvementFlash(req, res, type, message) {
|
||||||
req.session.flash = { type, message };
|
req.session.flash = { type, message };
|
||||||
return res.redirect(`/plugins/${PLUGIN_ID}/improvement_center`);
|
return res.redirect(`/plugins/${PLUGIN_ID}/improvement_center`);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_ai",
|
"id": "lumi_ai",
|
||||||
"name": "Lumi AI",
|
"name": "Lumi AI",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"description": "Managed local AI provider and scoped WebUI assistant for Lumi.",
|
"description": "Managed local AI provider and scoped WebUI assistant for Lumi.",
|
||||||
"main": "index.js"
|
"main": "index.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,32 @@
|
|||||||
.ai-test-output { max-height: 420px; overflow: auto; margin-top: 14px; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
.ai-test-output { max-height: 420px; overflow: auto; margin-top: 14px; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
.ai-remediation { margin: 14px 0; padding-left: 24px; }
|
.ai-remediation { margin: 14px 0; padding-left: 24px; }
|
||||||
.ai-raw-diagnostic pre { max-height: 420px; overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
.ai-raw-diagnostic pre { max-height: 420px; overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.ai-tools-modal { z-index: 110; }
|
||||||
|
.ai-tool-readme-modal { z-index: 120; }
|
||||||
|
.ai-tools-dialog { width: min(1180px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; }
|
||||||
|
.ai-tools-dialog .modal-header p { margin: 4px 0 0; color: var(--ink-soft); }
|
||||||
|
.ai-tools-source { margin: 10px 0; color: var(--ink-soft); font-size: 12px; overflow-wrap: anywhere; }
|
||||||
|
.ai-tools-source.error { color: var(--rose); }
|
||||||
|
.ai-tools-list { display: grid; gap: 9px; }
|
||||||
|
.ai-tool-row { border: 1px solid var(--border); border-radius: 7px; background: var(--card); }
|
||||||
|
.ai-tool-summary { display: grid; grid-template-columns: minmax(170px, 1fr) minmax(280px, 1.5fr) minmax(130px, .7fr) auto; align-items: center; gap: 12px; padding: 11px; }
|
||||||
|
.ai-tool-identity span { display: block; margin-top: 3px; color: var(--ink-soft); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.ai-tool-versions, .ai-tool-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||||
|
.ai-tool-version, .ai-tool-scope { color: var(--ink-soft); font-size: 12px; }
|
||||||
|
.ai-tool-actions { justify-content: flex-end; }
|
||||||
|
.ai-tool-actions form { margin: 0; }
|
||||||
|
.ai-tool-actions .button.update { border-color: var(--sun); color: var(--sun); font-weight: 800; }
|
||||||
|
.ai-tool-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; border-top: 1px solid var(--border); background: var(--border); }
|
||||||
|
.ai-tool-details[hidden] { display: none; }
|
||||||
|
.ai-tool-details > div { min-width: 0; padding: 9px 11px; background: var(--surface-2); }
|
||||||
|
.ai-tool-details strong, .ai-tool-details span { display: block; overflow-wrap: anywhere; }
|
||||||
|
.ai-tool-details strong { margin-bottom: 3px; font-size: 12px; }
|
||||||
|
.ai-tool-details span { color: var(--ink-soft); font-size: 12px; }
|
||||||
|
.ai-tool-readme-dialog { width: min(900px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; }
|
||||||
|
.ai-tool-readme { min-height: 160px; line-height: 1.55; }
|
||||||
|
.ai-tool-readme pre { max-height: 420px; padding: 12px; overflow: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-3); white-space: pre; }
|
||||||
|
.ai-tool-readme code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.ai-tool-readme a { color: var(--sea); font-weight: 700; }
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.ai-titlebar, .ai-section-heading { align-items: flex-start; flex-direction: column; }
|
.ai-titlebar, .ai-section-heading { align-items: flex-start; flex-direction: column; }
|
||||||
.ai-stat-grid { grid-template-columns: 1fr 1fr; }
|
.ai-stat-grid { grid-template-columns: 1fr 1fr; }
|
||||||
@ -91,4 +117,7 @@
|
|||||||
.ai-panel-render-diagnostic { grid-template-columns: 1fr; }
|
.ai-panel-render-diagnostic { grid-template-columns: 1fr; }
|
||||||
.ai-access-form { grid-template-columns: 1fr; max-width: none; }
|
.ai-access-form { grid-template-columns: 1fr; max-width: none; }
|
||||||
.ai-user-picker { grid-column: auto; }
|
.ai-user-picker { grid-column: auto; }
|
||||||
|
.ai-tool-summary { grid-template-columns: 1fr; }
|
||||||
|
.ai-tool-actions { justify-content: flex-start; }
|
||||||
|
.ai-tool-details { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
317
plugins/lumi_ai/public/tool-manager.js
Normal file
317
plugins/lumi_ai/public/tool-manager.js
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
(() => {
|
||||||
|
const openButton = document.querySelector("[data-ai-tools-open]");
|
||||||
|
const modal = document.querySelector("[data-ai-tools-modal]");
|
||||||
|
const list = modal?.querySelector("[data-ai-tools-list]");
|
||||||
|
const source = modal?.querySelector("[data-ai-tools-source]");
|
||||||
|
const refresh = modal?.querySelector("[data-ai-tools-refresh]");
|
||||||
|
const readmeModal = document.querySelector("[data-ai-tool-readme-modal]");
|
||||||
|
const readmeTitle = readmeModal?.querySelector("[data-ai-tool-readme-title]");
|
||||||
|
const readmeBody = readmeModal?.querySelector("[data-ai-tool-readme]");
|
||||||
|
if (!openButton || !modal || !list || !source || !readmeModal || !readmeTitle || !readmeBody) return;
|
||||||
|
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
const setOpen = (target, open) => {
|
||||||
|
target.classList.toggle("is-open", open);
|
||||||
|
target.setAttribute("aria-hidden", String(!open));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTools = async (force = false) => {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
list.replaceChildren(message("Loading AI tool plugins..."));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/plugins/lumi_ai/api/tools${force ? "?refresh=1" : ""}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Accept: "application/json" }
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) throw new Error(payload.error || "Unable to load AI tools.");
|
||||||
|
source.textContent = `${payload.repository} · ${payload.branch} · checked ${payload.checked_at ? new Date(payload.checked_at).toLocaleString() : "never"}${payload.cached ? " · cached" : ""}${payload.stale ? " · stale" : ""}`;
|
||||||
|
source.classList.toggle("error", Boolean(payload.error));
|
||||||
|
if (payload.error) source.title = payload.error;
|
||||||
|
renderTools(payload.tools || []);
|
||||||
|
} catch (error) {
|
||||||
|
list.replaceChildren(message(error.message, true));
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTools = (tools) => {
|
||||||
|
list.replaceChildren();
|
||||||
|
if (!tools.length) {
|
||||||
|
list.append(message("No local or remote Lumi AI tool plugins were found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const tool of tools) list.append(renderTool(tool));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTool = (tool) => {
|
||||||
|
const row = document.createElement("article");
|
||||||
|
row.className = "ai-tool-row";
|
||||||
|
const summary = document.createElement("div");
|
||||||
|
summary.className = "ai-tool-summary";
|
||||||
|
const identity = document.createElement("div");
|
||||||
|
identity.className = "ai-tool-identity";
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
name.textContent = tool.display_name || tool.tool_id;
|
||||||
|
const id = document.createElement("span");
|
||||||
|
id.textContent = tool.tool_id;
|
||||||
|
identity.append(name, id);
|
||||||
|
|
||||||
|
const versions = document.createElement("div");
|
||||||
|
versions.className = "ai-tool-versions";
|
||||||
|
versions.append(
|
||||||
|
badge(tool.installed ? "Installed" : "Remote", tool.installed ? "installed" : ""),
|
||||||
|
badge(tool.enabled ? "Enabled" : "Disabled", tool.enabled ? "installed" : ""),
|
||||||
|
textPair("Local", tool.local_version || "-"),
|
||||||
|
textPair("Remote", tool.remote_version || "-"),
|
||||||
|
badge(tool.remote_missing ? "Remote missing" : tool.update_available ? "Update available" : "Current", tool.update_available ? "warning" : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
const scope = document.createElement("div");
|
||||||
|
scope.className = "ai-tool-scope";
|
||||||
|
scope.textContent = `${tool.primary_type || "general"} · ${tool.primary_scope || "unspecified"}`;
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "ai-tool-actions";
|
||||||
|
const expand = button("Details", "subtle");
|
||||||
|
const details = renderDetails(tool);
|
||||||
|
expand.addEventListener("click", () => {
|
||||||
|
details.hidden = !details.hidden;
|
||||||
|
expand.textContent = details.hidden ? "Details" : "Hide details";
|
||||||
|
});
|
||||||
|
const inspect = button("Inspect", "subtle");
|
||||||
|
inspect.addEventListener("click", () => inspectReadme(tool));
|
||||||
|
const enable = button(tool.enabled ? "Disable" : "Enable", tool.enabled ? "subtle" : "");
|
||||||
|
enable.disabled = tool.installed && !tool.local_valid;
|
||||||
|
enable.addEventListener("click", () => runAction(tool, tool.enabled ? "disable" : "enable", enable));
|
||||||
|
const update = button("Update", tool.update_available ? "update" : "subtle");
|
||||||
|
update.disabled = !tool.update_enabled;
|
||||||
|
update.title = !tool.installed ? "Install the tool before updating." : tool.remote_missing ? "This tool is missing remotely." : "";
|
||||||
|
update.addEventListener("click", () => runAction(tool, "update", update));
|
||||||
|
actions.append(expand, inspect, enable, update);
|
||||||
|
if (tool.installed) actions.append(deleteForm(tool));
|
||||||
|
|
||||||
|
summary.append(identity, versions, scope, actions);
|
||||||
|
row.append(summary, details);
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDetails = (tool) => {
|
||||||
|
const details = document.createElement("div");
|
||||||
|
details.className = "ai-tool-details";
|
||||||
|
details.hidden = true;
|
||||||
|
details.append(
|
||||||
|
detail("Description", tool.description || "No description."),
|
||||||
|
detail("Capabilities", joinValue(tool.capabilities)),
|
||||||
|
detail("Limitations", joinValue(tool.limitations)),
|
||||||
|
detail("Scope", joinValue(tool.scope)),
|
||||||
|
detail("Permissions", joinValue(tool.permissions)),
|
||||||
|
detail("Dependencies", joinValue(tool.dependencies)),
|
||||||
|
detail("Required plugins", joinValue(tool.required_plugins)),
|
||||||
|
detail("Required platforms", joinValue(tool.required_platforms)),
|
||||||
|
detail("Dependency availability", dependencyStatus(tool.dependency_status)),
|
||||||
|
detail("Risk / confirmation", `${tool.risk_level || "sensitive"} / ${tool.confirmation_required === false ? "not required" : "required"}`),
|
||||||
|
detail("Runtime", `${tool.runtime_state || "unknown"}${tool.runtime_message ? `: ${tool.runtime_message}` : ""}`),
|
||||||
|
detail("Install status", tool.local_error || tool.remote_error || (tool.remote_missing ? "Installed locally; missing from remote repository." : "Ready"))
|
||||||
|
);
|
||||||
|
return details;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAction = async (tool, action, control) => {
|
||||||
|
control.disabled = true;
|
||||||
|
const original = control.textContent;
|
||||||
|
control.textContent = action === "enable" ? "Enabling..." : action === "disable" ? "Disabling..." : "Updating...";
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/plugins/lumi_ai/tools/${encodeURIComponent(tool.tool_id)}/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" }
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) throw new Error(payload.error || `${action} failed.`);
|
||||||
|
await loadTools(false);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error.message);
|
||||||
|
control.disabled = false;
|
||||||
|
control.textContent = original;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteForm = (tool) => {
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "post";
|
||||||
|
form.action = `/plugins/lumi_ai/tools/${encodeURIComponent(tool.tool_id)}/delete`;
|
||||||
|
form.dataset.confirmMode = "modal";
|
||||||
|
form.dataset.confirmTitle = `Delete ${tool.display_name || tool.tool_id}?`;
|
||||||
|
form.dataset.confirmText = "The installed AI tool files will be removed. Shared Lumi AI data and unrelated plugins are not affected.";
|
||||||
|
const remove = button("Delete", "danger");
|
||||||
|
remove.type = "submit";
|
||||||
|
form.append(remove);
|
||||||
|
return form;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inspectReadme = async (tool) => {
|
||||||
|
readmeTitle.textContent = `${tool.display_name || tool.tool_id} documentation`;
|
||||||
|
readmeBody.replaceChildren(message("Loading readme.md..."));
|
||||||
|
setOpen(readmeModal, true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/plugins/lumi_ai/api/tools/${encodeURIComponent(tool.tool_id)}/readme`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Accept: "application/json" }
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) throw new Error(payload.error || "Unable to load readme.md.");
|
||||||
|
renderMarkdown(readmeBody, payload.markdown);
|
||||||
|
} catch (error) {
|
||||||
|
readmeBody.replaceChildren(message(error.message, true));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMarkdown = (container, markdown) => {
|
||||||
|
container.replaceChildren();
|
||||||
|
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
|
||||||
|
let paragraph = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (!paragraph.length) return;
|
||||||
|
const p = document.createElement("p");
|
||||||
|
appendInline(p, paragraph.join(" "));
|
||||||
|
container.append(p);
|
||||||
|
paragraph = [];
|
||||||
|
};
|
||||||
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
|
const fence = lines[index].match(/^```([a-z0-9_+-]*)/i);
|
||||||
|
if (fence) {
|
||||||
|
flush();
|
||||||
|
const code = [];
|
||||||
|
for (index += 1; index < lines.length && !/^```/.test(lines[index]); index += 1) code.push(lines[index]);
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
const element = document.createElement("code");
|
||||||
|
element.textContent = code.join("\n");
|
||||||
|
element.className = `language-${fence[1] || "text"}`;
|
||||||
|
pre.append(element);
|
||||||
|
container.append(pre);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const heading = lines[index].match(/^(#{1,4})\s+(.+)/);
|
||||||
|
if (heading) {
|
||||||
|
flush();
|
||||||
|
const element = document.createElement(`h${heading[1].length}`);
|
||||||
|
appendInline(element, heading[2]);
|
||||||
|
container.append(element);
|
||||||
|
} else if (/^\s*[-*]\s+/.test(lines[index])) {
|
||||||
|
flush();
|
||||||
|
const list = container.lastElementChild?.tagName === "UL" ? container.lastElementChild : document.createElement("ul");
|
||||||
|
const item = document.createElement("li");
|
||||||
|
appendInline(item, lines[index].replace(/^\s*[-*]\s+/, ""));
|
||||||
|
list.append(item);
|
||||||
|
if (!list.isConnected) container.append(list);
|
||||||
|
} else if (!lines[index].trim()) {
|
||||||
|
flush();
|
||||||
|
} else {
|
||||||
|
paragraph.push(lines[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendInline = (parent, value) => {
|
||||||
|
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\([^)]+\))/g;
|
||||||
|
let offset = 0;
|
||||||
|
for (const match of String(value).matchAll(pattern)) {
|
||||||
|
parent.append(document.createTextNode(value.slice(offset, match.index)));
|
||||||
|
const token = match[0];
|
||||||
|
if (token.startsWith("`")) {
|
||||||
|
const code = document.createElement("code");
|
||||||
|
code.textContent = token.slice(1, -1);
|
||||||
|
parent.append(code);
|
||||||
|
} else if (token.startsWith("**")) {
|
||||||
|
const strong = document.createElement("strong");
|
||||||
|
strong.textContent = token.slice(2, -2);
|
||||||
|
parent.append(strong);
|
||||||
|
} else {
|
||||||
|
const parts = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||||
|
const anchor = safeAnchor(parts?.[2], parts?.[1]);
|
||||||
|
parent.append(anchor || document.createTextNode(parts?.[1] || token));
|
||||||
|
}
|
||||||
|
offset = match.index + token.length;
|
||||||
|
}
|
||||||
|
parent.append(document.createTextNode(String(value).slice(offset)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeAnchor = (href, label) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(href, window.location.origin);
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol)) return null;
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = parsed.href;
|
||||||
|
anchor.textContent = label;
|
||||||
|
if (parsed.origin !== window.location.origin) {
|
||||||
|
anchor.target = "_blank";
|
||||||
|
anchor.rel = "noopener noreferrer";
|
||||||
|
}
|
||||||
|
return anchor;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const detail = (label, value) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
const term = document.createElement("strong");
|
||||||
|
const body = document.createElement("span");
|
||||||
|
term.textContent = label;
|
||||||
|
body.textContent = value || "-";
|
||||||
|
item.append(term, body);
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
const dependencyStatus = (value) => {
|
||||||
|
const blocking = Array.isArray(value?.blocking) ? value.blocking : [];
|
||||||
|
const optional = Array.isArray(value?.optional) ? value.optional : [];
|
||||||
|
if (!blocking.length && !optional.length) return "Available";
|
||||||
|
return [
|
||||||
|
blocking.length ? `Blocking: ${blocking.join("; ")}` : "",
|
||||||
|
optional.length ? `Optional: ${optional.join("; ")}` : ""
|
||||||
|
].filter(Boolean).join(" | ");
|
||||||
|
};
|
||||||
|
const joinValue = (value) => Array.isArray(value) ? value.join(", ") || "-" : value && typeof value === "object" ? JSON.stringify(value) : String(value || "-");
|
||||||
|
const badge = (text, className = "") => {
|
||||||
|
const element = document.createElement("span");
|
||||||
|
element.className = `ai-tag ${className}`.trim();
|
||||||
|
element.textContent = text;
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
const textPair = (label, value) => {
|
||||||
|
const element = document.createElement("span");
|
||||||
|
element.className = "ai-tool-version";
|
||||||
|
element.textContent = `${label} ${value}`;
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
const button = (text, className = "") => {
|
||||||
|
const element = document.createElement("button");
|
||||||
|
element.type = "button";
|
||||||
|
element.className = `button ${className}`.trim();
|
||||||
|
element.textContent = text;
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
const message = (text, error = false) => {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.className = `callout${error ? " danger" : ""}`;
|
||||||
|
element.textContent = text;
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
openButton.addEventListener("click", () => {
|
||||||
|
setOpen(modal, true);
|
||||||
|
loadTools(false);
|
||||||
|
});
|
||||||
|
refresh?.addEventListener("click", () => loadTools(true));
|
||||||
|
modal.querySelectorAll("[data-ai-tools-close]").forEach((control) => control.addEventListener("click", () => setOpen(modal, false)));
|
||||||
|
readmeModal.querySelectorAll("[data-ai-tool-readme-close]").forEach((control) => control.addEventListener("click", () => setOpen(readmeModal, false)));
|
||||||
|
for (const target of [modal, readmeModal]) {
|
||||||
|
target.addEventListener("click", (event) => {
|
||||||
|
if (event.target === target) setOpen(target, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
303
plugins/lumi_ai/tests/verify-tools.js
Normal file
303
plugins/lumi_ai/tests/verify-tools.js
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
const { ToolRegistry } = require("../backend/tool_router");
|
||||||
|
const { registerManagedTool } = require("../backend/tool_registry");
|
||||||
|
const { ToolRepoClient, CACHE_TTL_MS } = require("../backend/tool_repo_client");
|
||||||
|
const { ToolInstaller, validateToolDirectory } = require("../backend/tool_installer");
|
||||||
|
const { ToolLoader } = require("../backend/tool_loader");
|
||||||
|
const { ToolManager } = require("../backend/tool_manager");
|
||||||
|
const { isDestructivePath, issueConfirmation, consumeConfirmation } = require("../../../src/services/destructive-confirm");
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-ai-tools-"));
|
||||||
|
const pluginsDir = path.join(root, "plugins");
|
||||||
|
const stagingRoot = path.join(root, "manager-data", "staging");
|
||||||
|
const stateFile = path.join(root, "manager-data", "enabled.json");
|
||||||
|
const remoteDir = path.join(root, "remote");
|
||||||
|
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(remoteDir, { recursive: true });
|
||||||
|
|
||||||
|
const remoteTools = new Map();
|
||||||
|
createTool(path.join(remoteDir, "lumi_ai_weather"), metadata("lumi_ai_weather", "1.0.0"), backendSource());
|
||||||
|
remoteTools.set("lumi_ai_weather", metadata("lumi_ai_weather", "1.0.0"));
|
||||||
|
const repoClient = {
|
||||||
|
calls: 0,
|
||||||
|
fail: false,
|
||||||
|
async discover() {
|
||||||
|
return {
|
||||||
|
repository: "https://git.example/owner/lumi",
|
||||||
|
branch: "main",
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
cached: false,
|
||||||
|
stale: false,
|
||||||
|
tools: [...remoteTools.values()]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async downloadTool(toolId, destination) {
|
||||||
|
this.calls += 1;
|
||||||
|
if (this.fail) {
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(destination, "tool_info.json"), "{}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyDirectory(path.join(remoteDir, toolId), destination);
|
||||||
|
},
|
||||||
|
async readReadme() {
|
||||||
|
return "# Weather\nRemote documentation.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const registry = new ToolRegistry(() => {});
|
||||||
|
const installer = new ToolInstaller({ repoClient, pluginsDir, stagingRoot });
|
||||||
|
const settings = { getSetting: (key, fallback) => key === "platform_discord_enabled" ? true : fallback };
|
||||||
|
const loader = new ToolLoader({
|
||||||
|
registry,
|
||||||
|
installer,
|
||||||
|
settings,
|
||||||
|
stateFile,
|
||||||
|
lumiAiVersion: "0.7.0",
|
||||||
|
lumiVersion: "0.1.0"
|
||||||
|
});
|
||||||
|
const manager = new ToolManager({ repoClient, installer, loader });
|
||||||
|
|
||||||
|
let listing = await manager.list();
|
||||||
|
assert.equal(listing.tools.length, 1);
|
||||||
|
assert.equal(listing.tools[0].installed, false);
|
||||||
|
assert.equal(listing.tools[0].update_enabled, false);
|
||||||
|
|
||||||
|
const enabled = await manager.enable("lumi_ai_weather");
|
||||||
|
assert.equal(enabled.loaded, true);
|
||||||
|
assert.equal(registry.tools.has("lumi_ai_weather.lookup"), true);
|
||||||
|
listing = await manager.list();
|
||||||
|
assert.equal(listing.tools[0].installed, true);
|
||||||
|
assert.equal(listing.tools[0].enabled, true);
|
||||||
|
|
||||||
|
await manager.disable("lumi_ai_weather");
|
||||||
|
assert.equal(registry.tools.has("lumi_ai_weather.lookup"), false);
|
||||||
|
assert.equal(fs.existsSync(path.join(pluginsDir, "lumi_ai_weather")), true);
|
||||||
|
listing = await manager.list();
|
||||||
|
assert.equal(listing.tools[0].enabled, false);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(pluginsDir, "lumi_ai_weather", "data"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(pluginsDir, "lumi_ai_weather", "data", "local.json"), "preserve");
|
||||||
|
createTool(path.join(remoteDir, "lumi_ai_weather"), metadata("lumi_ai_weather", "1.1.0"), backendSource("updated"));
|
||||||
|
remoteTools.set("lumi_ai_weather", metadata("lumi_ai_weather", "1.1.0"));
|
||||||
|
listing = await manager.list();
|
||||||
|
assert.equal(listing.tools[0].update_available, true);
|
||||||
|
await manager.update("lumi_ai_weather");
|
||||||
|
assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0");
|
||||||
|
assert.equal(fs.readFileSync(path.join(pluginsDir, "lumi_ai_weather", "data", "local.json"), "utf8"), "preserve");
|
||||||
|
|
||||||
|
repoClient.fail = true;
|
||||||
|
await assert.rejects(() => manager.update("lumi_ai_weather"), /required|missing/i);
|
||||||
|
assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0");
|
||||||
|
repoClient.fail = false;
|
||||||
|
|
||||||
|
createTool(path.join(pluginsDir, "lumi_ai_local"), metadata("lumi_ai_local", "2.0.0"), backendSource());
|
||||||
|
listing = await manager.list();
|
||||||
|
const localOnly = listing.tools.find((tool) => tool.tool_id === "lumi_ai_local");
|
||||||
|
assert.equal(localOnly.remote_missing, true);
|
||||||
|
assert.equal(localOnly.update_enabled, false);
|
||||||
|
|
||||||
|
createTool(
|
||||||
|
path.join(pluginsDir, "lumi_ai_optional"),
|
||||||
|
{ ...metadata("lumi_ai_optional", "1.0.0"), dependencies: ["module-that-does-not-exist"] },
|
||||||
|
backendSource("ok", "lumi_ai_optional")
|
||||||
|
);
|
||||||
|
const optionalResult = await loader.enable("lumi_ai_optional");
|
||||||
|
assert.equal(optionalResult.loaded, true);
|
||||||
|
assert(optionalResult.dependencies.optional.some((entry) => entry.includes("module-that-does-not-exist")));
|
||||||
|
assert.match(loader.status("lumi_ai_optional").message, /limitations/);
|
||||||
|
|
||||||
|
createTool(
|
||||||
|
path.join(pluginsDir, "lumi_ai_cross_dependency"),
|
||||||
|
{ ...metadata("lumi_ai_cross_dependency", "1.0.0"), required_plugins: ["lumi_ai_weather"] },
|
||||||
|
backendSource("ok", "lumi_ai_cross_dependency")
|
||||||
|
);
|
||||||
|
const blockedDependency = await loader.enable("lumi_ai_cross_dependency");
|
||||||
|
assert.equal(blockedDependency.unavailable, true);
|
||||||
|
assert.match(blockedDependency.message, /cannot depend on AI tool plugin/);
|
||||||
|
|
||||||
|
createTool(
|
||||||
|
path.join(pluginsDir, "lumi_ai_partial"),
|
||||||
|
metadata("lumi_ai_partial", "1.0.0"),
|
||||||
|
`module.exports.register = ({ registerTool }) => {
|
||||||
|
registerTool(${serializeDefinition("lumi_ai_partial.first")});
|
||||||
|
registerTool(${serializeDefinition("outside_namespace.second")});
|
||||||
|
};\n`
|
||||||
|
);
|
||||||
|
const partialResult = await loader.enable("lumi_ai_partial");
|
||||||
|
assert.equal(partialResult.unavailable, true);
|
||||||
|
assert.equal(registry.tools.has("lumi_ai_partial.first"), false);
|
||||||
|
|
||||||
|
const strictRegistry = new ToolRegistry(() => {});
|
||||||
|
registerManagedTool(strictRegistry, {
|
||||||
|
...metadata("lumi_ai_strict", "1.0.0"),
|
||||||
|
permissions: { required_role: "admin" }
|
||||||
|
}, definition("lumi_ai_strict.lookup", "user"));
|
||||||
|
assert.throws(() => strictRegistry.prepare({
|
||||||
|
tool: "lumi_ai_strict.lookup",
|
||||||
|
args: {},
|
||||||
|
user: { id: "ordinary-user" },
|
||||||
|
role: "user",
|
||||||
|
sessionId: "session"
|
||||||
|
}), /Permission denied/);
|
||||||
|
assert.equal(strictRegistry.unregisterOwner("lumi_ai_strict"), 1);
|
||||||
|
|
||||||
|
const unrelated = path.join(pluginsDir, "ordinary-plugin");
|
||||||
|
fs.mkdirSync(unrelated);
|
||||||
|
assert.equal(isDestructivePath("/plugins/lumi_ai/tools/lumi_ai_weather/delete"), true);
|
||||||
|
const request = fakeRequest();
|
||||||
|
const confirmation = issueConfirmation(request, "/plugins/lumi_ai/tools/lumi_ai_weather/delete");
|
||||||
|
assert.equal(consumeConfirmation(request, "/plugins/lumi_ai/tools/lumi_ai_weather/delete", confirmation.token).reason, "too_early");
|
||||||
|
await manager.delete("lumi_ai_weather");
|
||||||
|
assert.equal(fs.existsSync(path.join(pluginsDir, "lumi_ai_weather")), false);
|
||||||
|
assert.equal(fs.existsSync(unrelated), true);
|
||||||
|
|
||||||
|
repoClient.fail = true;
|
||||||
|
remoteTools.set("lumi_ai_broken", metadata("lumi_ai_broken", "1.0.0"));
|
||||||
|
await assert.rejects(() => manager.enable("lumi_ai_broken"), /required|missing/i);
|
||||||
|
assert.equal(fs.existsSync(path.join(pluginsDir, "lumi_ai_broken")), false);
|
||||||
|
|
||||||
|
await verifyRemoteCache(root);
|
||||||
|
|
||||||
|
const settingsTemplate = fs.readFileSync(path.join(__dirname, "..", "views", "settings.ejs"), "utf8");
|
||||||
|
const modalTemplate = fs.readFileSync(path.join(__dirname, "..", "views", "tool-modal.ejs"), "utf8");
|
||||||
|
const clientScript = fs.readFileSync(path.join(__dirname, "..", "public", "tool-manager.js"), "utf8");
|
||||||
|
const pluginLoader = fs.readFileSync(path.join(__dirname, "..", "..", "..", "src", "services", "plugins.js"), "utf8");
|
||||||
|
assert(settingsTemplate.indexOf("data-ai-tools-open") < settingsTemplate.indexOf("Improvement Center"));
|
||||||
|
assert(modalTemplate.includes("data-ai-tools-list"));
|
||||||
|
assert(modalTemplate.includes("data-ai-tool-readme-modal"));
|
||||||
|
assert(clientScript.includes('button("Update"'));
|
||||||
|
assert(clientScript.includes("update.disabled = !tool.update_enabled"));
|
||||||
|
assert(clientScript.includes('form.dataset.confirmMode = "modal"'));
|
||||||
|
assert(pluginLoader.includes('entry.name, "tool_info.json"'));
|
||||||
|
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
console.log("Lumi AI tool manager verification passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyRemoteCache(root) {
|
||||||
|
let now = Date.now();
|
||||||
|
let requests = 0;
|
||||||
|
const info = metadata("lumi_ai_remote", "1.2.3");
|
||||||
|
const fetch = async (url) => {
|
||||||
|
requests += 1;
|
||||||
|
const payload = url.includes("/contents/plugins/lumi_ai_remote/tool_info.json")
|
||||||
|
? { type: "file", encoding: "base64", content: Buffer.from(JSON.stringify(info)).toString("base64") }
|
||||||
|
: [{ type: "dir", name: "lumi_ai_remote", path: "plugins/lumi_ai_remote" }];
|
||||||
|
return { ok: true, status: 200, async json() { return payload; } };
|
||||||
|
};
|
||||||
|
const client = new ToolRepoClient({
|
||||||
|
settings: { getSetting: (key) => key === "git_remote" ? "https://git.example/owner/lumi" : "main" },
|
||||||
|
fetch,
|
||||||
|
cacheFile: path.join(root, "remote-cache.json"),
|
||||||
|
now: () => now
|
||||||
|
});
|
||||||
|
await client.discover();
|
||||||
|
assert.equal(requests, 2);
|
||||||
|
await client.discover();
|
||||||
|
assert.equal(requests, 2);
|
||||||
|
now += CACHE_TTL_MS + 1;
|
||||||
|
await client.discover();
|
||||||
|
assert.equal(requests, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadata(toolId, version) {
|
||||||
|
return {
|
||||||
|
tool_id: toolId,
|
||||||
|
display_name: toolId.replaceAll("_", " "),
|
||||||
|
version,
|
||||||
|
description: "Test tool",
|
||||||
|
scope: "assistant",
|
||||||
|
permissions: { required_role: "user" },
|
||||||
|
capabilities: ["lookup"],
|
||||||
|
limitations: ["test only"],
|
||||||
|
tool_type: "lookup",
|
||||||
|
entrypoints: { backend: "index.js" },
|
||||||
|
confirmation_required: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function definition(toolId, requiredRole = "user") {
|
||||||
|
return {
|
||||||
|
tool_id: toolId,
|
||||||
|
display_name: "Lookup",
|
||||||
|
description: "Runs a safe lookup.",
|
||||||
|
required_role: requiredRole,
|
||||||
|
required_permission: `${toolId}.use`,
|
||||||
|
audit_category: "lookup",
|
||||||
|
confirmation_required: false,
|
||||||
|
risk_level: "low",
|
||||||
|
schema: {},
|
||||||
|
permission_check: () => true,
|
||||||
|
workflow_handler: async () => ({ ok: true })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendSource(label = "ok", owner = "lumi_ai_weather") {
|
||||||
|
return `module.exports.register = ({ registerTool }) => {
|
||||||
|
registerTool({
|
||||||
|
tool_id: "${owner}.lookup",
|
||||||
|
display_name: "Weather lookup",
|
||||||
|
description: "Returns test weather.",
|
||||||
|
required_role: "user",
|
||||||
|
required_permission: "weather.lookup",
|
||||||
|
audit_category: "lookup",
|
||||||
|
confirmation_required: false,
|
||||||
|
risk_level: "low",
|
||||||
|
schema: {},
|
||||||
|
permission_check: () => true,
|
||||||
|
workflow_handler: async () => ({ value: ${JSON.stringify(label)} })
|
||||||
|
});
|
||||||
|
};\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeDefinition(toolId) {
|
||||||
|
return `{
|
||||||
|
tool_id: ${JSON.stringify(toolId)},
|
||||||
|
display_name: "Lookup",
|
||||||
|
description: "Safe lookup",
|
||||||
|
required_role: "user",
|
||||||
|
required_permission: "lookup.use",
|
||||||
|
audit_category: "lookup",
|
||||||
|
confirmation_required: false,
|
||||||
|
risk_level: "low",
|
||||||
|
schema: {},
|
||||||
|
permission_check: () => true,
|
||||||
|
workflow_handler: async () => ({ ok: true })
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTool(directory, info, source) {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(directory, "tool_info.json"), `${JSON.stringify(info, null, 2)}\n`);
|
||||||
|
fs.writeFileSync(path.join(directory, "readme.md"), `# ${info.display_name}\n`);
|
||||||
|
fs.writeFileSync(path.join(directory, "index.js"), source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDirectory(source, destination) {
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
||||||
|
const from = path.join(source, entry.name);
|
||||||
|
const to = path.join(destination, entry.name);
|
||||||
|
if (entry.isDirectory()) copyDirectory(from, to);
|
||||||
|
else fs.copyFileSync(from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeRequest() {
|
||||||
|
return {
|
||||||
|
session: { user: { id: "admin", isAdmin: true } },
|
||||||
|
body: {},
|
||||||
|
get() { return null; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@ -9,6 +9,7 @@
|
|||||||
<div class="ai-runtime-badge <%= runtimeStatus.healthy ? 'ready' : 'offline' %>">
|
<div class="ai-runtime-badge <%= runtimeStatus.healthy ? 'ready' : 'offline' %>">
|
||||||
<span></span><%= runtimeStatus.healthy ? "Runtime ready" : "Runtime offline" %>
|
<span></span><%= runtimeStatus.healthy ? "Runtime ready" : "Runtime offline" %>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="button subtle" type="button" data-ai-tools-open>Tools</button>
|
||||||
<a class="button subtle" href="/plugins/lumi_ai/improvement_center">Improvement Center</a>
|
<a class="button subtle" href="/plugins/lumi_ai/improvement_center">Improvement Center</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -582,5 +583,7 @@
|
|||||||
<p class="hint">If startup fails, confirm that the runtime and selected model show as installed, the plugin directory is writable, and enough RAM and disk are available. Runtime logs are stored under <code>plugins/lumi_ai/data/logs/</code>.</p>
|
<p class="hint">If startup fails, confirm that the runtime and selected model show as installed, the plugin directory is writable, and enough RAM and disk are available. Runtime logs are stored under <code>plugins/lumi_ai/data/logs/</code>.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<%- include("tool-modal") %>
|
||||||
<script src="/plugins/lumi_ai/assets/settings.js?v=<%= assetVersion %>" defer></script>
|
<script src="/plugins/lumi_ai/assets/settings.js?v=<%= assetVersion %>" defer></script>
|
||||||
|
<script src="/plugins/lumi_ai/assets/tool-manager.js?v=<%= assetVersion %>" defer></script>
|
||||||
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||||
|
|||||||
32
plugins/lumi_ai/views/tool-modal.ejs
Normal file
32
plugins/lumi_ai/views/tool-modal.ejs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<div class="modal-backdrop ai-tools-modal" data-ai-tools-modal aria-hidden="true">
|
||||||
|
<section class="modal ai-tools-dialog" role="dialog" aria-modal="true" aria-labelledby="ai-tools-title">
|
||||||
|
<header class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="ai-tools-title">Lumi AI Tools</h2>
|
||||||
|
<p>AI-specific tool plugins from the configured Lumi repository.</p>
|
||||||
|
</div>
|
||||||
|
<button class="icon-button" type="button" data-ai-tools-close aria-label="Close Tools">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="ai-tools-source" data-ai-tools-source>Checking configured repository...</div>
|
||||||
|
<div class="ai-tools-list" data-ai-tools-list aria-live="polite">
|
||||||
|
<div class="callout">Loading AI tool plugins...</div>
|
||||||
|
</div>
|
||||||
|
<footer class="modal-actions">
|
||||||
|
<button class="button subtle" type="button" data-ai-tools-refresh>Refresh remote versions</button>
|
||||||
|
<button class="button" type="button" data-ai-tools-close>Close</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop ai-tool-readme-modal" data-ai-tool-readme-modal aria-hidden="true">
|
||||||
|
<section class="modal ai-tool-readme-dialog" role="dialog" aria-modal="true" aria-labelledby="ai-tool-readme-title">
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2 id="ai-tool-readme-title" data-ai-tool-readme-title>Tool documentation</h2>
|
||||||
|
<button class="icon-button" type="button" data-ai-tool-readme-close aria-label="Close tool documentation">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="ai-tool-readme" data-ai-tool-readme></div>
|
||||||
|
<footer class="modal-actions">
|
||||||
|
<button class="button" type="button" data-ai-tool-readme-close>Close</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@ -21,6 +21,9 @@ function scanPluginDirectories() {
|
|||||||
if (!entry.isDirectory()) {
|
if (!entry.isDirectory()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (/^lumi_ai_.+/i.test(entry.name) && fs.existsSync(path.join(pluginsDir, entry.name, "tool_info.json"))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const manifestPath = path.join(pluginsDir, entry.name, "plugin.json");
|
const manifestPath = path.join(pluginsDir, entry.name, "plugin.json");
|
||||||
if (!fs.existsSync(manifestPath)) {
|
if (!fs.existsSync(manifestPath)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user