@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.
- package/README.md +31 -24
- package/README.zh-CN.md +31 -24
- package/dist/forge-extensions.d.ts +28 -0
- package/dist/forge-extensions.d.ts.map +1 -0
- package/dist/forge-extensions.js +182 -0
- package/dist/forge-extensions.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +5 -5
- package/dist/lifecycle.js.map +1 -1
- package/dist/preset-command.d.ts +1 -1
- package/dist/preset-command.d.ts.map +1 -1
- package/dist/preset-command.js +12 -3
- package/dist/preset-command.js.map +1 -1
- package/dist/runtime-state.d.ts +2 -0
- package/dist/runtime-state.d.ts.map +1 -1
- package/dist/runtime-state.js +2 -0
- package/dist/runtime-state.js.map +1 -1
- package/dist/storage.d.ts +4 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +13 -0
- package/dist/storage.js.map +1 -1
- package/dist/web-editor/server.js +4 -4
- package/dist/web-editor/server.js.map +1 -1
- package/dist/web-editor/types.d.ts +8 -8
- package/dist/web-editor/types.d.ts.map +1 -1
- package/dist/web-host.d.ts +1 -1
- package/dist/web-host.d.ts.map +1 -1
- package/dist/web-host.js +9 -9
- package/dist/web-host.js.map +1 -1
- package/examples/custom-system-status-extension/README.md +34 -27
- package/examples/custom-system-status-extension/index.ts +71 -111
- package/examples/custom-system-status-extension/prompt-stack.json +1 -1
- package/package.json +1 -1
- package/src/forge-extensions.ts +223 -0
- package/src/index.ts +16 -1
- package/src/lifecycle.ts +6 -6
- package/src/preset-command.ts +13 -4
- package/src/runtime-state.ts +4 -0
- package/src/storage.ts +17 -0
- package/src/web-editor/server.ts +4 -4
- package/src/web-editor/types.ts +4 -4
- 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
|
|
8
|
-
registerMacro:
|
|
9
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
@@ -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[] {
|
package/src/preset-command.ts
CHANGED
|
@@ -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");
|
package/src/runtime-state.ts
CHANGED
|
@@ -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
|
}
|