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.
- package/.claude/settings.local.json +17 -0
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/db/migrations/001_initial_schema.sql +91 -0
- package/doc/SPEC.md +896 -0
- package/hooks/auto-capture.sh +4 -0
- package/hooks/validate-command.sh +374 -0
- package/package.json +34 -20
- package/skills/journal/SKILL.md +112 -0
- package/skills/knowledge/SKILL.md +165 -0
- package/skills/project-manage/SKILL.md +216 -0
- package/skills/recall/SKILL.md +182 -0
- package/skills/system-ops/SKILL.md +161 -0
- package/src/attachments/store.js +167 -0
- package/src/claude/bridge.js +291 -0
- package/src/claude/conversation.js +219 -0
- package/src/config.js +90 -0
- package/src/db/migrate.js +94 -0
- package/src/db/pool.js +33 -0
- package/src/embeddings/engine.js +281 -0
- package/src/embeddings/worker.js +221 -0
- package/src/hooks/lifecycle.js +448 -0
- package/src/index.js +560 -0
- package/src/logging.js +91 -0
- package/src/mcp/config.js +75 -0
- package/src/mcp/embed-server.js +242 -0
- package/src/rate-limiter.js +114 -0
- package/src/telegram/bot.js +546 -0
- package/src/telegram/commands.js +440 -0
- package/src/web/server.js +1119 -0
|
@@ -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;
|