diff --git a/lib/core/llm/regex_service.dart b/lib/core/llm/regex_service.dart index 96545a58..c1c6c745 100644 --- a/lib/core/llm/regex_service.dart +++ b/lib/core/llm/regex_service.dart @@ -49,8 +49,22 @@ String applyRegexes( continue; } } - if (script.promptOnly && !isPrompt) continue; - if (script.markdownOnly && !isMarkdown) continue; + // ST semantics: "Only Format Display" (markdownOnly) and "Only Format + // Prompt" (promptOnly) are two independent opt-ins, not one exclusive + // switch — `getRegexedString` ORs the two clauses. A script that ticks + // both therefore runs in the display pass *and* in the prompt pass. + // Requiring both at once made such a script dead everywhere, because no + // caller passes `isMarkdown` and `isPrompt` together. + // + // The storage pass (run-on-edit) passes neither flag, and still skips + // both kinds: a display- or prompt-scoped rewrite must never be baked + // into the stored message. + if (script.promptOnly || script.markdownOnly) { + final wantsThisPass = + (script.promptOnly && isPrompt) || + (script.markdownOnly && isMarkdown); + if (!wantsThisPass) continue; + } final sEphemerality = script.ephemerality; if (!ignoreEphemerality && diff --git a/lib/core/state/active_regex_provider.dart b/lib/core/state/active_regex_provider.dart index 7efd3c15..f9cf04a2 100644 --- a/lib/core/state/active_regex_provider.dart +++ b/lib/core/state/active_regex_provider.dart @@ -26,5 +26,13 @@ final activeRegexesProvider = FutureProvider>((ref) async { final displayRegexesProvider = FutureProvider>((ref) async { final all = await ref.watch(activeRegexesProvider.future); - return all.where((r) => r.ephemerality.contains(1) && !r.promptOnly).toList(); + // "Only Format Prompt" keeps a script out of the display pass — unless it + // also ticks "Only Format Display", which opts it into both passes (ST ORs + // the two flags; see [applyRegexes]). + return all + .where( + (r) => + r.ephemerality.contains(1) && (!r.promptOnly || r.markdownOnly), + ) + .toList(); }); diff --git a/test/regex_service_test.dart b/test/regex_service_test.dart index f48e0eb8..225e1cf7 100644 --- a/test/regex_service_test.dart +++ b/test/regex_service_test.dart @@ -129,6 +129,61 @@ void main() { expect(prompt, equals('aYb')); }); + test( + 'markdownOnly + promptOnly runs in both passes, never on stored text', + () { + // The pair a user imports to swap spaces for U+2007 on the way out and + // back on the way in: the "return" half ticks both "Only Format + // Display" and "Only Format Prompt". ST ORs the two flags, so it must + // fire in the display pass and in the prompt pass alike. + final script = PresetRegex.fromJson({ + 'id': 'both-only', + 'name': 'Return Normal Spaces', + 'regex': '/\u2007/g', + 'replacement': ' ', + 'markdownOnly': true, + 'promptOnly': true, + 'placement': [1, 2], + 'ephemerality': [1, 2], + }); + + const input = 'a\u2007b'; + + expect( + applyRegexes(input, 2, 2, [script], ctx(), isPrompt: true), + equals('a b'), + ); + expect( + applyRegexes(input, 2, 1, [script], ctx(), isMarkdown: true), + equals('a b'), + ); + // Storage pass (run-on-edit): neither flag is set, so the rewrite must + // not be baked into the message. + expect(applyRegexes(input, 2, 1, [script], ctx()), equals(input)); + }, + ); + + test('promptOnly script rewrites spaces to figure spaces in the prompt', () { + final script = PresetRegex.fromJson({ + 'id': 'figure-spaces', + 'name': 'Use Figure Spaces', + 'regex': '/ /g', + 'replacement': '\u2007', + 'promptOnly': true, + 'placement': [1, 2], + 'ephemerality': [1, 2], + }); + + expect( + applyRegexes('a b', 2, 2, [script], ctx(), isPrompt: true), + equals('a\u2007b'), + ); + expect( + applyRegexes('a b', 2, 1, [script], ctx(), isMarkdown: true), + equals('a b'), + ); + }); + test('World Info placement 5 applies to lorebook blocks', () { final script = PresetRegex.fromJson({ 'id': 'wi-only',