2ndbrain 2026.1.32 → 2026.1.33
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 +4 -1
- package/package.json +1 -1
- package/src/config.js +13 -0
- package/src/db/pool.js +35 -1
- package/src/index.js +2 -1
- package/src/web/server.js +161 -17
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
"WebFetch(domain:latenode.com)",
|
|
12
12
|
"Bash(claude:*)",
|
|
13
13
|
"Bash(printf:*)",
|
|
14
|
-
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\\\\migrations\"\" 2>/dev/null || echo \"No migrations directory \")"
|
|
14
|
+
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\\\\migrations\"\" 2>/dev/null || echo \"No migrations directory \")",
|
|
15
|
+
"Bash(node:*)",
|
|
16
|
+
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\src\\\\web\"\" 2>/dev/null || echo \"web directory not found \")",
|
|
17
|
+
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\"\" 2>/dev/null || echo \"Directory not accessible \")"
|
|
15
18
|
]
|
|
16
19
|
}
|
|
17
20
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "2ndbrain",
|
|
3
|
-
"version": "2026.1.
|
|
3
|
+
"version": "2026.1.33",
|
|
4
4
|
"description": "Always-on Node.js service bridging Telegram messaging to Claude AI with knowledge graph, journal, project management, and semantic search.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/config.js
CHANGED
|
@@ -60,6 +60,19 @@ const config = {
|
|
|
60
60
|
EMBEDDING_BASE_URL: env.EMBEDDING_BASE_URL || '',
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
// Ensure DATABASE_URL includes a database name (default: 2ndbrain)
|
|
64
|
+
if (config.DATABASE_URL) {
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(config.DATABASE_URL);
|
|
67
|
+
if (!url.pathname || url.pathname === '/') {
|
|
68
|
+
url.pathname = '/2ndbrain';
|
|
69
|
+
config.DATABASE_URL = url.toString();
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// leave as-is if URL parsing fails
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
const REQUIRED_VARS = ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_ALLOWED_USERS', 'DATABASE_URL'];
|
|
64
77
|
|
|
65
78
|
/**
|
package/src/db/pool.js
CHANGED
|
@@ -12,6 +12,40 @@ pool.on('error', (err) => {
|
|
|
12
12
|
console.error(`[${new Date().toISOString()}] [error] [db/pool] Unexpected idle client error:`, err.message);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Ensure the target database exists, creating it if necessary.
|
|
17
|
+
* Connects to the 'postgres' maintenance database to check/create.
|
|
18
|
+
*/
|
|
19
|
+
async function ensureDatabase() {
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(config.DATABASE_URL);
|
|
22
|
+
const dbName = url.pathname.slice(1); // strip leading '/'
|
|
23
|
+
if (!dbName) return;
|
|
24
|
+
|
|
25
|
+
// Build a maintenance URL pointing at the 'postgres' system database
|
|
26
|
+
const maintenanceUrl = new URL(config.DATABASE_URL);
|
|
27
|
+
maintenanceUrl.pathname = '/postgres';
|
|
28
|
+
|
|
29
|
+
const client = new pg.Client({ connectionString: maintenanceUrl.toString() });
|
|
30
|
+
await client.connect();
|
|
31
|
+
|
|
32
|
+
const result = await client.query(
|
|
33
|
+
'SELECT 1 FROM pg_database WHERE datname = $1',
|
|
34
|
+
[dbName],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (result.rows.length === 0) {
|
|
38
|
+
// Use double-quoted identifier to handle special characters in name
|
|
39
|
+
await client.query(`CREATE DATABASE "${dbName}"`);
|
|
40
|
+
console.log(`[${new Date().toISOString()}] [info] [db/pool] Created database "${dbName}".`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await client.end();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(`[${new Date().toISOString()}] [error] [db/pool] ensureDatabase failed: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
15
49
|
/**
|
|
16
50
|
* Execute a parameterized SQL query against the pool.
|
|
17
51
|
* @param {string} text - SQL query string
|
|
@@ -29,5 +63,5 @@ async function close() {
|
|
|
29
63
|
await pool.end();
|
|
30
64
|
}
|
|
31
65
|
|
|
32
|
-
export { pool, query, close };
|
|
66
|
+
export { pool, query, close, ensureDatabase };
|
|
33
67
|
export default pool;
|
package/src/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
import fs from 'node:fs';
|
|
21
21
|
|
|
22
22
|
import { config, validateConfig, isFirstRun, PROJECT_ROOT } from './config.js';
|
|
23
|
-
import { pool, query, close as closeDb } from './db/pool.js';
|
|
23
|
+
import { pool, query, close as closeDb, ensureDatabase } from './db/pool.js';
|
|
24
24
|
import { migrate } from './db/migrate.js';
|
|
25
25
|
import logger from './logging.js';
|
|
26
26
|
import { createRateLimiters } from './rate-limiter.js';
|
|
@@ -329,6 +329,7 @@ async function main() {
|
|
|
329
329
|
let dbReady = false;
|
|
330
330
|
if (config.DATABASE_URL) {
|
|
331
331
|
try {
|
|
332
|
+
await ensureDatabase();
|
|
332
333
|
await pool.query('SELECT 1');
|
|
333
334
|
logger.info('startup', 'Database connection established.');
|
|
334
335
|
dbReady = true;
|
package/src/web/server.js
CHANGED
|
@@ -187,8 +187,10 @@ class WebServer {
|
|
|
187
187
|
'SELECT COUNT(*)::int AS count FROM conversation_messages',
|
|
188
188
|
);
|
|
189
189
|
data.messageCount = countRes.rows[0]?.count ?? 0;
|
|
190
|
-
} catch {
|
|
190
|
+
} catch (err) {
|
|
191
191
|
data.dbAvailable = false;
|
|
192
|
+
data.dbError = diagnosePgError(err);
|
|
193
|
+
this._logger.error('web', `Dashboard DB error: ${err.message} (code=${err.code || 'none'})`);
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
if (data.dbAvailable) {
|
|
@@ -299,14 +301,19 @@ class WebServer {
|
|
|
299
301
|
|
|
300
302
|
try {
|
|
301
303
|
await this._db.query('SELECT 1');
|
|
302
|
-
} catch {
|
|
303
|
-
health.components.database =
|
|
304
|
+
} catch (err) {
|
|
305
|
+
health.components.database = {
|
|
306
|
+
status: 'error',
|
|
307
|
+
error: err.message || 'Unknown error',
|
|
308
|
+
code: err.code || undefined,
|
|
309
|
+
};
|
|
304
310
|
}
|
|
305
311
|
|
|
306
312
|
// Derive overall status from component states
|
|
307
313
|
const states = Object.values(health.components);
|
|
308
|
-
|
|
309
|
-
|
|
314
|
+
const isErr = (s) => s === 'error' || (typeof s === 'object' && s.status === 'error');
|
|
315
|
+
if (states.some(isErr)) {
|
|
316
|
+
health.status = states.every(isErr) ? 'error' : 'degraded';
|
|
310
317
|
}
|
|
311
318
|
|
|
312
319
|
const httpStatus = health.status === 'error' ? 503 : 200;
|
|
@@ -351,7 +358,7 @@ class WebServer {
|
|
|
351
358
|
|
|
352
359
|
const tablesResult = await this._db.query(`
|
|
353
360
|
SELECT
|
|
354
|
-
relname AS name,
|
|
361
|
+
c.relname AS name,
|
|
355
362
|
n_live_tup AS row_count,
|
|
356
363
|
pg_size_pretty(pg_total_relation_size(c.oid)) AS size
|
|
357
364
|
FROM pg_class c
|
|
@@ -372,7 +379,9 @@ class WebServer {
|
|
|
372
379
|
data.dbSize = sizeResult.rows[0]?.size || '';
|
|
373
380
|
} catch (err) {
|
|
374
381
|
data.dbAvailable = false;
|
|
375
|
-
|
|
382
|
+
data.dbError = diagnosePgError(err);
|
|
383
|
+
data.dbUrl = maskDatabaseUrl(this._config.DATABASE_URL);
|
|
384
|
+
this._logger.error('web', `Database page error: ${err.message} (code=${err.code || 'none'})`);
|
|
376
385
|
}
|
|
377
386
|
|
|
378
387
|
res.send(databaseHTML(data));
|
|
@@ -499,6 +508,83 @@ function maskValue(value) {
|
|
|
499
508
|
return s.slice(0, 4) + '*'.repeat(Math.min(s.length - 8, 20)) + s.slice(-4);
|
|
500
509
|
}
|
|
501
510
|
|
|
511
|
+
/** Mask the password in a PostgreSQL connection URL for safe display. */
|
|
512
|
+
function maskDatabaseUrl(url) {
|
|
513
|
+
if (!url) return '(not set)';
|
|
514
|
+
const schemeEnd = url.indexOf('://');
|
|
515
|
+
const lastAt = url.lastIndexOf('@');
|
|
516
|
+
if (schemeEnd >= 0 && lastAt > schemeEnd) {
|
|
517
|
+
const userInfo = url.slice(schemeEnd + 3, lastAt);
|
|
518
|
+
const colonPos = userInfo.indexOf(':');
|
|
519
|
+
if (colonPos >= 0) {
|
|
520
|
+
return url.slice(0, schemeEnd + 3 + colonPos + 1) + '****' + url.slice(lastAt);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return maskValue(url);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Extract diagnostic information from a pg / Node.js connection error.
|
|
528
|
+
* Returns { message, code, detail, hint, diagnosis }.
|
|
529
|
+
*/
|
|
530
|
+
function diagnosePgError(err) {
|
|
531
|
+
const info = {
|
|
532
|
+
message: err.message || 'Unknown error',
|
|
533
|
+
code: err.code || '',
|
|
534
|
+
detail: err.detail || '',
|
|
535
|
+
hint: err.hint || '',
|
|
536
|
+
diagnosis: '',
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
switch (err.code) {
|
|
540
|
+
case 'ECONNREFUSED':
|
|
541
|
+
info.diagnosis = 'Cannot connect to the database server. Verify the host and port are correct and that PostgreSQL is running.';
|
|
542
|
+
break;
|
|
543
|
+
case 'ENOTFOUND':
|
|
544
|
+
info.diagnosis = 'DNS lookup failed. The database hostname could not be resolved. Check the host in your DATABASE_URL.';
|
|
545
|
+
break;
|
|
546
|
+
case 'ETIMEDOUT':
|
|
547
|
+
info.diagnosis = 'Connection timed out. The database server may be unreachable or behind a firewall.';
|
|
548
|
+
break;
|
|
549
|
+
case 'ECONNRESET':
|
|
550
|
+
info.diagnosis = 'Connection was reset by the server. This may indicate a network issue or server restart.';
|
|
551
|
+
break;
|
|
552
|
+
default:
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!info.diagnosis && typeof err.code === 'string' && err.code.length === 5) {
|
|
557
|
+
const cls = err.code.substring(0, 2);
|
|
558
|
+
switch (cls) {
|
|
559
|
+
case '08':
|
|
560
|
+
info.diagnosis = 'Connection exception. The database server rejected or dropped the connection.';
|
|
561
|
+
break;
|
|
562
|
+
case '28':
|
|
563
|
+
info.diagnosis = 'Authentication failed. Check the username and password in your DATABASE_URL.';
|
|
564
|
+
break;
|
|
565
|
+
case '3D':
|
|
566
|
+
info.diagnosis = 'The specified database does not exist. Verify the database name in your DATABASE_URL.';
|
|
567
|
+
break;
|
|
568
|
+
case '53':
|
|
569
|
+
info.diagnosis = 'The database server has insufficient resources (too many connections, out of memory, or disk full).';
|
|
570
|
+
break;
|
|
571
|
+
case '57':
|
|
572
|
+
info.diagnosis = 'The database server is shutting down or not accepting connections.';
|
|
573
|
+
break;
|
|
574
|
+
default:
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!info.diagnosis && err.message) {
|
|
580
|
+
if (/ssl/i.test(err.message) || /certificate/i.test(err.message)) {
|
|
581
|
+
info.diagnosis = 'SSL/TLS error. Check your SSL configuration or try adding ?sslmode=require or ?sslmode=no-verify to your DATABASE_URL.';
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return info;
|
|
586
|
+
}
|
|
587
|
+
|
|
502
588
|
// ---------------------------------------------------------------------------
|
|
503
589
|
// HTML template functions
|
|
504
590
|
// ---------------------------------------------------------------------------
|
|
@@ -670,6 +756,28 @@ function layoutHTML(title, content) {
|
|
|
670
756
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
|
671
757
|
margin-bottom: 0.25rem;
|
|
672
758
|
}
|
|
759
|
+
.secret-input-wrapper {
|
|
760
|
+
position: relative;
|
|
761
|
+
}
|
|
762
|
+
.secret-input-wrapper input {
|
|
763
|
+
padding-right: 2.5rem;
|
|
764
|
+
}
|
|
765
|
+
.secret-toggle {
|
|
766
|
+
position: absolute;
|
|
767
|
+
right: 0.4rem;
|
|
768
|
+
top: 50%;
|
|
769
|
+
transform: translateY(-50%);
|
|
770
|
+
background: none;
|
|
771
|
+
border: none;
|
|
772
|
+
color: #8b949e;
|
|
773
|
+
cursor: pointer;
|
|
774
|
+
padding: 0.2rem;
|
|
775
|
+
font-size: 0.85rem;
|
|
776
|
+
line-height: 1;
|
|
777
|
+
}
|
|
778
|
+
.secret-toggle:hover {
|
|
779
|
+
color: #c9d1d9;
|
|
780
|
+
}
|
|
673
781
|
.checkbox-group {
|
|
674
782
|
display: flex;
|
|
675
783
|
align-items: center;
|
|
@@ -828,9 +936,17 @@ function dashboardHTML(data) {
|
|
|
828
936
|
</div>`;
|
|
829
937
|
|
|
830
938
|
// -- DB warning ----------------------------------------------------------
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
939
|
+
let dbWarn = '';
|
|
940
|
+
if (!data.dbAvailable) {
|
|
941
|
+
const e = data.dbError || {};
|
|
942
|
+
dbWarn = '<div class="db-warn">Database is unavailable. Dashboard data may be incomplete.';
|
|
943
|
+
if (e.diagnosis) {
|
|
944
|
+
dbWarn += `<br><span style="font-size:0.82rem;">${esc(e.diagnosis)}</span>`;
|
|
945
|
+
} else if (e.message) {
|
|
946
|
+
dbWarn += `<br><span style="font-size:0.82rem;">${esc(e.message)}</span>`;
|
|
947
|
+
}
|
|
948
|
+
dbWarn += '</div>';
|
|
949
|
+
}
|
|
834
950
|
|
|
835
951
|
// -- Recent messages -----------------------------------------------------
|
|
836
952
|
let messagesSection;
|
|
@@ -900,15 +1016,23 @@ function settingsHTML(config, message) {
|
|
|
900
1016
|
let inputArea;
|
|
901
1017
|
|
|
902
1018
|
if (field.secret) {
|
|
1019
|
+
const maskedDisplay = field.key === 'DATABASE_URL'
|
|
1020
|
+
? maskDatabaseUrl(String(value))
|
|
1021
|
+
: maskValue(String(value));
|
|
903
1022
|
const status = value
|
|
904
|
-
? `Current: ${esc(
|
|
1023
|
+
? `Current: ${esc(maskedDisplay)}`
|
|
905
1024
|
: '(not set)';
|
|
906
1025
|
inputArea = `
|
|
907
1026
|
<div>
|
|
908
1027
|
<div class="secret-current">${status}</div>
|
|
909
|
-
<
|
|
910
|
-
|
|
911
|
-
|
|
1028
|
+
<div class="secret-input-wrapper">
|
|
1029
|
+
<input type="password" id="input-${esc(field.key)}" name="${esc(field.key)}"
|
|
1030
|
+
value="" placeholder="Enter new value to change"
|
|
1031
|
+
autocomplete="off" />
|
|
1032
|
+
<button type="button" class="secret-toggle"
|
|
1033
|
+
onclick="const inp=document.getElementById('input-${esc(field.key)}');const show=inp.type==='password';inp.type=show?'text':'password';this.textContent=show?'◉':'◎';"
|
|
1034
|
+
title="Toggle visibility">◎</button>
|
|
1035
|
+
</div>
|
|
912
1036
|
</div>`;
|
|
913
1037
|
} else if (field.type === 'boolean') {
|
|
914
1038
|
const checked = String(value).toLowerCase() === 'true' ? ' checked' : '';
|
|
@@ -991,9 +1115,29 @@ function databaseHTML(data) {
|
|
|
991
1115
|
? `<div class="alert alert-${esc(data.message.type)}">${esc(data.message.text)}</div>`
|
|
992
1116
|
: '';
|
|
993
1117
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1118
|
+
let dbWarn = '';
|
|
1119
|
+
if (!data.dbAvailable) {
|
|
1120
|
+
const e = data.dbError || {};
|
|
1121
|
+
dbWarn = '<div class="db-warn">';
|
|
1122
|
+
dbWarn += '<strong>Database is unavailable.</strong> Cannot retrieve database information.';
|
|
1123
|
+
if (e.diagnosis) {
|
|
1124
|
+
dbWarn += `<br><br><strong>Diagnosis:</strong> ${esc(e.diagnosis)}`;
|
|
1125
|
+
}
|
|
1126
|
+
dbWarn += `<br><br><strong>Error:</strong> ${esc(e.message || 'Unknown')}`;
|
|
1127
|
+
if (e.code) {
|
|
1128
|
+
dbWarn += ` <span class="muted">(code: ${esc(e.code)})</span>`;
|
|
1129
|
+
}
|
|
1130
|
+
if (e.detail) {
|
|
1131
|
+
dbWarn += `<br><strong>Detail:</strong> ${esc(e.detail)}`;
|
|
1132
|
+
}
|
|
1133
|
+
if (e.hint) {
|
|
1134
|
+
dbWarn += `<br><strong>Hint:</strong> ${esc(e.hint)}`;
|
|
1135
|
+
}
|
|
1136
|
+
if (data.dbUrl) {
|
|
1137
|
+
dbWarn += `<br><br><strong>Connection URL:</strong> <code style="font-size:0.82rem;">${esc(data.dbUrl)}</code>`;
|
|
1138
|
+
}
|
|
1139
|
+
dbWarn += '</div>';
|
|
1140
|
+
}
|
|
997
1141
|
|
|
998
1142
|
const stats = data.dbAvailable
|
|
999
1143
|
? `
|