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.
- package/AGENTS.md +1 -1
- package/README.md +19 -8
- package/README.zh.md +12 -6
- package/docs/contributing.md +16 -3
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -3
- package/lib/activity-log.js +22 -5
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +3 -3
- package/lib/client.js +1610 -117
- package/lib/consolidation-worker.js +68 -17
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1299 -83
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +190 -14
- package/lib/inbound.js +19 -0
- package/lib/index.js +33 -1
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +585 -76
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +128 -7
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +622 -13
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +124 -0
- package/lib/tools.js +230 -16
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/scripts/replay.mjs +573 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +144 -10
- package/tests/consolidation-snapshot.test.mjs +82 -4
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +324 -0
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +223 -0
- package/tests/graph.test.mjs +113 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +446 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +191 -2
|
@@ -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
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
402
|
+
// 1 字 quote 不满足 grounding 的 ≥6 字门槛(§4.6)——加长,测试意图不变。
|
|
403
|
+
evidence: [{ session_id: 'probe-session', quote: 'credential quote for grounding' }],
|
|
395
404
|
},
|
|
396
405
|
],
|
|
397
406
|
}
|
|
@@ -0,0 +1,314 @@
|
|
|
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 A (spec §6.1): curator review mode + risk score + input fixes.
|
|
9
|
+
//
|
|
10
|
+
// One entry of `memories` must land in exactly one of three states — active
|
|
11
|
+
// (committed), pending_review (index-only, held), or skipped — and the
|
|
12
|
+
// decision must follow §4.10: hard gates (conflict / missing evidence /
|
|
13
|
+
// invalid rewrite target) are pending in BOTH modes, `auto` passes clean
|
|
14
|
+
// creates and passes rewrites below the 0.70 risk threshold, `review-all`
|
|
15
|
+
// holds every content op. Unknown ops and invalid merge targets are skipped
|
|
16
|
+
// and never degrade into a create (the §0.2 bug).
|
|
17
|
+
//
|
|
18
|
+
// Same hermetic pattern as tests/curator-commit.test.mjs: './memu.js' is
|
|
19
|
+
// replaced before production modules load; everything else is the real module
|
|
20
|
+
// stack against a temp DSH_MEMU_HOME. Default settings keep the vector route
|
|
21
|
+
// lexical, so nothing here talks to an engine.
|
|
22
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-curator-review-'))
|
|
23
|
+
process.env.DSH_MEMU_HOME = home
|
|
24
|
+
process.env.DSH_MEMU_MEMORY_HOME = home
|
|
25
|
+
|
|
26
|
+
globalThis.__curatorReviewTest = {
|
|
27
|
+
async commit() { return { ok: true } },
|
|
28
|
+
async forget() { return { deleted: 1, segments: 1 } },
|
|
29
|
+
async retrieve() { return { files: [], segments: [], resources: [], raw: null } },
|
|
30
|
+
async listFiles() { return { recall_files: [], resources: [], raw: null } },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const memuSource = 'const bridge = globalThis.__curatorReviewTest;'
|
|
34
|
+
+ 'export const commit = (args) => bridge.commit(args), forget = (args) => bridge.forget(args),'
|
|
35
|
+
+ 'retrieve = (args) => bridge.retrieve(args), listFiles = (args) => bridge.listFiles(args);'
|
|
36
|
+
const hooks = registerHooks({
|
|
37
|
+
resolve(specifier, context, nextResolve) {
|
|
38
|
+
if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(memuSource), shortCircuit: true }
|
|
39
|
+
return nextResolve(specifier, context)
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const curator = await import('../lib/curator.js')
|
|
44
|
+
const state = await import('../lib/state.js')
|
|
45
|
+
const rpc = await import('../lib/rpc.js')
|
|
46
|
+
|
|
47
|
+
test.after(async () => {
|
|
48
|
+
try { hooks.unregister?.() } catch {}
|
|
49
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const TARGET = 'gmem-target'
|
|
53
|
+
const TARGET_ENTRY = {
|
|
54
|
+
track: 'memory', domain: 'general', title: '部署窗口约定',
|
|
55
|
+
content: '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。',
|
|
56
|
+
confidence: 0.9, status: 'active',
|
|
57
|
+
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
|
58
|
+
evidence: [{ sessionId: 'old', quote: 'old quote' }],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function seedGlobalIndex(entries = {}) {
|
|
62
|
+
state.saveGlobalIndex({ version: 1, entries: structuredClone(entries) })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Every test sets the mode it means to exercise; nothing relies on leftover
|
|
66
|
+
// settings from a previous case.
|
|
67
|
+
function setMode(mode) {
|
|
68
|
+
state.updateSettings({ curatorReviewMode: mode })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const cleanCreate = (over = {}) => ({
|
|
72
|
+
op: 'create', domain: 'general', track: 'memory',
|
|
73
|
+
title: '独立的全新事实',
|
|
74
|
+
content: '用户倾向于在正式汇报里先给结论再给细节。',
|
|
75
|
+
confidence: 0.9,
|
|
76
|
+
evidence: [
|
|
77
|
+
{ session_id: 's1', quote: '先说结论再说细节' },
|
|
78
|
+
{ session_id: 's2', quote: '汇报的时候结论在前' },
|
|
79
|
+
],
|
|
80
|
+
...over,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
function entryOf(index, predicate) {
|
|
84
|
+
return Object.values(index.entries).find(predicate) || null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test('auto + clean create with two evidence lands active with reviewRisk 0', async () => {
|
|
88
|
+
seedGlobalIndex({})
|
|
89
|
+
setMode('auto')
|
|
90
|
+
const result = await curator.commitCuratorOutput({ memories: [cleanCreate()] }, { job: { step: 'committing' } })
|
|
91
|
+
assert.equal(result.created, 1)
|
|
92
|
+
assert.equal(result.pending, 0)
|
|
93
|
+
assert.equal(result.files.length, 1)
|
|
94
|
+
// The risk summary is part of the contract: every written entry is counted.
|
|
95
|
+
assert.deepEqual(result.reviewRisk, { max: 0, flagged: 0, reasons: {} })
|
|
96
|
+
const index = state.loadGlobalIndex()
|
|
97
|
+
const entry = entryOf(index, (e) => e.title === '独立的全新事实')
|
|
98
|
+
assert.ok(entry, 'the create must land in the index')
|
|
99
|
+
assert.equal(entry.status, 'active')
|
|
100
|
+
assert.equal(entry.reviewRisk, 0)
|
|
101
|
+
assert.deepEqual(entry.reviewRiskReasons, [])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('auto + create with needs_review still lands active; the flag only feeds the risk score', async () => {
|
|
105
|
+
seedGlobalIndex({})
|
|
106
|
+
setMode('auto')
|
|
107
|
+
const result = await curator.commitCuratorOutput({
|
|
108
|
+
memories: [cleanCreate({ needs_review: true, review_reason: '一次口误,证据偏薄' })],
|
|
109
|
+
}, { job: { step: 'committing' } })
|
|
110
|
+
assert.equal(result.created, 1, 'auto passes a flagged create by default')
|
|
111
|
+
const entry = entryOf(state.loadGlobalIndex(), (e) => e.title === '独立的全新事实')
|
|
112
|
+
assert.equal(entry.status, 'active')
|
|
113
|
+
// needs_review 0.70 + review_reason 0.20 + single-evidence 0.10 was avoided by
|
|
114
|
+
// passing two evidence items, so 0.70 + 0.20 = 0.90 exactly.
|
|
115
|
+
assert.equal(entry.reviewRisk, 0.9)
|
|
116
|
+
assert.ok(entry.reviewRiskReasons.includes('needs_review'))
|
|
117
|
+
assert.ok(entry.reviewRiskReasons.includes('review_reason'))
|
|
118
|
+
assert.equal(entry.reviewReason, undefined, 'an active entry carries no reviewReason')
|
|
119
|
+
assert.equal(result.reviewRisk.flagged, 1, 'the >=0.70 entry is flagged in the summary')
|
|
120
|
+
assert.equal(result.reviewRisk.max, 0.9)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('auto + create with confidence 0.4 still lands active; low confidence only raises the risk score', async () => {
|
|
124
|
+
seedGlobalIndex({})
|
|
125
|
+
setMode('auto')
|
|
126
|
+
const result = await curator.commitCuratorOutput({
|
|
127
|
+
memories: [cleanCreate({ confidence: 0.4 })],
|
|
128
|
+
}, { job: { step: 'committing' } })
|
|
129
|
+
assert.equal(result.created, 1)
|
|
130
|
+
const entry = entryOf(state.loadGlobalIndex(), (e) => e.title === '独立的全新事实')
|
|
131
|
+
assert.equal(entry.status, 'active')
|
|
132
|
+
assert.equal(entry.reviewRisk, 0.7, 'confidence<0.50 is +0.70; the two evidence items avoid the single-evidence bump')
|
|
133
|
+
assert.ok(entry.reviewRiskReasons.includes('confidence<0.50'))
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('auto + merge with needs_review is held pending', async () => {
|
|
137
|
+
seedGlobalIndex({ [TARGET]: TARGET_ENTRY })
|
|
138
|
+
setMode('auto')
|
|
139
|
+
const result = await curator.commitCuratorOutput({
|
|
140
|
+
memories: [{
|
|
141
|
+
op: 'merge', domain: 'general', track: 'memory', target_id: TARGET,
|
|
142
|
+
title: '部署窗口的合并版本',
|
|
143
|
+
content: '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布,全量要等监控确认。',
|
|
144
|
+
confidence: 0.9, needs_review: true, review_reason: '模型标记待复核',
|
|
145
|
+
evidence: [{ session_id: 's3', quote: '部署窗口只在周四凌晨' }],
|
|
146
|
+
}],
|
|
147
|
+
}, { job: { step: 'committing' } })
|
|
148
|
+
assert.equal(result.merged, 0, 'a held merge is not counted as merged')
|
|
149
|
+
assert.equal(result.pending, 1)
|
|
150
|
+
assert.equal(result.files.length, 0, 'pending entries are never committed to the vector store')
|
|
151
|
+
const index = state.loadGlobalIndex()
|
|
152
|
+
const held = entryOf(index, (e) => e.status === 'pending_review')
|
|
153
|
+
assert.ok(held, 'the merge must be held for review')
|
|
154
|
+
assert.equal(held.conflictingWith, TARGET)
|
|
155
|
+
// non-hard pending: the model's own reason wins over the generic fallback.
|
|
156
|
+
assert.equal(held.reviewReason, '模型标记待复核')
|
|
157
|
+
assert.equal(held.reviewRisk, 1.2, 'needs_review 0.70 + merge 0.20 + review_reason 0.20 + single evidence 0.10')
|
|
158
|
+
assert.equal(index.entries[TARGET].status, 'active', 'a held merge never touches the target')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('auto + conflict is pending in both modes with the hard reason', async () => {
|
|
162
|
+
seedGlobalIndex({ [TARGET]: TARGET_ENTRY })
|
|
163
|
+
setMode('auto')
|
|
164
|
+
const result = await curator.commitCuratorOutput({
|
|
165
|
+
memories: [{
|
|
166
|
+
op: 'conflict', domain: 'general', track: 'memory', target_id: TARGET,
|
|
167
|
+
title: '部署窗口的两个说法', content: '两个会话对部署窗口的说法不一致。',
|
|
168
|
+
confidence: 0.9,
|
|
169
|
+
evidence: [{ session_id: 's4', quote: '部署窗口是周四' }, { session_id: 's5', quote: '部署窗口是周五' }],
|
|
170
|
+
}],
|
|
171
|
+
}, { job: { step: 'committing' } })
|
|
172
|
+
assert.equal(result.pending, 1)
|
|
173
|
+
assert.equal(result.created, 0)
|
|
174
|
+
const held = entryOf(state.loadGlobalIndex(), (e) => e.status === 'pending_review')
|
|
175
|
+
assert.ok(held, 'a conflict is always held')
|
|
176
|
+
assert.equal(held.reviewReason, 'conflict')
|
|
177
|
+
assert.equal(held.reviewRisk, 2)
|
|
178
|
+
assert.deepEqual(held.reviewRiskReasons, ['conflict'])
|
|
179
|
+
assert.equal(held.conflictingWith, TARGET)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('auto + missing evidence is pending with the hard reason', async () => {
|
|
183
|
+
seedGlobalIndex({})
|
|
184
|
+
setMode('auto')
|
|
185
|
+
const result = await curator.commitCuratorOutput({
|
|
186
|
+
memories: [{ op: 'create', domain: 'general', track: 'memory', title: '没有证据的说法', content: '模型没给引用。', confidence: 0.9 }],
|
|
187
|
+
}, { job: { step: 'committing' } })
|
|
188
|
+
assert.equal(result.created, 0)
|
|
189
|
+
assert.equal(result.pending, 1)
|
|
190
|
+
const held = entryOf(state.loadGlobalIndex(), (e) => e.status === 'pending_review')
|
|
191
|
+
assert.ok(held)
|
|
192
|
+
assert.equal(held.reviewReason, 'missing evidence')
|
|
193
|
+
assert.equal(held.reviewRisk, 2)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('review-all holds even a clean create', async () => {
|
|
197
|
+
seedGlobalIndex({})
|
|
198
|
+
setMode('review-all')
|
|
199
|
+
const result = await curator.commitCuratorOutput({ memories: [cleanCreate()] }, { job: { step: 'committing' } })
|
|
200
|
+
assert.equal(result.created, 0)
|
|
201
|
+
assert.equal(result.pending, 1)
|
|
202
|
+
const held = entryOf(state.loadGlobalIndex(), (e) => e.status === 'pending_review')
|
|
203
|
+
assert.ok(held)
|
|
204
|
+
// Not a hard gate and the model gave no reason: the generic threshold note.
|
|
205
|
+
assert.equal(held.reviewReason, 'risk threshold')
|
|
206
|
+
assert.equal(held.reviewRisk, 0, 'a clean hold carries risk 0, the mode alone caused the hold')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('merge with a nonexistent target is skipped and never degrades into a create', async () => {
|
|
210
|
+
seedGlobalIndex({ [TARGET]: TARGET_ENTRY })
|
|
211
|
+
setMode('auto')
|
|
212
|
+
const result = await curator.commitCuratorOutput({
|
|
213
|
+
memories: [{
|
|
214
|
+
op: 'merge', domain: 'general', track: 'memory', target_id: 'gmem-missing',
|
|
215
|
+
title: '没有落点的合并', content: '这段内容绝不能变成一条新记忆。',
|
|
216
|
+
confidence: 0.9,
|
|
217
|
+
evidence: [{ session_id: 's6', quote: '合并的目标丢了' }],
|
|
218
|
+
}],
|
|
219
|
+
}, { job: { step: 'committing' } })
|
|
220
|
+
assert.equal(result.created, 0, 'the §0.2 bug: a skipped merge must not continue as a create')
|
|
221
|
+
assert.equal(result.merged, 0)
|
|
222
|
+
assert.equal(result.pending, 0)
|
|
223
|
+
assert.equal(result.files.length, 0)
|
|
224
|
+
assert.equal(result.skipped.length, 1)
|
|
225
|
+
assert.match(result.skipped[0].reason, /target not found: gmem-missing/)
|
|
226
|
+
const index = state.loadGlobalIndex()
|
|
227
|
+
assert.equal(entryOf(index, (e) => e.title === '没有落点的合并'), null, 'no new entry was written')
|
|
228
|
+
assert.deepEqual(Object.keys(index.entries), [TARGET], 'the index gained nothing')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('an unknown op is skipped instead of being forced into a create', async () => {
|
|
232
|
+
seedGlobalIndex({})
|
|
233
|
+
setMode('auto')
|
|
234
|
+
const result = await curator.commitCuratorOutput({
|
|
235
|
+
memories: [
|
|
236
|
+
{ op: 'frobnicate', domain: 'general', track: 'memory', title: '未知操作', content: '模型写错了 op 名。', confidence: 0.9, evidence: [{ session_id: 's7', quote: '未知' }] },
|
|
237
|
+
// link 在 当前版本已进 OPS_ALLOWLIST(F1 落地),不再是 unknown op 的代表;
|
|
238
|
+
// 这里改用真正不在名单里的 op,测试意图不变:未知 op 不得默认 create。
|
|
239
|
+
{ op: 'delete', domain: 'general', track: 'memory', title: '未接线的关联', content: '模型写错了另一个 op 名。', confidence: 0.9, evidence: [{ session_id: 's8', quote: '删除操作的原话记录' }] },
|
|
240
|
+
],
|
|
241
|
+
}, { job: { step: 'committing' } })
|
|
242
|
+
assert.equal(result.created, 0, 'unknown ops must not default to create')
|
|
243
|
+
assert.equal(result.skipped.length, 2)
|
|
244
|
+
assert.ok(result.skipped.every((item) => /unknown op/.test(item.reason)))
|
|
245
|
+
assert.deepEqual(Object.keys(state.loadGlobalIndex().entries), [], 'nothing was written')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('setSettings rejects a bogus curatorReviewMode and accepts review-all', async () => {
|
|
249
|
+
await assert.rejects(
|
|
250
|
+
() => rpc.dispatch({}, 'setSettings', { patch: { curatorReviewMode: 'bogus' } }),
|
|
251
|
+
/invalid setting/,
|
|
252
|
+
'the enum gate must reject unknown modes',
|
|
253
|
+
)
|
|
254
|
+
const accepted = await rpc.dispatch({}, 'setSettings', { patch: { curatorReviewMode: 'review-all' } })
|
|
255
|
+
assert.equal(accepted.curatorReviewMode, 'review-all', 'the allowlisted mode persists')
|
|
256
|
+
setMode('auto')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('entries written before this version carry no reviewRisk and still read cleanly', async () => {
|
|
260
|
+
seedGlobalIndex({ [TARGET]: TARGET_ENTRY })
|
|
261
|
+
setMode('auto')
|
|
262
|
+
const result = await curator.commitCuratorOutput({
|
|
263
|
+
memories: [{
|
|
264
|
+
op: 'supersede', domain: 'general', track: 'memory', target_id: TARGET,
|
|
265
|
+
title: '部署窗口的替代版本', content: '部署窗口调整为周四凌晨的整段灰度时段。',
|
|
266
|
+
confidence: 0.92,
|
|
267
|
+
evidence: [{ session_id: 's9', quote: '窗口改成周四凌晨' }, { session_id: 's10', quote: '整段灰度时段一次性放开' }],
|
|
268
|
+
}],
|
|
269
|
+
}, { job: { step: 'committing' } })
|
|
270
|
+
assert.equal(result.superseded, 1)
|
|
271
|
+
const index = state.loadGlobalIndex()
|
|
272
|
+
const oldEntry = index.entries[TARGET]
|
|
273
|
+
assert.ok(oldEntry, 'the old entry is still there (superseded, never deleted)')
|
|
274
|
+
assert.equal(Object.hasOwn(oldEntry, 'reviewRisk'), false, 'the old entry keeps its old shape')
|
|
275
|
+
assert.equal(Object.hasOwn(oldEntry, 'reviewRiskReasons'), false)
|
|
276
|
+
assert.equal(oldEntry.status, 'superseded')
|
|
277
|
+
// Reading the mixed index through the default-value convention must not throw.
|
|
278
|
+
for (const entry of Object.values(index.entries)) {
|
|
279
|
+
assert.equal(typeof (entry.reviewRisk ?? 0), 'number')
|
|
280
|
+
assert.ok(Array.isArray(entry.reviewRiskReasons ?? []))
|
|
281
|
+
}
|
|
282
|
+
const fresh = entryOf(index, (e) => e.status === 'active')
|
|
283
|
+
assert.ok(fresh, 'the replacement landed active (risk 0.20 < 0.70)')
|
|
284
|
+
assert.equal(fresh.reviewRisk, 0.2, 'supersede adds 0.20; two evidence items avoid the single-evidence bump')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('reviewDecision unit: absorb risk and skip reasons follow §4.10 verbatim', () => {
|
|
288
|
+
const index = { entries: { 'gmem-a': { status: 'active' } } }
|
|
289
|
+
// absorb with >3 sources: +0.20; clean otherwise → auto passes at 0.20.
|
|
290
|
+
const absorb = curator.reviewDecision({
|
|
291
|
+
m: { confidence: 0.9, source_ids: ['a', 'b', 'c', 'd'] },
|
|
292
|
+
op: 'absorb', evidence: [{ session_id: 's', quote: 'q' }, { session_id: 's2', quote: 'q2' }],
|
|
293
|
+
targetId: 'gmem-a', index, mode: 'auto',
|
|
294
|
+
})
|
|
295
|
+
assert.equal(absorb.review, false)
|
|
296
|
+
assert.equal(absorb.risk, 0.2)
|
|
297
|
+
assert.ok(absorb.reasons.includes('absorb>3 sources'))
|
|
298
|
+
// absorb with a missing target is a skip, never a pending.
|
|
299
|
+
const noTarget = curator.reviewDecision({
|
|
300
|
+
m: { confidence: 0.9 }, op: 'absorb',
|
|
301
|
+
evidence: [{ session_id: 's', quote: 'q' }],
|
|
302
|
+
targetId: '', index, mode: 'auto',
|
|
303
|
+
})
|
|
304
|
+
assert.equal(noTarget.skip, true)
|
|
305
|
+
assert.match(noTarget.reason, /target not found: \(empty\)/)
|
|
306
|
+
// Hard gates cap at risk 2 regardless of mode.
|
|
307
|
+
const conflictReviewAll = curator.reviewDecision({
|
|
308
|
+
m: {}, op: 'conflict', evidence: [{ session_id: 's', quote: 'q' }],
|
|
309
|
+
targetId: 'gmem-a', index, mode: 'review-all',
|
|
310
|
+
})
|
|
311
|
+
assert.equal(conflictReviewAll.review, true)
|
|
312
|
+
assert.equal(conflictReviewAll.risk, 2)
|
|
313
|
+
assert.equal(conflictReviewAll.hard, true)
|
|
314
|
+
})
|
|
@@ -90,7 +90,8 @@ test('a secret stays out of the anchor but is still reachable through recall', a
|
|
|
90
90
|
assert.equal(anchor.text.includes('PINNED-SECRET-BODY'), false, 'pinning does not override secrecy')
|
|
91
91
|
assert.equal(anchor.text.includes(secretId), false)
|
|
92
92
|
assert.equal(anchor.text.includes(pinnedSecretId), false)
|
|
93
|
-
assert.ok(anchor.text.includes('
|
|
93
|
+
assert.ok(anchor.text.includes('deploy window'), 'ordinary memories still show up as title cards')
|
|
94
|
+
assert.equal(anchor.text.includes('SHARED-BODY'), false, 'the anchor never carries the body')
|
|
94
95
|
|
|
95
96
|
const recalled = await memory.privateRecall({ exec, query: 'leave company diagnosis deploy window', topK: 5, relevanceThreshold: 0 })
|
|
96
97
|
assert.ok(recalled.files.some((file) => file.name === secretId), 'a secret is a recall target, not a hidden row')
|