@zihanw/pi-forge 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,16 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
- import type { PromptStack, PromptStackItem } from "./types.ts";
3
+ import type { PromptRegexRule, PromptStack, PromptStackItem } from "./types.ts";
4
4
 
5
5
  // ── SillyTavern preset raw types ──────────────────────────────────────────
6
6
 
7
7
  interface StPreset {
8
8
  prompts?: StPromptDef[];
9
9
  prompt_order?: StPromptOrderEntry[];
10
+ extensions?: {
11
+ regex_scripts?: unknown[];
12
+ [key: string]: unknown;
13
+ };
10
14
  names_behavior?: number;
11
15
  preset_name?: string;
12
16
  name?: string;
@@ -39,6 +43,57 @@ interface StConversionItem {
39
43
  orderIndex: number;
40
44
  }
41
45
 
46
+ interface StRegexScript {
47
+ script_name?: string;
48
+ scriptName?: string;
49
+ name?: string;
50
+ disabled?: boolean;
51
+ promptOnly?: boolean;
52
+ markdownOnly?: boolean;
53
+ findRegex?: string;
54
+ replaceString?: string;
55
+ trimStrings?: unknown;
56
+ substituteRegex?: unknown;
57
+ placement?: unknown;
58
+ minDepth?: unknown;
59
+ maxDepth?: unknown;
60
+ runOnEdit?: unknown;
61
+ [key: string]: unknown;
62
+ }
63
+
64
+ type RegexScriptMode = "prompt-only" | "markdown-only" | "prompt+markdown" | "unspecified" | "disabled";
65
+
66
+ interface RegexScriptReportEntry {
67
+ name: string;
68
+ mode: RegexScriptMode;
69
+ enabled: boolean;
70
+ promptOnly: boolean;
71
+ markdownOnly: boolean;
72
+ findRegex?: string;
73
+ replaceString?: string;
74
+ trimStrings?: string[];
75
+ placement?: number[];
76
+ substituteRegex?: number;
77
+ minDepth?: number;
78
+ maxDepth?: number;
79
+ convertedRuleId?: string;
80
+ conversionNote?: string;
81
+ conversionWarnings: string[];
82
+ }
83
+
84
+ interface RegexScriptReport {
85
+ total: number;
86
+ enabled: number;
87
+ disabled: number;
88
+ promptOnly: number;
89
+ markdownOnly: number;
90
+ mixed: number;
91
+ unspecified: number;
92
+ converted: number;
93
+ scripts: RegexScriptReportEntry[];
94
+ rules: PromptRegexRule[];
95
+ }
96
+
42
97
  // ── Marker identifiers ─────────────────────────────────────────────────────
43
98
 
44
99
  const MARKER_CHAT_HISTORY = "chatHistory";
@@ -52,22 +107,58 @@ const MARKER_SKIP = new Set([
52
107
  "dialogueExamples",
53
108
  ]);
54
109
 
110
+ const ST_REGEX_PLACEMENT_LABELS: Record<number, string> = {
111
+ 0: "MD Display",
112
+ 1: "User Input",
113
+ 2: "AI Output",
114
+ 3: "Slash Commands",
115
+ 5: "World Info",
116
+ 6: "Reasoning",
117
+ };
118
+
55
119
  // ── ST macros that pi-forge handles natively ───────────────────────────────
56
120
 
57
121
  const NATIVE_MACROS = new Set([
58
- "char", // → static variable
59
- "user", // → static variable
60
- "lastUserMessage", // → handled by pi-forge runtime
122
+ "char", // → static variable
123
+ "user", // → static variable
124
+ "lastusermessage", // → handled by pi-forge runtime
61
125
  "cwd",
62
126
  "date",
63
127
  "time",
128
+ "setvar",
129
+ "setturnvar",
130
+ "setsessionvar",
131
+ "getvar",
132
+ "var",
133
+ "getturnvar",
134
+ "getsessionvar",
135
+ "clearvar",
136
+ "clearturnvar",
137
+ "clearsessionvar",
64
138
  ]);
65
139
 
140
+ const MACRO_DISPLAY_NAMES: Record<string, string> = {
141
+ lastusermessage: "lastUserMessage",
142
+ groupnotmuted: "groupNotMuted",
143
+ notchar: "notChar",
144
+ charifnotgroup: "charIfNotGroup",
145
+ mesexamples: "mesExamples",
146
+ mesexamplesraw: "mesExamplesRaw",
147
+ charprompt: "charPrompt",
148
+ charinstruction: "charInstruction",
149
+ charversion: "charVersion",
150
+ charfirstmessage: "charFirstMessage",
151
+ wibefore: "wiBefore",
152
+ wiafter: "wiAfter",
153
+ lorebefore: "loreBefore",
154
+ loreafter: "loreAfter",
155
+ anchorbefore: "anchorBefore",
156
+ anchorafter: "anchorAfter",
157
+ };
158
+
66
159
  // ── ST macros that need manual migration ───────────────────────────────────
67
160
 
68
161
  const MACRO_NEEDS_MIGRATION: Record<string, string> = {
69
- "setvar": "pi-forge {{setvar::name::value}} / {{setsessionvar::name::value}} — scoping differs",
70
- "getvar": "pi-forge {{getvar::name}} — lookup order: turn → session → static",
71
162
  "addvar": "use pi-forge {{getvar::name}} + math, or setvar with computed value",
72
163
  "getglobalvar": "use pi-forge session variables via {{getsessionvar::name}}",
73
164
  "setglobalvar": "use pi-forge {{setsessionvar::name::value}}",
@@ -78,26 +169,39 @@ const MACRO_NEEDS_MIGRATION: Record<string, string> = {
78
169
  "original": "SillyTavern-specific — no direct equivalent; set as session variable if needed",
79
170
  "outlet": "SillyTavern extension outlet — no pi-forge equivalent",
80
171
  "group": "SillyTavern group chat — no pi-forge equivalent",
81
- "groupNotMuted": "SillyTavern group chat — no pi-forge equivalent",
82
- "notChar": "SillyTavern group chat — no pi-forge equivalent",
83
- "charIfNotGroup": "SillyTavern group chat — no pi-forge equivalent",
172
+ "groupnotmuted": "SillyTavern group chat — no pi-forge equivalent",
173
+ "notchar": "SillyTavern group chat — no pi-forge equivalent",
174
+ "charifnotgroup": "SillyTavern group chat — no pi-forge equivalent",
84
175
  "description": "ST character card field — set as session variable or static variable if needed",
85
176
  "personality": "ST character card field — set as session variable or static variable if needed",
86
177
  "scenario": "ST character card field — set as session variable or static variable if needed",
87
178
  "persona": "ST persona field — set as session variable or static variable if needed",
88
- "mesExamples": "ST character dialogue examples — add inline or as context file if needed",
89
- "mesExamplesRaw": "ST character dialogue examples — add inline or as context file if needed",
90
- "charPrompt": "ST character prompt override — merge into system prompt blocks if needed",
91
- "charInstruction": "ST character instruction override — merge into post-history blocks if needed",
92
- "charVersion": "ST character version — set as static variable if needed",
93
- "charFirstMessage": "ST character first message — add as static variable if needed",
179
+ "mesexamples": "ST character dialogue examples — add inline or as context file if needed",
180
+ "mesexamplesraw": "ST character dialogue examples — add inline or as context file if needed",
181
+ "charprompt": "ST character prompt override — merge into system prompt blocks if needed",
182
+ "charinstruction": "ST character instruction override — merge into post-history blocks if needed",
183
+ "charversion": "ST character version — set as static variable if needed",
184
+ "charfirstmessage": "ST character first message — add as static variable if needed",
94
185
  "system": "ST context template system prompt — handled by pi-forge system prompt replacement",
95
- "wiBefore": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
96
- "wiAfter": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
97
- "loreBefore": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
98
- "loreAfter": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
99
- "anchorBefore": "ST extension injection point — no pi-forge equivalent",
100
- "anchorAfter": "ST extension injection point — no pi-forge equivalent",
186
+ "wibefore": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
187
+ "wiafter": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
188
+ "lorebefore": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
189
+ "loreafter": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
190
+ "anchorbefore": "ST extension injection point — no pi-forge equivalent",
191
+ "anchorafter": "ST extension injection point — no pi-forge equivalent",
192
+ };
193
+
194
+ const NATIVE_MACRO_NOTES: Record<string, string> = {
195
+ setvar: "handled as a turn variable by default, with {{setvar::session::name::value}} for session scope",
196
+ setturnvar: "handled as a turn variable",
197
+ setsessionvar: "handled as a session variable",
198
+ getvar: "handled with turn -> session -> static lookup",
199
+ var: "handled as an alias of getvar",
200
+ getturnvar: "handled as a turn-only lookup",
201
+ getsessionvar: "handled as a session-only lookup",
202
+ clearvar: "handled as a turn variable clear by default, with {{clearvar::session::name}} for session scope",
203
+ clearturnvar: "handled as a turn variable clear",
204
+ clearsessionvar: "handled as a session variable clear",
101
205
  };
102
206
 
103
207
  // ── Macros we can strip entirely (produce no output) ───────────────────────
@@ -118,6 +222,12 @@ export interface SillyTavernImportError {
118
222
 
119
223
  export type SillyTavernImportOutcome = SillyTavernImportResult | SillyTavernImportError;
120
224
 
225
+ export interface SillyTavernConvertOptions {
226
+ sourceName?: string;
227
+ sourcePath?: string;
228
+ characterId?: number;
229
+ }
230
+
121
231
  // ── Importer ───────────────────────────────────────────────────────────────
122
232
 
123
233
  export function importSillyTavernPreset(
@@ -135,23 +245,42 @@ export function importSillyTavernPreset(
135
245
  return { error: "Preset root must be a JSON object." };
136
246
  }
137
247
 
248
+ return convertSillyTavernPreset(raw, { sourceName: filePath, sourcePath: filePath, characterId });
249
+ }
250
+
251
+ export function convertSillyTavernPreset(
252
+ raw: unknown,
253
+ options: SillyTavernConvertOptions = {},
254
+ ): SillyTavernImportOutcome {
255
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
256
+ return { error: "Preset root must be a JSON object." };
257
+ }
258
+
138
259
  const preset = raw as StPreset;
260
+ const fallbackName = typeof preset.preset_name === "string" && preset.preset_name.trim()
261
+ ? preset.preset_name
262
+ : typeof preset.name === "string" && preset.name.trim()
263
+ ? preset.name
264
+ : "imported.json";
265
+ const sourceName = options.sourceName ?? fallbackName;
266
+ const sourcePath = options.sourcePath ?? sourceName;
139
267
  const allPrompts = preset.prompts ?? [];
140
268
  const promptOrder = preset.prompt_order ?? [];
269
+ const regexScripts = summarizeRegexScripts(preset);
141
270
 
142
271
  if (allPrompts.length === 0) {
143
272
  return { error: "Preset has no prompts array." };
144
273
  }
145
274
 
146
275
  // Select character_id
147
- const selectedEntry = selectCharacterEntry(promptOrder, characterId);
276
+ const selectedEntry = selectCharacterEntry(promptOrder, options.characterId);
148
277
  if (!selectedEntry) {
149
278
  const ids = promptOrder.map((entry) => entry.character_id);
150
279
  if (ids.length === 0) {
151
- return { error: "Preset has no prompt_order entries. Specify a character_id with /preset import-silly <path> <character_id>." };
280
+ return { error: "Preset has no prompt_order entries. Choose a character_id and retry." };
152
281
  }
153
282
  return {
154
- error: `Multiple character configs found: [${ids.join(", ")}]. Re-run with /preset import-silly <path> <character_id>.`,
283
+ error: `Multiple character configs found: [${ids.join(", ")}]. Choose a character_id and retry.`,
155
284
  };
156
285
  }
157
286
 
@@ -219,8 +348,8 @@ export function importSillyTavernPreset(
219
348
  continue;
220
349
  }
221
350
 
222
- // Detect {{lastUserMessage}} usage
223
- if (content.includes("{{lastUserMessage}}")) {
351
+ // Detect {{lastUserMessage}} usage, case-insensitively.
352
+ if (/\{\{\s*lastUserMessage\b/i.test(content)) {
224
353
  usesLastUserMessage = true;
225
354
  }
226
355
 
@@ -258,7 +387,7 @@ export function importSillyTavernPreset(
258
387
  }
259
388
 
260
389
  // Build stack name from file
261
- const fileName = basename(filePath, ".json");
390
+ const fileName = basename(sourceName).replace(/\.json$/i, "") || "imported";
262
391
  const styleName = preset.names_behavior === 1 ? "names" : preset.names_behavior === 2 ? "nonames" : "default";
263
392
  const stackId = fileName.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "imported";
264
393
 
@@ -276,6 +405,7 @@ export function importSillyTavernPreset(
276
405
  mode: "replace",
277
406
  variables: Object.keys(variables).length > 0 ? variables : undefined,
278
407
  context: { allowDuplicateChatHistory: false },
408
+ regex: regexScripts && regexScripts.rules.length > 0 ? { schemaVersion: 1, rules: regexScripts.rules } : undefined,
279
409
  items,
280
410
  import: {
281
411
  source: "sillytavern",
@@ -285,7 +415,8 @@ export function importSillyTavernPreset(
285
415
  },
286
416
  };
287
417
 
288
- // Add chat-history option if {{lastUserMessage}} is used after history
418
+ // Add chat-history option if {{lastUserMessage}} is used after history.
419
+ if (macroUsage.detected.has("lastusermessage")) usesLastUserMessage = true;
289
420
  if (usesLastUserMessage) {
290
421
  const chatHistoryItem = items.find(
291
422
  (item) => item.kind === "slot" && item.slot === "chat-history",
@@ -299,7 +430,7 @@ export function importSillyTavernPreset(
299
430
  const reportLines: string[] = [];
300
431
  reportLines.push(`# SillyTavern Import Report: ${fileName}`);
301
432
  reportLines.push("");
302
- reportLines.push(`- **Source file**: ${filePath}`);
433
+ reportLines.push(`- **Source file**: ${sourcePath}`);
303
434
  reportLines.push(`- **Character ID**: ${selectedEntry.character_id}`);
304
435
  reportLines.push(`- **Output stack ID**: ${stackId}`);
305
436
  reportLines.push(`- **Names behavior**: ${styleName} (ST value ${preset.names_behavior ?? "?"})`);
@@ -343,6 +474,47 @@ export function importSillyTavernPreset(
343
474
  reportLines.push("");
344
475
  }
345
476
 
477
+ // Regex scripts
478
+ if (regexScripts && regexScripts.total > 0) {
479
+ reportLines.push("## SillyTavern regex scripts");
480
+ reportLines.push("");
481
+ reportLines.push(`- **Total scripts**: ${regexScripts.total}`);
482
+ reportLines.push(`- **Enabled scripts**: ${regexScripts.enabled}`);
483
+ reportLines.push(`- **Disabled scripts**: ${regexScripts.disabled}`);
484
+ reportLines.push(`- **Prompt-only**: ${regexScripts.promptOnly}`);
485
+ reportLines.push(`- **Markdown-only / display-only**: ${regexScripts.markdownOnly}`);
486
+ reportLines.push(`- **Prompt + markdown mixed**: ${regexScripts.mixed}`);
487
+ if (regexScripts.unspecified > 0) {
488
+ reportLines.push(`- **Enabled with unspecified mode**: ${regexScripts.unspecified}`);
489
+ }
490
+ reportLines.push(`- **Converted to pi-forge rules**: ${regexScripts.converted}`);
491
+ reportLines.push("");
492
+ reportLines.push("| Script | Enabled | Mode | Converted | Warnings | Find regex | Replacement preview |");
493
+ reportLines.push("|--------|---------|------|-----------|----------|------------|---------------------|");
494
+ for (const script of regexScripts.scripts) {
495
+ const converted = script.convertedRuleId ? `yes: ${script.convertedRuleId}` : script.conversionNote ?? "no";
496
+ const warnings = script.conversionWarnings.length > 0 ? script.conversionWarnings.join("; ") : "-";
497
+ reportLines.push(`| ${markdownTableCell(script.name)} | ${script.enabled ? "yes" : "no"} | ${script.mode} | ${markdownTableCell(converted)} | ${markdownTableCell(warnings)} | ${markdownTableCell(script.findRegex)} | ${markdownTableCell(script.replaceString)} |`);
498
+ }
499
+ reportLines.push("");
500
+ const warningEntries = regexScripts.scripts.filter((script) => script.conversionWarnings.length > 0);
501
+ if (warningEntries.length > 0) {
502
+ reportLines.push("Conversion warnings:");
503
+ for (const script of warningEntries) {
504
+ for (const warning of script.conversionWarnings) {
505
+ reportLines.push(`- ${script.name}: ${warning}`);
506
+ }
507
+ }
508
+ reportLines.push("");
509
+ }
510
+ reportLines.push("pi-forge does not run SillyTavern markdown rewriting, DOM/browser automation, CSS/HTML decoration, toasts, embedded JavaScript, or UI panel behavior.");
511
+ if (regexScripts.converted > 0) {
512
+ reportLines.push("Enabled and disabled prompt-only deterministic regex scripts were converted to `regex.rules` with `stage: \"history\"`, `effect: \"outgoing\"`, JavaScript replacement syntax, and preserved SillyTavern metadata under `source.sillytavern`. History-stage depth is relative to the filtered chat history inserted at the `chat-history` slot, matching SillyTavern's chat-relative depth, and rules do not touch the compiled system prompt. Use `stage: \"compiled\"` manually for global prompt transforms.");
513
+ }
514
+ reportLines.push("Markdown-only, mixed prompt/markdown, unspecified, invalid, or unsupported regex scripts remain report-only and require manual review.");
515
+ reportLines.push("");
516
+ }
517
+
346
518
  // Auto-populated variables
347
519
  if (Object.keys(variables).length > 0) {
348
520
  reportLines.push("## Auto-populated variables");
@@ -353,7 +525,7 @@ export function importSillyTavernPreset(
353
525
  reportLines.push(`- \`${key}\` = \`${val}\` — replace with your character/persona name`);
354
526
  }
355
527
  reportLines.push("");
356
- reportLines.push("Use `/preset vars set <name> <value>` or edit the stack JSON to set real values.");
528
+ reportLines.push("Edit `stack.variables` in the stack JSON or web editor to set real values.");
357
529
  reportLines.push("");
358
530
  }
359
531
 
@@ -366,23 +538,10 @@ export function importSillyTavernPreset(
366
538
  reportLines.push("");
367
539
  for (const [name, count] of migrationEntries) {
368
540
  const note = MACRO_NEEDS_MIGRATION[name] ?? "no mapping available";
369
- reportLines.push(`- **\`{{${name}}}\`** (${count} occurrence${count > 1 ? "s" : ""}) — ${note}`);
541
+ reportLines.push(`- **\`{{${displayMacroName(name)}}}\`** (${count} occurrence${count > 1 ? "s" : ""}) — ${note}`);
370
542
  }
371
543
  reportLines.push("");
372
544
 
373
- // Specific guidance for heavy variable usage
374
- if (macroUsage.migrationNeeded.setvar || macroUsage.migrationNeeded.getvar) {
375
- reportLines.push("### Variable system migration");
376
- reportLines.push("");
377
- reportLines.push("ST uses `setvar`/`getvar` for ephemeral state. pi-forge has a different variable model:");
378
- reportLines.push("");
379
- reportLines.push("- **Static variables** — set in the stack JSON's `variables` object (character names, fixed config)");
380
- reportLines.push("- **Session variables** — persist across turns, set via `/preset vars set` or `forge_set_var` tool");
381
- reportLines.push("- **Turn variables** — ephemeral, set via `{{setvar::name::value}}` macros in blocks");
382
- reportLines.push("");
383
- reportLines.push("Review ST `setvar`/`getvar` calls and migrate to the appropriate pi-forge scope.");
384
- reportLines.push("");
385
- }
386
545
  }
387
546
 
388
547
  // Native macros handled
@@ -391,12 +550,15 @@ export function importSillyTavernPreset(
391
550
  reportLines.push("## Handled macros");
392
551
  reportLines.push("");
393
552
  for (const name of nativeDetected) {
553
+ const displayName = displayMacroName(name);
394
554
  if (name === "char" || name === "user") {
395
- reportLines.push(`- \`{{${name}}}\` → auto-populated as static variable`);
396
- } else if (name === "lastUserMessage") {
397
- reportLines.push(`- \`{{${name}}}\` → handled by pi-forge runtime (chat-history slot)`);
555
+ reportLines.push(`- \`{{${displayName}}}\` → auto-populated as static variable`);
556
+ } else if (name === "lastusermessage") {
557
+ reportLines.push(`- \`{{${displayName}}}\` → handled by pi-forge runtime (chat-history slot)`);
558
+ } else if (NATIVE_MACRO_NOTES[name]) {
559
+ reportLines.push(`- \`{{${displayName}}}\` → ${NATIVE_MACRO_NOTES[name]}`);
398
560
  } else {
399
- reportLines.push(`- \`{{${name}}}\` → handled natively by pi-forge`);
561
+ reportLines.push(`- \`{{${displayName}}}\` → handled natively by pi-forge`);
400
562
  }
401
563
  }
402
564
  reportLines.push("");
@@ -434,9 +596,9 @@ export function importSillyTavernPreset(
434
596
  reportLines.push("");
435
597
  reportLines.push("## Suggested next steps");
436
598
  reportLines.push("");
437
- reportLines.push("1. Set real values for auto-populated variables with `/preset vars set`.");
599
+ reportLines.push("1. Set real values for auto-populated variables by editing `stack.variables`.");
438
600
  reportLines.push("2. Review items with migration-needed macros and rewrite for pi-forge's macro system.");
439
- reportLines.push("3. Consider adding a `variables` slot for agent state visibility.");
601
+ reportLines.push("3. Consider adding a `variables` slot for template variable visibility.");
440
602
  reportLines.push(`4. Run \`/preset validate ${stackId}\` to check for issues.`);
441
603
 
442
604
  return {
@@ -445,6 +607,274 @@ export function importSillyTavernPreset(
445
607
  };
446
608
  }
447
609
 
610
+ // ── Regex script reporting ─────────────────────────────────────────────────
611
+
612
+ function summarizeRegexScripts(preset: StPreset): RegexScriptReport | undefined {
613
+ const rawScripts = preset.extensions?.regex_scripts;
614
+ if (!Array.isArray(rawScripts)) return undefined;
615
+
616
+ const scripts = rawScripts
617
+ .map((raw, index) => classifyRegexScript(raw, index))
618
+ .filter((script): script is RegexScriptReportEntry => script !== undefined);
619
+
620
+ const report: RegexScriptReport = {
621
+ total: scripts.length,
622
+ enabled: 0,
623
+ disabled: 0,
624
+ promptOnly: 0,
625
+ markdownOnly: 0,
626
+ mixed: 0,
627
+ unspecified: 0,
628
+ converted: 0,
629
+ scripts,
630
+ rules: [],
631
+ };
632
+
633
+ const seenRuleIds = new Set<string>();
634
+ for (const [index, script] of scripts.entries()) {
635
+ const conversion = convertPromptOnlyRegexScript(script, index, seenRuleIds);
636
+ if (conversion.rule) {
637
+ report.rules.push(conversion.rule);
638
+ report.converted++;
639
+ script.convertedRuleId = conversion.rule.id;
640
+ } else if (conversion.note) {
641
+ script.conversionNote = conversion.note;
642
+ }
643
+
644
+ if (!script.enabled) {
645
+ report.disabled++;
646
+ continue;
647
+ }
648
+ report.enabled++;
649
+ if (script.mode === "prompt-only") report.promptOnly++;
650
+ else if (script.mode === "markdown-only") report.markdownOnly++;
651
+ else if (script.mode === "prompt+markdown") report.mixed++;
652
+ else if (script.mode === "unspecified") report.unspecified++;
653
+ }
654
+
655
+ return report;
656
+ }
657
+
658
+ function classifyRegexScript(raw: unknown, index: number): RegexScriptReportEntry | undefined {
659
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
660
+ const script = raw as StRegexScript;
661
+ const enabled = script.disabled !== true;
662
+ const promptOnly = script.promptOnly === true;
663
+ const markdownOnly = script.markdownOnly === true;
664
+ const mode: RegexScriptMode = enabled
665
+ ? promptOnly && markdownOnly
666
+ ? "prompt+markdown"
667
+ : promptOnly
668
+ ? "prompt-only"
669
+ : markdownOnly
670
+ ? "markdown-only"
671
+ : "unspecified"
672
+ : "disabled";
673
+
674
+ return {
675
+ name: firstString(script.script_name, script.scriptName, script.name) ?? `regex-${index + 1}`,
676
+ mode,
677
+ enabled,
678
+ promptOnly,
679
+ markdownOnly,
680
+ findRegex: firstString(script.findRegex),
681
+ replaceString: firstString(script.replaceString),
682
+ trimStrings: normalizeTrimStrings(script.trimStrings),
683
+ placement: normalizeNumberArray(script.placement),
684
+ substituteRegex: normalizeNumber(script.substituteRegex),
685
+ minDepth: normalizeDepth(script.minDepth, -1),
686
+ maxDepth: normalizeDepth(script.maxDepth, 0),
687
+ conversionWarnings: [],
688
+ };
689
+ }
690
+
691
+ function convertPromptOnlyRegexScript(
692
+ entry: RegexScriptReportEntry,
693
+ index: number,
694
+ seenRuleIds: Set<string>,
695
+ ): { rule?: PromptRegexRule; note?: string } {
696
+ if (!entry.promptOnly) return { note: entry.enabled ? "not prompt-only" : "disabled non-prompt script" };
697
+ if (entry.markdownOnly) return { note: "mixed prompt/display script requires review" };
698
+ if (!entry.findRegex) return { note: "missing findRegex" };
699
+
700
+ const parsed = parseSillyTavernRegex(entry.findRegex);
701
+ if ("error" in parsed) return { note: parsed.error };
702
+ if (entry.placement && entry.placement.length > 0 && !entry.placement.some((placement) => placement === 1 || placement === 2)) {
703
+ return { note: "placement has no pi-forge runtime mapping" };
704
+ }
705
+ const replacement = normalizeSillyTavernReplacement(entry.replaceString ?? "", entry.conversionWarnings);
706
+ const roles = rolesForSillyTavernPlacement(entry.placement, entry.conversionWarnings);
707
+ if (entry.substituteRegex && entry.substituteRegex !== 0) {
708
+ entry.conversionWarnings.push("substituteRegex is preserved as metadata but dynamic find-regex macro substitution is not executed");
709
+ }
710
+ for (const placement of entry.placement ?? []) {
711
+ if (placement !== 1 && placement !== 2) {
712
+ entry.conversionWarnings.push(`placement ${placementLabel(placement)} is preserved as metadata but not directly mapped`);
713
+ }
714
+ }
715
+
716
+ const id = uniqueRegexRuleId(`st-${entry.name || `regex-${index + 1}`}`, seenRuleIds);
717
+ const rule: PromptRegexRule = {
718
+ id,
719
+ name: entry.name,
720
+ enabled: entry.enabled,
721
+ stage: "history",
722
+ effect: "outgoing",
723
+ pattern: parsed.pattern,
724
+ replace: replacement,
725
+ source: {
726
+ sillytavern: {
727
+ scriptName: entry.name,
728
+ findRegex: entry.findRegex,
729
+ replaceString: entry.replaceString ?? "",
730
+ trimStrings: entry.trimStrings ?? [],
731
+ placement: entry.placement ?? [],
732
+ placementNames: (entry.placement ?? []).map(placementLabel),
733
+ promptOnly: entry.promptOnly,
734
+ markdownOnly: entry.markdownOnly,
735
+ substituteRegex: entry.substituteRegex ?? 0,
736
+ minDepth: entry.minDepth,
737
+ maxDepth: entry.maxDepth,
738
+ },
739
+ },
740
+ };
741
+ if (parsed.flags) rule.flags = parsed.flags;
742
+ if (entry.trimStrings && entry.trimStrings.length > 0) rule.trimStrings = entry.trimStrings;
743
+ if (roles && roles.length > 0) rule.roles = roles;
744
+ if (isNonNegativeInteger(entry.minDepth)) rule.minDepth = entry.minDepth;
745
+ if (isNonNegativeInteger(entry.maxDepth)) rule.maxDepth = entry.maxDepth;
746
+ return {
747
+ rule,
748
+ };
749
+ }
750
+
751
+ function parseSillyTavernRegex(value: string): { pattern: string; flags?: string } | { error: string } {
752
+ const trimmed = value.trim();
753
+ if (!trimmed) return { error: "empty findRegex" };
754
+
755
+ if (!trimmed.startsWith("/")) return validateParsedRegex(trimmed, "");
756
+
757
+ const closingSlash = findRegexLiteralClosingSlash(trimmed);
758
+ if (closingSlash <= 0) return { error: "could not parse regex literal" };
759
+
760
+ const pattern = trimmed.slice(1, closingSlash);
761
+ const flags = trimmed.slice(closingSlash + 1);
762
+ return validateParsedRegex(pattern, flags);
763
+ }
764
+
765
+ function normalizeSillyTavernReplacement(value: string, warnings: string[]): string {
766
+ let result = value;
767
+ if (/\{\{\s*match\s*\}\}/i.test(result)) {
768
+ result = result.replace(/\{\{\s*match\s*\}\}/gi, () => "$&");
769
+ warnings.push("converted SillyTavern {{match}} to JavaScript $&");
770
+ }
771
+ if (/\$0(?!\d)/.test(result)) {
772
+ result = result.replace(/\$0(?!\d)/g, () => "$&");
773
+ warnings.push("converted SillyTavern-style $0 full match to JavaScript $&");
774
+ }
775
+ return result;
776
+ }
777
+
778
+ function rolesForSillyTavernPlacement(placement: number[] | undefined, warnings: string[]): string[] | undefined {
779
+ if (!placement || placement.length === 0) return undefined;
780
+ const roles: string[] = [];
781
+ if (placement.includes(1)) roles.push("user");
782
+ if (placement.includes(2)) roles.push("assistant");
783
+ if (placement.includes(6)) warnings.push("reasoning placement is preserved as metadata; use stripAssistantThinking or manual rules for thinking content");
784
+ return roles.length > 0 ? roles : undefined;
785
+ }
786
+
787
+ function placementLabel(value: number): string {
788
+ return ST_REGEX_PLACEMENT_LABELS[value] ? `${ST_REGEX_PLACEMENT_LABELS[value]} (${value})` : `unknown (${value})`;
789
+ }
790
+
791
+ function normalizeTrimStrings(value: unknown): string[] | undefined {
792
+ if (typeof value === "string") {
793
+ const strings = value.split(/\r?\n/).filter((entry) => entry.length > 0);
794
+ return strings.length > 0 ? strings : undefined;
795
+ }
796
+ if (!Array.isArray(value)) return undefined;
797
+ const strings = value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
798
+ return strings.length > 0 ? strings : undefined;
799
+ }
800
+
801
+ function normalizeNumberArray(value: unknown): number[] | undefined {
802
+ if (!Array.isArray(value)) return undefined;
803
+ const numbers = value
804
+ .map(normalizeNumber)
805
+ .filter((entry): entry is number => entry !== undefined);
806
+ return numbers.length > 0 ? numbers : undefined;
807
+ }
808
+
809
+ function normalizeNumber(value: unknown): number | undefined {
810
+ if (typeof value === "number" && Number.isFinite(value)) return value;
811
+ if (typeof value === "string" && value.trim()) {
812
+ const parsed = Number(value);
813
+ if (Number.isFinite(parsed)) return parsed;
814
+ }
815
+ if (typeof value === "boolean") return value ? 1 : 0;
816
+ return undefined;
817
+ }
818
+
819
+ function normalizeDepth(value: unknown, minimum: number): number | undefined {
820
+ const number = normalizeNumber(value);
821
+ if (number === undefined || !Number.isInteger(number) || number < minimum) return undefined;
822
+ return number;
823
+ }
824
+
825
+ function isNonNegativeInteger(value: unknown): value is number {
826
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
827
+ }
828
+
829
+ function validateParsedRegex(pattern: string, flags: string): { pattern: string; flags?: string } | { error: string } {
830
+ if (!pattern) return { error: "empty regex pattern" };
831
+ const seen = new Set<string>();
832
+ for (const flag of flags) {
833
+ if (!["g", "i", "m", "s", "u"].includes(flag)) return { error: `unsupported regex flag: ${flag}` };
834
+ if (seen.has(flag)) return { error: `duplicate regex flag: ${flag}` };
835
+ seen.add(flag);
836
+ }
837
+ try {
838
+ new RegExp(pattern, flags);
839
+ } catch (error) {
840
+ return { error: `regex failed to compile: ${error instanceof Error ? error.message : String(error)}` };
841
+ }
842
+ return { pattern, flags: flags || undefined };
843
+ }
844
+
845
+ function findRegexLiteralClosingSlash(value: string): number {
846
+ for (let index = value.length - 1; index > 0; index--) {
847
+ if (value[index] !== "/") continue;
848
+ let slashCount = 0;
849
+ for (let backslash = index - 1; backslash >= 0 && value[backslash] === "\\"; backslash--) slashCount++;
850
+ if (slashCount % 2 === 0) return index;
851
+ }
852
+ return -1;
853
+ }
854
+
855
+ function uniqueRegexRuleId(base: string, seen: Set<string>): string {
856
+ const normalized = base.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "st-regex";
857
+ let candidate = normalized;
858
+ let suffix = 2;
859
+ while (seen.has(candidate)) candidate = `${normalized}-${suffix++}`;
860
+ seen.add(candidate);
861
+ return candidate;
862
+ }
863
+
864
+ function firstString(...values: unknown[]): string | undefined {
865
+ for (const value of values) {
866
+ if (typeof value === "string" && value.trim()) return value;
867
+ }
868
+ return undefined;
869
+ }
870
+
871
+ function markdownTableCell(value: unknown): string {
872
+ if (typeof value !== "string" || !value.trim()) return "-";
873
+ const collapsed = value.replace(/\s+/g, " ").trim();
874
+ const truncated = collapsed.length > 96 ? `${collapsed.slice(0, 93)}...` : collapsed;
875
+ return truncated.replace(/\|/g, "\\|");
876
+ }
877
+
448
878
  // ── Macro detection ────────────────────────────────────────────────────────
449
879
 
450
880
  interface MacroDetection {
@@ -495,6 +925,10 @@ function detectMacros(conversionItems: StConversionItem[]): MacroDetection {
495
925
 
496
926
  // ── Content cleaning ──────────────────────────────────────────────────────
497
927
 
928
+ function displayMacroName(name: string): string {
929
+ return MACRO_DISPLAY_NAMES[name] ?? name;
930
+ }
931
+
498
932
  function cleanContent(content: string): string {
499
933
  // Collapse multiple blank lines (max 1 blank line between paragraphs)
500
934
  return content.replace(/\n{3,}/g, "\n\n").trim();