@stevederico/dotbot 0.16.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 (52) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +380 -0
  3. package/bin/dotbot.js +461 -0
  4. package/core/agent.js +779 -0
  5. package/core/compaction.js +261 -0
  6. package/core/cron_handler.js +262 -0
  7. package/core/events.js +229 -0
  8. package/core/failover.js +193 -0
  9. package/core/gptoss_tool_parser.js +173 -0
  10. package/core/init.js +154 -0
  11. package/core/normalize.js +324 -0
  12. package/core/trigger_handler.js +148 -0
  13. package/docs/core.md +103 -0
  14. package/docs/protected-files.md +59 -0
  15. package/examples/sqlite-session-example.js +69 -0
  16. package/index.js +341 -0
  17. package/observer/index.js +164 -0
  18. package/package.json +42 -0
  19. package/storage/CronStore.js +145 -0
  20. package/storage/EventStore.js +71 -0
  21. package/storage/MemoryStore.js +175 -0
  22. package/storage/MongoAdapter.js +291 -0
  23. package/storage/MongoCronAdapter.js +347 -0
  24. package/storage/MongoTaskAdapter.js +242 -0
  25. package/storage/MongoTriggerAdapter.js +158 -0
  26. package/storage/SQLiteAdapter.js +382 -0
  27. package/storage/SQLiteCronAdapter.js +562 -0
  28. package/storage/SQLiteEventStore.js +300 -0
  29. package/storage/SQLiteMemoryAdapter.js +240 -0
  30. package/storage/SQLiteTaskAdapter.js +419 -0
  31. package/storage/SQLiteTriggerAdapter.js +262 -0
  32. package/storage/SessionStore.js +149 -0
  33. package/storage/TaskStore.js +100 -0
  34. package/storage/TriggerStore.js +90 -0
  35. package/storage/cron_constants.js +48 -0
  36. package/storage/index.js +21 -0
  37. package/tools/appgen.js +311 -0
  38. package/tools/browser.js +634 -0
  39. package/tools/code.js +101 -0
  40. package/tools/events.js +145 -0
  41. package/tools/files.js +201 -0
  42. package/tools/images.js +253 -0
  43. package/tools/index.js +97 -0
  44. package/tools/jobs.js +159 -0
  45. package/tools/memory.js +332 -0
  46. package/tools/messages.js +135 -0
  47. package/tools/notify.js +42 -0
  48. package/tools/tasks.js +404 -0
  49. package/tools/triggers.js +159 -0
  50. package/tools/weather.js +82 -0
  51. package/tools/web.js +283 -0
  52. package/utils/providers.js +136 -0
package/core/init.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Unified initialization for dotbot.
3
+ *
4
+ * Provides a single entry point that creates and wires all stores, handlers,
5
+ * and the agent instance. Host apps only need to provide hooks for host-specific
6
+ * behavior (notifications, preferences).
7
+ */
8
+
9
+ import { SQLiteSessionStore } from '../storage/SQLiteAdapter.js';
10
+ import { SQLiteCronStore } from '../storage/SQLiteCronAdapter.js';
11
+ import { SQLiteTaskStore } from '../storage/SQLiteTaskAdapter.js';
12
+ import { SQLiteTriggerStore } from '../storage/SQLiteTriggerAdapter.js';
13
+ import { SQLiteMemoryStore } from '../storage/SQLiteMemoryAdapter.js';
14
+ import { coreTools } from '../tools/index.js';
15
+ import { createCronHandler } from './cron_handler.js';
16
+ import { createTriggerHandler } from './trigger_handler.js';
17
+
18
+ /**
19
+ * Initialize dotbot with unified configuration.
20
+ *
21
+ * @param {Object} options - Configuration options
22
+ * @param {string} options.dbPath - Path to SQLite database file
23
+ * @param {boolean} [options.storesOnly=false] - If true, only initialize stores (no agent, cron, triggers)
24
+ * @param {Object} [options.sessionStore] - Custom session store (default: SQLiteSessionStore)
25
+ * @param {Object} [options.providers] - Provider API keys: { anthropic: { apiKey }, openai: { apiKey }, xai: { apiKey } }
26
+ * @param {Array} [options.tools] - Tool definitions (default: coreTools)
27
+ * @param {number} [options.staleThresholdMs=86400000] - Skip heartbeat if user idle longer than this (default: 24h)
28
+ * @param {Function} [options.systemPrompt] - System prompt builder function
29
+ * @param {Function} [options.screenshotUrlPattern] - Screenshot URL pattern function
30
+ * @param {Object} [options.compaction] - Compaction settings
31
+ * @param {Object} [options.hooks] - Host-specific hooks
32
+ * @param {Function} [options.hooks.onNotification] - async (userId, { title, body, type }) => void
33
+ * @param {Function} [options.hooks.prefsFetcher] - async (userId) => { agentName, agentPersonality }
34
+ * @param {Function} [options.hooks.taskFetcher] - async (userId, taskId) => task object
35
+ * @param {Function} [options.hooks.tasksFinder] - async (userId, filter) => tasks array
36
+ * @returns {Promise<Object>} { agent, stores, fireTrigger, shutdown } or { stores, shutdown } if storesOnly
37
+ */
38
+ export async function init({
39
+ dbPath,
40
+ storesOnly = false,
41
+ sessionStore: customSessionStore,
42
+ providers = {},
43
+ tools = coreTools,
44
+ staleThresholdMs = 24 * 60 * 60 * 1000,
45
+ systemPrompt,
46
+ screenshotUrlPattern,
47
+ compaction = { enabled: true },
48
+ hooks = {},
49
+ } = {}) {
50
+ if (!dbPath) {
51
+ throw new Error('init() requires a dbPath');
52
+ }
53
+
54
+ // Initialize stores
55
+ const taskStore = new SQLiteTaskStore();
56
+ const triggerStore = new SQLiteTriggerStore();
57
+ const memoryStore = new SQLiteMemoryStore();
58
+
59
+ // Initialize task, trigger, and memory stores (always needed)
60
+ await taskStore.init({ dbPath });
61
+ await triggerStore.init({ dbPath });
62
+ await memoryStore.init({ dbPath });
63
+
64
+ // Bundle stores
65
+ const stores = {
66
+ task: taskStore,
67
+ trigger: triggerStore,
68
+ memory: memoryStore,
69
+ };
70
+
71
+ // For stores-only mode (e.g., dottie-desktop), skip session/cron/agent setup
72
+ if (storesOnly) {
73
+ return {
74
+ stores,
75
+ shutdown: async () => {},
76
+ };
77
+ }
78
+
79
+ // Full initialization with sessions, cron, and agent
80
+ const sessionStore = customSessionStore || new SQLiteSessionStore();
81
+ const cronStore = new SQLiteCronStore();
82
+
83
+ stores.session = sessionStore;
84
+ stores.cron = cronStore;
85
+
86
+ // Initialize session store with hooks
87
+ if (!customSessionStore) {
88
+ await sessionStore.init(dbPath, {
89
+ prefsFetcher: hooks.prefsFetcher,
90
+ heartbeatEnsurer: async (userId) => {
91
+ return await cronStore.ensureHeartbeat(userId);
92
+ },
93
+ });
94
+ }
95
+
96
+ // Build cron handler with extracted logic
97
+ const handleCronTask = createCronHandler({
98
+ sessionStore,
99
+ cronStore,
100
+ taskStore,
101
+ memoryStore,
102
+ providers,
103
+ staleThresholdMs,
104
+ hooks,
105
+ });
106
+
107
+ // Initialize cron store with the handler
108
+ await cronStore.init(dbPath, { onTaskFire: handleCronTask });
109
+
110
+ // Import createAgent dynamically to avoid circular dependency
111
+ const { createAgent } = await import('../index.js');
112
+
113
+ // Create the agent
114
+ const agent = createAgent({
115
+ sessionStore,
116
+ cronStore,
117
+ taskStore,
118
+ triggerStore,
119
+ memoryStore,
120
+ providers,
121
+ tools,
122
+ systemPrompt,
123
+ screenshotUrlPattern,
124
+ compaction,
125
+ });
126
+
127
+ // Wire agent to cron handler (resolves chicken-and-egg dependency)
128
+ handleCronTask.setAgent(agent);
129
+
130
+ // Create trigger handler
131
+ const fireTrigger = createTriggerHandler({
132
+ agent,
133
+ sessionStore,
134
+ triggerStore,
135
+ memoryStore,
136
+ providers,
137
+ hooks,
138
+ });
139
+
140
+ /**
141
+ * Gracefully shut down all stores and handlers.
142
+ */
143
+ async function shutdown() {
144
+ cronStore.stop();
145
+ // Add any other cleanup needed
146
+ }
147
+
148
+ return {
149
+ agent,
150
+ stores,
151
+ fireTrigger,
152
+ shutdown,
153
+ };
154
+ }
@@ -0,0 +1,324 @@
1
+ // core/normalize.js
2
+ // Standardize message formats across providers (Anthropic, OpenAI, xAI, Ollama).
3
+ // Provides bidirectional conversion between provider-specific formats and a
4
+ // unified schema that apps can consume without coupling to provider quirks.
5
+
6
+ /**
7
+ * Standard message schema (provider-agnostic):
8
+ *
9
+ * User message:
10
+ * {
11
+ * role: "user",
12
+ * content: string,
13
+ * _ts?: number
14
+ * }
15
+ *
16
+ * Assistant message:
17
+ * {
18
+ * role: "assistant",
19
+ * content: string,
20
+ * toolCalls?: [{ id, name, input, result?, status: "pending"|"done"|"error" }],
21
+ * thinking?: string,
22
+ * images?: [{ url, prompt }],
23
+ * _ts?: number
24
+ * }
25
+ *
26
+ * System message:
27
+ * {
28
+ * role: "system",
29
+ * content: string
30
+ * }
31
+ */
32
+
33
+ /**
34
+ * Convert provider-specific messages to standard format.
35
+ * Collapses assistant + tool_result pairs into single messages with toolCalls array.
36
+ *
37
+ * @param {Array} messages - Raw messages in Anthropic or OpenAI format
38
+ * @returns {Array} Normalized messages in standard format
39
+ */
40
+ export function toStandardFormat(messages) {
41
+ // Build tool result lookup (tool_use_id/tool_call_id → result content)
42
+ const toolResults = new Map();
43
+
44
+ for (const msg of messages) {
45
+ // Anthropic tool results (role: user, content: [{type: "tool_result", ...}])
46
+ if (msg.role === 'user' && Array.isArray(msg.content)) {
47
+ for (const block of msg.content) {
48
+ if (block.type === 'tool_result') {
49
+ toolResults.set(block.tool_use_id, block.content);
50
+ }
51
+ }
52
+ }
53
+ // OpenAI tool results (role: tool)
54
+ if (msg.role === 'tool' && msg.tool_call_id) {
55
+ toolResults.set(msg.tool_call_id, msg.content);
56
+ }
57
+ }
58
+
59
+ const normalized = [];
60
+
61
+ for (const msg of messages) {
62
+ // Skip tool-result-only user messages (Anthropic)
63
+ if (msg.role === 'user' && Array.isArray(msg.content) &&
64
+ msg.content.length > 0 && msg.content.every(b => b.type === 'tool_result')) {
65
+ continue;
66
+ }
67
+ // Skip OpenAI tool messages
68
+ if (msg.role === 'tool') continue;
69
+
70
+ // System messages
71
+ if (msg.role === 'system') {
72
+ normalized.push({ role: 'system', content: msg.content });
73
+ continue;
74
+ }
75
+
76
+ // User messages
77
+ if (msg.role === 'user') {
78
+ normalized.push({
79
+ role: 'user',
80
+ content: typeof msg.content === 'string' ? msg.content : '',
81
+ ...(msg._ts && { _ts: msg._ts })
82
+ });
83
+ continue;
84
+ }
85
+
86
+ // Assistant messages
87
+ if (msg.role === 'assistant') {
88
+ const standard = {
89
+ role: 'assistant',
90
+ content: '',
91
+ ...(msg._ts && { _ts: msg._ts })
92
+ };
93
+
94
+ // OpenAI format (string content + tool_calls array)
95
+ if (!Array.isArray(msg.content)) {
96
+ standard.content = typeof msg.content === 'string' ? msg.content : '';
97
+
98
+ if (msg.tool_calls) {
99
+ standard.toolCalls = msg.tool_calls.map(tc => {
100
+ const result = toolResults.get(tc.id);
101
+ let input = {};
102
+ try {
103
+ input = JSON.parse(tc.function?.arguments || '{}');
104
+ } catch {}
105
+
106
+ const toolCall = {
107
+ id: tc.id,
108
+ name: tc.function?.name,
109
+ input,
110
+ status: 'done'
111
+ };
112
+
113
+ if (result !== undefined) {
114
+ toolCall.result = result;
115
+ // Check if result is an image
116
+ try {
117
+ const parsed = JSON.parse(result);
118
+ if (parsed.type === 'image' && parsed.url) {
119
+ if (!standard.images) standard.images = [];
120
+ standard.images.push({ url: parsed.url, prompt: parsed.prompt });
121
+ }
122
+ } catch {}
123
+ }
124
+
125
+ return toolCall;
126
+ });
127
+ }
128
+ }
129
+ // Anthropic format (content block array)
130
+ else {
131
+ const toolCalls = [];
132
+
133
+ for (const block of msg.content) {
134
+ if (block.type === 'text') {
135
+ standard.content += block.text;
136
+ }
137
+ else if (block.type === 'tool_use') {
138
+ const result = toolResults.get(block.id);
139
+ const toolCall = {
140
+ id: block.id,
141
+ name: block.name,
142
+ input: block.input || {},
143
+ status: 'done'
144
+ };
145
+
146
+ if (result !== undefined) {
147
+ toolCall.result = result;
148
+ // Check if result is an image
149
+ try {
150
+ const parsed = JSON.parse(result);
151
+ if (parsed.type === 'image' && parsed.url) {
152
+ if (!standard.images) standard.images = [];
153
+ standard.images.push({ url: parsed.url, prompt: parsed.prompt });
154
+ }
155
+ } catch {}
156
+ }
157
+
158
+ toolCalls.push(toolCall);
159
+ }
160
+ else if (block.type === 'thinking') {
161
+ standard.thinking = (standard.thinking || '') + (block.thinking || '');
162
+ }
163
+ }
164
+
165
+ if (toolCalls.length > 0) {
166
+ standard.toolCalls = toolCalls;
167
+ }
168
+ }
169
+
170
+ // Clean up empty arrays
171
+ if (standard.toolCalls?.length === 0) delete standard.toolCalls;
172
+ if (standard.images?.length === 0) delete standard.images;
173
+
174
+ normalized.push(standard);
175
+ }
176
+ }
177
+
178
+ // Merge consecutive assistant messages (collapse tool-call phases)
179
+ const merged = [];
180
+ for (const msg of normalized) {
181
+ const prev = merged[merged.length - 1];
182
+ if (prev && prev.role === 'assistant' && msg.role === 'assistant') {
183
+ // Merge toolCalls
184
+ if (prev.toolCalls?.length) {
185
+ msg.toolCalls = [...prev.toolCalls, ...(msg.toolCalls || [])];
186
+ }
187
+ // Merge thinking
188
+ if (prev.thinking) {
189
+ msg.thinking = (prev.thinking || '') + (msg.thinking || '');
190
+ }
191
+ // Merge images
192
+ if (prev.images?.length) {
193
+ msg.images = [...(prev.images || []), ...(msg.images || [])];
194
+ }
195
+ // Keep first content if current is empty
196
+ if (!msg.content && prev.content) {
197
+ msg.content = prev.content;
198
+ }
199
+ // Keep earlier timestamp
200
+ if (prev._ts && !msg._ts) {
201
+ msg._ts = prev._ts;
202
+ }
203
+ merged[merged.length - 1] = msg;
204
+ } else {
205
+ merged.push(msg);
206
+ }
207
+ }
208
+
209
+ return merged;
210
+ }
211
+
212
+ /**
213
+ * Convert standard format messages to provider-specific format.
214
+ * Splits assistant messages with toolCalls into separate assistant + tool_result messages.
215
+ *
216
+ * @param {Array} messages - Normalized messages in standard format
217
+ * @param {"anthropic"|"openai"} targetFormat - Target provider format
218
+ * @returns {Array} Messages in provider-specific format
219
+ */
220
+ export function toProviderFormat(messages, targetFormat) {
221
+ const result = [];
222
+
223
+ for (const msg of messages) {
224
+ // System and user messages stay the same
225
+ if (msg.role === 'system' || msg.role === 'user') {
226
+ result.push({ ...msg });
227
+ continue;
228
+ }
229
+
230
+ // Assistant messages with tool calls
231
+ if (msg.role === 'assistant') {
232
+ if (targetFormat === 'anthropic') {
233
+ // Anthropic: content block array
234
+ const contentBlocks = [];
235
+
236
+ if (msg.content) {
237
+ contentBlocks.push({ type: 'text', text: msg.content });
238
+ }
239
+
240
+ if (msg.thinking) {
241
+ contentBlocks.push({ type: 'thinking', thinking: msg.thinking });
242
+ }
243
+
244
+ const toolUses = [];
245
+ if (msg.toolCalls) {
246
+ for (const tc of msg.toolCalls) {
247
+ contentBlocks.push({
248
+ type: 'tool_use',
249
+ id: tc.id,
250
+ name: tc.name,
251
+ input: tc.input
252
+ });
253
+ toolUses.push({ id: tc.id, result: tc.result });
254
+ }
255
+ }
256
+
257
+ result.push({
258
+ role: 'assistant',
259
+ content: contentBlocks,
260
+ ...(msg._ts && { _ts: msg._ts })
261
+ });
262
+
263
+ // Add tool results as separate user message
264
+ if (toolUses.length > 0) {
265
+ result.push({
266
+ role: 'user',
267
+ content: toolUses.map(tu => ({
268
+ type: 'tool_result',
269
+ tool_use_id: tu.id,
270
+ content: tu.result || ''
271
+ }))
272
+ });
273
+ }
274
+ }
275
+ else {
276
+ // OpenAI: string content + tool_calls array
277
+ const assistantMsg = {
278
+ role: 'assistant',
279
+ content: msg.content || '',
280
+ ...(msg._ts && { _ts: msg._ts })
281
+ };
282
+
283
+ if (msg.toolCalls) {
284
+ assistantMsg.tool_calls = msg.toolCalls.map(tc => ({
285
+ id: tc.id,
286
+ type: 'function',
287
+ function: {
288
+ name: tc.name,
289
+ arguments: typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input)
290
+ }
291
+ }));
292
+ }
293
+
294
+ result.push(assistantMsg);
295
+
296
+ // Add tool results as separate tool messages
297
+ if (msg.toolCalls) {
298
+ for (const tc of msg.toolCalls) {
299
+ if (tc.result !== undefined) {
300
+ result.push({
301
+ role: 'tool',
302
+ tool_call_id: tc.id,
303
+ content: tc.result
304
+ });
305
+ }
306
+ }
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ return result;
313
+ }
314
+
315
+ /**
316
+ * Normalize messages for frontend display.
317
+ * Alias for toStandardFormat() for backward compatibility.
318
+ *
319
+ * @param {Array} messages - Raw provider-specific messages
320
+ * @returns {Array} Normalized messages
321
+ */
322
+ export function normalizeMessages(messages) {
323
+ return toStandardFormat(messages);
324
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Trigger handler for dotbot.
3
+ *
4
+ * Extracted from dottie-os server.js to provide a reusable trigger executor
5
+ * that handles event matching, firing, and notification hooks.
6
+ */
7
+
8
+ import { compactMessages } from './compaction.js';
9
+
10
+ /**
11
+ * Create a trigger handler function.
12
+ *
13
+ * @param {Object} options
14
+ * @param {Object} options.agent - Agent instance with chat() method
15
+ * @param {Object} options.sessionStore - Session store instance
16
+ * @param {Object} options.triggerStore - Trigger store instance
17
+ * @param {Object} options.memoryStore - Memory store instance (optional)
18
+ * @param {Object} options.providers - Provider API keys for compaction
19
+ * @param {Object} [options.hooks] - Host-specific hooks
20
+ * @param {Function} [options.hooks.onNotification] - async (userId, { title, body, type }) => void
21
+ * @returns {Function} Async function: (eventType, userId, eventData?) => Promise<void>
22
+ */
23
+ export function createTriggerHandler({
24
+ agent,
25
+ sessionStore,
26
+ triggerStore,
27
+ memoryStore,
28
+ providers = {},
29
+ hooks = {},
30
+ }) {
31
+ /**
32
+ * Fire triggers for an event.
33
+ *
34
+ * @param {string} eventType - Event type (e.g., "user_login", "app_opened")
35
+ * @param {string} userId - User ID
36
+ * @param {Object} [eventData] - Optional event payload
37
+ */
38
+ async function fireTrigger(eventType, userId, eventData = {}) {
39
+ if (!userId) {
40
+ console.warn(`[triggers] fireTrigger called without userId for event ${eventType}`);
41
+ return;
42
+ }
43
+
44
+ try {
45
+ // Find matching triggers for this user and event
46
+ const triggers = await triggerStore.findMatchingTriggers(userId, eventType);
47
+ if (triggers.length === 0) {
48
+ console.log(`[triggers] no matching triggers for ${eventType} (user=${userId})`);
49
+ return;
50
+ }
51
+
52
+ console.log(`[triggers] found ${triggers.length} trigger(s) for ${eventType}`);
53
+
54
+ // Get or create the user's session
55
+ const session = await sessionStore.getOrCreateDefaultSession(userId);
56
+ if (!session) {
57
+ console.warn(`[triggers] could not get/create session for user ${userId}`);
58
+ return;
59
+ }
60
+
61
+ // Process each matching trigger
62
+ for (const trigger of triggers) {
63
+ await executeTrigger(trigger, eventType, session, eventData);
64
+ }
65
+ } catch (err) {
66
+ console.error(`[triggers] error handling ${eventType}:`, err.message);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Execute a single trigger.
72
+ *
73
+ * @param {Object} trigger - Trigger object
74
+ * @param {string} eventType - Event type
75
+ * @param {Object} session - User session
76
+ * @param {Object} eventData - Event payload
77
+ */
78
+ async function executeTrigger(trigger, eventType, session, eventData) {
79
+ // Mark trigger as fired (handles cooldown tracking)
80
+ await triggerStore.markTriggerFired(trigger.id);
81
+
82
+ // Build the message content
83
+ let messageContent = `[Event: ${eventType}] ${trigger.prompt}`;
84
+ if (Object.keys(eventData).length > 0) {
85
+ messageContent += `\n\nEvent data: ${JSON.stringify(eventData)}`;
86
+ }
87
+
88
+ // Add message to session
89
+ await sessionStore.addMessage(session.id, {
90
+ role: 'user',
91
+ content: messageContent,
92
+ });
93
+
94
+ // Re-fetch session to pick up the added message
95
+ const updatedSession = await sessionStore.getSessionInternal(session.id);
96
+ const providerId = updatedSession.provider || 'ollama';
97
+
98
+ // Compact old messages before running agent loop
99
+ const compacted = await compactMessages(updatedSession.messages, {
100
+ providerId,
101
+ providers,
102
+ });
103
+
104
+ if (compacted.compacted) {
105
+ updatedSession.messages = compacted.messages;
106
+ updatedSession.messages.push({
107
+ role: 'user',
108
+ content: '[Compaction] Compressed conversation history',
109
+ _ts: Date.now(),
110
+ });
111
+ }
112
+
113
+ // Run the agent chat loop
114
+ let finalText = '';
115
+ for await (const event of agent.chat({
116
+ sessionId: updatedSession.id,
117
+ message: '', // Message already added to session
118
+ provider: providerId,
119
+ model: updatedSession.model,
120
+ context: {
121
+ userID: updatedSession.owner,
122
+ sessionId: updatedSession.id,
123
+ memoryStore,
124
+ },
125
+ })) {
126
+ if (event.type === 'text_delta' && event.text) {
127
+ finalText += event.text;
128
+ }
129
+ }
130
+
131
+ // Create notification if the agent produced meaningful output
132
+ const trimmed = finalText.trim();
133
+ if (trimmed && trimmed.length > 10 && updatedSession.owner && hooks.onNotification) {
134
+ try {
135
+ await hooks.onNotification(updatedSession.owner, {
136
+ title: 'Dottie',
137
+ body: trimmed.slice(0, 500),
138
+ type: 'trigger',
139
+ });
140
+ console.log(`[triggers] notification created for ${updatedSession.owner} (${trimmed.length} chars)`);
141
+ } catch (err) {
142
+ console.error(`[triggers] failed to create notification for ${eventType}:`, err.message);
143
+ }
144
+ }
145
+ }
146
+
147
+ return fireTrigger;
148
+ }