mojulo 0.0.0 → 0.1.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 (121) hide show
  1. package/README.md +54 -4
  2. package/lib/audit-logger-new.js +11 -0
  3. package/lib/auth/gate.js +25 -0
  4. package/lib/auth/service.js +17 -0
  5. package/lib/auth/session.js +63 -0
  6. package/lib/builder/chat-processor.js +607 -0
  7. package/lib/builder/composer-bridge.js +82 -0
  8. package/lib/builder/evaluator.js +159 -0
  9. package/lib/builder/executor.js +252 -0
  10. package/lib/builder/index.js +48 -0
  11. package/lib/builder/session.js +248 -0
  12. package/lib/builder/system-prompt.js +422 -0
  13. package/lib/builder/tone-presets.js +75 -0
  14. package/lib/builder/tool-executors.js +1527 -0
  15. package/lib/builder/tools.js +338 -0
  16. package/lib/builder/validators.js +239 -0
  17. package/lib/composer/composer.js +225 -0
  18. package/lib/composer/index.js +40 -0
  19. package/lib/composer/protocols/00_base.txt +19 -0
  20. package/lib/composer/protocols/01_knowledge.txt +9 -0
  21. package/lib/composer/protocols/02_form-gathering.txt +32 -0
  22. package/lib/composer/protocols/03_appointments.txt +16 -0
  23. package/lib/composer/protocols/04_triage.txt +15 -0
  24. package/lib/composer/protocols/05_optical-read.txt +22 -0
  25. package/lib/composer/response-builder.js +98 -0
  26. package/lib/config-builder.js +650 -0
  27. package/lib/db/ids.js +10 -0
  28. package/lib/db/index.js +179 -0
  29. package/lib/db/repositories/apiKeys.js +72 -0
  30. package/lib/db/repositories/auditLogs.js +12 -0
  31. package/lib/db/repositories/botSpaces.js +12 -0
  32. package/lib/db/repositories/builderSessions.js +312 -0
  33. package/lib/db/repositories/deploymentEvents.js +12 -0
  34. package/lib/db/repositories/deployments.js +385 -0
  35. package/lib/db/repositories/documents.js +68 -0
  36. package/lib/db/repositories/mcpJobs.js +84 -0
  37. package/lib/deployers/bot-fleet.js +110 -0
  38. package/lib/deployers/bot-proxy.js +72 -0
  39. package/lib/deployers/build.js +89 -0
  40. package/lib/deployers/cloud-deploy.js +310 -0
  41. package/lib/deployers/docker.js +439 -0
  42. package/lib/deployers/fly.js +432 -0
  43. package/lib/deployers/index.js +38 -0
  44. package/lib/deployment-auth.js +36 -0
  45. package/lib/document-parser.js +171 -0
  46. package/lib/embedder/chunker.js +93 -0
  47. package/lib/embedder/local.js +101 -0
  48. package/lib/embedder/preview-rag.js +93 -0
  49. package/lib/envelope-schema.js +54 -0
  50. package/lib/fleet/scoped-sql.js +342 -0
  51. package/lib/form-schema-config/base.js +135 -0
  52. package/lib/form-schema-config/index.js +286 -0
  53. package/lib/form-schema-config/locales/af-ZA.js +153 -0
  54. package/lib/form-schema-config/locales/ar-AE.js +142 -0
  55. package/lib/form-schema-config/locales/ar-SA.js +164 -0
  56. package/lib/form-schema-config/locales/de-DE.js +152 -0
  57. package/lib/form-schema-config/locales/en-AU.js +161 -0
  58. package/lib/form-schema-config/locales/en-CA.js +115 -0
  59. package/lib/form-schema-config/locales/en-GB.js +132 -0
  60. package/lib/form-schema-config/locales/en-IN.js +219 -0
  61. package/lib/form-schema-config/locales/en-MY.js +171 -0
  62. package/lib/form-schema-config/locales/en-NG.js +198 -0
  63. package/lib/form-schema-config/locales/en-PH.js +186 -0
  64. package/lib/form-schema-config/locales/en-SG.js +153 -0
  65. package/lib/form-schema-config/locales/en-US.js +138 -0
  66. package/lib/form-schema-config/locales/es-ES.js +171 -0
  67. package/lib/form-schema-config/locales/es-MX.js +193 -0
  68. package/lib/form-schema-config/locales/fr-CA.js +138 -0
  69. package/lib/form-schema-config/locales/fr-FR.js +155 -0
  70. package/lib/form-schema-config/locales/hi-IN.js +219 -0
  71. package/lib/form-schema-config/locales/it-IT.js +157 -0
  72. package/lib/form-schema-config/locales/ja-JP.js +169 -0
  73. package/lib/form-schema-config/locales/ko-KR.js +140 -0
  74. package/lib/form-schema-config/locales/nl-NL.js +149 -0
  75. package/lib/form-schema-config/locales/pt-BR.js +168 -0
  76. package/lib/form-schema-config/locales/zh-CN.js +172 -0
  77. package/lib/form-schema-config/locales/zh-HK.js +142 -0
  78. package/lib/form-structure-schema.js +191 -0
  79. package/lib/llm-providers.js +828 -0
  80. package/lib/markdown.js +197 -0
  81. package/lib/mcp/catalysts/appointment-to-calendar.md +84 -0
  82. package/lib/mcp/catalysts/conversations-to-channel-digest.md +104 -0
  83. package/lib/mcp/catalysts/document-extract-to-store.md +92 -0
  84. package/lib/mcp/catalysts/knowledge-gap-miner.md +96 -0
  85. package/lib/mcp/catalysts/loader.js +144 -0
  86. package/lib/mcp/catalysts/qualify-lead-to-crm.md +83 -0
  87. package/lib/mcp/catalysts/scan-conversations-for-signal.md +92 -0
  88. package/lib/mcp/catalysts/submission-to-ticket.md +83 -0
  89. package/lib/mcp/catalysts/submissions-to-warehouse.md +103 -0
  90. package/lib/mcp/catalysts/weekly-submissions-digest.md +82 -0
  91. package/lib/mcp/jobs.js +64 -0
  92. package/lib/mcp/server.js +184 -0
  93. package/lib/mcp/session-binding.js +130 -0
  94. package/lib/mcp/tools/build.js +123 -0
  95. package/lib/mcp/tools/catalysts.js +477 -0
  96. package/lib/mcp/tools/context.js +325 -0
  97. package/lib/mcp/tools/fleet.js +391 -0
  98. package/lib/mcp/tools/jobs-tools.js +240 -0
  99. package/lib/mcp/tools/operate.js +314 -0
  100. package/lib/preview/build-preview-config.js +136 -0
  101. package/lib/rate-limiter.js +11 -0
  102. package/lib/resolve-api-key.js +142 -0
  103. package/lib/storage/index.js +40 -0
  104. package/messages/de.json +2136 -0
  105. package/messages/en.json +2136 -0
  106. package/messages/es.json +2136 -0
  107. package/messages/fr.json +2136 -0
  108. package/messages/it.json +2136 -0
  109. package/messages/ja.json +2136 -0
  110. package/messages/ko.json +2136 -0
  111. package/messages/nl.json +2136 -0
  112. package/messages/pl.json +2136 -0
  113. package/messages/pt.json +2136 -0
  114. package/messages/ru.json +2136 -0
  115. package/messages/uk.json +2136 -0
  116. package/messages/zh.json +2136 -0
  117. package/package.json +68 -5
  118. package/scripts/mcp-config.mjs +162 -0
  119. package/scripts/mcp-stdio-loader.mjs +42 -0
  120. package/scripts/mcp-stdio.mjs +108 -0
  121. package/scripts/mojulo-paths.mjs +48 -0
@@ -0,0 +1,1527 @@
1
+ /**
2
+ * Builder Tool Executors for Inverted Flow
3
+ *
4
+ * Executes tool calls from Claude during the inverted builder flow.
5
+ * These tools enable Claude to:
6
+ * - Process documents into RAG summaries
7
+ * - Infer user intent and confidence
8
+ * - Recommend protocols based on context
9
+ * - Generate configurations for each protocol
10
+ * - Compose bot identity
11
+ *
12
+ * Philosophy: "Claude proposes, User disposes"
13
+ */
14
+
15
+ import { validateToolInput } from './tools.js';
16
+ import { BuilderSessionRepository, SESSION_STATUS } from '@/lib/db/repositories/builderSessions.js';
17
+ import { DocumentRepository } from '@/lib/db/repositories/documents.js';
18
+ import { ApiKeyRepository } from '@/lib/db/repositories/apiKeys.js';
19
+ import { decryptApiKey } from '@/lib/deployment-auth.js';
20
+ import { getDefaultModelForTask, getAllowedProtocolsForModel } from '@/lib/llm-providers.js';
21
+ import { saveBuilderConfig } from './executor.js';
22
+ import { buildArtifact } from '@/lib/deployers/build.js';
23
+
24
+ /**
25
+ * Get LLM configuration from session's preloaded context
26
+ * Uses builder config settings with fallback to provider auto-selection.
27
+ *
28
+ * The `task` parameter selects the per-provider model tier (reasoning /
29
+ * structured / summary). Callers pass the tier appropriate for their
30
+ * workload so the form generator and summary calls aren't billed at the
31
+ * reasoning-tier rate.
32
+ *
33
+ * @param {Object} session - Builder session with preloadedContext
34
+ * @param {string} userId - User ID for API key lookup
35
+ * @param {string} [task='reasoning'] - Task tier: reasoning | structured | summary
36
+ * @returns {Promise<{ provider: string, apiKey: string, model: string }>}
37
+ */
38
+ async function getLLMConfigFromSession(session, userId, task = 'reasoning') {
39
+ const { defaultProvider, defaultApiKeyId } = session.preloadedContext || {};
40
+
41
+ // Get API keys for user
42
+ const apiKeys = await ApiKeyRepository.findByUserId(userId);
43
+
44
+ let apiKeyRecord;
45
+
46
+ // First try: Use the specific API key ID from builder config
47
+ if (defaultApiKeyId) {
48
+ apiKeyRecord = apiKeys.find((k) => k.id === defaultApiKeyId);
49
+ }
50
+
51
+ // Second try: Find any key for the default provider
52
+ if (!apiKeyRecord && defaultProvider) {
53
+ apiKeyRecord = apiKeys.find((k) => k.provider === defaultProvider);
54
+ }
55
+
56
+ // Final fallback: cloud providers first, then ollama. Local-only inference
57
+ // sits last so a user who has both Anthropic and Ollama keys doesn't get
58
+ // silently routed to the slower lane — they pick Ollama by marking it
59
+ // default, not by accident.
60
+ if (!apiKeyRecord) {
61
+ const fallbackOrder = ['anthropic', 'bedrock', 'openai', 'ollama'];
62
+ for (const provider of fallbackOrder) {
63
+ apiKeyRecord = apiKeys.find((k) => k.provider === provider);
64
+ if (apiKeyRecord) break;
65
+ }
66
+ }
67
+
68
+ if (!apiKeyRecord) {
69
+ throw new Error('No API key available for LLM operations');
70
+ }
71
+
72
+ return {
73
+ provider: apiKeyRecord.provider,
74
+ apiKey: decryptApiKey(apiKeyRecord.encryptedKey),
75
+ model: getDefaultModelForTask(apiKeyRecord.provider, task),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Static prompt suggestions per intent (fallback when domainDigest not available)
81
+ */
82
+ const STATIC_PROMPT_SUGGESTIONS = {
83
+ support_bot: [
84
+ 'How do I get started?',
85
+ 'What are the pricing options?',
86
+ 'I need help with my account',
87
+ ],
88
+ lead_gen: [
89
+ 'Tell me about your services',
90
+ 'I want to get a quote',
91
+ 'Schedule a demo',
92
+ ],
93
+ appointment_scheduler: [
94
+ 'What times are available?',
95
+ 'Book a consultation',
96
+ 'Reschedule my appointment',
97
+ ],
98
+ knowledge_base: [
99
+ 'How does this work?',
100
+ 'What are the features?',
101
+ 'Show me the documentation',
102
+ ],
103
+ feedback_collector: [
104
+ 'I have a suggestion',
105
+ 'Report an issue',
106
+ 'Share my experience',
107
+ ],
108
+ onboarding_assistant: [
109
+ 'Show me around',
110
+ 'What can I do here?',
111
+ 'Help me set up',
112
+ ],
113
+ triage_router: [
114
+ 'I need to talk to sales',
115
+ 'Technical support please',
116
+ 'Connect me to billing',
117
+ ],
118
+ };
119
+
120
+ /**
121
+ * Get static prompts for an intent type
122
+ * @param {string} intent - Bot intent type
123
+ * @returns {string[]} Array of suggested prompts
124
+ */
125
+ function getStaticPromptsForIntent(intent) {
126
+ return STATIC_PROMPT_SUGGESTIONS[intent] || STATIC_PROMPT_SUGGESTIONS.support_bot;
127
+ }
128
+
129
+ /**
130
+ * Generate contextual firstMessage and objective from a domain digest using LLM
131
+ * @param {string} domainDigest - Per-document LLM-composed digest of the corpus
132
+ * @param {string} userMessage - Original user message describing what they want
133
+ * @param {string} intent - Bot intent type
134
+ * @param {string} organizationName - Organization name if available
135
+ * @param {Object} session - Builder session for LLM config lookup
136
+ * @param {string} userId - User ID for API key lookup
137
+ * @returns {Promise<{ firstMessage: string, objective: string } | null>}
138
+ */
139
+ async function generateContextualIdentity(domainDigest, userMessage, intent, organizationName, session, userId) {
140
+ // Get LLM config from session (supports Anthropic, Bedrock, etc.)
141
+ // Structured tier: response is a JSON object parsed via jsonMatch.
142
+ let llmConfig;
143
+ try {
144
+ llmConfig = await getLLMConfigFromSession(session, userId, 'structured');
145
+ } catch (err) {
146
+ console.log('[Builder] No API key available for identity generation:', err.message);
147
+ return null;
148
+ }
149
+
150
+ const { provider, apiKey, model } = llmConfig;
151
+ const { generateSummary } = await import('@/lib/llm-providers.js');
152
+
153
+ const intentLabel = intent.replace(/_/g, ' ');
154
+
155
+ const identityPrompt = `Generate a contextual bot identity based on the following:
156
+
157
+ USER'S REQUEST:
158
+ ${userMessage.substring(0, 500)}
159
+
160
+ DOCUMENT SUMMARY (knowledge the bot will have):
161
+ ${domainDigest.substring(0, 1500)}
162
+
163
+ BOT TYPE: ${intentLabel}
164
+ ORGANIZATION: ${organizationName || 'Not specified'}
165
+
166
+ Generate:
167
+ 1. **firstMessage**: A warm, specific greeting (1-2 sentences) that:
168
+ - Introduces what the bot can help with based on the actual document content
169
+ - Mentions specific topics/services from the documents (not generic)
170
+ - Feels welcoming and helpful
171
+ - Max 150 characters
172
+
173
+ 2. **objective**: A concise statement (1 sentence) describing the bot's purpose that:
174
+ - Is specific to the document content and user's request
175
+ - Mentions key capabilities based on the documents
176
+ - Max 200 characters
177
+
178
+ Return ONLY a JSON object with "firstMessage" and "objective" keys, no other text.
179
+ Example:
180
+ {"firstMessage": "Hi! I'm the Valley Dental assistant. I can help with appointment booking, insurance questions, or info about our services.", "objective": "Help visitors learn about dental services, pricing, insurance, and book appointments at Valley Dental."}`;
181
+
182
+ try {
183
+ const response = await generateSummary(
184
+ provider,
185
+ identityPrompt,
186
+ apiKey,
187
+ 'Generate contextual bot identity',
188
+ model
189
+ );
190
+
191
+ // Parse JSON object from response (handles markdown code blocks too)
192
+ const jsonMatch = response.match(/\{[\s\S]*?\}/);
193
+ if (!jsonMatch) {
194
+ console.warn('[Builder] No JSON object found in identity generation response');
195
+ return null;
196
+ }
197
+
198
+ const identity = JSON.parse(jsonMatch[0]);
199
+
200
+ // Validate required fields
201
+ if (!identity.firstMessage || !identity.objective) {
202
+ console.warn('[Builder] Missing required fields in identity generation');
203
+ return null;
204
+ }
205
+
206
+ // Clean and truncate
207
+ const result = {
208
+ firstMessage: identity.firstMessage.trim().substring(0, 200),
209
+ objective: identity.objective.trim().substring(0, 250),
210
+ };
211
+
212
+ console.log('[Builder] Generated contextual identity:', result);
213
+ return result;
214
+ } catch (parseError) {
215
+ console.warn('[Builder] Failed to parse identity generation response:', parseError.message);
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Extract user-stated bot settings from the prompt via LLM.
222
+ *
223
+ * Replaces the English-locked regex in extractPrepopulatedSettings — the LLM
224
+ * handles "llámalo Maverick" / "把它叫做小助手" / possessives / multi-turn
225
+ * mentions that the regex misses. Returns the same shape so compose_identity
226
+ * consumes it unchanged. The botName is slug-normalized here (same rules as
227
+ * the regex path) so downstream code doesn't have to know which extractor ran.
228
+ *
229
+ * Returns null when no API key is available (signal to caller to fall back to
230
+ * regex). Returns {} on parse/validation failure.
231
+ */
232
+ async function extractPrepopulatedSettingsLLM(userMessage, session, userId) {
233
+ let llmConfig;
234
+ try {
235
+ llmConfig = await getLLMConfigFromSession(session, userId, 'summary');
236
+ } catch (err) {
237
+ console.log('[Builder] No API key for prepopulated extraction:', err.message);
238
+ return null;
239
+ }
240
+
241
+ const { provider, apiKey, model } = llmConfig;
242
+ const { generateSummary } = await import('@/lib/llm-providers.js');
243
+
244
+ const prompt = `Extract user-specified settings from this bot-building request. The user may write in any language (English, Spanish, Chinese, Polish, Arabic, etc.).
245
+
246
+ USER REQUEST:
247
+ ${userMessage.substring(0, 1000)}
248
+
249
+ Extract these fields IF — and only if — the user explicitly states them. Do not invent or infer.
250
+
251
+ - displayName: The proper-noun name the user gave the bot (e.g. "Maverick", "小助手", "Pelusa"). Preserve original script and capitalization. Omit if not stated.
252
+ - resourceName: The organization/company/brand the bot is for (e.g. "Acme Dental", "Valley Coffee"). Omit if not stated.
253
+ - firstMessage: An exact greeting/welcome message the user dictated in quotes. Omit if not stated.
254
+ - objective: An exact purpose/goal the user dictated in quotes. Omit if not stated.
255
+
256
+ Return ONLY a JSON object with whichever fields are present, no other text. Empty object {} if nothing was stated.
257
+
258
+ Examples:
259
+ - "build me a bot called Maverick for Acme Dental" → {"displayName": "Maverick", "resourceName": "Acme Dental"}
260
+ - "llámalo Sparky" → {"displayName": "Sparky"}
261
+ - "把这个机器人叫做小助手,给阳光咖啡馆用的" → {"displayName": "小助手", "resourceName": "阳光咖啡馆"}
262
+ - "I need a support bot" → {}`;
263
+
264
+ try {
265
+ const response = await generateSummary(
266
+ provider,
267
+ prompt,
268
+ apiKey,
269
+ 'Extract bot settings from user request',
270
+ model
271
+ );
272
+
273
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
274
+ if (!jsonMatch) return {};
275
+
276
+ const extracted = JSON.parse(jsonMatch[0]);
277
+ const settings = {};
278
+
279
+ if (typeof extracted.displayName === 'string' && extracted.displayName.trim()) {
280
+ const displayName = extracted.displayName.trim().substring(0, 40);
281
+ settings.displayName = displayName;
282
+ // Slug for botName: collapse to a-z0-9-, max 30. For non-ASCII names
283
+ // the slug ends up empty — fall back to a transliteration-free hash so
284
+ // the bot still has a stable id while displayName carries the original.
285
+ const slug = displayName
286
+ .toLowerCase()
287
+ .replace(/[^a-z0-9\s-]/g, '')
288
+ .replace(/\s+/g, '-')
289
+ .replace(/-+/g, '-')
290
+ .replace(/^-|-$/g, '')
291
+ .substring(0, 30);
292
+ if (slug) {
293
+ settings.botName = slug;
294
+ }
295
+ // No slug fallback: leaving botName unset lets compose_identity generate
296
+ // an org-derived slug, which is more useful than a hash to the operator.
297
+ }
298
+
299
+ if (typeof extracted.resourceName === 'string' && extracted.resourceName.trim()) {
300
+ settings.resourceName = extracted.resourceName.trim().substring(0, 60);
301
+ }
302
+
303
+ if (typeof extracted.firstMessage === 'string' && extracted.firstMessage.trim()) {
304
+ settings.firstMessage = extracted.firstMessage.trim().substring(0, 200);
305
+ }
306
+
307
+ if (typeof extracted.objective === 'string' && extracted.objective.trim()) {
308
+ settings.objective = extracted.objective.trim().substring(0, 250);
309
+ }
310
+
311
+ console.log('[Builder] LLM-extracted prepopulated settings:', settings);
312
+ return settings;
313
+ } catch (err) {
314
+ console.warn('[Builder] Failed to LLM-extract prepopulated settings:', err.message);
315
+ return {};
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Execute a modular tool call
321
+ * @param {string} toolName - Name of the tool
322
+ * @param {Object} input - Tool input
323
+ * @param {Object} context - Execution context (session, user, etc.)
324
+ * @returns {Promise<{ success: boolean, result?: any, error?: string }>}
325
+ */
326
+ export async function executeBuilderTool(toolName, input, context) {
327
+ const { session, userId } = context;
328
+
329
+ // Validate input
330
+ const validation = validateToolInput(toolName, input);
331
+ if (!validation.valid) {
332
+ return {
333
+ success: false,
334
+ error: `Invalid input: ${validation.error}`,
335
+ };
336
+ }
337
+
338
+ try {
339
+ const handler = builderToolHandlers[toolName];
340
+ if (!handler) {
341
+ return {
342
+ success: false,
343
+ error: `Unknown modular tool: ${toolName}`,
344
+ };
345
+ }
346
+
347
+ const result = await handler(input, context);
348
+ return { success: true, result };
349
+ } catch (error) {
350
+ console.error(`[Builder] Tool execution error (${toolName}):`, error);
351
+ return {
352
+ success: false,
353
+ error: error.message || 'Tool execution failed',
354
+ };
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Extract prepopulated settings from user message
360
+ * Detects patterns like "called X", "named X", "bot name X", "resource X"
361
+ * @param {string} userMessage - User's message
362
+ * @returns {Object} Extracted settings (botName, resourceName, displayName, etc.)
363
+ */
364
+ function extractPrepopulatedSettings(userMessage) {
365
+ const settings = {};
366
+
367
+ // Patterns for bot name detection
368
+ // Matches: "called X", "named X", "bot name X", "name it X", "call it X"
369
+ const botNamePatterns = [
370
+ /(?:called|named|name it|call it)\s+["']?([a-zA-Z0-9][\w\s-]{0,30}[a-zA-Z0-9])["']?(?:\s|$|,|\.)/i,
371
+ /bot\s+(?:name|called|named)\s+["']?([a-zA-Z0-9][\w\s-]{0,30}[a-zA-Z0-9])["']?(?:\s|$|,|\.)/i,
372
+ /["']([a-zA-Z0-9][\w\s-]{0,30}[a-zA-Z0-9])["']\s+(?:bot|assistant)/i,
373
+ ];
374
+
375
+ // Patterns for resource/company name detection
376
+ // Matches: "for X", "resource X", "company X", "organization X", "business X"
377
+ const resourceNamePatterns = [
378
+ /(?:for|resource|company|organization|business|brand)\s+(?:name\s+)?["']?([a-zA-Z0-9][\w\s&.-]{0,40}[a-zA-Z0-9])["']?(?:\s|$|,|\.)/i,
379
+ /["']([a-zA-Z0-9][\w\s&.-]{0,40}[a-zA-Z0-9])["']\s+(?:company|organization|business|brand)/i,
380
+ ];
381
+
382
+ // Patterns for greeting/first message detection
383
+ const greetingPatterns = [
384
+ /(?:greeting|first message|welcome message|start with)\s*[:\s]+["'](.{5,150})["']/i,
385
+ /greet(?:ing)?\s+(?:should be|as)\s+["'](.{5,150})["']/i,
386
+ ];
387
+
388
+ // Patterns for objective/purpose detection
389
+ const objectivePatterns = [
390
+ /(?:objective|purpose|goal)\s*[:\s]+["'](.{10,200})["']/i,
391
+ /(?:should|will)\s+(?:help|assist)\s+(?:users?\s+)?(?:with\s+)?(.{10,150})/i,
392
+ ];
393
+
394
+ // Try to extract bot name
395
+ for (const pattern of botNamePatterns) {
396
+ const match = userMessage.match(pattern);
397
+ if (match && match[1]) {
398
+ const extracted = match[1].trim();
399
+ // Sanitize for use as bot name (slug format)
400
+ settings.botName = extracted
401
+ .toLowerCase()
402
+ .replace(/[^a-z0-9\s-]/g, '')
403
+ .replace(/\s+/g, '-')
404
+ .replace(/-+/g, '-')
405
+ .substring(0, 30);
406
+ // Also store the display-friendly version
407
+ settings.displayName = extracted;
408
+ break;
409
+ }
410
+ }
411
+
412
+ // Try to extract resource/organization name
413
+ for (const pattern of resourceNamePatterns) {
414
+ const match = userMessage.match(pattern);
415
+ if (match && match[1]) {
416
+ const extracted = match[1].trim();
417
+ // Avoid matching common words that aren't company names
418
+ const skipWords = ['my', 'the', 'a', 'an', 'this', 'that', 'our', 'their', 'your'];
419
+ if (!skipWords.includes(extracted.toLowerCase())) {
420
+ settings.resourceName = extracted;
421
+ break;
422
+ }
423
+ }
424
+ }
425
+
426
+ // Try to extract custom greeting
427
+ for (const pattern of greetingPatterns) {
428
+ const match = userMessage.match(pattern);
429
+ if (match && match[1]) {
430
+ settings.firstMessage = match[1].trim();
431
+ break;
432
+ }
433
+ }
434
+
435
+ // Try to extract objective
436
+ for (const pattern of objectivePatterns) {
437
+ const match = userMessage.match(pattern);
438
+ if (match && match[1]) {
439
+ settings.objective = match[1].trim();
440
+ break;
441
+ }
442
+ }
443
+
444
+ return settings;
445
+ }
446
+
447
+ /**
448
+ * Embed a batch of {text, metadata} chunks locally and persist into the
449
+ * session's embeddings blob. If the blob already exists (e.g. knowledge docs
450
+ * were embedded earlier in the same session), append; otherwise create.
451
+ *
452
+ * The single-blob shape lets one cosine search return the most relevant chunk
453
+ * regardless of whether it came from a document or a triage route — the LLM
454
+ * uses metadata.source at the formatting layer to decide what to do with it.
455
+ */
456
+ async function embedAndPersistChunks(chunks, session) {
457
+ const { downloadToBuffer, uploadFile, deleteFile } = await import('@/lib/storage/index.js');
458
+ const { generateEmbeddings, LOCAL_EMBEDDING_MODEL } = await import('@/lib/embedder/local.js');
459
+
460
+ if (!chunks || chunks.length === 0) {
461
+ throw new Error('embedAndPersistChunks: chunks must be a non-empty array');
462
+ }
463
+
464
+ const storageKey = `embeddings/${session.id}.json`;
465
+
466
+ let existingChunks = [];
467
+ try {
468
+ const existing = await downloadToBuffer(storageKey);
469
+ if (existing) {
470
+ const parsed = JSON.parse(existing.toString('utf8'));
471
+ if (Array.isArray(parsed.chunks)) existingChunks = parsed.chunks;
472
+ }
473
+ } catch {
474
+ // First write or unreadable prior blob — start fresh.
475
+ }
476
+
477
+ let embeddings;
478
+ try {
479
+ embeddings = await generateEmbeddings(
480
+ chunks.map((c) => c.text),
481
+ { inputType: 'search_document' }
482
+ );
483
+ } catch (err) {
484
+ if (existingChunks.length === 0) {
485
+ await deleteFile(storageKey).catch(() => {});
486
+ }
487
+ throw new Error(`Local embedding failed: ${err.message}`);
488
+ }
489
+
490
+ if (embeddings.length !== chunks.length) {
491
+ if (existingChunks.length === 0) {
492
+ await deleteFile(storageKey).catch(() => {});
493
+ }
494
+ throw new Error(
495
+ `Embedder returned ${embeddings.length} vectors for ${chunks.length} chunks`
496
+ );
497
+ }
498
+
499
+ const newChunks = chunks.map((c, i) => ({
500
+ text: c.text,
501
+ embedding: embeddings[i],
502
+ metadata: c.metadata,
503
+ }));
504
+
505
+ const merged = [...existingChunks, ...newChunks];
506
+ const payload = {
507
+ model: LOCAL_EMBEDDING_MODEL,
508
+ chunkCount: merged.length,
509
+ createdAt: new Date().toISOString(),
510
+ chunks: merged,
511
+ };
512
+
513
+ await uploadFile(storageKey, Buffer.from(JSON.stringify(payload), 'utf8'));
514
+
515
+ return { storageKey, chunkCount: merged.length, model: LOCAL_EMBEDDING_MODEL };
516
+ }
517
+
518
+ /**
519
+ * Vector branch of process_documents: parse → chunk → embed locally via
520
+ * @huggingface/transformers (multilingual-e5-small) → persist a single JSON
521
+ * blob to the factory's filesystem storage. The resulting storageKey is
522
+ * stashed on the session so save_modular_bot can copy it onto the
523
+ * deployment row and the build pipeline can stream it into the artifact's
524
+ * config/embeddings.json.
525
+ *
526
+ * Embed failures: wipe the partial blob, surface the error. No silent
527
+ * partial state.
528
+ */
529
+ async function processDocumentsVector(documents, documentIds, session, userId) {
530
+ const { downloadToBuffer } = await import('@/lib/storage/index.js');
531
+ const { parseDocument } = await import('@/lib/document-parser.js');
532
+ const { chunkDocuments } = await import('@/lib/embedder/chunker.js');
533
+ const { LOCAL_EMBEDDING_MODEL } = await import('@/lib/embedder/local.js');
534
+
535
+ // Parse all documents. Prefer the row's cached parsed_text (every upload
536
+ // path — web, chat builder, MCP — populates it at ingest time). Falling
537
+ // back to download+re-parse covers any legacy row whose parsed_text was
538
+ // never set, and is the only path that can recover a doc whose buffer
539
+ // changed out-of-band.
540
+ const parsed = [];
541
+ for (const doc of documents) {
542
+ try {
543
+ let text = doc.parsedText;
544
+ if (!text || text.trim().length === 0) {
545
+ const buffer = await downloadToBuffer(doc.storagePath);
546
+ text = await parseDocument(buffer, doc.originalName);
547
+ }
548
+ if (text && text.trim().length > 0) {
549
+ parsed.push({ id: doc.id, originalName: doc.originalName, text });
550
+ }
551
+ } catch (err) {
552
+ console.error(`[Builder] Failed to parse ${doc.originalName}:`, err.message);
553
+ }
554
+ }
555
+ if (parsed.length === 0) {
556
+ throw new Error('Vector embedding: no documents parseable');
557
+ }
558
+
559
+ // Chunk.
560
+ const chunks = chunkDocuments(parsed);
561
+ if (chunks.length === 0) {
562
+ throw new Error('Vector embedding: no chunks produced from documents');
563
+ }
564
+
565
+ console.log(
566
+ `[Builder] Vector embedding ${chunks.length} chunks across ${parsed.length} docs locally (${LOCAL_EMBEDDING_MODEL})`
567
+ );
568
+
569
+ const { storageKey, chunkCount } = await embedAndPersistChunks(chunks, session);
570
+
571
+ // Compose a domain digest for build-time tools (compose_identity,
572
+ // infer_appointment_types, generate_suggested_prompts). Per-document LLM
573
+ // summary, then concatenate. Not consumed at runtime — the bundled
574
+ // embedding model handles retrieval — only used by the builder pipeline.
575
+ // Falls back to a chunk-slice surrogate if every summary call fails so
576
+ // the build can still progress.
577
+ const { generateSummary } = await import('@/lib/llm-providers.js');
578
+ // Summary tier: free-text per-document summarization for the domain digest.
579
+ const llmConfig = await getLLMConfigFromSession(session, userId, 'summary');
580
+ const { provider, apiKey, model } = llmConfig;
581
+
582
+ const summaryPrompt = `Analyze this document and provide a comprehensive summary that:
583
+
584
+ 1. Identifies key terms, concepts, and topics covered
585
+ 2. Highlights the main themes and subject areas
586
+ 3. Lists important entities, processes, or procedures mentioned
587
+ 4. Notes any technical specifications, data, or metrics
588
+
589
+ IMPORTANT: Generate the summary in the SAME LANGUAGE as the original document.
590
+
591
+ Synthesize the information into max 3 paragraphs, 200 words.
592
+
593
+ Keep the summary high-level, factual, and cohesive.`;
594
+
595
+ const individualSummaries = [];
596
+ for (const doc of parsed) {
597
+ try {
598
+ const docSummary = await generateSummary(provider, doc.text, apiKey, summaryPrompt, model);
599
+ individualSummaries.push({ name: doc.originalName, summary: docSummary });
600
+ } catch (err) {
601
+ console.error(`[Builder] Failed to summarize ${doc.originalName}:`, err.message);
602
+ individualSummaries.push({ name: doc.originalName, summary: `[Error: ${err.message}]` });
603
+ }
604
+ }
605
+
606
+ const combinedSummary = individualSummaries
607
+ .filter((s) => !s.summary.startsWith('[Error'))
608
+ .map((s) => `## ${s.name}\n\n${s.summary}`)
609
+ .join('\n\n---\n\n');
610
+
611
+ const domainDigest =
612
+ combinedSummary ||
613
+ chunks
614
+ .slice(0, 12)
615
+ .map((c) => c.text)
616
+ .join('\n')
617
+ .slice(0, 4000);
618
+
619
+ await BuilderSessionRepository.updateGeneratedConfig(session.id, userId, 'knowledge', {
620
+ domainDigest,
621
+ documentIds,
622
+ documentsProcessed: parsed.length,
623
+ totalDocuments: documents.length,
624
+ ragMode: 'vector',
625
+ });
626
+
627
+ await BuilderSessionRepository.updateGeneratedConfig(
628
+ session.id,
629
+ userId,
630
+ 'embeddings',
631
+ {
632
+ storageKey,
633
+ model: LOCAL_EMBEDDING_MODEL,
634
+ chunkCount,
635
+ }
636
+ );
637
+
638
+ return {
639
+ ragMode: 'vector',
640
+ documentsProcessed: parsed.length,
641
+ totalDocuments: documents.length,
642
+ chunkCount,
643
+ embeddingModel: LOCAL_EMBEDDING_MODEL,
644
+ storageKey,
645
+ message: `Embedded ${chunks.length} chunks from ${parsed.length} documents using ${LOCAL_EMBEDDING_MODEL} (total ${chunkCount} chunks in store).`,
646
+ };
647
+ }
648
+
649
+ /**
650
+ * Tool handlers for inverted modular flow
651
+ */
652
+ const builderToolHandlers = {
653
+ /**
654
+ * Parse uploaded documents, embed them locally via the bundled
655
+ * multilingual-e5-small ONNX model, and stash the embedding blob on the
656
+ * session so save_modular_bot can copy it onto the deployment row. Also
657
+ * generates a build-time `domainDigest` on the session that's consumed by
658
+ * compose_identity and other downstream tools.
659
+ */
660
+ async process_documents(input, context) {
661
+ const { documentIds } = input;
662
+ const { session, userId } = context;
663
+
664
+ if (!documentIds || documentIds.length === 0) {
665
+ throw new Error('No document IDs provided');
666
+ }
667
+
668
+ const documents = await DocumentRepository.findByIds(documentIds);
669
+ if (documents.length === 0) {
670
+ throw new Error('No documents found with the provided IDs');
671
+ }
672
+
673
+ console.log(`[Builder] Processing ${documents.length} documents (vector mode)`);
674
+ return processDocumentsVector(documents, documentIds, session, userId);
675
+ },
676
+
677
+ /**
678
+ * Infer user intent from message and context
679
+ */
680
+ async infer_intent(input, context) {
681
+ const { userMessage, domainDigest } = input;
682
+ const { session, userId } = context;
683
+
684
+ // Intent classification based on keywords and context
685
+ // Note: 'faq' maps to knowledge_base (Q&A from documents), not support_bot
686
+ const intentPatterns = [
687
+ { intent: 'knowledge_base', keywords: ['faq', 'knowledge', 'documentation', 'docs', 'wiki', 'information', 'answer questions', 'q&a'], confidence: 0.9 },
688
+ { intent: 'support_bot', keywords: ['support', 'help desk', 'customer service', 'ticket', 'assist', 'troubleshoot'], confidence: 0.88 },
689
+ { intent: 'lead_gen', keywords: ['lead', 'capture', 'collect', 'form', 'contact', 'inquiry', 'sales'], confidence: 0.88 },
690
+ { intent: 'appointment_scheduler', keywords: ['appointment', 'booking', 'schedule', 'calendar', 'book', 'meeting'], confidence: 0.92 },
691
+ { intent: 'feedback_collector', keywords: ['feedback', 'survey', 'review', 'rating', 'opinion'], confidence: 0.87 },
692
+ { intent: 'onboarding_assistant', keywords: ['onboard', 'welcome', 'getting started', 'new user', 'tutorial'], confidence: 0.86 },
693
+ { intent: 'triage_router', keywords: ['triage', 'route', 'routing', 'redirect', 'transfer', 'dispatch', 'multi-bot', 'orchestrat'], confidence: 0.91 },
694
+ ];
695
+
696
+ const messageLower = userMessage.toLowerCase();
697
+ const summaryLower = (domainDigest || '').toLowerCase();
698
+ const combined = `${messageLower} ${summaryLower}`;
699
+
700
+ let bestMatch = { intent: 'support_bot', confidence: 0.7, reason: 'Default intent for general assistance' };
701
+
702
+ for (const pattern of intentPatterns) {
703
+ const matchCount = pattern.keywords.filter(kw => combined.includes(kw)).length;
704
+ if (matchCount > 0) {
705
+ const adjustedConfidence = Math.min(pattern.confidence + (matchCount * 0.02), 0.98);
706
+ if (adjustedConfidence > bestMatch.confidence) {
707
+ bestMatch = {
708
+ intent: pattern.intent,
709
+ confidence: adjustedConfidence,
710
+ reason: `Detected keywords: ${pattern.keywords.filter(kw => combined.includes(kw)).join(', ')}`,
711
+ };
712
+ }
713
+ }
714
+ }
715
+
716
+ // Extract prepopulated settings from user message.
717
+ // LLM extractor handles any language; regex is the fallback when no API
718
+ // key is configured (returns null) or when the LLM yields nothing useful.
719
+ const llmSettings = await extractPrepopulatedSettingsLLM(userMessage, session, userId);
720
+ const prepopulatedSettings = (llmSettings && Object.keys(llmSettings).length > 0)
721
+ ? llmSettings
722
+ : extractPrepopulatedSettings(userMessage);
723
+
724
+ // Update session with inference and prepopulated settings
725
+ await BuilderSessionRepository.updateInference(session.id, userId, {
726
+ intent: bestMatch.intent,
727
+ confidence: bestMatch.confidence,
728
+ recommendedProtocols: {}, // Will be filled by recommend_protocols
729
+ });
730
+
731
+ // Store prepopulated settings in generatedConfigs for use by compose_identity
732
+ if (Object.keys(prepopulatedSettings).length > 0) {
733
+ await BuilderSessionRepository.updateGeneratedConfig(session.id, userId, 'prepopulated', prepopulatedSettings);
734
+ }
735
+
736
+ return {
737
+ intent: bestMatch.intent,
738
+ confidence: bestMatch.confidence,
739
+ reason: bestMatch.reason,
740
+ prepopulatedSettings: Object.keys(prepopulatedSettings).length > 0 ? prepopulatedSettings : undefined,
741
+ };
742
+ },
743
+
744
+ /**
745
+ * Recommend protocols based on inferred intent
746
+ */
747
+ async recommend_protocols(input, context) {
748
+ const { intent, domainDigest, userMessage } = input;
749
+ const { session, userId } = context;
750
+
751
+ const recommendations = {
752
+ knowledge: {
753
+ enabled: false,
754
+ reason: 'No document context detected',
755
+ presetConfig: null,
756
+ },
757
+ forms: {
758
+ enabled: false,
759
+ reason: 'No data collection intent detected',
760
+ presetConfig: null,
761
+ },
762
+ appointments: {
763
+ enabled: false,
764
+ reason: 'No booking/scheduling intent detected',
765
+ presetConfig: null,
766
+ },
767
+ triage: {
768
+ enabled: false,
769
+ reason: 'No routing intent detected',
770
+ presetConfig: null,
771
+ },
772
+ };
773
+
774
+ // Knowledge protocol
775
+ if (domainDigest || session.generatedConfigs?.knowledge?.domainDigest) {
776
+ recommendations.knowledge.enabled = true;
777
+ recommendations.knowledge.reason = 'Documents uploaded - knowledge base enabled';
778
+ recommendations.knowledge.presetConfig = {
779
+ domainDigest: domainDigest || session.generatedConfigs?.knowledge?.domainDigest,
780
+ documentIds: session.generatedConfigs?.knowledge?.documentIds || [],
781
+ };
782
+ }
783
+
784
+ // Forms protocol - only enable when user explicitly requests data collection
785
+ // Check for explicit collection keywords in the user's message
786
+ const messageLower = (userMessage || '').toLowerCase();
787
+ const explicitFormKeywords = [
788
+ 'collect', 'capture', 'gather', 'form', 'submit', 'input',
789
+ 'contact info', 'email address', 'phone number', 'sign up',
790
+ 'registration', 'lead', 'inquiry', 'feedback', 'survey'
791
+ ];
792
+ const hasExplicitFormRequest = explicitFormKeywords.some(kw => messageLower.includes(kw));
793
+
794
+ if (hasExplicitFormRequest) {
795
+ recommendations.forms.enabled = true;
796
+ recommendations.forms.reason = 'User requested data collection';
797
+ } else if (intent === 'lead_gen' || intent === 'feedback_collector') {
798
+ // These intents inherently require forms
799
+ recommendations.forms.enabled = true;
800
+ recommendations.forms.reason = `${intent} requires data collection`;
801
+ }
802
+
803
+ // Appointments protocol
804
+ if (intent === 'appointment_scheduler') {
805
+ recommendations.appointments.enabled = true;
806
+ recommendations.appointments.reason = 'Scheduling intent detected';
807
+ // Disable forms for pure appointment bots
808
+ recommendations.forms.enabled = false;
809
+ recommendations.forms.reason = 'Not needed for appointment-only flow';
810
+ }
811
+
812
+ // Triage protocol - detect routing/orchestration intent
813
+ const triageKeywords = [
814
+ 'triage', 'route', 'routing', 'redirect', 'transfer',
815
+ 'multi-bot', 'orchestrat', 'dispatch', 'forward',
816
+ 'different team', 'right department', 'connect to'
817
+ ];
818
+ const hasTriageRequest = triageKeywords.some(kw => messageLower.includes(kw));
819
+
820
+ // Also check if triage routes are already configured in the session
821
+ const hasExistingTriageRoutes = session.generatedConfigs?.triage?.routes?.length > 0;
822
+
823
+ if (hasTriageRequest || hasExistingTriageRoutes) {
824
+ recommendations.triage.enabled = true;
825
+ recommendations.triage.reason = hasExistingTriageRoutes
826
+ ? 'Triage routes configured'
827
+ : 'Routing intent detected';
828
+ if (hasExistingTriageRoutes) {
829
+ recommendations.triage.presetConfig = {
830
+ routes: session.generatedConfigs.triage.routes,
831
+ };
832
+ }
833
+ }
834
+
835
+ // Model-level gate: small Ollama models (qwen3, mistral-nemo) are only
836
+ // reliable at single-turn knowledge Q&A. Force-disable the stateful
837
+ // protocols regardless of what the heuristics above suggested. The
838
+ // wizard's enabledProtocols uses `formGathering`; this tool's shape uses
839
+ // `forms` — map across the boundary. The bot being built inherits the
840
+ // builder's default provider/model, so gating on preloadedContext is
841
+ // correct here.
842
+ const { defaultProvider, defaultModel } = session.preloadedContext || {};
843
+ const allowedForModel = getAllowedProtocolsForModel(defaultProvider, defaultModel);
844
+ if (allowedForModel) {
845
+ const toolKeyToWizardKey = {
846
+ knowledge: 'knowledge',
847
+ forms: 'formGathering',
848
+ appointments: 'appointments',
849
+ triage: 'triage',
850
+ };
851
+ const gateReason = `${defaultModel} only supports the knowledge protocol — switch to llama3.3 for multi-step flows.`;
852
+ for (const [toolKey, wizardKey] of Object.entries(toolKeyToWizardKey)) {
853
+ if (!allowedForModel.has(wizardKey) && recommendations[toolKey]) {
854
+ recommendations[toolKey].enabled = false;
855
+ recommendations[toolKey].reason = gateReason;
856
+ recommendations[toolKey].presetConfig = null;
857
+ }
858
+ }
859
+ }
860
+
861
+ // Update session with recommendations
862
+ await BuilderSessionRepository.updateInference(session.id, userId, {
863
+ intent,
864
+ confidence: session.intentConfidence || 0.85,
865
+ recommendedProtocols: recommendations,
866
+ });
867
+
868
+ return {
869
+ protocols: recommendations,
870
+ summary: Object.entries(recommendations)
871
+ .filter(([_, v]) => v.enabled)
872
+ .map(([k, _]) => k)
873
+ .join(', ') || 'None recommended',
874
+ };
875
+ },
876
+
877
+ /**
878
+ * Generate form schema based on context
879
+ */
880
+ async generate_form_schema(input, context) {
881
+ const { description, formType = 'custom', locale = 'en', afterSubmitChatMessage } = input;
882
+ const { session, userId } = context;
883
+
884
+ // Get LLM config from session (supports Anthropic, Bedrock, etc.)
885
+ // Structured tier: model returns a JSON schema parsed downstream.
886
+ const llmConfig = await getLLMConfigFromSession(session, userId, 'structured');
887
+ const { provider, apiKey, model } = llmConfig;
888
+
889
+ const { generateSummary } = await import('@/lib/llm-providers.js');
890
+ const { buildFormSchemaPrompt, isLocaleSupported, DEFAULT_LOCALE } = await import('@/lib/form-schema-config/index.js');
891
+
892
+ const resolvedLocale = isLocaleSupported(locale) ? locale : DEFAULT_LOCALE;
893
+
894
+ const basePrompt = `You are a form structure generator. Convert the description into a JSON form schema.
895
+
896
+ OUTPUT FORMAT: Return ONLY valid JSON matching this structure:
897
+ {
898
+ "sections": [
899
+ {
900
+ "id": "section-id",
901
+ "label": "Section Label",
902
+ "fields": [
903
+ {
904
+ "id": "fieldId",
905
+ "label": "Field Label",
906
+ "type": "text|email|tel|number|select|date|textarea|checkbox|radio",
907
+ "required": true|false,
908
+ "placeholder": "optional placeholder",
909
+ "options": ["for select/radio types"]
910
+ }
911
+ ]
912
+ }
913
+ ],
914
+ "afterSubmitMessage": "A contextual thank you message shown after form submission"
915
+ }
916
+
917
+ Keep forms concise - 4-8 fields maximum. Group related fields into sections.
918
+ The afterSubmitMessage should be friendly, contextual to the form purpose, and in the appropriate language for the locale.`;
919
+
920
+ const localePrompt = buildFormSchemaPrompt(resolvedLocale);
921
+ const fullPrompt = `${basePrompt}\n\n${localePrompt}`;
922
+
923
+ const response = await generateSummary(provider, description, apiKey, fullPrompt, model);
924
+
925
+ // Parse JSON response
926
+ let formSchema;
927
+ try {
928
+ const jsonMatch = response.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/);
929
+ const jsonString = jsonMatch ? jsonMatch[1] : response;
930
+ formSchema = JSON.parse(jsonString.trim());
931
+ } catch (parseError) {
932
+ console.error('[Builder] Failed to parse form schema:', parseError);
933
+ throw new Error('Failed to generate form structure');
934
+ }
935
+
936
+ // Validate structure
937
+ if (!Array.isArray(formSchema.sections) || formSchema.sections.length === 0) {
938
+ throw new Error('Generated form structure is invalid');
939
+ }
940
+
941
+ const fieldCount = formSchema.sections.reduce((acc, s) => acc + (s.fields?.length || 0), 0);
942
+
943
+ // Use provided afterSubmitChatMessage, or generated one from schema, or default
944
+ const resolvedAfterSubmitMessage = afterSubmitChatMessage
945
+ || formSchema.afterSubmitMessage
946
+ || 'Thank you for your submission! How can I help you further?';
947
+
948
+ // Remove afterSubmitMessage from schema (it's stored separately)
949
+ delete formSchema.afterSubmitMessage;
950
+
951
+ // Store in session - formSendHome defaults to true (send submissions to control plane)
952
+ // Save the original description as formStructureInput so it persists for edit mode
953
+ await BuilderSessionRepository.updateGeneratedConfig(session.id, userId, 'forms', {
954
+ formSchema,
955
+ formStructureInput: description,
956
+ fieldCount,
957
+ sectionCount: formSchema.sections.length,
958
+ formSendHome: true,
959
+ afterSubmitChatMessage: resolvedAfterSubmitMessage,
960
+ });
961
+
962
+ return {
963
+ formSchema,
964
+ fieldCount,
965
+ sectionCount: formSchema.sections.length,
966
+ formSendHome: true,
967
+ afterSubmitChatMessage: resolvedAfterSubmitMessage,
968
+ message: `Created form with ${fieldCount} fields`,
969
+ };
970
+ },
971
+
972
+ /**
973
+ * Generate appointment configuration
974
+ */
975
+ async generate_appointment_config(input, context) {
976
+ let { domainDigest, businessType, calendarProviders = [] } = input;
977
+ const { session, userId } = context;
978
+ // Same pattern as compose_identity / recommend_protocols: schema field is
979
+ // documentation, session is the source of truth.
980
+ domainDigest = domainDigest || session.generatedConfigs?.knowledge?.domainDigest;
981
+
982
+ // Generate basic appointment config structure
983
+ const config = {
984
+ destinations: [],
985
+ defaultDuration: 30,
986
+ bufferTime: 15,
987
+ maxAdvanceBooking: 30, // days
988
+ };
989
+
990
+ // If domainDigest contains service info, try to extract appointment types
991
+ if (domainDigest) {
992
+ // Basic extraction - could be enhanced with LLM
993
+ const serviceKeywords = ['consultation', 'meeting', 'session', 'appointment', 'call'];
994
+ for (const keyword of serviceKeywords) {
995
+ if (domainDigest.toLowerCase().includes(keyword)) {
996
+ config.destinations.push({
997
+ id: `${keyword}-${Date.now()}`,
998
+ provider: calendarProviders[0] || 'cal.com',
999
+ description: `${businessType || 'General'} ${keyword}`,
1000
+ duration: 30,
1001
+ });
1002
+ break; // Just add one default for now
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ // Store in session
1008
+ await BuilderSessionRepository.updateGeneratedConfig(session.id, userId, 'appointments', config);
1009
+
1010
+ return {
1011
+ config,
1012
+ message: config.destinations.length > 0
1013
+ ? `Generated ${config.destinations.length} appointment type(s)`
1014
+ : 'Appointment configuration ready - destinations need to be configured',
1015
+ };
1016
+ },
1017
+
1018
+ /**
1019
+ * Generate triage routing configuration
1020
+ */
1021
+ async generate_triage_config(input, context) {
1022
+ const { routes } = input;
1023
+ const { session, userId } = context;
1024
+
1025
+ if (!routes || routes.length === 0) {
1026
+ throw new Error('No triage routes provided');
1027
+ }
1028
+
1029
+ // Slugify helper for generating deployment IDs from names
1030
+ const slugify = (name) =>
1031
+ name
1032
+ .toLowerCase()
1033
+ .trim()
1034
+ .replace(/[^a-z0-9]+/g, '-')
1035
+ .replace(/^-|-$/g, '');
1036
+
1037
+ // Process and validate routes
1038
+ const processedRoutes = routes.map((route) => {
1039
+ if (!route.name || !route.description || !route.url) {
1040
+ throw new Error(`Invalid route: name, description, and url are required. Got: ${JSON.stringify(route)}`);
1041
+ }
1042
+
1043
+ return {
1044
+ deploymentId: route.deploymentId || slugify(route.name),
1045
+ name: route.name.trim(),
1046
+ description: route.description.trim(),
1047
+ url: route.url.trim(),
1048
+ };
1049
+ });
1050
+
1051
+ // Check for duplicate deploymentIds
1052
+ const deploymentIds = processedRoutes.map((r) => r.deploymentId);
1053
+ const duplicates = deploymentIds.filter((id, index) => deploymentIds.indexOf(id) !== index);
1054
+ if (duplicates.length > 0) {
1055
+ throw new Error(`Duplicate deployment IDs detected: ${[...new Set(duplicates)].join(', ')}`);
1056
+ }
1057
+
1058
+ console.log(`[Builder] Generated triage config with ${processedRoutes.length} routes`);
1059
+
1060
+ // Store in session's generated configs
1061
+ await BuilderSessionRepository.updateGeneratedConfig(session.id, userId, 'triage', {
1062
+ routes: processedRoutes,
1063
+ routeCount: processedRoutes.length,
1064
+ });
1065
+
1066
+ // Embed each route's description into the same cosine index that knowledge
1067
+ // chunks use. The retrieval signal here is intent-match: when a user
1068
+ // describes what they want, the LLM gets the matching route description
1069
+ // pulled into context, reinforcing the JSON-list lookup it would do
1070
+ // anyway. The deploymentId itself is authoritative on the JSON list — the
1071
+ // embedding is contextual reinforcement, not the source of routing IDs.
1072
+ const { chunkTriageRoutes } = await import('@/lib/embedder/chunker.js');
1073
+ const { LOCAL_EMBEDDING_MODEL } = await import('@/lib/embedder/local.js');
1074
+ const routeChunks = chunkTriageRoutes(processedRoutes);
1075
+ if (routeChunks.length > 0) {
1076
+ const { storageKey, chunkCount } = await embedAndPersistChunks(routeChunks, session);
1077
+ await BuilderSessionRepository.updateGeneratedConfig(
1078
+ session.id,
1079
+ userId,
1080
+ 'embeddings',
1081
+ {
1082
+ storageKey,
1083
+ model: LOCAL_EMBEDDING_MODEL,
1084
+ chunkCount,
1085
+ }
1086
+ );
1087
+ }
1088
+
1089
+ return {
1090
+ routes: processedRoutes,
1091
+ routeCount: processedRoutes.length,
1092
+ message: `Configured ${processedRoutes.length} triage route(s): ${processedRoutes.map((r) => r.name).join(', ')}`,
1093
+ };
1094
+ },
1095
+
1096
+ /**
1097
+ * Generate Optical Read extraction fields
1098
+ *
1099
+ * Slugifies idName from label when missing, dedupes by idName, and persists
1100
+ * the field list onto the session under generatedConfigs.opticalRead.
1101
+ * Mirrors generate_triage_config in shape; the protocol's directional
1102
+ * principle (templated-artifact prior, hint as load-bearing primitive) is
1103
+ * encoded in the cartridge — this executor just normalizes the field list.
1104
+ */
1105
+ async generate_optical_read_config(input, context) {
1106
+ const { fields } = input;
1107
+ const { session, userId } = context;
1108
+
1109
+ if (!fields || fields.length === 0) {
1110
+ throw new Error('No optical read fields provided');
1111
+ }
1112
+
1113
+ const slugify = (label) =>
1114
+ (label || '')
1115
+ .toLowerCase()
1116
+ .trim()
1117
+ .replace(/[^a-z0-9]+/g, '_')
1118
+ .replace(/^_+|_+$/g, '');
1119
+
1120
+ const ID_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
1121
+
1122
+ const seen = new Set();
1123
+ const processedFields = [];
1124
+ for (const field of fields) {
1125
+ if (!field.label || !field.label.trim()) {
1126
+ throw new Error(`Invalid optical read field: label is required. Got: ${JSON.stringify(field)}`);
1127
+ }
1128
+ const idName = (field.idName && field.idName.trim()) || slugify(field.label);
1129
+ if (!ID_NAME_PATTERN.test(idName)) {
1130
+ throw new Error(`Invalid idName "${idName}": must be snake_case (lowercase letters, digits, underscores)`);
1131
+ }
1132
+ if (seen.has(idName)) {
1133
+ // Dedupe rather than throw — the chat builder may produce overlapping
1134
+ // labels ("Name", "Full Name") that slug to the same key.
1135
+ continue;
1136
+ }
1137
+ seen.add(idName);
1138
+ processedFields.push({
1139
+ label: field.label.trim(),
1140
+ idName,
1141
+ hint: field.hint ? field.hint.trim() : '',
1142
+ });
1143
+ }
1144
+
1145
+ if (processedFields.length === 0) {
1146
+ throw new Error('No valid optical read fields after normalization');
1147
+ }
1148
+
1149
+ console.log(`[Builder] Generated optical read config with ${processedFields.length} fields`);
1150
+
1151
+ await BuilderSessionRepository.updateGeneratedConfig(session.id, userId, 'opticalRead', {
1152
+ fields: processedFields,
1153
+ });
1154
+
1155
+ return {
1156
+ fields: processedFields,
1157
+ fieldCount: processedFields.length,
1158
+ message: `Configured ${processedFields.length} extraction field(s): ${processedFields.map((f) => f.idName).join(', ')}`,
1159
+ };
1160
+ },
1161
+
1162
+ /**
1163
+ * Compose bot identity from context
1164
+ */
1165
+ async compose_identity(input, context) {
1166
+ const { intent, domainDigest, organizationName, enabledProtocols, userMessage } = input;
1167
+ const { session, userId } = context;
1168
+
1169
+ // Check for prepopulated settings from infer_intent
1170
+ const prepopulated = session.generatedConfigs?.prepopulated || {};
1171
+
1172
+ // Use prepopulated resource name if available, otherwise use organizationName
1173
+ const effectiveOrgName = prepopulated.resourceName || organizationName;
1174
+
1175
+ // Generate bot name - use prepopulated if available
1176
+ let botName;
1177
+ if (prepopulated.botName) {
1178
+ // Use the prepopulated bot name directly (already sanitized)
1179
+ botName = prepopulated.botName;
1180
+ } else {
1181
+ // Generate bot name from organization and intent
1182
+ const sanitizedOrg = (effectiveOrgName || 'my')
1183
+ .toLowerCase()
1184
+ .replace(/[^a-z0-9]/g, '-')
1185
+ .replace(/-+/g, '-')
1186
+ .substring(0, 20);
1187
+
1188
+ const intentSuffix = {
1189
+ support_bot: 'support',
1190
+ lead_gen: 'leads',
1191
+ appointment_scheduler: 'booking',
1192
+ knowledge_base: 'help',
1193
+ feedback_collector: 'feedback',
1194
+ onboarding_assistant: 'onboard',
1195
+ triage_router: 'triage',
1196
+ }[intent] || 'bot';
1197
+
1198
+ botName = `${sanitizedOrg}-${intentSuffix}`;
1199
+ }
1200
+
1201
+ // Generate objective based on intent - use prepopulated if available
1202
+ const objectives = {
1203
+ support_bot: `Help users with questions and support requests${effectiveOrgName ? ` for ${effectiveOrgName}` : ''}`,
1204
+ lead_gen: `Collect contact information and qualify leads${effectiveOrgName ? ` for ${effectiveOrgName}` : ''}`,
1205
+ appointment_scheduler: `Help users book appointments${effectiveOrgName ? ` with ${effectiveOrgName}` : ''}`,
1206
+ knowledge_base: `Answer questions using the knowledge base${effectiveOrgName ? ` for ${effectiveOrgName}` : ''}`,
1207
+ feedback_collector: `Collect user feedback and suggestions${effectiveOrgName ? ` for ${effectiveOrgName}` : ''}`,
1208
+ onboarding_assistant: `Guide new users through getting started${effectiveOrgName ? ` with ${effectiveOrgName}` : ''}`,
1209
+ triage_router: `Route users to the right team or specialist${effectiveOrgName ? ` at ${effectiveOrgName}` : ''}`,
1210
+ };
1211
+
1212
+ // Generate first message - use prepopulated if available
1213
+ const firstMessages = {
1214
+ support_bot: `Hi! I'm here to help with any questions you might have${effectiveOrgName ? ` about ${effectiveOrgName}` : ''}. How can I assist you today?`,
1215
+ lead_gen: `Hello! I'd love to learn more about what you're looking for. How can I help you get started?`,
1216
+ appointment_scheduler: `Hi! I can help you schedule an appointment. What type of appointment are you looking to book?`,
1217
+ knowledge_base: `Hello! I have access to our knowledge base and can answer your questions. What would you like to know?`,
1218
+ feedback_collector: `Hi there! I'd love to hear your thoughts and feedback. What's on your mind?`,
1219
+ onboarding_assistant: `Welcome! I'm here to help you get started. Would you like a quick tour of the features?`,
1220
+ triage_router: `Hi! I can help connect you with the right team. What can I help you with today?`,
1221
+ };
1222
+
1223
+ // Generate contextual identity (firstMessage + objective) if domainDigest available
1224
+ // Note: suggestedPrompts are now set separately via set_suggested_prompts tool
1225
+ // to ensure proper localization in the same language as documents
1226
+ let contextualFirstMessage = null;
1227
+ let contextualObjective = null;
1228
+
1229
+ // For triage routers, build an effective digest from route descriptions (botSummaries).
1230
+ // Fall back to the session-stored digest (written by process_documents) when
1231
+ // the LLM doesn't pass it — the schema field is documentation, the session
1232
+ // is the source of truth.
1233
+ let effectiveDigest = domainDigest || session.generatedConfigs?.knowledge?.domainDigest;
1234
+ if (intent === 'triage_router' && (!domainDigest || domainDigest.trim().length === 0)) {
1235
+ const triageRoutes = session.generatedConfigs?.triage?.routes;
1236
+ if (triageRoutes && triageRoutes.length > 0) {
1237
+ // Build digest from route descriptions (which are target bots' botSummary)
1238
+ effectiveDigest = triageRoutes
1239
+ .map((route) => `${route.name}: ${route.description}`)
1240
+ .join('\n');
1241
+ console.log('[Builder] Using triage routes as effective domain digest for identity');
1242
+ }
1243
+ }
1244
+
1245
+ if (effectiveDigest && effectiveDigest.trim().length > 0) {
1246
+ // Try to generate contextual identity (firstMessage + objective) using LLM
1247
+ const effectiveUserMessage = userMessage || session.userMessage || '';
1248
+ try {
1249
+ const contextualIdentity = await generateContextualIdentity(
1250
+ effectiveDigest,
1251
+ effectiveUserMessage,
1252
+ intent,
1253
+ effectiveOrgName,
1254
+ session,
1255
+ userId
1256
+ );
1257
+ if (contextualIdentity) {
1258
+ contextualFirstMessage = contextualIdentity.firstMessage;
1259
+ contextualObjective = contextualIdentity.objective;
1260
+ console.log('[Builder] Using contextual identity from LLM');
1261
+ }
1262
+ } catch (err) {
1263
+ console.warn('[Builder] Failed to generate contextual identity:', err.message);
1264
+ }
1265
+ }
1266
+
1267
+ // Use static prompts as placeholder - Claude will set localized prompts via set_suggested_prompts
1268
+ const suggestedPrompts = getStaticPromptsForIntent(intent);
1269
+
1270
+ // Build identity with priority: prepopulated > contextual LLM > static templates
1271
+ const identity = {
1272
+ botName,
1273
+ displayName: prepopulated.displayName || (effectiveOrgName ? `${effectiveOrgName} Assistant` : 'Assistant'),
1274
+ objective: prepopulated.objective || contextualObjective || objectives[intent] || objectives.support_bot,
1275
+ firstMessage: prepopulated.firstMessage || contextualFirstMessage || firstMessages[intent] || firstMessages.support_bot,
1276
+ suggestedPrompts,
1277
+ };
1278
+
1279
+ // Store in session
1280
+ await BuilderSessionRepository.updateGeneratedConfig(session.id, userId, 'identity', identity);
1281
+
1282
+ // Also update core config with defaults from preloaded context
1283
+ const coreConfig = {
1284
+ provider: session.preloadedContext?.defaultProvider || 'anthropic',
1285
+ model: session.preloadedContext?.defaultModel || 'claude-sonnet-4-20250514',
1286
+ apiKeyId: session.preloadedContext?.defaultApiKeyId,
1287
+ botName: identity.botName,
1288
+ };
1289
+
1290
+ await BuilderSessionRepository.updateGeneratedConfig(session.id, userId, 'core', coreConfig);
1291
+
1292
+ // Build message indicating what was used
1293
+ const usedPrepopulated = [];
1294
+ if (prepopulated.botName) usedPrepopulated.push('bot name');
1295
+ if (prepopulated.displayName) usedPrepopulated.push('display name');
1296
+ if (prepopulated.resourceName) usedPrepopulated.push('organization');
1297
+ if (prepopulated.objective) usedPrepopulated.push('objective');
1298
+ if (prepopulated.firstMessage) usedPrepopulated.push('greeting');
1299
+
1300
+ const prepopulatedNote = usedPrepopulated.length > 0
1301
+ ? ` (using user-specified: ${usedPrepopulated.join(', ')})`
1302
+ : '';
1303
+
1304
+ return {
1305
+ identity,
1306
+ prepopulatedSettings: Object.keys(prepopulated).length > 0 ? prepopulated : undefined,
1307
+ message: `Composed identity: ${identity.botName}${prepopulatedNote}`,
1308
+ };
1309
+ },
1310
+
1311
+ /**
1312
+ * Set suggested prompts for the bot (allows Claude to provide localized prompts)
1313
+ */
1314
+ async set_suggested_prompts(input, context) {
1315
+ const { prompts } = input;
1316
+ const { session, userId } = context;
1317
+
1318
+ if (!prompts || !Array.isArray(prompts) || prompts.length === 0) {
1319
+ throw new Error('At least one prompt is required');
1320
+ }
1321
+
1322
+ // Clean and validate prompts
1323
+ const cleanedPrompts = prompts
1324
+ .filter((p) => typeof p === 'string' && p.trim().length > 0)
1325
+ .map((p) => p.trim())
1326
+ .slice(0, 5);
1327
+
1328
+ if (cleanedPrompts.length === 0) {
1329
+ throw new Error('No valid prompts provided');
1330
+ }
1331
+
1332
+ // Get current identity from session
1333
+ const currentIdentity = session.generatedConfigs?.identity || {};
1334
+
1335
+ // Update identity with new prompts
1336
+ const updatedIdentity = {
1337
+ ...currentIdentity,
1338
+ suggestedPrompts: cleanedPrompts,
1339
+ };
1340
+
1341
+ // Store in session
1342
+ await BuilderSessionRepository.updateGeneratedConfig(session.id, userId, 'identity', updatedIdentity);
1343
+
1344
+ console.log('[Builder] Set suggested prompts:', cleanedPrompts);
1345
+
1346
+ return {
1347
+ identity: updatedIdentity,
1348
+ promptCount: cleanedPrompts.length,
1349
+ message: `Set ${cleanedPrompts.length} suggested prompts`,
1350
+ };
1351
+ },
1352
+
1353
+ /**
1354
+ * Generate a structured prose summary of the bot for multi-bot orchestration
1355
+ */
1356
+ async generate_bot_summary(input, context) {
1357
+ const { session, userId } = context;
1358
+
1359
+ // Get LLM config from session (supports Anthropic, Bedrock, etc.)
1360
+ // Summary tier: prose generation for multi-bot orchestration metadata.
1361
+ const llmConfig = await getLLMConfigFromSession(session, userId, 'summary');
1362
+ const { provider, apiKey, model } = llmConfig;
1363
+
1364
+ const { generateSummary } = await import('@/lib/llm-providers.js');
1365
+
1366
+ // Gather context from session
1367
+ const { enabledProtocols, identityConfig, protocolData, generatedConfigs } = session;
1368
+ const identity = generatedConfigs?.identity || identityConfig || {};
1369
+
1370
+ // Build compact context for the LLM
1371
+ const contextParts = [];
1372
+
1373
+ // Bot identity
1374
+ contextParts.push(`Bot Name: ${identity.botName || 'Unnamed Bot'}`);
1375
+ contextParts.push(`Purpose: ${identity.objective || 'General assistant'}`);
1376
+
1377
+ // Knowledge context
1378
+ if (enabledProtocols.knowledge) {
1379
+ const domainDigest = protocolData?.knowledge?.domainDigest || generatedConfigs?.knowledge?.domainDigest;
1380
+ if (domainDigest) {
1381
+ // Extract first 500 chars of the digest for context
1382
+ contextParts.push(`Knowledge Base Topics: ${domainDigest.substring(0, 500)}`);
1383
+ }
1384
+ const docCount = protocolData?.knowledge?.documents?.length || generatedConfigs?.knowledge?.documentsProcessed || 0;
1385
+ if (docCount > 0) {
1386
+ contextParts.push(`Documents: ${docCount} document(s) processed`);
1387
+ }
1388
+ }
1389
+
1390
+ // Form collection context
1391
+ if (enabledProtocols.formGathering) {
1392
+ const formSchema = protocolData?.formGathering?.generatedFormJson || generatedConfigs?.forms?.formSchema;
1393
+ if (formSchema?.sections) {
1394
+ const fieldNames = formSchema.sections
1395
+ .flatMap(s => s.fields || [])
1396
+ .map(f => f.label)
1397
+ .slice(0, 8);
1398
+ contextParts.push(`Collects Information: ${fieldNames.join(', ')}`);
1399
+ }
1400
+ }
1401
+
1402
+ // Appointments context
1403
+ if (enabledProtocols.appointments) {
1404
+ const destinations = protocolData?.appointments?.destinations || generatedConfigs?.appointments?.destinations || [];
1405
+ if (destinations.length > 0) {
1406
+ const destNames = destinations.map(d => d.name || d.description).slice(0, 5);
1407
+ contextParts.push(`Appointment Types: ${destNames.join(', ')}`);
1408
+ }
1409
+ }
1410
+
1411
+ // Triage context
1412
+ if (enabledProtocols.triage) {
1413
+ const routes = protocolData?.triage?.routes || [];
1414
+ if (routes.length > 0) {
1415
+ const routeNames = routes.map(r => r.botName || r.name).slice(0, 5);
1416
+ contextParts.push(`Routes To: ${routeNames.join(', ')}`);
1417
+ }
1418
+ }
1419
+
1420
+ const contextString = contextParts.join('\n');
1421
+
1422
+ // System prompt for structured prose generation
1423
+ const systemPrompt = `You are generating a bot summary for a multi-bot orchestration system. Other bots will read this summary to understand what this bot does and when to route conversations to it.
1424
+
1425
+ Write a clear, concise description in 2-3 sentences that covers:
1426
+ 1. What the bot is and its primary purpose
1427
+ 2. What knowledge or information it has access to (if any)
1428
+ 3. What actions it can perform (collect info, book appointments, route to specialists)
1429
+
1430
+ Style guidelines:
1431
+ - Use third person ("This bot..." or "The [Name] assistant...")
1432
+ - Be specific about capabilities, not generic
1433
+ - Keep it under 150 words
1434
+ - No bullet points - flowing prose only
1435
+ - No markdown formatting
1436
+
1437
+ Return ONLY the summary text, nothing else.`;
1438
+
1439
+ const userPrompt = `Generate a bot summary based on this configuration:\n\n${contextString}`;
1440
+
1441
+ try {
1442
+ const botSummary = await generateSummary(
1443
+ provider,
1444
+ userPrompt,
1445
+ apiKey,
1446
+ systemPrompt,
1447
+ model
1448
+ );
1449
+
1450
+ const cleanedSummary = botSummary.trim();
1451
+
1452
+ // Store botSummary at top level of generatedConfigs (beside objective, paradigm)
1453
+ await BuilderSessionRepository.updateGeneratedConfig(session.id, userId, 'botSummary', cleanedSummary);
1454
+
1455
+ console.log('[Builder] Generated bot summary:', cleanedSummary.substring(0, 100) + '...');
1456
+
1457
+ return {
1458
+ botSummary: cleanedSummary,
1459
+ message: 'Bot summary generated successfully',
1460
+ };
1461
+ } catch (error) {
1462
+ console.error('[Builder] Failed to generate bot summary:', error.message);
1463
+ throw new Error(`Failed to generate bot summary: ${error.message}`);
1464
+ }
1465
+ },
1466
+
1467
+ /**
1468
+ * Save the bot's composed configuration to a deployment row, then build
1469
+ * the artifact so the user lands on the dashboard with a ready ZIP.
1470
+ *
1471
+ * Build failures don't fail the tool — the row stays `saved` and the
1472
+ * dashboard's Build button picks up where this left off.
1473
+ */
1474
+ async save_modular_bot(input, context) {
1475
+ const { sessionId, confirmedProtocols } = input;
1476
+ const { session, userId } = context;
1477
+
1478
+ if (session.id !== sessionId) {
1479
+ throw new Error('Session ID mismatch');
1480
+ }
1481
+
1482
+ const editingDeployment = session.generatedConfigs?._editingDeployment;
1483
+ const isUpdate = !!editingDeployment?.id;
1484
+
1485
+ await BuilderSessionRepository.updateStatus(sessionId, userId, SESSION_STATUS.DEPLOYING);
1486
+ await BuilderSessionRepository.confirmProtocols(sessionId, userId, confirmedProtocols);
1487
+ await BuilderSessionRepository.syncGeneratedConfigsToLegacy(sessionId, userId);
1488
+
1489
+ const updatedSession = await BuilderSessionRepository.findById(sessionId);
1490
+
1491
+ const result = await saveBuilderConfig(sessionId, userId, {
1492
+ botSpaceId: updatedSession.botSpaceId,
1493
+ redeploymentId: isUpdate ? editingDeployment.id : null,
1494
+ });
1495
+
1496
+ if (result.success) {
1497
+ await BuilderSessionRepository.updateStatus(sessionId, userId, SESSION_STATUS.DEPLOYED);
1498
+ await BuilderSessionRepository.linkDeployment(sessionId, userId, result.deploymentId);
1499
+
1500
+ let buildStatus = result.status;
1501
+ let buildError = null;
1502
+ // artifactPath is the absolute on-disk zip — surfaced so MCP/stdio
1503
+ // callers (which have no HTTP server to hit downloadUrl against) can
1504
+ // hand the user a real path. Web flow keeps using downloadUrl.
1505
+ let artifactPath = null;
1506
+ try {
1507
+ const built = await buildArtifact(result.deploymentId);
1508
+ buildStatus = built.deployment.status;
1509
+ artifactPath = built.artifactPath;
1510
+ } catch (err) {
1511
+ console.error('[save_modular_bot] build after save failed:', err);
1512
+ buildError = err.message || 'Build failed';
1513
+ }
1514
+
1515
+ return { ...result, isUpdate, status: buildStatus, buildError, artifactPath };
1516
+ }
1517
+
1518
+ await BuilderSessionRepository.updateStatus(sessionId, userId, SESSION_STATUS.AWAITING_CONFIRM);
1519
+ return { ...result, isUpdate };
1520
+ },
1521
+ };
1522
+
1523
+ // Back-compat shim: chat sessions persisted before the rename still reference
1524
+ // the old tool name. Map it to the new handler so replays don't break.
1525
+ builderToolHandlers.deploy_modular_bot = builderToolHandlers.save_modular_bot;
1526
+
1527
+ export { builderToolHandlers };