2ndbrain 2026.1.30 → 2026.1.31
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 +16 -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 +880 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { escapeMarkdownV2 } from './bot.js';
|
|
3
|
+
|
|
4
|
+
/** How long a confirmation remains valid (ms). */
|
|
5
|
+
const CONFIRMATION_TTL_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Descriptions shown by the /help command.
|
|
9
|
+
* @type {Record<string, string>}
|
|
10
|
+
*/
|
|
11
|
+
const COMMAND_DESCRIPTIONS = {
|
|
12
|
+
'/status': 'System status -- uptime, memory, message count, last activity',
|
|
13
|
+
'/health': 'Health check -- component status (db, telegram, claude)',
|
|
14
|
+
'/stop': 'Graceful shutdown of the service',
|
|
15
|
+
'/restart': 'Restart the service process',
|
|
16
|
+
'/reboot': 'Reboot the host operating system',
|
|
17
|
+
'/new': 'Start a new conversation session',
|
|
18
|
+
'/help': 'List available commands',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Routes slash commands to their respective handlers and manages
|
|
23
|
+
* the confirmation flow for dangerous operations.
|
|
24
|
+
*/
|
|
25
|
+
class CommandRouter {
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} deps
|
|
28
|
+
* @param {import('./bot.js').TelegramBot} deps.bot - Telegram bot instance
|
|
29
|
+
* @param {import('../logging.js').Logger} deps.logger - Logger instance
|
|
30
|
+
* @param {object} deps.conversationManager - Conversation manager with resetSession()
|
|
31
|
+
* @param {object} deps.processInfo - Runtime info provider
|
|
32
|
+
* @param {number} deps.processInfo.startTime - Timestamp (ms) when the service started
|
|
33
|
+
* @param {() => Promise<number>} deps.processInfo.getMessageCount - Async fn returning total message count
|
|
34
|
+
* @param {() => Promise<object>} deps.processInfo.getHealthStatus - Async fn returning component health
|
|
35
|
+
*/
|
|
36
|
+
constructor({ bot, logger, conversationManager, processInfo }) {
|
|
37
|
+
/** @type {import('./bot.js').TelegramBot} */
|
|
38
|
+
this._bot = bot;
|
|
39
|
+
|
|
40
|
+
/** @type {import('../logging.js').Logger} */
|
|
41
|
+
this._logger = logger;
|
|
42
|
+
|
|
43
|
+
/** @type {object} */
|
|
44
|
+
this._conversationManager = conversationManager;
|
|
45
|
+
|
|
46
|
+
/** @type {object} */
|
|
47
|
+
this._processInfo = processInfo;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pending confirmations keyed by chatId.
|
|
51
|
+
* @type {Map<string, { command: string, timestamp: number }>}
|
|
52
|
+
*/
|
|
53
|
+
this._pendingConfirmations = new Map();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check whether the given text is a slash command.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} text
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
isCommand(text) {
|
|
63
|
+
return typeof text === 'string' && text.startsWith('/');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Route an inbound message to the appropriate command handler.
|
|
68
|
+
* If the message is a "YES" reply to a pending confirmation, that
|
|
69
|
+
* confirmation is executed instead.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} message
|
|
72
|
+
* @param {string} message.chatId
|
|
73
|
+
* @param {string} message.userId
|
|
74
|
+
* @param {string} message.text
|
|
75
|
+
* @param {number} message.messageId
|
|
76
|
+
* @returns {Promise<boolean>} true if the message was handled as a command or confirmation
|
|
77
|
+
*/
|
|
78
|
+
async route(message) {
|
|
79
|
+
const { chatId, text, messageId } = message;
|
|
80
|
+
const trimmed = (text || '').trim();
|
|
81
|
+
|
|
82
|
+
// Check for pending confirmation reply
|
|
83
|
+
if (this._checkConfirmation(chatId, trimmed)) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!this.isCommand(trimmed)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Extract the command name (everything up to the first space or @)
|
|
92
|
+
const command = trimmed.split(/[\s@]/)[0].toLowerCase();
|
|
93
|
+
|
|
94
|
+
const replyOpts = { reply_to_message_id: messageId, parse_mode: undefined };
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
switch (command) {
|
|
98
|
+
case '/status':
|
|
99
|
+
await this._handleStatus(chatId, replyOpts);
|
|
100
|
+
break;
|
|
101
|
+
case '/health':
|
|
102
|
+
await this._handleHealth(chatId, replyOpts);
|
|
103
|
+
break;
|
|
104
|
+
case '/stop':
|
|
105
|
+
await this._handleStop(chatId, replyOpts);
|
|
106
|
+
break;
|
|
107
|
+
case '/restart':
|
|
108
|
+
await this._handleRestart(chatId, replyOpts);
|
|
109
|
+
break;
|
|
110
|
+
case '/reboot':
|
|
111
|
+
await this._handleReboot(chatId, replyOpts);
|
|
112
|
+
break;
|
|
113
|
+
case '/new':
|
|
114
|
+
await this._handleNew(chatId, replyOpts);
|
|
115
|
+
break;
|
|
116
|
+
case '/help':
|
|
117
|
+
await this._handleHelp(chatId, replyOpts);
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
await this._bot.sendMessage(
|
|
121
|
+
chatId,
|
|
122
|
+
escapeMarkdownV2(`Unknown command: ${command}\nType /help for available commands.`),
|
|
123
|
+
replyOpts,
|
|
124
|
+
);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
this._logger.error('commands', `Error handling ${command}: ${err.message}`);
|
|
129
|
+
await this._sendPlain(chatId, `Error executing ${command}: ${err.message}`, replyOpts);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Confirmation flow
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Request confirmation from the user for a dangerous operation.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} chatId
|
|
143
|
+
* @param {string} command - The command that requires confirmation
|
|
144
|
+
* @param {string} prompt - Message to send asking for confirmation
|
|
145
|
+
* @param {object} replyOpts
|
|
146
|
+
* @private
|
|
147
|
+
*/
|
|
148
|
+
async _requestConfirmation(chatId, command, prompt, replyOpts) {
|
|
149
|
+
this._pendingConfirmations.set(chatId, {
|
|
150
|
+
command,
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await this._sendPlain(chatId, prompt, replyOpts);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check whether an incoming message is a "YES" confirmation for a
|
|
159
|
+
* pending dangerous command. Expired confirmations are cleaned up.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} chatId
|
|
162
|
+
* @param {string} text
|
|
163
|
+
* @returns {boolean} true if a confirmation was matched and executed
|
|
164
|
+
* @private
|
|
165
|
+
*/
|
|
166
|
+
_checkConfirmation(chatId, text) {
|
|
167
|
+
const pending = this._pendingConfirmations.get(chatId);
|
|
168
|
+
if (!pending) return false;
|
|
169
|
+
|
|
170
|
+
// Clean up expired confirmations
|
|
171
|
+
if (Date.now() - pending.timestamp > CONFIRMATION_TTL_MS) {
|
|
172
|
+
this._pendingConfirmations.delete(chatId);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (text.toUpperCase() !== 'YES') {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this._pendingConfirmations.delete(chatId);
|
|
181
|
+
const { command } = pending;
|
|
182
|
+
|
|
183
|
+
// Execute the confirmed command asynchronously
|
|
184
|
+
this._executeConfirmed(chatId, command);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Execute a command that has been confirmed by the user.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} chatId
|
|
192
|
+
* @param {string} command
|
|
193
|
+
* @private
|
|
194
|
+
*/
|
|
195
|
+
async _executeConfirmed(chatId, command) {
|
|
196
|
+
try {
|
|
197
|
+
switch (command) {
|
|
198
|
+
case '/stop':
|
|
199
|
+
await this._sendPlain(chatId, 'Shutting down...');
|
|
200
|
+
this._logger.info('commands', 'Graceful shutdown initiated via /stop');
|
|
201
|
+
// Allow message to send before exiting
|
|
202
|
+
setTimeout(() => process.exit(0), 500);
|
|
203
|
+
break;
|
|
204
|
+
|
|
205
|
+
case '/restart':
|
|
206
|
+
await this._sendPlain(chatId, 'Restarting...');
|
|
207
|
+
this._logger.info('commands', 'Process restart initiated via /restart');
|
|
208
|
+
// Exit with a restart-indicating code; process manager (systemd) should restart
|
|
209
|
+
setTimeout(() => process.exit(0), 500);
|
|
210
|
+
break;
|
|
211
|
+
|
|
212
|
+
case '/reboot':
|
|
213
|
+
await this._sendPlain(chatId, 'Rebooting host system...');
|
|
214
|
+
this._logger.info('commands', 'OS reboot initiated via /reboot');
|
|
215
|
+
setTimeout(() => {
|
|
216
|
+
try {
|
|
217
|
+
execSync('sudo reboot', { timeout: 10_000 });
|
|
218
|
+
} catch (err) {
|
|
219
|
+
this._logger.error('commands', `Reboot failed: ${err.message}`);
|
|
220
|
+
this._sendPlain(chatId, `Reboot failed: ${err.message}`).catch(() => {});
|
|
221
|
+
}
|
|
222
|
+
}, 500);
|
|
223
|
+
break;
|
|
224
|
+
|
|
225
|
+
default:
|
|
226
|
+
this._logger.warn('commands', `Unknown confirmed command: ${command}`);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
this._logger.error('commands', `Failed to execute confirmed ${command}: ${err.message}`);
|
|
231
|
+
await this._sendPlain(chatId, `Failed to execute ${command}: ${err.message}`).catch(() => {});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Command handlers
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* /status -- System status summary.
|
|
241
|
+
* @private
|
|
242
|
+
*/
|
|
243
|
+
async _handleStatus(chatId, replyOpts) {
|
|
244
|
+
const uptimeMs = Date.now() - this._processInfo.startTime;
|
|
245
|
+
const uptime = this._formatUptime(uptimeMs);
|
|
246
|
+
const mem = process.memoryUsage();
|
|
247
|
+
|
|
248
|
+
let messageCount = 'N/A';
|
|
249
|
+
try {
|
|
250
|
+
messageCount = String(await this._processInfo.getMessageCount());
|
|
251
|
+
} catch {
|
|
252
|
+
// Fall through with N/A
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const lines = [
|
|
256
|
+
'System Status',
|
|
257
|
+
'',
|
|
258
|
+
`Uptime: ${uptime}`,
|
|
259
|
+
`Memory (RSS): ${this._formatBytes(mem.rss)}`,
|
|
260
|
+
`Memory (Heap Used): ${this._formatBytes(mem.heapUsed)} / ${this._formatBytes(mem.heapTotal)}`,
|
|
261
|
+
`Messages: ${messageCount}`,
|
|
262
|
+
`Last Activity: ${new Date().toISOString()}`,
|
|
263
|
+
`Node.js: ${process.version}`,
|
|
264
|
+
`PID: ${process.pid}`,
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
await this._sendPlain(chatId, lines.join('\n'), replyOpts);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* /health -- Component health check.
|
|
272
|
+
* @private
|
|
273
|
+
*/
|
|
274
|
+
async _handleHealth(chatId, replyOpts) {
|
|
275
|
+
let health;
|
|
276
|
+
try {
|
|
277
|
+
health = await this._processInfo.getHealthStatus();
|
|
278
|
+
} catch (err) {
|
|
279
|
+
await this._sendPlain(
|
|
280
|
+
chatId,
|
|
281
|
+
`Health check failed: ${err.message}`,
|
|
282
|
+
replyOpts,
|
|
283
|
+
);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const { status, components } = health;
|
|
288
|
+
const statusEmoji = status === 'ok' ? 'OK' : status === 'degraded' ? 'DEGRADED' : 'ERROR';
|
|
289
|
+
|
|
290
|
+
const lines = [`Health: ${statusEmoji}`, ''];
|
|
291
|
+
|
|
292
|
+
if (components) {
|
|
293
|
+
for (const [name, detail] of Object.entries(components)) {
|
|
294
|
+
const componentStatus = detail.ok ? 'ok' : 'error';
|
|
295
|
+
const info = detail.message ? ` -- ${detail.message}` : '';
|
|
296
|
+
lines.push(` ${name}: ${componentStatus}${info}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await this._sendPlain(chatId, lines.join('\n'), replyOpts);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* /stop -- Graceful shutdown with confirmation.
|
|
305
|
+
* @private
|
|
306
|
+
*/
|
|
307
|
+
async _handleStop(chatId, replyOpts) {
|
|
308
|
+
await this._requestConfirmation(
|
|
309
|
+
chatId,
|
|
310
|
+
'/stop',
|
|
311
|
+
'Are you sure you want to stop the service? Reply YES to confirm.',
|
|
312
|
+
replyOpts,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* /restart -- Process restart with confirmation.
|
|
318
|
+
* @private
|
|
319
|
+
*/
|
|
320
|
+
async _handleRestart(chatId, replyOpts) {
|
|
321
|
+
await this._requestConfirmation(
|
|
322
|
+
chatId,
|
|
323
|
+
'/restart',
|
|
324
|
+
'Are you sure you want to restart the service? Reply YES to confirm.',
|
|
325
|
+
replyOpts,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* /reboot -- OS reboot with confirmation.
|
|
331
|
+
* @private
|
|
332
|
+
*/
|
|
333
|
+
async _handleReboot(chatId, replyOpts) {
|
|
334
|
+
await this._requestConfirmation(
|
|
335
|
+
chatId,
|
|
336
|
+
'/reboot',
|
|
337
|
+
'Are you sure you want to reboot the host system? Reply YES to confirm.',
|
|
338
|
+
replyOpts,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* /new -- Start a new conversation session.
|
|
344
|
+
* @private
|
|
345
|
+
*/
|
|
346
|
+
async _handleNew(chatId, replyOpts) {
|
|
347
|
+
try {
|
|
348
|
+
if (this._conversationManager && typeof this._conversationManager.newSession === 'function') {
|
|
349
|
+
this._conversationManager.newSession();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this._logger.info('commands', 'New conversation session started via /new');
|
|
353
|
+
await this._sendPlain(
|
|
354
|
+
chatId,
|
|
355
|
+
'New conversation session started. Previous session is preserved in logs.',
|
|
356
|
+
replyOpts,
|
|
357
|
+
);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
this._logger.error('commands', `Failed to start new session: ${err.message}`);
|
|
360
|
+
await this._sendPlain(
|
|
361
|
+
chatId,
|
|
362
|
+
`Failed to start new session: ${err.message}`,
|
|
363
|
+
replyOpts,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* /help -- List all available commands.
|
|
370
|
+
* @private
|
|
371
|
+
*/
|
|
372
|
+
async _handleHelp(chatId, replyOpts) {
|
|
373
|
+
const lines = ['Available Commands', ''];
|
|
374
|
+
|
|
375
|
+
for (const [cmd, desc] of Object.entries(COMMAND_DESCRIPTIONS)) {
|
|
376
|
+
lines.push(`${cmd} -- ${desc}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
await this._sendPlain(chatId, lines.join('\n'), replyOpts);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Utilities
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Send a plain-text message (no MarkdownV2 parsing).
|
|
388
|
+
*
|
|
389
|
+
* @param {string} chatId
|
|
390
|
+
* @param {string} text
|
|
391
|
+
* @param {object} [replyOpts]
|
|
392
|
+
* @returns {Promise<object[]>}
|
|
393
|
+
* @private
|
|
394
|
+
*/
|
|
395
|
+
async _sendPlain(chatId, text, replyOpts = {}) {
|
|
396
|
+
return this._bot.sendMessage(chatId, text, {
|
|
397
|
+
...replyOpts,
|
|
398
|
+
parse_mode: undefined,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Format a duration in milliseconds to a human-readable string.
|
|
404
|
+
*
|
|
405
|
+
* @param {number} ms
|
|
406
|
+
* @returns {string} e.g. "2d 5h 23m 12s"
|
|
407
|
+
* @private
|
|
408
|
+
*/
|
|
409
|
+
_formatUptime(ms) {
|
|
410
|
+
const seconds = Math.floor(ms / 1_000) % 60;
|
|
411
|
+
const minutes = Math.floor(ms / 60_000) % 60;
|
|
412
|
+
const hours = Math.floor(ms / 3_600_000) % 24;
|
|
413
|
+
const days = Math.floor(ms / 86_400_000);
|
|
414
|
+
|
|
415
|
+
const parts = [];
|
|
416
|
+
if (days > 0) parts.push(`${days}d`);
|
|
417
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
418
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
419
|
+
parts.push(`${seconds}s`);
|
|
420
|
+
|
|
421
|
+
return parts.join(' ');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Format a byte count to a human-readable string.
|
|
426
|
+
*
|
|
427
|
+
* @param {number} bytes
|
|
428
|
+
* @returns {string} e.g. "12.34 MB"
|
|
429
|
+
* @private
|
|
430
|
+
*/
|
|
431
|
+
_formatBytes(bytes) {
|
|
432
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
433
|
+
if (bytes < 1_048_576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
434
|
+
if (bytes < 1_073_741_824) return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
|
435
|
+
return `${(bytes / 1_073_741_824).toFixed(2)} GB`;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export { CommandRouter, COMMAND_DESCRIPTIONS };
|
|
440
|
+
export default CommandRouter;
|