@zihanw/pi-forge 0.2.0 → 0.3.1

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 (161) hide show
  1. package/README.md +233 -105
  2. package/README.zh-CN.md +215 -104
  3. package/dist/compiler.d.ts +10 -0
  4. package/dist/compiler.d.ts.map +1 -0
  5. package/dist/compiler.js +490 -0
  6. package/dist/compiler.js.map +1 -0
  7. package/dist/extension-registry.d.ts +25 -0
  8. package/dist/extension-registry.d.ts.map +1 -0
  9. package/dist/extension-registry.js +6 -0
  10. package/dist/extension-registry.js.map +1 -0
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +364 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/lifecycle.d.ts +14 -0
  16. package/dist/lifecycle.d.ts.map +1 -0
  17. package/dist/lifecycle.js +139 -0
  18. package/dist/lifecycle.js.map +1 -0
  19. package/dist/loader.d.ts +8 -0
  20. package/dist/loader.d.ts.map +1 -0
  21. package/dist/loader.js +345 -0
  22. package/dist/loader.js.map +1 -0
  23. package/dist/macro-engine.d.ts +23 -0
  24. package/dist/macro-engine.d.ts.map +1 -0
  25. package/dist/macro-engine.js +262 -0
  26. package/dist/macro-engine.js.map +1 -0
  27. package/dist/payload-capture.d.ts +15 -0
  28. package/dist/payload-capture.d.ts.map +1 -0
  29. package/dist/payload-capture.js +86 -0
  30. package/dist/payload-capture.js.map +1 -0
  31. package/dist/payload-command.d.ts +10 -0
  32. package/dist/payload-command.d.ts.map +1 -0
  33. package/dist/payload-command.js +112 -0
  34. package/dist/payload-command.js.map +1 -0
  35. package/dist/policy.d.ts +6 -0
  36. package/dist/policy.d.ts.map +1 -0
  37. package/dist/policy.js +38 -0
  38. package/dist/policy.js.map +1 -0
  39. package/dist/preset-command.d.ts +14 -0
  40. package/dist/preset-command.d.ts.map +1 -0
  41. package/dist/preset-command.js +223 -0
  42. package/dist/preset-command.js.map +1 -0
  43. package/dist/preview.d.ts +12 -0
  44. package/dist/preview.d.ts.map +1 -0
  45. package/dist/preview.js +179 -0
  46. package/dist/preview.js.map +1 -0
  47. package/dist/regex.d.ts +7 -0
  48. package/dist/regex.d.ts.map +1 -0
  49. package/dist/regex.js +432 -0
  50. package/dist/regex.js.map +1 -0
  51. package/dist/render-helpers.d.ts +44 -0
  52. package/dist/render-helpers.d.ts.map +1 -0
  53. package/dist/render-helpers.js +128 -0
  54. package/dist/render-helpers.js.map +1 -0
  55. package/dist/runtime-state.d.ts +24 -0
  56. package/dist/runtime-state.d.ts.map +1 -0
  57. package/dist/runtime-state.js +13 -0
  58. package/dist/runtime-state.js.map +1 -0
  59. package/dist/sillytavern-importer/items.d.ts +3 -0
  60. package/dist/sillytavern-importer/items.d.ts.map +1 -0
  61. package/dist/sillytavern-importer/items.js +88 -0
  62. package/dist/sillytavern-importer/items.js.map +1 -0
  63. package/dist/sillytavern-importer/macros.d.ts +15 -0
  64. package/dist/sillytavern-importer/macros.d.ts.map +1 -0
  65. package/dist/sillytavern-importer/macros.js +141 -0
  66. package/dist/sillytavern-importer/macros.js.map +1 -0
  67. package/dist/sillytavern-importer/prompt-order.d.ts +6 -0
  68. package/dist/sillytavern-importer/prompt-order.d.ts.map +1 -0
  69. package/dist/sillytavern-importer/prompt-order.js +38 -0
  70. package/dist/sillytavern-importer/prompt-order.js.map +1 -0
  71. package/dist/sillytavern-importer/regex.d.ts +3 -0
  72. package/dist/sillytavern-importer/regex.d.ts.map +1 -0
  73. package/dist/sillytavern-importer/regex.js +275 -0
  74. package/dist/sillytavern-importer/regex.js.map +1 -0
  75. package/dist/sillytavern-importer/report.d.ts +21 -0
  76. package/dist/sillytavern-importer/report.d.ts.map +1 -0
  77. package/dist/sillytavern-importer/report.js +166 -0
  78. package/dist/sillytavern-importer/report.js.map +1 -0
  79. package/dist/sillytavern-importer/types.d.ts +106 -0
  80. package/dist/sillytavern-importer/types.d.ts.map +1 -0
  81. package/dist/sillytavern-importer/types.js +2 -0
  82. package/dist/sillytavern-importer/types.js.map +1 -0
  83. package/dist/sillytavern-importer.d.ts +5 -0
  84. package/dist/sillytavern-importer.d.ts.map +1 -0
  85. package/dist/sillytavern-importer.js +117 -0
  86. package/dist/sillytavern-importer.js.map +1 -0
  87. package/dist/slot-renderers.d.ts +25 -0
  88. package/dist/slot-renderers.d.ts.map +1 -0
  89. package/dist/slot-renderers.js +334 -0
  90. package/dist/slot-renderers.js.map +1 -0
  91. package/dist/stack-migration.d.ts +29 -0
  92. package/dist/stack-migration.d.ts.map +1 -0
  93. package/dist/stack-migration.js +126 -0
  94. package/dist/stack-migration.js.map +1 -0
  95. package/dist/storage.d.ts +6 -0
  96. package/dist/storage.d.ts.map +1 -0
  97. package/dist/storage.js +23 -0
  98. package/dist/storage.js.map +1 -0
  99. package/dist/types.d.ts +173 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +2 -0
  102. package/dist/types.js.map +1 -0
  103. package/dist/web-editor/index.d.ts +3 -0
  104. package/dist/web-editor/index.d.ts.map +1 -0
  105. package/dist/web-editor/index.js +2 -0
  106. package/dist/web-editor/index.js.map +1 -0
  107. package/dist/web-editor/page.d.ts +2 -0
  108. package/dist/web-editor/page.d.ts.map +1 -0
  109. package/dist/web-editor/page.js +3331 -0
  110. package/dist/web-editor/page.js.map +1 -0
  111. package/dist/web-editor/server.d.ts +4 -0
  112. package/dist/web-editor/server.d.ts.map +1 -0
  113. package/dist/web-editor/server.js +263 -0
  114. package/dist/web-editor/server.js.map +1 -0
  115. package/dist/web-editor/types.d.ts +125 -0
  116. package/dist/web-editor/types.d.ts.map +1 -0
  117. package/dist/web-editor/types.js +2 -0
  118. package/dist/web-editor/types.js.map +1 -0
  119. package/dist/web-host.d.ts +29 -0
  120. package/dist/web-host.d.ts.map +1 -0
  121. package/dist/web-host.js +174 -0
  122. package/dist/web-host.js.map +1 -0
  123. package/examples/custom-system-status-extension/README.md +78 -0
  124. package/examples/custom-system-status-extension/index.ts +176 -0
  125. package/examples/custom-system-status-extension/prompt-stack.json +39 -0
  126. package/examples/default-prompt-stack.json +52 -93
  127. package/examples/image-reader-prompt-stack.json +124 -0
  128. package/examples/reviewer-prompt-stack.json +115 -0
  129. package/examples/sillytavern-dm-writer-prompt-stack.json +190 -0
  130. package/package.json +30 -9
  131. package/src/compiler.ts +321 -420
  132. package/src/extension-registry.ts +33 -0
  133. package/src/index.ts +310 -1110
  134. package/src/lifecycle.ts +171 -0
  135. package/src/loader.ts +141 -56
  136. package/src/macro-engine.ts +358 -0
  137. package/src/payload-capture.ts +85 -0
  138. package/src/payload-command.ts +138 -0
  139. package/src/policy.ts +42 -0
  140. package/src/preset-command.ts +271 -0
  141. package/src/preview.ts +226 -0
  142. package/src/regex.ts +500 -0
  143. package/src/render-helpers.ts +169 -0
  144. package/src/runtime-state.ts +36 -0
  145. package/src/sillytavern-importer/items.ts +98 -0
  146. package/src/sillytavern-importer/macros.ts +159 -0
  147. package/src/sillytavern-importer/prompt-order.ts +54 -0
  148. package/src/sillytavern-importer/regex.ts +270 -0
  149. package/src/sillytavern-importer/report.ts +202 -0
  150. package/src/sillytavern-importer/types.ts +120 -0
  151. package/src/sillytavern-importer.ts +57 -482
  152. package/src/slot-renderers.ts +414 -0
  153. package/src/stack-migration.ts +159 -0
  154. package/src/storage.ts +28 -0
  155. package/src/types.ts +83 -52
  156. package/src/web-editor/index.ts +2 -0
  157. package/src/web-editor/page.ts +3330 -0
  158. package/src/web-editor/server.ts +294 -0
  159. package/src/web-editor/types.ts +98 -0
  160. package/src/web-host.ts +232 -0
  161. package/src/web-editor.ts +0 -1491
@@ -0,0 +1,171 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ compileMessages,
4
+ compileSystemPrompt,
5
+ createPromptVariableStore,
6
+ getLatestUserMessage,
7
+ markSessionVariablesClean,
8
+ resetTurnVariables,
9
+ } from "./compiler.ts";
10
+ import { applyFinalizeRegexRulesToMessage } from "./regex.ts";
11
+ import { STATE_ENTRY_TYPE, VARIABLE_ENTRY_TYPE, type PiForgeRuntimeState } from "./runtime-state.ts";
12
+ import type { PromptStackDiagnostic, PromptVariableStore, PromptVariableValue } from "./types.ts";
13
+
14
+ export interface LifecycleDeps {
15
+ reloadStacks(ctx: ExtensionContext, preferredId?: string): void;
16
+ refreshWebEditorHost(ctx: ExtensionContext): void;
17
+ notifyActivePreset(ctx: ExtensionContext, detail: string): void;
18
+ syncActiveToolPolicy(ctx?: ExtensionContext): void;
19
+ activeId(): string | undefined;
20
+ persistActiveSelection(id: string): void;
21
+ recordCompileDiagnostics(ctx: ExtensionContext, diagnostics: PromptStackDiagnostic[]): void;
22
+ }
23
+
24
+ export function registerLifecycleHandlers(pi: ExtensionAPI, state: PiForgeRuntimeState, deps: LifecycleDeps): void {
25
+ pi.on("session_start", async (event, ctx) => {
26
+ restoreBranchScopedRuntime(ctx, state, deps);
27
+ deps.refreshWebEditorHost(ctx);
28
+ deps.notifyActivePreset(ctx, "after session " + event.reason);
29
+ });
30
+
31
+ pi.on("session_tree", async (_event, ctx) => {
32
+ restoreBranchScopedRuntime(ctx, state, deps);
33
+ deps.refreshWebEditorHost(ctx);
34
+ deps.notifyActivePreset(ctx, "after tree navigation");
35
+ });
36
+
37
+ pi.on("session_compact", async (_event, ctx) => {
38
+ restoreBranchScopedRuntime(ctx, state, deps);
39
+ deps.refreshWebEditorHost(ctx);
40
+ deps.notifyActivePreset(ctx, "after compaction");
41
+ });
42
+
43
+ pi.on("turn_start", async () => {
44
+ deps.syncActiveToolPolicy();
45
+ const id = deps.activeId();
46
+ if (id) deps.persistActiveSelection(id);
47
+ });
48
+
49
+ pi.on("before_agent_start", async (event, ctx) => {
50
+ state.currentSystemPromptOptions = event.systemPromptOptions;
51
+ state.currentLatestUserMessage = event.prompt;
52
+ state.currentVariableStore = createPromptVariableStore(state.sessionVariables);
53
+ resetTurnVariables(state.currentVariableStore);
54
+ state.contextRewritePending = true;
55
+
56
+ if (!state.active) return;
57
+
58
+ const result = compileSystemPrompt(
59
+ state.active.stack,
60
+ { options: event.systemPromptOptions, ctx, latestUserMessage: event.prompt, now: new Date(), variables: state.currentVariableStore },
61
+ event.systemPrompt,
62
+ );
63
+ deps.recordCompileDiagnostics(ctx, result.diagnostics);
64
+ persistVariablesIfDirty(pi, state, state.currentVariableStore);
65
+
66
+ return { systemPrompt: result.systemPrompt };
67
+ });
68
+
69
+ pi.on("context", async (event, ctx) => {
70
+ if (!state.active || !state.currentSystemPromptOptions || !state.contextRewritePending) return;
71
+
72
+ // Rewrite the message layout only for the first provider request of a user-submitted prompt.
73
+ // Tool-result follow-up turns must receive Pi's natural context; otherwise post-history
74
+ // prompt blocks such as COT / {{lastUserMessage}} are re-appended after every tool call
75
+ // and the model restarts its planning instead of continuing from the tool result.
76
+ state.contextRewritePending = false;
77
+
78
+ if (!state.currentVariableStore) state.currentVariableStore = createPromptVariableStore(state.sessionVariables);
79
+ const latestUserMessage = getLatestUserMessage(event.messages) ?? state.currentLatestUserMessage;
80
+ const result = compileMessages(
81
+ state.active.stack,
82
+ { options: state.currentSystemPromptOptions, ctx, latestUserMessage, now: new Date(), variables: state.currentVariableStore },
83
+ event.messages,
84
+ );
85
+ deps.recordCompileDiagnostics(ctx, [...state.latestCompileDiagnostics, ...result.diagnostics]);
86
+ persistVariablesIfDirty(pi, state, state.currentVariableStore);
87
+ return { messages: result.messages };
88
+ });
89
+
90
+ pi.on("message_end", async (event, ctx) => {
91
+ if (!state.active) return;
92
+ const diagnostics: PromptStackDiagnostic[] = [];
93
+ const message = applyFinalizeRegexRulesToMessage(state.active.stack, event.message, diagnostics);
94
+ if (diagnostics.length > 0) deps.recordCompileDiagnostics(ctx, [...state.latestCompileDiagnostics, ...diagnostics]);
95
+ if (!message) return;
96
+ return { message };
97
+ });
98
+
99
+ pi.on("agent_end", async () => {
100
+ persistVariablesIfDirty(pi, state, state.currentVariableStore);
101
+ state.currentSystemPromptOptions = undefined;
102
+ state.currentLatestUserMessage = undefined;
103
+ state.currentVariableStore = undefined;
104
+ state.contextRewritePending = false;
105
+ });
106
+ }
107
+
108
+ function restoreBranchScopedRuntime(ctx: ExtensionContext, state: PiForgeRuntimeState, deps: LifecycleDeps): void {
109
+ state.sessionVariables = getRestoredVariables(ctx);
110
+ state.currentVariableStore = undefined;
111
+ const restoredActiveId = getRestoredActiveId(ctx);
112
+ state.lastPersistedActiveId = restoredActiveId;
113
+ deps.reloadStacks(ctx, restoredActiveId);
114
+ }
115
+
116
+ function getCurrentBranchEntries(ctx: ExtensionContext): unknown[] {
117
+ const leafId = ctx.sessionManager.getLeafId();
118
+ if (leafId === null) return [];
119
+ const sessionManager = ctx.sessionManager as {
120
+ getBranch?: (fromId?: string) => unknown[];
121
+ getEntries: () => unknown[];
122
+ };
123
+ return sessionManager.getBranch ? sessionManager.getBranch(leafId ?? undefined) : sessionManager.getEntries();
124
+ }
125
+
126
+ function getRestoredActiveId(ctx: ExtensionContext): string | undefined {
127
+ const entries = getCurrentBranchEntries(ctx);
128
+ for (let i = entries.length - 1; i >= 0; i--) {
129
+ const entry = entries[i] as { type?: string; customType?: string; data?: { activeStackId?: unknown } };
130
+ if (entry.type === "custom" && entry.customType === STATE_ENTRY_TYPE) {
131
+ return typeof entry.data?.activeStackId === "string" ? entry.data.activeStackId : undefined;
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ function getRestoredVariables(ctx: ExtensionContext): Record<string, PromptVariableValue> {
138
+ const entries = getCurrentBranchEntries(ctx);
139
+ for (let i = entries.length - 1; i >= 0; i--) {
140
+ const entry = entries[i] as { type?: string; customType?: string; data?: { variables?: unknown } };
141
+ if (entry.type !== "custom" || entry.customType !== VARIABLE_ENTRY_TYPE) continue;
142
+ if (!entry.data || typeof entry.data.variables !== "object" || Array.isArray(entry.data.variables)) return {};
143
+ return normalizeVariableRecord(entry.data.variables as Record<string, unknown>);
144
+ }
145
+ return {};
146
+ }
147
+
148
+ function persistVariablesIfDirty(pi: ExtensionAPI, state: PiForgeRuntimeState, store: PromptVariableStore | undefined): void {
149
+ if (!store?.sessionDirty) return;
150
+ state.sessionVariables = { ...store.session };
151
+ pi.appendEntry(VARIABLE_ENTRY_TYPE, { variables: state.sessionVariables });
152
+ markSessionVariablesClean(store);
153
+ }
154
+
155
+ function normalizeVariableRecord(value: Record<string, unknown>): Record<string, PromptVariableValue> {
156
+ const result: Record<string, PromptVariableValue> = {};
157
+ for (const [key, raw] of Object.entries(value)) {
158
+ if (isPromptVariableValue(raw)) result[key] = raw;
159
+ }
160
+ return result;
161
+ }
162
+
163
+ function isPromptVariableValue(value: unknown): value is PromptVariableValue {
164
+ if (value === null) return true;
165
+ const type = typeof value;
166
+ if (type === "string" || type === "boolean") return true;
167
+ if (type === "number") return Number.isFinite(value);
168
+ if (Array.isArray(value)) return value.every(isPromptVariableValue);
169
+ if (!value || typeof value !== "object") return false;
170
+ return Object.values(value as Record<string, unknown>).every(isPromptVariableValue);
171
+ }
package/src/loader.ts CHANGED
@@ -1,38 +1,51 @@
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,
8
11
  PromptStackRole,
9
- PromptStateConfig,
10
- PromptStateDefinition,
11
- PromptStateScope,
12
- PromptStateValue,
13
12
  } from "./types.ts";
14
13
  import { SUPPORTED_SLOTS } from "./types.ts";
15
14
 
16
15
  const VALID_ROLES = new Set<PromptStackRole>(["system", "user", "assistant", "custom"]);
16
+ const VALID_CHAT_HISTORY_TOOL_MODES = new Set(["keep", "drop"]);
17
17
 
18
- export function promptStacksDir(cwd: string): string {
19
- return join(cwd, ".pi", "prompt-stacks");
20
- }
18
+ export { isInsidePromptStackStorage, legacyPromptStacksDir, promptStackPath, promptStackReadDirs, promptStacksDir } from "./storage.ts";
21
19
 
22
20
  export function loadPromptStacks(cwd: string): LoadedPromptStack[] {
23
- const dir = promptStacksDir(cwd);
24
- if (!existsSync(dir)) return [];
21
+ const files = promptStackFiles(cwd);
22
+ const loaded = files.map(loadPromptStackFile);
23
+ annotateDuplicateStackIds(loaded);
24
+ return loaded;
25
+ }
25
26
 
26
- let entries: string[];
27
- try {
28
- entries = readdirSync(dir).filter((name) => name.endsWith(".json"));
29
- } catch {
30
- 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
+ }
31
46
  }
32
47
 
33
- const loaded = entries.sort().map((name) => loadPromptStackFile(join(dir, name)));
34
- annotateDuplicateStackIds(loaded);
35
- return loaded;
48
+ return files;
36
49
  }
37
50
 
38
51
  function annotateDuplicateStackIds(stacks: LoadedPromptStack[]): void {
@@ -129,7 +142,7 @@ function normalizeStack(raw: unknown, filePath: string, diagnostics: PromptStack
129
142
 
130
143
  const obj = raw as Record<string, unknown>;
131
144
  const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : basename(filePath, ".json");
132
- const schemaVersion = obj.schemaVersion === 1 ? 1 : 1;
145
+ const schemaVersion = 1;
133
146
  if (obj.schemaVersion !== 1) {
134
147
  diagnostics.push({ level: "warning", message: "Missing or unsupported schemaVersion; assuming 1." });
135
148
  }
@@ -139,6 +152,13 @@ function normalizeStack(raw: unknown, filePath: string, diagnostics: PromptStack
139
152
  diagnostics.push({ level: "error", message: "Prompt stack must contain an items array." });
140
153
  }
141
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
+
142
162
  return {
143
163
  schemaVersion,
144
164
  type: obj.type === "pi-forge.prompt-stack" ? "pi-forge.prompt-stack" : undefined,
@@ -149,8 +169,10 @@ function normalizeStack(raw: unknown, filePath: string, diagnostics: PromptStack
149
169
  mode: obj.mode === "append" || obj.mode === "prepend" || obj.mode === "replace" ? obj.mode : undefined,
150
170
  defaults: isPlainObject(obj.defaults) ? (obj.defaults as PromptStack["defaults"]) : undefined,
151
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),
152
174
  variables: normalizeStringRecord(obj.variables),
153
- state: normalizeStateConfig(obj.state, diagnostics),
175
+ regex: normalizeRegexConfig(obj.regex, diagnostics),
154
176
  items,
155
177
  import: isPlainObject(obj.import) ? (obj.import as Record<string, unknown>) : undefined,
156
178
  };
@@ -213,6 +235,7 @@ export function validatePromptStack(stack: PromptStack): PromptStackDiagnostic[]
213
235
  diagnostics.push({ level: "warning", message: `Unsupported slot: ${item.slot}`, itemId: item.id });
214
236
  }
215
237
  if (item.enabled !== false && item.slot === "chat-history") chatHistoryCount++;
238
+ if (item.slot === "chat-history") validateChatHistoryOptions(item, diagnostics);
216
239
  }
217
240
 
218
241
  if (item.kind === "block" && item.role !== "system" && item.enabled !== false && !item.role) {
@@ -232,15 +255,64 @@ export function validatePromptStack(stack: PromptStack): PromptStackDiagnostic[]
232
255
  });
233
256
  }
234
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
+
235
269
  return diagnostics;
236
270
  }
237
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
+
238
302
  function normalizeId(value: unknown, fallback: string): string {
239
303
  if (typeof value === "string" && value.trim()) return value.trim();
240
304
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
241
305
  return fallback;
242
306
  }
243
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
+
244
316
  function isPlainObject(value: unknown): value is Record<string, unknown> {
245
317
  return !!value && typeof value === "object" && !Array.isArray(value);
246
318
  }
@@ -254,56 +326,69 @@ function normalizeStringRecord(value: unknown): Record<string, string> | undefin
254
326
  return result;
255
327
  }
256
328
 
257
- function normalizeStateConfig(value: unknown, diagnostics: PromptStackDiagnostic[]): PromptStateConfig | undefined {
329
+ function normalizeResourcePolicy(value: unknown, label: string, diagnostics: PromptStackDiagnostic[]): PromptResourcePolicy | undefined {
258
330
  if (value === undefined) return undefined;
259
331
  if (!isPlainObject(value)) {
260
- diagnostics.push({ level: "warning", message: "state must be an object when provided." });
332
+ diagnostics.push({ level: "error", message: `${label} policy must be an object when provided.` });
261
333
  return undefined;
262
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
+ }
263
345
 
264
- const definitionsRaw = value.definitions;
265
- const definitions: Record<string, PromptStateDefinition> = {};
266
-
267
- if (definitionsRaw !== undefined && !isPlainObject(definitionsRaw)) {
268
- diagnostics.push({ level: "warning", message: "state.definitions must be an object when provided." });
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;
269
351
  }
270
352
 
271
- if (isPlainObject(definitionsRaw)) {
272
- for (const [name, rawDefinition] of Object.entries(definitionsRaw)) {
273
- if (!isPlainObject(rawDefinition)) {
274
- diagnostics.push({ level: "warning", message: `state definition for ${name} must be an object.` });
275
- continue;
276
- }
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
+ }
277
363
 
278
- const definition: PromptStateDefinition = {};
279
- if (typeof rawDefinition.type === "string") definition.type = rawDefinition.type;
280
- if (isStateScope(rawDefinition.scope)) definition.scope = rawDefinition.scope;
281
- if (typeof rawDefinition.description === "string") definition.description = rawDefinition.description;
282
- if (typeof rawDefinition.agentWritable === "boolean") definition.agentWritable = rawDefinition.agentWritable;
283
- if (typeof rawDefinition.userWritable === "boolean") definition.userWritable = rawDefinition.userWritable;
284
- if (isPromptStateValue(rawDefinition.default)) definition.default = rawDefinition.default;
285
- else if (rawDefinition.default !== undefined) {
286
- diagnostics.push({ level: "warning", message: `state definition default for ${name} is not a JSON-compatible value.` });
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}.` });
287
376
  }
288
- definitions[name] = definition;
377
+ seen.add(value);
289
378
  }
290
379
  }
291
-
292
- return {
293
- schemaVersion: value.schemaVersion === 1 ? 1 : undefined,
294
- definitions: Object.keys(definitions).length > 0 ? definitions : undefined,
295
- };
380
+ return diagnostics;
296
381
  }
297
382
 
298
- function isStateScope(value: unknown): value is PromptStateScope {
299
- return value === "static" || value === "session" || value === "turn";
383
+ function hasPolicyEntries(policy: PromptResourcePolicy | undefined): boolean {
384
+ return !!policy && ((policy.allow?.length ?? 0) > 0 || (policy.deny?.length ?? 0) > 0);
300
385
  }
301
386
 
302
- function isPromptStateValue(value: unknown): value is PromptStateValue {
303
- if (value === null) return true;
304
- const type = typeof value;
305
- if (type === "string" || type === "number" || type === "boolean") return Number.isFinite(value as number) || type !== "number";
306
- if (Array.isArray(value)) return value.every(isPromptStateValue);
307
- if (!isPlainObject(value)) return false;
308
- return Object.values(value).every(isPromptStateValue);
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"];
309
394
  }