@zihanw/pi-forge 0.1.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.
@@ -0,0 +1,528 @@
1
+ import { readFileSync } from "node:fs";
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
+ // ── ST macros that need manual migration ───────────────────────────────────
67
+
68
+ const MACRO_NEEDS_MIGRATION: Record<string, string> = {
69
+ "setvar": "pi-forge {{setvar::name::value}} / {{setsessionvar::name::value}} — scoping differs",
70
+ "getvar": "pi-forge {{getvar::name}} — lookup order: turn → session → static",
71
+ "addvar": "use pi-forge {{getvar::name}} + math, or setvar with computed value",
72
+ "getglobalvar": "use pi-forge session variables via {{getsessionvar::name}}",
73
+ "setglobalvar": "use pi-forge {{setsessionvar::name::value}}",
74
+ "random": "use pi-forge conditionals (Priority 2) or precompute",
75
+ "pick": "use pi-forge conditionals (Priority 2) or precompute",
76
+ "roll": "use pi-forge conditionals (Priority 2) or precompute",
77
+ "if": "use pi-forge conditionals (Priority 2)",
78
+ "original": "SillyTavern-specific — no direct equivalent; set as session variable if needed",
79
+ "outlet": "SillyTavern extension outlet — no pi-forge equivalent",
80
+ "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",
84
+ "description": "ST character card field — set as session variable or static variable if needed",
85
+ "personality": "ST character card field — set as session variable or static variable if needed",
86
+ "scenario": "ST character card field — set as session variable or static variable if needed",
87
+ "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",
94
+ "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",
101
+ };
102
+
103
+ // ── Macros we can strip entirely (produce no output) ───────────────────────
104
+
105
+ const COMMENT_MACRO_RE = /\{\{\s*\/\/[\s\S]*?\}\}/g;
106
+ const TRIM_MACRO_RE = /\{\{\s*trim\s*\}\}/gi;
107
+
108
+ // ── Public result types ────────────────────────────────────────────────────
109
+
110
+ export interface SillyTavernImportResult {
111
+ stack: PromptStack;
112
+ report: string;
113
+ }
114
+
115
+ export interface SillyTavernImportError {
116
+ error: string;
117
+ }
118
+
119
+ export type SillyTavernImportOutcome = SillyTavernImportResult | SillyTavernImportError;
120
+
121
+ // ── Importer ───────────────────────────────────────────────────────────────
122
+
123
+ export function importSillyTavernPreset(
124
+ filePath: string,
125
+ characterId?: number,
126
+ ): SillyTavernImportOutcome {
127
+ let raw: unknown;
128
+ try {
129
+ raw = JSON.parse(readFileSync(filePath, "utf8"));
130
+ } catch (err) {
131
+ return { error: `Failed to read or parse ${filePath}: ${err instanceof Error ? err.message : String(err)}` };
132
+ }
133
+
134
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
135
+ return { error: "Preset root must be a JSON object." };
136
+ }
137
+
138
+ const preset = raw as StPreset;
139
+ const allPrompts = preset.prompts ?? [];
140
+ const promptOrder = preset.prompt_order ?? [];
141
+
142
+ if (allPrompts.length === 0) {
143
+ return { error: "Preset has no prompts array." };
144
+ }
145
+
146
+ // Select character_id
147
+ const selectedEntry = selectCharacterEntry(promptOrder, characterId);
148
+ if (!selectedEntry) {
149
+ const ids = promptOrder.map((entry) => entry.character_id);
150
+ if (ids.length === 0) {
151
+ return { error: "Preset has no prompt_order entries. Specify a character_id with /preset import-silly <path> <character_id>." };
152
+ }
153
+ return {
154
+ error: `Multiple character configs found: [${ids.join(", ")}]. Re-run with /preset import-silly <path> <character_id>.`,
155
+ };
156
+ }
157
+
158
+ // Build ordered, enabled conversion items
159
+ const conversionItems: StConversionItem[] = [];
160
+ const promptMap = new Map<string, StPromptDef>();
161
+ for (const def of allPrompts) {
162
+ promptMap.set(def.identifier, def);
163
+ }
164
+
165
+ let orderIndex = 0;
166
+ for (const orderItem of selectedEntry.order ?? []) {
167
+ const def = promptMap.get(orderItem.identifier);
168
+ if (def) {
169
+ conversionItems.push({ def, orderEnabled: orderItem.enabled, orderIndex: orderIndex++ });
170
+ }
171
+ }
172
+
173
+ // First pass: detect macros across all content
174
+ const macroUsage = detectMacros(conversionItems);
175
+
176
+ // Build pi-forge items
177
+ const reportNotes: string[] = [];
178
+ let nextId = 1;
179
+ const items: PromptStackItem[] = [];
180
+ let usesLastUserMessage = false;
181
+ const disabledCount = { marker: 0, empty: 0, orderDisable: 0 };
182
+
183
+ for (const { def, orderEnabled, orderIndex } of conversionItems) {
184
+ const id = String(nextId++);
185
+
186
+ // Marker items
187
+ if (def.marker) {
188
+ if (def.identifier === MARKER_CHAT_HISTORY) {
189
+ items.push({
190
+ kind: "slot",
191
+ id,
192
+ name: def.name || "Chat History",
193
+ enabled: orderEnabled,
194
+ slot: "chat-history",
195
+ source: { previousId: def.identifier, name: def.name },
196
+ });
197
+ } else if (MARKER_SKIP.has(def.identifier)) {
198
+ disabledCount.marker++;
199
+ }
200
+ continue;
201
+ }
202
+
203
+ // Clean content: strip comments, handle trim
204
+ let content = def.content ?? "";
205
+ if (!content.trim()) {
206
+ disabledCount.empty++;
207
+ continue;
208
+ }
209
+
210
+ // Strip ST comment macros
211
+ content = content.replace(COMMENT_MACRO_RE, "");
212
+
213
+ // Strip {{trim}} — it's a formatting hint for newline removal, not meaningful content
214
+ content = content.replace(TRIM_MACRO_RE, "");
215
+
216
+ // After stripping comments/trim, check if content is still meaningful
217
+ if (!content.trim()) {
218
+ disabledCount.empty++;
219
+ continue;
220
+ }
221
+
222
+ // Detect {{lastUserMessage}} usage
223
+ if (content.includes("{{lastUserMessage}}")) {
224
+ usesLastUserMessage = true;
225
+ }
226
+
227
+ const role = normalizeRole(def.role);
228
+ if (!role) {
229
+ disabledCount.empty++;
230
+ continue;
231
+ }
232
+
233
+ if (!orderEnabled) {
234
+ disabledCount.orderDisable++;
235
+ }
236
+
237
+ items.push({
238
+ kind: "block",
239
+ id,
240
+ name: def.name,
241
+ enabled: orderEnabled,
242
+ role,
243
+ content: cleanContent(content),
244
+ source: {
245
+ previousId: def.identifier,
246
+ previousName: def.name,
247
+ orderIndex,
248
+ },
249
+ });
250
+ }
251
+
252
+ // Diagnostic: any prompts in the order that don't exist in the prompts array
253
+ const missingIdentifiers: string[] = [];
254
+ for (const orderItem of selectedEntry.order ?? []) {
255
+ if (!promptMap.has(orderItem.identifier)) {
256
+ missingIdentifiers.push(orderItem.identifier);
257
+ }
258
+ }
259
+
260
+ // Build stack name from file
261
+ const fileName = basename(filePath, ".json");
262
+ const styleName = preset.names_behavior === 1 ? "names" : preset.names_behavior === 2 ? "nonames" : "default";
263
+ const stackId = fileName.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "imported";
264
+
265
+ // Build static variables
266
+ const variables: Record<string, string> = {};
267
+ if (macroUsage.detected.has("char")) variables.char = "{{char}}";
268
+ if (macroUsage.detected.has("user")) variables.user = "{{user}}";
269
+
270
+ const stack: PromptStack = {
271
+ schemaVersion: 1,
272
+ type: "pi-forge.prompt-stack",
273
+ id: stackId,
274
+ name: preset["preset_name"] || preset["name"] || fileName,
275
+ autoActivate: false,
276
+ mode: "replace",
277
+ variables: Object.keys(variables).length > 0 ? variables : undefined,
278
+ context: { allowDuplicateChatHistory: false },
279
+ items,
280
+ import: {
281
+ source: "sillytavern",
282
+ sourceFile: fileName,
283
+ characterId: selectedEntry.character_id,
284
+ convertedAt: new Date().toISOString(),
285
+ },
286
+ };
287
+
288
+ // Add chat-history option if {{lastUserMessage}} is used after history
289
+ if (usesLastUserMessage) {
290
+ const chatHistoryItem = items.find(
291
+ (item) => item.kind === "slot" && item.slot === "chat-history",
292
+ );
293
+ if (chatHistoryItem && chatHistoryItem.kind === "slot") {
294
+ chatHistoryItem.options = { includeLastUserMessage: false };
295
+ }
296
+ }
297
+
298
+ // Build report
299
+ const reportLines: string[] = [];
300
+ reportLines.push(`# SillyTavern Import Report: ${fileName}`);
301
+ reportLines.push("");
302
+ reportLines.push(`- **Source file**: ${filePath}`);
303
+ reportLines.push(`- **Character ID**: ${selectedEntry.character_id}`);
304
+ reportLines.push(`- **Output stack ID**: ${stackId}`);
305
+ reportLines.push(`- **Names behavior**: ${styleName} (ST value ${preset.names_behavior ?? "?"})`);
306
+ reportLines.push(`- **Total prompts in source**: ${allPrompts.length}`);
307
+ reportLines.push(`- **Items in prompt_order**: ${selectedEntry.order?.length ?? 0}`);
308
+ reportLines.push(`- **Converted items**: ${items.length}`);
309
+ reportLines.push("");
310
+
311
+ // Stripped content
312
+ if (macroUsage.commentsStripped > 0) {
313
+ reportLines.push(`- **Comments stripped**: ${macroUsage.commentsStripped} ST comment blocks removed`);
314
+ }
315
+ if (macroUsage.trimStripped > 0) {
316
+ reportLines.push(`- **Trim markers removed**: ${macroUsage.trimStripped} {{trim}} markers (ST formatting hint)`);
317
+ }
318
+ reportLines.push("");
319
+
320
+ // Skipped items
321
+ if (disabledCount.marker > 0 || disabledCount.empty > 0 || disabledCount.orderDisable > 0) {
322
+ reportLines.push("## Skipped items");
323
+ reportLines.push("");
324
+ if (disabledCount.marker > 0) {
325
+ reportLines.push(`- ${disabledCount.marker} marker items (charDescription, worldInfo, etc.) — handled by Pi/ST runtime, not needed in prompt stack`);
326
+ }
327
+ if (disabledCount.empty > 0) {
328
+ reportLines.push(`- ${disabledCount.empty} items with empty or missing content`);
329
+ }
330
+ if (disabledCount.orderDisable > 0) {
331
+ reportLines.push(`- ${disabledCount.orderDisable} items disabled in prompt_order`);
332
+ }
333
+ reportLines.push("");
334
+ }
335
+
336
+ // Missing identifiers
337
+ if (missingIdentifiers.length > 0) {
338
+ reportLines.push("## ⚠️ Missing identifiers in prompts array");
339
+ reportLines.push("");
340
+ for (const id of missingIdentifiers) {
341
+ reportLines.push(`- \`${id}\` (referenced in prompt_order but not found in prompts)`);
342
+ }
343
+ reportLines.push("");
344
+ }
345
+
346
+ // Auto-populated variables
347
+ if (Object.keys(variables).length > 0) {
348
+ reportLines.push("## Auto-populated variables");
349
+ reportLines.push("");
350
+ reportLines.push("These ST built-in macros were auto-populated as static variables with placeholder values:");
351
+ reportLines.push("");
352
+ for (const [key, val] of Object.entries(variables)) {
353
+ reportLines.push(`- \`${key}\` = \`${val}\` — replace with your character/persona name`);
354
+ }
355
+ reportLines.push("");
356
+ reportLines.push("Use `/preset vars set <name> <value>` or edit the stack JSON to set real values.");
357
+ reportLines.push("");
358
+ }
359
+
360
+ // Macros needing migration
361
+ const migrationEntries = Object.entries(macroUsage.migrationNeeded).sort(([, a], [, b]) => b - a);
362
+ if (migrationEntries.length > 0) {
363
+ reportLines.push("## ⚠️ Macros needing manual migration");
364
+ reportLines.push("");
365
+ reportLines.push("These ST macros appear in the preset but have no direct pi-forge equivalent:");
366
+ reportLines.push("");
367
+ for (const [name, count] of migrationEntries) {
368
+ const note = MACRO_NEEDS_MIGRATION[name] ?? "no mapping available";
369
+ reportLines.push(`- **\`{{${name}}}\`** (${count} occurrence${count > 1 ? "s" : ""}) — ${note}`);
370
+ }
371
+ reportLines.push("");
372
+
373
+ // Specific guidance for heavy variable usage
374
+ if (macroUsage.migrationNeeded.setvar || macroUsage.migrationNeeded.getvar) {
375
+ reportLines.push("### Variable system migration");
376
+ reportLines.push("");
377
+ reportLines.push("ST uses `setvar`/`getvar` for ephemeral state. pi-forge has a different variable model:");
378
+ reportLines.push("");
379
+ reportLines.push("- **Static variables** — set in the stack JSON's `variables` object (character names, fixed config)");
380
+ reportLines.push("- **Session variables** — persist across turns, set via `/preset vars set` or `forge_set_var` tool");
381
+ reportLines.push("- **Turn variables** — ephemeral, set via `{{setvar::name::value}}` macros in blocks");
382
+ reportLines.push("");
383
+ reportLines.push("Review ST `setvar`/`getvar` calls and migrate to the appropriate pi-forge scope.");
384
+ reportLines.push("");
385
+ }
386
+ }
387
+
388
+ // Native macros handled
389
+ const nativeDetected = [...macroUsage.detected].filter((m) => NATIVE_MACROS.has(m));
390
+ if (nativeDetected.length > 0) {
391
+ reportLines.push("## Handled macros");
392
+ reportLines.push("");
393
+ for (const name of nativeDetected) {
394
+ 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)`);
398
+ } else {
399
+ reportLines.push(`- \`{{${name}}}\` → handled natively by pi-forge`);
400
+ }
401
+ }
402
+ reportLines.push("");
403
+ }
404
+
405
+ // Item mapping table
406
+ reportLines.push(`## Item mapping`);
407
+ reportLines.push("");
408
+ reportLines.push(`| # | ST Identifier | ST Name | pi-forge ID | Role | Enabled | Kind |`);
409
+ reportLines.push(`|---|--------------|---------|-------------|------|---------|------|`);
410
+
411
+ for (const item of items) {
412
+ const stId = (item.source as Record<string, unknown> | undefined)?.previousId ?? "—";
413
+ const stName = item.name ?? "—";
414
+ const enabled = item.enabled !== false ? "✓" : "✗";
415
+ const kind = item.kind;
416
+ reportLines.push(`| ${item.id} | ${stId} | ${stName} | ${item.id} | ${item.role ?? "—"} | ${enabled} | ${kind} |`);
417
+ }
418
+
419
+ reportLines.push("");
420
+ reportLines.push("## General notes");
421
+ reportLines.push("");
422
+
423
+ if (usesLastUserMessage) {
424
+ reportLines.push("- Auto-detected `{{lastUserMessage}}` in post-history content. `chat-history` slot set with `includeLastUserMessage: false`.");
425
+ }
426
+
427
+ if (macroUsage.commentsStripped > 0 || macroUsage.trimStripped > 0) {
428
+ reportLines.push(`- ${macroUsage.commentsStripped} ST comment blocks and ${macroUsage.trimStripped} TRIM markers were stripped during import.`);
429
+ }
430
+
431
+ reportLines.push("- All items are assigned sequential numeric IDs. Original SillyTavern identifiers are preserved in `source.previousId`.");
432
+ 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.");
433
+ reportLines.push(`- The stack is set with \`autoActivate: false\`. Use \`/preset use ${stackId}\` to activate.`);
434
+ reportLines.push("");
435
+ reportLines.push("## Suggested next steps");
436
+ reportLines.push("");
437
+ reportLines.push("1. Set real values for auto-populated variables with `/preset vars set`.");
438
+ reportLines.push("2. Review items with migration-needed macros and rewrite for pi-forge's macro system.");
439
+ reportLines.push("3. Consider adding a `variables` slot for agent state visibility.");
440
+ reportLines.push(`4. Run \`/preset validate ${stackId}\` to check for issues.`);
441
+
442
+ return {
443
+ stack,
444
+ report: reportLines.join("\n"),
445
+ };
446
+ }
447
+
448
+ // ── Macro detection ────────────────────────────────────────────────────────
449
+
450
+ interface MacroDetection {
451
+ detected: Set<string>;
452
+ commentsStripped: number;
453
+ trimStripped: number;
454
+ migrationNeeded: Record<string, number>;
455
+ }
456
+
457
+ function detectMacros(conversionItems: StConversionItem[]): MacroDetection {
458
+ const detected = new Set<string>();
459
+ let commentsStripped = 0;
460
+ let trimStripped = 0;
461
+ const migrationNeeded: Record<string, number> = {};
462
+
463
+ // Regex to find {{macroName...}} — captures the first word/identifier
464
+ const macroFindRe = /\{\{(?!\/\/)([a-zA-Z_][a-zA-Z0-9_-]*)/gi;
465
+
466
+ for (const { def } of conversionItems) {
467
+ const content = def.content ?? "";
468
+ if (!content) continue;
469
+
470
+ // Count comments
471
+ const commentMatches = content.match(COMMENT_MACRO_RE);
472
+ if (commentMatches) commentsStripped += commentMatches.length;
473
+
474
+ // Count trims
475
+ const trimMatches = content.match(TRIM_MACRO_RE);
476
+ if (trimMatches) trimStripped += trimMatches.length;
477
+
478
+ // Work on a copy with comments stripped so we don't flag // as a macro
479
+ const cleaned = content.replace(COMMENT_MACRO_RE, "").replace(TRIM_MACRO_RE, "");
480
+
481
+ let match: RegExpExecArray | null;
482
+ macroFindRe.lastIndex = 0;
483
+ while ((match = macroFindRe.exec(cleaned)) !== null) {
484
+ const name = match[1].toLowerCase();
485
+ detected.add(name);
486
+
487
+ if (MACRO_NEEDS_MIGRATION[name]) {
488
+ migrationNeeded[name] = (migrationNeeded[name] ?? 0) + 1;
489
+ }
490
+ }
491
+ }
492
+
493
+ return { detected, commentsStripped, trimStripped, migrationNeeded };
494
+ }
495
+
496
+ // ── Content cleaning ──────────────────────────────────────────────────────
497
+
498
+ function cleanContent(content: string): string {
499
+ // Collapse multiple blank lines (max 1 blank line between paragraphs)
500
+ return content.replace(/\n{3,}/g, "\n\n").trim();
501
+ }
502
+
503
+ // ── Helpers ────────────────────────────────────────────────────────────────
504
+
505
+ function selectCharacterEntry(
506
+ order: StPromptOrderEntry[],
507
+ preferredId?: number,
508
+ ): StPromptOrderEntry | undefined {
509
+ if (order.length === 0) return undefined;
510
+
511
+ if (preferredId !== undefined) {
512
+ return order.find((entry) => entry.character_id === preferredId);
513
+ }
514
+
515
+ if (order.length === 1) return order[0];
516
+
517
+ // Multiple entries, no preference — can't auto-select
518
+ return undefined;
519
+ }
520
+
521
+ function normalizeRole(role: string | undefined): "system" | "user" | "assistant" | undefined {
522
+ if (!role) return undefined;
523
+ const lower = role.trim().toLowerCase();
524
+ if (lower === "system") return "system";
525
+ if (lower === "user") return "user";
526
+ if (lower === "assistant") return "assistant";
527
+ return undefined;
528
+ }
package/src/types.ts ADDED
@@ -0,0 +1,139 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { BuildSystemPromptOptions, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ export type PromptStackMode = "replace" | "append" | "prepend";
5
+
6
+ export type PromptStackRole = "system" | "user" | "assistant" | "custom";
7
+
8
+ export type PromptStackSlot =
9
+ | "chat-history"
10
+ | "tools"
11
+ | "tool-guidelines"
12
+ | "skills"
13
+ | "project-context"
14
+ | "append-system-prompt"
15
+ | "date"
16
+ | "cwd"
17
+ | "date-cwd"
18
+ | "active-model"
19
+ | "pi-docs"
20
+ | "variables";
21
+
22
+ export interface PromptStackDefaults {
23
+ syntheticMessagesVisible?: boolean;
24
+ unresolvedMacroPolicy?: "warn" | "keep" | "error";
25
+ }
26
+
27
+ export interface PromptStackContextOptions {
28
+ allowDuplicateChatHistory?: boolean;
29
+ }
30
+
31
+ export interface PromptStackBaseItem {
32
+ kind: "block" | "slot";
33
+ id: string;
34
+ name?: string;
35
+ enabled?: boolean;
36
+ role?: PromptStackRole;
37
+ tags?: string[];
38
+ source?: Record<string, unknown>;
39
+ }
40
+
41
+ export interface PromptStackBlockItem extends PromptStackBaseItem {
42
+ kind: "block";
43
+ content: string;
44
+ }
45
+
46
+ export interface VariablesSlotOptions {
47
+ /** Include static stack variables. Default: true. */
48
+ includeStatic?: boolean;
49
+ /** Include session variables. Default: true. */
50
+ includeSession?: boolean;
51
+ /** Include turn variables. Default: true. */
52
+ includeTurn?: boolean;
53
+ }
54
+
55
+ export interface PromptStackSlotOptions {
56
+ /** For chat-history: include the latest user message in the expanded history. Default: true. */
57
+ includeLastUserMessage?: boolean;
58
+ /** For variables: control which variable scopes are included. */
59
+ includeStatic?: boolean;
60
+ includeSession?: boolean;
61
+ includeTurn?: boolean;
62
+ [key: string]: unknown;
63
+ }
64
+
65
+ export interface PromptStackSlotItem extends PromptStackBaseItem {
66
+ kind: "slot";
67
+ slot: PromptStackSlot | string;
68
+ options?: PromptStackSlotOptions;
69
+ }
70
+
71
+ export type PromptStackItem = PromptStackBlockItem | PromptStackSlotItem;
72
+
73
+ export interface PromptStack {
74
+ schemaVersion: 1;
75
+ type?: "pi-forge.prompt-stack";
76
+ id: string;
77
+ name?: string;
78
+ description?: string;
79
+ autoActivate?: boolean;
80
+ mode?: PromptStackMode;
81
+ defaults?: PromptStackDefaults;
82
+ context?: PromptStackContextOptions;
83
+ variables?: Record<string, string>;
84
+ items: PromptStackItem[];
85
+ import?: Record<string, unknown>;
86
+ }
87
+
88
+ export interface LoadedPromptStack {
89
+ stack: PromptStack;
90
+ filePath: string;
91
+ diagnostics: PromptStackDiagnostic[];
92
+ }
93
+
94
+ export type PromptStackDiagnosticLevel = "error" | "warning" | "info";
95
+
96
+ export interface PromptStackDiagnostic {
97
+ level: PromptStackDiagnosticLevel;
98
+ message: string;
99
+ itemId?: string;
100
+ }
101
+
102
+ export interface PromptVariableStore {
103
+ turn: Record<string, string>;
104
+ session: Record<string, string>;
105
+ sessionDirty?: boolean;
106
+ }
107
+
108
+ export interface PromptRuntime {
109
+ options: BuildSystemPromptOptions;
110
+ ctx?: ExtensionContext;
111
+ latestUserMessage?: string;
112
+ now: Date;
113
+ variables?: PromptVariableStore;
114
+ }
115
+
116
+ export interface CompileSystemPromptResult {
117
+ systemPrompt: string;
118
+ diagnostics: PromptStackDiagnostic[];
119
+ }
120
+
121
+ export interface CompileMessagesResult {
122
+ messages: AgentMessage[];
123
+ diagnostics: PromptStackDiagnostic[];
124
+ }
125
+
126
+ export const SUPPORTED_SLOTS = new Set<PromptStackSlot>([
127
+ "chat-history",
128
+ "tools",
129
+ "tool-guidelines",
130
+ "skills",
131
+ "project-context",
132
+ "append-system-prompt",
133
+ "date",
134
+ "cwd",
135
+ "date-cwd",
136
+ "active-model",
137
+ "pi-docs",
138
+ "variables",
139
+ ]);