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/src/index.js ADDED
@@ -0,0 +1,560 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 2ndbrain -- main entry point and process manager.
5
+ *
6
+ * Startup sequence per spec section 10:
7
+ * 1. Load environment variables
8
+ * 2. Validate required config
9
+ * 3. Connect to PostgreSQL, run pending migrations
10
+ * 4. Resolve embedding configuration (if EMBEDDING_PROVIDER is set)
11
+ * 5. Verify claude-cli is available
12
+ * 6. Start web admin server
13
+ * 7. Start Telegram long-polling
14
+ * 8. Log startup, set signal handlers
15
+ * 9. Auto-open browser
16
+ */
17
+
18
+ import { execSync } from 'node:child_process';
19
+ import path from 'node:path';
20
+ import fs from 'node:fs';
21
+
22
+ import { config, validateConfig, isFirstRun, PROJECT_ROOT } from './config.js';
23
+ import { pool, query, close as closeDb } from './db/pool.js';
24
+ import { migrate } from './db/migrate.js';
25
+ import logger from './logging.js';
26
+ import { createRateLimiters } from './rate-limiter.js';
27
+ import hooks from './hooks/lifecycle.js';
28
+ import { TelegramBot } from './telegram/bot.js';
29
+ import { CommandRouter } from './telegram/commands.js';
30
+ import { ClaudeBridge } from './claude/bridge.js';
31
+ import { ConversationManager } from './claude/conversation.js';
32
+ import { generateMcpConfig } from './mcp/config.js';
33
+ import { createEmbedTool } from './mcp/embed-server.js';
34
+ import { AttachmentStore } from './attachments/store.js';
35
+ import { EmbeddingsEngine } from './embeddings/engine.js';
36
+ import { EmbeddingWorker } from './embeddings/worker.js';
37
+ import { WebServer } from './web/server.js';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // State
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const startTime = Date.now();
44
+ let bot = null;
45
+ let webServer = null;
46
+ let embeddingWorker = null;
47
+ let embedTool = null;
48
+ let shuttingDown = false;
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Helper: copy skill + hook files to the runtime directory
52
+ // ---------------------------------------------------------------------------
53
+
54
+ function setupRuntimeFiles() {
55
+ const runtimeDir = path.join(config.DATA_DIR, 'claude-runtime');
56
+ const skillsDestDir = path.join(runtimeDir, '.claude', 'skills');
57
+ const hooksDestDir = path.join(runtimeDir, 'hooks');
58
+
59
+ fs.mkdirSync(skillsDestDir, { recursive: true });
60
+ fs.mkdirSync(hooksDestDir, { recursive: true });
61
+
62
+ // Copy skill files
63
+ const skillsSrcDir = path.join(PROJECT_ROOT, 'skills');
64
+ if (fs.existsSync(skillsSrcDir)) {
65
+ for (const skillDir of fs.readdirSync(skillsSrcDir)) {
66
+ const srcSkillDir = path.join(skillsSrcDir, skillDir);
67
+ const destSkillDir = path.join(skillsDestDir, skillDir);
68
+ if (fs.statSync(srcSkillDir).isDirectory()) {
69
+ fs.mkdirSync(destSkillDir, { recursive: true });
70
+ for (const file of fs.readdirSync(srcSkillDir)) {
71
+ fs.copyFileSync(
72
+ path.join(srcSkillDir, file),
73
+ path.join(destSkillDir, file),
74
+ );
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ // Copy hook scripts
81
+ const hooksSrcDir = path.join(PROJECT_ROOT, 'hooks');
82
+ if (fs.existsSync(hooksSrcDir)) {
83
+ for (const file of fs.readdirSync(hooksSrcDir)) {
84
+ const src = path.join(hooksSrcDir, file);
85
+ const dest = path.join(hooksDestDir, file);
86
+ fs.copyFileSync(src, dest);
87
+ // Make scripts executable
88
+ if (file.endsWith('.sh')) {
89
+ fs.chmodSync(dest, 0o755);
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Helper: verify claude-cli is installed
97
+ // ---------------------------------------------------------------------------
98
+
99
+ function verifyClaude() {
100
+ try {
101
+ const version = execSync('claude --version', {
102
+ timeout: 10_000,
103
+ encoding: 'utf-8',
104
+ }).trim();
105
+ logger.info('startup', `claude-cli found: ${version}`);
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Helper: open browser
114
+ // ---------------------------------------------------------------------------
115
+
116
+ async function openBrowser(url) {
117
+ if (config.AUTO_OPEN_BROWSER !== 'true') return;
118
+ // Don't try to open browser when running under systemd or without TTY
119
+ if (!process.stdout.isTTY && !process.env.DISPLAY) return;
120
+
121
+ try {
122
+ const open = (await import('open')).default;
123
+ await open(url);
124
+ } catch (err) {
125
+ logger.debug('startup', `Could not open browser: ${err.message}`);
126
+ }
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Message handler -- wires Telegram messages to the Claude bridge
131
+ // ---------------------------------------------------------------------------
132
+
133
+ async function handleMessage(message, deps) {
134
+ const {
135
+ commandRouter,
136
+ claudeBridge,
137
+ conversationManager,
138
+ attachmentStore,
139
+ rateLimiters,
140
+ embeddingsEngine,
141
+ } = deps;
142
+
143
+ const { chatId, text, attachments, messageId } = message;
144
+
145
+ // 1. Check if it's a slash command
146
+ const handled = await commandRouter.route(message);
147
+ if (handled) return;
148
+
149
+ // 2. Save user message
150
+ const savedUserMsg = await conversationManager.saveMessage('user', text || '', {
151
+ telegram_message_id: messageId,
152
+ attachments: attachments?.map((a) => a.fileId) || [],
153
+ });
154
+
155
+ // 3. Handle attachments
156
+ if (attachments && attachments.length > 0) {
157
+ for (const att of attachments) {
158
+ try {
159
+ await attachmentStore.save(
160
+ { file_id: att.fileId, mime_type: att.mimeType },
161
+ savedUserMsg.id,
162
+ );
163
+ } catch (err) {
164
+ logger.error('attachments', `Failed to save attachment: ${err.message}`);
165
+ }
166
+ }
167
+ }
168
+
169
+ // 4. Start typing indicator
170
+ await bot.sendTyping(chatId, true);
171
+
172
+ try {
173
+ // 5. Rate limit check for Claude
174
+ await rateLimiters.claude.acquire();
175
+
176
+ // 6. Run on_pre_claude hook
177
+ const preResult = await hooks.emit('on_pre_claude', {
178
+ message: text,
179
+ systemPrompt: '',
180
+ sessionId: conversationManager.currentSessionId,
181
+ });
182
+
183
+ if (preResult.aborted) {
184
+ bot.stopTyping(chatId);
185
+ await bot.sendMessage(chatId, preResult.reason || 'Request aborted.', {
186
+ reply_to_message_id: messageId,
187
+ parse_mode: undefined,
188
+ });
189
+ return;
190
+ }
191
+
192
+ const ctx = preResult.context || {};
193
+
194
+ // 7. Invoke Claude
195
+ const result = await claudeBridge.invoke(
196
+ text || '',
197
+ conversationManager.currentSessionId,
198
+ ctx.systemPrompt || '',
199
+ );
200
+
201
+ // 8. Update session ID
202
+ if (result.sessionId) {
203
+ conversationManager.setSessionId(result.sessionId);
204
+ }
205
+
206
+ // 9. Save assistant response
207
+ const savedAssistantMsg = await conversationManager.saveMessage('assistant', result.text || '', {
208
+ session_id: result.sessionId,
209
+ cost_usd: result.cost,
210
+ duration_ms: result.duration,
211
+ tool_calls: result.toolCalls,
212
+ telegram_message_id: messageId,
213
+ });
214
+
215
+ // 10. Run on_post_claude hook
216
+ await hooks.emit('on_post_claude', {
217
+ response: result.text,
218
+ tool_calls: result.toolCalls,
219
+ duration: result.duration,
220
+ cost: result.cost,
221
+ sessionId: result.sessionId,
222
+ messageId: savedAssistantMsg.id,
223
+ });
224
+
225
+ // 11. Run on_pre_send hook
226
+ const sendResult = await hooks.emit('on_pre_send', {
227
+ text: result.text || 'No response.',
228
+ parse_mode: 'MarkdownV2',
229
+ });
230
+
231
+ const sendCtx = sendResult.context || { text: result.text, chunks: null };
232
+
233
+ // 12. Stop typing and send response
234
+ bot.stopTyping(chatId);
235
+
236
+ if (sendCtx.chunks && sendCtx.chunks.length > 0) {
237
+ for (let i = 0; i < sendCtx.chunks.length; i++) {
238
+ await bot.sendMessage(chatId, sendCtx.chunks[i], {
239
+ reply_to_message_id: i === 0 ? messageId : undefined,
240
+ });
241
+ }
242
+ } else {
243
+ await bot.sendMessage(chatId, sendCtx.text || 'No response.', {
244
+ reply_to_message_id: messageId,
245
+ });
246
+ }
247
+
248
+ // 13. Attempt auto-compaction (non-blocking)
249
+ conversationManager.compact(claudeBridge).catch((err) => {
250
+ logger.warn('conversation', `Auto-compaction error: ${err.message}`);
251
+ });
252
+ } catch (err) {
253
+ bot.stopTyping(chatId);
254
+ logger.error('message-handler', `Error processing message: ${err.message}`);
255
+
256
+ // Notify user of the error
257
+ const isTimeout = err.message?.includes('timed out');
258
+ const userMessage = isTimeout
259
+ ? 'Response timed out, please try again.'
260
+ : `Sorry, an error occurred: ${err.message}`;
261
+
262
+ await bot.sendMessage(chatId, userMessage, {
263
+ reply_to_message_id: messageId,
264
+ parse_mode: undefined,
265
+ });
266
+
267
+ // Run on_error hook
268
+ await hooks.emit('on_error', {
269
+ error: err,
270
+ source: 'claude-bridge',
271
+ context: { chatId, messageId },
272
+ });
273
+ }
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Graceful shutdown
278
+ // ---------------------------------------------------------------------------
279
+
280
+ async function shutdown(signal) {
281
+ if (shuttingDown) return;
282
+ shuttingDown = true;
283
+
284
+ logger.info('process', `Shutdown initiated (signal=${signal})`);
285
+
286
+ try {
287
+ await hooks.emit('on_shutdown', { reason: signal, timestamp: Date.now() });
288
+ } catch { /* best-effort */ }
289
+
290
+ // Stop components in reverse order
291
+ if (bot) {
292
+ try { bot.stopPolling(); } catch { /* ignore */ }
293
+ }
294
+
295
+ if (embeddingWorker) {
296
+ try { embeddingWorker.stop(); } catch { /* ignore */ }
297
+ }
298
+
299
+ if (embedTool?.server) {
300
+ try { embedTool.server.close(); } catch { /* ignore */ }
301
+ }
302
+
303
+ if (webServer) {
304
+ try { await webServer.stop(); } catch { /* ignore */ }
305
+ }
306
+
307
+ try { await closeDb(); } catch { /* ignore */ }
308
+
309
+ logger.info('process', 'Shutdown complete.');
310
+ process.exit(0);
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Main startup
315
+ // ---------------------------------------------------------------------------
316
+
317
+ async function main() {
318
+ console.log(`\n 2ndbrain v0.5.0\n`);
319
+
320
+ // Step 1: Validate required config
321
+ const firstRun = isFirstRun();
322
+ const { valid, missing } = validateConfig();
323
+
324
+ if (!valid && !firstRun) {
325
+ logger.error('startup', `Missing required config: ${missing.join(', ')}`);
326
+ }
327
+
328
+ // Step 2 & 3: Connect to database and run migrations (if config is present)
329
+ let dbReady = false;
330
+ if (config.DATABASE_URL) {
331
+ try {
332
+ await pool.query('SELECT 1');
333
+ logger.info('startup', 'Database connection established.');
334
+ dbReady = true;
335
+
336
+ // Initialize logger with db pool for structured logging
337
+ logger.init(pool);
338
+
339
+ // Run migrations
340
+ const applied = await migrate();
341
+ if (applied.length > 0) {
342
+ logger.info('startup', `Applied ${applied.length} migration(s).`);
343
+ }
344
+ } catch (err) {
345
+ logger.error('startup', `Database connection failed: ${err.message}`);
346
+ }
347
+ }
348
+
349
+ // Step 4: Embeddings configuration
350
+ const embeddingsEngine = new EmbeddingsEngine({ db: { query }, config, logger });
351
+ if (embeddingsEngine.isEnabled() && dbReady) {
352
+ try {
353
+ await embeddingsEngine.initialize();
354
+ } catch (err) {
355
+ logger.error('startup', `Embeddings initialization failed: ${err.message}`);
356
+ }
357
+ }
358
+
359
+ // Start the embed_query MCP tool server if embeddings are enabled
360
+ if (embeddingsEngine.isEnabled()) {
361
+ try {
362
+ embedTool = await createEmbedTool(config);
363
+ if (embedTool) {
364
+ config._embedServerUrl = embedTool.url;
365
+ logger.info('startup', `embed_query MCP server listening on ${embedTool.url}`);
366
+ }
367
+ } catch (err) {
368
+ logger.error('startup', `embed_query server failed to start: ${err.message}`);
369
+ }
370
+ }
371
+
372
+ // Step 5: Verify claude-cli
373
+ const claudeAvailable = verifyClaude();
374
+ if (!claudeAvailable) {
375
+ logger.error(
376
+ 'startup',
377
+ 'claude not found. Install Claude Code: https://claude.ai/code',
378
+ );
379
+ if (valid) {
380
+ // Only fail startup if config is present (not first run)
381
+ // On first run, we still want to show the settings page
382
+ if (!firstRun) {
383
+ process.exit(1);
384
+ }
385
+ }
386
+ }
387
+
388
+ // Set up runtime files (skills, hooks)
389
+ try {
390
+ fs.mkdirSync(config.DATA_DIR, { recursive: true });
391
+ setupRuntimeFiles();
392
+ logger.info('startup', 'Runtime files deployed.');
393
+ } catch (err) {
394
+ logger.error('startup', `Failed to set up runtime files: ${err.message}`);
395
+ }
396
+
397
+ // Generate MCP configuration
398
+ let mcpConfigPath;
399
+ if (config.DATABASE_URL) {
400
+ try {
401
+ mcpConfigPath = generateMcpConfig(config);
402
+ // Override MCP_CONFIG_PATH to use the generated one
403
+ config.MCP_CONFIG_PATH = mcpConfigPath;
404
+ logger.info('startup', `MCP config written to ${mcpConfigPath}`);
405
+ } catch (err) {
406
+ logger.error('startup', `Failed to generate MCP config: ${err.message}`);
407
+ }
408
+ }
409
+
410
+ // Create rate limiters
411
+ const rateLimiters = createRateLimiters();
412
+
413
+ // Create core components
414
+ const conversationManager = new ConversationManager({
415
+ db: { query },
416
+ logger,
417
+ config,
418
+ });
419
+
420
+ const claudeBridge = new ClaudeBridge({ config, logger, hooks });
421
+
422
+ // Register lifecycle hooks
423
+ hooks.registerDefaults({
424
+ logger,
425
+ db: { query },
426
+ config,
427
+ rateLimiters,
428
+ telegram: null, // Will be set after bot creation
429
+ embeddingsEngine,
430
+ });
431
+
432
+ // Step 6: Start web admin server
433
+ webServer = new WebServer({ config, db: { query }, logger });
434
+ try {
435
+ await webServer.start();
436
+ } catch (err) {
437
+ logger.error('startup', `Web admin server failed to start: ${err.message}`);
438
+ }
439
+
440
+ // Step 7: Start Telegram long-polling (only if token is configured)
441
+ if (config.TELEGRAM_BOT_TOKEN) {
442
+ bot = new TelegramBot({
443
+ token: config.TELEGRAM_BOT_TOKEN,
444
+ allowedUsers: config.TELEGRAM_ALLOWED_USERS
445
+ ? config.TELEGRAM_ALLOWED_USERS.split(',').map((id) => id.trim())
446
+ : [],
447
+ });
448
+
449
+ const attachmentStore = new AttachmentStore({
450
+ db: { query },
451
+ bot,
452
+ config,
453
+ logger,
454
+ });
455
+
456
+ const commandRouter = new CommandRouter({
457
+ bot,
458
+ logger,
459
+ conversationManager,
460
+ processInfo: {
461
+ startTime,
462
+ getMessageCount: () => conversationManager.getMessageCount(),
463
+ getHealthStatus: async () => {
464
+ const health = {
465
+ status: 'ok',
466
+ components: {
467
+ database: { ok: dbReady },
468
+ telegram: { ok: true },
469
+ claude: { ok: claudeAvailable },
470
+ },
471
+ };
472
+ // Check DB live status
473
+ try {
474
+ await query('SELECT 1');
475
+ health.components.database.ok = true;
476
+ } catch {
477
+ health.components.database.ok = false;
478
+ health.components.database.message = 'Connection failed';
479
+ }
480
+ // Derive overall status
481
+ const states = Object.values(health.components);
482
+ if (states.some((s) => !s.ok)) {
483
+ health.status = states.every((s) => !s.ok) ? 'error' : 'degraded';
484
+ }
485
+ return health;
486
+ },
487
+ },
488
+ });
489
+
490
+ const deps = {
491
+ commandRouter,
492
+ claudeBridge,
493
+ conversationManager,
494
+ attachmentStore,
495
+ rateLimiters,
496
+ embeddingsEngine,
497
+ };
498
+
499
+ // Wire message handler
500
+ bot.on('message', (msg) => {
501
+ handleMessage(msg, deps).catch((err) => {
502
+ logger.error('message-handler', `Unhandled error: ${err.message}`);
503
+ });
504
+ });
505
+
506
+ bot.startPolling();
507
+ logger.info('startup', 'Telegram bot started.');
508
+ } else {
509
+ logger.warn('startup', 'TELEGRAM_BOT_TOKEN not set; Telegram bot not started.');
510
+ }
511
+
512
+ // Start background embedding worker
513
+ if (embeddingsEngine.isEnabled() && dbReady) {
514
+ embeddingWorker = new EmbeddingWorker({ db: { query }, config, logger });
515
+ embeddingWorker.start();
516
+ }
517
+
518
+ // Step 8: Set signal handlers
519
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
520
+ process.on('SIGINT', () => shutdown('SIGINT'));
521
+ process.on('uncaughtException', async (err) => {
522
+ logger.error('process', `Uncaught exception: ${err.message}`);
523
+ try {
524
+ await hooks.emit('on_error', {
525
+ error: err,
526
+ source: 'uncaughtException',
527
+ });
528
+ } catch { /* best-effort */ }
529
+ await shutdown('uncaughtException');
530
+ });
531
+ process.on('unhandledRejection', (reason) => {
532
+ logger.error('process', `Unhandled rejection: ${reason}`);
533
+ });
534
+
535
+ // Fire on_startup hook
536
+ if (dbReady) {
537
+ await hooks.emit('on_startup', { version: '0.5.0', timestamp: Date.now(), config });
538
+ }
539
+
540
+ logger.info('startup', 'Startup complete.');
541
+
542
+ // Step 9: Auto-open browser
543
+ const baseUrl = `http://${config.WEB_BIND}:${config.WEB_PORT}`;
544
+ if (firstRun) {
545
+ await openBrowser(`${baseUrl}/settings`);
546
+ logger.info('startup', 'First run detected -- opening settings page.');
547
+ } else {
548
+ await openBrowser(baseUrl);
549
+ }
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // Run
554
+ // ---------------------------------------------------------------------------
555
+
556
+ main().catch((err) => {
557
+ console.error(`Fatal startup error: ${err.message}`);
558
+ console.error(err.stack);
559
+ process.exit(1);
560
+ });
package/src/logging.js ADDED
@@ -0,0 +1,91 @@
1
+ import config from './config.js';
2
+
3
+ const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
4
+
5
+ /**
6
+ * Structured logger that writes to both the console and the system_logs
7
+ * database table. Before `init()` is called, logs are console-only.
8
+ */
9
+ class Logger {
10
+ constructor() {
11
+ this._pool = null;
12
+ this._minLevel = LOG_LEVELS[config.LOG_LEVEL] ?? LOG_LEVELS.info;
13
+ }
14
+
15
+ /**
16
+ * Initialize database logging. Must be called after the pool is ready.
17
+ * @param {import('pg').Pool} pool
18
+ */
19
+ init(pool) {
20
+ this._pool = pool;
21
+ }
22
+
23
+ /**
24
+ * Core logging method. Writes to console (always) and to the database
25
+ * (when pool is available). Database write failures are caught and
26
+ * printed to stderr so they never crash the caller.
27
+ *
28
+ * @param {'debug'|'info'|'warn'|'error'} level
29
+ * @param {string} source - Component name (e.g. 'telegram', 'claude')
30
+ * @param {string} content - Log message
31
+ */
32
+ async _log(level, source, content) {
33
+ const numericLevel = LOG_LEVELS[level] ?? LOG_LEVELS.info;
34
+ if (numericLevel < this._minLevel) {
35
+ return;
36
+ }
37
+
38
+ const timestamp = new Date().toISOString();
39
+ const consoleMethod = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log';
40
+ console[consoleMethod](`[${timestamp}] [${level}] [${source}] ${content}`);
41
+
42
+ if (this._pool) {
43
+ try {
44
+ await this._pool.query(
45
+ 'INSERT INTO system_logs (level, source, content) VALUES ($1, $2, $3)',
46
+ [level, source, content],
47
+ );
48
+ } catch (err) {
49
+ // Avoid recursive logging -- print directly to stderr
50
+ console.error(`[${timestamp}] [error] [logger] Failed to write log to database:`, err.message);
51
+ }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @param {string} source
57
+ * @param {string} content
58
+ */
59
+ debug(source, content) {
60
+ return this._log('debug', source, content);
61
+ }
62
+
63
+ /**
64
+ * @param {string} source
65
+ * @param {string} content
66
+ */
67
+ info(source, content) {
68
+ return this._log('info', source, content);
69
+ }
70
+
71
+ /**
72
+ * @param {string} source
73
+ * @param {string} content
74
+ */
75
+ warn(source, content) {
76
+ return this._log('warn', source, content);
77
+ }
78
+
79
+ /**
80
+ * @param {string} source
81
+ * @param {string} content
82
+ */
83
+ error(source, content) {
84
+ return this._log('error', source, content);
85
+ }
86
+ }
87
+
88
+ const logger = new Logger();
89
+
90
+ export { Logger, logger };
91
+ export default logger;