Add Lumi AI web search tool

This commit is contained in:
Franz Rolfsvaag 2026-06-13 21:32:36 +02:00
parent 9a3091e410
commit 2d8a9554cf
25 changed files with 1966 additions and 21 deletions

View File

@ -33,6 +33,8 @@ The loader exposes no generic shell, SQL, filesystem, network, or code-execution
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.
Tools may declare a `settings_schema` in `tool_info.json`. The manager renders an admin-only Settings modal, validates and stores values under that tool's `data/settings.json`, redacts secret fields on reads, and reloads enabled tools after a save so availability and behavior update immediately.
## 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.

View File

@ -204,14 +204,15 @@ class AiProvider {
let confirmation = null;
let toolResult = null;
if (toolCall) {
const prepared = this.tools.prepare({ tool: toolCall.tool, args: toolCall.arguments, user, role, sessionId });
if (prepared.execute) toolResult = await this.tools.execute({ checked: prepared.checked, user, requestId });
const prepared = this.tools.prepare({ tool: toolCall.tool, args: toolCall.arguments, user, role, sessionId, context: originContext });
if (prepared.execute) toolResult = await this.tools.execute({ checked: prepared.checked, user, requestId, context: originContext });
confirmation = prepared.confirmation;
}
const out = {
success: true,
text: confirmation ? `Please confirm: ${confirmation.display_name}.`
: toolResult ? `Action completed: ${JSON.stringify(toolResult)}` : text,
: toolResult?.user_message ? toolResult.user_message
: toolResult ? `Action completed: ${JSON.stringify(toolResult)}` : text,
links: [],
raw_response: cfg.logging.log_responses || includeRaw ? result : null,
tool_call: toolCall,

View File

@ -117,6 +117,9 @@ function validateToolDirectory(directory, folderName = path.basename(directory))
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.");
if (metadata.settings_schema != null && (!metadata.settings_schema || typeof metadata.settings_schema !== "object" || Array.isArray(metadata.settings_schema))) {
throw new Error("settings_schema must be an object.");
}
validateRelativeEntrypoints(metadata.entrypoints);
return normalizeMetadata(metadata);
}

View File

@ -45,7 +45,7 @@ class ToolLoader {
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({
const context = {
metadata: Object.freeze({ ...local.metadata }),
registerTool: (definition) => {
const unregister = registerManagedTool(this.registry, local.metadata, definition);
@ -58,7 +58,17 @@ class ToolLoader {
config: path.join(local.dir, "config")
}),
assetUrl: (relative = "") => `/plugins/lumi_ai/tools/${toolId}/assets/${String(relative).replace(/^\/+/, "")}`
});
};
if (typeof module.checkAvailability === "function") {
const availability = await module.checkAvailability(context);
if (availability?.available === false) {
this.loaded.set(toolId, { cleanup: null, registered, metadata: local.metadata, dir: local.dir });
this.setStatus(toolId, "unavailable", String(availability.message || "Tool configuration is incomplete."), dependencies);
if (options.persist !== false) this.setEnabled(toolId, true);
return { loaded: false, unavailable: true, message: availability.message, dependencies };
}
}
const result = await register(context);
cleanup = typeof result === "function" ? result : typeof result?.stop === "function" ? () => result.stop() : null;
} catch (error) {
this.registry.unregisterOwner(toolId);

View File

@ -7,6 +7,7 @@ class ToolManager {
this.repoClient = options.repoClient;
this.installer = options.installer;
this.loader = options.loader;
this.settings = options.settings;
}
async list({ force = false } = {}) {
@ -53,7 +54,8 @@ class ToolManager {
runtime_message: runtime.message,
dependency_status: dependencies,
primary_type: metadata?.tool_type || "general",
primary_scope: displayScope(metadata?.scope)
primary_scope: displayScope(metadata?.scope),
has_settings: installed && Boolean(metadata?.settings_schema && Object.keys(metadata.settings_schema).length)
};
})
};
@ -116,6 +118,16 @@ class ToolManager {
return { markdown: await this.repoClient.readReadme(toolId), source: "remote" };
}
settingsFor(toolId) {
return this.settings.describe(toolId);
}
async saveSettings(toolId, values) {
const saved = this.settings.save(toolId, values);
if (this.loader.isEnabled(toolId)) await this.loader.enable(toolId, { persist: false });
return saved;
}
async loadEnabled() {
return this.loader.loadEnabled();
}

View File

@ -25,27 +25,35 @@ class ToolRegistry {
const def=this.tools.get(tool); if(!def) throw new Error("Tool is not registered.");
if(!roleAllows(role,def.required_role)) throw new Error("Permission denied for this tool.");
const schema=def.schema||{}; const clean={};
for(const [key,type] of Object.entries(schema)){ const value=args?.[key]; if(type==="integer" && !Number.isInteger(Number(value))) throw new Error(`${key} must be an integer.`); if(type==="string" && typeof value!=="string") throw new Error(`${key} must be a string.`); clean[key]=type==="integer"?Number(value):value; }
for(const [key,spec] of Object.entries(schema)){
const descriptor=typeof spec==="string"?{type:spec,required:true}:spec||{};
const value=args?.[key];
if(value==null && descriptor.required===false)continue;
if(descriptor.type==="integer" && !Number.isInteger(Number(value))) throw new Error(`${key} must be an integer.`);
if(descriptor.type==="string" && typeof value!=="string") throw new Error(`${key} must be a string.`);
if(Array.isArray(descriptor.enum) && !descriptor.enum.includes(value)) throw new Error(`${key} is invalid.`);
clean[key]=descriptor.type==="integer"?Number(value):value;
}
return {def,args:clean};
}
prepare({tool,args,user,role,sessionId}){
prepare({tool,args,user,role,sessionId,context=null}){
const checked=this.validate(tool,args,role);
const allowed=checked.def.permission_check({user,arguments:checked.args,required_permission:checked.def.required_permission});
const allowed=checked.def.permission_check({user,arguments:checked.args,required_permission:checked.def.required_permission,context});
if(allowed && typeof allowed.then==="function")throw new Error("AI tool permission checks must be synchronous.");
if(!allowed)throw new Error("The requesting user does not have permission for this action.");
if(!checked.def.confirmation_required) return {execute:true,checked};
const id=crypto.randomUUID(); this.confirmations.set(id,{id,userId:user.id,sessionId,expiresAt:Date.now()+120000,...checked});
if(!checked.def.confirmation_required) return {execute:true,checked,context};
const id=crypto.randomUUID(); this.confirmations.set(id,{id,userId:user.id,sessionId,expiresAt:Date.now()+120000,context,...checked});
return {execute:false,confirmation:{id,display_name:checked.def.display_name,arguments:checked.args,expires_at:Date.now()+120000}};
}
async execute({checked,user,requestId}){
const result=await checked.def.workflow_handler({arguments:checked.args,user,initiated_via_ai:true,ai_request_id:requestId});
async execute({checked,user,requestId,context=null}){
const result=await checked.def.workflow_handler({arguments:checked.args,user,ctx:context,initiated_via_ai:true,ai_request_id:requestId});
this.audit({kind:"tool",status:"success",user_id:user.id,tool_requested:checked.def.tool_id,tool_executed:true});
return result;
}
async confirm({id,user,sessionId}){
const pending=this.confirmations.get(id); this.confirmations.delete(id);
if(!pending || pending.expiresAt<Date.now() || pending.userId!==user.id || pending.sessionId!==sessionId) throw new Error("Confirmation is invalid or expired.");
return this.execute({checked:{def:pending.def,args:pending.args},user,requestId:id});
return this.execute({checked:{def:pending.def,args:pending.args},user,requestId:id,context:pending.context});
}
cancel(id,userId){ const p=this.confirmations.get(id); if(p?.userId===userId){this.confirmations.delete(id);return true;} return false; }
}

View File

@ -0,0 +1,160 @@
const fs = require("fs");
const path = require("path");
class ToolSettings {
constructor(options = {}) {
this.installer = options.installer;
}
describe(toolId) {
const local = this.installer.local(toolId);
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
const schema = normalizeSchema(local.metadata.settings_schema);
if (!Object.keys(schema).length) throw new Error("This AI tool does not expose configurable settings.");
const values = this.readValues(local.dir, schema);
return {
tool_id: toolId,
display_name: local.metadata.display_name,
schema,
values: redactSecrets(values, schema),
configured_secrets: Object.fromEntries(
Object.entries(schema)
.filter(([, field]) => field.secret === true)
.map(([key]) => [key, Boolean(values[key])])
)
};
}
save(toolId, input) {
const local = this.installer.local(toolId);
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
const schema = normalizeSchema(local.metadata.settings_schema);
if (!Object.keys(schema).length) throw new Error("This AI tool does not expose configurable settings.");
const current = this.readValues(local.dir, schema);
const next = {};
for (const [key, field] of Object.entries(schema)) {
const incoming = input?.[key];
if (field.secret === true && (incoming == null || String(incoming) === "")) {
next[key] = current[key] ?? field.default;
} else {
next[key] = normalizeValue(incoming, field, key);
}
}
const file = settingsFile(local.dir);
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
try { fs.chmodSync(temporary, 0o600); } catch {}
fs.renameSync(temporary, file);
return this.describe(toolId);
}
readRaw(toolId) {
const local = this.installer.local(toolId);
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
const schema = normalizeSchema(local.metadata.settings_schema);
return this.readValues(local.dir, schema);
}
readValues(toolDir, schema) {
let stored = {};
try { stored = JSON.parse(fs.readFileSync(settingsFile(toolDir), "utf8")); }
catch {}
return Object.fromEntries(
Object.entries(schema).map(([key, field]) => {
try {
return [key, normalizeValue(stored[key] ?? field.default, field, key)];
} catch {
return [key, normalizeValue(field.default, field, key)];
}
})
);
}
}
function normalizeSchema(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).map(([key, field]) => {
if (!/^[a-z][a-z0-9_]*$/i.test(key) || !field || typeof field !== "object" || Array.isArray(field)) {
throw new Error("settings_schema contains an invalid field.");
}
const type = String(field.type || "string");
if (!["string", "integer", "number", "boolean", "enum", "string_list", "multi_select"].includes(type)) {
throw new Error(`Unsupported settings type for ${key}.`);
}
const options = Array.isArray(field.options) ? field.options.map(String) : [];
if (["enum", "multi_select"].includes(type) && !options.length) {
throw new Error(`${key} must define options.`);
}
return [key, {
type,
label: String(field.label || key.replaceAll("_", " ")),
description: String(field.description || ""),
default: field.default ?? defaultValue(type, options),
options,
minimum: finiteOrNull(field.minimum),
maximum: finiteOrNull(field.maximum),
secret: field.secret === true,
required: field.required === true,
rows: Math.max(2, Math.min(12, Number.parseInt(field.rows, 10) || 3))
}];
}));
}
function normalizeValue(value, field, key = "setting") {
if (field.type === "boolean") return value === true || value === "true" || value === "1" || value === "on";
if (field.type === "integer" || field.type === "number") {
const number = field.type === "integer" ? Number.parseInt(value, 10) : Number(value);
if (!Number.isFinite(number)) throw new Error(`${field.label || key} must be a number.`);
return clamp(number, field.minimum, field.maximum);
}
if (field.type === "string_list") {
const rows = Array.isArray(value) ? value : String(value || "").split(/\r?\n|,/);
return [...new Set(rows.map((entry) => String(entry).trim()).filter(Boolean))].slice(0, 200);
}
if (field.type === "multi_select") {
const selected = Array.isArray(value) ? value.map(String) : value == null ? [] : [String(value)];
return [...new Set(selected)].filter((entry) => field.options.includes(entry));
}
const text = String(value ?? "").trim();
if (field.required && !text) throw new Error(`${field.label || key} is required.`);
if (field.type === "enum" && !field.options.includes(text)) throw new Error(`${field.label || key} is invalid.`);
return text;
}
function redactSecrets(values, schema) {
return Object.fromEntries(Object.entries(values).map(([key, value]) => [
key,
schema[key]?.secret === true ? "" : value
]));
}
function settingsFile(toolDir) {
return path.join(toolDir, "data", "settings.json");
}
function defaultValue(type, options) {
if (type === "boolean") return false;
if (type === "integer" || type === "number") return 0;
if (type === "string_list" || type === "multi_select") return [];
if (type === "enum") return options[0] || "";
return "";
}
function finiteOrNull(value) {
if (value == null || value === "") return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function clamp(value, minimum, maximum) {
return Math.max(minimum ?? value, Math.min(maximum ?? value, value));
}
module.exports = {
ToolSettings,
normalizeSchema,
normalizeValue,
redactSecrets,
settingsFile
};

View File

@ -33,6 +33,7 @@ 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 { ToolSettings } = require("./backend/tool_settings");
const storage = require("./backend/storage");
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
@ -76,10 +77,12 @@ module.exports = {
lumiAiVersion: require("./plugin.json").version,
lumiVersion: require("../../package.json").version
});
const toolSettings = new ToolSettings({ installer: toolInstaller });
const toolManager = new ToolManager({
repoClient: toolRepoClient,
installer: toolInstaller,
loader: toolLoader
loader: toolLoader,
settings: toolSettings
});
const contextProviders = new Map();
const frontendVisibility = new Map();
@ -1019,6 +1022,24 @@ module.exports = {
}
});
router.get("/api/tools/:id/settings", (req, res) => {
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
try {
return res.json(toolManager.settingsFor(req.params.id));
} catch (error) {
return res.status(404).json({ error: error.message });
}
});
router.post("/api/tools/:id/settings", express.json({ limit: "64kb" }), async (req, res) => {
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
try {
return res.json(await toolManager.saveSettings(req.params.id, req.body?.values || {}));
} catch (error) {
return res.status(400).json({ error: error.message });
}
});
router.get("/tools/:id/assets/*", (req, res) => {
const permission = canUseAssistant({
user: req.session.user,

View File

@ -1,7 +1,7 @@
{
"id": "lumi_ai",
"name": "Lumi AI",
"version": "0.7.0",
"version": "0.7.1",
"description": "Managed local AI provider and scoped WebUI assistant for Lumi.",
"main": "index.js"
}

View File

@ -81,6 +81,7 @@
.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-tool-settings-modal { z-index: 125; }
.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; }
@ -105,6 +106,16 @@
.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; }
.ai-tool-settings-dialog { width: min(760px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; }
.ai-tool-settings-dialog .modal-header p { margin: 4px 0 0; color: var(--ink-soft); }
.ai-tool-settings-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.ai-tool-setting { display: grid; gap: 6px; min-width: 0; }
.ai-tool-setting.wide { grid-column: 1 / -1; }
.ai-tool-setting label { font-weight: 800; }
.ai-tool-setting .hint { margin: 0; }
.ai-tool-setting textarea { min-height: 90px; resize: vertical; }
.ai-tool-setting .check-grid { display: flex; flex-wrap: wrap; gap: 8px 14px; }
.ai-tool-setting .check-grid label { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; }
@media (max-width: 800px) {
.ai-titlebar, .ai-section-heading { align-items: flex-start; flex-direction: column; }
.ai-stat-grid { grid-template-columns: 1fr 1fr; }
@ -120,4 +131,5 @@
.ai-tool-summary { grid-template-columns: 1fr; }
.ai-tool-actions { justify-content: flex-start; }
.ai-tool-details { grid-template-columns: 1fr; }
.ai-tool-settings-fields { grid-template-columns: 1fr; }
}

View File

@ -7,9 +7,16 @@
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;
const settingsModal = document.querySelector("[data-ai-tool-settings-modal]");
const settingsTitle = settingsModal?.querySelector("[data-ai-tool-settings-title]");
const settingsForm = settingsModal?.querySelector("[data-ai-tool-settings-form]");
const settingsFields = settingsModal?.querySelector("[data-ai-tool-settings-fields]");
const settingsSave = settingsModal?.querySelector("[data-ai-tool-settings-save]");
if (!openButton || !modal || !list || !source || !readmeModal || !readmeTitle || !readmeBody ||
!settingsModal || !settingsTitle || !settingsForm || !settingsFields || !settingsSave) return;
let loading = false;
let activeSettingsTool = null;
const setOpen = (target, open) => {
target.classList.toggle("is-open", open);
@ -84,6 +91,10 @@
});
const inspect = button("Inspect", "subtle");
inspect.addEventListener("click", () => inspectReadme(tool));
const settings = button("Settings", "subtle");
settings.disabled = !tool.has_settings;
settings.title = tool.has_settings ? "" : "This tool does not expose configurable settings.";
settings.addEventListener("click", () => openSettings(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));
@ -91,7 +102,7 @@
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);
actions.append(expand, inspect, settings, enable, update);
if (tool.installed) actions.append(deleteForm(tool));
summary.append(identity, versions, scope, actions);
@ -169,6 +180,136 @@
}
};
const openSettings = async (tool) => {
activeSettingsTool = tool;
settingsTitle.textContent = `${tool.display_name || tool.tool_id} settings`;
settingsFields.replaceChildren(message("Loading settings..."));
setOpen(settingsModal, true);
try {
const response = await fetch(`/plugins/lumi_ai/api/tools/${encodeURIComponent(tool.tool_id)}/settings`, {
cache: "no-store",
headers: { Accept: "application/json" }
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "Unable to load tool settings.");
renderSettings(payload);
} catch (error) {
settingsFields.replaceChildren(message(error.message, true));
}
};
const renderSettings = (payload) => {
settingsFields.replaceChildren();
for (const [key, field] of Object.entries(payload.schema || {})) {
const wrapper = document.createElement("div");
wrapper.className = `ai-tool-setting${["string_list", "multi_select"].includes(field.type) ? " wide" : ""}`;
const label = document.createElement("label");
label.textContent = field.label || key;
const control = settingsControl(key, field, payload.values?.[key], payload.configured_secrets?.[key]);
label.htmlFor = control.id || "";
wrapper.append(label, control);
if (field.description) {
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = field.description;
wrapper.append(hint);
}
settingsFields.append(wrapper);
}
};
const settingsControl = (key, field, value, configuredSecret) => {
if (field.type === "boolean") {
const input = document.createElement("input");
input.type = "checkbox";
input.name = key;
input.id = `ai-tool-setting-${key}`;
input.checked = value === true;
return input;
}
if (field.type === "multi_select") {
const group = document.createElement("div");
group.className = "check-grid";
group.dataset.settingName = key;
for (const option of field.options || []) {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.value = option;
input.checked = Array.isArray(value) && value.includes(option);
label.append(input, document.createTextNode(option));
group.append(label);
}
return group;
}
if (field.type === "string_list") {
const textarea = document.createElement("textarea");
textarea.name = key;
textarea.id = `ai-tool-setting-${key}`;
textarea.rows = field.rows || 3;
textarea.value = Array.isArray(value) ? value.join("\n") : "";
return textarea;
}
if (field.type === "enum") {
const select = document.createElement("select");
select.name = key;
select.id = `ai-tool-setting-${key}`;
for (const option of field.options || []) {
const item = document.createElement("option");
item.value = option;
item.textContent = option;
item.selected = option === value;
select.append(item);
}
return select;
}
const input = document.createElement("input");
input.name = key;
input.id = `ai-tool-setting-${key}`;
input.type = field.secret ? "password" : ["integer", "number"].includes(field.type) ? "number" : "text";
if (field.minimum != null) input.min = field.minimum;
if (field.maximum != null) input.max = field.maximum;
if (field.type === "number") input.step = "any";
input.value = field.secret ? "" : value ?? "";
if (field.secret && configuredSecret) input.placeholder = "Configured; leave blank to keep";
return input;
};
settingsForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!activeSettingsTool) return;
const values = {};
for (const [key, field] of Object.entries(activeSettingsTool.settings_schema || {})) {
if (field.type === "multi_select") {
const group = [...settingsFields.querySelectorAll("[data-setting-name]")]
.find((element) => element.dataset.settingName === key);
values[key] = [...(group?.querySelectorAll("input:checked") || [])].map((input) => input.value);
} else {
const input = [...settingsFields.querySelectorAll("[name]")]
.find((element) => element.name === key);
values[key] = field.type === "boolean" ? Boolean(input?.checked) : input?.value;
}
}
settingsSave.disabled = true;
settingsSave.textContent = "Saving...";
try {
const response = await fetch(`/plugins/lumi_ai/api/tools/${encodeURIComponent(activeSettingsTool.tool_id)}/settings`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ values })
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "Unable to save tool settings.");
setOpen(settingsModal, false);
await loadTools(false);
} catch (error) {
window.alert(error.message);
} finally {
settingsSave.disabled = false;
settingsSave.textContent = "Save settings";
}
});
const renderMarkdown = (container, markdown) => {
container.replaceChildren();
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
@ -309,7 +450,8 @@
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]) {
settingsModal.querySelectorAll("[data-ai-tool-settings-close]").forEach((control) => control.addEventListener("click", () => setOpen(settingsModal, false)));
for (const target of [modal, readmeModal, settingsModal]) {
target.addEventListener("click", (event) => {
if (event.target === target) setOpen(target, false);
});

View File

@ -8,6 +8,7 @@ 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 { ToolSettings } = require("../backend/tool_settings");
const { isDestructivePath, issueConfirmation, consumeConfirmation } = require("../../../src/services/destructive-confirm");
async function run() {
@ -57,10 +58,11 @@ async function run() {
installer,
settings,
stateFile,
lumiAiVersion: "0.7.0",
lumiAiVersion: "0.7.1",
lumiVersion: "0.1.0"
});
const manager = new ToolManager({ repoClient, installer, loader });
const toolSettings = new ToolSettings({ installer });
const manager = new ToolManager({ repoClient, installer, loader, settings: toolSettings });
let listing = await manager.list();
assert.equal(listing.tools.length, 1);
@ -111,6 +113,35 @@ async function run() {
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_configurable"),
{
...metadata("lumi_ai_configurable", "1.0.0"),
settings_schema: {
enabled: { type: "boolean", default: false, label: "Enabled" },
limit: { type: "integer", default: 5, minimum: 1, maximum: 10, label: "Limit" },
api_key: { type: "string", default: "", secret: true, label: "API key" }
}
},
backendSource("ok", "lumi_ai_configurable")
);
listing = await manager.list();
assert.equal(listing.tools.find((tool) => tool.tool_id === "lumi_ai_configurable").has_settings, true);
assert.deepEqual(manager.settingsFor("lumi_ai_configurable").values, { enabled: false, limit: 5, api_key: "" });
let savedSettings = await manager.saveSettings("lumi_ai_configurable", {
enabled: true,
limit: 99,
api_key: "secret-value"
});
assert.deepEqual(savedSettings.values, { enabled: true, limit: 10, api_key: "" });
assert.equal(savedSettings.configured_secrets.api_key, true);
savedSettings = await manager.saveSettings("lumi_ai_configurable", {
enabled: false,
limit: 4,
api_key: ""
});
assert.equal(toolSettings.readRaw("lumi_ai_configurable").api_key, "secret-value");
createTool(
path.join(pluginsDir, "lumi_ai_cross_dependency"),
{ ...metadata("lumi_ai_cross_dependency", "1.0.0"), required_plugins: ["lumi_ai_weather"] },
@ -146,6 +177,31 @@ async function run() {
}), /Permission denied/);
assert.equal(strictRegistry.unregisterOwner("lumi_ai_strict"), 1);
const contextRegistry = new ToolRegistry(() => {});
registerManagedTool(contextRegistry, metadata("lumi_ai_context", "1.0.0"), {
...definition("lumi_ai_context.lookup"),
schema: {
query: { type: "string", required: true },
freshness: { type: "string", required: false }
},
workflow_handler: async ({ arguments: args, ctx }) => ({ query: args.query, origin: ctx.origin })
});
const preparedContext = contextRegistry.prepare({
tool: "lumi_ai_context.lookup",
args: { query: "current information" },
user: { id: "ordinary-user" },
role: "user",
sessionId: "session",
context: { origin: "discord" }
});
const contextResult = await contextRegistry.execute({
checked: preparedContext.checked,
user: { id: "ordinary-user" },
requestId: "request",
context: preparedContext.context
});
assert.deepEqual(contextResult, { query: "current information", origin: "discord" });
const unrelated = path.join(pluginsDir, "ordinary-plugin");
fs.mkdirSync(unrelated);
assert.equal(isDestructivePath("/plugins/lumi_ai/tools/lumi_ai_weather/delete"), true);
@ -170,7 +226,9 @@ async function run() {
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(modalTemplate.includes("data-ai-tool-settings-modal"));
assert(clientScript.includes('button("Update"'));
assert(clientScript.includes('button("Settings"'));
assert(clientScript.includes("update.disabled = !tool.update_enabled"));
assert(clientScript.includes('form.dataset.confirmMode = "modal"'));
assert(pluginLoader.includes('entry.name, "tool_info.json"'));

View File

@ -18,6 +18,25 @@
</section>
</div>
<div class="modal-backdrop ai-tool-settings-modal" data-ai-tool-settings-modal aria-hidden="true">
<section class="modal ai-tool-settings-dialog" role="dialog" aria-modal="true" aria-labelledby="ai-tool-settings-title">
<header class="modal-header">
<div>
<h2 id="ai-tool-settings-title" data-ai-tool-settings-title>Tool settings</h2>
<p>These settings apply to WebUI and platform-triggered tool calls.</p>
</div>
<button class="icon-button" type="button" data-ai-tool-settings-close aria-label="Close tool settings">&times;</button>
</header>
<form data-ai-tool-settings-form>
<div class="ai-tool-settings-fields" data-ai-tool-settings-fields></div>
<div class="modal-actions">
<button class="button subtle" type="button" data-ai-tool-settings-close>Cancel</button>
<button class="button" type="submit" data-ai-tool-settings-save>Save settings</button>
</div>
</form>
</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">

View File

@ -0,0 +1,248 @@
const http = require("http");
const https = require("https");
const net = require("net");
const {
defaultResolveHost,
evaluateNetworkTarget,
evaluateUrl,
isPrivateAddress
} = require("./url_policy");
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const MAX_PAGE_BYTES = 512 * 1024;
class SearchProvider {
constructor(options = {}) {
this.fetch = options.fetch || null;
this.resolveHost = options.resolveHost;
}
async search(query, options) {
const endpoint = buildEndpoint(query, options);
const response = await this.request(endpoint, options, true, MAX_RESPONSE_BYTES);
const payload = JSON.parse(response.body.toString("utf8"));
return normalizeProviderResults(payload, options.provider_adapter);
}
async fetchPage(url, options) {
const response = await this.request(url, options, false, MAX_PAGE_BYTES);
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
if (!contentType.includes("text/html") && !contentType.includes("text/plain")) {
throw new Error("Page content type is not supported.");
}
return {
url: response.url,
text: extractPageText(response.body.toString("utf8")).slice(0, 6000)
};
}
async request(initialUrl, options, providerRequest, maxBytes) {
let current = initialUrl;
const providerOrigin = providerRequest ? new URL(initialUrl).origin : null;
for (let redirects = 0; redirects <= 3; redirects += 1) {
const policy = providerRequest
? await evaluateNetworkTarget(current, { resolveHost: this.resolveHost })
: await evaluateUrl(current, {
mode: options.policy_mode,
rules: options.url_rules,
resolveHost: this.resolveHost
});
if (!policy.allowed) throw blockedError(policy.reason);
if (providerRequest && new URL(policy.url).origin !== providerOrigin) {
throw blockedError("cross_origin_provider_redirect");
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.search_timeout_ms);
timer.unref?.();
try {
const headers = {
Accept: providerRequest ? "application/json" : "text/html,text/plain;q=0.9",
"User-Agent": "Lumi-AI-Web-Search/1.0"
};
if (providerRequest && options.provider_api_key) {
headers[options.provider_api_key_header] = [
options.provider_api_key_prefix,
options.provider_api_key
].filter(Boolean).join(" ");
}
const response = this.fetch
? await this.fetch(policy.url, {
method: "GET",
headers,
redirect: "manual",
signal: controller.signal
})
: await safeHttpRequest(policy.url, {
headers,
timeoutMs: options.search_timeout_ms,
maxBytes,
resolveHost: this.resolveHost
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) throw new Error("Provider redirect did not include a location.");
current = new URL(location, policy.url).href;
continue;
}
if (!response.ok) throw new Error(`Search provider request failed (${response.status}).`);
return {
url: policy.url,
headers: response.headers,
body: response.body || await readBounded(response, maxBytes)
};
} finally {
clearTimeout(timer);
}
}
throw new Error("Search request exceeded the redirect limit.");
}
}
async function safeHttpRequest(value, options = {}) {
const url = new URL(value);
const hostname = url.hostname.replace(/^\[|\]$/g, "");
const resolveHost = options.resolveHost || defaultResolveHost;
const addresses = await resolveHost(hostname);
const address = addresses.find((entry) => !isPrivateAddress(entry));
if (!address) throw blockedError("private_network");
const transport = url.protocol === "https:" ? https : http;
return new Promise((resolve, reject) => {
const request = transport.request({
protocol: url.protocol,
hostname,
port: url.port || undefined,
method: "GET",
path: `${url.pathname}${url.search}`,
headers: options.headers,
servername: url.protocol === "https:" && !net.isIP(hostname) ? hostname : undefined,
lookup: (_hostname, _options, callback) => callback(null, address, net.isIP(address))
}, (response) => {
const chunks = [];
let size = 0;
response.on("data", (chunk) => {
size += chunk.length;
if (size > options.maxBytes) {
request.destroy(new Error("Provider response is too large."));
return;
}
chunks.push(chunk);
});
response.on("end", () => resolve({
ok: response.statusCode >= 200 && response.statusCode < 300,
status: response.statusCode,
headers: { get: (name) => response.headers[String(name).toLowerCase()] || null },
body: Buffer.concat(chunks)
}));
});
request.setTimeout(options.timeoutMs, () => {
const error = new Error("Search provider timed out.");
error.name = "AbortError";
request.destroy(error);
});
request.on("error", reject);
request.end();
});
}
function buildEndpoint(query, settings) {
if (!settings.provider_endpoint) throw new Error("Search provider endpoint is not configured.");
const endpoint = settings.provider_endpoint.includes("{query}")
? settings.provider_endpoint.replaceAll("{query}", encodeURIComponent(query))
: settings.provider_endpoint;
const url = new URL(endpoint);
if (!settings.provider_endpoint.includes("{query}")) {
url.searchParams.set(settings.provider_query_parameter || "q", query);
}
url.searchParams.set("format", "json");
url.searchParams.set("safesearch", safeSearchValue(settings.safe_search));
url.searchParams.set("count", String(settings.max_results));
if (settings.freshness) url.searchParams.set("time_range", String(settings.freshness).slice(0, 32));
return url.href;
}
function normalizeProviderResults(payload, adapter) {
const rows = adapter === "searxng_json"
? payload?.results
: payload?.results || payload?.items || payload?.web?.results?.value;
if (!Array.isArray(rows)) throw new Error("Search provider response does not contain a supported result list.");
return rows.map((row, index) => ({
title: sanitizeText(row.title || row.name || "Untitled result", 240),
url: String(row.url || row.link || ""),
snippet: sanitizeText(row.content || row.snippet || row.description || "", 800),
source_type: sanitizeText(row.source_type || row.category || row.engine || "", 80) || null,
date: normalizeDate(row.publishedDate || row.published_date || row.date),
relevance_score: finiteScore(row.score, index)
})).filter((row) => row.url);
}
async function readBounded(response, maxBytes) {
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) throw new Error("Provider response is too large.");
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length > maxBytes) throw new Error("Provider response is too large.");
return buffer;
}
function extractPageText(value) {
return sanitizeText(
String(value)
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " "),
12000
);
}
function sanitizeText(value, maximum) {
return decodeEntities(String(value || "").replace(/<[^>]+>/g, " "))
.replace(/[\u0000-\u001f\u007f]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, maximum);
}
function decodeEntities(value) {
return value
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", "\"")
.replaceAll("&#39;", "'");
}
function normalizeDate(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function finiteScore(value, index) {
const number = Number(value);
return Number.isFinite(number) ? number : Math.max(0, 1 - index * 0.1);
}
function safeSearchValue(level) {
if (level === "off") return "0";
if (level === "moderate") return "1";
return "2";
}
function blockedError(reason) {
const error = new Error(`URL blocked by policy: ${reason}.`);
error.code = "URL_BLOCKED";
error.blockedReason = reason;
return error;
}
module.exports = {
MAX_PAGE_BYTES,
MAX_RESPONSE_BYTES,
SearchProvider,
blockedError,
buildEndpoint,
extractPageText,
normalizeProviderResults,
readBounded,
sanitizeText,
safeHttpRequest
};

View File

@ -0,0 +1,122 @@
const { sanitizeText } = require("./provider_adapter");
const REASONS = new Set([
"fact_lookup",
"resource_lookup",
"troubleshooting",
"documentation_lookup",
"news_or_recent",
"general_lookup"
]);
function formatResults(rows, options = {}) {
const reason = REASONS.has(options.reason) ? options.reason : "general_lookup";
const origin = normalizeOrigin(options.origin);
const budget = outputBudget(options.settings, origin);
const sourceLimit = origin === "twitch" ? 2 : origin === "discord" ? 4 : options.settings.max_results;
const snippetLimit = reason === "fact_lookup" ? 180
: ["resource_lookup", "troubleshooting", "documentation_lookup"].includes(reason) ? 300
: 240;
const ordered = prioritize(rows, reason).slice(0, sourceLimit);
const results = [];
let used = 0;
for (const row of ordered) {
const normalized = {
title: sanitizeText(row.title, 180),
url: options.settings.show_source_links ? row.url : null,
domain: safeDomain(row.url),
snippet: sanitizeText(row.page_excerpt || row.snippet, snippetLimit),
source_type: row.source_type || inferSourceType(row.url),
date: row.date || null,
relevance_score: Number.isFinite(Number(row.relevance_score)) ? Number(row.relevance_score) : null
};
const cost = normalized.title.length + normalized.snippet.length + (normalized.url?.length || 0) + 30;
if (results.length && used + cost > budget) break;
results.push(normalized);
used += cost;
}
const condensedText = buildCondensedText(results, reason, origin, budget);
return {
reason,
origin,
output_budget_chars: budget,
truncated: ordered.length > results.length || condensedText.truncated,
condensed_text: condensedText.text,
results
};
}
function buildCondensedText(results, reason, origin, budget) {
const lines = results.map((result, index) => {
const date = result.date ? ` (${result.date.slice(0, 10)})` : "";
const source = result.url ? ` ${result.url}` : ` [${result.domain}]`;
if (reason === "fact_lookup") return `${index + 1}. ${result.snippet || result.title}${date}${source}`;
return `${index + 1}. ${result.title}${date}: ${result.snippet}${source}`;
});
let text = lines.join(origin === "twitch" ? " | " : "\n");
let truncated = false;
if (text.length > budget) {
text = `${text.slice(0, Math.max(0, budget - 18)).trimEnd()}...`;
truncated = true;
}
return { text, truncated };
}
function prioritize(rows, reason) {
const values = [...rows];
if (["documentation_lookup", "troubleshooting"].includes(reason)) {
values.sort((left, right) =>
authorityScore(right) - authorityScore(left) ||
Number(right.relevance_score || 0) - Number(left.relevance_score || 0)
);
} else if (reason === "news_or_recent") {
values.sort((left, right) =>
dateValue(right.date) - dateValue(left.date) ||
Number(right.relevance_score || 0) - Number(left.relevance_score || 0)
);
}
return values;
}
function outputBudget(settings, origin) {
return Number(settings[`${origin}_output_chars`]) ||
Number(settings.other_output_chars) ||
500;
}
function normalizeOrigin(value) {
const origin = String(value || "other").toLowerCase();
return ["webui", "discord", "twitch", "youtube", "kick"].includes(origin) ? origin : "other";
}
function safeDomain(value) {
try { return new URL(value).hostname; }
catch { return ""; }
}
function inferSourceType(value) {
const domain = safeDomain(value);
if (/^(docs|developer|support)\./i.test(domain) || /\.(gov|edu)$/i.test(domain)) return "authoritative";
return "web";
}
function authorityScore(row) {
return ["official", "authoritative", "documentation"].includes(String(row.source_type || "").toLowerCase()) ||
inferSourceType(row.url) === "authoritative" ? 1 : 0;
}
function dateValue(value) {
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : 0;
}
module.exports = {
REASONS,
buildCondensedText,
formatResults,
inferSourceType,
normalizeOrigin,
outputBudget,
prioritize,
safeDomain
};

View File

@ -0,0 +1,184 @@
const fs = require("fs");
const path = require("path");
const { SearchProvider } = require("./provider_adapter");
const { formatResults, normalizeOrigin } = require("./result_formatter");
const { readSettings } = require("./settings");
const { evaluateUrl } = require("./url_policy");
class WebSearchTool {
constructor(options = {}) {
this.dataDir = options.dataDir;
this.provider = options.provider || new SearchProvider(options);
this.now = options.now || Date.now;
this.cache = new Map();
this.rateLimits = new Map();
}
async run(input = {}) {
const started = this.now();
const settings = readSettings(this.dataDir);
const query = String(input.query || "").trim().slice(0, 500);
const reason = String(input.reason || "general_lookup");
const origin = trustedOrigin(input.ctx, input.origin);
const actor = String(input.user?.id || input.user?.username || "unknown").slice(0, 120);
const server = String(input.ctx?.server_id || input.ctx?.channel_id || "direct").slice(0, 120);
const auditBase = { query, reason, origin, actor, server };
if (!settings.enabled) return this.finish(blockedResult(query, reason, "tool_disabled", settings, started, this.now()), auditBase);
if (!settings.allowed_origins.includes(origin)) {
return this.finish(blockedResult(query, reason, "origin_not_allowed", settings, started, this.now()), auditBase);
}
if (!query) return this.finish(blockedResult(query, reason, "query_required", settings, started, this.now()), auditBase);
if (!this.consumeRateLimit(`${origin}:${server}:${actor}`, settings.requests_per_minute)) {
return this.finish(blockedResult(query, reason, "rate_limited", settings, started, this.now()), auditBase);
}
const cacheKey = JSON.stringify([
query.toLowerCase(), reason, input.freshness || "", settings.provider_endpoint,
settings.policy_mode, settings.url_rules, settings.safe_search, settings.max_results,
origin, settings[`${origin}_output_chars`], settings.show_source_links,
input.requested_depth || "search", settings.allow_full_page_fetch
]);
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > this.now()) {
return this.finish({ ...cached.value, cache_hit: true, timing_ms: this.now() - started }, auditBase);
}
try {
const discovered = await this.provider.search(query, {
...settings,
freshness: input.freshness
});
const allowed = [];
for (const row of discovered) {
if (allowed.length >= settings.max_results) break;
const policy = await evaluateUrl(row.url, {
mode: settings.policy_mode,
rules: settings.url_rules,
resolveHost: this.provider.resolveHost
});
if (!policy.allowed) continue;
const normalized = { ...row, url: policy.url };
if (input.requested_depth === "full_page" && settings.allow_full_page_fetch) {
try {
const page = await this.provider.fetchPage(policy.url, settings);
normalized.url = page.url;
normalized.page_excerpt = page.text;
} catch (error) {
if (error.code === "URL_BLOCKED") continue;
}
}
allowed.push(normalized);
}
const formatted = formatResults(allowed, { reason, origin, settings });
const value = {
query,
reason: formatted.reason,
status: allowed.length ? "ok" : "no_results",
blocked_reason: null,
result_count: formatted.results.length,
results: formatted.results,
condensed_text: formatted.condensed_text,
output_budget_chars: formatted.output_budget_chars,
truncated: formatted.truncated,
timing_ms: this.now() - started,
cache_hit: false,
policy_mode: settings.policy_mode
};
if (settings.cache_ttl_seconds > 0 && allowed.length) {
this.cache.set(cacheKey, {
expiresAt: this.now() + settings.cache_ttl_seconds * 1000,
value
});
}
return this.finish(value, auditBase);
} catch (error) {
return this.finish({
query,
reason,
status: error.code === "URL_BLOCKED" ? "blocked" : "unavailable",
blocked_reason: error.blockedReason || null,
error: cleanError(error),
result_count: 0,
results: [],
condensed_text: "",
timing_ms: this.now() - started,
cache_hit: false,
policy_mode: settings.policy_mode
}, auditBase);
}
}
consumeRateLimit(key, maximum) {
const cutoff = this.now() - 60000;
const recent = (this.rateLimits.get(key) || []).filter((timestamp) => timestamp > cutoff);
if (recent.length >= maximum) {
this.rateLimits.set(key, recent);
return false;
}
recent.push(this.now());
this.rateLimits.set(key, recent);
return true;
}
finish(result, base) {
result.user_message ||= userMessage(result);
this.audit({
...base,
status: result.status,
allowed: result.status === "ok" || result.status === "no_results",
blocked_reason: result.blocked_reason || null,
result_count: result.result_count,
timing_ms: result.timing_ms,
cache_hit: result.cache_hit
});
return result;
}
audit(entry) {
fs.mkdirSync(this.dataDir, { recursive: true });
fs.appendFileSync(path.join(this.dataDir, "audit.jsonl"), `${JSON.stringify({
timestamp: new Date().toISOString(),
...entry
})}\n`);
}
}
function trustedOrigin(ctx, fallback) {
return normalizeOrigin(ctx?.origin || ctx?.platform || fallback || "other");
}
function blockedResult(query, reason, blockedReason, settings, started, now = Date.now()) {
return {
query,
reason,
status: "blocked",
blocked_reason: blockedReason,
result_count: 0,
results: [],
condensed_text: "",
timing_ms: Math.max(0, now - started),
cache_hit: false,
policy_mode: settings.policy_mode
};
}
function cleanError(error) {
if (error?.name === "AbortError") return "Search provider timed out.";
return "Search provider is unavailable.";
}
function userMessage(result) {
if (result.status === "ok") return result.condensed_text || "Web search completed without a usable summary.";
if (result.status === "no_results") return "No permitted web results were found.";
if (result.status === "unavailable") return "Web search is currently unavailable.";
if (result.blocked_reason === "rate_limited") return "Web search is temporarily rate-limited.";
if (result.blocked_reason === "origin_not_allowed") return "Web search is not enabled for this platform.";
if (result.blocked_reason === "tool_disabled") return "Web search is disabled.";
return "Web search was blocked by the configured safety policy.";
}
module.exports = {
WebSearchTool,
blockedResult,
cleanError,
trustedOrigin,
userMessage
};

View File

@ -0,0 +1,87 @@
const fs = require("fs");
const path = require("path");
const metadata = require("../tool_info.json");
function defaults() {
return Object.fromEntries(
Object.entries(metadata.settings_schema).map(([key, field]) => [key, structuredClone(field.default)])
);
}
function readSettings(dataDir) {
const fallback = defaults();
try {
const stored = JSON.parse(fs.readFileSync(settingsPath(dataDir), "utf8"));
return normalizeSettings({ ...fallback, ...stored });
} catch {
return normalizeSettings(fallback);
}
}
function writeSettings(dataDir, value) {
const normalized = normalizeSettings({ ...defaults(), ...value });
fs.mkdirSync(dataDir, { recursive: true });
const file = settingsPath(dataDir);
const temporary = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
try { fs.chmodSync(temporary, 0o600); } catch {}
fs.renameSync(temporary, file);
return normalized;
}
function normalizeSettings(value) {
const mode = value.policy_mode === "blacklist" ? "blacklist" : "whitelist";
const adapter = value.provider_adapter === "generic_json" ? "generic_json" : "searxng_json";
const safeSearch = ["off", "moderate", "strict"].includes(value.safe_search) ? value.safe_search : "strict";
return {
enabled: value.enabled === true,
policy_mode: mode,
url_rules: stringList(value.url_rules, 200),
max_results: integer(value.max_results, 1, 10, 5),
search_timeout_ms: integer(value.search_timeout_ms, 1000, 30000, 8000),
cache_ttl_seconds: integer(value.cache_ttl_seconds, 0, 3600, 300),
safe_search: safeSearch,
allowed_origins: stringList(value.allowed_origins, 6)
.filter((origin) => ["webui", "discord", "twitch", "youtube", "kick", "other"].includes(origin)),
webui_output_chars: integer(value.webui_output_chars, 300, 12000, 4000),
discord_output_chars: integer(value.discord_output_chars, 200, 4000, 1200),
twitch_output_chars: integer(value.twitch_output_chars, 120, 1000, 350),
youtube_output_chars: integer(value.youtube_output_chars, 120, 1500, 500),
kick_output_chars: integer(value.kick_output_chars, 120, 1000, 350),
other_output_chars: integer(value.other_output_chars, 120, 2000, 500),
provider_adapter: adapter,
provider_endpoint: String(value.provider_endpoint || "").trim(),
provider_api_key: String(value.provider_api_key || "").trim(),
provider_api_key_header: value.provider_api_key_header === "Authorization" ? "Authorization" : "X-API-Key",
provider_api_key_prefix: String(value.provider_api_key_prefix || "").trim().slice(0, 32),
provider_query_parameter: /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(String(value.provider_query_parameter || ""))
? String(value.provider_query_parameter)
: "q",
show_source_links: value.show_source_links !== false,
allow_full_page_fetch: value.allow_full_page_fetch === true,
requests_per_minute: integer(value.requests_per_minute, 1, 60, 6)
};
}
function settingsPath(dataDir) {
return path.join(dataDir, "settings.json");
}
function integer(value, minimum, maximum, fallback) {
const number = Number.parseInt(value, 10);
return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : fallback;
}
function stringList(value, limit) {
const rows = Array.isArray(value) ? value : String(value || "").split(/\r?\n|,/);
return [...new Set(rows.map((entry) => String(entry).trim()).filter(Boolean))].slice(0, limit);
}
module.exports = {
defaults,
normalizeSettings,
readSettings,
settingsPath,
writeSettings
};

View File

@ -0,0 +1,132 @@
const dns = require("dns");
const net = require("net");
const METADATA_HOSTS = new Set([
"metadata.google.internal",
"metadata.aws.internal",
"instance-data.ec2.internal"
]);
async function evaluateUrl(value, options = {}) {
let url;
try { url = new URL(String(value)); }
catch { return denied("invalid_url"); }
if (!["http:", "https:"].includes(url.protocol)) return denied("unsafe_protocol");
if (url.username || url.password) return denied("credentials_in_url");
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (isLocalHostname(hostname)) return denied("local_or_metadata_host");
if (net.isIP(hostname) && isPrivateAddress(hostname)) return denied("private_network");
const resolveHost = options.resolveHost || defaultResolveHost;
try {
const addresses = await resolveHost(hostname);
if (!addresses.length || addresses.some(isPrivateAddress)) return denied("private_network");
} catch {
return denied("dns_resolution_failed");
}
const mode = options.mode === "blacklist" ? "blacklist" : "whitelist";
const rules = Array.isArray(options.rules) ? options.rules : [];
const matched = rules.some((rule) => matchesRule(url, rule));
if (mode === "whitelist" && !matched) return denied("not_whitelisted");
if (mode === "blacklist" && matched) return denied("blacklisted");
return { allowed: true, url: canonicalUrl(url), reason: null };
}
async function evaluateNetworkTarget(value, options = {}) {
return evaluateUrl(value, { ...options, mode: "blacklist", rules: [] });
}
function matchesRule(url, ruleValue) {
const raw = String(ruleValue || "").trim();
if (!raw) return false;
const rule = raw.replace(/^GET\s+/i, "").replace(/#.*$/, "");
const hostname = url.hostname.toLowerCase();
const hostPath = `${hostname}${url.pathname}${url.search}`;
const full = canonicalUrl(url);
if (!rule.includes("*")) {
try {
const hasScheme = rule.includes("://");
const parsed = new URL(hasScheme ? rule : `https://${rule}`);
const ruleHost = parsed.hostname.toLowerCase();
const hostMatches = hostname === ruleHost || hostname.endsWith(`.${ruleHost}`);
if (!hostMatches) return false;
if (hasScheme && (url.protocol !== parsed.protocol || url.port !== parsed.port)) return false;
const rulePath = parsed.pathname === "/" && !rule.includes("/") ? "/" : parsed.pathname;
if (rulePath !== "/" && !url.pathname.startsWith(rulePath)) return false;
return !parsed.search || url.search === parsed.search;
} catch {
return false;
}
}
const expression = wildcardExpression(rule.toLowerCase());
return expression.test(full.toLowerCase()) ||
expression.test(hostPath.toLowerCase()) ||
expression.test(hostname);
}
function wildcardExpression(value) {
const escaped = value.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
return new RegExp(`^${escaped}$`, "i");
}
function canonicalUrl(url) {
const copy = new URL(url.href);
copy.hash = "";
return copy.href;
}
function isLocalHostname(hostname) {
return hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local") ||
METADATA_HOSTS.has(hostname);
}
function isPrivateAddress(address) {
const version = net.isIP(address);
if (version === 4) {
const [a, b, c] = address.split(".").map(Number);
return a === 0 || a === 10 || a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 0) ||
(a === 192 && b === 168) ||
(a === 192 && b === 88 && c === 99) ||
(a === 198 && (b === 18 || b === 19)) ||
(a === 198 && b === 51 && c === 100) ||
(a === 203 && b === 0 && c === 113) ||
a >= 224;
}
if (version === 6) {
const normalized = address.toLowerCase();
if (normalized === "::" || normalized === "::1") return true;
if (normalized.startsWith("fc") || normalized.startsWith("fd") ||
/^fe[89ab]/.test(normalized) || normalized.startsWith("ff")) return true;
if (normalized.startsWith("::ffff:")) return true;
if (normalized.startsWith("2001:db8:")) return true;
const first = Number.parseInt(normalized.split(":")[0], 16);
return !Number.isFinite(first) || first < 0x2000 || first > 0x3fff;
}
return true;
}
async function defaultResolveHost(hostname) {
if (net.isIP(hostname)) return [hostname];
const rows = await dns.promises.lookup(hostname, { all: true, verbatim: true });
return rows.map((row) => row.address);
}
function denied(reason) {
return { allowed: false, url: null, reason };
}
module.exports = {
canonicalUrl,
defaultResolveHost,
evaluateNetworkTarget,
evaluateUrl,
isLocalHostname,
isPrivateAddress,
matchesRule,
wildcardExpression
};

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,64 @@
const net = require("net");
const { WebSearchTool } = require("./backend/search_tool");
const { normalizeOrigin } = require("./backend/result_formatter");
const { readSettings } = require("./backend/settings");
const { isLocalHostname, isPrivateAddress } = require("./backend/url_policy");
module.exports.checkAvailability = ({ paths }) => {
const settings = readSettings(paths.data);
if (!settings.enabled) {
return { available: false, message: "Web search is disabled in tool settings." };
}
if (!settings.provider_endpoint) {
return { available: false, message: "Configure a search provider endpoint in Tool Settings." };
}
try {
const endpoint = new URL(settings.provider_endpoint);
const hostname = endpoint.hostname.replace(/^\[|\]$/g, "");
if (!["http:", "https:"].includes(endpoint.protocol) || endpoint.username || endpoint.password ||
isLocalHostname(hostname) || (net.isIP(hostname) && isPrivateAddress(hostname))) {
return { available: false, message: "Search provider endpoint is blocked by network safety rules." };
}
} catch {
return { available: false, message: "Search provider endpoint is invalid." };
}
return { available: true };
};
module.exports.register = ({ registerTool, paths }) => {
const search = new WebSearchTool({ dataDir: paths.data });
registerTool({
tool_id: "lumi_ai_web_search.search",
display_name: "Search the web",
description: "Search current public web information only when verified local Lumi context is insufficient or current external information is explicitly needed. Returns normalized, policy-filtered results for final answer formatting.",
required_role: "user",
required_permission: "lumi_ai_web_search.search",
audit_category: "web_search",
confirmation_required: false,
risk_level: "low",
schema: {
query: { type: "string", required: true },
reason: {
type: "string",
required: true,
enum: [
"fact_lookup",
"resource_lookup",
"troubleshooting",
"documentation_lookup",
"news_or_recent",
"general_lookup"
]
},
requested_depth: { type: "string", required: false, enum: ["search", "full_page"] },
freshness: { type: "string", required: false }
},
permission_check: ({ user, context }) => {
const settings = readSettings(paths.data);
const origin = normalizeOrigin(context?.origin || context?.platform || "other");
return Boolean(user?.id) && settings.enabled && settings.allowed_origins.includes(origin);
},
workflow_handler: ({ arguments: args, user, ctx }) =>
search.run({ ...args, user, ctx })
});
};

View File

@ -0,0 +1,4 @@
window.LumiAiToolSettings = window.LumiAiToolSettings || {};
window.LumiAiToolSettings.lumi_ai_web_search = Object.freeze({
policyExamples: ["docs.example.com", "*.example.com/docs/*", "https://example.com/resources/"]
});

View File

@ -0,0 +1,91 @@
# Lumi AI Web Search
`lumi_ai_web_search` is an AI tool plugin for controlled current-information lookup. It is loaded only by Lumi AI's tool manager and is not an ordinary core plugin.
## Installation and enablement
1. Install this directory as `plugins/lumi_ai_web_search/`.
2. Install Lumi AI `0.7.1` or newer.
3. Open **Plugins -> Lumi AI -> Tools**.
4. Select **Settings** for Lumi AI Web Search.
5. Configure the provider and URL policy, turn on **Web search enabled**, and save.
6. Select **Enable** in the Tools list.
The tool is not registered with the assistant while disabled. If its internal enabled setting or provider endpoint is missing, Lumi AI marks it unavailable without preventing Lumi from starting.
## Provider
The initial adapters accept JSON from a configured public endpoint:
- `searxng_json` reads the SearxNG `results` array.
- `generic_json` reads `results`, `items`, or `web.results.value`.
The configured query parameter defaults to `q`. The adapter adds `format=json`, safe-search level, and result count. API keys are stored in `data/settings.json` with restricted file permissions where supported. The settings API never returns the key and a blank save keeps the existing secret.
Provider requests use a strict timeout, a 2 MiB response limit, and at most three redirects. No page JavaScript is executed.
## URL policy
The default is an empty whitelist, so no result URL is usable until an administrator adds explicit rules. Rules support:
- Domain: `docs.example.com`
- Domain and subdomains: `example.com`
- Subdomain wildcard: `*.example.com`
- Path prefix: `example.com/docs`
- Full wildcard pattern: `https://*.example.com/resources/*`
Whitelist mode permits only matching result, page, and redirect URLs. Blacklist mode permits public URLs except matching rules.
Independent hard network rules always block:
- `localhost`, `.localhost`, `.local`, and known metadata hostnames
- Private, loopback, carrier-grade NAT, link-local, multicast, and reserved IP ranges
- DNS names resolving to private or otherwise unsafe addresses
- URL credentials
- Non-HTTP/HTTPS protocols
The same checks run before each page fetch and after every redirect. Administrator rules cannot override these blocks.
## Tool behavior
The registered tool ID is `lumi_ai_web_search.search`. It accepts:
- `query`
- `reason`: `fact_lookup`, `resource_lookup`, `troubleshooting`, `documentation_lookup`, `news_or_recent`, or `general_lookup`
- Optional `requested_depth`: `search` or `full_page`
- Optional `freshness`
The assistant should use this tool only for current or external information that is not available in verified local Lumi context.
Results are sanitized and returned as structured data rather than raw provider JSON. Each result contains a title, permitted URL or no URL when links are disabled, domain, condensed snippet, source type, date, and relevance score. Documentation and troubleshooting searches prioritize authoritative sources; recent searches prioritize dated sources.
Optional full-page mode extracts bounded visible text only when the administrator enables it. It does not automate a browser, submit forms, execute scripts, or follow unrestricted links.
## Origin limits and rate limits
Allowed origins and output budgets are independently configurable for WebUI, Discord, Twitch, YouTube, Kick, and other sources. Trusted runtime context determines the origin; a model-provided origin cannot elevate access.
Twitch is limited to compact output and at most two source references. Discord permits moderate detail. WebUI permits richer summaries and more results. The tool also applies a per-actor, per-origin, per-server/channel rolling request limit.
## Auditing and storage
All writable data remains under this plugin:
- `data/settings.json`: normalized settings and provider secret
- `data/audit.jsonl`: query, reason, actor, origin, server/channel, policy outcome, result count, cache status, and timing
Provider credentials are not written to audit records or returned in tool results. Updates preserve `data/` by default.
## Security boundary
The plugin has no shell, SQL, arbitrary filesystem, browser automation, or code-execution feature. Network access is limited to the configured public search provider and policy-approved public result pages. Lumi AI's backend role and permission checks remain authoritative.
## Verification
Run:
```powershell
node plugins/lumi_ai_web_search/tests/verify.js
```
The suite covers whitelist/blacklist matching, hard private-network blocks, redirect checks, reason-aware formatting, origin budgets, provider failures, settings effects, registration availability, and audits.

View File

@ -0,0 +1,328 @@
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { SearchProvider } = require("../backend/provider_adapter");
const { formatResults } = require("../backend/result_formatter");
const { WebSearchTool } = require("../backend/search_tool");
const { readSettings, writeSettings } = require("../backend/settings");
const { evaluateUrl, matchesRule } = require("../backend/url_policy");
const { ToolRegistry } = require("../../lumi_ai/backend/tool_router");
const { ToolInstaller } = require("../../lumi_ai/backend/tool_installer");
const { ToolLoader } = require("../../lumi_ai/backend/tool_loader");
const plugin = require("../index");
const PUBLIC_DNS = async () => ["93.184.216.34"];
async function run() {
await verifyPolicy();
await verifyRedirectPolicy();
verifyFormatting();
await verifySearchFlow();
await verifyLoaderLifecycle();
verifyRegistrationAvailability();
verifyStaticFiles();
console.log("Lumi AI Web Search verification passed.");
}
async function verifyLoaderLifecycle() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-web-loader-"));
const pluginsDir = path.join(root, "plugins");
const toolDir = path.join(pluginsDir, "lumi_ai_web_search");
copyDirectory(path.resolve(__dirname, ".."), toolDir, new Set(["data"]));
fs.mkdirSync(path.join(toolDir, "data"), { recursive: true });
const installer = new ToolInstaller({
pluginsDir,
stagingRoot: path.join(root, "staging"),
repoClient: {}
});
const registry = new ToolRegistry(() => {});
const loader = new ToolLoader({
registry,
installer,
settings: { getSetting: (_key, fallback) => fallback },
stateFile: path.join(root, "enabled.json"),
lumiAiVersion: "0.7.1",
lumiVersion: "0.1.0"
});
const unavailable = await loader.enable("lumi_ai_web_search");
assert.equal(unavailable.unavailable, true);
assert.equal(registry.tools.has("lumi_ai_web_search.search"), false);
writeSettings(path.join(toolDir, "data"), providerSettings());
const enabled = await loader.enable("lumi_ai_web_search");
assert.equal(enabled.loaded, true);
assert.equal(registry.tools.has("lumi_ai_web_search.search"), true);
await loader.disable("lumi_ai_web_search");
assert.equal(registry.tools.has("lumi_ai_web_search.search"), false);
fs.rmSync(root, { recursive: true, force: true });
}
async function verifyPolicy() {
let result = await evaluateUrl("https://docs.example.com/guide", {
mode: "whitelist",
rules: ["*.example.com/*"],
resolveHost: PUBLIC_DNS
});
assert.equal(result.allowed, true);
result = await evaluateUrl("https://unrelated.test/guide", {
mode: "whitelist",
rules: ["*.example.com/*"],
resolveHost: PUBLIC_DNS
});
assert.equal(result.allowed, false);
assert.equal(result.reason, "not_whitelisted");
result = await evaluateUrl("https://ads.example.com/tracker", {
mode: "blacklist",
rules: ["*.example.com/tracker*"],
resolveHost: PUBLIC_DNS
});
assert.equal(result.allowed, false);
result = await evaluateUrl("https://docs.example.org/", {
mode: "blacklist",
rules: ["*.example.com/tracker*"],
resolveHost: PUBLIC_DNS
});
assert.equal(result.allowed, true);
for (const target of [
"http://127.0.0.1/",
"http://10.1.2.3/",
"http://169.254.169.254/latest/meta-data/",
"http://localhost/",
"file:///etc/passwd"
]) {
result = await evaluateUrl(target, {
mode: "blacklist",
rules: [],
resolveHost: PUBLIC_DNS
});
assert.equal(result.allowed, false, target);
}
result = await evaluateUrl("https://dns-rebind.example/", {
mode: "blacklist",
rules: [],
resolveHost: async () => ["10.0.0.8"]
});
assert.equal(result.allowed, false);
assert.equal(matchesRule(new URL("https://docs.example.com/guide/start"), "example.com/guide"), true);
assert.equal(matchesRule(new URL("https://example.com/"), "http://example.com/"), false);
}
async function verifyRedirectPolicy() {
const provider = new SearchProvider({
resolveHost: PUBLIC_DNS,
fetch: async () => response({
status: 302,
headers: { location: "http://127.0.0.1/private" }
})
});
await assert.rejects(
() => provider.search("test", providerSettings()),
/blocked by policy/i
);
let calls = 0;
const crossOrigin = new SearchProvider({
resolveHost: PUBLIC_DNS,
fetch: async () => {
calls += 1;
return response({
status: 302,
headers: { location: "https://other-provider.example/search" }
});
}
});
await assert.rejects(
() => crossOrigin.search("test", { ...providerSettings(), provider_api_key: "secret" }),
/cross_origin_provider_redirect/i
);
assert.equal(calls, 1);
}
function verifyFormatting() {
const rows = [
result("Official docs", "https://docs.example.com/guide", "A detailed official answer for the requested subject.", "documentation"),
result("Community post", "https://community.example.com/post", "A secondary explanation with useful context.", "web"),
result("Recent update", "https://news.example.com/update", "A recently published update.", "news", "2026-06-12")
];
const settings = {
max_results: 5,
show_source_links: true,
twitch_output_chars: 180,
discord_output_chars: 700,
webui_output_chars: 3000,
other_output_chars: 500
};
const fact = formatResults(rows, { reason: "fact_lookup", origin: "twitch", settings });
const resource = formatResults(rows, { reason: "resource_lookup", origin: "discord", settings });
const webui = formatResults(rows, { reason: "documentation_lookup", origin: "webui", settings });
assert(fact.condensed_text.length <= 180);
assert(fact.results.length <= 2);
assert(resource.condensed_text.length <= 700);
assert(webui.condensed_text.length > fact.condensed_text.length);
assert.equal(webui.results[0].source_type, "documentation");
}
async function verifySearchFlow() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-web-search-"));
const settings = {
...providerSettings(),
enabled: true,
policy_mode: "whitelist",
url_rules: ["*.example.com/*"],
allowed_origins: ["webui", "discord", "twitch"],
cache_ttl_seconds: 60
};
writeSettings(root, settings);
let calls = 0;
const provider = {
resolveHost: PUBLIC_DNS,
async search() {
calls += 1;
return [
result("<b>Verified fact</b>", "https://docs.example.com/fact", "The <em>answer</em> is current.", "documentation"),
result("Blocked local", "http://127.0.0.1/private", "Must never be returned.", "web")
];
},
async fetchPage() {
return { url: "https://docs.example.com/fact", text: "Expanded public page text." };
}
};
const tool = new WebSearchTool({ dataDir: root, provider });
const first = await tool.run({
query: "current fact",
reason: "fact_lookup",
user: { id: "user-1" },
ctx: { origin: "webui", server_id: "server-1" }
});
assert.equal(first.status, "ok");
assert.equal(first.result_count, 1);
assert.equal(first.results[0].title, "Verified fact");
assert.equal(first.results.some((entry) => entry.url?.includes("127.0.0.1")), false);
const cached = await tool.run({
query: "current fact",
reason: "fact_lookup",
user: { id: "user-1" },
ctx: { origin: "webui", server_id: "server-1" }
});
assert.equal(cached.cache_hit, true);
assert.equal(calls, 1);
const twitch = await tool.run({
query: "current fact twitch",
reason: "resource_lookup",
user: { id: "user-1" },
ctx: { origin: "twitch", channel_id: "channel-1" }
});
assert(twitch.condensed_text.length <= readSettings(root).twitch_output_chars);
assert.equal(calls, 2);
writeSettings(root, { ...readSettings(root), allowed_origins: ["webui"] });
const blockedOrigin = await tool.run({
query: "current fact",
reason: "fact_lookup",
origin: "webui",
user: { id: "user-1" },
ctx: { origin: "discord" }
});
assert.equal(blockedOrigin.status, "blocked");
assert.equal(blockedOrigin.blocked_reason, "origin_not_allowed");
const failing = new WebSearchTool({
dataDir: root,
provider: {
resolveHost: PUBLIC_DNS,
async search() { throw new Error("provider secret https://provider.example/api?token=secret"); }
}
});
const failed = await failing.run({
query: "failure",
reason: "general_lookup",
user: { id: "user-2" },
ctx: { origin: "webui" }
});
assert.equal(failed.status, "unavailable");
assert.equal(failed.error.includes("provider.example"), false);
const audit = fs.readFileSync(path.join(root, "audit.jsonl"), "utf8")
.trim().split(/\r?\n/).map(JSON.parse);
assert(audit.some((entry) =>
entry.query === "current fact" &&
entry.actor === "user-1" &&
entry.origin === "webui" &&
typeof entry.timing_ms === "number"
));
fs.rmSync(root, { recursive: true, force: true });
}
function verifyRegistrationAvailability() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-web-register-"));
assert.equal(plugin.checkAvailability({ paths: { data: root } }).available, false);
writeSettings(root, { ...providerSettings(), enabled: true });
assert.equal(plugin.checkAvailability({ paths: { data: root } }).available, true);
const definitions = [];
plugin.register({
paths: { data: root },
registerTool: (definition) => definitions.push(definition)
});
assert.equal(definitions.length, 1);
assert.equal(definitions[0].tool_id, "lumi_ai_web_search.search");
assert.equal(definitions[0].permission_check({
user: { id: "user" },
context: { origin: "webui" }
}), true);
fs.rmSync(root, { recursive: true, force: true });
}
function verifyStaticFiles() {
const root = path.resolve(__dirname, "..");
const metadata = JSON.parse(fs.readFileSync(path.join(root, "tool_info.json"), "utf8"));
assert.equal(metadata.tool_id, "lumi_ai_web_search");
assert.equal(metadata.settings_schema.policy_mode.default, "whitelist");
assert(fs.existsSync(path.join(root, "readme.md")));
assert(fs.readFileSync(path.join(root, "views", "settings-modal.ejs"), "utf8").includes("settings_schema"));
}
function providerSettings() {
return {
...Object.fromEntries(
Object.entries(require("../tool_info.json").settings_schema).map(([key, field]) => [key, structuredClone(field.default)])
),
provider_endpoint: "https://search.example.net/search",
enabled: true,
allowed_origins: ["webui", "discord", "twitch"],
url_rules: ["*.example.com/*"]
};
}
function result(title, url, snippet, sourceType, date = null) {
return { title, url, snippet, source_type: sourceType, date, relevance_score: 0.9 };
}
function response({ status = 200, headers = {}, body = "" }) {
const normalized = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
return {
ok: status >= 200 && status < 300,
status,
headers: {
get(name) { return normalized[String(name).toLowerCase()] || null; }
},
async arrayBuffer() { return Buffer.from(body); }
};
}
function copyDirectory(source, destination, ignored = new Set()) {
fs.mkdirSync(destination, { recursive: true });
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
if (ignored.has(entry.name)) continue;
const from = path.join(source, entry.name);
const to = path.join(destination, entry.name);
if (entry.isDirectory()) copyDirectory(from, to);
else if (entry.isFile()) fs.copyFileSync(from, to);
}
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -0,0 +1,232 @@
{
"tool_id": "lumi_ai_web_search",
"name": "lumi_ai_web_search",
"display_name": "Lumi AI Web Search",
"version": "1.0.0",
"description": "Controlled current-information search for Lumi Assistant with URL policy, origin budgets, and source normalization.",
"scope": {
"label": "Assistant web lookup",
"required_role": "user"
},
"permissions": {
"required_role": "user",
"permission": "lumi_ai_web_search.search"
},
"capabilities": [
"Current web search through an administrator-configured JSON search provider",
"Whitelist or blacklist URL policy with wildcard rules",
"Optional bounded page excerpt fetching",
"Context-aware condensed results for WebUI and chat platforms"
],
"limitations": [
"Requires an administrator-configured search provider endpoint",
"Does not provide browser automation or execute page scripts",
"Private, local, link-local, metadata, and non-HTTP targets are always blocked",
"Search quality and freshness depend on the configured provider"
],
"tool_type": "web_search",
"owning_plugin": "lumi_ai",
"entrypoints": {
"backend": "index.js"
},
"frontend_assets": [
"public"
],
"views": [
"views/settings-modal.ejs"
],
"dependencies": [],
"minimum_lumi_version": "0.1.0",
"minimum_lumi_ai_version": "0.7.1",
"required_plugins": [
"core",
"lumi_ai"
],
"required_platforms": [],
"risk_level": "low",
"confirmation_required": false,
"data_paths": [
"data/settings.json",
"data/audit.jsonl"
],
"preserve_on_update": [
"data"
],
"update_notes": "Initial controlled web-search provider with policy enforcement and per-origin output budgets.",
"author": "Lumi",
"homepage": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi",
"repository_path": "plugins/lumi_ai_web_search",
"settings_schema": {
"enabled": {
"type": "boolean",
"label": "Web search enabled",
"description": "The tool remains unavailable to Lumi Assistant until this and the parent Tools enable state are both on.",
"default": false
},
"policy_mode": {
"type": "enum",
"label": "Search policy",
"description": "Whitelist permits only matching result/page URLs. Blacklist permits URLs except matching rules.",
"options": [
"whitelist",
"blacklist"
],
"default": "whitelist"
},
"url_rules": {
"type": "string_list",
"label": "URL policy rules",
"description": "One domain, URL, path prefix, or * wildcard pattern per line. Example: docs.example.com or *.example.com/docs/*.",
"default": [],
"rows": 6
},
"max_results": {
"type": "integer",
"label": "Maximum results",
"default": 5,
"minimum": 1,
"maximum": 10
},
"search_timeout_ms": {
"type": "integer",
"label": "Search timeout (ms)",
"default": 8000,
"minimum": 1000,
"maximum": 30000
},
"cache_ttl_seconds": {
"type": "integer",
"label": "Result cache TTL (seconds)",
"default": 300,
"minimum": 0,
"maximum": 3600
},
"safe_search": {
"type": "enum",
"label": "Safe search",
"options": [
"off",
"moderate",
"strict"
],
"default": "strict"
},
"allowed_origins": {
"type": "multi_select",
"label": "Allowed origins",
"options": [
"webui",
"discord",
"twitch",
"youtube",
"kick",
"other"
],
"default": [
"webui"
]
},
"webui_output_chars": {
"type": "integer",
"label": "WebUI output budget",
"default": 4000,
"minimum": 300,
"maximum": 12000
},
"discord_output_chars": {
"type": "integer",
"label": "Discord output budget",
"default": 1200,
"minimum": 200,
"maximum": 4000
},
"twitch_output_chars": {
"type": "integer",
"label": "Twitch output budget",
"default": 350,
"minimum": 120,
"maximum": 1000
},
"youtube_output_chars": {
"type": "integer",
"label": "YouTube output budget",
"default": 500,
"minimum": 120,
"maximum": 1500
},
"kick_output_chars": {
"type": "integer",
"label": "Kick output budget",
"default": 350,
"minimum": 120,
"maximum": 1000
},
"other_output_chars": {
"type": "integer",
"label": "Other output budget",
"default": 500,
"minimum": 120,
"maximum": 2000
},
"provider_adapter": {
"type": "enum",
"label": "Provider adapter",
"options": [
"searxng_json",
"generic_json"
],
"default": "searxng_json"
},
"provider_endpoint": {
"type": "string",
"label": "Provider endpoint",
"description": "HTTPS is recommended. The endpoint must be publicly routable and return JSON.",
"default": ""
},
"provider_api_key": {
"type": "string",
"label": "Provider API key",
"description": "Stored in the plugin data directory and never returned by the settings API.",
"default": "",
"secret": true
},
"provider_api_key_header": {
"type": "enum",
"label": "API key header",
"options": [
"X-API-Key",
"Authorization"
],
"default": "X-API-Key"
},
"provider_api_key_prefix": {
"type": "string",
"label": "API key prefix",
"description": "For example: Bearer",
"default": ""
},
"provider_query_parameter": {
"type": "string",
"label": "Query parameter",
"default": "q"
},
"show_source_links": {
"type": "boolean",
"label": "Show source links",
"default": true
},
"allow_full_page_fetch": {
"type": "boolean",
"label": "Allow page excerpts",
"description": "Allows bounded text extraction after search discovery. URL policy and redirect checks still apply.",
"default": false
},
"requests_per_minute": {
"type": "integer",
"label": "Requests per actor/minute",
"default": 6,
"minimum": 1,
"maximum": 60
}
}
}

View File

@ -0,0 +1,4 @@
<p class="hint">
Lumi AI renders this tool's settings from <code>tool_info.json</code> <code>settings_schema</code>. Provider secrets are write-only,
and policy changes apply to WebUI and platform tool calls after saving.
</p>