dsh-memu-memory 1.2.1 → 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 (87) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +12 -5
  3. package/README.zh.md +4 -2
  4. package/docs/contributing.md +16 -3
  5. package/docs/host-api.md +3 -1
  6. package/docs/memory-semantics.md +21 -2
  7. package/docs/troubleshooting.md +42 -4
  8. package/lib/backup.js +24 -2
  9. package/lib/binding-diagnostics.js +2 -2
  10. package/lib/client.js +1361 -146
  11. package/lib/consolidation-worker.js +30 -8
  12. package/lib/curator-evidence.js +131 -0
  13. package/lib/curator-policy.js +81 -0
  14. package/lib/curator.js +1252 -91
  15. package/lib/cycle.js +3 -0
  16. package/lib/embedding-download.js +39 -4
  17. package/lib/engine.js +6 -3
  18. package/lib/global-graph.js +282 -0
  19. package/lib/graph.js +51 -9
  20. package/lib/inbound.js +19 -0
  21. package/lib/inject.js +277 -17
  22. package/lib/lexicon.js +1115 -0
  23. package/lib/local-vector-service.js +8 -6
  24. package/lib/managed-runtime.js +33 -2
  25. package/lib/memory.js +282 -17
  26. package/lib/memu.js +2 -2
  27. package/lib/migrate.js +83 -2
  28. package/lib/private-links.js +587 -0
  29. package/lib/private.js +32 -3
  30. package/lib/prompts/global-curator.md +76 -19
  31. package/lib/prompts/private-consolidation.coding.md +4 -1
  32. package/lib/prompts/private-consolidation.md +3 -1
  33. package/lib/prompts/private-consolidation.mixed.md +3 -1
  34. package/lib/prompts/private-consolidation.rp.md +3 -1
  35. package/lib/prompts.js +1 -1
  36. package/lib/rpc.js +577 -9
  37. package/lib/state.js +4 -1
  38. package/lib/text-retrieval.js +661 -0
  39. package/lib/text-similarity.js +36 -18
  40. package/lib/tools.js +168 -10
  41. package/lib/webui/standalone.css +17 -0
  42. package/lib/webui/standalone.js +2 -0
  43. package/package.json +1 -1
  44. package/scripts/acceptance-local.mjs +8 -5
  45. package/scripts/curator-pair-calibrate.mjs +259 -0
  46. package/scripts/lexicon-calibrate.mjs +230 -0
  47. package/scripts/lexicon-dedupe.mjs +318 -0
  48. package/tests/absorb-review.test.mjs +353 -0
  49. package/tests/appendix-b2.test.mjs +2 -2
  50. package/tests/client-export.test.mjs +520 -0
  51. package/tests/client-render.test.mjs +19 -3
  52. package/tests/consolidation-fork.test.mjs +14 -12
  53. package/tests/consolidation-snapshot.test.mjs +39 -0
  54. package/tests/curator-absorb.test.mjs +443 -0
  55. package/tests/curator-calibrate.test.mjs +173 -0
  56. package/tests/curator-clusters.test.mjs +281 -0
  57. package/tests/curator-commit.test.mjs +52 -5
  58. package/tests/curator-evidence.test.mjs +102 -0
  59. package/tests/curator-link.test.mjs +316 -0
  60. package/tests/curator-pairing.test.mjs +252 -0
  61. package/tests/curator-probe.test.mjs +15 -6
  62. package/tests/curator-review-mode.test.mjs +314 -0
  63. package/tests/disclosure.test.mjs +2 -1
  64. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  65. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  66. package/tests/fixtures/lexicon-stub.mjs +284 -0
  67. package/tests/graph-cascade.test.mjs +35 -2
  68. package/tests/graph.test.mjs +96 -0
  69. package/tests/help-page.test.mjs +415 -0
  70. package/tests/inbound.test.mjs +6 -3
  71. package/tests/inject-guide.test.mjs +28 -0
  72. package/tests/inject.test.mjs +157 -5
  73. package/tests/lexicon-calibrate.test.mjs +48 -0
  74. package/tests/lexicon-dedupe.test.mjs +171 -0
  75. package/tests/lexicon-frequency.test.mjs +179 -0
  76. package/tests/lexicon-inject.test.mjs +410 -0
  77. package/tests/lexicon-perf.test.mjs +436 -0
  78. package/tests/lexicon-ui.test.mjs +547 -0
  79. package/tests/lexicon.test.mjs +922 -0
  80. package/tests/managed-runtime.test.mjs +32 -0
  81. package/tests/private-links.test.mjs +445 -0
  82. package/tests/prompts.test.mjs +35 -0
  83. package/tests/recall-dedup.test.mjs +33 -4
  84. package/tests/tool-schema.test.mjs +1 -1
  85. package/tests/tools.test.mjs +112 -3
  86. package/tests/webui-auth.test.mjs +348 -2
  87. package/tests/webui.test.mjs +188 -0
@@ -370,7 +370,8 @@ test('a large transcript that was never consolidated does fork, because the back
370
370
  // probe 曾据此判 unavailable(worker-tools-ptc-only),PTC 会话永远不被整理。宿主的
371
371
  // PTC executor 明确 "only run_code is directly callable",其余工具走 run_code 程序里的
372
372
  // SDK 绑定(tools.schemas(agent, toolSet) 枚举可见面)—— header 名单在 PTC 会话里
373
- // 不承载"能不能写记忆"。修复后:PTC 不构成否证 + fork 时把读写四件挂进 worker 自己的
373
+ // 不承载"能不能写记忆"。修复后:PTC 不构成否证 + fork 时把 worker 的读写面
374
+ // (WORKER_WRITE_TOOLS + WORKER_READ_TOOLS)挂进 worker 自己的
374
375
  // scope(注册表按 scope 链取可见面,不扩散到其它会话)。
375
376
 
376
377
  test('a PTC session (run_code only header) is consolidated and the worker gets write tools on its own scope', async () => {
@@ -397,15 +398,16 @@ test('a PTC session (run_code only header) is consolidated and the worker gets w
397
398
  assert.equal(result.ok, true, 'a run_code header must not block consolidation: ' + String(result.error || ''))
398
399
  assert.equal(result.settled, true)
399
400
  assert.equal(ctx.rec.created.length, 1, 'the fork is created for the PTC conversation')
400
- // 只挂读写四件;archive/delete/unpin 这些破坏性工具不得进 worker 的工具面。
401
- assert.deepEqual([...registered].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
401
+ // 只挂 worker 的读写面(记忆读写 + 关联裁决/候选 + 词典检索/写入);
402
+ // archive/delete/unpin 这些破坏性工具不得进 worker 的工具面。
403
+ assert.deepEqual([...registered].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
402
404
  // 整理结束(finally)必须卸载挂载,不能给这条一次性 worker 留常驻注册。
403
- assert.deepEqual([...disposals].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
405
+ assert.deepEqual([...disposals].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
404
406
  // 全量挂载的活动元数据:attached=实际子集(此处=完整 wanted)、requested=完整 wanted、
405
407
  // partial=false。三件一起钉住"成功路径不再含糊"。
406
408
  const started = startedEntryFor(ctx, 'fork-ptc')
407
- assert.deepEqual([...started.detail.workerToolsAttached].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
408
- assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
409
+ assert.deepEqual([...started.detail.workerToolsAttached].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
410
+ assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
409
411
  assert.equal(started.detail.workerToolsPartial, false, 'a full mount is not partial')
410
412
  const followup = ctx.rec.forkFollowups[0]?.message
411
413
  const brief = Array.isArray(followup?.content)
@@ -426,7 +428,7 @@ function startedEntryFor(ctx, sessionId) {
426
428
 
427
429
  test('a host registry refusing one write tool leaves a structured partial mount in the activity log', async () => {
428
430
  // 宿主注册表对单件工具说"不"(重名、scope 锁定)是真实的部分挂载路径:registerTools
429
- // 逐件 fail-open,其余三件照常挂上。活动日志必须记实际子集 + requested + partial,
431
+ // 逐件 fail-open,其余工具照常挂上。活动日志必须记实际子集 + requested + partial,
430
432
  // 而不是把 wanted 全量误报成已挂载(旧口径的缺陷)。
431
433
  const registered = []
432
434
  const disposals = []
@@ -447,14 +449,14 @@ test('a host registry refusing one write tool leaves a structured partial mount
447
449
  const result = await requestPrivateConsolidation(ctx, 'fork-ptc-partial')
448
450
  assert.equal(result.ok, true, String(result.error || ''))
449
451
  assert.equal(result.settled, true)
450
- assert.deepEqual([...registered].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_update'],
452
+ assert.deepEqual([...registered].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_update'],
451
453
  'exactly the tools that actually registered are mounted')
452
- assert.deepEqual([...disposals].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_update'],
454
+ assert.deepEqual([...disposals].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_update'],
453
455
  'dispose unmounts exactly what was mounted')
454
456
  const started = startedEntryFor(ctx, 'fork-ptc-partial')
455
- assert.deepEqual([...started.detail.workerToolsAttached].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_update'],
457
+ assert.deepEqual([...started.detail.workerToolsAttached].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_update'],
456
458
  'the log records the actual subset, not the full wanted list')
457
- assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
459
+ assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
458
460
  assert.equal(started.detail.workerToolsPartial, true)
459
461
  // 挂载为部分子集时任务书仍带 PTC 调用形态:worker 知道写记忆走 run_code 的 SDK 绑定。
460
462
  const followup = ctx.rec.forkFollowups[0]?.message
@@ -475,7 +477,7 @@ test('a fork context without a tools service records a null mount, the request a
475
477
  assert.equal(result.ok, true, String(result.error || ''))
476
478
  const started = startedEntryFor(ctx, 'fork-ptc-notools')
477
479
  assert.equal(started.detail.workerToolsAttached, null, 'no mount completed, so the log must not claim one')
478
- assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
480
+ assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_lexicon_propose', 'memu_lexicon_search', 'memu_private_link', 'memu_private_link_suggest', 'memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
479
481
  assert.equal(started.detail.workerToolsPartial, true)
480
482
  })
481
483
 
@@ -262,6 +262,45 @@ test('a header without memory tools is unavailable rather than a silent wasted f
262
262
  assert.equal(probe.reasonCode, 'worker-tools-missing')
263
263
  })
264
264
 
265
+ test('auxiliary link and lexicon tools alone do not prove the worker can write memory', () => {
266
+ // 判据只看 CORE_WRITE_TOOLS:link / link_suggest / lexicon_propose 是辅助面,
267
+ // 一份只列它们、没有 remember/update 的名单仍然是"不能写记忆",不能据此 fork。
268
+ const events = [{
269
+ seq: 0,
270
+ type: 'request/header',
271
+ data: { header: { config: { provider: 'p', model: 'm' }, tools: [
272
+ { name: 'memu_private_link' },
273
+ { name: 'memu_private_link_suggest' },
274
+ { name: 'memu_lexicon_propose' },
275
+ { name: 'memu_lexicon_search' },
276
+ ] } },
277
+ }]
278
+ const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
279
+ assert.equal(probe.ok, false)
280
+ assert.equal(probe.reasonCode, 'worker-tools-missing')
281
+ assert.equal(probe.hasWrite, false)
282
+ })
283
+
284
+ test('the brief teaches the suggest-then-adjudicate loop and lexicon extraction', () => {
285
+ const brief = buildWorkerBrief({
286
+ snapshot: { fromSeq: 0, throughSeq: 5, eventCount: 6, hash: 'brief01' },
287
+ probe: { tools: ['memu_private_remember', 'memu_private_update', 'memu_private_link_suggest', 'memu_lexicon_propose'] },
288
+ contract: 'BRIEF-CONTRACT',
289
+ })
290
+ assert.match(brief, /memu_private_link_suggest\(\{ids:\[\.\.\.\]\}\)/, 'the call shape is spelled out')
291
+ assert.match(brief, /已经自动连好/, 'high-confidence links are applied before the worker sees candidates')
292
+ assert.match(brief, /每轮最多确认 5 条/)
293
+ assert.match(brief, /noop/)
294
+ assert.match(brief, /conflict/)
295
+ assert.match(brief, /memu_lexicon_search/)
296
+ assert.match(brief, /memu_lexicon_propose/)
297
+ assert.match(brief, /term 是索引键/)
298
+ assert.match(brief, /sensitive trigger/)
299
+ assert.match(brief, /tools-unavailable/, 'the tools-unavailable contract survives')
300
+ assert.match(brief, /没有回执视为本次整理失败/, 'the receipt requirement survives')
301
+ assert.match(brief, /BRIEF-CONTRACT/)
302
+ })
303
+
265
304
  test('a PTC-only turn is not a refusal: the probe reports ptc and the SDK surface is checked at fork time', () => {
266
305
  // 2026-10-31 口径变更:PTC 模式下 header 只列 transport(这里 ptc_exec,真实宿主为
267
306
  // run_code),其余工具走 run_code 程序里的 SDK 绑定 —— header 名单不承载"能不能写",
@@ -0,0 +1,443 @@
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
+ // Package F2 (spec §6.7 / §4.7 / §4.11): commitCuratorPlans — the absorb commit.
9
+ //
10
+ // Covered here:
11
+ // 1. three entries compose into one merged entry; sources are archived, never deleted;
12
+ // 2. the model's target is only provenance — the computed medoid becomes
13
+ // representativeId when it differs;
14
+ // 3. pending_review / superseded members are demoted, never absorbed;
15
+ // 4. coverage below the auto band lands as a pendingAbsorb proposal (index only);
16
+ // 5. the same plan twice is idempotent through absorbOpId;
17
+ // 6. a concurrent source change holds a pending clone, does not land the merge
18
+ // active, and best-effort forgets the committed vector row;
19
+ // 7. links pointing into the cluster are rewired to the merged id and the
20
+ // pre-absorption state is captured in absorbSnapshot.
21
+ // 8. composeAbsorbContent unit behaviour: abstractive vs extractive, sentence
22
+ // dedup, MMR truncation marks dropped key sentences.
23
+ //
24
+ // Same hermetic pattern as tests/curator-commit.test.mjs: './memu.js' is
25
+ // replaced before production modules load; everything else is the real module
26
+ // stack against a temp DSH_MEMU_HOME. Default settings keep the vector route
27
+ // lexical except where a test activates a managed generation explicitly.
28
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-curator-absorb-'))
29
+ process.env.DSH_MEMU_HOME = home
30
+ process.env.DSH_MEMU_MEMORY_HOME = home
31
+
32
+ const backend = {
33
+ commits: [],
34
+ forgets: [],
35
+ onCommit: null,
36
+ }
37
+ globalThis.__curatorAbsorbTest = {
38
+ async commit(args) {
39
+ const names = (args.payload?.recall_files || []).map((file) => file.name)
40
+ backend.commits.push({ names, route: args.route, db: args.db })
41
+ if (backend.onCommit) await backend.onCommit()
42
+ return { ok: true }
43
+ },
44
+ async forget(args) {
45
+ backend.forgets.push({ name: args.name, db: args.db })
46
+ return { deleted: 1, segments: 1 }
47
+ },
48
+ async retrieve() { return { files: [], segments: [], resources: [], raw: null } },
49
+ async listFiles() { return { recall_files: [], resources: [], raw: null } },
50
+ }
51
+
52
+ const memuSource = 'const bridge = globalThis.__curatorAbsorbTest;'
53
+ + 'export const commit = (args) => bridge.commit(args), forget = (args) => bridge.forget(args),'
54
+ + 'retrieve = (args) => bridge.retrieve(args), listFiles = (args) => bridge.listFiles(args);'
55
+ const hooks = registerHooks({
56
+ resolve(specifier, context, nextResolve) {
57
+ if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(memuSource), shortCircuit: true }
58
+ return nextResolve(specifier, context)
59
+ },
60
+ })
61
+
62
+ const curator = await import('../lib/curator.js')
63
+ const state = await import('../lib/state.js')
64
+ const activity = await import('../lib/activity-log.js')
65
+ const vm = await import('../lib/vector-migration.js')
66
+ const vp = await import('../lib/vector-profile.js')
67
+ const catalog = await import('../lib/embedding-catalog.js')
68
+
69
+ const BGE = 'bge-small-zh-v1.5-int8'
70
+ const baseEntry = catalog.getEmbeddingModel(BGE)
71
+ const altEntry = JSON.parse(JSON.stringify(baseEntry))
72
+ altEntry.id = 'bge-small-zh-v1.5-int8-alt'
73
+ altEntry.displayName = 'BGE alternative revision (curator absorb fixture)'
74
+ altEntry.sources.modelscope.revision = 'c'.repeat(40)
75
+ altEntry.profileFingerprint = catalog.profileFingerprint(altEntry)
76
+ const models = { [BGE]: baseEntry, [altEntry.id]: altEntry }
77
+ vp.setCatalogEntryResolver((id) => models[id] ?? null)
78
+
79
+ state.updateSettings({ memoryBackend: 'api', vectorSource: 'local' })
80
+
81
+ test.after(async () => {
82
+ try { hooks.unregister?.() } catch {}
83
+ vp.resetCatalogEntryResolver()
84
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
85
+ })
86
+
87
+ function seedGlobalIndex(entries = {}) {
88
+ state.saveGlobalIndex({ version: 1, entries: structuredClone(entries) })
89
+ }
90
+
91
+ async function activateVectorGeneration() {
92
+ const dbs = new Map()
93
+ const rowsOf = (db) => {
94
+ if (!dbs.has(db)) dbs.set(db, new Map())
95
+ return dbs.get(db)
96
+ }
97
+ const migrator = vm.createVectorMigrator({
98
+ async listLibraries() { return [] },
99
+ loadIndex(libraryId) {
100
+ return libraryId ? state.loadPrivateIndex(libraryId) : state.loadGlobalIndex()
101
+ },
102
+ async commit({ db, item }) { rowsOf(db).set(item.id, item.text) },
103
+ async forget({ db, itemId }) { rowsOf(db).delete(itemId) },
104
+ async listFiles({ db }) { return [...rowsOf(db).keys()] },
105
+ async smoke() { return { ok: true, checks: ['dimension=512'] } },
106
+ })
107
+ const switched = await migrator.switchProfile({ profile: BGE })
108
+ assert.ok(['switched', 'already-active'].includes(switched.status), 'an active generation must be available')
109
+ return { migrator, generation: switched.generation }
110
+ }
111
+
112
+ const CORE = '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。'
113
+ const X = '全量发布必须在监控确认通过之后才能执行。'
114
+ const Y = '灰度发布按服务分组逐步放量,异常时立即回滚。'
115
+ const AT = '2026-01-01T00:00:00.000Z'
116
+
117
+ function baseEntryOf(id, title, content, extra = {}) {
118
+ return {
119
+ track: 'memory', domain: 'general', title, content,
120
+ confidence: 0.9, status: 'active',
121
+ createdAt: AT, updatedAt: AT,
122
+ evidence: [{ sessionId: 's0', quote: '部署窗口只在周四' }],
123
+ ...extra,
124
+ }
125
+ }
126
+
127
+ const A = 'gmem-absorb-a'
128
+ const B = 'gmem-absorb-b'
129
+ const C = 'gmem-absorb-c'
130
+
131
+ function absorbEvidence(ids) {
132
+ return ids.map((id, index) => ({ session_id: `s-absorb-${index}`, quote: `来源 ${id} 的落地引用内容`, source_id: id }))
133
+ }
134
+
135
+ // —— composeAbsorbContent 单元行为(spec §4.7)——
136
+
137
+ test('composeAbsorbContent: a well-covering proposed draft becomes abstractive, a poor one falls back to extractive', () => {
138
+ const target = { title: 't', content: CORE + X }
139
+ const source = { title: 's', content: CORE + Y }
140
+ const good = CORE + X + Y
141
+ const composed = curator.composeAbsorbContent({ target, sources: [source], proposed: good })
142
+ assert.equal(composed.mode, 'abstractive')
143
+ assert.equal(composed.content, good)
144
+ assert.ok(composed.coverage >= 0.75)
145
+
146
+ const poor = curator.composeAbsorbContent({ target, sources: [source], proposed: '完全无关的一句话,跟部署窗口没有关系。' })
147
+ assert.equal(poor.mode, 'extractive')
148
+ // extractive union keeps every unique sentence, target first.
149
+ assert.ok(poor.content.includes(CORE))
150
+ assert.ok(poor.content.includes(X))
151
+ assert.ok(poor.content.includes(Y))
152
+ assert.equal(poor.truncated, false)
153
+ })
154
+
155
+ test('composeAbsorbContent: duplicate sentences are dropped and MMR truncation marks dropped key sentences', () => {
156
+ const target = { title: 't', content: CORE + ' ' + X }
157
+ const source = { title: 's', content: CORE + ' ' + Y + ' ' + '补充说明一。补充说明二。补充说明三。补充说明四。补充说明五。' }
158
+ // maxChars forces MMR; the tiny budget cannot keep the target's second
159
+ // sentence, which is a key sentence (all target sentences are key).
160
+ const composed = curator.composeAbsorbContent({ target, sources: [source], maxChars: CORE.length + 2 })
161
+ assert.equal(composed.mode, 'extractive')
162
+ assert.ok(composed.content.length <= CORE.length + 2)
163
+ assert.equal(composed.truncated, true)
164
+ assert.ok(composed.omitted.length > 0, 'dropped key sentences must be reported')
165
+ })
166
+
167
+ // —— commitCuratorPlans:3 条合成 1 条 ——
168
+
169
+ test('an absorb plan merges three entries into one; sources are archived with bodies kept, never deleted', async () => {
170
+ seedGlobalIndex({
171
+ [A]: baseEntryOf(A, '部署窗口约定', CORE + X),
172
+ [B]: baseEntryOf(B, '部署窗口细节', CORE + Y, { confidence: 0.85 }),
173
+ [C]: baseEntryOf(C, '部署窗口约束', CORE + '窗口外禁止任何生产变更。', { confidence: 0.8 }),
174
+ })
175
+ const output = {
176
+ memories: [{
177
+ op: 'absorb', target_id: A, source_ids: [B, C],
178
+ title: '部署窗口(模型给的标题,应被 target 标题压过)',
179
+ content: '',
180
+ confidence: 0.9,
181
+ evidence: absorbEvidence([A, B, C]),
182
+ }],
183
+ }
184
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
185
+ assert.equal(result.absorbed, 1)
186
+ assert.equal(result.pending, 0)
187
+ assert.equal(result.skipped.length, 0)
188
+
189
+ const index = state.loadGlobalIndex()
190
+ const mergedId = Object.keys(index.entries).find((id) => index.entries[id]?.absorbedFrom)
191
+ assert.ok(mergedId, 'the merged entry must be in the index')
192
+ const merged = index.entries[mergedId]
193
+ assert.equal(merged.status, 'active')
194
+ assert.deepEqual(merged.absorbedFrom, [A, B, C].sort(), 'absorbedFrom is sorted target+sources')
195
+ assert.match(merged.absorbOpId, /^[0-9a-f]{16}$/)
196
+ assert.ok(merged.representativeId, 'the merged entry records the computed medoid')
197
+ assert.equal(merged.title, '部署窗口约定', 'title comes from the target first')
198
+ assert.equal(merged.mergeMode, 'extractive')
199
+ assert.ok(merged.sourceCoverage >= 0.45 && merged.unionCoverage >= 0.60, 'clean coverage passes the auto band')
200
+ // 内容是句级 union:target 的句子在前,来源句子去重并入,重复的 CORE 只出现一次。
201
+ assert.ok(merged.content.includes(X))
202
+ assert.ok(merged.content.includes(Y))
203
+ assert.equal(merged.content.split(CORE.replace(/。$/, '')).length - 1, 1, 'the shared core sentence appears once')
204
+ // 证据带 sourceId,覆盖每条 source。
205
+ const evidenceSources = new Set(merged.evidence.map((item) => item.sourceId))
206
+ for (const id of [A, B, C]) assert.ok(evidenceSources.has(id), `evidence must cover ${id}`)
207
+ // absorbSnapshot 记录吸收前状态。
208
+ assert.equal(merged.absorbSnapshot.target.id, A)
209
+ assert.deepEqual(merged.absorbSnapshot.sources.map((s) => s.id), [B, C].sort())
210
+ assert.ok(merged.absorbSnapshot.at)
211
+
212
+ // target superseded;sources archived——正文保留,绝不删除。
213
+ assert.equal(index.entries[A].status, 'superseded')
214
+ assert.equal(index.entries[A].absorbedInto, mergedId)
215
+ assert.equal(index.entries[A].supersededBy, mergedId)
216
+ assert.ok(index.entries[A].validTo)
217
+ for (const id of [B, C]) {
218
+ assert.equal(index.entries[id].status, 'archived', `${id} is archived, not deleted`)
219
+ assert.equal(index.entries[id].absorbedBy, mergedId)
220
+ assert.ok(index.entries[id].absorbedAt)
221
+ assert.ok(index.entries[id].content.includes(CORE), 'the archived source keeps its body')
222
+ }
223
+ // 词法路由下不提交向量;pending 之外没有多余条目。
224
+ assert.equal(backend.commits.length, 0)
225
+ })
226
+
227
+ // —— medoid 纠正 ——
228
+
229
+ test('when the model target is not the medoid, representativeId records the medoid instead', async () => {
230
+ // B is the bare core; A and C each append one long, mutually dissimilar
231
+ // sentence. B sits between them, so B is the medoid even though the model
232
+ // named A as target.
233
+ seedGlobalIndex({
234
+ [A]: baseEntryOf(A, '部署窗口约定', CORE + ' ' + '需求评审通过之后才允许进入发布队列,发布队列按优先级排序处理。'),
235
+ [B]: baseEntryOf(B, '部署窗口核心', CORE),
236
+ [C]: baseEntryOf(C, '部署窗口约束', CORE + ' ' + '窗口内的变更必须双人复核并且记录回滚预案到值班日志。'),
237
+ })
238
+ const output = {
239
+ memories: [{
240
+ op: 'absorb', target_id: A, source_ids: [B, C],
241
+ confidence: 0.9,
242
+ evidence: absorbEvidence([A, B, C]),
243
+ }],
244
+ }
245
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
246
+ assert.equal(result.absorbed, 1)
247
+ const index = state.loadGlobalIndex()
248
+ const merged = Object.values(index.entries).find((entry) => entry.absorbedFrom)
249
+ assert.equal(merged.representativeId, B, 'the medoid becomes representativeId')
250
+ assert.notEqual(merged.representativeId, A)
251
+ assert.equal(merged.absorbSnapshot.target.id, A, 'the model target stays as provenance')
252
+ })
253
+
254
+ // —— pending/superseded 成员降级 ——
255
+
256
+ test('pending and superseded members are demoted out of the absorb set, never absorbed', async () => {
257
+ const P = 'gmem-absorb-pending'
258
+ const S = 'gmem-absorb-superseded'
259
+ seedGlobalIndex({
260
+ [A]: baseEntryOf(A, '部署窗口约定', CORE + X),
261
+ [B]: baseEntryOf(B, '部署窗口细节', CORE + Y),
262
+ [P]: baseEntryOf(P, '待审条目', CORE + '待审的说法。', { status: 'pending_review' }),
263
+ [S]: baseEntryOf(S, '被取代条目', CORE + '旧的说法。', { status: 'superseded' }),
264
+ })
265
+ const output = {
266
+ memories: [{
267
+ op: 'absorb', target_id: A, source_ids: [P, S, B],
268
+ confidence: 0.9,
269
+ evidence: [...absorbEvidence([A, B]), { session_id: 's-x', quote: '无效来源的引用', source_id: P }],
270
+ }],
271
+ }
272
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
273
+ assert.equal(result.absorbed, 1)
274
+ const demotedIds = result.demoted.map((item) => item.sourceId)
275
+ assert.deepEqual(demotedIds.sort(), [P, S].sort())
276
+ const index = state.loadGlobalIndex()
277
+ const merged = Object.values(index.entries).find((entry) => entry.absorbedFrom)
278
+ assert.deepEqual(merged.absorbedFrom, [A, B].sort(), 'only active members are absorbed')
279
+ // 降级的成员状态原样。
280
+ assert.equal(index.entries[P].status, 'pending_review')
281
+ assert.equal(index.entries[S].status, 'superseded')
282
+ })
283
+
284
+ // —— 覆盖率不足 pending + 幂等 ——
285
+
286
+ test('coverage below the auto band lands as a pendingAbsorb proposal, and the same plan again is idempotent', async () => {
287
+ const T2 = 'gmem-absorb-t2'
288
+ const SAME = 'gmem-absorb-same'
289
+ const LOW = 'gmem-absorb-low'
290
+ // SAME 与 target 同文外加一句独有补充(句级去重后补充句保留,覆盖率 1.0);LOW 的
291
+ // 单句包含 target 的句子,整句被去重丢掉,覆盖率落到 skip 段之上、auto 段之下。
292
+ // 两者合成后:sourceCoverage = min(1.0, ~0.28) ≥ 0.25 不过 skip 门;unionCoverage
293
+ // ~0.5 落在 0.45–0.60 的 pending 段。三条同题——medoid 用 title+content 评估,
294
+ // 异题会把包含关系的相似度打到 0。
295
+ seedGlobalIndex({
296
+ [T2]: baseEntryOf(T2, '发布审批约定', '上线前必须完成发布审批。'),
297
+ [SAME]: baseEntryOf(SAME, '发布审批约定', '上线前必须完成发布审批。审批单需要写明影响范围与回滚预案。'),
298
+ [LOW]: baseEntryOf(LOW, '发布审批约定', '上线前必须完成发布审批,审批通过后还要在值班表登记变更单号并通知全部值班同学确认。'),
299
+ })
300
+ const output = {
301
+ memories: [{
302
+ op: 'absorb', target_id: T2, source_ids: [SAME, LOW],
303
+ confidence: 0.9,
304
+ evidence: absorbEvidence([T2, SAME, LOW]),
305
+ }],
306
+ }
307
+ const first = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
308
+ assert.equal(first.pending, 1, `expected a coverage pending, got ${JSON.stringify(first)}`)
309
+ assert.equal(first.absorbed, 0)
310
+
311
+ const index = state.loadGlobalIndex()
312
+ const proposalEntry = Object.entries(index.entries).find(([, entry]) => entry.pendingAbsorb)
313
+ assert.ok(proposalEntry, 'the pending absorb proposal is in the index')
314
+ const [proposalId, proposal] = proposalEntry
315
+ assert.equal(proposal.status, 'pending_review')
316
+ assert.equal(proposal.reviewReason, 'absorb coverage')
317
+ assert.equal(proposal.conflictingWith, T2)
318
+ const pa = proposal.pendingAbsorb
319
+ assert.equal(pa.targetId, T2)
320
+ assert.deepEqual([...pa.sourceIds].sort(), [SAME, LOW].sort())
321
+ assert.equal(pa.representativeId, T2)
322
+ assert.equal(pa.expectedUpdatedAt[T2], AT)
323
+ assert.equal(pa.expectedUpdatedAt[LOW], AT)
324
+ assert.ok(pa.opId)
325
+
326
+ // 同一提案再来一次:opId 已在(pendingAbsorb 里),幂等 skip,不再生成第二条。
327
+ const second = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
328
+ assert.equal(second.pending, 0)
329
+ assert.ok(second.skipped.some((item) => /duplicate absorb opId/.test(item.reason)))
330
+ const indexAfter = state.loadGlobalIndex()
331
+ assert.equal(Object.keys(indexAfter.entries).length, Object.keys(index.entries).length, 'no new entries on the idempotent run')
332
+ assert.equal(indexAfter.entries[proposalId].status, 'pending_review')
333
+ })
334
+
335
+ // —— medoid 相似度过低整簇跳过 ——
336
+
337
+ test('a cluster whose medoid average is below the pair floor is skipped entirely', async () => {
338
+ seedGlobalIndex({
339
+ [A]: baseEntryOf(A, '部署窗口约定', '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。'),
340
+ [B]: baseEntryOf(B, '完全另一件事', '用户偏好深色主题的代码高亮配色。'),
341
+ [C]: baseEntryOf(C, '又是另一件事', '会议纪要需要在当天归档到共享目录。'),
342
+ })
343
+ const output = {
344
+ memories: [{
345
+ op: 'absorb', target_id: A, source_ids: [B, C],
346
+ confidence: 0.9,
347
+ evidence: absorbEvidence([A, B, C]),
348
+ }],
349
+ }
350
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
351
+ assert.equal(result.absorbed, 0)
352
+ assert.equal(result.pending, 0)
353
+ assert.ok(result.skipped.some((item) => /below the pair floor/.test(item.reason)))
354
+ const index = state.loadGlobalIndex()
355
+ assert.equal(Object.keys(index.entries).length, 3, 'nothing was written')
356
+ })
357
+
358
+ // —— links rewire + snapshot ——
359
+
360
+ test('inbound links are rewired to the merged id, outbound links merge in, and the snapshot keeps pre-absorb state', async () => {
361
+ const D = 'gmem-absorb-d'
362
+ const E = 'gmem-absorb-e'
363
+ const linkToA = { id: 'lnk-in-1', opId: 'op1', to: A, kind: 'related', note: '这条边指向 target,吸收后要改指合并稿。', confidence: 0.7, evidence: [], createdAt: AT }
364
+ const linkFromA = { id: 'lnk-out-1', opId: 'op2', to: E, kind: 'refines', note: 'target 指向簇外的边要并入合并稿。', confidence: 0.7, evidence: [], createdAt: AT }
365
+ seedGlobalIndex({
366
+ [A]: baseEntryOf(A, '部署窗口约定', CORE + X, { links: [linkFromA] }),
367
+ [B]: baseEntryOf(B, '部署窗口细节', CORE + Y),
368
+ [C]: baseEntryOf(C, '部署窗口约束', CORE + '窗口外禁止任何生产变更。'),
369
+ [D]: baseEntryOf(D, '簇外条目', '另一个话题的内容,与吸收无关。', { links: [linkToA] }),
370
+ [E]: baseEntryOf(E, '簇外条目2', '被 target 指向的条目。'),
371
+ })
372
+ const output = {
373
+ memories: [{
374
+ op: 'absorb', target_id: A, source_ids: [B, C],
375
+ confidence: 0.9,
376
+ evidence: absorbEvidence([A, B, C]),
377
+ }],
378
+ }
379
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
380
+ assert.equal(result.absorbed, 1)
381
+ const index = state.loadGlobalIndex()
382
+ const mergedId = Object.keys(index.entries).find((id) => index.entries[id]?.absorbedFrom)
383
+ const merged = index.entries[mergedId]
384
+ // 入边改指 mergedId。
385
+ assert.equal(index.entries[D].links[0].to, mergedId)
386
+ assert.equal(index.entries[D].links[0].id, 'lnk-in-1', 'the edge keeps its id; only the endpoint moves')
387
+ // 出边并入 merged(去重、cap 20)。
388
+ assert.equal(merged.links.length, 1)
389
+ assert.equal(merged.links[0].to, E)
390
+ assert.equal(merged.links[0].kind, 'refines')
391
+ // 快照保留吸收前状态。
392
+ assert.deepEqual(merged.absorbSnapshot.rewired, [{ id: D, links: [linkToA] }])
393
+ assert.deepEqual(merged.absorbSnapshot.mergedLinks, [{ ...linkFromA, to: E }])
394
+ assert.equal(merged.absorbSnapshot.target.links.length, 1)
395
+ })
396
+
397
+ // —— 并发 source 变化 → held clone ——
398
+ test('a concurrent source change holds a pending clone, never lands the merge, and forgets the committed row', async () => {
399
+ seedGlobalIndex({
400
+ [A]: baseEntryOf(A, '部署窗口约定', CORE + X),
401
+ [B]: baseEntryOf(B, '部署窗口细节', CORE + Y),
402
+ })
403
+ await activateVectorGeneration()
404
+
405
+ // The concurrent writer lands while the curator sits between its index read
406
+ // and its index write; the vector commit is the async seam in between.
407
+ const changedAt = '2026-02-02T00:00:00.000Z'
408
+ backend.onCommit = async () => {
409
+ state.updateGlobalIndex((fresh) => {
410
+ fresh.entries[B] = { ...fresh.entries[B], content: '来源内容被并发修改。', updatedAt: changedAt }
411
+ })
412
+ }
413
+ const output = {
414
+ memories: [{
415
+ op: 'absorb', target_id: A, source_ids: [B],
416
+ confidence: 0.9,
417
+ evidence: absorbEvidence([A, B]),
418
+ }],
419
+ }
420
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
421
+ backend.onCommit = null
422
+
423
+ assert.equal(result.held, 1)
424
+ assert.equal(result.absorbed, 0)
425
+ const index = state.loadGlobalIndex()
426
+ const held = Object.values(index.entries).find((entry) => entry.pendingAbsorb)
427
+ assert.ok(held, 'the held pending clone must be in the index')
428
+ assert.equal(held.status, 'pending_review')
429
+ assert.equal(held.reviewReason, 'cluster changed while curator was running')
430
+ assert.equal(held.conflictingWith, A)
431
+ // expectedUpdatedAt 刷新为当前值,accept 时再做快照校验。
432
+ assert.equal(held.pendingAbsorb.expectedUpdatedAt[B], changedAt)
433
+ // 已提交向量库的 mergedId 行被 best-effort forget。
434
+ assert.deepEqual(backend.forgets.map((call) => call.name), backend.commits.at(-1).names)
435
+ // 成员原样:target 仍 active,被改过的 source 仍 active。
436
+ assert.equal(index.entries[A].status, 'active')
437
+ assert.equal(index.entries[B].status, 'active')
438
+ assert.equal(Object.values(index.entries).filter((entry) => entry.absorbedFrom && entry.status === 'active').length, 0,
439
+ 'no active merged entry landed')
440
+ // 结果进活动日志。
441
+ const event = activity.listEvents({ kind: 'curate' }).find((item) => item.detail?.op === 'absorb')
442
+ assert.ok(event, 'the absorb run is recorded')
443
+ })