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,75 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate the MCP configuration JSON for claude-cli and the runtime
|
|
6
|
+
* .claude/settings.json with subprocess hooks (spec sections 7 and 13.2).
|
|
7
|
+
*
|
|
8
|
+
* Writes:
|
|
9
|
+
* $DATA_DIR/claude-runtime/mcp-config.json -- MCP server definitions
|
|
10
|
+
* $DATA_DIR/claude-runtime/.claude/settings.json -- subprocess hooks
|
|
11
|
+
*
|
|
12
|
+
* @param {object} config - Application configuration object.
|
|
13
|
+
* Required: DATA_DIR, DATABASE_URL.
|
|
14
|
+
* Optional: EMBEDDING_PROVIDER (enables embed server entry),
|
|
15
|
+
* _embedServerUrl (runtime URL set by createEmbedTool).
|
|
16
|
+
* @returns {string} Absolute path to the written MCP config file.
|
|
17
|
+
*/
|
|
18
|
+
function generateMcpConfig(config) {
|
|
19
|
+
const runtimeDir = path.join(config.DATA_DIR, 'claude-runtime');
|
|
20
|
+
const claudeDir = path.join(runtimeDir, '.claude');
|
|
21
|
+
const hooksDir = path.join(runtimeDir, 'hooks');
|
|
22
|
+
const mcpConfigPath = path.join(runtimeDir, 'mcp-config.json');
|
|
23
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
24
|
+
|
|
25
|
+
// Ensure the full directory tree exists
|
|
26
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
27
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
28
|
+
|
|
29
|
+
// ---- MCP server configuration ----
|
|
30
|
+
|
|
31
|
+
const mcpConfig = {
|
|
32
|
+
mcpServers: {
|
|
33
|
+
pg: {
|
|
34
|
+
command: 'npx',
|
|
35
|
+
args: ['-y', '@modelcontextprotocol/server-postgres', config.DATABASE_URL],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Include the embed_query MCP server when embedding is enabled and the
|
|
41
|
+
// runtime HTTP server URL is available (set by createEmbedTool at startup).
|
|
42
|
+
if (config.EMBEDDING_PROVIDER && config._embedServerUrl) {
|
|
43
|
+
mcpConfig.mcpServers.embed = {
|
|
44
|
+
type: 'url',
|
|
45
|
+
url: config._embedServerUrl,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
|
|
50
|
+
|
|
51
|
+
// ---- Runtime .claude/settings.json with subprocess hooks (section 13.2) ----
|
|
52
|
+
|
|
53
|
+
const settings = {
|
|
54
|
+
hooks: {
|
|
55
|
+
PreToolUse: [
|
|
56
|
+
{
|
|
57
|
+
matcher: 'Bash',
|
|
58
|
+
hooks: [
|
|
59
|
+
{
|
|
60
|
+
type: 'command',
|
|
61
|
+
command: path.join(hooksDir, 'validate-command.sh'),
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
70
|
+
|
|
71
|
+
return mcpConfigPath;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { generateMcpConfig };
|
|
75
|
+
export default generateMcpConfig;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { URL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate a vector embedding for the given text by calling the configured
|
|
9
|
+
* OpenAI-compatible embedding provider API.
|
|
10
|
+
*
|
|
11
|
+
* Uses the node:https built-in module for the outbound API call.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} text - The text to embed.
|
|
14
|
+
* @param {object} config - Embedding configuration.
|
|
15
|
+
* @param {string} config.EMBEDDING_API_KEY - API key for the provider.
|
|
16
|
+
* @param {string} [config.EMBEDDING_MODEL] - Model name (default: text-embedding-3-small).
|
|
17
|
+
* @param {string} [config.EMBEDDING_BASE_URL] - Base URL (default: https://api.openai.com/v1).
|
|
18
|
+
* @param {string|number} [config.EMBEDDING_DIMENSIONS] - Override output dimensions.
|
|
19
|
+
* @returns {Promise<{ vector: number[], dimensions: number }>}
|
|
20
|
+
*/
|
|
21
|
+
function generateEmbedding(text, config) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const baseUrl = config.EMBEDDING_BASE_URL || DEFAULT_BASE_URL;
|
|
24
|
+
const url = new URL(`${baseUrl}/embeddings`);
|
|
25
|
+
const model = config.EMBEDDING_MODEL || 'text-embedding-3-small';
|
|
26
|
+
|
|
27
|
+
const payload = { input: text, model };
|
|
28
|
+
if (config.EMBEDDING_DIMENSIONS) {
|
|
29
|
+
payload.dimensions = parseInt(config.EMBEDDING_DIMENSIONS, 10);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const body = JSON.stringify(payload);
|
|
33
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
34
|
+
|
|
35
|
+
const options = {
|
|
36
|
+
hostname: url.hostname,
|
|
37
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
38
|
+
path: url.pathname + url.search,
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
'Authorization': `Bearer ${config.EMBEDDING_API_KEY}`,
|
|
43
|
+
'Content-Length': Buffer.byteLength(body),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const req = transport.request(options, (res) => {
|
|
48
|
+
let data = '';
|
|
49
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
50
|
+
res.on('end', () => {
|
|
51
|
+
try {
|
|
52
|
+
if (res.statusCode !== 200) {
|
|
53
|
+
reject(new Error(`Embedding API error (HTTP ${res.statusCode}): ${data}`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const parsed = JSON.parse(data);
|
|
57
|
+
const embedding = parsed.data[0].embedding;
|
|
58
|
+
resolve({ vector: embedding, dimensions: embedding.length });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
reject(new Error(`Failed to parse embedding response: ${err.message}`));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
req.on('error', (err) => {
|
|
66
|
+
reject(new Error(`Embedding API request failed: ${err.message}`));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
req.write(body);
|
|
70
|
+
req.end();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// MCP JSON-RPC handler
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Handle an incoming MCP JSON-RPC 2.0 request.
|
|
80
|
+
*
|
|
81
|
+
* Supported methods:
|
|
82
|
+
* - initialize -- handshake, returns server capabilities
|
|
83
|
+
* - notifications/* -- acknowledged silently (no response)
|
|
84
|
+
* - tools/list -- returns the embed_query tool definition
|
|
85
|
+
* - tools/call -- executes embed_query
|
|
86
|
+
*
|
|
87
|
+
* @param {object} request - JSON-RPC 2.0 request object.
|
|
88
|
+
* @param {object} config - Application configuration.
|
|
89
|
+
* @returns {Promise<object|null>} JSON-RPC response, or null for notifications.
|
|
90
|
+
*/
|
|
91
|
+
async function handleMcpRequest(request, config) {
|
|
92
|
+
const { method, id, params } = request;
|
|
93
|
+
|
|
94
|
+
// Notifications have no id and require no response
|
|
95
|
+
if (id === undefined || id === null) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
switch (method) {
|
|
100
|
+
case 'initialize':
|
|
101
|
+
return {
|
|
102
|
+
jsonrpc: '2.0',
|
|
103
|
+
id,
|
|
104
|
+
result: {
|
|
105
|
+
protocolVersion: '2024-11-05',
|
|
106
|
+
capabilities: { tools: {} },
|
|
107
|
+
serverInfo: { name: '2ndbrain-embed', version: '0.5.0' },
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
case 'tools/list':
|
|
112
|
+
return {
|
|
113
|
+
jsonrpc: '2.0',
|
|
114
|
+
id,
|
|
115
|
+
result: {
|
|
116
|
+
tools: [
|
|
117
|
+
{
|
|
118
|
+
name: 'embed_query',
|
|
119
|
+
description:
|
|
120
|
+
'Generate a vector embedding for a search query. ' +
|
|
121
|
+
'Returns { vector: [...], dimensions: N }.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
text: {
|
|
126
|
+
type: 'string',
|
|
127
|
+
description: 'The search query text to embed',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
required: ['text'],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
case 'tools/call': {
|
|
138
|
+
if (params?.name !== 'embed_query') {
|
|
139
|
+
return {
|
|
140
|
+
jsonrpc: '2.0',
|
|
141
|
+
id,
|
|
142
|
+
error: { code: -32601, message: `Unknown tool: ${params?.name}` },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const result = await generateEmbedding(params.arguments.text, config);
|
|
148
|
+
return {
|
|
149
|
+
jsonrpc: '2.0',
|
|
150
|
+
id,
|
|
151
|
+
result: {
|
|
152
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return {
|
|
157
|
+
jsonrpc: '2.0',
|
|
158
|
+
id,
|
|
159
|
+
result: {
|
|
160
|
+
content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
|
|
161
|
+
isError: true,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
default:
|
|
168
|
+
return {
|
|
169
|
+
jsonrpc: '2.0',
|
|
170
|
+
id,
|
|
171
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// HTTP-based MCP server
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Create and start a lightweight HTTP-based MCP server that exposes the
|
|
182
|
+
* embed_query tool. The server listens on 127.0.0.1 with an
|
|
183
|
+
* OS-assigned port so there is no conflict risk.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} config - Application configuration.
|
|
186
|
+
* @returns {Promise<{ server: http.Server, port: number, url: string }|null>}
|
|
187
|
+
* Resolves with server details, or null when EMBEDDING_PROVIDER is not set.
|
|
188
|
+
*/
|
|
189
|
+
function createEmbedTool(config) {
|
|
190
|
+
if (!config.EMBEDDING_PROVIDER) {
|
|
191
|
+
return Promise.resolve(null);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
const server = http.createServer((req, res) => {
|
|
196
|
+
// Only POST is meaningful for MCP JSON-RPC
|
|
197
|
+
if (req.method !== 'POST') {
|
|
198
|
+
res.writeHead(405, { Allow: 'POST' });
|
|
199
|
+
res.end();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let body = '';
|
|
204
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
205
|
+
req.on('end', async () => {
|
|
206
|
+
try {
|
|
207
|
+
const request = JSON.parse(body);
|
|
208
|
+
const response = await handleMcpRequest(request, config);
|
|
209
|
+
|
|
210
|
+
if (response === null) {
|
|
211
|
+
// Notification -- acknowledge with 202 No Content
|
|
212
|
+
res.writeHead(202);
|
|
213
|
+
res.end();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
218
|
+
res.end(JSON.stringify(response));
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const errorResponse = {
|
|
221
|
+
jsonrpc: '2.0',
|
|
222
|
+
id: null,
|
|
223
|
+
error: { code: -32700, message: `Parse error: ${err.message}` },
|
|
224
|
+
};
|
|
225
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
226
|
+
res.end(JSON.stringify(errorResponse));
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
server.on('error', reject);
|
|
232
|
+
|
|
233
|
+
server.listen(0, '127.0.0.1', () => {
|
|
234
|
+
const { port } = server.address();
|
|
235
|
+
const url = `http://127.0.0.1:${port}`;
|
|
236
|
+
resolve({ server, port, url });
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export { createEmbedTool, generateEmbedding };
|
|
242
|
+
export default createEmbedTool;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import config from './config.js';
|
|
2
|
+
|
|
3
|
+
const WINDOW_MS = 60_000; // 1-minute sliding window
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sliding-window rate limiter. Tracks call timestamps and queues
|
|
7
|
+
* requests that exceed the per-minute limit until a slot opens.
|
|
8
|
+
*/
|
|
9
|
+
class RateLimiter {
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} name - Identifier for this limiter (e.g. 'claude', 'telegram')
|
|
12
|
+
* @param {number} maxPerMinute - Maximum calls allowed per minute
|
|
13
|
+
*/
|
|
14
|
+
constructor(name, maxPerMinute) {
|
|
15
|
+
this.name = name;
|
|
16
|
+
this.maxPerMinute = maxPerMinute;
|
|
17
|
+
/** @type {number[]} Timestamps (ms) of recent calls */
|
|
18
|
+
this._timestamps = [];
|
|
19
|
+
/** @type {Array<{ resolve: () => void }>} Queued waiters */
|
|
20
|
+
this._queue = [];
|
|
21
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
22
|
+
this._drainTimer = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Prune timestamps older than the sliding window.
|
|
27
|
+
*/
|
|
28
|
+
_prune() {
|
|
29
|
+
const cutoff = Date.now() - WINDOW_MS;
|
|
30
|
+
while (this._timestamps.length > 0 && this._timestamps[0] <= cutoff) {
|
|
31
|
+
this._timestamps.shift();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Attempt to drain queued waiters whenever a slot becomes available.
|
|
37
|
+
*/
|
|
38
|
+
_scheduleDrain() {
|
|
39
|
+
if (this._drainTimer !== null || this._queue.length === 0) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Determine when the oldest timestamp expires to free a slot
|
|
44
|
+
this._prune();
|
|
45
|
+
if (this._timestamps.length < this.maxPerMinute) {
|
|
46
|
+
// Slot available right now
|
|
47
|
+
this._drain();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const waitMs = this._timestamps[0] + WINDOW_MS - Date.now() + 1;
|
|
52
|
+
this._drainTimer = setTimeout(() => {
|
|
53
|
+
this._drainTimer = null;
|
|
54
|
+
this._drain();
|
|
55
|
+
}, Math.max(waitMs, 0));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Release as many queued waiters as there are available slots.
|
|
60
|
+
*/
|
|
61
|
+
_drain() {
|
|
62
|
+
this._prune();
|
|
63
|
+
while (this._queue.length > 0 && this._timestamps.length < this.maxPerMinute) {
|
|
64
|
+
this._timestamps.push(Date.now());
|
|
65
|
+
const waiter = this._queue.shift();
|
|
66
|
+
waiter.resolve();
|
|
67
|
+
}
|
|
68
|
+
// If there are still queued items, schedule the next drain
|
|
69
|
+
if (this._queue.length > 0) {
|
|
70
|
+
this._scheduleDrain();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Acquire a rate-limit slot. Resolves immediately if under the limit;
|
|
76
|
+
* otherwise queues and resolves when a slot opens.
|
|
77
|
+
* @returns {Promise<void>}
|
|
78
|
+
*/
|
|
79
|
+
acquire() {
|
|
80
|
+
this._prune();
|
|
81
|
+
|
|
82
|
+
if (this._timestamps.length < this.maxPerMinute) {
|
|
83
|
+
this._timestamps.push(Date.now());
|
|
84
|
+
return Promise.resolve();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
this._queue.push({ resolve });
|
|
89
|
+
this._scheduleDrain();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns the number of requests currently waiting in the queue.
|
|
95
|
+
* @returns {number}
|
|
96
|
+
*/
|
|
97
|
+
queueDepth() {
|
|
98
|
+
return this._queue.length;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Factory that creates the standard rate limiters from config values.
|
|
104
|
+
* @returns {{ claude: RateLimiter, telegram: RateLimiter }}
|
|
105
|
+
*/
|
|
106
|
+
function createRateLimiters() {
|
|
107
|
+
return {
|
|
108
|
+
claude: new RateLimiter('claude', config.RATE_LIMIT_CLAUDE),
|
|
109
|
+
telegram: new RateLimiter('telegram', config.RATE_LIMIT_TELEGRAM),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export { RateLimiter, createRateLimiters };
|
|
114
|
+
export default createRateLimiters;
|