@zihanw/pi-forge 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,47 +1,71 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  import { buildSessionContext, type BuildSystemPromptOptions, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import { Type } from "typebox";
5
4
  import {
6
5
  compileMessages,
7
6
  compileSystemPrompt,
8
7
  createPromptVariableStore,
8
+ agentMessageToPreviewText,
9
9
  getLatestUserMessage,
10
10
  markSessionVariablesClean,
11
- renderPreviewMessages,
12
11
  resetTurnVariables,
13
12
  } from "./compiler.ts";
14
- import { chooseDefaultStack, isDisabledPromptStackId, loadPromptStacks, promptStacksDir, validatePromptStack } from "./loader.ts";
13
+ import { chooseDefaultStack, isDisabledPromptStackId, loadPromptStacks, promptStackPath, promptStackReadDirs } from "./loader.ts";
14
+ import { createProviderPayloadCapture, estimatePayloadTokens } from "./payload-capture.ts";
15
+ import { applyResourcePolicy, hasResourcePolicy } from "./policy.ts";
16
+ import { applyFinalizeRegexRulesToMessage } from "./regex.ts";
15
17
  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";
18
+ import { migrateLegacyPromptStacks, renderMigrationReport } from "./stack-migration.ts";
19
+ import type { CompileMessageSource, LoadedPromptStack, PromptStackDiagnostic, PromptVariableValue, PromptVariableStore } from "./types.ts";
20
+ 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";
18
22
 
19
23
  const STATE_ENTRY_TYPE = "pi-forge-prompt-stack-state";
20
24
  const VARIABLE_ENTRY_TYPE = "pi-forge-variable-state";
21
- const AGENT_VAR_PREFIX = "agent.";
25
+ const WEB_EDITOR_GLOBAL_KEY = "__piForgeWebEditor";
22
26
 
23
- type StateActor = "agent" | "user";
27
+ type PayloadDisplayTarget = "editor" | "web";
24
28
 
25
- interface StateUpdateInput {
26
- name: string;
27
- value: PromptStateValue;
28
- reason?: string;
29
+ interface SharedWebEditorState {
30
+ server?: WebEditorServer;
31
+ cwd?: string;
32
+ preferredPort?: number;
33
+ }
34
+
35
+ interface SharedWebEditorRegistry {
36
+ byCwd: Record<string, SharedWebEditorState | undefined>;
37
+ }
38
+
39
+ type PiForgeGlobal = typeof globalThis & {
40
+ __piForgeWebEditor?: SharedWebEditorRegistry;
41
+ };
42
+
43
+ function getSharedWebEditorRegistry(): SharedWebEditorRegistry {
44
+ const globalScope = globalThis as PiForgeGlobal;
45
+ globalScope[WEB_EDITOR_GLOBAL_KEY] ??= { byCwd: {} };
46
+ return globalScope[WEB_EDITOR_GLOBAL_KEY];
29
47
  }
30
48
 
31
49
  export default function piForge(pi: ExtensionAPI) {
50
+ const sharedWebEditors = getSharedWebEditorRegistry();
32
51
  let stacks: LoadedPromptStack[] = [];
33
52
  let active: LoadedPromptStack | undefined;
34
53
  let currentSystemPromptOptions: BuildSystemPromptOptions | undefined;
35
54
  let currentLatestUserMessage: string | undefined;
36
55
  let currentVariableStore: PromptVariableStore | undefined;
37
56
  let contextRewritePending = false;
38
- let sessionVariables: Record<string, PromptStateValue> = {};
57
+ let sessionVariables: Record<string, PromptVariableValue> = {};
39
58
  let lastPersistedActiveId: string | undefined;
40
59
  let interceptNextProviderPayload = false;
41
60
  let interceptPayloadSavePath: string | undefined;
61
+ let interceptPayloadDisplayTarget: PayloadDisplayTarget = "editor";
62
+ let payloadCaptureArmedAt: string | undefined;
63
+ let latestProviderPayloadCapture: WebEditorPayloadCapture | undefined;
42
64
  let latestCompileDiagnostics: PromptStackDiagnostic[] = [];
43
65
  let webEditor: WebEditorServer | undefined;
44
66
  let webEditorCwd: string | undefined;
67
+ let webEditorPreferredPort: number | undefined;
68
+ let toolPolicyBaseline: string[] | undefined;
45
69
 
46
70
  function activeId(): string | undefined {
47
71
  return active?.stack.id;
@@ -63,6 +87,7 @@ export default function piForge(pi: ExtensionAPI) {
63
87
  active = undefined;
64
88
  if (id) persistActiveSelection("none");
65
89
  if (ctx) updateStatus(ctx);
90
+ syncActiveToolPolicy(ctx);
66
91
  return true;
67
92
  }
68
93
 
@@ -71,6 +96,7 @@ export default function piForge(pi: ExtensionAPI) {
71
96
  active = found;
72
97
  persistActiveSelection(found.stack.id);
73
98
  if (ctx) updateStatus(ctx);
99
+ syncActiveToolPolicy(ctx);
74
100
  return true;
75
101
  }
76
102
 
@@ -78,6 +104,7 @@ export default function piForge(pi: ExtensionAPI) {
78
104
  if (!ctx.isProjectTrusted()) {
79
105
  stacks = [];
80
106
  active = undefined;
107
+ syncActiveToolPolicy(ctx);
81
108
  ctx.ui.notify("pi-forge: project is not trusted; prompt stacks are disabled.", "warning");
82
109
  updateStatus(ctx);
83
110
  return;
@@ -86,6 +113,7 @@ export default function piForge(pi: ExtensionAPI) {
86
113
  stacks = loadPromptStacks(ctx.cwd);
87
114
  active = chooseDefaultStack(stacks, preferredId);
88
115
  updateStatus(ctx);
116
+ syncActiveToolPolicy(ctx);
89
117
  }
90
118
 
91
119
  function updateStatus(ctx: ExtensionContext): void {
@@ -98,6 +126,37 @@ export default function piForge(pi: ExtensionAPI) {
98
126
  }
99
127
  }
100
128
 
129
+ function syncActiveToolPolicy(ctx?: ExtensionContext): void {
130
+ const policy = active?.stack.tools;
131
+ if (!hasResourcePolicy(policy)) {
132
+ restoreToolPolicy(ctx);
133
+ return;
134
+ }
135
+
136
+ const baseline = toolPolicyBaseline ?? pi.getActiveTools();
137
+ toolPolicyBaseline ??= [...baseline];
138
+ const nextTools = applyResourcePolicy(filterKnownTools(baseline), policy);
139
+ pi.setActiveTools(nextTools);
140
+ if (ctx) {
141
+ const label = nextTools.length > 0 ? `tools:${nextTools.length}` : "tools:none";
142
+ ctx.ui.setStatus("pi-forge-tools", ctx.ui.theme.fg(nextTools.length > 0 ? "accent" : "warning", label));
143
+ }
144
+ }
145
+
146
+ function restoreToolPolicy(ctx?: ExtensionContext): void {
147
+ if (toolPolicyBaseline) {
148
+ pi.setActiveTools(filterKnownTools(toolPolicyBaseline));
149
+ toolPolicyBaseline = undefined;
150
+ }
151
+ if (ctx) ctx.ui.setStatus("pi-forge-tools", undefined);
152
+ }
153
+
154
+ function filterKnownTools(names: string[]): string[] {
155
+ const known = new Set(pi.getAllTools().map((tool) => tool.name));
156
+ if (known.size === 0) return names;
157
+ return names.filter((name) => known.has(name));
158
+ }
159
+
101
160
  function notifyActivePreset(ctx: ExtensionContext, detail: string): void {
102
161
  if (!active) return;
103
162
  const errorCount = active.diagnostics.filter((d) => d.level === "error").length;
@@ -117,241 +176,129 @@ export default function piForge(pi: ExtensionAPI) {
117
176
  ctx.ui.setStatus("pi-forge-diagnostics", undefined);
118
177
  }
119
178
 
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;
179
+ function webHostRuntime(ctx: ExtensionCommandContext): WebHostRuntime {
123
180
  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,
134
- };
135
- }
136
-
137
- function stackSummaries(): WebEditorStackSummary[] {
138
- return stacks.map(stackSummary);
139
- }
140
-
141
- function createWebEditorHost(ctx: ExtensionCommandContext): WebEditorHost {
142
- 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;
181
+ getStacks: () => stacks,
182
+ getActive: () => active,
183
+ getActiveId: activeId,
184
+ getSelectedActiveId: selectedActiveId,
185
+ setActive: (id) => setActive(id, ctx),
186
+ reloadStacks: (preferredId) => reloadStacks(ctx, preferredId),
187
+ buildPreview: (target) => buildPreview(ctx, target),
188
+ getPayload: () => ({ ok: true, ...webPayloadSnapshot() }),
189
+ armPayload: (savePath) => {
190
+ armPayloadIntercept(ctx, savePath, "web");
191
+ return { ok: true, ...webPayloadSnapshot() };
148
192
  },
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
- }
154
-
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
- }
160
-
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() };
193
+ clearPayload: () => {
194
+ clearPayloadCapture(ctx);
195
+ return { ok: true, ...webPayloadSnapshot() };
188
196
  },
189
197
  };
190
198
  }
191
199
 
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
- }
200
-
201
- const idError = validateWebStackId(stack.id);
202
- if (idError) return { ok: false, status: 400, error: idError };
203
-
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
- }
209
-
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
- }
217
-
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);
200
+ function sharedWebEditorForCwd(cwd: string): SharedWebEditorState {
201
+ sharedWebEditors.byCwd[cwd] ??= {};
202
+ return sharedWebEditors.byCwd[cwd];
203
+ }
223
204
 
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() };
205
+ function syncWebEditorFromShared(cwd: string): void {
206
+ const shared = sharedWebEditorForCwd(cwd);
207
+ webEditor = shared.server;
208
+ webEditorCwd = shared.cwd;
209
+ webEditorPreferredPort = shared.preferredPort;
227
210
  }
228
211
 
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
- }
212
+ function rememberWebEditor(server: WebEditorServer, cwd: string, preferredPort: number | undefined): void {
213
+ const shared = sharedWebEditorForCwd(cwd);
214
+ webEditor = server;
215
+ webEditorCwd = cwd;
216
+ webEditorPreferredPort = preferredPort;
217
+ shared.server = server;
218
+ shared.cwd = cwd;
219
+ shared.preferredPort = preferredPort;
220
+ }
236
221
 
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." };
222
+ function clearWebEditor(server: WebEditorServer): void {
223
+ if (webEditor === server) {
224
+ webEditor = undefined;
225
+ webEditorCwd = undefined;
226
+ webEditorPreferredPort = undefined;
241
227
  }
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());
228
+ for (const [cwd, shared] of Object.entries(sharedWebEditors.byCwd)) {
229
+ if (shared?.server === server) delete sharedWebEditors.byCwd[cwd];
250
230
  }
251
- return { ok: true, activeId: activeId(), stacks: stackSummaries() };
252
231
  }
253
232
 
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;
233
+ function refreshWebEditorHost(ctx: ExtensionContext): void {
234
+ syncWebEditorFromShared(ctx.cwd);
235
+ if (!webEditor) return;
236
+ const commandCtx = ctx as ExtensionCommandContext;
237
+ webEditor.updateHost(createWebEditorHost(commandCtx, webHostRuntime(commandCtx)));
238
+ rememberWebEditor(webEditor, ctx.cwd, webEditorPreferredPort);
239
+ ctx.ui.setStatus("pi-forge-editor", ctx.ui.theme.fg("accent", `editor:${webEditor.port}`));
260
240
  }
261
241
 
262
242
  async function openWebEditor(ctx: ExtensionCommandContext, mode: "open" | "restart" = "open"): Promise<void> {
243
+ syncWebEditorFromShared(ctx.cwd);
263
244
  const settings = loadWebEditorSettings(ctx);
264
245
  for (const warning of settings.warnings) ctx.ui.notify(warning, "warning");
265
246
 
266
- if (webEditor && (mode === "restart" || webEditorCwd !== ctx.cwd || webEditor.port !== settings.port)) {
267
- await webEditor.close();
268
- webEditor = undefined;
269
- webEditorCwd = undefined;
247
+ if (webEditor && (mode === "restart" || webEditorPreferredPort !== settings.preferredPort)) {
248
+ const server = webEditor;
249
+ await server.close();
250
+ clearWebEditor(server);
270
251
  ctx.ui.setStatus("pi-forge-editor", undefined);
271
252
  }
272
253
 
273
254
  if (!webEditor) {
274
255
  try {
275
- webEditor = await startWebEditorServer(createWebEditorHost(ctx), { port: settings.port });
256
+ webEditor = await startWebEditorServer(createWebEditorHost(ctx, webHostRuntime(ctx)), { port: settings.preferredPort });
276
257
  } 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;
258
+ if (settings.preferredPort !== undefined) {
259
+ const detail = error instanceof Error ? error.message : String(error);
260
+ ctx.ui.notify(`pi-forge: preferred editor port 127.0.0.1:${settings.preferredPort} was unavailable (${detail}); using an available port instead.`, "warning");
261
+ try {
262
+ webEditor = await startWebEditorServer(createWebEditorHost(ctx, webHostRuntime(ctx)));
263
+ } catch (fallbackError) {
264
+ const fallbackDetail = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
265
+ ctx.ui.setStatus("pi-forge-editor", undefined);
266
+ ctx.ui.notify(`pi-forge: failed to start stack editor on an available localhost port: ${fallbackDetail}.`, "error");
267
+ return;
268
+ }
269
+ } else {
270
+ const detail = error instanceof Error ? error.message : String(error);
271
+ ctx.ui.setStatus("pi-forge-editor", undefined);
272
+ ctx.ui.notify(`pi-forge: failed to start stack editor on an available localhost port: ${detail}.`, "error");
273
+ return;
274
+ }
281
275
  }
282
- webEditorCwd = ctx.cwd;
276
+ rememberWebEditor(webEditor, ctx.cwd, settings.preferredPort);
283
277
  ctx.ui.setStatus("pi-forge-editor", ctx.ui.theme.fg("accent", `editor:${webEditor.port}`));
284
278
  ctx.ui.notify(`pi-forge: stack editor running at ${webEditor.url}`, "info");
285
279
  } else {
280
+ webEditor.updateHost(createWebEditorHost(ctx, webHostRuntime(ctx)));
281
+ rememberWebEditor(webEditor, ctx.cwd, settings.preferredPort);
282
+ ctx.ui.setStatus("pi-forge-editor", ctx.ui.theme.fg("accent", `editor:${webEditor.port}`));
286
283
  ctx.ui.notify(`pi-forge: stack editor already running at ${webEditor.url}`, "info");
287
284
  }
288
285
 
289
286
  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
287
  }
291
288
 
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
289
  async function stopWebEditor(ctx: ExtensionCommandContext): Promise<void> {
290
+ syncWebEditorFromShared(ctx.cwd);
337
291
  if (!webEditor) {
338
292
  ctx.ui.notify("pi-forge: stack editor is not running.", "info");
339
293
  return;
340
294
  }
341
- await webEditor.close();
342
- webEditor = undefined;
343
- webEditorCwd = undefined;
295
+ const server = webEditor;
296
+ await server.close();
297
+ clearWebEditor(server);
344
298
  ctx.ui.setStatus("pi-forge-editor", undefined);
345
299
  ctx.ui.notify("pi-forge: stack editor stopped.", "info");
346
300
  }
347
301
 
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
302
  function getCurrentBranchEntries(ctx: ExtensionContext): unknown[] {
356
303
  const leafId = ctx.sessionManager.getLeafId();
357
304
  if (leafId === null) return [];
@@ -373,13 +320,13 @@ export default function piForge(pi: ExtensionAPI) {
373
320
  return undefined;
374
321
  }
375
322
 
376
- function getRestoredVariables(ctx: ExtensionContext): Record<string, PromptStateValue> {
323
+ function getRestoredVariables(ctx: ExtensionContext): Record<string, PromptVariableValue> {
377
324
  const entries = getCurrentBranchEntries(ctx);
378
325
  for (let i = entries.length - 1; i >= 0; i--) {
379
326
  const entry = entries[i] as { type?: string; customType?: string; data?: { variables?: unknown } };
380
327
  if (entry.type !== "custom" || entry.customType !== VARIABLE_ENTRY_TYPE) continue;
381
328
  if (!entry.data || typeof entry.data.variables !== "object" || Array.isArray(entry.data.variables)) return {};
382
- return normalizeStateRecord(entry.data.variables as Record<string, unknown>);
329
+ return normalizeVariableRecord(entry.data.variables as Record<string, unknown>);
383
330
  }
384
331
  return {};
385
332
  }
@@ -391,135 +338,25 @@ export default function piForge(pi: ExtensionAPI) {
391
338
  markSessionVariablesClean(store);
392
339
  }
393
340
 
394
- function normalizeStateRecord(value: Record<string, unknown>): Record<string, PromptStateValue> {
395
- const result: Record<string, PromptStateValue> = {};
341
+ function normalizeVariableRecord(value: Record<string, unknown>): Record<string, PromptVariableValue> {
342
+ const result: Record<string, PromptVariableValue> = {};
396
343
  for (const [key, raw] of Object.entries(value)) {
397
- if (isPromptStateValue(raw)) result[key] = raw;
344
+ if (isPromptVariableValue(raw)) result[key] = raw;
398
345
  }
399
346
  return result;
400
347
  }
401
348
 
402
- function isPromptStateValue(value: unknown): value is PromptStateValue {
349
+ function isPromptVariableValue(value: unknown): value is PromptVariableValue {
403
350
  if (value === null) return true;
404
351
  const type = typeof value;
405
352
  if (type === "string" || type === "boolean") return true;
406
353
  if (type === "number") return Number.isFinite(value);
407
- if (Array.isArray(value)) return value.every(isPromptStateValue);
354
+ if (Array.isArray(value)) return value.every(isPromptVariableValue);
408
355
  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;
356
+ return Object.values(value as Record<string, unknown>).every(isPromptVariableValue);
516
357
  }
517
358
 
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 {
359
+ function restoreBranchScopedRuntime(ctx: ExtensionContext): void {
523
360
  sessionVariables = getRestoredVariables(ctx);
524
361
  currentVariableStore = undefined;
525
362
  const restoredActiveId = getRestoredActiveId(ctx);
@@ -528,21 +365,25 @@ export default function piForge(pi: ExtensionAPI) {
528
365
  }
529
366
 
530
367
  pi.on("session_start", async (event, ctx) => {
531
- restoreBranchScopedState(ctx);
368
+ restoreBranchScopedRuntime(ctx);
369
+ refreshWebEditorHost(ctx);
532
370
  notifyActivePreset(ctx, "after session " + event.reason);
533
371
  });
534
372
 
535
373
  pi.on("session_tree", async (_event, ctx) => {
536
- restoreBranchScopedState(ctx);
374
+ restoreBranchScopedRuntime(ctx);
375
+ refreshWebEditorHost(ctx);
537
376
  notifyActivePreset(ctx, "after tree navigation");
538
377
  });
539
378
 
540
379
  pi.on("session_compact", async (_event, ctx) => {
541
- restoreBranchScopedState(ctx);
380
+ restoreBranchScopedRuntime(ctx);
381
+ refreshWebEditorHost(ctx);
542
382
  notifyActivePreset(ctx, "after compaction");
543
383
  });
544
384
 
545
385
  pi.on("turn_start", async () => {
386
+ syncActiveToolPolicy();
546
387
  const id = activeId();
547
388
  if (id) persistActiveSelection(id);
548
389
  });
@@ -588,6 +429,15 @@ export default function piForge(pi: ExtensionAPI) {
588
429
  return { messages: result.messages };
589
430
  });
590
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
+
591
441
  pi.on("agent_end", async () => {
592
442
  persistVariablesIfDirty(currentVariableStore);
593
443
  currentSystemPromptOptions = undefined;
@@ -599,139 +449,61 @@ export default function piForge(pi: ExtensionAPI) {
599
449
  pi.on("before_provider_request", async (event, ctx) => {
600
450
  if (!interceptNextProviderPayload) return;
601
451
  const savePath = interceptPayloadSavePath;
452
+ const displayTarget = interceptPayloadDisplayTarget;
602
453
  interceptNextProviderPayload = false;
603
454
  interceptPayloadSavePath = undefined;
455
+ interceptPayloadDisplayTarget = "editor";
456
+ payloadCaptureArmedAt = undefined;
604
457
  ctx.ui.setStatus("pi-forge-intercept", undefined);
605
458
 
606
- const payload = safeStringify(event.payload);
607
- const approxTokens = estimatePayloadTokens(payload);
459
+ const capture = captureProviderPayload(event.payload, savePath);
608
460
  if (savePath) {
609
461
  if (!ctx.isProjectTrusted()) {
610
462
  ctx.ui.notify("pi-forge: project is not trusted; refusing to save provider payload.", "warning");
611
463
  } else {
612
464
  const resolvedPath = savePath.startsWith("/") ? savePath : join(ctx.cwd, savePath);
613
465
  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");
466
+ writeFileSync(resolvedPath, capture.text, "utf8");
467
+ ctx.ui.notify(`pi-forge: provider payload saved to ${resolvedPath} (${capture.chars} chars, ~${capture.approxTokens} tokens)`, "info");
616
468
  }
617
469
  }
618
470
 
619
- if (ctx.hasUI) {
620
- await ctx.ui.editor(`pi-forge: provider payload (${payload.length} chars, ~${approxTokens} tokens)`, payload);
471
+ if (displayTarget === "web") {
472
+ ctx.ui.notify(`pi-forge: provider payload captured for web editor (${capture.chars} chars, ~${capture.approxTokens} tokens).`, "info");
621
473
  return;
622
474
  }
623
475
 
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
- }
476
+ if (ctx.hasUI) {
477
+ await ctx.ui.editor(`pi-forge: provider payload (${capture.chars} chars, ~${capture.approxTokens} tokens)`, capture.text);
478
+ return;
479
+ }
692
480
 
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
- },
481
+ console.log(capture.text);
698
482
  });
699
483
 
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 {
484
+ function armPayloadIntercept(ctx: ExtensionCommandContext, savePath?: string, displayTarget: PayloadDisplayTarget = "editor"): void {
729
485
  interceptNextProviderPayload = true;
730
486
  interceptPayloadSavePath = savePath;
487
+ interceptPayloadDisplayTarget = displayTarget;
488
+ payloadCaptureArmedAt = new Date().toISOString();
489
+ latestProviderPayloadCapture = undefined;
731
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
+ }
732
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");
733
496
  }
734
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
+
735
507
  pi.registerCommand("intercept", {
736
508
  description: "Display the next provider payload before it is sent",
737
509
  handler: async (_args, ctx) => {
@@ -765,11 +537,11 @@ export default function piForge(pi: ExtensionAPI) {
765
537
  });
766
538
 
767
539
  pi.registerCommand("preset", {
768
- description: "Manage pi-forge prompt stacks: list, use, preview, validate, reload, vars, ui",
540
+ description: "Manage pi-forge prompt stacks: list, use, preview, validate, reload, ui",
769
541
  getArgumentCompletions: (prefix) => {
770
542
  const parts = prefix.trimStart().split(/\s+/);
771
543
  if (parts.length <= 1 && !prefix.endsWith(" ")) {
772
- const commands = ["list", "use", "preview", "validate", "diagnostics", "reload", "status", "vars", "import-silly", "ui"];
544
+ const commands = ["list", "use", "preview", "validate", "diagnostics", "reload", "status", "import-silly", "migrate-stacks", "ui"];
773
545
  return commands.filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
774
546
  }
775
547
  const first = parts[0];
@@ -778,49 +550,20 @@ export default function piForge(pi: ExtensionAPI) {
778
550
  const ids = ["none", ...stacks.map((loaded) => loaded.stack.id)];
779
551
  return ids.filter((id) => id.startsWith(fragment)).map((id) => ({ value: `${first} ${id}`, label: id }));
780
552
  }
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
553
  if (first === "ui" && parts.length <= 2) {
794
554
  const fragment = parts[1] ?? "";
795
555
  const subs = ["stop", "restart"];
796
556
  return subs.filter((s) => s.startsWith(fragment)).map((s) => ({ value: `ui ${s}`, label: s }));
797
557
  }
798
- return null;
799
- },
800
- handler: async (args, ctx) => {
801
- await handlePresetCommand(args, ctx);
802
- },
803
- });
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 }));
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 }));
819
562
  }
820
563
  return null;
821
564
  },
822
565
  handler: async (args, ctx) => {
823
- await handleStateCommand(args, ctx);
566
+ await handlePresetCommand(args, ctx);
824
567
  },
825
568
  });
826
569
 
@@ -849,57 +592,6 @@ export default function piForge(pi: ExtensionAPI) {
849
592
  return;
850
593
  }
851
594
 
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
595
  case "use": {
904
596
  const id = rest[0];
905
597
  if (!id) {
@@ -939,6 +631,28 @@ export default function piForge(pi: ExtensionAPI) {
939
631
  return;
940
632
  }
941
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
+
942
656
  case "import-silly": {
943
657
  if (!ctx.isProjectTrusted()) {
944
658
  ctx.ui.notify("pi-forge: project is not trusted; refusing to write imported prompt stacks.", "warning");
@@ -974,8 +688,9 @@ export default function piForge(pi: ExtensionAPI) {
974
688
  return;
975
689
  }
976
690
 
977
- const stacksDir = promptStacksDir(ctx.cwd);
978
- const stackPath = join(stacksDir, `${result.stack.id}.json`);
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);
979
694
  const reportDir = join(ctx.cwd, ".pi", "forge", "import-reports");
980
695
  const reportPath = join(reportDir, `${result.stack.id}.md`);
981
696
 
@@ -1017,71 +732,16 @@ export default function piForge(pi: ExtensionAPI) {
1017
732
  }
1018
733
  }
1019
734
 
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
735
  function renderStackList(ctx: ExtensionCommandContext): string {
1077
736
  const lines = [
1078
- `Prompt stack directory: ${promptStacksDir(ctx.cwd)}`,
737
+ "Prompt stack directories:",
738
+ ...promptStackReadDirs(ctx.cwd).map((dir, index) => ` ${index === 0 ? "primary" : "legacy"}: ${dir}`),
1079
739
  `Active stack: ${active?.stack.id ?? "(none)"}`,
1080
740
  "",
1081
741
  ];
1082
742
 
1083
743
  if (stacks.length === 0) {
1084
- lines.push("No prompt stacks found.", "Create .pi/prompt-stacks/default.json to auto-activate a stack.");
744
+ lines.push("No prompt stacks found.", "Create .pi/forge/prompt-stacks/default.json to auto-activate a stack.");
1085
745
  return lines.join("\n");
1086
746
  }
1087
747
 
@@ -1094,7 +754,7 @@ export default function piForge(pi: ExtensionAPI) {
1094
754
  lines.push(` ${loaded.filePath}`);
1095
755
  }
1096
756
 
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]]");
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]");
1098
758
  return lines.join("\n");
1099
759
  }
1100
760
 
@@ -1107,36 +767,28 @@ export default function piForge(pi: ExtensionAPI) {
1107
767
  return lines.join("\n");
1108
768
  }
1109
769
 
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
- }
770
+ function webPayloadSnapshot(): WebEditorPayloadSnapshot {
771
+ if (interceptNextProviderPayload) {
772
+ return {
773
+ status: "armed",
774
+ armedAt: payloadCaptureArmedAt,
775
+ savePath: interceptPayloadSavePath,
776
+ };
1133
777
  }
1134
-
1135
- lines.push("", "Turn variables are cleared for each user message and are only visible during prompt compilation.");
1136
- return lines.join("\n");
778
+ if (latestProviderPayloadCapture) {
779
+ return {
780
+ status: "captured",
781
+ capture: latestProviderPayloadCapture,
782
+ };
783
+ }
784
+ return { status: "idle" };
1137
785
  }
1138
786
 
1139
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[] } {
1140
792
  const options = ctx.getSystemPromptOptions();
1141
793
  const sessionContext = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
1142
794
  const latestUserMessage = getLatestUserMessage(sessionContext.messages);
@@ -1145,8 +797,23 @@ export default function piForge(pi: ExtensionAPI) {
1145
797
  const system = compileSystemPrompt(target.stack, runtime, ctx.getSystemPrompt());
1146
798
  const messages = compileMessages(target.stack, runtime, sessionContext.messages);
1147
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
+ };
1148
815
 
1149
- return [
816
+ const text = [
1150
817
  `# Prompt stack preview: ${target.stack.id}`,
1151
818
  "",
1152
819
  "## System prompt",
@@ -1155,76 +822,69 @@ export default function piForge(pi: ExtensionAPI) {
1155
822
  "",
1156
823
  "## Message layout",
1157
824
  "",
1158
- renderPreviewMessages(messages.messages),
825
+ renderPreviewSectionText(messageSections),
1159
826
  "",
1160
827
  "## Diagnostics",
1161
828
  "",
1162
829
  renderDiagnostics(diagnostics),
1163
830
  ].join("\n");
1164
- }
1165
831
 
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");
832
+ return { text, preview, diagnostics };
1169
833
  }
1170
834
 
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;
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}`;
1175
838
  }
1176
- console.log(text);
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}`;
1177
847
  }
1178
848
 
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)}`;
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]`;
1186
857
  }
858
+ return text.trimStart();
1187
859
  }
1188
860
 
1189
- function estimatePayloadTokens(payload: string): number {
1190
- return Math.max(1, Math.ceil(payload.length / 4));
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
+ };
1191
870
  }
1192
871
 
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;
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");
1218
875
  }
1219
876
 
1220
- function isSecretKey(key: string): boolean {
1221
- return /(api[-_]?key|authorization|bearer|token|secret|password|cookie|credential)/i.test(key);
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);
1222
883
  }
1223
884
 
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;
885
+ function captureProviderPayload(value: unknown, savePath?: string): WebEditorPayloadCapture {
886
+ const capture = createProviderPayloadCapture(value, { stackId: active?.stack.id, savePath });
887
+ latestProviderPayloadCapture = capture;
888
+ return capture;
1229
889
  }
1230
890
  }