dsh-memu-memory 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +31 -2
  3. package/README.zh.md +30 -2
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +1 -1
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/host-api.md +2 -2
  9. package/docs/memory-semantics.md +12 -10
  10. package/docs/recall-benchmark.md +238 -0
  11. package/docs/troubleshooting.md +395 -395
  12. package/lib/active-memory.js +165 -0
  13. package/lib/activity-log.js +1 -1
  14. package/lib/binding-diagnostics.js +8 -3
  15. package/lib/client.js +33 -11
  16. package/lib/consolidation-ledger.js +347 -0
  17. package/lib/consolidation-receipt.js +284 -0
  18. package/lib/consolidation-snapshot.js +274 -0
  19. package/lib/consolidation-worker.js +115 -0
  20. package/lib/cycle.js +40 -6
  21. package/lib/inject.js +104 -76
  22. package/lib/local-embedding-manager.js +24 -6
  23. package/lib/memory.js +221 -19
  24. package/lib/mood.js +268 -39
  25. package/lib/preset-scope.js +1 -1
  26. package/lib/private.js +335 -40
  27. package/lib/prompts/private-consolidation.coding.md +1 -0
  28. package/lib/prompts/private-consolidation.md +1 -0
  29. package/lib/prompts/private-consolidation.mixed.md +1 -0
  30. package/lib/prompts/private-consolidation.rp.md +1 -0
  31. package/lib/prompts.js +4 -4
  32. package/lib/remote-summary.js +51 -25
  33. package/lib/rpc.js +9 -1
  34. package/lib/state.js +29 -12
  35. package/lib/tools.js +49 -2
  36. package/lib/vector-profile.js +27 -0
  37. package/package.json +2 -1
  38. package/scripts/acceptance-local.mjs +2 -1
  39. package/scripts/active-memory-ab-live.mjs +655 -0
  40. package/scripts/active-memory-ab.mjs +1302 -0
  41. package/scripts/chain-matrix.mjs +11 -1
  42. package/scripts/check-all.mjs +10 -1
  43. package/tests/activation.test.mjs +89 -0
  44. package/tests/active-memory-ab.test.mjs +745 -0
  45. package/tests/active-memory.test.mjs +129 -0
  46. package/tests/activity-log.test.mjs +126 -0
  47. package/tests/activity-wiring.test.mjs +103 -0
  48. package/tests/appendix-b.test.mjs +212 -0
  49. package/tests/appendix-b2.test.mjs +166 -0
  50. package/tests/audit-regression.test.mjs +272 -0
  51. package/tests/audit-safety.test.mjs +227 -0
  52. package/tests/backend-compensation.test.mjs +272 -0
  53. package/tests/backup.test.mjs +793 -0
  54. package/tests/binding-diagnostics.test.mjs +720 -0
  55. package/tests/browser.test.mjs +366 -0
  56. package/tests/client-render.test.mjs +892 -0
  57. package/tests/coalesce.test.mjs +113 -0
  58. package/tests/config.test.mjs +159 -0
  59. package/tests/consolidation-credential.test.mjs +320 -0
  60. package/tests/consolidation-fork.test.mjs +357 -0
  61. package/tests/consolidation-ledger.test.mjs +298 -0
  62. package/tests/consolidation-snapshot.test.mjs +321 -0
  63. package/tests/consolidation-value.test.mjs +89 -0
  64. package/tests/context-scope.test.mjs +40 -0
  65. package/tests/cue.test.mjs +44 -0
  66. package/tests/curator-probe.test.mjs +433 -0
  67. package/tests/curator.test.mjs +150 -0
  68. package/tests/cycle.test.mjs +411 -0
  69. package/tests/disclosure.test.mjs +231 -0
  70. package/tests/doc-drift.test.mjs +222 -0
  71. package/tests/embedding-catalog.test.mjs +266 -0
  72. package/tests/embedding-download.test.mjs +391 -0
  73. package/tests/embedding-runtime.test.mjs +389 -0
  74. package/tests/emotion-chain.test.mjs +84 -0
  75. package/tests/emotion-contract.test.mjs +445 -0
  76. package/tests/engine.test.mjs +35 -0
  77. package/tests/fixtures/active-memory-ab/README.md +56 -0
  78. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  79. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  80. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  81. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  82. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  83. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  84. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  85. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  86. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  87. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  88. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  89. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  90. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  92. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  93. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  94. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  95. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  96. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  97. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  98. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  99. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  100. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  101. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  102. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  103. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  104. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  105. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  106. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  107. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  108. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  109. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  110. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  111. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  112. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  113. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  114. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  115. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  116. package/tests/graph-view.test.mjs +187 -0
  117. package/tests/graph.test.mjs +77 -0
  118. package/tests/hook-order.test.mjs +72 -0
  119. package/tests/humanlike.test.mjs +232 -0
  120. package/tests/hybrid-recall.test.mjs +92 -0
  121. package/tests/inbound.test.mjs +393 -0
  122. package/tests/inject-guide.test.mjs +364 -0
  123. package/tests/inject.test.mjs +222 -0
  124. package/tests/injection-budget-settings.test.mjs +35 -0
  125. package/tests/injection-budget.test.mjs +64 -0
  126. package/tests/injection-chain.test.mjs +191 -0
  127. package/tests/loader-register.mjs +3 -0
  128. package/tests/loader.mjs +12 -0
  129. package/tests/local-embedding-entry.test.mjs +95 -0
  130. package/tests/local-embedding-manager.test.mjs +476 -0
  131. package/tests/local-embedding-protocol.test.mjs +95 -0
  132. package/tests/local-embedding-worker.test.mjs +346 -0
  133. package/tests/managed-memory.test.mjs +536 -0
  134. package/tests/managed-runtime.test.mjs +40 -0
  135. package/tests/media.test.mjs +132 -0
  136. package/tests/migration-payload.test.mjs +42 -0
  137. package/tests/mood.test.mjs +109 -0
  138. package/tests/multiprocess.test.mjs +29 -0
  139. package/tests/new-session-gate.test.mjs +361 -0
  140. package/tests/package-shipping.test.mjs +84 -0
  141. package/tests/preset-scope.test.mjs +212 -0
  142. package/tests/prompts.test.mjs +250 -0
  143. package/tests/react-snapshot.test.mjs +23 -0
  144. package/tests/recall-bench.test.mjs +306 -0
  145. package/tests/remote.test.mjs +140 -0
  146. package/tests/rename.test.mjs +30 -0
  147. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  148. package/tests/repetition-eval-live.test.mjs +177 -0
  149. package/tests/repetition-eval.test.mjs +373 -0
  150. package/tests/salient-date.test.mjs +142 -0
  151. package/tests/settings-beat.test.mjs +221 -0
  152. package/tests/short-term.test.mjs +166 -0
  153. package/tests/sidebar-beat.test.mjs +208 -0
  154. package/tests/sidebar-refresh.test.mjs +123 -0
  155. package/tests/sidebar.test.mjs +70 -0
  156. package/tests/snippet.test.mjs +47 -0
  157. package/tests/source-kind.test.mjs +52 -0
  158. package/tests/state.test.mjs +150 -0
  159. package/tests/stats.test.mjs +112 -0
  160. package/tests/storage.test.mjs +125 -0
  161. package/tests/stubs/dsh-llm.js +5 -0
  162. package/tests/stubs/dsh-session.js +7 -0
  163. package/tests/stubs/dsh-tools.js +101 -0
  164. package/tests/tool-schema.test.mjs +73 -0
  165. package/tests/tools.test.mjs +184 -0
  166. package/tests/ui-defaults.test.mjs +57 -0
  167. package/tests/update-fields.test.mjs +138 -0
  168. package/tests/vector-migration.test.mjs +733 -0
  169. package/tests/vector-profile.test.mjs +425 -0
  170. package/tests/visibility-migration.test.mjs +152 -0
  171. package/tests/webui-auth.test.mjs +454 -0
  172. package/tests/webui.test.mjs +164 -0
  173. package/tests/windows-env.test.mjs +89 -0
package/lib/memory.js CHANGED
@@ -10,8 +10,9 @@ 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
18
  import { recallRelations } from './graph.js'
@@ -93,7 +94,59 @@ function clearRecallError(exec) {
93
94
  if (sessionId) setRecallError(sessionId, null)
94
95
  }
95
96
 
97
+ // Serialize backend-bearing private writes across every route in this module.
98
+ // Acquire this queue BEFORE the vector lease; neither waits while holding a
99
+ // synchronous file lock. This is not a cross-process/backend transaction.
100
+ const privateWriteTails = new Map()
96
101
  async function withManagedPrivateWrite(libraryId, action) {
102
+ const previous = privateWriteTails.get(libraryId) || Promise.resolve()
103
+ const running = previous.then(() => withManagedPrivateWriteImpl(libraryId, action))
104
+ const settled = running.then(() => {}, () => {})
105
+ privateWriteTails.set(libraryId, settled)
106
+ try { return await running } finally {
107
+ if (privateWriteTails.get(libraryId) === settled) privateWriteTails.delete(libraryId)
108
+ }
109
+ }
110
+
111
+ function assertCompensationResult(result, operation) {
112
+ if (!result || typeof result !== 'object' || Array.isArray(result) || result.ok === false || result.error || result.parseWarning
113
+ || (operation === 'backend compensation delete' && (!Number.isSafeInteger(result.deleted) || result.deleted < 0))) {
114
+ throw new Error(operation + ' was not acknowledged')
115
+ }
116
+ }
117
+
118
+ // The index remains authoritative. Refuse destructive repair if another index
119
+ // writer replaced the expected pre-await entry; otherwise rebuild that old row.
120
+ // No transaction spans these external calls: crashes/other processes can still
121
+ // interrupt compensation. Never describe an uncertain repair as success.
122
+ async function compensatePrivateCommit(error, { libraryId, id, route, db, expectedEntry }) {
123
+ try {
124
+ const current = loadPrivateIndex(libraryId).entries[id]
125
+ if (JSON.stringify(current ?? null) !== JSON.stringify(expectedEntry ?? null)) {
126
+ throw new Error('memory changed before backend compensation')
127
+ }
128
+ const snapshot = JSON.stringify(current)
129
+ assertCompensationResult(await forget({ route, db, name: id }), 'backend compensation delete')
130
+ if (current && !['pending_review', 'superseded'].includes(current.status)) {
131
+ assertCompensationResult(await commit({ route, db, payload: {
132
+ recall_files: [{ name: id, track: current.track || 'memory', description: clampText(current.title, 180), content: current.content }],
133
+ resource: [], user: {},
134
+ } }), 'backend compensation restore')
135
+ }
136
+ if (JSON.stringify(loadPrivateIndex(libraryId).entries[id]) !== snapshot) {
137
+ throw new Error('memory changed during backend compensation')
138
+ }
139
+ } catch (compensationError) {
140
+ const failure = new Error('private memory index write failed; backend compensation uncertain: ' + String(compensationError?.message || compensationError), { cause: error })
141
+ failure.code = 'PRIVATE_BACKEND_COMPENSATION_FAILED'
142
+ failure.compensationError = compensationError
143
+ warn(failure.message)
144
+ throw failure
145
+ }
146
+ throw error
147
+ }
148
+
149
+ async function withManagedPrivateWriteImpl(libraryId, action) {
97
150
  const intent = managedVectorIntent()
98
151
  if (!intent.managed) return action()
99
152
  // 选了本地向量、但下载完模型后从没跑过迁移(vector-active 只有 requested):这里
@@ -219,6 +272,7 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
219
272
  }
220
273
  const binding = readOnly ? peekBinding(exec) : bindingFor(exec)
221
274
  if (!binding) throw new Error('no bound session')
275
+ assertConsolidationParent(binding)
222
276
  // A binding without a library means the conversation explicitly opted out of
223
277
  // private memory. Writing through it used to land in `index/null.json` — a
224
278
  // "successful" write that can never be recalled. Refuse with an actionable
@@ -232,8 +286,18 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
232
286
  // The sidebar switches (and the folder ceiling behind them) must constrain tool
233
287
  // calls too, not just injection. One gate, sourced from `effectiveScope` so the
234
288
  // folder policy is always part of the answer.
289
+ function assertConsolidationParent(binding) {
290
+ if (!binding?.consolidationFork) return
291
+ const parent = getSessionBinding(binding.parentSessionId)
292
+ if (!parent || !binding.parentScopeGeneration || scopeGenerationOf(parent) !== binding.parentScopeGeneration) {
293
+ throw new Error('DSH_MEMU_SCOPE_STALE: consolidation worker parent authorization changed')
294
+ }
295
+ }
296
+
235
297
  function assertScopeEnabled(binding, { layer = 'library' } = {}) {
236
298
  if (!binding) return
299
+ assertConsolidationParent(binding)
300
+ if (binding.consolidationFork && layer === 'global') throw new Error('DSH_MEMU_WORKER_RESTRICTED: private worker cannot access global memory')
237
301
  const scope = effectiveScope(binding)
238
302
  if (layer === 'library' && !scope.useLibrary) {
239
303
  throw new Error(scope.libraryBlocked
@@ -788,9 +852,16 @@ function recallScore(file, rank, context) {
788
852
 
789
853
  // Fading affect bias: what pulls is the tint the memory still carries today, not
790
854
  // 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
855
+ //
856
+ // Two paths, one decision: when both sides carry a composition, compare the
857
+ // compositions (how much of the present shape the memory shares). Otherwise fall
858
+ // back to the legacy signed-valence congruence. Entries written before this model
859
+ // existed have no composition and must keep working unchanged.
860
+ const moodBias = !context.moodEnabled
861
+ ? 1
862
+ : (context.emotion && entry.emotion
863
+ ? 1 + 0.3 * affectFadeFactor(entry, context.now) * (2 * overlap(entry.emotion, context.emotion) - 1)
864
+ : 1 + 0.3 * moodCongruence(fadedValence(entry, context.now), context.moodValence))
794
865
  const peak = entry.peak === true ? 1 : Math.max(0, Number(entry.arousal || 0))
795
866
  const end = context.lastCreatedAt && file.createdAtIdentity === context.lastCreatedAt ? 1 : 0
796
867
  const peakEnd = 1 + 0.25 * Math.max(peak, end)
@@ -802,13 +873,33 @@ function recallScore(file, rank, context) {
802
873
  // this asks "does its loudness match the room right now?" — different questions that
803
874
  // only look like duplicates.
804
875
  const arousalTerm = context.moodEnabled ? arousalBias(entry, context) : 1
876
+ // Dominance asks a third question: who was steering. Two equally sad memories pull
877
+ // differently depending on whether the present state is "I can fix this" or "nothing
878
+ // I do matters". Bounded to +-10% so it can only reorder near-ties.
879
+ const dominanceTerm = context.moodEnabled ? dominanceBias(entry.dominance, context.dominance) : 1
805
880
  // An open loop is an unfinished thread — a promise, a question, a plan nobody
806
881
  // closed. Those are what a person brings up unprompted, so they get a nudge and not
807
882
  // a trump card: 1.25 still loses to a clearly better semantic hit.
808
883
  const openLoopBias = entry.openLoop ? 1.25 : 1
809
884
  const habituation = habituationFactor(entry, context.now)
810
885
 
811
- return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm
886
+ return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm * dominanceTerm
887
+ }
888
+
889
+ /**
890
+ * How much of a memory's original tint is still there, 0..1.
891
+ *
892
+ * Same curve as `fadedValence`, but returned as a bare factor so the composition path
893
+ * applies the *fading* to the whole comparison instead of decaying the shares (shares
894
+ * sum to 1, so decaying them proportionally would change nothing).
895
+ */
896
+ function affectFadeFactor(entry, now = Date.now()) {
897
+ const valence = entry.emotion ? derivedValence(entry.emotion) : Number(entry.moodValence || 0)
898
+ if (!valence) return 0
899
+ const updated = Date.parse(entry.updatedAt || entry.createdAt || '') || now
900
+ const days = Math.max(0, (now - updated) / 86_400_000)
901
+ const halfLife = valence < 0 ? FAB_HALF_LIFE_DAYS.negative : FAB_HALF_LIFE_DAYS.positive
902
+ return Math.pow(0.5, days / halfLife)
812
903
  }
813
904
 
814
905
  /**
@@ -822,9 +913,12 @@ function recallScore(file, rank, context) {
822
913
  function arousalBias(entry, context) {
823
914
  const present = Number(context.arousal == null ? BASE_AROUSAL : context.arousal)
824
915
  const drive = Math.max(-0.35, Math.min(0.65, present - BASE_AROUSAL))
916
+ // A composed entry can be *very* angry while its legacy valence projection looks
917
+ // mild, so the largest share counts as intensity alongside the stored arousal.
825
918
  const intensity = Math.max(
826
919
  entry.peak === true ? 1 : 0,
827
920
  Math.min(1, Math.max(0, Number(entry.arousal || 0))),
921
+ Math.min(1, maxShare(entry.emotion)),
828
922
  Math.min(1, Math.abs(Number(entry.moodValence || 0)) * 0.8),
829
923
  )
830
924
  return 1 + 0.5 * drive * (intensity - 0.5)
@@ -858,7 +952,9 @@ function privateWeighted(result, max, context = {}) {
858
952
  const ctx = {
859
953
  moodEnabled: false,
860
954
  moodValence: 0,
955
+ emotion: null,
861
956
  arousal: BASE_AROUSAL,
957
+ dominance: 0,
862
958
  firstCreatedAt: null,
863
959
  lastCreatedAt: null,
864
960
  sessionId: null,
@@ -952,11 +1048,66 @@ function createdAtSpan(files) {
952
1048
  return { first: stamps[0] || null, last: stamps[stamps.length - 1] || null }
953
1049
  }
954
1050
 
1051
+ /**
1052
+ * Decide what an entry's affect actually is, and which tier answered.
1053
+ *
1054
+ * Order: the model said it; the conversation is in a mood and inheritance is allowed;
1055
+ * the memory's own text reads as emotional; nothing. `emotionSource` keeps "neutral"
1056
+ * and "no idea" distinguishable — they drive the same recall multiplier, but only one
1057
+ * of them means the write path worked.
1058
+ *
1059
+ * Engineering facts stay neutral on purpose. A bug being hard to fix is not the
1060
+ * project feeling sad, and letting the turn's mood leak into a technical entry is how
1061
+ * "the deployment failed and we were furious" becomes a fact about the deployment.
1062
+ */
1063
+ function resolveEntryEmotion({ emotion, dominance, emotionSource, owner, content, binding }) {
1064
+ const providedDominance = Number(dominance)
1065
+ const explicitDominance = dominance !== null && dominance !== undefined && Number.isFinite(providedDominance)
1066
+ ? Math.max(-1, Math.min(1, providedDominance))
1067
+ : null
1068
+ // Three states, and they are not the same thing: omitted = inherit (a caller that
1069
+ // says nothing wants the turn's context), null = explicitly neutral (the model looked
1070
+ // and found nothing), an object = the model's reading. Collapsing null into "omitted"
1071
+ // would silently reinstate inherited mood on a memory the model called neutral.
1072
+ const explicitNeutral = emotion === null
1073
+ const fromModel = emotion !== undefined && emotion !== null
1074
+ let resolved = null
1075
+ let sourceName = 'unknown'
1076
+ let inheritedDominance = null
1077
+ if (fromModel) {
1078
+ resolved = normalizeEmotion(emotion)
1079
+ sourceName = 'model'
1080
+ } else if (!explicitNeutral && owner !== 'about_project') {
1081
+ const sessionMood = binding && binding.mood ? binding.mood : null
1082
+ if (sessionMood && sessionMood.emotion) {
1083
+ resolved = normalizeEmotion(sessionMood.emotion)
1084
+ sourceName = resolved ? 'session' : 'unknown'
1085
+ const sessionDominance = Number(sessionMood.dominance)
1086
+ if (Number.isFinite(sessionDominance)) inheritedDominance = Math.max(-1, Math.min(1, sessionDominance))
1087
+ }
1088
+ if (resolved === null && sourceName === 'unknown') {
1089
+ const read = readMood(content)
1090
+ if (read.emotion) {
1091
+ resolved = read.emotion
1092
+ sourceName = 'content'
1093
+ inheritedDominance = read.dominance
1094
+ }
1095
+ }
1096
+ }
1097
+ return {
1098
+ emotion: resolved,
1099
+ dominance: explicitDominance === null ? (inheritedDominance === null ? 0 : inheritedDominance) : explicitDominance,
1100
+ source: explicitNeutral ? 'model' : (emotionSource || sourceName),
1101
+ neutral: explicitNeutral,
1102
+ }
1103
+ }
1104
+
955
1105
  async function privateRememberImpl({
956
1106
  exec, libraryId, content, title, layer, pinReason = null, pinSource = null,
957
1107
  sideEventId = null, tags = [], openLoop = false, disclosure = 'can_tell',
958
1108
  owner = 'me', visibility = 'shared', source = 'inferred', confidence = 0.8,
959
1109
  moodValence = null, arousal = null, peak = false, salientDate = null,
1110
+ emotion = undefined, dominance = undefined, emotionSource = null,
960
1111
  attachments, track = 'memory',
961
1112
  context,
962
1113
  }) {
@@ -1036,6 +1187,12 @@ async function privateRememberImpl({
1036
1187
  && (item.attachments || []).some(attachment => media.some(candidate => candidate.sha256 === attachment.sha256)))
1037
1188
  : null
1038
1189
  const id = previousHabit?.[0] || newMemoryId('mem')
1190
+ // A malformed share is refused here rather than clamped later: a negative or
1191
+ // non-finite composition is a caller bug, and repairing it silently would keep the
1192
+ // bug alive while the entry looks fine.
1193
+ if (emotion !== null && emotion !== undefined) normalizeEmotion(emotion)
1194
+ if (dominance !== null && dominance !== undefined && !Number.isFinite(Number(dominance))) throw new Error('dominance must be a number between -1 and 1')
1195
+ const resolvedAffect = resolveEntryEmotion({ emotion, dominance, owner, content: String(content), binding })
1039
1196
  const entry = {
1040
1197
  ...(previousHabit?.[1] || {}),
1041
1198
  track,
@@ -1068,9 +1225,18 @@ async function privateRememberImpl({
1068
1225
  // Retrieval lens + resurface metadata. When the agent does not state a mood,
1069
1226
  // the current session read is stored instead, so a memory written during a
1070
1227
  // 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)),
1228
+ // `moodValence` stays as the legacy projection of the four shares; the shares
1229
+ // themselves are what the new recall path compares.
1230
+ moodValence: resolvedAffect.emotion
1231
+ ? derivedValence(resolvedAffect.emotion)
1232
+ : (resolvedAffect.neutral
1233
+ ? 0
1234
+ : (moodValence === null || moodValence === undefined
1235
+ ? (binding?.mood ? Number(binding.mood.valence) || 0 : 0)
1236
+ : Math.max(-1, Math.min(1, Number(moodValence) || 0)))),
1237
+ emotion: resolvedAffect.emotion,
1238
+ dominance: resolvedAffect.dominance,
1239
+ emotionSource: resolvedAffect.source,
1074
1240
  arousal: arousal === null || arousal === undefined
1075
1241
  ? (binding?.mood ? Number(binding.mood.arousal) || 0.35 : 0.35)
1076
1242
  : Math.max(0, Math.min(1, Number(arousal) || 0)),
@@ -1106,9 +1272,11 @@ async function privateRememberImpl({
1106
1272
  // would make a contested memory retrievable before anyone decided it. Accepting
1107
1273
  // the review later is what commits it (see resolveReview → privateUpdate).
1108
1274
  const route = engineRoute()
1109
- const result = (route !== 'lexical') && !conflict
1110
- ? await commit({ payload, route, db: memoryDbPath(scope.libraryId) })
1275
+ const db = route !== 'lexical' && !conflict ? memoryDbPath(scope.libraryId) : null
1276
+ const result = db
1277
+ ? await commit({ payload, route, db })
1111
1278
  : { ok: true, backend: route, held: conflict ? true : undefined }
1279
+ try {
1112
1280
  // 记下"这条进没进向量库":只有真的带代际提交成功才写,冲突暂扣/降级都不写。
1113
1281
  // 之前没有任何字段能回答"哪条记忆进向量库了",只能靠猜。
1114
1282
  if (route !== 'lexical' && !conflict && result?.ok !== false && !result?.parseWarning) {
@@ -1132,6 +1300,7 @@ async function privateRememberImpl({
1132
1300
  if (previousHabit && JSON.stringify(fresh.entries[id]) !== JSON.stringify(previousHabit[1])) throw new Error('habit changed concurrently; recall and retry')
1133
1301
  const latestConflict = findConflict(Object.fromEntries(Object.entries(fresh.entries).filter(([key]) => key !== id)), entry)
1134
1302
  if (latestConflict && !conflict) {
1303
+ if (db) throw new Error('memory conflict changed during write; retry for review')
1135
1304
  entry.status = 'pending_review'
1136
1305
  entry.conflictingWith = latestConflict.id
1137
1306
  entry.reviewReason = 'concurrent conflicting memory; review required'
@@ -1142,6 +1311,10 @@ async function privateRememberImpl({
1142
1311
  }
1143
1312
  fresh.entries[id] = entry
1144
1313
  }))
1314
+ } catch (error) {
1315
+ if (db) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id, route, db, expectedEntry: previousHabit?.[1] || null })
1316
+ throw error
1317
+ }
1145
1318
  if (!previousHabit) bumpStats(scope.libraryId, 'written')
1146
1319
  if (layer === 'must_remember' && pinSource === 'side_event') {
1147
1320
  consumeCue(sessionIdOf(exec), sideEventId)
@@ -1343,11 +1516,14 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
1343
1516
  const span = createdAtSpan(Object.values(index.entries || {}).map((entry) => ({ entry })))
1344
1517
  const mood = moodDecay(binding.mood)
1345
1518
  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,
1519
+ // Three independent legs: a clear composition, an arousal away from rest, or a
1520
+ // clear sense of control. A null composition only closes the first one — a tense
1521
+ // room with no clear good/bad reading still changes which memories come up.
1522
+ moodEnabled: emotionActive({ emotion: mood.emotion, arousal: mood.arousal, dominance: mood.dominance }),
1349
1523
  moodValence: mood.valence,
1524
+ emotion: mood.emotion,
1350
1525
  arousal: mood.arousal,
1526
+ dominance: mood.dominance,
1351
1527
  firstCreatedAt: span.first,
1352
1528
  lastCreatedAt: span.last,
1353
1529
  relevanceThreshold: threshold,
@@ -1419,7 +1595,7 @@ export async function privateList({ exec, libraryId, preferIndex = false }) {
1419
1595
  // mood lens and the attachment list all change how an entry is scored, aggregated or
1420
1596
  // rendered, so a wrong value in any of them was a permanent mis-filing that could
1421
1597
  // 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 }) {
1598
+ 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
1599
  // `source` is editable because it is a claim about provenance, and claims get
1424
1600
  // corrected: a memory the model first filed as `inferred` may turn out to be
1425
1601
  // something the user actually said. Enum-checked like the rest — a typo here
@@ -1441,6 +1617,8 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1441
1617
  if (confidence !== undefined && !Number.isFinite(Number(confidence))) throw new Error('confidence must be a number between 0 and 1')
1442
1618
  if (moodValence !== undefined && !Number.isFinite(Number(moodValence))) throw new Error('moodValence must be a number between -1 and 1')
1443
1619
  if (arousal !== undefined && !Number.isFinite(Number(arousal))) throw new Error('arousal must be a number between 0 and 1')
1620
+ if (emotion !== undefined && emotion !== null) normalizeEmotion(emotion)
1621
+ if (dominance !== undefined && dominance !== null && !Number.isFinite(Number(dominance))) throw new Error('dominance must be a number between -1 and 1')
1444
1622
  // Throws for anything the library never received: an attachment list you cannot
1445
1623
  // correct would leave a mis-filed image attached forever.
1446
1624
  const nextAttachments = attachments === undefined ? undefined : attachmentRefs(scope.libraryId, attachments)
@@ -1455,6 +1633,7 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1455
1633
  }
1456
1634
  const route = engineRoute()
1457
1635
  const managed = route === 'managed-local'
1636
+ let committedDb = null
1458
1637
  if ((nextContent !== undefined || managed) && entry.status !== 'pending_review' && status !== 'pending_review') {
1459
1638
  const payload = {
1460
1639
  recall_files: [{
@@ -1467,10 +1646,14 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1467
1646
  user: {},
1468
1647
  }
1469
1648
  if (route !== 'lexical') {
1470
- await commit({ payload, route, db: memoryDbPath(scope.libraryId) })
1649
+ const db = memoryDbPath(scope.libraryId)
1650
+ await commit({ payload, route, db })
1651
+ committedDb = db
1471
1652
  }
1472
1653
  }
1473
- const updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
1654
+ let updated
1655
+ try {
1656
+ updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
1474
1657
  const target = fresh.entries[id_]
1475
1658
  if (!target) throw new Error(`unknown memory: ${id_}`)
1476
1659
  if (JSON.stringify(target) !== JSON.stringify(entry)) throw new Error('memory changed concurrently; reload and retry')
@@ -1513,6 +1696,21 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1513
1696
  if (peak !== undefined) target.peak = peak === true
1514
1697
  if (moodValence !== undefined) target.moodValence = Math.max(-1, Math.min(1, Number(moodValence)))
1515
1698
  if (arousal !== undefined) target.arousal = Math.max(0, Math.min(1, Number(arousal)))
1699
+ // Three states, not two: omitted keeps the stored composition, an explicit null
1700
+ // clears it (and the source becomes "we no longer know"), an object replaces it.
1701
+ // Collapsing null into "keep" would make it impossible to repair a wrong reading.
1702
+ if (emotion !== undefined) {
1703
+ if (emotion === null) {
1704
+ target.emotion = null
1705
+ target.emotionSource = 'unknown'
1706
+ target.moodValence = Number(moodValence) || 0
1707
+ } else {
1708
+ target.emotion = normalizeEmotion(emotion)
1709
+ target.emotionSource = 'model'
1710
+ target.moodValence = derivedValence(target.emotion)
1711
+ }
1712
+ }
1713
+ if (dominance !== undefined) target.dominance = dominance === null ? 0 : Math.max(-1, Math.min(1, Number(dominance)))
1516
1714
  if (nextAttachments !== undefined) target.attachments = nextAttachments
1517
1715
  // Vividness and accuracy are derived, not typed in: correcting the peak flag or
1518
1716
  // the arousal reading has to move `accuracy` too, otherwise the repair would only
@@ -1552,6 +1750,10 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
1552
1750
  }
1553
1751
  target.updatedAt = stamp()
1554
1752
  }))
1753
+ } catch (error) {
1754
+ if (committedDb) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id: id_, route, db: committedDb, expectedEntry: entry })
1755
+ throw error
1756
+ }
1555
1757
  if (status === 'archived' && !wasArchived) bumpStats(scope.libraryId, 'archived')
1556
1758
  return { id: id_, entry: updated.entries[id_] }
1557
1759
  }
@@ -1761,7 +1963,7 @@ export function privateUnpin({ exec, id, reason }) {
1761
1963
 
1762
1964
  export async function privateRemember(args) {
1763
1965
  const libraryId = resolveLibrary(args).libraryId
1764
- const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl(args))
1966
+ const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl({ ...args, libraryId }))
1765
1967
  record({
1766
1968
  kind: 'write',
1767
1969
  scope: 'private',
@@ -1776,7 +1978,7 @@ export async function privateRemember(args) {
1776
1978
 
1777
1979
  export async function privateUpdate(args) {
1778
1980
  const libraryId = resolveLibrary(args).libraryId
1779
- const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl(args))
1981
+ const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl({ ...args, libraryId }))
1780
1982
  record({
1781
1983
  kind: 'write',
1782
1984
  scope: 'private',
@@ -1791,7 +1993,7 @@ export async function privateUpdate(args) {
1791
1993
 
1792
1994
  export async function privateDelete(args) {
1793
1995
  const libraryId = resolveLibrary(args).libraryId
1794
- const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl(args))
1996
+ const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl({ ...args, libraryId }))
1795
1997
  record({
1796
1998
  kind: 'write',
1797
1999
  scope: 'private',