synthos 0.5.0 → 0.7.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 (149) hide show
  1. package/README.md +33 -1
  2. package/default-pages/app_builder.html +40 -0
  3. package/default-pages/app_builder.json +1 -0
  4. package/default-pages/json_tools.html +89 -159
  5. package/default-pages/json_tools.json +1 -0
  6. package/default-pages/my_notes.html +33 -0
  7. package/default-pages/my_notes.json +12 -0
  8. package/default-pages/neon_asteroids.html +77 -0
  9. package/default-pages/neon_asteroids.json +12 -0
  10. package/default-pages/sidebar_builder.html +49 -0
  11. package/default-pages/sidebar_builder.json +1 -0
  12. package/default-pages/solar_explorer.html +1956 -0
  13. package/default-pages/solar_explorer.json +12 -0
  14. package/default-pages/solar_tutorial.html +476 -0
  15. package/default-pages/solar_tutorial.json +1 -0
  16. package/default-pages/two-panel_builder.html +66 -0
  17. package/default-pages/two-panel_builder.json +1 -0
  18. package/dist/connectors/index.d.ts +3 -0
  19. package/dist/connectors/index.d.ts.map +1 -0
  20. package/dist/connectors/index.js +6 -0
  21. package/dist/connectors/index.js.map +1 -0
  22. package/dist/connectors/registry.d.ts +3 -0
  23. package/dist/connectors/registry.d.ts.map +1 -0
  24. package/dist/connectors/registry.js +100 -0
  25. package/dist/connectors/registry.js.map +1 -0
  26. package/dist/connectors/types.d.ts +61 -0
  27. package/dist/connectors/types.d.ts.map +1 -0
  28. package/dist/connectors/types.js +3 -0
  29. package/dist/connectors/types.js.map +1 -0
  30. package/dist/files.d.ts +2 -0
  31. package/dist/files.d.ts.map +1 -1
  32. package/dist/files.js +12 -1
  33. package/dist/files.js.map +1 -1
  34. package/dist/init.d.ts +8 -1
  35. package/dist/init.d.ts.map +1 -1
  36. package/dist/init.js +155 -3
  37. package/dist/init.js.map +1 -1
  38. package/dist/migrations.d.ts +11 -0
  39. package/dist/migrations.d.ts.map +1 -0
  40. package/dist/migrations.js +281 -0
  41. package/dist/migrations.js.map +1 -0
  42. package/dist/models/index.d.ts +3 -0
  43. package/dist/models/index.d.ts.map +1 -0
  44. package/dist/models/index.js +10 -0
  45. package/dist/models/index.js.map +1 -0
  46. package/dist/models/providers.d.ts +7 -0
  47. package/dist/models/providers.d.ts.map +1 -0
  48. package/dist/models/providers.js +33 -0
  49. package/dist/models/providers.js.map +1 -0
  50. package/dist/models/types.d.ts +21 -0
  51. package/dist/models/types.d.ts.map +1 -0
  52. package/dist/models/types.js +3 -0
  53. package/dist/models/types.js.map +1 -0
  54. package/dist/pages.d.ts +21 -2
  55. package/dist/pages.d.ts.map +1 -1
  56. package/dist/pages.js +202 -23
  57. package/dist/pages.js.map +1 -1
  58. package/dist/scripts.js +2 -2
  59. package/dist/scripts.js.map +1 -1
  60. package/dist/service/createCompletePrompt.d.ts +3 -2
  61. package/dist/service/createCompletePrompt.d.ts.map +1 -1
  62. package/dist/service/createCompletePrompt.js +11 -16
  63. package/dist/service/createCompletePrompt.js.map +1 -1
  64. package/dist/service/debugLog.d.ts +11 -0
  65. package/dist/service/debugLog.d.ts.map +1 -0
  66. package/dist/service/debugLog.js +26 -0
  67. package/dist/service/debugLog.js.map +1 -0
  68. package/dist/service/modelInstructions.d.ts +7 -0
  69. package/dist/service/modelInstructions.d.ts.map +1 -0
  70. package/dist/service/modelInstructions.js +16 -0
  71. package/dist/service/modelInstructions.js.map +1 -0
  72. package/dist/service/requiresSettings.d.ts +2 -2
  73. package/dist/service/requiresSettings.d.ts.map +1 -1
  74. package/dist/service/requiresSettings.js.map +1 -1
  75. package/dist/service/server.d.ts.map +1 -1
  76. package/dist/service/server.js +15 -0
  77. package/dist/service/server.js.map +1 -1
  78. package/dist/service/transformPage.d.ts +81 -2
  79. package/dist/service/transformPage.d.ts.map +1 -1
  80. package/dist/service/transformPage.js +672 -82
  81. package/dist/service/transformPage.js.map +1 -1
  82. package/dist/service/useApiRoutes.d.ts.map +1 -1
  83. package/dist/service/useApiRoutes.js +579 -13
  84. package/dist/service/useApiRoutes.js.map +1 -1
  85. package/dist/service/useConnectorRoutes.d.ts +4 -0
  86. package/dist/service/useConnectorRoutes.d.ts.map +1 -0
  87. package/dist/service/useConnectorRoutes.js +389 -0
  88. package/dist/service/useConnectorRoutes.js.map +1 -0
  89. package/dist/service/useDataRoutes.d.ts.map +1 -1
  90. package/dist/service/useDataRoutes.js +83 -70
  91. package/dist/service/useDataRoutes.js.map +1 -1
  92. package/dist/service/usePageRoutes.d.ts.map +1 -1
  93. package/dist/service/usePageRoutes.js +243 -38
  94. package/dist/service/usePageRoutes.js.map +1 -1
  95. package/dist/settings.d.ts +33 -4
  96. package/dist/settings.d.ts.map +1 -1
  97. package/dist/settings.js +108 -15
  98. package/dist/settings.js.map +1 -1
  99. package/dist/synthos-cli.d.ts.map +1 -1
  100. package/dist/synthos-cli.js +11 -1
  101. package/dist/synthos-cli.js.map +1 -1
  102. package/dist/themes.d.ts +9 -0
  103. package/dist/themes.d.ts.map +1 -0
  104. package/dist/themes.js +64 -0
  105. package/dist/themes.js.map +1 -0
  106. package/package.json +5 -3
  107. package/required-pages/builder.html +74 -0
  108. package/required-pages/builder.json +1 -0
  109. package/required-pages/pages.html +169 -126
  110. package/required-pages/pages.json +1 -0
  111. package/required-pages/settings.html +812 -156
  112. package/required-pages/settings.json +1 -0
  113. package/required-pages/synthos_apis.html +272 -0
  114. package/required-pages/synthos_apis.json +1 -0
  115. package/required-pages/synthos_scripts.html +87 -0
  116. package/required-pages/synthos_scripts.json +1 -0
  117. package/src/connectors/index.ts +12 -0
  118. package/src/connectors/registry.ts +98 -0
  119. package/src/connectors/types.ts +68 -0
  120. package/src/files.ts +11 -0
  121. package/src/init.ts +151 -5
  122. package/src/migrations.ts +266 -0
  123. package/src/models/index.ts +2 -0
  124. package/src/models/providers.ts +33 -0
  125. package/src/models/types.ts +23 -0
  126. package/src/pages.ts +234 -26
  127. package/src/scripts.ts +2 -2
  128. package/src/service/createCompletePrompt.ts +14 -18
  129. package/src/service/debugLog.ts +17 -0
  130. package/src/service/modelInstructions.ts +14 -0
  131. package/src/service/requiresSettings.ts +3 -3
  132. package/src/service/server.ts +19 -2
  133. package/src/service/transformPage.ts +709 -88
  134. package/src/service/useApiRoutes.ts +632 -16
  135. package/src/service/useConnectorRoutes.ts +427 -0
  136. package/src/service/useDataRoutes.ts +87 -71
  137. package/src/service/usePageRoutes.ts +237 -44
  138. package/src/settings.ts +143 -20
  139. package/src/synthos-cli.ts +11 -1
  140. package/src/themes.ts +71 -0
  141. package/default-pages/[application].html +0 -95
  142. package/default-pages/[markdown].html +0 -271
  143. package/default-pages/[sidebar].html +0 -114
  144. package/default-pages/[split-application].html +0 -118
  145. package/default-pages/solar_system.html +0 -432
  146. package/default-pages/space_invaders.html +0 -617
  147. package/required-pages/apis.html +0 -362
  148. package/required-pages/home.html +0 -126
  149. package/required-pages/scripts.html +0 -350
@@ -1,12 +1,49 @@
1
- import { listPages } from "../pages";
2
- import {loadSettings, saveSettings } from "../settings";
1
+ import path from "path";
2
+ import { listPages, loadPageMetadata, PageMetadata, savePageMetadata, REQUIRED_PAGES, deletePage, copyPage, loadPageState, savePageState, PAGE_VERSION } from "../pages";
3
+ import { checkIfExists, copyFile, deleteFile, loadFile } from "../files";
4
+ import {getModelEntry, loadSettings, saveSettings, ServicesConfig } from "../settings";
3
5
  import { Application } from 'express';
4
6
  import { SynthOSConfig } from "../init";
5
- import { availableModels, createCompletePrompt } from "./createCompletePrompt";
7
+ import { createCompletePrompt, PROVIDERS } from "./createCompletePrompt";
6
8
  import { generateDefaultImage, generateImage } from "./generateImage";
7
9
  import { chainOfThought } from "agentm-core";
8
10
  import { requiresSettings } from "./requiresSettings";
9
11
  import { executeScript } from "../scripts";
12
+ import { listThemes, loadTheme, loadThemeInfo } from "../themes";
13
+ import { migratePage } from "../migrations";
14
+ import { loadPageWithFallback } from "./usePageRoutes";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Service registry
18
+ // ---------------------------------------------------------------------------
19
+
20
+ interface ServiceField {
21
+ name: string;
22
+ label: string;
23
+ type: 'password' | 'text';
24
+ }
25
+
26
+ interface ServiceDefinition {
27
+ id: string;
28
+ name: string;
29
+ category: string;
30
+ description: string;
31
+ fields: ServiceField[];
32
+ exclusive?: string;
33
+ }
34
+
35
+ const SERVICE_REGISTRY: ServiceDefinition[] = [
36
+ {
37
+ id: 'brave-search',
38
+ name: 'Brave Search',
39
+ category: 'Search',
40
+ description: 'Web search powered by the Brave Search API. Provides real-time search results from the web.',
41
+ fields: [
42
+ { name: 'apiKey', label: 'API Key', type: 'password' }
43
+ ],
44
+ exclusive: 'search'
45
+ }
46
+ ];
10
47
 
11
48
  export function useApiRoutes(config: SynthOSConfig, app: Application): void {
12
49
  // List pages
@@ -15,27 +52,253 @@ export function useApiRoutes(config: SynthOSConfig, app: Application): void {
15
52
  res.json(pages);
16
53
  });
17
54
 
55
+ // Get page metadata
56
+ app.get('/api/pages/:name', async (req, res) => {
57
+ try {
58
+ const { name } = req.params;
59
+ const metadata = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolder);
60
+ if (metadata) {
61
+ res.json(metadata);
62
+ } else {
63
+ const defaults: PageMetadata = {
64
+ title: '',
65
+ categories: [],
66
+ pinned: false,
67
+ showInAll: true,
68
+ createdDate: '',
69
+ lastModified: '',
70
+ pageVersion: 0,
71
+ mode: 'unlocked',
72
+ };
73
+ res.json(defaults);
74
+ }
75
+ } catch (err: unknown) {
76
+ console.error(err);
77
+ res.status(500).send((err as Error).message);
78
+ }
79
+ });
80
+
81
+ // Save page metadata (merge semantics)
82
+ app.post('/api/pages/:name', async (req, res) => {
83
+ try {
84
+ const { name } = req.params;
85
+ const body = req.body;
86
+
87
+ // Validate provided fields only
88
+ if ('title' in body && typeof body.title !== 'string') {
89
+ res.status(400).json({ error: 'title must be a string' });
90
+ return;
91
+ }
92
+ if ('categories' in body && !Array.isArray(body.categories)) {
93
+ res.status(400).json({ error: 'categories must be an array' });
94
+ return;
95
+ }
96
+ if ('pinned' in body && typeof body.pinned !== 'boolean') {
97
+ res.status(400).json({ error: 'pinned must be a boolean' });
98
+ return;
99
+ }
100
+ if ('mode' in body && body.mode !== 'unlocked' && body.mode !== 'locked') {
101
+ res.status(400).json({ error: 'mode must be "unlocked" or "locked"' });
102
+ return;
103
+ }
104
+ if ('showInAll' in body && typeof body.showInAll !== 'boolean') {
105
+ res.status(400).json({ error: 'showInAll must be a boolean' });
106
+ return;
107
+ }
108
+
109
+ // Load existing metadata (or defaults)
110
+ const existing = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolder);
111
+ const metadata: PageMetadata = {
112
+ title: existing?.title ?? '',
113
+ categories: existing?.categories ?? [],
114
+ pinned: existing?.pinned ?? false,
115
+ showInAll: existing?.showInAll ?? true,
116
+ createdDate: existing?.createdDate ?? '',
117
+ lastModified: existing?.lastModified ?? '',
118
+ pageVersion: existing?.pageVersion ?? 0,
119
+ mode: existing?.mode ?? 'unlocked',
120
+ };
121
+
122
+ // Overlay provided fields
123
+ if ('title' in body) metadata.title = body.title;
124
+ if ('categories' in body) metadata.categories = body.categories;
125
+ if ('pinned' in body) metadata.pinned = body.pinned;
126
+ if ('showInAll' in body) metadata.showInAll = body.showInAll;
127
+ if ('mode' in body) metadata.mode = body.mode;
128
+
129
+ // Auto-set lastModified
130
+ metadata.lastModified = new Date().toISOString();
131
+
132
+ // Promote required page to user folder if being unlocked/designed
133
+ if (metadata.mode !== 'locked') {
134
+ const userPagePath = path.join(config.pagesFolder, 'pages', name, 'page.html');
135
+ if (!(await checkIfExists(userPagePath))) {
136
+ const html = await loadPageState(config.requiredPagesFolder, name, false);
137
+ if (html) {
138
+ await savePageState(config.pagesFolder, name, html);
139
+ }
140
+ }
141
+ }
142
+
143
+ await savePageMetadata(config.pagesFolder, name, metadata);
144
+ res.json(metadata);
145
+ } catch (err: unknown) {
146
+ console.error(err);
147
+ res.status(500).send((err as Error).message);
148
+ }
149
+ });
150
+
151
+ // Pin/unpin a page
152
+ app.post('/api/pages/:name/pin', async (req, res) => {
153
+ try {
154
+ const { name } = req.params;
155
+ const { pinned } = req.body;
156
+ if (typeof pinned !== 'boolean') {
157
+ res.status(400).json({ error: 'pinned must be a boolean' });
158
+ return;
159
+ }
160
+
161
+ // Load existing metadata (user override → fallback .json → defaults)
162
+ let metadata = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolder);
163
+ if (!metadata) {
164
+ metadata = {
165
+ title: '',
166
+ categories: [],
167
+ pinned: false,
168
+ showInAll: true,
169
+ createdDate: '',
170
+ lastModified: '',
171
+ pageVersion: 0,
172
+ mode: 'unlocked',
173
+ };
174
+ }
175
+
176
+ metadata.pinned = pinned;
177
+ await savePageMetadata(config.pagesFolder, name, metadata);
178
+ res.json(metadata);
179
+ } catch (err: unknown) {
180
+ console.error(err);
181
+ res.status(500).send((err as Error).message);
182
+ }
183
+ });
184
+
185
+ // Delete a page
186
+ app.delete('/api/pages/:name', async (req, res) => {
187
+ try {
188
+ const { name } = req.params;
189
+
190
+ // Cannot delete required pages
191
+ if (REQUIRED_PAGES.includes(name)) {
192
+ res.status(400).json({ error: `Cannot delete required page "${name}"` });
193
+ return;
194
+ }
195
+
196
+ // Check if page exists (folder-based or legacy flat file)
197
+ const folderPath = path.join(config.pagesFolder, 'pages', name, 'page.html');
198
+ const flatPath = path.join(config.pagesFolder, `${name}.html`);
199
+ const exists = await checkIfExists(folderPath) || await checkIfExists(flatPath);
200
+ if (!exists) {
201
+ res.status(404).json({ error: `Page "${name}" not found` });
202
+ return;
203
+ }
204
+
205
+ await deletePage(config.pagesFolder, name);
206
+ res.json({ deleted: true });
207
+ } catch (err: unknown) {
208
+ console.error(err);
209
+ res.status(500).send((err as Error).message);
210
+ }
211
+ });
212
+
213
+ // Copy a page to a new name
214
+ app.post('/api/pages/:name/copy', async (req, res) => {
215
+ try {
216
+ const sourceName = req.params.name;
217
+ const { name: targetName, title, categories } = req.body;
218
+
219
+ // Validate target name
220
+ if (!targetName || typeof targetName !== 'string') {
221
+ res.status(400).json({ error: 'name is required' });
222
+ return;
223
+ }
224
+
225
+ if (!/^[a-zA-Z0-9_-]+$/.test(targetName)) {
226
+ res.status(400).json({ error: 'name can only contain letters, numbers, hyphens, and underscores' });
227
+ return;
228
+ }
229
+
230
+ // Can't copy over yourself
231
+ if (targetName === sourceName) {
232
+ res.status(400).json({ error: 'Cannot copy a page to itself' });
233
+ return;
234
+ }
235
+
236
+ // Check source exists (user pages → required pages)
237
+ const sourceFolderPath = path.join(config.pagesFolder, 'pages', sourceName, 'page.html');
238
+ const sourceFlatPath = path.join(config.pagesFolder, `${sourceName}.html`);
239
+ const sourceRequiredPath = path.join(config.requiredPagesFolder, `${sourceName}.html`);
240
+ const sourceExists = await checkIfExists(sourceFolderPath)
241
+ || await checkIfExists(sourceFlatPath)
242
+ || await checkIfExists(sourceRequiredPath);
243
+ if (!sourceExists) {
244
+ res.status(404).json({ error: `Source page "${sourceName}" not found` });
245
+ return;
246
+ }
247
+
248
+ // Check target doesn't already exist
249
+ const targetFolderPath = path.join(config.pagesFolder, 'pages', targetName, 'page.html');
250
+ const targetFlatPath = path.join(config.pagesFolder, `${targetName}.html`);
251
+ if (await checkIfExists(targetFolderPath) || await checkIfExists(targetFlatPath)) {
252
+ res.status(409).json({ error: `Page "${targetName}" already exists` });
253
+ return;
254
+ }
255
+
256
+ await copyPage(
257
+ config.pagesFolder,
258
+ sourceName,
259
+ targetName,
260
+ typeof title === 'string' ? title : '',
261
+ Array.isArray(categories) ? categories : [],
262
+ config.requiredPagesFolder
263
+ );
264
+
265
+ // Return the new page metadata
266
+ const metadata = await loadPageMetadata(config.pagesFolder, targetName);
267
+ res.status(201).json({ name: targetName, ...metadata });
268
+ } catch (err: unknown) {
269
+ console.error(err);
270
+ res.status(500).send((err as Error).message);
271
+ }
272
+ });
273
+
18
274
  // Define a route to return settings
19
275
  app.get('/api/settings', async (req, res) => {
20
276
  const settings = await loadSettings(config.pagesFolder);
21
- res.json({...settings, availableModels});
277
+ const providers = PROVIDERS.map(p => ({ name: p.name, builderModels: p.builderModels, chatModels: p.chatModels }));
278
+ res.json({...settings, providers});
22
279
  });
23
280
 
24
281
  // Define a route to save settings
25
282
  app.post('/api/settings', async (req, res) => {
26
283
  try {
27
- // Covert non-string values
284
+ // Coerce non-string values inside models array
28
285
  const settings = req.body as Record<string, any>;
29
- if (typeof settings.maxTokens === 'string') {
30
- settings.maxTokens = parseInt(settings.maxTokens);
31
- }
32
- if (typeof settings.logCompletions === 'string') {
33
- settings.logCompletions = settings.logCompletions === 'true';
286
+ if (Array.isArray(settings.models)) {
287
+ for (const entry of settings.models) {
288
+ if (entry.configuration) {
289
+ if (typeof entry.configuration.maxTokens === 'string') {
290
+ entry.configuration.maxTokens = parseInt(entry.configuration.maxTokens);
291
+ }
292
+ }
293
+ if (typeof entry.logCompletions === 'string') {
294
+ entry.logCompletions = entry.logCompletions === 'true';
295
+ }
296
+ }
34
297
  }
35
298
 
36
299
  // Save settings
37
300
  await saveSettings(config.pagesFolder, settings);
38
- res.redirect('/home');
301
+ res.redirect('/builder');
39
302
  } catch (err: unknown) {
40
303
  console.error(err);
41
304
  res.status(500).send((err as Error).message);
@@ -46,9 +309,10 @@ export function useApiRoutes(config: SynthOSConfig, app: Application): void {
46
309
  app.post('/api/generate/image', async (req, res) => {
47
310
  await requiresSettings(res, config.pagesFolder, async (settings) => {
48
311
  const { prompt, shape, style } = req.body;
49
- const { serviceApiKey, imageQuality, model } = settings;
50
- const response = model.startsWith('gpt-') ?
51
- await generateImage({ apiKey: serviceApiKey, prompt, shape, quality: imageQuality, style }) :
312
+ const builder = getModelEntry(settings, 'builder');
313
+ const { configuration, imageQuality, provider } = builder;
314
+ const response = provider === 'OpenAI' ?
315
+ await generateImage({ apiKey: configuration.apiKey, prompt, shape, quality: imageQuality, style }) :
52
316
  await generateDefaultImage();
53
317
  if (response.completed) {
54
318
  res.json(response.value);
@@ -62,8 +326,8 @@ export function useApiRoutes(config: SynthOSConfig, app: Application): void {
62
326
  app.post('/api/generate/completion', async (req, res) => {
63
327
  await requiresSettings(res, config.pagesFolder, async (settings) => {
64
328
  const { prompt, temperature } = req.body;
65
- const { maxTokens } = settings;
66
- const completePrompt = await createCompletePrompt(config.pagesFolder, req.body.model);
329
+ const maxTokens = getModelEntry(settings, 'chat').configuration.maxTokens;
330
+ const completePrompt = await createCompletePrompt(config.pagesFolder, 'chat', req.body.model);
67
331
  const response = await chainOfThought({ question: prompt, temperature, maxTokens, completePrompt });
68
332
  if (response.completed) {
69
333
  res.json(response.value ?? {});
@@ -74,6 +338,64 @@ export function useApiRoutes(config: SynthOSConfig, app: Application): void {
74
338
  });
75
339
  });
76
340
 
341
+ // Brainstorm endpoint
342
+ app.post('/api/brainstorm', async (req, res) => {
343
+ await requiresSettings(res, config.pagesFolder, async (settings) => {
344
+ const { context, messages } = req.body;
345
+ const maxTokens = getModelEntry(settings, 'chat').configuration.maxTokens;
346
+ const completePrompt = await createCompletePrompt(config.pagesFolder, 'chat');
347
+
348
+ const system: { role: 'system'; content: string } = {
349
+ role: 'system',
350
+ content: `You are a creative brainstorming assistant for SynthOS, a tool that builds web pages through conversation. The user is brainstorming — exploring ideas before building. Be concise, creative, and collaborative. Suggest concrete approaches when you can.
351
+
352
+ You MUST return your response as a JSON object with exactly these fields:
353
+ {
354
+ "response": "Your conversational reply — explanations, options, suggestions. Markdown OK.",
355
+ "prompt": "A clean, actionable instruction ready to paste into SynthOS chat to build what was discussed. Update this each exchange to reflect the latest brainstorm state.",
356
+ "suggestions": ["Short clickable option A", "Short clickable option B", "Short clickable option C"]
357
+ }
358
+
359
+ suggestions — 2-4 short phrases the user can click to continue the conversation. These are next-step options: directions to explore, questions to answer, or choices to make. Keep each under 60 characters. Always provide suggestions.
360
+
361
+ Return ONLY the JSON object. No markdown fences.
362
+
363
+ <CONTEXT>
364
+ ${context}
365
+ </CONTEXT>`
366
+ };
367
+
368
+ // Format multi-turn conversation into a single prompt
369
+ const formatted = (messages as { role: string; content: string }[]).map(m =>
370
+ `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`
371
+ ).join('\n\n');
372
+
373
+ const prompt: { role: 'user'; content: string } = { role: 'user', content: formatted };
374
+
375
+ const result = await completePrompt({ prompt, system, maxTokens, jsonMode: true });
376
+ if (result.completed) {
377
+ let response = result.value || '';
378
+ let brainstormPrompt = '';
379
+ let suggestions: string[] = [];
380
+ // jsonMode returns an already-parsed object from agentm-core
381
+ const parsed = (typeof result.value === 'object' && result.value !== null)
382
+ ? result.value as Record<string, unknown>
383
+ : (() => { try { return JSON.parse(result.value as string); } catch { return null; } })();
384
+ if (parsed) {
385
+ if (typeof parsed.response === 'string') response = parsed.response;
386
+ if (typeof parsed.prompt === 'string') brainstormPrompt = parsed.prompt;
387
+ if (Array.isArray(parsed.suggestions)) {
388
+ suggestions = parsed.suggestions.filter((s: unknown) => typeof s === 'string');
389
+ }
390
+ }
391
+ res.json({ response, prompt: brainstormPrompt, suggestions });
392
+ } else {
393
+ console.error(result.error);
394
+ res.status(500).send(result.error?.message);
395
+ }
396
+ });
397
+ });
398
+
77
399
  // Define a route for running configured scripts
78
400
  app.post('/api/scripts/:id', async (req, res) => {
79
401
  await requiresSettings(res, config.pagesFolder, async (settings) => {
@@ -92,4 +414,298 @@ export function useApiRoutes(config: SynthOSConfig, app: Application): void {
92
414
  }
93
415
  });
94
416
  });
417
+
418
+ // Return theme info as a self-executing JS script
419
+ app.get('/api/theme-info.js', async (req, res) => {
420
+ try {
421
+ const settings = await loadSettings(config.pagesFolder);
422
+ const themeName = settings.theme ?? 'nebula-dusk';
423
+ const info = await loadThemeInfo(themeName, config);
424
+ if (!info) {
425
+ res.status(404).send(`// Theme info for "${themeName}" not found`);
426
+ return;
427
+ }
428
+ const js = `window.themeInfo=${JSON.stringify(info)};document.documentElement.classList.add(window.themeInfo.mode+"-mode");`;
429
+ res.set('Content-Type', 'application/javascript');
430
+ res.send(js);
431
+ } catch (err: unknown) {
432
+ console.error(err);
433
+ res.status(500).send(`// ${(err as Error).message}`);
434
+ }
435
+ });
436
+
437
+ // Return page info as a self-executing JS script
438
+ app.get('/api/page-info.js', async (req, res) => {
439
+ try {
440
+ const page = req.query.page as string;
441
+ if (!page) {
442
+ res.status(400).send('// Missing page query parameter');
443
+ return;
444
+ }
445
+ const metadata = await loadPageMetadata(config.pagesFolder, page, config.requiredPagesFolder);
446
+ const mode = metadata?.mode ?? 'unlocked';
447
+ const title = metadata?.title ?? '';
448
+ const categories = metadata?.categories ?? [];
449
+ const info = JSON.stringify({ name: page, mode, latestPageVersion: PAGE_VERSION, title, categories });
450
+ const js = [
451
+ `window.pageInfo=${info};`,
452
+ `if(window.pageInfo.mode==="locked"){`,
453
+ `document.addEventListener("DOMContentLoaded",function(){`,
454
+ `var f=document.getElementById("chatForm");if(f)f.style.display="none";`,
455
+ `var s=document.getElementById("saveLink");if(s)s.textContent="Copy";`,
456
+ `var r=document.getElementById("resetLink");if(r){`,
457
+ `var c=r.cloneNode(true);c.textContent="Reload";`,
458
+ `c.addEventListener("click",function(e){e.preventDefault();window.location.href=window.location.pathname;});`,
459
+ `r.parentNode.replaceChild(c,r);}`,
460
+ `});`,
461
+ `}`,
462
+ ].join('');
463
+ res.set('Content-Type', 'application/javascript');
464
+ res.set('Cache-Control', 'no-store');
465
+ res.send(js);
466
+ } catch (err: unknown) {
467
+ console.error(err);
468
+ res.status(500).send(`// ${(err as Error).message}`);
469
+ }
470
+ });
471
+
472
+ // Return the current theme as CSS
473
+ app.get('/api/theme.css', async (req, res) => {
474
+ try {
475
+ const settings = await loadSettings(config.pagesFolder);
476
+ const themeName = settings.theme ?? 'nebula-dusk';
477
+ const css = await loadTheme(themeName, config);
478
+ if (!css) {
479
+ res.status(404).send(`Theme "${themeName}" not found`);
480
+ return;
481
+ }
482
+ res.set('Content-Type', 'text/css');
483
+ res.send(css);
484
+ } catch (err: unknown) {
485
+ console.error(err);
486
+ res.status(500).send((err as Error).message);
487
+ }
488
+ });
489
+
490
+ // List available themes
491
+ app.get('/api/themes', async (req, res) => {
492
+ try {
493
+ const themes = await listThemes(config);
494
+ res.json(themes);
495
+ } catch (err: unknown) {
496
+ console.error(err);
497
+ res.status(500).send((err as Error).message);
498
+ }
499
+ });
500
+
501
+ // Return a versioned page script
502
+ app.get('/api/page-script.js', async (req, res) => {
503
+ try {
504
+ const v = parseInt(req.query.v as string, 10);
505
+ if (isNaN(v) || v < 1) {
506
+ res.status(400).send('// Invalid version parameter');
507
+ return;
508
+ }
509
+ const scriptPath = path.join(config.pageScriptsFolder, `page-v${v}.js`);
510
+ if (!(await checkIfExists(scriptPath))) {
511
+ res.status(404).send(`// page-v${v}.js not found`);
512
+ return;
513
+ }
514
+ const js = await loadFile(scriptPath);
515
+ res.set('Content-Type', 'application/javascript');
516
+ res.set('Cache-Control', 'public, max-age=3600');
517
+ res.send(js);
518
+ } catch (err: unknown) {
519
+ console.error(err);
520
+ res.status(500).send(`// ${(err as Error).message}`);
521
+ }
522
+ });
523
+
524
+ // Return versioned page helpers
525
+ app.get('/api/page-helpers.js', async (req, res) => {
526
+ try {
527
+ const v = parseInt(req.query.v as string, 10);
528
+ if (isNaN(v) || v < 1) {
529
+ res.status(400).send('// Invalid version parameter');
530
+ return;
531
+ }
532
+ const scriptPath = path.join(config.pageScriptsFolder, `helpers-v${v}.js`);
533
+ if (!(await checkIfExists(scriptPath))) {
534
+ res.status(404).send(`// helpers-v${v}.js not found`);
535
+ return;
536
+ }
537
+ const js = await loadFile(scriptPath);
538
+ res.set('Content-Type', 'application/javascript');
539
+ res.set('Cache-Control', 'public, max-age=3600');
540
+ res.send(js);
541
+ } catch (err: unknown) {
542
+ console.error(err);
543
+ res.status(500).send(`// ${(err as Error).message}`);
544
+ }
545
+ });
546
+
547
+ // -----------------------------------------------------------------------
548
+ // Services
549
+ // -----------------------------------------------------------------------
550
+
551
+ // Return the service registry (available service definitions)
552
+ app.get('/api/services/registry', (_req, res) => {
553
+ res.json(SERVICE_REGISTRY);
554
+ });
555
+
556
+ // Return user's configured services (API keys masked)
557
+ app.get('/api/services', async (_req, res) => {
558
+ try {
559
+ const settings = await loadSettings(config.pagesFolder);
560
+ const services = settings.services ?? {};
561
+ const masked: Record<string, { enabled: boolean; hasKey: boolean }> = {};
562
+ for (const [id, cfg] of Object.entries(services)) {
563
+ masked[id] = {
564
+ enabled: cfg.enabled,
565
+ hasKey: typeof cfg.apiKey === 'string' && cfg.apiKey.length > 0
566
+ };
567
+ }
568
+ res.json(masked);
569
+ } catch (err: unknown) {
570
+ console.error(err);
571
+ res.status(500).send((err as Error).message);
572
+ }
573
+ });
574
+
575
+ // Save services config (enforces exclusive groups)
576
+ app.post('/api/services', async (req, res) => {
577
+ try {
578
+ const incoming = req.body as ServicesConfig;
579
+ const settings = await loadSettings(config.pagesFolder);
580
+ const existing = settings.services ?? {};
581
+
582
+ // Build merged config — empty apiKey means "keep existing"
583
+ const merged: ServicesConfig = {};
584
+ for (const [id, cfg] of Object.entries(incoming)) {
585
+ const apiKey = (cfg.apiKey && cfg.apiKey.length > 0) ? cfg.apiKey : (existing[id]?.apiKey ?? '');
586
+ merged[id] = { apiKey, enabled: cfg.enabled };
587
+ }
588
+
589
+ // Enforce exclusive groups: only one enabled per group
590
+ for (const def of SERVICE_REGISTRY) {
591
+ if (!def.exclusive) continue;
592
+ if (!merged[def.id]?.enabled) continue;
593
+ for (const other of SERVICE_REGISTRY) {
594
+ if (other.id !== def.id && other.exclusive === def.exclusive && merged[other.id]) {
595
+ merged[other.id].enabled = false;
596
+ }
597
+ }
598
+ }
599
+
600
+ await saveSettings(config.pagesFolder, { services: merged });
601
+ res.json({ saved: true });
602
+ } catch (err: unknown) {
603
+ console.error(err);
604
+ res.status(500).send((err as Error).message);
605
+ }
606
+ });
607
+
608
+ // -----------------------------------------------------------------------
609
+ // Web Search (Brave Search API)
610
+ // -----------------------------------------------------------------------
611
+
612
+ app.post('/api/search/web', async (req, res) => {
613
+ try {
614
+ const { query, count, country, freshness } = req.body;
615
+ if (!query || typeof query !== 'string') {
616
+ res.status(400).json({ error: 'query is required' });
617
+ return;
618
+ }
619
+
620
+ const settings = await loadSettings(config.pagesFolder);
621
+ const braveConfig = settings.connectors?.['brave-search'] ?? settings.services?.['brave-search'];
622
+ if (!braveConfig || !braveConfig.enabled || !braveConfig.apiKey) {
623
+ res.status(400).json({ error: 'Brave Search is not configured or not enabled. Add your API key in Settings > Services.' });
624
+ return;
625
+ }
626
+
627
+ const params = new URLSearchParams({ q: query });
628
+ if (count) params.set('count', String(Math.min(Number(count) || 5, 20)));
629
+ if (country) params.set('country', country);
630
+ if (freshness) params.set('freshness', freshness);
631
+
632
+ const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params.toString()}`, {
633
+ headers: {
634
+ 'Accept': 'application/json',
635
+ 'Accept-Encoding': 'gzip',
636
+ 'X-Subscription-Token': braveConfig.apiKey
637
+ }
638
+ });
639
+
640
+ if (!response.ok) {
641
+ const text = await response.text();
642
+ res.status(response.status).json({ error: `Brave Search API error: ${text}` });
643
+ return;
644
+ }
645
+
646
+ const data = await response.json() as { web?: { results?: Array<{ title: string; url: string; description: string }> } };
647
+ const results = (data.web?.results ?? []).map(r => ({
648
+ title: r.title,
649
+ url: r.url,
650
+ description: r.description
651
+ }));
652
+
653
+ res.json({ results });
654
+ } catch (err: unknown) {
655
+ console.error(err);
656
+ res.status(500).json({ error: (err as Error).message });
657
+ }
658
+ });
659
+
660
+ // Upgrade a page to the latest version
661
+ app.post('/api/pages/:name/upgrade', async (req, res) => {
662
+ try {
663
+ const { name } = req.params;
664
+
665
+ // Load current metadata
666
+ const metadata = await loadPageMetadata(config.pagesFolder, name, config.requiredPagesFolder);
667
+ if (!metadata) {
668
+ res.status(404).json({ error: `Page "${name}" not found` });
669
+ return;
670
+ }
671
+
672
+ const currentVersion = metadata.pageVersion;
673
+ if (currentVersion >= PAGE_VERSION) {
674
+ res.json({ upgraded: false, currentVersion });
675
+ return;
676
+ }
677
+
678
+ // Load the page HTML
679
+ const html = await loadPageWithFallback(name, config, false);
680
+ if (!html) {
681
+ res.status(404).json({ error: `Page HTML for "${name}" not found` });
682
+ return;
683
+ }
684
+
685
+ // Run LLM-based migration
686
+ const completePrompt = await createCompletePrompt(config.pagesFolder, 'builder');
687
+ const migratedHtml = await migratePage(html, currentVersion, PAGE_VERSION, completePrompt);
688
+
689
+ // Save upgraded HTML to v2 folder structure
690
+ await savePageState(config.pagesFolder, name, migratedHtml);
691
+
692
+ // Move legacy flat file to .migrated folder instead of deleting
693
+ const flatPath = path.join(config.pagesFolder, `${name}.html`);
694
+ if (await checkIfExists(flatPath)) {
695
+ const migratedFolder = path.join(config.pagesFolder, '.migrated');
696
+ await copyFile(flatPath, migratedFolder);
697
+ await deleteFile(flatPath);
698
+ }
699
+
700
+ // Update metadata
701
+ metadata.pageVersion = PAGE_VERSION;
702
+ metadata.lastModified = new Date().toISOString();
703
+ await savePageMetadata(config.pagesFolder, name, metadata);
704
+
705
+ res.json({ upgraded: true, fromVersion: currentVersion, toVersion: PAGE_VERSION });
706
+ } catch (err: unknown) {
707
+ console.error(err);
708
+ res.status(500).json({ error: (err as Error).message });
709
+ }
710
+ });
95
711
  }