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/mood.js CHANGED
@@ -1,66 +1,277 @@
1
- // Cheap, local, deterministic mood inference. No model call: the plugin needs a
2
- // mood signal every turn for mood-congruent retrieval, and a round trip per turn
1
+ // Cheap, local, deterministic affect inference. No model call: the plugin needs an
2
+ // affect signal every turn for affect-congruent retrieval, and a round trip per turn
3
3
  // would make injection slower than the memory it fetches.
4
4
  //
5
- // Scores are deliberately coarse (valence -1..1, arousal 0..1). A wrong read only
6
- // nudges recall ordering; it is never stored as a fact about the user.
7
-
8
- const VALENCE_WORDS = [
9
- [/开心|高兴|太好了|棒|喜欢|爱|满意|惊喜|顺利|爽|感谢|谢谢|amazing|great|happy|love|nice|awesome|thanks/i, 0.8],
10
- [/不错|还行|可以|有趣|好奇|舒服|ok|fine|good|interesting|curious/i, 0.4],
11
- [/有点|稍微|somewhat|kind of|a bit/i, 0.1],
12
- [/难受|难过|失望|烦|讨厌|累|困|郁闷|崩溃|糟糕|气|火|sad|upset|tired|annoying|awful|hate|frustrated|angry/i, -0.7],
13
- [/疼|痛|病|哭|害怕|慌|焦虑|压力|失眠|hurt|sick|cry|scared|anxious|stress|insomnia/i, -0.9],
14
- ]
15
-
16
- const AROUSAL_WORDS = [
17
- [/(!{2,}|[!?]{2,}|太|超|巨|特别|疯狂|爆炸|never|always|everything|nothing)/i, 0.8],
18
- [/必须|一定|马上|立刻|不能|别|停止|紧急|must|need|stop|urgent|now/i, 0.7],
19
- [/可能|也许|大概|想想|不确定|maybe|perhaps|might|wonder/i, 0.25],
20
- ]
5
+ // Model: four shares on the simplex (joy / anger / sorrow / fear) + two auxiliary
6
+ // axes — arousal (how loud) and dominance (who is in control). Shares answer "which
7
+ // feeling, in what proportion"; arousal answers "how loud"; dominance answers "who is
8
+ // steering". Normalising the shares throws intensity away, which is exactly why
9
+ // arousal has to stay a separate field rather than being folded into the four.
10
+ //
11
+ // Scores are deliberately coarse. A wrong read only nudges recall ordering; it is
12
+ // never stored as a fact about the user.
13
+
14
+ export const EMOTION_AXES = Object.freeze(['joy', 'anger', 'sorrow', 'fear'])
21
15
 
22
16
  // Mood fades back to neutral on its own, so an old read does not tint the whole
23
17
  // day's retrieval.
24
- const MOOD_HALF_LIFE_MINUTES = 90
18
+ export const MOOD_HALF_LIFE_MINUTES = 90
25
19
  // A resting level, not zero: a calm stretch reads as calm-and-alert, which is what
26
20
  // keeps the arousal term meaningful when nothing is happening.
27
21
  export const BASE_AROUSAL = 0.35
22
+ // Below this share no single axis is dominant enough to call the reading a mood.
23
+ export const EMOTION_MIN_SHARE = 0.35
24
+ // The composition stops being worth anything once the whole reading has faded this
25
+ // far; arousal and dominance keep decaying and are still reported.
26
+ export const EMOTION_FADE_FLOOR = 0.35
27
+ // How much anger-vs-fear moves dominance on its own. Anger carries agency, fear does
28
+ // not, and neither phrase usually contains a control word.
29
+ export const DOMINANCE_PRIOR_WEIGHT = 0.4
30
+ // Every axis projects onto the legacy valence with the same magnitude; only joy is
31
+ // positive. Anger is *not* a mild positive — it pulls the projection down as hard as
32
+ // sorrow does.
33
+ export const VALENCE_SCALE = 0.8
34
+
35
+ const NEGATION_AS_SAD = /不开心|不高兴|不喜欢|不满意|不顺利|not\s+happy|not\s+good|don't\s+like/gi
36
+ const NEGATION_TO_NEUTRAL = /不难过|不害怕|不焦虑|不生气|not\s+sad|not\s+afraid|not\s+anxious/gi
37
+
38
+ const AXIS_PATTERNS = Object.freeze({
39
+ joy: [
40
+ [/开心|高兴|太好了|喜欢|爱你|满意|惊喜|谢谢|感谢|期待|幸福|欣慰|兴奋|激动|雀跃/, 0.9],
41
+ [/不错|还行|顺利|爽|安心|踏实|棒|有趣|舒服/, 0.6],
42
+ ],
43
+ anger: [
44
+ [/气死|烦死|受不了|离谱|凭什么|恨|火大|欺人太甚/, 0.9],
45
+ [/生气|气|烦|讨厌|恶心|无语|恼火|不满|angry|annoying|hate|furious/, 0.6],
46
+ ],
47
+ sorrow: [
48
+ [/崩溃|难受|心疼|失望透顶|想哭|哭|心碎/, 0.9],
49
+ // `sad` is not decoration: the negation rules below rewrite 不开心/不高兴 into the
50
+ // word `sad`, so dropping English here silently turns a negated positive into a
51
+ // neutral reading instead of a negative one.
52
+ [/难过|失望|委屈|郁闷|累|困|没意思|算了|沮丧|孤独|sad|unhappy|upset|tired|miserable/, 0.6],
53
+ ],
54
+ fear: [
55
+ [/吓死|害怕|恐慌|焦虑|绝望|惊恐|发抖/, 0.9],
56
+ [/担心|紧张|慌|不敢|万一|怕|不安|忐忑|afraid|scared|anxious|worried|nervous/, 0.6],
57
+ ],
58
+ })
59
+
60
+ // Compound expressions are mixtures, not new axes. Each entry is a named point on the
61
+ // simplex plus the dominance that usually comes with it.
62
+ export const COMPOUND_PRESETS = Object.freeze({
63
+ 委屈: Object.freeze({ emotion: Object.freeze({ joy: 0, anger: 0.3, sorrow: 0.7, fear: 0 }), dominance: -0.4 }),
64
+ 尴尬: Object.freeze({ emotion: Object.freeze({ joy: 0, anger: 0, sorrow: 0.5, fear: 0.5 }), dominance: -0.5 }),
65
+ 嫉妒: Object.freeze({ emotion: Object.freeze({ joy: 0, anger: 0.5, sorrow: 0.3, fear: 0.2 }), dominance: -0.1 }),
66
+ 心疼: Object.freeze({ emotion: Object.freeze({ joy: 0.2, anger: 0, sorrow: 0.8, fear: 0 }), dominance: -0.2 }),
67
+ 不甘: Object.freeze({ emotion: Object.freeze({ joy: 0, anger: 0.6, sorrow: 0.4, fear: 0 }), dominance: 0.4 }),
68
+ })
69
+
70
+ const COMPOUND_PATTERN = new RegExp(Object.keys(COMPOUND_PRESETS).join('|'))
71
+ const HEDGE_PATTERN = /有点|稍微|一点点|a bit|somewhat/i
72
+ // "一点都不好" is an intensifier, not a hedge: it must never damp what follows.
73
+ const HEDGE_SKEW_GUARD = /一点都|一点也不|一点也没|一点没有/
74
+ const INTENSITY_PATTERN = /死|疯了|极了|受不了|爆|炸|太|超|巨|特别|疯狂/
75
+ const REPEATED_PUNCT = /[!!??]{2,}/
76
+
77
+ const HIGH_DOMINANCE = /凭什么|我决定|不许|听我的|我不在乎|随便你|必须|轮不到|说了算|懒得|我才不/g
78
+ const LOW_DOMINANCE = /不敢|没办法|只能|求|拜托|对不起|是我错|控制不住|万一|怎么办|逃|来不及|都怪我/g
79
+
80
+ const CLAUSE_SPLIT = /([,。!?;,.!?;]+)/
81
+
82
+ function clamp(value, min, max) {
83
+ return Math.max(min, Math.min(max, value))
84
+ }
85
+
86
+ /**
87
+ * Normalise a four-axis composition.
88
+ *
89
+ * Returns null for "no mood". An explicit all-zero object normalises to null too: it
90
+ * is the tool-friendly way of saying neutral, not a reading where all four are faintly
91
+ * present. Anything malformed is rejected instead of being repaired — silently
92
+ * clamping a negative share into a legal one would hide a caller bug forever.
93
+ */
94
+ export function normalizeEmotion(raw) {
95
+ if (raw === null || raw === undefined) return null
96
+ if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('emotion must be an object with joy, anger, sorrow, fear')
97
+ const values = {}
98
+ for (const axis of EMOTION_AXES) {
99
+ if (!Object.prototype.hasOwnProperty.call(raw, axis)) throw new Error('emotion is missing the ' + axis + ' share')
100
+ const value = raw[axis]
101
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('emotion.' + axis + ' must be a finite number')
102
+ if (value < 0) throw new Error('emotion.' + axis + ' must not be negative')
103
+ values[axis] = value
104
+ }
105
+ const sum = EMOTION_AXES.reduce((total, axis) => total + values[axis], 0)
106
+ if (!(sum > 0)) return null
107
+ const normalized = {}
108
+ for (const axis of EMOTION_AXES) normalized[axis] = values[axis] / sum
109
+ return normalized
110
+ }
111
+
112
+ /** The legacy single-number projection. Joy is the only axis that points up. */
113
+ export function derivedValence(emotion) {
114
+ if (!emotion) return 0
115
+ const raw = VALENCE_SCALE * (emotion.joy - emotion.anger - emotion.sorrow - emotion.fear)
116
+ return clamp(Number.isFinite(raw) ? raw : 0, -1, 1)
117
+ }
118
+
119
+ /** The largest single share; 0 for a neutral reading. */
120
+ export function maxShare(emotion) {
121
+ if (!emotion) return 0
122
+ return EMOTION_AXES.reduce((best, axis) => Math.max(best, Number(emotion[axis]) || 0), 0)
123
+ }
124
+
125
+ /** How much of the present composition a memory still shares. 0..1, symmetric. */
126
+ export function overlap(left, right) {
127
+ if (!left || !right) return 0
128
+ let total = 0
129
+ for (const axis of EMOTION_AXES) {
130
+ const a = Number(left[axis]) || 0
131
+ const b = Number(right[axis]) || 0
132
+ if (a > 0 && b > 0) total += Math.min(a, b)
133
+ }
134
+ return clamp(total, 0, 1)
135
+ }
136
+
137
+ /**
138
+ * Dominance similarity, mapped to a bounded multiplier. A missing or illegal value on
139
+ * either side is "no signal", never NaN: callers multiply by this result directly.
140
+ */
141
+ export function dominanceBias(entryDominance, contextDominance) {
142
+ // Number(null) is 0, so a missing value must be rejected before coercion — otherwise
143
+ // "we do not know" quietly becomes "slightly in control" and still moves the ranking.
144
+ if (entryDominance === null || entryDominance === undefined) return 1
145
+ if (contextDominance === null || contextDominance === undefined) return 1
146
+ const a = Number(entryDominance)
147
+ const b = Number(contextDominance)
148
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return 1
149
+ return 1 + 0.2 * (0.5 - Math.abs(clamp(a, -1, 1) - clamp(b, -1, 1)) / 2)
150
+ }
151
+
152
+ /**
153
+ * Whether the affect terms are worth applying at all. Three independent legs: a clear
154
+ * composition, an arousal away from rest, or a clear sense of control. A null
155
+ * composition only closes the first leg.
156
+ */
157
+ export function emotionActive({ emotion = null, arousal = BASE_AROUSAL, dominance = 0 } = {}) {
158
+ if (emotion !== null && emotion !== undefined && maxShare(emotion) >= EMOTION_MIN_SHARE) return true
159
+ const arousalValue = Number(arousal)
160
+ if (Number.isFinite(arousalValue) && Math.abs(arousalValue - BASE_AROUSAL) >= 0.1) return true
161
+ const dominanceValue = Number(dominance)
162
+ if (Number.isFinite(dominanceValue) && Math.abs(dominanceValue) >= 0.25) return true
163
+ return false
164
+ }
28
165
 
166
+ function clausesOf(source) {
167
+ return String(source || '')
168
+ .split(CLAUSE_SPLIT)
169
+ .map((part) => part.trim())
170
+ .filter((part) => part && !/^[,。!?;,.!?;]+$/.test(part))
171
+ }
172
+
173
+ function compoundIn(clause) {
174
+ const hit = COMPOUND_PATTERN.exec(clause)
175
+ return hit ? COMPOUND_PRESETS[hit[0]] : null
176
+ }
177
+
178
+ function axisHits(clause) {
179
+ const hits = {}
180
+ for (const axis of EMOTION_AXES) {
181
+ let best = 0
182
+ for (const [pattern, weight] of AXIS_PATTERNS[axis]) {
183
+ if (pattern.test(clause)) best = Math.max(best, weight)
184
+ }
185
+ if (best > 0) hits[axis] = best
186
+ }
187
+ return hits
188
+ }
189
+
190
+ /**
191
+ * Read the present affect state out of one user turn.
192
+ *
193
+ * Kept text-only and deterministic. `.valence` stays on the return value because the
194
+ * legacy retrieval path and its tests read it directly; `emotion`/`dominance` are
195
+ * additive, so a caller that only knows about valence keeps working.
196
+ */
29
197
  export function readMood(text) {
30
198
  const source = String(text || '')
31
199
  .replace(/["“「][^"”」]*["”」]/g, '')
32
- .replace(/不开心|不高兴|不喜欢|不满意|not\s+happy|not\s+good|don't\s+like/gi, ' sad ')
33
- .replace(/不难过|不害怕|不焦虑|not\s+sad|not\s+afraid/gi, '')
34
- let valence = 0
35
- for (const [pattern, weight] of VALENCE_WORDS) {
36
- if (pattern.test(source)) valence = valence === 0 ? weight : (valence + weight) / 2
37
- }
38
- let arousal = 0.35
39
- for (const [pattern, weight] of AROUSAL_WORDS) {
40
- if (pattern.test(source)) arousal = Math.max(arousal, weight)
200
+ .replace(NEGATION_AS_SAD, ' sad ')
201
+ .replace(NEGATION_TO_NEUTRAL, '')
202
+ const clauses = clausesOf(source)
203
+
204
+ const raw = { joy: 0, anger: 0, sorrow: 0, fear: 0 }
205
+ let compoundDominance = null
206
+ let peakIntensity = 0
207
+ let hedged = false
208
+
209
+ for (const clause of clauses) {
210
+ const hedge = HEDGE_PATTERN.test(clause) && !HEDGE_SKEW_GUARD.test(clause)
211
+ if (hedge) hedged = true
212
+ const scale = hedge ? 0.6 : 1
213
+ const compound = compoundIn(clause)
214
+ if (compound) {
215
+ for (const axis of EMOTION_AXES) raw[axis] += compound.emotion[axis] * scale
216
+ if (compoundDominance === null) compoundDominance = compound.dominance
217
+ } else {
218
+ const hits = axisHits(clause)
219
+ for (const axis of EMOTION_AXES) if (hits[axis]) raw[axis] += hits[axis] * scale
220
+ }
221
+ if (INTENSITY_PATTERN.test(clause) || REPEATED_PUNCT.test(clause)) peakIntensity = Math.max(peakIntensity, 0.75)
222
+ if (REPEATED_PUNCT.test(clause)) peakIntensity = Math.max(peakIntensity, 0.8)
41
223
  }
224
+
225
+ for (const axis of EMOTION_AXES) raw[axis] = Math.min(1, raw[axis])
226
+ const emotion = normalizeEmotion(raw)
227
+
228
+ let arousal = BASE_AROUSAL
229
+ if (emotion !== null) arousal = Math.max(arousal, peakIntensity)
42
230
  if (source.length > 600) arousal = Math.max(0.2, arousal - 0.15)
231
+
232
+ const high = (source.match(HIGH_DOMINANCE) || []).length
233
+ const low = (source.match(LOW_DOMINANCE) || []).length
234
+ const lexical = clamp(0.5 * (high - low), -1, 1)
235
+ const prior = emotion === null ? 0 : DOMINANCE_PRIOR_WEIGHT * (emotion.anger - emotion.fear)
236
+ const dominance = clamp(lexical + prior + (compoundDominance === null ? 0 : compoundDominance * 0.5), -1, 1)
237
+
43
238
  return {
44
- valence: Math.max(-1, Math.min(1, valence)),
45
- arousal: Math.max(0, Math.min(1, arousal)),
239
+ valence: derivedValence(emotion),
240
+ emotion,
241
+ arousal: clamp(arousal, 0, 1),
242
+ dominance: hedged ? clamp(dominance * 0.8, -1, 1) : dominance,
46
243
  }
47
244
  }
48
245
 
246
+ /**
247
+ * Fade a reading toward neutral.
248
+ *
249
+ * The composition does not decay proportionally — proportions sum to 1, so scaling
250
+ * them changes nothing. What fades is how much of it is left: arousal returns to rest,
251
+ * dominance returns to zero, and once the whole reading has faded past the floor the
252
+ * composition is dropped. Arousal and dominance keep decaying past that point.
253
+ */
49
254
  export function moodDecay(mood, now = Date.now()) {
50
- if (!mood || typeof mood.at !== 'number') return { valence: 0, arousal: BASE_AROUSAL }
51
- const minutes = Math.max(0, (now - mood.at) / 60_000)
52
- const fade = Math.pow(0.5, minutes / MOOD_HALF_LIFE_MINUTES)
53
- const arousal = Number(mood.arousal)
255
+ const hasStamp = Boolean(mood) && typeof mood.at === 'number'
256
+ const minutes = hasStamp ? Math.max(0, (now - mood.at) / 60_000) : 0
257
+ const fade = hasStamp ? Math.pow(0.5, minutes / MOOD_HALF_LIFE_MINUTES) : 0
258
+
259
+ const emotion = hasStamp && mood.emotion ? mood.emotion : null
260
+ const keptEmotion = fade < EMOTION_FADE_FLOOR ? null : emotion
261
+ const arousalValue = Number(mood && mood.arousal)
262
+ const dominanceValue = Number(mood && mood.dominance)
263
+ const legacyValence = Number(mood && mood.valence)
264
+
54
265
  return {
55
- valence: Number(mood.valence || 0) * fade,
56
- // Arousal relaxes back to the baseline instead of collapsing to zero, so a
57
- // calm stretch still reads as calm-but-alert rather than flat.
58
- arousal: BASE_AROUSAL + ((Number.isFinite(arousal) ? arousal : BASE_AROUSAL) - BASE_AROUSAL) * fade,
266
+ valence: (keptEmotion ? derivedValence(keptEmotion) : (Number.isFinite(legacyValence) ? legacyValence : 0)) * (hasStamp ? fade : 0),
267
+ emotion: keptEmotion,
268
+ arousal: BASE_AROUSAL + ((Number.isFinite(arousalValue) ? arousalValue : BASE_AROUSAL) - BASE_AROUSAL) * fade,
269
+ dominance: (Number.isFinite(dominanceValue) ? dominanceValue : 0) * fade,
59
270
  }
60
271
  }
61
272
 
62
273
  // Congruence is |a·b| over signed valence: sad recalls sad, happy recalls happy,
63
- // and a neutral present state recalls nothing extra.
274
+ // and a neutral present state recalls nothing extra. Kept for the legacy path.
64
275
  export function moodCongruence(entryValence, currentValence) {
65
276
  const a = Number(entryValence || 0)
66
277
  const b = Number(currentValence || 0)
@@ -69,3 +280,21 @@ export function moodCongruence(entryValence, currentValence) {
69
280
  const agreement = Math.min(Math.abs(a), Math.abs(b))
70
281
  return same ? agreement : -agreement * 0.5
71
282
  }
283
+
284
+ export default {
285
+ EMOTION_AXES,
286
+ MOOD_HALF_LIFE_MINUTES,
287
+ BASE_AROUSAL,
288
+ EMOTION_MIN_SHARE,
289
+ EMOTION_FADE_FLOOR,
290
+ COMPOUND_PRESETS,
291
+ normalizeEmotion,
292
+ derivedValence,
293
+ maxShare,
294
+ overlap,
295
+ dominanceBias,
296
+ emotionActive,
297
+ readMood,
298
+ moodDecay,
299
+ moodCongruence,
300
+ }
@@ -76,7 +76,7 @@ export function attachPresetAgents(ctx, { log = () => {}, attached = attachments
76
76
  seen.add(actx)
77
77
  if (!attached.has(actx) && !sameScopeTree(actx, ctx)) {
78
78
  try {
79
- const dispose = registerInjection(actx, { guide: false, guideMessage: true })
79
+ const dispose = registerInjection(actx)
80
80
  attached.set(actx, dispose)
81
81
  added += 1
82
82
  log(`attached memory injection to preset conversation ${sessionId}`)