@modusensus/dsh-mneme 0.7.18 → 0.7.21

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.
@@ -17,7 +17,8 @@
17
17
  import { randomUUID, createHash } from "node:crypto";
18
18
  import { validateDecisions, applyDecisions } from "./decisions.js";
19
19
  import { findPotentialConflicts } from "./clustering.js";
20
- import { buildReceipt, withEffortFallback } from "../dream.js";
20
+ import { buildReceipt, describeStreamFailure, withEffortFallback } 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):
@@ -60,12 +61,17 @@ function parseJsonArray(text) {
60
61
  }
61
62
 
62
63
  /** Same stream consumption contract as dream.js. */
63
- async function streamText(ctx, options) {
64
+ async function streamText(ctx, options, onStreamError) {
64
65
  if (!ctx?.llm?.stream) return undefined;
65
66
  let text = "";
66
67
  for await (const chunk of ctx.llm.stream(options)) {
67
68
  if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
68
69
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
70
+ // Same rc.1 error-as-finish-chunk behavior as dream.js — surface the
71
+ // cause instead of discarding it.
72
+ if (typeof onStreamError === "function") {
73
+ try { onStreamError(chunk.reason); } catch { /* diagnostics only */ }
74
+ }
69
75
  return undefined;
70
76
  }
71
77
  }
@@ -185,7 +191,10 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
185
191
  `候选冲突:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(相似度 ${p.similarity.toFixed(2)})`
186
192
  ).join("\n\n");
187
193
  const sleepEffort = config.sleepReasoningEffort && config.sleepReasoningEffort !== "none" ? config.sleepReasoningEffort : null;
188
- const runConflict = (withEffort) => streamText(ctx, {
194
+ let conflictStreamFailure = "";
195
+ const runConflict = (withEffort) => {
196
+ conflictStreamFailure = "";
197
+ return streamText(ctx, {
189
198
  provider: route.provider,
190
199
  model: route.model,
191
200
  purpose: "sleep-conflict",
@@ -195,9 +204,13 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
195
204
  { role: "system", content: [{ type: "text", text: CONFLICT_PROMPT }] },
196
205
  { role: "user", content: [{ type: "text", text: listText }] }
197
206
  ]
198
- });
199
- const text = await withEffortFallback(ctx, sleepEffort, () => runConflict(true), () => runConflict(false));
200
- if (text === undefined) return { status: "failed", error: "llm failed" };
207
+ }, (reason) => { conflictStreamFailure = describeStreamFailure(reason); });
208
+ };
209
+ const text = await withEffortFallback(ctx, sleepEffort, () => runConflict(true), () => runConflict(false), () => conflictStreamFailure);
210
+ if (text === undefined) {
211
+ if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
212
+ return { status: "failed", error: "llm failed" };
213
+ }
201
214
  const decisions = parseJsonArray(text);
202
215
  if (!decisions) return { status: "failed", error: "invalid decisions json" };
203
216
  // validateDecisions 要求每个 snapshot id 恰好被 claim 一次。v0.4.4 起它本身
@@ -242,10 +255,22 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
242
255
  for (const m of service.all()) {
243
256
  if (signal?.aborted) break;
244
257
  if (m.archived || m.forgotten) continue;
245
- const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
258
+ const ref = m.last_accessed_at ?? m.created_at;
246
259
  if (!ref) continue;
247
260
  const t = new Date(ref).getTime();
248
261
  if (Number.isNaN(t)) continue;
262
+ // v0.7.0 热联合判定(仅 heatEnabled 时启用;默认关则退回纯时间分层,
263
+ // 与 v0.7.12 行为一致):时间窗之外再加两道保护闸——热度低于
264
+ // sleepHeatThreshold 且 importance<5 才允许降级。λ=0 的免疫类型 heat 恒
265
+ // 1.0 天然豁免(preference/pattern/summary 永不因 sleep 降级);importance
266
+ // ≥5 的紧要记忆无论多冷都保留。`冷但重要` 与 `热但低值` 均不满足条件。
267
+ const heatOn = config.heatEnabled !== false;
268
+ if (heatOn) {
269
+ const heat = computeHeat(m, Date.now(), config);
270
+ const heatProtected = heat >= (config.sleepHeatThreshold ?? 0.05);
271
+ const important = (m.importance ?? 0) >= 5;
272
+ if (heatProtected || important) continue;
273
+ }
249
274
  if (t < compressCut) {
250
275
  service.setArchived(m.id, true);
251
276
  archived.push(m.id);
@@ -285,7 +310,10 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
285
310
  .join("\n");
286
311
  const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
287
312
  const sleepEffort = config.sleepReasoningEffort && config.sleepReasoningEffort !== "none" ? config.sleepReasoningEffort : null;
288
- const runPattern = (withEffort) => streamText(ctx, {
313
+ let patternStreamFailure = "";
314
+ const runPattern = (withEffort) => {
315
+ patternStreamFailure = "";
316
+ return streamText(ctx, {
289
317
  provider: route.provider,
290
318
  model: route.model,
291
319
  purpose: "sleep-pattern",
@@ -295,9 +323,13 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
295
323
  { role: "system", content: [{ type: "text", text: PATTERN_PROMPT.replace("N", String(maxPatterns)) }] },
296
324
  { role: "user", content: [{ type: "text", text: listText }] }
297
325
  ]
298
- });
299
- const text = await withEffortFallback(ctx, sleepEffort, () => runPattern(true), () => runPattern(false));
300
- if (text === undefined) return { status: "failed", error: "llm failed" };
326
+ }, (reason) => { patternStreamFailure = describeStreamFailure(reason); });
327
+ };
328
+ const text = await withEffortFallback(ctx, sleepEffort, () => runPattern(true), () => runPattern(false), () => patternStreamFailure);
329
+ if (text === undefined) {
330
+ if (patternStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: pattern stream aborted or errored (${patternStreamFailure})`);
331
+ return { status: "failed", error: "llm failed" };
332
+ }
301
333
  const decisions = parseJsonArray(text);
302
334
  if (!decisions || decisions.length === 0) return { status: "skipped", reason: "no patterns found" };
303
335
  // Evidence ids are provenance refs; an LLM-fabricated id would mint a dead
package/lib/dream.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { validateDecisions, applyDecisions } from "./dream/decisions.js";
2
2
  import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- export { validateDecisions, applyDecisions, withEffortFallback };
4
+ export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure };
5
5
 
6
6
 
7
7
  // Extract the first JSON array from LLM output, tolerating markdown fences,
@@ -238,18 +238,34 @@ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
238
238
  * surfaces as undefined. The caller decides how to treat an empty result.
239
239
  * `onUsage` (optional, Bug8) receives any usage chunk for token accounting.
240
240
  */
241
- async function streamText(ctx, options, onUsage) {
241
+ async function streamText(ctx, options, onUsage, onStreamError) {
242
242
  let text = "";
243
243
  for await (const chunk of ctx.llm.stream(options)) {
244
244
  if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
245
245
  if (chunk.type === "usage" && typeof onUsage === "function") onUsage(chunk);
246
246
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
247
+ // dsh-llm rc.1 turns adapter-stage failures (unknown provider route,
248
+ // UNSUPPORTED_REASONING_EFFORT from resolveCallWithInfo, …) into a
249
+ // terminal finish chunk instead of a throw — the cause rides in
250
+ // chunk.reason.failure {message, code}. Surface it, never swallow it.
251
+ if (typeof onStreamError === "function") {
252
+ try { onStreamError(chunk.reason); } catch { /* diagnostics only */ }
253
+ }
247
254
  return undefined;
248
255
  }
249
256
  }
250
257
  return text;
251
258
  }
252
259
 
260
+ /** One-line human-readable cause from a finish-chunk failure reason. */
261
+ function describeStreamFailure(reason) {
262
+ const failure = reason?.failure ?? reason ?? {};
263
+ const code = failure.code ? String(failure.code) : "";
264
+ const message = String(failure.message ?? failure.error ?? "");
265
+ if (code && message) return message.includes(code) ? message : `${code}: ${message}`;
266
+ return code || message;
267
+ }
268
+
253
269
  /**
254
270
  * Bug8: wrap a background LLM call so its token/time/status are recorded in the
255
271
  * llm_audit_logs table. Best-effort bookkeeping: a failure to WRITE the audit
@@ -279,9 +295,12 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
279
295
  });
280
296
  if (result === undefined) {
281
297
  // stream aborted/errored: the caller treats undefined as a failed run;
282
- // record it as error here so the audit shows the truth.
298
+ // record it as error here so the audit shows the truth. spec.streamError
299
+ // (a getter) lets the caller attach the finish-chunk cause so the audit
300
+ // row names it instead of a bare "aborted".
283
301
  status = "error";
284
- errorMessage = errorMessage ?? "llm stream aborted or errored";
302
+ const streamErr = typeof spec.streamError === "function" ? String(spec.streamError() ?? "") : "";
303
+ errorMessage = errorMessage ?? (streamErr ? `llm stream aborted or errored (${streamErr})` : "llm stream aborted or errored");
285
304
  } else if (typeof spec.auditError === "function") {
286
305
  // A stream that returned text but yields nothing usable is still a
287
306
  // failed call — record it as error, not the default success, so the
@@ -328,10 +347,22 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
328
347
  * accepted → reasoning capped; rejected → provider default (old behavior),
329
348
  * logged so the rejection is observable.
330
349
  */
331
- async function withEffortFallback(ctx, effort, attempt, fallback) {
350
+ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError) {
332
351
  if (!effort || effort === "none") return attempt();
333
352
  try {
334
- return await attempt();
353
+ const result = await attempt();
354
+ if (result === undefined) {
355
+ // dsh-llm rc.1 streams a provider effort-rejection as a terminal error
356
+ // finish chunk (adapterStream catches everything, never throws) — match
357
+ // on the chunk's failure reason here or the retry below is dead code
358
+ // for the stream path.
359
+ const reason = String(getStreamError?.() ?? "");
360
+ if (/reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i.test(reason)) {
361
+ ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected via stream (${reason}); retrying without it`);
362
+ return fallback();
363
+ }
364
+ }
365
+ return result;
335
366
  } catch (error) {
336
367
  const message = String(error?.message ?? error);
337
368
  // matches both "reasoning effort" (natural language) and the bare
@@ -630,11 +661,15 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
630
661
  // 记 audit error 并在日志带原始输出前 300 字节,便于定位"推理吞预算返回空体"。
631
662
  const effort = config.dreamReasoningEffort && config.dreamReasoningEffort !== "none" ? config.dreamReasoningEffort : null;
632
663
  let decisions = null;
633
- const runConsolidation = (withEffort) => runAuditedLlm(ctx, service, config, {
664
+ let streamFailure = "";
665
+ const runConsolidation = (withEffort) => {
666
+ streamFailure = "";
667
+ return runAuditedLlm(ctx, service, config, {
634
668
  triggerSource: "autoDream",
635
669
  operationType: "dream_consolidate",
636
670
  modelId: `${route.provider}:${route.model}`,
637
671
  relatedMemoryIds: [...snapshot.keys()],
672
+ streamError: () => streamFailure,
638
673
  auditError: (text) => {
639
674
  decisions = extractJsonArray(text);
640
675
  return Array.isArray(decisions) ? null : "no json array in llm output";
@@ -649,18 +684,19 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
649
684
  { role: "system", content: [{ type: "text", text: consolidationPrompt }] },
650
685
  { role: "user", content: [{ type: "text", text: listText }] }
651
686
  ]
652
- }, reportUsage));
687
+ }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
688
+ };
653
689
  try {
654
690
  // Bug8: the consolidation call is audited (tokens/time/status). A throw
655
691
  // re-propagates to the catch below; an aborted stream returns undefined
656
692
  // and is treated as a failed run after the check below.
657
- decisionText = await withEffortFallback(ctx, effort, () => runConsolidation(true), () => runConsolidation(false));
693
+ decisionText = await withEffortFallback(ctx, effort, () => runConsolidation(true), () => runConsolidation(false), () => streamFailure);
658
694
  } catch (error) {
659
695
  logger?.warn?.(`dsh-mneme dream: consolidation llm call failed: ${String(error)}`);
660
696
  return finish({ ok: false, error: "llm failed", summary: false });
661
697
  }
662
698
  if (decisionText === undefined) {
663
- logger?.warn?.("dsh-mneme dream: consolidation llm stream aborted or errored");
699
+ logger?.warn?.(`dsh-mneme dream: consolidation llm stream aborted or errored${streamFailure ? ` (${streamFailure})` : ""}`);
664
700
  return finish({ ok: false, error: "llm failed", summary: false });
665
701
  }
666
702
  if (!Array.isArray(decisions)) {
@@ -782,11 +818,15 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
782
818
  // Summary generation (second LLM call). A throwing stream is reported as
783
819
  // a failed run; summary:false marks a run that produced no summary.
784
820
  let summaryText;
785
- const runSummary = (withEffort) => runAuditedLlm(ctx, service, config, {
821
+ let summaryStreamFailure = "";
822
+ const runSummary = (withEffort) => {
823
+ summaryStreamFailure = "";
824
+ return runAuditedLlm(ctx, service, config, {
786
825
  triggerSource: "autoDream",
787
826
  operationType: "dream_summarize",
788
827
  modelId: `${route.provider}:${route.model}`,
789
- relatedMemoryIds: []
828
+ relatedMemoryIds: [],
829
+ streamError: () => summaryStreamFailure
790
830
  }, (reportUsage) => streamText(ctx, {
791
831
  provider: route.provider,
792
832
  model: route.model,
@@ -797,10 +837,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
797
837
  { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
798
838
  { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
799
839
  ]
800
- }, reportUsage));
840
+ }, reportUsage, (reason) => { summaryStreamFailure = describeStreamFailure(reason); }));
841
+ };
801
842
  try {
802
843
  // Bug8: the summary call is audited too (operation dream_summarize).
803
- summaryText = await withEffortFallback(ctx, effort, () => runSummary(true), () => runSummary(false));
844
+ summaryText = await withEffortFallback(ctx, effort, () => runSummary(true), () => runSummary(false), () => summaryStreamFailure);
804
845
  } catch (error) {
805
846
  logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
806
847
  return finish({ ok: false, error: "llm failed", applied, decisions: auditDecisions, outcome, frozen: frozenCount, summary: false });
package/lib/heat.js ADDED
@@ -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
+ }
package/lib/service.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { TYPE_FILE } from "./mirror.js";
3
+ import { computeHeat } from "./heat.js";
3
4
  import { evaluateMemoryQuality } from "./quality-filter.js";
4
5
  import { createBM25Index } from "./search/bm25.js";
5
6
  import { adaptiveThreshold } from "./search/adaptive.js";
@@ -398,14 +399,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
398
399
  }
399
400
 
400
401
  /**
401
- * Sleep touch (v0.4.0): when sleep is enabled, any memory surfaced by recall
402
- * or auto-injection gets its last_accessed_at bumped, so the "unrecalled N
403
- * days → demote/archive" tiering counts real access. Best-effort and gated on
404
- * config.sleepModeEnabled when sleep is off this is a complete no-op (no
405
- * writes on the hot recall path). A touch failure must never break search/inject.
402
+ * Recall touch (v0.4.0 sleep; v0.7.0 heat gating): any memory surfaced by
403
+ * recall or auto-injection gets its last_accessed_at bumped, so the "unrecalled
404
+ * N days → demote/archive" tiering counts real access and the heat clock
405
+ * resets (heat ref = last_accessed_at). Best-effort and gated on
406
+ * config.heatEnabled when heat is off this is a complete no-op (no writes
407
+ * on the hot recall path). A touch failure must never break search/inject.
406
408
  */
407
409
  function touchRecalled(memories) {
408
- if (config?.sleepModeEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
410
+ if (config?.heatEnabled === false || !Array.isArray(memories) || memories.length === 0) return;
409
411
  for (const m of memories) {
410
412
  if (!m?.id) continue;
411
413
  try {
@@ -415,7 +417,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
415
417
  }
416
418
 
417
419
  async function searchMemories(query, options = {}) {
418
- const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = options;
420
+ const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true) } = options;
419
421
  const q = String(query ?? "").trim();
420
422
  if (!q) return [];
421
423
 
@@ -1506,6 +1508,21 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1506
1508
  saveRelation: (r) => store.saveRelation(r),
1507
1509
  listEntities: (o) => store.listEntities(o),
1508
1510
  getRelations: (id) => store.getRelations(id),
1511
+ // v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(取 max)。无关联记忆
1512
+ // 或 heatEnabled=false 时返回 null;前端据此决定图谱节点大小/明暗。
1513
+ entityHeat: (entityId) => {
1514
+ if (config.heatEnabled === false) return null;
1515
+ const rels = store.getRelations(entityId) ?? [];
1516
+ let max = -Infinity;
1517
+ for (const rel of rels) {
1518
+ if (!rel.memory_id) continue;
1519
+ const mem = store.getById(rel.memory_id);
1520
+ if (!mem) continue;
1521
+ const h = computeHeat(mem, Date.now(), config);
1522
+ if (h > max) max = h;
1523
+ }
1524
+ return max === -Infinity ? null : max;
1525
+ },
1509
1526
  saveAttr: (r) => store.saveAttr(r),
1510
1527
  createEntity: (r) => store.createEntity(r),
1511
1528
  findEntityByName: (n) => store.findEntityByName(n),
package/lib/settings.js CHANGED
@@ -47,6 +47,7 @@ const FEATURE_FLAG_BOOLEANS = [
47
47
  "codingRetrospect",
48
48
  "autoDream",
49
49
  "sleepModeEnabled",
50
+ "heatEnabled",
50
51
  "hybridInject",
51
52
  "selectiveInjectEnabled",
52
53
  "searchSemanticDedup",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.7.18",
4
+ "version": "0.7.21",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -39,8 +39,7 @@
39
39
  "slots",
40
40
  "locale",
41
41
  "layout",
42
- "connection",
43
- "betterSidebar"
42
+ "connection"
44
43
  ],
45
44
  "platform": "web"
46
45
  },
package/src/api.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { timingSafeEqual, randomBytes } from "node:crypto";
4
4
  import { FEATURE_FLAG_SPEC } from "./settings.js";
5
5
  import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
6
+ import { computeHeat } from "./heat.js";
6
7
 
7
8
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
8
9
  function sendJson(res, status, payload, headers = {}) {
@@ -185,10 +186,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
185
186
  const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly });
186
187
  // 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出
187
188
  // schema 严格复用 toApiList,扩展只发生在 HTTP 层。
189
+ // heat 投影(阶段二前端数据源):仅 heatEnabled=true 时下发逐条热度
190
+ // (heat.js 纯函数,λ=0 免疫类型恒 1.0);字段缺省时前端徽章自动隐藏。
191
+ const heatOn = config?.heatEnabled === true;
188
192
  const items = service.toApiList(rows).map((m, i) => ({
189
193
  ...m,
190
194
  archived: rows[i].archived === true || rows[i].archived === 1,
191
- quality_score: rows[i].quality_score ?? null
195
+ quality_score: rows[i].quality_score ?? null,
196
+ ...(heatOn ? { heat: computeHeat(rows[i], Date.now(), config ?? {}) } : {})
192
197
  }));
193
198
  // Total honors the same filters as the rows, or the pager's
194
199
  // has-more math breaks whenever minImportance/source/updated-at
@@ -590,7 +595,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
590
595
  name: n.name,
591
596
  type: n.type ?? null,
592
597
  mention_count: n.mention_count ?? 1,
593
- distance: n.distance
598
+ distance: n.distance,
599
+ // v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(max),前端据此
600
+ // 缩放节点大小/明暗。heatEnabled=false 时 entityHeat 返回 null。
601
+ heat: service.entityHeat?.(n.id) ?? null
594
602
  })),
595
603
  edges: [...edgeMap.values()].map((e) => ({
596
604
  id: e.id,
@@ -779,7 +787,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
779
787
  status: r.status,
780
788
  provider: r.provider ?? null,
781
789
  model: r.model ?? null,
782
- error: r.error ?? null
790
+ error: r.error ?? null,
791
+ run_type: r.run_type ?? "auto",
792
+ // sleep 审计:heat/时间分层降级决策计数(工作动态可展示"降级 N 条")。
793
+ // 数据来自 runSleep 写入的 decisions.demotion({demoted,archived} 数组)。
794
+ demotion: r.decisions?.demotion
795
+ ? { demoted: (r.decisions.demotion.demoted ?? []).length, archived: (r.decisions.demotion.archived ?? []).length }
796
+ : null
783
797
  }));
784
798
  const pendingConflicts = service.countConflictPending?.() ?? 0;
785
799
  const ids = new Set();
package/src/config.js CHANGED
@@ -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"),
@@ -301,6 +302,24 @@ export const Config = z.object({
301
302
  // reaches any service; a persisted panel_mode="light" (settings kv) counts
302
303
  // as lightMode=true too and wins over the bundle config.
303
304
  lightMode: z.boolean().default(false),
305
+
306
+ // --- heat: v0.7.0 self-evolution (heat + interest drift) ----------------
307
+ // 总开关,默认关(v0.7.12+ 用户已习惯无 heat 行为,默认开=全员行为变更)。
308
+ // 开启后:提供热度字段 / sleep 降级联合判定保护 / 前端热度投影,不改变
309
+ // 召回排序。关闭则跳过所有 heat 计算与热度触达,sleep 降级退回纯时间分层。
310
+ // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
311
+ heatEnabled: z.boolean().default(false),
312
+ // 幂律形状参数 α(heat = 1/(1+λΔt)^α),越大衰减越快。
313
+ heatGlobalAlpha: z.number().min(0.1).max(5).default(1.2),
314
+ // per-type 衰减因子 λ;λ=0 的类型免疫(热度恒 1.0,sleep 永不降级)。
315
+ // 未知类型走默认 0.002。dict 的键为 type 字符串、值为数字 λ。
316
+ heatTypeDecay: z.dict(z.number(), z.string()).default({ ...TYPE_DECAY_DEFAULTS }),
317
+ // sleep 降级联合判定的热度下限:heat < 该值 且 importance<5 才允许降级。
318
+ sleepHeatThreshold: z.number().min(0).max(1).default(0.05),
319
+ // recordRecall 默认值(recall_runs 记录默认开;显式传 false 的调用方不受影响)。
320
+ recallRecordDefault: z.boolean().default(true),
321
+ // recall_runs 滚动清理保留天数。
322
+ recallRetentionDays: z.natural().min(1).max(3650).default(90),
304
323
  });
305
324
 
306
325
  // Fields forced to false by the light-mode preset. Everything not listed here
@@ -315,7 +334,9 @@ const LIGHT_MODE_OFF = [
315
334
  "hybridInject",
316
335
  "searchSemanticDedup",
317
336
  "selectiveInjectEnabled",
318
- "bm25SearchEnabled"
337
+ "bm25SearchEnabled",
338
+ // 轻量模式不开热计算(heat 属于重型增强;关掉后 sleep 降级也退回纯时间分层)。
339
+ "heatEnabled"
319
340
  ];
320
341
 
321
342
  /**