@zihanw/pi-forge 0.2.0 → 0.3.1

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 (161) hide show
  1. package/README.md +233 -105
  2. package/README.zh-CN.md +215 -104
  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/index.d.ts +7 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +364 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/lifecycle.d.ts +14 -0
  16. package/dist/lifecycle.d.ts.map +1 -0
  17. package/dist/lifecycle.js +139 -0
  18. package/dist/lifecycle.js.map +1 -0
  19. package/dist/loader.d.ts +8 -0
  20. package/dist/loader.d.ts.map +1 -0
  21. package/dist/loader.js +345 -0
  22. package/dist/loader.js.map +1 -0
  23. package/dist/macro-engine.d.ts +23 -0
  24. package/dist/macro-engine.d.ts.map +1 -0
  25. package/dist/macro-engine.js +262 -0
  26. package/dist/macro-engine.js.map +1 -0
  27. package/dist/payload-capture.d.ts +15 -0
  28. package/dist/payload-capture.d.ts.map +1 -0
  29. package/dist/payload-capture.js +86 -0
  30. package/dist/payload-capture.js.map +1 -0
  31. package/dist/payload-command.d.ts +10 -0
  32. package/dist/payload-command.d.ts.map +1 -0
  33. package/dist/payload-command.js +112 -0
  34. package/dist/payload-command.js.map +1 -0
  35. package/dist/policy.d.ts +6 -0
  36. package/dist/policy.d.ts.map +1 -0
  37. package/dist/policy.js +38 -0
  38. package/dist/policy.js.map +1 -0
  39. package/dist/preset-command.d.ts +14 -0
  40. package/dist/preset-command.d.ts.map +1 -0
  41. package/dist/preset-command.js +223 -0
  42. package/dist/preset-command.js.map +1 -0
  43. package/dist/preview.d.ts +12 -0
  44. package/dist/preview.d.ts.map +1 -0
  45. package/dist/preview.js +179 -0
  46. package/dist/preview.js.map +1 -0
  47. package/dist/regex.d.ts +7 -0
  48. package/dist/regex.d.ts.map +1 -0
  49. package/dist/regex.js +432 -0
  50. package/dist/regex.js.map +1 -0
  51. package/dist/render-helpers.d.ts +44 -0
  52. package/dist/render-helpers.d.ts.map +1 -0
  53. package/dist/render-helpers.js +128 -0
  54. package/dist/render-helpers.js.map +1 -0
  55. package/dist/runtime-state.d.ts +24 -0
  56. package/dist/runtime-state.d.ts.map +1 -0
  57. package/dist/runtime-state.js +13 -0
  58. package/dist/runtime-state.js.map +1 -0
  59. package/dist/sillytavern-importer/items.d.ts +3 -0
  60. package/dist/sillytavern-importer/items.d.ts.map +1 -0
  61. package/dist/sillytavern-importer/items.js +88 -0
  62. package/dist/sillytavern-importer/items.js.map +1 -0
  63. package/dist/sillytavern-importer/macros.d.ts +15 -0
  64. package/dist/sillytavern-importer/macros.d.ts.map +1 -0
  65. package/dist/sillytavern-importer/macros.js +141 -0
  66. package/dist/sillytavern-importer/macros.js.map +1 -0
  67. package/dist/sillytavern-importer/prompt-order.d.ts +6 -0
  68. package/dist/sillytavern-importer/prompt-order.d.ts.map +1 -0
  69. package/dist/sillytavern-importer/prompt-order.js +38 -0
  70. package/dist/sillytavern-importer/prompt-order.js.map +1 -0
  71. package/dist/sillytavern-importer/regex.d.ts +3 -0
  72. package/dist/sillytavern-importer/regex.d.ts.map +1 -0
  73. package/dist/sillytavern-importer/regex.js +275 -0
  74. package/dist/sillytavern-importer/regex.js.map +1 -0
  75. package/dist/sillytavern-importer/report.d.ts +21 -0
  76. package/dist/sillytavern-importer/report.d.ts.map +1 -0
  77. package/dist/sillytavern-importer/report.js +166 -0
  78. package/dist/sillytavern-importer/report.js.map +1 -0
  79. package/dist/sillytavern-importer/types.d.ts +106 -0
  80. package/dist/sillytavern-importer/types.d.ts.map +1 -0
  81. package/dist/sillytavern-importer/types.js +2 -0
  82. package/dist/sillytavern-importer/types.js.map +1 -0
  83. package/dist/sillytavern-importer.d.ts +5 -0
  84. package/dist/sillytavern-importer.d.ts.map +1 -0
  85. package/dist/sillytavern-importer.js +117 -0
  86. package/dist/sillytavern-importer.js.map +1 -0
  87. package/dist/slot-renderers.d.ts +25 -0
  88. package/dist/slot-renderers.d.ts.map +1 -0
  89. package/dist/slot-renderers.js +334 -0
  90. package/dist/slot-renderers.js.map +1 -0
  91. package/dist/stack-migration.d.ts +29 -0
  92. package/dist/stack-migration.d.ts.map +1 -0
  93. package/dist/stack-migration.js +126 -0
  94. package/dist/stack-migration.js.map +1 -0
  95. package/dist/storage.d.ts +6 -0
  96. package/dist/storage.d.ts.map +1 -0
  97. package/dist/storage.js +23 -0
  98. package/dist/storage.js.map +1 -0
  99. package/dist/types.d.ts +173 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +2 -0
  102. package/dist/types.js.map +1 -0
  103. package/dist/web-editor/index.d.ts +3 -0
  104. package/dist/web-editor/index.d.ts.map +1 -0
  105. package/dist/web-editor/index.js +2 -0
  106. package/dist/web-editor/index.js.map +1 -0
  107. package/dist/web-editor/page.d.ts +2 -0
  108. package/dist/web-editor/page.d.ts.map +1 -0
  109. package/dist/web-editor/page.js +3331 -0
  110. package/dist/web-editor/page.js.map +1 -0
  111. package/dist/web-editor/server.d.ts +4 -0
  112. package/dist/web-editor/server.d.ts.map +1 -0
  113. package/dist/web-editor/server.js +263 -0
  114. package/dist/web-editor/server.js.map +1 -0
  115. package/dist/web-editor/types.d.ts +125 -0
  116. package/dist/web-editor/types.d.ts.map +1 -0
  117. package/dist/web-editor/types.js +2 -0
  118. package/dist/web-editor/types.js.map +1 -0
  119. package/dist/web-host.d.ts +29 -0
  120. package/dist/web-host.d.ts.map +1 -0
  121. package/dist/web-host.js +174 -0
  122. package/dist/web-host.js.map +1 -0
  123. package/examples/custom-system-status-extension/README.md +78 -0
  124. package/examples/custom-system-status-extension/index.ts +176 -0
  125. package/examples/custom-system-status-extension/prompt-stack.json +39 -0
  126. package/examples/default-prompt-stack.json +52 -93
  127. package/examples/image-reader-prompt-stack.json +124 -0
  128. package/examples/reviewer-prompt-stack.json +115 -0
  129. package/examples/sillytavern-dm-writer-prompt-stack.json +190 -0
  130. package/package.json +30 -9
  131. package/src/compiler.ts +321 -420
  132. package/src/extension-registry.ts +33 -0
  133. package/src/index.ts +310 -1110
  134. package/src/lifecycle.ts +171 -0
  135. package/src/loader.ts +141 -56
  136. package/src/macro-engine.ts +358 -0
  137. package/src/payload-capture.ts +85 -0
  138. package/src/payload-command.ts +138 -0
  139. package/src/policy.ts +42 -0
  140. package/src/preset-command.ts +271 -0
  141. package/src/preview.ts +226 -0
  142. package/src/regex.ts +500 -0
  143. package/src/render-helpers.ts +169 -0
  144. package/src/runtime-state.ts +36 -0
  145. package/src/sillytavern-importer/items.ts +98 -0
  146. package/src/sillytavern-importer/macros.ts +159 -0
  147. package/src/sillytavern-importer/prompt-order.ts +54 -0
  148. package/src/sillytavern-importer/regex.ts +270 -0
  149. package/src/sillytavern-importer/report.ts +202 -0
  150. package/src/sillytavern-importer/types.ts +120 -0
  151. package/src/sillytavern-importer.ts +57 -482
  152. package/src/slot-renderers.ts +414 -0
  153. package/src/stack-migration.ts +159 -0
  154. package/src/storage.ts +28 -0
  155. package/src/types.ts +83 -52
  156. package/src/web-editor/index.ts +2 -0
  157. package/src/web-editor/page.ts +3330 -0
  158. package/src/web-editor/server.ts +294 -0
  159. package/src/web-editor/types.ts +98 -0
  160. package/src/web-host.ts +232 -0
  161. package/src/web-editor.ts +0 -1491
@@ -1,149 +1,28 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
- import type { PromptStack, PromptStackItem } from "./types.ts";
4
-
5
- // ── SillyTavern preset raw types ──────────────────────────────────────────
6
-
7
- interface StPreset {
8
- prompts?: StPromptDef[];
9
- prompt_order?: StPromptOrderEntry[];
10
- names_behavior?: number;
11
- preset_name?: string;
12
- name?: string;
13
- [key: string]: unknown;
14
- }
15
-
16
- interface StPromptDef {
17
- identifier: string;
18
- name?: string;
19
- role?: string;
20
- content?: string;
21
- system_prompt?: boolean;
22
- marker?: boolean;
23
- enabled?: boolean;
24
- }
25
-
26
- interface StPromptOrderEntry {
27
- character_id: number;
28
- order?: StOrderItem[];
29
- }
30
-
31
- interface StOrderItem {
32
- identifier: string;
33
- enabled: boolean;
34
- }
35
-
36
- interface StConversionItem {
37
- def: StPromptDef;
38
- orderEnabled: boolean;
39
- orderIndex: number;
40
- }
41
-
42
- // ── Marker identifiers ─────────────────────────────────────────────────────
43
-
44
- const MARKER_CHAT_HISTORY = "chatHistory";
45
- const MARKER_SKIP = new Set([
46
- "worldInfoBefore",
47
- "worldInfoAfter",
48
- "charDescription",
49
- "charPersonality",
50
- "personaDescription",
51
- "scenario",
52
- "dialogueExamples",
53
- ]);
54
-
55
- // ── ST macros that pi-forge handles natively ───────────────────────────────
56
-
57
- const NATIVE_MACROS = new Set([
58
- "char", // → static variable
59
- "user", // → static variable
60
- "lastusermessage", // → handled by pi-forge runtime
61
- "cwd",
62
- "date",
63
- "time",
64
- ]);
65
-
66
- const MACRO_DISPLAY_NAMES: Record<string, string> = {
67
- lastusermessage: "lastUserMessage",
68
- groupnotmuted: "groupNotMuted",
69
- notchar: "notChar",
70
- charifnotgroup: "charIfNotGroup",
71
- mesexamples: "mesExamples",
72
- mesexamplesraw: "mesExamplesRaw",
73
- charprompt: "charPrompt",
74
- charinstruction: "charInstruction",
75
- charversion: "charVersion",
76
- charfirstmessage: "charFirstMessage",
77
- wibefore: "wiBefore",
78
- wiafter: "wiAfter",
79
- lorebefore: "loreBefore",
80
- loreafter: "loreAfter",
81
- anchorbefore: "anchorBefore",
82
- anchorafter: "anchorAfter",
83
- };
84
-
85
- // ── ST macros that need manual migration ───────────────────────────────────
86
-
87
- 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
- "addvar": "use pi-forge {{getvar::name}} + math, or setvar with computed value",
91
- "getglobalvar": "use pi-forge session variables via {{getsessionvar::name}}",
92
- "setglobalvar": "use pi-forge {{setsessionvar::name::value}}",
93
- "random": "use pi-forge conditionals (Priority 2) or precompute",
94
- "pick": "use pi-forge conditionals (Priority 2) or precompute",
95
- "roll": "use pi-forge conditionals (Priority 2) or precompute",
96
- "if": "use pi-forge conditionals (Priority 2)",
97
- "original": "SillyTavern-specific — no direct equivalent; set as session variable if needed",
98
- "outlet": "SillyTavern extension outlet — no pi-forge equivalent",
99
- "group": "SillyTavern group chat — no pi-forge equivalent",
100
- "groupnotmuted": "SillyTavern group chat — no pi-forge equivalent",
101
- "notchar": "SillyTavern group chat — no pi-forge equivalent",
102
- "charifnotgroup": "SillyTavern group chat — no pi-forge equivalent",
103
- "description": "ST character card field — set as session variable or static variable if needed",
104
- "personality": "ST character card field — set as session variable or static variable if needed",
105
- "scenario": "ST character card field — set as session variable or static variable if needed",
106
- "persona": "ST persona field — set as session variable or static variable if needed",
107
- "mesexamples": "ST character dialogue examples — add inline or as context file if needed",
108
- "mesexamplesraw": "ST character dialogue examples — add inline or as context file if needed",
109
- "charprompt": "ST character prompt override — merge into system prompt blocks if needed",
110
- "charinstruction": "ST character instruction override — merge into post-history blocks if needed",
111
- "charversion": "ST character version — set as static variable if needed",
112
- "charfirstmessage": "ST character first message — add as static variable if needed",
113
- "system": "ST context template system prompt — handled by pi-forge system prompt replacement",
114
- "wibefore": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
115
- "wiafter": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
116
- "lorebefore": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
117
- "loreafter": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
118
- "anchorbefore": "ST extension injection point — no pi-forge equivalent",
119
- "anchorafter": "ST extension injection point — no pi-forge equivalent",
120
- };
121
-
122
- // ── Macros we can strip entirely (produce no output) ───────────────────────
123
-
124
- const COMMENT_MACRO_RE = /\{\{\s*\/\/[\s\S]*?\}\}/g;
125
- const TRIM_MACRO_RE = /\{\{\s*trim\s*\}\}/gi;
126
-
127
- // ── Public result types ────────────────────────────────────────────────────
128
-
129
- export interface SillyTavernImportResult {
130
- stack: PromptStack;
131
- report: string;
132
- }
133
-
134
- export interface SillyTavernImportError {
135
- error: string;
136
- }
137
-
138
- export type SillyTavernImportOutcome = SillyTavernImportResult | SillyTavernImportError;
139
-
140
- export interface SillyTavernConvertOptions {
141
- sourceName?: string;
142
- sourcePath?: string;
143
- characterId?: number;
144
- }
145
-
146
- // ── 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";
147
26
 
148
27
  export function importSillyTavernPreset(
149
28
  filePath: string,
@@ -181,12 +60,12 @@ export function convertSillyTavernPreset(
181
60
  const sourcePath = options.sourcePath ?? sourceName;
182
61
  const allPrompts = preset.prompts ?? [];
183
62
  const promptOrder = preset.prompt_order ?? [];
63
+ const regexScripts = summarizeRegexScripts(preset);
184
64
 
185
65
  if (allPrompts.length === 0) {
186
66
  return { error: "Preset has no prompts array." };
187
67
  }
188
68
 
189
- // Select character_id
190
69
  const selectedEntry = selectCharacterEntry(promptOrder, options.characterId);
191
70
  if (!selectedEntry) {
192
71
  const ids = promptOrder.map((entry) => entry.character_id);
@@ -198,127 +77,27 @@ export function convertSillyTavernPreset(
198
77
  };
199
78
  }
200
79
 
201
- // Build ordered, enabled conversion items
202
- const conversionItems: StConversionItem[] = [];
203
- const promptMap = new Map<string, StPromptDef>();
204
- for (const def of allPrompts) {
205
- promptMap.set(def.identifier, def);
206
- }
207
-
208
- let orderIndex = 0;
209
- for (const orderItem of selectedEntry.order ?? []) {
210
- const def = promptMap.get(orderItem.identifier);
211
- if (def) {
212
- conversionItems.push({ def, orderEnabled: orderItem.enabled, orderIndex: orderIndex++ });
213
- }
214
- }
215
-
216
- // First pass: detect macros across all content
80
+ const promptMap = buildPromptMap(allPrompts);
81
+ const conversionItems = buildConversionItems(selectedEntry, promptMap);
217
82
  const macroUsage = detectMacros(conversionItems);
218
-
219
- // Build pi-forge items
220
- const reportNotes: string[] = [];
221
- let nextId = 1;
222
- const items: PromptStackItem[] = [];
223
- let usesLastUserMessage = false;
224
- const disabledCount = { marker: 0, empty: 0, orderDisable: 0 };
225
-
226
- for (const { def, orderEnabled, orderIndex } of conversionItems) {
227
- const id = String(nextId++);
228
-
229
- // Marker items
230
- if (def.marker) {
231
- if (def.identifier === MARKER_CHAT_HISTORY) {
232
- items.push({
233
- kind: "slot",
234
- id,
235
- name: def.name || "Chat History",
236
- enabled: orderEnabled,
237
- slot: "chat-history",
238
- source: { previousId: def.identifier, name: def.name },
239
- });
240
- } else if (MARKER_SKIP.has(def.identifier)) {
241
- disabledCount.marker++;
242
- }
243
- continue;
244
- }
245
-
246
- // Clean content: strip comments, handle trim
247
- let content = def.content ?? "";
248
- if (!content.trim()) {
249
- disabledCount.empty++;
250
- continue;
251
- }
252
-
253
- // Strip ST comment macros
254
- content = content.replace(COMMENT_MACRO_RE, "");
255
-
256
- // Strip {{trim}} — it's a formatting hint for newline removal, not meaningful content
257
- content = content.replace(TRIM_MACRO_RE, "");
258
-
259
- // After stripping comments/trim, check if content is still meaningful
260
- if (!content.trim()) {
261
- disabledCount.empty++;
262
- continue;
263
- }
264
-
265
- // Detect {{lastUserMessage}} usage, case-insensitively.
266
- if (/\{\{\s*lastUserMessage\b/i.test(content)) {
267
- usesLastUserMessage = true;
268
- }
269
-
270
- const role = normalizeRole(def.role);
271
- if (!role) {
272
- disabledCount.empty++;
273
- continue;
274
- }
275
-
276
- if (!orderEnabled) {
277
- disabledCount.orderDisable++;
278
- }
279
-
280
- items.push({
281
- kind: "block",
282
- id,
283
- name: def.name,
284
- enabled: orderEnabled,
285
- role,
286
- content: cleanContent(content),
287
- source: {
288
- previousId: def.identifier,
289
- previousName: def.name,
290
- orderIndex,
291
- },
292
- });
293
- }
294
-
295
- // Diagnostic: any prompts in the order that don't exist in the prompts array
296
- const missingIdentifiers: string[] = [];
297
- for (const orderItem of selectedEntry.order ?? []) {
298
- if (!promptMap.has(orderItem.identifier)) {
299
- missingIdentifiers.push(orderItem.identifier);
300
- }
301
- }
302
-
303
- // Build stack name from file
83
+ const { items, disabledCount, usesLastUserMessage: itemUsesLastUserMessage } = convertPromptItems(conversionItems);
84
+ const missingIdentifiers = findMissingIdentifiers(selectedEntry, promptMap);
304
85
  const fileName = basename(sourceName).replace(/\.json$/i, "") || "imported";
305
86
  const styleName = preset.names_behavior === 1 ? "names" : preset.names_behavior === 2 ? "nonames" : "default";
306
- const stackId = fileName.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "imported";
307
-
308
- // Build static variables
309
- const variables: Record<string, string> = {};
310
- if (macroUsage.detected.has("char")) variables.char = "{{char}}";
311
- if (macroUsage.detected.has("user")) variables.user = "{{user}}";
87
+ const stackId = normalizeStackId(fileName);
88
+ const variables = buildStaticVariables(macroUsage.detected);
89
+ let usesLastUserMessage = itemUsesLastUserMessage;
312
90
 
313
91
  const stack: PromptStack = {
314
92
  schemaVersion: 1,
315
93
  type: "pi-forge.prompt-stack",
316
94
  id: stackId,
317
- name: preset["preset_name"] || preset["name"] || fileName,
95
+ name: preset.preset_name || preset.name || fileName,
318
96
  autoActivate: false,
319
97
  mode: "replace",
320
98
  variables: Object.keys(variables).length > 0 ? variables : undefined,
321
99
  context: { allowDuplicateChatHistory: false },
100
+ regex: regexScripts && regexScripts.rules.length > 0 ? { schemaVersion: 1, rules: regexScripts.rules } : undefined,
322
101
  items,
323
102
  import: {
324
103
  source: "sillytavern",
@@ -328,7 +107,6 @@ export function convertSillyTavernPreset(
328
107
  },
329
108
  };
330
109
 
331
- // Add chat-history option if {{lastUserMessage}} is used after history.
332
110
  if (macroUsage.detected.has("lastusermessage")) usesLastUserMessage = true;
333
111
  if (usesLastUserMessage) {
334
112
  const chatHistoryItem = items.find(
@@ -339,239 +117,36 @@ export function convertSillyTavernPreset(
339
117
  }
340
118
  }
341
119
 
342
- // Build report
343
- const reportLines: string[] = [];
344
- reportLines.push(`# SillyTavern Import Report: ${fileName}`);
345
- reportLines.push("");
346
- reportLines.push(`- **Source file**: ${sourcePath}`);
347
- reportLines.push(`- **Character ID**: ${selectedEntry.character_id}`);
348
- reportLines.push(`- **Output stack ID**: ${stackId}`);
349
- reportLines.push(`- **Names behavior**: ${styleName} (ST value ${preset.names_behavior ?? "?"})`);
350
- reportLines.push(`- **Total prompts in source**: ${allPrompts.length}`);
351
- reportLines.push(`- **Items in prompt_order**: ${selectedEntry.order?.length ?? 0}`);
352
- reportLines.push(`- **Converted items**: ${items.length}`);
353
- reportLines.push("");
354
-
355
- // Stripped content
356
- if (macroUsage.commentsStripped > 0) {
357
- reportLines.push(`- **Comments stripped**: ${macroUsage.commentsStripped} ST comment blocks removed`);
358
- }
359
- if (macroUsage.trimStripped > 0) {
360
- reportLines.push(`- **Trim markers removed**: ${macroUsage.trimStripped} {{trim}} markers (ST formatting hint)`);
361
- }
362
- reportLines.push("");
363
-
364
- // Skipped items
365
- if (disabledCount.marker > 0 || disabledCount.empty > 0 || disabledCount.orderDisable > 0) {
366
- reportLines.push("## Skipped items");
367
- reportLines.push("");
368
- if (disabledCount.marker > 0) {
369
- reportLines.push(`- ${disabledCount.marker} marker items (charDescription, worldInfo, etc.) — handled by Pi/ST runtime, not needed in prompt stack`);
370
- }
371
- if (disabledCount.empty > 0) {
372
- reportLines.push(`- ${disabledCount.empty} items with empty or missing content`);
373
- }
374
- if (disabledCount.orderDisable > 0) {
375
- reportLines.push(`- ${disabledCount.orderDisable} items disabled in prompt_order`);
376
- }
377
- reportLines.push("");
378
- }
379
-
380
- // Missing identifiers
381
- if (missingIdentifiers.length > 0) {
382
- reportLines.push("## ⚠️ Missing identifiers in prompts array");
383
- reportLines.push("");
384
- for (const id of missingIdentifiers) {
385
- reportLines.push(`- \`${id}\` (referenced in prompt_order but not found in prompts)`);
386
- }
387
- reportLines.push("");
388
- }
389
-
390
- // Auto-populated variables
391
- if (Object.keys(variables).length > 0) {
392
- reportLines.push("## Auto-populated variables");
393
- reportLines.push("");
394
- reportLines.push("These ST built-in macros were auto-populated as static variables with placeholder values:");
395
- reportLines.push("");
396
- for (const [key, val] of Object.entries(variables)) {
397
- reportLines.push(`- \`${key}\` = \`${val}\` — replace with your character/persona name`);
398
- }
399
- reportLines.push("");
400
- reportLines.push("Use `/state set <name> <value>` or edit the stack JSON to set real values.");
401
- reportLines.push("");
402
- }
403
-
404
- // Macros needing migration
405
- const migrationEntries = Object.entries(macroUsage.migrationNeeded).sort(([, a], [, b]) => b - a);
406
- if (migrationEntries.length > 0) {
407
- reportLines.push("## ⚠️ Macros needing manual migration");
408
- reportLines.push("");
409
- reportLines.push("These ST macros appear in the preset but have no direct pi-forge equivalent:");
410
- reportLines.push("");
411
- for (const [name, count] of migrationEntries) {
412
- const note = MACRO_NEEDS_MIGRATION[name] ?? "no mapping available";
413
- reportLines.push(`- **\`{{${displayMacroName(name)}}}\`** (${count} occurrence${count > 1 ? "s" : ""}) — ${note}`);
414
- }
415
- reportLines.push("");
416
-
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
- }
431
-
432
- // Native macros handled
433
- const nativeDetected = [...macroUsage.detected].filter((m) => NATIVE_MACROS.has(m));
434
- if (nativeDetected.length > 0) {
435
- reportLines.push("## Handled macros");
436
- reportLines.push("");
437
- for (const name of nativeDetected) {
438
- const displayName = displayMacroName(name);
439
- if (name === "char" || name === "user") {
440
- reportLines.push(`- \`{{${displayName}}}\` → auto-populated as static variable`);
441
- } else if (name === "lastusermessage") {
442
- reportLines.push(`- \`{{${displayName}}}\` → handled by pi-forge runtime (chat-history slot)`);
443
- } else {
444
- reportLines.push(`- \`{{${displayName}}}\` → handled natively by pi-forge`);
445
- }
446
- }
447
- reportLines.push("");
448
- }
449
-
450
- // Item mapping table
451
- reportLines.push(`## Item mapping`);
452
- reportLines.push("");
453
- reportLines.push(`| # | ST Identifier | ST Name | pi-forge ID | Role | Enabled | Kind |`);
454
- reportLines.push(`|---|--------------|---------|-------------|------|---------|------|`);
455
-
456
- for (const item of items) {
457
- const stId = (item.source as Record<string, unknown> | undefined)?.previousId ?? "—";
458
- const stName = item.name ?? "—";
459
- const enabled = item.enabled !== false ? "✓" : "✗";
460
- const kind = item.kind;
461
- reportLines.push(`| ${item.id} | ${stId} | ${stName} | ${item.id} | ${item.role ?? "—"} | ${enabled} | ${kind} |`);
462
- }
463
-
464
- reportLines.push("");
465
- reportLines.push("## General notes");
466
- reportLines.push("");
467
-
468
- if (usesLastUserMessage) {
469
- reportLines.push("- Auto-detected `{{lastUserMessage}}` in post-history content. `chat-history` slot set with `includeLastUserMessage: false`.");
470
- }
471
-
472
- if (macroUsage.commentsStripped > 0 || macroUsage.trimStripped > 0) {
473
- reportLines.push(`- ${macroUsage.commentsStripped} ST comment blocks and ${macroUsage.trimStripped} TRIM markers were stripped during import.`);
474
- }
475
-
476
- reportLines.push("- All items are assigned sequential numeric IDs. Original SillyTavern identifiers are preserved in `source.previousId`.");
477
- 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.");
478
- reportLines.push(`- The stack is set with \`autoActivate: false\`. Use \`/preset use ${stackId}\` to activate.`);
479
- reportLines.push("");
480
- reportLines.push("## Suggested next steps");
481
- reportLines.push("");
482
- reportLines.push("1. Set real values for auto-populated variables with `/state set`.");
483
- reportLines.push("2. Review items with migration-needed macros and rewrite for pi-forge's macro system.");
484
- reportLines.push("3. Consider adding a `variables` slot for agent state visibility.");
485
- 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
+ });
486
136
 
487
137
  return {
488
138
  stack,
489
- report: reportLines.join("\n"),
139
+ report,
490
140
  };
491
141
  }
492
142
 
493
- // ── Macro detection ────────────────────────────────────────────────────────
494
-
495
- interface MacroDetection {
496
- detected: Set<string>;
497
- commentsStripped: number;
498
- trimStripped: number;
499
- migrationNeeded: Record<string, number>;
143
+ function normalizeStackId(fileName: string): string {
144
+ return fileName.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "imported";
500
145
  }
501
146
 
502
- function detectMacros(conversionItems: StConversionItem[]): MacroDetection {
503
- const detected = new Set<string>();
504
- let commentsStripped = 0;
505
- let trimStripped = 0;
506
- const migrationNeeded: Record<string, number> = {};
507
-
508
- // Regex to find {{macroName...}} — captures the first word/identifier
509
- const macroFindRe = /\{\{(?!\/\/)([a-zA-Z_][a-zA-Z0-9_-]*)/gi;
510
-
511
- for (const { def } of conversionItems) {
512
- const content = def.content ?? "";
513
- if (!content) continue;
514
-
515
- // Count comments
516
- const commentMatches = content.match(COMMENT_MACRO_RE);
517
- if (commentMatches) commentsStripped += commentMatches.length;
518
-
519
- // Count trims
520
- const trimMatches = content.match(TRIM_MACRO_RE);
521
- if (trimMatches) trimStripped += trimMatches.length;
522
-
523
- // Work on a copy with comments stripped so we don't flag // as a macro
524
- const cleaned = content.replace(COMMENT_MACRO_RE, "").replace(TRIM_MACRO_RE, "");
525
-
526
- let match: RegExpExecArray | null;
527
- macroFindRe.lastIndex = 0;
528
- while ((match = macroFindRe.exec(cleaned)) !== null) {
529
- const name = match[1].toLowerCase();
530
- detected.add(name);
531
-
532
- if (MACRO_NEEDS_MIGRATION[name]) {
533
- migrationNeeded[name] = (migrationNeeded[name] ?? 0) + 1;
534
- }
535
- }
536
- }
537
-
538
- return { detected, commentsStripped, trimStripped, migrationNeeded };
539
- }
540
-
541
- // ── Content cleaning ──────────────────────────────────────────────────────
542
-
543
- function displayMacroName(name: string): string {
544
- return MACRO_DISPLAY_NAMES[name] ?? name;
545
- }
546
-
547
- function cleanContent(content: string): string {
548
- // Collapse multiple blank lines (max 1 blank line between paragraphs)
549
- return content.replace(/\n{3,}/g, "\n\n").trim();
550
- }
551
-
552
- // ── Helpers ────────────────────────────────────────────────────────────────
553
-
554
- function selectCharacterEntry(
555
- order: StPromptOrderEntry[],
556
- preferredId?: number,
557
- ): StPromptOrderEntry | undefined {
558
- if (order.length === 0) return undefined;
559
-
560
- if (preferredId !== undefined) {
561
- return order.find((entry) => entry.character_id === preferredId);
562
- }
563
-
564
- if (order.length === 1) return order[0];
565
-
566
- // Multiple entries, no preference — can't auto-select
567
- return undefined;
568
- }
569
-
570
- function normalizeRole(role: string | undefined): "system" | "user" | "assistant" | undefined {
571
- if (!role) return undefined;
572
- const lower = role.trim().toLowerCase();
573
- if (lower === "system") return "system";
574
- if (lower === "user") return "user";
575
- if (lower === "assistant") return "assistant";
576
- 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;
577
152
  }