@modusensus/dsh-mneme 0.7.31 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -27
- package/lib/api.js +139 -6
- package/lib/client.js +369 -32
- package/lib/commands.js +36 -5
- package/lib/config.js +101 -14
- package/lib/dream/decisions.js +147 -14
- package/lib/dream/sleep.js +84 -34
- package/lib/dream.js +250 -60
- package/lib/embedding.js +22 -2
- package/lib/index.js +68 -37
- package/lib/inject.js +54 -13
- package/lib/lang.js +168 -0
- package/lib/local-embedder.js +23 -9
- package/lib/mirror.js +17 -14
- package/lib/reranker.js +23 -8
- package/lib/runtime/adopt.js +176 -0
- package/lib/runtime/closure.js +172 -0
- package/lib/runtime/download.js +247 -0
- package/lib/runtime/layout.js +235 -0
- package/lib/runtime/loader.js +249 -0
- package/lib/runtime/provision.js +237 -0
- package/lib/runtime/tarball.js +130 -0
- package/lib/runtime/verify.js +191 -0
- package/lib/scope.js +83 -0
- package/lib/service.js +374 -55
- package/lib/settings.js +43 -2
- package/lib/store.js +126 -13
- package/lib/summarize.js +108 -35
- package/lib/tools.js +205 -12
- package/package.json +7 -2
- package/runtime-manifest.json +596 -0
- package/scripts/build-runtime-manifest.mjs +217 -0
- package/scripts/mneme-runtime.mjs +277 -0
- package/src/api.js +139 -6
- package/src/commands.js +36 -5
- package/src/config.js +101 -14
- package/src/dream/decisions.js +147 -14
- package/src/dream/sleep.js +84 -34
- package/src/dream.js +250 -60
- package/src/embedding.js +22 -2
- package/src/index.js +68 -37
- package/src/inject.js +54 -13
- package/src/lang.js +168 -0
- package/src/local-embedder.js +23 -9
- package/src/mirror.js +17 -14
- package/src/reranker.js +23 -8
- package/src/runtime/adopt.js +176 -0
- package/src/runtime/closure.js +172 -0
- package/src/runtime/download.js +247 -0
- package/src/runtime/layout.js +235 -0
- package/src/runtime/loader.js +249 -0
- package/src/runtime/provision.js +237 -0
- package/src/runtime/tarball.js +130 -0
- package/src/runtime/verify.js +191 -0
- package/src/scope.js +83 -0
- package/src/service.js +374 -55
- package/src/settings.js +43 -2
- package/src/store.js +126 -13
- package/src/summarize.js +108 -35
- package/src/tools.js +205 -12
- package/test/api.test.js +175 -9
- package/test/client.test.js +152 -10
- package/test/commands.test.js +40 -3
- package/test/config.test.js +17 -1
- package/test/dream-candidate-hybrid.test.js +281 -0
- package/test/dream.test.js +172 -0
- package/test/embedding-ready.test.js +196 -0
- package/test/helpers/runtime-source.js +60 -0
- package/test/helpers/tar-builder.js +80 -0
- package/test/inject.test.js +130 -1
- package/test/lang.test.js +87 -0
- package/test/quality-filter.test.js +68 -0
- package/test/reasoning-effort.test.js +106 -15
- package/test/reindex-backfill.test.js +62 -0
- package/test/runtime-adopt.test.js +206 -0
- package/test/runtime-cli.test.js +89 -0
- package/test/runtime-closure.test.js +193 -0
- package/test/runtime-download.test.js +302 -0
- package/test/runtime-layout.test.js +163 -0
- package/test/runtime-loader.test.js +237 -0
- package/test/runtime-manifest.test.js +158 -0
- package/test/runtime-provision.test.js +101 -0
- package/test/runtime-tarball.test.js +82 -0
- package/test/runtime-verify.test.js +214 -0
- package/test/scope-retrieval.test.js +197 -0
- package/test/scope-storage.test.js +247 -0
- package/test/scope-strict.test.js +165 -0
- package/test/service.test.js +76 -0
- package/test/sleep-action-set.test.js +422 -0
- package/test/store.test.js +14 -0
- package/test/summarize.test.js +224 -0
- package/test/tools.test.js +30 -5
- package/test/webServer-optional.test.js +34 -1
package/lib/service.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { TYPE_FILE } from "./mirror.js";
|
|
3
|
+
import { updatedAtBounds } from "./store.js";
|
|
4
|
+
import { STR, langOf } from "./lang.js";
|
|
3
5
|
import { computeHeat } from "./heat.js";
|
|
4
6
|
import { evaluateMemoryQuality } from "./quality-filter.js";
|
|
7
|
+
import { applyDecisions } from "./dream/decisions.js";
|
|
5
8
|
import { createBM25Index } from "./search/bm25.js";
|
|
6
9
|
import { adaptiveThreshold } from "./search/adaptive.js";
|
|
7
10
|
|
|
@@ -32,6 +35,71 @@ const EPISTEMIC_WEIGHTS = { observation: 1.0, inferred: 0.85, subjective: 0.7 };
|
|
|
32
35
|
// how the version was superseded (auto_merge | human_override | overwrite).
|
|
33
36
|
const CONTENT_HISTORY_MAX = 20;
|
|
34
37
|
|
|
38
|
+
// Issue #135 附属发现 2:质量过滤器写下的系统信号标签(「为什么被降权/归档」的
|
|
39
|
+
// 唯一审计线索)。更新路径整组替换 tags 会把它们抹掉——更新时按此清单并集保留。
|
|
40
|
+
// 清单必须与 quality-filter.js 的写入端对齐(6 个,含 type 自指的 self_referential)。
|
|
41
|
+
const SIGNAL_TAGS = ["low_quality", "duplicate", "meta", "repetitive", "short_content", "self_referential"];
|
|
42
|
+
|
|
43
|
+
// v0.8.0 A1(issue #17)去重键的 scope 归一:非空 trim 字符串原样比较,其余
|
|
44
|
+
// (NULL/undefined/空串)统一为「未标注」。与 store.normalizeScopeText 的落库
|
|
45
|
+
// 口径一致——两把钥匙只有都过这里再比较,NULL 与 'global' 才不会错配。
|
|
46
|
+
function scopeKeyOf(v) {
|
|
47
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// v0.8.0 A2(issue #17)scope 检索加权系数:两维都无法确立 foreign 的候选
|
|
51
|
+
// (命中行、未标注行、当前侧维度解析不到的行)→ 加成;只有确立了 foreign
|
|
52
|
+
// (记忆带标注 + 当前维度可解析 + 值不等)→ 降权但保留可见(硬过滤是 A3
|
|
53
|
+
// strictScope)。即未标注行与命中行同列、不吃惩罚,但注意它们吃的是 BOOST
|
|
54
|
+
// 而非 ×1 中性。系数是模块常量而非配置项:A2 只定性行为,数值要等线上检索
|
|
55
|
+
// 质量反馈再调(加配置面=提前优化)。
|
|
56
|
+
const SCOPE_MATCH_BOOST = 1.25;
|
|
57
|
+
const SCOPE_FOREIGN_PENALTY = 0.5;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 单条候选的 scope 乘数。匹配语义与去重键一致(scopeKeyOf):记忆侧未标注视同
|
|
61
|
+
* 该维度命中(全局记忆对谁都可见);当前会话侧某维度解析不到则该维度不参与
|
|
62
|
+
* 比较(无法比较 ≠ 不匹配)。
|
|
63
|
+
*/
|
|
64
|
+
function scopeMultiplier(m, current) {
|
|
65
|
+
const agentForeign = scopeKeyOf(m.agent_scope) !== null
|
|
66
|
+
&& current.agent_scope !== null
|
|
67
|
+
&& scopeKeyOf(m.agent_scope) !== current.agent_scope;
|
|
68
|
+
const workspaceForeign = scopeKeyOf(m.workspace_scope) !== null
|
|
69
|
+
&& current.workspace_scope !== null
|
|
70
|
+
&& scopeKeyOf(m.workspace_scope) !== current.workspace_scope;
|
|
71
|
+
return (agentForeign || workspaceForeign) ? SCOPE_FOREIGN_PENALTY : SCOPE_MATCH_BOOST;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* v0.8.0 A2:occurred_at 后置过滤谓词(搜索融合池用)。行侧取
|
|
76
|
+
* COALESCE(occurred_at, created_at)——与 store.list 的 SQL 过滤同口径,未标注
|
|
77
|
+
* 行回退创建时间;时间戳损坏的行保留(宁可放宽也不误杀,同 summarize inWindow)。
|
|
78
|
+
*/
|
|
79
|
+
function inOccurredBounds(m, bounds) {
|
|
80
|
+
const t = Date.parse(String(m.occurred_at ?? m.created_at ?? ""));
|
|
81
|
+
if (!Number.isFinite(t)) return true;
|
|
82
|
+
if (bounds.from && t < Date.parse(bounds.from)) return false;
|
|
83
|
+
if (bounds.to && t > Date.parse(bounds.to)) return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* v0.8.0 A3(issue #17)strictScope 可见性谓词:issue 的四象限可见性公式
|
|
89
|
+
* (agent 不对称可见性)——记忆对当前会话可见 ⇔ (agent 维:未标注 或 命中当前
|
|
90
|
+
* agent) AND (workspace 维:未标注 或 命中当前 workspace)。NULL=未标注=全局;
|
|
91
|
+
* 当前会话某维度解析不到时,该维度带标注的记忆一律不可见(fail-closed:身份
|
|
92
|
+
* 不明的会话只见全局,不冒认)。sensitivity 是标签不参与可见性判定(其语义
|
|
93
|
+
* 留给后续批次)。store.list 的 SQL 过滤与此谓词同口径(见 store.js visibility)。
|
|
94
|
+
*/
|
|
95
|
+
function isVisibleInScope(m, current) {
|
|
96
|
+
const agent = scopeKeyOf(m.agent_scope);
|
|
97
|
+
if (agent !== null && (current.agent_scope === null || agent !== current.agent_scope)) return false;
|
|
98
|
+
const ws = scopeKeyOf(m.workspace_scope);
|
|
99
|
+
if (ws !== null && (current.workspace_scope === null || ws !== current.workspace_scope)) return false;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
35
103
|
/** Prepend the previous content to a memory's content_history (FIFO capped). */
|
|
36
104
|
function pushContentHistory(existing, source) {
|
|
37
105
|
const history = Array.isArray(existing?.content_history) ? existing.content_history : [];
|
|
@@ -78,6 +146,7 @@ export function computeRetrievalMetrics(actualIds, expectedIds) {
|
|
|
78
146
|
}
|
|
79
147
|
|
|
80
148
|
export function createService({ store, mirror, config, onWrite, logger }) {
|
|
149
|
+
const language = langOf(config);
|
|
81
150
|
// Optional dream scheduler hook, installed via setDreamHook after creation
|
|
82
151
|
// (the scheduler holds a reference back to the service, so it cannot be
|
|
83
152
|
// passed in the constructor). Fired on the same write events as onWrite.
|
|
@@ -582,7 +651,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
582
651
|
* a vector/rerank failure degrades to keyword results.
|
|
583
652
|
*/
|
|
584
653
|
async function searchMemories(query, options = {}) {
|
|
585
|
-
const {
|
|
654
|
+
const {
|
|
655
|
+
mode = "auto",
|
|
656
|
+
topK = 20,
|
|
657
|
+
threshold,
|
|
658
|
+
useRerank = true,
|
|
659
|
+
// v0.8.0 A2(issue #17):当前会话 scope(工具层从 exec 解析后传入)与
|
|
660
|
+
// occurred_at 时间窗。两者都只在显式传入时生效,既有调用方(注入/梦/评测)
|
|
661
|
+
// 不传 → 行为与 A2 前一致。
|
|
662
|
+
scope = null,
|
|
663
|
+
occurredFrom = null,
|
|
664
|
+
occurredTo = null,
|
|
665
|
+
recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true)
|
|
666
|
+
} = options;
|
|
586
667
|
const q = String(query ?? "").trim();
|
|
587
668
|
if (!q) return [];
|
|
588
669
|
|
|
@@ -649,6 +730,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
649
730
|
|
|
650
731
|
const { merged: fusedMerged, signals } = fuseRecall({ keyword, vector, bm25, lim, mode, wv, wk, wb });
|
|
651
732
|
let merged = fusedMerged;
|
|
733
|
+
// v0.8.0 A3(issue #17):strictScope 硬过滤——他 scope 的候选直接出局
|
|
734
|
+
// (区别于 A2 的降权保留可见);未标注行与命中行保留。strict 与 A2 加权
|
|
735
|
+
// 叠加:过滤后剩下的命中行仍吃加成。scope 未传(flag 关)或完全解析不到
|
|
736
|
+
// 时跳过——identity 为空的对象({null,null})按 fail-closed 过滤。
|
|
737
|
+
if (config.strictScope === true && scope) {
|
|
738
|
+
merged = merged.filter((m) => isVisibleInScope(m, scope));
|
|
739
|
+
}
|
|
740
|
+
// v0.8.0 A2:occurred_at 时间过滤——在融合池上先滤再 dedup/slice,rerank
|
|
741
|
+
// 只看窗内候选,topK 槽位不被窗外行占用。
|
|
742
|
+
const occurredBounds = updatedAtBounds(occurredFrom, occurredTo);
|
|
743
|
+
if (occurredBounds.from || occurredBounds.to) {
|
|
744
|
+
merged = merged.filter((m) => inOccurredBounds(m, occurredBounds));
|
|
745
|
+
}
|
|
652
746
|
// Signal transparency (#2): decorate each returned row with its per-source
|
|
653
747
|
// scores and the final fused score. Purely additive — never changes rank.
|
|
654
748
|
if (config?.signalTransparency === true) {
|
|
@@ -678,6 +772,18 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
678
772
|
.slice(0, lim);
|
|
679
773
|
}
|
|
680
774
|
|
|
775
|
+
// v0.8.0 A2(issue #17):scope 检索加权——两维都无法确立 foreign 的候选
|
|
776
|
+
// (命中/未标注/当前侧解析不到)加成,确立 foreign 的候选降权但保留可见
|
|
777
|
+
// (硬过滤是 A3 strictScope);未标注行与命中行同列吃 BOOST、不被压制。
|
|
778
|
+
// flag 关或调用方未传 scope 时不动分,排序与 A2 前逐字节一致。与
|
|
779
|
+
// epistemic 加权同款收尾:乘分 → 降序 → 截 topK。
|
|
780
|
+
if (config.scopeEnabled === true && scope && (scope.agent_scope || scope.workspace_scope)) {
|
|
781
|
+
result = result
|
|
782
|
+
.map((m) => ({ ...m, score: (m.score ?? 0) * scopeMultiplier(m, scope) }))
|
|
783
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
784
|
+
.slice(0, lim);
|
|
785
|
+
}
|
|
786
|
+
|
|
681
787
|
// Recall layer receipt: with recordRecall on, hand the actual merged
|
|
682
788
|
// candidate list (id/title/content/score/source) to the injected recorder
|
|
683
789
|
// before returning, making the retrieval scene replayable — the sibling of
|
|
@@ -865,6 +971,60 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
865
971
|
}
|
|
866
972
|
}
|
|
867
973
|
|
|
974
|
+
/**
|
|
975
|
+
* 写入端同会话语义去重(Issue #127,opt-in,默认 off):落库前把新条目与
|
|
976
|
+
* 「同会话 + 时间窗」内已落库的记忆比对,命中即并入既有条目,不再新造一行。
|
|
977
|
+
*
|
|
978
|
+
* 分层:title 档走零成本的标题归一化;vector 档复用已有 embedding 列做近邻,
|
|
979
|
+
* 新条目的向量由 embedder 现算(embedQuery),全程无 LLM 调用。embedder 不可用
|
|
980
|
+
* 或向量缺失一律返回 undefined 回落正常写入——去重是增强,绝不能成为写入依赖。
|
|
981
|
+
*
|
|
982
|
+
* 作用域刻意限定「同会话」:跨会话的同类事实仍交给 autoDream / sleep 的批量裁决。
|
|
983
|
+
* 比对逻辑与并入动作分别复用 cosineVec 与 saveWithDedupe 的 _mergeInto 分支。
|
|
984
|
+
*
|
|
985
|
+
* @returns {{action: "merged", memory: object, sim: number}|undefined} 命中则返回
|
|
986
|
+
* 并入目标与相似度;未命中(含任何异常)返回 undefined。
|
|
987
|
+
*/
|
|
988
|
+
async function findSessionDuplicate(memory, { mode = "off", minSim = 0.92, windowHours = 24, source } = {}) {
|
|
989
|
+
if (mode === "off" || !source || !memory?.title) return undefined;
|
|
990
|
+
try {
|
|
991
|
+
const sinceMs = windowHours > 0 ? Date.now() - windowHours * 3600000 : 0;
|
|
992
|
+
const inWindow = (m) => {
|
|
993
|
+
if (sinceMs <= 0) return true;
|
|
994
|
+
const t = Date.parse(m.created_at ?? "");
|
|
995
|
+
return !Number.isFinite(t) || t >= sinceMs; // 时间戳损坏时不误杀
|
|
996
|
+
};
|
|
997
|
+
const candidates = store
|
|
998
|
+
.list({ type: memory.type, source, limit: 200 })
|
|
999
|
+
.filter((m) => !m.archived && inWindow(m));
|
|
1000
|
+
if (!candidates.length) return undefined;
|
|
1001
|
+
|
|
1002
|
+
if (mode === "title") {
|
|
1003
|
+
const norm = (s) => String(s ?? "").toLowerCase().replace(/[\s\p{P}]+/gu, "");
|
|
1004
|
+
const key = norm(memory.title);
|
|
1005
|
+
if (!key) return undefined;
|
|
1006
|
+
const hit = candidates.find((m) => norm(m.title) === key);
|
|
1007
|
+
return hit ? { action: "merged", memory: hit, sim: 1 } : undefined;
|
|
1008
|
+
}
|
|
1009
|
+
if (mode !== "vector") return undefined;
|
|
1010
|
+
|
|
1011
|
+
const vecs = store.getEmbeddings(candidates.map((m) => m.id));
|
|
1012
|
+
if (!vecs.size) return undefined;
|
|
1013
|
+
const probe = await embedQuery([memory.title, memory.content].filter(Boolean).join("\n"));
|
|
1014
|
+
if (!probe) return undefined;
|
|
1015
|
+
let best;
|
|
1016
|
+
for (const m of candidates) {
|
|
1017
|
+
const v = vecs.get(m.id);
|
|
1018
|
+
if (!v) continue; // 无向量的既有行不参与比对(无信号 = 不判定)
|
|
1019
|
+
const sim = cosineVec(probe, v);
|
|
1020
|
+
if (sim >= minSim && (!best || sim > best.sim)) best = { action: "merged", memory: m, sim };
|
|
1021
|
+
}
|
|
1022
|
+
return best;
|
|
1023
|
+
} catch {
|
|
1024
|
+
return undefined; // 去重失败绝不阻塞写入
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
868
1028
|
/**
|
|
869
1029
|
* Save a memory, merging into an existing one when title matches within the same type.
|
|
870
1030
|
*
|
|
@@ -900,9 +1060,32 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
900
1060
|
});
|
|
901
1061
|
} catch { /* quality scoring is best-effort */ }
|
|
902
1062
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1063
|
+
// dream 总览按 source 身份去重,而非本地化标题:memory.language 切换后
|
|
1064
|
+
// 标题变化,若仍按 (type, title) 匹配会出现两个活跃的 summary 同时注入。
|
|
1065
|
+
// 其余类型维持 (type, title) 匹配不变。
|
|
1066
|
+
const candidates = store.list({ type: memory.type, limit: 100 });
|
|
1067
|
+
// v0.8.0 A1(issue #17):scopeEnabled 开启时去重键由 (type, title) 扩展为
|
|
1068
|
+
// (type, title, agent_scope, workspace_scope, sensitivity)——不同作用域的同
|
|
1069
|
+
// 标题记忆绝不互相物理合并(旧逻辑跨 agent/workspace 并行,事后无法拆分)。
|
|
1070
|
+
// NULL 归一为「未标注」:未标注行之间互相匹配(含存量行),与已标注行不
|
|
1071
|
+
// 匹配。flag 关闭时跳过比较,行为与 A1 前逐字节一致。
|
|
1072
|
+
const scopeOn = config.scopeEnabled === true;
|
|
1073
|
+
const scopeMatches = (m) =>
|
|
1074
|
+
!scopeOn ||
|
|
1075
|
+
(scopeKeyOf(m.agent_scope) === scopeKeyOf(memory.agent_scope) &&
|
|
1076
|
+
scopeKeyOf(m.workspace_scope) === scopeKeyOf(memory.workspace_scope) &&
|
|
1077
|
+
scopeKeyOf(m.sensitivity) === scopeKeyOf(memory.sensitivity));
|
|
1078
|
+
// Issue #127:写入端语义去重命中时,调用方用 _mergeInto 指定并入目标——复用
|
|
1079
|
+
// 下面这段并入逻辑(appendContent + content_history + 质量处置),不另写第二份
|
|
1080
|
+
// 实现。目标已被并发删除时回落到常规匹配。显式目标同样过 scope 门:跨作用域
|
|
1081
|
+
// 的语义相似不并入(防泄漏),回落为独立新行。
|
|
1082
|
+
const explicitTarget = memory._mergeInto ? store.getById(memory._mergeInto) : undefined;
|
|
1083
|
+
const existing = explicitTarget
|
|
1084
|
+
? (scopeMatches(explicitTarget) ? explicitTarget : undefined)
|
|
1085
|
+
: (memory.type === "summary" && memory.source === "dream")
|
|
1086
|
+
? (candidates.find((m) => m.source === "dream" && scopeMatches(m))
|
|
1087
|
+
?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim()))
|
|
1088
|
+
: candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim());
|
|
906
1089
|
if (existing) {
|
|
907
1090
|
const newContent = String(memory.content ?? "");
|
|
908
1091
|
if (!newContent.trim()) {
|
|
@@ -938,6 +1121,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
938
1121
|
tags: memory.tags ?? [],
|
|
939
1122
|
importance: memory.importance ?? 3,
|
|
940
1123
|
source: memory.source ?? "manual",
|
|
1124
|
+
// v0.8.0 A1:scope 标注透传(store 端归一化,未标注落 NULL)。
|
|
1125
|
+
agent_scope: memory.agent_scope,
|
|
1126
|
+
workspace_scope: memory.workspace_scope,
|
|
1127
|
+
sensitivity: memory.sensitivity,
|
|
1128
|
+
occurred_at: memory.occurred_at,
|
|
941
1129
|
...(quality ? { quality_score: quality.score } : {})
|
|
942
1130
|
});
|
|
943
1131
|
const result = applyQualityDisposition(created, quality, qf);
|
|
@@ -956,6 +1144,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
956
1144
|
* (importance × score/100). Best-effort: a disposition write failure must
|
|
957
1145
|
* never fail the save. Returns the (possibly refreshed) memory row so callers
|
|
958
1146
|
* see the archived/tagged state, not the pre-disposition snapshot.
|
|
1147
|
+
*
|
|
1148
|
+
* Issue #135 附属发现 1:importance ≥ memoryQualityFilter.exemptImportance
|
|
1149
|
+
* (默认 4)的记忆不参与静默自动归档——评分与信号标签照常写入(可观测),
|
|
1150
|
+
* 注入排序照常按 importance × score/100 降权,但 setArchived 跳过。报告实测
|
|
1151
|
+
* 150 条低分归档里 128 条 importance ≥ 4(51 条 = 5):一条被显式标为重要的
|
|
1152
|
+
* 记忆不该被规则分静默归档、无感知无豁免。
|
|
959
1153
|
*/
|
|
960
1154
|
function applyQualityDisposition(memory, quality, qf) {
|
|
961
1155
|
if (!quality || qf?.enabled !== true) return memory;
|
|
@@ -963,14 +1157,21 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
963
1157
|
// Signal tags (meta / repetitive / duplicate / short_content / low_quality)
|
|
964
1158
|
// are merged onto the stored row in every assessed band so the verdict is
|
|
965
1159
|
// observable, not just the numeric score. Below the archive threshold the
|
|
966
|
-
// memory is additionally archived (still explicitly searchable)
|
|
1160
|
+
// memory is additionally archived (still explicitly searchable) — unless
|
|
1161
|
+
// its importance reaches the exemption floor (kept active, demoted only).
|
|
967
1162
|
const tags = [...new Set([...(memory.tags ?? []), ...(quality.tags ?? [])])];
|
|
968
|
-
|
|
1163
|
+
const exemptImportance = qf.exemptImportance ?? 4;
|
|
1164
|
+
const importanceExempt = Number.isInteger(memory.importance) && memory.importance >= exemptImportance;
|
|
1165
|
+
if (tags.length === (memory.tags?.length ?? 0) && (quality.score >= archiveThreshold || importanceExempt)) {
|
|
969
1166
|
return memory; // no tag drift and not archived → nothing extra to write
|
|
970
1167
|
}
|
|
971
1168
|
try {
|
|
972
1169
|
store.update(memory.id, { tags, quality_score: quality.score });
|
|
973
|
-
if (quality.score < archiveThreshold
|
|
1170
|
+
if (quality.score < archiveThreshold && !importanceExempt) {
|
|
1171
|
+
store.setArchived(memory.id, true);
|
|
1172
|
+
} else if (quality.score < archiveThreshold) {
|
|
1173
|
+
logger?.info?.(`[dsh-mneme] quality filter: memory ${memory.id} scored ${quality.score} < ${archiveThreshold} but importance ${memory.importance} >= exemptImportance ${exemptImportance}; demoted, not archived`);
|
|
1174
|
+
}
|
|
974
1175
|
return store.getById(memory.id);
|
|
975
1176
|
} catch {
|
|
976
1177
|
return memory;
|
|
@@ -990,7 +1191,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
990
1191
|
* fills + dedupes the remaining slots. Empty query / no cached recall /
|
|
991
1192
|
* hybridInject off → pure legacy rule-based selection.
|
|
992
1193
|
*/
|
|
993
|
-
function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector } = {}) {
|
|
1194
|
+
function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector, scope = null } = {}) {
|
|
994
1195
|
const q = String(query ?? "").trim();
|
|
995
1196
|
// codingRetrospect 读取侧门控:编码记忆(rejected_solution / pitfall /
|
|
996
1197
|
// constraint)只在编码任务时注入,防噪声污染其他业务;编码任务时按
|
|
@@ -1080,6 +1281,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1080
1281
|
}
|
|
1081
1282
|
} catch { /* topic re-rank unavailable: keep rule-based order */ }
|
|
1082
1283
|
}
|
|
1284
|
+
// v0.8.0 A3(issue #17):strictScope 硬过滤同样作用于自动注入——scoped 记忆
|
|
1285
|
+
// 泄进无关注入上下文是最典型的越权通道,检索侧过滤挡不住这里。
|
|
1286
|
+
if (config?.strictScope === true && scope) {
|
|
1287
|
+
candidates = candidates.filter((m) => isVisibleInScope(m, scope));
|
|
1288
|
+
}
|
|
1083
1289
|
const selected = candidates.slice(0, maxItems);
|
|
1084
1290
|
touchRecalled(selected);
|
|
1085
1291
|
return selected;
|
|
@@ -1133,6 +1339,94 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1133
1339
|
return applied;
|
|
1134
1340
|
}
|
|
1135
1341
|
|
|
1342
|
+
/**
|
|
1343
|
+
* v0.8.0 冲突集中处理(issue 反馈:冻结冲突没有人工出口——store 的
|
|
1344
|
+
* resolveConflictPending 此前无任何 API/UI 调用)。人工确认一条待审冲突:
|
|
1345
|
+
* 盖章 resolved_at/resolved_winner(审计),可选按 dream 非冻结 conflict
|
|
1346
|
+
* 路径**同款**处置落地——复用 applyDecisions 的 conflict 分支,CAS 快照 +
|
|
1347
|
+
* 事务 + 胜者追加已否决注记 + 败者归档 + 幂等全部同源,不另写第二份裁决。
|
|
1348
|
+
*
|
|
1349
|
+
* @param {string} id conflict_pending 行 id
|
|
1350
|
+
* @param {{winner?: "a"|"b"|null, apply?: boolean}} opts
|
|
1351
|
+
* winner: 选哪一方保留(a = memory_a,冻结时 LLM 的建议胜者;b = memory_b);
|
|
1352
|
+
* null = 仅盖章标记已读、不落地处置。人工选择是权威裁决:传入
|
|
1353
|
+
* applyDecisions 的 config 关掉 trustEpistemicWeighting——可信度自动换位
|
|
1354
|
+
* 只应作用于 LLM 提议对,不能推翻人工选择。
|
|
1355
|
+
* @returns {object|undefined} 盖章后的冲突行 + disposition 摘要;未知 id 返回 undefined
|
|
1356
|
+
*/
|
|
1357
|
+
function resolveConflictPending(id, { winner = null, apply = true } = {}) {
|
|
1358
|
+
const rows = store.listConflictPending({ includeResolved: true, limit: 500 });
|
|
1359
|
+
const row = rows.find((r) => r.id === id);
|
|
1360
|
+
if (!row) return undefined;
|
|
1361
|
+
const winnerId = winner === "a" ? row.memory_a : winner === "b" ? row.memory_b : null;
|
|
1362
|
+
const loserId = winner === "a" ? row.memory_b : winner === "b" ? row.memory_a : null;
|
|
1363
|
+
let disposition = null;
|
|
1364
|
+
if (apply && winnerId && loserId) {
|
|
1365
|
+
const snapshot = new Map();
|
|
1366
|
+
for (const m of [store.getById(row.memory_a), store.getById(row.memory_b)]) {
|
|
1367
|
+
if (m) snapshot.set(m.id, { updated_at: m.updated_at, content: m.content, title: m.title });
|
|
1368
|
+
}
|
|
1369
|
+
// applyDecisions 需要一个 service 形状的参数(getById/update/setArchived/
|
|
1370
|
+
// transaction)——委托给闭包内的同名实现,mirror/通知/嵌入与主写路径同源。
|
|
1371
|
+
const applyService = {
|
|
1372
|
+
getById: (mid) => store.getById(mid),
|
|
1373
|
+
update: (mid, patch) => updateMemory(mid, patch),
|
|
1374
|
+
setArchived: (mid, v) => archiveMemory(mid, v),
|
|
1375
|
+
transaction: (fn) => transaction(fn)
|
|
1376
|
+
};
|
|
1377
|
+
const { committed, failures } = applyDecisions(
|
|
1378
|
+
[{ action: "conflict", winner: winnerId, loser: loserId, reason: row.reason ?? "" }],
|
|
1379
|
+
applyService,
|
|
1380
|
+
logger,
|
|
1381
|
+
snapshot,
|
|
1382
|
+
{ ...config, trustEpistemicWeighting: false }
|
|
1383
|
+
);
|
|
1384
|
+
// Review 发现:处置必须**先于**盖章。原实现先 resolveConflictPending 盖章、
|
|
1385
|
+
// 再 applyDecisions——若 CAS 拒掉(确认前双方被并发更新),行已 resolved 从
|
|
1386
|
+
// 队列消失、前端仍显示成功,审计却记了"已确认"且无法重试。现在失败时不盖章
|
|
1387
|
+
// 直接返回:行留在队列待重试,审计不被污染。
|
|
1388
|
+
if (failures.length) {
|
|
1389
|
+
return { ...row, disposition: { ok: false, failures } };
|
|
1390
|
+
}
|
|
1391
|
+
disposition = { ok: true, committed };
|
|
1392
|
+
}
|
|
1393
|
+
// 处置成功(或纯标记)后才盖章。幂等重放(败者已归档 → skipped)也走这里:
|
|
1394
|
+
// 处置本就存在,标记已审即可。
|
|
1395
|
+
const stamped = store.resolveConflictPending(id, { winner: winnerId });
|
|
1396
|
+
if (!stamped) return undefined;
|
|
1397
|
+
return { ...stamped, disposition };
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* v0.8.0 冲突队列视图数据源:未解决冲突行,联表带出双方的当前状态(标题/
|
|
1402
|
+
* 内容/类型/重要性/归档标记),面板一处即可集中审阅,不必按徽章逐条找。
|
|
1403
|
+
* 参与记忆被删除/失联时该侧返回 {missing: true}(队列不因此丢行)。
|
|
1404
|
+
*/
|
|
1405
|
+
function listConflictQueue({ limit = 50, offset = 0 } = {}) {
|
|
1406
|
+
const rows = store.listConflictPending({ limit, offset, includeResolved: false });
|
|
1407
|
+
const side = (id) => {
|
|
1408
|
+
const m = store.getById(id);
|
|
1409
|
+
if (!m) return { id, missing: true };
|
|
1410
|
+
return {
|
|
1411
|
+
id,
|
|
1412
|
+
title: m.title,
|
|
1413
|
+
type: m.type,
|
|
1414
|
+
importance: m.importance,
|
|
1415
|
+
content: m.content,
|
|
1416
|
+
archived: m.archived === true,
|
|
1417
|
+
forgotten: m.forgotten === true
|
|
1418
|
+
};
|
|
1419
|
+
};
|
|
1420
|
+
return rows.map((r) => ({
|
|
1421
|
+
id: r.id,
|
|
1422
|
+
run_id: r.run_id ?? null,
|
|
1423
|
+
reason: r.reason ?? null,
|
|
1424
|
+
created_at: r.created_at,
|
|
1425
|
+
memory_a: side(r.memory_a),
|
|
1426
|
+
memory_b: side(r.memory_b)
|
|
1427
|
+
}));
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1136
1430
|
function toApiList(rows) {
|
|
1137
1431
|
return rows.map((m) => ({
|
|
1138
1432
|
id: m.id,
|
|
@@ -1143,7 +1437,14 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1143
1437
|
importance: m.importance,
|
|
1144
1438
|
source: m.source,
|
|
1145
1439
|
created_at: m.created_at,
|
|
1146
|
-
updated_at: m.updated_at
|
|
1440
|
+
updated_at: m.updated_at,
|
|
1441
|
+
// v0.8.0 A2:scope 标注与事件发生时间透出——条件展开(未标注行不带键,
|
|
1442
|
+
// DTO 与 A2 前逐字节同形;带 undefined 键会被 in-process schema 校验
|
|
1443
|
+
// 判违规,JSON 序列化虽会丢弃但形状不稳定)。
|
|
1444
|
+
...(m.agent_scope !== undefined ? { agent_scope: m.agent_scope } : {}),
|
|
1445
|
+
...(m.workspace_scope !== undefined ? { workspace_scope: m.workspace_scope } : {}),
|
|
1446
|
+
...(m.sensitivity !== undefined ? { sensitivity: m.sensitivity } : {}),
|
|
1447
|
+
...(m.occurred_at !== undefined ? { occurred_at: m.occurred_at } : {})
|
|
1147
1448
|
}));
|
|
1148
1449
|
}
|
|
1149
1450
|
|
|
@@ -1195,7 +1496,8 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1195
1496
|
// (保留现有 storeChanged 逻辑)
|
|
1196
1497
|
const storeChanged = edit.updated_at !== undefined && m.updated_at !== edit.updated_at;
|
|
1197
1498
|
if (storeChanged) {
|
|
1198
|
-
|
|
1499
|
+
// 三方合并保留双方时的冲突批注:随实例语言,写进记忆正文。
|
|
1500
|
+
const marker = STR.serviceConflictMarker[language](m.updated_at, m.content);
|
|
1199
1501
|
store.update(m.id, { title: edit.title, content: `${edit.content}${marker}` });
|
|
1200
1502
|
} else {
|
|
1201
1503
|
store.update(m.id, { title: edit.title, content: edit.content });
|
|
@@ -1423,6 +1725,62 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1423
1725
|
}
|
|
1424
1726
|
}
|
|
1425
1727
|
|
|
1728
|
+
// 返回字面量与内部裁决器(resolveConflictPending → applyDecisions 的 conflict
|
|
1729
|
+
// 处置)共用的写路径:mirror 同步、写通知、嵌入调度全部同源,不能各写一份。
|
|
1730
|
+
function updateMemory(id, p, ctx = {}) {
|
|
1731
|
+
const old = store.getById(id);
|
|
1732
|
+
// Issue #135 附属发现 2:质量过滤器把「为什么被降权/归档」写在系统信号标签
|
|
1733
|
+
// 上(SIGNAL_TAGS,applyQualityDisposition 以并集写入),而这里整组替换
|
|
1734
|
+
// tags——任何带 tags 的更新都会抹掉这条唯一的审计线索(报告实测 150 条低分
|
|
1735
|
+
// 记忆里恰好缺的 2 条就是被 update 过的,并因此误判过归档来源)。把 existing
|
|
1736
|
+
// 的系统信号标签并集保留;用户自传的标签照常生效。
|
|
1737
|
+
const patch = (p && Array.isArray(p.tags) && old && Array.isArray(old.tags))
|
|
1738
|
+
? { ...p, tags: [...new Set([...p.tags, ...old.tags.filter((t) => SIGNAL_TAGS.includes(t))])] }
|
|
1739
|
+
: p;
|
|
1740
|
+
const updated = store.update(id, patch);
|
|
1741
|
+
// Record a user correction when any meaningful field changed and the
|
|
1742
|
+
// reflection failure tracker is enabled. expected = what it became,
|
|
1743
|
+
// actual = what it was before; query (when provided) captures the
|
|
1744
|
+
// user's original intent so later reflection can reason about recall.
|
|
1745
|
+
const hasMeaningfulChange = old && updated && (
|
|
1746
|
+
old.content !== updated.content ||
|
|
1747
|
+
old.title !== updated.title ||
|
|
1748
|
+
old.importance !== updated.importance
|
|
1749
|
+
);
|
|
1750
|
+
if (hasMeaningfulChange && config.reflectionFailureTracking) {
|
|
1751
|
+
store.saveFailure({
|
|
1752
|
+
id: randomUUID(),
|
|
1753
|
+
query: ctx.query ?? null,
|
|
1754
|
+
expected: updated.content,
|
|
1755
|
+
actual: old.content,
|
|
1756
|
+
before: { title: old.title, content: old.content, importance: old.importance },
|
|
1757
|
+
failure_type: "user_correction",
|
|
1758
|
+
memory_id: id
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
const sync = afterSync("write");
|
|
1762
|
+
notifyWrite();
|
|
1763
|
+
scheduleEmbed(updated);
|
|
1764
|
+
// Audit peer B: when the mirror sync failed, the store write landed but
|
|
1765
|
+
// the mirror did not converge — return an explicit degraded receipt rather
|
|
1766
|
+
// than a plain success. Non-enumerable so existing deepEqual assertions on
|
|
1767
|
+
// the memory shape keep passing.
|
|
1768
|
+
if (!sync?.success && !sync?.deferred) {
|
|
1769
|
+
Object.defineProperty(updated, "_mirror", {
|
|
1770
|
+
value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
|
|
1771
|
+
enumerable: false,
|
|
1772
|
+
configurable: true
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
return updated;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function archiveMemory(id, f) {
|
|
1779
|
+
const updated = store.setArchived(id, f);
|
|
1780
|
+
afterSync("write");
|
|
1781
|
+
return updated;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1426
1784
|
return {
|
|
1427
1785
|
saveWithDedupe,
|
|
1428
1786
|
recoverMirror,
|
|
@@ -1431,6 +1789,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1431
1789
|
injectCandidates,
|
|
1432
1790
|
mergeHumanEdits,
|
|
1433
1791
|
toApiList,
|
|
1792
|
+
isVisibleInScope,
|
|
1434
1793
|
transaction,
|
|
1435
1794
|
enqueue,
|
|
1436
1795
|
setDreamHook(fn) { dreamHook = fn; },
|
|
@@ -1467,6 +1826,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1467
1826
|
setRecallRecorder(fn) { recallRecorder = fn; },
|
|
1468
1827
|
searchMemories,
|
|
1469
1828
|
embedQuery,
|
|
1829
|
+
findSessionDuplicate,
|
|
1470
1830
|
evaluateRetrieval,
|
|
1471
1831
|
computeRetrievalMetrics,
|
|
1472
1832
|
// passthroughs used by tools and api layers; mutations keep the mirror in sync
|
|
@@ -1482,45 +1842,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1482
1842
|
afterSync("write");
|
|
1483
1843
|
notifyWrite();
|
|
1484
1844
|
},
|
|
1485
|
-
update:
|
|
1486
|
-
const old = store.getById(id);
|
|
1487
|
-
const updated = store.update(id, p);
|
|
1488
|
-
// Record a user correction when any meaningful field changed and the
|
|
1489
|
-
// reflection failure tracker is enabled. expected = what it became,
|
|
1490
|
-
// actual = what it was before; query (when provided) captures the
|
|
1491
|
-
// user's original intent so later reflection can reason about recall.
|
|
1492
|
-
const hasMeaningfulChange = old && updated && (
|
|
1493
|
-
old.content !== updated.content ||
|
|
1494
|
-
old.title !== updated.title ||
|
|
1495
|
-
old.importance !== updated.importance
|
|
1496
|
-
);
|
|
1497
|
-
if (hasMeaningfulChange && config.reflectionFailureTracking) {
|
|
1498
|
-
store.saveFailure({
|
|
1499
|
-
id: randomUUID(),
|
|
1500
|
-
query: ctx.query ?? null,
|
|
1501
|
-
expected: updated.content,
|
|
1502
|
-
actual: old.content,
|
|
1503
|
-
before: { title: old.title, content: old.content, importance: old.importance },
|
|
1504
|
-
failure_type: "user_correction",
|
|
1505
|
-
memory_id: id
|
|
1506
|
-
});
|
|
1507
|
-
}
|
|
1508
|
-
const sync = afterSync("write");
|
|
1509
|
-
notifyWrite();
|
|
1510
|
-
scheduleEmbed(updated);
|
|
1511
|
-
// Audit peer B: when the mirror sync failed, the store write landed but
|
|
1512
|
-
// the mirror did not converge — return an explicit degraded receipt rather
|
|
1513
|
-
// than a plain success. Non-enumerable so existing deepEqual assertions on
|
|
1514
|
-
// the memory shape keep passing.
|
|
1515
|
-
if (!sync?.success && !sync?.deferred) {
|
|
1516
|
-
Object.defineProperty(updated, "_mirror", {
|
|
1517
|
-
value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
|
|
1518
|
-
enumerable: false,
|
|
1519
|
-
configurable: true
|
|
1520
|
-
});
|
|
1521
|
-
}
|
|
1522
|
-
return updated;
|
|
1523
|
-
},
|
|
1845
|
+
update: updateMemory,
|
|
1524
1846
|
// Compare-and-set update: applies the patch only when the row still carries
|
|
1525
1847
|
// `expectedUpdatedAt`. Returns undefined on a miss (no write) so the caller
|
|
1526
1848
|
// can re-read and retry — the primitive that prevents lost updates across
|
|
@@ -1563,11 +1885,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1563
1885
|
afterSync("write");
|
|
1564
1886
|
return updated;
|
|
1565
1887
|
},
|
|
1566
|
-
setArchived:
|
|
1567
|
-
const updated = store.setArchived(id, f);
|
|
1568
|
-
afterSync("write");
|
|
1569
|
-
return updated;
|
|
1570
|
-
},
|
|
1888
|
+
setArchived: archiveMemory,
|
|
1571
1889
|
// sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate
|
|
1572
1890
|
// content so they ride the normal write-hook path (mirror re-renders).
|
|
1573
1891
|
// touchLastAccess is a read-stamp — deliberately NO write hook (a recall
|
|
@@ -1599,7 +1917,8 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1599
1917
|
// above: an audit write, never a write-hook-triggering memory mutation).
|
|
1600
1918
|
saveConflictPending: (r) => store.saveConflictPending(r),
|
|
1601
1919
|
listConflictPending: (opts) => store.listConflictPending(opts),
|
|
1602
|
-
|
|
1920
|
+
listConflictQueue,
|
|
1921
|
+
resolveConflictPending,
|
|
1603
1922
|
countConflictPending: () => store.countConflictPending(),
|
|
1604
1923
|
// Recall evaluation trail (方案 B): audit-bookkeeping semantics like the
|
|
1605
1924
|
// dream/recall passthroughs above — a recall_evals write is a snapshot, not
|
package/lib/settings.js
CHANGED
|
@@ -64,6 +64,13 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
64
64
|
// Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。
|
|
65
65
|
"dreamSkipInvalid",
|
|
66
66
|
"allowCrossTypeMerge",
|
|
67
|
+
// Issue #17(v0.8.0 A1):scope 隔离存储层总开关。开启后写入标注
|
|
68
|
+
// agent_scope/workspace_scope、去重键扩展(含 sensitivity);检索侧过滤在
|
|
69
|
+
// A2/A3。默认关=行为与 A1 前逐字节一致。
|
|
70
|
+
"scopeEnabled",
|
|
71
|
+
// Issue #17(v0.8.0 A3):strictScope 硬过滤——他 scope 完全不可见(关闭时
|
|
72
|
+
// 为 A2 软隔离:降权保留可见)。
|
|
73
|
+
"strictScope",
|
|
67
74
|
// 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
|
|
68
75
|
// 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
|
|
69
76
|
// 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
|
|
@@ -78,7 +85,21 @@ const FEATURE_FLAG_INT_RANGES = {
|
|
|
78
85
|
distillMaxChars: [1000, 200000],
|
|
79
86
|
codingBoostFactor: [1, 5],
|
|
80
87
|
dreamMinIntervalMinutes: [0, 10080],
|
|
81
|
-
dreamMaxTokens: [256, 131072]
|
|
88
|
+
dreamMaxTokens: [256, 131072],
|
|
89
|
+
// Issue #127:autoSummarize 节流三键(0 = 零行为变化,等同现状)。
|
|
90
|
+
summarizeMinIntervalMinutes: [0, 10080],
|
|
91
|
+
summarizeMaxEntriesPerRun: [0, 50],
|
|
92
|
+
summarizeDedupeWindowHours: [0, 168],
|
|
93
|
+
// Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。
|
|
94
|
+
dreamCandidateMax: [0, 5000]
|
|
95
|
+
};
|
|
96
|
+
// 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关
|
|
97
|
+
// 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。
|
|
98
|
+
const FEATURE_FLAG_NUMBER_RANGES = {
|
|
99
|
+
// Issue #127:vector 去重档的并入阈值。
|
|
100
|
+
summarizeDedupeMinSim: [0.5, 0.99],
|
|
101
|
+
// Issue #125:hybrid 判"高相似"的阈值。
|
|
102
|
+
dreamCandidateMinSim: [0.5, 0.99]
|
|
82
103
|
};
|
|
83
104
|
// 自由字符串开关(与 config.js 的 z.string() 同名同型):trim 后 ≤200 字符,
|
|
84
105
|
// 空串合法(= 跟随主对话模型/默认路径,面板显示 placeholder)。
|
|
@@ -106,7 +127,13 @@ const FEATURE_FLAG_ENUMS = {
|
|
|
106
127
|
// rank/scale-aware alternatives selected by the panel.
|
|
107
128
|
recallFusion: ["blend", "rrf", "minmax"],
|
|
108
129
|
// 实体抽取思考强度(issue #109):与 dreamReasoningEffort 枚举对齐。
|
|
109
|
-
entityExtractionReasoning: ["low", "medium", "high", "none"]
|
|
130
|
+
entityExtractionReasoning: ["low", "medium", "high", "none"],
|
|
131
|
+
// Issue #127:落库前去重档位(off 默认,等同现状)。
|
|
132
|
+
summarizeDedupeMode: ["off", "title", "vector"],
|
|
133
|
+
// Issue #126:sleep 冲突阶段的动作集(conflict 默认 = 现状;full = 六分支)。
|
|
134
|
+
sleepActionSet: ["conflict", "full"],
|
|
135
|
+
// Issue #125:dream 候选集构造方式(window 默认 = 现状;hybrid 并入向量组)。
|
|
136
|
+
dreamCandidateMode: ["window", "hybrid"]
|
|
110
137
|
};
|
|
111
138
|
const FEATURE_FLAG_STRING_MAX = 200;
|
|
112
139
|
|
|
@@ -114,6 +141,7 @@ const FEATURE_FLAG_STRING_MAX = 200;
|
|
|
114
141
|
export const FEATURE_FLAG_SPEC = {
|
|
115
142
|
booleans: FEATURE_FLAG_BOOLEANS,
|
|
116
143
|
ints: FEATURE_FLAG_INT_RANGES,
|
|
144
|
+
numbers: FEATURE_FLAG_NUMBER_RANGES,
|
|
117
145
|
strings: FEATURE_FLAG_STRINGS,
|
|
118
146
|
urls: FEATURE_FLAG_URLS,
|
|
119
147
|
enums: FEATURE_FLAG_ENUMS
|
|
@@ -145,6 +173,14 @@ function validateFlag(key, value) {
|
|
|
145
173
|
}
|
|
146
174
|
return value;
|
|
147
175
|
}
|
|
176
|
+
const numRange = FEATURE_FLAG_NUMBER_RANGES[key];
|
|
177
|
+
if (numRange) {
|
|
178
|
+
const [min, max] = numRange;
|
|
179
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
|
|
180
|
+
throw new TypeError(`feature flag "${key}" must be a number in [${min}, ${max}]`);
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
148
184
|
if (FEATURE_FLAG_STRINGS.includes(key)) {
|
|
149
185
|
if (typeof value !== "string") {
|
|
150
186
|
throw new TypeError(`feature flag "${key}" must be a string`);
|
|
@@ -185,6 +221,11 @@ function sanitizeFlags(raw) {
|
|
|
185
221
|
for (const [key, [min, max]] of Object.entries(FEATURE_FLAG_INT_RANGES)) {
|
|
186
222
|
if (Number.isInteger(raw[key]) && raw[key] >= min && raw[key] <= max) out[key] = raw[key];
|
|
187
223
|
}
|
|
224
|
+
for (const [key, [min, max]] of Object.entries(FEATURE_FLAG_NUMBER_RANGES)) {
|
|
225
|
+
if (typeof raw[key] === "number" && Number.isFinite(raw[key]) && raw[key] >= min && raw[key] <= max) {
|
|
226
|
+
out[key] = raw[key];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
188
229
|
for (const key of FEATURE_FLAG_STRINGS) {
|
|
189
230
|
if (typeof raw[key] === "string" && raw[key].trim().length <= FEATURE_FLAG_STRING_MAX) {
|
|
190
231
|
out[key] = raw[key].trim();
|