@zihanw/pi-forge 0.3.0 → 0.3.2

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 (156) hide show
  1. package/README.md +87 -2
  2. package/README.zh-CN.md +72 -2
  3. package/dist/compiler.d.ts +10 -0
  4. package/dist/compiler.d.ts.map +1 -0
  5. package/dist/compiler.js +490 -0
  6. package/dist/compiler.js.map +1 -0
  7. package/dist/extension-registry.d.ts +25 -0
  8. package/dist/extension-registry.d.ts.map +1 -0
  9. package/dist/extension-registry.js +6 -0
  10. package/dist/extension-registry.js.map +1 -0
  11. package/dist/forge-extensions.d.ts +28 -0
  12. package/dist/forge-extensions.d.ts.map +1 -0
  13. package/dist/forge-extensions.js +182 -0
  14. package/dist/forge-extensions.js.map +1 -0
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +376 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/lifecycle.d.ts +14 -0
  20. package/dist/lifecycle.d.ts.map +1 -0
  21. package/dist/lifecycle.js +139 -0
  22. package/dist/lifecycle.js.map +1 -0
  23. package/dist/loader.d.ts +8 -0
  24. package/dist/loader.d.ts.map +1 -0
  25. package/dist/loader.js +345 -0
  26. package/dist/loader.js.map +1 -0
  27. package/dist/macro-engine.d.ts +23 -0
  28. package/dist/macro-engine.d.ts.map +1 -0
  29. package/dist/macro-engine.js +262 -0
  30. package/dist/macro-engine.js.map +1 -0
  31. package/dist/payload-capture.d.ts +15 -0
  32. package/dist/payload-capture.d.ts.map +1 -0
  33. package/dist/payload-capture.js +86 -0
  34. package/dist/payload-capture.js.map +1 -0
  35. package/dist/payload-command.d.ts +10 -0
  36. package/dist/payload-command.d.ts.map +1 -0
  37. package/dist/payload-command.js +112 -0
  38. package/dist/payload-command.js.map +1 -0
  39. package/dist/policy.d.ts +6 -0
  40. package/dist/policy.d.ts.map +1 -0
  41. package/dist/policy.js +38 -0
  42. package/dist/policy.js.map +1 -0
  43. package/dist/preset-command.d.ts +14 -0
  44. package/dist/preset-command.d.ts.map +1 -0
  45. package/dist/preset-command.js +232 -0
  46. package/dist/preset-command.js.map +1 -0
  47. package/dist/preview.d.ts +12 -0
  48. package/dist/preview.d.ts.map +1 -0
  49. package/dist/preview.js +179 -0
  50. package/dist/preview.js.map +1 -0
  51. package/dist/regex.d.ts +7 -0
  52. package/dist/regex.d.ts.map +1 -0
  53. package/dist/regex.js +432 -0
  54. package/dist/regex.js.map +1 -0
  55. package/dist/render-helpers.d.ts +44 -0
  56. package/dist/render-helpers.d.ts.map +1 -0
  57. package/dist/render-helpers.js +128 -0
  58. package/dist/render-helpers.js.map +1 -0
  59. package/dist/runtime-state.d.ts +26 -0
  60. package/dist/runtime-state.d.ts.map +1 -0
  61. package/dist/runtime-state.js +15 -0
  62. package/dist/runtime-state.js.map +1 -0
  63. package/dist/sillytavern-importer/items.d.ts +3 -0
  64. package/dist/sillytavern-importer/items.d.ts.map +1 -0
  65. package/dist/sillytavern-importer/items.js +88 -0
  66. package/dist/sillytavern-importer/items.js.map +1 -0
  67. package/dist/sillytavern-importer/macros.d.ts +15 -0
  68. package/dist/sillytavern-importer/macros.d.ts.map +1 -0
  69. package/dist/sillytavern-importer/macros.js +141 -0
  70. package/dist/sillytavern-importer/macros.js.map +1 -0
  71. package/dist/sillytavern-importer/prompt-order.d.ts +6 -0
  72. package/dist/sillytavern-importer/prompt-order.d.ts.map +1 -0
  73. package/dist/sillytavern-importer/prompt-order.js +38 -0
  74. package/dist/sillytavern-importer/prompt-order.js.map +1 -0
  75. package/dist/sillytavern-importer/regex.d.ts +3 -0
  76. package/dist/sillytavern-importer/regex.d.ts.map +1 -0
  77. package/dist/sillytavern-importer/regex.js +275 -0
  78. package/dist/sillytavern-importer/regex.js.map +1 -0
  79. package/dist/sillytavern-importer/report.d.ts +21 -0
  80. package/dist/sillytavern-importer/report.d.ts.map +1 -0
  81. package/dist/sillytavern-importer/report.js +166 -0
  82. package/dist/sillytavern-importer/report.js.map +1 -0
  83. package/dist/sillytavern-importer/types.d.ts +106 -0
  84. package/dist/sillytavern-importer/types.d.ts.map +1 -0
  85. package/dist/sillytavern-importer/types.js +2 -0
  86. package/dist/sillytavern-importer/types.js.map +1 -0
  87. package/dist/sillytavern-importer.d.ts +5 -0
  88. package/dist/sillytavern-importer.d.ts.map +1 -0
  89. package/dist/sillytavern-importer.js +117 -0
  90. package/dist/sillytavern-importer.js.map +1 -0
  91. package/dist/slot-renderers.d.ts +25 -0
  92. package/dist/slot-renderers.d.ts.map +1 -0
  93. package/dist/slot-renderers.js +334 -0
  94. package/dist/slot-renderers.js.map +1 -0
  95. package/dist/stack-migration.d.ts +29 -0
  96. package/dist/stack-migration.d.ts.map +1 -0
  97. package/dist/stack-migration.js +126 -0
  98. package/dist/stack-migration.js.map +1 -0
  99. package/dist/storage.d.ts +10 -0
  100. package/dist/storage.d.ts.map +1 -0
  101. package/dist/storage.js +36 -0
  102. package/dist/storage.js.map +1 -0
  103. package/dist/types.d.ts +173 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +2 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/web-editor/index.d.ts +3 -0
  108. package/dist/web-editor/index.d.ts.map +1 -0
  109. package/dist/web-editor/index.js +2 -0
  110. package/dist/web-editor/index.js.map +1 -0
  111. package/dist/web-editor/page.d.ts +2 -0
  112. package/dist/web-editor/page.d.ts.map +1 -0
  113. package/dist/web-editor/page.js +3331 -0
  114. package/dist/web-editor/page.js.map +1 -0
  115. package/dist/web-editor/server.d.ts +4 -0
  116. package/dist/web-editor/server.d.ts.map +1 -0
  117. package/dist/web-editor/server.js +263 -0
  118. package/dist/web-editor/server.js.map +1 -0
  119. package/dist/web-editor/types.d.ts +125 -0
  120. package/dist/web-editor/types.d.ts.map +1 -0
  121. package/dist/web-editor/types.js +2 -0
  122. package/dist/web-editor/types.js.map +1 -0
  123. package/dist/web-host.d.ts +29 -0
  124. package/dist/web-host.d.ts.map +1 -0
  125. package/dist/web-host.js +174 -0
  126. package/dist/web-host.js.map +1 -0
  127. package/examples/custom-system-status-extension/README.md +85 -0
  128. package/examples/custom-system-status-extension/index.ts +136 -0
  129. package/examples/custom-system-status-extension/prompt-stack.json +39 -0
  130. package/examples/image-reader-prompt-stack.json +124 -0
  131. package/package.json +28 -2
  132. package/src/compiler.ts +2 -472
  133. package/src/extension-registry.ts +33 -0
  134. package/src/forge-extensions.ts +223 -0
  135. package/src/index.ts +208 -653
  136. package/src/lifecycle.ts +171 -0
  137. package/src/macro-engine.ts +358 -0
  138. package/src/payload-command.ts +138 -0
  139. package/src/preset-command.ts +280 -0
  140. package/src/preview.ts +226 -0
  141. package/src/render-helpers.ts +169 -0
  142. package/src/runtime-state.ts +40 -0
  143. package/src/sillytavern-importer/items.ts +98 -0
  144. package/src/sillytavern-importer/macros.ts +159 -0
  145. package/src/sillytavern-importer/prompt-order.ts +54 -0
  146. package/src/sillytavern-importer/regex.ts +270 -0
  147. package/src/sillytavern-importer/report.ts +202 -0
  148. package/src/sillytavern-importer/types.ts +120 -0
  149. package/src/sillytavern-importer.ts +55 -865
  150. package/src/slot-renderers.ts +414 -0
  151. package/src/storage.ts +17 -0
  152. package/src/types.ts +3 -14
  153. package/src/web-editor/page.ts +516 -30
  154. package/src/web-editor/server.ts +9 -4
  155. package/src/web-editor/types.ts +18 -4
  156. package/src/web-host.ts +16 -13
@@ -192,6 +192,11 @@ html, body {
192
192
  overflow: auto;
193
193
  height: calc(100% - 74px);
194
194
  }
195
+ .side-empty {
196
+ color: var(--muted);
197
+ font-size: 12px;
198
+ padding: 8px;
199
+ }
195
200
  .stack-row {
196
201
  display: block;
197
202
  width: 100%;
@@ -579,6 +584,17 @@ html, body {
579
584
  color: var(--muted);
580
585
  padding: 24px;
581
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
+ }
582
598
  .diagnostics {
583
599
  border-top: 1px solid var(--line);
584
600
  background: var(--pane);
@@ -798,7 +814,7 @@ html, body {
798
814
  grid-template-columns: minmax(160px, 240px) minmax(220px, 1fr) minmax(180px, 260px) 168px;
799
815
  }
800
816
  .policy-row {
801
- grid-template-columns: minmax(120px, 170px) minmax(260px, 320px) minmax(260px, 1fr) minmax(180px, 260px);
817
+ grid-template-columns: minmax(110px, 150px) minmax(230px, 290px) minmax(220px, 1fr) minmax(220px, 1fr) minmax(150px, 220px);
802
818
  }
803
819
  .policy-title {
804
820
  font-weight: 650;
@@ -812,11 +828,77 @@ html, body {
812
828
  .policy-patterns {
813
829
  min-height: 96px;
814
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
+ }
815
857
  .policy-summary {
816
858
  color: var(--muted);
817
859
  font-size: 12px;
818
860
  overflow-wrap: anywhere;
819
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
+ }
820
902
  .regex-row {
821
903
  grid-template-columns: 72px minmax(0, 1fr) 86px;
822
904
  }
@@ -939,10 +1021,11 @@ html, body {
939
1021
  </aside>
940
1022
  <main class="main">
941
1023
  <div class="main-actions">
1024
+ <button id="newStackBtn" data-icon="+" title="Create a new prompt stack (Ctrl/Cmd+N)">New stack</button>
942
1025
  <button id="activateBtn" class="primary" data-icon="▶" title="Make this stack active for the current Pi session">Activate</button>
943
- <button id="saveBtn" class="primary" data-icon="✓" title="Save the edited stack JSON to disk">Save</button>
944
- <button id="validateBtn" data-icon="!" title="Validate the edited stack without saving">Validate</button>
945
- <button id="previewBtn" data-icon="◱" title="Preview the compiled prompt without sending it">Preview</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>
946
1029
  <button id="payloadBtn" data-icon="◆" title="Capture the next provider payload in the browser">Arm payload</button>
947
1030
  <button id="forkBtn" data-icon="⑂" title="Create a new stack from the current edits">Fork</button>
948
1031
  <button id="importBtn" data-icon="⇪" title="Import pi-forge stack JSON or SillyTavern preset JSON">Import JSON</button>
@@ -972,7 +1055,8 @@ html, body {
972
1055
  <span id="itemCount" class="stack-meta"></span>
973
1056
  </div>
974
1057
  <div class="item-tools">
975
- <button id="addItemBtn" data-icon="+" title="Add an item, then choose block or slot in the editor">Add item</button>
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>
976
1060
  <span class="item-tools-spacer"></span>
977
1061
  <button id="deleteItemBtn" class="danger" data-icon="×" title="Delete the selected stack item">Delete item</button>
978
1062
  </div>
@@ -1008,6 +1092,7 @@ let sidebarCollapsed = false;
1008
1092
  let slotOptionsMode = "form";
1009
1093
  let previewCopyTexts = [];
1010
1094
  let payloadSnapshot = { status: "idle" };
1095
+ let policyResources = { tools: [], skills: [] };
1011
1096
  let latestDiagnostics = [];
1012
1097
  let stackVariablesError = "";
1013
1098
  let regexRulesError = "";
@@ -1097,11 +1182,29 @@ function renderDirtyState() {
1097
1182
  const badge = el("dirtyBadge");
1098
1183
  if (!badge) return;
1099
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
+ });
1100
1199
  }
1101
1200
 
1102
1201
  async function loadStacks(preferId = selectedId) {
1103
- const data = await api("/api/stacks");
1202
+ const [data, resources] = await Promise.all([
1203
+ api("/api/stacks"),
1204
+ api("/api/resources"),
1205
+ ]);
1104
1206
  stacks = data.stacks || [];
1207
+ policyResources = normalizePolicyResources(resources);
1105
1208
  cwd = data.cwd || "";
1106
1209
  el("cwd").textContent = cwd;
1107
1210
  renderStackList();
@@ -1134,6 +1237,7 @@ function renderAll(diagnostics = []) {
1134
1237
  renderActiveTab();
1135
1238
  renderDiagnostics(diagnostics);
1136
1239
  hidePreview();
1240
+ updateActionState();
1137
1241
  }
1138
1242
 
1139
1243
  function renderActiveTab() {
@@ -1160,6 +1264,10 @@ function renderActiveTab() {
1160
1264
  function renderStackList() {
1161
1265
  const list = el("stackList");
1162
1266
  list.innerHTML = "";
1267
+ if (!stacks.length) {
1268
+ list.innerHTML = '<div class="side-empty">No prompt stacks in this project.</div>';
1269
+ return;
1270
+ }
1163
1271
  for (const stack of stacks) {
1164
1272
  const row = document.createElement("button");
1165
1273
  row.className = "stack-row" + (stack.active ? " active" : "") + (stack.id === selectedId ? " selected" : "");
@@ -1172,6 +1280,26 @@ function renderStackList() {
1172
1280
  }
1173
1281
  }
1174
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
+
1175
1303
  function renderSettings() {
1176
1304
  const settings = el("settings");
1177
1305
  if (!currentStack) {
@@ -1492,6 +1620,9 @@ function renderSlotOptionsForm(item, options) {
1492
1620
  if (["tools", "tool-guidelines", "skills", "project-context"].includes(item.slot)) {
1493
1621
  fields.push(optionSelect("format", "Format", options.format || "xml", ["xml", "plain"]));
1494
1622
  }
1623
+ if (["date", "date-cwd"].includes(item.slot)) {
1624
+ fields.push(optionCheckbox("includeTime", "Include current time", options.includeTime === true));
1625
+ }
1495
1626
  if (fields.length === 0) {
1496
1627
  fields.push('<div class="wide option-note">This slot has no structured options yet. Use JSON mode for advanced settings.</div>');
1497
1628
  }
@@ -1555,7 +1686,7 @@ function setSlotOption(item, key, value, defaultValue) {
1555
1686
 
1556
1687
  function defaultSlotOptionValue(key) {
1557
1688
  if (["includeLastUserMessage", "includeSummaries", "includeStatic", "includeSession", "includeTurn"].includes(key)) return true;
1558
- if (key === "stripAssistantThinking") return false;
1689
+ if (["stripAssistantThinking", "includeTime"].includes(key)) return false;
1559
1690
  if (key === "toolMode") return "keep";
1560
1691
  if (key === "format") return "xml";
1561
1692
  return undefined;
@@ -1591,6 +1722,7 @@ function optionHelp(key) {
1591
1722
  includeStatic: "Include static stack variables in this variables slot.",
1592
1723
  includeSession: "Include session variables created by template macros.",
1593
1724
  includeTurn: "Include temporary turn variables created during prompt compilation.",
1725
+ includeTime: "Render the current time in HH:MM:SS after the current date.",
1594
1726
  format: "Choose XML or compact plain text rendering.",
1595
1727
  };
1596
1728
  return descriptions[key] || "Advanced slot option.";
@@ -1652,7 +1784,7 @@ function renderPolicyTab() {
1652
1784
  '<div class="tab-section-title">Tool and skill policy</div>' +
1653
1785
  '<div class="tab-section-meta">Choose one mode per resource. Patterns support exact names and * wildcards.</div>' +
1654
1786
  '<div class="data-table" id="policyRows">' +
1655
- '<div class="data-row header policy-row"><div>Resource</div><div>Mode</div><div>Patterns</div><div>Status</div></div>' +
1787
+ '<div class="data-row header policy-row"><div>Resource</div><div>Mode</div><div>Patterns</div><div>Available</div><div>Status</div></div>' +
1656
1788
  policyRowHtml("tools", "Tools") +
1657
1789
  policyRowHtml("skills", "Skills") +
1658
1790
  '</div>' +
@@ -1663,7 +1795,9 @@ function renderPolicyTab() {
1663
1795
  function policyRowHtml(kind, label) {
1664
1796
  const policy = stackPolicyObject(kind);
1665
1797
  const mode = policyMode(policy);
1666
- const patterns = policyPatternsToText(mode === "deny" ? policy.deny : policy.allow);
1798
+ const rawPatterns = mode === "deny" ? policy.deny : mode === "allow" ? policy.allow : [];
1799
+ const patterns = Array.isArray(rawPatterns) ? rawPatterns : [];
1800
+ const patternText = policyPatternsToText(patterns);
1667
1801
  const disabled = mode === "none" ? " disabled" : "";
1668
1802
  return '<div class="data-row policy-row" data-policy-row data-policy-kind="' + attr(kind) + '" data-policy-mode="' + attr(mode) + '">' +
1669
1803
  '<div><div class="policy-title">' + escapeHtml(label) + '</div><div class="modal-meta">' + escapeHtml(kind) + '</div></div>' +
@@ -1672,28 +1806,186 @@ function policyRowHtml(kind, label) {
1672
1806
  policyModeButton("allow", "Allow", mode) +
1673
1807
  policyModeButton("deny", "Deny", mode) +
1674
1808
  '</div></div>' +
1675
- '<div class="field"><label>Patterns</label><textarea class="policy-patterns" data-policy-patterns spellcheck="false" placeholder="' + attr(policyPatternPlaceholder(mode)) + '"' + disabled + '>' + escapeHtml(patterns) + '</textarea></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) +
1676
1811
  '<div class="policy-summary" data-policy-summary>' + escapeHtml(policySummary(kind, policy)) + '</div>' +
1677
1812
  '</div>';
1678
1813
  }
1679
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
+
1680
1882
  function policyModeButton(value, label, current) {
1681
1883
  return '<button type="button" data-policy-mode-option="' + attr(value) + '" class="' + (value === current ? "active" : "") + '">' + escapeHtml(label) + '</button>';
1682
1884
  }
1683
1885
 
1684
1886
  function bindPolicyEditor() {
1685
- document.querySelectorAll("[data-policy-row] [data-policy-mode-option]").forEach((button) => {
1686
- button.onclick = () => {
1687
- const row = button.closest("[data-policy-row]");
1688
- setPolicyRowMode(row, button.dataset.policyModeOption);
1689
- if (button.dataset.policyModeOption === "none") row.querySelector("[data-policy-patterns]").value = "";
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 = "";
1690
1894
  syncResourcePolicyFromTab();
1691
- };
1692
- });
1693
- document.querySelectorAll("[data-policy-row] textarea").forEach((control) => {
1694
- control.oninput = () => syncResourcePolicyFromTab();
1695
- });
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
+ };
1696
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");
1697
1989
  }
1698
1990
 
1699
1991
  function syncResourcePolicyFromTab() {
@@ -1717,6 +2009,7 @@ function syncResourcePolicyFromTab() {
1717
2009
  stackPolicyError = errors[0] || "";
1718
2010
  markDirty();
1719
2011
  refreshPolicySummaries();
2012
+ refreshPolicyResourceControls();
1720
2013
  if (stackPolicyError) setStatus(stackPolicyError, "error");
1721
2014
  }
1722
2015
 
@@ -2284,7 +2577,7 @@ async function createStackRemote(stack, options = {}) {
2284
2577
  }
2285
2578
  }
2286
2579
 
2287
- async function openImportedStack(stack, activate, actionLabel, extraOptions = {}) {
2580
+ async function createAndOpenStack(stack, activate, actionLabel, extraOptions = {}) {
2288
2581
  const data = await createStackRemote(stack, { ...extraOptions, activate });
2289
2582
  stacks = data.stacks || stacks;
2290
2583
  selectedId = data.stack?.id || stack.id;
@@ -2296,6 +2589,143 @@ async function openImportedStack(stack, activate, actionLabel, extraOptions = {}
2296
2589
  if (data.importReport) showImportReport(data.importReport, selectedId);
2297
2590
  }
2298
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
+
2299
2729
  function showImportReport(report, stackId) {
2300
2730
  showStackModal(
2301
2731
  "SillyTavern import report",
@@ -2324,7 +2754,7 @@ async function handleImportFile(event) {
2324
2754
  const characterId = promptSillyTavernCharacterId(imported);
2325
2755
  if (characterId === null) return;
2326
2756
  const activate = confirm("Convert and activate imported SillyTavern stack now?");
2327
- await openImportedStack(imported, activate, "Imported", { sourceName: file.name, characterId });
2757
+ await createAndOpenStack(imported, activate, "Imported", { sourceName: file.name, characterId });
2328
2758
  return;
2329
2759
  }
2330
2760
 
@@ -2338,7 +2768,7 @@ async function handleImportFile(event) {
2338
2768
  if (!stack.schemaVersion) stack.schemaVersion = 1;
2339
2769
  if (!stack.type) stack.type = "pi-forge.prompt-stack";
2340
2770
  const activate = confirm("Activate imported stack now?");
2341
- await openImportedStack(stack, activate, "Imported");
2771
+ await createAndOpenStack(stack, activate, "Imported");
2342
2772
  }
2343
2773
 
2344
2774
  function isSillyTavernImport(value) {
@@ -2372,7 +2802,7 @@ async function forkStack() {
2372
2802
  if (forkName && forkName.trim()) fork.name = forkName;
2373
2803
  fork.autoActivate = false;
2374
2804
  const activate = confirm("Activate fork now?");
2375
- await openImportedStack(fork, activate, "Forked");
2805
+ await createAndOpenStack(fork, activate, "Forked");
2376
2806
  }
2377
2807
 
2378
2808
  async function exportStackJson() {
@@ -2414,6 +2844,15 @@ function uniqueForkId(baseId) {
2414
2844
  return candidate;
2415
2845
  }
2416
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
+
2417
2856
  function sanitizeStackId(value) {
2418
2857
  return String(value || "")
2419
2858
  .trim()
@@ -2716,16 +3155,33 @@ function renderEmpty() {
2716
3155
  currentStack = null;
2717
3156
  selectedId = "";
2718
3157
  dirty = false;
3158
+ activeTab = "items";
2719
3159
  stackPolicyError = "";
2720
3160
  renderDirtyState();
3161
+ document.querySelectorAll("[data-tab]").forEach((button) => {
3162
+ button.classList.toggle("active", button.dataset.tab === activeTab);
3163
+ });
3164
+ el("workspace").style.display = "";
2721
3165
  el("metadataPanel").style.display = "none";
2722
3166
  el("settings").innerHTML = "";
3167
+ el("itemCount").textContent = "";
2723
3168
  el("itemList").innerHTML = "";
2724
- el("itemEditor").innerHTML = '<div class="empty">No prompt stacks found.</div>';
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>';
2725
3178
  el("tabPanel").classList.remove("open");
2726
3179
  el("tabPanel").innerHTML = "";
2727
3180
  renderDiagnostics([]);
3181
+ el("emptyNewStackBtn").onclick = () => run(createNewStack);
3182
+ el("emptyImportBtn").onclick = () => run(importStackJson);
2728
3183
  setStatus("No prompt stacks found");
3184
+ updateActionState();
2729
3185
  }
2730
3186
 
2731
3187
  function field(label, control, className = "") {
@@ -2774,6 +3230,7 @@ function toggleSidebar() {
2774
3230
 
2775
3231
  el("sidebarToggleBtn").onclick = toggleSidebar;
2776
3232
  el("themeBtn").onclick = toggleTheme;
3233
+ el("newStackBtn").onclick = () => run(createNewStack);
2777
3234
  el("reloadBtn").onclick = () => run(reloadFromDisk);
2778
3235
  el("disableBtn").onclick = () => run(disableStacks);
2779
3236
  el("activateBtn").onclick = () => run(activateStack);
@@ -2823,15 +3280,44 @@ el("preview").onclick = (event) => {
2823
3280
  run(() => copyPreviewText(Number(button.dataset.copyIndex)));
2824
3281
  };
2825
3282
  el("addItemBtn").onclick = () => addItem("block");
3283
+ el("addSlotBtn").onclick = () => addItem("slot");
2826
3284
  el("deleteItemBtn").onclick = deleteSelectedItem;
2827
3285
  document.addEventListener("dragover", handleDocumentItemDragOver);
2828
3286
  document.addEventListener("drop", handleDocumentItemDrop);
2829
3287
  window.onbeforeunload = () => dirty ? "Unsaved changes" : undefined;
2830
- window.addEventListener("keydown", (event) => {
2831
- if (event.key !== "Escape") return;
2832
- if (el("preview").classList.contains("open")) hidePreview();
2833
- else if (el("stackModal").classList.contains("open")) closeStackModal();
2834
- });
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
+ }
2835
3321
 
2836
3322
  run(async () => {
2837
3323
  await loadStacks();