@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/README.md +303 -184
- package/README.zh-CN.md +405 -0
- package/examples/default-prompt-stack.json +44 -0
- package/package.json +2 -1
- package/src/compiler.ts +163 -33
- package/src/index.ts +761 -52
- package/src/loader.ts +83 -1
- package/src/sillytavern-importer.ts +83 -34
- package/src/types.ts +41 -2
- package/src/web-editor.ts +1491 -0
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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
|
|
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,
|
|
106
|
-
const entries = ctx
|
|
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
|
|
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
|
|
124
|
-
const result: Record<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 (
|
|
397
|
+
if (isPromptStateValue(raw)) result[key] = raw;
|
|
127
398
|
}
|
|
128
399
|
return result;
|
|
129
400
|
}
|
|
130
401
|
|
|
131
|
-
|
|
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(
|
|
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: "
|
|
218
|
-
promptSnippet: "
|
|
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
|
-
"
|
|
221
|
-
`Only variable names starting with '${AGENT_VAR_PREFIX}' are writable
|
|
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 }
|
|
230
|
-
if (!
|
|
714
|
+
const result = applyStatePatch([{ name: params.name, value: params.value }], [], "agent");
|
|
715
|
+
if (!result.ok) {
|
|
231
716
|
return {
|
|
232
|
-
content: [{ type: "text", text: `Error:
|
|
233
|
-
details: { error:
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|