Repository navigation
Regex lorebook keys silently fail on later scanned messages (arg.keys mutated during scan) #1530
Description
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 — butarg.keys[0]now holds the stripped string/(?!)|\bstudio\b. - 2nd message:
regexStringis the stripped string.regexString.split('/').pop()now returns the pattern body instead of the flags, and the code effectively callsnew RegExp('', '(?!)|\\bstudio\\b'), which throwsSyntaxError: 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: SyntaxErrorAdditional 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/flagskey 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
cubicj commentedon Jul 12, 2026
I can reproduce all four defects on current main, and the proposed fix looks right. Feel free to open a PR.
renamerdis-wq commentedon Jul 12, 2026
Thanks for confirming! I’ve opened PR #1532.
Fix searchMatch key mutation in regex lorebook (kwaroran#1532)