mojulo 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -4
- package/lib/audit-logger-new.js +11 -0
- package/lib/auth/gate.js +25 -0
- package/lib/auth/service.js +17 -0
- package/lib/auth/session.js +63 -0
- package/lib/builder/chat-processor.js +607 -0
- package/lib/builder/composer-bridge.js +82 -0
- package/lib/builder/evaluator.js +159 -0
- package/lib/builder/executor.js +252 -0
- package/lib/builder/index.js +48 -0
- package/lib/builder/session.js +248 -0
- package/lib/builder/system-prompt.js +422 -0
- package/lib/builder/tone-presets.js +75 -0
- package/lib/builder/tool-executors.js +1418 -0
- package/lib/builder/tools.js +338 -0
- package/lib/builder/validators.js +239 -0
- package/lib/composer/composer.js +225 -0
- package/lib/composer/index.js +40 -0
- package/lib/composer/protocols/00_base.txt +19 -0
- package/lib/composer/protocols/01_knowledge.txt +9 -0
- package/lib/composer/protocols/02_form-gathering.txt +32 -0
- package/lib/composer/protocols/03_appointments.txt +16 -0
- package/lib/composer/protocols/04_triage.txt +15 -0
- package/lib/composer/protocols/05_optical-read.txt +22 -0
- package/lib/composer/response-builder.js +98 -0
- package/lib/config-builder.js +650 -0
- package/lib/db/ids.js +10 -0
- package/lib/db/index.js +179 -0
- package/lib/db/repositories/apiKeys.js +72 -0
- package/lib/db/repositories/auditLogs.js +12 -0
- package/lib/db/repositories/botSpaces.js +12 -0
- package/lib/db/repositories/builderSessions.js +312 -0
- package/lib/db/repositories/deploymentEvents.js +12 -0
- package/lib/db/repositories/deployments.js +385 -0
- package/lib/db/repositories/documents.js +68 -0
- package/lib/db/repositories/mcpJobs.js +84 -0
- package/lib/deployers/bot-fleet.js +110 -0
- package/lib/deployers/bot-proxy.js +72 -0
- package/lib/deployers/build.js +89 -0
- package/lib/deployers/cloud-deploy.js +310 -0
- package/lib/deployers/docker.js +439 -0
- package/lib/deployers/fly.js +432 -0
- package/lib/deployers/index.js +38 -0
- package/lib/deployment-auth.js +36 -0
- package/lib/document-parser.js +171 -0
- package/lib/embedder/chunker.js +93 -0
- package/lib/embedder/local.js +101 -0
- package/lib/embedder/preview-rag.js +93 -0
- package/lib/envelope-schema.js +54 -0
- package/lib/fleet/scoped-sql.js +342 -0
- package/lib/form-schema-config/base.js +135 -0
- package/lib/form-schema-config/index.js +286 -0
- package/lib/form-schema-config/locales/af-ZA.js +153 -0
- package/lib/form-schema-config/locales/ar-AE.js +142 -0
- package/lib/form-schema-config/locales/ar-SA.js +164 -0
- package/lib/form-schema-config/locales/de-DE.js +152 -0
- package/lib/form-schema-config/locales/en-AU.js +161 -0
- package/lib/form-schema-config/locales/en-CA.js +115 -0
- package/lib/form-schema-config/locales/en-GB.js +132 -0
- package/lib/form-schema-config/locales/en-IN.js +219 -0
- package/lib/form-schema-config/locales/en-MY.js +171 -0
- package/lib/form-schema-config/locales/en-NG.js +198 -0
- package/lib/form-schema-config/locales/en-PH.js +186 -0
- package/lib/form-schema-config/locales/en-SG.js +153 -0
- package/lib/form-schema-config/locales/en-US.js +138 -0
- package/lib/form-schema-config/locales/es-ES.js +171 -0
- package/lib/form-schema-config/locales/es-MX.js +193 -0
- package/lib/form-schema-config/locales/fr-CA.js +138 -0
- package/lib/form-schema-config/locales/fr-FR.js +155 -0
- package/lib/form-schema-config/locales/hi-IN.js +219 -0
- package/lib/form-schema-config/locales/it-IT.js +157 -0
- package/lib/form-schema-config/locales/ja-JP.js +169 -0
- package/lib/form-schema-config/locales/ko-KR.js +140 -0
- package/lib/form-schema-config/locales/nl-NL.js +149 -0
- package/lib/form-schema-config/locales/pt-BR.js +168 -0
- package/lib/form-schema-config/locales/zh-CN.js +172 -0
- package/lib/form-schema-config/locales/zh-HK.js +142 -0
- package/lib/form-structure-schema.js +191 -0
- package/lib/llm-providers.js +828 -0
- package/lib/markdown.js +197 -0
- package/lib/mcp/catalysts/appointment-to-calendar.md +84 -0
- package/lib/mcp/catalysts/conversations-to-channel-digest.md +104 -0
- package/lib/mcp/catalysts/document-extract-to-store.md +92 -0
- package/lib/mcp/catalysts/knowledge-gap-miner.md +96 -0
- package/lib/mcp/catalysts/loader.js +144 -0
- package/lib/mcp/catalysts/qualify-lead-to-crm.md +83 -0
- package/lib/mcp/catalysts/scan-conversations-for-signal.md +92 -0
- package/lib/mcp/catalysts/submission-to-ticket.md +83 -0
- package/lib/mcp/catalysts/submissions-to-warehouse.md +103 -0
- package/lib/mcp/catalysts/weekly-submissions-digest.md +82 -0
- package/lib/mcp/jobs.js +64 -0
- package/lib/mcp/server.js +184 -0
- package/lib/mcp/session-binding.js +130 -0
- package/lib/mcp/tools/build.js +123 -0
- package/lib/mcp/tools/catalysts.js +477 -0
- package/lib/mcp/tools/context.js +325 -0
- package/lib/mcp/tools/fleet.js +391 -0
- package/lib/mcp/tools/jobs-tools.js +240 -0
- package/lib/mcp/tools/operate.js +314 -0
- package/lib/preview/build-preview-config.js +136 -0
- package/lib/rate-limiter.js +11 -0
- package/lib/resolve-api-key.js +142 -0
- package/lib/storage/index.js +40 -0
- package/messages/de.json +2136 -0
- package/messages/en.json +2136 -0
- package/messages/es.json +2136 -0
- package/messages/fr.json +2136 -0
- package/messages/it.json +2136 -0
- package/messages/ja.json +2136 -0
- package/messages/ko.json +2136 -0
- package/messages/nl.json +2136 -0
- package/messages/pl.json +2136 -0
- package/messages/pt.json +2136 -0
- package/messages/ru.json +2136 -0
- package/messages/uk.json +2136 -0
- package/messages/zh.json +2136 -0
- package/package.json +61 -5
- package/scripts/mcp-config.mjs +162 -0
- package/scripts/mcp-stdio-loader.mjs +42 -0
- package/scripts/mcp-stdio.mjs +108 -0
- package/scripts/mojulo-paths.mjs +48 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Ring 2 — operate / read tools.
|
|
3
|
+
*
|
|
4
|
+
* Lets the user's Claude reason over deployed bot state without exposing new
|
|
5
|
+
* HTTP endpoints from the control plane. Reads either hit local SQLite
|
|
6
|
+
* (deployment metadata) or pass through bot-proxy to the bot's own SQLite
|
|
7
|
+
* (conversations, submissions, chain verification) — preserving the
|
|
8
|
+
* "conversation data never leaves the bot's SQLite" invariant.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { DeploymentRepository } from '@/lib/db/repositories/deployments';
|
|
12
|
+
import { fetchFromBot } from '@/lib/deployers/bot-proxy';
|
|
13
|
+
import { registerTool } from '@/lib/mcp/server';
|
|
14
|
+
|
|
15
|
+
function summarizeDeployment(d) {
|
|
16
|
+
if (!d) return null;
|
|
17
|
+
return {
|
|
18
|
+
id: d.id,
|
|
19
|
+
botName: d.botName,
|
|
20
|
+
status: d.status,
|
|
21
|
+
url: d.url || null,
|
|
22
|
+
lastSeenAt: d.lastSeenAt ? d.lastSeenAt.toISOString() : null,
|
|
23
|
+
configHash: d.configHash || null,
|
|
24
|
+
lastBuiltHash: d.lastBuiltHash || null,
|
|
25
|
+
ragMode: d.ragMode,
|
|
26
|
+
embeddingChunkCount: d.embeddingChunkCount,
|
|
27
|
+
cloud: d.cloudProvider
|
|
28
|
+
? {
|
|
29
|
+
provider: d.cloudProvider,
|
|
30
|
+
status: d.cloudStatus,
|
|
31
|
+
url: d.cloudUrl,
|
|
32
|
+
appName: d.cloudAppName,
|
|
33
|
+
lastDeployedAt: d.cloudLastDeployedAt ? d.cloudLastDeployedAt.toISOString() : null,
|
|
34
|
+
}
|
|
35
|
+
: null,
|
|
36
|
+
createdAt: d.createdAt.toISOString(),
|
|
37
|
+
updatedAt: d.updatedAt.toISOString(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function listDeploymentsHandler(input, _ctx) {
|
|
42
|
+
const { status, mode, limit = 50, offset = 0 } = input || {};
|
|
43
|
+
const all = await DeploymentRepository.list();
|
|
44
|
+
let filtered = all;
|
|
45
|
+
if (status) {
|
|
46
|
+
filtered = filtered.filter((d) => d.status === status);
|
|
47
|
+
}
|
|
48
|
+
if (mode === 'cloud') {
|
|
49
|
+
filtered = filtered.filter((d) => !!d.cloudProvider);
|
|
50
|
+
} else if (mode === 'local') {
|
|
51
|
+
filtered = filtered.filter((d) => !d.cloudProvider);
|
|
52
|
+
}
|
|
53
|
+
const total = filtered.length;
|
|
54
|
+
const page = filtered.slice(offset, offset + limit).map(summarizeDeployment);
|
|
55
|
+
return { total, limit, offset, deployments: page };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function redactConfigCredentials(config) {
|
|
59
|
+
if (!config || typeof config !== 'object') return config;
|
|
60
|
+
const llmConfig = config.llmConfig;
|
|
61
|
+
if (!llmConfig || typeof llmConfig !== 'object') return config;
|
|
62
|
+
const redactedLlm = {};
|
|
63
|
+
for (const [provider, providerConfig] of Object.entries(llmConfig)) {
|
|
64
|
+
if (!providerConfig || typeof providerConfig !== 'object') {
|
|
65
|
+
redactedLlm[provider] = providerConfig;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const next = { ...providerConfig };
|
|
69
|
+
if ('apiKey' in next) next.apiKey = next.apiKey ? '[redacted]' : '';
|
|
70
|
+
if ('accessKeyId' in next && next.accessKeyId) next.accessKeyId = '[redacted]';
|
|
71
|
+
if ('secretAccessKey' in next && next.secretAccessKey) next.secretAccessKey = '[redacted]';
|
|
72
|
+
redactedLlm[provider] = next;
|
|
73
|
+
}
|
|
74
|
+
return { ...config, llmConfig: redactedLlm };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function getDeploymentHandler(input, _ctx) {
|
|
78
|
+
const { id } = input || {};
|
|
79
|
+
if (!id) throw new Error('id is required');
|
|
80
|
+
const dep = await DeploymentRepository.findById(id);
|
|
81
|
+
if (!dep) throw new Error(`Deployment not found: ${id}`);
|
|
82
|
+
const summary = summarizeDeployment(dep);
|
|
83
|
+
return {
|
|
84
|
+
...summary,
|
|
85
|
+
config: redactConfigCredentials(dep.config),
|
|
86
|
+
botSummary: dep.config?.botSummary || null,
|
|
87
|
+
documentIds: dep.documentIds,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function loadConnectedDeployment(id) {
|
|
92
|
+
if (!id) throw new Error('id is required');
|
|
93
|
+
const dep = await DeploymentRepository.findById(id);
|
|
94
|
+
if (!dep) throw new Error(`Deployment not found: ${id}`);
|
|
95
|
+
if (!dep.url) throw new Error(`Deployment ${id} has no URL — bot is not connected`);
|
|
96
|
+
return dep;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function proxyJson(dep, path, opts) {
|
|
100
|
+
let response;
|
|
101
|
+
try {
|
|
102
|
+
response = await fetchFromBot(dep, path, opts);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new Error(`Could not reach bot: ${err.message || err.name}`);
|
|
105
|
+
}
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
const text = await response.text().catch(() => '');
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Bot returned ${response.status}: ${text.slice(0, 200)}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
await DeploymentRepository.touchLastSeen(dep.id).catch(() => {});
|
|
113
|
+
return response.json();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Both list and full-dump flows hit /api/conversations/export rather than
|
|
117
|
+
// /api/conversations. The list endpoint has a guardrail that returns 0 rows
|
|
118
|
+
// unless conversationId / startDate / endDate is provided, which surfaced as
|
|
119
|
+
// "0 results but the bot clearly has conversations" through MCP.
|
|
120
|
+
async function fetchExport(dep, { startDate, endDate }) {
|
|
121
|
+
const qs = new URLSearchParams();
|
|
122
|
+
if (startDate) qs.set('startDate', String(startDate));
|
|
123
|
+
if (endDate) qs.set('endDate', String(endDate));
|
|
124
|
+
const path = `/api/conversations/export${qs.toString() ? `?${qs.toString()}` : ''}`;
|
|
125
|
+
return proxyJson(dep, path, { timeoutMs: 60000 });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function queryConversationsHandler(input, _ctx) {
|
|
129
|
+
const { id, since, until } = input || {};
|
|
130
|
+
const dep = await loadConnectedDeployment(id);
|
|
131
|
+
const data = await fetchExport(dep, { startDate: since, endDate: until });
|
|
132
|
+
const conversations = (Array.isArray(data) ? data : []).map((c) => ({
|
|
133
|
+
conversationId: c.conversationId,
|
|
134
|
+
startedAt: c.startedAt,
|
|
135
|
+
lastActivity: c.lastActivity,
|
|
136
|
+
turnCount: c.turnCount,
|
|
137
|
+
}));
|
|
138
|
+
return { botName: dep.botName, total: conversations.length, conversations };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The bot's /api/conversations/:id endpoint emits raw snake_case columns, while
|
|
142
|
+
// /api/conversations/export hand-maps to camelCase. Normalize the by-id turn
|
|
143
|
+
// shape so consumers see one casing across both tools.
|
|
144
|
+
function normalizeTurn(t) {
|
|
145
|
+
if (!t || typeof t !== 'object') return t;
|
|
146
|
+
return {
|
|
147
|
+
id: t.id,
|
|
148
|
+
conversationId: t.conversation_id,
|
|
149
|
+
turn: t.turn,
|
|
150
|
+
timestamp: t.timestamp,
|
|
151
|
+
userPrompt: t.user_prompt,
|
|
152
|
+
llmResponse: t.llm_response,
|
|
153
|
+
machineState: t.machine_state,
|
|
154
|
+
ragContext: t.rag_context,
|
|
155
|
+
contentHash: t.content_hash,
|
|
156
|
+
chainHash: t.chain_hash,
|
|
157
|
+
eventType: t.event_type,
|
|
158
|
+
handoffHash: t.handoff_hash,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function getConversationHandler(input, _ctx) {
|
|
163
|
+
const { id, conversationId } = input || {};
|
|
164
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
165
|
+
const dep = await loadConnectedDeployment(id);
|
|
166
|
+
const data = await proxyJson(dep, `/api/conversations/${encodeURIComponent(conversationId)}`);
|
|
167
|
+
return {
|
|
168
|
+
...data,
|
|
169
|
+
turns: Array.isArray(data?.turns) ? data.turns.map(normalizeTurn) : data?.turns,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function exportConversationsHandler(input, _ctx) {
|
|
174
|
+
const { id, startDate, endDate } = input || {};
|
|
175
|
+
const dep = await loadConnectedDeployment(id);
|
|
176
|
+
const data = await fetchExport(dep, { startDate, endDate });
|
|
177
|
+
return { botName: dep.botName, conversations: data };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function querySubmissionsHandler(input, _ctx) {
|
|
181
|
+
const { id, limit, offset, since, until } = input || {};
|
|
182
|
+
const dep = await loadConnectedDeployment(id);
|
|
183
|
+
const qs = new URLSearchParams();
|
|
184
|
+
if (limit != null) qs.set('limit', String(limit));
|
|
185
|
+
if (offset != null) qs.set('offset', String(offset));
|
|
186
|
+
if (since) qs.set('since', String(since));
|
|
187
|
+
if (until) qs.set('until', String(until));
|
|
188
|
+
const path = `/api/forms${qs.toString() ? `?${qs.toString()}` : ''}`;
|
|
189
|
+
const data = await proxyJson(dep, path);
|
|
190
|
+
return { botName: dep.botName, ...data };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function verifyChainHandler(input, _ctx) {
|
|
194
|
+
const { id, conversationId } = input || {};
|
|
195
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
196
|
+
const dep = await loadConnectedDeployment(id);
|
|
197
|
+
return proxyJson(dep, `/verify/${encodeURIComponent(conversationId)}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function registerOperateTools() {
|
|
201
|
+
// Deployment metadata — always available. No transcript data, just rows
|
|
202
|
+
// from the control plane's deployments table.
|
|
203
|
+
registerTool({
|
|
204
|
+
name: 'list_deployments',
|
|
205
|
+
description:
|
|
206
|
+
'List bots known to the control plane. Returns id, name, status, URL, connection state, and cloud metadata. Filter by status (saved | building | ready | stale | build_failed) or mode (local | cloud).',
|
|
207
|
+
inputSchema: {
|
|
208
|
+
type: 'object',
|
|
209
|
+
properties: {
|
|
210
|
+
status: { type: 'string', description: 'Filter by deployment status.' },
|
|
211
|
+
mode: { type: 'string', enum: ['local', 'cloud'], description: 'Filter by local-only or cloud-deployed bots.' },
|
|
212
|
+
limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 },
|
|
213
|
+
offset: { type: 'integer', minimum: 0, default: 0 },
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
handler: listDeploymentsHandler,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
registerTool({
|
|
220
|
+
name: 'get_deployment',
|
|
221
|
+
description:
|
|
222
|
+
'Get the full deployment row for one bot: identity, enabled protocols, generated configs, document ids, and cloud state. Reads from the control plane SQLite only — no proxy to the bot.',
|
|
223
|
+
inputSchema: {
|
|
224
|
+
type: 'object',
|
|
225
|
+
properties: {
|
|
226
|
+
id: { type: 'string', description: 'Deployment id.' },
|
|
227
|
+
},
|
|
228
|
+
required: ['id'],
|
|
229
|
+
},
|
|
230
|
+
handler: getDeploymentHandler,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Transcript-touching tools. The proxy reads keep conversation data inside
|
|
234
|
+
// the bot's SQLite — the model sees them but the control-plane DB never does.
|
|
235
|
+
registerTool({
|
|
236
|
+
name: 'query_conversations',
|
|
237
|
+
description:
|
|
238
|
+
'List conversation summaries on a connected bot (one entry per conversation — id, started_at, last_activity, turn_count). Proxies through to the bot — conversation rows live in the bot SQLite, never the control plane. Optional since / until ISO bounds filter on the first-turn timestamp. Use get_conversation for the full turn list, or export_conversations to pull every turn in one shot.',
|
|
239
|
+
inputSchema: {
|
|
240
|
+
type: 'object',
|
|
241
|
+
properties: {
|
|
242
|
+
id: { type: 'string', description: 'Deployment id.' },
|
|
243
|
+
since: { type: 'string', description: 'ISO timestamp lower bound on first-turn timestamp.' },
|
|
244
|
+
until: { type: 'string', description: 'ISO timestamp upper bound on first-turn timestamp.' },
|
|
245
|
+
},
|
|
246
|
+
required: ['id'],
|
|
247
|
+
},
|
|
248
|
+
handler: queryConversationsHandler,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
registerTool({
|
|
252
|
+
name: 'get_conversation',
|
|
253
|
+
description:
|
|
254
|
+
'Get the full turn list for one conversation on a connected bot. Proxies through to the bot.',
|
|
255
|
+
inputSchema: {
|
|
256
|
+
type: 'object',
|
|
257
|
+
properties: {
|
|
258
|
+
id: { type: 'string', description: 'Deployment id.' },
|
|
259
|
+
conversationId: { type: 'string', description: 'Conversation id on the bot.' },
|
|
260
|
+
},
|
|
261
|
+
required: ['id', 'conversationId'],
|
|
262
|
+
},
|
|
263
|
+
handler: getConversationHandler,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
registerTool({
|
|
267
|
+
name: 'export_conversations',
|
|
268
|
+
description:
|
|
269
|
+
'Bulk export full conversations on a connected bot, including every turn. Optional startDate / endDate ISO bounds filter on the conversation\'s first turn. Returns one entry per conversation with the full turn list nested under each — use sparingly on bots with many conversations.',
|
|
270
|
+
inputSchema: {
|
|
271
|
+
type: 'object',
|
|
272
|
+
properties: {
|
|
273
|
+
id: { type: 'string', description: 'Deployment id.' },
|
|
274
|
+
startDate: { type: 'string', description: 'ISO timestamp lower bound on first-turn timestamp.' },
|
|
275
|
+
endDate: { type: 'string', description: 'ISO timestamp upper bound on first-turn timestamp.' },
|
|
276
|
+
},
|
|
277
|
+
required: ['id'],
|
|
278
|
+
},
|
|
279
|
+
handler: exportConversationsHandler,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
registerTool({
|
|
283
|
+
name: 'query_submissions',
|
|
284
|
+
description:
|
|
285
|
+
'List form-gathering submissions on a connected bot. Proxies through to the bot.',
|
|
286
|
+
inputSchema: {
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: {
|
|
289
|
+
id: { type: 'string', description: 'Deployment id.' },
|
|
290
|
+
limit: { type: 'integer', minimum: 1, maximum: 200 },
|
|
291
|
+
offset: { type: 'integer', minimum: 0 },
|
|
292
|
+
since: { type: 'string' },
|
|
293
|
+
until: { type: 'string' },
|
|
294
|
+
},
|
|
295
|
+
required: ['id'],
|
|
296
|
+
},
|
|
297
|
+
handler: querySubmissionsHandler,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
registerTool({
|
|
301
|
+
name: 'verify_chain',
|
|
302
|
+
description:
|
|
303
|
+
'Walk the tamper-evident hash chain for one conversation. Returns the verification result from the bot. See docs/turn-hashing.md for the chain semantics.',
|
|
304
|
+
inputSchema: {
|
|
305
|
+
type: 'object',
|
|
306
|
+
properties: {
|
|
307
|
+
id: { type: 'string', description: 'Deployment id.' },
|
|
308
|
+
conversationId: { type: 'string', description: 'Conversation id on the bot.' },
|
|
309
|
+
},
|
|
310
|
+
required: ['id', 'conversationId'],
|
|
311
|
+
},
|
|
312
|
+
handler: verifyChainHandler,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map the wizard's in-progress formData into the shape the bot's client
|
|
3
|
+
* (lite-template/client/index.html) expects from a deployed container's
|
|
4
|
+
* `/context` endpoint. This is what the preview iframe reads via
|
|
5
|
+
* `window.__INITIAL_CONFIG__`.
|
|
6
|
+
*
|
|
7
|
+
* Production source of truth: mojulo-lite/lite-template/server.js — the
|
|
8
|
+
* `/` route (line ~186) and `/context` route (line ~963) build the same
|
|
9
|
+
* object from on-disk config files. We mirror that shape here, but pulled
|
|
10
|
+
* from React state instead.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { buildLLMConfig } from '@/lib/config-builder';
|
|
14
|
+
|
|
15
|
+
function safeParseFormJson(json) {
|
|
16
|
+
if (!json) return null;
|
|
17
|
+
try {
|
|
18
|
+
return typeof json === 'string' ? JSON.parse(json) : json;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the `botContext` payload from wizard formData + enabledProtocols.
|
|
26
|
+
* Returns null if the bare-minimum LLM config isn't ready yet (no provider
|
|
27
|
+
* or no credential) so the preview can show a setup hint instead of booting
|
|
28
|
+
* a half-configured bot.
|
|
29
|
+
*
|
|
30
|
+
* Credential can come in three shapes:
|
|
31
|
+
* - formData.apiKey pasted plaintext (rides in the llm block)
|
|
32
|
+
* - formData.apiKeyId saved-key reference (decrypted server-side at
|
|
33
|
+
* /api/preview/chat — mirrors the deploy path)
|
|
34
|
+
* - formData.hasStoredApiKey + formData.editDeploymentId
|
|
35
|
+
* edit mode reusing the existing on-file key:
|
|
36
|
+
* the chat route looks it up from the deployment
|
|
37
|
+
* row server-side. The browser never sees the
|
|
38
|
+
* plaintext.
|
|
39
|
+
*/
|
|
40
|
+
export function buildPreviewConfig(formData, enabledProtocols) {
|
|
41
|
+
// Ollama is credential-less — provider + model are sufficient to boot
|
|
42
|
+
// the preview. The host falls back to LLM_PROVIDERS.ollama.defaultHost in
|
|
43
|
+
// buildLLMConfig when ollamaHost is blank.
|
|
44
|
+
const isOllama = formData?.provider === 'ollama';
|
|
45
|
+
const hasCredential = isOllama || Boolean(
|
|
46
|
+
formData?.apiKey ||
|
|
47
|
+
formData?.apiKeyId ||
|
|
48
|
+
(formData?.hasStoredApiKey && formData?.editDeploymentId),
|
|
49
|
+
);
|
|
50
|
+
if (!formData?.provider || !hasCredential || !formData?.model) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const llm = buildLLMConfig(formData.provider, formData.apiKey || '', formData.model, {
|
|
55
|
+
maxTokens: 2048,
|
|
56
|
+
ollamaHost: formData.ollamaHost,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const formStructure = enabledProtocols.formGathering
|
|
60
|
+
? safeParseFormJson(formData.generatedFormJson)
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
const botContext = {
|
|
64
|
+
name: formData.botName || 'Preview Bot',
|
|
65
|
+
chatDisplayName:
|
|
66
|
+
formData.uiSettings?.chatDisplayName || formData.chatDisplayName || 'Bot',
|
|
67
|
+
placeholder:
|
|
68
|
+
formData.uiSettings?.placeholder ||
|
|
69
|
+
formData.placeholder ||
|
|
70
|
+
'Type your message...',
|
|
71
|
+
firstMessage:
|
|
72
|
+
formData.firstMessage || 'Hello! How can I help you today?',
|
|
73
|
+
suggestedPrompts: formData.suggestedPrompts || [],
|
|
74
|
+
|
|
75
|
+
isForm: Boolean(enabledProtocols.formGathering && formStructure),
|
|
76
|
+
formStructure: formStructure || undefined,
|
|
77
|
+
formCompletionWebhook: formData.formCompletionWebhook || '',
|
|
78
|
+
afterSubmitChatMessage: formData.afterSubmitChatMessage || '',
|
|
79
|
+
formSendHome: Boolean(formData.formSendHome),
|
|
80
|
+
termsAndConditions: formData.termsAndConditions || '',
|
|
81
|
+
|
|
82
|
+
isCalendar: Boolean(
|
|
83
|
+
enabledProtocols.appointments &&
|
|
84
|
+
formData.appointmentDestinations?.length > 0,
|
|
85
|
+
),
|
|
86
|
+
calendarConfig: enabledProtocols.appointments
|
|
87
|
+
? formData.appointmentDestinations || []
|
|
88
|
+
: [],
|
|
89
|
+
|
|
90
|
+
isTriage: Boolean(
|
|
91
|
+
enabledProtocols.triage && formData.triageRoutes?.length > 0,
|
|
92
|
+
),
|
|
93
|
+
triageRoutes: enabledProtocols.triage ? formData.triageRoutes || [] : [],
|
|
94
|
+
|
|
95
|
+
isOpticalRead: Boolean(
|
|
96
|
+
enabledProtocols.opticalRead && formData.opticalReadFields?.length > 0,
|
|
97
|
+
),
|
|
98
|
+
opticalReadFields: enabledProtocols.opticalRead
|
|
99
|
+
? formData.opticalReadFields || []
|
|
100
|
+
: [],
|
|
101
|
+
opticalReadShowUploadOnStart: Boolean(
|
|
102
|
+
enabledProtocols.opticalRead && formData.opticalReadShowUploadOnStart,
|
|
103
|
+
),
|
|
104
|
+
opticalReadAfterSubmitMessage: enabledProtocols.opticalRead
|
|
105
|
+
? formData.opticalReadAfterSubmitMessage || ''
|
|
106
|
+
: '',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// The shim uses these to translate /chat calls into /api/preview/chat
|
|
110
|
+
// calls. They aren't part of the deployed bot's /context shape — they
|
|
111
|
+
// ride along as `__previewMeta` so the production client never sees them.
|
|
112
|
+
const previewMeta = {
|
|
113
|
+
objective:
|
|
114
|
+
formData.objective || `Help users as ${formData.botName || 'a bot'}.`,
|
|
115
|
+
enabledProtocols,
|
|
116
|
+
protocolData: {
|
|
117
|
+
...(formStructure ? { formStructure } : {}),
|
|
118
|
+
...(enabledProtocols.appointments
|
|
119
|
+
? { appointments: formData.appointmentDestinations || [] }
|
|
120
|
+
: {}),
|
|
121
|
+
...(enabledProtocols.triage
|
|
122
|
+
? { triage: formData.triageRoutes || [] }
|
|
123
|
+
: {}),
|
|
124
|
+
...(enabledProtocols.opticalRead
|
|
125
|
+
? { opticalRead: { fields: formData.opticalReadFields || [] } }
|
|
126
|
+
: {}),
|
|
127
|
+
},
|
|
128
|
+
llm,
|
|
129
|
+
apiKeyId: formData.apiKeyId || null,
|
|
130
|
+
editDeploymentId: formData.editDeploymentId || null,
|
|
131
|
+
documentIds: (formData.documents || []).map((d) => d.id),
|
|
132
|
+
embeddingsStorageKey: formData.embeddings?.storageKey || null,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return { botContext, previewMeta };
|
|
136
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Lite runs locally for a single operator — no need to rate-limit ourselves.
|
|
2
|
+
// Keep the shape the copied stream route expects.
|
|
3
|
+
|
|
4
|
+
export const RateLimitPresets = {
|
|
5
|
+
expensive: { windowMs: 60_000, max: 60 },
|
|
6
|
+
default: { windowMs: 60_000, max: 300 },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function checkRateLimit(_request, _options) {
|
|
10
|
+
return { allowed: true };
|
|
11
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { ApiKeyRepository } from './db/repositories/apiKeys.js';
|
|
2
|
+
import { decryptApiKey } from './deployment-auth.js';
|
|
3
|
+
import { buildBedrockModelId } from './llm-providers.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Inject a saved (encrypted) provider credential into a deployment config so
|
|
7
|
+
* the browser never has to handle plaintext when picking a saved key. The
|
|
8
|
+
* wizard sends apiKeyId; the route hands the deployment config + id here, and
|
|
9
|
+
* we patch config.llm[provider] in place. Throws if the id is unknown or its
|
|
10
|
+
* provider doesn't match config.llm.provider — both indicate a stale UI state
|
|
11
|
+
* worth surfacing.
|
|
12
|
+
*/
|
|
13
|
+
export async function resolveSavedApiKeyIntoConfig(config, apiKeyId) {
|
|
14
|
+
if (!apiKeyId || !config?.llm?.provider) return config;
|
|
15
|
+
|
|
16
|
+
const record = await ApiKeyRepository.findById(apiKeyId);
|
|
17
|
+
if (!record) {
|
|
18
|
+
throw new Error(`Saved API key ${apiKeyId} not found`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const provider = config.llm.provider;
|
|
22
|
+
if (record.provider !== provider) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Saved API key provider "${record.provider}" does not match selected provider "${provider}"`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const plaintext = decryptApiKey(record.encryptedKey);
|
|
29
|
+
|
|
30
|
+
if (provider === 'bedrock') {
|
|
31
|
+
const credentials = JSON.parse(plaintext);
|
|
32
|
+
const region = credentials.region || 'us-east-1';
|
|
33
|
+
const baseModel = config.llm.bedrock?.model || '';
|
|
34
|
+
config.llm.bedrock = {
|
|
35
|
+
...config.llm.bedrock,
|
|
36
|
+
region,
|
|
37
|
+
useIamRole: credentials.useIamRole || false,
|
|
38
|
+
accessKeyId: credentials.accessKeyId || null,
|
|
39
|
+
secretAccessKey: credentials.secretAccessKey || null,
|
|
40
|
+
model: buildBedrockModelId(baseModel, region),
|
|
41
|
+
};
|
|
42
|
+
} else if (provider === 'ollama') {
|
|
43
|
+
// Ollama's "credential" row stores {"host": "..."} JSON. The host stamps
|
|
44
|
+
// onto config.llm.ollama just like buildLLMConfig would have done from
|
|
45
|
+
// the wizard's ollamaHost field — saved-host references and pasted
|
|
46
|
+
// hosts converge on the same artifact shape.
|
|
47
|
+
const parsed = JSON.parse(plaintext);
|
|
48
|
+
config.llm.ollama = {
|
|
49
|
+
...config.llm.ollama,
|
|
50
|
+
host: parsed.host || config.llm.ollama?.host || '',
|
|
51
|
+
};
|
|
52
|
+
} else {
|
|
53
|
+
config.llm[provider] = {
|
|
54
|
+
...config.llm[provider],
|
|
55
|
+
apiKey: plaintext,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return config;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether a deployment config currently carries a usable provider credential
|
|
64
|
+
* for its selected provider. Computed against the un-redacted config so the
|
|
65
|
+
* GET endpoint can advertise "key on file" without exposing the value. The
|
|
66
|
+
* wizard reads this to gate the credential requirement in edit mode.
|
|
67
|
+
*/
|
|
68
|
+
export function configHasStoredApiKey(config) {
|
|
69
|
+
const provider = config?.llm?.provider;
|
|
70
|
+
if (!provider) return false;
|
|
71
|
+
const block = config.llm[provider];
|
|
72
|
+
if (!block) return false;
|
|
73
|
+
if (provider === 'bedrock') {
|
|
74
|
+
return !!(block.useIamRole || (block.accessKeyId && block.secretAccessKey));
|
|
75
|
+
}
|
|
76
|
+
if (provider === 'ollama') {
|
|
77
|
+
// Host is the only transport field. Empty host means the wizard will
|
|
78
|
+
// fall back to LLM_PROVIDERS.ollama.defaultHost at build time, which is
|
|
79
|
+
// still a usable artifact — but the "on file" claim should be honest
|
|
80
|
+
// about whether the user actually configured one.
|
|
81
|
+
return !!block.host;
|
|
82
|
+
}
|
|
83
|
+
return !!block.apiKey;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Strip provider credentials from a deployment config before returning it
|
|
88
|
+
* to the browser. The deployment row stores plaintext (it's what gets baked
|
|
89
|
+
* into the artifact's .env), but edit-mode hydration shouldn't have to
|
|
90
|
+
* surface it to populate the wizard. Operates on a clone — caller's object
|
|
91
|
+
* is not mutated.
|
|
92
|
+
*/
|
|
93
|
+
export function redactApiKeysFromConfig(config) {
|
|
94
|
+
if (!config?.llm) return config;
|
|
95
|
+
const clone = structuredClone(config);
|
|
96
|
+
for (const key of Object.keys(clone.llm)) {
|
|
97
|
+
if (key === 'provider') continue;
|
|
98
|
+
const block = clone.llm[key];
|
|
99
|
+
if (!block || typeof block !== 'object') continue;
|
|
100
|
+
if ('apiKey' in block) block.apiKey = '';
|
|
101
|
+
if ('accessKeyId' in block) block.accessKeyId = null;
|
|
102
|
+
if ('secretAccessKey' in block) block.secretAccessKey = null;
|
|
103
|
+
}
|
|
104
|
+
return clone;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* When PATCH'ing an existing deployment without a fresh credential — the
|
|
109
|
+
* wizard hydrated from the redacted GET and the user didn't paste/pick a
|
|
110
|
+
* new key — copy the previously-stored credentials forward so the artifact
|
|
111
|
+
* keeps working. Mutates newConfig in place. Provider-switch is detected by
|
|
112
|
+
* comparing llm.provider; on switch we don't carry credentials across.
|
|
113
|
+
*/
|
|
114
|
+
export function preserveExistingCredentials(newConfig, oldConfig) {
|
|
115
|
+
const provider = newConfig?.llm?.provider;
|
|
116
|
+
if (!provider || !oldConfig?.llm) return newConfig;
|
|
117
|
+
if (oldConfig.llm.provider !== provider) return newConfig;
|
|
118
|
+
|
|
119
|
+
const newBlock = newConfig.llm[provider];
|
|
120
|
+
const oldBlock = oldConfig.llm[provider];
|
|
121
|
+
if (!newBlock || !oldBlock) return newConfig;
|
|
122
|
+
|
|
123
|
+
if (provider === 'bedrock') {
|
|
124
|
+
const newHasCreds = newBlock.useIamRole || (newBlock.accessKeyId && newBlock.secretAccessKey);
|
|
125
|
+
if (!newHasCreds) {
|
|
126
|
+
newBlock.useIamRole = oldBlock.useIamRole;
|
|
127
|
+
newBlock.accessKeyId = oldBlock.accessKeyId;
|
|
128
|
+
newBlock.secretAccessKey = oldBlock.secretAccessKey;
|
|
129
|
+
newBlock.region = newBlock.region || oldBlock.region;
|
|
130
|
+
}
|
|
131
|
+
} else if (provider === 'ollama') {
|
|
132
|
+
// Ollama carries host instead of a credential. Edit mode that doesn't
|
|
133
|
+
// re-submit a host should keep the previously stored one.
|
|
134
|
+
if (!newBlock.host) {
|
|
135
|
+
newBlock.host = oldBlock.host || '';
|
|
136
|
+
}
|
|
137
|
+
} else if (!newBlock.apiKey) {
|
|
138
|
+
newBlock.apiKey = oldBlock.apiKey || '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return newConfig;
|
|
142
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const STORAGE_ROOT =
|
|
5
|
+
process.env.STORAGE_ROOT || path.join(process.cwd(), 'data', 'storage');
|
|
6
|
+
|
|
7
|
+
function resolveStoragePath(key) {
|
|
8
|
+
// Prevent traversal escape
|
|
9
|
+
const safe = key.replace(/^\/+/, '').replace(/\.\./g, '_');
|
|
10
|
+
return path.join(STORAGE_ROOT, safe);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function ensureDir(filePath) {
|
|
14
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function uploadFile(key, buffer, _legacyIgnored, _opts = {}) {
|
|
18
|
+
const dest = resolveStoragePath(key);
|
|
19
|
+
await ensureDir(dest);
|
|
20
|
+
await fs.writeFile(dest, buffer);
|
|
21
|
+
return { storagePath: key };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function downloadToBuffer(key) {
|
|
25
|
+
const source = resolveStoragePath(key);
|
|
26
|
+
return fs.readFile(source);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function deleteFile(key) {
|
|
30
|
+
const target = resolveStoragePath(key);
|
|
31
|
+
try {
|
|
32
|
+
await fs.unlink(target);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err.code !== 'ENOENT') throw err;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getStorageRoot() {
|
|
39
|
+
return STORAGE_ROOT;
|
|
40
|
+
}
|