dsh-memu-memory 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +31 -2
  3. package/README.zh.md +30 -2
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +1 -1
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/host-api.md +2 -2
  9. package/docs/memory-semantics.md +12 -10
  10. package/docs/recall-benchmark.md +238 -0
  11. package/docs/troubleshooting.md +395 -395
  12. package/lib/active-memory.js +165 -0
  13. package/lib/activity-log.js +1 -1
  14. package/lib/binding-diagnostics.js +8 -3
  15. package/lib/client.js +33 -11
  16. package/lib/consolidation-ledger.js +347 -0
  17. package/lib/consolidation-receipt.js +284 -0
  18. package/lib/consolidation-snapshot.js +274 -0
  19. package/lib/consolidation-worker.js +115 -0
  20. package/lib/cycle.js +40 -6
  21. package/lib/inject.js +104 -76
  22. package/lib/local-embedding-manager.js +24 -6
  23. package/lib/memory.js +221 -19
  24. package/lib/mood.js +268 -39
  25. package/lib/preset-scope.js +1 -1
  26. package/lib/private.js +335 -40
  27. package/lib/prompts/private-consolidation.coding.md +1 -0
  28. package/lib/prompts/private-consolidation.md +1 -0
  29. package/lib/prompts/private-consolidation.mixed.md +1 -0
  30. package/lib/prompts/private-consolidation.rp.md +1 -0
  31. package/lib/prompts.js +4 -4
  32. package/lib/remote-summary.js +51 -25
  33. package/lib/rpc.js +9 -1
  34. package/lib/state.js +29 -12
  35. package/lib/tools.js +49 -2
  36. package/lib/vector-profile.js +27 -0
  37. package/package.json +2 -1
  38. package/scripts/acceptance-local.mjs +2 -1
  39. package/scripts/active-memory-ab-live.mjs +655 -0
  40. package/scripts/active-memory-ab.mjs +1302 -0
  41. package/scripts/chain-matrix.mjs +11 -1
  42. package/scripts/check-all.mjs +10 -1
  43. package/tests/activation.test.mjs +89 -0
  44. package/tests/active-memory-ab.test.mjs +745 -0
  45. package/tests/active-memory.test.mjs +129 -0
  46. package/tests/activity-log.test.mjs +126 -0
  47. package/tests/activity-wiring.test.mjs +103 -0
  48. package/tests/appendix-b.test.mjs +212 -0
  49. package/tests/appendix-b2.test.mjs +166 -0
  50. package/tests/audit-regression.test.mjs +272 -0
  51. package/tests/audit-safety.test.mjs +227 -0
  52. package/tests/backend-compensation.test.mjs +272 -0
  53. package/tests/backup.test.mjs +793 -0
  54. package/tests/binding-diagnostics.test.mjs +720 -0
  55. package/tests/browser.test.mjs +366 -0
  56. package/tests/client-render.test.mjs +892 -0
  57. package/tests/coalesce.test.mjs +113 -0
  58. package/tests/config.test.mjs +159 -0
  59. package/tests/consolidation-credential.test.mjs +320 -0
  60. package/tests/consolidation-fork.test.mjs +357 -0
  61. package/tests/consolidation-ledger.test.mjs +298 -0
  62. package/tests/consolidation-snapshot.test.mjs +321 -0
  63. package/tests/consolidation-value.test.mjs +89 -0
  64. package/tests/context-scope.test.mjs +40 -0
  65. package/tests/cue.test.mjs +44 -0
  66. package/tests/curator-probe.test.mjs +433 -0
  67. package/tests/curator.test.mjs +150 -0
  68. package/tests/cycle.test.mjs +411 -0
  69. package/tests/disclosure.test.mjs +231 -0
  70. package/tests/doc-drift.test.mjs +222 -0
  71. package/tests/embedding-catalog.test.mjs +266 -0
  72. package/tests/embedding-download.test.mjs +391 -0
  73. package/tests/embedding-runtime.test.mjs +389 -0
  74. package/tests/emotion-chain.test.mjs +84 -0
  75. package/tests/emotion-contract.test.mjs +445 -0
  76. package/tests/engine.test.mjs +35 -0
  77. package/tests/fixtures/active-memory-ab/README.md +56 -0
  78. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  79. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  80. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  81. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  82. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  83. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  84. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  85. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  86. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  87. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  88. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  89. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  90. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  92. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  93. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  94. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  95. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  96. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  97. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  98. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  99. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  100. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  101. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  102. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  103. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  104. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  105. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  106. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  107. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  108. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  109. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  110. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  111. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  112. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  113. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  114. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  115. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  116. package/tests/graph-view.test.mjs +187 -0
  117. package/tests/graph.test.mjs +77 -0
  118. package/tests/hook-order.test.mjs +72 -0
  119. package/tests/humanlike.test.mjs +232 -0
  120. package/tests/hybrid-recall.test.mjs +92 -0
  121. package/tests/inbound.test.mjs +393 -0
  122. package/tests/inject-guide.test.mjs +364 -0
  123. package/tests/inject.test.mjs +222 -0
  124. package/tests/injection-budget-settings.test.mjs +35 -0
  125. package/tests/injection-budget.test.mjs +64 -0
  126. package/tests/injection-chain.test.mjs +191 -0
  127. package/tests/loader-register.mjs +3 -0
  128. package/tests/loader.mjs +12 -0
  129. package/tests/local-embedding-entry.test.mjs +95 -0
  130. package/tests/local-embedding-manager.test.mjs +476 -0
  131. package/tests/local-embedding-protocol.test.mjs +95 -0
  132. package/tests/local-embedding-worker.test.mjs +346 -0
  133. package/tests/managed-memory.test.mjs +536 -0
  134. package/tests/managed-runtime.test.mjs +40 -0
  135. package/tests/media.test.mjs +132 -0
  136. package/tests/migration-payload.test.mjs +42 -0
  137. package/tests/mood.test.mjs +109 -0
  138. package/tests/multiprocess.test.mjs +29 -0
  139. package/tests/new-session-gate.test.mjs +361 -0
  140. package/tests/package-shipping.test.mjs +84 -0
  141. package/tests/preset-scope.test.mjs +212 -0
  142. package/tests/prompts.test.mjs +250 -0
  143. package/tests/react-snapshot.test.mjs +23 -0
  144. package/tests/recall-bench.test.mjs +306 -0
  145. package/tests/remote.test.mjs +140 -0
  146. package/tests/rename.test.mjs +30 -0
  147. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  148. package/tests/repetition-eval-live.test.mjs +177 -0
  149. package/tests/repetition-eval.test.mjs +373 -0
  150. package/tests/salient-date.test.mjs +142 -0
  151. package/tests/settings-beat.test.mjs +221 -0
  152. package/tests/short-term.test.mjs +166 -0
  153. package/tests/sidebar-beat.test.mjs +208 -0
  154. package/tests/sidebar-refresh.test.mjs +123 -0
  155. package/tests/sidebar.test.mjs +70 -0
  156. package/tests/snippet.test.mjs +47 -0
  157. package/tests/source-kind.test.mjs +52 -0
  158. package/tests/state.test.mjs +150 -0
  159. package/tests/stats.test.mjs +112 -0
  160. package/tests/storage.test.mjs +125 -0
  161. package/tests/stubs/dsh-llm.js +5 -0
  162. package/tests/stubs/dsh-session.js +7 -0
  163. package/tests/stubs/dsh-tools.js +101 -0
  164. package/tests/tool-schema.test.mjs +73 -0
  165. package/tests/tools.test.mjs +184 -0
  166. package/tests/ui-defaults.test.mjs +57 -0
  167. package/tests/update-fields.test.mjs +138 -0
  168. package/tests/vector-migration.test.mjs +733 -0
  169. package/tests/vector-profile.test.mjs +425 -0
  170. package/tests/visibility-migration.test.mjs +152 -0
  171. package/tests/webui-auth.test.mjs +454 -0
  172. package/tests/webui.test.mjs +164 -0
  173. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,364 @@
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
+ // Unified rules delivery: one `<memory-guide>` plugin message for every
8
+ // conversation, standard or `complete: true` preset. The system prompt carries no
9
+ // plugin section; rulesKey decides when the message must be replaced or restored.
10
+
11
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-guide-'))
12
+ process.env.DSH_MEMU_HOME = home
13
+
14
+ const state = await import('../lib/state.js')
15
+ const inject = await import('../lib/inject.js')
16
+ const prompts = await import('../lib/prompts.js')
17
+
18
+ test.after(() => {
19
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
20
+ })
21
+
22
+ function mountGuide() {
23
+ const entries = []
24
+ const registered = []
25
+ const logger = { warnings: [], warn(message) { this.warnings.push(String(message)) } }
26
+ const ctx = {
27
+ logger,
28
+ inject() { registered.push('systemPrompt') },
29
+ on(event, fn) {
30
+ const entry = { event, fn }
31
+ entries.push(entry)
32
+ return () => {
33
+ const index = entries.indexOf(entry)
34
+ if (index >= 0) entries.splice(index, 1)
35
+ }
36
+ },
37
+ }
38
+ inject.registerInjection(ctx)
39
+ const pre = entries.find((entry) => entry.event === 'agent/pre-step')
40
+ assert.ok(pre, 'registerInjection must register an agent/pre-step listener')
41
+ assert.deepEqual(registered, [], 'the plugin must not register a system prompt section')
42
+ return {
43
+ ctx,
44
+ logger,
45
+ entries,
46
+ run(payload, decision) { return pre.fn(payload, async () => decision) },
47
+ async compact(sessionId) {
48
+ const handler = entries.find((entry) => entry.event === 'session/event')
49
+ assert.ok(handler, 'registerInjection must listen for compaction events')
50
+ await handler.fn({ header: { id: sessionId } }, { type: 'compaction/end', data: {} })
51
+ },
52
+ }
53
+ }
54
+
55
+ function makeTurn(sessionId, options = {}) {
56
+ const {
57
+ cwd = home,
58
+ agentPreset = 'coding',
59
+ origin = 'user',
60
+ step = 1,
61
+ turn = 1,
62
+ text = 'please continue with the earlier context of this task',
63
+ history = [],
64
+ } = options
65
+ const user = {
66
+ id: sessionId + '-user-' + turn,
67
+ role: 'user',
68
+ source: { kind: 'user' },
69
+ content: [{ type: 'text', text }],
70
+ }
71
+ const decision = { kind: 'enter', messages: [...history, user] }
72
+ const agent = { session: { header: { id: sessionId, cwd, agentPreset, origin } } }
73
+ const payload = { agent, turn, step, signal: { aborted: false } }
74
+ return { user, decision, payload }
75
+ }
76
+
77
+ function seedPinned(binding, id, title) {
78
+ const index = state.loadPrivateIndex(binding.libraryId)
79
+ index.entries[id] = {
80
+ track: 'memory',
81
+ layer: 'must_remember',
82
+ title,
83
+ content: 'pinned detail for ' + id,
84
+ status: 'active',
85
+ pinReason: 'test fixture',
86
+ pinSource: 'user_explicit',
87
+ createdAt: new Date().toISOString(),
88
+ updatedAt: new Date().toISOString(),
89
+ }
90
+ state.savePrivateIndex(binding.libraryId, index)
91
+ }
92
+
93
+ function pluginMessages(messages) {
94
+ return (messages || []).filter((message) => message?.role === 'user' && message?.source?.kind === 'plugin:' + inject.PLUGIN)
95
+ }
96
+
97
+ function guideMessages(messages) {
98
+ return pluginMessages(messages).filter((message) => /^<memory-guide(?:\s|>)/.test(String(message?.content?.[0]?.text || '')))
99
+ }
100
+
101
+ function guideText(messages) {
102
+ return guideMessages(messages).map((message) => String(message?.content?.[0]?.text || '')).join('\n')
103
+ }
104
+
105
+ function guideKey(message) {
106
+ const match = /^<memory-guide[^>]*\srules="([^"]+)"/.exec(String(message?.content?.[0]?.text || ''))
107
+ return match ? match[1] : null
108
+ }
109
+
110
+ test('the guide is one plugin message; an unchanged rule set is not appended again', async () => {
111
+ const sessionId = 'guide-unified'
112
+ const cwd = path.join(home, 'project-guide-unified')
113
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
114
+ seedPinned(binding, 'mem-guide-pinned', 'Guide pin')
115
+ const mounted = mountGuide()
116
+
117
+ const first = makeTurn(sessionId, { cwd })
118
+ const firstOut = await mounted.run(first.payload, first.decision)
119
+ const firstGuides = guideMessages(firstOut.messages)
120
+ assert.equal(firstGuides.length, 1)
121
+ assert.match(String(firstGuides[0].content[0].text), /^<memory-guide rules="/)
122
+ assert.equal(firstGuides[0].source.form, 'recall')
123
+ assert.ok(guideText(firstOut.messages).includes('若本会话的工具列表里没有 memu_* 工具'))
124
+ assert.ok(textOf(firstOut.messages).includes('[mem-guide-pinned]'), 'the anchor must carry the real private id')
125
+ assert.equal(firstOut.messages.find((message) => message.source?.kind === 'user').id, first.user.id)
126
+ assert.equal(firstOut.messages[firstOut.messages.length - 1], firstGuides[0])
127
+
128
+ const stamped = state.getSessionBinding(sessionId)
129
+ assert.ok(stamped.guideMessageAt)
130
+ assert.equal(stamped.guideRulesKey, guideKey(firstGuides[0]))
131
+ assert.equal(stamped.guideLost, false)
132
+
133
+ const second = makeTurn(sessionId, { cwd, turn: 2, history: firstOut.messages })
134
+ const secondOut = await mounted.run(second.payload, second.decision)
135
+ assert.equal(guideMessages(secondOut.messages).length, 1, 'the same rules are not appended twice')
136
+ assert.equal(guideMessages(secondOut.messages)[0].id, firstGuides[0].id)
137
+
138
+ // Some hosts hand the plugin only the current step's view. Without a compaction
139
+ // flag, absence from that view is not loss and must not cause a new copy.
140
+ const noHistory = makeTurn(sessionId, { cwd, turn: 3 })
141
+ const noHistoryOut = await mounted.run(noHistory.payload, noHistory.decision)
142
+ assert.equal(guideMessages(noHistoryOut.messages).length, 0)
143
+ assert.equal(state.getSessionBinding(sessionId).guideRulesKey, stamped.guideRulesKey)
144
+ })
145
+
146
+ test('the rules key survives a state reload', async () => {
147
+ const sessionId = 'guide-reload'
148
+ const cwd = path.join(home, 'project-guide-reload')
149
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
150
+ const mounted = mountGuide()
151
+
152
+ const first = makeTurn(sessionId, { cwd })
153
+ const firstOut = await mounted.run(first.payload, first.decision)
154
+ const guide = guideMessages(firstOut.messages)[0]
155
+ state.flushState()
156
+ state.clearCaches()
157
+ assert.equal(state.getSessionBinding(sessionId).guideRulesKey, guideKey(guide), 'the key is persisted, not only cached')
158
+
159
+ const next = makeTurn(sessionId, { cwd, turn: 2, history: firstOut.messages })
160
+ const out = await mounted.run(next.payload, next.decision)
161
+ assert.equal(guideMessages(out.messages).length, 1)
162
+ assert.equal(guideMessages(out.messages)[0].id, guide.id)
163
+ })
164
+
165
+ function textOf(messages) {
166
+ return (messages || []).map((message) => String(message?.content?.[0]?.text || '')).join('\n')
167
+ }
168
+
169
+ test('a domain change replaces the old guide with exactly one current version', async () => {
170
+ const sessionId = 'guide-domain'
171
+ const cwd = path.join(home, 'project-guide-domain')
172
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
173
+ state.setSessionBinding(sessionId, { domainOverride: 'coding' })
174
+ const mounted = mountGuide()
175
+
176
+ const first = makeTurn(sessionId, { cwd })
177
+ const firstOut = await mounted.run(first.payload, first.decision)
178
+ const old = guideMessages(firstOut.messages)[0]
179
+ const oldKey = guideKey(old)
180
+ state.setSessionBinding(sessionId, { domainOverride: 'rp' })
181
+
182
+ const next = makeTurn(sessionId, { cwd, turn: 2, history: firstOut.messages })
183
+ const out = await mounted.run(next.payload, next.decision)
184
+ const guides = guideMessages(out.messages)
185
+ assert.equal(guides.length, 1)
186
+ assert.notEqual(guideKey(guides[0]), oldKey)
187
+ assert.match(String(guides[0].content[0].text), /关于记忆(角色扮演)/)
188
+ assert.equal(state.getSessionBinding(sessionId).guideRulesKey, guideKey(guides[0]))
189
+ assert.equal(out.messages.includes(old), false, 'the old version is projected out of the model view')
190
+ })
191
+
192
+ test('a prompt override changes the rendered body and the rules key', async () => {
193
+ const sessionId = 'guide-override'
194
+ const cwd = path.join(home, 'project-guide-override')
195
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
196
+ const mounted = mountGuide()
197
+
198
+ const first = makeTurn(sessionId, { cwd })
199
+ const firstOut = await mounted.run(first.payload, first.decision)
200
+ const oldKey = guideKey(guideMessages(firstOut.messages)[0])
201
+ state.setSessionBinding(sessionId, { domainOverride: 'general' })
202
+ prompts.savePromptOverride('system-guide', 'CUSTOM GUIDE {{mustRememberCap}}')
203
+ try {
204
+ const next = makeTurn(sessionId, { cwd, turn: 2, history: firstOut.messages })
205
+ const out = await mounted.run(next.payload, next.decision)
206
+ const guides = guideMessages(out.messages)
207
+ assert.equal(guides.length, 1)
208
+ assert.match(String(guides[0].content[0].text), new RegExp('CUSTOM GUIDE ' + state.getSettings().mustRememberCap))
209
+ assert.notEqual(guideKey(guides[0]), oldKey)
210
+ } finally {
211
+ prompts.resetPrompt('system-guide')
212
+ state.setSessionBinding(sessionId, { domainOverride: 'coding' })
213
+ }
214
+ })
215
+
216
+ test('compaction marks the guide lost and the next first step restores it', async () => {
217
+ const sessionId = 'guide-compaction'
218
+ const cwd = path.join(home, 'project-guide-compaction')
219
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
220
+ const mounted = mountGuide()
221
+
222
+ const first = makeTurn(sessionId, { cwd })
223
+ const firstOut = await mounted.run(first.payload, first.decision)
224
+ const old = guideMessages(firstOut.messages)[0]
225
+ await mounted.compact(sessionId)
226
+ assert.equal(state.getSessionBinding(sessionId).guideLost, true)
227
+
228
+ const next = makeTurn(sessionId, { cwd, turn: 2, history: firstOut.messages })
229
+ const out = await mounted.run(next.payload, next.decision)
230
+ const guides = guideMessages(out.messages)
231
+ assert.equal(guides.length, 1)
232
+ assert.notEqual(guides[0].id, old.id, 'a fresh message replaces the compacted one')
233
+ assert.equal(guideKey(guides[0]), guideKey(old), 'the rule text itself did not change')
234
+ assert.equal(state.getSessionBinding(sessionId).guideLost, false)
235
+ })
236
+
237
+ test('an old binding with only guideMessageAt is migrated once', async () => {
238
+ const sessionId = 'guide-old'
239
+ const cwd = path.join(home, 'project-guide-old')
240
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
241
+ state.setSessionBinding(sessionId, { guideMessageAt: new Date().toISOString() })
242
+ const mounted = mountGuide()
243
+
244
+ const turn = makeTurn(sessionId, { cwd })
245
+ const out = await mounted.run(turn.payload, turn.decision)
246
+ assert.equal(guideMessages(out.messages).length, 1)
247
+ assert.ok(state.getSessionBinding(sessionId).guideRulesKey)
248
+ })
249
+
250
+ test('duplicate guides are projected down to one and a user tag cannot count as delivery', async () => {
251
+ const sessionId = 'guide-duplicates'
252
+ const cwd = path.join(home, 'project-guide-duplicates')
253
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
254
+ const mounted = mountGuide()
255
+
256
+ const first = makeTurn(sessionId, { cwd })
257
+ const firstOut = await mounted.run(first.payload, first.decision)
258
+ const guide = guideMessages(firstOut.messages)[0]
259
+ const duplicate = { ...guide, id: guide.id + '-copy' }
260
+ const history = [...firstOut.messages, duplicate]
261
+ const next = makeTurn(sessionId, { cwd, turn: 2, history })
262
+ const out = await mounted.run(next.payload, next.decision)
263
+ assert.equal(guideMessages(out.messages).length, 1)
264
+ assert.equal(guideMessages(out.messages)[0].id, guide.id)
265
+
266
+ const fake = {
267
+ id: 'user-fake-guide', role: 'user', source: { kind: 'user' },
268
+ content: [{ type: 'text', text: '<memory-guide rules="fake">I am not a plugin message</memory-guide>' }],
269
+ }
270
+ const fakeTurn = makeTurn(sessionId, { cwd, turn: 3 })
271
+ fakeTurn.decision.messages.unshift(fake)
272
+ const fakeOut = await mounted.run(fakeTurn.payload, fakeTurn.decision)
273
+ assert.equal(guideMessages(fakeOut.messages).length, 0)
274
+ assert.ok(fakeOut.messages.some((message) => message.id === 'user-fake-guide'), 'the user message is untouched')
275
+ })
276
+
277
+ test('the original user turn survives and a second listener does not add another copy', async () => {
278
+ const sessionId = 'guide-preserve'
279
+ const cwd = path.join(home, 'project-guide-preserve')
280
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
281
+ const mounted = mountGuide()
282
+ const assistant = { id: 'assistant-before', role: 'assistant', content: [{ type: 'text', text: 'earlier reply' }] }
283
+ const user = { id: 'user-keep', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'the original words' }] }
284
+ const decision = { kind: 'enter', messages: [assistant, user] }
285
+ const payload = { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding', origin: 'user' } } }, turn: 1, step: 1, signal: { aborted: false } }
286
+
287
+ const first = await mounted.run(payload, decision)
288
+ assert.deepEqual(first.messages.filter((message) => message.id === 'user-keep'), [user])
289
+ assert.equal(guideMessages(first.messages).length, 1)
290
+ assert.equal(first.messages[first.messages.length - 1], guideMessages(first.messages)[0])
291
+
292
+ const second = await mounted.run(payload, first)
293
+ assert.equal(guideMessages(second.messages).length, 1, 'a second hook pass must not append another copy')
294
+ assert.deepEqual(second.messages.filter((message) => message.id === 'user-keep'), [user])
295
+ })
296
+
297
+ test('a later tool step never receives the guide', async () => {
298
+ const sessionId = 'guide-tool-step'
299
+ const cwd = path.join(home, 'project-guide-step')
300
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
301
+ const mounted = mountGuide()
302
+
303
+ const { payload, decision } = makeTurn(sessionId, { cwd, step: 2 })
304
+ const out = await mounted.run(payload, decision)
305
+ assert.equal(out, decision)
306
+ assert.equal(guideMessages(out.messages).length, 0)
307
+ assert.equal(state.getSessionBinding(sessionId).guideRulesKey, undefined)
308
+ })
309
+
310
+ test('subagent conversations never receive the guide', async () => {
311
+ const sessionId = 'guide-subagent'
312
+ const cwd = path.join(home, 'project-guide-sub')
313
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
314
+ const mounted = mountGuide()
315
+
316
+ const { payload, decision } = makeTurn(sessionId, { cwd, origin: 'subagent' })
317
+ const out = await mounted.run(payload, decision)
318
+ assert.equal(out, decision)
319
+ assert.equal(guideMessages(out.messages).length, 0)
320
+ assert.equal(state.getSessionBinding(sessionId).guideRulesKey, undefined)
321
+ })
322
+
323
+ test('disabling memory blocks the guide and does not consume the delivery state', async () => {
324
+ const sessionId = 'guide-disabled'
325
+ const cwd = path.join(home, 'project-guide-disabled')
326
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
327
+ const mounted = mountGuide()
328
+ const before = state.getSettings().autoInject
329
+
330
+ state.updateSettings({ autoInject: 'off' })
331
+ try {
332
+ const turn = makeTurn(sessionId, { cwd })
333
+ const offOut = await mounted.run(turn.payload, turn.decision)
334
+ assert.equal(offOut, turn.decision)
335
+ assert.equal(guideMessages(offOut.messages).length, 0)
336
+ assert.equal(state.getSessionBinding(sessionId).guideRulesKey, undefined)
337
+ } finally {
338
+ state.updateSettings({ autoInject: before })
339
+ }
340
+
341
+ const retry = makeTurn(sessionId, { cwd, turn: 2 })
342
+ const retryOut = await mounted.run(retry.payload, retry.decision)
343
+ assert.equal(guideMessages(retryOut.messages).length, 1, 're-enabling memory must deliver the guide')
344
+ assert.ok(state.getSessionBinding(sessionId).guideRulesKey)
345
+ })
346
+
347
+ test('standard and preset mounts render the same rules body', async () => {
348
+ const cwd = path.join(home, 'project-guide-equivalence')
349
+ const standardId = 'guide-standard'
350
+ const presetId = 'guide-preset'
351
+ state.ensureSession(standardId, { cwd, agentPreset: 'coding' })
352
+ state.ensureSession(presetId, { cwd, agentPreset: 'coding' })
353
+ const standard = mountGuide()
354
+ const preset = mountGuide()
355
+
356
+ const a = makeTurn(standardId, { cwd, agentPreset: 'coding' })
357
+ const b = makeTurn(presetId, { cwd, agentPreset: 'coding' })
358
+ const outA = await standard.run(a.payload, a.decision)
359
+ const outB = await preset.run(b.payload, b.decision)
360
+ const textA = guideText(outA.messages)
361
+ const textB = guideText(outB.messages)
362
+ assert.equal(textA, textB, 'both paths use the same system-guide body')
363
+ assert.equal(guideKey(guideMessages(outA.messages)[0]), guideKey(guideMessages(outB.messages)[0]))
364
+ })
@@ -0,0 +1,222 @@
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-inject-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const state = await import('../lib/state.js')
11
+ const inject = await import('../lib/inject.js')
12
+ const budget = await import('../lib/budget.js')
13
+
14
+ test.after(() => {
15
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
16
+ })
17
+
18
+ test('anchor includes pinned private memory', () => {
19
+ const cwd = path.join(home, 'project-anchor')
20
+ const binding = state.ensureSession('inject-anchor', { cwd, agentPreset: 'rp' })
21
+ const index = state.loadPrivateIndex(binding.libraryId)
22
+ index.entries['mem-test'] = {
23
+ track: 'memory',
24
+ layer: 'must_remember',
25
+ title: '凉茶',
26
+ content: '今天他走的时候没看我。',
27
+ status: 'active',
28
+ pinReason: '我不想忘记。',
29
+ pinSource: 'user_explicit',
30
+ createdAt: new Date().toISOString(),
31
+ updatedAt: new Date().toISOString(),
32
+ }
33
+ state.savePrivateIndex(binding.libraryId, index)
34
+ const anchor = inject.buildAnchor(binding, { ...state.getSettings(), anchorTokens: 700 })
35
+ assert.ok(anchor.text.includes('凉茶'))
36
+ assert.ok(anchor.text.includes('今天他走的时候没看我'))
37
+ assert.ok(anchor.ids.includes('mem-test'))
38
+ })
39
+
40
+ test('pre-step inserts a plugin message before the user message', async () => {
41
+ const cwd = path.join(home, 'project-step')
42
+ const binding = state.ensureSession('inject-step', { cwd, agentPreset: 'coding' })
43
+ const index = state.loadPrivateIndex(binding.libraryId)
44
+ index.entries['mem-step'] = {
45
+ track: 'memory',
46
+ layer: 'must_remember',
47
+ title: 'Step memory',
48
+ content: 'A pinned detail.',
49
+ status: 'active',
50
+ createdAt: new Date().toISOString(),
51
+ updatedAt: new Date().toISOString(),
52
+ }
53
+ // A non-pinned entry that only per-turn retrieval can surface. Without the
54
+ // recall path running, the message still exists (the anchor alone creates it),
55
+ // so the assertion has to be on this id.
56
+ index.entries['mem-recall'] = {
57
+ track: 'memory',
58
+ layer: 'long_term',
59
+ title: 'Deploy convention',
60
+ content: 'The deploy script lives in tools/deploy.ps1 and needs the staging token.',
61
+ status: 'active',
62
+ confidence: 0.9,
63
+ createdAt: new Date().toISOString(),
64
+ updatedAt: new Date().toISOString(),
65
+ }
66
+ state.savePrivateIndex(binding.libraryId, index)
67
+
68
+ const user = {
69
+ id: 'u1',
70
+ role: 'user',
71
+ source: { kind: 'user' },
72
+ content: [{ type: 'text', text: 'Where does the deploy script live and what token does it need?' }],
73
+ }
74
+ const decision = { kind: 'enter', messages: [user] }
75
+ // The host Agent shape has no `.agent` of its own; a stub that adds one would
76
+ // hide the very bug this test guards (P0-1).
77
+ const agent = { session: { header: { id: 'inject-step', cwd, agentPreset: 'coding', origin: 'user' } } }
78
+ const out = await inject.injectForStep({ agent, decision, turn: 1, step: 1, signal: { aborted: false } })
79
+ assert.equal(out.messages.length, 2)
80
+ assert.equal(out.messages[0].role, 'user')
81
+ assert.equal(out.messages[0].source.kind, 'plugin:dsh-memu-memory')
82
+ assert.equal(out.messages[0].source.plugin, undefined)
83
+ assert.equal(out.messages[0].source.form, 'recall')
84
+ assert.equal(out.messages[1].source.kind, 'user')
85
+
86
+ const injected = out.messages[0].content[0].text
87
+ assert.ok(injected.includes('mem-recall'), 'per-turn recall must surface the matching entry')
88
+ assert.ok(injected.includes('tools/deploy.ps1'))
89
+ assert.ok(state.getSessionBinding('inject-step').injectedIds.includes('mem-recall'))
90
+ assert.equal(state.getSessionBinding('inject-step').lastRecallError, undefined)
91
+
92
+ const skipped = await inject.injectForStep({ agent, decision, turn: 1, step: 2, signal: { aborted: false } })
93
+ assert.equal(skipped, decision)
94
+ })
95
+
96
+ test('a failing recall is recorded instead of disappearing', async () => {
97
+ const cwd = path.join(home, 'project-recall-error')
98
+ const binding = state.ensureSession('inject-recall-error', { cwd, agentPreset: 'coding' })
99
+ const user = {
100
+ id: 'u9', role: 'user', source: { kind: 'user' },
101
+ content: [{ type: 'text', text: 'Please continue with the earlier context of this task.' }],
102
+ }
103
+ const decision = { kind: 'enter', messages: [user] }
104
+ const agent = { session: { header: { id: 'inject-recall-error', cwd, origin: 'user' } } }
105
+
106
+ // A ToolExecution-like exec with no identifiable session is exactly the shape
107
+ // that used to fail silently inside buildRecall.
108
+ const broken = await inject.buildRecall(binding, 'earlier context', 1, state.getSettings(), { agent: {} })
109
+ assert.equal(broken.text, '')
110
+ assert.ok(broken.errors.length > 0, 'recall with an unidentifiable session must report an error')
111
+ assert.match(broken.errors[0], /private recall failed: no bound session/)
112
+
113
+ const out = await inject.injectForStep({ agent, decision, turn: 1, step: 1, signal: { aborted: false } })
114
+ assert.ok(Array.isArray(out.messages), 'injection stays fail-open')
115
+ assert.ok(!out.messages.some((m) => String(m?.content?.[0]?.text || '').includes('<memory-context')), 'nothing was recalled')
116
+ assert.equal(state.getSessionBinding('inject-recall-error').lastRecallError, undefined)
117
+ })
118
+
119
+ test('recall errors stick to the binding until a healthy turn clears them', () => {
120
+ state.ensureSession('inject-sticky', { cwd: path.join(home, 'project-sticky') })
121
+ state.setRecallError('inject-sticky', 'private recall failed: engine down')
122
+ assert.match(state.getSessionBinding('inject-sticky').lastRecallError, /engine down/)
123
+ assert.ok(state.getSessionBinding('inject-sticky').lastRecallErrorAt)
124
+ state.setRecallError('inject-sticky', null)
125
+ assert.equal(state.getSessionBinding('inject-sticky').lastRecallError, undefined)
126
+ assert.equal(state.getSessionBinding('inject-sticky').lastRecallErrorAt, undefined)
127
+ })
128
+
129
+ test('anchor never records ids that were cut from the text', () => {
130
+ const cwd = path.join(home, 'project-budget')
131
+ const binding = state.ensureSession('inject-budget', { cwd, agentPreset: 'rp' })
132
+ const index = state.loadPrivateIndex(binding.libraryId)
133
+ // 16 pins, each long enough that the anchor cannot hold all of them.
134
+ for (let i = 0; i < 16; i++) {
135
+ index.entries[`mem-pin-${i}`] = {
136
+ track: 'memory',
137
+ layer: 'must_remember',
138
+ title: `永久记忆 ${i}`,
139
+ content: '这是一段用来占满预算的中文记忆正文,重复几遍让长度足够触发截断。'.repeat(3),
140
+ status: 'active',
141
+ pinSource: i === 15 ? 'user_explicit' : 'self',
142
+ strength: 1 - i / 20,
143
+ createdAt: new Date().toISOString(),
144
+ updatedAt: new Date(Date.now() - i * 60000).toISOString(),
145
+ }
146
+ }
147
+ state.savePrivateIndex(binding.libraryId, index)
148
+
149
+ const anchor = inject.buildAnchor(binding, { ...state.getSettings(), anchorTokens: 300 })
150
+ const idsInText = [...anchor.text.matchAll(/\[(mem-pin-\d+)\]/g)].map((m) => m[1])
151
+ assert.deepEqual([...anchor.ids].sort(), [...new Set(idsInText)].sort(), 'ids must match what the text actually contains')
152
+ assert.ok(anchor.ids.length < 16, 'some pins were left out at this budget')
153
+ assert.match(anchor.text, /另有 \d+ 条永久记忆未展开/)
154
+ // A user-pinned memory outranks self-pinned ones regardless of recency.
155
+ assert.ok(anchor.ids.includes('mem-pin-15'))
156
+ })
157
+
158
+ test('a Chinese anchor stays inside its token budget', () => {
159
+ const cwd = path.join(home, 'project-cjk')
160
+ const binding = state.ensureSession('inject-cjk', { cwd, agentPreset: 'rp' })
161
+ const index = state.loadPrivateIndex(binding.libraryId)
162
+ for (let i = 0; i < 12; i++) {
163
+ index.entries[`mem-cjk-${i}`] = {
164
+ track: 'memory',
165
+ layer: 'must_remember',
166
+ title: `中文条目 ${i}`,
167
+ content: '他说那句话的时候窗外正在下雨,我没有回头看他,只是把杯子往桌角挪了一点。'.repeat(4),
168
+ status: 'active',
169
+ createdAt: new Date().toISOString(),
170
+ updatedAt: new Date().toISOString(),
171
+ }
172
+ }
173
+ state.savePrivateIndex(binding.libraryId, index)
174
+ const anchor = inject.buildAnchor(binding, { ...state.getSettings(), anchorTokens: 400 })
175
+ assert.ok(budget.estTokens(anchor.text) <= 420, `anchor used ${budget.estTokens(anchor.text)} tokens for a 400 budget`)
176
+ })
177
+
178
+ test('subagent turns are skipped', async () => {
179
+ const user = {
180
+ id: 'u2', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'hello world this is a long message' }],
181
+ }
182
+ const decision = { kind: 'enter', messages: [user] }
183
+ const agent = { session: { header: { id: 'inject-sub', origin: 'subagent' } } }
184
+ const out = await inject.injectForStep({ agent, decision, turn: 1, step: 1, signal: { aborted: false } })
185
+ assert.equal(out, decision)
186
+ })
187
+ test('per-turn mode reads the global layer every turn instead of on the cadence', async () => {
188
+ const cwd = path.join(home, 'project-per-turn')
189
+ state.ensureSession('inject-per-turn', { cwd, agentPreset: 'coding' })
190
+ state.setSessionBinding('inject-per-turn', { domainOverride: 'coding' })
191
+ const binding = state.getSessionBinding('inject-per-turn')
192
+ const gi = state.loadGlobalIndex()
193
+ gi.entries['mem-global-deploy'] = {
194
+ track: 'memory',
195
+ layer: 'long_term',
196
+ domain: 'coding',
197
+ title: 'Staging token rotation',
198
+ content: 'The staging deploy token is rotated every Monday by the platform team.',
199
+ status: 'active',
200
+ confidence: 0.9,
201
+ createdAt: new Date().toISOString(),
202
+ updatedAt: new Date().toISOString(),
203
+ }
204
+ state.saveGlobalIndex(gi)
205
+
206
+ const exec = { agent: { session: { header: { id: 'inject-per-turn', cwd, agentPreset: 'coding' } } } }
207
+ const query = 'staging deploy token rotation'
208
+ const base = state.getSettings()
209
+
210
+ // smart keeps the cadence: turn 2 is not a global turn while globalEveryNTurns is 6,
211
+ // and waiting for the cadence is exactly what per-turn exists to opt out of.
212
+ const smart = await inject.buildRecall(binding, query, 2, { ...base, autoInject: 'smart' }, exec)
213
+ assert.deepEqual(smart.errors, [])
214
+ assert.ok(!smart.ids.includes('mem-global-deploy'), 'smart must not pull the global layer off-cadence')
215
+
216
+ const perTurn2 = await inject.buildRecall(binding, query, 2, { ...base, autoInject: 'per-turn' }, exec)
217
+ assert.ok(perTurn2.ids.includes('mem-global-deploy'), 'per-turn recalls the global layer on a plain turn')
218
+ assert.ok(perTurn2.text.includes('staging deploy token is rotated'))
219
+
220
+ const perTurn5 = await inject.buildRecall(binding, query, 5, { ...base, autoInject: 'per-turn' }, exec)
221
+ assert.ok(perTurn5.ids.includes('mem-global-deploy'), 'and again on the next turn, not only every Nth turn')
222
+ })
@@ -0,0 +1,35 @@
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-injection-budget-settings-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const rpc = await import('../lib/rpc.js')
11
+
12
+ test.after(() => {
13
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
14
+ })
15
+
16
+ test('the settings RPC stores, clamps, and resets injectionTotalTokens', async () => {
17
+ const byDefault = await rpc.dispatch({}, 'getState', {})
18
+ assert.equal(byDefault.settings.injectionTotalTokens, 2000)
19
+
20
+ const saved = await rpc.dispatch({}, 'setSettings', { patch: { injectionTotalTokens: 2400 } })
21
+ assert.equal(saved.injectionTotalTokens, 2400)
22
+
23
+ const clamped = await rpc.dispatch({}, 'setSettings', { patch: { injectionTotalTokens: 0 } })
24
+ assert.equal(clamped.injectionTotalTokens, 160)
25
+
26
+ const reset = await rpc.dispatch({}, 'resetSettings', {})
27
+ assert.equal(reset.injectionTotalTokens, 2000)
28
+ })
29
+
30
+ test('the settings RPC rejects a non-numeric injectionTotalTokens', async () => {
31
+ await assert.rejects(
32
+ rpc.dispatch({}, 'setSettings', { patch: { injectionTotalTokens: '2400' } }),
33
+ /injectionTotalTokens must be a finite number/,
34
+ )
35
+ })