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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Conversation manager (spec section 6).
3
+ *
4
+ * Persists all conversation turns to the conversation_messages table,
5
+ * manages Claude CLI sessions, and controls history size through
6
+ * auto-compaction.
7
+ */
8
+ class ConversationManager {
9
+ /**
10
+ * @param {object} options
11
+ * @param {object} options.db - Database pool or query interface (must expose .query())
12
+ * @param {object} options.logger - Structured logger instance
13
+ * @param {object} options.config - Application configuration object
14
+ */
15
+ constructor({ db, logger, config }) {
16
+ this.db = db;
17
+ this.logger = logger;
18
+ this.config = config;
19
+
20
+ /** @type {string|null} Active Claude CLI session ID */
21
+ this.currentSessionId = null;
22
+ }
23
+
24
+ /**
25
+ * Save a conversation message to the database.
26
+ *
27
+ * @param {string} role - Message role: 'user', 'assistant', 'system', or 'summary'
28
+ * @param {string} content - Message text content
29
+ * @param {object} [metadata=null] - Optional JSONB metadata (tool calls, attachments, cost, etc.)
30
+ * @returns {Promise<object>} The inserted row
31
+ */
32
+ async saveMessage(role, content, metadata = null) {
33
+ const result = await this.db.query(
34
+ `INSERT INTO conversation_messages (session_id, role, content, metadata)
35
+ VALUES ($1, $2, $3, $4)
36
+ RETURNING *`,
37
+ [this.currentSessionId, role, content, metadata ? JSON.stringify(metadata) : null],
38
+ );
39
+
40
+ this.logger.debug(
41
+ 'conversation',
42
+ `Saved ${role} message (session=${this.currentSessionId || 'none'}, length=${content.length})`,
43
+ );
44
+
45
+ return result.rows[0];
46
+ }
47
+
48
+ /**
49
+ * Fetch recent conversation messages.
50
+ *
51
+ * @param {number} [limit] - Maximum number of messages to return.
52
+ * Defaults to HISTORY_COMPACT_THRESHOLD from config.
53
+ * @returns {Promise<Array<object>>} Messages ordered oldest-first
54
+ */
55
+ async getHistory(limit) {
56
+ const effectiveLimit = limit ?? this.config.HISTORY_COMPACT_THRESHOLD;
57
+
58
+ const result = await this.db.query(
59
+ `SELECT id, created_at, session_id, role, content, metadata
60
+ FROM conversation_messages
61
+ ORDER BY created_at DESC
62
+ LIMIT $1`,
63
+ [effectiveLimit],
64
+ );
65
+
66
+ // Return in chronological order (oldest first)
67
+ return result.rows.reverse();
68
+ }
69
+
70
+ /**
71
+ * Get the total number of conversation messages.
72
+ *
73
+ * @returns {Promise<number>}
74
+ */
75
+ async getMessageCount() {
76
+ const result = await this.db.query('SELECT COUNT(*)::int AS count FROM conversation_messages');
77
+ return result.rows[0].count;
78
+ }
79
+
80
+ /**
81
+ * Get the timestamp of the most recent conversation message.
82
+ *
83
+ * @returns {Promise<string|null>} ISO timestamp string, or null if no messages exist
84
+ */
85
+ async getLastActivity() {
86
+ const result = await this.db.query(
87
+ 'SELECT created_at FROM conversation_messages ORDER BY created_at DESC LIMIT 1',
88
+ );
89
+ return result.rows[0]?.created_at?.toISOString() ?? null;
90
+ }
91
+
92
+ /**
93
+ * Start a new conversation session.
94
+ *
95
+ * Sets currentSessionId to null so the next Claude invocation will
96
+ * create a fresh session. The previous session remains in the database.
97
+ */
98
+ newSession() {
99
+ this.logger.info('conversation', `Starting new session (previous=${this.currentSessionId || 'none'})`);
100
+ this.currentSessionId = null;
101
+ }
102
+
103
+ /**
104
+ * Update the current session ID (typically called after receiving
105
+ * the session_id from a Claude CLI result).
106
+ *
107
+ * @param {string} id - The Claude CLI session ID
108
+ */
109
+ setSessionId(id) {
110
+ this.currentSessionId = id;
111
+ this.logger.debug('conversation', `Session ID set to ${id}`);
112
+ }
113
+
114
+ /**
115
+ * Auto-compact conversation history when it exceeds the configured threshold.
116
+ *
117
+ * Compaction only runs when no Claude subprocess is active (prevents race
118
+ * conditions). Old messages are summarized via Claude and replaced with a
119
+ * single summary message, keeping the 20 most recent messages intact.
120
+ *
121
+ * @param {object} claudeBridge - ClaudeBridge instance (used to check activity and summarize)
122
+ * @returns {Promise<boolean>} True if compaction was performed
123
+ */
124
+ async compact(claudeBridge) {
125
+ // Guard: do not compact while Claude is processing a request
126
+ if (claudeBridge.isActive()) {
127
+ this.logger.debug('conversation', 'Skipping compaction: Claude subprocess is active');
128
+ return false;
129
+ }
130
+
131
+ const count = await this.getMessageCount();
132
+ const threshold = this.config.HISTORY_COMPACT_THRESHOLD;
133
+
134
+ if (count <= threshold) {
135
+ this.logger.debug('conversation', `No compaction needed (${count}/${threshold} messages)`);
136
+ return false;
137
+ }
138
+
139
+ this.logger.info('conversation', `Starting compaction (${count} messages, threshold=${threshold})`);
140
+
141
+ const keepRecent = 20;
142
+ const removeCount = count - keepRecent;
143
+
144
+ try {
145
+ // Fetch the oldest messages that will be summarized
146
+ const oldMessages = await this.db.query(
147
+ `SELECT id, created_at, role, content
148
+ FROM conversation_messages
149
+ ORDER BY created_at ASC
150
+ LIMIT $1`,
151
+ [removeCount],
152
+ );
153
+
154
+ if (oldMessages.rows.length === 0) {
155
+ return false;
156
+ }
157
+
158
+ // Format old messages for the summarization prompt
159
+ const formatted = oldMessages.rows
160
+ .map((msg) => `[${msg.role}] (${msg.created_at.toISOString()}): ${msg.content}`)
161
+ .join('\n\n');
162
+
163
+ const summarizationPrompt =
164
+ 'Summarize the following conversation history into a concise context summary. ' +
165
+ 'Preserve key facts, decisions, ongoing topics, and any important context. ' +
166
+ 'Use bullet points for clarity. This summary will replace the original messages ' +
167
+ 'to keep conversation history manageable.';
168
+
169
+ // Use the Claude bridge to generate a summary
170
+ const result = await claudeBridge.invoke(
171
+ `${summarizationPrompt}\n\n---\n\n${formatted}`,
172
+ null, // New session for the summary task
173
+ 'You are a conversation summarizer. Produce a concise, factual summary.',
174
+ );
175
+
176
+ if (!result.text) {
177
+ this.logger.warn('conversation', 'Compaction aborted: empty summary from Claude');
178
+ return false;
179
+ }
180
+
181
+ // Collect IDs of messages to delete
182
+ const idsToDelete = oldMessages.rows.map((msg) => msg.id);
183
+
184
+ // Insert the summary message
185
+ await this.db.query(
186
+ `INSERT INTO conversation_messages (session_id, role, content, metadata)
187
+ VALUES ($1, 'summary', $2, $3)`,
188
+ [
189
+ this.currentSessionId,
190
+ result.text,
191
+ JSON.stringify({
192
+ compacted_count: idsToDelete.length,
193
+ compacted_at: new Date().toISOString(),
194
+ summary_cost_usd: result.cost,
195
+ }),
196
+ ],
197
+ );
198
+
199
+ // Delete the original old messages
200
+ await this.db.query(
201
+ 'DELETE FROM conversation_messages WHERE id = ANY($1::int[])',
202
+ [idsToDelete],
203
+ );
204
+
205
+ this.logger.info(
206
+ 'conversation',
207
+ `Compaction complete: ${idsToDelete.length} messages replaced with summary`,
208
+ );
209
+
210
+ return true;
211
+ } catch (err) {
212
+ this.logger.error('conversation', `Compaction failed: ${err.message}`);
213
+ return false;
214
+ }
215
+ }
216
+ }
217
+
218
+ export { ConversationManager };
219
+ export default ConversationManager;
package/src/config.js ADDED
@@ -0,0 +1,90 @@
1
+ import { config as dotenvConfig } from 'dotenv';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import fs from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const PROJECT_ROOT = path.resolve(__dirname, '..');
10
+ const ENV_PATH = path.join(PROJECT_ROOT, '.env');
11
+
12
+ // Load .env from project root
13
+ dotenvConfig({ path: ENV_PATH });
14
+
15
+ const env = process.env;
16
+
17
+ const config = {
18
+ // Required
19
+ TELEGRAM_BOT_TOKEN: env.TELEGRAM_BOT_TOKEN || '',
20
+ TELEGRAM_ALLOWED_USERS: env.TELEGRAM_ALLOWED_USERS || '',
21
+ DATABASE_URL: env.DATABASE_URL || '',
22
+
23
+ // Claude Bridge
24
+ CLAUDE_MODEL: env.CLAUDE_MODEL || 'claude-sonnet-4-20250514',
25
+ CLAUDE_THINKING: env.CLAUDE_THINKING || 'true',
26
+ CLAUDE_TIMEOUT: parseInt(env.CLAUDE_TIMEOUT, 10) || 120000,
27
+ CLAUDE_MAX_BUDGET: env.CLAUDE_MAX_BUDGET || '',
28
+
29
+ // Storage
30
+ DATA_DIR: env.DATA_DIR || path.join(os.homedir(), 'data'),
31
+
32
+ // MCP
33
+ MCP_CONFIG_PATH: env.MCP_CONFIG_PATH || path.join(os.homedir(), '.claude', 'mcp.json'),
34
+ MCP_TOOLS_WHITELIST: env.MCP_TOOLS_WHITELIST || '*',
35
+ COMMANDS_WHITELIST: env.COMMANDS_WHITELIST || '',
36
+
37
+ // Security
38
+ FILE_EDIT_PATHS: env.FILE_EDIT_PATHS || '',
39
+
40
+ // Rate Limiting
41
+ RATE_LIMIT_CLAUDE: parseInt(env.RATE_LIMIT_CLAUDE, 10) || 10,
42
+ RATE_LIMIT_TELEGRAM: parseInt(env.RATE_LIMIT_TELEGRAM, 10) || 30,
43
+
44
+ // Conversation
45
+ HISTORY_COMPACT_THRESHOLD: parseInt(env.HISTORY_COMPACT_THRESHOLD, 10) || 100,
46
+
47
+ // Logging
48
+ LOG_LEVEL: env.LOG_LEVEL || 'info',
49
+
50
+ // Web Admin
51
+ WEB_PORT: parseInt(env.WEB_PORT, 10) || 3000,
52
+ WEB_BIND: env.WEB_BIND || '127.0.0.1',
53
+ AUTO_OPEN_BROWSER: env.AUTO_OPEN_BROWSER || 'true',
54
+
55
+ // Embeddings
56
+ EMBEDDING_PROVIDER: env.EMBEDDING_PROVIDER || '',
57
+ EMBEDDING_API_KEY: env.EMBEDDING_API_KEY || '',
58
+ EMBEDDING_MODEL: env.EMBEDDING_MODEL || 'text-embedding-3-small',
59
+ EMBEDDING_DIMENSIONS: env.EMBEDDING_DIMENSIONS || '',
60
+ EMBEDDING_BASE_URL: env.EMBEDDING_BASE_URL || '',
61
+ };
62
+
63
+ const REQUIRED_VARS = ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_ALLOWED_USERS', 'DATABASE_URL'];
64
+
65
+ /**
66
+ * Validates that all required configuration variables are present and non-empty.
67
+ * Returns an object with `valid` boolean and `missing` array of variable names.
68
+ */
69
+ function validateConfig() {
70
+ const missing = REQUIRED_VARS.filter((key) => !config[key]);
71
+ return {
72
+ valid: missing.length === 0,
73
+ missing,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Returns true if this appears to be a first run -- either the .env file
79
+ * does not exist or required configuration variables are missing.
80
+ */
81
+ function isFirstRun() {
82
+ if (!fs.existsSync(ENV_PATH)) {
83
+ return true;
84
+ }
85
+ const { valid } = validateConfig();
86
+ return !valid;
87
+ }
88
+
89
+ export { config, validateConfig, isFirstRun, PROJECT_ROOT, ENV_PATH };
90
+ export default config;
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { pool } from './pool.js';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const MIGRATIONS_DIR = path.resolve(__dirname, '../../db/migrations');
9
+
10
+ /**
11
+ * Ensure the schema_migrations tracking table exists.
12
+ */
13
+ async function ensureMigrationsTable() {
14
+ await pool.query(`
15
+ CREATE TABLE IF NOT EXISTS schema_migrations (
16
+ name TEXT PRIMARY KEY,
17
+ applied_at TIMESTAMPTZ DEFAULT NOW()
18
+ )
19
+ `);
20
+ }
21
+
22
+ /**
23
+ * Retrieve the set of already-applied migration names.
24
+ * @returns {Promise<Set<string>>}
25
+ */
26
+ async function getAppliedMigrations() {
27
+ const result = await pool.query('SELECT name FROM schema_migrations ORDER BY name');
28
+ return new Set(result.rows.map((row) => row.name));
29
+ }
30
+
31
+ /**
32
+ * Read all .sql migration files from the migrations directory, sorted by filename.
33
+ * @returns {string[]} Sorted array of filenames
34
+ */
35
+ function getMigrationFiles() {
36
+ if (!fs.existsSync(MIGRATIONS_DIR)) {
37
+ return [];
38
+ }
39
+ return fs
40
+ .readdirSync(MIGRATIONS_DIR)
41
+ .filter((f) => f.endsWith('.sql'))
42
+ .sort();
43
+ }
44
+
45
+ /**
46
+ * Run all pending database migrations inside individual transactions.
47
+ * Each migration that has not yet been recorded in schema_migrations
48
+ * is executed and tracked.
49
+ *
50
+ * @returns {Promise<string[]>} List of migration names that were applied
51
+ */
52
+ async function migrate() {
53
+ await ensureMigrationsTable();
54
+
55
+ const applied = await getAppliedMigrations();
56
+ const files = getMigrationFiles();
57
+ const newlyApplied = [];
58
+
59
+ for (const file of files) {
60
+ if (applied.has(file)) {
61
+ continue;
62
+ }
63
+
64
+ const filePath = path.join(MIGRATIONS_DIR, file);
65
+ const sql = fs.readFileSync(filePath, 'utf-8');
66
+
67
+ const client = await pool.connect();
68
+ try {
69
+ await client.query('BEGIN');
70
+ await client.query(sql);
71
+ await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]);
72
+ await client.query('COMMIT');
73
+ newlyApplied.push(file);
74
+ console.log(`[${new Date().toISOString()}] [info] [migrate] Applied migration: ${file}`);
75
+ } catch (err) {
76
+ await client.query('ROLLBACK');
77
+ console.error(`[${new Date().toISOString()}] [error] [migrate] Failed to apply migration ${file}:`, err.message);
78
+ throw err;
79
+ } finally {
80
+ client.release();
81
+ }
82
+ }
83
+
84
+ if (newlyApplied.length === 0) {
85
+ console.log(`[${new Date().toISOString()}] [info] [migrate] No pending migrations.`);
86
+ } else {
87
+ console.log(`[${new Date().toISOString()}] [info] [migrate] Applied ${newlyApplied.length} migration(s).`);
88
+ }
89
+
90
+ return newlyApplied;
91
+ }
92
+
93
+ export { migrate, getAppliedMigrations, getMigrationFiles, ensureMigrationsTable };
94
+ export default migrate;
package/src/db/pool.js ADDED
@@ -0,0 +1,33 @@
1
+ import pg from 'pg';
2
+ import config from '../config.js';
3
+
4
+ const { Pool } = pg;
5
+
6
+ const pool = new Pool({
7
+ connectionString: config.DATABASE_URL,
8
+ });
9
+
10
+ // Emit errors on idle clients so they don't crash the process
11
+ pool.on('error', (err) => {
12
+ console.error(`[${new Date().toISOString()}] [error] [db/pool] Unexpected idle client error:`, err.message);
13
+ });
14
+
15
+ /**
16
+ * Execute a parameterized SQL query against the pool.
17
+ * @param {string} text - SQL query string
18
+ * @param {Array} [params] - Query parameters
19
+ * @returns {Promise<pg.QueryResult>}
20
+ */
21
+ async function query(text, params) {
22
+ return pool.query(text, params);
23
+ }
24
+
25
+ /**
26
+ * Gracefully close all connections in the pool.
27
+ */
28
+ async function close() {
29
+ await pool.end();
30
+ }
31
+
32
+ export { pool, query, close };
33
+ export default pool;