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,373 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import test from 'node:test'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ import {
10
+ CATEGORIES,
11
+ FIXTURE_DIR,
12
+ NECESSARY_CATEGORIES,
13
+ aggregateMetrics,
14
+ evaluateDialogue,
15
+ findRepeatedPhrases,
16
+ loadDataset,
17
+ maskForRepetition,
18
+ runEvaluation,
19
+ validateDialogueShape,
20
+ } from '../scripts/repetition-eval.mjs'
21
+
22
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
23
+ const ROOT = path.resolve(HERE, '..')
24
+ const SCRIPT = path.join(ROOT, 'scripts', 'repetition-eval.mjs')
25
+ const BACKTICK = String.fromCharCode(96)
26
+
27
+ function runCli(args, options = {}) {
28
+ const result = spawnSync(process.execPath, [SCRIPT, ...args], {
29
+ cwd: options.cwd || ROOT,
30
+ encoding: 'utf8',
31
+ maxBuffer: 20 * 1024 * 1024,
32
+ timeout: 30000,
33
+ })
34
+ if (result.error) throw result.error
35
+ return result
36
+ }
37
+
38
+ function turnsWith(assistantTexts) {
39
+ const turns = []
40
+ for (const text of assistantTexts) {
41
+ turns.push({ role: 'user', text: '继续' })
42
+ turns.push({ role: 'assistant', text })
43
+ }
44
+ return turns
45
+ }
46
+
47
+ function makeDialogue(overrides = {}) {
48
+ const defaults = {
49
+ schemaVersion: 1,
50
+ kind: 'repetition-eval-dialogue',
51
+ draft: true,
52
+ notGold: true,
53
+ id: 'math-dialogue',
54
+ pairId: 'math-pair',
55
+ category: 'zh-rp',
56
+ condition: 'baseline',
57
+ window: 4,
58
+ turns: turnsWith([
59
+ '林眠轻轻叹了口气,说走吧。',
60
+ '林眠轻轻叹了口气,说走吧。',
61
+ '林眠抬头看天,没有说话。',
62
+ ]),
63
+ labels: { necessaryPhrases: [], facts: [] },
64
+ }
65
+ return validateDialogueShape({ ...defaults, ...overrides })
66
+ }
67
+
68
+ function fixture(category) {
69
+ return loadDataset().dialogues.find(dialogue => dialogue.category === category)
70
+ }
71
+
72
+ test('synthetic fixture set is complete and marked not-gold', () => {
73
+ const dataset = loadDataset()
74
+ assert.equal(dataset.name, 'repetition-eval-synthetic')
75
+ assert.equal(dataset.status, 'synthetic')
76
+ assert.equal(dataset.draft, true)
77
+ assert.equal(dataset.notGold, true)
78
+ assert.equal(dataset.files.length, 6)
79
+ assert.equal(dataset.dialogues.length, 6)
80
+ assert.match(dataset.sha256, /^[a-f0-9]{64}$/)
81
+
82
+ const turns = dataset.dialogues.flatMap(dialogue => dialogue.turns)
83
+ assert.equal(turns.length, 36)
84
+ assert.equal(turns.filter(turn => turn.role === 'assistant').length, 18)
85
+ assert.deepEqual([...new Set(dataset.dialogues.map(dialogue => dialogue.category))].sort(), [...CATEGORIES].sort())
86
+ assert.ok(dataset.dialogues.every(dialogue => dialogue.condition === 'baseline'))
87
+
88
+ const labelCategories = new Set(
89
+ dataset.dialogues.flatMap(dialogue => dialogue.labels.necessaryPhrases.map(label => label.category)),
90
+ )
91
+ for (const category of NECESSARY_CATEGORIES) assert.ok(labelCategories.has(category), category)
92
+ assert.ok(dataset.dialogues.every(dialogue => dialogue.draft === true && dialogue.notGold === true))
93
+ })
94
+
95
+ test('aggregateMetrics uses explicit denominators and rounds consistently', () => {
96
+ const metrics = aggregateMetrics([
97
+ {
98
+ eligibleTurns: 3,
99
+ repeatedTurns: 2,
100
+ repeatedPhraseEvents: 3,
101
+ necessaryLabels: 4,
102
+ necessaryRetained: 3,
103
+ factsTotal: 2,
104
+ factsComplete: 1,
105
+ repetitionEvents: [{ phrase: 'a' }, { phrase: 'a' }, { phrase: 'b' }],
106
+ },
107
+ {
108
+ eligibleTurns: 3,
109
+ repeatedTurns: 1,
110
+ repeatedPhraseEvents: 2,
111
+ necessaryLabels: 0,
112
+ necessaryRetained: 0,
113
+ factsTotal: 2,
114
+ factsComplete: 2,
115
+ repetitionEvents: [{ phrase: 'b' }],
116
+ },
117
+ ])
118
+ assert.equal(metrics.phraseRepetitionRate, 0.5)
119
+ assert.equal(metrics.repeatedTurns, 3)
120
+ assert.equal(metrics.eligibleTurns, 6)
121
+ assert.equal(metrics.repeatedPhraseEvents, 5)
122
+ assert.equal(metrics.distinctRepeatedPhrases, 2)
123
+ assert.equal(metrics.necessaryRetentionRate, 0.75)
124
+ assert.equal(metrics.factCompleteness, 0.75)
125
+ assert.deepEqual(metrics.denominators, { eligibleTurns: 6, necessaryLabels: 4, factsTotal: 4 })
126
+
127
+ const third = aggregateMetrics([
128
+ {
129
+ eligibleTurns: 3,
130
+ repeatedTurns: 1,
131
+ repeatedPhraseEvents: 1,
132
+ necessaryLabels: 0,
133
+ necessaryRetained: 0,
134
+ factsTotal: 0,
135
+ factsComplete: 0,
136
+ repetitionEvents: [{ phrase: 'only' }],
137
+ },
138
+ ])
139
+ assert.equal(third.phraseRepetitionRate, 0.333333)
140
+ assert.equal(third.emptySet.necessaryRetentionRate, true)
141
+ assert.equal(third.emptySet.factCompleteness, true)
142
+ })
143
+
144
+ test('empty denominators are zeros with emptySet flags, not perfect scores', () => {
145
+ const empty = aggregateMetrics([])
146
+ assert.equal(empty.phraseRepetitionRate, 0)
147
+ assert.equal(empty.necessaryRetentionRate, 0)
148
+ assert.equal(empty.factCompleteness, 0)
149
+ assert.deepEqual(empty.emptySet, {
150
+ phraseRepetitionRate: true,
151
+ necessaryRetentionRate: true,
152
+ factCompleteness: true,
153
+ })
154
+ assert.deepEqual(empty.denominators, { eligibleTurns: 0, necessaryLabels: 0, factsTotal: 0 })
155
+
156
+ const onlyTurn = makeDialogue({
157
+ id: 'empty-dialogue',
158
+ turns: [{ role: 'user', text: '你好' }, { role: 'assistant', text: '你好。' }],
159
+ })
160
+ const evaluated = evaluateDialogue(onlyTurn)
161
+ assert.equal(evaluated.eligibleTurns, 0)
162
+ assert.equal(evaluated.phraseRepetitionRate, 0)
163
+ assert.equal(evaluated.emptySet.phraseRepetitionRate, true)
164
+ assert.equal(evaluated.emptySet.necessaryRetentionRate, true)
165
+ assert.equal(evaluated.emptySet.factCompleteness, true)
166
+
167
+ const report = runEvaluation()
168
+ assert.equal(report.metrics.humanPreference.status, 'not-collected')
169
+ assert.equal(report.metrics.humanPreference.preferenceRate, null)
170
+ assert.equal(report.metrics.humanPreference.denominator, 0)
171
+ assert.equal(report.metrics.humanPreference.emptySet, true)
172
+ })
173
+
174
+ test('evaluateDialogue computes the rate on the eligible assistant turns', () => {
175
+ const dialogue = makeDialogue({
176
+ id: 'math-rate-dialogue',
177
+ labels: {
178
+ necessaryPhrases: [
179
+ { text: '林眠', category: 'name', turn: 1 },
180
+ { text: '林眠', category: 'name', turn: 3 },
181
+ { text: '林眠', category: 'name', turn: 5 },
182
+ ],
183
+ facts: [
184
+ { id: 'math-fact-1', turn: 1, requiredTerms: ['走吧'] },
185
+ { id: 'math-fact-2', turn: 3, requiredTerms: ['走吧'] },
186
+ { id: 'math-fact-3', turn: 5, requiredTerms: ['抬头'] },
187
+ ],
188
+ },
189
+ })
190
+ const evaluated = evaluateDialogue(dialogue)
191
+ assert.equal(evaluated.assistantTurns, 3)
192
+ assert.equal(evaluated.eligibleTurns, 2)
193
+ assert.equal(evaluated.repeatedTurns, 1)
194
+ assert.equal(evaluated.phraseRepetitionRate, 0.5)
195
+ assert.equal(evaluated.necessaryLabels, 3)
196
+ assert.equal(evaluated.necessaryRetained, 3)
197
+ assert.equal(evaluated.necessaryRetentionRate, 1)
198
+ assert.equal(evaluated.necessaryByCategory.name.retained, 3)
199
+ assert.equal(evaluated.factsTotal, 3)
200
+ assert.equal(evaluated.factsComplete, 3)
201
+ assert.ok(evaluated.repetitionEvents.length > 0)
202
+ assert.ok(evaluated.repetitionEvents.every(event => event.turn > event.priorTurn))
203
+ assert.ok(!evaluated.repetitionEvents.some(event => event.phrase.includes('林眠')))
204
+ })
205
+
206
+ test('necessary retention reports missing labels and facts', () => {
207
+ const dialogue = makeDialogue({
208
+ id: 'missing-label-dialogue',
209
+ turns: turnsWith(['她轻轻点头。', '她轻轻点头。', '她抬头看天。']),
210
+ labels: {
211
+ necessaryPhrases: [{ text: '林眠', category: 'name', turn: 1 }],
212
+ facts: [{ id: 'missing-fact', turn: 1, requiredTerms: ['铜钥匙'] }],
213
+ },
214
+ })
215
+ const evaluated = evaluateDialogue(dialogue)
216
+ assert.equal(evaluated.necessaryRetained, 0)
217
+ assert.equal(evaluated.necessaryRetentionRate, 0)
218
+ assert.equal(evaluated.necessaryByCategory.name.total, 1)
219
+ assert.equal(evaluated.factsComplete, 0)
220
+ assert.equal(evaluated.factCompleteness, 0)
221
+ assert.deepEqual(evaluated.facts[0].missingTerms, ['铜钥匙'])
222
+ })
223
+
224
+ test('findRepeatedPhrases detects maximal CJK and Latin spans only', () => {
225
+ const cjk = findRepeatedPhrases('林眠轻轻叹了口气,望着窗外。', ['林眠轻轻叹了口气,没有说话。'])
226
+ assert.ok(cjk.length > 0)
227
+ assert.ok(cjk.every(event => event.kind === 'cjk'))
228
+ assert.ok(cjk.some(event => event.phrase.includes('轻轻叹了口气')))
229
+
230
+ const latin = findRepeatedPhrases('The rain kept falling on the roof.', ['The rain kept falling on the glass.'])
231
+ assert.ok(latin.length > 0)
232
+ assert.ok(latin.every(event => event.kind === 'latin'))
233
+ assert.ok(latin.some(event => event.phrase.toLowerCase().includes('rain kept falling')))
234
+
235
+ assert.deepEqual(findRepeatedPhrases('完全不同的短句。', ['另一个短句。']), [])
236
+ })
237
+
238
+ test('code spans and code-like tokens are protected from prose matching', () => {
239
+ const fenced = BACKTICK.repeat(3) + 'js\nconst value = parseConfig(raw)\n' + BACKTICK.repeat(3)
240
+ const maskedFence = maskForRepetition(fenced)
241
+ assert.deepEqual(findRepeatedPhrases(maskedFence, [maskedFence]), [])
242
+
243
+ const inline = BACKTICK + 'parseConfig' + BACKTICK + ' ' + BACKTICK + 'config.port' + BACKTICK
244
+ const maskedInline = maskForRepetition(inline)
245
+ assert.deepEqual(findRepeatedPhrases(maskedInline, [maskedInline]), [])
246
+
247
+ assert.deepEqual(
248
+ findRepeatedPhrases('Call parseConfig and sessionId now.', ['Call parseConfig and sessionId later.']),
249
+ [],
250
+ )
251
+
252
+ const maskedNecessary = maskForRepetition('林眠把铜钥匙放在桌角。', ['铜钥匙'])
253
+ assert.ok(!maskedNecessary.includes('铜钥匙'))
254
+ assert.ok(maskedNecessary.includes('林眠'))
255
+ })
256
+
257
+ test('English and code fixtures do not flag names, quotes, or identifiers', () => {
258
+ const english = evaluateDialogue(fixture('en'))
259
+ assert.equal(english.phraseRepetitionRate, 0.5)
260
+ assert.equal(english.necessaryRetentionRate, 1)
261
+ assert.equal(english.factsTotal, 3)
262
+ assert.equal(english.factCompleteness, 1)
263
+ assert.ok(english.repetitionEvents.some(event => event.phrase.toLowerCase().includes('rain kept falling')))
264
+ assert.ok(english.repetitionEvents.every(event => !/Mara|lighthouse|blue door/i.test(event.phrase)))
265
+
266
+ const code = evaluateDialogue(fixture('code'))
267
+ assert.equal(code.phraseRepetitionRate, 1)
268
+ assert.equal(code.necessaryRetentionRate, 1)
269
+ assert.equal(code.factsTotal, 3)
270
+ assert.equal(code.factCompleteness, 1)
271
+ assert.ok(code.repetitionEvents.some(event => event.phrase === 'As you can see'))
272
+ assert.ok(code.repetitionEvents.every(event => !/parseConfig|ReadOnlyMap|config\.port/.test(event.phrase)))
273
+ })
274
+
275
+ test('user quotes and agreements are retained and not counted as repetition', () => {
276
+ const quote = evaluateDialogue(fixture('user-quote'))
277
+ assert.equal(quote.necessaryRetentionRate, 1)
278
+ assert.equal(quote.factCompleteness, 1)
279
+ assert.ok(quote.repetitionEvents.every(event => !event.phrase.includes('把灯留着,别关')))
280
+
281
+ const agreement = evaluateDialogue(fixture('agreement'))
282
+ assert.equal(agreement.necessaryRetentionRate, 1)
283
+ assert.equal(agreement.factCompleteness, 1)
284
+ assert.ok(agreement.repetitionEvents.every(event => !event.phrase.includes('每周三晚上一起复盘,不改时间')))
285
+ assert.ok(agreement.repetitionEvents.some(event => event.phrase.includes('这一点很重要')))
286
+ })
287
+
288
+ test('window only looks back N assistant turns', () => {
289
+ const texts = ['这是第一句套话内容。', '完全不同的第二句内容。', '这是第一句套话内容。']
290
+ const narrow = evaluateDialogue(makeDialogue({ id: 'window-one', window: 1, turns: turnsWith(texts) }))
291
+ assert.equal(narrow.eligibleTurns, 2)
292
+ assert.equal(narrow.repeatedTurns, 0)
293
+
294
+ const wide = evaluateDialogue(makeDialogue({ id: 'window-two', window: 2, turns: turnsWith(texts) }))
295
+ assert.equal(wide.eligibleTurns, 2)
296
+ assert.equal(wide.repeatedTurns, 1)
297
+ })
298
+
299
+ test('fixture validation rejects non-synthetic and unknown labels', () => {
300
+ assert.throws(() => validateDialogueShape({ ...makeDialogue(), notGold: false }), /notGold/)
301
+ assert.throws(
302
+ () => validateDialogueShape({
303
+ ...makeDialogue(),
304
+ labels: {
305
+ necessaryPhrases: [{ text: '某物', category: 'unknown', turn: 1 }],
306
+ facts: [],
307
+ },
308
+ }),
309
+ /unknown necessary category/,
310
+ )
311
+ })
312
+
313
+ test('CLI exposes help, default JSON, --out, and documented exit codes', (t) => {
314
+ const help = runCli(['--help'])
315
+ assert.equal(help.status, 0)
316
+ assert.match(help.stdout, /Usage: node scripts\/repetition-eval\.mjs/)
317
+
318
+ const defaultRun = runCli([])
319
+ assert.equal(defaultRun.status, 0)
320
+ const defaultReport = JSON.parse(defaultRun.stdout)
321
+ assert.equal(defaultReport.ok, true)
322
+ assert.equal(defaultReport.status, 'synthetic-baseline-not-validated')
323
+ assert.equal(defaultReport.metrics.denominators.eligibleTurns, 12)
324
+ assert.equal(defaultReport.metrics.denominators.necessaryLabels, 32)
325
+ assert.equal(defaultReport.metrics.denominators.factsTotal, 17)
326
+
327
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repetition-eval-test-'))
328
+ t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
329
+ const outFile = path.join(tempDir, 'report.json')
330
+ const outRun = runCli(['--fixture-dir', FIXTURE_DIR, '--out', outFile])
331
+ assert.equal(outRun.status, 0)
332
+ assert.match(outRun.stdout, /wrote/)
333
+ const body = fs.readFileSync(outFile, 'utf8')
334
+ assert.ok(body.includes('\r\n'))
335
+ assert.ok(!/(?<!\r)\n/.test(body))
336
+ const outReport = JSON.parse(body)
337
+ assert.equal(outReport.ok, true)
338
+ assert.equal(outReport.dataset.sha256, defaultReport.dataset.sha256)
339
+ assert.equal(outReport.metrics.phraseRepetitionRate, 0.75)
340
+ })
341
+
342
+ test('CLI returns 2 for usage errors and 1 for fixture errors', (t) => {
343
+ const unknown = runCli(['--unknown'])
344
+ assert.equal(unknown.status, 2)
345
+ assert.equal(JSON.parse(unknown.stderr).ok, false)
346
+
347
+ const missing = runCli(['--fixture-dir', path.join(os.tmpdir(), 'repetition-eval-missing-dir')])
348
+ assert.equal(missing.status, 1)
349
+ assert.equal(JSON.parse(missing.stderr).ok, false)
350
+
351
+ const badDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repetition-eval-bad-'))
352
+ t.after(() => fs.rmSync(badDir, { recursive: true, force: true }))
353
+ fs.writeFileSync(path.join(badDir, 'bad.json'), '{')
354
+ const bad = runCli(['--fixture-dir', badDir])
355
+ assert.equal(bad.status, 1)
356
+ assert.equal(JSON.parse(bad.stderr).ok, false)
357
+ })
358
+
359
+ test('runEvaluation and CLI output are deterministic', () => {
360
+ const first = runEvaluation()
361
+ const second = runEvaluation()
362
+ assert.deepEqual(first, second)
363
+ assert.match(first.dataset.sha256, /^[a-f0-9]{64}$/)
364
+ assert.equal(first.metrics.phraseRepetitionRate, 0.75)
365
+ assert.equal(first.metrics.necessaryRetentionRate, 1)
366
+ assert.equal(first.metrics.factCompleteness, 1)
367
+
368
+ const runA = runCli([])
369
+ const runB = runCli([])
370
+ assert.equal(runA.status, 0)
371
+ assert.equal(runB.status, 0)
372
+ assert.equal(runA.stdout, runB.stdout)
373
+ })
@@ -0,0 +1,142 @@
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-salient-'))
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
+
14
+ test.after(() => {
15
+ try { state.flushState() } catch {}
16
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
17
+ })
18
+
19
+ const binding = state.ensureSession('salient', { cwd: path.join(home, 'coding') })
20
+ const exec = { agent: { session: { header: { id: 'salient', cwd: path.join(home, 'coding') } } } }
21
+ const local = (y, m, d) => new Date(y, m - 1, d, 12, 0, 0)
22
+
23
+ test('a date with a year keeps it; a bare month-day stays annual', () => {
24
+ assert.equal(memory.normalizeSalientDate('2026-10-29'), '2026-10-29')
25
+ assert.equal(memory.normalizeSalientDate('2026/10/29'), '2026-10-29')
26
+ assert.equal(memory.normalizeSalientDate('2026-10-29T08:00:00+08:00'), '2026-10-29')
27
+ assert.equal(memory.normalizeSalientDate('10-29'), '10-29')
28
+ assert.equal(memory.normalizeSalientDate('2024-02-29'), '2024-02-29', 'a leap day really exists')
29
+ assert.equal(memory.normalizeSalientDate('2026-02-30'), null, 'an impossible date is refused, not rolled over')
30
+ assert.equal(memory.normalizeSalientDate('2026-13-01'), null)
31
+ assert.equal(memory.normalizeSalientDate('下次再说'), null)
32
+ assert.equal(memory.normalizeSalientDate(null), null)
33
+ })
34
+
35
+ test('an anniversary recurs every year; a dated memory fires once and then is spent', () => {
36
+ assert.equal(memory.salientDateFires('10-29', local(2026, 10, 29)), true)
37
+ assert.equal(memory.salientDateFires('10-29', local(2031, 10, 29)), true, 'anniversaries recur')
38
+ assert.equal(memory.salientDateFires('10-29', local(2026, 10, 30)), false)
39
+
40
+ assert.equal(memory.salientDateFires('2026-10-29', local(2026, 10, 28)), true, 'the day before is the reminder')
41
+ assert.equal(memory.salientDateFires('2026-10-29', local(2026, 10, 29)), true, 'the day itself')
42
+ assert.equal(memory.salientDateFires('2026-10-29', local(2026, 10, 27)), false)
43
+ assert.equal(memory.salientDateFires('2026-10-29', local(2026, 10, 30)), false, 'a one-off is spent once it has passed')
44
+ assert.equal(memory.salientDateFires('2026-10-29', local(2027, 10, 28)), false, 'and never returns as an anniversary')
45
+ assert.equal(memory.salientDateFires('2026-10-29', local(2027, 10, 29)), false)
46
+
47
+ assert.equal(memory.salientDaysAhead('2026-10-29', local(2026, 10, 28)), 1)
48
+ assert.equal(memory.salientDaysAhead('2026-10-29', local(2026, 10, 29)), 0)
49
+ assert.equal(memory.salientDaysAhead('2026-10-29', local(2026, 10, 20)), 9)
50
+ assert.equal(memory.salientDaysAhead('10-29', local(2026, 10, 28)), null)
51
+ })
52
+
53
+ test('a remembered appointment is stored with its year, and an unreadable date is refused', async () => {
54
+ const saved = await memory.privateRemember({
55
+ exec, title: '考驾照', content: '明天要去考驾照。', layer: 'long_term', salientDate: '2026-10-29',
56
+ })
57
+ assert.equal(saved.entry.salientDate, '2026-10-29')
58
+ assert.ok(state.loadPrivateIndex(binding.libraryId).entries[saved.id].salientDate, 'the year survives the write to disk')
59
+ await assert.rejects(
60
+ () => memory.privateRemember({ exec, title: '坏日期', content: '日期写错了。', salientDate: '2026-02-30' }),
61
+ /salient_date/,
62
+ 'a date that cannot be parsed is an error, not a silent drop',
63
+ )
64
+ })
65
+
66
+ test('a stored date can be re-dated or cleared, and junk is refused', async () => {
67
+ const saved = await memory.privateRemember({ exec, title: '周年', content: '每年都要记得的日子。', salientDate: '10-29' })
68
+ const redated = await memory.privateUpdate({ exec, id: saved.id, salientDate: '2027-01-01' })
69
+ assert.equal(redated.entry.salientDate, '2027-01-01')
70
+ await assert.rejects(() => memory.privateUpdate({ exec, id: saved.id, salientDate: '2027-02-30' }), /salient_date/)
71
+ const cleared = await memory.privateUpdate({ exec, id: saved.id, salientDate: null })
72
+ assert.equal(cleared.entry.salientDate, undefined)
73
+ const untouched = await memory.privateUpdate({ exec, id: saved.id, title: '周年(改名)' })
74
+ assert.equal(untouched.entry.title, '周年(改名)', 'omitting salient_date leaves any date alone')
75
+ })
76
+
77
+ test('an appointment is announced as due, once per day, and never as an anniversary', () => {
78
+ const lib = state.ensureSession('salient-due', { cwd: path.join(home, 'due') })
79
+ const now = local(2026, 10, 28)
80
+ const index = state.loadPrivateIndex(lib.libraryId)
81
+ index.entries['mem-due'] = {
82
+ track: 'memory', layer: 'must_remember', title: '考驾照', content: '明天要去考驾照。',
83
+ status: 'active', salientDate: '2026-10-29', pinReason: '用户明确要求记住这件事', pinSource: 'user_explicit',
84
+ createdAt: now.toISOString(), updatedAt: now.toISOString(),
85
+ }
86
+ state.savePrivateIndex(lib.libraryId, index)
87
+
88
+ const due = inject.buildAnniversary('salient-due', lib, now)
89
+ assert.match(due.text, /memory-due/)
90
+ assert.match(due.text, /明天/)
91
+ assert.doesNotMatch(due.text, /memory-anniversary/, 'an appointment is not a commemorative date')
92
+ assert.deepEqual(due.ids, ['mem-due'])
93
+
94
+ state.markAnniversaryShown('salient-due', 'mem-due', due.dayKey)
95
+ assert.equal(inject.buildAnniversary('salient-due', lib, now).text, '', 'one firing per day')
96
+
97
+ const onTheDay = inject.buildAnniversary('salient-due', lib, local(2026, 10, 29))
98
+ assert.match(onTheDay.text, /就是今天/)
99
+ assert.equal(inject.buildAnniversary('salient-due', lib, local(2026, 10, 30)).text, '', 'spent after it has passed')
100
+ assert.equal(inject.buildAnniversary('salient-due', lib, local(2027, 10, 28)).text, '', 'and not next year')
101
+ })
102
+
103
+ test('an annual entry still surfaces as an anniversary block', () => {
104
+ const lib = state.ensureSession('salient-annual', { cwd: path.join(home, 'annual') })
105
+ const now = local(2026, 10, 29)
106
+ const index = state.loadPrivateIndex(lib.libraryId)
107
+ index.entries['mem-annual'] = {
108
+ track: 'memory', layer: 'long_term', title: '第一次一起看海', content: '那天下着小雨。',
109
+ status: 'active', salientDate: '10-29', createdAt: now.toISOString(), updatedAt: now.toISOString(),
110
+ }
111
+ state.savePrivateIndex(lib.libraryId, index)
112
+ const block = inject.buildAnniversary('salient-annual', lib, now)
113
+ assert.match(block.text, /memory-anniversary/)
114
+ assert.doesNotMatch(block.text, /memory-due/)
115
+ });
116
+ test('a model that writes the date into the text still gets it stored', async () => {
117
+ const written = await memory.privateRemember({
118
+ exec,
119
+ content: '测试记忆条目 3:2026-10-30(星期五),用户第三次要求再写一次。',
120
+ title: '连续写入验证(2026-10-30 05:10)',
121
+ })
122
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries[written.id].salientDate, '2026-10-30')
123
+ })
124
+
125
+ test('two different dates in one memory are not guessed', async () => {
126
+ const written = await memory.privateRemember({
127
+ exec,
128
+ content: '2026-10-30 开始改版,2026-11-05 上线。',
129
+ title: '两个日期',
130
+ })
131
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries[written.id].salientDate, null)
132
+ })
133
+
134
+ test('an explicit salient_date still wins over the text', async () => {
135
+ const written = await memory.privateRemember({
136
+ exec,
137
+ content: '正文提到 2026-10-30,但字段另有指定。',
138
+ title: '字段优先',
139
+ salientDate: '11-11',
140
+ })
141
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries[written.id].salientDate, '11-11')
142
+ })