dsh-memu-memory 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,140 @@
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-remote-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+ process.env.MEMU_CONFIG = path.join(home, 'config.env')
10
+
11
+ const state = await import('../lib/state.js')
12
+ const config = await import('../lib/memu-config.js')
13
+ const { requestRemoteConsolidation, requestPrivateConsolidation } = await import('../lib/private.js')
14
+ const { summarizeRemote } = await import('../lib/remote-summary.js')
15
+ const { loadPromptForDomain, CONSOLIDATION_CONTRACTS } = await import('../lib/prompts.js')
16
+
17
+ test.after(() => {
18
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
19
+ })
20
+
21
+ test('remote consolidation reads the transcript and stores model-written memories', async () => {
22
+ const cwd = path.join(home, 'project')
23
+ const binding = state.ensureSession('remote-session', { cwd })
24
+ assert.ok(binding?.libraryId, 'ensureSession must bind a library')
25
+ state.updateSettings({ memoryBackend: 'agent' })
26
+ config.saveMemuConfig({
27
+ summaryModel: 'deepseek-chat',
28
+ summaryBaseUrl: 'https://api.deepseek.com/v1',
29
+ summaryApiKey: 'summary-secret',
30
+ })
31
+ const originalFetch = globalThis.fetch
32
+ let seenBody = null
33
+ globalThis.fetch = async (_url, init) => {
34
+ seenBody = JSON.parse(init.body)
35
+ return {
36
+ ok: true,
37
+ status: 200,
38
+ text: async () => JSON.stringify({
39
+ choices: [{
40
+ message: {
41
+ content: JSON.stringify({
42
+ memories: [{ title: '香菜', content: '他记得我不吃香菜。', tags: ['food'] }],
43
+ }),
44
+ },
45
+ }],
46
+ }),
47
+ }
48
+ }
49
+ const ctx = {
50
+ get: (name) => name === 'sessionQuery'
51
+ ? {
52
+ readSession: async () => ({
53
+ session: { id: 'remote-session' },
54
+ events: [{
55
+ type: 'user/message',
56
+ data: { source: { kind: 'user' }, content: [{ type: 'text', text: '我不吃香菜' }] },
57
+ }],
58
+ }),
59
+ }
60
+ : null,
61
+ }
62
+ try {
63
+ const result = await requestRemoteConsolidation(ctx, 'remote-session')
64
+ assert.equal(result.ok, true)
65
+ assert.equal(result.backend, 'api')
66
+ assert.equal(result.count, 1)
67
+ assert.ok(String(seenBody.messages[0].content).includes('我不吃香菜'))
68
+ const index = state.loadPrivateIndex(binding.libraryId)
69
+ const entries = Object.values(index.entries)
70
+ assert.equal(entries.length, 1)
71
+ assert.equal(entries[0].title, '香菜')
72
+ assert.equal(entries[0].layer, 'long_term')
73
+ } finally {
74
+ globalThis.fetch = originalFetch
75
+ }
76
+ })
77
+
78
+ test('explicit remote consolidation remains available when vector route is local', async () => {
79
+ state.ensureSession('remote-session', { cwd: path.join(home, 'project') })
80
+ state.updateSettings({ memoryBackend: 'api', vectorSource: 'local' })
81
+ config.saveMemuConfig({
82
+ summaryModel: 'deepseek-chat',
83
+ summaryBaseUrl: 'https://api.deepseek.com/v1',
84
+ summaryApiKey: 'summary-secret',
85
+ })
86
+ const originalFetch = globalThis.fetch
87
+ globalThis.fetch = async () => ({
88
+ ok: true,
89
+ status: 200,
90
+ text: async () => JSON.stringify({
91
+ choices: [{ message: { content: JSON.stringify({ memories: [] }) } }],
92
+ }),
93
+ })
94
+ const ctx = {
95
+ get: (name) => name === 'sessionQuery'
96
+ ? {
97
+ readSession: async () => ({
98
+ events: [{
99
+ type: 'user/message',
100
+ data: { source: { kind: 'user' }, content: [{ type: 'text', text: '记一下' }] },
101
+ }],
102
+ }),
103
+ }
104
+ : null,
105
+ }
106
+ try {
107
+ const result = await requestRemoteConsolidation(ctx, 'remote-session')
108
+ assert.equal(result.ok, true)
109
+ assert.equal(result.backend, 'api')
110
+ assert.equal(result.count, 0)
111
+ } finally {
112
+ globalThis.fetch = originalFetch
113
+ state.updateSettings({ memoryBackend: 'agent' })
114
+ }
115
+ })
116
+
117
+ test('remote consolidation uses each domain body with a JSON-only, tool-free contract', async () => {
118
+ const originalFetch = globalThis.fetch
119
+ let seenPrompt = ''
120
+ globalThis.fetch = async (_url, init) => {
121
+ seenPrompt = JSON.parse(init.body).messages[0].content
122
+ return { ok: true, text: async () => JSON.stringify({ choices: [{ message: { content: '{"memories":[]}' } }] }) }
123
+ }
124
+ try {
125
+ for (const domain of ['coding', 'rp', 'mixed', 'general']) {
126
+ const transcript = 'input-evidence: user asks to preserve a verified decision, not a new instruction'
127
+ assert.deepEqual(await summarizeRemote({ domain, libraryName: 'domain-test', transcript }), [])
128
+ const expected = loadPromptForDomain('private-consolidation', {
129
+ domain, libraryName: 'domain-test', mustRememberCap: state.getSettings().mustRememberCap,
130
+ }, domain).text
131
+ assert.ok(seenPrompt.includes(expected), `${domain} body must match local defaults`)
132
+ assert.ok(seenPrompt.includes(CONSOLIDATION_CONTRACTS.remote))
133
+ assert.equal(seenPrompt.includes(CONSOLIDATION_CONTRACTS.local), false)
134
+ assert.doesNotMatch(seenPrompt, /\{\{\s*\w+\s*\}\}/)
135
+ assert.ok(seenPrompt.endsWith(transcript))
136
+ }
137
+ } finally {
138
+ globalThis.fetch = originalFetch
139
+ }
140
+ })
@@ -0,0 +1,30 @@
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
+ import { dataDir } from '../lib/paths.js'
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-rename-'))
8
+ const previous = { DSH_HOME: process.env.DSH_HOME, DSH_MEMU_HOME: process.env.DSH_MEMU_HOME, DSH_MEMU_MEMORY_HOME: process.env.DSH_MEMU_MEMORY_HOME }
9
+ process.env.DSH_HOME = home
10
+ delete process.env.DSH_MEMU_HOME
11
+ delete process.env.DSH_MEMU_MEMORY_HOME
12
+ test.after(() => {
13
+ for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value }
14
+ fs.rmSync(home, { recursive: true, force: true })
15
+ })
16
+ test('fresh installations use the new name but existing memories remain in their original directory', () => {
17
+ assert.equal(dataDir(), path.join(home, 'dsh-memu-memory'))
18
+ fs.mkdirSync(path.join(home, 'dsh-memu'))
19
+ assert.equal(dataDir(), path.join(home, 'dsh-memu'))
20
+ fs.mkdirSync(path.join(home, 'dsh-memu-memory'))
21
+ assert.equal(dataDir(), path.join(home, 'dsh-memu'), 'an empty new directory must not shadow existing memories')
22
+ fs.writeFileSync(path.join(home, 'dsh-memu-memory', 'state.json'), '{}')
23
+ assert.equal(dataDir(), path.join(home, 'dsh-memu-memory'))
24
+ })
25
+ test('both the new and the previous explicit data-root overrides stay supported', () => {
26
+ process.env.DSH_MEMU_HOME = path.join(home, 'old-override')
27
+ assert.equal(dataDir(), process.env.DSH_MEMU_HOME)
28
+ process.env.DSH_MEMU_MEMORY_HOME = path.join(home, 'new-override')
29
+ assert.equal(dataDir(), process.env.DSH_MEMU_MEMORY_HOME)
30
+ })
@@ -0,0 +1,231 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {
4
+ buildRepetitionHint,
5
+ extractAssistantTexts,
6
+ renderRepetitionHint,
7
+ repetitionHintForMessages,
8
+ cjkRatio,
9
+ REPETITION_PHRASE_MIN,
10
+ REPETITION_PHRASE_MAX,
11
+ REPETITION_MAX_TOKENS,
12
+ } from '../scripts/repetition-detector-snapshot.mjs'
13
+ import { estTokens } from '../lib/budget.js'
14
+
15
+ const BT = String.fromCharCode(96)
16
+
17
+ function hintedPhrases(hint) {
18
+ const line = String(hint || '').split('\n').find((item) => item.startsWith('最近反复出现的措辞:'))
19
+ if (!line) return []
20
+ const body = line.slice('最近反复出现的措辞:'.length).split('。请换一种说法')[0]
21
+ return body ? body.split('、') : []
22
+ }
23
+
24
+ test('extractAssistantTexts keeps assistant prose only, in order', () => {
25
+ const messages = [
26
+ { role: 'user', content: [{ type: 'text', text: 'user text' }] },
27
+ { role: 'assistant', content: 'first string' },
28
+ { role: 'assistant', content: [
29
+ { type: 'tool-call', name: 'memu_recall', arguments: {} },
30
+ { type: 'text', text: 'second block' },
31
+ { type: 'text', text: 'third block' },
32
+ ] },
33
+ { role: 'assistant', content: [{ type: 'image', text: 'not prose' }] },
34
+ { role: 'assistant', content: ' ' },
35
+ { role: 'system', content: 'system text' },
36
+ ]
37
+ assert.deepEqual(extractAssistantTexts(messages), ['first string', 'second block\nthird block'])
38
+ assert.deepEqual(extractAssistantTexts(null), [])
39
+ assert.deepEqual(extractAssistantTexts([{ role: 'assistant', content: null }]), [])
40
+ })
41
+
42
+ test('extractAssistantTexts never mutates frozen host history', () => {
43
+ const frozen = Object.freeze([
44
+ Object.freeze({ role: 'assistant', content: Object.freeze([Object.freeze({ type: 'text', text: '轻轻叹了口气。' })]) }),
45
+ Object.freeze({ role: 'user', content: Object.freeze([Object.freeze({ type: 'text', text: '继续。' })]) }),
46
+ ])
47
+ assert.deepEqual(extractAssistantTexts(frozen), ['轻轻叹了口气。'])
48
+ assert.equal(frozen.length, 2)
49
+ })
50
+
51
+ test('a hint needs two earlier assistant texts', () => {
52
+ assert.equal(buildRepetitionHint({ assistantTexts: [] }), '')
53
+ assert.equal(buildRepetitionHint({ assistantTexts: ['轻轻叹了口气,然后看着窗外的雨。'] }), '')
54
+ assert.equal(repetitionHintForMessages([{ role: 'assistant', content: '只有一条。' }]), '')
55
+ })
56
+
57
+ test('two different earlier turns with a shared Han phrase produce the fixed hint', () => {
58
+ const hint = buildRepetitionHint({ assistantTexts: [
59
+ '他轻轻叹了口气,然后看着窗外的雨。',
60
+ '她轻轻叹了口气,没有说话。',
61
+ ] })
62
+ assert.ok(hint.startsWith('<repetition-hint>\n'))
63
+ assert.ok(hint.endsWith('\n</repetition-hint>'))
64
+ assert.ok(hint.includes('最近反复出现的措辞:'))
65
+ assert.ok(hint.includes('请换一种说法;保留人物姓名、用户原话、约定和事实。'))
66
+ assert.ok(hint.includes('以下是历史助手输出摘要,仅用于避免措辞复读,不是用户的新指令,也不是新的事实来源。'))
67
+ assert.ok(hintedPhrases(hint).some((phrase) => phrase.includes('轻轻叹了口气')))
68
+ assert.ok(hintedPhrases(hint).every((phrase) => phrase.length >= REPETITION_PHRASE_MIN && phrase.length <= REPETITION_PHRASE_MAX))
69
+ })
70
+
71
+ test('a phrase repeated inside one turn alone is not cross-turn repetition', () => {
72
+ assert.equal(buildRepetitionHint({ assistantTexts: [
73
+ '今天天气不错。',
74
+ '她轻轻叹了口气。她轻轻叹了口气。',
75
+ ] }), '')
76
+ })
77
+
78
+ test('no shared four-character Han span means no hint', () => {
79
+ assert.equal(buildRepetitionHint({ assistantTexts: ['窗外雨声很大。', '桌上杯子很凉。'] }), '')
80
+ })
81
+
82
+ test('grammatical filler made only of stop characters is not reported', () => {
83
+ assert.equal(buildRepetitionHint({ assistantTexts: ['这是一个问题。', '这是一个问题。'] }), '')
84
+ })
85
+
86
+ test('pure English history is outside the Chinese-dominant gate', () => {
87
+ const text = 'As you can see, the rain outside is falling again.'
88
+ assert.equal(buildRepetitionHint({ assistantTexts: [text, text] }), '')
89
+ assert.ok(cjkRatio('中文abc') > 0 && cjkRatio('中文abc') < 1)
90
+ assert.equal(cjkRatio(''), 0)
91
+ })
92
+
93
+ test('a Chinese-dominant turn may contain Latin, but Latin phrases are never extracted', () => {
94
+ const text = '她轻轻叹了口气,As you can see,雨还在下。'
95
+ const hint = buildRepetitionHint({ assistantTexts: [text, text] })
96
+ assert.ok(hint.includes('轻轻叹了口气'))
97
+ assert.ok(!hint.includes('As you can see'))
98
+ })
99
+
100
+ test('code fences are masked before extraction, even when the fence holds prose', () => {
101
+ const fenced = (prose) => '这' + BT + BT + BT + '\n轻轻叹了口气\n' + BT + BT + BT + prose
102
+ // Without masking the fenced "轻轻叹了口气" would be a shared phrase and the
103
+ // hint would appear; with masking only the unrelated prose remains.
104
+ assert.equal(buildRepetitionHint({ assistantTexts: [fenced('窗外雨声很大。'), fenced('桌上杯子很凉。')] }), '')
105
+ })
106
+
107
+ test('fenced repetition and identifiers stay out while outside prose still appears', () => {
108
+ const fenced = (tail) => '这' + BT + BT + BT + 'js\nconst deploy_now = "轻轻叹了口气";\n' + BT + BT + BT + tail
109
+ const hint = buildRepetitionHint({ assistantTexts: [
110
+ fenced('窗外雨声很大,她觉得这一切都像一场梦。远处有人轻轻关上了门,风从窗缝里钻进来。她起身去关窗,手指碰到冰凉的玻璃。'),
111
+ fenced('窗外雨声很大,她觉得这一切都像一场梦。远处有人轻轻关上了门,风从窗缝里钻进来。她起身去关窗,手指碰到冰凉的玻璃。'),
112
+ ] })
113
+ assert.ok(hint.includes('窗外雨声很大'), hint)
114
+ assert.ok(!hint.includes('轻轻叹了口气'), 'fenced prose leaked into the hint')
115
+ assert.ok(!hint.includes('deploy_now'), 'fenced identifier leaked into the hint')
116
+ })
117
+
118
+ test('code-only or code-heavy text suppresses the hint', () => {
119
+ const fence = '这' + BT + BT + BT + 'js\nconst x = 1\n' + BT + BT + BT + ',雨还在下。'
120
+ assert.equal(buildRepetitionHint({ assistantTexts: [fence, fence] }), '')
121
+ const heavy = '这' + BT + BT + BT + 'js\n' + 'const a = 1;\n'.repeat(40) + BT + BT + BT + ',她轻轻叹了口气。'
122
+ assert.equal(buildRepetitionHint({ assistantTexts: [heavy, heavy] }), '')
123
+ })
124
+
125
+ test('inline code is masked while the surrounding prose still counts', () => {
126
+ const a = '他轻轻叹了口气,运行 ' + BT + 'deploy_now()' + BT + ' 之后雨停了。'
127
+ const b = '她轻轻叹了口气,运行 ' + BT + 'deploy_now()' + BT + ' 之后雨停了。'
128
+ const hint = buildRepetitionHint({ assistantTexts: [a, b] })
129
+ assert.ok(hint.includes('轻轻叹了口气'))
130
+ assert.ok(!hint.includes('deploy_now'))
131
+ })
132
+
133
+ test('URLs, e-mail addresses, secrets, and long digit runs never enter the hint', () => {
134
+ const a = '轻轻叹了口气,邮箱 test@example.com 和 https://example.com/x 都别发,订单123456789,密钥 sk-abcdef123456。'
135
+ const hint = buildRepetitionHint({ assistantTexts: [a, a] })
136
+ assert.ok(hint.includes('轻轻叹了口气'))
137
+ for (const bad of ['test@example.com', 'https://example.com', '123456789', 'sk-abcdef123456']) {
138
+ assert.ok(!hint.includes(bad), 'hint leaked ' + bad)
139
+ }
140
+ })
141
+
142
+ test('renderRepetitionHint strips tags, backticks, controls, and newlines', () => {
143
+ const hint = renderRepetitionHint(['轻<轻>叹' + BT + '了', '控制\u0000字符', '换\n行短语'])
144
+ assert.ok(hint.includes('轻轻叹了'))
145
+ assert.ok(!hint.includes('轻<轻'))
146
+ assert.ok(!hint.includes(BT))
147
+ assert.ok(!hint.includes('\u0000'))
148
+ assert.equal(hint.split('\n').length, 4, 'tag, one phrase line, source, close')
149
+ })
150
+
151
+ test('renderRepetitionHint returns empty string when nothing valid is left', () => {
152
+ assert.equal(renderRepetitionHint([]), '')
153
+ assert.equal(renderRepetitionHint(['短', 'As you can see here']), '')
154
+ })
155
+
156
+ test('necessary dates, numbers, commitments and prohibitions are never offered as repeated wording', () => {
157
+ const necessary = [
158
+ '每周三晚上八点一起复盘。',
159
+ '绝不能对生产库直接执行迁移。',
160
+ '明天下午三点在北门见。',
161
+ '我们约好下周一交稿。',
162
+ ]
163
+ for (const text of necessary) {
164
+ assert.equal(buildRepetitionHint({ assistantTexts: [text, text] }), '', text)
165
+ }
166
+ assert.equal(renderRepetitionHint(['每周三晚上八点一起复盘']), '')
167
+ const ordinary = buildRepetitionHint({ assistantTexts: [
168
+ '他轻轻叹了口气,然后看着窗外的雨。',
169
+ '她轻轻叹了口气,没有说话。',
170
+ ] })
171
+ assert.ok(ordinary.includes('轻轻叹了口气'), 'ordinary wording must still be reported')
172
+ })
173
+
174
+ test('phrases stay within four and twenty-four characters', () => {
175
+ const longest = '他心里想着这件事情真的非常重要我们都要记住它并且'
176
+ assert.equal(longest.length, REPETITION_PHRASE_MAX)
177
+ const hint = renderRepetitionHint([longest])
178
+ assert.ok(hint.includes(longest))
179
+ assert.equal(renderRepetitionHint([longest + '啊']), '', 'a 25-character phrase is rejected')
180
+ assert.equal(renderRepetitionHint(['短短']), '', 'a 2-character phrase is rejected')
181
+ })
182
+
183
+ test('at most three phrases are reported and duplicates collapse', () => {
184
+ const text = '轻轻叹了口气。窗外雨声很大。桌上杯子很凉。远处钟声响起。'
185
+ const hint = buildRepetitionHint({ assistantTexts: [text, text] })
186
+ const phrases = hintedPhrases(hint)
187
+ assert.ok(phrases.length >= 1 && phrases.length <= 3)
188
+ assert.equal(new Set(phrases).size, phrases.length)
189
+ })
190
+
191
+ test('maxTokens is hard capped at 168 and tiny budgets omit the whole hint', () => {
192
+ const a = '他轻轻叹了口气,然后看着窗外的雨。'
193
+ const b = '她轻轻叹了口气,没有说话。'
194
+ const wide = buildRepetitionHint({ assistantTexts: [a, b], maxTokens: 5000 })
195
+ assert.ok(wide.length > 0)
196
+ assert.ok(estTokens(wide) <= REPETITION_MAX_TOKENS, 'used ' + estTokens(wide))
197
+ assert.equal(buildRepetitionHint({ assistantTexts: [a, b], maxTokens: 20 }), '')
198
+ })
199
+
200
+ test('three full-length phrases fit inside the 168-token hint budget', () => {
201
+ const p1 = '他心里想着这件事情真的非常重要我们都要记住它并且'
202
+ const p2 = '我们必须把这段话说得更加清楚一些然后再继续往下走'
203
+ const p3 = '窗外的雨一直下个不停而桌上的那杯茶早就已经凉了啊'
204
+ assert.equal(p1.length, 24)
205
+ assert.equal(p2.length, 24)
206
+ assert.equal(p3.length, 24)
207
+ const text = [p1, p2, p3].join('。')
208
+ const hint = buildRepetitionHint({ assistantTexts: [text, text], maxTokens: 5000 })
209
+ assert.equal(hintedPhrases(hint).length, 3)
210
+ assert.ok(estTokens(hint) <= REPETITION_MAX_TOKENS, 'used ' + estTokens(hint))
211
+ const narrow = buildRepetitionHint({ assistantTexts: [text, text], maxTokens: 160 })
212
+ assert.ok(estTokens(narrow) <= 160, 'used ' + estTokens(narrow))
213
+ assert.ok(hintedPhrases(narrow).length < 3, 'three full-length phrases need more than 160 tokens')
214
+ })
215
+
216
+ test('only the recent window is considered', () => {
217
+ const hint = buildRepetitionHint({
218
+ assistantTexts: ['轻轻叹了口气。', '轻轻叹了口气,然后。', '完全不同的话。'],
219
+ window: 2,
220
+ })
221
+ assert.equal(hint, '')
222
+ })
223
+
224
+ test('the output is stable and ordered by first appearance', () => {
225
+ const a = '轻轻叹了口气。窗外雨声很大。'
226
+ const b = '轻轻叹了口气。窗外雨声很大。'
227
+ const one = buildRepetitionHint({ assistantTexts: [a, b] })
228
+ const two = buildRepetitionHint({ assistantTexts: [a, b] })
229
+ assert.equal(one, two)
230
+ assert.ok(hintedPhrases(one).length > 0)
231
+ })
@@ -0,0 +1,177 @@
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 live = await import('../scripts/repetition-eval-live.mjs')
8
+ const base = await import('../scripts/repetition-eval.mjs')
9
+ const snapshot = await import('../scripts/repetition-detector-snapshot.mjs')
10
+ const scenarios = await import('../scripts/repetition-ab-scenarios.mjs')
11
+ const fixtures = base.loadDataset()
12
+ const roots = []
13
+
14
+ test.after(() => {
15
+ for (const root of roots.splice(0)) {
16
+ try { fs.rmSync(root, { recursive: true, force: true }) } catch {}
17
+ }
18
+ })
19
+
20
+ function freshRoot() {
21
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-repetition-live-'))
22
+ roots.push(root)
23
+ return root
24
+ }
25
+
26
+ function fixture(id) {
27
+ return fixtures.dialogues.find(dialogue => dialogue.id === id)
28
+ }
29
+
30
+ test('buildRepetitionHint uses the frozen evaluation snapshot without fixture-label filtering', () => {
31
+ const dialogue = fixture('zh-rp-baseline')
32
+ const assistantTexts = dialogue.turns.filter(turn => turn.role === 'assistant').map(turn => turn.text)
33
+ const hint = live.buildRepetitionHint(assistantTexts, dialogue.labels)
34
+ assert.match(hint, /<repetition-hint>/)
35
+ assert.match(hint, /轻轻叹了口气/)
36
+ // The frozen snapshot has no gold labels at runtime. The A/B scorer, not
37
+ // the detector, measures whether necessary names/facts were harmed.
38
+ assert.equal(typeof hint, 'string')
39
+ assert.equal(hint, snapshot.buildRepetitionHint({ assistantTexts }))
40
+ assert.equal(hint, live.buildRepetitionHint(assistantTexts, { necessaryPhrases: [], facts: [] }))
41
+ assert.equal(live.buildRepetitionHint(['only one'], dialogue.labels), '')
42
+ })
43
+
44
+ test('generateSample uses the original prefix and adds a hint only in the candidate condition', async () => {
45
+ const dialogue = fixture('zh-rp-baseline')
46
+ const calls = []
47
+ const fakeChat = async ({ messages }) => {
48
+ calls.push(messages)
49
+ return '林眠轻轻叹了口气,看着窗外的雨。'
50
+ }
51
+ const candidate = await live.generateSample({
52
+ fixture: dialogue,
53
+ condition: 'candidate',
54
+ chat: fakeChat,
55
+ config: { ready: true, model: 'fake', baseUrl: 'http://127.0.0.1' },
56
+ })
57
+ assert.equal(candidate.kind, 'repetition-eval-live-sample')
58
+ assert.equal(candidate.condition, 'candidate')
59
+ assert.equal(candidate.prefixTurns.filter(turn => turn.role === 'user').length, 3)
60
+ assert.equal(candidate.prefixTurns.filter(turn => turn.role === 'assistant').length, 2)
61
+ assert.equal(calls.length, 1)
62
+ assert.ok(calls[0].some(message => message.role === 'assistant' && message.content.includes('桌角')))
63
+ assert.ok(calls[0].some(message => message.role === 'user'
64
+ && message.source?.kind === 'plugin:dsh-memu-memory'
65
+ && message.content?.[0]?.text?.includes('<repetition-hint>')))
66
+ assert.equal(candidate.hintApplied, true)
67
+ const pluginIndex = calls[0].findIndex(message => message.source?.kind === 'plugin:dsh-memu-memory')
68
+ assert.equal(pluginIndex, calls[0].length - 2)
69
+ assert.equal(calls[0].filter(message => message.role === 'system').length, 1)
70
+ assert.doesNotMatch(String(calls[0].find(message => message.role === 'system').content), /character names|user quotes|agreements|established facts/i)
71
+
72
+ calls.length = 0
73
+ const baseline = await live.generateSample({
74
+ fixture: dialogue,
75
+ condition: 'baseline',
76
+ chat: fakeChat,
77
+ config: { ready: true, model: 'fake', baseUrl: 'http://127.0.0.1' },
78
+ })
79
+ assert.equal(calls.length, 1)
80
+ assert.equal(calls[0].some(message => message.source?.kind === 'plugin:dsh-memu-memory'), false)
81
+ assert.equal(baseline.hintApplied, false)
82
+ })
83
+
84
+ test('only labels for the generated target are scored, not every earlier answer', async () => {
85
+ const dialogue = fixture('agreement-baseline')
86
+ const target = live.targetAssistantIndex(dialogue)
87
+ const sample = await live.generateSample({
88
+ fixture: dialogue, condition: 'baseline',
89
+ chat: async () => dialogue.turns[target].text,
90
+ })
91
+ assert.equal(sample.labels.facts.length, 1)
92
+ assert.equal(sample.evaluation.factCompleteness, 1)
93
+ assert.equal(sample.evaluation.necessaryRetentionRate, 1)
94
+ })
95
+
96
+ test('the A/B pack contains twenty unique scenarios and self-consistent target labels', () => {
97
+ const dataset = scenarios.loadAbDataset()
98
+ const ab = dataset.dialogues.filter(dialogue => dialogue.id.startsWith('ab-'))
99
+ assert.equal(ab.length, 14)
100
+ assert.equal(new Set(ab.map(dialogue => dialogue.pairId)).size, 14)
101
+ for (const dialogue of ab) {
102
+ base.validateDialogueShape(dialogue)
103
+ const target = live.targetAssistantIndex(dialogue)
104
+ const evaluation = base.evaluateAssistantTurn({
105
+ text: dialogue.turns[target].text,
106
+ priorAssistantTexts: [],
107
+ labels: {
108
+ necessaryPhrases: dialogue.labels.necessaryPhrases.filter(label => label.turn === target),
109
+ facts: dialogue.labels.facts.filter(label => label.turn === target),
110
+ },
111
+ })
112
+ assert.equal(evaluation.factsTotal === 0 || evaluation.factCompleteness === 1, true, dialogue.id)
113
+ assert.equal(evaluation.necessaryRetentionRate, 1, dialogue.id)
114
+ }
115
+ })
116
+
117
+ test('twenty scenarios and three repeats use exactly 120 calls with paired outputs', async () => {
118
+ let calls = 0
119
+ const report = await live.runLivePilot({
120
+ ab: true, repeats: 3, concurrency: 4, outDir: freshRoot(),
121
+ config: { ready: true, model: 'fake', baseUrl: 'https://example.test' },
122
+ chat: async () => { calls += 1; return '这是一条合成测试输出。' },
123
+ })
124
+ assert.equal(calls, 120)
125
+ assert.equal(report.config.attempts, 120)
126
+ assert.equal(report.config.automaticRetries, 0)
127
+ assert.equal(report.failures.length, 0)
128
+ assert.equal(report.dialogues.length, 120)
129
+ })
130
+
131
+ test('an over-budget plan is rejected before any generation call', async () => {
132
+ let calls = 0
133
+ await assert.rejects(() => live.runLivePilot({
134
+ ab: true, repeats: 4, outDir: freshRoot(),
135
+ config: { ready: true },
136
+ chat: async () => { calls += 1; return 'not reached' },
137
+ }), /120-attempt limit/)
138
+ assert.equal(calls, 0)
139
+ })
140
+
141
+ test('a failed provider is not retried and details or credentials are not reported', async () => {
142
+ let calls = 0
143
+ const report = await live.runLivePilot({
144
+ outDir: freshRoot(), config: { ready: true, model: 'fake', baseUrl: 'https://example.test', apiKey: 'unit-secret' },
145
+ chat: async () => { calls += 1; throw new Error('unit-secret provider-body') },
146
+ })
147
+ assert.equal(calls, 4)
148
+ assert.equal(report.ok, false)
149
+ assert.equal(report.config.stoppedEarly, true)
150
+ assert.equal(report.failures.length, 4)
151
+ assert.equal(JSON.stringify(report).includes('unit-secret'), false)
152
+ assert.equal(JSON.stringify(report).includes('provider-body'), false)
153
+ })
154
+
155
+ test('runLivePilot writes twelve samples and reports both conditions without claiming gold', async () => {
156
+ const outDir = freshRoot()
157
+ const report = await live.runLivePilot({
158
+ outDir,
159
+ config: { ready: true, model: 'fake-model', baseUrl: 'http://127.0.0.1', apiKey: 'not-used' },
160
+ chat: async () => '我会继续,但不会重复刚才的套话。',
161
+ })
162
+ assert.equal(report.status, 'live-pilot-not-validated')
163
+ assert.equal(report.draft, true)
164
+ assert.equal(report.notGold, true)
165
+ assert.equal(report.dataset.dialogueCount, 12)
166
+ assert.equal(report.dataset.fileCount, 12)
167
+ assert.equal(report.dialogues.length, 12)
168
+ assert.equal(report.metrics.byCondition.baseline.emptySet.phraseRepetitionRate, false)
169
+ assert.equal(report.metrics.byCondition.candidate.emptySet.phraseRepetitionRate, false)
170
+ assert.equal(report.metrics.humanPreference.status, 'not-collected')
171
+ assert.ok(fs.existsSync(path.join(outDir, 'report.json')))
172
+ assert.equal(fs.readdirSync(outDir).filter(name => name.endsWith('.json')).length, 13)
173
+ assert.match(fs.readFileSync(path.join(outDir, 'report.json'), 'utf8'), /\r\n/)
174
+ const sample = JSON.parse(fs.readFileSync(path.join(outDir, '1-zh-rp-baseline-live-candidate.json'), 'utf8'))
175
+ assert.equal(sample.kind, 'repetition-eval-live-sample')
176
+ assert.equal(sample.prefixTurns.length, 5)
177
+ })