@modusensus/dsh-mneme 0.7.3 → 0.7.5

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.
Files changed (145) hide show
  1. package/.github/workflows/test.yml +32 -0
  2. package/CHANGELOG.md +89 -0
  3. package/CONTRIBUTING.md +245 -0
  4. package/README.md +159 -419
  5. package/SECURITY.md +674 -0
  6. package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +247 -0
  7. package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +145 -0
  8. package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +56 -0
  9. package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +77 -0
  10. package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +64 -0
  11. package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +90 -0
  12. package/dsh-mneme/CHANGELOG.md +358 -0
  13. package/dsh-mneme/LICENSE +21 -0
  14. package/dsh-mneme/README.md +493 -0
  15. package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +183 -0
  16. package/dsh-mneme/docs/ENTITIES.md +245 -0
  17. package/dsh-mneme/docs/LOCAL_MODEL.md +141 -0
  18. package/dsh-mneme/docs/MIGRATION.md +127 -0
  19. package/dsh-mneme/docs/SEMANTIC.md +256 -0
  20. package/dsh-mneme/docs/SLEEP.md +163 -0
  21. package/{src → dsh-mneme/lib}/api.js +15 -0
  22. package/{lib → dsh-mneme/lib}/client.js +192 -3
  23. package/{lib → dsh-mneme/lib}/config.js +7 -0
  24. package/{lib → dsh-mneme/lib}/inject.js +20 -3
  25. package/{lib → dsh-mneme/lib}/mirror.js +3 -1
  26. package/{lib → dsh-mneme/lib}/quality-filter.js +3 -1
  27. package/{src → dsh-mneme/lib}/service.js +9 -5
  28. package/{lib → dsh-mneme/lib}/store.js +35 -1
  29. package/{lib → dsh-mneme/lib}/summarize.js +2 -2
  30. package/{src → dsh-mneme/lib}/tools.js +3 -3
  31. package/dsh-mneme/package-lock.json +1936 -0
  32. package/dsh-mneme/package.json +83 -0
  33. package/{lib → dsh-mneme/src}/api.js +15 -0
  34. package/{src → dsh-mneme/src}/config.js +7 -0
  35. package/{src → dsh-mneme/src}/inject.js +20 -3
  36. package/{src → dsh-mneme/src}/mirror.js +3 -1
  37. package/{src → dsh-mneme/src}/quality-filter.js +3 -1
  38. package/{lib → dsh-mneme/src}/service.js +9 -5
  39. package/{src → dsh-mneme/src}/store.js +35 -1
  40. package/{src → dsh-mneme/src}/summarize.js +2 -2
  41. package/{lib → dsh-mneme/src}/tools.js +3 -3
  42. package/{test → dsh-mneme/test}/config.test.js +7 -0
  43. package/{test → dsh-mneme/test}/inject.test.js +57 -0
  44. package/dsh-mneme/test/layered-types-stats.test.js +144 -0
  45. package/package.json +18 -43
  46. package//346/250/252/345/271/205.png +0 -0
  47. /package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +0 -0
  48. /package/{lib → dsh-mneme/lib}/commands.js +0 -0
  49. /package/{lib → dsh-mneme/lib}/dream/clustering.js +0 -0
  50. /package/{lib → dsh-mneme/lib}/dream/decisions.js +0 -0
  51. /package/{lib → dsh-mneme/lib}/dream/sleep.js +0 -0
  52. /package/{lib → dsh-mneme/lib}/dream/tag-extractor.js +0 -0
  53. /package/{lib → dsh-mneme/lib}/dream.js +0 -0
  54. /package/{lib → dsh-mneme/lib}/embedding.js +0 -0
  55. /package/{lib → dsh-mneme/lib}/entities/extractor.js +0 -0
  56. /package/{lib → dsh-mneme/lib}/heat.js +0 -0
  57. /package/{lib → dsh-mneme/lib}/hot-memory.js +0 -0
  58. /package/{lib → dsh-mneme/lib}/index.js +0 -0
  59. /package/{lib → dsh-mneme/lib}/local-embedder.js +0 -0
  60. /package/{lib → dsh-mneme/lib}/parser/tag.js +0 -0
  61. /package/{lib → dsh-mneme/lib}/parser/wiki-link.js +0 -0
  62. /package/{lib → dsh-mneme/lib}/reranker.js +0 -0
  63. /package/{lib → dsh-mneme/lib}/search/adaptive.js +0 -0
  64. /package/{lib → dsh-mneme/lib}/search/bm25.js +0 -0
  65. /package/{lib → dsh-mneme/lib}/search/tag-boost.js +0 -0
  66. /package/{lib → dsh-mneme/lib}/settings.js +0 -0
  67. /package/{lib → dsh-mneme/lib}/vector-index.js +0 -0
  68. /package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +0 -0
  69. /package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +0 -0
  70. /package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +0 -0
  71. /package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +0 -0
  72. /package/{scripts → dsh-mneme/scripts}/stress-dsh.js +0 -0
  73. /package/{scripts → dsh-mneme/scripts}/sync-lib.js +0 -0
  74. /package/{src → dsh-mneme/src}/commands.js +0 -0
  75. /package/{src → dsh-mneme/src}/dream/clustering.js +0 -0
  76. /package/{src → dsh-mneme/src}/dream/decisions.js +0 -0
  77. /package/{src → dsh-mneme/src}/dream/sleep.js +0 -0
  78. /package/{src → dsh-mneme/src}/dream/tag-extractor.js +0 -0
  79. /package/{src → dsh-mneme/src}/dream.js +0 -0
  80. /package/{src → dsh-mneme/src}/embedding.js +0 -0
  81. /package/{src → dsh-mneme/src}/entities/extractor.js +0 -0
  82. /package/{src → dsh-mneme/src}/heat.js +0 -0
  83. /package/{src → dsh-mneme/src}/hot-memory.js +0 -0
  84. /package/{src → dsh-mneme/src}/index.js +0 -0
  85. /package/{src → dsh-mneme/src}/local-embedder.js +0 -0
  86. /package/{src → dsh-mneme/src}/parser/tag.js +0 -0
  87. /package/{src → dsh-mneme/src}/parser/wiki-link.js +0 -0
  88. /package/{src → dsh-mneme/src}/reranker.js +0 -0
  89. /package/{src → dsh-mneme/src}/search/adaptive.js +0 -0
  90. /package/{src → dsh-mneme/src}/search/bm25.js +0 -0
  91. /package/{src → dsh-mneme/src}/search/tag-boost.js +0 -0
  92. /package/{src → dsh-mneme/src}/settings.js +0 -0
  93. /package/{src → dsh-mneme/src}/vector-index.js +0 -0
  94. /package/{test → dsh-mneme/test}/api.test.js +0 -0
  95. /package/{test → dsh-mneme/test}/audit.test.js +0 -0
  96. /package/{test → dsh-mneme/test}/benchmark.test.js +0 -0
  97. /package/{test → dsh-mneme/test}/boundary-v0625.test.js +0 -0
  98. /package/{test → dsh-mneme/test}/client.test.js +0 -0
  99. /package/{test → dsh-mneme/test}/clustering.test.js +0 -0
  100. /package/{test → dsh-mneme/test}/commands.test.js +0 -0
  101. /package/{test → dsh-mneme/test}/conflict-freeze.test.js +0 -0
  102. /package/{test → dsh-mneme/test}/directory.test.js +0 -0
  103. /package/{test → dsh-mneme/test}/dream.test.js +0 -0
  104. /package/{test → dsh-mneme/test}/entities.test.js +0 -0
  105. /package/{test → dsh-mneme/test}/epistemic.test.js +0 -0
  106. /package/{test → dsh-mneme/test}/fnew-0112.test.js +0 -0
  107. /package/{test → dsh-mneme/test}/fnew-03.test.js +0 -0
  108. /package/{test → dsh-mneme/test}/graph-api.test.js +0 -0
  109. /package/{test → dsh-mneme/test}/heat.test.js +0 -0
  110. /package/{test → dsh-mneme/test}/helpers/dream-mock.js +0 -0
  111. /package/{test → dsh-mneme/test}/hot-memory.test.js +0 -0
  112. /package/{test → dsh-mneme/test}/llm-audit.test.js +0 -0
  113. /package/{test → dsh-mneme/test}/local-embedder.test.js +0 -0
  114. /package/{test → dsh-mneme/test}/mirror-dirty.test.js +0 -0
  115. /package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +0 -0
  116. /package/{test → dsh-mneme/test}/mirror-generation.test.js +0 -0
  117. /package/{test → dsh-mneme/test}/mirror.test.js +0 -0
  118. /package/{test → dsh-mneme/test}/normalize-decisions.test.js +0 -0
  119. /package/{test → dsh-mneme/test}/peer-blockers.test.js +0 -0
  120. /package/{test → dsh-mneme/test}/policy-epoch.test.js +0 -0
  121. /package/{test → dsh-mneme/test}/provenance.test.js +0 -0
  122. /package/{test → dsh-mneme/test}/quality-filter.test.js +0 -0
  123. /package/{test → dsh-mneme/test}/reasoning-effort.test.js +0 -0
  124. /package/{test → dsh-mneme/test}/recall-evals.test.js +0 -0
  125. /package/{test → dsh-mneme/test}/recall-layer.test.js +0 -0
  126. /package/{test → dsh-mneme/test}/recall-runs.test.js +0 -0
  127. /package/{test → dsh-mneme/test}/receipt-chain.test.js +0 -0
  128. /package/{test → dsh-mneme/test}/reflection.test.js +0 -0
  129. /package/{test → dsh-mneme/test}/reranker.test.js +0 -0
  130. /package/{test → dsh-mneme/test}/search-fusion.test.js +0 -0
  131. /package/{test → dsh-mneme/test}/semantic.test.js +0 -0
  132. /package/{test → dsh-mneme/test}/service-search.test.js +0 -0
  133. /package/{test → dsh-mneme/test}/service.test.js +0 -0
  134. /package/{test → dsh-mneme/test}/settings.test.js +0 -0
  135. /package/{test → dsh-mneme/test}/sleep-heat.test.js +0 -0
  136. /package/{test → dsh-mneme/test}/sleep.test.js +0 -0
  137. /package/{test → dsh-mneme/test}/store.test.js +0 -0
  138. /package/{test → dsh-mneme/test}/stress.test.js +0 -0
  139. /package/{test → dsh-mneme/test}/summarize.test.js +0 -0
  140. /package/{test → dsh-mneme/test}/tag-boost.test.js +0 -0
  141. /package/{test → dsh-mneme/test}/tag.test.js +0 -0
  142. /package/{test → dsh-mneme/test}/tools.test.js +0 -0
  143. /package/{test → dsh-mneme/test}/updated-at-semantics.test.js +0 -0
  144. /package/{test → dsh-mneme/test}/vector-index.test.js +0 -0
  145. /package/{test → dsh-mneme/test}/wiki-link.test.js +0 -0
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@modusensus/dsh-mneme",
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.5",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/modusensus/dsh-mneme.git"
9
+ },
10
+ "homepage": "https://github.com/modusensus/dsh-mneme#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/modusensus/dsh-mneme/issues"
13
+ },
14
+ "type": "module",
15
+ "main": "lib/index.js",
16
+ "exports": {
17
+ ".": {
18
+ "default": "./lib/index.js"
19
+ },
20
+ "./client": {
21
+ "default": "./lib/client.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "lib",
27
+ "src",
28
+ "scripts",
29
+ "test",
30
+ "cordis.patch.yml"
31
+ ],
32
+ "dsh": {
33
+ "client": {
34
+ "inject": [
35
+ "slots",
36
+ "locale",
37
+ "layout",
38
+ "connection"
39
+ ],
40
+ "platform": "web"
41
+ },
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ }
45
+ },
46
+ "peerDependencies": {
47
+ "@deepseek-ai/cordis": "^4.0.1",
48
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
49
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
50
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
51
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
52
+ "@deepseek-ai/schemastery": "^3.18.1"
53
+ },
54
+ "devDependencies": {
55
+ "@deepseek-ai/cordis": "^4.0.1",
56
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
57
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
58
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
59
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
60
+ "@deepseek-ai/schemastery": "^3.18.1",
61
+ "c8": "^12.0.0"
62
+ },
63
+ "scripts": {
64
+ "sync": "node scripts/sync-lib.js",
65
+ "prepack": "npm run sync",
66
+ "test": "node --test test/*.test.js",
67
+ "test:coverage": "c8 node --test test/*.test.js",
68
+ "e2e": "node scripts/e2e-dsh.js",
69
+ "stress": "node scripts/stress-dsh.js"
70
+ },
71
+ "dependencies": {
72
+ "@huggingface/transformers": "^4.2.0"
73
+ },
74
+ "overrides": {
75
+ "adm-zip": "0.6.0"
76
+ },
77
+ "c8": {
78
+ "reporter": [
79
+ "text",
80
+ "lcov"
81
+ ]
82
+ }
83
+ }
@@ -96,6 +96,21 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
96
96
  }
97
97
  });
98
98
 
99
+ register({
100
+ kind: "exact",
101
+ path: "/api/dsh-mneme/stats",
102
+ handler(req, res) {
103
+ try {
104
+ const url = new URL(req.url, "http://localhost");
105
+ const raw = url.searchParams.get("days");
106
+ const days = Math.min(30, Math.max(1, parseInt(raw ?? "7", 10) || 7));
107
+ sendJson(res, 200, service.stats({ days }));
108
+ } catch {
109
+ sendJson(res, 500, { error: "internal" });
110
+ }
111
+ }
112
+ });
113
+
99
114
  register({
100
115
  kind: "exact",
101
116
  path: "/api/dsh-mneme/search",
@@ -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),
@@ -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 = [
@@ -7,7 +7,9 @@ export const TYPE_FILE = {
7
7
  project: "projects.md",
8
8
  decision: "decisions.md",
9
9
  history: "history.md",
10
- summary: "summary.md"
10
+ summary: "summary.md",
11
+ user: "user.md",
12
+ fact: "facts.md"
11
13
  };
12
14
 
13
15
  const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
@@ -34,7 +34,9 @@ const TYPE_LABELS = {
34
34
  decision: ["decision", "决策", "决定"],
35
35
  history: ["history", "历史", "事件"],
36
36
  summary: ["summary", "总结", "摘要", "总览"],
37
- pattern: ["pattern", "模式", "规律"]
37
+ pattern: ["pattern", "模式", "规律"],
38
+ user: ["user", "用户"],
39
+ fact: ["fact", "事实", "原子事实"]
38
40
  };
39
41
 
40
42
  /** Normalized bigram-overlap similarity in [0,1]; 0 for tiny/empty inputs. */
@@ -7,7 +7,7 @@ import { parseWikiLinks } from "./parser/wiki-link.js";
7
7
  import { extractQueryTags, applyTagBoost } from "./search/tag-boost.js";
8
8
  import { computeHeat } from "./heat.js";
9
9
 
10
- const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
10
+ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "user", "fact"]);
11
11
 
12
12
  // Epistemic trust weights (v0.4.5): when config.trustEpistemicWeighting is on,
13
13
  // each recall candidate's existing score is multiplied by the weight of its
@@ -1047,12 +1047,15 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1047
1047
  // (quality_score null) count as 100 (weight 1), so legacy stores keep their
1048
1048
  // exact summary>preference>importance ordering.
1049
1049
  const qualityWeight = (m) => (m.quality_score != null ? m.quality_score / 100 : 1);
1050
+ // summary/user/preference are injected regardless of importance (context
1051
+ // layers); every other INJECT_TYPES type (e.g. fact) only when
1052
+ // importance >= threshold — the frontend "常注入" badge maps to this split.
1050
1053
  const items = store.list({ limit: 200, includeForgotten: false })
1051
1054
  .filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
1052
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold))
1055
+ (m.type === "summary" || m.type === "preference" || m.type === "user" || m.importance >= threshold))
1053
1056
  .sort((a, b) => {
1054
- const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
1055
- const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
1057
+ const pa = a.type === "summary" ? 0 : (a.type === "preference" || a.type === "user") ? 1 : 2;
1058
+ const pb = b.type === "summary" ? 0 : (b.type === "preference" || b.type === "user") ? 1 : 2;
1056
1059
  return pa - pb || (b.importance * qualityWeight(b)) - (a.importance * qualityWeight(a));
1057
1060
  });
1058
1061
  let candidates = items;
@@ -1068,7 +1071,7 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1068
1071
  const hits = vectorIndex.search(queryVector, { limit: maxItems * 2, threshold: 0 });
1069
1072
  for (const m of hits) {
1070
1073
  if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
1071
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
1074
+ (m.type === "summary" || m.type === "preference" || m.type === "user" || m.importance >= threshold)) {
1072
1075
  semanticItems.push(m);
1073
1076
  }
1074
1077
  }
@@ -1606,6 +1609,7 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1606
1609
  list: (o) => store.list(o),
1607
1610
  all: () => store.all(),
1608
1611
  count: (type, opts) => store.count(type, opts),
1612
+ stats: (opts) => store.stats(opts),
1609
1613
  getById: (id) => store.getById(id),
1610
1614
  remove: (id) => {
1611
1615
  store.remove(id);
@@ -242,7 +242,7 @@ CREATE TABLE IF NOT EXISTS mirror_state (
242
242
  );
243
243
  `;
244
244
 
245
- const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
245
+ const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern", "user", "fact"]);
246
246
 
247
247
  // Epistemic status: what kind of evidence a memory rests on. Defaults to
248
248
  // 'subjective' so legacy rows (and rows without any signal) stay compatible.
@@ -659,6 +659,39 @@ export function createStore(path) {
659
659
  return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
660
660
  }
661
661
 
662
+ /**
663
+ * Aggregated stats for the Web panel layered overview: per-type distribution
664
+ * plus a recent daily creation trend. Counts live (non-forgotten /
665
+ * non-archived / not session-disposed) memories only, matching `count`.
666
+ * created_at is ISO TEXT, so date strings compare lexicographically.
667
+ */
668
+ function stats({ days = 7 } = {}) {
669
+ const LIVE = "forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL";
670
+ // Clamp + integer-coerce so fractional strings (e.g. ?days=7.5) can't
671
+ // produce ragged trend windows (kimi-k2.7-code 复验 S2).
672
+ days = Math.min(30, Math.max(1, Math.floor(Number(days) || 7)));
673
+ const now = Date.now(); // single clock read so the window doesn't span a day boundary
674
+ const byType = db.prepare(
675
+ `SELECT type, count(*) AS c FROM memories WHERE ${LIVE} GROUP BY type`
676
+ ).all();
677
+ const byTypeCounts = {};
678
+ for (const row of byType) byTypeCounts[row.type] = row.c;
679
+ const since = new Date(now - (days - 1) * 86400000).toISOString().slice(0, 10);
680
+ const trend = db.prepare(
681
+ `SELECT substr(created_at, 1, 10) AS d, count(*) AS c FROM memories
682
+ WHERE ${LIVE} AND created_at >= ? GROUP BY d ORDER BY d`
683
+ ).all(since);
684
+ const trendCounts = {};
685
+ for (const row of trend) trendCounts[row.d] = row.c;
686
+ const recent = [];
687
+ for (let i = days - 1; i >= 0; i--) {
688
+ const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
689
+ recent.push({ date: d, count: trendCounts[d] ?? 0 });
690
+ }
691
+ const total = Object.values(byTypeCounts).reduce((s, n) => s + n, 0);
692
+ return { byType: byTypeCounts, total, recent, days };
693
+ }
694
+
662
695
  function getById(id) {
663
696
  const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
664
697
  return toRow(row);
@@ -2175,6 +2208,7 @@ export function createStore(path) {
2175
2208
  return {
2176
2209
  db,
2177
2210
  count,
2211
+ stats,
2178
2212
  getById,
2179
2213
  save,
2180
2214
  update,
@@ -1,7 +1,7 @@
1
1
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
2
 
3
3
  const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼 2-3 条值得跨会话记住的记忆。
4
- 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"一句话内容","importance":1-5}。
4
+ 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history|user|fact","title":"简短标题","content":"一句话内容","importance":1-5}。
5
5
  不要输出任何其他文字。`;
6
6
 
7
7
  /** Extract a JSON array from LLM output that may contain prose around it. */
@@ -17,7 +17,7 @@ export function parseSummaryJson(raw) {
17
17
  return [];
18
18
  }
19
19
  if (!Array.isArray(arr)) return [];
20
- const VALID = new Set(["preference", "project", "decision", "history"]);
20
+ const VALID = new Set(["preference", "project", "decision", "history", "user", "fact"]);
21
21
  return arr.filter(
22
22
  (item) =>
23
23
  item &&
@@ -192,7 +192,7 @@ export function createTools(ctx, service, config, embedder) {
192
192
  "Call this when the user states a durable preference, a project decision is made, or a lesson is learned. " +
193
193
  "Merges into an existing entry of the same type when the title matches.",
194
194
  parameters: {
195
- type: { type: "string", required: true, enum: ["preference", "project", "decision", "history"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary" },
195
+ type: { type: "string", required: true, enum: ["preference", "project", "decision", "history", "user", "fact"], description: "preference=user preference; project=project knowledge/state; decision=key decision; history=conversation summary; user=user profile (background/identity); fact=atomic factual statement" },
196
196
  title: { type: "string", required: true, description: "Short unique title" },
197
197
  content: { type: "string", required: true, description: "Memory body" },
198
198
  tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
@@ -266,7 +266,7 @@ export function createTools(ctx, service, config, embedder) {
266
266
  name: "memory_list",
267
267
  description: "List at most 50 memory entries by type, high-importance first, then newest, paginated. Set include_archived=true to also list archived (hidden) entries so they can be located and restored. The JSONL summary reports returned/shown/omitted, offset/total/next_offset, and each rendered entry's exact id, type, title, importance, updated_at, tags, and truncated content_preview.",
268
268
  parameters: {
269
- type: { type: "string", enum: ["preference", "project", "decision", "history"], description: "Filter by type; omit for all" },
269
+ type: { type: "string", enum: ["preference", "project", "decision", "history", "user", "fact"], description: "Filter by type; omit for all" },
270
270
  limit: { type: "integer", description: "Page size (default and maximum 50; nonpositive values use 50)" },
271
271
  offset: { type: "integer", description: "Page offset (default 0)" },
272
272
  include_archived: { type: "boolean", description: "Include archived (hidden) entries so they can be found and restored (default false)" }
@@ -305,7 +305,7 @@ export function createTools(ctx, service, config, embedder) {
305
305
  id: { type: "string", required: true, description: "Memory id" },
306
306
  title: { type: "string" },
307
307
  content: { type: "string" },
308
- type: { type: "string", enum: ["preference", "project", "decision", "history"] },
308
+ type: { type: "string", enum: ["preference", "project", "decision", "history", "user", "fact"] },
309
309
  tags: { type: "array", items: { type: "string" } },
310
310
  importance: { type: "integer", description: "1-5" },
311
311
  reason: { type: "string", description: "Optional context for the correction (what the user actually said/wanted), recorded for reflection" }
@@ -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
+ });
@@ -0,0 +1,144 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+ import { createApi } from "../src/api.js";
7
+ import { createSettings } from "../src/settings.js";
8
+ import { TYPE_FILE } from "../src/mirror.js";
9
+ import { parseSummaryJson } from "../src/summarize.js";
10
+
11
+ // v0.8.x: layered memory types (user / fact) + Web panel stats endpoint.
12
+ // user = user profile (background/identity), fact = atomic factual statement.
13
+ // Both ride the existing single-table `memories` design — only the type enum
14
+ // and downstream lists (mirror / tools / summarize / inject) were extended.
15
+
16
+ class FakeRes extends EventEmitter {
17
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
18
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
19
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
20
+ }
21
+
22
+ function req(path, method = "GET", body = null) {
23
+ const r = new EventEmitter();
24
+ r.url = path;
25
+ r.method = method;
26
+ r.headers = {};
27
+ if (body !== null) {
28
+ process.nextTick(() => {
29
+ r.emit("data", Buffer.from(JSON.stringify(body)));
30
+ r.emit("end");
31
+ });
32
+ }
33
+ return r;
34
+ }
35
+
36
+ function setup() {
37
+ const store = createStore(":memory:");
38
+ const settings = createSettings(store.db);
39
+ const service = createService({ store, mirror: null, config: {}, settings });
40
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
41
+ const routes = [];
42
+ const ctx = {
43
+ webServer: {
44
+ register(route) { routes.push(route); return () => {}; }
45
+ }
46
+ };
47
+ createApi(ctx, service, settings, commands, undefined, undefined, "");
48
+ const handler = (path) => routes.find((r) => r.path === path)?.handler;
49
+ return { store, service, handler };
50
+ }
51
+
52
+ test("user/fact are valid memory types for saveWithDedupe", () => {
53
+ const { service } = setup();
54
+ const a = service.saveWithDedupe({ type: "user", title: "桉桉", content: "湖南工业大学学生,考研公共管理学", importance: 5 });
55
+ const b = service.saveWithDedupe({ type: "fact", title: "服务器地址", content: "云服务器 Ubuntu 22.04", importance: 3 });
56
+ assert.equal(a.memory.type, "user");
57
+ assert.equal(b.memory.type, "fact");
58
+ assert.equal(service.count("user"), 1);
59
+ assert.equal(service.count("fact"), 1);
60
+ });
61
+
62
+ test("mirror TYPE_FILE covers user/fact layers", () => {
63
+ assert.equal(TYPE_FILE.user, "user.md");
64
+ assert.equal(TYPE_FILE.fact, "facts.md");
65
+ });
66
+
67
+ test("autoSummarize accepts user/fact types", () => {
68
+ const parsed = parseSummaryJson(
69
+ '[{"type":"user","title":"用户城市","content":"湖南株洲","importance":4},{"type":"fact","title":"端口","content":"23334","importance":3}]'
70
+ );
71
+ assert.equal(parsed.length, 2);
72
+ assert.deepEqual(parsed.map((i) => i.type), ["user", "fact"]);
73
+ // legacy types still pass, unknown types rejected
74
+ const mixed = parseSummaryJson(
75
+ '[{"type":"preference","title":"语言","content":"中文","importance":2},{"type":"nonsense","title":"x","content":"y","importance":1}]'
76
+ );
77
+ assert.equal(mixed.length, 1);
78
+ assert.equal(mixed[0].type, "preference");
79
+ });
80
+
81
+ test("GET /api/dsh-mneme/list?type=user filters the new layer", async () => {
82
+ const { service, handler } = setup();
83
+ service.saveWithDedupe({ type: "user", title: "桉桉", content: "用户画像", importance: 5 });
84
+ service.saveWithDedupe({ type: "fact", title: "事实", content: "原子事实", importance: 3 });
85
+ const res = new FakeRes();
86
+ await handler("/api/dsh-mneme/list")?.({ url: "/api/dsh-mneme/list?type=user" }, res);
87
+ const data = JSON.parse(res.body);
88
+ assert.equal(res.statusCode, 200);
89
+ assert.equal(data.total, 1);
90
+ assert.equal(data.items[0].type, "user");
91
+ });
92
+
93
+ test("GET /api/dsh-mneme/stats returns byType distribution + recent trend", async () => {
94
+ const { service, handler } = setup();
95
+ service.saveWithDedupe({ type: "user", title: "桉桉", content: "用户画像", importance: 5 });
96
+ service.saveWithDedupe({ type: "fact", title: "事实1", content: "原子事实", importance: 3 });
97
+ service.saveWithDedupe({ type: "fact", title: "事实2", content: "另一个事实", importance: 2 });
98
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文", importance: 2 });
99
+ const res = new FakeRes();
100
+ await handler("/api/dsh-mneme/stats")?.({ url: "/api/dsh-mneme/stats?days=7" }, res);
101
+ const data = JSON.parse(res.body);
102
+ assert.equal(res.statusCode, 200);
103
+ assert.equal(data.byType.user, 1);
104
+ assert.equal(data.byType.fact, 2);
105
+ assert.equal(data.byType.preference, 1);
106
+ assert.equal(data.total, 4);
107
+ // 7-day trend covers today (the only day with live rows)
108
+ assert.equal(data.recent.length, 7);
109
+ const today = new Date().toISOString().slice(0, 10);
110
+ assert.equal(data.recent[6].date, today);
111
+ assert.equal(data.recent[6].count, 4);
112
+ });
113
+
114
+ test("stats excludes archived/forgotten memories", async () => {
115
+ const { service, handler } = setup();
116
+ const a = service.saveWithDedupe({ type: "user", title: "桉桉", content: "用户画像", importance: 5 });
117
+ const b = service.saveWithDedupe({ type: "fact", title: "事实", content: "原子事实", importance: 3 });
118
+ service.setArchived(a.memory.id, true);
119
+ service.setForget(b.memory.id, true);
120
+ const res = new FakeRes();
121
+ await handler("/api/dsh-mneme/stats")?.({ url: "/api/dsh-mneme/stats?days=7" }, res);
122
+ const data = JSON.parse(res.body);
123
+ assert.equal(data.total, 0);
124
+ });
125
+
126
+ test("stats clamps days to 1..30", async () => {
127
+ const { handler } = setup();
128
+ const res = new FakeRes();
129
+ await handler("/api/dsh-mneme/stats")?.({ url: "/api/dsh-mneme/stats?days=999" }, res);
130
+ const data = JSON.parse(res.body);
131
+ assert.equal(res.statusCode, 200);
132
+ assert.equal(data.recent.length, 30);
133
+ });
134
+
135
+ test("stats coerces fractional days to integer (kimi S2)", async () => {
136
+ const { service, handler } = setup();
137
+ service.saveWithDedupe({ type: "fact", title: "事实", content: "原子事实", importance: 3 });
138
+ const res = new FakeRes();
139
+ await handler("/api/dsh-mneme/stats")?.({ url: "/api/dsh-mneme/stats?days=7.5" }, res);
140
+ const data = JSON.parse(res.body);
141
+ assert.equal(data.recent.length, 7); // floor(7.5) → 7, never a ragged 8
142
+ assert.equal(data.days, 7);
143
+ assert.equal(data.byType.fact, 1);
144
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
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",
3
+ "version": "0.7.5",
4
+ "description": "Structured memory engine for DeepSeek Harness. Offline semantic search, entity-attribute-timeline, autoDream self-consolidation, and human-editable Markdown storage.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -12,23 +12,16 @@
12
12
  "url": "https://github.com/modusensus/dsh-mneme/issues"
13
13
  },
14
14
  "type": "module",
15
- "main": "lib/index.js",
15
+ "main": "./dsh-mneme/lib/index.js",
16
16
  "exports": {
17
17
  ".": {
18
- "default": "./lib/index.js"
18
+ "default": "./dsh-mneme/lib/index.js"
19
19
  },
20
20
  "./client": {
21
- "default": "./lib/client.js"
21
+ "default": "./dsh-mneme/lib/client.js"
22
22
  },
23
- "./package.json": "./package.json"
23
+ "./package.json": "./dsh-mneme/package.json"
24
24
  },
25
- "files": [
26
- "lib",
27
- "src",
28
- "scripts",
29
- "test",
30
- "cordis.patch.yml"
31
- ],
32
25
  "dsh": {
33
26
  "client": {
34
27
  "inject": [
@@ -40,9 +33,20 @@
40
33
  "platform": "web"
41
34
  },
42
35
  "bundle": {
43
- "patch": "./cordis.patch.yml"
36
+ "patch": "./dsh-mneme/cordis.patch.yml"
37
+ },
38
+ "marketplace": {
39
+ "profiles": [
40
+ "web"
41
+ ],
42
+ "requiresBuildApproval": false,
43
+ "requiresRestart": true,
44
+ "manualSteps": false
44
45
  }
45
46
  },
47
+ "scripts": {
48
+ "prepublishOnly": "node -e \"const fs=require('fs');const v=JSON.parse(fs.readFileSync('./dsh-mneme/package.json','utf8')).version;const p=JSON.parse(fs.readFileSync('./package.json','utf8'));p.version=v;fs.writeFileSync('./package.json',JSON.stringify(p,null,2)+'\\n')\""
49
+ },
46
50
  "peerDependencies": {
47
51
  "@deepseek-ai/cordis": "^4.0.1",
48
52
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
@@ -50,34 +54,5 @@
50
54
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
51
55
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
52
56
  "@deepseek-ai/schemastery": "^3.18.1"
53
- },
54
- "devDependencies": {
55
- "@deepseek-ai/cordis": "^4.0.1",
56
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
57
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
58
- "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
59
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
60
- "@deepseek-ai/schemastery": "^3.18.1",
61
- "c8": "^12.0.0"
62
- },
63
- "scripts": {
64
- "sync": "node scripts/sync-lib.js",
65
- "prepack": "npm run sync",
66
- "test": "node --test test/*.test.js",
67
- "test:coverage": "c8 node --test test/*.test.js",
68
- "e2e": "node scripts/e2e-dsh.js",
69
- "stress": "node scripts/stress-dsh.js"
70
- },
71
- "dependencies": {
72
- "@huggingface/transformers": "^4.2.0"
73
- },
74
- "overrides": {
75
- "adm-zip": "0.6.0"
76
- },
77
- "c8": {
78
- "reporter": [
79
- "text",
80
- "lcov"
81
- ]
82
57
  }
83
58
  }