@zihanw/pi-forge 0.1.0 → 0.2.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,5 +1,5 @@
1
- import { writeFileSync, existsSync, mkdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { buildSessionContext, type BuildSystemPromptOptions, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
5
  import {
@@ -11,14 +11,23 @@ import {
11
11
  renderPreviewMessages,
12
12
  resetTurnVariables,
13
13
  } from "./compiler.ts";
14
- import { chooseDefaultStack, isDisabledPromptStackId, loadPromptStacks, promptStacksDir } from "./loader.ts";
14
+ import { chooseDefaultStack, isDisabledPromptStackId, loadPromptStacks, promptStacksDir, validatePromptStack } from "./loader.ts";
15
15
  import { importSillyTavernPreset } from "./sillytavern-importer.ts";
16
- import type { LoadedPromptStack, PromptStackDiagnostic, PromptVariableStore } from "./types.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";
17
18
 
18
19
  const STATE_ENTRY_TYPE = "pi-forge-prompt-stack-state";
19
20
  const VARIABLE_ENTRY_TYPE = "pi-forge-variable-state";
20
21
  const AGENT_VAR_PREFIX = "agent.";
21
22
 
23
+ type StateActor = "agent" | "user";
24
+
25
+ interface StateUpdateInput {
26
+ name: string;
27
+ value: PromptStateValue;
28
+ reason?: string;
29
+ }
30
+
22
31
  export default function piForge(pi: ExtensionAPI) {
23
32
  let stacks: LoadedPromptStack[] = [];
24
33
  let active: LoadedPromptStack | undefined;
@@ -26,9 +35,13 @@ export default function piForge(pi: ExtensionAPI) {
26
35
  let currentLatestUserMessage: string | undefined;
27
36
  let currentVariableStore: PromptVariableStore | undefined;
28
37
  let contextRewritePending = false;
29
- let sessionVariables: Record<string, string> = {};
38
+ let sessionVariables: Record<string, PromptStateValue> = {};
30
39
  let lastPersistedActiveId: string | undefined;
31
40
  let interceptNextProviderPayload = false;
41
+ let interceptPayloadSavePath: string | undefined;
42
+ let latestCompileDiagnostics: PromptStackDiagnostic[] = [];
43
+ let webEditor: WebEditorServer | undefined;
44
+ let webEditorCwd: string | undefined;
32
45
 
33
46
  function activeId(): string | undefined {
34
47
  return active?.stack.id;
@@ -80,6 +93,8 @@ export default function piForge(pi: ExtensionAPI) {
80
93
  ctx.ui.setStatus("pi-forge", ctx.ui.theme.fg("accent", "stack:" + active.stack.id));
81
94
  } else {
82
95
  ctx.ui.setStatus("pi-forge", undefined);
96
+ latestCompileDiagnostics = [];
97
+ ctx.ui.setStatus("pi-forge-diagnostics", undefined);
83
98
  }
84
99
  }
85
100
 
@@ -91,8 +106,264 @@ export default function piForge(pi: ExtensionAPI) {
91
106
  ctx.ui.notify("pi-forge: active preset " + active.stack.id + suffix + " (" + detail + ")", errorCount ? "error" : "info");
92
107
  }
93
108
 
109
+ function recordCompileDiagnostics(ctx: ExtensionContext, diagnostics: PromptStackDiagnostic[]): void {
110
+ latestCompileDiagnostics = diagnostics;
111
+ const errors = diagnostics.filter((d) => d.level === "error").length;
112
+ const warnings = diagnostics.filter((d) => d.level === "warning").length;
113
+ if (errors || warnings) {
114
+ ctx.ui.setStatus("pi-forge-diagnostics", ctx.ui.theme.fg(errors ? "error" : "warning", `forge:${errors}e/${warnings}w`));
115
+ return;
116
+ }
117
+ ctx.ui.setStatus("pi-forge-diagnostics", undefined);
118
+ }
119
+
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;
123
+ 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;
148
+ },
149
+ createStack: (stack, options) => createStackFile(ctx, stack, options),
150
+ saveStack: (id, stack) => {
151
+ if (!ctx.isProjectTrusted()) {
152
+ return { ok: false, status: 403, error: "Project is not trusted; refusing to save prompt stacks." };
153
+ }
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() };
188
+ },
189
+ };
190
+ }
191
+
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);
223
+
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() };
227
+ }
228
+
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
+ }
236
+
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." };
241
+ }
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());
250
+ }
251
+ return { ok: true, activeId: activeId(), stacks: stackSummaries() };
252
+ }
253
+
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;
260
+ }
261
+
262
+ async function openWebEditor(ctx: ExtensionCommandContext, mode: "open" | "restart" = "open"): Promise<void> {
263
+ const settings = loadWebEditorSettings(ctx);
264
+ for (const warning of settings.warnings) ctx.ui.notify(warning, "warning");
265
+
266
+ if (webEditor && (mode === "restart" || webEditorCwd !== ctx.cwd || webEditor.port !== settings.port)) {
267
+ await webEditor.close();
268
+ webEditor = undefined;
269
+ webEditorCwd = undefined;
270
+ ctx.ui.setStatus("pi-forge-editor", undefined);
271
+ }
272
+
273
+ if (!webEditor) {
274
+ try {
275
+ webEditor = await startWebEditorServer(createWebEditorHost(ctx), { port: settings.port });
276
+ } 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;
281
+ }
282
+ webEditorCwd = ctx.cwd;
283
+ ctx.ui.setStatus("pi-forge-editor", ctx.ui.theme.fg("accent", `editor:${webEditor.port}`));
284
+ ctx.ui.notify(`pi-forge: stack editor running at ${webEditor.url}`, "info");
285
+ } else {
286
+ ctx.ui.notify(`pi-forge: stack editor already running at ${webEditor.url}`, "info");
287
+ }
288
+
289
+ 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
+ }
291
+
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
+ async function stopWebEditor(ctx: ExtensionCommandContext): Promise<void> {
337
+ if (!webEditor) {
338
+ ctx.ui.notify("pi-forge: stack editor is not running.", "info");
339
+ return;
340
+ }
341
+ await webEditor.close();
342
+ webEditor = undefined;
343
+ webEditorCwd = undefined;
344
+ ctx.ui.setStatus("pi-forge-editor", undefined);
345
+ ctx.ui.notify("pi-forge: stack editor stopped.", "info");
346
+ }
347
+
348
+ function isInsidePromptStacksDir(cwd: string, filePath: string): boolean {
349
+ const root = resolve(promptStacksDir(cwd));
350
+ const target = resolve(filePath);
351
+ const rel = relative(root, target);
352
+ return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
353
+ }
354
+
355
+ function getCurrentBranchEntries(ctx: ExtensionContext): unknown[] {
356
+ const leafId = ctx.sessionManager.getLeafId();
357
+ if (leafId === null) return [];
358
+ const sessionManager = ctx.sessionManager as {
359
+ getBranch?: (fromId?: string) => unknown[];
360
+ getEntries: () => unknown[];
361
+ };
362
+ return sessionManager.getBranch ? sessionManager.getBranch(leafId ?? undefined) : sessionManager.getEntries();
363
+ }
364
+
94
365
  function getRestoredActiveId(ctx: ExtensionContext): string | undefined {
95
- const entries = ctx.sessionManager.getEntries();
366
+ const entries = getCurrentBranchEntries(ctx);
96
367
  for (let i = entries.length - 1; i >= 0; i--) {
97
368
  const entry = entries[i] as { type?: string; customType?: string; data?: { activeStackId?: unknown } };
98
369
  if (entry.type === "custom" && entry.customType === STATE_ENTRY_TYPE) {
@@ -102,13 +373,13 @@ export default function piForge(pi: ExtensionAPI) {
102
373
  return undefined;
103
374
  }
104
375
 
105
- function getRestoredVariables(ctx: ExtensionContext): Record<string, string> {
106
- const entries = ctx.sessionManager.getEntries();
376
+ function getRestoredVariables(ctx: ExtensionContext): Record<string, PromptStateValue> {
377
+ const entries = getCurrentBranchEntries(ctx);
107
378
  for (let i = entries.length - 1; i >= 0; i--) {
108
379
  const entry = entries[i] as { type?: string; customType?: string; data?: { variables?: unknown } };
109
380
  if (entry.type !== "custom" || entry.customType !== VARIABLE_ENTRY_TYPE) continue;
110
381
  if (!entry.data || typeof entry.data.variables !== "object" || Array.isArray(entry.data.variables)) return {};
111
- return normalizeStringRecord(entry.data.variables as Record<string, unknown>);
382
+ return normalizeStateRecord(entry.data.variables as Record<string, unknown>);
112
383
  }
113
384
  return {};
114
385
  }
@@ -120,28 +391,154 @@ export default function piForge(pi: ExtensionAPI) {
120
391
  markSessionVariablesClean(store);
121
392
  }
122
393
 
123
- function normalizeStringRecord(value: Record<string, unknown>): Record<string, string> {
124
- const result: Record<string, string> = {};
394
+ function normalizeStateRecord(value: Record<string, unknown>): Record<string, PromptStateValue> {
395
+ const result: Record<string, PromptStateValue> = {};
125
396
  for (const [key, raw] of Object.entries(value)) {
126
- if (typeof raw === "string") result[key] = raw;
397
+ if (isPromptStateValue(raw)) result[key] = raw;
127
398
  }
128
399
  return result;
129
400
  }
130
401
 
131
- pi.on("session_start", async (event, ctx) => {
402
+ function isPromptStateValue(value: unknown): value is PromptStateValue {
403
+ if (value === null) return true;
404
+ const type = typeof value;
405
+ if (type === "string" || type === "boolean") return true;
406
+ if (type === "number") return Number.isFinite(value);
407
+ if (Array.isArray(value)) return value.every(isPromptStateValue);
408
+ if (!value || typeof value !== "object") return false;
409
+ return Object.values(value as Record<string, unknown>).every(isPromptStateValue);
410
+ }
411
+
412
+ function validateStateName(name: string): string | undefined {
413
+ if (!name.trim()) return "state name must not be empty";
414
+ if (!/^[A-Za-z0-9_.:-]+$/.test(name)) {
415
+ return "state name may only contain letters, numbers, underscore, dash, dot, and colon";
416
+ }
417
+ return undefined;
418
+ }
419
+
420
+ function validateStateUpdate(update: StateUpdateInput, actor: StateActor): string | undefined {
421
+ const permissionError = validateStateWritePermission(update.name, actor, actor === "agent" ? "write" : "set");
422
+ if (permissionError) return permissionError;
423
+
424
+ const definition = active?.stack.state?.definitions?.[update.name];
425
+ if (definition?.type) {
426
+ const typeError = validateStateValueType(update.value, definition.type);
427
+ if (typeError) return `${update.name}: expected ${definition.type}, got ${typeError}`;
428
+ }
429
+ return undefined;
430
+ }
431
+
432
+ function validateStateClear(name: string, actor: StateActor): string | undefined {
433
+ return validateStateWritePermission(name, actor, "clear");
434
+ }
435
+
436
+ function validateStateWritePermission(name: string, actor: StateActor, action: "set" | "write" | "clear"): string | undefined {
437
+ const nameError = validateStateName(name);
438
+ if (nameError) return `${name || "(empty)"}: ${nameError}`;
439
+
440
+ if (actor === "agent" && !name.startsWith(AGENT_VAR_PREFIX)) {
441
+ return `${name}: agents may only ${action} ${AGENT_VAR_PREFIX}* state`;
442
+ }
443
+
444
+ const definition = active?.stack.state?.definitions?.[name];
445
+ if (actor === "agent" && definition?.agentWritable === false) {
446
+ return `${name}: stack schema marks this state as not agent-writable`;
447
+ }
448
+ if (actor === "user" && definition?.userWritable === false) {
449
+ return `${name}: stack schema marks this state as not user-writable`;
450
+ }
451
+ return undefined;
452
+ }
453
+
454
+ function applyStatePatch(updates: StateUpdateInput[], clears: string[], actor: StateActor): { ok: true; updated: number; cleared: number } | { ok: false; error: string } {
455
+ for (const update of updates) {
456
+ const error = validateStateUpdate(update, actor);
457
+ if (error) return { ok: false, error };
458
+ }
459
+ for (const name of clears) {
460
+ const error = validateStateClear(name, actor);
461
+ if (error) return { ok: false, error };
462
+ }
463
+
464
+ for (const update of updates) {
465
+ sessionVariables[update.name] = update.value;
466
+ if (currentVariableStore) currentVariableStore.session[update.name] = update.value;
467
+ }
468
+ for (const name of clears) {
469
+ delete sessionVariables[name];
470
+ if (currentVariableStore) delete currentVariableStore.session[name];
471
+ }
472
+
473
+ pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: { ...sessionVariables } });
474
+ if (currentVariableStore) markSessionVariablesClean(currentVariableStore);
475
+ return { ok: true, updated: updates.length, cleared: clears.length };
476
+ }
477
+
478
+ function validateStateValueType(value: PromptStateValue, typeExpression: string): string | undefined {
479
+ const types = typeExpression.split("|").map((part) => part.trim()).filter(Boolean);
480
+ if (types.length === 0 || types.some((type) => stateValueMatchesType(value, type))) return undefined;
481
+ return inferRuntimeType(value);
482
+ }
483
+
484
+ function stateValueMatchesType(value: PromptStateValue, type: string): boolean {
485
+ if (type === "any" || type === "unknown" || type === "json" || type === "Json") return true;
486
+ if (type === "null") return value === null;
487
+ if (type === "array" || type === "unknown[]") return Array.isArray(value);
488
+ if (type === "object" || type === "Record<string, unknown>" || type === "Record<string, any>") {
489
+ return !!value && typeof value === "object" && !Array.isArray(value);
490
+ }
491
+ if (type.endsWith("[]")) {
492
+ if (!Array.isArray(value)) return false;
493
+ const itemType = type.slice(0, -2).trim();
494
+ return value.every((item) => stateValueMatchesType(item, itemType));
495
+ }
496
+ return typeof value === type;
497
+ }
498
+
499
+ function inferRuntimeType(value: PromptStateValue): string {
500
+ if (value === null) return "null";
501
+ if (Array.isArray(value)) return "array";
502
+ return typeof value;
503
+ }
504
+
505
+ function parseStateCommandValue(raw: string): PromptStateValue {
506
+ const trimmed = raw.trim();
507
+ if (/^(true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|["[{])/.test(trimmed)) {
508
+ try {
509
+ const parsed = JSON.parse(trimmed);
510
+ if (isPromptStateValue(parsed)) return parsed;
511
+ } catch {
512
+ // Fall back to string for friendly command input.
513
+ }
514
+ }
515
+ return raw;
516
+ }
517
+
518
+ function formatStateValue(value: PromptStateValue): string {
519
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
520
+ }
521
+
522
+ function restoreBranchScopedState(ctx: ExtensionContext): void {
132
523
  sessionVariables = getRestoredVariables(ctx);
133
524
  currentVariableStore = undefined;
134
525
  const restoredActiveId = getRestoredActiveId(ctx);
135
526
  lastPersistedActiveId = restoredActiveId;
136
527
  reloadStacks(ctx, restoredActiveId);
528
+ }
529
+
530
+ pi.on("session_start", async (event, ctx) => {
531
+ restoreBranchScopedState(ctx);
137
532
  notifyActivePreset(ctx, "after session " + event.reason);
138
533
  });
139
534
 
140
535
  pi.on("session_tree", async (_event, ctx) => {
536
+ restoreBranchScopedState(ctx);
141
537
  notifyActivePreset(ctx, "after tree navigation");
142
538
  });
143
539
 
144
540
  pi.on("session_compact", async (_event, ctx) => {
541
+ restoreBranchScopedState(ctx);
145
542
  notifyActivePreset(ctx, "after compaction");
146
543
  });
147
544
 
@@ -164,6 +561,7 @@ export default function piForge(pi: ExtensionAPI) {
164
561
  { options: event.systemPromptOptions, ctx, latestUserMessage: event.prompt, now: new Date(), variables: currentVariableStore },
165
562
  event.systemPrompt,
166
563
  );
564
+ recordCompileDiagnostics(ctx, result.diagnostics);
167
565
  persistVariablesIfDirty(currentVariableStore);
168
566
 
169
567
  return { systemPrompt: result.systemPrompt };
@@ -185,6 +583,7 @@ export default function piForge(pi: ExtensionAPI) {
185
583
  { options: currentSystemPromptOptions, ctx, latestUserMessage, now: new Date(), variables: currentVariableStore },
186
584
  event.messages,
187
585
  );
586
+ recordCompileDiagnostics(ctx, [...latestCompileDiagnostics, ...result.diagnostics]);
188
587
  persistVariablesIfDirty(currentVariableStore);
189
588
  return { messages: result.messages };
190
589
  });
@@ -199,64 +598,178 @@ export default function piForge(pi: ExtensionAPI) {
199
598
 
200
599
  pi.on("before_provider_request", async (event, ctx) => {
201
600
  if (!interceptNextProviderPayload) return;
601
+ const savePath = interceptPayloadSavePath;
202
602
  interceptNextProviderPayload = false;
603
+ interceptPayloadSavePath = undefined;
203
604
  ctx.ui.setStatus("pi-forge-intercept", undefined);
204
605
 
205
606
  const payload = safeStringify(event.payload);
607
+ const approxTokens = estimatePayloadTokens(payload);
608
+ if (savePath) {
609
+ if (!ctx.isProjectTrusted()) {
610
+ ctx.ui.notify("pi-forge: project is not trusted; refusing to save provider payload.", "warning");
611
+ } else {
612
+ const resolvedPath = savePath.startsWith("/") ? savePath : join(ctx.cwd, savePath);
613
+ mkdirSync(dirname(resolvedPath), { recursive: true });
614
+ writeFileSync(resolvedPath, payload, "utf8");
615
+ ctx.ui.notify(`pi-forge: provider payload saved to ${resolvedPath} (${payload.length} chars, ~${approxTokens} tokens)`, "info");
616
+ }
617
+ }
618
+
206
619
  if (ctx.hasUI) {
207
- await ctx.ui.editor("pi-forge: provider payload", payload);
620
+ await ctx.ui.editor(`pi-forge: provider payload (${payload.length} chars, ~${approxTokens} tokens)`, payload);
208
621
  return;
209
622
  }
210
623
 
211
624
  console.log(payload);
212
625
  });
213
626
 
627
+ pi.registerTool({
628
+ name: "forge_state_set",
629
+ label: "Set Prompt State",
630
+ description: "Batch update persistent prompt state for future turns. Only names starting with 'agent.' can be written by the agent.",
631
+ promptSnippet: "Batch update agent-scoped prompt state for cross-turn continuity. Use this when durable state should be visible in the prompt_state slot on future turns.",
632
+ promptGuidelines: [
633
+ "Use forge_state_set to persist concise cross-turn state such as task progress, story state, open questions, or durable facts the user asked you to remember.",
634
+ `Only state names starting with '${AGENT_VAR_PREFIX}' are writable by the agent. User and stack configuration state are read-only to the agent.`,
635
+ "Prefer one batch update at natural checkpoints instead of many small updates. Do not store secrets or large transcripts.",
636
+ "State written by this tool is primarily for future turns; the current prompt has already been built.",
637
+ ],
638
+ parameters: Type.Object({
639
+ updates: Type.Optional(Type.Array(Type.Object({
640
+ name: Type.String({ description: `State name (must start with '${AGENT_VAR_PREFIX}')` }),
641
+ value: Type.Unknown({ description: "JSON-compatible value: string, number, boolean, null, array, or object" }),
642
+ reason: Type.Optional(Type.String({ description: "Brief reason for the update" })),
643
+ }))),
644
+ clears: Type.Optional(Type.Array(Type.String({ description: `State names to clear (must start with '${AGENT_VAR_PREFIX}')` }))),
645
+ }),
646
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
647
+ const updatesRaw = Array.isArray(params.updates) ? params.updates : [];
648
+ const clears = Array.isArray(params.clears) ? params.clears.filter((name): name is string => typeof name === "string") : [];
649
+ const updates: StateUpdateInput[] = [];
650
+
651
+ for (const raw of updatesRaw) {
652
+ if (!raw || typeof raw !== "object") {
653
+ return {
654
+ content: [{ type: "text", text: "Error: every update must be an object." }],
655
+ details: { error: "invalid update" },
656
+ };
657
+ }
658
+ const update = raw as { name?: unknown; value?: unknown; reason?: unknown };
659
+ if (typeof update.name !== "string") {
660
+ return {
661
+ content: [{ type: "text", text: "Error: every update needs a string name." }],
662
+ details: { error: "invalid update name" },
663
+ };
664
+ }
665
+ if (!isPromptStateValue(update.value)) {
666
+ return {
667
+ content: [{ type: "text", text: `Error: ${update.name} value is not JSON-compatible.` }],
668
+ details: { error: "invalid value", name: update.name },
669
+ };
670
+ }
671
+ updates.push({
672
+ name: update.name,
673
+ value: update.value,
674
+ reason: typeof update.reason === "string" ? update.reason : undefined,
675
+ });
676
+ }
677
+
678
+ if (updates.length === 0 && clears.length === 0) {
679
+ return {
680
+ content: [{ type: "text", text: "No state updates or clears provided." }],
681
+ details: { updated: 0, cleared: 0 },
682
+ };
683
+ }
684
+
685
+ const result = applyStatePatch(updates, clears, "agent");
686
+ if (!result.ok) {
687
+ return {
688
+ content: [{ type: "text", text: `Error: ${result.error}` }],
689
+ details: { error: result.error },
690
+ };
691
+ }
692
+
693
+ return {
694
+ content: [{ type: "text", text: `State updated: ${result.updated} set, ${result.cleared} cleared.` }],
695
+ details: { updated: updates.map(({ name, value, reason }) => ({ name, value, reason })), cleared: clears },
696
+ };
697
+ },
698
+ });
699
+
214
700
  pi.registerTool({
215
701
  name: "forge_set_var",
216
702
  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).",
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.",
219
705
  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.",
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.`,
223
708
  ],
224
709
  parameters: Type.Object({
225
710
  name: Type.String({ description: `Variable name (must start with '${AGENT_VAR_PREFIX}')` }),
226
711
  value: Type.String({ description: "Variable value" }),
227
712
  }),
228
713
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
229
- const { name, value } = params;
230
- if (!name.startsWith(AGENT_VAR_PREFIX)) {
714
+ const result = applyStatePatch([{ name: params.name, value: params.value }], [], "agent");
715
+ if (!result.ok) {
231
716
  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 },
717
+ content: [{ type: "text", text: `Error: ${result.error}` }],
718
+ details: { error: result.error, name: params.name },
234
719
  };
235
720
  }
236
- sessionVariables[name] = value;
237
- pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: { ...sessionVariables } });
238
721
  return {
239
- content: [{ type: "text", text: `Variable ${name} set.` }],
240
- details: { name, value },
722
+ content: [{ type: "text", text: `Variable ${params.name} set.` }],
723
+ details: { name: params.name, value: params.value },
241
724
  };
242
725
  },
243
726
  });
244
727
 
728
+ function armPayloadIntercept(ctx: ExtensionCommandContext, savePath?: string): void {
729
+ interceptNextProviderPayload = true;
730
+ interceptPayloadSavePath = savePath;
731
+ ctx.ui.setStatus("pi-forge-intercept", ctx.ui.theme.fg("warning", savePath ? "payload:armed+save" : "payload:armed"));
732
+ ctx.ui.notify(savePath ? `pi-forge: next provider payload will be displayed and saved to ${savePath}.` : "pi-forge: next provider payload will be displayed before sending.", "info");
733
+ }
734
+
245
735
  pi.registerCommand("intercept", {
246
736
  description: "Display the next provider payload before it is sent",
247
737
  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");
738
+ armPayloadIntercept(ctx);
739
+ },
740
+ });
741
+
742
+ pi.registerCommand("payload", {
743
+ description: "Inspect or save provider payloads: /payload next [save=<path>]",
744
+ getArgumentCompletions: (prefix) => {
745
+ const parts = prefix.trimStart().split(/\s+/);
746
+ if (parts.length <= 1 && !prefix.endsWith(" ")) {
747
+ return ["next"].filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
748
+ }
749
+ if (parts[0] === "next" && parts.length <= 2) {
750
+ const suggestion = "save=.pi/forge/payloads/last.json";
751
+ return suggestion.startsWith(parts[1] ?? "") ? [{ value: `next ${suggestion}`, label: suggestion }] : null;
752
+ }
753
+ return null;
754
+ },
755
+ handler: async (args, ctx) => {
756
+ const [command = "next", ...rest] = args.trim() ? args.trim().split(/\s+/) : ["next"];
757
+ if (command !== "next") {
758
+ ctx.ui.notify(`Unknown /payload subcommand: ${command}`, "warning");
759
+ return;
760
+ }
761
+ const saveArg = rest.find((arg) => arg.startsWith("save="));
762
+ const savePath = saveArg?.slice("save=".length).trim() || undefined;
763
+ armPayloadIntercept(ctx, savePath);
251
764
  },
252
765
  });
253
766
 
254
767
  pi.registerCommand("preset", {
255
- description: "Manage pi-forge prompt stacks: list, use, preview, validate, reload, vars",
768
+ description: "Manage pi-forge prompt stacks: list, use, preview, validate, reload, vars, ui",
256
769
  getArgumentCompletions: (prefix) => {
257
770
  const parts = prefix.trimStart().split(/\s+/);
258
771
  if (parts.length <= 1 && !prefix.endsWith(" ")) {
259
- const commands = ["list", "use", "preview", "validate", "reload", "status", "vars", "import-silly"];
772
+ const commands = ["list", "use", "preview", "validate", "diagnostics", "reload", "status", "vars", "import-silly", "ui"];
260
773
  return commands.filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
261
774
  }
262
775
  const first = parts[0];
@@ -277,6 +790,11 @@ export default function piForge(pi: ExtensionAPI) {
277
790
  return names.filter((n) => n.startsWith(fragment)).map((n) => ({ value: `vars ${sub} ${n}`, label: n }));
278
791
  }
279
792
  }
793
+ if (first === "ui" && parts.length <= 2) {
794
+ const fragment = parts[1] ?? "";
795
+ const subs = ["stop", "restart"];
796
+ return subs.filter((s) => s.startsWith(fragment)).map((s) => ({ value: `ui ${s}`, label: s }));
797
+ }
280
798
  return null;
281
799
  },
282
800
  handler: async (args, ctx) => {
@@ -284,6 +802,28 @@ export default function piForge(pi: ExtensionAPI) {
284
802
  },
285
803
  });
286
804
 
805
+ pi.registerCommand("state", {
806
+ description: "Manage pi-forge prompt state",
807
+ getArgumentCompletions: (prefix) => {
808
+ const parts = prefix.trimStart().split(/\s+/);
809
+ if (parts.length <= 1 && !prefix.endsWith(" ")) {
810
+ const commands = ["list", "set", "get", "clear"];
811
+ return commands.filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
812
+ }
813
+ const first = parts[0];
814
+ if (["get", "clear"].includes(first) && parts.length <= 2) {
815
+ const fragment = parts[1] ?? "";
816
+ return Object.keys(sessionVariables)
817
+ .filter((name) => name.startsWith(fragment))
818
+ .map((name) => ({ value: `${first} ${name}`, label: name }));
819
+ }
820
+ return null;
821
+ },
822
+ handler: async (args, ctx) => {
823
+ await handleStateCommand(args, ctx);
824
+ },
825
+ });
826
+
287
827
  async function handlePresetCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
288
828
  const trimmed = args.trim();
289
829
  const [command = "list", ...rest] = trimmed ? trimmed.split(/\s+/) : ["list"];
@@ -299,6 +839,16 @@ export default function piForge(pi: ExtensionAPI) {
299
839
  ctx.ui.notify(`pi-forge: reloaded ${stacks.length} prompt stack(s).`, "info");
300
840
  return;
301
841
 
842
+ case "ui": {
843
+ const sub = rest[0];
844
+ if (sub === "stop") {
845
+ await stopWebEditor(ctx);
846
+ return;
847
+ }
848
+ await openWebEditor(ctx, sub === "restart" ? "restart" : "open");
849
+ return;
850
+ }
851
+
302
852
  case "vars": {
303
853
  const sub = rest[0];
304
854
 
@@ -309,8 +859,11 @@ export default function piForge(pi: ExtensionAPI) {
309
859
  ctx.ui.notify("Usage: /preset vars set <name> <value>", "warning");
310
860
  return;
311
861
  }
312
- sessionVariables[name] = value;
313
- pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: { ...sessionVariables } });
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
+ }
314
867
  ctx.ui.notify(`pi-forge: set session variable ${name} = ${JSON.stringify(value)}`, "info");
315
868
  return;
316
869
  }
@@ -325,17 +878,19 @@ export default function piForge(pi: ExtensionAPI) {
325
878
  if (value === undefined) {
326
879
  await showText(ctx, `pi-forge variable: ${name}`, `# ${name}\n\n(not set)`);
327
880
  } else {
328
- await showText(ctx, `pi-forge variable: ${name}`, `# ${name}\n\n${value}`);
881
+ await showText(ctx, `pi-forge variable: ${name}`, `# ${name}\n\n${formatStateValue(value)}`);
329
882
  }
330
883
  return;
331
884
  }
332
885
 
333
886
  if (sub === "clear") {
334
887
  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 });
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
+ }
339
894
  ctx.ui.notify(name ? `pi-forge: cleared session variable ${name}` : "pi-forge: cleared all session variables", "info");
340
895
  return;
341
896
  }
@@ -379,23 +934,37 @@ export default function piForge(pi: ExtensionAPI) {
379
934
  return;
380
935
  }
381
936
 
937
+ case "diagnostics": {
938
+ await showText(ctx, "pi-forge diagnostics", renderCurrentDiagnostics());
939
+ return;
940
+ }
941
+
382
942
  case "import-silly": {
943
+ if (!ctx.isProjectTrusted()) {
944
+ ctx.ui.notify("pi-forge: project is not trusted; refusing to write imported prompt stacks.", "warning");
945
+ return;
946
+ }
947
+
383
948
  const sourcePath = rest[0];
384
949
  if (!sourcePath) {
385
- ctx.ui.notify("Usage: /preset import-silly <path> [character_id]", "warning");
950
+ ctx.ui.notify("Usage: /preset import-silly <path> [character_id] [--dry-run] [--overwrite]", "warning");
386
951
  return;
387
952
  }
388
953
 
954
+ const charIdToken = rest[1]?.startsWith("--") ? undefined : rest[1];
955
+ const flags = new Set(rest.slice(charIdToken ? 2 : 1));
956
+ const dryRun = flags.has("--dry-run");
957
+ let overwrite = flags.has("--overwrite");
958
+
389
959
  const resolvedPath = sourcePath.startsWith("/") ? sourcePath : join(ctx.cwd, sourcePath);
390
960
  if (!existsSync(resolvedPath)) {
391
961
  ctx.ui.notify(`File not found: ${resolvedPath}`, "error");
392
962
  return;
393
963
  }
394
964
 
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");
965
+ const charId = charIdToken ? Number(charIdToken) : undefined;
966
+ if (charIdToken && (Number.isNaN(charId) || !Number.isFinite(charId))) {
967
+ ctx.ui.notify(`Invalid character_id: ${charIdToken}`, "error");
399
968
  return;
400
969
  }
401
970
 
@@ -405,16 +974,33 @@ export default function piForge(pi: ExtensionAPI) {
405
974
  return;
406
975
  }
407
976
 
408
- // Write the prompt stack
409
977
  const stacksDir = promptStacksDir(ctx.cwd);
410
- if (!existsSync(stacksDir)) mkdirSync(stacksDir, { recursive: true });
411
978
  const stackPath = join(stacksDir, `${result.stack.id}.json`);
979
+ const reportDir = join(ctx.cwd, ".pi", "forge", "import-reports");
980
+ const reportPath = join(reportDir, `${result.stack.id}.md`);
981
+
982
+ if (dryRun) {
983
+ await showText(ctx, `pi-forge import dry run: ${result.stack.id}`, `Would write stack to: ${stackPath}\nWould write report to: ${reportPath}\n\n## Generated stack JSON\n\n\`\`\`json\n${JSON.stringify(result.stack, null, 2)}\n\`\`\`\n\n${result.report}`);
984
+ return;
985
+ }
986
+
987
+ const existingPaths = [stackPath, reportPath].filter((path) => existsSync(path));
988
+ if (existingPaths.length > 0 && !overwrite) {
989
+ if (!ctx.hasUI) {
990
+ ctx.ui.notify(`pi-forge: import would overwrite existing file(s): ${existingPaths.join(", ")}. Re-run with --overwrite.`, "error");
991
+ return;
992
+ }
993
+ overwrite = await ctx.ui.confirm("Overwrite pi-forge import output?", `These file(s) already exist:\n${existingPaths.join("\n")}\n\nOverwrite them?`);
994
+ if (!overwrite) {
995
+ ctx.ui.notify("pi-forge: import cancelled; existing files were left unchanged.", "info");
996
+ return;
997
+ }
998
+ }
999
+
1000
+ if (!existsSync(stacksDir)) mkdirSync(stacksDir, { recursive: true });
412
1001
  writeFileSync(stackPath, JSON.stringify(result.stack, null, 2), "utf8");
413
1002
 
414
- // Write the import report
415
- const reportDir = join(ctx.cwd, ".pi", "forge", "import-reports");
416
1003
  if (!existsSync(reportDir)) mkdirSync(reportDir, { recursive: true });
417
- const reportPath = join(reportDir, `${result.stack.id}.md`);
418
1004
  writeFileSync(reportPath, result.report, "utf8");
419
1005
 
420
1006
  // Reload stacks to pick up the new one
@@ -431,6 +1017,62 @@ export default function piForge(pi: ExtensionAPI) {
431
1017
  }
432
1018
  }
433
1019
 
1020
+ async function handleStateCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
1021
+ const trimmed = args.trim();
1022
+ const [command = "list", ...rest] = trimmed ? trimmed.split(/\s+/) : ["list"];
1023
+
1024
+ switch (command) {
1025
+ case "list":
1026
+ case "status":
1027
+ await showText(ctx, "pi-forge state", renderVariablesList());
1028
+ return;
1029
+
1030
+ case "set": {
1031
+ const name = rest[0];
1032
+ const rawValue = rest.slice(1).join(" ");
1033
+ if (!name || rawValue === "") {
1034
+ ctx.ui.notify("Usage: /state set <name> <json-or-text-value>", "warning");
1035
+ return;
1036
+ }
1037
+ const value = parseStateCommandValue(rawValue);
1038
+ const result = applyStatePatch([{ name, value }], [], "user");
1039
+ if (!result.ok) {
1040
+ ctx.ui.notify(`pi-forge state error: ${result.error}`, "error");
1041
+ return;
1042
+ }
1043
+ ctx.ui.notify(`pi-forge: set state ${name} = ${JSON.stringify(value)}`, "info");
1044
+ return;
1045
+ }
1046
+
1047
+ case "get": {
1048
+ const name = rest[0];
1049
+ if (!name) {
1050
+ ctx.ui.notify("Usage: /state get <name>", "warning");
1051
+ return;
1052
+ }
1053
+ const value = sessionVariables[name];
1054
+ await showText(ctx, `pi-forge state: ${name}`, value === undefined ? `# ${name}\n\n(not set)` : `# ${name}\n\n${formatStateValue(value)}`);
1055
+ return;
1056
+ }
1057
+
1058
+ case "clear": {
1059
+ const name = rest[0];
1060
+ const clears = name ? [name] : Object.keys(sessionVariables);
1061
+ const result = applyStatePatch([], clears, "user");
1062
+ if (!result.ok) {
1063
+ ctx.ui.notify(`pi-forge state error: ${result.error}`, "error");
1064
+ return;
1065
+ }
1066
+ ctx.ui.notify(name ? `pi-forge: cleared state ${name}` : "pi-forge: cleared all session state", "info");
1067
+ return;
1068
+ }
1069
+
1070
+ default:
1071
+ ctx.ui.notify(`Unknown /state subcommand: ${command}`, "warning");
1072
+ return;
1073
+ }
1074
+ }
1075
+
434
1076
  function renderStackList(ctx: ExtensionCommandContext): string {
435
1077
  const lines = [
436
1078
  `Prompt stack directory: ${promptStacksDir(ctx.cwd)}`,
@@ -452,12 +1094,21 @@ export default function piForge(pi: ExtensionAPI) {
452
1094
  lines.push(` ${loaded.filePath}`);
453
1095
  }
454
1096
 
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]]");
1097
+ lines.push("", "Commands:", " /preset use <id|none>", " /preset preview [id]", " /preset validate [id]", " /preset diagnostics", " /preset reload", " /preset ui [stop|restart]", " /state [list|set <name> <value>|get <name>|clear [name]]", " /preset vars [set <name> <value>|get <name>|clear [name]]");
1098
+ return lines.join("\n");
1099
+ }
1100
+
1101
+ function renderCurrentDiagnostics(): string {
1102
+ const lines = ["# pi-forge diagnostics", ""];
1103
+ lines.push("## Active stack load/validation diagnostics", "");
1104
+ lines.push(active ? renderDiagnostics(active.diagnostics) : "No active prompt stack.");
1105
+ lines.push("", "## Latest runtime compile diagnostics", "");
1106
+ lines.push(renderDiagnostics(latestCompileDiagnostics));
456
1107
  return lines.join("\n");
457
1108
  }
458
1109
 
459
1110
  function renderVariablesList(): string {
460
- const lines = ["# pi-forge variables", "", "## Session variables", ""];
1111
+ const lines = ["# pi-forge state", "", "## Session state", ""];
461
1112
  const sessionEntries = Object.entries(sessionVariables).sort(([a], [b]) => a.localeCompare(b));
462
1113
  if (sessionEntries.length === 0) lines.push("(none)");
463
1114
  else for (const [key, value] of sessionEntries) lines.push(`${key} = ${JSON.stringify(value)}`);
@@ -467,6 +1118,20 @@ export default function piForge(pi: ExtensionAPI) {
467
1118
  if (staticEntries.length === 0) lines.push("(none)");
468
1119
  else for (const [key, value] of staticEntries) lines.push(`${key} = ${JSON.stringify(value)}`);
469
1120
 
1121
+ const definitions = Object.entries(active?.stack.state?.definitions ?? {}).sort(([a], [b]) => a.localeCompare(b));
1122
+ if (definitions.length > 0) {
1123
+ lines.push("", "## Active stack state definitions", "");
1124
+ for (const [name, definition] of definitions) {
1125
+ const details = [
1126
+ definition.type ? `type=${definition.type}` : undefined,
1127
+ definition.scope ? `scope=${definition.scope}` : undefined,
1128
+ definition.agentWritable !== undefined ? `agentWritable=${definition.agentWritable}` : undefined,
1129
+ definition.userWritable !== undefined ? `userWritable=${definition.userWritable}` : undefined,
1130
+ ].filter(Boolean).join(", ");
1131
+ lines.push(`${name}${details ? ` (${details})` : ""}${definition.description ? ` — ${definition.description}` : ""}`);
1132
+ }
1133
+ }
1134
+
470
1135
  lines.push("", "Turn variables are cleared for each user message and are only visible during prompt compilation.");
471
1136
  return lines.join("\n");
472
1137
  }
@@ -513,9 +1178,53 @@ export default function piForge(pi: ExtensionAPI) {
513
1178
 
514
1179
  function safeStringify(value: unknown): string {
515
1180
  try {
516
- return JSON.stringify(value, null, 2);
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;
517
1184
  } catch (error) {
518
1185
  return `Failed to stringify provider payload: ${error instanceof Error ? error.message : String(error)}`;
519
1186
  }
520
1187
  }
1188
+
1189
+ function estimatePayloadTokens(payload: string): number {
1190
+ return Math.max(1, Math.ceil(payload.length / 4));
1191
+ }
1192
+
1193
+ function redactPayload(value: unknown, depth = 0): unknown {
1194
+ if (depth > 8) return "[pi-forge: max depth reached]";
1195
+ if (typeof value === "string") return redactLongString(value);
1196
+ if (value === null || typeof value !== "object") return value;
1197
+ if (Array.isArray(value)) {
1198
+ const maxItems = 80;
1199
+ const items = value.slice(0, maxItems).map((item) => redactPayload(item, depth + 1));
1200
+ if (value.length > maxItems) items.push(`[pi-forge: ${value.length - maxItems} more items omitted]`);
1201
+ return items;
1202
+ }
1203
+
1204
+ const result: Record<string, unknown> = {};
1205
+ let count = 0;
1206
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
1207
+ if (++count > 120) {
1208
+ result["[pi-forge: omitted]"] = "object has more than 120 keys";
1209
+ break;
1210
+ }
1211
+ if (isSecretKey(key)) {
1212
+ result[key] = "[redacted]";
1213
+ continue;
1214
+ }
1215
+ result[key] = redactPayload(raw, depth + 1);
1216
+ }
1217
+ return result;
1218
+ }
1219
+
1220
+ function isSecretKey(key: string): boolean {
1221
+ return /(api[-_]?key|authorization|bearer|token|secret|password|cookie|credential)/i.test(key);
1222
+ }
1223
+
1224
+ function redactLongString(value: string): string {
1225
+ if (/^data:image\//.test(value)) return "[image data omitted]";
1226
+ if (value.length > 8_000 && /^[A-Za-z0-9+/=\r\n]+$/.test(value)) return `[base64-like data omitted: ${value.length} chars]`;
1227
+ if (value.length > 12_000) return `${value.slice(0, 12_000)}\n[pi-forge: string truncated from ${value.length} chars]`;
1228
+ return value;
1229
+ }
521
1230
  }