@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/LICENSE +21 -0
- package/README.md +286 -0
- package/examples/default-prompt-stack.json +117 -0
- package/examples/validation-issues-prompt-stack.json +38 -0
- package/package.json +58 -0
- package/src/compiler.ts +547 -0
- package/src/index.ts +521 -0
- package/src/loader.ts +227 -0
- package/src/sillytavern-importer.ts +528 -0
- package/src/types.ts +139 -0
package/src/loader.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
LoadedPromptStack,
|
|
5
|
+
PromptStack,
|
|
6
|
+
PromptStackDiagnostic,
|
|
7
|
+
PromptStackItem,
|
|
8
|
+
PromptStackRole,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
import { SUPPORTED_SLOTS } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const VALID_ROLES = new Set<PromptStackRole>(["system", "user", "assistant", "custom"]);
|
|
13
|
+
|
|
14
|
+
export function promptStacksDir(cwd: string): string {
|
|
15
|
+
return join(cwd, ".pi", "prompt-stacks");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function loadPromptStacks(cwd: string): LoadedPromptStack[] {
|
|
19
|
+
const dir = promptStacksDir(cwd);
|
|
20
|
+
if (!existsSync(dir)) return [];
|
|
21
|
+
|
|
22
|
+
let entries: string[];
|
|
23
|
+
try {
|
|
24
|
+
entries = readdirSync(dir).filter((name) => name.endsWith(".json"));
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return entries.sort().map((name) => loadPromptStackFile(join(dir, name)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function chooseDefaultStack(
|
|
33
|
+
stacks: LoadedPromptStack[],
|
|
34
|
+
preferredId?: string,
|
|
35
|
+
): LoadedPromptStack | undefined {
|
|
36
|
+
if (isDisabledPromptStackId(preferredId)) return undefined;
|
|
37
|
+
|
|
38
|
+
if (preferredId) {
|
|
39
|
+
const preferred = stacks.find((loaded) => loaded.stack.id === preferredId);
|
|
40
|
+
if (preferred && isUsablePromptStack(preferred)) return preferred;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const defaultStack = stacks.find(
|
|
44
|
+
(loaded) =>
|
|
45
|
+
basename(loaded.filePath) === "default.json" &&
|
|
46
|
+
loaded.stack.autoActivate !== false &&
|
|
47
|
+
isUsablePromptStack(loaded),
|
|
48
|
+
);
|
|
49
|
+
if (defaultStack) return defaultStack;
|
|
50
|
+
|
|
51
|
+
return stacks.find((loaded) => loaded.stack.autoActivate === true && isUsablePromptStack(loaded));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isUsablePromptStack(loaded: LoadedPromptStack): boolean {
|
|
55
|
+
return !loaded.diagnostics.some((diagnostic) => diagnostic.level === "error");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isDisabledPromptStackId(id: string | undefined): boolean {
|
|
59
|
+
return id === "none" || id === "off";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadPromptStackFile(filePath: string): LoadedPromptStack {
|
|
63
|
+
const diagnostics: PromptStackDiagnostic[] = [];
|
|
64
|
+
let raw: unknown;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
raw = JSON.parse(readFileSync(filePath, "utf8"));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return {
|
|
70
|
+
filePath,
|
|
71
|
+
stack: fallbackStack(filePath),
|
|
72
|
+
diagnostics: [
|
|
73
|
+
{
|
|
74
|
+
level: "error",
|
|
75
|
+
message: `Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const stack = normalizeStack(raw, filePath, diagnostics);
|
|
82
|
+
diagnostics.push(...validatePromptStack(stack));
|
|
83
|
+
|
|
84
|
+
return { filePath, stack, diagnostics };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fallbackStack(filePath: string): PromptStack {
|
|
88
|
+
return {
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
type: "pi-forge.prompt-stack",
|
|
91
|
+
id: basename(filePath, ".json"),
|
|
92
|
+
name: basename(filePath),
|
|
93
|
+
items: [],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeStack(raw: unknown, filePath: string, diagnostics: PromptStackDiagnostic[]): PromptStack {
|
|
98
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
99
|
+
diagnostics.push({ level: "error", message: "Prompt stack root must be a JSON object." });
|
|
100
|
+
return fallbackStack(filePath);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const obj = raw as Record<string, unknown>;
|
|
104
|
+
const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : basename(filePath, ".json");
|
|
105
|
+
const schemaVersion = obj.schemaVersion === 1 ? 1 : 1;
|
|
106
|
+
if (obj.schemaVersion !== 1) {
|
|
107
|
+
diagnostics.push({ level: "warning", message: "Missing or unsupported schemaVersion; assuming 1." });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const items = Array.isArray(obj.items) ? obj.items.map((item, index) => normalizeItem(item, index, diagnostics)) : [];
|
|
111
|
+
if (!Array.isArray(obj.items)) {
|
|
112
|
+
diagnostics.push({ level: "error", message: "Prompt stack must contain an items array." });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
schemaVersion,
|
|
117
|
+
type: obj.type === "pi-forge.prompt-stack" ? "pi-forge.prompt-stack" : undefined,
|
|
118
|
+
id,
|
|
119
|
+
name: typeof obj.name === "string" ? obj.name : undefined,
|
|
120
|
+
description: typeof obj.description === "string" ? obj.description : undefined,
|
|
121
|
+
autoActivate: typeof obj.autoActivate === "boolean" ? obj.autoActivate : undefined,
|
|
122
|
+
mode: obj.mode === "append" || obj.mode === "prepend" || obj.mode === "replace" ? obj.mode : undefined,
|
|
123
|
+
defaults: isPlainObject(obj.defaults) ? (obj.defaults as PromptStack["defaults"]) : undefined,
|
|
124
|
+
context: isPlainObject(obj.context) ? (obj.context as PromptStack["context"]) : undefined,
|
|
125
|
+
variables: normalizeStringRecord(obj.variables),
|
|
126
|
+
items,
|
|
127
|
+
import: isPlainObject(obj.import) ? (obj.import as Record<string, unknown>) : undefined,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeItem(raw: unknown, index: number, diagnostics: PromptStackDiagnostic[]): PromptStackItem {
|
|
132
|
+
const fallbackId = `item-${index + 1}`;
|
|
133
|
+
|
|
134
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
135
|
+
diagnostics.push({ level: "error", message: `Item ${index + 1} must be an object.`, itemId: fallbackId });
|
|
136
|
+
return { kind: "block", id: fallbackId, enabled: false, content: "" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const obj = raw as Record<string, unknown>;
|
|
140
|
+
const kind = obj.kind === "slot" ? "slot" : "block";
|
|
141
|
+
const id = normalizeId(obj.id, fallbackId);
|
|
142
|
+
const base = {
|
|
143
|
+
kind,
|
|
144
|
+
id,
|
|
145
|
+
name: typeof obj.name === "string" ? obj.name : undefined,
|
|
146
|
+
enabled: typeof obj.enabled === "boolean" ? obj.enabled : undefined,
|
|
147
|
+
role: VALID_ROLES.has(obj.role as PromptStackRole) ? (obj.role as PromptStackRole) : undefined,
|
|
148
|
+
tags: Array.isArray(obj.tags) ? obj.tags.filter((tag): tag is string => typeof tag === "string") : undefined,
|
|
149
|
+
source: isPlainObject(obj.source) ? (obj.source as Record<string, unknown>) : undefined,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (kind === "slot") {
|
|
153
|
+
return {
|
|
154
|
+
...base,
|
|
155
|
+
kind: "slot",
|
|
156
|
+
slot: typeof obj.slot === "string" ? obj.slot : "",
|
|
157
|
+
options: isPlainObject(obj.options) ? obj.options : undefined,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
...base,
|
|
163
|
+
kind: "block",
|
|
164
|
+
content: typeof obj.content === "string" ? obj.content : "",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function validatePromptStack(stack: PromptStack): PromptStackDiagnostic[] {
|
|
169
|
+
const diagnostics: PromptStackDiagnostic[] = [];
|
|
170
|
+
const ids = new Set<string>();
|
|
171
|
+
let chatHistoryCount = 0;
|
|
172
|
+
|
|
173
|
+
if (!stack.id.trim()) diagnostics.push({ level: "error", message: "Stack id must not be empty." });
|
|
174
|
+
|
|
175
|
+
for (const item of stack.items) {
|
|
176
|
+
if (ids.has(item.id)) diagnostics.push({ level: "error", message: `Duplicate item id: ${item.id}`, itemId: item.id });
|
|
177
|
+
ids.add(item.id);
|
|
178
|
+
|
|
179
|
+
if (item.role && !VALID_ROLES.has(item.role)) {
|
|
180
|
+
diagnostics.push({ level: "error", message: `Invalid role: ${item.role}`, itemId: item.id });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (item.kind === "slot") {
|
|
184
|
+
if (!SUPPORTED_SLOTS.has(item.slot as any)) {
|
|
185
|
+
diagnostics.push({ level: "warning", message: `Unsupported slot: ${item.slot}`, itemId: item.id });
|
|
186
|
+
}
|
|
187
|
+
if (item.enabled !== false && item.slot === "chat-history") chatHistoryCount++;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (item.kind === "block" && item.role !== "system" && item.enabled !== false && !item.role) {
|
|
191
|
+
diagnostics.push({ level: "warning", message: "Enabled block has no role and will be ignored.", itemId: item.id });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (chatHistoryCount === 0) {
|
|
196
|
+
diagnostics.push({
|
|
197
|
+
level: "warning",
|
|
198
|
+
message: "No enabled chat-history slot found; Pi chat context will be appended at the end.",
|
|
199
|
+
});
|
|
200
|
+
} else if (chatHistoryCount > 1 && !stack.context?.allowDuplicateChatHistory) {
|
|
201
|
+
diagnostics.push({
|
|
202
|
+
level: "warning",
|
|
203
|
+
message: "Multiple enabled chat-history slots found; only the first will be expanded unless context.allowDuplicateChatHistory is true.",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return diagnostics;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeId(value: unknown, fallback: string): string {
|
|
211
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
212
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
213
|
+
return fallback;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
217
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
|
|
221
|
+
if (!isPlainObject(value)) return undefined;
|
|
222
|
+
const result: Record<string, string> = {};
|
|
223
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
224
|
+
if (typeof raw === "string") result[key] = raw;
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|