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.
@@ -0,0 +1,161 @@
1
+ # Skill: system-ops
2
+
3
+ ## Description
4
+
5
+ Check system health, uptime, memory usage, disk space, and database status. This skill provides **read-only** system diagnostics. It answers questions about how the system is running, surfaces recent errors, and reports resource usage.
6
+
7
+ ## READ-ONLY RESTRICTION
8
+
9
+ **THIS SKILL IS STRICTLY READ-ONLY.** You must NEVER use this skill to:
10
+
11
+ - Restart, reboot, or shut down the system or any service
12
+ - Kill or stop any process
13
+ - Modify any system configuration
14
+ - Delete any files or data
15
+ - Run any command that changes system state
16
+
17
+ Restart, reboot, and shutdown operations are handled exclusively by slash commands (`/restart`, `/reboot`, `/stop`) with confirmation prompts. If the user asks you to restart or reboot through conversation, direct them to use the appropriate slash command instead.
18
+
19
+ ## When to Activate
20
+
21
+ Activate this skill automatically when the user asks about system health or status. Common triggers include:
22
+
23
+ - "How's the system?" or "Is everything running?"
24
+ - "Check disk space" or "How much storage is left?"
25
+ - "Memory usage" or "How much RAM is being used?"
26
+ - "System uptime" or "How long has the system been running?"
27
+ - "Are there any errors?" or "Check for recent problems"
28
+ - "Database status" or "Is the database OK?"
29
+ - "System health" or "Give me a health check"
30
+
31
+ ## Available Tools
32
+
33
+ - **Bash** -- Execute read-only shell commands. Only the following commands are permitted:
34
+ - `uptime` -- System uptime
35
+ - `free -h` -- Memory usage
36
+ - `df -h` -- Disk usage
37
+ - `pg_isready` -- PostgreSQL connection check
38
+
39
+ - **`mcp__pg__query`** -- Execute read-only SQL queries against the PostgreSQL database.
40
+
41
+ No other tools are permitted for this skill.
42
+
43
+ ## Database Tables (Read-Only Access)
44
+
45
+ ### `system_logs`
46
+
47
+ | Column | Type | Description |
48
+ |--------|------|-------------|
49
+ | `id` | SERIAL PRIMARY KEY | Auto-incrementing identifier |
50
+ | `created_at` | TIMESTAMPTZ | Timestamp of the log entry |
51
+ | `level` | TEXT | Log level: `'debug'`, `'info'`, `'warn'`, `'error'` |
52
+ | `source` | TEXT | Component name (e.g., `'telegram'`, `'claude'`, `'process'`) |
53
+ | `content` | TEXT | Log message content |
54
+
55
+ ### `conversation_messages` (Read-Only Access)
56
+
57
+ | Column | Type | Description |
58
+ |--------|------|-------------|
59
+ | `id` | SERIAL PRIMARY KEY | Auto-incrementing identifier |
60
+ | `created_at` | TIMESTAMPTZ | Timestamp of the message |
61
+ | `session_id` | TEXT | Claude CLI session ID |
62
+ | `role` | TEXT | Message role: `'user'`, `'assistant'`, `'system'`, `'summary'` |
63
+ | `content` | TEXT | Message content |
64
+
65
+ ## Operations
66
+
67
+ ### System Uptime
68
+
69
+ Report how long the system has been running.
70
+
71
+ ```bash
72
+ uptime
73
+ ```
74
+
75
+ Present the output in a human-readable way (e.g., "The system has been running for 3 days, 4 hours").
76
+
77
+ ### Memory Usage
78
+
79
+ Check current RAM usage.
80
+
81
+ ```bash
82
+ free -h
83
+ ```
84
+
85
+ Summarize the key figures: total memory, used memory, available memory. Flag if available memory is critically low (under 10% of total).
86
+
87
+ ### Disk Usage
88
+
89
+ Check available disk space.
90
+
91
+ ```bash
92
+ df -h
93
+ ```
94
+
95
+ Focus on the root filesystem and any mounted data partitions. Flag if any partition is above 90% usage.
96
+
97
+ ### Database Status
98
+
99
+ Check whether PostgreSQL is accepting connections.
100
+
101
+ ```bash
102
+ pg_isready
103
+ ```
104
+
105
+ Also check the conversation message count as a basic activity indicator:
106
+
107
+ ```sql
108
+ SELECT COUNT(*) AS total_messages FROM conversation_messages;
109
+ ```
110
+
111
+ And check the most recent conversation activity:
112
+
113
+ ```sql
114
+ SELECT created_at AS last_activity
115
+ FROM conversation_messages
116
+ ORDER BY created_at DESC
117
+ LIMIT 1;
118
+ ```
119
+
120
+ ### Recent Errors
121
+
122
+ Retrieve the most recent error log entries.
123
+
124
+ ```sql
125
+ SELECT created_at, source, content
126
+ FROM system_logs
127
+ WHERE level = 'error'
128
+ ORDER BY created_at DESC
129
+ LIMIT 5;
130
+ ```
131
+
132
+ If no errors are found, report that clearly: "No recent errors found."
133
+
134
+ For a broader view including warnings:
135
+
136
+ ```sql
137
+ SELECT created_at, level, source, content
138
+ FROM system_logs
139
+ WHERE level IN ('error', 'warn')
140
+ ORDER BY created_at DESC
141
+ LIMIT 10;
142
+ ```
143
+
144
+ ### Full Health Summary
145
+
146
+ When the user asks for a general health check, run all of the above operations and present a consolidated summary covering:
147
+
148
+ 1. System uptime
149
+ 2. Memory usage (total / used / available)
150
+ 3. Disk usage (used / available / percent)
151
+ 4. Database status (connected / message count / last activity)
152
+ 5. Recent errors (count and brief summary, or "none")
153
+
154
+ ## Restrictions and Notes
155
+
156
+ - **READ-ONLY ONLY.** Never run commands that modify system state. No `sudo`, no `kill`, no `systemctl restart`, no `rm`, no writes of any kind.
157
+ - Only use the four whitelisted Bash commands: `uptime`, `free -h`, `df -h`, `pg_isready`. Do not execute any other shell commands through this skill.
158
+ - Only run SELECT queries against the database. Never INSERT, UPDATE, DELETE, or DROP.
159
+ - If the user asks to restart, reboot, or shut down the system, respond: "I can only check system status. To restart or reboot, please use the `/restart` or `/reboot` slash commands."
160
+ - Present diagnostic information in a clear, summarized format. Avoid dumping raw command output -- interpret it for the user.
161
+ - If any check fails (e.g., `pg_isready` returns an error), report the failure clearly and suggest next steps (e.g., "The database appears to be down. You may want to check the PostgreSQL service.").
@@ -0,0 +1,167 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ /**
6
+ * Map of common MIME types to file extensions.
7
+ * Falls back to the MIME subtype or "bin" for unknown types.
8
+ */
9
+ const MIME_TO_EXT = {
10
+ 'image/jpeg': 'jpg',
11
+ 'image/png': 'png',
12
+ 'image/gif': 'gif',
13
+ 'image/webp': 'webp',
14
+ 'image/bmp': 'bmp',
15
+ 'image/svg+xml': 'svg',
16
+ 'image/tiff': 'tiff',
17
+ 'video/mp4': 'mp4',
18
+ 'video/webm': 'webm',
19
+ 'video/quicktime': 'mov',
20
+ 'audio/ogg': 'ogg',
21
+ 'audio/mpeg': 'mp3',
22
+ 'audio/mp4': 'm4a',
23
+ 'audio/wav': 'wav',
24
+ 'audio/x-wav': 'wav',
25
+ 'application/pdf': 'pdf',
26
+ 'application/zip': 'zip',
27
+ 'application/gzip': 'gz',
28
+ 'application/x-tar': 'tar',
29
+ 'text/plain': 'txt',
30
+ 'text/html': 'html',
31
+ 'text/csv': 'csv',
32
+ 'application/json': 'json',
33
+ 'application/xml': 'xml',
34
+ 'application/octet-stream': 'bin',
35
+ };
36
+
37
+ /**
38
+ * Derive a file extension from a MIME type string.
39
+ *
40
+ * @param {string|undefined} mimeType
41
+ * @returns {string} File extension without leading dot.
42
+ */
43
+ function extFromMime(mimeType) {
44
+ if (!mimeType) return 'bin';
45
+ return MIME_TO_EXT[mimeType] || mimeType.split('/').pop() || 'bin';
46
+ }
47
+
48
+ /**
49
+ * Attachment store -- downloads Telegram file attachments, saves them to
50
+ * a date-organized directory tree under $DATA_DIR/attachments/, and records
51
+ * metadata in the attachments database table (spec section 8).
52
+ */
53
+ class AttachmentStore {
54
+ /**
55
+ * @param {object} deps
56
+ * @param {object} deps.db - Database query interface ({ query(sql, params) }).
57
+ * @param {object} deps.bot - Telegram bot adapter ({ downloadFile(fileId): Promise<Buffer> }).
58
+ * @param {object} deps.config - Application configuration (needs DATA_DIR).
59
+ * @param {object} deps.logger - Logger instance.
60
+ */
61
+ constructor({ db, bot, config, logger }) {
62
+ this.db = db;
63
+ this.bot = bot;
64
+ this.config = config;
65
+ this.logger = logger;
66
+ }
67
+
68
+ /**
69
+ * Download a Telegram attachment, persist it to disk, and insert a
70
+ * database record.
71
+ *
72
+ * Storage path: $DATA_DIR/attachments/YYYY/MM/DD/{uuid}.{ext}
73
+ *
74
+ * @param {object} attachment - Telegram attachment metadata.
75
+ * @param {string} attachment.file_id - Telegram file identifier.
76
+ * @param {string} [attachment.mime_type] - MIME type of the file.
77
+ * @param {number} [attachment.file_size] - File size in bytes.
78
+ * @param {number} messageId - ID of the parent conversation_messages row.
79
+ * @returns {Promise<{ id: number, filePath: string, mimeType: string|null, fileSize: number }>}
80
+ */
81
+ async save(attachment, messageId) {
82
+ const { file_id: fileId, mime_type: mimeType, file_size: fileSize } = attachment;
83
+
84
+ // 1. Download the file from Telegram
85
+ const fileBuffer = await this.bot.downloadFile(fileId);
86
+
87
+ // 2. Build the date-organized storage path
88
+ const now = new Date();
89
+ const year = String(now.getFullYear());
90
+ const month = String(now.getMonth() + 1).padStart(2, '0');
91
+ const day = String(now.getDate()).padStart(2, '0');
92
+
93
+ const ext = extFromMime(mimeType);
94
+ const filename = `${crypto.randomUUID()}.${ext}`;
95
+
96
+ const relativeDir = path.join('attachments', year, month, day);
97
+ const absoluteDir = path.join(this.config.DATA_DIR, relativeDir);
98
+ const relativePath = path.join(relativeDir, filename);
99
+ const absolutePath = path.join(absoluteDir, filename);
100
+
101
+ // 3. Create directory structure if needed
102
+ fs.mkdirSync(absoluteDir, { recursive: true });
103
+
104
+ // 4. Save the file to disk
105
+ fs.writeFileSync(absolutePath, fileBuffer);
106
+
107
+ const actualSize = fileSize ?? fileBuffer.length;
108
+
109
+ // 5. Insert record into the attachments table
110
+ const result = await this.db.query(
111
+ `INSERT INTO attachments (message_id, telegram_file_id, mime_type, file_path, file_size)
112
+ VALUES ($1, $2, $3, $4, $5)
113
+ RETURNING id`,
114
+ [messageId, fileId, mimeType || null, relativePath, actualSize],
115
+ );
116
+
117
+ const record = {
118
+ id: result.rows[0].id,
119
+ filePath: relativePath,
120
+ mimeType: mimeType || null,
121
+ fileSize: actualSize,
122
+ };
123
+
124
+ this.logger.info(
125
+ 'attachments',
126
+ `Saved attachment ${record.id}: ${relativePath} (${actualSize} bytes)`,
127
+ );
128
+
129
+ // 6. Return the attachment record
130
+ return record;
131
+ }
132
+
133
+ /**
134
+ * Retrieve all attachment records associated with a conversation message.
135
+ *
136
+ * @param {number} messageId - ID of the conversation_messages row.
137
+ * @returns {Promise<Array<{
138
+ * id: number,
139
+ * filePath: string,
140
+ * mimeType: string|null,
141
+ * fileSize: number,
142
+ * telegramFileId: string|null,
143
+ * createdAt: Date
144
+ * }>>}
145
+ */
146
+ async getByMessageId(messageId) {
147
+ const result = await this.db.query(
148
+ `SELECT id, file_path, mime_type, file_size, telegram_file_id, created_at
149
+ FROM attachments
150
+ WHERE message_id = $1
151
+ ORDER BY created_at ASC`,
152
+ [messageId],
153
+ );
154
+
155
+ return result.rows.map((row) => ({
156
+ id: row.id,
157
+ filePath: row.file_path,
158
+ mimeType: row.mime_type,
159
+ fileSize: row.file_size,
160
+ telegramFileId: row.telegram_file_id,
161
+ createdAt: row.created_at,
162
+ }));
163
+ }
164
+ }
165
+
166
+ export { AttachmentStore };
167
+ export default AttachmentStore;
@@ -0,0 +1,291 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+
4
+ /**
5
+ * Claude CLI subprocess bridge (spec section 5).
6
+ *
7
+ * Spawns `claude -p` as a child process for each conversational message.
8
+ * Emits 'typing' events during streaming (for refreshing Telegram typing
9
+ * indicator) and 'error' events on failures.
10
+ */
11
+ class ClaudeBridge extends EventEmitter {
12
+ /**
13
+ * @param {object} options
14
+ * @param {object} options.config - Application configuration object
15
+ * @param {object} options.logger - Structured logger instance
16
+ * @param {object} [options.hooks] - Optional lifecycle hooks
17
+ */
18
+ constructor({ config, logger, hooks = {} }) {
19
+ super();
20
+ this.config = config;
21
+ this.logger = logger;
22
+ this.hooks = hooks;
23
+
24
+ /** @type {import('node:child_process').ChildProcess | null} */
25
+ this.activeProcess = null;
26
+ }
27
+
28
+ /**
29
+ * Invoke the Claude CLI with a user message.
30
+ *
31
+ * For new sessions (no sessionId), spawns with full configuration flags.
32
+ * For continuations, spawns with --resume to continue the existing session.
33
+ * The user message is piped via stdin to handle special characters safely.
34
+ *
35
+ * @param {string} message - The user message to send
36
+ * @param {string|null} [sessionId=null] - Existing session ID for continuation
37
+ * @param {string} [systemPrompt=''] - System prompt for new sessions
38
+ * @returns {Promise<{ text: string, sessionId: string, cost: number, duration: number, toolCalls: Array }>}
39
+ */
40
+ async invoke(message, sessionId = null, systemPrompt = '') {
41
+ const startTime = Date.now();
42
+ const args = this._buildArgs(sessionId, systemPrompt);
43
+
44
+ return new Promise((resolve, reject) => {
45
+ const proc = spawn('claude', args, {
46
+ stdio: ['pipe', 'pipe', 'pipe'],
47
+ env: { ...process.env },
48
+ });
49
+
50
+ this.activeProcess = proc;
51
+
52
+ let stdoutBuffer = '';
53
+ let stderrBuffer = '';
54
+ const textChunks = [];
55
+ const toolCalls = [];
56
+ let resultData = null;
57
+ let timedOut = false;
58
+
59
+ // Set up the timeout guard
60
+ const timeout = setTimeout(() => {
61
+ timedOut = true;
62
+ this.logger.warn('claude', `Subprocess timed out after ${this.config.CLAUDE_TIMEOUT}ms`);
63
+ this.kill();
64
+ }, this.config.CLAUDE_TIMEOUT);
65
+
66
+ // Pipe the user message via stdin and close it
67
+ proc.stdin.write(message);
68
+ proc.stdin.end();
69
+
70
+ // Collect and parse stdout stream-json chunks
71
+ proc.stdout.on('data', (chunk) => {
72
+ stdoutBuffer += chunk.toString();
73
+
74
+ // Process complete lines (NDJSON: one JSON object per line)
75
+ const lines = stdoutBuffer.split('\n');
76
+ // Keep the last potentially incomplete line in the buffer
77
+ stdoutBuffer = lines.pop() || '';
78
+
79
+ for (const line of lines) {
80
+ const trimmed = line.trim();
81
+ if (!trimmed) continue;
82
+
83
+ try {
84
+ const parsed = JSON.parse(trimmed);
85
+ this._handleStreamChunk(parsed, textChunks, toolCalls);
86
+
87
+ if (parsed.type === 'result') {
88
+ resultData = parsed;
89
+ }
90
+ } catch {
91
+ // Non-JSON line; ignore
92
+ this.logger.debug('claude', `Non-JSON stdout line: ${trimmed}`);
93
+ }
94
+ }
95
+ });
96
+
97
+ // Monitor stderr for errors
98
+ proc.stderr.on('data', (chunk) => {
99
+ stderrBuffer += chunk.toString();
100
+ });
101
+
102
+ proc.on('close', (code) => {
103
+ clearTimeout(timeout);
104
+ this.activeProcess = null;
105
+
106
+ // Process any remaining data in the stdout buffer
107
+ if (stdoutBuffer.trim()) {
108
+ try {
109
+ const parsed = JSON.parse(stdoutBuffer.trim());
110
+ this._handleStreamChunk(parsed, textChunks, toolCalls);
111
+ if (parsed.type === 'result') {
112
+ resultData = parsed;
113
+ }
114
+ } catch {
115
+ // Ignore trailing non-JSON data
116
+ }
117
+ }
118
+
119
+ const duration = Date.now() - startTime;
120
+
121
+ if (timedOut) {
122
+ reject(new Error(`Claude subprocess timed out after ${this.config.CLAUDE_TIMEOUT}ms`));
123
+ return;
124
+ }
125
+
126
+ if (code !== 0 && !resultData) {
127
+ const errMsg = stderrBuffer.trim() || `Claude process exited with code ${code}`;
128
+ this.logger.error('claude', errMsg);
129
+ this.emit('error', new Error(errMsg));
130
+ reject(new Error(errMsg));
131
+ return;
132
+ }
133
+
134
+ if (stderrBuffer.trim()) {
135
+ this.logger.debug('claude', `stderr: ${stderrBuffer.trim()}`);
136
+ }
137
+
138
+ const text = textChunks.join('');
139
+ const resolvedSessionId = resultData?.session_id || sessionId || '';
140
+ const cost = resultData?.total_cost_usd ?? 0;
141
+
142
+ this.logger.info(
143
+ 'claude',
144
+ `Response received (session=${resolvedSessionId}, cost=$${cost.toFixed(4)}, duration=${duration}ms)`,
145
+ );
146
+
147
+ resolve({
148
+ text,
149
+ sessionId: resolvedSessionId,
150
+ cost,
151
+ duration,
152
+ toolCalls,
153
+ });
154
+ });
155
+
156
+ proc.on('error', (err) => {
157
+ clearTimeout(timeout);
158
+ this.activeProcess = null;
159
+ this.logger.error('claude', `Failed to spawn claude: ${err.message}`);
160
+ this.emit('error', err);
161
+ reject(err);
162
+ });
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Build the CLI argument array for the claude subprocess.
168
+ *
169
+ * @param {string|null} sessionId - Session ID for continuation, or null for new
170
+ * @param {string} systemPrompt - System prompt for new sessions
171
+ * @returns {string[]}
172
+ * @private
173
+ */
174
+ _buildArgs(sessionId, systemPrompt) {
175
+ const args = ['-p', '--output-format', 'stream-json', '--verbose'];
176
+
177
+ if (sessionId) {
178
+ // Continuation: resume an existing session
179
+ args.push('--resume', sessionId);
180
+ } else {
181
+ // New session: full configuration
182
+ args.push('--model', this.config.CLAUDE_MODEL);
183
+
184
+ if (systemPrompt) {
185
+ args.push('--system-prompt', systemPrompt);
186
+ }
187
+
188
+ args.push('--mcp-config', this.config.MCP_CONFIG_PATH);
189
+ args.push('--allowed-tools', this.config.MCP_TOOLS_WHITELIST);
190
+
191
+ if (this.config.CLAUDE_MAX_BUDGET) {
192
+ args.push('--max-budget-usd', this.config.CLAUDE_MAX_BUDGET);
193
+ }
194
+ }
195
+
196
+ return args;
197
+ }
198
+
199
+ /**
200
+ * Handle a single parsed stream-json chunk.
201
+ *
202
+ * Stream-json output consists of objects with a `type` field:
203
+ * - "assistant": contains text content chunks
204
+ * - "result": final object with session_id, total_cost_usd, usage
205
+ * - "tool_use": tool invocation records
206
+ *
207
+ * @param {object} chunk - Parsed JSON chunk
208
+ * @param {string[]} textChunks - Accumulator for assistant text
209
+ * @param {Array} toolCalls - Accumulator for tool call records
210
+ * @private
211
+ */
212
+ _handleStreamChunk(chunk, textChunks, toolCalls) {
213
+ switch (chunk.type) {
214
+ case 'assistant': {
215
+ // Extract text content from assistant messages
216
+ const content = chunk.message?.content;
217
+ if (Array.isArray(content)) {
218
+ for (const block of content) {
219
+ if (block.type === 'text' && block.text) {
220
+ textChunks.push(block.text);
221
+ }
222
+ }
223
+ } else if (typeof content === 'string') {
224
+ textChunks.push(content);
225
+ }
226
+
227
+ // Emit typing event so Telegram adapter can refresh the indicator
228
+ this.emit('typing');
229
+ break;
230
+ }
231
+
232
+ case 'content_block_delta': {
233
+ // Incremental text deltas during streaming
234
+ if (chunk.delta?.type === 'text_delta' && chunk.delta.text) {
235
+ textChunks.push(chunk.delta.text);
236
+ }
237
+ this.emit('typing');
238
+ break;
239
+ }
240
+
241
+ case 'tool_use': {
242
+ toolCalls.push({
243
+ id: chunk.id,
244
+ name: chunk.name,
245
+ input: chunk.input,
246
+ });
247
+ break;
248
+ }
249
+
250
+ case 'result': {
251
+ // Final result object -- extract any remaining text from the result
252
+ const resultContent = chunk.result?.content;
253
+ if (Array.isArray(resultContent)) {
254
+ for (const block of resultContent) {
255
+ if (block.type === 'text' && block.text) {
256
+ textChunks.push(block.text);
257
+ }
258
+ }
259
+ }
260
+ break;
261
+ }
262
+
263
+ default:
264
+ // Other chunk types (e.g., "system", "thinking") are logged at debug level
265
+ this.logger.debug('claude', `Stream chunk type: ${chunk.type}`);
266
+ break;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Returns true if a Claude subprocess is currently running.
272
+ * @returns {boolean}
273
+ */
274
+ isActive() {
275
+ return this.activeProcess !== null && this.activeProcess.exitCode === null;
276
+ }
277
+
278
+ /**
279
+ * Kill the active Claude subprocess, if any.
280
+ */
281
+ kill() {
282
+ if (this.activeProcess && this.activeProcess.exitCode === null) {
283
+ this.logger.warn('claude', 'Killing active subprocess');
284
+ this.activeProcess.kill('SIGTERM');
285
+ this.activeProcess = null;
286
+ }
287
+ }
288
+ }
289
+
290
+ export { ClaudeBridge };
291
+ export default ClaudeBridge;