@zihanw/pi-forge 0.1.0 → 0.2.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/src/loader.ts CHANGED
@@ -6,6 +6,10 @@ import type {
6
6
  PromptStackDiagnostic,
7
7
  PromptStackItem,
8
8
  PromptStackRole,
9
+ PromptStateConfig,
10
+ PromptStateDefinition,
11
+ PromptStateScope,
12
+ PromptStateValue,
9
13
  } from "./types.ts";
10
14
  import { SUPPORTED_SLOTS } from "./types.ts";
11
15
 
@@ -26,7 +30,30 @@ export function loadPromptStacks(cwd: string): LoadedPromptStack[] {
26
30
  return [];
27
31
  }
28
32
 
29
- return entries.sort().map((name) => loadPromptStackFile(join(dir, name)));
33
+ const loaded = entries.sort().map((name) => loadPromptStackFile(join(dir, name)));
34
+ annotateDuplicateStackIds(loaded);
35
+ return loaded;
36
+ }
37
+
38
+ function annotateDuplicateStackIds(stacks: LoadedPromptStack[]): void {
39
+ const byId = new Map<string, LoadedPromptStack[]>();
40
+ for (const loaded of stacks) {
41
+ const id = loaded.stack.id;
42
+ const matches = byId.get(id) ?? [];
43
+ matches.push(loaded);
44
+ byId.set(id, matches);
45
+ }
46
+
47
+ for (const [id, matches] of byId) {
48
+ if (matches.length <= 1) continue;
49
+ const files = matches.map((loaded) => basename(loaded.filePath)).join(", ");
50
+ for (const loaded of matches) {
51
+ loaded.diagnostics.push({
52
+ level: "error",
53
+ message: `Duplicate stack id: ${id} appears in multiple files (${files}).`,
54
+ });
55
+ }
56
+ }
30
57
  }
31
58
 
32
59
  export function chooseDefaultStack(
@@ -123,6 +150,7 @@ function normalizeStack(raw: unknown, filePath: string, diagnostics: PromptStack
123
150
  defaults: isPlainObject(obj.defaults) ? (obj.defaults as PromptStack["defaults"]) : undefined,
124
151
  context: isPlainObject(obj.context) ? (obj.context as PromptStack["context"]) : undefined,
125
152
  variables: normalizeStringRecord(obj.variables),
153
+ state: normalizeStateConfig(obj.state, diagnostics),
126
154
  items,
127
155
  import: isPlainObject(obj.import) ? (obj.import as Record<string, unknown>) : undefined,
128
156
  };
@@ -225,3 +253,57 @@ function normalizeStringRecord(value: unknown): Record<string, string> | undefin
225
253
  }
226
254
  return result;
227
255
  }
256
+
257
+ function normalizeStateConfig(value: unknown, diagnostics: PromptStackDiagnostic[]): PromptStateConfig | undefined {
258
+ if (value === undefined) return undefined;
259
+ if (!isPlainObject(value)) {
260
+ diagnostics.push({ level: "warning", message: "state must be an object when provided." });
261
+ return undefined;
262
+ }
263
+
264
+ const definitionsRaw = value.definitions;
265
+ const definitions: Record<string, PromptStateDefinition> = {};
266
+
267
+ if (definitionsRaw !== undefined && !isPlainObject(definitionsRaw)) {
268
+ diagnostics.push({ level: "warning", message: "state.definitions must be an object when provided." });
269
+ }
270
+
271
+ if (isPlainObject(definitionsRaw)) {
272
+ for (const [name, rawDefinition] of Object.entries(definitionsRaw)) {
273
+ if (!isPlainObject(rawDefinition)) {
274
+ diagnostics.push({ level: "warning", message: `state definition for ${name} must be an object.` });
275
+ continue;
276
+ }
277
+
278
+ const definition: PromptStateDefinition = {};
279
+ if (typeof rawDefinition.type === "string") definition.type = rawDefinition.type;
280
+ if (isStateScope(rawDefinition.scope)) definition.scope = rawDefinition.scope;
281
+ if (typeof rawDefinition.description === "string") definition.description = rawDefinition.description;
282
+ if (typeof rawDefinition.agentWritable === "boolean") definition.agentWritable = rawDefinition.agentWritable;
283
+ if (typeof rawDefinition.userWritable === "boolean") definition.userWritable = rawDefinition.userWritable;
284
+ if (isPromptStateValue(rawDefinition.default)) definition.default = rawDefinition.default;
285
+ else if (rawDefinition.default !== undefined) {
286
+ diagnostics.push({ level: "warning", message: `state definition default for ${name} is not a JSON-compatible value.` });
287
+ }
288
+ definitions[name] = definition;
289
+ }
290
+ }
291
+
292
+ return {
293
+ schemaVersion: value.schemaVersion === 1 ? 1 : undefined,
294
+ definitions: Object.keys(definitions).length > 0 ? definitions : undefined,
295
+ };
296
+ }
297
+
298
+ function isStateScope(value: unknown): value is PromptStateScope {
299
+ return value === "static" || value === "session" || value === "turn";
300
+ }
301
+
302
+ function isPromptStateValue(value: unknown): value is PromptStateValue {
303
+ if (value === null) return true;
304
+ const type = typeof value;
305
+ if (type === "string" || type === "number" || type === "boolean") return Number.isFinite(value as number) || type !== "number";
306
+ if (Array.isArray(value)) return value.every(isPromptStateValue);
307
+ if (!isPlainObject(value)) return false;
308
+ return Object.values(value).every(isPromptStateValue);
309
+ }
@@ -55,14 +55,33 @@ const MARKER_SKIP = new Set([
55
55
  // ── ST macros that pi-forge handles natively ───────────────────────────────
56
56
 
57
57
  const NATIVE_MACROS = new Set([
58
- "char", // → static variable
59
- "user", // → static variable
60
- "lastUserMessage", // → handled by pi-forge runtime
58
+ "char", // → static variable
59
+ "user", // → static variable
60
+ "lastusermessage", // → handled by pi-forge runtime
61
61
  "cwd",
62
62
  "date",
63
63
  "time",
64
64
  ]);
65
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
+
66
85
  // ── ST macros that need manual migration ───────────────────────────────────
67
86
 
68
87
  const MACRO_NEEDS_MIGRATION: Record<string, string> = {
@@ -78,26 +97,26 @@ const MACRO_NEEDS_MIGRATION: Record<string, string> = {
78
97
  "original": "SillyTavern-specific — no direct equivalent; set as session variable if needed",
79
98
  "outlet": "SillyTavern extension outlet — no pi-forge equivalent",
80
99
  "group": "SillyTavern group chat — no pi-forge equivalent",
81
- "groupNotMuted": "SillyTavern group chat — no pi-forge equivalent",
82
- "notChar": "SillyTavern group chat — no pi-forge equivalent",
83
- "charIfNotGroup": "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",
84
103
  "description": "ST character card field — set as session variable or static variable if needed",
85
104
  "personality": "ST character card field — set as session variable or static variable if needed",
86
105
  "scenario": "ST character card field — set as session variable or static variable if needed",
87
106
  "persona": "ST persona field — set as session variable or static variable if needed",
88
- "mesExamples": "ST character dialogue examples — add inline or as context file if needed",
89
- "mesExamplesRaw": "ST character dialogue examples — add inline or as context file if needed",
90
- "charPrompt": "ST character prompt override — merge into system prompt blocks if needed",
91
- "charInstruction": "ST character instruction override — merge into post-history blocks if needed",
92
- "charVersion": "ST character version — set as static variable if needed",
93
- "charFirstMessage": "ST character first message — add as 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",
94
113
  "system": "ST context template system prompt — handled by pi-forge system prompt replacement",
95
- "wiBefore": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
96
- "wiAfter": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
97
- "loreBefore": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
98
- "loreAfter": "ST world info — no pi-forge equivalent; merge relevant lore into static blocks",
99
- "anchorBefore": "ST extension injection point — no pi-forge equivalent",
100
- "anchorAfter": "ST extension injection point — no pi-forge equivalent",
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",
101
120
  };
102
121
 
103
122
  // ── Macros we can strip entirely (produce no output) ───────────────────────
@@ -118,6 +137,12 @@ export interface SillyTavernImportError {
118
137
 
119
138
  export type SillyTavernImportOutcome = SillyTavernImportResult | SillyTavernImportError;
120
139
 
140
+ export interface SillyTavernConvertOptions {
141
+ sourceName?: string;
142
+ sourcePath?: string;
143
+ characterId?: number;
144
+ }
145
+
121
146
  // ── Importer ───────────────────────────────────────────────────────────────
122
147
 
123
148
  export function importSillyTavernPreset(
@@ -135,7 +160,25 @@ export function importSillyTavernPreset(
135
160
  return { error: "Preset root must be a JSON object." };
136
161
  }
137
162
 
163
+ return convertSillyTavernPreset(raw, { sourceName: filePath, sourcePath: filePath, characterId });
164
+ }
165
+
166
+ export function convertSillyTavernPreset(
167
+ raw: unknown,
168
+ options: SillyTavernConvertOptions = {},
169
+ ): SillyTavernImportOutcome {
170
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
171
+ return { error: "Preset root must be a JSON object." };
172
+ }
173
+
138
174
  const preset = raw as StPreset;
175
+ const fallbackName = typeof preset.preset_name === "string" && preset.preset_name.trim()
176
+ ? preset.preset_name
177
+ : typeof preset.name === "string" && preset.name.trim()
178
+ ? preset.name
179
+ : "imported.json";
180
+ const sourceName = options.sourceName ?? fallbackName;
181
+ const sourcePath = options.sourcePath ?? sourceName;
139
182
  const allPrompts = preset.prompts ?? [];
140
183
  const promptOrder = preset.prompt_order ?? [];
141
184
 
@@ -144,14 +187,14 @@ export function importSillyTavernPreset(
144
187
  }
145
188
 
146
189
  // Select character_id
147
- const selectedEntry = selectCharacterEntry(promptOrder, characterId);
190
+ const selectedEntry = selectCharacterEntry(promptOrder, options.characterId);
148
191
  if (!selectedEntry) {
149
192
  const ids = promptOrder.map((entry) => entry.character_id);
150
193
  if (ids.length === 0) {
151
- return { error: "Preset has no prompt_order entries. Specify a character_id with /preset import-silly <path> <character_id>." };
194
+ return { error: "Preset has no prompt_order entries. Choose a character_id and retry." };
152
195
  }
153
196
  return {
154
- error: `Multiple character configs found: [${ids.join(", ")}]. Re-run with /preset import-silly <path> <character_id>.`,
197
+ error: `Multiple character configs found: [${ids.join(", ")}]. Choose a character_id and retry.`,
155
198
  };
156
199
  }
157
200
 
@@ -219,8 +262,8 @@ export function importSillyTavernPreset(
219
262
  continue;
220
263
  }
221
264
 
222
- // Detect {{lastUserMessage}} usage
223
- if (content.includes("{{lastUserMessage}}")) {
265
+ // Detect {{lastUserMessage}} usage, case-insensitively.
266
+ if (/\{\{\s*lastUserMessage\b/i.test(content)) {
224
267
  usesLastUserMessage = true;
225
268
  }
226
269
 
@@ -258,7 +301,7 @@ export function importSillyTavernPreset(
258
301
  }
259
302
 
260
303
  // Build stack name from file
261
- const fileName = basename(filePath, ".json");
304
+ const fileName = basename(sourceName).replace(/\.json$/i, "") || "imported";
262
305
  const styleName = preset.names_behavior === 1 ? "names" : preset.names_behavior === 2 ? "nonames" : "default";
263
306
  const stackId = fileName.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "imported";
264
307
 
@@ -285,7 +328,8 @@ export function importSillyTavernPreset(
285
328
  },
286
329
  };
287
330
 
288
- // Add chat-history option if {{lastUserMessage}} is used after history
331
+ // Add chat-history option if {{lastUserMessage}} is used after history.
332
+ if (macroUsage.detected.has("lastusermessage")) usesLastUserMessage = true;
289
333
  if (usesLastUserMessage) {
290
334
  const chatHistoryItem = items.find(
291
335
  (item) => item.kind === "slot" && item.slot === "chat-history",
@@ -299,7 +343,7 @@ export function importSillyTavernPreset(
299
343
  const reportLines: string[] = [];
300
344
  reportLines.push(`# SillyTavern Import Report: ${fileName}`);
301
345
  reportLines.push("");
302
- reportLines.push(`- **Source file**: ${filePath}`);
346
+ reportLines.push(`- **Source file**: ${sourcePath}`);
303
347
  reportLines.push(`- **Character ID**: ${selectedEntry.character_id}`);
304
348
  reportLines.push(`- **Output stack ID**: ${stackId}`);
305
349
  reportLines.push(`- **Names behavior**: ${styleName} (ST value ${preset.names_behavior ?? "?"})`);
@@ -353,7 +397,7 @@ export function importSillyTavernPreset(
353
397
  reportLines.push(`- \`${key}\` = \`${val}\` — replace with your character/persona name`);
354
398
  }
355
399
  reportLines.push("");
356
- reportLines.push("Use `/preset vars set <name> <value>` or edit the stack JSON to set real values.");
400
+ reportLines.push("Use `/state set <name> <value>` or edit the stack JSON to set real values.");
357
401
  reportLines.push("");
358
402
  }
359
403
 
@@ -366,7 +410,7 @@ export function importSillyTavernPreset(
366
410
  reportLines.push("");
367
411
  for (const [name, count] of migrationEntries) {
368
412
  const note = MACRO_NEEDS_MIGRATION[name] ?? "no mapping available";
369
- reportLines.push(`- **\`{{${name}}}\`** (${count} occurrence${count > 1 ? "s" : ""}) — ${note}`);
413
+ reportLines.push(`- **\`{{${displayMacroName(name)}}}\`** (${count} occurrence${count > 1 ? "s" : ""}) — ${note}`);
370
414
  }
371
415
  reportLines.push("");
372
416
 
@@ -377,7 +421,7 @@ export function importSillyTavernPreset(
377
421
  reportLines.push("ST uses `setvar`/`getvar` for ephemeral state. pi-forge has a different variable model:");
378
422
  reportLines.push("");
379
423
  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");
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");
381
425
  reportLines.push("- **Turn variables** — ephemeral, set via `{{setvar::name::value}}` macros in blocks");
382
426
  reportLines.push("");
383
427
  reportLines.push("Review ST `setvar`/`getvar` calls and migrate to the appropriate pi-forge scope.");
@@ -391,12 +435,13 @@ export function importSillyTavernPreset(
391
435
  reportLines.push("## Handled macros");
392
436
  reportLines.push("");
393
437
  for (const name of nativeDetected) {
438
+ const displayName = displayMacroName(name);
394
439
  if (name === "char" || name === "user") {
395
- reportLines.push(`- \`{{${name}}}\` → auto-populated as static variable`);
396
- } else if (name === "lastUserMessage") {
397
- reportLines.push(`- \`{{${name}}}\` → handled by pi-forge runtime (chat-history slot)`);
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)`);
398
443
  } else {
399
- reportLines.push(`- \`{{${name}}}\` → handled natively by pi-forge`);
444
+ reportLines.push(`- \`{{${displayName}}}\` → handled natively by pi-forge`);
400
445
  }
401
446
  }
402
447
  reportLines.push("");
@@ -434,7 +479,7 @@ export function importSillyTavernPreset(
434
479
  reportLines.push("");
435
480
  reportLines.push("## Suggested next steps");
436
481
  reportLines.push("");
437
- reportLines.push("1. Set real values for auto-populated variables with `/preset vars set`.");
482
+ reportLines.push("1. Set real values for auto-populated variables with `/state set`.");
438
483
  reportLines.push("2. Review items with migration-needed macros and rewrite for pi-forge's macro system.");
439
484
  reportLines.push("3. Consider adding a `variables` slot for agent state visibility.");
440
485
  reportLines.push(`4. Run \`/preset validate ${stackId}\` to check for issues.`);
@@ -495,6 +540,10 @@ function detectMacros(conversionItems: StConversionItem[]): MacroDetection {
495
540
 
496
541
  // ── Content cleaning ──────────────────────────────────────────────────────
497
542
 
543
+ function displayMacroName(name: string): string {
544
+ return MACRO_DISPLAY_NAMES[name] ?? name;
545
+ }
546
+
498
547
  function cleanContent(content: string): string {
499
548
  // Collapse multiple blank lines (max 1 blank line between paragraphs)
500
549
  return content.replace(/\n{3,}/g, "\n\n").trim();
package/src/types.ts CHANGED
@@ -50,6 +50,18 @@ export interface VariablesSlotOptions {
50
50
  includeSession?: boolean;
51
51
  /** Include turn variables. Default: true. */
52
52
  includeTurn?: boolean;
53
+ /** Include only these scopes. Overrides includeStatic/includeSession/includeTurn when present. */
54
+ includeScopes?: PromptStateScope[];
55
+ /** Include variable names matching these exact names or wildcard prefixes, e.g. agent.*. */
56
+ includeNamespaces?: string[];
57
+ /** Exclude variable names matching these exact names or wildcard prefixes. */
58
+ excludeNamespaces?: string[];
59
+ /** Include type and description metadata from stack.state.definitions. Default: false. */
60
+ includeMetadata?: boolean;
61
+ /** Render format. Default: xml. */
62
+ format?: "xml" | "json";
63
+ /** Truncate each rendered value after this many characters. */
64
+ maxValueChars?: number;
53
65
  }
54
66
 
55
67
  export interface PromptStackSlotOptions {
@@ -59,6 +71,12 @@ export interface PromptStackSlotOptions {
59
71
  includeStatic?: boolean;
60
72
  includeSession?: boolean;
61
73
  includeTurn?: boolean;
74
+ includeScopes?: PromptStateScope[];
75
+ includeNamespaces?: string[];
76
+ excludeNamespaces?: string[];
77
+ includeMetadata?: boolean;
78
+ format?: "xml" | "json";
79
+ maxValueChars?: number;
62
80
  [key: string]: unknown;
63
81
  }
64
82
 
@@ -70,6 +88,26 @@ export interface PromptStackSlotItem extends PromptStackBaseItem {
70
88
 
71
89
  export type PromptStackItem = PromptStackBlockItem | PromptStackSlotItem;
72
90
 
91
+ export type PromptStatePrimitive = string | number | boolean | null;
92
+
93
+ export type PromptStateValue = PromptStatePrimitive | PromptStateValue[] | { [key: string]: PromptStateValue };
94
+
95
+ export type PromptStateScope = "static" | "session" | "turn";
96
+
97
+ export interface PromptStateDefinition {
98
+ type?: string;
99
+ scope?: PromptStateScope;
100
+ description?: string;
101
+ agentWritable?: boolean;
102
+ userWritable?: boolean;
103
+ default?: PromptStateValue;
104
+ }
105
+
106
+ export interface PromptStateConfig {
107
+ schemaVersion?: 1;
108
+ definitions?: Record<string, PromptStateDefinition>;
109
+ }
110
+
73
111
  export interface PromptStack {
74
112
  schemaVersion: 1;
75
113
  type?: "pi-forge.prompt-stack";
@@ -81,6 +119,7 @@ export interface PromptStack {
81
119
  defaults?: PromptStackDefaults;
82
120
  context?: PromptStackContextOptions;
83
121
  variables?: Record<string, string>;
122
+ state?: PromptStateConfig;
84
123
  items: PromptStackItem[];
85
124
  import?: Record<string, unknown>;
86
125
  }
@@ -100,8 +139,8 @@ export interface PromptStackDiagnostic {
100
139
  }
101
140
 
102
141
  export interface PromptVariableStore {
103
- turn: Record<string, string>;
104
- session: Record<string, string>;
142
+ turn: Record<string, PromptStateValue>;
143
+ session: Record<string, PromptStateValue>;
105
144
  sessionDirty?: boolean;
106
145
  }
107
146