@modusensus/dsh-mneme 0.7.2 → 0.7.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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-770%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-782%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/modusensus/dsh-mneme/test.yml)](https://github.com/modusensus/dsh-mneme/actions)
10
10
  [![node](https://img.shields.io/badge/node-24%2B-blue)](https://nodejs.org)
11
11
  [![npm downloads](https://img.shields.io/npm/dm/@modusensus/dsh-mneme?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -241,6 +241,8 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
241
241
 
242
242
  | 版本 | 亮点 |
243
243
  |------|------|
244
+ | **v0.7.4** | issue #40 修复:记忆内容含 `{{...}}` 模板语法时整轮崩溃(注入边界 run-based 花括号转义 `{{a}}`→`{\{a\}\}`,奇数连续如 `{{{a}}}` 也不残留字面 `{{`;新增 `escapePromptVariables` 配置默认开);issue #41 修复:记忆窗口关闭按钮与宿主窗口控制按钮重叠无法点击(顶栏左对齐,关闭按钮离开右上角宿主控制区);782 测试全绿 |
245
+ | **v0.7.3** | issue #38 新功能:左下角入口按钮可选开关 `showSidebarTrigger`(默认开)——与 dsh-cost-meter 等抢占 footer slot 的插件冲突时可在 Web 面板「设置」一键关闭,仅隐藏按钮、记忆库标签不受影响;776 测试全绿 |
244
246
  | **v0.7.2** | issue #35 修复:目录页删除按钮改面板内联两步确认(不再依赖宿主 `window.confirm`)+ 删除失败可见报错;issue #34 新功能:opt-in `injectTimePrefix` 对话开始自动注入当前时间一次(默认关);770 测试全绿 |
245
247
  | **v0.7.1** | issue #31 修复:memory_save/memory_update 的 tags 桥接进 entity_attrs 标签存储(目录/`tag:` 检索/tagBoost 立即可见,`tags: []` 清空移回 untagged)+ `store.setMemoryTags` 反向同步 `memories.tags` 列 + autoTag 面板开关成为运行时消费方(settings 覆盖 config);764 测试全绿 |
246
248
  | **v0.7.0** | 自进化记忆(heat 热度模型 + per-type 差异化半衰期 + sleep 热联合双保护)+ updated_at 语义修正(不算访问)+ recall_runs injected 两档标记 + 90 天滚动清理 + 实体热投影(ego-graph node heat → 前端节点大小/明暗);757 测试全绿 |
@@ -289,6 +291,8 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
289
291
  | **v0.7.0** | ✅ 完成 | 自进化记忆 | heat 幂律衰减 + per-type 差异化半衰期(TYPE_DECAY)+ sleep 热联合双保护 + updated_at 语义修正 + recall_runs injected 两档标记 + 90 天清理 + 实体热投影(前端节点大小/明暗);757 测试全绿 |
290
292
  | **v0.7.1** | ✅ 完成 | issue #31 修复 | memory_save/update tags 桥接 entity_attrs 标签存储 + 列反向同步 + autoTag 面板开关生效(settings 覆盖 config);764 测试全绿 |
291
293
  | **v0.7.2** | ✅ 完成 | issue #34 + #35 修复 | 目录页删除按钮改内联两步确认 + 删除失败可见报错;opt-in `injectTimePrefix` 对话开始注入当前时间一次(默认关);770 测试全绿 |
294
+ | **v0.7.3** | ✅ 完成 | issue #38 新功能 | 左下角入口按钮可选开关 `showSidebarTrigger`(默认开,settings-over-config);Web 面板设置一键关闭,与 dsh-cost-meter 等 footer 插件冲突可隐藏按钮、记忆库标签不受影响;776 测试全绿 |
295
+ | **v0.7.4** | ✅ 完成 | issue #40 + #41 修复 | 注入边界 run-based 花括号转义(`{{a}}`→`{\{a\}\}`、奇数连续如 `{{{a}}}` 不残留字面,`escapePromptVariables` 默认开)+ 记忆窗口顶栏左对齐、关闭按钮避开宿主窗口控制按钮区;782 测试全绿 |
292
296
  | **v0.8.0** | 🚧 计划中(9 月末) | 图谱增强 | 兴趣漂移可视化 + 跨 workspace 记忆共享 + 更多 heat 信号 |
293
297
 
294
298
  > 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
@@ -409,6 +413,7 @@ dsh web
409
413
  | `memoryQualityFilter` | `{enabled:true, archiveThreshold:30, degradeThreshold:60, minContentLength:10}` | 记忆质量过滤(v0.4.6,默认开):写库前启发式打分 0-100,元记忆词汇/自指/过短/重复/近似重复扣分;≥60 正常存储,30-60 降权(注入排序按 importance×quality/100),<30 归档标记 `low_quality`(显式搜索仍可召回,永不自动注入) |
410
414
  | `llmAudit` | `{enabled:true, retentionDays:90}` | LLM 消耗审计(v0.4.6,默认开):每次后台 LLM 调用(autoDream/autoSummarize)写 `llm_audit_logs`(tokens/duration/status/source);失败记 error 不阻塞;只读 API `/api/dsh-mneme/semantic/llm-audit` + `/llm-audit/stats` |
411
415
  | `sessionLifecycleEnabled` | `false` | 会话生命周期(v0.6.0,默认关):开启后会话被删除/销毁时自动把该会话出生的记忆软隐藏(`session_disposed_at`,与 `archived` 正交、可恢复);存量无 `session_id` 的记忆永不参与清理 |
416
+ | `showSidebarTrigger` | `true` | 侧边栏底部(左下角)记忆入口按钮(issue #38,默认开):与其他插件(如 dsh-cost-meter)抢占同一 footer slot 导致 UI 冲突时可关掉;仅隐藏按钮,记忆库仍可通过顶部「记忆库」标签访问。Web 面板「设置」里有对应开关 |
412
417
 
413
418
  > 🔐 **API 安全**:DSH 无内置鉴权且默认仅监听 `127.0.0.1`。插件 API 默认开放(便于 Web 面板即装即用)。如需防护(如局域网暴露),在配置中设置 `apiToken`:写操作(画像/规则/命令)与密钥端点(`vector-config`、`vector-reindex`)需携带 `Authorization: Bearer <token>`(前端设置面板可填入同一 token),只读的 `list` / `search` / `semantic` 保持开放。`/api/dsh-mneme/vector-config` 返回的 `apiKey` 已掩码(`sk-***…`),存储仍保留明文供调用;前端回传空或掩码值表示"不改 key"。
414
419
 
@@ -463,7 +468,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
463
468
  ```bash
464
469
  cd dsh-mneme
465
470
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
466
- npm test # 运行 770 个测试
471
+ npm test # 运行 782 个测试
467
472
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
468
473
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
469
474
  ```
package/lib/api.js CHANGED
@@ -238,19 +238,22 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
238
238
  }
239
239
  return true;
240
240
  };
241
- if (!setIfBoolean("autoTagEnabled") || !setIfBoolean("manualTagEnabled")) return;
241
+ if (!setIfBoolean("autoTagEnabled") || !setIfBoolean("manualTagEnabled") || !setIfBoolean("showSidebarTrigger")) return;
242
242
  if (Object.keys(patch).length === 0) {
243
243
  sendJson(res, 400, { error: "no valid config field provided" });
244
244
  return;
245
245
  }
246
+ // Each setter ignores the keys it doesn't own, so one patch can feed
247
+ // both stores safely (tag toggles → "autoTag", UI prefs → "ui").
246
248
  settings.setAutoTagConfig(patch);
249
+ settings.setUiConfig(patch);
247
250
  // Respond with the effective (settings-over-config merged) toggles
248
251
  // so the panel always sees booleans that actually gate runtime
249
252
  // behaviour (issue #31).
250
- sendJson(res, 200, { config: service.getTagConfig() });
253
+ sendJson(res, 200, { config: service.getAppConfig() });
251
254
  return;
252
255
  }
253
- sendJson(res, 200, { config: service.getTagConfig() });
256
+ sendJson(res, 200, { config: service.getAppConfig() });
254
257
  } catch {
255
258
  sendJson(res, 500, { error: "internal" });
256
259
  }
package/lib/client.js CHANGED
@@ -77,6 +77,11 @@ window.__ModuleLoader__.load({
77
77
  "memory.settings.autoTagEnabled": "自动打标签",
78
78
  "memory.settings.autoTagSave": "保存",
79
79
  "memory.settings.autoTagSaved": "已保存",
80
+ "memory.settings.sidebarTriggerTitle": "侧边栏入口按钮",
81
+ "memory.settings.sidebarTriggerHint": "关闭后隐藏侧边栏底部(左下角)的记忆入口按钮。与其他插件 UI 冲突时建议关闭;记忆库仍可通过顶部「记忆库」标签访问",
82
+ "memory.settings.sidebarTriggerEnabled": "显示左下角入口按钮",
83
+ "memory.settings.sidebarTriggerSave": "保存",
84
+ "memory.settings.sidebarTriggerSaved": "已保存",
80
85
  "memory.settings.ruleAdd": "添加规则",
81
86
  "memory.settings.rulePlaceholder": "例如:回答时总是先给结论",
82
87
  "memory.settings.commands": "自定义指令",
@@ -186,6 +191,11 @@ window.__ModuleLoader__.load({
186
191
  "memory.settings.autoTagEnabled": "Auto-tagging",
187
192
  "memory.settings.autoTagSave": "Save",
188
193
  "memory.settings.autoTagSaved": "Saved",
194
+ "memory.settings.sidebarTriggerTitle": "Sidebar trigger",
195
+ "memory.settings.sidebarTriggerHint": "Hide the memory entrance button at the sidebar footer (bottom-left) when it clashes with other plugins. The library stays reachable via the 记忆库 conversation tab",
196
+ "memory.settings.sidebarTriggerEnabled": "Show the bottom-left trigger",
197
+ "memory.settings.sidebarTriggerSave": "Save",
198
+ "memory.settings.sidebarTriggerSaved": "Saved",
189
199
  "memory.settings.ruleAdd": "Add Rule",
190
200
  "memory.settings.rulePlaceholder": "e.g. Always lead with a conclusion",
191
201
  "memory.settings.commands": "Custom Commands",
@@ -462,7 +472,11 @@ window.__ModuleLoader__.load({
462
472
  // there. This surface reuses the exact MemoryExplorer UI at full size —
463
473
  // not a side drawer — so the library stays reachable from any state.
464
474
  ".mneme-overlay{position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1);animation:mneme-fadein .12s ease-out}",
465
- ".mneme-overlaybar{flex:none;display:flex;align-items:center;justify-content:space-between;height:44px;padding:0 12px 0 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
475
+ // issue #41: overlay 顶栏右侧原本与宿主窗口标题栏的控制按钮(最小化/最大化/关闭)
476
+ // 落在同一区域——宿主控制按钮浮在 web 内容最上层,抢占了 overlay"关闭"按钮的
477
+ // 点击热区,导致记忆窗口无法关闭。改为左对齐(标题 + 关闭按钮并排),关闭按钮
478
+ // 离开右上角宿主控制按钮区,任何窗口尺寸/宿主下都可见可点。
479
+ ".mneme-overlaybar{flex:none;display:flex;align-items:center;justify-content:flex-start;gap:12px;height:44px;padding:0 12px 0 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
466
480
  ".mneme-overlaytitle{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}",
467
481
  ".mneme-overlaybody{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}",
468
482
  "@keyframes mneme-fadein{from{opacity:0}to{opacity:1}}"
@@ -874,6 +888,8 @@ window.__ModuleLoader__.load({
874
888
  const [apiTokenSaved, setApiTokenSaved] = react.useState(false);
875
889
  const [autoTag, setAutoTag] = react.useState(false);
876
890
  const [autoTagSaved, setAutoTagSaved] = react.useState(false);
891
+ const [showSidebarTrigger, setShowSidebarTrigger] = react.useState(true);
892
+ const [sidebarTriggerSaved, setSidebarTriggerSaved] = react.useState(false);
877
893
 
878
894
  const load = react.useCallback(async () => {
879
895
  try {
@@ -891,7 +907,9 @@ window.__ModuleLoader__.load({
891
907
  try {
892
908
  const g = await apiFetch("/api/dsh-mneme/config").then((res) => res.json());
893
909
  setAutoTag(g.config?.autoTagEnabled === true);
894
- } catch { /* keep default off */ }
910
+ // issue #38: sidebar trigger defaults to visible (true).
911
+ setShowSidebarTrigger(g.config?.showSidebarTrigger !== false);
912
+ } catch { /* keep defaults */ }
895
913
  } catch { /* ignore */ }
896
914
  }, []);
897
915
 
@@ -1000,6 +1018,18 @@ window.__ModuleLoader__.load({
1000
1018
  } catch { /* ignore */ }
1001
1019
  }
1002
1020
 
1021
+ async function saveSidebarTrigger() {
1022
+ try {
1023
+ await apiFetch("/api/dsh-mneme/config", {
1024
+ method: "PUT",
1025
+ headers: { "Content-Type": "application/json" },
1026
+ body: JSON.stringify({ showSidebarTrigger })
1027
+ });
1028
+ setSidebarTriggerSaved(true);
1029
+ setTimeout(() => setSidebarTriggerSaved(false), 1500);
1030
+ } catch { /* ignore */ }
1031
+ }
1032
+
1003
1033
  const inputStyle = { boxSizing: "border-box", width: "100%", height: 34, padding: "0 12px", borderRadius: 10, border: "1px solid var(--dsw-alias-border-l2, #ddd)", background: "var(--dsw-alias-bg-base, transparent)", color: "var(--dsw-alias-label-primary)", fontFamily: "inherit", fontSize: 13, outline: "none", marginBottom: 8 };
1004
1034
  const labelStyle = { fontSize: 12, fontWeight: 600, margin: "12px 0 4px", color: "var(--dsw-alias-label-primary)" };
1005
1035
  const hintStyle = { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)", marginBottom: 8 };
@@ -1077,6 +1107,17 @@ window.__ModuleLoader__.load({
1077
1107
  h("button", { style: styles.footerButton, onClick: saveAutoTag }, t("memory.settings.autoTagSave")),
1078
1108
  autoTagSaved && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.autoTagSaved"))
1079
1109
  ),
1110
+ // sidebar trigger switch (issue #38)
1111
+ h("div", { style: { ...labelStyle, marginTop: 20 } }, t("memory.settings.sidebarTriggerTitle")),
1112
+ h("div", { style: hintStyle }, t("memory.settings.sidebarTriggerHint")),
1113
+ h("label", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 8, fontSize: 13 } },
1114
+ h("input", { type: "checkbox", checked: showSidebarTrigger, onChange: (e) => setShowSidebarTrigger(e.target.checked) }),
1115
+ h("span", null, t("memory.settings.sidebarTriggerEnabled"))
1116
+ ),
1117
+ h("div", { style: { display: "flex", alignItems: "center", gap: 6 } },
1118
+ h("button", { style: styles.footerButton, onClick: saveSidebarTrigger }, t("memory.settings.sidebarTriggerSave")),
1119
+ sidebarTriggerSaved && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.sidebarTriggerSaved"))
1120
+ ),
1080
1121
  // vector search
1081
1122
  h("div", { style: { ...labelStyle, marginTop: 20 } }, t("memory.settings.vectorTitle")),
1082
1123
  h("div", { style: hintStyle }, t("memory.settings.vectorHint")),
@@ -1749,7 +1790,22 @@ window.__ModuleLoader__.load({
1749
1790
  // opens the full-viewport overlay instead — the library stays reachable
1750
1791
  // from every conversation state.
1751
1792
  function SidebarTrigger({ wide, t }) {
1793
+ // issue #38: the entrance button is now optional (settings-over-config
1794
+ // toggle, persisted via /api/dsh-mneme/config). While the value loads we
1795
+ // render the button (default = visible) so it never flashes out; if the
1796
+ // user disabled it, it drops away after the config resolves. The library
1797
+ // itself is unaffected — the 记忆库 conversation tab stays.
1798
+ const [visible, setVisible] = useState(true);
1799
+ useEffect(() => {
1800
+ let cancelled = false;
1801
+ apiFetch("/api/dsh-mneme/config")
1802
+ .then((res) => res.json())
1803
+ .then((d) => { if (!cancelled) setVisible(d.config?.showSidebarTrigger !== false); })
1804
+ .catch(() => { if (!cancelled) setVisible(true); });
1805
+ return () => { cancelled = true; };
1806
+ }, []);
1752
1807
  const [, setOpen] = useOverlayOpen();
1808
+ if (!visible) return null;
1753
1809
  return h("button", {
1754
1810
  type: "button",
1755
1811
  className: wide ? "mneme-trigger" : "mneme-trigger mneme-rail",
package/lib/config.js CHANGED
@@ -22,6 +22,13 @@ export const Config = z.object({
22
22
  // "现在几点/周几"。只注入一次(对话开始时),同会话后续渲染不再重复;
23
23
  // 关闭时行为与之前完全一致。
24
24
  injectTimePrefix: z.boolean().default(false),
25
+ // 花括号转义(issue #40,默认开):DSH 核心 interpolate() 会把 `{{name}}`
26
+ // 当 prompt 变量做严格校验(变量名须匹配 /^[a-z][a-z0-9_]*$/),记忆正文里
27
+ // 合法的模板语法(如 `{{hl|}}`、`{{挖空}}`、`{{关键词}}`)会因非法变量名
28
+ // 直接 throw、让整轮对话崩溃。开启后注入边界把 `{{`→`\{\{`、`}}`→`\}\}`,
29
+ // 文本不再含 `{{`/`}}` 子串,interpolate 不再扫描到,内容保持可读且幂等
30
+ // (不会二次转义)。关闭后按原样透传。
31
+ escapePromptVariables: z.boolean().default(true),
25
32
  autoDream: z.boolean().default(true),
26
33
  dreamThresholdCount: z.natural().min(1).max(1000).default(10),
27
34
  dreamThresholdChars: z.natural().min(100).max(100000).default(5000),
@@ -203,6 +210,16 @@ export const Config = z.object({
203
210
  // set false to disable the manual write path too.
204
211
  manualTagEnabled: z.boolean().default(true),
205
212
 
213
+ // --- sidebar trigger (issue #38) ---------------------------------------
214
+ // The memory library is reachable from two UI surfaces: a "记忆库" tab in the
215
+ // conversation header AND an entrance button at the sidebar footer (bottom
216
+ // left). Some sidebars get crowded there — dsh-cost-meter and friends also
217
+ // claim the footer slot — so the entrance button is now optional. Off just
218
+ // hides the button; the conversation tab stays, so no functionality is lost.
219
+ // The Web panel exposes this as a toggle (settings-over-config, see
220
+ // settings.getUiConfig); the value here is the plugin-config default.
221
+ showSidebarTrigger: z.boolean().default(true),
222
+
206
223
  // --- tag-weighted re-rank (v0.6.4) -------------------------------------
207
224
  // Opt-in: boost candidates whose tags overlap the query/session tags.
208
225
  tagBoostEnabled: z.boolean().default(false),
package/lib/inject.js CHANGED
@@ -75,6 +75,23 @@ export function createInjector(ctx, service, settings, config) {
75
75
  const maxItems = config.maxInjectedItems ?? 5;
76
76
  const threshold = config.importanceThreshold ?? 3;
77
77
 
78
+ // Prompt-variable escaping (issue #40): DSH's interpolate() treats `{{name}}`
79
+ // as a prompt variable and strictly validates the name against
80
+ // /^[a-z][a-z0-9_]*$/ — memory/profile content carrying legal template syntax
81
+ // (Obsidian-style `{{hl|}}`, `{{挖空}}`, `{{关键词}}`) would hit an illegal
82
+ // variable name and throw, crashing the whole turn. At the injection boundary
83
+ // we escape every run of 2+ consecutive braces, inserting a `\` between each
84
+ // pair, so no `{{`/`}}` substring survives into the prompt: `{{a}}` → `{\{a\}\}`,
85
+ // and odd runs like `{{{a}}}` (which pair-wise escaping would leave with a
86
+ // literal `{{`) are handled too. The text keeps its readable template form,
87
+ // the transform is idempotent (escaped braces are single + `\`, never two
88
+ // adjacent), and single braces pass through untouched — interpolate only
89
+ // scans `{{`. Off via config.escapePromptVariables=false (default true).
90
+ function escapePromptVars(text) {
91
+ if (config.escapePromptVariables === false) return String(text);
92
+ return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
93
+ }
94
+
78
95
  // Time-prefix injection (issue #34): opt-in, off by default. When enabled the
79
96
  // current date/time is injected once per conversation — at the first prompt
80
97
  // assembly of a new session — so the model can sense what time/day it is.
@@ -134,7 +151,7 @@ export function createInjector(ctx, service, settings, config) {
134
151
  for (const r of rounds) hot.add(r);
135
152
  const body = hot.getContext();
136
153
  if (!body) return "";
137
- return `[短期上下文] 最近对话(共 ${rounds.length} 轮):\n${body}`;
154
+ return escapePromptVars(`[短期上下文] 最近对话(共 ${rounds.length} 轮):\n${body}`);
138
155
  }
139
156
 
140
157
  function render(candidates) {
@@ -158,7 +175,7 @@ export function createInjector(ctx, service, settings, config) {
158
175
  lines.push(`- [${m.type}] ${verified}${title}`);
159
176
  }
160
177
  }
161
- return lines.join("\n");
178
+ return escapePromptVars(lines.join("\n"));
162
179
  }
163
180
 
164
181
  // Bug4: the system-prompt render is synchronous, so the semantic query vector
@@ -192,7 +209,7 @@ export function createInjector(ctx, service, settings, config) {
192
209
  const lines = ["[用户设置] 来自 dsh-mneme 的用户画像与规则:"];
193
210
  if (profile) lines.push(`- 用户画像:${profile}`);
194
211
  for (const rule of rules) lines.push(`- 规则:${rule}`);
195
- return lines.join("\n");
212
+ return escapePromptVars(lines.join("\n"));
196
213
  }
197
214
 
198
215
  const disposers = [
package/lib/service.js CHANGED
@@ -105,6 +105,19 @@ export function createService({ store, mirror, config, onWrite, logger, settings
105
105
  };
106
106
  };
107
107
 
108
+ // App-wide effective config for the Web panel (/api/dsh-mneme/config): the
109
+ // tag toggles plus UI preferences (issue #38). Settings overrides plugin
110
+ // config when explicitly stored; unset keys fall back to the zod default.
111
+ const appConfig = () => {
112
+ const stored = settings?.getUiConfig?.() ?? {};
113
+ return {
114
+ ...tagConfig(),
115
+ showSidebarTrigger: typeof stored.showSidebarTrigger === "boolean"
116
+ ? stored.showSidebarTrigger
117
+ : (config.showSidebarTrigger ?? true)
118
+ };
119
+ };
120
+
108
121
  // Optional recall recorder, installed via setRecallRecorder after creation.
109
122
  // When searchMemories is called with recordRecall=true it receives the
110
123
  // actual merged recall scene (candidates + scores + source + threshold) so
@@ -1803,6 +1816,7 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1803
1816
  // Effective tag toggle pair (settings-over-config merge) for the panel
1804
1817
  // (/api/dsh-mneme/config GET/PUT).
1805
1818
  getTagConfig: tagConfig,
1819
+ getAppConfig: appConfig,
1806
1820
  applyMemoryTags: (memoryId, tags) => store.setMemoryTags(memoryId, tags),
1807
1821
  saveAttr: (r) => store.saveAttr(r),
1808
1822
  createEntity: (r) => store.createEntity(r),
package/lib/settings.js CHANGED
@@ -176,6 +176,37 @@ export function createSettings(db) {
176
176
  else if (cur.manualTagEnabled !== undefined) cfg.manualTagEnabled = cur.manualTagEnabled === true;
177
177
  setSetting("autoTag", JSON.stringify(cfg));
178
178
  return cfg;
179
+ },
180
+ /** UI preferences (issue #38): same settings-over-config pattern as the
181
+ * tag toggles. null = never explicitly stored → callers fall back to the
182
+ * plugin-config default, so a partial update can't invent a value for a
183
+ * key the user never touched. */
184
+ getUiConfig() {
185
+ const raw = getSetting("ui");
186
+ let stored = {};
187
+ if (raw) {
188
+ try {
189
+ const j = JSON.parse(raw);
190
+ if (j && typeof j === "object") stored = j;
191
+ } catch { /* fall through to defaults */ }
192
+ }
193
+ return {
194
+ showSidebarTrigger: typeof stored.showSidebarTrigger === "boolean" ? stored.showSidebarTrigger : null
195
+ };
196
+ },
197
+ setUiConfig(partial) {
198
+ const cur = (() => {
199
+ const raw = getSetting("ui");
200
+ try {
201
+ const j = JSON.parse(raw);
202
+ return j && typeof j === "object" ? j : {};
203
+ } catch { return {}; }
204
+ })();
205
+ const cfg = {};
206
+ if (partial.showSidebarTrigger !== undefined) cfg.showSidebarTrigger = partial.showSidebarTrigger === true;
207
+ else if (cur.showSidebarTrigger !== undefined) cfg.showSidebarTrigger = cur.showSidebarTrigger === true;
208
+ setSetting("ui", JSON.stringify(cfg));
209
+ return cfg;
179
210
  }
180
211
  };
181
212
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.7.2",
4
+ "version": "0.7.4",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -75,6 +75,9 @@
75
75
  "adm-zip": "0.6.0"
76
76
  },
77
77
  "c8": {
78
- "reporter": ["text", "lcov"]
78
+ "reporter": [
79
+ "text",
80
+ "lcov"
81
+ ]
79
82
  }
80
83
  }
package/src/api.js CHANGED
@@ -238,19 +238,22 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
238
238
  }
239
239
  return true;
240
240
  };
241
- if (!setIfBoolean("autoTagEnabled") || !setIfBoolean("manualTagEnabled")) return;
241
+ if (!setIfBoolean("autoTagEnabled") || !setIfBoolean("manualTagEnabled") || !setIfBoolean("showSidebarTrigger")) return;
242
242
  if (Object.keys(patch).length === 0) {
243
243
  sendJson(res, 400, { error: "no valid config field provided" });
244
244
  return;
245
245
  }
246
+ // Each setter ignores the keys it doesn't own, so one patch can feed
247
+ // both stores safely (tag toggles → "autoTag", UI prefs → "ui").
246
248
  settings.setAutoTagConfig(patch);
249
+ settings.setUiConfig(patch);
247
250
  // Respond with the effective (settings-over-config merged) toggles
248
251
  // so the panel always sees booleans that actually gate runtime
249
252
  // behaviour (issue #31).
250
- sendJson(res, 200, { config: service.getTagConfig() });
253
+ sendJson(res, 200, { config: service.getAppConfig() });
251
254
  return;
252
255
  }
253
- sendJson(res, 200, { config: service.getTagConfig() });
256
+ sendJson(res, 200, { config: service.getAppConfig() });
254
257
  } catch {
255
258
  sendJson(res, 500, { error: "internal" });
256
259
  }
package/src/config.js CHANGED
@@ -22,6 +22,13 @@ export const Config = z.object({
22
22
  // "现在几点/周几"。只注入一次(对话开始时),同会话后续渲染不再重复;
23
23
  // 关闭时行为与之前完全一致。
24
24
  injectTimePrefix: z.boolean().default(false),
25
+ // 花括号转义(issue #40,默认开):DSH 核心 interpolate() 会把 `{{name}}`
26
+ // 当 prompt 变量做严格校验(变量名须匹配 /^[a-z][a-z0-9_]*$/),记忆正文里
27
+ // 合法的模板语法(如 `{{hl|}}`、`{{挖空}}`、`{{关键词}}`)会因非法变量名
28
+ // 直接 throw、让整轮对话崩溃。开启后注入边界把 `{{`→`\{\{`、`}}`→`\}\}`,
29
+ // 文本不再含 `{{`/`}}` 子串,interpolate 不再扫描到,内容保持可读且幂等
30
+ // (不会二次转义)。关闭后按原样透传。
31
+ escapePromptVariables: z.boolean().default(true),
25
32
  autoDream: z.boolean().default(true),
26
33
  dreamThresholdCount: z.natural().min(1).max(1000).default(10),
27
34
  dreamThresholdChars: z.natural().min(100).max(100000).default(5000),
@@ -203,6 +210,16 @@ export const Config = z.object({
203
210
  // set false to disable the manual write path too.
204
211
  manualTagEnabled: z.boolean().default(true),
205
212
 
213
+ // --- sidebar trigger (issue #38) ---------------------------------------
214
+ // The memory library is reachable from two UI surfaces: a "记忆库" tab in the
215
+ // conversation header AND an entrance button at the sidebar footer (bottom
216
+ // left). Some sidebars get crowded there — dsh-cost-meter and friends also
217
+ // claim the footer slot — so the entrance button is now optional. Off just
218
+ // hides the button; the conversation tab stays, so no functionality is lost.
219
+ // The Web panel exposes this as a toggle (settings-over-config, see
220
+ // settings.getUiConfig); the value here is the plugin-config default.
221
+ showSidebarTrigger: z.boolean().default(true),
222
+
206
223
  // --- tag-weighted re-rank (v0.6.4) -------------------------------------
207
224
  // Opt-in: boost candidates whose tags overlap the query/session tags.
208
225
  tagBoostEnabled: z.boolean().default(false),
package/src/inject.js CHANGED
@@ -75,6 +75,23 @@ export function createInjector(ctx, service, settings, config) {
75
75
  const maxItems = config.maxInjectedItems ?? 5;
76
76
  const threshold = config.importanceThreshold ?? 3;
77
77
 
78
+ // Prompt-variable escaping (issue #40): DSH's interpolate() treats `{{name}}`
79
+ // as a prompt variable and strictly validates the name against
80
+ // /^[a-z][a-z0-9_]*$/ — memory/profile content carrying legal template syntax
81
+ // (Obsidian-style `{{hl|}}`, `{{挖空}}`, `{{关键词}}`) would hit an illegal
82
+ // variable name and throw, crashing the whole turn. At the injection boundary
83
+ // we escape every run of 2+ consecutive braces, inserting a `\` between each
84
+ // pair, so no `{{`/`}}` substring survives into the prompt: `{{a}}` → `{\{a\}\}`,
85
+ // and odd runs like `{{{a}}}` (which pair-wise escaping would leave with a
86
+ // literal `{{`) are handled too. The text keeps its readable template form,
87
+ // the transform is idempotent (escaped braces are single + `\`, never two
88
+ // adjacent), and single braces pass through untouched — interpolate only
89
+ // scans `{{`. Off via config.escapePromptVariables=false (default true).
90
+ function escapePromptVars(text) {
91
+ if (config.escapePromptVariables === false) return String(text);
92
+ return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
93
+ }
94
+
78
95
  // Time-prefix injection (issue #34): opt-in, off by default. When enabled the
79
96
  // current date/time is injected once per conversation — at the first prompt
80
97
  // assembly of a new session — so the model can sense what time/day it is.
@@ -134,7 +151,7 @@ export function createInjector(ctx, service, settings, config) {
134
151
  for (const r of rounds) hot.add(r);
135
152
  const body = hot.getContext();
136
153
  if (!body) return "";
137
- return `[短期上下文] 最近对话(共 ${rounds.length} 轮):\n${body}`;
154
+ return escapePromptVars(`[短期上下文] 最近对话(共 ${rounds.length} 轮):\n${body}`);
138
155
  }
139
156
 
140
157
  function render(candidates) {
@@ -158,7 +175,7 @@ export function createInjector(ctx, service, settings, config) {
158
175
  lines.push(`- [${m.type}] ${verified}${title}`);
159
176
  }
160
177
  }
161
- return lines.join("\n");
178
+ return escapePromptVars(lines.join("\n"));
162
179
  }
163
180
 
164
181
  // Bug4: the system-prompt render is synchronous, so the semantic query vector
@@ -192,7 +209,7 @@ export function createInjector(ctx, service, settings, config) {
192
209
  const lines = ["[用户设置] 来自 dsh-mneme 的用户画像与规则:"];
193
210
  if (profile) lines.push(`- 用户画像:${profile}`);
194
211
  for (const rule of rules) lines.push(`- 规则:${rule}`);
195
- return lines.join("\n");
212
+ return escapePromptVars(lines.join("\n"));
196
213
  }
197
214
 
198
215
  const disposers = [
package/src/service.js CHANGED
@@ -105,6 +105,19 @@ export function createService({ store, mirror, config, onWrite, logger, settings
105
105
  };
106
106
  };
107
107
 
108
+ // App-wide effective config for the Web panel (/api/dsh-mneme/config): the
109
+ // tag toggles plus UI preferences (issue #38). Settings overrides plugin
110
+ // config when explicitly stored; unset keys fall back to the zod default.
111
+ const appConfig = () => {
112
+ const stored = settings?.getUiConfig?.() ?? {};
113
+ return {
114
+ ...tagConfig(),
115
+ showSidebarTrigger: typeof stored.showSidebarTrigger === "boolean"
116
+ ? stored.showSidebarTrigger
117
+ : (config.showSidebarTrigger ?? true)
118
+ };
119
+ };
120
+
108
121
  // Optional recall recorder, installed via setRecallRecorder after creation.
109
122
  // When searchMemories is called with recordRecall=true it receives the
110
123
  // actual merged recall scene (candidates + scores + source + threshold) so
@@ -1803,6 +1816,7 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1803
1816
  // Effective tag toggle pair (settings-over-config merge) for the panel
1804
1817
  // (/api/dsh-mneme/config GET/PUT).
1805
1818
  getTagConfig: tagConfig,
1819
+ getAppConfig: appConfig,
1806
1820
  applyMemoryTags: (memoryId, tags) => store.setMemoryTags(memoryId, tags),
1807
1821
  saveAttr: (r) => store.saveAttr(r),
1808
1822
  createEntity: (r) => store.createEntity(r),
package/src/settings.js CHANGED
@@ -176,6 +176,37 @@ export function createSettings(db) {
176
176
  else if (cur.manualTagEnabled !== undefined) cfg.manualTagEnabled = cur.manualTagEnabled === true;
177
177
  setSetting("autoTag", JSON.stringify(cfg));
178
178
  return cfg;
179
+ },
180
+ /** UI preferences (issue #38): same settings-over-config pattern as the
181
+ * tag toggles. null = never explicitly stored → callers fall back to the
182
+ * plugin-config default, so a partial update can't invent a value for a
183
+ * key the user never touched. */
184
+ getUiConfig() {
185
+ const raw = getSetting("ui");
186
+ let stored = {};
187
+ if (raw) {
188
+ try {
189
+ const j = JSON.parse(raw);
190
+ if (j && typeof j === "object") stored = j;
191
+ } catch { /* fall through to defaults */ }
192
+ }
193
+ return {
194
+ showSidebarTrigger: typeof stored.showSidebarTrigger === "boolean" ? stored.showSidebarTrigger : null
195
+ };
196
+ },
197
+ setUiConfig(partial) {
198
+ const cur = (() => {
199
+ const raw = getSetting("ui");
200
+ try {
201
+ const j = JSON.parse(raw);
202
+ return j && typeof j === "object" ? j : {};
203
+ } catch { return {}; }
204
+ })();
205
+ const cfg = {};
206
+ if (partial.showSidebarTrigger !== undefined) cfg.showSidebarTrigger = partial.showSidebarTrigger === true;
207
+ else if (cur.showSidebarTrigger !== undefined) cfg.showSidebarTrigger = cur.showSidebarTrigger === true;
208
+ setSetting("ui", JSON.stringify(cfg));
209
+ return cfg;
179
210
  }
180
211
  };
181
212
  }
package/test/api.test.js CHANGED
@@ -557,12 +557,26 @@ test("GET/PUT /api/dsh-mneme/config round-trips autoTag switches", async () => {
557
557
  await route.handler(req("/api/dsh-mneme/config"), res1);
558
558
  assert.equal(res1.statusCode, 200);
559
559
  // unset → effective config falls back to plugin-config defaults:
560
- // autoTagEnabled=false, manualTagEnabled=true (issue #31 default unification)
561
- assert.deepEqual(JSON.parse(res1.body).config, { autoTagEnabled: false, manualTagEnabled: true });
560
+ // autoTagEnabled=false, manualTagEnabled=true (issue #31 default unification),
561
+ // showSidebarTrigger=true (issue #38 default)
562
+ assert.deepEqual(JSON.parse(res1.body).config, { autoTagEnabled: false, manualTagEnabled: true, showSidebarTrigger: true });
562
563
  const res2 = new FakeRes();
563
564
  await route.handler(req("/api/dsh-mneme/config", "PUT", { autoTagEnabled: true, manualTagEnabled: false }), res2);
564
565
  assert.equal(res2.statusCode, 200);
565
- assert.deepEqual(JSON.parse(res2.body).config, { autoTagEnabled: true, manualTagEnabled: false });
566
+ assert.deepEqual(JSON.parse(res2.body).config, { autoTagEnabled: true, manualTagEnabled: false, showSidebarTrigger: true });
567
+ });
568
+
569
+ test("GET/PUT /api/dsh-mneme/config round-trips the sidebar trigger toggle (issue #38)", async () => {
570
+ const { routes } = setup();
571
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/config");
572
+ const res = new FakeRes();
573
+ await route.handler(req("/api/dsh-mneme/config", "PUT", { showSidebarTrigger: false }), res);
574
+ assert.equal(res.statusCode, 200);
575
+ // Toggling the UI pref must not disturb the tag toggles (settings-over-config).
576
+ assert.deepEqual(JSON.parse(res.body).config, { autoTagEnabled: false, manualTagEnabled: true, showSidebarTrigger: false });
577
+ const res2 = new FakeRes();
578
+ await route.handler(req("/api/dsh-mneme/config"), res2);
579
+ assert.deepEqual(JSON.parse(res2.body).config, { autoTagEnabled: false, manualTagEnabled: true, showSidebarTrigger: false });
566
580
  });
567
581
 
568
582
  test("DELETE /api/dsh-mneme/memories removes by id and reports misses", async () => {
@@ -597,5 +611,14 @@ test("PUT /api/dsh-mneme/config supports partial update", async () => {
597
611
  // Partial PUT only stores autoTagEnabled; manualTagEnabled stays unset and
598
612
  // falls back to the plugin-config default (true) — a lone autoTag toggle must
599
613
  // never silently disable manual tagging (issue #31).
600
- assert.deepEqual(JSON.parse(res.body).config, { autoTagEnabled: true, manualTagEnabled: true });
614
+ assert.deepEqual(JSON.parse(res.body).config, { autoTagEnabled: true, manualTagEnabled: true, showSidebarTrigger: true });
615
+ });
616
+
617
+ test("PUT /api/dsh-mneme/config rejects non-boolean showSidebarTrigger", async () => {
618
+ const { routes } = setup();
619
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/config");
620
+ const res = new FakeRes();
621
+ await route.handler(req("/api/dsh-mneme/config", "PUT", { showSidebarTrigger: "no" }), res);
622
+ assert.equal(res.statusCode, 400);
623
+ assert.match(JSON.parse(res.body).error, /showSidebarTrigger/);
601
624
  });
@@ -403,3 +403,23 @@ test("delete failure surfaces visible error feedback", () => {
403
403
  "a failed row must get a visible error style"
404
404
  );
405
405
  });
406
+
407
+ // issue #38: the sidebar foot trigger must be disableable so it stops colliding
408
+ // with other plugins that also claim the bottom-left footer slot (dsh-cost-meter
409
+ // et al). The client reads the settings-over-config toggle from the /config
410
+ // endpoint and renders no button when it is off; the 记忆库 conversation tab is
411
+ // unaffected, so nothing is lost by hiding the entry.
412
+ test("sidebar trigger is hidden when showSidebarTrigger is false (issue #38)", () => {
413
+ assert.ok(
414
+ clientSource.includes('apiFetch("/api/dsh-mneme/config")'),
415
+ "trigger must read the effective config from /api/dsh-mneme/config"
416
+ );
417
+ assert.ok(
418
+ /d\.config\?\.showSidebarTrigger !== false/.test(clientSource),
419
+ "trigger must hide when showSidebarTrigger is explicitly false and stay visible by default"
420
+ );
421
+ assert.ok(
422
+ clientSource.includes('"memory.settings.sidebarTriggerTitle"'),
423
+ "settings panel must expose a sidebar-trigger toggle"
424
+ );
425
+ });
@@ -55,3 +55,17 @@ test("startup probe: the reranker module never statically imports transformers/o
55
55
  );
56
56
  assert.match(src, /await import\("@huggingface\/transformers"\)/, "transformers.js loads lazily via dynamic import");
57
57
  });
58
+
59
+ test("showSidebarTrigger is on by default, disabled explicitly (issue #38)", () => {
60
+ const cfg = Config({});
61
+ assert.equal(cfg.showSidebarTrigger, true, "sidebar trigger defaults to visible (behavior unchanged)");
62
+ const off = Config({ showSidebarTrigger: false });
63
+ assert.equal(off.showSidebarTrigger, false, "explicit false hides the trigger");
64
+ });
65
+
66
+ test("escapePromptVariables is on by default, disabled explicitly (issue #40)", () => {
67
+ const cfg = Config({});
68
+ assert.equal(cfg.escapePromptVariables, true, "{{...}} escaping defaults to on (protects interpolate)");
69
+ const off = Config({ escapePromptVariables: false });
70
+ assert.equal(off.escapePromptVariables, false, "explicit false passes {{...}} through verbatim");
71
+ });
@@ -129,3 +129,60 @@ test("injectTimePrefix: same session never re-injects, a new session injects aga
129
129
  const third = contexts[0].text({ agent: { session: { id: "s2" } } });
130
130
  assert.match(third, /^\[当前时间:/, "a new session injects the prefix again");
131
131
  });
132
+
133
+ test("issue #40: {{...}} in memory content is escaped at the injection boundary", () => {
134
+ const { contexts, service } = setup();
135
+ service.saveWithDedupe({ type: "preference", title: "模板记忆", content: "{{hl|highlight}} 和 {{挖空}} {{关键词}}", importance: 5 });
136
+ const text = contexts[0].text({});
137
+ assert.ok(text.includes("模板记忆"), "memory still rendered by title");
138
+ assert.ok(text.includes("{\\{hl|highlight}\\}"), "ASCII template braces escaped");
139
+ assert.ok(text.includes("{\\{挖空}\\}"), "CJK template braces escaped");
140
+ assert.ok(!text.includes("{{"), "no raw {{ survives into the prompt");
141
+ assert.ok(!text.includes("}}"), "no raw }} survives into the prompt");
142
+ });
143
+
144
+ test("issue #40: odd brace runs like {{{a}}} are escaped too", () => {
145
+ const { contexts, service } = setup();
146
+ service.saveWithDedupe({ type: "preference", title: "三层模板", content: "{{{a}}} 和 }}} 结尾", importance: 5 });
147
+ const text = contexts[0].text({});
148
+ assert.ok(!text.includes("{{"), "no raw {{ for odd brace runs");
149
+ assert.ok(!text.includes("}}"), "no raw }} for odd brace runs");
150
+ });
151
+
152
+ test("issue #40: {{...}} in user profile/rules is escaped", () => {
153
+ const { contexts, settings } = setup();
154
+ settings.setProfile("我叫{{名字}},前端开发者");
155
+ settings.setRules(["回答时用 {{hl|term}}", "以 }} 结尾的规则"]);
156
+ const settingsCtx = contexts.find((c) => c.name === "user-settings");
157
+ const text = settingsCtx.text({});
158
+ assert.ok(text.includes("{\\{名字}\\}"), "profile braces escaped");
159
+ assert.ok(text.includes("{\\{hl|term}\\}"), "rule braces escaped");
160
+ assert.ok(!text.includes("{{"), "no raw {{ in user-settings block");
161
+ assert.ok(!text.includes("}}"), "no raw }} in user-settings block");
162
+ });
163
+
164
+ test("issue #40: hot-context rounds with {{...}} are escaped", () => {
165
+ const { contexts } = setup();
166
+ const text = contexts[0].text({
167
+ agent: {
168
+ session: {
169
+ id: "s1",
170
+ events: [
171
+ { type: "user/message", data: { source: { kind: "user" }, content: ["用 {{hl|你好}} 测试"] } },
172
+ { type: "assistant/message", data: { source: { kind: "assistant" }, content: ["返回 {{答案}}"] } }
173
+ ]
174
+ }
175
+ }
176
+ });
177
+ assert.ok(text.includes("[短期上下文]"), "hot context rendered");
178
+ assert.ok(!text.includes("{{"), "no raw {{ in hot context");
179
+ assert.ok(!text.includes("}}"), "no raw }} in hot context");
180
+ });
181
+
182
+ test("issue #40: escapePromptVariables=false passes {{...}} through verbatim", () => {
183
+ const { contexts, service } = setup({ escapePromptVariables: false });
184
+ service.saveWithDedupe({ type: "preference", title: "模板", content: "{{挖空}} 与 {{hl|term}}", importance: 5 });
185
+ const text = contexts[0].text({});
186
+ assert.ok(text.includes("{{挖空}}"), "raw braces preserved when escaping disabled");
187
+ assert.ok(text.includes("{{hl|term}}"), "raw ASCII braces preserved when escaping disabled");
188
+ });
@@ -120,3 +120,25 @@ test("autoTag config coerces non-boolean to false", () => {
120
120
  assert.deepEqual(settings.getAutoTagConfig(), { autoTagEnabled: false, manualTagEnabled: false });
121
121
  store.close();
122
122
  });
123
+
124
+ test("ui config (sidebar trigger) defaults to null and round-trips only explicit keys", () => {
125
+ const { store, settings } = setup();
126
+ // Issue #38: same settings-over-config pattern as the tag toggles — an unset
127
+ // key is null so consumers fall back to the plugin-config default (visible).
128
+ assert.deepEqual(settings.getUiConfig(), { showSidebarTrigger: null });
129
+ settings.setUiConfig({ showSidebarTrigger: false });
130
+ assert.deepEqual(settings.getUiConfig(), { showSidebarTrigger: false });
131
+ settings.setUiConfig({ showSidebarTrigger: true });
132
+ assert.deepEqual(settings.getUiConfig(), { showSidebarTrigger: true });
133
+ // A partial update with no showSidebarTrigger keeps the stored value.
134
+ settings.setUiConfig({});
135
+ assert.deepEqual(settings.getUiConfig(), { showSidebarTrigger: true });
136
+ store.close();
137
+ });
138
+
139
+ test("ui config coerces non-boolean to false", () => {
140
+ const { store, settings } = setup();
141
+ settings.setUiConfig({ showSidebarTrigger: "yes" });
142
+ assert.deepEqual(settings.getUiConfig(), { showSidebarTrigger: false });
143
+ store.close();
144
+ });