@mebular/mcp 0.0.0-stage → 0.1.0

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,690 @@
1
+ // 配置管道单一真源(A):每一项配置的「写入校验 / 待重启判定 / 生效值 / 未生效原因 / GUI 暴露面 / 文档」都从这里取。
2
+ //
3
+ // 之前配置声明散在 4 处(serve.mjs CONFIG_PATCH_SPECS / admin.mjs RESTART_CONFIG_PATHS /
4
+ // console.js CONFIG_EDITOR+CONFIG_EFFECTIVE / settings-ia.js),每加一项就漂移。本文件是纯数据
5
+ // (无副作用、不依赖 node 内建),被 serve/admin/控制台/verify:config/E1 共用。
6
+ //
7
+ // exposure 语义(用户拍板:**自动的东西不保留在 GUI**):
8
+ // editable —— 用户可改:GUI 渲染编辑器,`POST /admin/api/config` 可写(按 kind/spec 校验)
9
+ // status-only —— 只读展示:值由运行时/自动池/学习得出,写请求一律 400
10
+ // internal —— 不暴露到 GUI(诊断/文档可见);API 仍可写,但控制台不渲染
11
+ //
12
+ // requiresRestart:守护只在启动时读一次 config.json,改动需重启才生效(真值由 #80 的 pendingRestart 判定)。
13
+ // effective(settings):从 `/admin/api/settings` 载荷取「实际生效值」(与运行时同源,不是磁盘原值)。
14
+ // reason(ctx):可选的「未生效原因」覆盖;ctx 见 admin.mjs buildEffectiveReasons。
15
+
16
+ /** 数组归一:非数组/全空 → null(「未设置」),与 #80 pendingRestart 的归一一致。 */
17
+ export function listOrNull(value) {
18
+ if (!Array.isArray(value)) return null;
19
+ const list = value.filter((entry) => typeof entry === 'string' && entry.length > 0);
20
+ return list.length > 0 ? list : null;
21
+ }
22
+
23
+ /**
24
+ * 字段表。`kind` 决定写校验(bool/int/num/string/enum/list),`spec` 携带范围/前缀等约束;
25
+ * `ui` 是控制台编辑器元数据(type/label/help/warn/step/min/max/placeholder/options)。
26
+ */
27
+ export const CONFIG_SCHEMA = [
28
+ // ------------------------------------------------------------------ editable · 常用(6)
29
+ {
30
+ path: 'network.enabled',
31
+ read: (c) => c?.network?.enabled ?? false,
32
+ kind: 'bool',
33
+ exposure: 'editable',
34
+ requiresRestart: true,
35
+ default: false,
36
+ envVar: 'MEBULAR_NETWORK_ENABLED',
37
+ ui: {
38
+ type: 'bool',
39
+ label: '启用 P2P',
40
+ labelKey: 'settings.schema.network.enabled.label',
41
+ help: '关闭后仅本机离线使用',
42
+ helpKey: 'settings.fields.networkEnabled.help',
43
+ },
44
+ effective: (s) => s?.network?.enabled === true,
45
+ },
46
+ {
47
+ path: 'network.libp2p.listen',
48
+ read: (c) => listOrNull(c?.network?.libp2p?.listen),
49
+ kind: 'list',
50
+ spec: { prefix: '/', empty: 'delete' },
51
+ exposure: 'editable',
52
+ requiresRestart: true,
53
+ default: null,
54
+ ui: {
55
+ type: 'list',
56
+ label: '监听地址',
57
+ labelKey: 'settings.schema.network.listen.label',
58
+ placeholder: '/ip4/127.0.0.1/tcp/14001',
59
+ help: 'multiaddr 列表;留空 = 默认监听',
60
+ helpKey: 'settings.fields.networkListen.help',
61
+ },
62
+ effective: (s) => (s?.network?.listenConfigured?.length ? s.network.listenConfigured : (s?.network?.listen ?? [])),
63
+ reason: (ctx) => {
64
+ const actual = ctx.settings?.network?.listen ?? [];
65
+ const nonLoopback = actual.some((addr) => !/\/ip4\/127\.|\/ip6\/::1|\/ip4\/0\.0\.0\.0/.test(addr) && !/\/dns/.test(addr));
66
+ const wildcard = actual.some((addr) => /\/ip4\/0\.0\.0\.0/.test(addr));
67
+ if (actual.length > 0 && !nonLoopback && !wildcard) return '仅回环监听:LAN 内其他设备不可达(如需 LAN 可设 0.0.0.0 或本机 LAN IP)';
68
+ return null;
69
+ },
70
+ },
71
+ {
72
+ path: 'sync.namespaces',
73
+ read: (c) => listOrNull(c?.sync?.namespaces),
74
+ kind: 'list',
75
+ spec: { empty: 'set' },
76
+ exposure: 'editable',
77
+ requiresRestart: false,
78
+ default: null,
79
+ ui: {
80
+ type: 'list',
81
+ label: '订阅的记忆域',
82
+ labelKey: 'settings.schema.sync.namespaces.label',
83
+ placeholder: 'default, notes',
84
+ help: '订阅 = 阅读权 + 维持义务:接收该域的更新,并把本机新增同步给其他订阅设备。留空 = 订阅全部。控制台开关热生效,无需重启',
85
+ helpKey: 'settings.schema.sync.namespaces.help',
86
+ },
87
+ effective: (s) => (s?.sync?.subscriptions?.length ? s.sync.subscriptions : null),
88
+ },
89
+ {
90
+ path: 'joinService.enabled',
91
+ read: (c) => c?.joinService?.enabled === true,
92
+ kind: 'bool',
93
+ exposure: 'editable',
94
+ requiresRestart: true,
95
+ default: false,
96
+ ui: {
97
+ type: 'bool',
98
+ label: '启用加入服务',
99
+ labelKey: 'settings.schema.join.enabled.label',
100
+ help: '开启后可由「+ 邀请新设备」生成一次性令牌(需重启)',
101
+ helpKey: 'settings.fields.joinEnabled.help',
102
+ },
103
+ effective: (s) => s?.join?.enabled === true,
104
+ reason: (ctx) => (ctx.settings?.join?.enabled === true && ctx.settings?.join?.endpointLoopback === true
105
+ ? 'join 端点解析为回环地址:LAN 内新设备不可达(joinService.bind 设 0.0.0.0)'
106
+ : null),
107
+ },
108
+ {
109
+ path: 'mcp.http.port',
110
+ read: (c) => c?.mcp?.http?.port ?? 7331,
111
+ kind: 'int',
112
+ spec: { min: 0, max: 65535, nullable: true },
113
+ exposure: 'editable',
114
+ requiresRestart: true,
115
+ default: 7331,
116
+ ui: {
117
+ type: 'number',
118
+ label: '端口',
119
+ labelKey: 'settings.schema.mcp.port.label',
120
+ min: 0,
121
+ max: 65535,
122
+ helpKey: 'settings.fields.mcpPort.help',
123
+ },
124
+ effective: (s) => s?.mcp?.port,
125
+ },
126
+ {
127
+ path: 'mcp.http.auth',
128
+ read: (c) => c?.mcp?.http?.auth ?? 'none',
129
+ kind: 'enum',
130
+ spec: { values: ['none', 'bearer', 'oauth'] },
131
+ exposure: 'editable',
132
+ requiresRestart: true,
133
+ default: 'none',
134
+ ui: {
135
+ type: 'select',
136
+ label: '鉴权模式',
137
+ labelKey: 'settings.fields.mcpAuth.label',
138
+ options: [['none', 'none(仅回环)'], ['bearer', 'bearer(token)'], ['oauth', 'oauth']],
139
+ optionKeys: {
140
+ none: 'settings.schema.mcpAuth.option.none',
141
+ bearer: 'settings.schema.mcpAuth.option.bearer',
142
+ oauth: 'settings.schema.mcpAuth.option.oauth',
143
+ },
144
+ helpKey: 'settings.fields.mcpAuth.help',
145
+ warn: '切换后控制台 API 立即需要凭证:bearer 需先 `mebular token grant --scope memory.read,memory.admin` 并在页面粘贴 token(否则 401 missing bearer token);oauth 需在 env 提供 MEBULAR_OAUTH_ADMIN_SECRET / MEBULAR_OAUTH_REGISTER_SECRET,否则 /register 默认 404、静态 token 会 401 invalid token,控制台内无法自救。误切后恢复:编辑 config.json 把 mcp.http.auth 改回 none(仅回环),或补齐凭证后重启 mebular serve',
146
+ warnKey: 'settings.schema.mcpAuth.warn',
147
+ },
148
+ effective: (s) => s?.mcp?.auth,
149
+ reason: (ctx) => (ctx.settings?.mcp?.auth !== 'none'
150
+ ? '已生效:控制台 API 现需凭证(页面需粘贴 token / 走 OAuth)'
151
+ : null),
152
+ },
153
+
154
+ // ------------------------------------------------------------------ editable · 高级(14)
155
+ {
156
+ path: 'sync.antiEntropy.enabled',
157
+ read: (c) => c?.sync?.antiEntropy?.enabled ?? true,
158
+ kind: 'bool',
159
+ exposure: 'editable',
160
+ requiresRestart: true,
161
+ default: true,
162
+ ui: {
163
+ group: '反熵与快照',
164
+ type: 'bool',
165
+ label: '周期反熵',
166
+ labelKey: 'settings.fields.antiEntropy.label',
167
+ help: '周期性对账,弥补推送丢失',
168
+ helpKey: 'settings.fields.antiEntropy.help',
169
+ },
170
+ effective: (s) => s?.sync?.antiEntropy?.enabled ?? true,
171
+ },
172
+ {
173
+ path: 'sync.antiEntropy.intervalMs',
174
+ read: (c) => c?.sync?.antiEntropy?.intervalMs ?? 600000,
175
+ kind: 'int',
176
+ spec: { min: 1000, nullable: true },
177
+ exposure: 'internal',
178
+ writable: true,
179
+ requiresRestart: true,
180
+ default: 600000,
181
+ ui: { type: 'minutes', label: '反熵间隔(分钟)', help: '默认 10 分钟(±20% 抖动)' },
182
+ effective: (s) => s?.sync?.antiEntropy?.intervalMs ?? 600000,
183
+ },
184
+ {
185
+ path: 'sync.antiEntropy.jitterRatio',
186
+ read: (c) => c?.sync?.antiEntropy?.jitterRatio ?? 0.2,
187
+ kind: 'num',
188
+ spec: { min: 0, max: 1, nullable: true },
189
+ exposure: 'internal',
190
+ writable: true,
191
+ requiresRestart: true,
192
+ default: 0.2,
193
+ ui: { type: 'number', label: '反熵抖动比例', step: 0.05, min: 0, max: 1, help: '0 ~ 1,默认 0.2' },
194
+ effective: (s) => s?.sync?.antiEntropy?.jitterRatio ?? 0.2,
195
+ },
196
+
197
+ // ------------------------------------------------------------------ P3 · 收敛健康度(internal:config.json 直接配置;不进 GUI/计数断言)
198
+ {
199
+ path: 'sync.convergence.enabled',
200
+ read: (c) => c?.sync?.convergence?.enabled ?? true,
201
+ kind: 'bool',
202
+ exposure: 'internal',
203
+ requiresRestart: true,
204
+ default: true,
205
+ ui: { group: '反熵与快照', type: 'bool', label: '收敛健康度', help: '周期对账「未收敛/停滞」并在控制台/CLI 呈现;守护默认开启(core 库形态默认关闭)' },
206
+ effective: (s) => s?.sync?.convergence?.enabled ?? true,
207
+ },
208
+ {
209
+ path: 'sync.convergence.evalIntervalMs',
210
+ read: (c) => c?.sync?.convergence?.evalIntervalMs ?? 60000,
211
+ kind: 'int',
212
+ spec: { min: 1000, nullable: true },
213
+ exposure: 'internal',
214
+ requiresRestart: true,
215
+ default: 60000,
216
+ ui: { type: 'number', label: '收敛评估间隔(ms)', help: '默认 60000(60s)' },
217
+ effective: (s) => s?.sync?.convergence?.evalIntervalMs ?? 60000,
218
+ },
219
+ {
220
+ path: 'sync.convergence.quietMs',
221
+ read: (c) => c?.sync?.convergence?.quietMs ?? 60000,
222
+ kind: 'int',
223
+ spec: { min: 100, nullable: true },
224
+ exposure: 'internal',
225
+ requiresRestart: true,
226
+ default: 60000,
227
+ ui: { type: 'number', label: '静止窗口(ms)', help: '距上次会话/本地写入 ≥ 此值才做去抖确认' },
228
+ effective: (s) => s?.sync?.convergence?.quietMs ?? 60000,
229
+ },
230
+ {
231
+ path: 'sync.convergence.stallWindowMs',
232
+ read: (c) => c?.sync?.convergence?.stallWindowMs ?? 900000,
233
+ kind: 'int',
234
+ spec: { min: 1000, nullable: true },
235
+ exposure: 'internal',
236
+ requiresRestart: true,
237
+ default: 900000,
238
+ ui: { type: 'number', label: '停滞统计窗口(ms)', help: '默认 900000(15min)' },
239
+ effective: (s) => s?.sync?.convergence?.stallWindowMs ?? 900000,
240
+ },
241
+ {
242
+ path: 'sync.convergence.failStreak',
243
+ read: (c) => c?.sync?.convergence?.failStreak ?? 3,
244
+ kind: 'int',
245
+ spec: { min: 1, nullable: true },
246
+ exposure: 'internal',
247
+ requiresRestart: true,
248
+ default: 3,
249
+ ui: { type: 'number', label: '会话失败连击阈值', help: '连续失败达到此数判停滞(默认 3)' },
250
+ effective: (s) => s?.sync?.convergence?.failStreak ?? 3,
251
+ },
252
+ {
253
+ path: 'sync.convergence.mismatchConfirmations',
254
+ read: (c) => c?.sync?.convergence?.mismatchConfirmations ?? 2,
255
+ kind: 'int',
256
+ spec: { min: 1, nullable: true },
257
+ exposure: 'internal',
258
+ requiresRestart: true,
259
+ default: 2,
260
+ ui: { type: 'number', label: '未收敛确认次数', help: '指纹不一致需连续(静止)确认次数(去抖,默认 2)' },
261
+ effective: (s) => s?.sync?.convergence?.mismatchConfirmations ?? 2,
262
+ },
263
+ {
264
+ path: 'sync.convergence.autoProbe',
265
+ read: (c) => c?.sync?.convergence?.autoProbe ?? true,
266
+ kind: 'bool',
267
+ exposure: 'internal',
268
+ requiresRestart: true,
269
+ default: true,
270
+ ui: { type: 'bool', label: '记账矛盾自动探测', help: 'A1:对「记账矛盾」对端限频自动探测一轮(复用既有触发,方向安全)' },
271
+ effective: (s) => s?.sync?.convergence?.autoProbe ?? true,
272
+ },
273
+ {
274
+ path: 'sync.snapshotThreshold',
275
+ read: (c) => c?.sync?.snapshotThreshold ?? null,
276
+ kind: 'int',
277
+ spec: { min: 1, nullable: true },
278
+ exposure: 'internal',
279
+ writable: true,
280
+ requiresRestart: true,
281
+ default: null,
282
+ ui: { type: 'number', label: '快照阈值(事件数)', min: 1, placeholder: '留空 = 不启用', help: '对端空时钟且缺失事件数 ≥ 阈值时改用物化快照' },
283
+ effective: (s) => s?.sync?.snapshotThreshold ?? null,
284
+ },
285
+ {
286
+ // config-only:不在 GUI 渲染输入框;在 config.json 手工配置。
287
+ path: 'sync.peerWhitelist',
288
+ read: (c) => listOrNull(c?.sync?.peerWhitelist),
289
+ kind: 'list',
290
+ spec: { empty: 'delete' },
291
+ exposure: 'internal',
292
+ writable: false,
293
+ requiresRestart: true,
294
+ default: null,
295
+ ui: { type: 'list', label: '对端白名单(仅 config.json)' },
296
+ effective: (s) => (s?.sync?.peerWhitelist?.length ? s.sync.peerWhitelist : null),
297
+ },
298
+ {
299
+ path: 'semantic.enabled',
300
+ read: (c) => c?.semantic?.enabled ?? false,
301
+ kind: 'bool',
302
+ exposure: 'editable',
303
+ requiresRestart: true,
304
+ default: false,
305
+ envVar: 'MEBULAR_SEMANTIC_ENABLED',
306
+ ui: { group: '语义召回(可选依赖)', type: 'bool',
307
+ label: '启用语义召回',
308
+ labelKey: 'settings.fields.semantic.label',
309
+ help: '需要本地 embedding 模型(可选依赖)',
310
+ helpKey: 'settings.fields.semantic.help',
311
+ warn: '需可选依赖 @huggingface/transformers;缺失时自动降级关键词并告警',
312
+ warnKey: 'settings.schema.semantic.warn',
313
+ },
314
+ effective: (s) => s?.semantic?.enabled === true,
315
+ reason: (ctx) => (ctx.settings?.semantic?.enabled === true
316
+ ? null
317
+ : '缺可选依赖 @huggingface/transformers(或未安装):自动降级关键词召回'),
318
+ },
319
+ {
320
+ path: 'semantic.minScore',
321
+ read: (c) => c?.semantic?.minScore ?? 0.2,
322
+ kind: 'num',
323
+ spec: { min: 0, max: 1, nullable: true },
324
+ exposure: 'internal',
325
+ writable: true,
326
+ requiresRestart: true,
327
+ default: 0.2,
328
+ ui: { type: 'number', label: '召回阈值', step: 0.05, min: 0, max: 1, help: '0 ~ 1,默认 0.2' },
329
+ effective: (s) => s?.semantic?.minScore ?? 0.2,
330
+ },
331
+ {
332
+ path: 'joinService.bind',
333
+ read: (c) => c?.joinService?.bind ?? '0.0.0.0',
334
+ kind: 'string',
335
+ spec: { nonEmpty: true },
336
+ exposure: 'editable',
337
+ requiresRestart: true,
338
+ default: '0.0.0.0',
339
+ ui: { group: '设备接入(高级)', type: 'text',
340
+ label: '绑定地址',
341
+ labelKey: 'settings.schema.join.bind.label',
342
+ placeholder: '0.0.0.0',
343
+ help: '令牌 join 端点绑定;默认即 0.0.0.0(quickstart 依赖 LAN 可达),仅可信 LAN 使用。它同时决定邀请令牌里写死的 endpoint:通配时自动取本机 LAN IPv4(无 LAN 时回环并在邀请面板告警)',
344
+ helpKey: 'settings.fields.joinBind.help',
345
+ },
346
+ effective: (s) => s?.join?.bind,
347
+ },
348
+ {
349
+ path: 'joinService.port',
350
+ read: (c) => c?.joinService?.port ?? 4002,
351
+ kind: 'int',
352
+ spec: { min: 0, max: 65535, nullable: true },
353
+ exposure: 'editable',
354
+ requiresRestart: true,
355
+ default: 4002,
356
+ ui: {
357
+ type: 'number',
358
+ label: '端口',
359
+ labelKey: 'settings.schema.join.port.label',
360
+ min: 0,
361
+ max: 65535,
362
+ help: '默认 4002',
363
+ helpKey: 'settings.fields.joinPort.help',
364
+ },
365
+ effective: (s) => s?.join?.port,
366
+ reason: (ctx) => {
367
+ const lastError = ctx.settings?.join?.lastError;
368
+ if (lastError && /EADDRINUSE|被占|占用/.test(String(lastError.message ?? lastError))) {
369
+ return `join 端口被占(上次启动失败:${lastError.port ?? ctx.settings?.join?.port})——改端口或释放占用`;
370
+ }
371
+ return null;
372
+ },
373
+ },
374
+ {
375
+ path: 'mcp.http.host',
376
+ read: (c) => c?.mcp?.http?.host ?? '127.0.0.1',
377
+ kind: 'string',
378
+ spec: { nonEmpty: true, empty: 'delete' },
379
+ exposure: 'editable',
380
+ requiresRestart: true,
381
+ default: '127.0.0.1',
382
+ ui: { group: 'MCP 接入(高级)', type: 'text',
383
+ label: '监听地址',
384
+ labelKey: 'settings.schema.mcp.host.label',
385
+ placeholder: '127.0.0.1',
386
+ help: '仅回环可 auth=none/无 TLS',
387
+ helpKey: 'settings.fields.mcpHost.help',
388
+ warn: '非回环(如 0.0.0.0)必须 auth≠none 且启用 TLS 并配证书,否则 serve 拒绝启动',
389
+ warnKey: 'settings.schema.mcpHost.warn',
390
+ },
391
+ effective: (s) => s?.mcp?.host,
392
+ },
393
+ {
394
+ path: 'mcp.http.tls',
395
+ read: (c) => c?.mcp?.http?.tls === true,
396
+ kind: 'bool',
397
+ exposure: 'editable',
398
+ requiresRestart: true,
399
+ default: false,
400
+ ui: {
401
+ type: 'bool',
402
+ label: '启用 TLS',
403
+ labelKey: 'settings.schema.mcp.tls.label',
404
+ help: '真开关:true 但缺证书时 serve 启动即报错(不静默降级);证书齐备即实际启用(与运行状态同一真值)',
405
+ helpKey: 'settings.fields.mcpTls.help',
406
+ },
407
+ effective: (s) => s?.mcp?.tls === true,
408
+ reason: (ctx) => (ctx.config?.mcp?.http?.tls === true && !(ctx.config?.mcp?.http?.tlsKey && ctx.config?.mcp?.http?.tlsCert)
409
+ ? '缺证书:mcp.http.tlsKey / tlsCert 未齐备,serve 启动即报 MCP_INSECURE_CONFIG'
410
+ : null),
411
+ },
412
+ {
413
+ path: 'mcp.http.tlsKey',
414
+ read: (c) => c?.mcp?.http?.tlsKey ?? null,
415
+ kind: 'string',
416
+ spec: { nonEmpty: true, empty: 'delete' },
417
+ exposure: 'editable',
418
+ requiresRestart: true,
419
+ default: null,
420
+ ui: {
421
+ type: 'text',
422
+ label: 'TLS 证书私钥路径',
423
+ labelKey: 'settings.fields.mcpTlsKey.label',
424
+ placeholder: '/path/to/key.pem',
425
+ help: '与证书路径同时填写即实际启用 TLS(tls=true 则强制要求)',
426
+ helpKey: 'settings.fields.mcpTlsKey.help',
427
+ },
428
+ effective: (s) => (s?.mcp?.tlsKeyConfigured ? '(已配置)' : null),
429
+ },
430
+ {
431
+ path: 'mcp.http.tlsCert',
432
+ read: (c) => c?.mcp?.http?.tlsCert ?? null,
433
+ kind: 'string',
434
+ spec: { nonEmpty: true, empty: 'delete' },
435
+ exposure: 'editable',
436
+ requiresRestart: true,
437
+ default: null,
438
+ ui: {
439
+ type: 'text',
440
+ label: 'TLS 证书路径',
441
+ labelKey: 'settings.fields.mcpTlsCert.label',
442
+ placeholder: '/path/to/cert.pem',
443
+ help: '与证书路径同时填写即实际启用 TLS(tls=true 则强制要求)',
444
+ helpKey: 'settings.fields.mcpTlsCert.help',
445
+ },
446
+ effective: (s) => (s?.mcp?.tlsCertConfigured ? '(已配置)' : null),
447
+ },
448
+
449
+ // ------------------------------------------------------------------ status-only(只读;写 → 400)
450
+ {
451
+ // B:自动 relay 池(seeds/hints/学习)——用户拍板「自动的不保留在 GUI 编辑面」
452
+ path: 'network.libp2p.relayServers',
453
+ read: (c) => listOrNull(c?.network?.libp2p?.relayServers),
454
+ kind: 'list',
455
+ exposure: 'status-only',
456
+ requiresRestart: true,
457
+ default: null,
458
+ ui: {
459
+ type: 'list',
460
+ label: 'Relay 池(自动)',
461
+ labelKey: 'settings.schema.status.relayServers.label',
462
+ help: '自动来源:config seeds ∪ 配对令牌 hints ∪ 地址簿学习;只读展示,手工配置请编辑 config.json',
463
+ helpKey: 'settings.schema.status.relayServers.help',
464
+ },
465
+ effective: (s) => (s?.network?.relays?.length ? s.network.relays : null),
466
+ reason: (ctx) => (ctx.settings?.network?.relays?.length ? '已生效:自动池(seeds/hints/学习)' : '空:无已知 relay(配对或提供 relay 后自动填充)'),
467
+ },
468
+ {
469
+ // 默认常开,不需暴露(决策:只读展示真值)
470
+ path: 'sync.autoSync',
471
+ read: (c) => c?.sync?.autoSync ?? true,
472
+ kind: 'bool',
473
+ exposure: 'status-only',
474
+ requiresRestart: true,
475
+ default: true,
476
+ ui: {
477
+ type: 'bool',
478
+ label: '自动同步',
479
+ labelKey: 'settings.schema.status.autoSync.label',
480
+ help: '连接建立或事件到达时自动触发一次收敛(默认常开)',
481
+ helpKey: 'settings.schema.status.autoSync.help',
482
+ },
483
+ effective: (s) => s?.sync?.autoSync ?? true,
484
+ reason: (() => '已生效:常驻模式默认常开'),
485
+ },
486
+ {
487
+ path: 'sync.pushOnWrite',
488
+ read: (c) => c?.sync?.pushOnWrite ?? true,
489
+ kind: 'bool',
490
+ exposure: 'status-only',
491
+ requiresRestart: true,
492
+ default: true,
493
+ envVar: 'MEBULAR_PUSH_ON_WRITE',
494
+ ui: {
495
+ type: 'bool',
496
+ label: '写入即推送',
497
+ labelKey: 'settings.schema.status.pushOnWrite.label',
498
+ help: '本机写入后即时推给对端(常驻模式默认开)',
499
+ helpKey: 'settings.schema.status.pushOnWrite.help',
500
+ },
501
+ effective: (s) => s?.sync?.pushOnWrite ?? true,
502
+ reason: (() => '已生效:常驻模式默认常开'),
503
+ },
504
+ {
505
+ path: 'status.lan.discovery',
506
+ kind: 'status',
507
+ exposure: 'status-only',
508
+ requiresRestart: false,
509
+ ui: { type: 'status', label: 'LAN 自动发现(mDNS)', labelKey: 'settings.schema.status.lanDiscovery.label' },
510
+ effective: (s) => s?.net?.mode ?? 'off',
511
+ reason: (ctx) => (ctx.settings?.network?.enabled !== true
512
+ ? '网络未启用:LAN 发现为 no-op'
513
+ : '已生效:随网络启用(默认 mDNS)'),
514
+ },
515
+ {
516
+ path: 'status.relay.role',
517
+ kind: 'status',
518
+ exposure: 'status-only',
519
+ requiresRestart: false,
520
+ ui: { type: 'status', label: '内建 relay 角色(桥)', labelKey: 'settings.schema.status.relayRole.label' },
521
+ effective: (s) => s?.relay?.mode ?? 'off',
522
+ reason: (ctx) => {
523
+ const relay = ctx.settings?.relay;
524
+ if (!relay) return '状态不可读';
525
+ if (relay.mode === 'off') return `未提供中转:${relay.reason ?? 'mode=off'}`;
526
+ return relay.serving ? '已生效:本机对外提供中转(仅已配对/已入网对端)' : `待条件满足:${relay.reason ?? '未对外可达'}`;
527
+ },
528
+ },
529
+ {
530
+ path: 'status.relay.bridge',
531
+ kind: 'status',
532
+ exposure: 'status-only',
533
+ requiresRestart: false,
534
+ ui: { type: 'status', label: '当前中转(经谁)', labelKey: 'settings.schema.status.relayBridge.label' },
535
+ effective: (s) => s?.relay?.bridge?.peer ?? null,
536
+ reason: (ctx) => (ctx.settings?.relay?.bridge
537
+ ? `已生效:经 ${ctx.settings.relay.bridge.address ?? ctx.settings.relay.bridge.peer} 中转`
538
+ : '直连优先:当前未走 relay(打洞成功或直连可达)'),
539
+ },
540
+ {
541
+ path: 'status.nat.holepunch',
542
+ kind: 'status',
543
+ exposure: 'status-only',
544
+ requiresRestart: false,
545
+ ui: { type: 'status', label: 'NAT 穿透(AutoNAT/DCUtR)', labelKey: 'settings.schema.status.holepunch.label' },
546
+ effective: (s) => (s?.nat?.dcutrEnabled ? 'on' : 'off'),
547
+ reason: (ctx) => {
548
+ const nat = ctx.settings?.nat;
549
+ if (!nat) return '状态不可读';
550
+ if (nat.enabled !== true) return '未启用:网络未开启(AutoNAT/DCUtR 未装配)';
551
+ if (nat.dcutrEnabled !== true) return `打洞不可用:${nat.loadError ?? 'DCUtR 未启用'}`;
552
+ return nat.directUpgrades > 0 ? `已生效:已直连升级 ${nat.directUpgrades} 次` : '已装配:尚无打洞升级(直连失败时后台重试)';
553
+ },
554
+ },
555
+ {
556
+ path: 'status.net.broadcast',
557
+ kind: 'status',
558
+ exposure: 'status-only',
559
+ requiresRestart: false,
560
+ ui: { type: 'status', label: '地址自动广播(只作 hints)', labelKey: 'settings.schema.status.broadcast.label' },
561
+ effective: (s) => s?.net?.mode ?? 'off',
562
+ reason: (ctx) => (ctx.settings?.net?.enabled
563
+ ? `已生效:档位 ${ctx.settings.net.mode}(已发布 ${ctx.settings.net.published} / 已采用 ${ctx.settings.net.applied})`
564
+ : '未启用(opt-in):默认不广播地址'),
565
+ },
566
+ {
567
+ path: 'status.join.endpoint',
568
+ kind: 'status',
569
+ exposure: 'status-only',
570
+ requiresRestart: false,
571
+ ui: { type: 'status', label: '邀请端点(join)', labelKey: 'settings.schema.status.joinEndpoint.label' },
572
+ effective: (s) => s?.join?.endpoint ?? (s?.join?.port ? `:${s.join.port}` : null),
573
+ reason: (ctx) => {
574
+ const join = ctx.settings?.join;
575
+ if (!join?.enabled && !join?.endpoint) return '未启用:joinService.enabled=false';
576
+ if (join.endpointLoopback === true) return '不可达:端点回环(新设备无法访问)——joinService.bind 设 0.0.0.0';
577
+ if (join.lastError) return `上次启动失败:${join.lastError.message ?? join.lastError}`;
578
+ return '已生效:端点对新设备可达';
579
+ },
580
+ },
581
+ {
582
+ path: 'status.tls',
583
+ kind: 'status',
584
+ exposure: 'status-only',
585
+ requiresRestart: false,
586
+ ui: { type: 'status', label: 'TLS(实际)', labelKey: 'settings.schema.status.tls.label' },
587
+ effective: (s) => (s?.mcp?.tls === true ? 'on' : 'off'),
588
+ reason: (ctx) => (ctx.settings?.mcp?.tls ? '已生效:TLS 实际启用' : '未启用:明文 HTTP(仅回环安全)'),
589
+ },
590
+ // ------------------------------------------------------------------ internal(GUI 不渲染;API 仍可写)
591
+ { path: 'network.libp2p.relayUnlimited', read: (c) => c?.network?.libp2p?.relayUnlimited === true, kind: 'bool', exposure: 'internal', writable: true, requiresRestart: true, default: false, ui: { type: 'bool', label: 'Relay 不做限额' } },
592
+ { path: 'network.libp2p.relayServer', kind: 'bool', exposure: 'internal', requiresRestart: true, default: false, ui: { type: 'bool', label: '本机充当 relay 服务端' } },
593
+ { path: 'network.libp2p.relayPolicy', kind: 'object', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'object', label: 'Relay 白名单/预约上限' } },
594
+ { path: 'network.lan.enabled', kind: 'bool', exposure: 'internal', requiresRestart: true, default: true, ui: { type: 'bool', label: 'LAN 自动发现开关' } },
595
+ { path: 'network.lan.autoDial', kind: 'bool', exposure: 'internal', requiresRestart: true, default: true, ui: { type: 'bool', label: 'LAN 发现后自动拨号' } },
596
+ { path: 'network.lan.defaultFactory', kind: 'bool', exposure: 'internal', requiresRestart: true, default: true, ui: { type: 'bool', label: '内置 mDNS factory' } },
597
+ { path: 'network.relayService', kind: 'enum', spec: { values: ['auto', 'off', 'on'] }, exposure: 'internal', requiresRestart: true, default: 'auto', ui: { type: 'select', label: '内建 relay 角色' } },
598
+ { path: 'network.broadcast.mode', kind: 'enum', spec: { values: ['off', 'full', 'relay-only'] }, exposure: 'internal', requiresRestart: true, default: 'off', ui: { type: 'select', label: '地址广播档位' } },
599
+ { path: 'network.broadcast.ttlMs', kind: 'int', exposure: 'internal', requiresRestart: true, default: 86400000, ui: { type: 'number', label: '广播 TTL' } },
600
+ { path: 'network.nat.autonat', kind: 'bool', exposure: 'internal', requiresRestart: true, default: true, ui: { type: 'bool', label: 'AutoNAT' } },
601
+ { path: 'network.nat.dcutr', kind: 'bool', exposure: 'internal', requiresRestart: true, default: true, ui: { type: 'bool', label: 'DCUtR 打洞' } },
602
+ { path: 'network.endpointStore', kind: 'object', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'object', label: '端点簿存储接缝' } },
603
+ { path: 'network.autoConnect', kind: 'bool', exposure: 'internal', requiresRestart: true, default: true, ui: { type: 'bool', label: '地址簿自动拨号' } },
604
+ { path: 'network.peers', kind: 'list', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'list', label: '启动时主动拨号对端' } },
605
+ { path: 'sync.pushOnWriteThrottleMs', kind: 'int', exposure: 'internal', requiresRestart: true, default: 50, ui: { type: 'number', label: '写入即推节流' } },
606
+ { path: 'sync.syncTimeout', kind: 'int', exposure: 'internal', requiresRestart: true, default: null, ui: { type: 'number', label: '同步超时' } },
607
+ { path: 'sync.syncStatePath', kind: 'string', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'text', label: '已确认集合持久化路径' } },
608
+ { path: 'semantic.model', kind: 'string', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'text', label: 'embedding 模型' } },
609
+ { path: 'semantic.cacheDir', kind: 'string', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'text', label: '模型缓存目录' } },
610
+ { path: 'storagePath', kind: 'string', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'text', label: '存储路径' } },
611
+ { path: 'storageAdapter', kind: 'enum', spec: { values: ['json', 'sqlite'] }, exposure: 'internal', writable: false, requiresRestart: true, default: 'json', ui: { type: 'select', label: '存储适配器' } },
612
+ { path: 'deviceId', kind: 'string', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'text', label: '设备标识' } },
613
+ { path: 'deviceName', kind: 'string', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'text', label: '设备名' } },
614
+ { path: 'identity.mode', kind: 'enum', spec: { values: ['root', 'delegated'] }, exposure: 'internal', writable: false, requiresRestart: true, default: 'root', ui: { type: 'select', label: '身份模式' } },
615
+ { path: 'encryption.level', kind: 'enum', spec: { values: ['none', 'at-rest'] }, exposure: 'internal', writable: false, requiresRestart: true, default: 'none', ui: { type: 'select', label: '加密级别' } },
616
+ { path: 'encryption.passphraseEnv', kind: 'string', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'text', label: '口令环境变量名' } },
617
+ { path: 'mcp.http.tokensFile', kind: 'string', exposure: 'internal', writable: false, requiresRestart: true, default: null, ui: { type: 'text', label: '静态 token 文件' } },
618
+ ];
619
+
620
+ /** path → 条目(O(1) 查表)。 */
621
+ export const CONFIG_SCHEMA_BY_PATH = new Map(CONFIG_SCHEMA.map((entry) => [entry.path, entry]));
622
+
623
+ /** 某 path 的暴露面;未知 path = null(写请求应拒绝)。 */
624
+ export function exposureOf(path) {
625
+ return CONFIG_SCHEMA_BY_PATH.get(path)?.exposure ?? null;
626
+ }
627
+
628
+ /**
629
+ * 是否允许经 `POST /admin/api/config` 写入:
630
+ * editable → 允许;internal → 仅 `writable: true` 允许;status-only / 敏感 internal / 未知 → 拒绝。
631
+ */
632
+ export function isWritable(path) {
633
+ const entry = CONFIG_SCHEMA_BY_PATH.get(path);
634
+ if (!entry) return false;
635
+ if (entry.exposure === 'editable') return true;
636
+ if (entry.exposure === 'internal') return entry.writable === true;
637
+ return false;
638
+ }
639
+
640
+ /** 写请求拒绝时的分类(供 400 文案与断言)。 */
641
+ export function writeRejection(path) {
642
+ const entry = CONFIG_SCHEMA_BY_PATH.get(path);
643
+ if (!entry) return 'unknown';
644
+ if (entry.exposure === 'status-only') return 'status-only';
645
+ if (entry.exposure === 'internal') return entry.writable === true ? null : 'internal-protected';
646
+ return null;
647
+ }
648
+
649
+ const pathsWith = (exposure) => CONFIG_SCHEMA.filter((entry) => entry.exposure === exposure).map((entry) => entry.path);
650
+
651
+ /** 可编辑面(常用 6 + 高级 14 = 20)。 */
652
+ export const EDITABLE_PATHS = pathsWith('editable');
653
+ /** 只读面(自动池/状态行;写请求 → 400)。 */
654
+ export const STATUS_ONLY_PATHS = pathsWith('status-only');
655
+ /** 内部面(GUI 不渲染;API 仍可写)。 */
656
+ export const INTERNAL_PATHS = pathsWith('internal');
657
+ /** 内部面中 API 仍可写的 path——诊断三元组(已配置/实际/未生效原因)同样覆盖:写了就能生效/待重启,排查要看得到。 */
658
+ export const WRITABLE_INTERNAL_PATHS = CONFIG_SCHEMA
659
+ .filter((entry) => entry.exposure === 'internal' && entry.writable === true)
660
+ .map((entry) => entry.path);
661
+
662
+ /**
663
+ * 需要重启才生效的 path(真值判定仍在 admin.computePendingRestart)。
664
+ * 待重启横幅只覆盖「有 GUI 去处」的项:editable ∪ status-only ∪ 显式可写 internal
665
+ * (= 与 #80 的 24 项逐一对应;纯 protected internal 不渲染也不报)。
666
+ */
667
+ export const PENDING_RESTART_PATHS = CONFIG_SCHEMA.filter(
668
+ (entry) => entry.requiresRestart === true
669
+ && (entry.exposure === 'editable' || entry.exposure === 'status-only' || entry.writable === true),
670
+ );
671
+
672
+ /** 控制台编辑器元数据(editable 面;含 path/kind/spec/ui/requiresRestart/default)。 */
673
+ export function consoleSchema() {
674
+ return CONFIG_SCHEMA.filter((entry) => entry.exposure === 'editable').map((entry) => ({
675
+ path: entry.path,
676
+ kind: entry.kind,
677
+ spec: entry.spec ?? {},
678
+ ui: entry.ui,
679
+ requiresRestart: entry.requiresRestart === true,
680
+ default: entry.default ?? null,
681
+ }));
682
+ }
683
+
684
+ /** 控制台只读面(status-only 行:label + 实际值 + 原因),供「关于本机 / 诊断」只读展示。 */
685
+ export function consoleStatusSchema() {
686
+ return CONFIG_SCHEMA.filter((entry) => entry.exposure === 'status-only').map((entry) => ({
687
+ path: entry.path,
688
+ ui: entry.ui,
689
+ }));
690
+ }