dsh-memento 0.5.2 → 0.5.4

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/client/client.js CHANGED
@@ -1,30 +1,43 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // client/client.js — dsh-memento 浏览器观察面板(F9,零构建 vanilla)。
2
+ // client/client.js — dsh-memento 浏览器半侧(零构建 vanilla,单模块)。
3
3
  //
4
4
  // host 端 dsh.client 扫描把本文件作为 classic script 注入 __DSH_BOOT__ 图,
5
- // 执行时经 window.__ModuleLoader__.load 注册工厂;apply 挂载浮层抽屉面板。
5
+ // 执行时经 window.__ModuleLoader__.load 注册唯一 factory(id = 插件名,与
6
+ // 宿主 graph row 一致;同文件多个 load 会产生永远不被物化的孤儿 factory)。
7
+ // apply 挂载两块表面:浮层抽屉面板 + 宿主设置弹窗的一级设置项。
6
8
  // 面板只读:条目浏览/搜索/预算条/审计尾,全部走本插件自注册的
7
9
  // /api/memento/* JSON 路由(只走公开 API)。写与审批在 DSH 内置审批 UI 完成,
8
10
  // 面板不产生任何模型可见内容、不做任何审批决策。
9
11
  // 面板文案随 Config.language(en/zh)切换,语言来自 entries 路由响应。
12
+ // 设置页经 ctx.settingsScope 读/写用户层(settings.yaml),暂存—保存语义
13
+ // 与宿主内置卡片一致;factory 的 require 由宿主模块系统提供(react 为平台
14
+ // 内置模块)。
10
15
 
11
- (function () {
16
+ ;(function () {
12
17
  'use strict'
13
18
  if (typeof window === 'undefined' || !window.__ModuleLoader__ || !window.__ModuleLoader__.load) return
14
19
  window.__ModuleLoader__.load({
15
20
  id: 'dsh-memento',
16
- factory: function () {
17
- return {
18
- name: 'memento-panel',
19
- inject: [],
20
- apply: function () { installPanel() },
21
- }
22
- },
23
- })
24
- })()
21
+ factory: function (require) {
22
+ const react = require('react')
23
+ /** createElement 简写(宿主平台内置 react,无需构建期 JSX 编译)。 */
24
+ const jsx = react.createElement
25
25
 
26
26
  const PANEL_ID = 'dsh-memento-panel'
27
27
 
28
+ /** 本页已渲染的悬浮入口按钮引用(设置页 panel.enabled 开关即时切换用)。 */
29
+ let panelOpenButton = null
30
+
31
+ /** 悬浮窗开关的即时生效面:切显隐;开启时按钮缺失(上次探测为关)则重建。 */
32
+ function setPanelButtonVisible(visible) {
33
+ if (panelOpenButton === null) panelOpenButton = document.getElementById('mem-open')
34
+ if (panelOpenButton !== null) {
35
+ panelOpenButton.style.display = visible ? '' : 'none'
36
+ } else if (visible) {
37
+ void bootPanel()
38
+ }
39
+ }
40
+
28
41
  /** 面板文案(en 源文 / zh 译文;语言来自 /api/memento/entries 响应的 language 字段,缺省 en)。 */
29
42
  const STRINGS = {
30
43
  en: {
@@ -63,9 +76,35 @@ const STRINGS = {
63
76
  },
64
77
  }
65
78
 
66
- function installPanel() {
79
+ /**
80
+ * 启动探测:panel.enabled=false 时入口按钮不渲染(设置面板可随时改回);
81
+ * 探测失败按开启处理,行为与未引入开关前的版本一致。
82
+ * @returns {Promise<{enabled: boolean, language: string}>}。
83
+ */
84
+ async function probePanelState() {
85
+ try {
86
+ const response = await fetch('/api/memento/entries?limit=1')
87
+ if (response.ok) {
88
+ const data = await response.json()
89
+ if (data.error === undefined) {
90
+ return { enabled: data.panel?.enabled !== false, language: data.language ?? 'en' }
91
+ }
92
+ }
93
+ } catch {
94
+ // 探测失败:保持默认开启。
95
+ }
96
+ return { enabled: true, language: 'en' }
97
+ }
98
+
99
+ async function bootPanel() {
100
+ const state = await probePanelState()
101
+ if (!state.enabled) return
102
+ installPanel(state)
103
+ }
104
+
105
+ function installPanel(state) {
67
106
  if (document.getElementById(PANEL_ID)) return
68
- let S = STRINGS.en
107
+ let S = STRINGS[state.language] ?? STRINGS.en
69
108
 
70
109
  const style = document.createElement('style')
71
110
  style.textContent = `
@@ -95,6 +134,7 @@ function installPanel() {
95
134
  const openBtn = document.createElement('button')
96
135
  openBtn.id = 'mem-open'
97
136
  openBtn.textContent = S.open
137
+ panelOpenButton = openBtn
98
138
 
99
139
  const root = document.createElement('div')
100
140
  root.id = PANEL_ID
@@ -239,3 +279,525 @@ function installPanel() {
239
279
  function escapeHtml(value) {
240
280
  return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
241
281
  }
282
+
283
+ // ── 宿主设置页(settings.section 一级项,id = dsh-memento)──────────────
284
+ /** 卡片文案(en 源文 / zh 译文;语言跟随 namespace value.language,保存后即时切换)。 */
285
+ const CARD_STRINGS = {
286
+ en: {
287
+ title: 'dsh-memento memory',
288
+ description: 'Approval-gated cross-session memory. Writes, snapshot wording and the floating panel follow these values.',
289
+ sectionPermissions: 'Write approval policy',
290
+ sectionPanel: 'Floating panel',
291
+ sectionLanguage: 'Language',
292
+ sectionBudgets: 'Character budgets (per track/layer)',
293
+ sectionLimits: 'Query & command limits',
294
+ sectionRecall: 'Recall defaults',
295
+ sectionPanelPage: 'Panel page limits',
296
+ sectionProposals: 'Auto-capture proposals',
297
+ sectionStorage: 'Storage & audit retention',
298
+ sectionAdvanced: 'Advanced (applied after DSH reload)',
299
+ writePolicy: 'Global write policy',
300
+ writePolicies: 'Per-track/scope policies',
301
+ writePoliciesHint: 'One per line: track/scope=policy or source:name=policy. Unknown keys fail validation on save.',
302
+ panelEnabled: 'Show the floating panel button',
303
+ language: 'Language',
304
+ budgetUserGlobal: 'user / user-global',
305
+ budgetUserWorkspace: 'user / workspace',
306
+ budgetAgentGlobal: 'agent / user-global',
307
+ budgetAgentWorkspace: 'agent / workspace',
308
+ maxEntriesPerQuery: 'query default limit',
309
+ commandListLimit: '/memory list|query page limit',
310
+ commandAuditLimit: '/memory audit page limit',
311
+ recallHistoryLimit: 'history sessions per recall',
312
+ recallSnippetCap: 'snippets per session',
313
+ recallSnippetChars: 'snippet characters',
314
+ recallWindowDays: 'history window (days)',
315
+ panelEntriesLimit: 'entries page limit',
316
+ panelAuditLimit: 'audit page limit',
317
+ proposalsEnabled: 'Generate proposals after compaction',
318
+ proposalsMaxChars: 'proposal max characters',
319
+ proposalsMaxPending: 'max pending proposals',
320
+ dbPath: 'Memory database path',
321
+ dbPathHint: 'Empty = default ($DSH_HOME/dsh-memento/memory.db). Saving reopens the store immediately.',
322
+ snapshotOrder: 'Snapshot section order',
323
+ auditRetentionDays: 'Audit retention (days, 0 = unlimited)',
324
+ retrievalVector: 'Vector recall (when an embedding provider exists)',
325
+ reloadHint: 'Applied after DSH reload',
326
+ overridden: 'Overridden',
327
+ reset: 'Reset',
328
+ resetField: 'Reset field',
329
+ save: 'Save',
330
+ saving: 'Saving…',
331
+ discard: 'Discard',
332
+ unsaved: 'Unsaved changes',
333
+ saveFailed: 'Save failed — drafts kept for correction.',
334
+ readOnly: 'Read-only (settings service unavailable).',
335
+ invalidNumber: 'Must be a whole number.',
336
+ invalidPolicy: 'Must be ask, auto or off.',
337
+ },
338
+ zh: {
339
+ title: 'dsh-memento 记忆',
340
+ description: '带审批门的跨会话记忆。写入策略、快照文案与悬浮窗跟随这些值。',
341
+ sectionPermissions: '写审批策略',
342
+ sectionPanel: '悬浮窗',
343
+ sectionLanguage: '语言',
344
+ sectionBudgets: '字符预算(每轨道/层)',
345
+ sectionLimits: '查询与命令上限',
346
+ sectionRecall: '召回默认值',
347
+ sectionPanelPage: '面板页上限',
348
+ sectionProposals: '自动捕捉提案',
349
+ sectionStorage: '存储与审计保留',
350
+ sectionAdvanced: '高级(DSH 重载后生效)',
351
+ writePolicy: '全局写策略',
352
+ writePolicies: '按轨道/层粒度策略',
353
+ writePoliciesHint: '每行一条:track/scope=策略 或 source:name=策略。保存时无法识别的键会被校验拒绝。',
354
+ panelEnabled: '显示悬浮窗入口按钮',
355
+ language: '语言',
356
+ budgetUserGlobal: 'user / user-global',
357
+ budgetUserWorkspace: 'user / workspace',
358
+ budgetAgentGlobal: 'agent / user-global',
359
+ budgetAgentWorkspace: 'agent / workspace',
360
+ maxEntriesPerQuery: 'query 默认上限',
361
+ commandListLimit: '/memory list|query 单页上限',
362
+ commandAuditLimit: '/memory audit 单页上限',
363
+ recallHistoryLimit: '每次召回扫描会话数',
364
+ recallSnippetCap: '每会话片段数',
365
+ recallSnippetChars: '片段字符数',
366
+ recallWindowDays: '历史窗口(天)',
367
+ panelEntriesLimit: '条目单页上限',
368
+ panelAuditLimit: '审计单页上限',
369
+ proposalsEnabled: '压缩结束后生成提案',
370
+ proposalsMaxChars: '提案最大字符数',
371
+ proposalsMaxPending: '待审批提案上限',
372
+ dbPath: '记忆库路径',
373
+ dbPathHint: '留空 = 默认($DSH_HOME/dsh-memento/memory.db)。保存后立即重开记忆库。',
374
+ snapshotOrder: '快照段注入顺序',
375
+ auditRetentionDays: '审计保留天数(0 = 不限)',
376
+ retrievalVector: '向量召回(存在 embedding provider 时)',
377
+ reloadHint: 'DSH 重载后生效',
378
+ overridden: '已覆盖',
379
+ reset: '重置',
380
+ resetField: '重置该字段',
381
+ save: '保存',
382
+ saving: '保存中…',
383
+ discard: '放弃修改',
384
+ unsaved: '有未保存修改',
385
+ saveFailed: '保存失败——草稿已保留,可修正后重试。',
386
+ readOnly: '只读(设置服务不可用)。',
387
+ invalidNumber: '必须是整数。',
388
+ invalidPolicy: '必须是 ask、auto 或 off。',
389
+ },
390
+ }
391
+
392
+ /** 顶层字段名(保存按顶层聚合:scope.set(topField, 合并值),不依赖点路径写入面)。 */
393
+ const POLICIES = ['ask', 'auto', 'off']
394
+
395
+ /** 简版快照 store(useSyncExternalStore 形状:{getSnapshot, subscribe}+set)。 */
396
+ function createSnapshotStore(initial) {
397
+ let snapshot = initial
398
+ const listeners = new Set()
399
+ return {
400
+ getSnapshot() { return snapshot },
401
+ subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn) } },
402
+ set(next) { snapshot = next; for (const fn of listeners) fn() },
403
+ }
404
+ }
405
+
406
+ /** 读点路径('a.b' → obj?.a?.b)。 */
407
+ function pathValue(obj, path) {
408
+ let cursor = obj
409
+ for (const key of path.split('.')) {
410
+ if (cursor === null || typeof cursor !== 'object') return undefined
411
+ cursor = cursor[key]
412
+ }
413
+ return cursor
414
+ }
415
+
416
+ /** 判定子字段是否被用户层覆盖(user 层子路径 hasOwn)。 */
417
+ function pathStored(user, path) {
418
+ const keys = path.split('.')
419
+ const last = keys.pop()
420
+ let cursor = user
421
+ for (const key of keys) {
422
+ if (cursor === null || typeof cursor !== 'object') return false
423
+ cursor = cursor[key]
424
+ }
425
+ return cursor !== null && typeof cursor === 'object' && Object.hasOwn(cursor, last)
426
+ }
427
+
428
+ /** 深合并(草稿子树 → 当前顶层值;数组与标量直接替换)。 */
429
+ function deepMerge(base, patch) {
430
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return patch
431
+ const out = { ...(base !== null && typeof base === 'object' && !Array.isArray(base) ? base : {}) }
432
+ for (const [key, value] of Object.entries(patch)) out[key] = deepMerge(out[key], value)
433
+ return out
434
+ }
435
+
436
+ /** 子字段规格:path + kind(number/text/bool/choice/policies)+ choice 候选 + key(文案键)。 */
437
+ const FIELD_SPECS = [
438
+ { path: 'writePolicy', kind: 'choice', choices: POLICIES, key: 'writePolicy' },
439
+ { path: 'writePolicies', kind: 'policies', key: 'writePolicies' },
440
+ { path: 'panel.enabled', kind: 'bool', key: 'panelEnabled' },
441
+ { path: 'language', kind: 'choice', choices: ['en', 'zh'], key: 'language' },
442
+ { path: 'budgets.user.userGlobal', kind: 'number', key: 'budgetUserGlobal' },
443
+ { path: 'budgets.user.workspace', kind: 'number', key: 'budgetUserWorkspace' },
444
+ { path: 'budgets.agent.userGlobal', kind: 'number', key: 'budgetAgentGlobal' },
445
+ { path: 'budgets.agent.workspace', kind: 'number', key: 'budgetAgentWorkspace' },
446
+ { path: 'maxEntriesPerQuery', kind: 'number', key: 'maxEntriesPerQuery' },
447
+ { path: 'commandListLimit', kind: 'number', key: 'commandListLimit' },
448
+ { path: 'commandAuditLimit', kind: 'number', key: 'commandAuditLimit' },
449
+ { path: 'recall.historyLimitDefault', kind: 'number', key: 'recallHistoryLimit' },
450
+ { path: 'recall.snippetCap', kind: 'number', key: 'recallSnippetCap' },
451
+ { path: 'recall.snippetChars', kind: 'number', key: 'recallSnippetChars' },
452
+ { path: 'recall.windowDays', kind: 'number', key: 'recallWindowDays' },
453
+ { path: 'panelEntriesLimit', kind: 'number', key: 'panelEntriesLimit' },
454
+ { path: 'panelAuditLimit', kind: 'number', key: 'panelAuditLimit' },
455
+ { path: 'proposals.enabled', kind: 'bool', key: 'proposalsEnabled' },
456
+ { path: 'proposals.maxChars', kind: 'number', key: 'proposalsMaxChars' },
457
+ { path: 'proposals.maxPending', kind: 'number', key: 'proposalsMaxPending' },
458
+ { path: 'dbPath', kind: 'text', key: 'dbPath' },
459
+ { path: 'snapshotOrder', kind: 'number', key: 'snapshotOrder' },
460
+ { path: 'auditRetentionDays', kind: 'number', key: 'auditRetentionDays' },
461
+ { path: 'retrieval.vector', kind: 'bool', key: 'retrievalVector' },
462
+ ]
463
+ const SPEC_BY_PATH = new Map(FIELD_SPECS.map((spec) => [spec.path, spec]))
464
+ const RELOAD_PATHS = new Set(['snapshotOrder'])
465
+
466
+ /** 草稿文本 → 顶层字段写入计划;无法解析的草稿返回 undefined(阻塞保存)。 */
467
+ function parseDraftText(spec, text, currentValue) {
468
+ if (spec.kind === 'bool') {
469
+ if (text === 'true') return { ok: true, value: true }
470
+ if (text === 'false') return { ok: true, value: false }
471
+ return { ok: false }
472
+ }
473
+ if (spec.kind === 'choice') {
474
+ return spec.choices.includes(text) ? { ok: true, value: text } : { ok: false }
475
+ }
476
+ if (spec.kind === 'number') {
477
+ const trimmed = text.trim()
478
+ const parsed = Number(trimmed)
479
+ return trimmed !== '' && Number.isFinite(parsed) && Number.isInteger(parsed) ? { ok: true, value: parsed } : { ok: false }
480
+ }
481
+ if (spec.kind === 'policies') {
482
+ const next = {}
483
+ for (const rawLine of text.split('\n')) {
484
+ const line = rawLine.trim()
485
+ if (line === '') continue
486
+ const eq = line.indexOf('=')
487
+ if (eq <= 0) return { ok: false }
488
+ const key = line.slice(0, eq).trim()
489
+ const policy = line.slice(eq + 1).trim()
490
+ if (key === '' || !POLICIES.includes(policy)) return { ok: false }
491
+ next[key] = policy
492
+ }
493
+ return { ok: true, value: next }
494
+ }
495
+ // text:空串 = 恢复 base 语义由 clear 处理,这里空串写空字符串(dbPath 空 = 默认路径)。
496
+ void currentValue
497
+ return { ok: true, value: text }
498
+ }
499
+
500
+ /** 子字段格式化(快照值 → 控件文本)。 */
501
+ function formatValue(value) {
502
+ if (value === undefined || value === null) return ''
503
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
504
+ return String(value)
505
+ }
506
+
507
+ /** policies 顶层对象的控件文本(每行 key=policy)。 */
508
+ function formatPolicies(value) {
509
+ if (value === null || typeof value !== 'object') return ''
510
+ return Object.entries(value).map(([key, policy]) => `${key}=${policy}`).join('\n')
511
+ }
512
+
513
+ /**
514
+ * 暂存表单(学宿主 CardForm:staged → save 才写;revision 栅防并发覆盖)。
515
+ * 顶层聚合:同顶层字段的多个子字段草稿一次 scope.set(top, 合并值)。
516
+ */
517
+ class CardForm {
518
+ constructor(scope, onLanded) {
519
+ this.scope = scope
520
+ /** @type {((tops: Map<string, object>) => void) | undefined} 落盘成功回调(panel 显隐同步用)。 */
521
+ this.onLanded = onLanded
522
+ /** @type {Map<string, string>} */
523
+ this.staged = new Map()
524
+ this.saving = false
525
+ this.failed = false
526
+ this.listeners = new Set()
527
+ scope.subscribe(() => this.publish())
528
+ }
529
+
530
+ bind(project) {
531
+ const store = createSnapshotStore(project())
532
+ this.listeners.add(() => store.set(project()))
533
+ return store
534
+ }
535
+
536
+ stage(path, text) {
537
+ this.staged.set(path, text)
538
+ this.failed = false
539
+ this.publish()
540
+ }
541
+
542
+ discard() {
543
+ if (this.staged.size === 0 && !this.failed) return
544
+ this.staged.clear()
545
+ this.failed = false
546
+ this.publish()
547
+ }
548
+
549
+ async save() {
550
+ if (this.saving || this.staged.size === 0) return
551
+ const snapshot = this.scope.getSnapshot()
552
+ if (snapshot.status !== 'ready' || !snapshot.writable) return
553
+ /** @type {Map<string, object>} */
554
+ const tops = new Map()
555
+ let valid = true
556
+ for (const [path, text] of this.staged) {
557
+ const spec = SPEC_BY_PATH.get(path)
558
+ const keys = path.split('.')
559
+ const top = keys[0]
560
+ const parsed = parseDraftText(spec, text, snapshot.value)
561
+ if (!parsed.ok) { valid = false; continue }
562
+ const base = tops.get(top) ?? (snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value[top] : undefined)
563
+ const merged = keys.length === 1
564
+ ? parsed.value
565
+ : deepMerge(base, (() => { /** @type {Record<string, unknown>} */ const out = {}; let cursor = out; for (let i = 1; i < keys.length - 1; i++) { cursor[keys[i]] = {}; cursor = cursor[keys[i]] } cursor[keys[keys.length - 1]] = parsed.value; return out })())
566
+ tops.set(top, merged)
567
+ }
568
+ if (!valid || this.staged.size === 0) { this.publish(); return }
569
+ this.saving = true
570
+ this.failed = false
571
+ this.publish()
572
+ let landed = true
573
+ for (const [top, value] of tops) {
574
+ try {
575
+ await this.scope.set(top, value)
576
+ } catch {
577
+ landed = false
578
+ }
579
+ }
580
+ if (landed && typeof this.onLanded === 'function') this.onLanded(tops)
581
+ if (landed) this.staged.clear()
582
+ this.saving = false
583
+ this.failed = !landed
584
+ this.publish()
585
+ }
586
+
587
+ fieldState(path) {
588
+ const spec = SPEC_BY_PATH.get(path)
589
+ const snapshot = this.scope.getSnapshot()
590
+ const staged = this.staged.get(path)
591
+ const effective = pathValue(snapshot.value, path)
592
+ const overridden = pathStored(snapshot.user, path)
593
+ if (staged === undefined) {
594
+ const text = spec.kind === 'policies' ? formatPolicies(effective) : formatValue(effective)
595
+ return { text, overridden, invalid: false }
596
+ }
597
+ const parsed = parseDraftText(spec, staged, snapshot.value)
598
+ return { text: staged, overridden, invalid: !parsed.ok }
599
+ }
600
+
601
+ shell() {
602
+ const snapshot = this.scope.getSnapshot()
603
+ let invalid = false
604
+ for (const [path, text] of this.staged) {
605
+ if (!parseDraftText(SPEC_BY_PATH.get(path), text, snapshot.value).ok) invalid = true
606
+ }
607
+ // 界面语言跟随「所选」语言:草稿里的 language 合法即生效,保存与否不影响 UI 语言。
608
+ const stagedLanguage = this.staged.get('language')
609
+ const draftLanguage = stagedLanguage === 'en' || stagedLanguage === 'zh' ? stagedLanguage : undefined
610
+ return {
611
+ available: snapshot.status === 'ready',
612
+ writable: snapshot.writable === true,
613
+ dirty: this.staged.size > 0,
614
+ invalid,
615
+ saving: this.saving,
616
+ failed: this.failed,
617
+ language: draftLanguage ?? snapshot.value?.language,
618
+ }
619
+ }
620
+
621
+ publish() {
622
+ for (const listener of this.listeners) listener()
623
+ }
624
+ }
625
+
626
+ /** 字段分组(卡片 UI 布局;reload 组标注重载生效)。 */
627
+ const FIELD_GROUPS = [
628
+ { key: 'sectionPermissions', paths: ['writePolicy', 'writePolicies'] },
629
+ { key: 'sectionPanel', paths: ['panel.enabled'] },
630
+ { key: 'sectionLanguage', paths: ['language'] },
631
+ { key: 'sectionBudgets', paths: ['budgets.user.userGlobal', 'budgets.user.workspace', 'budgets.agent.userGlobal', 'budgets.agent.workspace'] },
632
+ { key: 'sectionLimits', paths: ['maxEntriesPerQuery', 'commandListLimit', 'commandAuditLimit'] },
633
+ { key: 'sectionRecall', paths: ['recall.historyLimitDefault', 'recall.snippetCap', 'recall.snippetChars', 'recall.windowDays', 'retrieval.vector'] },
634
+ { key: 'sectionPanelPage', paths: ['panelEntriesLimit', 'panelAuditLimit'] },
635
+ { key: 'sectionProposals', paths: ['proposals.enabled', 'proposals.maxChars', 'proposals.maxPending'] },
636
+ { key: 'sectionStorage', paths: ['dbPath', 'auditRetentionDays'] },
637
+ { key: 'sectionAdvanced', paths: ['snapshotOrder'] },
638
+ ]
639
+
640
+ let styleInstalled = false
641
+ const CARD_CSS = `
642
+ .memsec { max-width: 720px; color: inherit; font-size: 13px; }
643
+ .memsec-title { margin: 0 0 4px; font-size: 16px; font-weight: 600; }
644
+ .memsec-desc { margin: 0 0 8px; font-size: 13px; color: var(--dsw-alias-label-secondary, #8a93a6); }
645
+ .memsec-note { margin: 0 0 8px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #98a2b3); }
646
+ .memsec-group { margin: 20px 0 0; padding-bottom: 4px; font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-secondary, #6f87ad); }
647
+ .memcard-field { display: flex; flex-direction: column; padding: 8px 0; border-top: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.22)); }
648
+ .memcard-row { display: flex; flex: 1; flex-wrap: wrap; min-width: 0; align-items: center; gap: 8px; }
649
+ .memcard-label { flex: 1 1 auto; min-width: 0; font-size: 13px; }
650
+ .memcard-input { border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35)); background: transparent; color: inherit; border-radius: 6px; padding: 4px 8px; font: inherit; min-width: 0; width: 240px; }
651
+ .memcard-input[aria-invalid="true"] { border-color: var(--dsw-alias-label-error, #e5534b); }
652
+ .memcard-textarea { flex-basis: 100%; width: 100%; min-height: 64px; font: 12px/1.5 ui-monospace, monospace; resize: vertical; }
653
+ .memcard-hint, .memcard-invalid { margin: 2px 0 0; font-size: 12px; }
654
+ .memcard-hint { color: var(--dsw-alias-label-tertiary, #98a2b3); }
655
+ .memcard-invalid { color: var(--dsw-alias-label-error, #e5534b); }
656
+ .memcard-override { font-size: 11px; color: var(--dsw-alias-label-secondary, #98a2b3); white-space: nowrap; }
657
+ .memcard-badge { white-space: nowrap; background: var(--dsw-alias-bg-module-platform, rgba(128, 128, 128, 0.15)); color: var(--dsw-alias-label-secondary, #98a2b3); border-radius: 999px; padding: 1px 8px; font-size: 11px; }
658
+ .memcard-reset { font: inherit; font-size: 12px; color: var(--dsw-alias-label-secondary, #98a2b3); cursor: pointer; background: 0 0; border: none; padding: 0; white-space: nowrap; }
659
+ .memsec-footer { display: flex; gap: 8px; align-items: center; margin: 0 0 6px; }
660
+ .memsec-spacer { flex: 1; }
661
+ .memsec-dirty { font-size: 12px; color: var(--dsw-alias-label-secondary, #98a2b3); }
662
+ .memsec-failed { font-size: 12px; color: var(--dsw-alias-label-error, #e5534b); }
663
+ .memsec-btn { font: inherit; font-size: 13px; border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35)); background: transparent; color: inherit; border-radius: 6px; padding: 4px 14px; cursor: pointer; }
664
+ .memsec-btn:disabled { opacity: 0.5; cursor: default; }
665
+ `
666
+
667
+ /** 单字段控件行(label + input/checkbox/select + override 徽标 + reset + hint/invalid)。 */
668
+ function FieldRow(props) {
669
+ const { t, spec, state, disabled, onEdit, onReset } = props
670
+ const label = t(spec.key ?? spec.path)
671
+ const reloadBadge = RELOAD_PATHS.has(spec.path) ? jsx('span', { className: 'memcard-badge', title: t('reloadHint') }, '⟳') : null
672
+ const invalid = spec.kind === 'choice' ? t('invalidPolicy') : t('invalidNumber')
673
+ const control = spec.kind === 'bool'
674
+ ? jsx('input', {
675
+ type: 'checkbox', disabled,
676
+ checked: state.text === 'true',
677
+ onChange: (event) => onEdit(event.target.checked ? 'true' : 'false'),
678
+ })
679
+ : spec.kind === 'choice'
680
+ ? jsx('select', {
681
+ className: 'memcard-input', disabled, value: state.text,
682
+ onChange: (event) => onEdit(event.target.value),
683
+ }, spec.choices.map((choice) => jsx('option', { key: choice, value: choice }, choice)))
684
+ : spec.kind === 'policies'
685
+ ? jsx('textarea', {
686
+ className: 'memcard-input memcard-textarea', disabled,
687
+ value: state.text, 'aria-invalid': state.invalid,
688
+ onChange: (event) => onEdit(event.target.value),
689
+ })
690
+ : jsx('input', {
691
+ type: spec.kind === 'number' ? 'text' : 'text',
692
+ inputMode: spec.kind === 'number' ? 'numeric' : undefined,
693
+ className: 'memcard-input', disabled,
694
+ value: state.text, 'aria-invalid': state.invalid,
695
+ onChange: (event) => onEdit(event.target.value),
696
+ })
697
+ return jsx('div', { className: 'memcard-field' },
698
+ jsx('div', { className: 'memcard-row' },
699
+ jsx('label', { className: 'memcard-label' }, label, state.overridden ? jsx('span', { className: 'memcard-override' }, ` · ${t('overridden')}`) : null, reloadBadge),
700
+ state.overridden ? jsx('button', { type: 'button', className: 'memcard-reset', disabled, title: t('resetField'), onClick: onReset }, t('reset')) : null,
701
+ control,
702
+ ),
703
+ spec.path === 'writePolicies' ? jsx('p', { className: 'memcard-hint' }, t('writePoliciesHint'))
704
+ : spec.path === 'dbPath' ? jsx('p', { className: 'memcard-hint' }, t('dbPathHint'))
705
+ : null,
706
+ state.invalid ? jsx('p', { className: 'memcard-invalid' }, invalid) : null,
707
+ )
708
+ }
709
+
710
+ /** 设置页组件(settings.section 渲染入口;hooks share: mementoCard → useMementoCard)。 */
711
+ function MementoSection(props) {
712
+ const state = props.useMementoCard((snapshot) => snapshot)
713
+ const language = state.language === 'zh' ? 'zh' : 'en'
714
+ const t = (key) => CARD_STRINGS[language][key] ?? key
715
+ const blocked = !state.dirty || state.saving || state.invalid
716
+ return jsx('div', { className: 'memsec' },
717
+ jsx('h2', { className: 'memsec-title' }, t('title')),
718
+ jsx('p', { className: 'memsec-desc' }, t('description')),
719
+ !state.available || !state.writable ? jsx('p', { className: 'memsec-note' }, t('readOnly')) : null,
720
+ jsx('div', { className: 'memsec-footer' },
721
+ state.dirty ? jsx('span', { className: 'memsec-dirty' }, t('unsaved')) : null,
722
+ state.failed ? jsx('span', { className: 'memsec-failed' }, t('saveFailed')) : null,
723
+ jsx('span', { className: 'memsec-spacer' }),
724
+ jsx('button', { type: 'button', className: 'memsec-btn', disabled: !state.dirty || state.saving, onClick: props.discard }, t('discard')),
725
+ jsx('button', { type: 'button', className: 'memsec-btn', disabled: blocked, onClick: props.save }, state.saving ? t('saving') : t('save')),
726
+ ),
727
+ state.available
728
+ ? FIELD_GROUPS.map((group) => jsx('div', { key: group.key },
729
+ jsx('div', { className: 'memsec-group' }, t(group.key)),
730
+ group.paths.map((path) => {
731
+ const spec = SPEC_BY_PATH.get(path)
732
+ return jsx(FieldRow, {
733
+ key: path, t, spec, disabled: !state.writable,
734
+ state: state.fields[path],
735
+ onEdit: (text) => props.edit(path, text),
736
+ onReset: () => {
737
+ const baseValue = pathValue(props.base, path)
738
+ props.edit(path, spec.kind === 'policies' ? formatPolicies(baseValue) : formatValue(baseValue))
739
+ },
740
+ })
741
+ }),
742
+ ))
743
+ : null,
744
+ )
745
+ }
746
+
747
+ /** 控制器:scope → 暂存表单 → 设置页快照。保存落盘后同步本页悬浮按钮显隐。 */
748
+ class MementoCardController {
749
+ constructor(scope) {
750
+ this.scope = scope
751
+ this.form = new CardForm(scope, (tops) => {
752
+ const panel = tops instanceof Map ? tops.get('panel') : undefined
753
+ if (panel !== null && typeof panel === 'object' && 'enabled' in panel) {
754
+ setPanelButtonVisible(panel.enabled === true)
755
+ }
756
+ })
757
+ this.store = this.form.bind(() => this.projection())
758
+ }
759
+
760
+ projection() {
761
+ /** @type {Record<string, {text: string, overridden: boolean, invalid: boolean}>} */
762
+ const fields = {}
763
+ for (const spec of FIELD_SPECS) fields[spec.path] = this.form.fieldState(spec.path)
764
+ return { ...this.form.shell(), fields, base: this.scope.getSnapshot().base }
765
+ }
766
+
767
+ inject() {
768
+ return {
769
+ hooks: { mementoCard: this.store },
770
+ edit: (path, text) => this.form.stage(path, text),
771
+ discard: () => this.form.discard(),
772
+ save: () => { void this.form.save() },
773
+ }
774
+ }
775
+ }
776
+
777
+ function apply(ctx) {
778
+ void bootPanel()
779
+ if (!styleInstalled && typeof document !== 'undefined') {
780
+ styleInstalled = true
781
+ const tag = document.createElement('style')
782
+ tag.dataset.plugin = 'dsh-memento'
783
+ tag.textContent = CARD_CSS
784
+ document.head.appendChild(tag)
785
+ }
786
+ const controller = new MementoCardController(ctx.settingsScope.bind({ namespace: 'dsh-memento' }))
787
+ ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({
788
+ name: 'settings.section',
789
+ id: 'dsh-memento',
790
+ order: 16,
791
+ label: 'dsh-memento',
792
+ inject: () => controller.inject(),
793
+ }, MementoSection)), 'dsh-memento: settings section')
794
+ }
795
+
796
+ return {
797
+ name: 'memento-client',
798
+ inject: ['slots', 'settingsScope'],
799
+ apply,
800
+ }
801
+ },
802
+ })
803
+ })()