@zihanw/pi-forge 0.1.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 ADDED
@@ -0,0 +1,521 @@
1
+ import { writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { buildSessionContext, type BuildSystemPromptOptions, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import {
6
+ compileMessages,
7
+ compileSystemPrompt,
8
+ createPromptVariableStore,
9
+ getLatestUserMessage,
10
+ markSessionVariablesClean,
11
+ renderPreviewMessages,
12
+ resetTurnVariables,
13
+ } from "./compiler.ts";
14
+ import { chooseDefaultStack, isDisabledPromptStackId, loadPromptStacks, promptStacksDir } from "./loader.ts";
15
+ import { importSillyTavernPreset } from "./sillytavern-importer.ts";
16
+ import type { LoadedPromptStack, PromptStackDiagnostic, PromptVariableStore } from "./types.ts";
17
+
18
+ const STATE_ENTRY_TYPE = "pi-forge-prompt-stack-state";
19
+ const VARIABLE_ENTRY_TYPE = "pi-forge-variable-state";
20
+ const AGENT_VAR_PREFIX = "agent.";
21
+
22
+ export default function piForge(pi: ExtensionAPI) {
23
+ let stacks: LoadedPromptStack[] = [];
24
+ let active: LoadedPromptStack | undefined;
25
+ let currentSystemPromptOptions: BuildSystemPromptOptions | undefined;
26
+ let currentLatestUserMessage: string | undefined;
27
+ let currentVariableStore: PromptVariableStore | undefined;
28
+ let contextRewritePending = false;
29
+ let sessionVariables: Record<string, string> = {};
30
+ let lastPersistedActiveId: string | undefined;
31
+ let interceptNextProviderPayload = false;
32
+
33
+ function activeId(): string | undefined {
34
+ return active?.stack.id;
35
+ }
36
+
37
+ function selectedActiveId(): string | undefined {
38
+ if (active) return active.stack.id;
39
+ return isDisabledPromptStackId(lastPersistedActiveId) ? "none" : undefined;
40
+ }
41
+
42
+ function persistActiveSelection(id: string): void {
43
+ if (id === lastPersistedActiveId) return;
44
+ pi.appendEntry(STATE_ENTRY_TYPE, { activeStackId: id });
45
+ lastPersistedActiveId = id;
46
+ }
47
+
48
+ function setActive(id: string | undefined, ctx?: ExtensionContext): boolean {
49
+ if (!id || isDisabledPromptStackId(id)) {
50
+ active = undefined;
51
+ if (id) persistActiveSelection("none");
52
+ if (ctx) updateStatus(ctx);
53
+ return true;
54
+ }
55
+
56
+ const found = stacks.find((candidate) => candidate.stack.id === id);
57
+ if (!found) return false;
58
+ active = found;
59
+ persistActiveSelection(found.stack.id);
60
+ if (ctx) updateStatus(ctx);
61
+ return true;
62
+ }
63
+
64
+ function reloadStacks(ctx: ExtensionContext, preferredId?: string): void {
65
+ if (!ctx.isProjectTrusted()) {
66
+ stacks = [];
67
+ active = undefined;
68
+ ctx.ui.notify("pi-forge: project is not trusted; prompt stacks are disabled.", "warning");
69
+ updateStatus(ctx);
70
+ return;
71
+ }
72
+
73
+ stacks = loadPromptStacks(ctx.cwd);
74
+ active = chooseDefaultStack(stacks, preferredId);
75
+ updateStatus(ctx);
76
+ }
77
+
78
+ function updateStatus(ctx: ExtensionContext): void {
79
+ if (active) {
80
+ ctx.ui.setStatus("pi-forge", ctx.ui.theme.fg("accent", "stack:" + active.stack.id));
81
+ } else {
82
+ ctx.ui.setStatus("pi-forge", undefined);
83
+ }
84
+ }
85
+
86
+ function notifyActivePreset(ctx: ExtensionContext, detail: string): void {
87
+ if (!active) return;
88
+ const errorCount = active.diagnostics.filter((d) => d.level === "error").length;
89
+ const warningCount = active.diagnostics.filter((d) => d.level === "warning").length;
90
+ const suffix = errorCount || warningCount ? " (" + errorCount + " errors, " + warningCount + " warnings)" : "";
91
+ ctx.ui.notify("pi-forge: active preset " + active.stack.id + suffix + " (" + detail + ")", errorCount ? "error" : "info");
92
+ }
93
+
94
+ function getRestoredActiveId(ctx: ExtensionContext): string | undefined {
95
+ const entries = ctx.sessionManager.getEntries();
96
+ for (let i = entries.length - 1; i >= 0; i--) {
97
+ const entry = entries[i] as { type?: string; customType?: string; data?: { activeStackId?: unknown } };
98
+ if (entry.type === "custom" && entry.customType === STATE_ENTRY_TYPE) {
99
+ return typeof entry.data?.activeStackId === "string" ? entry.data.activeStackId : undefined;
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ function getRestoredVariables(ctx: ExtensionContext): Record<string, string> {
106
+ const entries = ctx.sessionManager.getEntries();
107
+ for (let i = entries.length - 1; i >= 0; i--) {
108
+ const entry = entries[i] as { type?: string; customType?: string; data?: { variables?: unknown } };
109
+ if (entry.type !== "custom" || entry.customType !== VARIABLE_ENTRY_TYPE) continue;
110
+ if (!entry.data || typeof entry.data.variables !== "object" || Array.isArray(entry.data.variables)) return {};
111
+ return normalizeStringRecord(entry.data.variables as Record<string, unknown>);
112
+ }
113
+ return {};
114
+ }
115
+
116
+ function persistVariablesIfDirty(store: PromptVariableStore | undefined): void {
117
+ if (!store?.sessionDirty) return;
118
+ sessionVariables = { ...store.session };
119
+ pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: sessionVariables });
120
+ markSessionVariablesClean(store);
121
+ }
122
+
123
+ function normalizeStringRecord(value: Record<string, unknown>): Record<string, string> {
124
+ const result: Record<string, string> = {};
125
+ for (const [key, raw] of Object.entries(value)) {
126
+ if (typeof raw === "string") result[key] = raw;
127
+ }
128
+ return result;
129
+ }
130
+
131
+ pi.on("session_start", async (event, ctx) => {
132
+ sessionVariables = getRestoredVariables(ctx);
133
+ currentVariableStore = undefined;
134
+ const restoredActiveId = getRestoredActiveId(ctx);
135
+ lastPersistedActiveId = restoredActiveId;
136
+ reloadStacks(ctx, restoredActiveId);
137
+ notifyActivePreset(ctx, "after session " + event.reason);
138
+ });
139
+
140
+ pi.on("session_tree", async (_event, ctx) => {
141
+ notifyActivePreset(ctx, "after tree navigation");
142
+ });
143
+
144
+ pi.on("session_compact", async (_event, ctx) => {
145
+ notifyActivePreset(ctx, "after compaction");
146
+ });
147
+
148
+ pi.on("turn_start", async () => {
149
+ const id = activeId();
150
+ if (id) persistActiveSelection(id);
151
+ });
152
+
153
+ pi.on("before_agent_start", async (event, ctx) => {
154
+ currentSystemPromptOptions = event.systemPromptOptions;
155
+ currentLatestUserMessage = event.prompt;
156
+ currentVariableStore = createPromptVariableStore(sessionVariables);
157
+ resetTurnVariables(currentVariableStore);
158
+ contextRewritePending = true;
159
+
160
+ if (!active) return;
161
+
162
+ const result = compileSystemPrompt(
163
+ active.stack,
164
+ { options: event.systemPromptOptions, ctx, latestUserMessage: event.prompt, now: new Date(), variables: currentVariableStore },
165
+ event.systemPrompt,
166
+ );
167
+ persistVariablesIfDirty(currentVariableStore);
168
+
169
+ return { systemPrompt: result.systemPrompt };
170
+ });
171
+
172
+ pi.on("context", async (event, ctx) => {
173
+ if (!active || !currentSystemPromptOptions || !contextRewritePending) return;
174
+
175
+ // Rewrite the message layout only for the first provider request of a user-submitted prompt.
176
+ // Tool-result follow-up turns must receive Pi's natural context; otherwise post-history
177
+ // prompt blocks such as COT / {{lastUserMessage}} are re-appended after every tool call
178
+ // and the model restarts its planning instead of continuing from the tool result.
179
+ contextRewritePending = false;
180
+
181
+ if (!currentVariableStore) currentVariableStore = createPromptVariableStore(sessionVariables);
182
+ const latestUserMessage = getLatestUserMessage(event.messages) ?? currentLatestUserMessage;
183
+ const result = compileMessages(
184
+ active.stack,
185
+ { options: currentSystemPromptOptions, ctx, latestUserMessage, now: new Date(), variables: currentVariableStore },
186
+ event.messages,
187
+ );
188
+ persistVariablesIfDirty(currentVariableStore);
189
+ return { messages: result.messages };
190
+ });
191
+
192
+ pi.on("agent_end", async () => {
193
+ persistVariablesIfDirty(currentVariableStore);
194
+ currentSystemPromptOptions = undefined;
195
+ currentLatestUserMessage = undefined;
196
+ currentVariableStore = undefined;
197
+ contextRewritePending = false;
198
+ });
199
+
200
+ pi.on("before_provider_request", async (event, ctx) => {
201
+ if (!interceptNextProviderPayload) return;
202
+ interceptNextProviderPayload = false;
203
+ ctx.ui.setStatus("pi-forge-intercept", undefined);
204
+
205
+ const payload = safeStringify(event.payload);
206
+ if (ctx.hasUI) {
207
+ await ctx.ui.editor("pi-forge: provider payload", payload);
208
+ return;
209
+ }
210
+
211
+ console.log(payload);
212
+ });
213
+
214
+ pi.registerTool({
215
+ name: "forge_set_var",
216
+ label: "Set Prompt Variable",
217
+ description: "Set a persistent session variable in the active prompt stack. Only variables starting with 'agent.' can be written by the agent; other variables are read-only.",
218
+ promptSnippet: "Set agent-scoped prompt stack variables for cross-turn state (character mood, task progress, discovered facts).",
219
+ promptGuidelines: [
220
+ "Use forge_set_var to persist state across turns — character mood, story progress, task checkpoints, or discovered facts the user might ask about later.",
221
+ `Only variable names starting with '${AGENT_VAR_PREFIX}' are writable. Static and manually-set variables are read-only.`,
222
+ "Do not use forge_set_var for prompt configuration; it is for tracked runtime state only.",
223
+ ],
224
+ parameters: Type.Object({
225
+ name: Type.String({ description: `Variable name (must start with '${AGENT_VAR_PREFIX}')` }),
226
+ value: Type.String({ description: "Variable value" }),
227
+ }),
228
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
229
+ const { name, value } = params;
230
+ if (!name.startsWith(AGENT_VAR_PREFIX)) {
231
+ return {
232
+ content: [{ type: "text", text: `Error: only variables starting with '${AGENT_VAR_PREFIX}' are writable by the agent. Received: ${name}` }],
233
+ details: { error: "namespace restriction", name },
234
+ };
235
+ }
236
+ sessionVariables[name] = value;
237
+ pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: { ...sessionVariables } });
238
+ return {
239
+ content: [{ type: "text", text: `Variable ${name} set.` }],
240
+ details: { name, value },
241
+ };
242
+ },
243
+ });
244
+
245
+ pi.registerCommand("intercept", {
246
+ description: "Display the next provider payload before it is sent",
247
+ 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");
251
+ },
252
+ });
253
+
254
+ pi.registerCommand("preset", {
255
+ description: "Manage pi-forge prompt stacks: list, use, preview, validate, reload, vars",
256
+ getArgumentCompletions: (prefix) => {
257
+ const parts = prefix.trimStart().split(/\s+/);
258
+ if (parts.length <= 1 && !prefix.endsWith(" ")) {
259
+ const commands = ["list", "use", "preview", "validate", "reload", "status", "vars", "import-silly"];
260
+ return commands.filter((cmd) => cmd.startsWith(parts[0] ?? "")).map((cmd) => ({ value: cmd, label: cmd }));
261
+ }
262
+ const first = parts[0];
263
+ if (["use", "preview", "validate"].includes(first)) {
264
+ const fragment = parts[1] ?? "";
265
+ const ids = ["none", ...stacks.map((loaded) => loaded.stack.id)];
266
+ return ids.filter((id) => id.startsWith(fragment)).map((id) => ({ value: `${first} ${id}`, label: id }));
267
+ }
268
+ if (first === "vars") {
269
+ const sub = parts[1] ?? "";
270
+ if (parts.length <= 2 && !prefix.endsWith(" ")) {
271
+ const subs = ["set", "get", "clear", "list"];
272
+ return subs.filter((s) => s.startsWith(sub)).map((s) => ({ value: `vars ${s}`, label: s }));
273
+ }
274
+ if (["set", "get", "clear"].includes(sub) && parts.length <= 3) {
275
+ const fragment = parts[2] ?? "";
276
+ const names = Object.keys(sessionVariables);
277
+ return names.filter((n) => n.startsWith(fragment)).map((n) => ({ value: `vars ${sub} ${n}`, label: n }));
278
+ }
279
+ }
280
+ return null;
281
+ },
282
+ handler: async (args, ctx) => {
283
+ await handlePresetCommand(args, ctx);
284
+ },
285
+ });
286
+
287
+ async function handlePresetCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
288
+ const trimmed = args.trim();
289
+ const [command = "list", ...rest] = trimmed ? trimmed.split(/\s+/) : ["list"];
290
+
291
+ switch (command) {
292
+ case "list":
293
+ case "status":
294
+ await showText(ctx, "pi-forge prompt stacks", renderStackList(ctx));
295
+ return;
296
+
297
+ case "reload":
298
+ reloadStacks(ctx, selectedActiveId());
299
+ ctx.ui.notify(`pi-forge: reloaded ${stacks.length} prompt stack(s).`, "info");
300
+ return;
301
+
302
+ case "vars": {
303
+ const sub = rest[0];
304
+
305
+ if (sub === "set") {
306
+ const name = rest[1];
307
+ const value = rest.slice(2).join(" ");
308
+ if (!name || value === "") {
309
+ ctx.ui.notify("Usage: /preset vars set <name> <value>", "warning");
310
+ return;
311
+ }
312
+ sessionVariables[name] = value;
313
+ pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: { ...sessionVariables } });
314
+ ctx.ui.notify(`pi-forge: set session variable ${name} = ${JSON.stringify(value)}`, "info");
315
+ return;
316
+ }
317
+
318
+ if (sub === "get") {
319
+ const name = rest[1];
320
+ if (!name) {
321
+ ctx.ui.notify("Usage: /preset vars get <name>", "warning");
322
+ return;
323
+ }
324
+ const value = sessionVariables[name];
325
+ if (value === undefined) {
326
+ await showText(ctx, `pi-forge variable: ${name}`, `# ${name}\n\n(not set)`);
327
+ } else {
328
+ await showText(ctx, `pi-forge variable: ${name}`, `# ${name}\n\n${value}`);
329
+ }
330
+ return;
331
+ }
332
+
333
+ if (sub === "clear") {
334
+ const name = rest[1];
335
+ if (name) delete sessionVariables[name];
336
+ else sessionVariables = {};
337
+ currentVariableStore = createPromptVariableStore(sessionVariables);
338
+ pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: sessionVariables });
339
+ ctx.ui.notify(name ? `pi-forge: cleared session variable ${name}` : "pi-forge: cleared all session variables", "info");
340
+ return;
341
+ }
342
+
343
+ // No subcommand or "list" — show all variables
344
+ await showText(ctx, "pi-forge variables", renderVariablesList());
345
+ return;
346
+ }
347
+
348
+ case "use": {
349
+ const id = rest[0];
350
+ if (!id) {
351
+ ctx.ui.notify("Usage: /preset use <id|none>", "warning");
352
+ return;
353
+ }
354
+ if (!setActive(id, ctx)) {
355
+ ctx.ui.notify(`Unknown prompt stack: ${id}`, "error");
356
+ return;
357
+ }
358
+ ctx.ui.notify(active ? `pi-forge: active prompt stack ${active.stack.id}` : "pi-forge: prompt stack disabled", "info");
359
+ return;
360
+ }
361
+
362
+ case "preview": {
363
+ const target = rest[0] ? stacks.find((loaded) => loaded.stack.id === rest[0]) : active;
364
+ if (!target) {
365
+ ctx.ui.notify(rest[0] ? `Unknown prompt stack: ${rest[0]}` : "No active prompt stack.", "warning");
366
+ return;
367
+ }
368
+ await showText(ctx, `pi-forge preview: ${target.stack.id}`, renderPreview(ctx, target));
369
+ return;
370
+ }
371
+
372
+ case "validate": {
373
+ const target = rest[0] ? stacks.find((loaded) => loaded.stack.id === rest[0]) : active;
374
+ if (!target) {
375
+ ctx.ui.notify(rest[0] ? `Unknown prompt stack: ${rest[0]}` : "No active prompt stack.", "warning");
376
+ return;
377
+ }
378
+ await showText(ctx, `pi-forge validation: ${target.stack.id}`, renderDiagnostics(target.diagnostics));
379
+ return;
380
+ }
381
+
382
+ case "import-silly": {
383
+ const sourcePath = rest[0];
384
+ if (!sourcePath) {
385
+ ctx.ui.notify("Usage: /preset import-silly <path> [character_id]", "warning");
386
+ return;
387
+ }
388
+
389
+ const resolvedPath = sourcePath.startsWith("/") ? sourcePath : join(ctx.cwd, sourcePath);
390
+ if (!existsSync(resolvedPath)) {
391
+ ctx.ui.notify(`File not found: ${resolvedPath}`, "error");
392
+ return;
393
+ }
394
+
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");
399
+ return;
400
+ }
401
+
402
+ const result = importSillyTavernPreset(resolvedPath, charId);
403
+ if ("error" in result) {
404
+ ctx.ui.notify(`pi-forge import error: ${result.error}`, "error");
405
+ return;
406
+ }
407
+
408
+ // Write the prompt stack
409
+ const stacksDir = promptStacksDir(ctx.cwd);
410
+ if (!existsSync(stacksDir)) mkdirSync(stacksDir, { recursive: true });
411
+ const stackPath = join(stacksDir, `${result.stack.id}.json`);
412
+ writeFileSync(stackPath, JSON.stringify(result.stack, null, 2), "utf8");
413
+
414
+ // Write the import report
415
+ const reportDir = join(ctx.cwd, ".pi", "forge", "import-reports");
416
+ if (!existsSync(reportDir)) mkdirSync(reportDir, { recursive: true });
417
+ const reportPath = join(reportDir, `${result.stack.id}.md`);
418
+ writeFileSync(reportPath, result.report, "utf8");
419
+
420
+ // Reload stacks to pick up the new one
421
+ reloadStacks(ctx, selectedActiveId());
422
+
423
+ ctx.ui.notify(`pi-forge: imported ${result.stack.id} (${result.stack.items.length} items)`, "info");
424
+ await showText(ctx, `pi-forge import report: ${result.stack.id}`, `Stack written to: ${stackPath}\nReport written to: ${reportPath}\n\n${result.report}`);
425
+ return;
426
+ }
427
+
428
+ default:
429
+ ctx.ui.notify(`Unknown /preset subcommand: ${command}`, "warning");
430
+ return;
431
+ }
432
+ }
433
+
434
+ function renderStackList(ctx: ExtensionCommandContext): string {
435
+ const lines = [
436
+ `Prompt stack directory: ${promptStacksDir(ctx.cwd)}`,
437
+ `Active stack: ${active?.stack.id ?? "(none)"}`,
438
+ "",
439
+ ];
440
+
441
+ if (stacks.length === 0) {
442
+ lines.push("No prompt stacks found.", "Create .pi/prompt-stacks/default.json to auto-activate a stack.");
443
+ return lines.join("\n");
444
+ }
445
+
446
+ for (const loaded of stacks) {
447
+ const marker = loaded === active ? "*" : " ";
448
+ const errors = loaded.diagnostics.filter((d) => d.level === "error").length;
449
+ const warnings = loaded.diagnostics.filter((d) => d.level === "warning").length;
450
+ const suffix = errors || warnings ? ` (${errors} errors, ${warnings} warnings)` : "";
451
+ lines.push(`${marker} ${loaded.stack.id}${loaded.stack.name ? ` — ${loaded.stack.name}` : ""}${suffix}`);
452
+ lines.push(` ${loaded.filePath}`);
453
+ }
454
+
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]]");
456
+ return lines.join("\n");
457
+ }
458
+
459
+ function renderVariablesList(): string {
460
+ const lines = ["# pi-forge variables", "", "## Session variables", ""];
461
+ const sessionEntries = Object.entries(sessionVariables).sort(([a], [b]) => a.localeCompare(b));
462
+ if (sessionEntries.length === 0) lines.push("(none)");
463
+ else for (const [key, value] of sessionEntries) lines.push(`${key} = ${JSON.stringify(value)}`);
464
+
465
+ lines.push("", "## Active stack static variables", "");
466
+ const staticEntries = Object.entries(active?.stack.variables ?? {}).sort(([a], [b]) => a.localeCompare(b));
467
+ if (staticEntries.length === 0) lines.push("(none)");
468
+ else for (const [key, value] of staticEntries) lines.push(`${key} = ${JSON.stringify(value)}`);
469
+
470
+ lines.push("", "Turn variables are cleared for each user message and are only visible during prompt compilation.");
471
+ return lines.join("\n");
472
+ }
473
+
474
+ function renderPreview(ctx: ExtensionCommandContext, target: LoadedPromptStack): string {
475
+ const options = ctx.getSystemPromptOptions();
476
+ const sessionContext = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
477
+ const latestUserMessage = getLatestUserMessage(sessionContext.messages);
478
+ const previewVariables = createPromptVariableStore(sessionVariables);
479
+ const runtime = { options, ctx, latestUserMessage, now: new Date(), variables: previewVariables };
480
+ const system = compileSystemPrompt(target.stack, runtime, ctx.getSystemPrompt());
481
+ const messages = compileMessages(target.stack, runtime, sessionContext.messages);
482
+ const diagnostics = [...target.diagnostics, ...system.diagnostics, ...messages.diagnostics];
483
+
484
+ return [
485
+ `# Prompt stack preview: ${target.stack.id}`,
486
+ "",
487
+ "## System prompt",
488
+ "",
489
+ system.systemPrompt || "(empty)",
490
+ "",
491
+ "## Message layout",
492
+ "",
493
+ renderPreviewMessages(messages.messages),
494
+ "",
495
+ "## Diagnostics",
496
+ "",
497
+ renderDiagnostics(diagnostics),
498
+ ].join("\n");
499
+ }
500
+
501
+ function renderDiagnostics(diagnostics: PromptStackDiagnostic[]): string {
502
+ if (diagnostics.length === 0) return "No diagnostics.";
503
+ return diagnostics.map((d) => `${d.level.toUpperCase()}${d.itemId ? ` [${d.itemId}]` : ""}: ${d.message}`).join("\n");
504
+ }
505
+
506
+ async function showText(ctx: ExtensionCommandContext, title: string, text: string): Promise<void> {
507
+ if (ctx.hasUI) {
508
+ await ctx.ui.editor(title, text);
509
+ return;
510
+ }
511
+ console.log(text);
512
+ }
513
+
514
+ function safeStringify(value: unknown): string {
515
+ try {
516
+ return JSON.stringify(value, null, 2);
517
+ } catch (error) {
518
+ return `Failed to stringify provider payload: ${error instanceof Error ? error.message : String(error)}`;
519
+ }
520
+ }
521
+ }