dsh-memu-memory 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/README.md +12 -5
- package/README.zh.md +4 -2
- package/docs/contributing.md +16 -3
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -4
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +2 -2
- package/lib/client.js +1361 -146
- package/lib/consolidation-worker.js +30 -8
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1252 -91
- 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 +51 -9
- package/lib/inbound.js +19 -0
- 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 +282 -17
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +32 -3
- 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 +577 -9
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +36 -18
- package/lib/tools.js +168 -10
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +1 -1
- 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/tests/absorb-review.test.mjs +353 -0
- 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-fork.test.mjs +14 -12
- package/tests/consolidation-snapshot.test.mjs +39 -0
- 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 +52 -5
- 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 +35 -2
- package/tests/graph.test.mjs +96 -0
- package/tests/help-page.test.mjs +415 -0
- 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-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 +33 -4
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +188 -0
|
@@ -17,6 +17,17 @@ test('managed Python discovery is pinned and all write directories stay inside e
|
|
|
17
17
|
const binary = path.join(home, 'engine', 'runtime', `uv-${UV_VERSION}`, process.platform === 'win32' ? 'uv.exe' : 'uv')
|
|
18
18
|
fs.mkdirSync(path.dirname(binary), { recursive: true })
|
|
19
19
|
fs.writeFileSync(binary, 'fixture; never executed')
|
|
20
|
+
const assets = {
|
|
21
|
+
'win32-x64': ['uv-x86_64-pc-windows-msvc.zip', 'a2a8820d5eea32f08ef467ef3b7f66883a9b076d5737a2fc2fbb5f458d1bb50f'],
|
|
22
|
+
'win32-arm64': ['uv-aarch64-pc-windows-msvc.zip', 'c2426c0c8e28b53d8c440e47a18d96ad4db51e159866c0e81435b196d7f0081c'],
|
|
23
|
+
'linux-x64': ['uv-x86_64-unknown-linux-gnu.tar.gz', 'f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645'],
|
|
24
|
+
'linux-arm64': ['uv-aarch64-unknown-linux-gnu.tar.gz', '229e194255c7958d86d56e8790d969a6f74736592133f0dfd0076f754c642c1d'],
|
|
25
|
+
'darwin-x64': ['uv-x86_64-apple-darwin.tar.gz', '63ba17fdda55ad1a230d4b37fc6017ffa10c23688c83818bd3bfdf914c652a02'],
|
|
26
|
+
'darwin-arm64': ['uv-aarch64-apple-darwin.tar.gz', '60fbd5c8a1ed681845eea28d9d622fcde03c51e91a43465b64a1e17db1ed3472'],
|
|
27
|
+
}
|
|
28
|
+
const asset = assets[process.platform + '-' + process.arch]
|
|
29
|
+
const fingerprint = crypto.createHash('sha256').update(fs.readFileSync(binary)).digest('hex')
|
|
30
|
+
fs.writeFileSync(path.join(path.dirname(binary), 'verified.json'), JSON.stringify({ version: UV_VERSION, archive: asset[0], archiveSha256: asset[1], executableBytes: fs.statSync(binary).size, executableSha256: fingerprint }))
|
|
20
31
|
const python = path.join(home, 'engine', 'runtime', 'python', 'python.exe')
|
|
21
32
|
fs.mkdirSync(path.dirname(python), { recursive: true })
|
|
22
33
|
fs.writeFileSync(python, 'fixture')
|
|
@@ -33,6 +44,27 @@ test('managed Python discovery is pinned and all write directories stay inside e
|
|
|
33
44
|
for (const call of calls) for (const key of ['UV_PYTHON_INSTALL_DIR', 'UV_CACHE_DIR', 'UV_PYTHON_BIN_DIR']) assert.ok(call.options.env[key].startsWith(path.join(home, 'engine')))
|
|
34
45
|
assert.ok(calls.some(call => call.options.env.UV_PYTHON_PREFERENCE === 'only-managed' && call.args.includes('--no-python-downloads')))
|
|
35
46
|
})
|
|
47
|
+
test('tampered or legacy uv metadata is never executed', async () => {
|
|
48
|
+
const previousHome = process.env.DSH_MEMU_HOME
|
|
49
|
+
const isolated = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-runtime-tamper-'))
|
|
50
|
+
process.env.DSH_MEMU_HOME = isolated
|
|
51
|
+
try {
|
|
52
|
+
const binary = path.join(isolated, 'engine', 'runtime', `uv-${UV_VERSION}`, process.platform === 'win32' ? 'uv.exe' : 'uv')
|
|
53
|
+
fs.mkdirSync(path.dirname(binary), { recursive: true })
|
|
54
|
+
fs.writeFileSync(binary, 'trusted fixture')
|
|
55
|
+
fs.writeFileSync(path.join(path.dirname(binary), 'verified.json'), JSON.stringify({ version: UV_VERSION, archive: 'legacy', sha256: '0'.repeat(64) }))
|
|
56
|
+
let executed = false
|
|
57
|
+
await assert.rejects(ensureManagedPython({
|
|
58
|
+
run: async () => { executed = true; return { code: 0, stdout: `uv ${UV_VERSION}` } },
|
|
59
|
+
fetcher: async () => { throw new Error('replacement required') },
|
|
60
|
+
}), /replacement required/)
|
|
61
|
+
assert.equal(executed, false)
|
|
62
|
+
} finally {
|
|
63
|
+
process.env.DSH_MEMU_HOME = previousHome
|
|
64
|
+
fs.rmSync(isolated, { recursive: true, force: true })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
36
68
|
test('a discovered interpreter outside the plugin directory is refused', async () => {
|
|
37
69
|
await assert.rejects(ensureManagedPython({
|
|
38
70
|
run: async (_cmd, args) => ({ code: 0, stdout: args[0] === '--version' ? `uv ${UV_VERSION}` : 'C:/outside/python.exe' }),
|
|
@@ -0,0 +1,445 @@
|
|
|
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
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-private-links-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
process.env.DSH_MEMU_MEMORY_HOME = home
|
|
10
|
+
|
|
11
|
+
const state = await import('../lib/state.js')
|
|
12
|
+
const graph = await import('../lib/graph.js')
|
|
13
|
+
const activity = await import('../lib/activity-log.js')
|
|
14
|
+
const privateLinks = await import('../lib/private-links.js')
|
|
15
|
+
const {
|
|
16
|
+
suggestPrivateLinks,
|
|
17
|
+
autoLinkPrivateMemories,
|
|
18
|
+
normalizeEntityName,
|
|
19
|
+
inferEntityKind,
|
|
20
|
+
} = privateLinks
|
|
21
|
+
|
|
22
|
+
test.after(() => {
|
|
23
|
+
try { state.flushState() } catch {}
|
|
24
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const ZERO = { linked: 0, entities: 0, suggested: 0, candidates: [], autoLinked: [] }
|
|
28
|
+
|
|
29
|
+
function entry(title, content, extra = {}) {
|
|
30
|
+
return { title, content, status: 'active', visibility: 'shared', layer: 'long_term', ...extra }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function snapshot(entries) {
|
|
34
|
+
return { version: 1, entries }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function bind(sessionId) {
|
|
38
|
+
return state.ensureSession(sessionId, { cwd: path.join(home, 'ws', sessionId), agentPreset: 'rp' })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function labels(pairs) {
|
|
42
|
+
return pairs.map(pair => `${pair.from}->${pair.to}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- 纯函数:规范化 / 实体类型 ----
|
|
46
|
+
|
|
47
|
+
test('normalizeEntityName folds case, width, whitespace and punctuation', () => {
|
|
48
|
+
assert.equal(normalizeEntityName('Yuike'), 'yuike')
|
|
49
|
+
assert.equal(normalizeEntityName(' Yuike! '), 'yuike')
|
|
50
|
+
assert.equal(normalizeEntityName('yuike_01'), 'yuike01')
|
|
51
|
+
assert.equal(normalizeEntityName('Yuike-Chan'), 'yuikechan')
|
|
52
|
+
assert.equal(normalizeEntityName(''), '')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('inferEntityKind: person cues win, Latin falls back to thing, CJK without cues stays uncertain', () => {
|
|
56
|
+
assert.equal(inferEntityKind([
|
|
57
|
+
{ title: '猫娘yuike', content: '' },
|
|
58
|
+
{ title: 'yuike名字的由来', content: '' },
|
|
59
|
+
], 'yuike'), 'person')
|
|
60
|
+
assert.equal(inferEntityKind([
|
|
61
|
+
{ title: 'nebula kickoff', content: 'a nebula survey starts at dawn' },
|
|
62
|
+
], 'nebula'), 'thing')
|
|
63
|
+
assert.equal(inferEntityKind([
|
|
64
|
+
{ title: '星港入口', content: '深夜的星港入口很冷' },
|
|
65
|
+
], '星港'), null, 'a CJK token with no person cue must stay unresolved')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// ---- 纯函数:高置信直连 + 实体枢纽 ----
|
|
69
|
+
|
|
70
|
+
test('yuike: a rare name-like pair is linked and becomes a person hub reused by case-insensitive name', () => {
|
|
71
|
+
const index = snapshot({
|
|
72
|
+
'mem-a': entry('猫娘yuike', 'yuike 是一只住在窗台的猫娘,喜欢在午后晒太阳。'),
|
|
73
|
+
'mem-b': entry('yuike名字的由来', '这个名字来自小时候养过的一只白猫,后来一直被沿用。'),
|
|
74
|
+
})
|
|
75
|
+
const result = suggestPrivateLinks({ index, ids: ['mem-a'] })
|
|
76
|
+
assert.equal(result.autoLinks.length, 1)
|
|
77
|
+
const link = result.autoLinks[0]
|
|
78
|
+
assert.equal(link.from, 'mem-a')
|
|
79
|
+
assert.equal(link.to, 'mem-b')
|
|
80
|
+
assert.equal(link.token, 'yuike')
|
|
81
|
+
assert.equal(link.nameLike, true)
|
|
82
|
+
assert.ok(link.confidence >= 0.6 && link.confidence <= 0.78, 'name-like confidence lives in the 0.6–0.78 band')
|
|
83
|
+
assert.deepEqual(labels(result.candidates), [], 'a resolved pair never appears in candidates')
|
|
84
|
+
assert.equal(result.entityHubs.length, 1)
|
|
85
|
+
const hub = result.entityHubs[0]
|
|
86
|
+
assert.equal(hub.token, 'yuike')
|
|
87
|
+
assert.equal(hub.name, 'yuike', 'display name keeps the original casing from the first title')
|
|
88
|
+
assert.equal(hub.kind, 'person')
|
|
89
|
+
assert.equal('entityId' in hub, false)
|
|
90
|
+
assert.deepEqual(new Set(hub.memoryIds), new Set(['mem-a', 'mem-b']))
|
|
91
|
+
|
|
92
|
+
const reused = suggestPrivateLinks({
|
|
93
|
+
index,
|
|
94
|
+
entities: [{ id: 'ent-yuike', name: 'Yuike', kind: 'person', aliases: [] }],
|
|
95
|
+
ids: ['mem-a'],
|
|
96
|
+
})
|
|
97
|
+
assert.equal(reused.entityHubs.length, 1)
|
|
98
|
+
assert.equal(reused.entityHubs[0].entityId, 'ent-yuike', 'a pre-existing Yuike entity is reused, not duplicated')
|
|
99
|
+
assert.equal(reused.autoLinks.length, 1)
|
|
100
|
+
|
|
101
|
+
const nfkc = suggestPrivateLinks({
|
|
102
|
+
index,
|
|
103
|
+
entities: [{ id: 'ent-yuike', name: 'Yuike', kind: 'person', aliases: ['yuike-chan'] }],
|
|
104
|
+
ids: ['mem-a'],
|
|
105
|
+
})
|
|
106
|
+
assert.equal(nfkc.entityHubs[0].entityId, 'ent-yuike', 'NFKC + punctuation-insensitive name matching')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// ---- 纯函数:资格过滤 ----
|
|
110
|
+
|
|
111
|
+
test('secret, archived, pending_review, other context, other track and invisible entries are filtered', () => {
|
|
112
|
+
bind('links-pure-filter')
|
|
113
|
+
const index = snapshot({
|
|
114
|
+
'mem-new': entry('yuike main', 'yuike 的第一条日常记录。'),
|
|
115
|
+
'mem-ok': entry('yuike friend', 'yuike 的第二条日常记录。'),
|
|
116
|
+
'mem-context': entry('yuike other', '别处的记录。', { context: { characterId: 'other' } }),
|
|
117
|
+
'mem-secret': entry('yuike secret', '秘密记录。', { visibility: 'secret' }),
|
|
118
|
+
'mem-archived': entry('yuike archived', '旧记录。', { status: 'archived' }),
|
|
119
|
+
'mem-pending': entry('yuike pending', '待复核记录。', { status: 'pending_review' }),
|
|
120
|
+
'mem-known': entry('yuike known', '只有某个角色知道的记录。', { context: { knownBy: ['someone-else'] } }),
|
|
121
|
+
})
|
|
122
|
+
const result = suggestPrivateLinks({ index, ids: ['mem-new'], sessionId: 'links-pure-filter' })
|
|
123
|
+
assert.deepEqual(labels(result.autoLinks), ['mem-new->mem-ok'])
|
|
124
|
+
assert.equal(result.entityHubs.length, 1)
|
|
125
|
+
assert.deepEqual(new Set(result.entityHubs[0].memoryIds), new Set(['mem-new', 'mem-ok']))
|
|
126
|
+
assert.deepEqual(result.candidates, [])
|
|
127
|
+
const seen = JSON.stringify(result)
|
|
128
|
+
for (const excluded of ['mem-context', 'mem-secret', 'mem-archived', 'mem-pending', 'mem-known']) {
|
|
129
|
+
assert.ok(!seen.includes(excluded), `${excluded} must not appear anywhere in the suggestions`)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('a shared token is never linked across tracks', () => {
|
|
134
|
+
const index = snapshot({
|
|
135
|
+
'mem-track-a': entry('yuike main', 'yuike 的第一条日常记录。'),
|
|
136
|
+
'mem-track-b': entry('yuike habit', 'yuike 的习惯记录。', { track: 'habit' }),
|
|
137
|
+
})
|
|
138
|
+
const result = suggestPrivateLinks({ index, ids: ['mem-track-a'] })
|
|
139
|
+
assert.deepEqual(result.autoLinks, [])
|
|
140
|
+
assert.deepEqual(result.candidates, [])
|
|
141
|
+
assert.deepEqual(result.entityHubs, [], 'groups are also partitioned by track')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ---- 纯函数:重复 / 冲突 / 已有 link ----
|
|
145
|
+
|
|
146
|
+
test('pairScore >= 0.82, findConflict hits and existing relation/person links skip the pair', () => {
|
|
147
|
+
// Duplicate band: identical title + body is a merge problem, not a link.
|
|
148
|
+
const duplicate = snapshot({
|
|
149
|
+
'mem-dup-a': entry('yuike profile', 'yuike lives by the window and naps in the afternoon sun every single day.'),
|
|
150
|
+
'mem-dup-b': entry('yuike profile', 'yuike lives by the window and naps in the afternoon sun every single day.'),
|
|
151
|
+
})
|
|
152
|
+
const dupResult = suggestPrivateLinks({ index: duplicate, ids: ['mem-dup-a'] })
|
|
153
|
+
assert.deepEqual(labels(dupResult.autoLinks), [])
|
|
154
|
+
assert.deepEqual(labels(dupResult.candidates), [])
|
|
155
|
+
|
|
156
|
+
// Conflict: same title but contradictory bodies (findConflict same_title).
|
|
157
|
+
const conflict = snapshot({
|
|
158
|
+
'mem-cf-a': entry('yuike setting', 'yuike 喜欢在雨天沿着河堤散步。'),
|
|
159
|
+
'mem-cf-b': entry('yuike setting', 'yuike 讨厌所有甜食和奶油蛋糕。'),
|
|
160
|
+
})
|
|
161
|
+
const cfResult = suggestPrivateLinks({ index: conflict, ids: ['mem-cf-a'] })
|
|
162
|
+
assert.deepEqual(labels(cfResult.autoLinks), [])
|
|
163
|
+
assert.deepEqual(labels(cfResult.candidates), [])
|
|
164
|
+
|
|
165
|
+
// Existing relation link from the new entry: idempotent.
|
|
166
|
+
const forward = snapshot({
|
|
167
|
+
'mem-lk-a': entry('yuike linked', 'yuike 的第一条记录。', {
|
|
168
|
+
links: [{ to: 'mem-lk-b', kind: 'relation', note: 'already linked by an earlier pass', confidence: 0.7 }],
|
|
169
|
+
}),
|
|
170
|
+
'mem-lk-b': entry('yuike partner', 'yuike 的第二条记录。'),
|
|
171
|
+
})
|
|
172
|
+
const fwResult = suggestPrivateLinks({ index: forward, ids: ['mem-lk-a'] })
|
|
173
|
+
assert.deepEqual(labels(fwResult.autoLinks), [])
|
|
174
|
+
assert.deepEqual(labels(fwResult.candidates), [])
|
|
175
|
+
|
|
176
|
+
// Existing link in the OTHER direction must also block the pair.
|
|
177
|
+
const backward = snapshot({
|
|
178
|
+
'mem-rv-a': entry('yuike reverse', 'yuike 的第一条记录。'),
|
|
179
|
+
'mem-rv-b': entry('yuike reverse partner', 'yuike 的第二条记录。', {
|
|
180
|
+
links: [{ to: 'mem-rv-a', kind: 'relation', note: 'already linked by an earlier pass', confidence: 0.7 }],
|
|
181
|
+
}),
|
|
182
|
+
})
|
|
183
|
+
const rvResult = suggestPrivateLinks({ index: backward, ids: ['mem-rv-a'] })
|
|
184
|
+
assert.deepEqual(labels(rvResult.autoLinks), [])
|
|
185
|
+
assert.deepEqual(labels(rvResult.candidates), [])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// ---- 纯函数:停用词 ----
|
|
189
|
+
|
|
190
|
+
test('stopword-only title overlaps never auto-link and never become candidates', () => {
|
|
191
|
+
const index = snapshot({
|
|
192
|
+
'mem-sw-a': entry('记忆的名字', '窗台上的灰尘积了很厚。'),
|
|
193
|
+
'mem-sw-b': entry('名字的记忆', '码头今天停了三艘货轮。'),
|
|
194
|
+
})
|
|
195
|
+
const result = suggestPrivateLinks({ index, ids: ['mem-sw-a'] })
|
|
196
|
+
assert.deepEqual(result.autoLinks, [])
|
|
197
|
+
assert.deepEqual(result.candidates, [])
|
|
198
|
+
assert.deepEqual(result.entityHubs, [])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// ---- 纯函数:上限 ----
|
|
202
|
+
|
|
203
|
+
test('perEntry, total, maxCandidates and maxEntities caps hold', () => {
|
|
204
|
+
const index = snapshot({
|
|
205
|
+
'mem-new-1': entry('alpha bravo', 'alpha survey starts at dawn.'),
|
|
206
|
+
'mem-new-2': entry('charlie delta', 'charlie survey ends at dusk.'),
|
|
207
|
+
'mem-old-a': entry('alpha archive', 'archive of the alpha survey.'),
|
|
208
|
+
'mem-old-b': entry('bravo archive', 'archive of the bravo survey.'),
|
|
209
|
+
'mem-old-c': entry('charlie archive', 'archive of the charlie survey.'),
|
|
210
|
+
'mem-old-d': entry('delta archive', 'archive of the delta survey.'),
|
|
211
|
+
})
|
|
212
|
+
const ids = ['mem-new-1', 'mem-new-2']
|
|
213
|
+
const withDefaults = suggestPrivateLinks({ index, ids, maxEntities: 4 })
|
|
214
|
+
assert.equal(withDefaults.autoLinks.length, 4)
|
|
215
|
+
assert.equal(withDefaults.autoLinks.filter(link => link.from === 'mem-new-1').length, 2)
|
|
216
|
+
assert.equal(withDefaults.autoLinks.filter(link => link.from === 'mem-new-2').length, 2)
|
|
217
|
+
assert.equal(withDefaults.entityHubs.length, 4)
|
|
218
|
+
|
|
219
|
+
const perEntry = suggestPrivateLinks({ index, ids, perEntry: 1, maxEntities: 4 })
|
|
220
|
+
assert.equal(perEntry.autoLinks.length, 2)
|
|
221
|
+
assert.equal(perEntry.autoLinks.filter(link => link.from === 'mem-new-1').length, 1)
|
|
222
|
+
assert.equal(perEntry.autoLinks.filter(link => link.from === 'mem-new-2').length, 1)
|
|
223
|
+
|
|
224
|
+
const total = suggestPrivateLinks({ index, ids, total: 3, maxEntities: 4 })
|
|
225
|
+
assert.equal(total.autoLinks.length, 3)
|
|
226
|
+
|
|
227
|
+
const entities = suggestPrivateLinks({ index, ids, maxEntities: 2 })
|
|
228
|
+
assert.equal(entities.entityHubs.length, 2)
|
|
229
|
+
|
|
230
|
+
// Candidates are capped too, highest score first. Unresolved CJK hubs are the
|
|
231
|
+
// uncertain kind, so three pairs yield three candidates.
|
|
232
|
+
const uncertain = snapshot({
|
|
233
|
+
'mem-u1': entry('星港入口', '深夜的星港入口很冷,风从闸门缝里灌进来。'),
|
|
234
|
+
'mem-u2': entry('星港记录', '档案室的旧星港记录已经泛黄,编号从三年前开始。'),
|
|
235
|
+
'mem-v1': entry('雾谷入口', '清晨的雾谷入口很安静,石阶上还挂着水珠。'),
|
|
236
|
+
'mem-v2': entry('雾谷记录', '旧册子里的雾谷记录被虫蛀了一角,字迹模糊。'),
|
|
237
|
+
'mem-w1': entry('白塔入口', '黄昏的白塔入口被藤蔓遮住,门锁已经锈死。'),
|
|
238
|
+
'mem-w2': entry('白塔记录', '白塔记录停在二十年前,最后一页没有落款。'),
|
|
239
|
+
})
|
|
240
|
+
const all = suggestPrivateLinks({ index: uncertain, ids: ['mem-u1', 'mem-v1', 'mem-w1'] })
|
|
241
|
+
assert.equal(all.candidates.length, 3)
|
|
242
|
+
assert.equal(all.autoLinks.length, 0)
|
|
243
|
+
assert.equal(all.entityHubs.length, 0)
|
|
244
|
+
const capped = suggestPrivateLinks({ index: uncertain, ids: ['mem-u1', 'mem-v1', 'mem-w1'], maxCandidates: 2 })
|
|
245
|
+
assert.equal(capped.candidates.length, 2)
|
|
246
|
+
assert.ok(capped.candidates[0].score >= capped.candidates[1].score, 'highest score is preferred')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// ---- 纯函数:不确定实体类型 / thing / kind 冲突 ----
|
|
250
|
+
|
|
251
|
+
test('CJK name-like token without a person cue is an uncertain candidate, not a hub', () => {
|
|
252
|
+
const index = snapshot({
|
|
253
|
+
'mem-a': entry('星港入口', '深夜的星港入口很冷,风从闸门缝里灌进来。'),
|
|
254
|
+
'mem-b': entry('星港记录', '档案室的旧星港记录已经泛黄,编号从三年前开始。'),
|
|
255
|
+
})
|
|
256
|
+
const result = suggestPrivateLinks({ index, ids: ['mem-a'] })
|
|
257
|
+
assert.deepEqual(result.autoLinks, [], 'an unresolved entity kind does not auto-link')
|
|
258
|
+
assert.deepEqual(result.entityHubs, [], 'a CJK token without a person cue never auto-creates a hub')
|
|
259
|
+
assert.equal(result.candidates.length, 1)
|
|
260
|
+
const candidate = result.candidates[0]
|
|
261
|
+
assert.deepEqual(Object.keys(candidate).sort(), ['from', 'preview', 'score', 'sharedToken', 'suggestedEntity', 'title', 'to'])
|
|
262
|
+
assert.equal(candidate.from, 'mem-a')
|
|
263
|
+
assert.equal(candidate.to, 'mem-b')
|
|
264
|
+
assert.equal(candidate.sharedToken, '星港')
|
|
265
|
+
assert.deepEqual(candidate.suggestedEntity, { name: '星港', kind: null })
|
|
266
|
+
assert.equal(candidate.title, '星港记录', 'title/preview describe the OTHER memory')
|
|
267
|
+
assert.equal(candidate.preview, '档案室的旧星港记录已经泛黄,编号从三年前开始。')
|
|
268
|
+
assert.ok(candidate.title.length <= 80)
|
|
269
|
+
assert.ok(candidate.preview.length <= 120)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('a Latin name-like token without a person cue becomes a thing hub', () => {
|
|
273
|
+
const index = snapshot({
|
|
274
|
+
'mem-c': entry('nebula kickoff', 'the nebula survey starts at dawn near the outer ring.'),
|
|
275
|
+
'mem-d': entry('nebula notes', 'a nebula chart was folded inside the old folder.'),
|
|
276
|
+
})
|
|
277
|
+
const result = suggestPrivateLinks({ index, ids: ['mem-c'] })
|
|
278
|
+
assert.equal(result.autoLinks.length, 1)
|
|
279
|
+
assert.equal(result.entityHubs.length, 1)
|
|
280
|
+
assert.equal(result.entityHubs[0].kind, 'thing')
|
|
281
|
+
assert.equal(result.entityHubs[0].name, 'nebula')
|
|
282
|
+
assert.deepEqual(result.candidates, [])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('an existing entity with a conflicting kind blocks the auto-link and becomes an uncertain candidate', () => {
|
|
286
|
+
const index = snapshot({
|
|
287
|
+
'mem-c': entry('nebula kickoff', 'the nebula survey starts at dawn near the outer ring.'),
|
|
288
|
+
'mem-d': entry('nebula notes', 'a nebula chart was folded inside the old folder.'),
|
|
289
|
+
})
|
|
290
|
+
const result = suggestPrivateLinks({
|
|
291
|
+
index,
|
|
292
|
+
entities: [{ id: 'ent-nebula', name: 'NEBULA', kind: 'person', aliases: [] }],
|
|
293
|
+
ids: ['mem-c'],
|
|
294
|
+
})
|
|
295
|
+
assert.deepEqual(result.autoLinks, [])
|
|
296
|
+
assert.deepEqual(result.entityHubs, [])
|
|
297
|
+
assert.equal(result.candidates.length, 1)
|
|
298
|
+
assert.equal(result.candidates[0].sharedToken, 'nebula')
|
|
299
|
+
assert.deepEqual(result.candidates[0].suggestedEntity, { name: 'nebula', kind: 'thing' })
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// ---- 应用层 ----
|
|
303
|
+
|
|
304
|
+
test('autoLinkPrivateMemories applies the link, reuses Yuike and is idempotent', async () => {
|
|
305
|
+
activity.clearEvents()
|
|
306
|
+
const binding = bind('links-apply')
|
|
307
|
+
const libraryId = binding.libraryId
|
|
308
|
+
const yuike = graph.createEntity({ libraryId, name: 'Yuike', kind: 'person' })
|
|
309
|
+
state.savePrivateIndex(libraryId, snapshot({
|
|
310
|
+
'mem-a': entry('猫娘yuike', 'yuike 是一只住在窗台的猫娘,喜欢在午后晒太阳。'),
|
|
311
|
+
'mem-b': entry('yuike名字的由来', '这个名字来自小时候养过的一只白猫,后来一直被沿用。'),
|
|
312
|
+
}))
|
|
313
|
+
|
|
314
|
+
const result = await autoLinkPrivateMemories({ libraryId, sessionId: 'links-apply', ids: ['mem-a'] })
|
|
315
|
+
assert.equal(result.linked, 1)
|
|
316
|
+
assert.equal(result.entities, 2)
|
|
317
|
+
assert.equal(result.suggested, 0)
|
|
318
|
+
assert.deepEqual(result.candidates, [])
|
|
319
|
+
// The compact return the worker tool forwards: edges only, no bodies or notes, and
|
|
320
|
+
// one entry per write (the memory pair first, then both hub links).
|
|
321
|
+
assert.deepEqual(result.autoLinked.map(item => `${item.from}->${item.to}:${item.kind}`), [
|
|
322
|
+
'mem-a->mem-b:relation',
|
|
323
|
+
`mem-a->${yuike.id}:person`,
|
|
324
|
+
`mem-b->${yuike.id}:person`,
|
|
325
|
+
])
|
|
326
|
+
for (const item of result.autoLinked) assert.deepEqual(Object.keys(item).sort(), ['from', 'kind', 'to'])
|
|
327
|
+
|
|
328
|
+
const index = state.loadPrivateIndex(libraryId)
|
|
329
|
+
const outbound = index.entries['mem-a'].links || []
|
|
330
|
+
const memoryLink = outbound.find(link => link.to === 'mem-b')
|
|
331
|
+
assert.ok(memoryLink, 'the high-confidence memory link is persisted')
|
|
332
|
+
assert.equal(memoryLink.kind, 'relation')
|
|
333
|
+
assert.equal(memoryLink.source, 'auto', 'the auto source is persisted on the link record')
|
|
334
|
+
assert.match(memoryLink.note, /自动关联/)
|
|
335
|
+
assert.match(memoryLink.note, /yuike/)
|
|
336
|
+
assert.ok(memoryLink.confidence >= 0.6 && memoryLink.confidence <= 0.78)
|
|
337
|
+
const entityLink = outbound.find(link => link.to === yuike.id)
|
|
338
|
+
assert.ok(entityLink, 'the memory links to the reused entity hub')
|
|
339
|
+
assert.equal(entityLink.kind, 'person')
|
|
340
|
+
assert.equal(entityLink.source, 'auto')
|
|
341
|
+
assert.match(entityLink.note, /自动实体枢纽/)
|
|
342
|
+
assert.equal(entityLink.confidence, 0.65)
|
|
343
|
+
assert.ok((index.entries['mem-b'].links || []).some(link => link.to === yuike.id),
|
|
344
|
+
'every memory sharing the stable name is linked to the hub')
|
|
345
|
+
|
|
346
|
+
const entities = Object.values(graph.graphExport(libraryId).entities)
|
|
347
|
+
assert.equal(entities.length, 1, 'Yuike is reused, not duplicated as yuike')
|
|
348
|
+
assert.equal(entities[0].id, yuike.id)
|
|
349
|
+
|
|
350
|
+
const events = activity.listEvents({ kind: 'write', sessionId: 'links-apply' })
|
|
351
|
+
const linkEvent = events.find(event => event.detail?.op === 'auto-link' && event.ok)
|
|
352
|
+
const entityEvents = events.filter(event => event.detail?.op === 'auto-entity' && event.ok)
|
|
353
|
+
assert.ok(linkEvent, 'the applied link is recorded in the activity log')
|
|
354
|
+
assert.equal(linkEvent.detail.source, 'auto', 'source travels through the apply layer')
|
|
355
|
+
assert.deepEqual(new Set(linkEvent.refs), new Set(['mem-a', 'mem-b']))
|
|
356
|
+
assert.equal(entityEvents.length, 2)
|
|
357
|
+
assert.ok(entityEvents.every(event => event.detail.source === 'auto'))
|
|
358
|
+
assert.ok(entityEvents.every(event => event.detail.kind === 'person'))
|
|
359
|
+
|
|
360
|
+
activity.clearEvents()
|
|
361
|
+
const again = await autoLinkPrivateMemories({ libraryId, sessionId: 'links-apply', ids: ['mem-a'] })
|
|
362
|
+
assert.equal(again.linked, 0, 'an existing relation link makes the pair idempotent')
|
|
363
|
+
assert.equal(again.entities, 0, 'a memory already linked to the entity is skipped')
|
|
364
|
+
const after = state.loadPrivateIndex(libraryId)
|
|
365
|
+
assert.equal((after.entries['mem-a'].links || []).filter(link => link.to === 'mem-b').length, 1)
|
|
366
|
+
assert.equal((after.entries['mem-a'].links || []).filter(link => link.to === yuike.id).length, 1)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test('autoLinkPrivateMemories links a thing hub with kind relation', async () => {
|
|
370
|
+
activity.clearEvents()
|
|
371
|
+
const binding = bind('links-thing')
|
|
372
|
+
const libraryId = binding.libraryId
|
|
373
|
+
state.savePrivateIndex(libraryId, snapshot({
|
|
374
|
+
'mem-c': entry('nebula kickoff', 'the nebula survey starts at dawn near the outer ring.'),
|
|
375
|
+
'mem-d': entry('nebula notes', 'a nebula chart was folded inside the old folder.'),
|
|
376
|
+
}))
|
|
377
|
+
const result = await autoLinkPrivateMemories({ libraryId, sessionId: 'links-thing', ids: ['mem-c'] })
|
|
378
|
+
assert.equal(result.linked, 1)
|
|
379
|
+
assert.equal(result.entities, 2)
|
|
380
|
+
const entity = Object.values(graph.graphExport(libraryId).entities)
|
|
381
|
+
.find(item => normalizeEntityName(item.name) === 'nebula')
|
|
382
|
+
assert.ok(entity)
|
|
383
|
+
assert.equal(entity.kind, 'thing')
|
|
384
|
+
const index = state.loadPrivateIndex(libraryId)
|
|
385
|
+
const link = (index.entries['mem-c'].links || []).find(item => item.to === entity.id)
|
|
386
|
+
assert.ok(link)
|
|
387
|
+
assert.equal(link.kind, 'relation', 'non-person hubs use kind relation')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('autoLinkPrivateMemories never throws on a missing or broken index and logs a warning path', async () => {
|
|
391
|
+
activity.clearEvents()
|
|
392
|
+
// Missing index: an empty library is a no-op, not a failure.
|
|
393
|
+
const missing = bind('links-missing')
|
|
394
|
+
assert.deepEqual(
|
|
395
|
+
await autoLinkPrivateMemories({ libraryId: missing.libraryId, sessionId: 'links-missing', ids: ['mem-none'] }),
|
|
396
|
+
ZERO,
|
|
397
|
+
)
|
|
398
|
+
// Unknown library: the top-level load must be caught.
|
|
399
|
+
assert.deepEqual(
|
|
400
|
+
await autoLinkPrivateMemories({ libraryId: 'no-such-library', sessionId: 'links-missing', ids: ['mem-none'] }),
|
|
401
|
+
ZERO,
|
|
402
|
+
)
|
|
403
|
+
// Invalid library id reaches loadPrivateIndex's own guard.
|
|
404
|
+
assert.deepEqual(
|
|
405
|
+
await autoLinkPrivateMemories({ libraryId: '../escape', sessionId: 'links-missing', ids: ['mem-a'] }),
|
|
406
|
+
ZERO,
|
|
407
|
+
)
|
|
408
|
+
// Corrupt entity store fails listEntities AFTER the index loaded; nothing is written.
|
|
409
|
+
const broken = bind('links-broken')
|
|
410
|
+
state.savePrivateIndex(broken.libraryId, snapshot({
|
|
411
|
+
'mem-a': entry('yuike one', 'yuike 的第一条日常记录。'),
|
|
412
|
+
'mem-b': entry('yuike two', 'yuike 的第二条日常记录。'),
|
|
413
|
+
}))
|
|
414
|
+
fs.mkdirSync(path.join(home, 'index'), { recursive: true })
|
|
415
|
+
fs.writeFileSync(path.join(home, 'index', `${broken.libraryId}.entities.json`), '{not json', 'utf8')
|
|
416
|
+
assert.deepEqual(
|
|
417
|
+
await autoLinkPrivateMemories({ libraryId: broken.libraryId, sessionId: 'links-broken', ids: ['mem-a'] }),
|
|
418
|
+
ZERO,
|
|
419
|
+
)
|
|
420
|
+
assert.equal(state.loadPrivateIndex(broken.libraryId).entries['mem-a'].links, undefined, 'a failed pass writes nothing')
|
|
421
|
+
const warnings = activity.listEvents({ kind: 'write', level: 'warn' })
|
|
422
|
+
assert.ok(warnings.length >= 2, 'each top-level failure leaves a warning row')
|
|
423
|
+
assert.ok(warnings.some(event => event.detail?.op === 'auto-link'))
|
|
424
|
+
assert.ok(warnings.some(event => /unknown library|invalid library id|cannot read entity store/.test(event.message)))
|
|
425
|
+
fs.rmSync(path.join(home, 'index', `${broken.libraryId}.entities.json`), { force: true })
|
|
426
|
+
|
|
427
|
+
// No ids: zero without writes or log noise.
|
|
428
|
+
const before = activity.listEvents({ kind: 'write' }).length
|
|
429
|
+
assert.deepEqual(
|
|
430
|
+
await autoLinkPrivateMemories({ libraryId: broken.libraryId, sessionId: 'links-broken' }),
|
|
431
|
+
ZERO,
|
|
432
|
+
)
|
|
433
|
+
assert.deepEqual(
|
|
434
|
+
await autoLinkPrivateMemories({ libraryId: broken.libraryId, sessionId: 'links-broken', ids: [] }),
|
|
435
|
+
ZERO,
|
|
436
|
+
)
|
|
437
|
+
assert.equal(activity.listEvents({ kind: 'write' }).length, before, 'no ids means no work and no log row')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('the default export exposes the whole module surface', () => {
|
|
441
|
+
assert.equal(privateLinks.default.suggestPrivateLinks, suggestPrivateLinks)
|
|
442
|
+
assert.equal(privateLinks.default.autoLinkPrivateMemories, autoLinkPrivateMemories)
|
|
443
|
+
assert.equal(privateLinks.default.normalizeEntityName, normalizeEntityName)
|
|
444
|
+
assert.equal(privateLinks.default.inferEntityKind, inferEntityKind)
|
|
445
|
+
})
|
package/tests/prompts.test.mjs
CHANGED
|
@@ -199,6 +199,41 @@ test('domain defaults distinguish engineering evidence, character continuity and
|
|
|
199
199
|
assert.match(curator, /前置条件、适用版本与验证边界/)
|
|
200
200
|
})
|
|
201
201
|
|
|
202
|
+
test('title guidance ships in every consolidation page; tool workflow lives in the worker brief and the remote contract stays tool-free', async () => {
|
|
203
|
+
for (const domain of ['general', 'coding', 'rp', 'mixed']) {
|
|
204
|
+
const key = domain === 'general' ? 'private-consolidation' : `private-consolidation.${domain}`
|
|
205
|
+
const text = prompts.getPromptState(key).defaultText
|
|
206
|
+
assert.match(text, /8–40 字/, `${key}: title length rule`)
|
|
207
|
+
assert.match(text, /目录项/, `${key}: the title is the directory entry`)
|
|
208
|
+
assert.match(text, /覆盖来源的关键词/, `${key}: a merged title covers every source`)
|
|
209
|
+
assert.match(text, /没有正文|不显示正文/, `${key}: the anchor omits bodies`)
|
|
210
|
+
assert.match(text, /可抽词典词/, `${key}: the shared body still says what vocabulary is worth extracting`)
|
|
211
|
+
assert.doesNotMatch(text, /召唤/, `${key}: the typo must not return`)
|
|
212
|
+
// The domain body is shared with the remote JSON path, which has no tools at
|
|
213
|
+
// all. Tool calls belong in the local worker brief, not here.
|
|
214
|
+
assert.doesNotMatch(text, /memu_lexicon|memu_private_link|memu_private_remember|memu_private_update/, `${key}: no tool workflow in the shared body`)
|
|
215
|
+
}
|
|
216
|
+
const curator = prompts.getPromptState('global-curator').defaultText
|
|
217
|
+
assert.match(curator, /标题即目录/)
|
|
218
|
+
assert.match(curator, /8–40 字/)
|
|
219
|
+
assert.match(curator, /覆盖所有来源的关键词/)
|
|
220
|
+
// The JSON schema is unchanged: title is still a plain optional string field.
|
|
221
|
+
assert.match(curator, /"title":"\.\.\."/)
|
|
222
|
+
|
|
223
|
+
const { buildWorkerBrief } = await import('../lib/consolidation-worker.js')
|
|
224
|
+
const brief = buildWorkerBrief({ snapshot: { fromSeq: 1, throughSeq: 2, eventCount: 2, hash: 'prompt-test' }, probe: null, contract: '' })
|
|
225
|
+
for (const rule of [/memu_lexicon_search/, /memu_lexicon_propose/, /term 是索引键/, /sensitive trigger/, /memu_private_link_suggest/, /noop/, /conflict/, /最多确认 5 条/]) {
|
|
226
|
+
assert.match(brief, rule, 'the local worker brief owns the tool workflow')
|
|
227
|
+
}
|
|
228
|
+
assert.match(prompts.CONSOLIDATION_CONTRACTS.remote, /8-40字/, 'the remote JSON contract uses the same title length')
|
|
229
|
+
assert.doesNotMatch(prompts.CONSOLIDATION_CONTRACTS.remote, /memu_lexicon|memu_private_link/, 'the remote contract promises no tools')
|
|
230
|
+
for (const domain of ['general', 'coding', 'rp', 'mixed']) {
|
|
231
|
+
const assembled = prompts.loadPromptForDomain('private-consolidation', { libraryName: 'x', domain, mustRememberCap: 16 }, domain).text
|
|
232
|
+
+ '\n' + prompts.CONSOLIDATION_CONTRACTS.remote
|
|
233
|
+
assert.doesNotMatch(assembled, /memu_/, `${domain}: the remote prompt must not mention tools`)
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
202
237
|
test('all shipped template variables are declared and fully rendered, with bounded resident guidance', () => {
|
|
203
238
|
const vars = { mustRememberCap: 16, unpinBudget: 2, shortTermTtlHours: 48, anchorTokens: 700, turnTokens: 500,
|
|
204
239
|
libraryName: 'test-library', domain: 'coding', input: '{"sessions":[]}' }
|
|
@@ -135,18 +135,21 @@ test('the same fact arriving on both legs is folded once, keeping the highest-rr
|
|
|
135
135
|
assert.deepEqual(fused.debug.collapsed, [{ kept: 'mem-dup1', dropped: ['mem-dup3'] }])
|
|
136
136
|
})
|
|
137
137
|
|
|
138
|
-
test('a write with dice ≈ 0.93 is
|
|
138
|
+
test('a write with dice ≈ 0.93 is held as a near_duplicate instead of being lost', async () => {
|
|
139
139
|
const f = fixture()
|
|
140
140
|
const first = await memory.privateRemember({ exec: f.exec, title: '部署窗口约定', content: BODY_A })
|
|
141
141
|
const measured = similarity.dice(similarity.bigrams('部署窗口的约定 ' + BODY_A), similarity.bigrams('部署窗口约定 ' + BODY_A))
|
|
142
142
|
assert.ok(measured >= 0.90, 'fixture pair must sit in the reject band, got ' + measured)
|
|
143
143
|
const second = await memory.privateRemember({ exec: f.exec, title: '部署窗口的约定', content: BODY_A })
|
|
144
144
|
assert.equal(second.deduped, true)
|
|
145
|
+
assert.equal(second.pending, true)
|
|
145
146
|
assert.equal(second.existingId, first.id)
|
|
146
147
|
assert.match(second.hint, /memu_private_update/)
|
|
147
148
|
const index = state.loadPrivateIndex(f.libraryId)
|
|
148
|
-
assert.equal(Object.keys(index.entries).length,
|
|
149
|
-
assert.equal(index.entries[
|
|
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')
|
|
150
153
|
const written = activity.listEvents({ kind: 'write' })[0]
|
|
151
154
|
assert.match(written.message, /拒绝重复写入/)
|
|
152
155
|
assert.equal(written.detail.existingId, first.id)
|
|
@@ -203,10 +206,13 @@ test('a short memory padded into a much longer rewrite is refused, not slipped p
|
|
|
203
206
|
assert.ok(similarity.lengthGap(unit1, unit2) > similarity.LENGTH_PRUNE_RATIO, 'the pair must sit in the pruned band')
|
|
204
207
|
const second = await memory.privateRemember({ exec: f.exec, content: padded })
|
|
205
208
|
assert.equal(second.deduped, true)
|
|
209
|
+
assert.equal(second.pending, true)
|
|
206
210
|
assert.equal(second.existingId, first.id)
|
|
207
211
|
assert.ok(second.similarity >= similarity.DUP_REJECT_DICE, 'the probe floor lands in the reject band, got ' + second.similarity)
|
|
208
212
|
const index = state.loadPrivateIndex(f.libraryId)
|
|
209
|
-
assert.equal(Object.keys(index.entries).length,
|
|
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')
|
|
210
216
|
})
|
|
211
217
|
|
|
212
218
|
test('a genuinely different longer fact against a short entry is still written', async () => {
|
|
@@ -220,6 +226,29 @@ test('a genuinely different longer fact against a short entry is still written',
|
|
|
220
226
|
assert.equal(Object.keys(index.entries).length, 2)
|
|
221
227
|
})
|
|
222
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
|
+
|
|
223
252
|
test('allowDuplicate: true writes a second independent observation without a tag', async () => {
|
|
224
253
|
const f = fixture()
|
|
225
254
|
const first = await memory.privateRemember({ exec: f.exec, title: '部署窗口约定', content: BODY_A })
|
|
@@ -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
|
})
|