Skip to content

Regex Scripts

Permission required: regex_scripts

Full CRUD access to the user's regex scripts plus a context-aware active-rule resolver. Use this for extensions that manage, analyze, or batch-edit find/replace rules — card-format compatibility shims, regex analytics, debug tooling, or anything that needs to mirror the resolution Lumiverse uses internally during prompt assembly, response baking, and display rendering.

Usage

// List regex scripts (paginated)
const { data, total } = await spindle.regex_scripts.list({ limit: 50, offset: 0 })

// List only character-scoped rules attached to a specific character
const charRules = await spindle.regex_scripts.list({
  scope: 'character',
  scopeId: 'character-id',
})

// List only display-target rules
const displayRules = await spindle.regex_scripts.list({ target: 'display' })

// Get a single script
const script = await spindle.regex_scripts.get('script-id')
if (script) {
  spindle.log.info(`${script.name}: /${script.find_regex}/${script.flags}`)
}

// Create a script
const newScript = await spindle.regex_scripts.create({
  name: 'Strip OOC blocks',
  find_regex: '\\(\\(.*?\\)\\)',
  replace_string: '',
  flags: 'g',
  placement: ['ai_output'],
  target: 'display',
  scope: 'character',
  scope_id: 'character-id',
})

// Update a script
const updated = await spindle.regex_scripts.update(newScript.id, {
  disabled: true,
})

// Delete a script
const deleted = await spindle.regex_scripts.delete(newScript.id)

// Resolve the rules that would actually fire for a given context
const active = await spindle.regex_scripts.getActive({
  target: 'display',
  characterId: 'character-id',
  chatId: 'chat-id',
})

Methods

Method Returns Description
list(options?) Promise<{ data: RegexScriptDTO[], total: number }> List scripts with strict scope filtering. Options: { scope?, scopeId?, target?, limit?, offset?, userId? }. Defaults: limit 50, max 200.
get(scriptId) Promise<RegexScriptDTO \| null> Get a script by ID. Returns null if not found.
create(input) Promise<RegexScriptDTO> Create a new regex script. name and find_regex are required.
update(scriptId, input) Promise<RegexScriptDTO> Update a script. All fields are optional. Throws if the script is not found.
delete(scriptId) Promise<boolean> Delete a script. Returns true if deleted.
getActive(options) Promise<RegexScriptDTO[]> Resolve enabled scripts that would fire for a given target plus character/chat context. Merges global + character + chat scopes and orders them by scope tier then sort_order.

RegexScriptListOptionsDTO

Field Type Description
scope "global" \| "character" \| "chat" Filter to a single scope. Omit to include all scopes.
scopeId string Required when scope is character or chat to narrow to a single entity. Ignored otherwise.
target "prompt" \| "response" \| "display" Filter by execution target.
limit number Page size. Default 50, max 200.
offset number Pagination offset.
userId string For operator-scoped extensions only.

RegexScriptActiveOptionsDTO

Field Type Description
target "prompt" \| "response" \| "display" Required. The execution target to resolve for.
characterId string Include character-scoped rules attached to this character.
chatId string Include chat-scoped rules attached to this chat.
userId string For operator-scoped extensions only.

getActive always includes global rules. Disabled rules are excluded.

RegexScriptCreateDTO

Field Type Required Description
name string Yes Display name shown in the regex panel.
find_regex string Yes Pattern compiled with the JavaScript regex engine. Validated at create time.
replace_string string No Replacement template. Supports $1 / $& / $<name> capture references. Default "".
flags string No Any subset of dgimsuvy (full JS regex flag set: d hasIndices, g global, i ignore-case, m multiline, s dotAll, u unicode, v unicodeSets, y sticky). No duplicates. Default "gi".
placement RegexPlacementDTO[] No Which message roles the rule applies to. Default ["ai_output"].
scope "global" \| "character" \| "chat" No Default "global".
scope_id string \| null No Required when scope is non-global.
target "prompt" \| "response" \| "display" No When the rule fires. Default "response".
min_depth number \| null No Lower bound on chat-history depth (0 = latest).
max_depth number \| null No Upper bound on chat-history depth.
trim_strings string[] No Additional substrings stripped from output after the regex pass.
run_on_edit boolean No Re-run the rule when a message is edited.
substitute_macros "none" \| "raw" \| "escaped" \| "after" No How CBS / {{...}} macros inside the rule resolve. Prefer "after" for any rule whose replace_string contains macros — see "Macro substitution modes" below. Default "none".
disabled boolean No Create as disabled.
sort_order number No Lower values run earlier within the same scope tier. Default 0.
description string No Free-form note.
folder string No Folder label shown in the regex panel.
metadata Record<string, unknown> No Arbitrary metadata namespaced to your extension.
script_id string No Stable identifier (normalized to lowercase + underscores) for cross-instance references.

RegexScriptUpdateDTO

Same fields as RegexScriptCreateDTO, all optional.

RegexScriptDTO

{
  id: string
  name: string
  script_id: string             // stable, normalized identifier (lowercase, _-only)
  find_regex: string
  replace_string: string
  flags: string                 // any subset of "dgimsuvy" (full JS regex flag set)
  placement: ("user_input" | "ai_output" | "world_info" | "reasoning")[]
  scope: "global" | "character" | "chat"
  scope_id: string | null       // character ID or chat ID when scoped
  target: "prompt" | "response" | "display"
  min_depth: number | null      // chat-history depth bound, or null
  max_depth: number | null
  trim_strings: string[]        // additional substrings stripped from output
  run_on_edit: boolean
  substitute_macros: "none" | "raw" | "escaped" | "after"
  disabled: boolean
  sort_order: number            // lower runs earlier within the same scope tier
  description: string
  folder: string
  metadata: Record<string, unknown>
  created_at: number            // unix epoch seconds
  updated_at: number
}

Macro substitution modes

substitute_macros controls when macros inside replace_string evaluate relative to capture-group substitution. The mode you pick is mostly a performance decision — all four are correct, but their cost and capability profiles differ.

  • "none" — no macro evaluation. replace_string is substituted as-is by the regex engine; capture refs ($1, $&, $<name>) work, but any {{...}} survives literal in the output. Use when you don't need macros.
  • "raw" — substitute captures into replace_string first, then evaluate the result per match. Macros can reference captures (e.g. {{lower::$1}}). Cost: N evaluate() calls for N matches.
  • "escaped" — evaluate replace_string once before substitution, then double-escape $ so capture refs do not fire. Cost: one evaluate() call per render. Cannot use captures ($1 is dead).
  • "after" — substitute captures literally with native String.replace, then run one evaluate() over the entire result body. Cost: one evaluate() call per render. Macros can reference captures (they appear as plain text by the time evaluation runs).

Prefer "after" whenever your replace_string contains macros. It collapses N evaluation calls to one (matching "escaped" performance) while keeping capture support (matching "raw" capability). It also matches how single-pass parsers in upstream regex pipelines already work, so ported rules behave the same.

The one observable difference from "raw": stateful macros ({{counter::*}}, {{addvar::*::1}}{{getvar::*}} patterns, etc.) accumulate left-to-right across matches in "after" mode rather than running in isolation per match. A counter that emitted 1, 1, 1, 1 under "raw" emits 1, 2, 3, 4 under "after". The "after" behavior is almost always what you actually want; stay on "raw" only if you specifically need per-match isolation.

Targets and where they fire

  • prompt rules run during prompt assembly, against each message before it goes to the LLM. They do not modify stored content.
  • response rules run once after the LLM stream ends, against the full assistant message. The result is written back to chat storage.
  • display rules run per render in the frontend. They do not modify stored content.

What getActive returns

getActive mirrors the resolution Lumiverse uses internally during a generation: only enabled rules, only rules whose target matches, and only rules whose scope applies to the supplied context. Use list instead when you need the raw, unfiltered view (including disabled rules) for management or analytics.


Reacting to changes

Users can edit, reorder, enable, disable, and delete regex scripts at any time through the regex panel. Subscribe to the script lifecycle events to keep extension-side caches in sync.

spindle.on('REGEX_SCRIPT_CHANGED', (payload) => {
  // payload: { id: string, script: RegexScriptDTO }
  // fires on create, update, duplicate, reorder, and enable/disable.
})

spindle.on('REGEX_SCRIPT_DELETED', (payload) => {
  // payload: { id: string }
})

A common pattern: cache spindle.regex_scripts.getActive(...) per chat, invalidate the cache on either event, and re-fetch lazily on the next read.