dsh-memu-memory 1.1.3 → 1.2.1

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 (184) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +39 -6
  3. package/README.zh.md +39 -7
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +3 -3
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +8 -0
  9. package/docs/host-api.md +2 -2
  10. package/docs/memory-semantics.md +12 -10
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +396 -395
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +23 -6
  15. package/lib/binding-diagnostics.js +8 -3
  16. package/lib/client.js +335 -35
  17. package/lib/consolidation-ledger.js +347 -0
  18. package/lib/consolidation-receipt.js +284 -0
  19. package/lib/consolidation-snapshot.js +274 -0
  20. package/lib/consolidation-worker.js +144 -0
  21. package/lib/curator.js +86 -31
  22. package/lib/cycle.js +40 -6
  23. package/lib/graph.js +139 -5
  24. package/lib/index.js +33 -1
  25. package/lib/inject.js +104 -76
  26. package/lib/local-embedding-manager.js +24 -6
  27. package/lib/memory.js +534 -88
  28. package/lib/mood.js +268 -39
  29. package/lib/preset-scope.js +1 -1
  30. package/lib/private.js +430 -43
  31. package/lib/prompts/private-consolidation.coding.md +1 -0
  32. package/lib/prompts/private-consolidation.md +1 -0
  33. package/lib/prompts/private-consolidation.mixed.md +1 -0
  34. package/lib/prompts/private-consolidation.rp.md +1 -0
  35. package/lib/prompts.js +4 -4
  36. package/lib/remote-summary.js +51 -25
  37. package/lib/rpc.js +54 -5
  38. package/lib/state.js +29 -12
  39. package/lib/text-similarity.js +106 -0
  40. package/lib/tools.js +113 -10
  41. package/lib/vector-migration.js +28 -3
  42. package/lib/vector-profile.js +51 -0
  43. package/package.json +4 -2
  44. package/scripts/acceptance-local.mjs +2 -1
  45. package/scripts/active-memory-ab-live.mjs +655 -0
  46. package/scripts/active-memory-ab.mjs +1302 -0
  47. package/scripts/chain-matrix.mjs +11 -1
  48. package/scripts/check-all.mjs +10 -1
  49. package/scripts/replay.mjs +573 -0
  50. package/tests/activation.test.mjs +89 -0
  51. package/tests/active-memory-ab.test.mjs +745 -0
  52. package/tests/active-memory.test.mjs +129 -0
  53. package/tests/activity-log.test.mjs +126 -0
  54. package/tests/activity-wiring.test.mjs +103 -0
  55. package/tests/appendix-b.test.mjs +216 -0
  56. package/tests/appendix-b2.test.mjs +166 -0
  57. package/tests/audit-regression.test.mjs +272 -0
  58. package/tests/audit-safety.test.mjs +227 -0
  59. package/tests/backend-compensation.test.mjs +272 -0
  60. package/tests/backup.test.mjs +793 -0
  61. package/tests/binding-diagnostics.test.mjs +720 -0
  62. package/tests/browser.test.mjs +366 -0
  63. package/tests/client-render.test.mjs +892 -0
  64. package/tests/coalesce.test.mjs +113 -0
  65. package/tests/config.test.mjs +159 -0
  66. package/tests/consolidation-credential.test.mjs +331 -0
  67. package/tests/consolidation-feed.test.mjs +96 -0
  68. package/tests/consolidation-fork.test.mjs +489 -0
  69. package/tests/consolidation-ledger.test.mjs +298 -0
  70. package/tests/consolidation-snapshot.test.mjs +360 -0
  71. package/tests/consolidation-value.test.mjs +89 -0
  72. package/tests/context-scope.test.mjs +40 -0
  73. package/tests/cue.test.mjs +44 -0
  74. package/tests/curator-commit.test.mjs +277 -0
  75. package/tests/curator-probe.test.mjs +433 -0
  76. package/tests/curator.test.mjs +150 -0
  77. package/tests/cycle.test.mjs +411 -0
  78. package/tests/disclosure.test.mjs +231 -0
  79. package/tests/doc-drift.test.mjs +222 -0
  80. package/tests/embedding-catalog.test.mjs +266 -0
  81. package/tests/embedding-download.test.mjs +391 -0
  82. package/tests/embedding-runtime.test.mjs +389 -0
  83. package/tests/emotion-chain.test.mjs +84 -0
  84. package/tests/emotion-contract.test.mjs +445 -0
  85. package/tests/engine.test.mjs +35 -0
  86. package/tests/fixtures/active-memory-ab/README.md +56 -0
  87. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  88. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  89. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  90. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  92. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  93. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  94. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  95. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  96. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  97. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  98. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  99. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  100. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  101. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  102. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  103. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  104. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  105. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  106. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  107. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  108. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  109. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  110. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  111. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  112. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  113. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  114. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  115. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  116. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  117. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  118. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  119. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  120. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  121. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  122. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  123. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  124. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  125. package/tests/graph-cascade.test.mjs +190 -0
  126. package/tests/graph-view.test.mjs +187 -0
  127. package/tests/graph.test.mjs +94 -0
  128. package/tests/hook-order.test.mjs +72 -0
  129. package/tests/humanlike.test.mjs +232 -0
  130. package/tests/hybrid-recall.test.mjs +122 -0
  131. package/tests/inbound.test.mjs +393 -0
  132. package/tests/inject-guide.test.mjs +364 -0
  133. package/tests/inject.test.mjs +222 -0
  134. package/tests/injection-budget-settings.test.mjs +35 -0
  135. package/tests/injection-budget.test.mjs +64 -0
  136. package/tests/injection-chain.test.mjs +191 -0
  137. package/tests/loader-register.mjs +3 -0
  138. package/tests/loader.mjs +12 -0
  139. package/tests/local-embedding-entry.test.mjs +95 -0
  140. package/tests/local-embedding-manager.test.mjs +476 -0
  141. package/tests/local-embedding-protocol.test.mjs +95 -0
  142. package/tests/local-embedding-worker.test.mjs +346 -0
  143. package/tests/managed-memory.test.mjs +591 -0
  144. package/tests/managed-runtime.test.mjs +40 -0
  145. package/tests/media.test.mjs +132 -0
  146. package/tests/migration-payload.test.mjs +42 -0
  147. package/tests/mood.test.mjs +109 -0
  148. package/tests/multiprocess.test.mjs +29 -0
  149. package/tests/new-session-gate.test.mjs +361 -0
  150. package/tests/package-shipping.test.mjs +84 -0
  151. package/tests/preset-scope.test.mjs +212 -0
  152. package/tests/prompts.test.mjs +250 -0
  153. package/tests/react-snapshot.test.mjs +23 -0
  154. package/tests/recall-bench.test.mjs +306 -0
  155. package/tests/recall-dedup.test.mjs +417 -0
  156. package/tests/remote.test.mjs +140 -0
  157. package/tests/rename.test.mjs +30 -0
  158. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  159. package/tests/repetition-eval-live.test.mjs +177 -0
  160. package/tests/repetition-eval.test.mjs +373 -0
  161. package/tests/salient-date.test.mjs +142 -0
  162. package/tests/settings-beat.test.mjs +221 -0
  163. package/tests/short-term.test.mjs +166 -0
  164. package/tests/sidebar-beat.test.mjs +212 -0
  165. package/tests/sidebar-refresh.test.mjs +123 -0
  166. package/tests/sidebar.test.mjs +70 -0
  167. package/tests/snippet.test.mjs +47 -0
  168. package/tests/source-kind.test.mjs +52 -0
  169. package/tests/state.test.mjs +150 -0
  170. package/tests/stats.test.mjs +112 -0
  171. package/tests/storage.test.mjs +125 -0
  172. package/tests/stubs/dsh-llm.js +5 -0
  173. package/tests/stubs/dsh-session.js +7 -0
  174. package/tests/stubs/dsh-tools.js +101 -0
  175. package/tests/tool-schema.test.mjs +73 -0
  176. package/tests/tools.test.mjs +184 -0
  177. package/tests/ui-defaults.test.mjs +57 -0
  178. package/tests/update-fields.test.mjs +138 -0
  179. package/tests/vector-migration.test.mjs +790 -0
  180. package/tests/vector-profile.test.mjs +425 -0
  181. package/tests/visibility-migration.test.mjs +152 -0
  182. package/tests/webui-auth.test.mjs +454 -0
  183. package/tests/webui.test.mjs +165 -0
  184. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,72 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+
5
+ // 2026-10-30 现场:日志页的 7 个 useState + useEffect 被写在
6
+ // `if (!state) return ...` 之后。首屏少调 10 个 hook、数据到达后多调 10 个,
7
+ // React 抛 #310,整个设置弹窗和 WebUI 白屏,控制台只有一行 minified 报错。
8
+ //
9
+ // 这类 bug 很难被假 React 的渲染用例覆盖(它不校验 hook 顺序),所以用一条静态守卫:
10
+ // 组件函数体内、与组件同级的 React.use* 必须全部出现在提前 return 之前。
11
+
12
+ const source = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
13
+
14
+ // 从 `function Name(` 后的 `{` 开始做括号配对,跳过字符串与注释,拿到精确的函数体。
15
+ function bodyOf(name) {
16
+ const start = source.indexOf('function ' + name + '(')
17
+ assert.ok(start > 0, name + ' not found in lib/client.js')
18
+ let index = source.indexOf('{', source.indexOf(')', start))
19
+ assert.ok(index > start, name + ' has no function body')
20
+ const begin = index
21
+ let depth = 0
22
+ let quote = null
23
+ for (; index < source.length; index++) {
24
+ const ch = source[index]
25
+ if (quote) {
26
+ if (ch === '\\') { index++; continue }
27
+ if (ch === quote) quote = null
28
+ continue
29
+ }
30
+ if (ch === "'" || ch === '"' || ch === '\u0060') { quote = ch; continue }
31
+ if (ch === '/' && source[index + 1] === '/') {
32
+ const newline = source.indexOf('\n', index)
33
+ index = newline < 0 ? source.length : newline
34
+ continue
35
+ }
36
+ if (ch === '/' && source[index + 1] === '*') {
37
+ const close = source.indexOf('*/', index + 2)
38
+ index = close < 0 ? source.length : close + 1
39
+ continue
40
+ }
41
+ if (ch === '{') depth += 1
42
+ else if (ch === '}') {
43
+ depth -= 1
44
+ if (depth === 0) return source.slice(begin, index + 1)
45
+ }
46
+ }
47
+ assert.fail(name + ' body was never closed')
48
+ }
49
+
50
+ for (const [name, earlyReturn] of [
51
+ ['SettingsPage', 'if (!state) return'],
52
+ ['MemoryTab', 'if (!sessionId) return'],
53
+ ]) {
54
+ test(`${name}: every component-level hook is declared before "${earlyReturn}"`, () => {
55
+ const body = bodyOf(name)
56
+ const lines = body.split('\n')
57
+ // 只认与组件同级(6 空格缩进)的提前 return:注释里也会出现这段文字,
58
+ // 嵌套回调里的 return 则不影响组件自身的 hook 顺序。
59
+ const literal = earlyReturn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
60
+ const atComponentLevel = new RegExp('^ {6}' + literal + '\\b')
61
+ const retLine = lines.findIndex(line => atComponentLevel.test(line))
62
+ assert.ok(retLine > 0, `${name} no longer contains "${earlyReturn}" — update this guard instead of deleting it`)
63
+ const later = []
64
+ for (let line = retLine + 1; line < lines.length; line++) {
65
+ // 同级 hook 缩进固定为 6 个空格(模块工厂内 4 + 函数体 2);嵌套组件的 hook 更深,
66
+ // 它们的顺序不受这个提前 return 影响。
67
+ if (!/^ {6}\S/.test(lines[line])) continue
68
+ if (/React\.use[A-Z]/.test(lines[line])) later.push(`${line + 1}: ${lines[line].trim().slice(0, 80)}`)
69
+ }
70
+ assert.deepEqual(later, [], `${name}: hook after the early return — React #310 blanks the panel. Move it above.\n` + later.join('\n'))
71
+ })
72
+ }
@@ -0,0 +1,232 @@
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-human-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const state = await import('../lib/state.js')
11
+ const memory = await import('../lib/memory.js')
12
+ const inject = await import('../lib/inject.js')
13
+ const rpc = await import('../lib/rpc.js')
14
+
15
+ test.after(() => {
16
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
17
+ })
18
+
19
+ function execFor(sessionId, cwd) {
20
+ return { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } }
21
+ }
22
+
23
+ function ageEntry(libraryId, id, days) {
24
+ const index = state.loadPrivateIndex(libraryId)
25
+ const at = new Date(Date.now() - days * 86_400_000).toISOString()
26
+ index.entries[id].updatedAt = at
27
+ index.entries[id].createdAt = at
28
+ state.savePrivateIndex(libraryId, index)
29
+ }
30
+
31
+ test('relative time reads like a person, not like a timestamp', () => {
32
+ const now = Date.parse('2026-10-27T12:00:00.000Z')
33
+ const ago = (ms) => new Date(now - ms).toISOString()
34
+ assert.equal(memory.relativeTime(ago(30_000), now), '刚刚')
35
+ assert.equal(memory.relativeTime(ago(5 * 60_000), now), '5 分钟前')
36
+ assert.equal(memory.relativeTime(ago(3 * 3_600_000), now), '3 小时前')
37
+ assert.equal(memory.relativeTime(ago(2 * 86_400_000), now), '2 天前')
38
+ assert.equal(memory.relativeTime(ago(40 * 86_400_000), now), '1 个月前')
39
+ // An unparseable stamp must not paint "NaN 天前" into the prompt.
40
+ assert.equal(memory.relativeTime('not a date', now), '')
41
+ })
42
+
43
+ test('the injected line carries how old the memory is', async () => {
44
+ const cwd = path.join(home, 'proj-age')
45
+ const sessionId = 'age-1'
46
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
47
+ const exec = execFor(sessionId, cwd)
48
+ const { id } = await memory.privateRemember({
49
+ exec, content: 'The rollback drill runs every Friday afternoon.', title: 'rollback drill',
50
+ })
51
+ ageEntry(binding.libraryId, id, 4)
52
+ const anchor = inject.buildAnchor(binding, { ...state.getSettings(), anchorTokens: 700 })
53
+ assert.ok(anchor.text.includes('4 天前'), anchor.text)
54
+ })
55
+
56
+ test('near-duplicate memories stop eating the recall budget', async () => {
57
+ const cwd = path.join(home, 'proj-mmr')
58
+ const sessionId = 'mmr-1'
59
+ const exec = execFor(sessionId, cwd)
60
+ const written = []
61
+ for (const [title, content] of [
62
+ ['hero plan', 'pricing page rewrite plan for the landing hero'],
63
+ ['hero plan b', 'pricing page rewrite plan for the landing hero section'],
64
+ ['hero plan c', 'pricing page rewrite plan for the landing hero area'],
65
+ ['pricing doc', 'pricing page rewrite plan is written down in docs/pricing.md'],
66
+ ]) {
67
+ const { id } = await memory.privateRemember({ exec, content, title })
68
+ written.push(id)
69
+ }
70
+ const result = await memory.privateRecall({
71
+ exec, query: 'pricing page rewrite plan', topK: 2, relevanceThreshold: 0,
72
+ })
73
+ const names = result.files.map((file) => file.name)
74
+ assert.equal(names.length, 2)
75
+ assert.ok(names.includes(written[3]), `the distinct memory must survive the near-duplicates, got ${names.join(',')}`)
76
+ })
77
+
78
+ test('a memory that keeps being pushed fades, and editing it brings it back', async () => {
79
+ const cwd = path.join(home, 'proj-habit')
80
+ const sessionId = 'habit-1'
81
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
82
+ const exec = execFor(sessionId, cwd)
83
+ const { id } = await memory.privateRemember({
84
+ exec, content: 'Deploy window notes: staging turns over at noon.', title: 'deploy window',
85
+ })
86
+ const scoreOf = async () => {
87
+ const res = await memory.privateRecall({ exec, query: 'deploy window notes', topK: 3, relevanceThreshold: 0 })
88
+ const hit = res.files.find((file) => file.name === id)
89
+ return hit ? hit.score : 0
90
+ }
91
+ const fresh = await scoreOf()
92
+ memory.noteInjected({ libraryId: binding.libraryId, ids: [id] })
93
+ memory.noteInjected({ libraryId: binding.libraryId, ids: [id] })
94
+ const dulled = await scoreOf()
95
+ assert.ok(dulled < fresh, `repeated injection should lower the score (${fresh} -> ${dulled})`)
96
+
97
+ // Rewriting the body is the signal that this memory is worth keeping current.
98
+ await memory.privateUpdate({ exec, id, content: 'Deploy window notes: staging turns over at noon, prod at six.' })
99
+ const refreshed = await scoreOf()
100
+ assert.ok(refreshed > dulled, `an edit must clear the habituation (${dulled} -> ${refreshed})`)
101
+ })
102
+
103
+ test('a context-scoped memory stays inside its character until the user widens it', async () => {
104
+ const cwd = path.join(home, 'proj-scope')
105
+ const mine = 'scope-a'
106
+ const other = 'scope-b'
107
+ const binding = state.ensureSession(mine, { cwd, agentPreset: 'coding' })
108
+ const execMine = execFor(mine, cwd)
109
+ const execOther = execFor(other, cwd)
110
+ state.ensureSession(other, { cwd, agentPreset: 'coding' })
111
+ // Both conversations live in one folder. The only thing that may separate them is the
112
+ // scope: `session` used to, and it did so by making the memory disappear for everyone.
113
+ state.setSessionBinding(mine, { memoryContext: { characterId: 'hero' } })
114
+ state.setSessionBinding(other, { memoryContext: { characterId: 'villain' } })
115
+
116
+ const { id } = await memory.privateRemember({
117
+ exec: execMine,
118
+ content: 'The seal number for this thread is 4471.',
119
+ title: 'seal number',
120
+ })
121
+ const seenByMine = await memory.privateRecall({ exec: execMine, query: 'seal number', topK: 3, relevanceThreshold: 0 })
122
+ assert.ok(seenByMine.files.some((file) => file.name === id), 'the writing conversation must still see it')
123
+
124
+ const seenByOther = await memory.privateRecall({ exec: execOther, query: 'seal number', topK: 3, relevanceThreshold: 0 })
125
+ assert.equal(seenByOther.files.some((file) => file.name === id), false, 'another character must not see it')
126
+ const anchorForOther = inject.buildAnchor(state.getSessionBinding(other), { ...state.getSettings(), anchorTokens: 700 }, other)
127
+ assert.equal(anchorForOther.text.includes(id), false)
128
+
129
+ // Only the user can widen a stored boundary; the memory wall has no conversation and
130
+ // therefore no scope to defend. Widening drops the pointer, it does not copy the entry.
131
+ await rpc.dispatch({}, 'updateMemory', { libraryId: binding.libraryId, id, patch: { context: {} } })
132
+ assert.deepEqual(state.loadPrivateIndex(binding.libraryId).entries[id].context, {})
133
+ const seenAgain = await memory.privateRecall({ exec: execOther, query: 'seal number', topK: 3, relevanceThreshold: 0 })
134
+ assert.ok(seenAgain.files.some((file) => file.name === id), 'once the user widens it, the folder can read it')
135
+ })
136
+
137
+ test('an old open loop is raised once, then left alone', async () => {
138
+ const cwd = path.join(home, 'proj-loop')
139
+ const sessionId = 'loop-1'
140
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
141
+ const exec = execFor(sessionId, cwd)
142
+ const { id } = await memory.privateRemember({
143
+ exec,
144
+ content: 'He asked whether the salary review letter ever went out; I never answered.',
145
+ title: 'salary review letter',
146
+ openLoop: true,
147
+ })
148
+ ageEntry(binding.libraryId, id, 6)
149
+
150
+ const first = inject.buildOpenLoopNudge(state.getSessionBinding(sessionId))
151
+ assert.ok(first.text.includes('[open loop]'), first.text)
152
+ assert.ok(first.text.includes(id))
153
+ assert.ok(first.text.includes('没下文了'))
154
+
155
+ state.markInjected(sessionId, [id], 3)
156
+ const second = inject.buildOpenLoopNudge(state.getSessionBinding(sessionId))
157
+ assert.equal(second.text, '', 'once it has been said, it stops being a nudge')
158
+ })
159
+
160
+ test('compaction replay replays gists, never whole bodies, and skips secrets', async () => {
161
+ const cwd = path.join(home, 'proj-replay')
162
+ const sessionId = 'replay-1'
163
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
164
+ const exec = execFor(sessionId, cwd)
165
+ const long = 'X'.repeat(400)
166
+ const { id: plain } = await memory.privateRemember({
167
+ exec, content: `BODY-START ${long}`, title: 'long one',
168
+ })
169
+ const { id: secret } = await memory.privateRemember({
170
+ exec, content: 'SECRET-BODY that must stay out of the prompt', title: 'secret one', visibility: 'secret',
171
+ })
172
+ state.setSessionBinding(sessionId, { replayIds: [plain, secret] })
173
+ const replay = inject.buildReplay(state.getSessionBinding(sessionId), state.getSettings(), sessionId)
174
+ assert.ok(replay.text.includes('[replay'), replay.text)
175
+ assert.ok(replay.text.includes(plain))
176
+ assert.equal(replay.text.includes('X'.repeat(120)), false, 'a replay is a gist, not the body')
177
+ assert.equal(replay.text.includes(secret), false)
178
+ assert.equal(replay.text.includes('SECRET-BODY'), false)
179
+ assert.ok(binding.libraryId)
180
+ })
181
+
182
+ test('import restores what can travel and reports what cannot', async () => {
183
+ const cwd = path.join(home, 'proj-import')
184
+ const sessionId = 'import-1'
185
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
186
+ const exec = execFor(sessionId, cwd)
187
+ // Already here: the duplicate check has to look at the target library, not just the file.
188
+ await memory.privateRemember({ exec, content: 'pricing thread body about the hero', title: 'pricing thread' })
189
+
190
+ const result = await memory.importMemories({
191
+ libraryId: binding.libraryId,
192
+ memories: [
193
+ { id: 'old-1', title: 'pricing thread', content: 'pricing thread body about the hero', layer: 'long_term' },
194
+ { id: 'old-2', title: 'pricing thread copy', content: 'pricing thread body about the hero' },
195
+ { id: 'old-3', title: 'archived one', content: 'dead content', status: 'archived' },
196
+ { id: 'old-4', title: 'session note', content: 'session bound body', visibility: 'session' },
197
+ { id: 'old-5', content: ' ' },
198
+ { id: 'old-6', title: 'draft', content: 'draft body that expires', layer: 'short_term', expiresAt: new Date(Date.now() + 3_600_000).toISOString() },
199
+ { id: 'old-7', title: 'unique', content: 'a body nobody here has ever written' },
200
+ ],
201
+ })
202
+
203
+ assert.equal(result.imported, 3, JSON.stringify(result.counts))
204
+ assert.equal(result.counts.duplicate, 2)
205
+ assert.equal(result.counts.dead, 1)
206
+ assert.equal(result.counts.invalid, 1)
207
+ // A `session` entry from an older export lands as `shared` with the same marker a stored
208
+ // entry gets: dropping it silently would be the very data loss this upgrade prevents,
209
+ // and the report still has to say that its reader went from one conversation to a folder.
210
+ assert.equal(result.counts.downgraded, 1)
211
+
212
+ const index = state.loadPrivateIndex(binding.libraryId)
213
+ const entries = Object.entries(index.entries)
214
+ const restored = entries.filter(([, entry]) => entry.importedFrom)
215
+ assert.equal(restored.length, 3)
216
+ const kept = state.loadPrivateIndex(binding.libraryId).entries
217
+ const fromSession = Object.values(kept).find((entry) => entry.importedFrom === 'old-4')
218
+ assert.equal(fromSession.visibility, 'shared', 'the removed level has to come back as shared')
219
+ assert.equal(fromSession.migratedFromVisibility, 'session')
220
+ assert.equal('sessionId' in fromSession, false)
221
+ assert.ok(result.examples.downgraded.some((line) => line.includes('session note')), 'the report has to name what changed level')
222
+ assert.ok(result.examples.downgraded.some((line) => line.includes('session -> shared')))
223
+ const draft = restored.find(([, entry]) => entry.importedFrom === 'old-6')
224
+ assert.equal(draft[1].layer, 'short_term')
225
+ const hours = (Date.parse(draft[1].expiresAt) - Date.now()) / 3_600_000
226
+ assert.ok(hours > 0 && hours <= 1, `the earlier deadline wins, got ${hours}h`)
227
+ const unique = restored.find(([, entry]) => entry.importedFrom === 'old-7')
228
+ assert.ok(unique[1].content.includes('a body nobody here has ever written'), 'the body has to survive the restore')
229
+
230
+ const recall = await memory.privateRecall({ exec, query: 'body nobody ever written', topK: 3, relevanceThreshold: 0 })
231
+ assert.ok(recall.files.some((file) => file.name === unique[0]), 'a restored memory must be retrievable')
232
+ })
@@ -0,0 +1,122 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ const memory = await import('../lib/memory.js')
5
+
6
+ function entry(overrides = {}) {
7
+ return { status: 'active', title: 'memory', content: 'memory body', createdAt: '2026-10-01T00:00:00.000Z', updatedAt: '2026-10-01T00:00:00.000Z', ...overrides }
8
+ }
9
+
10
+ function indexOf(entries) {
11
+ return { entries }
12
+ }
13
+
14
+ test('RRF keeps one canonical id when both routes find it and preserves the best order', () => {
15
+ const index = indexOf({
16
+ 'mem-a': entry({ title: 'alpha' }),
17
+ 'mem-b': entry({ title: 'beta' }),
18
+ 'mem-c': entry({ title: 'gamma' }),
19
+ })
20
+ const fused = memory.fuseRecallCandidates({
21
+ local: [{ name: 'mem-a' }, { name: 'mem-b' }],
22
+ semantic: [{ name: 'mem-b.md' }, { name: 'mem-c' }],
23
+ index,
24
+ limit: 12,
25
+ })
26
+ assert.deepEqual(fused.files.map((file) => file.name), ['mem-b', 'mem-a', 'mem-c'])
27
+ assert.equal(fused.files[0].retrievalScore, 1)
28
+ assert.deepEqual(fused.files[0].fusion.routes, ['local', 'semantic'])
29
+ assert.equal(fused.debug.shared, 1)
30
+ assert.equal(fused.debug.localOnly, 1)
31
+ assert.equal(fused.debug.semanticOnly, 1)
32
+ assert.equal(fused.debug.fused, 3)
33
+ })
34
+
35
+ test('duplicate names within one route cannot double-score or consume a candidate slot', () => {
36
+ const index = indexOf({ 'mem-a': entry({ title: 'alpha' }), 'mem-b': entry({ title: 'beta' }) })
37
+ const fused = memory.fuseRecallCandidates({
38
+ local: [{ name: 'mem-a' }, { name: 'mem-a.md' }, { name: 'mem-b' }],
39
+ semantic: [],
40
+ index,
41
+ limit: 2,
42
+ })
43
+ assert.deepEqual(fused.files.map((file) => file.name), ['mem-a', 'mem-b'])
44
+ assert.equal(fused.debug.localCandidates, 2)
45
+ const first = fused.files.find((file) => file.name === 'mem-a')
46
+ assert.equal(first.fusion.rrf, 1 / 61)
47
+ assert.equal(first.fusion.bestRank, 1)
48
+ assert.equal(first.fusion.localRank, 1)
49
+ })
50
+
51
+ test('eligibility and context are applied before the candidate cap', () => {
52
+ const index = indexOf({
53
+ 'mem-pending': entry({ status: 'pending_review' }),
54
+ 'mem-secret': entry({ visibility: 'secret' }),
55
+ 'mem-ok': entry({ title: 'ok' }),
56
+ })
57
+ const fused = memory.fuseRecallCandidates({
58
+ local: [{ name: 'mem-pending' }, { name: 'mem-secret' }, { name: 'mem-ok' }],
59
+ semantic: [{ name: 'mem-secret' }],
60
+ index,
61
+ limit: 12,
62
+ automatic: true,
63
+ })
64
+ assert.deepEqual(fused.files.map((file) => file.name), ['mem-ok'])
65
+ assert.equal(fused.debug.localCandidates, 1)
66
+ assert.equal(fused.debug.semanticCandidates, 0)
67
+ })
68
+
69
+ test('the per-route candidate cap is bounded and deterministic', () => {
70
+ const entries = {}
71
+ const local = []
72
+ // Distinct bodies: near-identical candidates would fold into one cluster slot
73
+ // (fact-level dedup), and this test is about the cap, not the folding.
74
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz'
75
+ const token = (n) => alphabet[n % 26] + alphabet[Math.floor(n / 26) % 26]
76
+ for (let i = 0; i < 60; i += 1) {
77
+ const id = 'mem-' + String(i).padStart(2, '0')
78
+ entries[id] = entry({
79
+ title: 'topic ' + token(180 + i),
80
+ content: [token(3 * i), token(3 * i + 1), token(3 * i + 2)].join('of'),
81
+ })
82
+ local.push({ name: id })
83
+ }
84
+ assert.equal(memory.hybridCandidateLimit(1), 12)
85
+ assert.equal(memory.hybridCandidateLimit(3), 12)
86
+ assert.equal(memory.hybridCandidateLimit(8), 32)
87
+ assert.equal(memory.hybridCandidateLimit(50), 40)
88
+ const fused = memory.fuseRecallCandidates({ local, semantic: [], index: indexOf(entries), limit: memory.hybridCandidateLimit(3) })
89
+ assert.equal(fused.files.length, 12)
90
+ assert.deepEqual(fused.files.map((file) => file.name), local.slice(0, 12).map((file) => file.name).sort((a, b) => a.localeCompare(b)))
91
+ })
92
+
93
+ test('near-duplicate candidates fold into one candidate slot before the cap', () => {
94
+ const entries = {}
95
+ const local = []
96
+ for (let i = 0; i < 12; i += 1) {
97
+ const id = 'mem-dup-' + String(i).padStart(2, '0')
98
+ entries[id] = entry({ title: 'deploy window ' + (i ? 'variant ' + i : ''), content: 'the deploy window is thursday two to four am, grey releases only' })
99
+ local.push({ name: id })
100
+ }
101
+ entries['mem-real'] = entry({ title: 'reimbursement deadline', content: 'expense reports are due before the last working day of each month' })
102
+ const fused = memory.fuseRecallCandidates({ local: [...local, { name: 'mem-real' }], semantic: [], index: indexOf(entries), limit: 12 })
103
+ // The cluster spends one slot instead of twelve, so the real memory enters
104
+ // even though 13 candidates competed for 12 places.
105
+ assert.equal(fused.debug.localCandidates, 2)
106
+ assert.deepEqual(fused.files.map((file) => file.name), ['mem-dup-00', 'mem-real'])
107
+ assert.deepEqual(fused.debug.collapsed, [{ kept: 'mem-dup-00', dropped: local.slice(1).map((file) => file.name) }])
108
+ assert.equal(fused.debug.clusterCount, 1)
109
+ })
110
+
111
+ test('equal fused scores fall back to the best rank and then the canonical id', () => {
112
+ // Distinct texts: two byte-identical memories would fold as one fact before
113
+ // the tie-break this test is about ever gets a chance to run.
114
+ const index = indexOf({
115
+ 'mem-a': entry({ title: 'alpha topic', content: 'alpha body' }),
116
+ 'mem-b': entry({ title: 'beta topic', content: 'beta body' }),
117
+ })
118
+ const first = memory.fuseRecallCandidates({ local: [{ name: 'mem-a' }], semantic: [{ name: 'mem-b' }], index })
119
+ const second = memory.fuseRecallCandidates({ local: [{ name: 'mem-a' }], semantic: [{ name: 'mem-b' }], index })
120
+ assert.deepEqual(first.files.map((file) => file.name), ['mem-a', 'mem-b'])
121
+ assert.deepEqual(first.files.map((file) => file.name), second.files.map((file) => file.name))
122
+ })