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,446 @@
|
|
|
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
|
+
// Fact-level dedup .
|
|
9
|
+
// No engine, no download: the MemU boundary is replaced before any production
|
|
10
|
+
// module is imported, exactly like tests/managed-memory.test.mjs does.
|
|
11
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-recall-dedup-'))
|
|
12
|
+
process.env.DSH_MEMU_HOME = home
|
|
13
|
+
process.env.DSH_MEMU_MEMORY_HOME = home
|
|
14
|
+
|
|
15
|
+
let semanticFiles = []
|
|
16
|
+
const commitCalls = []
|
|
17
|
+
globalThis.__recallDedupTest = {
|
|
18
|
+
async commit(args) { commitCalls.push(args); return { ok: true } },
|
|
19
|
+
async retrieve() {
|
|
20
|
+
return { files: semanticFiles.map((name) => ({ name })), segments: [], resources: [], raw: null }
|
|
21
|
+
},
|
|
22
|
+
async forget() { return { deleted: 1, segments: 1 } },
|
|
23
|
+
async listFiles() { return { recall_files: [], resources: [], raw: null } },
|
|
24
|
+
}
|
|
25
|
+
const engineSource = 'const bridge = globalThis.__recallDedupTest; export const commit = bridge.commit, retrieve = bridge.retrieve, forget = bridge.forget, listFiles = bridge.listFiles;'
|
|
26
|
+
const hooks = registerHooks({
|
|
27
|
+
resolve(specifier, context, nextResolve) {
|
|
28
|
+
if (specifier === './memu.js') {
|
|
29
|
+
return { url: 'data:text/javascript,' + encodeURIComponent(engineSource), shortCircuit: true }
|
|
30
|
+
}
|
|
31
|
+
return nextResolve(specifier, context)
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const state = await import('../lib/state.js')
|
|
36
|
+
const paths = await import('../lib/paths.js')
|
|
37
|
+
const memory = await import('../lib/memory.js')
|
|
38
|
+
const activity = await import('../lib/activity-log.js')
|
|
39
|
+
const similarity = await import('../lib/text-similarity.js')
|
|
40
|
+
|
|
41
|
+
const BODY_A = '项目部署约定:每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布,全量上线要等值班的人确认监控没有异常之后才能继续,紧急修复也要先在群里报备。'
|
|
42
|
+
const BODY_B = BODY_A.slice(0, 55) + '变更流程另见发布手册第四章,联系人轮值表每月更新一次,遇到节假日的调整以公告为准,请留意。'
|
|
43
|
+
const REAL_A = '每个月的报销单要在最后一个工作日之前提交,超期的票据并入下月批次,特殊情况提前找主管签批。'
|
|
44
|
+
const CLUSTER_TITLES = ['部署窗口约定', '部署窗口的约定', '上线窗口约定', '窗口约定', '部署约定', '发布窗口约定',
|
|
45
|
+
'灰度窗口约定', '窗口时间约定', '部署时段约定', '上线时段约定', '发布时段约定', '灰度发布约定']
|
|
46
|
+
|
|
47
|
+
let serial = 0
|
|
48
|
+
function entry(title, content, extra = {}) {
|
|
49
|
+
return { status: 'active', title, content, layer: 'long_term', strength: 1,
|
|
50
|
+
createdAt: '2026-10-01T00:00:00.000Z', updatedAt: '2026-10-01T00:00:00.000Z', ...extra }
|
|
51
|
+
}
|
|
52
|
+
function fixture(entries = {}) {
|
|
53
|
+
serial += 1
|
|
54
|
+
const sessionId = 'dedup-' + serial
|
|
55
|
+
const cwd = path.join(home, 'project-' + serial)
|
|
56
|
+
const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
|
|
57
|
+
state.setSessionBinding(sessionId, { useLibrary: true, useGlobal: true, domainOverride: 'coding' })
|
|
58
|
+
state.savePrivateIndex(binding.libraryId, { entries })
|
|
59
|
+
return { sessionId, libraryId: binding.libraryId, exec: { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } } }
|
|
60
|
+
}
|
|
61
|
+
const clusterEntries = (titles) => Object.fromEntries(titles.map((title, i) => ['mem-dup' + (i + 1), entry(title, BODY_A)]))
|
|
62
|
+
|
|
63
|
+
test.beforeEach(() => {
|
|
64
|
+
semanticFiles = []
|
|
65
|
+
commitCalls.length = 0
|
|
66
|
+
state.updateSettings({ memoryBackend: 'api', vectorSource: 'api', relevanceThreshold: 0, privateTopK: 5 })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('shared metric module: constants and preprocessing stay in one place', () => {
|
|
70
|
+
assert.equal(similarity.DUP_DICE, 0.82)
|
|
71
|
+
assert.equal(similarity.TOPIC_DICE, 0.55)
|
|
72
|
+
assert.equal(similarity.DUP_REJECT_DICE, 0.90)
|
|
73
|
+
assert.equal(similarity.dice(similarity.bigrams(''), similarity.bigrams('x')), 0)
|
|
74
|
+
// Same preprocessing as the old findConflict ruler: punctuation and case are noise.
|
|
75
|
+
assert.equal(similarity.dice(similarity.bigrams('Hello, WORLD!'), similarity.bigrams('hello world')), 1)
|
|
76
|
+
assert.ok(similarity.lengthGap('aaaa', 'aa') <= 0.5)
|
|
77
|
+
assert.ok(similarity.lengthGap('aaaaa', 'aa') > 0.5)
|
|
78
|
+
// Length pruning: a pair this unequal can never reach the reject band, so it is
|
|
79
|
+
// reported as 0 without building bigrams at all.
|
|
80
|
+
assert.equal(similarity.similarTexts('a'.repeat(100), 'b'), 0)
|
|
81
|
+
assert.equal(similarity.similarTexts(BODY_A, BODY_A), 1)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('three near-duplicates leave one seat and both real memories stay in top5', async () => {
|
|
85
|
+
const f = fixture({
|
|
86
|
+
...clusterEntries(CLUSTER_TITLES.slice(0, 3)),
|
|
87
|
+
'mem-realA': entry('报销截止日', REAL_A),
|
|
88
|
+
'mem-realB': entry('值班接龙安排', '每周四凌晨的值班安排要提前一天在群里接龙,缺席要找替班的人。'),
|
|
89
|
+
})
|
|
90
|
+
semanticFiles = ['mem-dup1', 'mem-dup2', 'mem-dup3', 'mem-realA', 'mem-realB']
|
|
91
|
+
const result = await memory.privateRecall({ exec: f.exec, query: '部署窗口', topK: 5 })
|
|
92
|
+
// Assertions land on concrete ids (§1.10): exactly one cluster member survives,
|
|
93
|
+
// and both real memories are recalled, not crowded out.
|
|
94
|
+
assert.deepEqual(result.files.map((file) => file.name), ['mem-dup1', 'mem-realA', 'mem-realB'])
|
|
95
|
+
assert.deepEqual(result.dedup.collapsed, [{ kept: 'mem-dup1', dropped: ['mem-dup2', 'mem-dup3'] }])
|
|
96
|
+
assert.equal(result.dedup.clusterCount, 1)
|
|
97
|
+
// The observation signal must exist and name the survivors (§13.5 观测).
|
|
98
|
+
const event = activity.listEvents({ kind: 'recall' }).find((e) => e.detail?.clusterCount >= 1)
|
|
99
|
+
assert.ok(event, 'a recall event with dedup detail must be recorded')
|
|
100
|
+
assert.deepEqual(event.detail.collapsed, [{ kept: 'mem-dup1', dropped: ['mem-dup2', 'mem-dup3'] }])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('a vector leg returning 12 same-cluster hits cannot starve real candidates (fold ①)', () => {
|
|
104
|
+
const titles = CLUSTER_TITLES
|
|
105
|
+
const index = { entries: { ...clusterEntries(titles), 'mem-realA': entry('报销截止日', REAL_A), 'mem-realB': entry('独立事实', '另一件完全不同的事情,关于差旅住宿发票的开具流程与注意事项。') } }
|
|
106
|
+
const fused = memory.fuseRecallCandidates({
|
|
107
|
+
local: [],
|
|
108
|
+
semantic: [...titles.map((_, i) => ({ name: 'mem-dup' + (i + 1) })), { name: 'mem-realA' }, { name: 'mem-realB' }],
|
|
109
|
+
index,
|
|
110
|
+
limit: memory.hybridCandidateLimit(3), // 12: without folding the reals are cut here
|
|
111
|
+
})
|
|
112
|
+
assert.equal(fused.debug.candidateLimit, 12)
|
|
113
|
+
assert.equal(fused.files.length, 3)
|
|
114
|
+
assert.ok(fused.files.some((file) => file.name === 'mem-realA'), 'real candidate must survive the cap')
|
|
115
|
+
assert.ok(fused.files.some((file) => file.name === 'mem-realB'))
|
|
116
|
+
assert.equal(fused.files.filter((file) => file.name.startsWith('mem-dup')).length, 1)
|
|
117
|
+
assert.deepEqual(fused.debug.collapsed, [{ kept: 'mem-dup1', dropped: titles.slice(1).map((_, i) => 'mem-dup' + (i + 2)) }])
|
|
118
|
+
assert.equal(fused.files[0].mergedTogether, undefined, 'fold ① is candidate-level; mergedTogether belongs to fold ②')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('the same fact arriving on both legs is folded once, keeping the highest-rrf id (fold ②)', () => {
|
|
122
|
+
const index = { entries: {
|
|
123
|
+
'mem-dup1': entry('部署窗口约定', BODY_A),
|
|
124
|
+
'mem-dup3': entry('上线窗口约定', BODY_A),
|
|
125
|
+
'mem-other': entry('独立事实', '另一件完全不同的事情,关于差旅住宿发票的开具流程与注意事项。'),
|
|
126
|
+
} }
|
|
127
|
+
const fused = memory.fuseRecallCandidates({
|
|
128
|
+
local: [{ name: 'mem-dup1' }, { name: 'mem-other' }],
|
|
129
|
+
semantic: [{ name: 'mem-dup3' }],
|
|
130
|
+
index,
|
|
131
|
+
limit: 12,
|
|
132
|
+
})
|
|
133
|
+
assert.deepEqual(fused.files.map((file) => file.name), ['mem-dup1', 'mem-other'])
|
|
134
|
+
assert.deepEqual(fused.files[0].mergedTogether, ['mem-dup3'])
|
|
135
|
+
assert.deepEqual(fused.debug.collapsed, [{ kept: 'mem-dup1', dropped: ['mem-dup3'] }])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('a write with dice ≈ 0.93 is held as a near_duplicate instead of being lost', async () => {
|
|
139
|
+
const f = fixture()
|
|
140
|
+
const first = await memory.privateRemember({ exec: f.exec, title: '部署窗口约定', content: BODY_A })
|
|
141
|
+
const measured = similarity.dice(similarity.bigrams('部署窗口的约定 ' + BODY_A), similarity.bigrams('部署窗口约定 ' + BODY_A))
|
|
142
|
+
assert.ok(measured >= 0.90, 'fixture pair must sit in the reject band, got ' + measured)
|
|
143
|
+
const second = await memory.privateRemember({ exec: f.exec, title: '部署窗口的约定', content: BODY_A })
|
|
144
|
+
assert.equal(second.deduped, true)
|
|
145
|
+
assert.equal(second.pending, true)
|
|
146
|
+
assert.equal(second.existingId, first.id)
|
|
147
|
+
assert.match(second.hint, /memu_private_update/)
|
|
148
|
+
const index = state.loadPrivateIndex(f.libraryId)
|
|
149
|
+
assert.equal(Object.keys(index.entries).length, 2, 'the new text is held, not silently dropped')
|
|
150
|
+
assert.equal(index.entries[second.id].status, 'pending_review')
|
|
151
|
+
assert.equal(index.entries[second.id].reviewReason, 'near_duplicate')
|
|
152
|
+
assert.equal(index.entries[first.id].status, 'active', 'the original must be untouched')
|
|
153
|
+
const written = activity.listEvents({ kind: 'write' })[0]
|
|
154
|
+
assert.match(written.message, /拒绝重复写入/)
|
|
155
|
+
assert.equal(written.detail.existingId, first.id)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('a write with dice ≈ 0.60 creates a new entry tagged duplicateOf', async () => {
|
|
159
|
+
const f = fixture()
|
|
160
|
+
const first = await memory.privateRemember({ exec: f.exec, title: '部署窗口约定', content: BODY_A })
|
|
161
|
+
const measured = similarity.dice(similarity.bigrams('部署安排与变更 ' + BODY_B), similarity.bigrams('部署窗口约定 ' + BODY_A))
|
|
162
|
+
assert.ok(measured >= 0.55 && measured < 0.90, 'fixture pair must sit in the tag band, got ' + measured)
|
|
163
|
+
const second = await memory.privateRemember({ exec: f.exec, title: '部署安排与变更', content: BODY_B })
|
|
164
|
+
assert.notEqual(second.id, first.id)
|
|
165
|
+
assert.equal(second.deduped, undefined)
|
|
166
|
+
assert.equal(second.entry.duplicateOf, first.id)
|
|
167
|
+
assert.equal(second.entry.status, 'active')
|
|
168
|
+
const index = state.loadPrivateIndex(f.libraryId)
|
|
169
|
+
assert.equal(Object.keys(index.entries).length, 2, 'a tagged entry is still written')
|
|
170
|
+
assert.equal(index.entries[second.id].duplicateOf, first.id)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// Length-gap bypass : padding a short
|
|
174
|
+
// memory into a much longer rewrite used to skip the comparison entirely — the
|
|
175
|
+
// prune reported 0 and the duplicate landed. Two containment probes close it.
|
|
176
|
+
test('containment probes catch length-disparate pairs that plain dice cannot see', () => {
|
|
177
|
+
const short = '他只喝黑咖啡不加糖'
|
|
178
|
+
// Probe 1 fires: the short cleaned text is a verbatim substring of the long one.
|
|
179
|
+
const appended = short + ',从小就是这样,早上晚上各一杯,出差也自带豆子。'
|
|
180
|
+
assert.ok(similarity.lengthGap(short, appended) > similarity.LENGTH_PRUNE_RATIO)
|
|
181
|
+
assert.equal(similarity.similarTexts(short, appended), similarity.CONTAINMENT_FLOOR)
|
|
182
|
+
// Probe 2 fires: every short-side bigram occurs in the long text, but the short
|
|
183
|
+
// text itself is not contiguous in it (咖啡从不… breaks the verbatim containment;
|
|
184
|
+
// the spanning 啡不 junction appears in the tail clause instead).
|
|
185
|
+
const reworded = '他只喝黑咖啡,从不加糖也不加奶,只喝黑咖啡不加糖。'
|
|
186
|
+
assert.ok(!similarity.clean(reworded).includes(similarity.clean(short)), 'fixture must not be a verbatim containment')
|
|
187
|
+
assert.equal(similarity.similarTexts(short, reworded), similarity.CORE_COVER_FLOOR)
|
|
188
|
+
// Unrelated long text: probe fails fast on the first absent bigram.
|
|
189
|
+
assert.equal(similarity.similarTexts(short, '图书馆周三下午闭馆整理新到的书,借阅上限是五本。'), 0)
|
|
190
|
+
// Probe budget: a tiny short side has too little signal to reject a write on.
|
|
191
|
+
assert.equal(similarity.similarTexts('a'.repeat(100), 'b'), 0)
|
|
192
|
+
// Within the prune ratio nothing changed: dice stays the only ruler.
|
|
193
|
+
assert.equal(similarity.similarTexts(BODY_A, BODY_A), 1)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('a short memory padded into a much longer rewrite is refused, not slipped past the gate', async () => {
|
|
197
|
+
const f = fixture()
|
|
198
|
+
const first = await memory.privateRemember({ exec: f.exec, content: '他只喝黑咖啡' })
|
|
199
|
+
const padded = '他只喝黑咖啡。他从小到大就只喝黑咖啡,早上要喝一杯,晚上也要喝一杯,加班的时候更要喝,出去吃饭也只点黑咖啡,从来不加糖也从不加奶,换过很多家咖啡馆都一样。'
|
|
200
|
+
// Prove this is a genuine prune-band bypass pair: plain dice sits far below
|
|
201
|
+
// even the tag band, and the raw length gap exceeds the prune ratio.
|
|
202
|
+
const unit1 = '他只喝黑咖啡 他只喝黑咖啡'
|
|
203
|
+
const unit2 = padded + ' ' + padded
|
|
204
|
+
const plainDice = similarity.dice(similarity.bigrams(unit1), similarity.bigrams(unit2))
|
|
205
|
+
assert.ok(plainDice < 0.55, 'plain dice must miss this pair, got ' + plainDice)
|
|
206
|
+
assert.ok(similarity.lengthGap(unit1, unit2) > similarity.LENGTH_PRUNE_RATIO, 'the pair must sit in the pruned band')
|
|
207
|
+
const second = await memory.privateRemember({ exec: f.exec, content: padded })
|
|
208
|
+
assert.equal(second.deduped, true)
|
|
209
|
+
assert.equal(second.pending, true)
|
|
210
|
+
assert.equal(second.existingId, first.id)
|
|
211
|
+
assert.ok(second.similarity >= similarity.DUP_REJECT_DICE, 'the probe floor lands in the reject band, got ' + second.similarity)
|
|
212
|
+
const index = state.loadPrivateIndex(f.libraryId)
|
|
213
|
+
assert.equal(Object.keys(index.entries).length, 2, 'the padded rewrite is held for review, not written active')
|
|
214
|
+
assert.equal(index.entries[second.id].status, 'pending_review')
|
|
215
|
+
assert.equal(index.entries[second.id].reviewReason, 'near_duplicate')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('a genuinely different longer fact against a short entry is still written', async () => {
|
|
219
|
+
const f = fixture()
|
|
220
|
+
const first = await memory.privateRemember({ exec: f.exec, content: '他只喝黑咖啡' })
|
|
221
|
+
const second = await memory.privateRemember({ exec: f.exec, content: '图书馆周三下午闭馆整理新书,借阅上限是五本,超期按天收取滞纳金,续借要在到期日之前操作。' })
|
|
222
|
+
assert.equal(second.deduped, undefined)
|
|
223
|
+
assert.notEqual(second.id, first.id)
|
|
224
|
+
assert.equal(second.entry.duplicateOf, undefined, 'an unrelated fact is not tagged as a duplicate')
|
|
225
|
+
const index = state.loadPrivateIndex(f.libraryId)
|
|
226
|
+
assert.equal(Object.keys(index.entries).length, 2)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('a knownBy-hidden near-duplicate never leaks its id', async () => {
|
|
230
|
+
const f = fixture()
|
|
231
|
+
state.setSessionBinding(f.sessionId, { memoryContext: { characterId: 'hero', knownBy: ['someone-else'] } })
|
|
232
|
+
const first = await memory.privateRemember({
|
|
233
|
+
exec: f.exec, title: '隐藏部署甲', content: BODY_A,
|
|
234
|
+
context: { characterId: 'hero', knownBy: ['someone-else'] },
|
|
235
|
+
})
|
|
236
|
+
const second = await memory.privateRemember({
|
|
237
|
+
exec: f.exec, title: '隐藏部署乙', content: BODY_A.replace('灰度发布', '白名单发布'),
|
|
238
|
+
context: { characterId: 'hero', knownBy: ['someone-else'] },
|
|
239
|
+
})
|
|
240
|
+
assert.equal(second.deduped, true)
|
|
241
|
+
assert.equal(second.pending, true)
|
|
242
|
+
assert.equal(second.existingId, undefined, 'a hidden duplicate must not expose its id')
|
|
243
|
+
assert.equal(second.similarity, undefined, 'a hidden duplicate must not expose its similarity')
|
|
244
|
+
assert.equal(second.similarityBand, '0.90+')
|
|
245
|
+
const index = state.loadPrivateIndex(f.libraryId)
|
|
246
|
+
assert.equal(memory.visibleToSession(index.entries[first.id], f.sessionId), false)
|
|
247
|
+
assert.equal(index.entries[second.id].status, 'pending_review')
|
|
248
|
+
assert.equal(index.entries[second.id].reviewReason, 'near_duplicate')
|
|
249
|
+
assert.equal(index.entries[second.id].conflictingWith, null, 'the hidden id must not be written into the held entry')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('allowDuplicate: true writes a second independent observation without a tag', async () => {
|
|
253
|
+
const f = fixture()
|
|
254
|
+
const first = await memory.privateRemember({ exec: f.exec, title: '部署窗口约定', content: BODY_A })
|
|
255
|
+
const second = await memory.privateRemember({ exec: f.exec, title: '部署窗口的约定', content: BODY_A, allowDuplicate: true })
|
|
256
|
+
assert.notEqual(second.id, first.id)
|
|
257
|
+
assert.equal(second.deduped, undefined)
|
|
258
|
+
assert.equal(second.entry.duplicateOf, undefined)
|
|
259
|
+
const index = state.loadPrivateIndex(f.libraryId)
|
|
260
|
+
assert.equal(Object.keys(index.entries).length, 2)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('same title + clearly different body still lands as pending_review, not deduped', async () => {
|
|
264
|
+
const f = fixture()
|
|
265
|
+
const first = await memory.privateRemember({ exec: f.exec, title: 'ZK', content: 'alpha body text one' })
|
|
266
|
+
const second = await memory.privateRemember({ exec: f.exec, title: 'ZK', content: 'alpha body text two' })
|
|
267
|
+
// The conflict gate must win: a contradictory same-title rewording is a held
|
|
268
|
+
// claim for review, never a silent duplicate swallow (§13.5 顺序约束).
|
|
269
|
+
assert.equal(second.conflict, first.id)
|
|
270
|
+
assert.equal(second.entry.status, 'pending_review')
|
|
271
|
+
assert.equal(second.entry.duplicateOf, undefined)
|
|
272
|
+
assert.equal(second.deduped, undefined)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('negation conflicts survive the duplicate gate byte-for-byte', async () => {
|
|
276
|
+
const f = fixture()
|
|
277
|
+
const first = await memory.privateRemember({ exec: f.exec, title: '行踪', content: '周五下午他不在家' })
|
|
278
|
+
const second = await memory.privateRemember({ exec: f.exec, title: '行踪记录', content: '周五下午他在家' })
|
|
279
|
+
const measured = similarity.dice(similarity.bigrams('周五下午他不在家'), similarity.bigrams('周五下午他在家'))
|
|
280
|
+
assert.ok(measured >= 0.55, 'the pair must clear the topic band so the legacy branch fires')
|
|
281
|
+
assert.equal(second.conflict, first.id)
|
|
282
|
+
assert.match(second.entry.reviewReason, /说法相反/)
|
|
283
|
+
assert.equal(second.entry.status, 'pending_review')
|
|
284
|
+
assert.equal(second.deduped, undefined)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('the lexical route folds its cluster at the ranking stage and reports it (fold ③)', async () => {
|
|
288
|
+
const f = fixture({
|
|
289
|
+
...clusterEntries(CLUSTER_TITLES.slice(0, 3)),
|
|
290
|
+
'mem-realA': entry('部署值班安排', '每周四凌晨的部署值班要提前一天在群里接龙,缺席要找替班的人。'),
|
|
291
|
+
})
|
|
292
|
+
state.updateSettings({ memoryBackend: 'agent' })
|
|
293
|
+
try {
|
|
294
|
+
// No semantic leg runs on the agent backend, so folds ①/② never see these —
|
|
295
|
+
// mmrSelect's hard skip is the only seat limit left for the lexical route.
|
|
296
|
+
const result = await memory.privateRecall({ exec: f.exec, query: '部署窗口', topK: 5 })
|
|
297
|
+
assert.deepEqual(result.files.map((file) => file.name), ['mem-dup1', 'mem-realA'])
|
|
298
|
+
assert.deepEqual(result.dedup.collapsed, [{ kept: 'mem-dup1', dropped: ['mem-dup2', 'mem-dup3'] }])
|
|
299
|
+
assert.equal(result.fusion, undefined, 'the lexical route has no fusion stage')
|
|
300
|
+
const event = activity.listEvents({ kind: 'recall' }).find((e) => e.detail?.clusterCount >= 1)
|
|
301
|
+
assert.ok(event, 'the lexical fold must be observable too')
|
|
302
|
+
} finally {
|
|
303
|
+
state.updateSettings({ memoryBackend: 'api' })
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('an entry tagged duplicateOf is recalled below the original it copies', async () => {
|
|
308
|
+
const f = fixture({
|
|
309
|
+
'mem-x': entry('咖啡偏好', '他只喝黑咖啡,不加糖。'),
|
|
310
|
+
'mem-y': entry('咖啡口味', '他只喝拿铁,加燕麦奶。', { duplicateOf: 'mem-x', updatedAt: '2026-10-02T00:00:00.000Z' }),
|
|
311
|
+
})
|
|
312
|
+
// Both legs rank mem-y first (fresher + listed first): without the 0.7 factor
|
|
313
|
+
// the tagged copy would outrank the memory it duplicates.
|
|
314
|
+
semanticFiles = ['mem-y', 'mem-x']
|
|
315
|
+
const result = await memory.privateRecall({ exec: f.exec, query: '咖啡', topK: 5 })
|
|
316
|
+
assert.deepEqual(result.files.map((file) => file.name), ['mem-x', 'mem-y'])
|
|
317
|
+
assert.equal(result.files[1].entry.duplicateOf, 'mem-x')
|
|
318
|
+
assert.equal(result.dedup.clusterCount, 0, 'the pair is below the fold threshold; only the down-weight applies')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------- update/review gate
|
|
322
|
+
// 写入侧近似门同样盖住 privateUpdate 与 review accept:
|
|
323
|
+
// 把旧记忆改写成与另一条几乎相同、或接受一条与库内第三条几乎相同的复核,都不能
|
|
324
|
+
// 从这两条路径绕开写入侧的门。口径与 remember 相同:≥ 0.90 拒绝,[0.55, 0.90) 打标。
|
|
325
|
+
const BODY_DEPLOY = '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布,全量上线要等值班的人确认监控平稳之后才能继续。'
|
|
326
|
+
const BODY_COFFEE = '茶水间的咖啡机每天下午三点以后免费使用,用完要自己清洗滤网并补充咖啡豆。'
|
|
327
|
+
|
|
328
|
+
test('updating a memory into a near-duplicate of another entry is refused, not merged', async () => {
|
|
329
|
+
const f = fixture({
|
|
330
|
+
'mem-host': entry('部署窗口约定', BODY_DEPLOY),
|
|
331
|
+
'mem-t': entry('占位记录', '这条只是个占位,与部署无关。'),
|
|
332
|
+
})
|
|
333
|
+
await assert.rejects(
|
|
334
|
+
memory.privateUpdate({ exec: f.exec, libraryId: f.libraryId, id: 'mem-t', content: BODY_DEPLOY }),
|
|
335
|
+
(error) => {
|
|
336
|
+
assert.match(error.message, /update refused/)
|
|
337
|
+
assert.match(error.message, /mem-host/, 'the refusal must name the existing entry')
|
|
338
|
+
return true
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
const index = state.loadPrivateIndex(f.libraryId)
|
|
342
|
+
assert.equal(index.entries['mem-t'].content, '这条只是个占位,与部署无关。', 'the refused update changes nothing')
|
|
343
|
+
assert.equal(index.entries['mem-host'].content, BODY_DEPLOY, 'the existing entry is untouched')
|
|
344
|
+
assert.equal(commitCalls.length, 0, 'the refusal happens before any backend commit')
|
|
345
|
+
const event = activity.listEvents({ kind: 'write' }).find((item) => item.detail?.deduped === true && item.detail?.id === 'mem-t')
|
|
346
|
+
assert.ok(event, 'the refusal must be observable in the activity log')
|
|
347
|
+
assert.equal(event.detail.existingId, 'mem-host')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('rewriting an entry close to its own previous text is not a duplicate (self excluded)', async () => {
|
|
351
|
+
const f = fixture({
|
|
352
|
+
'mem-t': entry('占位记录', '这条只是个占位,内容与部署无关,写得随便一点。'),
|
|
353
|
+
})
|
|
354
|
+
const result = await memory.privateUpdate({
|
|
355
|
+
exec: f.exec, libraryId: f.libraryId, id: 'mem-t',
|
|
356
|
+
content: '这条只是个占位,内容与部署无关,写得随便一些。',
|
|
357
|
+
})
|
|
358
|
+
assert.equal(result.entry.content, '这条只是个占位,内容与部署无关,写得随便一些。')
|
|
359
|
+
assert.equal(result.entry.duplicateOf, undefined)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('an update landing in the topic band tags duplicateOf instead of refusing', async () => {
|
|
363
|
+
const f = fixture({
|
|
364
|
+
'mem-host': entry('部署窗口约定', BODY_DEPLOY),
|
|
365
|
+
'mem-t': entry('占位记录', '这条只是个占位,与部署无关。'),
|
|
366
|
+
})
|
|
367
|
+
const measured = similarity.dice(
|
|
368
|
+
similarity.bigrams('占位记录 ' + BODY_DEPLOY.slice(0, 40) + '变更流程另见发布手册第四章,联系人轮值表每月更新一次。'),
|
|
369
|
+
similarity.bigrams('部署窗口约定 ' + BODY_DEPLOY),
|
|
370
|
+
)
|
|
371
|
+
assert.ok(measured >= 0.55 && measured < 0.90, 'fixture pair must sit in the tag band, got ' + measured)
|
|
372
|
+
const result = await memory.privateUpdate({
|
|
373
|
+
exec: f.exec, libraryId: f.libraryId, id: 'mem-t',
|
|
374
|
+
content: BODY_DEPLOY.slice(0, 40) + '变更流程另见发布手册第四章,联系人轮值表每月更新一次。',
|
|
375
|
+
})
|
|
376
|
+
assert.equal(result.entry.status, 'active')
|
|
377
|
+
assert.equal(result.entry.duplicateOf, 'mem-host')
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test('an update may keep converging on the entry it is already tagged duplicateOf', async () => {
|
|
381
|
+
const f = fixture({
|
|
382
|
+
'mem-host': entry('咖啡口味', '他只喝黑咖啡,从不加糖也从不加奶,只喝现磨的。'),
|
|
383
|
+
'mem-copy': entry('口味备注', '他只喝黑咖啡。', { duplicateOf: 'mem-host' }),
|
|
384
|
+
})
|
|
385
|
+
const result = await memory.privateUpdate({
|
|
386
|
+
exec: f.exec, libraryId: f.libraryId, id: 'mem-copy',
|
|
387
|
+
content: '他只喝黑咖啡,不加糖也不加奶。',
|
|
388
|
+
})
|
|
389
|
+
assert.equal(result.entry.duplicateOf, 'mem-host', 'the tag survives: the text is still on-topic with the host')
|
|
390
|
+
// Rewriting to an unrelated fact drops the basis of the tag, so the stale tag goes.
|
|
391
|
+
const rewritten = await memory.privateUpdate({
|
|
392
|
+
exec: f.exec, libraryId: f.libraryId, id: 'mem-copy',
|
|
393
|
+
content: '图书馆周三下午闭馆整理新书,借阅上限是五本,超期按天收取滞纳金。',
|
|
394
|
+
})
|
|
395
|
+
assert.equal(rewritten.entry.duplicateOf, undefined, 'a tag whose textual basis is gone must not stay')
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('review accept that would duplicate a third entry is refused and the review stays pending', async () => {
|
|
399
|
+
const f = fixture({
|
|
400
|
+
'mem-third': entry('茶水间约定', BODY_COFFEE),
|
|
401
|
+
'mem-c': entry('部署窗口约定', BODY_DEPLOY),
|
|
402
|
+
'mem-p': entry('茶水间的约定', '茶水间自动售货机经常卡币的说法待定。', { status: 'pending_review', conflictingWith: 'mem-c', reviewReason: 'conflict' }),
|
|
403
|
+
})
|
|
404
|
+
await assert.rejects(
|
|
405
|
+
memory.resolveMemoryReview({ libraryId: f.libraryId, id: 'mem-p', action: 'accept', content: BODY_COFFEE }),
|
|
406
|
+
(error) => {
|
|
407
|
+
assert.match(error.message, /review accept refused/)
|
|
408
|
+
assert.match(error.message, /mem-third/)
|
|
409
|
+
return true
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
const index = state.loadPrivateIndex(f.libraryId)
|
|
413
|
+
assert.equal(index.entries['mem-p'].status, 'pending_review', 'the review stays resolvable')
|
|
414
|
+
assert.equal(index.entries['mem-c'].status, 'active', 'a refused accept supersedes nothing')
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
test('review accept in the topic band lands active tagged duplicateOf; replace semantics unchanged', async () => {
|
|
418
|
+
const f = fixture({
|
|
419
|
+
'mem-third': entry('茶水间约定', BODY_COFFEE),
|
|
420
|
+
'mem-c': entry('部署窗口约定', BODY_DEPLOY),
|
|
421
|
+
'mem-p': entry('茶水间自动售货', '茶水间的自动售货机经常卡币。', { status: 'pending_review', conflictingWith: 'mem-c', reviewReason: 'conflict' }),
|
|
422
|
+
})
|
|
423
|
+
const accepted = await memory.resolveMemoryReview({
|
|
424
|
+
libraryId: f.libraryId, id: 'mem-p', action: 'accept',
|
|
425
|
+
content: '茶水间的咖啡机下午三点以后不用付费,用完要自己清洗滤网。',
|
|
426
|
+
})
|
|
427
|
+
assert.equal(accepted.status, 'active')
|
|
428
|
+
const index = state.loadPrivateIndex(f.libraryId)
|
|
429
|
+
assert.equal(index.entries['mem-p'].duplicateOf, 'mem-third')
|
|
430
|
+
assert.equal(index.entries['mem-c'].status, 'superseded', 'resolution=replace still supersedes the conflicting entry')
|
|
431
|
+
assert.equal(index.entries['mem-p'].supersedes, 'mem-c')
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('review accept matching only its own conflictingWith entry is not a duplicate', async () => {
|
|
435
|
+
const f = fixture({
|
|
436
|
+
'mem-c': entry('部署窗口约定', BODY_DEPLOY),
|
|
437
|
+
'mem-p': entry('部署窗口的复核', '这是待复核的另一个说法。', { status: 'pending_review', conflictingWith: 'mem-c', reviewReason: 'conflict' }),
|
|
438
|
+
})
|
|
439
|
+
const accepted = await memory.resolveMemoryReview({
|
|
440
|
+
libraryId: f.libraryId, id: 'mem-p', action: 'accept', content: BODY_DEPLOY,
|
|
441
|
+
})
|
|
442
|
+
assert.equal(accepted.status, 'active')
|
|
443
|
+
const index = state.loadPrivateIndex(f.libraryId)
|
|
444
|
+
assert.equal(index.entries['mem-p'].duplicateOf, undefined)
|
|
445
|
+
assert.equal(index.entries['mem-c'].status, 'superseded')
|
|
446
|
+
})
|
|
@@ -155,7 +155,11 @@ async function paintTab() {
|
|
|
155
155
|
test('mounting paints once and arms exactly one heartbeat', async () => {
|
|
156
156
|
const tab = await paintTab()
|
|
157
157
|
assert.equal(fullCalls(tab.calls).length, 1, 'the panel fetches the first screen once on mount')
|
|
158
|
-
|
|
158
|
+
// 两个定时器:记忆心跳(秒级)+ 整理提醒的慢轮询(20s,跨会话失败提醒的数据源)。
|
|
159
|
+
// 真正的 beat 只允许有一个,且必须是秒级。
|
|
160
|
+
assert.equal(intervals.length, 2, 'the beat plus the consolidation-feed poll, nothing else')
|
|
161
|
+
assert.equal(intervals.filter(interval => interval.ms <= 5000).length, 1, 'exactly one short heartbeat interval is armed')
|
|
162
|
+
assert.equal(intervals[0].ms, Math.min(...intervals.map(interval => interval.ms)), 'the beat is the first timer, so intervals[0] targets it')
|
|
159
163
|
assert.ok(intervals[0].ms > 0 && intervals[0].ms <= 5000, 'the beat is seconds apart, not minutes')
|
|
160
164
|
assert.equal(visibilityListeners.size, 1, 'returning to the window is listened for')
|
|
161
165
|
})
|
|
@@ -68,6 +68,6 @@ test('the shipped tool surface compiles under the host rules', async () => {
|
|
|
68
68
|
tools: { register: (tool) => { registered.push(tool.name); return () => {} } },
|
|
69
69
|
logger: { warn: (message) => { throw new Error(`unexpected registration warning: ${message}`) } },
|
|
70
70
|
})
|
|
71
|
-
assert.equal(registered.length,
|
|
71
|
+
assert.equal(registered.length, 13, `registered ${registered.join(', ')}`)
|
|
72
72
|
dispose()
|
|
73
73
|
})
|
package/tests/tools.test.mjs
CHANGED
|
@@ -39,22 +39,25 @@ test('all v1 tools register', () => {
|
|
|
39
39
|
const names = tools.map((t) => t.name)
|
|
40
40
|
for (const name of [
|
|
41
41
|
'memu_private_link',
|
|
42
|
+
'memu_private_link_suggest',
|
|
42
43
|
'memu_private_remember',
|
|
43
44
|
'memu_private_recall',
|
|
44
45
|
'memu_private_list',
|
|
45
46
|
'memu_private_update',
|
|
46
47
|
'memu_private_archive',
|
|
47
48
|
'memu_private_unpin',
|
|
49
|
+
'memu_lexicon_search',
|
|
50
|
+
'memu_lexicon_propose',
|
|
48
51
|
'memu_global_recall',
|
|
49
52
|
'memu_global_list',
|
|
50
53
|
'memu_status',
|
|
51
54
|
]) {
|
|
52
55
|
assert.ok(names.includes(name), `missing ${name}`)
|
|
53
56
|
}
|
|
54
|
-
// Exactly
|
|
57
|
+
// Exactly thirteen, not "at least these twelve": a schema the host DSL refuses used to
|
|
55
58
|
// abort registerTools() before any register() ran, so counting is the assertion
|
|
56
59
|
// that actually catches a total registration failure.
|
|
57
|
-
assert.equal(names.length,
|
|
60
|
+
assert.equal(names.length, 13, `expected 13 tools, got ${names.length}: ${names.join(', ')}`)
|
|
58
61
|
dispose()
|
|
59
62
|
})
|
|
60
63
|
|
|
@@ -65,7 +68,113 @@ test('status works without an engine', async () => {
|
|
|
65
68
|
assert.match(status, /library:/)
|
|
66
69
|
// The registration count is the visible signal for a refused schema: without it
|
|
67
70
|
// a host that rejects the tool and a plugin that never had it look identical.
|
|
68
|
-
assert.match(status, /tools:
|
|
71
|
+
assert.match(status, /tools: 13\/13 registered/)
|
|
72
|
+
dispose()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('title descriptions explain the permanent index rule', () => {
|
|
76
|
+
const { tools, dispose } = fakeCtx()
|
|
77
|
+
const remember = tools.find((t) => t.name === 'memu_private_remember')
|
|
78
|
+
const update = tools.find((t) => t.name === 'memu_private_update')
|
|
79
|
+
for (const tool of [remember, update]) {
|
|
80
|
+
const title = tool.parameters.title.description
|
|
81
|
+
assert.match(title, /8-40/, `${tool.name} title carries the 8-40 rule`)
|
|
82
|
+
assert.match(title, /name the concrete fact|title is a permanent index|anchor shows only titles/, `${tool.name} title says it is an index`)
|
|
83
|
+
assert.match(title, /empty label/, `${tool.name} title forbids empty labels`)
|
|
84
|
+
}
|
|
85
|
+
assert.match(remember.parameters.title.description, /WITHOUT the body/, 'the anchor shows titles without bodies')
|
|
86
|
+
const link = tools.find((t) => t.name === 'memu_private_link')
|
|
87
|
+
assert.match(link.description, /memu_private_link_suggest/, 'link is named as the adjudication tool for candidates')
|
|
88
|
+
assert.match(link.parameters.entity.description, /reused/, 'a named entity can be reused instead of duplicated')
|
|
89
|
+
const search = tools.find((t) => t.name === 'memu_lexicon_search')
|
|
90
|
+
assert.match(search.description, /ACTIVE/)
|
|
91
|
+
assert.match(search.description, /archived entries are excluded/)
|
|
92
|
+
const propose = tools.find((t) => t.name === 'memu_lexicon_propose')
|
|
93
|
+
assert.match(propose.description, /ACTIVE immediately/)
|
|
94
|
+
assert.doesNotMatch(propose.description, /stored as pending|needs human review before/, 'a model proposal is no longer pending')
|
|
95
|
+
assert.match(propose.parameters.term.description, /index key/)
|
|
96
|
+
assert.match(propose.parameters.meaning.description, /explanation/)
|
|
97
|
+
assert.match(tools.find((t) => t.name === 'memu_private_link_suggest').parameters.ids.description, /Required/)
|
|
98
|
+
dispose()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('lexicon propose writes an active entry immediately; search only sees active terms', async () => {
|
|
102
|
+
const { tools, dispose } = fakeCtx()
|
|
103
|
+
const cwd = path.join(home, 'project-lex')
|
|
104
|
+
const execLex = { agent: { session: { header: { id: 'tools-lex', cwd, agentPreset: 'coding' } } } }
|
|
105
|
+
const binding = state.ensureSession('tools-lex', { cwd, agentPreset: 'coding' })
|
|
106
|
+
const lexicon = await import('../lib/lexicon.js')
|
|
107
|
+
const propose = tools.find((t) => t.name === 'memu_lexicon_propose')
|
|
108
|
+
const search = tools.find((t) => t.name === 'memu_lexicon_search')
|
|
109
|
+
|
|
110
|
+
const written = await propose.execute({ term: '糖刀', kind: 'slang', meaning: '又甜又扎人的说话方式。' }, execLex)
|
|
111
|
+
assert.match(written, /status=active/)
|
|
112
|
+
assert.doesNotMatch(written, /pending|待审/)
|
|
113
|
+
const storedPair = Object.entries(lexicon.loadLexicon(binding.libraryId).terms).find(([, entry]) => entry.term === '糖刀')
|
|
114
|
+
const stored = storedPair[1]
|
|
115
|
+
assert.equal(stored.status, 'active', 'model-proposal lands as active, no review step')
|
|
116
|
+
assert.equal(stored.source, 'model-proposal')
|
|
117
|
+
|
|
118
|
+
// Writing the same normalized term again updates the active entry, it does not fork it.
|
|
119
|
+
const again = await propose.execute({ term: '糖刀', kind: 'slang', meaning: '又甜又扎人的说话方式。', usage: '撒娇时用。' }, execLex)
|
|
120
|
+
assert.match(again, /已更新现有生效词条/)
|
|
121
|
+
assert.equal(Object.values(lexicon.loadLexicon(binding.libraryId).terms).filter((entry) => entry.term === '糖刀').length, 1)
|
|
122
|
+
|
|
123
|
+
const hit = await search.execute({ query: '糖刀' }, execLex)
|
|
124
|
+
assert.match(hit, /糖刀/)
|
|
125
|
+
|
|
126
|
+
// Archived terms leave both the search surface and injection.
|
|
127
|
+
lexicon.archiveLexiconTerm({ libraryId: binding.libraryId, termId: storedPair[0] })
|
|
128
|
+
const archived = await search.execute({ query: '糖刀' }, execLex)
|
|
129
|
+
assert.match(archived, /没有命中/)
|
|
130
|
+
// Writing the archived term again is not the model's call: it reports the entry was not changed.
|
|
131
|
+
const notOwned = await propose.execute({ term: '糖刀', kind: 'slang', meaning: '再次写入。' }, execLex)
|
|
132
|
+
assert.match(notOwned, /未改动/)
|
|
133
|
+
assert.match(notOwned, /已归档或归用户管理/)
|
|
134
|
+
assert.equal(Object.values(lexicon.loadLexicon(binding.libraryId).terms).find((entry) => entry.term === '糖刀').status, 'archived')
|
|
135
|
+
|
|
136
|
+
const refused = await propose.execute({ term: '别离开我', kind: 'trigger', meaning: '离别场景的触发词。' }, execLex)
|
|
137
|
+
assert.match(refused, /sensitive trigger/, 'a sensitive trigger stays user-managed')
|
|
138
|
+
dispose()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('link suggest applies high-confidence links and returns only uncertain candidates', async () => {
|
|
142
|
+
const { tools, dispose } = fakeCtx()
|
|
143
|
+
const cwd = path.join(home, 'project-links')
|
|
144
|
+
const execLinks = { agent: { session: { header: { id: 'tools-links', cwd, agentPreset: 'rp' } } } }
|
|
145
|
+
const binding = state.ensureSession('tools-links', { cwd, agentPreset: 'rp' })
|
|
146
|
+
state.savePrivateIndex(binding.libraryId, { version: 1, entries: {
|
|
147
|
+
'mem-a': { title: '猫娘yuike', content: 'yuike 是一只住在窗台的猫娘,喜欢晒太阳。', status: 'active', visibility: 'shared', layer: 'long_term' },
|
|
148
|
+
'mem-b': { title: 'yuike名字的由来', content: '这个名字来自小时候养过的一只白猫。', status: 'active', visibility: 'shared', layer: 'long_term' },
|
|
149
|
+
} })
|
|
150
|
+
const suggest = tools.find((t) => t.name === 'memu_private_link_suggest')
|
|
151
|
+
assert.equal(suggest.isConcurrencySafe(), false, 'suggest writes links, so it is not a concurrent read')
|
|
152
|
+
|
|
153
|
+
const unbound = await suggest.execute({ ids: ['mem-a'] }, {
|
|
154
|
+
agent: { session: { header: { id: 'tools-links-unbound', cwd: path.join(home, 'project-links-unbound') } } },
|
|
155
|
+
})
|
|
156
|
+
assert.match(unbound, /没有绑定记忆库/)
|
|
157
|
+
const empty = await suggest.execute({ ids: [] }, execLinks)
|
|
158
|
+
assert.match(empty, /1-20/)
|
|
159
|
+
|
|
160
|
+
const payload = JSON.parse(await suggest.execute({ ids: ['mem-a'] }, execLinks))
|
|
161
|
+
assert.deepEqual(Object.keys(payload).sort(), ['autoLinked', 'candidates'], 'the tool answers with the two-field contract only')
|
|
162
|
+
assert.deepEqual(payload.candidates, [], 'the yuike pair is high confidence, so nothing is handed over')
|
|
163
|
+
assert.equal(payload.autoLinked.length, 3, 'relation link plus one hub link per memory')
|
|
164
|
+
for (const edge of payload.autoLinked) {
|
|
165
|
+
assert.deepEqual(Object.keys(edge).sort(), ['from', 'kind', 'to'], 'no bodies or notes in the compact payload')
|
|
166
|
+
}
|
|
167
|
+
assert.ok(payload.autoLinked.some((edge) => edge.from === 'mem-a' && edge.to === 'mem-b' && edge.kind === 'relation'))
|
|
168
|
+
assert.ok(payload.autoLinked.some((edge) => edge.from === 'mem-a' && edge.kind === 'person'))
|
|
169
|
+
// The auto source survives into the graph the UI reads.
|
|
170
|
+
const graph = await import('../lib/graph.js')
|
|
171
|
+
const view = graph.getGraph({ libraryId: binding.libraryId, focusId: 'mem-a', depth: 1 })
|
|
172
|
+
assert.ok(view.edges.some((edge) => edge.from === 'mem-a' && edge.to === 'mem-b' && edge.source === 'auto'))
|
|
173
|
+
|
|
174
|
+
// Running it again is idempotent: no new links, same empty candidate list.
|
|
175
|
+
const rerun = JSON.parse(await suggest.execute({ ids: ['mem-a', 'mem-b'] }, execLinks))
|
|
176
|
+
assert.deepEqual(rerun.autoLinked, [])
|
|
177
|
+
assert.deepEqual(rerun.candidates, [])
|
|
69
178
|
dispose()
|
|
70
179
|
})
|
|
71
180
|
|