@zihanw/pi-forge 0.3.0 → 0.3.2

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.
Files changed (156) hide show
  1. package/README.md +87 -2
  2. package/README.zh-CN.md +72 -2
  3. package/dist/compiler.d.ts +10 -0
  4. package/dist/compiler.d.ts.map +1 -0
  5. package/dist/compiler.js +490 -0
  6. package/dist/compiler.js.map +1 -0
  7. package/dist/extension-registry.d.ts +25 -0
  8. package/dist/extension-registry.d.ts.map +1 -0
  9. package/dist/extension-registry.js +6 -0
  10. package/dist/extension-registry.js.map +1 -0
  11. package/dist/forge-extensions.d.ts +28 -0
  12. package/dist/forge-extensions.d.ts.map +1 -0
  13. package/dist/forge-extensions.js +182 -0
  14. package/dist/forge-extensions.js.map +1 -0
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +376 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/lifecycle.d.ts +14 -0
  20. package/dist/lifecycle.d.ts.map +1 -0
  21. package/dist/lifecycle.js +139 -0
  22. package/dist/lifecycle.js.map +1 -0
  23. package/dist/loader.d.ts +8 -0
  24. package/dist/loader.d.ts.map +1 -0
  25. package/dist/loader.js +345 -0
  26. package/dist/loader.js.map +1 -0
  27. package/dist/macro-engine.d.ts +23 -0
  28. package/dist/macro-engine.d.ts.map +1 -0
  29. package/dist/macro-engine.js +262 -0
  30. package/dist/macro-engine.js.map +1 -0
  31. package/dist/payload-capture.d.ts +15 -0
  32. package/dist/payload-capture.d.ts.map +1 -0
  33. package/dist/payload-capture.js +86 -0
  34. package/dist/payload-capture.js.map +1 -0
  35. package/dist/payload-command.d.ts +10 -0
  36. package/dist/payload-command.d.ts.map +1 -0
  37. package/dist/payload-command.js +112 -0
  38. package/dist/payload-command.js.map +1 -0
  39. package/dist/policy.d.ts +6 -0
  40. package/dist/policy.d.ts.map +1 -0
  41. package/dist/policy.js +38 -0
  42. package/dist/policy.js.map +1 -0
  43. package/dist/preset-command.d.ts +14 -0
  44. package/dist/preset-command.d.ts.map +1 -0
  45. package/dist/preset-command.js +232 -0
  46. package/dist/preset-command.js.map +1 -0
  47. package/dist/preview.d.ts +12 -0
  48. package/dist/preview.d.ts.map +1 -0
  49. package/dist/preview.js +179 -0
  50. package/dist/preview.js.map +1 -0
  51. package/dist/regex.d.ts +7 -0
  52. package/dist/regex.d.ts.map +1 -0
  53. package/dist/regex.js +432 -0
  54. package/dist/regex.js.map +1 -0
  55. package/dist/render-helpers.d.ts +44 -0
  56. package/dist/render-helpers.d.ts.map +1 -0
  57. package/dist/render-helpers.js +128 -0
  58. package/dist/render-helpers.js.map +1 -0
  59. package/dist/runtime-state.d.ts +26 -0
  60. package/dist/runtime-state.d.ts.map +1 -0
  61. package/dist/runtime-state.js +15 -0
  62. package/dist/runtime-state.js.map +1 -0
  63. package/dist/sillytavern-importer/items.d.ts +3 -0
  64. package/dist/sillytavern-importer/items.d.ts.map +1 -0
  65. package/dist/sillytavern-importer/items.js +88 -0
  66. package/dist/sillytavern-importer/items.js.map +1 -0
  67. package/dist/sillytavern-importer/macros.d.ts +15 -0
  68. package/dist/sillytavern-importer/macros.d.ts.map +1 -0
  69. package/dist/sillytavern-importer/macros.js +141 -0
  70. package/dist/sillytavern-importer/macros.js.map +1 -0
  71. package/dist/sillytavern-importer/prompt-order.d.ts +6 -0
  72. package/dist/sillytavern-importer/prompt-order.d.ts.map +1 -0
  73. package/dist/sillytavern-importer/prompt-order.js +38 -0
  74. package/dist/sillytavern-importer/prompt-order.js.map +1 -0
  75. package/dist/sillytavern-importer/regex.d.ts +3 -0
  76. package/dist/sillytavern-importer/regex.d.ts.map +1 -0
  77. package/dist/sillytavern-importer/regex.js +275 -0
  78. package/dist/sillytavern-importer/regex.js.map +1 -0
  79. package/dist/sillytavern-importer/report.d.ts +21 -0
  80. package/dist/sillytavern-importer/report.d.ts.map +1 -0
  81. package/dist/sillytavern-importer/report.js +166 -0
  82. package/dist/sillytavern-importer/report.js.map +1 -0
  83. package/dist/sillytavern-importer/types.d.ts +106 -0
  84. package/dist/sillytavern-importer/types.d.ts.map +1 -0
  85. package/dist/sillytavern-importer/types.js +2 -0
  86. package/dist/sillytavern-importer/types.js.map +1 -0
  87. package/dist/sillytavern-importer.d.ts +5 -0
  88. package/dist/sillytavern-importer.d.ts.map +1 -0
  89. package/dist/sillytavern-importer.js +117 -0
  90. package/dist/sillytavern-importer.js.map +1 -0
  91. package/dist/slot-renderers.d.ts +25 -0
  92. package/dist/slot-renderers.d.ts.map +1 -0
  93. package/dist/slot-renderers.js +334 -0
  94. package/dist/slot-renderers.js.map +1 -0
  95. package/dist/stack-migration.d.ts +29 -0
  96. package/dist/stack-migration.d.ts.map +1 -0
  97. package/dist/stack-migration.js +126 -0
  98. package/dist/stack-migration.js.map +1 -0
  99. package/dist/storage.d.ts +10 -0
  100. package/dist/storage.d.ts.map +1 -0
  101. package/dist/storage.js +36 -0
  102. package/dist/storage.js.map +1 -0
  103. package/dist/types.d.ts +173 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +2 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/web-editor/index.d.ts +3 -0
  108. package/dist/web-editor/index.d.ts.map +1 -0
  109. package/dist/web-editor/index.js +2 -0
  110. package/dist/web-editor/index.js.map +1 -0
  111. package/dist/web-editor/page.d.ts +2 -0
  112. package/dist/web-editor/page.d.ts.map +1 -0
  113. package/dist/web-editor/page.js +3331 -0
  114. package/dist/web-editor/page.js.map +1 -0
  115. package/dist/web-editor/server.d.ts +4 -0
  116. package/dist/web-editor/server.d.ts.map +1 -0
  117. package/dist/web-editor/server.js +263 -0
  118. package/dist/web-editor/server.js.map +1 -0
  119. package/dist/web-editor/types.d.ts +125 -0
  120. package/dist/web-editor/types.d.ts.map +1 -0
  121. package/dist/web-editor/types.js +2 -0
  122. package/dist/web-editor/types.js.map +1 -0
  123. package/dist/web-host.d.ts +29 -0
  124. package/dist/web-host.d.ts.map +1 -0
  125. package/dist/web-host.js +174 -0
  126. package/dist/web-host.js.map +1 -0
  127. package/examples/custom-system-status-extension/README.md +85 -0
  128. package/examples/custom-system-status-extension/index.ts +136 -0
  129. package/examples/custom-system-status-extension/prompt-stack.json +39 -0
  130. package/examples/image-reader-prompt-stack.json +124 -0
  131. package/package.json +28 -2
  132. package/src/compiler.ts +2 -472
  133. package/src/extension-registry.ts +33 -0
  134. package/src/forge-extensions.ts +223 -0
  135. package/src/index.ts +208 -653
  136. package/src/lifecycle.ts +171 -0
  137. package/src/macro-engine.ts +358 -0
  138. package/src/payload-command.ts +138 -0
  139. package/src/preset-command.ts +280 -0
  140. package/src/preview.ts +226 -0
  141. package/src/render-helpers.ts +169 -0
  142. package/src/runtime-state.ts +40 -0
  143. package/src/sillytavern-importer/items.ts +98 -0
  144. package/src/sillytavern-importer/macros.ts +159 -0
  145. package/src/sillytavern-importer/prompt-order.ts +54 -0
  146. package/src/sillytavern-importer/regex.ts +270 -0
  147. package/src/sillytavern-importer/report.ts +202 -0
  148. package/src/sillytavern-importer/types.ts +120 -0
  149. package/src/sillytavern-importer.ts +55 -865
  150. package/src/slot-renderers.ts +414 -0
  151. package/src/storage.ts +17 -0
  152. package/src/types.ts +3 -14
  153. package/src/web-editor/page.ts +516 -30
  154. package/src/web-editor/server.ts +9 -4
  155. package/src/web-editor/types.ts +18 -4
  156. package/src/web-host.ts +16 -13
@@ -1,234 +1,28 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
- import type { PromptRegexRule, PromptStack, PromptStackItem } from "./types.ts";
4
-
5
- // ── SillyTavern preset raw types ──────────────────────────────────────────
6
-
7
- interface StPreset {
8
- prompts?: StPromptDef[];
9
- prompt_order?: StPromptOrderEntry[];
10
- extensions?: {
11
- regex_scripts?: unknown[];
12
- [key: string]: unknown;
13
- };
14
- names_behavior?: number;
15
- preset_name?: string;
16
- name?: string;
17
- [key: string]: unknown;
18
- }
19
-
20
- interface StPromptDef {
21
- identifier: string;
22
- name?: string;
23
- role?: string;
24
- content?: string;
25
- system_prompt?: boolean;
26
- marker?: boolean;
27
- enabled?: boolean;
28
- }
29
-
30
- interface StPromptOrderEntry {
31
- character_id: number;
32
- order?: StOrderItem[];
33
- }
34
-
35
- interface StOrderItem {
36
- identifier: string;
37
- enabled: boolean;
38
- }
39
-
40
- interface StConversionItem {
41
- def: StPromptDef;
42
- orderEnabled: boolean;
43
- orderIndex: number;
44
- }
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
-
97
- // ── Marker identifiers ─────────────────────────────────────────────────────
98
-
99
- const MARKER_CHAT_HISTORY = "chatHistory";
100
- const MARKER_SKIP = new Set([
101
- "worldInfoBefore",
102
- "worldInfoAfter",
103
- "charDescription",
104
- "charPersonality",
105
- "personaDescription",
106
- "scenario",
107
- "dialogueExamples",
108
- ]);
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
-
119
- // ── ST macros that pi-forge handles natively ───────────────────────────────
120
-
121
- const NATIVE_MACROS = new Set([
122
- "char", // → static variable
123
- "user", // → static variable
124
- "lastusermessage", // → handled by pi-forge runtime
125
- "cwd",
126
- "date",
127
- "time",
128
- "setvar",
129
- "setturnvar",
130
- "setsessionvar",
131
- "getvar",
132
- "var",
133
- "getturnvar",
134
- "getsessionvar",
135
- "clearvar",
136
- "clearturnvar",
137
- "clearsessionvar",
138
- ]);
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
-
159
- // ── ST macros that need manual migration ───────────────────────────────────
160
-
161
- const MACRO_NEEDS_MIGRATION: Record<string, string> = {
162
- "addvar": "use pi-forge {{getvar::name}} + math, or setvar with computed value",
163
- "getglobalvar": "use pi-forge session variables via {{getsessionvar::name}}",
164
- "setglobalvar": "use pi-forge {{setsessionvar::name::value}}",
165
- "random": "use pi-forge conditionals (Priority 2) or precompute",
166
- "pick": "use pi-forge conditionals (Priority 2) or precompute",
167
- "roll": "use pi-forge conditionals (Priority 2) or precompute",
168
- "if": "use pi-forge conditionals (Priority 2)",
169
- "original": "SillyTavern-specific — no direct equivalent; set as session variable if needed",
170
- "outlet": "SillyTavern extension outlet — no pi-forge equivalent",
171
- "group": "SillyTavern group chat — no pi-forge equivalent",
172
- "groupnotmuted": "SillyTavern group chat — no pi-forge equivalent",
173
- "notchar": "SillyTavern group chat — no pi-forge equivalent",
174
- "charifnotgroup": "SillyTavern group chat — no pi-forge equivalent",
175
- "description": "ST character card field — set as session variable or static variable if needed",
176
- "personality": "ST character card field — set as session variable or static variable if needed",
177
- "scenario": "ST character card field — set as session variable or static variable if needed",
178
- "persona": "ST persona field — set as session variable or static variable if needed",
179
- "mesexamples": "ST character dialogue examples — add inline or as context file if needed",
180
- "mesexamplesraw": "ST character dialogue examples — add inline or as context file if needed",
181
- "charprompt": "ST character prompt override — merge into system prompt blocks if needed",
182
- "charinstruction": "ST character instruction override — merge into post-history blocks if needed",
183
- "charversion": "ST character version — set as static variable if needed",
184
- "charfirstmessage": "ST character first message — add as static variable if needed",
185
- "system": "ST context template system prompt — handled by pi-forge system prompt replacement",
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",
205
- };
206
-
207
- // ── Macros we can strip entirely (produce no output) ───────────────────────
208
-
209
- const COMMENT_MACRO_RE = /\{\{\s*\/\/[\s\S]*?\}\}/g;
210
- const TRIM_MACRO_RE = /\{\{\s*trim\s*\}\}/gi;
211
-
212
- // ── Public result types ────────────────────────────────────────────────────
213
-
214
- export interface SillyTavernImportResult {
215
- stack: PromptStack;
216
- report: string;
217
- }
218
-
219
- export interface SillyTavernImportError {
220
- error: string;
221
- }
222
-
223
- export type SillyTavernImportOutcome = SillyTavernImportResult | SillyTavernImportError;
224
-
225
- export interface SillyTavernConvertOptions {
226
- sourceName?: string;
227
- sourcePath?: string;
228
- characterId?: number;
229
- }
230
-
231
- // ── Importer ───────────────────────────────────────────────────────────────
3
+ import { convertPromptItems } from "./sillytavern-importer/items.ts";
4
+ import { detectMacros } from "./sillytavern-importer/macros.ts";
5
+ import {
6
+ buildConversionItems,
7
+ buildPromptMap,
8
+ findMissingIdentifiers,
9
+ selectCharacterEntry,
10
+ } from "./sillytavern-importer/prompt-order.ts";
11
+ import { summarizeRegexScripts } from "./sillytavern-importer/regex.ts";
12
+ import { buildSillyTavernImportReport } from "./sillytavern-importer/report.ts";
13
+ import type {
14
+ SillyTavernConvertOptions,
15
+ SillyTavernImportOutcome,
16
+ StPreset,
17
+ } from "./sillytavern-importer/types.ts";
18
+ import type { PromptStack } from "./types.ts";
19
+
20
+ export type {
21
+ SillyTavernConvertOptions,
22
+ SillyTavernImportError,
23
+ SillyTavernImportOutcome,
24
+ SillyTavernImportResult,
25
+ } from "./sillytavern-importer/types.ts";
232
26
 
233
27
  export function importSillyTavernPreset(
234
28
  filePath: string,
@@ -272,7 +66,6 @@ export function convertSillyTavernPreset(
272
66
  return { error: "Preset has no prompts array." };
273
67
  }
274
68
 
275
- // Select character_id
276
69
  const selectedEntry = selectCharacterEntry(promptOrder, options.characterId);
277
70
  if (!selectedEntry) {
278
71
  const ids = promptOrder.map((entry) => entry.character_id);
@@ -284,123 +77,22 @@ export function convertSillyTavernPreset(
284
77
  };
285
78
  }
286
79
 
287
- // Build ordered, enabled conversion items
288
- const conversionItems: StConversionItem[] = [];
289
- const promptMap = new Map<string, StPromptDef>();
290
- for (const def of allPrompts) {
291
- promptMap.set(def.identifier, def);
292
- }
293
-
294
- let orderIndex = 0;
295
- for (const orderItem of selectedEntry.order ?? []) {
296
- const def = promptMap.get(orderItem.identifier);
297
- if (def) {
298
- conversionItems.push({ def, orderEnabled: orderItem.enabled, orderIndex: orderIndex++ });
299
- }
300
- }
301
-
302
- // First pass: detect macros across all content
80
+ const promptMap = buildPromptMap(allPrompts);
81
+ const conversionItems = buildConversionItems(selectedEntry, promptMap);
303
82
  const macroUsage = detectMacros(conversionItems);
304
-
305
- // Build pi-forge items
306
- const reportNotes: string[] = [];
307
- let nextId = 1;
308
- const items: PromptStackItem[] = [];
309
- let usesLastUserMessage = false;
310
- const disabledCount = { marker: 0, empty: 0, orderDisable: 0 };
311
-
312
- for (const { def, orderEnabled, orderIndex } of conversionItems) {
313
- const id = String(nextId++);
314
-
315
- // Marker items
316
- if (def.marker) {
317
- if (def.identifier === MARKER_CHAT_HISTORY) {
318
- items.push({
319
- kind: "slot",
320
- id,
321
- name: def.name || "Chat History",
322
- enabled: orderEnabled,
323
- slot: "chat-history",
324
- source: { previousId: def.identifier, name: def.name },
325
- });
326
- } else if (MARKER_SKIP.has(def.identifier)) {
327
- disabledCount.marker++;
328
- }
329
- continue;
330
- }
331
-
332
- // Clean content: strip comments, handle trim
333
- let content = def.content ?? "";
334
- if (!content.trim()) {
335
- disabledCount.empty++;
336
- continue;
337
- }
338
-
339
- // Strip ST comment macros
340
- content = content.replace(COMMENT_MACRO_RE, "");
341
-
342
- // Strip {{trim}} — it's a formatting hint for newline removal, not meaningful content
343
- content = content.replace(TRIM_MACRO_RE, "");
344
-
345
- // After stripping comments/trim, check if content is still meaningful
346
- if (!content.trim()) {
347
- disabledCount.empty++;
348
- continue;
349
- }
350
-
351
- // Detect {{lastUserMessage}} usage, case-insensitively.
352
- if (/\{\{\s*lastUserMessage\b/i.test(content)) {
353
- usesLastUserMessage = true;
354
- }
355
-
356
- const role = normalizeRole(def.role);
357
- if (!role) {
358
- disabledCount.empty++;
359
- continue;
360
- }
361
-
362
- if (!orderEnabled) {
363
- disabledCount.orderDisable++;
364
- }
365
-
366
- items.push({
367
- kind: "block",
368
- id,
369
- name: def.name,
370
- enabled: orderEnabled,
371
- role,
372
- content: cleanContent(content),
373
- source: {
374
- previousId: def.identifier,
375
- previousName: def.name,
376
- orderIndex,
377
- },
378
- });
379
- }
380
-
381
- // Diagnostic: any prompts in the order that don't exist in the prompts array
382
- const missingIdentifiers: string[] = [];
383
- for (const orderItem of selectedEntry.order ?? []) {
384
- if (!promptMap.has(orderItem.identifier)) {
385
- missingIdentifiers.push(orderItem.identifier);
386
- }
387
- }
388
-
389
- // Build stack name from file
83
+ const { items, disabledCount, usesLastUserMessage: itemUsesLastUserMessage } = convertPromptItems(conversionItems);
84
+ const missingIdentifiers = findMissingIdentifiers(selectedEntry, promptMap);
390
85
  const fileName = basename(sourceName).replace(/\.json$/i, "") || "imported";
391
86
  const styleName = preset.names_behavior === 1 ? "names" : preset.names_behavior === 2 ? "nonames" : "default";
392
- const stackId = fileName.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "imported";
393
-
394
- // Build static variables
395
- const variables: Record<string, string> = {};
396
- if (macroUsage.detected.has("char")) variables.char = "{{char}}";
397
- if (macroUsage.detected.has("user")) variables.user = "{{user}}";
87
+ const stackId = normalizeStackId(fileName);
88
+ const variables = buildStaticVariables(macroUsage.detected);
89
+ let usesLastUserMessage = itemUsesLastUserMessage;
398
90
 
399
91
  const stack: PromptStack = {
400
92
  schemaVersion: 1,
401
93
  type: "pi-forge.prompt-stack",
402
94
  id: stackId,
403
- name: preset["preset_name"] || preset["name"] || fileName,
95
+ name: preset.preset_name || preset.name || fileName,
404
96
  autoActivate: false,
405
97
  mode: "replace",
406
98
  variables: Object.keys(variables).length > 0 ? variables : undefined,
@@ -415,7 +107,6 @@ export function convertSillyTavernPreset(
415
107
  },
416
108
  };
417
109
 
418
- // Add chat-history option if {{lastUserMessage}} is used after history.
419
110
  if (macroUsage.detected.has("lastusermessage")) usesLastUserMessage = true;
420
111
  if (usesLastUserMessage) {
421
112
  const chatHistoryItem = items.find(
@@ -426,537 +117,36 @@ export function convertSillyTavernPreset(
426
117
  }
427
118
  }
428
119
 
429
- // Build report
430
- const reportLines: string[] = [];
431
- reportLines.push(`# SillyTavern Import Report: ${fileName}`);
432
- reportLines.push("");
433
- reportLines.push(`- **Source file**: ${sourcePath}`);
434
- reportLines.push(`- **Character ID**: ${selectedEntry.character_id}`);
435
- reportLines.push(`- **Output stack ID**: ${stackId}`);
436
- reportLines.push(`- **Names behavior**: ${styleName} (ST value ${preset.names_behavior ?? "?"})`);
437
- reportLines.push(`- **Total prompts in source**: ${allPrompts.length}`);
438
- reportLines.push(`- **Items in prompt_order**: ${selectedEntry.order?.length ?? 0}`);
439
- reportLines.push(`- **Converted items**: ${items.length}`);
440
- reportLines.push("");
441
-
442
- // Stripped content
443
- if (macroUsage.commentsStripped > 0) {
444
- reportLines.push(`- **Comments stripped**: ${macroUsage.commentsStripped} ST comment blocks removed`);
445
- }
446
- if (macroUsage.trimStripped > 0) {
447
- reportLines.push(`- **Trim markers removed**: ${macroUsage.trimStripped} {{trim}} markers (ST formatting hint)`);
448
- }
449
- reportLines.push("");
450
-
451
- // Skipped items
452
- if (disabledCount.marker > 0 || disabledCount.empty > 0 || disabledCount.orderDisable > 0) {
453
- reportLines.push("## Skipped items");
454
- reportLines.push("");
455
- if (disabledCount.marker > 0) {
456
- reportLines.push(`- ${disabledCount.marker} marker items (charDescription, worldInfo, etc.) — handled by Pi/ST runtime, not needed in prompt stack`);
457
- }
458
- if (disabledCount.empty > 0) {
459
- reportLines.push(`- ${disabledCount.empty} items with empty or missing content`);
460
- }
461
- if (disabledCount.orderDisable > 0) {
462
- reportLines.push(`- ${disabledCount.orderDisable} items disabled in prompt_order`);
463
- }
464
- reportLines.push("");
465
- }
466
-
467
- // Missing identifiers
468
- if (missingIdentifiers.length > 0) {
469
- reportLines.push("## ⚠️ Missing identifiers in prompts array");
470
- reportLines.push("");
471
- for (const id of missingIdentifiers) {
472
- reportLines.push(`- \`${id}\` (referenced in prompt_order but not found in prompts)`);
473
- }
474
- reportLines.push("");
475
- }
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
-
518
- // Auto-populated variables
519
- if (Object.keys(variables).length > 0) {
520
- reportLines.push("## Auto-populated variables");
521
- reportLines.push("");
522
- reportLines.push("These ST built-in macros were auto-populated as static variables with placeholder values:");
523
- reportLines.push("");
524
- for (const [key, val] of Object.entries(variables)) {
525
- reportLines.push(`- \`${key}\` = \`${val}\` — replace with your character/persona name`);
526
- }
527
- reportLines.push("");
528
- reportLines.push("Edit `stack.variables` in the stack JSON or web editor to set real values.");
529
- reportLines.push("");
530
- }
531
-
532
- // Macros needing migration
533
- const migrationEntries = Object.entries(macroUsage.migrationNeeded).sort(([, a], [, b]) => b - a);
534
- if (migrationEntries.length > 0) {
535
- reportLines.push("## ⚠️ Macros needing manual migration");
536
- reportLines.push("");
537
- reportLines.push("These ST macros appear in the preset but have no direct pi-forge equivalent:");
538
- reportLines.push("");
539
- for (const [name, count] of migrationEntries) {
540
- const note = MACRO_NEEDS_MIGRATION[name] ?? "no mapping available";
541
- reportLines.push(`- **\`{{${displayMacroName(name)}}}\`** (${count} occurrence${count > 1 ? "s" : ""}) — ${note}`);
542
- }
543
- reportLines.push("");
544
-
545
- }
546
-
547
- // Native macros handled
548
- const nativeDetected = [...macroUsage.detected].filter((m) => NATIVE_MACROS.has(m));
549
- if (nativeDetected.length > 0) {
550
- reportLines.push("## Handled macros");
551
- reportLines.push("");
552
- for (const name of nativeDetected) {
553
- const displayName = displayMacroName(name);
554
- if (name === "char" || name === "user") {
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]}`);
560
- } else {
561
- reportLines.push(`- \`{{${displayName}}}\` → handled natively by pi-forge`);
562
- }
563
- }
564
- reportLines.push("");
565
- }
566
-
567
- // Item mapping table
568
- reportLines.push(`## Item mapping`);
569
- reportLines.push("");
570
- reportLines.push(`| # | ST Identifier | ST Name | pi-forge ID | Role | Enabled | Kind |`);
571
- reportLines.push(`|---|--------------|---------|-------------|------|---------|------|`);
572
-
573
- for (const item of items) {
574
- const stId = (item.source as Record<string, unknown> | undefined)?.previousId ?? "—";
575
- const stName = item.name ?? "—";
576
- const enabled = item.enabled !== false ? "✓" : "✗";
577
- const kind = item.kind;
578
- reportLines.push(`| ${item.id} | ${stId} | ${stName} | ${item.id} | ${item.role ?? "—"} | ${enabled} | ${kind} |`);
579
- }
580
-
581
- reportLines.push("");
582
- reportLines.push("## General notes");
583
- reportLines.push("");
584
-
585
- if (usesLastUserMessage) {
586
- reportLines.push("- Auto-detected `{{lastUserMessage}}` in post-history content. `chat-history` slot set with `includeLastUserMessage: false`.");
587
- }
588
-
589
- if (macroUsage.commentsStripped > 0 || macroUsage.trimStripped > 0) {
590
- reportLines.push(`- ${macroUsage.commentsStripped} ST comment blocks and ${macroUsage.trimStripped} TRIM markers were stripped during import.`);
591
- }
592
-
593
- reportLines.push("- All items are assigned sequential numeric IDs. Original SillyTavern identifiers are preserved in `source.previousId`.");
594
- reportLines.push("- Marker items for world info, character description, persona, scenario, and dialogue examples are omitted — these are handled by the SillyTavern frontend and have no direct pi-forge equivalent.");
595
- reportLines.push(`- The stack is set with \`autoActivate: false\`. Use \`/preset use ${stackId}\` to activate.`);
596
- reportLines.push("");
597
- reportLines.push("## Suggested next steps");
598
- reportLines.push("");
599
- reportLines.push("1. Set real values for auto-populated variables by editing `stack.variables`.");
600
- reportLines.push("2. Review items with migration-needed macros and rewrite for pi-forge's macro system.");
601
- reportLines.push("3. Consider adding a `variables` slot for template variable visibility.");
602
- reportLines.push(`4. Run \`/preset validate ${stackId}\` to check for issues.`);
120
+ const report = buildSillyTavernImportReport({
121
+ fileName,
122
+ sourcePath,
123
+ characterId: selectedEntry.character_id,
124
+ stackId,
125
+ styleName: `${styleName} (ST value ${preset.names_behavior ?? "?"})`,
126
+ totalPrompts: allPrompts.length,
127
+ orderItemCount: selectedEntry.order?.length ?? 0,
128
+ items,
129
+ macroUsage,
130
+ disabledCount,
131
+ missingIdentifiers,
132
+ regexScripts,
133
+ variables,
134
+ usesLastUserMessage,
135
+ });
603
136
 
604
137
  return {
605
138
  stack,
606
- report: reportLines.join("\n"),
607
- };
608
- }
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,
139
+ report,
748
140
  };
749
141
  }
750
142
 
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);
143
+ function normalizeStackId(fileName: string): string {
144
+ return fileName.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "imported";
763
145
  }
764
146
 
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
-
878
- // ── Macro detection ────────────────────────────────────────────────────────
879
-
880
- interface MacroDetection {
881
- detected: Set<string>;
882
- commentsStripped: number;
883
- trimStripped: number;
884
- migrationNeeded: Record<string, number>;
885
- }
886
-
887
- function detectMacros(conversionItems: StConversionItem[]): MacroDetection {
888
- const detected = new Set<string>();
889
- let commentsStripped = 0;
890
- let trimStripped = 0;
891
- const migrationNeeded: Record<string, number> = {};
892
-
893
- // Regex to find {{macroName...}} — captures the first word/identifier
894
- const macroFindRe = /\{\{(?!\/\/)([a-zA-Z_][a-zA-Z0-9_-]*)/gi;
895
-
896
- for (const { def } of conversionItems) {
897
- const content = def.content ?? "";
898
- if (!content) continue;
899
-
900
- // Count comments
901
- const commentMatches = content.match(COMMENT_MACRO_RE);
902
- if (commentMatches) commentsStripped += commentMatches.length;
903
-
904
- // Count trims
905
- const trimMatches = content.match(TRIM_MACRO_RE);
906
- if (trimMatches) trimStripped += trimMatches.length;
907
-
908
- // Work on a copy with comments stripped so we don't flag // as a macro
909
- const cleaned = content.replace(COMMENT_MACRO_RE, "").replace(TRIM_MACRO_RE, "");
910
-
911
- let match: RegExpExecArray | null;
912
- macroFindRe.lastIndex = 0;
913
- while ((match = macroFindRe.exec(cleaned)) !== null) {
914
- const name = match[1].toLowerCase();
915
- detected.add(name);
916
-
917
- if (MACRO_NEEDS_MIGRATION[name]) {
918
- migrationNeeded[name] = (migrationNeeded[name] ?? 0) + 1;
919
- }
920
- }
921
- }
922
-
923
- return { detected, commentsStripped, trimStripped, migrationNeeded };
924
- }
925
-
926
- // ── Content cleaning ──────────────────────────────────────────────────────
927
-
928
- function displayMacroName(name: string): string {
929
- return MACRO_DISPLAY_NAMES[name] ?? name;
930
- }
931
-
932
- function cleanContent(content: string): string {
933
- // Collapse multiple blank lines (max 1 blank line between paragraphs)
934
- return content.replace(/\n{3,}/g, "\n\n").trim();
935
- }
936
-
937
- // ── Helpers ────────────────────────────────────────────────────────────────
938
-
939
- function selectCharacterEntry(
940
- order: StPromptOrderEntry[],
941
- preferredId?: number,
942
- ): StPromptOrderEntry | undefined {
943
- if (order.length === 0) return undefined;
944
-
945
- if (preferredId !== undefined) {
946
- return order.find((entry) => entry.character_id === preferredId);
947
- }
948
-
949
- if (order.length === 1) return order[0];
950
-
951
- // Multiple entries, no preference — can't auto-select
952
- return undefined;
953
- }
954
-
955
- function normalizeRole(role: string | undefined): "system" | "user" | "assistant" | undefined {
956
- if (!role) return undefined;
957
- const lower = role.trim().toLowerCase();
958
- if (lower === "system") return "system";
959
- if (lower === "user") return "user";
960
- if (lower === "assistant") return "assistant";
961
- return undefined;
147
+ function buildStaticVariables(detectedMacros: Set<string>): Record<string, string> {
148
+ const variables: Record<string, string> = {};
149
+ if (detectedMacros.has("char")) variables.char = "{{char}}";
150
+ if (detectedMacros.has("user")) variables.user = "{{user}}";
151
+ return variables;
962
152
  }