@zihanw/pi-forge 0.2.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,6 +107,15 @@ 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([
@@ -61,6 +125,16 @@ const NATIVE_MACROS = new Set([
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
 
66
140
  const MACRO_DISPLAY_NAMES: Record<string, string> = {
@@ -85,8 +159,6 @@ const MACRO_DISPLAY_NAMES: Record<string, string> = {
85
159
  // ── ST macros that need manual migration ───────────────────────────────────
86
160
 
87
161
  const MACRO_NEEDS_MIGRATION: Record<string, string> = {
88
- "setvar": "pi-forge {{setvar::name::value}} / {{setsessionvar::name::value}} — scoping differs",
89
- "getvar": "pi-forge {{getvar::name}} — lookup order: turn → session → static",
90
162
  "addvar": "use pi-forge {{getvar::name}} + math, or setvar with computed value",
91
163
  "getglobalvar": "use pi-forge session variables via {{getsessionvar::name}}",
92
164
  "setglobalvar": "use pi-forge {{setsessionvar::name::value}}",
@@ -119,6 +191,19 @@ const MACRO_NEEDS_MIGRATION: Record<string, string> = {
119
191
  "anchorafter": "ST extension injection point — no pi-forge equivalent",
120
192
  };
121
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",
205
+ };
206
+
122
207
  // ── Macros we can strip entirely (produce no output) ───────────────────────
123
208
 
124
209
  const COMMENT_MACRO_RE = /\{\{\s*\/\/[\s\S]*?\}\}/g;
@@ -181,6 +266,7 @@ export function convertSillyTavernPreset(
181
266
  const sourcePath = options.sourcePath ?? sourceName;
182
267
  const allPrompts = preset.prompts ?? [];
183
268
  const promptOrder = preset.prompt_order ?? [];
269
+ const regexScripts = summarizeRegexScripts(preset);
184
270
 
185
271
  if (allPrompts.length === 0) {
186
272
  return { error: "Preset has no prompts array." };
@@ -319,6 +405,7 @@ export function convertSillyTavernPreset(
319
405
  mode: "replace",
320
406
  variables: Object.keys(variables).length > 0 ? variables : undefined,
321
407
  context: { allowDuplicateChatHistory: false },
408
+ regex: regexScripts && regexScripts.rules.length > 0 ? { schemaVersion: 1, rules: regexScripts.rules } : undefined,
322
409
  items,
323
410
  import: {
324
411
  source: "sillytavern",
@@ -387,6 +474,47 @@ export function convertSillyTavernPreset(
387
474
  reportLines.push("");
388
475
  }
389
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
+
390
518
  // Auto-populated variables
391
519
  if (Object.keys(variables).length > 0) {
392
520
  reportLines.push("## Auto-populated variables");
@@ -397,7 +525,7 @@ export function convertSillyTavernPreset(
397
525
  reportLines.push(`- \`${key}\` = \`${val}\` — replace with your character/persona name`);
398
526
  }
399
527
  reportLines.push("");
400
- reportLines.push("Use `/state 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.");
401
529
  reportLines.push("");
402
530
  }
403
531
 
@@ -414,19 +542,6 @@ export function convertSillyTavernPreset(
414
542
  }
415
543
  reportLines.push("");
416
544
 
417
- // Specific guidance for heavy variable usage
418
- if (macroUsage.migrationNeeded.setvar || macroUsage.migrationNeeded.getvar) {
419
- reportLines.push("### Variable system migration");
420
- reportLines.push("");
421
- reportLines.push("ST uses `setvar`/`getvar` for ephemeral state. pi-forge has a different variable model:");
422
- reportLines.push("");
423
- reportLines.push("- **Static variables** — set in the stack JSON's `variables` object (character names, fixed config)");
424
- reportLines.push("- **Session state** — persists across turns, set by the user via `/state set` or by the agent via `forge_state_set` for `agent.*` names");
425
- reportLines.push("- **Turn variables** — ephemeral, set via `{{setvar::name::value}}` macros in blocks");
426
- reportLines.push("");
427
- reportLines.push("Review ST `setvar`/`getvar` calls and migrate to the appropriate pi-forge scope.");
428
- reportLines.push("");
429
- }
430
545
  }
431
546
 
432
547
  // Native macros handled
@@ -440,6 +555,8 @@ export function convertSillyTavernPreset(
440
555
  reportLines.push(`- \`{{${displayName}}}\` → auto-populated as static variable`);
441
556
  } else if (name === "lastusermessage") {
442
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]}`);
443
560
  } else {
444
561
  reportLines.push(`- \`{{${displayName}}}\` → handled natively by pi-forge`);
445
562
  }
@@ -479,9 +596,9 @@ export function convertSillyTavernPreset(
479
596
  reportLines.push("");
480
597
  reportLines.push("## Suggested next steps");
481
598
  reportLines.push("");
482
- reportLines.push("1. Set real values for auto-populated variables with `/state set`.");
599
+ reportLines.push("1. Set real values for auto-populated variables by editing `stack.variables`.");
483
600
  reportLines.push("2. Review items with migration-needed macros and rewrite for pi-forge's macro system.");
484
- 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.");
485
602
  reportLines.push(`4. Run \`/preset validate ${stackId}\` to check for issues.`);
486
603
 
487
604
  return {
@@ -490,6 +607,274 @@ export function convertSillyTavernPreset(
490
607
  };
491
608
  }
492
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
+
493
878
  // ── Macro detection ────────────────────────────────────────────────────────
494
879
 
495
880
  interface MacroDetection {
@@ -0,0 +1,159 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { legacyPromptStacksDir, promptStacksDir } from "./storage.ts";
4
+
5
+ export interface PromptStackMigrationOptions {
6
+ dryRun?: boolean;
7
+ overwrite?: boolean;
8
+ deleteLegacy?: boolean;
9
+ }
10
+
11
+ export interface PromptStackMigrationFile {
12
+ name: string;
13
+ sourcePath: string;
14
+ targetPath: string;
15
+ action: "copy" | "overwrite" | "skip" | "error";
16
+ reason?: string;
17
+ deleteLegacy: boolean;
18
+ }
19
+
20
+ export interface PromptStackMigrationReport {
21
+ sourceDir: string;
22
+ targetDir: string;
23
+ dryRun: boolean;
24
+ overwrite: boolean;
25
+ deleteLegacy: boolean;
26
+ files: PromptStackMigrationFile[];
27
+ copied: number;
28
+ overwritten: number;
29
+ skipped: number;
30
+ errors: number;
31
+ deletedLegacy: number;
32
+ }
33
+
34
+ export function migrateLegacyPromptStacks(cwd: string, options: PromptStackMigrationOptions = {}): PromptStackMigrationReport {
35
+ const sourceDir = legacyPromptStacksDir(cwd);
36
+ const targetDir = promptStacksDir(cwd);
37
+ const report: PromptStackMigrationReport = {
38
+ sourceDir,
39
+ targetDir,
40
+ dryRun: options.dryRun === true,
41
+ overwrite: options.overwrite === true,
42
+ deleteLegacy: options.deleteLegacy === true,
43
+ files: [],
44
+ copied: 0,
45
+ overwritten: 0,
46
+ skipped: 0,
47
+ errors: 0,
48
+ deletedLegacy: 0,
49
+ };
50
+
51
+ if (!existsSync(sourceDir)) return report;
52
+
53
+ let entries: string[];
54
+ try {
55
+ entries = readdirSync(sourceDir).filter((name) => name.endsWith(".json")).sort();
56
+ } catch (error) {
57
+ report.files.push({
58
+ name: "(legacy directory)",
59
+ sourcePath: sourceDir,
60
+ targetPath: targetDir,
61
+ action: "error",
62
+ reason: error instanceof Error ? error.message : String(error),
63
+ deleteLegacy: false,
64
+ });
65
+ report.errors++;
66
+ return report;
67
+ }
68
+
69
+ for (const name of entries) {
70
+ const sourcePath = join(sourceDir, name);
71
+ const targetPath = join(targetDir, name);
72
+ const targetExists = existsSync(targetPath);
73
+ if (targetExists && !report.overwrite) {
74
+ report.files.push({
75
+ name,
76
+ sourcePath,
77
+ targetPath,
78
+ action: "skip",
79
+ reason: "target already exists",
80
+ deleteLegacy: false,
81
+ });
82
+ report.skipped++;
83
+ continue;
84
+ }
85
+
86
+ const action = targetExists ? "overwrite" : "copy";
87
+ try {
88
+ if (!report.dryRun) {
89
+ mkdirSync(dirname(targetPath), { recursive: true });
90
+ copyFileSync(sourcePath, targetPath);
91
+ if (report.deleteLegacy) {
92
+ unlinkSync(sourcePath);
93
+ report.deletedLegacy++;
94
+ }
95
+ }
96
+ if (action === "overwrite") report.overwritten++;
97
+ else report.copied++;
98
+ report.files.push({
99
+ name,
100
+ sourcePath,
101
+ targetPath,
102
+ action,
103
+ deleteLegacy: report.deleteLegacy,
104
+ });
105
+ } catch (error) {
106
+ report.files.push({
107
+ name,
108
+ sourcePath,
109
+ targetPath,
110
+ action: "error",
111
+ reason: error instanceof Error ? error.message : String(error),
112
+ deleteLegacy: false,
113
+ });
114
+ report.errors++;
115
+ }
116
+ }
117
+
118
+ return report;
119
+ }
120
+
121
+ export function renderMigrationReport(report: PromptStackMigrationReport): string {
122
+ const lines = [
123
+ "# Prompt Stack Migration",
124
+ "",
125
+ `Source: ${report.sourceDir}`,
126
+ `Target: ${report.targetDir}`,
127
+ `Mode: ${report.dryRun ? "dry run" : "write"}`,
128
+ `Overwrite existing target files: ${report.overwrite ? "yes" : "no"}`,
129
+ `Delete legacy files after copy: ${report.deleteLegacy ? "yes" : "no"}`,
130
+ "",
131
+ `Copied: ${report.copied}`,
132
+ `Overwritten: ${report.overwritten}`,
133
+ `Skipped: ${report.skipped}`,
134
+ `Errors: ${report.errors}`,
135
+ `Deleted legacy files: ${report.deletedLegacy}`,
136
+ "",
137
+ "## Files",
138
+ "",
139
+ ];
140
+
141
+ if (report.files.length === 0) {
142
+ lines.push("No legacy prompt-stack JSON files found.");
143
+ return lines.join("\n");
144
+ }
145
+
146
+ for (const file of report.files) {
147
+ const suffix = file.reason ? ` (${file.reason})` : "";
148
+ lines.push(`- ${file.action}: ${file.name}${suffix}`);
149
+ lines.push(` from: ${file.sourcePath}`);
150
+ lines.push(` to: ${file.targetPath}`);
151
+ if (file.deleteLegacy) lines.push(" legacy delete: yes");
152
+ }
153
+
154
+ if (!report.deleteLegacy && !report.dryRun && (report.copied || report.overwritten)) {
155
+ lines.push("", "Legacy files were left in place. Re-run with `--delete-legacy` after checking the migrated stacks if you want to remove them.");
156
+ }
157
+
158
+ return lines.join("\n");
159
+ }