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
@@ -0,0 +1,129 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ const { ACTIVE_MEMORY_POLICY, assessWake, collectEvidence, scoreEvidence, suppressionReason } = await import('../lib/active-memory.js')
5
+
6
+ // 2026-10-30 的现场:长会话里连续触发整理,每次都把整段转录种进 fork,重复付 token。
7
+ // 这组用例钉住"什么时候值得唤醒"和"什么时候必须抑制"。
8
+
9
+ const human = text => ({ type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } })
10
+ const pluginNotice = text => ({ type: 'user/message', data: { source: { kind: 'plugin:other' }, content: [{ type: 'text', text }] } })
11
+ const assistant = (blocks) => ({ type: 'assistant/message', data: { message: { content: blocks } } })
12
+ const toolResult = (text, extra = {}) => ({ type: 'tool/result', data: { message: { isError: false, content: [{ type: 'text', text }], ...extra } } })
13
+
14
+ test('evidence only counts what a person actually typed, plus deduped work', () => {
15
+ const evidence = collectEvidence([
16
+ human('帮我看一下这个 fork 为什么建不起来'),
17
+ pluginNotice('(插件提示:本轮召回 3 条)'),
18
+ assistant([{ type: 'text', text: '看到了,是模型路由没继承。' }, { type: 'tool-call', name: 'read' }]),
19
+ toolResult('文件内容 A'.repeat(100)),
20
+ toolResult('文件内容 A'.repeat(100)),
21
+ toolResult(' '),
22
+ { type: 'tool/result', data: { message: { isError: true, content: [{ type: 'text', text: 'boom' }] } } },
23
+ ])
24
+ assert.equal(evidence.humanTurns, 1)
25
+ assert.equal(evidence.toolCalls, 1)
26
+ assert.equal(evidence.toolUnits, 1, 'polling repeats and error results are not work units')
27
+ assert.ok(evidence.toolChars > 0 && evidence.toolChars <= ACTIVE_MEMORY_POLICY.toolResultCharsCap)
28
+ assert.ok(evidence.assistantChars > 0)
29
+ })
30
+
31
+ test('each score part is capped so tool spam cannot reach the threshold alone', () => {
32
+ const manyTools = { humanTurns: 0, humanChars: 0, assistantChars: 0, toolChars: 200_000, toolUnits: 500 }
33
+ const scored = scoreEvidence(manyTools)
34
+ assert.equal(scored.parts.tool, ACTIVE_MEMORY_POLICY.partCap)
35
+ const absent = assessWake({ evidence: manyTools })
36
+ assert.equal(absent.wake, false)
37
+ assert.equal(absent.reason, 'no-human-turn')
38
+ })
39
+
40
+ test('a real long turn clears the threshold, a greeting does not', () => {
41
+ const longTurn = collectEvidence([
42
+ human('把 managed vector 的切换重建链路再核一遍,尤其是回读和失败回滚'),
43
+ assistant([{ type: 'text', text: '结论:回读缺失会导致整代重建作废。'.repeat(40) }, { type: 'tool-call', name: 'read' }]),
44
+ toolResult('daemon 日志与调用链摘要'.repeat(80)),
45
+ toolResult('测试输出摘要'.repeat(80)),
46
+ toolResult('配置摘要'.repeat(40)),
47
+ toolResult('失败明细样例'.repeat(60)),
48
+ toolResult('迁移账本摘要'.repeat(60)),
49
+ human('继续,把失败明细也加上'),
50
+ toolResult('权限矩阵摘要'.repeat(50)),
51
+ toolResult('重建进度样例'.repeat(50)),
52
+ human('再补一版日志页的筛选项'),
53
+ ])
54
+ const wake = assessWake({ evidence: longTurn })
55
+ assert.equal(wake.wake, true)
56
+ assert.ok(wake.score >= ACTIVE_MEMORY_POLICY.minScore)
57
+
58
+ const greeting = assessWake({ evidence: collectEvidence([human('早'), assistant([{ type: 'text', text: '早!' }])]) })
59
+ assert.equal(greeting.wake, false)
60
+ assert.equal(greeting.reason, 'low-score')
61
+ })
62
+
63
+ test('starvation wakes a quiet-but-long small session, not a large transcript', () => {
64
+ const thin = collectEvidence([human('嗯,继续')])
65
+ assert.equal(assessWake({ evidence: thin }).wake, false)
66
+ const starved = assessWake({ evidence: thin, starved: true })
67
+ assert.equal(starved.wake, true)
68
+ assert.equal(starved.reason, 'starved')
69
+ // 真机会话 5913 条事件:fork 要把整段转录重新种一遍,薄回合不能靠饥饿放行。
70
+ const largeThin = assessWake({ evidence: thin, starved: true, transcriptEvents: 5913 })
71
+ assert.equal(largeThin.wake, false)
72
+ assert.equal(largeThin.reason, 'large-transcript-thin')
73
+ assert.equal(assessWake({ evidence: thin, starved: false, transcriptEvents: 5913 }).reason, 'low-score')
74
+ })
75
+
76
+ test('a dense turn still wakes a large transcript on merit', () => {
77
+ const dense = collectEvidence([
78
+ human('把这次重建失败的原因和回滚步骤记下来'),
79
+ assistant([{ type: 'text', text: '根因是提交载荷少了 recall_files。'.repeat(300) }, { type: 'tool-call', name: 'read' }]),
80
+ ...Array.from({ length: 20 }, (_, i) => toolResult(('回读失败明细 ' + i + ' ').repeat(200))),
81
+ human('还有迁移账本和失败明细'),
82
+ human('最后给我一版回滚步骤'),
83
+ ])
84
+ const wake = assessWake({ evidence: dense, starved: true, transcriptEvents: 5913 })
85
+ assert.equal(wake.wake, true)
86
+ assert.equal(wake.reason, 'score')
87
+ assert.ok(wake.score >= ACTIVE_MEMORY_POLICY.minScore)
88
+ assert.equal(ACTIVE_MEMORY_POLICY.largeTranscriptEvents, 2000, 'threshold is part of the calibrated policy')
89
+ })
90
+
91
+ test('recent consolidation or a recent recall suppresses the idle fork', () => {
92
+ const now = Date.parse('2026-10-30T05:00:00.000Z')
93
+ assert.equal(suppressionReason({ now, lastSleepAt: '2026-10-30T04:50:00.000Z' }), 'recent-consolidation')
94
+ assert.equal(suppressionReason({ now, lastInjectedAt: '2026-10-30T04:55:00.000Z' }), 'recent-recall')
95
+ assert.equal(suppressionReason({ now, lastSleepAt: '2026-10-30T03:00:00.000Z', lastInjectedAt: '2026-10-30T04:00:00.000Z' }), null)
96
+ assert.equal(suppressionReason({ now, lastSleepAt: '2026-10-30T04:50:00.000Z', consolidatedCooldownMinutes: 5 }), null, 'cooldown is configurable')
97
+ // 没有时间戳 = 从未整理/注入,不抑制。
98
+ assert.equal(suppressionReason({ now }), null)
99
+ })
100
+
101
+ test('a large unconsolidated backlog wakes even when the turn itself is thin', () => {
102
+ // 守卫的理由是「为一句轻话重种整段转录不划算」。但当整段转录本身还没整理过,
103
+ // 那不是重种,是欠着:没有积压上界,一个 20k 事件的会话只要每轮都说「继续」,
104
+ // 它的早期决定就永远进不了记忆——延迟没有上界,等于丢失。
105
+ const thin = collectEvidence([
106
+ human('继续'),
107
+ assistant([{ type: 'text', text: '还在跑。' }]),
108
+ ])
109
+ const blocked = assessWake({ evidence: thin, starved: true, transcriptEvents: 20_000, pendingEvents: 0 })
110
+ assert.equal(blocked.wake, false)
111
+ assert.equal(blocked.reason, 'large-transcript-thin')
112
+
113
+ const owed = assessWake({ evidence: thin, starved: true, transcriptEvents: 20_000, pendingEvents: 900 })
114
+ assert.equal(owed.wake, true)
115
+ assert.equal(owed.reason, 'backlog')
116
+
117
+ // 边界:正好达到上界就放行,且没有真人回合时积压不构成理由(那是 no-human-turn)。
118
+ assert.equal(assessWake({ evidence: thin, starved: true, transcriptEvents: 20_000, pendingEvents: 600 }).reason, 'backlog')
119
+ assert.equal(assessWake({ evidence: thin, starved: true, transcriptEvents: 20_000, pendingEvents: 599 }).reason, 'large-transcript-thin')
120
+ const noHuman = collectEvidence([assistant([{ type: 'text', text: '还在跑。' }])])
121
+ assert.equal(assessWake({ evidence: noHuman, starved: true, transcriptEvents: 20_000, pendingEvents: 900 }).reason, 'no-human-turn')
122
+ })
123
+
124
+ test('the backlog bound stays out of the way for a session that is already up to date', () => {
125
+ const thin = collectEvidence([human('继续'), assistant([{ type: 'text', text: '好。' }])])
126
+ // 已整理到最新:待整理区间很小,规则回到原来的分数/饥饿判定。
127
+ assert.equal(assessWake({ evidence: thin, starved: false, transcriptEvents: 20_000, pendingEvents: 3 }).wake, false)
128
+ assert.equal(assessWake({ evidence: thin, starved: true, transcriptEvents: 900, pendingEvents: 3 }).reason, 'starved')
129
+ })
@@ -0,0 +1,126 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-activity-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+ process.env.DSH_MEMU_MEMORY_HOME = home
10
+
11
+ const activity = await import('../lib/activity-log.js')
12
+ const paths = await import('../lib/paths.js')
13
+
14
+ test.after(() => {
15
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
16
+ })
17
+
18
+ test.beforeEach(() => {
19
+ activity.clearEvents()
20
+ activity.resetActivityLogCache()
21
+ paths.setWarnSink(null)
22
+ })
23
+
24
+ test('records newest first and persists a JSONL line', () => {
25
+ activity.record({ kind: 'recall', scope: 'private', sessionId: 's1', libraryId: 'lib', message: 'first' })
26
+ activity.record({ kind: 'write', sessionId: 's1', message: 'second', detail: { ids: ['mem-1'] }, refs: ['mem-1'] })
27
+ const rows = activity.listEvents()
28
+ assert.deepEqual(rows.map(row => row.message), ['second', 'first'])
29
+ assert.deepEqual(rows[0].refs, ['mem-1'])
30
+ const file = path.join(home, 'logs', 'activity.jsonl')
31
+ assert.ok(fs.existsSync(file))
32
+ assert.equal(fs.readFileSync(file, 'utf8').trim().split('\n').length, 2)
33
+ })
34
+
35
+ test('filters by kind, session, scope and free text', () => {
36
+ activity.record({ kind: 'recall', scope: 'private', sessionId: 'a', message: 'recall a', detail: { route: 'managed-vector-rrf' } })
37
+ activity.record({ kind: 'recall', scope: 'global', sessionId: 'b', message: 'recall b', detail: { route: 'local' } })
38
+ activity.record({ kind: 'write', scope: 'private', sessionId: 'a', message: 'write a' })
39
+ assert.equal(activity.listEvents({ kind: 'recall' }).length, 2)
40
+ assert.equal(activity.listEvents({ kind: 'recall', sessionId: 'b' }).length, 1)
41
+ assert.equal(activity.listEvents({ scope: 'private' }).length, 2)
42
+ assert.equal(activity.listEvents({ q: 'managed-vector' }).length, 1)
43
+ assert.equal(activity.listEvents({ q: 'nothing-here' }).length, 0)
44
+ })
45
+
46
+ test('secrets are redacted before they reach the file', () => {
47
+ activity.record({
48
+ kind: 'error',
49
+ message: 'failed with sk-abcdefghijklmnop and Bearer abcdefghijklmnop',
50
+ detail: { apiKey: 'sk-zyxwvutsrqponmlk', note: 'Authorization: Bearer abcdefghijklmnopqrst' },
51
+ })
52
+ const [row] = activity.listEvents()
53
+ const text = JSON.stringify(row)
54
+ assert.doesNotMatch(text, /sk-abcdefghijklmnop/)
55
+ assert.doesNotMatch(text, /Bearer abcdefghijklmnop/)
56
+ assert.match(text, /REDACTED/)
57
+ })
58
+
59
+ test('long messages and details are clipped instead of bloating the log', () => {
60
+ activity.record({ kind: 'write', message: 'x'.repeat(5000), detail: { body: 'y'.repeat(9000) } })
61
+ const [row] = activity.listEvents()
62
+ assert.ok(row.message.length <= 300)
63
+ assert.ok(JSON.stringify(row.detail).length <= 4001)
64
+ })
65
+
66
+ test('a corrupt log starts empty and leaves a warning instead of throwing', () => {
67
+ activity.clearEvents()
68
+ const file = path.join(home, 'logs', 'activity.jsonl')
69
+ fs.mkdirSync(path.dirname(file), { recursive: true })
70
+ fs.writeFileSync(file, '{not json\n', 'utf8')
71
+ activity.resetActivityLogCache()
72
+ const warnings = []
73
+ paths.setWarnSink(message => warnings.push(String(message)))
74
+ assert.deepEqual(activity.listEvents(), [])
75
+ assert.equal(warnings.length, 0, 'a bad line is skipped silently, not treated as an error')
76
+ const entry = activity.record({ kind: 'recall', message: 'after corruption' })
77
+ assert.ok(entry)
78
+ assert.equal(activity.listEvents()[0].message, 'after corruption')
79
+ })
80
+
81
+ test('an unwritable directory degrades to memory-only and warns once', () => {
82
+ activity.clearEvents()
83
+ const warnings = []
84
+ paths.setWarnSink(message => warnings.push(String(message)))
85
+ const original = fs.appendFileSync
86
+ fs.appendFileSync = () => { throw Object.assign(new Error('EACCES: fixture'), { code: 'EACCES' }) }
87
+ try {
88
+ activity.record({ kind: 'write', message: 'one' })
89
+ activity.record({ kind: 'write', message: 'two' })
90
+ } finally {
91
+ fs.appendFileSync = original
92
+ }
93
+ assert.deepEqual(activity.listEvents().map(row => row.message), ['two', 'one'], 'memory keeps working')
94
+ assert.equal(warnings.filter(message => /activity log could not be written/.test(message)).length, 1)
95
+ })
96
+
97
+ test('clear empties both the cache and the file; export emits JSONL', () => {
98
+ activity.record({ kind: 'write', message: 'a' })
99
+ activity.record({ kind: 'write', message: 'b' })
100
+ const text = activity.exportEvents()
101
+ assert.equal(text.split('\n').length, 2)
102
+ for (const line of text.split('\n')) JSON.parse(line)
103
+ activity.clearEvents()
104
+ assert.deepEqual(activity.listEvents(), [])
105
+ const file = path.join(home, 'logs', 'activity.jsonl')
106
+ assert.equal(fs.readFileSync(file, 'utf8'), '')
107
+ })
108
+
109
+ test('the ring keeps the newest entries and reports its own size', () => {
110
+ for (let index = 0; index < 30; index += 1) activity.record({ kind: 'recall', message: 'row ' + index })
111
+ const rows = activity.listEvents({ limit: 5 })
112
+ assert.deepEqual(rows.map(row => row.message), ['row 29', 'row 28', 'row 27', 'row 26', 'row 25'])
113
+ const stats = activity.logStats()
114
+ assert.equal(stats.total, 30)
115
+ assert.ok(stats.bytes > 0)
116
+ assert.equal(stats.file, path.join(home, 'logs', 'activity.jsonl'))
117
+ })
118
+
119
+ test('a level is inferred from ok=false and kept when explicit', () => {
120
+ activity.record({ kind: 'consolidate', ok: false, message: 'timed out' })
121
+ activity.record({ kind: 'consolidate', ok: true, level: 'error', message: 'explicit' })
122
+ const rows = activity.listEvents({ kind: 'consolidate' })
123
+ assert.equal(rows[0].level, 'error')
124
+ assert.equal(rows[1].level, 'warn')
125
+ assert.equal(activity.listEvents({ level: 'warn' }).length, 1)
126
+ })
@@ -0,0 +1,103 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-wiring-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+ process.env.DSH_MEMU_MEMORY_HOME = home
10
+
11
+ const state = await import('../lib/state.js')
12
+ const memory = await import('../lib/memory.js')
13
+ const inject = await import('../lib/inject.js')
14
+ const activity = await import('../lib/activity-log.js')
15
+ const { requestPrivateConsolidation } = await import('../lib/private.js')
16
+ const { record } = activity
17
+
18
+ test.after(() => {
19
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
20
+ })
21
+
22
+ test.beforeEach(() => {
23
+ activity.clearEvents()
24
+ state.updateSettings({ memoryBackend: 'agent', vectorSource: 'local', relevanceThreshold: 0, sleepConsolidation: false })
25
+ })
26
+
27
+ function bind(sessionId) {
28
+ const cwd = path.join(home, 'ws', sessionId)
29
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
30
+ return { cwd, exec: { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } } }
31
+ }
32
+
33
+ test('a private write lands in the activity log with its id', async () => {
34
+ const { exec } = bind('wiring-write')
35
+ const written = await memory.privateRemember({ exec, content: '写入链路必须留一条日志', title: '写入链路' })
36
+ const rows = activity.listEvents({ kind: 'write' })
37
+ assert.equal(rows.length, 1)
38
+ assert.equal(rows[0].scope, 'private')
39
+ assert.equal(rows[0].sessionId, 'wiring-write')
40
+ assert.deepEqual(rows[0].refs, [written.id])
41
+ assert.match(rows[0].message, /写入/)
42
+ assert.ok(rows[0].libraryId, 'the library is recorded so the log row can be traced back')
43
+ })
44
+
45
+ test('a turn recall records the ids it injected and the route it took', async () => {
46
+ const { exec } = bind('wiring-recall')
47
+ const written = await memory.privateRemember({ exec, content: '海边的黄昏散步', title: '海边的黄昏' })
48
+ activity.clearEvents()
49
+ const binding = state.getSessionBinding('wiring-recall')
50
+ const result = await inject.buildRecall(binding, '海边的黄昏', 1, state.getSettings(), exec, 'wiring-recall')
51
+ const rows = activity.listEvents({ kind: 'recall' })
52
+ assert.equal(rows.length, 1)
53
+ assert.equal(rows[0].sessionId, 'wiring-recall')
54
+ assert.equal(rows[0].ok, true)
55
+ assert.deepEqual(rows[0].detail.ids, result.ids)
56
+ assert.deepEqual(rows[0].refs, result.ids)
57
+ assert.ok(result.ids.includes(written.id), 'the seeded memory is what got recalled')
58
+ assert.equal(rows[0].detail.route, undefined, 'the route is reported per layer')
59
+ assert.ok(['local', 'managed-vector-rrf'].includes(rows[0].detail.privateRoute))
60
+ })
61
+
62
+ test('a recall with a degraded semantic leg is logged as not-ok', async () => {
63
+ const { exec } = bind('wiring-recall-error')
64
+ state.updateSettings({ memoryBackend: 'api', vectorSource: 'local' })
65
+ const binding = state.getSessionBinding('wiring-recall-error')
66
+ const result = await inject.buildRecall(binding, 'anything', 1, state.getSettings(), exec, 'wiring-recall-error')
67
+ const [row] = activity.listEvents({ kind: 'recall' })
68
+ assert.equal(row.ok, false)
69
+ assert.ok(row.detail.errors.length > 0)
70
+ assert.ok(result.errors.length > 0, 'the caller still sees the error')
71
+ })
72
+
73
+ test('a consolidation that cannot start is logged with its reason', async () => {
74
+ bind('wiring-consolidate')
75
+ const result = await requestPrivateConsolidation({ get: () => undefined }, 'wiring-consolidate')
76
+ assert.equal(result.ok, false)
77
+ const [row] = activity.listEvents({ kind: 'consolidate' })
78
+ assert.equal(row.ok, false)
79
+ assert.equal(row.sessionId, 'wiring-consolidate')
80
+ assert.equal(row.level, 'error')
81
+ assert.match(row.message, /整理失败/)
82
+ assert.match(row.message, /readSession/)
83
+ })
84
+
85
+ test('a vector write outside an active generation is logged as a warning', async () => {
86
+ const { exec } = bind('wiring-vector')
87
+ state.updateSettings({ memoryBackend: 'api', vectorSource: 'local' })
88
+ await memory.privateRemember({ exec, content: '没有代际时也要落库', title: '退化写入' })
89
+ const rows = activity.listEvents({ kind: 'vector' })
90
+ assert.equal(rows.length, 1)
91
+ assert.equal(rows[0].level, 'warn')
92
+ assert.match(rows[0].message, /降级/)
93
+ })
94
+
95
+ test('the log stays queryable by session and kind together', async () => {
96
+ const a = bind('wiring-a')
97
+ const b = bind('wiring-b')
98
+ await memory.privateRemember({ exec: a.exec, content: 'a 的写入', title: 'a' })
99
+ await memory.privateRemember({ exec: b.exec, content: 'b 的写入', title: 'b' })
100
+ record({ kind: 'recall', sessionId: 'wiring-a', message: 'a 的召回' })
101
+ assert.equal(activity.listEvents({ sessionId: 'wiring-a' }).length, 2)
102
+ assert.equal(activity.listEvents({ sessionId: 'wiring-a', kind: 'write' }).length, 1)
103
+ })
@@ -0,0 +1,212 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-appb-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const state = await import('../lib/state.js')
11
+ const memory = await import('../lib/memory.js')
12
+ const inject = await import('../lib/inject.js')
13
+ const mood = await import('../lib/mood.js')
14
+
15
+ test.after(() => {
16
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
17
+ })
18
+
19
+ function execFor(sessionId, cwd) {
20
+ return { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } }
21
+ }
22
+
23
+ function setup(name) {
24
+ const cwd = path.join(home, 'proj-' + name)
25
+ const sessionId = 'appb-' + name
26
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
27
+ return { cwd, sessionId, binding, exec: execFor(sessionId, cwd) }
28
+ }
29
+
30
+ // ---- source: default, explicit, and illegal ----
31
+
32
+ test('source defaults to inferred, not user_said', async () => {
33
+ const { exec, binding } = setup('source')
34
+ const { id } = await memory.privateRemember({ exec, content: 'The build script probably wipes dist before bundling.', title: 'build wipe' })
35
+ const entry = state.loadPrivateIndex(binding.libraryId).entries[id]
36
+ // Defaulting to user_said made every model conclusion read as a quotable quote.
37
+ assert.equal(entry.source, 'inferred')
38
+
39
+ const quoted = await memory.privateRemember({ exec, content: 'He said the ledger lives in docs/ledger.md.', title: 'ledger', source: 'user_said' })
40
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries[quoted.id].source, 'user_said')
41
+ })
42
+
43
+ test('an unknown source is refused instead of stored', async () => {
44
+ const { exec } = setup('source-bad')
45
+ await assert.rejects(
46
+ () => memory.privateRemember({ exec, content: 'x', title: 'x', source: 'user-stated' }),
47
+ /source must be/,
48
+ )
49
+ })
50
+
51
+ // ---- cue derivation ----
52
+
53
+ test('cue candidates skip side-event, unspeakable, secret, and too-new entries', async () => {
54
+ const { sessionId, binding, exec } = setup('candidates')
55
+ const old = new Date(Date.now() - 40 * 3600_000).toISOString()
56
+ const keep = await memory.privateRemember({ exec, content: 'The hiss of the kettle at four in the morning.', title: 'kettle' })
57
+ // A cue-born memory is a side_event pin, which the host only accepts while that
58
+ // exact cue is still alive — so the cue has to be armed first.
59
+ state.armCue(sessionId, { id: 'cue-seed', text: 'a seed cue', createdAt: new Date().toISOString() })
60
+ const fromCue = await memory.privateRemember({
61
+ exec, content: 'A memory born of a cue.', title: 'born',
62
+ layer: 'must_remember', pinReason: '它一直挥之不去,我放不下那只杯子。', pinSource: 'side_event', sideEventId: 'cue-seed',
63
+ })
64
+ const silent = await memory.privateRemember({ exec, content: 'Cannot be told.', title: 'silent', disclosure: 'never_say' })
65
+ const secret = await memory.privateRemember({ exec, content: 'Hidden body.', title: 'secret', visibility: 'secret' })
66
+ const fresh = await memory.privateRemember({ exec, content: 'Just written a moment ago.', title: 'fresh' })
67
+
68
+ // Age everything so the 12h gate is what decides, except the fresh one.
69
+ const index = state.loadPrivateIndex(binding.libraryId)
70
+ for (const id of [keep.id, fromCue.id, silent.id, secret.id]) index.entries[id].updatedAt = old
71
+ state.savePrivateIndex(binding.libraryId, index)
72
+
73
+ const ids = memory.cueCandidates({ libraryId: binding.libraryId, sessionId }).map((c) => c.id)
74
+ assert.ok(ids.includes(keep.id))
75
+ assert.ok(!ids.includes(fromCue.id), 'a cue-born memory must not become a cue source')
76
+ assert.ok(!ids.includes(silent.id), 'disclosure never_say cannot surface as a cue')
77
+ assert.ok(!ids.includes(secret.id))
78
+ assert.ok(!ids.includes(fresh.id), 'a memory needs some age before it can echo')
79
+
80
+ // skipIds has to win over everything else.
81
+ const skipped = memory.cueCandidates({ libraryId: binding.libraryId, sessionId, skipIds: [keep.id] }).map((c) => c.id)
82
+ assert.ok(!skipped.includes(keep.id))
83
+ })
84
+
85
+ test('cue candidates put the least-used memory first', async () => {
86
+ const { sessionId, binding, exec } = setup('order')
87
+ const old = new Date(Date.now() - 40 * 3600_000).toISOString()
88
+ const used = await memory.privateRemember({ exec, content: 'The noisy one.', title: 'used' })
89
+ const quiet = await memory.privateRemember({ exec, content: 'The quiet one.', title: 'quiet' })
90
+ const index = state.loadPrivateIndex(binding.libraryId)
91
+ index.entries[used.id].updatedAt = old
92
+ index.entries[quiet.id].updatedAt = old
93
+ index.entries[used.id].accessCount = 9
94
+ index.entries[used.id].injectCount = 4
95
+ state.savePrivateIndex(binding.libraryId, index)
96
+
97
+ const ids = memory.cueCandidates({ libraryId: binding.libraryId, sessionId }).map((c) => c.id)
98
+ assert.equal(ids[0], quiet.id, 'an echo is more interesting when it was forgotten')
99
+ })
100
+
101
+ test('deriveCue returns a memory-backed cue, or null when it has nothing', async () => {
102
+ const { sessionId, binding } = setup('derive')
103
+ assert.deepEqual(inject.deriveCue({ libraryId: null }, sessionId), null)
104
+ assert.deepEqual(inject.deriveCue({ libraryId: binding.libraryId, useLibrary: false }, sessionId), null)
105
+ assert.deepEqual(inject.deriveCue(binding, sessionId), null, 'no aged memory means no derived cue')
106
+
107
+ const exec = execFor(sessionId, path.join(home, 'proj-derive'))
108
+ const { id } = await memory.privateRemember({ exec, content: 'The cat knocked the cup off the desk and just watched it fall.', title: 'the cup' })
109
+ const index = state.loadPrivateIndex(binding.libraryId)
110
+ index.entries[id].updatedAt = new Date(Date.now() - 30 * 3600_000).toISOString()
111
+ state.savePrivateIndex(binding.libraryId, index)
112
+
113
+ const cue = inject.deriveCue(binding, sessionId)
114
+ assert.ok(cue, 'an aged memory should be derivable')
115
+ assert.equal(cue.sourceId, id)
116
+ assert.match(cue.id, /^cue-/)
117
+ assert.ok(cue.text.includes('knocked the cup'), 'the cue quotes the memory, not a fixed sentence')
118
+ assert.ok(!inject.CUE_TEXTS.includes(cue.text))
119
+ assert.ok(cue.recallQuery.includes('the cup'))
120
+ })
121
+
122
+ test('maybeCue arms the cue with its source and returns related ids', async () => {
123
+ const { sessionId, binding } = setup('maybe')
124
+ state.updateSettings({ randomCueChance: 1 })
125
+ const exec = execFor(sessionId, path.join(home, 'proj-maybe'))
126
+ const { id } = await memory.privateRemember({ exec, content: 'The lamp buzzed all evening and nobody fixed it.', title: 'the lamp' })
127
+ const index = state.loadPrivateIndex(binding.libraryId)
128
+ index.entries[id].updatedAt = new Date(Date.now() - 30 * 3600_000).toISOString()
129
+ state.savePrivateIndex(binding.libraryId, index)
130
+
131
+ const out = await inject.maybeCue({ sessionId, turn: 2, settings: { ...state.getSettings(), randomCueChance: 1 }, binding, exec })
132
+ assert.ok(out && typeof out.text === 'string')
133
+ assert.ok(Array.isArray(out.ids))
134
+ const armed = state.getActiveCue(sessionId)
135
+ assert.equal(armed.sourceId, id, 'the cue records where it came from')
136
+ assert.ok(armed.recallQuery.includes('lamp'))
137
+ assert.ok(out.text.includes('这不是用户说的话'), 'a derived cue says so instead of claiming a random sense impression')
138
+ })
139
+
140
+ test('maybeCue falls back to a fixed sentence when no memory qualifies', async () => {
141
+ const { sessionId, binding } = setup('fallback')
142
+ const out = await inject.maybeCue({ sessionId, turn: 2, settings: { ...state.getSettings(), randomCueChance: 1 }, binding, exec: null })
143
+ assert.ok(inject.CUE_TEXTS.some((t) => out.text.includes(t)), 'the fixed pool still backs the feature')
144
+ const armed = state.getActiveCue(sessionId)
145
+ assert.equal(armed.sourceId, null)
146
+ })
147
+
148
+ // ---- arousal in retrieval ----
149
+
150
+ test('a tense room pulls charged memories further ahead than a quiet one', async () => {
151
+ const { sessionId, exec } = setup('arousal')
152
+ const calm = await memory.privateRemember({
153
+ exec, content: 'The hallway smelled of rain on warm stone.', title: 'hallway calm', arousal: 0.1, moodValence: 0.05,
154
+ })
155
+ const loud = await memory.privateRemember({
156
+ exec, content: 'The hallway smelled of rain on warm stone.', title: 'hallway loud', arousal: 0.95, moodValence: 0.05,
157
+ })
158
+
159
+ async function gapAt(arousal) {
160
+ state.setSessionMood(sessionId, { valence: 0, arousal })
161
+ const out = await memory.privateRecall({ exec, query: 'hallway rain stone', topK: 2, relevanceThreshold: 0 })
162
+ const byId = new Map((out.files || []).map((f) => [f.name, f.score]))
163
+ assert.deepEqual(new Set(byId.keys()), new Set([calm.id, loud.id]), 'both stay in range: arousal only reorders')
164
+ return byId.get(loud.id) / byId.get(calm.id)
165
+ }
166
+
167
+ const tense = await gapAt(0.95)
168
+ const quiet = await gapAt(0.1)
169
+ // The factor is bounded, so it must never invert the ranking — it only changes
170
+ // how far ahead the charged memory sits.
171
+ assert.ok(tense > 1 && quiet > 1, 'the charged memory wins in both rooms')
172
+ assert.ok(tense > quiet, 'a tense present favours the charged memory more than a calm one')
173
+ })
174
+
175
+ test('arousal cannot outrank a clearly better semantic hit', async () => {
176
+ const { sessionId, exec } = setup('arousal-weak')
177
+ const relevant = await memory.privateRemember({
178
+ exec, content: 'The deployment key is stored in the vault under dsh-memu-memory-prod.', title: 'deployment key', arousal: 0.05,
179
+ })
180
+ const charged = await memory.privateRemember({
181
+ exec, content: 'A door slammed somewhere in the building.', title: 'a door', arousal: 1, peak: true,
182
+ })
183
+ state.setSessionMood(sessionId, { valence: 0, arousal: 1 })
184
+ const out = await memory.privateRecall({ exec, query: 'where is the deployment key for dsh-memu-memory-prod', topK: 2, relevanceThreshold: 0 })
185
+ const ids = (out.files || []).map((f) => f.name)
186
+ assert.equal(ids[0], relevant.id, 'intensity is a nudge, not a trump card')
187
+ assert.ok(ids.includes(charged.id))
188
+ })
189
+
190
+ test('a mood that is tense but neither good nor bad still counts as a mood', async () => {
191
+ const { sessionId, exec } = setup('tense')
192
+ const flat = await memory.privateRemember({ exec, content: 'The invoice is due on the fourteenth.', title: 'invoice', arousal: 0.1, moodValence: 0 })
193
+ const edge = await memory.privateRemember({ exec, content: 'The invoice is due on the fourteenth.', title: 'invoice edge', arousal: 0.9, moodValence: 0 })
194
+ // valence is exactly neutral here, so without the arousal gate this room would be
195
+ // treated as "no mood at all" and arousal would never reach recallScore.
196
+ state.setSessionMood(sessionId, { valence: 0, arousal: 0.9 })
197
+ const tense = await memory.privateRecall({ exec, query: 'invoice due fourteenth', topK: 2, relevanceThreshold: 0 })
198
+ const tenseScores = new Map((tense.files || []).map((f) => [f.name, f.score]))
199
+ assert.deepEqual(new Set(tenseScores.keys()), new Set([flat.id, edge.id]))
200
+ const ratio = tenseScores.get(edge.id) / tenseScores.get(flat.id)
201
+
202
+ state.setSessionMood(sessionId, { valence: 0, arousal: 0.1 })
203
+ const calm = await memory.privateRecall({ exec, query: 'invoice due fourteenth', topK: 2, relevanceThreshold: 0 })
204
+ const calmScores = new Map((calm.files || []).map((f) => [f.name, f.score]))
205
+ const calmRatio = calmScores.get(edge.id) / calmScores.get(flat.id)
206
+ assert.ok(ratio > calmRatio, 'the tense read widens the same gap')
207
+ })
208
+
209
+ test('BASE_AROUSAL is exported and is a real baseline, not zero', () => {
210
+ assert.equal(typeof mood.BASE_AROUSAL, 'number')
211
+ assert.ok(mood.BASE_AROUSAL > 0 && mood.BASE_AROUSAL < 1)
212
+ })