@zihanw/pi-forge 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +354 -185
- package/README.zh-CN.md +453 -0
- package/examples/default-prompt-stack.json +54 -51
- package/examples/reviewer-prompt-stack.json +115 -0
- package/examples/sillytavern-dm-writer-prompt-stack.json +190 -0
- package/package.json +4 -8
- package/src/compiler.ts +588 -87
- package/src/index.ts +509 -140
- package/src/loader.ts +179 -12
- package/src/payload-capture.ts +85 -0
- package/src/policy.ts +42 -0
- package/src/regex.ts +500 -0
- package/src/sillytavern-importer.ts +484 -50
- package/src/stack-migration.ts +159 -0
- package/src/storage.ts +28 -0
- package/src/types.ts +83 -2
- package/src/web-editor/index.ts +2 -0
- package/src/web-editor/page.ts +2844 -0
- package/src/web-editor/server.ts +289 -0
- package/src/web-editor/types.ts +84 -0
- package/src/web-host.ts +229 -0
package/src/loader.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
|
+
import { validateRegexConfig } from "./regex.ts";
|
|
4
|
+
import { promptStackReadDirs } from "./storage.ts";
|
|
3
5
|
import type {
|
|
4
6
|
LoadedPromptStack,
|
|
7
|
+
PromptResourcePolicy,
|
|
5
8
|
PromptStack,
|
|
6
9
|
PromptStackDiagnostic,
|
|
7
10
|
PromptStackItem,
|
|
@@ -10,23 +13,60 @@ import type {
|
|
|
10
13
|
import { SUPPORTED_SLOTS } from "./types.ts";
|
|
11
14
|
|
|
12
15
|
const VALID_ROLES = new Set<PromptStackRole>(["system", "user", "assistant", "custom"]);
|
|
16
|
+
const VALID_CHAT_HISTORY_TOOL_MODES = new Set(["keep", "drop"]);
|
|
13
17
|
|
|
14
|
-
export
|
|
15
|
-
return join(cwd, ".pi", "prompt-stacks");
|
|
16
|
-
}
|
|
18
|
+
export { isInsidePromptStackStorage, legacyPromptStacksDir, promptStackPath, promptStackReadDirs, promptStacksDir } from "./storage.ts";
|
|
17
19
|
|
|
18
20
|
export function loadPromptStacks(cwd: string): LoadedPromptStack[] {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
+
const files = promptStackFiles(cwd);
|
|
22
|
+
const loaded = files.map(loadPromptStackFile);
|
|
23
|
+
annotateDuplicateStackIds(loaded);
|
|
24
|
+
return loaded;
|
|
25
|
+
}
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
function promptStackFiles(cwd: string): string[] {
|
|
28
|
+
const files: string[] = [];
|
|
29
|
+
const shadowedNames = new Set<string>();
|
|
30
|
+
|
|
31
|
+
for (const dir of promptStackReadDirs(cwd)) {
|
|
32
|
+
if (!existsSync(dir)) continue;
|
|
33
|
+
|
|
34
|
+
let entries: string[];
|
|
35
|
+
try {
|
|
36
|
+
entries = readdirSync(dir).filter((name) => name.endsWith(".json"));
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const name of entries.sort()) {
|
|
42
|
+
if (shadowedNames.has(name)) continue;
|
|
43
|
+
shadowedNames.add(name);
|
|
44
|
+
files.push(join(dir, name));
|
|
45
|
+
}
|
|
27
46
|
}
|
|
28
47
|
|
|
29
|
-
return
|
|
48
|
+
return files;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function annotateDuplicateStackIds(stacks: LoadedPromptStack[]): void {
|
|
52
|
+
const byId = new Map<string, LoadedPromptStack[]>();
|
|
53
|
+
for (const loaded of stacks) {
|
|
54
|
+
const id = loaded.stack.id;
|
|
55
|
+
const matches = byId.get(id) ?? [];
|
|
56
|
+
matches.push(loaded);
|
|
57
|
+
byId.set(id, matches);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const [id, matches] of byId) {
|
|
61
|
+
if (matches.length <= 1) continue;
|
|
62
|
+
const files = matches.map((loaded) => basename(loaded.filePath)).join(", ");
|
|
63
|
+
for (const loaded of matches) {
|
|
64
|
+
loaded.diagnostics.push({
|
|
65
|
+
level: "error",
|
|
66
|
+
message: `Duplicate stack id: ${id} appears in multiple files (${files}).`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
30
70
|
}
|
|
31
71
|
|
|
32
72
|
export function chooseDefaultStack(
|
|
@@ -102,7 +142,7 @@ function normalizeStack(raw: unknown, filePath: string, diagnostics: PromptStack
|
|
|
102
142
|
|
|
103
143
|
const obj = raw as Record<string, unknown>;
|
|
104
144
|
const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : basename(filePath, ".json");
|
|
105
|
-
const schemaVersion =
|
|
145
|
+
const schemaVersion = 1;
|
|
106
146
|
if (obj.schemaVersion !== 1) {
|
|
107
147
|
diagnostics.push({ level: "warning", message: "Missing or unsupported schemaVersion; assuming 1." });
|
|
108
148
|
}
|
|
@@ -112,6 +152,13 @@ function normalizeStack(raw: unknown, filePath: string, diagnostics: PromptStack
|
|
|
112
152
|
diagnostics.push({ level: "error", message: "Prompt stack must contain an items array." });
|
|
113
153
|
}
|
|
114
154
|
|
|
155
|
+
if (isPlainObject(obj.state) && Object.keys(obj.state).length > 0) {
|
|
156
|
+
diagnostics.push({
|
|
157
|
+
level: "info",
|
|
158
|
+
message: "state is no longer supported and was ignored; use stack.variables and template variable macros instead.",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
115
162
|
return {
|
|
116
163
|
schemaVersion,
|
|
117
164
|
type: obj.type === "pi-forge.prompt-stack" ? "pi-forge.prompt-stack" : undefined,
|
|
@@ -122,7 +169,10 @@ function normalizeStack(raw: unknown, filePath: string, diagnostics: PromptStack
|
|
|
122
169
|
mode: obj.mode === "append" || obj.mode === "prepend" || obj.mode === "replace" ? obj.mode : undefined,
|
|
123
170
|
defaults: isPlainObject(obj.defaults) ? (obj.defaults as PromptStack["defaults"]) : undefined,
|
|
124
171
|
context: isPlainObject(obj.context) ? (obj.context as PromptStack["context"]) : undefined,
|
|
172
|
+
tools: normalizeResourcePolicy(obj.tools, "tools", diagnostics),
|
|
173
|
+
skills: normalizeResourcePolicy(obj.skills, "skills", diagnostics),
|
|
125
174
|
variables: normalizeStringRecord(obj.variables),
|
|
175
|
+
regex: normalizeRegexConfig(obj.regex, diagnostics),
|
|
126
176
|
items,
|
|
127
177
|
import: isPlainObject(obj.import) ? (obj.import as Record<string, unknown>) : undefined,
|
|
128
178
|
};
|
|
@@ -185,6 +235,7 @@ export function validatePromptStack(stack: PromptStack): PromptStackDiagnostic[]
|
|
|
185
235
|
diagnostics.push({ level: "warning", message: `Unsupported slot: ${item.slot}`, itemId: item.id });
|
|
186
236
|
}
|
|
187
237
|
if (item.enabled !== false && item.slot === "chat-history") chatHistoryCount++;
|
|
238
|
+
if (item.slot === "chat-history") validateChatHistoryOptions(item, diagnostics);
|
|
188
239
|
}
|
|
189
240
|
|
|
190
241
|
if (item.kind === "block" && item.role !== "system" && item.enabled !== false && !item.role) {
|
|
@@ -204,15 +255,64 @@ export function validatePromptStack(stack: PromptStack): PromptStackDiagnostic[]
|
|
|
204
255
|
});
|
|
205
256
|
}
|
|
206
257
|
|
|
258
|
+
diagnostics.push(...validateResourcePolicy(stack.tools, "tools"));
|
|
259
|
+
diagnostics.push(...validateResourcePolicy(stack.skills, "skills"));
|
|
260
|
+
if ((stack.mode === "append" || stack.mode === "prepend") && hasPolicyEntries(stack.skills)) {
|
|
261
|
+
diagnostics.push({
|
|
262
|
+
level: "warning",
|
|
263
|
+
message: "skills policy only filters pi-forge skills slots. Use mode \"replace\" if you need the base Pi prompt to omit filtered skills.",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
diagnostics.push(...validateRegexConfig(stack.regex));
|
|
268
|
+
|
|
207
269
|
return diagnostics;
|
|
208
270
|
}
|
|
209
271
|
|
|
272
|
+
function validateChatHistoryOptions(
|
|
273
|
+
item: Extract<PromptStackItem, { kind: "slot" }>,
|
|
274
|
+
diagnostics: PromptStackDiagnostic[],
|
|
275
|
+
): void {
|
|
276
|
+
const options = item.options;
|
|
277
|
+
if (!options) return;
|
|
278
|
+
|
|
279
|
+
for (const key of ["includeLastUserMessage", "stripAssistantThinking", "includeSummaries"]) {
|
|
280
|
+
const value = options[key];
|
|
281
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
282
|
+
diagnostics.push({ level: "warning", message: `chat-history option ${key} should be a boolean.`, itemId: item.id });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (options.roles !== undefined && !isStringArray(options.roles)) {
|
|
287
|
+
diagnostics.push({ level: "error", message: "chat-history option roles must be an array of strings.", itemId: item.id });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (options.toolMode !== undefined && (typeof options.toolMode !== "string" || !VALID_CHAT_HISTORY_TOOL_MODES.has(options.toolMode))) {
|
|
291
|
+
diagnostics.push({ level: "error", message: 'chat-history option toolMode must be "keep" or "drop".', itemId: item.id });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const key of ["maxMessages", "maxChars"]) {
|
|
295
|
+
const value = options[key];
|
|
296
|
+
if (value !== undefined && !isPositiveInteger(value)) {
|
|
297
|
+
diagnostics.push({ level: "error", message: `chat-history option ${key} must be a positive integer.`, itemId: item.id });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
210
302
|
function normalizeId(value: unknown, fallback: string): string {
|
|
211
303
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
212
304
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
213
305
|
return fallback;
|
|
214
306
|
}
|
|
215
307
|
|
|
308
|
+
function isStringArray(value: unknown): value is string[] {
|
|
309
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
313
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
216
316
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
217
317
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
218
318
|
}
|
|
@@ -225,3 +325,70 @@ function normalizeStringRecord(value: unknown): Record<string, string> | undefin
|
|
|
225
325
|
}
|
|
226
326
|
return result;
|
|
227
327
|
}
|
|
328
|
+
|
|
329
|
+
function normalizeResourcePolicy(value: unknown, label: string, diagnostics: PromptStackDiagnostic[]): PromptResourcePolicy | undefined {
|
|
330
|
+
if (value === undefined) return undefined;
|
|
331
|
+
if (!isPlainObject(value)) {
|
|
332
|
+
diagnostics.push({ level: "error", message: `${label} policy must be an object when provided.` });
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
const allow = normalizePolicyPatterns(value.allow, `${label}.allow`, diagnostics);
|
|
336
|
+
const deny = normalizePolicyPatterns(value.deny, `${label}.deny`, diagnostics);
|
|
337
|
+
if (allow && deny) {
|
|
338
|
+
diagnostics.push({ level: "error", message: `${label} policy must use either allow or deny, not both.` });
|
|
339
|
+
return { allow };
|
|
340
|
+
}
|
|
341
|
+
if (allow) return { allow };
|
|
342
|
+
if (deny) return { deny };
|
|
343
|
+
return {};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function normalizePolicyPatterns(value: unknown, label: string, diagnostics: PromptStackDiagnostic[]): string[] | undefined {
|
|
347
|
+
if (value === undefined) return undefined;
|
|
348
|
+
if (!Array.isArray(value)) {
|
|
349
|
+
diagnostics.push({ level: "error", message: `${label} must be an array of strings when provided.` });
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const patterns: string[] = [];
|
|
354
|
+
for (const [index, item] of value.entries()) {
|
|
355
|
+
if (typeof item !== "string" || !item.trim()) {
|
|
356
|
+
diagnostics.push({ level: "error", message: `${label}[${index}] must be a non-empty string.` });
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
patterns.push(item.trim());
|
|
360
|
+
}
|
|
361
|
+
return patterns.length > 0 ? patterns : undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validateResourcePolicy(policy: PromptResourcePolicy | undefined, label: string): PromptStackDiagnostic[] {
|
|
365
|
+
const diagnostics: PromptStackDiagnostic[] = [];
|
|
366
|
+
if ((policy?.allow?.length ?? 0) > 0 && (policy?.deny?.length ?? 0) > 0) {
|
|
367
|
+
diagnostics.push({ level: "error", message: `${label} policy must use either allow or deny, not both.` });
|
|
368
|
+
}
|
|
369
|
+
for (const key of ["allow", "deny"] as const) {
|
|
370
|
+
const values = policy?.[key];
|
|
371
|
+
if (!values) continue;
|
|
372
|
+
const seen = new Set<string>();
|
|
373
|
+
for (const value of values) {
|
|
374
|
+
if (seen.has(value)) {
|
|
375
|
+
diagnostics.push({ level: "warning", message: `Duplicate ${label}.${key} pattern: ${value}.` });
|
|
376
|
+
}
|
|
377
|
+
seen.add(value);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return diagnostics;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function hasPolicyEntries(policy: PromptResourcePolicy | undefined): boolean {
|
|
384
|
+
return !!policy && ((policy.allow?.length ?? 0) > 0 || (policy.deny?.length ?? 0) > 0);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function normalizeRegexConfig(value: unknown, diagnostics: PromptStackDiagnostic[]): PromptStack["regex"] | undefined {
|
|
388
|
+
if (value === undefined) return undefined;
|
|
389
|
+
if (!isPlainObject(value)) {
|
|
390
|
+
diagnostics.push({ level: "warning", message: "regex must be an object when provided." });
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
return value as PromptStack["regex"];
|
|
394
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { WebEditorPayloadCapture } from "./web-editor/index.ts";
|
|
2
|
+
|
|
3
|
+
export function createProviderPayloadCapture(value: unknown, options: { stackId?: string; savePath?: string } = {}): WebEditorPayloadCapture {
|
|
4
|
+
const formatted = formatProviderPayload(value);
|
|
5
|
+
return {
|
|
6
|
+
capturedAt: new Date().toISOString(),
|
|
7
|
+
stackId: options.stackId,
|
|
8
|
+
savePath: options.savePath,
|
|
9
|
+
payload: formatted.payload,
|
|
10
|
+
text: formatted.text,
|
|
11
|
+
chars: formatted.chars,
|
|
12
|
+
approxTokens: formatted.approxTokens,
|
|
13
|
+
truncated: formatted.truncated,
|
|
14
|
+
error: formatted.error,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatProviderPayload(value: unknown): { payload?: unknown; text: string; chars: number; approxTokens: number; truncated: boolean; error?: string } {
|
|
19
|
+
try {
|
|
20
|
+
const payload = redactPayload(value);
|
|
21
|
+
const renderedJson = JSON.stringify(payload, null, 2);
|
|
22
|
+
const text = renderedJson === undefined ? String(payload) : renderedJson;
|
|
23
|
+
const maxChars = 200_000;
|
|
24
|
+
const truncated = text.length > maxChars;
|
|
25
|
+
const rendered = truncated ? `${text.slice(0, maxChars)}\n\n[pi-forge: payload truncated after ${maxChars} chars]` : text;
|
|
26
|
+
return {
|
|
27
|
+
payload: truncated ? undefined : payload,
|
|
28
|
+
text: rendered,
|
|
29
|
+
chars: rendered.length,
|
|
30
|
+
approxTokens: estimatePayloadTokens(rendered),
|
|
31
|
+
truncated,
|
|
32
|
+
};
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const text = `Failed to stringify provider payload: ${error instanceof Error ? error.message : String(error)}`;
|
|
35
|
+
return {
|
|
36
|
+
text,
|
|
37
|
+
chars: text.length,
|
|
38
|
+
approxTokens: estimatePayloadTokens(text),
|
|
39
|
+
truncated: false,
|
|
40
|
+
error: text,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function estimatePayloadTokens(payload: string): number {
|
|
46
|
+
return Math.max(1, Math.ceil(payload.length / 4));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function redactPayload(value: unknown, depth = 0): unknown {
|
|
50
|
+
if (depth > 8) return "[pi-forge: max depth reached]";
|
|
51
|
+
if (typeof value === "string") return redactLongString(value);
|
|
52
|
+
if (value === null || typeof value !== "object") return value;
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
const maxItems = 80;
|
|
55
|
+
const items = value.slice(0, maxItems).map((item) => redactPayload(item, depth + 1));
|
|
56
|
+
if (value.length > maxItems) items.push(`[pi-forge: ${value.length - maxItems} more items omitted]`);
|
|
57
|
+
return items;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result: Record<string, unknown> = {};
|
|
61
|
+
let count = 0;
|
|
62
|
+
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
63
|
+
if (++count > 120) {
|
|
64
|
+
result["[pi-forge: omitted]"] = "object has more than 120 keys";
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
if (isSecretKey(key)) {
|
|
68
|
+
result[key] = "[redacted]";
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
result[key] = redactPayload(raw, depth + 1);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isSecretKey(key: string): boolean {
|
|
77
|
+
return /(api[-_]?key|authorization|bearer|token|secret|password|cookie|credential)/i.test(key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function redactLongString(value: string): string {
|
|
81
|
+
if (/^data:image\//.test(value)) return "[image data omitted]";
|
|
82
|
+
if (value.length > 8_000 && /^[A-Za-z0-9+/=\r\n]+$/.test(value)) return `[base64-like data omitted: ${value.length} chars]`;
|
|
83
|
+
if (value.length > 12_000) return `${value.slice(0, 12_000)}\n[pi-forge: string truncated from ${value.length} chars]`;
|
|
84
|
+
return value;
|
|
85
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PromptResourcePolicy } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function hasResourcePolicy(policy: PromptResourcePolicy | undefined): boolean {
|
|
4
|
+
return !!policy && (hasEffectiveAllowPolicy(policy.allow) || hasPatterns(policy.deny));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function applyResourcePolicy(names: string[], policy: PromptResourcePolicy | undefined): string[] {
|
|
8
|
+
if (!hasResourcePolicy(policy)) return names;
|
|
9
|
+
if (hasEffectiveAllowPolicy(policy?.allow)) {
|
|
10
|
+
return names.filter((name) => matchesAnyPattern(name, policy.allow!));
|
|
11
|
+
}
|
|
12
|
+
if (hasPatterns(policy?.deny)) {
|
|
13
|
+
return names.filter((name) => !matchesAnyPattern(name, policy.deny!));
|
|
14
|
+
}
|
|
15
|
+
return names;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function matchesAnyPattern(name: string, patterns: string[]): boolean {
|
|
19
|
+
return patterns.some((pattern) => resourcePatternMatches(name, pattern));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resourcePatternMatches(name: string, pattern: string): boolean {
|
|
23
|
+
if (pattern === "*") return true;
|
|
24
|
+
if (!pattern.includes("*")) return name === pattern;
|
|
25
|
+
const escaped = pattern
|
|
26
|
+
.split("*")
|
|
27
|
+
.map(escapeRegExp)
|
|
28
|
+
.join(".*");
|
|
29
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasPatterns(value: string[] | undefined): value is string[] {
|
|
33
|
+
return Array.isArray(value) && value.length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasEffectiveAllowPolicy(value: string[] | undefined): value is string[] {
|
|
37
|
+
return hasPatterns(value) && value.some((pattern) => pattern !== "*");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function escapeRegExp(value: string): string {
|
|
41
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
}
|