@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.
Files changed (93) hide show
  1. package/README.md +65 -27
  2. package/lib/api.js +139 -6
  3. package/lib/client.js +369 -32
  4. package/lib/commands.js +36 -5
  5. package/lib/config.js +101 -14
  6. package/lib/dream/decisions.js +147 -14
  7. package/lib/dream/sleep.js +84 -34
  8. package/lib/dream.js +250 -60
  9. package/lib/embedding.js +22 -2
  10. package/lib/index.js +68 -37
  11. package/lib/inject.js +54 -13
  12. package/lib/lang.js +168 -0
  13. package/lib/local-embedder.js +23 -9
  14. package/lib/mirror.js +17 -14
  15. package/lib/reranker.js +23 -8
  16. package/lib/runtime/adopt.js +176 -0
  17. package/lib/runtime/closure.js +172 -0
  18. package/lib/runtime/download.js +247 -0
  19. package/lib/runtime/layout.js +235 -0
  20. package/lib/runtime/loader.js +249 -0
  21. package/lib/runtime/provision.js +237 -0
  22. package/lib/runtime/tarball.js +130 -0
  23. package/lib/runtime/verify.js +191 -0
  24. package/lib/scope.js +83 -0
  25. package/lib/service.js +374 -55
  26. package/lib/settings.js +43 -2
  27. package/lib/store.js +126 -13
  28. package/lib/summarize.js +108 -35
  29. package/lib/tools.js +205 -12
  30. package/package.json +7 -2
  31. package/runtime-manifest.json +596 -0
  32. package/scripts/build-runtime-manifest.mjs +217 -0
  33. package/scripts/mneme-runtime.mjs +277 -0
  34. package/src/api.js +139 -6
  35. package/src/commands.js +36 -5
  36. package/src/config.js +101 -14
  37. package/src/dream/decisions.js +147 -14
  38. package/src/dream/sleep.js +84 -34
  39. package/src/dream.js +250 -60
  40. package/src/embedding.js +22 -2
  41. package/src/index.js +68 -37
  42. package/src/inject.js +54 -13
  43. package/src/lang.js +168 -0
  44. package/src/local-embedder.js +23 -9
  45. package/src/mirror.js +17 -14
  46. package/src/reranker.js +23 -8
  47. package/src/runtime/adopt.js +176 -0
  48. package/src/runtime/closure.js +172 -0
  49. package/src/runtime/download.js +247 -0
  50. package/src/runtime/layout.js +235 -0
  51. package/src/runtime/loader.js +249 -0
  52. package/src/runtime/provision.js +237 -0
  53. package/src/runtime/tarball.js +130 -0
  54. package/src/runtime/verify.js +191 -0
  55. package/src/scope.js +83 -0
  56. package/src/service.js +374 -55
  57. package/src/settings.js +43 -2
  58. package/src/store.js +126 -13
  59. package/src/summarize.js +108 -35
  60. package/src/tools.js +205 -12
  61. package/test/api.test.js +175 -9
  62. package/test/client.test.js +152 -10
  63. package/test/commands.test.js +40 -3
  64. package/test/config.test.js +17 -1
  65. package/test/dream-candidate-hybrid.test.js +281 -0
  66. package/test/dream.test.js +172 -0
  67. package/test/embedding-ready.test.js +196 -0
  68. package/test/helpers/runtime-source.js +60 -0
  69. package/test/helpers/tar-builder.js +80 -0
  70. package/test/inject.test.js +130 -1
  71. package/test/lang.test.js +87 -0
  72. package/test/quality-filter.test.js +68 -0
  73. package/test/reasoning-effort.test.js +106 -15
  74. package/test/reindex-backfill.test.js +62 -0
  75. package/test/runtime-adopt.test.js +206 -0
  76. package/test/runtime-cli.test.js +89 -0
  77. package/test/runtime-closure.test.js +193 -0
  78. package/test/runtime-download.test.js +302 -0
  79. package/test/runtime-layout.test.js +163 -0
  80. package/test/runtime-loader.test.js +237 -0
  81. package/test/runtime-manifest.test.js +158 -0
  82. package/test/runtime-provision.test.js +101 -0
  83. package/test/runtime-tarball.test.js +82 -0
  84. package/test/runtime-verify.test.js +214 -0
  85. package/test/scope-retrieval.test.js +197 -0
  86. package/test/scope-storage.test.js +247 -0
  87. package/test/scope-strict.test.js +165 -0
  88. package/test/service.test.js +76 -0
  89. package/test/sleep-action-set.test.js +422 -0
  90. package/test/store.test.js +14 -0
  91. package/test/summarize.test.js +224 -0
  92. package/test/tools.test.js +30 -5
  93. package/test/webServer-optional.test.js +34 -1
package/src/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 { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true) } = options;
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
- const existing = store
904
- .list({ type: memory.type, limit: 100 })
905
- .find((m) => m.title.trim() === String(memory.title).trim());
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
- if (tags.length === (memory.tags?.length ?? 0) && quality.score >= archiveThreshold) {
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) store.setArchived(memory.id, true);
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
- const marker = `\n\n> ⚠️ 并发冲突:人工编辑 vs 记忆库并发更新(${m.updated_at})\n> 记忆库版本:${m.content}`;
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: (id, p, ctx = {}) => {
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: (id, f) => {
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
- resolveConflictPending: (id, o) => store.resolveConflictPending(id, o),
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/src/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();