@andespindola/brainlink 1.6.5 → 1.6.7

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/README.md CHANGED
@@ -611,7 +611,19 @@ blink server --host 127.0.0.1 --port 4321
611
611
  ```
612
612
 
613
613
  By default, the server uses `$HOME/.brainlink/vault`. Pass `--vault ./vault` only when you want to inspect a custom vault.
614
- By default, the server watches Markdown files in local filesystem vaults and reindexes after note changes. Use `--no-watch` to disable realtime reindexing.
614
+ By default, the server watches Markdown files in local filesystem vaults and reindexes after note changes. Use `--no-watch` to disable realtime reindexing. While the graph is open it polls `/api/graph-version` and re-fetches automatically, so notes an agent writes (via MCP) surface live without a manual reload.
615
+
616
+ ### Local vs exposed mode
617
+
618
+ `blink server` has two modes, mirroring the local/remote split of the MCP server:
619
+
620
+ - **Local (default):** loopback-only, no authentication — for your own machine.
621
+ - **Exposed (`blink server --expose`):** intended to sit behind a TLS-terminating reverse proxy. Every route is gated behind a **branded login** (not the browser's Basic-auth prompt); it allows a non-loopback bind and serves a public `/healthz`. Credentials come from a persistent store (`<brainlink-home>/web-auth.json`, seeded once from `BRAINLINK_WEB_USER` + `BRAINLINK_WEB_PASSWORD`); the session is a signed HttpOnly cookie, and the password can be changed at `/change-password` (the change persists across restarts). Exposed mode refuses to start without configured credentials.
622
+
623
+ ```bash
624
+ BRAINLINK_WEB_USER="you" BRAINLINK_WEB_PASSWORD="change-me" \
625
+ blink server --host 127.0.0.1 --port 4321 --no-open --expose
626
+ ```
615
627
  By default, `blink server` tries to open the graph in a native desktop GUI window:
616
628
  - macOS: Swift + WebKit
617
629
  - Windows: PowerShell WinForms WebBrowser
@@ -0,0 +1,185 @@
1
+ // Client module (served as a plain string, same pattern as live-refresh.ts) that
2
+ // injects an account control into the graph page when server-side auth is
3
+ // enabled. In local (no-auth) mode there is no account, so the control is never
4
+ // shown. The bootstrap calls startAccountMenu(); the module only defines it.
5
+ // Styling mirrors the branded auth pages: dark #08131d surfaces, subtle borders,
6
+ // and the #5aa8ff accent, kept unobtrusive in the top-right corner.
7
+ export const createAccountMenuJs = () => `
8
+ const startAccountMenu = () => {
9
+ if (typeof fetch !== 'function' || typeof document === 'undefined' || !document.body) {
10
+ return
11
+ }
12
+ if (document.getElementById('bl-account')) {
13
+ return
14
+ }
15
+
16
+ const injectStyles = () => {
17
+ if (document.getElementById('bl-account-styles')) {
18
+ return
19
+ }
20
+ const style = document.createElement('style')
21
+ style.id = 'bl-account-styles'
22
+ style.textContent = [
23
+ '.bl-account {',
24
+ ' position: fixed;',
25
+ ' top: 12px;',
26
+ ' right: 12px;',
27
+ ' z-index: 2147483000;',
28
+ ' font-family: "Crowquill Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;',
29
+ '}',
30
+ '.bl-account-button {',
31
+ ' display: inline-flex;',
32
+ ' align-items: center;',
33
+ ' gap: 6px;',
34
+ ' height: 32px;',
35
+ ' padding: 0 12px;',
36
+ ' border: 1px solid rgba(143, 172, 204, 0.18);',
37
+ ' border-radius: 8px;',
38
+ ' background: rgba(13, 24, 35, 0.92);',
39
+ ' color: #edf4ff;',
40
+ ' font: inherit;',
41
+ ' font-size: 12px;',
42
+ ' cursor: pointer;',
43
+ ' transition: border-color 120ms ease;',
44
+ '}',
45
+ '.bl-account-button:hover,',
46
+ '.bl-account-button:focus {',
47
+ ' border-color: #5aa8ff;',
48
+ ' outline: none;',
49
+ '}',
50
+ '.bl-account-menu {',
51
+ ' position: absolute;',
52
+ ' top: 40px;',
53
+ ' right: 0;',
54
+ ' min-width: 168px;',
55
+ ' padding: 6px;',
56
+ ' border: 1px solid rgba(143, 172, 204, 0.18);',
57
+ ' border-radius: 10px;',
58
+ ' background: rgba(13, 24, 35, 0.98);',
59
+ ' box-shadow: 0 18px 60px rgba(1, 6, 13, 0.48);',
60
+ ' display: grid;',
61
+ ' gap: 2px;',
62
+ '}',
63
+ '.bl-account-menu[hidden] {',
64
+ ' display: none;',
65
+ '}',
66
+ '.bl-account-item {',
67
+ ' display: block;',
68
+ ' width: 100%;',
69
+ ' padding: 8px 10px;',
70
+ ' border: 0;',
71
+ ' border-radius: 6px;',
72
+ ' background: transparent;',
73
+ ' color: #edf4ff;',
74
+ ' font: inherit;',
75
+ ' font-size: 12px;',
76
+ ' text-align: left;',
77
+ ' cursor: pointer;',
78
+ '}',
79
+ '.bl-account-item:hover,',
80
+ '.bl-account-item:focus {',
81
+ ' background: rgba(90, 168, 255, 0.18);',
82
+ ' outline: none;',
83
+ '}'
84
+ ].join('\\n')
85
+ document.head.appendChild(style)
86
+ }
87
+
88
+ const build = (user) => {
89
+ injectStyles()
90
+
91
+ const root = document.createElement('div')
92
+ root.id = 'bl-account'
93
+ root.className = 'bl-account'
94
+
95
+ const button = document.createElement('button')
96
+ button.type = 'button'
97
+ button.className = 'bl-account-button'
98
+ button.textContent = typeof user === 'string' && user.length > 0 ? user : 'Account'
99
+ button.setAttribute('aria-haspopup', 'true')
100
+ button.setAttribute('aria-expanded', 'false')
101
+
102
+ const menu = document.createElement('div')
103
+ menu.className = 'bl-account-menu'
104
+ menu.setAttribute('role', 'menu')
105
+ menu.hidden = true
106
+
107
+ const addItem = (label, onSelect) => {
108
+ const item = document.createElement('button')
109
+ item.type = 'button'
110
+ item.className = 'bl-account-item'
111
+ item.setAttribute('role', 'menuitem')
112
+ item.textContent = label
113
+ item.addEventListener('click', onSelect)
114
+ menu.appendChild(item)
115
+ }
116
+
117
+ const closeMenu = () => {
118
+ menu.hidden = true
119
+ button.setAttribute('aria-expanded', 'false')
120
+ }
121
+
122
+ const openMenu = () => {
123
+ menu.hidden = false
124
+ button.setAttribute('aria-expanded', 'true')
125
+ }
126
+
127
+ addItem('Settings', () => {
128
+ closeMenu()
129
+ window.location.href = '/settings'
130
+ })
131
+ addItem('Change password', () => {
132
+ closeMenu()
133
+ window.location.href = '/change-password'
134
+ })
135
+ addItem('Sign out', () => {
136
+ closeMenu()
137
+ fetch('/api/logout', { method: 'POST' })
138
+ .catch(() => {})
139
+ .then(() => {
140
+ window.location.href = '/login'
141
+ })
142
+ })
143
+
144
+ button.addEventListener('click', (event) => {
145
+ event.stopPropagation()
146
+ if (menu.hidden) {
147
+ openMenu()
148
+ } else {
149
+ closeMenu()
150
+ }
151
+ })
152
+
153
+ document.addEventListener('click', (event) => {
154
+ if (!root.contains(event.target)) {
155
+ closeMenu()
156
+ }
157
+ })
158
+
159
+ document.addEventListener('keydown', (event) => {
160
+ if (event.key === 'Escape') {
161
+ closeMenu()
162
+ }
163
+ })
164
+
165
+ root.appendChild(button)
166
+ root.appendChild(menu)
167
+ document.body.appendChild(root)
168
+ }
169
+
170
+ fetch('/api/auth-status', { headers: { accept: 'application/json' } })
171
+ .then((response) => {
172
+ if (!response.ok) {
173
+ return null
174
+ }
175
+ return response.json().catch(() => null)
176
+ })
177
+ .then((payload) => {
178
+ if (!payload || payload.enabled !== true) {
179
+ return
180
+ }
181
+ build(payload.user)
182
+ })
183
+ .catch(() => {})
184
+ }
185
+ `;
@@ -71,6 +71,7 @@ const state = {
71
71
  workerReady: false,
72
72
  rendererMode: 'worker',
73
73
  renderWorker: null,
74
+ graphVersion: null,
74
75
  agentId: '',
75
76
  contextId: '',
76
77
  graphSignature: '',
@@ -0,0 +1,42 @@
1
+ export const createLiveRefreshJs = () => `
2
+ // Poll a cheap generation counter so the graph reflects vault changes live:
3
+ // when an agent writes notes via MCP, the watcher bumps the version and the
4
+ // client re-fetches the current viewport and refreshes the context list, so new
5
+ // nodes and contexts surface without a manual reload.
6
+ const liveRefreshIntervalMs = 3000
7
+
8
+ const pollGraphVersion = async () => {
9
+ try {
10
+ const response = await fetch('/api/graph-version', { headers: { accept: 'application/json' } })
11
+ if (!response.ok) {
12
+ return
13
+ }
14
+ const payload = await response.json()
15
+ const version = typeof payload?.version === 'number' ? payload.version : null
16
+ if (version === null) {
17
+ return
18
+ }
19
+ if (state.graphVersion === null) {
20
+ state.graphVersion = version
21
+ return
22
+ }
23
+ if (version !== state.graphVersion) {
24
+ state.graphVersion = version
25
+ await loadContexts().catch((error) => console.error(error))
26
+ scheduleChunkFetch()
27
+ }
28
+ } catch (error) {
29
+ // Transient poll failures (offline, redeploy) are non-fatal; the next tick retries.
30
+ }
31
+ }
32
+
33
+ const startLiveRefresh = () => {
34
+ if (typeof fetch !== 'function') {
35
+ return
36
+ }
37
+ pollGraphVersion().catch(() => {})
38
+ setInterval(() => {
39
+ pollGraphVersion().catch(() => {})
40
+ }, liveRefreshIntervalMs)
41
+ }
42
+ `;
@@ -211,6 +211,8 @@ const bootstrap = async () => {
211
211
  updateTotals()
212
212
 
213
213
  scheduleChunkFetch({ fit: true })
214
+ startLiveRefresh()
215
+ startAccountMenu()
214
216
  }
215
217
 
216
218
  bootstrap().catch((error) => {
@@ -8,6 +8,8 @@ import { createChunkFetchJs } from './client/chunk-fetch.js';
8
8
  import { createInputJs } from './client/input.js';
9
9
  import { createUploadJs } from './client/upload.js';
10
10
  import { createControlsJs } from './client/controls.js';
11
+ import { createLiveRefreshJs } from './client/live-refresh.js';
12
+ import { createAccountMenuJs } from './client/account-menu.js';
11
13
  import { createGraphRendererJs } from './client/graph-renderer.js';
12
14
  import { createWorkerBootstrapJs } from './client/worker-bootstrap.js';
13
15
  export const createClientJs = () => [
@@ -21,6 +23,8 @@ export const createClientJs = () => [
21
23
  createInputJs(),
22
24
  createUploadJs(),
23
25
  createControlsJs(),
26
+ createLiveRefreshJs(),
27
+ createAccountMenuJs(),
24
28
  createGraphRendererJs(),
25
29
  createWorkerBootstrapJs()
26
30
  ].join('');
@@ -0,0 +1,322 @@
1
+ // Self-contained branded Settings page for the graph web server. Served as a
2
+ // plain string (inline <style> + inline <script>, no external assets, no
3
+ // framework) so it works identically for a global npm install. The visual
4
+ // identity mirrors the auth pages (see login-page.ts / oauth-authorize-page.ts):
5
+ // dark theme on #08131d, the same grid/radial gradient, Crowquill Mono with a
6
+ // monospace fallback, and the #5aa8ff accent used for graph nodes.
7
+ const settingsStyles = () => `:root {
8
+ color-scheme: dark;
9
+ --bg: #08131d;
10
+ --panel: #0d1823;
11
+ --panel-strong: #112130;
12
+ --line: rgba(143, 172, 204, 0.18);
13
+ --text: #edf4ff;
14
+ --muted: #97a9bd;
15
+ --accent: #5aa8ff;
16
+ --accent-weak: rgba(90, 168, 255, 0.18);
17
+ --danger: #ff6b6b;
18
+ --success: #57d9a3;
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ html,
26
+ body {
27
+ width: 100%;
28
+ min-height: 100%;
29
+ margin: 0;
30
+ }
31
+
32
+ body {
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ min-height: 100vh;
37
+ min-height: 100dvh;
38
+ padding: 24px;
39
+ color: var(--text);
40
+ font-family: "Crowquill Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
41
+ background:
42
+ linear-gradient(rgba(164, 197, 230, 0.05) 1px, transparent 1px),
43
+ linear-gradient(90deg, rgba(164, 197, 230, 0.05) 1px, transparent 1px),
44
+ radial-gradient(circle at top, rgba(43, 93, 143, 0.22), transparent 44%),
45
+ #08131d;
46
+ background-size: 28px 28px, 28px 28px, auto;
47
+ }
48
+
49
+ button,
50
+ input,
51
+ select {
52
+ font: inherit;
53
+ }
54
+
55
+ .settings-card {
56
+ width: 100%;
57
+ max-width: 440px;
58
+ padding: 32px 28px;
59
+ border: 1px solid var(--line);
60
+ border-radius: 14px;
61
+ background: rgba(13, 24, 35, 0.92);
62
+ box-shadow: 0 24px 80px rgba(1, 6, 13, 0.48);
63
+ backdrop-filter: blur(8px);
64
+ }
65
+
66
+ .settings-brand {
67
+ display: grid;
68
+ gap: 4px;
69
+ margin-bottom: 24px;
70
+ }
71
+
72
+ .settings-brand strong {
73
+ font-size: 26px;
74
+ letter-spacing: 0.01em;
75
+ }
76
+
77
+ .settings-brand span {
78
+ color: var(--muted);
79
+ font-size: 13px;
80
+ }
81
+
82
+ .settings-form {
83
+ display: grid;
84
+ gap: 16px;
85
+ }
86
+
87
+ .settings-field {
88
+ display: grid;
89
+ gap: 7px;
90
+ }
91
+
92
+ .settings-field label {
93
+ color: var(--muted);
94
+ font-size: 12px;
95
+ letter-spacing: 0.02em;
96
+ }
97
+
98
+ .settings-field input,
99
+ .settings-field select {
100
+ width: 100%;
101
+ height: 42px;
102
+ padding: 0 14px;
103
+ border: 1px solid var(--line);
104
+ border-radius: 8px;
105
+ outline: none;
106
+ background: rgba(12, 24, 36, 0.94);
107
+ color: var(--text);
108
+ }
109
+
110
+ .settings-field input:focus,
111
+ .settings-field select:focus {
112
+ border-color: var(--accent);
113
+ }
114
+
115
+ .settings-check {
116
+ display: flex;
117
+ align-items: center;
118
+ gap: 10px;
119
+ }
120
+
121
+ .settings-check input {
122
+ width: 18px;
123
+ height: 18px;
124
+ margin: 0;
125
+ accent-color: var(--accent);
126
+ }
127
+
128
+ .settings-check label {
129
+ color: var(--text);
130
+ font-size: 13px;
131
+ letter-spacing: 0;
132
+ }
133
+
134
+ .settings-submit {
135
+ height: 44px;
136
+ margin-top: 4px;
137
+ border: 1px solid var(--accent);
138
+ border-radius: 8px;
139
+ background: var(--accent-weak);
140
+ color: var(--text);
141
+ cursor: pointer;
142
+ transition: background 120ms ease, opacity 120ms ease;
143
+ }
144
+
145
+ .settings-submit:hover:not(:disabled),
146
+ .settings-submit:focus:not(:disabled) {
147
+ background: rgba(90, 168, 255, 0.3);
148
+ }
149
+
150
+ .settings-submit:disabled {
151
+ cursor: progress;
152
+ opacity: 0.6;
153
+ }
154
+
155
+ .settings-message {
156
+ min-height: 18px;
157
+ margin: 0;
158
+ font-size: 12px;
159
+ line-height: 1.4;
160
+ overflow-wrap: anywhere;
161
+ }
162
+
163
+ .settings-message[hidden] {
164
+ display: none;
165
+ }
166
+
167
+ .settings-message.is-error {
168
+ color: var(--danger);
169
+ }
170
+
171
+ .settings-message.is-success {
172
+ color: var(--success);
173
+ }
174
+
175
+ .settings-back {
176
+ display: inline-block;
177
+ margin-top: 20px;
178
+ color: var(--muted);
179
+ font-size: 12px;
180
+ text-decoration: none;
181
+ }
182
+
183
+ .settings-back:hover,
184
+ .settings-back:focus {
185
+ color: var(--accent);
186
+ }`;
187
+ const settingsScript = () => `(function () {
188
+ var form = document.getElementById('settingsForm');
189
+ var button = document.getElementById('submit');
190
+ var message = document.getElementById('message');
191
+ var searchMode = document.getElementById('defaultSearchMode');
192
+ var contextStrategy = document.getElementById('defaultContextStrategy');
193
+ var contextTokens = document.getElementById('defaultContextTokens');
194
+ var autoVersion = document.getElementById('autoVersion');
195
+ var defaultAgent = document.getElementById('defaultAgent');
196
+
197
+ function show(text, kind) {
198
+ message.textContent = text;
199
+ message.className = 'settings-message ' + (kind === 'success' ? 'is-success' : 'is-error');
200
+ message.hidden = false;
201
+ }
202
+
203
+ function clearMessage() {
204
+ message.textContent = '';
205
+ message.className = 'settings-message';
206
+ message.hidden = true;
207
+ }
208
+
209
+ function setValue(select, value) {
210
+ if (typeof value === 'string' && value.length > 0) {
211
+ select.value = value;
212
+ }
213
+ }
214
+
215
+ fetch('/api/settings', { headers: { accept: 'application/json' } })
216
+ .then(function (response) {
217
+ if (!response.ok) {
218
+ return {};
219
+ }
220
+ return response.json().catch(function () {
221
+ return {};
222
+ });
223
+ })
224
+ .then(function (data) {
225
+ data = data || {};
226
+ setValue(searchMode, data.defaultSearchMode);
227
+ setValue(contextStrategy, data.defaultContextStrategy);
228
+ if (typeof data.defaultContextTokens === 'number') {
229
+ contextTokens.value = String(data.defaultContextTokens);
230
+ }
231
+ autoVersion.checked = data.autoVersion === true;
232
+ if (typeof data.defaultAgent === 'string') {
233
+ defaultAgent.value = data.defaultAgent;
234
+ }
235
+ })
236
+ .catch(function () {
237
+ // A failed prefetch leaves the defaults in place; the form is still usable.
238
+ });
239
+
240
+ form.addEventListener('submit', function (event) {
241
+ event.preventDefault();
242
+ clearMessage();
243
+ button.disabled = true;
244
+
245
+ var payload = {
246
+ defaultSearchMode: searchMode.value,
247
+ defaultContextStrategy: contextStrategy.value,
248
+ defaultContextTokens: Number(contextTokens.value),
249
+ autoVersion: autoVersion.checked,
250
+ defaultAgent: defaultAgent.value
251
+ };
252
+
253
+ fetch('/api/settings', {
254
+ method: 'POST',
255
+ headers: { 'content-type': 'application/json' },
256
+ body: JSON.stringify(payload)
257
+ })
258
+ .then(function (response) {
259
+ if (response.ok) {
260
+ show('Saved', 'success');
261
+ } else {
262
+ show('Unable to save settings. Please try again.', 'error');
263
+ }
264
+ button.disabled = false;
265
+ })
266
+ .catch(function () {
267
+ show('Something went wrong. Please try again.', 'error');
268
+ button.disabled = false;
269
+ });
270
+ });
271
+ })();`;
272
+ export const createSettingsPageHtml = () => `<!DOCTYPE html>
273
+ <html lang="en">
274
+ <head>
275
+ <meta charset="utf-8" />
276
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
277
+ <title>Settings · Brainlink</title>
278
+ <style>${settingsStyles()}</style>
279
+ </head>
280
+ <body>
281
+ <main class="settings-card">
282
+ <div class="settings-brand">
283
+ <strong>Brainlink</strong>
284
+ <span>Settings</span>
285
+ </div>
286
+ <form id="settingsForm" class="settings-form" novalidate>
287
+ <div class="settings-field">
288
+ <label for="defaultSearchMode">Default search mode</label>
289
+ <select id="defaultSearchMode" name="defaultSearchMode">
290
+ <option value="fts">fts</option>
291
+ <option value="semantic">semantic</option>
292
+ <option value="hybrid">hybrid</option>
293
+ </select>
294
+ </div>
295
+ <div class="settings-field">
296
+ <label for="defaultContextStrategy">Default context strategy</label>
297
+ <select id="defaultContextStrategy" name="defaultContextStrategy">
298
+ <option value="rag">rag</option>
299
+ <option value="cag">cag</option>
300
+ <option value="auto">auto</option>
301
+ </select>
302
+ </div>
303
+ <div class="settings-field">
304
+ <label for="defaultContextTokens">Default context tokens</label>
305
+ <input id="defaultContextTokens" name="defaultContextTokens" type="number" min="1" />
306
+ </div>
307
+ <div class="settings-check">
308
+ <input id="autoVersion" name="autoVersion" type="checkbox" />
309
+ <label for="autoVersion">Auto-version vault</label>
310
+ </div>
311
+ <div class="settings-field">
312
+ <label for="defaultAgent">Default agent</label>
313
+ <input id="defaultAgent" name="defaultAgent" type="text" autocomplete="off" />
314
+ </div>
315
+ <p id="message" class="settings-message" role="alert" aria-live="polite" hidden></p>
316
+ <button id="submit" class="settings-submit" type="submit">Save</button>
317
+ </form>
318
+ <a class="settings-back" href="/">&larr; Back</a>
319
+ </main>
320
+ <script>${settingsScript()}</script>
321
+ </body>
322
+ </html>`;
@@ -0,0 +1,11 @@
1
+ // Monotonic generation counter for the graph. The vault watcher bumps it after
2
+ // every reindex, and the web client polls /api/graph-version to know when to
3
+ // re-fetch — so nodes and contexts written by an agent (via MCP) surface live
4
+ // in an open graph without a manual reload. Process-local by design: a restart
5
+ // resets it, which the client simply treats as one more change.
6
+ let version = 0;
7
+ export const bumpGraphVersion = () => {
8
+ version += 1;
9
+ return version;
10
+ };
11
+ export const getGraphVersion = () => version;
@@ -24,6 +24,9 @@ import { createClientWorkerJs } from '../frontend/client-worker-js.js';
24
24
  import { createClientRenderWorkerJs } from '../frontend/client-render-worker-js.js';
25
25
  import { createLoginPageHtml, createChangePasswordPageHtml } from '../frontend/login-page.js';
26
26
  import { buildClearSessionCookie, buildSessionSetCookie, changeWebPassword, createSessionToken, parseCookies, resolveWebAuth, SESSION_COOKIE_NAME, verifySessionToken, verifyWebPassword } from './web-auth.js';
27
+ import { getGraphVersion } from './graph-version.js';
28
+ import { readAuthStatus, readEditableSettings, updateEditableSettings } from './settings-store.js';
29
+ import { createSettingsPageHtml } from '../frontend/settings-page.js';
27
30
  import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
28
31
  import { parseMultipartForm } from './multipart.js';
29
32
  const readRuntimeDefaults = async (url) => {
@@ -519,6 +522,28 @@ export const route = async (request, url, vaultPath, options = {}) => {
519
522
  if (isReadMethod(request) && url.pathname === '/api/agents') {
520
523
  return createResponse(createJsonResponse({ agents: await listAgents(vaultPath) }), 200, contentTypes['.json']);
521
524
  }
525
+ // Cheap generation counter the client polls to auto-refresh when the vault
526
+ // changes (new nodes/contexts written via MCP surface live).
527
+ if (isReadMethod(request) && url.pathname === '/api/graph-version') {
528
+ return createResponse(createJsonResponse({ version: getGraphVersion() }), 200, contentTypes['.json']);
529
+ }
530
+ // Account: whether the exposed-mode login is active (drives the graph's
531
+ // account menu) plus the settings the hosted account can edit.
532
+ if (isReadMethod(request) && url.pathname === '/api/auth-status') {
533
+ const user = options.requireAuth ? (await resolveWebAuth())?.user ?? null : null;
534
+ return createResponse(createJsonResponse(await readAuthStatus(options.requireAuth === true, user)), 200, contentTypes['.json']);
535
+ }
536
+ if (isReadMethod(request) && url.pathname === '/api/settings') {
537
+ return createResponse(createJsonResponse(await readEditableSettings()), 200, contentTypes['.json']);
538
+ }
539
+ if (request.method === 'POST' && url.pathname === '/api/settings') {
540
+ const body = await readJsonBody(request);
541
+ const updated = await updateEditableSettings((body ?? {}));
542
+ return createResponse(createJsonResponse(updated), 200, contentTypes['.json']);
543
+ }
544
+ if (isReadMethod(request) && url.pathname === '/settings') {
545
+ return createResponse(createSettingsPageHtml(), 200, contentTypes['.html']);
546
+ }
522
547
  if (isReadMethod(request) && url.pathname === '/api/graph-contexts') {
523
548
  return createResponse(createJsonResponse({ contexts: await getGraphContexts(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
524
549
  }
@@ -0,0 +1,51 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { getGlobalConfigPath, loadBrainlinkConfig, sanitizeContextStrategy, sanitizeSearchMode, writeRawConfig } from '../../infrastructure/config.js';
3
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ const readRawGlobalConfig = async () => {
5
+ try {
6
+ const raw = await readFile(getGlobalConfigPath(), 'utf8');
7
+ const parsed = JSON.parse(raw);
8
+ return isRecord(parsed) ? parsed : {};
9
+ }
10
+ catch {
11
+ return {};
12
+ }
13
+ };
14
+ const searchModes = new Set(['fts', 'semantic', 'hybrid']);
15
+ const contextStrategies = new Set(['rag', 'cag', 'auto']);
16
+ const buildSanitizedPatch = (patch) => ({
17
+ ...(typeof patch.defaultSearchMode === 'string' && searchModes.has(patch.defaultSearchMode)
18
+ ? { defaultSearchMode: sanitizeSearchMode(patch.defaultSearchMode) }
19
+ : {}),
20
+ ...(typeof patch.defaultContextStrategy === 'string' && contextStrategies.has(patch.defaultContextStrategy)
21
+ ? { defaultContextStrategy: sanitizeContextStrategy(patch.defaultContextStrategy) }
22
+ : {}),
23
+ ...(typeof patch.defaultContextTokens === 'number' &&
24
+ Number.isFinite(patch.defaultContextTokens) &&
25
+ patch.defaultContextTokens > 0
26
+ ? { defaultContextTokens: Math.floor(patch.defaultContextTokens) }
27
+ : {}),
28
+ ...(typeof patch.autoVersion === 'boolean' ? { autoVersion: patch.autoVersion } : {}),
29
+ ...(typeof patch.defaultAgent === 'string' && patch.defaultAgent.trim().length > 0
30
+ ? { defaultAgent: patch.defaultAgent.trim() }
31
+ : {})
32
+ });
33
+ const projectEditableSettings = (config) => ({
34
+ defaultSearchMode: config.defaultSearchMode,
35
+ defaultContextStrategy: config.defaultContextStrategy,
36
+ defaultContextTokens: config.defaultContextTokens,
37
+ autoVersion: config.autoVersion,
38
+ defaultAgent: config.defaultAgent ?? ''
39
+ });
40
+ export const readEditableSettings = async () => projectEditableSettings(await loadBrainlinkConfig());
41
+ export const updateEditableSettings = async (patch) => {
42
+ const sanitizedPatch = buildSanitizedPatch(patch);
43
+ const rawConfig = await readRawGlobalConfig();
44
+ const merged = { ...rawConfig, ...sanitizedPatch };
45
+ await writeRawConfig('global', merged);
46
+ return readEditableSettings();
47
+ };
48
+ export const readAuthStatus = async (requireAuth, user) => ({
49
+ enabled: requireAuth,
50
+ user
51
+ });
@@ -2,6 +2,7 @@ import { createServer } from 'node:http';
2
2
  import { brotliCompressSync, constants, gzipSync } from 'node:zlib';
3
3
  import { indexVault } from './index-vault.js';
4
4
  import { startVaultWatcher } from './watch-vault.js';
5
+ import { bumpGraphVersion } from './server/graph-version.js';
5
6
  import { assertLoopbackHost } from './server/host-security.js';
6
7
  import { contentTypes, createJsonResponse, isHttpError } from './server/http.js';
7
8
  import { route } from './server/routes.js';
@@ -82,10 +83,16 @@ export const startServer = async (input) => {
82
83
  }
83
84
  if (input.shouldIndex) {
84
85
  await indexVault(input.vaultPath);
86
+ bumpGraphVersion();
85
87
  }
86
88
  const watcher = input.shouldWatch
87
89
  ? startVaultWatcher({
88
90
  vaultPath: input.vaultPath,
91
+ // Signal the web client (via /api/graph-version) that the graph changed
92
+ // so it can re-fetch and surface new nodes/contexts live.
93
+ onIndex: () => {
94
+ bumpGraphVersion();
95
+ },
89
96
  onError: (error) => console.error(error)
90
97
  })
91
98
  : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "1.6.5",
3
+ "version": "1.6.7",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",