@modusensus/dsh-mneme 0.6.11 → 0.7.0

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 +149 -398
  5. package/SECURITY.md +544 -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 +286 -0
  13. package/dsh-mneme/LICENSE +21 -0
  14. package/dsh-mneme/README.md +482 -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 +4 -1
  22. package/{lib → dsh-mneme/lib}/client.js +10 -2
  23. package/{lib → dsh-mneme/lib}/config.js +17 -0
  24. package/{lib → dsh-mneme/lib}/dream/sleep.js +13 -1
  25. package/dsh-mneme/lib/heat.js +136 -0
  26. package/{lib → dsh-mneme/lib}/index.js +7 -0
  27. package/{src → dsh-mneme/lib}/service.js +63 -8
  28. package/{lib → dsh-mneme/lib}/store.js +17 -0
  29. package/dsh-mneme/package-lock.json +1936 -0
  30. package/dsh-mneme/package.json +80 -0
  31. package/{lib → dsh-mneme/src}/api.js +4 -1
  32. package/{src → dsh-mneme/src}/config.js +17 -0
  33. package/{src → dsh-mneme/src}/dream/sleep.js +13 -1
  34. package/dsh-mneme/src/heat.js +136 -0
  35. package/{src → dsh-mneme/src}/index.js +7 -0
  36. package/{lib → dsh-mneme/src}/service.js +63 -8
  37. package/{src → dsh-mneme/src}/store.js +17 -0
  38. package/{test → dsh-mneme/test}/graph-api.test.js +34 -0
  39. package/dsh-mneme/test/heat.test.js +148 -0
  40. package/{test → dsh-mneme/test}/recall-layer.test.js +26 -5
  41. package/dsh-mneme/test/recall-runs.test.js +93 -0
  42. package/dsh-mneme/test/sleep-heat.test.js +112 -0
  43. package/{test → dsh-mneme/test}/sleep.test.js +12 -4
  44. package/dsh-mneme/test/updated-at-semantics.test.js +113 -0
  45. package/package.json +18 -40
  46. package/v0.7-plan-2026-08-21.md +75 -0
  47. package//346/250/252/345/271/205.png +0 -0
  48. /package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +0 -0
  49. /package/{lib → dsh-mneme/lib}/commands.js +0 -0
  50. /package/{lib → dsh-mneme/lib}/dream/clustering.js +0 -0
  51. /package/{lib → dsh-mneme/lib}/dream/decisions.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}/hot-memory.js +0 -0
  57. /package/{lib → dsh-mneme/lib}/inject.js +0 -0
  58. /package/{lib → dsh-mneme/lib}/local-embedder.js +0 -0
  59. /package/{lib → dsh-mneme/lib}/mirror.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}/quality-filter.js +0 -0
  63. /package/{lib → dsh-mneme/lib}/reranker.js +0 -0
  64. /package/{lib → dsh-mneme/lib}/search/adaptive.js +0 -0
  65. /package/{lib → dsh-mneme/lib}/search/bm25.js +0 -0
  66. /package/{lib → dsh-mneme/lib}/search/tag-boost.js +0 -0
  67. /package/{lib → dsh-mneme/lib}/settings.js +0 -0
  68. /package/{lib → dsh-mneme/lib}/summarize.js +0 -0
  69. /package/{lib → dsh-mneme/lib}/tools.js +0 -0
  70. /package/{lib → dsh-mneme/lib}/vector-index.js +0 -0
  71. /package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +0 -0
  72. /package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +0 -0
  73. /package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +0 -0
  74. /package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +0 -0
  75. /package/{scripts → dsh-mneme/scripts}/stress-dsh.js +0 -0
  76. /package/{scripts → dsh-mneme/scripts}/sync-lib.js +0 -0
  77. /package/{src → dsh-mneme/src}/commands.js +0 -0
  78. /package/{src → dsh-mneme/src}/dream/clustering.js +0 -0
  79. /package/{src → dsh-mneme/src}/dream/decisions.js +0 -0
  80. /package/{src → dsh-mneme/src}/dream/tag-extractor.js +0 -0
  81. /package/{src → dsh-mneme/src}/dream.js +0 -0
  82. /package/{src → dsh-mneme/src}/embedding.js +0 -0
  83. /package/{src → dsh-mneme/src}/entities/extractor.js +0 -0
  84. /package/{src → dsh-mneme/src}/hot-memory.js +0 -0
  85. /package/{src → dsh-mneme/src}/inject.js +0 -0
  86. /package/{src → dsh-mneme/src}/local-embedder.js +0 -0
  87. /package/{src → dsh-mneme/src}/mirror.js +0 -0
  88. /package/{src → dsh-mneme/src}/parser/tag.js +0 -0
  89. /package/{src → dsh-mneme/src}/parser/wiki-link.js +0 -0
  90. /package/{src → dsh-mneme/src}/quality-filter.js +0 -0
  91. /package/{src → dsh-mneme/src}/reranker.js +0 -0
  92. /package/{src → dsh-mneme/src}/search/adaptive.js +0 -0
  93. /package/{src → dsh-mneme/src}/search/bm25.js +0 -0
  94. /package/{src → dsh-mneme/src}/search/tag-boost.js +0 -0
  95. /package/{src → dsh-mneme/src}/settings.js +0 -0
  96. /package/{src → dsh-mneme/src}/summarize.js +0 -0
  97. /package/{src → dsh-mneme/src}/tools.js +0 -0
  98. /package/{src → dsh-mneme/src}/vector-index.js +0 -0
  99. /package/{test → dsh-mneme/test}/api.test.js +0 -0
  100. /package/{test → dsh-mneme/test}/audit.test.js +0 -0
  101. /package/{test → dsh-mneme/test}/benchmark.test.js +0 -0
  102. /package/{test → dsh-mneme/test}/boundary-v0625.test.js +0 -0
  103. /package/{test → dsh-mneme/test}/client.test.js +0 -0
  104. /package/{test → dsh-mneme/test}/clustering.test.js +0 -0
  105. /package/{test → dsh-mneme/test}/commands.test.js +0 -0
  106. /package/{test → dsh-mneme/test}/config.test.js +0 -0
  107. /package/{test → dsh-mneme/test}/conflict-freeze.test.js +0 -0
  108. /package/{test → dsh-mneme/test}/directory.test.js +0 -0
  109. /package/{test → dsh-mneme/test}/dream.test.js +0 -0
  110. /package/{test → dsh-mneme/test}/entities.test.js +0 -0
  111. /package/{test → dsh-mneme/test}/epistemic.test.js +0 -0
  112. /package/{test → dsh-mneme/test}/fnew-0112.test.js +0 -0
  113. /package/{test → dsh-mneme/test}/fnew-03.test.js +0 -0
  114. /package/{test → dsh-mneme/test}/helpers/dream-mock.js +0 -0
  115. /package/{test → dsh-mneme/test}/hot-memory.test.js +0 -0
  116. /package/{test → dsh-mneme/test}/inject.test.js +0 -0
  117. /package/{test → dsh-mneme/test}/llm-audit.test.js +0 -0
  118. /package/{test → dsh-mneme/test}/local-embedder.test.js +0 -0
  119. /package/{test → dsh-mneme/test}/mirror-dirty.test.js +0 -0
  120. /package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +0 -0
  121. /package/{test → dsh-mneme/test}/mirror-generation.test.js +0 -0
  122. /package/{test → dsh-mneme/test}/mirror.test.js +0 -0
  123. /package/{test → dsh-mneme/test}/normalize-decisions.test.js +0 -0
  124. /package/{test → dsh-mneme/test}/peer-blockers.test.js +0 -0
  125. /package/{test → dsh-mneme/test}/policy-epoch.test.js +0 -0
  126. /package/{test → dsh-mneme/test}/provenance.test.js +0 -0
  127. /package/{test → dsh-mneme/test}/quality-filter.test.js +0 -0
  128. /package/{test → dsh-mneme/test}/reasoning-effort.test.js +0 -0
  129. /package/{test → dsh-mneme/test}/recall-evals.test.js +0 -0
  130. /package/{test → dsh-mneme/test}/receipt-chain.test.js +0 -0
  131. /package/{test → dsh-mneme/test}/reflection.test.js +0 -0
  132. /package/{test → dsh-mneme/test}/reranker.test.js +0 -0
  133. /package/{test → dsh-mneme/test}/search-fusion.test.js +0 -0
  134. /package/{test → dsh-mneme/test}/semantic.test.js +0 -0
  135. /package/{test → dsh-mneme/test}/service-search.test.js +0 -0
  136. /package/{test → dsh-mneme/test}/service.test.js +0 -0
  137. /package/{test → dsh-mneme/test}/settings.test.js +0 -0
  138. /package/{test → dsh-mneme/test}/store.test.js +0 -0
  139. /package/{test → dsh-mneme/test}/stress.test.js +0 -0
  140. /package/{test → dsh-mneme/test}/summarize.test.js +0 -0
  141. /package/{test → dsh-mneme/test}/tag-boost.test.js +0 -0
  142. /package/{test → dsh-mneme/test}/tag.test.js +0 -0
  143. /package/{test → dsh-mneme/test}/tools.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,80 @@
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.0",
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": ["text", "lcov"]
79
+ }
80
+ }
@@ -458,7 +458,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
458
458
  name: n.name,
459
459
  type: n.type ?? null,
460
460
  mention_count: n.mention_count ?? 1,
461
- distance: n.distance
461
+ distance: n.distance,
462
+ // v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(max),前端据此
463
+ // 缩放节点大小/明暗。heatEnabled=false 时 entityHeat 返回 null。
464
+ heat: service.entityHeat?.(n.id) ?? null
462
465
  })),
463
466
  edges: [...edgeMap.values()].map((e) => ({
464
467
  id: e.id,
@@ -1,4 +1,5 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
+ import { TYPE_DECAY_DEFAULTS } from "./heat.js";
2
3
 
3
4
  export const Config = z.object({
4
5
  memoryDir: z.string().default("~/.dsh/memory"),
@@ -295,4 +296,20 @@ export const Config = z.object({
295
296
  // audits to recall_runs and NEVER touches recall_evals, regardless of this
296
297
  // flag (production isolation is unconditional).
297
298
  evalPersistTestResults: z.boolean().default(false),
299
+
300
+ // --- heat: v0.7.0 self-evolution (heat + interest drift) ----------------
301
+ // 总开关,默认开但保守:不改变召回排序,只提供热度字段 / sleep 降级
302
+ // 联合判定保护 / 前端热度投影。关闭后跳过所有 heat 计算与热度触达。
303
+ heatEnabled: z.boolean().default(true),
304
+ // 幂律形状参数 α(heat = 1/(1+λΔt)^α),越大衰减越快。
305
+ heatGlobalAlpha: z.number().min(0.1).max(5).default(1.2),
306
+ // per-type 衰减因子 λ;λ=0 的类型免疫(热度恒 1.0,sleep 永不降级)。
307
+ // 未知类型走默认 0.002。z.dict 的键为 type 字符串、值为数字 λ。
308
+ heatTypeDecay: z.dict(z.number(), z.string()).default({ ...TYPE_DECAY_DEFAULTS }),
309
+ // sleep 降级联合判定的热度下限:heat < 该值 且 importance<5 才允许降级。
310
+ sleepHeatThreshold: z.number().min(0).max(1).default(0.05),
311
+ // recordRecall 默认值(recall_runs 记录默认开;显式传 false 的调用方不受影响)。
312
+ recallRecordDefault: z.boolean().default(true),
313
+ // recall_runs 滚动清理保留天数。
314
+ recallRetentionDays: z.natural().min(1).max(3650).default(90),
298
315
  });
@@ -18,6 +18,7 @@ import { randomUUID, createHash } from "node:crypto";
18
18
  import { validateDecisions, applyDecisions } from "./decisions.js";
19
19
  import { findPotentialConflicts } from "./clustering.js";
20
20
  import { buildReceipt } from "../dream.js";
21
+ import { computeHeat } from "../heat.js";
21
22
 
22
23
  const SUMMARY_MAX = 120;
23
24
  // Conflict similarity threshold per strictness level (v0.4.0):
@@ -247,10 +248,21 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
247
248
  for (const m of service.all()) {
248
249
  if (signal?.aborted) break;
249
250
  if (m.archived || m.forgotten || m.session_disposed_at) continue;
250
- const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
251
+ // v0.7.0 语义修正:ref 只用 last_accessed_at,缺失退 created_at;不再
252
+ // fallback updated_at —— 合并/更新刷的 updated_at 不是访问,防止
253
+ // autoDream 合并动作重置新鲜度时钟而伪装成"刚被召回"。
254
+ const ref = m.last_accessed_at ?? m.created_at;
251
255
  if (!ref) continue;
252
256
  const t = new Date(ref).getTime();
253
257
  if (Number.isNaN(t)) continue;
258
+ // v0.7.0 热联合判定(待办③):时间窗之外再加两道保护闸——热度低于
259
+ // sleepHeatThreshold 且 importance<5 才允许降级。λ=0 的免疫类型 heat 恒
260
+ // 1.0 天然豁免(preference/pattern/summary 永不因 sleep 降级);importance
261
+ // ≥5 的紧要记忆无论多冷都保留。`冷但重要` 与 `热但低值` 均不满足条件。
262
+ const heat = computeHeat(m, Date.now(), config);
263
+ const heatProtected = heat >= (config.sleepHeatThreshold ?? 0.05);
264
+ const important = (m.importance ?? 0) >= 5;
265
+ if (heatProtected || important) continue;
254
266
  if (t < compressCut) {
255
267
  service.setArchived(m.id, true);
256
268
  archived.push(m.id);
@@ -0,0 +1,136 @@
1
+ // dsh-mneme/src/heat.js
2
+ // 热度(heat)纯函数模块:基于类遗忘曲线计算 memory 的当前热度。
3
+ // 零数据库依赖,不引入任何外部依赖。
4
+
5
+ /**
6
+ * 默认的 per-type 衰减因子 λ。
7
+ * λ = 0 表示该类型免疫热度衰减,热度恒为 1.0。
8
+ */
9
+ export const TYPE_DECAY_DEFAULTS = Object.freeze({
10
+ preference: 0, // 免疫:用户画像需长期保持
11
+ pattern: 0, // 免疫:发现型稳定规律
12
+ summary: 0, // 免疫:已是压缩产物
13
+ project: 0.0008, // 慢衰减
14
+ decision: 0.002, // 中速衰减
15
+ history: 0.006, // 较快(会话摘要不断被合并)
16
+ });
17
+
18
+ const HOUR_MS = 3600000;
19
+ const DEFAULT_ALPHA = 1.2;
20
+ const DEFAULT_LAMBDA = 0.002;
21
+
22
+ /**
23
+ * 将可能的日期值统一转成毫秒时间戳。
24
+ * 支持 Date、number、ISO 字符串;无法解析时返回 NaN。
25
+ */
26
+ function toTimestamp(value) {
27
+ if (value === null || value === undefined) return NaN;
28
+
29
+ if (value instanceof Date) {
30
+ return Number.isFinite(value.getTime()) ? value.getTime() : NaN;
31
+ }
32
+
33
+ if (typeof value === 'number') {
34
+ return Number.isFinite(value) ? value : NaN;
35
+ }
36
+
37
+ if (typeof value === 'string') {
38
+ const parsed = Date.parse(value);
39
+ return Number.isFinite(parsed) ? parsed : NaN;
40
+ }
41
+
42
+ return NaN;
43
+ }
44
+
45
+ /**
46
+ * 获取用于计算热度的参考时间点 ref。
47
+ * 优先取 last_accessed_at;缺失时退到 created_at。
48
+ * 绝不 fallback 到 updated_at。
49
+ */
50
+ function getRef(memory) {
51
+ if (!memory || typeof memory !== 'object') return NaN;
52
+ return toTimestamp(memory.last_accessed_at ?? memory.created_at);
53
+ }
54
+
55
+ /**
56
+ * 解析并校验配置,提供安全的 alpha 与衰减表。
57
+ */
58
+ function resolveConfig(config) {
59
+ const safe = config && typeof config === 'object' ? config : {};
60
+
61
+ let alpha = safe.heatGlobalAlpha ?? DEFAULT_ALPHA;
62
+ if (!Number.isFinite(alpha) || alpha <= 0) {
63
+ alpha = DEFAULT_ALPHA;
64
+ }
65
+
66
+ const decayMap = safe.heatTypeDecay ?? TYPE_DECAY_DEFAULTS;
67
+
68
+ return { alpha, decayMap };
69
+ }
70
+
71
+ /**
72
+ * 构建热度信号对象,便于调试与后续扩展。
73
+ *
74
+ * @param {object} memory - memory 记录
75
+ * @param {object} config - 插件配置
76
+ * @param {number} [now=Date.now()] - 当前毫秒时间戳
77
+ * @returns {{ type, ref, lambda, alpha, deltaHours }}
78
+ */
79
+ export function buildHeatSignals(memory, config, now = Date.now()) {
80
+ const nowMs = Number.isFinite(now) ? now : Date.now();
81
+ const ref = getRef(memory);
82
+ const { alpha, decayMap } = resolveConfig(config);
83
+
84
+ const type = memory?.type;
85
+
86
+ // 取对应类型的 λ,未知类型走默认
87
+ let lambda = decayMap[type];
88
+ if (!Number.isFinite(lambda)) {
89
+ lambda = DEFAULT_LAMBDA;
90
+ }
91
+ // λ < 0 视为非法,回退到默认;λ === 0 保留为免疫
92
+ if (lambda < 0) {
93
+ lambda = DEFAULT_LAMBDA;
94
+ }
95
+
96
+ let deltaHours;
97
+ if (!Number.isFinite(ref)) {
98
+ deltaHours = NaN;
99
+ } else if (nowMs < ref) {
100
+ deltaHours = 0;
101
+ } else {
102
+ deltaHours = (nowMs - ref) / HOUR_MS;
103
+ }
104
+
105
+ return { type, ref, lambda, alpha, deltaHours };
106
+ }
107
+
108
+ /**
109
+ * 计算 memory 的热度 H ∈ [0, 1]。
110
+ *
111
+ * 公式:H = 1 / (1 + λ · ΔtHours)^α
112
+ *
113
+ * @param {object} memory - memory 记录
114
+ * @param {number} [now=Date.now()] - 当前毫秒时间戳
115
+ * @param {object} [config={}] - 插件配置
116
+ * @returns {number} 热度值
117
+ */
118
+ export function computeHeat(memory, now = Date.now(), config = {}) {
119
+ const signals = buildHeatSignals(memory, config, now);
120
+ const { alpha, lambda, deltaHours, ref } = signals;
121
+
122
+ // 无有效参考时间、或未来时间,热度视为满格
123
+ if (!Number.isFinite(ref) || deltaHours <= 0) {
124
+ return 1.0;
125
+ }
126
+
127
+ // λ = 0 的类型免疫,热度恒满
128
+ if (lambda === 0) {
129
+ return 1.0;
130
+ }
131
+
132
+ const heat = 1 / Math.pow(1 + lambda * deltaHours, alpha);
133
+
134
+ // 防止浮点误差越界
135
+ return Math.min(1, Math.max(0, heat));
136
+ }
@@ -52,6 +52,13 @@ export const apply = (ctx, config) => {
52
52
  store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
53
53
  }
54
54
  } catch { /* non-fatal */ }
55
+ // v0.7.0: recall_runs 滚动清理 —— recordRecall 默认开且注入路径也记账,表随
56
+ // 活跃度增长;启动时按 recallRetentionDays(默认 90 天)清一次,防膨胀。
57
+ // 与 llm_audit 同策略:纯 bookkeeping,清理失败绝不阻塞插件启动。
58
+ try {
59
+ const recallRetentionDays = Number.isInteger(cfg.recallRetentionDays) ? cfg.recallRetentionDays : 90;
60
+ store.purgeRecallRunsOlderThan(recallRetentionDays);
61
+ } catch { /* non-fatal */ }
55
62
  const mirror = createMirror(memoryDir);
56
63
  const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
57
64
 
@@ -5,6 +5,7 @@ import { createBM25Index } from "./search/bm25.js";
5
5
  import { adaptiveThreshold } from "./search/adaptive.js";
6
6
  import { parseWikiLinks } from "./parser/wiki-link.js";
7
7
  import { extractQueryTags, applyTagBoost } from "./search/tag-boost.js";
8
+ import { computeHeat } from "./heat.js";
8
9
 
9
10
  const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
10
11
 
@@ -467,14 +468,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
467
468
  }
468
469
 
469
470
  /**
470
- * Sleep touch (v0.4.0): when sleep is enabled, any memory surfaced by recall
471
- * or auto-injection gets its last_accessed_at bumped, so the "unrecalled N
472
- * days demote/archive" tiering counts real access. Best-effort and gated on
473
- * config.sleepModeEnabled when sleep is off this is a complete no-op (no
474
- * writes on the hot recall path). A touch failure must never break search/inject.
471
+ * Recall touch (v0.7.0 数据前提): any memory surfaced by recall or
472
+ * auto-injection gets its last_accessed_at bumped so the heat model has a
473
+ * truthful access clock. Previously gated on config.sleepModeEnabled now
474
+ * gated on config.heatEnabled (default on) so heat data is collected
475
+ * independently of sleep mode; sleep consumes it when it runs. Best-effort:
476
+ * a touch failure must never break search/inject.
475
477
  */
476
478
  function touchRecalled(memories) {
477
- if (config?.sleepModeEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
479
+ if (config?.heatEnabled === false || !Array.isArray(memories) || memories.length === 0) return;
478
480
  for (const m of memories) {
479
481
  if (!m?.id) continue;
480
482
  try {
@@ -484,7 +486,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
484
486
  }
485
487
 
486
488
  async function searchMemories(query, options = {}) {
487
- const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = options;
489
+ // v0.7.0: recall_runs 记录默认开(config.recallRecordDefault,默认 true);
490
+ // 显式传 recordRecall:false 或配置 recallRecordDefault:false 可关。
491
+ const {
492
+ mode = "auto",
493
+ topK = 20,
494
+ threshold,
495
+ useRerank = true,
496
+ recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true)
497
+ } = options;
488
498
  const raw = String(query ?? "").trim();
489
499
  if (!raw) return [];
490
500
 
@@ -683,12 +693,16 @@ export function createService({ store, mirror, config, onWrite, logger }) {
683
693
  mode,
684
694
  topK: lim,
685
695
  threshold: threshold ?? null,
696
+ // v0.7.0 recall-layer 强度标记:搜索帧一律 injected:false(被召回 =
697
+ // 检索直接命中);注入上下文帧由 injectCandidates 以 injected:true
698
+ // 单独记账(被注入 = 替对话搭建上下文),两档强度可区分。
686
699
  candidates: result.map((m) => ({
687
700
  id: m.id,
688
701
  title: m.title,
689
702
  content: m.content,
690
703
  score: m.score ?? null,
691
- source: m.source ?? "keyword"
704
+ source: m.source ?? "keyword",
705
+ injected: false
692
706
  })),
693
707
  createdAt: new Date().toISOString()
694
708
  });
@@ -1060,6 +1074,32 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1060
1074
  } catch { /* topic re-rank unavailable: keep rule-based order */ }
1061
1075
  }
1062
1076
  const selected = candidates.slice(0, maxItems);
1077
+ // v0.7.0 recall-layer 强度标记:注入场景也走 recall_runs 记账(mode="inject"、
1078
+ // injected:true),与 searchMemories 的搜索帧(injected:false)互补,区分
1079
+ // "被召回"与"被注入上下文"两个消耗强度。与搜索侧同受 recallRecordDefault
1080
+ // 门控;记账失败绝不阻断注入本身。
1081
+ if (recallRecorder && config.recallRecordDefault !== false) {
1082
+ try {
1083
+ recallRecorder({
1084
+ query: q,
1085
+ mode: "inject",
1086
+ topK: selected.length,
1087
+ threshold: null,
1088
+ candidates: selected.map((m) => ({
1089
+ id: m.id,
1090
+ title: m.title,
1091
+ content: m.content,
1092
+ score: m.score ?? null,
1093
+ // 注入帧的 source 固定为帧级标记 "inject",与搜索帧的
1094
+ // keyword/vector/rerank 对齐(记忆行自带的 source 是来源列,
1095
+ // 与检索层级无关,不混入)。
1096
+ source: "inject",
1097
+ injected: true
1098
+ })),
1099
+ createdAt: new Date().toISOString()
1100
+ });
1101
+ } catch { /* recall receipt is best effort */ }
1102
+ }
1063
1103
  touchRecalled(selected);
1064
1104
  return selected;
1065
1105
  }
@@ -1696,6 +1736,21 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1696
1736
  saveWikiLinks: (r) => store.saveWikiLinks(r),
1697
1737
  listEntities: (o) => store.listEntities(o),
1698
1738
  getRelations: (id) => store.getRelations(id),
1739
+ // v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(取 max)。无关联记忆
1740
+ // 或 heatEnabled=false 时返回 null;前端据此决定图谱节点大小/明暗。
1741
+ entityHeat: (entityId) => {
1742
+ if (config.heatEnabled === false) return null;
1743
+ const rels = store.getRelations(entityId) ?? [];
1744
+ let max = -Infinity;
1745
+ for (const rel of rels) {
1746
+ if (!rel.memory_id) continue;
1747
+ const mem = store.getById(rel.memory_id);
1748
+ if (!mem) continue;
1749
+ const h = computeHeat(mem, Date.now(), config);
1750
+ if (h > max) max = h;
1751
+ }
1752
+ return max === -Infinity ? null : max;
1753
+ },
1699
1754
  // Tag system (v0.6.2). setMemoryTags is the manual/user path: gated by
1700
1755
  // config.manualTagEnabled (default true), re-renders the mirror and fires
1701
1756
  // the write hook. applyMemoryTags is the raw write used by the autoDream
@@ -725,6 +725,9 @@ export function createStore(path) {
725
725
  return getById(id);
726
726
  }
727
727
 
728
+ // v0.7.0 语义:updated_at 只记录内容变更,绝不当访问(与 last_accessed_at
729
+ // 正交,sleep/heat 永不 fallback 到它)。合并/更新刷 updated_at 不应
730
+ // 重置热度时钟 —— 防 autoDream 合并动作伪装成"刚被召回"。
728
731
  function update(id, patch) {
729
732
  const existing = getById(id);
730
733
  if (!existing) throw new Error(`memory not found: ${id}`);
@@ -1288,6 +1291,19 @@ export function createStore(path) {
1288
1291
  return rows.map(toRecallRun);
1289
1292
  }
1290
1293
 
1294
+ /**
1295
+ * Rolling purge of the recall_runs production audit (v0.7.0): with recordRecall
1296
+ * defaulting ON, the table would otherwise grow unboundedly on a busy store.
1297
+ * Drop rows older than `days` (config.recallRetentionDays, default 90).
1298
+ * created_at is an ISO TEXT string, so the ISO bound compares lexicographically.
1299
+ * Returns the number of rows deleted (bookkeeping — never throws on empty).
1300
+ */
1301
+ function purgeRecallRunsOlderThan(days) {
1302
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString();
1303
+ const result = db.prepare("DELETE FROM recall_runs WHERE created_at < ?").run(cutoff);
1304
+ return result.changes;
1305
+ }
1306
+
1291
1307
  // --- recall evaluation trail (方案 B: separate from the production audit) -
1292
1308
 
1293
1309
  /**
@@ -2185,6 +2201,7 @@ export function createStore(path) {
2185
2201
  saveRecallRun,
2186
2202
  getRecallRun,
2187
2203
  listRecallRuns,
2204
+ purgeRecallRunsOlderThan,
2188
2205
  saveRecallEval,
2189
2206
  getRecallEval,
2190
2207
  listRecallEvals,
@@ -173,3 +173,37 @@ test("ego 2-hop over 100+ nodes stays under 50ms (spec §7)", async () => {
173
173
  assert.ok(data.edges.length > 0);
174
174
  assert.ok(ms < 50, `2-hop query took ${ms.toFixed(1)}ms, spec budget is 50ms`);
175
175
  });
176
+
177
+ test("ego node heat projects from linked memory heat (v0.7.0)", async () => {
178
+ const { routes, service } = setup();
179
+ const mem = service.saveWithDedupe({ type: "history", title: "test", content: "test entity heat", importance: 3 });
180
+ const entity = service.createEntity({ name: "E", type: "project" });
181
+ service.saveRelation({ from_entity: entity.id, to_entity: entity.id, relation_type: "evidence", memory_id: mem.memory.id });
182
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/graph/ego");
183
+ assert.ok(route, "ego route exists");
184
+ const res = new FakeRes();
185
+ await route.handler(req("/api/dsh-mneme/semantic/graph/ego?entity=E"), res);
186
+ const data = JSON.parse(res.body);
187
+ const node = data.nodes.find((n) => n.name === "E");
188
+ assert.ok(typeof node.heat === "number" && node.heat >= 0 && node.heat <= 1, "entity node has a heat value");
189
+ assert.ok(Math.abs(node.heat - 1) < 0.01, "fresh memory -> heat ~1.0 -> entity heat ~1.0");
190
+ });
191
+
192
+ test("ego node heat is null when heatEnabled=false (v0.7.0)", async () => {
193
+ const store = createStore(":memory:");
194
+ const service = createService({ store, mirror: null, config: { heatEnabled: false } });
195
+ const settings = createSettings(store.db);
196
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
197
+ const routes = [];
198
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
199
+ createApi(ctx, service, settings, commands, null, undefined, "");
200
+ const mem = service.saveWithDedupe({ type: "history", title: "关联记忆", content: "实体关联", importance: 3 });
201
+ const entity = service.createEntity({ name: "F", type: "project" });
202
+ service.saveRelation({ from_entity: entity.id, to_entity: entity.id, relation_type: "evidence", memory_id: mem.memory.id });
203
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/graph/ego");
204
+ const res = new FakeRes();
205
+ await route.handler(req("/api/dsh-mneme/semantic/graph/ego?entity=F"), res);
206
+ const data = JSON.parse(res.body);
207
+ assert.equal(data.nodes.find((n) => n.name === "F").heat, null, "heat disabled → entity heat is null");
208
+ store.close();
209
+ });
@@ -0,0 +1,148 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ computeHeat,
6
+ buildHeatSignals,
7
+ TYPE_DECAY_DEFAULTS,
8
+ } from '../src/heat.js';
9
+
10
+ const HOUR = 3600000;
11
+
12
+ describe('heat.js', () => {
13
+ test('未知类型使用默认 λ=0.002;72h 后 heat < 1 且 > 0.8,并随 Δt 单调递减', () => {
14
+ const now = Date.now();
15
+ const base = { type: 'episodic', last_accessed_at: now - 72 * HOUR };
16
+
17
+ const heat72 = computeHeat(base, now, {});
18
+ const heat144 = computeHeat(
19
+ { ...base, last_accessed_at: now - 144 * HOUR },
20
+ now,
21
+ {}
22
+ );
23
+
24
+ assert(heat72 < 1.0, '72h 后热度应小于 1');
25
+ assert(heat72 > 0.8, '72h 后热度应仍大于 0.8');
26
+ assert(heat144 < heat72, 'Δt 越大,热度应越低');
27
+ });
28
+
29
+ test('λ=0 的免疫类型任意 Δt 返回 1.0', () => {
30
+ const now = Date.now();
31
+ const config = { heatTypeDecay: { preference: 0 } };
32
+
33
+ assert.strictEqual(
34
+ computeHeat(
35
+ { type: 'preference', last_accessed_at: now - 999 * 24 * HOUR },
36
+ now,
37
+ config
38
+ ),
39
+ 1.0
40
+ );
41
+
42
+ assert.strictEqual(
43
+ computeHeat(
44
+ { type: 'pattern', last_accessed_at: now - 365 * 24 * HOUR },
45
+ now,
46
+ { heatTypeDecay: TYPE_DECAY_DEFAULTS }
47
+ ),
48
+ 1.0
49
+ );
50
+ });
51
+
52
+ test('ref 优先使用 last_accessed_at,缺失退 created_at,皆无返回 1.0', () => {
53
+ const now = Date.now();
54
+ const config = {
55
+ heatTypeDecay: { decision: 0.002 },
56
+ heatGlobalAlpha: 1.0,
57
+ };
58
+
59
+ const withLast = computeHeat(
60
+ {
61
+ type: 'decision',
62
+ last_accessed_at: now - 24 * HOUR,
63
+ created_at: now - 100 * HOUR,
64
+ },
65
+ now,
66
+ config
67
+ );
68
+
69
+ const withCreated = computeHeat(
70
+ { type: 'decision', created_at: now - 24 * HOUR },
71
+ now,
72
+ config
73
+ );
74
+
75
+ assert(withLast < 1.0);
76
+ assert.strictEqual(withLast, withCreated);
77
+
78
+ const noRef = computeHeat({ type: 'decision' }, now, config);
79
+ assert.strictEqual(noRef, 1.0);
80
+ });
81
+
82
+ test('非法 ref 或 now < ref 时返回 1.0', () => {
83
+ const now = Date.now();
84
+
85
+ assert.strictEqual(
86
+ computeHeat(
87
+ { type: 'decision', last_accessed_at: 'not-a-date' },
88
+ now,
89
+ {}
90
+ ),
91
+ 1.0
92
+ );
93
+
94
+ assert.strictEqual(
95
+ computeHeat(
96
+ { type: 'decision', last_accessed_at: now + 1000 },
97
+ now,
98
+ {}
99
+ ),
100
+ 1.0
101
+ );
102
+ });
103
+
104
+ test('α 越大衰减越快(同一 Δt 下 α=2 的热度低于 α=1)', () => {
105
+ const now = Date.now();
106
+ const base = {
107
+ type: 'decision',
108
+ last_accessed_at: now - 7 * 24 * HOUR,
109
+ };
110
+
111
+ const h1 = computeHeat(base, now, {
112
+ heatTypeDecay: { decision: 0.002 },
113
+ heatGlobalAlpha: 1.0,
114
+ });
115
+
116
+ const h2 = computeHeat(base, now, {
117
+ heatTypeDecay: { decision: 0.002 },
118
+ heatGlobalAlpha: 2.0,
119
+ });
120
+
121
+ assert(h2 < h1, 'α 更大时,同一 Δt 热度应更低');
122
+ });
123
+
124
+ test('buildHeatSignals 返回字段齐全且 deltaHours 正确', () => {
125
+ const now = 1000000000000; // 固定毫秒时间戳
126
+ const ref = now - 12 * HOUR;
127
+
128
+ const signals = buildHeatSignals(
129
+ { type: 'project', last_accessed_at: ref },
130
+ { heatGlobalAlpha: 1.5 },
131
+ now
132
+ );
133
+
134
+ assert.deepStrictEqual(Object.keys(signals).sort(), [
135
+ 'alpha',
136
+ 'deltaHours',
137
+ 'lambda',
138
+ 'ref',
139
+ 'type',
140
+ ]);
141
+
142
+ assert.strictEqual(signals.type, 'project');
143
+ assert.strictEqual(signals.lambda, 0.0008);
144
+ assert.strictEqual(signals.alpha, 1.5);
145
+ assert.strictEqual(signals.ref, ref);
146
+ assert.strictEqual(signals.deltaHours, 12);
147
+ });
148
+ });