@modusensus/dsh-mneme 0.7.17 → 0.7.20
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 +6 -3
- package/README.md +12 -17
- package/cordis.patch.yml +5 -0
- package/lib/api.js +23 -5
- package/lib/client.js +280 -42
- package/lib/config.js +22 -1
- package/lib/dream/sleep.js +14 -1
- package/lib/heat.js +136 -0
- package/lib/service.js +24 -7
- package/lib/settings.js +1 -0
- package/lib/store.js +16 -2
- package/package.json +8 -2
- package/src/api.js +23 -5
- package/src/config.js +22 -1
- package/src/dream/sleep.js +14 -1
- package/src/heat.js +136 -0
- package/src/service.js +24 -7
- package/src/settings.js +1 -0
- package/src/store.js +16 -2
- package/test/api.test.js +88 -5
- package/test/client.test.js +134 -0
- package/test/heat.test.js +148 -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/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
|
-
*
|
|
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/lib/settings.js
CHANGED
package/lib/store.js
CHANGED
|
@@ -637,7 +637,7 @@ export function createStore(path) {
|
|
|
637
637
|
return ts;
|
|
638
638
|
}
|
|
639
639
|
|
|
640
|
-
function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, updatedFrom = null, updatedTo = null } = {}) {
|
|
640
|
+
function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null } = {}) {
|
|
641
641
|
const clauses = [];
|
|
642
642
|
const params = [];
|
|
643
643
|
if (type !== undefined) {
|
|
@@ -673,6 +673,12 @@ export function createStore(path) {
|
|
|
673
673
|
} else if (!includeArchived) {
|
|
674
674
|
clauses.push("archived = 0");
|
|
675
675
|
}
|
|
676
|
+
// 与 list() 同过滤:deposited 视图的 total 才能和行保持一致。
|
|
677
|
+
if (depositedOnly) {
|
|
678
|
+
clauses.push(
|
|
679
|
+
"(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
|
|
680
|
+
);
|
|
681
|
+
}
|
|
676
682
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
677
683
|
return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
|
|
678
684
|
}
|
|
@@ -924,7 +930,7 @@ export function createStore(path) {
|
|
|
924
930
|
return rows.map(toRow);
|
|
925
931
|
}
|
|
926
932
|
|
|
927
|
-
function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
|
|
933
|
+
function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
|
|
928
934
|
const clauses = [];
|
|
929
935
|
const params = [];
|
|
930
936
|
if (type) {
|
|
@@ -962,6 +968,14 @@ export function createStore(path) {
|
|
|
962
968
|
} else if (!includeArchived) {
|
|
963
969
|
clauses.push("archived = 0");
|
|
964
970
|
}
|
|
971
|
+
// depositedOnly:只看 autoDream 巩固过的记忆——receipt_chain 的 merge /
|
|
972
|
+
// update live verdict(record_id 即保留/更新目标)∪ source="dream" 的
|
|
973
|
+
// 直写沉淀(记忆库总览)。conflict 不算沉淀:两侧只被仲裁,内容未落。
|
|
974
|
+
if (depositedOnly) {
|
|
975
|
+
clauses.push(
|
|
976
|
+
"(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
|
|
977
|
+
);
|
|
978
|
+
}
|
|
965
979
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
966
980
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
967
981
|
// "chrono" is pure newest-first — the stable order paged browsing (month
|
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.
|
|
4
|
+
"version": "0.7.20",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -53,7 +53,13 @@
|
|
|
53
53
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
54
54
|
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
|
|
55
55
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
56
|
-
"@deepseek-ai/schemastery": "^3.18.1"
|
|
56
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
57
|
+
"dsh-better-sidebar": "*"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"dsh-better-sidebar": {
|
|
61
|
+
"optional": true
|
|
62
|
+
}
|
|
57
63
|
},
|
|
58
64
|
"devDependencies": {
|
|
59
65
|
"@deepseek-ai/cordis": "^4.0.1",
|
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 = {}) {
|
|
@@ -178,18 +179,26 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
178
179
|
// archived=only:只看归档(状态页的归档列表用)。归档行不进默认列表,
|
|
179
180
|
// 所以这是独立的视图开关,而不是 includeArchived 的混看模式。
|
|
180
181
|
const onlyArchived = url.searchParams.get("archived") === "only";
|
|
181
|
-
|
|
182
|
+
// deposited=only:只看 autoDream 巩固过的记忆(receipt_chain 的
|
|
183
|
+
// merge/update verdict ∪ source=dream 直写)——状态页「查看全部」
|
|
184
|
+
// 与记忆库的「沉淀」筛选 chip 共用这个视图。
|
|
185
|
+
const depositedOnly = url.searchParams.get("deposited") === "only";
|
|
186
|
+
const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly });
|
|
182
187
|
// 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出
|
|
183
188
|
// schema 严格复用 toApiList,扩展只发生在 HTTP 层。
|
|
189
|
+
// heat 投影(阶段二前端数据源):仅 heatEnabled=true 时下发逐条热度
|
|
190
|
+
// (heat.js 纯函数,λ=0 免疫类型恒 1.0);字段缺省时前端徽章自动隐藏。
|
|
191
|
+
const heatOn = config?.heatEnabled === true;
|
|
184
192
|
const items = service.toApiList(rows).map((m, i) => ({
|
|
185
193
|
...m,
|
|
186
194
|
archived: rows[i].archived === true || rows[i].archived === 1,
|
|
187
|
-
quality_score: rows[i].quality_score ?? null
|
|
195
|
+
quality_score: rows[i].quality_score ?? null,
|
|
196
|
+
...(heatOn ? { heat: computeHeat(rows[i], Date.now(), config ?? {}) } : {})
|
|
188
197
|
}));
|
|
189
198
|
// Total honors the same filters as the rows, or the pager's
|
|
190
199
|
// has-more math breaks whenever minImportance/source/updated-at
|
|
191
200
|
// bounds are active.
|
|
192
|
-
sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived }) });
|
|
201
|
+
sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) });
|
|
193
202
|
} catch {
|
|
194
203
|
sendJson(res, 500, { error: "internal" });
|
|
195
204
|
}
|
|
@@ -586,7 +595,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
586
595
|
name: n.name,
|
|
587
596
|
type: n.type ?? null,
|
|
588
597
|
mention_count: n.mention_count ?? 1,
|
|
589
|
-
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
|
|
590
602
|
})),
|
|
591
603
|
edges: [...edgeMap.values()].map((e) => ({
|
|
592
604
|
id: e.id,
|
|
@@ -775,7 +787,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
775
787
|
status: r.status,
|
|
776
788
|
provider: r.provider ?? null,
|
|
777
789
|
model: r.model ?? null,
|
|
778
|
-
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
|
|
779
797
|
}));
|
|
780
798
|
const pendingConflicts = service.countConflictPending?.() ?? 0;
|
|
781
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
|
/**
|
package/src/dream/sleep.js
CHANGED
|
@@ -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, 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):
|
|
@@ -242,10 +243,22 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
|
|
|
242
243
|
for (const m of service.all()) {
|
|
243
244
|
if (signal?.aborted) break;
|
|
244
245
|
if (m.archived || m.forgotten) continue;
|
|
245
|
-
const ref = m.last_accessed_at ?? m.
|
|
246
|
+
const ref = m.last_accessed_at ?? m.created_at;
|
|
246
247
|
if (!ref) continue;
|
|
247
248
|
const t = new Date(ref).getTime();
|
|
248
249
|
if (Number.isNaN(t)) continue;
|
|
250
|
+
// v0.7.0 热联合判定(仅 heatEnabled 时启用;默认关则退回纯时间分层,
|
|
251
|
+
// 与 v0.7.12 行为一致):时间窗之外再加两道保护闸——热度低于
|
|
252
|
+
// sleepHeatThreshold 且 importance<5 才允许降级。λ=0 的免疫类型 heat 恒
|
|
253
|
+
// 1.0 天然豁免(preference/pattern/summary 永不因 sleep 降级);importance
|
|
254
|
+
// ≥5 的紧要记忆无论多冷都保留。`冷但重要` 与 `热但低值` 均不满足条件。
|
|
255
|
+
const heatOn = config.heatEnabled !== false;
|
|
256
|
+
if (heatOn) {
|
|
257
|
+
const heat = computeHeat(m, Date.now(), config);
|
|
258
|
+
const heatProtected = heat >= (config.sleepHeatThreshold ?? 0.05);
|
|
259
|
+
const important = (m.importance ?? 0) >= 5;
|
|
260
|
+
if (heatProtected || important) continue;
|
|
261
|
+
}
|
|
249
262
|
if (t < compressCut) {
|
|
250
263
|
service.setArchived(m.id, true);
|
|
251
264
|
archived.push(m.id);
|
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/src/store.js
CHANGED
|
@@ -637,7 +637,7 @@ export function createStore(path) {
|
|
|
637
637
|
return ts;
|
|
638
638
|
}
|
|
639
639
|
|
|
640
|
-
function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, updatedFrom = null, updatedTo = null } = {}) {
|
|
640
|
+
function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null } = {}) {
|
|
641
641
|
const clauses = [];
|
|
642
642
|
const params = [];
|
|
643
643
|
if (type !== undefined) {
|
|
@@ -673,6 +673,12 @@ export function createStore(path) {
|
|
|
673
673
|
} else if (!includeArchived) {
|
|
674
674
|
clauses.push("archived = 0");
|
|
675
675
|
}
|
|
676
|
+
// 与 list() 同过滤:deposited 视图的 total 才能和行保持一致。
|
|
677
|
+
if (depositedOnly) {
|
|
678
|
+
clauses.push(
|
|
679
|
+
"(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
|
|
680
|
+
);
|
|
681
|
+
}
|
|
676
682
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
677
683
|
return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
|
|
678
684
|
}
|
|
@@ -924,7 +930,7 @@ export function createStore(path) {
|
|
|
924
930
|
return rows.map(toRow);
|
|
925
931
|
}
|
|
926
932
|
|
|
927
|
-
function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
|
|
933
|
+
function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
|
|
928
934
|
const clauses = [];
|
|
929
935
|
const params = [];
|
|
930
936
|
if (type) {
|
|
@@ -962,6 +968,14 @@ export function createStore(path) {
|
|
|
962
968
|
} else if (!includeArchived) {
|
|
963
969
|
clauses.push("archived = 0");
|
|
964
970
|
}
|
|
971
|
+
// depositedOnly:只看 autoDream 巩固过的记忆——receipt_chain 的 merge /
|
|
972
|
+
// update live verdict(record_id 即保留/更新目标)∪ source="dream" 的
|
|
973
|
+
// 直写沉淀(记忆库总览)。conflict 不算沉淀:两侧只被仲裁,内容未落。
|
|
974
|
+
if (depositedOnly) {
|
|
975
|
+
clauses.push(
|
|
976
|
+
"(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
|
|
977
|
+
);
|
|
978
|
+
}
|
|
965
979
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
966
980
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
967
981
|
// "chrono" is pure newest-first — the stable order paged browsing (month
|