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,89 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ const { assessConsolidationValue, unconsolidatedEvents } = await import('../lib/private.js')
5
+
6
+ function human(seq, text) {
7
+ return { seq, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }
8
+ }
9
+
10
+ function notice(seq, text) {
11
+ return { seq, type: 'user/message', data: { source: { kind: 'plugin:dsh-memu-memory' }, content: [{ type: 'text', text }] } }
12
+ }
13
+
14
+ function assistant(seq, blocks) {
15
+ return { seq, type: 'assistant/message', data: { message: { content: blocks } } }
16
+ }
17
+
18
+ test('an empty tail is not worth a fork', () => {
19
+ const value = assessConsolidationValue([])
20
+ assert.equal(value.worth, false)
21
+ assert.equal(value.reason, 'no-human-turn')
22
+ assert.equal(value.humanTurns, 0)
23
+ })
24
+
25
+ test('plugin notices and tool chatter are not human turns', () => {
26
+ const value = assessConsolidationValue([
27
+ notice(0, '整理这场对话的私有记忆'),
28
+ { seq: 1, type: 'tool/result', data: { message: { content: [{ type: 'text', text: 'ok' }] } } },
29
+ notice(2, 'short_term · 快到期了'),
30
+ ])
31
+ assert.equal(value.worth, false)
32
+ assert.equal(value.humanTurns, 0)
33
+ })
34
+
35
+ test('a bare thank-you is small talk', () => {
36
+ const value = assessConsolidationValue([human(0, '谢谢'), assistant(1, [{ type: 'text', text: '不客气' }])])
37
+ assert.equal(value.worth, false)
38
+ assert.equal(value.reason, 'small-talk')
39
+ assert.equal(value.humanTurns, 1)
40
+ })
41
+
42
+ test('one substantive message is enough', () => {
43
+ const value = assessConsolidationValue([human(0, '以后发版前不要动 AGENTS.md 里那行预算,它一超就被静默截断,模型永远看不到后半段。')])
44
+ assert.equal(value.worth, true)
45
+ assert.equal(value.reason, 'substantive')
46
+ assert.ok(value.humanChars >= 12)
47
+ })
48
+
49
+ test('tool work counts even when the person types little', () => {
50
+ const value = assessConsolidationValue([
51
+ human(0, '跑一下'),
52
+ assistant(1, [{ type: 'tool-call', name: 'bash', arguments: '{"command":"npm test"}' }]),
53
+ { seq: 2, type: 'tool/call', data: { name: 'bash', arguments: '{}' } },
54
+ ])
55
+ assert.equal(value.worth, true)
56
+ assert.equal(value.toolCalls, 2, 'both shapes of tool call are counted')
57
+ })
58
+
59
+ test('a short message can be the whole point', () => {
60
+ // Length is not substance: five characters are a durable preference.
61
+ const value = assessConsolidationValue([human(0, '我不吃香菜')])
62
+ assert.equal(value.worth, true)
63
+ assert.equal(value.humanTurns, 1)
64
+ })
65
+
66
+ test('an acknowledgement is refused, a short question is not', () => {
67
+ assert.equal(assessConsolidationValue([human(0, '好的,继续')]).worth, false)
68
+ assert.equal(assessConsolidationValue([human(0, '这个怎么配')]).worth, true, 'a question is worth reading even when it is short')
69
+ assert.equal(assessConsolidationValue([human(0, '这个怎么配'), human(1, '我按文档试了还是不行')]).worth, true)
70
+ })
71
+
72
+ test('only the events after the last consolidation are judged', () => {
73
+ const events = [human(0, '很久以前的一次长对话,已经整理过了,内容足够长足够长足够长足够长。'), human(1, '谢谢')]
74
+ // No marker recorded yet: the whole transcript is the tail, so the old material
75
+ // still counts once. That is deliberate — the first pass after an upgrade should
76
+ // not decide "small talk" from a transcript it has never read.
77
+ assert.equal(unconsolidatedEvents({}, events).length, 2)
78
+ assert.equal(assessConsolidationValue(unconsolidatedEvents({}, events)).worth, true)
79
+ // Once a marker exists, the tail is only what came after it.
80
+ const tail = unconsolidatedEvents({ lastSleepSeq: 0 }, events)
81
+ assert.equal(tail.length, 1)
82
+ assert.equal(assessConsolidationValue(tail).worth, false)
83
+ })
84
+
85
+ test('a zero marker is honoured (seq 0 is a real position)', () => {
86
+ const events = [human(0, '旧消息'), human(1, '新的一次决定,内容足够长足够长足够长足够长足够长足够长。')]
87
+ const tail = unconsolidatedEvents({ lastSleepSeq: 0 }, events)
88
+ assert.deepEqual(tail.map((event) => event.seq), [1])
89
+ })
@@ -0,0 +1,40 @@
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
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-scope-'))
7
+ process.env.DSH_MEMU_HOME = home
8
+ const state = await import('../lib/state.js')
9
+ const memory = await import('../lib/memory.js')
10
+ const { dispatch } = await import('../lib/rpc.js')
11
+ test.after(() => { state.flushState(); fs.rmSync(home, { recursive: true, force: true }) })
12
+ function exec(id) { return { agent: { session: { header: { id } } } } }
13
+ for (const id of ['a', 'b']) state.ensureSession(id, { cwd: path.join(home, 'shared-rp'), agentPreset: 'rp' })
14
+
15
+ test('same-title claims from distinct speakers do not become contradictions', async () => {
16
+ const a = await memory.privateRemember({ exec: exec('a'), title: '饮品偏好', content: '小甲喜欢红茶。', context: { speakerId: 'user-a' } })
17
+ const b = await memory.privateRemember({ exec: exec('a'), title: '饮品偏好', content: '小乙不喜欢红茶。', context: { speakerId: 'user-b' } })
18
+ assert.equal(a.entry.status, 'active')
19
+ assert.equal(b.entry.status, 'active')
20
+ })
21
+
22
+ test('world, branch and channel isolate every recall and list surface', async () => {
23
+ await dispatch({}, 'setMemoryContext', { sessionId: 'a', context: { channelId: 'group-1', characterId: 'hero', worldId: 'world-1', branchId: 'west' } })
24
+ await dispatch({}, 'setMemoryContext', { sessionId: 'b', context: { channelId: 'group-2', characterId: 'hero', worldId: 'world-1', branchId: 'west' } })
25
+ const secret = await memory.privateRemember({ exec: exec('a'), title: '剧场钥匙', content: '剧场钥匙在台灯旁。', context: { knownBy: ['hero'] } })
26
+ assert.ok((await memory.privateRecall({ exec: exec('a'), query: secret.id })).files.length)
27
+ assert.equal((await memory.privateRecall({ exec: exec('b'), query: secret.id })).files.length, 0)
28
+ assert.ok(!(await memory.privateList({ exec: exec('b') })).files.some(file => file.name === secret.id))
29
+ await dispatch({}, 'setMemoryContext', { sessionId: 'b', context: { channelId: 'group-1', characterId: 'hero', worldId: 'world-1', branchId: 'east' } })
30
+ assert.equal((await memory.privateRecall({ exec: exec('b'), query: secret.id })).files.length, 0)
31
+ await assert.rejects(memory.privateRemember({ exec: exec('b'), content: 'scope bypass', context: { branchId: 'west' } }), /cannot widen/)
32
+ await assert.rejects(memory.privateRemember({ exec: exec('b'), content: 'scope widening', context: { branchId: '' } }), /cannot widen/)
33
+ })
34
+
35
+ test('knownBy is a character knowledge boundary, not a relationship score', async () => {
36
+ await dispatch({}, 'setMemoryContext', { sessionId: 'a', context: { characterId: 'hero' } })
37
+ const record = await memory.privateRemember({ exec: exec('a'), title: '反派的计划', content: '叙事者记录了反派的计划,主角不知道。', context: { knownBy: ['villain'] } })
38
+ assert.equal((await memory.privateRecall({ exec: exec('a'), query: record.id })).files.length, 0)
39
+ assert.ok(!(await memory.privateList({ exec: exec('a') })).files.some(file => file.name === record.id))
40
+ })
@@ -0,0 +1,44 @@
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-cue-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const state = await import('../lib/state.js')
11
+ const inject = await import('../lib/inject.js')
12
+
13
+ test.after(() => {
14
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
15
+ })
16
+
17
+ test('random cue is injected once and armed', async () => {
18
+ state.updateSettings({ randomCueChance: 1 })
19
+ const cwd = path.join(home, 'project-cue')
20
+ state.ensureSession('cue-session', { cwd, agentPreset: 'rp' })
21
+ const user = {
22
+ id: 'u-cue', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '今天发生了什么,你还记得吗?' }],
23
+ }
24
+ const decision = { kind: 'enter', messages: [user] }
25
+ const agent = { session: { header: { id: 'cue-session', cwd, agentPreset: 'rp' } } }
26
+ const out = await inject.injectForStep({ agent, decision, turn: 2, step: 1, signal: { aborted: false } })
27
+ const text = out.messages[0]?.content?.[0]?.text || ''
28
+ assert.ok(text.includes('<memory-cue id="'))
29
+ const active = state.getActiveCue('cue-session')
30
+ assert.ok(active && active.id)
31
+
32
+ const again = await inject.injectForStep({ agent, decision, turn: 3, step: 1, signal: { aborted: false } })
33
+ const textAgain = again.messages?.[0]?.content?.[0]?.text || ''
34
+ assert.ok(!textAgain.includes('<memory-cue id="'))
35
+ assert.equal(state.consumeCue('cue-session', active.id), true)
36
+ assert.equal(state.getActiveCue('cue-session'), null)
37
+ })
38
+
39
+ test('makeCue returns a stable shape', () => {
40
+ const cue = inject.makeCue()
41
+ assert.match(cue.id, /^cue-/)
42
+ assert.ok(cue.text.length > 0)
43
+ assert.ok(inject.CUE_TEXTS.includes(cue.text))
44
+ })
@@ -0,0 +1,277 @@
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 { registerHooks } from 'node:module'
7
+
8
+ // commitCuratorOutput regressions :
9
+ // 1. A held clone created by a target-changed conflict must NOT inherit the
10
+ // vector stamp of the batch commit (the batch committed `id`, not heldId),
11
+ // and membership must not count either the clone or the unindexed `id` row.
12
+ // 2. A global-index write failure after the batch commit must be compensated:
13
+ // every committed name receives a forget, and partial/complete outcomes are
14
+ // recorded to the activity log (compensation design, not a transaction).
15
+ //
16
+ // No engine, no download: './memu.js' is replaced before production modules
17
+ // load (same pattern as tests/managed-memory.test.mjs), and './state.js' is
18
+ // wrapped with a one-shot updateGlobalIndex failure switch. Everything else is
19
+ // the real module stack against a temp DSH_MEMU_HOME.
20
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-curator-commit-'))
21
+ process.env.DSH_MEMU_HOME = home
22
+ process.env.DSH_MEMU_MEMORY_HOME = home
23
+
24
+ const backend = {
25
+ commits: [],
26
+ forgets: [],
27
+ onCommit: null,
28
+ }
29
+ globalThis.__curatorCommitTest = {
30
+ async commit(args) {
31
+ const names = (args.payload?.recall_files || []).map((file) => file.name)
32
+ backend.commits.push({ names, route: args.route, db: args.db })
33
+ if (backend.onCommit) await backend.onCommit()
34
+ return { ok: true }
35
+ },
36
+ async forget(args) {
37
+ backend.forgets.push({ name: args.name, db: args.db })
38
+ return { deleted: 1, segments: 1 }
39
+ },
40
+ async retrieve() { return { files: [], segments: [], resources: [], raw: null } },
41
+ async listFiles() { return { recall_files: [], resources: [], raw: null } },
42
+ }
43
+
44
+ // One-shot failure switch on updateGlobalIndex; every other state.js export is
45
+ // forwarded to the real module (local exports shadow the star re-export).
46
+ const indexHook = { failNext: 0, thrown: [] }
47
+ globalThis.__curatorIndexHook = indexHook
48
+
49
+ const realStateUrl = new URL('../lib/state.js', import.meta.url).href
50
+ const realMemuUrl = new URL('../lib/memu.js', import.meta.url).href
51
+ const memuSource = 'const bridge = globalThis.__curatorCommitTest;'
52
+ + 'export const commit = (args) => bridge.commit(args), forget = (args) => bridge.forget(args),'
53
+ + 'retrieve = (args) => bridge.retrieve(args), listFiles = (args) => bridge.listFiles(args);'
54
+ const stateSource = [
55
+ `import * as real from ${JSON.stringify(realStateUrl)}`,
56
+ 'const hook = globalThis.__curatorIndexHook',
57
+ 'export function updateGlobalIndex(mutator) {',
58
+ ' if (hook.failNext > 0) { hook.failNext -= 1; const error = new Error("disk full (simulated index failure)"); hook.thrown.push(String(error.message)); throw error }',
59
+ ' return real.updateGlobalIndex(mutator)',
60
+ '}',
61
+ `export * from ${JSON.stringify(realStateUrl)}`,
62
+ ].join('\n')
63
+ const hooks = registerHooks({
64
+ resolve(specifier, context, nextResolve) {
65
+ if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(memuSource), shortCircuit: true }
66
+ if (specifier === './state.js') return { url: 'data:text/javascript,' + encodeURIComponent(stateSource), shortCircuit: true }
67
+ return nextResolve(specifier, context)
68
+ },
69
+ })
70
+
71
+ const catalog = await import('../lib/embedding-catalog.js')
72
+ const vp = await import('../lib/vector-profile.js')
73
+ const vm = await import('../lib/vector-migration.js')
74
+ const curator = await import('../lib/curator.js')
75
+ const state = await import('../lib/state.js')
76
+ const activity = await import('../lib/activity-log.js')
77
+
78
+ const BGE = 'bge-small-zh-v1.5-int8'
79
+ // A second resolvable catalog entry so switchProfile has a verified profile to
80
+ // activate (same fixture trick as tests/vector-migration.test.mjs).
81
+ const baseEntry = catalog.getEmbeddingModel(BGE)
82
+ const altEntry = JSON.parse(JSON.stringify(baseEntry))
83
+ altEntry.id = 'bge-small-zh-v1.5-int8-alt'
84
+ altEntry.displayName = 'BGE alternative revision (curator commit fixture)'
85
+ altEntry.sources.modelscope.revision = 'c'.repeat(40)
86
+ altEntry.profileFingerprint = catalog.profileFingerprint(altEntry)
87
+ const models = { [BGE]: baseEntry, [altEntry.id]: altEntry }
88
+ vp.setCatalogEntryResolver((id) => models[id] ?? null)
89
+
90
+ state.updateSettings({ memoryBackend: 'api', vectorSource: 'local' })
91
+
92
+ const TARGET = 'gmem-target'
93
+ const TARGET_ENTRY = {
94
+ track: 'memory', domain: 'general', title: '部署窗口约定',
95
+ content: '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。',
96
+ confidence: 0.9, status: 'active',
97
+ createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
98
+ evidence: [{ sessionId: 'old', quote: 'old quote' }],
99
+ }
100
+
101
+ function seedGlobalIndex(entries = {}) {
102
+ state.saveGlobalIndex({ version: 1, entries: structuredClone(entries) })
103
+ }
104
+
105
+ async function activateVectorGeneration() {
106
+ // In-memory stand-in for the sqlite vector store so the migration readback
107
+ // (syncShadow verifies committed items via listFiles) is faithful.
108
+ const dbs = new Map()
109
+ const rowsOf = (db) => {
110
+ if (!dbs.has(db)) dbs.set(db, new Map())
111
+ return dbs.get(db)
112
+ }
113
+ const migrator = vm.createVectorMigrator({
114
+ async listLibraries() { return [] },
115
+ // membership() 的戳腿/账本腿读的是真实索引;这里直接转发到真 state.js。
116
+ loadIndex(libraryId) {
117
+ return libraryId ? state.loadPrivateIndex(libraryId) : state.loadGlobalIndex()
118
+ },
119
+ async commit({ db, item }) { rowsOf(db).set(item.id, item.text) },
120
+ async forget({ db, itemId }) { rowsOf(db).delete(itemId) },
121
+ async listFiles({ db }) { return [...rowsOf(db).keys()] },
122
+ async smoke() { return { ok: true, checks: ['dimension=512'] } },
123
+ })
124
+ const switched = await migrator.switchProfile({ profile: BGE })
125
+ assert.ok(['switched', 'already-active'].includes(switched.status), 'an active generation must be available')
126
+ return { migrator, generation: switched.generation }
127
+ }
128
+
129
+ test.after(async () => {
130
+ try { hooks.unregister?.() } catch {}
131
+ vp.resetCatalogEntryResolver()
132
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
133
+ })
134
+
135
+ const mergeOutput = {
136
+ memories: [{
137
+ op: 'merge', domain: 'general', track: 'memory', target_id: TARGET,
138
+ title: '部署窗口的合并版本',
139
+ content: '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布,全量要等监控确认。',
140
+ confidence: 0.9,
141
+ evidence: [{ session_id: 's1', quote: '部署窗口只在周四凌晨' }],
142
+ }],
143
+ }
144
+
145
+ test('a held clone from a target-changed conflict carries no vector stamp and membership counts neither it nor the committed id row', async () => {
146
+ seedGlobalIndex({ [TARGET]: TARGET_ENTRY })
147
+ const { migrator, generation } = await activateVectorGeneration()
148
+
149
+ // The concurrent writer lands while the curator is between its index read and
150
+ // its index write: the backend commit is the only async seam in between.
151
+ backend.onCommit = async () => {
152
+ state.updateGlobalIndex((fresh) => {
153
+ fresh.entries[TARGET] = { ...fresh.entries[TARGET], content: '内容已被并发修改', updatedAt: '2026-02-02T00:00:00.000Z' }
154
+ })
155
+ }
156
+ const result = await curator.commitCuratorOutput(structuredClone(mergeOutput), { job: { step: 'committing' } })
157
+ backend.onCommit = null
158
+
159
+ assert.equal(result.merged, 1, 'the merge update is still accounted as attempted')
160
+ assert.equal(result.files.length, 1)
161
+ const committedId = result.files[0].name
162
+ assert.deepEqual(backend.commits.map((call) => call.names), [[committedId]], 'the batch committed exactly the new id')
163
+
164
+ const index = state.loadGlobalIndex()
165
+ // The held clone got its own id; the committed id row is NOT in the index.
166
+ const heldId = Object.keys(index.entries).find((id) => index.entries[id]?.reviewReason === 'target changed while curator was running')
167
+ assert.ok(heldId, 'the target-changed merge must be held for review')
168
+ assert.notEqual(heldId, committedId)
169
+ assert.equal(index.entries[committedId], undefined, 'the committed id must not land in the index when held')
170
+ const held = index.entries[heldId]
171
+ assert.equal(held.status, 'pending_review')
172
+ assert.equal(held.conflictingWith, TARGET)
173
+ // The regression: the clone spread `...entry` after the stamp loop stamped it.
174
+ assert.equal(Object.hasOwn(held, 'vector'), false, 'a held clone never entered the vector store, so it must not carry the stamp')
175
+ assert.equal(index.entries[TARGET].status, 'active', 'the untouched target is not superseded by a held clone')
176
+
177
+ // membership: the ledger leg keeps TARGET; the stamp leg admits the new
178
+ // create/update stamps only. Neither heldId (no stamp, not in ledger) nor the
179
+ // committed `id` (row exists in the backend, but not in the index) may appear.
180
+ const membership = migrator.membership()
181
+ assert.ok(membership, 'an active generation must produce a membership map')
182
+ const globalIds = membership.global || []
183
+ assert.ok(globalIds.includes(TARGET), 'the migrated target stays counted')
184
+ assert.ok(!globalIds.includes(heldId), 'the held clone is not counted as vector membership')
185
+ assert.ok(!globalIds.includes(committedId), 'the backend row `id` must not be attributed to the index it never landed in')
186
+ })
187
+
188
+ test('a clean create still carries the current-generation stamp and is counted by membership', async () => {
189
+ seedGlobalIndex({})
190
+ const { migrator, generation } = await activateVectorGeneration()
191
+ const output = {
192
+ memories: [{
193
+ op: 'create', domain: 'general', track: 'memory',
194
+ title: '独立的全新事实',
195
+ content: '用户倾向于在正式汇报里先给结论再给细节。',
196
+ confidence: 0.9,
197
+ evidence: [{ session_id: 's2', quote: '先说结论' }],
198
+ }],
199
+ }
200
+ const result = await curator.commitCuratorOutput(output, { job: { step: 'committing' } })
201
+ assert.equal(result.created, 1)
202
+ const index = state.loadGlobalIndex()
203
+ const entry = Object.values(index.entries)[0]
204
+ assert.equal(entry.status, 'active')
205
+ assert.equal(entry.vector?.route, 'managed-local')
206
+ assert.equal(Number(entry.vector?.generation), generation, 'the stamp names the active generation')
207
+ const membership = migrator.membership()
208
+ assert.deepEqual(membership.global, [result.files[0].name])
209
+ })
210
+
211
+ test('a global-index write failure after the batch commit is compensated name by name and reported', async () => {
212
+ seedGlobalIndex({})
213
+ await activateVectorGeneration()
214
+ const output = {
215
+ memories: [
216
+ { op: 'create', domain: 'general', track: 'memory', title: '第一条', content: '第一条全新事实的内容,足够长以通过校验。', confidence: 0.9, evidence: [{ session_id: 's3', quote: '一' }] },
217
+ { op: 'create', domain: 'general', track: 'memory', title: '第二条', content: '第二条全新事实的内容,与第一条没有关系。', confidence: 0.9, evidence: [{ session_id: 's4', quote: '二' }] },
218
+ ],
219
+ }
220
+ indexHook.failNext = 1
221
+ await assert.rejects(
222
+ curator.commitCuratorOutput(structuredClone(output), { job: { step: 'committing' } }),
223
+ /disk full/,
224
+ 'the original index error must surface, not the compensation result',
225
+ )
226
+ const committedNames = backend.commits.at(-1).names
227
+ assert.equal(committedNames.length, 2)
228
+ // Compensation: every committed name received a forget against the global db.
229
+ assert.deepEqual(
230
+ backend.forgets.map((call) => call.name).sort(),
231
+ [...committedNames].sort(),
232
+ 'each just-committed name must be forgotten',
233
+ )
234
+ assert.ok(backend.forgets.every((call) => call.db === state.globalDbPath()))
235
+ // The index write failed, so nothing landed: the library must not gain entries
236
+ // and the curation failure stays the job's error.
237
+ assert.deepEqual(Object.keys(state.loadGlobalIndex().entries), [])
238
+ // The compensation outcome is visible in the activity log, not swallowed.
239
+ const event = activity.listEvents({ kind: 'curate' }).find((item) => item.level === 'error' || item.level === 'warn')
240
+ assert.ok(event, 'the compensation must be recorded')
241
+ assert.equal(event.ok, true, 'both names were compensated, so the record says so')
242
+ assert.deepEqual(event.detail.compensated.sort(), [...committedNames].sort())
243
+ assert.deepEqual(event.detail.failed, [])
244
+ assert.match(event.message, /回滚|补偿/)
245
+ })
246
+
247
+ test('a compensation failure is reported per name and never hides the original error', async () => {
248
+ seedGlobalIndex({})
249
+ await activateVectorGeneration()
250
+ // Make forget fail for the second name only: partial compensation must be
251
+ // recorded name by name.
252
+ const originalForget = globalThis.__curatorCommitTest.forget
253
+ const forgetBaseline = backend.forgets.length
254
+ globalThis.__curatorCommitTest.forget = async (args) => {
255
+ if (backend.forgets.length > forgetBaseline) throw new Error('engine db is locked')
256
+ return originalForget(args)
257
+ }
258
+ const output = {
259
+ memories: [
260
+ { op: 'create', domain: 'general', track: 'memory', title: '甲', content: '甲事实的内容,与乙无关。', confidence: 0.9, evidence: [{ session_id: 's5', quote: '甲' }] },
261
+ { op: 'create', domain: 'general', track: 'memory', title: '乙', content: '乙事实的内容,与甲无关。', confidence: 0.9, evidence: [{ session_id: 's6', quote: '乙' }] },
262
+ ],
263
+ }
264
+ indexHook.failNext = 1
265
+ await assert.rejects(
266
+ curator.commitCuratorOutput(structuredClone(output), { job: { step: 'committing' } }),
267
+ /disk full/,
268
+ )
269
+ globalThis.__curatorCommitTest.forget = originalForget
270
+ const event = activity.listEvents({ kind: 'curate' }).filter((item) => item.level === 'error').at(-1)
271
+ assert.ok(event, 'a partial compensation must be recorded at error level')
272
+ assert.equal(event.ok, false)
273
+ assert.equal(event.detail.compensated.length, 1, 'the name whose forget succeeded is recorded as compensated')
274
+ assert.equal(event.detail.failed.length, 1, 'the name whose forget failed is recorded with its reason')
275
+ assert.match(event.detail.failed[0].error, /engine db is locked/)
276
+ assert.match(event.message, /残留孤儿行/)
277
+ })