@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 +7 -2
- package/lib/api.js +6 -3
- package/lib/client.js +58 -2
- package/lib/config.js +17 -0
- package/lib/inject.js +20 -3
- package/lib/service.js +14 -0
- package/lib/settings.js +31 -0
- package/package.json +5 -2
- package/src/api.js +6 -3
- package/src/config.js +17 -0
- package/src/inject.js +20 -3
- package/src/service.js +14 -0
- package/src/settings.js +31 -0
- package/test/api.test.js +27 -4
- package/test/client.test.js +20 -0
- package/test/config.test.js +14 -0
- package/test/inject.test.js +57 -0
- package/test/settings.test.js +22 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](https://github.com/modusensus/dsh-mneme)
|
|
9
9
|
[](https://github.com/modusensus/dsh-mneme/actions)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
[](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 # 运行
|
|
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.
|
|
253
|
+
sendJson(res, 200, { config: service.getAppConfig() });
|
|
251
254
|
return;
|
|
252
255
|
}
|
|
253
|
-
sendJson(res, 200, { config: service.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|
|
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.
|
|
253
|
+
sendJson(res, 200, { config: service.getAppConfig() });
|
|
251
254
|
return;
|
|
252
255
|
}
|
|
253
|
-
sendJson(res, 200, { config: service.
|
|
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
|
-
|
|
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
|
});
|
package/test/client.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/config.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/inject.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/settings.test.js
CHANGED
|
@@ -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
|
+
});
|