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,439 @@
1
+ import fs from 'fs';
2
+ import fsp from 'fs/promises';
3
+ import path from 'path';
4
+ import archiver from 'archiver';
5
+ import { composeInstructions } from '../composer/composer.js';
6
+ import { downloadToBuffer } from '../storage/index.js';
7
+
8
+ const LITE_TEMPLATE_PATH =
9
+ process.env.LITE_TEMPLATE_PATH ||
10
+ path.resolve(process.cwd(), '..', 'lite-template');
11
+
12
+ const ARTIFACTS_DIR =
13
+ process.env.ARTIFACTS_DIR || path.join(process.cwd(), 'data', 'artifacts');
14
+
15
+ const BOT_DEFAULT_PORT = process.env.BOT_DEFAULT_PORT || '3000';
16
+
17
+ // Prebuilt bot image published by .github/workflows/publish-bot-image.yml.
18
+ // Pin an exact version per release — never ship :latest to users.
19
+ const BOT_IMAGE =
20
+ process.env.BOT_IMAGE || 'ghcr.io/zombico/mojulo-bot:0.5.1';
21
+
22
+ // Escape hatch for users who can't reach ghcr.io (air-gapped networks,
23
+ // firewalls). Set MOJULO_OFFLINE_BUILD=1 on the control plane and the
24
+ // emitted artifact bundles the full source + Dockerfile and builds locally.
25
+ const OFFLINE_BUILD = process.env.MOJULO_OFFLINE_BUILD === '1';
26
+
27
+ // Used only in offline-build mode (the npm package's deploy path never
28
+ // reads lite-template). Drops local-state dirs and the `config` dir that
29
+ // the deploy flow writes inline a few lines below.
30
+ const TEMPLATE_EXCLUDES = new Set([
31
+ 'node_modules',
32
+ '.next',
33
+ '.env',
34
+ '.DS_Store',
35
+ 'data',
36
+ 'documents',
37
+ 'config',
38
+ 'test',
39
+ 'integration',
40
+ ]);
41
+
42
+ async function ensureDir(dir) {
43
+ await fsp.mkdir(dir, { recursive: true });
44
+ }
45
+
46
+ // Strip path-traversal characters and normalize the originalName so it's safe
47
+ // to write under documents/. Falls back to a deterministic stub when the
48
+ // upload's name was empty or only-separators after sanitization.
49
+ function sanitizeDocFilename(name) {
50
+ const base = (name || '').split(/[\\/]/).pop() || '';
51
+ const cleaned = base.replace(/\.\./g, '_').replace(/^\.+/, '_').trim();
52
+ return cleaned || 'document';
53
+ }
54
+
55
+ async function copyTemplateFiles(srcRoot, dstRoot, excludes) {
56
+ const entries = await fsp.readdir(srcRoot, { withFileTypes: true });
57
+ for (const entry of entries) {
58
+ if (excludes.has(entry.name)) continue;
59
+ const srcPath = path.join(srcRoot, entry.name);
60
+ const dstPath = path.join(dstRoot, entry.name);
61
+ if (entry.isDirectory()) {
62
+ await ensureDir(dstPath);
63
+ await copyTemplateFiles(srcPath, dstPath, excludes);
64
+ } else {
65
+ await fsp.copyFile(srcPath, dstPath);
66
+ }
67
+ }
68
+ }
69
+
70
+ function buildDockerCompose(botName) {
71
+ const imageOrBuild = OFFLINE_BUILD
72
+ ? `build: .
73
+ image: mojulo/bot:local`
74
+ : `image: ${BOT_IMAGE}`;
75
+
76
+ return `version: '3.8'
77
+
78
+ services:
79
+ ${botName}:
80
+ ${imageOrBuild}
81
+ container_name: ${botName}
82
+ ports:
83
+ - "${BOT_DEFAULT_PORT}:3000"
84
+ env_file:
85
+ - .env
86
+ volumes:
87
+ - ./data:/data
88
+ - ./config:/app/config
89
+ - ./documents:/app/documents
90
+ restart: unless-stopped
91
+ healthcheck:
92
+ test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
93
+ interval: 30s
94
+ timeout: 3s
95
+ retries: 3
96
+ start_period: 10s
97
+ `;
98
+ }
99
+
100
+ function buildEnvExample(llmConfig) {
101
+ const provider = llmConfig.provider || 'anthropic';
102
+ return [
103
+ '# Mojulo-Lite bot runtime env.',
104
+ '# The builder baked your selected LLM provider + model into config/config.json,',
105
+ `# so you only need to paste the matching API key below before running 'docker compose up'.`,
106
+ '',
107
+ `LLM_PROVIDER=${provider}`,
108
+ '',
109
+ '# Uncomment and set the key for the provider you selected:',
110
+ '# OPENAI_API_KEY=',
111
+ '# ANTHROPIC_API_KEY=',
112
+ '',
113
+ '# AWS Bedrock (only if provider=bedrock)',
114
+ '# AWS_REGION=us-east-1',
115
+ '# AWS_ACCESS_KEY_ID=',
116
+ '# AWS_SECRET_ACCESS_KEY=',
117
+ '',
118
+ '# Admin API key for protected endpoints',
119
+ `MOJULO_API_KEY=`,
120
+ '',
121
+ ].join('\n');
122
+ }
123
+
124
+ function buildReadme(botName, enabledProtocols = {}, hasEmbeddings = false) {
125
+ const protocols = Object.entries(enabledProtocols)
126
+ .filter(([, v]) => v)
127
+ .map(([k]) => `- ${k}`)
128
+ .join('\n') || '- (base only)';
129
+
130
+ const vectorSection = hasEmbeddings ? `
131
+ ## Vector RAG (this bot)
132
+
133
+ The corpus embeddings ship in \`config/embeddings.json\`; the embedding
134
+ model (multilingual-e5-small, ONNX) ships in \`models/\`. User queries
135
+ are embedded in-process, then cosine similarity runs locally against
136
+ the baked corpus.
137
+
138
+ - No factory dependency at runtime — the bot is fully self-contained.
139
+ - If the model files in \`models/\` are missing or corrupt, queries fail
140
+ loudly.
141
+
142
+ ` : '';
143
+
144
+ const formsSection = enabledProtocols.formGathering ? `
145
+ ## Form submissions
146
+
147
+ Completed forms are captured to the bot's local SQLite database
148
+ (\`data/conversation.db\`, table \`form_submissions\`) on every completion,
149
+ independent of any webhook configuration. The webhook (\`formSendHome\`) is
150
+ attempted in addition when configured; failures are recorded but never block
151
+ local capture.
152
+
153
+ Admin endpoints (require \`x-mojulo-api-key: <MOJULO_API_KEY>\`):
154
+
155
+ - \`GET /api/forms\` — list submissions. Query params: \`conversationId\`,
156
+ \`since\` (ISO timestamp), \`limit\` (default 100, max 1000).
157
+ - \`GET /api/forms/:id\` — fetch one submission.
158
+ - \`GET /api/forms/export\` — CSV export with the same filters. Includes a
159
+ UTF-8 BOM so non-Latin field values render correctly in Excel.
160
+ ` : '';
161
+
162
+ const quickStart = OFFLINE_BUILD
163
+ ? `## Quick start
164
+
165
+ 1. Paste your LLM provider API key into \`.env\` (see \`.env.example\`).
166
+ 2. Build and run:
167
+
168
+ \`\`\`bash
169
+ docker compose up --build
170
+ \`\`\`
171
+
172
+ First run builds the image locally (~3min). Subsequent runs skip straight to start.
173
+
174
+ 3. Open http://localhost:${BOT_DEFAULT_PORT}.`
175
+ : `## Quick start
176
+
177
+ 1. Paste your LLM provider API key into \`.env\` (see \`.env.example\`).
178
+ 2. Run:
179
+
180
+ \`\`\`bash
181
+ docker compose up
182
+ \`\`\`
183
+
184
+ First run pulls the bot image from GHCR (~30s on a typical connection); subsequent runs are instant.
185
+
186
+ 3. Open http://localhost:${BOT_DEFAULT_PORT}.`;
187
+
188
+ const composeLine = OFFLINE_BUILD
189
+ ? `\`docker-compose.yml\` — builds \`mojulo/bot:local\` from the Dockerfile in this directory.`
190
+ : `\`docker-compose.yml\` — pulls \`${BOT_IMAGE}\` and runs it with your config mounted.`;
191
+
192
+ return `# ${botName}
193
+
194
+ Portable Mojulo bot artifact. One image, one zip, one command.
195
+
196
+ ${quickStart}
197
+
198
+ ## What's inside
199
+
200
+ - ${composeLine}
201
+ - \`.env.example\` — LLM keys + webhook URLs.
202
+ - \`config/\`
203
+ - \`instructions.txt\` — composed protocol cartridges.
204
+ - \`config.json\` — bot identity + LLM provider + model.
205
+ - \`formFormat.json\` — ghost-form schema (if forms enabled).
206
+ - \`embeddings.json\` — pre-baked vector index (if knowledge or triage enabled).
207
+
208
+ ## Enabled protocols
209
+
210
+ ${protocols}
211
+ ${vectorSection}${formsSection}
212
+ ## Deploying elsewhere
213
+
214
+ The artifact is endpoint-agnostic. Any host that can run \`docker compose up\`
215
+ works: Fly.io, Railway, a $5 VPS, or your laptop.
216
+ `;
217
+ }
218
+
219
+ function writeJson(file, data) {
220
+ return fsp.writeFile(file, JSON.stringify(data, null, 2), 'utf8');
221
+ }
222
+
223
+ function zipDirectory(sourceDir, outPath) {
224
+ return new Promise((resolve, reject) => {
225
+ const output = fs.createWriteStream(outPath);
226
+ const archive = archiver('zip', { zlib: { level: 9 } });
227
+ output.on('close', () => resolve({ bytes: archive.pointer() }));
228
+ archive.on('error', reject);
229
+ archive.pipe(output);
230
+ archive.directory(sourceDir, false);
231
+ archive.finalize();
232
+ });
233
+ }
234
+
235
+ export class DockerDeployer {
236
+ /**
237
+ * Build a portable bot artifact. Pure: takes config in, writes a ZIP out.
238
+ * Caller is responsible for any DB updates around the call.
239
+ *
240
+ * @param {Object} params
241
+ * @param {string} params.deploymentId - Used only for staging dir + zip filename
242
+ * @param {string} params.botName - Slug-friendly bot name
243
+ * @param {Object} params.config - Deployment config
244
+ * @param {string} params.apiKey - Generated admin API key for bot
245
+ * @param {Array} [params.appointmentDestinations]
246
+ * @param {Array} [params.triageDestinations]
247
+ * @param {Array} [params.opticalReadFields]
248
+ * @param {Object} [params.enabledProtocols]
249
+ * @param {string} [params.embeddingStorageKey]
250
+ * @param {string} [params.embeddingModel]
251
+ * @param {number} [params.embeddingChunkCount]
252
+ * @param {boolean} [params.withDocs=false] - Bundle source documents into documents/
253
+ * @param {Array<{originalName: string, storagePath: string}>} [params.documents=[]] - Required when withDocs=true
254
+ */
255
+ async deploy(params) {
256
+ const {
257
+ deploymentId,
258
+ botName,
259
+ config,
260
+ apiKey,
261
+ appointmentDestinations = [],
262
+ triageDestinations = [],
263
+ opticalReadFields = [],
264
+ enabledProtocols = {},
265
+ embeddingStorageKey = null,
266
+ embeddingModel = null,
267
+ embeddingChunkCount = null,
268
+ withDocs = false,
269
+ documents = [],
270
+ } = params;
271
+
272
+ await ensureDir(ARTIFACTS_DIR);
273
+ const stagingDir = path.join(ARTIFACTS_DIR, `${botName}-${deploymentId}`);
274
+ if (fs.existsSync(stagingDir)) await fsp.rm(stagingDir, { recursive: true, force: true });
275
+ await ensureDir(stagingDir);
276
+
277
+ // 1. Stage the bot source.
278
+ // Prebuilt-image mode (default) writes nothing here — the GHCR image
279
+ // holds the source and `docker-compose.yml` (written in step 7) pulls
280
+ // it at `docker compose up`. Offline mode copies the full template so
281
+ // `docker compose up --build` works air-gapped, which only makes sense
282
+ // in clone-and-run installs (the npm package doesn't ship lite-template).
283
+ if (OFFLINE_BUILD) {
284
+ if (!fs.existsSync(LITE_TEMPLATE_PATH)) {
285
+ throw new Error(
286
+ `MOJULO_OFFLINE_BUILD=1 but lite-template not found at ${LITE_TEMPLATE_PATH}. ` +
287
+ `Offline mode requires a repo clone; set LITE_TEMPLATE_PATH or unset MOJULO_OFFLINE_BUILD.`
288
+ );
289
+ }
290
+ await copyTemplateFiles(LITE_TEMPLATE_PATH, stagingDir, TEMPLATE_EXCLUDES);
291
+ }
292
+
293
+ // 2. Create config, data dirs
294
+ const configDir = path.join(stagingDir, 'config');
295
+ const dataDir = path.join(stagingDir, 'data');
296
+ await ensureDir(configDir);
297
+ await ensureDir(dataDir);
298
+
299
+ // 3. Compose instructions.txt from enabled protocols
300
+ const objective = config.objective || `Help users as ${botName}`;
301
+ const protocolData = {
302
+ formStructure: config.formStructure,
303
+ appointments: appointmentDestinations,
304
+ triage: triageDestinations,
305
+ opticalRead: { fields: opticalReadFields },
306
+ };
307
+ const instructions =
308
+ config._composedInstructions ||
309
+ (await composeInstructions({ objective, enabledProtocols, protocolData }));
310
+ await fsp.writeFile(path.join(configDir, 'instructions.txt'), instructions, 'utf8');
311
+
312
+ // 4. Write config.json — container reads bot identity + LLM provider here
313
+ const hasEmbeddings = !!embeddingStorageKey;
314
+ const configJson = {
315
+ config: {
316
+ ...config.config,
317
+ instructions: './config/instructions.txt',
318
+ rag: {
319
+ ...(config.config?.rag || {}),
320
+ ...(hasEmbeddings
321
+ ? {
322
+ embeddingsPath: './config/embeddings.json',
323
+ embeddingModel,
324
+ embeddingChunkCount,
325
+ }
326
+ : {}),
327
+ },
328
+ },
329
+ llm: config.llm,
330
+ };
331
+ await writeJson(path.join(configDir, 'config.json'), configJson);
332
+
333
+ // 5. Write per-protocol config files
334
+ if (config.formStructure) {
335
+ await writeJson(path.join(configDir, 'formFormat.json'), config.formStructure);
336
+ }
337
+ if (appointmentDestinations.length > 0) {
338
+ await writeJson(path.join(configDir, 'calendarConfig.json'), {
339
+ destinations: appointmentDestinations,
340
+ });
341
+ }
342
+ if (triageDestinations.length > 0) {
343
+ // Captured at build time — never re-resolved at runtime. The JSON is
344
+ // the authoritative deploymentId list the LLM picks from; route
345
+ // description text lives in embeddings.json for retrieval reinforcement.
346
+ await writeJson(path.join(configDir, 'triageRoutes.json'), triageDestinations);
347
+ }
348
+ if (opticalReadFields.length > 0) {
349
+ // Read once at bot startup like formFormat.json. The bot's /api/extract
350
+ // endpoint and the upload-card UX both key off this list — keep the
351
+ // shape (idName, label, hint) byte-identical to what the wizard saved
352
+ // so edit-mode round-trips don't drift.
353
+ await writeJson(path.join(configDir, 'opticalReadFields.json'), opticalReadFields);
354
+ }
355
+
356
+ // 6. Vector RAG: copy the pre-baked embeddings blob into the artifact.
357
+ // Build is pure copy — no embed call here. Re-build = re-copy. Bots
358
+ // with neither knowledge nor triage have no embeddings; runtime tolerates
359
+ // the missing file and silently disables RAG.
360
+ if (hasEmbeddings) {
361
+ const embeddingsBuffer = await downloadToBuffer(embeddingStorageKey);
362
+ await fsp.writeFile(path.join(configDir, 'embeddings.json'), embeddingsBuffer);
363
+ }
364
+
365
+ // 6.5 Optional: bundle source documents under documents/. The compose file
366
+ // always mounts ./documents:/app/documents, so anything written here
367
+ // becomes visible to the bot at runtime. Skipped by default — only
368
+ // the "Download with Docs" path opts in.
369
+ if (withDocs && documents.length > 0) {
370
+ const docsDir = path.join(stagingDir, 'documents');
371
+ await ensureDir(docsDir);
372
+ const used = new Map();
373
+ for (const doc of documents) {
374
+ if (!doc?.storagePath) continue;
375
+ let name = sanitizeDocFilename(doc.originalName);
376
+ const seen = used.get(name) || 0;
377
+ if (seen > 0) {
378
+ const dot = name.lastIndexOf('.');
379
+ name = dot > 0
380
+ ? `${name.slice(0, dot)} (${seen})${name.slice(dot)}`
381
+ : `${name} (${seen})`;
382
+ }
383
+ used.set(sanitizeDocFilename(doc.originalName), seen + 1);
384
+ const buf = await downloadToBuffer(doc.storagePath);
385
+ await fsp.writeFile(path.join(docsDir, name), buf);
386
+ }
387
+ }
388
+
389
+ // 7. Write docker-compose.yml + .env.example + README.md
390
+ await fsp.writeFile(
391
+ path.join(stagingDir, 'docker-compose.yml'),
392
+ buildDockerCompose(botName),
393
+ 'utf8'
394
+ );
395
+ await fsp.writeFile(
396
+ path.join(stagingDir, '.env.example'),
397
+ buildEnvExample(config.llm || {}),
398
+ 'utf8'
399
+ );
400
+ await fsp.writeFile(
401
+ path.join(stagingDir, 'README.md'),
402
+ buildReadme(botName, enabledProtocols, hasEmbeddings),
403
+ 'utf8'
404
+ );
405
+
406
+ // 9. Write the bot's admin API key into the staging .env (users are
407
+ // expected to replace the LLM key themselves). The pre-populated
408
+ // MOJULO_API_KEY lets the bot protect its /api/conversations
409
+ // endpoints immediately.
410
+ await fsp.writeFile(
411
+ path.join(stagingDir, '.env'),
412
+ `MOJULO_API_KEY=${apiKey}\n# Paste your LLM provider key below. See .env.example.\n`,
413
+ 'utf8'
414
+ );
415
+
416
+ // 10. Zip it up. The with-docs variant lives at a sibling path so it
417
+ // never clobbers the lean cache the deployment row tracks.
418
+ const zipSuffix = withDocs ? '-with-docs' : '';
419
+ const zipPath = path.join(
420
+ ARTIFACTS_DIR,
421
+ `${botName}-${deploymentId}${zipSuffix}.zip`
422
+ );
423
+ await zipDirectory(stagingDir, zipPath);
424
+
425
+ return {
426
+ success: true,
427
+ appName: botName,
428
+ artifactPath: zipPath,
429
+ relativeArtifactPath: path.relative(process.cwd(), zipPath),
430
+ url: `http://localhost:${BOT_DEFAULT_PORT}`,
431
+ };
432
+ }
433
+
434
+ async destroy(_appId) {
435
+ // Lite: "destroy" just removes the stored artifact; containers the user
436
+ // ran themselves are theirs to stop.
437
+ return { success: true };
438
+ }
439
+ }