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
@@ -2,7 +2,7 @@ import fs from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { dataDir, ensureDir, stamp, withFileLock } from './paths.js'
5
- import { getSettings } from './state.js'
5
+ import { getSettings, updateSettings } from './state.js'
6
6
  import { commit, retrieve } from './memu.js'
7
7
 
8
8
  export const MEMU_CONFIG_KEYS = [
@@ -108,9 +108,9 @@ export function getMemuConfig() {
108
108
  baseUrl,
109
109
  hasKey,
110
110
  providers: Object.keys(PROVIDER_ENV),
111
- // Derived, not a separate stored flag: a loopback base URL is what "local"
112
- // actually means to MemU, so the toggle can never drift from the config.
113
- source: baseUrl && /^https?:\/\/(127\.0\.0\.1|localhost)(:|\/)/.test(baseUrl) ? 'local' : 'remote',
111
+ // Source is a persisted route choice, not an inference from connection fields.
112
+ // Toggling vector mode must never rewrite a saved remote endpoint or key.
113
+ source: getSettings().vectorSource === 'api' ? 'api' : 'local',
114
114
  localBaseUrl: LOCAL_EMBED_BASE_URL,
115
115
  localModel: LOCAL_EMBED_MODEL,
116
116
  }
@@ -132,24 +132,12 @@ export function getSummaryConfig({ withKey = false } = {}) {
132
132
  return withKey ? { ...view, apiKey } : view
133
133
  }
134
134
 
135
- // Switching source rewrites the connection fields but never the API key: leaving a
136
- // third-party key in place after moving to a loopback endpoint would send it to a
137
- // local server, and clearing it on the way out would silently break a working setup.
138
- export function setEmbeddingSource(source, { keepModel = false } = {}) {
139
- const env = readMemuEnv()
140
- if (source === 'local') {
141
- env.MEMU_EMBED_PROVIDER = 'openai'
142
- env.MEMU_BASE_URL = String(env.MEMU_BASE_URL || '').includes('127.0.0.1') || String(env.MEMU_BASE_URL || '').includes('localhost')
143
- ? env.MEMU_BASE_URL
144
- : LOCAL_EMBED_BASE_URL
145
- if (!keepModel && !String(env.MEMU_EMBED_MODEL || '')) env.MEMU_EMBED_MODEL = LOCAL_EMBED_MODEL
146
- } else if (source === 'remote') {
147
- if (String(env.MEMU_BASE_URL || '') === LOCAL_EMBED_BASE_URL) delete env.MEMU_BASE_URL
148
- if (String(env.MEMU_EMBED_MODEL || '') === LOCAL_EMBED_MODEL) delete env.MEMU_EMBED_MODEL
149
- } else {
150
- throw new Error('source must be local or remote')
151
- }
152
- writeMemuEnv(env)
135
+ // Switch only the vector route. Connection fields belong to the selected profile and
136
+ // must survive a route toggle; callers may explicitly edit them through saveMemuConfig.
137
+ export function setEmbeddingSource(source) {
138
+ const normalized = source === 'remote' ? 'api' : source
139
+ if (normalized !== 'local' && normalized !== 'api') throw new Error('source must be local or api')
140
+ updateSettings({ vectorSource: normalized })
153
141
  return getMemuConfig()
154
142
  }
155
143
 
package/lib/memu.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { spawn } from 'node:child_process'
4
- import { dataDir, ensureDir, redactSecrets } from './paths.js'
4
+ import { dataDir, ensureDir, redactSecrets, windowsSystemEnv } from './paths.js'
5
5
  import { readCurrent, verifyEngine } from './engine.js'
6
6
  import { getSettings } from './state.js'
7
+ import { activeProfile, captureVectorGeneration, assertVectorGeneration, assertProfileVerified, assertMigrationProfile } from './vector-profile.js'
8
+ import { embeddingWorkerManager } from './local-embedding-manager.js'
7
9
 
8
10
  // MemU's own storage mode. 'local' keeps every row in this plugin's sqlite files;
9
11
  // 'cloud' hands storage to MemU's backend and needs the vendor credentials in
@@ -36,6 +38,7 @@ function runRaw(pythonArgs, { env, timeoutMs = 90_000, cwd } = {}) {
36
38
  const child = spawn(current.python, pythonArgs, {
37
39
  cwd,
38
40
  env: {
41
+ ...windowsSystemEnv(),
39
42
  ...process.env,
40
43
  PYTHONUTF8: '1',
41
44
  PYTHONIOENCODING: 'utf-8',
@@ -75,11 +78,31 @@ function runRaw(pythonArgs, { env, timeoutMs = 90_000, cwd } = {}) {
75
78
  })
76
79
  }
77
80
 
78
- function run(args, opts) {
79
- return runRaw(['-m', 'memu', ...args], opts)
81
+ async function runConfigured(args, options = {}) {
82
+ const route = options.route || (options.vectorProfile ? 'managed-local' : 'remote-api')
83
+ const useManagedLocal = route === 'managed-local'
84
+ const migration = options.vectorProfile ? assertMigrationProfile({ profile: options.vectorProfile, db: options.db }) : null
85
+ const generation = useManagedLocal && !options.vectorProfile && activeProfile() ? captureVectorGeneration() : null
86
+ if (generation && options.db && ![generation.global, ...Object.values(generation.libraries)].includes(options.db)) {
87
+ throw new MemuError('database does not belong to the captured vector generation', 'VECTOR_DB_MISMATCH')
88
+ }
89
+ const env = await envFor({ ...options, route })
90
+ if (generation) assertVectorGeneration(generation)
91
+ if (migration) assertMigrationProfile(migration)
92
+ const result = await runRaw(['-m', 'memu', ...args], { env, timeoutMs: options.timeoutMs })
93
+ if (generation) assertVectorGeneration(generation)
94
+ if (migration) assertMigrationProfile(migration)
95
+ return result
80
96
  }
81
97
 
82
- function envFor({ db, provider, embedModel, baseUrl, apiKey }) {
98
+ async function envFor({ db, provider, embedModel, baseUrl, apiKey, vectorProfile, route }) {
99
+ const profile = route === 'managed-local' ? (vectorProfile || activeProfile()) : null
100
+ if (profile) {
101
+ assertProfileVerified(profile)
102
+ const endpoint = await embeddingWorkerManager().acquire(profile)
103
+ return localEmbeddingEnv({ ...endpoint, db })
104
+ }
105
+ if (route === 'managed-local') throw new MemuError('managed local vector profile is not active', 'VECTOR_NOT_READY')
83
106
  const env = {}
84
107
  if (db) env.MEMU_DB = db
85
108
  if (provider) env.MEMU_EMBED_PROVIDER = provider
@@ -89,6 +112,26 @@ function envFor({ db, provider, embedModel, baseUrl, apiKey }) {
89
112
  return env
90
113
  }
91
114
 
115
+ // §14.3 strict-local embedding wiring. The plugin injects this into each MemU
116
+ // child instead of asking the user to export anything: the loopback endpoint, the
117
+ // per-process random token (env only, never argv/log/config) and the pinned
118
+ // embedding model id. Telemetry is switched off explicitly, because "local mode"
119
+ // alone is not proof that nothing leaves the machine.
120
+ export function localEmbeddingEnv({ baseUrl, token, model, db } = {}) {
121
+ const env = {
122
+ MEMU_MEMORY_MODE: 'local',
123
+ MEMU_EMBED_PROVIDER: 'openai',
124
+ MEMU_TELEMETRY: '0',
125
+ DO_NOT_TRACK: '1',
126
+ MEMU_EVENTS_BASE_URL: '',
127
+ }
128
+ if (typeof baseUrl === 'string' && baseUrl.trim()) env.MEMU_BASE_URL = baseUrl.trim()
129
+ if (typeof token === 'string' && token.trim()) env.MEMU_API_KEY = token.trim()
130
+ if (typeof model === 'string' && model.trim()) env.MEMU_EMBED_MODEL = model.trim()
131
+ if (typeof db === 'string' && db.trim()) env.MEMU_DB = db.trim()
132
+ return env
133
+ }
134
+
92
135
  export function parseJson(text) {
93
136
  const raw = String(text || '').trim()
94
137
  if (!raw) return null
@@ -101,14 +144,11 @@ export function parseJson(text) {
101
144
  return null
102
145
  }
103
146
 
104
- export async function retrieve({ query, db, provider, embedModel, baseUrl, apiKey, timeoutMs = 60_000 }) {
147
+ export async function retrieve({ query, db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs = 60_000 }) {
105
148
  if (!query || !String(query).trim()) {
106
149
  throw new MemuError('retrieve requires a non-empty query', 'MEMU_BAD_ARGUMENT')
107
150
  }
108
- const res = await run(['retrieve', String(query), '--json'], {
109
- env: envFor({ db, provider, embedModel, baseUrl, apiKey }),
110
- timeoutMs,
111
- })
151
+ const res = await runConfigured(['retrieve', String(query), '--json'], { db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs })
112
152
  const data = parseJson(res.stdout)
113
153
  if (data === null) {
114
154
  return { raw: res.stdout.trim(), parseWarning: 'memu retrieve returned non-JSON' }
@@ -116,11 +156,8 @@ export async function retrieve({ query, db, provider, embedModel, baseUrl, apiKe
116
156
  return data
117
157
  }
118
158
 
119
- export async function listFiles({ db, provider, embedModel, baseUrl, apiKey, timeoutMs = 60_000 } = {}) {
120
- const res = await run(['list-files', '--json'], {
121
- env: envFor({ db, provider, embedModel, baseUrl, apiKey }),
122
- timeoutMs,
123
- })
159
+ export async function listFiles({ db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs = 60_000 } = {}) {
160
+ const res = await runConfigured(['list-files', '--json'], { db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs })
124
161
  const data = parseJson(res.stdout)
125
162
  if (data === null) {
126
163
  return { recall_files: [], raw: res.stdout.trim(), parseWarning: 'memu list-files returned non-JSON' }
@@ -128,15 +165,12 @@ export async function listFiles({ db, provider, embedModel, baseUrl, apiKey, tim
128
165
  return data
129
166
  }
130
167
 
131
- export async function commit({ payload, db, provider, embedModel, baseUrl, apiKey, timeoutMs = 180_000 }) {
168
+ export async function commit({ payload, db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs = 180_000 }) {
132
169
  const tmpDir = ensureDir(path.join(dataDir(), 'tmp'))
133
170
  const file = path.join(tmpDir, `commit-${process.pid}-${Date.now()}.json`)
134
171
  try {
135
172
  fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8')
136
- const res = await run(['commit', file, '--json'], {
137
- env: envFor({ db, provider, embedModel, baseUrl, apiKey }),
138
- timeoutMs,
139
- })
173
+ const res = await runConfigured(['commit', file, '--json'], { db, provider, embedModel, baseUrl, apiKey, vectorProfile, route, timeoutMs })
140
174
  const data = parseJson(res.stdout)
141
175
  if (data === null) {
142
176
  return { raw: res.stdout.trim(), parseWarning: 'memu commit returned non-JSON' }
package/lib/mood.js CHANGED
@@ -1,66 +1,277 @@
1
- // Cheap, local, deterministic mood inference. No model call: the plugin needs a
2
- // mood signal every turn for mood-congruent retrieval, and a round trip per turn
1
+ // Cheap, local, deterministic affect inference. No model call: the plugin needs an
2
+ // affect signal every turn for affect-congruent retrieval, and a round trip per turn
3
3
  // would make injection slower than the memory it fetches.
4
4
  //
5
- // Scores are deliberately coarse (valence -1..1, arousal 0..1). A wrong read only
6
- // nudges recall ordering; it is never stored as a fact about the user.
7
-
8
- const VALENCE_WORDS = [
9
- [/开心|高兴|太好了|棒|喜欢|爱|满意|惊喜|顺利|爽|感谢|谢谢|amazing|great|happy|love|nice|awesome|thanks/i, 0.8],
10
- [/不错|还行|可以|有趣|好奇|舒服|ok|fine|good|interesting|curious/i, 0.4],
11
- [/有点|稍微|somewhat|kind of|a bit/i, 0.1],
12
- [/难受|难过|失望|烦|讨厌|累|困|郁闷|崩溃|糟糕|气|火|sad|upset|tired|annoying|awful|hate|frustrated|angry/i, -0.7],
13
- [/疼|痛|病|哭|害怕|慌|焦虑|压力|失眠|hurt|sick|cry|scared|anxious|stress|insomnia/i, -0.9],
14
- ]
15
-
16
- const AROUSAL_WORDS = [
17
- [/(!{2,}|[!?]{2,}|太|超|巨|特别|疯狂|爆炸|never|always|everything|nothing)/i, 0.8],
18
- [/必须|一定|马上|立刻|不能|别|停止|紧急|must|need|stop|urgent|now/i, 0.7],
19
- [/可能|也许|大概|想想|不确定|maybe|perhaps|might|wonder/i, 0.25],
20
- ]
5
+ // Model: four shares on the simplex (joy / anger / sorrow / fear) + two auxiliary
6
+ // axes — arousal (how loud) and dominance (who is in control). Shares answer "which
7
+ // feeling, in what proportion"; arousal answers "how loud"; dominance answers "who is
8
+ // steering". Normalising the shares throws intensity away, which is exactly why
9
+ // arousal has to stay a separate field rather than being folded into the four.
10
+ //
11
+ // Scores are deliberately coarse. A wrong read only nudges recall ordering; it is
12
+ // never stored as a fact about the user.
13
+
14
+ export const EMOTION_AXES = Object.freeze(['joy', 'anger', 'sorrow', 'fear'])
21
15
 
22
16
  // Mood fades back to neutral on its own, so an old read does not tint the whole
23
17
  // day's retrieval.
24
- const MOOD_HALF_LIFE_MINUTES = 90
18
+ export const MOOD_HALF_LIFE_MINUTES = 90
25
19
  // A resting level, not zero: a calm stretch reads as calm-and-alert, which is what
26
20
  // keeps the arousal term meaningful when nothing is happening.
27
21
  export const BASE_AROUSAL = 0.35
22
+ // Below this share no single axis is dominant enough to call the reading a mood.
23
+ export const EMOTION_MIN_SHARE = 0.35
24
+ // The composition stops being worth anything once the whole reading has faded this
25
+ // far; arousal and dominance keep decaying and are still reported.
26
+ export const EMOTION_FADE_FLOOR = 0.35
27
+ // How much anger-vs-fear moves dominance on its own. Anger carries agency, fear does
28
+ // not, and neither phrase usually contains a control word.
29
+ export const DOMINANCE_PRIOR_WEIGHT = 0.4
30
+ // Every axis projects onto the legacy valence with the same magnitude; only joy is
31
+ // positive. Anger is *not* a mild positive — it pulls the projection down as hard as
32
+ // sorrow does.
33
+ export const VALENCE_SCALE = 0.8
34
+
35
+ const NEGATION_AS_SAD = /不开心|不高兴|不喜欢|不满意|不顺利|not\s+happy|not\s+good|don't\s+like/gi
36
+ const NEGATION_TO_NEUTRAL = /不难过|不害怕|不焦虑|不生气|not\s+sad|not\s+afraid|not\s+anxious/gi
37
+
38
+ const AXIS_PATTERNS = Object.freeze({
39
+ joy: [
40
+ [/开心|高兴|太好了|喜欢|爱你|满意|惊喜|谢谢|感谢|期待|幸福|欣慰|兴奋|激动|雀跃/, 0.9],
41
+ [/不错|还行|顺利|爽|安心|踏实|棒|有趣|舒服/, 0.6],
42
+ ],
43
+ anger: [
44
+ [/气死|烦死|受不了|离谱|凭什么|恨|火大|欺人太甚/, 0.9],
45
+ [/生气|气|烦|讨厌|恶心|无语|恼火|不满|angry|annoying|hate|furious/, 0.6],
46
+ ],
47
+ sorrow: [
48
+ [/崩溃|难受|心疼|失望透顶|想哭|哭|心碎/, 0.9],
49
+ // `sad` is not decoration: the negation rules below rewrite 不开心/不高兴 into the
50
+ // word `sad`, so dropping English here silently turns a negated positive into a
51
+ // neutral reading instead of a negative one.
52
+ [/难过|失望|委屈|郁闷|累|困|没意思|算了|沮丧|孤独|sad|unhappy|upset|tired|miserable/, 0.6],
53
+ ],
54
+ fear: [
55
+ [/吓死|害怕|恐慌|焦虑|绝望|惊恐|发抖/, 0.9],
56
+ [/担心|紧张|慌|不敢|万一|怕|不安|忐忑|afraid|scared|anxious|worried|nervous/, 0.6],
57
+ ],
58
+ })
59
+
60
+ // Compound expressions are mixtures, not new axes. Each entry is a named point on the
61
+ // simplex plus the dominance that usually comes with it.
62
+ export const COMPOUND_PRESETS = Object.freeze({
63
+ 委屈: Object.freeze({ emotion: Object.freeze({ joy: 0, anger: 0.3, sorrow: 0.7, fear: 0 }), dominance: -0.4 }),
64
+ 尴尬: Object.freeze({ emotion: Object.freeze({ joy: 0, anger: 0, sorrow: 0.5, fear: 0.5 }), dominance: -0.5 }),
65
+ 嫉妒: Object.freeze({ emotion: Object.freeze({ joy: 0, anger: 0.5, sorrow: 0.3, fear: 0.2 }), dominance: -0.1 }),
66
+ 心疼: Object.freeze({ emotion: Object.freeze({ joy: 0.2, anger: 0, sorrow: 0.8, fear: 0 }), dominance: -0.2 }),
67
+ 不甘: Object.freeze({ emotion: Object.freeze({ joy: 0, anger: 0.6, sorrow: 0.4, fear: 0 }), dominance: 0.4 }),
68
+ })
69
+
70
+ const COMPOUND_PATTERN = new RegExp(Object.keys(COMPOUND_PRESETS).join('|'))
71
+ const HEDGE_PATTERN = /有点|稍微|一点点|a bit|somewhat/i
72
+ // "一点都不好" is an intensifier, not a hedge: it must never damp what follows.
73
+ const HEDGE_SKEW_GUARD = /一点都|一点也不|一点也没|一点没有/
74
+ const INTENSITY_PATTERN = /死|疯了|极了|受不了|爆|炸|太|超|巨|特别|疯狂/
75
+ const REPEATED_PUNCT = /[!!??]{2,}/
76
+
77
+ const HIGH_DOMINANCE = /凭什么|我决定|不许|听我的|我不在乎|随便你|必须|轮不到|说了算|懒得|我才不/g
78
+ const LOW_DOMINANCE = /不敢|没办法|只能|求|拜托|对不起|是我错|控制不住|万一|怎么办|逃|来不及|都怪我/g
79
+
80
+ const CLAUSE_SPLIT = /([,。!?;,.!?;]+)/
81
+
82
+ function clamp(value, min, max) {
83
+ return Math.max(min, Math.min(max, value))
84
+ }
85
+
86
+ /**
87
+ * Normalise a four-axis composition.
88
+ *
89
+ * Returns null for "no mood". An explicit all-zero object normalises to null too: it
90
+ * is the tool-friendly way of saying neutral, not a reading where all four are faintly
91
+ * present. Anything malformed is rejected instead of being repaired — silently
92
+ * clamping a negative share into a legal one would hide a caller bug forever.
93
+ */
94
+ export function normalizeEmotion(raw) {
95
+ if (raw === null || raw === undefined) return null
96
+ if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('emotion must be an object with joy, anger, sorrow, fear')
97
+ const values = {}
98
+ for (const axis of EMOTION_AXES) {
99
+ if (!Object.prototype.hasOwnProperty.call(raw, axis)) throw new Error('emotion is missing the ' + axis + ' share')
100
+ const value = raw[axis]
101
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('emotion.' + axis + ' must be a finite number')
102
+ if (value < 0) throw new Error('emotion.' + axis + ' must not be negative')
103
+ values[axis] = value
104
+ }
105
+ const sum = EMOTION_AXES.reduce((total, axis) => total + values[axis], 0)
106
+ if (!(sum > 0)) return null
107
+ const normalized = {}
108
+ for (const axis of EMOTION_AXES) normalized[axis] = values[axis] / sum
109
+ return normalized
110
+ }
111
+
112
+ /** The legacy single-number projection. Joy is the only axis that points up. */
113
+ export function derivedValence(emotion) {
114
+ if (!emotion) return 0
115
+ const raw = VALENCE_SCALE * (emotion.joy - emotion.anger - emotion.sorrow - emotion.fear)
116
+ return clamp(Number.isFinite(raw) ? raw : 0, -1, 1)
117
+ }
118
+
119
+ /** The largest single share; 0 for a neutral reading. */
120
+ export function maxShare(emotion) {
121
+ if (!emotion) return 0
122
+ return EMOTION_AXES.reduce((best, axis) => Math.max(best, Number(emotion[axis]) || 0), 0)
123
+ }
124
+
125
+ /** How much of the present composition a memory still shares. 0..1, symmetric. */
126
+ export function overlap(left, right) {
127
+ if (!left || !right) return 0
128
+ let total = 0
129
+ for (const axis of EMOTION_AXES) {
130
+ const a = Number(left[axis]) || 0
131
+ const b = Number(right[axis]) || 0
132
+ if (a > 0 && b > 0) total += Math.min(a, b)
133
+ }
134
+ return clamp(total, 0, 1)
135
+ }
136
+
137
+ /**
138
+ * Dominance similarity, mapped to a bounded multiplier. A missing or illegal value on
139
+ * either side is "no signal", never NaN: callers multiply by this result directly.
140
+ */
141
+ export function dominanceBias(entryDominance, contextDominance) {
142
+ // Number(null) is 0, so a missing value must be rejected before coercion — otherwise
143
+ // "we do not know" quietly becomes "slightly in control" and still moves the ranking.
144
+ if (entryDominance === null || entryDominance === undefined) return 1
145
+ if (contextDominance === null || contextDominance === undefined) return 1
146
+ const a = Number(entryDominance)
147
+ const b = Number(contextDominance)
148
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return 1
149
+ return 1 + 0.2 * (0.5 - Math.abs(clamp(a, -1, 1) - clamp(b, -1, 1)) / 2)
150
+ }
151
+
152
+ /**
153
+ * Whether the affect terms are worth applying at all. Three independent legs: a clear
154
+ * composition, an arousal away from rest, or a clear sense of control. A null
155
+ * composition only closes the first leg.
156
+ */
157
+ export function emotionActive({ emotion = null, arousal = BASE_AROUSAL, dominance = 0 } = {}) {
158
+ if (emotion !== null && emotion !== undefined && maxShare(emotion) >= EMOTION_MIN_SHARE) return true
159
+ const arousalValue = Number(arousal)
160
+ if (Number.isFinite(arousalValue) && Math.abs(arousalValue - BASE_AROUSAL) >= 0.1) return true
161
+ const dominanceValue = Number(dominance)
162
+ if (Number.isFinite(dominanceValue) && Math.abs(dominanceValue) >= 0.25) return true
163
+ return false
164
+ }
28
165
 
166
+ function clausesOf(source) {
167
+ return String(source || '')
168
+ .split(CLAUSE_SPLIT)
169
+ .map((part) => part.trim())
170
+ .filter((part) => part && !/^[,。!?;,.!?;]+$/.test(part))
171
+ }
172
+
173
+ function compoundIn(clause) {
174
+ const hit = COMPOUND_PATTERN.exec(clause)
175
+ return hit ? COMPOUND_PRESETS[hit[0]] : null
176
+ }
177
+
178
+ function axisHits(clause) {
179
+ const hits = {}
180
+ for (const axis of EMOTION_AXES) {
181
+ let best = 0
182
+ for (const [pattern, weight] of AXIS_PATTERNS[axis]) {
183
+ if (pattern.test(clause)) best = Math.max(best, weight)
184
+ }
185
+ if (best > 0) hits[axis] = best
186
+ }
187
+ return hits
188
+ }
189
+
190
+ /**
191
+ * Read the present affect state out of one user turn.
192
+ *
193
+ * Kept text-only and deterministic. `.valence` stays on the return value because the
194
+ * legacy retrieval path and its tests read it directly; `emotion`/`dominance` are
195
+ * additive, so a caller that only knows about valence keeps working.
196
+ */
29
197
  export function readMood(text) {
30
198
  const source = String(text || '')
31
199
  .replace(/["“「][^"”」]*["”」]/g, '')
32
- .replace(/不开心|不高兴|不喜欢|不满意|not\s+happy|not\s+good|don't\s+like/gi, ' sad ')
33
- .replace(/不难过|不害怕|不焦虑|not\s+sad|not\s+afraid/gi, '')
34
- let valence = 0
35
- for (const [pattern, weight] of VALENCE_WORDS) {
36
- if (pattern.test(source)) valence = valence === 0 ? weight : (valence + weight) / 2
37
- }
38
- let arousal = 0.35
39
- for (const [pattern, weight] of AROUSAL_WORDS) {
40
- if (pattern.test(source)) arousal = Math.max(arousal, weight)
200
+ .replace(NEGATION_AS_SAD, ' sad ')
201
+ .replace(NEGATION_TO_NEUTRAL, '')
202
+ const clauses = clausesOf(source)
203
+
204
+ const raw = { joy: 0, anger: 0, sorrow: 0, fear: 0 }
205
+ let compoundDominance = null
206
+ let peakIntensity = 0
207
+ let hedged = false
208
+
209
+ for (const clause of clauses) {
210
+ const hedge = HEDGE_PATTERN.test(clause) && !HEDGE_SKEW_GUARD.test(clause)
211
+ if (hedge) hedged = true
212
+ const scale = hedge ? 0.6 : 1
213
+ const compound = compoundIn(clause)
214
+ if (compound) {
215
+ for (const axis of EMOTION_AXES) raw[axis] += compound.emotion[axis] * scale
216
+ if (compoundDominance === null) compoundDominance = compound.dominance
217
+ } else {
218
+ const hits = axisHits(clause)
219
+ for (const axis of EMOTION_AXES) if (hits[axis]) raw[axis] += hits[axis] * scale
220
+ }
221
+ if (INTENSITY_PATTERN.test(clause) || REPEATED_PUNCT.test(clause)) peakIntensity = Math.max(peakIntensity, 0.75)
222
+ if (REPEATED_PUNCT.test(clause)) peakIntensity = Math.max(peakIntensity, 0.8)
41
223
  }
224
+
225
+ for (const axis of EMOTION_AXES) raw[axis] = Math.min(1, raw[axis])
226
+ const emotion = normalizeEmotion(raw)
227
+
228
+ let arousal = BASE_AROUSAL
229
+ if (emotion !== null) arousal = Math.max(arousal, peakIntensity)
42
230
  if (source.length > 600) arousal = Math.max(0.2, arousal - 0.15)
231
+
232
+ const high = (source.match(HIGH_DOMINANCE) || []).length
233
+ const low = (source.match(LOW_DOMINANCE) || []).length
234
+ const lexical = clamp(0.5 * (high - low), -1, 1)
235
+ const prior = emotion === null ? 0 : DOMINANCE_PRIOR_WEIGHT * (emotion.anger - emotion.fear)
236
+ const dominance = clamp(lexical + prior + (compoundDominance === null ? 0 : compoundDominance * 0.5), -1, 1)
237
+
43
238
  return {
44
- valence: Math.max(-1, Math.min(1, valence)),
45
- arousal: Math.max(0, Math.min(1, arousal)),
239
+ valence: derivedValence(emotion),
240
+ emotion,
241
+ arousal: clamp(arousal, 0, 1),
242
+ dominance: hedged ? clamp(dominance * 0.8, -1, 1) : dominance,
46
243
  }
47
244
  }
48
245
 
246
+ /**
247
+ * Fade a reading toward neutral.
248
+ *
249
+ * The composition does not decay proportionally — proportions sum to 1, so scaling
250
+ * them changes nothing. What fades is how much of it is left: arousal returns to rest,
251
+ * dominance returns to zero, and once the whole reading has faded past the floor the
252
+ * composition is dropped. Arousal and dominance keep decaying past that point.
253
+ */
49
254
  export function moodDecay(mood, now = Date.now()) {
50
- if (!mood || typeof mood.at !== 'number') return { valence: 0, arousal: BASE_AROUSAL }
51
- const minutes = Math.max(0, (now - mood.at) / 60_000)
52
- const fade = Math.pow(0.5, minutes / MOOD_HALF_LIFE_MINUTES)
53
- const arousal = Number(mood.arousal)
255
+ const hasStamp = Boolean(mood) && typeof mood.at === 'number'
256
+ const minutes = hasStamp ? Math.max(0, (now - mood.at) / 60_000) : 0
257
+ const fade = hasStamp ? Math.pow(0.5, minutes / MOOD_HALF_LIFE_MINUTES) : 0
258
+
259
+ const emotion = hasStamp && mood.emotion ? mood.emotion : null
260
+ const keptEmotion = fade < EMOTION_FADE_FLOOR ? null : emotion
261
+ const arousalValue = Number(mood && mood.arousal)
262
+ const dominanceValue = Number(mood && mood.dominance)
263
+ const legacyValence = Number(mood && mood.valence)
264
+
54
265
  return {
55
- valence: Number(mood.valence || 0) * fade,
56
- // Arousal relaxes back to the baseline instead of collapsing to zero, so a
57
- // calm stretch still reads as calm-but-alert rather than flat.
58
- arousal: BASE_AROUSAL + ((Number.isFinite(arousal) ? arousal : BASE_AROUSAL) - BASE_AROUSAL) * fade,
266
+ valence: (keptEmotion ? derivedValence(keptEmotion) : (Number.isFinite(legacyValence) ? legacyValence : 0)) * (hasStamp ? fade : 0),
267
+ emotion: keptEmotion,
268
+ arousal: BASE_AROUSAL + ((Number.isFinite(arousalValue) ? arousalValue : BASE_AROUSAL) - BASE_AROUSAL) * fade,
269
+ dominance: (Number.isFinite(dominanceValue) ? dominanceValue : 0) * fade,
59
270
  }
60
271
  }
61
272
 
62
273
  // Congruence is |a·b| over signed valence: sad recalls sad, happy recalls happy,
63
- // and a neutral present state recalls nothing extra.
274
+ // and a neutral present state recalls nothing extra. Kept for the legacy path.
64
275
  export function moodCongruence(entryValence, currentValence) {
65
276
  const a = Number(entryValence || 0)
66
277
  const b = Number(currentValence || 0)
@@ -69,3 +280,21 @@ export function moodCongruence(entryValence, currentValence) {
69
280
  const agreement = Math.min(Math.abs(a), Math.abs(b))
70
281
  return same ? agreement : -agreement * 0.5
71
282
  }
283
+
284
+ export default {
285
+ EMOTION_AXES,
286
+ MOOD_HALF_LIFE_MINUTES,
287
+ BASE_AROUSAL,
288
+ EMOTION_MIN_SHARE,
289
+ EMOTION_FADE_FLOOR,
290
+ COMPOUND_PRESETS,
291
+ normalizeEmotion,
292
+ derivedValence,
293
+ maxShare,
294
+ overlap,
295
+ dominanceBias,
296
+ emotionActive,
297
+ readMood,
298
+ moodDecay,
299
+ moodCongruence,
300
+ }
package/lib/paths.js CHANGED
@@ -243,6 +243,30 @@ export function stamp() {
243
243
  return new Date().toISOString()
244
244
  }
245
245
 
246
+ /**
247
+ * Windows 子进程的系统变量补齐。
248
+ *
249
+ * 宿主在工具化执行里把 `process.env` 清空后(docs/env-and-commands.md §9.1),注入进程的
250
+ * 组件(输入法云缓存、DirectWrite 字体缓存)用 `%SystemDrive%\ProgramData\...` 拼路径时会
251
+ * 展开失败,于是整串被当成相对路径写进**当前工作目录** —— 仓库里出现过字面量
252
+ * `%SystemDrive%/ProgramData/...` 就是这么来的。
253
+ *
254
+ * 只补缺失的键:宿主给了正确的值就绝不覆盖。返回的是 overlay,调用方按
255
+ * `{ ...overlay, ...process.env, ...explicit }` 的顺序合并。
256
+ */
257
+ export function windowsSystemEnv(platform = process.platform, env = process.env) {
258
+ if (platform !== 'win32') return {}
259
+ const source = env || {}
260
+ const drive = String(source.SystemDrive || source.HOMEDRIVE || 'C:')
261
+ const root = String(source.SystemRoot || source.windir || source.WINDIR || (drive.replace(/\\$/, '') + '\\Windows')).replace(/\\/g, '/')
262
+ const overlay = {}
263
+ if (!source.SystemDrive) overlay.SystemDrive = drive
264
+ if (!source.SystemRoot) overlay.SystemRoot = root
265
+ if (!source.windir && !source.WINDIR) overlay.windir = root
266
+ if (!source.PATH && !source.Path) overlay.PATH = root + '/system32'
267
+ return overlay
268
+ }
269
+
246
270
  export function safeSegment(value, fallback = 'unknown') {
247
271
  const text = String(value ?? '').trim()
248
272
  if (!text) return fallback
@@ -76,7 +76,7 @@ export function attachPresetAgents(ctx, { log = () => {}, attached = attachments
76
76
  seen.add(actx)
77
77
  if (!attached.has(actx) && !sameScopeTree(actx, ctx)) {
78
78
  try {
79
- const dispose = registerInjection(actx, { guide: false, guideMessage: true })
79
+ const dispose = registerInjection(actx)
80
80
  attached.set(actx, dispose)
81
81
  added += 1
82
82
  log(`attached memory injection to preset conversation ${sessionId}`)