synthos 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) 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/default-themes/nebula-dawn.css +682 -0
  19. package/default-themes/nebula-dawn.json +19 -0
  20. package/default-themes/nebula-dusk.css +674 -0
  21. package/default-themes/nebula-dusk.json +19 -0
  22. package/dist/connectors/index.d.ts +3 -0
  23. package/dist/connectors/index.d.ts.map +1 -0
  24. package/dist/connectors/index.js +6 -0
  25. package/dist/connectors/index.js.map +1 -0
  26. package/dist/connectors/registry.d.ts +3 -0
  27. package/dist/connectors/registry.d.ts.map +1 -0
  28. package/dist/connectors/registry.js +100 -0
  29. package/dist/connectors/registry.js.map +1 -0
  30. package/dist/connectors/types.d.ts +61 -0
  31. package/dist/connectors/types.d.ts.map +1 -0
  32. package/dist/connectors/types.js +3 -0
  33. package/dist/connectors/types.js.map +1 -0
  34. package/dist/files.d.ts +2 -0
  35. package/dist/files.d.ts.map +1 -1
  36. package/dist/files.js +12 -1
  37. package/dist/files.js.map +1 -1
  38. package/dist/init.d.ts +8 -1
  39. package/dist/init.d.ts.map +1 -1
  40. package/dist/init.js +155 -3
  41. package/dist/init.js.map +1 -1
  42. package/dist/migrations.d.ts +11 -0
  43. package/dist/migrations.d.ts.map +1 -0
  44. package/dist/migrations.js +281 -0
  45. package/dist/migrations.js.map +1 -0
  46. package/dist/models/index.d.ts +3 -0
  47. package/dist/models/index.d.ts.map +1 -0
  48. package/dist/models/index.js +10 -0
  49. package/dist/models/index.js.map +1 -0
  50. package/dist/models/providers.d.ts +7 -0
  51. package/dist/models/providers.d.ts.map +1 -0
  52. package/dist/models/providers.js +33 -0
  53. package/dist/models/providers.js.map +1 -0
  54. package/dist/models/types.d.ts +21 -0
  55. package/dist/models/types.d.ts.map +1 -0
  56. package/dist/models/types.js +3 -0
  57. package/dist/models/types.js.map +1 -0
  58. package/dist/pages.d.ts +21 -2
  59. package/dist/pages.d.ts.map +1 -1
  60. package/dist/pages.js +202 -23
  61. package/dist/pages.js.map +1 -1
  62. package/dist/scripts.js +2 -2
  63. package/dist/scripts.js.map +1 -1
  64. package/dist/service/createCompletePrompt.d.ts +3 -2
  65. package/dist/service/createCompletePrompt.d.ts.map +1 -1
  66. package/dist/service/createCompletePrompt.js +11 -16
  67. package/dist/service/createCompletePrompt.js.map +1 -1
  68. package/dist/service/debugLog.d.ts +11 -0
  69. package/dist/service/debugLog.d.ts.map +1 -0
  70. package/dist/service/debugLog.js +26 -0
  71. package/dist/service/debugLog.js.map +1 -0
  72. package/dist/service/modelInstructions.d.ts +7 -0
  73. package/dist/service/modelInstructions.d.ts.map +1 -0
  74. package/dist/service/modelInstructions.js +16 -0
  75. package/dist/service/modelInstructions.js.map +1 -0
  76. package/dist/service/requiresSettings.d.ts +2 -2
  77. package/dist/service/requiresSettings.d.ts.map +1 -1
  78. package/dist/service/requiresSettings.js.map +1 -1
  79. package/dist/service/server.d.ts.map +1 -1
  80. package/dist/service/server.js +15 -0
  81. package/dist/service/server.js.map +1 -1
  82. package/dist/service/transformPage.d.ts +81 -2
  83. package/dist/service/transformPage.d.ts.map +1 -1
  84. package/dist/service/transformPage.js +672 -82
  85. package/dist/service/transformPage.js.map +1 -1
  86. package/dist/service/useApiRoutes.d.ts.map +1 -1
  87. package/dist/service/useApiRoutes.js +579 -13
  88. package/dist/service/useApiRoutes.js.map +1 -1
  89. package/dist/service/useConnectorRoutes.d.ts +4 -0
  90. package/dist/service/useConnectorRoutes.d.ts.map +1 -0
  91. package/dist/service/useConnectorRoutes.js +389 -0
  92. package/dist/service/useConnectorRoutes.js.map +1 -0
  93. package/dist/service/useDataRoutes.d.ts.map +1 -1
  94. package/dist/service/useDataRoutes.js +83 -70
  95. package/dist/service/useDataRoutes.js.map +1 -1
  96. package/dist/service/usePageRoutes.d.ts.map +1 -1
  97. package/dist/service/usePageRoutes.js +243 -38
  98. package/dist/service/usePageRoutes.js.map +1 -1
  99. package/dist/settings.d.ts +33 -4
  100. package/dist/settings.d.ts.map +1 -1
  101. package/dist/settings.js +108 -15
  102. package/dist/settings.js.map +1 -1
  103. package/dist/synthos-cli.d.ts.map +1 -1
  104. package/dist/synthos-cli.js +11 -1
  105. package/dist/synthos-cli.js.map +1 -1
  106. package/dist/themes.d.ts +9 -0
  107. package/dist/themes.d.ts.map +1 -0
  108. package/dist/themes.js +64 -0
  109. package/dist/themes.js.map +1 -0
  110. package/package.json +6 -3
  111. package/required-pages/builder.html +74 -0
  112. package/required-pages/builder.json +1 -0
  113. package/required-pages/pages.html +169 -126
  114. package/required-pages/pages.json +1 -0
  115. package/required-pages/settings.html +812 -156
  116. package/required-pages/settings.json +1 -0
  117. package/required-pages/synthos_apis.html +272 -0
  118. package/required-pages/synthos_apis.json +1 -0
  119. package/required-pages/synthos_scripts.html +87 -0
  120. package/required-pages/synthos_scripts.json +1 -0
  121. package/src/connectors/index.ts +12 -0
  122. package/src/connectors/registry.ts +98 -0
  123. package/src/connectors/types.ts +68 -0
  124. package/src/files.ts +11 -0
  125. package/src/init.ts +151 -5
  126. package/src/migrations.ts +266 -0
  127. package/src/models/index.ts +2 -0
  128. package/src/models/providers.ts +33 -0
  129. package/src/models/types.ts +23 -0
  130. package/src/pages.ts +234 -26
  131. package/src/scripts.ts +2 -2
  132. package/src/service/createCompletePrompt.ts +14 -18
  133. package/src/service/debugLog.ts +17 -0
  134. package/src/service/modelInstructions.ts +14 -0
  135. package/src/service/requiresSettings.ts +3 -3
  136. package/src/service/server.ts +19 -2
  137. package/src/service/transformPage.ts +709 -88
  138. package/src/service/useApiRoutes.ts +632 -16
  139. package/src/service/useConnectorRoutes.ts +427 -0
  140. package/src/service/useDataRoutes.ts +87 -71
  141. package/src/service/usePageRoutes.ts +237 -44
  142. package/src/settings.ts +143 -20
  143. package/src/synthos-cli.ts +11 -1
  144. package/src/themes.ts +71 -0
  145. package/default-pages/[application].html +0 -95
  146. package/default-pages/[markdown].html +0 -271
  147. package/default-pages/[sidebar].html +0 -114
  148. package/default-pages/[split-application].html +0 -118
  149. package/default-pages/solar_system.html +0 -432
  150. package/default-pages/space_invaders.html +0 -617
  151. package/required-pages/apis.html +0 -362
  152. package/required-pages/home.html +0 -126
  153. package/required-pages/scripts.html +0 -350
@@ -1,14 +1,95 @@
1
- import { loadPageState, normalizePageName, savePageState, updatePageState } from "../pages";
2
- import { hasConfiguredSettings, loadSettings } from "../settings";
1
+ import { loadPageMetadata, loadPageState, normalizePageName, PAGE_VERSION, REQUIRED_PAGES, savePageMetadata, savePageState, updatePageState } from "../pages";
2
+ import { getModelEntry, hasConfiguredSettings, loadSettings } from "../settings";
3
3
  import { Application } from 'express';
4
- import { transformPage, transformPageAsObject } from "./transformPage";
4
+ import { transformPage } from "./transformPage";
5
+ import { getModelInstructions } from "./modelInstructions";
5
6
  import { SynthOSConfig } from "../init";
6
7
  import { createCompletePrompt } from "./createCompletePrompt";
7
8
  import { completePrompt } from "agentm-core";
9
+ import { green, red, dim, estimateTokens } from "./debugLog";
10
+ import { loadThemeInfo } from "../themes";
11
+ import * as cheerio from 'cheerio';
8
12
 
9
- const HOME_PAGE_ROUTE = '/home';
13
+ /**
14
+ * Required CDN imports that must be present on every v2 page.
15
+ * Each entry maps a detection selector to the script tag src to inject.
16
+ */
17
+ const REQUIRED_IMPORTS: { selector: string; src: string }[] = [
18
+ { selector: 'script[src*="marked"]', src: 'https://cdnjs.cloudflare.com/ajax/libs/marked/14.1.1/marked.min.js' },
19
+ ];
20
+
21
+ /**
22
+ * Uses cheerio to ensure every required import is present in the page's <head>.
23
+ * Skips imports that already exist (detected via selector).
24
+ */
25
+ function ensureRequiredImports(html: string, pageVersion: number): string {
26
+ if (pageVersion < 2) return html;
27
+ const $ = cheerio.load(html);
28
+ for (const imp of REQUIRED_IMPORTS) {
29
+ if ($(imp.selector).length === 0) {
30
+ $('head').append(`<script src="${imp.src}"></script>\n`);
31
+ }
32
+ }
33
+ return $.html();
34
+ }
35
+
36
+ const HOME_PAGE_ROUTE = '/builder';
10
37
  const PAGE_NOT_FOUND = 'Page not found';
11
38
 
39
+ function injectPageInfoScript(html: string, pageName: string): string {
40
+ const tag = `<script id="page-info" src="/api/page-info.js?page=${encodeURIComponent(pageName)}"></script>`;
41
+
42
+ // Replace any existing page-info script (may have a stale page name from the template)
43
+ const existing = html.match(/<script\s+id="page-info"[^>]*><\/script>/);
44
+ if (existing) {
45
+ return html.replace(existing[0], tag);
46
+ }
47
+
48
+ const idx = html.indexOf('</head>');
49
+ if (idx !== -1) {
50
+ return html.slice(0, idx) + tag + '\n' + html.slice(idx);
51
+ }
52
+ return tag + '\n' + html;
53
+ }
54
+
55
+ function injectPageHelpers(html: string, pageVersion: number): string {
56
+ if (pageVersion < 2) return html;
57
+ const tag = `<script id="page-helpers" src="/api/page-helpers.js?v=${pageVersion}"></script>`;
58
+
59
+ // Replace any existing page-helpers script (may be at wrong position from prior LLM output)
60
+ const existing = html.match(/<script\s+id="page-helpers"[^>]*><\/script>/);
61
+ if (existing) {
62
+ return html.replace(existing[0], tag);
63
+ }
64
+
65
+ // Inject into <head> after page-info so helpers are available before inline body scripts
66
+ const pageInfo = html.indexOf('id="page-info"');
67
+ if (pageInfo !== -1) {
68
+ const closeTag = html.indexOf('</script>', pageInfo);
69
+ if (closeTag !== -1) {
70
+ const insertAt = closeTag + '</script>'.length;
71
+ return html.slice(0, insertAt) + '\n' + tag + html.slice(insertAt);
72
+ }
73
+ }
74
+
75
+ const idx = html.indexOf('</head>');
76
+ if (idx !== -1) {
77
+ return html.slice(0, idx) + tag + '\n' + html.slice(idx);
78
+ }
79
+ return tag + '\n' + html;
80
+ }
81
+
82
+ function injectPageScript(html: string, pageVersion: number): string {
83
+ if (pageVersion < 2) return html;
84
+ if (html.includes('id="page-script"')) return html;
85
+ const tag = `<script id="page-script" src="/api/page-script.js?v=${pageVersion}"></script>`;
86
+ const idx = html.indexOf('</body>');
87
+ if (idx !== -1) {
88
+ return html.slice(0, idx) + tag + '\n' + html.slice(idx);
89
+ }
90
+ return html + '\n' + tag;
91
+ }
92
+
12
93
  export function usePageRoutes(config: SynthOSConfig, app: Application): void {
13
94
  // Redirect / to /home page
14
95
  app.get('/', (req, res) => res.redirect(HOME_PAGE_ROUTE));
@@ -19,7 +100,7 @@ export function usePageRoutes(config: SynthOSConfig, app: Application): void {
19
100
  const { page } = req.params;
20
101
  const isConfigured = await hasConfiguredSettings(config.pagesFolder);
21
102
  if (!isConfigured && page !== 'settings') {
22
- res.redirect('/settings');
103
+ res.redirect('/settings?firstRun=1');
23
104
  return;
24
105
  }
25
106
 
@@ -30,7 +111,21 @@ export function usePageRoutes(config: SynthOSConfig, app: Application): void {
30
111
  return;
31
112
  }
32
113
 
33
- res.send(pageState);
114
+ // Load page metadata for version-based script injection
115
+ const metadata = await loadPageMetadata(config.pagesFolder, page, config.requiredPagesFolder);
116
+ const pageVersion = metadata?.pageVersion ?? 0;
117
+
118
+ // Block outdated pages (redirect to /pages so user sees upgrade UI)
119
+ if (pageVersion < PAGE_VERSION && !REQUIRED_PAGES.includes(page)) {
120
+ res.redirect('/pages');
121
+ return;
122
+ }
123
+
124
+ let html = ensureRequiredImports(pageState, pageVersion);
125
+ html = injectPageInfoScript(html, page);
126
+ html = injectPageHelpers(html, pageVersion);
127
+ html = injectPageScript(html, pageVersion);
128
+ res.send(html);
34
129
  });
35
130
 
36
131
  // Page reset
@@ -39,7 +134,7 @@ export function usePageRoutes(config: SynthOSConfig, app: Application): void {
39
134
  const { page } = req.params;
40
135
  const isConfigured = await hasConfiguredSettings(config.pagesFolder);
41
136
  if (!isConfigured) {
42
- res.redirect('/settings');
137
+ res.redirect('/settings?firstRun=1');
43
138
  return;
44
139
  }
45
140
 
@@ -54,32 +149,82 @@ export function usePageRoutes(config: SynthOSConfig, app: Application): void {
54
149
  });
55
150
 
56
151
  // Page save
57
- app.get('/:page/save', async (req, res) => {
58
- // Redirect if settings not configured
59
- const { page } = req.params;
60
- const isConfigured = await hasConfiguredSettings(config.pagesFolder);
61
- if (!isConfigured) {
62
- res.redirect('/settings');
63
- return;
64
- }
152
+ app.post('/:page/save', async (req, res) => {
153
+ try {
154
+ // Redirect if settings not configured
155
+ const { page } = req.params;
156
+ const isConfigured = await hasConfiguredSettings(config.pagesFolder);
157
+ if (!isConfigured) {
158
+ res.status(400).json({ error: 'Settings not configured' });
159
+ return;
160
+ }
65
161
 
66
- // Normalize and validate save-as parameter
67
- const saveAs = normalizePageName(req.query['name'] as string);
68
- if (!saveAs) {
69
- res.status(400).send('Invalid or missing name parameter');
70
- return;
71
- }
162
+ // Extract fields from JSON body
163
+ const { title, categories, greeting } = req.body;
164
+ if (!title || typeof title !== 'string') {
165
+ res.status(400).json({ error: 'title is required' });
166
+ return;
167
+ }
168
+ if (!categories || !Array.isArray(categories) || categories.length === 0) {
169
+ res.status(400).json({ error: 'categories is required (array of strings)' });
170
+ return;
171
+ }
72
172
 
73
- // Load page state
74
- const pageState = await loadPageWithFallback(page, config, false);
75
- if (!pageState) {
76
- res.status(404).send(PAGE_NOT_FOUND);
77
- return;
78
- }
173
+ // Normalize page name from title
174
+ const saveAs = normalizePageName(title);
175
+ if (!saveAs) {
176
+ res.status(400).json({ error: 'Invalid title — cannot derive a page name' });
177
+ return;
178
+ }
179
+
180
+ // Reject reserved names
181
+ if (saveAs === 'builder') {
182
+ res.status(400).json({ error: '"Builder" is a reserved page name' });
183
+ return;
184
+ }
185
+
186
+ // Load page state
187
+ let pageState = await loadPageWithFallback(page, config, false);
188
+ if (!pageState) {
189
+ res.status(404).json({ error: PAGE_NOT_FOUND });
190
+ return;
191
+ }
192
+
193
+ // If greeting is provided, process with cheerio
194
+ if (greeting && typeof greeting === 'string' && greeting.trim().length > 0) {
195
+ const $ = cheerio.load(pageState);
196
+ const messages = $('#chatMessages .chat-message');
197
+ // Keep only the first message, remove the rest
198
+ messages.slice(1).remove();
199
+ // Update the greeting text in the first message
200
+ const firstP = messages.first().find('p');
201
+ const strong = firstP.find('strong');
202
+ if (strong.length) {
203
+ firstP.html('<strong>Synthos:</strong> ' + greeting.trim());
204
+ }
205
+ pageState = $.html();
206
+ }
207
+
208
+ // Save as new page
209
+ await savePageState(config.pagesFolder, saveAs, pageState, title, categories);
210
+
211
+ // Also update metadata with categories (in case page.json already existed)
212
+ await savePageMetadata(config.pagesFolder, saveAs, {
213
+ title,
214
+ categories,
215
+ pinned: false,
216
+ showInAll: true,
217
+ createdDate: new Date().toISOString(),
218
+ lastModified: new Date().toISOString(),
219
+ pageVersion: PAGE_VERSION,
220
+ mode: 'unlocked',
221
+ });
79
222
 
80
- // Save as new page and redirect to saved page
81
- await savePageState(config.pagesFolder, saveAs, pageState);
82
- res.redirect(`/${saveAs}`);
223
+ res.json({ redirect: `/${saveAs}` });
224
+ } catch (err: unknown) {
225
+ console.error(err);
226
+ res.status(500).json({ error: (err as Error).message });
227
+ }
83
228
  });
84
229
 
85
230
  // Page transformation
@@ -108,28 +253,76 @@ export function usePageRoutes(config: SynthOSConfig, app: Application): void {
108
253
  }
109
254
 
110
255
  // Create model instance
111
- const innerCompletePrompt = await createCompletePrompt(config.pagesFolder, req.body.model);
112
- const completePrompt: completePrompt = (args) => {
113
- // console.log(`SYSTEM:\n${args.system!.content}`);
114
- // console.log(`PROMPT:\n${args.prompt!.content}`);
115
- return innerCompletePrompt(args);
256
+ const innerCompletePrompt = await createCompletePrompt(config.pagesFolder, 'builder', req.body.model);
257
+ const debugVerbose = config.debugPageUpdates;
258
+ let inputChars = 0;
259
+ let outputChars = 0;
260
+ const completePrompt: completePrompt = async (args) => {
261
+ if (debugVerbose) {
262
+ console.log(green(dim('\n ===== PAGE UPDATE REQUEST =====')));
263
+ console.log(green(` SYSTEM:\n${args.system?.content}`));
264
+ console.log(green(`\n PROMPT:\n${args.prompt.content}`));
265
+ }
266
+ inputChars += (args.system?.content?.length ?? 0) + (args.prompt.content?.length ?? 0);
267
+ const result = await innerCompletePrompt(args);
268
+ if (result.completed) {
269
+ outputChars += result.value?.length ?? 0;
270
+ }
271
+ if (debugVerbose) {
272
+ console.log(green(dim('\n ----- PAGE UPDATE RESPONSE -----')));
273
+ if (result.completed) {
274
+ console.log(green(` RESPONSE:\n${result.value}`));
275
+ } else {
276
+ console.log(red(` ERROR: ${result.error?.message}`));
277
+ }
278
+ console.log(green(dim(' ================================\n')));
279
+ }
280
+ return result;
116
281
  }
117
282
 
118
-
119
- // Transform and cache updated page
283
+ // Transform and cache updated page
120
284
  const pagesFolder = config.pagesFolder;
121
- const { maxTokens, instructions, model } = await loadSettings(config.pagesFolder);
122
- const result = model.startsWith('gpt-') ?
123
- await transformPageAsObject({ pagesFolder, pageState, message, maxTokens, instructions, completePrompt }) :
124
- await transformPage({ pagesFolder, pageState, message, maxTokens, instructions, completePrompt });
285
+ const settings = await loadSettings(config.pagesFolder);
286
+ const builder = getModelEntry(settings, 'builder');
287
+ const { configuration, instructions } = builder;
288
+ const maxTokens = configuration.maxTokens;
289
+ const theme = settings.theme;
290
+ const themeInfo = await loadThemeInfo(theme ?? 'nebula-dusk', config);
291
+ const modelInstructions = getModelInstructions(builder.provider);
292
+ const configuredConnectors = settings.connectors;
293
+ const result = await transformPage({ pagesFolder, pageState, message, maxTokens, instructions, modelInstructions, completePrompt, themeInfo, configuredConnectors });
125
294
  if (result.completed) {
126
- updatePageState(page, result.value!);
127
- res.send(result.value!);
295
+ const { html, changeCount } = result.value!;
296
+ if (config.debug) {
297
+ const inTokens = estimateTokens(inputChars).toLocaleString();
298
+ const outTokens = estimateTokens(outputChars).toLocaleString();
299
+ console.log(` page: ${page} | message: ${message.length} chars | changes: ${changeCount} ops | ~${inTokens} in / ~${outTokens} out tokens`);
300
+ }
301
+ updatePageState(page, html);
302
+
303
+ // Update lastModified timestamp in page metadata
304
+ const metadata = await loadPageMetadata(pagesFolder, page, config.requiredPagesFolder);
305
+ if (metadata) {
306
+ metadata.lastModified = new Date().toISOString();
307
+ await savePageMetadata(pagesFolder, page, metadata);
308
+ }
309
+
310
+ // Inject required imports and page scripts (same as GET)
311
+ const pv = metadata?.pageVersion ?? 0;
312
+ let out = ensureRequiredImports(html, pv);
313
+ out = injectPageInfoScript(out, page);
314
+ out = injectPageHelpers(out, pv);
315
+ out = injectPageScript(out, pv);
316
+ res.send(out);
128
317
  } else {
129
318
  throw result.error;
130
319
  }
131
320
  } catch (err: unknown) {
132
- console.error(err);
321
+ if (config.debug) {
322
+ console.log(red(` ERROR: ${(err as Error).message}`));
323
+ } else {
324
+ console.error(err);
325
+ }
133
326
  res.status(500).send((err as Error).message);
134
327
  }
135
328
  });
package/src/settings.ts CHANGED
@@ -1,60 +1,183 @@
1
1
  import {checkIfExists, loadFile, saveFile} from './files';
2
2
  import path from 'path';
3
+ import { ModelEntry, ProviderName, detectProvider } from './models';
3
4
 
4
- let _settings: Partial<Settings>|undefined;
5
+ let _settings: Partial<SettingsV2>|undefined;
5
6
 
6
- export interface Settings {
7
+ export interface ServiceConfig {
8
+ apiKey: string;
9
+ enabled: boolean;
10
+ }
11
+
12
+ export type ServicesConfig = { [serviceId: string]: ServiceConfig };
13
+
14
+ // Re-export ModelEntry from models so existing imports from settings still work
15
+ export type { ModelEntry } from './models';
16
+
17
+ /**
18
+ * V1 settings shape (flat, single model). Used as migration source.
19
+ */
20
+ export interface SettingsV1 {
7
21
  serviceApiKey: string;
8
22
  model: string;
9
23
  maxTokens: number;
10
24
  imageQuality: 'standard' | 'hd';
11
25
  instructions?: string;
12
26
  logCompletions?: boolean;
27
+ theme?: string;
28
+ services?: ServicesConfig;
13
29
  }
14
30
 
15
- export const DefaultSettings: Settings = {
16
- serviceApiKey: '',
17
- model: '',
18
- maxTokens: 32000,
19
- imageQuality: 'standard',
20
- instructions: '',
21
- logCompletions: false
31
+ /**
32
+ * V2 settings shape with versioned models array.
33
+ */
34
+ export interface SettingsV2 {
35
+ version: 2;
36
+ theme: string;
37
+ models: ModelEntry[];
38
+ features: string[];
39
+ services?: ServicesConfig;
40
+ connectors?: ServicesConfig;
41
+ }
42
+
43
+ export const DefaultSettings: SettingsV2 = {
44
+ version: 2,
45
+ theme: 'nebula-dawn',
46
+ models: [
47
+ {
48
+ use: 'builder',
49
+ provider: 'Anthropic',
50
+ configuration: { apiKey: '', model: '', maxTokens: 32000 },
51
+ imageQuality: 'standard',
52
+ instructions: '',
53
+ logCompletions: false,
54
+ },
55
+ {
56
+ use: 'chat',
57
+ provider: 'Anthropic',
58
+ configuration: { apiKey: '', model: '', maxTokens: 32000 },
59
+ imageQuality: 'standard',
60
+ instructions: '',
61
+ logCompletions: false,
62
+ },
63
+ ],
64
+ features: [],
65
+ services: {},
66
+ connectors: {}
22
67
  };
23
68
 
69
+ /**
70
+ * Find a model entry by its `use` field. Falls back to default if not found.
71
+ */
72
+ export function getModelEntry(settings: SettingsV2, use: 'builder' | 'chat'): ModelEntry {
73
+ const entry = settings.models.find(m => m.use === use);
74
+ if (entry) return entry;
75
+ return DefaultSettings.models.find(m => m.use === use)!;
76
+ }
77
+
78
+ /**
79
+ * Migrate a v1 settings object to v2 by detecting provider from model prefix
80
+ * and wrapping fields into the new configuration object.
81
+ */
82
+ function migrateV1toV2(raw: Record<string, unknown>): SettingsV2 {
83
+ const v1 = raw as unknown as SettingsV1;
84
+ const rawModel = v1.model ?? '';
85
+ // Migrate retired model names
86
+ const model = rawModel === 'claude-opus-4-5' ? 'claude-opus-4-6' : rawModel;
87
+ const detected = detectProvider(model);
88
+ const provider: ProviderName = detected?.name ?? 'Anthropic';
89
+
90
+ // Default chat to claude-haiku-4-5 when migrating from an Anthropic model
91
+ const chatModel = provider === 'Anthropic' ? 'claude-haiku-4-5' : model;
92
+
93
+ return {
94
+ version: 2,
95
+ theme: v1.theme ?? 'nebula-dusk',
96
+ models: [
97
+ {
98
+ use: 'builder',
99
+ provider,
100
+ configuration: {
101
+ apiKey: v1.serviceApiKey ?? '',
102
+ model,
103
+ maxTokens: v1.maxTokens ?? 32000,
104
+ },
105
+ imageQuality: v1.imageQuality ?? 'standard',
106
+ instructions: v1.instructions ?? '',
107
+ logCompletions: v1.logCompletions ?? false,
108
+ },
109
+ {
110
+ use: 'chat',
111
+ provider,
112
+ configuration: {
113
+ apiKey: v1.serviceApiKey ?? '',
114
+ model: chatModel,
115
+ maxTokens: v1.maxTokens ?? 32000,
116
+ },
117
+ imageQuality: v1.imageQuality ?? 'standard',
118
+ instructions: v1.instructions ?? '',
119
+ logCompletions: v1.logCompletions ?? false,
120
+ },
121
+ ],
122
+ features: [],
123
+ services: v1.services,
124
+ };
125
+ }
126
+
24
127
  export async function hasConfiguredSettings(folder: string): Promise<boolean> {
25
128
  const settings = await loadSettings(folder);
26
- if (typeof settings.serviceApiKey !== 'string' || settings.serviceApiKey.length == 0) {
129
+ const builder = getModelEntry(settings, 'builder');
130
+ if (typeof builder.configuration.apiKey !== 'string' || builder.configuration.apiKey.length == 0) {
27
131
  return false;
28
132
  }
29
- if (typeof settings.model !== 'string' || settings.model.length == 0) {
133
+ if (typeof builder.configuration.model !== 'string' || builder.configuration.model.length == 0) {
30
134
  return false;
31
135
  }
32
- if (typeof settings.maxTokens !== 'number' || settings.maxTokens <= 0) {
136
+ if (typeof builder.configuration.maxTokens !== 'number' || builder.configuration.maxTokens <= 0) {
33
137
  return false;
34
138
  }
35
139
 
36
- return true;
140
+ return true;
37
141
  }
38
142
 
39
- export async function loadSettings(folder: string): Promise<Settings> {
143
+ export async function loadSettings(folder: string): Promise<SettingsV2> {
40
144
  if (_settings == undefined) {
41
- // Check for file to exist
42
145
  const filename = path.join(folder, 'settings.json');
43
146
  if (await checkIfExists(filename)) {
44
147
  try {
45
- // Load and parse file
46
- _settings = JSON.parse(await loadFile(filename));
148
+ const raw = JSON.parse(await loadFile(filename));
149
+ if (!raw.version) {
150
+ // V1 file — migrate
151
+ console.log('Migrating settings.json from v1 to v2...');
152
+ const migrated = migrateV1toV2(raw);
153
+ _settings = migrated;
154
+ await saveFile(filename, JSON.stringify(migrated, null, 4));
155
+ } else {
156
+ _settings = raw as Partial<SettingsV2>;
157
+ }
47
158
  } catch {
48
159
  // Invalid JSON
49
160
  }
50
161
  }
51
162
  }
52
163
 
53
- // Return settings from file
54
- return {...DefaultSettings, ..._settings};
164
+ const merged = {...DefaultSettings, ..._settings, models: _settings?.models ?? DefaultSettings.models};
165
+
166
+ // Auto-migrate: copy services into connectors if connectors is empty
167
+ const connectors = merged.connectors ?? {};
168
+ const services = merged.services ?? {};
169
+ if (Object.keys(connectors).length === 0 && Object.keys(services).length > 0) {
170
+ merged.connectors = { ...services };
171
+ }
172
+
173
+ return merged;
55
174
  }
56
175
 
57
- export async function saveSettings(folder: string, settings: Partial<Settings>): Promise<void> {
176
+ export async function saveSettings(folder: string, settings: Partial<SettingsV2>): Promise<void> {
58
177
  _settings = {..._settings, ...settings};
178
+ if (settings.models) {
179
+ _settings.models = settings.models;
180
+ }
181
+ _settings.version = 2;
59
182
  await saveFile(path.join(folder, 'settings.json'), JSON.stringify(_settings, null, 4));
60
183
  }
@@ -20,9 +20,19 @@ export async function run() {
20
20
  type: 'boolean',
21
21
  default: true
22
22
  })
23
+ .option('debug', {
24
+ describe: `Log all server requests with timing and page update summaries.`,
25
+ type: 'boolean',
26
+ default: false
27
+ })
28
+ .option('debug-page-updates', {
29
+ describe: `Log model input/output for page transformations to the console.`,
30
+ type: 'boolean',
31
+ default: false
32
+ })
23
33
  .demandOption([]);
24
34
  }, async (args) => {
25
- const config = createConfig();
35
+ const config = createConfig('.synthos', { debug: args.debug, debugPageUpdates: args.debugPageUpdates });
26
36
  await init(config, args.pages);
27
37
  await server(config).listen(args.port, async () => {
28
38
  console.log(`SynthOS server is running on http://localhost:${args.port}`);
package/src/themes.ts ADDED
@@ -0,0 +1,71 @@
1
+ import path from 'path';
2
+ import { checkIfExists, listFiles, loadFile } from './files';
3
+ import { SynthOSConfig } from './init';
4
+
5
+ export interface ThemeInfo {
6
+ mode: 'light' | 'dark';
7
+ colors: Record<string, string>;
8
+ }
9
+
10
+ function userThemesFolder(config: SynthOSConfig): string {
11
+ return path.join(config.pagesFolder, 'themes');
12
+ }
13
+
14
+ export async function loadThemeInfo(name: string, config: SynthOSConfig): Promise<ThemeInfo | undefined> {
15
+ // Check user's local themes first, then fall back to package defaults
16
+ const localPath = path.join(userThemesFolder(config), `${name}.json`);
17
+ if (await checkIfExists(localPath)) {
18
+ const raw = await loadFile(localPath);
19
+ return raw ? JSON.parse(raw) : undefined;
20
+ }
21
+
22
+ const defaultPath = path.join(config.defaultThemesFolder, `${name}.json`);
23
+ if (await checkIfExists(defaultPath)) {
24
+ const raw = await loadFile(defaultPath);
25
+ return raw ? JSON.parse(raw) : undefined;
26
+ }
27
+
28
+ return undefined;
29
+ }
30
+
31
+ export async function loadTheme(name: string, config: SynthOSConfig): Promise<string | undefined> {
32
+ // Check user's local themes first, then fall back to package defaults
33
+ const localPath = path.join(userThemesFolder(config), `${name}.css`);
34
+ if (await checkIfExists(localPath)) {
35
+ return await loadFile(localPath);
36
+ }
37
+
38
+ const defaultPath = path.join(config.defaultThemesFolder, `${name}.css`);
39
+ if (await checkIfExists(defaultPath)) {
40
+ return await loadFile(defaultPath);
41
+ }
42
+
43
+ return undefined;
44
+ }
45
+
46
+ export async function listThemes(config: SynthOSConfig): Promise<string[]> {
47
+ const names = new Set<string>();
48
+
49
+ // Collect from user's local themes folder
50
+ const localFolder = userThemesFolder(config);
51
+ if (await checkIfExists(localFolder)) {
52
+ const files = await listFiles(localFolder);
53
+ for (const f of files) {
54
+ if (f.endsWith('.css')) {
55
+ names.add(f.replace(/\.css$/, ''));
56
+ }
57
+ }
58
+ }
59
+
60
+ // Collect from package defaults
61
+ if (await checkIfExists(config.defaultThemesFolder)) {
62
+ const files = await listFiles(config.defaultThemesFolder);
63
+ for (const f of files) {
64
+ if (f.endsWith('.css')) {
65
+ names.add(f.replace(/\.css$/, ''));
66
+ }
67
+ }
68
+ }
69
+
70
+ return Array.from(names).sort();
71
+ }