synthos 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/README.md +5 -5
  2. package/default-pages/elevenlabs_effects_studio/chat-history.json +1 -0
  3. package/default-pages/elevenlabs_effects_studio/page.html +1345 -1363
  4. package/default-pages/elevenlabs_effects_studio/page.json +13 -11
  5. package/default-pages/elevenlabs_voice_studio/chat-history.json +1 -0
  6. package/default-pages/elevenlabs_voice_studio/page.html +782 -801
  7. package/default-pages/elevenlabs_voice_studio/page.json +13 -11
  8. package/default-pages/json_tools/chat-history.json +1 -0
  9. package/default-pages/json_tools/page.html +70 -90
  10. package/default-pages/json_tools/page.json +12 -10
  11. package/default-pages/my_notes/chat-history.json +1 -0
  12. package/default-pages/my_notes/page.html +115 -131
  13. package/default-pages/my_notes/page.json +14 -12
  14. package/default-pages/neon_asteroids/chat-history.json +1 -0
  15. package/default-pages/neon_asteroids/page.html +1777 -1803
  16. package/default-pages/neon_asteroids/page.json +14 -12
  17. package/default-pages/oregon_trail/chat-history.json +1 -0
  18. package/default-pages/oregon_trail/page.html +290 -307
  19. package/default-pages/oregon_trail/page.json +14 -12
  20. package/default-pages/solar_explorer/chat-history.json +1 -0
  21. package/default-pages/solar_explorer/page.html +1929 -1951
  22. package/default-pages/solar_explorer/page.json +14 -12
  23. package/default-pages/solar_tutorial/chat-history.json +1 -0
  24. package/default-pages/solar_tutorial/page.html +464 -478
  25. package/default-pages/solar_tutorial/page.json +12 -10
  26. package/default-pages/us_map/chat-history.json +1 -0
  27. package/default-pages/us_map/page.html +170 -193
  28. package/default-pages/us_map/page.json +14 -12
  29. package/default-pages/us_map/page.light.png +0 -0
  30. package/default-pages/us_map_1850/chat-history.json +1 -0
  31. package/default-pages/us_map_1850/page.html +302 -326
  32. package/default-pages/us_map_1850/page.json +14 -12
  33. package/default-pages/western_cities_1850/chat-history.json +1 -0
  34. package/default-pages/western_cities_1850/page.html +503 -527
  35. package/default-pages/western_cities_1850/page.json +14 -12
  36. package/default-themes/aurora-dawn.v3.css +15 -14
  37. package/default-themes/aurora-dusk.v3.css +26 -26
  38. package/default-themes/cosmos-dawn.v3.css +15 -14
  39. package/default-themes/cosmos-dusk.v3.css +26 -26
  40. package/default-themes/elemental-dawn.v3.css +200 -0
  41. package/default-themes/nebula-dawn.v3.css +15 -14
  42. package/default-themes/nebula-dusk.v3.css +24 -24
  43. package/default-themes/solar-flare-dawn.v3.css +15 -14
  44. package/default-themes/solar-flare-dusk.v3.css +26 -26
  45. package/dist/builders/anthropic.d.ts +26 -2
  46. package/dist/builders/anthropic.d.ts.map +1 -1
  47. package/dist/builders/anthropic.js +132 -31
  48. package/dist/builders/anthropic.js.map +1 -1
  49. package/dist/builders/claudecode.d.ts +13 -0
  50. package/dist/builders/claudecode.d.ts.map +1 -0
  51. package/dist/builders/claudecode.js +253 -0
  52. package/dist/builders/claudecode.js.map +1 -0
  53. package/dist/builders/index.d.ts +2 -1
  54. package/dist/builders/index.d.ts.map +1 -1
  55. package/dist/builders/index.js +8 -1
  56. package/dist/builders/index.js.map +1 -1
  57. package/dist/builders/openai.js +2 -1
  58. package/dist/builders/openai.js.map +1 -1
  59. package/dist/builders/types.d.ts +31 -7
  60. package/dist/builders/types.d.ts.map +1 -1
  61. package/dist/builders/types.js +60 -28
  62. package/dist/builders/types.js.map +1 -1
  63. package/dist/connectors/types.d.ts +8 -0
  64. package/dist/connectors/types.d.ts.map +1 -1
  65. package/dist/init.d.ts.map +1 -1
  66. package/dist/init.js +13 -6
  67. package/dist/init.js.map +1 -1
  68. package/dist/migrations.d.ts.map +1 -1
  69. package/dist/migrations.js +161 -14
  70. package/dist/migrations.js.map +1 -1
  71. package/dist/models/anthropic.d.ts +1 -0
  72. package/dist/models/anthropic.d.ts.map +1 -1
  73. package/dist/models/anthropic.js +129 -29
  74. package/dist/models/anthropic.js.map +1 -1
  75. package/dist/models/chainOfThought.d.ts.map +1 -1
  76. package/dist/models/chainOfThought.js +32 -19
  77. package/dist/models/chainOfThought.js.map +1 -1
  78. package/dist/models/index.d.ts +2 -2
  79. package/dist/models/index.d.ts.map +1 -1
  80. package/dist/models/index.js +2 -1
  81. package/dist/models/index.js.map +1 -1
  82. package/dist/models/providers.d.ts +1 -0
  83. package/dist/models/providers.d.ts.map +1 -1
  84. package/dist/models/providers.js +12 -4
  85. package/dist/models/providers.js.map +1 -1
  86. package/dist/models/types.d.ts +15 -1
  87. package/dist/models/types.d.ts.map +1 -1
  88. package/dist/models/types.js.map +1 -1
  89. package/dist/pages.d.ts +57 -8
  90. package/dist/pages.d.ts.map +1 -1
  91. package/dist/pages.js +258 -45
  92. package/dist/pages.js.map +1 -1
  93. package/dist/service/createCompletePrompt.d.ts.map +1 -1
  94. package/dist/service/createCompletePrompt.js +5 -0
  95. package/dist/service/createCompletePrompt.js.map +1 -1
  96. package/dist/service/mediaCache.d.ts +36 -0
  97. package/dist/service/mediaCache.d.ts.map +1 -0
  98. package/dist/service/mediaCache.js +182 -0
  99. package/dist/service/mediaCache.js.map +1 -0
  100. package/dist/service/pageValidator.d.ts +25 -0
  101. package/dist/service/pageValidator.d.ts.map +1 -0
  102. package/dist/service/pageValidator.js +315 -0
  103. package/dist/service/pageValidator.js.map +1 -0
  104. package/dist/service/server.d.ts.map +1 -1
  105. package/dist/service/server.js +4 -0
  106. package/dist/service/server.js.map +1 -1
  107. package/dist/service/sharedTableSchema.d.ts +73 -0
  108. package/dist/service/sharedTableSchema.d.ts.map +1 -0
  109. package/dist/service/sharedTableSchema.js +206 -0
  110. package/dist/service/sharedTableSchema.js.map +1 -0
  111. package/dist/service/transformPage.d.ts +49 -11
  112. package/dist/service/transformPage.d.ts.map +1 -1
  113. package/dist/service/transformPage.js +354 -241
  114. package/dist/service/transformPage.js.map +1 -1
  115. package/dist/service/useApiRoutes.d.ts.map +1 -1
  116. package/dist/service/useApiRoutes.js +288 -34
  117. package/dist/service/useApiRoutes.js.map +1 -1
  118. package/dist/service/useConnectorRoutes.d.ts.map +1 -1
  119. package/dist/service/useConnectorRoutes.js +170 -32
  120. package/dist/service/useConnectorRoutes.js.map +1 -1
  121. package/dist/service/useDataRoutes.d.ts.map +1 -1
  122. package/dist/service/useDataRoutes.js +59 -2
  123. package/dist/service/useDataRoutes.js.map +1 -1
  124. package/dist/service/useExtractRoutes.d.ts +4 -0
  125. package/dist/service/useExtractRoutes.d.ts.map +1 -0
  126. package/dist/service/useExtractRoutes.js +304 -0
  127. package/dist/service/useExtractRoutes.js.map +1 -0
  128. package/dist/service/usePageRoutes.d.ts +17 -0
  129. package/dist/service/usePageRoutes.d.ts.map +1 -1
  130. package/dist/service/usePageRoutes.js +1385 -483
  131. package/dist/service/usePageRoutes.js.map +1 -1
  132. package/dist/service/useSharedDataRoutes.d.ts.map +1 -1
  133. package/dist/service/useSharedDataRoutes.js +54 -2
  134. package/dist/service/useSharedDataRoutes.js.map +1 -1
  135. package/dist/settings.d.ts +27 -0
  136. package/dist/settings.d.ts.map +1 -1
  137. package/dist/settings.js +40 -1
  138. package/dist/settings.js.map +1 -1
  139. package/dist/themes.d.ts +0 -5
  140. package/dist/themes.d.ts.map +1 -1
  141. package/dist/themes.js +3 -95
  142. package/dist/themes.js.map +1 -1
  143. package/migration-rules/v2-to-v3.md +277 -119
  144. package/package.json +5 -1
  145. package/{default-pages/application → required-pages/_shell}/page.html +56 -42
  146. package/required-pages/_shell/page.json +14 -0
  147. package/required-pages/_starters/page.html +534 -0
  148. package/required-pages/_starters/page.json +12 -0
  149. package/required-pages/builder/page.html +353 -43
  150. package/required-pages/builder/page.json +12 -10
  151. package/required-pages/pages/page.html +697 -924
  152. package/required-pages/pages/page.json +12 -10
  153. package/required-pages/settings/page.html +1879 -1753
  154. package/required-pages/settings/page.json +12 -10
  155. package/required-pages/synthos_apis/page.html +834 -845
  156. package/required-pages/synthos_apis/page.json +12 -10
  157. package/required-pages/synthos_scripts/page.html +74 -88
  158. package/required-pages/synthos_scripts/page.json +12 -10
  159. package/scripts/append-instructions.py +90 -0
  160. package/scripts/audit-instructions.py +76 -0
  161. package/scripts/cleanup-shell-markup.mjs +112 -0
  162. package/service-connectors/buffer/connector.json +46 -0
  163. package/service-connectors/canva/connector.json +67 -0
  164. package/service-connectors/elevenlabs/connector.json +1 -1
  165. package/src/builders/anthropic.ts +150 -25
  166. package/src/builders/claudecode.ts +310 -0
  167. package/src/builders/index.ts +7 -1
  168. package/src/builders/openai.ts +2 -1
  169. package/src/builders/types.ts +93 -32
  170. package/src/connectors/types.ts +8 -0
  171. package/src/init.ts +13 -7
  172. package/src/migrations.ts +187 -16
  173. package/src/models/anthropic.ts +140 -30
  174. package/src/models/chainOfThought.ts +33 -18
  175. package/src/models/index.ts +2 -2
  176. package/src/models/providers.ts +10 -1
  177. package/src/models/types.ts +21 -1
  178. package/src/pages.ts +271 -35
  179. package/src/service/createCompletePrompt.ts +6 -0
  180. package/src/service/mediaCache.ts +206 -0
  181. package/src/service/pageValidator.ts +337 -0
  182. package/src/service/server.ts +4 -0
  183. package/src/service/sharedTableSchema.ts +236 -0
  184. package/src/service/transformPage.ts +370 -260
  185. package/src/service/useApiRoutes.ts +282 -32
  186. package/src/service/useConnectorRoutes.ts +189 -34
  187. package/src/service/useDataRoutes.ts +198 -116
  188. package/src/service/useExtractRoutes.ts +331 -0
  189. package/src/service/usePageRoutes.ts +1411 -394
  190. package/src/service/useSharedDataRoutes.ts +184 -109
  191. package/src/settings.ts +65 -0
  192. package/src/themes.ts +78 -180
  193. package/starters/blank_starter/chat-history.json +1 -0
  194. package/starters/blank_starter/page.dark.png +0 -0
  195. package/starters/blank_starter/page.html +47 -0
  196. package/starters/blank_starter/page.json +13 -0
  197. package/starters/blank_starter/page.light.png +0 -0
  198. package/starters/calculator_starter/chat-history.json +1 -0
  199. package/starters/calculator_starter/page.dark.png +0 -0
  200. package/starters/calculator_starter/page.html +232 -0
  201. package/starters/calculator_starter/page.json +13 -0
  202. package/starters/calculator_starter/page.light.png +0 -0
  203. package/starters/calendar_starter/chat-history.json +1 -0
  204. package/starters/calendar_starter/page.dark.png +0 -0
  205. package/starters/calendar_starter/page.html +495 -0
  206. package/starters/calendar_starter/page.json +13 -0
  207. package/starters/calendar_starter/page.light.png +0 -0
  208. package/starters/chat_starter/chat-history.json +1 -0
  209. package/starters/chat_starter/page.dark.png +0 -0
  210. package/starters/chat_starter/page.html +351 -0
  211. package/starters/chat_starter/page.json +13 -0
  212. package/starters/chat_starter/page.light.png +0 -0
  213. package/starters/checklist_starter/chat-history.json +1 -0
  214. package/starters/checklist_starter/page.dark.png +0 -0
  215. package/starters/checklist_starter/page.html +437 -0
  216. package/starters/checklist_starter/page.json +13 -0
  217. package/starters/checklist_starter/page.light.png +0 -0
  218. package/starters/dashboard_starter/chat-history.json +1 -0
  219. package/starters/dashboard_starter/page.dark.png +0 -0
  220. package/starters/dashboard_starter/page.html +195 -0
  221. package/starters/dashboard_starter/page.json +13 -0
  222. package/starters/dashboard_starter/page.light.png +0 -0
  223. package/starters/form_starter/chat-history.json +1 -0
  224. package/starters/form_starter/page.dark.png +0 -0
  225. package/starters/form_starter/page.html +313 -0
  226. package/starters/form_starter/page.json +13 -0
  227. package/starters/form_starter/page.light.png +0 -0
  228. package/starters/gallery_starter/chat-history.json +1 -0
  229. package/starters/gallery_starter/page.dark.png +0 -0
  230. package/starters/gallery_starter/page.html +418 -0
  231. package/starters/gallery_starter/page.json +13 -0
  232. package/starters/gallery_starter/page.light.png +0 -0
  233. package/starters/generator_starter/chat-history.json +1 -0
  234. package/starters/generator_starter/page.dark.png +0 -0
  235. package/starters/generator_starter/page.html +261 -0
  236. package/starters/generator_starter/page.json +13 -0
  237. package/starters/generator_starter/page.light.png +0 -0
  238. package/starters/index.html +538 -0
  239. package/starters/kanban_starter/chat-history.json +1 -0
  240. package/starters/kanban_starter/page.dark.png +0 -0
  241. package/starters/kanban_starter/page.html +432 -0
  242. package/starters/kanban_starter/page.json +13 -0
  243. package/starters/kanban_starter/page.light.png +0 -0
  244. package/starters/presentation_builder/chat-history.json +1 -0
  245. package/starters/presentation_builder/page.dark.png +0 -0
  246. package/starters/presentation_builder/page.html +970 -0
  247. package/starters/presentation_builder/page.json +15 -0
  248. package/starters/presentation_builder/page.light.png +0 -0
  249. package/starters/presentation_builder/presentation_voice/voice_config.json +9 -0
  250. package/starters/pulse_starter/chat-history.json +1 -0
  251. package/starters/pulse_starter/page.dark.png +0 -0
  252. package/starters/pulse_starter/page.html +698 -0
  253. package/starters/pulse_starter/page.json +13 -0
  254. package/starters/pulse_starter/page.light.png +0 -0
  255. package/starters/quiz_starter/chat-history.json +1 -0
  256. package/starters/quiz_starter/page.dark.png +0 -0
  257. package/starters/quiz_starter/page.html +292 -0
  258. package/starters/quiz_starter/page.json +13 -0
  259. package/starters/quiz_starter/page.light.png +0 -0
  260. package/starters/reference_starter/chat-history.json +1 -0
  261. package/starters/reference_starter/page.dark.png +0 -0
  262. package/starters/reference_starter/page.html +250 -0
  263. package/starters/reference_starter/page.json +13 -0
  264. package/starters/reference_starter/page.light.png +0 -0
  265. package/starters/retro_game_starter/chat-history.json +1 -0
  266. package/starters/retro_game_starter/page.dark.png +0 -0
  267. package/{default-pages → starters}/retro_game_starter/page.html +1281 -1308
  268. package/starters/retro_game_starter/page.json +15 -0
  269. package/starters/retro_game_starter/page.light.png +0 -0
  270. package/starters/roster_starter/chat-history.json +1 -0
  271. package/starters/roster_starter/page.dark.png +0 -0
  272. package/starters/roster_starter/page.html +600 -0
  273. package/starters/roster_starter/page.json +13 -0
  274. package/starters/roster_starter/page.light.png +0 -0
  275. package/starters/server.js +182 -0
  276. package/starters/start.cmd +1 -0
  277. package/starters/timeline_starter/chat-history.json +1 -0
  278. package/starters/timeline_starter/page.dark.png +0 -0
  279. package/starters/timeline_starter/page.html +446 -0
  280. package/starters/timeline_starter/page.json +13 -0
  281. package/starters/timeline_starter/page.light.png +0 -0
  282. package/starters/tutorial_starter/chat-history.json +1 -0
  283. package/starters/tutorial_starter/page.dark.png +0 -0
  284. package/starters/tutorial_starter/page.html +283 -0
  285. package/starters/tutorial_starter/page.json +13 -0
  286. package/starters/tutorial_starter/page.light.png +0 -0
  287. package/static-files/agent.v3.js +122 -0
  288. package/static-files/connector.v3.js +48 -0
  289. package/static-files/extract.v3.js +188 -0
  290. package/static-files/helpers.v3.js +50 -6
  291. package/static-files/page-bridge.js +114 -0
  292. package/static-files/page.v3.js +1292 -1290
  293. package/static-files/script.v3.js +32 -0
  294. package/static-files/server.v3.js +89 -0
  295. package/static-files/shell-bridge.v3.js +174 -0
  296. package/static-files/shell-modals.v3.js +521 -0
  297. package/static-files/{shell.css → shell.v3.css} +271 -22
  298. package/static-files/shell.v3.js +1865 -0
  299. package/static-files/storage.v3.js +176 -0
  300. package/tests/anthropic.spec.ts +42 -7
  301. package/tests/builders.spec.ts +70 -2
  302. package/tests/pageValidator.spec.ts +548 -0
  303. package/tests/profiles.spec.ts +122 -0
  304. package/tests/sharedTableSchema.spec.ts +242 -0
  305. package/tests/transformPage.spec.ts +62 -81
  306. package/default-pages/application/page.json +0 -10
  307. package/default-pages/retro_game_starter/page.json +0 -12
  308. package/default-pages/sidebar_page/page.html +0 -51
  309. package/default-pages/sidebar_page/page.json +0 -10
  310. package/default-pages/two-panel_page/page.html +0 -68
  311. package/default-pages/two-panel_page/page.json +0 -10
@@ -26,7 +26,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
26
26
  return (mod && mod.__esModule) ? mod : { "default": mod };
27
27
  };
28
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
- exports.loadPageWithFallback = exports.usePageRoutes = void 0;
29
+ exports.loadPageWithFallback = exports.usePageRoutes = exports.applySectionModeOverrides = exports.assembleSections = void 0;
30
30
  const pages_1 = require("../pages");
31
31
  const settings_1 = require("../settings");
32
32
  const transformPage_1 = require("./transformPage");
@@ -34,11 +34,15 @@ const createCompletePrompt_1 = require("./createCompletePrompt");
34
34
  const debugLog_1 = require("./debugLog");
35
35
  const themes_1 = require("../themes");
36
36
  const builders_1 = require("../builders");
37
+ const anthropic_1 = require("../builders/anthropic");
37
38
  const connectors_1 = require("../connectors");
38
39
  const scripts_1 = require("../scripts");
39
40
  const path_1 = __importDefault(require("path"));
41
+ const fs = __importStar(require("fs"));
42
+ const crypto = __importStar(require("crypto"));
40
43
  const files_1 = require("../files");
41
44
  const cheerio = __importStar(require("cheerio"));
45
+ const sharedTableSchema_1 = require("./sharedTableSchema");
42
46
  /**
43
47
  * Required CDN imports that must be present on every v2 page.
44
48
  * Each entry maps a detection selector to the script tag src to inject.
@@ -47,23 +51,113 @@ const REQUIRED_IMPORTS = [
47
51
  { selector: 'script[src*="marked"]', src: 'https://cdnjs.cloudflare.com/ajax/libs/marked/14.1.1/marked.min.js' },
48
52
  { selector: 'script[src*="html2canvas"]', src: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js' },
49
53
  ];
54
+ // ensureRequiredImports is now ensureRequiredImports$() — operates on shared $ instance
55
+ const HOME_PAGE_ROUTE = '/builder';
56
+ const PAGE_NOT_FOUND = 'Page not found';
57
+ const SHELL_PAGE_NAME = '_shell';
58
+ const NO_PERSIST_CATEGORIES = ['_Starters', 'System'];
59
+ // Pages that are served as top-level documents (peers to _shell) — never wrapped
60
+ // in the shell iframe chrome. The page itself owns the full viewport.
61
+ const STANDALONE_PAGE_NAMES = ['_starters'];
62
+ // ---------------------------------------------------------------------------
63
+ // Cache-busting for /static assets
64
+ // ---------------------------------------------------------------------------
65
+ // Versions are computed lazily from file mtime + size, so any edit/deploy
66
+ // produces a new ?v=<hash> suffix and clients fetch fresh content.
67
+ let _staticFolders = [];
68
+ const _assetVersions = new Map();
69
+ function initStaticAssetVersioning(folders) {
70
+ _staticFolders = folders;
71
+ _assetVersions.clear();
72
+ }
73
+ function getAssetVersion(filename) {
74
+ const cached = _assetVersions.get(filename);
75
+ if (cached)
76
+ return cached;
77
+ for (const folder of _staticFolders) {
78
+ try {
79
+ const stat = fs.statSync(path_1.default.join(folder, filename));
80
+ const version = crypto.createHash('sha1')
81
+ .update(`${stat.mtimeMs}:${stat.size}`)
82
+ .digest('hex').slice(0, 8);
83
+ _assetVersions.set(filename, version);
84
+ return version;
85
+ }
86
+ catch { /* try next folder */ }
87
+ }
88
+ _assetVersions.set(filename, 'dev');
89
+ return 'dev';
90
+ }
91
+ function assetUrl(filename) {
92
+ return `/static/${filename}?v=${getAssetVersion(filename)}`;
93
+ }
50
94
  /**
51
- * Uses cheerio to ensure every required import is present in the page's <head>.
52
- * Skips imports that already exist (detected via selector).
95
+ * Rewrite /static/<file> URLs in HTML to append cache-busting ?v=<hash>.
96
+ * Matches href= and src= attributes only; leaves URLs with existing query strings alone.
53
97
  */
54
- function ensureRequiredImports(html, pageVersion) {
55
- if (pageVersion < 2)
98
+ function applyAssetVersions(html) {
99
+ return html.replace(/(href|src)="\/static\/([^"?]+)"/g, (_m, attr, file) => `${attr}="${assetUrl(file)}"`);
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Per-page concurrency lock
103
+ // ---------------------------------------------------------------------------
104
+ // Serializes concurrent transform/patch requests for the same page to prevent
105
+ // version clobbering (two requests reading the same currentVersion, both
106
+ // writing to page.v{N+1}.html). Different pages run in parallel.
107
+ const pageLocks = new Map();
108
+ function withPageLock(page, fn) {
109
+ const prev = pageLocks.get(page) ?? Promise.resolve();
110
+ let releaseLock;
111
+ const next = new Promise(resolve => { releaseLock = resolve; });
112
+ pageLocks.set(page, next);
113
+ return prev.then(fn).finally(() => {
114
+ releaseLock();
115
+ // Clean up if no new work was queued behind us
116
+ if (pageLocks.get(page) === next)
117
+ pageLocks.delete(page);
118
+ });
119
+ }
120
+ /** Attributes safe to perform product-name replacement in (user-visible text). */
121
+ const SAFE_REPLACE_ATTRS = ['alt', 'placeholder', 'aria-label', 'title', 'content'];
122
+ /**
123
+ * Replace "SynthOS" (case-insensitive) with the custom product name in
124
+ * user-visible text only. Skips <script>, <style>, and code-bearing
125
+ * attributes (class, id, src, href, onclick, data-*, etc.) to avoid
126
+ * breaking `window.synthos.*` API calls, CSS classes, and JS identifiers.
127
+ */
128
+ function replaceProductName(html, productName) {
129
+ if (productName === 'SynthOS')
56
130
  return html;
57
131
  const $ = cheerio.load(html);
58
- for (const imp of REQUIRED_IMPORTS) {
59
- if ($(imp.selector).length === 0) {
60
- $('head').append(`<script src="${imp.src}"></script>\n`);
132
+ const regex = /synthos/gi;
133
+ // Replace in safe attributes on all elements
134
+ $('*').each((_i, el) => {
135
+ if (el.type !== 'tag')
136
+ return;
137
+ const elem = $(el);
138
+ for (const attr of SAFE_REPLACE_ATTRS) {
139
+ const val = elem.attr(attr);
140
+ if (val && regex.test(val)) {
141
+ elem.attr(attr, val.replace(regex, productName));
142
+ }
61
143
  }
62
- }
144
+ });
145
+ // Replace in text nodes, but skip <script> and <style> contents.
146
+ // Use a simple selector approach: find all text-bearing elements that
147
+ // are NOT script or style, and replace text in their direct text nodes.
148
+ $('*').not('script, style').contents().each((_i, node) => {
149
+ if (node.type === 'text' && node.data) {
150
+ // Skip if parent is script or style (contents() can include nested)
151
+ const parentTag = node.parentNode?.tagName?.toLowerCase?.() ?? node.parentNode?.name?.toLowerCase?.();
152
+ if (parentTag === 'script' || parentTag === 'style')
153
+ return;
154
+ if (regex.test(node.data)) {
155
+ node.data = node.data.replace(regex, productName);
156
+ }
157
+ }
158
+ });
63
159
  return $.html();
64
160
  }
65
- const HOME_PAGE_ROUTE = '/builder';
66
- const PAGE_NOT_FOUND = 'Page not found';
67
161
  function injectPageInfoScript(html, pageName) {
68
162
  const tag = `<script id="page-info" src="/api/page-info.js?page=${encodeURIComponent(pageName)}"></script>`;
69
163
  // Replace any existing page-info script (may have a stale page name from the template)
@@ -77,43 +171,7 @@ function injectPageInfoScript(html, pageName) {
77
171
  }
78
172
  return tag + '\n' + html;
79
173
  }
80
- function injectPageHelpers(html, pageVersion) {
81
- if (pageVersion < 2)
82
- return html;
83
- const tag = `<script id="page-helpers" src="/api/page-helpers.js?v=${pageVersion}"></script>`;
84
- // Remove any existing page-helpers script (may be at wrong position from prior LLM output)
85
- // so it gets re-injected at the correct position below.
86
- const existing = html.match(/<script\s+id="page-helpers"[^>]*><\/script>/);
87
- if (existing) {
88
- html = html.replace(existing[0], '');
89
- }
90
- // Inject into <head> after page-info so helpers are available before inline body scripts
91
- const pageInfo = html.indexOf('id="page-info"');
92
- if (pageInfo !== -1) {
93
- const closeTag = html.indexOf('</script>', pageInfo);
94
- if (closeTag !== -1) {
95
- const insertAt = closeTag + '</script>'.length;
96
- return html.slice(0, insertAt) + '\n' + tag + html.slice(insertAt);
97
- }
98
- }
99
- const idx = html.indexOf('</head>');
100
- if (idx !== -1) {
101
- return html.slice(0, idx) + tag + '\n' + html.slice(idx);
102
- }
103
- return tag + '\n' + html;
104
- }
105
- function injectPageScript(html, pageVersion) {
106
- if (pageVersion < 2)
107
- return html;
108
- if (html.includes('id="page-script"'))
109
- return html;
110
- const tag = `<script id="page-script" src="/api/page-script.js?v=${pageVersion}"></script>`;
111
- const idx = html.indexOf('</body>');
112
- if (idx !== -1) {
113
- return html.slice(0, idx) + tag + '\n' + html.slice(idx);
114
- }
115
- return html + '\n' + tag;
116
- }
174
+ // injectPageHelpers is now injectPageHelpers$() — operates on shared $ instance
117
175
  /**
118
176
  * Wrap each inline <script> body in an IIFE so that top-level const/let
119
177
  * declarations become function-scoped. This prevents "Identifier … has
@@ -138,92 +196,27 @@ function wrapInlineScriptsInIIFE(html) {
138
196
  return `<script${attrs}>(function(){${body}})();</script>`;
139
197
  });
140
198
  }
141
- /**
142
- * Move any external <script src="..."> tags found in <body> up to the end
143
- * of <head>, preserving their relative order. Library scripts (d3, Chart.js,
144
- * marked, etc.) only define globals and don't touch the DOM, so executing
145
- * them in <head> is safe and guarantees they are available before any inline
146
- * <body> scripts run. This also eliminates browser "parser-blocking cross
147
- * site script invoked via document.write" warnings.
148
- */
149
- function hoistExternalScriptsToHead(html) {
150
- const $ = cheerio.load(html, { decodeEntities: false });
151
- const hoisted = [];
152
- $('body script[src]').each((_, el) => {
153
- const script = $(el);
154
- // Don't move data-locked system scripts (page-script, page-helpers, etc.)
155
- if (script.attr('data-locked') !== undefined)
156
- return;
157
- hoisted.push($.html(script));
158
- script.remove();
159
- });
160
- if (hoisted.length > 0) {
161
- $('head').append(hoisted.join('\n') + '\n');
162
- }
163
- return $.html();
164
- }
199
+ // hoistExternalScriptsToHead is now hoistExternalScriptsToHead$() — operates on shared $ instance
165
200
  /**
166
201
  * Inline error-capture script injected as the first child of <head> so it
167
202
  * registers window.onerror / unhandledrejection *before* any page scripts run.
168
203
  * Stripped before page transformation so the LLM never sees it.
169
204
  */
170
- const ERROR_CAPTURE_ID = 'synthos-error-capture';
171
- const ERROR_CAPTURE_SCRIPT = `<script id="${ERROR_CAPTURE_ID}">
172
- (function(){
173
- var E=window.__synthOSErrors=[];
174
- window.onerror=function(m,s,l,c,e){
175
- var entry=m+' at '+(s||'?')+':'+(l||'?')+':'+(c||'?');
176
- if(e&&e.stack)entry+='\\n'+e.stack;
177
- E.push(entry);showErr();return false;
178
- };
179
- window.addEventListener('unhandledrejection',function(ev){
180
- var r=ev.reason;
181
- E.push('Unhandled rejection: '+(r&&r.stack?r.stack:String(r)));showErr();
182
- });
183
- function showErr(){
184
- var cm=document.getElementById('chatMessages');if(!cm)return;
185
- if(showErr._p)return;showErr._p=true;
186
- setTimeout(function(){
187
- showErr._p=false;
188
- var d=document.createElement('div');d.className='chat-message';
189
- var p=document.createElement('p');
190
- var pn=(window.pageInfo&&window.pageInfo.productName)||'SynthOS';
191
- p.innerHTML='<strong>'+pn+':</strong> I noticed a JavaScript error on this page. '+
192
- '<a href="#" style="color:var(--accent-primary,#a78bfa);text-decoration:underline;cursor:pointer" '+
193
- 'onclick="(function(e){e.preventDefault();var ci=document.getElementById(\\'chatInput\\');'+
194
- 'var f=document.getElementById(\\'chatForm\\');if(!ci||!f)return;'+
195
- 'ci.value=\\'Fix the following JavaScript errors on this page:\\\\n\\\\nCONSOLE_ERRORS:\\\\n\\'+window.__synthOSErrors.join(\\'\\\\n---\\\\n\\');'+
196
- 'window.__synthOSErrors=[];f.requestSubmit?f.requestSubmit():f.submit();})(event)">'+
197
- 'Let me try to fix it</a>';
198
- d.appendChild(p);cm.appendChild(d);
199
- cm.scrollTo({top:cm.scrollHeight,behavior:'smooth'});
200
- },500);
201
- }
202
- })();
203
- </script>`;
204
- function injectErrorCapture(html, pageVersion) {
205
- if (pageVersion < 2)
206
- return html;
207
- if (html.includes(`id="${ERROR_CAPTURE_ID}"`))
208
- return html;
209
- const $ = cheerio.load(html, { decodeEntities: false });
210
- $('head').prepend(ERROR_CAPTURE_SCRIPT + '\n');
211
- return $.html();
212
- }
213
205
  /**
214
- * Inject shell.css (always) and FluentLM base CSS/JS (v3 themes only).
215
- * For v3 themes also adds the theme name class to <html> so scoped rules apply.
206
+ * Inject shell.v3.css and FluentLM base CSS/JS (always the shell always emits
207
+ * FluentLM-keyed modals). The theme-name class on <html> stays gated to v3 so
208
+ * pre-v3 themes don't accidentally activate scoped rules.
216
209
  */
217
210
  function injectShellAssets(html, themeName, themeVersion, toolbarPosition) {
218
211
  const $ = cheerio.load(html, { decodeEntities: false });
219
212
  const themeLink = $('link#theme-css');
220
213
  // Favicon
221
214
  if ($('link#synthos-favicon').length === 0) {
222
- $('head').prepend('<link id="synthos-favicon" rel="icon" type="image/svg+xml" href="/static/favicon.svg">\n');
215
+ $('head').prepend(`<link id="synthos-favicon" rel="icon" type="image/svg+xml" href="${assetUrl('favicon.svg')}">\n`);
223
216
  }
224
- // shell.css — always injected (provides toolbar + layout chrome)
217
+ // shell.v3.css — always injected (provides toolbar + layout chrome)
225
218
  if ($('link#shell-css').length === 0) {
226
- const shellLink = '<link id="shell-css" rel="stylesheet" href="/static/shell.css">';
219
+ const shellLink = `<link id="shell-css" rel="stylesheet" href="${assetUrl('shell.v3.css')}">`;
227
220
  if (themeLink.length > 0) {
228
221
  themeLink.before(shellLink + '\n');
229
222
  }
@@ -231,36 +224,291 @@ function injectShellAssets(html, themeName, themeVersion, toolbarPosition) {
231
224
  $('head').append(shellLink + '\n');
232
225
  }
233
226
  }
234
- // FluentLM assets — only for v3 themes
227
+ // Theme-name class on <html> — only for v3 themes that ship scoped rules
235
228
  if (themeVersion >= 3) {
236
- // Add theme name class to <html> (e.g. "nebula-dusk")
237
229
  $('html').addClass(themeName);
238
- // Inject CSS: fluentlm.min.css before shell.css (load order matters)
239
- if ($('link#fluentlm-css').length === 0) {
240
- const fluentLink = '<link id="fluentlm-css" rel="stylesheet" href="/static/fluentlm.min.css">';
241
- const shellCss = $('link#shell-css');
242
- if (shellCss.length > 0) {
243
- shellCss.before(fluentLink + '\n');
244
- }
245
- else if (themeLink.length > 0) {
246
- themeLink.before(fluentLink + '\n');
247
- }
248
- else {
249
- $('head').append(fluentLink + '\n');
250
- }
230
+ }
231
+ // FluentLM CSS always injected; shell modals depend on .flm-dialog-overlay
232
+ if ($('link#fluentlm-css').length === 0) {
233
+ const fluentLink = `<link id="fluentlm-css" rel="stylesheet" href="${assetUrl('fluentlm.min.css')}">`;
234
+ const shellCss = $('link#shell-css');
235
+ if (shellCss.length > 0) {
236
+ shellCss.before(fluentLink + '\n');
251
237
  }
252
- // Inject FluentLM JS before </body>
253
- if ($('script#fluentlm-js').length === 0) {
254
- const fluentScript = '<script id="fluentlm-js" src="/static/fluentlm.min.js"></script>';
255
- $('body').append(fluentScript + '\n');
238
+ else if (themeLink.length > 0) {
239
+ themeLink.before(fluentLink + '\n');
240
+ }
241
+ else {
242
+ $('head').append(fluentLink + '\n');
256
243
  }
257
244
  }
245
+ // FluentLM JS — always injected
246
+ if ($('script#fluentlm-js').length === 0) {
247
+ const fluentScript = `<script id="fluentlm-js" src="${assetUrl('fluentlm.min.js')}"></script>`;
248
+ $('body').append(fluentScript + '\n');
249
+ }
258
250
  $('html').attr('data-toolbar', toolbarPosition || 'left');
259
251
  return $.html();
260
252
  }
261
253
  // ---------------------------------------------------------------------------
254
+ // iframe architecture helpers
255
+ // ---------------------------------------------------------------------------
256
+ // ---------------------------------------------------------------------------
257
+ // Cheerio-based transform steps (operate on shared $ instance)
258
+ // ---------------------------------------------------------------------------
259
+ /**
260
+ * Strip shell markup (toolbar, chat panel) from the in-memory DOM.
261
+ */
262
+ function stripShellMarkup$($) {
263
+ $('.shell-toolbar').remove();
264
+ $('.chat-panel').remove();
265
+ $('#loadingOverlay').remove();
266
+ $('script#page-script').remove();
267
+ $('script#synthos-error-capture').remove();
268
+ // v2-legacy idle animation — the inline <script id="idle-animation"> block
269
+ // declares top-level hideIdleAnimation/showIdleAnimation functions that
270
+ // get wrapped in an IIFE by wrapInlineScriptsInIIFE, so cross-script
271
+ // callers hit ReferenceError. Strip the block and provide no-op globals
272
+ // via the stub script below.
273
+ $('script#idle-animation').remove();
274
+ // Inject hidden stub elements for shell IDs that legacy page scripts may
275
+ // reference (chatForm, chatInput, chatMessages, loadingOverlay) and no-op
276
+ // globals for v2-era helper functions (hideIdleAnimation,
277
+ // showIdleAnimation) that used to live on window via top-level function
278
+ // declarations. Still needed: 17+ pages reference these IDs. Remove once
279
+ // all pages are migrated to use postMessage bridge instead of direct DOM
280
+ // access.
281
+ const stubScript = `<script data-shell-compat="true">(function(){` +
282
+ `var s={chatForm:'form',chatInput:'textarea',chatMessages:'div',loadingOverlay:'div'};` +
283
+ `for(var id in s){if(!document.getElementById(id)){` +
284
+ `var el=document.createElement(s[id]);el.id=id;el.style.display='none';` +
285
+ `document.body.appendChild(el);}}` +
286
+ `if(typeof window.hideIdleAnimation!=='function')window.hideIdleAnimation=function(){};` +
287
+ `if(typeof window.showIdleAnimation!=='function')window.showIdleAnimation=function(){};` +
288
+ `})();</script>`;
289
+ $('body').prepend(stubScript);
290
+ }
291
+ /**
292
+ * Ensure every required CDN import is present in the page's <head>.
293
+ */
294
+ function ensureRequiredImports$($, pageVersion) {
295
+ if (pageVersion < 2)
296
+ return;
297
+ for (const imp of REQUIRED_IMPORTS) {
298
+ if ($(imp.selector).length === 0) {
299
+ $('head').append(`<script src="${imp.src}"></script>\n`);
300
+ }
301
+ }
302
+ }
303
+ /**
304
+ * Inject FluentLM CSS/JS and theme class into page content for iframe rendering.
305
+ */
306
+ function injectContentAssets$($, themeName, themeVersion) {
307
+ // Inject iframe viewport styles
308
+ if ($('style#iframe-viewport').length === 0) {
309
+ $('head').prepend('<style id="iframe-viewport">html,body,.viewer-panel{height:100%;margin:0}</style>\n');
310
+ }
311
+ // Move non-locked <script> tags from <head> to end of <body>.
312
+ const headScripts = $('head script').not('[data-locked]');
313
+ headScripts.each((_, el) => {
314
+ const $el = $(el);
315
+ $el.remove();
316
+ $('body').append($el);
317
+ $('body').append('\n');
318
+ });
319
+ // Theme-name class on <html> — only for v3 themes that ship scoped rules
320
+ if (themeVersion >= 3) {
321
+ $('html').addClass(themeName);
322
+ }
323
+ // FluentLM CSS + JS — always injected; page-level FluentLM usage should work
324
+ // regardless of theme version.
325
+ if ($('link#fluentlm-css').length === 0) {
326
+ const fluentLink = `<link id="fluentlm-css" rel="stylesheet" href="${assetUrl('fluentlm.min.css')}">`;
327
+ const themeLink = $('link#theme-css');
328
+ if (themeLink.length > 0) {
329
+ themeLink.before(fluentLink + '\n');
330
+ }
331
+ else {
332
+ $('head').append(fluentLink + '\n');
333
+ }
334
+ }
335
+ if ($('script#fluentlm-js').length === 0) {
336
+ $('body').append(`<script id="fluentlm-js" src="${assetUrl('fluentlm.min.js')}"></script>\n`);
337
+ }
338
+ }
339
+ /**
340
+ * Inject page-info script tag into <head>.
341
+ */
342
+ function injectPageInfoScript$($, pageName) {
343
+ const tag = `<script id="page-info" src="/api/page-info.js?page=${encodeURIComponent(pageName)}"></script>`;
344
+ const existing = $('script#page-info');
345
+ if (existing.length > 0) {
346
+ existing.replaceWith(tag);
347
+ }
348
+ else {
349
+ $('head').append(tag + '\n');
350
+ }
351
+ }
352
+ /**
353
+ * V3 helper script IDs and their static file names.
354
+ * shell-bridge.v3.js loads first (creates window.synthos); the rest attach namespaces.
355
+ */
356
+ const V3_HELPER_SCRIPTS = [
357
+ { id: 'shell-bridge-v3', file: 'shell-bridge.v3.js' },
358
+ { id: 'server-v3', file: 'server.v3.js' },
359
+ { id: 'storage-v3', file: 'storage.v3.js' },
360
+ { id: 'script-v3', file: 'script.v3.js' },
361
+ { id: 'connector-v3', file: 'connector.v3.js' },
362
+ { id: 'agent-v3', file: 'agent.v3.js' },
363
+ { id: 'extract-v3', file: 'extract.v3.js' },
364
+ ];
365
+ /**
366
+ * Inject v3 helper scripts into the page.
367
+ * shell-bridge.v3.js goes at end of <body> (bridge must be last to initialize after DOM).
368
+ * All other v3 scripts go into <head> after page-info.
369
+ */
370
+ function injectPageHelpers$($, pageVersion) {
371
+ if (pageVersion < 2)
372
+ return;
373
+ // Remove legacy monolithic helpers if present
374
+ $('script#page-helpers').remove();
375
+ $('script#page-bridge').remove();
376
+ // Remove old bridge id in case pages were rendered with an older version
377
+ $('script#shell-v3').remove();
378
+ // Inject v3 scripts
379
+ for (const script of V3_HELPER_SCRIPTS) {
380
+ if ($(`script#${script.id}`).length > 0)
381
+ continue;
382
+ const tag = `<script id="${script.id}" src="${assetUrl(script.file)}"></script>`;
383
+ if (script.id === 'shell-bridge-v3') {
384
+ // Bridge goes at end of body (needs to run after page DOM)
385
+ $('body').append(tag + '\n');
386
+ }
387
+ else {
388
+ // API scripts go into head after page-info
389
+ const pageInfo = $('script#page-info');
390
+ if (pageInfo.length > 0) {
391
+ pageInfo.after('\n' + tag);
392
+ }
393
+ else {
394
+ $('head').append(tag + '\n');
395
+ }
396
+ }
397
+ }
398
+ }
399
+ /**
400
+ * @deprecated No longer needed — shell-bridge.v3.js replaces page-bridge.js.
401
+ * Kept as a no-op for any call sites not yet updated.
402
+ */
403
+ function injectPageBridge$($, pageVersion) {
404
+ // No-op: shell-bridge.v3.js is now injected by injectPageHelpers$
405
+ }
406
+ /**
407
+ * Move external <script src="..."> from <body> to end of <head>.
408
+ */
409
+ function hoistExternalScriptsToHead$($) {
410
+ const hoisted = [];
411
+ $('body script[src]').each((_, el) => {
412
+ const script = $(el);
413
+ if (script.attr('data-locked') !== undefined)
414
+ return;
415
+ hoisted.push($.html(script));
416
+ script.remove();
417
+ });
418
+ if (hoisted.length > 0) {
419
+ $('head').append(hoisted.join('\n') + '\n');
420
+ }
421
+ }
422
+ /**
423
+ * Full iframe content preparation pipeline. Parses HTML once, applies all
424
+ * cheerio-based transforms on the in-memory DOM, serializes once, then runs
425
+ * string-based transforms (IIFE wrapping, product-name replacement).
426
+ *
427
+ * Centralised here so every endpoint that serves page HTML into the iframe
428
+ * goes through the same steps.
429
+ */
430
+ function prepareForIframe(html, page, pageVersion, themeName, themeVersion, productName) {
431
+ // Single cheerio parse
432
+ const $ = cheerio.load(html, { decodeEntities: false });
433
+ // All DOM transforms on the shared $ instance
434
+ stripShellMarkup$($);
435
+ ensureRequiredImports$($, pageVersion);
436
+ injectContentAssets$($, themeName, themeVersion);
437
+ injectPageInfoScript$($, page);
438
+ injectPageHelpers$($, pageVersion);
439
+ injectPageBridge$($, pageVersion);
440
+ hoistExternalScriptsToHead$($);
441
+ // Single serialize, then string-based transforms
442
+ let out = $.html();
443
+ out = wrapInlineScriptsInIIFE(out);
444
+ out = replaceProductName(out, productName);
445
+ return out;
446
+ }
447
+ /**
448
+ * Inject __shellInit script into the shell page with page-specific data.
449
+ */
450
+ function injectShellInit(shellHtml, page, messages, metadata, version, productName, builderStarter) {
451
+ const isBuilder = metadata.categories.some(c => c === 'Builders' || c === 'System' || c === '_Starters');
452
+ const noPersistHistory = metadata.categories.some(c => NO_PERSIST_CATEGORIES.includes(c));
453
+ const isStarter = metadata.categories.includes('_Starters');
454
+ const initData = JSON.stringify({
455
+ page,
456
+ messages,
457
+ version,
458
+ productName,
459
+ pageTitle: metadata.title,
460
+ pageCategories: metadata.categories,
461
+ isLocked: metadata.mode === 'locked',
462
+ isRequiredPage: metadata.isRequiredPage,
463
+ isBuilder,
464
+ noPersistHistory,
465
+ isStarter,
466
+ greeting: metadata.greeting,
467
+ firstRunGreeting: metadata.firstRunGreeting,
468
+ builderStarter: builderStarter ?? '',
469
+ });
470
+ const script = `<script id="shell-init">window.__shellInit=${initData};</script>`;
471
+ // Inject before shell-modals.v3.js — it's the earliest consumer of window.__shellInit
472
+ // and captures values at module-load time, so __shellInit MUST be set before it loads.
473
+ // Match href with or without cache-bust query.
474
+ const shellModalsIdx = shellHtml.search(/<script src="\/static\/shell-modals\.v3\.js(\?[^"]*)?">/);
475
+ if (shellModalsIdx !== -1) {
476
+ return shellHtml.slice(0, shellModalsIdx) + script + '\n' + shellHtml.slice(shellModalsIdx);
477
+ }
478
+ // Fallback: before shell.v3.js
479
+ const shellJsIdx = shellHtml.search(/<script src="\/static\/shell\.v3\.js(\?[^"]*)?">/);
480
+ if (shellJsIdx !== -1) {
481
+ return shellHtml.slice(0, shellJsIdx) + script + '\n' + shellHtml.slice(shellJsIdx);
482
+ }
483
+ // Fallback: inject before </body>
484
+ const bodyIdx = shellHtml.indexOf('</body>');
485
+ if (bodyIdx !== -1) {
486
+ return shellHtml.slice(0, bodyIdx) + script + '\n' + shellHtml.slice(bodyIdx);
487
+ }
488
+ return shellHtml + '\n' + script;
489
+ }
490
+ /**
491
+ * Set the iframe src attribute in the shell page HTML.
492
+ */
493
+ function setIframeSrc(shellHtml, page, starter) {
494
+ const starterQuery = starter ? `&starter=${encodeURIComponent(starter)}` : '';
495
+ return shellHtml.replace('id="viewerFrame"', `id="viewerFrame" src="/${page}?frame=1${starterQuery}"`);
496
+ }
497
+ // ---------------------------------------------------------------------------
262
498
  // Context section builders — assemble ContextSections from enabled features
263
499
  // ---------------------------------------------------------------------------
500
+ /**
501
+ * Extract the first non-empty line of each blank-separated block. Used to
502
+ * generate a sketch for sections whose full content is a list of method
503
+ * signatures + descriptions (e.g. SERVER_APIS, SERVER_SCRIPTS).
504
+ */
505
+ function firstLineOfEachBlock(s) {
506
+ return s
507
+ .split(/\n\s*\n/)
508
+ .map(b => b.trimStart().split('\n')[0].trim())
509
+ .filter(Boolean)
510
+ .join('\n');
511
+ }
264
512
  function buildContextSection() {
265
513
  const now = new Date();
266
514
  const dateTime = now.toLocaleString('en-US', {
@@ -270,32 +518,47 @@ function buildContextSection() {
270
518
  return {
271
519
  title: '<CONTEXT>',
272
520
  content: `Current date and time: ${dateTime}`,
521
+ sketch: null,
522
+ mode: 'always-full',
273
523
  instructions: '',
274
524
  };
275
525
  }
276
526
  function buildServerApisSection(customizer) {
277
- const content = customizer ? (0, transformPage_1.buildRouteHints)(customizer) : transformPage_1.serverAPIs;
527
+ const full = (customizer ? (0, transformPage_1.buildRouteHints)(customizer) : transformPage_1.serverAPIs)
528
+ .replace(/^<SERVER_APIS>\n?/, '');
278
529
  return {
279
530
  title: '<SERVER_APIS>',
280
- content: content.replace(/^<SERVER_APIS>\n?/, ''),
531
+ content: full,
532
+ sketch: firstLineOfEachBlock(full),
533
+ mode: 'classifier-decides',
534
+ forceFullOnInitial: true,
281
535
  instructions: 'provides a list of available server APIs and helper functions you can call from injected scripts. Use synthos.* helpers instead of raw fetch().',
282
536
  };
283
537
  }
284
538
  async function buildServerScriptsSection(pagesFolder) {
285
- const scripts = await (0, scripts_1.listScripts)(pagesFolder);
539
+ const scripts = (await (0, scripts_1.listScripts)(pagesFolder)) || '';
286
540
  return {
287
541
  title: '<SERVER_SCRIPTS>',
288
- content: scripts || '',
289
- instructions: 'provides a list of available scripts callable via synthos.scripts.run(id, variables).',
542
+ content: scripts,
543
+ sketch: scripts ? firstLineOfEachBlock(scripts) : null,
544
+ mode: 'always-omit-when-empty',
545
+ forceFullOnInitial: true,
546
+ instructions: 'provides a list of available scripts callable via synthos.script.run(id, variables).',
290
547
  };
291
548
  }
292
549
  function buildConnectorsSection(configuredConnectors) {
293
- if (!configuredConnectors)
294
- return undefined;
295
- const entries = Object.entries(configuredConnectors)
296
- .filter(([, cfg]) => cfg.enabled && cfg.apiKey);
297
- if (entries.length === 0)
298
- return undefined;
550
+ const entries = configuredConnectors
551
+ ? Object.entries(configuredConnectors).filter(([, cfg]) => cfg.enabled && cfg.apiKey)
552
+ : [];
553
+ if (entries.length === 0) {
554
+ return {
555
+ title: '<CONFIGURED_CONNECTORS>',
556
+ content: '',
557
+ sketch: null,
558
+ mode: 'always-omit-when-empty',
559
+ instructions: '',
560
+ };
561
+ }
299
562
  const blocks = entries.map(([id, cfg]) => {
300
563
  const def = (0, connectors_1.getConnectorRegistry)().find(d => d.id === id);
301
564
  if (!def)
@@ -317,17 +580,30 @@ function buildConnectorsSection(configuredConnectors) {
317
580
  }
318
581
  return block;
319
582
  });
320
- const content = `The user has configured and enabled these connectors:\n${blocks.join('\n\n')}\n\nYou may use synthos.connectors.call(connector, method, path, opts) to call them.\nIMPORTANT: Before making any connector call, ALWAYS check that the connector is configured first using synthos.connectors.list(). If the connector is not configured, show the user a friendly message with a link to the Settings > Connectors page (/settings?tab=connectors) so they can set it up.\nDo NOT hardcode API keys. The connector proxy attaches authentication automatically.`;
583
+ const content = `The user has configured and enabled these connectors:\n${blocks.join('\n\n')}\n\nYou may use synthos.connector.call(connector, method, path, opts) to call them.\nIMPORTANT: Before making any connector call, ALWAYS check that the connector is configured first using synthos.connector.list(). If the connector is not configured, show the user a friendly message with a link to the Settings > Connectors page (/settings?tab=connectors) so they can set it up.\nDo NOT hardcode API keys. The connector proxy attaches authentication automatically.`;
584
+ const sketchNames = entries.map(([id]) => {
585
+ const def = (0, connectors_1.getConnectorRegistry)().find(d => d.id === id);
586
+ return def?.name ?? id;
587
+ });
321
588
  return {
322
589
  title: '<CONFIGURED_CONNECTORS>',
323
590
  content,
591
+ sketch: `Configured connectors: ${sketchNames.join(', ')}`,
592
+ mode: 'always-omit-when-empty',
324
593
  instructions: '',
325
594
  };
326
595
  }
327
596
  function buildAgentsSection(configuredAgents) {
328
597
  const enabledAgents = (configuredAgents ?? []).filter(a => a.enabled);
329
- if (enabledAgents.length === 0)
330
- return undefined;
598
+ if (enabledAgents.length === 0) {
599
+ return {
600
+ title: '<CONFIGURED_AGENTS>',
601
+ content: '',
602
+ sketch: null,
603
+ mode: 'always-omit-when-empty',
604
+ instructions: '',
605
+ };
606
+ }
331
607
  const agentBlocks = enabledAgents.map(a => {
332
608
  let block = `- ${a.name} (id: "${a.id}", provider: ${a.provider})`;
333
609
  block += `\n Description: ${a.description}`;
@@ -340,9 +616,12 @@ function buildAgentsSection(configuredAgents) {
340
616
  }
341
617
  return block;
342
618
  });
619
+ const sketchLines = enabledAgents.map(a => `- ${a.name}: ${a.description}`);
343
620
  return {
344
621
  title: '<CONFIGURED_AGENTS>',
345
622
  content: `The user has configured these agents:\n\n${agentBlocks.join('\n\n')}\n\n${transformPage_1.AGENT_API_REFERENCE}`,
623
+ sketch: `Configured agents:\n${sketchLines.join('\n')}`,
624
+ mode: 'always-omit-when-empty',
346
625
  instructions: '',
347
626
  };
348
627
  }
@@ -353,12 +632,42 @@ function buildThemeSection(themeInfo) {
353
632
  const colorList = Object.entries(colors)
354
633
  .map(([name, value]) => ` --${name}: ${value}`)
355
634
  .join('\n');
356
- content = `Mode: ${mode}\nCSS custom properties (use instead of hardcoded values):\n${colorList}\n\nShared shell classes (pre-styled by theme, do not redefine):\n .chat-panel — Left sidebar container (30% width)\n .chat-header — Chat panel title bar\n .chat-messages — Scrollable message container\n .chat-message — Individual message wrapper\n .chat-input — Message text input\n .chat-submit — Send button\n .viewer-panel — Right content area (70% width)\n .loading-overlay — Full-screen loading overlay\n .spinner — Animated loading spinner\n .modal-overlay — Full-screen modal backdrop (position:fixed, z-index:2000, backdrop-filter:blur). Add class "show" to display.\n .modal-content — Centered modal container\n .modal-header — Gradient header bar\n .modal-body — Modal content area\n .modal-footer — Bottom action bar (flex, space-between)\n .modal-footer-right — Right-aligned button group\n\nModals and popups: ALWAYS use the theme\'s .modal-overlay class for any modal or popup overlay. Do NOT create custom overlay classes with position:fixed and z-index. Structure:\n <div class="modal-overlay" id="myModal">\n <div class="modal-content">\n <div class="modal-header">Title</div>\n <div class="modal-body">Content</div>\n <div class="modal-footer"><div class="modal-footer-right"><button>OK</button></div></div>\n </div>\n </div>\nShow/hide by toggling the "show" class: el.classList.add(\'show\') / el.classList.remove(\'show\'). This ensures correct z-index layering above the chat toggle and other UI elements.\n\nPage title bars: To align with the chat header, apply these styles:\n min-height: var(--header-min-height);\n padding: var(--header-padding-vertical) var(--header-padding-horizontal);\n line-height: var(--header-line-height);\n display: flex; align-items: center; justify-content: center; box-sizing: border-box;\n\nFull-viewer mode: For games, animations, or full-screen content, add class "full-viewer" to the viewer-panel element to remove its padding.\n\nChat panel behaviours (auto-injected via page script — do NOT recreate in page code):\n The server injects page-v2.js after transformation. It provides:\n - Form submit handler: sets action to window.location.pathname, shows #loadingOverlay, disables inputs\n - Chat scroll to bottom (#chatMessages)\n - Chat toggle button (.chat-toggle) — created dynamically if not in markup\n - .chat-input-wrapper — wraps #chatInput with a brainstorm icon button\n - Brainstorm modal (#brainstormModal) — LLM-powered brainstorm UI, created dynamically\n - Focus management — keeps keyboard input directed to #chatInput\n\n Do NOT:\n - Create your own form submit handler, toggle button, or input wrapper\n - Modify or replace .chat-panel, .chat-header, #chatForm, or .chat-toggle\n - INSERT new <script> blocks that duplicate existing ones — when fixing JavaScript, UPDATE or REPLACE the existing script's nodeId instead. Always give inline scripts a unique id attribute.\n - Set the form action attribute (page-v2.js sets it dynamically)\n - Include these CSS rules (in the theme): #loadingOverlay position, .chat-submit:disabled, .chat-input:disabled\n\n To add chat messages: use insert with parentId of #chatMessages and position "append".\n #chatMessages is the only unlocked element inside .chat-panel.\n\nThe <html> element has class "${mode}-mode". Always add .light-mode CSS overrides for any page-specific styles so the page works in both light and dark themes, unless the user has explicitly requested a very specific color scheme.`;
635
+ content = `Mode: ${mode}
636
+ CSS custom properties (use instead of hardcoded values):
637
+ ${colorList}
638
+
639
+ Page rendering context: Your page runs inside an iframe. The shell (toolbar, chat panel, modals) is in the parent frame and is NOT part of your page. You own the full <body> — there is no .shell-toolbar, .chat-panel, #chatForm, or #loadingOverlay in your page.
640
+
641
+ Modals and popups: ALWAYS use FluentLM dialog components for any modal or popup. Do NOT create custom overlay classes with position:fixed and z-index. Structure:
642
+ <div class="flm-dialog-overlay" id="myModal" data-light-dismiss>
643
+ <div class="flm-dialog">
644
+ <div class="flm-dialog-header">
645
+ <h2 class="flm-dialog-title">Title</h2>
646
+ <button class="flm-dialog-close" data-icon="Cancel" aria-label="Close"></button>
647
+ </div>
648
+ <div class="flm-dialog-body">Content</div>
649
+ <div class="flm-dialog-footer">
650
+ <button class="flm-button" data-dialog-close>Cancel</button>
651
+ <button class="flm-button flm-button--primary" data-dialog-close>OK</button>
652
+ </div>
653
+ </div>
654
+ </div>
655
+ Show/hide via FluentLM JS: FluentLM auto-wires open/close on elements with data-dialog-open="#id" and data-dialog-close attributes. For programmatic control use: FluentLMDialogComponent.open('overlayId') and FluentLMDialogComponent.close('overlayId'). NEVER use classList.add('is-open') — the correct class is 'flm-dialog-overlay--open' but always prefer the JS API.
656
+
657
+ Navigation: To navigate to another page from within the iframe, use window.__synthOSNavigateTo(url). This routes through the parent shell which handles unsaved-changes checks.
658
+
659
+ Do NOT:
660
+ - Create shell chrome elements (.shell-toolbar, .chat-panel, #chatForm, #loadingOverlay, .chat-messages) — these belong to the parent frame
661
+ - INSERT new <script> blocks that duplicate existing ones — when fixing JavaScript, UPDATE or REPLACE the existing script's nodeId instead. Always give inline scripts a unique id attribute.
662
+
663
+ The <html> element has class "${mode}-mode". Always add .light-mode CSS overrides for any page-specific styles so the page works in both light and dark themes, unless the user has explicitly requested a very specific color scheme.`;
357
664
  }
358
665
  return {
359
666
  title: '<THEME>',
360
667
  content,
361
- instructions: 'provides details on the current theme\'s color scheme and shared shell classes to help you generate theme-aware pages that fit seamlessly into the user experience.',
668
+ sketch: null,
669
+ mode: 'always-full',
670
+ instructions: 'provides details on the current theme\'s color scheme to help you generate theme-aware pages.',
362
671
  };
363
672
  }
364
673
  const LLMD_READING_GUIDE = `Content below is LLMD v0.2 — a compressed, token-optimized format. Read it as follows:
@@ -404,58 +713,326 @@ function buildLlmdReadingGuideSection() {
404
713
  return {
405
714
  title: '<LLMD_READING_GUIDE>',
406
715
  content: LLMD_READING_GUIDE,
716
+ sketch: 'Reading guide for LLMD v0.2 — a token-compressed format used by some context sections (defines @scope, :k=v attrs, -lists, →relations, ::lang literal blocks, and meta keys).',
717
+ mode: 'classifier-decides',
718
+ forceFullOnInitial: true,
407
719
  instructions: '',
408
720
  };
409
721
  }
722
+ const FLUENTLM_INSTRUCTIONS = `<FLUENTLM_COMPONENTS> is the component library available on every page. You MUST use FluentLM components instead of writing custom HTML/CSS for standard UI elements.
723
+ REQUIRED: Use flm-button for buttons, flm-textfield for inputs, flm-dropdown for selects, flm-dialog/flm-panel/flm-modal for overlays, flm-pivot for tabs, flm-nav for navigation, flm-toggle for switches, flm-card for cards, flm-callout for tooltips, flm-messagebar for alerts, and all other components listed in <FLUENTLM_COMPONENTS>.
724
+ FORBIDDEN: Do NOT create custom CSS classes for buttons (e.g. .my-btn, .okr-btn), inputs, modals, cards, tabs, dropdowns, or any UI element that has a FluentLM equivalent. Do NOT use raw <button>, <input>, or <select> elements without FluentLM classes.
725
+ Apply FluentLM utility classes (flm-text--secondary, flm-stack, etc.) for layout and typography instead of custom CSS where possible.`;
410
726
  async function buildFluentLMSection(config) {
411
727
  const filePath = await (0, files_1.findFileInFolders)(config.staticFilesFolders, 'fluentlm-instructions.md');
412
- if (!filePath)
413
- return undefined;
728
+ if (!filePath) {
729
+ return {
730
+ title: '<FLUENTLM_COMPONENTS>',
731
+ content: '',
732
+ sketch: null,
733
+ mode: 'always-omit-when-empty',
734
+ instructions: '',
735
+ };
736
+ }
414
737
  try {
415
738
  const content = await (0, files_1.loadFile)(filePath);
739
+ const componentNames = (content.match(/^## (.+)$/gm) ?? [])
740
+ .map(h => h.replace(/^## /, '').trim())
741
+ .filter(name => !/^(Global Classes|Global Implementation Notes|CSS Custom Properties)$/i.test(name));
742
+ const sketch = componentNames.length > 0
743
+ ? `Available FluentLM components (call full reference for usage): ${componentNames.join(', ')}`
744
+ : 'FluentLM UI component library (full reference available on expand).';
416
745
  return {
417
746
  title: '<FLUENTLM_COMPONENTS>',
418
747
  content,
419
- instructions: `<FLUENTLM_COMPONENTS> is the component library available on every page. You MUST use FluentLM components instead of writing custom HTML/CSS for standard UI elements.
420
- REQUIRED: Use flm-button for buttons, flm-textfield for inputs, flm-dropdown for selects, flm-dialog/flm-panel/flm-modal for overlays, flm-pivot for tabs, flm-nav for navigation, flm-toggle for switches, flm-card for cards, flm-callout for tooltips, flm-messagebar for alerts, and all other components listed in <FLUENTLM_COMPONENTS>.
421
- FORBIDDEN: Do NOT create custom CSS classes for buttons (e.g. .my-btn, .okr-btn), inputs, modals, cards, tabs, dropdowns, or any UI element that has a FluentLM equivalent. Do NOT use raw <button>, <input>, or <select> elements without FluentLM classes.
422
- Apply FluentLM utility classes (flm-text--secondary, flm-stack, etc.) for layout and typography instead of custom CSS where possible.`,
748
+ sketch,
749
+ mode: 'always-omit-when-empty',
750
+ instructions: FLUENTLM_INSTRUCTIONS,
423
751
  };
424
752
  }
425
753
  catch {
426
- return undefined;
754
+ return {
755
+ title: '<FLUENTLM_COMPONENTS>',
756
+ content: '',
757
+ sketch: null,
758
+ mode: 'always-omit-when-empty',
759
+ instructions: '',
760
+ };
427
761
  }
428
762
  }
429
763
  async function buildRecommendedFrameworksSection(config) {
430
764
  const filePath = await (0, files_1.findFileInFolders)(config.staticFilesFolders, 'recommended-frameworks.llmd');
431
- if (!filePath)
432
- return undefined;
765
+ if (!filePath) {
766
+ return {
767
+ title: '<RECOMMENDED_FRAMEWORKS>',
768
+ content: '',
769
+ sketch: null,
770
+ mode: 'always-omit-when-empty',
771
+ instructions: '',
772
+ };
773
+ }
433
774
  try {
434
775
  const content = await (0, files_1.loadFile)(filePath);
776
+ const names = (content.match(/^@(\w+)/gm) ?? [])
777
+ .map(m => m.slice(1))
778
+ .filter(n => n !== 'recommended_frameworks');
779
+ const sketch = names.length > 0
780
+ ? `Recommended frameworks (with CDN URLs in full content): ${names.join(', ')}`
781
+ : 'Recommended third-party frameworks with CDN URLs (expand for details).';
435
782
  return {
436
783
  title: '<RECOMMENDED_FRAMEWORKS>',
437
784
  content,
438
- instructions: 'lists recommended third-party frameworks with CDN URLs. When a page needs a framework from this list, load it via <script> (or <link> for CSS) tags at the end of the <head> block. Always use the version shown in the CDN URL (it is the latest approved version).',
785
+ sketch,
786
+ mode: 'always-omit-when-empty',
787
+ instructions: 'lists recommended third-party frameworks with CDN URLs. When a page needs a framework from this list, load it via <link> for CSS in <head>, and <script> tags at the end of <body> (before your page scripts). Always use the version shown in the CDN URL (it is the latest approved version).',
439
788
  };
440
789
  }
441
790
  catch {
791
+ return {
792
+ title: '<RECOMMENDED_FRAMEWORKS>',
793
+ content: '',
794
+ sketch: null,
795
+ mode: 'always-omit-when-empty',
796
+ instructions: '',
797
+ };
798
+ }
799
+ }
800
+ // ---------------------------------------------------------------------------
801
+ // Shared / per-page table sections
802
+ // ---------------------------------------------------------------------------
803
+ // Surfaces table inventory + schemas + one example record per table so the
804
+ // builder generates consumer code against real field names. Both sections
805
+ // ship as `always-omit-when-empty` — workspaces with zero tables pay zero
806
+ // tokens. Spec: docs/specs/shared-tables-schema-sidecar.md.
807
+ const PAGE_TABLES_RESERVED = new Set(['files']);
808
+ /**
809
+ * Load one example record for a table — the first id alphabetically, picked
810
+ * deterministically so prompt content is stable across builds.
811
+ */
812
+ async function loadFirstRecord(config, parent, table) {
813
+ const sp = config.storageProvider;
814
+ const folder = path_1.default.join(parent, table);
815
+ if (!await sp.checkIfExists(folder))
442
816
  return undefined;
817
+ const files = (await sp.listFiles(folder)).filter(f => f.endsWith('.json')).sort();
818
+ if (files.length === 0)
819
+ return undefined;
820
+ try {
821
+ const raw = await sp.loadFile(path_1.default.join(folder, files[0]));
822
+ const parsed = JSON.parse(raw);
823
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
824
+ return parsed;
825
+ }
826
+ }
827
+ catch { /* skip unparsable */ }
828
+ return undefined;
829
+ }
830
+ async function gatherTablesForPrompt(config, parent, reserved) {
831
+ const tables = await (0, sharedTableSchema_1.listTables)(config, parent, reserved);
832
+ const out = [];
833
+ for (const t of tables) {
834
+ const wrapper = t.hasSchema ? await (0, sharedTableSchema_1.loadSchema)(config, parent, t.name) : undefined;
835
+ const example = t.recordCount > 0 ? await loadFirstRecord(config, parent, t.name) : undefined;
836
+ out.push({
837
+ name: t.name,
838
+ recordCount: t.recordCount,
839
+ hasSchema: !!wrapper,
840
+ schema: wrapper?.schema,
841
+ example,
842
+ });
843
+ }
844
+ return out;
845
+ }
846
+ function escapeXmlAttr(s) {
847
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
848
+ }
849
+ function renderTablesSketch(rootTag, tables) {
850
+ const lines = tables.map(t => ` <table name="${escapeXmlAttr(t.name)}" recordCount="${t.recordCount}" hasSchema="${t.hasSchema}" />`);
851
+ return `<${rootTag}>\n${lines.join('\n')}\n</${rootTag}>`;
852
+ }
853
+ function renderTablesFull(rootTag, tables) {
854
+ const blocks = tables.map(t => {
855
+ const parts = [];
856
+ parts.push(` <table name="${escapeXmlAttr(t.name)}" recordCount="${t.recordCount}">`);
857
+ if (t.schema) {
858
+ parts.push(` <schema>${JSON.stringify(t.schema)}</schema>`);
859
+ }
860
+ if (t.example) {
861
+ parts.push(` <example>${JSON.stringify(t.example)}</example>`);
862
+ }
863
+ parts.push(` </table>`);
864
+ return parts.join('\n');
865
+ });
866
+ return `<${rootTag}>\n${blocks.join('\n')}\n</${rootTag}>`;
867
+ }
868
+ const SHARED_TABLES_INSTRUCTIONS = 'lists shared tables visible across pages, with their schemas (when defined) and one example record. Use these to write code against the user\'s ACTUAL field names — do not invent names. Read/write via synthos.shared.data.* and synthos.shared.data.defineSchema(table, schema) when extending.';
869
+ const PAGE_TABLES_INSTRUCTIONS = 'lists per-page tables scoped to this page, with their schemas (when defined) and one example record. Use these to write code against the user\'s ACTUAL field names. Read/write via synthos.data.* and synthos.data.defineSchema(table, schema) when extending.';
870
+ async function buildSharedTablesSection(config) {
871
+ const parent = path_1.default.join(config.pagesFolder, 'shared');
872
+ const tables = await gatherTablesForPrompt(config, parent, new Set());
873
+ if (tables.length === 0) {
874
+ return {
875
+ title: '<SHARED_TABLES>',
876
+ content: '',
877
+ sketch: null,
878
+ mode: 'always-omit-when-empty',
879
+ instructions: '',
880
+ };
443
881
  }
882
+ return {
883
+ title: '<SHARED_TABLES>',
884
+ content: renderTablesFull('SHARED_TABLES', tables),
885
+ sketch: renderTablesSketch('SHARED_TABLES', tables),
886
+ mode: 'always-omit-when-empty',
887
+ instructions: SHARED_TABLES_INSTRUCTIONS,
888
+ };
444
889
  }
445
- function buildMessageFormatSection(productName) {
890
+ async function buildPageTablesSection(config, pageName) {
891
+ const parent = path_1.default.join(config.pagesFolder, 'pages', pageName);
892
+ const tables = await gatherTablesForPrompt(config, parent, PAGE_TABLES_RESERVED);
893
+ if (tables.length === 0) {
894
+ return {
895
+ title: '<PAGE_TABLES>',
896
+ content: '',
897
+ sketch: null,
898
+ mode: 'always-omit-when-empty',
899
+ instructions: '',
900
+ };
901
+ }
446
902
  return {
447
- title: '<MESSAGE_FORMAT>',
448
- content: `<div class="chat-message"><p><strong>{${productName}: | User:}</strong> {message contents}</p></div>`,
449
- instructions: 'provides the HTML structure for chat messages in the chat panel.',
903
+ title: '<PAGE_TABLES>',
904
+ content: renderTablesFull('PAGE_TABLES', tables),
905
+ sketch: renderTablesSketch('PAGE_TABLES', tables),
906
+ mode: 'always-omit-when-empty',
907
+ instructions: PAGE_TABLES_INSTRUCTIONS,
450
908
  };
451
909
  }
910
+ // ---------------------------------------------------------------------------
911
+ // Section assembly — picks full vs sketch per section per the section's mode
912
+ // ---------------------------------------------------------------------------
913
+ /**
914
+ * Resolve each section to either its full content or its sketch (or skip it
915
+ * entirely) based on the section's `mode`, the classifier's `expand` set, and
916
+ * whether this is the initial build (`newBuild`).
917
+ *
918
+ * Per `docs/specs/page-section-sketch-full.md`:
919
+ * - 'always-full' → always render full content.
920
+ * - 'classifier-decides' → render full when title is in `expand`, else sketch.
921
+ * - 'always-omit-when-empty' → skip entirely when sketch is null; otherwise
922
+ * render full when in `expand`, else sketch.
923
+ * - `forceFullOnInitial` on a section forces full content on the first build
924
+ * regardless of `expand`.
925
+ */
926
+ function assembleSections(sections, expand, newBuild) {
927
+ const out = [];
928
+ for (const s of sections) {
929
+ const expanded = expand.has(s.title) || (newBuild && s.forceFullOnInitial === true);
930
+ switch (s.mode) {
931
+ case 'always-full':
932
+ out.push(s);
933
+ break;
934
+ case 'classifier-decides':
935
+ if (expanded || s.sketch === null) {
936
+ out.push(s);
937
+ }
938
+ else {
939
+ out.push({ ...s, content: s.sketch });
940
+ }
941
+ break;
942
+ case 'always-omit-when-empty':
943
+ if (s.sketch === null) {
944
+ // Empty section — skip entirely.
945
+ break;
946
+ }
947
+ if (expanded) {
948
+ out.push(s);
949
+ }
950
+ else {
951
+ out.push({ ...s, content: s.sketch });
952
+ }
953
+ break;
954
+ }
955
+ }
956
+ return out;
957
+ }
958
+ exports.assembleSections = assembleSections;
959
+ /** Apply per-page section-mode overrides from page metadata. */
960
+ function applySectionModeOverrides(sections, overrides) {
961
+ if (!overrides || Object.keys(overrides).length === 0)
962
+ return sections;
963
+ return sections.map(s => {
964
+ const override = overrides[s.title];
965
+ return override ? { ...s, mode: override } : s;
966
+ });
967
+ }
968
+ exports.applySectionModeOverrides = applySectionModeOverrides;
452
969
  function usePageRoutes(config, app, customizer) {
970
+ // Initialize cache-busting for /static/* URLs
971
+ initStaticAssetVersioning(config.staticFilesFolders);
453
972
  // Redirect / to /home page
454
973
  app.get('/', (req, res) => res.redirect(HOME_PAGE_ROUTE));
455
- // Page retrieval
974
+ // Builder with starter scaffold. Renders the same shell as /builder, but
975
+ // embeds the starter name in shellInit (so chat POSTs include it) and the
976
+ // iframe src (so iframe content == starter HTML, not the carousel).
977
+ // Must be registered BEFORE /:page — Express matches in declaration order.
978
+ app.get('/builder/:starter', async (req, res) => {
979
+ const { starter } = req.params;
980
+ const isConfigured = await (0, settings_1.hasConfiguredSettings)(config);
981
+ if (!isConfigured) {
982
+ res.redirect('/settings?firstRun=1');
983
+ return;
984
+ }
985
+ // Validate starter — must exist with category "_Starters". 404 otherwise.
986
+ const starterMeta = await (0, pages_1.loadPageMetadata)(config, starter, config.requiredPagesFolders);
987
+ const isValidStarter = !!starterMeta?.categories?.includes('_Starters');
988
+ if (!isValidStarter) {
989
+ res.status(404).send(PAGE_NOT_FOUND);
990
+ return;
991
+ }
992
+ // Reuse builder's metadata for the shell wrapping (page name == 'builder').
993
+ const builderMeta = await (0, pages_1.loadPageMetadata)(config, 'builder', config.requiredPagesFolders);
994
+ const settings = await (0, settings_1.loadSettings)(config);
995
+ const themeName = settings.theme ?? 'nebula-dusk';
996
+ const themeVersion = await (0, themes_1.loadThemeVersion)(themeName, config);
997
+ const productName = customizer?.productName ?? 'SynthOS';
998
+ const shellHtml = await loadPageWithFallback(SHELL_PAGE_NAME, config, true);
999
+ if (!shellHtml) {
1000
+ res.status(500).send('Shell page not found');
1001
+ return;
1002
+ }
1003
+ // Builder is noPersistHistory — start fresh: greeting only.
1004
+ const greetingText = builderMeta?.greeting ?? '';
1005
+ const messages = greetingText
1006
+ ? [{ role: 'assistant', content: greetingText }]
1007
+ : [];
1008
+ let html = injectShellAssets(shellHtml, themeName, themeVersion, settings.toolbarPosition);
1009
+ html = injectShellInit(html, 'builder', messages, {
1010
+ title: builderMeta?.title ?? '',
1011
+ categories: builderMeta?.categories ?? [],
1012
+ mode: builderMeta?.mode ?? 'unlocked',
1013
+ isRequiredPage: true,
1014
+ greeting: builderMeta?.greeting ?? '',
1015
+ firstRunGreeting: builderMeta?.firstRunGreeting ?? '',
1016
+ }, 0, productName, starter);
1017
+ html = setIframeSrc(html, 'builder', starter);
1018
+ html = injectPageInfoScript(html, 'builder');
1019
+ html = replaceProductName(html, productName);
1020
+ html = applyAssetVersions(html);
1021
+ res.send(html);
1022
+ });
1023
+ // Page retrieval — serves either the shell (parent frame) or page content (iframe)
456
1024
  app.get('/:page', async (req, res) => {
457
- // Redirect if settings not configured
458
1025
  const { page } = req.params;
1026
+ const isFrameRequest = req.query.frame === '1';
1027
+ const requestedStarter = isFrameRequest && page === 'builder' && typeof req.query.starter === 'string'
1028
+ ? String(req.query.starter)
1029
+ : '';
1030
+ // _shell is an internal page — never served directly
1031
+ if (page === SHELL_PAGE_NAME) {
1032
+ res.redirect(HOME_PAGE_ROUTE);
1033
+ return;
1034
+ }
1035
+ // Redirect if settings not configured
459
1036
  const isConfigured = await (0, settings_1.hasConfiguredSettings)(config);
460
1037
  if (!isConfigured && page !== 'settings') {
461
1038
  res.redirect('/settings?firstRun=1');
@@ -463,7 +1040,7 @@ function usePageRoutes(config, app, customizer) {
463
1040
  }
464
1041
  // Ensure page exists — force fresh disk read for required pages
465
1042
  const isRequiredPage = config.requiredPages.includes(page);
466
- const pageState = await loadPageWithFallback(page, config, isRequiredPage);
1043
+ let pageState = await loadPageWithFallback(page, config, isRequiredPage);
467
1044
  if (!pageState) {
468
1045
  res.status(404).send(PAGE_NOT_FOUND);
469
1046
  return;
@@ -476,30 +1053,73 @@ function usePageRoutes(config, app, customizer) {
476
1053
  res.redirect(customizer?.tabsListRoute ?? '/pages');
477
1054
  return;
478
1055
  }
479
- // Load settings to determine theme version for FluentLM base injection
1056
+ // Load settings
480
1057
  const settings = await (0, settings_1.loadSettings)(config);
481
1058
  const themeName = settings.theme ?? 'nebula-dusk';
482
1059
  const themeVersion = await (0, themes_1.loadThemeVersion)(themeName, config);
483
- let html = ensureRequiredImports(pageState, pageVersion);
484
- html = injectErrorCapture(html, pageVersion);
485
- html = injectShellAssets(html, themeName, themeVersion, settings.toolbarPosition);
486
- html = injectPageInfoScript(html, page);
487
- html = injectPageHelpers(html, pageVersion);
488
- html = injectPageScript(html, pageVersion);
489
- // Inject version meta tag so undo/try-again links appear on page load
490
- {
491
- const latestVersion = await (0, pages_1.getLatestVersion)(config, page);
492
- if (latestVersion > 0) {
493
- html = html.replace('</head>', `<meta name="synthos-version" content="${latestVersion}">\n</head>`);
494
- }
495
- }
496
- html = hoistExternalScriptsToHead(html);
497
- // Replace branding for white-label forks
498
1060
  const productName = customizer?.productName ?? 'SynthOS';
499
- if (productName !== 'SynthOS') {
500
- html = html.replace(/synthos/gi, productName);
1061
+ const isStandalone = STANDALONE_PAGE_NAMES.includes(page);
1062
+ if (isFrameRequest || isStandalone) {
1063
+ // Builder + ?starter=<name>: swap iframe content to the starter's HTML.
1064
+ // page name stays 'builder' (chat POSTs target /builder); the starter HTML
1065
+ // becomes the working scaffold the chat agent edits.
1066
+ if (requestedStarter) {
1067
+ const starterMeta = await (0, pages_1.loadPageMetadata)(config, requestedStarter, config.requiredPagesFolders);
1068
+ if (!starterMeta?.categories?.includes('_Starters')) {
1069
+ res.status(404).send(PAGE_NOT_FOUND);
1070
+ return;
1071
+ }
1072
+ const starterState = await loadPageWithFallback(requestedStarter, config, false);
1073
+ if (!starterState) {
1074
+ res.status(404).send(PAGE_NOT_FOUND);
1075
+ return;
1076
+ }
1077
+ pageState = starterState;
1078
+ }
1079
+ // --- iframe content OR standalone top-level page: serve page HTML with
1080
+ // theme + helpers + bridge, no shell wrapping. Standalone pages (peers to
1081
+ // _shell) own the full viewport themselves.
1082
+ const html = prepareForIframe(pageState, page, pageVersion, themeName, themeVersion, productName);
1083
+ res.send(html);
1084
+ }
1085
+ else {
1086
+ // --- Shell request: load _shell page, inject init data, set iframe src ---
1087
+ const shellHtml = await loadPageWithFallback(SHELL_PAGE_NAME, config, true);
1088
+ if (!shellHtml) {
1089
+ res.status(500).send('Shell page not found');
1090
+ return;
1091
+ }
1092
+ // Ensure any legacy v-files / chat-history.json have been migrated
1093
+ // to the c-scheme so we read a single consistent view.
1094
+ await (0, pages_1.migrateLegacyChangeFiles)(config, page);
1095
+ // Build the greeting message from metadata (authoritative source),
1096
+ // followed by the flat concat of all per-change message logs.
1097
+ const greetingText = metadata?.greeting ?? '';
1098
+ const changeMessages = await (0, pages_1.loadAllChangeMessages)(config, page);
1099
+ const messages = [];
1100
+ if (greetingText) {
1101
+ messages.push({ role: 'assistant', content: greetingText });
1102
+ }
1103
+ messages.push(...changeMessages);
1104
+ const latestVersion = await (0, pages_1.getLatestChangeHtmlNumber)(config, page);
1105
+ // Build the shell HTML
1106
+ let html = injectShellAssets(shellHtml, themeName, themeVersion, settings.toolbarPosition);
1107
+ html = injectShellInit(html, page, messages, {
1108
+ title: metadata?.title ?? '',
1109
+ categories: metadata?.categories ?? [],
1110
+ mode: metadata?.mode ?? 'unlocked',
1111
+ isRequiredPage,
1112
+ greeting: metadata?.greeting ?? '',
1113
+ firstRunGreeting: metadata?.firstRunGreeting ?? '',
1114
+ }, latestVersion, productName);
1115
+ html = setIframeSrc(html, page);
1116
+ // Inject page-info script into shell too (for pageInfo access in shell.v3.js)
1117
+ html = injectPageInfoScript(html, page);
1118
+ html = replaceProductName(html, productName);
1119
+ // Append cache-busting ?v=<hash> to all /static/* refs in shell HTML
1120
+ html = applyAssetVersions(html);
1121
+ res.send(html);
501
1122
  }
502
- res.send(html);
503
1123
  });
504
1124
  // Page save
505
1125
  app.post('/:page/save', async (req, res) => {
@@ -532,41 +1152,33 @@ function usePageRoutes(config, app, customizer) {
532
1152
  res.status(400).json({ error: '"Builder" is a reserved page name' });
533
1153
  return;
534
1154
  }
535
- // Load page state
536
- let pageState = await loadPageWithFallback(page, config, false);
537
- if (!pageState) {
1155
+ // Save sequence (per the c-scheme spec):
1156
+ // 1. Determine <last> = max N where page.c<N>.html exists on source.
1157
+ // 2. The final HTML is page.c<last>.html (or page.html if none).
1158
+ // 3. Write that HTML to <saveAs>/page.html.
1159
+ // 4. Delete every page.c*.html on source.
1160
+ // 5. Delete every page.c*.json on source.
1161
+ // 6. Update metadata.
1162
+ // 7. Return redirect.
1163
+ // Resolve legacy files before reading latest state
1164
+ await (0, pages_1.migrateLegacyChangeFiles)(config, page);
1165
+ const lastN = await (0, pages_1.getLatestChangeHtmlNumber)(config, page);
1166
+ let finalHtml;
1167
+ if (lastN > 0) {
1168
+ finalHtml = await (0, pages_1.loadChangeHtml)(config, page, lastN);
1169
+ }
1170
+ if (!finalHtml) {
1171
+ // No pending changes — fall back to the current baseline
1172
+ finalHtml = await loadPageWithFallback(page, config, false);
1173
+ }
1174
+ if (!finalHtml) {
538
1175
  res.status(404).json({ error: PAGE_NOT_FOUND });
539
1176
  return;
540
1177
  }
541
- // Always trim chat to the first message on save and remove undo links
542
- {
543
- const $ = cheerio.load(pageState);
544
- const messages = $('#chatMessages .chat-message');
545
- messages.slice(1).remove();
546
- // Remove any undo links
547
- $('#chatMessages .synthos-undo-link').remove();
548
- // Update greeting text if provided
549
- if (greeting && typeof greeting === 'string' && greeting.trim().length > 0) {
550
- const firstP = messages.first().find('p');
551
- const strong = firstP.find('strong');
552
- if (strong.length) {
553
- firstP.html('<strong>Synthos:</strong> ' + greeting.trim());
554
- }
555
- }
556
- pageState = $.html();
557
- }
558
- // Inject save-line marker at the end of chat messages (skip for locked pages)
1178
+ // Load source page metadata (needed for category checks and greeting fallback)
559
1179
  const sourceMetadata = await (0, pages_1.loadPageMetadata)(config, page, config.requiredPagesFolders);
560
- if (sourceMetadata?.mode !== 'locked') {
561
- const $ = cheerio.load(pageState);
562
- // Remove any existing save-line first
563
- $('#chatMessages .save-line').remove();
564
- // Append new save-line
565
- $('#chatMessages').append('<div class="save-line" data-locked="true"><span class="save-line-label">Saved</span></div>');
566
- pageState = $.html();
567
- }
568
- // Save as new page
569
- await (0, pages_1.savePageState)(config, saveAs, pageState, title, categories);
1180
+ // Write the final HTML as the new baseline for <saveAs>
1181
+ await (0, pages_1.savePageState)(config, saveAs, finalHtml, title, categories);
570
1182
  // Copy files (sound effects, etc.) from source page when saving as a different name
571
1183
  if (page !== saveAs) {
572
1184
  // Check source page's files/ dir in user pages, then fallback folders
@@ -589,11 +1201,33 @@ function usePageRoutes(config, app, customizer) {
589
1201
  await config.storageProvider.copyFolderRecursive(sourceFilesDir, targetFilesDir);
590
1202
  }
591
1203
  }
592
- // Clear version files after saving (fresh baseline)
593
- await (0, pages_1.clearVersions)(config, saveAs);
594
- // Also update metadata with categories (in case page.json already existed)
1204
+ // Delete all per-change files (html + json) on source AND destination —
1205
+ // save collapses the journal back to a single baseline.
1206
+ await (0, pages_1.clearChanges)(config, page);
1207
+ if (page !== saveAs) {
1208
+ await (0, pages_1.clearChanges)(config, saveAs);
1209
+ }
1210
+ // Delete data tables from the source starter page (ephemeral data shouldn't persist)
1211
+ if (sourceMetadata?.categories?.some(c => NO_PERSIST_CATEGORIES.includes(c))) {
1212
+ const sourcePageDir = path_1.default.join(config.pagesFolder, 'pages', page);
1213
+ const sp = config.storageProvider;
1214
+ if (await sp.checkIfExists(sourcePageDir)) {
1215
+ const RESERVED_SUBDIRS = new Set(['files']);
1216
+ const entries = await sp.listFolders(sourcePageDir);
1217
+ for (const entry of entries) {
1218
+ if (!RESERVED_SUBDIRS.has(entry)) {
1219
+ await sp.deleteFolder(path_1.default.join(sourcePageDir, entry));
1220
+ }
1221
+ }
1222
+ }
1223
+ }
1224
+ // Also update metadata with categories and greeting (in case page.json already existed)
1225
+ const savedGreeting = (greeting && typeof greeting === 'string' && greeting.trim().length > 0)
1226
+ ? greeting.trim()
1227
+ : (sourceMetadata?.greeting ?? '');
595
1228
  await (0, pages_1.savePageMetadata)(config, saveAs, {
596
1229
  title,
1230
+ description: sourceMetadata?.description ?? '',
597
1231
  categories,
598
1232
  pinned: false,
599
1233
  showInAll: true,
@@ -601,6 +1235,8 @@ function usePageRoutes(config, app, customizer) {
601
1235
  lastModified: new Date().toISOString(),
602
1236
  pageVersion: pages_1.PAGE_VERSION,
603
1237
  mode: 'unlocked',
1238
+ greeting: savedGreeting,
1239
+ firstRunGreeting: '',
604
1240
  });
605
1241
  res.json({ redirect: `/${saveAs}` });
606
1242
  }
@@ -609,60 +1245,126 @@ function usePageRoutes(config, app, customizer) {
609
1245
  res.status(500).json({ error: err.message });
610
1246
  }
611
1247
  });
612
- // Page undoroll back to the previous version
1248
+ // Page patchlightweight DOM patching via cheerio (no LLM)
1249
+ app.post('/:page/patch', async (req, res) => {
1250
+ const { page } = req.params;
1251
+ await withPageLock(page, async () => {
1252
+ try {
1253
+ const { operations, message } = req.body;
1254
+ // Validate operations array
1255
+ if (!operations || !Array.isArray(operations) || operations.length === 0) {
1256
+ res.status(400).json({ error: 'operations is required (non-empty array)' });
1257
+ return;
1258
+ }
1259
+ // Load current page state
1260
+ const pageState = await loadPageWithFallback(page, config, false);
1261
+ if (!pageState) {
1262
+ res.status(404).json({ error: PAGE_NOT_FOUND });
1263
+ return;
1264
+ }
1265
+ // Parse with cheerio
1266
+ const $ = cheerio.load(pageState, { decodeEntities: false });
1267
+ // Apply each operation
1268
+ for (const op of operations) {
1269
+ if (op.op !== 'set-attr') {
1270
+ res.status(400).json({ error: `Unsupported operation: '${op.op}'` });
1271
+ return;
1272
+ }
1273
+ if (!op.selector || typeof op.selector !== 'string') {
1274
+ res.status(400).json({ error: 'Each operation requires a selector string' });
1275
+ return;
1276
+ }
1277
+ if (!op.attr || typeof op.attr !== 'string') {
1278
+ res.status(400).json({ error: 'Each set-attr operation requires an attr string' });
1279
+ return;
1280
+ }
1281
+ if (typeof op.value !== 'string') {
1282
+ res.status(400).json({ error: 'Each set-attr operation requires a value string' });
1283
+ return;
1284
+ }
1285
+ const matches = $(op.selector);
1286
+ if (matches.length !== 1) {
1287
+ res.status(400).json({
1288
+ error: `Selector '${op.selector}' matched ${matches.length} elements (expected exactly 1)`
1289
+ });
1290
+ return;
1291
+ }
1292
+ matches.attr(op.attr, op.value);
1293
+ }
1294
+ const productName = customizer?.productName ?? 'SynthOS';
1295
+ const html = $.html();
1296
+ // Save as next change (c-scheme). Number advances from max(html, json).
1297
+ await (0, pages_1.migrateLegacyChangeFiles)(config, page);
1298
+ const currentVersion = await (0, pages_1.getLatestChangeNumber)(config, page);
1299
+ const nextVersion = currentVersion + 1;
1300
+ await (0, pages_1.saveChangeHtml)(config, page, nextVersion, html);
1301
+ // Update lastModified
1302
+ const metadata = await (0, pages_1.loadPageMetadata)(config, page, config.requiredPagesFolders);
1303
+ if (metadata) {
1304
+ metadata.lastModified = new Date().toISOString();
1305
+ await (0, pages_1.savePageMetadata)(config, page, metadata);
1306
+ }
1307
+ // Persist patch as a self-contained change entry (skip for Starters/System).
1308
+ // Patches are user-less server-side actions, so the journal captures just
1309
+ // the assistant summary.
1310
+ const patchCategories = metadata?.categories ?? [];
1311
+ if (!patchCategories.some(c => NO_PERSIST_CATEGORIES.includes(c))) {
1312
+ const displayMessage = (message && typeof message === 'string') ? message : 'Page patched';
1313
+ await (0, pages_1.saveChangeMessages)(config, page, nextVersion, [
1314
+ { role: 'assistant', content: displayMessage },
1315
+ ]);
1316
+ }
1317
+ // Inject content assets for iframe rendering (no shell chrome)
1318
+ const settings = await (0, settings_1.loadSettings)(config);
1319
+ const pv = metadata?.pageVersion ?? 0;
1320
+ const themeName = settings.theme ?? 'nebula-dusk';
1321
+ const themeVersion = await (0, themes_1.loadThemeVersion)(themeName, config);
1322
+ const out = prepareForIframe(html, page, pv, themeName, themeVersion, productName);
1323
+ res.json({ html: out, changeCount: operations.length, version: nextVersion });
1324
+ }
1325
+ catch (err) {
1326
+ console.error(err);
1327
+ res.status(500).json({ error: err.message });
1328
+ }
1329
+ }); // withPageLock
1330
+ });
1331
+ // Page undo — roll back to the previous change
613
1332
  app.post('/:page/undo', async (req, res) => {
614
1333
  try {
615
1334
  const { page } = req.params;
616
- const sp = config.storageProvider;
617
- const cv = await (0, pages_1.getLatestVersion)(config, page);
1335
+ await (0, pages_1.migrateLegacyChangeFiles)(config, page);
1336
+ const cv = await (0, pages_1.getLatestChangeNumber)(config, page);
618
1337
  if (cv <= 0) {
619
1338
  res.status(400).send('Nothing to undo');
620
1339
  return;
621
1340
  }
622
- // Delete the current version file
623
- const pageFolder = path_1.default.join(config.pagesFolder, 'pages', page);
624
- const versionFile = path_1.default.join(pageFolder, `page.v${cv}.html`);
625
- if (await sp.checkIfExists(versionFile)) {
626
- await sp.deleteFile(versionFile);
627
- }
628
- // Load previous version (v{cv-1}.html, or page.html if rolling back to v0)
629
- const prevVersion = cv - 1;
1341
+ // Drop the entire change pair (both html + json) for the highest N.
1342
+ // Each user turn owns one pair — rolling it back = removing both.
1343
+ await (0, pages_1.deleteChange)(config, page, cv);
1344
+ // Find the next-lower change with HTML to restore; if none, fall
1345
+ // back to the saved baseline.
630
1346
  let previousHtml;
631
- if (prevVersion > 0) {
632
- previousHtml = await (0, pages_1.loadPageVersion)(config, page, prevVersion);
1347
+ let prevN = cv - 1;
1348
+ while (prevN > 0 && !previousHtml) {
1349
+ previousHtml = await (0, pages_1.loadChangeHtml)(config, page, prevN);
1350
+ if (!previousHtml)
1351
+ prevN--;
633
1352
  }
634
- else {
635
- // v0 = the saved page.html baseline (user folder → required folders fallback)
1353
+ if (!previousHtml) {
636
1354
  previousHtml = await loadPageWithFallback(page, config, true);
637
1355
  }
638
1356
  if (!previousHtml) {
639
1357
  res.status(500).send('Could not load previous version');
640
1358
  return;
641
1359
  }
642
- // Inject shell assets (same as GET handler)
1360
+ // Inject content assets for iframe rendering (no shell chrome)
643
1361
  const settings = await (0, settings_1.loadSettings)(config);
644
1362
  const metadata = await (0, pages_1.loadPageMetadata)(config, page, config.requiredPagesFolders);
645
1363
  const pv = metadata?.pageVersion ?? 0;
646
1364
  const themeName = settings.theme ?? 'nebula-dusk';
647
1365
  const themeVersion = await (0, themes_1.loadThemeVersion)(themeName, config);
648
- let out = ensureRequiredImports(previousHtml, pv);
649
- out = injectErrorCapture(out, pv);
650
- out = injectShellAssets(out, themeName, themeVersion, settings.toolbarPosition);
651
- out = injectPageInfoScript(out, page);
652
- out = injectPageHelpers(out, pv);
653
- out = injectPageScript(out, pv);
654
- // Inject version meta tag if still have versions
655
- if (prevVersion > 0) {
656
- out = out.replace('</head>', `<meta name="synthos-version" content="${prevVersion}">\n</head>`);
657
- }
658
- // Same hardening as POST handler (undo also loads via document.write)
659
- out = hoistExternalScriptsToHead(out);
660
- out = wrapInlineScriptsInIIFE(out);
661
- // Replace branding for white-label forks
662
1366
  const productName = customizer?.productName ?? 'SynthOS';
663
- if (productName !== 'SynthOS') {
664
- out = out.replace(/synthos/gi, productName);
665
- }
1367
+ const out = prepareForIframe(previousHtml, page, pv, themeName, themeVersion, productName);
666
1368
  res.send(out);
667
1369
  }
668
1370
  catch (err) {
@@ -670,263 +1372,463 @@ function usePageRoutes(config, app, customizer) {
670
1372
  res.status(500).send(err.message);
671
1373
  }
672
1374
  });
673
- // Page transformation
674
- app.post('/:page', async (req, res) => {
1375
+ // Page reset — clear change journal, return saved baseline
1376
+ app.post('/:page/reset', async (req, res) => {
675
1377
  try {
676
- // Ensure settings configured
677
1378
  const { page } = req.params;
678
- const isConfigured = await (0, settings_1.hasConfiguredSettings)(config);
679
- if (!isConfigured) {
680
- res.status(400).send('Settings not configured');
1379
+ // Clear all change html + json files
1380
+ await (0, pages_1.migrateLegacyChangeFiles)(config, page);
1381
+ await (0, pages_1.clearChanges)(config, page);
1382
+ // Load the fresh baseline (page.html from user folder or required folders)
1383
+ const freshHtml = await loadPageWithFallback(page, config, true);
1384
+ if (!freshHtml) {
1385
+ res.status(404).json({ error: PAGE_NOT_FOUND });
681
1386
  return;
682
1387
  }
683
- // Ensure page exists
684
- const pageState = await loadPageWithFallback(page, config, false);
685
- if (!pageState) {
686
- res.status(404).send(PAGE_NOT_FOUND);
1388
+ // Build greeting from metadata (authoritative source)
1389
+ const settings = await (0, settings_1.loadSettings)(config);
1390
+ const metadata = await (0, pages_1.loadPageMetadata)(config, page, config.requiredPagesFolders);
1391
+ const greetingText = metadata?.greeting ?? '';
1392
+ const greeting = greetingText
1393
+ ? [{ role: 'assistant', content: greetingText }]
1394
+ : [];
1395
+ const pv = metadata?.pageVersion ?? 0;
1396
+ const themeName = settings.theme ?? 'nebula-dusk';
1397
+ const themeVersion = await (0, themes_1.loadThemeVersion)(themeName, config);
1398
+ const productName = customizer?.productName ?? 'SynthOS';
1399
+ const out = prepareForIframe(freshHtml, page, pv, themeName, themeVersion, productName);
1400
+ res.json({ html: out, greeting: greeting });
1401
+ }
1402
+ catch (err) {
1403
+ console.error(err);
1404
+ res.status(500).json({ error: err.message });
1405
+ }
1406
+ });
1407
+ // Update page greeting (inline edit from chat panel)
1408
+ app.post('/:page/greeting', async (req, res) => {
1409
+ try {
1410
+ const { page } = req.params;
1411
+ const { greeting } = req.body;
1412
+ if (typeof greeting !== 'string') {
1413
+ res.status(400).json({ error: 'greeting is required (string)' });
687
1414
  return;
688
1415
  }
689
- // Reject modifications to locked pages
690
- const lockMetadata = await (0, pages_1.loadPageMetadata)(config, page, config.requiredPagesFolders);
691
- if (lockMetadata?.mode === 'locked') {
692
- res.status(403).send('This page is locked and cannot be modified');
1416
+ // Reject editing on locked pages and the builder page
1417
+ const metadata = await (0, pages_1.loadPageMetadata)(config, page, config.requiredPagesFolders);
1418
+ if (!metadata) {
1419
+ res.status(404).json({ error: PAGE_NOT_FOUND });
693
1420
  return;
694
1421
  }
695
- // Get required and optional parameters
696
- const { message } = req.body; // Extract the message from the request body
697
- if (typeof message !== 'string') {
698
- res.status(400).send('Invalid or missing message parameter');
1422
+ if (metadata.mode === 'locked') {
1423
+ res.status(403).json({ error: 'This page is locked' });
699
1424
  return;
700
1425
  }
701
- // Extract and validate optional attachments
702
- let attachments;
703
- if (Array.isArray(req.body.attachments)) {
704
- attachments = [];
705
- for (const att of req.body.attachments) {
706
- if (att && typeof att.mediaType === 'string' && att.mediaType.startsWith('image/') && typeof att.data === 'string') {
707
- attachments.push({ mediaType: att.mediaType, data: att.data, name: typeof att.name === 'string' ? att.name : undefined });
708
- }
1426
+ if (page === 'builder') {
1427
+ res.status(403).json({ error: 'Builder greeting is not editable' });
1428
+ return;
1429
+ }
1430
+ // Update greeting in metadata
1431
+ metadata.greeting = greeting.trim();
1432
+ metadata.lastModified = new Date().toISOString();
1433
+ await (0, pages_1.savePageMetadata)(config, page, metadata);
1434
+ res.json({ ok: true });
1435
+ }
1436
+ catch (err) {
1437
+ console.error(err);
1438
+ res.status(500).json({ error: err.message });
1439
+ }
1440
+ });
1441
+ // Page transformation
1442
+ app.post('/:page', async (req, res) => {
1443
+ const { page } = req.params;
1444
+ await withPageLock(page, async () => {
1445
+ // Hoisted so the catch block can append errors to the pre-committed
1446
+ // page.c<N>.json turn file.
1447
+ let pendingVersion;
1448
+ let shouldPersistTurn = false;
1449
+ let pendingRawMessage;
1450
+ try {
1451
+ // Ensure settings configured
1452
+ const isConfigured = await (0, settings_1.hasConfiguredSettings)(config);
1453
+ if (!isConfigured) {
1454
+ res.status(400).send('Settings not configured');
1455
+ return;
709
1456
  }
710
- if (attachments.length === 0)
711
- attachments = undefined;
712
- }
713
- // Create model instance
714
- const innerCompletePrompt = await (0, createCompletePrompt_1.createCompletePrompt)(config, 'builder', req.body.model);
715
- const debugVerbose = config.debugPageUpdates;
716
- let inputChars = 0;
717
- let outputChars = 0;
718
- const wrapModel = (inner) => {
719
- return async (args) => {
720
- if (debugVerbose) {
721
- console.log((0, debugLog_1.green)((0, debugLog_1.dim)('\n ===== PAGE UPDATE REQUEST =====')));
722
- console.log((0, debugLog_1.green)(` SYSTEM:\n${args.system?.content}`));
723
- console.log((0, debugLog_1.green)(`\n PROMPT:\n${args.prompt.content}`));
724
- }
725
- inputChars += (args.system?.content?.length ?? 0) + (args.prompt.content?.length ?? 0);
726
- const result = await inner(args);
727
- if (result.completed) {
728
- outputChars += result.value?.length ?? 0;
1457
+ // Ensure page exists
1458
+ const pageState = await loadPageWithFallback(page, config, false);
1459
+ if (!pageState) {
1460
+ res.status(404).send(PAGE_NOT_FOUND);
1461
+ return;
1462
+ }
1463
+ // Reject modifications to locked pages
1464
+ const lockMetadata = await (0, pages_1.loadPageMetadata)(config, page, config.requiredPagesFolders);
1465
+ if (lockMetadata?.mode === 'locked') {
1466
+ res.status(403).send('This page is locked and cannot be modified');
1467
+ return;
1468
+ }
1469
+ // Get required and optional parameters
1470
+ const { message: rawMessage } = req.body;
1471
+ if (typeof rawMessage !== 'string') {
1472
+ res.status(400).send('Invalid or missing message parameter');
1473
+ return;
1474
+ }
1475
+ // If the shell sent captured JS errors, append them as context for the LLM
1476
+ // but keep the raw message clean for chat history persistence.
1477
+ const clientErrors = Array.isArray(req.body.errors) ? req.body.errors : [];
1478
+ const message = clientErrors.length > 0
1479
+ ? rawMessage + '\n\nCONSOLE_ERRORS:\n' + clientErrors.join('\n---\n')
1480
+ : rawMessage;
1481
+ // Extract and validate optional attachments
1482
+ let attachments;
1483
+ if (Array.isArray(req.body.attachments)) {
1484
+ attachments = [];
1485
+ for (const att of req.body.attachments) {
1486
+ if (att && typeof att.mediaType === 'string' && att.mediaType.startsWith('image/') && typeof att.data === 'string') {
1487
+ attachments.push({ mediaType: att.mediaType, data: att.data, name: typeof att.name === 'string' ? att.name : undefined });
1488
+ }
729
1489
  }
730
- if (debugVerbose) {
731
- console.log((0, debugLog_1.green)((0, debugLog_1.dim)('\n ----- PAGE UPDATE RESPONSE -----')));
1490
+ if (attachments.length === 0)
1491
+ attachments = undefined;
1492
+ }
1493
+ // Create model instance
1494
+ const innerCompletePrompt = await (0, createCompletePrompt_1.createCompletePrompt)(config, 'builder', req.body.model);
1495
+ const debugVerbose = config.debugPageUpdates;
1496
+ let inputChars = 0;
1497
+ let outputChars = 0;
1498
+ const wrapModel = (inner) => {
1499
+ return async (args) => {
1500
+ if (debugVerbose) {
1501
+ console.log((0, debugLog_1.green)((0, debugLog_1.dim)('\n ===== PAGE UPDATE REQUEST =====')));
1502
+ console.log((0, debugLog_1.green)(` SYSTEM:\n${args.system?.content}`));
1503
+ console.log((0, debugLog_1.green)(`\n PROMPT:\n${args.prompt.content}`));
1504
+ }
1505
+ inputChars += (args.system?.content?.length ?? 0) + (args.prompt.content?.length ?? 0);
1506
+ const result = await inner(args);
732
1507
  if (result.completed) {
733
- console.log((0, debugLog_1.green)(` RESPONSE:\n${result.value}`));
1508
+ outputChars += result.value?.length ?? 0;
1509
+ }
1510
+ if (debugVerbose) {
1511
+ console.log((0, debugLog_1.green)((0, debugLog_1.dim)('\n ----- PAGE UPDATE RESPONSE -----')));
1512
+ if (result.completed) {
1513
+ console.log((0, debugLog_1.green)(` RESPONSE:\n${result.value}`));
1514
+ }
1515
+ else {
1516
+ console.log((0, debugLog_1.red)(` ERROR: ${result.error?.message}`));
1517
+ }
1518
+ console.log((0, debugLog_1.green)((0, debugLog_1.dim)(' ================================\n')));
734
1519
  }
735
- else {
736
- console.log((0, debugLog_1.red)(` ERROR: ${result.error?.message}`));
1520
+ return result;
1521
+ };
1522
+ };
1523
+ const wrappedCompletePrompt = wrapModel(innerCompletePrompt);
1524
+ // Load settings and build context
1525
+ const pagesFolder = config.pagesFolder;
1526
+ const settings = await (0, settings_1.loadSettings)(config);
1527
+ const entry = (0, settings_1.getModelEntry)(settings, 'builder');
1528
+ const { instructions } = entry;
1529
+ const productName = customizer?.productName ?? 'SynthOS';
1530
+ // Build incoming conversation history from the server-side journal.
1531
+ // Shape: [greeting?, ...flat concat of page.c<n>.json messages]
1532
+ await (0, pages_1.migrateLegacyChangeFiles)(config, page);
1533
+ const pageMeta = await (0, pages_1.loadPageMetadata)(config, page, config.requiredPagesFolders);
1534
+ const incomingHistory = [];
1535
+ if (pageMeta?.greeting) {
1536
+ incomingHistory.push({ role: 'assistant', content: pageMeta.greeting });
1537
+ }
1538
+ incomingHistory.push(...await (0, pages_1.loadAllChangeMessages)(config, page));
1539
+ // Detect first edit (c0 → c1) for saved pages
1540
+ const isRequiredPage = config.requiredPages.includes(page);
1541
+ const pageFolder = path_1.default.join(pagesFolder, 'pages', page);
1542
+ const pageFileExists = await config.storageProvider.checkIfExists(path_1.default.join(pageFolder, 'page.html'));
1543
+ let currentVersion = await (0, pages_1.getLatestChangeNumber)(config, page);
1544
+ const isFirstEdit = !isRequiredPage && pageFileExists && currentVersion === 0;
1545
+ // Try again — drop the last change before re-running the transform
1546
+ const tryAgain = req.body.tryAgain === true;
1547
+ let transformInput = pageState;
1548
+ // New-build reset: when /builder receives its first message in a
1549
+ // fresh conversation (history is just the greeting, or empty), wipe
1550
+ // the change journal and start from a starter scaffold. The AI
1551
+ // reasons about the scaffold's "standard" node layout and emits ops
1552
+ // using those data-nid values; if the live page still holds stale
1553
+ // state from a previous session, those ops silently skip against
1554
+ // the wrong elements and the resulting page looks built but is
1555
+ // missing critical JS.
1556
+ //
1557
+ // Scaffold selection (in order):
1558
+ // 1. req.body.starter if it's a valid `_Starters` page
1559
+ // 2. blank_starter (the silent fallback for /builder with no pick)
1560
+ // 3. builder's own page.html (last-resort)
1561
+ const isNewBuildForBuilder = page === 'builder' && incomingHistory.length <= 1 && !tryAgain;
1562
+ if (isNewBuildForBuilder) {
1563
+ await (0, pages_1.clearChanges)(config, page);
1564
+ let scaffoldName = '';
1565
+ const requestedStarter = typeof req.body.starter === 'string' ? req.body.starter : '';
1566
+ if (requestedStarter) {
1567
+ const sm = await (0, pages_1.loadPageMetadata)(config, requestedStarter, config.requiredPagesFolders);
1568
+ if (sm?.categories?.includes('_Starters')) {
1569
+ scaffoldName = requestedStarter;
737
1570
  }
738
- console.log((0, debugLog_1.green)((0, debugLog_1.dim)(' ================================\n')));
739
1571
  }
740
- return result;
1572
+ if (!scaffoldName)
1573
+ scaffoldName = 'blank_starter';
1574
+ let scaffold = await loadPageWithFallback(scaffoldName, config, false);
1575
+ if (!scaffold && scaffoldName !== 'blank_starter') {
1576
+ scaffold = await loadPageWithFallback('blank_starter', config, false);
1577
+ }
1578
+ if (!scaffold) {
1579
+ scaffold = await loadPageWithFallback(page, config, false);
1580
+ }
1581
+ if (scaffold) {
1582
+ transformInput = scaffold;
1583
+ currentVersion = 0;
1584
+ }
1585
+ }
1586
+ if (tryAgain && currentVersion > 0) {
1587
+ // Drop the latest change (html + json) and reload prior state
1588
+ await (0, pages_1.deleteChange)(config, page, currentVersion);
1589
+ currentVersion = await (0, pages_1.getLatestChangeNumber)(config, page);
1590
+ const latestHtml = await (0, pages_1.getLatestChangeHtmlNumber)(config, page);
1591
+ if (latestHtml > 0) {
1592
+ transformInput = await (0, pages_1.loadChangeHtml)(config, page, latestHtml) ?? pageState;
1593
+ }
1594
+ else {
1595
+ transformInput = await loadPageWithFallback(page, config, false) ?? pageState;
1596
+ }
1597
+ // incomingHistory is stale now (it read before deletion); the
1598
+ // builder forces Opus+no-ranged for tryAgain regardless.
1599
+ }
1600
+ // --- Opt-8: NDJSON streaming response ---
1601
+ // Set headers for newline-delimited JSON streaming.
1602
+ // Progress events are emitted before the LLM call; the final result is the last line.
1603
+ res.setHeader('Content-Type', 'application/x-ndjson');
1604
+ res.setHeader('Transfer-Encoding', 'chunked');
1605
+ res.setHeader('Cache-Control', 'no-cache');
1606
+ const emitProgress = (status) => {
1607
+ res.write(JSON.stringify({ event: 'progress', status }) + '\n');
741
1608
  };
742
- };
743
- const wrappedCompletePrompt = wrapModel(innerCompletePrompt);
744
- // Load settings and build context
745
- const pagesFolder = config.pagesFolder;
746
- const settings = await (0, settings_1.loadSettings)(config);
747
- const entry = (0, settings_1.getModelEntry)(settings, 'builder');
748
- const { instructions } = entry;
749
- const productName = customizer?.productName ?? 'SynthOS';
750
- // Build context sections from enabled features
751
- const featureSections = [];
752
- // CONTEXT section — always first so the model knows the current date/time
753
- featureSections.push(buildContextSection());
754
- // LLMD_READING_GUIDE section (first — tells model how to read compressed content)
755
- featureSections.push(buildLlmdReadingGuideSection());
756
- // SERVER_APIS section
757
- featureSections.push(buildServerApisSection(customizer));
758
- // SERVER_SCRIPTS section
759
- featureSections.push(await buildServerScriptsSection(pagesFolder));
760
- // CONFIGURED_CONNECTORS section
761
- const connectorsSection = buildConnectorsSection(settings.connectors);
762
- if (connectorsSection)
763
- featureSections.push(connectorsSection);
764
- // CONFIGURED_AGENTS section
765
- const agentsSection = buildAgentsSection(settings.agents);
766
- if (agentsSection)
767
- featureSections.push(agentsSection);
768
- // THEME section
769
- const theme = settings.theme;
770
- const themeInfo = await (0, themes_1.loadThemeInfo)(theme ?? 'nebula-dusk', config);
771
- featureSections.push(buildThemeSection(themeInfo));
772
- // FLUENTLM_COMPONENTS section
773
- const fluentLMSection = await buildFluentLMSection(config);
774
- if (fluentLMSection)
775
- featureSections.push(fluentLMSection);
776
- // RECOMMENDED_FRAMEWORKS section
777
- const frameworksSection = await buildRecommendedFrameworksSection(config);
778
- if (frameworksSection)
779
- featureSections.push(frameworksSection);
780
- // MESSAGE_FORMAT section
781
- featureSections.push(buildMessageFormatSection(productName));
782
- // Custom transform instructions as a section (backward compat)
783
- const customTransformInstructions = customizer ? customizer.getTransformInstructions() : undefined;
784
- if (customTransformInstructions && customTransformInstructions.length > 0) {
785
- featureSections.push({
786
- title: '<CUSTOM_INSTRUCTIONS>',
787
- content: customTransformInstructions.join('\n'),
788
- instructions: '',
1609
+ // ---- Section assembly: build all sections, classifier picks expand ----
1610
+ // Each section ships with both `full` content and a `sketch`; the
1611
+ // classifier sees sketches and returns `expand: string[]` listing
1612
+ // which sections to render in full. Anything else renders as its
1613
+ // sketch (or skips entirely for empty omit-when-empty sections).
1614
+ // See docs/specs/page-section-sketch-full.md.
1615
+ const isNewBuildRequest = incomingHistory.length <= 1;
1616
+ // Build all candidate sections in deterministic order.
1617
+ const theme = settings.theme;
1618
+ const themeInfo = await (0, themes_1.loadThemeInfo)(theme ?? 'nebula-dusk', config);
1619
+ const allSections = [];
1620
+ allSections.push(buildContextSection());
1621
+ allSections.push(buildLlmdReadingGuideSection());
1622
+ allSections.push(buildServerApisSection(customizer));
1623
+ allSections.push(await buildServerScriptsSection(pagesFolder));
1624
+ allSections.push(buildThemeSection(themeInfo));
1625
+ allSections.push(buildConnectorsSection(settings.connectors));
1626
+ allSections.push(buildAgentsSection(settings.agents));
1627
+ allSections.push(await buildFluentLMSection(config));
1628
+ allSections.push(await buildRecommendedFrameworksSection(config));
1629
+ allSections.push(await buildSharedTablesSection(config));
1630
+ allSections.push(await buildPageTablesSection(config, page));
1631
+ // Custom transform instructions (always-full when present)
1632
+ const customTransformInstructions = customizer ? customizer.getTransformInstructions() : undefined;
1633
+ if (customTransformInstructions && customTransformInstructions.length > 0) {
1634
+ allSections.push({
1635
+ title: '<CUSTOM_INSTRUCTIONS>',
1636
+ content: customTransformInstructions.join('\n'),
1637
+ sketch: null,
1638
+ mode: 'always-full',
1639
+ instructions: '',
1640
+ });
1641
+ }
1642
+ // User profile (always-full when present — small, identity context).
1643
+ const profileMarkdown = (0, settings_1.renderUserProfile)(settings.profile);
1644
+ if (profileMarkdown) {
1645
+ allSections.push({
1646
+ title: '<USER_PROFILE>',
1647
+ content: profileMarkdown,
1648
+ sketch: null,
1649
+ mode: 'always-full',
1650
+ forceFullOnInitial: true,
1651
+ instructions: 'Details about the current user. Personalize generated pages accordingly when relevant — use the name, address, locations, hours, and other details verbatim when they fit the page being built.',
1652
+ });
1653
+ console.log('user_profile: injected');
1654
+ }
1655
+ // Custom context sections from Customizer (appended last).
1656
+ if (customizer) {
1657
+ allSections.push(...customizer.getContextSections());
1658
+ }
1659
+ // Apply per-page section-mode overrides from page metadata.
1660
+ const sectionsAfterOverride = applySectionModeOverrides(allSections, pageMeta?.sectionModes);
1661
+ // Run the classifier (when an Anthropic API key is available). The
1662
+ // classifier sees sketches for every section that's not always-full
1663
+ // and not empty, and returns `expand: string[]` of titles to render
1664
+ // in full. Without an API key, fall back to expanding everything
1665
+ // expandable (mirrors the previous bypass behavior).
1666
+ let preClassified;
1667
+ let expand = new Set();
1668
+ const classifierSections = sectionsAfterOverride
1669
+ .filter(s => s.mode !== 'always-full' && s.sketch !== null)
1670
+ .map(s => ({ title: s.title, sketch: s.sketch }));
1671
+ if (entry.configuration.apiKey) {
1672
+ emitProgress('classifying');
1673
+ preClassified = await (0, anthropic_1.classifyRequest)(entry.configuration.apiKey, transformInput, rawMessage, classifierSections);
1674
+ console.log(`classifyRequest: "${preClassified.classification}" (expand: ${JSON.stringify(preClassified.expand)})`);
1675
+ expand = new Set(preClassified.expand);
1676
+ }
1677
+ else {
1678
+ // No classifier — expand every non-always-full section that has a sketch.
1679
+ for (const s of classifierSections)
1680
+ expand.add(s.title);
1681
+ }
1682
+ // Resolve each section to either its full content or its sketch
1683
+ // (or skip it entirely for empty omit-when-empty sections).
1684
+ const featureSections = assembleSections(sectionsAfterOverride, expand, isNewBuildRequest);
1685
+ // Create builder. Tool-handler-based section fetching is retired
1686
+ // in v1 — the classifier prefetches everything the model needs.
1687
+ const builder = (0, builders_1.createBuilder)(entry.provider, wrappedCompletePrompt, instructions, productName, {
1688
+ apiKey: entry.configuration.apiKey,
1689
+ model: entry.configuration.model,
1690
+ wrapModel,
1691
+ isFirstEdit,
1692
+ tryAgain,
1693
+ historyLength: incomingHistory.length,
1694
+ preClassified,
789
1695
  });
790
- }
791
- // Custom context sections from Customizer (appended last)
792
- if (customizer) {
793
- featureSections.push(...customizer.getContextSections());
794
- }
795
- // Detect first edit (v0→v1) for saved pages
796
- const isRequiredPage = config.requiredPages.includes(page);
797
- const pageFolder = path_1.default.join(pagesFolder, 'pages', page);
798
- const pageFileExists = await config.storageProvider.checkIfExists(path_1.default.join(pageFolder, 'page.html'));
799
- let currentVersion = await (0, pages_1.getLatestVersion)(config, page);
800
- const isFirstEdit = !isRequiredPage && pageFileExists && currentVersion === 0;
801
- // Try again roll back to previous version before re-running the transform
802
- const tryAgain = req.body.tryAgain === true;
803
- let transformInput = pageState;
804
- if (tryAgain && currentVersion > 0) {
805
- // Delete current version file
806
- const versionFile = path_1.default.join(pageFolder, `page.v${currentVersion}.html`);
807
- if (await config.storageProvider.checkIfExists(versionFile)) {
808
- await config.storageProvider.deleteFile(versionFile);
809
- }
810
- // Load previous version
811
- const prevVersion = currentVersion - 1;
812
- if (prevVersion > 0) {
813
- transformInput = await (0, pages_1.loadPageVersion)(config, page, prevVersion) ?? pageState;
1696
+ // Pre-commit the user message to page.c<N>.json BEFORE the LLM runs
1697
+ // so any thrown error has a concrete file to append to. Skipped for
1698
+ // Starters/System (ephemeral). The final write in the success path
1699
+ // overwrites this with [user, assistant]; the catch block appends
1700
+ // `Error: ...` to this same file if the LLM call throws.
1701
+ const precommitCategories = pageMeta?.categories ?? [];
1702
+ shouldPersistTurn = !precommitCategories.some(c => NO_PERSIST_CATEGORIES.includes(c));
1703
+ pendingVersion = currentVersion + 1;
1704
+ pendingRawMessage = rawMessage;
1705
+ if (shouldPersistTurn) {
1706
+ await (0, pages_1.saveChangeMessages)(config, page, pendingVersion, [
1707
+ { role: 'user', content: rawMessage },
1708
+ ]);
1709
+ }
1710
+ // Transform page
1711
+ emitProgress('transforming');
1712
+ const result = await (0, transformPage_1.transformPage)({
1713
+ pageState: transformInput,
1714
+ message,
1715
+ instructions,
1716
+ builder,
1717
+ additionalSections: featureSections,
1718
+ isBuilder: page === 'builder',
1719
+ productName,
1720
+ attachments,
1721
+ history: incomingHistory,
1722
+ });
1723
+ if (result.completed) {
1724
+ let { html, changeCount, replyText, errorText, skippedOps, skipReasons } = result.value;
1725
+ if (config.debug) {
1726
+ const inTokens = (0, debugLog_1.estimateTokens)(inputChars).toLocaleString();
1727
+ const outTokens = (0, debugLog_1.estimateTokens)(outputChars).toLocaleString();
1728
+ console.log(` page: ${page} | message: ${message.length} chars | changes: ${changeCount} ops | ~${inTokens} in / ~${outTokens} out tokens`);
1729
+ }
1730
+ // Handle 0-ops: model returned no page changes.
1731
+ // Skip HTML snapshot entirely — identical content would pollute undo history.
1732
+ const madeChanges = changeCount > 0;
1733
+ if (!madeChanges) {
1734
+ html = pageState;
1735
+ }
1736
+ const nextVersion = pendingVersion;
1737
+ if (madeChanges) {
1738
+ await (0, pages_1.saveChangeHtml)(config, page, nextVersion, html);
1739
+ }
1740
+ // Update lastModified timestamp only when actual changes were made
1741
+ const metadata = await (0, pages_1.loadPageMetadata)(config, page, config.requiredPagesFolders);
1742
+ if (madeChanges && metadata) {
1743
+ metadata.lastModified = new Date().toISOString();
1744
+ await (0, pages_1.savePageMetadata)(config, page, metadata);
1745
+ }
1746
+ // Finalize the turn's page.c<N>.json — overwrite the user-only
1747
+ // pre-commit with the full [user, assistant] pair.
1748
+ const turnMessages = [{ role: 'user', content: rawMessage }];
1749
+ if (replyText) {
1750
+ turnMessages.push({ role: 'assistant', content: replyText });
1751
+ }
1752
+ else if (errorText) {
1753
+ turnMessages.push({ role: 'assistant', content: `Error: ${errorText}` });
1754
+ }
1755
+ else {
1756
+ turnMessages.push({ role: 'assistant', content: `Made ${changeCount} change(s) to the page.` });
1757
+ }
1758
+ if (shouldPersistTurn) {
1759
+ await (0, pages_1.saveChangeMessages)(config, page, nextVersion, turnMessages);
1760
+ }
1761
+ // Inject content assets for iframe rendering (no shell chrome)
1762
+ const pv = metadata?.pageVersion ?? 0;
1763
+ const themeName = settings.theme ?? 'nebula-dusk';
1764
+ const themeVersion = await (0, themes_1.loadThemeVersion)(themeName, config);
1765
+ const out = prepareForIframe(html, page, pv, themeName, themeVersion, productName);
1766
+ // Surface builder/transform errors to the server log so they're
1767
+ // not lost when only the client sees the NDJSON result event.
1768
+ if (errorText) {
1769
+ console.error((0, debugLog_1.red)(` BUILDER ERROR: ${errorText}`));
1770
+ }
1771
+ // Surface skipped ops to the server log so partial builds are
1772
+ // visible in operator logs, not just the client-side warning.
1773
+ if (skippedOps && skippedOps > 0) {
1774
+ console.warn((0, debugLog_1.red)(` SKIPPED ${skippedOps} op(s) on ${page}: ${skipReasons?.join('; ')}`));
1775
+ }
1776
+ // `version` reflects the latest HTML snapshot, not the journal —
1777
+ // the shell uses it to decide whether the save button lights up.
1778
+ const htmlVersion = madeChanges
1779
+ ? nextVersion
1780
+ : await (0, pages_1.getLatestChangeHtmlNumber)(config, page);
1781
+ res.write(JSON.stringify({
1782
+ event: 'result',
1783
+ html: out,
1784
+ replyText: replyText ?? null,
1785
+ errorText: errorText ?? null,
1786
+ changeCount,
1787
+ skippedOps: skippedOps ?? 0,
1788
+ skipReasons: skipReasons ?? [],
1789
+ version: htmlVersion,
1790
+ }) + '\n');
1791
+ res.end();
814
1792
  }
815
1793
  else {
816
- transformInput = await loadPageWithFallback(page, config, true) ?? pageState;
1794
+ throw result.error;
817
1795
  }
818
- currentVersion = prevVersion;
819
1796
  }
820
- // Create builder
821
- const builder = (0, builders_1.createBuilder)(entry.provider, wrappedCompletePrompt, instructions, productName, {
822
- apiKey: entry.configuration.apiKey,
823
- model: entry.configuration.model,
824
- wrapModel,
825
- isFirstEdit,
826
- tryAgain,
827
- });
828
- // Inject save-line before transform if not already present, so new
829
- // messages appended by the LLM land after the marker.
830
- {
831
- const $pre = cheerio.load(transformInput, { decodeEntities: false });
832
- if ($pre('#chatMessages').length > 0 && $pre('#chatMessages .save-line').length === 0) {
833
- $pre('#chatMessages').append('<div class="save-line" data-locked="true"><span class="save-line-label">Saved</span></div>');
834
- transformInput = $pre.html();
835
- }
836
- }
837
- // Transform page
838
- const result = await (0, transformPage_1.transformPage)({
839
- pageState: transformInput,
840
- message,
841
- instructions,
842
- builder,
843
- additionalSections: featureSections,
844
- isBuilder: page === 'builder',
845
- productName,
846
- attachments,
847
- });
848
- if (result.completed) {
849
- let { html, changeCount } = result.value;
1797
+ catch (err) {
850
1798
  if (config.debug) {
851
- const inTokens = (0, debugLog_1.estimateTokens)(inputChars).toLocaleString();
852
- const outTokens = (0, debugLog_1.estimateTokens)(outputChars).toLocaleString();
853
- console.log(` page: ${page} | message: ${message.length} chars | changes: ${changeCount} ops | ~${inTokens} in / ~${outTokens} out tokens`);
854
- }
855
- // Handle 0-ops from transforms: model returned no page changes.
856
- // Append the user's message + a "try again" prompt with undo link.
857
- // Still save as a version so undo logic stays simple.
858
- // changeCount -1 means error/reply path already handled messaging.
859
- if (changeCount === 0) {
860
- const $ = cheerio.load(pageState, { decodeEntities: false });
861
- const chatMessages = $('#chatMessages');
862
- if (chatMessages.length > 0) {
863
- const escapedMsg = message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
864
- chatMessages.append(`<div class="chat-message"><p><strong>User:</strong> ${escapedMsg}</p></div>`);
865
- const tryAgainLink = `<a href="#" style="color:var(--themePrimary);text-decoration:underline;cursor:pointer" `
866
- + `onclick="(function(e){e.preventDefault();var ci=document.getElementById('chatInput');if(ci){ci.focus();ci.value='';}})(event)">try again</a>`;
867
- const undoLink = `<a href="#" style="color:var(--themePrimary);text-decoration:underline;cursor:pointer" `
868
- + `onclick="(function(e){e.preventDefault();var o=document.getElementById('loadingOverlay');if(o)o.style.display='flex';`
869
- + `fetch(window.location.pathname+'/undo',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})`
870
- + `.then(function(r){return r.text()}).then(function(h){document.open();document.write(h);document.close()})`
871
- + `.catch(function(){if(o)o.style.display='none'});})(event)">undo</a>`;
872
- chatMessages.append(`<div class="chat-message"><p><strong>${productName}:</strong> Sorry, I wasn\u2019t able to make changes for that request. Please ${tryAgainLink} or ${undoLink}.</p></div>`);
1799
+ console.log((0, debugLog_1.red)(` ERROR: ${err.message}`));
1800
+ }
1801
+ else {
1802
+ console.error(err);
1803
+ }
1804
+ // Append the error to the pre-committed turn file so the user can
1805
+ // see what went wrong on the next page load.
1806
+ if (pendingVersion != null && shouldPersistTurn && pendingRawMessage != null) {
1807
+ try {
1808
+ await (0, pages_1.saveChangeMessages)(config, page, pendingVersion, [
1809
+ { role: 'user', content: pendingRawMessage },
1810
+ { role: 'assistant', content: `Error: ${err.message}` },
1811
+ ]);
873
1812
  }
874
- html = $.html();
1813
+ catch { /* journal write must never mask the real error */ }
875
1814
  }
876
- // Save version snapshot (working state for all pages, undo support for saved pages)
877
- const nextVersion = currentVersion + 1;
878
- await (0, pages_1.savePageVersion)(config, page, nextVersion, html);
879
- // Update lastModified timestamp in page metadata
880
- const metadata = await (0, pages_1.loadPageMetadata)(config, page, config.requiredPagesFolders);
881
- if (metadata) {
882
- metadata.lastModified = new Date().toISOString();
883
- await (0, pages_1.savePageMetadata)(config, page, metadata);
1815
+ // Emit error as NDJSON if headers already sent, otherwise plain error
1816
+ if (res.headersSent) {
1817
+ res.write(JSON.stringify({ event: 'error', error: err.message }) + '\n');
1818
+ res.end();
1819
+ }
1820
+ else {
1821
+ res.status(500).send(err.message);
884
1822
  }
885
- // Inject required imports and page scripts (same as GET)
886
- const pv = metadata?.pageVersion ?? 0;
887
- const themeName = settings.theme ?? 'nebula-dusk';
888
- const themeVersion = await (0, themes_1.loadThemeVersion)(themeName, config);
889
- let out = ensureRequiredImports(html, pv);
890
- out = injectErrorCapture(out, pv);
891
- out = injectShellAssets(out, themeName, themeVersion, settings.toolbarPosition);
892
- out = injectPageInfoScript(out, page);
893
- out = injectPageHelpers(out, pv);
894
- out = injectPageScript(out, pv);
895
- // Inject version meta tag for client-side undo support
896
- if (nextVersion > 0) {
897
- out = out.replace('</head>', `<meta name="synthos-version" content="${nextVersion}">\n</head>`);
898
- }
899
- // Hoist external scripts to <head> so libs load before
900
- // inline body scripts, then wrap inlines in IIFEs to
901
- // avoid const/let redeclaration via document.write().
902
- out = hoistExternalScriptsToHead(out);
903
- out = wrapInlineScriptsInIIFE(out);
904
- // Replace branding for white-label forks
905
- if (productName !== 'SynthOS') {
906
- out = out.replace(/synthos/gi, productName);
907
- }
908
- res.send(out);
909
- }
910
- else {
911
- throw result.error;
912
- }
913
- }
914
- catch (err) {
915
- if (config.debug) {
916
- console.log((0, debugLog_1.red)(` ERROR: ${err.message}`));
917
- }
918
- else {
919
- console.error(err);
920
1823
  }
921
- res.status(500).send(err.message);
922
- }
1824
+ }); // withPageLock
923
1825
  });
924
1826
  }
925
1827
  exports.usePageRoutes = usePageRoutes;
926
1828
  async function loadPageWithFallback(page, config, reset) {
927
1829
  if (reset) {
928
- // Clear working-state versions so we get the fresh template
929
- await (0, pages_1.clearVersions)(config, page);
1830
+ // Clear working-state changes so we get the fresh template
1831
+ await (0, pages_1.clearChanges)(config, page);
930
1832
  }
931
1833
  // Try primary pages folder first
932
1834
  const pageState = await (0, pages_1.loadPageState)(config, page);