@zihanw/pi-forge 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +233 -105
  2. package/README.zh-CN.md +215 -104
  3. package/dist/compiler.d.ts +10 -0
  4. package/dist/compiler.d.ts.map +1 -0
  5. package/dist/compiler.js +490 -0
  6. package/dist/compiler.js.map +1 -0
  7. package/dist/extension-registry.d.ts +25 -0
  8. package/dist/extension-registry.d.ts.map +1 -0
  9. package/dist/extension-registry.js +6 -0
  10. package/dist/extension-registry.js.map +1 -0
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +364 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/lifecycle.d.ts +14 -0
  16. package/dist/lifecycle.d.ts.map +1 -0
  17. package/dist/lifecycle.js +139 -0
  18. package/dist/lifecycle.js.map +1 -0
  19. package/dist/loader.d.ts +8 -0
  20. package/dist/loader.d.ts.map +1 -0
  21. package/dist/loader.js +345 -0
  22. package/dist/loader.js.map +1 -0
  23. package/dist/macro-engine.d.ts +23 -0
  24. package/dist/macro-engine.d.ts.map +1 -0
  25. package/dist/macro-engine.js +262 -0
  26. package/dist/macro-engine.js.map +1 -0
  27. package/dist/payload-capture.d.ts +15 -0
  28. package/dist/payload-capture.d.ts.map +1 -0
  29. package/dist/payload-capture.js +86 -0
  30. package/dist/payload-capture.js.map +1 -0
  31. package/dist/payload-command.d.ts +10 -0
  32. package/dist/payload-command.d.ts.map +1 -0
  33. package/dist/payload-command.js +112 -0
  34. package/dist/payload-command.js.map +1 -0
  35. package/dist/policy.d.ts +6 -0
  36. package/dist/policy.d.ts.map +1 -0
  37. package/dist/policy.js +38 -0
  38. package/dist/policy.js.map +1 -0
  39. package/dist/preset-command.d.ts +14 -0
  40. package/dist/preset-command.d.ts.map +1 -0
  41. package/dist/preset-command.js +223 -0
  42. package/dist/preset-command.js.map +1 -0
  43. package/dist/preview.d.ts +12 -0
  44. package/dist/preview.d.ts.map +1 -0
  45. package/dist/preview.js +179 -0
  46. package/dist/preview.js.map +1 -0
  47. package/dist/regex.d.ts +7 -0
  48. package/dist/regex.d.ts.map +1 -0
  49. package/dist/regex.js +432 -0
  50. package/dist/regex.js.map +1 -0
  51. package/dist/render-helpers.d.ts +44 -0
  52. package/dist/render-helpers.d.ts.map +1 -0
  53. package/dist/render-helpers.js +128 -0
  54. package/dist/render-helpers.js.map +1 -0
  55. package/dist/runtime-state.d.ts +24 -0
  56. package/dist/runtime-state.d.ts.map +1 -0
  57. package/dist/runtime-state.js +13 -0
  58. package/dist/runtime-state.js.map +1 -0
  59. package/dist/sillytavern-importer/items.d.ts +3 -0
  60. package/dist/sillytavern-importer/items.d.ts.map +1 -0
  61. package/dist/sillytavern-importer/items.js +88 -0
  62. package/dist/sillytavern-importer/items.js.map +1 -0
  63. package/dist/sillytavern-importer/macros.d.ts +15 -0
  64. package/dist/sillytavern-importer/macros.d.ts.map +1 -0
  65. package/dist/sillytavern-importer/macros.js +141 -0
  66. package/dist/sillytavern-importer/macros.js.map +1 -0
  67. package/dist/sillytavern-importer/prompt-order.d.ts +6 -0
  68. package/dist/sillytavern-importer/prompt-order.d.ts.map +1 -0
  69. package/dist/sillytavern-importer/prompt-order.js +38 -0
  70. package/dist/sillytavern-importer/prompt-order.js.map +1 -0
  71. package/dist/sillytavern-importer/regex.d.ts +3 -0
  72. package/dist/sillytavern-importer/regex.d.ts.map +1 -0
  73. package/dist/sillytavern-importer/regex.js +275 -0
  74. package/dist/sillytavern-importer/regex.js.map +1 -0
  75. package/dist/sillytavern-importer/report.d.ts +21 -0
  76. package/dist/sillytavern-importer/report.d.ts.map +1 -0
  77. package/dist/sillytavern-importer/report.js +166 -0
  78. package/dist/sillytavern-importer/report.js.map +1 -0
  79. package/dist/sillytavern-importer/types.d.ts +106 -0
  80. package/dist/sillytavern-importer/types.d.ts.map +1 -0
  81. package/dist/sillytavern-importer/types.js +2 -0
  82. package/dist/sillytavern-importer/types.js.map +1 -0
  83. package/dist/sillytavern-importer.d.ts +5 -0
  84. package/dist/sillytavern-importer.d.ts.map +1 -0
  85. package/dist/sillytavern-importer.js +117 -0
  86. package/dist/sillytavern-importer.js.map +1 -0
  87. package/dist/slot-renderers.d.ts +25 -0
  88. package/dist/slot-renderers.d.ts.map +1 -0
  89. package/dist/slot-renderers.js +334 -0
  90. package/dist/slot-renderers.js.map +1 -0
  91. package/dist/stack-migration.d.ts +29 -0
  92. package/dist/stack-migration.d.ts.map +1 -0
  93. package/dist/stack-migration.js +126 -0
  94. package/dist/stack-migration.js.map +1 -0
  95. package/dist/storage.d.ts +6 -0
  96. package/dist/storage.d.ts.map +1 -0
  97. package/dist/storage.js +23 -0
  98. package/dist/storage.js.map +1 -0
  99. package/dist/types.d.ts +173 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +2 -0
  102. package/dist/types.js.map +1 -0
  103. package/dist/web-editor/index.d.ts +3 -0
  104. package/dist/web-editor/index.d.ts.map +1 -0
  105. package/dist/web-editor/index.js +2 -0
  106. package/dist/web-editor/index.js.map +1 -0
  107. package/dist/web-editor/page.d.ts +2 -0
  108. package/dist/web-editor/page.d.ts.map +1 -0
  109. package/dist/web-editor/page.js +3331 -0
  110. package/dist/web-editor/page.js.map +1 -0
  111. package/dist/web-editor/server.d.ts +4 -0
  112. package/dist/web-editor/server.d.ts.map +1 -0
  113. package/dist/web-editor/server.js +263 -0
  114. package/dist/web-editor/server.js.map +1 -0
  115. package/dist/web-editor/types.d.ts +125 -0
  116. package/dist/web-editor/types.d.ts.map +1 -0
  117. package/dist/web-editor/types.js +2 -0
  118. package/dist/web-editor/types.js.map +1 -0
  119. package/dist/web-host.d.ts +29 -0
  120. package/dist/web-host.d.ts.map +1 -0
  121. package/dist/web-host.js +174 -0
  122. package/dist/web-host.js.map +1 -0
  123. package/examples/custom-system-status-extension/README.md +78 -0
  124. package/examples/custom-system-status-extension/index.ts +176 -0
  125. package/examples/custom-system-status-extension/prompt-stack.json +39 -0
  126. package/examples/default-prompt-stack.json +52 -93
  127. package/examples/image-reader-prompt-stack.json +124 -0
  128. package/examples/reviewer-prompt-stack.json +115 -0
  129. package/examples/sillytavern-dm-writer-prompt-stack.json +190 -0
  130. package/package.json +30 -9
  131. package/src/compiler.ts +321 -420
  132. package/src/extension-registry.ts +33 -0
  133. package/src/index.ts +310 -1110
  134. package/src/lifecycle.ts +171 -0
  135. package/src/loader.ts +141 -56
  136. package/src/macro-engine.ts +358 -0
  137. package/src/payload-capture.ts +85 -0
  138. package/src/payload-command.ts +138 -0
  139. package/src/policy.ts +42 -0
  140. package/src/preset-command.ts +271 -0
  141. package/src/preview.ts +226 -0
  142. package/src/regex.ts +500 -0
  143. package/src/render-helpers.ts +169 -0
  144. package/src/runtime-state.ts +36 -0
  145. package/src/sillytavern-importer/items.ts +98 -0
  146. package/src/sillytavern-importer/macros.ts +159 -0
  147. package/src/sillytavern-importer/prompt-order.ts +54 -0
  148. package/src/sillytavern-importer/regex.ts +270 -0
  149. package/src/sillytavern-importer/report.ts +202 -0
  150. package/src/sillytavern-importer/types.ts +120 -0
  151. package/src/sillytavern-importer.ts +57 -482
  152. package/src/slot-renderers.ts +414 -0
  153. package/src/stack-migration.ts +159 -0
  154. package/src/storage.ts +28 -0
  155. package/src/types.ts +83 -52
  156. package/src/web-editor/index.ts +2 -0
  157. package/src/web-editor/page.ts +3330 -0
  158. package/src/web-editor/server.ts +294 -0
  159. package/src/web-editor/types.ts +98 -0
  160. package/src/web-host.ts +232 -0
  161. package/src/web-editor.ts +0 -1491
package/src/index.ts CHANGED
@@ -1,113 +1,174 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
- import { buildSessionContext, type BuildSystemPromptOptions, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import { Type } from "typebox";
5
- import {
6
- compileMessages,
7
- compileSystemPrompt,
8
- createPromptVariableStore,
9
- getLatestUserMessage,
10
- markSessionVariablesClean,
11
- renderPreviewMessages,
12
- resetTurnVariables,
13
- } from "./compiler.ts";
14
- import { chooseDefaultStack, isDisabledPromptStackId, loadPromptStacks, promptStacksDir, validatePromptStack } from "./loader.ts";
15
- import { importSillyTavernPreset } from "./sillytavern-importer.ts";
16
- import type { LoadedPromptStack, PromptStack, PromptStackDiagnostic, PromptStateValue, PromptVariableStore } from "./types.ts";
17
- import { DEFAULT_WEB_EDITOR_PORT, startWebEditorServer, type WebEditorCreateStackOptions, type WebEditorHost, type WebEditorServer, type WebEditorStackSummary } from "./web-editor.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";
5
+ import { applyResourcePolicy, hasResourcePolicy } from "./policy.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";
10
+ import { createWebEditorHost, loadWebEditorSettings, type WebHostRuntime } from "./web-host.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";
42
+
43
+ const WEB_EDITOR_GLOBAL_KEY = "__piForgeWebEditor";
44
+
45
+ interface SharedWebEditorState {
46
+ server?: WebEditorServer;
47
+ cwd?: string;
48
+ preferredPort?: number;
49
+ }
18
50
 
19
- const STATE_ENTRY_TYPE = "pi-forge-prompt-stack-state";
20
- const VARIABLE_ENTRY_TYPE = "pi-forge-variable-state";
21
- const AGENT_VAR_PREFIX = "agent.";
51
+ interface SharedWebEditorRegistry {
52
+ byCwd: Record<string, SharedWebEditorState | undefined>;
53
+ }
22
54
 
23
- type StateActor = "agent" | "user";
55
+ type PiForgeGlobal = typeof globalThis & {
56
+ __piForgeWebEditor?: SharedWebEditorRegistry;
57
+ };
24
58
 
25
- interface StateUpdateInput {
26
- name: string;
27
- value: PromptStateValue;
28
- reason?: string;
59
+ function getSharedWebEditorRegistry(): SharedWebEditorRegistry {
60
+ const globalScope = globalThis as PiForgeGlobal;
61
+ globalScope[WEB_EDITOR_GLOBAL_KEY] ??= { byCwd: {} };
62
+ return globalScope[WEB_EDITOR_GLOBAL_KEY];
29
63
  }
30
64
 
31
65
  export default function piForge(pi: ExtensionAPI) {
32
- let stacks: LoadedPromptStack[] = [];
33
- let active: LoadedPromptStack | undefined;
34
- let currentSystemPromptOptions: BuildSystemPromptOptions | undefined;
35
- let currentLatestUserMessage: string | undefined;
36
- let currentVariableStore: PromptVariableStore | undefined;
37
- let contextRewritePending = false;
38
- let sessionVariables: Record<string, PromptStateValue> = {};
39
- let lastPersistedActiveId: string | undefined;
40
- let interceptNextProviderPayload = false;
41
- let interceptPayloadSavePath: string | undefined;
42
- let latestCompileDiagnostics: PromptStackDiagnostic[] = [];
66
+ const sharedWebEditors = getSharedWebEditorRegistry();
67
+ const state = createRuntimeState();
43
68
  let webEditor: WebEditorServer | undefined;
44
69
  let webEditorCwd: string | undefined;
70
+ let webEditorPreferredPort: number | undefined;
71
+ let toolPolicyBaseline: string[] | undefined;
45
72
 
46
73
  function activeId(): string | undefined {
47
- return active?.stack.id;
74
+ return state.active?.stack.id;
48
75
  }
49
76
 
50
77
  function selectedActiveId(): string | undefined {
51
- if (active) return active.stack.id;
52
- return isDisabledPromptStackId(lastPersistedActiveId) ? "none" : undefined;
78
+ return selectedActiveIdForState(state);
53
79
  }
54
80
 
55
81
  function persistActiveSelection(id: string): void {
56
- if (id === lastPersistedActiveId) return;
82
+ if (id === state.lastPersistedActiveId) return;
57
83
  pi.appendEntry(STATE_ENTRY_TYPE, { activeStackId: id });
58
- lastPersistedActiveId = id;
84
+ state.lastPersistedActiveId = id;
59
85
  }
60
86
 
61
87
  function setActive(id: string | undefined, ctx?: ExtensionContext): boolean {
62
88
  if (!id || isDisabledPromptStackId(id)) {
63
- active = undefined;
89
+ state.active = undefined;
64
90
  if (id) persistActiveSelection("none");
65
91
  if (ctx) updateStatus(ctx);
92
+ syncActiveToolPolicy(ctx);
66
93
  return true;
67
94
  }
68
95
 
69
- const found = stacks.find((candidate) => candidate.stack.id === id);
96
+ const found = state.stacks.find((candidate) => candidate.stack.id === id);
70
97
  if (!found) return false;
71
- active = found;
98
+ state.active = found;
72
99
  persistActiveSelection(found.stack.id);
73
100
  if (ctx) updateStatus(ctx);
101
+ syncActiveToolPolicy(ctx);
74
102
  return true;
75
103
  }
76
104
 
77
105
  function reloadStacks(ctx: ExtensionContext, preferredId?: string): void {
78
106
  if (!ctx.isProjectTrusted()) {
79
- stacks = [];
80
- active = undefined;
107
+ state.stacks = [];
108
+ state.active = undefined;
109
+ syncActiveToolPolicy(ctx);
81
110
  ctx.ui.notify("pi-forge: project is not trusted; prompt stacks are disabled.", "warning");
82
111
  updateStatus(ctx);
83
112
  return;
84
113
  }
85
114
 
86
- stacks = loadPromptStacks(ctx.cwd);
87
- active = chooseDefaultStack(stacks, preferredId);
115
+ state.stacks = loadPromptStacks(ctx.cwd);
116
+ state.active = chooseDefaultStack(state.stacks, preferredId);
88
117
  updateStatus(ctx);
118
+ syncActiveToolPolicy(ctx);
89
119
  }
90
120
 
91
121
  function updateStatus(ctx: ExtensionContext): void {
92
- if (active) {
93
- 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));
94
124
  } else {
95
125
  ctx.ui.setStatus("pi-forge", undefined);
96
- latestCompileDiagnostics = [];
126
+ state.latestCompileDiagnostics = [];
97
127
  ctx.ui.setStatus("pi-forge-diagnostics", undefined);
98
128
  }
99
129
  }
100
130
 
131
+ function syncActiveToolPolicy(ctx?: ExtensionContext): void {
132
+ const policy = state.active?.stack.tools;
133
+ if (!hasResourcePolicy(policy)) {
134
+ restoreToolPolicy(ctx);
135
+ return;
136
+ }
137
+
138
+ const baseline = toolPolicyBaseline ?? pi.getActiveTools();
139
+ toolPolicyBaseline ??= [...baseline];
140
+ const nextTools = applyResourcePolicy(filterKnownTools(baseline), policy);
141
+ pi.setActiveTools(nextTools);
142
+ if (ctx) {
143
+ const label = nextTools.length > 0 ? `tools:${nextTools.length}` : "tools:none";
144
+ ctx.ui.setStatus("pi-forge-tools", ctx.ui.theme.fg(nextTools.length > 0 ? "accent" : "warning", label));
145
+ }
146
+ }
147
+
148
+ function restoreToolPolicy(ctx?: ExtensionContext): void {
149
+ if (toolPolicyBaseline) {
150
+ pi.setActiveTools(filterKnownTools(toolPolicyBaseline));
151
+ toolPolicyBaseline = undefined;
152
+ }
153
+ if (ctx) ctx.ui.setStatus("pi-forge-tools", undefined);
154
+ }
155
+
156
+ function filterKnownTools(names: string[]): string[] {
157
+ const known = new Set(pi.getAllTools().map((tool) => tool.name));
158
+ if (known.size === 0) return names;
159
+ return names.filter((name) => known.has(name));
160
+ }
161
+
101
162
  function notifyActivePreset(ctx: ExtensionContext, detail: string): void {
102
- if (!active) return;
103
- const errorCount = active.diagnostics.filter((d) => d.level === "error").length;
104
- 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;
105
166
  const suffix = errorCount || warningCount ? " (" + errorCount + " errors, " + warningCount + " warnings)" : "";
106
- 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");
107
168
  }
108
169
 
109
170
  function recordCompileDiagnostics(ctx: ExtensionContext, diagnostics: PromptStackDiagnostic[]): void {
110
- latestCompileDiagnostics = diagnostics;
171
+ state.latestCompileDiagnostics = diagnostics;
111
172
  const errors = diagnostics.filter((d) => d.level === "error").length;
112
173
  const warnings = diagnostics.filter((d) => d.level === "warning").length;
113
174
  if (errors || warnings) {
@@ -117,1114 +178,253 @@ export default function piForge(pi: ExtensionAPI) {
117
178
  ctx.ui.setStatus("pi-forge-diagnostics", undefined);
118
179
  }
119
180
 
120
- function stackSummary(loaded: LoadedPromptStack): WebEditorStackSummary {
121
- const errors = loaded.diagnostics.filter((d) => d.level === "error").length;
122
- const warnings = loaded.diagnostics.filter((d) => d.level === "warning").length;
181
+ function webHostRuntime(ctx: ExtensionCommandContext): WebHostRuntime {
123
182
  return {
124
- id: loaded.stack.id,
125
- name: loaded.stack.name,
126
- filePath: loaded.filePath,
127
- active: loaded === active,
128
- autoActivate: loaded.stack.autoActivate,
129
- mode: loaded.stack.mode ?? "replace",
130
- itemCount: loaded.stack.items.length,
131
- errors,
132
- warnings,
133
- diagnostics: loaded.diagnostics,
183
+ getStacks: () => state.stacks,
184
+ getActive: () => state.active,
185
+ getActiveId: activeId,
186
+ getSelectedActiveId: selectedActiveId,
187
+ setActive: (id) => setActive(id, ctx),
188
+ reloadStacks: (preferredId) => reloadStacks(ctx, preferredId),
189
+ buildPreview: (target) => buildPreview(ctx, target, state.sessionVariables, previewOptionsForStack(ctx, target.stack)),
190
+ getPolicyResources: () => getPolicyResources(ctx),
191
+ getPayload: () => ({ ok: true, ...webPayloadSnapshot(state) }),
192
+ armPayload: (savePath) => {
193
+ armPayloadIntercept(state, ctx, savePath, "web");
194
+ return { ok: true, ...webPayloadSnapshot(state) };
195
+ },
196
+ clearPayload: () => {
197
+ clearPayloadCapture(state, ctx);
198
+ return { ok: true, ...webPayloadSnapshot(state) };
199
+ },
134
200
  };
135
201
  }
136
202
 
137
- function stackSummaries(): WebEditorStackSummary[] {
138
- return stacks.map(stackSummary);
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
+ };
139
269
  }
140
270
 
141
- function createWebEditorHost(ctx: ExtensionCommandContext): WebEditorHost {
271
+ function normalizeSkillResource(skill: { name?: unknown; description?: unknown; filePath?: unknown; disableModelInvocation?: unknown }): WebEditorPolicyResource {
142
272
  return {
143
- cwd: ctx.cwd,
144
- listStacks: () => stackSummaries(),
145
- getStack: (id) => {
146
- const loaded = stacks.find((candidate) => candidate.stack.id === id);
147
- return loaded ? { stack: loaded.stack, filePath: loaded.filePath, diagnostics: loaded.diagnostics } : undefined;
148
- },
149
- createStack: (stack, options) => createStackFile(ctx, stack, options),
150
- saveStack: (id, stack) => {
151
- if (!ctx.isProjectTrusted()) {
152
- return { ok: false, status: 403, error: "Project is not trusted; refusing to save prompt stacks." };
153
- }
273
+ name: String(skill.name ?? ""),
274
+ description: stringValue(skill.description),
275
+ source: stringValue(skill.filePath),
276
+ hidden: skill.disableModelInvocation === true,
277
+ };
278
+ }
154
279
 
155
- const target = stacks.find((candidate) => candidate.stack.id === id);
156
- if (!target) return { ok: false, status: 404, error: `Unknown prompt stack: ${id}` };
157
- if (!isInsidePromptStacksDir(ctx.cwd, target.filePath)) {
158
- return { ok: false, status: 403, error: "Refusing to save outside .pi/prompt-stacks." };
159
- }
280
+ function stringValue(value: unknown): string | undefined {
281
+ return typeof value === "string" && value.trim() ? value : undefined;
282
+ }
160
283
 
161
- writeFileSync(target.filePath, `${JSON.stringify(stack, null, 2)}\n`, "utf8");
162
- const preferredId = active?.stack.id === id ? stack.id : selectedActiveId();
163
- reloadStacks(ctx, preferredId);
164
- const saved = stacks.find((candidate) => candidate.stack.id === stack.id) ?? stacks.find((candidate) => candidate.filePath === target.filePath);
165
- if (!saved) return { ok: false, status: 500, error: "Saved stack could not be reloaded." };
166
- return { ok: true, stack: stackSummary(saved), stacks: stackSummaries() };
167
- },
168
- deleteStack: (id) => deleteStackFile(ctx, id),
169
- validateStack: (stack) => validatePromptStack(stack),
170
- previewStack: (id, stack) => {
171
- const target = stacks.find((candidate) => candidate.stack.id === id);
172
- if (!target) return { ok: false, status: 404, error: `Unknown prompt stack: ${id}` };
173
- const diagnostics = validatePromptStack(stack);
174
- const text = renderPreview(ctx, { stack, filePath: target.filePath, diagnostics });
175
- return { ok: true, text, diagnostics };
176
- },
177
- activateStack: (id) => {
178
- if (!setActive(id, ctx)) return { ok: false, status: 404, error: `Unknown prompt stack: ${id}` };
179
- return { ok: true, activeId: activeId(), stacks: stackSummaries() };
180
- },
181
- disableStacks: () => {
182
- setActive("none", ctx);
183
- return { ok: true, activeId: activeId(), stacks: stackSummaries() };
184
- },
185
- reloadStacks: () => {
186
- reloadStacks(ctx, selectedActiveId());
187
- return { ok: true, activeId: activeId(), stacks: stackSummaries() };
188
- },
189
- };
284
+ function stringArrayValue(value: unknown): string[] {
285
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && !!item.trim()) : [];
190
286
  }
191
287
 
192
- function createStackFile(
193
- ctx: ExtensionCommandContext,
194
- stack: PromptStack,
195
- options: WebEditorCreateStackOptions,
196
- ): { ok: true; stack: WebEditorStackSummary; stacks: WebEditorStackSummary[] } | { ok: false; status?: number; error: string } {
197
- if (!ctx.isProjectTrusted()) {
198
- return { ok: false, status: 403, error: "Project is not trusted; refusing to create prompt stacks." };
199
- }
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
+ }
200
295
 
201
- const idError = validateWebStackId(stack.id);
202
- if (idError) return { ok: false, status: 400, error: idError };
296
+ function comparePolicyResource(a: WebEditorPolicyResource, b: WebEditorPolicyResource): number {
297
+ return a.name.localeCompare(b.name);
298
+ }
203
299
 
204
- const stacksDir = promptStacksDir(ctx.cwd);
205
- const targetPath = join(stacksDir, `${stack.id}.json`);
206
- if (!isInsidePromptStacksDir(ctx.cwd, targetPath)) {
207
- return { ok: false, status: 403, error: "Refusing to create outside .pi/prompt-stacks." };
208
- }
300
+ function hasPolicyResourceName(resource: WebEditorPolicyResource): boolean {
301
+ return !!resource.name.trim();
302
+ }
209
303
 
210
- const existingById = stacks.find((candidate) => candidate.stack.id === stack.id);
211
- if (existingById && resolve(existingById.filePath) !== resolve(targetPath)) {
212
- return { ok: false, status: 409, error: `Stack id already exists in ${existingById.filePath}.` };
213
- }
214
- if ((existsSync(targetPath) || existingById) && !options.overwrite) {
215
- return { ok: false, status: 409, error: `Prompt stack already exists: ${stack.id}` };
216
- }
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
+ }
217
309
 
218
- const previousSelection = selectedActiveId();
219
- mkdirSync(stacksDir, { recursive: true });
220
- writeFileSync(targetPath, `${JSON.stringify(stack, null, 2)}\n`, "utf8");
221
- reloadStacks(ctx, options.activate ? stack.id : (previousSelection ?? "none"));
222
- if (options.activate) setActive(stack.id, ctx);
310
+ function sharedWebEditorForCwd(cwd: string): SharedWebEditorState {
311
+ sharedWebEditors.byCwd[cwd] ??= {};
312
+ return sharedWebEditors.byCwd[cwd];
313
+ }
223
314
 
224
- const created = stacks.find((candidate) => candidate.stack.id === stack.id);
225
- if (!created) return { ok: false, status: 500, error: "Created stack could not be reloaded." };
226
- return { ok: true, stack: stackSummary(created), stacks: stackSummaries() };
315
+ function syncWebEditorFromShared(cwd: string): void {
316
+ const shared = sharedWebEditorForCwd(cwd);
317
+ webEditor = shared.server;
318
+ webEditorCwd = shared.cwd;
319
+ webEditorPreferredPort = shared.preferredPort;
227
320
  }
228
321
 
229
- function deleteStackFile(
230
- ctx: ExtensionCommandContext,
231
- id: string,
232
- ): { ok: true; activeId?: string; stacks: WebEditorStackSummary[] } | { ok: false; status?: number; error: string } {
233
- if (!ctx.isProjectTrusted()) {
234
- return { ok: false, status: 403, error: "Project is not trusted; refusing to delete prompt stacks." };
235
- }
322
+ function rememberWebEditor(server: WebEditorServer, cwd: string, preferredPort: number | undefined): void {
323
+ const shared = sharedWebEditorForCwd(cwd);
324
+ webEditor = server;
325
+ webEditorCwd = cwd;
326
+ webEditorPreferredPort = preferredPort;
327
+ shared.server = server;
328
+ shared.cwd = cwd;
329
+ shared.preferredPort = preferredPort;
330
+ }
236
331
 
237
- const target = stacks.find((candidate) => candidate.stack.id === id);
238
- if (!target) return { ok: false, status: 404, error: `Unknown prompt stack: ${id}` };
239
- if (!isInsidePromptStacksDir(ctx.cwd, target.filePath)) {
240
- return { ok: false, status: 403, error: "Refusing to delete outside .pi/prompt-stacks." };
332
+ function clearWebEditor(server: WebEditorServer): void {
333
+ if (webEditor === server) {
334
+ webEditor = undefined;
335
+ webEditorCwd = undefined;
336
+ webEditorPreferredPort = undefined;
241
337
  }
242
-
243
- const wasActive = active?.stack.id === id;
244
- unlinkSync(target.filePath);
245
- if (wasActive) {
246
- setActive("none", ctx);
247
- reloadStacks(ctx, "none");
248
- } else {
249
- reloadStacks(ctx, selectedActiveId());
338
+ for (const [cwd, shared] of Object.entries(sharedWebEditors.byCwd)) {
339
+ if (shared?.server === server) delete sharedWebEditors.byCwd[cwd];
250
340
  }
251
- return { ok: true, activeId: activeId(), stacks: stackSummaries() };
252
341
  }
253
342
 
254
- function validateWebStackId(id: string): string | undefined {
255
- if (!id.trim()) return "Stack id must not be empty.";
256
- if (!/^[A-Za-z0-9_-]+$/.test(id)) {
257
- return "Stack id may only contain letters, numbers, underscore, and dash.";
258
- }
259
- return undefined;
343
+ function refreshWebEditorHost(ctx: ExtensionContext): void {
344
+ syncWebEditorFromShared(ctx.cwd);
345
+ if (!webEditor) return;
346
+ const commandCtx = ctx as ExtensionCommandContext;
347
+ webEditor.updateHost(createWebEditorHost(commandCtx, webHostRuntime(commandCtx)));
348
+ rememberWebEditor(webEditor, ctx.cwd, webEditorPreferredPort);
349
+ ctx.ui.setStatus("pi-forge-editor", ctx.ui.theme.fg("accent", `editor:${webEditor.port}`));
260
350
  }
261
351
 
262
352
  async function openWebEditor(ctx: ExtensionCommandContext, mode: "open" | "restart" = "open"): Promise<void> {
353
+ syncWebEditorFromShared(ctx.cwd);
263
354
  const settings = loadWebEditorSettings(ctx);
264
355
  for (const warning of settings.warnings) ctx.ui.notify(warning, "warning");
265
356
 
266
- if (webEditor && (mode === "restart" || webEditorCwd !== ctx.cwd || webEditor.port !== settings.port)) {
267
- await webEditor.close();
268
- webEditor = undefined;
269
- webEditorCwd = undefined;
357
+ if (webEditor && (mode === "restart" || webEditorPreferredPort !== settings.preferredPort)) {
358
+ const server = webEditor;
359
+ await server.close();
360
+ clearWebEditor(server);
270
361
  ctx.ui.setStatus("pi-forge-editor", undefined);
271
362
  }
272
363
 
273
364
  if (!webEditor) {
274
365
  try {
275
- webEditor = await startWebEditorServer(createWebEditorHost(ctx), { port: settings.port });
366
+ webEditor = await startWebEditorServer(createWebEditorHost(ctx, webHostRuntime(ctx)), { port: settings.preferredPort });
276
367
  } catch (error) {
277
- const detail = error instanceof Error ? error.message : String(error);
278
- ctx.ui.setStatus("pi-forge-editor", undefined);
279
- ctx.ui.notify(`pi-forge: failed to start stack editor on 127.0.0.1:${settings.port}: ${detail}. Change ${settings.configPath} or stop the process using that port.`, "error");
280
- return;
368
+ if (settings.preferredPort !== undefined) {
369
+ const detail = error instanceof Error ? error.message : String(error);
370
+ ctx.ui.notify(`pi-forge: preferred editor port 127.0.0.1:${settings.preferredPort} was unavailable (${detail}); using an available port instead.`, "warning");
371
+ try {
372
+ webEditor = await startWebEditorServer(createWebEditorHost(ctx, webHostRuntime(ctx)));
373
+ } catch (fallbackError) {
374
+ const fallbackDetail = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
375
+ ctx.ui.setStatus("pi-forge-editor", undefined);
376
+ ctx.ui.notify(`pi-forge: failed to start stack editor on an available localhost port: ${fallbackDetail}.`, "error");
377
+ return;
378
+ }
379
+ } else {
380
+ const detail = error instanceof Error ? error.message : String(error);
381
+ ctx.ui.setStatus("pi-forge-editor", undefined);
382
+ ctx.ui.notify(`pi-forge: failed to start stack editor on an available localhost port: ${detail}.`, "error");
383
+ return;
384
+ }
281
385
  }
282
- webEditorCwd = ctx.cwd;
386
+ rememberWebEditor(webEditor, ctx.cwd, settings.preferredPort);
283
387
  ctx.ui.setStatus("pi-forge-editor", ctx.ui.theme.fg("accent", `editor:${webEditor.port}`));
284
388
  ctx.ui.notify(`pi-forge: stack editor running at ${webEditor.url}`, "info");
285
389
  } else {
390
+ webEditor.updateHost(createWebEditorHost(ctx, webHostRuntime(ctx)));
391
+ rememberWebEditor(webEditor, ctx.cwd, settings.preferredPort);
392
+ ctx.ui.setStatus("pi-forge-editor", ctx.ui.theme.fg("accent", `editor:${webEditor.port}`));
286
393
  ctx.ui.notify(`pi-forge: stack editor already running at ${webEditor.url}`, "info");
287
394
  }
288
395
 
289
396
  await showText(ctx, "pi-forge stack editor", `Open the local stack editor:\n\n${webEditor.url}\n\nServer bound to 127.0.0.1:${webEditor.port}\nOptional config: ${settings.configPath}\nProject: ${webEditorCwd}`);
290
397
  }
291
398
 
292
- function loadWebEditorSettings(ctx: ExtensionCommandContext): { port: number; configPath: string; warnings: string[] } {
293
- const configPath = join(ctx.cwd, ".pi", "forge", "config.json");
294
- if (!ctx.isProjectTrusted() || !existsSync(configPath)) {
295
- return { port: DEFAULT_WEB_EDITOR_PORT, configPath, warnings: [] };
296
- }
297
-
298
- let raw: unknown;
299
- try {
300
- raw = JSON.parse(readFileSync(configPath, "utf8"));
301
- } catch (error) {
302
- return {
303
- port: DEFAULT_WEB_EDITOR_PORT,
304
- configPath,
305
- warnings: [`pi-forge: failed to read ${configPath}; using editor port ${DEFAULT_WEB_EDITOR_PORT}. ${error instanceof Error ? error.message : String(error)}`],
306
- };
307
- }
308
-
309
- if (!isPlainObject(raw)) {
310
- return {
311
- port: DEFAULT_WEB_EDITOR_PORT,
312
- configPath,
313
- warnings: [`pi-forge: ${configPath} must be a JSON object; using editor port ${DEFAULT_WEB_EDITOR_PORT}.`],
314
- };
315
- }
316
-
317
- const webEditorConfig = isPlainObject(raw.webEditor) ? raw.webEditor : undefined;
318
- const rawPort = webEditorConfig?.port ?? raw.webEditorPort;
319
- if (rawPort === undefined) return { port: DEFAULT_WEB_EDITOR_PORT, configPath, warnings: [] };
320
-
321
- if (typeof rawPort === "number" && Number.isInteger(rawPort) && rawPort >= 1 && rawPort <= 65535) {
322
- return { port: rawPort, configPath, warnings: [] };
323
- }
324
-
325
- return {
326
- port: DEFAULT_WEB_EDITOR_PORT,
327
- configPath,
328
- warnings: [`pi-forge: ${configPath} webEditor.port must be an integer from 1 to 65535; using editor port ${DEFAULT_WEB_EDITOR_PORT}.`],
329
- };
330
- }
331
-
332
- function isPlainObject(value: unknown): value is Record<string, unknown> {
333
- return !!value && typeof value === "object" && !Array.isArray(value);
334
- }
335
-
336
399
  async function stopWebEditor(ctx: ExtensionCommandContext): Promise<void> {
400
+ syncWebEditorFromShared(ctx.cwd);
337
401
  if (!webEditor) {
338
402
  ctx.ui.notify("pi-forge: stack editor is not running.", "info");
339
403
  return;
340
404
  }
341
- await webEditor.close();
342
- webEditor = undefined;
343
- webEditorCwd = undefined;
405
+ const server = webEditor;
406
+ await server.close();
407
+ clearWebEditor(server);
344
408
  ctx.ui.setStatus("pi-forge-editor", undefined);
345
409
  ctx.ui.notify("pi-forge: stack editor stopped.", "info");
346
410
  }
347
411
 
348
- function isInsidePromptStacksDir(cwd: string, filePath: string): boolean {
349
- const root = resolve(promptStacksDir(cwd));
350
- const target = resolve(filePath);
351
- const rel = relative(root, target);
352
- return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
353
- }
354
-
355
- function getCurrentBranchEntries(ctx: ExtensionContext): unknown[] {
356
- const leafId = ctx.sessionManager.getLeafId();
357
- if (leafId === null) return [];
358
- const sessionManager = ctx.sessionManager as {
359
- getBranch?: (fromId?: string) => unknown[];
360
- getEntries: () => unknown[];
361
- };
362
- return sessionManager.getBranch ? sessionManager.getBranch(leafId ?? undefined) : sessionManager.getEntries();
363
- }
364
-
365
- function getRestoredActiveId(ctx: ExtensionContext): string | undefined {
366
- const entries = getCurrentBranchEntries(ctx);
367
- for (let i = entries.length - 1; i >= 0; i--) {
368
- const entry = entries[i] as { type?: string; customType?: string; data?: { activeStackId?: unknown } };
369
- if (entry.type === "custom" && entry.customType === STATE_ENTRY_TYPE) {
370
- return typeof entry.data?.activeStackId === "string" ? entry.data.activeStackId : undefined;
371
- }
372
- }
373
- return undefined;
374
- }
375
-
376
- function getRestoredVariables(ctx: ExtensionContext): Record<string, PromptStateValue> {
377
- const entries = getCurrentBranchEntries(ctx);
378
- for (let i = entries.length - 1; i >= 0; i--) {
379
- const entry = entries[i] as { type?: string; customType?: string; data?: { variables?: unknown } };
380
- if (entry.type !== "custom" || entry.customType !== VARIABLE_ENTRY_TYPE) continue;
381
- if (!entry.data || typeof entry.data.variables !== "object" || Array.isArray(entry.data.variables)) return {};
382
- return normalizeStateRecord(entry.data.variables as Record<string, unknown>);
383
- }
384
- return {};
385
- }
386
-
387
- function persistVariablesIfDirty(store: PromptVariableStore | undefined): void {
388
- if (!store?.sessionDirty) return;
389
- sessionVariables = { ...store.session };
390
- pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: sessionVariables });
391
- markSessionVariablesClean(store);
392
- }
393
-
394
- function normalizeStateRecord(value: Record<string, unknown>): Record<string, PromptStateValue> {
395
- const result: Record<string, PromptStateValue> = {};
396
- for (const [key, raw] of Object.entries(value)) {
397
- if (isPromptStateValue(raw)) result[key] = raw;
398
- }
399
- return result;
400
- }
401
-
402
- function isPromptStateValue(value: unknown): value is PromptStateValue {
403
- if (value === null) return true;
404
- const type = typeof value;
405
- if (type === "string" || type === "boolean") return true;
406
- if (type === "number") return Number.isFinite(value);
407
- if (Array.isArray(value)) return value.every(isPromptStateValue);
408
- if (!value || typeof value !== "object") return false;
409
- return Object.values(value as Record<string, unknown>).every(isPromptStateValue);
410
- }
411
-
412
- function validateStateName(name: string): string | undefined {
413
- if (!name.trim()) return "state name must not be empty";
414
- if (!/^[A-Za-z0-9_.:-]+$/.test(name)) {
415
- return "state name may only contain letters, numbers, underscore, dash, dot, and colon";
416
- }
417
- return undefined;
418
- }
419
-
420
- function validateStateUpdate(update: StateUpdateInput, actor: StateActor): string | undefined {
421
- const permissionError = validateStateWritePermission(update.name, actor, actor === "agent" ? "write" : "set");
422
- if (permissionError) return permissionError;
423
-
424
- const definition = active?.stack.state?.definitions?.[update.name];
425
- if (definition?.type) {
426
- const typeError = validateStateValueType(update.value, definition.type);
427
- if (typeError) return `${update.name}: expected ${definition.type}, got ${typeError}`;
428
- }
429
- return undefined;
430
- }
431
-
432
- function validateStateClear(name: string, actor: StateActor): string | undefined {
433
- return validateStateWritePermission(name, actor, "clear");
434
- }
435
-
436
- function validateStateWritePermission(name: string, actor: StateActor, action: "set" | "write" | "clear"): string | undefined {
437
- const nameError = validateStateName(name);
438
- if (nameError) return `${name || "(empty)"}: ${nameError}`;
439
-
440
- if (actor === "agent" && !name.startsWith(AGENT_VAR_PREFIX)) {
441
- return `${name}: agents may only ${action} ${AGENT_VAR_PREFIX}* state`;
442
- }
443
-
444
- const definition = active?.stack.state?.definitions?.[name];
445
- if (actor === "agent" && definition?.agentWritable === false) {
446
- return `${name}: stack schema marks this state as not agent-writable`;
447
- }
448
- if (actor === "user" && definition?.userWritable === false) {
449
- return `${name}: stack schema marks this state as not user-writable`;
450
- }
451
- return undefined;
452
- }
453
-
454
- function applyStatePatch(updates: StateUpdateInput[], clears: string[], actor: StateActor): { ok: true; updated: number; cleared: number } | { ok: false; error: string } {
455
- for (const update of updates) {
456
- const error = validateStateUpdate(update, actor);
457
- if (error) return { ok: false, error };
458
- }
459
- for (const name of clears) {
460
- const error = validateStateClear(name, actor);
461
- if (error) return { ok: false, error };
462
- }
463
-
464
- for (const update of updates) {
465
- sessionVariables[update.name] = update.value;
466
- if (currentVariableStore) currentVariableStore.session[update.name] = update.value;
467
- }
468
- for (const name of clears) {
469
- delete sessionVariables[name];
470
- if (currentVariableStore) delete currentVariableStore.session[name];
471
- }
472
-
473
- pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: { ...sessionVariables } });
474
- if (currentVariableStore) markSessionVariablesClean(currentVariableStore);
475
- return { ok: true, updated: updates.length, cleared: clears.length };
476
- }
477
-
478
- function validateStateValueType(value: PromptStateValue, typeExpression: string): string | undefined {
479
- const types = typeExpression.split("|").map((part) => part.trim()).filter(Boolean);
480
- if (types.length === 0 || types.some((type) => stateValueMatchesType(value, type))) return undefined;
481
- return inferRuntimeType(value);
482
- }
483
-
484
- function stateValueMatchesType(value: PromptStateValue, type: string): boolean {
485
- if (type === "any" || type === "unknown" || type === "json" || type === "Json") return true;
486
- if (type === "null") return value === null;
487
- if (type === "array" || type === "unknown[]") return Array.isArray(value);
488
- if (type === "object" || type === "Record<string, unknown>" || type === "Record<string, any>") {
489
- return !!value && typeof value === "object" && !Array.isArray(value);
490
- }
491
- if (type.endsWith("[]")) {
492
- if (!Array.isArray(value)) return false;
493
- const itemType = type.slice(0, -2).trim();
494
- return value.every((item) => stateValueMatchesType(item, itemType));
495
- }
496
- return typeof value === type;
497
- }
498
-
499
- function inferRuntimeType(value: PromptStateValue): string {
500
- if (value === null) return "null";
501
- if (Array.isArray(value)) return "array";
502
- return typeof value;
503
- }
504
-
505
- function parseStateCommandValue(raw: string): PromptStateValue {
506
- const trimmed = raw.trim();
507
- if (/^(true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|["[{])/.test(trimmed)) {
508
- try {
509
- const parsed = JSON.parse(trimmed);
510
- if (isPromptStateValue(parsed)) return parsed;
511
- } catch {
512
- // Fall back to string for friendly command input.
513
- }
514
- }
515
- return raw;
516
- }
517
-
518
- function formatStateValue(value: PromptStateValue): string {
519
- return typeof value === "string" ? value : JSON.stringify(value, null, 2);
520
- }
521
-
522
- function restoreBranchScopedState(ctx: ExtensionContext): void {
523
- sessionVariables = getRestoredVariables(ctx);
524
- currentVariableStore = undefined;
525
- const restoredActiveId = getRestoredActiveId(ctx);
526
- lastPersistedActiveId = restoredActiveId;
527
- reloadStacks(ctx, restoredActiveId);
528
- }
529
-
530
- pi.on("session_start", async (event, ctx) => {
531
- restoreBranchScopedState(ctx);
532
- notifyActivePreset(ctx, "after session " + event.reason);
533
- });
534
-
535
- pi.on("session_tree", async (_event, ctx) => {
536
- restoreBranchScopedState(ctx);
537
- notifyActivePreset(ctx, "after tree navigation");
538
- });
539
-
540
- pi.on("session_compact", async (_event, ctx) => {
541
- restoreBranchScopedState(ctx);
542
- notifyActivePreset(ctx, "after compaction");
543
- });
544
-
545
- pi.on("turn_start", async () => {
546
- const id = activeId();
547
- if (id) persistActiveSelection(id);
548
- });
549
-
550
- pi.on("before_agent_start", async (event, ctx) => {
551
- currentSystemPromptOptions = event.systemPromptOptions;
552
- currentLatestUserMessage = event.prompt;
553
- currentVariableStore = createPromptVariableStore(sessionVariables);
554
- resetTurnVariables(currentVariableStore);
555
- contextRewritePending = true;
556
-
557
- if (!active) return;
558
-
559
- const result = compileSystemPrompt(
560
- active.stack,
561
- { options: event.systemPromptOptions, ctx, latestUserMessage: event.prompt, now: new Date(), variables: currentVariableStore },
562
- event.systemPrompt,
563
- );
564
- recordCompileDiagnostics(ctx, result.diagnostics);
565
- persistVariablesIfDirty(currentVariableStore);
566
-
567
- return { systemPrompt: result.systemPrompt };
568
- });
569
-
570
- pi.on("context", async (event, ctx) => {
571
- if (!active || !currentSystemPromptOptions || !contextRewritePending) return;
572
-
573
- // Rewrite the message layout only for the first provider request of a user-submitted prompt.
574
- // Tool-result follow-up turns must receive Pi's natural context; otherwise post-history
575
- // prompt blocks such as COT / {{lastUserMessage}} are re-appended after every tool call
576
- // and the model restarts its planning instead of continuing from the tool result.
577
- contextRewritePending = false;
578
-
579
- if (!currentVariableStore) currentVariableStore = createPromptVariableStore(sessionVariables);
580
- const latestUserMessage = getLatestUserMessage(event.messages) ?? currentLatestUserMessage;
581
- const result = compileMessages(
582
- active.stack,
583
- { options: currentSystemPromptOptions, ctx, latestUserMessage, now: new Date(), variables: currentVariableStore },
584
- event.messages,
585
- );
586
- recordCompileDiagnostics(ctx, [...latestCompileDiagnostics, ...result.diagnostics]);
587
- persistVariablesIfDirty(currentVariableStore);
588
- return { messages: result.messages };
589
- });
590
-
591
- pi.on("agent_end", async () => {
592
- persistVariablesIfDirty(currentVariableStore);
593
- currentSystemPromptOptions = undefined;
594
- currentLatestUserMessage = undefined;
595
- currentVariableStore = undefined;
596
- contextRewritePending = false;
597
- });
598
-
599
- pi.on("before_provider_request", async (event, ctx) => {
600
- if (!interceptNextProviderPayload) return;
601
- const savePath = interceptPayloadSavePath;
602
- interceptNextProviderPayload = false;
603
- interceptPayloadSavePath = undefined;
604
- ctx.ui.setStatus("pi-forge-intercept", undefined);
605
-
606
- const payload = safeStringify(event.payload);
607
- const approxTokens = estimatePayloadTokens(payload);
608
- if (savePath) {
609
- if (!ctx.isProjectTrusted()) {
610
- ctx.ui.notify("pi-forge: project is not trusted; refusing to save provider payload.", "warning");
611
- } else {
612
- const resolvedPath = savePath.startsWith("/") ? savePath : join(ctx.cwd, savePath);
613
- mkdirSync(dirname(resolvedPath), { recursive: true });
614
- writeFileSync(resolvedPath, payload, "utf8");
615
- ctx.ui.notify(`pi-forge: provider payload saved to ${resolvedPath} (${payload.length} chars, ~${approxTokens} tokens)`, "info");
616
- }
617
- }
618
-
619
- if (ctx.hasUI) {
620
- await ctx.ui.editor(`pi-forge: provider payload (${payload.length} chars, ~${approxTokens} tokens)`, payload);
621
- return;
622
- }
623
-
624
- console.log(payload);
625
- });
626
-
627
- pi.registerTool({
628
- name: "forge_state_set",
629
- label: "Set Prompt State",
630
- description: "Batch update persistent prompt state for future turns. Only names starting with 'agent.' can be written by the agent.",
631
- promptSnippet: "Batch update agent-scoped prompt state for cross-turn continuity. Use this when durable state should be visible in the prompt_state slot on future turns.",
632
- promptGuidelines: [
633
- "Use forge_state_set to persist concise cross-turn state such as task progress, story state, open questions, or durable facts the user asked you to remember.",
634
- `Only state names starting with '${AGENT_VAR_PREFIX}' are writable by the agent. User and stack configuration state are read-only to the agent.`,
635
- "Prefer one batch update at natural checkpoints instead of many small updates. Do not store secrets or large transcripts.",
636
- "State written by this tool is primarily for future turns; the current prompt has already been built.",
637
- ],
638
- parameters: Type.Object({
639
- updates: Type.Optional(Type.Array(Type.Object({
640
- name: Type.String({ description: `State name (must start with '${AGENT_VAR_PREFIX}')` }),
641
- value: Type.Unknown({ description: "JSON-compatible value: string, number, boolean, null, array, or object" }),
642
- reason: Type.Optional(Type.String({ description: "Brief reason for the update" })),
643
- }))),
644
- clears: Type.Optional(Type.Array(Type.String({ description: `State names to clear (must start with '${AGENT_VAR_PREFIX}')` }))),
645
- }),
646
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
647
- const updatesRaw = Array.isArray(params.updates) ? params.updates : [];
648
- const clears = Array.isArray(params.clears) ? params.clears.filter((name): name is string => typeof name === "string") : [];
649
- const updates: StateUpdateInput[] = [];
650
-
651
- for (const raw of updatesRaw) {
652
- if (!raw || typeof raw !== "object") {
653
- return {
654
- content: [{ type: "text", text: "Error: every update must be an object." }],
655
- details: { error: "invalid update" },
656
- };
657
- }
658
- const update = raw as { name?: unknown; value?: unknown; reason?: unknown };
659
- if (typeof update.name !== "string") {
660
- return {
661
- content: [{ type: "text", text: "Error: every update needs a string name." }],
662
- details: { error: "invalid update name" },
663
- };
664
- }
665
- if (!isPromptStateValue(update.value)) {
666
- return {
667
- content: [{ type: "text", text: `Error: ${update.name} value is not JSON-compatible.` }],
668
- details: { error: "invalid value", name: update.name },
669
- };
670
- }
671
- updates.push({
672
- name: update.name,
673
- value: update.value,
674
- reason: typeof update.reason === "string" ? update.reason : undefined,
675
- });
676
- }
677
-
678
- if (updates.length === 0 && clears.length === 0) {
679
- return {
680
- content: [{ type: "text", text: "No state updates or clears provided." }],
681
- details: { updated: 0, cleared: 0 },
682
- };
683
- }
684
-
685
- const result = applyStatePatch(updates, clears, "agent");
686
- if (!result.ok) {
687
- return {
688
- content: [{ type: "text", text: `Error: ${result.error}` }],
689
- details: { error: result.error },
690
- };
691
- }
692
-
693
- return {
694
- content: [{ type: "text", text: `State updated: ${result.updated} set, ${result.cleared} cleared.` }],
695
- details: { updated: updates.map(({ name, value, reason }) => ({ name, value, reason })), cleared: clears },
696
- };
697
- },
698
- });
699
-
700
- pi.registerTool({
701
- name: "forge_set_var",
702
- label: "Set Prompt Variable",
703
- description: "Compatibility alias for forge_state_set. Set one persistent agent-scoped state value. Only variables starting with 'agent.' can be written by the agent.",
704
- promptSnippet: "Compatibility alias for forge_state_set. Prefer forge_state_set for batch prompt state updates.",
705
- promptGuidelines: [
706
- "Prefer forge_state_set when updating prompt state. forge_set_var only exists for compatibility with older prompt stacks.",
707
- `Only variable names starting with '${AGENT_VAR_PREFIX}' are writable.`,
708
- ],
709
- parameters: Type.Object({
710
- name: Type.String({ description: `Variable name (must start with '${AGENT_VAR_PREFIX}')` }),
711
- value: Type.String({ description: "Variable value" }),
712
- }),
713
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
714
- const result = applyStatePatch([{ name: params.name, value: params.value }], [], "agent");
715
- if (!result.ok) {
716
- return {
717
- content: [{ type: "text", text: `Error: ${result.error}` }],
718
- details: { error: result.error, name: params.name },
719
- };
720
- }
721
- return {
722
- content: [{ type: "text", text: `Variable ${params.name} set.` }],
723
- details: { name: params.name, value: params.value },
724
- };
725
- },
726
- });
727
-
728
- function armPayloadIntercept(ctx: ExtensionCommandContext, savePath?: string): void {
729
- interceptNextProviderPayload = true;
730
- interceptPayloadSavePath = savePath;
731
- ctx.ui.setStatus("pi-forge-intercept", ctx.ui.theme.fg("warning", savePath ? "payload:armed+save" : "payload:armed"));
732
- 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");
733
- }
734
-
735
- pi.registerCommand("intercept", {
736
- description: "Display the next provider payload before it is sent",
737
- handler: async (_args, ctx) => {
738
- armPayloadIntercept(ctx);
739
- },
740
- });
741
-
742
- pi.registerCommand("payload", {
743
- description: "Inspect or save provider payloads: /payload next [save=<path>]",
744
- getArgumentCompletions: (prefix) => {
745
- const parts = prefix.trimStart().split(/\s+/);
746
- if (parts.length <= 1 && !prefix.endsWith(" ")) {
747
- return ["next"].filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
748
- }
749
- if (parts[0] === "next" && parts.length <= 2) {
750
- const suggestion = "save=.pi/forge/payloads/last.json";
751
- return suggestion.startsWith(parts[1] ?? "") ? [{ value: `next ${suggestion}`, label: suggestion }] : null;
752
- }
753
- return null;
754
- },
755
- handler: async (args, ctx) => {
756
- const [command = "next", ...rest] = args.trim() ? args.trim().split(/\s+/) : ["next"];
757
- if (command !== "next") {
758
- ctx.ui.notify(`Unknown /payload subcommand: ${command}`, "warning");
759
- return;
760
- }
761
- const saveArg = rest.find((arg) => arg.startsWith("save="));
762
- const savePath = saveArg?.slice("save=".length).trim() || undefined;
763
- armPayloadIntercept(ctx, savePath);
764
- },
765
- });
766
-
767
- pi.registerCommand("preset", {
768
- description: "Manage pi-forge prompt stacks: list, use, preview, validate, reload, vars, ui",
769
- getArgumentCompletions: (prefix) => {
770
- const parts = prefix.trimStart().split(/\s+/);
771
- if (parts.length <= 1 && !prefix.endsWith(" ")) {
772
- const commands = ["list", "use", "preview", "validate", "diagnostics", "reload", "status", "vars", "import-silly", "ui"];
773
- return commands.filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
774
- }
775
- const first = parts[0];
776
- if (["use", "preview", "validate"].includes(first)) {
777
- const fragment = parts[1] ?? "";
778
- const ids = ["none", ...stacks.map((loaded) => loaded.stack.id)];
779
- return ids.filter((id) => id.startsWith(fragment)).map((id) => ({ value: `${first} ${id}`, label: id }));
780
- }
781
- if (first === "vars") {
782
- const sub = parts[1] ?? "";
783
- if (parts.length <= 2 && !prefix.endsWith(" ")) {
784
- const subs = ["set", "get", "clear", "list"];
785
- return subs.filter((s) => s.startsWith(sub)).map((s) => ({ value: `vars ${s}`, label: s }));
786
- }
787
- if (["set", "get", "clear"].includes(sub) && parts.length <= 3) {
788
- const fragment = parts[2] ?? "";
789
- const names = Object.keys(sessionVariables);
790
- return names.filter((n) => n.startsWith(fragment)).map((n) => ({ value: `vars ${sub} ${n}`, label: n }));
791
- }
792
- }
793
- if (first === "ui" && parts.length <= 2) {
794
- const fragment = parts[1] ?? "";
795
- const subs = ["stop", "restart"];
796
- return subs.filter((s) => s.startsWith(fragment)).map((s) => ({ value: `ui ${s}`, label: s }));
797
- }
798
- return null;
799
- },
800
- handler: async (args, ctx) => {
801
- await handlePresetCommand(args, ctx);
802
- },
412
+ registerLifecycleHandlers(pi, state, {
413
+ reloadStacks,
414
+ refreshWebEditorHost,
415
+ notifyActivePreset,
416
+ syncActiveToolPolicy,
417
+ activeId,
418
+ persistActiveSelection,
419
+ recordCompileDiagnostics,
803
420
  });
804
-
805
- pi.registerCommand("state", {
806
- description: "Manage pi-forge prompt state",
807
- getArgumentCompletions: (prefix) => {
808
- const parts = prefix.trimStart().split(/\s+/);
809
- if (parts.length <= 1 && !prefix.endsWith(" ")) {
810
- const commands = ["list", "set", "get", "clear"];
811
- return commands.filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
812
- }
813
- const first = parts[0];
814
- if (["get", "clear"].includes(first) && parts.length <= 2) {
815
- const fragment = parts[1] ?? "";
816
- return Object.keys(sessionVariables)
817
- .filter((name) => name.startsWith(fragment))
818
- .map((name) => ({ value: `${first} ${name}`, label: name }));
819
- }
820
- return null;
821
- },
822
- handler: async (args, ctx) => {
823
- await handleStateCommand(args, ctx);
824
- },
421
+ registerPayloadRequestHandler(pi, state, () => state.active);
422
+ registerPayloadCommands(pi, state);
423
+ registerPresetCommand(pi, state, {
424
+ selectedActiveId,
425
+ setActive,
426
+ reloadStacks,
427
+ openWebEditor,
428
+ stopWebEditor,
825
429
  });
826
-
827
- async function handlePresetCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
828
- const trimmed = args.trim();
829
- const [command = "list", ...rest] = trimmed ? trimmed.split(/\s+/) : ["list"];
830
-
831
- switch (command) {
832
- case "list":
833
- case "status":
834
- await showText(ctx, "pi-forge prompt stacks", renderStackList(ctx));
835
- return;
836
-
837
- case "reload":
838
- reloadStacks(ctx, selectedActiveId());
839
- ctx.ui.notify(`pi-forge: reloaded ${stacks.length} prompt stack(s).`, "info");
840
- return;
841
-
842
- case "ui": {
843
- const sub = rest[0];
844
- if (sub === "stop") {
845
- await stopWebEditor(ctx);
846
- return;
847
- }
848
- await openWebEditor(ctx, sub === "restart" ? "restart" : "open");
849
- return;
850
- }
851
-
852
- case "vars": {
853
- const sub = rest[0];
854
-
855
- if (sub === "set") {
856
- const name = rest[1];
857
- const value = rest.slice(2).join(" ");
858
- if (!name || value === "") {
859
- ctx.ui.notify("Usage: /preset vars set <name> <value>", "warning");
860
- return;
861
- }
862
- const result = applyStatePatch([{ name, value }], [], "user");
863
- if (!result.ok) {
864
- ctx.ui.notify(`pi-forge variable error: ${result.error}`, "error");
865
- return;
866
- }
867
- ctx.ui.notify(`pi-forge: set session variable ${name} = ${JSON.stringify(value)}`, "info");
868
- return;
869
- }
870
-
871
- if (sub === "get") {
872
- const name = rest[1];
873
- if (!name) {
874
- ctx.ui.notify("Usage: /preset vars get <name>", "warning");
875
- return;
876
- }
877
- const value = sessionVariables[name];
878
- if (value === undefined) {
879
- await showText(ctx, `pi-forge variable: ${name}`, `# ${name}\n\n(not set)`);
880
- } else {
881
- await showText(ctx, `pi-forge variable: ${name}`, `# ${name}\n\n${formatStateValue(value)}`);
882
- }
883
- return;
884
- }
885
-
886
- if (sub === "clear") {
887
- const name = rest[1];
888
- const clears = name ? [name] : Object.keys(sessionVariables);
889
- const result = applyStatePatch([], clears, "user");
890
- if (!result.ok) {
891
- ctx.ui.notify(`pi-forge variable error: ${result.error}`, "error");
892
- return;
893
- }
894
- ctx.ui.notify(name ? `pi-forge: cleared session variable ${name}` : "pi-forge: cleared all session variables", "info");
895
- return;
896
- }
897
-
898
- // No subcommand or "list" — show all variables
899
- await showText(ctx, "pi-forge variables", renderVariablesList());
900
- return;
901
- }
902
-
903
- case "use": {
904
- const id = rest[0];
905
- if (!id) {
906
- ctx.ui.notify("Usage: /preset use <id|none>", "warning");
907
- return;
908
- }
909
- if (!setActive(id, ctx)) {
910
- ctx.ui.notify(`Unknown prompt stack: ${id}`, "error");
911
- return;
912
- }
913
- ctx.ui.notify(active ? `pi-forge: active prompt stack ${active.stack.id}` : "pi-forge: prompt stack disabled", "info");
914
- return;
915
- }
916
-
917
- case "preview": {
918
- const target = rest[0] ? stacks.find((loaded) => loaded.stack.id === rest[0]) : active;
919
- if (!target) {
920
- ctx.ui.notify(rest[0] ? `Unknown prompt stack: ${rest[0]}` : "No active prompt stack.", "warning");
921
- return;
922
- }
923
- await showText(ctx, `pi-forge preview: ${target.stack.id}`, renderPreview(ctx, target));
924
- return;
925
- }
926
-
927
- case "validate": {
928
- const target = rest[0] ? stacks.find((loaded) => loaded.stack.id === rest[0]) : active;
929
- if (!target) {
930
- ctx.ui.notify(rest[0] ? `Unknown prompt stack: ${rest[0]}` : "No active prompt stack.", "warning");
931
- return;
932
- }
933
- await showText(ctx, `pi-forge validation: ${target.stack.id}`, renderDiagnostics(target.diagnostics));
934
- return;
935
- }
936
-
937
- case "diagnostics": {
938
- await showText(ctx, "pi-forge diagnostics", renderCurrentDiagnostics());
939
- return;
940
- }
941
-
942
- case "import-silly": {
943
- if (!ctx.isProjectTrusted()) {
944
- ctx.ui.notify("pi-forge: project is not trusted; refusing to write imported prompt stacks.", "warning");
945
- return;
946
- }
947
-
948
- const sourcePath = rest[0];
949
- if (!sourcePath) {
950
- ctx.ui.notify("Usage: /preset import-silly <path> [character_id] [--dry-run] [--overwrite]", "warning");
951
- return;
952
- }
953
-
954
- const charIdToken = rest[1]?.startsWith("--") ? undefined : rest[1];
955
- const flags = new Set(rest.slice(charIdToken ? 2 : 1));
956
- const dryRun = flags.has("--dry-run");
957
- let overwrite = flags.has("--overwrite");
958
-
959
- const resolvedPath = sourcePath.startsWith("/") ? sourcePath : join(ctx.cwd, sourcePath);
960
- if (!existsSync(resolvedPath)) {
961
- ctx.ui.notify(`File not found: ${resolvedPath}`, "error");
962
- return;
963
- }
964
-
965
- const charId = charIdToken ? Number(charIdToken) : undefined;
966
- if (charIdToken && (Number.isNaN(charId) || !Number.isFinite(charId))) {
967
- ctx.ui.notify(`Invalid character_id: ${charIdToken}`, "error");
968
- return;
969
- }
970
-
971
- const result = importSillyTavernPreset(resolvedPath, charId);
972
- if ("error" in result) {
973
- ctx.ui.notify(`pi-forge import error: ${result.error}`, "error");
974
- return;
975
- }
976
-
977
- const stacksDir = promptStacksDir(ctx.cwd);
978
- const stackPath = join(stacksDir, `${result.stack.id}.json`);
979
- const reportDir = join(ctx.cwd, ".pi", "forge", "import-reports");
980
- const reportPath = join(reportDir, `${result.stack.id}.md`);
981
-
982
- if (dryRun) {
983
- 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}`);
984
- return;
985
- }
986
-
987
- const existingPaths = [stackPath, reportPath].filter((path) => existsSync(path));
988
- if (existingPaths.length > 0 && !overwrite) {
989
- if (!ctx.hasUI) {
990
- ctx.ui.notify(`pi-forge: import would overwrite existing file(s): ${existingPaths.join(", ")}. Re-run with --overwrite.`, "error");
991
- return;
992
- }
993
- overwrite = await ctx.ui.confirm("Overwrite pi-forge import output?", `These file(s) already exist:\n${existingPaths.join("\n")}\n\nOverwrite them?`);
994
- if (!overwrite) {
995
- ctx.ui.notify("pi-forge: import cancelled; existing files were left unchanged.", "info");
996
- return;
997
- }
998
- }
999
-
1000
- if (!existsSync(stacksDir)) mkdirSync(stacksDir, { recursive: true });
1001
- writeFileSync(stackPath, JSON.stringify(result.stack, null, 2), "utf8");
1002
-
1003
- if (!existsSync(reportDir)) mkdirSync(reportDir, { recursive: true });
1004
- writeFileSync(reportPath, result.report, "utf8");
1005
-
1006
- // Reload stacks to pick up the new one
1007
- reloadStacks(ctx, selectedActiveId());
1008
-
1009
- ctx.ui.notify(`pi-forge: imported ${result.stack.id} (${result.stack.items.length} items)`, "info");
1010
- await showText(ctx, `pi-forge import report: ${result.stack.id}`, `Stack written to: ${stackPath}\nReport written to: ${reportPath}\n\n${result.report}`);
1011
- return;
1012
- }
1013
-
1014
- default:
1015
- ctx.ui.notify(`Unknown /preset subcommand: ${command}`, "warning");
1016
- return;
1017
- }
1018
- }
1019
-
1020
- async function handleStateCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
1021
- const trimmed = args.trim();
1022
- const [command = "list", ...rest] = trimmed ? trimmed.split(/\s+/) : ["list"];
1023
-
1024
- switch (command) {
1025
- case "list":
1026
- case "status":
1027
- await showText(ctx, "pi-forge state", renderVariablesList());
1028
- return;
1029
-
1030
- case "set": {
1031
- const name = rest[0];
1032
- const rawValue = rest.slice(1).join(" ");
1033
- if (!name || rawValue === "") {
1034
- ctx.ui.notify("Usage: /state set <name> <json-or-text-value>", "warning");
1035
- return;
1036
- }
1037
- const value = parseStateCommandValue(rawValue);
1038
- const result = applyStatePatch([{ name, value }], [], "user");
1039
- if (!result.ok) {
1040
- ctx.ui.notify(`pi-forge state error: ${result.error}`, "error");
1041
- return;
1042
- }
1043
- ctx.ui.notify(`pi-forge: set state ${name} = ${JSON.stringify(value)}`, "info");
1044
- return;
1045
- }
1046
-
1047
- case "get": {
1048
- const name = rest[0];
1049
- if (!name) {
1050
- ctx.ui.notify("Usage: /state get <name>", "warning");
1051
- return;
1052
- }
1053
- const value = sessionVariables[name];
1054
- await showText(ctx, `pi-forge state: ${name}`, value === undefined ? `# ${name}\n\n(not set)` : `# ${name}\n\n${formatStateValue(value)}`);
1055
- return;
1056
- }
1057
-
1058
- case "clear": {
1059
- const name = rest[0];
1060
- const clears = name ? [name] : Object.keys(sessionVariables);
1061
- const result = applyStatePatch([], clears, "user");
1062
- if (!result.ok) {
1063
- ctx.ui.notify(`pi-forge state error: ${result.error}`, "error");
1064
- return;
1065
- }
1066
- ctx.ui.notify(name ? `pi-forge: cleared state ${name}` : "pi-forge: cleared all session state", "info");
1067
- return;
1068
- }
1069
-
1070
- default:
1071
- ctx.ui.notify(`Unknown /state subcommand: ${command}`, "warning");
1072
- return;
1073
- }
1074
- }
1075
-
1076
- function renderStackList(ctx: ExtensionCommandContext): string {
1077
- const lines = [
1078
- `Prompt stack directory: ${promptStacksDir(ctx.cwd)}`,
1079
- `Active stack: ${active?.stack.id ?? "(none)"}`,
1080
- "",
1081
- ];
1082
-
1083
- if (stacks.length === 0) {
1084
- lines.push("No prompt stacks found.", "Create .pi/prompt-stacks/default.json to auto-activate a stack.");
1085
- return lines.join("\n");
1086
- }
1087
-
1088
- for (const loaded of stacks) {
1089
- const marker = loaded === active ? "*" : " ";
1090
- const errors = loaded.diagnostics.filter((d) => d.level === "error").length;
1091
- const warnings = loaded.diagnostics.filter((d) => d.level === "warning").length;
1092
- const suffix = errors || warnings ? ` (${errors} errors, ${warnings} warnings)` : "";
1093
- lines.push(`${marker} ${loaded.stack.id}${loaded.stack.name ? ` — ${loaded.stack.name}` : ""}${suffix}`);
1094
- lines.push(` ${loaded.filePath}`);
1095
- }
1096
-
1097
- lines.push("", "Commands:", " /preset use <id|none>", " /preset preview [id]", " /preset validate [id]", " /preset diagnostics", " /preset reload", " /preset ui [stop|restart]", " /state [list|set <name> <value>|get <name>|clear [name]]", " /preset vars [set <name> <value>|get <name>|clear [name]]");
1098
- return lines.join("\n");
1099
- }
1100
-
1101
- function renderCurrentDiagnostics(): string {
1102
- const lines = ["# pi-forge diagnostics", ""];
1103
- lines.push("## Active stack load/validation diagnostics", "");
1104
- lines.push(active ? renderDiagnostics(active.diagnostics) : "No active prompt stack.");
1105
- lines.push("", "## Latest runtime compile diagnostics", "");
1106
- lines.push(renderDiagnostics(latestCompileDiagnostics));
1107
- return lines.join("\n");
1108
- }
1109
-
1110
- function renderVariablesList(): string {
1111
- const lines = ["# pi-forge state", "", "## Session state", ""];
1112
- const sessionEntries = Object.entries(sessionVariables).sort(([a], [b]) => a.localeCompare(b));
1113
- if (sessionEntries.length === 0) lines.push("(none)");
1114
- else for (const [key, value] of sessionEntries) lines.push(`${key} = ${JSON.stringify(value)}`);
1115
-
1116
- lines.push("", "## Active stack static variables", "");
1117
- const staticEntries = Object.entries(active?.stack.variables ?? {}).sort(([a], [b]) => a.localeCompare(b));
1118
- if (staticEntries.length === 0) lines.push("(none)");
1119
- else for (const [key, value] of staticEntries) lines.push(`${key} = ${JSON.stringify(value)}`);
1120
-
1121
- const definitions = Object.entries(active?.stack.state?.definitions ?? {}).sort(([a], [b]) => a.localeCompare(b));
1122
- if (definitions.length > 0) {
1123
- lines.push("", "## Active stack state definitions", "");
1124
- for (const [name, definition] of definitions) {
1125
- const details = [
1126
- definition.type ? `type=${definition.type}` : undefined,
1127
- definition.scope ? `scope=${definition.scope}` : undefined,
1128
- definition.agentWritable !== undefined ? `agentWritable=${definition.agentWritable}` : undefined,
1129
- definition.userWritable !== undefined ? `userWritable=${definition.userWritable}` : undefined,
1130
- ].filter(Boolean).join(", ");
1131
- lines.push(`${name}${details ? ` (${details})` : ""}${definition.description ? ` — ${definition.description}` : ""}`);
1132
- }
1133
- }
1134
-
1135
- lines.push("", "Turn variables are cleared for each user message and are only visible during prompt compilation.");
1136
- return lines.join("\n");
1137
- }
1138
-
1139
- function renderPreview(ctx: ExtensionCommandContext, target: LoadedPromptStack): string {
1140
- const options = ctx.getSystemPromptOptions();
1141
- const sessionContext = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
1142
- const latestUserMessage = getLatestUserMessage(sessionContext.messages);
1143
- const previewVariables = createPromptVariableStore(sessionVariables);
1144
- const runtime = { options, ctx, latestUserMessage, now: new Date(), variables: previewVariables };
1145
- const system = compileSystemPrompt(target.stack, runtime, ctx.getSystemPrompt());
1146
- const messages = compileMessages(target.stack, runtime, sessionContext.messages);
1147
- const diagnostics = [...target.diagnostics, ...system.diagnostics, ...messages.diagnostics];
1148
-
1149
- return [
1150
- `# Prompt stack preview: ${target.stack.id}`,
1151
- "",
1152
- "## System prompt",
1153
- "",
1154
- system.systemPrompt || "(empty)",
1155
- "",
1156
- "## Message layout",
1157
- "",
1158
- renderPreviewMessages(messages.messages),
1159
- "",
1160
- "## Diagnostics",
1161
- "",
1162
- renderDiagnostics(diagnostics),
1163
- ].join("\n");
1164
- }
1165
-
1166
- function renderDiagnostics(diagnostics: PromptStackDiagnostic[]): string {
1167
- if (diagnostics.length === 0) return "No diagnostics.";
1168
- return diagnostics.map((d) => `${d.level.toUpperCase()}${d.itemId ? ` [${d.itemId}]` : ""}: ${d.message}`).join("\n");
1169
- }
1170
-
1171
- async function showText(ctx: ExtensionCommandContext, title: string, text: string): Promise<void> {
1172
- if (ctx.hasUI) {
1173
- await ctx.ui.editor(title, text);
1174
- return;
1175
- }
1176
- console.log(text);
1177
- }
1178
-
1179
- function safeStringify(value: unknown): string {
1180
- try {
1181
- const text = JSON.stringify(redactPayload(value), null, 2);
1182
- const maxChars = 200_000;
1183
- return text.length > maxChars ? `${text.slice(0, maxChars)}\n\n[pi-forge: payload truncated after ${maxChars} chars]` : text;
1184
- } catch (error) {
1185
- return `Failed to stringify provider payload: ${error instanceof Error ? error.message : String(error)}`;
1186
- }
1187
- }
1188
-
1189
- function estimatePayloadTokens(payload: string): number {
1190
- return Math.max(1, Math.ceil(payload.length / 4));
1191
- }
1192
-
1193
- function redactPayload(value: unknown, depth = 0): unknown {
1194
- if (depth > 8) return "[pi-forge: max depth reached]";
1195
- if (typeof value === "string") return redactLongString(value);
1196
- if (value === null || typeof value !== "object") return value;
1197
- if (Array.isArray(value)) {
1198
- const maxItems = 80;
1199
- const items = value.slice(0, maxItems).map((item) => redactPayload(item, depth + 1));
1200
- if (value.length > maxItems) items.push(`[pi-forge: ${value.length - maxItems} more items omitted]`);
1201
- return items;
1202
- }
1203
-
1204
- const result: Record<string, unknown> = {};
1205
- let count = 0;
1206
- for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
1207
- if (++count > 120) {
1208
- result["[pi-forge: omitted]"] = "object has more than 120 keys";
1209
- break;
1210
- }
1211
- if (isSecretKey(key)) {
1212
- result[key] = "[redacted]";
1213
- continue;
1214
- }
1215
- result[key] = redactPayload(raw, depth + 1);
1216
- }
1217
- return result;
1218
- }
1219
-
1220
- function isSecretKey(key: string): boolean {
1221
- return /(api[-_]?key|authorization|bearer|token|secret|password|cookie|credential)/i.test(key);
1222
- }
1223
-
1224
- function redactLongString(value: string): string {
1225
- if (/^data:image\//.test(value)) return "[image data omitted]";
1226
- if (value.length > 8_000 && /^[A-Za-z0-9+/=\r\n]+$/.test(value)) return `[base64-like data omitted: ${value.length} chars]`;
1227
- if (value.length > 12_000) return `${value.slice(0, 12_000)}\n[pi-forge: string truncated from ${value.length} chars]`;
1228
- return value;
1229
- }
1230
430
  }