🎨 优化扩展模块,完成ai接入和对话功能

This commit is contained in:
2026-02-12 23:12:28 +08:00
parent 4e611d3a5e
commit 572f3aa15b
779 changed files with 194400 additions and 3136 deletions

View File

@@ -0,0 +1,680 @@
/**
* MacroBrowser - Dynamic documentation browser for macros.
* Similar to SlashCommandBrowser but for the macro system.
*/
import { MacroRegistry, MacroCategory } from './engine/MacroRegistry.js';
import { performFuzzySearch } from '../power-user.js';
/** @typedef {import('./engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
/** @typedef {import('./engine/MacroRegistry.js').MacroValueType} MacroValueType */
/**
* Category display names and order for documentation.
* @type {Record<string, { label: string, order: number }>}
*/
const CATEGORY_CONFIG = {
[MacroCategory.NAMES]: { label: 'Names & Participants', order: 1 },
[MacroCategory.UTILITY]: { label: 'Utilities', order: 2 },
[MacroCategory.RANDOM]: { label: 'Randomization', order: 3 },
[MacroCategory.TIME]: { label: 'Date & Time', order: 4 },
[MacroCategory.VARIABLE]: { label: 'Variables', order: 5 },
[MacroCategory.STATE]: { label: 'Runtime State', order: 6 },
[MacroCategory.CHARACTER]: { label: 'Character Card & Persona Fields', order: 7 },
[MacroCategory.CHAT]: { label: 'Chat History & Messages', order: 8 },
[MacroCategory.PROMPTS]: { label: 'Prompt Templates', order: 9 },
[MacroCategory.MISC]: { label: 'Miscellaneous', order: 10 },
};
/**
* MacroBrowser class for displaying searchable macro documentation.
*/
export class MacroBrowser {
/** @type {Map<string, MacroDefinition[]>} */
macrosByCategory = new Map();
/** @type {HTMLElement} */
dom;
/** @type {HTMLInputElement} */
searchInput;
/** @type {HTMLElement} */
detailsPanel;
/** @type {Map<string, HTMLElement>} */
itemMap = new Map();
/** @type {boolean} */
isSorted = false;
/**
* Groups macros by category in registration order.
* Excludes hidden aliases from the list.
*/
#loadMacros() {
this.macrosByCategory.clear();
// Exclude hidden aliases - they won't show in the list
const allMacros = MacroRegistry.getAllMacros({ excludeHiddenAliases: true });
for (const macro of allMacros) {
const category = macro.category || MacroCategory.MISC;
if (!this.macrosByCategory.has(category)) {
this.macrosByCategory.set(category, []);
}
this.macrosByCategory.get(category).push(macro);
}
}
/**
* Sorts macros within each category alphabetically.
*/
#sortMacros() {
for (const [, macros] of this.macrosByCategory) {
macros.sort((a, b) => a.name.localeCompare(b.name));
}
}
/**
* Gets categories sorted by their configured order.
* @returns {string[]}
*/
#getSortedCategories() {
return Array.from(this.macrosByCategory.keys())
.sort((a, b) => getCategoryConfig(a).order - getCategoryConfig(b).order);
}
/**
* Renders the browser into a parent element.
* @param {HTMLElement} parent
* @returns {HTMLElement}
*/
renderInto(parent) {
this.#loadMacros();
const root = document.createElement('div');
root.classList.add('macroBrowser');
this.dom = root;
// Search bar and sort button
const toolbar = document.createElement('div');
toolbar.classList.add('macro-toolbar');
const searchLabel = document.createElement('label');
searchLabel.classList.add('macro-search-label');
searchLabel.textContent = 'Search: ';
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.classList.add('macro-search-input', 'text_pole');
searchInput.placeholder = 'Search macros by name or description...';
searchInput.addEventListener('input', () => this.#handleSearch(searchInput.value));
this.searchInput = searchInput;
searchLabel.appendChild(searchInput);
toolbar.appendChild(searchLabel);
const sortBtn = document.createElement('button');
sortBtn.classList.add('macro-sort-btn', 'menu_button');
sortBtn.innerHTML = '<i class="fa-solid fa-arrow-down-a-z"></i> Sort A-Z';
sortBtn.title = 'Sort macros alphabetically within each category';
sortBtn.addEventListener('click', () => this.#toggleSort());
toolbar.appendChild(sortBtn);
root.appendChild(toolbar);
// Container for list and details
const container = document.createElement('div');
container.classList.add('macro-container');
// Macro list
const listPanel = document.createElement('div');
listPanel.classList.add('macro-list-panel');
this.#renderList(listPanel);
container.appendChild(listPanel);
// Details panel
const detailsPanel = document.createElement('div');
detailsPanel.classList.add('macro-details-panel');
detailsPanel.innerHTML = '<div class="macro-details-placeholder">Select a macro to view details</div>';
this.detailsPanel = detailsPanel;
container.appendChild(detailsPanel);
root.appendChild(container);
parent.appendChild(root);
return root;
}
/**
* Renders the macro list grouped by category.
* @param {HTMLElement} listPanel
*/
#renderList(listPanel) {
listPanel.innerHTML = '';
this.itemMap.clear();
for (const category of this.#getSortedCategories()) {
const macros = this.macrosByCategory.get(category);
if (!macros || macros.length === 0) continue;
// Category header
const categoryHeader = document.createElement('div');
categoryHeader.classList.add('macro-category-header');
categoryHeader.textContent = getCategoryConfig(category).label;
categoryHeader.dataset.category = category;
listPanel.appendChild(categoryHeader);
// Macro items
for (const macro of macros) {
const item = renderMacroItem(macro);
item.addEventListener('click', () => this.#showDetails(macro, item));
this.itemMap.set(macro.name, item);
listPanel.appendChild(item);
}
}
}
/**
* Shows details for a selected macro.
* @param {MacroDefinition} macro
* @param {HTMLElement} item
*/
#showDetails(macro, item) {
// Clear previous selection
this.dom.querySelectorAll('.macro-item.selected').forEach(el => el.classList.remove('selected'));
item.classList.add('selected');
// Render details
this.detailsPanel.innerHTML = '';
this.detailsPanel.appendChild(renderMacroDetails(macro));
}
/**
* Handles search input using fuzzy search.
* @param {string} query
*/
#handleSearch(query) {
query = query.trim();
// Clear details on search
this.detailsPanel.innerHTML = '<div class="macro-details-placeholder">Select a macro to view details</div>';
this.dom.querySelectorAll('.macro-item.selected').forEach(el => el.classList.remove('selected'));
// If empty query, show all
if (!query) {
for (const item of this.itemMap.values()) {
item.classList.remove('isFiltered');
}
this.dom.querySelectorAll('.macro-category-header').forEach(h => h.classList.remove('isFiltered'));
return;
}
// Trim query of braces, as we don't have them in the macro names of the search definitions
query = query.replace(/[{}]/g, '');
// Build searchable data array from all macros
const allMacros = MacroRegistry.getAllMacros();
const searchData = allMacros.map(macro => ({
name: macro.name,
aliases: macro.aliases?.map(a => a.alias).join(' '),
description: macro.description || '',
category: getCategoryConfig(macro.category).label,
argNames: macro.unnamedArgDefs.map(d => d.name).join(' '),
argDescriptions: macro.unnamedArgDefs.map(d => d.description || '').join(' '),
}));
// Fuzzy search with weighted keys
const keys = [
{ name: 'name', weight: 10 },
{ name: 'aliases', weight: 1 }, // No need to rank those high, if they are important (visible) they have their own entry
{ name: 'description', weight: 5 },
{ name: 'category', weight: 3 },
{ name: 'argNames', weight: 2 },
{ name: 'argDescriptions', weight: 1 },
];
const results = performFuzzySearch('macro-browser', searchData, keys, query);
const matchedNames = new Set(results.map(r => r.item.name));
// Filter items based on fuzzy results
for (const [name, item] of this.itemMap) {
item.classList.toggle('isFiltered', !matchedNames.has(name));
}
// Hide empty category headers
this.dom.querySelectorAll('.macro-category-header').forEach(header => {
if (!(header instanceof HTMLElement)) return;
const category = header.dataset.category;
const hasVisible = Array.from(this.itemMap.values())
.filter(item => item.dataset.macroName)
.some(item => {
const macro = MacroRegistry.getMacro(item.dataset.macroName);
return macro?.category === category && !item.classList.contains('isFiltered');
});
header.classList.toggle('isFiltered', !hasVisible);
});
}
/**
* Toggles alphabetical sorting.
*/
#toggleSort() {
this.isSorted = !this.isSorted;
if (this.isSorted) {
this.#sortMacros();
} else {
this.#loadMacros(); // Reload to restore registration order
}
const listPanel = this.dom.querySelector('.macro-list-panel');
if (!(listPanel instanceof HTMLElement)) return;
this.#renderList(listPanel);
// Re-apply current search filter
if (this.searchInput?.value) {
this.#handleSearch(this.searchInput.value);
}
// Update button state
const sortBtn = this.dom.querySelector('.macro-sort-btn');
sortBtn?.classList.toggle('active', this.isSorted);
}
/**
* Handles keyboard shortcuts.
* @param {KeyboardEvent} evt
*/
#handleKeyDown(evt) {
if (!evt.shiftKey && !evt.altKey && evt.ctrlKey && evt.key.toLowerCase() === 'f') {
if (!this.dom.closest('body')) return;
if (this.dom.closest('.mes') && !this.dom.closest('.last_mes')) return;
evt.preventDefault();
evt.stopPropagation();
evt.stopImmediatePropagation();
this.searchInput?.focus();
}
}
}
/**
* Gets the macro help content.
* If experimental_macro_engine is enabled, returns a placeholder for the browser.
* Otherwise returns the static template content.
*
* @returns {string} HTML string for help content
*/
export function getMacrosHelp() {
// Return a placeholder that will be replaced with the browser
return '<div class="macroHelp"><i class="fa-solid fa-spinner fa-spin"></i> Loading macro documentation...</div>';
}
/**
* Gets display config for a category.
* @param {string} category
* @returns {{ label: string, order: number }}
*/
function getCategoryConfig(category) {
return CATEGORY_CONFIG[category] ?? { label: category, order: 100 };
}
/**
* Formats a macro signature with its arguments.
* Uses displayOverride if available, otherwise auto-generates from args.
* Optional args are shown in [brackets].
* @param {MacroDefinition} macro
* @returns {string}
*/
export function formatMacroSignature(macro) {
// Use displayOverride if provided
if (macro.displayOverride) {
return macro.displayOverride;
}
const parts = [macro.name];
// Add all unnamed args (required + optional)
for (let i = 0; i < macro.unnamedArgDefs.length; i++) {
const argDef = macro.unnamedArgDefs[i];
const argName = argDef?.sampleValue || argDef?.name || `arg${i + 1}`;
// Wrap optional args in brackets
parts.push(argDef?.optional ? `[${argName}]` : argName);
}
// Add list args indicator
if (macro.list) {
const hasMin = macro.list.min > 0;
const hasMax = macro.list.max !== null;
if (hasMin && hasMax && macro.list.min === macro.list.max) {
// Fixed number of list items
for (let i = 0; i < macro.list.min; i++) {
parts.push(`item${i + 1}`);
}
} else {
// Variable list
parts.push('item1', 'item2', '...');
}
}
return `{{${parts.join('::')}}}`;
}
/**
* Creates a DOM element for a macro's source indicator (extension/third-party icons).
* @param {MacroDefinition} macro
* @returns {HTMLElement}
*/
export function createSourceIndicator(macro) {
const src = document.createElement('span');
src.classList.add('macro-source', 'fa-solid');
if (macro.source.isExtension) {
src.classList.add('isExtension', 'fa-cubes');
src.classList.add(macro.source.isThirdParty ? 'isThirdParty' : 'isCore');
} else {
src.classList.add('isCore', 'fa-star-of-life');
}
const titleParts = [
macro.source.isExtension ? 'Extension' : 'Core',
macro.source.isThirdParty ? 'Third Party' : (macro.source.isExtension ? 'Built-in' : null),
macro.source.name,
].filter(Boolean);
src.title = titleParts.join('\n');
return src;
}
/**
* Creates a DOM element for alias indicator icon.
* @param {MacroDefinition} macro
* @returns {HTMLElement|null}
*/
export function createAliasIndicator(macro) {
if (!macro.aliasOf) return null;
const icon = document.createElement('span');
icon.classList.add('macro-alias-indicator', 'fa-solid', 'fa-arrow-turn-up');
icon.title = `Alias of {{${macro.aliasOf}}}`;
return icon;
}
/**
* Creates a type badge element. Supports single type or array of types.
* @param {MacroValueType|MacroValueType[]} type - Single type or array of accepted types.
* @returns {HTMLElement}
*/
export function createTypeBadge(type) {
const badge = document.createElement('span');
badge.classList.add('macro-arg-type');
if (Array.isArray(type)) {
badge.textContent = type.join(' | ');
badge.title = `Accepts: ${type.join(', ')}`;
} else {
badge.textContent = type;
}
return badge;
}
/**
* Renders a single macro item for the list.
* Order: [signature] [description (shrinks)] [alias icon?] [source icon]
* @param {MacroDefinition} macro
* @returns {HTMLElement}
*/
function renderMacroItem(macro) {
const item = document.createElement('div');
item.classList.add('macro-item');
if (macro.aliasOf) item.classList.add('isAlias');
item.dataset.macroName = macro.name;
// Signature (fixed width, truncates if too long)
const signature = document.createElement('code');
signature.classList.add('macro-signature');
signature.textContent = formatMacroSignature(macro);
item.appendChild(signature);
// Description preview (shrinks to fit, truncates)
const desc = document.createElement('span');
desc.classList.add('macro-desc-preview');
desc.textContent = macro.description || '<no description>';
item.appendChild(desc);
// Alias indicator (if this is an alias entry)
const aliasIcon = createAliasIndicator(macro);
if (aliasIcon) item.appendChild(aliasIcon);
// Source indicator (fixed, stays at right edge)
item.appendChild(createSourceIndicator(macro));
return item;
}
/**
* Renders detailed information for a macro.
* Can optionally highlight the current argument being typed.
* @param {MacroDefinition} macro
* @param {Object} [options]
* @param {number} [options.currentArgIndex=-1] - Index of argument to highlight (-1 for none).
* @param {boolean} [options.showCategory=true] - Whether to show category badge.
* @returns {HTMLElement}
*/
export function renderMacroDetails(macro, options = {}) {
const { currentArgIndex = -1, showCategory = true } = options;
const details = document.createElement('div');
details.classList.add('macro-details');
// Header with name and source
const header = document.createElement('div');
header.classList.add('macro-details-header');
const nameEl = document.createElement('code');
nameEl.classList.add('macro-details-name');
nameEl.textContent = formatMacroSignature(macro);
header.appendChild(nameEl);
header.appendChild(createSourceIndicator(macro));
details.appendChild(header);
// Category badge (optional)
if (showCategory) {
const categoryBadge = document.createElement('span');
categoryBadge.classList.add('macro-category-badge');
categoryBadge.textContent = getCategoryConfig(macro.category).label;
details.appendChild(categoryBadge);
}
// If this is an alias, show what it's an alias of
if (macro.aliasOf) {
const aliasOfSection = document.createElement('div');
aliasOfSection.classList.add('macro-alias-of');
aliasOfSection.innerHTML = `<i class="fa-solid fa-arrow-turn-up"></i> Alias of <code>{{${macro.aliasOf}}}</code>`;
details.appendChild(aliasOfSection);
}
// Description
const descSection = document.createElement('div');
descSection.classList.add('macro-details-section');
const descLabel = document.createElement('div');
descLabel.classList.add('macro-details-label');
descLabel.textContent = 'Description';
descSection.appendChild(descLabel);
const descText = document.createElement('div');
descText.classList.add('macro-details-text');
descText.textContent = macro.description || '<no description>';
descSection.appendChild(descText);
details.appendChild(descSection);
// Arguments section (if any)
if (macro.unnamedArgDefs.length > 0 || macro.list) {
const argsSection = document.createElement('div');
argsSection.classList.add('macro-details-section');
const argsLabel = document.createElement('div');
argsLabel.classList.add('macro-details-label');
argsLabel.textContent = 'Arguments';
argsSection.appendChild(argsLabel);
const argsList = document.createElement('ul');
argsList.classList.add('macro-args-list');
// Unnamed args (required + optional)
for (let i = 0; i < macro.unnamedArgDefs.length; i++) {
const argDef = macro.unnamedArgDefs[i];
const argItem = document.createElement('li');
argItem.classList.add('macro-arg-item');
if (argDef?.optional) argItem.classList.add('isOptional');
if (currentArgIndex === i) argItem.classList.add('current');
const argName = document.createElement('code');
argName.classList.add('macro-arg-name');
argName.textContent = argDef?.name || `arg${i + 1}`;
argItem.appendChild(argName);
argItem.appendChild(createTypeBadge(argDef.type ?? 'string'));
const argRequiredLabel = document.createElement('span');
argRequiredLabel.classList.add(argDef?.optional ? 'macro-arg-optional' : 'macro-arg-required');
if (argDef?.optional && argDef.defaultValue !== undefined) {
argRequiredLabel.textContent = `(optional, default: ${argDef.defaultValue === '' ? '<empty string>' : argDef.defaultValue})`;
} else {
argRequiredLabel.textContent = argDef?.optional ? '(optional)' : '(required)';
}
argItem.appendChild(argRequiredLabel);
if (argDef?.description) {
const argDesc = document.createElement('span');
argDesc.classList.add('macro-arg-desc');
argDesc.textContent = `${argDef.description}`;
argItem.appendChild(argDesc);
}
if (argDef?.sampleValue) {
const sample = document.createElement('span');
sample.classList.add('macro-arg-sample');
sample.textContent = ` (e.g. ${argDef.sampleValue})`;
argItem.appendChild(sample);
}
argsList.appendChild(argItem);
}
// List args
if (macro.list) {
const listItem = document.createElement('li');
listItem.classList.add('macro-arg-item', 'macro-arg-list');
if (currentArgIndex >= macro.maxArgs) listItem.classList.add('current');
const listName = document.createElement('code');
listName.classList.add('macro-arg-name');
listName.textContent = 'item1::item2::...';
listItem.appendChild(listName);
const listInfo = document.createElement('span');
listInfo.classList.add('macro-arg-list-info');
const minMax = [];
if (macro.list.min > 0) minMax.push(`min: ${macro.list.min}`);
if (macro.list.max !== null) minMax.push(`max: ${macro.list.max}`);
if (minMax.length > 0) {
listInfo.textContent = ` (list, ${minMax.join(', ')})`;
} else {
listInfo.textContent = ' (variable-length list)';
}
listItem.appendChild(listInfo);
argsList.appendChild(listItem);
}
argsSection.appendChild(argsList);
details.appendChild(argsSection);
}
// Returns section (always show - at minimum shows the type)
{
const returnsSection = document.createElement('div');
returnsSection.classList.add('macro-details-section');
const returnsLabel = document.createElement('div');
returnsLabel.classList.add('macro-details-label');
returnsLabel.textContent = 'Returns';
returnsSection.appendChild(returnsLabel);
const returnsContent = document.createElement('div');
returnsContent.classList.add('macro-returns-content');
// Add return type badge
const returnTypeBadge = createTypeBadge(macro.returnType);
returnsContent.appendChild(returnTypeBadge);
// Add description text if provided
if (macro.returns) {
const returnsText = document.createElement('span');
returnsText.classList.add('macro-details-text');
returnsText.textContent = macro.returns;
returnsContent.appendChild(returnsText);
}
returnsSection.appendChild(returnsContent);
details.appendChild(returnsSection);
}
// Example usage section (if any)
if (macro.exampleUsage && macro.exampleUsage.length > 0) {
const exampleSection = document.createElement('div');
exampleSection.classList.add('macro-details-section');
const exampleLabel = document.createElement('div');
exampleLabel.classList.add('macro-details-label');
exampleLabel.textContent = 'Example Usage';
exampleSection.appendChild(exampleLabel);
const exampleList = document.createElement('ul');
exampleList.classList.add('macro-example-list');
for (const example of macro.exampleUsage) {
const li = document.createElement('li');
const code = document.createElement('code');
code.textContent = example;
li.appendChild(code);
exampleList.appendChild(li);
}
exampleSection.appendChild(exampleList);
details.appendChild(exampleSection);
}
// Aliases section (if this macro has aliases)
if (macro.aliases && macro.aliases.length > 0) {
const aliasSection = document.createElement('div');
aliasSection.classList.add('macro-details-section');
const aliasLabel = document.createElement('div');
aliasLabel.classList.add('macro-details-label');
aliasLabel.textContent = 'Aliases';
aliasSection.appendChild(aliasLabel);
const aliasList = document.createElement('ul');
aliasList.classList.add('macro-alias-list');
for (const { alias, visible } of macro.aliases) {
const li = document.createElement('li');
li.classList.add('macro-alias-item');
if (!visible) li.classList.add('isHidden');
const code = document.createElement('code');
code.textContent = `{{${alias}}}`;
li.appendChild(code);
if (!visible) {
const hiddenBadge = document.createElement('span');
hiddenBadge.classList.add('macro-alias-hidden-badge');
hiddenBadge.textContent = '(deprecated)';
hiddenBadge.title = 'This alias is deprecated and will not be shown in documentation or autocomplete';
li.appendChild(hiddenBadge);
}
aliasList.appendChild(li);
}
aliasSection.appendChild(aliasList);
details.appendChild(aliasSection);
}
return details;
}

View File

@@ -0,0 +1,136 @@
import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
import { chat, chat_metadata } from '../../../script.js';
/**
* Registers macros that inspect the current chat log and swipe state
* (message texts, indices, swipes, and context boundaries).
*/
export function registerChatMacros() {
MacroRegistry.registerMacro('lastMessage', {
category: MacroCategory.CHAT,
description: 'Last message in the chat.',
returns: 'Last message in the chat.',
handler: () => String(getLastMessage() ?? ''),
});
MacroRegistry.registerMacro('lastMessageId', {
category: MacroCategory.CHAT,
description: 'Index of the last message in the chat.',
returns: 'Index of the last message in the chat.',
returnType: MacroValueType.INTEGER,
handler: () => String(getLastMessageId() ?? ''),
});
MacroRegistry.registerMacro('lastUserMessage', {
category: MacroCategory.CHAT,
description: 'Last user message in the chat.',
returns: 'Last user message in the chat.',
handler: () => String(getLastUserMessage() ?? ''),
});
MacroRegistry.registerMacro('lastCharMessage', {
category: MacroCategory.CHAT,
description: 'Last character/bot message in the chat.',
returns: 'Last character/bot message in the chat.',
handler: () => String(getLastCharMessage() ?? ''),
});
MacroRegistry.registerMacro('firstIncludedMessageId', {
category: MacroCategory.CHAT,
description: 'Index of the first message included in the current context.',
returns: 'Index of the first message included in the context.',
returnType: MacroValueType.INTEGER,
handler: () => String(getFirstIncludedMessageId() ?? ''),
});
MacroRegistry.registerMacro('firstDisplayedMessageId', {
category: MacroCategory.CHAT,
description: 'Index of the first displayed message in the chat.',
returns: 'Index of the first displayed message in the chat.',
returnType: MacroValueType.INTEGER,
handler: () => String(getFirstDisplayedMessageId() ?? ''),
});
MacroRegistry.registerMacro('lastSwipeId', {
category: MacroCategory.CHAT,
description: '1-based index of the last swipe for the last message.',
returns: '1-based index of the last swipe.',
returnType: MacroValueType.INTEGER,
handler: () => String(getLastSwipeId() ?? ''),
});
MacroRegistry.registerMacro('currentSwipeId', {
category: MacroCategory.CHAT,
description: '1-based index of the current swipe.',
returns: '1-based index of the current swipe.',
returnType: MacroValueType.INTEGER,
handler: () => String(getCurrentSwipeId() ?? ''),
});
}
function getLastMessageId({ exclude_swipe_in_propress = true, filter = null } = {}) {
if (!Array.isArray(chat) || chat.length === 0) {
return null;
}
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (exclude_swipe_in_propress && message.swipes && message.swipe_id >= message.swipes.length) {
continue;
}
if (!filter || filter(message)) {
return i;
}
}
return null;
}
function getLastMessage() {
const mid = getLastMessageId();
return typeof mid === 'number' ? (chat[mid]?.mes ?? '') : '';
}
function getLastUserMessage() {
const mid = getLastMessageId({ filter: m => m.is_user && !m.is_system });
return typeof mid === 'number' ? (chat[mid]?.mes ?? '') : '';
}
function getLastCharMessage() {
const mid = getLastMessageId({ filter: m => !m.is_user && !m.is_system });
return typeof mid === 'number' ? (chat[mid]?.mes ?? '') : '';
}
function getFirstIncludedMessageId() {
const value = chat_metadata['lastInContextMessageId'];
return typeof value === 'number' ? value : null;
}
function getFirstDisplayedMessageId() {
const mesElement = document.querySelector('#chat .mes');
const mesId = Number(mesElement?.getAttribute('mesid'));
if (!Number.isNaN(mesId) && mesId >= 0) {
return mesId;
}
return null;
}
function getLastSwipeId() {
const mid = getLastMessageId({ exclude_swipe_in_propress: false });
if (typeof mid !== 'number') {
return null;
}
const swipes = chat[mid]?.swipes;
return Array.isArray(swipes) ? swipes.length : null;
}
function getCurrentSwipeId() {
const mid = getLastMessageId({ exclude_swipe_in_propress: false });
if (typeof mid !== 'number') {
return null;
}
const swipeId = chat[mid]?.swipe_id;
return typeof swipeId === 'number' ? swipeId + 1 : null;
}

View File

@@ -0,0 +1,280 @@
import { seedrandom, droll } from '../../../lib.js';
import { chat_metadata, main_api, getMaxContextSize, extension_prompts, getCurrentChatId } from '../../../script.js';
import { getStringHash } from '../../utils.js';
import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js';
import { inject_ids } from '../../constants.js';
import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
/**
* Registers SillyTavern's core built-in macros in the MacroRegistry.
*
* These macros correspond to the main {{...}} macros that are available
* in prompts (time/date/chat info, utility macros, etc.). They are
* intended to preserve the behavior of the existing regex-based macros
* in macros.js while using the new MacroRegistry/MacroEngine pipeline.
*/
export function registerCoreMacros() {
// {{space}} -> ' '
MacroRegistry.registerMacro('space', {
category: MacroCategory.UTILITY,
unnamedArgs: [
{
name: 'count',
optional: true,
defaultValue: '1',
type: MacroValueType.INTEGER,
description: 'Number of spaces to insert.',
},
],
description: 'Returns one or more spaces. One space by default, more if the count argument is specified.',
returns: 'One or more spaces.',
exampleUsage: ['{{space}}', '{{space::4}}'],
handler: ({ unnamedArgs: [count] }) => ' '.repeat(Number(count ?? 1)),
});
// {{newline}} -> '\n'
MacroRegistry.registerMacro('newline', {
category: MacroCategory.UTILITY,
unnamedArgs: [
{
name: 'count',
optional: true,
defaultValue: '1',
type: MacroValueType.INTEGER,
description: 'Number of newlines to insert.',
},
],
description: 'Inserts one or more newlines. One newline by default, more if the count argument is specified.',
returns: 'One or more \\n.',
exampleUsage: ['{{newline}}', '{{newline::2}}'],
handler: ({ unnamedArgs: [count] }) => '\n'.repeat(Number(count ?? 1)),
});
// {{noop}} -> ''
MacroRegistry.registerMacro('noop', {
category: MacroCategory.UTILITY,
description: 'Does nothing and produces an empty string.',
returns: '',
handler: () => '',
});
// {{trim}} -> macro will currently replace itself with itself. Trimming is handled in post-processing.
MacroRegistry.registerMacro('trim', {
category: MacroCategory.UTILITY,
description: 'Trims all whitespaces around the trim macro.',
returns: '',
handler: () => '{{trim}}',
});
// {{input}} -> current textarea content
MacroRegistry.registerMacro('input', {
category: MacroCategory.UTILITY,
description: 'Current text from the send textarea.',
returns: 'Current text from the send textarea.',
handler: () => (/** @type {HTMLTextAreaElement} */(document.querySelector('#send_textarea')))?.value ?? '',
});
// {{maxPrompt}} -> max context size
MacroRegistry.registerMacro('maxPrompt', {
category: MacroCategory.STATE,
description: 'Maximum prompt context size.',
returns: 'Maximum prompt context size.',
returnType: MacroValueType.INTEGER,
handler: () => String(getMaxContextSize()),
});
// String utilities
MacroRegistry.registerMacro('reverse', {
category: MacroCategory.UTILITY,
unnamedArgs: [
{
name: 'value',
type: MacroValueType.STRING,
description: 'The string to reverse.',
},
],
description: 'Reverses the characters of the argument provided.',
returns: 'Reversed string.',
exampleUsage: ['{{reverse::I am Lana}}'],
handler: ({ unnamedArgs: [value] }) => Array.from(value).reverse().join(''),
});
// Comment macro: {{// ...}} -> '' (consumes any arguments)
MacroRegistry.registerMacro('//', {
aliases: [{ alias: 'comment' }],
category: MacroCategory.UTILITY,
list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway
strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid
description: 'Comment macro that produces an empty string. Can be used for writing into prompt definitions, without being passed to the context.',
returns: '',
displayOverride: '{{// ...}}',
exampleUsage: ['{{// This is a comment}}'],
handler: () => '',
});
// Time and date macros
// Dice roll macro: {{roll 1d6}} or {{roll: 1d6}}
MacroRegistry.registerMacro('roll', {
category: MacroCategory.RANDOM,
unnamedArgs: [
{
name: 'formula',
sampleValue: '1d20',
description: 'Dice roll formula using droll syntax (e.g. 1d20).',
type: 'string',
},
],
description: 'Rolls dice using droll syntax (e.g. {{roll 1d20}}).',
returns: 'Dice roll result.',
returnType: MacroValueType.INTEGER,
exampleUsage: [
'{{roll::1d20}}',
'{{roll::6}}',
'{{roll::3d6+4}}',
],
handler: ({ unnamedArgs: [formula] }) => {
// If only digits were provided, treat it as `1dX`.
if (/^\d+$/.test(formula)) {
formula = `1d${formula}`;
}
const isValid = droll.validate(formula);
if (!isValid) {
console.debug(`Invalid roll formula: ${formula}`);
return '';
}
const result = droll.roll(formula);
if (result === false) return '';
return String(result.total);
},
});
// Random choice macro: {{random::a::b}} or {{random a,b}}
MacroRegistry.registerMacro('random', {
category: MacroCategory.RANDOM,
list: true,
description: 'Picks a random item from a list. Will be re-rolled every time macros are resolved.',
returns: 'Randomly selected item from the list.',
exampleUsage: ['{{random::blonde::brown::red::black::blue}}'],
handler: ({ list }) => {
// Handle old legacy cases, where we have to split the list manually
if (list.length === 1) {
list = readSingleArgsRandomList(list[0]);
}
if (list.length === 0) {
return '';
}
const rng = seedrandom('added entropy.', { entropy: true });
const randomIndex = Math.floor(rng() * list.length);
return list[randomIndex];
},
});
// Deterministic choice macro: {{pick::a::b}} or {{pick a,b}}
MacroRegistry.registerMacro('pick', {
category: MacroCategory.RANDOM,
list: true,
description: 'Picks a random item from a list, but keeps the choice stable for a given chat and macro position.',
returns: 'Stable randomly selected item from the list.',
exampleUsage: ['{{pick::blonde::brown::red::black::blue}}'],
handler: ({ list, range, env }) => {
// Handle old legacy cases, where we have to split the list manually
if (list.length === 1) {
list = readSingleArgsRandomList(list[0]);
}
if (!list.length) {
return '';
}
const chatIdHash = getChatIdHash();
// Use the full original input string for deterministic behavior
const rawContentHash = env.contentHash;
const offset = typeof range?.startOffset === 'number' ? range.startOffset : 0;
const combinedSeedString = `${chatIdHash}-${rawContentHash}-${offset}`;
const finalSeed = getStringHash(combinedSeedString);
const rng = seedrandom(String(finalSeed));
const randomIndex = Math.floor(rng() * list.length);
return list[randomIndex];
},
});
/** @param {string} listString @return {string[]} */
function readSingleArgsRandomList(listString) {
// If it contains double colons, those will have precedence over comma-seperated lists.
// This can only happen if the macro only had a single colon to introduce the list...
// like, {{random:a::b::c}}
if (listString.includes('::')) {
return listString.split('::').map((/** @type {string} */ item) => item.trim());
}
// Otherwise, we fall back and split by commas that may be present
return listString
.replace(/\\,/g, '##<23>COMMA<4D>##')
.split(',')
.map((/** @type {string} */ item) => item.trim().replace(/##<23>COMMA<4D>##/g, ','));
}
// Banned words macro: {{banned "word"}}
MacroRegistry.registerMacro('banned', {
category: MacroCategory.UTILITY,
unnamedArgs: [
{
name: 'word',
sampleValue: 'word',
description: 'Word to ban for textgenerationwebui backend.',
type: 'string',
},
],
description: 'Bans a word for textgenerationwebui backend. (Strips quotes surrounding the banned word, if present)',
returns: '',
exampleUsage: ['{{banned::delve}}'],
handler: ({ unnamedArgs: [bannedWord] }) => {
// Strip quotes via regex, which were allowed in legacy syntax
bannedWord = bannedWord.replace(/^"|"$/g, '');
if (main_api === 'textgenerationwebui') {
console.log('Found banned word in macros: ' + bannedWord);
textgenerationwebui_banned_in_macros.push(bannedWord);
}
return '';
},
});
// Outlet macro: {{outlet::key}}
MacroRegistry.registerMacro('outlet', {
category: MacroCategory.UTILITY,
unnamedArgs: [
{
name: 'key',
sampleValue: 'my-outlet-key',
description: 'Outlet key.',
type: 'string',
},
],
description: 'Returns the world info outlet prompt for a given outlet key.',
returns: 'World info outlet prompt.',
exampleUsage: ['{{outlet::character-achievements}}'],
handler: ({ unnamedArgs: [outlet] }) => {
if (!outlet) return '';
const value = extension_prompts[inject_ids.CUSTOM_WI_OUTLET(outlet)]?.value;
return value || '';
},
});
}
function getChatIdHash() {
const cachedIdHash = chat_metadata['chat_id_hash'];
if (typeof cachedIdHash === 'number') {
return cachedIdHash;
}
const chatId = chat_metadata['main_chat'] ?? getCurrentChatId();
const chatIdHash = getStringHash(chatId);
chat_metadata['chat_id_hash'] = chatIdHash;
return chatIdHash;
}

View File

@@ -0,0 +1,181 @@
import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
import { isMobile } from '../../RossAscends-mods.js';
import { parseMesExamples, main_api } from '../../../script.js';
import { power_user } from '../../power-user.js';
import { formatInstructModeExamples } from '../../instruct-mode.js';
/** @typedef {import('../engine/MacroEnv.types.js').MacroEnv} MacroEnv */
/**
* Registers macros that mostly act as simple accessors to MacroEnv fields
* (names, character card fields, system metadata, extras) or basic
* environment flags.
*/
export function registerEnvMacros() {
// Names and participant macros (from MacroEnv.names)
MacroRegistry.registerMacro('user', {
category: MacroCategory.NAMES,
description: 'Your current Persona username.',
returns: 'Persona username.',
handler: ({ env }) => env.names.user,
});
MacroRegistry.registerMacro('char', {
category: MacroCategory.NAMES,
description: 'The character\'s name.',
returns: 'Character name.',
handler: ({ env }) => env.names.char,
});
MacroRegistry.registerMacro('group', {
aliases: [{ alias: 'charIfNotGroup', visible: false }],
category: MacroCategory.NAMES,
description: 'Comma-separated list of group member names (including muted) or the character name in solo chats.',
returns: 'List of group member names.',
handler: ({ env }) => env.names.group ?? '',
});
MacroRegistry.registerMacro('groupNotMuted', {
category: MacroCategory.NAMES,
description: 'Comma-separated list of group member names excluding muted members.',
returns: 'List of group member names excluding muted members.',
handler: ({ env }) => env.names.groupNotMuted ?? '',
});
MacroRegistry.registerMacro('notChar', {
category: MacroCategory.NAMES,
description: 'Comma-separated list of all participants except the current speaker.',
returns: 'List of all participants except the current speaker.',
handler: ({ env }) => env.names.notChar ?? '',
});
// Character card field macros (from MacroEnv.character)
MacroRegistry.registerMacro('charPrompt', {
category: MacroCategory.CHARACTER,
description: 'The character\'s Main Prompt override.',
returns: 'Character Main Prompt override.',
handler: ({ env }) => env.character.charPrompt ?? '',
});
MacroRegistry.registerMacro('charInstruction', {
category: MacroCategory.CHARACTER,
description: 'The character\'s Post-History Instructions override.',
returns: 'Character Post-History Instructions override.',
handler: ({ env }) => env.character.charInstruction ?? '',
});
MacroRegistry.registerMacro('charDescription', {
aliases: [{ alias: 'description' }],
category: MacroCategory.CHARACTER,
description: 'The character\'s description.',
returns: 'Character description.',
handler: ({ env }) => env.character.description ?? '',
});
MacroRegistry.registerMacro('charPersonality', {
aliases: [{ alias: 'personality' }],
category: MacroCategory.CHARACTER,
description: 'The character\'s personality.',
returns: 'Character personality.',
handler: ({ env }) => env.character.personality ?? '',
});
MacroRegistry.registerMacro('charScenario', {
aliases: [{ alias: 'scenario' }],
category: MacroCategory.CHARACTER,
description: 'The character\'s scenario.',
returns: 'Character scenario.',
handler: ({ env }) => env.character.scenario ?? '',
});
MacroRegistry.registerMacro('persona', {
category: MacroCategory.CHARACTER,
description: 'Your current Persona description.',
returns: 'Persona description.',
handler: ({ env }) => env.character.persona ?? '',
});
MacroRegistry.registerMacro('mesExamplesRaw', {
category: MacroCategory.CHARACTER,
description: 'Unformatted dialogue examples from the character card.',
returns: 'Unformatted dialogue examples.',
handler: ({ env }) => env.character.mesExamplesRaw ?? '',
});
MacroRegistry.registerMacro('mesExamples', {
category: MacroCategory.CHARACTER,
description: 'The character\'s dialogue examples, formatted for instruct mode when enabled.',
returns: 'Formatted dialogue examples.',
handler: ({ env }) => {
const raw = env.character.mesExamplesRaw ?? '';
if (!raw) return '';
const isInstruct = !!power_user?.instruct?.enabled && main_api !== 'openai';
const parsed = parseMesExamples(raw, isInstruct);
if (!Array.isArray(parsed) || parsed.length === 0) {
return '';
}
if (!isInstruct) {
return parsed.join('');
}
const formatted = formatInstructModeExamples(parsed, env.names.user, env.names.char);
return Array.isArray(formatted) ? formatted.join('') : '';
},
});
MacroRegistry.registerMacro('charDepthPrompt', {
category: MacroCategory.CHARACTER,
description: 'The character\'s @ Depth Note.',
returns: 'Character @ Depth Note.',
handler: ({ env }) => env.character.charDepthPrompt ?? '',
});
MacroRegistry.registerMacro('charCreatorNotes', {
aliases: [{ alias: 'creatorNotes' }],
category: MacroCategory.CHARACTER,
description: 'Creator notes from the character card.',
returns: 'Creator notes.',
handler: ({ env }) => env.character.creatorNotes ?? '',
});
// Character version macros (legacy variants and documented {{charVersion}})
MacroRegistry.registerMacro('charVersion', {
aliases: [
{ alias: 'version', visible: false }, // Legacy alias
{ alias: 'char_version', visible: false }, // Legacy underscore variant
],
category: MacroCategory.CHARACTER,
description: 'The character\'s version number.',
returns: 'Character version number.',
handler: ({ env }) => env.character.version ?? '',
});
// System / env extras macros (from MacroEnv.system / MacroEnv.extra)
MacroRegistry.registerMacro('model', {
category: MacroCategory.STATE,
description: 'Model name for the currently selected API (Chat Completion or Chat Completion).',
returns: 'Model name.',
handler: ({ env }) => env.system.model,
});
MacroRegistry.registerMacro('original', {
category: MacroCategory.CHARACTER,
description: 'Original message content for {{original}} substitution in in character prompt overrides.',
returns: 'Original message content.',
handler: ({ env }) => {
const value = env.functions.original();
return value;
},
});
// Device / environment macros
MacroRegistry.registerMacro('isMobile', {
category: MacroCategory.STATE,
description: '"true" if currently running in a mobile environment, "false" otherwise.',
returns: 'Whether the environment is mobile.',
returnType: MacroValueType.BOOLEAN,
handler: () => String(isMobile()),
});
}

View File

@@ -0,0 +1,76 @@
import { MacroRegistry, MacroCategory } from '../engine/MacroRegistry.js';
import { power_user } from '../../power-user.js';
/**
* Registers instruct-mode related {{...}} macros (instruct* and system
* prompt/context macros) in the MacroRegistry.
*/
export function registerInstructMacros() {
/**
* Helper to register macros that just expose a value from power_user.instruct.
* The first name is the primary, subsequent names become visible aliases.
* @param {string[]} names - First is primary, rest are aliases.
* @param {() => string} getValue
* @param {() => boolean} isEnabled
* @param {string} description
* @param {string} [category=MacroCategory.PROMPTS]
*/
function registerSimple(names, getValue, isEnabled, description, category = MacroCategory.PROMPTS) {
const [primary, ...aliasNames] = names;
const aliases = aliasNames.map(alias => ({ alias }));
MacroRegistry.registerMacro(primary, {
category,
description,
aliases: aliases.length > 0 ? aliases : undefined,
handler: () => (isEnabled() ? (getValue() ?? '') : ''),
});
}
const instEnabled = () => !!power_user.instruct.enabled;
const sysEnabled = () => !!power_user.sysprompt.enabled;
// Instruct template macros
registerSimple(['instructStoryStringPrefix'], () => power_user.instruct.story_string_prefix, instEnabled, 'Instruct story string prefix.');
registerSimple(['instructStoryStringSuffix'], () => power_user.instruct.story_string_suffix, instEnabled, 'Instruct story string suffix.');
registerSimple(['instructUserPrefix', 'instructInput'], () => power_user.instruct.input_sequence, instEnabled, 'Instruct input / user prefix sequence.');
registerSimple(['instructUserSuffix'], () => power_user.instruct.input_suffix, instEnabled, 'Instruct input / user suffix sequence.');
registerSimple(['instructAssistantPrefix', 'instructOutput'], () => power_user.instruct.output_sequence, instEnabled, 'Instruct output / assistant prefix sequence.');
registerSimple(['instructAssistantSuffix', 'instructSeparator'], () => power_user.instruct.output_suffix, instEnabled, 'Instruct output / assistant suffix sequence.');
registerSimple(['instructSystemPrefix'], () => power_user.instruct.system_sequence, instEnabled, 'Instruct system prefix sequence.');
registerSimple(['instructSystemSuffix'], () => power_user.instruct.system_suffix, instEnabled, 'Instruct system suffix sequence.');
registerSimple(['instructFirstAssistantPrefix', 'instructFirstOutputPrefix'], () => power_user.instruct.first_output_sequence || power_user.instruct.output_sequence, instEnabled, 'Instruct first assistant / output prefix sequence');
registerSimple(['instructLastAssistantPrefix', 'instructLastOutputPrefix'], () => power_user.instruct.last_output_sequence || power_user.instruct.output_sequence, instEnabled, 'Instruct last assistant / output prefix sequence.');
registerSimple(['instructStop'], () => power_user.instruct.stop_sequence, instEnabled, 'Instruct stop sequence.');
registerSimple(['instructUserFiller'], () => power_user.instruct.user_alignment_message, instEnabled, 'Instruct user alignment filler.');
registerSimple(['instructSystemInstructionPrefix'], () => power_user.instruct.last_system_sequence, instEnabled, 'Instruct system instruction prefix sequence.');
registerSimple(['instructFirstUserPrefix', 'instructFirstInput'], () => power_user.instruct.first_input_sequence || power_user.instruct.input_sequence, instEnabled, 'Instruct first user / input prefix sequence.');
registerSimple(['instructLastUserPrefix', 'instructLastInput'], () => power_user.instruct.last_input_sequence || power_user.instruct.input_sequence, instEnabled, 'Instruct last user / input prefix sequence.');
// System prompt macros
registerSimple(['defaultSystemPrompt', 'instructSystem', 'instructSystemPrompt'], () => power_user.sysprompt.content, sysEnabled, 'Default system prompt.');
MacroRegistry.registerMacro('systemPrompt', {
category: MacroCategory.PROMPTS,
description: 'Active system prompt text (optionally overridden by character prompt)',
handler: ({ env }) => {
const isEnabled = !!power_user.sysprompt.enabled;
if (!isEnabled) return '';
if (power_user.prefer_character_prompt && env.character.charPrompt) {
return env.character.charPrompt;
}
return power_user.sysprompt.content ?? '';
},
});
// Context template macros
registerSimple(['exampleSeparator', 'chatSeparator'], () => power_user.context.example_separator, () => true, 'Separator used between example chat blocks in text completion prompts.');
registerSimple(['chatStart'], () => power_user.context.chat_start, () => true, 'Chat start marker used in text completion prompts.');
}

View File

@@ -0,0 +1,40 @@
import { MacroRegistry, MacroCategory } from '../engine/MacroRegistry.js';
import { eventSource, event_types } from '../../events.js';
let lastGenerationTypeValue = '';
let lastGenerationTypeTrackingInitialized = false;
function ensureLastGenerationTypeTracking() {
if (lastGenerationTypeTrackingInitialized) {
return;
}
lastGenerationTypeTrackingInitialized = true;
try {
eventSource?.on?.(event_types.GENERATION_STARTED, (type, _params, isDryRun) => {
if (isDryRun) return;
lastGenerationTypeValue = type || 'normal';
});
eventSource?.on?.(event_types.CHAT_CHANGED, () => {
lastGenerationTypeValue = '';
});
} catch {
// In non-runtime environments (tests), eventSource may be undefined or not fully initialized.
}
}
/**
* Registers macros that depend on runtime application state or event tracking
* rather than static environment fields.
*/
export function registerStateMacros() {
ensureLastGenerationTypeTracking();
MacroRegistry.registerMacro('lastGenerationType', {
category: MacroCategory.STATE,
description: 'Type of the last queued generation request (e.g. "normal", "impersonate", "regenerate", "quiet", "swipe", "continue"). Empty if none yet or chat was switched.',
returns: 'Type of the last queued generation request.',
handler: () => lastGenerationTypeValue,
});
}

View File

@@ -0,0 +1,151 @@
import { moment } from '../../../lib.js';
import { chat } from '../../../script.js';
import { timestampToMoment } from '../../utils.js';
import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
/**
* Registers time/date related macros and utilities.
*/
export function registerTimeMacros() {
// Time and date macros
MacroRegistry.registerMacro('time', {
category: MacroCategory.TIME,
// Optional single list argument: UTC offset, e.g. {{time::UTC+2}}
unnamedArgs: [
{
name: 'offset',
optional: true,
defaultValue: 'null',
type: MacroValueType.STRING,
sampleValue: 'UTC+2',
description: 'UTC offset in the format UTC±(offset).',
},
],
description: 'Current local time, or UTC offset when called as {{time::UTC±(offset)}}',
returns: 'A time string in the format HH:mm.',
displayOverride: '{{time::[UTC±(offset)]}}',
exampleUsage: ['{{time}}', '{{time::UTC+2}}', '{{time::UTC-7}}'],
handler: ({ unnamedArgs: [offsetSpec] }) => {
if (!offsetSpec) return moment().format('LT');
const match = /^UTC([+-]\d+)$/.exec(offsetSpec);
if (!match) return moment().format('LT');
const offset = Number.parseInt(match[1], 10);
if (Number.isNaN(offset)) return moment().format('LT');
return moment().utc().utcOffset(offset).format('LT');
},
});
MacroRegistry.registerMacro('date', {
category: MacroCategory.TIME,
description: 'Current local date as a string in the local short format.',
returns: 'Current local date in local short format.',
handler: () => moment().format('LL'),
});
MacroRegistry.registerMacro('weekday', {
category: MacroCategory.TIME,
description: 'Current weekday name.',
returns: 'Current weekday name.',
handler: () => moment().format('dddd'),
});
MacroRegistry.registerMacro('isotime', {
category: MacroCategory.TIME,
description: 'Current time in HH:mm format.',
returns: 'Current time in HH:mm format.',
handler: () => moment().format('HH:mm'),
});
MacroRegistry.registerMacro('isodate', {
category: MacroCategory.TIME,
description: 'Current date in YYYY-MM-DD format.',
returns: 'Current date in YYYY-MM-DD format.',
handler: () => moment().format('YYYY-MM-DD'),
});
MacroRegistry.registerMacro('datetimeformat', {
category: MacroCategory.TIME,
unnamedArgs: [
{
name: 'format',
sampleValue: 'YYYY-MM-DD HH:mm:ss',
description: 'Moment.js format string.',
type: 'string',
},
],
description: 'Formats the current date/time using the given moment.js format string.',
returns: 'Formatted date/time string.',
exampleUsage: ['{{datetimeformat::YYYY-MM-DD HH:mm:ss}}', '{{datetimeformat::LLLL}}'],
handler: ({ unnamedArgs: [format] }) => moment().format(format),
});
MacroRegistry.registerMacro('idleDuration', {
aliases: [{ alias: 'idle_duration', visible: false }],
category: MacroCategory.TIME,
description: 'Human-readable duration since the last user message.',
returns: 'Human-readable duration since the last user message.',
handler: () => getTimeSinceLastMessage(),
});
// Time difference between two values
MacroRegistry.registerMacro('timeDiff', {
category: MacroCategory.TIME,
unnamedArgs: [
{
name: 'left',
sampleValue: '2023-01-01 12:00:00',
description: 'Left time value.',
type: 'string',
},
{
name: 'right',
sampleValue: '2023-01-01 15:00:00',
description: 'Right time value.',
type: 'string',
},
],
description: 'Human-readable difference between two times. Order of times does not matter, it will return the absolute difference.',
returns: 'Human-readable difference between two times.',
displayOverride: '{{timeDiff::left::right}}', // Shorten this, otherwise it's too long. Full dates don't really help for understanding the macro.
exampleUsage: ['{{ timeDiff :: 2023-01-01 12:00:00 :: 2023-01-01 15:00:00 }}'],
handler: ({ unnamedArgs: [left, right] }) => {
const diff = moment.duration(moment(left).diff(moment(right)));
return diff.humanize(true);
},
});
}
function getTimeSinceLastMessage() {
const now = moment();
if (Array.isArray(chat) && chat.length > 0) {
let lastMessage;
let takeNext = false;
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (message.is_system) {
continue;
}
if (message.is_user && takeNext) {
lastMessage = message;
break;
}
takeNext = true;
}
if (lastMessage?.send_date) {
const lastMessageDate = timestampToMoment(lastMessage.send_date);
const duration = moment.duration(now.diff(lastMessageDate));
return duration.humanize();
}
}
return 'just now';
}

View File

@@ -0,0 +1,224 @@
import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
/**
* Registers variable-related {{...}} macros that operate on local and global
* variables (e.g. {{setvar}}, {{getvar}}, {{incvar}}, etc.).
*/
export function registerVariableMacros() {
const ctx = SillyTavern.getContext();
// {{setvar::name::value}} -> '' (side-effect on local variable)
MacroRegistry.registerMacro('setvar', {
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the local variable to set.',
},
{
name: 'value',
type: [MacroValueType.STRING, MacroValueType.NUMBER],
description: 'The value to set the local variable to.',
},
],
description: 'Sets a local variable to the given value.',
returns: '',
exampleUsage: ['{{setvar::myvar::foo}}', '{{setvar::myintvar::3}}'],
handler: ({ unnamedArgs: [name, value] }) => {
ctx.variables.local.set(name, value);
return '';
},
});
// {{addvar::name::value}} -> '' (side-effect via addLocalVariable)
MacroRegistry.registerMacro('addvar', {
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the local variable to add to.',
},
{
name: 'value',
type: [MacroValueType.STRING, MacroValueType.NUMBER],
description: 'The value to add to the local variable.',
},
],
description: 'Adds a value to an existing local variable (numeric or string append). If the variable does not exist, it will be created.',
returns: '',
exampleUsage: ['{{addvar::mystrvar::foo}}', '{{addvar::myintvar::3}}'],
handler: ({ unnamedArgs: [name, value] }) => {
ctx.variables.local.add(name, value);
return '';
},
});
// {{incvar::name}} -> returns new value
MacroRegistry.registerMacro('incvar', {
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the local variable to increment.',
},
],
description: 'Increments a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',
returns: 'The new value of the local variable.',
returnType: MacroValueType.NUMBER,
exampleUsage: ['{{incvar::myintvar}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.local.inc(name);
return normalize(result);
},
});
// {{decvar::name}} -> returns new value
MacroRegistry.registerMacro('decvar', {
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the local variable to decrement.',
},
],
description: 'Decrements a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',
returns: 'The new value of the local variable.',
returnType: MacroValueType.NUMBER,
exampleUsage: ['{{decvar::myintvar}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.local.dec(name);
return normalize(result);
},
});
// {{getvar::name}} -> returns current value
MacroRegistry.registerMacro('getvar', {
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the local variable to get.',
},
],
description: 'Gets the value of a local variable.',
returns: 'The value of the local variable.',
returnType: [MacroValueType.STRING, MacroValueType.NUMBER],
exampleUsage: ['{{getvar::myvar}}', '{{getvar::myintvar}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.local.get(name);
return normalize(result);
},
});
// {{setglobalvar::name::value}} -> ''
MacroRegistry.registerMacro('setglobalvar', {
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the global variable to set.',
},
{
name: 'value',
type: [MacroValueType.STRING, MacroValueType.NUMBER],
description: 'The value to set the global variable to.',
},
],
description: 'Sets a global variable to the given value.',
returns: '',
exampleUsage: ['{{setglobalvar::myvar::foo}}', '{{setglobalvar::myintvar::3}}'],
handler: ({ unnamedArgs: [name, value] }) => {
ctx.variables.global.set(name, value);
return '';
},
});
// {{addglobalvar::name::value}} -> ''
MacroRegistry.registerMacro('addglobalvar', {
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the global variable to add to.',
},
{
name: 'value',
type: [MacroValueType.STRING, MacroValueType.NUMBER],
description: 'The value to add to the global variable.',
},
],
description: 'Adds a value to an existing global variable (numeric or string append). If the variable does not exist, it will be created.',
returns: '',
exampleUsage: ['{{addglobalvar::mystrvar::foo}}', '{{addglobalvar::myintvar::3}}'],
handler: ({ unnamedArgs: [name, value] }) => {
ctx.variables.global.add(name, value);
return '';
},
});
// {{incglobalvar::name}} -> returns new value
MacroRegistry.registerMacro('incglobalvar', {
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the global variable to increment.',
},
],
description: 'Increments a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',
returns: 'The new value of the global variable.',
returnType: MacroValueType.NUMBER,
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.global.inc(name);
return normalize(result);
},
});
// {{decglobalvar::name}} -> returns new value
MacroRegistry.registerMacro('decglobalvar', {
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the global variable to decrement.',
},
],
description: 'Decrements a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',
returns: 'The new value of the global variable.',
returnType: MacroValueType.NUMBER,
exampleUsage: ['{{decglobalvar::myintvar}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.global.dec(name);
return normalize(result);
},
});
// {{getglobalvar::name}} -> returns current value
MacroRegistry.registerMacro('getglobalvar', {
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the global variable to get.',
},
],
description: 'Gets the value of a global variable.',
returns: 'The value of the global variable.',
returnType: [MacroValueType.STRING, MacroValueType.NUMBER],
exampleUsage: ['{{getglobalvar::myvar}}', '{{getglobalvar::myintvar}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.global.get(name);
return normalize(result);
},
});
}

View File

@@ -0,0 +1,433 @@
/** @typedef {import('chevrotain').CstNode} CstNode */
/** @typedef {import('chevrotain').IToken} IToken */
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
/**
* @typedef {Object} MacroCall
* @property {string} name
* @property {string[]} args
* @property {MacroEnv} env
* @property {string} rawInner
* @property {string} rawWithBraces
* @property {{ startOffset: number, endOffset: number }} range
* @property {CstNode} cstNode
*/
/**
* @typedef {Object} EvaluationContext
* @property {string} text
* @property {MacroEnv} env
* @property {(call: MacroCall) => string} resolveMacro
*/
/**
* @typedef {Object} TokenRange
* @property {number} startOffset
* @property {number} endOffset
*/
/**
* The singleton instance of the MacroCstWalker.
*
* @type {MacroCstWalker}
*/
let instance;
export { instance as MacroCstWalker };
class MacroCstWalker {
/** @type {MacroCstWalker} */ static #instance;
/** @type {MacroCstWalker} */ static get instance() { return MacroCstWalker.#instance ?? (MacroCstWalker.#instance = new MacroCstWalker()); }
constructor() { }
/**
* Evaluates a full document CST into a resolved string.
*
* @param {EvaluationContext & { cst: CstNode }} options
* @returns {string}
*/
evaluateDocument(options) {
const { text, cst, env, resolveMacro } = options;
if (typeof text !== 'string') {
throw new Error('MacroCstWalker.evaluateDocument: text must be a string');
}
if (!cst || typeof cst !== 'object' || !cst.children) {
throw new Error('MacroCstWalker.evaluateDocument: cst must be a CstNode');
}
if (typeof resolveMacro !== 'function') {
throw new Error('MacroCstWalker.evaluateDocument: resolveMacro must be a function');
}
/** @type {EvaluationContext} */
const context = { text, env, resolveMacro };
const items = this.#collectDocumentItems(cst);
if (items.length === 0) {
return text;
}
let result = '';
let cursor = 0;
// Iterate over all items in the document. Evaluate any macro being found, and keep them in the exact same place.
for (const item of items) {
if (item.startOffset > cursor) {
result += text.slice(cursor, item.startOffset);
}
// Items can be either plaintext or macro nodes
if (item.type === 'plaintext') {
result += text.slice(item.startOffset, item.endOffset + 1);
} else {
result += this.#evaluateMacroNode(item.node, context);
}
cursor = item.endOffset + 1;
}
if (cursor < text.length) {
result += text.slice(cursor);
}
return result;
}
/** @typedef {{ type: 'plaintext', startOffset: number, endOffset: number, token: IToken }} DocumentItemPlaintext */
/** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode }} DocumentItemMacro */
/** @typedef {DocumentItemPlaintext | DocumentItemMacro} DocumentItem */
/**
* Collects top-level plaintext tokens and macro nodes from the document CST.
*
* @param {CstNode} cst
* @returns {Array<DocumentItem>}
*/
#collectDocumentItems(cst) {
const plaintextTokens = /** @type {IToken[]} */ (cst.children.plaintext || []);
const macroNodes = /** @type {CstNode[]} */ (cst.children.macro || []);
/** @type {Array<DocumentItem>} */
const items = [];
for (const token of plaintextTokens) {
if (typeof token.startOffset !== 'number' || typeof token.endOffset !== 'number') {
continue;
}
items.push({
type: 'plaintext',
startOffset: token.startOffset,
endOffset: token.endOffset,
token,
});
}
for (const macroNode of macroNodes) {
const children = macroNode.children || {};
const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
// If the end token was inserted during error recovery, treat this macro as plaintext
if (this.#isRecoveryToken(endToken)) {
// Flatten the incomplete macro: collect its tokens as plaintext but keep nested macros
this.#flattenIncompleteMacro(macroNode, endToken, items);
continue;
}
const range = this.#getMacroRange(macroNode);
items.push({
type: 'macro',
startOffset: range.startOffset,
endOffset: range.endOffset,
node: macroNode,
});
}
items.sort((a, b) => {
if (a.startOffset !== b.startOffset) {
return a.startOffset - b.startOffset;
}
return a.endOffset - b.endOffset;
});
return items;
}
/**
* Evaluates a single macro CST node, resolving any nested macros first.
*
* @param {CstNode} macroNode
* @param {EvaluationContext} context
* @returns {string}
*/
#evaluateMacroNode(macroNode, context) {
const { text, env, resolveMacro } = context;
const children = macroNode.children || {};
const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);
const name = identifierTokens[0]?.image || '';
const range = this.#getMacroRange(macroNode);
const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]);
const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
const innerStart = startToken ? startToken.endOffset + 1 : range.startOffset;
const innerEnd = endToken ? endToken.startOffset - 1 : range.endOffset;
// Extract argument nodes from the "arguments" rule (if present)
const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]);
const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
/** @type {string[]} */
const args = [];
/** @type {({ value: string } & TokenRange)[]} */
const evaluatedArguments = [];
for (const argNode of argumentNodes) {
const argValue = this.#evaluateArgumentNode(argNode, context);
args.push(argValue);
const location = this.#getArgumentLocation(argNode);
if (location) {
evaluatedArguments.push({
value: argValue,
...location,
});
}
}
evaluatedArguments.sort((a, b) => a.startOffset - b.startOffset);
// Build the inner raw string between the braces, with nested macros resolved.
// This uses the already evaluated argument strings and preserves any text
// between arguments (such as separators or whitespace).
let rawInner = '';
if (innerStart <= innerEnd) {
let cursor = innerStart;
for (const entry of evaluatedArguments) {
if (entry.startOffset > cursor) {
rawInner += text.slice(cursor, entry.startOffset);
}
rawInner += entry.value;
cursor = entry.endOffset + 1;
}
if (cursor <= innerEnd) {
rawInner += text.slice(cursor, innerEnd + 1);
}
}
/** @type {MacroCall} */
const call = {
name,
args,
rawInner,
rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),
range,
cstNode: macroNode,
env,
};
const value = resolveMacro(call);
const stringValue = typeof value === 'string' ? value : String(value ?? '');
return stringValue;
}
/**
* Evaluates a single argument node by resolving nested macros and reconstructing
* the original argument text.
*
* @param {CstNode} argNode
* @param {EvaluationContext} context
* @returns {string}
*/
#evaluateArgumentNode(argNode, context) {
const location = this.#getArgumentLocation(argNode);
if (!location) {
return '';
}
const { text } = context;
const nestedMacros = /** @type {CstNode[]} */ ((argNode.children || {}).macro || []);
// If there are no nested macros, we can just return the original text
if (nestedMacros.length === 0) {
return text.slice(location.startOffset, location.endOffset + 1);
}
// If there are macros, evaluate them one by one in appearing order, inside the argument, before we return the resolved argument
const nestedWithRange = nestedMacros.map(node => ({
node,
range: this.#getMacroRange(node),
}));
nestedWithRange.sort((a, b) => a.range.startOffset - b.range.startOffset);
let result = '';
let cursor = location.startOffset;
for (const entry of nestedWithRange) {
if (entry.range.startOffset < cursor) {
continue;
}
result += text.slice(cursor, entry.range.startOffset);
result += this.#evaluateMacroNode(entry.node, context);
cursor = entry.range.endOffset + 1;
}
if (cursor <= location.endOffset) {
result += text.slice(cursor, location.endOffset + 1);
}
return result;
}
/**
* Computes the character range of a macro node based on its start/end tokens
* or its own location if those are not available.
*
* @param {CstNode} macroNode
* @returns {TokenRange}
*/
#getMacroRange(macroNode) {
const startToken = /** @type {IToken?} */ (((macroNode.children || {})['Macro.Start'] || [])[0]);
const endToken = /** @type {IToken?} */ (((macroNode.children || {})['Macro.End'] || [])[0]);
if (startToken && endToken) {
return { startOffset: startToken.startOffset, endOffset: endToken.endOffset };
}
if (macroNode.location) {
return { startOffset: macroNode.location.startOffset, endOffset: macroNode.location.endOffset };
}
return { startOffset: 0, endOffset: 0 };
}
/**
* Flattens an incomplete macro node into document items.
* Tokens from the incomplete macro become plaintext, but nested complete macros are preserved.
*
* @param {CstNode} macroNode
* @param {IToken} excludeToken - The recovery-inserted token to exclude
* @param {Array<DocumentItem>} items - The items array to add to
*/
#flattenIncompleteMacro(macroNode, excludeToken, items) {
const children = macroNode.children || {};
for (const key of Object.keys(children)) {
for (const element of children[key] || []) {
// Skip the recovery-inserted token
if (element === excludeToken) continue;
// Handle IToken - add as plaintext
if ('startOffset' in element && typeof element.startOffset === 'number') {
items.push({
type: 'plaintext',
startOffset: element.startOffset,
endOffset: element.endOffset ?? element.startOffset,
token: element,
});
}
// Handle nested CstNode (macro or argument)
else if ('children' in element) {
const nestedChildren = element.children || {};
const nestedEnd = /** @type {IToken?} */ ((nestedChildren['Macro.End'] || [])[0]);
const nestedStart = /** @type {IToken?} */ ((nestedChildren['Macro.Start'] || [])[0]);
// Check if this is a complete macro node
if (nestedStart && nestedEnd) {
if (!this.#isRecoveryToken(nestedEnd)) {
// Complete nested macro - add as macro item
const range = this.#getMacroRange(element);
items.push({
type: 'macro',
startOffset: range.startOffset,
endOffset: range.endOffset,
node: element,
});
} else {
// Another incomplete nested macro - recurse
this.#flattenIncompleteMacro(element, nestedEnd, items);
}
} else {
// Not a macro node (e.g., arguments, argument) - recurse into it
this.#flattenIncompleteMacro(element, excludeToken, items);
}
}
}
}
}
/**
* Checks if a token was inserted during Chevrotain's error recovery.
* Recovery tokens have `isInsertedInRecovery=true` or invalid offset values.
*
* @param {IToken|null|undefined} token
* @returns {boolean}
*/
#isRecoveryToken(token) {
return token?.isInsertedInRecovery === true
|| typeof token?.startOffset !== 'number'
|| Number.isNaN(token?.startOffset);
}
/**
* Computes the character range of an argument node based on all its child
* tokens and nested macros.
*
* @param {CstNode} argNode
* @returns {TokenRange|null}
*/
#getArgumentLocation(argNode) {
const children = argNode.children || {};
let startOffset = Number.POSITIVE_INFINITY;
let endOffset = Number.NEGATIVE_INFINITY;
for (const key of Object.keys(children)) {
for (const element of children[key] || []) {
if (this.#isCstNode(element)) {
const location = element.location;
if (!location) {
continue;
}
if (location.startOffset < startOffset) {
startOffset = location.startOffset;
}
if (location.endOffset > endOffset) {
endOffset = location.endOffset;
}
} else if (element) {
if (element.startOffset < startOffset) {
startOffset = element.startOffset;
}
if (element.endOffset > endOffset) {
endOffset = element.endOffset;
}
}
}
}
if (!Number.isFinite(startOffset) || !Number.isFinite(endOffset)) {
return null;
}
return { startOffset, endOffset };
}
/**
* Determines whether the given value is a CST node.
*
* @param {any} value
* @returns {value is CstNode}
*/
#isCstNode(value) {
return !!value && typeof value === 'object' && 'name' in value && 'children' in value;
}
}
instance = MacroCstWalker.instance;

View File

@@ -0,0 +1,197 @@
/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
/** @typedef {import('chevrotain').ILexingError} ILexingError */
/** @typedef {import('chevrotain').IRecognitionException} IRecognitionException */
/**
* @typedef {Object} MacroErrorContext
* @property {string} [macroName]
* @property {MacroCall} [call]
* @property {MacroDefinition} [def]
*/
/**
* Options for creating a macro runtime error.
*
* @typedef {MacroErrorContext & { message: string }} MacroRuntimeErrorOptions
*/
/**
* Options for logging macro warnings or errors.
*
* @typedef {MacroErrorContext & { message: string, error?: any }} MacroLogOptions
*/
/**
* Creates an error representing a runtime macro invocation problem (such as
* arity or type mismatches). These errors are intended to be caught by the
* MacroEngine, which will log them as runtime warnings and leave the macro
* raw in the evaluated text.
*
* @param {MacroRuntimeErrorOptions} options
* @returns {Error}
*/
export function createMacroRuntimeError({ message, call, def, macroName }) {
const inferredName = inferMacroName(call, def, macroName);
const error = new Error(message);
error.name = 'MacroRuntimeError';
// @ts-ignore - custom tagging for downstream classification
error.isMacroRuntimeError = true;
// @ts-ignore - helpful metadata for debugging
error.macroName = inferredName;
// @ts-ignore - best-effort location information
error.macroRange = call && call.range ? call.range : null;
// @ts-ignore - attach raw call/definition for convenience
if (call) error.macroCall = call;
// @ts-ignore
if (def) error.macroDefinition = def;
return error;
}
/**
* Logs a macro runtime warning with consistent, helpful context. These
* correspond to issues in how a macro was written in the text (e.g. invalid
* arguments), not bugs in macro definitions or the engine itself.
*
* @param {MacroLogOptions} options
*/
export function logMacroRuntimeWarning({ message, call, def, macroName, error }) {
const payload = buildMacroPayload({ call, def, macroName, error });
console.warn('[Macro] Warning:', message, payload);
}
/**
* Logs an internal macro error (definition or engine bug) with a consistent
* schema. These are surfaced as red errors in the console.
*
* @param {MacroLogOptions} options
*/
export function logMacroInternalError({ message, call, macroName, error }) {
const payload = buildMacroPayload({ call, def: undefined, macroName, error });
console.error('[Macro] Error:', message, payload);
}
/**
* Logs a warning during macro registration.
*
* @param {{ message: string, macroName?: string, error?: any }} options
*/
export function logMacroRegisterWarning({ message, macroName, error = undefined }) {
const payload = buildMacroPayload({ macroName, error });
console.warn('[Macro] Warning:', message, payload);
}
/**
* Logs an error during macro registration. Used when registration fails
* and the macro will not be available.
*
* @param {{ message: string, macroName?: string, error?: any }} options
*/
export function logMacroRegisterError({ message, macroName, error = undefined }) {
const payload = buildMacroPayload({ macroName, error });
console.error('[Macro] Registration Error:', message, payload);
}
/**
* Logs a macro error with a consistent schema.
*
* @param {{ message: string, error?: any }} options
*/
export function logMacroGeneralError({ message, error }) {
console.error('[Macro] Error:', message, error);
}
/**
* Logs lexer/parser syntax warnings for the macro engine with a compact,
* human-readable payload.
*
* @param {{ phase: 'lexing', input: string, errors: ILexingError[] }|{ phase: 'parsing', input: string, errors: IRecognitionException[] }} options
*/
export function logMacroSyntaxWarning({ phase, input, errors }) {
if (!errors || errors.length === 0) {
return;
}
/** @type {{ message: string, line: number|null, column: number|null, length: number|null }[]} */
const issues = errors.map((err) => {
const hasOwnLine = typeof err.line === 'number';
const hasOwnColumn = typeof err.column === 'number';
const token = /** @type {{ startLine?: number, startColumn?: number, startOffset?: number, endOffset?: number }|undefined} */ (err.token);
const line = hasOwnLine ? err.line : (token && typeof token.startLine === 'number' ? token.startLine : null);
const column = hasOwnColumn ? err.column : (token && typeof token.startColumn === 'number' ? token.startColumn : null);
/** @type {number|null} */
let length = null;
if (typeof err.length === 'number') {
length = err.length;
} else if (token && typeof token.startOffset === 'number' && typeof token.endOffset === 'number') {
length = token.endOffset - token.startOffset + 1;
}
return {
message: err.message,
line,
column,
length,
};
});
const label = phase === 'lexing' ? 'Lexing' : 'Parsing';
/** @type {Record<string, any>} */
const payload = {
phase,
count: issues.length,
issues,
input,
};
console.warn('[Macro] Warning:', `${label} errors detected`, payload);
}
/**
* Builds a structured payload for macro logging.
*
* @param {MacroErrorContext & { error?: any }} ctx
*/
function buildMacroPayload({ call, def, macroName, error }) {
const inferredName = inferMacroName(call, def, macroName);
/** @type {Record<string, any>} */
const payload = {
macroName: inferredName,
};
if (call && call.range) payload.range = call.range;
if (call && typeof call.rawInner === 'string') payload.raw = call.rawInner;
if (call) payload.call = call;
if (def) payload.def = def;
if (error) payload.error = error;
return payload;
}
/**
* Infers the most appropriate macro name from the available context.
*
* @param {MacroCall} [call]
* @param {MacroDefinition} [def]
* @param {string} [explicit]
* @returns {string}
*/
function inferMacroName(call, def, explicit) {
if (typeof explicit === 'string' && explicit.trim()) {
return explicit.trim();
}
if (call && typeof call.name === 'string' && call.name.trim()) {
return call.name.trim();
}
if (def && typeof def.name === 'string' && def.name.trim()) {
return def.name.trim();
}
return 'unknown';
}

View File

@@ -0,0 +1,212 @@
import { MacroParser } from './MacroParser.js';
import { MacroCstWalker } from './MacroCstWalker.js';
import { MacroRegistry } from './MacroRegistry.js';
import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
/**
* The singleton instance of the MacroEngine.
*
* @type {MacroEngine}
*/
let instance;
export { instance as MacroEngine };
class MacroEngine {
/** @type {MacroEngine} */ static #instance;
/** @type {MacroEngine} */ static get instance() { return MacroEngine.#instance ?? (MacroEngine.#instance = new MacroEngine()); }
constructor() { }
/**
* Evaluates a string containing macros and resolves them.
*
* @param {string} input - The input string to evaluate.
* @param {MacroEnv} env - The environment to pass to the macro handler.
* @returns {string} The resolved string.
*/
evaluate(input, env) {
if (!input) {
return '';
}
const safeEnv = Object.freeze({ ...env });
const preProcessed = this.#runPreProcessors(input, safeEnv);
const { cst, lexingErrors, parserErrors } = MacroParser.parseDocument(preProcessed);
// For now, we log and still try to process what we can.
if (lexingErrors && lexingErrors.length > 0) {
logMacroSyntaxWarning({ phase: 'lexing', input, errors: lexingErrors });
}
if (parserErrors && parserErrors.length > 0) {
logMacroSyntaxWarning({ phase: 'parsing', input, errors: parserErrors });
}
// If the parser did not produce a valid CST, fall back to the original input.
if (!cst || typeof cst !== 'object' || !cst.children) {
logMacroGeneralError({ message: 'Macro parser produced an invalid CST. Returning original input.', error: { input, lexingErrors, parserErrors } });
return input;
}
let evaluated;
try {
evaluated = MacroCstWalker.evaluateDocument({
text: preProcessed,
cst,
env: safeEnv,
resolveMacro: this.#resolveMacro.bind(this),
});
} catch (error) {
logMacroGeneralError({ message: 'Macro evaluation failed. Returning original input.', error: { input, error } });
return input;
}
const result = this.#runPostProcessors(evaluated, safeEnv);
return result;
}
/**
* Resolves a macro call.
*
* @param {MacroCall} call - The macro call to resolve.
* @returns {string} The resolved macro.
*/
#resolveMacro(call) {
const { name, env } = call;
const raw = `{{${call.rawInner}}}`;
if (!name) return raw;
// First check if this is a dynamic macro to use. If so, we will create a temporary macro definition for it and use that over any registered macro.
/** @type {MacroDefinition?} */
let defOverride = null;
if (Object.hasOwn(env.dynamicMacros, name)) {
const impl = env.dynamicMacros[name];
defOverride = {
name,
aliases: [],
category: 'dynamic',
description: 'Dynamic macro',
minArgs: 0,
maxArgs: 0,
unnamedArgDefs: [],
list: null,
strictArgs: true, // Fail dynamic macros if they are called with arguments
returns: null,
returnType: 'string',
displayOverride: null,
exampleUsage: [],
source: { name: 'dynamic', isExtension: false, isThirdParty: false },
aliasOf: null,
aliasVisible: null,
handler: typeof impl === 'function' ? impl : () => impl,
};
}
// If not, check if the macro exists and is registered
if (!defOverride && !MacroRegistry.hasMacro(name)) {
return raw; // Unknown macro: keep macro syntax, but nested macros inside rawInner are already resolved.
}
try {
const result = MacroRegistry.executeMacro(call, { defOverride });
try {
return call.env.functions.postProcess(result);
} catch (error) {
logMacroInternalError({ message: `Macro "${name}" postProcess function failed.`, call, error });
return result;
}
} catch (error) {
const isRuntimeError = !!(error && (error.name === 'MacroRuntimeError' || error.isMacroRuntimeError));
if (isRuntimeError) {
logMacroRuntimeWarning({ message: (error.message || `Macro "${name}" execution failed.`), call, error });
} else {
logMacroInternalError({ message: `Macro "${name}" internal execution error.`, call, error });
}
return raw;
}
}
/**
* Runs pre-processors on the input text, before the engine processes the input.
*
* @param {string} text - The input text to process.
* @param {MacroEnv} env - The environment to pass to the macro handler.
* @returns {string} The processed text.
*/
#runPreProcessors(text, env) {
let result = text;
// This legacy macro will not be supported by the new macro parser, but rather regex-replaced beforehand
// {{time_UTC-10}} => {{time::UTC-10}}
result = result.replace(/{{time_(UTC[+-]\d+)}}/gi, (_match, utcOffset) => {
return `{{time::${utcOffset}}}`;
});
// Legacy non-curly markers like <USER>, <BOT>, <GROUP>, etc.
// These are rewritten into their equivalent macro forms so they go through the normal engine pipeline.
result = result.replace(/<USER>/gi, '{{user}}');
result = result.replace(/<BOT>/gi, '{{char}}');
result = result.replace(/<CHAR>/gi, '{{char}}');
result = result.replace(/<GROUP>/gi, '{{group}}');
result = result.replace(/<CHARIFNOTGROUP>/gi, '{{charIfNotGroup}}');
return result;
}
/**
* Runs post-processors on the input text, after the engine finished processing the input.
*
* @param {string} text - The input text to process.
* @param {MacroEnv} env - The environment to pass to the macro handler.
* @returns {string} The processed text.
*/
#runPostProcessors(text, env) {
let result = text;
// Unescape braces: \{ → { and \} → }
// Since \{\{ doesn't match {{ (MacroStart), it passes through as plain text.
// We only need to remove the backslashes in post-processing.
result = result.replace(/\\([{}])/g, '$1');
// The original trim macro is reaching over the boundaries of the defined macro. This is not something the engine supports.
// To treat {{trim}} as it was before, we won't process it by the engine itself,
// but doing a regex replace on {{trim}} and the surrounding area, after all other macros have been processed.
result = result.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, '');
return result;
}
/**
* Normalizes macro results into a string.
* This mirrors the behavior of the legacy macro system in a simplified way.
*
* @param {any} value
* @returns {string}
*/
normalizeMacroResult(value) {
if (value === null || value === undefined) {
return '';
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'object' || Array.isArray(value)) {
try {
return JSON.stringify(value);
} catch (_error) {
return String(value);
}
}
return String(value);
}
}
instance = MacroEngine.instance;

View File

@@ -0,0 +1,57 @@
/**
* Shared typedefs for the structured macro environment object (MacroEnv)
* used by the macro engine, registry, env builder, and macro definition
* modules. This file intentionally only contains JSDoc typedefs so that
* it can be imported purely for type information from multiple modules
* without creating runtime dependencies.
*/
/** @typedef {import('./MacroRegistry.js').MacroHandler} MacroHandler */
/**
* @typedef {Object} MacroEnvNames
* @property {string} user
* @property {string} char
* @property {string} group
* @property {string} groupNotMuted
* @property {string} notChar
*/
/**
* @typedef {Object} MacroEnvCharacter
* @property {string} [description]
* @property {string} [personality]
* @property {string} [scenario]
* @property {string} [persona]
* @property {string} [charPrompt]
* @property {string} [charInstruction]
* @property {string} [mesExamplesRaw]
* @property {string} [charDepthPrompt]
* @property {string} [creatorNotes]
* @property {string} [version]
*/
/**
* @typedef {Object} MacroEnvSystem
* @property {string} model
*/
/**
* @typedef {Object} MacroEnvFunctions
* @property {() => string} [original]
* @property {(text: string) => string} postProcess
*/
/**
* @typedef {Object} MacroEnv
* @property {string} content - The full original input string that is being processed by the macro engine. This is the same value as substituteParams "content" and is provided so macros can build deterministic behavior based on the whole prompt when needed.
* @property {number} contentHash - A hash of the content string, used for caching and comparison.
* @property {MacroEnvNames} names
* @property {MacroEnvCharacter} character
* @property {MacroEnvSystem} system
* @property {MacroEnvFunctions} functions
* @property {Object<string, string|MacroHandler>} dynamicMacros
* @property {Record<string, unknown>} extra
*/
export {};

View File

@@ -0,0 +1,199 @@
import { name1, name2, characters, getCharacterCardFieldsLazy, getGeneratingModel } from '../../../script.js';
import { groups, selected_group } from '../../../scripts/group-chats.js';
import { logMacroGeneralError } from './MacroDiagnostics.js';
import { getStringHash } from '/scripts/utils.js';
/**
* MacroEnvBuilder is responsible for constructing the MacroEnv object
* that is passed to macro handlers.
*
* It does **not** depend on the legacy regex macro system. Instead, it
* works from the same raw inputs that substituteParams receives plus a
* small bundle of global helpers, so it can eventually replace the
* environment-building block in substituteParams.
*/
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
/**
* @typedef {Object} MacroEnvRawContext
* @property {string} content
* @property {string|null} [name1Override]
* @property {string|null} [name2Override]
* @property {string|null} [original]
* @property {string|null} [groupOverride]
* @property {boolean} [replaceCharacterCard]
* @property {Record<string, any>|null} [dynamicMacros]
* @property {(value: string) => string} [postProcessFn]
*/
/**
* @typedef {(env: MacroEnv, ctx: MacroEnvRawContext) => void} MacroEnvProvider
*/
/**
* @enum {number} Exposed ordering buckets for providers. Callers can use envBuilder.providerOrder.* when registering providers.
*/
export const env_provider_order = {
EARLIEST: 0,
EARLY: 10,
NORMAL: 50,
LATE: 90,
LATEST: 100,
};
/** @type {MacroEnvBuilder} */
let instance;
export { instance as MacroEnvBuilder };
class MacroEnvBuilder {
/** @type {MacroEnvBuilder} */ static #instance;
/** @type {MacroEnvBuilder} */ static get instance() { return MacroEnvBuilder.#instance ?? (MacroEnvBuilder.#instance = new MacroEnvBuilder()); }
/** @type {{ fn: MacroEnvProvider, order: env_provider_order }[]} */
#providers;
constructor() {
this.#providers = [];
}
/**
* Registers a provider that can augment the MacroEnv with additional
* data (for extensions, extra context, etc.).
*
* Should be called once during initialization.
*
* @param {MacroEnvProvider} provider
* @param {env_provider_order} [order=env_provider_order.NORMAL]
* @returns {void}
*/
registerProvider(provider, order = env_provider_order.NORMAL) {
if (typeof provider !== 'function') throw new Error('Provider must be a function');
this.#providers.push({ fn: provider, order });
}
/**
* Builds a MacroEnv from the raw arguments that are conceptually the
* same as substituteParams receives, plus a bundle of global helpers.
*
* @param {MacroEnvRawContext} ctx
* @returns {MacroEnv}
*/
buildFromRawEnv(ctx) {
// Create the env first, we will populate it step by step.
// Some fields are marked as required, so we have to fill them with dummy fields here
/** @type {MacroEnv} */
const env = {
content: ctx.content,
contentHash: getStringHash(ctx.content),
names: { user: '', char: '', group: '', groupNotMuted: '', notChar: '' },
character: {},
system: { model: '' },
functions: { postProcess: (x) => x },
dynamicMacros: {},
extra: {},
};
if (ctx.replaceCharacterCard) {
// Use lazy fields - each property is only resolved when accessed
const fields = getCharacterCardFieldsLazy();
if (fields) {
// Define lazy getters on env.character that delegate to fields
const fieldMappings = /** @type {const} */ ([
['charPrompt', 'system'],
['charInstruction', 'jailbreak'],
['description', 'description'],
['personality', 'personality'],
['scenario', 'scenario'],
['persona', 'persona'],
['mesExamplesRaw', 'mesExamples'],
['version', 'version'],
['charDepthPrompt', 'charDepthPrompt'],
['creatorNotes', 'creatorNotes'],
]);
for (const [envKey, fieldKey] of fieldMappings) {
Object.defineProperty(env.character, envKey, {
get() { return fields[fieldKey] || ''; },
enumerable: true,
configurable: true,
});
}
}
}
// Names
env.names.user = ctx.name1Override ?? name1 ?? '';
env.names.char = ctx.name2Override ?? name2 ?? '';
env.names.group = getGroupValue(ctx, { currentChar: env.names.char, includeMuted: true });
env.names.groupNotMuted = getGroupValue(ctx, { currentChar: env.names.char, includeMuted: false });
env.names.notChar = getGroupValue(ctx, { currentChar: env.names.char, filterOutChar: true, includeUser: env.names.user });
// System
env.system.model = getGeneratingModel();
// Functions
// original (one-shot) and arbitrary additional values
if (typeof ctx.original === 'string') {
let originalSubstituted = false;
env.functions.original = () => {
if (originalSubstituted) return '';
originalSubstituted = true;
return ctx.original;
};
}
env.functions.postProcess = typeof ctx.postProcessFn === 'function' ? ctx.postProcessFn : (x) => x;
// Dynamic, per-call macros that should be visible only for this evaluation run.
if (ctx.dynamicMacros && typeof ctx.dynamicMacros === 'object') {
env.dynamicMacros = { ...ctx.dynamicMacros };
}
// Let providers augment the env, if any are registered. Apply them in order,
// so callers can influence when their provider runs relative to others.
const orderedProviders = this.#providers.slice().sort((a, b) => a.order - b.order);
for (const { fn } of orderedProviders) {
try {
fn(env, ctx);
} catch (e) {
// Provider errors should not break macro evaluation
logMacroGeneralError({ message: 'MacroEnvBuilder: Provider error', error: e });
}
}
return env;
}
}
instance = MacroEnvBuilder.instance;
/**
* @param {MacroEnvRawContext} ctx
* @param {Object} options
* @param {string} [options.currentChar=null]
* @param {boolean} [options.includeMuted=false]
* @param {boolean} [options.filterOutChar=false]
* @param {string|null} [options.includeUser=null]
* @returns {string}
*/
function getGroupValue(ctx, { currentChar = null, includeMuted = false, filterOutChar = false, includeUser = null }) {
if (typeof ctx.groupOverride === 'string') {
return ctx.groupOverride;
}
if (!selected_group) return filterOutChar ? (includeUser || '') : (currentChar ?? '');
const groupEntry = Array.isArray(groups) ? groups.find(x => x && x.id === selected_group) : null;
const members = /** @type {string[]} */ (groupEntry?.members ?? []);
const disabledMembers = /** @type {string[]} */ (groupEntry?.disabled_members ?? []);
const names = Array.isArray(members)
? members
.filter(((id) => includeMuted ? true : !disabledMembers.includes(id)))
.map(m => Array.isArray(characters) ? characters.find(c => c && c.avatar === m) : null)
.filter(c => !!c && typeof c.name === 'string')
.filter(c => !filterOutChar || c.name !== currentChar)
.map(c => c.name)
.join(', ')
: '';
return names;
}

View File

@@ -0,0 +1,239 @@
import { chevrotain } from '../../../lib.js';
const { createToken, Lexer } = chevrotain;
/** @typedef {import('chevrotain').TokenType} TokenType */
/** @enum {string} */
const modes = {
plaintext: 'plaintext_mode',
macro_def: 'macro_def_mode',
macro_identifier_end: 'macro_identifier_end_mode',
macro_args: 'macro_args_mode',
macro_filter_modifer: 'macro_filter_modifer_mode',
macro_filter_modifier_end: 'macro_filter_modifier_end_mode',
};
/** @readonly */
const Tokens = {
// General capture-all plaintext without macros. Consumes any character that is not the first '{' of a macro opener '{{'.
Plaintext: createToken({ name: 'Plaintext', pattern: /(?:[^{]|\{(?!\{))+/u, line_breaks: true }),
// Single literal '{' that appears immediately before a macro opener '{{'.
PlaintextOpenBrace: createToken({ name: 'Plaintext.OpenBrace', pattern: /\{(?=\{\{)/ }),
// General macro capture
Macro: {
Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }),
// Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name"
// We need this, because this token is going to switch lexer mode, while the general identifier does not.
Flags: createToken({ name: 'Macro.Flag', pattern: /[!?#~/.$]/ }),
DoubleSlash: createToken({ name: 'Macro.DoubleSlash', pattern: /\/\// }),
Identifier: createToken({ name: 'Macro.Identifier', pattern: /[a-zA-Z][\w-_]*/ }),
// At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces
EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),
End: createToken({ name: 'Macro.End', pattern: /\}\}/ }),
},
// Captures that only appear inside arguments
Args: {
DoubleColon: createToken({ name: 'Args.DoubleColon', pattern: /::/ }),
Colon: createToken({ name: 'Args.Colon', pattern: /:/ }),
Equals: createToken({ name: 'Args.Equals', pattern: /=/ }),
Quote: createToken({ name: 'Args.Quote', pattern: /"/ }),
},
Filter: {
EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),
Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),
Identifier: createToken({ name: 'Filter.Identifier', pattern: /[a-zA-Z][\w-_]*/ }),
// At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces
EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
},
// All tokens that can be captured inside a macro
Identifier: createToken({ name: 'Identifier', pattern: /[a-zA-Z][\w-_]*/ }),
WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }),
// Capture unknown characters one by one, to still allow other tokens being matched once they are there.
// This includes any possible braces that is not the double closing braces as MacroEnd.
Unknown: createToken({ name: 'Unknown', pattern: /([^}]|\}(?!\}))/ }),
// TODO: Capture-all rest for now, that is not the macro end or opening of a new macro. Might be replaced later down the line.
Text: createToken({ name: 'Text', pattern: /.+(?=\}\}|\{\{)/, line_breaks: true }),
// DANGER ZONE: Careful with this token. This is used as a way to pop the current mode, if no other token matches.
// Can be used in modes that don't have a "defined" end really, like when capturing a single argument, argument list, etc.
// Has to ALWAYS be the last token.
ModePopper: createToken({ name: 'ModePopper', pattern: () => [''], line_breaks: false, group: Lexer.SKIPPED }),
};
/** @type {Map<string,string>} Saves all token definitions that are marked as entering modes */
const enterModesMap = new Map();
const Def = {
modes: {
[modes.plaintext]: [
using(Tokens.Plaintext),
using(Tokens.PlaintextOpenBrace),
enter(Tokens.Macro.Start, modes.macro_def),
],
[modes.macro_def]: [
exits(Tokens.Macro.End, modes.macro_def),
// An explicit double-slash will be treated above flags to consume, as it'll introduce a comment macro. Directly following is the args then.
enter(Tokens.Macro.DoubleSlash, modes.macro_args),
using(Tokens.Macro.Flags),
// We allow whitspaces inbetween flags or in front of the modifier
using(Tokens.WhiteSpace),
// Inside a macro, we will match the identifier
// Enter 'macro_identifier_end' mode automatically at the end of the identifier, so we don't match more than one identifier
enter(Tokens.Macro.Identifier, modes.macro_identifier_end),
// If none of the tokens above are found, this is an invalid macro at runtime.
// We still need to exit the mode to prevent lexer errors
exits(Tokens.ModePopper, modes.macro_def),
],
[modes.macro_identifier_end]: [
// Valid options after a macro identifier: whitespace, colon/double-colon (captured), macro end braces, or output modifier pipe.
exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end),
enter(Tokens.Macro.EndOfIdentifier, modes.macro_args, { andExits: modes.macro_identifier_end }),
],
[modes.macro_args]: [
// Macro args allow nested macros
enter(Tokens.Macro.Start, modes.macro_def),
// We allow escaped pipes to not start output modifiers. We need to capture this first, before the pipe
using(Tokens.Filter.EscapedPipe),
// If at any place during args writing there is a pipe, we lex it as an output identifier, and then continue with lex its args
enter(Tokens.Filter.Pipe, modes.macro_filter_modifer),
using(Tokens.Args.DoubleColon),
using(Tokens.Args.Colon),
using(Tokens.Args.Equals),
using(Tokens.Args.Quote),
using(Tokens.Identifier),
using(Tokens.WhiteSpace),
// Last fallback, before we need to exit the mode, as we might have characters we (wrongly) haven't defined yet
using(Tokens.Unknown),
// Args are optional, and we don't know how long, so exit the mode to be able to capture the actual macro end
exits(Tokens.ModePopper, modes.macro_args),
],
[modes.macro_filter_modifer]: [
using(Tokens.WhiteSpace),
enter(Tokens.Filter.Identifier, modes.macro_filter_modifier_end, { andExits: modes.macro_filter_modifer }),
],
[modes.macro_filter_modifier_end]: [
// Valid options after a filter itenfier: whitespace, colon/double-colon (captured), macro end braces, or output modifier pipe.
exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end),
exits(Tokens.Filter.EndOfIdentifier, modes.macro_filter_modifer),
],
},
defaultMode: modes.plaintext,
};
/**
* The singleton instance of the MacroLexer.
*
* @type {MacroLexer}
*/
let instance;
export { instance as MacroLexer };
class MacroLexer extends Lexer {
/** @type {MacroLexer} */ static #instance;
/** @type {MacroLexer} */ static get instance() { return MacroLexer.#instance ?? (MacroLexer.#instance = new MacroLexer()); }
// Define the tokens
/** @readonly */ static tokens = Tokens;
/** @readonly */ static def = Def;
/** @readonly */ tokens = Tokens;
/** @readonly */ def = MacroLexer.def;
/** @private */
constructor() {
super(MacroLexer.def, {
traceInitPerf: false,
});
}
test(input) {
const result = this.tokenize(input);
return {
errors: result.errors,
groups: result.groups,
tokens: result.tokens.map(({ tokenType, ...rest }) => ({ type: tokenType.name, ...rest, tokenType: tokenType })),
};
}
}
instance = MacroLexer.instance;
/**
* [Utility]
* Set push mode on the token definition.
* Can be used inside the token mode definition block.
*
* Marks the token to **enter** the following lexer mode.
*
* Optionally, you can specify the modes to exit when entering this mode.
*
* @param {TokenType} token - The token to modify
* @param {string} mode - The mode to set
* @param {object} [options={}] - Additional options
* @param {string} [options.andExits] - The modes to exit when entering this mode
* @returns {TokenType} The token again
*/
function enter(token, mode, { andExits = undefined } = {}) {
if (!token) throw new Error('Token must not be undefined');
if (enterModesMap.has(token.name) && enterModesMap.get(token.name) !== mode) {
throw new Error(`Token ${token.name} already is set to enter mode ${enterModesMap.get(token.name)}. The token definition are global, so they cannot be used to lead to different modes.`);
}
if (andExits) exits(token, andExits);
token.PUSH_MODE = mode;
enterModesMap.set(token.name, mode);
return token;
}
/**
* [Utility]
* Set pop mode on the token definition.
* Can be used inside the token mode definition block.
*
* Marks the token to **exit** the following lexer mode.
*
* @param {TokenType} token - The token to modify
* @param {string} mode - The mode to leave
* @returns {TokenType} The token again
*/
function exits(token, mode) {
if (!token) throw new Error('Token must not be undefined');
token.POP_MODE = !!mode; // Always set to true. We just use the mode here, so the linter thinks it was used. We just pass it in for clarity in the definition
return token;
}
/**
* [Utility]
* Can be used inside the token mode definition block.
*
* Marks the token to to just be used/consumed, and not exit or enter a mode.
*
* @param {TokenType} token - The token to modify
* @returns {TokenType} The token again
*/
function using(token) {
if (!token) throw new Error('Token must not be undefined');
if (enterModesMap.has(token.name)) {
throw new Error(`Token ${token.name} is already marked to enter a mode (${enterModesMap.get(token.name)}). The token definition are global, so they cannot be used to lead or stay differently.`);
}
return token;
}

View File

@@ -0,0 +1,149 @@
import { chevrotain } from '../../../lib.js';
import { MacroLexer } from './MacroLexer.js';
const { CstParser } = chevrotain;
/** @typedef {import('chevrotain').TokenType} TokenType */
/** @typedef {import('chevrotain').CstNode} CstNode */
/** @typedef {import('chevrotain').ILexingError} ILexingError */
/** @typedef {import('chevrotain').IRecognitionException} IRecognitionException */
/**
* The singleton instance of the MacroParser.
*
* @type {MacroParser}
*/
let instance;
export { instance as MacroParser };
class MacroParser extends CstParser {
/** @type {MacroParser} */ static #instance;
/** @type {MacroParser} */ static get instance() { return MacroParser.#instance ?? (MacroParser.#instance = new MacroParser()); }
/** @private */
constructor() {
super(MacroLexer.def, {
traceInitPerf: false,
nodeLocationTracking: 'full',
recoveryEnabled: true,
});
const Tokens = MacroLexer.tokens;
const $ = this;
// Top-level document rule that can handle both plaintext and macros
$.document = $.RULE('document', () => {
$.MANY(() => {
$.OR([
{ ALT: () => $.CONSUME(Tokens.Plaintext, { LABEL: 'plaintext' }) },
{ ALT: () => $.CONSUME(Tokens.PlaintextOpenBrace, { LABEL: 'plaintext' }) },
{ ALT: () => $.SUBRULE($.macro) },
{ ALT: () => $.CONSUME(Tokens.Macro.Start, { LABEL: 'plaintext' }) },
]);
});
});
// Basic Macro Structure
$.macro = $.RULE('macro', () => {
$.CONSUME(Tokens.Macro.Start);
$.OR([
{ ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },
{ ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },
]);
$.OPTION(() => $.SUBRULE($.arguments));
$.CONSUME(Tokens.Macro.End);
});
// Arguments Parsing
$.arguments = $.RULE('arguments', () => {
$.OR([
{
ALT: () => {
$.CONSUME(Tokens.Args.DoubleColon, { LABEL: 'separator' });
$.AT_LEAST_ONE_SEP({
SEP: Tokens.Args.DoubleColon,
DEF: () => $.SUBRULE($.argument, { LABEL: 'argument' }),
});
},
},
{
ALT: () => {
$.OPTION(() => {
$.CONSUME(Tokens.Args.Colon, { LABEL: 'separator' });
});
$.SUBRULE($.argumentAllowingColons, { LABEL: 'argument' });
},
// So, this is a bit hacky. But implemented below, the argument capture does explicitly exclude double colons
// from being captured as the first token. The potential ambiguity chevrotain claims here is not possible.
// It says stuff like <Args.DoubleColon, Identifier/Macro/Unknown> is possible in both branches, but it is not.
IGNORE_AMBIGUITIES: true,
},
]);
});
// List the argument tokens here, as we need two rules, one to be able to parse with double colons and one without
const validArgumentTokens = [
{ ALT: () => $.SUBRULE($.macro) }, // Nested Macros
{ ALT: () => $.CONSUME(Tokens.Identifier) },
{ ALT: () => $.CONSUME(Tokens.Unknown) },
{ ALT: () => $.CONSUME(Tokens.Args.Colon) },
{ ALT: () => $.CONSUME(Tokens.Args.Equals) },
{ ALT: () => $.CONSUME(Tokens.Args.Quote) },
];
$.argument = $.RULE('argument', () => {
$.MANY(() => {
$.OR([...validArgumentTokens]);
});
});
$.argumentAllowingColons = $.RULE('argumentAllowingColons', () => {
$.AT_LEAST_ONE(() => {
$.OR([
...validArgumentTokens,
{ ALT: () => $.CONSUME(Tokens.Args.DoubleColon) },
]);
});
});
this.performSelfAnalysis();
}
/**
* Parses a document into a CST.
*
* @param {string} input
* @returns {{ cst: CstNode|null, errors: ({ message: string }|ILexingError|IRecognitionException)[] , lexingErrors: ILexingError[], parserErrors: IRecognitionException[] }}
*/
parseDocument(input) {
if (!input) {
return { cst: null, errors: [{ message: 'Input is empty' }], lexingErrors: [], parserErrors: [] };
}
const lexingResult = MacroLexer.tokenize(input);
this.input = lexingResult.tokens;
const cst = this.document();
const errors = [
...lexingResult.errors,
...this.errors,
];
return { cst, errors, lexingErrors: lexingResult.errors, parserErrors: this.errors };
}
test(input) {
const lexingResult = MacroLexer.tokenize(input);
// "input" is a setter which will reset the parser's state.
this.input = lexingResult.tokens;
const cst = this.macro();
// For testing purposes we need to actually persist the error messages in the object,
// otherwise the test cases cannot read those, as they don't have access to the exception object type.
const errors = this.errors.map(x => ({ message: x.message, ...x, stack: x.stack }));
return { cst, errors: errors };
}
}
instance = MacroParser.instance;

View File

@@ -0,0 +1,672 @@
/** @typedef {import('chevrotain').CstNode} CstNode */
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
import { isFalseBoolean, isTrueBoolean } from '../../utils.js';
import { MacroEngine } from './MacroEngine.js';
import { createMacroRuntimeError, logMacroRegisterError, logMacroRegisterWarning, logMacroRuntimeWarning } from './MacroDiagnostics.js';
/**
* Enum of standard macro categories for grouping in documentation and autocomplete.
* Extensions may use these or define custom category strings.
*
* @readonly
* @enum {string}
*/
export const MacroCategory = Object.freeze({
/** Basic utilities and text manipulation (newline, noop, trim, reverse, comment) */
UTILITY: 'utility',
/** Randomization and dice rolling (random, pick, roll) */
RANDOM: 'random',
/** Participant names and name lists (user, char, group, notChar) */
NAMES: 'names',
/** Character card fields and persona (description, personality, scenario, mesExamples, persona) */
CHARACTER: 'character',
/** Chat history, messages, and swipes */
CHAT: 'chat',
/** Date, time, and duration macros */
TIME: 'time',
/** Local and global variable operations */
VARIABLE: 'variable',
/** Prompt templates for text completion (instruct sequences, system prompts, author's notes, context templates) */
PROMPTS: 'prompts',
/** Runtime application state (model, API, lastGenerationType, isMobile) */
STATE: 'state',
/** Macros that don't fit in any of the other categories, but don't really need/deserve their own */
MISC: 'misc',
});
/**
* Enum of standard macro value types for type checking and documentation.
* Used for both argument types and return types.
*
* @readonly
* @enum {string}
*/
export const MacroValueType = Object.freeze({
/** String value of any kind */
STRING: 'string',
/** Integer value (natural number, no decimal spaces) */
INTEGER: 'integer',
/** Number value (decimal spaces allowed, includes integers values) */
NUMBER: 'number',
/** Boolean value (true/false, 1/0, yes/no, on/off) */
BOOLEAN: 'boolean',
});
/**
* @typedef {Object} MacroDefinitionOptions
* @property {MacroAliasDef[]} [aliases] - Alternative names for this macro. Each alias creates a lookup entry pointing to the same definition.
* @property {MacroCategory|string} category - Category for grouping in documentation/autocomplete. Use MacroCategory enum values or a custom string.
* @property {number|MacroUnnamedArgDef[]} [unnamedArgs=0] - Specifies the macro's unnamed positional arguments. Can be a number (all required) or an array of definitions (supports optional args). Optional args must be a suffix.
* @property {boolean|MacroListSpec} [list] - Whether the macro allows a list of arguments (optional min and max values can be set). These arguments will be added AFTER the unnamed args.
* @property {boolean} [strictArgs=true] - Whether the macro should be strict about its arguments.
* @property {string} [description=''] - Add a description of what the macro does.
* @property {string} [returns] - Add a specific description of what the macro returns, if it is not obvious from the description.
* @property {MacroValueType|MacroValueType[]} [returnType=MacroValueType.STRING] - The type(s) this macro returns. Defaults to string.
* @property {string} [displayOverride] - Override the auto-generated macro signature for display (must include curly braces, e.g. "{{macro::arg}}").
* @property {string|string[]} [exampleUsage] - Example usage(s) shown in documentation (must include curly braces).
* @property {MacroHandler} handler - The handler function for the macro.
*/
/**
* @typedef {Object} MacroAliasDef
* @property {string} alias - The alias name.
* @property {boolean} [visible=true] - Whether this alias appears in documentation/autocomplete. Defaults to true.
*/
/**
* @typedef {Object} MacroUnnamedArgDef
* @property {string} name
* @property {boolean} [optional=false] - Whether this argument is optional. Optional args must form a contiguous suffix (no required args after an optional).
* @property {string} [defaultValue] - Default value for optional args. ONLY meaningful when optional is true. Shown in docs/autocomplete.
* @property {MacroValueType|MacroValueType[]} [type=MacroValueType.STRING] - Single type or array of accepted types.
* @property {string} [sampleValue]
* @property {string} [description]
*/
/**
* @typedef {Object} MacroListSpec
* @property {number} [min]
* @property {number} [max]
*/
/**
* @typedef {(context: MacroExecutionContext) => string} MacroHandler
*/
/**
* @typedef {Object} MacroExecutionContext
* @property {string} name
* @property {string[]} args - All unnamed arguments passed to the macro.
* @property {string[]} unnamedArgs - Unnamed positional arguments (both required and optional, up to the defined count).
* @property {string[]|null} list - List arguments (after unnamed args), or null if list is not enabled.
* @property {{ [key: string]: string }|null} namedArgs - Reserved for future named argument support.
* @property {string} raw
* @property {MacroEnv} env
* @property {CstNode|null} cstNode
* @property {{ startOffset: number, endOffset: number }|null} range
* @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected.
*/
/**
* @typedef {Object} MacroDefinition
* @property {string} name - Primary macro name.
* @property {MacroResolvedAlias[]} aliases - Parsed alias definitions for this macro.
* @property {MacroCategory|string} category
* @property {number} minArgs - Minimum number of unnamed args required (excludes optional args).
* @property {number} maxArgs - Maximum number of unnamed args accepted (includes optional args).
* @property {MacroUnnamedArgDef[]} unnamedArgDefs - Definitions for all unnamed positional arguments (required + optional).
* @property {{ min: number, max: (number|null) }|null} list
* @property {boolean} strictArgs
* @property {string} description
* @property {string|null} returns
* @property {MacroValueType|MacroValueType[]} returnType - The type(s) this macro returns.
* @property {string|null} displayOverride - Override for the auto-generated macro signature display.
* @property {string[]} exampleUsage - Example usage strings for documentation.
* @property {MacroHandler} handler
* @property {MacroSource} source
* @property {string|null} aliasOf - If this is an alias, the primary macro name this is an alias of. Can also be used to check if this is an alias macro.
* @property {boolean|null} aliasVisible - If this is an alias, whether this alias is visible in docs/autocomplete.
*/
/**
* @typedef {Object} MacroResolvedAlias
* @property {string} alias - The alias name.
* @property {boolean} visible - Whether this alias is visible in documentation/autocomplete.
*/
/**
* @typedef {Object} MacroSource
* @property {string} name - Source identifier (extension name or script path)
* @property {boolean} isExtension - True if registered from an extension
* @property {boolean} isThirdParty - True if registered from a third-party extension
*/
/**
* The singleton instance of the MacroRegistry.
*
* @type {MacroRegistry}
*/
let instance;
export { instance as MacroRegistry };
class MacroRegistry {
/** @type {MacroRegistry} */ static #instance;
/** @type {MacroRegistry} */ static get instance() { return MacroRegistry.#instance ?? (MacroRegistry.#instance = new MacroRegistry()); }
/** @type {Map<string, MacroDefinition>} */
#macros;
/**
* @private
*/
constructor() {
/** @type {Map<string, MacroDefinition>} */
this.#macros = new Map();
}
/**
* Registers a macro with the registry.
* Errors during registration are caught and logged, the macro will not be registered, and the function returns null.
*
* @param {string} name - Macro name (identifier).
* @param {MacroDefinitionOptions} options - Macro registration options including handler and metadata.
* @returns {MacroDefinition|null} The registered definition, or null if registration failed.
*/
registerMacro(name, options) {
// Extract name early for error logging
name = typeof name === 'string' ? name.trim() : String(name);
try {
if (typeof name !== 'string' || !name) throw new Error('Macro name must be a non-empty string');
if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
const {
aliases: rawAliases,
category: rawCategory,
unnamedArgs: rawUnnamedArgs,
list: rawList,
strictArgs: rawStrictArgs,
description: rawDescription,
returns: rawReturns,
returnType: rawReturnType,
displayOverride: rawDisplayOverride,
exampleUsage: rawExampleUsage,
handler,
} = options;
if (typeof handler !== 'function') throw new Error(`Macro "${name}" options.handler must be a function.`);
/** @type {MacroResolvedAlias[]} */
const aliases = [];
if (rawAliases !== undefined && rawAliases !== null) {
if (!Array.isArray(rawAliases)) throw new Error(`Macro "${name}" options.aliases must be an array.`);
for (const [i, aliasDef] of rawAliases.entries()) {
if (!aliasDef || typeof aliasDef !== 'object') throw new Error(`Macro "${name}" options.aliases[${i}] must be an object.`);
if (typeof aliasDef.alias !== 'string' || !aliasDef.alias.trim()) throw new Error(`Macro "${name}" options.aliases[${i}].alias must be a non-empty string.`);
const aliasName = aliasDef.alias.trim();
if (aliasName === name) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name.`);
const visible = aliasDef.visible !== false; // Default to true
aliases.push({ alias: aliasName, visible });
}
}
if (typeof rawCategory !== 'string' || !rawCategory.trim()) throw new Error(`Macro "${name}" options.category must be a non-empty string.`);
const category = rawCategory.trim();
let minArgs = 0;
let maxArgs = 0;
/** @type {MacroUnnamedArgDef[]} */
let unnamedArgDefs = [];
if (rawUnnamedArgs !== undefined) {
if (Array.isArray(rawUnnamedArgs)) {
// Parse array of argument definitions with optional support
let foundOptional = false;
unnamedArgDefs = rawUnnamedArgs.map((def, index) => {
if (!def || typeof def !== 'object') throw new Error(`Macro "${name}" options.unnamedArgs[${index}] must be an object when using argument definitions.`);
if (typeof def.name !== 'string' || !def.name.trim()) throw new Error(`Macro "${name}" options.unnamedArgs[${index}].name must be a non-empty string when using argument definitions.`);
// Validate: no required args after optional
if (foundOptional && !def.optional) {
throw new Error(`Macro "${name}" options.unnamedArgs[${index}] is required but follows an optional argument. Optional args must be a suffix.`);
}
if (def.optional) foundOptional = true;
/** @type {MacroUnnamedArgDef} */
const normalized = {
name: def.name.trim(),
optional: def.optional || false,
defaultValue: def.defaultValue?.trim(),
type: Array.isArray(def.type) && def.type.length === 0 ? 'string' : def.type ?? 'string',
sampleValue: def.sampleValue?.trim(),
description: typeof def.description === 'string' ? def.description : undefined,
};
const validTypes = ['string', 'integer', 'number', 'boolean'];
const type = Array.isArray(normalized.type) ? normalized.type : [normalized.type];
if (type.some(t => !validTypes.includes(t))) {
throw new Error(`Macro "${name}" options.unnamedArgs[${index}].type must be one of "string", "integer", "number", or "boolean" when provided.`);
}
return normalized;
});
// Compute minArgs (required count) and maxArgs (total count)
maxArgs = unnamedArgDefs.length;
minArgs = unnamedArgDefs.findIndex(d => d.optional);
if (minArgs === -1) minArgs = maxArgs; // No optional args, all are required
} else if (typeof rawUnnamedArgs === 'number') {
if (!Number.isInteger(rawUnnamedArgs) || rawUnnamedArgs < 0) {
throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer when provided.`);
}
minArgs = rawUnnamedArgs;
maxArgs = rawUnnamedArgs;
unnamedArgDefs = Array.from({ length: rawUnnamedArgs }, (_, i) => ({
name: `arg${i + 1}`,
optional: false,
type: 'string',
sampleValue: `arg${i + 1}`,
}));
} else {
throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer or an array of argument definitions when provided.`);
}
}
/** @type {{ min: number, max: (number|null) }|null} */
let list = null;
if (rawList !== undefined) {
if (typeof rawList === 'boolean') {
list = rawList ? { min: 0, max: null } : null;
} else if (typeof rawList === 'object' && rawList !== null) {
if (typeof rawList.min !== 'number' || rawList.min < 0) throw new Error(`Macro "${name}" options.list.min must be a non-negative integer when provided.`);
if (rawList.max !== undefined && typeof rawList.max !== 'number') throw new Error(`Macro "${name}" options.list.max must be a number when provided.`);
if (rawList.max !== undefined && rawList.max < rawList.min) throw new Error(`Macro "${name}" options.list.max must be greater than or equal to options.list.min.`);
list = { min: rawList.min, max: rawList.max ?? null };
} else {
throw new Error(`Macro "${name}" options.list must be a boolean or an object with numeric min/max when provided.`);
}
}
let strictArgs = true;
if (rawStrictArgs !== undefined) {
if (typeof rawStrictArgs !== 'boolean') throw new Error(`Macro "${name}" options.strictArgs must be a boolean when provided.`);
strictArgs = rawStrictArgs;
}
let description = '<no description>';
if (rawDescription !== undefined) {
if (typeof rawDescription !== 'string') throw new Error(`Macro "${name}" options.description must be a string when provided.`);
description = rawDescription;
}
let returns = null;
if (rawReturns !== undefined && rawReturns !== null) {
if (typeof rawReturns !== 'string') throw new Error(`Macro "${name}" options.returns must be a string when provided.`);
returns = rawReturns || '<empty string>';
}
// Process and validate returnType (defaults to 'string')
const validTypes = ['string', 'integer', 'number', 'boolean'];
let returnType = /** @type {MacroValueType|MacroValueType[]} */ ('string');
if (rawReturnType !== undefined && rawReturnType !== null) {
// Normalize to non-empty value or default
returnType = Array.isArray(rawReturnType) && rawReturnType.length === 0 ? 'string' : rawReturnType;
// Validate all types
const typesToValidate = Array.isArray(returnType) ? returnType : [returnType];
if (typesToValidate.some(t => !validTypes.includes(t))) {
throw new Error(`Macro "${name}" options.returnType must be one of "string", "integer", "number", or "boolean" (or an array of these) when provided.`);
}
}
let displayOverride = null;
if (rawDisplayOverride !== undefined && rawDisplayOverride !== null) {
if (typeof rawDisplayOverride !== 'string') throw new Error(`Macro "${name}" options.displayOverride must be a string when provided.`);
displayOverride = rawDisplayOverride.trim();
if (displayOverride && !displayOverride.startsWith('{{')) {
logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.displayOverride should include curly braces. Auto-wrapping.` });
displayOverride = `{{${displayOverride}}}`;
}
}
/** @type {string[]} */
let exampleUsage = [];
if (rawExampleUsage !== undefined && rawExampleUsage !== null) {
const examples = Array.isArray(rawExampleUsage) ? rawExampleUsage : [rawExampleUsage];
for (const [i, ex] of examples.entries()) {
if (typeof ex !== 'string') throw new Error(`Macro "${name}" options.exampleUsage[${i}] must be a string.`);
let trimmed = ex.trim();
if (trimmed && !trimmed.startsWith('{{')) {
logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.exampleUsage[${i}] should include curly braces. Auto-wrapping.` });
trimmed = `{{${trimmed}}}`;
}
if (trimmed) exampleUsage.push(trimmed);
}
}
if (this.#macros.has(name)) {
logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });
}
// Detect extension/third-party status from call stack
const { isExtension, isThirdParty, source } = detectMacroSource();
/** @type {MacroDefinition} */
const definition = {
name: name,
aliases,
category,
minArgs,
maxArgs,
unnamedArgDefs,
list,
strictArgs,
description,
returns,
returnType,
displayOverride,
exampleUsage,
handler,
source: {
name: source,
isExtension,
isThirdParty,
},
aliasOf: null,
aliasVisible: null,
};
this.#macros.set(name, definition);
// Register alias entries pointing to the same definition
for (const { alias, visible } of aliases) {
if (this.#macros.has(alias)) {
logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });
}
/** @type {MacroDefinition} */
const aliasEntry = {
...definition,
name: alias, // The lookup name is the alias
aliasOf: name,
aliasVisible: visible,
};
this.#macros.set(alias, aliasEntry);
}
return definition;
} catch (error) {
logMacroRegisterError({
message: `Failed to register macro "${name}". The macro will not be available.`,
macroName: name,
error,
});
return null;
}
}
/**
* Unregisters a macro.
*
* @param {string} name - Macro name (identifier).
* @returns {boolean} True if a macro was removed.
*/
unregisterMacro(name) {
if (typeof name !== 'string' || !name.trim()) throw new Error('Macro name must be a non-empty string');
name = name.trim();
return this.#macros.delete(name);
}
/**
* Checks whether a macro with the given name is registered.
*
* @param {string} name - Macro name (identifier).
* @returns {boolean}
*/
hasMacro(name) {
if (typeof name !== 'string' || !name.trim()) return false;
name = name.trim();
return this.#macros.has(name);
}
/**
* Returns the macro definition for a given name.
*
* @param {string} name - Macro name (identifier).
* @returns {MacroDefinition|undefined}
*/
getMacro(name) {
if (typeof name !== 'string' || !name.trim()) return undefined;
name = name.trim();
return this.#macros.get(name);
}
/**
* Returns the primary (non-alias) definition for a macro.
* If given an alias name, returns the primary definition it points to.
*
* @param {string} name - Macro name or alias.
* @returns {MacroDefinition|undefined}
*/
getPrimaryMacro(name) {
const def = this.getMacro(name);
if (!def) return undefined;
return def.aliasOf ? this.getMacro(def.aliasOf) : def;
}
/**
* Returns an array of all registered macros.
*
* @param {Object} [options] - Filter options.
* @param {boolean} [options.excludeAliases=false] - If true, excludes alias entries (only returns primary definitions).
* @param {boolean} [options.excludeHiddenAliases=false] - If true, excludes alias entries where visible=false.
* @returns {MacroDefinition[]}
*/
getAllMacros({ excludeAliases = false, excludeHiddenAliases = false } = {}) {
let macros = Array.from(this.#macros.values());
if (excludeAliases) {
macros = macros.filter(m => !m.aliasOf);
} else if (excludeHiddenAliases) {
macros = macros.filter(m => !m.aliasOf || m.aliasVisible !== false);
}
return macros;
}
/**
* Executes a macro for a given call.
*
* @param {MacroCall} call - Macro call information.
* @param {Object} [options] - Additional options.
* @param {MacroDefinition} [options.defOverride] - Override the macro definition.
* @returns {string}
*/
executeMacro(call, { defOverride } = {}) {
const name = call.name;
const def = defOverride || this.getMacro(name);
if (!def) {
throw new Error(`Macro "${name}" is not registered`);
}
const args = Array.isArray(call.args) ? call.args : [];
if (!isArgsValid(def, args)) {
const expectedMin = def.list ? def.minArgs + def.list.min : def.minArgs;
const expectedMax = def.list && def.list.max !== null
? def.maxArgs + def.list.max
: (def.list ? null : def.maxArgs);
const expectation = (() => {
if (expectedMax !== null && expectedMax !== expectedMin) return `between ${expectedMin} and ${expectedMax}`;
if (expectedMax !== null && expectedMax === expectedMin) return `${expectedMin}`;
return `at least ${expectedMin}`;
})();
const message = `Macro "${def.name}" called with ${args.length} unnamed arguments but expects ${expectation}.`;
if (def.strictArgs) {
throw createMacroRuntimeError({ message, call, def });
}
logMacroRuntimeWarning({ message, call, def });
}
// Compute unnamed args (required + optional, up to maxArgs)
const unnamedArgsCount = Math.min(args.length, def.maxArgs);
const unnamedArgsValues = args.slice(0, unnamedArgsCount);
const listValues = !def.list ? null : args.length > def.maxArgs ? args.slice(def.maxArgs) : [];
// Perform best-effort type validation for documented positional arguments.
// This can throw an error if the arguments are invalid.
validateArgTypes(call, def, unnamedArgsValues);
const namedArgs = null;
/** @type {MacroExecutionContext} */
const executionContext = {
name: def.name,
args,
unnamedArgs: unnamedArgsValues,
list: listValues,
namedArgs,
raw: call.rawInner,
env: call.env,
cstNode: call.cstNode,
range: call.range,
normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
};
const result = def.handler(executionContext);
return executionContext.normalize(result);
}
}
instance = MacroRegistry.instance;
/**
* Validates the arguments for a macro definition.
* Supports required args (minArgs), optional args (up to maxArgs), and list tail.
*
* @param {MacroDefinition} def - Macro definition.
* @param {any[]} args - Arguments to validate.
* @returns {boolean} True if the arguments are valid, false otherwise.
*/
function isArgsValid(def, args) {
const hasListArgs = def.list !== null;
// Without list: args must be between minArgs and maxArgs (inclusive)
if (!hasListArgs) {
return args.length >= def.minArgs && args.length <= def.maxArgs;
}
// With list: args must be at least minArgs + list.min
const minRequired = def.minArgs + def.list.min;
if (args.length < minRequired) return false;
// List items are everything after maxArgs positional slots
const listCount = Math.max(0, args.length - def.maxArgs);
if (def.list.max !== null && listCount > def.list.max) return false;
return true;
}
/**
* Performs type validation for unnamed positional arguments using the metadata
* defined on the macro definition. When strictArgs is true, invalid argument
* types cause an error to be thrown. When strictArgs is false, only warnings
* are logged and execution continues.
*
* @param {MacroCall} call
* @param {MacroDefinition} def
* @param {string[]} unnamedArgs
*/
function validateArgTypes(call, def, unnamedArgs) {
if (def.unnamedArgDefs.length === 0) return;
const defs = def.unnamedArgDefs;
const count = Math.min(defs.length, unnamedArgs.length);
for (let i = 0; i < count; i++) {
const argDef = defs[i];
const value = unnamedArgs[i];
if (!argDef || !argDef.type || typeof value !== 'string') {
// Misconfigured macro definition: always surface as an error.
throw new Error(`Macro "${call.name}" (position ${i + 1}) has invalid definition or type.`);
}
const types = Array.isArray(argDef.type) ? argDef.type : [argDef.type];
if (!types.some(type => isValueOfType(value, type))) {
const argName = argDef.name || `Argument ${i + 1}`;
const optionalLabel = argDef.optional ? ' (optional)' : '';
const message = `Macro "${call.name}" (position ${i + 1}${optionalLabel}) argument "${argName}" expected type ${argDef.type} but got value "${value}".`;
if (def.strictArgs) {
throw createMacroRuntimeError({ message, call, def: def });
}
logMacroRuntimeWarning({ message, call, def: def });
}
}
}
/**
* Checks whether a string value conforms to the given macro argument type.
*
* @param {string} value
* @param {MacroValueType} type
* @returns {boolean}
*/
function isValueOfType(value, type) {
const trimmed = value.trim();
if (type === 'string') {
return true;
}
if (type === 'integer') {
return /^-?\d+$/.test(trimmed);
}
if (type === 'number') {
const n = Number(trimmed);
return Number.isFinite(n);
}
if (type === 'boolean') {
return isTrueBoolean(trimmed) || isFalseBoolean(trimmed);
}
// Unknown type: treat it as invalid.
return false;
}
/**
* Detects the source of a macro registration from the call stack.
* Similar to how SlashCommandParser detects command sources.
*
* @returns {{ isExtension: boolean, isThirdParty: boolean, source: string }}
*/
function detectMacroSource() {
const stack = new Error().stack?.split('\n').map(line => line.trim()) ?? [];
const isExtension = stack.some(line => line.includes('/scripts/extensions/'));
const isThirdParty = stack.some(line => line.includes('/scripts/extensions/third-party/'));
let source = 'unknown';
if (isThirdParty) {
const match = stack.find(line => line.includes('/scripts/extensions/third-party/'));
if (match) {
source = match.replace(/^.*?\/scripts\/extensions\/third-party\/([^/]+)\/.*$/, '$1');
}
} else if (isExtension) {
const match = stack.find(line => line.includes('/scripts/extensions/'));
if (match) {
source = match.replace(/^.*?\/scripts\/extensions\/([^/]+)\/.*$/, '$1');
}
} else {
// Find the first meaningful caller outside MacroRegistry
const callerIdx = stack.findIndex(line =>
line.includes('registerMacro') && line.includes('MacroRegistry'),
);
if (callerIdx >= 0 && callerIdx + 1 < stack.length) {
const callerLine = stack[callerIdx + 1];
// Extract script path from stack frame
const scriptMatch = callerLine.match(/\/((?:scripts\/)?(?:macros\/)?[^/]+\.js)/);
if (scriptMatch) {
source = scriptMatch[1];
}
}
}
return { isExtension, isThirdParty, source };
}

View File

@@ -0,0 +1,83 @@
/**
* Central entry point for the new macro system.
*
* Exposes the MacroEngine / MacroRegistry singletons and provides a
* single registerMacros() function that wires up all built-in macro
* definition sets (core, env, state, chat, time, variables, instruct).
*/
// Engine singletons and enums
import { MacroEngine } from './engine/MacroEngine.js';
import { MacroRegistry, MacroCategory, MacroValueType } from './engine/MacroRegistry.js';
import { MacroLexer } from './engine/MacroLexer.js';
import { MacroParser } from './engine/MacroParser.js';
import { MacroCstWalker } from './engine/MacroCstWalker.js';
import { MacroEnvBuilder } from './engine/MacroEnvBuilder.js';
// Macro definition groups
import { registerCoreMacros } from './definitions/core-macros.js';
import { registerEnvMacros } from './definitions/env-macros.js';
import { registerStateMacros } from './definitions/state-macros.js';
import { registerChatMacros } from './definitions/chat-macros.js';
import { registerTimeMacros } from './definitions/time-macros.js';
import { registerVariableMacros } from './definitions/variable-macros.js';
import { registerInstructMacros } from './definitions/instruct-macros.js';
// Re-export the category enum for external use
export { MacroCategory, MacroValueType };
// Re-export most-used jsdoc definitions
/** @typedef {import('./engine/MacroRegistry.js').MacroDefinitionOptions} MacroDefinitionOptions */
/** @typedef {import('./engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
/** @typedef {import('./engine/MacroRegistry.js').MacroUnnamedArgDef} MacroUnnamedArgDef */
/** @typedef {import('./engine/MacroRegistry.js').MacroListSpec} MacroListSpec */
/** @typedef {import('./engine/MacroRegistry.js').MacroHandler} MacroHandler */
/** @typedef {import('./engine/MacroRegistry.js').MacroExecutionContext} MacroExecutionContext */
/** @typedef {import('chevrotain').CstNode} CstNode */
/** @typedef {import('./engine/MacroEnv.types.js').MacroEnv} MacroEnv */
/** @typedef {import('./engine/MacroEnv.types.js').MacroEnvNames} MacroEnvNames */
/** @typedef {import('./engine/MacroEnv.types.js').MacroEnvCharacter} MacroEnvCharacter */
/** @typedef {import('./engine/MacroEnv.types.js').MacroEnvSystem} MacroEnvSystem */
/** @typedef {import('./engine/MacroEnv.types.js').MacroEnvFunctions} MacroEnvFunctions */
export const macros = {
// engine singletons
engine: MacroEngine,
registry: MacroRegistry,
envBuilder: MacroEnvBuilder,
lexer: MacroLexer,
parser: MacroParser,
cstWalker: MacroCstWalker,
// enums
category: MacroCategory,
// shorthand functions
register: MacroRegistry.registerMacro.bind(MacroRegistry),
};
/**
* Registers all built-in macros in a well-defined order.
* Intended to be called once during app initialization.
*/
export function initRegisterMacros() {
// Core utilities and generic helpers
registerCoreMacros();
// Env / character / system / extras
registerEnvMacros();
// Runtime state tracking (eventSource etc.)
registerStateMacros();
// Chat/history inspection macros
registerChatMacros();
// Time / date / durations
registerTimeMacros();
// Variable and instruct macros
registerVariableMacros();
registerInstructMacros();
}