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
@@ -0,0 +1,316 @@
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
+ // Global links (spec §3.2/§4.9/§6.6): symmetric/directed writes, opId
9
+ // idempotency, caps, evidence grounding, cross-domain rules, review-mode
10
+ // behaviour, and removal. Unit level through lib/global-graph.js, integration
11
+ // level through commitCuratorOutput's link branch — same hermetic rig as
12
+ // tests/curator-commit.test.mjs.
13
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-curator-link-'))
14
+ process.env.DSH_MEMU_HOME = home
15
+ process.env.DSH_MEMU_MEMORY_HOME = home
16
+
17
+ globalThis.__curatorLinkTest = {
18
+ async commit() { return { ok: true } },
19
+ async forget() { return { deleted: 1, segments: 1 } },
20
+ async retrieve() { return { files: [], segments: [], resources: [], raw: null } },
21
+ async listFiles() { return { recall_files: [], resources: [], raw: null } },
22
+ }
23
+ const memuSource = 'const bridge = globalThis.__curatorLinkTest;'
24
+ + 'export const commit = (args) => bridge.commit(args), forget = (args) => bridge.forget(args),'
25
+ + 'retrieve = (args) => bridge.retrieve(args), listFiles = (args) => bridge.listFiles(args);'
26
+ const hooks = registerHooks({
27
+ resolve(specifier, context, nextResolve) {
28
+ if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(memuSource), shortCircuit: true }
29
+ return nextResolve(specifier, context)
30
+ },
31
+ })
32
+
33
+ const curator = await import('../lib/curator.js')
34
+ const state = await import('../lib/state.js')
35
+ const graph = await import('../lib/global-graph.js')
36
+ const evidence = await import('../lib/curator-evidence.js')
37
+ const activity = await import('../lib/activity-log.js')
38
+
39
+ test.after(async () => {
40
+ try { hooks.unregister?.() } catch {}
41
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
42
+ })
43
+
44
+ const sessionCorpus = evidence.buildEvidenceCorpus([
45
+ { id: 's1', transcript: 'User: 部署窗口只在周四凌晨两点到四点开放,其余时间不发版。' },
46
+ ])
47
+
48
+ const A = () => ({
49
+ track: 'memory', domain: 'general', status: 'active', title: '部署窗口约定',
50
+ content: '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。',
51
+ createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
52
+ })
53
+ const B = (over = {}) => ({
54
+ track: 'memory', domain: 'general', status: 'active', title: '灰度发布规则',
55
+ content: '窗口内的发布全部走灰度,全量要等监控确认。',
56
+ createdAt: '2026-01-02T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', ...over,
57
+ })
58
+
59
+ function seed(entries) {
60
+ state.saveGlobalIndex({ version: 1, entries: structuredClone(entries) })
61
+ }
62
+
63
+ const linkOp = (over = {}) => ({
64
+ op: 'link', from_id: 'gmem-a', to_id: 'gmem-b', kind: 'related',
65
+ note: '两条记忆同讲部署窗口:部署窗口只在周四凌晨两点到四点开放',
66
+ confidence: 0.8,
67
+ evidence: [{ session_id: 's1', quote: '部署窗口只在周四凌晨两点到四点开放' }],
68
+ ...over,
69
+ })
70
+
71
+ const validateArgs = (over = {}) => ({
72
+ index: state.loadGlobalIndex(),
73
+ fromId: 'gmem-a', toId: 'gmem-b', kind: 'related',
74
+ note: '两条记忆同讲部署窗口:部署窗口只在周四凌晨两点到四点开放',
75
+ confidence: 0.8,
76
+ evidence: [{ session_id: 's1', quote: '部署窗口只在周四凌晨两点到四点开放' }],
77
+ sessionCorpus,
78
+ ...over,
79
+ })
80
+
81
+ // —— validateGlobalLink ——
82
+
83
+ test('validateGlobalLink: related is symmetric — both ends carry the same id and opId', () => {
84
+ seed({ 'gmem-a': A(), 'gmem-b': B() })
85
+ const verdict = graph.validateGlobalLink(validateArgs())
86
+ assert.equal(verdict.ok, true, verdict.reason || '')
87
+ const writes = [verdict]
88
+ const { applied } = graph.applyGlobalLinks(JSON.parse(JSON.stringify(state.loadGlobalIndex().entries)), writes)
89
+ assert.equal(applied.length, 1)
90
+ seed({
91
+ 'gmem-a': { ...A(), links: [{ ...verdict.link, to: 'gmem-b' }] },
92
+ 'gmem-b': { ...B(), links: [{ ...verdict.link, to: 'gmem-a' }] },
93
+ })
94
+ const a = state.loadGlobalIndex().entries['gmem-a']
95
+ const b = state.loadGlobalIndex().entries['gmem-b']
96
+ assert.equal(a.links[0].id, b.links[0].id)
97
+ assert.equal(a.links[0].opId, b.links[0].opId)
98
+ assert.equal(a.links[0].to, 'gmem-b')
99
+ assert.equal(b.links[0].to, 'gmem-a')
100
+ assert.match(a.links[0].id, /^lnk_[0-9a-f]{16}$/)
101
+ })
102
+
103
+ test('validateGlobalLink: a contradictory pair is rejected instead of becoming a link', () => {
104
+ const sameTitle = A().title
105
+ seed({
106
+ 'gmem-a': A(),
107
+ 'gmem-b': B({ title: sameTitle, content: '部署窗口在周五,窗口内禁止任何灰度发布。' }),
108
+ })
109
+ const verdict = graph.validateGlobalLink(validateArgs())
110
+ assert.equal(verdict.ok, false)
111
+ assert.match(verdict.reason, /conflict pair detected/)
112
+ })
113
+
114
+ test('link writes an audit trail on both ends; unlink appends a removal event', () => {
115
+ seed({ 'gmem-a': A(), 'gmem-b': B() })
116
+ const verdict = graph.validateGlobalLink(validateArgs())
117
+ assert.equal(verdict.ok, true, verdict.reason || '')
118
+ const entries = JSON.parse(JSON.stringify(state.loadGlobalIndex().entries))
119
+ graph.applyGlobalLinks(entries, [verdict])
120
+ assert.equal(entries['gmem-a'].auditTrail?.at(-1)?.op, 'link')
121
+ assert.equal(entries['gmem-b'].auditTrail?.at(-1)?.op, 'link')
122
+ assert.equal(entries['gmem-a'].auditTrail.at(-1).linkId, verdict.link.id)
123
+ state.saveGlobalIndex({ version: 1, entries })
124
+ const removed = graph.removeGlobalLink({ linkId: verdict.link.id })
125
+ assert.equal(removed.removed, 2)
126
+ const index = state.loadGlobalIndex()
127
+ assert.equal(index.entries['gmem-a'].auditTrail.at(-1).op, 'unlink')
128
+ assert.equal(index.entries['gmem-b'].auditTrail.at(-1).op, 'unlink')
129
+ })
130
+
131
+ test('validateGlobalLink: refines/supports are directed — only the source end stores the edge', () => {
132
+ seed({ 'gmem-a': A(), 'gmem-b': B() })
133
+ const verdict = graph.validateGlobalLink(validateArgs({ kind: 'supports' }))
134
+ assert.equal(verdict.ok, true, verdict.reason || '')
135
+ assert.equal(verdict.symmetric, false)
136
+ const entries = { 'gmem-a': A(), 'gmem-b': B() }
137
+ const { applied } = graph.applyGlobalLinks(entries, [verdict])
138
+ assert.equal(applied.length, 1)
139
+ assert.equal(entries['gmem-a'].links.length, 1)
140
+ assert.equal(entries['gmem-b'].links, undefined, 'the target end gains nothing for a directed edge')
141
+ // opId uses the ordered pair, so swapping ends changes it
142
+ const swapped = graph.validateGlobalLink(validateArgs({ kind: 'supports', fromId: 'gmem-b', toId: 'gmem-a' }))
143
+ assert.notEqual(swapped.link.opId, verdict.link.opId)
144
+ })
145
+
146
+ test('validateGlobalLink: rejections — endpoints, self, kinds, note bounds, confidence, evidence', () => {
147
+ seed({ 'gmem-a': A(), 'gmem-b': B(), 'gmem-dead': B({ status: 'archived' }) })
148
+ const cases = [
149
+ [{ ...validateArgs(), fromId: 'gmem-missing' }, /endpoint not found/],
150
+ [{ ...validateArgs(), toId: 'gmem-dead' }, /endpoint not active/],
151
+ [{ ...validateArgs(), fromId: 'gmem-a', toId: 'gmem-a' }, /self link/],
152
+ [{ ...validateArgs(), kind: 'contradicts' }, /unknown kind/],
153
+ [{ ...validateArgs(), note: '短' }, /note length 1 outside 8-500/],
154
+ [{ ...validateArgs(), note: 'x'.repeat(501) }, /note length 501 outside 8-500/],
155
+ [{ ...validateArgs(), confidence: 0.4 }, /confidence 0.4 outside 0.5-1/],
156
+ [{ ...validateArgs(), evidence: [] }, /evidence must be 1-3/],
157
+ [{ ...validateArgs(), evidence: [{ session_id: 's1', quote: 'nope' }, { session_id: 's1', quote: '部署窗口只在周四凌晨两点到四点开放' }, { session_id: 's1', quote: '部署窗口只在周四凌晨两点到四点开放' }, { session_id: 's1', quote: '部署窗口只在周四凌晨两点到四点开放' }] }, /evidence must be 1-3/],
158
+ [{ ...validateArgs(), evidence: [{ session_id: 's9', quote: '部署窗口只在周四凌晨两点到四点开放' }] }, /evidence not grounded/],
159
+ [{ ...validateArgs(), note: '这句注记没有引用任何原话内容' }, /note must quote at least one grounded evidence verbatim/],
160
+ ]
161
+ for (const [args, expected] of cases) {
162
+ const verdict = graph.validateGlobalLink(args)
163
+ assert.equal(verdict.ok, false, `${JSON.stringify(args.note || args.kind)} should be rejected`)
164
+ assert.match(verdict.reason, expected)
165
+ }
166
+ })
167
+
168
+ test('validateGlobalLink: cross-domain links must be related; rp and coding never link', () => {
169
+ seed({
170
+ 'gmem-a': A(),
171
+ 'gmem-b': B({ domain: 'coding' }),
172
+ 'gmem-rp': B({ domain: 'rp' }),
173
+ })
174
+ const refines = graph.validateGlobalLink(validateArgs({ kind: 'refines', toId: 'gmem-b' }))
175
+ assert.equal(refines.ok, false)
176
+ assert.match(refines.reason, /cross-domain links must be related/)
177
+ const clash = graph.validateGlobalLink(validateArgs({ toId: 'gmem-rp' }))
178
+ assert.equal(clash.ok, true, 'general↔rp cross-domain related is allowed')
179
+ const rpCoding = graph.validateGlobalLink(validateArgs({ fromId: 'gmem-b', toId: 'gmem-rp' }))
180
+ assert.equal(rpCoding.ok, false)
181
+ assert.match(rpCoding.reason, /rp and coding never link/)
182
+ })
183
+
184
+ test('applyGlobalLinks: (to,kind) idempotent; cap 20 per end; one full end blocks a symmetric pair', () => {
185
+ const third = B({ title: '第三条', content: '另一条不相关的记忆内容,用来测上限。' })
186
+ seed({ 'gmem-a': A(), 'gmem-b': B(), 'gmem-c': third })
187
+ const first = graph.validateGlobalLink(validateArgs())
188
+ const entries = { 'gmem-a': A(), 'gmem-b': B(), 'gmem-c': structuredClone(third) }
189
+ graph.applyGlobalLinks(entries, [first])
190
+ // same (to, kind) again → skipped
191
+ const again = graph.applyGlobalLinks(entries, [first])
192
+ assert.equal(again.applied.length, 0)
193
+ assert.match(again.skipped[0].reason, /duplicate link/)
194
+ // fill gmem-a to 20 links → the next link out of gmem-a hits the cap
195
+ entries['gmem-a'].links.push(...Array.from({ length: 19 }, (_, i) => ({ id: `lnk_pad_${i}`, to: `pad-${i}`, kind: 'related' })))
196
+ assert.equal(entries['gmem-a'].links.length, 20)
197
+ const capped = graph.validateGlobalLink(validateArgs({ index: { entries }, toId: 'gmem-c' }))
198
+ assert.equal(capped.ok, false)
199
+ assert.match(capped.reason, /link cap reached on gmem-a/)
200
+ // a forced write against the full end blocks BOTH ends of a symmetric pair
201
+ const forced = { fromId: 'gmem-a', toId: 'gmem-b', kind: 'related', symmetric: true, link: first.link }
202
+ const blocked = graph.applyGlobalLinks(entries, [forced])
203
+ assert.equal(blocked.applied.length, 0)
204
+ assert.equal(entries['gmem-b'].links.length, 1, 'the non-full end gains nothing when the other end is full')
205
+ })
206
+
207
+ test('applyGlobalLinks: directed edges reject cycles within depth 3', () => {
208
+ const entries = {
209
+ 'n1': { ...A(), links: [{ id: 'lnk_1', to: 'n2', kind: 'refines' }] },
210
+ 'n2': { ...B(), links: [{ id: 'lnk_2', to: 'n3', kind: 'refines' }] },
211
+ 'n3': { ...B({ title: 'C' }), links: [] },
212
+ }
213
+ // n3 → n1 would close n1→n2→n3→n1
214
+ const cyclic = { fromId: 'n3', toId: 'n1', kind: 'refines', symmetric: false, link: { id: 'lnk_x', opId: 'x', to: 'n1', kind: 'refines', note: 'x', confidence: 0.8, evidence: [], createdAt: 'now' } }
215
+ const { applied, skipped } = graph.applyGlobalLinks(entries, [cyclic])
216
+ assert.equal(applied.length, 0)
217
+ assert.match(skipped[0].reason, /directed cycle/)
218
+ // depth 4 is out of the check's reach by design (§4.9: depth ≤3)
219
+ const deep = {
220
+ 'd1': { ...A(), links: [{ id: 'l1', to: 'd2', kind: 'refines' }] },
221
+ 'd2': { ...B(), links: [{ id: 'l2', to: 'd3', kind: 'refines' }] },
222
+ 'd3': { ...B(), links: [{ id: 'l3', to: 'd4', kind: 'refines' }] },
223
+ 'd4': { ...B(), links: [] },
224
+ }
225
+ assert.equal(graph.directedReaches(deep, 'd4', 'd1', 3), false, 'a depth-4 path is beyond the cycle check')
226
+ })
227
+
228
+ test('removeGlobalLink: deletes both ends of a symmetric edge and only metadata', () => {
229
+ seed({
230
+ 'gmem-a': { ...A(), links: [{ id: 'lnk_sym', opId: 'op1', to: 'gmem-b', kind: 'related' }, { id: 'lnk_keep', to: 'gmem-c', kind: 'related' }] },
231
+ 'gmem-b': { ...B(), links: [{ id: 'lnk_sym', opId: 'op1', to: 'gmem-a', kind: 'related' }] },
232
+ })
233
+ const result = graph.removeGlobalLink({ linkId: 'lnk_sym' })
234
+ assert.equal(result.removed, 2)
235
+ assert.deepEqual(result.entries.sort(), ['gmem-a', 'gmem-b'])
236
+ const index = state.loadGlobalIndex()
237
+ assert.deepEqual(index.entries['gmem-a'].links, [{ id: 'lnk_keep', to: 'gmem-c', kind: 'related' }])
238
+ assert.equal(index.entries['gmem-b'].links.length, 0)
239
+ assert.equal(index.entries['gmem-a'].status, 'active', 'removal never touches entry status')
240
+ const miss = graph.removeGlobalLink({ linkId: 'lnk_absent' })
241
+ assert.equal(miss.removed, 0)
242
+ })
243
+
244
+ // —— commitCuratorOutput link branch ——
245
+
246
+ test('commitCuratorOutput: a grounded link lands in both modes and never enters files', async () => {
247
+ seed({ 'gmem-a': A(), 'gmem-b': B() })
248
+ state.updateSettings({ curatorReviewMode: 'review-all' })
249
+ const result = await curator.commitCuratorOutput({ memories: [linkOp()] }, { job: { step: 'committing' }, sessionCorpus })
250
+ assert.equal(result.linked, 1)
251
+ assert.equal(result.files.length, 0, 'links never enter the vector commit')
252
+ assert.equal(result.pending, 0, 'review-all does not hold links (C2)')
253
+ const index = state.loadGlobalIndex()
254
+ assert.equal(index.entries['gmem-a'].links[0].to, 'gmem-b')
255
+ const event = activity.listEvents({ kind: 'curate' }).find((item) => item.detail?.op === 'link')
256
+ assert.ok(event, 'each applied link gets its own activity record')
257
+ assert.equal(event.detail.kind, 'related')
258
+ assert.equal(event.detail.from, 'gmem-a')
259
+ assert.match(event.detail.linkId, /^lnk_/)
260
+ state.updateSettings({ curatorReviewMode: 'auto' })
261
+ })
262
+
263
+ test('commitCuratorOutput: ungrounded evidence means no edge and a skipped entry (C10)', async () => {
264
+ seed({ 'gmem-a': A(), 'gmem-b': B() })
265
+ // grounded corpus present but the quote is fabricated
266
+ const bad = await curator.commitCuratorOutput({
267
+ memories: [linkOp({ evidence: [{ session_id: 's1', quote: '凭空编造的引用根本不在会话里' }] })],
268
+ }, { job: { step: 'committing' }, sessionCorpus })
269
+ assert.equal(bad.linked, 0)
270
+ assert.equal(bad.skipped.length, 1)
271
+ assert.match(bad.skipped[0].reason, /link rejected/)
272
+ // no corpus at all (direct call): shape-valid evidence stays unverified and lands
273
+ const direct = await curator.commitCuratorOutput({ memories: [linkOp()] }, { job: { step: 'committing' } })
274
+ assert.equal(direct.linked, 1)
275
+ // empty evidence is always rejected
276
+ const empty = await curator.commitCuratorOutput({ memories: [linkOp({ evidence: [] })] }, { job: { step: 'committing' } })
277
+ assert.equal(empty.linked, 0)
278
+ assert.match(empty.skipped[0].reason, /evidence must be 1-3/)
279
+ })
280
+
281
+ test('commitCuratorOutput: duplicate (to,kind) is idempotent and links ride the same transaction as content', async () => {
282
+ seed({ 'gmem-a': A(), 'gmem-b': B(), 'gmem-c': B({ title: '第三条', content: '另一条不相关的记忆内容,用来测上限。' }) })
283
+ const firstOutput = { memories: [structuredClone(linkOp())] }
284
+ const result = await curator.commitCuratorOutput(firstOutput, { job: { step: 'committing' }, sessionCorpus })
285
+ assert.equal(result.linked, 1)
286
+ const again = await curator.commitCuratorOutput(firstOutput, { job: { step: 'committing' }, sessionCorpus })
287
+ assert.equal(again.linked, 0, 'the second run is fully idempotent')
288
+ assert.equal(again.skipped.length, 1)
289
+ // links + a create share one commit (same updateGlobalIndex transaction)
290
+ const mixed = await curator.commitCuratorOutput({
291
+ memories: [
292
+ linkOp({ from_id: 'gmem-b', to_id: 'gmem-c' }),
293
+ {
294
+ op: 'create', domain: 'general', track: 'memory', title: '全新独立事实',
295
+ content: '用户倾向在代码评审里先看测试再看过渡逻辑。',
296
+ confidence: 0.9,
297
+ evidence: [{ session_id: 's1', quote: '部署窗口只在周四凌晨两点到四点开放' }],
298
+ },
299
+ ],
300
+ }, { job: { step: 'committing' }, sessionCorpus })
301
+ assert.equal(mixed.linked, 1)
302
+ assert.equal(mixed.created, 1)
303
+ })
304
+
305
+ test('commitCuratorOutput: absorb ops now flow into commitCuratorPlans (wired; missing target skips)', async () => {
306
+ // F2 接线后:absorb 不再走 relay skip,而是由 commitCuratorPlans 直接提交。
307
+ // 空索引里 target 不存在 → 提交层按校验顺序跳过(不是 create,也不是 relay)。
308
+ seed({})
309
+ const result = await curator.commitCuratorOutput({
310
+ memories: [{ op: 'absorb', target_id: 'gmem-a', source_ids: ['gmem-b'], title: 'x', content: 'y', confidence: 0.9, evidence: [{ session_id: 's1', quote: 'zzzzzz' }] }],
311
+ }, { job: { step: 'committing' } })
312
+ assert.equal(result.created, 0, 'absorb never falls through to create')
313
+ assert.equal(Object.keys(state.loadGlobalIndex().entries).length, 0, 'nothing lands without a real target')
314
+ assert.ok(result.skipped.length >= 1, 'the dead-end absorb is reported in skipped')
315
+ assert.ok(!result.skipped.some((item) => /absorb lands in a later pass/.test(item.reason)), 'the relay reason is gone: absorb is wired')
316
+ })
@@ -0,0 +1,252 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {
4
+ bm25,
5
+ buildCorpus,
6
+ globalConflictCheck,
7
+ negationPolarity,
8
+ numericSetsDiffer,
9
+ pairDecision,
10
+ pairFeatures,
11
+ pairScore,
12
+ rankPairCandidates,
13
+ tokenize,
14
+ } from '../lib/text-retrieval.js'
15
+ import { similarityDetail, similarTexts, bigrams, dice } from '../lib/text-similarity.js'
16
+
17
+ // —— §4.1 tokenize ——
18
+
19
+ test('tokenize: latin runs of >=2 chars keep ._ - joins, single chars drop', () => {
20
+ assert.deepEqual(tokenize('use dsh-memu-memory_v2.today ok'), ['use', 'dsh-memu-memory_v2.today', 'ok'])
21
+ assert.deepEqual(tokenize('a b cd'), ['cd'])
22
+ assert.deepEqual(tokenize('per-turn retry'), ['per-turn', 'retry'])
23
+ })
24
+
25
+ test('tokenize: CJK runs become overlapping bigrams, isolated chars stay single', () => {
26
+ assert.deepEqual(tokenize('部署窗口'), ['部署', '署窗', '窗口'])
27
+ assert.deepEqual(tokenize('猫'), ['猫'])
28
+ // two CJK runs separated by latin/punct stay separate runs
29
+ assert.deepEqual(tokenize('猫。狗'), ['猫', '狗'])
30
+ })
31
+
32
+ test('tokenize: NFKC folds full-width forms, lowercases, dedupes', () => {
33
+ assert.deepEqual(tokenize('RISCV CACHE CACHE'), ['riscv', 'cache'])
34
+ assert.deepEqual(tokenize('缓存 缓存'), ['缓存'])
35
+ })
36
+
37
+ // —— §4.1 buildCorpus + bm25 ——
38
+
39
+ function corpusFor() {
40
+ return buildCorpus([
41
+ { id: 'a', title: '部署窗口', content: '每周四凌晨部署窗口只做灰度', track: 'memory', domain: 'general' },
42
+ { id: 'b', title: '缓存策略', content: 'Redis 缓存统一 30 秒过期', track: 'memory', domain: 'coding' },
43
+ { id: 'c', title: '站会安排', content: '每周一上午十点开站会', track: 'memory', domain: 'general' },
44
+ ])
45
+ }
46
+
47
+ test('buildCorpus: tf/df/avgdl account for every doc and term', () => {
48
+ const corpus = corpusFor()
49
+ assert.equal(corpus.size, 3)
50
+ assert.equal(corpus.df.get('缓存'), 1)
51
+ assert.equal(corpus.df.get('每周'), 2)
52
+ const doc = corpus.docs.get('a')
53
+ assert.equal(doc.tf.get('部署'), 2)
54
+ assert.equal(doc.len, [...doc.tf.values()].reduce((sum, n) => sum + n, 0))
55
+ assert.ok(corpus.avgdl > 0)
56
+ })
57
+
58
+ test('bm25: docs containing query terms outscore non-matching docs', () => {
59
+ const corpus = corpusFor()
60
+ const query = tokenize('缓存 过期')
61
+ const scores = ['a', 'b', 'c'].map((id) => bm25(query, corpus.docs.get(id), corpus))
62
+ assert.ok(scores[1] > scores[0] && scores[1] > scores[2])
63
+ assert.equal(scores[2], 0)
64
+ })
65
+
66
+ test('bm25: queries wider than 64 terms keep the matching terms (junk never evicts them)', () => {
67
+ const corpus = corpusFor()
68
+ const junk = [...Array(80).keys()].map((i) => `raretoken${i}`)
69
+ const query = [...junk, '缓存']
70
+ // Absent terms carry the highest nominal IDF but zero scoring mass; the top-64
71
+ // quota must not spend itself on them and drop the one term that can score.
72
+ const hits = query.filter((t) => (corpus.df.get(t) || 0) > 0)
73
+ assert.equal(bm25(query, corpus.docs.get('b'), corpus), bm25(hits, corpus.docs.get('b'), corpus))
74
+ assert.ok(bm25(hits, corpus.docs.get('b'), corpus) > 0)
75
+ })
76
+ // —— §4.2 similarityDetail / pairFeatures / pairScore ——
77
+
78
+ test('similarityDetail: length-comparable pairs report dice, pruned pairs report the containment floor', () => {
79
+ const near = similarityDetail('每周四凌晨部署窗口只做灰度', '每周四凌晨部署窗口只做灰度发布')
80
+ assert.equal(near.containment, 0)
81
+ assert.ok(near.dice > 0.9)
82
+ const contained = similarityDetail('Redis 缓存统一设置 30 秒过期。', 'Redis 缓存统一设置 30 秒过期。过期之后直接回源查询并把回源结果写回缓存,缓存击穿时用互斥锁保护。')
83
+ assert.equal(contained.dice, 0)
84
+ assert.equal(contained.containment, 0.92)
85
+ // similarTexts stays the max of the two — the pre-existing ruler, unchanged
86
+ assert.equal(similarTexts('每周四凌晨部署窗口只做灰度', '每周四凌晨部署窗口只做灰度发布'), near.dice)
87
+ assert.equal(similarTexts('Redis 缓存统一设置 30 秒过期。', 'Redis 缓存统一设置 30 秒过期。过期之后直接回源查询并把回源结果写回缓存,缓存击穿时用互斥锁保护。'), 0.92)
88
+ assert.ok(dice(bigrams('缓存策略'), bigrams('缓存策略')) > 0.99)
89
+ })
90
+
91
+ test('pairFeatures: entityJaccard is 0.5 neutral when both sides are empty, 1 when identical', () => {
92
+ const bothEmpty = pairFeatures({ title: 'A', content: '缓存策略内容' }, { title: 'A', content: '缓存策略内容' })
93
+ assert.equal(bothEmpty.entityJaccard, 0.5)
94
+ assert.equal(bothEmpty.titleExact, true)
95
+ const same = pairFeatures(
96
+ { title: 'A', content: '缓存策略内容', appliesTo: ['Deploy', ' coding '] },
97
+ { title: 'A', content: '缓存策略内容', appliesTo: ['deploy', 'CODING'] },
98
+ )
99
+ assert.equal(same.entityJaccard, 1)
100
+ const disjoint = pairFeatures(
101
+ { title: 'A', content: '缓存策略内容', appliesTo: ['deploy'] },
102
+ { title: 'A', content: '缓存策略内容', appliesTo: ['backend'] },
103
+ )
104
+ assert.equal(disjoint.entityJaccard, 0)
105
+ })
106
+
107
+ test('pairScore: weighted sum 0.50 lexical + 0.20 token + 0.15 title + 0.15 entity', () => {
108
+ const features = { lexical: 1, tokenJaccard: 1, titleDice: 1, entityJaccard: 1 }
109
+ assert.ok(Math.abs(pairScore(features) - 1) < 1e-9)
110
+ assert.ok(Math.abs(pairScore({ ...features, entityJaccard: 0.5 }) - 0.925) < 1e-9)
111
+ // entries form is accepted too
112
+ const entryScore = pairScore(
113
+ { title: '缓存策略', content: '缓存策略内容一致', appliesTo: ['x'] },
114
+ { title: '缓存策略', content: '缓存策略内容一致', appliesTo: ['x'] },
115
+ )
116
+ assert.ok(entryScore > 0.95)
117
+ })
118
+
119
+ // —— §4.2 pairDecision ——
120
+
121
+ const base = { track: 'memory', domain: 'general', title: '缓存策略', content: 'Redis 缓存统一 30 秒过期', appliesTo: [] }
122
+
123
+ test('pairDecision: different track is always none, even at identical text', () => {
124
+ const b = { ...base, track: 'skill' }
125
+ assert.equal(pairDecision(base, b), 'none')
126
+ })
127
+
128
+ test('pairDecision: cross-domain links only at >=0.65, never absorbs, rp<->coding never links', () => {
129
+ const same = { ...base, domain: 'coding', title: '缓存策略', content: 'Redis 缓存统一 30 秒过期,额外上下文让分数降下来一些' }
130
+ const d = pairDecision(base, same)
131
+ assert.ok(['link', 'none'].includes(d), `cross-domain must be link/none, got ${d}`)
132
+ const coding = { ...base, domain: 'coding' }
133
+ const rp = { ...base, domain: 'rp', title: '缓存策略', content: 'Redis 缓存统一 30 秒过期' }
134
+ assert.equal(pairDecision(coding, rp), 'none', 'rp and coding never link')
135
+ })
136
+
137
+ test('pairDecision: same-title different-body is conflict; identical text is duplicate', () => {
138
+ const different = { ...base, title: '缓存策略', content: '缓存全部走内存队列,不落 Redis' }
139
+ assert.equal(pairDecision(base, different), 'conflict')
140
+ assert.equal(pairDecision(base, { ...base }), 'duplicate')
141
+ })
142
+
143
+ test('pairDecision: band boundaries 0.90/0.82/0.55 come from the policy table', () => {
144
+ const probe = (tweakA, tweakB) => {
145
+ const a = { ...base, ...tweakA }
146
+ const b = { ...base, ...tweakB }
147
+ return { score: pairScore(pairFeatures(a, b)), decision: pairDecision(a, b) }
148
+ }
149
+ // construct scores inside each band by mixing known-similar/known-different text
150
+ const absorbish = probe(
151
+ { content: 'Redis 缓存统一设置 30 秒过期,命中直接返回' },
152
+ { content: 'Redis 缓存统一设置 30 秒过期,命中直接返回数据' },
153
+ )
154
+ assert.ok(absorbish.score >= 0.82 && absorbish.score < 0.90, `expected absorb band, got ${absorbish.score}`)
155
+ assert.equal(absorbish.decision, 'absorb')
156
+ })
157
+
158
+ // —— §4.3 rankPairCandidates ——
159
+
160
+ function bigCorpus({ withTarget = true } = {}) {
161
+ // 301 fillers push the corpus past the exact-pair limit → blocking only.
162
+ const entries = []
163
+ for (let i = 1; i <= 301; i++) {
164
+ entries.push({
165
+ id: `filler-${i}`,
166
+ track: 'memory',
167
+ domain: 'filler',
168
+ title: `填充条目 ${i}`,
169
+ content: `这是第 ${i} 条填充记忆,用于撑大语料规模,内容彼此不同填充。`,
170
+ updatedAt: '2026-01-01T00:00:00.000Z',
171
+ })
172
+ }
173
+ if (withTarget) {
174
+ entries.push({ id: 'target', track: 'memory', domain: 'general', title: '缓存过期', content: 'Redis 缓存统一设置 30 秒过期,命中直接返回,不做全量。', appliesTo: ['deploy'], updatedAt: '2026-01-02T00:00:00.000Z' })
175
+ entries.push({ id: 'title-only', track: 'memory', domain: 'general', title: '缓存过期', content: '完全不同的词汇组织起来的另一段内容。', updatedAt: '2026-01-03T00:00:00.000Z' })
176
+ entries.push({ id: 'single-token', track: 'memory', domain: 'elsewhere', title: '项目排期', content: '安排下季度的路线图评审。', updatedAt: '2026-01-04T00:00:00.000Z' })
177
+ }
178
+ return buildCorpus(entries)
179
+ }
180
+
181
+ test('rankPairCandidates: blocking finds the shared-token target in a >300 corpus', () => {
182
+ const corpus = bigCorpus()
183
+ assert.ok(corpus.size > 300, 'corpus must exceed the exact-pair limit')
184
+ const proposal = { id: 'p', track: 'memory', domain: 'general', title: '缓存过期', content: 'Redis 缓存统一设置 30 秒过期,命中直接返回。', appliesTo: ['deploy'] }
185
+ const found = rankPairCandidates({ corpus, proposal, limit: 20 })
186
+ const ids = found.map((c) => c.id)
187
+ assert.ok(ids.includes('target'), `shared >=2 rare tokens must block, got ${ids.join(',')}`)
188
+ const target = found.find((c) => c.id === 'target')
189
+ assert.ok(['duplicate', 'absorb'].includes(target.decision), `same-fact band decision, got ${target.decision}`)
190
+ assert.ok(target.score >= 0.82)
191
+ })
192
+
193
+ test('rankPairCandidates: exact-title entries enter even without shared tokens', () => {
194
+ const corpus = bigCorpus()
195
+ const proposal = { id: 'p', track: 'memory', domain: 'general', title: '缓存过期', content: '完全无重叠词元的新内容组合安排。', appliesTo: [] }
196
+ const found = rankPairCandidates({ corpus, proposal, limit: 20 })
197
+ assert.ok(found.some((c) => c.id === 'title-only'))
198
+ })
199
+
200
+ test('rankPairCandidates: a one-shared-token candidate outside the proposal domain stays out', () => {
201
+ const corpus = bigCorpus()
202
+ const proposal = { id: 'p', track: 'memory', domain: 'general', title: '缓存过期', content: '完全无重叠词元的新内容组合安排。', appliesTo: [] }
203
+ const found = rankPairCandidates({ corpus, proposal, limit: 20 })
204
+ // 'single-token' shares exactly one rare token (安排) with the proposal and
205
+ // sits in a different domain: blocking needs >=2 shared tokens and the
206
+ // same-domain backfill never reaches a foreign domain.
207
+ assert.ok(!found.some((c) => c.id === 'single-token'))
208
+ })
209
+
210
+ test('rankPairCandidates: small corpora score every same-track entry and rank the partner first', () => {
211
+ const corpus = buildCorpus([
212
+ { id: 'partner', track: 'memory', domain: 'general', title: '部署窗口约定', content: '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。', appliesTo: ['deploy'] },
213
+ { id: 'other', track: 'memory', domain: 'general', title: '咖啡偏好', content: '用户下午的咖啡只喝美式,上午喝拿铁。', appliesTo: [] },
214
+ ])
215
+ const proposal = { id: 'p', track: 'memory', domain: 'general', title: '部署窗口约定', content: '每周四凌晨两点到四点是固定部署窗口,窗口里只允许灰度发布操作。', appliesTo: ['deploy'] }
216
+ const found = rankPairCandidates({ corpus, proposal, limit: 20 })
217
+ assert.equal(found[0].id, 'partner')
218
+ assert.equal(found[0].decision, 'absorb')
219
+ assert.ok(found.some((c) => c.id === 'other'), 'same-domain backfill keeps the weak partner visible')
220
+ })
221
+
222
+ test('rankPairCandidates: deterministic — same input, same output', () => {
223
+ const corpus = bigCorpus()
224
+ const proposal = { id: 'p', track: 'memory', domain: 'general', title: '缓存过期', content: 'Redis 缓存统一设置 30 秒过期。', appliesTo: [] }
225
+ const run1 = rankPairCandidates({ corpus, proposal, limit: 20 })
226
+ const run2 = rankPairCandidates({ corpus, proposal, limit: 20 })
227
+ assert.deepEqual(run1, run2)
228
+ })
229
+
230
+ // —— §4.8 全局冲突预筛 ——
231
+
232
+ test('globalConflictCheck: same title + different body is same_title; negation flips conflict', () => {
233
+ const a = { title: '缓存策略', content: 'Redis 缓存统一设置 30 秒过期' }
234
+ const b = { title: '缓存策略', content: '缓存全部走内存队列不落 Redis' }
235
+ assert.equal(globalConflictCheck(a, b), 'same_title')
236
+ // different titles (so same_title cannot fire first) + one side negated
237
+ const c = { title: '告警偏好', content: '用户希望每次部署失败都发告警通知' }
238
+ const d = { title: '告警设置', content: '用户不希望每次部署失败都发告警通知' }
239
+ assert.equal(globalConflictCheck(c, d), 'negation')
240
+ assert.equal(negationPolarity('不发告警'), true)
241
+ assert.equal(negationPolarity('每次都发告警'), false)
242
+ })
243
+
244
+ test('globalConflictCheck: differing numbers on similar titles raise value, containment exempts', () => {
245
+ const a = { title: '备份保留', content: '备份保留 14 天后轮转删除' }
246
+ const b = { title: '备份保留期', content: '备份保留 90 天后轮转删除' }
247
+ assert.equal(numericSetsDiffer(a.content, b.content), true)
248
+ assert.equal(globalConflictCheck(a, b), 'value')
249
+ const superset = { title: '备份保留', content: '备份保留 14 天后轮转删除,删除前先校验快照完整性' }
250
+ const detail = globalConflictCheck(a, superset)
251
+ assert.notEqual(detail, 'value', 'a long refinement of the same fact is not a value conflict')
252
+ })
@@ -296,7 +296,7 @@ test('six channels, layer by layer: transcript -> prompt -> simulated output ->
296
296
  const prompt = captured.request.prompt[0].text
297
297
  assert.match(prompt, /全局记忆提炼任务/)
298
298
  const input = promptInput(prompt)
299
- assert.deepEqual(Object.keys(input).sort(), ['existing_global_index', 'sessions'])
299
+ assert.deepEqual(Object.keys(input).sort(), ['candidate_clusters', 'clusters_truncated', 'existing_global_index', 'index_truncated', 'sessions'])
300
300
  assert.equal(input.sessions.length, 1)
301
301
  assert.deepEqual(
302
302
  Object.keys(input.sessions[0]).sort(),
@@ -336,15 +336,23 @@ test('six channels, layer by layer: transcript -> prompt -> simulated output ->
336
336
  CHANNELS.map((channel) => channel.layers),
337
337
  )
338
338
 
339
- // The simulated model echoed only what reached the prompt: four transcript
340
- // channels plus the two visible existing-index entries.
341
- assert.equal(job.result.created, 6)
342
- for (const key of ['user', 'assistant', 'toolArgs', 'recallSecret', 'existing', 'pending']) {
339
+ // The simulated model echoed what reached the prompt: four transcript channels
340
+ // commit as active; the two index-only echoes (existing/pending never entered the
341
+ // transcript) fail evidence grounding and are held for review — §4.6 的防幻觉
342
+ // 门槛正是为这种情况设计的:prompt 里见过 ≠ 转录里说过。
343
+ assert.equal(job.result.created, 4)
344
+ for (const key of ['user', 'assistant', 'toolArgs', 'recallSecret']) {
343
345
  const entry = Object.values(loadGlobalIndex().entries).find((item) => item.title === 'Copied ' + key)
344
346
  assert.ok(entry, 'expected a committed entry copied from ' + key)
345
347
  assert.equal(entry.status, 'active')
346
348
  assert.match(entry.content, new RegExp(M[key]))
347
349
  }
350
+ for (const key of ['existing', 'pending']) {
351
+ const held = Object.values(loadGlobalIndex().entries).find((item) => item.title === 'Copied ' + key)
352
+ assert.ok(held, 'expected the index-only echo to be held, not committed')
353
+ assert.equal(held.status, 'pending_review')
354
+ assert.match(held.reviewReason, /missing evidence/)
355
+ }
348
356
  const secretEntry = Object.values(loadGlobalIndex().entries).find((item) => item.title === 'Copied recallSecret')
349
357
  assert.match(JSON.stringify(secretEntry.evidence), new RegExp(M.recallSecret))
350
358
  for (const key of ['secretAuto', 'recallPlain', 'toolTail', 'archived', 'modelOnly', 'invalidDomain']) {
@@ -391,7 +399,8 @@ test('commit accepts model output without provenance or secret filtering', async
391
399
  op: 'create', domain: 'general', track: 'memory', title: 'Credential shaped',
392
400
  content: 'api key: ' + M.credential,
393
401
  confidence: 0.9,
394
- evidence: [{ session_id: 'probe-session', quote: 'q' }],
402
+ // 1 字 quote 不满足 grounding 的 ≥6 字门槛(§4.6)——加长,测试意图不变。
403
+ evidence: [{ session_id: 'probe-session', quote: 'credential quote for grounding' }],
395
404
  },
396
405
  ],
397
406
  }