Support conditional command keyword aliases

This commit is contained in:
Franz Rolfsvaag 2026-07-19 20:34:37 +02:00
parent 4853092956
commit 66d0b63077
14 changed files with 137 additions and 36 deletions

View File

@ -1,5 +1,9 @@
# Lumi changelog # Lumi changelog
## 0.2.20
- Added comma-separated aliases to conditional command reply rules, allowing several keywords to return one shared response while retaining exact and fuzzy matching.
## 0.2.19 ## 0.2.19
- Added a protected, implicitly inherited default command group that can be edited but not deleted. - Added a protected, implicitly inherited default command group that can be edited but not deleted.

View File

@ -21,6 +21,9 @@ search suggestions with positive-only live setting/source filters; and atomic
page-wide saving for every command so collapsed or filtered drafts survive the page-wide saving for every command so collapsed or filtered drafts survive the
save. save.
Patch completed on 2026-07-19: conditional reply rules now accept
comma-separated keyword aliases that share one response.
Remaining work: Remaining work:
- Add platform action providers when YouTube or future chat integrations expose - Add platform action providers when YouTube or future chat integrations expose

View File

@ -44,6 +44,9 @@ For example, a `rules` command can answer `!rules backseating` and
`!rules spoilers` differently. The optional main response is shown for `!rules` `!rules spoilers` differently. The optional main response is shown for `!rules`
without an argument; if it is empty, Lumi lists the available keywords. without an argument; if it is empty, Lumi lists the available keywords.
Separate keywords with commas when several aliases should return the same
reply. For example, `backseat, backseating, hints` can share one rules response.
Exact keyword matches always win. Optional fuzzy matching accepts only a clear, Exact keyword matches always win. Optional fuzzy matching accepts only a clear,
close typo and refuses ambiguous or unrelated text. Reply templates use the close typo and refuses ambiguous or unrelated text. Reply templates use the
same shared placeholder validation and rendering as static custom commands. same shared placeholder validation and rendering as static custom commands.

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
## Runtime ## Runtime
Package: lumi-bot Package: lumi-bot
Version: 0.2.19 Version: 0.2.20
## Routes ## Routes
- GET /api/events - GET /api/events
- POST /api/destructive-confirmations - POST /api/destructive-confirmations

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.19", "version": "0.2.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.19", "version": "0.2.20",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.19", "version": "0.2.20",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {

View File

@ -2,6 +2,36 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "releases": [
{
"version": "0.2.20",
"ref": "refs/tags/v0.2.20",
"released_at": "2026-07-19",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Adds comma-separated aliases for conditional command reply rules. Existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.5",
"moderation": "0.1.5",
"okf": "0.1.1",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{ {
"version": "0.2.19", "version": "0.2.19",
"ref": "refs/tags/v0.2.19", "ref": "refs/tags/v0.2.19",

View File

@ -27,6 +27,22 @@ try {
assert.strictEqual(fuzzy.match.keyword, "backseating"); assert.strictEqual(fuzzy.match.keyword, "backseating");
assert.strictEqual(fuzzy.fuzzy, true); assert.strictEqual(fuzzy.fuzzy, true);
assert.strictEqual(conditional.findConditionalReply(replies, "something unrelated").match, null); assert.strictEqual(conditional.findConditionalReply(replies, "something unrelated").match, null);
const aliases = conditional.conditionalRepliesFromBody({
conditional_keyword: "backseat, backseating, hints",
conditional_response: "Please ask before helping."
});
assert.strictEqual(aliases.ok, true);
assert.strictEqual(aliases.replies[0].keyword, "backseat, backseating, hints");
assert.strictEqual(conditional.normalizeConditionalReplyRows(JSON.stringify(aliases.replies))[0].keyword, "backseat, backseating, hints");
for (const alias of ["backseat", "backseating", "hints"]) {
assert.strictEqual(conditional.findConditionalReply(aliases.replies, alias).match.response, "Please ask before helping.");
}
const duplicateAlias = conditional.conditionalRepliesFromBody({
conditional_keyword: ["spoilers, leaks", "leaks"],
conditional_response: ["First", "Second"]
});
assert.strictEqual(duplicateAlias.ok, false);
assert.match(duplicateAlias.errors[0], /leaks.*more than once/);
const groupId = policies.createGroup({ const groupId = policies.createGroup({
name: "Verification group", name: "Verification group",
@ -102,6 +118,7 @@ try {
assert(view.includes("data-policy-search") && view.includes("data-policy-setting-filter")); assert(view.includes("data-policy-search") && view.includes("data-policy-setting-filter"));
assert(view.includes("Protected default") && view.includes("data-command-policy-batch")); assert(view.includes("Protected default") && view.includes("data-command-policy-batch"));
assert(commandView.includes('value="conditional"') && commandView.includes("data-conditional-replies")); assert(commandView.includes('value="conditional"') && commandView.includes("data-conditional-replies"));
assert(commandView.includes("Separate aliases with commas"));
database.db.close(); database.db.close();
database = null; database = null;

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning"); const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const releaseVersion = "0.2.19"; const releaseVersion = "0.2.20";
const previousCoreVersion = "0.2.18"; const previousCoreVersion = "0.2.19";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = { const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.19, Lumi AI 0.8.5, and synchronized package metadata."); console.log("Release metadata verification passed: core 0.2.20, Lumi AI 0.8.5, and synchronized package metadata.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json"); const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version); const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.deepEqual(releaseVersions, ["0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) { for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref); assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = { const baseTarget = {
current_version: "0.2.4", current_version: "0.2.4",
available_versions: [ available_versions: [
{ version: "0.2.20", ref: "refs/tags/v0.2.20", rollback_safe: true },
{ version: "0.2.19", ref: "refs/tags/v0.2.19", rollback_safe: true }, { version: "0.2.19", ref: "refs/tags/v0.2.19", rollback_safe: true },
{ version: "0.2.18", ref: "refs/tags/v0.2.18", rollback_safe: true }, { version: "0.2.18", ref: "refs/tags/v0.2.18", rollback_safe: true },
{ version: "0.2.17", ref: "refs/tags/v0.2.17", rollback_safe: true }, { version: "0.2.17", ref: "refs/tags/v0.2.17", rollback_safe: true },
@ -79,7 +80,7 @@ const corrected = buildStatus({
channel: "stable" channel: "stable"
}); });
assert.equal(corrected.version_correction, true); assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.19"); assert.equal(corrected.safe_target_version, "0.2.20");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -9,21 +9,46 @@ function normalizeConditionalKey(value) {
.trim(); .trim();
} }
function splitConditionalKeywords(value) {
return String(value || "")
.split(",")
.map((keyword) => keyword.trim().slice(0, 100))
.filter(Boolean);
}
function normalizeConditionalReplies(value) { function normalizeConditionalReplies(value) {
return normalizeConditionalReplyRows(value).flatMap((entry) => splitConditionalKeywords(entry.keyword).map((keyword) => ({
keyword,
normalized: normalizeConditionalKey(keyword),
response: entry.response
})));
}
function normalizeConditionalReplyRows(value) {
let input = value; let input = value;
if (typeof input === "string") { if (typeof input === "string") {
try { input = JSON.parse(input); } catch { input = []; } try { input = JSON.parse(input); } catch { input = []; }
} }
if (!Array.isArray(input)) return []; if (!Array.isArray(input)) return [];
const seen = new Set(); const seen = new Set();
return input.slice(0, MAX_CONDITIONAL_REPLIES).flatMap((entry) => { const rows = [];
const keyword = String(entry?.keyword || "").trim().slice(0, 100); let aliasCount = 0;
for (const entry of input) {
const response = String(entry?.response || "").trim().slice(0, 4000); const response = String(entry?.response || "").trim().slice(0, 4000);
if (!response) continue;
const aliases = [];
for (const keyword of splitConditionalKeywords(entry?.keyword)) {
const normalized = normalizeConditionalKey(keyword); const normalized = normalizeConditionalKey(keyword);
if (!normalized || !response || seen.has(normalized)) return []; if (!normalized || seen.has(normalized)) continue;
seen.add(normalized); seen.add(normalized);
return [{ keyword, normalized, response }]; aliases.push(keyword);
}); aliasCount += 1;
if (aliasCount >= MAX_CONDITIONAL_REPLIES) break;
}
if (aliases.length) rows.push({ keyword: aliases.join(", "), response });
if (aliasCount >= MAX_CONDITIONAL_REPLIES) break;
}
return rows;
} }
function conditionalRepliesFromBody(body = {}) { function conditionalRepliesFromBody(body = {}) {
@ -32,26 +57,28 @@ function conditionalRepliesFromBody(body = {}) {
const replies = []; const replies = [];
const errors = []; const errors = [];
const seen = new Set(); const seen = new Set();
let aliasCount = 0;
const count = Math.max(keywords.length, responses.length); const count = Math.max(keywords.length, responses.length);
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
const keyword = String(keywords[index] || "").trim(); const keyword = String(keywords[index] || "").trim();
const aliases = splitConditionalKeywords(keyword);
const response = String(responses[index] || "").trim(); const response = String(responses[index] || "").trim();
if (!keyword && !response) continue; if (!keyword && !response) continue;
if (!keyword || !response) { if (!aliases.length || !response) {
errors.push("Each conditional reply needs both a keyword and a response."); errors.push("Each conditional reply needs both a keyword and a response.");
continue; continue;
} }
const normalized = normalizeConditionalKey(keyword); for (const alias of aliases) {
if (seen.has(normalized)) { const normalized = normalizeConditionalKey(alias);
errors.push(`The keyword “${keyword}” is listed more than once.`); if (seen.has(normalized)) errors.push(`The keyword “${alias}” is listed more than once.`);
continue;
}
seen.add(normalized); seen.add(normalized);
replies.push({ keyword: keyword.slice(0, 100), response: response.slice(0, 4000) }); }
aliasCount += aliases.length;
replies.push({ keyword: aliases.join(", "), response: response.slice(0, 4000) });
} }
if (!replies.length) errors.push("Add at least one keyword reply."); if (!replies.length) errors.push("Add at least one keyword reply.");
if (replies.length > MAX_CONDITIONAL_REPLIES) errors.push(`Use no more than ${MAX_CONDITIONAL_REPLIES} keyword replies.`); if (aliasCount > MAX_CONDITIONAL_REPLIES) errors.push(`Use no more than ${MAX_CONDITIONAL_REPLIES} keywords.`);
return { ok: errors.length === 0, errors, replies: replies.slice(0, MAX_CONDITIONAL_REPLIES) }; return { ok: errors.length === 0, errors, replies };
} }
function findConditionalReply(replies, argument, { fuzzy = true } = {}) { function findConditionalReply(replies, argument, { fuzzy = true } = {}) {
@ -107,5 +134,7 @@ module.exports = {
conditionalRepliesFromBody, conditionalRepliesFromBody,
findConditionalReply, findConditionalReply,
normalizeConditionalKey, normalizeConditionalKey,
normalizeConditionalReplies normalizeConditionalReplies,
normalizeConditionalReplyRows,
splitConditionalKeywords
}; };

View File

@ -79,7 +79,8 @@ const { getClient: getTwitchClient } = require("../services/twitch");
const { getClient: getYouTubeClient } = require("../services/youtube"); const { getClient: getYouTubeClient } = require("../services/youtube");
const { const {
conditionalRepliesFromBody, conditionalRepliesFromBody,
normalizeConditionalReplies normalizeConditionalReplies,
normalizeConditionalReplyRows
} = require("../services/command-conditional"); } = require("../services/command-conditional");
const { const {
createGroup: createCommandGroup, createGroup: createCommandGroup,
@ -1320,7 +1321,7 @@ function buildRandomCommandPresentation(command) {
} }
function buildConditionalCommandPresentation(command) { function buildConditionalCommandPresentation(command) {
return { replies: normalizeConditionalReplies(command.conditional_replies_json) }; return { replies: normalizeConditionalReplyRows(command.conditional_replies_json) };
} }
function validateConditionalReplyTemplates(config, user) { function validateConditionalReplyTemplates(config, user) {

View File

@ -64,14 +64,14 @@
<label>Keyword replies</label> <label>Keyword replies</label>
<div class="command-random-replies" data-conditional-replies> <div class="command-random-replies" data-conditional-replies>
<div class="command-random-row command-conditional-row" data-conditional-reply-row> <div class="command-random-row command-conditional-row" data-conditional-reply-row>
<input name="conditional_keyword" placeholder="backseating" aria-label="Keyword" /> <input name="conditional_keyword" placeholder="backseat, backseating" aria-label="Keywords, separated with commas" />
<input name="conditional_response" placeholder="The reply for this keyword" aria-label="Reply" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" /> <input name="conditional_response" placeholder="The reply for this keyword" aria-label="Reply" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
<button type="button" class="button subtle" data-conditional-remove>Remove</button> <button type="button" class="button subtle" data-conditional-remove>Remove</button>
</div> </div>
</div> </div>
<button type="button" class="button subtle" data-conditional-add>Add keyword</button> <button type="button" class="button subtle" data-conditional-add>Add keyword</button>
<label class="switch"><input type="checkbox" class="switch-input" name="conditional_fuzzy" checked /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Suggest a close keyword when there is a small typo</span></label> <label class="switch"><input type="checkbox" class="switch-input" name="conditional_fuzzy" checked /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Suggest a close keyword when there is a small typo</span></label>
<p class="hint">Example: <code>!rules backseating</code>. Exact matches are preferred; fuzzy matching only accepts a clear close match.</p> <p class="hint">Separate aliases with commas to give them the same reply. Example: <code>backseat, backseating</code>. Exact matches are preferred; fuzzy matching only accepts a clear close match.</p>
</div> </div>
<div class="field full js-field-random"> <div class="field full js-field-random">
<label>Random replies</label> <label>Random replies</label>
@ -138,7 +138,7 @@
<% if (command.random.rng !== null) { %><span class="hint">Example roll: <%= command.random.rng %></span><% } %> <% if (command.random.rng !== null) { %><span class="hint">Example roll: <%= command.random.rng %></span><% } %>
<% if (command.random.error) { %><span class="command-preview-unavailable"><%= command.random.error %></span><% } %> <% if (command.random.error) { %><span class="command-preview-unavailable"><%= command.random.error %></span><% } %>
<% } else if (command.mode === "conditional") { %> <% } else if (command.mode === "conditional") { %>
<span><strong><%= command.conditional.replies.length %> keyword repl<%= command.conditional.replies.length === 1 ? "y" : "ies" %></strong></span> <span><strong><%= command.conditional.replies.length %> reply rule<%= command.conditional.replies.length === 1 ? "" : "s" %></strong></span>
<span class="hint"><%= command.conditional.replies.map((reply) => reply.keyword).join(", ") %></span> <span class="hint"><%= command.conditional.replies.map((reply) => reply.keyword).join(", ") %></span>
<% } else if (command.preview.status === "ready") { %> <% } else if (command.preview.status === "ready") { %>
<% const previewId = `command-preview-${command.id}`; %> <% const previewId = `command-preview-${command.id}`; %>
@ -239,7 +239,7 @@
<div class="command-random-replies" data-conditional-replies> <div class="command-random-replies" data-conditional-replies>
<% (command.conditional.replies.length ? command.conditional.replies : [{ keyword: "", response: "" }]).forEach((reply) => { %> <% (command.conditional.replies.length ? command.conditional.replies : [{ keyword: "", response: "" }]).forEach((reply) => { %>
<div class="command-random-row command-conditional-row" data-conditional-reply-row> <div class="command-random-row command-conditional-row" data-conditional-reply-row>
<input name="conditional_keyword" value="<%= reply.keyword %>" placeholder="backseating" aria-label="Keyword" /> <input name="conditional_keyword" value="<%= reply.keyword %>" placeholder="backseat, backseating" aria-label="Keywords, separated with commas" />
<input name="conditional_response" value="<%= reply.response %>" placeholder="The reply for this keyword" aria-label="Reply" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" /> <input name="conditional_response" value="<%= reply.response %>" placeholder="The reply for this keyword" aria-label="Reply" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
<button type="button" class="button subtle" data-conditional-remove>Remove</button> <button type="button" class="button subtle" data-conditional-remove>Remove</button>
</div> </div>
@ -247,6 +247,7 @@
</div> </div>
<button type="button" class="button subtle" data-conditional-add>Add keyword</button> <button type="button" class="button subtle" data-conditional-add>Add keyword</button>
<label class="switch"><input type="checkbox" class="switch-input" name="conditional_fuzzy" <%= command.conditional_fuzzy ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Suggest a close keyword when there is a small typo</span></label> <label class="switch"><input type="checkbox" class="switch-input" name="conditional_fuzzy" <%= command.conditional_fuzzy ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Suggest a close keyword when there is a small typo</span></label>
<p class="hint">Separate aliases with commas to give them the same reply.</p>
</div> </div>
<div class="field full js-field-random"> <div class="field full js-field-random">
<label>Random replies</label> <label>Random replies</label>
@ -309,8 +310,8 @@
row.dataset.conditionalReplyRow = ""; row.dataset.conditionalReplyRow = "";
const keyword = document.createElement("input"); const keyword = document.createElement("input");
keyword.name = "conditional_keyword"; keyword.name = "conditional_keyword";
keyword.placeholder = "keyword"; keyword.placeholder = "keyword, alias";
keyword.setAttribute("aria-label", "Keyword"); keyword.setAttribute("aria-label", "Keywords, separated with commas");
const response = document.createElement("input"); const response = document.createElement("input");
response.name = "conditional_response"; response.name = "conditional_response";
response.placeholder = "The reply for this keyword"; response.placeholder = "The reply for this keyword";

View File

@ -1,6 +1,6 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.2.19", "version": "0.2.20",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-19", "released_at": "2026-07-19",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [ "replaces_versions": [
"1.2.0" "1.2.0"
], ],
"migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves. Existing commands, policies, groups, usage totals, overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.", "migration_notes": "Adds comma-separated aliases for conditional command reply rules. Existing commands, policies, groups, usage totals, overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.",
"rollback_safe": true, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -253,6 +253,18 @@
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves; existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." "migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves; existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
},
{
"version": "0.2.20",
"channel": "stable",
"released_at": "2026-07-19",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Adds comma-separated aliases for conditional command reply rules; existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
} }
] ]
} }