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
@@ -1,6 +1,8 @@
1
1
  import { getSummaryConfig } from './memu-config.js'
2
2
  import { loadPromptForDomain, CONSOLIDATION_CONTRACTS } from './prompts.js'
3
3
  import { getSettings } from './state.js'
4
+ import { normalizeEmotion } from './mood.js'
5
+ import { record } from './activity-log.js'
4
6
 
5
7
  function parseJsonObject(text) {
6
8
  const raw = String(text || '').trim()
@@ -12,31 +14,42 @@ function parseJsonObject(text) {
12
14
  try { return JSON.parse(raw.slice(start, end + 1)) } catch { return null }
13
15
  }
14
16
 
15
- function cleanMemories(value) {
17
+ // 逐条清洗。非法字段不修,只把这一条连同原因丢进 problems:一条坏数据不该让
18
+ // 整批整理白跑,也不该被悄悄改成"看起来合法"的情绪留在库里。
19
+ function cleanMemories(value, problems = []) {
16
20
  const list = Array.isArray(value) ? value : []
17
- return list
18
- .map((m) => ({
19
- title: String(m?.title || '').trim().slice(0, 120),
20
- content: String(m?.content || '').trim(),
21
- tags: Array.isArray(m?.tags) ? m.tags.slice(0, 20).map((t) => String(t)) : [],
22
- openLoop: m?.open_loop === true || m?.openLoop === true,
23
- confidence: Number.isFinite(Number(m?.confidence)) ? Number(m.confidence) : 0.8,
24
- layer: ['long_term', 'short_term'].includes(m?.layer) ? m.layer : 'long_term',
25
- disclosure: ['can_tell', 'only_if_asked', 'never_say'].includes(m?.disclosure) ? m.disclosure : 'can_tell',
26
- // A consolidation pass that forgets the field must not invent a scope: the default
27
- // here has to match the tool path (`privateRemember`, which defaults to 'shared').
28
- // 'session' used to be the fallback, which silently turned every sloppy summary into
29
- // a memory that died with its conversation (and the level itself is gone now).
30
- visibility: ['shared', 'secret'].includes(m?.visibility) ? m.visibility : 'shared',
31
- owner: ['me', 'us', 'about_user', 'about_project'].includes(m?.owner) ? m.owner : 'me',
32
- source: ['user_said', 'inferred', 'imagined', 'told'].includes(m?.source) ? m.source : 'inferred',
33
- moodValence: Number.isFinite(m?.mood_valence) ? Math.max(-1, Math.min(1, m.mood_valence)) : null,
34
- arousal: Number.isFinite(m?.arousal) ? Math.max(0, Math.min(1, m.arousal)) : null,
35
- peak: m?.peak === true,
36
- salientDate: typeof m?.salient_date === 'string' ? m.salient_date : null,
37
- }))
38
- .filter((m) => m.content)
39
- .slice(0, 20)
21
+ const out = []
22
+ list.forEach((m, index) => {
23
+ try {
24
+ out.push({
25
+ title: String(m?.title || '').trim().slice(0, 120),
26
+ content: String(m?.content || '').trim(),
27
+ tags: Array.isArray(m?.tags) ? m.tags.slice(0, 20).map((t) => String(t)) : [],
28
+ openLoop: m?.open_loop === true || m?.openLoop === true,
29
+ confidence: Number.isFinite(Number(m?.confidence)) ? Number(m.confidence) : 0.8,
30
+ layer: ['long_term', 'short_term'].includes(m?.layer) ? m.layer : 'long_term',
31
+ disclosure: ['can_tell', 'only_if_asked', 'never_say'].includes(m?.disclosure) ? m.disclosure : 'can_tell',
32
+ // A consolidation pass that forgets the field must not invent a scope: the default
33
+ // here has to match the tool path (`privateRemember`, which defaults to 'shared').
34
+ // 'session' used to be the fallback, which silently turned every sloppy summary into
35
+ // a memory that died with its conversation (and the level itself is gone now).
36
+ visibility: ['shared', 'secret'].includes(m?.visibility) ? m.visibility : 'shared',
37
+ owner: ['me', 'us', 'about_user', 'about_project'].includes(m?.owner) ? m.owner : 'me',
38
+ source: ['user_said', 'inferred', 'imagined', 'told'].includes(m?.source) ? m.source : 'inferred',
39
+ // 缺字段是"没读到情绪",null 才是;这里不能拿 valence 反推四轴。
40
+ emotion: normalizeEmotion(m?.emotion ?? null),
41
+ // 缺失或非法给 null 而不是 0:0 表示"明确中性",不是"不知道"。
42
+ dominance: Number.isFinite(m?.dominance) ? Math.max(-1, Math.min(1, m.dominance)) : null,
43
+ moodValence: Number.isFinite(m?.mood_valence) ? Math.max(-1, Math.min(1, m.mood_valence)) : null,
44
+ arousal: Number.isFinite(m?.arousal) ? Math.max(0, Math.min(1, m.arousal)) : null,
45
+ peak: m?.peak === true,
46
+ salientDate: typeof m?.salient_date === 'string' ? m.salient_date : null,
47
+ })
48
+ } catch (error) {
49
+ problems.push({ index, title: String(m?.title || '').slice(0, 120), error: String(error?.message || error) })
50
+ }
51
+ })
52
+ return out.filter((m) => m.content).slice(0, 20)
40
53
  }
41
54
 
42
55
  // Remote mode: the configured chat endpoint does the summarizing. The session
@@ -87,7 +100,20 @@ export async function summarizeRemote({ transcript, libraryName, domain, signal,
87
100
  const text = data?.choices?.[0]?.message?.content
88
101
  const parsed = parseJsonObject(text)
89
102
  if (!parsed) throw new Error(`remote summary returned non-JSON: ${String(text || body).slice(0, 600)}`)
90
- return cleanMemories(parsed.memories)
103
+ const problems = []
104
+ const memories = cleanMemories(parsed.memories, problems)
105
+ if (problems.length) {
106
+ // 远程模型的错误只能靠日志查,静默丢弃等于把问题留给下一个人复现。
107
+ record({
108
+ kind: 'consolidate',
109
+ scope: 'private',
110
+ ok: false,
111
+ level: 'warn',
112
+ message: `远程整理有 ${problems.length} 条被拒绝:${problems[0].error}`,
113
+ detail: { rejected: problems },
114
+ })
115
+ }
116
+ return memories
91
117
  } finally {
92
118
  clearTimeout(timer)
93
119
  signal?.removeEventListener?.('abort', onAbort)
package/lib/rpc.js CHANGED
@@ -86,6 +86,11 @@ async function privateListView(scope) {
86
86
  decay: Math.min(1, Math.max(0, decayStrength(e) / 1.5)),
87
87
  moodValence: e?.moodValence ?? null,
88
88
  arousal: e?.arousal ?? null,
89
+ // 构成与掌控感分开投影:同一个 valence 说得出"多难过",说不出"是怒还是怕"。
90
+ emotion: e?.emotion ?? null,
91
+ dominance: e?.dominance ?? null,
92
+ // 来源是审计字段,只读:面板显示"这个情绪是谁给的",不给改。
93
+ emotionSource: e?.emotionSource ?? null,
89
94
  peak: e?.peak === true,
90
95
  salientDate: e?.salientDate || null,
91
96
  attachments: attachmentPreview(libraryId, e || {}),
@@ -453,6 +458,7 @@ async function dispatch(ctx, endpoint, payload = {}) {
453
458
  } catch {}
454
459
  const rows = Object.entries(state.sessions || {})
455
460
  .map(([sessionId, binding]) => {
461
+ if (binding.consolidationFork) return null
456
462
  const inFolder = binding.scopeKey === scopeKey
457
463
  || (binding.libraryId && state.libraries?.[binding.libraryId]?.scopeKey === scopeKey)
458
464
  if (!inFolder) return null
@@ -771,7 +777,9 @@ async function dispatch(ctx, endpoint, payload = {}) {
771
777
  // `source` is writable: correcting provenance is the point of showing it.
772
778
  // `salientDate` is writable for the same reason: an entry whose year was
773
779
  // dropped has to be repairable from the browser, not only from JSON.
774
- const allowed = ['content', 'title', 'tags', 'status', 'openLoop', 'disclosure', 'confidence', 'owner', 'visibility', 'layer', 'source', 'salientDate', 'track', 'peak', 'moodValence', 'arousal', 'attachments', 'context']
780
+ // `emotion`/`dominance` 可写:面板修的是那条记忆所述事件的感受。
781
+ // `emotionSource` 故意不在名单里:来源是审计结论,能改就等于能伪造。
782
+ const allowed = ['content', 'title', 'tags', 'status', 'openLoop', 'disclosure', 'confidence', 'owner', 'visibility', 'layer', 'source', 'salientDate', 'track', 'peak', 'moodValence', 'arousal', 'emotion', 'dominance', 'attachments', 'context']
775
783
  // An unknown key used to be dropped in silence, so a typo here or in a script
776
784
  // came back as a successful save that changed nothing. Refuse instead: the
777
785
  // caller can only fix a field name it is told is wrong.
package/lib/state.js CHANGED
@@ -10,7 +10,8 @@ import {
10
10
  pruneBackups, restoreFromBackup, withBackupLock,
11
11
  } from './backup.js'
12
12
  import { migrateIndexVisibility } from './visibility.js'
13
- import { memuDbFor, memuGlobalDbFor } from './vector-profile.js'
13
+ import { memuDbFor, memuGlobalDbFor, removeActiveLibrary } from './vector-profile.js'
14
+ import { normalizeEmotion, derivedValence } from './mood.js'
14
15
 
15
16
  const STATE_FILE = () => path.join(dataDir(), 'state.json')
16
17
  const GLOBAL_DB = () => path.join(dataDir(), 'global.sqlite3')
@@ -94,6 +95,12 @@ export const DEFAULT_SETTINGS = {
94
95
  // "gone", and short enough that a conversation which ended gets tidied in the
95
96
  // same sitting instead of waiting for a quiet hour that may never arrive.
96
97
  sleepAfterIdleMinutes: 3,
98
+ // 主动记忆:分数门槛、饥饿阈值(空闲超过它无条件检查)、两种抑制冷却(分钟;整理冷却默认 0,
99
+ // 薄证据的重复 fork 由评分拦截,只有用户显式配置才启用硬冷却)。
100
+ activeMemoryMinScore: 0.8,
101
+ activeMemoryStarvationMinutes: 15,
102
+ activeMemoryCooldownMinutes: 0,
103
+ activeMemoryRecallCooldownMinutes: 15,
97
104
  autoCurateEveryHours: 0,
98
105
  autoCurateSessionLimit: 8,
99
106
  // 'local' keeps every memory on this machine; 'cloud' hands MemU's own backend
@@ -343,6 +350,8 @@ export function updateSettings(patch) {
343
350
  mustRememberCap: [1, 100], randomCueChance: [0, 1], shortTermTtlHours: [0, 8760],
344
351
  unpinBudgetPerSession: [1, 20], unpinCooldownMinutes: [0, 1440], sleepAfterIdleMinutes: [1, 1440],
345
352
  autoCurateEveryHours: [0, 8760], autoCurateSessionLimit: [1, 10], purgeArchivedAfterDays: [0, 36500],
353
+ activeMemoryMinScore: [0, 10], activeMemoryStarvationMinutes: [1, 1440],
354
+ activeMemoryCooldownMinutes: [0, 10080], activeMemoryRecallCooldownMinutes: [0, 10080],
346
355
  }
347
356
  const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], vectorSource: ['local', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'] }
348
357
  for (const [key, value] of Object.entries(patch || {})) {
@@ -541,13 +550,20 @@ export function deleteLibrary(id) {
541
550
  const state = loadState()
542
551
  if (!state.libraries[id]) return { ok: false, error: 'library not found' }
543
552
  if (state.libraries[id].archived !== true) return { ok: false, error: 'archive the library before deleting it' }
553
+ const activeVectorDb = removeActiveLibrary(id)
554
+ const legacyVectorDb = path.join(librariesDir(), id + '.sqlite3')
544
555
  delete state.libraries[id]
545
556
  for (const [sessionId, binding] of Object.entries(state.sessions || {})) {
546
557
  if (binding?.libraryId === id) delete state.sessions[sessionId]
547
558
  }
548
559
  saveState(state)
549
560
  const removed = []
550
- for (const file of [memoryDbPath(id), ...['json', 'entities.json', 'media.json', 'stats.json'].map(suffix => path.join(indexDir(), `${id}.${suffix}`))]) {
561
+ const vectorPrefix = id + '.'
562
+ const vectorGenerationFiles = fs.existsSync(librariesDir())
563
+ ? fs.readdirSync(librariesDir()).filter(name => name.startsWith(vectorPrefix) && name.endsWith('.sqlite3')).map(name => path.join(librariesDir(), name))
564
+ : []
565
+ for (const file of [activeVectorDb, legacyVectorDb, ...vectorGenerationFiles, ...['json', 'entities.json', 'media.json', 'stats.json'].map(suffix => path.join(indexDir(), `${id}.${suffix}`))]) {
566
+ if (!file) continue
551
567
  try {
552
568
  if (fs.existsSync(file)) { fs.rmSync(file, { force: true }); removed.push(file) }
553
569
  } catch {}
@@ -791,6 +807,8 @@ export function markInjected(sessionId, ids, turn) {
791
807
  for (const id of ids || []) at[id] = turn ?? Date.now()
792
808
  binding.injectedAt = at
793
809
  binding.lastInjectedTurn = turn ?? binding.lastInjectedTurn ?? 0
810
+ // 主动记忆抑制要一个"最近一次真正注入了记忆"的墙钟时间;turn 号不能跨会话比较。
811
+ binding.lastInjectedAt = stamp()
794
812
  return state
795
813
  })
796
814
  }
@@ -870,17 +888,16 @@ export function consumeCue(sessionId, cueId) {
870
888
  return true
871
889
  }
872
890
 
873
- // The session's running mood, kept on the binding rather than in a memory entry:
874
- // it is a retrieval lens, not a fact about the user, and must never be recalled
875
- // as a stored memory.
891
+ // The session's running mood is a retrieval lens on the binding, not a fact about
892
+ // the user: it must never be recalled as a stored memory.
876
893
  export function setSessionMood(sessionId, mood) {
877
- return setSessionBinding(sessionId, {
878
- mood: {
879
- valence: Math.max(-1, Math.min(1, Number(mood?.valence) || 0)),
880
- arousal: Math.max(0, Math.min(1, Number(mood?.arousal) || 0)),
881
- at: Date.now(),
882
- },
883
- })
894
+ // 给了 emotion 就以四轴为准,valence 只是投影;只有 valence 的旧形状原样存,不反推四轴。
895
+ const emotion = normalizeEmotion(mood?.emotion ?? null)
896
+ const bounded = (raw, min, max) => Math.max(min, Math.min(max, Number.isFinite(raw) ? raw : 0)) // 显式 0 是读数的一部分,不能用 `|| 默认值`
897
+ return setSessionBinding(sessionId, { mood: {
898
+ valence: emotion ? derivedValence(emotion) : bounded(mood?.valence, -1, 1),
899
+ emotion, arousal: bounded(mood?.arousal, 0, 1), dominance: bounded(mood?.dominance, -1, 1), at: Date.now(),
900
+ } })
884
901
  }
885
902
 
886
903
  export function getSessionMood(sessionId) {
package/lib/tools.js CHANGED
@@ -80,6 +80,28 @@ function salientTag(entry) {
80
80
  return isDatedSalientDate(entry.salientDate) ? `date=${entry.salientDate}` : `anniversary=${entry.salientDate}`
81
81
  }
82
82
 
83
+ // 四轴名在展示层就地映射:这是给人读的一行摘要,不是本地化词条。
84
+ const EMOTION_LABELS = { joy: '喜', anger: '怒', sorrow: '哀', fear: '惧' }
85
+ const EMOTION_AXES_IN_ORDER = ['joy', 'anger', 'sorrow', 'fear']
86
+
87
+ // 只留够显眼的份额,最多两项:四项全列会把一行标签读成噪声。
88
+ function emotionBrief(entry) {
89
+ const emotion = entry.emotion
90
+ if (!emotion || typeof emotion !== 'object') return ''
91
+ const shares = EMOTION_AXES_IN_ORDER
92
+ .map((axis) => ({ axis, share: Number(emotion[axis]) || 0 }))
93
+ .filter((item) => item.share >= 0.05)
94
+ .sort((a, b) => b.share - a.share)
95
+ .slice(0, 2)
96
+ if (!shares.length) return ''
97
+ const parts = shares.map((item) => `${EMOTION_LABELS[item.axis]}${item.share.toFixed(2)}`)
98
+ const dominance = Number(entry.dominance)
99
+ if (Number.isFinite(dominance)) parts.push(`dom${dominance >= 0 ? '+' : ''}${dominance.toFixed(2)}`)
100
+ const arousal = Number(entry.arousal)
101
+ if (Number.isFinite(arousal)) parts.push(`a${arousal.toFixed(2)}`)
102
+ return parts.join(' · ')
103
+ }
104
+
83
105
  function formatPrivateItem(item, index, options = {}) {
84
106
  const entry = item.entry || {}
85
107
  const lines = [
@@ -107,7 +129,10 @@ function formatPrivateItem(item, index, options = {}) {
107
129
  // way made "the test is tomorrow" read like a recurring commemorative date.
108
130
  const tag = salientTag(entry)
109
131
  if (tag) lines.push(` ${tag}`)
110
- if (typeof entry.moodValence === 'number' && entry.moodValence !== 0) lines.push(` mood=${entry.moodValence.toFixed(2)}`)
132
+ // 有构成就讲构成:同一个 valence 可能来自怒,也可能来自哀,一个数字分不出来。
133
+ const brief = emotionBrief(entry)
134
+ if (brief) lines.push(` emo=${brief}`)
135
+ else if (typeof entry.moodValence === 'number' && entry.moodValence !== 0) lines.push(` mood=${entry.moodValence.toFixed(2)}`)
111
136
  if (item.content) {
112
137
  const body = options.query
113
138
  ? centeredSnippet(item.content, options.query)
@@ -187,8 +212,16 @@ export function registerTools(ctx) {
187
212
  description: 'Where this came from: inferred (default — you concluded it), user_said (only when the words are actually theirs and quotable), imagined, or told.',
188
213
  },
189
214
  confidence: { type: 'number', description: '0-1 confidence in this memory. Below 0.45 it is injected as a hedge, so use it honestly.' },
190
- mood_valence: { type: 'number', description: 'How this moment felt, -1 (bad) to 1 (good). Omit to inherit the current conversation mood.' },
215
+ mood_valence: { type: 'number', description: '派生值:给了 emotion 时由四项算出,-1(坏)到 1(好)。省略则沿用当前会话读数。' },
191
216
  arousal: { type: 'number', description: 'How strongly it hit, 0-1.' },
217
+ emotion: {
218
+ type: 'object',
219
+ properties: { joy: { type: 'number' }, anger: { type: 'number' }, sorrow: { type: 'number' }, fear: { type: 'number' } },
220
+ // 四个轴是封闭集合:多写的键只会被误当成第五种情绪。
221
+ additionalProperties: false,
222
+ description: '情绪构成:喜悦/愤怒/哀伤/恐惧,四项自动归一化到和为 1。填记忆所述那件事的情绪,不是此刻整理时的心情。省略=继承当前会话读数;显式全零=明确中性;工程事实请用中性,不要因为 bug 难修或结论重要就填负面情绪。',
223
+ },
224
+ dominance: { type: 'number', description: '谁在掌控:-1 完全失控 … +1 完全掌控。省略=继承当前会话。' },
192
225
  peak: { type: 'boolean', description: 'A high point or low point of the arc — peaks and endings stay easier to recall.' },
193
226
  salient_date: { type: 'string', description: 'MM-DD for something that comes back every year (a birthday, an anniversary of an event); it resurfaces on that day each year. YYYY-MM-DD for a one-off dated commitment or appointment: it is raised the day before and on the day itself, and never again after it has passed. Use the annual form only when the day really does recur. A relative day (今天/明天/后天/today/tomorrow) must be resolved to the absolute date it points at: sending a bare MM-DD turns one appointment into a yearly reminder. If the content itself names a date, pass it here too: when this field is empty the plugin only falls back to a single unambiguous YYYY-MM-DD found in the text.' },
194
227
  track: { type: 'string', description: 'memory (default) or habit for an aggregated expression/sticker habit; do not write one habit per occurrence.' },
@@ -219,6 +252,11 @@ export function registerTools(ctx) {
219
252
  confidence: args.confidence,
220
253
  moodValence: args.mood_valence ?? null,
221
254
  arousal: args.arousal ?? null,
255
+ // `undefined` means "the model said nothing" and is allowed to inherit the
256
+ // turn's mood; `null` means "explicitly neutral" and must survive as such. Using
257
+ // `?? null` here would erase that difference and re-inherit on a neutral call.
258
+ emotion: args.emotion,
259
+ dominance: args.dominance ?? null,
222
260
  peak: args.peak === true,
223
261
  salientDate: args.salient_date ?? null,
224
262
  })
@@ -309,6 +347,13 @@ export function registerTools(ctx) {
309
347
  peak: { type: 'boolean', description: 'Flashbulb flag: the moment was charged. Changing it recomputes accuracy.' },
310
348
  mood_valence: { type: 'number', description: 'How the moment felt, -1..1. Used as a retrieval lens; omitted keeps the stored value.' },
311
349
  arousal: { type: 'number', description: 'How charged the moment was, 0..1. Used as a retrieval lens; omitted keeps the stored value.' },
350
+ emotion: {
351
+ type: 'object',
352
+ properties: { joy: { type: 'number' }, anger: { type: 'number' }, sorrow: { type: 'number' }, fear: { type: 'number' } },
353
+ additionalProperties: false,
354
+ description: '情绪构成,三项语义不同:省略=保留原值;显式 null=清除 emotion,来源变 unknown;显式对象=校验并归一化后整份替换。改的是那条记忆所述事件的感受,不是此刻的心情。',
355
+ },
356
+ dominance: { type: 'number', description: '谁在掌控:-1 完全失控 … +1 完全掌控。省略=保留原值。' },
312
357
  salient_date: { type: 'string', description: 'Re-date the memory: MM-DD for an annual anniversary, YYYY-MM-DD for a one-off dated commitment, or an empty string to remove the date. Use this to repair a date that was stored without its year.' },
313
358
  },
314
359
  async (args, exec) => {
@@ -318,6 +363,8 @@ export function registerTools(ctx) {
318
363
  layer: args.layer, owner: args.owner, visibility: args.visibility,
319
364
  source: args.source, track: args.track, peak: args.peak,
320
365
  moodValence: args.mood_valence, arousal: args.arousal,
366
+ // undefined 就是"省略",必须原样传下去;换成 null 会把保留变成清除。
367
+ emotion: args.emotion, dominance: args.dominance,
321
368
  // Sentinel: absent means "leave the date as it is", '' means "clear it".
322
369
  salientDate: args.salient_date,
323
370
  })
@@ -565,6 +565,33 @@ export function dbPathFor(libraryId = null) {
565
565
  * loadIndex must be synchronous; by default the authoritative raw index is read.
566
566
  * A nonempty, corrupt, quarantined or unknown-shaped index requires rebuilding.
567
567
  */
568
+ /**
569
+ * Remove one library from the active database map under the manifest lock.
570
+ * The caller owns filesystem cleanup; returning the captured absolute path keeps
571
+ * the manifest mutation atomic without deleting a possibly newer generation.
572
+ */
573
+ export function removeActiveLibrary(libraryId) {
574
+ if (!isSafeId(libraryId) || libraryId === 'global') throw vectorError(VECTOR_ERROR_CODES.PATH_ESCAPE, 'unsafe private library id: ' + JSON.stringify(libraryId))
575
+ let removed = null
576
+ updateVectorStateSync(state => {
577
+ const guard = liveGuard(state.writeGuard)
578
+ if (guard.frozen || Object.keys(guard.leases).length) {
579
+ throw vectorError('VECTOR_WRITE_BUSY', 'an in-flight vector write prevents deleting a library; retry shortly', { libraryId })
580
+ }
581
+ if (state.pending) {
582
+ throw vectorError('VECTOR_WRITE_BUSY', 'a vector generation switch prevents deleting a library; retry shortly', { libraryId })
583
+ }
584
+ const active = state.active
585
+ const entry = active?.databases?.libraries?.[libraryId]
586
+ if (!active || !entry) return null
587
+ removed = resolveVectorPath(entry.path)
588
+ const libraries = { ...active.databases.libraries }
589
+ delete libraries[libraryId]
590
+ return { ...state, active: { ...active, databases: { ...active.databases, libraries } } }
591
+ })
592
+ return removed
593
+ }
594
+
568
595
  export function enrollActiveLibrary(libraryId, { loadIndex } = {}) {
569
596
  if (!isSafeId(libraryId) || libraryId === 'global') throw vectorError(VECTOR_ERROR_CODES.PATH_ESCAPE, 'unsafe private library id: ' + JSON.stringify(libraryId))
570
597
  let db = null
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-memu-memory",
3
3
  "icon": "./assets/icon.png",
4
- "version": "1.1.3",
4
+ "version": "1.2.0",
5
5
  "description": "面向长篇 RP 与陪伴对话的 DeepSeek Harness 记忆插件:三层记忆、逐轮召回、来源标注、关系图、图片附件与独立 WebUI。",
6
6
  "keywords": [
7
7
  "deepseek-harness",
@@ -45,6 +45,7 @@
45
45
  "files": [
46
46
  "lib",
47
47
  "scripts",
48
+ "tests",
48
49
  "cordis.patch.yml",
49
50
  "requirements.lock",
50
51
  "README.md",
@@ -699,7 +699,8 @@ await auto('G-1', 'the kept docs exist and nothing points at a retired one', asy
699
699
  for (const rel of ['assets/icon.png', 'assets/icon-256.png', 'assets/icon.svg']) {
700
700
  assert.ok(fs.existsSync(path.join(ROOT, rel)), rel + ' exists')
701
701
  }
702
- assert.ok(!fs.existsSync(path.join(ROOT, 'CHANGELOG' + '.md')), 'the retired changelog is gone')
702
+ // Since 1.1.3 the changelog is the source for release notes (release.yml), so it is kept.
703
+ assert.ok(fs.existsSync(path.join(ROOT, 'CHANGELOG' + '.md')), 'CHANGELOG.md carries release notes')
703
704
  // 细节从 AGENTS.md 搬进了 docs/:索引必须短,指针必须都能落地
704
705
  const agents = fs.readFileSync(path.join(ROOT, 'AGENTS.md'), 'utf8')
705
706
  assert.ok(agents.includes('plugin:dsh-memu-memory'), 'the notes carry the real source kind, not the old one')