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