dsh-memu-memory 1.1.2 → 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 (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
package/lib/state.js CHANGED
@@ -5,12 +5,21 @@ import {
5
5
  dataDir, librariesDir, indexDir, backupsDir,
6
6
  ensureDir, readJson, readFailure, readFailuresUnder, writeJson, withFileLock, assertJsonShape, warn, stamp, shortHash, libraryIdForScope, isSafeId,
7
7
  } from './paths.js'
8
+ import {
9
+ activeLeases, createBackup, deleteBackupEntry, listBackupEntries,
10
+ pruneBackups, restoreFromBackup, withBackupLock,
11
+ } from './backup.js'
8
12
  import { migrateIndexVisibility } from './visibility.js'
13
+ import { memuDbFor, memuGlobalDbFor, removeActiveLibrary } from './vector-profile.js'
14
+ import { normalizeEmotion, derivedValence } from './mood.js'
9
15
 
10
16
  const STATE_FILE = () => path.join(dataDir(), 'state.json')
11
17
  const GLOBAL_DB = () => path.join(dataDir(), 'global.sqlite3')
12
18
  const GLOBAL_INDEX = () => path.join(indexDir(), 'global.json')
13
19
 
20
+ // Observable outcome of the last backup action; see listBackupEntries().
21
+ let lastBackupReport = null
22
+
14
23
  export const DEFAULT_SETTINGS = {
15
24
  sidebarSettings: false,
16
25
  visionCaptionEnabled: true,
@@ -24,12 +33,17 @@ export const DEFAULT_SETTINGS = {
24
33
  // "too little memory", it is noise: a per-turn block sized for paragraph-long
25
34
  // prompts drowns the conversation it was meant to help. So the per-turn budgets
26
35
  // are small, and the windows that stop repetition are measured in many turns.
27
- privateTopK: 3,
28
- globalTopK: 2,
29
- anchorTokens: 700,
30
- // 640 tokens per user turn. A dense chat calls recall on almost every message,
31
- // so the injected block has to stay clearly smaller than the message it serves.
32
- turnTokens: 640,
36
+ privateTopK: 5,
37
+ globalTopK: 5,
38
+ anchorTokens: 800,
39
+ // 800 tokens for one recall block. A dense chat calls recall on almost every
40
+ // message, so the injected block has to stay clearly smaller than the message it
41
+ // serves. This is the recall budget, not the whole plugin-message ceiling.
42
+ turnTokens: 800,
43
+ // Whole plugin-message ceiling: anchor + recall + optional cue/texture. It must
44
+ // be at least the sum of the sub-budgets the user wants to allow; the default leaves
45
+ // room for the default anchor and recall blocks.
46
+ injectionTotalTokens: 2000,
33
47
  relevanceThreshold: 0.4,
34
48
  // 16 turns is a couple of minutes of group traffic. The old 8 expired inside a
35
49
  // single burst, so the same entry came back every minute or two.
@@ -81,6 +95,12 @@ export const DEFAULT_SETTINGS = {
81
95
  // "gone", and short enough that a conversation which ended gets tidied in the
82
96
  // same sitting instead of waiting for a quiet hour that may never arrive.
83
97
  sleepAfterIdleMinutes: 3,
98
+ // 主动记忆:分数门槛、饥饿阈值(空闲超过它无条件检查)、两种抑制冷却(分钟;整理冷却默认 0,
99
+ // 薄证据的重复 fork 由评分拦截,只有用户显式配置才启用硬冷却)。
100
+ activeMemoryMinScore: 0.8,
101
+ activeMemoryStarvationMinutes: 15,
102
+ activeMemoryCooldownMinutes: 0,
103
+ activeMemoryRecallCooldownMinutes: 15,
84
104
  autoCurateEveryHours: 0,
85
105
  autoCurateSessionLimit: 8,
86
106
  // 'local' keeps every memory on this machine; 'cloud' hands MemU's own backend
@@ -90,6 +110,7 @@ export const DEFAULT_SETTINGS = {
90
110
  // agent writes and summarizes into the JSON index, with no MemU engine and no
91
111
  // embedding API. 'api' hands the work to the configured remote MemU service.
92
112
  memoryBackend: 'agent',
113
+ vectorSource: 'local',
93
114
  habitsInjection: true,
94
115
  // A positive age marks cleanup candidates; deletion always needs a user action.
95
116
  purgeArchivedAfterDays: 0,
@@ -110,10 +131,14 @@ function defaultState() {
110
131
  function mergeState(raw) {
111
132
  const base = defaultState()
112
133
  if (!raw || typeof raw !== 'object') return base
134
+ const rawSettings = raw.settings && typeof raw.settings === 'object' ? raw.settings : {}
135
+ const legacyVectorSource = rawSettings.vectorSource === 'api' || rawSettings.vectorSource === 'local'
136
+ ? rawSettings.vectorSource
137
+ : (rawSettings.memoryMode === 'cloud' ? 'api' : 'local')
113
138
  return {
114
139
  ...base,
115
140
  ...raw,
116
- settings: { ...base.settings, ...(raw.settings || {}) },
141
+ settings: { ...base.settings, ...rawSettings, vectorSource: legacyVectorSource },
117
142
  libraries: raw.libraries && typeof raw.libraries === 'object' ? raw.libraries : {},
118
143
  sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {},
119
144
  workspacePolicies: raw.workspacePolicies && typeof raw.workspacePolicies === 'object' ? raw.workspacePolicies : {},
@@ -306,16 +331,29 @@ export function getMemoryBackend() {
306
331
  return loadState().settings?.memoryBackend === 'api' ? 'api' : 'agent'
307
332
  }
308
333
 
334
+ // Single route authority for automatic memory reads and writes.
335
+ export function effectiveVectorRoute(settings = loadState().settings) {
336
+ if (settings?.memoryBackend !== 'api') return 'lexical'
337
+ return settings?.vectorSource === 'api' ? 'remote-api' : 'managed-local'
338
+ }
339
+
340
+ export function vectorBackendEnabled(settings = loadState().settings) {
341
+ return effectiveVectorRoute(settings) !== 'lexical'
342
+ }
343
+
309
344
  export function updateSettings(patch) {
310
345
  const clean = {}
311
346
  const ranges = {
312
347
  privateTopK: [1, 20], globalTopK: [1, 20], anchorTokens: [160, 8000], turnTokens: [80, 8000],
348
+ injectionTotalTokens: [160, 16000],
313
349
  relevanceThreshold: [0, 1], dedupeTurns: [0, 200], globalEveryNTurns: [0, 200], minUserChars: [0, 1000],
314
350
  mustRememberCap: [1, 100], randomCueChance: [0, 1], shortTermTtlHours: [0, 8760],
315
351
  unpinBudgetPerSession: [1, 20], unpinCooldownMinutes: [0, 1440], sleepAfterIdleMinutes: [1, 1440],
316
352
  autoCurateEveryHours: [0, 8760], autoCurateSessionLimit: [1, 10], purgeArchivedAfterDays: [0, 36500],
353
+ activeMemoryMinScore: [0, 10], activeMemoryStarvationMinutes: [1, 1440],
354
+ activeMemoryCooldownMinutes: [0, 10080], activeMemoryRecallCooldownMinutes: [0, 10080],
317
355
  }
318
- const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'] }
356
+ const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], vectorSource: ['local', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'] }
319
357
  for (const [key, value] of Object.entries(patch || {})) {
320
358
  if (!Object.hasOwn(DEFAULT_SETTINGS, key)) continue
321
359
  if (enums[key]) {
@@ -512,13 +550,20 @@ export function deleteLibrary(id) {
512
550
  const state = loadState()
513
551
  if (!state.libraries[id]) return { ok: false, error: 'library not found' }
514
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')
515
555
  delete state.libraries[id]
516
556
  for (const [sessionId, binding] of Object.entries(state.sessions || {})) {
517
557
  if (binding?.libraryId === id) delete state.sessions[sessionId]
518
558
  }
519
559
  saveState(state)
520
560
  const removed = []
521
- 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
522
567
  try {
523
568
  if (fs.existsSync(file)) { fs.rmSync(file, { force: true }); removed.push(file) }
524
569
  } catch {}
@@ -762,6 +807,8 @@ export function markInjected(sessionId, ids, turn) {
762
807
  for (const id of ids || []) at[id] = turn ?? Date.now()
763
808
  binding.injectedAt = at
764
809
  binding.lastInjectedTurn = turn ?? binding.lastInjectedTurn ?? 0
810
+ // 主动记忆抑制要一个"最近一次真正注入了记忆"的墙钟时间;turn 号不能跨会话比较。
811
+ binding.lastInjectedAt = stamp()
765
812
  return state
766
813
  })
767
814
  }
@@ -841,17 +888,16 @@ export function consumeCue(sessionId, cueId) {
841
888
  return true
842
889
  }
843
890
 
844
- // The session's running mood, kept on the binding rather than in a memory entry:
845
- // it is a retrieval lens, not a fact about the user, and must never be recalled
846
- // 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.
847
893
  export function setSessionMood(sessionId, mood) {
848
- return setSessionBinding(sessionId, {
849
- mood: {
850
- valence: Math.max(-1, Math.min(1, Number(mood?.valence) || 0)),
851
- arousal: Math.max(0, Math.min(1, Number(mood?.arousal) || 0)),
852
- at: Date.now(),
853
- },
854
- })
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
+ } })
855
901
  }
856
902
 
857
903
  export function getSessionMood(sessionId) {
@@ -906,12 +952,12 @@ export function markAnniversaryShown(sessionId, memoryId, dayKey) {
906
952
 
907
953
  export function memoryDbPath(libraryId) {
908
954
  ensureDir(librariesDir())
909
- return path.join(librariesDir(), `${libraryId}.sqlite3`)
955
+ return effectiveVectorRoute() === 'managed-local' ? memuDbFor(libraryId) : path.join(librariesDir(), `${libraryId}.sqlite3`)
910
956
  }
911
957
 
912
958
  export function globalDbPath() {
913
959
  ensureDir(dataDir())
914
- return GLOBAL_DB()
960
+ return effectiveVectorRoute() === 'managed-local' ? memuGlobalDbFor() : GLOBAL_DB()
915
961
  }
916
962
 
917
963
  // Index files are parsed on every folder in the sidebar, and the parse (full
@@ -1144,175 +1190,94 @@ export function newMemoryId(prefix = 'mem') {
1144
1190
  return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`
1145
1191
  }
1146
1192
 
1147
- export function backupDatabases() {
1193
+ /**
1194
+ * Creates a managed backup of the local memory stores (plugin state, indexes,
1195
+ * prompts, attachments, the vector journal and every MemU database), then keeps
1196
+ * the newest BACKUP_KEEP complete ones. Retention, staging, checksums, atomic
1197
+ * publish and the shared lock all live in lib/backup.js so there is exactly one
1198
+ * place that decides what a backup is and which backup may be deleted.
1199
+ *
1200
+ * Returns the published directory: callers (engine switch, library delete,
1201
+ * scripts) treat the return value as a path.
1202
+ */
1203
+ export function backupDatabases(options = {}) {
1148
1204
  // Pending bookkeeping changes live in memory for up to 50ms; a backup taken
1149
1205
  // before a flush would silently capture the previous state.
1150
- flushState()
1151
- const dest = path.join(backupsDir(), stamp().replace(/[:.]/g, '-'))
1152
- ensureDir(dest)
1153
- ensureDir(path.join(dest, 'libraries'))
1154
- ensureDir(path.join(dest, 'index'))
1155
- // The prompt manifest copy below silently no-opped without this directory —
1156
- // an empty catch swallowed the ENOENT and backups shipped without it.
1157
- ensureDir(path.join(dest, 'prompts'))
1158
- const copy = (from, to) => {
1159
- let info
1160
- try { info = fs.lstatSync(from) } catch (error) { if (error.code === 'ENOENT') return; throw error }
1161
- if (!info.isFile()) throw new Error(`backup refuses non-file: ${path.basename(from)}`)
1162
- fs.copyFileSync(from, to)
1163
- }
1164
- copy(STATE_FILE(), path.join(dest, 'state.json'))
1165
- copy(GLOBAL_DB(), path.join(dest, 'global.sqlite3'))
1166
- copy(GLOBAL_INDEX(), path.join(dest, 'index', 'global.json'))
1167
- copy(path.join(dataDir(), 'prompts', 'manifest.json'), path.join(dest, 'prompts', 'manifest.json'))
1168
- try {
1169
- for (const name of fs.readdirSync(librariesDir())) {
1170
- if (name.endsWith('.sqlite3') || name.endsWith('.sqlite3-wal') || name.endsWith('.sqlite3-shm')) {
1171
- copy(path.join(librariesDir(), name), path.join(dest, 'libraries', name))
1172
- }
1173
- }
1174
- } catch (error) { if (error.code !== 'ENOENT') throw error }
1206
+ let report
1175
1207
  try {
1176
- for (const name of fs.readdirSync(indexDir())) {
1177
- if (name.endsWith('.json')) copy(path.join(indexDir(), name), path.join(dest, 'index', name))
1178
- }
1179
- } catch (error) { if (error.code !== 'ENOENT') throw error }
1180
- const copyTree = (source, target) => {
1181
- let names
1182
- try { names = fs.readdirSync(source, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return; throw error }
1183
- ensureDir(target)
1184
- for (const item of names) {
1185
- if (item.isSymbolicLink()) throw new Error('backup refuses symbolic links')
1186
- if (item.isDirectory()) copyTree(path.join(source, item.name), path.join(target, item.name))
1187
- else copy(path.join(source, item.name), path.join(target, item.name))
1188
- }
1189
- }
1190
- copyTree(path.join(dataDir(), 'attachments'), path.join(dest, 'attachments'))
1191
- copyTree(path.join(dataDir(), 'prompts'), path.join(dest, 'prompts'))
1192
-
1193
- // A backup whose integrity nobody can check is only a hope. Hash every file so
1194
- // a restore can refuse a truncated or half-written copy.
1195
- const files = {}
1196
- const walk = (dir, prefix = '') => {
1197
- let names = []
1198
- try { names = fs.readdirSync(dir) } catch { return }
1199
- for (const name of names) {
1200
- const full = path.join(dir, name)
1201
- let stat = null
1202
- try { stat = fs.statSync(full) } catch { continue }
1203
- if (stat.isDirectory()) { walk(full, `${prefix}${name}/`); continue }
1204
- let sha256 = null
1205
- try { sha256 = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex') } catch {}
1206
- files[`${prefix}${name}`] = { bytes: stat.size, sha256 }
1208
+ report = createBackup({ ...options, flushState })
1209
+ } catch (error) {
1210
+ // A refusal is the safety property working, not something to swallow: a
1211
+ // database the engine could not snapshot aborts the backup, and the reason
1212
+ // has to stay visible on the same channel the panel already reads.
1213
+ lastBackupReport = {
1214
+ at: stamp(),
1215
+ action: 'create',
1216
+ status: 'failed',
1217
+ code: error?.code || null,
1218
+ error: String(error?.message || error),
1219
+ name: null,
1220
+ dir: null,
1221
+ files: 0,
1222
+ bytes: 0,
1223
+ snapshots: [],
1224
+ pruned: [],
1225
+ pruneFailed: [],
1226
+ pendingCleanup: 0,
1227
+ notice: null,
1207
1228
  }
1229
+ throw error
1208
1230
  }
1209
- walk(dest)
1210
- const current = engineCurrentSafe()
1211
- writeJson(path.join(dest, 'manifest.json'), {
1212
- version: 1,
1213
- createdAt: stamp(),
1214
- engine: current ? { version: current.version, schemaVersion: current.schemaVersion || null } : null,
1215
- files,
1216
- })
1217
- return dest
1231
+ lastBackupReport = { at: stamp(), action: 'create', ...report }
1232
+ return report.dir
1218
1233
  }
1219
1234
 
1220
- function engineCurrentSafe() {
1221
- try {
1222
- return JSON.parse(fs.readFileSync(path.join(dataDir(), 'engine', 'current.json'), 'utf8'))
1223
- } catch {
1224
- return null
1225
- }
1235
+ /** What the last backup/restore/prune did, so the outcome stays observable. */
1236
+ export function lastBackupReportView() {
1237
+ return lastBackupReport ? { ...lastBackupReport } : null
1226
1238
  }
1227
1239
 
1240
+ /** Newest first, including entries that failed to complete (reported, never deleted). */
1228
1241
  export function listBackups() {
1229
- let names = []
1230
- try { names = fs.readdirSync(backupsDir()) } catch { return [] }
1231
- return names
1232
- .map((name) => {
1233
- const dir = path.join(backupsDir(), name)
1234
- let stat = null
1235
- try { stat = fs.statSync(dir) } catch { return null }
1236
- if (!stat.isDirectory()) return null
1237
- const manifest = readJson(path.join(dir, 'manifest.json'), null)
1238
- const fileCount = manifest?.files ? Object.keys(manifest.files).length : null
1239
- const bytes = manifest?.files
1240
- ? Object.values(manifest.files).reduce((sum, f) => sum + Number(f.bytes || 0), 0)
1241
- : null
1242
- return {
1243
- name,
1244
- dir,
1245
- createdAt: manifest?.createdAt || stat.mtime.toISOString(),
1246
- engine: manifest?.engine || null,
1247
- files: fileCount,
1248
- bytes,
1249
- hasManifest: Boolean(manifest),
1250
- }
1251
- })
1252
- .filter(Boolean)
1253
- .sort((a, b) => String(b.name).localeCompare(String(a.name)))
1242
+ return listBackupEntries()
1254
1243
  }
1255
1244
 
1256
1245
  /** Removes one backup directory. The name is validated, never used as a path. */
1257
1246
  export function deleteBackup(name) {
1258
- if (!isSafeId(name)) throw new Error('invalid backup name')
1259
- const dir = path.join(backupsDir(), name)
1260
- if (!fs.existsSync(dir)) throw new Error(`backup not found: ${name}`)
1261
- fs.rmSync(dir, { recursive: true, force: true })
1262
- return { deleted: name }
1247
+ const result = deleteBackupEntry(name)
1248
+ lastBackupReport = { at: stamp(), action: 'delete', ...result }
1249
+ return { deleted: result.deleted, status: result.status }
1263
1250
  }
1264
1251
 
1265
1252
  /**
1266
- * Puts a backup back. The current state is backed up first, so a restore is
1267
- * itself reversible. Nothing is written until the target verifies, and the
1268
- * in-memory caches are dropped so the next read sees the restored files.
1253
+ * Applies the retention rule on demand. Creation already prunes, so this exists
1254
+ * for the paths that must reclaim the space a protected restore target kept
1255
+ * alive, and for callers that want the report without making a backup.
1269
1256
  */
1270
- export function restoreBackup(name, { beforeRestore = null } = {}) {
1271
- if (!isSafeId(name)) throw new Error('invalid backup name')
1272
- const dir = path.join(backupsDir(), name)
1273
- if (!fs.existsSync(dir)) throw new Error(`backup not found: ${name}`)
1274
- const manifest = readJson(path.join(dir, 'manifest.json'), null)
1275
- if (manifest?.files) {
1276
- for (const [rel, info] of Object.entries(manifest.files)) {
1277
- const full = path.join(dir, rel)
1278
- if (!full.startsWith(path.resolve(dir) + path.sep) || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) throw new Error('invalid backup manifest path')
1279
- if (!fs.existsSync(full)) throw new Error(`backup is incomplete: ${rel} is missing`)
1280
- if (info.sha256) {
1281
- const actual = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex')
1282
- if (actual !== info.sha256) throw new Error(`backup is corrupt: ${rel} failed its checksum`)
1283
- }
1284
- }
1285
- }
1286
- const safety = typeof beforeRestore === 'function' ? beforeRestore() : backupDatabases()
1287
- const copy = (from, to) => {
1288
- if (!fs.existsSync(from)) return
1289
- ensureDir(path.dirname(to))
1290
- fs.copyFileSync(from, to)
1291
- }
1292
- copy(path.join(dir, 'state.json'), STATE_FILE())
1293
- copy(path.join(dir, 'global.sqlite3'), GLOBAL_DB())
1294
- copy(path.join(dir, 'index', 'global.json'), GLOBAL_INDEX())
1295
- copy(path.join(dir, 'prompts', 'manifest.json'), path.join(dataDir(), 'prompts', 'manifest.json'))
1296
- for (const [sub, target] of [['index', indexDir()], ['libraries', librariesDir()]]) {
1297
- let names = []
1298
- try { names = fs.readdirSync(path.join(dir, sub)) } catch { continue }
1299
- for (const file of names) copy(path.join(dir, sub, file), path.join(target, file))
1300
- }
1301
- const restoreTree = (from, target) => {
1302
- let items
1303
- try { items = fs.readdirSync(from, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return; throw error }
1304
- for (const item of items) {
1305
- if (item.isSymbolicLink()) throw new Error('restore refuses symbolic links')
1306
- if (item.isDirectory()) restoreTree(path.join(from, item.name), path.join(target, item.name))
1307
- else copy(path.join(from, item.name), path.join(target, item.name))
1308
- }
1309
- }
1310
- restoreTree(path.join(dir, 'attachments'), path.join(dataDir(), 'attachments'))
1311
- restoreTree(path.join(dir, 'prompts'), path.join(dataDir(), 'prompts'))
1312
- // The restored files are the truth now; anything cached describes the state
1313
- // that was just replaced.
1314
- clearCaches()
1315
- return { restored: name, safety }
1257
+ export function pruneOldBackups(options = {}) {
1258
+ const result = pruneBackups(options)
1259
+ lastBackupReport = { at: stamp(), action: 'prune', ...result }
1260
+ return result
1261
+ }
1262
+
1263
+ /** Names of backups that a running restore or migration is reading right now. */
1264
+ export function leasedBackups() {
1265
+ return Object.keys(activeLeases())
1266
+ }
1267
+
1268
+ /**
1269
+ * Puts a backup back. The target is leased and its whole inventory is verified
1270
+ * before anything is written; the live root is saved as a safety backup next;
1271
+ * only then are files replaced. Failure leaves a recovery path: the safety
1272
+ * backup holds the state that was replaced and retention may not delete it.
1273
+ * A backup that predates the vector journal also retires the stale live
1274
+ * vector-active pointer, so recall falls back to text instead of querying
1275
+ * databases that no longer match the restored index.
1276
+ */
1277
+ export function restoreBackup(name, { beforeRestore = null, keep = undefined } = {}) {
1278
+ const result = restoreFromBackup(name, { beforeRestore, flushState, clearCaches, keep })
1279
+ lastBackupReport = { at: stamp(), action: 'restore', ...result }
1280
+ return result
1316
1281
  }
1317
1282
 
1318
1283
  /** Drops the in-memory mirrors so the next read comes from disk. */
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,10 +212,18 @@ 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
- 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.' },
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.' },
195
228
  attachments: { type: 'array', items: { type: 'object', properties: { sha256: { type: 'string', required: true } }, additionalProperties: true }, description: 'At most 8 sha256 references received in this conversation. Never supply paths or bytes. Ordinary image memories default to short_term if layer is omitted; choose long_term explicitly only when important.' },
196
229
  context: { type: 'object', properties: {
@@ -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
  })
@@ -252,11 +290,14 @@ export function registerTools(ctx) {
252
290
  },
253
291
  async (args, exec) => {
254
292
  const result = await privateRecall({ exec, query: args.query, topK: args.topK })
293
+ // Surface a degraded semantic leg on the explicit tool path too; silently
294
+ // returning fewer hits would make a failed backend look like an empty library.
295
+ const warning = (result.errors || []).length ? `\n注意:本次召回已降级:${(result.errors || []).join('; ')}` : ''
255
296
  // Read-only: never materialize a binding/library from a recall header.
256
297
  const binding = peekBinding(exec)
257
298
  const header = `私有记忆(library=${binding?.libraryId || '?'}, domain=${domainOf(binding)})`
258
- if (!result.files?.length) return `${header}\n没有命中。`
259
- return [header, ...result.files.map((f, i) => formatPrivateItem(f, i + 1, { query: args.query }))].join('\n\n')
299
+ if (!result.files?.length) return `${header}\n没有命中。${warning}`
300
+ return [header, ...result.files.map((f, i) => formatPrivateItem(f, i + 1, { query: args.query }))].join('\n\n') + warning
260
301
  },
261
302
  { isConcurrencySafe: () => true },
262
303
  ))
@@ -270,14 +311,15 @@ export function registerTools(ctx) {
270
311
  // instead of ensureSession() silently creating a placeholder library.
271
312
  if (!peekBinding(exec)?.libraryId) return '当前对话没有绑定记忆库。'
272
313
  const result = await privateList({ exec })
273
- if (!result.files?.length) return `私有记忆库 ${result.libraryId} 为空。`
314
+ const warning = result.warning ? `\n注意:${result.warning}` : ''
315
+ if (!result.files?.length) return `私有记忆库 ${result.libraryId} 为空。${warning}`
274
316
  const lines = [`私有记忆库 ${result.libraryId}(${result.files.length} 条)`]
275
317
  for (const file of result.files) {
276
318
  const e = file.entry || {}
277
319
  const tag = salientTag(e)
278
320
  lines.push(`- [${file.name}] ${e.title || file.description || '(untitled)'} · ${e.layer || 'long_term'} · ${e.status || 'active'}${e.marginalized ? ' · marginalized' : ''}${tag ? ` · ${tag}` : ''}`)
279
321
  }
280
- return lines.join('\n')
322
+ return lines.join('\n') + warning
281
323
  },
282
324
  { isConcurrencySafe: () => true },
283
325
  ))
@@ -305,6 +347,13 @@ export function registerTools(ctx) {
305
347
  peak: { type: 'boolean', description: 'Flashbulb flag: the moment was charged. Changing it recomputes accuracy.' },
306
348
  mood_valence: { type: 'number', description: 'How the moment felt, -1..1. Used as a retrieval lens; omitted keeps the stored value.' },
307
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 完全掌控。省略=保留原值。' },
308
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.' },
309
358
  },
310
359
  async (args, exec) => {
@@ -314,6 +363,8 @@ export function registerTools(ctx) {
314
363
  layer: args.layer, owner: args.owner, visibility: args.visibility,
315
364
  source: args.source, track: args.track, peak: args.peak,
316
365
  moodValence: args.mood_valence, arousal: args.arousal,
366
+ // undefined 就是"省略",必须原样传下去;换成 null 会把保留变成清除。
367
+ emotion: args.emotion, dominance: args.dominance,
317
368
  // Sentinel: absent means "leave the date as it is", '' means "clear it".
318
369
  salientDate: args.salient_date,
319
370
  })