@zihanw/pi-forge 0.1.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,34 +1,71 @@
1
- import { writeFileSync, existsSync, mkdirSync } from "node:fs";
2
- import { join } 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 } 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, PromptStackDiagnostic, PromptVariableStore } from "./types.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";
17
22
 
18
23
  const STATE_ENTRY_TYPE = "pi-forge-prompt-stack-state";
19
24
  const VARIABLE_ENTRY_TYPE = "pi-forge-variable-state";
20
- const AGENT_VAR_PREFIX = "agent.";
25
+ const WEB_EDITOR_GLOBAL_KEY = "__piForgeWebEditor";
26
+
27
+ type PayloadDisplayTarget = "editor" | "web";
28
+
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];
47
+ }
21
48
 
22
49
  export default function piForge(pi: ExtensionAPI) {
50
+ const sharedWebEditors = getSharedWebEditorRegistry();
23
51
  let stacks: LoadedPromptStack[] = [];
24
52
  let active: LoadedPromptStack | undefined;
25
53
  let currentSystemPromptOptions: BuildSystemPromptOptions | undefined;
26
54
  let currentLatestUserMessage: string | undefined;
27
55
  let currentVariableStore: PromptVariableStore | undefined;
28
56
  let contextRewritePending = false;
29
- let sessionVariables: Record<string, string> = {};
57
+ let sessionVariables: Record<string, PromptVariableValue> = {};
30
58
  let lastPersistedActiveId: string | undefined;
31
59
  let interceptNextProviderPayload = false;
60
+ let interceptPayloadSavePath: string | undefined;
61
+ let interceptPayloadDisplayTarget: PayloadDisplayTarget = "editor";
62
+ let payloadCaptureArmedAt: string | undefined;
63
+ let latestProviderPayloadCapture: WebEditorPayloadCapture | undefined;
64
+ let latestCompileDiagnostics: PromptStackDiagnostic[] = [];
65
+ let webEditor: WebEditorServer | undefined;
66
+ let webEditorCwd: string | undefined;
67
+ let webEditorPreferredPort: number | undefined;
68
+ let toolPolicyBaseline: string[] | undefined;
32
69
 
33
70
  function activeId(): string | undefined {
34
71
  return active?.stack.id;
@@ -50,6 +87,7 @@ export default function piForge(pi: ExtensionAPI) {
50
87
  active = undefined;
51
88
  if (id) persistActiveSelection("none");
52
89
  if (ctx) updateStatus(ctx);
90
+ syncActiveToolPolicy(ctx);
53
91
  return true;
54
92
  }
55
93
 
@@ -58,6 +96,7 @@ export default function piForge(pi: ExtensionAPI) {
58
96
  active = found;
59
97
  persistActiveSelection(found.stack.id);
60
98
  if (ctx) updateStatus(ctx);
99
+ syncActiveToolPolicy(ctx);
61
100
  return true;
62
101
  }
63
102
 
@@ -65,6 +104,7 @@ export default function piForge(pi: ExtensionAPI) {
65
104
  if (!ctx.isProjectTrusted()) {
66
105
  stacks = [];
67
106
  active = undefined;
107
+ syncActiveToolPolicy(ctx);
68
108
  ctx.ui.notify("pi-forge: project is not trusted; prompt stacks are disabled.", "warning");
69
109
  updateStatus(ctx);
70
110
  return;
@@ -73,6 +113,7 @@ export default function piForge(pi: ExtensionAPI) {
73
113
  stacks = loadPromptStacks(ctx.cwd);
74
114
  active = chooseDefaultStack(stacks, preferredId);
75
115
  updateStatus(ctx);
116
+ syncActiveToolPolicy(ctx);
76
117
  }
77
118
 
78
119
  function updateStatus(ctx: ExtensionContext): void {
@@ -80,9 +121,42 @@ export default function piForge(pi: ExtensionAPI) {
80
121
  ctx.ui.setStatus("pi-forge", ctx.ui.theme.fg("accent", "stack:" + active.stack.id));
81
122
  } else {
82
123
  ctx.ui.setStatus("pi-forge", undefined);
124
+ latestCompileDiagnostics = [];
125
+ ctx.ui.setStatus("pi-forge-diagnostics", undefined);
83
126
  }
84
127
  }
85
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
+
86
160
  function notifyActivePreset(ctx: ExtensionContext, detail: string): void {
87
161
  if (!active) return;
88
162
  const errorCount = active.diagnostics.filter((d) => d.level === "error").length;
@@ -91,8 +165,152 @@ export default function piForge(pi: ExtensionAPI) {
91
165
  ctx.ui.notify("pi-forge: active preset " + active.stack.id + suffix + " (" + detail + ")", errorCount ? "error" : "info");
92
166
  }
93
167
 
168
+ function recordCompileDiagnostics(ctx: ExtensionContext, diagnostics: PromptStackDiagnostic[]): void {
169
+ latestCompileDiagnostics = diagnostics;
170
+ const errors = diagnostics.filter((d) => d.level === "error").length;
171
+ const warnings = diagnostics.filter((d) => d.level === "warning").length;
172
+ if (errors || warnings) {
173
+ ctx.ui.setStatus("pi-forge-diagnostics", ctx.ui.theme.fg(errors ? "error" : "warning", `forge:${errors}e/${warnings}w`));
174
+ return;
175
+ }
176
+ ctx.ui.setStatus("pi-forge-diagnostics", undefined);
177
+ }
178
+
179
+ function webHostRuntime(ctx: ExtensionCommandContext): WebHostRuntime {
180
+ return {
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() };
192
+ },
193
+ clearPayload: () => {
194
+ clearPayloadCapture(ctx);
195
+ return { ok: true, ...webPayloadSnapshot() };
196
+ },
197
+ };
198
+ }
199
+
200
+ function sharedWebEditorForCwd(cwd: string): SharedWebEditorState {
201
+ sharedWebEditors.byCwd[cwd] ??= {};
202
+ return sharedWebEditors.byCwd[cwd];
203
+ }
204
+
205
+ function syncWebEditorFromShared(cwd: string): void {
206
+ const shared = sharedWebEditorForCwd(cwd);
207
+ webEditor = shared.server;
208
+ webEditorCwd = shared.cwd;
209
+ webEditorPreferredPort = shared.preferredPort;
210
+ }
211
+
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
+ }
221
+
222
+ function clearWebEditor(server: WebEditorServer): void {
223
+ if (webEditor === server) {
224
+ webEditor = undefined;
225
+ webEditorCwd = undefined;
226
+ webEditorPreferredPort = undefined;
227
+ }
228
+ for (const [cwd, shared] of Object.entries(sharedWebEditors.byCwd)) {
229
+ if (shared?.server === server) delete sharedWebEditors.byCwd[cwd];
230
+ }
231
+ }
232
+
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}`));
240
+ }
241
+
242
+ async function openWebEditor(ctx: ExtensionCommandContext, mode: "open" | "restart" = "open"): Promise<void> {
243
+ syncWebEditorFromShared(ctx.cwd);
244
+ const settings = loadWebEditorSettings(ctx);
245
+ for (const warning of settings.warnings) ctx.ui.notify(warning, "warning");
246
+
247
+ if (webEditor && (mode === "restart" || webEditorPreferredPort !== settings.preferredPort)) {
248
+ const server = webEditor;
249
+ await server.close();
250
+ clearWebEditor(server);
251
+ ctx.ui.setStatus("pi-forge-editor", undefined);
252
+ }
253
+
254
+ if (!webEditor) {
255
+ try {
256
+ webEditor = await startWebEditorServer(createWebEditorHost(ctx, webHostRuntime(ctx)), { port: settings.preferredPort });
257
+ } catch (error) {
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
+ }
275
+ }
276
+ rememberWebEditor(webEditor, ctx.cwd, settings.preferredPort);
277
+ ctx.ui.setStatus("pi-forge-editor", ctx.ui.theme.fg("accent", `editor:${webEditor.port}`));
278
+ ctx.ui.notify(`pi-forge: stack editor running at ${webEditor.url}`, "info");
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}`));
283
+ ctx.ui.notify(`pi-forge: stack editor already running at ${webEditor.url}`, "info");
284
+ }
285
+
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}`);
287
+ }
288
+
289
+ async function stopWebEditor(ctx: ExtensionCommandContext): Promise<void> {
290
+ syncWebEditorFromShared(ctx.cwd);
291
+ if (!webEditor) {
292
+ ctx.ui.notify("pi-forge: stack editor is not running.", "info");
293
+ return;
294
+ }
295
+ const server = webEditor;
296
+ await server.close();
297
+ clearWebEditor(server);
298
+ ctx.ui.setStatus("pi-forge-editor", undefined);
299
+ ctx.ui.notify("pi-forge: stack editor stopped.", "info");
300
+ }
301
+
302
+ function getCurrentBranchEntries(ctx: ExtensionContext): unknown[] {
303
+ const leafId = ctx.sessionManager.getLeafId();
304
+ if (leafId === null) return [];
305
+ const sessionManager = ctx.sessionManager as {
306
+ getBranch?: (fromId?: string) => unknown[];
307
+ getEntries: () => unknown[];
308
+ };
309
+ return sessionManager.getBranch ? sessionManager.getBranch(leafId ?? undefined) : sessionManager.getEntries();
310
+ }
311
+
94
312
  function getRestoredActiveId(ctx: ExtensionContext): string | undefined {
95
- const entries = ctx.sessionManager.getEntries();
313
+ const entries = getCurrentBranchEntries(ctx);
96
314
  for (let i = entries.length - 1; i >= 0; i--) {
97
315
  const entry = entries[i] as { type?: string; customType?: string; data?: { activeStackId?: unknown } };
98
316
  if (entry.type === "custom" && entry.customType === STATE_ENTRY_TYPE) {
@@ -102,13 +320,13 @@ export default function piForge(pi: ExtensionAPI) {
102
320
  return undefined;
103
321
  }
104
322
 
105
- function getRestoredVariables(ctx: ExtensionContext): Record<string, string> {
106
- const entries = ctx.sessionManager.getEntries();
323
+ function getRestoredVariables(ctx: ExtensionContext): Record<string, PromptVariableValue> {
324
+ const entries = getCurrentBranchEntries(ctx);
107
325
  for (let i = entries.length - 1; i >= 0; i--) {
108
326
  const entry = entries[i] as { type?: string; customType?: string; data?: { variables?: unknown } };
109
327
  if (entry.type !== "custom" || entry.customType !== VARIABLE_ENTRY_TYPE) continue;
110
328
  if (!entry.data || typeof entry.data.variables !== "object" || Array.isArray(entry.data.variables)) return {};
111
- return normalizeStringRecord(entry.data.variables as Record<string, unknown>);
329
+ return normalizeVariableRecord(entry.data.variables as Record<string, unknown>);
112
330
  }
113
331
  return {};
114
332
  }
@@ -120,32 +338,52 @@ export default function piForge(pi: ExtensionAPI) {
120
338
  markSessionVariablesClean(store);
121
339
  }
122
340
 
123
- function normalizeStringRecord(value: Record<string, unknown>): Record<string, string> {
124
- const result: Record<string, string> = {};
341
+ function normalizeVariableRecord(value: Record<string, unknown>): Record<string, PromptVariableValue> {
342
+ const result: Record<string, PromptVariableValue> = {};
125
343
  for (const [key, raw] of Object.entries(value)) {
126
- if (typeof raw === "string") result[key] = raw;
344
+ if (isPromptVariableValue(raw)) result[key] = raw;
127
345
  }
128
346
  return result;
129
347
  }
130
348
 
131
- pi.on("session_start", async (event, ctx) => {
349
+ function isPromptVariableValue(value: unknown): value is PromptVariableValue {
350
+ if (value === null) return true;
351
+ const type = typeof value;
352
+ if (type === "string" || type === "boolean") return true;
353
+ if (type === "number") return Number.isFinite(value);
354
+ if (Array.isArray(value)) return value.every(isPromptVariableValue);
355
+ if (!value || typeof value !== "object") return false;
356
+ return Object.values(value as Record<string, unknown>).every(isPromptVariableValue);
357
+ }
358
+
359
+ function restoreBranchScopedRuntime(ctx: ExtensionContext): void {
132
360
  sessionVariables = getRestoredVariables(ctx);
133
361
  currentVariableStore = undefined;
134
362
  const restoredActiveId = getRestoredActiveId(ctx);
135
363
  lastPersistedActiveId = restoredActiveId;
136
364
  reloadStacks(ctx, restoredActiveId);
365
+ }
366
+
367
+ pi.on("session_start", async (event, ctx) => {
368
+ restoreBranchScopedRuntime(ctx);
369
+ refreshWebEditorHost(ctx);
137
370
  notifyActivePreset(ctx, "after session " + event.reason);
138
371
  });
139
372
 
140
373
  pi.on("session_tree", async (_event, ctx) => {
374
+ restoreBranchScopedRuntime(ctx);
375
+ refreshWebEditorHost(ctx);
141
376
  notifyActivePreset(ctx, "after tree navigation");
142
377
  });
143
378
 
144
379
  pi.on("session_compact", async (_event, ctx) => {
380
+ restoreBranchScopedRuntime(ctx);
381
+ refreshWebEditorHost(ctx);
145
382
  notifyActivePreset(ctx, "after compaction");
146
383
  });
147
384
 
148
385
  pi.on("turn_start", async () => {
386
+ syncActiveToolPolicy();
149
387
  const id = activeId();
150
388
  if (id) persistActiveSelection(id);
151
389
  });
@@ -164,6 +402,7 @@ export default function piForge(pi: ExtensionAPI) {
164
402
  { options: event.systemPromptOptions, ctx, latestUserMessage: event.prompt, now: new Date(), variables: currentVariableStore },
165
403
  event.systemPrompt,
166
404
  );
405
+ recordCompileDiagnostics(ctx, result.diagnostics);
167
406
  persistVariablesIfDirty(currentVariableStore);
168
407
 
169
408
  return { systemPrompt: result.systemPrompt };
@@ -185,10 +424,20 @@ export default function piForge(pi: ExtensionAPI) {
185
424
  { options: currentSystemPromptOptions, ctx, latestUserMessage, now: new Date(), variables: currentVariableStore },
186
425
  event.messages,
187
426
  );
427
+ recordCompileDiagnostics(ctx, [...latestCompileDiagnostics, ...result.diagnostics]);
188
428
  persistVariablesIfDirty(currentVariableStore);
189
429
  return { messages: result.messages };
190
430
  });
191
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
+
192
441
  pi.on("agent_end", async () => {
193
442
  persistVariablesIfDirty(currentVariableStore);
194
443
  currentSystemPromptOptions = undefined;
@@ -199,64 +448,100 @@ export default function piForge(pi: ExtensionAPI) {
199
448
 
200
449
  pi.on("before_provider_request", async (event, ctx) => {
201
450
  if (!interceptNextProviderPayload) return;
451
+ const savePath = interceptPayloadSavePath;
452
+ const displayTarget = interceptPayloadDisplayTarget;
202
453
  interceptNextProviderPayload = false;
454
+ interceptPayloadSavePath = undefined;
455
+ interceptPayloadDisplayTarget = "editor";
456
+ payloadCaptureArmedAt = undefined;
203
457
  ctx.ui.setStatus("pi-forge-intercept", undefined);
204
458
 
205
- const payload = safeStringify(event.payload);
459
+ const capture = captureProviderPayload(event.payload, savePath);
460
+ if (savePath) {
461
+ if (!ctx.isProjectTrusted()) {
462
+ ctx.ui.notify("pi-forge: project is not trusted; refusing to save provider payload.", "warning");
463
+ } else {
464
+ const resolvedPath = savePath.startsWith("/") ? savePath : join(ctx.cwd, savePath);
465
+ mkdirSync(dirname(resolvedPath), { recursive: true });
466
+ writeFileSync(resolvedPath, capture.text, "utf8");
467
+ ctx.ui.notify(`pi-forge: provider payload saved to ${resolvedPath} (${capture.chars} chars, ~${capture.approxTokens} tokens)`, "info");
468
+ }
469
+ }
470
+
471
+ if (displayTarget === "web") {
472
+ ctx.ui.notify(`pi-forge: provider payload captured for web editor (${capture.chars} chars, ~${capture.approxTokens} tokens).`, "info");
473
+ return;
474
+ }
475
+
206
476
  if (ctx.hasUI) {
207
- await ctx.ui.editor("pi-forge: provider payload", payload);
477
+ await ctx.ui.editor(`pi-forge: provider payload (${capture.chars} chars, ~${capture.approxTokens} tokens)`, capture.text);
208
478
  return;
209
479
  }
210
480
 
211
- console.log(payload);
481
+ console.log(capture.text);
212
482
  });
213
483
 
214
- pi.registerTool({
215
- name: "forge_set_var",
216
- label: "Set Prompt Variable",
217
- description: "Set a persistent session variable in the active prompt stack. Only variables starting with 'agent.' can be written by the agent; other variables are read-only.",
218
- promptSnippet: "Set agent-scoped prompt stack variables for cross-turn state (character mood, task progress, discovered facts).",
219
- promptGuidelines: [
220
- "Use forge_set_var to persist state across turns — character mood, story progress, task checkpoints, or discovered facts the user might ask about later.",
221
- `Only variable names starting with '${AGENT_VAR_PREFIX}' are writable. Static and manually-set variables are read-only.`,
222
- "Do not use forge_set_var for prompt configuration; it is for tracked runtime state only.",
223
- ],
224
- parameters: Type.Object({
225
- name: Type.String({ description: `Variable name (must start with '${AGENT_VAR_PREFIX}')` }),
226
- value: Type.String({ description: "Variable value" }),
227
- }),
228
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
229
- const { name, value } = params;
230
- if (!name.startsWith(AGENT_VAR_PREFIX)) {
231
- return {
232
- content: [{ type: "text", text: `Error: only variables starting with '${AGENT_VAR_PREFIX}' are writable by the agent. Received: ${name}` }],
233
- details: { error: "namespace restriction", name },
234
- };
235
- }
236
- sessionVariables[name] = value;
237
- pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: { ...sessionVariables } });
238
- return {
239
- content: [{ type: "text", text: `Variable ${name} set.` }],
240
- details: { name, value },
241
- };
242
- },
243
- });
484
+ function armPayloadIntercept(ctx: ExtensionCommandContext, savePath?: string, displayTarget: PayloadDisplayTarget = "editor"): void {
485
+ interceptNextProviderPayload = true;
486
+ interceptPayloadSavePath = savePath;
487
+ interceptPayloadDisplayTarget = displayTarget;
488
+ payloadCaptureArmedAt = new Date().toISOString();
489
+ latestProviderPayloadCapture = undefined;
490
+ ctx.ui.setStatus("pi-forge-intercept", ctx.ui.theme.fg("warning", savePath ? "payload:armed+save" : "payload:armed"));
491
+ if (displayTarget === "web") {
492
+ ctx.ui.notify(savePath ? `pi-forge: next provider payload will be captured in the web editor and saved to ${savePath}.` : "pi-forge: next provider payload will be captured in the web editor.", "info");
493
+ return;
494
+ }
495
+ ctx.ui.notify(savePath ? `pi-forge: next provider payload will be displayed and saved to ${savePath}.` : "pi-forge: next provider payload will be displayed before sending.", "info");
496
+ }
497
+
498
+ function clearPayloadCapture(ctx: ExtensionCommandContext): void {
499
+ interceptNextProviderPayload = false;
500
+ interceptPayloadSavePath = undefined;
501
+ interceptPayloadDisplayTarget = "editor";
502
+ payloadCaptureArmedAt = undefined;
503
+ latestProviderPayloadCapture = undefined;
504
+ ctx.ui.setStatus("pi-forge-intercept", undefined);
505
+ }
244
506
 
245
507
  pi.registerCommand("intercept", {
246
508
  description: "Display the next provider payload before it is sent",
247
509
  handler: async (_args, ctx) => {
248
- interceptNextProviderPayload = true;
249
- ctx.ui.setStatus("pi-forge-intercept", ctx.ui.theme.fg("warning", "intercept:armed"));
250
- ctx.ui.notify("pi-forge: next provider payload will be displayed before sending.", "info");
510
+ armPayloadIntercept(ctx);
511
+ },
512
+ });
513
+
514
+ pi.registerCommand("payload", {
515
+ description: "Inspect or save provider payloads: /payload next [save=<path>]",
516
+ getArgumentCompletions: (prefix) => {
517
+ const parts = prefix.trimStart().split(/\s+/);
518
+ if (parts.length <= 1 && !prefix.endsWith(" ")) {
519
+ return ["next"].filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
520
+ }
521
+ if (parts[0] === "next" && parts.length <= 2) {
522
+ const suggestion = "save=.pi/forge/payloads/last.json";
523
+ return suggestion.startsWith(parts[1] ?? "") ? [{ value: `next ${suggestion}`, label: suggestion }] : null;
524
+ }
525
+ return null;
526
+ },
527
+ handler: async (args, ctx) => {
528
+ const [command = "next", ...rest] = args.trim() ? args.trim().split(/\s+/) : ["next"];
529
+ if (command !== "next") {
530
+ ctx.ui.notify(`Unknown /payload subcommand: ${command}`, "warning");
531
+ return;
532
+ }
533
+ const saveArg = rest.find((arg) => arg.startsWith("save="));
534
+ const savePath = saveArg?.slice("save=".length).trim() || undefined;
535
+ armPayloadIntercept(ctx, savePath);
251
536
  },
252
537
  });
253
538
 
254
539
  pi.registerCommand("preset", {
255
- description: "Manage pi-forge prompt stacks: list, use, preview, validate, reload, vars",
540
+ description: "Manage pi-forge prompt stacks: list, use, preview, validate, reload, ui",
256
541
  getArgumentCompletions: (prefix) => {
257
542
  const parts = prefix.trimStart().split(/\s+/);
258
543
  if (parts.length <= 1 && !prefix.endsWith(" ")) {
259
- const commands = ["list", "use", "preview", "validate", "reload", "status", "vars", "import-silly"];
544
+ const commands = ["list", "use", "preview", "validate", "diagnostics", "reload", "status", "import-silly", "migrate-stacks", "ui"];
260
545
  return commands.filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
261
546
  }
262
547
  const first = parts[0];
@@ -265,17 +550,15 @@ export default function piForge(pi: ExtensionAPI) {
265
550
  const ids = ["none", ...stacks.map((loaded) => loaded.stack.id)];
266
551
  return ids.filter((id) => id.startsWith(fragment)).map((id) => ({ value: `${first} ${id}`, label: id }));
267
552
  }
268
- if (first === "vars") {
269
- const sub = parts[1] ?? "";
270
- if (parts.length <= 2 && !prefix.endsWith(" ")) {
271
- const subs = ["set", "get", "clear", "list"];
272
- return subs.filter((s) => s.startsWith(sub)).map((s) => ({ value: `vars ${s}`, label: s }));
273
- }
274
- if (["set", "get", "clear"].includes(sub) && parts.length <= 3) {
275
- const fragment = parts[2] ?? "";
276
- const names = Object.keys(sessionVariables);
277
- return names.filter((n) => n.startsWith(fragment)).map((n) => ({ value: `vars ${sub} ${n}`, label: n }));
278
- }
553
+ if (first === "ui" && parts.length <= 2) {
554
+ const fragment = parts[1] ?? "";
555
+ const subs = ["stop", "restart"];
556
+ return subs.filter((s) => s.startsWith(fragment)).map((s) => ({ value: `ui ${s}`, label: s }));
557
+ }
558
+ if (first === "migrate-stacks") {
559
+ const fragment = parts[parts.length - 1] ?? "";
560
+ const flags = ["--dry-run", "--overwrite", "--delete-legacy"];
561
+ return flags.filter((flag) => flag.startsWith(fragment)).map((flag) => ({ value: `${parts.slice(0, -1).join(" ")} ${flag}`.trim(), label: flag }));
279
562
  }
280
563
  return null;
281
564
  },
@@ -299,49 +582,13 @@ export default function piForge(pi: ExtensionAPI) {
299
582
  ctx.ui.notify(`pi-forge: reloaded ${stacks.length} prompt stack(s).`, "info");
300
583
  return;
301
584
 
302
- case "vars": {
585
+ case "ui": {
303
586
  const sub = rest[0];
304
-
305
- if (sub === "set") {
306
- const name = rest[1];
307
- const value = rest.slice(2).join(" ");
308
- if (!name || value === "") {
309
- ctx.ui.notify("Usage: /preset vars set <name> <value>", "warning");
310
- return;
311
- }
312
- sessionVariables[name] = value;
313
- pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: { ...sessionVariables } });
314
- ctx.ui.notify(`pi-forge: set session variable ${name} = ${JSON.stringify(value)}`, "info");
587
+ if (sub === "stop") {
588
+ await stopWebEditor(ctx);
315
589
  return;
316
590
  }
317
-
318
- if (sub === "get") {
319
- const name = rest[1];
320
- if (!name) {
321
- ctx.ui.notify("Usage: /preset vars get <name>", "warning");
322
- return;
323
- }
324
- const value = sessionVariables[name];
325
- if (value === undefined) {
326
- await showText(ctx, `pi-forge variable: ${name}`, `# ${name}\n\n(not set)`);
327
- } else {
328
- await showText(ctx, `pi-forge variable: ${name}`, `# ${name}\n\n${value}`);
329
- }
330
- return;
331
- }
332
-
333
- if (sub === "clear") {
334
- const name = rest[1];
335
- if (name) delete sessionVariables[name];
336
- else sessionVariables = {};
337
- currentVariableStore = createPromptVariableStore(sessionVariables);
338
- pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: sessionVariables });
339
- ctx.ui.notify(name ? `pi-forge: cleared session variable ${name}` : "pi-forge: cleared all session variables", "info");
340
- return;
341
- }
342
-
343
- // No subcommand or "list" — show all variables
344
- await showText(ctx, "pi-forge variables", renderVariablesList());
591
+ await openWebEditor(ctx, sub === "restart" ? "restart" : "open");
345
592
  return;
346
593
  }
347
594
 
@@ -379,23 +626,59 @@ export default function piForge(pi: ExtensionAPI) {
379
626
  return;
380
627
  }
381
628
 
629
+ case "diagnostics": {
630
+ await showText(ctx, "pi-forge diagnostics", renderCurrentDiagnostics());
631
+ return;
632
+ }
633
+
634
+ case "migrate-stacks": {
635
+ const flags = new Set(rest);
636
+ const dryRun = flags.has("--dry-run");
637
+ if (!ctx.isProjectTrusted() && !dryRun) {
638
+ ctx.ui.notify("pi-forge: project is not trusted; refusing to migrate prompt stacks.", "warning");
639
+ return;
640
+ }
641
+ const report = migrateLegacyPromptStacks(ctx.cwd, {
642
+ dryRun,
643
+ overwrite: flags.has("--overwrite"),
644
+ deleteLegacy: flags.has("--delete-legacy"),
645
+ });
646
+ if (!dryRun) reloadStacks(ctx, selectedActiveId());
647
+ const changed = report.copied + report.overwritten;
648
+ const summary = dryRun
649
+ ? `pi-forge: migration dry run found ${report.files.length} legacy stack file(s).`
650
+ : `pi-forge: migrated ${changed} legacy stack file(s), skipped ${report.skipped}, errors ${report.errors}.`;
651
+ ctx.ui.notify(summary, report.errors ? "warning" : "info");
652
+ await showText(ctx, "pi-forge prompt-stack migration", renderMigrationReport(report));
653
+ return;
654
+ }
655
+
382
656
  case "import-silly": {
657
+ if (!ctx.isProjectTrusted()) {
658
+ ctx.ui.notify("pi-forge: project is not trusted; refusing to write imported prompt stacks.", "warning");
659
+ return;
660
+ }
661
+
383
662
  const sourcePath = rest[0];
384
663
  if (!sourcePath) {
385
- ctx.ui.notify("Usage: /preset import-silly <path> [character_id]", "warning");
664
+ ctx.ui.notify("Usage: /preset import-silly <path> [character_id] [--dry-run] [--overwrite]", "warning");
386
665
  return;
387
666
  }
388
667
 
668
+ const charIdToken = rest[1]?.startsWith("--") ? undefined : rest[1];
669
+ const flags = new Set(rest.slice(charIdToken ? 2 : 1));
670
+ const dryRun = flags.has("--dry-run");
671
+ let overwrite = flags.has("--overwrite");
672
+
389
673
  const resolvedPath = sourcePath.startsWith("/") ? sourcePath : join(ctx.cwd, sourcePath);
390
674
  if (!existsSync(resolvedPath)) {
391
675
  ctx.ui.notify(`File not found: ${resolvedPath}`, "error");
392
676
  return;
393
677
  }
394
678
 
395
- const charIdStr = rest[1];
396
- const charId = charIdStr ? Number(charIdStr) : undefined;
397
- if (charIdStr && (Number.isNaN(charId) || !Number.isFinite(charId))) {
398
- ctx.ui.notify(`Invalid character_id: ${charIdStr}`, "error");
679
+ const charId = charIdToken ? Number(charIdToken) : undefined;
680
+ if (charIdToken && (Number.isNaN(charId) || !Number.isFinite(charId))) {
681
+ ctx.ui.notify(`Invalid character_id: ${charIdToken}`, "error");
399
682
  return;
400
683
  }
401
684
 
@@ -405,16 +688,34 @@ export default function piForge(pi: ExtensionAPI) {
405
688
  return;
406
689
  }
407
690
 
408
- // Write the prompt stack
409
- const stacksDir = promptStacksDir(ctx.cwd);
691
+ const existingStack = stacks.find((candidate) => candidate.stack.id === result.stack.id);
692
+ const stackPath = existingStack?.filePath ?? promptStackPath(ctx.cwd, result.stack.id);
693
+ const stacksDir = dirname(stackPath);
694
+ const reportDir = join(ctx.cwd, ".pi", "forge", "import-reports");
695
+ const reportPath = join(reportDir, `${result.stack.id}.md`);
696
+
697
+ if (dryRun) {
698
+ await showText(ctx, `pi-forge import dry run: ${result.stack.id}`, `Would write stack to: ${stackPath}\nWould write report to: ${reportPath}\n\n## Generated stack JSON\n\n\`\`\`json\n${JSON.stringify(result.stack, null, 2)}\n\`\`\`\n\n${result.report}`);
699
+ return;
700
+ }
701
+
702
+ const existingPaths = [stackPath, reportPath].filter((path) => existsSync(path));
703
+ if (existingPaths.length > 0 && !overwrite) {
704
+ if (!ctx.hasUI) {
705
+ ctx.ui.notify(`pi-forge: import would overwrite existing file(s): ${existingPaths.join(", ")}. Re-run with --overwrite.`, "error");
706
+ return;
707
+ }
708
+ overwrite = await ctx.ui.confirm("Overwrite pi-forge import output?", `These file(s) already exist:\n${existingPaths.join("\n")}\n\nOverwrite them?`);
709
+ if (!overwrite) {
710
+ ctx.ui.notify("pi-forge: import cancelled; existing files were left unchanged.", "info");
711
+ return;
712
+ }
713
+ }
714
+
410
715
  if (!existsSync(stacksDir)) mkdirSync(stacksDir, { recursive: true });
411
- const stackPath = join(stacksDir, `${result.stack.id}.json`);
412
716
  writeFileSync(stackPath, JSON.stringify(result.stack, null, 2), "utf8");
413
717
 
414
- // Write the import report
415
- const reportDir = join(ctx.cwd, ".pi", "forge", "import-reports");
416
718
  if (!existsSync(reportDir)) mkdirSync(reportDir, { recursive: true });
417
- const reportPath = join(reportDir, `${result.stack.id}.md`);
418
719
  writeFileSync(reportPath, result.report, "utf8");
419
720
 
420
721
  // Reload stacks to pick up the new one
@@ -433,13 +734,14 @@ export default function piForge(pi: ExtensionAPI) {
433
734
 
434
735
  function renderStackList(ctx: ExtensionCommandContext): string {
435
736
  const lines = [
436
- `Prompt stack directory: ${promptStacksDir(ctx.cwd)}`,
737
+ "Prompt stack directories:",
738
+ ...promptStackReadDirs(ctx.cwd).map((dir, index) => ` ${index === 0 ? "primary" : "legacy"}: ${dir}`),
437
739
  `Active stack: ${active?.stack.id ?? "(none)"}`,
438
740
  "",
439
741
  ];
440
742
 
441
743
  if (stacks.length === 0) {
442
- 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.");
443
745
  return lines.join("\n");
444
746
  }
445
747
 
@@ -452,26 +754,41 @@ export default function piForge(pi: ExtensionAPI) {
452
754
  lines.push(` ${loaded.filePath}`);
453
755
  }
454
756
 
455
- lines.push("", "Commands:", " /preset use <id|none>", " /preset preview [id]", " /preset validate [id]", " /preset reload", " /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]");
456
758
  return lines.join("\n");
457
759
  }
458
760
 
459
- function renderVariablesList(): string {
460
- const lines = ["# pi-forge variables", "", "## Session variables", ""];
461
- const sessionEntries = Object.entries(sessionVariables).sort(([a], [b]) => a.localeCompare(b));
462
- if (sessionEntries.length === 0) lines.push("(none)");
463
- else for (const [key, value] of sessionEntries) lines.push(`${key} = ${JSON.stringify(value)}`);
464
-
465
- lines.push("", "## Active stack static variables", "");
466
- const staticEntries = Object.entries(active?.stack.variables ?? {}).sort(([a], [b]) => a.localeCompare(b));
467
- if (staticEntries.length === 0) lines.push("(none)");
468
- else for (const [key, value] of staticEntries) lines.push(`${key} = ${JSON.stringify(value)}`);
469
-
470
- lines.push("", "Turn variables are cleared for each user message and are only visible during prompt compilation.");
761
+ function renderCurrentDiagnostics(): string {
762
+ const lines = ["# pi-forge diagnostics", ""];
763
+ lines.push("## Active stack load/validation diagnostics", "");
764
+ lines.push(active ? renderDiagnostics(active.diagnostics) : "No active prompt stack.");
765
+ lines.push("", "## Latest runtime compile diagnostics", "");
766
+ lines.push(renderDiagnostics(latestCompileDiagnostics));
471
767
  return lines.join("\n");
472
768
  }
473
769
 
770
+ function webPayloadSnapshot(): WebEditorPayloadSnapshot {
771
+ if (interceptNextProviderPayload) {
772
+ return {
773
+ status: "armed",
774
+ armedAt: payloadCaptureArmedAt,
775
+ savePath: interceptPayloadSavePath,
776
+ };
777
+ }
778
+ if (latestProviderPayloadCapture) {
779
+ return {
780
+ status: "captured",
781
+ capture: latestProviderPayloadCapture,
782
+ };
783
+ }
784
+ return { status: "idle" };
785
+ }
786
+
474
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[] } {
475
792
  const options = ctx.getSystemPromptOptions();
476
793
  const sessionContext = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
477
794
  const latestUserMessage = getLatestUserMessage(sessionContext.messages);
@@ -480,8 +797,23 @@ export default function piForge(pi: ExtensionAPI) {
480
797
  const system = compileSystemPrompt(target.stack, runtime, ctx.getSystemPrompt());
481
798
  const messages = compileMessages(target.stack, runtime, sessionContext.messages);
482
799
  const diagnostics = [...target.diagnostics, ...system.diagnostics, ...messages.diagnostics];
483
-
484
- return [
800
+ const messageSections = messages.messages.map((message, index) => {
801
+ const content = agentMessageToPreviewText(message);
802
+ const source = messages.messageSources[index];
803
+ return previewSection(`message-${index}`, previewMessageTitle(source, index), content, message.role);
804
+ });
805
+ const systemSection = previewSection("system", "System prompt", system.systemPrompt || "(empty)");
806
+ const totalChars = systemSection.chars + messageSections.reduce((sum, section) => sum + section.chars, 0);
807
+ const preview: WebEditorPreview = {
808
+ stackId: target.stack.id,
809
+ generatedAt: new Date().toISOString(),
810
+ system: systemSection,
811
+ messages: messageSections,
812
+ totalChars,
813
+ approxTokens: estimatePayloadTokens(`${system.systemPrompt}\n${messageSections.map((section) => section.content).join("\n")}`),
814
+ };
815
+
816
+ const text = [
485
817
  `# Prompt stack preview: ${target.stack.id}`,
486
818
  "",
487
819
  "## System prompt",
@@ -490,12 +822,51 @@ export default function piForge(pi: ExtensionAPI) {
490
822
  "",
491
823
  "## Message layout",
492
824
  "",
493
- renderPreviewMessages(messages.messages),
825
+ renderPreviewSectionText(messageSections),
494
826
  "",
495
827
  "## Diagnostics",
496
828
  "",
497
829
  renderDiagnostics(diagnostics),
498
830
  ].join("\n");
831
+
832
+ return { text, preview, diagnostics };
833
+ }
834
+
835
+ function previewMessageTitle(source: CompileMessageSource | undefined, index: number): string {
836
+ if (source?.kind === "stack-item") {
837
+ return source.itemName?.trim() || source.itemId || `Stack item ${index + 1}`;
838
+ }
839
+ if (source?.kind === "chat-history") {
840
+ const label = source.itemName?.trim() || "Chat history";
841
+ return `${label} #${source.historyIndex ?? index + 1}`;
842
+ }
843
+ if (source?.kind === "implicit-history") {
844
+ return `Conversation history #${source.historyIndex ?? index + 1}`;
845
+ }
846
+ return `Message ${index + 1}`;
847
+ }
848
+
849
+ function renderPreviewSectionText(sections: WebEditorPreviewSection[], maxChars = 8000): string {
850
+ let text = "";
851
+ for (const section of sections) {
852
+ const role = section.role ? ` (${section.role})` : "";
853
+ text += `\n--- ${section.title}${role} ---\n`;
854
+ text += section.content;
855
+ text += "\n";
856
+ if (text.length > maxChars) return `${text.slice(0, maxChars)}\n\n[preview truncated]`;
857
+ }
858
+ return text.trimStart();
859
+ }
860
+
861
+ function previewSection(id: string, title: string, content: string, role?: string): WebEditorPreviewSection {
862
+ return {
863
+ id,
864
+ title,
865
+ role,
866
+ content,
867
+ chars: content.length,
868
+ approxTokens: estimatePayloadTokens(content),
869
+ };
499
870
  }
500
871
 
501
872
  function renderDiagnostics(diagnostics: PromptStackDiagnostic[]): string {
@@ -511,11 +882,9 @@ export default function piForge(pi: ExtensionAPI) {
511
882
  console.log(text);
512
883
  }
513
884
 
514
- function safeStringify(value: unknown): string {
515
- try {
516
- return JSON.stringify(value, null, 2);
517
- } catch (error) {
518
- return `Failed to stringify provider payload: ${error instanceof Error ? error.message : String(error)}`;
519
- }
885
+ function captureProviderPayload(value: unknown, savePath?: string): WebEditorPayloadCapture {
886
+ const capture = createProviderPayloadCapture(value, { stackId: active?.stack.id, savePath });
887
+ latestProviderPayloadCapture = capture;
888
+ return capture;
520
889
  }
521
890
  }