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