@modusensus/dsh-mneme 0.7.3 → 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,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
241
241
 
242
242
  | 版本 | 亮点 |
243
243
  |------|------|
244
+ | **v0.7.4** | issue #40 修复:记忆内容含 `{{...}}` 模板语法时整轮崩溃(注入边界 run-based 花括号转义 `{{a}}`→`{\{a\}\}`,奇数连续如 `{{{a}}}` 也不残留字面 `{{`;新增 `escapePromptVariables` 配置默认开);issue #41 修复:记忆窗口关闭按钮与宿主窗口控制按钮重叠无法点击(顶栏左对齐,关闭按钮离开右上角宿主控制区);782 测试全绿 |
244
245
  | **v0.7.3** | issue #38 新功能:左下角入口按钮可选开关 `showSidebarTrigger`(默认开)——与 dsh-cost-meter 等抢占 footer slot 的插件冲突时可在 Web 面板「设置」一键关闭,仅隐藏按钮、记忆库标签不受影响;776 测试全绿 |
245
246
  | **v0.7.2** | issue #35 修复:目录页删除按钮改面板内联两步确认(不再依赖宿主 `window.confirm`)+ 删除失败可见报错;issue #34 新功能:opt-in `injectTimePrefix` 对话开始自动注入当前时间一次(默认关);770 测试全绿 |
246
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 测试全绿 |
@@ -291,6 +292,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
291
292
  | **v0.7.1** | ✅ 完成 | issue #31 修复 | memory_save/update tags 桥接 entity_attrs 标签存储 + 列反向同步 + autoTag 面板开关生效(settings 覆盖 config);764 测试全绿 |
292
293
  | **v0.7.2** | ✅ 完成 | issue #34 + #35 修复 | 目录页删除按钮改内联两步确认 + 删除失败可见报错;opt-in `injectTimePrefix` 对话开始注入当前时间一次(默认关);770 测试全绿 |
293
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 测试全绿 |
294
296
  | **v0.8.0** | 🚧 计划中(9 月末) | 图谱增强 | 兴趣漂移可视化 + 跨 workspace 记忆共享 + 更多 heat 信号 |
295
297
 
296
298
  > 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
@@ -466,7 +468,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
466
468
  ```bash
467
469
  cd dsh-mneme
468
470
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
469
- npm test # 运行 770 个测试
471
+ npm test # 运行 782 个测试
470
472
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
471
473
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
472
474
  ```
package/lib/client.js CHANGED
@@ -472,7 +472,11 @@ window.__ModuleLoader__.load({
472
472
  // there. This surface reuses the exact MemoryExplorer UI at full size —
473
473
  // not a side drawer — so the library stays reachable from any state.
474
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}",
475
- ".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)}",
476
480
  ".mneme-overlaytitle{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}",
477
481
  ".mneme-overlaybody{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}",
478
482
  "@keyframes mneme-fadein{from{opacity:0}to{opacity:1}}"
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),
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/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.3",
4
+ "version": "0.7.4",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
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),
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 = [
@@ -62,3 +62,10 @@ test("showSidebarTrigger is on by default, disabled explicitly (issue #38)", ()
62
62
  const off = Config({ showSidebarTrigger: false });
63
63
  assert.equal(off.showSidebarTrigger, false, "explicit false hides the trigger");
64
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
+ });