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