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
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;
|