dsh-memu-memory 1.1.3 → 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.
Files changed (184) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +39 -6
  3. package/README.zh.md +39 -7
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +3 -3
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +8 -0
  9. package/docs/host-api.md +2 -2
  10. package/docs/memory-semantics.md +12 -10
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +396 -395
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +23 -6
  15. package/lib/binding-diagnostics.js +8 -3
  16. package/lib/client.js +335 -35
  17. package/lib/consolidation-ledger.js +347 -0
  18. package/lib/consolidation-receipt.js +284 -0
  19. package/lib/consolidation-snapshot.js +274 -0
  20. package/lib/consolidation-worker.js +144 -0
  21. package/lib/curator.js +86 -31
  22. package/lib/cycle.js +40 -6
  23. package/lib/graph.js +139 -5
  24. package/lib/index.js +33 -1
  25. package/lib/inject.js +104 -76
  26. package/lib/local-embedding-manager.js +24 -6
  27. package/lib/memory.js +534 -88
  28. package/lib/mood.js +268 -39
  29. package/lib/preset-scope.js +1 -1
  30. package/lib/private.js +430 -43
  31. package/lib/prompts/private-consolidation.coding.md +1 -0
  32. package/lib/prompts/private-consolidation.md +1 -0
  33. package/lib/prompts/private-consolidation.mixed.md +1 -0
  34. package/lib/prompts/private-consolidation.rp.md +1 -0
  35. package/lib/prompts.js +4 -4
  36. package/lib/remote-summary.js +51 -25
  37. package/lib/rpc.js +54 -5
  38. package/lib/state.js +29 -12
  39. package/lib/text-similarity.js +106 -0
  40. package/lib/tools.js +113 -10
  41. package/lib/vector-migration.js +28 -3
  42. package/lib/vector-profile.js +51 -0
  43. package/package.json +4 -2
  44. package/scripts/acceptance-local.mjs +2 -1
  45. package/scripts/active-memory-ab-live.mjs +655 -0
  46. package/scripts/active-memory-ab.mjs +1302 -0
  47. package/scripts/chain-matrix.mjs +11 -1
  48. package/scripts/check-all.mjs +10 -1
  49. package/scripts/replay.mjs +573 -0
  50. package/tests/activation.test.mjs +89 -0
  51. package/tests/active-memory-ab.test.mjs +745 -0
  52. package/tests/active-memory.test.mjs +129 -0
  53. package/tests/activity-log.test.mjs +126 -0
  54. package/tests/activity-wiring.test.mjs +103 -0
  55. package/tests/appendix-b.test.mjs +216 -0
  56. package/tests/appendix-b2.test.mjs +166 -0
  57. package/tests/audit-regression.test.mjs +272 -0
  58. package/tests/audit-safety.test.mjs +227 -0
  59. package/tests/backend-compensation.test.mjs +272 -0
  60. package/tests/backup.test.mjs +793 -0
  61. package/tests/binding-diagnostics.test.mjs +720 -0
  62. package/tests/browser.test.mjs +366 -0
  63. package/tests/client-render.test.mjs +892 -0
  64. package/tests/coalesce.test.mjs +113 -0
  65. package/tests/config.test.mjs +159 -0
  66. package/tests/consolidation-credential.test.mjs +331 -0
  67. package/tests/consolidation-feed.test.mjs +96 -0
  68. package/tests/consolidation-fork.test.mjs +489 -0
  69. package/tests/consolidation-ledger.test.mjs +298 -0
  70. package/tests/consolidation-snapshot.test.mjs +360 -0
  71. package/tests/consolidation-value.test.mjs +89 -0
  72. package/tests/context-scope.test.mjs +40 -0
  73. package/tests/cue.test.mjs +44 -0
  74. package/tests/curator-commit.test.mjs +277 -0
  75. package/tests/curator-probe.test.mjs +433 -0
  76. package/tests/curator.test.mjs +150 -0
  77. package/tests/cycle.test.mjs +411 -0
  78. package/tests/disclosure.test.mjs +231 -0
  79. package/tests/doc-drift.test.mjs +222 -0
  80. package/tests/embedding-catalog.test.mjs +266 -0
  81. package/tests/embedding-download.test.mjs +391 -0
  82. package/tests/embedding-runtime.test.mjs +389 -0
  83. package/tests/emotion-chain.test.mjs +84 -0
  84. package/tests/emotion-contract.test.mjs +445 -0
  85. package/tests/engine.test.mjs +35 -0
  86. package/tests/fixtures/active-memory-ab/README.md +56 -0
  87. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  88. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  89. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  90. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  92. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  93. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  94. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  95. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  96. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  97. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  98. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  99. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  100. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  101. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  102. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  103. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  104. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  105. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  106. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  107. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  108. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  109. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  110. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  111. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  112. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  113. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  114. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  115. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  116. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  117. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  118. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  119. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  120. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  121. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  122. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  123. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  124. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  125. package/tests/graph-cascade.test.mjs +190 -0
  126. package/tests/graph-view.test.mjs +187 -0
  127. package/tests/graph.test.mjs +94 -0
  128. package/tests/hook-order.test.mjs +72 -0
  129. package/tests/humanlike.test.mjs +232 -0
  130. package/tests/hybrid-recall.test.mjs +122 -0
  131. package/tests/inbound.test.mjs +393 -0
  132. package/tests/inject-guide.test.mjs +364 -0
  133. package/tests/inject.test.mjs +222 -0
  134. package/tests/injection-budget-settings.test.mjs +35 -0
  135. package/tests/injection-budget.test.mjs +64 -0
  136. package/tests/injection-chain.test.mjs +191 -0
  137. package/tests/loader-register.mjs +3 -0
  138. package/tests/loader.mjs +12 -0
  139. package/tests/local-embedding-entry.test.mjs +95 -0
  140. package/tests/local-embedding-manager.test.mjs +476 -0
  141. package/tests/local-embedding-protocol.test.mjs +95 -0
  142. package/tests/local-embedding-worker.test.mjs +346 -0
  143. package/tests/managed-memory.test.mjs +591 -0
  144. package/tests/managed-runtime.test.mjs +40 -0
  145. package/tests/media.test.mjs +132 -0
  146. package/tests/migration-payload.test.mjs +42 -0
  147. package/tests/mood.test.mjs +109 -0
  148. package/tests/multiprocess.test.mjs +29 -0
  149. package/tests/new-session-gate.test.mjs +361 -0
  150. package/tests/package-shipping.test.mjs +84 -0
  151. package/tests/preset-scope.test.mjs +212 -0
  152. package/tests/prompts.test.mjs +250 -0
  153. package/tests/react-snapshot.test.mjs +23 -0
  154. package/tests/recall-bench.test.mjs +306 -0
  155. package/tests/recall-dedup.test.mjs +417 -0
  156. package/tests/remote.test.mjs +140 -0
  157. package/tests/rename.test.mjs +30 -0
  158. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  159. package/tests/repetition-eval-live.test.mjs +177 -0
  160. package/tests/repetition-eval.test.mjs +373 -0
  161. package/tests/salient-date.test.mjs +142 -0
  162. package/tests/settings-beat.test.mjs +221 -0
  163. package/tests/short-term.test.mjs +166 -0
  164. package/tests/sidebar-beat.test.mjs +212 -0
  165. package/tests/sidebar-refresh.test.mjs +123 -0
  166. package/tests/sidebar.test.mjs +70 -0
  167. package/tests/snippet.test.mjs +47 -0
  168. package/tests/source-kind.test.mjs +52 -0
  169. package/tests/state.test.mjs +150 -0
  170. package/tests/stats.test.mjs +112 -0
  171. package/tests/storage.test.mjs +125 -0
  172. package/tests/stubs/dsh-llm.js +5 -0
  173. package/tests/stubs/dsh-session.js +7 -0
  174. package/tests/stubs/dsh-tools.js +101 -0
  175. package/tests/tool-schema.test.mjs +73 -0
  176. package/tests/tools.test.mjs +184 -0
  177. package/tests/ui-defaults.test.mjs +57 -0
  178. package/tests/update-fields.test.mjs +138 -0
  179. package/tests/vector-migration.test.mjs +790 -0
  180. package/tests/vector-profile.test.mjs +425 -0
  181. package/tests/visibility-migration.test.mjs +152 -0
  182. package/tests/webui-auth.test.mjs +454 -0
  183. package/tests/webui.test.mjs +165 -0
  184. package/tests/windows-env.test.mjs +89 -0
package/lib/memory.js CHANGED
@@ -10,14 +10,16 @@ import {
10
10
  getLibrary, setSessionBinding, setRecallError, effectiveScope, listLibrariesAll,
11
11
  } from './state.js'
12
12
  import { stamp, redactSecrets, retryStoreWrite, warn } from './paths.js'
13
+ import { scopeGenerationOf } from './consolidation-ledger.js'
13
14
  import { record } from './activity-log.js'
14
- import { BASE_AROUSAL, moodCongruence, moodDecay } from './mood.js'
15
+ import { BASE_AROUSAL, derivedValence, dominanceBias, emotionActive, maxShare, moodCongruence, moodDecay, normalizeEmotion, overlap, readMood } from './mood.js'
15
16
  import { commit, retrieve, listFiles, forget } from './memu.js'
16
17
  import { bumpStats } from './stats.js'
17
- import { recallRelations } from './graph.js'
18
+ import { recallRelations, pruneOrphanEntities } from './graph.js'
18
19
  import { attachmentRefs } from './media.js'
19
20
  import { normalizeMemoryContext, contextVisible, sameFactContext } from './context.js'
20
- 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'
21
23
 
22
24
  const HALF_LIFE_DAYS = 14
23
25
  // A short-term entry is meant to live for the length of a conversation, not a
@@ -37,6 +39,11 @@ const HABITUATION_FLOOR = 0.5
37
39
  // library with three variants of the same decision would spend the whole block on it.
38
40
  // The top hit is always kept; later picks pay lambda x max-similarity-to-what-is-in.
39
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
40
47
  // Semantic fusion uses RRF: each route contributes a
41
48
  // rank term and the merged score is normalised to the best fused hit before the
42
49
  // existing mood/strength/MMR pipeline sees it. The candidate cap keeps a long
@@ -93,7 +100,59 @@ function clearRecallError(exec) {
93
100
  if (sessionId) setRecallError(sessionId, null)
94
101
  }
95
102
 
103
+ // Serialize backend-bearing private writes across every route in this module.
104
+ // Acquire this queue BEFORE the vector lease; neither waits while holding a
105
+ // synchronous file lock. This is not a cross-process/backend transaction.
106
+ const privateWriteTails = new Map()
96
107
  async function withManagedPrivateWrite(libraryId, action) {
108
+ const previous = privateWriteTails.get(libraryId) || Promise.resolve()
109
+ const running = previous.then(() => withManagedPrivateWriteImpl(libraryId, action))
110
+ const settled = running.then(() => {}, () => {})
111
+ privateWriteTails.set(libraryId, settled)
112
+ try { return await running } finally {
113
+ if (privateWriteTails.get(libraryId) === settled) privateWriteTails.delete(libraryId)
114
+ }
115
+ }
116
+
117
+ function assertCompensationResult(result, operation) {
118
+ if (!result || typeof result !== 'object' || Array.isArray(result) || result.ok === false || result.error || result.parseWarning
119
+ || (operation === 'backend compensation delete' && (!Number.isSafeInteger(result.deleted) || result.deleted < 0))) {
120
+ throw new Error(operation + ' was not acknowledged')
121
+ }
122
+ }
123
+
124
+ // The index remains authoritative. Refuse destructive repair if another index
125
+ // writer replaced the expected pre-await entry; otherwise rebuild that old row.
126
+ // No transaction spans these external calls: crashes/other processes can still
127
+ // interrupt compensation. Never describe an uncertain repair as success.
128
+ async function compensatePrivateCommit(error, { libraryId, id, route, db, expectedEntry }) {
129
+ try {
130
+ const current = loadPrivateIndex(libraryId).entries[id]
131
+ if (JSON.stringify(current ?? null) !== JSON.stringify(expectedEntry ?? null)) {
132
+ throw new Error('memory changed before backend compensation')
133
+ }
134
+ const snapshot = JSON.stringify(current)
135
+ assertCompensationResult(await forget({ route, db, name: id }), 'backend compensation delete')
136
+ if (current && !['pending_review', 'superseded'].includes(current.status)) {
137
+ assertCompensationResult(await commit({ route, db, payload: {
138
+ recall_files: [{ name: id, track: current.track || 'memory', description: clampText(current.title, 180), content: current.content }],
139
+ resource: [], user: {},
140
+ } }), 'backend compensation restore')
141
+ }
142
+ if (JSON.stringify(loadPrivateIndex(libraryId).entries[id]) !== snapshot) {
143
+ throw new Error('memory changed during backend compensation')
144
+ }
145
+ } catch (compensationError) {
146
+ const failure = new Error('private memory index write failed; backend compensation uncertain: ' + String(compensationError?.message || compensationError), { cause: error })
147
+ failure.code = 'PRIVATE_BACKEND_COMPENSATION_FAILED'
148
+ failure.compensationError = compensationError
149
+ warn(failure.message)
150
+ throw failure
151
+ }
152
+ throw error
153
+ }
154
+
155
+ async function withManagedPrivateWriteImpl(libraryId, action) {
97
156
  const intent = managedVectorIntent()
98
157
  if (!intent.managed) return action()
99
158
  // 选了本地向量、但下载完模型后从没跑过迁移(vector-active 只有 requested):这里
@@ -219,6 +278,7 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
219
278
  }
220
279
  const binding = readOnly ? peekBinding(exec) : bindingFor(exec)
221
280
  if (!binding) throw new Error('no bound session')
281
+ assertConsolidationParent(binding)
222
282
  // A binding without a library means the conversation explicitly opted out of
223
283
  // private memory. Writing through it used to land in `index/null.json` — a
224
284
  // "successful" write that can never be recalled. Refuse with an actionable
@@ -232,8 +292,18 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
232
292
  // The sidebar switches (and the folder ceiling behind them) must constrain tool
233
293
  // calls too, not just injection. One gate, sourced from `effectiveScope` so the
234
294
  // folder policy is always part of the answer.
295
+ function assertConsolidationParent(binding) {
296
+ if (!binding?.consolidationFork) return
297
+ const parent = getSessionBinding(binding.parentSessionId)
298
+ if (!parent || !binding.parentScopeGeneration || scopeGenerationOf(parent) !== binding.parentScopeGeneration) {
299
+ throw new Error('DSH_MEMU_SCOPE_STALE: consolidation worker parent authorization changed')
300
+ }
301
+ }
302
+
235
303
  function assertScopeEnabled(binding, { layer = 'library' } = {}) {
236
304
  if (!binding) return
305
+ assertConsolidationParent(binding)
306
+ if (binding.consolidationFork && layer === 'global') throw new Error('DSH_MEMU_WORKER_RESTRICTED: private worker cannot access global memory')
237
307
  const scope = effectiveScope(binding)
238
308
  if (layer === 'library' && !scope.useLibrary) {
239
309
  throw new Error(scope.libraryBlocked
@@ -491,20 +561,8 @@ export function isTipOfTongue(entry) {
491
561
  // Not semantic entailment: two bigram sets and a negation test. It only has to be
492
562
  // good enough to stop "he likes tea" and "he hates tea" from sitting side by side
493
563
  // as equally true, and quiet enough that ordinary rewordings do not trip it.
494
- function bigrams(text) {
495
- const clean = String(text || '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
496
- const out = new Set()
497
- for (let i = 0; i < clean.length - 1; i += 1) out.add(clean.slice(i, i + 2))
498
- return out
499
- }
500
-
501
- function dice(a, b) {
502
- if (a.size === 0 || b.size === 0) return 0
503
- let shared = 0
504
- for (const gram of a) if (b.has(gram)) shared += 1
505
- return (2 * shared) / (a.size + b.size)
506
- }
507
-
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.
508
566
  const NEGATION_RE = /不|没|别|无|never|not|no longer|doesn't|don't/i
509
567
 
510
568
  /**
@@ -706,27 +764,47 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
706
764
  if (automatic && entry.visibility === 'secret') return null
707
765
  return { id, entry, file, route, rank }
708
766
  }
709
- const take = (files, route) => {
767
+ const take = (files, route, collapsed) => {
710
768
  const picked = []
711
769
  // One route can name the same canonical entry more than once: a caller may
712
770
  // pass both `mem-x` and `mem-x.md`, and an engine can repeat a hit. Dedupe by
713
771
  // canonical id before scoring and truncating, keeping the first (best) rank.
714
772
  // Otherwise every duplicate adds another 1/(k+rank) and consumes a slot.
715
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
+ }
716
788
  for (const file of Array.isArray(files) ? files : []) {
717
789
  const item = eligible(file, route, picked.length + 1)
718
790
  if (!item || seen.has(item.id)) continue
719
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
+ }
720
797
  picked.push(item)
721
798
  if (picked.length >= limit) break
722
799
  }
723
800
  return picked
724
801
  }
725
- const localPicked = take(local, 'local')
726
- const semanticPicked = take(semantic, 'semantic')
802
+ const routeCollapsed = []
803
+ const localPicked = take(local, 'local', routeCollapsed)
804
+ const semanticPicked = take(semantic, 'semantic', routeCollapsed)
727
805
  const merged = new Map()
728
806
  const add = (item) => {
729
- 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: [] }
730
808
  current.rrf += 1 / (HYBRID_RRF_K + item.rank)
731
809
  current.bestRank = Math.min(current.bestRank, item.rank)
732
810
  if (item.route === 'local') current.localRank = item.rank
@@ -740,14 +818,51 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
740
818
  for (const item of localPicked) add(item)
741
819
  for (const item of semanticPicked) add(item)
742
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()]
743
856
  const maxRrf = rows.reduce((max, row) => Math.max(max, row.rrf), 0) || 1
744
857
  const files = rows
858
+ .filter((row) => !absorbed.has(row.id))
745
859
  .map((row) => ({
746
860
  ...row.file,
747
861
  name: row.id,
748
862
  entry: row.entry,
749
863
  retrievalScore: row.rrf / maxRrf,
750
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() } : {}),
751
866
  }))
752
867
  .sort((a, b) => b.retrievalScore - a.retrievalScore
753
868
  || a.fusion.bestRank - b.fusion.bestRank
@@ -766,10 +881,21 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
766
881
  localOnly: [...localIds].filter((id) => !semanticIds.has(id)).length,
767
882
  semanticOnly: [...semanticIds].filter((id) => !localIds.has(id)).length,
768
883
  shared: [...localIds].filter((id) => semanticIds.has(id)).length,
884
+ collapsed,
885
+ clusterCount: collapsed.length,
769
886
  },
770
887
  }
771
888
  }
772
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
+
773
899
  // Human-like recall ordering. Retrieval answers first (semantic match is still
774
900
  // the primary signal, which is why it stays a rank decay), then these biases
775
901
  // re-order within that set:
@@ -779,18 +905,22 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
779
905
  // Every factor is bounded so no bias can outrank a good semantic hit by luck.
780
906
  function recallScore(file, rank, context) {
781
907
  const entry = file.entry || {}
782
- // A fused file carries its own normalised RRF share. Falling back to the old
783
- // rank decay keeps every lexical caller byte-for-byte compatible.
784
- const fused = Number(file.retrievalScore)
785
- const retrieval = Number.isFinite(fused) ? Math.max(0, Math.min(1, fused)) : 1 / (rank + 1)
908
+ const retrieval = retrievalShare(file, rank)
786
909
  const strengthTerm = 0.6 + 0.4 * file.strength
787
910
  const marginal = entry.marginalized ? 0.35 : 1
788
911
 
789
912
  // Fading affect bias: what pulls is the tint the memory still carries today, not
790
913
  // the one it was written with. Old grievances quieten down; old fondness does not.
791
- const moodBias = context.moodEnabled
792
- ? 1 + 0.3 * moodCongruence(fadedValence(entry, context.now), context.moodValence)
793
- : 1
914
+ //
915
+ // Two paths, one decision: when both sides carry a composition, compare the
916
+ // compositions (how much of the present shape the memory shares). Otherwise fall
917
+ // back to the legacy signed-valence congruence. Entries written before this model
918
+ // existed have no composition and must keep working unchanged.
919
+ const moodBias = !context.moodEnabled
920
+ ? 1
921
+ : (context.emotion && entry.emotion
922
+ ? 1 + 0.3 * affectFadeFactor(entry, context.now) * (2 * overlap(entry.emotion, context.emotion) - 1)
923
+ : 1 + 0.3 * moodCongruence(fadedValence(entry, context.now), context.moodValence))
794
924
  const peak = entry.peak === true ? 1 : Math.max(0, Number(entry.arousal || 0))
795
925
  const end = context.lastCreatedAt && file.createdAtIdentity === context.lastCreatedAt ? 1 : 0
796
926
  const peakEnd = 1 + 0.25 * Math.max(peak, end)
@@ -802,13 +932,38 @@ function recallScore(file, rank, context) {
802
932
  // this asks "does its loudness match the room right now?" — different questions that
803
933
  // only look like duplicates.
804
934
  const arousalTerm = context.moodEnabled ? arousalBias(entry, context) : 1
935
+ // Dominance asks a third question: who was steering. Two equally sad memories pull
936
+ // differently depending on whether the present state is "I can fix this" or "nothing
937
+ // I do matters". Bounded to +-10% so it can only reorder near-ties.
938
+ const dominanceTerm = context.moodEnabled ? dominanceBias(entry.dominance, context.dominance) : 1
805
939
  // An open loop is an unfinished thread — a promise, a question, a plan nobody
806
940
  // closed. Those are what a person brings up unprompted, so they get a nudge and not
807
941
  // a trump card: 1.25 still loses to a clearly better semantic hit.
808
942
  const openLoopBias = entry.openLoop ? 1.25 : 1
809
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
810
949
 
811
- return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm
950
+ return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm * dominanceTerm * duplicateFactor
951
+ }
952
+
953
+ /**
954
+ * How much of a memory's original tint is still there, 0..1.
955
+ *
956
+ * Same curve as `fadedValence`, but returned as a bare factor so the composition path
957
+ * applies the *fading* to the whole comparison instead of decaying the shares (shares
958
+ * sum to 1, so decaying them proportionally would change nothing).
959
+ */
960
+ function affectFadeFactor(entry, now = Date.now()) {
961
+ const valence = entry.emotion ? derivedValence(entry.emotion) : Number(entry.moodValence || 0)
962
+ if (!valence) return 0
963
+ const updated = Date.parse(entry.updatedAt || entry.createdAt || '') || now
964
+ const days = Math.max(0, (now - updated) / 86_400_000)
965
+ const halfLife = valence < 0 ? FAB_HALF_LIFE_DAYS.negative : FAB_HALF_LIFE_DAYS.positive
966
+ return Math.pow(0.5, days / halfLife)
812
967
  }
813
968
 
814
969
  /**
@@ -822,9 +977,12 @@ function recallScore(file, rank, context) {
822
977
  function arousalBias(entry, context) {
823
978
  const present = Number(context.arousal == null ? BASE_AROUSAL : context.arousal)
824
979
  const drive = Math.max(-0.35, Math.min(0.65, present - BASE_AROUSAL))
980
+ // A composed entry can be *very* angry while its legacy valence projection looks
981
+ // mild, so the largest share counts as intensity alongside the stored arousal.
825
982
  const intensity = Math.max(
826
983
  entry.peak === true ? 1 : 0,
827
984
  Math.min(1, Math.max(0, Number(entry.arousal || 0))),
985
+ Math.min(1, maxShare(entry.emotion)),
828
986
  Math.min(1, Math.abs(Number(entry.moodValence || 0)) * 0.8),
829
987
  )
830
988
  return 1 + 0.5 * drive * (intensity - 0.5)
@@ -846,23 +1004,33 @@ function habituationFactor(entry, now = Date.now()) {
846
1004
  // bounded biases, so its scale depends on the library and a fixed cutoff would mean
847
1005
  // different things in different conversations. "Keep what scores within N of the best
848
1006
  // hit" is scale-free, and 0 turns the filter off entirely.
849
- 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
850
1012
  const cut = Number(threshold)
851
- if (!Number.isFinite(cut) || cut <= 0) return files
852
- const best = Number(files[0]?.score || 0)
853
- if (best <= 0) return files
854
- 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
855
1020
  }
856
1021
 
857
1022
  function privateWeighted(result, max, context = {}) {
858
1023
  const ctx = {
859
1024
  moodEnabled: false,
860
1025
  moodValence: 0,
1026
+ emotion: null,
861
1027
  arousal: BASE_AROUSAL,
1028
+ dominance: 0,
862
1029
  firstCreatedAt: null,
863
1030
  lastCreatedAt: null,
864
1031
  sessionId: null,
865
1032
  now: Date.now(),
1033
+ retrievalFloor: RETRIEVAL_FLOOR,
866
1034
  ...context,
867
1035
  }
868
1036
  const items = (result.files || [])
@@ -873,55 +1041,79 @@ function privateWeighted(result, max, context = {}) {
873
1041
  // memories. Filtering here keeps the anchor and the recall block in agreement.
874
1042
  .filter((f) => visibleToSession(f.entry, ctx.sessionId))
875
1043
  .filter((f) => !ctx.automatic || f.entry?.visibility !== 'secret')
876
- .map((f, i) => ({ ...f, score: recallScore(f, i, ctx) }))
1044
+ .map((f, i) => ({ ...f, score: recallScore(f, i, ctx), retrieval: retrievalShare(f, i) }))
877
1045
  .sort((a, b) => b.score - a.score)
878
- 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 } }
879
1054
  }
880
1055
 
881
1056
  /**
882
1057
  * Greedy maximal marginal relevance over already-scored files.
883
1058
  *
884
- * Similarity is lexical (queryTokens + Jaccard) on purpose: it must be cheap enough
885
- * to run on every recall and predictable enough to explain. An embedding version
886
- * 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.
887
1071
  */
888
1072
  function mmrSelect(items, max, lambda = MMR_LAMBDA) {
889
1073
  const limit = Number(max || 0)
890
- if (!(limit > 0)) return items
891
- 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: [] }
892
1076
  const chosen = []
893
1077
  const pool = items.slice()
894
- const tokenCache = new Map()
895
- const tokensOf = (file) => {
1078
+ const gramsCache = new Map()
1079
+ const gramsOf = (file) => {
896
1080
  const key = file.name
897
- if (tokenCache.has(key)) return tokenCache.get(key)
898
- const set = new Set(queryTokens(String(file.entry?.title || '') + ' ' + String(file.entry?.content || '')))
899
- 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)
900
1084
  return set
901
1085
  }
902
1086
  // Anchor the penalty to the best raw score so the diversity term is on the same
903
1087
  // scale as relevance instead of being dominated by it.
904
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()
905
1092
  while (pool.length > 0 && chosen.length < limit) {
906
- let bestIdx = 0
1093
+ let bestIdx = -1
907
1094
  let bestScore = -Infinity
908
1095
  for (let i = 0; i < pool.length; i += 1) {
909
1096
  let overlap = 0
910
- 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 }
911
1104
  const adjusted = Number(pool[i].score || 0) - lambda * overlap * ceiling
912
1105
  if (adjusted > bestScore) { bestScore = adjusted; bestIdx = i }
913
1106
  }
1107
+ if (bestIdx === -1) break // nothing selectable is left: only hard duplicates remain
914
1108
  chosen.push(pool.splice(bestIdx, 1)[0])
915
1109
  }
916
- return chosen
917
- }
918
-
919
- /** Jaccard overlap of two token sets; 0 when either side is empty. */
920
- function jaccard(a, b) {
921
- if (!a || !b || a.size === 0 || b.size === 0) return 0
922
- let shared = 0
923
- for (const token of a) if (b.has(token)) shared += 1
924
- 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 }
925
1117
  }
926
1118
 
927
1119
  /**
@@ -952,13 +1144,72 @@ function createdAtSpan(files) {
952
1144
  return { first: stamps[0] || null, last: stamps[stamps.length - 1] || null }
953
1145
  }
954
1146
 
1147
+ /**
1148
+ * Decide what an entry's affect actually is, and which tier answered.
1149
+ *
1150
+ * Order: the model said it; the conversation is in a mood and inheritance is allowed;
1151
+ * the memory's own text reads as emotional; nothing. `emotionSource` keeps "neutral"
1152
+ * and "no idea" distinguishable — they drive the same recall multiplier, but only one
1153
+ * of them means the write path worked.
1154
+ *
1155
+ * Engineering facts stay neutral on purpose. A bug being hard to fix is not the
1156
+ * project feeling sad, and letting the turn's mood leak into a technical entry is how
1157
+ * "the deployment failed and we were furious" becomes a fact about the deployment.
1158
+ */
1159
+ function resolveEntryEmotion({ emotion, dominance, emotionSource, owner, content, binding }) {
1160
+ const providedDominance = Number(dominance)
1161
+ const explicitDominance = dominance !== null && dominance !== undefined && Number.isFinite(providedDominance)
1162
+ ? Math.max(-1, Math.min(1, providedDominance))
1163
+ : null
1164
+ // Three states, and they are not the same thing: omitted = inherit (a caller that
1165
+ // says nothing wants the turn's context), null = explicitly neutral (the model looked
1166
+ // and found nothing), an object = the model's reading. Collapsing null into "omitted"
1167
+ // would silently reinstate inherited mood on a memory the model called neutral.
1168
+ const explicitNeutral = emotion === null
1169
+ const fromModel = emotion !== undefined && emotion !== null
1170
+ let resolved = null
1171
+ let sourceName = 'unknown'
1172
+ let inheritedDominance = null
1173
+ if (fromModel) {
1174
+ resolved = normalizeEmotion(emotion)
1175
+ sourceName = 'model'
1176
+ } else if (!explicitNeutral && owner !== 'about_project') {
1177
+ const sessionMood = binding && binding.mood ? binding.mood : null
1178
+ if (sessionMood && sessionMood.emotion) {
1179
+ resolved = normalizeEmotion(sessionMood.emotion)
1180
+ sourceName = resolved ? 'session' : 'unknown'
1181
+ const sessionDominance = Number(sessionMood.dominance)
1182
+ if (Number.isFinite(sessionDominance)) inheritedDominance = Math.max(-1, Math.min(1, sessionDominance))
1183
+ }
1184
+ if (resolved === null && sourceName === 'unknown') {
1185
+ const read = readMood(content)
1186
+ if (read.emotion) {
1187
+ resolved = read.emotion
1188
+ sourceName = 'content'
1189
+ inheritedDominance = read.dominance
1190
+ }
1191
+ }
1192
+ }
1193
+ return {
1194
+ emotion: resolved,
1195
+ dominance: explicitDominance === null ? (inheritedDominance === null ? 0 : inheritedDominance) : explicitDominance,
1196
+ source: explicitNeutral ? 'model' : (emotionSource || sourceName),
1197
+ neutral: explicitNeutral,
1198
+ }
1199
+ }
1200
+
955
1201
  async function privateRememberImpl({
956
1202
  exec, libraryId, content, title, layer, pinReason = null, pinSource = null,
957
1203
  sideEventId = null, tags = [], openLoop = false, disclosure = 'can_tell',
958
1204
  owner = 'me', visibility = 'shared', source = 'inferred', confidence = 0.8,
959
1205
  moodValence = null, arousal = null, peak = false, salientDate = null,
1206
+ emotion = undefined, dominance = undefined, emotionSource = null,
960
1207
  attachments, track = 'memory',
961
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,
962
1213
  }) {
963
1214
  const scope = resolveLibrary({ exec, libraryId })
964
1215
  const binding = scope.binding
@@ -1036,6 +1287,12 @@ async function privateRememberImpl({
1036
1287
  && (item.attachments || []).some(attachment => media.some(candidate => candidate.sha256 === attachment.sha256)))
1037
1288
  : null
1038
1289
  const id = previousHabit?.[0] || newMemoryId('mem')
1290
+ // A malformed share is refused here rather than clamped later: a negative or
1291
+ // non-finite composition is a caller bug, and repairing it silently would keep the
1292
+ // bug alive while the entry looks fine.
1293
+ if (emotion !== null && emotion !== undefined) normalizeEmotion(emotion)
1294
+ if (dominance !== null && dominance !== undefined && !Number.isFinite(Number(dominance))) throw new Error('dominance must be a number between -1 and 1')
1295
+ const resolvedAffect = resolveEntryEmotion({ emotion, dominance, owner, content: String(content), binding })
1039
1296
  const entry = {
1040
1297
  ...(previousHabit?.[1] || {}),
1041
1298
  track,
@@ -1068,9 +1325,18 @@ async function privateRememberImpl({
1068
1325
  // Retrieval lens + resurface metadata. When the agent does not state a mood,
1069
1326
  // the current session read is stored instead, so a memory written during a
1070
1327
  // hard conversation is tinted by it without the model having to name it.
1071
- moodValence: moodValence === null || moodValence === undefined
1072
- ? (binding?.mood ? Number(binding.mood.valence) || 0 : 0)
1073
- : Math.max(-1, Math.min(1, Number(moodValence) || 0)),
1328
+ // `moodValence` stays as the legacy projection of the four shares; the shares
1329
+ // themselves are what the new recall path compares.
1330
+ moodValence: resolvedAffect.emotion
1331
+ ? derivedValence(resolvedAffect.emotion)
1332
+ : (resolvedAffect.neutral
1333
+ ? 0
1334
+ : (moodValence === null || moodValence === undefined
1335
+ ? (binding?.mood ? Number(binding.mood.valence) || 0 : 0)
1336
+ : Math.max(-1, Math.min(1, Number(moodValence) || 0)))),
1337
+ emotion: resolvedAffect.emotion,
1338
+ dominance: resolvedAffect.dominance,
1339
+ emotionSource: resolvedAffect.source,
1074
1340
  arousal: arousal === null || arousal === undefined
1075
1341
  ? (binding?.mood ? Number(binding.mood.arousal) || 0.35 : 0.35)
1076
1342
  : Math.max(0, Math.min(1, Number(arousal) || 0)),
@@ -1092,6 +1358,35 @@ async function privateRememberImpl({
1092
1358
  ? `与已有记忆「${clampText(conflict.entry.title || conflict.id, 60)}」同题但内容不同`
1093
1359
  : `与已有记忆「${clampText(conflict.entry.title || conflict.id, 60)}」说法相反`
1094
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
1095
1390
  const payload = {
1096
1391
  recall_files: [{
1097
1392
  name: id,
@@ -1106,22 +1401,15 @@ async function privateRememberImpl({
1106
1401
  // would make a contested memory retrievable before anyone decided it. Accepting
1107
1402
  // the review later is what commits it (see resolveReview → privateUpdate).
1108
1403
  const route = engineRoute()
1109
- const result = (route !== 'lexical') && !conflict
1110
- ? await commit({ payload, route, db: memoryDbPath(scope.libraryId) })
1404
+ const db = route !== 'lexical' && !conflict ? memoryDbPath(scope.libraryId) : null
1405
+ const result = db
1406
+ ? await commit({ payload, route, db })
1111
1407
  : { ok: true, backend: route, held: conflict ? true : undefined }
1408
+ try {
1112
1409
  // 记下"这条进没进向量库":只有真的带代际提交成功才写,冲突暂扣/降级都不写。
1113
1410
  // 之前没有任何字段能回答"哪条记忆进向量库了",只能靠猜。
1114
1411
  if (route !== 'lexical' && !conflict && result?.ok !== false && !result?.parseWarning) {
1115
- const active = readVectorState({ tolerateMissing: true }).state?.active
1116
- if (active) {
1117
- entry.vector = {
1118
- route,
1119
- model: active.profile?.catalogId || null,
1120
- generation: active.generation,
1121
- fingerprint: String(active.fingerprint || '').slice(0, 8),
1122
- at: stamp(),
1123
- }
1124
- }
1412
+ entry.vector = activeEntryStamp(route) || entry.vector
1125
1413
  }
1126
1414
  // Re-read after the await: a curator job or another turn may have written in
1127
1415
  // the meantime, and saving the pre-await snapshot would erase it.
@@ -1132,6 +1420,7 @@ async function privateRememberImpl({
1132
1420
  if (previousHabit && JSON.stringify(fresh.entries[id]) !== JSON.stringify(previousHabit[1])) throw new Error('habit changed concurrently; recall and retry')
1133
1421
  const latestConflict = findConflict(Object.fromEntries(Object.entries(fresh.entries).filter(([key]) => key !== id)), entry)
1134
1422
  if (latestConflict && !conflict) {
1423
+ if (db) throw new Error('memory conflict changed during write; retry for review')
1135
1424
  entry.status = 'pending_review'
1136
1425
  entry.conflictingWith = latestConflict.id
1137
1426
  entry.reviewReason = 'concurrent conflicting memory; review required'
@@ -1142,6 +1431,10 @@ async function privateRememberImpl({
1142
1431
  }
1143
1432
  fresh.entries[id] = entry
1144
1433
  }))
1434
+ } catch (error) {
1435
+ if (db) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id, route, db, expectedEntry: previousHabit?.[1] || null })
1436
+ throw error
1437
+ }
1145
1438
  if (!previousHabit) bumpStats(scope.libraryId, 'written')
1146
1439
  if (layer === 'must_remember' && pinSource === 'side_event') {
1147
1440
  consumeCue(sessionIdOf(exec), sideEventId)
@@ -1305,6 +1598,12 @@ async function importMemoriesImpl({ libraryId, memories, maxFiles = 2000, now =
1305
1598
  engineWarning = String(error?.message || error)
1306
1599
  }
1307
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
+ }
1308
1607
  const idMap = Object.fromEntries(entries.filter(item => item.entry.importedFrom).map(item => [item.entry.importedFrom, item.id]))
1309
1608
  await retryStoreWrite(() => updatePrivateIndex(libraryId, (fresh) => {
1310
1609
  for (const { id, entry } of entries) {
@@ -1343,11 +1642,14 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
1343
1642
  const span = createdAtSpan(Object.values(index.entries || {}).map((entry) => ({ entry })))
1344
1643
  const mood = moodDecay(binding.mood)
1345
1644
  const scored = privateWeighted(enriched, Number(topK || settings.privateTopK || 4), {
1346
- // Arousal is used even when valence is near neutral: a tense room with no clear
1347
- // good/bad reading still changes which memories come up.
1348
- moodEnabled: Math.abs(mood.valence) >= MOOD_MIN_STRENGTH || Math.abs(mood.arousal - BASE_AROUSAL) >= 0.1,
1645
+ // Three independent legs: a clear composition, an arousal away from rest, or a
1646
+ // clear sense of control. A null composition only closes the first one — a tense
1647
+ // room with no clear good/bad reading still changes which memories come up.
1648
+ moodEnabled: emotionActive({ emotion: mood.emotion, arousal: mood.arousal, dominance: mood.dominance }),
1349
1649
  moodValence: mood.valence,
1650
+ emotion: mood.emotion,
1350
1651
  arousal: mood.arousal,
1652
+ dominance: mood.dominance,
1351
1653
  firstCreatedAt: span.first,
1352
1654
  lastCreatedAt: span.last,
1353
1655
  relevanceThreshold: threshold,
@@ -1360,6 +1662,21 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
1360
1662
  // like one nobody ever touches. Batched into a single index write.
1361
1663
  if (purpose !== 'automatic') touchAccessMany({ libraryId: binding.libraryId, ids: (scored.files || []).map((f) => f.name) })
1362
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
+ }
1363
1680
  for (const file of scored.files || []) {
1364
1681
  if (file.entry?.attachments?.length) {
1365
1682
  file.entry = { ...file.entry, attachments: file.entry.attachments.map(item => {
@@ -1419,7 +1736,7 @@ export async function privateList({ exec, libraryId, preferIndex = false }) {
1419
1736
  // mood lens and the attachment list all change how an entry is scored, aggregated or
1420
1737
  // rendered, so a wrong value in any of them was a permanent mis-filing that could
1421
1738
  // only be undone by archiving the memory and writing it again.
1422
- async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, status, openLoop, disclosure, confidence, layer, owner, visibility, source, salientDate, track, peak, moodValence, arousal, attachments, context }) {
1739
+ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, status, openLoop, disclosure, confidence, layer, owner, visibility, source, salientDate, track, peak, moodValence, arousal, emotion, dominance, attachments, context }) {
1423
1740
  // `source` is editable because it is a claim about provenance, and claims get
1424
1741
  // corrected: a memory the model first filed as `inferred` may turn out to be
1425
1742
  // something the user actually said. Enum-checked like the rest — a typo here
@@ -1441,6 +1758,8 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1441
1758
  if (confidence !== undefined && !Number.isFinite(Number(confidence))) throw new Error('confidence must be a number between 0 and 1')
1442
1759
  if (moodValence !== undefined && !Number.isFinite(Number(moodValence))) throw new Error('moodValence must be a number between -1 and 1')
1443
1760
  if (arousal !== undefined && !Number.isFinite(Number(arousal))) throw new Error('arousal must be a number between 0 and 1')
1761
+ if (emotion !== undefined && emotion !== null) normalizeEmotion(emotion)
1762
+ if (dominance !== undefined && dominance !== null && !Number.isFinite(Number(dominance))) throw new Error('dominance must be a number between -1 and 1')
1444
1763
  // Throws for anything the library never received: an attachment list you cannot
1445
1764
  // correct would leave a mis-filed image attached forever.
1446
1765
  const nextAttachments = attachments === undefined ? undefined : attachmentRefs(scope.libraryId, attachments)
@@ -1448,13 +1767,43 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1448
1767
  // the same way. Capture before the update: `entry` is the pre-update snapshot.
1449
1768
  const wasArchived = entry.status === 'archived'
1450
1769
  const nextContent = content === undefined ? undefined : redactSecrets(content)
1451
- 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) {
1452
1781
  const others = Object.fromEntries(Object.entries(index.entries).filter(([key]) => key !== id_))
1453
- const conflict = findConflict(others, { ...entry, title: title ?? entry.title, content: nextContent })
1782
+ const conflict = findConflict(others, { ...entry, title: nextTitle, content: nextContent ?? entry.content })
1454
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
+ }
1455
1803
  }
1456
1804
  const route = engineRoute()
1457
1805
  const managed = route === 'managed-local'
1806
+ let committedDb = null
1458
1807
  if ((nextContent !== undefined || managed) && entry.status !== 'pending_review' && status !== 'pending_review') {
1459
1808
  const payload = {
1460
1809
  recall_files: [{
@@ -1467,10 +1816,14 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1467
1816
  user: {},
1468
1817
  }
1469
1818
  if (route !== 'lexical') {
1470
- await commit({ payload, route, db: memoryDbPath(scope.libraryId) })
1819
+ const db = memoryDbPath(scope.libraryId)
1820
+ await commit({ payload, route, db })
1821
+ committedDb = db
1471
1822
  }
1472
1823
  }
1473
- const updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
1824
+ let updated
1825
+ try {
1826
+ updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
1474
1827
  const target = fresh.entries[id_]
1475
1828
  if (!target) throw new Error(`unknown memory: ${id_}`)
1476
1829
  if (JSON.stringify(target) !== JSON.stringify(entry)) throw new Error('memory changed concurrently; reload and retry')
@@ -1480,6 +1833,18 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1480
1833
  if (currentBinding) assertScopeEnabled(currentBinding)
1481
1834
  if (title !== undefined) target.title = clampText(title, 120)
1482
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
+ }
1483
1848
  if (tags !== undefined) target.tags = Array.isArray(tags) ? tags.slice(0, 20) : target.tags
1484
1849
  if (status !== undefined) target.status = status
1485
1850
  if (openLoop !== undefined) target.openLoop = !!openLoop
@@ -1513,7 +1878,28 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1513
1878
  if (peak !== undefined) target.peak = peak === true
1514
1879
  if (moodValence !== undefined) target.moodValence = Math.max(-1, Math.min(1, Number(moodValence)))
1515
1880
  if (arousal !== undefined) target.arousal = Math.max(0, Math.min(1, Number(arousal)))
1881
+ // Three states, not two: omitted keeps the stored composition, an explicit null
1882
+ // clears it (and the source becomes "we no longer know"), an object replaces it.
1883
+ // Collapsing null into "keep" would make it impossible to repair a wrong reading.
1884
+ if (emotion !== undefined) {
1885
+ if (emotion === null) {
1886
+ target.emotion = null
1887
+ target.emotionSource = 'unknown'
1888
+ target.moodValence = Number(moodValence) || 0
1889
+ } else {
1890
+ target.emotion = normalizeEmotion(emotion)
1891
+ target.emotionSource = 'model'
1892
+ target.moodValence = derivedValence(target.emotion)
1893
+ }
1894
+ }
1895
+ if (dominance !== undefined) target.dominance = dominance === null ? 0 : Math.max(-1, Math.min(1, Number(dominance)))
1516
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
+ }
1517
1903
  // Vividness and accuracy are derived, not typed in: correcting the peak flag or
1518
1904
  // the arousal reading has to move `accuracy` too, otherwise the repair would only
1519
1905
  // change how the entry is stored and not how much it is trusted when recalled.
@@ -1552,6 +1938,10 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1552
1938
  }
1553
1939
  target.updatedAt = stamp()
1554
1940
  }))
1941
+ } catch (error) {
1942
+ if (committedDb) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id: id_, route, db: committedDb, expectedEntry: entry })
1943
+ throw error
1944
+ }
1555
1945
  if (status === 'archived' && !wasArchived) bumpStats(scope.libraryId, 'archived')
1556
1946
  return { id: id_, entry: updated.entries[id_] }
1557
1947
  }
@@ -1569,6 +1959,30 @@ async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolutio
1569
1959
  if (action === 'accept') {
1570
1960
  if (typeof content === 'string' && content.trim()) next.content = content
1571
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
1572
1986
  const reviewRoute = engineRoute()
1573
1987
  if (reviewRoute !== 'lexical') {
1574
1988
  await commit({
@@ -1576,6 +1990,9 @@ async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolutio
1576
1990
  route: reviewRoute,
1577
1991
  db: libraryId ? memoryDbPath(libraryId) : globalDbPath(),
1578
1992
  })
1993
+ // pending_review 从不提交,所以被接受的那一刻才第一次真正进向量库:补上戳,
1994
+ // 否则这条在向量计数里永远缺失。
1995
+ next.vector = activeEntryStamp(reviewRoute) || next.vector
1579
1996
  }
1580
1997
  }
1581
1998
  const mutate = fresh => {
@@ -1695,7 +2112,27 @@ async function privateDeleteImpl({ exec, libraryId, id }) {
1695
2112
  await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
1696
2113
  if (JSON.stringify(fresh.entries[id_]) !== JSON.stringify(entry)) throw new Error('memory changed during deletion; reload and retry')
1697
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
+ }
1698
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
+ }
1699
2136
  return { id: id_, libraryId: scope.libraryId, deleted: true, backend, engine }
1700
2137
  }
1701
2138
 
@@ -1761,22 +2198,27 @@ export function privateUnpin({ exec, id, reason }) {
1761
2198
 
1762
2199
  export async function privateRemember(args) {
1763
2200
  const libraryId = resolveLibrary(args).libraryId
1764
- const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl(args))
2201
+ const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl({ ...args, libraryId }))
2202
+ const deduped = result?.deduped === true
1765
2203
  record({
1766
2204
  kind: 'write',
1767
2205
  scope: 'private',
1768
2206
  sessionId: sessionIdOf(args.exec) || null,
1769
2207
  libraryId,
1770
- message: '写入 ' + (result?.id || '?') + ':' + clampText(args.title || args.content, 60) + (result?.conflict ? '(待复核)' : ''),
1771
- detail: { id: result?.id || null, title: args.title || null, layer: args.layer || null, conflict: result?.conflict || null },
1772
- 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] : []),
1773
2215
  })
1774
2216
  return result
1775
2217
  }
1776
2218
 
1777
2219
  export async function privateUpdate(args) {
1778
2220
  const libraryId = resolveLibrary(args).libraryId
1779
- const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl(args))
2221
+ const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl({ ...args, libraryId }))
1780
2222
  record({
1781
2223
  kind: 'write',
1782
2224
  scope: 'private',
@@ -1791,7 +2233,7 @@ export async function privateUpdate(args) {
1791
2233
 
1792
2234
  export async function privateDelete(args) {
1793
2235
  const libraryId = resolveLibrary(args).libraryId
1794
- const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl(args))
2236
+ const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl({ ...args, libraryId }))
1795
2237
  record({
1796
2238
  kind: 'write',
1797
2239
  scope: 'private',
@@ -1884,15 +2326,19 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold, retr
1884
2326
  ...f,
1885
2327
  // Local lexical rank is already meaningful; confidence still nudges ties, and
1886
2328
  // a cross-session fact that has been injected three turns running gets the same
1887
- // 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),
1888
2332
  score: (Number.isFinite(f.retrievalScore) ? f.retrievalScore : f.lexical > 0 ? f.lexical : 1 / (i + 1))
1889
2333
  * (0.85 + 0.15 * Number(f.entry.confidence ?? 0.5))
1890
2334
  * habituationFactor(f.entry),
1891
2335
  }))
1892
2336
  .sort((a, b) => b.score - a.score)
2337
+ const ranked = mmrSelect(cutWeakScores(filtered, threshold, RETRIEVAL_FLOOR), Number(topK || settings.globalTopK || 3))
1893
2338
  return {
1894
2339
  ...enriched,
1895
- files: mmrSelect(cutWeakScores(filtered, threshold), Number(topK || settings.globalTopK || 3)),
2340
+ files: ranked.chosen,
2341
+ dedup: { collapsed: ranked.skipped, clusterCount: ranked.skipped.length },
1896
2342
  domain: domainOf(binding),
1897
2343
  unbound: !peeked,
1898
2344
  }