@zihanw/pi-forge 0.3.1 → 0.3.2

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.
Files changed (47) hide show
  1. package/README.md +31 -24
  2. package/README.zh-CN.md +31 -24
  3. package/dist/forge-extensions.d.ts +28 -0
  4. package/dist/forge-extensions.d.ts.map +1 -0
  5. package/dist/forge-extensions.js +182 -0
  6. package/dist/forge-extensions.js.map +1 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +13 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/lifecycle.d.ts +1 -1
  12. package/dist/lifecycle.d.ts.map +1 -1
  13. package/dist/lifecycle.js +5 -5
  14. package/dist/lifecycle.js.map +1 -1
  15. package/dist/preset-command.d.ts +1 -1
  16. package/dist/preset-command.d.ts.map +1 -1
  17. package/dist/preset-command.js +12 -3
  18. package/dist/preset-command.js.map +1 -1
  19. package/dist/runtime-state.d.ts +2 -0
  20. package/dist/runtime-state.d.ts.map +1 -1
  21. package/dist/runtime-state.js +2 -0
  22. package/dist/runtime-state.js.map +1 -1
  23. package/dist/storage.d.ts +4 -0
  24. package/dist/storage.d.ts.map +1 -1
  25. package/dist/storage.js +13 -0
  26. package/dist/storage.js.map +1 -1
  27. package/dist/web-editor/server.js +4 -4
  28. package/dist/web-editor/server.js.map +1 -1
  29. package/dist/web-editor/types.d.ts +8 -8
  30. package/dist/web-editor/types.d.ts.map +1 -1
  31. package/dist/web-host.d.ts +1 -1
  32. package/dist/web-host.d.ts.map +1 -1
  33. package/dist/web-host.js +9 -9
  34. package/dist/web-host.js.map +1 -1
  35. package/examples/custom-system-status-extension/README.md +34 -27
  36. package/examples/custom-system-status-extension/index.ts +71 -111
  37. package/examples/custom-system-status-extension/prompt-stack.json +1 -1
  38. package/package.json +1 -1
  39. package/src/forge-extensions.ts +223 -0
  40. package/src/index.ts +16 -1
  41. package/src/lifecycle.ts +6 -6
  42. package/src/preset-command.ts +13 -4
  43. package/src/runtime-state.ts +4 -0
  44. package/src/storage.ts +17 -0
  45. package/src/web-editor/server.ts +4 -4
  46. package/src/web-editor/types.ts +4 -4
  47. package/src/web-host.ts +13 -13
@@ -1,12 +1,26 @@
1
1
  import { cpus, freemem, loadavg, totalmem, uptime } from "node:os";
2
- import { resolve } from "node:path";
3
- import { pathToFileURL } from "node:url";
4
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import type { registerMacro, registerSlot } from "../../src/index.ts";
6
2
 
7
- interface PiForgeRegistrationApi {
8
- registerMacro: typeof registerMacro;
9
- registerSlot: typeof registerSlot;
3
+ interface ForgeRegistrationApi {
4
+ registerMacro(definition: {
5
+ name: string;
6
+ source?: string;
7
+ description?: string;
8
+ render: () => string;
9
+ }): () => void;
10
+ registerSlot(definition: {
11
+ name: string;
12
+ source?: string;
13
+ description?: string;
14
+ options?: Record<string, unknown>;
15
+ render: (ctx: {
16
+ options: Record<string, unknown>;
17
+ format: () => "xml" | "plain" | "json";
18
+ helpers: {
19
+ escapeXml(value: string): string;
20
+ plainBullet(label: string, value: string): string;
21
+ };
22
+ }) => string;
23
+ }): () => void;
10
24
  }
11
25
 
12
26
  interface SystemStatusSnapshot {
@@ -22,117 +36,63 @@ interface SystemStatusSnapshot {
22
36
  uptimeSeconds: number;
23
37
  }
24
38
 
25
- interface ExampleRegistryState {
26
- unregister: Array<() => void>;
27
- }
28
-
29
- type ExampleGlobal = typeof globalThis & {
30
- __piForgeSystemStatusExample?: ExampleRegistryState;
31
- };
32
-
33
- function registryState(): ExampleRegistryState {
34
- const globalScope = globalThis as ExampleGlobal;
35
- globalScope.__piForgeSystemStatusExample ??= { unregister: [] };
36
- return globalScope.__piForgeSystemStatusExample;
37
- }
38
-
39
- function resetRegistrations(): void {
40
- const state = registryState();
41
- for (const unregister of state.unregister.splice(0)) unregister();
42
- }
43
-
44
- export default async function systemStatusExtension(_pi: ExtensionAPI): Promise<void> {
45
- const { registerMacro, registerSlot } = await loadPiForgeRegistrationApi();
46
- resetRegistrations();
47
- const state = registryState();
48
-
49
- state.unregister.push(
50
- registerMacro({
51
- name: "cpuLoad",
52
- source: "pi-forge-example-system-status",
53
- description: "Current machine CPU load as normalized 1-minute OS load average.",
54
- render: () => formatCpuLoad(readSystemStatus()),
55
- }),
56
- registerSlot({
57
- name: "machine-status",
58
- source: "pi-forge-example-system-status",
59
- description: "Current machine CPU, memory, and uptime snapshot.",
60
- options: {
61
- format: { type: "enum", values: ["plain", "xml"], default: "plain" },
62
- heading: { type: "string", default: "Machine status" },
63
- includeMemory: { type: "boolean", default: true },
64
- includeUptime: { type: "boolean", default: true },
65
- },
66
- render: (ctx) => {
67
- const snapshot = readSystemStatus();
68
- const includeMemory = ctx.options.includeMemory !== false;
69
- const includeUptime = ctx.options.includeUptime !== false;
70
- const heading = typeof ctx.options.heading === "string" && ctx.options.heading.trim()
71
- ? ctx.options.heading.trim()
72
- : "Machine status";
73
-
74
- if (ctx.format() === "xml") {
75
- const lines = [
76
- "<machine_status>",
77
- ` <cpu logical_cores=\"${snapshot.logicalCores}\" normalized_load_1m=\"${snapshot.normalizedLoad1.toFixed(3)}\" model=\"${ctx.helpers.escapeXml(snapshot.cpuModel)}\">${ctx.helpers.escapeXml(formatCpuLoad(snapshot))}</cpu>`,
78
- ` <load_average one_minute=\"${snapshot.load1.toFixed(2)}\" five_minutes=\"${snapshot.load5.toFixed(2)}\" fifteen_minutes=\"${snapshot.load15.toFixed(2)}\" />`,
79
- ];
80
- if (includeMemory) {
81
- lines.push(` <memory used_percent=\"${snapshot.usedMemoryPercent.toFixed(1)}\" free_mb=\"${snapshot.freeMemoryMb}\" total_mb=\"${snapshot.totalMemoryMb}\" />`);
82
- }
83
- if (includeUptime) {
84
- lines.push(` <uptime>${ctx.helpers.escapeXml(formatDuration(snapshot.uptimeSeconds))}</uptime>`);
85
- }
86
- lines.push("</machine_status>");
87
- return lines.join("\n");
88
- }
89
-
39
+ export default function registerSystemStatus(api: ForgeRegistrationApi): void {
40
+ api.registerMacro({
41
+ name: "cpuLoad",
42
+ source: "pi-forge-example-system-status",
43
+ description: "Current machine CPU load as normalized 1-minute OS load average.",
44
+ render: () => formatCpuLoad(readSystemStatus()),
45
+ });
46
+
47
+ api.registerSlot({
48
+ name: "machine-status",
49
+ source: "pi-forge-example-system-status",
50
+ description: "Current machine CPU, memory, and uptime snapshot.",
51
+ options: {
52
+ format: { type: "enum", values: ["plain", "xml"], default: "plain" },
53
+ heading: { type: "string", default: "Machine status" },
54
+ includeMemory: { type: "boolean", default: true },
55
+ includeUptime: { type: "boolean", default: true },
56
+ },
57
+ render: (ctx) => {
58
+ const snapshot = readSystemStatus();
59
+ const includeMemory = ctx.options.includeMemory !== false;
60
+ const includeUptime = ctx.options.includeUptime !== false;
61
+ const heading = typeof ctx.options.heading === "string" && ctx.options.heading.trim()
62
+ ? ctx.options.heading.trim()
63
+ : "Machine status";
64
+
65
+ if (ctx.format() === "xml") {
90
66
  const lines = [
91
- `${heading}:`,
92
- ctx.helpers.plainBullet("CPU", `${snapshot.logicalCores} logical cores, ${snapshot.cpuModel}`),
93
- ctx.helpers.plainBullet("CPU load", formatCpuLoad(snapshot)),
94
- ctx.helpers.plainBullet("Load average", `${snapshot.load1.toFixed(2)} / ${snapshot.load5.toFixed(2)} / ${snapshot.load15.toFixed(2)} (1m/5m/15m)`),
67
+ "<machine_status>",
68
+ ` <cpu logical_cores=\"${snapshot.logicalCores}\" normalized_load_1m=\"${snapshot.normalizedLoad1.toFixed(3)}\" model=\"${ctx.helpers.escapeXml(snapshot.cpuModel)}\">${ctx.helpers.escapeXml(formatCpuLoad(snapshot))}</cpu>`,
69
+ ` <load_average one_minute=\"${snapshot.load1.toFixed(2)}\" five_minutes=\"${snapshot.load5.toFixed(2)}\" fifteen_minutes=\"${snapshot.load15.toFixed(2)}\" />`,
95
70
  ];
96
71
  if (includeMemory) {
97
- lines.push(ctx.helpers.plainBullet("Memory", `${snapshot.usedMemoryPercent.toFixed(1)}% used (${snapshot.freeMemoryMb} MB free / ${snapshot.totalMemoryMb} MB total)`));
72
+ lines.push(` <memory used_percent=\"${snapshot.usedMemoryPercent.toFixed(1)}\" free_mb=\"${snapshot.freeMemoryMb}\" total_mb=\"${snapshot.totalMemoryMb}\" />`);
98
73
  }
99
74
  if (includeUptime) {
100
- lines.push(ctx.helpers.plainBullet("Uptime", formatDuration(snapshot.uptimeSeconds)));
75
+ lines.push(` <uptime>${ctx.helpers.escapeXml(formatDuration(snapshot.uptimeSeconds))}</uptime>`);
101
76
  }
77
+ lines.push("</machine_status>");
102
78
  return lines.join("\n");
103
- },
104
- }),
105
- );
106
- }
107
-
108
- async function loadPiForgeRegistrationApi(): Promise<PiForgeRegistrationApi> {
109
- const override = process.env.PI_FORGE_MODULE;
110
- if (override) {
111
- return importPiForgeApi(pathToFileURL(resolve(override)).href);
112
- }
113
-
114
- try {
115
- return await importPiForgeApi("@zihanw/pi-forge");
116
- } catch {
117
- try {
118
- return await importPiForgeApi("../../src/index.ts");
119
- } catch (error) {
120
- const detail = error instanceof Error ? error.message : String(error);
121
- throw new Error(
122
- "Unable to load pi-forge registration API. Install @zihanw/pi-forge, " +
123
- "set PI_FORGE_MODULE=/absolute/path/to/pi-forge/src/index.ts, or run this example in place from the pi-forge checkout. " +
124
- `Last error: ${detail}`,
125
- );
126
- }
127
- }
128
- }
129
-
130
- async function importPiForgeApi(specifier: string): Promise<PiForgeRegistrationApi> {
131
- const mod = await import(specifier) as Partial<PiForgeRegistrationApi>;
132
- if (typeof mod.registerMacro !== "function" || typeof mod.registerSlot !== "function") {
133
- throw new Error(`Module does not export registerMacro/registerSlot: ${specifier}`);
134
- }
135
- return { registerMacro: mod.registerMacro, registerSlot: mod.registerSlot };
79
+ }
80
+
81
+ const lines = [
82
+ `${heading}:`,
83
+ ctx.helpers.plainBullet("CPU", `${snapshot.logicalCores} logical cores, ${snapshot.cpuModel}`),
84
+ ctx.helpers.plainBullet("CPU load", formatCpuLoad(snapshot)),
85
+ ctx.helpers.plainBullet("Load average", `${snapshot.load1.toFixed(2)} / ${snapshot.load5.toFixed(2)} / ${snapshot.load15.toFixed(2)} (1m/5m/15m)`),
86
+ ];
87
+ if (includeMemory) {
88
+ lines.push(ctx.helpers.plainBullet("Memory", `${snapshot.usedMemoryPercent.toFixed(1)}% used (${snapshot.freeMemoryMb} MB free / ${snapshot.totalMemoryMb} MB total)`));
89
+ }
90
+ if (includeUptime) {
91
+ lines.push(ctx.helpers.plainBullet("Uptime", formatDuration(snapshot.uptimeSeconds)));
92
+ }
93
+ return lines.join("\n");
94
+ },
95
+ });
136
96
  }
137
97
 
138
98
  function readSystemStatus(): SystemStatusSnapshot {
@@ -3,7 +3,7 @@
3
3
  "type": "pi-forge.prompt-stack",
4
4
  "id": "custom-system-status",
5
5
  "name": "Custom System Status",
6
- "description": "Example stack that uses a trusted custom macro and custom slot registered by examples/custom-system-status-extension/index.ts.",
6
+ "description": "Example stack that uses a trusted custom macro and custom slot registered from .pi/forge/extensions/system-status.ts.",
7
7
  "mode": "append",
8
8
  "items": [
9
9
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zihanw/pi-forge",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Pi extension for prompt stack and agent profile management.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,223 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import {
5
+ getRegisteredMacros,
6
+ registerMacro,
7
+ type PromptMacroDefinition,
8
+ } from "./macro-engine.ts";
9
+ import { promptRenderHelpers, type PromptRenderHelpers } from "./render-helpers.ts";
10
+ import {
11
+ getRegisteredSlots,
12
+ registerSlot,
13
+ type PromptSlotDefinition,
14
+ } from "./slot-renderers.ts";
15
+ import { forgeDir, forgeExtensionsDir, globalForgeDir, globalForgeExtensionsDir } from "./storage.ts";
16
+ import type { PromptStackDiagnostic } from "./types.ts";
17
+
18
+ export interface ForgeExtensionApi {
19
+ cwd: string;
20
+ forgeDir: string;
21
+ extensionPath: string;
22
+ helpers: PromptRenderHelpers;
23
+ registerMacro(definition: PromptMacroDefinition): () => void;
24
+ registerSlot(definition: PromptSlotDefinition): () => void;
25
+ getRegisteredMacros: typeof getRegisteredMacros;
26
+ getRegisteredSlots: typeof getRegisteredSlots;
27
+ }
28
+
29
+ export type ForgeExtensionRegister = (api: ForgeExtensionApi) => void | (() => void) | Promise<void | (() => void)>;
30
+
31
+ export interface ForgeExtensionState {
32
+ unregister: Array<() => void>;
33
+ loadVersion: number;
34
+ }
35
+
36
+ export interface ForgeExtensionLoadResult {
37
+ diagnostics: PromptStackDiagnostic[];
38
+ loadedPaths: string[];
39
+ }
40
+
41
+ interface ForgeExtensionCandidate {
42
+ filePath: string;
43
+ forgeDir: string;
44
+ }
45
+
46
+ export function createForgeExtensionState(): ForgeExtensionState {
47
+ return { unregister: [], loadVersion: 0 };
48
+ }
49
+
50
+ export async function reloadForgeExtensions(cwd: string, state: ForgeExtensionState): Promise<ForgeExtensionLoadResult> {
51
+ const diagnostics = unloadForgeExtensions(state);
52
+ state.loadVersion++;
53
+ const loadedPaths: string[] = [];
54
+
55
+ for (const candidate of discoverForgeExtensionCandidates(cwd)) {
56
+ const result = await loadForgeExtensionFile(cwd, candidate.forgeDir, candidate.filePath, state.loadVersion);
57
+ diagnostics.push(...result.diagnostics);
58
+ if (result.loaded) {
59
+ state.unregister.push(...result.unregister);
60
+ loadedPaths.push(candidate.filePath);
61
+ }
62
+ }
63
+
64
+ return { diagnostics, loadedPaths };
65
+ }
66
+
67
+ export function unloadForgeExtensions(state: ForgeExtensionState): PromptStackDiagnostic[] {
68
+ const diagnostics: PromptStackDiagnostic[] = [];
69
+ for (const unregister of state.unregister.splice(0).reverse()) {
70
+ try {
71
+ unregister();
72
+ } catch (error) {
73
+ diagnostics.push({
74
+ level: "warning",
75
+ message: `Failed to unload pi-forge extension: ${error instanceof Error ? error.message : String(error)}`,
76
+ });
77
+ }
78
+ }
79
+ return diagnostics;
80
+ }
81
+
82
+ export function discoverForgeExtensionFiles(cwd: string): string[] {
83
+ return discoverForgeExtensionCandidates(cwd).map((candidate) => candidate.filePath);
84
+ }
85
+
86
+ function discoverForgeExtensionCandidates(cwd: string): ForgeExtensionCandidate[] {
87
+ const dirs = [
88
+ { dir: globalForgeExtensionsDir(), forgeDir: globalForgeDir() },
89
+ { dir: forgeExtensionsDir(cwd), forgeDir: forgeDir(cwd) },
90
+ ];
91
+ const seenDirs = new Set<string>();
92
+ return dirs.flatMap(({ dir, forgeDir }) => {
93
+ if (seenDirs.has(dir)) return [];
94
+ seenDirs.add(dir);
95
+ return discoverForgeExtensionFilesInDir(dir).map((filePath) => ({ filePath, forgeDir }));
96
+ });
97
+ }
98
+
99
+ function discoverForgeExtensionFilesInDir(dir: string): string[] {
100
+ if (!existsSync(dir)) return [];
101
+
102
+ const files: string[] = [];
103
+ let entries: string[];
104
+ try {
105
+ entries = readdirSync(dir);
106
+ } catch {
107
+ return [];
108
+ }
109
+
110
+ for (const name of entries.sort()) {
111
+ if (name.startsWith(".")) continue;
112
+ const fullPath = join(dir, name);
113
+ let stats;
114
+ try {
115
+ stats = statSync(fullPath);
116
+ } catch {
117
+ continue;
118
+ }
119
+
120
+ if (stats.isFile() && isForgeExtensionFile(fullPath)) {
121
+ files.push(fullPath);
122
+ continue;
123
+ }
124
+
125
+ if (!stats.isDirectory()) continue;
126
+ for (const indexName of ["index.ts", "index.js", "index.mjs", "index.cjs"]) {
127
+ const indexPath = join(fullPath, indexName);
128
+ if (existsSync(indexPath)) {
129
+ files.push(indexPath);
130
+ break;
131
+ }
132
+ }
133
+ }
134
+
135
+ return files;
136
+ }
137
+
138
+ async function loadForgeExtensionFile(
139
+ cwd: string,
140
+ forgeDirPath: string,
141
+ filePath: string,
142
+ loadVersion: number,
143
+ ): Promise<{ loaded: boolean; unregister: Array<() => void>; diagnostics: PromptStackDiagnostic[] }> {
144
+ const unregister: Array<() => void> = [];
145
+ const diagnostics: PromptStackDiagnostic[] = [];
146
+ try {
147
+ const module = await import(`${pathToFileURL(filePath).href}?piForgeReload=${loadVersion}`);
148
+ const register = readRegisterFunction(module);
149
+ if (!register) {
150
+ return {
151
+ loaded: false,
152
+ unregister,
153
+ diagnostics: [{
154
+ level: "warning",
155
+ message: `pi-forge extension ${extensionLabel(filePath)} must export a default function or named register function.`,
156
+ }],
157
+ };
158
+ }
159
+
160
+ const cleanup = await register(createForgeExtensionApi(cwd, forgeDirPath, filePath, unregister));
161
+ if (typeof cleanup === "function") unregister.push(once(cleanup));
162
+ return { loaded: true, unregister, diagnostics };
163
+ } catch (error) {
164
+ for (const cleanup of unregister.splice(0).reverse()) {
165
+ try {
166
+ cleanup();
167
+ } catch {
168
+ // Preserve the original load error; cleanup failures are secondary.
169
+ }
170
+ }
171
+ diagnostics.push({
172
+ level: "warning",
173
+ message: `Failed to load pi-forge extension ${extensionLabel(filePath)}: ${error instanceof Error ? error.message : String(error)}`,
174
+ });
175
+ return { loaded: false, unregister: [], diagnostics };
176
+ }
177
+ }
178
+
179
+ function createForgeExtensionApi(cwd: string, forgeDirPath: string, extensionPath: string, unregister: Array<() => void>): ForgeExtensionApi {
180
+ return {
181
+ cwd,
182
+ forgeDir: forgeDirPath,
183
+ extensionPath,
184
+ helpers: promptRenderHelpers,
185
+ registerMacro: (definition) => trackUnregister(unregister, registerMacro(definition)),
186
+ registerSlot: (definition) => trackUnregister(unregister, registerSlot(definition)),
187
+ getRegisteredMacros,
188
+ getRegisteredSlots,
189
+ };
190
+ }
191
+
192
+ function trackUnregister(unregisters: Array<() => void>, unregister: () => void): () => void {
193
+ const tracked = once(unregister);
194
+ unregisters.push(tracked);
195
+ return tracked;
196
+ }
197
+
198
+ function once(callback: () => void): () => void {
199
+ let active = true;
200
+ return () => {
201
+ if (!active) return;
202
+ active = false;
203
+ callback();
204
+ };
205
+ }
206
+
207
+ function readRegisterFunction(module: unknown): ForgeExtensionRegister | undefined {
208
+ const record = module && typeof module === "object" ? module as { default?: unknown; register?: unknown } : {};
209
+ if (typeof record.default === "function") return record.default as ForgeExtensionRegister;
210
+ if (typeof record.register === "function") return record.register as ForgeExtensionRegister;
211
+ if (record.default && typeof record.default === "object" && typeof (record.default as { register?: unknown }).register === "function") {
212
+ return (record.default as { register: ForgeExtensionRegister }).register;
213
+ }
214
+ return undefined;
215
+ }
216
+
217
+ function isForgeExtensionFile(filePath: string): boolean {
218
+ return (filePath.endsWith(".ts") || filePath.endsWith(".js") || filePath.endsWith(".mjs") || filePath.endsWith(".cjs")) && !filePath.endsWith(".d.ts");
219
+ }
220
+
221
+ function extensionLabel(filePath: string): string {
222
+ return basename(filePath);
223
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { createForgeExtensionState, reloadForgeExtensions, unloadForgeExtensions } from "./forge-extensions.ts";
2
3
  import { registerLifecycleHandlers } from "./lifecycle.ts";
3
4
  import { chooseDefaultStack, isDisabledPromptStackId, loadPromptStacks } from "./loader.ts";
4
5
  import { registerPayloadCommands, registerPayloadRequestHandler, armPayloadIntercept, clearPayloadCapture, webPayloadSnapshot } from "./payload-command.ts";
@@ -31,6 +32,10 @@ export {
31
32
  type PromptExtensionOptionType,
32
33
  type PromptRegistryEntry,
33
34
  } from "./extension-registry.ts";
35
+ export {
36
+ type ForgeExtensionApi,
37
+ type ForgeExtensionRegister,
38
+ } from "./forge-extensions.ts";
34
39
  export {
35
40
  createVariableAccess,
36
41
  promptRenderHelpers,
@@ -69,6 +74,7 @@ export default function piForge(pi: ExtensionAPI) {
69
74
  let webEditorCwd: string | undefined;
70
75
  let webEditorPreferredPort: number | undefined;
71
76
  let toolPolicyBaseline: string[] | undefined;
77
+ const forgeExtensionState = createForgeExtensionState();
72
78
 
73
79
  function activeId(): string | undefined {
74
80
  return state.active?.stack.id;
@@ -102,8 +108,11 @@ export default function piForge(pi: ExtensionAPI) {
102
108
  return true;
103
109
  }
104
110
 
105
- function reloadStacks(ctx: ExtensionContext, preferredId?: string): void {
111
+ async function reloadStacks(ctx: ExtensionContext, preferredId?: string): Promise<void> {
106
112
  if (!ctx.isProjectTrusted()) {
113
+ const unloadDiagnostics = unloadForgeExtensions(forgeExtensionState);
114
+ state.forgeExtensionDiagnostics = unloadDiagnostics;
115
+ state.forgeExtensionPaths = [];
107
116
  state.stacks = [];
108
117
  state.active = undefined;
109
118
  syncActiveToolPolicy(ctx);
@@ -112,7 +121,13 @@ export default function piForge(pi: ExtensionAPI) {
112
121
  return;
113
122
  }
114
123
 
124
+ const extensionResult = await reloadForgeExtensions(ctx.cwd, forgeExtensionState);
125
+ state.forgeExtensionDiagnostics = extensionResult.diagnostics;
126
+ state.forgeExtensionPaths = extensionResult.loadedPaths;
115
127
  state.stacks = loadPromptStacks(ctx.cwd);
128
+ if (state.forgeExtensionDiagnostics.length > 0) {
129
+ for (const loaded of state.stacks) loaded.diagnostics.unshift(...state.forgeExtensionDiagnostics);
130
+ }
116
131
  state.active = chooseDefaultStack(state.stacks, preferredId);
117
132
  updateStatus(ctx);
118
133
  syncActiveToolPolicy(ctx);
package/src/lifecycle.ts CHANGED
@@ -12,7 +12,7 @@ import { STATE_ENTRY_TYPE, VARIABLE_ENTRY_TYPE, type PiForgeRuntimeState } from
12
12
  import type { PromptStackDiagnostic, PromptVariableStore, PromptVariableValue } from "./types.ts";
13
13
 
14
14
  export interface LifecycleDeps {
15
- reloadStacks(ctx: ExtensionContext, preferredId?: string): void;
15
+ reloadStacks(ctx: ExtensionContext, preferredId?: string): Promise<void>;
16
16
  refreshWebEditorHost(ctx: ExtensionContext): void;
17
17
  notifyActivePreset(ctx: ExtensionContext, detail: string): void;
18
18
  syncActiveToolPolicy(ctx?: ExtensionContext): void;
@@ -23,19 +23,19 @@ export interface LifecycleDeps {
23
23
 
24
24
  export function registerLifecycleHandlers(pi: ExtensionAPI, state: PiForgeRuntimeState, deps: LifecycleDeps): void {
25
25
  pi.on("session_start", async (event, ctx) => {
26
- restoreBranchScopedRuntime(ctx, state, deps);
26
+ await restoreBranchScopedRuntime(ctx, state, deps);
27
27
  deps.refreshWebEditorHost(ctx);
28
28
  deps.notifyActivePreset(ctx, "after session " + event.reason);
29
29
  });
30
30
 
31
31
  pi.on("session_tree", async (_event, ctx) => {
32
- restoreBranchScopedRuntime(ctx, state, deps);
32
+ await restoreBranchScopedRuntime(ctx, state, deps);
33
33
  deps.refreshWebEditorHost(ctx);
34
34
  deps.notifyActivePreset(ctx, "after tree navigation");
35
35
  });
36
36
 
37
37
  pi.on("session_compact", async (_event, ctx) => {
38
- restoreBranchScopedRuntime(ctx, state, deps);
38
+ await restoreBranchScopedRuntime(ctx, state, deps);
39
39
  deps.refreshWebEditorHost(ctx);
40
40
  deps.notifyActivePreset(ctx, "after compaction");
41
41
  });
@@ -105,12 +105,12 @@ export function registerLifecycleHandlers(pi: ExtensionAPI, state: PiForgeRuntim
105
105
  });
106
106
  }
107
107
 
108
- function restoreBranchScopedRuntime(ctx: ExtensionContext, state: PiForgeRuntimeState, deps: LifecycleDeps): void {
108
+ async function restoreBranchScopedRuntime(ctx: ExtensionContext, state: PiForgeRuntimeState, deps: LifecycleDeps): Promise<void> {
109
109
  state.sessionVariables = getRestoredVariables(ctx);
110
110
  state.currentVariableStore = undefined;
111
111
  const restoredActiveId = getRestoredActiveId(ctx);
112
112
  state.lastPersistedActiveId = restoredActiveId;
113
- deps.reloadStacks(ctx, restoredActiveId);
113
+ await deps.reloadStacks(ctx, restoredActiveId);
114
114
  }
115
115
 
116
116
  function getCurrentBranchEntries(ctx: ExtensionContext): unknown[] {
@@ -6,12 +6,13 @@ import { renderDiagnostics, renderPreview, showText } from "./preview.ts";
6
6
  import type { PiForgeRuntimeState } from "./runtime-state.ts";
7
7
  import { importSillyTavernPreset } from "./sillytavern-importer.ts";
8
8
  import { migrateLegacyPromptStacks, renderMigrationReport } from "./stack-migration.ts";
9
+ import { forgeExtensionsDir, globalForgeExtensionsDir } from "./storage.ts";
9
10
  import type { LoadedPromptStack } from "./types.ts";
10
11
 
11
12
  export interface PresetCommandDeps {
12
13
  selectedActiveId(): string | undefined;
13
14
  setActive(id: string | undefined, ctx?: ExtensionCommandContext): boolean;
14
- reloadStacks(ctx: ExtensionCommandContext, preferredId?: string): void;
15
+ reloadStacks(ctx: ExtensionCommandContext, preferredId?: string): Promise<void>;
15
16
  openWebEditor(ctx: ExtensionCommandContext, mode?: "open" | "restart"): Promise<void>;
16
17
  stopWebEditor(ctx: ExtensionCommandContext): Promise<void>;
17
18
  }
@@ -65,7 +66,7 @@ async function handlePresetCommand(
65
66
  return;
66
67
 
67
68
  case "reload":
68
- deps.reloadStacks(ctx, deps.selectedActiveId());
69
+ await deps.reloadStacks(ctx, deps.selectedActiveId());
69
70
  ctx.ui.notify(`pi-forge: reloaded ${state.stacks.length} prompt stack(s).`, "info");
70
71
  return;
71
72
 
@@ -130,7 +131,7 @@ async function handlePresetCommand(
130
131
  overwrite: flags.has("--overwrite"),
131
132
  deleteLegacy: flags.has("--delete-legacy"),
132
133
  });
133
- if (!dryRun) deps.reloadStacks(ctx, deps.selectedActiveId());
134
+ if (!dryRun) await deps.reloadStacks(ctx, deps.selectedActiveId());
134
135
  const changed = report.copied + report.overwritten;
135
136
  const summary = dryRun
136
137
  ? `pi-forge: migration dry run found ${report.files.length} legacy stack file(s).`
@@ -220,7 +221,7 @@ async function handleImportSilly(
220
221
  if (!existsSync(reportDir)) mkdirSync(reportDir, { recursive: true });
221
222
  writeFileSync(reportPath, result.report, "utf8");
222
223
 
223
- deps.reloadStacks(ctx, deps.selectedActiveId());
224
+ await deps.reloadStacks(ctx, deps.selectedActiveId());
224
225
 
225
226
  ctx.ui.notify(`pi-forge: imported ${result.stack.id} (${result.stack.items.length} items)`, "info");
226
227
  await showText(ctx, `pi-forge import report: ${result.stack.id}`, `Stack written to: ${stackPath}\nReport written to: ${reportPath}\n\n${result.report}`);
@@ -230,6 +231,10 @@ function renderStackList(state: PiForgeRuntimeState, ctx: ExtensionCommandContex
230
231
  const lines = [
231
232
  "Prompt stack directories:",
232
233
  ...promptStackReadDirs(ctx.cwd).map((dir, index) => ` ${index === 0 ? "primary" : "legacy"}: ${dir}`),
234
+ "Forge extension directories:",
235
+ ` global: ${globalForgeExtensionsDir()}`,
236
+ ` project: ${forgeExtensionsDir(ctx.cwd)}`,
237
+ `Loaded forge extensions: ${state.forgeExtensionPaths.length}`,
233
238
  `Active stack: ${state.active?.stack.id ?? "(none)"}`,
234
239
  "",
235
240
  ];
@@ -256,6 +261,10 @@ function renderCurrentDiagnostics(state: PiForgeRuntimeState): string {
256
261
  const lines = ["# pi-forge diagnostics", ""];
257
262
  lines.push("## Active stack load/validation diagnostics", "");
258
263
  lines.push(state.active ? renderDiagnostics(state.active.diagnostics) : "No active prompt stack.");
264
+ lines.push("", "## pi-forge extension diagnostics", "");
265
+ lines.push(renderDiagnostics(state.forgeExtensionDiagnostics));
266
+ lines.push("", "## Loaded pi-forge extensions", "");
267
+ lines.push(state.forgeExtensionPaths.length > 0 ? state.forgeExtensionPaths.map((path) => `- ${path}`).join("\n") : "(none)");
259
268
  lines.push("", "## Latest runtime compile diagnostics", "");
260
269
  lines.push(renderDiagnostics(state.latestCompileDiagnostics));
261
270
  return lines.join("\n");
@@ -17,6 +17,8 @@ export interface PiForgeRuntimeState {
17
17
  sessionVariables: Record<string, PromptVariableValue>;
18
18
  lastPersistedActiveId?: string;
19
19
  latestCompileDiagnostics: PromptStackDiagnostic[];
20
+ forgeExtensionDiagnostics: PromptStackDiagnostic[];
21
+ forgeExtensionPaths: string[];
20
22
  interceptNextProviderPayload: boolean;
21
23
  interceptPayloadSavePath?: string;
22
24
  interceptPayloadDisplayTarget: PayloadDisplayTarget;
@@ -30,6 +32,8 @@ export function createRuntimeState(): PiForgeRuntimeState {
30
32
  contextRewritePending: false,
31
33
  sessionVariables: {},
32
34
  latestCompileDiagnostics: [],
35
+ forgeExtensionDiagnostics: [],
36
+ forgeExtensionPaths: [],
33
37
  interceptNextProviderPayload: false,
34
38
  interceptPayloadDisplayTarget: "editor",
35
39
  };
package/src/storage.ts CHANGED
@@ -1,9 +1,26 @@
1
+ import { homedir } from "node:os";
1
2
  import { isAbsolute, join, relative, resolve } from "node:path";
2
3
 
3
4
  export function promptStacksDir(cwd: string): string {
4
5
  return join(cwd, ".pi", "forge", "prompt-stacks");
5
6
  }
6
7
 
8
+ export function forgeDir(cwd: string): string {
9
+ return join(cwd, ".pi", "forge");
10
+ }
11
+
12
+ export function forgeExtensionsDir(cwd: string): string {
13
+ return join(forgeDir(cwd), "extensions");
14
+ }
15
+
16
+ export function globalForgeDir(): string {
17
+ return join(homedir(), ".pi", "forge");
18
+ }
19
+
20
+ export function globalForgeExtensionsDir(): string {
21
+ return join(globalForgeDir(), "extensions");
22
+ }
23
+
7
24
  export function legacyPromptStacksDir(cwd: string): string {
8
25
  return join(cwd, ".pi", "prompt-stacks");
9
26
  }