@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.
- package/README.en.md +11 -3
- package/README.md +16 -17
- package/cordis.patch.yml +5 -0
- package/lib/api.js +17 -3
- package/lib/client.js +154 -37
- package/lib/config.js +22 -1
- package/lib/dream/sleep.js +43 -11
- package/lib/dream.js +55 -14
- package/lib/heat.js +136 -0
- package/lib/service.js +24 -7
- package/lib/settings.js +1 -0
- package/package.json +2 -3
- package/src/api.js +17 -3
- package/src/config.js +22 -1
- package/src/dream/sleep.js +43 -11
- package/src/dream.js +55 -14
- package/src/heat.js +136 -0
- package/src/service.js +24 -7
- package/src/settings.js +1 -0
- package/test/api.test.js +33 -5
- package/test/client.test.js +70 -13
- package/test/heat.test.js +148 -0
- package/test/reasoning-effort.test.js +114 -0
- package/test/recall-layer.test.js +15 -4
- package/test/sleep-heat.test.js +125 -0
- package/test/sleep.test.js +12 -4
- package/test/updated-at-semantics.test.js +113 -0
package/src/dream/sleep.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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/src/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?.(
|
|
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
|
-
|
|
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/src/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/src/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
|
-
*
|
|
402
|
-
* or auto-injection gets its last_accessed_at bumped, so the "unrecalled
|
|
403
|
-
* days → demote/archive" tiering counts real access
|
|
404
|
-
*
|
|
405
|
-
*
|
|
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?.
|
|
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 =
|
|
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/src/settings.js
CHANGED
package/test/api.test.js
CHANGED
|
@@ -527,10 +527,10 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
527
527
|
assert.equal(res.statusCode, 200);
|
|
528
528
|
const data = JSON.parse(res.body);
|
|
529
529
|
assert.deepEqual(data.overrides, {});
|
|
530
|
-
// effective 覆盖全部
|
|
531
|
-
// dreamProvider/dreamModel 无 schema
|
|
532
|
-
//
|
|
533
|
-
assert.equal(Object.keys(data.effective).length,
|
|
530
|
+
// effective 覆盖全部 31 个白名单键(含 v0.7.20 新增的 heatEnabled),未覆盖时
|
|
531
|
+
// 取 bundle 配置的解析默认值;dreamProvider/dreamModel 无 schema 默认值
|
|
532
|
+
// (Config({}) 解析为 undefined),不编造给前端 → 31 - 2 = 29
|
|
533
|
+
assert.equal(Object.keys(data.effective).length, 29);
|
|
534
534
|
assert.equal(data.effective.autoInject, true);
|
|
535
535
|
assert.equal(data.effective.codingRetrospect, false);
|
|
536
536
|
assert.equal(data.effective.distillMaxChars, 24000);
|
|
@@ -903,7 +903,9 @@ test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", as
|
|
|
903
903
|
assert.equal(data.runs.length, 2);
|
|
904
904
|
assert.equal(data.runs[0].created_at, "2026-01-02T00:00:00.000Z", "created_at DESC");
|
|
905
905
|
assert.deepEqual(data.lastRun, data.runs[0]);
|
|
906
|
-
assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "error", "model", "provider", "status"]);
|
|
906
|
+
assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "demotion", "error", "model", "provider", "run_type", "status"]);
|
|
907
|
+
assert.equal(data.lastRun.run_type, "auto", "default run_type is auto");
|
|
908
|
+
assert.equal(data.lastRun.demotion, null, "no demotion info for non-sleep runs");
|
|
907
909
|
assert.equal(data.runs[0].error, "boom");
|
|
908
910
|
assert.equal(data.runs[1].provider, "ollama");
|
|
909
911
|
assert.equal(data.pendingConflicts, 1);
|
|
@@ -1049,3 +1051,29 @@ test("GET /api/dsh-mneme/list?deposited=only lists dream-touched memories (recei
|
|
|
1049
1051
|
assert.deepEqual(bothData.items.map((m) => m.title), ["被巩固"]);
|
|
1050
1052
|
assert.equal(bothData.total, 1);
|
|
1051
1053
|
});
|
|
1054
|
+
|
|
1055
|
+
test("GET /api/dsh-mneme/list projects per-memory heat only when heatEnabled=true", async () => {
|
|
1056
|
+
// 默认(heatEnabled=false):heat 字段整体缺省——前端徽章据此自动隐藏
|
|
1057
|
+
const off = setup();
|
|
1058
|
+
off.service.saveWithDedupe({ type: "preference", title: "免疫型", content: "immune" });
|
|
1059
|
+
const r0 = new FakeRes();
|
|
1060
|
+
await off.routes.find((r) => r.path === "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list"), r0);
|
|
1061
|
+
const d0 = JSON.parse(r0.body);
|
|
1062
|
+
assert.equal(d0.total, 1);
|
|
1063
|
+
assert.equal("heat" in d0.items[0], false, "heat must be absent from the wire DTO when the flag is off");
|
|
1064
|
+
|
|
1065
|
+
// heatEnabled=true:逐条投影。λ=0 免疫类型(preference)恒 1.0;其余落在
|
|
1066
|
+
// [0,1] 区间(新建记忆 Δt≈0 接近满格,衰减数学由 heat.test.js 看门)。
|
|
1067
|
+
const on = setup(null, "", { heatEnabled: true });
|
|
1068
|
+
on.service.saveWithDedupe({ type: "preference", title: "免疫型", content: "immune" });
|
|
1069
|
+
on.service.saveWithDedupe({ type: "history", title: "会话历史", content: "recent" });
|
|
1070
|
+
const r1 = new FakeRes();
|
|
1071
|
+
await on.routes.find((r) => r.path === "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list"), r1);
|
|
1072
|
+
const d1 = JSON.parse(r1.body);
|
|
1073
|
+
assert.equal(d1.total, 2);
|
|
1074
|
+
const byTitle = Object.fromEntries(d1.items.map((m) => [m.title, m.heat]));
|
|
1075
|
+
assert.equal(byTitle["免疫型"], 1, "λ=0 immune types stay at full heat");
|
|
1076
|
+
for (const v of Object.values(byTitle)) {
|
|
1077
|
+
assert.ok(typeof v === "number" && v >= 0 && v <= 1, "heat values stay within [0,1]");
|
|
1078
|
+
}
|
|
1079
|
+
});
|
package/test/client.test.js
CHANGED
|
@@ -223,18 +223,16 @@ test("importance renders as star glyphs, not raw text stars", () => {
|
|
|
223
223
|
// external-plugin-guide §2.2): 'betterSidebar' IS declared in inject (DSH's
|
|
224
224
|
// runtime gates ctx property access on the inject declaration — probing
|
|
225
225
|
// without declaring fails the whole loader entry, verified in the field) and
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
226
|
+
// better-sidebar 软集成(issue #88 修正):模块级 inject 声明 betterSidebar
|
|
227
|
+
// 是硬等待——未安装 bs 的环境整个 entry pending("1 entry did not activate",
|
|
228
|
+
// Failed to load plugins)。正确模式(dsh-server-deck 同款):外层入口零
|
|
229
|
+
// inject 立即激活(独立模式保底),tab 注册挂在内层动态子插件
|
|
230
|
+
// ctx.plugin({ inject: ['betterSidebar'] }) 由 cordis 原生等待服务——bs 未装
|
|
231
|
+
// 时该内层 fiber 永远 INACTIVE,静默无害。
|
|
232
|
+
test("better-sidebar tab mounts via an inner sub-plugin, standalone mode intact", () => {
|
|
231
233
|
assert.ok(
|
|
232
|
-
/const
|
|
233
|
-
"
|
|
234
|
-
);
|
|
235
|
-
assert.ok(
|
|
236
|
-
/const bs = ctx\.betterSidebar;[\s\S]{0,60}typeof bs\.registerTab === "function"/.test(clientSource),
|
|
237
|
-
"the tab registration must still probe ctx.betterSidebar at runtime (undefined when absent)"
|
|
234
|
+
/const reg = bsCtx\.betterSidebar;[\s\S]{0,60}typeof reg\.registerTab !== "function"/.test(clientSource),
|
|
235
|
+
"the inner apply must still guard the service shape before registering"
|
|
238
236
|
);
|
|
239
237
|
assert.ok(
|
|
240
238
|
/id: "dsh-mneme:memory"/.test(clientSource),
|
|
@@ -252,9 +250,20 @@ test("better-sidebar tab declares optional inject, probes, and skips gracefully"
|
|
|
252
250
|
clientSource.includes('"dsh-mneme: better-sidebar tab"'),
|
|
253
251
|
"the registration effect must carry a named label for scope cleanup"
|
|
254
252
|
);
|
|
253
|
+
// issue #88:模块级 inject 声明 betterSidebar 是硬等待——未安装 bs 的环境
|
|
254
|
+
// 整个 entry pending("1 entry did not activate")。tab 注册必须挂在内层
|
|
255
|
+
// 动态子插件(dsh-server-deck 同款模式),外层入口零 inject 立即激活。
|
|
256
|
+
assert.ok(
|
|
257
|
+
/const inject = \["slots", "locale"\]/.test(clientSource),
|
|
258
|
+
"the module inject must not declare betterSidebar (hard-wait regression)"
|
|
259
|
+
);
|
|
255
260
|
assert.ok(
|
|
256
|
-
/
|
|
257
|
-
"the
|
|
261
|
+
/ctx\.plugin\?\.\(\{[\s\S]*?inject: \["betterSidebar"\][\s\S]*?apply: \(bsCtx\) =>/.test(clientSource),
|
|
262
|
+
"the tab registration must live in an inner dynamic sub-plugin waiting on cordis"
|
|
263
|
+
);
|
|
264
|
+
assert.ok(
|
|
265
|
+
/if \(\+\+tries <= 10\) timer = setTimeout\(attempt, 1000\);/.test(clientSource) === false,
|
|
266
|
+
"the old 10×1s probe must go — cordis waits for the inner inject natively"
|
|
258
267
|
);
|
|
259
268
|
});
|
|
260
269
|
|
|
@@ -409,3 +418,51 @@ test("explorer chrome aligns with the host design system", () => {
|
|
|
409
418
|
"pill chips belong to the drawer era and must stay gone"
|
|
410
419
|
);
|
|
411
420
|
});
|
|
421
|
+
|
|
422
|
+
// heat 阶段二:/list 仅在 heatEnabled=true 时下发逐条 heat,前端徽章三档
|
|
423
|
+
// 配色且自门控(字段缺省自动隐藏)——卡片页脚、抽屉 meta、状态分布卡共用
|
|
424
|
+
// 同一数据源,前端不感知开关状态。
|
|
425
|
+
test("heat badges render from the /list projection and self-hide when off", () => {
|
|
426
|
+
assert.ok(
|
|
427
|
+
/const HeatBadge = \(\{ value, size = 12 \}\) =>/.test(clientSource),
|
|
428
|
+
"the heat badge component must exist"
|
|
429
|
+
);
|
|
430
|
+
assert.ok(
|
|
431
|
+
clientSource.includes("flame:"),
|
|
432
|
+
"the Lucide flame glyph must back the badge"
|
|
433
|
+
);
|
|
434
|
+
assert.ok(
|
|
435
|
+
/h\(HeatBadge, \{ value: m\.heat \}\)/.test(clientSource),
|
|
436
|
+
"the card foot must render the heat badge"
|
|
437
|
+
);
|
|
438
|
+
assert.ok(
|
|
439
|
+
clientSource.includes('t("memory.explorer.heat")'),
|
|
440
|
+
"the drawer must show a localized heat meta row"
|
|
441
|
+
);
|
|
442
|
+
assert.ok(
|
|
443
|
+
clientSource.includes("function HeatStatusCard"),
|
|
444
|
+
"the status grid must include the heat distribution card"
|
|
445
|
+
);
|
|
446
|
+
assert.ok(
|
|
447
|
+
clientSource.includes('typeof items[0].heat !== "number"'),
|
|
448
|
+
"the distribution card must self-hide when /list omits heat"
|
|
449
|
+
);
|
|
450
|
+
assert.ok(
|
|
451
|
+
/\.mneme-heat--hot\{/.test(clientSource),
|
|
452
|
+
"the three-tier heat colors must be styled"
|
|
453
|
+
);
|
|
454
|
+
// order=heat 的前端补口:热度是运行时投影无存储序,SQL 排不了——页内
|
|
455
|
+
// 对已加载条目降序,时间树保持 chrono;chip 自门控(heat 缺省不出现)。
|
|
456
|
+
assert.ok(
|
|
457
|
+
/const heatAvailable = visible\.some\(\(m\) => typeof m\.heat === "number"\);/.test(clientSource),
|
|
458
|
+
"the heat-sort chip must self-gate on the /list heat field"
|
|
459
|
+
);
|
|
460
|
+
assert.ok(
|
|
461
|
+
/const gridItems = heatSort[\s\S]{0,80}\(b\.heat \?\? 0\) - \(a\.heat \?\? 0\)/.test(clientSource),
|
|
462
|
+
"the cards grid must sort loaded items by heat in-page"
|
|
463
|
+
);
|
|
464
|
+
assert.ok(
|
|
465
|
+
clientSource.includes("if (!heatSort) switchViewMode(\"cards\")"),
|
|
466
|
+
"toggling heat sort must land on the cards view (sort does not apply to the month tree)"
|
|
467
|
+
);
|
|
468
|
+
});
|