@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.
- package/README.md +354 -185
- package/README.zh-CN.md +453 -0
- package/examples/default-prompt-stack.json +54 -51
- package/examples/reviewer-prompt-stack.json +115 -0
- package/examples/sillytavern-dm-writer-prompt-stack.json +190 -0
- package/package.json +4 -8
- package/src/compiler.ts +588 -87
- package/src/index.ts +509 -140
- package/src/loader.ts +179 -12
- package/src/payload-capture.ts +85 -0
- package/src/policy.ts +42 -0
- package/src/regex.ts +500 -0
- package/src/sillytavern-importer.ts +484 -50
- package/src/stack-migration.ts +159 -0
- package/src/storage.ts +28 -0
- package/src/types.ts +83 -2
- 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
|
@@ -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",
|
|
59
|
-
"user",
|
|
60
|
-
"
|
|
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
|
-
"
|
|
82
|
-
"
|
|
83
|
-
"
|
|
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
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
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
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
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.
|
|
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(", ")}].
|
|
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 (
|
|
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(
|
|
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**: ${
|
|
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("
|
|
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(`- \`{{${
|
|
396
|
-
} else if (name === "
|
|
397
|
-
reportLines.push(`- \`{{${
|
|
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(`- \`{{${
|
|
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
|
|
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
|
|
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();
|