@modusensus/dsh-mneme 0.6.11 → 0.7.1

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 +152 -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 +295 -0
  13. package/dsh-mneme/LICENSE +21 -0
  14. package/dsh-mneme/README.md +484 -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 +10 -4
  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/{lib → dsh-mneme/lib}/dream.js +7 -2
  26. package/dsh-mneme/lib/heat.js +136 -0
  27. package/{lib → dsh-mneme/lib}/index.js +14 -6
  28. package/{src → dsh-mneme/lib}/service.js +100 -11
  29. package/{lib → dsh-mneme/lib}/settings.js +15 -6
  30. package/{lib → dsh-mneme/lib}/store.js +22 -0
  31. package/dsh-mneme/package-lock.json +1936 -0
  32. package/dsh-mneme/package.json +80 -0
  33. package/{lib → dsh-mneme/src}/api.js +10 -4
  34. package/{src → dsh-mneme/src}/config.js +17 -0
  35. package/{src → dsh-mneme/src}/dream/sleep.js +13 -1
  36. package/{src → dsh-mneme/src}/dream.js +7 -2
  37. package/dsh-mneme/src/heat.js +136 -0
  38. package/{src → dsh-mneme/src}/index.js +14 -6
  39. package/{lib → dsh-mneme/src}/service.js +100 -11
  40. package/{src → dsh-mneme/src}/settings.js +15 -6
  41. package/{src → dsh-mneme/src}/store.js +22 -0
  42. package/{test → dsh-mneme/test}/api.test.js +10 -3
  43. package/{test → dsh-mneme/test}/graph-api.test.js +34 -0
  44. package/dsh-mneme/test/heat.test.js +148 -0
  45. package/{test → dsh-mneme/test}/recall-layer.test.js +26 -5
  46. package/dsh-mneme/test/recall-runs.test.js +93 -0
  47. package/{test → dsh-mneme/test}/settings.test.js +6 -2
  48. package/dsh-mneme/test/sleep-heat.test.js +112 -0
  49. package/{test → dsh-mneme/test}/sleep.test.js +12 -4
  50. package/{test → dsh-mneme/test}/tag.test.js +114 -0
  51. package/dsh-mneme/test/updated-at-semantics.test.js +113 -0
  52. package/package.json +18 -40
  53. package/v0.7-plan-2026-08-21.md +75 -0
  54. package//346/250/252/345/271/205.png +0 -0
  55. /package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +0 -0
  56. /package/{lib → dsh-mneme/lib}/commands.js +0 -0
  57. /package/{lib → dsh-mneme/lib}/dream/clustering.js +0 -0
  58. /package/{lib → dsh-mneme/lib}/dream/decisions.js +0 -0
  59. /package/{lib → dsh-mneme/lib}/dream/tag-extractor.js +0 -0
  60. /package/{lib → dsh-mneme/lib}/embedding.js +0 -0
  61. /package/{lib → dsh-mneme/lib}/entities/extractor.js +0 -0
  62. /package/{lib → dsh-mneme/lib}/hot-memory.js +0 -0
  63. /package/{lib → dsh-mneme/lib}/inject.js +0 -0
  64. /package/{lib → dsh-mneme/lib}/local-embedder.js +0 -0
  65. /package/{lib → dsh-mneme/lib}/mirror.js +0 -0
  66. /package/{lib → dsh-mneme/lib}/parser/tag.js +0 -0
  67. /package/{lib → dsh-mneme/lib}/parser/wiki-link.js +0 -0
  68. /package/{lib → dsh-mneme/lib}/quality-filter.js +0 -0
  69. /package/{lib → dsh-mneme/lib}/reranker.js +0 -0
  70. /package/{lib → dsh-mneme/lib}/search/adaptive.js +0 -0
  71. /package/{lib → dsh-mneme/lib}/search/bm25.js +0 -0
  72. /package/{lib → dsh-mneme/lib}/search/tag-boost.js +0 -0
  73. /package/{lib → dsh-mneme/lib}/summarize.js +0 -0
  74. /package/{lib → dsh-mneme/lib}/tools.js +0 -0
  75. /package/{lib → dsh-mneme/lib}/vector-index.js +0 -0
  76. /package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +0 -0
  77. /package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +0 -0
  78. /package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +0 -0
  79. /package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +0 -0
  80. /package/{scripts → dsh-mneme/scripts}/stress-dsh.js +0 -0
  81. /package/{scripts → dsh-mneme/scripts}/sync-lib.js +0 -0
  82. /package/{src → dsh-mneme/src}/commands.js +0 -0
  83. /package/{src → dsh-mneme/src}/dream/clustering.js +0 -0
  84. /package/{src → dsh-mneme/src}/dream/decisions.js +0 -0
  85. /package/{src → dsh-mneme/src}/dream/tag-extractor.js +0 -0
  86. /package/{src → dsh-mneme/src}/embedding.js +0 -0
  87. /package/{src → dsh-mneme/src}/entities/extractor.js +0 -0
  88. /package/{src → dsh-mneme/src}/hot-memory.js +0 -0
  89. /package/{src → dsh-mneme/src}/inject.js +0 -0
  90. /package/{src → dsh-mneme/src}/local-embedder.js +0 -0
  91. /package/{src → dsh-mneme/src}/mirror.js +0 -0
  92. /package/{src → dsh-mneme/src}/parser/tag.js +0 -0
  93. /package/{src → dsh-mneme/src}/parser/wiki-link.js +0 -0
  94. /package/{src → dsh-mneme/src}/quality-filter.js +0 -0
  95. /package/{src → dsh-mneme/src}/reranker.js +0 -0
  96. /package/{src → dsh-mneme/src}/search/adaptive.js +0 -0
  97. /package/{src → dsh-mneme/src}/search/bm25.js +0 -0
  98. /package/{src → dsh-mneme/src}/search/tag-boost.js +0 -0
  99. /package/{src → dsh-mneme/src}/summarize.js +0 -0
  100. /package/{src → dsh-mneme/src}/tools.js +0 -0
  101. /package/{src → dsh-mneme/src}/vector-index.js +0 -0
  102. /package/{test → dsh-mneme/test}/audit.test.js +0 -0
  103. /package/{test → dsh-mneme/test}/benchmark.test.js +0 -0
  104. /package/{test → dsh-mneme/test}/boundary-v0625.test.js +0 -0
  105. /package/{test → dsh-mneme/test}/client.test.js +0 -0
  106. /package/{test → dsh-mneme/test}/clustering.test.js +0 -0
  107. /package/{test → dsh-mneme/test}/commands.test.js +0 -0
  108. /package/{test → dsh-mneme/test}/config.test.js +0 -0
  109. /package/{test → dsh-mneme/test}/conflict-freeze.test.js +0 -0
  110. /package/{test → dsh-mneme/test}/directory.test.js +0 -0
  111. /package/{test → dsh-mneme/test}/dream.test.js +0 -0
  112. /package/{test → dsh-mneme/test}/entities.test.js +0 -0
  113. /package/{test → dsh-mneme/test}/epistemic.test.js +0 -0
  114. /package/{test → dsh-mneme/test}/fnew-0112.test.js +0 -0
  115. /package/{test → dsh-mneme/test}/fnew-03.test.js +0 -0
  116. /package/{test → dsh-mneme/test}/helpers/dream-mock.js +0 -0
  117. /package/{test → dsh-mneme/test}/hot-memory.test.js +0 -0
  118. /package/{test → dsh-mneme/test}/inject.test.js +0 -0
  119. /package/{test → dsh-mneme/test}/llm-audit.test.js +0 -0
  120. /package/{test → dsh-mneme/test}/local-embedder.test.js +0 -0
  121. /package/{test → dsh-mneme/test}/mirror-dirty.test.js +0 -0
  122. /package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +0 -0
  123. /package/{test → dsh-mneme/test}/mirror-generation.test.js +0 -0
  124. /package/{test → dsh-mneme/test}/mirror.test.js +0 -0
  125. /package/{test → dsh-mneme/test}/normalize-decisions.test.js +0 -0
  126. /package/{test → dsh-mneme/test}/peer-blockers.test.js +0 -0
  127. /package/{test → dsh-mneme/test}/policy-epoch.test.js +0 -0
  128. /package/{test → dsh-mneme/test}/provenance.test.js +0 -0
  129. /package/{test → dsh-mneme/test}/quality-filter.test.js +0 -0
  130. /package/{test → dsh-mneme/test}/reasoning-effort.test.js +0 -0
  131. /package/{test → dsh-mneme/test}/recall-evals.test.js +0 -0
  132. /package/{test → dsh-mneme/test}/receipt-chain.test.js +0 -0
  133. /package/{test → dsh-mneme/test}/reflection.test.js +0 -0
  134. /package/{test → dsh-mneme/test}/reranker.test.js +0 -0
  135. /package/{test → dsh-mneme/test}/search-fusion.test.js +0 -0
  136. /package/{test → dsh-mneme/test}/semantic.test.js +0 -0
  137. /package/{test → dsh-mneme/test}/service-search.test.js +0 -0
  138. /package/{test → dsh-mneme/test}/service.test.js +0 -0
  139. /package/{test → dsh-mneme/test}/store.test.js +0 -0
  140. /package/{test → dsh-mneme/test}/stress.test.js +0 -0
  141. /package/{test → dsh-mneme/test}/summarize.test.js +0 -0
  142. /package/{test → dsh-mneme/test}/tag-boost.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
@@ -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);
@@ -559,7 +559,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
559
559
  if (inFlight) await inFlight.catch(() => {});
560
560
  }
561
561
 
562
- async function runDream(ctx, service, config) {
562
+ async function runDream(ctx, service, config, settings) {
563
563
  const logger = ctx.logger;
564
564
  let memories = service.all().filter((m) => !m.archived && m.type !== "summary");
565
565
  if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
@@ -838,7 +838,12 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
838
838
  // and always fail-safe — a tag failure must never change the consolidation
839
839
  // outcome reported below (it is logged and counted, nothing more).
840
840
  let autoTagged = 0;
841
- if (config.autoTagEnabled === true) {
841
+ // Effective opt-in (issue #31): the Web panel's settings toggle
842
+ // (user_settings) overrides the zod plugin config when explicitly stored;
843
+ // unset → fall back to config.autoTagEnabled. settings is null-safe (absent
844
+ // in tests / direct calls) so behaviour is unchanged without it.
845
+ const autoTagEnabled = settings?.getAutoTagConfig?.().autoTagEnabled ?? config.autoTagEnabled;
846
+ if (autoTagEnabled === true) {
842
847
  try {
843
848
  const tagResult = await runAutoTag({ ctx, service, config, route });
844
849
  autoTagged = tagResult?.tagged ?? 0;
@@ -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,8 +52,20 @@ 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
- const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
63
+ // User-configurable settings (profile, rules) and custom commands share the
64
+ // same SQLite file but live in dedicated tables, isolated from memories.
65
+ // Created before the service so the tag-config resolver (issue #31) can merge
66
+ // the Web panel's persisted toggles over the plugin config at runtime.
67
+ const settings = createSettings(store.db);
68
+ const service = createService({ store, mirror, config: cfg, logger: ctx.logger, settings });
57
69
 
58
70
  // F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
59
71
  // a safe re-render at boot so a stale mirror converges without needing a
@@ -78,10 +90,6 @@ export const apply = (ctx, config) => {
78
90
  } catch { /* non-fatal: recall recording is bookkeeping */ }
79
91
  });
80
92
 
81
- // User-configurable settings (profile, rules) and custom commands share the
82
- // same SQLite file but live in dedicated tables, isolated from memories.
83
- const settings = createSettings(store.db);
84
-
85
93
  // Semantic pipeline: a local/ollama embedder when configured, otherwise the
86
94
  // legacy OpenAI-compatible embedder (settings-driven). The vector index wraps
87
95
  // the store's embedding column and tracks the active model fingerprint. A
@@ -244,7 +252,7 @@ export const apply = (ctx, config) => {
244
252
  delayMs: cfg.dreamDelayMs,
245
253
  logger: ctx.logger,
246
254
  semantic: { embedder, vectorIndex },
247
- onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
255
+ onRun: () => (dream ? dream.runDream(ctx, service, cfg, settings) : Promise.resolve({ ok: true, skipped: true }))
248
256
  });
249
257
  service.setDreamHook(() => dream.maybeSchedule(service));
250
258
  }
@@ -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
 
@@ -64,7 +65,7 @@ export function computeRetrievalMetrics(actualIds, expectedIds) {
64
65
  };
65
66
  }
66
67
 
67
- export function createService({ store, mirror, config, onWrite, logger }) {
68
+ export function createService({ store, mirror, config, onWrite, logger, settings }) {
68
69
  // Optional dream scheduler hook, installed via setDreamHook after creation
69
70
  // (the scheduler holds a reference back to the service, so it cannot be
70
71
  // passed in the constructor). Fired on the same write events as onWrite.
@@ -90,6 +91,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
90
91
  let vectorIndex = null;
91
92
  let reranker = null;
92
93
 
94
+ // Effective tag toggles (issue #31): the settings layer (Web panel switches,
95
+ // persisted in user_settings) overrides the plugin config when explicitly
96
+ // stored; unset keys fall back to the zod config default. This is the single
97
+ // runtime source of truth for setMemoryTags' manual gate and the Web panel's
98
+ // getTagConfig display — the panel toggle and the dream autoTag gate were
99
+ // previously writing/reading two disconnected stores.
100
+ const tagConfig = () => {
101
+ const stored = settings?.getAutoTagConfig?.() ?? {};
102
+ return {
103
+ autoTagEnabled: typeof stored.autoTagEnabled === "boolean" ? stored.autoTagEnabled : (config.autoTagEnabled ?? false),
104
+ manualTagEnabled: typeof stored.manualTagEnabled === "boolean" ? stored.manualTagEnabled : (config.manualTagEnabled ?? true)
105
+ };
106
+ };
107
+
93
108
  // Optional recall recorder, installed via setRecallRecorder after creation.
94
109
  // When searchMemories is called with recordRecall=true it receives the
95
110
  // actual merged recall scene (candidates + scores + source + threshold) so
@@ -467,14 +482,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
467
482
  }
468
483
 
469
484
  /**
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.
485
+ * Recall touch (v0.7.0 数据前提): any memory surfaced by recall or
486
+ * auto-injection gets its last_accessed_at bumped so the heat model has a
487
+ * truthful access clock. Previously gated on config.sleepModeEnabled now
488
+ * gated on config.heatEnabled (default on) so heat data is collected
489
+ * independently of sleep mode; sleep consumes it when it runs. Best-effort:
490
+ * a touch failure must never break search/inject.
475
491
  */
476
492
  function touchRecalled(memories) {
477
- if (config?.sleepModeEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
493
+ if (config?.heatEnabled === false || !Array.isArray(memories) || memories.length === 0) return;
478
494
  for (const m of memories) {
479
495
  if (!m?.id) continue;
480
496
  try {
@@ -484,7 +500,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
484
500
  }
485
501
 
486
502
  async function searchMemories(query, options = {}) {
487
- const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = options;
503
+ // v0.7.0: recall_runs 记录默认开(config.recallRecordDefault,默认 true);
504
+ // 显式传 recordRecall:false 或配置 recallRecordDefault:false 可关。
505
+ const {
506
+ mode = "auto",
507
+ topK = 20,
508
+ threshold,
509
+ useRerank = true,
510
+ recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true)
511
+ } = options;
488
512
  const raw = String(query ?? "").trim();
489
513
  if (!raw) return [];
490
514
 
@@ -683,12 +707,16 @@ export function createService({ store, mirror, config, onWrite, logger }) {
683
707
  mode,
684
708
  topK: lim,
685
709
  threshold: threshold ?? null,
710
+ // v0.7.0 recall-layer 强度标记:搜索帧一律 injected:false(被召回 =
711
+ // 检索直接命中);注入上下文帧由 injectCandidates 以 injected:true
712
+ // 单独记账(被注入 = 替对话搭建上下文),两档强度可区分。
686
713
  candidates: result.map((m) => ({
687
714
  id: m.id,
688
715
  title: m.title,
689
716
  content: m.content,
690
717
  score: m.score ?? null,
691
- source: m.source ?? "keyword"
718
+ source: m.source ?? "keyword",
719
+ injected: false
692
720
  })),
693
721
  createdAt: new Date().toISOString()
694
722
  });
@@ -919,6 +947,13 @@ export function createService({ store, mirror, config, onWrite, logger }) {
919
947
  });
920
948
  // Bug7: a degraded/archived result is applied on top of the merged row.
921
949
  const result = applyQualityDisposition(merged, quality, qf);
950
+ // Bridge tool-passed tags (plus any quality signal tags) into the
951
+ // entity_attrs tag store so directory / tag: recall / tagBoost see them
952
+ // (issue #31). Must run before afterSync so the mirror re-render reads
953
+ // the bridged row. Unconditional on the array (empty included): a merge
954
+ // that clears tags must also invalidate any stale entity_attrs row from
955
+ // an earlier save, or the directory keeps showing the old tag.
956
+ if (Array.isArray(result.tags)) store.setMemoryTags(result.id, result.tags);
922
957
  afterSync("write");
923
958
  notifyWrite();
924
959
  scheduleEmbed(result);
@@ -938,6 +973,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
938
973
  ...(quality ? { quality_score: quality.score } : {})
939
974
  });
940
975
  const result = applyQualityDisposition(created, quality, qf);
976
+ // Bridge tool-passed tags (plus any quality signal tags) into the
977
+ // entity_attrs tag store so directory / tag: recall / tagBoost see them
978
+ // (issue #31). Must run before afterSync so the mirror re-render reads
979
+ // the bridged row.
980
+ if (Array.isArray(result.tags) && result.tags.length) store.setMemoryTags(result.id, result.tags);
941
981
  afterSync("write");
942
982
  notifyWrite();
943
983
  scheduleEmbed(result);
@@ -1060,6 +1100,32 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1060
1100
  } catch { /* topic re-rank unavailable: keep rule-based order */ }
1061
1101
  }
1062
1102
  const selected = candidates.slice(0, maxItems);
1103
+ // v0.7.0 recall-layer 强度标记:注入场景也走 recall_runs 记账(mode="inject"、
1104
+ // injected:true),与 searchMemories 的搜索帧(injected:false)互补,区分
1105
+ // "被召回"与"被注入上下文"两个消耗强度。与搜索侧同受 recallRecordDefault
1106
+ // 门控;记账失败绝不阻断注入本身。
1107
+ if (recallRecorder && config.recallRecordDefault !== false) {
1108
+ try {
1109
+ recallRecorder({
1110
+ query: q,
1111
+ mode: "inject",
1112
+ topK: selected.length,
1113
+ threshold: null,
1114
+ candidates: selected.map((m) => ({
1115
+ id: m.id,
1116
+ title: m.title,
1117
+ content: m.content,
1118
+ score: m.score ?? null,
1119
+ // 注入帧的 source 固定为帧级标记 "inject",与搜索帧的
1120
+ // keyword/vector/rerank 对齐(记忆行自带的 source 是来源列,
1121
+ // 与检索层级无关,不混入)。
1122
+ source: "inject",
1123
+ injected: true
1124
+ })),
1125
+ createdAt: new Date().toISOString()
1126
+ });
1127
+ } catch { /* recall receipt is best effort */ }
1128
+ }
1063
1129
  touchRecalled(selected);
1064
1130
  return selected;
1065
1131
  }
@@ -1558,6 +1624,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1558
1624
  update: (id, p, ctx = {}) => {
1559
1625
  const old = store.getById(id);
1560
1626
  const updated = store.update(id, p);
1627
+ // Bridge tag edits made through update (memory_update tool / panel) into
1628
+ // the entity_attrs tag store too (issue #31) — same convergence as
1629
+ // saveWithDedupe. An explicit empty array clears the live tag row, moving
1630
+ // the memory back to `untagged`.
1631
+ if (p?.tags !== undefined) store.setMemoryTags(updated.id, updated.tags);
1561
1632
  // Record a user correction when any meaningful field changed and the
1562
1633
  // reflection failure tracker is enabled. expected = what it became,
1563
1634
  // actual = what it was before; query (when provided) captures the
@@ -1696,13 +1767,28 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1696
1767
  saveWikiLinks: (r) => store.saveWikiLinks(r),
1697
1768
  listEntities: (o) => store.listEntities(o),
1698
1769
  getRelations: (id) => store.getRelations(id),
1770
+ // v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(取 max)。无关联记忆
1771
+ // 或 heatEnabled=false 时返回 null;前端据此决定图谱节点大小/明暗。
1772
+ entityHeat: (entityId) => {
1773
+ if (config.heatEnabled === false) return null;
1774
+ const rels = store.getRelations(entityId) ?? [];
1775
+ let max = -Infinity;
1776
+ for (const rel of rels) {
1777
+ if (!rel.memory_id) continue;
1778
+ const mem = store.getById(rel.memory_id);
1779
+ if (!mem) continue;
1780
+ const h = computeHeat(mem, Date.now(), config);
1781
+ if (h > max) max = h;
1782
+ }
1783
+ return max === -Infinity ? null : max;
1784
+ },
1699
1785
  // Tag system (v0.6.2). setMemoryTags is the manual/user path: gated by
1700
1786
  // config.manualTagEnabled (default true), re-renders the mirror and fires
1701
1787
  // the write hook. applyMemoryTags is the raw write used by the autoDream
1702
1788
  // tag pass (gated by autoTagEnabled, wrapped in a transaction by the
1703
1789
  // extractor so the mirror re-renders exactly once).
1704
1790
  setMemoryTags: (memoryId, tags) => {
1705
- if (config.manualTagEnabled === false) {
1791
+ if (tagConfig().manualTagEnabled === false) {
1706
1792
  return { ok: false, error: "manualTagEnabled is off" };
1707
1793
  }
1708
1794
  const stored = store.setMemoryTags(memoryId, tags);
@@ -1713,7 +1799,10 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1713
1799
  getMemoryTags: (memoryId) => store.getMemoryTags(memoryId),
1714
1800
  // Read-only gate flag so the Web panel can hide tag editing when the
1715
1801
  // manual path is disabled (default: manual tagging is on).
1716
- manualTagEnabled: () => config.manualTagEnabled !== false,
1802
+ manualTagEnabled: () => tagConfig().manualTagEnabled !== false,
1803
+ // Effective tag toggle pair (settings-over-config merge) for the panel
1804
+ // (/api/dsh-mneme/config GET/PUT).
1805
+ getTagConfig: tagConfig,
1717
1806
  applyMemoryTags: (memoryId, tags) => store.setMemoryTags(memoryId, tags),
1718
1807
  saveAttr: (r) => store.saveAttr(r),
1719
1808
  createEntity: (r) => store.createEntity(r),
@@ -148,9 +148,13 @@ export function createSettings(db) {
148
148
  if (j && typeof j === "object") stored = j;
149
149
  } catch { /* fall through to defaults */ }
150
150
  }
151
+ // null = never explicitly stored → callers fall back to the plugin
152
+ // config default, so the settings layer can't override a toggle the
153
+ // user never touched (issue #31). This also unifies the manualTagEnabled
154
+ // default (settings no longer invents false where plugin config says true).
151
155
  return {
152
- autoTagEnabled: stored.autoTagEnabled === true,
153
- manualTagEnabled: stored.manualTagEnabled === true
156
+ autoTagEnabled: typeof stored.autoTagEnabled === "boolean" ? stored.autoTagEnabled : null,
157
+ manualTagEnabled: typeof stored.manualTagEnabled === "boolean" ? stored.manualTagEnabled : null
154
158
  };
155
159
  },
156
160
  setAutoTagConfig(partial) {
@@ -161,10 +165,15 @@ export function createSettings(db) {
161
165
  return j && typeof j === "object" ? j : {};
162
166
  } catch { return {}; }
163
167
  })();
164
- const cfg = {
165
- autoTagEnabled: partial.autoTagEnabled === undefined ? cur.autoTagEnabled === true : partial.autoTagEnabled === true,
166
- manualTagEnabled: partial.manualTagEnabled === undefined ? cur.manualTagEnabled === true : partial.manualTagEnabled === true
167
- };
168
+ const cfg = {};
169
+ // Only persist keys the caller explicitly set (or already stored). A
170
+ // partial update must never invent `false` for a key the user left
171
+ // alone — that would silently disable manual tagging on a toggle of
172
+ // autoTag alone (issue #31).
173
+ if (partial.autoTagEnabled !== undefined) cfg.autoTagEnabled = partial.autoTagEnabled === true;
174
+ else if (cur.autoTagEnabled !== undefined) cfg.autoTagEnabled = cur.autoTagEnabled === true;
175
+ if (partial.manualTagEnabled !== undefined) cfg.manualTagEnabled = partial.manualTagEnabled === true;
176
+ else if (cur.manualTagEnabled !== undefined) cfg.manualTagEnabled = cur.manualTagEnabled === true;
168
177
  setSetting("autoTag", JSON.stringify(cfg));
169
178
  return cfg;
170
179
  }
@@ -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
  /**
@@ -1727,6 +1743,11 @@ export function createStore(path) {
1727
1743
  VALUES (?, ?, 'tags', ?, ?, ?, NULL, 1.0, 'manual')`
1728
1744
  ).run(id, memoryId, JSON.stringify(arr), memoryId, now);
1729
1745
  }
1746
+ // Keep memories.tags (the display/search column) as a mirror of the
1747
+ // entity_attrs source of truth, so API rows and keyword search never
1748
+ // drift from the directory/tag store (issue #31).
1749
+ db.prepare("UPDATE memories SET tags = ?, updated_at = ? WHERE id = ?")
1750
+ .run(JSON.stringify(arr), now, memoryId);
1730
1751
  db.exec("RELEASE set_memory_tags");
1731
1752
  } catch (e) {
1732
1753
  db.exec("ROLLBACK TO set_memory_tags");
@@ -2185,6 +2206,7 @@ export function createStore(path) {
2185
2206
  saveRecallRun,
2186
2207
  getRecallRun,
2187
2208
  listRecallRuns,
2209
+ purgeRecallRunsOlderThan,
2188
2210
  saveRecallEval,
2189
2211
  getRecallEval,
2190
2212
  listRecallEvals,