@zihanw/pi-forge 0.3.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 (150) hide show
  1. package/README.md +80 -2
  2. package/README.zh-CN.md +65 -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/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/image-reader-prompt-stack.json +124 -0
  127. package/package.json +28 -2
  128. package/src/compiler.ts +2 -472
  129. package/src/extension-registry.ts +33 -0
  130. package/src/index.ts +192 -652
  131. package/src/lifecycle.ts +171 -0
  132. package/src/macro-engine.ts +358 -0
  133. package/src/payload-command.ts +138 -0
  134. package/src/preset-command.ts +271 -0
  135. package/src/preview.ts +226 -0
  136. package/src/render-helpers.ts +169 -0
  137. package/src/runtime-state.ts +36 -0
  138. package/src/sillytavern-importer/items.ts +98 -0
  139. package/src/sillytavern-importer/macros.ts +159 -0
  140. package/src/sillytavern-importer/prompt-order.ts +54 -0
  141. package/src/sillytavern-importer/regex.ts +270 -0
  142. package/src/sillytavern-importer/report.ts +202 -0
  143. package/src/sillytavern-importer/types.ts +120 -0
  144. package/src/sillytavern-importer.ts +55 -865
  145. package/src/slot-renderers.ts +414 -0
  146. package/src/types.ts +3 -14
  147. package/src/web-editor/page.ts +516 -30
  148. package/src/web-editor/server.ts +5 -0
  149. package/src/web-editor/types.ts +14 -0
  150. package/src/web-host.ts +3 -0
package/src/index.ts CHANGED
@@ -1,31 +1,47 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { buildSessionContext, type BuildSystemPromptOptions, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import {
5
- compileMessages,
6
- compileSystemPrompt,
7
- createPromptVariableStore,
8
- agentMessageToPreviewText,
9
- getLatestUserMessage,
10
- markSessionVariablesClean,
11
- resetTurnVariables,
12
- } from "./compiler.ts";
13
- import { chooseDefaultStack, isDisabledPromptStackId, loadPromptStacks, promptStackPath, promptStackReadDirs } from "./loader.ts";
14
- import { createProviderPayloadCapture, estimatePayloadTokens } from "./payload-capture.ts";
1
+ import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { registerLifecycleHandlers } from "./lifecycle.ts";
3
+ import { chooseDefaultStack, isDisabledPromptStackId, loadPromptStacks } from "./loader.ts";
4
+ import { registerPayloadCommands, registerPayloadRequestHandler, armPayloadIntercept, clearPayloadCapture, webPayloadSnapshot } from "./payload-command.ts";
15
5
  import { applyResourcePolicy, hasResourcePolicy } from "./policy.ts";
16
- import { applyFinalizeRegexRulesToMessage } from "./regex.ts";
17
- import { importSillyTavernPreset } from "./sillytavern-importer.ts";
18
- import { migrateLegacyPromptStacks, renderMigrationReport } from "./stack-migration.ts";
19
- import type { CompileMessageSource, LoadedPromptStack, PromptStackDiagnostic, PromptVariableValue, PromptVariableStore } from "./types.ts";
6
+ import { buildPreview, showText } from "./preview.ts";
7
+ import { registerPresetCommand, selectedActiveId as selectedActiveIdForState } from "./preset-command.ts";
8
+ import { createRuntimeState, STATE_ENTRY_TYPE } from "./runtime-state.ts";
9
+ import type { PromptStack, PromptStackDiagnostic } from "./types.ts";
20
10
  import { createWebEditorHost, loadWebEditorSettings, type WebHostRuntime } from "./web-host.ts";
21
- import { startWebEditorServer, type WebEditorPayloadCapture, type WebEditorPayloadSnapshot, type WebEditorPreview, type WebEditorPreviewSection, type WebEditorServer } from "./web-editor/index.ts";
11
+ import { startWebEditorServer, type WebEditorPolicyResource, type WebEditorPolicyResources, type WebEditorServer } from "./web-editor/index.ts";
12
+
13
+ export {
14
+ getRegisteredMacros,
15
+ registerMacro,
16
+ type PromptMacroDefinition,
17
+ type PromptMacroRenderContext,
18
+ type PromptMacroRenderer,
19
+ } from "./macro-engine.ts";
20
+ export {
21
+ getRegisteredSlots,
22
+ registerSlot,
23
+ type PromptSlotDefinition,
24
+ type PromptSlotRenderContext,
25
+ type PromptSlotRenderer,
26
+ } from "./slot-renderers.ts";
27
+ export {
28
+ type PromptExtensionArgumentDefinition,
29
+ type PromptExtensionOptionDefinition,
30
+ type PromptExtensionOptionsSchema,
31
+ type PromptExtensionOptionType,
32
+ type PromptRegistryEntry,
33
+ } from "./extension-registry.ts";
34
+ export {
35
+ createVariableAccess,
36
+ promptRenderHelpers,
37
+ type PromptRenderHelpers,
38
+ type PromptVariableAccess,
39
+ type PromptVariableScope,
40
+ type PromptWritableVariableScope,
41
+ } from "./render-helpers.ts";
22
42
 
23
- const STATE_ENTRY_TYPE = "pi-forge-prompt-stack-state";
24
- const VARIABLE_ENTRY_TYPE = "pi-forge-variable-state";
25
43
  const WEB_EDITOR_GLOBAL_KEY = "__piForgeWebEditor";
26
44
 
27
- type PayloadDisplayTarget = "editor" | "web";
28
-
29
45
  interface SharedWebEditorState {
30
46
  server?: WebEditorServer;
31
47
  cwd?: string;
@@ -48,52 +64,38 @@ function getSharedWebEditorRegistry(): SharedWebEditorRegistry {
48
64
 
49
65
  export default function piForge(pi: ExtensionAPI) {
50
66
  const sharedWebEditors = getSharedWebEditorRegistry();
51
- let stacks: LoadedPromptStack[] = [];
52
- let active: LoadedPromptStack | undefined;
53
- let currentSystemPromptOptions: BuildSystemPromptOptions | undefined;
54
- let currentLatestUserMessage: string | undefined;
55
- let currentVariableStore: PromptVariableStore | undefined;
56
- let contextRewritePending = false;
57
- let sessionVariables: Record<string, PromptVariableValue> = {};
58
- let lastPersistedActiveId: string | undefined;
59
- let interceptNextProviderPayload = false;
60
- let interceptPayloadSavePath: string | undefined;
61
- let interceptPayloadDisplayTarget: PayloadDisplayTarget = "editor";
62
- let payloadCaptureArmedAt: string | undefined;
63
- let latestProviderPayloadCapture: WebEditorPayloadCapture | undefined;
64
- let latestCompileDiagnostics: PromptStackDiagnostic[] = [];
67
+ const state = createRuntimeState();
65
68
  let webEditor: WebEditorServer | undefined;
66
69
  let webEditorCwd: string | undefined;
67
70
  let webEditorPreferredPort: number | undefined;
68
71
  let toolPolicyBaseline: string[] | undefined;
69
72
 
70
73
  function activeId(): string | undefined {
71
- return active?.stack.id;
74
+ return state.active?.stack.id;
72
75
  }
73
76
 
74
77
  function selectedActiveId(): string | undefined {
75
- if (active) return active.stack.id;
76
- return isDisabledPromptStackId(lastPersistedActiveId) ? "none" : undefined;
78
+ return selectedActiveIdForState(state);
77
79
  }
78
80
 
79
81
  function persistActiveSelection(id: string): void {
80
- if (id === lastPersistedActiveId) return;
82
+ if (id === state.lastPersistedActiveId) return;
81
83
  pi.appendEntry(STATE_ENTRY_TYPE, { activeStackId: id });
82
- lastPersistedActiveId = id;
84
+ state.lastPersistedActiveId = id;
83
85
  }
84
86
 
85
87
  function setActive(id: string | undefined, ctx?: ExtensionContext): boolean {
86
88
  if (!id || isDisabledPromptStackId(id)) {
87
- active = undefined;
89
+ state.active = undefined;
88
90
  if (id) persistActiveSelection("none");
89
91
  if (ctx) updateStatus(ctx);
90
92
  syncActiveToolPolicy(ctx);
91
93
  return true;
92
94
  }
93
95
 
94
- const found = stacks.find((candidate) => candidate.stack.id === id);
96
+ const found = state.stacks.find((candidate) => candidate.stack.id === id);
95
97
  if (!found) return false;
96
- active = found;
98
+ state.active = found;
97
99
  persistActiveSelection(found.stack.id);
98
100
  if (ctx) updateStatus(ctx);
99
101
  syncActiveToolPolicy(ctx);
@@ -102,32 +104,32 @@ export default function piForge(pi: ExtensionAPI) {
102
104
 
103
105
  function reloadStacks(ctx: ExtensionContext, preferredId?: string): void {
104
106
  if (!ctx.isProjectTrusted()) {
105
- stacks = [];
106
- active = undefined;
107
+ state.stacks = [];
108
+ state.active = undefined;
107
109
  syncActiveToolPolicy(ctx);
108
110
  ctx.ui.notify("pi-forge: project is not trusted; prompt stacks are disabled.", "warning");
109
111
  updateStatus(ctx);
110
112
  return;
111
113
  }
112
114
 
113
- stacks = loadPromptStacks(ctx.cwd);
114
- active = chooseDefaultStack(stacks, preferredId);
115
+ state.stacks = loadPromptStacks(ctx.cwd);
116
+ state.active = chooseDefaultStack(state.stacks, preferredId);
115
117
  updateStatus(ctx);
116
118
  syncActiveToolPolicy(ctx);
117
119
  }
118
120
 
119
121
  function updateStatus(ctx: ExtensionContext): void {
120
- if (active) {
121
- ctx.ui.setStatus("pi-forge", ctx.ui.theme.fg("accent", "stack:" + active.stack.id));
122
+ if (state.active) {
123
+ ctx.ui.setStatus("pi-forge", ctx.ui.theme.fg("accent", "stack:" + state.active.stack.id));
122
124
  } else {
123
125
  ctx.ui.setStatus("pi-forge", undefined);
124
- latestCompileDiagnostics = [];
126
+ state.latestCompileDiagnostics = [];
125
127
  ctx.ui.setStatus("pi-forge-diagnostics", undefined);
126
128
  }
127
129
  }
128
130
 
129
131
  function syncActiveToolPolicy(ctx?: ExtensionContext): void {
130
- const policy = active?.stack.tools;
132
+ const policy = state.active?.stack.tools;
131
133
  if (!hasResourcePolicy(policy)) {
132
134
  restoreToolPolicy(ctx);
133
135
  return;
@@ -158,15 +160,15 @@ export default function piForge(pi: ExtensionAPI) {
158
160
  }
159
161
 
160
162
  function notifyActivePreset(ctx: ExtensionContext, detail: string): void {
161
- if (!active) return;
162
- const errorCount = active.diagnostics.filter((d) => d.level === "error").length;
163
- const warningCount = active.diagnostics.filter((d) => d.level === "warning").length;
163
+ if (!state.active) return;
164
+ const errorCount = state.active.diagnostics.filter((d) => d.level === "error").length;
165
+ const warningCount = state.active.diagnostics.filter((d) => d.level === "warning").length;
164
166
  const suffix = errorCount || warningCount ? " (" + errorCount + " errors, " + warningCount + " warnings)" : "";
165
- ctx.ui.notify("pi-forge: active preset " + active.stack.id + suffix + " (" + detail + ")", errorCount ? "error" : "info");
167
+ ctx.ui.notify("pi-forge: active preset " + state.active.stack.id + suffix + " (" + detail + ")", errorCount ? "error" : "info");
166
168
  }
167
169
 
168
170
  function recordCompileDiagnostics(ctx: ExtensionContext, diagnostics: PromptStackDiagnostic[]): void {
169
- latestCompileDiagnostics = diagnostics;
171
+ state.latestCompileDiagnostics = diagnostics;
170
172
  const errors = diagnostics.filter((d) => d.level === "error").length;
171
173
  const warnings = diagnostics.filter((d) => d.level === "warning").length;
172
174
  if (errors || warnings) {
@@ -178,25 +180,133 @@ export default function piForge(pi: ExtensionAPI) {
178
180
 
179
181
  function webHostRuntime(ctx: ExtensionCommandContext): WebHostRuntime {
180
182
  return {
181
- getStacks: () => stacks,
182
- getActive: () => active,
183
+ getStacks: () => state.stacks,
184
+ getActive: () => state.active,
183
185
  getActiveId: activeId,
184
186
  getSelectedActiveId: selectedActiveId,
185
187
  setActive: (id) => setActive(id, ctx),
186
188
  reloadStacks: (preferredId) => reloadStacks(ctx, preferredId),
187
- buildPreview: (target) => buildPreview(ctx, target),
188
- getPayload: () => ({ ok: true, ...webPayloadSnapshot() }),
189
+ buildPreview: (target) => buildPreview(ctx, target, state.sessionVariables, previewOptionsForStack(ctx, target.stack)),
190
+ getPolicyResources: () => getPolicyResources(ctx),
191
+ getPayload: () => ({ ok: true, ...webPayloadSnapshot(state) }),
189
192
  armPayload: (savePath) => {
190
- armPayloadIntercept(ctx, savePath, "web");
191
- return { ok: true, ...webPayloadSnapshot() };
193
+ armPayloadIntercept(state, ctx, savePath, "web");
194
+ return { ok: true, ...webPayloadSnapshot(state) };
192
195
  },
193
196
  clearPayload: () => {
194
- clearPayloadCapture(ctx);
195
- return { ok: true, ...webPayloadSnapshot() };
197
+ clearPayloadCapture(state, ctx);
198
+ return { ok: true, ...webPayloadSnapshot(state) };
196
199
  },
197
200
  };
198
201
  }
199
202
 
203
+ function getPolicyResources(ctx: ExtensionCommandContext): WebEditorPolicyResources {
204
+ const options = ctx.getSystemPromptOptions();
205
+ const activeTools = new Set(pi.getActiveTools());
206
+ const snippets = options.toolSnippets ?? {};
207
+ const tools = pi.getAllTools()
208
+ .map((tool) => normalizeToolResource(tool, activeTools, snippets))
209
+ .filter(hasPolicyResourceName)
210
+ .sort(comparePolicyResource);
211
+ const skills = (options.skills ?? [])
212
+ .map(normalizeSkillResource)
213
+ .filter(hasPolicyResourceName)
214
+ .sort(comparePolicyResource);
215
+ return { tools, skills };
216
+ }
217
+
218
+ function previewOptionsForStack(ctx: ExtensionCommandContext, stack: PromptStack): BuildSystemPromptOptions {
219
+ const base = ctx.getSystemPromptOptions();
220
+ const baseSelectedTools = Array.isArray(base.selectedTools) ? base.selectedTools : pi.getActiveTools();
221
+ const policyActive = hasResourcePolicy(stack.tools);
222
+ const baselineTools = policyActive ? (toolPolicyBaseline ?? pi.getActiveTools()) : baseSelectedTools;
223
+ const selectedTools = policyActive
224
+ ? applyResourcePolicy(filterKnownTools(baselineTools), stack.tools)
225
+ : baseSelectedTools;
226
+ const selectedToolSet = new Set(selectedTools);
227
+ const toolSnippets = filterToolSnippets(base.toolSnippets ?? {}, selectedToolSet);
228
+ const toolInfos = pi.getAllTools();
229
+ for (const tool of toolInfos) {
230
+ const name = stringValue((tool as { name?: unknown }).name);
231
+ if (!name || !selectedToolSet.has(name) || toolSnippets[name]) continue;
232
+ const snippet = stringValue((tool as { promptSnippet?: unknown }).promptSnippet);
233
+ if (snippet) toolSnippets[name] = snippet;
234
+ }
235
+
236
+ const mappedGuidelines = toolInfos
237
+ .filter((tool) => {
238
+ const name = stringValue((tool as { name?: unknown }).name);
239
+ return !!name && selectedToolSet.has(name);
240
+ })
241
+ .flatMap((tool) => stringArrayValue((tool as { promptGuidelines?: unknown }).promptGuidelines));
242
+ const promptGuidelines = policyActive && !sameStringSet(baseSelectedTools, selectedTools)
243
+ ? mappedGuidelines
244
+ : (base.promptGuidelines ?? mappedGuidelines);
245
+
246
+ return { ...base, selectedTools, toolSnippets, promptGuidelines };
247
+ }
248
+
249
+ function filterToolSnippets(snippets: Record<string, string | undefined>, selectedTools: Set<string>): Record<string, string> {
250
+ const filtered: Record<string, string> = {};
251
+ for (const [name, snippet] of Object.entries(snippets)) {
252
+ if (selectedTools.has(name) && snippet) filtered[name] = snippet;
253
+ }
254
+ return filtered;
255
+ }
256
+
257
+ function normalizeToolResource(
258
+ tool: { name?: unknown; description?: unknown; promptSnippet?: unknown; sourceInfo?: unknown },
259
+ activeTools: Set<string>,
260
+ snippets: Record<string, string | undefined>,
261
+ ): WebEditorPolicyResource {
262
+ const name = String(tool.name ?? "");
263
+ return {
264
+ name,
265
+ description: stringValue(tool.description) ?? stringValue(tool.promptSnippet) ?? snippets[name],
266
+ source: sourceLabel(tool.sourceInfo),
267
+ active: activeTools.has(name),
268
+ };
269
+ }
270
+
271
+ function normalizeSkillResource(skill: { name?: unknown; description?: unknown; filePath?: unknown; disableModelInvocation?: unknown }): WebEditorPolicyResource {
272
+ return {
273
+ name: String(skill.name ?? ""),
274
+ description: stringValue(skill.description),
275
+ source: stringValue(skill.filePath),
276
+ hidden: skill.disableModelInvocation === true,
277
+ };
278
+ }
279
+
280
+ function stringValue(value: unknown): string | undefined {
281
+ return typeof value === "string" && value.trim() ? value : undefined;
282
+ }
283
+
284
+ function stringArrayValue(value: unknown): string[] {
285
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && !!item.trim()) : [];
286
+ }
287
+
288
+ function sourceLabel(value: unknown): string | undefined {
289
+ if (!value || typeof value !== "object") return undefined;
290
+ const source = stringValue((value as { source?: unknown }).source);
291
+ const path = stringValue((value as { path?: unknown }).path);
292
+ if (source && path) return `${source}: ${path}`;
293
+ return source ?? path;
294
+ }
295
+
296
+ function comparePolicyResource(a: WebEditorPolicyResource, b: WebEditorPolicyResource): number {
297
+ return a.name.localeCompare(b.name);
298
+ }
299
+
300
+ function hasPolicyResourceName(resource: WebEditorPolicyResource): boolean {
301
+ return !!resource.name.trim();
302
+ }
303
+
304
+ function sameStringSet(a: string[], b: string[]): boolean {
305
+ if (a.length !== b.length) return false;
306
+ const bSet = new Set(b);
307
+ return a.every((value) => bSet.has(value));
308
+ }
309
+
200
310
  function sharedWebEditorForCwd(cwd: string): SharedWebEditorState {
201
311
  sharedWebEditors.byCwd[cwd] ??= {};
202
312
  return sharedWebEditors.byCwd[cwd];
@@ -299,592 +409,22 @@ export default function piForge(pi: ExtensionAPI) {
299
409
  ctx.ui.notify("pi-forge: stack editor stopped.", "info");
300
410
  }
301
411
 
302
- function getCurrentBranchEntries(ctx: ExtensionContext): unknown[] {
303
- const leafId = ctx.sessionManager.getLeafId();
304
- if (leafId === null) return [];
305
- const sessionManager = ctx.sessionManager as {
306
- getBranch?: (fromId?: string) => unknown[];
307
- getEntries: () => unknown[];
308
- };
309
- return sessionManager.getBranch ? sessionManager.getBranch(leafId ?? undefined) : sessionManager.getEntries();
310
- }
311
-
312
- function getRestoredActiveId(ctx: ExtensionContext): string | undefined {
313
- const entries = getCurrentBranchEntries(ctx);
314
- for (let i = entries.length - 1; i >= 0; i--) {
315
- const entry = entries[i] as { type?: string; customType?: string; data?: { activeStackId?: unknown } };
316
- if (entry.type === "custom" && entry.customType === STATE_ENTRY_TYPE) {
317
- return typeof entry.data?.activeStackId === "string" ? entry.data.activeStackId : undefined;
318
- }
319
- }
320
- return undefined;
321
- }
322
-
323
- function getRestoredVariables(ctx: ExtensionContext): Record<string, PromptVariableValue> {
324
- const entries = getCurrentBranchEntries(ctx);
325
- for (let i = entries.length - 1; i >= 0; i--) {
326
- const entry = entries[i] as { type?: string; customType?: string; data?: { variables?: unknown } };
327
- if (entry.type !== "custom" || entry.customType !== VARIABLE_ENTRY_TYPE) continue;
328
- if (!entry.data || typeof entry.data.variables !== "object" || Array.isArray(entry.data.variables)) return {};
329
- return normalizeVariableRecord(entry.data.variables as Record<string, unknown>);
330
- }
331
- return {};
332
- }
333
-
334
- function persistVariablesIfDirty(store: PromptVariableStore | undefined): void {
335
- if (!store?.sessionDirty) return;
336
- sessionVariables = { ...store.session };
337
- pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: sessionVariables });
338
- markSessionVariablesClean(store);
339
- }
340
-
341
- function normalizeVariableRecord(value: Record<string, unknown>): Record<string, PromptVariableValue> {
342
- const result: Record<string, PromptVariableValue> = {};
343
- for (const [key, raw] of Object.entries(value)) {
344
- if (isPromptVariableValue(raw)) result[key] = raw;
345
- }
346
- return result;
347
- }
348
-
349
- function isPromptVariableValue(value: unknown): value is PromptVariableValue {
350
- if (value === null) return true;
351
- const type = typeof value;
352
- if (type === "string" || type === "boolean") return true;
353
- if (type === "number") return Number.isFinite(value);
354
- if (Array.isArray(value)) return value.every(isPromptVariableValue);
355
- if (!value || typeof value !== "object") return false;
356
- return Object.values(value as Record<string, unknown>).every(isPromptVariableValue);
357
- }
358
-
359
- function restoreBranchScopedRuntime(ctx: ExtensionContext): void {
360
- sessionVariables = getRestoredVariables(ctx);
361
- currentVariableStore = undefined;
362
- const restoredActiveId = getRestoredActiveId(ctx);
363
- lastPersistedActiveId = restoredActiveId;
364
- reloadStacks(ctx, restoredActiveId);
365
- }
366
-
367
- pi.on("session_start", async (event, ctx) => {
368
- restoreBranchScopedRuntime(ctx);
369
- refreshWebEditorHost(ctx);
370
- notifyActivePreset(ctx, "after session " + event.reason);
371
- });
372
-
373
- pi.on("session_tree", async (_event, ctx) => {
374
- restoreBranchScopedRuntime(ctx);
375
- refreshWebEditorHost(ctx);
376
- notifyActivePreset(ctx, "after tree navigation");
377
- });
378
-
379
- pi.on("session_compact", async (_event, ctx) => {
380
- restoreBranchScopedRuntime(ctx);
381
- refreshWebEditorHost(ctx);
382
- notifyActivePreset(ctx, "after compaction");
383
- });
384
-
385
- pi.on("turn_start", async () => {
386
- syncActiveToolPolicy();
387
- const id = activeId();
388
- if (id) persistActiveSelection(id);
389
- });
390
-
391
- pi.on("before_agent_start", async (event, ctx) => {
392
- currentSystemPromptOptions = event.systemPromptOptions;
393
- currentLatestUserMessage = event.prompt;
394
- currentVariableStore = createPromptVariableStore(sessionVariables);
395
- resetTurnVariables(currentVariableStore);
396
- contextRewritePending = true;
397
-
398
- if (!active) return;
399
-
400
- const result = compileSystemPrompt(
401
- active.stack,
402
- { options: event.systemPromptOptions, ctx, latestUserMessage: event.prompt, now: new Date(), variables: currentVariableStore },
403
- event.systemPrompt,
404
- );
405
- recordCompileDiagnostics(ctx, result.diagnostics);
406
- persistVariablesIfDirty(currentVariableStore);
407
-
408
- return { systemPrompt: result.systemPrompt };
409
- });
410
-
411
- pi.on("context", async (event, ctx) => {
412
- if (!active || !currentSystemPromptOptions || !contextRewritePending) return;
413
-
414
- // Rewrite the message layout only for the first provider request of a user-submitted prompt.
415
- // Tool-result follow-up turns must receive Pi's natural context; otherwise post-history
416
- // prompt blocks such as COT / {{lastUserMessage}} are re-appended after every tool call
417
- // and the model restarts its planning instead of continuing from the tool result.
418
- contextRewritePending = false;
419
-
420
- if (!currentVariableStore) currentVariableStore = createPromptVariableStore(sessionVariables);
421
- const latestUserMessage = getLatestUserMessage(event.messages) ?? currentLatestUserMessage;
422
- const result = compileMessages(
423
- active.stack,
424
- { options: currentSystemPromptOptions, ctx, latestUserMessage, now: new Date(), variables: currentVariableStore },
425
- event.messages,
426
- );
427
- recordCompileDiagnostics(ctx, [...latestCompileDiagnostics, ...result.diagnostics]);
428
- persistVariablesIfDirty(currentVariableStore);
429
- return { messages: result.messages };
430
- });
431
-
432
- pi.on("message_end", async (event, ctx) => {
433
- if (!active) return;
434
- const diagnostics: PromptStackDiagnostic[] = [];
435
- const message = applyFinalizeRegexRulesToMessage(active.stack, event.message, diagnostics);
436
- if (diagnostics.length > 0) recordCompileDiagnostics(ctx, [...latestCompileDiagnostics, ...diagnostics]);
437
- if (!message) return;
438
- return { message };
439
- });
440
-
441
- pi.on("agent_end", async () => {
442
- persistVariablesIfDirty(currentVariableStore);
443
- currentSystemPromptOptions = undefined;
444
- currentLatestUserMessage = undefined;
445
- currentVariableStore = undefined;
446
- contextRewritePending = false;
447
- });
448
-
449
- pi.on("before_provider_request", async (event, ctx) => {
450
- if (!interceptNextProviderPayload) return;
451
- const savePath = interceptPayloadSavePath;
452
- const displayTarget = interceptPayloadDisplayTarget;
453
- interceptNextProviderPayload = false;
454
- interceptPayloadSavePath = undefined;
455
- interceptPayloadDisplayTarget = "editor";
456
- payloadCaptureArmedAt = undefined;
457
- ctx.ui.setStatus("pi-forge-intercept", undefined);
458
-
459
- const capture = captureProviderPayload(event.payload, savePath);
460
- if (savePath) {
461
- if (!ctx.isProjectTrusted()) {
462
- ctx.ui.notify("pi-forge: project is not trusted; refusing to save provider payload.", "warning");
463
- } else {
464
- const resolvedPath = savePath.startsWith("/") ? savePath : join(ctx.cwd, savePath);
465
- mkdirSync(dirname(resolvedPath), { recursive: true });
466
- writeFileSync(resolvedPath, capture.text, "utf8");
467
- ctx.ui.notify(`pi-forge: provider payload saved to ${resolvedPath} (${capture.chars} chars, ~${capture.approxTokens} tokens)`, "info");
468
- }
469
- }
470
-
471
- if (displayTarget === "web") {
472
- ctx.ui.notify(`pi-forge: provider payload captured for web editor (${capture.chars} chars, ~${capture.approxTokens} tokens).`, "info");
473
- return;
474
- }
475
-
476
- if (ctx.hasUI) {
477
- await ctx.ui.editor(`pi-forge: provider payload (${capture.chars} chars, ~${capture.approxTokens} tokens)`, capture.text);
478
- return;
479
- }
480
-
481
- console.log(capture.text);
482
- });
483
-
484
- function armPayloadIntercept(ctx: ExtensionCommandContext, savePath?: string, displayTarget: PayloadDisplayTarget = "editor"): void {
485
- interceptNextProviderPayload = true;
486
- interceptPayloadSavePath = savePath;
487
- interceptPayloadDisplayTarget = displayTarget;
488
- payloadCaptureArmedAt = new Date().toISOString();
489
- latestProviderPayloadCapture = undefined;
490
- ctx.ui.setStatus("pi-forge-intercept", ctx.ui.theme.fg("warning", savePath ? "payload:armed+save" : "payload:armed"));
491
- if (displayTarget === "web") {
492
- ctx.ui.notify(savePath ? `pi-forge: next provider payload will be captured in the web editor and saved to ${savePath}.` : "pi-forge: next provider payload will be captured in the web editor.", "info");
493
- return;
494
- }
495
- ctx.ui.notify(savePath ? `pi-forge: next provider payload will be displayed and saved to ${savePath}.` : "pi-forge: next provider payload will be displayed before sending.", "info");
496
- }
497
-
498
- function clearPayloadCapture(ctx: ExtensionCommandContext): void {
499
- interceptNextProviderPayload = false;
500
- interceptPayloadSavePath = undefined;
501
- interceptPayloadDisplayTarget = "editor";
502
- payloadCaptureArmedAt = undefined;
503
- latestProviderPayloadCapture = undefined;
504
- ctx.ui.setStatus("pi-forge-intercept", undefined);
505
- }
506
-
507
- pi.registerCommand("intercept", {
508
- description: "Display the next provider payload before it is sent",
509
- handler: async (_args, ctx) => {
510
- armPayloadIntercept(ctx);
511
- },
512
- });
513
-
514
- pi.registerCommand("payload", {
515
- description: "Inspect or save provider payloads: /payload next [save=<path>]",
516
- getArgumentCompletions: (prefix) => {
517
- const parts = prefix.trimStart().split(/\s+/);
518
- if (parts.length <= 1 && !prefix.endsWith(" ")) {
519
- return ["next"].filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
520
- }
521
- if (parts[0] === "next" && parts.length <= 2) {
522
- const suggestion = "save=.pi/forge/payloads/last.json";
523
- return suggestion.startsWith(parts[1] ?? "") ? [{ value: `next ${suggestion}`, label: suggestion }] : null;
524
- }
525
- return null;
526
- },
527
- handler: async (args, ctx) => {
528
- const [command = "next", ...rest] = args.trim() ? args.trim().split(/\s+/) : ["next"];
529
- if (command !== "next") {
530
- ctx.ui.notify(`Unknown /payload subcommand: ${command}`, "warning");
531
- return;
532
- }
533
- const saveArg = rest.find((arg) => arg.startsWith("save="));
534
- const savePath = saveArg?.slice("save=".length).trim() || undefined;
535
- armPayloadIntercept(ctx, savePath);
536
- },
412
+ registerLifecycleHandlers(pi, state, {
413
+ reloadStacks,
414
+ refreshWebEditorHost,
415
+ notifyActivePreset,
416
+ syncActiveToolPolicy,
417
+ activeId,
418
+ persistActiveSelection,
419
+ recordCompileDiagnostics,
537
420
  });
538
-
539
- pi.registerCommand("preset", {
540
- description: "Manage pi-forge prompt stacks: list, use, preview, validate, reload, ui",
541
- getArgumentCompletions: (prefix) => {
542
- const parts = prefix.trimStart().split(/\s+/);
543
- if (parts.length <= 1 && !prefix.endsWith(" ")) {
544
- const commands = ["list", "use", "preview", "validate", "diagnostics", "reload", "status", "import-silly", "migrate-stacks", "ui"];
545
- return commands.filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
546
- }
547
- const first = parts[0];
548
- if (["use", "preview", "validate"].includes(first)) {
549
- const fragment = parts[1] ?? "";
550
- const ids = ["none", ...stacks.map((loaded) => loaded.stack.id)];
551
- return ids.filter((id) => id.startsWith(fragment)).map((id) => ({ value: `${first} ${id}`, label: id }));
552
- }
553
- if (first === "ui" && parts.length <= 2) {
554
- const fragment = parts[1] ?? "";
555
- const subs = ["stop", "restart"];
556
- return subs.filter((s) => s.startsWith(fragment)).map((s) => ({ value: `ui ${s}`, label: s }));
557
- }
558
- if (first === "migrate-stacks") {
559
- const fragment = parts[parts.length - 1] ?? "";
560
- const flags = ["--dry-run", "--overwrite", "--delete-legacy"];
561
- return flags.filter((flag) => flag.startsWith(fragment)).map((flag) => ({ value: `${parts.slice(0, -1).join(" ")} ${flag}`.trim(), label: flag }));
562
- }
563
- return null;
564
- },
565
- handler: async (args, ctx) => {
566
- await handlePresetCommand(args, ctx);
567
- },
421
+ registerPayloadRequestHandler(pi, state, () => state.active);
422
+ registerPayloadCommands(pi, state);
423
+ registerPresetCommand(pi, state, {
424
+ selectedActiveId,
425
+ setActive,
426
+ reloadStacks,
427
+ openWebEditor,
428
+ stopWebEditor,
568
429
  });
569
-
570
- async function handlePresetCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
571
- const trimmed = args.trim();
572
- const [command = "list", ...rest] = trimmed ? trimmed.split(/\s+/) : ["list"];
573
-
574
- switch (command) {
575
- case "list":
576
- case "status":
577
- await showText(ctx, "pi-forge prompt stacks", renderStackList(ctx));
578
- return;
579
-
580
- case "reload":
581
- reloadStacks(ctx, selectedActiveId());
582
- ctx.ui.notify(`pi-forge: reloaded ${stacks.length} prompt stack(s).`, "info");
583
- return;
584
-
585
- case "ui": {
586
- const sub = rest[0];
587
- if (sub === "stop") {
588
- await stopWebEditor(ctx);
589
- return;
590
- }
591
- await openWebEditor(ctx, sub === "restart" ? "restart" : "open");
592
- return;
593
- }
594
-
595
- case "use": {
596
- const id = rest[0];
597
- if (!id) {
598
- ctx.ui.notify("Usage: /preset use <id|none>", "warning");
599
- return;
600
- }
601
- if (!setActive(id, ctx)) {
602
- ctx.ui.notify(`Unknown prompt stack: ${id}`, "error");
603
- return;
604
- }
605
- ctx.ui.notify(active ? `pi-forge: active prompt stack ${active.stack.id}` : "pi-forge: prompt stack disabled", "info");
606
- return;
607
- }
608
-
609
- case "preview": {
610
- const target = rest[0] ? stacks.find((loaded) => loaded.stack.id === rest[0]) : active;
611
- if (!target) {
612
- ctx.ui.notify(rest[0] ? `Unknown prompt stack: ${rest[0]}` : "No active prompt stack.", "warning");
613
- return;
614
- }
615
- await showText(ctx, `pi-forge preview: ${target.stack.id}`, renderPreview(ctx, target));
616
- return;
617
- }
618
-
619
- case "validate": {
620
- const target = rest[0] ? stacks.find((loaded) => loaded.stack.id === rest[0]) : active;
621
- if (!target) {
622
- ctx.ui.notify(rest[0] ? `Unknown prompt stack: ${rest[0]}` : "No active prompt stack.", "warning");
623
- return;
624
- }
625
- await showText(ctx, `pi-forge validation: ${target.stack.id}`, renderDiagnostics(target.diagnostics));
626
- return;
627
- }
628
-
629
- case "diagnostics": {
630
- await showText(ctx, "pi-forge diagnostics", renderCurrentDiagnostics());
631
- return;
632
- }
633
-
634
- case "migrate-stacks": {
635
- const flags = new Set(rest);
636
- const dryRun = flags.has("--dry-run");
637
- if (!ctx.isProjectTrusted() && !dryRun) {
638
- ctx.ui.notify("pi-forge: project is not trusted; refusing to migrate prompt stacks.", "warning");
639
- return;
640
- }
641
- const report = migrateLegacyPromptStacks(ctx.cwd, {
642
- dryRun,
643
- overwrite: flags.has("--overwrite"),
644
- deleteLegacy: flags.has("--delete-legacy"),
645
- });
646
- if (!dryRun) reloadStacks(ctx, selectedActiveId());
647
- const changed = report.copied + report.overwritten;
648
- const summary = dryRun
649
- ? `pi-forge: migration dry run found ${report.files.length} legacy stack file(s).`
650
- : `pi-forge: migrated ${changed} legacy stack file(s), skipped ${report.skipped}, errors ${report.errors}.`;
651
- ctx.ui.notify(summary, report.errors ? "warning" : "info");
652
- await showText(ctx, "pi-forge prompt-stack migration", renderMigrationReport(report));
653
- return;
654
- }
655
-
656
- case "import-silly": {
657
- if (!ctx.isProjectTrusted()) {
658
- ctx.ui.notify("pi-forge: project is not trusted; refusing to write imported prompt stacks.", "warning");
659
- return;
660
- }
661
-
662
- const sourcePath = rest[0];
663
- if (!sourcePath) {
664
- ctx.ui.notify("Usage: /preset import-silly <path> [character_id] [--dry-run] [--overwrite]", "warning");
665
- return;
666
- }
667
-
668
- const charIdToken = rest[1]?.startsWith("--") ? undefined : rest[1];
669
- const flags = new Set(rest.slice(charIdToken ? 2 : 1));
670
- const dryRun = flags.has("--dry-run");
671
- let overwrite = flags.has("--overwrite");
672
-
673
- const resolvedPath = sourcePath.startsWith("/") ? sourcePath : join(ctx.cwd, sourcePath);
674
- if (!existsSync(resolvedPath)) {
675
- ctx.ui.notify(`File not found: ${resolvedPath}`, "error");
676
- return;
677
- }
678
-
679
- const charId = charIdToken ? Number(charIdToken) : undefined;
680
- if (charIdToken && (Number.isNaN(charId) || !Number.isFinite(charId))) {
681
- ctx.ui.notify(`Invalid character_id: ${charIdToken}`, "error");
682
- return;
683
- }
684
-
685
- const result = importSillyTavernPreset(resolvedPath, charId);
686
- if ("error" in result) {
687
- ctx.ui.notify(`pi-forge import error: ${result.error}`, "error");
688
- return;
689
- }
690
-
691
- const existingStack = stacks.find((candidate) => candidate.stack.id === result.stack.id);
692
- const stackPath = existingStack?.filePath ?? promptStackPath(ctx.cwd, result.stack.id);
693
- const stacksDir = dirname(stackPath);
694
- const reportDir = join(ctx.cwd, ".pi", "forge", "import-reports");
695
- const reportPath = join(reportDir, `${result.stack.id}.md`);
696
-
697
- if (dryRun) {
698
- await showText(ctx, `pi-forge import dry run: ${result.stack.id}`, `Would write stack to: ${stackPath}\nWould write report to: ${reportPath}\n\n## Generated stack JSON\n\n\`\`\`json\n${JSON.stringify(result.stack, null, 2)}\n\`\`\`\n\n${result.report}`);
699
- return;
700
- }
701
-
702
- const existingPaths = [stackPath, reportPath].filter((path) => existsSync(path));
703
- if (existingPaths.length > 0 && !overwrite) {
704
- if (!ctx.hasUI) {
705
- ctx.ui.notify(`pi-forge: import would overwrite existing file(s): ${existingPaths.join(", ")}. Re-run with --overwrite.`, "error");
706
- return;
707
- }
708
- overwrite = await ctx.ui.confirm("Overwrite pi-forge import output?", `These file(s) already exist:\n${existingPaths.join("\n")}\n\nOverwrite them?`);
709
- if (!overwrite) {
710
- ctx.ui.notify("pi-forge: import cancelled; existing files were left unchanged.", "info");
711
- return;
712
- }
713
- }
714
-
715
- if (!existsSync(stacksDir)) mkdirSync(stacksDir, { recursive: true });
716
- writeFileSync(stackPath, JSON.stringify(result.stack, null, 2), "utf8");
717
-
718
- if (!existsSync(reportDir)) mkdirSync(reportDir, { recursive: true });
719
- writeFileSync(reportPath, result.report, "utf8");
720
-
721
- // Reload stacks to pick up the new one
722
- reloadStacks(ctx, selectedActiveId());
723
-
724
- ctx.ui.notify(`pi-forge: imported ${result.stack.id} (${result.stack.items.length} items)`, "info");
725
- await showText(ctx, `pi-forge import report: ${result.stack.id}`, `Stack written to: ${stackPath}\nReport written to: ${reportPath}\n\n${result.report}`);
726
- return;
727
- }
728
-
729
- default:
730
- ctx.ui.notify(`Unknown /preset subcommand: ${command}`, "warning");
731
- return;
732
- }
733
- }
734
-
735
- function renderStackList(ctx: ExtensionCommandContext): string {
736
- const lines = [
737
- "Prompt stack directories:",
738
- ...promptStackReadDirs(ctx.cwd).map((dir, index) => ` ${index === 0 ? "primary" : "legacy"}: ${dir}`),
739
- `Active stack: ${active?.stack.id ?? "(none)"}`,
740
- "",
741
- ];
742
-
743
- if (stacks.length === 0) {
744
- lines.push("No prompt stacks found.", "Create .pi/forge/prompt-stacks/default.json to auto-activate a stack.");
745
- return lines.join("\n");
746
- }
747
-
748
- for (const loaded of stacks) {
749
- const marker = loaded === active ? "*" : " ";
750
- const errors = loaded.diagnostics.filter((d) => d.level === "error").length;
751
- const warnings = loaded.diagnostics.filter((d) => d.level === "warning").length;
752
- const suffix = errors || warnings ? ` (${errors} errors, ${warnings} warnings)` : "";
753
- lines.push(`${marker} ${loaded.stack.id}${loaded.stack.name ? ` — ${loaded.stack.name}` : ""}${suffix}`);
754
- lines.push(` ${loaded.filePath}`);
755
- }
756
-
757
- lines.push("", "Commands:", " /preset use <id|none>", " /preset preview [id]", " /preset validate [id]", " /preset diagnostics", " /preset reload", " /preset migrate-stacks [--dry-run] [--overwrite] [--delete-legacy]", " /preset ui [stop|restart]");
758
- return lines.join("\n");
759
- }
760
-
761
- function renderCurrentDiagnostics(): string {
762
- const lines = ["# pi-forge diagnostics", ""];
763
- lines.push("## Active stack load/validation diagnostics", "");
764
- lines.push(active ? renderDiagnostics(active.diagnostics) : "No active prompt stack.");
765
- lines.push("", "## Latest runtime compile diagnostics", "");
766
- lines.push(renderDiagnostics(latestCompileDiagnostics));
767
- return lines.join("\n");
768
- }
769
-
770
- function webPayloadSnapshot(): WebEditorPayloadSnapshot {
771
- if (interceptNextProviderPayload) {
772
- return {
773
- status: "armed",
774
- armedAt: payloadCaptureArmedAt,
775
- savePath: interceptPayloadSavePath,
776
- };
777
- }
778
- if (latestProviderPayloadCapture) {
779
- return {
780
- status: "captured",
781
- capture: latestProviderPayloadCapture,
782
- };
783
- }
784
- return { status: "idle" };
785
- }
786
-
787
- function renderPreview(ctx: ExtensionCommandContext, target: LoadedPromptStack): string {
788
- return buildPreview(ctx, target).text;
789
- }
790
-
791
- function buildPreview(ctx: ExtensionCommandContext, target: LoadedPromptStack): { text: string; preview: WebEditorPreview; diagnostics: PromptStackDiagnostic[] } {
792
- const options = ctx.getSystemPromptOptions();
793
- const sessionContext = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
794
- const latestUserMessage = getLatestUserMessage(sessionContext.messages);
795
- const previewVariables = createPromptVariableStore(sessionVariables);
796
- const runtime = { options, ctx, latestUserMessage, now: new Date(), variables: previewVariables };
797
- const system = compileSystemPrompt(target.stack, runtime, ctx.getSystemPrompt());
798
- const messages = compileMessages(target.stack, runtime, sessionContext.messages);
799
- const diagnostics = [...target.diagnostics, ...system.diagnostics, ...messages.diagnostics];
800
- const messageSections = messages.messages.map((message, index) => {
801
- const content = agentMessageToPreviewText(message);
802
- const source = messages.messageSources[index];
803
- return previewSection(`message-${index}`, previewMessageTitle(source, index), content, message.role);
804
- });
805
- const systemSection = previewSection("system", "System prompt", system.systemPrompt || "(empty)");
806
- const totalChars = systemSection.chars + messageSections.reduce((sum, section) => sum + section.chars, 0);
807
- const preview: WebEditorPreview = {
808
- stackId: target.stack.id,
809
- generatedAt: new Date().toISOString(),
810
- system: systemSection,
811
- messages: messageSections,
812
- totalChars,
813
- approxTokens: estimatePayloadTokens(`${system.systemPrompt}\n${messageSections.map((section) => section.content).join("\n")}`),
814
- };
815
-
816
- const text = [
817
- `# Prompt stack preview: ${target.stack.id}`,
818
- "",
819
- "## System prompt",
820
- "",
821
- system.systemPrompt || "(empty)",
822
- "",
823
- "## Message layout",
824
- "",
825
- renderPreviewSectionText(messageSections),
826
- "",
827
- "## Diagnostics",
828
- "",
829
- renderDiagnostics(diagnostics),
830
- ].join("\n");
831
-
832
- return { text, preview, diagnostics };
833
- }
834
-
835
- function previewMessageTitle(source: CompileMessageSource | undefined, index: number): string {
836
- if (source?.kind === "stack-item") {
837
- return source.itemName?.trim() || source.itemId || `Stack item ${index + 1}`;
838
- }
839
- if (source?.kind === "chat-history") {
840
- const label = source.itemName?.trim() || "Chat history";
841
- return `${label} #${source.historyIndex ?? index + 1}`;
842
- }
843
- if (source?.kind === "implicit-history") {
844
- return `Conversation history #${source.historyIndex ?? index + 1}`;
845
- }
846
- return `Message ${index + 1}`;
847
- }
848
-
849
- function renderPreviewSectionText(sections: WebEditorPreviewSection[], maxChars = 8000): string {
850
- let text = "";
851
- for (const section of sections) {
852
- const role = section.role ? ` (${section.role})` : "";
853
- text += `\n--- ${section.title}${role} ---\n`;
854
- text += section.content;
855
- text += "\n";
856
- if (text.length > maxChars) return `${text.slice(0, maxChars)}\n\n[preview truncated]`;
857
- }
858
- return text.trimStart();
859
- }
860
-
861
- function previewSection(id: string, title: string, content: string, role?: string): WebEditorPreviewSection {
862
- return {
863
- id,
864
- title,
865
- role,
866
- content,
867
- chars: content.length,
868
- approxTokens: estimatePayloadTokens(content),
869
- };
870
- }
871
-
872
- function renderDiagnostics(diagnostics: PromptStackDiagnostic[]): string {
873
- if (diagnostics.length === 0) return "No diagnostics.";
874
- return diagnostics.map((d) => `${d.level.toUpperCase()}${d.itemId ? ` [${d.itemId}]` : ""}: ${d.message}`).join("\n");
875
- }
876
-
877
- async function showText(ctx: ExtensionCommandContext, title: string, text: string): Promise<void> {
878
- if (ctx.hasUI) {
879
- await ctx.ui.editor(title, text);
880
- return;
881
- }
882
- console.log(text);
883
- }
884
-
885
- function captureProviderPayload(value: unknown, savePath?: string): WebEditorPayloadCapture {
886
- const capture = createProviderPayloadCapture(value, { stackId: active?.stack.id, savePath });
887
- latestProviderPayloadCapture = capture;
888
- return capture;
889
- }
890
430
  }