@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/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 function promptStacksDir(cwd: string): string {
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 dir = promptStacksDir(cwd);
20
- if (!existsSync(dir)) return [];
21
+ const files = promptStackFiles(cwd);
22
+ const loaded = files.map(loadPromptStackFile);
23
+ annotateDuplicateStackIds(loaded);
24
+ return loaded;
25
+ }
21
26
 
22
- let entries: string[];
23
- try {
24
- entries = readdirSync(dir).filter((name) => name.endsWith(".json"));
25
- } catch {
26
- return [];
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 entries.sort().map((name) => loadPromptStackFile(join(dir, name)));
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 = obj.schemaVersion === 1 ? 1 : 1;
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
+ }