@zihanw/pi-forge 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +87 -2
  2. package/README.zh-CN.md +72 -2
  3. package/dist/compiler.d.ts +10 -0
  4. package/dist/compiler.d.ts.map +1 -0
  5. package/dist/compiler.js +490 -0
  6. package/dist/compiler.js.map +1 -0
  7. package/dist/extension-registry.d.ts +25 -0
  8. package/dist/extension-registry.d.ts.map +1 -0
  9. package/dist/extension-registry.js +6 -0
  10. package/dist/extension-registry.js.map +1 -0
  11. package/dist/forge-extensions.d.ts +28 -0
  12. package/dist/forge-extensions.d.ts.map +1 -0
  13. package/dist/forge-extensions.js +182 -0
  14. package/dist/forge-extensions.js.map +1 -0
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +376 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/lifecycle.d.ts +14 -0
  20. package/dist/lifecycle.d.ts.map +1 -0
  21. package/dist/lifecycle.js +139 -0
  22. package/dist/lifecycle.js.map +1 -0
  23. package/dist/loader.d.ts +8 -0
  24. package/dist/loader.d.ts.map +1 -0
  25. package/dist/loader.js +345 -0
  26. package/dist/loader.js.map +1 -0
  27. package/dist/macro-engine.d.ts +23 -0
  28. package/dist/macro-engine.d.ts.map +1 -0
  29. package/dist/macro-engine.js +262 -0
  30. package/dist/macro-engine.js.map +1 -0
  31. package/dist/payload-capture.d.ts +15 -0
  32. package/dist/payload-capture.d.ts.map +1 -0
  33. package/dist/payload-capture.js +86 -0
  34. package/dist/payload-capture.js.map +1 -0
  35. package/dist/payload-command.d.ts +10 -0
  36. package/dist/payload-command.d.ts.map +1 -0
  37. package/dist/payload-command.js +112 -0
  38. package/dist/payload-command.js.map +1 -0
  39. package/dist/policy.d.ts +6 -0
  40. package/dist/policy.d.ts.map +1 -0
  41. package/dist/policy.js +38 -0
  42. package/dist/policy.js.map +1 -0
  43. package/dist/preset-command.d.ts +14 -0
  44. package/dist/preset-command.d.ts.map +1 -0
  45. package/dist/preset-command.js +232 -0
  46. package/dist/preset-command.js.map +1 -0
  47. package/dist/preview.d.ts +12 -0
  48. package/dist/preview.d.ts.map +1 -0
  49. package/dist/preview.js +179 -0
  50. package/dist/preview.js.map +1 -0
  51. package/dist/regex.d.ts +7 -0
  52. package/dist/regex.d.ts.map +1 -0
  53. package/dist/regex.js +432 -0
  54. package/dist/regex.js.map +1 -0
  55. package/dist/render-helpers.d.ts +44 -0
  56. package/dist/render-helpers.d.ts.map +1 -0
  57. package/dist/render-helpers.js +128 -0
  58. package/dist/render-helpers.js.map +1 -0
  59. package/dist/runtime-state.d.ts +26 -0
  60. package/dist/runtime-state.d.ts.map +1 -0
  61. package/dist/runtime-state.js +15 -0
  62. package/dist/runtime-state.js.map +1 -0
  63. package/dist/sillytavern-importer/items.d.ts +3 -0
  64. package/dist/sillytavern-importer/items.d.ts.map +1 -0
  65. package/dist/sillytavern-importer/items.js +88 -0
  66. package/dist/sillytavern-importer/items.js.map +1 -0
  67. package/dist/sillytavern-importer/macros.d.ts +15 -0
  68. package/dist/sillytavern-importer/macros.d.ts.map +1 -0
  69. package/dist/sillytavern-importer/macros.js +141 -0
  70. package/dist/sillytavern-importer/macros.js.map +1 -0
  71. package/dist/sillytavern-importer/prompt-order.d.ts +6 -0
  72. package/dist/sillytavern-importer/prompt-order.d.ts.map +1 -0
  73. package/dist/sillytavern-importer/prompt-order.js +38 -0
  74. package/dist/sillytavern-importer/prompt-order.js.map +1 -0
  75. package/dist/sillytavern-importer/regex.d.ts +3 -0
  76. package/dist/sillytavern-importer/regex.d.ts.map +1 -0
  77. package/dist/sillytavern-importer/regex.js +275 -0
  78. package/dist/sillytavern-importer/regex.js.map +1 -0
  79. package/dist/sillytavern-importer/report.d.ts +21 -0
  80. package/dist/sillytavern-importer/report.d.ts.map +1 -0
  81. package/dist/sillytavern-importer/report.js +166 -0
  82. package/dist/sillytavern-importer/report.js.map +1 -0
  83. package/dist/sillytavern-importer/types.d.ts +106 -0
  84. package/dist/sillytavern-importer/types.d.ts.map +1 -0
  85. package/dist/sillytavern-importer/types.js +2 -0
  86. package/dist/sillytavern-importer/types.js.map +1 -0
  87. package/dist/sillytavern-importer.d.ts +5 -0
  88. package/dist/sillytavern-importer.d.ts.map +1 -0
  89. package/dist/sillytavern-importer.js +117 -0
  90. package/dist/sillytavern-importer.js.map +1 -0
  91. package/dist/slot-renderers.d.ts +25 -0
  92. package/dist/slot-renderers.d.ts.map +1 -0
  93. package/dist/slot-renderers.js +334 -0
  94. package/dist/slot-renderers.js.map +1 -0
  95. package/dist/stack-migration.d.ts +29 -0
  96. package/dist/stack-migration.d.ts.map +1 -0
  97. package/dist/stack-migration.js +126 -0
  98. package/dist/stack-migration.js.map +1 -0
  99. package/dist/storage.d.ts +10 -0
  100. package/dist/storage.d.ts.map +1 -0
  101. package/dist/storage.js +36 -0
  102. package/dist/storage.js.map +1 -0
  103. package/dist/types.d.ts +173 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +2 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/web-editor/index.d.ts +3 -0
  108. package/dist/web-editor/index.d.ts.map +1 -0
  109. package/dist/web-editor/index.js +2 -0
  110. package/dist/web-editor/index.js.map +1 -0
  111. package/dist/web-editor/page.d.ts +2 -0
  112. package/dist/web-editor/page.d.ts.map +1 -0
  113. package/dist/web-editor/page.js +3331 -0
  114. package/dist/web-editor/page.js.map +1 -0
  115. package/dist/web-editor/server.d.ts +4 -0
  116. package/dist/web-editor/server.d.ts.map +1 -0
  117. package/dist/web-editor/server.js +263 -0
  118. package/dist/web-editor/server.js.map +1 -0
  119. package/dist/web-editor/types.d.ts +125 -0
  120. package/dist/web-editor/types.d.ts.map +1 -0
  121. package/dist/web-editor/types.js +2 -0
  122. package/dist/web-editor/types.js.map +1 -0
  123. package/dist/web-host.d.ts +29 -0
  124. package/dist/web-host.d.ts.map +1 -0
  125. package/dist/web-host.js +174 -0
  126. package/dist/web-host.js.map +1 -0
  127. package/examples/custom-system-status-extension/README.md +85 -0
  128. package/examples/custom-system-status-extension/index.ts +136 -0
  129. package/examples/custom-system-status-extension/prompt-stack.json +39 -0
  130. package/examples/image-reader-prompt-stack.json +124 -0
  131. package/package.json +28 -2
  132. package/src/compiler.ts +2 -472
  133. package/src/extension-registry.ts +33 -0
  134. package/src/forge-extensions.ts +223 -0
  135. package/src/index.ts +208 -653
  136. package/src/lifecycle.ts +171 -0
  137. package/src/macro-engine.ts +358 -0
  138. package/src/payload-command.ts +138 -0
  139. package/src/preset-command.ts +280 -0
  140. package/src/preview.ts +226 -0
  141. package/src/render-helpers.ts +169 -0
  142. package/src/runtime-state.ts +40 -0
  143. package/src/sillytavern-importer/items.ts +98 -0
  144. package/src/sillytavern-importer/macros.ts +159 -0
  145. package/src/sillytavern-importer/prompt-order.ts +54 -0
  146. package/src/sillytavern-importer/regex.ts +270 -0
  147. package/src/sillytavern-importer/report.ts +202 -0
  148. package/src/sillytavern-importer/types.ts +120 -0
  149. package/src/sillytavern-importer.ts +55 -865
  150. package/src/slot-renderers.ts +414 -0
  151. package/src/storage.ts +17 -0
  152. package/src/types.ts +3 -14
  153. package/src/web-editor/page.ts +516 -30
  154. package/src/web-editor/server.ts +9 -4
  155. package/src/web-editor/types.ts +18 -4
  156. package/src/web-host.ts +16 -13
package/src/index.ts CHANGED
@@ -1,31 +1,52 @@
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 { createForgeExtensionState, reloadForgeExtensions, unloadForgeExtensions } from "./forge-extensions.ts";
3
+ import { registerLifecycleHandlers } from "./lifecycle.ts";
4
+ import { chooseDefaultStack, isDisabledPromptStackId, loadPromptStacks } from "./loader.ts";
5
+ import { registerPayloadCommands, registerPayloadRequestHandler, armPayloadIntercept, clearPayloadCapture, webPayloadSnapshot } from "./payload-command.ts";
15
6
  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";
7
+ import { buildPreview, showText } from "./preview.ts";
8
+ import { registerPresetCommand, selectedActiveId as selectedActiveIdForState } from "./preset-command.ts";
9
+ import { createRuntimeState, STATE_ENTRY_TYPE } from "./runtime-state.ts";
10
+ import type { PromptStack, PromptStackDiagnostic } from "./types.ts";
20
11
  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";
12
+ import { startWebEditorServer, type WebEditorPolicyResource, type WebEditorPolicyResources, type WebEditorServer } from "./web-editor/index.ts";
13
+
14
+ export {
15
+ getRegisteredMacros,
16
+ registerMacro,
17
+ type PromptMacroDefinition,
18
+ type PromptMacroRenderContext,
19
+ type PromptMacroRenderer,
20
+ } from "./macro-engine.ts";
21
+ export {
22
+ getRegisteredSlots,
23
+ registerSlot,
24
+ type PromptSlotDefinition,
25
+ type PromptSlotRenderContext,
26
+ type PromptSlotRenderer,
27
+ } from "./slot-renderers.ts";
28
+ export {
29
+ type PromptExtensionArgumentDefinition,
30
+ type PromptExtensionOptionDefinition,
31
+ type PromptExtensionOptionsSchema,
32
+ type PromptExtensionOptionType,
33
+ type PromptRegistryEntry,
34
+ } from "./extension-registry.ts";
35
+ export {
36
+ type ForgeExtensionApi,
37
+ type ForgeExtensionRegister,
38
+ } from "./forge-extensions.ts";
39
+ export {
40
+ createVariableAccess,
41
+ promptRenderHelpers,
42
+ type PromptRenderHelpers,
43
+ type PromptVariableAccess,
44
+ type PromptVariableScope,
45
+ type PromptWritableVariableScope,
46
+ } from "./render-helpers.ts";
22
47
 
23
- const STATE_ENTRY_TYPE = "pi-forge-prompt-stack-state";
24
- const VARIABLE_ENTRY_TYPE = "pi-forge-variable-state";
25
48
  const WEB_EDITOR_GLOBAL_KEY = "__piForgeWebEditor";
26
49
 
27
- type PayloadDisplayTarget = "editor" | "web";
28
-
29
50
  interface SharedWebEditorState {
30
51
  server?: WebEditorServer;
31
52
  cwd?: string;
@@ -48,86 +69,82 @@ function getSharedWebEditorRegistry(): SharedWebEditorRegistry {
48
69
 
49
70
  export default function piForge(pi: ExtensionAPI) {
50
71
  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[] = [];
72
+ const state = createRuntimeState();
65
73
  let webEditor: WebEditorServer | undefined;
66
74
  let webEditorCwd: string | undefined;
67
75
  let webEditorPreferredPort: number | undefined;
68
76
  let toolPolicyBaseline: string[] | undefined;
77
+ const forgeExtensionState = createForgeExtensionState();
69
78
 
70
79
  function activeId(): string | undefined {
71
- return active?.stack.id;
80
+ return state.active?.stack.id;
72
81
  }
73
82
 
74
83
  function selectedActiveId(): string | undefined {
75
- if (active) return active.stack.id;
76
- return isDisabledPromptStackId(lastPersistedActiveId) ? "none" : undefined;
84
+ return selectedActiveIdForState(state);
77
85
  }
78
86
 
79
87
  function persistActiveSelection(id: string): void {
80
- if (id === lastPersistedActiveId) return;
88
+ if (id === state.lastPersistedActiveId) return;
81
89
  pi.appendEntry(STATE_ENTRY_TYPE, { activeStackId: id });
82
- lastPersistedActiveId = id;
90
+ state.lastPersistedActiveId = id;
83
91
  }
84
92
 
85
93
  function setActive(id: string | undefined, ctx?: ExtensionContext): boolean {
86
94
  if (!id || isDisabledPromptStackId(id)) {
87
- active = undefined;
95
+ state.active = undefined;
88
96
  if (id) persistActiveSelection("none");
89
97
  if (ctx) updateStatus(ctx);
90
98
  syncActiveToolPolicy(ctx);
91
99
  return true;
92
100
  }
93
101
 
94
- const found = stacks.find((candidate) => candidate.stack.id === id);
102
+ const found = state.stacks.find((candidate) => candidate.stack.id === id);
95
103
  if (!found) return false;
96
- active = found;
104
+ state.active = found;
97
105
  persistActiveSelection(found.stack.id);
98
106
  if (ctx) updateStatus(ctx);
99
107
  syncActiveToolPolicy(ctx);
100
108
  return true;
101
109
  }
102
110
 
103
- function reloadStacks(ctx: ExtensionContext, preferredId?: string): void {
111
+ async function reloadStacks(ctx: ExtensionContext, preferredId?: string): Promise<void> {
104
112
  if (!ctx.isProjectTrusted()) {
105
- stacks = [];
106
- active = undefined;
113
+ const unloadDiagnostics = unloadForgeExtensions(forgeExtensionState);
114
+ state.forgeExtensionDiagnostics = unloadDiagnostics;
115
+ state.forgeExtensionPaths = [];
116
+ state.stacks = [];
117
+ state.active = undefined;
107
118
  syncActiveToolPolicy(ctx);
108
119
  ctx.ui.notify("pi-forge: project is not trusted; prompt stacks are disabled.", "warning");
109
120
  updateStatus(ctx);
110
121
  return;
111
122
  }
112
123
 
113
- stacks = loadPromptStacks(ctx.cwd);
114
- active = chooseDefaultStack(stacks, preferredId);
124
+ const extensionResult = await reloadForgeExtensions(ctx.cwd, forgeExtensionState);
125
+ state.forgeExtensionDiagnostics = extensionResult.diagnostics;
126
+ state.forgeExtensionPaths = extensionResult.loadedPaths;
127
+ state.stacks = loadPromptStacks(ctx.cwd);
128
+ if (state.forgeExtensionDiagnostics.length > 0) {
129
+ for (const loaded of state.stacks) loaded.diagnostics.unshift(...state.forgeExtensionDiagnostics);
130
+ }
131
+ state.active = chooseDefaultStack(state.stacks, preferredId);
115
132
  updateStatus(ctx);
116
133
  syncActiveToolPolicy(ctx);
117
134
  }
118
135
 
119
136
  function updateStatus(ctx: ExtensionContext): void {
120
- if (active) {
121
- ctx.ui.setStatus("pi-forge", ctx.ui.theme.fg("accent", "stack:" + active.stack.id));
137
+ if (state.active) {
138
+ ctx.ui.setStatus("pi-forge", ctx.ui.theme.fg("accent", "stack:" + state.active.stack.id));
122
139
  } else {
123
140
  ctx.ui.setStatus("pi-forge", undefined);
124
- latestCompileDiagnostics = [];
141
+ state.latestCompileDiagnostics = [];
125
142
  ctx.ui.setStatus("pi-forge-diagnostics", undefined);
126
143
  }
127
144
  }
128
145
 
129
146
  function syncActiveToolPolicy(ctx?: ExtensionContext): void {
130
- const policy = active?.stack.tools;
147
+ const policy = state.active?.stack.tools;
131
148
  if (!hasResourcePolicy(policy)) {
132
149
  restoreToolPolicy(ctx);
133
150
  return;
@@ -158,15 +175,15 @@ export default function piForge(pi: ExtensionAPI) {
158
175
  }
159
176
 
160
177
  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;
178
+ if (!state.active) return;
179
+ const errorCount = state.active.diagnostics.filter((d) => d.level === "error").length;
180
+ const warningCount = state.active.diagnostics.filter((d) => d.level === "warning").length;
164
181
  const suffix = errorCount || warningCount ? " (" + errorCount + " errors, " + warningCount + " warnings)" : "";
165
- ctx.ui.notify("pi-forge: active preset " + active.stack.id + suffix + " (" + detail + ")", errorCount ? "error" : "info");
182
+ ctx.ui.notify("pi-forge: active preset " + state.active.stack.id + suffix + " (" + detail + ")", errorCount ? "error" : "info");
166
183
  }
167
184
 
168
185
  function recordCompileDiagnostics(ctx: ExtensionContext, diagnostics: PromptStackDiagnostic[]): void {
169
- latestCompileDiagnostics = diagnostics;
186
+ state.latestCompileDiagnostics = diagnostics;
170
187
  const errors = diagnostics.filter((d) => d.level === "error").length;
171
188
  const warnings = diagnostics.filter((d) => d.level === "warning").length;
172
189
  if (errors || warnings) {
@@ -178,25 +195,133 @@ export default function piForge(pi: ExtensionAPI) {
178
195
 
179
196
  function webHostRuntime(ctx: ExtensionCommandContext): WebHostRuntime {
180
197
  return {
181
- getStacks: () => stacks,
182
- getActive: () => active,
198
+ getStacks: () => state.stacks,
199
+ getActive: () => state.active,
183
200
  getActiveId: activeId,
184
201
  getSelectedActiveId: selectedActiveId,
185
202
  setActive: (id) => setActive(id, ctx),
186
203
  reloadStacks: (preferredId) => reloadStacks(ctx, preferredId),
187
- buildPreview: (target) => buildPreview(ctx, target),
188
- getPayload: () => ({ ok: true, ...webPayloadSnapshot() }),
204
+ buildPreview: (target) => buildPreview(ctx, target, state.sessionVariables, previewOptionsForStack(ctx, target.stack)),
205
+ getPolicyResources: () => getPolicyResources(ctx),
206
+ getPayload: () => ({ ok: true, ...webPayloadSnapshot(state) }),
189
207
  armPayload: (savePath) => {
190
- armPayloadIntercept(ctx, savePath, "web");
191
- return { ok: true, ...webPayloadSnapshot() };
208
+ armPayloadIntercept(state, ctx, savePath, "web");
209
+ return { ok: true, ...webPayloadSnapshot(state) };
192
210
  },
193
211
  clearPayload: () => {
194
- clearPayloadCapture(ctx);
195
- return { ok: true, ...webPayloadSnapshot() };
212
+ clearPayloadCapture(state, ctx);
213
+ return { ok: true, ...webPayloadSnapshot(state) };
196
214
  },
197
215
  };
198
216
  }
199
217
 
218
+ function getPolicyResources(ctx: ExtensionCommandContext): WebEditorPolicyResources {
219
+ const options = ctx.getSystemPromptOptions();
220
+ const activeTools = new Set(pi.getActiveTools());
221
+ const snippets = options.toolSnippets ?? {};
222
+ const tools = pi.getAllTools()
223
+ .map((tool) => normalizeToolResource(tool, activeTools, snippets))
224
+ .filter(hasPolicyResourceName)
225
+ .sort(comparePolicyResource);
226
+ const skills = (options.skills ?? [])
227
+ .map(normalizeSkillResource)
228
+ .filter(hasPolicyResourceName)
229
+ .sort(comparePolicyResource);
230
+ return { tools, skills };
231
+ }
232
+
233
+ function previewOptionsForStack(ctx: ExtensionCommandContext, stack: PromptStack): BuildSystemPromptOptions {
234
+ const base = ctx.getSystemPromptOptions();
235
+ const baseSelectedTools = Array.isArray(base.selectedTools) ? base.selectedTools : pi.getActiveTools();
236
+ const policyActive = hasResourcePolicy(stack.tools);
237
+ const baselineTools = policyActive ? (toolPolicyBaseline ?? pi.getActiveTools()) : baseSelectedTools;
238
+ const selectedTools = policyActive
239
+ ? applyResourcePolicy(filterKnownTools(baselineTools), stack.tools)
240
+ : baseSelectedTools;
241
+ const selectedToolSet = new Set(selectedTools);
242
+ const toolSnippets = filterToolSnippets(base.toolSnippets ?? {}, selectedToolSet);
243
+ const toolInfos = pi.getAllTools();
244
+ for (const tool of toolInfos) {
245
+ const name = stringValue((tool as { name?: unknown }).name);
246
+ if (!name || !selectedToolSet.has(name) || toolSnippets[name]) continue;
247
+ const snippet = stringValue((tool as { promptSnippet?: unknown }).promptSnippet);
248
+ if (snippet) toolSnippets[name] = snippet;
249
+ }
250
+
251
+ const mappedGuidelines = toolInfos
252
+ .filter((tool) => {
253
+ const name = stringValue((tool as { name?: unknown }).name);
254
+ return !!name && selectedToolSet.has(name);
255
+ })
256
+ .flatMap((tool) => stringArrayValue((tool as { promptGuidelines?: unknown }).promptGuidelines));
257
+ const promptGuidelines = policyActive && !sameStringSet(baseSelectedTools, selectedTools)
258
+ ? mappedGuidelines
259
+ : (base.promptGuidelines ?? mappedGuidelines);
260
+
261
+ return { ...base, selectedTools, toolSnippets, promptGuidelines };
262
+ }
263
+
264
+ function filterToolSnippets(snippets: Record<string, string | undefined>, selectedTools: Set<string>): Record<string, string> {
265
+ const filtered: Record<string, string> = {};
266
+ for (const [name, snippet] of Object.entries(snippets)) {
267
+ if (selectedTools.has(name) && snippet) filtered[name] = snippet;
268
+ }
269
+ return filtered;
270
+ }
271
+
272
+ function normalizeToolResource(
273
+ tool: { name?: unknown; description?: unknown; promptSnippet?: unknown; sourceInfo?: unknown },
274
+ activeTools: Set<string>,
275
+ snippets: Record<string, string | undefined>,
276
+ ): WebEditorPolicyResource {
277
+ const name = String(tool.name ?? "");
278
+ return {
279
+ name,
280
+ description: stringValue(tool.description) ?? stringValue(tool.promptSnippet) ?? snippets[name],
281
+ source: sourceLabel(tool.sourceInfo),
282
+ active: activeTools.has(name),
283
+ };
284
+ }
285
+
286
+ function normalizeSkillResource(skill: { name?: unknown; description?: unknown; filePath?: unknown; disableModelInvocation?: unknown }): WebEditorPolicyResource {
287
+ return {
288
+ name: String(skill.name ?? ""),
289
+ description: stringValue(skill.description),
290
+ source: stringValue(skill.filePath),
291
+ hidden: skill.disableModelInvocation === true,
292
+ };
293
+ }
294
+
295
+ function stringValue(value: unknown): string | undefined {
296
+ return typeof value === "string" && value.trim() ? value : undefined;
297
+ }
298
+
299
+ function stringArrayValue(value: unknown): string[] {
300
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && !!item.trim()) : [];
301
+ }
302
+
303
+ function sourceLabel(value: unknown): string | undefined {
304
+ if (!value || typeof value !== "object") return undefined;
305
+ const source = stringValue((value as { source?: unknown }).source);
306
+ const path = stringValue((value as { path?: unknown }).path);
307
+ if (source && path) return `${source}: ${path}`;
308
+ return source ?? path;
309
+ }
310
+
311
+ function comparePolicyResource(a: WebEditorPolicyResource, b: WebEditorPolicyResource): number {
312
+ return a.name.localeCompare(b.name);
313
+ }
314
+
315
+ function hasPolicyResourceName(resource: WebEditorPolicyResource): boolean {
316
+ return !!resource.name.trim();
317
+ }
318
+
319
+ function sameStringSet(a: string[], b: string[]): boolean {
320
+ if (a.length !== b.length) return false;
321
+ const bSet = new Set(b);
322
+ return a.every((value) => bSet.has(value));
323
+ }
324
+
200
325
  function sharedWebEditorForCwd(cwd: string): SharedWebEditorState {
201
326
  sharedWebEditors.byCwd[cwd] ??= {};
202
327
  return sharedWebEditors.byCwd[cwd];
@@ -299,592 +424,22 @@ export default function piForge(pi: ExtensionAPI) {
299
424
  ctx.ui.notify("pi-forge: stack editor stopped.", "info");
300
425
  }
301
426
 
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
- },
427
+ registerLifecycleHandlers(pi, state, {
428
+ reloadStacks,
429
+ refreshWebEditorHost,
430
+ notifyActivePreset,
431
+ syncActiveToolPolicy,
432
+ activeId,
433
+ persistActiveSelection,
434
+ recordCompileDiagnostics,
537
435
  });
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
- },
436
+ registerPayloadRequestHandler(pi, state, () => state.active);
437
+ registerPayloadCommands(pi, state);
438
+ registerPresetCommand(pi, state, {
439
+ selectedActiveId,
440
+ setActive,
441
+ reloadStacks,
442
+ openWebEditor,
443
+ stopWebEditor,
568
444
  });
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
445
  }