@chatcops/server 0.5.0 → 0.7.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.
Files changed (81) hide show
  1. package/README.md +4 -4
  2. package/dist/adapters/ai-sdk.d.ts +12 -0
  3. package/dist/adapters/ai-sdk.d.ts.map +1 -0
  4. package/dist/adapters/ai-sdk.js +27 -0
  5. package/dist/adapters/ai-sdk.js.map +1 -0
  6. package/dist/adapters/cloudflare.d.ts.map +1 -1
  7. package/dist/adapters/cloudflare.js +9 -83
  8. package/dist/adapters/cloudflare.js.map +1 -1
  9. package/dist/adapters/express.d.ts +6 -2
  10. package/dist/adapters/express.d.ts.map +1 -1
  11. package/dist/adapters/express.js +102 -30
  12. package/dist/adapters/express.js.map +1 -1
  13. package/dist/adapters/index.d.ts +1 -0
  14. package/dist/adapters/index.d.ts.map +1 -1
  15. package/dist/adapters/index.js +1 -0
  16. package/dist/adapters/index.js.map +1 -1
  17. package/dist/adapters/vercel.d.ts.map +1 -1
  18. package/dist/adapters/vercel.js +7 -74
  19. package/dist/adapters/vercel.js.map +1 -1
  20. package/dist/client-ip.d.ts +37 -0
  21. package/dist/client-ip.d.ts.map +1 -0
  22. package/dist/client-ip.js +57 -0
  23. package/dist/client-ip.js.map +1 -0
  24. package/dist/config.d.ts +136 -8
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +15 -1
  27. package/dist/config.js.map +1 -1
  28. package/dist/examples/cloudflare-worker.js +1 -1
  29. package/dist/examples/cloudflare-worker.js.map +1 -1
  30. package/dist/handler.d.ts +52 -1
  31. package/dist/handler.d.ts.map +1 -1
  32. package/dist/handler.js +417 -136
  33. package/dist/handler.js.map +1 -1
  34. package/dist/http.d.ts +69 -0
  35. package/dist/http.d.ts.map +1 -0
  36. package/dist/http.js +262 -0
  37. package/dist/http.js.map +1 -0
  38. package/dist/index.d.ts +4 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/knowledge.d.ts +18 -0
  43. package/dist/knowledge.d.ts.map +1 -0
  44. package/dist/knowledge.js +32 -0
  45. package/dist/knowledge.js.map +1 -0
  46. package/dist/rate-limit.d.ts +5 -0
  47. package/dist/rate-limit.d.ts.map +1 -1
  48. package/dist/rate-limit.js +5 -0
  49. package/dist/rate-limit.js.map +1 -1
  50. package/dist/security/origin-policy.d.ts +19 -0
  51. package/dist/security/origin-policy.d.ts.map +1 -0
  52. package/dist/security/origin-policy.js +60 -0
  53. package/dist/security/origin-policy.js.map +1 -0
  54. package/dist/security/origin.d.ts +5 -0
  55. package/dist/security/origin.d.ts.map +1 -0
  56. package/dist/security/origin.js +47 -0
  57. package/dist/security/origin.js.map +1 -0
  58. package/dist/security/session-token.d.ts +65 -0
  59. package/dist/security/session-token.d.ts.map +1 -0
  60. package/dist/security/session-token.js +338 -0
  61. package/dist/security/session-token.js.map +1 -0
  62. package/dist/security/sessions.d.ts +72 -0
  63. package/dist/security/sessions.d.ts.map +1 -0
  64. package/dist/security/sessions.js +216 -0
  65. package/dist/security/sessions.js.map +1 -0
  66. package/dist/security/startup-checks.d.ts +45 -0
  67. package/dist/security/startup-checks.d.ts.map +1 -0
  68. package/dist/security/startup-checks.js +124 -0
  69. package/dist/security/startup-checks.js.map +1 -0
  70. package/dist/security/turnstile.d.ts +24 -0
  71. package/dist/security/turnstile.d.ts.map +1 -0
  72. package/dist/security/turnstile.js +93 -0
  73. package/dist/security/turnstile.js.map +1 -0
  74. package/dist/visitor-context.d.ts.map +1 -1
  75. package/dist/visitor-context.js +7 -0
  76. package/dist/visitor-context.js.map +1 -1
  77. package/dist/webhook.d.ts +3 -2
  78. package/dist/webhook.d.ts.map +1 -1
  79. package/dist/webhook.js +5 -2
  80. package/dist/webhook.js.map +1 -1
  81. package/package.json +22 -5
package/dist/handler.js CHANGED
@@ -1,49 +1,204 @@
1
- import { createProvider, ConversationManager, AnalyticsCollector, toolToDefinition, } from '@chatcops/core';
1
+ import { createProvider, isAIProvider, ConversationManager, AnalyticsCollector, ANALYTICS_EVENTS, BufferedEventSink, statsFromSink, createBudgetGuard, withDefaultBudgets, createRateLimiter, memoryStorage, describeInvalidRequest, errorBody, partsOf, PROTOCOL_VERSION, runTurn, statusForError, toLegacyChunk, toolRegistry, toolToDefinition, validateTools, consoleLogger, } from '@chatcops/core';
2
2
  import { chatRequestSchema } from './config.js';
3
+ import { allowedOriginsOf } from './security/origin-policy.js';
4
+ import { DEFAULT_SESSION_TTL_SEC, createSessions } from './security/sessions.js';
5
+ import { logSecurityAtStartup } from './security/startup-checks.js';
3
6
  import { VISITOR_CONTEXT_INSTRUCTION, buildVisitorContext, isLocaleTag, withVisitorContext } from './visitor-context.js';
4
- import { RateLimiter } from './rate-limit.js';
5
7
  import { WebhookDispatcher } from './webhook.js';
8
+ import { historyBefore, knowledgeContext } from './knowledge.js';
6
9
  export function createChatHandler(config) {
10
+ const log = config.logger ?? consoleLogger();
7
11
  if (config.i18n) {
8
- console.warn('[chatcops] The `i18n` option is deprecated and ignored. Set the widget text with its `strings` option.');
12
+ log.warn('deprecated_option', 'The `i18n` option is deprecated and ignored. Set the widget text with its `strings` option.');
9
13
  }
10
- const providerPromise = createProvider(config.provider);
11
- const conversations = new ConversationManager({ store: config.conversationStore });
12
- const rateLimiter = config.rateLimit ? new RateLimiter(config.rateLimit) : new RateLimiter();
13
- const analytics = config.analytics ? new AnalyticsCollector() : null;
14
- const webhooks = config.webhooks?.length ? new WebhookDispatcher(config.webhooks) : null;
14
+ const providerPromise = isAIProvider(config.provider)
15
+ ? Promise.resolve(config.provider)
16
+ : createProvider({ logger: log, ...config.provider });
17
+ // A failed setup (unknown provider type, SDK that throws on a missing key)
18
+ // is logged once here and reported to visitors per request below. Without
19
+ // this handler it would be an unhandled rejection and stop the process.
20
+ // Only the message is logged: SDK errors can carry the config or headers.
21
+ providerPromise.catch((err) => {
22
+ log.error('provider_setup_failed', `Provider setup failed: ${err instanceof Error ? err.message : String(err)}`);
23
+ });
24
+ if (config.storage && config.conversationStore) {
25
+ throw new Error('Set `storage` or `conversationStore`, not both. `storage.conversations` replaces `conversationStore`.');
26
+ }
27
+ if (!config.storage && !config.conversationStore && onServerless() && !warnedMemoryStorage) {
28
+ warnedMemoryStorage = true;
29
+ log.warn('memory_storage', 'No `storage` is set, so conversations are kept in memory. On serverless each instance has its own memory, so a conversation can lose its history between messages. Use a store package such as @chatcops/store-postgres or @chatcops/store-redis.');
30
+ }
31
+ const storage = config.storage ?? (config.conversationStore ? { conversations: config.conversationStore } : undefined);
32
+ const conversations = new ConversationManager({ ...(storage ? { storage } : {}), logger: log });
33
+ // Counted in the storage's counters when it has them, so limits and
34
+ // budgets hold across every server instance; otherwise in this process.
35
+ // Nothing runs on a timer, which Workers don't allow at global scope.
36
+ const counters = (storage?.counters ?? memoryStorage().counters);
37
+ const budgets = withDefaultBudgets(config.budgets);
38
+ const budgetGuard = budgets ? createBudgetGuard(counters, budgets.budgets) : undefined;
39
+ if (budgets && !loggedBudgets) {
40
+ loggedBudgets = true;
41
+ log.info('budgets', describeBudgets(budgets.budgets, budgets.defaulted));
42
+ }
43
+ const rateLimit = { maxRequests: 30, windowMs: 60_000, ...config.rateLimit };
44
+ if (rateLimit.maxRequests <= 0)
45
+ throw new Error('maxRequests must be greater than 0');
46
+ const rateLimiter = createRateLimiter(counters, {
47
+ limit: rateLimit.maxRequests,
48
+ windowMs: rateLimit.windowMs,
49
+ });
50
+ // Events always count in this process, and go to the storage's event sink
51
+ // too when it has one, so stats can cover every instance.
52
+ const collector = config.analytics ? new AnalyticsCollector() : null;
53
+ const eventSink = config.analytics ? storage?.events : undefined;
54
+ const bufferedEvents = eventSink ? new BufferedEventSink(eventSink, { logger: log }) : undefined;
55
+ const analytics = collector
56
+ ? {
57
+ track(type, data) {
58
+ collector.track(type, data);
59
+ bufferedEvents?.track(type, data);
60
+ },
61
+ }
62
+ : null;
63
+ const webhooks = config.webhooks?.length ? new WebhookDispatcher(config.webhooks, undefined, log) : null;
15
64
  const toolDefs = config.tools?.map(toolToDefinition) ?? [];
16
- const toolsByName = new Map((config.tools ?? []).map((tool) => [tool.name, tool]));
17
- // Periodic cleanup
18
- const cleanupInterval = setInterval(() => rateLimiter.cleanup(), 60_000);
65
+ // A broken tool definition is a code mistake, so fail here, at startup,
66
+ // rather than as a provider error on the first message.
67
+ validateTools(toolDefs);
68
+ const tools = toolRegistry(config.tools);
69
+ const sessions = createSessions(config, async (id) => {
70
+ const conversation = await conversations.getOrCreate(id);
71
+ const uid = conversation.metadata?.uid;
72
+ return { unused: conversation.messages.length === 0, ...(typeof uid === 'string' ? { uid } : {}) };
73
+ });
74
+ // A tier's budgets replace those scopes of the base ones; counts are shared.
75
+ // Built here so a mistake in one fails at startup.
76
+ const tierGuards = new Map(budgets
77
+ ? Object.entries(config.identity?.tiers ?? {}).map(([tier, tierBudgets]) => [
78
+ tier,
79
+ createBudgetGuard(counters, { ...budgets.budgets, ...tierBudgets }),
80
+ ])
81
+ : []);
82
+ const guardFor = (tier) => (tier && tierGuards.get(tier)) || budgetGuard;
83
+ if (!loggedSecurity) {
84
+ loggedSecurity = true;
85
+ logSecurityAtStartup({
86
+ allowedOrigins: allowedOriginsOf(config),
87
+ sessionSecrets: config.sessionSecrets,
88
+ sessionTtlSec: sessions ? (config.sessionTtlSec ?? DEFAULT_SESSION_TTL_SEC) : undefined,
89
+ requireSession: sessions?.required,
90
+ turnstile: Boolean(config.turnstile),
91
+ }, log);
92
+ }
93
+ const requests = { v1: 0, legacy: 0, 'ai-sdk': 0 };
19
94
  /**
20
- * Streams one reply. Pass `options.signal` to stop when the visitor goes
21
- * away: the provider request is cancelled, nothing more is yielded, and the
22
- * partial reply isn't saved.
95
+ * Starts one reply. Resolves a refusal for a request that can't be
96
+ * answered (protocol v1 sends it as an HTTP status before any stream), or
97
+ * the reply as protocol v1 parts. The conversation stays locked until the
98
+ * parts are read to the end or `return()` is called on them.
23
99
  */
24
- async function* handleChat(body, clientIp, options = {}) {
25
- const { signal } = options;
26
- const provider = await providerPromise;
27
- // Validate request
100
+ async function startChat(body, clientIp, options = {}) {
101
+ const format = options.format ?? 'v1';
102
+ requests[format] += 1;
103
+ if (format === 'legacy' && !loggedLegacyFormat) {
104
+ loggedLegacyFormat = true;
105
+ log.info('legacy_stream_format', 'Answered a request in the 0.5 stream format, from a widget older than 0.6 or a client that doesn\'t send `protocol: 1`. That format keeps working until 1.0.');
106
+ }
28
107
  const parsed = chatRequestSchema.safeParse(body);
29
108
  if (!parsed.success) {
30
- yield JSON.stringify({ error: 'Invalid request', details: parsed.error.flatten() });
31
- return;
109
+ return refuse('invalid_request', { message: describeInvalidRequest(parsed.error) }, { error: 'Invalid request', details: parsed.error.flatten() });
32
110
  }
33
111
  const req = parsed.data;
34
- // Rate limit
112
+ let user;
113
+ if (sessions) {
114
+ const session = await sessions.check(options.authorization, options.origin ?? null, req.conversationId);
115
+ if (!session.ok)
116
+ return refuse(session.code, { message: session.message }, { error: 'provider_error' });
117
+ user = session.claims?.uid ? { uid: session.claims.uid, tier: session.claims.tier } : undefined;
118
+ }
35
119
  if (clientIp) {
36
- const limit = rateLimiter.check(clientIp);
120
+ const limit = await rateLimiter.check(clientIp);
37
121
  if (!limit.allowed) {
38
- yield JSON.stringify({ error: 'rate_limit', retryAfter: limit.retryAfter });
39
- return;
122
+ const retryAfterMs = limit.retryAfterMs ?? 1000;
123
+ // 0.5 widgets read seconds.
124
+ return refuse('rate_limited', { retryAfterMs }, { error: 'rate_limit', retryAfter: Math.ceil(retryAfterMs / 1000) });
125
+ }
126
+ }
127
+ let provider;
128
+ try {
129
+ provider = await providerPromise;
130
+ }
131
+ catch {
132
+ // Already logged where the provider was created.
133
+ return refuse('provider_unavailable', {}, { error: 'provider_error' });
134
+ }
135
+ // One reply per conversation at a time: a second request while a reply
136
+ // is being written would otherwise answer the same history twice.
137
+ const release = await conversations.acquireTurn(req.conversationId);
138
+ if (!release) {
139
+ return refuse('conversation_busy', {}, { error: 'provider_error' });
140
+ }
141
+ return { ok: true, parts: replyParts(provider, req, options.signal, clientIp, release, user) };
142
+ }
143
+ async function* replyParts(provider, req, signal, clientIp, release, user) {
144
+ const pendingWebhooks = new Set();
145
+ try {
146
+ for await (const part of reply(provider, req, signal, pendingWebhooks, clientIp, user)) {
147
+ // Tells the widget to get a session before its next request.
148
+ yield part.type === 'start' && sessions ? { ...part, capabilities: { ...part.capabilities, session: true } } : part;
40
149
  }
41
150
  }
42
- // Get or create conversation
43
- await conversations.getOrCreate(req.conversationId);
44
- const conversationMessages = await conversations.getMessages(req.conversationId);
151
+ finally {
152
+ await release();
153
+ }
154
+ // Keeping the stream open until lead webhooks and analytics writes
155
+ // finish keeps serverless runtimes from cutting them off once the
156
+ // response ends. A failed analytics write is logged, never thrown.
157
+ await awaitPendingWebhooks(pendingWebhooks);
158
+ await bufferedEvents?.flush();
159
+ }
160
+ /**
161
+ * Analytics stats for `range` (default: the last 30 days), counted in the
162
+ * storage's event sink when it can be queried, so they cover every
163
+ * instance. Otherwise they come from this process only, as
164
+ * `getAnalytics()` does. `null` when `analytics` is off.
165
+ */
166
+ async function analyticsStats(range) {
167
+ if (!collector)
168
+ return null;
169
+ if (!bufferedEvents || !eventSink?.query)
170
+ return collector.getStats();
171
+ await bufferedEvents.flush();
172
+ const to = range?.to ?? Date.now() + 1;
173
+ return statsFromSink(eventSink, { from: range?.from ?? to - 30 * DAY_MS, to });
174
+ }
175
+ /**
176
+ * Streams one reply in the 0.5 format, as JSON strings. Pass
177
+ * `options.signal` to stop when the visitor goes away: the provider request
178
+ * is cancelled and nothing more is yielded.
179
+ */
180
+ async function* handleChat(body, clientIp, options = {}) {
181
+ const start = await startChat(body, clientIp, { ...options, format: 'legacy' });
182
+ if (!start.ok) {
183
+ yield JSON.stringify(start.legacy);
184
+ return;
185
+ }
186
+ yield* toLegacyStream(start.parts);
187
+ }
188
+ async function* reply(provider, req, signal, pendingWebhooks, clientIp, user) {
189
+ const conversation = await conversations.getOrCreate(req.conversationId);
190
+ const conversationMessages = conversation.messages;
191
+ // Pre-chat answers are kept from the first message on, so the widget
192
+ // doesn't have to send them again and they can't change mid-conversation.
193
+ const storedVisitor = conversation.metadata?.visitor;
194
+ const newVisitor = storedVisitor ? undefined : allowedUserData(req.userData, config.preChatFields);
195
+ const userData = storedVisitor ?? newVisitor;
45
196
  let assistantToReplace;
46
- if (req.regenerate) {
197
+ if (typeof req.regenerate === 'object') {
198
+ const id = req.regenerate.assistantMessageId;
199
+ assistantToReplace = conversationMessages.find((message) => message.id === id && message.role === 'assistant');
200
+ }
201
+ if (req.regenerate && !assistantToReplace) {
47
202
  const userIndex = req.messageId
48
203
  ? conversationMessages.findIndex((message) => message.id === req.messageId)
49
204
  : findLastIndex(conversationMessages, (message) => message.role === 'user');
@@ -54,11 +209,25 @@ export function createChatHandler(config) {
54
209
  assistantToReplace = [...conversationMessages].reverse().find((message) => message.role === 'assistant');
55
210
  }
56
211
  }
212
+ // A retry of a message that already has a finished reply (the network
213
+ // dropped after the server saved it) gets that reply again instead of a
214
+ // second one. A partial reply is replaced once the new one succeeds.
215
+ if (!req.regenerate && req.messageId) {
216
+ const index = conversationMessages.findIndex((message) => message.id === req.messageId && message.role === 'user');
217
+ const answer = index === -1 ? undefined : conversationMessages[index + 1];
218
+ if (answer?.role === 'assistant') {
219
+ if (answer.status === undefined || answer.status === 'complete') {
220
+ yield* replay(answer, req.conversationId);
221
+ return;
222
+ }
223
+ assistantToReplace = answer;
224
+ }
225
+ }
57
226
  // Track analytics
58
227
  if (analytics && conversationMessages.length === 0) {
59
- analytics.track('conversation:started', { conversationId: req.conversationId });
228
+ analytics.track(ANALYTICS_EVENTS.conversationStarted, { conversationId: req.conversationId });
60
229
  }
61
- analytics?.track('message:sent', { conversationId: req.conversationId });
230
+ analytics?.track(ANALYTICS_EVENTS.messageSent, { conversationId: req.conversationId });
62
231
  // Add user message unless this is a retry of the same message or a regenerate request that already has a user message
63
232
  const hasExistingUserMessage = req.messageId
64
233
  ? conversationMessages.some((message) => message.id === req.messageId)
@@ -76,134 +245,246 @@ export function createChatHandler(config) {
76
245
  content: req.message,
77
246
  timestamp: Date.now(),
78
247
  };
79
- await conversations.addMessage(req.conversationId, userMessage);
248
+ // The signed-in user it belongs to, set once with the first message.
249
+ const metadata = {
250
+ ...(newVisitor ? { visitor: newVisitor } : {}),
251
+ ...(user && conversation.metadata?.uid === undefined ? { uid: user.uid } : {}),
252
+ };
253
+ await conversations.appendMessages(req.conversationId, [userMessage], Object.keys(metadata).length ? { metadata } : undefined);
80
254
  }
81
- // Build system prompt
82
- let systemPrompt = config.systemPrompt;
83
- // Add knowledge context
255
+ // The system prompt is the operator's text and nothing else, so it's the
256
+ // same on every request and providers can cache it. Everything that
257
+ // changes per request goes in the context after it.
258
+ const context = [];
259
+ let sources = [];
84
260
  if (config.knowledge?.length) {
85
- const contexts = await Promise.all(config.knowledge.map((k) => k.getContext(req.message)));
86
- const combined = contexts.filter(Boolean).join('\n\n');
87
- if (combined) {
88
- systemPrompt += '\n\n' + combined;
89
- }
261
+ const found = await knowledgeContext(config.knowledge, req.message, historyBefore(conversationMessages, req), signal);
262
+ if (found.context)
263
+ context.push(found.context);
264
+ sources = found.sources;
90
265
  }
91
266
  // Visitor-supplied page context and pre-chat answers never go into the
92
267
  // system prompt; see visitor-context.ts.
93
- const visitorContext = buildVisitorContext(req);
94
- if (visitorContext) {
95
- systemPrompt += `\n\n${VISITOR_CONTEXT_INSTRUCTION}`;
96
- }
268
+ const visitorContext = buildVisitorContext({ pageContext: req.pageContext, userData });
269
+ if (visitorContext)
270
+ context.push(VISITOR_CONTEXT_INSTRUCTION);
97
271
  if (req.locale && isLocaleTag(req.locale)) {
98
- systemPrompt += `\n\nReply in the visitor's language. Their browser locale is ${req.locale}.`;
272
+ context.push(`Reply in the visitor's language. Their browser locale is ${req.locale}.`);
99
273
  }
100
- // Get conversation messages for context
101
- const storedMessages = await conversations.getMessages(req.conversationId);
274
+ // The history the model sees, trimmed to the token budget
275
+ const storedMessages = await conversations.getHistory(req.conversationId);
102
276
  const messages = withVisitorContext(assistantToReplace
103
277
  ? storedMessages.filter((message) => message.id !== assistantToReplace.id)
104
278
  : storedMessages, visitorContext);
105
- // Stream response
106
- let fullResponse = '';
107
- const pendingEvents = [];
108
- const pendingWebhooks = new Set();
109
- try {
110
- for await (const chunk of provider.chat({
111
- messages,
112
- systemPrompt,
113
- tools: toolDefs.length > 0 ? toolDefs : undefined,
114
- toolTimeoutMs: config.toolTimeoutMs,
115
- maxTokens: config.maxTokens,
116
- temperature: config.temperature,
117
- signal,
118
- toolExecutor: async (toolCall) => {
119
- const tool = toolsByName.get(toolCall.name);
120
- if (!tool) {
121
- return {
122
- success: false,
123
- message: `Unknown tool: ${toolCall.name}`,
124
- };
125
- }
126
- const result = await tool.execute(toolCall.input);
127
- if (tool.name === 'capture_lead' && result.success) {
128
- analytics?.track('lead:captured', { conversationId: req.conversationId });
129
- pendingEvents.push(JSON.stringify({
130
- leadCaptured: true,
131
- leadData: result.data,
132
- }));
133
- if (webhooks) {
134
- const delivery = webhooks
135
- .dispatch('lead:captured', {
136
- lead: result.data,
137
- conversationId: req.conversationId,
138
- })
139
- .catch((err) => {
140
- console.error('[chatcops] Webhook delivery failed:', err);
141
- })
142
- .finally(() => {
143
- pendingWebhooks.delete(delivery);
144
- });
145
- pendingWebhooks.add(delivery);
146
- }
147
- }
148
- return result;
149
- },
150
- })) {
151
- if (signal?.aborted)
152
- break;
153
- while (pendingEvents.length > 0) {
154
- yield pendingEvents.shift();
279
+ // The reply is saved as it grows: appended after the first tool round,
280
+ // updated after each one. A reply that fails before anything was saved
281
+ // isn't saved at all, so a failed regenerate keeps the old answer.
282
+ let saved = false;
283
+ const turn = runTurn({
284
+ provider,
285
+ messages,
286
+ systemPrompt: config.systemPrompt,
287
+ ...(context.length ? { systemContext: context.join('\n\n') } : {}),
288
+ ...(sources.length ? { sources } : {}),
289
+ tools,
290
+ conversationId: req.conversationId,
291
+ toolTimeoutMs: config.toolTimeoutMs,
292
+ maxTokens: config.maxTokens,
293
+ temperature: config.temperature,
294
+ exposeUsage: config.exposeUsage,
295
+ logger: log,
296
+ signal,
297
+ ...(budgetGuard
298
+ ? { budget: { guard: guardFor(user?.tier) ?? budgetGuard, context: { conversationId: req.conversationId, ...(clientIp ? { ip: clientIp } : {}) } } }
299
+ : {}),
300
+ onPart: (part) => {
301
+ if (part.type !== 'lead-captured')
302
+ return;
303
+ analytics?.track(ANALYTICS_EVENTS.leadCaptured, { conversationId: req.conversationId });
304
+ if (!webhooks)
305
+ return;
306
+ const delivery = webhooks
307
+ .dispatch('lead:captured', { lead: part.lead, conversationId: req.conversationId })
308
+ .catch((err) => {
309
+ log.error('webhook_failed', 'Webhook delivery failed', { error: err });
310
+ })
311
+ .finally(() => {
312
+ pendingWebhooks.delete(delivery);
313
+ });
314
+ pendingWebhooks.add(delivery);
315
+ },
316
+ onPersist: async (message, { final }) => {
317
+ const complete = message.status === 'complete';
318
+ if (!saved) {
319
+ if (final && !message.content && !message.parts?.length)
320
+ return;
321
+ // A reply the visitor walked away from is kept, marked `aborted`,
322
+ // so the conversation shows what they saw (the model doesn't see
323
+ // it). When regenerating, the old answer stands instead. A reply
324
+ // that failed isn't kept.
325
+ if (final && !complete && (message.status !== 'aborted' || assistantToReplace))
326
+ return;
327
+ await conversations.appendMessages(req.conversationId, [message]);
328
+ saved = true;
155
329
  }
156
- fullResponse += chunk;
157
- yield JSON.stringify({ content: chunk });
158
- }
159
- while (pendingEvents.length > 0) {
160
- yield pendingEvents.shift();
161
- }
330
+ else {
331
+ await conversations.updateMessage(req.conversationId, message);
332
+ }
333
+ if (final && complete) {
334
+ if (assistantToReplace)
335
+ await conversations.removeMessage(req.conversationId, assistantToReplace.id);
336
+ if (message.content)
337
+ analytics?.track(ANALYTICS_EVENTS.messageReceived, { conversationId: req.conversationId });
338
+ }
339
+ },
340
+ });
341
+ let failure;
342
+ let next = await turn.next();
343
+ while (!next.done) {
344
+ const part = next.value;
345
+ if (part.type === 'error')
346
+ failure = part;
347
+ yield part;
348
+ next = await turn.next();
162
349
  }
163
- catch (err) {
164
- if (signal?.aborted) {
165
- // The visitor left. Let lead webhooks finish, but write nothing else.
166
- await awaitPendingWebhooks(pendingWebhooks);
167
- return;
350
+ const result = next.value;
351
+ if (failure && result.status !== 'aborted') {
352
+ if (failure.code === 'budget_exceeded') {
353
+ log.warn('budget_exceeded', `The ${failure.scope ?? ''} budget is used up, so a reply was refused.`, { scope: failure.scope });
168
354
  }
169
- console.error('[chatcops] Provider error:', err);
170
- await awaitPendingWebhooks(pendingWebhooks);
171
- yield JSON.stringify({ error: 'provider_error' });
172
- return;
173
- }
174
- if (signal?.aborted) {
175
- await awaitPendingWebhooks(pendingWebhooks);
176
- return;
177
- }
178
- // Save assistant message
179
- if (fullResponse) {
180
- const assistantMessage = {
181
- id: crypto.randomUUID(),
182
- role: 'assistant',
183
- content: fullResponse,
184
- timestamp: Date.now(),
185
- };
186
- await conversations.addMessage(req.conversationId, assistantMessage);
187
- if (assistantToReplace) {
188
- await conversations.removeMessage(req.conversationId, assistantToReplace.id);
355
+ else {
356
+ log.error('provider_error', 'Provider error', { error: result.error });
189
357
  }
190
- analytics?.track('message:received', { conversationId: req.conversationId });
191
358
  }
192
- // The reply is finished for the visitor, so say so first. Keeping the generator
193
- // open until lead webhooks finish keeps serverless runtimes from cutting
194
- // them off once the response ends.
195
- yield JSON.stringify({ done: true });
196
- await awaitPendingWebhooks(pendingWebhooks);
359
+ // The visitor left, or the reply failed: let lead webhooks finish before
360
+ // the stream ends.
361
+ if (failure || result.status === 'aborted')
362
+ await awaitPendingWebhooks(pendingWebhooks);
197
363
  }
198
- function cleanup() {
199
- clearInterval(cleanupInterval);
364
+ /**
365
+ * Deletes conversations last updated more than `retention.conversationsDays`
366
+ * (default 30) days ago. Resolves how many, or `null` when the store can't
367
+ * purge.
368
+ */
369
+ async function purge(now = Date.now()) {
370
+ const store = storage?.conversations;
371
+ if (!store?.purge)
372
+ return null;
373
+ const days = config.retention?.conversationsDays ?? 30;
374
+ return store.purge(now - days * DAY_MS);
200
375
  }
376
+ /** Kept for compatibility: the handler no longer holds timers, so there's nothing to clean up. */
377
+ function cleanup() { }
201
378
  return {
202
379
  handleChat,
203
- getAnalytics: () => analytics?.getStats() ?? null,
380
+ startChat,
381
+ purge,
382
+ /** How many requests asked for each stream format, to tell when 0.5 widgets are gone. */
383
+ /** Answers `POST /session`, or `undefined` when session tokens are off. */
384
+ startSession: sessions
385
+ ? (body, origin, clientIp) => sessions.mint(body, origin, clientIp)
386
+ : undefined,
387
+ protocolStats: () => ({ ...requests }),
388
+ /** Stats from this process only. `analyticsStats()` reads the storage's event sink. */
389
+ getAnalytics: () => collector?.getStats() ?? null,
390
+ analyticsStats,
204
391
  cleanup,
392
+ /** The logger the handler writes to, for adapters to use as well. */
393
+ logger: log,
205
394
  };
206
395
  }
396
+ function refuse(code, options, legacy) {
397
+ return { ok: false, status: statusForError(code), error: errorBody(code, options).error, legacy };
398
+ }
399
+ /**
400
+ * Protocol v1 parts as 0.5 chunks (JSON strings). An error goes out last,
401
+ * after the turn has ended and lead webhooks have finished, as in 0.5.
402
+ */
403
+ export async function* toLegacyStream(parts) {
404
+ let failure = null;
405
+ let aborted = false;
406
+ for await (const part of parts) {
407
+ if (part.type === 'error') {
408
+ failure = toLegacyChunk(part);
409
+ continue;
410
+ }
411
+ if (part.type === 'finish' && part.reason === 'aborted')
412
+ aborted = true;
413
+ const chunk = toLegacyChunk(part);
414
+ if (chunk)
415
+ yield JSON.stringify(chunk);
416
+ }
417
+ // Nobody is listening after an abort.
418
+ if (failure && !aborted)
419
+ yield JSON.stringify(failure);
420
+ }
421
+ /** A saved reply, sent again: the request was a retry of an answered message. */
422
+ function* replay(message, conversationId) {
423
+ yield { type: 'start', protocol: PROTOCOL_VERSION, conversationId, messageId: message.id };
424
+ let texts = 0;
425
+ for (const part of partsOf(message)) {
426
+ if (part.type === 'text' && part.text) {
427
+ const id = `t${++texts}`;
428
+ yield { type: 'text-start', id };
429
+ yield { type: 'text-delta', id, delta: part.text };
430
+ yield { type: 'text-end', id };
431
+ }
432
+ else if (part.type === 'source') {
433
+ const { sourceId, url, title, snippet } = part;
434
+ yield { type: 'source', sourceId, url, ...(title ? { title } : {}), ...(snippet ? { snippet } : {}) };
435
+ }
436
+ else if (part.type === 'lead-captured') {
437
+ yield { type: 'lead-captured', lead: part.lead };
438
+ }
439
+ }
440
+ yield { type: 'finish', reason: 'stop' };
441
+ }
442
+ /** The pre-chat answers to keep: all of them, or only the configured fields. */
443
+ function allowedUserData(userData, fields) {
444
+ if (!userData)
445
+ return undefined;
446
+ const kept = fields ? Object.fromEntries(Object.entries(userData).filter(([key]) => fields.includes(key))) : userData;
447
+ return Object.keys(kept).length ? kept : undefined;
448
+ }
449
+ const DAY_MS = 24 * 60 * 60 * 1000;
450
+ /** Once per process (per isolate on Workers), not once per handler. */
451
+ let warnedMemoryStorage = false;
452
+ let loggedBudgets = false;
453
+ let loggedSecurity = false;
454
+ let loggedLegacyFormat = false;
455
+ const SCOPE_LABELS = { conversation: 'per conversation', ip: 'per IP per day', global: 'per day in total' };
456
+ function amount(value, unit) {
457
+ const short = value >= 1_000_000 ? `${value / 1_000_000}M` : value >= 1_000 ? `${value / 1_000}k` : String(value);
458
+ return `${short} ${unit}`;
459
+ }
460
+ /** The startup line, so the limits in force are never a surprise. */
461
+ export function describeBudgets(budgets, defaulted = []) {
462
+ const scopes = [
463
+ ['conversation', budgets.perConversation],
464
+ ['ip', budgets.perIpPerDay],
465
+ ['global', budgets.globalPerDay],
466
+ ];
467
+ const parts = [];
468
+ for (const [scope, limit] of scopes) {
469
+ if (!limit)
470
+ continue;
471
+ const caps = [
472
+ ...(limit.tokens !== undefined ? [amount(limit.tokens, 'tokens')] : []),
473
+ ...(limit.messages !== undefined ? [amount(limit.messages, 'replies')] : []),
474
+ ...(limit.usd !== undefined ? [`$${limit.usd}`] : []),
475
+ ];
476
+ parts.push(`${caps.join(' and ')} ${SCOPE_LABELS[scope]}${defaulted.includes(scope) ? ' (default)' : ''}`);
477
+ }
478
+ const noTotal = budgets.globalPerDay ? '' : ' No daily total is set; `budgets.globalPerDay` caps your whole bill.';
479
+ return `Budgets: ${parts.join(', ')}.${noTotal} Set \`budgets\` to change them, or \`budgets: false\` to turn them off.`;
480
+ }
481
+ /** Vercel, AWS Lambda, Netlify and Cloudflare Workers, where memory doesn't last. */
482
+ function onServerless() {
483
+ const env = globalThis.process?.env ?? {};
484
+ if (env.VERCEL || env.AWS_LAMBDA_FUNCTION_NAME || env.NETLIFY || env.K_SERVICE)
485
+ return true;
486
+ return globalThis.navigator?.userAgent === 'Cloudflare-Workers';
487
+ }
207
488
  async function awaitPendingWebhooks(pendingWebhooks) {
208
489
  if (pendingWebhooks.size > 0) {
209
490
  await Promise.allSettled(Array.from(pendingWebhooks));