@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.
- package/README.md +154 -104
- package/README.zh-CN.md +151 -103
- package/examples/default-prompt-stack.json +52 -93
- package/examples/reviewer-prompt-stack.json +115 -0
- package/examples/sillytavern-dm-writer-prompt-stack.json +190 -0
- package/package.json +3 -8
- package/src/compiler.ts +546 -175
- package/src/index.ts +320 -660
- package/src/loader.ts +141 -56
- package/src/payload-capture.ts +85 -0
- package/src/policy.ts +42 -0
- package/src/regex.ts +500 -0
- package/src/sillytavern-importer.ts +404 -19
- package/src/stack-migration.ts +159 -0
- package/src/storage.ts +28 -0
- package/src/types.ts +80 -38
- package/src/web-editor/index.ts +2 -0
- package/src/web-editor/page.ts +2844 -0
- package/src/web-editor/server.ts +289 -0
- package/src/web-editor/types.ts +84 -0
- package/src/web-host.ts +229 -0
- package/src/web-editor.ts +0 -1491
|
@@ -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("
|
|
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
|
|
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
|
|
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
|
+
}
|