2ndbrain 2026.1.31 → 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.
@@ -10,7 +10,8 @@
10
10
  "WebFetch(domain:api.x.ai)",
11
11
  "WebFetch(domain:latenode.com)",
12
12
  "Bash(claude:*)",
13
- "Bash(printf:*)"
13
+ "Bash(printf:*)",
14
+ "Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\\\\migrations\"\" 2>/dev/null || echo \"No migrations directory \")"
14
15
  ]
15
16
  }
16
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "2ndbrain",
3
- "version": "2026.1.31",
3
+ "version": "2026.1.32",
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/db/migrate.js CHANGED
@@ -90,5 +90,5 @@ async function migrate() {
90
90
  return newlyApplied;
91
91
  }
92
92
 
93
- export { migrate };
93
+ export { migrate, getAppliedMigrations, getMigrationFiles, ensureMigrationsTable };
94
94
  export default migrate;
package/src/web/server.js CHANGED
@@ -3,6 +3,7 @@ import { createServer } from 'node:http';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { migrate, getMigrationFiles, ensureMigrationsTable } from '../db/migrate.js';
6
7
 
7
8
  // Resolve project root (two directories up from src/web/)
8
9
  const __filename = fileURLToPath(import.meta.url);
@@ -31,7 +32,7 @@ const SETTINGS_FIELDS = [
31
32
  section: 'Claude',
32
33
  fields: [
33
34
  { key: 'CLAUDE_MODEL', label: 'Model', hint: 'Default: claude-sonnet-4-20250514' },
34
- { key: 'CLAUDE_THINKING', label: 'Thinking', hint: 'Enable extended thinking (true/false)' },
35
+ { key: 'CLAUDE_THINKING', label: 'Thinking', type: 'boolean', hint: 'Enable extended thinking' },
35
36
  { key: 'CLAUDE_TIMEOUT', label: 'Timeout (ms)', hint: 'Default: 120000' },
36
37
  { key: 'CLAUDE_MAX_BUDGET', label: 'Max Budget (USD)', hint: 'Max cost per invocation (e.g. 0.50)' },
37
38
  ],
@@ -74,7 +75,7 @@ const SETTINGS_FIELDS = [
74
75
  fields: [
75
76
  { key: 'WEB_PORT', label: 'Port', hint: 'Default: 3000' },
76
77
  { key: 'WEB_BIND', label: 'Bind Address', hint: 'Default: 127.0.0.1' },
77
- { key: 'AUTO_OPEN_BROWSER', label: 'Auto Open Browser', hint: 'true/false (default: true)' },
78
+ { key: 'AUTO_OPEN_BROWSER', label: 'Auto Open Browser', type: 'boolean', hint: 'Default: true' },
78
79
  ],
79
80
  },
80
81
  {
@@ -127,6 +128,8 @@ class WebServer {
127
128
  app.post('/settings', (req, res) => this._handleSaveSettings(req, res));
128
129
  app.get('/logs', (req, res) => this._handleLogs(req, res));
129
130
  app.get('/health', (req, res) => this._handleHealth(req, res));
131
+ app.get('/database', (req, res) => this._handleDatabase(req, res));
132
+ app.post('/database/migrate', (req, res) => this._handleRunMigrations(req, res));
130
133
 
131
134
  // Start listening
132
135
  const server = createServer(app);
@@ -310,6 +313,86 @@ class WebServer {
310
313
  res.status(httpStatus).json(health);
311
314
  }
312
315
 
316
+ // -----------------------------------------------------------------------
317
+ // Database page
318
+ // -----------------------------------------------------------------------
319
+
320
+ async _handleDatabase(req, res) {
321
+ const data = {
322
+ dbAvailable: true,
323
+ migrations: { applied: [], pending: [], total: 0 },
324
+ tables: [],
325
+ dbVersion: '',
326
+ dbSize: '',
327
+ message: null,
328
+ };
329
+
330
+ if (req.query.migrated) {
331
+ const count = parseInt(req.query.migrated, 10) || 0;
332
+ data.message = { type: 'success', text: `Successfully applied ${count} migration(s).` };
333
+ } else if (req.query.error) {
334
+ data.message = { type: 'error', text: req.query.error };
335
+ } else if (req.query.noop === '1') {
336
+ data.message = { type: 'success', text: 'No pending migrations to apply.' };
337
+ }
338
+
339
+ try {
340
+ await ensureMigrationsTable();
341
+
342
+ const appliedResult = await this._db.query(
343
+ 'SELECT name, applied_at FROM schema_migrations ORDER BY name',
344
+ );
345
+ const appliedSet = new Set(appliedResult.rows.map((r) => r.name));
346
+ const allFiles = getMigrationFiles();
347
+
348
+ data.migrations.applied = appliedResult.rows;
349
+ data.migrations.pending = allFiles.filter((f) => !appliedSet.has(f));
350
+ data.migrations.total = allFiles.length;
351
+
352
+ const tablesResult = await this._db.query(`
353
+ SELECT
354
+ relname AS name,
355
+ n_live_tup AS row_count,
356
+ pg_size_pretty(pg_total_relation_size(c.oid)) AS size
357
+ FROM pg_class c
358
+ JOIN pg_namespace n ON n.oid = c.relnamespace
359
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
360
+ WHERE n.nspname = 'public'
361
+ AND c.relkind = 'r'
362
+ ORDER BY pg_total_relation_size(c.oid) DESC
363
+ `);
364
+ data.tables = tablesResult.rows;
365
+
366
+ const versionResult = await this._db.query('SELECT version()');
367
+ data.dbVersion = versionResult.rows[0]?.version || '';
368
+
369
+ const sizeResult = await this._db.query(
370
+ 'SELECT pg_size_pretty(pg_database_size(current_database())) AS size',
371
+ );
372
+ data.dbSize = sizeResult.rows[0]?.size || '';
373
+ } catch (err) {
374
+ data.dbAvailable = false;
375
+ this._logger.error('web', `Database page error: ${err.message}`);
376
+ }
377
+
378
+ res.send(databaseHTML(data));
379
+ }
380
+
381
+ async _handleRunMigrations(_req, res) {
382
+ try {
383
+ const applied = await migrate();
384
+ if (applied.length === 0) {
385
+ res.redirect('/database?noop=1');
386
+ } else {
387
+ this._logger.info('web', `Applied ${applied.length} migration(s) via web admin.`);
388
+ res.redirect(`/database?migrated=${applied.length}`);
389
+ }
390
+ } catch (err) {
391
+ this._logger.error('web', `Migration failed via web admin: ${err.message}`);
392
+ res.redirect(`/database?error=${encodeURIComponent(err.message)}`);
393
+ }
394
+ }
395
+
313
396
  // -----------------------------------------------------------------------
314
397
  // .env file helpers
315
398
  // -----------------------------------------------------------------------
@@ -587,6 +670,24 @@ function layoutHTML(title, content) {
587
670
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
588
671
  margin-bottom: 0.25rem;
589
672
  }
673
+ .checkbox-group {
674
+ display: flex;
675
+ align-items: center;
676
+ gap: 0.5rem;
677
+ padding-top: 0.35rem;
678
+ }
679
+ .checkbox-group input[type="checkbox"] {
680
+ width: 1.1rem;
681
+ height: 1.1rem;
682
+ accent-color: #238636;
683
+ cursor: pointer;
684
+ }
685
+ .checkbox-label {
686
+ font-size: 0.85rem;
687
+ color: #8b949e;
688
+ padding-top: 0;
689
+ cursor: pointer;
690
+ }
590
691
  button[type="submit"] {
591
692
  background: #238636;
592
693
  color: #fff;
@@ -680,6 +781,7 @@ function layoutHTML(title, content) {
680
781
  <a href="/">Dashboard</a>
681
782
  <a href="/settings">Settings</a>
682
783
  <a href="/logs">Logs</a>
784
+ <a href="/database">Database</a>
683
785
  </nav>
684
786
  <div class="container">
685
787
  ${content}
@@ -808,6 +910,15 @@ function settingsHTML(config, message) {
808
910
  value="" placeholder="Enter new value to change"
809
911
  autocomplete="off" />
810
912
  </div>`;
913
+ } else if (field.type === 'boolean') {
914
+ const checked = String(value).toLowerCase() === 'true' ? ' checked' : '';
915
+ inputArea = `
916
+ <div class="checkbox-group">
917
+ <input type="hidden" name="${esc(field.key)}" value="false" />
918
+ <input type="checkbox" id="${esc(field.key)}" name="${esc(field.key)}"
919
+ value="true"${checked} />
920
+ <label for="${esc(field.key)}" class="checkbox-label">Enabled</label>
921
+ </div>`;
811
922
  } else {
812
923
  inputArea = `<input type="text" name="${esc(field.key)}"
813
924
  value="${esc(String(value))}" />`;
@@ -872,6 +983,134 @@ function logsHTML(logs, activeLevel) {
872
983
  `);
873
984
  }
874
985
 
986
+ /**
987
+ * Database admin page showing migration status and table metadata.
988
+ */
989
+ function databaseHTML(data) {
990
+ const alert = data.message
991
+ ? `<div class="alert alert-${esc(data.message.type)}">${esc(data.message.text)}</div>`
992
+ : '';
993
+
994
+ const dbWarn = data.dbAvailable
995
+ ? ''
996
+ : '<div class="db-warn">Database is unavailable. Cannot retrieve database information.</div>';
997
+
998
+ const stats = data.dbAvailable
999
+ ? `
1000
+ <div class="grid">
1001
+ <div class="card">
1002
+ <div class="stat-label">Total Migrations</div>
1003
+ <div class="stat-value">${data.migrations.total}</div>
1004
+ </div>
1005
+ <div class="card">
1006
+ <div class="stat-label">Applied</div>
1007
+ <div class="stat-value" style="color:#3fb950;">${data.migrations.applied.length}</div>
1008
+ </div>
1009
+ <div class="card">
1010
+ <div class="stat-label">Pending</div>
1011
+ <div class="stat-value"${data.migrations.pending.length > 0 ? ' style="color:#d29922;"' : ''}>${data.migrations.pending.length}</div>
1012
+ </div>
1013
+ <div class="card">
1014
+ <div class="stat-label">Database Size</div>
1015
+ <div class="stat-value">${esc(data.dbSize)}</div>
1016
+ </div>
1017
+ </div>`
1018
+ : '';
1019
+
1020
+ const versionSection = data.dbVersion
1021
+ ? `<p class="muted" style="margin-bottom:1rem;font-size:0.82rem;">${esc(data.dbVersion)}</p>`
1022
+ : '';
1023
+
1024
+ let pendingSection = '';
1025
+ if (data.dbAvailable && data.migrations.pending.length > 0) {
1026
+ const pendingRows = data.migrations.pending
1027
+ .map(
1028
+ (name) => `
1029
+ <tr>
1030
+ <td><span class="badge badge-warn">pending</span></td>
1031
+ <td style="font-family:monospace;">${esc(name)}</td>
1032
+ <td class="muted">--</td>
1033
+ </tr>`,
1034
+ )
1035
+ .join('');
1036
+
1037
+ pendingSection = `
1038
+ <h2>Pending Migrations</h2>
1039
+ <div class="card" style="overflow-x:auto;">
1040
+ <table>
1041
+ <thead><tr><th>Status</th><th>Migration</th><th>Applied At</th></tr></thead>
1042
+ <tbody>${pendingRows}</tbody>
1043
+ </table>
1044
+ <form method="POST" action="/database/migrate"
1045
+ onsubmit="return confirm('Run ${data.migrations.pending.length} pending migration(s)?\\n\\nThis will modify the database schema.');"
1046
+ style="margin-top:1rem;">
1047
+ <button type="submit" style="background:#d29922;">Run ${data.migrations.pending.length} Pending Migration${data.migrations.pending.length === 1 ? '' : 's'}</button>
1048
+ </form>
1049
+ </div>`;
1050
+ }
1051
+
1052
+ let appliedSection = '';
1053
+ if (data.dbAvailable && data.migrations.applied.length > 0) {
1054
+ const appliedRows = data.migrations.applied
1055
+ .map(
1056
+ (m) => `
1057
+ <tr>
1058
+ <td><span class="badge badge-ok">applied</span></td>
1059
+ <td style="font-family:monospace;">${esc(m.name)}</td>
1060
+ <td>${fmtTime(m.applied_at)}</td>
1061
+ </tr>`,
1062
+ )
1063
+ .join('');
1064
+
1065
+ appliedSection = `
1066
+ <h2>Applied Migrations</h2>
1067
+ <div class="card" style="overflow-x:auto;">
1068
+ <table>
1069
+ <thead><tr><th>Status</th><th>Migration</th><th>Applied At</th></tr></thead>
1070
+ <tbody>${appliedRows}</tbody>
1071
+ </table>
1072
+ </div>`;
1073
+ } else if (data.dbAvailable && data.migrations.applied.length === 0) {
1074
+ appliedSection = `
1075
+ <h2>Applied Migrations</h2>
1076
+ <div class="card"><p class="empty">No migrations have been applied yet.</p></div>`;
1077
+ }
1078
+
1079
+ let tablesSection = '';
1080
+ if (data.dbAvailable && data.tables.length > 0) {
1081
+ const tableRows = data.tables
1082
+ .map(
1083
+ (t) => `
1084
+ <tr>
1085
+ <td style="font-family:monospace;">${esc(t.name)}</td>
1086
+ <td style="text-align:right;">${t.row_count ?? '--'}</td>
1087
+ <td style="text-align:right;">${esc(t.size)}</td>
1088
+ </tr>`,
1089
+ )
1090
+ .join('');
1091
+
1092
+ tablesSection = `
1093
+ <h2>Tables</h2>
1094
+ <div class="card" style="overflow-x:auto;">
1095
+ <table>
1096
+ <thead><tr><th>Table</th><th style="text-align:right;">Rows (est.)</th><th style="text-align:right;">Size</th></tr></thead>
1097
+ <tbody>${tableRows}</tbody>
1098
+ </table>
1099
+ </div>`;
1100
+ }
1101
+
1102
+ return layoutHTML('Database', `
1103
+ <h1>Database</h1>
1104
+ ${alert}
1105
+ ${dbWarn}
1106
+ ${versionSection}
1107
+ ${stats}
1108
+ ${pendingSection}
1109
+ ${appliedSection}
1110
+ ${tablesSection}
1111
+ `);
1112
+ }
1113
+
875
1114
  // ---------------------------------------------------------------------------
876
1115
  // Export
877
1116
  // ---------------------------------------------------------------------------