@andespindola/brainlink 1.6.6 → 1.6.8

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,202 @@
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: relative;',
25
+ ' display: inline-flex;',
26
+ ' align-items: center;',
27
+ ' margin-left: 8px;',
28
+ ' font-family: "Crowquill Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;',
29
+ '}',
30
+ '.bl-account--floating {',
31
+ ' position: fixed;',
32
+ ' top: 12px;',
33
+ ' right: 12px;',
34
+ ' z-index: 2147483000;',
35
+ '}',
36
+ '.bl-account-button {',
37
+ ' display: inline-flex;',
38
+ ' align-items: center;',
39
+ ' gap: 6px;',
40
+ ' height: 32px;',
41
+ ' padding: 0 12px;',
42
+ ' border: 1px solid rgba(143, 172, 204, 0.18);',
43
+ ' border-radius: 8px;',
44
+ ' background: rgba(13, 24, 35, 0.92);',
45
+ ' color: #edf4ff;',
46
+ ' font: inherit;',
47
+ ' font-size: 12px;',
48
+ ' cursor: pointer;',
49
+ ' transition: border-color 120ms ease;',
50
+ '}',
51
+ '.bl-account-button:hover,',
52
+ '.bl-account-button:focus {',
53
+ ' border-color: #5aa8ff;',
54
+ ' outline: none;',
55
+ '}',
56
+ '.bl-account-menu {',
57
+ ' position: absolute;',
58
+ ' top: 40px;',
59
+ ' right: 0;',
60
+ ' z-index: 2147483000;',
61
+ ' min-width: 168px;',
62
+ ' padding: 6px;',
63
+ ' border: 1px solid rgba(143, 172, 204, 0.18);',
64
+ ' border-radius: 10px;',
65
+ ' background: rgba(13, 24, 35, 0.98);',
66
+ ' box-shadow: 0 18px 60px rgba(1, 6, 13, 0.48);',
67
+ ' display: grid;',
68
+ ' gap: 2px;',
69
+ '}',
70
+ '.bl-account-menu[hidden] {',
71
+ ' display: none;',
72
+ '}',
73
+ '.bl-account-item {',
74
+ ' display: block;',
75
+ ' width: 100%;',
76
+ ' padding: 8px 10px;',
77
+ ' border: 0;',
78
+ ' border-radius: 6px;',
79
+ ' background: transparent;',
80
+ ' color: #edf4ff;',
81
+ ' font: inherit;',
82
+ ' font-size: 12px;',
83
+ ' text-align: left;',
84
+ ' cursor: pointer;',
85
+ '}',
86
+ '.bl-account-item:hover,',
87
+ '.bl-account-item:focus {',
88
+ ' background: rgba(90, 168, 255, 0.18);',
89
+ ' outline: none;',
90
+ '}'
91
+ ].join('\\n')
92
+ document.head.appendChild(style)
93
+ }
94
+
95
+ const build = (user) => {
96
+ injectStyles()
97
+
98
+ const root = document.createElement('div')
99
+ root.id = 'bl-account'
100
+ root.className = 'bl-account'
101
+
102
+ const button = document.createElement('button')
103
+ button.type = 'button'
104
+ button.className = 'bl-account-button'
105
+ button.textContent = typeof user === 'string' && user.length > 0 ? user : 'Account'
106
+ button.setAttribute('aria-haspopup', 'true')
107
+ button.setAttribute('aria-expanded', 'false')
108
+
109
+ const menu = document.createElement('div')
110
+ menu.className = 'bl-account-menu'
111
+ menu.setAttribute('role', 'menu')
112
+ menu.hidden = true
113
+
114
+ const addItem = (label, onSelect) => {
115
+ const item = document.createElement('button')
116
+ item.type = 'button'
117
+ item.className = 'bl-account-item'
118
+ item.setAttribute('role', 'menuitem')
119
+ item.textContent = label
120
+ item.addEventListener('click', onSelect)
121
+ menu.appendChild(item)
122
+ }
123
+
124
+ const closeMenu = () => {
125
+ menu.hidden = true
126
+ button.setAttribute('aria-expanded', 'false')
127
+ }
128
+
129
+ const openMenu = () => {
130
+ menu.hidden = false
131
+ button.setAttribute('aria-expanded', 'true')
132
+ }
133
+
134
+ addItem('Settings', () => {
135
+ closeMenu()
136
+ window.location.href = '/settings'
137
+ })
138
+ addItem('Change password', () => {
139
+ closeMenu()
140
+ window.location.href = '/change-password'
141
+ })
142
+ addItem('Sign out', () => {
143
+ closeMenu()
144
+ fetch('/api/logout', { method: 'POST' })
145
+ .catch(() => {})
146
+ .then(() => {
147
+ window.location.href = '/login'
148
+ })
149
+ })
150
+
151
+ button.addEventListener('click', (event) => {
152
+ event.stopPropagation()
153
+ if (menu.hidden) {
154
+ openMenu()
155
+ } else {
156
+ closeMenu()
157
+ }
158
+ })
159
+
160
+ document.addEventListener('click', (event) => {
161
+ if (!root.contains(event.target)) {
162
+ closeMenu()
163
+ }
164
+ })
165
+
166
+ document.addEventListener('keydown', (event) => {
167
+ if (event.key === 'Escape') {
168
+ closeMenu()
169
+ }
170
+ })
171
+
172
+ root.appendChild(button)
173
+ root.appendChild(menu)
174
+
175
+ // Mount inside the graph header so the control flows with the existing
176
+ // toolbar instead of overlapping it. Fall back to a floating corner only if
177
+ // the header is not present.
178
+ const host = document.querySelector('.header-actions') || document.querySelector('.graph-header')
179
+ if (host) {
180
+ host.appendChild(root)
181
+ } else {
182
+ root.classList.add('bl-account--floating')
183
+ document.body.appendChild(root)
184
+ }
185
+ }
186
+
187
+ fetch('/api/auth-status', { headers: { accept: 'application/json' } })
188
+ .then((response) => {
189
+ if (!response.ok) {
190
+ return null
191
+ }
192
+ return response.json().catch(() => null)
193
+ })
194
+ .then((payload) => {
195
+ if (!payload || payload.enabled !== true) {
196
+ return
197
+ }
198
+ build(payload.user)
199
+ })
200
+ .catch(() => {})
201
+ }
202
+ `;
@@ -212,6 +212,7 @@ const bootstrap = async () => {
212
212
 
213
213
  scheduleChunkFetch({ fit: true })
214
214
  startLiveRefresh()
215
+ startAccountMenu()
215
216
  }
216
217
 
217
218
  bootstrap().catch((error) => {
@@ -9,6 +9,7 @@ import { createInputJs } from './client/input.js';
9
9
  import { createUploadJs } from './client/upload.js';
10
10
  import { createControlsJs } from './client/controls.js';
11
11
  import { createLiveRefreshJs } from './client/live-refresh.js';
12
+ import { createAccountMenuJs } from './client/account-menu.js';
12
13
  import { createGraphRendererJs } from './client/graph-renderer.js';
13
14
  import { createWorkerBootstrapJs } from './client/worker-bootstrap.js';
14
15
  export const createClientJs = () => [
@@ -23,6 +24,7 @@ export const createClientJs = () => [
23
24
  createUploadJs(),
24
25
  createControlsJs(),
25
26
  createLiveRefreshJs(),
27
+ createAccountMenuJs(),
26
28
  createGraphRendererJs(),
27
29
  createWorkerBootstrapJs()
28
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>`;
@@ -25,6 +25,8 @@ import { createClientRenderWorkerJs } from '../frontend/client-render-worker-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
27
  import { getGraphVersion } from './graph-version.js';
28
+ import { readAuthStatus, readEditableSettings, updateEditableSettings } from './settings-store.js';
29
+ import { createSettingsPageHtml } from '../frontend/settings-page.js';
28
30
  import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
29
31
  import { parseMultipartForm } from './multipart.js';
30
32
  const readRuntimeDefaults = async (url) => {
@@ -525,6 +527,23 @@ export const route = async (request, url, vaultPath, options = {}) => {
525
527
  if (isReadMethod(request) && url.pathname === '/api/graph-version') {
526
528
  return createResponse(createJsonResponse({ version: getGraphVersion() }), 200, contentTypes['.json']);
527
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
+ }
528
547
  if (isReadMethod(request) && url.pathname === '/api/graph-contexts') {
529
548
  return createResponse(createJsonResponse({ contexts: await getGraphContexts(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
530
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "1.6.6",
3
+ "version": "1.6.8",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",