dsh-memu-memory 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +19 -8
  3. package/README.zh.md +12 -6
  4. package/docs/contributing.md +16 -3
  5. package/docs/env-and-commands.md +8 -0
  6. package/docs/host-api.md +3 -1
  7. package/docs/memory-semantics.md +21 -2
  8. package/docs/troubleshooting.md +42 -3
  9. package/lib/activity-log.js +22 -5
  10. package/lib/backup.js +24 -2
  11. package/lib/binding-diagnostics.js +3 -3
  12. package/lib/client.js +1610 -117
  13. package/lib/consolidation-worker.js +68 -17
  14. package/lib/curator-evidence.js +131 -0
  15. package/lib/curator-policy.js +81 -0
  16. package/lib/curator.js +1299 -83
  17. package/lib/cycle.js +3 -0
  18. package/lib/embedding-download.js +39 -4
  19. package/lib/engine.js +6 -3
  20. package/lib/global-graph.js +282 -0
  21. package/lib/graph.js +190 -14
  22. package/lib/inbound.js +19 -0
  23. package/lib/index.js +33 -1
  24. package/lib/inject.js +277 -17
  25. package/lib/lexicon.js +1115 -0
  26. package/lib/local-vector-service.js +8 -6
  27. package/lib/managed-runtime.js +33 -2
  28. package/lib/memory.js +585 -76
  29. package/lib/memu.js +2 -2
  30. package/lib/migrate.js +83 -2
  31. package/lib/private-links.js +587 -0
  32. package/lib/private.js +128 -7
  33. package/lib/prompts/global-curator.md +76 -19
  34. package/lib/prompts/private-consolidation.coding.md +4 -1
  35. package/lib/prompts/private-consolidation.md +3 -1
  36. package/lib/prompts/private-consolidation.mixed.md +3 -1
  37. package/lib/prompts/private-consolidation.rp.md +3 -1
  38. package/lib/prompts.js +1 -1
  39. package/lib/rpc.js +622 -13
  40. package/lib/state.js +4 -1
  41. package/lib/text-retrieval.js +661 -0
  42. package/lib/text-similarity.js +124 -0
  43. package/lib/tools.js +230 -16
  44. package/lib/vector-migration.js +28 -3
  45. package/lib/vector-profile.js +24 -0
  46. package/lib/webui/standalone.css +17 -0
  47. package/lib/webui/standalone.js +2 -0
  48. package/package.json +3 -2
  49. package/scripts/acceptance-local.mjs +8 -5
  50. package/scripts/curator-pair-calibrate.mjs +259 -0
  51. package/scripts/lexicon-calibrate.mjs +230 -0
  52. package/scripts/lexicon-dedupe.mjs +318 -0
  53. package/scripts/replay.mjs +573 -0
  54. package/tests/absorb-review.test.mjs +353 -0
  55. package/tests/appendix-b.test.mjs +6 -2
  56. package/tests/appendix-b2.test.mjs +2 -2
  57. package/tests/client-export.test.mjs +520 -0
  58. package/tests/client-render.test.mjs +19 -3
  59. package/tests/consolidation-credential.test.mjs +16 -5
  60. package/tests/consolidation-feed.test.mjs +96 -0
  61. package/tests/consolidation-fork.test.mjs +144 -10
  62. package/tests/consolidation-snapshot.test.mjs +82 -4
  63. package/tests/curator-absorb.test.mjs +443 -0
  64. package/tests/curator-calibrate.test.mjs +173 -0
  65. package/tests/curator-clusters.test.mjs +281 -0
  66. package/tests/curator-commit.test.mjs +324 -0
  67. package/tests/curator-evidence.test.mjs +102 -0
  68. package/tests/curator-link.test.mjs +316 -0
  69. package/tests/curator-pairing.test.mjs +252 -0
  70. package/tests/curator-probe.test.mjs +15 -6
  71. package/tests/curator-review-mode.test.mjs +314 -0
  72. package/tests/disclosure.test.mjs +2 -1
  73. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  74. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  75. package/tests/fixtures/lexicon-stub.mjs +284 -0
  76. package/tests/graph-cascade.test.mjs +223 -0
  77. package/tests/graph.test.mjs +113 -0
  78. package/tests/help-page.test.mjs +415 -0
  79. package/tests/hybrid-recall.test.mjs +32 -2
  80. package/tests/inbound.test.mjs +6 -3
  81. package/tests/inject-guide.test.mjs +28 -0
  82. package/tests/inject.test.mjs +157 -5
  83. package/tests/lexicon-calibrate.test.mjs +48 -0
  84. package/tests/lexicon-dedupe.test.mjs +171 -0
  85. package/tests/lexicon-frequency.test.mjs +179 -0
  86. package/tests/lexicon-inject.test.mjs +410 -0
  87. package/tests/lexicon-perf.test.mjs +436 -0
  88. package/tests/lexicon-ui.test.mjs +547 -0
  89. package/tests/lexicon.test.mjs +922 -0
  90. package/tests/managed-memory.test.mjs +55 -0
  91. package/tests/managed-runtime.test.mjs +32 -0
  92. package/tests/private-links.test.mjs +445 -0
  93. package/tests/prompts.test.mjs +35 -0
  94. package/tests/recall-dedup.test.mjs +446 -0
  95. package/tests/sidebar-beat.test.mjs +5 -1
  96. package/tests/tool-schema.test.mjs +1 -1
  97. package/tests/tools.test.mjs +112 -3
  98. package/tests/vector-migration.test.mjs +58 -1
  99. package/tests/webui-auth.test.mjs +348 -2
  100. package/tests/webui.test.mjs +191 -2
@@ -0,0 +1,281 @@
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
+ // Candidate clusters (spec §4.4) + curator context packing (§4.5).
9
+ //
10
+ // buildCandidateClusters is pure (text-retrieval), but packCuratorIndexContext /
11
+ // packCuratorClusterContext live in curator.js, which pulls the module stack —
12
+ // so this file uses the same hermetic rig as tests/curator-commit.test.mjs:
13
+ // './memu.js' is replaced before production modules load and DSH_MEMU_HOME
14
+ // points at a temp root.
15
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-clusters-'))
16
+ process.env.DSH_MEMU_HOME = home
17
+ process.env.DSH_MEMU_MEMORY_HOME = home
18
+
19
+ globalThis.__curatorClusterTest = {
20
+ async commit() { return { ok: true } },
21
+ async forget() { return { deleted: 1, segments: 1 } },
22
+ async retrieve() { return { files: [], segments: [], resources: [], raw: null } },
23
+ async listFiles() { return { recall_files: [], resources: [], raw: null } },
24
+ }
25
+ const memuSource = 'const bridge = globalThis.__curatorClusterTest;'
26
+ + 'export const commit = (args) => bridge.commit(args), forget = (args) => bridge.forget(args),'
27
+ + 'retrieve = (args) => bridge.retrieve(args), listFiles = (args) => bridge.listFiles(args);'
28
+ const hooks = registerHooks({
29
+ resolve(specifier, context, nextResolve) {
30
+ if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(memuSource), shortCircuit: true }
31
+ return nextResolve(specifier, context)
32
+ },
33
+ })
34
+
35
+ const { buildCandidateClusters } = await import('../lib/text-retrieval.js')
36
+ const curator = await import('../lib/curator.js')
37
+
38
+ test.after(async () => {
39
+ try { hooks.unregister?.() } catch {}
40
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
41
+ })
42
+
43
+ const entryOf = (id, title, content, extra = {}) => ({
44
+ id, track: 'memory', domain: 'general', status: 'active',
45
+ title, content, updatedAt: '2026-01-01T00:00:00.000Z', ...extra,
46
+ })
47
+
48
+ const index = (entries) => ({ version: 1, entries: Object.fromEntries(entries.map((e) => [e.id, e])) })
49
+
50
+ // —— blocking ——
51
+
52
+ // 1440 distinct CJK words; each filler picks 6 with its own PRNG stream, so two
53
+ // fillers share at most a stray bigram (never enough Dice for an edge) and the
54
+ // pair under test is the only real signal in the corpus. The one-char title
55
+ // keeps both probe entries' token sets to their content words ('x' is dropped
56
+ // by tokenize) while the title bigrams keep Dice high.
57
+ const VOCAB = []
58
+ for (const first of '甲乙丙丁戊己庚辛壬癸') {
59
+ for (const second of '子丑寅卯辰巳午未申酉戌亥') {
60
+ for (const third of '東南西北中發白裏乾坤') VOCAB.push(`词${first}${second}${third}`)
61
+ }
62
+ }
63
+ function mulberry32(seed) {
64
+ let state = seed >>> 0
65
+ return () => {
66
+ state = (state + 0x6D2B79F5) | 0
67
+ let t = Math.imul(state ^ (state >>> 15), 1 | state)
68
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
69
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
70
+ }
71
+ }
72
+ function fillers(count, from = 0) {
73
+ const out = []
74
+ for (let i = from + 1; i <= from + count; i++) {
75
+ const random = mulberry32(i * 7919)
76
+ const deck = [...VOCAB.keys()]
77
+ const words = []
78
+ for (let t = 0; t < 6; t++) {
79
+ const pick = Math.floor(random() * deck.length)
80
+ words.push(VOCAB[deck.splice(pick, 1)[0]])
81
+ }
82
+ out.push(entryOf(`filler-${i}`, 'x', words.join('、') + '。'))
83
+ }
84
+ return out
85
+ }
86
+
87
+ test('N=300 exact all-pairs still cluster a high-Dice one-shared-token pair', () => {
88
+ const entries = [
89
+ entryOf('deploy-a', 'x', 'deploy cache'),
90
+ entryOf('deploy-b', 'x', 'redeploy cache'),
91
+ ...fillers(298),
92
+ ]
93
+ const { clusters } = buildCandidateClusters(index(entries))
94
+ const pair = clusters.find((c) => c.members.some((m) => m.id === 'deploy-a'))
95
+ assert.ok(pair, 'at N<=300 the exact pass scores every same-track pair')
96
+ assert.deepEqual(pair.members.map((m) => m.id).sort(), ['deploy-a', 'deploy-b'])
97
+ })
98
+
99
+ test('N=301 switches to inverted blocking: the same pair shares only 1 token and must not cluster', () => {
100
+ const entries = [
101
+ entryOf('deploy-a', '部署', 'deploy cache'),
102
+ entryOf('deploy-b', '锚点', 'redeploy cache'),
103
+ ...fillers(299),
104
+ ]
105
+ const { clusters } = buildCandidateClusters(index(entries))
106
+ assert.equal(clusters.length, 0, 'shared token count 1 < blockMinShared 2 keeps the pair out')
107
+ })
108
+
109
+ // —— all-pairs clique growth ——
110
+
111
+ // A-B ≈0.68, B-C ≈0.82, A-C ≈0.39: whichever edge seeds the cluster, the third
112
+ // member always violates the all-pairs constraint against the opposite side.
113
+ const chainIndex = () => index([
114
+ entryOf('A', '站会要点', '站会先过昨日进展,再排今日任务,最后收集阻塞。'),
115
+ entryOf('B', '站会要点', '站会先过昨日进展,然后直接进入深挖环节,限时十分钟。'),
116
+ entryOf('C', '站会要点', '站会先过昨日进展,然后直接进入深挖环节。'),
117
+ ])
118
+
119
+ test('all-pairs constraint: an A-B-C chain never becomes one cluster', () => {
120
+ const { clusters } = buildCandidateClusters(chainIndex())
121
+ assert.ok(clusters.length >= 1, 'the two strong edges must form at least one cluster')
122
+ for (const cluster of clusters) {
123
+ const ids = cluster.members.map((m) => m.id)
124
+ assert.ok(!ids.includes('A') || !ids.includes('C'), `A and C must never cohabit: ${ids.join(',')}`)
125
+ }
126
+ // every kept edge honours the floor pair-wise, not just transitively
127
+ for (const cluster of clusters) {
128
+ for (const [key, score] of Object.entries(cluster.pair_scores)) {
129
+ assert.ok(score >= 0.55, `in-cluster pair ${key} below floor: ${score}`)
130
+ }
131
+ }
132
+ })
133
+
134
+ test('all-pairs constraint: deterministic across runs', () => {
135
+ assert.deepEqual(buildCandidateClusters(chainIndex()), buildCandidateClusters(chainIndex()))
136
+ })
137
+
138
+ // —— medoid ——
139
+
140
+ test('medoid is the member with the highest average pairwise score; ties break updatedAt desc then id asc', () => {
141
+ const entries = [
142
+ entryOf('t1', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源并写回缓存。', { updatedAt: '2026-01-01T00:00:00.000Z' }),
143
+ entryOf('t2', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源并写回缓存集合。', { updatedAt: '2026-01-02T00:00:00.000Z' }),
144
+ entryOf('t3', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源写回缓存集合。', { updatedAt: '2026-01-03T00:00:00.000Z' }),
145
+ ]
146
+ const { clusters } = buildCandidateClusters(index(entries))
147
+ assert.equal(clusters.length, 1)
148
+ const cluster = clusters[0]
149
+ assert.equal(cluster.members.length, 3)
150
+ // medoid: recompute averages from the published upper-triangle pair scores
151
+ const scores = cluster.pair_scores
152
+ const pairsOf = (id) => Object.entries(scores).filter(([key]) => key.includes(id))
153
+ const avg = (id) => pairsOf(id).reduce((sum, [, value]) => sum + value, 0) / pairsOf(id).length
154
+ const best = cluster.members.map((m) => m.id).sort((x, y) => avg(y) - avg(x) || String(y).localeCompare(String(x)))[0]
155
+ assert.equal(cluster.medoid_id, best)
156
+ assert.ok(cluster.avg_score >= 0.82, `a tight triple must average in the absorb band, got ${cluster.avg_score}`)
157
+ assert.equal(cluster.suggested_op, 'absorb', '3 members, same domain, avg >=0.82')
158
+ assert.equal(cluster.cluster_id, 'cl-1')
159
+ assert.equal(Object.keys(scores).length, 3, 'upper triangle only')
160
+ })
161
+
162
+ test('a 2-member cluster never suggests absorb even at duplicate-level similarity', () => {
163
+ const { clusters } = buildCandidateClusters(index([
164
+ entryOf('p1', '发布清单', '发版前按清单逐项确认版本号、changelog 和回归标记。'),
165
+ entryOf('p2', '发布清单', '发版前按清单逐项确认版本号、changelog与回归标记。'),
166
+ ]))
167
+ assert.equal(clusters.length, 1)
168
+ assert.equal(clusters[0].members.length, 2)
169
+ assert.equal(clusters[0].suggested_op, 'link')
170
+ })
171
+
172
+ test('medoid average below the pair floor drops the whole cluster (defensive, via lowered minPairScore)', () => {
173
+ const { clusters } = buildCandidateClusters(index([
174
+ entryOf('w1', '杂项', '条目甲的内容讲预算审批流程。'),
175
+ entryOf('w2', '杂项', '条目甲的内容讲预算审批与报销流程。'),
176
+ entryOf('w3', '杂项', '条目甲的内容讲报销与发票流程。'),
177
+ ]), { minPairScore: 0.3 })
178
+ for (const cluster of clusters) {
179
+ assert.ok(cluster.avg_score >= 0.55 || cluster.medoid_avg_dropped !== true)
180
+ }
181
+ // with the floor at 0.3 edges form, but a medoid averaging <0.55 must be dropped
182
+ const weak = clusters.filter((c) => c.avg_score < 0.55)
183
+ assert.equal(weak.length, 0, 'no cluster below the 0.55 output floor survives')
184
+ })
185
+
186
+ test('clusters never span tracks', () => {
187
+ const { clusters } = buildCandidateClusters(index([
188
+ entryOf('m1', '发布清单', '发版前按清单逐项确认版本号、changelog 和回归标记。', { track: 'memory' }),
189
+ entryOf('s1', '发布清单', '发版前按清单逐项确认版本号、changelog 与回归标记。', { track: 'skill' }),
190
+ ]))
191
+ assert.equal(clusters.length, 0)
192
+ })
193
+
194
+ test('>12 clusters are truncated by avg_score desc', () => {
195
+ const entries = []
196
+ for (let i = 1; i <= 15; i++) {
197
+ entries.push(entryOf(`pair-${i}-a`, `主题${i}`, `主题甲${i}:独有词${i}${i}的内容陈述。`))
198
+ entries.push(entryOf(`pair-${i}-b`, `主题${i}`, `主题甲${i}:独有词${i}${i}的内容陈述变体。`))
199
+ }
200
+ const { clusters } = buildCandidateClusters(index(entries))
201
+ assert.equal(clusters.length, 12)
202
+ const avgs = clusters.map((c) => c.avg_score)
203
+ assert.deepEqual([...avgs].sort((a, b) => b - a), avgs, 'output stays avg_score-descending')
204
+ for (const cluster of clusters) assert.equal(cluster.suggested_op, 'link')
205
+ })
206
+
207
+ test('recentLimit cuts the pool to the most recent entries and flags truncation', () => {
208
+ const partner = entryOf('partner-old', '旧站会', '站会先过昨日进展,再排今日任务,最后收集阻塞。', { updatedAt: '2025-06-01T00:00:00.000Z' })
209
+ const seed = entryOf('seed-new', '旧站会', '站会先过昨日进展,再排今日任务,最后收集阻塞项。', { updatedAt: '2026-06-01T00:00:00.000Z' })
210
+ const entries = [partner, seed, ...fillers(58)]
211
+ const result = buildCandidateClusters(index(entries), { recentLimit: 30 })
212
+ assert.equal(result.truncated, true)
213
+ assert.equal(result.scanned, 30)
214
+ assert.equal(result.clusters.length, 0, 'the older partner fell outside the recent window')
215
+ })
216
+
217
+ // —— §4.5 context packing ——
218
+
219
+ test('packCuratorIndexContext: deterministic, budgeted, BM25-ordered, archived out / pending in', () => {
220
+ const entries = {
221
+ 'gmem-hot': entryOf('gmem-hot', '部署窗口', '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。'),
222
+ 'gmem-cold': entryOf('gmem-cold', '摄影器材', '用户的相机是全画幅微单,常用 35mm 定焦。'),
223
+ 'gmem-pending': entryOf('gmem-pending', '部署告警', '部署失败要发告警到值班群。', { status: 'pending_review', updatedAt: '2026-02-01T00:00:00.000Z' }),
224
+ 'gmem-archived': entryOf('gmem-archived', '旧部署窗口', '旧窗口已废弃不用。', { status: 'archived' }),
225
+ 'gmem-linked': entryOf('gmem-linked', '回滚流程', '窗口结束前完成回滚。', { links: [{ id: 'lnk_1', to: 'gmem-hot', kind: 'related' }, { id: 'lnk_2', to: 'gmem-x', kind: 'refines' }] }),
226
+ }
227
+ const sessionText = '这次会话都在讨论部署窗口与灰度发布的安排'
228
+ const pack1 = curator.packCuratorIndexContext({ index: { entries }, sessionText })
229
+ const pack2 = curator.packCuratorIndexContext({ index: { entries }, sessionText })
230
+ assert.deepEqual(pack1, pack2)
231
+ const ids = pack1.entries.map((e) => e.id)
232
+ assert.ok(ids.includes('gmem-hot'), 'the BM25-best entry ranks first')
233
+ assert.equal(ids[0], 'gmem-hot')
234
+ assert.ok(ids.includes('gmem-pending'), 'pending_review stays in the pool')
235
+ assert.ok(!ids.includes('gmem-archived'), 'archived entries are filtered')
236
+ const linked = pack1.entries.find((e) => e.id === 'gmem-linked')
237
+ assert.equal(linked.linkCount, 2)
238
+ assert.deepEqual(linked.linkedTo, [
239
+ { to: 'gmem-hot', kind: 'related' },
240
+ { to: 'gmem-x', kind: 'refines' },
241
+ ], 'linkedTo carries the first 5 links without notes')
242
+ const hot = pack1.entries.find((e) => e.id === 'gmem-hot')
243
+ assert.ok(hot.content.includes('灰度发布'), 'content survives truncation at 900/600 chars')
244
+ assert.ok(pack1.tokens <= 4000, `index budget respected, got ${pack1.tokens}`)
245
+ })
246
+
247
+ test('packCuratorIndexContext: a tiny budget drops low-scoring entries instead of truncating JSON', () => {
248
+ const entries = {
249
+ 'gmem-a': entryOf('gmem-a', '部署窗口', '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。'),
250
+ 'gmem-b': entryOf('gmem-b', '摄影器材', '用户的相机是全画幅微单,常用 35mm 定焦。'),
251
+ 'gmem-c': entryOf('gmem-c', '咖啡偏好', '用户下午的咖啡只喝美式,上午喝拿铁。'),
252
+ }
253
+ const packed = curator.packCuratorIndexContext({ index: { entries }, sessionText: '部署窗口 灰度发布', tokenBudget: 120 })
254
+ assert.ok(packed.entries.length < 3, `a 120-token budget must drop entries, kept ${packed.entries.length}`)
255
+ assert.equal(packed.entries[0].id, 'gmem-a', 'the best-scoring entry survives a tiny budget')
256
+ assert.equal(packed.omitted, 3 - packed.entries.length)
257
+ })
258
+
259
+ test('packCuratorClusterContext: budget 2000 keeps whole clusters, drops the lowest scores', () => {
260
+ const clusterResult = buildCandidateClusters(index([
261
+ entryOf('k1', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源并写回缓存。'),
262
+ entryOf('k2', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源并写回缓存集合。'),
263
+ entryOf('k3', '缓存策略', 'Redis 缓存统一设置 30 秒过期,过期回源写回缓存集合。'),
264
+ entryOf('j1', '发布清单', '发版前按清单逐项确认版本号、changelog 和回归标记。'),
265
+ entryOf('j2', '发布清单', '发版前按清单逐项确认版本号、changelog与回归标记。'),
266
+ ]))
267
+ assert.equal(clusterResult.clusters.length, 2)
268
+ const packed = curator.packCuratorClusterContext(clusterResult)
269
+ assert.deepEqual(packed.clusters.map((c) => c.cluster_id), clusterResult.clusters.map((c) => c.cluster_id))
270
+ assert.ok(packed.tokens <= 2000)
271
+ assert.equal(packed.truncated, false)
272
+ const rendered = packed.clusters[0]
273
+ assert.ok(rendered.members[0].content.length <= 301, 'cluster member content is cut to 300 chars')
274
+ for (const score of Object.values(rendered.pair_scores)) {
275
+ assert.equal(Math.round(score * 100) / 100, score, 'pair_scores keep 2 decimals')
276
+ }
277
+ const squeezed = curator.packCuratorClusterContext(clusterResult, { tokenBudget: 100 })
278
+ assert.equal(squeezed.clusters.length, 1, 'a 100-token budget keeps only the best cluster')
279
+ assert.equal(squeezed.omitted, 1)
280
+ assert.equal(squeezed.truncated, true)
281
+ })
@@ -0,0 +1,324 @@
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
+ })
278
+
279
+ // Package A (spec §4.10): the risk score is an audit trail, so it must survive
280
+ // the two paths that rewrite an entry after the decision was made: the held
281
+ // clone (target changed mid-run) and the activity-log summary.
282
+ test('a held clone from a target-changed conflict keeps the decision risk score for audit', async () => {
283
+ seedGlobalIndex({ [TARGET]: TARGET_ENTRY })
284
+ await activateVectorGeneration()
285
+ // mergeOutput is a clean risky-rewrite candidate: merge 0.20 + single evidence
286
+ // 0.10 → risk 0.30 < 0.70, so auto commits it and the concurrent writer turns
287
+ // the committed row into a held clone.
288
+ backend.onCommit = async () => {
289
+ state.updateGlobalIndex((fresh) => {
290
+ fresh.entries[TARGET] = { ...fresh.entries[TARGET], content: '内容已被并发修改', updatedAt: '2026-02-02T00:00:00.000Z' }
291
+ })
292
+ }
293
+ await curator.commitCuratorOutput(structuredClone(mergeOutput), { job: { step: 'committing' } })
294
+ backend.onCommit = null
295
+ const index = state.loadGlobalIndex()
296
+ const heldId = Object.keys(index.entries).find((id) => index.entries[id]?.reviewReason === 'target changed while curator was running')
297
+ assert.ok(heldId, 'the target-changed merge must be held for review')
298
+ const held = index.entries[heldId]
299
+ assert.equal(held.reviewRisk, 0.3, 'merge 0.20 + single evidence 0.10 survives the clone')
300
+ assert.deepEqual(held.reviewRiskReasons, ['merge', 'single evidence'])
301
+ assert.equal(held.status, 'pending_review')
302
+ })
303
+
304
+ test('the curate activity record carries the batch review-risk summary', async () => {
305
+ seedGlobalIndex({})
306
+ await activateVectorGeneration()
307
+ const output = {
308
+ memories: [
309
+ { op: 'create', domain: 'general', track: 'memory', title: '低风险条目', content: '低风险条目的内容,与另一条无关。', confidence: 0.9, evidence: [{ session_id: 's7', quote: '低风险条目的原话记录' }] },
310
+ { op: 'create', domain: 'general', track: 'memory', title: '高风险条目', content: '高风险条目的内容,模型自己标了待复核。', confidence: 0.9, needs_review: true, evidence: [{ session_id: 's8', quote: '高风险条目的原话记录' }] },
311
+ ],
312
+ }
313
+ const result = await curator.commitCuratorOutput(structuredClone(output), { job: { step: 'committing' } })
314
+ assert.equal(result.created, 2)
315
+ assert.deepEqual(result.reviewRisk, { max: 0.8, flagged: 1, reasons: { 'needs_review': 1, 'single evidence': 2 } })
316
+ // listEvents is newest-first; this run is the only successful curation with
317
+ // exactly two created entries, so match on that instead of list position.
318
+ const event = activity.listEvents({ kind: 'curate' })
319
+ .find((item) => item.ok === true && item.detail?.reviewRisk && item.detail?.created === 2)
320
+ assert.ok(event, 'the successful curation must be recorded')
321
+ assert.equal(event.detail.reviewRisk.max, 0.8)
322
+ assert.equal(event.detail.reviewRisk.flagged, 1)
323
+ assert.equal(event.detail.reviewRisk.reasons.needs_review, 1)
324
+ })