dsh-memu-memory 1.2.0 → 1.2.1

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/lib/memory.js CHANGED
@@ -15,10 +15,11 @@ import { record } from './activity-log.js'
15
15
  import { BASE_AROUSAL, derivedValence, dominanceBias, emotionActive, maxShare, moodCongruence, moodDecay, normalizeEmotion, overlap, readMood } from './mood.js'
16
16
  import { commit, retrieve, listFiles, forget } from './memu.js'
17
17
  import { bumpStats } from './stats.js'
18
- import { recallRelations } from './graph.js'
18
+ import { recallRelations, pruneOrphanEntities } from './graph.js'
19
19
  import { attachmentRefs } from './media.js'
20
20
  import { normalizeMemoryContext, contextVisible, sameFactContext } from './context.js'
21
- import { assertGeneration, assertVectorWriteAllowed, captureGeneration, enrollActiveLibrary, hasActiveVectorGeneration, isVectorStateError, readVectorState, withVectorWrite } from './vector-profile.js'
21
+ import { activeEntryStamp, assertGeneration, assertVectorWriteAllowed, captureGeneration, enrollActiveLibrary, hasActiveVectorGeneration, isVectorStateError, readVectorState, withVectorWrite } from './vector-profile.js'
22
+ import { bigrams, dice, similarTexts, DUP_DICE, DUP_REJECT_DICE, TOPIC_DICE } from './text-similarity.js'
22
23
 
23
24
  const HALF_LIFE_DAYS = 14
24
25
  // A short-term entry is meant to live for the length of a conversation, not a
@@ -38,6 +39,11 @@ const HABITUATION_FLOOR = 0.5
38
39
  // library with three variants of the same decision would spend the whole block on it.
39
40
  // The top hit is always kept; later picks pay lambda x max-similarity-to-what-is-in.
40
41
  const MMR_LAMBDA = 0.72
42
+ // Absolute floor on the retrieval share itself . The relative
43
+ // relevanceThreshold cannot stop a near-empty result set from being padded to topK with
44
+ // junk that only matched a duplicate cluster; a floor cuts those before selection.
45
+ // 0 turns it off entirely — the calibrated default until §13.6 has run.
46
+ const RETRIEVAL_FLOOR = 0
41
47
  // Semantic fusion uses RRF: each route contributes a
42
48
  // rank term and the merged score is normalised to the best fused hit before the
43
49
  // existing mood/strength/MMR pipeline sees it. The candidate cap keeps a long
@@ -555,20 +561,8 @@ export function isTipOfTongue(entry) {
555
561
  // Not semantic entailment: two bigram sets and a negation test. It only has to be
556
562
  // good enough to stop "he likes tea" and "he hates tea" from sitting side by side
557
563
  // as equally true, and quiet enough that ordinary rewordings do not trip it.
558
- function bigrams(text) {
559
- const clean = String(text || '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
560
- const out = new Set()
561
- for (let i = 0; i < clean.length - 1; i += 1) out.add(clean.slice(i, i + 2))
562
- return out
563
- }
564
-
565
- function dice(a, b) {
566
- if (a.size === 0 || b.size === 0) return 0
567
- let shared = 0
568
- for (const gram of a) if (b.has(gram)) shared += 1
569
- return (2 * shared) / (a.size + b.size)
570
- }
571
-
564
+ // The bigrams/dice measure lives in lib/text-similarity.js so recall-side folding
565
+ // and the write-side duplicate gate measure with the exact same ruler.
572
566
  const NEGATION_RE = /不|没|别|无|never|not|no longer|doesn't|don't/i
573
567
 
574
568
  /**
@@ -770,27 +764,47 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
770
764
  if (automatic && entry.visibility === 'secret') return null
771
765
  return { id, entry, file, route, rank }
772
766
  }
773
- const take = (files, route) => {
767
+ const take = (files, route, collapsed) => {
774
768
  const picked = []
775
769
  // One route can name the same canonical entry more than once: a caller may
776
770
  // pass both `mem-x` and `mem-x.md`, and an engine can repeat a hit. Dedupe by
777
771
  // canonical id before scoring and truncating, keeping the first (best) rank.
778
772
  // Otherwise every duplicate adds another 1/(k+rank) and consumes a slot.
779
773
  const seen = new Set()
774
+ // Fact-level folding : id dedupe cannot see "same fact,
775
+ // different id", so a paraphrase cluster would spend one candidate slot per
776
+ // member and crowd real memories out before any ranking runs. A candidate that
777
+ // a accepted candidate already speaks for (dice ≥ DUP_DICE on title + body)
778
+ // is recorded and skipped: one cluster, one candidate slot.
779
+ const gramsCache = new Map()
780
+ const gramsOf = (item) => {
781
+ let set = gramsCache.get(item.id)
782
+ if (!set) {
783
+ set = bigrams(String(item.entry?.title || '') + ' ' + String(item.entry?.content || ''))
784
+ gramsCache.set(item.id, set)
785
+ }
786
+ return set
787
+ }
780
788
  for (const file of Array.isArray(files) ? files : []) {
781
789
  const item = eligible(file, route, picked.length + 1)
782
790
  if (!item || seen.has(item.id)) continue
783
791
  seen.add(item.id)
792
+ const twin = picked.find((accepted) => dice(gramsOf(item), gramsOf(accepted)) >= DUP_DICE)
793
+ if (twin) {
794
+ collapsed.push({ kept: twin.id, dropped: item.id })
795
+ continue
796
+ }
784
797
  picked.push(item)
785
798
  if (picked.length >= limit) break
786
799
  }
787
800
  return picked
788
801
  }
789
- const localPicked = take(local, 'local')
790
- const semanticPicked = take(semantic, 'semantic')
802
+ const routeCollapsed = []
803
+ const localPicked = take(local, 'local', routeCollapsed)
804
+ const semanticPicked = take(semantic, 'semantic', routeCollapsed)
791
805
  const merged = new Map()
792
806
  const add = (item) => {
793
- const current = merged.get(item.id) || { id: item.id, entry: item.entry, file: item.file, rrf: 0, bestRank: Infinity, localRank: null, semanticRank: null, routes: [] }
807
+ const current = merged.get(item.id) || { id: item.id, entry: item.entry, file: item.file, rrf: 0, bestRank: Infinity, localRank: null, semanticRank: null, routes: [], mergedTogether: [] }
794
808
  current.rrf += 1 / (HYBRID_RRF_K + item.rank)
795
809
  current.bestRank = Math.min(current.bestRank, item.rank)
796
810
  if (item.route === 'local') current.localRank = item.rank
@@ -804,14 +818,51 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
804
818
  for (const item of localPicked) add(item)
805
819
  for (const item of semanticPicked) add(item)
806
820
  const rows = [...merged.values()]
821
+ // Cross-leg folding . Each leg folded within itself, but the
822
+ // two legs can still meet the same fact under two ids. Keep the highest-rrf member
823
+ // of each cross-leg cluster and let the rest ride along as mergedTogether metadata
824
+ // instead of spending recall slots; this must happen BEFORE mmrSelect, so the
825
+ // diversity ceiling is set by a real spread of memories rather than a cluster.
826
+ const fusionCollapsed = []
827
+ const absorbed = new Set()
828
+ const rowGrams = new Map()
829
+ const gramsOfRow = (row) => {
830
+ let set = rowGrams.get(row.id)
831
+ if (!set) {
832
+ set = bigrams(String(row.entry?.title || '') + ' ' + String(row.entry?.content || ''))
833
+ rowGrams.set(row.id, set)
834
+ }
835
+ return set
836
+ }
837
+ const hosts = []
838
+ for (const row of rows.slice().sort((a, b) => b.rrf - a.rrf || a.id.localeCompare(b.id))) {
839
+ const host = hosts.find((kept) => dice(gramsOfRow(row), gramsOfRow(kept)) >= DUP_DICE)
840
+ if (host) {
841
+ host.mergedTogether.push(row.id)
842
+ absorbed.add(row.id)
843
+ fusionCollapsed.push({ kept: host.id, dropped: row.id })
844
+ } else {
845
+ hosts.push(row)
846
+ }
847
+ }
848
+ const collapsedGroups = new Map()
849
+ for (const { kept, dropped } of [...routeCollapsed, ...fusionCollapsed]) {
850
+ if (!collapsedGroups.has(kept)) collapsedGroups.set(kept, { kept, dropped: [] })
851
+ const group = collapsedGroups.get(kept)
852
+ // The same pair can meet on both routes; report it once.
853
+ if (!group.dropped.includes(dropped)) group.dropped.push(dropped)
854
+ }
855
+ const collapsed = [...collapsedGroups.values()]
807
856
  const maxRrf = rows.reduce((max, row) => Math.max(max, row.rrf), 0) || 1
808
857
  const files = rows
858
+ .filter((row) => !absorbed.has(row.id))
809
859
  .map((row) => ({
810
860
  ...row.file,
811
861
  name: row.id,
812
862
  entry: row.entry,
813
863
  retrievalScore: row.rrf / maxRrf,
814
864
  fusion: { rrf: row.rrf, bestRank: row.bestRank, localRank: row.localRank, semanticRank: row.semanticRank, routes: row.routes.slice() },
865
+ ...(row.mergedTogether.length ? { mergedTogether: row.mergedTogether.slice() } : {}),
815
866
  }))
816
867
  .sort((a, b) => b.retrievalScore - a.retrievalScore
817
868
  || a.fusion.bestRank - b.fusion.bestRank
@@ -830,10 +881,21 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
830
881
  localOnly: [...localIds].filter((id) => !semanticIds.has(id)).length,
831
882
  semanticOnly: [...semanticIds].filter((id) => !localIds.has(id)).length,
832
883
  shared: [...localIds].filter((id) => semanticIds.has(id)).length,
884
+ collapsed,
885
+ clusterCount: collapsed.length,
833
886
  },
834
887
  }
835
888
  }
836
889
 
890
+ // The retrieval component of recallScore: a fused file carries its own normalised
891
+ // RRF share; falling back to the old rank decay keeps every lexical caller
892
+ // byte-for-byte compatible. Shared with the retrieval-floor cut so the two can
893
+ // never disagree about what "retrieval" means.
894
+ function retrievalShare(file, rank) {
895
+ const fused = Number(file?.retrievalScore)
896
+ return Number.isFinite(fused) ? Math.max(0, Math.min(1, fused)) : 1 / (rank + 1)
897
+ }
898
+
837
899
  // Human-like recall ordering. Retrieval answers first (semantic match is still
838
900
  // the primary signal, which is why it stays a rank decay), then these biases
839
901
  // re-order within that set:
@@ -843,10 +905,7 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
843
905
  // Every factor is bounded so no bias can outrank a good semantic hit by luck.
844
906
  function recallScore(file, rank, context) {
845
907
  const entry = file.entry || {}
846
- // A fused file carries its own normalised RRF share. Falling back to the old
847
- // rank decay keeps every lexical caller byte-for-byte compatible.
848
- const fused = Number(file.retrievalScore)
849
- const retrieval = Number.isFinite(fused) ? Math.max(0, Math.min(1, fused)) : 1 / (rank + 1)
908
+ const retrieval = retrievalShare(file, rank)
850
909
  const strengthTerm = 0.6 + 0.4 * file.strength
851
910
  const marginal = entry.marginalized ? 0.35 : 1
852
911
 
@@ -882,8 +941,13 @@ function recallScore(file, rank, context) {
882
941
  // a trump card: 1.25 still loses to a clearly better semantic hit.
883
942
  const openLoopBias = entry.openLoop ? 1.25 : 1
884
943
  const habituation = habituationFactor(entry, context.now)
944
+ // Fact-level dedup down-weight . A near-copy that arrived
945
+ // later is tagged duplicateOf at write time; without this factor the copy would
946
+ // ride the same touch/decay loop as the original and could outlive it in rank —
947
+ // the "duplicates age better than the truth" loop this closes.
948
+ const duplicateFactor = entry.duplicateOf ? 0.7 : 1
885
949
 
886
- return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm * dominanceTerm
950
+ return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm * dominanceTerm * duplicateFactor
887
951
  }
888
952
 
889
953
  /**
@@ -940,12 +1004,19 @@ function habituationFactor(entry, now = Date.now()) {
940
1004
  // bounded biases, so its scale depends on the library and a fixed cutoff would mean
941
1005
  // different things in different conversations. "Keep what scores within N of the best
942
1006
  // hit" is scale-free, and 0 turns the filter off entirely.
943
- function cutWeakScores(files, threshold) {
1007
+ // `retrievalFloor` is the absolute complement : whatever the
1008
+ // library looks like, a hit whose retrieval share never cleared the floor must not
1009
+ // pad a topK that a duplicate cluster emptied. 0 turns it off entirely.
1010
+ function cutWeakScores(files, threshold, retrievalFloor = 0) {
1011
+ let out = files
944
1012
  const cut = Number(threshold)
945
- if (!Number.isFinite(cut) || cut <= 0) return files
946
- const best = Number(files[0]?.score || 0)
947
- if (best <= 0) return files
948
- return files.filter((f) => Number(f.score || 0) >= best * cut)
1013
+ if (Number.isFinite(cut) && cut > 0) {
1014
+ const best = Number(out[0]?.score || 0)
1015
+ if (best > 0) out = out.filter((f) => Number(f.score || 0) >= best * cut)
1016
+ }
1017
+ const floor = Number(retrievalFloor)
1018
+ if (Number.isFinite(floor) && floor > 0) out = out.filter((f) => Number(f.retrieval ?? retrievalShare(f, 0)) >= floor)
1019
+ return out
949
1020
  }
950
1021
 
951
1022
  function privateWeighted(result, max, context = {}) {
@@ -959,6 +1030,7 @@ function privateWeighted(result, max, context = {}) {
959
1030
  lastCreatedAt: null,
960
1031
  sessionId: null,
961
1032
  now: Date.now(),
1033
+ retrievalFloor: RETRIEVAL_FLOOR,
962
1034
  ...context,
963
1035
  }
964
1036
  const items = (result.files || [])
@@ -969,55 +1041,79 @@ function privateWeighted(result, max, context = {}) {
969
1041
  // memories. Filtering here keeps the anchor and the recall block in agreement.
970
1042
  .filter((f) => visibleToSession(f.entry, ctx.sessionId))
971
1043
  .filter((f) => !ctx.automatic || f.entry?.visibility !== 'secret')
972
- .map((f, i) => ({ ...f, score: recallScore(f, i, ctx) }))
1044
+ .map((f, i) => ({ ...f, score: recallScore(f, i, ctx), retrieval: retrievalShare(f, i) }))
973
1045
  .sort((a, b) => b.score - a.score)
974
- return { ...result, files: mmrSelect(cutWeakScores(items, ctx.relevanceThreshold), max) }
1046
+ const { chosen, skipped } = mmrSelect(cutWeakScores(items, ctx.relevanceThreshold, ctx.retrievalFloor), max)
1047
+ // Observation for the dedup thresholds : candidate folding
1048
+ // (①②) reports through result.fusion, the ranking-level hard skips (③) come back
1049
+ // from mmrSelect. One merged list — without it, a mistuned threshold eats real
1050
+ // memories invisibly. Fail-open: metadata only, never a recall failure.
1051
+ const fusionCollapsed = Array.isArray(result.fusion?.collapsed) ? result.fusion.collapsed : []
1052
+ const collapsed = [...fusionCollapsed, ...skipped]
1053
+ return { ...result, files: chosen, dedup: { collapsed, clusterCount: collapsed.length } }
975
1054
  }
976
1055
 
977
1056
  /**
978
1057
  * Greedy maximal marginal relevance over already-scored files.
979
1058
  *
980
- * Similarity is lexical (queryTokens + Jaccard) on purpose: it must be cheap enough
981
- * to run on every recall and predictable enough to explain. An embedding version
982
- * would need a second engine round trip just to de-duplicate four lines.
1059
+ * Similarity is lexical on purpose (dice over title+body bigrams, the same shared
1060
+ * ruler the write-side duplicate gate uses): it must be cheap enough to run on every
1061
+ * recall and predictable enough to explain. An embedding version would need a second
1062
+ * engine round trip just to de-duplicate four lines.
1063
+ *
1064
+ * Two bands :
1065
+ * · dice >= DUP_DICE — the candidate describes a fact something already chosen
1066
+ * describes: it is never selected, only reported as `skipped`. A soft penalty
1067
+ * cannot bound how many seats one fact takes; this can.
1068
+ * · TOPIC_DICE <= dice < DUP_DICE — same topic, different fact: the existing soft
1069
+ * penalty (lambda x overlap x ceiling) still applies.
1070
+ * Below TOPIC_DICE the two memories do not interact.
983
1071
  */
984
1072
  function mmrSelect(items, max, lambda = MMR_LAMBDA) {
985
1073
  const limit = Number(max || 0)
986
- if (!(limit > 0)) return items
987
- if (items.length <= 1) return items.slice(0, limit)
1074
+ if (!(limit > 0)) return { chosen: items, skipped: [] }
1075
+ if (items.length <= 1) return { chosen: items.slice(0, limit), skipped: [] }
988
1076
  const chosen = []
989
1077
  const pool = items.slice()
990
- const tokenCache = new Map()
991
- const tokensOf = (file) => {
1078
+ const gramsCache = new Map()
1079
+ const gramsOf = (file) => {
992
1080
  const key = file.name
993
- if (tokenCache.has(key)) return tokenCache.get(key)
994
- const set = new Set(queryTokens(String(file.entry?.title || '') + ' ' + String(file.entry?.content || '')))
995
- tokenCache.set(key, set)
1081
+ if (gramsCache.has(key)) return gramsCache.get(key)
1082
+ const set = bigrams(String(file.entry?.title || '') + ' ' + String(file.entry?.content || ''))
1083
+ gramsCache.set(key, set)
996
1084
  return set
997
1085
  }
998
1086
  // Anchor the penalty to the best raw score so the diversity term is on the same
999
1087
  // scale as relevance instead of being dominated by it.
1000
1088
  const ceiling = Number(items[0]?.score || 0) || 1
1089
+ // dropped id -> kept id. Recorded across rounds; an item hard-blocked in one round
1090
+ // stays blocked (chosen only grows), and the map keeps it reported exactly once.
1091
+ const blocked = new Map()
1001
1092
  while (pool.length > 0 && chosen.length < limit) {
1002
- let bestIdx = 0
1093
+ let bestIdx = -1
1003
1094
  let bestScore = -Infinity
1004
1095
  for (let i = 0; i < pool.length; i += 1) {
1005
1096
  let overlap = 0
1006
- for (const picked of chosen) overlap = Math.max(overlap, jaccard(tokensOf(pool[i]), tokensOf(picked)))
1097
+ let twin = null
1098
+ for (const picked of chosen) {
1099
+ const similarity = dice(gramsOf(pool[i]), gramsOf(picked))
1100
+ if (similarity >= DUP_DICE) { twin = picked.name; break }
1101
+ if (similarity >= TOPIC_DICE) overlap = Math.max(overlap, similarity)
1102
+ }
1103
+ if (twin) { blocked.set(pool[i].name, twin); continue }
1007
1104
  const adjusted = Number(pool[i].score || 0) - lambda * overlap * ceiling
1008
1105
  if (adjusted > bestScore) { bestScore = adjusted; bestIdx = i }
1009
1106
  }
1107
+ if (bestIdx === -1) break // nothing selectable is left: only hard duplicates remain
1010
1108
  chosen.push(pool.splice(bestIdx, 1)[0])
1011
1109
  }
1012
- return chosen
1013
- }
1014
-
1015
- /** Jaccard overlap of two token sets; 0 when either side is empty. */
1016
- function jaccard(a, b) {
1017
- if (!a || !b || a.size === 0 || b.size === 0) return 0
1018
- let shared = 0
1019
- for (const token of a) if (b.has(token)) shared += 1
1020
- return shared / (a.size + b.size - shared)
1110
+ const skipped = []
1111
+ for (const [dropped, kept] of blocked) {
1112
+ let group = skipped.find((g) => g.kept === kept)
1113
+ if (!group) { group = { kept, dropped: [] }; skipped.push(group) }
1114
+ group.dropped.push(dropped)
1115
+ }
1116
+ return { chosen, skipped }
1021
1117
  }
1022
1118
 
1023
1119
  /**
@@ -1110,6 +1206,10 @@ async function privateRememberImpl({
1110
1206
  emotion = undefined, dominance = undefined, emotionSource = null,
1111
1207
  attachments, track = 'memory',
1112
1208
  context,
1209
+ // Escape hatch for the duplicate gate : two genuine
1210
+ // observations of the same fact on the same day are a legal write. Off by
1211
+ // default — the model must say it looked, not default past the gate.
1212
+ allowDuplicate = false,
1113
1213
  }) {
1114
1214
  const scope = resolveLibrary({ exec, libraryId })
1115
1215
  const binding = scope.binding
@@ -1258,6 +1358,35 @@ async function privateRememberImpl({
1258
1358
  ? `与已有记忆「${clampText(conflict.entry.title || conflict.id, 60)}」同题但内容不同`
1259
1359
  : `与已有记忆「${clampText(conflict.entry.title || conflict.id, 60)}」说法相反`
1260
1360
  }
1361
+ // Fact-level duplicate gate . Strictly AFTER the conflict
1362
+ // check — in the same-title band a contradictory rewording must land as
1363
+ // pending_review, not be swallowed as a duplicate — and strictly AFTER
1364
+ // redactSecrets, so key material cannot leak into the similarity metric. Measures
1365
+ // with the shared dice the recall side folds with: one ruler for "same fact".
1366
+ // Never deletes or rewrites anything: a hit either refuses the new entry, or tags
1367
+ // it duplicateOf so recall can keep one seat per fact.
1368
+ let duplicate = null
1369
+ if (!conflict && allowDuplicate !== true) {
1370
+ const text = `${entry.title || ''} ${content || ''}`
1371
+ const grams = bigrams(text)
1372
+ for (const [otherId, other] of Object.entries(index.entries)) {
1373
+ // Same filter set the recall side uses: only an entry a recall could actually
1374
+ // surface can be "the same fact" — and a duplicateOf tag must point at one.
1375
+ if (otherId === id || !isMemoryEligible(other)) continue
1376
+ if (!sameFactContext(other.context, entry.context)) continue
1377
+ const similarity = similarTexts(text, `${other.title || ''} ${other.content || ''}`)
1378
+ if (similarity > (duplicate?.similarity ?? 0)) duplicate = { id: otherId, similarity }
1379
+ }
1380
+ }
1381
+ if (duplicate && duplicate.similarity >= DUP_REJECT_DICE) {
1382
+ return {
1383
+ deduped: true,
1384
+ existingId: duplicate.id,
1385
+ similarity: duplicate.similarity,
1386
+ hint: '库内已有一条几乎相同的记忆;改用 memu_private_update 合并内容。若确属两次独立观测,带 allowDuplicate=true 重写',
1387
+ }
1388
+ }
1389
+ if (duplicate && duplicate.similarity >= TOPIC_DICE) entry.duplicateOf = duplicate.id
1261
1390
  const payload = {
1262
1391
  recall_files: [{
1263
1392
  name: id,
@@ -1280,16 +1409,7 @@ async function privateRememberImpl({
1280
1409
  // 记下"这条进没进向量库":只有真的带代际提交成功才写,冲突暂扣/降级都不写。
1281
1410
  // 之前没有任何字段能回答"哪条记忆进向量库了",只能靠猜。
1282
1411
  if (route !== 'lexical' && !conflict && result?.ok !== false && !result?.parseWarning) {
1283
- const active = readVectorState({ tolerateMissing: true }).state?.active
1284
- if (active) {
1285
- entry.vector = {
1286
- route,
1287
- model: active.profile?.catalogId || null,
1288
- generation: active.generation,
1289
- fingerprint: String(active.fingerprint || '').slice(0, 8),
1290
- at: stamp(),
1291
- }
1292
- }
1412
+ entry.vector = activeEntryStamp(route) || entry.vector
1293
1413
  }
1294
1414
  // Re-read after the await: a curator job or another turn may have written in
1295
1415
  // the meantime, and saving the pre-await snapshot would erase it.
@@ -1478,6 +1598,12 @@ async function importMemoriesImpl({ libraryId, memories, maxFiles = 2000, now =
1478
1598
  engineWarning = String(error?.message || error)
1479
1599
  }
1480
1600
  }
1601
+ // 批量恢复与单条写入同口径:提交成功就在条目上落"已入向量库"戳,否则这些条目
1602
+ // 在向量计数(membership 的戳腿)里永远缺失。受管提交失败直接抛,不会走到这里。
1603
+ if (route !== 'lexical' && engineWarning === null) {
1604
+ const vector = activeEntryStamp(route)
1605
+ if (vector) for (const item of entries) item.entry.vector = vector
1606
+ }
1481
1607
  const idMap = Object.fromEntries(entries.filter(item => item.entry.importedFrom).map(item => [item.entry.importedFrom, item.id]))
1482
1608
  await retryStoreWrite(() => updatePrivateIndex(libraryId, (fresh) => {
1483
1609
  for (const { id, entry } of entries) {
@@ -1536,6 +1662,21 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
1536
1662
  // like one nobody ever touches. Batched into a single index write.
1537
1663
  if (purpose !== 'automatic') touchAccessMany({ libraryId: binding.libraryId, ids: (scored.files || []).map((f) => f.name) })
1538
1664
  bumpStats(binding.libraryId, 'recalled', (scored.files || []).length)
1665
+ // Fact-level dedup observation . Fail-open signal, not
1666
+ // bookkeeping: without it a mistuned DUP_DICE silently eats real memories and
1667
+ // nobody can tell "recalled N" from "recalled N members of one fact".
1668
+ if (scored.dedup?.collapsed?.length) {
1669
+ const folded = scored.dedup.collapsed.reduce((n, group) => n + group.dropped.length, 0)
1670
+ record({
1671
+ kind: 'recall',
1672
+ scope: 'private',
1673
+ sessionId: memorySessionId(exec),
1674
+ libraryId: binding.libraryId,
1675
+ message: `去重折叠 ${folded} 条候选(${scored.dedup.clusterCount} 簇)`,
1676
+ detail: { collapsed: scored.dedup.collapsed, clusterCount: scored.dedup.clusterCount },
1677
+ refs: scored.dedup.collapsed.map((group) => group.kept).slice(0, 20),
1678
+ })
1679
+ }
1539
1680
  for (const file of scored.files || []) {
1540
1681
  if (file.entry?.attachments?.length) {
1541
1682
  file.entry = { ...file.entry, attachments: file.entry.attachments.map(item => {
@@ -1626,10 +1767,39 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1626
1767
  // the same way. Capture before the update: `entry` is the pre-update snapshot.
1627
1768
  const wasArchived = entry.status === 'archived'
1628
1769
  const nextContent = content === undefined ? undefined : redactSecrets(content)
1629
- if (nextContent !== undefined && entry.status !== 'pending_review') {
1770
+ // 改写文本(title 或 content 任一变化)要过与 remember 写入门同口径的两道门
1771
+ // :先矛盾、后重复,度量是同一把 dice 尺。此前只有新建
1772
+ // 被拦——把一条记忆改写成与另一条几乎相同就能从更新路径绕开门,这里补上。
1773
+ // 门在 backend commit 之前跑:拒绝时引擎里什么都没写,compensation 路径不受影响。
1774
+ const textChanged = nextContent !== undefined || title !== undefined
1775
+ const nextTitle = title !== undefined ? clampText(title, 120) : (entry.title || '')
1776
+ const nextContext = context === undefined ? entry.context : normalizeMemoryContext(context)
1777
+ const scanned = textChanged && entry.status !== 'pending_review'
1778
+ let duplicate = null
1779
+ let text = null
1780
+ if (scanned) {
1630
1781
  const others = Object.fromEntries(Object.entries(index.entries).filter(([key]) => key !== id_))
1631
- const conflict = findConflict(others, { ...entry, title: title ?? entry.title, content: nextContent })
1782
+ const conflict = findConflict(others, { ...entry, title: nextTitle, content: nextContent ?? entry.content })
1632
1783
  if (conflict) throw new Error(`content conflicts with ${conflict.id}; write a new memory for review`)
1784
+ text = `${nextTitle} ${nextContent ?? entry.content}`
1785
+ for (const [otherId, other] of Object.entries(others)) {
1786
+ // 与 remember 的门同一过滤集(isMemoryEligible + sameFactContext),再排除它原本的
1787
+ // duplicateOf 关系——那对条目是写入时就获准共存的,向它继续靠拢不该被拦。
1788
+ if (!isMemoryEligible(other)) continue
1789
+ if (otherId === entry.duplicateOf) continue
1790
+ if (!sameFactContext(other.context, nextContext)) continue
1791
+ const similarity = similarTexts(text, `${other.title || ''} ${other.content || ''}`)
1792
+ if (similarity > (duplicate?.similarity ?? 0)) duplicate = { id: otherId, similarity }
1793
+ }
1794
+ if (duplicate && duplicate.similarity >= DUP_REJECT_DICE) {
1795
+ record({
1796
+ kind: 'write', scope: 'private', sessionId: sessionIdOf(exec) || null, libraryId: scope.libraryId,
1797
+ message: '拒绝重复更新:' + id_ + ' 改写后与 ' + duplicate.id + ' 几乎相同',
1798
+ detail: { id: id_, deduped: true, existingId: duplicate.id, similarity: duplicate.similarity },
1799
+ refs: [id_, duplicate.id],
1800
+ })
1801
+ throw new Error(`update refused: the rewritten text is nearly identical to ${duplicate.id} (dice ${duplicate.similarity.toFixed(2)}); merge into ${duplicate.id} or archive one of the two instead`)
1802
+ }
1633
1803
  }
1634
1804
  const route = engineRoute()
1635
1805
  const managed = route === 'managed-local'
@@ -1663,6 +1833,18 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1663
1833
  if (currentBinding) assertScopeEnabled(currentBinding)
1664
1834
  if (title !== undefined) target.title = clampText(title, 120)
1665
1835
  if (nextContent !== undefined) target.content = nextContent
1836
+ // 打标带与 remember 同口径:[0.55, 0.90) 改写后仍与另一条同主题 → duplicateOf。
1837
+ // 旧打标的去留也按同一把尺重新裁定:文本改过、且与新文本最相似的条目不在打标带,
1838
+ // 但原 duplicateOf 对象仍与改写后文本同主题(它被排除在拒绝扫描之外,不代表相似
1839
+ // 消失)→ 保留;否则(相似依据消失,或原指向对象已不可召回)摘除——留着会错误
1840
+ // 压低这条记忆的召回权重。未扫(无文本变化)不动。
1841
+ if (scanned) {
1842
+ const previousHost = target.duplicateOf ? fresh.entries[target.duplicateOf] : null
1843
+ const stillHosted = previousHost && isMemoryEligible(previousHost)
1844
+ && similarTexts(text, `${previousHost.title || ''} ${previousHost.content || ''}`) >= TOPIC_DICE
1845
+ if (duplicate && duplicate.similarity >= TOPIC_DICE) target.duplicateOf = duplicate.id
1846
+ else if (!stillHosted) delete target.duplicateOf
1847
+ }
1666
1848
  if (tags !== undefined) target.tags = Array.isArray(tags) ? tags.slice(0, 20) : target.tags
1667
1849
  if (status !== undefined) target.status = status
1668
1850
  if (openLoop !== undefined) target.openLoop = !!openLoop
@@ -1712,6 +1894,12 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1712
1894
  }
1713
1895
  if (dominance !== undefined) target.dominance = dominance === null ? 0 : Math.max(-1, Math.min(1, Number(dominance)))
1714
1896
  if (nextAttachments !== undefined) target.attachments = nextAttachments
1897
+ // 受管路由下这次更新把这一行重新提交进了当前向量空间(managed 时 commit 无条件发生)。
1898
+ // 戳要跟着刷新:它现在描述的是"这条在当前代际的空间里",membership 的戳腿靠它计数。
1899
+ if (committedDb) {
1900
+ const vector = activeEntryStamp(route)
1901
+ if (vector) target.vector = vector
1902
+ }
1715
1903
  // Vividness and accuracy are derived, not typed in: correcting the peak flag or
1716
1904
  // the arousal reading has to move `accuracy` too, otherwise the repair would only
1717
1905
  // change how the entry is stored and not how much it is trusted when recalled.
@@ -1771,6 +1959,30 @@ async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolutio
1771
1959
  if (action === 'accept') {
1772
1960
  if (typeof content === 'string' && content.trim()) next.content = content
1773
1961
  if (typeof title === 'string' && title.trim()) next.title = title
1962
+ // 接受即写入(accept 会把这条提交进向量库并转为 active),所以它与 remember /
1963
+ // privateUpdate 过同一道重复门:≥ 0.90 拒绝接受(复核保持
1964
+ // 暂扣,不丢数据——换内容或先归档对方后可重试),[0.55, 0.90) 接受但打 duplicateOf。
1965
+ // 排除它自己和 conflictingWith:后者正是这次复核要替代/共存的对象,与它相似是
1966
+ // 复核的本来含义,不是重复。
1967
+ const text = `${next.title || ''} ${next.content || ''}`
1968
+ let duplicate = null
1969
+ for (const [otherId, other] of Object.entries(index.entries || {})) {
1970
+ if (otherId === id || otherId === entry.conflictingWith) continue
1971
+ if (!isMemoryEligible(other)) continue
1972
+ if (!sameFactContext(other.context, next.context)) continue
1973
+ const similarity = similarTexts(text, `${other.title || ''} ${other.content || ''}`)
1974
+ if (similarity > (duplicate?.similarity ?? 0)) duplicate = { id: otherId, similarity }
1975
+ }
1976
+ if (duplicate && duplicate.similarity >= DUP_REJECT_DICE) {
1977
+ record({
1978
+ kind: 'write', scope: libraryId ? 'private' : 'global', libraryId: libraryId || null,
1979
+ message: '拒绝复核接受:' + id + ' 与 ' + duplicate.id + ' 几乎相同',
1980
+ detail: { id, deduped: true, existingId: duplicate.id, similarity: duplicate.similarity },
1981
+ refs: [id, duplicate.id],
1982
+ })
1983
+ throw new Error(`review accept refused: the accepted text is nearly identical to ${duplicate.id} (dice ${duplicate.similarity.toFixed(2)}); pass different content or archive ${duplicate.id} first`)
1984
+ }
1985
+ if (duplicate && duplicate.similarity >= TOPIC_DICE) next.duplicateOf = duplicate.id
1774
1986
  const reviewRoute = engineRoute()
1775
1987
  if (reviewRoute !== 'lexical') {
1776
1988
  await commit({
@@ -1778,6 +1990,9 @@ async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolutio
1778
1990
  route: reviewRoute,
1779
1991
  db: libraryId ? memoryDbPath(libraryId) : globalDbPath(),
1780
1992
  })
1993
+ // pending_review 从不提交,所以被接受的那一刻才第一次真正进向量库:补上戳,
1994
+ // 否则这条在向量计数里永远缺失。
1995
+ next.vector = activeEntryStamp(reviewRoute) || next.vector
1781
1996
  }
1782
1997
  }
1783
1998
  const mutate = fresh => {
@@ -1897,7 +2112,27 @@ async function privateDeleteImpl({ exec, libraryId, id }) {
1897
2112
  await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
1898
2113
  if (JSON.stringify(fresh.entries[id_]) !== JSON.stringify(entry)) throw new Error('memory changed during deletion; reload and retry')
1899
2114
  delete fresh.entries[id_]
2115
+ // Cascade: inbound edges live in *other* entries' links[], so dropping the
2116
+ // row without sweeping them leaves links that point at a memory nothing can
2117
+ // resolve any more (outbound links die with the row itself). In place, O(N
2118
+ // over links) — same style as the unlink mutator, no deep clone. Archived /
2119
+ // suppressed entries keep their links on purpose: only this permanent path
2120
+ // cascades, so archiving stays reversible.
2121
+ for (const other of Object.values(fresh.entries)) {
2122
+ if (Array.isArray(other.links) && other.links.some(link => link.to === id_)) {
2123
+ other.links = other.links.filter(link => link.to !== id_)
2124
+ }
2125
+ }
1900
2126
  }))
2127
+ // Collect entities this row was the last thing keeping alive. Cleanup after
2128
+ // the fact, not a correctness step: if it fails (lock contention, a store
2129
+ // another writer holds) the delete stands, and the startup sweep gets the
2130
+ // orphans on the next launch.
2131
+ try {
2132
+ pruneOrphanEntities(scope.libraryId)
2133
+ } catch (error) {
2134
+ warn(`orphan entity prune after permanent delete skipped: ${error?.message || error}`)
2135
+ }
1901
2136
  return { id: id_, libraryId: scope.libraryId, deleted: true, backend, engine }
1902
2137
  }
1903
2138
 
@@ -1964,14 +2199,19 @@ export function privateUnpin({ exec, id, reason }) {
1964
2199
  export async function privateRemember(args) {
1965
2200
  const libraryId = resolveLibrary(args).libraryId
1966
2201
  const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl({ ...args, libraryId }))
2202
+ const deduped = result?.deduped === true
1967
2203
  record({
1968
2204
  kind: 'write',
1969
2205
  scope: 'private',
1970
2206
  sessionId: sessionIdOf(args.exec) || null,
1971
2207
  libraryId,
1972
- message: '写入 ' + (result?.id || '?') + ':' + clampText(args.title || args.content, 60) + (result?.conflict ? '(待复核)' : ''),
1973
- detail: { id: result?.id || null, title: args.title || null, layer: args.layer || null, conflict: result?.conflict || null },
1974
- refs: result?.id ? [result.id] : [],
2208
+ message: deduped
2209
+ ? '拒绝重复写入:与 ' + (result.existingId || '?') + ' 几乎相同'
2210
+ : '写入 ' + (result?.id || '?') + ':' + clampText(args.title || args.content, 60) + (result?.conflict ? '(待复核)' : ''),
2211
+ detail: deduped
2212
+ ? { deduped: true, existingId: result.existingId || null, similarity: result.similarity ?? null }
2213
+ : { id: result?.id || null, title: args.title || null, layer: args.layer || null, conflict: result?.conflict || null },
2214
+ refs: deduped ? [result.existingId] : (result?.id ? [result.id] : []),
1975
2215
  })
1976
2216
  return result
1977
2217
  }
@@ -2086,15 +2326,19 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold, retr
2086
2326
  ...f,
2087
2327
  // Local lexical rank is already meaningful; confidence still nudges ties, and
2088
2328
  // a cross-session fact that has been injected three turns running gets the same
2089
- // wear penalty as a private one.
2329
+ // wear penalty as a private one. `retrieval` mirrors the retrieval term of the
2330
+ // score so the absolute floor cut sees the same number the score was built from.
2331
+ retrieval: Number.isFinite(f.retrievalScore) ? f.retrievalScore : f.lexical > 0 ? f.lexical : 1 / (i + 1),
2090
2332
  score: (Number.isFinite(f.retrievalScore) ? f.retrievalScore : f.lexical > 0 ? f.lexical : 1 / (i + 1))
2091
2333
  * (0.85 + 0.15 * Number(f.entry.confidence ?? 0.5))
2092
2334
  * habituationFactor(f.entry),
2093
2335
  }))
2094
2336
  .sort((a, b) => b.score - a.score)
2337
+ const ranked = mmrSelect(cutWeakScores(filtered, threshold, RETRIEVAL_FLOOR), Number(topK || settings.globalTopK || 3))
2095
2338
  return {
2096
2339
  ...enriched,
2097
- files: mmrSelect(cutWeakScores(filtered, threshold), Number(topK || settings.globalTopK || 3)),
2340
+ files: ranked.chosen,
2341
+ dedup: { collapsed: ranked.skipped, clusterCount: ranked.skipped.length },
2098
2342
  domain: domainOf(binding),
2099
2343
  unbound: !peeked,
2100
2344
  }