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
@@ -30,6 +30,7 @@
30
30
  - open_loop:只标真实未完的承诺或已铺垫且影响后续的悬念;普通沉默和未说出口的念头不自动标 true,兑现或明确取消后才关闭。
31
31
  - confidence:不确定就低一点。
32
32
  - mood_valence/arousal 是感受,confidence 是证据,不把情绪强度当准确性。owner 区分自己的体验、共同经历与对方明确说出的事;不为好召回而夸大 peak。
33
+ - 情绪按事件所在回合还原:emotion 填的是那一瞬间的感受,取那一轮对话里的情绪,不是整理时的心情。同一轮的多条条目各写各的,一场争吵的情绪不串到所有条目。工程事实(owner=about_project)固定中性。dominance 跟着 emotion 一起填:那一刻是自己推动的(正)还是被推着走的(负),没把握就省略,不要用 0 凑数。
33
34
 
34
35
  永久记忆只在对方明确要求、随机侧事件、或极少数「忘了就不再是我」时使用;不要为了显得深刻而 pin。
35
36
  不要写全局记忆;全局记忆只由 curator 写。
package/lib/prompts.js CHANGED
@@ -19,7 +19,7 @@ export const PROMPT_DEFS = [
19
19
  key: 'system-guide',
20
20
  file: 'system-guide.md',
21
21
  title: '记忆使用指南',
22
- description: '注入 system prompt 的常驻记忆规则:什么时候检索、什么时候写入、永久记忆与 unpin 的边界。',
22
+ description: '以插件消息投放的常驻记忆规则:什么时候检索、什么时候写入、永久记忆与 unpin 的边界;按 domain 选变体,规则变化会在下一回合首步重新投放。',
23
23
  variables: ['mustRememberCap', 'unpinBudget', 'shortTermTtlHours', 'anchorTokens', 'turnTokens'],
24
24
  scope: 'private',
25
25
  domainLayered: true,
@@ -28,7 +28,7 @@ export const PROMPT_DEFS = [
28
28
  key: 'private-policy',
29
29
  file: 'private-policy.md',
30
30
  title: '私有记忆策略',
31
- description: '按领域约束主 agent 的记忆写入:代码逻辑与验证、角色连续性、事实与推断、永久记忆与披露边界。',
31
+ description: '字段规约模板;当前版本不再自动投放主会话,字段契约由工具 schema 与运行校验提供。保留供查阅和覆盖记录。',
32
32
  variables: ['mustRememberCap', 'unpinBudget', 'shortTermTtlHours', 'anchorTokens', 'turnTokens'],
33
33
  scope: 'private',
34
34
  domainLayered: true,
@@ -84,8 +84,8 @@ export const CONSOLIDATION_CONTRACTS = {
84
84
  local: '使用当前可用的记忆工具,先按主题检索去重。兼容细节用 memu_private_update 合并;真正新增或矛盾待复核的内容用 memu_private_remember,一条一件,不自行裁定冲突。不能查询或更新时如实保留限制,不宣称已核对或已修改。没有值得留的就直接结束,不要勉强写。',
85
85
  remote: [
86
86
  '只输出一个 JSON 对象,不要任何解释或 Markdown 代码块:',
87
- '{"memories":[{"title":"≤30字","content":"完整正文","layer":"long_term|short_term","tags":["可选"],"open_loop":false,"confidence":0.0,"source":"inferred|user_said|imagined|told","visibility":"session|shared|secret","disclosure":"can_tell|only_if_asked|never_say","owner":"me|us|about_user|about_project","mood_valence":0.0,"arousal":0.35,"peak":false,"salient_date":null}]}',
88
- '你没有记忆工具,也没有输入以外的索引:只返回最多 3 条候选,不声称已检索、合并、更新、裁决或完成写入。不要输出工具调用或 must_remember;矛盾候选在 content 中保留来源、时间与不确定性,交给后续复核。领域表达服从上文,字段服从本 JSON 协议。未确定的感受/日期用 null,不为填字段编造。',
87
+ '{"memories":[{"title":"≤30字","content":"完整正文","layer":"long_term|short_term","tags":["可选"],"open_loop":false,"confidence":0.0,"source":"inferred|user_said|imagined|told","visibility":"shared|secret","disclosure":"can_tell|only_if_asked|never_say","owner":"me|us|about_user|about_project","emotion":null,"dominance":0,"mood_valence":0.0,"arousal":0.35,"peak":false,"salient_date":null}]}',
88
+ '你没有记忆工具,也没有输入以外的索引:只返回最多 3 条候选,不声称已检索、合并、更新、裁决或完成写入。不要输出工具调用或 must_remember;矛盾候选在 content 中保留来源、时间与不确定性,交给后续复核。领域表达服从上文,字段服从本 JSON 协议。emotion 只接受四轴 {"joy","anger","sorrow","fear"} 的份额对象,没有明确依据就用 null;dominance 是 -1..1 的掌控感。未确定的感受/日期用 null,不为填字段编造。',
89
89
  '没有值得留的就输出 {"memories":[]}。',
90
90
  ].join('\n'),
91
91
  }
@@ -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
@@ -18,7 +18,7 @@ import { statsView } from './stats.js'
18
18
  import { getMemuConfig, saveMemuConfig, setEmbeddingSource, testMemuConfig, getSummaryConfig, getVisionConfig } from './memu-config.js'
19
19
  import { getLocalVectorState, downloadLocalVectorModel, installLocalVectorRuntime, selectLocalVectorModel, switchLocalVectorProfile, rollbackLocalVectorProfile, abortLocalVectorMigration, testLocalVector } from './local-vector-service.js'
20
20
  import { webUiHtml, WEBUI_PATH, standaloneHtml, standaloneAsset, SETTINGS_PATH } from './webui.js'
21
- import { getGraph, listEntities, createEntity, renameEntity, linkEntries, unlinkEntry, getRelationship, updateRelationship, graphExport, restoreGraphData } from './graph.js'
21
+ import { getGraph, listEntities, createEntity, renameEntity, deleteEntity, linkEntries, unlinkEntry, getRelationship, updateRelationship, graphExport, restoreGraphData } from './graph.js'
22
22
  import { getAttachment, attachmentThumbnail, attachmentPreview, exportMediaBundle, importMediaBundle, streamMediaExport, importMediaStream } from './media.js'
23
23
  import { listEvents, logStats, clearEvents } from './activity-log.js'
24
24
 
@@ -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 || {}),
@@ -250,13 +255,44 @@ async function readSessionTitles(sessionQuery, ids) {
250
255
  for (const r of await sessionQuery.readTitleSnapshots(ids) || []) {
251
256
  if (r?.status !== 'fulfilled' || !r.sessionId) continue
252
257
  const title = r.value?.title?.title || r.value?.session?.title
253
- if (title) titles.set(r.sessionId, title)
258
+ if (title && String(title).trim()) titles.set(r.sessionId, String(title).trim())
254
259
  }
255
260
  }
256
261
  } catch {}
257
262
  return titles
258
263
  }
259
264
 
265
+ // 日志行原本只有 sessionId/libraryId 裸 id:日志页和侧边栏提醒要回答"这是哪个对话、
266
+ // 哪个文件夹",得在服务端把标题与库名拼上。标题走宿主 readTitleSnapshots(批量一次),
267
+ // 查不到的行保留 null,客户端回退到短 id。
268
+ async function decorateLogRows(ctx, rows) {
269
+ const ids = [...new Set(rows.map(row => row?.sessionId).filter(Boolean))]
270
+ const titles = await readSessionTitles(ctx?.get?.('sessionQuery'), ids)
271
+ return rows.map(row => ({
272
+ ...row,
273
+ sessionTitle: row?.sessionId ? (titles.get(row.sessionId) || null) : null,
274
+ libraryName: row?.libraryId ? (getLibrary(row.libraryId)?.name || null) : null,
275
+ }))
276
+ }
277
+
278
+ // 侧边栏"整理提醒"的数据源:跨会话(不按当前对话过滤),失败/成功两列分开,
279
+ // 每列只取最新 limit 条。失败的行带会话标题与库名,客户端用它挂跳转按钮。
280
+ async function consolidationFeed(ctx, payload = {}) {
281
+ const perColumn = Math.max(1, Math.min(20, Number(payload?.limit) || 6))
282
+ const rows = await decorateLogRows(ctx, listEvents({ kind: 'consolidate', limit: 80 }))
283
+ const slim = ({ detail, ...row }) => ({
284
+ ...row,
285
+ reasonCode: detail?.reasonCode || detail?.code || null,
286
+ timedOut: detail?.timedOut === true,
287
+ count: detail?.count ?? null,
288
+ force: detail?.force === true,
289
+ })
290
+ return {
291
+ failures: rows.filter(row => row.ok === false).slice(0, perColumn).map(slim),
292
+ successes: rows.filter(row => row.ok !== false).slice(0, perColumn).map(slim),
293
+ }
294
+ }
295
+
260
296
  // The host's session corpus is the authority for "this conversation still
261
297
  // exists". A binding in state.json is deliberately kept after a conversation is
262
298
  // deleted so its memory library survives; it must not keep the dead row visible.
@@ -453,6 +489,7 @@ async function dispatch(ctx, endpoint, payload = {}) {
453
489
  } catch {}
454
490
  const rows = Object.entries(state.sessions || {})
455
491
  .map(([sessionId, binding]) => {
492
+ if (binding.consolidationFork) return null
456
493
  const inFolder = binding.scopeKey === scopeKey
457
494
  || (binding.libraryId && state.libraries?.[binding.libraryId]?.scopeKey === scopeKey)
458
495
  if (!inFolder) return null
@@ -771,7 +808,9 @@ async function dispatch(ctx, endpoint, payload = {}) {
771
808
  // `source` is writable: correcting provenance is the point of showing it.
772
809
  // `salientDate` is writable for the same reason: an entry whose year was
773
810
  // 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']
811
+ // `emotion`/`dominance` 可写:面板修的是那条记忆所述事件的感受。
812
+ // `emotionSource` 故意不在名单里:来源是审计结论,能改就等于能伪造。
813
+ const allowed = ['content', 'title', 'tags', 'status', 'openLoop', 'disclosure', 'confidence', 'owner', 'visibility', 'layer', 'source', 'salientDate', 'track', 'peak', 'moodValence', 'arousal', 'emotion', 'dominance', 'attachments', 'context']
775
814
  // An unknown key used to be dropped in silence, so a typo here or in a script
776
815
  // came back as a successful save that changed nothing. Refuse instead: the
777
816
  // caller can only fix a field name it is told is wrong.
@@ -874,6 +913,8 @@ async function dispatch(ctx, endpoint, payload = {}) {
874
913
  return createEntity(payload || {})
875
914
  case 'renameEntity':
876
915
  return renameEntity(payload || {})
916
+ case 'deleteEntity':
917
+ return deleteEntity(payload || {})
877
918
  case 'linkEntries':
878
919
  return linkEntries(payload || {})
879
920
  case 'unlinkEntry':
@@ -1132,7 +1173,7 @@ async function dispatch(ctx, endpoint, payload = {}) {
1132
1173
  return engineView()
1133
1174
  case 'getActivityLog':
1134
1175
  return {
1135
- events: listEvents({
1176
+ events: await decorateLogRows(ctx, listEvents({
1136
1177
  limit: payload?.limit,
1137
1178
  kind: payload?.kind,
1138
1179
  level: payload?.level,
@@ -1140,9 +1181,17 @@ async function dispatch(ctx, endpoint, payload = {}) {
1140
1181
  sessionId: payload?.sessionId,
1141
1182
  since: payload?.since,
1142
1183
  q: payload?.q,
1143
- }),
1184
+ })),
1144
1185
  stats: logStats(),
1145
1186
  }
1187
+ case 'getConsolidationFeed':
1188
+ return await consolidationFeed(ctx, payload)
1189
+ case 'clearConsolidationFailures': {
1190
+ // 侧边栏失败提醒的"清除":只删 consolidate 且 ok=false 的行。返回删除条数和
1191
+ // 清除后的新 feed,客户端直接回填,不用二次拉取。
1192
+ const cleared = clearEvents({ kind: 'consolidate', ok: false })
1193
+ return { cleared: cleared.removed, feed: await consolidationFeed(ctx, payload) }
1194
+ }
1146
1195
  case 'clearActivityLog':
1147
1196
  return clearEvents()
1148
1197
  default:
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) {
@@ -0,0 +1,106 @@
1
+ // One ruler for "do these two memories say the same thing".
2
+ //
3
+ // 5: the recall side (candidate folding, MMR hard skip) and the
4
+ // write side (the duplicate gate) must measure with the same formula, or recall
5
+ // folds what write kept and vice versa. Everything here is deliberately
6
+ // dependency-free — two bigram sets and the Dice coefficient — and the text
7
+ // preprocessing is the one the contradiction check always used (lowercase, strip
8
+ // whitespace/punctuation/symbols), so findConflict's behaviour is unchanged by
9
+ // this extraction.
10
+
11
+ // Same fact: a recall cluster keeps one candidate, MMR hard-skips the rest.
12
+ export const DUP_DICE = 0.82
13
+ // Write-side reject: a new memory this close to an existing one is refused.
14
+ export const DUP_REJECT_DICE = 0.90
15
+ // Same topic, different fact: soft-penalty band on recall, duplicateOf tag on write.
16
+ export const TOPIC_DICE = 0.55
17
+ // Write-side pruning: skip the bigram pass when two texts differ in length by more
18
+ // than this fraction of the longer one. At this ratio Dice cannot reach the reject
19
+ // band even with zero unique bigrams on the short side.
20
+ export const LENGTH_PRUNE_RATIO = 0.5
21
+
22
+ // Length-disparate pairs (gap > LENGTH_PRUNE_RATIO) do not go through Dice — the
23
+ // ratio math makes a reject-band Dice impossible, which is exactly the hole a
24
+ // "rewrite it much longer" evader walks through. Two cheap containment probes
25
+ // close the hole without paying the full bigram pass:
26
+ // * verbatim containment — the short cleaned text is a substring of the long one;
27
+ // * core coverage — every character bigram of the short text occurs somewhere in
28
+ // the long one ("核心词全含"), probed with String.includes so the long side is
29
+ // never materialised into a bigram set and a miss fails fast on the first
30
+ // absent gram.
31
+ // Both are strictly stronger signals than Dice ≥ DUP_REJECT_DICE (a verbatim or
32
+ // all-grams-contained overlap cannot be an accident), so they report a floor in
33
+ // the reject band instead of a made-up Dice value. Partial coverage deliberately
34
+ // reports 0: with a >0.5 length gap there is no honest way to say "same topic",
35
+ // and a false reject on the write side is worse than a rare paraphrase bypass —
36
+ // the residual risk (heavily reworded padding) is documented in
37
+ export const CONTAINMENT_FLOOR = 0.92
38
+ export const CORE_COVER_FLOOR = DUP_REJECT_DICE
39
+ // Below this many cleaned characters the probe has too little signal (a 4-char
40
+ // string and its bigrams occur in unrelated texts all the time); above the max the
41
+ // probe budget is exceeded and both sides are substantial enough to trust the
42
+ // length prune again. Bounds keep the write-side gate O(|short|·|long|) native
43
+ // scans per pruned pair instead of O(N²) bigram-set construction.
44
+ const PROBE_MIN_CHARS = 6
45
+ const PROBE_MAX_CHARS = 240
46
+
47
+ /** The one text preprocessing every measurement here shares. */
48
+ export function clean(text) {
49
+ return String(text || '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
50
+ }
51
+
52
+ /** Lowercase, strip whitespace/punctuation/symbols, then collect character bigrams. */
53
+ export function bigrams(text) {
54
+ const c = clean(text)
55
+ const out = new Set()
56
+ for (let i = 0; i < c.length - 1; i += 1) out.add(c.slice(i, i + 2))
57
+ return out
58
+ }
59
+
60
+ /** Dice coefficient over two bigram sets; 0 when either side is empty. */
61
+ export function dice(a, b) {
62
+ if (a.size === 0 || b.size === 0) return 0
63
+ let shared = 0
64
+ for (const gram of a) if (b.has(gram)) shared += 1
65
+ return (2 * shared) / (a.size + b.size)
66
+ }
67
+
68
+ /** |len(A)-len(B)| / max(len); 0 when both sides are empty. */
69
+ export function lengthGap(aText, bText) {
70
+ const a = String(aText || '').length
71
+ const b = String(bText || '').length
72
+ return Math.abs(a - b) / Math.max(a, b, 1)
73
+ }
74
+
75
+ /**
76
+ * Dice over raw texts with the write-side length pruning applied: pairs whose
77
+ * lengths differ too much return 0 without paying for the bigram build (the
78
+ * gate runs over the whole library on every write). 0 can never win a
79
+ * max-similarity scan, so pruned pairs simply never become the best match —
80
+ * unless they hit one of the two containment probes below, which are the only
81
+ * way a length-disparate pair reaches the reject band.
82
+ */
83
+ export function similarTexts(aText, bText) {
84
+ const a = String(aText || '')
85
+ const b = String(bText || '')
86
+ if (lengthGap(a, b) > LENGTH_PRUNE_RATIO) {
87
+ const aClean = clean(a)
88
+ const bClean = clean(b)
89
+ if (!aClean || !bClean) return 0
90
+ const shortClean = aClean.length <= bClean.length ? aClean : bClean
91
+ const longClean = aClean.length <= bClean.length ? bClean : aClean
92
+ if (shortClean.length < PROBE_MIN_CHARS || shortClean.length > PROBE_MAX_CHARS) return 0
93
+ // Probe 1: verbatim containment of the short text inside the long one.
94
+ if (longClean.includes(shortClean)) return CONTAINMENT_FLOOR
95
+ // Probe 2: core coverage — every bigram of the short text occurs in the long
96
+ // one. includes() over the cleaned long text is exactly long-bigram-set
97
+ // membership (a hit means the two characters are adjacent somewhere in the
98
+ // long text), so no long-side set is ever built; the first absent gram ends
99
+ // the probe.
100
+ for (let i = 0; i < shortClean.length - 1; i += 1) {
101
+ if (!longClean.includes(shortClean.slice(i, i + 2))) return 0
102
+ }
103
+ return CORE_COVER_FLOOR
104
+ }
105
+ return dice(bigrams(a), bigrams(b))
106
+ }