dsh-memu-memory 1.2.0 → 1.2.2

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 (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +19 -8
  3. package/README.zh.md +12 -6
  4. package/docs/contributing.md +16 -3
  5. package/docs/env-and-commands.md +8 -0
  6. package/docs/host-api.md +3 -1
  7. package/docs/memory-semantics.md +21 -2
  8. package/docs/troubleshooting.md +42 -3
  9. package/lib/activity-log.js +22 -5
  10. package/lib/backup.js +24 -2
  11. package/lib/binding-diagnostics.js +3 -3
  12. package/lib/client.js +1610 -117
  13. package/lib/consolidation-worker.js +68 -17
  14. package/lib/curator-evidence.js +131 -0
  15. package/lib/curator-policy.js +81 -0
  16. package/lib/curator.js +1299 -83
  17. package/lib/cycle.js +3 -0
  18. package/lib/embedding-download.js +39 -4
  19. package/lib/engine.js +6 -3
  20. package/lib/global-graph.js +282 -0
  21. package/lib/graph.js +190 -14
  22. package/lib/inbound.js +19 -0
  23. package/lib/index.js +33 -1
  24. package/lib/inject.js +277 -17
  25. package/lib/lexicon.js +1115 -0
  26. package/lib/local-vector-service.js +8 -6
  27. package/lib/managed-runtime.js +33 -2
  28. package/lib/memory.js +585 -76
  29. package/lib/memu.js +2 -2
  30. package/lib/migrate.js +83 -2
  31. package/lib/private-links.js +587 -0
  32. package/lib/private.js +128 -7
  33. package/lib/prompts/global-curator.md +76 -19
  34. package/lib/prompts/private-consolidation.coding.md +4 -1
  35. package/lib/prompts/private-consolidation.md +3 -1
  36. package/lib/prompts/private-consolidation.mixed.md +3 -1
  37. package/lib/prompts/private-consolidation.rp.md +3 -1
  38. package/lib/prompts.js +1 -1
  39. package/lib/rpc.js +622 -13
  40. package/lib/state.js +4 -1
  41. package/lib/text-retrieval.js +661 -0
  42. package/lib/text-similarity.js +124 -0
  43. package/lib/tools.js +230 -16
  44. package/lib/vector-migration.js +28 -3
  45. package/lib/vector-profile.js +24 -0
  46. package/lib/webui/standalone.css +17 -0
  47. package/lib/webui/standalone.js +2 -0
  48. package/package.json +3 -2
  49. package/scripts/acceptance-local.mjs +8 -5
  50. package/scripts/curator-pair-calibrate.mjs +259 -0
  51. package/scripts/lexicon-calibrate.mjs +230 -0
  52. package/scripts/lexicon-dedupe.mjs +318 -0
  53. package/scripts/replay.mjs +573 -0
  54. package/tests/absorb-review.test.mjs +353 -0
  55. package/tests/appendix-b.test.mjs +6 -2
  56. package/tests/appendix-b2.test.mjs +2 -2
  57. package/tests/client-export.test.mjs +520 -0
  58. package/tests/client-render.test.mjs +19 -3
  59. package/tests/consolidation-credential.test.mjs +16 -5
  60. package/tests/consolidation-feed.test.mjs +96 -0
  61. package/tests/consolidation-fork.test.mjs +144 -10
  62. package/tests/consolidation-snapshot.test.mjs +82 -4
  63. package/tests/curator-absorb.test.mjs +443 -0
  64. package/tests/curator-calibrate.test.mjs +173 -0
  65. package/tests/curator-clusters.test.mjs +281 -0
  66. package/tests/curator-commit.test.mjs +324 -0
  67. package/tests/curator-evidence.test.mjs +102 -0
  68. package/tests/curator-link.test.mjs +316 -0
  69. package/tests/curator-pairing.test.mjs +252 -0
  70. package/tests/curator-probe.test.mjs +15 -6
  71. package/tests/curator-review-mode.test.mjs +314 -0
  72. package/tests/disclosure.test.mjs +2 -1
  73. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  74. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  75. package/tests/fixtures/lexicon-stub.mjs +284 -0
  76. package/tests/graph-cascade.test.mjs +223 -0
  77. package/tests/graph.test.mjs +113 -0
  78. package/tests/help-page.test.mjs +415 -0
  79. package/tests/hybrid-recall.test.mjs +32 -2
  80. package/tests/inbound.test.mjs +6 -3
  81. package/tests/inject-guide.test.mjs +28 -0
  82. package/tests/inject.test.mjs +157 -5
  83. package/tests/lexicon-calibrate.test.mjs +48 -0
  84. package/tests/lexicon-dedupe.test.mjs +171 -0
  85. package/tests/lexicon-frequency.test.mjs +179 -0
  86. package/tests/lexicon-inject.test.mjs +410 -0
  87. package/tests/lexicon-perf.test.mjs +436 -0
  88. package/tests/lexicon-ui.test.mjs +547 -0
  89. package/tests/lexicon.test.mjs +922 -0
  90. package/tests/managed-memory.test.mjs +55 -0
  91. package/tests/managed-runtime.test.mjs +32 -0
  92. package/tests/private-links.test.mjs +445 -0
  93. package/tests/prompts.test.mjs +35 -0
  94. package/tests/recall-dedup.test.mjs +446 -0
  95. package/tests/sidebar-beat.test.mjs +5 -1
  96. package/tests/tool-schema.test.mjs +1 -1
  97. package/tests/tools.test.mjs +112 -3
  98. package/tests/vector-migration.test.mjs +58 -1
  99. package/tests/webui-auth.test.mjs +348 -2
  100. package/tests/webui.test.mjs +191 -2
@@ -0,0 +1,410 @@
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-lexicon-inject-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const paths = await import('../lib/paths.js')
11
+ const state = await import('../lib/state.js')
12
+ const lexicon = await import('../lib/lexicon.js')
13
+ const inject = await import('../lib/inject.js')
14
+ const inbound = await import('../lib/inbound.js')
15
+ const { estTokens } = await import('../lib/budget.js')
16
+
17
+ test.after(() => {
18
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
19
+ })
20
+
21
+ let serial = 0
22
+ function setupSession(name, { terms = [], memories = [] } = {}) {
23
+ serial += 1
24
+ const sessionId = `inject-${name}-${serial}`
25
+ const cwd = path.join(home, `project-${name}-${serial}`)
26
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'rp' })
27
+ if (memories.length) {
28
+ const index = state.loadPrivateIndex(binding.libraryId)
29
+ for (const { id, entry } of memories) index.entries[id] = entry
30
+ state.savePrivateIndex(binding.libraryId, index)
31
+ }
32
+ for (const args of terms) lexicon.upsertLexiconTerm({ libraryId: binding.libraryId, ...args })
33
+ // state.js 的 binding 不带 sessionId;测试里补上,方便把 binding/session 一起传。
34
+ return { ...binding, sessionId }
35
+ }
36
+
37
+ function agentFor(binding) {
38
+ return { session: { header: { id: binding.sessionId, cwd: path.join(home, 'cwd-' + binding.sessionId), agentPreset: 'rp', origin: 'user' } } }
39
+ }
40
+
41
+ function userMessage(id, text) {
42
+ return { id, role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text }] }
43
+ }
44
+
45
+ function pluginTexts(out) {
46
+ return out.messages
47
+ .filter(message => message?.source?.kind === 'plugin:dsh-memu-memory')
48
+ .map(message => Array.isArray(message.content) ? message.content.filter(b => b?.type === 'text').map(b => b.text).join('\n') : String(message.content || ''))
49
+ }
50
+
51
+ function lexFile(libraryId) {
52
+ return path.join(home, 'lexicon', `${libraryId}.json`)
53
+ }
54
+
55
+ function setStrength(libraryId, term, strength) {
56
+ const lex = lexicon.loadLexicon(libraryId)
57
+ for (const entry of Object.values(lex.terms)) {
58
+ if (entry.normalizedTerm === lexicon.normalizeLexeme(term)) entry.strength = strength
59
+ }
60
+ paths.writeJson(lexFile(libraryId), lex)
61
+ }
62
+
63
+ // ---- 匹配流水线(§3.1) ----
64
+
65
+ test('exact layer: term containment and whole-message equality both hit as exact-term', () => {
66
+ const binding = setupSession('exact', { terms: [{ term: '糖刀', kind: 'meme', meaning: '表面发糖、实际虐心' }] })
67
+ const hits = lexicon.matchLexiconHits(binding.libraryId, '这段太糖刀了', binding.memoryContext)
68
+ assert.equal(hits.length, 1)
69
+ assert.equal(hits[0].kindOfHit, 'exact-term')
70
+ assert.equal(hits[0].term, '糖刀')
71
+ assert.equal(hits[0].meaning, '表面发糖、实际虐心')
72
+ const whole = lexicon.matchLexiconHits(binding.libraryId, '糖刀', binding.memoryContext)
73
+ assert.equal(whole[0].kindOfHit, 'exact-term')
74
+ })
75
+
76
+ test('exact layer: aliases hit as exact-alias and fold onto the same term', () => {
77
+ const binding = setupSession('alias', { terms: [{ term: '糖刀', kind: 'meme', meaning: '表面发糖', aliases: ['糖里藏刀'] }] })
78
+ const hits = lexicon.matchLexiconHits(binding.libraryId, '这不就是糖里藏刀吗', binding.memoryContext)
79
+ assert.equal(hits.length, 1)
80
+ assert.equal(hits[0].kindOfHit, 'exact-alias')
81
+ assert.equal(hits[0].term, '糖刀')
82
+ })
83
+
84
+ test('dice fallback: a fuzzy variant inside the prefix bucket hits with score >= threshold; unrelated text does not', () => {
85
+ const binding = setupSession('dice', { terms: [{ term: '糖刀文学', kind: 'term', meaning: '又甜又虐的写法' }] })
86
+ const hits = lexicon.matchLexiconHits(binding.libraryId, '这段糖刀文雪啊', binding.memoryContext)
87
+ assert.equal(hits.length, 1, '糖刀文雪 vs 糖刀文学 shares the 糖刀 bucket and dices to 2/3')
88
+ assert.equal(hits[0].kindOfHit, 'dice')
89
+ assert.ok(hits[0].score >= lexicon.LEXICON_DICE_THRESHOLD)
90
+ assert.deepEqual(lexicon.matchLexiconHits(binding.libraryId, '今天天气不错', binding.memoryContext), [])
91
+ })
92
+
93
+ test('active and restored terms match; archived and rejected never match or search', () => {
94
+ const binding = setupSession('status', {})
95
+ const lib = binding.libraryId
96
+ // 用户写入直接 active(没有审核门)。
97
+ const archivedSeed = lexicon.upsertLexiconTerm({ libraryId: lib, term: '归档词', kind: 'term', meaning: '会被归档' })
98
+ assert.equal(archivedSeed.term.status, 'active')
99
+ assert.equal(lexicon.matchLexiconHits(lib, '来点归档词', binding.memoryContext).length, 1)
100
+ // 归档:退出匹配/注入/检索/锚点摘要。
101
+ lexicon.archiveLexiconTerm({ libraryId: lib, termId: archivedSeed.term.termId })
102
+ assert.deepEqual(lexicon.matchLexiconHits(lib, '来点归档词', binding.memoryContext), [], 'archived terms leave the matching pipeline')
103
+ assert.deepEqual(lexicon.searchLexicon({ libraryId: lib, query: '归档词', memoryContext: binding.memoryContext }), [], 'archived terms are not searchable')
104
+ assert.ok(!lexicon.visibleLexiconTerms(lib, binding.memoryContext).some(entry => entry.term === '归档词'))
105
+ assert.ok(!lexicon.lexiconAnchorSummary(lib, {}).lines.join('\n').includes('归档词'), 'archived terms stay out of the anchor summary')
106
+ // 恢复:重新参与注入与检索。
107
+ lexicon.restoreLexiconTerm({ libraryId: lib, termId: archivedSeed.term.termId })
108
+ assert.equal(lexicon.matchLexiconHits(lib, '来点归档词', binding.memoryContext).length, 1, 'restored terms rejoin injection')
109
+ assert.equal(lexicon.searchLexicon({ libraryId: lib, query: '归档词', memoryContext: binding.memoryContext }).length, 1)
110
+ // 模型提案同样直接 active,没有 pending / 审核步骤。
111
+ const proposal = lexicon.upsertLexiconTerm({ libraryId: lib, term: '提案词', kind: 'term', meaning: '直接生效', source: 'model-proposal' })
112
+ assert.equal(proposal.term.status, 'active', 'model proposals are active immediately')
113
+ assert.equal(lexicon.matchLexiconHits(lib, '来点提案词', binding.memoryContext).length, 1)
114
+ // rejected 是内部墓碑(没有公开 API 产出它):永不匹配、永不检索。
115
+ const tombstone = lexicon.upsertLexiconTerm({ libraryId: lib, term: '墓碑词', kind: 'term', meaning: '内部墓碑' })
116
+ const file = lexFile(lib)
117
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
118
+ raw.terms[tombstone.term.termId].status = 'rejected'
119
+ paths.writeJson(file, raw)
120
+ assert.deepEqual(lexicon.matchLexiconHits(lib, '来点墓碑词', binding.memoryContext), [], 'rejected tombstones never match')
121
+ assert.deepEqual(lexicon.searchLexicon({ libraryId: lib, query: '墓碑词', memoryContext: binding.memoryContext }), [], 'rejected tombstones never search')
122
+ assert.equal(lexicon.matchLexiconHits(lib, '来点归档词', binding.memoryContext).length, 1, 'the restored term still matches')
123
+ })
124
+
125
+ test('stableRank: sensitive trigger > exact term > dice, then strength×confidence, deterministic across runs', () => {
126
+ const binding = setupSession('rank', { terms: [
127
+ { term: '敏感词甲', kind: 'trigger', meaning: '触发' }, // trigger 默认 sensitive
128
+ { term: '普通词乙', kind: 'meme', meaning: '普通' },
129
+ { term: '糖刀文学', kind: 'term', meaning: '模糊' },
130
+ ] })
131
+ const lib = binding.libraryId
132
+ setStrength(lib, '敏感词甲', 0.1)
133
+ setStrength(lib, '普通词乙', 0.9)
134
+ setStrength(lib, '糖刀文学', 0.5)
135
+ const hits = lexicon.matchLexiconHits(lib, '敏感词甲和普通词乙加糖刀文雪', binding.memoryContext)
136
+ assert.deepEqual(hits.map(hit => hit.term), ['敏感词甲', '普通词乙', '糖刀文学'],
137
+ 'sensitive trigger first, then exact by strength, dice last — despite strength order')
138
+ const again = lexicon.matchLexiconHits(lib, '敏感词甲和普通词乙加糖刀文雪', binding.memoryContext)
139
+ assert.deepEqual(again, hits, 'the ranking is a pure function of the data, not of time or iteration order')
140
+ })
141
+
142
+ test('stableRank: final tie-break is the lexicographic id, never the clock', () => {
143
+ const binding = setupSession('tie', { terms: [
144
+ { term: '双生甲', kind: 'term', meaning: '同分一' },
145
+ { term: '双生乙', kind: 'term', meaning: '同分二' },
146
+ ] })
147
+ // 两个词条完全同分(strength/confidence/updatedAt 手工抹平):顺序只能由 termId 决定。
148
+ const lex = lexicon.loadLexicon(binding.libraryId)
149
+ for (const entry of Object.values(lex.terms)) {
150
+ entry.strength = 0.5
151
+ entry.confidence = 0.5
152
+ entry.updatedAt = '2026-01-01T00:00:00.000Z'
153
+ }
154
+ paths.writeJson(lexFile(binding.libraryId), lex)
155
+ const hits = lexicon.matchLexiconHits(binding.libraryId, '双生甲和双生乙都在这句话里', binding.memoryContext)
156
+ assert.equal(hits.length, 2)
157
+ assert.deepEqual(hits.map(hit => hit.termId), [...hits.map(hit => hit.termId)].sort((a, b) => a.localeCompare(b)))
158
+ const onceMore = lexicon.matchLexiconHits(binding.libraryId, '双生甲和双生乙都在这句话里', binding.memoryContext)
159
+ assert.deepEqual(onceMore.map(hit => hit.termId), hits.map(hit => hit.termId))
160
+ })
161
+
162
+ // ---- 冷却与回合预算(§3.3) ----
163
+
164
+ test('cooldownAndBudget: at most LEXICON_MAX_PER_TURN terms per turn, token budget respected', () => {
165
+ const binding = setupSession('budget', { terms: [
166
+ { term: '预算词甲', kind: 'term', meaning: '甲' },
167
+ { term: '预算词乙', kind: 'term', meaning: '乙' },
168
+ { term: '预算词丙', kind: 'term', meaning: '丙' },
169
+ ] })
170
+ setStrength(binding.libraryId, '预算词甲', 0.9)
171
+ setStrength(binding.libraryId, '预算词乙', 0.6)
172
+ setStrength(binding.libraryId, '预算词丙', 0.3)
173
+ lexicon.lexiconCooldownStore.clear()
174
+ const hits = lexicon.matchLexiconHits(binding.libraryId, '预算词甲预算词乙预算词丙', binding.memoryContext)
175
+ assert.equal(hits.length, 3)
176
+ const { selected, onCooldown } = lexicon.cooldownAndBudget(hits, binding.sessionId, 10_000)
177
+ assert.equal(selected.length, lexicon.LEXICON_MAX_PER_TURN)
178
+ assert.deepEqual(selected.map(hit => hit.term), ['预算词甲', '预算词乙'], 'highest strength first; the third waits for the next turn')
179
+ assert.deepEqual(onCooldown, [])
180
+ // 预算小到一行都放不下:一个都不选,hits 原样保留给 candidate 计数。
181
+ const squeezed = lexicon.cooldownAndBudget(hits, binding.sessionId, 3)
182
+ assert.deepEqual(squeezed.selected, [])
183
+ })
184
+
185
+ test('cooldown: within 30 minutes a term is counted as candidate but never injected again', () => {
186
+ const binding = setupSession('cooldown', { terms: [{ term: '冷却词', kind: 'term', meaning: '含义' }] })
187
+ lexicon.lexiconCooldownStore.clear()
188
+ const lib = binding.libraryId
189
+ // loadLexicon 返回原始 terms 表,termId 就是键名。
190
+ const entryId = () => Object.keys(lexicon.loadLexicon(lib).terms)[0]
191
+
192
+ const first = inject.injectLexiconTurn({ binding, sessionId: binding.sessionId, userTexts: ['这句话提到冷却词'], tokenBudget: 10_000 })
193
+ assert.equal(first.selected.length, 1)
194
+ inject.commitLexiconTurn({ libraryId: lib, sessionId: binding.sessionId, hits: first.hits, selected: first.selected })
195
+ assert.ok(lexicon.lexiconCooldownStore.get(binding.sessionId)?.has(entryId()), 'the cooldown store remembers the injection per binding')
196
+
197
+ const second = inject.injectLexiconTurn({ binding, sessionId: binding.sessionId, userTexts: ['冷却词又出现了'], tokenBudget: 10_000 })
198
+ assert.equal(second.text, '', 'no B-channel text while cooling down')
199
+ assert.equal(second.selected.length, 0)
200
+ assert.equal(second.onCooldown.length, 1)
201
+ inject.commitLexiconTurn({ libraryId: lib, sessionId: binding.sessionId, hits: second.hits, selected: second.selected })
202
+
203
+ const entry = lexicon.loadLexicon(lib).terms[entryId()]
204
+ assert.deepEqual(entry.hitCounts, { candidate: 2, injection: 1, query: 0 },
205
+ 'cooldown suppresses injection only; the candidate still counts')
206
+ assert.ok(lexicon.lexiconOnCooldown(binding.sessionId, entryId()))
207
+ assert.equal(lexicon.LEXICON_RECALL_COOLDOWN_MINUTES, 30)
208
+ assert.equal(lexicon.LEXICON_MAX_PER_TURN, 2)
209
+ })
210
+
211
+ test('cooldown does not leak across bindings', () => {
212
+ const binding = setupSession('cooldown-scope', { terms: [{ term: '跨会话词', kind: 'term', meaning: '含义' }] })
213
+ lexicon.lexiconCooldownStore.clear()
214
+ const first = inject.injectLexiconTurn({ binding, sessionId: binding.sessionId, userTexts: ['跨会话词'], tokenBudget: 10_000 })
215
+ inject.commitLexiconTurn({ libraryId: binding.libraryId, sessionId: binding.sessionId, hits: first.hits, selected: first.selected })
216
+ const other = inject.injectLexiconTurn({ binding, sessionId: 'another-binding', userTexts: ['跨会话词'], tokenBudget: 10_000 })
217
+ assert.equal(other.selected.length, 1, 'another session has its own cooldown window')
218
+ })
219
+
220
+ // ---- 锚点摘要(通道 A) ----
221
+
222
+ test('buildLexiconAnchor packs to the 300-token hard cap and drops the weakest lines first', () => {
223
+ const terms = []
224
+ for (let i = 0; i < 9; i++) terms.push({ term: `词条甲乙丙${i}`, kind: 'term', meaning: '长'.repeat(400) })
225
+ const binding = setupSession('anchor-cap', { terms })
226
+ const lib = binding.libraryId
227
+ const lex = lexicon.loadLexicon(lib)
228
+ const ids = Object.keys(lex.terms)
229
+ ids.forEach((id, i) => { lex.terms[id].strength = (i + 1) / ids.length })
230
+ paths.writeJson(lexFile(lib), lex)
231
+
232
+ // 300 硬上限:8 条上限 + 超预算丢尾部(最弱的一条先丢)。每条 meaning 截到
233
+ // 80 字 ≈ 86 token,300 的预算装得下 3 条。
234
+ const full = inject.buildLexiconAnchor(binding, binding.sessionId, 300)
235
+ assert.ok(full.text.length > 0)
236
+ assert.ok(estTokens(full.text) <= 300, `anchor section must fit 300 tokens, got ${estTokens(full.text)}`)
237
+ assert.ok(!full.text.includes('词条甲乙丙0'), 'the weakest term is the first dropped')
238
+ const summary = lexicon.lexiconAnchorSummary(lib, {})
239
+ assert.equal(summary.lines.length, 8, 'at most 8 summary lines even with 9 candidates')
240
+ assert.match(summary.lines[0], /词条甲乙丙8/, 'strongest first')
241
+
242
+ // 更小的预算:只有最强的几行能留下。
243
+ const tight = inject.buildLexiconAnchor(binding, binding.sessionId, 200)
244
+ assert.ok(estTokens(tight.text) <= 200, `tight section must fit 200 tokens, got ${estTokens(tight.text)}`)
245
+ assert.ok(tight.text.includes('词条甲乙丙8'))
246
+ assert.ok(!tight.text.includes('词条甲乙丙2'))
247
+ // 预算为 0:整节放弃,不挤占记忆预算。
248
+ assert.deepEqual(inject.buildLexiconAnchor(binding, binding.sessionId, 0), { text: '', ids: [] })
249
+ })
250
+
251
+ test('the anchor section lists sensitive triggers separately with cap 2', () => {
252
+ const terms = []
253
+ for (let i = 0; i < 4; i++) terms.push({ term: `触发词组${i}`, kind: 'trigger', meaning: `触发${i}` })
254
+ terms.push({ term: '普通展示词', kind: 'term', meaning: '普通' })
255
+ const binding = setupSession('anchor-sensitive', { terms })
256
+ const lib = binding.libraryId
257
+ const lex = lexicon.loadLexicon(lib)
258
+ // 拉开梯度,避免同分 tie-break 让"前两名"变成随机对。
259
+ const strengths = { 触发词组0: 0.9, 触发词组1: 0.8, 触发词组2: 0.7, 触发词组3: 0.6, 普通展示词: 0.1 }
260
+ for (const entry of Object.values(lex.terms)) entry.strength = strengths[entry.term] ?? 0.5
261
+ paths.writeJson(lexFile(lib), lex)
262
+ const section = inject.buildLexiconAnchor(binding, binding.sessionId, 300)
263
+ assert.match(section.text, /\[lexicon · 敏感词\]/)
264
+ const sensitivePart = section.text.split('[lexicon · 敏感词]')[1] || ''
265
+ assert.ok(sensitivePart.includes('触发词组0') && sensitivePart.includes('触发词组1'))
266
+ assert.ok(!sensitivePart.includes('触发词组2'), 'at most 2 sensitive triggers in the anchor')
267
+ assert.ok(section.text.includes('普通展示词'), 'normal terms still appear in the main lines')
268
+ })
269
+
270
+ // ---- 端到端:injectForStep(原生用户消息路径) ----
271
+
272
+ test('injectForStep appends the anchor lexicon summary and the B-channel hit block', async () => {
273
+ const binding = setupSession('e2e', {
274
+ terms: [
275
+ { term: '糖刀', kind: 'meme', meaning: '表面发糖、实际虐心' },
276
+ { term: '锚点常驻词', kind: 'term', meaning: '常驻含义' },
277
+ ],
278
+ memories: [{
279
+ id: 'mem-pin',
280
+ entry: {
281
+ track: 'memory', layer: 'must_remember', title: '凉茶', content: '今天他走的时候没看我。',
282
+ status: 'active', pinSource: 'user_explicit', pinReason: '记得', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
283
+ },
284
+ }],
285
+ })
286
+ const agent = agentFor(binding)
287
+ // 真实用户消息先走 inbound journal(与宿主事件路径同构),再进入决策消息。
288
+ inbound.ingestInboundBatch({
289
+ sessionId: binding.sessionId, session: agent.session, ctx: null,
290
+ batch: { type: inbound.INBOUND_TYPE, version: 1, producer: 'host.user', batchId: 'user:u1',
291
+ messages: [{ id: 'u1', role: 'user', context: {}, content: [{ type: 'text', text: '这段太糖刀了' }] }] },
292
+ })
293
+ const decision = { kind: 'enter', messages: [userMessage('u1', '这段太糖刀了')] }
294
+ const out = await inject.injectForStep({ agent, decision, turn: 1, step: 1, signal: { aborted: false }, log: () => {}, ctx: null })
295
+ const texts = pluginTexts(out)
296
+ assert.equal(texts.length, 1, 'one plugin message carries anchor + lexicon')
297
+ assert.match(texts[0], /===== memory anchor/)
298
+ assert.match(texts[0], /凉茶/, 'memory recall itself is untouched')
299
+ assert.match(texts[0], /\[lexicon · 词典\]/, 'anchor lexicon summary rides the anchor block')
300
+ assert.match(texts[0], /锚点常驻词/)
301
+ assert.match(texts[0], /<memory-lexicon>/, 'the B-channel hit block is in the same message')
302
+ assert.match(texts[0], /糖刀」:表面发糖、实际虐心/)
303
+
304
+ // 注入记账:candidate + injection 各一次,冷却已记。
305
+ const lib = binding.libraryId
306
+ const [entryId, entry] = Object.entries(lexicon.loadLexicon(lib).terms).find(([, t]) => t.normalizedTerm === '糖刀')
307
+ assert.deepEqual(entry.hitCounts, { candidate: 1, injection: 1, query: 0 })
308
+ assert.ok(lexicon.lexiconOnCooldown(binding.sessionId, entryId))
309
+ })
310
+
311
+ test('the second turn inside the cooldown window injects no lexicon block but still counts candidates', async () => {
312
+ const binding = setupSession('e2e-cooldown', { terms: [{ term: '冷却验证词', kind: 'term', meaning: '含义' }] })
313
+ const agent = agentFor(binding)
314
+ const send = async (id, text, turn) => {
315
+ inbound.ingestInboundBatch({
316
+ sessionId: binding.sessionId, session: agent.session, ctx: null,
317
+ batch: { type: inbound.INBOUND_TYPE, version: 1, producer: 'host.user', batchId: `user:${id}`,
318
+ messages: [{ id, role: 'user', context: {}, content: [{ type: 'text', text }] }] },
319
+ })
320
+ return inject.injectForStep({ agent, decision: { kind: 'enter', messages: [userMessage(id, text)] }, turn, step: 1, signal: { aborted: false }, log: () => {}, ctx: null })
321
+ }
322
+ await send('c1', '关于冷却验证词', 1)
323
+ const second = await send('c2', '再说一次冷却验证词', 2)
324
+ const texts = pluginTexts(second)
325
+ assert.ok(!texts.some(text => text.includes('<memory-lexicon>')), 'cooldown blocks the repeat injection')
326
+ const entry = Object.values(lexicon.loadLexicon(binding.libraryId).terms)[0]
327
+ assert.deepEqual(entry.hitCounts, { candidate: 2, injection: 1, query: 0 })
328
+ })
329
+
330
+ // ---- 端到端:inbound 批次路径(桥接 producer,无原生用户消息的回合) ----
331
+
332
+ test('the B channel also rides the real inbound batch path', async () => {
333
+ const binding = setupSession('e2e-bridge', { terms: [{ term: '糖刀', kind: 'meme', meaning: '表面发糖、实际虐心' }] })
334
+ const agent = agentFor(binding)
335
+ inbound.ingestInboundBatch({
336
+ sessionId: binding.sessionId, session: agent.session, ctx: null,
337
+ batch: { type: inbound.INBOUND_TYPE, version: 1, producer: 'friend-app', batchId: 'bridge:1',
338
+ messages: [{ id: 'bridge-1', role: 'user', context: {}, content: [{ type: 'text', text: '这段太糖刀了' }] }] },
339
+ })
340
+ const out = await inject.injectForStep({ agent, decision: { kind: 'enter', messages: [] }, turn: 1, step: 1, signal: { aborted: false }, log: () => {}, ctx: null })
341
+ const texts = pluginTexts(out)
342
+ assert.equal(texts.length, 1)
343
+ assert.match(texts[0], /<memory-lexicon>/)
344
+ assert.match(texts[0], /表面发糖、实际虐心/)
345
+ const entry = Object.values(lexicon.loadLexicon(binding.libraryId).terms)[0]
346
+ assert.deepEqual(entry.hitCounts, { candidate: 1, injection: 1, query: 0 })
347
+ })
348
+
349
+ test('plugin notices, tool results and assistant text are not lexicon text sources', async () => {
350
+ const binding = setupSession('e2e-sources', { terms: [{ term: '糖刀', kind: 'meme', meaning: '表面发糖' }] })
351
+ const agent = agentFor(binding)
352
+ // 决策消息里只有插件消息与 assistant 自言自语;没有真实用户消息。
353
+ const pluginNotice = {
354
+ id: 'notice-1', role: 'user', source: { kind: 'plugin:dsh-memu-memory' },
355
+ content: [{ type: 'text', text: '糖刀糖刀糖刀' }],
356
+ }
357
+ const assistant = {
358
+ id: 'a1', role: 'assistant', content: [{ type: 'text', text: '我自己提到了糖刀' }],
359
+ }
360
+ const out = await inject.injectForStep({ agent, decision: { kind: 'enter', messages: [pluginNotice, assistant] }, turn: 1, step: 1, signal: { aborted: false }, log: () => {}, ctx: null })
361
+ const texts = pluginTexts(out)
362
+ assert.ok(!texts.some(text => text.includes('<memory-lexicon>')), 'no lexicon hit may come from non-user text')
363
+ const entry = Object.values(lexicon.loadLexicon(binding.libraryId).terms)[0]
364
+ assert.deepEqual(entry.hitCounts, { candidate: 0, injection: 0, query: 0 })
365
+ })
366
+
367
+ // ---- fail-open(§3.3) ----
368
+
369
+ test('a broken lexicon degrades with logger.warn + lastRecallError and never blocks memory recall', async () => {
370
+ const binding = setupSession('failopen', {
371
+ terms: [{ term: '糖刀', kind: 'meme', meaning: '表面发糖' }],
372
+ memories: [{
373
+ id: 'mem-pin',
374
+ entry: {
375
+ track: 'memory', layer: 'must_remember', title: '凉茶', content: '今天他走的时候没看我。',
376
+ status: 'active', pinSource: 'user_explicit', pinReason: '记得', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
377
+ },
378
+ }],
379
+ })
380
+ // 词典文件损坏:loadLexicon 抛 LEXICON_CORRUPT,注入链必须吞掉并继续。
381
+ fs.writeFileSync(lexFile(binding.libraryId), '{ this is not json')
382
+ const warnings = []
383
+ const agent = agentFor(binding)
384
+ inbound.ingestInboundBatch({
385
+ sessionId: binding.sessionId, session: agent.session, ctx: null,
386
+ batch: { type: inbound.INBOUND_TYPE, version: 1, producer: 'host.user', batchId: 'user:u1',
387
+ messages: [{ id: 'u1', role: 'user', context: {}, content: [{ type: 'text', text: '这段太糖刀了' }] }] },
388
+ })
389
+ const out = await inject.injectForStep({
390
+ agent, decision: { kind: 'enter', messages: [userMessage('u1', '这段太糖刀了')] },
391
+ turn: 1, step: 1, signal: { aborted: false },
392
+ log: (message) => warnings.push(String(message)), ctx: null,
393
+ })
394
+ const texts = pluginTexts(out)
395
+ assert.equal(texts.length, 1, 'the plugin message still exists')
396
+ assert.match(texts[0], /===== memory anchor/, 'memory recall output survived')
397
+ assert.match(texts[0], /凉茶/, 'the pinned memory is still injected')
398
+ assert.ok(!texts[0].includes('<memory-lexicon>'), 'no lexicon block when the lexicon is broken')
399
+ assert.ok(warnings.some(w => /lexicon degraded/.test(w)), 'the failure is logged, not swallowed silently')
400
+ const current = state.getSessionBinding(binding.sessionId)
401
+ assert.match(String(current.lastRecallError || ''), /lexicon/, 'binding.lastRecallError records the degradation')
402
+ // 记忆召回的错误面没有被词典污染成"召回失败"。
403
+ assert.ok(!/private recall failed/.test(String(current.lastRecallError || '')))
404
+ })
405
+
406
+ test('a corrupt lexicon still surfaces a typed error for readers', () => {
407
+ const binding = setupSession('failopen-read', {})
408
+ fs.writeFileSync(lexFile(binding.libraryId), 'not json at all')
409
+ assert.throws(() => lexicon.loadLexicon(binding.libraryId), (error) => error.code === 'LEXICON_CORRUPT')
410
+ })