Lumi/plugins/lumi_ai/backend/repo_indexer.js
2026-06-12 19:27:43 +02:00

315 lines
13 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { PLUGIN_ROOT, resolveData } = require("./paths");
const INDEX_FILE = "index.json";
const REPOSITORY_URL = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi";
const SOURCE_EXTENSIONS = new Set([".js", ".ejs", ".json", ".md"]);
const ROUTE_ALIASES = Object.freeze({
"/admin/twitch-wizard": "twitch configuration config settings wizard location where integration",
"/admin/discord-wizard": "discord configuration config settings wizard location where integration",
"/admin/youtube-wizard": "youtube configuration config settings wizard location where integration",
"/admin/plugins": "plugin plugins settings configuration management",
"/admin/commands": "command commands custom command settings management",
"/plugins/echonomy-framework": "economy echonomy currency points banking settings",
"/plugins/moderation": "moderation mod tools sanctions bans timeouts settings"
});
function repoRoot() {
return path.resolve(PLUGIN_ROOT, "..", "..");
}
function refreshIndex(root = repoRoot()) {
const routes = [];
const documents = [];
const plugins = [];
const commands = [];
walk(root, (file) => {
const relative = normalize(path.relative(root, file));
if (relative.startsWith("plugins/lumi_ai/data/") || relative.startsWith("updates/") || relative.includes("/node_modules/")) return;
const extension = path.extname(file).toLowerCase();
if (!SOURCE_EXTENSIONS.has(extension)) return;
const content = safeRead(file);
if (!content) return;
if (extension === ".js") collectRoutes(content, relative, routes);
if (relative.endsWith("/plugin.json")) collectPlugin(content, relative, plugins);
if (relative.endsWith("/cmds.json")) collectCommands(content, relative, commands);
if (extension === ".md" || extension === ".ejs") {
documents.push({ path: relative, title: firstTitle(content) || path.basename(file), summary: summarize(content) });
}
});
const index = {
schema_version: 1,
source: "local",
repository: REPOSITORY_URL,
root: normalize(root),
indexed_at: new Date().toISOString(),
commit: readGitCommit(root),
routes: uniqueBy(routes, (entry) => `${entry.method}:${entry.path}`),
plugins: uniqueBy(plugins, (entry) => entry.id),
commands: uniqueBy(commands, (entry) => `${entry.plugin}:${entry.name}`),
documents
};
const target = resolveData("repo_index", INDEX_FILE);
fs.writeFileSync(target, `${JSON.stringify(index, null, 2)}\n`);
return index;
}
function refreshPublicIndex() {
const staging = resolveData("repo_index", `public-${Date.now()}`);
try {
const result = spawnSync("git", ["clone", "--depth", "1", REPOSITORY_URL, staging], {
encoding: "utf8",
timeout: 120000,
windowsHide: true,
shell: false
});
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || "Git clone failed.").trim().slice(0, 1000));
}
const index = refreshIndex(staging);
index.source = "public";
index.root = REPOSITORY_URL;
fs.writeFileSync(resolveData("repo_index", INDEX_FILE), `${JSON.stringify(index, null, 2)}\n`);
return index;
} finally {
const resolved = path.resolve(staging);
const parent = path.resolve(resolveData("repo_index"));
if (resolved.startsWith(`${parent}${path.sep}`)) fs.rmSync(resolved, { recursive: true, force: true });
}
}
function loadIndex() {
const target = resolveData("repo_index", INDEX_FILE);
if (!fs.existsSync(target)) return null;
try { return JSON.parse(fs.readFileSync(target, "utf8")); } catch { return null; }
}
function indexStatus(index = loadIndex()) {
return index ? {
present: true,
indexed_at: index.indexed_at,
commit: index.commit,
route_count: index.routes?.length || 0,
plugin_count: index.plugins?.length || 0,
command_count: index.commands?.length || 0,
stale: Date.now() - new Date(index.indexed_at).getTime() > 7 * 24 * 60 * 60 * 1000
} : { present: false, indexed_at: null, commit: null, route_count: 0, plugin_count: 0, command_count: 0, stale: true };
}
function lookupSupport(message, index = loadIndex()) {
if (!message) return null;
const query = String(message).toLowerCase();
const terms = query.split(/[^a-z0-9]+/).filter((term) => term.length > 2);
if (isCommunityContactQuery(query)) {
return {
type: "contact",
text: "Use the Discord community server to contact OokamiKunTV, Jenni, administrators, moderators, or community staff. I could not verify a Lumi WebUI, notification, direct-message, or Throne contact workflow."
};
}
const ambiguous = /\b(this|that|it|option|setting|settings)\b/i.test(query) &&
!/\b(lumi|bot|webui|plugin|twitch|discord|youtube|moderation|command)\b/i.test(query);
if (ambiguous) {
return {
type: "clarification",
text: "Do you mean a setting in Lumi Bot, or a setting on the external platform?"
};
}
if (!index) {
return isLumiFeatureQuery(query) ? {
type: "unknown",
text: "I could not verify that Lumi feature or WebUI location because the current repository and help index is unavailable. Which exact feature, plugin, or page do you mean?"
} : null;
}
const candidates = augmentedRoutes(index)
.filter((route) => route.method === "GET")
.map((route) => ({ ...route, score: scoreRoute(route, terms) }))
.filter((route) => route.score > 0)
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
if (!candidates.length || candidates[0].score < 2) {
if (!isLumiFeatureQuery(query)) return null;
return {
type: "unknown",
text: "I could not verify that Lumi feature or WebUI location in the current repository and help index. It may not exist or may use a different name. Which exact feature, plugin, or page do you mean?"
};
}
const best = candidates[0];
return {
type: "route",
text: `${routeLabel(best.path)} is available in Lumi's WebUI.`,
links: [{ href: best.path, label: routeLabel(best.path) }],
source: { path: best.source, confidence: best.score >= 5 ? "high" : "medium" }
};
}
function isCommunityContactQuery(query) {
const contact = /\b(contact|reach|message|dm|notify|notification|talk\s+to|send\s+(?:a\s+)?message)\b/i.test(query);
const person = /\b(ookamikuntv|ookami|jenni|admin|admins|administrator|administrators|moderator|moderators|mods?|community\s+staff|staff)\b/i.test(query);
return contact && person;
}
function isLumiFeatureQuery(query) {
const lumiSubject = /\b(lumi|webui|plugin|plugins|feature|features|route|routes|page|pages|menu|setting|settings|command|commands|throne|notification|notifications|messaging|messages?|direct messages?|dm|inbox)\b/i.test(query);
const featureIntent = /\b(where|find|open|navigate|location|located|does|is there|can lumi|how (?:do|can)|support|supports|have|has|enable|configure|setup|set up)\b/i.test(query);
return lumiSubject && featureIntent;
}
function verifyInternalLinks(links, index = loadIndex()) {
const known = new Set((index?.routes || []).filter((route) => route.method === "GET").map((route) => route.path));
return (Array.isArray(links) ? links : []).filter((link) =>
link && typeof link.href === "string" && link.href.startsWith("/") && known.has(link.href)
);
}
function supportContext(message, index = loadIndex(), limit = 8, role = "user", allowModeratorCodeHelp = false) {
if (typeof limit === "string") {
allowModeratorCodeHelp = role === true;
role = limit;
limit = 8;
}
if (!index || !message) return [];
const terms = String(message).toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length > 2);
const technical = role === "admin";
const snippetsAllowed = role === "mod" && allowModeratorCodeHelp;
const rows = [
...augmentedRoutes(index).map((entry) => ({
text: technical
? `Lumi route ${entry.path}; source ${entry.source || "curated route index"}`
: `${routeLabel(entry.path)}: ${entry.path}`,
value: `${entry.path} ${entry.source || ""} ${ROUTE_ALIASES[entry.path] || ""}`
})),
...(index.plugins || []).map((entry) => ({
text: technical
? `Plugin ${entry.name}: ${entry.description} (${entry.path})`
: `Plugin ${entry.name}: ${entry.description}`,
value: `${entry.name} ${entry.description} ${entry.id}`
})),
...(index.commands || []).map((entry) => ({
text: technical || snippetsAllowed
? `Command ${entry.name}: ${entry.description}${technical ? ` (${entry.plugin})` : ""}`
: `Command ${entry.name}: ${entry.description}`,
value: `${entry.name} ${entry.description} ${entry.plugin}`
})),
...(technical ? index.documents || [] : []).map((entry) => ({
text: `${entry.title}: ${entry.summary} (${entry.path})`,
value: `${entry.title} ${entry.summary} ${entry.path}`
}))
];
return rows.map((row) => ({
text: row.text,
score: terms.reduce((score, term) => score + (row.value.toLowerCase().includes(term) ? 1 : 0), 0)
})).filter((row) => row.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((row) => row.text);
}
function collectRoutes(content, source, output) {
const pattern = /\b(?:app|router)\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g;
let match;
while ((match = pattern.exec(content))) {
if (!match[2].includes(":") && !match[2].includes("${")) {
const pluginMatch = source.match(/^plugins\/([^/]+)\/index\.js$/);
const routePath = pluginMatch && match[2].startsWith("/")
? `/plugins/${pluginMatch[1]}${match[2] === "/" ? "" : match[2]}`
: match[2];
output.push({ method: match[1].toUpperCase(), path: routePath, source });
}
}
}
function collectPlugin(content, source, output) {
try {
const manifest = JSON.parse(content);
if (manifest.id) output.push({ id: manifest.id, name: manifest.name || manifest.id, description: manifest.description || "", path: source });
} catch {}
}
function collectCommands(content, source, output) {
try {
const parsed = JSON.parse(content);
const rows = Array.isArray(parsed) ? parsed : parsed.commands || [];
for (const command of rows) {
const name = command.name || command.command || command.trigger;
if (name) output.push({ plugin: source.split("/")[1] || "core", name, description: command.description || "" });
}
} catch {}
}
function walk(dir, visit) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if ([".git", "node_modules", "updates"].includes(entry.name)) continue;
const target = path.join(dir, entry.name);
if (entry.isDirectory()) walk(target, visit);
else if (entry.isFile()) visit(target);
}
}
function readGitCommit(root) {
try {
const git = path.join(root, ".git");
const head = fs.readFileSync(path.join(git, "HEAD"), "utf8").trim();
if (!head.startsWith("ref: ")) return head;
return fs.readFileSync(path.join(git, head.slice(5)), "utf8").trim();
} catch { return null; }
}
function scoreRoute(route, terms) {
const haystack = `${route.path} ${route.source || ""} ${ROUTE_ALIASES[route.path] || ""}`.toLowerCase();
const generic = new Set(["lumi", "webui", "setting", "settings", "admin", "page"]);
return terms.reduce((score, term) => {
if (!haystack.includes(term)) return score;
if (route.path.includes(term) || ROUTE_ALIASES[route.path]?.includes(term)) return score + (generic.has(term) ? 1 : 4);
return score + 1;
}, 0);
}
function augmentedRoutes(index) {
return [...(index?.routes || [])].filter((route) => route.method === "GET");
}
function verifiedRoutePaths(index = loadIndex()) {
return [...new Set(augmentedRoutes(index).map((route) => route.path))];
}
function routeLabel(route) {
const known = {
"/admin/twitch-wizard": "Twitch Configuration Wizard",
"/admin/discord-wizard": "Discord Configuration Wizard",
"/admin/youtube-wizard": "YouTube Configuration Wizard",
"/admin/plugins": "Plugin Management",
"/admin/commands": "Command Management",
"/admin/settings": "Bot Settings"
};
const segment = route.split("/").filter(Boolean).pop() || "Lumi Home";
return known[route] || segment.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function firstTitle(content) {
return content.match(/^\s*#\s+(.+)$/m)?.[1]?.trim() || content.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1]?.trim();
}
function summarize(content) {
return content.replace(/<[^>]+>/g, " ").replace(/[`#*_>\r\n]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 280);
}
function safeRead(file) {
try { return fs.statSync(file).size <= 2 * 1024 * 1024 ? fs.readFileSync(file, "utf8") : ""; } catch { return ""; }
}
function normalize(value) { return value.split(path.sep).join("/"); }
function uniqueBy(items, key) { return [...new Map(items.map((item) => [key(item), item])).values()]; }
module.exports = {
REPOSITORY_URL,
refreshIndex,
refreshPublicIndex,
loadIndex,
indexStatus,
lookupSupport,
supportContext,
verifyInternalLinks,
verifiedRoutePaths,
isCommunityContactQuery,
isLumiFeatureQuery,
repoRoot
};