@zihanw/pi-forge 0.3.2 → 0.4.0-beta.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 (244) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/PUBLIC_API.md +28 -0
  3. package/README.md +137 -11
  4. package/README.zh-CN.md +137 -11
  5. package/SUBAGENT_ADAPTER_CONTRACT.md +199 -0
  6. package/dist/agent-profile.d.ts +71 -0
  7. package/dist/agent-profile.d.ts.map +1 -0
  8. package/dist/agent-profile.js +303 -0
  9. package/dist/agent-profile.js.map +1 -0
  10. package/dist/forge-config.d.ts +8 -0
  11. package/dist/forge-config.d.ts.map +1 -0
  12. package/dist/forge-config.js +40 -0
  13. package/dist/forge-config.js.map +1 -0
  14. package/dist/forge-extensions.d.ts.map +1 -1
  15. package/dist/forge-extensions.js +19 -3
  16. package/dist/forge-extensions.js.map +1 -1
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +75 -361
  20. package/dist/index.js.map +1 -1
  21. package/dist/lifecycle.d.ts +11 -3
  22. package/dist/lifecycle.d.ts.map +1 -1
  23. package/dist/lifecycle.js +90 -4
  24. package/dist/lifecycle.js.map +1 -1
  25. package/dist/loader.d.ts.map +1 -1
  26. package/dist/loader.js +116 -5
  27. package/dist/loader.js.map +1 -1
  28. package/dist/payload-capture.d.ts.map +1 -1
  29. package/dist/payload-capture.js +27 -0
  30. package/dist/payload-capture.js.map +1 -1
  31. package/dist/payload-command.d.ts +3 -3
  32. package/dist/payload-command.d.ts.map +1 -1
  33. package/dist/payload-command.js.map +1 -1
  34. package/dist/preview.d.ts +2 -2
  35. package/dist/preview.d.ts.map +1 -1
  36. package/dist/preview.js +2 -3
  37. package/dist/preview.js.map +1 -1
  38. package/dist/profile-command.d.ts +12 -0
  39. package/dist/profile-command.d.ts.map +1 -0
  40. package/dist/profile-command.js +291 -0
  41. package/dist/profile-command.js.map +1 -0
  42. package/dist/profile-service.d.ts +103 -0
  43. package/dist/profile-service.d.ts.map +1 -0
  44. package/dist/profile-service.js +215 -0
  45. package/dist/profile-service.js.map +1 -0
  46. package/dist/runtime/profile-runtime.d.ts +13 -0
  47. package/dist/runtime/profile-runtime.d.ts.map +1 -0
  48. package/dist/runtime/profile-runtime.js +47 -0
  49. package/dist/runtime/profile-runtime.js.map +1 -0
  50. package/dist/runtime/prompt-stack-runtime.d.ts +22 -0
  51. package/dist/runtime/prompt-stack-runtime.d.ts.map +1 -0
  52. package/dist/runtime/prompt-stack-runtime.js +104 -0
  53. package/dist/runtime/prompt-stack-runtime.js.map +1 -0
  54. package/dist/runtime/subagent-runtime.d.ts +30 -0
  55. package/dist/runtime/subagent-runtime.d.ts.map +1 -0
  56. package/dist/runtime/subagent-runtime.js +114 -0
  57. package/dist/runtime/subagent-runtime.js.map +1 -0
  58. package/dist/runtime/tool-policy-runtime.d.ts +15 -0
  59. package/dist/runtime/tool-policy-runtime.d.ts.map +1 -0
  60. package/dist/runtime/tool-policy-runtime.js +170 -0
  61. package/dist/runtime/tool-policy-runtime.js.map +1 -0
  62. package/dist/runtime/web-editor-runtime.d.ts +9 -0
  63. package/dist/runtime/web-editor-runtime.d.ts.map +1 -0
  64. package/dist/runtime/web-editor-runtime.js +131 -0
  65. package/dist/runtime/web-editor-runtime.js.map +1 -0
  66. package/dist/runtime-state.d.ts +4 -0
  67. package/dist/runtime-state.d.ts.map +1 -1
  68. package/dist/runtime-state.js +2 -0
  69. package/dist/runtime-state.js.map +1 -1
  70. package/dist/storage.d.ts +3 -0
  71. package/dist/storage.d.ts.map +1 -1
  72. package/dist/storage.js +9 -0
  73. package/dist/storage.js.map +1 -1
  74. package/dist/subagent/backend-registry.d.ts +75 -0
  75. package/dist/subagent/backend-registry.d.ts.map +1 -0
  76. package/dist/subagent/backend-registry.js +463 -0
  77. package/dist/subagent/backend-registry.js.map +1 -0
  78. package/dist/subagent/canonical.d.ts +10 -0
  79. package/dist/subagent/canonical.d.ts.map +1 -0
  80. package/dist/subagent/canonical.js +52 -0
  81. package/dist/subagent/canonical.js.map +1 -0
  82. package/dist/subagent/context.d.ts +8 -0
  83. package/dist/subagent/context.d.ts.map +1 -0
  84. package/dist/subagent/context.js +125 -0
  85. package/dist/subagent/context.js.map +1 -0
  86. package/dist/subagent/contract.d.ts +11 -0
  87. package/dist/subagent/contract.d.ts.map +1 -0
  88. package/dist/subagent/contract.js +11 -0
  89. package/dist/subagent/contract.js.map +1 -0
  90. package/dist/subagent/diagnostics.d.ts +3 -0
  91. package/dist/subagent/diagnostics.d.ts.map +1 -0
  92. package/dist/subagent/diagnostics.js +5 -0
  93. package/dist/subagent/diagnostics.js.map +1 -0
  94. package/dist/subagent/index.d.ts +13 -0
  95. package/dist/subagent/index.d.ts.map +1 -0
  96. package/dist/subagent/index.js +13 -0
  97. package/dist/subagent/index.js.map +1 -0
  98. package/dist/subagent/pi-model-runtime.d.ts +8 -0
  99. package/dist/subagent/pi-model-runtime.d.ts.map +1 -0
  100. package/dist/subagent/pi-model-runtime.js +22 -0
  101. package/dist/subagent/pi-model-runtime.js.map +1 -0
  102. package/dist/subagent/pi-sdk-backend.d.ts +23 -0
  103. package/dist/subagent/pi-sdk-backend.d.ts.map +1 -0
  104. package/dist/subagent/pi-sdk-backend.js +383 -0
  105. package/dist/subagent/pi-sdk-backend.js.map +1 -0
  106. package/dist/subagent/pi-subprocess-backend.d.ts +72 -0
  107. package/dist/subagent/pi-subprocess-backend.d.ts.map +1 -0
  108. package/dist/subagent/pi-subprocess-backend.js +756 -0
  109. package/dist/subagent/pi-subprocess-backend.js.map +1 -0
  110. package/dist/subagent/plan.d.ts +14 -0
  111. package/dist/subagent/plan.d.ts.map +1 -0
  112. package/dist/subagent/plan.js +160 -0
  113. package/dist/subagent/plan.js.map +1 -0
  114. package/dist/subagent/preflight.d.ts +4 -0
  115. package/dist/subagent/preflight.d.ts.map +1 -0
  116. package/dist/subagent/preflight.js +108 -0
  117. package/dist/subagent/preflight.js.map +1 -0
  118. package/dist/subagent/request.d.ts +4 -0
  119. package/dist/subagent/request.d.ts.map +1 -0
  120. package/dist/subagent/request.js +122 -0
  121. package/dist/subagent/request.js.map +1 -0
  122. package/dist/subagent/response.d.ts +8 -0
  123. package/dist/subagent/response.d.ts.map +1 -0
  124. package/dist/subagent/response.js +155 -0
  125. package/dist/subagent/response.js.map +1 -0
  126. package/dist/subagent/subprocess-bridge.d.ts +21 -0
  127. package/dist/subagent/subprocess-bridge.d.ts.map +1 -0
  128. package/dist/subagent/subprocess-bridge.js +87 -0
  129. package/dist/subagent/subprocess-bridge.js.map +1 -0
  130. package/dist/subagent/subprocess-report.d.ts +4 -0
  131. package/dist/subagent/subprocess-report.d.ts.map +1 -0
  132. package/dist/subagent/subprocess-report.js +55 -0
  133. package/dist/subagent/subprocess-report.js.map +1 -0
  134. package/dist/subagent/tools.d.ts +4 -0
  135. package/dist/subagent/tools.d.ts.map +1 -0
  136. package/dist/subagent/tools.js +42 -0
  137. package/dist/subagent/tools.js.map +1 -0
  138. package/dist/subagent/types.d.ts +384 -0
  139. package/dist/subagent/types.d.ts.map +1 -0
  140. package/dist/subagent/types.js +3 -0
  141. package/dist/subagent/types.js.map +1 -0
  142. package/dist/subagent/validation.d.ts +35 -0
  143. package/dist/subagent/validation.d.ts.map +1 -0
  144. package/dist/subagent/validation.js +500 -0
  145. package/dist/subagent/validation.js.map +1 -0
  146. package/dist/subagent-command.d.ts +4 -0
  147. package/dist/subagent-command.d.ts.map +1 -0
  148. package/dist/subagent-command.js +153 -0
  149. package/dist/subagent-command.js.map +1 -0
  150. package/dist/subagent-contract.d.ts +8 -0
  151. package/dist/subagent-contract.d.ts.map +1 -0
  152. package/dist/subagent-contract.js +8 -0
  153. package/dist/subagent-contract.js.map +1 -0
  154. package/dist/subagent-host.d.ts +44 -0
  155. package/dist/subagent-host.d.ts.map +1 -0
  156. package/dist/subagent-host.js +291 -0
  157. package/dist/subagent-host.js.map +1 -0
  158. package/dist/subagent-profile-tool.d.ts +26 -0
  159. package/dist/subagent-profile-tool.d.ts.map +1 -0
  160. package/dist/subagent-profile-tool.js +93 -0
  161. package/dist/subagent-profile-tool.js.map +1 -0
  162. package/dist/subagent-tool.d.ts +50 -0
  163. package/dist/subagent-tool.d.ts.map +1 -0
  164. package/dist/subagent-tool.js +385 -0
  165. package/dist/subagent-tool.js.map +1 -0
  166. package/dist/web-editor/client/api.d.ts +9 -0
  167. package/dist/web-editor/client/api.d.ts.map +1 -0
  168. package/dist/web-editor/client/api.js +26 -0
  169. package/dist/web-editor/client/api.js.map +1 -0
  170. package/dist/web-editor/client/dom.d.ts +13 -0
  171. package/dist/web-editor/client/dom.d.ts.map +1 -0
  172. package/dist/web-editor/client/dom.js +30 -0
  173. package/dist/web-editor/client/dom.js.map +1 -0
  174. package/dist/web-editor/client/inspector.d.ts +22 -0
  175. package/dist/web-editor/client/inspector.d.ts.map +1 -0
  176. package/dist/web-editor/client/inspector.js +226 -0
  177. package/dist/web-editor/client/inspector.js.map +1 -0
  178. package/dist/web-editor/client/main.d.ts +2 -0
  179. package/dist/web-editor/client/main.d.ts.map +1 -0
  180. package/dist/web-editor/client/main.js +1468 -0
  181. package/dist/web-editor/client/main.js.map +1 -0
  182. package/dist/web-editor/client/policy-editor.d.ts +16 -0
  183. package/dist/web-editor/client/policy-editor.d.ts.map +1 -0
  184. package/dist/web-editor/client/policy-editor.js +330 -0
  185. package/dist/web-editor/client/policy-editor.js.map +1 -0
  186. package/dist/web-editor/client/regex-editor.d.ts +19 -0
  187. package/dist/web-editor/client/regex-editor.d.ts.map +1 -0
  188. package/dist/web-editor/client/regex-editor.js +281 -0
  189. package/dist/web-editor/client/regex-editor.js.map +1 -0
  190. package/dist/web-editor/client/types.d.ts +60 -0
  191. package/dist/web-editor/client/types.d.ts.map +1 -0
  192. package/dist/web-editor/client/types.js +2 -0
  193. package/dist/web-editor/client/types.js.map +1 -0
  194. package/dist/web-editor/client-script.d.ts +2 -0
  195. package/dist/web-editor/client-script.d.ts.map +1 -0
  196. package/dist/web-editor/client-script.generated.d.ts +2 -0
  197. package/dist/web-editor/client-script.generated.d.ts.map +1 -0
  198. package/dist/web-editor/client-script.generated.js +3 -0
  199. package/dist/web-editor/client-script.generated.js.map +1 -0
  200. package/dist/web-editor/client-script.js +2 -0
  201. package/dist/web-editor/client-script.js.map +1 -0
  202. package/dist/web-editor/page.d.ts.map +1 -1
  203. package/dist/web-editor/page.js +6 -3249
  204. package/dist/web-editor/page.js.map +1 -1
  205. package/dist/web-editor/styles.d.ts +2 -0
  206. package/dist/web-editor/styles.d.ts.map +1 -0
  207. package/dist/web-editor/styles.js +996 -0
  208. package/dist/web-editor/styles.js.map +1 -0
  209. package/dist/web-host.d.ts +3 -3
  210. package/dist/web-host.d.ts.map +1 -1
  211. package/dist/web-host.js +6 -0
  212. package/dist/web-host.js.map +1 -1
  213. package/package.json +42 -14
  214. package/src/compiler.ts +0 -578
  215. package/src/extension-registry.ts +0 -33
  216. package/src/forge-extensions.ts +0 -223
  217. package/src/index.ts +0 -445
  218. package/src/lifecycle.ts +0 -171
  219. package/src/loader.ts +0 -394
  220. package/src/macro-engine.ts +0 -358
  221. package/src/payload-capture.ts +0 -85
  222. package/src/payload-command.ts +0 -138
  223. package/src/policy.ts +0 -42
  224. package/src/preset-command.ts +0 -280
  225. package/src/preview.ts +0 -226
  226. package/src/regex.ts +0 -500
  227. package/src/render-helpers.ts +0 -169
  228. package/src/runtime-state.ts +0 -40
  229. package/src/sillytavern-importer/items.ts +0 -98
  230. package/src/sillytavern-importer/macros.ts +0 -159
  231. package/src/sillytavern-importer/prompt-order.ts +0 -54
  232. package/src/sillytavern-importer/regex.ts +0 -270
  233. package/src/sillytavern-importer/report.ts +0 -202
  234. package/src/sillytavern-importer/types.ts +0 -120
  235. package/src/sillytavern-importer.ts +0 -152
  236. package/src/slot-renderers.ts +0 -414
  237. package/src/stack-migration.ts +0 -159
  238. package/src/storage.ts +0 -45
  239. package/src/types.ts +0 -209
  240. package/src/web-editor/index.ts +0 -2
  241. package/src/web-editor/page.ts +0 -3330
  242. package/src/web-editor/server.ts +0 -294
  243. package/src/web-editor/types.ts +0 -98
  244. package/src/web-host.ts +0 -232
@@ -1,3330 +0,0 @@
1
- export function renderEditorHtml(): string {
2
- return String.raw`<!doctype html>
3
- <html lang="en">
4
- <head>
5
- <meta charset="utf-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1">
7
- <title>pi-forge stack editor</title>
8
- <style>
9
- :root {
10
- color-scheme: light;
11
- --bg: #f7f8fb;
12
- --pane: #ffffff;
13
- --line: #d8dee8;
14
- --line-strong: #aeb8c7;
15
- --text: #18202c;
16
- --muted: #647083;
17
- --accent: #146b5f;
18
- --accent-bg: #e5f3ef;
19
- --warning: #9b6200;
20
- --warning-bg: #fff4d8;
21
- --error: #b42318;
22
- --error-bg: #fde8e7;
23
- --success: #1f7a3a;
24
- --control: #ffffff;
25
- --control-muted: #f3f5f8;
26
- --pane-soft: #fbfcfe;
27
- --row: #ffffff;
28
- --code-bg: #111827;
29
- --code-text: #e5e7eb;
30
- --shadow: rgba(15, 23, 42, .24);
31
- }
32
- body[data-theme="dark"] {
33
- color-scheme: dark;
34
- --bg: #111315;
35
- --pane: #181a1d;
36
- --line: #32363b;
37
- --line-strong: #525a63;
38
- --text: #edf0f2;
39
- --muted: #a0a8b2;
40
- --accent: #2aa889;
41
- --accent-bg: #15362f;
42
- --warning: #e4b75f;
43
- --warning-bg: #3a2d13;
44
- --error: #f06f64;
45
- --error-bg: #3c1d1a;
46
- --success: #69c98c;
47
- --control: #202327;
48
- --control-muted: #25292e;
49
- --pane-soft: #151719;
50
- --row: #1c1f23;
51
- --code-bg: #0b0d10;
52
- --code-text: #e8edf2;
53
- --shadow: rgba(0, 0, 0, .42);
54
- }
55
- * { box-sizing: border-box; }
56
- body {
57
- margin: 0;
58
- background: var(--bg);
59
- color: var(--text);
60
- font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
61
- letter-spacing: 0;
62
- }
63
- button, input, select, textarea {
64
- font: inherit;
65
- letter-spacing: 0;
66
- }
67
- button {
68
- border: 1px solid var(--line-strong);
69
- background: var(--control);
70
- color: var(--text);
71
- min-height: 32px;
72
- padding: 5px 10px;
73
- border-radius: 6px;
74
- cursor: pointer;
75
- }
76
- button[data-icon]::before {
77
- content: attr(data-icon);
78
- display: inline-block;
79
- min-width: 1em;
80
- margin-right: 6px;
81
- text-align: center;
82
- color: currentColor;
83
- }
84
- button.icon[data-icon]::before {
85
- margin-right: 0;
86
- }
87
- button.primary {
88
- border-color: var(--accent);
89
- background: var(--accent);
90
- color: white;
91
- }
92
- button.danger {
93
- border-color: var(--error);
94
- color: var(--error);
95
- }
96
- button.icon {
97
- width: 34px;
98
- padding: 5px 0;
99
- }
100
- button:disabled {
101
- cursor: default;
102
- opacity: .55;
103
- }
104
- input, select, textarea {
105
- border: 1px solid var(--line-strong);
106
- border-radius: 6px;
107
- background: var(--control);
108
- color: var(--text);
109
- padding: 6px 8px;
110
- width: 100%;
111
- }
112
- textarea {
113
- min-height: 140px;
114
- resize: vertical;
115
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
116
- font-size: 13px;
117
- }
118
- html, body {
119
- height: 100%;
120
- overflow: hidden;
121
- }
122
- .topbar {
123
- display: flex;
124
- align-items: center;
125
- flex-wrap: wrap;
126
- gap: 10px;
127
- min-height: 48px;
128
- padding: 8px 12px;
129
- border-bottom: 1px solid var(--line);
130
- background: var(--pane);
131
- }
132
- .brand {
133
- font-weight: 700;
134
- margin-right: 8px;
135
- white-space: nowrap;
136
- }
137
- .status {
138
- color: var(--muted);
139
- flex: 1;
140
- min-width: 0;
141
- overflow: hidden;
142
- text-overflow: ellipsis;
143
- white-space: nowrap;
144
- }
145
- .dirty-badge {
146
- display: none;
147
- flex: 0 0 auto;
148
- border: 1px solid var(--warning);
149
- color: var(--warning);
150
- background: var(--warning-bg);
151
- border-radius: 999px;
152
- padding: 2px 8px;
153
- font-size: 12px;
154
- }
155
- .dirty-badge.visible {
156
- display: inline-block;
157
- }
158
- .shell {
159
- display: grid;
160
- grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
161
- height: calc(100vh - 48px);
162
- min-height: 0;
163
- transition: grid-template-columns .16s ease;
164
- }
165
- .shell.sidebar-collapsed {
166
- grid-template-columns: 0 minmax(0, 1fr);
167
- }
168
- .sidebar {
169
- border-right: 1px solid var(--line);
170
- background: var(--pane);
171
- min-width: 0;
172
- overflow: hidden;
173
- }
174
- .shell.sidebar-collapsed .sidebar {
175
- border-right: 0;
176
- }
177
- .side-head {
178
- padding: 12px;
179
- border-bottom: 1px solid var(--line);
180
- }
181
- .side-title {
182
- font-weight: 650;
183
- }
184
- .cwd {
185
- margin-top: 4px;
186
- color: var(--muted);
187
- font-size: 12px;
188
- overflow-wrap: anywhere;
189
- }
190
- .stack-list {
191
- padding: 8px;
192
- overflow: auto;
193
- height: calc(100% - 74px);
194
- }
195
- .side-empty {
196
- color: var(--muted);
197
- font-size: 12px;
198
- padding: 8px;
199
- }
200
- .stack-row {
201
- display: block;
202
- width: 100%;
203
- text-align: left;
204
- border: 1px solid transparent;
205
- background: transparent;
206
- border-radius: 6px;
207
- padding: 8px;
208
- margin-bottom: 4px;
209
- }
210
- .stack-row.active {
211
- background: var(--accent-bg);
212
- border-color: #9dccbf;
213
- }
214
- .stack-row.selected {
215
- border-color: var(--accent);
216
- }
217
- .stack-name {
218
- font-weight: 650;
219
- overflow-wrap: anywhere;
220
- }
221
- .stack-meta {
222
- color: var(--muted);
223
- font-size: 12px;
224
- margin-top: 2px;
225
- }
226
- .badge {
227
- display: inline-block;
228
- border-radius: 999px;
229
- padding: 1px 7px;
230
- margin-left: 5px;
231
- font-size: 12px;
232
- border: 1px solid var(--line);
233
- color: var(--muted);
234
- }
235
- .badge.error {
236
- color: var(--error);
237
- background: var(--error-bg);
238
- border-color: #f2b8b5;
239
- }
240
- .badge.warning {
241
- color: var(--warning);
242
- background: var(--warning-bg);
243
- border-color: #efd28b;
244
- }
245
- .main {
246
- min-width: 0;
247
- min-height: 0;
248
- display: flex;
249
- flex-direction: column;
250
- }
251
- .main-actions {
252
- display: flex;
253
- gap: 8px;
254
- flex-wrap: wrap;
255
- padding: 10px 12px;
256
- border-bottom: 1px solid var(--line);
257
- background: var(--pane);
258
- }
259
- .view-tabs {
260
- display: flex;
261
- gap: 6px;
262
- padding: 8px 12px;
263
- border-bottom: 1px solid var(--line);
264
- background: var(--pane);
265
- flex-wrap: wrap;
266
- }
267
- .view-tabs button.active {
268
- border-color: var(--accent);
269
- background: var(--accent-bg);
270
- color: var(--accent);
271
- }
272
- .action-spacer {
273
- flex: 1 1 auto;
274
- min-width: 12px;
275
- }
276
- .metadata-panel {
277
- border-bottom: 1px solid var(--line);
278
- background: var(--pane-soft);
279
- }
280
- .metadata-head {
281
- display: flex;
282
- align-items: center;
283
- gap: 10px;
284
- padding: 8px 12px;
285
- }
286
- .metadata-title {
287
- font-weight: 650;
288
- }
289
- .metadata-summary {
290
- color: var(--muted);
291
- font-size: 12px;
292
- min-width: 0;
293
- overflow: hidden;
294
- text-overflow: ellipsis;
295
- white-space: nowrap;
296
- }
297
- .settings {
298
- display: grid;
299
- grid-template-columns: repeat(4, minmax(130px, 1fr));
300
- gap: 10px;
301
- padding: 12px;
302
- flex: 0 0 auto;
303
- }
304
- .settings textarea {
305
- min-height: 48px;
306
- max-height: 72px;
307
- resize: vertical;
308
- }
309
- .field label {
310
- display: block;
311
- color: var(--muted);
312
- font-size: 12px;
313
- margin-bottom: 4px;
314
- }
315
- .checkline {
316
- display: flex;
317
- align-items: center;
318
- gap: 8px;
319
- min-height: 32px;
320
- }
321
- .checkline input {
322
- width: auto;
323
- }
324
- .workspace {
325
- display: grid;
326
- grid-template-columns: minmax(230px, 340px) minmax(0, 1fr);
327
- flex: 1;
328
- min-height: 0;
329
- }
330
- .tab-panel {
331
- display: none;
332
- flex: 1;
333
- min-height: 0;
334
- overflow: auto;
335
- padding: 12px;
336
- background: var(--pane-soft);
337
- }
338
- .tab-panel.open {
339
- display: block;
340
- }
341
- .tab-section {
342
- border: 1px solid var(--line);
343
- border-radius: 6px;
344
- background: var(--pane);
345
- padding: 10px;
346
- margin-bottom: 12px;
347
- }
348
- .tab-section-title {
349
- font-weight: 650;
350
- margin-bottom: 4px;
351
- }
352
- .tab-section-meta {
353
- color: var(--muted);
354
- font-size: 12px;
355
- margin-bottom: 10px;
356
- }
357
- .items-pane {
358
- border-right: 1px solid var(--line);
359
- background: var(--pane);
360
- min-width: 0;
361
- min-height: 0;
362
- display: flex;
363
- flex-direction: column;
364
- }
365
- .pane-head {
366
- display: flex;
367
- align-items: center;
368
- justify-content: space-between;
369
- gap: 8px;
370
- padding: 10px 12px;
371
- border-bottom: 1px solid var(--line);
372
- font-weight: 650;
373
- }
374
- .item-tools {
375
- display: flex;
376
- gap: 6px;
377
- padding: 8px 12px;
378
- border-bottom: 1px solid var(--line);
379
- flex: 0 0 auto;
380
- }
381
- .item-tools-spacer {
382
- flex: 1 1 auto;
383
- min-width: 8px;
384
- }
385
- .item-list {
386
- padding: 8px;
387
- overflow: auto;
388
- flex: 1;
389
- min-height: 0;
390
- }
391
- .item-list.drag-active {
392
- background: var(--accent-bg);
393
- }
394
- .item-row {
395
- width: 100%;
396
- text-align: left;
397
- border: 1px solid var(--line);
398
- background: var(--row);
399
- border-radius: 6px;
400
- padding: 8px;
401
- margin-bottom: 6px;
402
- display: grid;
403
- grid-template-columns: 18px minmax(0, 1fr) 48px;
404
- gap: 8px;
405
- align-items: center;
406
- cursor: grab;
407
- position: relative;
408
- }
409
- .item-row:active {
410
- cursor: grabbing;
411
- }
412
- .item-row.selected {
413
- border-color: var(--accent);
414
- background: var(--accent-bg);
415
- }
416
- .item-row.disabled {
417
- opacity: .62;
418
- }
419
- .item-row.dragging {
420
- border-style: dashed;
421
- opacity: .58;
422
- }
423
- .item-row.drop-before::before,
424
- .item-row.drop-after::after {
425
- content: "";
426
- position: absolute;
427
- left: 6px;
428
- right: 6px;
429
- height: 4px;
430
- border-radius: 999px;
431
- background: var(--accent);
432
- box-shadow: 0 0 0 3px var(--accent-bg);
433
- pointer-events: none;
434
- z-index: 2;
435
- }
436
- .item-row.drop-before::before {
437
- top: -6px;
438
- }
439
- .item-row.drop-after::after {
440
- bottom: -6px;
441
- }
442
- .drag-handle {
443
- color: var(--muted);
444
- font-size: 20px;
445
- line-height: 1;
446
- text-align: center;
447
- user-select: none;
448
- }
449
- .item-toggle {
450
- width: 44px;
451
- min-height: 26px;
452
- padding: 2px 0;
453
- border-radius: 999px;
454
- font-size: 12px;
455
- cursor: pointer;
456
- }
457
- .item-toggle.enabled {
458
- border-color: var(--accent);
459
- background: var(--accent);
460
- color: white;
461
- }
462
- .item-toggle.disabled {
463
- border-color: var(--line-strong);
464
- background: var(--control-muted);
465
- color: var(--muted);
466
- }
467
- .item-badge {
468
- display: inline-block;
469
- border-radius: 999px;
470
- padding: 0 6px;
471
- margin-left: 6px;
472
- font-size: 11px;
473
- line-height: 18px;
474
- border: 1px solid var(--line);
475
- }
476
- .item-badge.error {
477
- color: var(--error);
478
- background: var(--error-bg);
479
- border-color: var(--error);
480
- }
481
- .item-badge.warning {
482
- color: var(--warning);
483
- background: var(--warning-bg);
484
- border-color: var(--warning);
485
- }
486
- .item-title {
487
- font-weight: 650;
488
- overflow-wrap: anywhere;
489
- }
490
- .item-meta {
491
- color: var(--muted);
492
- font-size: 12px;
493
- margin-top: 2px;
494
- }
495
- .editor-pane {
496
- min-width: 0;
497
- min-height: 0;
498
- display: flex;
499
- flex-direction: column;
500
- background: var(--pane-soft);
501
- }
502
- .item-editor {
503
- flex: 1;
504
- min-height: 0;
505
- overflow: hidden;
506
- }
507
- .item-form {
508
- display: flex;
509
- flex-direction: column;
510
- gap: 10px;
511
- padding: 12px;
512
- border-bottom: 1px solid var(--line);
513
- height: 100%;
514
- min-height: 0;
515
- overflow: hidden;
516
- }
517
- .item-fields {
518
- display: grid;
519
- grid-template-columns: repeat(4, minmax(120px, 1fr));
520
- gap: 10px;
521
- flex: 0 0 auto;
522
- }
523
- .item-body {
524
- flex: 1;
525
- min-height: 0;
526
- display: flex;
527
- }
528
- .item-body > .field {
529
- width: 100%;
530
- }
531
- .wide {
532
- grid-column: 1 / -1;
533
- }
534
- .content-field {
535
- display: flex;
536
- flex-direction: column;
537
- min-height: 0;
538
- }
539
- .content-field textarea {
540
- flex: 1;
541
- min-height: 0;
542
- height: 100%;
543
- resize: none;
544
- }
545
- .slot-options {
546
- display: flex;
547
- flex-direction: column;
548
- min-height: 0;
549
- }
550
- .segmented {
551
- display: inline-flex;
552
- gap: 4px;
553
- margin-bottom: 8px;
554
- }
555
- .segmented button {
556
- min-height: 28px;
557
- padding: 3px 9px;
558
- }
559
- .segmented button.active {
560
- border-color: var(--accent);
561
- background: var(--accent-bg);
562
- color: var(--accent);
563
- }
564
- .options-grid {
565
- display: grid;
566
- grid-template-columns: repeat(3, minmax(150px, 1fr));
567
- gap: 10px;
568
- overflow: auto;
569
- padding-right: 4px;
570
- flex: 1;
571
- min-height: 0;
572
- }
573
- .option-note {
574
- color: var(--muted);
575
- font-size: 12px;
576
- }
577
- .json-options {
578
- flex: 1;
579
- min-height: 0;
580
- height: 100%;
581
- resize: none;
582
- }
583
- .empty {
584
- color: var(--muted);
585
- padding: 24px;
586
- }
587
- .empty-title {
588
- color: var(--text);
589
- font-weight: 650;
590
- margin-bottom: 4px;
591
- }
592
- .empty-actions {
593
- display: flex;
594
- gap: 8px;
595
- flex-wrap: wrap;
596
- margin-top: 14px;
597
- }
598
- .diagnostics {
599
- border-top: 1px solid var(--line);
600
- background: var(--pane);
601
- flex: 0 0 128px;
602
- min-height: 0;
603
- overflow: auto;
604
- }
605
- .diagnostic {
606
- padding: 6px 12px;
607
- border-bottom: 1px solid var(--line);
608
- }
609
- .diagnostic.error {
610
- color: var(--error);
611
- background: var(--error-bg);
612
- }
613
- .diagnostic.warning {
614
- color: var(--warning);
615
- background: var(--warning-bg);
616
- }
617
- .diagnostic.info {
618
- color: var(--muted);
619
- }
620
- .preview {
621
- display: none;
622
- position: fixed;
623
- inset: 0;
624
- z-index: 100;
625
- align-items: stretch;
626
- justify-content: center;
627
- background: rgba(15, 23, 42, .38);
628
- color: var(--text);
629
- margin: 0;
630
- padding: 24px;
631
- overflow: hidden;
632
- }
633
- .preview.open {
634
- display: flex;
635
- }
636
- .preview-dialog {
637
- width: min(1220px, calc(100vw - 48px));
638
- height: min(900px, calc(100vh - 48px));
639
- border: 1px solid var(--line-strong);
640
- border-radius: 8px;
641
- background: var(--pane-soft);
642
- box-shadow: 0 18px 60px var(--shadow);
643
- display: flex;
644
- flex-direction: column;
645
- overflow: hidden;
646
- }
647
- .preview-head {
648
- display: flex;
649
- align-items: center;
650
- gap: 8px;
651
- padding: 10px 12px;
652
- border-bottom: 1px solid var(--line);
653
- background: var(--pane);
654
- flex: 0 0 auto;
655
- }
656
- .preview-body {
657
- padding: 10px 12px 14px;
658
- overflow: auto;
659
- flex: 1;
660
- min-height: 0;
661
- }
662
- .preview-actions {
663
- margin-left: auto;
664
- display: flex;
665
- align-items: center;
666
- gap: 8px;
667
- }
668
- .preview-title {
669
- font-weight: 650;
670
- }
671
- .preview-meta {
672
- color: var(--muted);
673
- font-size: 12px;
674
- }
675
- .preview-section {
676
- border: 1px solid var(--line);
677
- border-radius: 6px;
678
- background: var(--row);
679
- margin-bottom: 8px;
680
- overflow: hidden;
681
- }
682
- .preview-section summary {
683
- display: flex;
684
- align-items: center;
685
- gap: 8px;
686
- padding: 8px 10px;
687
- cursor: pointer;
688
- list-style: none;
689
- border-bottom: 1px solid transparent;
690
- }
691
- .preview-section[open] summary {
692
- border-bottom-color: var(--line);
693
- }
694
- .preview-section summary::-webkit-details-marker {
695
- display: none;
696
- }
697
- .preview-section summary::before {
698
- content: "▶";
699
- color: var(--muted);
700
- font-size: 10px;
701
- }
702
- .preview-section[open] summary::before {
703
- content: "▼";
704
- }
705
- .preview-text {
706
- margin: 0;
707
- padding: 10px;
708
- background: var(--code-bg);
709
- color: var(--code-text);
710
- overflow: auto;
711
- max-height: min(62vh, 680px);
712
- white-space: pre-wrap;
713
- font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
714
- }
715
- .preview-copy {
716
- min-height: 26px;
717
- padding: 2px 8px;
718
- font-size: 12px;
719
- }
720
- .modal {
721
- display: none;
722
- position: fixed;
723
- inset: 0;
724
- z-index: 95;
725
- align-items: stretch;
726
- justify-content: center;
727
- background: rgba(15, 23, 42, .34);
728
- padding: 24px;
729
- }
730
- .modal.open {
731
- display: flex;
732
- }
733
- .modal-dialog {
734
- width: min(1280px, calc(100vw - 48px));
735
- height: min(860px, calc(100vh - 48px));
736
- border: 1px solid var(--line-strong);
737
- border-radius: 8px;
738
- background: var(--pane);
739
- box-shadow: 0 18px 60px var(--shadow);
740
- display: flex;
741
- flex-direction: column;
742
- overflow: hidden;
743
- }
744
- .modal-head {
745
- display: flex;
746
- align-items: center;
747
- gap: 8px;
748
- padding: 10px 12px;
749
- border-bottom: 1px solid var(--line);
750
- }
751
- .modal-title {
752
- font-weight: 650;
753
- }
754
- .modal-meta {
755
- color: var(--muted);
756
- font-size: 12px;
757
- }
758
- .modal-actions {
759
- margin-left: auto;
760
- display: flex;
761
- align-items: center;
762
- gap: 8px;
763
- }
764
- .modal-body {
765
- flex: 1;
766
- min-height: 0;
767
- overflow: auto;
768
- padding: 12px;
769
- background: var(--pane-soft);
770
- }
771
- .modal-toolbar {
772
- display: flex;
773
- gap: 8px;
774
- align-items: center;
775
- margin-bottom: 10px;
776
- }
777
- .modal-body.json-modal {
778
- display: flex;
779
- flex-direction: column;
780
- }
781
- .modal-spacer {
782
- flex: 1;
783
- }
784
- .data-table {
785
- display: flex;
786
- flex-direction: column;
787
- gap: 6px;
788
- }
789
- .data-row {
790
- display: grid;
791
- gap: 8px;
792
- align-items: start;
793
- padding: 8px;
794
- border: 1px solid var(--line);
795
- border-radius: 6px;
796
- background: var(--row);
797
- }
798
- .data-row.header {
799
- color: var(--muted);
800
- font-size: 12px;
801
- font-weight: 650;
802
- background: transparent;
803
- border-color: transparent;
804
- padding-top: 0;
805
- padding-bottom: 0;
806
- }
807
- .variable-row {
808
- grid-template-columns: minmax(160px, 260px) minmax(220px, 1fr) 86px;
809
- }
810
- .definition-row {
811
- grid-template-columns: minmax(150px, 210px) minmax(100px, 140px) minmax(90px, 120px) minmax(180px, 1fr) minmax(190px, 260px) minmax(110px, 130px) minmax(110px, 130px) 86px;
812
- }
813
- .session-row {
814
- grid-template-columns: minmax(160px, 240px) minmax(220px, 1fr) minmax(180px, 260px) 168px;
815
- }
816
- .policy-row {
817
- grid-template-columns: minmax(110px, 150px) minmax(230px, 290px) minmax(220px, 1fr) minmax(220px, 1fr) minmax(150px, 220px);
818
- }
819
- .policy-title {
820
- font-weight: 650;
821
- margin-bottom: 3px;
822
- }
823
- .policy-mode {
824
- display: flex;
825
- flex-wrap: wrap;
826
- margin-bottom: 0;
827
- }
828
- .policy-patterns {
829
- min-height: 96px;
830
- }
831
- .selected-patterns {
832
- display: flex;
833
- flex-wrap: wrap;
834
- gap: 6px;
835
- margin-bottom: 8px;
836
- }
837
- .selected-pattern-chip {
838
- min-height: 26px;
839
- padding: 2px 7px;
840
- font-size: 12px;
841
- border-color: var(--accent);
842
- background: var(--accent-bg);
843
- color: var(--accent);
844
- overflow-wrap: anywhere;
845
- }
846
- .selected-pattern-chip span {
847
- margin-left: 6px;
848
- color: var(--muted);
849
- }
850
- .selected-pattern-empty {
851
- color: var(--muted);
852
- font-size: 12px;
853
- min-height: 26px;
854
- display: inline-flex;
855
- align-items: center;
856
- }
857
- .policy-summary {
858
- color: var(--muted);
859
- font-size: 12px;
860
- overflow-wrap: anywhere;
861
- }
862
- .resource-picker label {
863
- display: block;
864
- color: var(--muted);
865
- font-size: 12px;
866
- margin-bottom: 4px;
867
- }
868
- .resource-list {
869
- display: flex;
870
- flex-wrap: wrap;
871
- gap: 6px;
872
- max-height: 132px;
873
- overflow: auto;
874
- padding-right: 2px;
875
- }
876
- .resource-filter {
877
- margin-bottom: 8px;
878
- }
879
- .resource-chip {
880
- max-width: 100%;
881
- min-height: 26px;
882
- padding: 2px 7px;
883
- font-size: 12px;
884
- overflow-wrap: anywhere;
885
- }
886
- .resource-chip.active {
887
- border-color: var(--accent);
888
- background: var(--accent-bg);
889
- color: var(--accent);
890
- }
891
- .resource-chip.hidden {
892
- border-style: dashed;
893
- opacity: .72;
894
- }
895
- .resource-empty {
896
- color: var(--muted);
897
- font-size: 12px;
898
- border: 1px dashed var(--line);
899
- border-radius: 6px;
900
- padding: 8px;
901
- }
902
- .regex-row {
903
- grid-template-columns: 72px minmax(0, 1fr) 86px;
904
- }
905
- .regex-controls {
906
- display: flex;
907
- flex-direction: column;
908
- gap: 6px;
909
- }
910
- .regex-fields {
911
- display: grid;
912
- grid-template-columns: repeat(6, minmax(110px, 1fr));
913
- gap: 8px;
914
- }
915
- .regex-fields .span-2 {
916
- grid-column: span 2;
917
- }
918
- .regex-fields .span-3 {
919
- grid-column: span 3;
920
- }
921
- .regex-fields .wide {
922
- grid-column: 1 / -1;
923
- }
924
- .regex-checks {
925
- display: flex;
926
- flex-wrap: wrap;
927
- gap: 8px 12px;
928
- min-height: 32px;
929
- align-items: center;
930
- }
931
- .regex-checks label {
932
- color: var(--text);
933
- font-size: 13px;
934
- margin: 0;
935
- }
936
- .regex-checks input {
937
- width: auto;
938
- }
939
- .regex-warning {
940
- color: var(--warning);
941
- background: var(--warning-bg);
942
- border: 1px solid var(--warning);
943
- border-radius: 6px;
944
- padding: 6px 8px;
945
- font-size: 12px;
946
- }
947
- .data-row textarea {
948
- min-height: 56px;
949
- resize: vertical;
950
- }
951
- .row-actions {
952
- display: flex;
953
- gap: 6px;
954
- }
955
- .raw-json-editor {
956
- flex: 1;
957
- min-height: 0;
958
- height: 100%;
959
- resize: none;
960
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
961
- font-size: 12px;
962
- }
963
- .tab-section .raw-json-editor {
964
- height: min(48vh, 520px);
965
- min-height: 280px;
966
- }
967
- @media (max-width: 900px) {
968
- .shell, .workspace, .settings, .item-fields {
969
- grid-template-columns: 1fr;
970
- }
971
- html, body {
972
- overflow: auto;
973
- }
974
- .shell {
975
- height: auto;
976
- min-height: calc(100vh - 48px);
977
- }
978
- .sidebar, .items-pane {
979
- border-right: 0;
980
- border-bottom: 1px solid var(--line);
981
- }
982
- .metadata-head {
983
- align-items: flex-start;
984
- flex-direction: column;
985
- }
986
- .metadata-summary {
987
- white-space: normal;
988
- }
989
- .item-list {
990
- max-height: 260px;
991
- }
992
- .variable-row, .definition-row, .session-row, .policy-row, .regex-row {
993
- grid-template-columns: 1fr;
994
- }
995
- .regex-fields {
996
- grid-template-columns: 1fr;
997
- }
998
- .regex-fields .span-2, .regex-fields .span-3 {
999
- grid-column: 1 / -1;
1000
- }
1001
- }
1002
- </style>
1003
- </head>
1004
- <body>
1005
- <header class="topbar">
1006
- <button id="sidebarToggleBtn" class="icon" data-icon="☰" title="Toggle prompt stacks sidebar" aria-label="Toggle prompt stacks sidebar"></button>
1007
- <div class="brand">pi-forge stack editor</div>
1008
- <div id="status" class="status">Loading</div>
1009
- <span id="dirtyBadge" class="dirty-badge" title="The current stack has unsaved edits">Unsaved</span>
1010
- <button id="themeBtn" data-icon="◐" title="Toggle light or dark theme">Theme</button>
1011
- <button id="reloadBtn" data-icon="↻" title="Reload prompt stacks from disk">Reload</button>
1012
- <button id="disableBtn" data-icon="■" title="Disable the active prompt stack">Disable stack</button>
1013
- </header>
1014
- <div id="shell" class="shell">
1015
- <aside class="sidebar">
1016
- <div class="side-head">
1017
- <div class="side-title">Prompt stacks</div>
1018
- <div id="cwd" class="cwd"></div>
1019
- </div>
1020
- <div id="stackList" class="stack-list"></div>
1021
- </aside>
1022
- <main class="main">
1023
- <div class="main-actions">
1024
- <button id="newStackBtn" data-icon="+" title="Create a new prompt stack (Ctrl/Cmd+N)">New stack</button>
1025
- <button id="activateBtn" class="primary" data-icon="▶" title="Make this stack active for the current Pi session">Activate</button>
1026
- <button id="saveBtn" class="primary" data-icon="✓" title="Save the edited stack JSON to disk (Ctrl/Cmd+S)">Save</button>
1027
- <button id="validateBtn" data-icon="!" title="Validate the edited stack without saving (Ctrl/Cmd+Shift+Enter)">Validate</button>
1028
- <button id="previewBtn" data-icon="◱" title="Preview the compiled prompt without sending it (Ctrl/Cmd+Enter)">Preview</button>
1029
- <button id="payloadBtn" data-icon="◆" title="Capture the next provider payload in the browser">Arm payload</button>
1030
- <button id="forkBtn" data-icon="⑂" title="Create a new stack from the current edits">Fork</button>
1031
- <button id="importBtn" data-icon="⇪" title="Import pi-forge stack JSON or SillyTavern preset JSON">Import JSON</button>
1032
- <button id="exportBtn" data-icon="⇩" title="Download the current stack JSON, or copy it if download is unavailable">Export JSON</button>
1033
- <span class="action-spacer"></span>
1034
- <button id="deleteStackBtn" class="danger" data-icon="×" title="Delete the selected stack JSON file">Delete stack</button>
1035
- <input id="importFileInput" type="file" accept="application/json,.json" hidden>
1036
- </div>
1037
- <section id="metadataPanel" class="metadata-panel">
1038
- <div class="metadata-head">
1039
- <button id="metadataToggleBtn" data-icon="▾" title="Show or hide stack metadata">Hide metadata</button>
1040
- <div class="metadata-title">Stack metadata</div>
1041
- <div id="metadataSummary" class="metadata-summary"></div>
1042
- </div>
1043
- <div id="settings" class="settings"></div>
1044
- </section>
1045
- <nav class="view-tabs" aria-label="Stack editor sections">
1046
- <button id="itemsTabBtn" data-tab="items" class="active" data-icon="☰" title="Edit prompt stack items">Items</button>
1047
- <button id="regexTabBtn" data-tab="regex" data-icon=".*" title="Edit regex transform rules">Regex</button>
1048
- <button id="policyTabBtn" data-tab="policy" data-icon="⊕" title="Edit tool and skill policy">Policy</button>
1049
- <button id="stackTabBtn" data-tab="stack" data-icon="{}" title="Edit context options and raw stack JSON">Stack</button>
1050
- </nav>
1051
- <section id="workspace" class="workspace">
1052
- <div class="items-pane">
1053
- <div class="pane-head">
1054
- <span>Items</span>
1055
- <span id="itemCount" class="stack-meta"></span>
1056
- </div>
1057
- <div class="item-tools">
1058
- <button id="addItemBtn" data-icon="+" title="Add a prompt block item">Add block</button>
1059
- <button id="addSlotBtn" data-icon="+" title="Add a runtime slot item">Add slot</button>
1060
- <span class="item-tools-spacer"></span>
1061
- <button id="deleteItemBtn" class="danger" data-icon="×" title="Delete the selected stack item">Delete item</button>
1062
- </div>
1063
- <div id="itemList" class="item-list"></div>
1064
- </div>
1065
- <div class="editor-pane">
1066
- <div id="itemEditor" class="item-editor"></div>
1067
- <div id="diagnostics" class="diagnostics"></div>
1068
- </div>
1069
- </section>
1070
- <section id="tabPanel" class="tab-panel"></section>
1071
- </main>
1072
- </div>
1073
- <div id="preview" class="preview"></div>
1074
- <div id="stackModal" class="modal"></div>
1075
- <script>
1076
- const token = new URLSearchParams(location.search).get("token") || "";
1077
- let stacks = [];
1078
- let cwd = "";
1079
- let selectedId = "";
1080
- let currentStack = null;
1081
- let currentFilePath = "";
1082
- let selectedItemIndex = -1;
1083
- let dirty = false;
1084
- let dragIndex = -1;
1085
- let dragDropIndex = -1;
1086
- let dragScrollFrame = 0;
1087
- let dragScrollSpeed = 0;
1088
- let dragClientY = 0;
1089
- let optionsText = "";
1090
- let optionsError = "";
1091
- let sidebarCollapsed = false;
1092
- let slotOptionsMode = "form";
1093
- let previewCopyTexts = [];
1094
- let payloadSnapshot = { status: "idle" };
1095
- let policyResources = { tools: [], skills: [] };
1096
- let latestDiagnostics = [];
1097
- let stackVariablesError = "";
1098
- let regexRulesError = "";
1099
- let stackPolicyError = "";
1100
- let activeTab = "items";
1101
- let metadataCollapsed = true;
1102
- let currentTheme = readStoredTheme() || (window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light");
1103
-
1104
- const slotNames = [
1105
- "chat-history", "tools", "tool-guidelines", "skills", "project-context",
1106
- "append-system-prompt", "variables", "date", "cwd", "date-cwd",
1107
- "active-model", "pi-docs"
1108
- ];
1109
- const roles = ["", "system", "user", "assistant", "custom"];
1110
- const regexStages = ["history", "compiled"];
1111
- const regexEffects = ["outgoing", "finalize", "display", "both"];
1112
- const regexTargets = ["system", "messages"];
1113
- const regexRoles = ["system", "user", "assistant", "custom"];
1114
-
1115
- const el = (id) => document.getElementById(id);
1116
-
1117
- function applyTheme(theme) {
1118
- currentTheme = theme === "dark" ? "dark" : "light";
1119
- document.body.dataset.theme = currentTheme;
1120
- const button = el("themeBtn");
1121
- if (button) {
1122
- button.textContent = currentTheme === "dark" ? "Light" : "Dark";
1123
- button.title = currentTheme === "dark" ? "Switch to light theme" : "Switch to dark theme";
1124
- }
1125
- }
1126
-
1127
- function toggleTheme() {
1128
- const next = currentTheme === "dark" ? "light" : "dark";
1129
- writeStoredTheme(next);
1130
- applyTheme(next);
1131
- setStatus(next === "dark" ? "Dark theme enabled" : "Light theme enabled", "success");
1132
- }
1133
-
1134
- applyTheme(currentTheme);
1135
-
1136
- function readStoredTheme() {
1137
- try {
1138
- return localStorage.getItem("pi-forge-theme");
1139
- } catch {
1140
- return "";
1141
- }
1142
- }
1143
-
1144
- function writeStoredTheme(theme) {
1145
- try {
1146
- localStorage.setItem("pi-forge-theme", theme);
1147
- } catch {
1148
- // Ignore storage failures; the current page can still switch themes.
1149
- }
1150
- }
1151
-
1152
- async function api(path, options = {}) {
1153
- const headers = { "x-pi-forge-token": token, ...(options.headers || {}) };
1154
- let body = options.body;
1155
- if (body && typeof body !== "string") {
1156
- headers["content-type"] = "application/json";
1157
- body = JSON.stringify(body);
1158
- }
1159
- const res = await fetch(path, { ...options, headers, body });
1160
- const text = await res.text();
1161
- const data = text ? JSON.parse(text) : {};
1162
- if (!res.ok) {
1163
- const error = new Error(data.error || res.statusText);
1164
- error.status = res.status;
1165
- throw error;
1166
- }
1167
- return data;
1168
- }
1169
-
1170
- function setStatus(text, tone = "") {
1171
- el("status").textContent = text;
1172
- el("status").style.color = tone === "error" ? "var(--error)" : tone === "success" ? "var(--success)" : "var(--muted)";
1173
- }
1174
-
1175
- function markDirty() {
1176
- dirty = true;
1177
- renderDirtyState();
1178
- setStatus("Unsaved changes");
1179
- }
1180
-
1181
- function renderDirtyState() {
1182
- const badge = el("dirtyBadge");
1183
- if (!badge) return;
1184
- badge.classList.toggle("visible", dirty);
1185
- updateActionState();
1186
- }
1187
-
1188
- function updateActionState() {
1189
- const hasStack = !!currentStack;
1190
- for (const id of ["activateBtn", "saveBtn", "validateBtn", "previewBtn", "forkBtn", "exportBtn", "deleteStackBtn", "addItemBtn", "addSlotBtn", "metadataToggleBtn"]) {
1191
- const button = el(id);
1192
- if (button) button.disabled = !hasStack;
1193
- }
1194
- const deleteItemButton = el("deleteItemBtn");
1195
- if (deleteItemButton) deleteItemButton.disabled = !hasStack || selectedItemIndex < 0;
1196
- document.querySelectorAll("[data-tab]").forEach((button) => {
1197
- button.disabled = !hasStack;
1198
- });
1199
- }
1200
-
1201
- async function loadStacks(preferId = selectedId) {
1202
- const [data, resources] = await Promise.all([
1203
- api("/api/stacks"),
1204
- api("/api/resources"),
1205
- ]);
1206
- stacks = data.stacks || [];
1207
- policyResources = normalizePolicyResources(resources);
1208
- cwd = data.cwd || "";
1209
- el("cwd").textContent = cwd;
1210
- renderStackList();
1211
- const next = stacks.find((stack) => stack.id === preferId) || stacks.find((stack) => stack.active) || stacks[0];
1212
- if (next) await selectStack(next.id, { keepDirty: false });
1213
- else renderEmpty();
1214
- }
1215
-
1216
- async function selectStack(id, options = {}) {
1217
- if (dirty && !options.keepDirty && !confirm("Discard unsaved changes?")) return;
1218
- const data = await api("/api/stacks/" + encodeURIComponent(id));
1219
- selectedId = id;
1220
- currentStack = structuredClone(data.stack);
1221
- currentFilePath = data.filePath || "";
1222
- selectedItemIndex = currentStack.items.length ? 0 : -1;
1223
- dirty = false;
1224
- optionsError = "";
1225
- stackVariablesError = "";
1226
- regexRulesError = "";
1227
- stackPolicyError = "";
1228
- renderDirtyState();
1229
- renderAll(data.diagnostics || []);
1230
- setStatus("Loaded " + currentStack.id);
1231
- }
1232
-
1233
- function renderAll(diagnostics = []) {
1234
- latestDiagnostics = diagnostics;
1235
- renderStackList();
1236
- renderSettings();
1237
- renderActiveTab();
1238
- renderDiagnostics(diagnostics);
1239
- hidePreview();
1240
- updateActionState();
1241
- }
1242
-
1243
- function renderActiveTab() {
1244
- document.querySelectorAll("[data-tab]").forEach((button) => {
1245
- button.classList.toggle("active", button.dataset.tab === activeTab);
1246
- });
1247
- const workspace = el("workspace");
1248
- const panel = el("tabPanel");
1249
- if (activeTab === "items") {
1250
- workspace.style.display = "";
1251
- panel.classList.remove("open");
1252
- panel.innerHTML = "";
1253
- renderItemList();
1254
- renderItemEditor();
1255
- return;
1256
- }
1257
- workspace.style.display = "none";
1258
- panel.classList.add("open");
1259
- if (activeTab === "regex") renderRegexTab();
1260
- else if (activeTab === "policy") renderPolicyTab();
1261
- else if (activeTab === "stack") renderStackTab();
1262
- }
1263
-
1264
- function renderStackList() {
1265
- const list = el("stackList");
1266
- list.innerHTML = "";
1267
- if (!stacks.length) {
1268
- list.innerHTML = '<div class="side-empty">No prompt stacks in this project.</div>';
1269
- return;
1270
- }
1271
- for (const stack of stacks) {
1272
- const row = document.createElement("button");
1273
- row.className = "stack-row" + (stack.active ? " active" : "") + (stack.id === selectedId ? " selected" : "");
1274
- const diag = stack.errors ? '<span class="badge error">' + stack.errors + ' error</span>' : stack.warnings ? '<span class="badge warning">' + stack.warnings + ' warning</span>' : "";
1275
- row.innerHTML = '<div class="stack-name">' + escapeHtml(stack.id) + (stack.active ? '<span class="badge">active</span>' : '') + diag + '</div>' +
1276
- '<div class="stack-meta">' + escapeHtml(stack.name || "(unnamed)") + '</div>' +
1277
- '<div class="stack-meta">' + stack.itemCount + ' items | ' + escapeHtml(stack.mode || "replace") + '</div>';
1278
- row.onclick = () => selectStack(stack.id);
1279
- list.appendChild(row);
1280
- }
1281
- }
1282
-
1283
- function normalizePolicyResources(value) {
1284
- return {
1285
- tools: normalizeResourceList(value?.tools),
1286
- skills: normalizeResourceList(value?.skills),
1287
- };
1288
- }
1289
-
1290
- function normalizeResourceList(value) {
1291
- if (!Array.isArray(value)) return [];
1292
- return value
1293
- .filter((resource) => resource && typeof resource === "object" && typeof resource.name === "string" && resource.name.trim())
1294
- .map((resource) => ({
1295
- name: resource.name.trim(),
1296
- description: typeof resource.description === "string" ? resource.description : "",
1297
- source: typeof resource.source === "string" ? resource.source : "",
1298
- active: resource.active === true,
1299
- hidden: resource.hidden === true,
1300
- }));
1301
- }
1302
-
1303
- function renderSettings() {
1304
- const settings = el("settings");
1305
- if (!currentStack) {
1306
- settings.innerHTML = "";
1307
- el("metadataSummary").textContent = "";
1308
- return;
1309
- }
1310
- el("metadataPanel").style.display = "";
1311
- el("metadataSummary").textContent = [
1312
- currentStack.id || "(no id)",
1313
- currentStack.name || "(unnamed)",
1314
- currentStack.mode || "replace",
1315
- currentFilePath || "",
1316
- ].filter(Boolean).join(" | ");
1317
- settings.style.display = metadataCollapsed ? "none" : "grid";
1318
- el("metadataToggleBtn").textContent = metadataCollapsed ? "Show metadata" : "Hide metadata";
1319
- el("metadataToggleBtn").dataset.icon = metadataCollapsed ? "▸" : "▾";
1320
- settings.innerHTML = [
1321
- field("Stack ID", '<input id="stackId" value="' + attr(currentStack.id) + '">'),
1322
- field("Name", '<input id="stackName" value="' + attr(currentStack.name || "") + '">'),
1323
- field("Mode", '<select id="stackMode"><option value="replace">replace</option><option value="append">append</option><option value="prepend">prepend</option></select>'),
1324
- field("Auto activate", '<label class="checkline"><input id="stackAuto" type="checkbox"> enabled</label>'),
1325
- field("Description", '<textarea id="stackDescription" class="wide">' + escapeHtml(currentStack.description || "") + '</textarea>', "wide"),
1326
- field("File", '<input value="' + attr(currentFilePath) + '" disabled>', "wide"),
1327
- ].join("");
1328
- el("stackMode").value = currentStack.mode || "replace";
1329
- el("stackAuto").checked = currentStack.autoActivate === true;
1330
- el("stackId").oninput = (event) => { currentStack.id = event.target.value; markDirty(); };
1331
- el("stackName").oninput = (event) => { setOptionalString(currentStack, "name", event.target.value); markDirty(); };
1332
- el("stackMode").onchange = (event) => { currentStack.mode = event.target.value; markDirty(); };
1333
- el("stackAuto").onchange = (event) => { currentStack.autoActivate = event.target.checked; markDirty(); };
1334
- el("stackDescription").oninput = (event) => { setOptionalString(currentStack, "description", event.target.value); markDirty(); };
1335
- }
1336
-
1337
- function toggleMetadata() {
1338
- metadataCollapsed = !metadataCollapsed;
1339
- renderSettings();
1340
- setStatus(metadataCollapsed ? "Stack metadata hidden" : "Stack metadata shown");
1341
- }
1342
-
1343
- function renderItemList() {
1344
- const list = el("itemList");
1345
- list.innerHTML = "";
1346
- list.classList.toggle("drag-active", dragIndex !== -1);
1347
- if (!currentStack) return;
1348
- el("itemCount").textContent = currentStack.items.length + " total";
1349
- list.ondragover = handleItemListDragOver;
1350
- list.ondrop = handleItemListDrop;
1351
- const diagnosticsByItem = diagnosticsForItems();
1352
- currentStack.items.forEach((item, index) => {
1353
- const row = document.createElement("div");
1354
- row.className = "item-row" + (index === selectedItemIndex ? " selected" : "") + (item.enabled === false ? " disabled" : "");
1355
- row.dataset.itemIndex = String(index);
1356
- row.draggable = true;
1357
- const enabled = item.enabled !== false;
1358
- const itemDiagnostics = diagnosticsByItem[item.id] || [];
1359
- const errors = itemDiagnostics.filter((diag) => diag.level === "error").length;
1360
- const warnings = itemDiagnostics.filter((diag) => diag.level === "warning").length;
1361
- const diagBadge = errors
1362
- ? '<span class="item-badge error" title="' + attr(diagnosticTitle(itemDiagnostics)) + '">' + errors + 'E</span>'
1363
- : warnings
1364
- ? '<span class="item-badge warning" title="' + attr(diagnosticTitle(itemDiagnostics)) + '">' + warnings + 'W</span>'
1365
- : "";
1366
- row.innerHTML = '<div class="drag-handle" title="Drag to reorder">≡</div>' +
1367
- '<div><div class="item-title">' + escapeHtml(displayItemName(item)) + diagBadge + '</div>' +
1368
- '<div class="item-meta">' + escapeHtml(item.kind) + ' | id: ' + escapeHtml(item.id) + (item.role ? " | " + escapeHtml(item.role) : "") + (item.kind === "slot" ? " | " + escapeHtml(item.slot || "") : "") + '</div></div>' +
1369
- '<button type="button" class="item-toggle ' + (enabled ? "enabled" : "disabled") + '" title="Toggle item">' + (enabled ? "On" : "Off") + '</button>';
1370
- row.onclick = (event) => {
1371
- if (event.target?.classList?.contains("item-toggle")) return;
1372
- selectedItemIndex = index;
1373
- renderItemList();
1374
- renderItemEditor();
1375
- };
1376
- row.querySelector(".item-toggle").onclick = (event) => {
1377
- event.stopPropagation();
1378
- item.enabled = item.enabled === false;
1379
- selectedItemIndex = index;
1380
- markDirty();
1381
- renderItemList();
1382
- renderItemEditor();
1383
- };
1384
- row.ondragstart = (event) => {
1385
- dragIndex = index;
1386
- dragDropIndex = index;
1387
- row.classList.add("dragging");
1388
- list.classList.add("drag-active");
1389
- if (event.dataTransfer) {
1390
- event.dataTransfer.effectAllowed = "move";
1391
- event.dataTransfer.setData("text/plain", item.id || String(index));
1392
- }
1393
- updateItemDropIndicator();
1394
- };
1395
- row.ondragend = finishItemDrag;
1396
- row.ondragover = handleItemListDragOver;
1397
- row.ondrop = handleItemListDrop;
1398
- list.appendChild(row);
1399
- });
1400
- updateItemDropIndicator();
1401
- }
1402
-
1403
- function handleItemListDragOver(event) {
1404
- if (dragIndex === -1 || !currentStack) return;
1405
- event.preventDefault();
1406
- dragClientY = event.clientY;
1407
- updateItemDragAutoScroll(event.clientY);
1408
- setItemDropIndex(dropIndexFromClientY(event.clientY));
1409
- }
1410
-
1411
- function handleItemListDrop(event) {
1412
- if (dragIndex === -1 || !currentStack) return;
1413
- event.preventDefault();
1414
- dropDraggedItem();
1415
- }
1416
-
1417
- function dropDraggedItem() {
1418
- if (!currentStack || dragIndex < 0 || dragIndex >= currentStack.items.length) {
1419
- finishItemDrag();
1420
- return;
1421
- }
1422
- let insertIndex = dragDropIndex;
1423
- if (insertIndex < 0) insertIndex = dragIndex;
1424
- insertIndex = Math.max(0, Math.min(insertIndex, currentStack.items.length));
1425
- const [moved] = currentStack.items.splice(dragIndex, 1);
1426
- if (!moved) {
1427
- finishItemDrag();
1428
- return;
1429
- }
1430
- if (dragIndex < insertIndex) insertIndex--;
1431
- insertIndex = Math.max(0, Math.min(insertIndex, currentStack.items.length));
1432
- currentStack.items.splice(insertIndex, 0, moved);
1433
- selectedItemIndex = insertIndex;
1434
- const changed = dragIndex !== insertIndex;
1435
- finishItemDrag(false);
1436
- if (changed) markDirty();
1437
- renderItemList();
1438
- renderItemEditor();
1439
- }
1440
-
1441
- function setItemDropIndex(index) {
1442
- if (!currentStack) return;
1443
- const next = Math.max(0, Math.min(index, currentStack.items.length));
1444
- if (dragDropIndex === next) return;
1445
- dragDropIndex = next;
1446
- updateItemDropIndicator();
1447
- }
1448
-
1449
- function dropIndexFromClientY(clientY) {
1450
- const list = el("itemList");
1451
- const rows = [...list.querySelectorAll(".item-row")];
1452
- if (!rows.length) return 0;
1453
- const listRect = list.getBoundingClientRect();
1454
- if (clientY <= listRect.top) return 0;
1455
- if (clientY >= listRect.bottom) return rows.length;
1456
- for (const row of rows) {
1457
- const rect = row.getBoundingClientRect();
1458
- const index = Number(row.dataset.itemIndex);
1459
- if (clientY < rect.top + rect.height / 2) return index;
1460
- }
1461
- return rows.length;
1462
- }
1463
-
1464
- function updateItemDropIndicator() {
1465
- const list = el("itemList");
1466
- const rows = [...list.querySelectorAll(".item-row")];
1467
- for (const row of rows) row.classList.remove("drop-before", "drop-after");
1468
- list.classList.toggle("drag-active", dragIndex !== -1);
1469
- if (dragIndex === -1 || dragDropIndex === -1 || !rows.length) return;
1470
- if (dragDropIndex <= 0) rows[0].classList.add("drop-before");
1471
- else if (dragDropIndex >= rows.length) rows[rows.length - 1].classList.add("drop-after");
1472
- else rows[dragDropIndex].classList.add("drop-before");
1473
- }
1474
-
1475
- function updateItemDragAutoScroll(clientY) {
1476
- const list = el("itemList");
1477
- const rect = list.getBoundingClientRect();
1478
- const edge = Math.min(72, Math.max(36, rect.height / 5));
1479
- let speed = 0;
1480
- if (clientY < rect.top) speed = -Math.min(30, 8 + (rect.top - clientY) / 3);
1481
- else if (clientY < rect.top + edge) speed = -Math.min(22, (rect.top + edge - clientY) / 3);
1482
- else if (clientY > rect.bottom) speed = Math.min(30, 8 + (clientY - rect.bottom) / 3);
1483
- else if (clientY > rect.bottom - edge) speed = Math.min(22, (clientY - (rect.bottom - edge)) / 3);
1484
- dragScrollSpeed = speed;
1485
- if (speed !== 0 && !dragScrollFrame) dragScrollFrame = requestAnimationFrame(runItemDragAutoScroll);
1486
- }
1487
-
1488
- function runItemDragAutoScroll() {
1489
- dragScrollFrame = 0;
1490
- if (dragIndex === -1 || dragScrollSpeed === 0) return;
1491
- const list = el("itemList");
1492
- list.scrollTop += dragScrollSpeed;
1493
- setItemDropIndex(dropIndexFromClientY(dragClientY));
1494
- dragScrollFrame = requestAnimationFrame(runItemDragAutoScroll);
1495
- }
1496
-
1497
- function finishItemDrag(clearIndicator = true) {
1498
- dragIndex = -1;
1499
- dragDropIndex = -1;
1500
- dragScrollSpeed = 0;
1501
- dragClientY = 0;
1502
- if (dragScrollFrame) {
1503
- cancelAnimationFrame(dragScrollFrame);
1504
- dragScrollFrame = 0;
1505
- }
1506
- if (clearIndicator) updateItemDropIndicator();
1507
- }
1508
-
1509
- function handleDocumentItemDragOver(event) {
1510
- if (dragIndex === -1 || !currentStack) return;
1511
- event.preventDefault();
1512
- dragClientY = event.clientY;
1513
- updateItemDragAutoScroll(event.clientY);
1514
- setItemDropIndex(dropIndexFromClientY(event.clientY));
1515
- }
1516
-
1517
- function handleDocumentItemDrop(event) {
1518
- if (dragIndex === -1 || !currentStack) return;
1519
- event.preventDefault();
1520
- dropDraggedItem();
1521
- }
1522
-
1523
- function diagnosticsForItems() {
1524
- const grouped = {};
1525
- for (const diagnostic of latestDiagnostics || []) {
1526
- if (!diagnostic.itemId) continue;
1527
- if (!grouped[diagnostic.itemId]) grouped[diagnostic.itemId] = [];
1528
- grouped[diagnostic.itemId].push(diagnostic);
1529
- }
1530
- return grouped;
1531
- }
1532
-
1533
- function diagnosticTitle(diagnostics) {
1534
- return diagnostics.map((diag) => (diag.level || "info").toUpperCase() + ": " + (diag.message || "")).join("\n");
1535
- }
1536
-
1537
- function renderItemEditor() {
1538
- const editor = el("itemEditor");
1539
- if (!currentStack || selectedItemIndex < 0 || !currentStack.items[selectedItemIndex]) {
1540
- editor.innerHTML = '<div class="empty">No item selected.</div>';
1541
- el("deleteItemBtn").disabled = true;
1542
- return;
1543
- }
1544
- el("deleteItemBtn").disabled = false;
1545
- const item = currentStack.items[selectedItemIndex];
1546
- optionsText = JSON.stringify(item.options || {}, null, 2);
1547
- optionsError = "";
1548
- const slotSelect = '<select id="itemSlot">' + slotNames.map((slot) => '<option value="' + attr(slot) + '">' + escapeHtml(slot) + '</option>').join("") + '</select>';
1549
- const roleSelect = '<select id="itemRole">' + roles.map((role) => '<option value="' + attr(role) + '">' + escapeHtml(role || "(none)") + '</option>').join("") + '</select>';
1550
- const kindSelect = '<select id="itemKind"><option value="block">block</option><option value="slot">slot</option></select>';
1551
- const topFields = '<div class="item-fields">' +
1552
- field("Kind", kindSelect) +
1553
- field("ID", '<input id="itemId" value="' + attr(item.id) + '">') +
1554
- field("Name", '<input id="itemName" value="' + attr(item.name || "") + '">') +
1555
- field("Role", roleSelect) +
1556
- (item.kind === "slot" ? field("Slot", slotSelect) : "") +
1557
- '</div>';
1558
- const body = item.kind === "block"
1559
- ? field("Content", '<textarea id="itemContent">' + escapeHtml(item.content || "") + '</textarea>', "content-field")
1560
- : renderSlotOptionsEditor(item);
1561
- editor.innerHTML = '<div class="item-form">' + topFields + '<div class="item-body">' + body + '</div></div>';
1562
-
1563
- el("itemKind").value = item.kind;
1564
- el("itemRole").value = item.role || "";
1565
- if (item.kind === "slot") el("itemSlot").value = item.slot || "chat-history";
1566
-
1567
- el("itemKind").onchange = (event) => {
1568
- if (event.target.value === item.kind) return;
1569
- const base = { id: item.id, name: item.name, enabled: item.enabled, role: item.role, tags: item.tags, source: item.source };
1570
- currentStack.items[selectedItemIndex] = event.target.value === "slot"
1571
- ? { ...base, kind: "slot", slot: "chat-history" }
1572
- : { ...base, kind: "block", content: "" };
1573
- markDirty();
1574
- renderItemList();
1575
- renderItemEditor();
1576
- };
1577
- el("itemId").oninput = (event) => { item.id = event.target.value; markDirty(); renderItemList(); };
1578
- el("itemName").oninput = (event) => { setOptionalString(item, "name", event.target.value); markDirty(); };
1579
- el("itemRole").onchange = (event) => { setOptionalString(item, "role", event.target.value); markDirty(); renderItemList(); };
1580
- if (item.kind === "block") {
1581
- el("itemContent").oninput = (event) => { item.content = event.target.value; markDirty(); };
1582
- } else {
1583
- el("itemSlot").onchange = (event) => { item.slot = event.target.value; markDirty(); renderItemList(); };
1584
- bindSlotOptionsEditor(item);
1585
- }
1586
- }
1587
-
1588
- function renderSlotOptionsEditor(item) {
1589
- const options = item.options || {};
1590
- const jsonActive = slotOptionsMode === "json";
1591
- const formButton = '<button id="slotOptionsFormBtn" type="button" class="' + (!jsonActive ? "active" : "") + '">Form</button>';
1592
- const jsonButton = '<button id="slotOptionsJsonBtn" type="button" class="' + (jsonActive ? "active" : "") + '">JSON</button>';
1593
- const body = jsonActive
1594
- ? '<textarea id="itemOptions" class="json-options">' + escapeHtml(optionsText) + '</textarea>'
1595
- : renderSlotOptionsForm(item, options);
1596
- return '<div class="field wide slot-options"><label>Slot options</label><div class="segmented">' + formButton + jsonButton + '</div>' + body + '</div>';
1597
- }
1598
-
1599
- function renderSlotOptionsForm(item, options) {
1600
- const fields = [];
1601
- if (item.slot === "chat-history") {
1602
- fields.push(
1603
- optionCheckbox("includeLastUserMessage", "Include last user message", options.includeLastUserMessage !== false),
1604
- optionCheckbox("stripAssistantThinking", "Strip assistant thinking", options.stripAssistantThinking === true),
1605
- optionCheckbox("includeSummaries", "Include summaries", options.includeSummaries !== false),
1606
- optionSelect("toolMode", "Tool history", options.toolMode || "keep", ["keep", "drop"]),
1607
- optionText("roles", "Roles", Array.isArray(options.roles) ? options.roles.join(", ") : ""),
1608
- optionNumber("maxMessages", "Max messages", options.maxMessages ?? ""),
1609
- optionNumber("maxChars", "Max chars", options.maxChars ?? ""),
1610
- );
1611
- }
1612
- if (item.slot === "variables") {
1613
- fields.push(
1614
- optionCheckbox("includeStatic", "Include static variables", options.includeStatic !== false),
1615
- optionCheckbox("includeSession", "Include session variables", options.includeSession !== false),
1616
- optionCheckbox("includeTurn", "Include turn variables", options.includeTurn !== false),
1617
- optionSelect("format", "Format", options.format || "xml", ["xml", "plain"]),
1618
- );
1619
- }
1620
- if (["tools", "tool-guidelines", "skills", "project-context"].includes(item.slot)) {
1621
- fields.push(optionSelect("format", "Format", options.format || "xml", ["xml", "plain"]));
1622
- }
1623
- if (["date", "date-cwd"].includes(item.slot)) {
1624
- fields.push(optionCheckbox("includeTime", "Include current time", options.includeTime === true));
1625
- }
1626
- if (fields.length === 0) {
1627
- fields.push('<div class="wide option-note">This slot has no structured options yet. Use JSON mode for advanced settings.</div>');
1628
- }
1629
- fields.push('<div class="wide option-note">Unknown option keys are preserved. Use JSON mode for advanced settings.</div>');
1630
- return '<div class="options-grid">' + fields.join("") + '</div>';
1631
- }
1632
-
1633
- function bindSlotOptionsEditor(item) {
1634
- el("slotOptionsFormBtn").onclick = () => {
1635
- slotOptionsMode = "form";
1636
- renderItemEditor();
1637
- };
1638
- el("slotOptionsJsonBtn").onclick = () => {
1639
- slotOptionsMode = "json";
1640
- renderItemEditor();
1641
- };
1642
-
1643
- if (slotOptionsMode === "json") {
1644
- el("itemOptions").oninput = (event) => {
1645
- optionsText = event.target.value;
1646
- try {
1647
- const parsed = optionsText.trim() ? JSON.parse(optionsText) : {};
1648
- item.options = Object.keys(parsed).length ? parsed : undefined;
1649
- optionsError = "";
1650
- markDirty();
1651
- } catch (error) {
1652
- optionsError = error.message;
1653
- setStatus("Invalid item options JSON", "error");
1654
- }
1655
- };
1656
- return;
1657
- }
1658
-
1659
- document.querySelectorAll("[data-option]").forEach((control) => {
1660
- control.onchange = (event) => {
1661
- const target = event.target;
1662
- const key = target.dataset.option;
1663
- if (!key) return;
1664
- if (target.type === "checkbox") {
1665
- setSlotOption(item, key, target.checked, defaultSlotOptionValue(key));
1666
- } else if (target.type === "number") {
1667
- const value = target.value.trim();
1668
- setSlotOption(item, key, value ? Number(value) : undefined);
1669
- } else if (target.dataset.array === "true") {
1670
- const values = target.value.split(",").map((part) => part.trim()).filter(Boolean);
1671
- setSlotOption(item, key, values.length ? values : undefined);
1672
- } else {
1673
- setSlotOption(item, key, target.value || undefined, defaultSlotOptionValue(key));
1674
- }
1675
- markDirty();
1676
- };
1677
- });
1678
- }
1679
-
1680
- function setSlotOption(item, key, value, defaultValue) {
1681
- const options = { ...(item.options || {}) };
1682
- if (value === undefined || value === defaultValue) delete options[key];
1683
- else options[key] = value;
1684
- item.options = Object.keys(options).length ? options : undefined;
1685
- }
1686
-
1687
- function defaultSlotOptionValue(key) {
1688
- if (["includeLastUserMessage", "includeSummaries", "includeStatic", "includeSession", "includeTurn"].includes(key)) return true;
1689
- if (["stripAssistantThinking", "includeTime"].includes(key)) return false;
1690
- if (key === "toolMode") return "keep";
1691
- if (key === "format") return "xml";
1692
- return undefined;
1693
- }
1694
-
1695
- function optionCheckbox(key, label, checked) {
1696
- return '<label class="checkline" title="' + attr(optionHelp(key)) + '"><input type="checkbox" data-option="' + attr(key) + '" ' + (checked ? "checked" : "") + '> ' + escapeHtml(label) + '</label>';
1697
- }
1698
-
1699
- function optionSelect(key, label, value, choices) {
1700
- return '<div class="field" title="' + attr(optionHelp(key)) + '"><label>' + escapeHtml(label) + '</label><select data-option="' + attr(key) + '">' +
1701
- choices.map((choice) => '<option value="' + attr(choice) + '"' + (choice === value ? " selected" : "") + '>' + escapeHtml(choice) + '</option>').join("") +
1702
- '</select></div>';
1703
- }
1704
-
1705
- function optionText(key, label, value) {
1706
- return '<div class="field" title="' + attr(optionHelp(key)) + '"><label>' + escapeHtml(label) + '</label><input data-option="' + attr(key) + '" data-array="true" value="' + attr(value) + '" placeholder="comma,separated"></div>';
1707
- }
1708
-
1709
- function optionNumber(key, label, value) {
1710
- return '<div class="field" title="' + attr(optionHelp(key)) + '"><label>' + escapeHtml(label) + '</label><input type="number" min="1" data-option="' + attr(key) + '" value="' + attr(value) + '"></div>';
1711
- }
1712
-
1713
- function optionHelp(key) {
1714
- const descriptions = {
1715
- includeLastUserMessage: "Keep the latest user message inside the inserted chat history.",
1716
- stripAssistantThinking: "Remove prior assistant thinking blocks from inserted chat history while keeping visible text, tool calls, and tool results.",
1717
- includeSummaries: "Keep Pi branch and compaction summary messages inside inserted chat history.",
1718
- toolMode: "Keep tool calls/results or drop prior tool history from inserted chat history.",
1719
- roles: "Optional comma-separated message roles to keep, such as user, assistant, toolResult, compactionSummary.",
1720
- maxMessages: "Keep only the most recent N chat-history messages after filtering.",
1721
- maxChars: "Keep only the most recent chat-history messages within an approximate character budget.",
1722
- includeStatic: "Include static stack variables in this variables slot.",
1723
- includeSession: "Include session variables created by template macros.",
1724
- includeTurn: "Include temporary turn variables created during prompt compilation.",
1725
- includeTime: "Render the current time in HH:MM:SS after the current date.",
1726
- format: "Choose XML or compact plain text rendering.",
1727
- };
1728
- return descriptions[key] || "Advanced slot option.";
1729
- }
1730
-
1731
- function showStackModal(title, meta, body, options = {}) {
1732
- const pane = el("stackModal");
1733
- pane.innerHTML = '<div class="modal-dialog" role="dialog" aria-modal="true" aria-label="' + attr(title) + '">' +
1734
- '<div class="modal-head"><div><div class="modal-title">' + escapeHtml(title) + '</div><div class="modal-meta">' + escapeHtml(meta || "") + '</div></div>' +
1735
- '<div class="modal-actions"><button data-modal-close="true" data-icon="×" title="Close this dialog">Close</button></div></div>' +
1736
- '<div class="modal-body ' + attr(options.bodyClass || "") + '">' + body + '</div></div>';
1737
- pane.classList.add("open");
1738
- }
1739
-
1740
- function closeStackModal() {
1741
- const pane = el("stackModal");
1742
- pane.classList.remove("open");
1743
- pane.innerHTML = "";
1744
- }
1745
-
1746
- function openContextEditor() {
1747
- if (!currentStack) return;
1748
- showStackModal(
1749
- "Context options",
1750
- "Stack-level behavior for how pi-forge rewrites Pi conversation context.",
1751
- '<div class="modal-toolbar"><span class="modal-meta">Save writes these changes to the stack JSON.</span></div>' +
1752
- '<div class="data-table">' +
1753
- '<div class="data-row">' +
1754
- '<label class="checkline" title="Allow multiple enabled chat-history slots. When off, only the first enabled chat-history slot is expanded.">' +
1755
- '<input id="allowDuplicateChatHistoryInput" type="checkbox" ' + (currentStack.context?.allowDuplicateChatHistory === true ? "checked" : "") + '> Allow duplicate chat-history slots</label>' +
1756
- '<div class="option-note">Keep this off unless you intentionally want the same conversation history injected more than once.</div>' +
1757
- '</div>' +
1758
- '</div>',
1759
- );
1760
- el("allowDuplicateChatHistoryInput").onchange = (event) => {
1761
- setContextOption("allowDuplicateChatHistory", event.target.checked, false);
1762
- markDirty();
1763
- };
1764
- }
1765
-
1766
- function setContextOption(key, value, defaultValue) {
1767
- const context = { ...(currentStack.context || {}) };
1768
- if (value === defaultValue || value === undefined) delete context[key];
1769
- else context[key] = value;
1770
- if (Object.keys(context).length) currentStack.context = context;
1771
- else delete currentStack.context;
1772
- }
1773
-
1774
- function renderRegexTab() {
1775
- if (!currentStack) return;
1776
- el("tabPanel").innerHTML = '<div class="tab-section">' + regexEditorBody() + '</div>';
1777
- bindRegexEditor();
1778
- }
1779
-
1780
- function renderPolicyTab() {
1781
- if (!currentStack) return;
1782
- el("tabPanel").innerHTML =
1783
- '<div class="tab-section">' +
1784
- '<div class="tab-section-title">Tool and skill policy</div>' +
1785
- '<div class="tab-section-meta">Choose one mode per resource. Patterns support exact names and * wildcards.</div>' +
1786
- '<div class="data-table" id="policyRows">' +
1787
- '<div class="data-row header policy-row"><div>Resource</div><div>Mode</div><div>Patterns</div><div>Available</div><div>Status</div></div>' +
1788
- policyRowHtml("tools", "Tools") +
1789
- policyRowHtml("skills", "Skills") +
1790
- '</div>' +
1791
- '</div>';
1792
- bindPolicyEditor();
1793
- }
1794
-
1795
- function policyRowHtml(kind, label) {
1796
- const policy = stackPolicyObject(kind);
1797
- const mode = policyMode(policy);
1798
- const rawPatterns = mode === "deny" ? policy.deny : mode === "allow" ? policy.allow : [];
1799
- const patterns = Array.isArray(rawPatterns) ? rawPatterns : [];
1800
- const patternText = policyPatternsToText(patterns);
1801
- const disabled = mode === "none" ? " disabled" : "";
1802
- return '<div class="data-row policy-row" data-policy-row data-policy-kind="' + attr(kind) + '" data-policy-mode="' + attr(mode) + '">' +
1803
- '<div><div class="policy-title">' + escapeHtml(label) + '</div><div class="modal-meta">' + escapeHtml(kind) + '</div></div>' +
1804
- '<div class="field"><label>Mode</label><div class="segmented policy-mode">' +
1805
- policyModeButton("none", "Unrestricted", mode) +
1806
- policyModeButton("allow", "Allow", mode) +
1807
- policyModeButton("deny", "Deny", mode) +
1808
- '</div></div>' +
1809
- '<div class="field"><label>Patterns</label>' + selectedPolicyPatternsHtml(patterns) + '<textarea class="policy-patterns" data-policy-patterns spellcheck="false" placeholder="' + attr(policyPatternPlaceholder(mode)) + '"' + disabled + '>' + escapeHtml(patternText) + '</textarea></div>' +
1810
- resourcePickerHtml(kind, patterns) +
1811
- '<div class="policy-summary" data-policy-summary>' + escapeHtml(policySummary(kind, policy)) + '</div>' +
1812
- '</div>';
1813
- }
1814
-
1815
- function selectedPolicyPatternsHtml(patterns) {
1816
- return '<div class="selected-patterns" data-selected-patterns>' + selectedPolicyPatternButtonsHtml(patterns) + '</div>';
1817
- }
1818
-
1819
- function selectedPolicyPatternButtonsHtml(patterns) {
1820
- if (!patterns.length) return '<span class="selected-pattern-empty">No selected patterns.</span>';
1821
- return patterns.map((pattern) =>
1822
- '<button type="button" class="selected-pattern-chip" data-remove-policy-pattern="' + attr(pattern) + '" title="Remove selected pattern">' +
1823
- escapeHtml(pattern) + '<span aria-hidden="true">x</span></button>'
1824
- ).join("");
1825
- }
1826
-
1827
- function resourcePickerHtml(kind, selectedPatterns = []) {
1828
- const resources = policyResources[kind] || [];
1829
- if (!resources.length) {
1830
- return '<div class="resource-picker"><label>Available ' + escapeHtml(kind) + '</label><div class="resource-empty">No registered ' + escapeHtml(kind) + ' reported.</div></div>';
1831
- }
1832
- const listId = "resource-options-" + kind;
1833
- return '<div class="resource-picker"><label>Available ' + escapeHtml(kind) + '</label><div>' +
1834
- '<input class="resource-filter" data-resource-filter list="' + attr(listId) + '" placeholder="Type to filter or add">' +
1835
- '<datalist id="' + attr(listId) + '" data-resource-options>' + resourceOptionsHtml(kind, selectedPatterns) + '</datalist>' +
1836
- '<div class="resource-list" data-resource-list>' + resourceListHtml(kind, selectedPatterns) + '</div>' +
1837
- '</div></div>';
1838
- }
1839
-
1840
- function resourceOptionsHtml(kind, selectedPatterns = []) {
1841
- return availablePolicyResources(kind, selectedPatterns)
1842
- .map((resource) => '<option value="' + attr(resource.name) + '"></option>')
1843
- .join("");
1844
- }
1845
-
1846
- function resourceListHtml(kind, selectedPatterns = [], filter = "") {
1847
- const resources = availablePolicyResources(kind, selectedPatterns, filter);
1848
- if (!resources.length) return '<div class="resource-empty">No matching unselected ' + escapeHtml(kind) + '.</div>';
1849
- return resources.map((resource) => resourceChipHtml(resource)).join("");
1850
- }
1851
-
1852
- function availablePolicyResources(kind, selectedPatterns = [], filter = "") {
1853
- const selected = new Set(selectedPatterns);
1854
- const needle = filter.trim().toLowerCase();
1855
- return (policyResources[kind] || [])
1856
- .filter((resource) => !selected.has(resource.name))
1857
- .filter((resource) => !needle || policyResourceMatchesFilter(resource, needle));
1858
- }
1859
-
1860
- function policyResourceMatchesFilter(resource, needle) {
1861
- return [resource.name, resource.description, resource.source]
1862
- .filter(Boolean)
1863
- .some((value) => String(value).toLowerCase().includes(needle));
1864
- }
1865
-
1866
- function resourceChipHtml(resource) {
1867
- const classes = ["resource-chip"];
1868
- if (resource.active) classes.push("active");
1869
- if (resource.hidden) classes.push("hidden");
1870
- const notes = [
1871
- resource.description,
1872
- resource.source ? "Source: " + resource.source : "",
1873
- resource.active ? "Active tool" : "",
1874
- resource.hidden ? "Hidden from model invocation" : "",
1875
- ].filter(Boolean);
1876
- const suffix = resource.active ? " *" : resource.hidden ? " hidden" : "";
1877
- return '<button type="button" class="' + attr(classes.join(" ")) + '" data-resource-name="' + attr(resource.name) + '" title="' + attr(notes.join("\n") || resource.name) + '">' +
1878
- escapeHtml(resource.name + suffix) +
1879
- '</button>';
1880
- }
1881
-
1882
- function policyModeButton(value, label, current) {
1883
- return '<button type="button" data-policy-mode-option="' + attr(value) + '" class="' + (value === current ? "active" : "") + '">' + escapeHtml(label) + '</button>';
1884
- }
1885
-
1886
- function bindPolicyEditor() {
1887
- const rows = el("policyRows");
1888
- rows.onclick = (event) => {
1889
- const modeButton = event.target.closest?.("[data-policy-mode-option]");
1890
- if (modeButton) {
1891
- const row = modeButton.closest("[data-policy-row]");
1892
- setPolicyRowMode(row, modeButton.dataset.policyModeOption);
1893
- if (modeButton.dataset.policyModeOption === "none") row.querySelector("[data-policy-patterns]").value = "";
1894
- syncResourcePolicyFromTab();
1895
- return;
1896
- }
1897
-
1898
- const removeButton = event.target.closest?.("[data-remove-policy-pattern]");
1899
- if (removeButton) {
1900
- const row = removeButton.closest("[data-policy-row]");
1901
- removePolicyPattern(row, removeButton.dataset.removePolicyPattern || "");
1902
- syncResourcePolicyFromTab();
1903
- return;
1904
- }
1905
-
1906
- const resourceButton = event.target.closest?.("[data-resource-name]");
1907
- if (resourceButton) {
1908
- const row = resourceButton.closest("[data-policy-row]");
1909
- addPolicyPattern(row, resourceButton.dataset.resourceName || "");
1910
- clearPolicyResourceFilter(row);
1911
- syncResourcePolicyFromTab();
1912
- }
1913
- };
1914
- rows.oninput = (event) => {
1915
- const target = event.target;
1916
- if (target.matches?.("[data-policy-patterns]")) {
1917
- syncResourcePolicyFromTab();
1918
- return;
1919
- }
1920
- if (target.matches?.("[data-resource-filter]")) {
1921
- refreshPolicyResourceControls(target.closest("[data-policy-row]"));
1922
- }
1923
- };
1924
- rows.onkeydown = (event) => {
1925
- const target = event.target;
1926
- if (!target.matches?.("[data-resource-filter]") || event.key !== "Enter") return;
1927
- event.preventDefault();
1928
- const row = target.closest("[data-policy-row]");
1929
- const name = policyResourceAutocompleteValue(row, target.value);
1930
- if (!name) return;
1931
- addPolicyPattern(row, name);
1932
- target.value = "";
1933
- syncResourcePolicyFromTab();
1934
- };
1935
- refreshPolicySummaries();
1936
- refreshPolicyResourceControls();
1937
- }
1938
-
1939
- function clearPolicyResourceFilter(row) {
1940
- const filter = row?.querySelector("[data-resource-filter]");
1941
- if (filter) filter.value = "";
1942
- }
1943
-
1944
- function policyResourceAutocompleteValue(row, value) {
1945
- if (!row) return "";
1946
- const typed = value.trim();
1947
- if (!typed) return "";
1948
- const kind = row.dataset.policyKind;
1949
- const selected = selectedPolicyPatterns(row);
1950
- const resources = availablePolicyResources(kind, selected, typed);
1951
- const exact = resources.find((resource) => resource.name.toLowerCase() === typed.toLowerCase());
1952
- return exact?.name || resources[0]?.name || typed;
1953
- }
1954
-
1955
- function removePolicyPattern(row, pattern) {
1956
- if (!row || !pattern) return;
1957
- const textarea = row.querySelector("[data-policy-patterns]");
1958
- const patterns = parsePolicyPatterns(textarea.value).filter((candidate) => candidate !== pattern);
1959
- textarea.value = patterns.join("\n");
1960
- }
1961
-
1962
- function refreshPolicyResourceControls(root = document) {
1963
- const rows = root.matches?.("[data-policy-row]") ? [root] : root.querySelectorAll("[data-policy-row]");
1964
- rows.forEach((row) => {
1965
- const kind = row.dataset.policyKind;
1966
- const patterns = selectedPolicyPatterns(row);
1967
- const selected = row.querySelector("[data-selected-patterns]");
1968
- if (selected) selected.innerHTML = selectedPolicyPatternButtonsHtml(patterns);
1969
- const filter = row.querySelector("[data-resource-filter]")?.value || "";
1970
- const options = row.querySelector("[data-resource-options]");
1971
- if (options) options.innerHTML = resourceOptionsHtml(kind, patterns);
1972
- const list = row.querySelector("[data-resource-list]");
1973
- if (list) list.innerHTML = resourceListHtml(kind, patterns, filter);
1974
- });
1975
- }
1976
-
1977
- function selectedPolicyPatterns(row) {
1978
- if (!row || (row.dataset.policyMode || "none") === "none") return [];
1979
- return parsePolicyPatterns(row.querySelector("[data-policy-patterns]")?.value || "");
1980
- }
1981
-
1982
- function addPolicyPattern(row, name) {
1983
- if (!row || !name) return;
1984
- if ((row.dataset.policyMode || "none") === "none") setPolicyRowMode(row, "allow");
1985
- const textarea = row.querySelector("[data-policy-patterns]");
1986
- const patterns = parsePolicyPatterns(textarea.value);
1987
- if (!patterns.includes(name)) patterns.push(name);
1988
- textarea.value = patterns.join("\n");
1989
- }
1990
-
1991
- function syncResourcePolicyFromTab() {
1992
- if (!currentStack) return;
1993
- const errors = [];
1994
- document.querySelectorAll("[data-policy-row]").forEach((row) => {
1995
- const kind = row.dataset.policyKind;
1996
- const mode = row.dataset.policyMode || "none";
1997
- const patterns = mode === "none" ? [] : parsePolicyPatterns(row.querySelector("[data-policy-patterns]").value);
1998
- const duplicate = duplicatePolicyPattern(patterns);
1999
- if (duplicate) errors.push(kind + "." + mode + " has duplicate pattern: " + duplicate);
2000
- const policy = { ...stackPolicyObject(kind) };
2001
- delete policy.allow;
2002
- delete policy.deny;
2003
- if (mode === "allow" && patterns.length) policy.allow = patterns;
2004
- if (mode === "deny" && patterns.length) policy.deny = patterns;
2005
- if (Object.keys(policy).length) currentStack[kind] = policy;
2006
- else delete currentStack[kind];
2007
- setPolicyRowMode(row, mode);
2008
- });
2009
- stackPolicyError = errors[0] || "";
2010
- markDirty();
2011
- refreshPolicySummaries();
2012
- refreshPolicyResourceControls();
2013
- if (stackPolicyError) setStatus(stackPolicyError, "error");
2014
- }
2015
-
2016
- function stackPolicyObject(kind) {
2017
- const policy = currentStack?.[kind];
2018
- return policy && typeof policy === "object" && !Array.isArray(policy) ? policy : {};
2019
- }
2020
-
2021
- function policyMode(policy) {
2022
- const allow = Array.isArray(policy.allow) ? policy.allow : [];
2023
- const deny = Array.isArray(policy.deny) ? policy.deny : [];
2024
- if (deny.length && !allow.length) return "deny";
2025
- if (allow.length) return "allow";
2026
- return "none";
2027
- }
2028
-
2029
- function setPolicyRowMode(row, mode) {
2030
- if (!row) return;
2031
- row.dataset.policyMode = mode || "none";
2032
- row.querySelectorAll("[data-policy-mode-option]").forEach((button) => {
2033
- button.classList.toggle("active", button.dataset.policyModeOption === row.dataset.policyMode);
2034
- });
2035
- const patterns = row.querySelector("[data-policy-patterns]");
2036
- if (!patterns) return;
2037
- patterns.disabled = row.dataset.policyMode === "none";
2038
- patterns.placeholder = policyPatternPlaceholder(row.dataset.policyMode);
2039
- }
2040
-
2041
- function policyPatternPlaceholder(mode) {
2042
- if (mode === "allow") return "read\nbrowser-*";
2043
- if (mode === "deny") return "browser-danger\nlegacy-*";
2044
- return "";
2045
- }
2046
-
2047
- function policyPatternsToText(patterns) {
2048
- return Array.isArray(patterns) ? patterns.join("\n") : "";
2049
- }
2050
-
2051
- function parsePolicyPatterns(value) {
2052
- return String(value || "")
2053
- .split(/[\n,]/)
2054
- .map((pattern) => pattern.trim())
2055
- .filter(Boolean);
2056
- }
2057
-
2058
- function duplicatePolicyPattern(patterns) {
2059
- const seen = new Set();
2060
- for (const pattern of patterns) {
2061
- if (seen.has(pattern)) return pattern;
2062
- seen.add(pattern);
2063
- }
2064
- return "";
2065
- }
2066
-
2067
- function refreshPolicySummaries() {
2068
- document.querySelectorAll("[data-policy-row]").forEach((row) => {
2069
- const summary = row.querySelector("[data-policy-summary]");
2070
- if (!summary) return;
2071
- const kind = row.dataset.policyKind;
2072
- const mode = row.dataset.policyMode || "none";
2073
- const patterns = mode === "none" ? [] : parsePolicyPatterns(row.querySelector("[data-policy-patterns]").value);
2074
- const policy = mode === "allow" ? { allow: patterns } : mode === "deny" ? { deny: patterns } : {};
2075
- summary.textContent = policySummary(kind, policy);
2076
- setPolicyRowMode(row, mode);
2077
- });
2078
- }
2079
-
2080
- function policySummary(kind, policy) {
2081
- const allow = Array.isArray(policy.allow) ? policy.allow : [];
2082
- const deny = Array.isArray(policy.deny) ? policy.deny : [];
2083
- if (allow.length && deny.length) return "Invalid mixed policy.";
2084
- if (allow.some((pattern) => pattern !== "*")) return "Allow list active: " + allow.length + " pattern" + (allow.length === 1 ? "" : "s") + ".";
2085
- if (allow.length) return "Unrestricted " + kind + ".";
2086
- if (deny.length) return "Deny list active: " + deny.length + " pattern" + (deny.length === 1 ? "" : "s") + ".";
2087
- return "Unrestricted " + kind + ".";
2088
- }
2089
-
2090
- function renderStackTab() {
2091
- if (!currentStack) return;
2092
- const json = JSON.stringify(stackForDisplay(), null, 2);
2093
- const variableRows = Object.entries(currentStack.variables || {})
2094
- .sort(([a], [b]) => a.localeCompare(b))
2095
- .map(([name, value]) => variableRowHtml(name, value))
2096
- .join("");
2097
- el("tabPanel").innerHTML =
2098
- '<div class="tab-section">' +
2099
- '<div class="tab-section-title">Context options</div>' +
2100
- '<div class="tab-section-meta">Stack-level behavior for how pi-forge rewrites Pi conversation context.</div>' +
2101
- '<label class="checkline" title="Allow multiple enabled chat-history slots. When off, only the first enabled chat-history slot is expanded.">' +
2102
- '<input id="allowDuplicateChatHistoryInput" type="checkbox" ' + (currentStack.context?.allowDuplicateChatHistory === true ? "checked" : "") + '> Allow duplicate chat-history slots</label>' +
2103
- '<div class="option-note">Keep this off unless you intentionally want the same conversation history injected more than once.</div>' +
2104
- '</div>' +
2105
- '<div class="tab-section">' +
2106
- '<div class="tab-section-title">Stack variables</div>' +
2107
- '<div class="tab-section-meta">Static string variables available to template macros and variables slots.</div>' +
2108
- '<div class="modal-toolbar"><button id="addVariableBtn" data-icon="+" title="Add a static stack variable">Add variable</button><span class="modal-spacer"></span><span class="modal-meta">Saved in stack.variables.</span></div>' +
2109
- '<div class="data-table" id="variablesRows">' +
2110
- '<div class="data-row header variable-row"><div>Name</div><div>Value</div><div></div></div>' +
2111
- variableRows +
2112
- '</div></div>' +
2113
- '<div class="tab-section">' +
2114
- '<div class="tab-section-title">Stack JSON</div>' +
2115
- '<div class="tab-section-meta">Raw recovery view for advanced fields. Apply updates the editor; Save writes to disk.</div>' +
2116
- '<div class="modal-toolbar">' +
2117
- '<button id="copyStackJsonBtn" data-icon="□" title="Copy this JSON to the clipboard">Copy</button>' +
2118
- '<button id="applyStackJsonBtn" class="primary" data-icon="✓" title="Apply this JSON to the editor without saving">Apply to editor</button>' +
2119
- '<span class="modal-spacer"></span><span id="stackJsonStatus" class="modal-meta">Unsaved stack JSON draft.</span>' +
2120
- '</div>' +
2121
- '<textarea id="stackJsonText" class="raw-json-editor" spellcheck="false">' + escapeHtml(json) + '</textarea>' +
2122
- '</div>';
2123
- el("allowDuplicateChatHistoryInput").onchange = (event) => {
2124
- setContextOption("allowDuplicateChatHistory", event.target.checked, false);
2125
- markDirty();
2126
- };
2127
- bindVariablesEditor();
2128
- el("copyStackJsonBtn").onclick = () => run(copyRawStackJson);
2129
- el("applyStackJsonBtn").onclick = () => run(applyRawStackJson);
2130
- }
2131
-
2132
- function openRawStackJsonEditor() {
2133
- if (!currentStack) return;
2134
- const json = JSON.stringify(stackForDisplay(), null, 2);
2135
- showStackModal(
2136
- "Stack JSON",
2137
- "Raw recovery view for advanced fields. Apply updates the editor; Save writes to disk.",
2138
- '<div class="modal-toolbar">' +
2139
- '<button id="copyStackJsonBtn" data-icon="□" title="Copy this JSON to the clipboard">Copy</button>' +
2140
- '<button id="applyStackJsonBtn" class="primary" data-icon="✓" title="Apply this JSON to the editor without saving">Apply to editor</button>' +
2141
- '<span class="modal-spacer"></span><span id="stackJsonStatus" class="modal-meta">Unsaved stack JSON draft.</span>' +
2142
- '</div>' +
2143
- '<textarea id="stackJsonText" class="raw-json-editor" spellcheck="false">' + escapeHtml(json) + '</textarea>',
2144
- { bodyClass: "json-modal" },
2145
- );
2146
- el("copyStackJsonBtn").onclick = () => run(copyRawStackJson);
2147
- el("applyStackJsonBtn").onclick = () => run(applyRawStackJson);
2148
- }
2149
-
2150
- function stackForDisplay() {
2151
- if (!currentStack) throw new Error("No stack selected.");
2152
- const clone = structuredClone(currentStack);
2153
- if (!clone.type) clone.type = "pi-forge.prompt-stack";
2154
- if (!clone.schemaVersion) clone.schemaVersion = 1;
2155
- return clone;
2156
- }
2157
-
2158
- async function copyRawStackJson() {
2159
- await copyTextToClipboard(el("stackJsonText").value);
2160
- el("stackJsonStatus").textContent = "Copied JSON.";
2161
- setStatus("Copied stack JSON", "success");
2162
- }
2163
-
2164
- async function applyRawStackJson() {
2165
- const text = el("stackJsonText").value;
2166
- const parsed = JSON.parse(text);
2167
- validateRawStackJson(parsed);
2168
- currentStack = parsed;
2169
- if (!currentStack.schemaVersion) currentStack.schemaVersion = 1;
2170
- if (!currentStack.type) currentStack.type = "pi-forge.prompt-stack";
2171
- selectedItemIndex = currentStack.items.length ? Math.min(Math.max(selectedItemIndex, 0), currentStack.items.length - 1) : -1;
2172
- optionsError = "";
2173
- stackVariablesError = "";
2174
- regexRulesError = "";
2175
- stackPolicyError = "";
2176
- closeStackModal();
2177
- markDirty();
2178
- renderAll(latestDiagnostics);
2179
- setStatus("Applied stack JSON to editor", "success");
2180
- }
2181
-
2182
- function validateRawStackJson(stack) {
2183
- if (!stack || typeof stack !== "object" || Array.isArray(stack)) throw new Error("Stack JSON must be an object.");
2184
- if (typeof stack.id !== "string" || !stack.id.trim()) throw new Error("Stack JSON needs a non-empty string id.");
2185
- if (!Array.isArray(stack.items)) throw new Error("Stack JSON needs an items array.");
2186
- stack.items.forEach((item, index) => {
2187
- const label = "Item " + (index + 1);
2188
- if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(label + " must be an object.");
2189
- if (item.kind !== "block" && item.kind !== "slot") throw new Error(label + " kind must be block or slot.");
2190
- if (typeof item.id !== "string" || !item.id.trim()) throw new Error(label + " needs a non-empty string id.");
2191
- if (item.kind === "block" && typeof item.content !== "string") throw new Error(label + " block content must be a string.");
2192
- if (item.kind === "slot" && typeof item.slot !== "string") throw new Error(label + " slot must be a string.");
2193
- });
2194
- }
2195
-
2196
- function openVariablesEditor() {
2197
- if (!currentStack) return;
2198
- const rows = Object.entries(currentStack.variables || {})
2199
- .sort(([a], [b]) => a.localeCompare(b))
2200
- .map(([name, value]) => variableRowHtml(name, value))
2201
- .join("");
2202
- showStackModal(
2203
- "Stack variables",
2204
- "Static string variables available to macros and variables slots.",
2205
- '<div class="modal-toolbar"><button id="addVariableBtn" data-icon="+" title="Add a static stack variable">Add variable</button><span class="modal-spacer"></span><span class="modal-meta">Save writes these changes to the stack JSON.</span></div>' +
2206
- '<div class="data-table" id="variablesRows">' +
2207
- '<div class="data-row header variable-row"><div>Name</div><div>Value</div><div></div></div>' +
2208
- rows +
2209
- '</div>',
2210
- );
2211
- bindVariablesEditor();
2212
- }
2213
-
2214
- function variableRowHtml(name = "", value = "") {
2215
- return '<div class="data-row variable-row" data-var-row>' +
2216
- '<input data-var-name value="' + attr(name) + '" placeholder="char">' +
2217
- '<input data-var-value value="' + attr(value) + '" placeholder="泉此方">' +
2218
- '<button type="button" class="danger" data-delete-row="true" data-icon="×" title="Delete this stack variable">Delete</button>' +
2219
- '</div>';
2220
- }
2221
-
2222
- function bindVariablesEditor() {
2223
- el("addVariableBtn").onclick = () => {
2224
- el("variablesRows").insertAdjacentHTML("beforeend", variableRowHtml(uniqueVariableName()));
2225
- bindVariablesEditor();
2226
- syncVariablesFromModal();
2227
- };
2228
- document.querySelectorAll("[data-var-row] input").forEach((input) => {
2229
- input.oninput = () => syncVariablesFromModal();
2230
- });
2231
- document.querySelectorAll("[data-var-row] [data-delete-row]").forEach((button) => {
2232
- button.onclick = (event) => {
2233
- event.target.closest("[data-var-row]").remove();
2234
- syncVariablesFromModal();
2235
- };
2236
- });
2237
- }
2238
-
2239
- function uniqueVariableName() {
2240
- const existing = new Set(Object.keys(currentStack?.variables || {}));
2241
- let index = existing.size + 1;
2242
- let name = "var" + index;
2243
- while (existing.has(name)) name = "var" + (++index);
2244
- return name;
2245
- }
2246
-
2247
- function syncVariablesFromModal() {
2248
- if (!currentStack) return;
2249
- const variables = {};
2250
- const seen = new Set();
2251
- let duplicate = false;
2252
- document.querySelectorAll("[data-var-row]").forEach((row) => {
2253
- const name = row.querySelector("[data-var-name]").value.trim();
2254
- const value = row.querySelector("[data-var-value]").value;
2255
- if (!name) return;
2256
- if (seen.has(name)) duplicate = true;
2257
- seen.add(name);
2258
- variables[name] = value;
2259
- });
2260
- if (Object.keys(variables).length) currentStack.variables = variables;
2261
- else delete currentStack.variables;
2262
- stackVariablesError = duplicate ? "Duplicate stack variable names." : "";
2263
- markDirty();
2264
- if (duplicate) setStatus(stackVariablesError, "error");
2265
- }
2266
-
2267
- function openRegexEditor() {
2268
- if (!currentStack) return;
2269
- showStackModal(
2270
- "Regex rules",
2271
- "Ordered JavaScript RegExp replacements for outgoing prompt text and finalized assistant messages.",
2272
- regexEditorBody(),
2273
- );
2274
- bindRegexEditor();
2275
- }
2276
-
2277
- function regexEditorBody() {
2278
- const rules = Array.isArray(currentStack?.regex?.rules) ? currentStack.regex.rules : [];
2279
- const rows = rules.map((rule) => regexRuleRowHtml(rule)).join("");
2280
- return '<div class="tab-section-title">Regex rules</div>' +
2281
- '<div class="tab-section-meta">Ordered JavaScript RegExp replacements for outgoing prompt text and finalized assistant messages.</div>' +
2282
- '<div class="modal-toolbar"><button id="addRegexRuleBtn" data-icon="+" title="Add a regex rule">Add rule</button><button id="validateRegexRulesBtn" data-icon="!" title="Validate the edited stack">Validate</button><span class="modal-spacer"></span><span class="modal-meta">Save writes these rules to stack.regex.rules.</span></div>' +
2283
- '<div class="data-table" id="regexRows">' + rows + '</div>';
2284
- }
2285
-
2286
- function regexRuleRowHtml(rule = {}) {
2287
- const original = JSON.stringify(rule || {});
2288
- const enabled = rule.enabled !== false;
2289
- const targets = Array.isArray(rule.targets) ? rule.targets : [];
2290
- const roles = Array.isArray(rule.roles) ? rule.roles : [];
2291
- return '<div class="data-row regex-row" data-regex-row>' +
2292
- '<div class="regex-controls">' +
2293
- '<button type="button" data-regex-up="true" data-icon="↑" title="Move this rule up">Up</button>' +
2294
- '<button type="button" data-regex-down="true" data-icon="↓" title="Move this rule down">Down</button>' +
2295
- '</div>' +
2296
- '<div class="regex-fields">' +
2297
- '<textarea data-regex-original hidden>' + escapeHtml(original) + '</textarea>' +
2298
- '<label class="checkline"><input type="checkbox" data-regex-enabled ' + (enabled ? "checked" : "") + '> Enabled</label>' +
2299
- regexTextField("data-regex-id", "ID", rule.id || "", "trim-ooc") +
2300
- regexTextField("data-regex-name", "Name", rule.name || "", "Readable label") +
2301
- regexSelect("data-regex-stage", "Stage", rule.stage || "compiled", regexStages) +
2302
- regexSelect("data-regex-effect", "Effect", rule.effect || "outgoing", regexEffects) +
2303
- regexTextField("data-regex-flags", "Flags", rule.flags || "", "gimsu") +
2304
- regexCheckGroup("Targets", "data-regex-target", regexTargets, targets, "compiled only; empty means default") +
2305
- regexCheckGroup("Roles", "data-regex-role", regexRoles, roles, "message rules only; empty means all roles") +
2306
- regexNumberField("data-regex-max-messages", "Max messages", rule.maxMessages ?? "") +
2307
- regexNumberField("data-regex-max-chars", "Max chars", rule.maxChars ?? "") +
2308
- regexNumberField("data-regex-min-depth", "Min depth", rule.minDepth ?? "", 0) +
2309
- regexNumberField("data-regex-max-depth", "Max depth", rule.maxDepth ?? "", 0) +
2310
- regexTextArea("data-regex-trim-strings", "Trim strings", Array.isArray(rule.trimStrings) ? rule.trimStrings.join("\n") : "", "span-2", "one per line") +
2311
- regexTextArea("data-regex-pattern", "Pattern", rule.pattern || "", "span-3", "\\\\(OOC:[^)]+\\\\)") +
2312
- regexTextArea("data-regex-replace", "Replace", rule.replace || "", "span-3", "") +
2313
- '<div class="regex-warning wide" data-regex-warning>' + escapeHtml(regexRuleWarning(rule)) + '</div>' +
2314
- '</div>' +
2315
- '<button type="button" class="danger" data-delete-row="true" data-icon="×" title="Delete this regex rule">Delete</button>' +
2316
- '</div>';
2317
- }
2318
-
2319
- function regexTextField(attribute, label, value, placeholder = "") {
2320
- return '<div class="field"><label>' + escapeHtml(label) + '</label><input ' + attribute + ' value="' + attr(value) + '" placeholder="' + attr(placeholder) + '"></div>';
2321
- }
2322
-
2323
- function regexNumberField(attribute, label, value, min = 1) {
2324
- return '<div class="field"><label>' + escapeHtml(label) + '</label><input type="number" min="' + attr(min) + '" ' + attribute + ' value="' + attr(value) + '"></div>';
2325
- }
2326
-
2327
- function regexTextArea(attribute, label, value, className, placeholder = "") {
2328
- return '<div class="field ' + className + '"><label>' + escapeHtml(label) + '</label><textarea ' + attribute + ' spellcheck="false" placeholder="' + attr(placeholder) + '">' + escapeHtml(value) + '</textarea></div>';
2329
- }
2330
-
2331
- function regexSelect(attribute, label, value, choices) {
2332
- return '<div class="field"><label>' + escapeHtml(label) + '</label><select ' + attribute + '>' +
2333
- choices.map((choice) => '<option value="' + attr(choice) + '"' + (choice === value ? " selected" : "") + '>' + escapeHtml(choice) + '</option>').join("") +
2334
- '</select></div>';
2335
- }
2336
-
2337
- function regexCheckGroup(label, attribute, choices, selected, help) {
2338
- const selectedSet = new Set(selected || []);
2339
- return '<div class="field span-2"><label>' + escapeHtml(label) + '</label><div class="regex-checks" title="' + attr(help) + '">' +
2340
- choices.map((choice) => '<label><input type="checkbox" ' + attribute + ' value="' + attr(choice) + '"' + (selectedSet.has(choice) ? " checked" : "") + '> ' + escapeHtml(choice) + '</label>').join("") +
2341
- '</div></div>';
2342
- }
2343
-
2344
- function bindRegexEditor() {
2345
- el("addRegexRuleBtn").onclick = () => {
2346
- el("regexRows").insertAdjacentHTML("beforeend", regexRuleRowHtml(defaultRegexRule()));
2347
- bindRegexEditor();
2348
- syncRegexRulesFromModal();
2349
- };
2350
- el("validateRegexRulesBtn").onclick = () => run(validateStack);
2351
- document.querySelectorAll("[data-regex-row] input, [data-regex-row] textarea:not([data-regex-original]), [data-regex-row] select").forEach((control) => {
2352
- control.oninput = () => syncRegexRulesFromModal();
2353
- control.onchange = () => syncRegexRulesFromModal();
2354
- });
2355
- document.querySelectorAll("[data-regex-row] [data-delete-row]").forEach((button) => {
2356
- button.onclick = (event) => {
2357
- event.target.closest("[data-regex-row]").remove();
2358
- syncRegexRulesFromModal();
2359
- };
2360
- });
2361
- document.querySelectorAll("[data-regex-up]").forEach((button) => {
2362
- button.onclick = (event) => {
2363
- const row = event.target.closest("[data-regex-row]");
2364
- const previous = row.previousElementSibling;
2365
- if (!previous) return;
2366
- row.parentNode.insertBefore(row, previous);
2367
- syncRegexRulesFromModal();
2368
- };
2369
- });
2370
- document.querySelectorAll("[data-regex-down]").forEach((button) => {
2371
- button.onclick = (event) => {
2372
- const row = event.target.closest("[data-regex-row]");
2373
- const next = row.nextElementSibling;
2374
- if (!next) return;
2375
- row.parentNode.insertBefore(next, row);
2376
- syncRegexRulesFromModal();
2377
- };
2378
- });
2379
- refreshRegexWarnings();
2380
- }
2381
-
2382
- function defaultRegexRule() {
2383
- return {
2384
- id: uniqueRegexRuleId(),
2385
- enabled: true,
2386
- stage: "compiled",
2387
- effect: "outgoing",
2388
- targets: ["messages"],
2389
- pattern: "",
2390
- replace: "",
2391
- };
2392
- }
2393
-
2394
- function uniqueRegexRuleId() {
2395
- const existing = new Set((currentStack?.regex?.rules || []).map((rule) => rule?.id).filter(Boolean));
2396
- let index = existing.size + 1;
2397
- let id = "regex-" + index;
2398
- while (existing.has(id)) id = "regex-" + (++index);
2399
- return id;
2400
- }
2401
-
2402
- function syncRegexRulesFromModal() {
2403
- if (!currentStack) return;
2404
- const rules = [];
2405
- const seen = new Set();
2406
- const errors = [];
2407
- document.querySelectorAll("[data-regex-row]").forEach((row, index) => {
2408
- const rule = regexRuleFromRow(row);
2409
- const label = rule.id || "rule " + (index + 1);
2410
- if (!rule.id) errors.push("Regex rule " + (index + 1) + " needs an id.");
2411
- else if (seen.has(rule.id)) errors.push("Duplicate regex rule id: " + rule.id);
2412
- seen.add(rule.id);
2413
- if (!rule.pattern) errors.push("Regex rule " + label + " needs a pattern.");
2414
- if (row.querySelector("[data-regex-max-messages]").value && !rule.maxMessages) errors.push("Regex rule " + label + " maxMessages must be a positive integer.");
2415
- if (row.querySelector("[data-regex-max-chars]").value && !rule.maxChars) errors.push("Regex rule " + label + " maxChars must be a positive integer.");
2416
- if (row.querySelector("[data-regex-min-depth]").value && rule.minDepth === undefined) errors.push("Regex rule " + label + " minDepth must be a non-negative integer.");
2417
- if (row.querySelector("[data-regex-max-depth]").value && rule.maxDepth === undefined) errors.push("Regex rule " + label + " maxDepth must be a non-negative integer.");
2418
- if (rule.minDepth !== undefined && rule.maxDepth !== undefined && rule.maxDepth < rule.minDepth) errors.push("Regex rule " + label + " maxDepth must be greater than or equal to minDepth.");
2419
- rules.push(rule);
2420
- });
2421
-
2422
- if (rules.length) {
2423
- currentStack.regex = { ...(currentStack.regex || {}), schemaVersion: currentStack.regex?.schemaVersion || 1, rules };
2424
- } else {
2425
- delete currentStack.regex;
2426
- }
2427
- regexRulesError = errors[0] || "";
2428
- markDirty();
2429
- refreshRegexWarnings();
2430
- if (regexRulesError) setStatus(regexRulesError, "error");
2431
- }
2432
-
2433
- function regexRuleFromRow(row) {
2434
- const rule = originalRegexRuleFromRow(row);
2435
- for (const key of ["id", "name", "enabled", "stage", "effect", "pattern", "flags", "replace", "trimStrings", "roles", "targets", "maxMessages", "maxChars", "minDepth", "maxDepth"]) {
2436
- delete rule[key];
2437
- }
2438
- rule.id = row.querySelector("[data-regex-id]").value.trim();
2439
- setOptionalObjectString(rule, "name", row.querySelector("[data-regex-name]").value);
2440
- rule.enabled = row.querySelector("[data-regex-enabled]").checked;
2441
- rule.stage = row.querySelector("[data-regex-stage]").value || "compiled";
2442
- rule.effect = row.querySelector("[data-regex-effect]").value || "outgoing";
2443
- rule.pattern = row.querySelector("[data-regex-pattern]").value;
2444
- const flags = row.querySelector("[data-regex-flags]").value.trim();
2445
- if (flags) rule.flags = flags;
2446
- const replace = row.querySelector("[data-regex-replace]").value;
2447
- if (replace) rule.replace = replace;
2448
- const trimStrings = row.querySelector("[data-regex-trim-strings]").value.split(/\r?\n/).filter((line) => line.length > 0);
2449
- if (trimStrings.length) rule.trimStrings = trimStrings;
2450
- const roles = checkedRegexValues(row, "data-regex-role");
2451
- if (roles.length) rule.roles = roles;
2452
- const targets = checkedRegexValues(row, "data-regex-target");
2453
- if (targets.length) rule.targets = targets;
2454
- const maxMessages = positiveIntegerFromInput(row.querySelector("[data-regex-max-messages]").value);
2455
- const maxChars = positiveIntegerFromInput(row.querySelector("[data-regex-max-chars]").value);
2456
- const minDepth = nonNegativeIntegerFromInput(row.querySelector("[data-regex-min-depth]").value);
2457
- const maxDepth = nonNegativeIntegerFromInput(row.querySelector("[data-regex-max-depth]").value);
2458
- if (maxMessages) rule.maxMessages = maxMessages;
2459
- if (maxChars) rule.maxChars = maxChars;
2460
- if (minDepth !== undefined) rule.minDepth = minDepth;
2461
- if (maxDepth !== undefined) rule.maxDepth = maxDepth;
2462
- return rule;
2463
- }
2464
-
2465
- function originalRegexRuleFromRow(row) {
2466
- try {
2467
- const parsed = JSON.parse(row.querySelector("[data-regex-original]")?.value || "{}");
2468
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
2469
- } catch {
2470
- return {};
2471
- }
2472
- }
2473
-
2474
- function checkedRegexValues(row, attribute) {
2475
- return Array.from(row.querySelectorAll("[" + attribute + "]"))
2476
- .filter((input) => input.checked)
2477
- .map((input) => input.value);
2478
- }
2479
-
2480
- function positiveIntegerFromInput(value) {
2481
- if (!String(value || "").trim()) return undefined;
2482
- const number = Number(value);
2483
- return Number.isInteger(number) && number > 0 ? number : undefined;
2484
- }
2485
-
2486
- function nonNegativeIntegerFromInput(value) {
2487
- if (!String(value || "").trim()) return undefined;
2488
- const number = Number(value);
2489
- return Number.isInteger(number) && number >= 0 ? number : undefined;
2490
- }
2491
-
2492
- function refreshRegexWarnings() {
2493
- document.querySelectorAll("[data-regex-row]").forEach((row) => {
2494
- const warning = row.querySelector("[data-regex-warning]");
2495
- if (!warning) return;
2496
- const text = regexRuleWarning(regexRuleFromRow(row));
2497
- warning.textContent = text;
2498
- warning.style.display = text ? "" : "none";
2499
- });
2500
- }
2501
-
2502
- function regexRuleWarning(rule) {
2503
- if (rule.effect === "finalize") {
2504
- return 'Warning: finalize runs after streaming and replaces the stored assistant transcript. Use stage "compiled" with target "messages".';
2505
- }
2506
- if (rule.effect === "display") {
2507
- return 'Warning: display rules validate but are ignored at runtime until true display transforms exist.';
2508
- }
2509
- if (rule.effect === "both") {
2510
- return 'Warning: both is ignored at runtime; create separate outgoing and finalize rules instead.';
2511
- }
2512
- if (typeof rule.replace === "string" && /\{\{\s*match\s*\}\}/i.test(rule.replace)) {
2513
- return 'Warning: {{match}} is SillyTavern syntax. Use $& or $0 for the full match in pi-forge rules.';
2514
- }
2515
- return "";
2516
- }
2517
-
2518
- function setOptionalObjectString(target, key, value) {
2519
- const trimmed = value.trim();
2520
- if (trimmed) target[key] = trimmed;
2521
- }
2522
-
2523
- function addItem(kind) {
2524
- if (!currentStack) return;
2525
- const id = nextNumericItemId();
2526
- const insertIndex = selectedItemIndex >= 0 && selectedItemIndex < currentStack.items.length
2527
- ? selectedItemIndex + 1
2528
- : currentStack.items.length;
2529
- currentStack.items.splice(insertIndex, 0, kind === "slot"
2530
- ? { kind: "slot", id, enabled: true, slot: "chat-history" }
2531
- : { kind: "block", id, enabled: true, role: "user", content: "" });
2532
- selectedItemIndex = insertIndex;
2533
- markDirty();
2534
- renderItemList();
2535
- renderItemEditor();
2536
- }
2537
-
2538
- function nextNumericItemId() {
2539
- const existing = new Set((currentStack?.items || []).map((item) => String(item.id)));
2540
- let index = 1;
2541
- while (existing.has(String(index))) index++;
2542
- return String(index);
2543
- }
2544
-
2545
- function deleteSelectedItem() {
2546
- if (!currentStack || selectedItemIndex < 0) return;
2547
- const item = currentStack.items[selectedItemIndex];
2548
- if (!confirm("Delete item " + item.id + "?")) return;
2549
- currentStack.items.splice(selectedItemIndex, 1);
2550
- selectedItemIndex = Math.min(selectedItemIndex, currentStack.items.length - 1);
2551
- markDirty();
2552
- renderItemList();
2553
- renderItemEditor();
2554
- }
2555
-
2556
- async function saveStack() {
2557
- const stack = stackForSubmit();
2558
- const data = await api("/api/stacks/" + encodeURIComponent(selectedId), { method: "PUT", body: { stack } });
2559
- stacks = data.stacks || stacks;
2560
- selectedId = data.stack?.id || stack.id;
2561
- currentStack = structuredClone(stack);
2562
- dirty = false;
2563
- renderDirtyState();
2564
- renderAll(data.stack?.diagnostics || []);
2565
- setStatus("Saved " + selectedId, "success");
2566
- await selectStack(selectedId, { keepDirty: true });
2567
- }
2568
-
2569
- async function createStackRemote(stack, options = {}) {
2570
- try {
2571
- return await api("/api/stacks", { method: "POST", body: { stack, ...options } });
2572
- } catch (error) {
2573
- if (error.status === 409 && !options.overwrite && confirm((error.message || "Stack already exists.") + "\n\nOverwrite it?")) {
2574
- return await api("/api/stacks", { method: "POST", body: { stack, ...options, overwrite: true } });
2575
- }
2576
- throw error;
2577
- }
2578
- }
2579
-
2580
- async function createAndOpenStack(stack, activate, actionLabel, extraOptions = {}) {
2581
- const data = await createStackRemote(stack, { ...extraOptions, activate });
2582
- stacks = data.stacks || stacks;
2583
- selectedId = data.stack?.id || stack.id;
2584
- dirty = false;
2585
- renderDirtyState();
2586
- await selectStack(selectedId, { keepDirty: true });
2587
- const converted = data.importFormat === "sillytavern" ? " from SillyTavern" : "";
2588
- setStatus(actionLabel + converted + " " + selectedId, "success");
2589
- if (data.importReport) showImportReport(data.importReport, selectedId);
2590
- }
2591
-
2592
- async function createNewStack() {
2593
- if (dirty && !confirm("Discard unsaved changes?")) return;
2594
- const promptedId = prompt("New stack id", uniqueStackId("new-stack"));
2595
- if (promptedId === null) return;
2596
- const id = sanitizeStackId(promptedId);
2597
- if (!id) throw new Error("Stack id must not be empty.");
2598
- if (id !== promptedId.trim() && !confirm("Use stack id '" + id + "'?")) return;
2599
- const promptedName = prompt("Stack display name", "Default Pi Prompt Mirror");
2600
- if (promptedName === null) return;
2601
- const stack = defaultNewStack(id, promptedName.trim() || id);
2602
- const activate = stacks.length === 0 || confirm("Activate new stack now?");
2603
- await createAndOpenStack(stack, activate, "Created");
2604
- }
2605
-
2606
- function defaultNewStack(id, name) {
2607
- return {
2608
- schemaVersion: 1,
2609
- type: "pi-forge.prompt-stack",
2610
- id,
2611
- name,
2612
- description: "Recreates Pi's built-in prompt layout with pi-forge slots, exposing tools, guidelines, docs, append-system-prompt, project context, skills, date/cwd, and chat history as movable pieces.",
2613
- autoActivate: stacks.length === 0,
2614
- mode: "replace",
2615
- defaults: {
2616
- syntheticMessagesVisible: false,
2617
- unresolvedMacroPolicy: "warn",
2618
- },
2619
- context: {
2620
- allowDuplicateChatHistory: false,
2621
- },
2622
- tools: {
2623
- allow: ["*"],
2624
- },
2625
- skills: {
2626
- allow: ["*"],
2627
- },
2628
- items: [
2629
- {
2630
- kind: "block",
2631
- id: "main-role",
2632
- name: "Pi Default Role",
2633
- enabled: true,
2634
- role: "system",
2635
- source: {
2636
- package: "@earendil-works/pi-coding-agent",
2637
- file: "dist/core/system-prompt.js",
2638
- },
2639
- content: "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.",
2640
- },
2641
- {
2642
- kind: "slot",
2643
- id: "tools",
2644
- name: "Available Tools",
2645
- enabled: true,
2646
- role: "system",
2647
- slot: "tools",
2648
- options: {
2649
- format: "plain",
2650
- onlyWithSnippets: true,
2651
- },
2652
- },
2653
- {
2654
- kind: "block",
2655
- id: "custom-tools-note",
2656
- name: "Custom Tools Note",
2657
- enabled: true,
2658
- role: "system",
2659
- content: "In addition to the tools above, you may have access to other custom tools depending on the project.",
2660
- },
2661
- {
2662
- kind: "slot",
2663
- id: "tool-guidelines",
2664
- name: "Guidelines",
2665
- enabled: true,
2666
- role: "system",
2667
- slot: "tool-guidelines",
2668
- options: {
2669
- format: "plain",
2670
- heading: "Guidelines:",
2671
- includePiDefaultGuidelines: true,
2672
- piStyle: true,
2673
- },
2674
- },
2675
- {
2676
- kind: "slot",
2677
- id: "pi-docs",
2678
- name: "Pi Documentation Guidance",
2679
- enabled: true,
2680
- role: "system",
2681
- slot: "pi-docs",
2682
- },
2683
- {
2684
- kind: "slot",
2685
- id: "append-system-prompt",
2686
- name: "User Append System Prompt",
2687
- enabled: true,
2688
- role: "system",
2689
- slot: "append-system-prompt",
2690
- },
2691
- {
2692
- kind: "slot",
2693
- id: "project-context",
2694
- name: "Project Context",
2695
- enabled: true,
2696
- role: "system",
2697
- slot: "project-context",
2698
- },
2699
- {
2700
- kind: "slot",
2701
- id: "skills",
2702
- name: "Available Skills",
2703
- enabled: true,
2704
- role: "system",
2705
- slot: "skills",
2706
- options: {
2707
- requireReadTool: true,
2708
- },
2709
- },
2710
- {
2711
- kind: "slot",
2712
- id: "date-cwd",
2713
- name: "Date and Working Directory",
2714
- enabled: true,
2715
- role: "system",
2716
- slot: "date-cwd",
2717
- },
2718
- {
2719
- kind: "slot",
2720
- id: "chat-history",
2721
- name: "Chat History",
2722
- enabled: true,
2723
- slot: "chat-history",
2724
- },
2725
- ],
2726
- };
2727
- }
2728
-
2729
- function showImportReport(report, stackId) {
2730
- showStackModal(
2731
- "SillyTavern import report",
2732
- stackId || "",
2733
- '<div class="modal-toolbar"><button id="copyImportReportBtn" data-icon="□" title="Copy this import report">Copy report</button><span class="modal-spacer"></span><span class="modal-meta">Report-only notes; stack changes are already saved.</span></div>' +
2734
- '<pre class="preview-text">' + escapeHtml(report) + '</pre>',
2735
- );
2736
- el("copyImportReportBtn").onclick = () => run(async () => {
2737
- await copyTextToClipboard(report);
2738
- setStatus("Copied import report", "success");
2739
- });
2740
- }
2741
-
2742
- async function importStackJson() {
2743
- el("importFileInput").value = "";
2744
- el("importFileInput").click();
2745
- }
2746
-
2747
- async function handleImportFile(event) {
2748
- const file = event.target.files?.[0];
2749
- if (!file) return;
2750
- const text = await file.text();
2751
- const imported = JSON.parse(text);
2752
- if (!imported || typeof imported !== "object" || Array.isArray(imported)) throw new Error("Imported JSON must be an object.");
2753
- if (isSillyTavernImport(imported)) {
2754
- const characterId = promptSillyTavernCharacterId(imported);
2755
- if (characterId === null) return;
2756
- const activate = confirm("Convert and activate imported SillyTavern stack now?");
2757
- await createAndOpenStack(imported, activate, "Imported", { sourceName: file.name, characterId });
2758
- return;
2759
- }
2760
-
2761
- const stack = imported;
2762
- if (!stack.id || typeof stack.id !== "string") {
2763
- const promptedId = prompt("Stack id", sanitizeStackId(file.name.replace(/\.json$/i, "")));
2764
- if (!promptedId) return;
2765
- stack.id = promptedId.trim();
2766
- }
2767
- if (!Array.isArray(stack.items)) throw new Error("Imported stack must contain an items array.");
2768
- if (!stack.schemaVersion) stack.schemaVersion = 1;
2769
- if (!stack.type) stack.type = "pi-forge.prompt-stack";
2770
- const activate = confirm("Activate imported stack now?");
2771
- await createAndOpenStack(stack, activate, "Imported");
2772
- }
2773
-
2774
- function isSillyTavernImport(value) {
2775
- return value && typeof value === "object" && !Array.isArray(value) && Array.isArray(value.prompts) && !Array.isArray(value.items);
2776
- }
2777
-
2778
- function promptSillyTavernCharacterId(value) {
2779
- const ids = Array.isArray(value.prompt_order)
2780
- ? value.prompt_order
2781
- .map((entry) => entry && entry.character_id)
2782
- .filter((id) => Number.isInteger(id))
2783
- : [];
2784
- const uniqueIds = [...new Set(ids)];
2785
- if (uniqueIds.length <= 1) return undefined;
2786
- const answer = prompt("SillyTavern character_id (" + uniqueIds.join(", ") + ")", String(uniqueIds[0]));
2787
- if (answer === null) return null;
2788
- const parsed = Number(answer.trim());
2789
- if (!Number.isInteger(parsed) || !uniqueIds.includes(parsed)) {
2790
- throw new Error("Choose one of these character_id values: " + uniqueIds.join(", "));
2791
- }
2792
- return parsed;
2793
- }
2794
-
2795
- async function forkStack() {
2796
- const source = stackForSubmit();
2797
- const forkId = prompt("New fork stack id", uniqueForkId(source.id || "stack"));
2798
- if (!forkId) return;
2799
- const forkName = prompt("Fork display name", ((source.name || source.id || "Prompt stack") + " fork"));
2800
- const fork = structuredClone(source);
2801
- fork.id = forkId.trim();
2802
- if (forkName && forkName.trim()) fork.name = forkName;
2803
- fork.autoActivate = false;
2804
- const activate = confirm("Activate fork now?");
2805
- await createAndOpenStack(fork, activate, "Forked");
2806
- }
2807
-
2808
- async function exportStackJson() {
2809
- const stack = stackForSubmit();
2810
- const json = JSON.stringify(stack, null, 2) + "\n";
2811
- const downloaded = downloadTextFile(sanitizeStackId(stack.id || "prompt-stack") + ".json", json, "application/json");
2812
- if (downloaded) {
2813
- setStatus("Exported " + (stack.id || "prompt stack"), "success");
2814
- return;
2815
- }
2816
- await copyTextToClipboard(json);
2817
- setStatus("Copied " + (stack.id || "prompt stack") + " JSON", "success");
2818
- }
2819
-
2820
- function downloadTextFile(filename, text, type) {
2821
- if (typeof Blob === "undefined" || typeof URL === "undefined" || !URL.createObjectURL) return false;
2822
- const blob = new Blob([text], { type });
2823
- const url = URL.createObjectURL(blob);
2824
- const link = document.createElement("a");
2825
- if (!("download" in link)) {
2826
- URL.revokeObjectURL(url);
2827
- return false;
2828
- }
2829
- link.href = url;
2830
- link.download = filename;
2831
- document.body.appendChild(link);
2832
- link.click();
2833
- link.remove();
2834
- URL.revokeObjectURL(url);
2835
- return true;
2836
- }
2837
-
2838
- function uniqueForkId(baseId) {
2839
- const base = sanitizeStackId(baseId || "stack") || "stack";
2840
- const existing = new Set(stacks.map((stack) => stack.id));
2841
- let candidate = base + "-fork";
2842
- let index = 2;
2843
- while (existing.has(candidate)) candidate = base + "-fork-" + index++;
2844
- return candidate;
2845
- }
2846
-
2847
- function uniqueStackId(baseId) {
2848
- const base = sanitizeStackId(baseId || "stack") || "stack";
2849
- const existing = new Set(stacks.map((stack) => stack.id));
2850
- let candidate = base;
2851
- let index = 2;
2852
- while (existing.has(candidate)) candidate = base + "-" + index++;
2853
- return candidate;
2854
- }
2855
-
2856
- function sanitizeStackId(value) {
2857
- return String(value || "")
2858
- .trim()
2859
- .replace(/[^A-Za-z0-9_-]+/g, "-")
2860
- .replace(/-+/g, "-")
2861
- .replace(/^-|-$/g, "")
2862
- .toLowerCase();
2863
- }
2864
-
2865
- async function validateStack() {
2866
- const stack = stackForSubmit();
2867
- const data = await api("/api/stacks/" + encodeURIComponent(selectedId) + "/validate", { method: "POST", body: { stack } });
2868
- renderDiagnostics(data.diagnostics || []);
2869
- renderItemList();
2870
- hidePreview();
2871
- setStatus("Validation complete", "success");
2872
- }
2873
-
2874
- async function previewStack() {
2875
- const stack = stackForSubmit();
2876
- const data = await api("/api/stacks/" + encodeURIComponent(selectedId) + "/preview", { method: "POST", body: { stack } });
2877
- renderDiagnostics(data.diagnostics || []);
2878
- renderItemList();
2879
- renderPreviewInspector(data);
2880
- setStatus("Preview rendered", "success");
2881
- }
2882
-
2883
- async function refreshPayloadCapture(options = {}) {
2884
- const previousCapturedAt = payloadSnapshot.status === "captured" ? payloadSnapshot.capture?.capturedAt : "";
2885
- const data = await api("/api/payload");
2886
- payloadSnapshot = data;
2887
- updatePayloadButton();
2888
- const nextCapturedAt = payloadSnapshot.status === "captured" ? payloadSnapshot.capture?.capturedAt : "";
2889
- if (options.open || (options.autoOpen && nextCapturedAt && nextCapturedAt !== previousCapturedAt)) {
2890
- renderPayloadInspector(payloadSnapshot);
2891
- }
2892
- }
2893
-
2894
- async function armPayloadCapture(showInspector = false) {
2895
- const data = await api("/api/payload/arm", { method: "POST" });
2896
- payloadSnapshot = data;
2897
- updatePayloadButton();
2898
- setStatus("Payload capture armed; send the next Pi prompt");
2899
- if (showInspector) renderPayloadInspector(payloadSnapshot);
2900
- }
2901
-
2902
- async function clearPayloadCapture() {
2903
- const data = await api("/api/payload", { method: "DELETE" });
2904
- payloadSnapshot = data;
2905
- updatePayloadButton();
2906
- hidePreview();
2907
- setStatus("Payload capture cleared", "success");
2908
- }
2909
-
2910
- async function openPayloadCapture() {
2911
- await refreshPayloadCapture();
2912
- if (payloadSnapshot.status === "captured" || payloadSnapshot.status === "armed") {
2913
- renderPayloadInspector(payloadSnapshot);
2914
- return;
2915
- }
2916
- await armPayloadCapture();
2917
- }
2918
-
2919
- function updatePayloadButton() {
2920
- const button = el("payloadBtn");
2921
- if (!button) return;
2922
- button.classList.remove("primary");
2923
- if (payloadSnapshot.status === "armed") {
2924
- button.textContent = "Payload armed";
2925
- button.classList.add("primary");
2926
- button.title = "Waiting for the next provider payload";
2927
- return;
2928
- }
2929
- if (payloadSnapshot.status === "captured") {
2930
- button.textContent = "View payload";
2931
- button.title = "Open the latest captured provider payload";
2932
- return;
2933
- }
2934
- button.textContent = "Arm payload";
2935
- button.title = "Capture the next provider payload in this editor";
2936
- }
2937
-
2938
- function hidePreview() {
2939
- const pane = el("preview");
2940
- pane.classList.remove("open");
2941
- pane.innerHTML = "";
2942
- previewCopyTexts = [];
2943
- }
2944
-
2945
- function renderPreviewInspector(data) {
2946
- const pane = el("preview");
2947
- const preview = data.preview;
2948
- if (!preview) {
2949
- previewCopyTexts = [data.text || ""];
2950
- pane.innerHTML = '<div class="preview-dialog" role="dialog" aria-modal="true" aria-label="Prompt preview">' +
2951
- '<div class="preview-head"><div><div class="preview-title">Preview</div><div class="preview-meta">Plain text fallback</div></div>' +
2952
- '<div class="preview-actions"><button class="preview-copy" data-copy-index="0" data-icon="□" title="Copy the full preview text">Copy</button><button data-preview-close="true" data-icon="×" title="Close the preview">Close</button></div></div>' +
2953
- '<div class="preview-body"><pre class="preview-text">' + escapeHtml(data.text || "") + '</pre></div></div>';
2954
- pane.classList.add("open");
2955
- return;
2956
- }
2957
-
2958
- const sections = [preview.system, ...(preview.messages || [])];
2959
- previewCopyTexts = [data.text || "", ...sections.map((section) => section.content || "")];
2960
- const sectionHtml = sections.map((section, index) => {
2961
- const open = index === 0 ? " open" : "";
2962
- const label = section.role ? section.role + " · " : "";
2963
- return '<details class="preview-section"' + open + '>' +
2964
- '<summary><span class="preview-title">' + escapeHtml(section.title || section.id) + '</span>' +
2965
- '<span class="preview-meta">' + escapeHtml(label + formatCount(section.chars) + " chars · ~" + formatCount(section.approxTokens) + " tokens") + '</span>' +
2966
- '<button class="preview-copy" data-copy-index="' + attr(index + 1) + '" data-icon="□" title="Copy this preview section" onclick="event.preventDefault()">Copy</button></summary>' +
2967
- '<pre class="preview-text">' + escapeHtml(section.content || "") + '</pre>' +
2968
- '</details>';
2969
- }).join("");
2970
-
2971
- pane.innerHTML = '<div class="preview-dialog" role="dialog" aria-modal="true" aria-label="Prompt preview">' +
2972
- '<div class="preview-head"><div><div class="preview-title">Prompt preview: ' + escapeHtml(preview.stackId || selectedId) + '</div>' +
2973
- '<div class="preview-meta">' + escapeHtml(formatCount(preview.totalChars) + " chars · ~" + formatCount(preview.approxTokens) + " tokens · " + (preview.messages || []).length + " messages") + '</div></div>' +
2974
- '<div class="preview-actions"><button class="preview-copy" data-copy-index="0" data-icon="□" title="Copy the full prompt preview">Copy full</button><button data-preview-close="true" data-icon="×" title="Close the preview">Close</button></div></div>' +
2975
- '<div class="preview-body">' + sectionHtml + '</div></div>';
2976
- pane.classList.add("open");
2977
- }
2978
-
2979
- function renderPayloadInspector(snapshot) {
2980
- const pane = el("preview");
2981
- if (snapshot.status === "idle") {
2982
- previewCopyTexts = [];
2983
- pane.innerHTML = '<div class="preview-dialog" role="dialog" aria-modal="true" aria-label="Provider payload capture">' +
2984
- '<div class="preview-head"><div><div class="preview-title">Provider payload</div><div class="preview-meta">No payload captured.</div></div>' +
2985
- '<div class="preview-actions"><button data-payload-arm="true" data-icon="◆" title="Capture the next provider payload">Arm next</button><button data-preview-close="true" data-icon="×" title="Close the payload inspector">Close</button></div></div>' +
2986
- '<div class="preview-body"><div class="empty">Arm capture, then send the next prompt in Pi. The provider payload will appear here before it is sent.</div></div></div>';
2987
- pane.classList.add("open");
2988
- return;
2989
- }
2990
-
2991
- if (snapshot.status === "armed") {
2992
- const meta = snapshot.armedAt ? "Armed at " + snapshot.armedAt : "Waiting for next provider request";
2993
- previewCopyTexts = [];
2994
- pane.innerHTML = '<div class="preview-dialog" role="dialog" aria-modal="true" aria-label="Provider payload capture">' +
2995
- '<div class="preview-head"><div><div class="preview-title">Payload capture armed</div><div class="preview-meta">' + escapeHtml(meta) + '</div></div>' +
2996
- '<div class="preview-actions"><button class="danger" data-payload-clear="true" data-icon="×" title="Clear the armed payload capture">Clear</button><button data-preview-close="true" data-icon="×" title="Close the payload inspector">Close</button></div></div>' +
2997
- '<div class="preview-body"><div class="empty">Send the next prompt in Pi. The exact provider payload will be captured here and redacted before display.</div></div></div>';
2998
- pane.classList.add("open");
2999
- return;
3000
- }
3001
-
3002
- const capture = snapshot.capture || {};
3003
- const sections = payloadSections(capture);
3004
- previewCopyTexts = [capture.text || "", ...sections.map((section) => section.content || "")];
3005
- const sectionHtml = sections.map((section, index) => {
3006
- const open = index === 0 ? " open" : "";
3007
- return '<details class="preview-section"' + open + '>' +
3008
- '<summary><span class="preview-title">' + escapeHtml(section.title) + '</span>' +
3009
- '<span class="preview-meta">' + escapeHtml(section.meta) + '</span>' +
3010
- '<button class="preview-copy" data-copy-index="' + attr(index + 1) + '" data-icon="□" title="Copy this payload section" onclick="event.preventDefault()">Copy</button></summary>' +
3011
- '<pre class="preview-text">' + escapeHtml(section.content || "") + '</pre>' +
3012
- '</details>';
3013
- }).join("");
3014
- const metaParts = [
3015
- formatCount(capture.chars) + " chars",
3016
- "~" + formatCount(capture.approxTokens) + " tokens",
3017
- capture.stackId ? "stack " + capture.stackId : undefined,
3018
- capture.truncated ? "truncated" : undefined,
3019
- ].filter(Boolean);
3020
- pane.innerHTML = '<div class="preview-dialog" role="dialog" aria-modal="true" aria-label="Provider payload capture">' +
3021
- '<div class="preview-head"><div><div class="preview-title">Provider payload</div>' +
3022
- '<div class="preview-meta">' + escapeHtml(metaParts.join(" · ") + (capture.capturedAt ? " · " + capture.capturedAt : "")) + '</div></div>' +
3023
- '<div class="preview-actions"><button class="preview-copy" data-copy-index="0" data-icon="□" title="Copy the full redacted payload">Copy full</button><button data-payload-arm="true" data-icon="◆" title="Capture the next provider payload">Arm again</button><button class="danger" data-payload-clear="true" data-icon="×" title="Clear the captured payload">Clear</button><button data-preview-close="true" data-icon="×" title="Close the payload inspector">Close</button></div></div>' +
3024
- '<div class="preview-body">' + sectionHtml + '</div></div>';
3025
- pane.classList.add("open");
3026
- }
3027
-
3028
- function payloadSections(capture) {
3029
- const value = capture.payload;
3030
- if (value && typeof value === "object") {
3031
- if (Array.isArray(value)) {
3032
- return value.map((item, index) => payloadSection(String(index), item));
3033
- }
3034
- const entries = Object.entries(value);
3035
- if (entries.length) return entries.map(([key, item]) => payloadSection(key, item));
3036
- }
3037
- return [{
3038
- title: capture.error ? "Stringify error" : capture.truncated ? "Raw truncated payload" : "Raw payload",
3039
- meta: formatCount((capture.text || "").length) + " chars",
3040
- content: capture.text || "",
3041
- }];
3042
- }
3043
-
3044
- function payloadSection(title, value) {
3045
- const rendered = JSON.stringify(value, null, 2);
3046
- const content = rendered === undefined ? String(value) : rendered;
3047
- const meta = describePayloadValue(value) + " · " + formatCount(content.length) + " chars";
3048
- return { title, meta, content };
3049
- }
3050
-
3051
- function describePayloadValue(value) {
3052
- if (Array.isArray(value)) return "array[" + value.length + "]";
3053
- if (value && typeof value === "object") return "object{" + Object.keys(value).length + "}";
3054
- if (value === null) return "null";
3055
- return typeof value;
3056
- }
3057
-
3058
- function formatCount(value) {
3059
- return Number(value || 0).toLocaleString();
3060
- }
3061
-
3062
- async function copyPreviewText(index) {
3063
- const text = previewCopyTexts[index] || "";
3064
- if (!text) return;
3065
- await copyTextToClipboard(text);
3066
- setStatus("Copied text", "success");
3067
- }
3068
-
3069
- async function copyTextToClipboard(text) {
3070
- if (navigator.clipboard && window.isSecureContext) {
3071
- await navigator.clipboard.writeText(text);
3072
- } else {
3073
- const area = document.createElement("textarea");
3074
- area.value = text;
3075
- area.style.position = "fixed";
3076
- area.style.left = "-9999px";
3077
- document.body.appendChild(area);
3078
- area.select();
3079
- document.execCommand("copy");
3080
- area.remove();
3081
- }
3082
- }
3083
-
3084
- async function activateStack() {
3085
- if (!currentStack) return;
3086
- const data = await api("/api/stacks/" + encodeURIComponent(selectedId) + "/activate", { method: "POST" });
3087
- stacks = data.stacks || stacks;
3088
- renderStackList();
3089
- setStatus("Activated " + selectedId, "success");
3090
- }
3091
-
3092
- async function disableStacks() {
3093
- const data = await api("/api/disable", { method: "POST" });
3094
- stacks = data.stacks || stacks;
3095
- renderStackList();
3096
- setStatus("Prompt stack disabled", "success");
3097
- }
3098
-
3099
- async function deleteCurrentStack() {
3100
- if (!currentStack) return;
3101
- const id = selectedId;
3102
- const message = "Delete prompt stack '" + id + "'?\n\nThis removes its JSON file from prompt-stack storage.";
3103
- if (!confirm(message)) return;
3104
- const data = await api("/api/stacks/" + encodeURIComponent(id), { method: "DELETE" });
3105
- stacks = data.stacks || [];
3106
- dirty = false;
3107
- renderDirtyState();
3108
- const next = stacks.find((stack) => stack.active) || stacks[0];
3109
- if (next) {
3110
- await selectStack(next.id, { keepDirty: true });
3111
- setStatus("Deleted " + id, "success");
3112
- } else {
3113
- renderStackList();
3114
- renderEmpty();
3115
- setStatus("Deleted " + id + "; no stacks remain", "success");
3116
- }
3117
- }
3118
-
3119
- async function reloadFromDisk() {
3120
- if (dirty && !confirm("Discard unsaved changes?")) return;
3121
- const data = await api("/api/reload", { method: "POST" });
3122
- stacks = data.stacks || [];
3123
- renderStackList();
3124
- await loadStacks(selectedId);
3125
- setStatus("Reloaded from disk", "success");
3126
- }
3127
-
3128
- function stackForSubmit() {
3129
- if (!currentStack) throw new Error("No stack selected.");
3130
- if (optionsError) throw new Error("Invalid item options JSON: " + optionsError);
3131
- if (stackVariablesError) throw new Error(stackVariablesError);
3132
- if (regexRulesError) throw new Error(regexRulesError);
3133
- if (stackPolicyError) throw new Error(stackPolicyError);
3134
- const clone = structuredClone(currentStack);
3135
- if (!clone.type) clone.type = "pi-forge.prompt-stack";
3136
- if (!clone.schemaVersion) clone.schemaVersion = 1;
3137
- return clone;
3138
- }
3139
-
3140
- function renderDiagnostics(diagnostics) {
3141
- latestDiagnostics = diagnostics || [];
3142
- const pane = el("diagnostics");
3143
- if (!latestDiagnostics.length) {
3144
- pane.innerHTML = '<div class="diagnostic info">No diagnostics.</div>';
3145
- return;
3146
- }
3147
- pane.innerHTML = latestDiagnostics.map((diag) => {
3148
- const level = diag.level || "info";
3149
- const item = diag.itemId ? " [" + escapeHtml(diag.itemId) + "]" : "";
3150
- return '<div class="diagnostic ' + attr(level) + '"><strong>' + escapeHtml(level.toUpperCase()) + item + '</strong>: ' + escapeHtml(diag.message || "") + '</div>';
3151
- }).join("");
3152
- }
3153
-
3154
- function renderEmpty() {
3155
- currentStack = null;
3156
- selectedId = "";
3157
- dirty = false;
3158
- activeTab = "items";
3159
- stackPolicyError = "";
3160
- renderDirtyState();
3161
- document.querySelectorAll("[data-tab]").forEach((button) => {
3162
- button.classList.toggle("active", button.dataset.tab === activeTab);
3163
- });
3164
- el("workspace").style.display = "";
3165
- el("metadataPanel").style.display = "none";
3166
- el("settings").innerHTML = "";
3167
- el("itemCount").textContent = "";
3168
- el("itemList").innerHTML = "";
3169
- el("itemEditor").innerHTML =
3170
- '<div class="empty">' +
3171
- '<div class="empty-title">No prompt stacks found.</div>' +
3172
- '<div>Create a stack in this project, or import an existing pi-forge/SillyTavern JSON file.</div>' +
3173
- '<div class="empty-actions">' +
3174
- '<button id="emptyNewStackBtn" class="primary" data-icon="+" title="Create a new prompt stack">New stack</button>' +
3175
- '<button id="emptyImportBtn" data-icon="⇪" title="Import pi-forge stack JSON or SillyTavern preset JSON">Import JSON</button>' +
3176
- '</div>' +
3177
- '</div>';
3178
- el("tabPanel").classList.remove("open");
3179
- el("tabPanel").innerHTML = "";
3180
- renderDiagnostics([]);
3181
- el("emptyNewStackBtn").onclick = () => run(createNewStack);
3182
- el("emptyImportBtn").onclick = () => run(importStackJson);
3183
- setStatus("No prompt stacks found");
3184
- updateActionState();
3185
- }
3186
-
3187
- function field(label, control, className = "") {
3188
- return '<div class="field ' + className + '"><label>' + escapeHtml(label) + '</label>' + control + '</div>';
3189
- }
3190
-
3191
- function displayItemName(item) {
3192
- if (item.name) return item.name;
3193
- if (item.source && typeof item.source.previousName === "string" && item.source.previousName.trim()) return item.source.previousName;
3194
- if (item.kind === "slot" && item.slot) return item.slot;
3195
- if (item.kind === "block" && item.content) {
3196
- const firstLine = item.content.trim().split(/\n/)[0]?.trim();
3197
- if (firstLine) return firstLine.length > 46 ? firstLine.slice(0, 43) + "..." : firstLine;
3198
- }
3199
- return item.id || "(unnamed)";
3200
- }
3201
-
3202
- function setOptionalString(target, key, value) {
3203
- const trimmed = value.trim();
3204
- if (trimmed) target[key] = value;
3205
- else delete target[key];
3206
- }
3207
-
3208
- function escapeHtml(value) {
3209
- return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
3210
- }
3211
-
3212
- function attr(value) {
3213
- return escapeHtml(value);
3214
- }
3215
-
3216
- async function run(action) {
3217
- try {
3218
- await action();
3219
- } catch (error) {
3220
- setStatus(error.message || String(error), "error");
3221
- }
3222
- }
3223
-
3224
- function toggleSidebar() {
3225
- sidebarCollapsed = !sidebarCollapsed;
3226
- el("shell").classList.toggle("sidebar-collapsed", sidebarCollapsed);
3227
- el("sidebarToggleBtn").title = sidebarCollapsed ? "Show prompt stacks sidebar" : "Hide prompt stacks sidebar";
3228
- setStatus(sidebarCollapsed ? "Prompt stacks sidebar hidden" : "Prompt stacks sidebar shown");
3229
- }
3230
-
3231
- el("sidebarToggleBtn").onclick = toggleSidebar;
3232
- el("themeBtn").onclick = toggleTheme;
3233
- el("newStackBtn").onclick = () => run(createNewStack);
3234
- el("reloadBtn").onclick = () => run(reloadFromDisk);
3235
- el("disableBtn").onclick = () => run(disableStacks);
3236
- el("activateBtn").onclick = () => run(activateStack);
3237
- el("saveBtn").onclick = () => run(saveStack);
3238
- el("validateBtn").onclick = () => run(validateStack);
3239
- el("previewBtn").onclick = () => run(previewStack);
3240
- el("payloadBtn").onclick = () => run(openPayloadCapture);
3241
- el("metadataToggleBtn").onclick = toggleMetadata;
3242
- document.querySelectorAll("[data-tab]").forEach((button) => {
3243
- button.onclick = () => {
3244
- activeTab = button.dataset.tab || "items";
3245
- renderActiveTab();
3246
- hidePreview();
3247
- };
3248
- });
3249
- el("forkBtn").onclick = () => run(forkStack);
3250
- el("importBtn").onclick = () => run(importStackJson);
3251
- el("exportBtn").onclick = () => run(exportStackJson);
3252
- el("importFileInput").onchange = (event) => run(() => handleImportFile(event));
3253
- el("deleteStackBtn").onclick = () => run(deleteCurrentStack);
3254
- el("stackModal").onclick = (event) => {
3255
- if (event.target === el("stackModal") || event.target.closest?.("[data-modal-close]")) {
3256
- closeStackModal();
3257
- }
3258
- };
3259
- el("preview").onclick = (event) => {
3260
- if (event.target === el("preview") || event.target.closest?.("[data-preview-close]")) {
3261
- hidePreview();
3262
- return;
3263
- }
3264
- if (event.target.closest?.("[data-payload-arm]")) {
3265
- event.preventDefault();
3266
- event.stopPropagation();
3267
- run(() => armPayloadCapture(true));
3268
- return;
3269
- }
3270
- if (event.target.closest?.("[data-payload-clear]")) {
3271
- event.preventDefault();
3272
- event.stopPropagation();
3273
- run(clearPayloadCapture);
3274
- return;
3275
- }
3276
- const button = event.target.closest?.("[data-copy-index]");
3277
- if (!button) return;
3278
- event.preventDefault();
3279
- event.stopPropagation();
3280
- run(() => copyPreviewText(Number(button.dataset.copyIndex)));
3281
- };
3282
- el("addItemBtn").onclick = () => addItem("block");
3283
- el("addSlotBtn").onclick = () => addItem("slot");
3284
- el("deleteItemBtn").onclick = deleteSelectedItem;
3285
- document.addEventListener("dragover", handleDocumentItemDragOver);
3286
- document.addEventListener("drop", handleDocumentItemDrop);
3287
- window.onbeforeunload = () => dirty ? "Unsaved changes" : undefined;
3288
- window.addEventListener("keydown", handleEditorShortcut);
3289
-
3290
- function handleEditorShortcut(event) {
3291
- if (event.key === "Escape") {
3292
- if (el("preview").classList.contains("open")) hidePreview();
3293
- else if (el("stackModal").classList.contains("open")) closeStackModal();
3294
- return;
3295
- }
3296
-
3297
- const modifier = event.ctrlKey || event.metaKey;
3298
- if (!modifier || event.altKey) return;
3299
- const key = event.key.toLowerCase();
3300
-
3301
- if (key === "n") {
3302
- event.preventDefault();
3303
- run(createNewStack);
3304
- return;
3305
- }
3306
- if (key === "s") {
3307
- event.preventDefault();
3308
- if (currentStack) run(saveStack);
3309
- return;
3310
- }
3311
- if (key === "enter" && event.shiftKey) {
3312
- event.preventDefault();
3313
- if (currentStack) run(validateStack);
3314
- return;
3315
- }
3316
- if (key === "enter") {
3317
- event.preventDefault();
3318
- if (currentStack) run(previewStack);
3319
- }
3320
- }
3321
-
3322
- run(async () => {
3323
- await loadStacks();
3324
- await refreshPayloadCapture();
3325
- setInterval(() => run(() => refreshPayloadCapture({ autoOpen: true })), 2000);
3326
- });
3327
- </script>
3328
- </body>
3329
- </html>`;
3330
- }