diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3f65c42..de25f94 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -42,7 +42,10 @@ "Bash(test:*)", "WebFetch(domain:docs.openclaw.ai)", "Bash(curl:*)", - "Bash(npm run test:mocha:*)" + "Bash(npm run test:mocha:*)", + "Bash(git -C \"C:/source/synthos\" log --oneline)", + "Bash(git -C \"C:/source/synthos\" log --oneline --all)", + "Bash(git -C C:/source/synthos log --oneline --all)" ] } } diff --git a/package.json b/package.json index cdc1383..0d5b14d 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "typescript": "^4.2.3" }, "scripts": { - "build": "tsc -b && node scripts/copy-connector-json.js", + "build": "tsc -b", "clean": "rimraf dist tsconfig.tsbuildinfo node_modules", "start": "node ./bin/synthos.js", "test": "npm run build && npm run test:mocha", @@ -61,6 +61,7 @@ "default-themes", "page-scripts", "required-pages", + "service-connectors", "images" ] } diff --git a/scripts/copy-connector-json.js b/scripts/copy-connector-json.js deleted file mode 100644 index 7b476d3..0000000 --- a/scripts/copy-connector-json.js +++ /dev/null @@ -1,17 +0,0 @@ -// Copies connector.json files from src/connectors/*/ to dist/connectors/*/ -// Run after tsc to ensure JSON assets are available at runtime. -const fs = require('fs'); -const path = require('path'); - -const src = path.join(__dirname, '..', 'src', 'connectors'); -const dst = path.join(__dirname, '..', 'dist', 'connectors'); - -fs.readdirSync(src, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .forEach(entry => { - const jsonFile = path.join(src, entry.name, 'connector.json'); - if (!fs.existsSync(jsonFile)) return; - const targetDir = path.join(dst, entry.name); - fs.mkdirSync(targetDir, { recursive: true }); - fs.copyFileSync(jsonFile, path.join(targetDir, 'connector.json')); - }); diff --git a/src/connectors/airtable/connector.json b/service-connectors/airtable/connector.json similarity index 100% rename from src/connectors/airtable/connector.json rename to service-connectors/airtable/connector.json diff --git a/src/connectors/alpha-vantage/connector.json b/service-connectors/alpha-vantage/connector.json similarity index 100% rename from src/connectors/alpha-vantage/connector.json rename to service-connectors/alpha-vantage/connector.json diff --git a/src/connectors/brave-search/connector.json b/service-connectors/brave-search/connector.json similarity index 100% rename from src/connectors/brave-search/connector.json rename to service-connectors/brave-search/connector.json diff --git a/src/connectors/cloudinary/connector.json b/service-connectors/cloudinary/connector.json similarity index 100% rename from src/connectors/cloudinary/connector.json rename to service-connectors/cloudinary/connector.json diff --git a/src/connectors/deepl/connector.json b/service-connectors/deepl/connector.json similarity index 100% rename from src/connectors/deepl/connector.json rename to service-connectors/deepl/connector.json diff --git a/src/connectors/elevenlabs/connector.json b/service-connectors/elevenlabs/connector.json similarity index 100% rename from src/connectors/elevenlabs/connector.json rename to service-connectors/elevenlabs/connector.json diff --git a/src/connectors/giphy/connector.json b/service-connectors/giphy/connector.json similarity index 100% rename from src/connectors/giphy/connector.json rename to service-connectors/giphy/connector.json diff --git a/src/connectors/github/connector.json b/service-connectors/github/connector.json similarity index 100% rename from src/connectors/github/connector.json rename to service-connectors/github/connector.json diff --git a/src/connectors/huggingface/connector.json b/service-connectors/huggingface/connector.json similarity index 100% rename from src/connectors/huggingface/connector.json rename to service-connectors/huggingface/connector.json diff --git a/src/connectors/imgur/connector.json b/service-connectors/imgur/connector.json similarity index 100% rename from src/connectors/imgur/connector.json rename to service-connectors/imgur/connector.json diff --git a/src/connectors/instagram/connector.json b/service-connectors/instagram/connector.json similarity index 100% rename from src/connectors/instagram/connector.json rename to service-connectors/instagram/connector.json diff --git a/src/connectors/jira/connector.json b/service-connectors/jira/connector.json similarity index 100% rename from src/connectors/jira/connector.json rename to service-connectors/jira/connector.json diff --git a/src/connectors/mapbox/connector.json b/service-connectors/mapbox/connector.json similarity index 100% rename from src/connectors/mapbox/connector.json rename to service-connectors/mapbox/connector.json diff --git a/src/connectors/nasa/connector.json b/service-connectors/nasa/connector.json similarity index 100% rename from src/connectors/nasa/connector.json rename to service-connectors/nasa/connector.json diff --git a/src/connectors/newsapi/connector.json b/service-connectors/newsapi/connector.json similarity index 100% rename from src/connectors/newsapi/connector.json rename to service-connectors/newsapi/connector.json diff --git a/src/connectors/notion/connector.json b/service-connectors/notion/connector.json similarity index 100% rename from src/connectors/notion/connector.json rename to service-connectors/notion/connector.json diff --git a/src/connectors/open-exchange-rates/connector.json b/service-connectors/open-exchange-rates/connector.json similarity index 100% rename from src/connectors/open-exchange-rates/connector.json rename to service-connectors/open-exchange-rates/connector.json diff --git a/src/connectors/openweathermap/connector.json b/service-connectors/openweathermap/connector.json similarity index 100% rename from src/connectors/openweathermap/connector.json rename to service-connectors/openweathermap/connector.json diff --git a/src/connectors/pexels/connector.json b/service-connectors/pexels/connector.json similarity index 100% rename from src/connectors/pexels/connector.json rename to service-connectors/pexels/connector.json diff --git a/src/connectors/resend/connector.json b/service-connectors/resend/connector.json similarity index 100% rename from src/connectors/resend/connector.json rename to service-connectors/resend/connector.json diff --git a/src/connectors/rss2json/connector.json b/service-connectors/rss2json/connector.json similarity index 100% rename from src/connectors/rss2json/connector.json rename to service-connectors/rss2json/connector.json diff --git a/src/connectors/sendgrid/connector.json b/service-connectors/sendgrid/connector.json similarity index 100% rename from src/connectors/sendgrid/connector.json rename to service-connectors/sendgrid/connector.json diff --git a/src/connectors/spoonacular/connector.json b/service-connectors/spoonacular/connector.json similarity index 100% rename from src/connectors/spoonacular/connector.json rename to service-connectors/spoonacular/connector.json diff --git a/src/connectors/stability-ai/connector.json b/service-connectors/stability-ai/connector.json similarity index 100% rename from src/connectors/stability-ai/connector.json rename to service-connectors/stability-ai/connector.json diff --git a/src/connectors/twilio/connector.json b/service-connectors/twilio/connector.json similarity index 100% rename from src/connectors/twilio/connector.json rename to service-connectors/twilio/connector.json diff --git a/src/connectors/unsplash/connector.json b/service-connectors/unsplash/connector.json similarity index 100% rename from src/connectors/unsplash/connector.json rename to service-connectors/unsplash/connector.json diff --git a/src/connectors/wolfram-alpha/connector.json b/service-connectors/wolfram-alpha/connector.json similarity index 100% rename from src/connectors/wolfram-alpha/connector.json rename to service-connectors/wolfram-alpha/connector.json diff --git a/src/connectors/youtube-data/connector.json b/service-connectors/youtube-data/connector.json similarity index 100% rename from src/connectors/youtube-data/connector.json rename to service-connectors/youtube-data/connector.json diff --git a/src/connectors/index.ts b/src/connectors/index.ts index ae65b77..645f767 100644 --- a/src/connectors/index.ts +++ b/src/connectors/index.ts @@ -1,4 +1,4 @@ -export { CONNECTOR_REGISTRY } from './registry'; +export { loadConnectorRegistry, getConnectorRegistry } from './registry'; export type { AuthStrategy, ConnectorField, diff --git a/src/connectors/registry.ts b/src/connectors/registry.ts index 566d4df..f3ae531 100644 --- a/src/connectors/registry.ts +++ b/src/connectors/registry.ts @@ -2,8 +2,6 @@ import * as fs from 'fs'; import * as path from 'path'; import { ConnectorDefinition, ConnectorJson } from './types'; -const connectorsDir = __dirname; - function loadConnectorJson(dir: string): ConnectorDefinition | null { const file = path.join(dir, 'connector.json'); if (!fs.existsSync(file)) return null; @@ -14,9 +12,19 @@ function loadConnectorJson(dir: string): ConnectorDefinition | null { }; } -export const CONNECTOR_REGISTRY: ConnectorDefinition[] = fs - .readdirSync(connectorsDir, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => loadConnectorJson(path.join(connectorsDir, d.name))) - .filter((d): d is ConnectorDefinition => d !== null) - .sort((a, b) => a.name.localeCompare(b.name)); +export function loadConnectorRegistry(connectorsDir: string): ConnectorDefinition[] { + if (!fs.existsSync(connectorsDir)) return []; + return fs.readdirSync(connectorsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => loadConnectorJson(path.join(connectorsDir, d.name))) + .filter((d): d is ConnectorDefinition => d !== null) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +let _registry: ConnectorDefinition[] | undefined; +export function getConnectorRegistry(connectorsDir?: string): ConnectorDefinition[] { + if (!_registry || connectorsDir) { + _registry = loadConnectorRegistry(connectorsDir ?? path.join(__dirname, '../../service-connectors')); + } + return _registry; +} diff --git a/src/customizer/Customizer.ts b/src/customizer/Customizer.ts new file mode 100644 index 0000000..98b3669 --- /dev/null +++ b/src/customizer/Customizer.ts @@ -0,0 +1,106 @@ +import { Application } from 'express'; +import { SynthOSConfig } from '../init'; +import path from 'path'; + +export type RouteInstaller = (config: SynthOSConfig, app: Application) => void; + +export class Customizer { + protected disabled: Set = new Set(); + protected extraRoutes: RouteInstaller[] = []; + + /** Route hints for the LLM prompt (fork developers can add custom hints). */ + protected routeHintEntries: { hints: string }[] = []; + + /** Custom instructions appended to transformPage's instruction block. */ + protected customTransformInstructions: string[] = []; + + // --- Content folder paths --- + // Override these in a derived class to point to your fork's folders. + + /** Folder containing built-in system pages (builder, settings, etc.) */ + get requiredPagesFolder(): string { + return path.join(__dirname, '../../required-pages'); + } + + /** Folder containing starter page templates copied on init */ + get defaultPagesFolder(): string { + return path.join(__dirname, '../../default-pages'); + } + + /** Folder containing theme CSS/JSON files */ + get defaultThemesFolder(): string { + return path.join(__dirname, '../../default-themes'); + } + + /** Folder containing versioned page scripts (page-v2.js, etc.) */ + get pageScriptsFolder(): string { + return path.join(__dirname, '../../page-scripts'); + } + + /** Folder containing connector JSON definitions */ + get serviceConnectorsFolder(): string { + return path.join(__dirname, '../../service-connectors'); + } + + // --- Feature group control --- + // Built-in groups: 'pages', 'api', 'connectors', 'agents', + // 'data', 'brainstorm', 'search', 'scripts' + + disable(...groups: string[]): this { + for (const g of groups) this.disabled.add(g); + return this; + } + + enable(...groups: string[]): this { + for (const g of groups) this.disabled.delete(g); + return this; + } + + isEnabled(group: string): boolean { + return !this.disabled.has(group); + } + + // --- Custom routes --- + + addRoutes(...installers: (RouteInstaller | { installer: RouteInstaller; hints: string })[]): this { + for (const entry of installers) { + if (typeof entry === 'function') { + this.extraRoutes.push(entry); + } else { + this.extraRoutes.push(entry.installer); + this.routeHintEntries.push({ hints: entry.hints }); + } + } + return this; + } + + getExtraRoutes(): RouteInstaller[] { + return this.extraRoutes; + } + + // --- Route hints --- + + /** Add route hints that will be shown to the LLM in transformPage. */ + addRouteHints(...hints: string[]): this { + for (const h of hints) this.routeHintEntries.push({ hints: h }); + return this; + } + + /** Get all custom route hints. */ + getRouteHints(): string[] { + return this.routeHintEntries.map(e => e.hints); + } + + // --- Custom transform instructions --- + + /** Add custom instructions for the transformPage LLM prompt. */ + addTransformInstructions(...instructions: string[]): this { + this.customTransformInstructions.push(...instructions); + return this; + } + + /** Get custom transform instructions. */ + getTransformInstructions(): string[] { + return this.customTransformInstructions; + } +} diff --git a/src/customizer/index.ts b/src/customizer/index.ts new file mode 100644 index 0000000..d2670b7 --- /dev/null +++ b/src/customizer/index.ts @@ -0,0 +1,6 @@ +export { Customizer, RouteInstaller } from './Customizer'; +import { Customizer } from './Customizer'; + +// Default instance — enables everything, uses base folders. +// Fork developers: replace this with your derived class instance. +export const customizer = new Customizer(); diff --git a/src/index.ts b/src/index.ts index 97f3b48..61c6dd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,5 @@ export * from './files'; export * from './init'; export * from './pages'; export * from './scripts'; -export * from './settings'; \ No newline at end of file +export * from './settings'; +export * from './customizer'; \ No newline at end of file diff --git a/src/init.ts b/src/init.ts index f398631..fdf058c 100644 --- a/src/init.ts +++ b/src/init.ts @@ -4,6 +4,7 @@ import { checkIfExists, copyFile, copyFiles, deleteFile, ensureFolderExists, lis import { PAGE_VERSION } from "./pages"; import { DefaultSettings } from "./settings"; import { getOutdatedThemes, parseThemeFilename } from "./themes"; +import { Customizer } from './customizer'; export interface SynthOSConfig { pagesFolder: string; @@ -12,18 +13,24 @@ export interface SynthOSConfig { defaultScriptsFolder: string; defaultThemesFolder: string; pageScriptsFolder: string; + serviceConnectorsFolder: string; debug: boolean; debugPageUpdates: boolean; } -export function createConfig(pagesFolder = '.synthos', options?: { debug?: boolean; debugPageUpdates?: boolean }): SynthOSConfig { +export function createConfig( + pagesFolder = '.synthos', + options?: { debug?: boolean; debugPageUpdates?: boolean }, + customizer?: Customizer +): SynthOSConfig { return { pagesFolder: path.join(process.cwd(), pagesFolder), - requiredPagesFolder: path.join(__dirname, '../required-pages'), - defaultPagesFolder: path.join(__dirname, '../default-pages'), + requiredPagesFolder: customizer?.requiredPagesFolder ?? path.join(__dirname, '../required-pages'), + defaultPagesFolder: customizer?.defaultPagesFolder ?? path.join(__dirname, '../default-pages'), defaultScriptsFolder: path.join(__dirname, '../default-scripts'), - defaultThemesFolder: path.join(__dirname, '../default-themes'), - pageScriptsFolder: path.join(__dirname, '../page-scripts'), + defaultThemesFolder: customizer?.defaultThemesFolder ?? path.join(__dirname, '../default-themes'), + pageScriptsFolder: customizer?.pageScriptsFolder ?? path.join(__dirname, '../page-scripts'), + serviceConnectorsFolder: customizer?.serviceConnectorsFolder ?? path.join(__dirname, '../service-connectors'), debug: options?.debug ?? false, debugPageUpdates: options?.debugPageUpdates ?? false }; diff --git a/src/service/server.ts b/src/service/server.ts index 194fdb6..32889f4 100644 --- a/src/service/server.ts +++ b/src/service/server.ts @@ -6,8 +6,9 @@ import { useDataRoutes } from './useDataRoutes'; import { useConnectorRoutes } from './useConnectorRoutes'; import { useAgentRoutes } from './useAgentRoutes'; import { cyan, yellow, formatTime } from './debugLog'; +import { customizer as defaultCustomizer, Customizer } from '../customizer'; -export function server(config: SynthOSConfig): Application { +export function server(config: SynthOSConfig, customizer: Customizer = defaultCustomizer): Application { const app = express(); // Debug request-logging middleware @@ -29,19 +30,24 @@ export function server(config: SynthOSConfig): Application { app.use(express.json()); // Page handling routes - usePageRoutes(config, app); + if (customizer.isEnabled('pages')) usePageRoutes(config, app, customizer); // API routes - useApiRoutes(config, app); + if (customizer.isEnabled('api')) useApiRoutes(config, app, customizer); // Connector routes - useConnectorRoutes(config, app); + if (customizer.isEnabled('connectors')) useConnectorRoutes(config, app); // Agent routes - useAgentRoutes(config, app); + if (customizer.isEnabled('agents')) useAgentRoutes(config, app); // Data routes - useDataRoutes(config, app); + if (customizer.isEnabled('data')) useDataRoutes(config, app); + + // Custom routes from the Customizer + for (const installer of customizer.getExtraRoutes()) { + installer(config, app); + } return app; -} \ No newline at end of file +} diff --git a/src/service/transformPage.ts b/src/service/transformPage.ts index 32befe5..1aeac6d 100644 --- a/src/service/transformPage.ts +++ b/src/service/transformPage.ts @@ -2,8 +2,9 @@ import { AgentArgs, AgentCompletion, SystemMessage, UserMessage } from "../model import { listScripts } from "../scripts"; import * as cheerio from "cheerio"; import { ThemeInfo } from "../themes"; -import { CONNECTOR_REGISTRY, ConnectorsConfig, ConnectorOAuthConfig } from "../connectors"; +import { getConnectorRegistry, ConnectorsConfig, ConnectorOAuthConfig } from "../connectors"; import { AgentConfig } from "../agents"; +import { Customizer } from "../customizer"; // --------------------------------------------------------------------------- // Types @@ -24,6 +25,10 @@ export interface TransformPageArgs extends AgentArgs { configuredConnectors?: ConnectorsConfig; /** User's configured A2A agents (from settings). */ configuredAgents?: AgentConfig[]; + /** Pre-built route hints string (from buildRouteHints). Falls back to full serverAPIs. */ + routeHints?: string; + /** Custom transform instructions from Customizer. */ + customTransformInstructions?: string[]; } export type ChangeOp = @@ -83,7 +88,7 @@ export async function transformPage(args: TransformPageArgs): Promise cfg.enabled && cfg.apiKey); if (entries.length > 0) { const blocks = entries.map(([id, cfg]) => { - const def = CONNECTOR_REGISTRY.find(d => d.id === id); + const def = getConnectorRegistry().find(d => d.id === id); if (!def) return `- ${id}`; let block = `- ${def.name} (id: "${id}", category: ${def.category})\n Base URL: ${def.baseUrl}`; if (def.hints) { @@ -124,7 +129,8 @@ export async function transformPage(args: TransformPageArgs): Promise\nThe user has configured these agents:\n\n${agentBlocks.join('\n\n')}\n\n${AGENT_API_REFERENCE}`; } - const systemMessage = [currentPage, serverAPIs, serverScripts, connectorsBlock, agentsBlock, themeBlock, messageFormat].filter(s => s).join('\n\n'); + const routeHintsBlock = args.routeHints ?? serverAPIs; + const systemMessage = [currentPage, routeHintsBlock, serverScripts, connectorsBlock, agentsBlock, themeBlock, messageFormat].filter(s => s).join('\n\n'); const system: SystemMessage = { role: 'system', content: systemMessage @@ -132,7 +138,8 @@ export async function transformPage(args: TransformPageArgs): Promise s.trim() !== '').join('\n'); + const customInstr = (args.customTransformInstructions ?? []).join('\n'); + const instructions = [userInstr, modelInstr, transformInstr, customInstr].filter(s => s.trim() !== '').join('\n'); const prompt: UserMessage = { role: 'user', content: `\n${message}\n\n\n${instructions}` @@ -704,6 +711,146 @@ Stream with attachments: IMPORTANT: Always check synthos.agents.list({ enabled: true }) before calling an agent. If no agents are configured, show the user a link to Settings > Agents (/settings?tab=agents).`; +// --------------------------------------------------------------------------- +// Route hint blocks — keyed by feature group so they can be filtered +// --------------------------------------------------------------------------- + +export const DEFAULT_ROUTE_HINTS = new Map([ + ['data', `GET /api/data/:page/:table +description: Retrieve all rows from a page-scoped table (tables are stored per-page). Supports pagination via query params. +query params: limit (number, optional) — max rows to return; offset (number, optional, default 0) — rows to skip +response (without limit): Array of JSON rows [{ id: string, ... }] +response (with limit): { items: [{ id: string, ... }], total: number, offset: number, limit: number, hasMore: boolean } + +GET /api/data/:page/:table/:id +description: Retrieve a single row from a page-scoped table +response: JSON row { id: string, ... } + +POST /api/data/:page/:table +description: Replaces or adds a single row to a page-scoped table and returns the row +request: JSON row { id?: string, ... } +response: { id: string, ... } + +DELETE /api/data/:page/:table/:id +description: Delete a single row from a page-scoped table +response: { success: true } + + synthos.data.list(table, opts?) — GET /api/data/:page/:table (auto-scoped to current page; opts: { limit?, offset? } — when limit is set, returns { items, total, offset, limit, hasMore }) + synthos.data.get(table, id) — GET /api/data/:page/:table/:id (auto-scoped to current page) + synthos.data.save(table, row) — POST /api/data/:page/:table (auto-scoped to current page) + synthos.data.remove(table, id) — DELETE /api/data/:page/:table/:id (auto-scoped to current page)`], + + ['api', `POST /api/generate/image +description: Generate an image based on a prompt +request: { prompt: string, shape: 'square' | 'portrait' | 'landscape', style: 'vivid' | 'natural' } +response: { url: string } + +POST /api/generate/completion +description: Generates a text completion based on a prompt +request: { prompt: string, temperature?: number } +response: { answer: string, explanation: string } + + synthos.generate.image({ prompt, shape, style }) — POST /api/generate/image + synthos.generate.completion({ prompt, temperature? }) — POST /api/generate/completion`], + + ['pages', `GET /api/pages +description: Retrieve a list of all pages with metadata +response: Array of { name: string, title: string, categories: string[], pinned: boolean, createdDate: string, lastModified: string, pageVersion: number, mode: 'unlocked' | 'locked' } + +GET /api/pages/:name +description: Retrieve metadata for a single page +response: { title: string, categories: string[], pinned: boolean, createdDate: string, lastModified: string, pageVersion: number, mode: 'unlocked' | 'locked' } + +POST /api/pages/:name +description: Update page metadata (merge semantics — send only fields to change; lastModified is auto-set) +request: { title?: string, categories?: string[], pinned?: boolean, mode?: 'unlocked' | 'locked' } +response: Full metadata object + +DELETE /api/pages/:name +description: Delete a user page (cannot delete required/system pages) +response: { deleted: true } + + synthos.pages.list() — GET /api/pages + synthos.pages.get(name) — GET /api/pages/:name + synthos.pages.update(name, metadata) — POST /api/pages/:name + synthos.pages.remove(name) — DELETE /api/pages/:name`], + + ['scripts', `POST /api/scripts/:id +description: Execute a script with the passed in variables +request: { [key: string]: string } +response: string + + synthos.scripts.run(id, variables) — POST /api/scripts/:id`], + + ['search', `POST /api/search/web +description: Search the web using Brave Search (must be enabled in Settings > Connectors) +request: { query: string, count?: number, country?: string, freshness?: string } +response: { results: [{ title: string, url: string, description: string }] } + + synthos.search.web(query, opts?) — POST /api/search/web (opts: { count?, country?, freshness? })`], + + ['agents', `GET /api/agents +description: List configured agents (A2A and OpenClaw). Supports ?enabled=true and ?provider=a2a|openclaw filters. +response: [{ id: string, name: string, description: string, url: string, enabled: boolean, provider: 'a2a'|'openclaw', capabilities?: object }] + +POST /api/agents/:id/send +description: Send a text message to an agent (works for both A2A and OpenClaw protocols) and receive a normalized response +request: { message: string, attachments?: [{ fileName: string, mimeType: string, content: string }] } +response: { kind: 'message'|'task', text?: string, raw: object } + +POST /api/agents/:id/stream +description: Send a message and receive a streaming SSE response (text/event-stream). Each event is JSON: { kind: 'text'|'status'|'artifact'|'done'|'error', data: any } +request: { message: string, attachments?: [{ fileName: string, mimeType: string, content: string }] } +response: SSE stream + + synthos.agents.list(opts?) — GET /api/agents (returns configured agents; opts: { enabled?, provider? }; returns [{ id, name, description, url, enabled, provider, capabilities }]) + synthos.agents.send(agentId, message, attachments?) — POST /api/agents/:id/send (sends a text message to any agent, returns normalized { kind, text, raw }; attachments: [{ fileName, mimeType, content }]) + synthos.agents.sendStream(agentId, message, onEvent, attachments?) — POST /api/agents/:id/stream (SSE streaming; onEvent receives { kind, data }; returns { close() } handle; attachments: [{ fileName, mimeType, content }]) + synthos.agents.isEnabled(agentId) — checks if an agent is enabled (returns Promise) + synthos.agents.getCapabilities(agentId) — returns agent capabilities object (streaming, skills, etc.)`], + + ['connectors', `GET /api/connectors +description: List available connectors (REST API proxies). Supports ?category=X and ?id=X filters. +response: [{ id: string, name: string, category: string, configured: boolean }] + +GET /api/connectors/:id +description: Get full detail for a connector including its definition and configuration status +response: { id, name, category, description, baseUrl, authStrategy, authKey, fields, configured, enabled, hasKey } + +POST /api/connectors (proxy call) +description: Proxy a request through a configured connector. The connector attaches auth automatically. +request: { connector: string, method: string, path: string, headers?: object, body?: any, query?: object } +response: Upstream API response (JSON or text) + + synthos.connectors.call(connector, method, path, opts?) — POST /api/connectors (proxy call; opts: { headers?, body?, query? }) + synthos.connectors.list(opts?) — GET /api/connectors (opts: { category?, id? })`], +]); + +/** + * Assemble the prompt block, including only hints for enabled + * feature groups and any custom route hints from the Customizer. + */ +export function buildRouteHints(customizer: Customizer): string { + const blocks: string[] = ['']; + + // Built-in hints — only include enabled groups + for (const [group, hints] of DEFAULT_ROUTE_HINTS) { + if (customizer.isEnabled(group)) { + blocks.push(hints); + } + } + + // Custom route hints from fork + for (const hint of customizer.getRouteHints()) { + blocks.push(hint); + } + + blocks.push('PAGE HELPERS (available globally as window.synthos):'); + blocks.push('All methods return Promises. Prefer these helpers over raw fetch().'); + return blocks.join('\n\n'); +} + +// Backward-compatible full serverAPIs string (used when no Customizer is passed) const serverAPIs = ` GET /api/data/:page/:table diff --git a/src/service/useApiRoutes.ts b/src/service/useApiRoutes.ts index a703373..0407c24 100644 --- a/src/service/useApiRoutes.ts +++ b/src/service/useApiRoutes.ts @@ -15,6 +15,7 @@ import { executeScript } from "../scripts"; import { listThemes, loadTheme, loadThemeInfo } from "../themes"; import { migratePage } from "../migrations"; import { loadPageWithFallback } from "./usePageRoutes"; +import { Customizer } from "../customizer"; // --------------------------------------------------------------------------- // Service registry @@ -48,7 +49,7 @@ const SERVICE_REGISTRY: ServiceDefinition[] = [ } ]; -export function useApiRoutes(config: SynthOSConfig, app: Application): void { +export function useApiRoutes(config: SynthOSConfig, app: Application, customizer?: Customizer): void { // List pages app.get('/api/pages', async (req, res) => { const pages = await listPages(config.pagesFolder, config.requiredPagesFolder); @@ -428,6 +429,7 @@ export function useApiRoutes(config: SynthOSConfig, app: Application): void { }); // Brainstorm endpoint + if (!customizer || customizer.isEnabled('brainstorm')) app.post('/api/brainstorm', async (req, res) => { await requiresSettings(res, config.pagesFolder, async (settings) => { const { context, messages } = req.body; @@ -495,6 +497,7 @@ Return ONLY the JSON object.`}; }); // Define a route for running configured scripts + if (!customizer || customizer.isEnabled('scripts')) app.post('/api/scripts/:id', async (req, res) => { await requiresSettings(res, config.pagesFolder, async (settings) => { const { id } = req.params; @@ -707,6 +710,7 @@ Return ONLY the JSON object.`}; // Web Search (Brave Search API) // ----------------------------------------------------------------------- + if (!customizer || customizer.isEnabled('search')) app.post('/api/search/web', async (req, res) => { try { const { query, count, country, freshness } = req.body; diff --git a/src/service/useConnectorRoutes.ts b/src/service/useConnectorRoutes.ts index b0420b0..78995f8 100644 --- a/src/service/useConnectorRoutes.ts +++ b/src/service/useConnectorRoutes.ts @@ -2,7 +2,7 @@ import { Application } from 'express'; import { SynthOSConfig } from '../init'; import { loadSettings, saveSettings } from '../settings'; import { - CONNECTOR_REGISTRY, + getConnectorRegistry, ConnectorSummary, ConnectorDetail, ConnectorCallRequest, @@ -21,7 +21,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi const categoryFilter = req.query.category as string | undefined; const idFilter = req.query.id as string | undefined; - const list: ConnectorSummary[] = CONNECTOR_REGISTRY + const list: ConnectorSummary[] = getConnectorRegistry(config.serviceConnectorsFolder) .filter(def => { if (categoryFilter && def.category !== categoryFilter) return false; if (idFilter && def.id !== idFilter) return false; @@ -54,7 +54,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi app.get('/api/connectors/:id', async (req, res) => { try { const { id } = req.params; - const def = CONNECTOR_REGISTRY.find(d => d.id === id); + const def = getConnectorRegistry(config.serviceConnectorsFolder).find(d => d.id === id); if (!def) { res.status(404).json({ error: `Connector "${id}" not found` }); return; @@ -88,7 +88,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi app.post('/api/connectors/:id', async (req, res) => { try { const { id } = req.params; - const def = CONNECTOR_REGISTRY.find(d => d.id === id); + const def = getConnectorRegistry(config.serviceConnectorsFolder).find(d => d.id === id); if (!def) { res.status(404).json({ error: `Connector "${id}" not found` }); return; @@ -164,7 +164,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi app.get('/api/connectors/:id/authorize', async (req, res) => { try { const { id } = req.params; - const def = CONNECTOR_REGISTRY.find(d => d.id === id); + const def = getConnectorRegistry(config.serviceConnectorsFolder).find(d => d.id === id); if (!def || def.authStrategy !== 'oauth2') { res.status(400).json({ error: `Connector "${id}" is not an OAuth2 connector` }); return; @@ -214,7 +214,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi const state = JSON.parse(stateRaw) as { connector: string }; const connectorId = state.connector; - const def = CONNECTOR_REGISTRY.find(d => d.id === connectorId); + const def = getConnectorRegistry(config.serviceConnectorsFolder).find(d => d.id === connectorId); if (!def || def.authStrategy !== 'oauth2') { res.status(400).json({ error: `Unknown OAuth2 connector: ${connectorId}` }); return; @@ -326,7 +326,7 @@ export function useConnectorRoutes(config: SynthOSConfig, app: Application): voi return; } - const def = CONNECTOR_REGISTRY.find(d => d.id === request.connector); + const def = getConnectorRegistry(config.serviceConnectorsFolder).find(d => d.id === request.connector); if (!def) { res.status(404).json({ error: `Connector "${request.connector}" not found` }); return; diff --git a/src/service/usePageRoutes.ts b/src/service/usePageRoutes.ts index 26f6744..e0e9b22 100644 --- a/src/service/usePageRoutes.ts +++ b/src/service/usePageRoutes.ts @@ -1,13 +1,14 @@ import { loadPageMetadata, loadPageState, normalizePageName, PAGE_VERSION, REQUIRED_PAGES, savePageMetadata, savePageState, updatePageState } from "../pages"; import { getModelEntry, hasConfiguredSettings, loadSettings } from "../settings"; import { Application } from 'express'; -import { transformPage } from "./transformPage"; +import { transformPage, buildRouteHints } from "./transformPage"; import { getModelInstructions } from "./modelInstructions"; import { SynthOSConfig } from "../init"; import { createCompletePrompt } from "./createCompletePrompt"; import { completePrompt } from "../models"; import { green, red, dim, estimateTokens } from "./debugLog"; import { loadThemeInfo } from "../themes"; +import { Customizer } from "../customizer"; import * as cheerio from 'cheerio'; /** @@ -91,7 +92,7 @@ function injectPageScript(html: string, pageVersion: number): string { return html + '\n' + tag; } -export function usePageRoutes(config: SynthOSConfig, app: Application): void { +export function usePageRoutes(config: SynthOSConfig, app: Application, customizer?: Customizer): void { // Redirect / to /home page app.get('/', (req, res) => res.redirect(HOME_PAGE_ROUTE)); @@ -304,7 +305,9 @@ export function usePageRoutes(config: SynthOSConfig, app: Application): void { const modelInstructions = getModelInstructions(builder.provider); const configuredConnectors = settings.connectors; const configuredAgents = settings.agents; - const result = await transformPage({ pagesFolder, pageState, message, instructions, modelInstructions, completePrompt, themeInfo, configuredConnectors, configuredAgents }); + const routeHints = customizer ? buildRouteHints(customizer) : undefined; + const customTransformInstructions = customizer ? customizer.getTransformInstructions() : undefined; + const result = await transformPage({ pagesFolder, pageState, message, instructions, modelInstructions, completePrompt, themeInfo, configuredConnectors, configuredAgents, routeHints, customTransformInstructions }); if (result.completed) { const { html, changeCount } = result.value!; if (config.debug) { diff --git a/src/synthos-cli.ts b/src/synthos-cli.ts index 3a28a02..9e28ae9 100644 --- a/src/synthos-cli.ts +++ b/src/synthos-cli.ts @@ -2,6 +2,7 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { server } from "./service"; import { createConfig, init } from "./init"; +import { customizer } from "./customizer"; const dynamicImport = new Function('specifier', `return import(specifier)`); @@ -32,9 +33,9 @@ export async function run() { }) .demandOption([]); }, async (args) => { - const config = createConfig('.synthos', { debug: args.debug, debugPageUpdates: args.debugPageUpdates }); + const config = createConfig('.synthos', { debug: args.debug, debugPageUpdates: args.debugPageUpdates }, customizer); await init(config, args.pages); - await server(config).listen(args.port, async () => { + await server(config, customizer).listen(args.port, async () => { console.log(`SynthOS server is running on http://localhost:${args.port}`); // Open using default browser