2ndbrain 2026.1.30 → 2026.1.32

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.
@@ -0,0 +1,448 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ /**
4
+ * Valid lifecycle event names per spec section 13.1.
5
+ * @type {ReadonlyArray<string>}
6
+ */
7
+ const LIFECYCLE_EVENTS = Object.freeze([
8
+ 'on_message_received',
9
+ 'on_pre_claude',
10
+ 'on_post_claude',
11
+ 'on_pre_send',
12
+ 'on_error',
13
+ 'on_startup',
14
+ 'on_shutdown',
15
+ ]);
16
+
17
+ /**
18
+ * Telegram MarkdownV2 special characters that must be escaped.
19
+ *
20
+ * Characters: _ * [ ] ( ) ~ ` > # + - = | { } . !
21
+ */
22
+ const MARKDOWN_V2_SPECIAL = /([_*\[\]()~`>#+\-=|{}.!\\])/g;
23
+
24
+ /**
25
+ * Maximum length for a single Telegram message.
26
+ */
27
+ const TELEGRAM_MAX_LENGTH = 4096;
28
+
29
+ /**
30
+ * Soft limit for response text before truncation notice is appended.
31
+ */
32
+ const RESPONSE_SOFT_LIMIT = 3500;
33
+
34
+ /**
35
+ * Application lifecycle hooks system per spec section 13.1.
36
+ *
37
+ * Each event can have multiple async handlers registered. When emitted,
38
+ * handlers execute in registration order. A handler receives the context
39
+ * object and may modify it. If any handler throws or returns
40
+ * `{ abort: true, reason }`, the pipeline aborts immediately and
41
+ * `emit()` returns `{ aborted: true, reason }`.
42
+ *
43
+ * @extends EventEmitter
44
+ */
45
+ class LifecycleHooks extends EventEmitter {
46
+ constructor() {
47
+ super();
48
+
49
+ /**
50
+ * Map of event name -> ordered array of async handler functions.
51
+ * We maintain our own registry instead of relying solely on
52
+ * EventEmitter listeners so we can run handlers sequentially
53
+ * and support abort semantics.
54
+ * @type {Map<string, Array<(ctx: object) => Promise<object|void>>>}
55
+ */
56
+ this._handlers = new Map();
57
+
58
+ for (const event of LIFECYCLE_EVENTS) {
59
+ this._handlers.set(event, []);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Register an async handler for a lifecycle event.
65
+ *
66
+ * @param {string} event - One of the LIFECYCLE_EVENTS names
67
+ * @param {(ctx: object) => Promise<object|void>} handler - Async function
68
+ * that receives and optionally modifies the context object
69
+ * @returns {LifecycleHooks} this (for chaining)
70
+ * @throws {Error} If the event name is not a valid lifecycle event
71
+ */
72
+ on(event, handler) {
73
+ if (!this._handlers.has(event)) {
74
+ throw new Error(
75
+ `Unknown lifecycle event "${event}". Valid events: ${LIFECYCLE_EVENTS.join(', ')}`,
76
+ );
77
+ }
78
+
79
+ if (typeof handler !== 'function') {
80
+ throw new TypeError('Handler must be a function');
81
+ }
82
+
83
+ this._handlers.get(event).push(handler);
84
+ return this;
85
+ }
86
+
87
+ /**
88
+ * Trigger a lifecycle event, running all registered handlers in order.
89
+ *
90
+ * Each handler receives the current context object. If a handler returns
91
+ * a plain object (other than an abort signal), the context is replaced
92
+ * with the returned value. If a handler returns `{ abort: true, reason }`
93
+ * or throws an error, the pipeline stops immediately.
94
+ *
95
+ * @param {string} event - The lifecycle event to trigger
96
+ * @param {object} context - Mutable context passed through the pipeline
97
+ * @returns {Promise<{ aborted: false, context: object } | { aborted: true, reason: string }>}
98
+ */
99
+ async emit(event, context = {}) {
100
+ const handlers = this._handlers.get(event);
101
+
102
+ if (!handlers) {
103
+ throw new Error(
104
+ `Unknown lifecycle event "${event}". Valid events: ${LIFECYCLE_EVENTS.join(', ')}`,
105
+ );
106
+ }
107
+
108
+ let ctx = context;
109
+
110
+ for (const handler of handlers) {
111
+ try {
112
+ const result = await handler(ctx);
113
+
114
+ // Check for explicit abort signal
115
+ if (result && typeof result === 'object' && result.abort === true) {
116
+ const reason = result.reason || 'Aborted by hook handler';
117
+ super.emit('hook:aborted', { event, reason });
118
+ return { aborted: true, reason };
119
+ }
120
+
121
+ // Allow handler to replace context by returning a new object
122
+ if (result && typeof result === 'object') {
123
+ ctx = result;
124
+ }
125
+ } catch (err) {
126
+ const reason = err.message || 'Hook handler threw an error';
127
+ super.emit('hook:error', { event, error: err });
128
+ return { aborted: true, reason };
129
+ }
130
+ }
131
+
132
+ return { aborted: false, context: ctx };
133
+ }
134
+
135
+ /**
136
+ * Remove all handlers for a specific event, or all events if no
137
+ * event name is provided.
138
+ *
139
+ * @param {string} [event] - Optional event name to clear
140
+ */
141
+ clear(event) {
142
+ if (event) {
143
+ if (!this._handlers.has(event)) {
144
+ throw new Error(
145
+ `Unknown lifecycle event "${event}". Valid events: ${LIFECYCLE_EVENTS.join(', ')}`,
146
+ );
147
+ }
148
+ this._handlers.set(event, []);
149
+ } else {
150
+ for (const key of this._handlers.keys()) {
151
+ this._handlers.set(key, []);
152
+ }
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Returns the number of handlers registered for a given event.
158
+ *
159
+ * @param {string} event
160
+ * @returns {number}
161
+ */
162
+ handlerCount(event) {
163
+ const handlers = this._handlers.get(event);
164
+ return handlers ? handlers.length : 0;
165
+ }
166
+
167
+ /**
168
+ * Register the default lifecycle handlers per spec section 13.1.
169
+ *
170
+ * @param {object} deps - Application dependencies
171
+ * @param {object} deps.logger - Structured logger instance
172
+ * @param {object} deps.db - Database query interface ({ query(sql, params) })
173
+ * @param {object} deps.config - Application configuration object
174
+ * @param {object} deps.rateLimiters - { telegram: RateLimiter, claude: RateLimiter }
175
+ * @param {object} deps.telegram - TelegramBot instance
176
+ * @param {object} deps.embeddingsEngine - EmbeddingsEngine instance
177
+ */
178
+ registerDefaults({ logger, db, config, rateLimiters, telegram, embeddingsEngine }) {
179
+ // -------------------------------------------------------------------------
180
+ // on_message_received
181
+ // 1. Log inbound message to system_logs
182
+ // 2. Rate-limit via telegram limiter
183
+ // 3. Validate sender against telegram whitelist
184
+ // -------------------------------------------------------------------------
185
+ this.on('on_message_received', async (ctx) => {
186
+ const userId = ctx.telegram_user_id || ctx.message?.userId || 'unknown';
187
+ const text = ctx.message?.text || '';
188
+ const preview = text.length > 80 ? text.slice(0, 80) + '...' : text;
189
+
190
+ logger.info('hooks', `Message received from user ${userId}: ${preview}`);
191
+
192
+ // Rate limit check
193
+ try {
194
+ await rateLimiters.telegram.acquire();
195
+ } catch (err) {
196
+ logger.warn('hooks', `Telegram rate limit exceeded for user ${userId}: ${err.message}`);
197
+ return { abort: true, reason: 'Rate limit exceeded. Please wait a moment.' };
198
+ }
199
+
200
+ // Telegram whitelist validation
201
+ const allowedUsers = config.TELEGRAM_ALLOWED_USERS
202
+ ? config.TELEGRAM_ALLOWED_USERS.split(',').map((id) => id.trim())
203
+ : [];
204
+
205
+ if (allowedUsers.length > 0 && !allowedUsers.includes(String(userId))) {
206
+ logger.warn('hooks', `Unauthorized message from user ${userId}`);
207
+ return { abort: true, reason: `User ${userId} is not in the allowed users list` };
208
+ }
209
+
210
+ return ctx;
211
+ });
212
+
213
+ // -------------------------------------------------------------------------
214
+ // on_pre_claude
215
+ // 1. Inject system prompt with current date/time
216
+ // 2. Assemble conversation context
217
+ // -------------------------------------------------------------------------
218
+ this.on('on_pre_claude', async (ctx) => {
219
+ const now = new Date();
220
+ const dateStr = now.toISOString();
221
+ const localDate = now.toLocaleDateString('en-US', {
222
+ weekday: 'long',
223
+ year: 'numeric',
224
+ month: 'long',
225
+ day: 'numeric',
226
+ });
227
+ const localTime = now.toLocaleTimeString('en-US', {
228
+ hour: '2-digit',
229
+ minute: '2-digit',
230
+ hour12: true,
231
+ });
232
+
233
+ const dateContext = `Current date and time: ${localDate}, ${localTime} (${dateStr})`;
234
+
235
+ // Inject date/time into the system prompt
236
+ if (ctx.systemPrompt) {
237
+ ctx.systemPrompt = `${dateContext}\n\n${ctx.systemPrompt}`;
238
+ } else {
239
+ ctx.systemPrompt = dateContext;
240
+ }
241
+
242
+ // Assemble conversation context from recent history if db is available
243
+ if (db && ctx.includeHistory !== false) {
244
+ try {
245
+ const history = await db.query(
246
+ `SELECT role, content FROM conversation_messages
247
+ ORDER BY created_at DESC LIMIT 20`,
248
+ );
249
+
250
+ if (history.rows.length > 0) {
251
+ ctx.conversationContext = history.rows.reverse().map((row) => ({
252
+ role: row.role,
253
+ content: row.content,
254
+ }));
255
+ }
256
+ } catch (err) {
257
+ logger.warn('hooks', `Failed to load conversation history: ${err.message}`);
258
+ }
259
+ }
260
+
261
+ return ctx;
262
+ });
263
+
264
+ // -------------------------------------------------------------------------
265
+ // on_post_claude
266
+ // 1. Log duration and cost to system_logs
267
+ // 2. Queue response for embedding if embeddings are enabled
268
+ // -------------------------------------------------------------------------
269
+ this.on('on_post_claude', async (ctx) => {
270
+ const duration = ctx.duration ?? 0;
271
+ const cost = ctx.cost ?? 0;
272
+ const sessionId = ctx.sessionId || 'unknown';
273
+
274
+ logger.info(
275
+ 'hooks',
276
+ `Claude response: session=${sessionId} duration=${duration}ms cost=$${cost.toFixed(4)}`,
277
+ );
278
+
279
+ // Persist cost/duration to system_logs for analytics
280
+ try {
281
+ await db.query(
282
+ `INSERT INTO system_logs (level, source, content)
283
+ VALUES ('info', 'claude', $1)`,
284
+ [`duration=${duration}ms cost=$${cost.toFixed(4)} session=${sessionId}`],
285
+ );
286
+ } catch (err) {
287
+ logger.warn('hooks', `Failed to log Claude metrics: ${err.message}`);
288
+ }
289
+
290
+ // Queue for embedding if enabled
291
+ if (embeddingsEngine && embeddingsEngine.isEnabled() && ctx.messageId) {
292
+ try {
293
+ await embeddingsEngine.queueEmbedding('message', ctx.messageId);
294
+ logger.debug('hooks', `Queued embedding for message ${ctx.messageId}`);
295
+ } catch (err) {
296
+ logger.warn('hooks', `Failed to queue embedding: ${err.message}`);
297
+ }
298
+ }
299
+
300
+ return ctx;
301
+ });
302
+
303
+ // -------------------------------------------------------------------------
304
+ // on_pre_send
305
+ // 1. Truncate response > 3500 chars with notice
306
+ // 2. Chunk messages > 4096 chars for Telegram delivery
307
+ // 3. Escape MarkdownV2 special characters
308
+ // -------------------------------------------------------------------------
309
+ this.on('on_pre_send', async (ctx) => {
310
+ let text = ctx.text || '';
311
+
312
+ // Response length guard: truncate with notice
313
+ if (text.length > RESPONSE_SOFT_LIMIT) {
314
+ const truncated = text.slice(0, RESPONSE_SOFT_LIMIT);
315
+ text = truncated + '\n\n[Response truncated - original was ' + text.length + ' characters]';
316
+ logger.debug('hooks', `Response truncated from ${ctx.text.length} to ${text.length} chars`);
317
+ }
318
+
319
+ // Escape MarkdownV2 special characters
320
+ text = text.replace(MARKDOWN_V2_SPECIAL, '\\$1');
321
+
322
+ // Chunk for Telegram's 4096-char limit
323
+ if (text.length > TELEGRAM_MAX_LENGTH) {
324
+ const chunks = [];
325
+ let remaining = text;
326
+
327
+ while (remaining.length > 0) {
328
+ if (remaining.length <= TELEGRAM_MAX_LENGTH) {
329
+ chunks.push(remaining);
330
+ break;
331
+ }
332
+
333
+ // Try to split on newline within the limit
334
+ let splitAt = remaining.lastIndexOf('\n', TELEGRAM_MAX_LENGTH);
335
+ if (splitAt <= 0) {
336
+ splitAt = remaining.lastIndexOf(' ', TELEGRAM_MAX_LENGTH);
337
+ }
338
+ if (splitAt <= 0) {
339
+ splitAt = TELEGRAM_MAX_LENGTH;
340
+ }
341
+
342
+ chunks.push(remaining.slice(0, splitAt));
343
+ remaining = remaining.slice(splitAt).replace(/^\n/, '');
344
+ }
345
+
346
+ ctx.chunks = chunks;
347
+ ctx.text = chunks[0];
348
+ } else {
349
+ ctx.text = text;
350
+ ctx.chunks = [text];
351
+ }
352
+
353
+ return ctx;
354
+ });
355
+
356
+ // -------------------------------------------------------------------------
357
+ // on_error
358
+ // 1. Log error to system_logs
359
+ // 2. Notify owner via Telegram
360
+ // -------------------------------------------------------------------------
361
+ this.on('on_error', async (ctx) => {
362
+ const error = ctx.error || {};
363
+ const message = error.message || error.toString?.() || 'Unknown error';
364
+ const source = ctx.source || 'unknown';
365
+
366
+ logger.error('hooks', `Error in ${source}: ${message}`);
367
+
368
+ // Persist to system_logs
369
+ try {
370
+ await db.query(
371
+ `INSERT INTO system_logs (level, source, content)
372
+ VALUES ('error', $1, $2)`,
373
+ [source, message],
374
+ );
375
+ } catch (dbErr) {
376
+ logger.error('hooks', `Failed to log error to database: ${dbErr.message}`);
377
+ }
378
+
379
+ // Notify owner via Telegram
380
+ if (telegram && config.TELEGRAM_ALLOWED_USERS) {
381
+ const ownerIds = config.TELEGRAM_ALLOWED_USERS.split(',').map((id) => id.trim());
382
+ const ownerChatId = ownerIds[0];
383
+
384
+ if (ownerChatId) {
385
+ try {
386
+ const escapedSource = source.replace(MARKDOWN_V2_SPECIAL, '\\$1');
387
+ const escapedMessage = message.replace(MARKDOWN_V2_SPECIAL, '\\$1');
388
+ const notification = `*Error in ${escapedSource}:*\n${escapedMessage}`;
389
+
390
+ await telegram.sendMessage(ownerChatId, notification);
391
+ } catch (tgErr) {
392
+ logger.error('hooks', `Failed to notify owner via Telegram: ${tgErr.message}`);
393
+ }
394
+ }
395
+ }
396
+
397
+ return ctx;
398
+ });
399
+
400
+ // -------------------------------------------------------------------------
401
+ // on_startup
402
+ // Log application startup event
403
+ // -------------------------------------------------------------------------
404
+ this.on('on_startup', async (ctx) => {
405
+ const version = ctx.version || 'unknown';
406
+ logger.info('hooks', `Application started (version=${version})`);
407
+
408
+ try {
409
+ await db.query(
410
+ `INSERT INTO system_logs (level, source, content)
411
+ VALUES ('info', 'lifecycle', $1)`,
412
+ [`Application started (version=${version})`],
413
+ );
414
+ } catch (err) {
415
+ logger.warn('hooks', `Failed to log startup event: ${err.message}`);
416
+ }
417
+
418
+ return ctx;
419
+ });
420
+
421
+ // -------------------------------------------------------------------------
422
+ // on_shutdown
423
+ // Log application shutdown event
424
+ // -------------------------------------------------------------------------
425
+ this.on('on_shutdown', async (ctx) => {
426
+ const reason = ctx.reason || 'normal';
427
+ logger.info('hooks', `Application shutting down (reason=${reason})`);
428
+
429
+ try {
430
+ await db.query(
431
+ `INSERT INTO system_logs (level, source, content)
432
+ VALUES ('info', 'lifecycle', $1)`,
433
+ [`Application shutting down (reason=${reason})`],
434
+ );
435
+ } catch (err) {
436
+ logger.warn('hooks', `Failed to log shutdown event: ${err.message}`);
437
+ }
438
+
439
+ return ctx;
440
+ });
441
+ }
442
+ }
443
+
444
+ /** Singleton instance shared across the application. */
445
+ const hooks = new LifecycleHooks();
446
+
447
+ export { LifecycleHooks, LIFECYCLE_EVENTS, hooks };
448
+ export default hooks;