Skip to content

Regex lorebook keys silently fail on later scanned messages (arg.keys mutated during scan) #1530

Closed

Description

@renamerdis-wq

Summary

When a regex lorebook key does not match the first (oldest) scanned message, the key stored in arg.keys has already been mutated, and later messages are matched against a corrupted key — typically failing with a caught SyntaxError. In practice, regex entries fail to activate on triggers that appear in recent messages, which is the common case.

The regex matching loop mutates arg.keys[0] while scanning messages. After the first unsuccessful message, the stored regex key no longer contains its trailing delimiter and flags, so the next iteration parses the pattern body as flags. The resulting SyntaxError is caught and the function returns false; the lorebook entry is silently skipped, with no error logged by this branch.

The same block has three additional defects:

  • the opening / is kept as part of the regex pattern;
  • regexes with no flags, such as /studio/, are never tested;
  • the parsed pattern is always written to arg.keys[0], even while processing a later key, so with multiple regex keys the first key's original value is destroyed and replaced by a mangled copy of whichever key was processed last.

Environment

  • Checked against main (src/ts/process/lorebook.svelte.ts), 2026-07-11.

Location

  • File: src/ts/process/lorebook.svelte.ts
  • Function: searchMatch
  • Branch: if (arg.regex)

Current behavior

if(arg.regex){
    for(const mText of mList){
        for(const regexString of arg.keys){
            if(!regexString.startsWith('/')){
                return false
            }
            const regexFlag = regexString.split('/').pop()
            if(regexFlag){
                arg.keys[0] = regexString.replace('/'+regexFlag,'')
                try {
                    const regex = new RegExp(arg.keys[0],regexFlag)
                    const d = regex.test(mText.data)
                    if(d){
                        matchLog.push({
                            prompt: mText.prompt,
                            source: mText.source,
                            activated: regexString
                        })
                        return true
                    }
                } catch (error) {
                    return false
                }
            }
        }
    }
    return false
}

Root cause

This line mutates the shared key array during iteration:

arg.keys[0] = regexString.replace('/' + regexFlag, '')

mList is iterated oldest → newest, and the inner loop re-reads arg.keys for every message. For a key such as:

/(?!)|\bstudio\b/i
  • 1st message: regexFlag = 'i', the key compiles correctly, and the message is tested. If it does not match, the loop moves on — but arg.keys[0] now holds the stripped string /(?!)|\bstudio\b.
  • 2nd message: regexString is the stripped string. regexString.split('/').pop() now returns the pattern body instead of the flags, and the code effectively calls new RegExp('', '(?!)|\\bstudio\\b'), which throws SyntaxError: Invalid flags.
  • The exception is caught with return false, so the remaining messages and keys are never tested and the entry is silently skipped.

As a result, a regex lorebook entry only activates reliably if the match happens to be in the oldest message of the scan window (or if searchDepth is 1); later messages are evaluated against a corrupted key. Because the failure is intermittent — it works when the trigger happens to sit in the oldest scanned message — it is easy to misdiagnose as a configuration problem.

Minimal reproduction

function searchMatchRegex(key, messages) {
  const arg = { keys: [key] };

  for (const message of messages) {
    for (const regexString of arg.keys) {
      if (!regexString.startsWith('/')) return false;

      const regexFlag = regexString.split('/').pop();

      if (regexFlag) {
        arg.keys[0] = regexString.replace('/' + regexFlag, '');

        try {
          if (new RegExp(arg.keys[0], regexFlag).test(message)) {
            return true;
          }
        } catch (error) {
          return `CAUGHT: ${error.name}`;
        }
      }
    }
  }

  return false;
}

// The "(?!)|" prefix works around the leading-slash defect
// described below, so this example isolates the mutation bug.
const key = '/(?!)|\\bstudio\\b/i';

console.log(searchMatchRegex(key, ['the studio']));
// true

console.log(searchMatchRegex(key, ['no match', 'the studio']));
// CAUGHT: SyntaxError

Additional defects in the same block

1. Opening slash remains in the pattern

Removing only '/' + regexFlag leaves the opening / in place. For example:

/유즈|yuzu/i

is compiled with the pattern /유즈|yuzu, so the first alternative requires a literal / in the text and effectively never matches. (Prepending a non-matching (?!) alternative works around this defect: the leading slash then attaches to the dead alternative instead of a usable one.) With a single-alternative key such as /\bstudio\b/i, the whole pattern becomes /\bstudio\b and the key cannot match ordinary text at all — even in the first scanned message.

2. Regexes without flags are skipped

For /studio/, regexString.split('/').pop() returns an empty string. Since the regex is only evaluated inside if (regexFlag), the key is never tested.

3. Multiple regex keys clobber arg.keys[0]

The parsed value is always written to arg.keys[0], even while the loop is processing a later key. With more than one regex key, the first key's original value is destroyed after the first message and replaced by a mangled copy of the last-processed key.

Proposed fix

Parse the pattern and flags into local variables without mutating arg.keys:

         if(arg.regex){
             for(const mText of mList){
                 for(const regexString of arg.keys){
-                    if(!regexString.startsWith('/')){
+                    const parsed = regexString.match(/^\/([\s\S]*)\/([dgimsuvy]*)$/)
+                    if(!parsed){
                         return false
                     }
-                    const regexFlag = regexString.split('/').pop()
-                    if(regexFlag){
-                        arg.keys[0] = regexString.replace('/'+regexFlag,'')
-                        try {
-                            const regex = new RegExp(arg.keys[0],regexFlag)
-                            const d = regex.test(mText.data)
-                            if(d){
-                                matchLog.push({
-                                    prompt: mText.prompt,
-                                    source: mText.source,
-                                    activated: regexString
-                                })
-                                return true
-                            }
-                        } catch (error) {
-                            return false
+
+                    const [, pattern, flags] = parsed
+
+                    try {
+                        const regex = new RegExp(pattern, flags)
+                        if(regex.test(mText.data)){
+                            matchLog.push({
+                                prompt: mText.prompt,
+                                source: mText.source,
+                                activated: regexString
+                            })
+                            return true
                         }
+                    } catch (error) {
+                        return false
                     }
                 }
             }
             return false
         }

This fixes all four defects at once: no mutation, the opening / is excluded from the pattern, flagless regexes are accepted (([dgimsuvy]*) matches an empty flag string), and each key is evaluated independently.

An equivalent alternative is to split at lastIndexOf('/') and let the RegExp constructor validate the flags, which avoids hardcoding the flag list; either approach removes the mutation.

Notes on behavior changes:

  • Existing keys using the (?!)| prefix workaround will continue to work after this change, because (?!) is a non-matching alternative.
  • Entries whose keys previously could not activate — flagless regexes, and matches in any message after the first scanned one — will begin activating. This is the intended fix.
  • Keys that (likely unintentionally) relied on the retained leading slash may change behavior; the new parsing aligns with the apparent /pattern/flags key format.

Expected behavior

Case Current behavior Expected behavior
Match in first scanned message Activates in some cases (leading-slash defect can still block it) Activates
Match only in a later scanned message Caught SyntaxError, returns false Activates
Flagless regex /studio/ Skipped Activates
Multiple regex keys arg.keys[0] clobbered after the first message Each key evaluated independently

Activity

  1. cubicj commented on Jul 12, 2026

    @cubicj
    Collaborator

    I can reproduce all four defects on current main, and the proposed fix looks right. Feel free to open a PR.

  2. renamerdis-wq commented on Jul 12, 2026

    @renamerdis-wq
    Author

    Thanks for confirming! I’ve opened PR #1532.

  3. added a commit that references this issue on Oct 10, 2026
    979dbc9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions