Add Lumi AI, birthday plugin, and persistent updates

This commit is contained in:
Franz Rolfsvaag 2026-06-11 06:35:43 +02:00
parent 5588819df4
commit 34e78d69c3
56 changed files with 3701 additions and 13 deletions

7
.gitignore vendored
View File

@ -1,6 +1,9 @@
node_modules/
data/
updates/
/data/
/updates/
plugins/*/data/**
!plugins/*/data/**/
!plugins/*/data/**/.gitkeep
.env
.env.*
!.env.example

View File

@ -112,6 +112,8 @@ Plugins (important)
- Plugins should avoid core edits unless explicitly requested
- Plugins receive `webhooks` for raw-body inbound endpoint registration and
outbound webhook sending. See `docs/webhooks.md`.
- `web.addAssistantPanel({ id, view, stylesheet?, script?, role?, isVisible?, locals? })`
contributes a role-filtered sidebar pill/global panel above the user footer.
Current notable plugins
- echonomy-framework:

View File

@ -0,0 +1,43 @@
# Birthday Plugin
Standalone Lumi plugin for birthday profile settings, chat commands, Discord birthday announcements, and optional echonomy birthday gifts.
## Install
Install `updates/lumi-plugin-birthday-v0.1.0.zip` through Admin -> Plugins. The ZIP is built from the contents of this folder, so `plugin.json` is at the ZIP root.
## Commands
- `!birthday` shows help and the caller's stored birthday when available.
- `!birthday set YYYY/MM/DD` stores a full birthday.
- `!birthday set MM/DD` stores a birthday without a year.
- `!birthday unset` removes the caller's birthday.
- `!birthday <user>` looks up a Lumi user by internal username, linked display name, provider user ID, or Discord mention.
- `!birthday claim` claims the configured gift when manual gift mode is enabled.
The `bday` alias is also registered. The command supports Discord, Twitch, and YouTube command contexts.
## WebUI
- Admin/mod page: `/plugins/birthday`
- Owner profile section: rendered on `/profile` through `web.addProfileSection`
- Visitor birthday card: `/plugins/birthday/u/:username`
Lumi currently exposes an owner profile section hook, but this repository does not expose a separate public visitor profile hook for plugins. Because of that, visitor display is implemented as the plugin route above instead of being injected into a core public profile page.
## Storage
Birthdays are stored in plugin-owned tables:
- `birthday_profiles`
- `birthday_deliveries`
Plugin settings are stored in `plugin_settings` with `plugin_id = 'birthday'`.
## Notes
- Accepted date formats are `YYYY/MM/DD` and `MM/DD`.
- Dash-separated dates and `DD/MM` dates are rejected.
- Default privacy is `limited`.
- If echonomy is not loaded, birthday announcements still work and gifts are skipped.
- Automatic gifts are delivered once per birthday occurrence. Manual gifts are claimed once per birthday occurrence.

View File

@ -0,0 +1,15 @@
{
"pluginId": "birthday",
"commands": [
{
"id": "birthday",
"name": "Birthday",
"trigger": "birthday",
"aliases": ["bday"],
"description": "Set, unset, or look up birthdays.",
"usage": "birthday set YYYY/MM/DD | birthday set MM/DD | birthday unset | birthday <user>",
"platforms": ["discord", "twitch", "youtube"],
"origin": "plugin:birthday"
}
]
}

964
plugins/birthday/index.js Normal file
View File

@ -0,0 +1,964 @@
const crypto = require("crypto");
const path = require("path");
const PLUGIN_ID = "birthday";
const TIMER_KEY = Symbol.for("lumi.birthday.interval");
const ALLOWED_PRIVACY = new Set(["public", "limited", "private"]);
const ALLOWED_PLACEHOLDERS = new Set([
"username",
"display_name",
"pronoun",
"pronoun_subject",
"pronoun_object",
"pronoun_possessive",
"birthday",
"birthday_day",
"birthday_day_text",
"birthday_month",
"time_until_birthday",
"days_until_birthday",
"months_until_birthday",
"age_before",
"age_after",
"birthday_weekday",
"gift_amount",
"gift_amount_text",
"currency_name"
]);
const DEFAULT_FULL = [
"Happy birthday, {display_name}! {display_name} is turning {age_after} today.",
"Everyone wish {display_name} a happy birthday! From {age_before} to {age_after} - hope today is amazing."
];
const DEFAULT_PARTIAL = [
"Happy birthday, {display_name}! Hope today is full of good vibes and cake.",
"Everyone wish {display_name} a happy birthday today!"
];
const DEFAULTS = {
enabled: "1",
announcement_channel_id: "",
timezone: "UTC",
leap_day_policy: "feb28",
gift_mode: "automatic",
gift_amount: "0",
birthday_check_interval_minutes: "60",
response_templates: JSON.stringify({
fullYear: DEFAULT_FULL.map((text) => newTemplate(text)),
partialYear: DEFAULT_PARTIAL.map((text) => newTemplate(text))
})
};
module.exports = {
id: PLUGIN_ID,
init({ web, db, settings, commandRouter, discordClient }) {
ensureTables(db);
ensureDefaults(db);
registerCommands({ db, settings, commandRouter });
const router = web.createRouter();
router.get("/", async (req, res) => {
const user = req.session.user || null;
if (!canModerate(user)) {
return renderDenied(res);
}
const config = getConfig(db);
const diagnostics = await buildDiagnostics(discordClient, config);
res.render(path.join(__dirname, "views", "birthday-admin.ejs"), {
title: "Birthdays",
config,
diagnostics,
textChannels: getTextChannels(discordClient),
isAdmin: Boolean(user?.isAdmin),
allowedPlaceholders: Array.from(ALLOWED_PLACEHOLDERS).sort(),
previewTemplate,
formatDateTime
});
});
router.post("/settings", async (req, res) => {
const user = req.session.user || null;
if (!canModerate(user)) {
return renderDenied(res);
}
const config = getConfig(db);
const next = {
enabled: req.body.enabled === "on" ? "1" : "0",
announcement_channel_id: (req.body.announcement_channel_id || "").trim(),
timezone: (req.body.timezone || "UTC").trim(),
leap_day_policy: req.body.leap_day_policy === "mar1" ? "mar1" : "feb28",
gift_mode: req.body.gift_mode === "manual" ? "manual" : "automatic",
birthday_check_interval_minutes: String(clampInt(req.body.birthday_check_interval_minutes, 5, 1440, 60)),
gift_amount: config.gift_amount,
response_templates: JSON.stringify(config.response_templates)
};
if (!isValidTimezone(next.timezone)) {
req.session.flash = { type: "error", message: "Timezone must be a valid IANA timezone." };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
if (next.announcement_channel_id) {
const channel = await validateTextChannel(discordClient, next.announcement_channel_id);
if (!channel.valid) {
req.session.flash = { type: "error", message: channel.message };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
}
if (user?.isAdmin) {
next.gift_amount = String(Math.max(0, parseInt(req.body.gift_amount || "0", 10) || 0));
} else if ((req.body.gift_amount || "").trim() !== String(config.gift_amount)) {
req.session.flash = { type: "error", message: "Only admins can change the birthday gift amount." };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
saveConfig(db, next);
restartScheduler({ db, discordClient });
req.session.flash = { type: "success", message: "Birthday settings saved." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/templates/create", (req, res) => {
if (!canModerate(req.session.user)) {
return renderDenied(res);
}
const pool = normalizePool(req.body.pool);
const text = (req.body.text || "").trim();
const error = validateTemplate(text);
if (error) {
req.session.flash = { type: "error", message: error };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
const config = getConfig(db);
config.response_templates[pool].push(newTemplate(text));
saveConfig(db, serializeConfig(config));
req.session.flash = { type: "success", message: "Template added." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/templates/:id/update", (req, res) => {
if (!canModerate(req.session.user)) {
return renderDenied(res);
}
const pool = normalizePool(req.body.pool);
const text = (req.body.text || "").trim();
const error = validateTemplate(text);
if (error) {
req.session.flash = { type: "error", message: error };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
const config = getConfig(db);
const template = config.response_templates[pool].find((item) => item.id === req.params.id);
if (!template) {
req.session.flash = { type: "error", message: "Template not found." };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
template.text = text;
template.enabled = req.body.enabled === "on";
template.updatedAt = Date.now();
saveConfig(db, serializeConfig(config));
req.session.flash = { type: "success", message: "Template updated." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/templates/:id/duplicate", (req, res) => {
if (!canModerate(req.session.user)) {
return renderDenied(res);
}
const pool = normalizePool(req.body.pool);
const config = getConfig(db);
const source = config.response_templates[pool].find((item) => item.id === req.params.id);
if (source) {
config.response_templates[pool].push(newTemplate(source.text));
saveConfig(db, serializeConfig(config));
}
req.session.flash = { type: "success", message: "Template duplicated." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/templates/:id/remove", (req, res) => {
if (!canModerate(req.session.user)) {
return renderDenied(res);
}
const pool = normalizePool(req.body.pool);
const config = getConfig(db);
config.response_templates[pool] = config.response_templates[pool].filter((item) => item.id !== req.params.id);
saveConfig(db, serializeConfig(config));
req.session.flash = { type: "success", message: "Template removed." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/profile", (req, res) => {
const user = req.session.user || null;
if (!user?.id) {
return renderDenied(res);
}
const parsed = parseBirthdayInput(req.body.birthday || "");
if (!parsed.ok) {
req.session.flash = { type: "error", message: parsed.message };
return res.redirect("/profile");
}
const privacy = normalizePrivacy(req.body.privacy);
upsertBirthday(db, user.id, { ...parsed, privacy });
req.session.flash = {
type: "success",
message: `Stored birthday: ${formatBirthday(parsed)}. Privacy: ${privacy}.`
};
res.redirect("/profile");
});
router.post("/profile/unset", (req, res) => {
const user = req.session.user || null;
if (!user?.id) {
return renderDenied(res);
}
deleteBirthday(db, user.id);
req.session.flash = { type: "success", message: "Birthday removed." };
res.redirect("/profile");
});
router.get("/u/:username", (req, res) => {
const viewer = req.session.user || null;
const target = findUser(db, req.params.username);
if (!target) {
return res.status(404).render("error", {
title: "Birthday not found",
message: "No matching Lumi user was found."
});
}
const birthday = getBirthday(db, target.id);
const canView = birthday && canViewBirthday({ viewer, ownerId: target.id, privacy: birthday.privacy, commandContext: false });
res.render(path.join(__dirname, "views", "profile-birthday.ejs"), {
title: `${target.internal_username} Birthday`,
target,
birthday,
canView,
viewer,
formatBirthday,
formatBirthdayDateOnly,
publicUrl: `/plugins/${PLUGIN_ID}/u/${encodeURIComponent(target.internal_username)}`
});
});
web.mount(`/plugins/${PLUGIN_ID}`, router, {
label: "Birthdays",
role: "mod",
section: "plugins"
});
ensureSidebarNavItem(settings);
if (typeof web.addProfileSection === "function") {
web.addProfileSection({
id: PLUGIN_ID,
label: "Birthday",
view: path.join(__dirname, "views", "profile-birthday.ejs"),
order: 45,
locals: {
profileSection: true,
getBirthday: (userId) => getBirthday(db, userId),
formatBirthday,
formatBirthdayDateOnly
}
});
}
restartScheduler({ db, discordClient });
}
};
function ensureTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS birthday_profiles (
user_id TEXT PRIMARY KEY REFERENCES user_profiles(id) ON DELETE CASCADE,
year INTEGER NULL,
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
day INTEGER NOT NULL CHECK(day BETWEEN 1 AND 31),
privacy TEXT NOT NULL DEFAULT 'limited' CHECK(privacy IN ('public','limited','private')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS birthday_deliveries (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
delivery_key TEXT NOT NULL,
delivery_type TEXT NOT NULL CHECK(delivery_type IN ('announcement','gift')),
status TEXT NOT NULL CHECK(status IN ('sent','skipped','failed','claimed')),
details TEXT NULL,
created_at INTEGER NOT NULL,
UNIQUE(user_id, delivery_key, delivery_type)
);
CREATE INDEX IF NOT EXISTS birthday_profiles_month_day_idx ON birthday_profiles(month, day);
CREATE INDEX IF NOT EXISTS birthday_deliveries_key_idx ON birthday_deliveries(delivery_key, delivery_type);
`);
}
function ensureDefaults(db) {
const now = Date.now();
for (const [key, value] of Object.entries(DEFAULTS)) {
db.prepare(
"INSERT OR IGNORE INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?)"
).run(PLUGIN_ID, key, value, now);
}
}
function getConfig(db) {
const rows = db.prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?").all(PLUGIN_ID);
const raw = { ...DEFAULTS };
for (const row of rows) {
raw[row.key] = row.value;
}
let templates;
try {
templates = JSON.parse(raw.response_templates);
} catch {
templates = {};
}
return {
enabled: raw.enabled === "1",
announcement_channel_id: raw.announcement_channel_id || "",
timezone: isValidTimezone(raw.timezone) ? raw.timezone : "UTC",
leap_day_policy: raw.leap_day_policy === "mar1" ? "mar1" : "feb28",
gift_mode: raw.gift_mode === "manual" ? "manual" : "automatic",
gift_amount: Math.max(0, parseInt(raw.gift_amount || "0", 10) || 0),
birthday_check_interval_minutes: clampInt(raw.birthday_check_interval_minutes, 5, 1440, 60),
response_templates: normalizeTemplates(templates)
};
}
function serializeConfig(config) {
return {
enabled: config.enabled ? "1" : "0",
announcement_channel_id: config.announcement_channel_id || "",
timezone: config.timezone || "UTC",
leap_day_policy: config.leap_day_policy === "mar1" ? "mar1" : "feb28",
gift_mode: config.gift_mode === "manual" ? "manual" : "automatic",
gift_amount: String(Math.max(0, parseInt(config.gift_amount || "0", 10) || 0)),
birthday_check_interval_minutes: String(clampInt(config.birthday_check_interval_minutes, 5, 1440, 60)),
response_templates: JSON.stringify(normalizeTemplates(config.response_templates))
};
}
function saveConfig(db, values) {
const now = Date.now();
for (const [key, value] of Object.entries(values)) {
db.prepare(
"INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " +
"ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
).run(PLUGIN_ID, key, String(value), now);
}
}
function normalizeTemplates(value) {
return {
fullYear: normalizeTemplateList(value?.fullYear, DEFAULT_FULL),
partialYear: normalizeTemplateList(value?.partialYear, DEFAULT_PARTIAL)
};
}
function normalizeTemplateList(items, defaults) {
const source = Array.isArray(items) && items.length ? items : defaults.map((text) => newTemplate(text));
return source
.map((item) => ({
id: item?.id || crypto.randomUUID(),
text: (typeof item === "string" ? item : item?.text || "").toString(),
enabled: item?.enabled !== false,
createdAt: Number(item?.createdAt) || Date.now(),
updatedAt: Number(item?.updatedAt) || Date.now()
}))
.filter((item) => item.text.trim());
}
function newTemplate(text) {
const now = Date.now();
return { id: crypto.randomUUID(), text, enabled: true, createdAt: now, updatedAt: now };
}
function registerCommands({ db, settings, commandRouter }) {
if (!commandRouter) {
return;
}
commandRouter.registerCommands(PLUGIN_ID, [
{
id: "birthday",
triggers: ["birthday", "bday"],
platforms: ["discord", "twitch", "youtube"],
handler: (ctx) => handleBirthdayCommand({ ctx, db, settings })
}
]);
}
async function handleBirthdayCommand({ ctx, db, settings }) {
const prefix = settings?.getSetting ? settings.getSetting("command_prefix", "!") : "!";
const sub = (ctx.args[0] || "").toLowerCase();
if (!sub) {
const own = getBirthday(db, ctx.user.id);
const suffix = own ? ` Your birthday is ${formatBirthday(own)} (${own.privacy}).` : "";
await ctx.reply(`Usage: ${prefix}birthday set YYYY/MM/DD, ${prefix}birthday set MM/DD, ${prefix}birthday unset, ${prefix}birthday <user>, or ${prefix}birthday claim.${suffix}`);
return true;
}
if (sub === "set") {
const input = ctx.args[1] || "";
if (!input) {
await ctx.reply("Use YYYY/MM/DD for a full birthday or MM/DD if you do not want to store the year. Year is optional but recommended for age-based messages.");
return true;
}
const parsed = parseBirthdayInput(input);
if (!parsed.ok) {
await ctx.reply(parsed.message);
return true;
}
const existing = getBirthday(db, ctx.user.id);
const privacy = existing?.privacy || "limited";
upsertBirthday(db, ctx.user.id, { ...parsed, privacy });
await ctx.reply(`Stored birthday: ${formatBirthday(parsed)}. Privacy: ${privacy}.`);
return true;
}
if (sub === "unset") {
deleteBirthday(db, ctx.user.id);
await ctx.reply("Birthday removed.");
return true;
}
if (sub === "claim") {
await ctx.reply(await claimGift(db, ctx.user.id));
return true;
}
const targetText = ctx.argsText.trim();
const target = findUser(db, targetText);
if (!target) {
await ctx.reply("No matching Lumi user was found.");
return true;
}
const birthday = getBirthday(db, target.id);
if (!birthday) {
await ctx.reply("No birthday on file for that user.");
return true;
}
if (!canViewBirthday({ viewer: { id: ctx.user.id }, ownerId: target.id, privacy: birthday.privacy, commandContext: true })) {
await ctx.reply("That birthday is private.");
return true;
}
await ctx.reply(`${target.internal_username}'s birthday is ${formatBirthdayDateOnly(birthday)}.`);
return true;
}
function parseBirthdayInput(input) {
const text = (input || "").trim();
const full = text.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
const partial = text.match(/^(\d{1,2})\/(\d{1,2})$/);
const nowYear = new Date().getUTCFullYear();
if (full) {
const year = parseInt(full[1], 10);
const month = parseInt(full[2], 10);
const day = parseInt(full[3], 10);
if (year < 1900 || year > nowYear) {
return { ok: false, message: `Year must be between 1900 and ${nowYear}.` };
}
if (!isValidDate(year, month, day)) {
return { ok: false, message: "Birthday must be a real calendar date in YYYY/MM/DD format." };
}
return { ok: true, year, month, day };
}
if (partial) {
const month = parseInt(partial[1], 10);
const day = parseInt(partial[2], 10);
if (!isValidMonthDay(month, day)) {
return { ok: false, message: "Birthday must be a real calendar date in MM/DD format." };
}
return { ok: true, year: null, month, day };
}
return { ok: false, message: "Use slash-delimited YYYY/MM/DD or MM/DD. DD/MM and dash-separated dates are not accepted." };
}
function isValidDate(year, month, day) {
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}
function isValidMonthDay(month, day) {
return month === 2 && day === 29 ? true : isValidDate(2000, month, day);
}
function upsertBirthday(db, userId, birthday) {
const now = Date.now();
db.prepare(
"INSERT INTO birthday_profiles (user_id, year, month, day, privacy, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(user_id) DO UPDATE SET year = excluded.year, month = excluded.month, day = excluded.day, privacy = excluded.privacy, updated_at = excluded.updated_at"
).run(userId, birthday.year || null, birthday.month, birthday.day, normalizePrivacy(birthday.privacy), now, now);
}
function deleteBirthday(db, userId) {
db.prepare("DELETE FROM birthday_profiles WHERE user_id = ?").run(userId);
}
function getBirthday(db, userId) {
return userId ? db.prepare("SELECT * FROM birthday_profiles WHERE user_id = ?").get(userId) : null;
}
function findUser(db, text) {
const raw = (text || "").trim();
if (!raw) {
return null;
}
const mention = raw.match(/^<@!?(\d+)>$/);
if (mention) {
const row = db.prepare("SELECT p.id, p.internal_username FROM user_profiles p JOIN user_identities i ON i.user_id = p.id WHERE i.provider = 'discord' AND i.provider_user_id = ?").get(mention[1]);
if (row) return row;
}
const cleaned = raw.replace(/^@/, "");
return db.prepare("SELECT id, internal_username FROM user_profiles WHERE lower(internal_username) = lower(?) LIMIT 1").get(cleaned) ||
db.prepare("SELECT p.id, p.internal_username FROM user_profiles p JOIN user_identities i ON i.user_id = p.id WHERE lower(i.display_name) = lower(?) OR i.provider_user_id = ? LIMIT 1").get(cleaned, cleaned);
}
function canViewBirthday({ viewer, ownerId, privacy, commandContext }) {
if (viewer?.id && viewer.id === ownerId) {
return true;
}
if (viewer?.isAdmin || viewer?.isMod) {
return true;
}
if (privacy === "public") {
return true;
}
if (privacy === "limited") {
return commandContext ? Boolean(viewer?.id) : Boolean(viewer?.id);
}
return false;
}
function normalizePrivacy(value) {
return ALLOWED_PRIVACY.has(value) ? value : "limited";
}
function restartScheduler({ db, discordClient }) {
if (global[TIMER_KEY]) {
clearInterval(global[TIMER_KEY]);
global[TIMER_KEY] = null;
}
const config = getConfig(db);
if (!config.enabled) {
return;
}
checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error));
global[TIMER_KEY] = setInterval(() => {
checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error));
}, config.birthday_check_interval_minutes * 60 * 1000);
}
async function checkBirthdays({ db, discordClient }) {
const config = getConfig(db);
if (!config.enabled) {
return;
}
const today = getZonedDateParts(config.timezone);
const effectiveDates = getEffectiveDates(today, config.leap_day_policy);
const channelResult = config.announcement_channel_id
? await validateTextChannel(discordClient, config.announcement_channel_id)
: { valid: false, message: "No birthday channel configured." };
const birthdays = db.prepare(
"SELECT b.*, p.internal_username FROM birthday_profiles b JOIN user_profiles p ON p.id = b.user_id WHERE " +
effectiveDates.map(() => "(b.month = ? AND b.day = ?)").join(" OR ")
).all(...effectiveDates.flatMap((date) => [date.month, date.day]));
for (const birthday of birthdays) {
const deliveryKey = `${today.year}-${pad2(today.month)}-${pad2(today.day)}`;
const announceReserved = reserveDelivery(db, birthday.user_id, deliveryKey, "announcement", channelResult.valid ? "sent" : "skipped", channelResult.message);
if (!announceReserved) {
continue;
}
let gift = null;
if (config.gift_mode === "automatic") {
gift = grantGiftOnce(db, birthday.user_id, deliveryKey, "automatic");
}
if (!channelResult.valid || !channelResult.channel) {
continue;
}
const message = buildBirthdayMessage({ db, config, birthday, today, gift });
await channelResult.channel.send({ content: message, allowedMentions: { parse: [] } });
}
}
function reserveDelivery(db, userId, deliveryKey, type, status, details) {
try {
db.prepare(
"INSERT INTO birthday_deliveries (id, user_id, delivery_key, delivery_type, status, details, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
).run(crypto.randomUUID(), userId, deliveryKey, type, status, details || null, Date.now());
return true;
} catch {
return false;
}
}
function grantGiftOnce(db, userId, deliveryKey, mode) {
const config = getConfig(db);
const amount = config.gift_amount;
const framework = global.lumiFrameworks?.echonomy;
if (!amount || !framework || typeof framework.addBalance !== "function") {
return null;
}
if (!reserveDelivery(db, userId, deliveryKey, "gift", mode === "manual" ? "claimed" : "sent", "Gift reserved.")) {
return null;
}
try {
framework.addBalance({
userId,
amount,
note: "Birthday Gift",
meta: { source: "birthday", deliveryKey, mode },
allowFrozen: false
});
return { amount, currencyName: getCurrencyName(framework) };
} catch (error) {
db.prepare("UPDATE birthday_deliveries SET status = 'failed', details = ? WHERE user_id = ? AND delivery_key = ? AND delivery_type = 'gift'")
.run(error?.message || String(error), userId, deliveryKey);
return null;
}
}
async function claimGift(db, userId) {
const config = getConfig(db);
if (config.gift_mode !== "manual") {
return "Birthday gifts are automatic right now.";
}
if (!config.gift_amount) {
return "Birthday gifts are not configured right now.";
}
const birthday = getBirthday(db, userId);
if (!birthday) {
return "You do not have a birthday on file.";
}
const today = getZonedDateParts(config.timezone);
if (!isBirthdayToday(birthday, today, config.leap_day_policy)) {
return "Birthday gifts can only be claimed on your birthday.";
}
const deliveryKey = `${today.year}-${pad2(today.month)}-${pad2(today.day)}`;
const gift = grantGiftOnce(db, userId, deliveryKey, "manual");
return gift ? `Birthday gift claimed: ${gift.amount}.` : "Birthday gift is unavailable or was already claimed.";
}
function buildBirthdayMessage({ db, config, birthday, today, gift }) {
const profile = db.prepare("SELECT internal_username FROM user_profiles WHERE id = ?").get(birthday.user_id);
const tokens = buildTokens({ db, profile, birthday, today, gift });
const pool = birthday.year ? config.response_templates.fullYear : config.response_templates.partialYear;
const eligible = pool.filter((item) => item.enabled && templateUsable(item.text, tokens));
const template = eligible.length ? eligible[Math.floor(Math.random() * eligible.length)].text : "Happy birthday, {display_name}!";
return renderTemplate(template, tokens);
}
function buildTokens({ db, profile, birthday, today, gift }) {
const upcoming = getUpcomingBirthday(birthday, today);
const pronouns = getPronouns(db, birthday.user_id);
const ageAfter = birthday.year ? upcoming.year - birthday.year : null;
const daysUntil = daysBetween(today, upcoming);
const currencyName = gift?.currencyName || "coins";
return {
username: profile?.internal_username || "Lumi user",
display_name: profile?.internal_username || "Lumi user",
pronoun: pronouns.value,
pronoun_subject: pronouns.subject,
pronoun_object: pronouns.object,
pronoun_possessive: pronouns.possessive,
birthday: formatBirthdayDateOnly(birthday),
birthday_day: String(birthday.day),
birthday_day_text: ordinal(birthday.day),
birthday_month: monthName(birthday.month),
time_until_birthday: daysUntil === 0 ? "today" : `${daysUntil} day(s)`,
days_until_birthday: String(daysUntil),
months_until_birthday: String(Math.floor(daysUntil / 30)),
age_before: ageAfter === null ? null : String(ageAfter - 1),
age_after: ageAfter === null ? null : String(ageAfter),
birthday_weekday: weekdayName(upcoming.year, upcoming.month, upcoming.day),
gift_amount: gift ? String(gift.amount) : null,
gift_amount_text: gift ? `${gift.amount} ${currencyName}` : null,
currency_name: gift ? currencyName : null
};
}
function templateUsable(text, tokens) {
for (const match of text.matchAll(/\{([^{}]+)\}/g)) {
const key = match[1].trim();
if (!Object.prototype.hasOwnProperty.call(tokens, key) || tokens[key] === null || tokens[key] === undefined) {
return false;
}
}
return true;
}
function renderTemplate(text, tokens) {
return text.replace(/\{([^{}]+)\}/g, (full, key) => {
const value = tokens[key.trim()];
return value === null || value === undefined ? full : String(value);
});
}
function validateTemplate(text) {
if (!text) {
return "Template text is required.";
}
if (text.length > 1800) {
return "Template text is too long.";
}
for (const match of text.matchAll(/\{([^{}]+)\}/g)) {
const key = match[1].trim();
if (!ALLOWED_PLACEHOLDERS.has(key)) {
return `Unknown placeholder: {${key}}.`;
}
}
return null;
}
function previewTemplate(text) {
return renderTemplate(text, {
username: "lumi_user",
display_name: "Lumi User",
pronoun: "they/them",
pronoun_subject: "they",
pronoun_object: "them",
pronoun_possessive: "their",
birthday: "April 17",
birthday_day: "17",
birthday_day_text: "17th",
birthday_month: "April",
time_until_birthday: "2 months, 5 days",
days_until_birthday: "66",
months_until_birthday: "2",
age_before: "30",
age_after: "31",
birthday_weekday: "Friday",
gift_amount: "100",
gift_amount_text: "100 coins",
currency_name: "coins"
});
}
function getPronouns(db, userId) {
try {
const row = db.prepare("SELECT pronoun_set, subject_pronoun FROM welcome_message_pronouns WHERE user_id = ?").get(userId);
return pronounParts(row?.pronoun_set || row?.subject_pronoun);
} catch {
return pronounParts(null);
}
}
function pronounParts(value) {
const raw = (value || "they/them").toString().toLowerCase();
if (raw.startsWith("he/")) return { value: raw, subject: "he", object: "him", possessive: "his" };
if (raw.startsWith("she/")) return { value: raw, subject: "she", object: "her", possessive: "her" };
if (raw.startsWith("it/")) return { value: raw, subject: "it", object: "it", possessive: "its" };
return { value: raw || "they/them", subject: "they", object: "them", possessive: "their" };
}
function getZonedDateParts(timezone) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(new Date());
const get = (type) => parseInt(parts.find((part) => part.type === type)?.value, 10);
return { year: get("year"), month: get("month"), day: get("day") };
}
function getEffectiveDates(today, leapPolicy) {
const dates = [{ month: today.month, day: today.day }];
if (!isLeapYear(today.year)) {
if (leapPolicy === "mar1" && today.month === 3 && today.day === 1) {
dates.push({ month: 2, day: 29 });
}
if (leapPolicy !== "mar1" && today.month === 2 && today.day === 28) {
dates.push({ month: 2, day: 29 });
}
}
return dates;
}
function isBirthdayToday(birthday, today, leapPolicy) {
return getEffectiveDates(today, leapPolicy).some((date) => date.month === birthday.month && date.day === birthday.day);
}
function getUpcomingBirthday(birthday, today) {
let year = today.year;
let month = birthday.month;
let day = birthday.day;
if (month === 2 && day === 29 && !isLeapYear(year)) {
day = 28;
}
if (month < today.month || (month === today.month && day < today.day)) {
year += 1;
if (birthday.month === 2 && birthday.day === 29 && !isLeapYear(year)) {
month = 2;
day = 28;
}
}
return { year, month, day };
}
function daysBetween(from, to) {
const start = Date.UTC(from.year, from.month - 1, from.day);
const end = Date.UTC(to.year, to.month - 1, to.day);
return Math.max(0, Math.round((end - start) / 86400000));
}
function formatBirthday(birthday) {
const base = `${monthName(birthday.month)} ${birthday.day}`;
return birthday.year ? `${base}, ${birthday.year}` : `${base} (year not set)`;
}
function formatBirthdayDateOnly(birthday) {
return `${monthName(birthday.month)} ${birthday.day}`;
}
function monthName(month) {
return new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" }).format(new Date(Date.UTC(2000, month - 1, 1)));
}
function weekdayName(year, month, day) {
return new Intl.DateTimeFormat("en-US", { weekday: "long", timeZone: "UTC" }).format(new Date(Date.UTC(year, month - 1, day)));
}
function ordinal(day) {
const mod10 = day % 10;
const mod100 = day % 100;
if (mod10 === 1 && mod100 !== 11) return `${day}st`;
if (mod10 === 2 && mod100 !== 12) return `${day}nd`;
if (mod10 === 3 && mod100 !== 13) return `${day}rd`;
return `${day}th`;
}
function isLeapYear(year) {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
function isValidTimezone(timezone) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
return true;
} catch {
return false;
}
}
function normalizePool(pool) {
return pool === "partialYear" ? "partialYear" : "fullYear";
}
function clampInt(value, min, max, fallback) {
const parsed = parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
}
function pad2(value) {
return String(value).padStart(2, "0");
}
function getCurrencyName(framework) {
try {
const config = typeof framework.getConfig === "function" ? framework.getConfig() : null;
return config?.currency?.plural || config?.currency?.name || "coins";
} catch {
return "coins";
}
}
function canModerate(user) {
return Boolean(user?.isAdmin || user?.isMod);
}
function ensureSidebarNavItem(settings) {
if (!settings?.getSetting || !settings?.setSetting) {
return;
}
const navId = "plugins_birthday";
const raw = settings.getSetting("nav_structure", null);
if (!raw) {
return;
}
let structure = raw;
if (typeof structure === "string") {
try {
structure = JSON.parse(structure);
} catch {
return;
}
}
if (!structure?.enabled || !Array.isArray(structure.sections)) {
return;
}
for (const section of structure.sections) {
if (Array.isArray(section.items)) {
section.items = section.items.filter((item) => item !== navId);
}
}
let pluginsSection = structure.sections.find((section) => section.id === "plugins");
if (!pluginsSection) {
pluginsSection = {
id: "plugins",
label: "Plugins",
icon: "blocks",
items: []
};
structure.sections.push(pluginsSection);
}
pluginsSection.items = Array.isArray(pluginsSection.items) ? pluginsSection.items : [];
pluginsSection.items.push(navId);
settings.setSetting("nav_structure", structure);
}
function renderDenied(res) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
async function buildDiagnostics(discordClient, config) {
const channel = config.announcement_channel_id
? await validateTextChannel(discordClient, config.announcement_channel_id)
: { valid: false, message: "No announcement channel configured." };
return {
discordAvailable: Boolean(discordClient),
discordReady: Boolean(discordClient?.readyAt),
channel,
echonomyAvailable: Boolean(global.lumiFrameworks?.echonomy?.addBalance),
currentDate: getZonedDateParts(config.timezone)
};
}
function getTextChannels(discordClient) {
const channels = [];
if (!discordClient?.guilds?.cache) {
return channels;
}
for (const guild of discordClient.guilds.cache.values()) {
guild.channels?.cache?.forEach((channel) => {
if (isRegularTextChannel(channel)) {
channels.push({ id: channel.id, label: `${guild.name} - ${channel.name}` });
}
});
}
return channels.sort((a, b) => a.label.localeCompare(b.label));
}
async function validateTextChannel(discordClient, channelId) {
if (!discordClient) {
return { valid: false, message: "Discord client is unavailable." };
}
const channel = discordClient.channels?.cache?.get(channelId) ||
(typeof discordClient.channels?.fetch === "function" ? await discordClient.channels.fetch(channelId).catch(() => null) : null);
if (!channel) {
return { valid: false, message: "Configured channel was not found." };
}
if (!isRegularTextChannel(channel)) {
return { valid: false, message: "Birthday channel must be a regular text channel." };
}
return { valid: true, message: "Channel is valid.", channel };
}
function isRegularTextChannel(channel) {
if (!channel || channel.isThread?.()) return false;
return channel.type === "GUILD_TEXT" || channel.type === 0;
}
function formatDateTime(value) {
return value ? new Date(value).toLocaleString() : "";
}

View File

@ -0,0 +1,7 @@
{
"id": "birthday",
"name": "Birthday",
"version": "0.1.0",
"description": "Birthday profiles, announcements, lookup commands, and optional birthday currency gifts.",
"main": "index.js"
}

View File

@ -0,0 +1,187 @@
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
<section class="card">
<div class="section-header">
<div>
<h1>Birthdays</h1>
<p class="command-subtitle">Birthday announcements, profile display, and optional echonomy gifts.</p>
</div>
</div>
<div class="table-wrap">
<table class="table">
<tbody>
<tr><th>Discord client</th><td><%= diagnostics.discordAvailable ? (diagnostics.discordReady ? "Ready" : "Available") : "Unavailable" %></td></tr>
<tr><th>Announcement channel</th><td><%= diagnostics.channel.message %></td></tr>
<tr><th>Echonomy</th><td><%= diagnostics.echonomyAvailable ? "Available" : "Unavailable" %></td></tr>
<tr><th>Current plugin date</th><td><%= diagnostics.currentDate.year %>-<%= String(diagnostics.currentDate.month).padStart(2, "0") %>-<%= String(diagnostics.currentDate.day).padStart(2, "0") %></td></tr>
</tbody>
</table>
</div>
</section>
<section class="card">
<h2>Delivery</h2>
<form method="post" action="/plugins/birthday/settings" class="form-grid">
<div class="field">
<label>Announcements</label>
<label class="switch">
<input type="checkbox" class="switch-input" name="enabled" <%= config.enabled ? "checked" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text"><%= config.enabled ? "On" : "Off" %></span>
</label>
</div>
<div class="field">
<label>Timezone</label>
<input name="timezone" value="<%= config.timezone %>" />
</div>
<div class="field">
<label>Gift mode</label>
<select name="gift_mode">
<option value="automatic" <%= config.gift_mode === "automatic" ? "selected" : "" %>>automatic</option>
<option value="manual" <%= config.gift_mode === "manual" ? "selected" : "" %>>manual</option>
</select>
</div>
<div class="field">
<label>Gift amount</label>
<input name="gift_amount" type="number" min="0" step="1" value="<%= config.gift_amount %>" <%= isAdmin ? "" : "readonly" %> />
<% if (!isAdmin) { %><p class="hint">Only admins can change this value.</p><% } %>
</div>
<div class="field">
<label>Check interval minutes</label>
<input name="birthday_check_interval_minutes" type="number" min="5" max="1440" step="1" value="<%= config.birthday_check_interval_minutes %>" />
</div>
<div class="field">
<label>Feb 29 handling</label>
<select name="leap_day_policy">
<option value="feb28" <%= config.leap_day_policy === "feb28" ? "selected" : "" %>>feb28</option>
<option value="mar1" <%= config.leap_day_policy === "mar1" ? "selected" : "" %>>mar1</option>
</select>
</div>
<div class="field full">
<label>Announcement channel</label>
<% if (textChannels.length) { %>
<select name="announcement_channel_id">
<option value="">Select a Discord text channel</option>
<% textChannels.forEach((channel) => { %>
<option value="<%= channel.id %>" <%= channel.id === config.announcement_channel_id ? "selected" : "" %>><%= channel.label %></option>
<% }) %>
</select>
<% } else { %>
<input name="announcement_channel_id" value="<%= config.announcement_channel_id %>" placeholder="Discord text channel ID" />
<% } %>
<p class="hint">Only regular guild text channels are accepted.</p>
</div>
<div class="field full">
<button type="submit" class="button">Save settings</button>
</div>
</form>
</section>
<section class="card">
<h2>Allowed Placeholders</h2>
<p>
<% allowedPlaceholders.forEach((name) => { %>
<code>{<%= name %>}</code>
<% }) %>
</p>
</section>
<section class="card">
<h2>Full-year birthday messages</h2>
<% const fullYearMessages = config.response_templates.fullYear; %>
<% if (!fullYearMessages.length) { %>
<p>No templates configured.</p>
<% } %>
<% fullYearMessages.forEach((message) => { %>
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/update" class="form-grid">
<input type="hidden" name="pool" value="fullYear" />
<div class="field full">
<label>Template</label>
<textarea name="text" rows="3"><%= message.text %></textarea>
<p class="hint">Preview: <%= previewTemplate(message.text) %></p>
</div>
<div class="field">
<label>Enabled</label>
<label class="switch">
<input type="checkbox" class="switch-input" name="enabled" <%= message.enabled ? "checked" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text"><%= message.enabled ? "On" : "Off" %></span>
</label>
</div>
<div class="field profile-actions">
<button type="submit" class="button subtle">Save</button>
</div>
</form>
<div class="profile-actions">
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/duplicate">
<input type="hidden" name="pool" value="fullYear" />
<button type="submit" class="button subtle">Duplicate</button>
</form>
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove">
<input type="hidden" name="pool" value="fullYear" />
<button type="submit" class="button subtle">Remove</button>
</form>
</div>
<% }) %>
<form method="post" action="/plugins/birthday/templates/create" class="form-grid">
<input type="hidden" name="pool" value="fullYear" />
<div class="field full">
<label>New template</label>
<textarea name="text" rows="3"></textarea>
</div>
<div class="field full">
<button type="submit" class="button">Add template</button>
</div>
</form>
</section>
<section class="card">
<h2>Partial-year birthday messages</h2>
<% const partialYearMessages = config.response_templates.partialYear; %>
<% if (!partialYearMessages.length) { %>
<p>No templates configured.</p>
<% } %>
<% partialYearMessages.forEach((message) => { %>
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/update" class="form-grid">
<input type="hidden" name="pool" value="partialYear" />
<div class="field full">
<label>Template</label>
<textarea name="text" rows="3"><%= message.text %></textarea>
<p class="hint">Preview: <%= previewTemplate(message.text) %></p>
</div>
<div class="field">
<label>Enabled</label>
<label class="switch">
<input type="checkbox" class="switch-input" name="enabled" <%= message.enabled ? "checked" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text"><%= message.enabled ? "On" : "Off" %></span>
</label>
</div>
<div class="field profile-actions">
<button type="submit" class="button subtle">Save</button>
</div>
</form>
<div class="profile-actions">
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/duplicate">
<input type="hidden" name="pool" value="partialYear" />
<button type="submit" class="button subtle">Duplicate</button>
</form>
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove">
<input type="hidden" name="pool" value="partialYear" />
<button type="submit" class="button subtle">Remove</button>
</form>
</div>
<% }) %>
<form method="post" action="/plugins/birthday/templates/create" class="form-grid">
<input type="hidden" name="pool" value="partialYear" />
<div class="field full">
<label>New template</label>
<textarea name="text" rows="3"></textarea>
</div>
<div class="field full">
<button type="submit" class="button">Add template</button>
</div>
</form>
</section>
<%- include("../../../src/web/views/partials/layout-bottom") %>

View File

@ -0,0 +1,45 @@
<% if (typeof profileSection !== "undefined" && profileSection) { %>
<% const birthday = getBirthday(user.id); %>
<form method="post" action="/plugins/birthday/profile" class="form-grid">
<div class="field">
<label>Birthday</label>
<input name="birthday" value="<%= birthday ? (birthday.year ? birthday.year + '/' : '') + String(birthday.month).padStart(2, '0') + '/' + String(birthday.day).padStart(2, '0') : '' %>" placeholder="YYYY/MM/DD or MM/DD" />
<p class="hint">Use YYYY/MM/DD or MM/DD. Year is optional.</p>
</div>
<div class="field">
<label>Privacy</label>
<select name="privacy">
<% ["public", "limited", "private"].forEach((option) => { %>
<option value="<%= option %>" <%= (birthday?.privacy || "limited") === option ? "selected" : "" %>><%= option %></option>
<% }) %>
</select>
</div>
<% if (birthday) { %>
<p class="hint full">Stored birthday: <%= formatBirthday(birthday) %>.</p>
<% } %>
<div class="field full profile-actions">
<button type="submit" class="button subtle">Save birthday</button>
</div>
</form>
<% if (birthday) { %>
<form method="post" action="/plugins/birthday/profile/unset">
<button type="submit" class="button subtle">Remove birthday</button>
</form>
<% } %>
<% } else { %>
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
<section class="card">
<div class="section-header">
<div>
<h1><%= target.internal_username %></h1>
<p class="command-subtitle">Birthday profile</p>
</div>
</div>
<% if (canView && birthday) { %>
<p><strong>Birthday:</strong> <%= formatBirthdayDateOnly(birthday) %></p>
<% } else { %>
<p>This user's birthday is not visible to you.</p>
<% } %>
</section>
<%- include("../../../src/web/views/partials/layout-bottom") %>
<% } %>

97
plugins/lumi_ai/README.md Normal file
View File

@ -0,0 +1,97 @@
# Lumi AI
`lumi_ai` is a standalone Lumi plugin that manages a local `llama.cpp` inference process and adds a scoped AI Assistant to the WebUI.
## Install and configure
1. Place this directory at `plugins/lumi_ai/`.
2. Restart Lumi.
3. Open **Plugins -> Lumi AI** in the sidebar.
4. Download the managed runtime and a compatible model.
5. Select the model, configure visibility and instructions, then save.
6. Start the runtime and enable AI.
The settings page is always registered as an admin-only item in the `Plugins` sidebar section. The assistant pill is injected separately above the profile footer and follows the configured admin, moderator, and user visibility controls.
## Storage
Every writable path is confined to `plugins/lumi_ai/data/`:
- `config/`: settings and runtime state
- `models/`: verified GGUF models
- `runtime/`: extracted `llama.cpp` runtime
- `logs/`: runtime logs
- `metrics/`: usage and audit records
- `rag/`, `cache/`, `tmp/`: plugin-local working data
Downloads are written to `data/tmp/`, verified against a pinned SHA-256 digest, and only then moved or extracted into their final plugin-local directory.
## Runtime and downloads
Models use pinned Hugging Face repository commits. The runtime uses a pinned official `ggml-org/llama.cpp` GitHub release because the llama.cpp project does not publish authoritative multi-platform runtime archives on Hugging Face. This is the only download-source exception; the archive URL, version, size, and SHA-256 are pinned in `runtime_manifest.json`.
The runtime binds only to `127.0.0.1` on an ephemeral port. It is never exposed on `0.0.0.0`.
Before loading a model, Lumi AI runs `llama-server --help` as a smoke test. Failed launches and exits are decoded into plugin-local diagnostics, including Windows NTSTATUS values such as `0xC0000005 / STATUS_ACCESS_VIOLATION`. The admin page provides remediation steps, raw stdout/stderr tails, model verification, and a redacted diagnostics bundle.
The test console no longer exposes a user-editable scope label. Clearly unrelated requests are rejected deterministically, while ambiguous requests are passed to the scoped Lumi system prompt instead of being rejected by a fixed keyword list.
## Plugin API
Other Lumi plugins can use:
```js
const ai = global.lumiFrameworks?.ai;
const health = await ai.health();
const result = await ai.generate({
message: "Summarize this Lumi event.",
user: requestingUser,
sessionId: requestSessionId,
scope: "my_plugin"
});
```
Available functions:
- `generate`
- `classify`
- `summarize`
- `route_tool`
- `health`
- `capabilities`
- `metrics_summary`
- `registerContext`
- `unregisterContext`
- `registerTool`
AI tools must provide an owning plugin, a synchronous permission check, a fixed argument schema, and an established workflow handler. Model output cannot execute SQL, shell commands, file operations, or arbitrary URLs.
## Tool registration
```js
ai.registerTool({
tool_id: "example.action",
display_name: "Example action",
description: "Runs an existing plugin workflow.",
owning_plugin: "example",
required_role: "user",
required_permission: "example.action.self",
permission_check: ({ user, arguments: args }) => canRunWorkflow(user, args),
schema: { target: "string", amount: "integer" },
confirmation_required: true,
risk_level: "sensitive",
audit_category: "example",
workflow_handler: ({ arguments: args, user, initiated_via_ai, ai_request_id }) =>
existingWorkflow({ ...args, actor: user, initiated_via_ai, ai_request_id })
});
```
## Verification
Run:
```powershell
node plugins/lumi_ai/tests/verify.js
```
The verification covers path confinement, traversal rejection, assistant role access, tool schema and permission checks, user/session confirmation ownership, expiry, action attribution, audit recording, queue limits, refusal behavior, and runtime resume persistence.

View File

@ -0,0 +1,54 @@
const crypto=require("crypto");const {buildPrompt}=require("./prompt_builder");const {roleOf}=require("./permissions");const {parseToolCall}=require("./tool_router");
const ROUTE_HELP=[
{terms:["twitch","configuration"],text:"Twitch configuration is available in [Settings -> Twitch wizard](/admin/twitch-wizard)."},
{terms:["discord","configuration"],text:"Discord configuration is available in [Settings -> Discord wizard](/admin/discord-wizard)."},
{terms:["youtube","configuration"],text:"YouTube configuration is available in [Settings -> YouTube wizard](/admin/youtube-wizard)."},
{terms:["plugins"],text:"Plugin management is available in [Admin -> Plugins](/admin/plugins)."}
];
const CLEARLY_UNRELATED_PATTERNS=[
/\b(capital|population|president|prime minister)\s+of\b/i,
/\b(weather|forecast)\s+(in|for|at)\b/i,
/\b(stock price|exchange rate|sports score|lottery)\b/i,
/\b(write|compose)\s+(a\s+)?(poem|story|song|essay)\b/i,
/\b(recipe|cook|bake)\b/i,
/\b(homework|calculus|algebra|chemistry|physics)\b/i
];
class AiProvider{
constructor({getConfig,runtime,queue,tools,metrics,getContext}){Object.assign(this,{getConfig,runtime,queue,tools,metrics,getContext});}
async generate({message,user,sessionId,scope="assistant",max_tokens,includeRaw=false}){
const requestId=crypto.randomUUID(),role=roleOf(user),started=Date.now();
if(isClearlyOutOfScope(message)){this.metrics.record({kind:"request",status:"refused",request_id:requestId,user_id:user.id,role,scope,duration_ms:Date.now()-started});return{success:false,text:this.getConfig().instructions.out_of_scope_response,refusal_reason:"out_of_scope",request_id:requestId};}
const direct=ROUTE_HELP.find(row=>row.terms.every(t=>message.toLowerCase().includes(t)));if(direct){this.metrics.record({kind:"request",status:"success",request_id:requestId,user_id:user.id,role,scope,duration_ms:Date.now()-started});return{success:true,text:direct.text,model_id:"lumi-route-help",duration_ms:Date.now()-started,queue_wait_ms:0,request_id:requestId};}
return this.queue.run(user.id,role,async(queueWait)=>{
const cfg=this.getConfig(),prompt=buildPrompt({config:cfg,role,message,contextBlocks:this.getContext(role),tools:this.tools.list(role)});
const result=await this.runtime.infer([{role:"system",content:prompt},{role:"user",content:message}],max_tokens||300);
const text=result.choices?.[0]?.message?.content||"";const toolCall=parseToolCall(text);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});confirmation=prepared.confirmation;}
const out={success:true,text:confirmation?`Please confirm: ${confirmation.display_name}.`:toolResult?`Action completed: ${JSON.stringify(toolResult)}`:text,raw_response:cfg.logging.log_responses||includeRaw?result:null,tool_call:toolCall,tool_result:toolResult,confirmation,model_id:cfg.selected_model_id,duration_ms:Date.now()-started,queue_wait_ms:queueWait,finish_reason:result.choices?.[0]?.finish_reason||null,request_id:requestId};
this.metrics.record({kind:"request",status:"success",request_id:requestId,user_id:user.id,role,scope,model:cfg.selected_model_id,duration_ms:out.duration_ms,queue_wait_ms:queueWait,tool_requested:toolCall?.tool||null,tool_executed:false});return out;
});
}
async classify({message,labels,user}){const result=await this.generate({message:`Classify this Lumi-related request into exactly one label: ${labels.join(", ")}. Request: ${message}`,user,scope:"classify",max_tokens:40});return{...result,label:labels.find(l=>result.text.toLowerCase().includes(l.toLowerCase()))||null};}
async summarize({text,max_length=500,user}){return this.generate({message:`Summarize this Lumi-related content in at most ${max_length} characters:\n${text}`,user,scope:"summarize",max_tokens:Math.ceil(max_length/3)});}
async test({message,user,max_tokens=300,includeRaw=false}){
const requestId=crypto.randomUUID(),role=roleOf(user),started=Date.now();
return this.queue.run(user.id,role,async(queueWait)=>{
const cfg=this.getConfig();
const prompt=[
"You are running an administrator-requested local model diagnostic.",
"Answer the exact user message directly and concisely.",
"Do not call tools, perform actions, claim access to Lumi data, or follow requests to execute code, files, SQL, shell commands, or URLs.",
`Maximum answer length: ${cfg.instructions.maximum_answer_length || 700} characters.`
].join("\n");
const result=await this.runtime.infer([{role:"system",content:prompt},{role:"user",content:message}],max_tokens);
const text=result.choices?.[0]?.message?.content||"";
const output={success:true,text,raw_response:includeRaw?result:null,raw_prompt:prompt,tool_call:null,tool_result:null,confirmation:null,model_id:cfg.selected_model_id,duration_ms:Date.now()-started,queue_wait_ms:queueWait,finish_reason:result.choices?.[0]?.finish_reason||null,request_id:requestId};
this.metrics.record({kind:"request",status:"success",request_id:requestId,user_id:user.id,role,scope:"model_test",model:cfg.selected_model_id,duration_ms:output.duration_ms,queue_wait_ms:queueWait});
return output;
});
}
}
function isClearlyOutOfScope(message){const value=(message||"").trim();return value.length>0&&CLEARLY_UNRELATED_PATTERNS.some(pattern=>pattern.test(value));}
function isInScope(message){return !isClearlyOutOfScope(message);}
module.exports={AiProvider,isInScope,isClearlyOutOfScope};

View File

@ -0,0 +1,68 @@
const fs = require("fs");
const { resolveData, ensureDataDirs } = require("./paths");
const DEFAULT_CONFIG = {
enabled: false,
selected_model_id: "qwen3-1.7b-q4",
context_size: 4096,
threads: 0,
concurrency: 1,
max_queue_length: 8,
request_timeout_ms: 120000,
per_user_requests_per_minute: 6,
admin_bypass_rate_limit: false,
assistant_visibility: { admins: true, mods: false, users: false },
instructions: {
identity: "You are Lumi Assistant, a concise assistant for this Lumi bot and community.",
style: "Be brief, factual, and provide internal WebUI links when known.",
allowed_topics: "Lumi, its WebUI, plugins, community systems, streams, and videos.",
out_of_scope_response: "I am sorry, but that is outside my scope.",
maximum_answer_length: 700,
roleplay_intensity: 0,
community_tone: "",
admin_custom: ""
},
logging: {
log_prompts: false,
log_responses: false,
log_tool_calls: true,
log_metrics: true,
log_internal_audit: true
}
};
function readJson(name, fallback) {
ensureDataDirs();
const file = resolveData("config", name);
if (!fs.existsSync(file)) {
writeJson(name, fallback);
return structuredClone(fallback);
}
try { return { ...structuredClone(fallback), ...JSON.parse(fs.readFileSync(file, "utf8")) }; }
catch { return structuredClone(fallback); }
}
function writeJson(name, value) {
const file = resolveData("config", name);
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
fs.renameSync(tmp, file);
}
function getConfig() { return readJson("ai_config.json", DEFAULT_CONFIG); }
function saveConfig(value) {
const merged = { ...DEFAULT_CONFIG, ...value };
merged.assistant_visibility = { ...DEFAULT_CONFIG.assistant_visibility, ...(value.assistant_visibility || {}) };
merged.instructions = { ...DEFAULT_CONFIG.instructions, ...(value.instructions || {}) };
merged.logging = { ...DEFAULT_CONFIG.logging, ...(value.logging || {}) };
writeJson("ai_config.json", merged);
return merged;
}
function getRuntimeState() {
return readJson("runtime_state.json", {
desired_state: "stopped", last_known_state: "stopped", last_stop_reason: "never_started",
last_manual_stop: true, last_crashed: false, last_exit_code: null,
last_diagnostic_category: null, selected_model_id: null, updated_at: new Date().toISOString()
});
}
function saveRuntimeState(value) { writeJson("runtime_state.json", { ...value, updated_at: new Date().toISOString() }); }
module.exports = { DEFAULT_CONFIG, getConfig, saveConfig, getRuntimeState, saveRuntimeState, readJson, writeJson };

View File

@ -0,0 +1,58 @@
const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");
const { resolveData, ensureDataDirs } = require("./paths");
function redact(value) {
if (Array.isArray(value)) return value.map(redact);
if (!value || typeof value !== "object") return value;
const output = {};
for (const [key, item] of Object.entries(value)) {
output[key] = /token|secret|password|cookie|authorization|session/i.test(key) ? "[REDACTED]" : redact(item);
}
return output;
}
function createDiagnostic(input) {
return redact({
timestamp: new Date().toISOString(),
severity: "error",
can_retry: true,
requires_admin_action: true,
should_auto_resume: false,
platform: process.platform,
...input
});
}
function persistDiagnostic(input) {
ensureDataDirs();
const diagnostic = createDiagnostic(input);
fs.writeFileSync(resolveData("diagnostics", "latest_runtime_diagnostic.json"), `${JSON.stringify(diagnostic, null, 2)}\n`);
fs.appendFileSync(resolveData("diagnostics", "runtime_diagnostics.jsonl"), `${JSON.stringify(diagnostic)}\n`);
return diagnostic;
}
function getLatestDiagnostic() {
try { return JSON.parse(fs.readFileSync(resolveData("diagnostics", "latest_runtime_diagnostic.json"), "utf8")); }
catch { return null; }
}
function createDiagnosticsBundle({ config, runtimeState, manifest, metrics }) {
ensureDataDirs();
const destination = resolveData("diagnostics", `lumi-ai-diagnostics-${Date.now()}.zip`);
const zip = new AdmZip();
const addJson = (name, value) => zip.addFile(name, Buffer.from(`${JSON.stringify(redact(value), null, 2)}\n`));
addJson("config.json", config);
addJson("runtime_state.json", runtimeState);
addJson("latest_runtime_diagnostic.json", getLatestDiagnostic());
addJson("runtime_manifest.json", manifest);
addJson("metrics_summary.json", metrics);
for (const name of ["runtime-selftest.log"]) {
const file = resolveData("logs", name);
if (fs.existsSync(file)) zip.addLocalFile(file, "logs");
}
const logs = fs.readdirSync(resolveData("logs")).filter((name) => name.startsWith("runtime-")).sort().slice(-2);
for (const name of logs) zip.addLocalFile(resolveData("logs", name), "logs");
zip.writeZip(destination);
return destination;
}
function tail(value, length = 4000) { return String(value || "").slice(-length); }
module.exports = { redact, createDiagnostic, persistDiagnostic, getLatestDiagnostic, createDiagnosticsBundle, tail };

View File

@ -0,0 +1,103 @@
const fs = require("fs");
const crypto = require("crypto");
const path = require("path");
const { spawn } = require("child_process");
const AdmZip = require("adm-zip");
const { resolveData } = require("./paths");
class DownloadManager {
constructor(onEvent){ this.jobs=new Map(); this.onEvent=onEvent; }
status(id){ return this.jobs.get(id)||null; }
start({id,url,filename,sha256,kind,archive=false,size=0}){
if(this.jobs.get(id)?.state==="downloading") throw new Error("Download already running.");
if(size&&freeDiskBytes()<size*1.2)throw new Error("not enough disk space");
const job={id,state:"queued",downloaded:0,total:0,error:null,started_at:Date.now()};this.jobs.set(id,job);
this.download({job,url,filename,sha256,kind,archive}).catch(error=>{const classified=classifyError(error);job.state="error";job.error=classified.message;job.error_category=classified.category;this.onEvent?.({kind:"download",status:"failed",download_id:id,error:job.error,category:classified.category});});
return job;
}
async download({job,url,filename,sha256,kind,archive}){
job.state="downloading";
const tmp=resolveData("tmp",`${filename}.part`), finalDir=resolveData(kind==="model"?"models":"runtime");
const existing=fs.existsSync(tmp)?fs.statSync(tmp).size:0;
const headers=existing?{Range:`bytes=${existing}-`}:{};
const response=await fetch(url,{headers}); if(!response.ok && response.status!==206) throw new Error(`source unavailable (${response.status})`);
const resumed=existing>0&&response.status===206;
const total=Number(response.headers.get("content-length")||0)+(resumed?existing:0); job.total=total; job.downloaded=resumed?existing:0;
const stream=fs.createWriteStream(tmp,{flags:resumed?"a":"w"});
for await(const chunk of response.body){ if(!stream.write(chunk)) await new Promise(r=>stream.once("drain",r)); job.downloaded+=chunk.length; }
await new Promise((resolve,reject)=>stream.end(error=>error?reject(error):resolve()));
job.state="verifying"; const actual=await hashFile(tmp); if(actual!==sha256.toLowerCase()){fs.unlinkSync(tmp);throw new Error("hash mismatch");}
if(archive){
job.state="extracting";
const staging=resolveData("tmp",`runtime-extract-${Date.now()}`);
fs.mkdirSync(staging,{recursive:true});
try{
await extractArchive(tmp,staging,filename);
await makeRuntimeExecutable(staging);
const executable=findRuntimeExecutable(staging);
if(!executable)throw new Error("runtime executable missing after extraction");
for(const entry of fs.readdirSync(finalDir))fs.rmSync(path.join(finalDir,entry),{recursive:true,force:true});
for(const entry of fs.readdirSync(staging))fs.renameSync(path.join(staging,entry),path.join(finalDir,entry));
fs.unlinkSync(tmp);
job.executable=findRuntimeExecutable(finalDir);
}finally{
fs.rmSync(staging,{recursive:true,force:true});
}
}
else { const final=path.join(finalDir,filename); if(fs.existsSync(final))fs.unlinkSync(final); fs.renameSync(tmp,final); }
job.state="complete";job.finished_at=Date.now();job.sha256=actual;this.onEvent?.({kind:"download",status:"success",download_id:job.id,sha256:actual,duration_ms:job.finished_at-job.started_at});
}
}
async function makeRuntimeExecutable(dir){
if(process.platform==="win32")return;
for(const entry of fs.readdirSync(dir,{withFileTypes:true})){
const target=path.join(dir,entry.name);
if(entry.isDirectory())await makeRuntimeExecutable(target);
else if(entry.name==="llama-server")fs.chmodSync(target,0o755);
}
}
function findRuntimeExecutable(dir){
const name=process.platform==="win32"?"llama-server.exe":"llama-server";
for(const entry of fs.readdirSync(dir,{withFileTypes:true})){
const target=path.join(dir,entry.name);
if(entry.isFile()&&entry.name===name)return target;
if(entry.isDirectory()){const found=findRuntimeExecutable(target);if(found)return found;}
}
return null;
}
async function hashFile(file){const hash=crypto.createHash("sha256");for await(const chunk of fs.createReadStream(file))hash.update(chunk);return hash.digest("hex");}
async function extractArchive(file,dest,name){
if(name.endsWith(".zip")){
const zip=new AdmZip(file);
for(const entry of zip.getEntries())validateArchivePath(entry.entryName);
zip.extractAllTo(dest,true);
return;
}
const entries=await capture("tar",["-tzf",file]);
if(entries.code!==0)throw new Error(`archive corrupt (${entries.code})`);
for(const entry of entries.stdout.split(/\r?\n/).filter(Boolean))validateArchivePath(entry);
await new Promise((resolve,reject)=>{const child=spawn("tar",["-xzf",file,"-C",dest],{windowsHide:true,shell:false});child.on("exit",c=>c===0?resolve():reject(new Error(`archive extraction failed (${c})`)));child.on("error",reject);});
}
function validateArchivePath(entry){
const normalized=path.posix.normalize(String(entry).replace(/\\/g,"/"));
if(path.posix.isAbsolute(normalized)||normalized===".."||normalized.startsWith("../"))throw new Error("archive path traversal");
}
function capture(command,args){return new Promise((resolve,reject)=>{const child=spawn(command,args,{windowsHide:true,shell:false});let stdout="",stderr="";child.stdout.on("data",c=>stdout+=c);child.stderr.on("data",c=>stderr+=c);child.on("error",reject);child.on("exit",code=>resolve({code,stdout,stderr}));});}
function classifyError(error){
const message=error?.message||String(error);
if(/ENOSPC|not enough disk/i.test(message))return{category:"disk_full",message:"Not enough disk space."};
if(/EACCES|EPERM|permission denied/i.test(message))return{category:"permission_denied",message:"Permission denied."};
if(/hash mismatch/i.test(message))return{category:"hash_mismatch",message:"Downloaded file failed SHA-256 verification."};
if(/archive path traversal/i.test(message))return{category:"archive_path_traversal",message:"Archive contains an unsafe path."};
if(/archive corrupt|extraction failed/i.test(message))return{category:"archive_corrupt",message};
if(/\(404\)/.test(message))return{category:"http_404",message:"Download source was not found (404)."};
if(/\(403\)/.test(message))return{category:"http_403",message:"Download source denied access (403)."};
if(/\(429\)/.test(message))return{category:"http_429",message:"Download source rate limit reached (429)."};
if(/\(5\d\d\)/.test(message))return{category:"server_error",message};
if(/timeout|abort/i.test(message))return{category:"timeout",message:"Download timed out."};
if(/fetch|network|ENOTFOUND|EAI_AGAIN/i.test(message))return{category:"network_unavailable",message};
if(/runtime executable missing/i.test(message))return{category:"install_validation_failed",message};
return{category:"download_failed",message};
}
function freeDiskBytes(){try{const stat=fs.statfsSync(resolveData("tmp"));return Number(stat.bavail)*Number(stat.bsize);}catch{return Number.MAX_SAFE_INTEGER;}}
module.exports={DownloadManager,hashFile,validateArchivePath,classifyError};

View File

@ -0,0 +1,74 @@
const WINDOWS_STATUS = {
0xC0000005: ["STATUS_ACCESS_VIOLATION", "runtime_crash", ["Reinstall the runtime.", "Install or update Microsoft Visual C++ Redistributable x64.", "Unblock downloaded runtime files.", "Try a local disk path or alternate runtime build."]],
0xC000001D: ["STATUS_ILLEGAL_INSTRUCTION", "cpu_incompatible", ["Use a baseline or more compatible CPU runtime build.", "Verify CPU AVX/AVX2 support."]],
0xC0000135: ["STATUS_DLL_NOT_FOUND", "missing_dependency", ["Reinstall the runtime.", "Verify sibling DLL files.", "Install or update Microsoft Visual C++ Redistributable x64."]],
0xC0000139: ["STATUS_ENTRYPOINT_NOT_FOUND", "dependency_mismatch", ["Delete and reinstall the complete runtime folder.", "Do not mix DLLs from different releases."]],
0xC000007B: ["STATUS_INVALID_IMAGE_FORMAT", "architecture_mismatch", ["Download the runtime matching the detected OS and architecture.", "Reinstall and verify the archive hash."]],
0xC0000142: ["STATUS_DLL_INIT_FAILED", "dependency_initialization_failed", ["Unblock runtime files.", "Install or update Microsoft Visual C++ Redistributable x64.", "Review security software restrictions."]],
0xC0000409: ["STATUS_STACK_BUFFER_OVERRUN", "runtime_crash", ["Reinstall the runtime.", "Try an alternate runtime build.", "Inspect the captured runtime logs."]],
0xC0000374: ["STATUS_HEAP_CORRUPTION", "runtime_crash", ["Reinstall the runtime.", "Try an alternate build.", "Reduce context size if failure occurs after model load."]]
};
const WINDOWS_LAUNCH = {
2: ["ERROR_FILE_NOT_FOUND", "executable_missing"],
3: ["ERROR_PATH_NOT_FOUND", "path_missing"],
5: ["ERROR_ACCESS_DENIED", "permission_denied"],
126: ["ERROR_MOD_NOT_FOUND", "missing_dependency"],
127: ["ERROR_PROC_NOT_FOUND", "dependency_mismatch"],
193: ["ERROR_BAD_EXE_FORMAT", "architecture_mismatch"],
206: ["ERROR_FILENAME_EXCED_RANGE", "path_too_long"],
740: ["ERROR_ELEVATION_REQUIRED", "permission_denied"],
1114: ["ERROR_DLL_INIT_FAILED", "dependency_initialization_failed"],
1455: ["ERROR_COMMITMENT_LIMIT", "insufficient_memory"]
};
const POSIX = {
11: ["SIGSEGV", "runtime_crash"],
4: ["SIGILL", "cpu_incompatible"],
6: ["SIGABRT", "runtime_abort"],
9: ["SIGKILL", "killed_or_oom"],
15: ["SIGTERM", "terminated"]
};
function normalizeExitCode(code, signal, platform = process.platform) {
if (platform === "win32" && Number.isInteger(code)) {
const unsigned = code >>> 0;
const signed = unsigned | 0;
const known = WINDOWS_STATUS[unsigned];
return {
raw_exit_code: code,
signed_exit_code: signed,
unsigned_exit_code: unsigned,
hex_exit_code: `0x${unsigned.toString(16).toUpperCase().padStart(8, "0")}`,
code: known?.[0] || "WINDOWS_PROCESS_EXIT",
category: known?.[1] || "runtime_exit",
remediation_steps: known?.[2] || ["Inspect runtime stdout and stderr.", "Reinstall or try an alternate runtime build."]
};
}
const signalNumber = typeof signal === "string" ? require("os").constants.signals[signal] : null;
const number = signalNumber || (Number.isInteger(code) && code >= 128 ? code - 128 : Number.isInteger(code) && code < 0 ? -code : null);
const known = number ? POSIX[number] : null;
return {
raw_exit_code: code,
signed_exit_code: code,
unsigned_exit_code: Number.isInteger(code) ? code >>> 0 : null,
hex_exit_code: Number.isInteger(code) ? `0x${(code >>> 0).toString(16).toUpperCase().padStart(8, "0")}` : null,
code: known?.[0] || signal || "PROCESS_EXIT",
category: known?.[1] || "runtime_exit",
remediation_steps: known ? ["Inspect runtime logs.", "Verify runtime compatibility and model settings."] : ["Inspect runtime stdout and stderr."]
};
}
function classifyLaunchError(error, platform = process.platform) {
const numeric = Number(error?.errno);
const known = platform === "win32" ? WINDOWS_LAUNCH[numeric] : null;
return {
raw_exit_code: numeric || null,
signed_exit_code: numeric || null,
unsigned_exit_code: Number.isInteger(numeric) ? numeric >>> 0 : null,
hex_exit_code: Number.isInteger(numeric) ? `0x${(numeric >>> 0).toString(16).toUpperCase().padStart(8, "0")}` : null,
code: known?.[0] || error?.code || "PROCESS_LAUNCH_FAILED",
category: known?.[1] || (/EACCES|EPERM/.test(error?.code) ? "permission_denied" : /ENOENT/.test(error?.code) ? "executable_missing" : "launch_failed"),
remediation_steps: ["Verify the executable and working directory.", "Reinstall the runtime.", "Check file permissions and security software."]
};
}
module.exports = { normalizeExitCode, classifyLaunchError };

View File

@ -0,0 +1,47 @@
const os = require("os");
const fs = require("fs");
const { spawnSync } = require("child_process");
const { PLUGIN_DATA, PLUGIN_ROOT } = require("./paths");
function detectHardware(models) {
const freeDisk = getFreeDisk();
const totalRamMb = Math.floor(os.totalmem() / 1048576);
const availableRamMb = Math.floor(os.freemem() / 1048576);
const gpu = detectGpu();
const writable = testWritable();
const recommendation = [...models]
.filter((model) => model.ram_gb * 1024 <= totalRamMb && model.size / 1048576 <= freeDisk)
.sort((a, b) => b.ram_gb - a.ram_gb)[0]?.tier || "tiny";
return {
platform: os.platform(), architecture: os.arch(), cpu_threads: os.cpus().length,
total_ram_mb: totalRamMb, available_ram_mb: availableRamMb, free_disk_mb: freeDisk,
gpu, subprocess_allowed: true, plugin_writable: writable, recommended_tier: recommendation,
plugin_path: PLUGIN_ROOT, path_length: PLUGIN_ROOT.length,
long_path_warning: os.platform()==="win32" && PLUGIN_ROOT.length > 220,
network_path_warning: os.platform()==="win32" && PLUGIN_ROOT.startsWith("\\\\")
};
}
function getFreeDisk() {
try {
if (typeof fs.statfsSync === "function") {
const stat = fs.statfsSync(PLUGIN_DATA);
return Math.floor((Number(stat.bavail) * Number(stat.bsize)) / 1048576);
}
} catch {}
return 0;
}
function detectGpu() {
try {
const result = spawnSync("nvidia-smi", ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], { encoding: "utf8", timeout: 3000 });
if (result.status === 0 && result.stdout.trim()) {
const [name, vram] = result.stdout.trim().split(",").map((v) => v.trim());
return { present: true, name, vram_mb: Number(vram) || null };
}
} catch {}
return { present: false, name: null, vram_mb: null };
}
function testWritable() {
try { const file = require("path").join(PLUGIN_DATA, ".write-test"); fs.writeFileSync(file, "ok"); fs.unlinkSync(file); return true; }
catch { return false; }
}
module.exports = { detectHardware };

View File

@ -0,0 +1,55 @@
const fs = require("fs");
const { resolveData } = require("./paths");
const historyFile = () => resolveData("metrics", "history.jsonl");
const stateFile = () => resolveData("metrics", "summary.json");
function getSummary() {
try { return JSON.parse(fs.readFileSync(stateFile(), "utf8")); }
catch { return { total_requests:0, successful:0, failed:0, refusals:0, tool_suggestions:0, tool_executions:0, tool_denials:0, confirmation_cancellations:0, timeout_count:0, runtime_crash_count:0, runtime_self_test_total:0, runtime_self_test_failed_total:0, runtime_start_attempt_total:0, runtime_start_failed_total:0, verified_downloads:0, failed_downloads:0, requests_by_role:{}, requests_by_scope:{}, runtime_exit_code_counts:{}, durations:[], queue_wait_total_ms:0 }; }
}
function record(entry) {
const summary = getSummary();
summary.requests_by_role ||= {};
summary.requests_by_scope ||= {};
if (entry.kind === "request") {
summary.total_requests += 1;
if (entry.status === "success") summary.successful += 1;
if (entry.status === "failed") summary.failed += 1;
if (entry.status === "refused") summary.refusals += 1;
if (entry.role) summary.requests_by_role[entry.role] = (summary.requests_by_role[entry.role] || 0) + 1;
if (entry.scope) summary.requests_by_scope[entry.scope] = (summary.requests_by_scope[entry.scope] || 0) + 1;
}
if (entry.tool_requested) summary.tool_suggestions += 1;
if (entry.tool_executed) summary.tool_executions += 1;
if (entry.kind === "tool" && entry.status === "failed") summary.tool_denials += 1;
if (entry.kind === "tool" && entry.status === "cancelled") summary.confirmation_cancellations += 1;
if (entry.timeout) summary.timeout_count += 1;
if (entry.runtime_crash) summary.runtime_crash_count += 1;
if (entry.kind === "runtime_self_test") {
summary.runtime_self_test_total += 1;
if (entry.status === "failed") summary.runtime_self_test_failed_total += 1;
}
if (entry.kind === "runtime_start") {
if (entry.status === "attempt") summary.runtime_start_attempt_total += 1;
if (entry.status === "failed") summary.runtime_start_failed_total += 1;
}
if (entry.code) {
summary.runtime_exit_code_counts ||= {};
summary.runtime_exit_code_counts[entry.code] = (summary.runtime_exit_code_counts[entry.code] || 0) + 1;
}
if (entry.kind === "download" && entry.status === "success") summary.verified_downloads += 1;
if (entry.kind === "download" && entry.status === "failed") summary.failed_downloads += 1;
if (entry.duration_ms != null) summary.durations.push(entry.duration_ms);
summary.durations = summary.durations.slice(-500);
if (entry.queue_wait_ms) summary.queue_wait_total_ms += entry.queue_wait_ms;
fs.writeFileSync(stateFile(), JSON.stringify(summary, null, 2));
fs.appendFileSync(historyFile(), `${JSON.stringify({ timestamp:new Date().toISOString(), ...entry })}\n`);
}
function report() {
const s = getSummary(); const sorted=[...s.durations].sort((a,b)=>a-b);
return { ...s, average_response_ms: sorted.length ? Math.round(sorted.reduce((a,b)=>a+b,0)/sorted.length) : 0, median_response_ms: sorted.length ? sorted[Math.floor(sorted.length/2)] : 0 };
}
function history(limit=100) {
try { return fs.readFileSync(historyFile(),"utf8").trim().split(/\r?\n/).filter(Boolean).slice(-limit).reverse().map(JSON.parse); } catch { return []; }
}
module.exports = { record, report, history };

View File

@ -0,0 +1,20 @@
const path = require("path");
const fs = require("fs");
const PLUGIN_ROOT = path.resolve(__dirname, "..");
const PLUGIN_DATA = path.join(PLUGIN_ROOT, "data");
const DIRS = ["config", "models", "runtime", "logs", "metrics", "rag", "cache", "tmp", "diagnostics"];
function ensureDataDirs() {
for (const dir of DIRS) fs.mkdirSync(path.join(PLUGIN_DATA, dir), { recursive: true });
}
function resolveData(...parts) {
const target = path.resolve(PLUGIN_DATA, ...parts);
if (target !== PLUGIN_DATA && !target.startsWith(`${PLUGIN_DATA}${path.sep}`)) {
throw new Error("Path escapes Lumi AI plugin storage.");
}
return target;
}
module.exports = { PLUGIN_ROOT, PLUGIN_DATA, ensureDataDirs, resolveData };

View File

@ -0,0 +1,10 @@
function roleOf(user) { return user?.isAdmin ? "admin" : user?.isMod ? "mod" : user?.id ? "user" : "anonymous"; }
function canUse(user, config) {
const role = roleOf(user);
if (role === "anonymous") return false;
return role === "admin" ? config.assistant_visibility.admins : role === "mod" ? config.assistant_visibility.mods : config.assistant_visibility.users;
}
function roleAllows(actual, required) {
const rank={anonymous:0,user:1,mod:2,admin:3}; return rank[actual] >= rank[required || "user"];
}
module.exports = { roleOf, canUse, roleAllows };

View File

@ -0,0 +1,21 @@
const fs = require("fs");
const path = require("path");
const { PLUGIN_ROOT } = require("./paths");
function readTemplate(name){ return fs.readFileSync(path.join(PLUGIN_ROOT,"templates",name),"utf8").trim(); }
function buildPrompt({ config, role, message, contextBlocks=[], tools=[] }) {
const sections=[
readTemplate("system.txt"),
config.instructions.identity,
`ALLOWED TOPICS:\n${config.instructions.allowed_topics}`,
`REQUESTING ROLE: ${role}\n${readTemplate(`role_${role}.txt`)}`,
`RESPONSE STYLE:\n${config.instructions.style}\nMaximum answer length: ${config.instructions.maximum_answer_length} characters.\nRoleplay intensity: ${config.instructions.roleplay_intensity || 0}/10.`,
config.instructions.community_tone ? `COMMUNITY TONE:\n${config.instructions.community_tone}` : "",
`ADMIN CUSTOM INSTRUCTIONS (cannot override hard rules):\n${config.instructions.admin_custom || "(none)"}`,
`SAFE LUMI CONTEXT:\n${contextBlocks.join("\n\n") || "(none)"}`,
`ALLOWED TOOLS:\n${tools.map(t=>JSON.stringify({tool_id:t.tool_id,description:t.description,schema:t.schema})).join("\n") || "(none)"}`,
`USER MESSAGE:\n${message}`
];
return sections.filter(Boolean).join("\n\n---\n\n");
}
module.exports = { buildPrompt };

View File

@ -0,0 +1,24 @@
class RequestQueue {
constructor(getConfig) { this.getConfig=getConfig; this.active=0; this.pending=[]; this.rate=new Map(); }
get length(){ return this.pending.length; }
async run(userId, role, fn) {
const cfg=this.getConfig(); this.checkRate(userId,role,cfg);
if(this.pending.length >= cfg.max_queue_length) throw Object.assign(new Error("AI is busy right now. Try again in a moment."),{code:"QUEUE_FULL"});
const queuedAt=Date.now();
return new Promise((resolve,reject)=>{ this.pending.push({fn,resolve,reject,queuedAt}); this.drain(); });
}
checkRate(userId,role,cfg) {
if(role==="admin" && cfg.admin_bypass_rate_limit) return;
const now=Date.now(), key=`${role}:${userId}`, rows=(this.rate.get(key)||[]).filter(t=>now-t<60000);
if(rows.length >= cfg.per_user_requests_per_minute) throw Object.assign(new Error("AI rate limit reached. Try again shortly."),{code:"RATE_LIMIT"});
rows.push(now); this.rate.set(key,rows);
}
drain(){
const limit=Math.max(1,Number(this.getConfig().concurrency)||1);
while(this.active<limit && this.pending.length){
const job=this.pending.shift(); this.active++;
Promise.resolve().then(()=>job.fn(Date.now()-job.queuedAt)).then(job.resolve,job.reject).finally(()=>{this.active--;this.drain();});
}
}
}
module.exports = { RequestQueue };

View File

@ -0,0 +1,290 @@
const fs = require("fs");
const path = require("path");
const net = require("net");
const os = require("os");
const crypto = require("crypto");
const { spawn } = require("child_process");
const { resolveData } = require("./paths");
const { getRuntimeState, saveRuntimeState } = require("./config_manager");
const { normalizeExitCode, classifyLaunchError } = require("./error_codes");
const { persistDiagnostic, getLatestDiagnostic, tail } = require("./diagnostics");
class RuntimeManager {
constructor({ getConfig, getModel, runtimeManifest, onCrash, onDiagnostic }) {
Object.assign(this, { getConfig, getModel, runtimeManifest, onCrash, onDiagnostic });
this.child = null;
this.port = null;
this.startedAt = null;
this.lastError = null;
this.lastSelfTest = null;
}
findBinary() {
return findRecursive(resolveData("runtime"), process.platform === "win32" ? "llama-server.exe" : "llama-server");
}
modelPath() {
const model = this.getModel(this.getConfig().selected_model_id);
return model ? resolveData("models", model.filename) : null;
}
status() {
const binary = this.findBinary();
const model = this.modelPath();
return {
state: this.child && !this.child.killed ? "running" : this.lastError ? "error" : "stopped",
runtime_installed: Boolean(binary),
runtime_usable: this.lastSelfTest?.success ?? null,
model_downloaded: Boolean(model && fs.existsSync(model)),
port: this.port,
pid: this.child?.pid || null,
uptime_ms: this.startedAt ? Date.now() - this.startedAt : 0,
last_error: this.lastError,
last_self_test: this.lastSelfTest,
executable_path: binary,
working_directory: binary ? path.dirname(binary) : null,
model_path: model,
latest_diagnostic: getLatestDiagnostic()
};
}
async selfTest() {
const binary = this.findBinary();
if (!binary) return this.failDiagnostic("executable_missing", "RUNTIME_MISSING", "Runtime executable was not found.", { remediation_steps: ["Download or reinstall the managed runtime."] });
const installation = this.verifyRuntimeInstallation();
if (!installation.success) return this.failDiagnostic(installation.category, "INSTALL_VALIDATION_FAILED", installation.message, installation);
const result = await runCaptured(binary, ["--help"], path.dirname(binary), 10000);
fs.writeFileSync(resolveData("logs", "runtime-selftest.log"), `${result.stdout}\n${result.stderr}`.trim());
if (result.error) {
const decoded = classifyLaunchError(result.error);
return this.failDiagnostic(decoded.category, decoded.code, result.error.message, { ...decoded, executable_path: binary, working_directory: path.dirname(binary), command_args: ["--help"], stdout_tail: tail(result.stdout), stderr_tail: tail(result.stderr) });
}
if (result.timedOut) return this.failDiagnostic("self_test_timeout", "SELF_TEST_TIMEOUT", "Runtime self-test exceeded 10 seconds.", { executable_path: binary, working_directory: path.dirname(binary), command_args: ["--help"], stdout_tail: tail(result.stdout), stderr_tail: tail(result.stderr) });
if (result.code !== 0 || !/llama|usage|server|options/i.test(`${result.stdout}\n${result.stderr}`)) {
const decoded = normalizeExitCode(result.code, result.signal);
return this.failDiagnostic(decoded.category, decoded.code, "Runtime self-test failed.", { ...decoded, executable_path: binary, working_directory: path.dirname(binary), command_args: ["--help"], stdout_tail: tail(result.stdout), stderr_tail: tail(result.stderr) });
}
this.lastSelfTest = { success: true, timestamp: new Date().toISOString(), executable_path: binary, code: result.code };
this.lastError = null;
this.onDiagnostic?.({ kind: "runtime_self_test", status: "success" });
return this.lastSelfTest;
}
verifyRuntimeInstallation() {
const binary = this.findBinary();
if (!binary) return { success: false, category: "executable_missing", message: "Runtime executable was not found." };
const runtimeDir = resolveData("runtime");
const size = folderSize(runtimeDir);
if (size < 1024 * 1024) return { success: false, category: "incomplete_extraction", message: "Extracted runtime folder is unexpectedly small.", executable_path: binary, runtime_folder_size: size };
if (process.platform !== "win32") {
try { fs.accessSync(binary, fs.constants.X_OK); } catch { return { success: false, category: "permission_denied", message: "Runtime executable bit is not set.", executable_path: binary, runtime_folder_size: size }; }
}
if (process.platform === "win32") {
const dlls = findFiles(runtimeDir, (name) => name.toLowerCase().endsWith(".dll"));
if (!dlls.length) return { success: false, category: "missing_dependency", message: "No runtime DLL files were found after extraction.", executable_path: binary, runtime_folder_size: size };
return { success: true, executable_path: binary, runtime_folder_size: size, dll_count: dlls.length };
}
return { success: true, executable_path: binary, runtime_folder_size: size };
}
async verifyModel() {
const model = this.getModel(this.getConfig().selected_model_id);
const file = this.modelPath();
if (!model || !file || !fs.existsSync(file)) return { success: false, category: "model_missing", message: "Selected model file is missing." };
const stat = fs.statSync(file);
if (stat.size !== model.size) return { success: false, category: "model_size_mismatch", message: `Expected ${model.size} bytes, found ${stat.size}.` };
const header = Buffer.alloc(4);
const descriptor = fs.openSync(file, "r");
try { fs.readSync(descriptor, header, 0, 4, 0); } finally { fs.closeSync(descriptor); }
if (header.toString("ascii") !== "GGUF") return { success: false, category: "model_invalid", message: "Selected file does not have a GGUF header." };
const sha256 = await hashFile(file);
if (sha256 !== model.sha256) return { success: false, category: "model_hash_mismatch", message: "Selected model SHA-256 does not match the manifest.", sha256 };
return { success: true, file, size: stat.size, sha256 };
}
async start({ resume = false } = {}) {
if (this.child && !this.child.killed) return this.status();
this.onDiagnostic?.({ kind: "runtime_start", status: "attempt" });
const selfTest = await this.selfTest();
if (!selfTest.success) {
this.onDiagnostic?.({ kind: "runtime_start", status: "failed", category: selfTest.category });
throw new Error(selfTest.message || "Runtime self-test failed.");
}
const modelValidation = await this.verifyModel();
if (!modelValidation.success) {
const diagnostic = this.failDiagnostic(modelValidation.category, "MODEL_VALIDATION_FAILED", modelValidation.message, { model_path: this.modelPath() });
saveRuntimeState({ ...getRuntimeState(), desired_state: "stopped", last_known_state: "error", last_stop_reason: modelValidation.category, last_manual_stop: false, last_crashed: false, last_diagnostic_category: modelValidation.category });
throw new Error(diagnostic.message);
}
const binary = this.findBinary();
const model = this.modelPath();
this.port = await freePort();
const cfg = this.getConfig();
const threads = Number(cfg.threads) > 0 ? Number(cfg.threads) : os.cpus().length;
const args = ["--host", "127.0.0.1", "--port", String(this.port), "-m", model, "-c", String(cfg.context_size || 4096), "-t", String(threads)];
const logPath = resolveData("logs", `runtime-${Date.now()}.log`);
const log = fs.openSync(logPath, "a");
const child = spawn(binary, args, { cwd: path.dirname(binary), stdio: ["ignore", log, log], windowsHide: true, shell: false });
fs.closeSync(log);
this.child = child;
this.startedAt = Date.now();
this.lastError = null;
child.once("error", (error) => {
child.__spawnFailed = true;
const decoded = classifyLaunchError(error);
this.failDiagnostic(decoded.category, decoded.code, error.message, { ...decoded, executable_path: binary, working_directory: path.dirname(binary), command_args: args, model_path: model });
if (this.child === child) this.child = null;
this.persistCrash(decoded.category, error.message, decoded.signed_exit_code);
});
child.once("exit", (code, signal) => {
const expected = child.__manualStop || child.__spawnFailed;
if (this.child === child) this.child = null;
if (!expected) {
const decoded = normalizeExitCode(code, signal);
const diagnostic = this.failDiagnostic(decoded.category, decoded.code, `Runtime exited before or after health readiness.`, { ...decoded, executable_path: binary, working_directory: path.dirname(binary), command_args: args, model_path: model });
this.persistCrash(decoded.category, diagnostic.message, decoded.signed_exit_code);
}
});
saveRuntimeState({ ...getRuntimeState(), desired_state: "running", last_known_state: "starting", last_crashed: false, last_manual_stop: false, last_stop_reason: resume ? "resuming" : "starting", selected_model_id: cfg.selected_model_id });
try {
await waitHealth(this, 45000);
saveRuntimeState({ ...getRuntimeState(), desired_state: "running", last_known_state: "running", last_crashed: false, last_manual_stop: false, last_stop_reason: resume ? "resumed" : "started", selected_model_id: cfg.selected_model_id });
this.onDiagnostic?.({ kind: "runtime_start", status: "success", model_load_ms: Date.now() - this.startedAt });
return this.status();
} catch (error) {
if (this.child) await this.stop({ manual: false, reason: "health_timeout" });
const existing = getLatestDiagnostic();
const preserveProcessExit = error.category === "process_exited_before_health" && existing?.raw_exit_code != null;
if (!preserveProcessExit) {
this.failDiagnostic(error.category || "health_timeout", "RUNTIME_HEALTH_FAILED", error.message, { executable_path: binary, working_directory: path.dirname(binary), command_args: args, model_path: model });
saveRuntimeState({ ...getRuntimeState(), desired_state: "stopped", last_known_state: "error", last_stop_reason: error.category || "health_timeout", last_manual_stop: false, last_crashed: false, last_diagnostic_category: error.category || "health_timeout" });
} else {
error.message = `${existing.code}: ${existing.message}`;
}
this.onDiagnostic?.({ kind: "runtime_start", status: "failed", category: error.category || "health_timeout" });
throw error;
}
}
failDiagnostic(category, code, message, extra = {}) {
this.lastError = message;
this.lastSelfTest = category.startsWith("self_test") || code === "RUNTIME_MISSING" || extra.command_args?.[0] === "--help" ? { success: false, category, code, message } : this.lastSelfTest;
const diagnostic = persistDiagnostic({ category, code, message, ...extra });
if (extra.command_args?.[0] === "--help" || code === "RUNTIME_MISSING" || category === "self_test_timeout") {
saveRuntimeState({ ...getRuntimeState(), desired_state: "stopped", last_known_state: "error", last_stop_reason: "self_test_failed", last_manual_stop: false, last_crashed: false, last_diagnostic_category: category, last_exit_code: extra.signed_exit_code ?? null });
this.onDiagnostic?.({ kind: "runtime_self_test", status: "failed", category, code });
}
this.onDiagnostic?.({ kind: "runtime_diagnostic", status: "failed", category, code });
return { success: false, ...diagnostic };
}
persistCrash(category, message, exitCode) {
saveRuntimeState({ ...getRuntimeState(), desired_state: "stopped", last_known_state: "crashed", last_crashed: true, last_stop_reason: "runtime_crash", last_manual_stop: false, last_exit_code: exitCode ?? null, last_diagnostic_category: category });
this.onCrash?.(message);
}
async stop({ manual = true, reason = "manual_stop" } = {}) {
const wasRunning = Boolean(this.child && !this.child.killed);
if (this.child) {
const child = this.child;
child.__manualStop = true;
child.kill();
await waitExit(child, 10000);
if (this.child === child && !child.killed) child.kill("SIGKILL");
}
this.child = null;
this.startedAt = null;
const resumeAfterShutdown = !manual && reason === "bot_shutdown" && wasRunning;
saveRuntimeState({ ...getRuntimeState(), desired_state: resumeAfterShutdown ? "running" : "stopped", last_known_state: "stopped", last_stop_reason: reason, last_manual_stop: manual, last_crashed: false });
return this.status();
}
async restart() { await this.stop({ manual: false, reason: "restart" }); return this.start(); }
async health() {
const status = this.status();
if (status.state !== "running") return { ...status, healthy: false };
try {
const response = await fetch(`http://127.0.0.1:${this.port}/health`, { signal: AbortSignal.timeout(2000) });
if (!response.ok) return { ...status, healthy: false, health_status: "http_error", health_http_status: response.status };
try {
const body = await response.json();
return { ...status, healthy: true, health_status: "ready", health_response: body };
} catch {
return { ...status, healthy: false, health_status: "invalid_json" };
}
} catch (error) {
return { ...status, healthy: false, health_status: error.name === "TimeoutError" ? "connection_timeout" : "connection_refused" };
}
}
async infer(messages, maxTokens = 300) {
if (!this.port) throw new Error("Runtime is offline.");
const response = await fetch(`http://127.0.0.1:${this.port}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "local", messages, max_tokens: maxTokens, temperature: 0.2 }), signal: AbortSignal.timeout(this.getConfig().request_timeout_ms || 120000) });
if (!response.ok) throw new Error(`Inference failed (${response.status})`);
return response.json();
}
}
function findRecursive(dir, name) {
if (!fs.existsSync(dir)) return null;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const target = path.join(dir, entry.name);
if (entry.isFile() && entry.name === name) return target;
if (entry.isDirectory()) { const found = findRecursive(target, name); if (found) return found; }
}
return null;
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => { const port = server.address().port; server.close(() => resolve(port)); });
server.on("error", reject);
});
}
function runCaptured(executable, args, cwd, timeoutMs) {
return new Promise((resolve) => {
const child = spawn(executable, args, { cwd, windowsHide: true, shell: false });
let stdout = "", stderr = "", settled = false, timedOut = false, timer;
const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ stdout, stderr, timedOut, ...result }); };
child.stdout.on("data", (chunk) => { stdout = tail(stdout + chunk, 12000); });
child.stderr.on("data", (chunk) => { stderr = tail(stderr + chunk, 12000); });
child.once("error", (error) => finish({ error }));
child.once("exit", (code, signal) => finish({ code, signal }));
timer = setTimeout(() => { timedOut = true; child.kill(); }, timeoutMs);
});
}
async function waitHealth(manager, timeout) {
const end = Date.now() + timeout;
let lastCategory = "connection_refused";
while (Date.now() < end) {
if (!manager.child) throw Object.assign(new Error("Runtime process exited before health became ready."), { category: "process_exited_before_health" });
try {
const response = await fetch(`http://127.0.0.1:${manager.port}/health`, { signal: AbortSignal.timeout(2000) });
if (!response.ok) lastCategory = "http_error";
else {
try { await response.json(); return; }
catch { lastCategory = "invalid_json"; }
}
} catch (error) {
lastCategory = error.name === "TimeoutError" ? "connection_timeout" : "connection_refused";
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw Object.assign(new Error(`Runtime process remained alive but health did not become ready within 45 seconds (${lastCategory}).`), { category: lastCategory === "connection_refused" ? "model_load_timeout" : lastCategory });
}
function waitExit(child, timeout) {
return new Promise((resolve) => {
if (child.exitCode != null) return resolve();
const timer = setTimeout(resolve, timeout);
child.once("exit", () => { clearTimeout(timer); resolve(); });
});
}
async function hashFile(file) {
const hash = crypto.createHash("sha256");
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
return hash.digest("hex");
}
function folderSize(dir) {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => {
const target = path.join(dir, entry.name);
return total + (entry.isDirectory() ? folderSize(target) : entry.isFile() ? fs.statSync(target).size : 0);
}, 0);
}
function findFiles(dir, predicate) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(dir, entry.name);
return entry.isDirectory() ? findFiles(target, predicate) : entry.isFile() && predicate(entry.name) ? [target] : [];
});
}
module.exports = { RuntimeManager, runCaptured };

View File

@ -0,0 +1,43 @@
const crypto = require("crypto");
const { roleAllows } = require("./permissions");
class ToolRegistry {
constructor(audit){ this.tools=new Map(); this.confirmations=new Map(); this.audit=audit; }
register(def){
if(!def?.tool_id || !def.display_name || !def.description || !def.owning_plugin || !def.required_permission || !def.audit_category || typeof def.workflow_handler!=="function" || typeof def.permission_check!=="function" || !def.schema) throw new Error("Invalid AI tool definition.");
this.tools.set(def.tool_id,{required_role:"user",confirmation_required:true,risk_level:"sensitive",...def});
}
list(role){ return [...this.tools.values()].filter(t=>roleAllows(role,t.required_role)).map(({workflow_handler,permission_check,...t})=>t); }
validate(tool,args,role){
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; }
return {def,args:clean};
}
prepare({tool,args,user,role,sessionId}){
const checked=this.validate(tool,args,role);
const allowed=checked.def.permission_check({user,arguments:checked.args,required_permission:checked.def.required_permission});
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});
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});
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});
}
cancel(id,userId){ const p=this.confirmations.get(id); if(p?.userId===userId){this.confirmations.delete(id);return true;} return false; }
}
function parseToolCall(text){
const match=(text||"").match(/\{[\s\S]*"type"\s*:\s*"tool_call"[\s\S]*\}/); if(!match)return null;
try{const value=JSON.parse(match[0]);return value.type==="tool_call"?value:null;}catch{return null;}
}
module.exports = { ToolRegistry, parseToolCall };

View File

@ -0,0 +1 @@
{"pluginId":"lumi_ai","commands":[]}

1
plugins/lumi_ai/data/cache/.gitkeep vendored Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

376
plugins/lumi_ai/index.js Normal file
View File

@ -0,0 +1,376 @@
const fs = require("fs");
const path = require("path");
const express = require("express");
const { ensureDataDirs, resolveData } = require("./backend/paths");
const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager");
const { detectHardware } = require("./backend/hardware");
const metrics = require("./backend/metrics");
const { canUse, roleOf } = require("./backend/permissions");
const { RequestQueue } = require("./backend/queue_manager");
const { ToolRegistry } = require("./backend/tool_router");
const { DownloadManager } = require("./backend/downloader");
const { RuntimeManager } = require("./backend/runtime_manager");
const { AiProvider } = require("./backend/ai_provider");
const { getLatestDiagnostic, createDiagnosticsBundle } = require("./backend/diagnostics");
const PLUGIN_ID = "lumi_ai";
const modelManifest = require("./models_manifest.json");
const runtimeManifest = require("./runtime_manifest.json");
module.exports = {
id: PLUGIN_ID,
init({ web, settings }) {
ensureDataDirs();
let config = getConfig();
const getModel = (id) => modelManifest.models.find((model) => model.id === id);
const downloads = new DownloadManager((entry) => metrics.record(entry));
const queue = new RequestQueue(() => config);
const tools = new ToolRegistry((entry) => metrics.record(entry));
const contextProviders = new Map();
const getSafeContext = (role) => [...contextProviders.values()].flatMap((fn) => {
try { return normalizeContext(fn({ role })); } catch { return []; }
});
const runtime = new RuntimeManager({
getConfig: () => config,
getModel,
runtimeManifest,
onCrash: (message) => metrics.record({ kind: "runtime", status: "failed", runtime_crash: true, message }),
onDiagnostic: (entry) => metrics.record(entry)
});
const provider = new AiProvider({
getConfig: () => config,
runtime,
queue,
tools,
metrics,
getContext: getSafeContext
});
const api = {
health: () => runtime.health(),
capabilities: () => ({
provider: "local_llama_cpp",
enabled: config.enabled,
model_id: config.selected_model_id,
roles: config.assistant_visibility,
tools: tools.list("admin").map((tool) => tool.tool_id)
}),
metrics_summary: () => metrics.report(),
generate: (input) => provider.generate(input),
classify: (input) => provider.classify(input),
summarize: (input) => provider.summarize(input),
route_tool: async ({ message, allowed_tools = [], ...input }) => {
const result = await provider.generate({ message, ...input, scope: "route_tool" });
if (result.tool_call && !allowed_tools.includes(result.tool_call.tool)) {
return { ...result, success: false, tool_call: null, refusal_reason: "tool_not_allowed" };
}
return result;
},
registerTool: (definition) => tools.register(definition),
registerContext: (id, factory) => {
if (!id || typeof factory !== "function") throw new Error("Invalid AI context provider.");
contextProviders.set(id, factory);
},
unregisterContext: (id) => contextProviders.delete(id)
};
global.lumiFrameworks = global.lumiFrameworks || {};
global.lumiFrameworks.ai = api;
global.lumiFrameworks.lumi_ai = api;
const router = web.createRouter();
router.use("/assets", express.static(path.join(__dirname, "public")));
router.get("/", async (req, res) => {
if (!req.session.user?.isAdmin) return denied(res);
const hardware = detectHardware(modelManifest.models);
const runtimeTarget = getRuntimeTarget();
res.render(path.join(__dirname, "views", "settings.ejs"), {
title: "Lumi AI",
config,
models: modelManifest.models.map((model) => ({
...model,
downloaded: fs.existsSync(resolveData("models", model.filename)),
compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb
})),
runtimeTarget,
runtimeManifest,
runtimeStatus: await runtime.health(),
runtimeState: getRuntimeState(),
latestDiagnostic: getLatestDiagnostic(),
runtimeFolderSize: folderSize(resolveData("runtime")),
modelFileSize: modelFileSize(getModel(config.selected_model_id)),
hardware,
metrics: metrics.report(),
history: metrics.history(25),
logFiles: listLogFiles(),
formatBytes,
formatDuration
});
});
router.post("/settings", (req, res) => {
if (!req.session.user?.isAdmin) return denied(res);
const model = getModel(req.body.selected_model_id);
if (!model) return flash(req, res, "error", "Unknown model.");
config = saveConfig({
...config,
enabled: req.body.enabled === "on",
selected_model_id: model.id,
context_size: boundedInt(req.body.context_size, 512, 131072, 4096),
threads: boundedInt(req.body.threads, 0, 256, 0),
concurrency: boundedInt(req.body.concurrency, 1, 8, 1),
max_queue_length: boundedInt(req.body.max_queue_length, 1, 100, 8),
request_timeout_ms: boundedInt(req.body.request_timeout_ms, 5000, 600000, 120000),
per_user_requests_per_minute: boundedInt(req.body.per_user_requests_per_minute, 1, 120, 6),
admin_bypass_rate_limit: req.body.admin_bypass_rate_limit === "on",
assistant_visibility: {
admins: req.body.visibility_admins === "on",
mods: req.body.visibility_mods === "on",
users: req.body.visibility_users === "on"
},
instructions: {
identity: cleanText(req.body.identity, 1000),
style: cleanText(req.body.style, 1000),
allowed_topics: cleanText(req.body.allowed_topics, 2000),
out_of_scope_response: cleanText(req.body.out_of_scope_response, 1000),
maximum_answer_length: boundedInt(req.body.maximum_answer_length, 100, 4000, 700),
roleplay_intensity: boundedInt(req.body.roleplay_intensity, 0, 10, 0),
community_tone: cleanText(req.body.community_tone, 2000),
admin_custom: cleanText(req.body.admin_custom, 6000)
},
logging: {
log_prompts: req.body.log_prompts === "on",
log_responses: req.body.log_responses === "on",
log_tool_calls: req.body.log_tool_calls === "on",
log_metrics: req.body.log_metrics === "on",
log_internal_audit: req.body.log_internal_audit === "on"
}
});
return flash(req, res, "success", "Lumi AI settings saved.");
});
router.post("/download/runtime", (req, res) => {
if (!req.session.user?.isAdmin) return denied(res);
const target = getRuntimeTarget();
if (!target) return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform.");
try {
downloads.start({ id: "runtime", ...target, kind: "runtime", archive: true });
return flash(req, res, "success", "Runtime download started.");
} catch (error) {
return flash(req, res, "error", error.message);
}
});
router.post("/download/model/:id", (req, res) => {
if (!req.session.user?.isAdmin) return denied(res);
const model = getModel(req.params.id);
if (!model) return flash(req, res, "error", "Unknown model.");
const hardware = detectHardware(modelManifest.models);
const incompatible = model.ram_gb * 1024 > hardware.total_ram_mb || model.size / 1048576 > hardware.free_disk_mb;
if (incompatible && req.body.override_compatibility !== "on") {
return flash(req, res, "error", "This model exceeds detected RAM or free disk. Check override to download anyway.");
}
try {
downloads.start({
id: `model:${model.id}`,
url: `https://huggingface.co/${model.repo}/resolve/${model.revision}/${model.filename}`,
filename: model.filename,
sha256: model.sha256,
size: model.size,
kind: "model"
});
return flash(req, res, "success", `${model.label} download started.`);
} catch (error) {
return flash(req, res, "error", error.message);
}
});
router.get("/api/status", async (req, res) => {
if (!canUse(req.session.user, config) && !req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
res.json({ runtime: await runtime.health(), queue_length: queue.length, enabled: config.enabled, model_id: config.selected_model_id });
});
router.get("/api/downloads", (req, res) => {
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
res.json(Object.fromEntries(downloads.jobs));
});
router.post("/runtime/:action", async (req, res) => {
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
try {
const action = req.params.action;
if (!["start", "stop", "restart", "self-test", "verify-runtime", "verify-model"].includes(action)) throw new Error("Unknown runtime action.");
const result = action === "self-test" ? await runtime.selfTest()
: action === "verify-runtime" ? runtime.verifyRuntimeInstallation()
: action === "verify-model" ? await runtime.verifyModel()
: action === "stop"
? await runtime.stop({ manual: true, reason: "admin_stop" })
: action === "restart" ? await runtime.restart() : await runtime.start();
if (result?.success === false) return res.status(400).json({ error: result.message, diagnostic: result });
res.json(result);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
router.get("/diagnostics/download", (req, res) => {
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
const file = createDiagnosticsBundle({
config,
runtimeState: getRuntimeState(),
manifest: { runtime: runtimeManifest, model: getModel(config.selected_model_id) },
metrics: metrics.report()
});
return res.download(file);
});
router.post("/assistant/message", async (req, res) => {
if (!config.enabled || !canUse(req.session.user, config)) return res.status(403).json({ error: "Lumi AI is unavailable for this account." });
const message = cleanText(req.body.message, 6000);
if (!message) return res.status(400).json({ error: "Message is required." });
try {
res.json(await provider.generate({ message, user: req.session.user, sessionId: req.sessionID }));
} catch (error) {
metrics.record({ kind: "request", status: "failed", user_id: req.session.user.id, role: roleOf(req.session.user), message: error.message });
res.status(error.code === "QUEUE_FULL" || error.code === "RATE_LIMIT" ? 429 : 503).json({ error: error.message });
}
});
router.post("/assistant/test", async (req, res) => {
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
const message = cleanText(req.body.message, 6000);
if (!message) return res.status(400).json({ error: "Message is required." });
const simulatedRole = ["admin", "mod", "user"].includes(req.body.role) ? req.body.role : "admin";
const simulatedUser = {
id: req.session.user.id,
username: req.session.user.username,
isAdmin: simulatedRole === "admin",
isMod: simulatedRole === "mod"
};
try {
const result = await provider.test({
message,
user: simulatedUser,
includeRaw: Boolean(req.body.show_raw_output)
});
if (!req.body.show_raw_prompt) delete result.raw_prompt;
res.json(result);
} catch (error) {
res.status(503).json({ error: error.message });
}
});
router.post("/assistant/confirm", async (req, res) => {
if (!canUse(req.session.user, config)) return res.status(403).json({ error: "Access denied." });
try { res.json({ success: true, result: await tools.confirm({ id: req.body.id, user: req.session.user, sessionId: req.sessionID }) }); }
catch (error) { res.status(400).json({ error: error.message }); }
});
router.post("/assistant/cancel", (req, res) => {
if (!canUse(req.session.user, config)) return res.status(403).json({ error: "Access denied." });
const cancelled = tools.cancel(req.body.id, req.session.user.id);
metrics.record({ kind: "tool", status: cancelled ? "cancelled" : "failed", user_id: req.session.user.id });
res.json({ success: cancelled });
});
web.mount(`/plugins/${PLUGIN_ID}`, router, {
label: "Lumi AI",
role: "admin",
section: "plugins"
});
if (typeof web.addAssistantPanel === "function") {
web.addAssistantPanel({
id: PLUGIN_ID,
role: "user",
isVisible: (user) => config.enabled && canUse(user, config),
view: path.join(__dirname, "views", "assistant-panel.ejs"),
stylesheet: `/plugins/${PLUGIN_ID}/assets/assistant.css`,
script: `/plugins/${PLUGIN_ID}/assets/assistant.js`,
locals: { endpoint: `/plugins/${PLUGIN_ID}` }
});
} else {
console.warn("Lumi AI assistant panel hook is unavailable; settings remain accessible.");
}
ensureSidebarNavItem(settings);
const state = getRuntimeState();
if (shouldAutoResume(config, state)) {
setImmediate(() => runtime.start({ resume: true }).catch((error) => console.error("Lumi AI runtime resume failed", error)));
}
return async () => {
await runtime.stop({ manual: false, reason: "bot_shutdown" });
if (global.lumiFrameworks?.ai === api) delete global.lumiFrameworks.ai;
if (global.lumiFrameworks?.lumi_ai === api) delete global.lumiFrameworks.lumi_ai;
};
}
};
function getRuntimeTarget() {
return runtimeManifest.targets[`${process.platform}-${process.arch}`] || null;
}
function normalizeContext(value) {
if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
return typeof value === "string" ? [value] : [];
}
function boundedInt(value, min, max, fallback) {
const number = Number.parseInt(value, 10);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
}
function cleanText(value, max) {
return String(value || "").trim().slice(0, max);
}
function flash(req, res, type, message) {
req.session.flash = { type, message };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
function denied(res) {
return res.status(403).render("error", { title: "Access denied", message: "Administrator access is required." });
}
function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "MB", "GB", "TB"];
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
}
function formatDuration(ms) {
if (!ms) return "0 ms";
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
}
function listLogFiles() {
const dir = resolveData("logs");
return fs.readdirSync(dir).filter((name) => name.endsWith(".log")).sort().reverse().slice(0, 10).map((name) => ({ name, size: fs.statSync(path.join(dir, name)).size }));
}
function folderSize(dir) {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => {
const target = path.join(dir, entry.name);
return total + (entry.isDirectory() ? folderSize(target) : entry.isFile() ? fs.statSync(target).size : 0);
}, 0);
}
function modelFileSize(model) {
if (!model) return 0;
const file = resolveData("models", model.filename);
return fs.existsSync(file) ? fs.statSync(file).size : 0;
}
function ensureSidebarNavItem(settings) {
if (!settings?.getSetting || !settings?.setSetting) return;
const raw = settings.getSetting("nav_structure", null);
if (!raw) return;
let structure = raw;
if (typeof structure === "string") {
try { structure = JSON.parse(structure); } catch { return; }
}
if (!structure?.enabled || !Array.isArray(structure.sections)) return;
const navId = "plugins_lumi_ai";
for (const section of structure.sections) {
if (Array.isArray(section.items)) section.items = section.items.filter((item) => item !== navId);
}
let plugins = structure.sections.find((section) => section.id === "plugins");
if (!plugins) {
plugins = { id: "plugins", label: "Plugins", icon: "blocks", items: [] };
structure.sections.push(plugins);
}
plugins.items = Array.isArray(plugins.items) ? plugins.items : [];
plugins.items.push(navId);
settings.setSetting("nav_structure", structure);
}
function shouldAutoResume(config, state) {
return Boolean(config.enabled && state.desired_state === "running" && !state.last_manual_stop && !state.last_crashed);
}
module.exports.shouldAutoResume = shouldAutoResume;

View File

@ -0,0 +1,207 @@
{
"models": [
{
"id": "smollm2-360m-q8",
"label": "Tiny - SmolLM2 360M Q8",
"display_name": "SmolLM2 360M Instruct",
"model_family": "SmolLM2",
"parameter_count": "360M",
"repo_id": "HuggingFaceTB/SmolLM2-360M-Instruct-GGUF",
"license": "apache-2.0",
"format": "GGUF",
"quantization": "Q8_0",
"min_ram_mb": 1024,
"recommended_ram_mb": 2048,
"min_disk_mb": 400,
"recommended_threads": 2,
"default_context": 2048,
"max_context": 8192,
"supports_chat": true,
"supports_json": false,
"supports_tool_routing": false,
"recommended_use": "Basic intent routing and very short responses.",
"warnings": "Not recommended for complex assistant behavior.",
"repo": "HuggingFaceTB/SmolLM2-360M-Instruct-GGUF",
"revision": "593b5a2e04c8f3e4ee880263f93e0bd2901ad47f",
"filename": "smollm2-360m-instruct-q8_0.gguf",
"size": 386404992,
"sha256": "48ab3034d0dd401fbc721eb1df3217902fee7dab9078992d66431f09b7750201",
"ram_gb": 2,
"tier": "tiny"
},
{
"id": "qwen3-0.6b-q4",
"label": "Small - Qwen3 0.6B Q4_K_M",
"display_name": "Qwen3 0.6B Instruct",
"model_family": "Qwen3",
"parameter_count": "0.6B",
"repo_id": "bartowski/Qwen_Qwen3-0.6B-GGUF",
"license": "apache-2.0",
"format": "GGUF",
"quantization": "Q4_K_M",
"min_ram_mb": 1536,
"recommended_ram_mb": 3072,
"min_disk_mb": 500,
"recommended_threads": 4,
"default_context": 4096,
"max_context": 32768,
"supports_chat": true,
"supports_json": true,
"supports_tool_routing": false,
"recommended_use": "Basic scoped assistant for weak servers.",
"warnings": "Limited reasoning and tool-routing reliability.",
"repo": "bartowski/Qwen_Qwen3-0.6B-GGUF",
"revision": "60b85c0e3d8fe0f6474f406922a26d12aca4550d",
"filename": "Qwen_Qwen3-0.6B-Q4_K_M.gguf",
"size": 484220320,
"sha256": "9acfc1e001311f34b4252001b626f2e466d592a42065f66571bff3790d4e1b14",
"ram_gb": 3,
"tier": "small"
},
{
"id": "qwen3-1.7b-q4",
"label": "Medium - Qwen3 1.7B Q4_K_M",
"display_name": "Qwen3 1.7B Instruct",
"model_family": "Qwen3",
"parameter_count": "1.7B",
"repo_id": "bartowski/Qwen_Qwen3-1.7B-GGUF",
"license": "apache-2.0",
"format": "GGUF",
"quantization": "Q4_K_M",
"min_ram_mb": 3072,
"recommended_ram_mb": 5120,
"min_disk_mb": 1300,
"recommended_threads": 6,
"default_context": 4096,
"max_context": 32768,
"supports_chat": true,
"supports_json": true,
"supports_tool_routing": true,
"recommended_use": "Recommended minimum for useful bot assistant behavior.",
"warnings": "CPU response speed depends heavily on host memory bandwidth.",
"repo": "bartowski/Qwen_Qwen3-1.7B-GGUF",
"revision": "dcb19155b962dbb6389f4691a982043a8e651022",
"filename": "Qwen_Qwen3-1.7B-Q4_K_M.gguf",
"size": 1282439584,
"sha256": "72c5c3cb38fa32d5256e2fe30d03e7a64c6c79e668ad84057e3bd66e250b24fb",
"ram_gb": 5,
"tier": "medium"
},
{
"id": "qwen3-4b-q4",
"label": "Large - Qwen3 4B Q4_K_M",
"display_name": "Qwen3 4B Instruct",
"model_family": "Qwen3",
"parameter_count": "4B",
"repo_id": "bartowski/Qwen_Qwen3-4B-GGUF",
"license": "apache-2.0",
"format": "GGUF",
"quantization": "Q4_K_M",
"min_ram_mb": 5120,
"recommended_ram_mb": 8192,
"min_disk_mb": 2500,
"recommended_threads": 8,
"default_context": 4096,
"max_context": 32768,
"supports_chat": true,
"supports_json": true,
"supports_tool_routing": true,
"recommended_use": "Better style following, tool routing, and reasoning.",
"warnings": "May be slow on CPU-only systems.",
"repo": "bartowski/Qwen_Qwen3-4B-GGUF",
"revision": "cb76885dc66d50759b207c5a48c4e78dfa00c638",
"filename": "Qwen_Qwen3-4B-Q4_K_M.gguf",
"size": 2497280960,
"sha256": "fbe1d5edd4ce802ae3ae7c7e4ab7d09789d697fdac1fc7929f8df4ca3c41bae3",
"ram_gb": 8,
"tier": "large"
},
{
"id": "qwen3-8b-q4",
"label": "General - Qwen3 8B Q4_K_M",
"display_name": "Qwen3 8B Instruct",
"model_family": "Qwen3",
"parameter_count": "8B",
"repo_id": "bartowski/Qwen_Qwen3-8B-GGUF",
"license": "apache-2.0",
"format": "GGUF",
"quantization": "Q4_K_M",
"min_ram_mb": 8192,
"recommended_ram_mb": 12288,
"min_disk_mb": 5100,
"recommended_threads": 10,
"default_context": 4096,
"max_context": 32768,
"supports_chat": true,
"supports_json": true,
"supports_tool_routing": true,
"recommended_use": "More capable general assistant.",
"warnings": "Requires decent RAM and patience on CPU.",
"repo": "bartowski/Qwen_Qwen3-8B-GGUF",
"revision": "0b69f75b7472688e6808490aa2b85efdb81b5ce7",
"filename": "Qwen_Qwen3-8B-Q4_K_M.gguf",
"size": 5027784224,
"sha256": "54fffa050078e984116639c83dfb64b5aa6d4cd474e018b076777c632bbccccd",
"ram_gb": 12,
"tier": "general"
},
{
"id": "qwen3-14b-q4",
"label": "GPU - Qwen3 14B Q4_K_M",
"display_name": "Qwen3 14B Instruct",
"model_family": "Qwen3",
"parameter_count": "14B",
"repo_id": "bartowski/Qwen_Qwen3-14B-GGUF",
"license": "apache-2.0",
"format": "GGUF",
"quantization": "Q4_K_M",
"min_ram_mb": 14336,
"recommended_ram_mb": 20480,
"min_disk_mb": 9000,
"recommended_threads": 12,
"default_context": 4096,
"max_context": 32768,
"supports_chat": true,
"supports_json": true,
"supports_tool_routing": true,
"recommended_use": "Serious local assistant tier.",
"warnings": "GPU strongly recommended.",
"repo": "bartowski/Qwen_Qwen3-14B-GGUF",
"revision": "bd080f768a6401c2d5a7fa53a2e50cd8218a9ce2",
"filename": "Qwen_Qwen3-14B-Q4_K_M.gguf",
"size": 9001753632,
"sha256": "915913e22399475dbe6c968ac014d9f1fbe08975e489279aede9d5c7b2c98eb6",
"ram_gb": 20,
"tier": "gpu"
},
{
"id": "qwen3-30b-a3b-q4",
"label": "GPU XL - Qwen3 30B-A3B Q4_K_M",
"display_name": "Qwen3 30B-A3B Instruct",
"model_family": "Qwen3 MoE",
"parameter_count": "30B total / 3B active",
"repo_id": "bartowski/Qwen_Qwen3-30B-A3B-GGUF",
"license": "apache-2.0",
"format": "GGUF",
"quantization": "Q4_K_M",
"min_ram_mb": 24576,
"recommended_ram_mb": 32768,
"min_disk_mb": 18700,
"recommended_threads": 16,
"default_context": 4096,
"max_context": 32768,
"supports_chat": true,
"supports_json": true,
"supports_tool_routing": true,
"recommended_use": "Experimental high-end assistant tier.",
"warnings": "Requires strong hardware and substantial disk space.",
"repo": "bartowski/Qwen_Qwen3-30B-A3B-GGUF",
"revision": "46f17e079cba70b04390bef39b57d2783e9fd015",
"filename": "Qwen_Qwen3-30B-A3B-Q4_K_M.gguf",
"size": 18632184480,
"sha256": "a015794bfb1d69cb03dbb86b185fb2b9b339f757df5f8f9dd9ebdab8f6ed5d32",
"ram_gb": 32,
"tier": "gpu_xl"
}
]
}

View File

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

View File

@ -0,0 +1,29 @@
.lumi-ai-shell { width: 100%; min-width: 0; }
.lumi-ai-pill { width: 100%; min-height: 42px; display: flex; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--ink); cursor: pointer; }
.lumi-ai-pill:hover { border-color: var(--sea); background: var(--surface-3); }
.lumi-ai-mark { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 6px; background: var(--sea); color: white; font-size: 10px; font-weight: 800; }
.lumi-ai-pill-label { flex: 1; text-align: left; font-weight: 700; }
.lumi-ai-state { width: 8px; height: 8px; border-radius: 50%; background: #8b949e; box-shadow: 0 0 0 3px color-mix(in srgb, #8b949e 18%, transparent); }
.lumi-ai-state.ready { background: #2ea043; box-shadow: 0 0 0 3px color-mix(in srgb, #2ea043 18%, transparent); }
.lumi-ai-state.error { background: #d73a49; box-shadow: 0 0 0 3px color-mix(in srgb, #d73a49 18%, transparent); }
.lumi-ai-panel { position: fixed; z-index: 80; left: calc(var(--sidebar-width, 260px) + 14px); right: 14px; top: var(--lumi-ai-top, calc(100vh - 16.666vh - 14px)); height: max(180px, 16.666vh); max-height: calc(100vh - 16px); display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 18px 55px rgba(0,0,0,.22); opacity: 0; transform: translateY(100%); pointer-events: none; transition: transform 0.5s ease-in-out, height 0.5s ease-in-out, opacity 0.5s ease-in-out; }
.lumi-ai-panel.open { opacity: 1; transform: translateY(0); pointer-events: auto; }
.lumi-ai-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--surface-2); }
.lumi-ai-header div { display: flex; align-items: baseline; gap: 10px; }
.lumi-ai-header span { color: var(--ink-soft); font-size: 12px; }
.lumi-ai-close { border: 0; background: transparent; color: var(--ink-soft); font-size: 22px; cursor: pointer; }
.lumi-ai-messages { min-height: 0; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.lumi-ai-message { max-width: min(760px, 86%); padding: 8px 10px; border-radius: 7px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.4; }
.lumi-ai-message.assistant { align-self: flex-start; background: var(--surface-2); border: 1px solid var(--border); }
.lumi-ai-message.user { align-self: flex-end; background: var(--sea); color: white; }
.lumi-ai-message.error { border-color: var(--rose); color: var(--rose); }
.lumi-ai-confirm { display: flex; gap: 8px; margin-top: 8px; }
.lumi-ai-confirm button { padding: 5px 9px; border-radius: 5px; border: 1px solid var(--border); cursor: pointer; }
.lumi-ai-compose { display: grid; grid-template-columns: 1fr 40px; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); }
.lumi-ai-compose textarea { width: 100%; min-height: 40px; max-height: 96px; resize: vertical; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--ink); padding: 8px; }
.lumi-ai-compose button { display: grid; place-items: center; border: 0; border-radius: 6px; background: var(--sea); color: white; cursor: pointer; }
.lumi-ai-compose svg { width: 19px; height: 19px; }
@media (max-width: 800px) { .lumi-ai-panel { left: 10px; right: 10px; } }
body.sidebar-collapsed .lumi-ai-pill { justify-content: center; padding: 8px; }
body.sidebar-collapsed .lumi-ai-pill-label,
body.sidebar-collapsed .lumi-ai-state { display: none; }

View File

@ -0,0 +1,122 @@
(() => {
const root = document.querySelector("[data-lumi-ai]");
if (!root) return;
const endpoint = root.dataset.endpoint;
const panel = root.querySelector("[data-lumi-ai-panel]");
const toggle = root.querySelector("[data-lumi-ai-toggle]");
const close = root.querySelector("[data-lumi-ai-close]");
const state = root.querySelector("[data-lumi-ai-state]");
const status = root.querySelector("[data-lumi-ai-status]");
const messages = root.querySelector("[data-lumi-ai-messages]");
const form = root.querySelector("[data-lumi-ai-form]");
const input = form.querySelector("textarea");
const setOpen = (open) => {
if (open) positionPanel();
panel.classList.toggle("open", open);
panel.setAttribute("aria-hidden", String(!open));
toggle.setAttribute("aria-expanded", String(open));
if (open) input.focus();
};
const positionPanel = () => {
const viewportHeight = window.innerHeight;
const desiredHeight = Math.max(180, viewportHeight / 6);
const footerRect = document.querySelector(".site-footer")?.getBoundingClientRect();
const bottomLimit = footerRect && footerRect.top < viewportHeight && footerRect.bottom > 0
? Math.max(8, footerRect.top - 8)
: viewportHeight - 8;
const anchor = toggle.getBoundingClientRect();
let top = anchor.bottom + 8;
if (top + desiredHeight > bottomLimit) {
const overflow = top + desiredHeight - bottomLimit;
top -= overflow / 2;
if (top + desiredHeight > bottomLimit) top = bottomLimit - desiredHeight;
}
top = Math.max(8, Math.min(top, viewportHeight - desiredHeight - 8));
panel.style.setProperty("--lumi-ai-top", `${top}px`);
panel.style.height = `${Math.min(desiredHeight, viewportHeight - top - 8)}px`;
};
const addMessage = (text, type, confirmation) => {
const item = document.createElement("div");
item.className = `lumi-ai-message ${type}`;
item.textContent = text;
if (confirmation) {
const actions = document.createElement("div");
actions.className = "lumi-ai-confirm";
for (const [label, route] of [["Confirm", "confirm"], ["Cancel", "cancel"]]) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.addEventListener("click", async () => {
button.disabled = true;
try {
const response = await fetch(`${endpoint}/assistant/${route}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: confirmation.id })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Action failed.");
addMessage(route === "confirm" ? "Confirmed and completed." : "Cancelled.", "assistant");
actions.remove();
} catch (error) { addMessage(error.message, "assistant error"); }
});
actions.append(button);
}
item.append(actions);
}
messages.append(item);
messages.scrollTop = messages.scrollHeight;
};
const refreshStatus = async () => {
try {
const response = await fetch(`${endpoint}/api/status`);
const data = await response.json();
const ready = response.ok && data.enabled && data.runtime?.healthy;
state.className = `lumi-ai-state ${ready ? "ready" : "error"}`;
if (ready) status.textContent = `${data.model_id} ready`;
else if (!data.enabled) status.textContent = "Disabled by administrator";
else if (!data.runtime?.runtime_installed) status.textContent = "Runtime not installed";
else if (!data.runtime?.model_downloaded) status.textContent = "Selected model missing";
else if (data.runtime?.state === "error") status.textContent = "Runtime error";
else status.textContent = "Runtime stopped";
} catch {
state.className = "lumi-ai-state error";
status.textContent = "Status unavailable";
}
};
toggle.addEventListener("click", () => setOpen(!panel.classList.contains("open")));
close.addEventListener("click", () => setOpen(false));
form.addEventListener("submit", async (event) => {
event.preventDefault();
const message = input.value.trim();
if (!message) return;
addMessage(message, "user");
input.value = "";
input.disabled = true;
try {
const response = await fetch(`${endpoint}/assistant/message`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Request failed.");
addMessage(data.text, "assistant", data.confirmation);
} catch (error) {
addMessage(error.message, "assistant error");
} finally {
input.disabled = false;
input.focus();
}
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
form.requestSubmit();
}
});
window.addEventListener("resize", () => {
if (panel.classList.contains("open")) positionPanel();
});
refreshStatus();
window.setInterval(refreshStatus, 15000);
})();

View File

@ -0,0 +1,42 @@
.ai-titlebar, .ai-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.ai-titlebar { margin-bottom: 14px; }
.ai-titlebar h1, .ai-section-heading h2 { margin: 0; }
.ai-titlebar p, .ai-section-heading p { margin: 4px 0 0; color: var(--ink-soft); }
.ai-runtime-badge { display: flex; align-items: center; gap: 8px; font-weight: 700; }
.ai-runtime-badge span { width: 9px; height: 9px; border-radius: 50%; background: #d73a49; }
.ai-runtime-badge.ready span { background: #2ea043; }
.ai-tabs { position: sticky; top: 0; z-index: 4; display: flex; gap: 4px; overflow-x: auto; padding: 8px 0; background: var(--bg-1); border-bottom: 1px solid var(--border); }
.ai-tabs a { padding: 7px 10px; border-radius: 5px; color: var(--ink-soft); text-decoration: none; font-weight: 700; white-space: nowrap; }
.ai-tabs a:hover { background: var(--surface-2); color: var(--ink); }
.ai-band { padding: 24px 0; border-bottom: 1px solid var(--border); scroll-margin-top: 60px; }
.ai-stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; margin-top: 16px; overflow: hidden; border: 1px solid var(--border); border-radius: 7px; background: var(--border); }
.ai-stat-grid div { min-width: 0; padding: 14px; background: var(--card); }
.ai-stat-grid span { display: block; color: var(--ink-soft); font-size: 12px; }
.ai-stat-grid strong { display: block; margin-top: 4px; overflow-wrap: anywhere; }
.ai-stat-grid.compact div { padding: 10px 12px; }
.ai-model-list { margin-top: 14px; border-top: 1px solid var(--border); }
.ai-model-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--border); }
.ai-model-main span { display: block; margin-top: 3px; color: var(--ink-soft); font-size: 12px; overflow-wrap: anywhere; }
.ai-tag { padding: 4px 7px; border: 1px solid var(--border); border-radius: 5px; color: var(--ink-soft); font-size: 12px; }
.ai-tag.installed { border-color: #2ea043; color: #2ea043; }
.ai-tag.warning { border-color: var(--sun); color: var(--sun); }
.ai-inline-form, .ai-inline-form label, .ai-actions { display: flex; align-items: center; gap: 8px; }
.ai-runtime-grid { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(280px, 1fr); gap: 28px; margin-top: 16px; }
.ai-diagnostic { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; }
.ai-diagnostic span { color: var(--ink-soft); }
.ai-diagnostic strong { overflow-wrap: anywhere; }
.ai-download-status { margin-top: 12px; padding: 9px; border: 1px solid var(--border); border-radius: 6px; }
.ai-form { margin-top: 16px; }
.ai-fieldset { display: flex; flex-wrap: wrap; gap: 10px 20px; margin: 0; padding: 12px; border: 1px solid var(--border); border-radius: 7px; }
.ai-fieldset legend { padding: 0 5px; font-weight: 700; }
.ai-fieldset label { display: flex; align-items: center; gap: 6px; }
.ai-test-output { max-height: 420px; overflow: auto; margin-top: 14px; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
.ai-remediation { margin: 14px 0; padding-left: 24px; }
.ai-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; }
@media (max-width: 800px) {
.ai-titlebar, .ai-section-heading { align-items: flex-start; flex-direction: column; }
.ai-stat-grid { grid-template-columns: 1fr 1fr; }
.ai-model-row { grid-template-columns: 1fr auto; }
.ai-inline-form { grid-column: 1 / -1; }
.ai-runtime-grid { grid-template-columns: 1fr; }
}

View File

@ -0,0 +1,71 @@
(() => {
const actions = document.querySelector("[data-ai-runtime-actions]");
const state = document.querySelector("[data-runtime-state]");
const downloadStatus = document.querySelector("[data-download-status]");
const testForm = document.querySelector("[data-ai-test-form]");
const testOutput = document.querySelector("[data-ai-test-output]");
if (actions) {
actions.addEventListener("click", async (event) => {
const button = event.target.closest("[data-runtime-action]");
if (!button) return;
button.disabled = true;
try {
const response = await fetch(`/plugins/lumi_ai/runtime/${button.dataset.runtimeAction}`, { method: "POST" });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Runtime action failed.");
if (data.state) state.textContent = data.state;
if (["self-test", "verify-runtime", "verify-model"].includes(button.dataset.runtimeAction)) {
const labels = { "self-test": "Runtime self-test passed.", "verify-runtime": "Runtime installation verified.", "verify-model": "Model verification passed." };
window.alert(labels[button.dataset.runtimeAction]);
}
} catch (error) {
window.alert(error.message);
} finally {
button.disabled = false;
}
});
}
const pollDownloads = async () => {
if (!downloadStatus) return;
try {
const response = await fetch("/plugins/lumi_ai/api/downloads");
if (!response.ok) return;
const jobs = Object.values(await response.json());
const active = jobs.filter((job) => !["complete", "error"].includes(job.state));
if (!jobs.length) return;
downloadStatus.hidden = false;
downloadStatus.textContent = jobs.map((job) => {
const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0;
return `${job.id}: ${job.state}${job.total ? ` ${percent}%` : ""}${job.error ? ` - ${job.error}` : ""}`;
}).join(" | ");
if (active.length) window.setTimeout(pollDownloads, 1000);
} catch {}
};
if (testForm && testOutput) {
testForm.addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(testForm);
testOutput.hidden = false;
testOutput.textContent = "Running...";
try {
const response = await fetch("/plugins/lumi_ai/assistant/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
role: form.get("role"),
message: form.get("message"),
show_raw_prompt: form.get("show_raw_prompt") === "on",
show_raw_output: form.get("show_raw_output") === "on"
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Test failed.");
if (form.get("show_raw_output") !== "on") delete data.raw_response;
testOutput.textContent = JSON.stringify(data, null, 2);
} catch (error) {
testOutput.textContent = error.message;
}
});
}
pollDownloads();
})();

View File

@ -0,0 +1,36 @@
{
"version": "b9592",
"source": "https://github.com/ggml-org/llama.cpp/releases/tag/b9592",
"targets": {
"win32-x64": {
"filename": "llama-b9592-bin-win-cpu-x64.zip",
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-win-cpu-x64.zip",
"sha256": "2b3d4e167be290bf6266d405746da52813c19a58fe02dc88a97ab75c4c021428",
"size": 16722005
},
"linux-x64": {
"filename": "llama-b9592-bin-ubuntu-x64.tar.gz",
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-ubuntu-x64.tar.gz",
"sha256": "ce07450c3463473721843772fbbe4ea6c1691e097e4991e93239a1dda0dfa440",
"size": 15408227
},
"linux-arm64": {
"filename": "llama-b9592-bin-ubuntu-arm64.tar.gz",
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-ubuntu-arm64.tar.gz",
"sha256": "345ca9cac08f237496adcf476b72c1e28053a0636971513d57de513a67df3754",
"size": 12424701
},
"darwin-arm64": {
"filename": "llama-b9592-bin-macos-arm64.tar.gz",
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-macos-arm64.tar.gz",
"sha256": "e395d9f746bc1b04e3e019295e76a5158de3ecc837a2f08b7fe6e76ec5b42729",
"size": 10548003
},
"darwin-x64": {
"filename": "llama-b9592-bin-macos-x64.tar.gz",
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-macos-x64.tar.gz",
"sha256": "6b5ba1d89560f51d68d5845ca7a76b5093b1f9b4908882229135e9c186262121",
"size": 10790342
}
}
}

View File

@ -0,0 +1 @@
The user is a Lumi administrator. Administrative explanations are allowed. Destructive or sensitive actions still require a registered workflow and explicit confirmation.

View File

@ -0,0 +1 @@
The user is a Lumi moderator. Do not expose administrator-only settings, secrets, logs, or tools.

View File

@ -0,0 +1 @@
The user is a regular Lumi user. Only provide public or self-service information and tools.

View File

@ -0,0 +1,5 @@
Operate only within Lumi, its WebUI, installed plugins, community systems, streams, and videos.
Never claim an action succeeded unless a registered tool returned success.
Never invent settings, routes, user data, balances, sanctions, commands, or plugin capabilities.
For an unavailable fact or capability, say what is unavailable and direct the user to the relevant Lumi page.
Tool calls must be a single JSON object: {"type":"tool_call","tool":"tool_id","arguments":{}}.

View File

@ -0,0 +1,157 @@
const assert = require("assert");
const fs = require("fs");
const { ensureDataDirs, PLUGIN_ROOT, PLUGIN_DATA, resolveData } = require("../backend/paths");
const { canUse } = require("../backend/permissions");
const { ToolRegistry } = require("../backend/tool_router");
const { RequestQueue } = require("../backend/queue_manager");
const { RuntimeManager, runCaptured } = require("../backend/runtime_manager");
const { getRuntimeState } = require("../backend/config_manager");
const { AiProvider } = require("../backend/ai_provider");
const { shouldAutoResume } = require("../index");
const { normalizeExitCode, classifyLaunchError } = require("../backend/error_codes");
const { redact } = require("../backend/diagnostics");
const { validateArchivePath, classifyError } = require("../backend/downloader");
const { EventEmitter } = require("events");
async function run() {
ensureDataDirs();
assert(PLUGIN_DATA.startsWith(PLUGIN_ROOT));
assert(resolveData("models", "model.gguf").startsWith(PLUGIN_ROOT));
assert.throws(() => resolveData("..", "..", "outside"), /escapes/);
assert.throws(() => validateArchivePath("../../outside.exe"), /traversal/);
assert.doesNotThrow(() => validateArchivePath("bin/llama-server.exe"));
assert.equal(classifyError(new Error("source unavailable (404)")).category, "http_404");
assert.equal(classifyError(new Error("hash mismatch")).category, "hash_mismatch");
const accessViolation = normalizeExitCode(-1073741819, null, "win32");
assert.equal(accessViolation.unsigned_exit_code, 3221225477);
assert.equal(accessViolation.hex_exit_code, "0xC0000005");
assert.equal(accessViolation.code, "STATUS_ACCESS_VIOLATION");
assert.equal(normalizeExitCode(139, null, "linux").code, "SIGSEGV");
assert.equal(classifyLaunchError({ code: "EACCES" }, "linux").category, "permission_denied");
assert.deepEqual(redact({ token: "secret", nested: { password: "secret", value: "ok" } }), { token: "[REDACTED]", nested: { password: "[REDACTED]", value: "ok" } });
const captured = await runCaptured(process.execPath, ["-e", "console.log('llama server usage')"], process.cwd(), 3000);
assert.equal(captured.code, 0);
assert.match(captured.stdout, /llama server usage/);
const config = { assistant_visibility: { admins: true, mods: false, users: true } };
assert.equal(canUse({ id: "a", isAdmin: true }, config), true);
assert.equal(canUse({ id: "m", isMod: true }, config), false);
assert.equal(canUse({ id: "u" }, config), true);
assert.equal(canUse(null, config), false);
const audit = [];
const calls = [];
const registry = new ToolRegistry((entry) => audit.push(entry));
registry.register({
tool_id: "test.action",
display_name: "Test action",
description: "Runs a test workflow.",
owning_plugin: "test",
required_role: "user",
required_permission: "test.self",
permission_check: ({ user }) => user.id === "user-1",
schema: { amount: "integer", recipient: "string" },
confirmation_required: true,
risk_level: "sensitive",
audit_category: "test",
workflow_handler: async (input) => { calls.push(input); return { ok: true }; }
});
assert.throws(() => registry.prepare({ tool: "test.action", args: { amount: "bad", recipient: "x" }, user: { id: "user-1" }, role: "user", sessionId: "s1" }), /integer/);
assert.throws(() => registry.prepare({ tool: "test.action", args: { amount: 1, recipient: "x" }, user: { id: "other" }, role: "user", sessionId: "s1" }), /permission/);
const prepared = registry.prepare({ tool: "test.action", args: { amount: 2, recipient: "x" }, user: { id: "user-1" }, role: "user", sessionId: "s1" });
await assert.rejects(() => registry.confirm({ id: prepared.confirmation.id, user: { id: "user-1" }, sessionId: "wrong" }), /invalid or expired/);
const expiring = registry.prepare({ tool: "test.action", args: { amount: 2, recipient: "x" }, user: { id: "user-1" }, role: "user", sessionId: "s1" });
registry.confirmations.get(expiring.confirmation.id).expiresAt = Date.now() - 1;
await assert.rejects(() => registry.confirm({ id: expiring.confirmation.id, user: { id: "user-1" }, sessionId: "s1" }), /invalid or expired/);
const valid = registry.prepare({ tool: "test.action", args: { amount: 3, recipient: "x" }, user: { id: "user-1" }, role: "user", sessionId: "s1" });
await registry.confirm({ id: valid.confirmation.id, user: { id: "user-1" }, sessionId: "s1" });
assert.equal(calls[0].user.id, "user-1");
assert.equal(calls[0].initiated_via_ai, true);
assert.equal(audit[0].tool_executed, true);
const queueConfig = { concurrency: 1, max_queue_length: 1, per_user_requests_per_minute: 20 };
const queue = new RequestQueue(() => queueConfig);
let release;
const blocked = new Promise((resolve) => { release = resolve; });
const first = queue.run("u1", "user", () => blocked);
const second = queue.run("u2", "user", async () => "second");
await assert.rejects(() => queue.run("u3", "user", async () => "third"), /busy/);
release("first");
assert.equal(await first, "first");
assert.equal(await second, "second");
const fakeMetrics = { record() {} };
const provider = new AiProvider({
getConfig: () => ({ instructions: { out_of_scope_response: "OUT" } }),
runtime: { infer: async () => { throw new Error("must not run"); } },
queue,
tools: registry,
metrics: fakeMetrics,
getContext: () => []
});
const refused = await provider.generate({ message: "What is the capital of France?", user: { id: "u1" }, sessionId: "s1" });
assert.equal(refused.refusal_reason, "out_of_scope");
const routed = await provider.generate({ message: "Where can I find Twitch configuration?", user: { id: "u1" }, sessionId: "s1" });
assert.equal(routed.success, true);
assert.match(routed.text, /twitch-wizard/);
const ambiguousProvider = new AiProvider({
getConfig: () => ({ selected_model_id: "test", request_timeout_ms: 1000, logging: {}, instructions: { identity: "Lumi", style: "Brief", allowed_topics: "Lumi", maximum_answer_length: 700, out_of_scope_response: "OUT" } }),
runtime: { infer: async () => ({ choices: [{ message: { content: "Open the relevant Lumi settings page." }, finish_reason: "stop" }] }) },
queue,
tools: registry,
metrics: fakeMetrics,
getContext: () => []
});
const ambiguous = await ambiguousProvider.generate({ message: "How do I change this option?", user: { id: "u1" }, sessionId: "s1" });
assert.equal(ambiguous.success, true);
let diagnosticMessages;
const testProvider = new AiProvider({
getConfig: () => ({ selected_model_id: "test", instructions: { maximum_answer_length: 700 } }),
runtime: { infer: async (messages) => { diagnosticMessages = messages; return { choices: [{ message: { content: "There are 3 Rs." }, finish_reason: "stop" }] }; } },
queue,
tools: registry,
metrics: fakeMetrics,
getContext: () => []
});
const diagnosticTest = await testProvider.test({ message: 'How many "R"s are in Strawberry?', user: { id: "u1" }, includeRaw: true });
assert.equal(diagnosticMessages[1].content, 'How many "R"s are in Strawberry?');
assert.equal(diagnosticTest.text, "There are 3 Rs.");
assert.match(diagnosticTest.raw_prompt, /local model diagnostic/);
const statePath = resolveData("config", "runtime_state.json");
const originalState = fs.readFileSync(statePath, "utf8");
try {
const runtime = new RuntimeManager({ getConfig: () => ({}), getModel: () => null, runtimeManifest: {} });
runtime.child = fakeChild();
await runtime.stop({ manual: false, reason: "bot_shutdown" });
assert.equal(getRuntimeState().desired_state, "running");
assert.equal(shouldAutoResume({ enabled: true }, getRuntimeState()), true);
runtime.child = fakeChild();
await runtime.stop({ manual: true, reason: "admin_stop" });
assert.equal(getRuntimeState().desired_state, "stopped");
assert.equal(shouldAutoResume({ enabled: true }, getRuntimeState()), false);
assert.equal(shouldAutoResume({ enabled: true }, { desired_state: "running", last_manual_stop: false, last_crashed: true }), false);
} finally {
fs.writeFileSync(statePath, originalState);
}
console.log("Lumi AI verification passed.");
}
function fakeChild() {
const child = new EventEmitter();
child.killed = false;
child.exitCode = null;
child.kill = function kill() {
this.killed = true;
this.exitCode = 0;
this.emit("exit", 0, null);
};
return child;
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -0,0 +1,25 @@
<div class="lumi-ai-shell" data-lumi-ai data-endpoint="<%= endpoint %>">
<button class="lumi-ai-pill" type="button" data-lumi-ai-toggle aria-expanded="false" aria-controls="lumi-ai-panel">
<span class="lumi-ai-mark" aria-hidden="true">AI</span>
<span class="lumi-ai-pill-label">AI Assistant</span>
<span class="lumi-ai-state" data-lumi-ai-state title="Checking runtime"></span>
</button>
<section class="lumi-ai-panel" id="lumi-ai-panel" data-lumi-ai-panel aria-hidden="true" aria-label="Lumi AI Assistant">
<header class="lumi-ai-header">
<div>
<strong>Lumi AI</strong>
<span data-lumi-ai-status>Checking local runtime</span>
</div>
<button type="button" class="lumi-ai-close" data-lumi-ai-close aria-label="Close AI Assistant" title="Close">&times;</button>
</header>
<div class="lumi-ai-messages" data-lumi-ai-messages aria-live="polite">
<div class="lumi-ai-message assistant">Ask about Lumi, plugins, settings, streams, or community systems.</div>
</div>
<form class="lumi-ai-compose" data-lumi-ai-form>
<textarea name="message" rows="2" maxlength="6000" placeholder="Ask Lumi AI" aria-label="Message Lumi AI" required></textarea>
<button type="submit" aria-label="Send message" title="Send">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4l17 8-17 8 3-8zM7 12h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</button>
</form>
</section>
</div>

View File

@ -0,0 +1,219 @@
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
<link rel="stylesheet" href="/plugins/lumi_ai/assets/settings.css?v=<%= assetVersion %>" />
<section class="ai-titlebar">
<div>
<h1>Lumi AI</h1>
<p>Managed local inference, assistant access, and guarded plugin tools.</p>
</div>
<div class="ai-runtime-badge <%= runtimeStatus.healthy ? 'ready' : 'offline' %>">
<span></span><%= runtimeStatus.healthy ? "Runtime ready" : "Runtime offline" %>
</div>
</section>
<nav class="ai-tabs" aria-label="Lumi AI settings">
<a href="#overview">Overview</a>
<a href="#models">Models</a>
<a href="#runtime">Runtime</a>
<a href="#assistant">Assistant</a>
<a href="#metrics">Metrics</a>
</nav>
<section class="ai-band" id="overview">
<div class="ai-section-heading">
<div><h2>Overview</h2><p>Current installation and host capacity.</p></div>
</div>
<div class="ai-stat-grid">
<div><span>Provider</span><strong>llama.cpp</strong></div>
<div><span>Selected model</span><strong><%= models.find((model) => model.id === config.selected_model_id)?.label || config.selected_model_id %></strong></div>
<div><span>RAM</span><strong><%= Math.round(hardware.total_ram_mb / 1024) %> GB</strong></div>
<div><span>Free disk</span><strong><%= formatBytes(hardware.free_disk_mb * 1048576) %></strong></div>
<div><span>CPU threads</span><strong><%= hardware.cpu_threads %></strong></div>
<div><span>GPU</span><strong><%= hardware.gpu.present ? hardware.gpu.name : "Not detected" %></strong></div>
</div>
</section>
<section class="ai-band" id="models">
<div class="ai-section-heading">
<div><h2>Models</h2><p>Pinned GGUF files downloaded directly from Hugging Face and verified by SHA-256.</p></div>
</div>
<div class="ai-model-list">
<% models.forEach((model) => { %>
<article class="ai-model-row">
<div class="ai-model-main">
<strong><%= model.label %></strong>
<span><%= formatBytes(model.size) %> &middot; <%= model.ram_gb %> GB recommended RAM &middot; <%= model.repo %></span>
</div>
<span class="ai-tag <%= model.downloaded ? 'installed' : model.compatible ? '' : 'warning' %>">
<%= model.downloaded ? "Installed" : model.compatible ? "Available" : "Exceeds host" %>
</span>
<% if (!model.downloaded) { %>
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" class="ai-inline-form">
<% if (!model.compatible) { %>
<label title="Allow download despite detected capacity"><input type="checkbox" name="override_compatibility" /> Override</label>
<% } %>
<button class="button subtle" type="submit">Download</button>
</form>
<% } %>
</article>
<% }) %>
</div>
</section>
<section class="ai-band" id="runtime">
<div class="ai-section-heading">
<div><h2>Runtime</h2><p>Official llama.cpp release, bound to localhost and stored inside this plugin.</p></div>
<div class="ai-actions" data-ai-runtime-actions>
<button class="button" type="button" data-runtime-action="start">Start</button>
<button class="button subtle" type="button" data-runtime-action="self-test">Run self-test</button>
<button class="button subtle" type="button" data-runtime-action="verify-runtime">Verify runtime</button>
<button class="button subtle" type="button" data-runtime-action="verify-model">Verify model</button>
<button class="button subtle" type="button" data-runtime-action="restart">Restart</button>
<button class="button danger" type="button" data-runtime-action="stop">Stop</button>
</div>
</div>
<div class="ai-runtime-grid">
<div class="ai-diagnostic">
<span>Installed</span><strong><%= runtimeStatus.runtime_installed ? "Yes" : "No" %></strong>
<span>Process</span><strong data-runtime-state><%= runtimeStatus.state %></strong>
<span>Health</span><strong><%= runtimeStatus.healthy ? "Healthy" : "Unavailable" %></strong>
<span>PID</span><strong><%= runtimeStatus.pid || "None" %></strong>
<span>Last stop</span><strong><%= runtimeState.last_stop_reason %></strong>
<span>Platform</span><strong><%= hardware.platform %>-<%= hardware.architecture %></strong>
<span>Self-test</span><strong><%= runtimeStatus.last_self_test?.success ? "Passed" : runtimeStatus.last_self_test ? "Failed" : "Not run" %></strong>
<span>Runtime folder</span><strong><%= formatBytes(runtimeFolderSize) %></strong>
<span>Runtime archive</span><strong><%= runtimeTarget ? formatBytes(runtimeTarget.size) : "Unavailable" %></strong>
<span>Model installed</span><strong><%= formatBytes(modelFileSize) %></strong>
<span>Model download</span><strong><%= formatBytes(models.find((model) => model.id === config.selected_model_id)?.size || 0) %></strong>
</div>
<div>
<% if (runtimeTarget) { %>
<p><strong>Managed release <%= runtimeManifest?.version || "b9592" %></strong></p>
<p class="hint"><%= runtimeTarget.filename %> &middot; <%= formatBytes(runtimeTarget.size) %></p>
<form method="post" action="/plugins/lumi_ai/download/runtime">
<button class="button subtle" type="submit"><%= runtimeStatus.runtime_installed ? "Reinstall runtime" : "Download runtime" %></button>
</form>
<% } else { %>
<div class="callout">No managed runtime build is available for this OS and architecture.</div>
<% } %>
<% if (runtimeStatus.last_error) { %><div class="callout danger"><%= runtimeStatus.last_error %></div><% } %>
</div>
</div>
<div class="ai-download-status" data-download-status hidden></div>
</section>
<section class="ai-band" id="runtime-diagnostics">
<div class="ai-section-heading">
<div><h2>Runtime diagnostics</h2><p>Latest plugin-local runtime failure and remediation details.</p></div>
<a class="button subtle" href="/plugins/lumi_ai/diagnostics/download">Download diagnostics</a>
</div>
<% if (latestDiagnostic) { %>
<div class="callout danger">
<strong><%= latestDiagnostic.code %>: <%= latestDiagnostic.message %></strong>
<p><%= latestDiagnostic.category %> / <%= latestDiagnostic.severity %></p>
</div>
<% if (latestDiagnostic.remediation_steps?.length) { %>
<ol class="ai-remediation"><% latestDiagnostic.remediation_steps.forEach((step) => { %><li><%= step %></li><% }) %></ol>
<% } %>
<details class="ai-raw-diagnostic">
<summary>Raw diagnostic details</summary>
<pre><%= JSON.stringify(latestDiagnostic, null, 2) %></pre>
</details>
<% } else { %>
<p class="hint">No runtime diagnostic has been recorded.</p>
<% } %>
<% if (hardware.network_path_warning) { %>
<div class="callout">The plugin path may be a mapped or network-like location. A local disk path is more reliable for native runtime DLL loading.</div>
<% } %>
<% if (hardware.long_path_warning) { %><div class="callout">The plugin path is unusually long for Windows native loading. Consider a shorter local installation path.</div><% } %>
</section>
<form method="post" action="/plugins/lumi_ai/settings">
<section class="ai-band" id="assistant">
<div class="ai-section-heading">
<div><h2>Assistant</h2><p>Configuration remains admin-only. Visibility controls only the sidebar assistant.</p></div>
<button class="button" type="submit">Save settings</button>
</div>
<div class="form-grid ai-form">
<div class="field">
<label>AI enabled</label>
<label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= config.enabled ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Available</span></label>
</div>
<div class="field">
<label for="selected-model">Selected model</label>
<select id="selected-model" name="selected_model_id"><% models.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.selected_model_id ? "selected" : "" %>><%= model.label %></option><% }) %></select>
</div>
<div class="field"><label>Context size</label><input type="number" name="context_size" min="512" max="131072" value="<%= config.context_size %>" /></div>
<div class="field"><label>CPU threads (0 = auto)</label><input type="number" name="threads" min="0" max="256" value="<%= config.threads %>" /></div>
<div class="field"><label>Concurrent requests</label><input type="number" name="concurrency" min="1" max="8" value="<%= config.concurrency %>" /></div>
<div class="field"><label>Maximum queue</label><input type="number" name="max_queue_length" min="1" max="100" value="<%= config.max_queue_length %>" /></div>
<div class="field"><label>Timeout (ms)</label><input type="number" name="request_timeout_ms" min="5000" max="600000" value="<%= config.request_timeout_ms %>" /></div>
<div class="field"><label>Requests per user/minute</label><input type="number" name="per_user_requests_per_minute" min="1" max="120" value="<%= config.per_user_requests_per_minute %>" /></div>
<div class="field"><label>Admin rate-limit bypass</label><label class="switch"><input class="switch-input" type="checkbox" name="admin_bypass_rate_limit" <%= config.admin_bypass_rate_limit ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Bypass</span></label></div>
<fieldset class="field full ai-fieldset">
<legend>Sidebar visibility</legend>
<label><input type="checkbox" name="visibility_admins" <%= config.assistant_visibility.admins ? "checked" : "" %> /> Administrators</label>
<label><input type="checkbox" name="visibility_mods" <%= config.assistant_visibility.mods ? "checked" : "" %> /> Moderators</label>
<label><input type="checkbox" name="visibility_users" <%= config.assistant_visibility.users ? "checked" : "" %> /> Users</label>
</fieldset>
<div class="field full"><label>Identity</label><textarea name="identity" rows="2"><%= config.instructions.identity %></textarea></div>
<div class="field full"><label>Response style</label><textarea name="style" rows="2"><%= config.instructions.style %></textarea></div>
<div class="field full"><label>Allowed topics</label><textarea name="allowed_topics" rows="2"><%= config.instructions.allowed_topics %></textarea></div>
<div class="field full"><label>Out-of-scope response</label><textarea name="out_of_scope_response" rows="2"><%= config.instructions.out_of_scope_response %></textarea></div>
<div class="field"><label>Maximum answer length</label><input type="number" name="maximum_answer_length" min="100" max="4000" value="<%= config.instructions.maximum_answer_length %>" /></div>
<div class="field"><label>Roleplay intensity (0-10)</label><input type="number" name="roleplay_intensity" min="0" max="10" value="<%= config.instructions.roleplay_intensity || 0 %>" /></div>
<div class="field full"><label>Community tone</label><textarea name="community_tone" rows="2"><%= config.instructions.community_tone %></textarea></div>
<div class="field full"><label>Admin custom instructions</label><textarea name="admin_custom" rows="4"><%= config.instructions.admin_custom %></textarea><span class="hint">Hard scope, role, tool, and confirmation rules cannot be overridden.</span></div>
<fieldset class="field full ai-fieldset">
<legend>Logging</legend>
<% [["log_prompts","Prompts"],["log_responses","Responses"],["log_tool_calls","Tool calls"],["log_metrics","Metrics"],["log_internal_audit","Internal audit"]].forEach(([key,label]) => { %>
<label><input type="checkbox" name="<%= key %>" <%= config.logging[key] ? "checked" : "" %> /> <%= label %></label>
<% }) %>
</fieldset>
</div>
</section>
</form>
<section class="ai-band" id="test-console">
<div class="ai-section-heading"><div><h2>Test console</h2><p>Run a request as a simulated role without changing the logged-in actor.</p></div></div>
<form class="form-grid ai-form" data-ai-test-form>
<div class="field"><label>Simulated role</label><select name="role"><option value="admin">Admin</option><option value="mod">Moderator</option><option value="user">User</option></select></div>
<div class="field full"><label>Message</label><textarea name="message" rows="3" required>Where can I find Twitch configuration?</textarea></div>
<div class="field full ai-fieldset">
<label><input type="checkbox" name="show_raw_prompt" /> Show assembled prompt</label>
<label><input type="checkbox" name="show_raw_output" /> Show raw model response</label>
</div>
<div class="field full"><button class="button" type="submit">Run test</button></div>
</form>
<pre class="ai-test-output" data-ai-test-output hidden></pre>
</section>
<section class="ai-band" id="metrics">
<div class="ai-section-heading"><div><h2>Metrics</h2><p>Plugin-local operational counters and recent requests.</p></div></div>
<div class="ai-stat-grid compact">
<div><span>Requests</span><strong><%= metrics.total_requests %></strong></div>
<div><span>Successful</span><strong><%= metrics.successful %></strong></div>
<div><span>Failed</span><strong><%= metrics.failed %></strong></div>
<div><span>Refused</span><strong><%= metrics.refusals %></strong></div>
<div><span>Average</span><strong><%= formatDuration(metrics.average_response_ms) %></strong></div>
<div><span>Median</span><strong><%= formatDuration(metrics.median_response_ms) %></strong></div>
</div>
<div class="table-wrap">
<table class="table"><thead><tr><th>Time</th><th>Kind</th><th>Status</th><th>Role</th><th>Duration</th></tr></thead><tbody>
<% history.forEach((entry) => { %><tr><td><%= entry.timestamp %></td><td><%= entry.kind %></td><td><%= entry.status %></td><td><%= entry.role || "-" %></td><td><%= formatDuration(entry.duration_ms) %></td></tr><% }) %>
<% if (!history.length) { %><tr><td colspan="5">No requests recorded.</td></tr><% } %>
</tbody></table>
</div>
<% if (logFiles.length) { %><p class="hint">Runtime logs: <%= logFiles.map((file) => `${file.name} (${formatBytes(file.size)})`).join(", ") %></p><% } %>
</section>
<section class="ai-band">
<div class="ai-section-heading"><div><h2>Privacy and troubleshooting</h2><p>Local inference remains on this host.</p></div></div>
<div class="callout">
Models are downloaded from pinned Hugging Face revisions. The managed runtime is downloaded from the official llama.cpp release and verified by SHA-256. No cloud inference is used. Prompt and response logging are off by default.
</div>
<p class="hint">If startup fails, confirm that the runtime and selected model show as installed, the plugin directory is writable, and enough RAM and disk are available. Runtime logs are stored under <code>plugins/lumi_ai/data/logs/</code>.</p>
</section>
<script src="/plugins/lumi_ai/assets/settings.js?v=<%= assetVersion %>" defer></script>
<%- include("../../../src/web/views/partials/layout-bottom") %>

View File

@ -0,0 +1,32 @@
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { replacePluginDirectory } = require("../src/services/update-manager");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-update-test-"));
try {
const source = path.join(root, "source");
const target = path.join(root, "target");
fs.mkdirSync(path.join(source, "data", "config"), { recursive: true });
fs.mkdirSync(path.join(target, "data", "models"), { recursive: true });
fs.writeFileSync(path.join(source, "plugin.json"), '{"id":"test"}');
fs.writeFileSync(path.join(source, "index.js"), "module.exports = 'new';");
fs.writeFileSync(path.join(source, "data", "config", "default.json"), '{"default":true}');
fs.writeFileSync(path.join(target, "index.js"), "module.exports = 'old';");
fs.writeFileSync(path.join(target, "stale.js"), "stale");
const model = path.join(target, "data", "models", "large.gguf");
const descriptor = fs.openSync(model, "w");
fs.ftruncateSync(descriptor, 3 * 1024 * 1024 * 1024);
fs.closeSync(descriptor);
replacePluginDirectory(source, target, { preserveData: true });
assert.equal(fs.readFileSync(path.join(target, "index.js"), "utf8"), "module.exports = 'new';");
assert.equal(fs.existsSync(path.join(target, "stale.js")), false);
assert.equal(fs.statSync(model).size, 3 * 1024 * 1024 * 1024);
assert.equal(fs.existsSync(path.join(target, "data", "config", "default.json")), false);
console.log("Plugin update preservation verification passed.");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}

View File

@ -4,7 +4,12 @@ const { createWebServer } = require("./web/server");
const { startBot, stopBot } = require("./services/discord");
const { startTwitchBot, stopTwitchBot } = require("./services/twitch");
const { startYouTubeBot, stopYouTubeBot } = require("./services/youtube");
const { loadEnabled } = require("./services/plugins");
const pluginService = require("./services/plugins");
const { loadEnabled } = pluginService;
const stopPlugins =
typeof pluginService.stopPlugins === "function"
? pluginService.stopPlugins
: async () => {};
const { checkForUpdates, pullUpdates, requestRestart } = require("./services/updater");
const { createCommandRouter } = require("./services/command-router");
const { registerTopCommand } = require("./services/top");
@ -86,12 +91,21 @@ async function main() {
}, intervalMs);
}
process.on("SIGINT", async () => {
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) {
return;
}
shuttingDown = true;
await stopPlugins();
await stopBot();
await stopTwitchBot();
await stopYouTubeBot();
process.exit(0);
});
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
main();

View File

@ -4,6 +4,7 @@ const { spawnSync } = require("child_process");
const { db } = require("./db");
const pluginsDir = path.join(__dirname, "..", "..", "plugins");
const cleanupHandlers = [];
function readJson(filePath) {
const raw = fs.readFileSync(filePath, "utf8");
@ -120,7 +121,7 @@ function loadEnabled({
try {
const mod = require(mainPath);
if (mod && typeof mod.init === "function") {
mod.init({
const cleanup = mod.init({
app,
discordClient,
twitchClient,
@ -132,6 +133,11 @@ function loadEnabled({
plugin,
commandRouter
});
if (typeof cleanup === "function") {
cleanupHandlers.push({ id: plugin.id, cleanup });
} else if (cleanup && typeof cleanup.stop === "function") {
cleanupHandlers.push({ id: plugin.id, cleanup: () => cleanup.stop() });
}
}
} catch (error) {
console.error(`Plugin ${plugin.id} failed to load`, error);
@ -139,6 +145,17 @@ function loadEnabled({
}
}
async function stopPlugins() {
const handlers = cleanupHandlers.splice(0).reverse();
for (const handler of handlers) {
try {
await handler.cleanup();
} catch (error) {
console.error(`Plugin ${handler.id} failed to stop`, error);
}
}
}
function installFromGit(url, targetFolder) {
if (!fs.existsSync(pluginsDir)) {
fs.mkdirSync(pluginsDir, { recursive: true });
@ -225,6 +242,7 @@ module.exports = {
setPluginEnabled,
removePlugin,
loadEnabled,
stopPlugins,
installFromGit,
updatePluginFromGit,
createLocalPlugin

View File

@ -70,7 +70,10 @@ async function createSnapshot({ type, pluginId }) {
pluginExisted = fs.existsSync(pluginDir);
if (pluginExisted) {
pluginZip = path.join(snapshotPath, "plugin.zip");
zipFolder(pluginDir, pluginZip, { base: pluginDir });
zipFolder(pluginDir, pluginZip, {
base: pluginDir,
ignore: new Set(["node_modules", "data"])
});
}
}
@ -248,7 +251,7 @@ function zipFolder(source, destination, options) {
}
const zip = new AdmZip();
const base = options?.base || source;
addFolder(zip, source, base, new Set(["node_modules"]));
addFolder(zip, source, base, options?.ignore || new Set(["node_modules"]));
zip.writeZip(destination);
}
@ -330,13 +333,42 @@ function hasAnyFiles(rootPath) {
return false;
}
function applyPluginFiles(rootPath, pluginId) {
function resetPluginCode(targetDir) {
if (!fs.existsSync(targetDir)) {
return;
}
for (const entry of fs.readdirSync(targetDir, { withFileTypes: true })) {
if (entry.name === "data") {
continue;
}
fs.rmSync(path.join(targetDir, entry.name), {
recursive: true,
force: true
});
}
}
function applyPluginFiles(rootPath, pluginId, options = {}) {
const pluginsDir = path.join(repoRoot, "plugins");
const targetDir = path.join(pluginsDir, pluginId);
fs.rmSync(targetDir, { recursive: true, force: true });
fs.mkdirSync(pluginsDir, { recursive: true });
replacePluginDirectory(rootPath, targetDir, options);
}
function replacePluginDirectory(rootPath, targetDir, options = {}) {
if (options.preserveData) {
resetPluginCode(targetDir);
} else {
fs.rmSync(targetDir, { recursive: true, force: true });
}
fs.mkdirSync(targetDir, { recursive: true });
copyDirectory(rootPath, targetDir, new Set(["node_modules"]));
copyDirectory(
rootPath,
targetDir,
options.preserveData
? new Set(["node_modules", "data"])
: new Set(["node_modules"])
);
}
async function applyBotUpdate(zipPath, options = {}) {
@ -378,7 +410,9 @@ async function applyPluginUpdate(zipPath) {
const snapshot = await createSnapshot({ type: "plugin", pluginId: manifest.id });
try {
applyPluginFiles(rootPath, manifest.id);
applyPluginFiles(rootPath, manifest.id, {
preserveData: snapshot.pluginExisted
});
return finalizeSnapshot(snapshot);
} catch (error) {
discardSnapshot(snapshot);
@ -435,7 +469,7 @@ function restoreSnapshot(id) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
extractZip(pluginZip, tempDir);
const rootPath = resolvePluginRoot(tempDir);
applyPluginFiles(rootPath, entry.pluginId);
applyPluginFiles(rootPath, entry.pluginId, { preserveData: true });
fs.rmSync(tempDir, { recursive: true, force: true });
} else {
fs.rmSync(targetDir, { recursive: true, force: true });
@ -450,6 +484,9 @@ function restoreSnapshot(id) {
module.exports = {
applyBotUpdate,
applyPluginUpdate,
applyPluginFiles,
resetPluginCode,
replacePluginDirectory,
listSnapshots,
restoreSnapshot
};

View File

@ -209,6 +209,11 @@ body {
gap: 10px;
}
.sidebar-assistant-panels {
flex: 0 0 auto;
min-width: 0;
}
.user-chip {
font-weight: 600;
padding: 6px 10px;
@ -1504,6 +1509,10 @@ body.sidebar-collapsed .sidebar-footer {
gap: 8px;
}
body.sidebar-collapsed .sidebar-assistant-panels {
width: 100%;
}
body.sidebar-collapsed .user-chip {
padding: 6px;
border-radius: 12px;

View File

@ -1804,6 +1804,7 @@ function createWebServer({ loadPlugins, discordClient }) {
})
);
app.use(express.urlencoded({ extended: false }));
app.use(express.json({ limit: "1mb" }));
app.use(express.static(path.join(__dirname, "public")));
const uploadDir = path.join(__dirname, "..", "..", "data", "uploads");
@ -1848,6 +1849,7 @@ function createWebServer({ loadPlugins, discordClient }) {
const navItems = [];
const profileSections = [];
const assistantPanels = [];
const web = {
createRouter: () => express.Router(),
mount: (mountPath, router, navItem) => {
@ -1864,6 +1866,12 @@ function createWebServer({ loadPlugins, discordClient }) {
return;
}
profileSections.push(section);
},
addAssistantPanel: (panel) => {
if (!panel || !panel.id || !panel.view || !fs.existsSync(panel.view)) {
return;
}
assistantPanels.push(panel);
}
};
@ -1887,6 +1895,20 @@ function createWebServer({ loadPlugins, discordClient }) {
const twitchPlatform = platformStatus.find((platform) => platform.id === "twitch");
res.locals.twitchConfigured = Boolean(twitchPlatform?.configured);
res.locals.currentPath = req.path;
res.locals.assistantPanels = assistantPanels
.filter((panel) => hasAccess(req.session.user, panel.role || "public"))
.filter((panel) => {
if (typeof panel.isVisible !== "function") {
return true;
}
try {
return panel.isVisible(req.session.user);
} catch (error) {
console.error(`Assistant panel ${panel.id} visibility check failed`, error);
return false;
}
})
.map((panel) => ({ ...panel, locals: { ...(panel.locals || {}), user: req.session.user } }));
res.locals.userAvatar = req.session.user
? getPreferredAvatar(req.session.user.id)
: null;

View File

@ -1,10 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<% const optionalAssistantPanels =
typeof assistantPanels !== "undefined" && Array.isArray(assistantPanels)
? assistantPanels
: []; %>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %> - <%= siteTitle %></title>
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
<% optionalAssistantPanels.forEach((panel) => { if (panel.stylesheet) { %>
<link rel="stylesheet" href="<%= panel.stylesheet %>?v=<%= assetVersion %>" />
<% } }) %>
<% if (theme) { %>
<style>
:root {
@ -92,6 +99,13 @@
</details>
<% }) %>
</nav>
<% if (optionalAssistantPanels.length) { %>
<div class="sidebar-assistant-panels">
<% optionalAssistantPanels.forEach((panel) => { %>
<%- include(panel.view, panel.locals || {}) %>
<% }) %>
</div>
<% } %>
<div class="sidebar-footer">
<% if (user) { %>
<a href="/profile" class="user-chip user-chip-link" title="View profile">
@ -138,4 +152,7 @@
<% if (flash) { %>
<div class="flash <%= flash.type %>"><%= flash.message %></div>
<% } %>
<% optionalAssistantPanels.forEach((panel) => { if (panel.script) { %>
<script src="<%= panel.script %>?v=<%= assetVersion %>" defer></script>
<% } }) %>