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,102 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { buildEvidenceCorpus, groundEvidence, groundLinkNote, normalizeEvidenceText } from '../lib/curator-evidence.js'
4
+
5
+ const transcript = [
6
+ 'User: 我们把每周的部署窗口定在周四凌晨两点到四点,只允许灰度发布。',
7
+ 'Assistant: 好的,部署窗口已记录:周四 02:00-04:00,灰度 only。',
8
+ 'User: 另外我的 api_key 是 sk-abcdef1234567890abcdef,别外传。',
9
+ ].join('\n')
10
+
11
+ const corpus = buildEvidenceCorpus([{ id: 's1', transcript }])
12
+
13
+ test('normalizeEvidenceText: NFKC + lowercase + strip whitespace/punctuation/symbols', () => {
14
+ assert.equal(normalizeEvidenceText('部署窗口!Api_Key…'), '部署窗口apikey'.replace('api', 'api'))
15
+ assert.equal(normalizeEvidenceText(' Hello, World! '), 'helloworld')
16
+ assert.equal(normalizeEvidenceText('灰度-发布_v2'), '灰度发布v2')
17
+ })
18
+
19
+ test('groundEvidence: exact quote grounds; punctuation differences do not block', () => {
20
+ const exact = groundEvidence([{ session_id: 's1', quote: '周四凌晨两点到四点' }], corpus)
21
+ assert.equal(exact.status, 'grounded')
22
+ assert.equal(exact.valid.length, 1)
23
+ assert.deepEqual(exact.invalid, [])
24
+ const punctuated = groundEvidence([{ session_id: 's1', quote: '部署窗口定在周四凌晨两点到四点,只允许灰度发布。' }], corpus)
25
+ assert.equal(punctuated.valid.length, 1, 'punctuation is stripped on both sides before matching')
26
+ })
27
+
28
+ test('groundEvidence: a fabricated quote fails grounding', () => {
29
+ const fabricated = groundEvidence([{ session_id: 's1', quote: '周五凌晨五点才部署呢' }], corpus)
30
+ assert.equal(fabricated.valid.length, 0)
31
+ assert.match(fabricated.invalid[0].reason, /not found in session/)
32
+ })
33
+
34
+ test('groundEvidence: session_id must belong to this curation', () => {
35
+ const stranger = groundEvidence([{ session_id: 's-other', quote: '周四凌晨两点到四点' }], corpus)
36
+ assert.equal(stranger.valid.length, 0)
37
+ assert.match(stranger.invalid[0].reason, /not part of this curation/)
38
+ })
39
+
40
+ test('groundEvidence: quotes shorter than 6 normalized chars are rejected', () => {
41
+ const short = groundEvidence([{ session_id: 's1', quote: '周四' }], corpus)
42
+ assert.equal(short.valid.length, 0)
43
+ assert.match(short.invalid[0].reason, /shorter than 6/)
44
+ })
45
+
46
+ test('groundEvidence: ellipsis quotes split into 2-3 segments, each >=4 chars and found', () => {
47
+ const ok = groundEvidence([{ session_id: 's1', quote: '周四凌晨两点到四点……只允许灰度发布' }], corpus)
48
+ assert.equal(ok.valid.length, 1, 'a two-segment ellipsis quote with both halves present grounds')
49
+ const missing = groundEvidence([{ session_id: 's1', quote: '周四凌晨两点到四点……周五凌晨五点开工' }], corpus)
50
+ assert.equal(missing.valid.length, 0)
51
+ assert.match(missing.invalid[0].reason, /ellipsis segment not found/)
52
+ const tooShort = groundEvidence([{ session_id: 's1', quote: '周四凌晨两点到四点……灰度' }], corpus)
53
+ assert.equal(tooShort.valid.length, 0)
54
+ assert.match(tooShort.invalid[0].reason, /ellipsis segment shorter than 4/)
55
+ const tooMany = groundEvidence([{ session_id: 's1', quote: '我们……周四凌晨……灰度发布……记录' }], corpus)
56
+ assert.equal(tooMany.valid.length, 0)
57
+ assert.match(tooMany.invalid[0].reason, /2-3 segments/)
58
+ })
59
+
60
+ test('groundEvidence: quotes are redactSecrets-ed before storage', () => {
61
+ // The transcript above contains a raw key; quoting it verbatim must fail the
62
+ // substring check (the corpus is redacted), and the stored quote is redacted.
63
+ const leaked = groundEvidence([{ session_id: 's1', quote: 'sk-abcdef1234567890abcdef' }], corpus)
64
+ assert.equal(leaked.valid.length, 0, 'a raw secret is not in the redacted corpus, so it cannot ground')
65
+ const mixed = groundEvidence([{ session_id: 's1', quote: 'api_key 是 sk-abcdef1234567890abcdef,别外传' }], corpus)
66
+ assert.equal(mixed.valid.length, 0, 'the secret-bearing span cannot ground either')
67
+ // Direct redaction check: groundEvidence never stores an unredacted secret.
68
+ const unverifiedCorpus = buildEvidenceCorpus([{ id: 'x', transcript: 'token sk-zzzz1111222233334444 inside' }])
69
+ const got = groundEvidence([{ session_id: 'x', quote: 'token sk-zzzz1111222233334444 inside' }], unverifiedCorpus)
70
+ assert.equal(got.valid.length, 0, 'even a self-consistent corpus fails when the quote redacts away')
71
+ })
72
+
73
+ test('groundEvidence: no sessionCorpus means shape-checked unverified, not rejected', () => {
74
+ const got = groundEvidence([{ session_id: 's9', quote: '完全没有出处的引用也可以' }], null)
75
+ assert.equal(got.status, 'unverified')
76
+ assert.equal(got.valid.length, 1)
77
+ assert.deepEqual(got.invalid, [])
78
+ const malformed = groundEvidence([{ session_id: '', quote: '完全没有出处的引用也可以' }], null)
79
+ assert.equal(malformed.valid.length, 0)
80
+ assert.match(malformed.invalid[0].reason, /missing session_id/)
81
+ })
82
+
83
+ test('buildEvidenceCorpus: ids and normalized transcripts; empty ids skipped', () => {
84
+ const c = buildEvidenceCorpus([
85
+ { id: 'a', transcript: '部署窗口!' },
86
+ { id: '', transcript: 'ignored' },
87
+ { id: 'b', text: 'second channel' },
88
+ ])
89
+ assert.deepEqual([...c.ids].sort(), ['a', 'b'])
90
+ assert.equal(c.text.get('a'), '部署窗口')
91
+ assert.equal(c.text.get('b'), 'secondchannel')
92
+ })
93
+
94
+ test('groundLinkNote: 8-500 chars and must contain one grounded quote verbatim', () => {
95
+ const evidence = [{ sessionId: 's1', quote: '周四凌晨两点到四点' }]
96
+ assert.equal(groundLinkNote('部署窗口与告警规则相关联', evidence).ok, false, 'note without the quote is rejected')
97
+ const ok = groundLinkNote('两条记忆都源于部署窗口的约定:周四凌晨两点到四点', evidence)
98
+ assert.equal(ok.ok, true, 'note quoting the evidence grounds')
99
+ assert.equal(groundLinkNote('太短了', evidence).ok, false)
100
+ assert.equal(groundLinkNote('x'.repeat(501), evidence).ok, false)
101
+ assert.equal(groundLinkNote('八到五百之间的正常说明文字', []).ok, false, 'no evidence, no note grounding')
102
+ })
@@ -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
+ })