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
|
@@ -425,6 +425,61 @@ test('a write under an active generation records which vector space it entered',
|
|
|
425
425
|
assert.equal(state.loadPrivateIndex(f.libraryId).entries[plain.id].vector, undefined)
|
|
426
426
|
})
|
|
427
427
|
|
|
428
|
+
test('the vector count follows reality: writes stamp, review/import/curator stamp, deletes shrink', async () => {
|
|
429
|
+
// 现场事故(2026-10-31):设置页"当前已入向量库 N 条"只数迁移账本 —— 账本在切换
|
|
430
|
+
// 那一刻写完就再也不更新,聊天里新写的记忆永远不进计数。membership = 账本腿(迁移
|
|
431
|
+
// 写入,扣除已删除)+ 戳腿(迁移后写入)。每条迁移后写入路径都必须落戳,否则漏数。
|
|
432
|
+
const vm = await import('../lib/vector-migration.js')
|
|
433
|
+
const f = fixture({
|
|
434
|
+
'mem-migrated': entry('迁移时就有的记忆'),
|
|
435
|
+
'mem-held': entry('待审的主张', { status: 'pending_review' }),
|
|
436
|
+
})
|
|
437
|
+
activate([f.libraryId])
|
|
438
|
+
// 预置"切换时写入 1/1"的账本快照:mem-migrated 在迁移时进了向量库(索引故意无戳)。
|
|
439
|
+
const { ledger } = vm.loadLedger(profile.fingerprint, 1)
|
|
440
|
+
ledger.libraries[f.libraryId] = { items: { 'mem-migrated': { hash: 'seed', at: new Date().toISOString() } }, tombstones: {}, snapshot: null }
|
|
441
|
+
vm.saveLedger(ledger)
|
|
442
|
+
const migrator = vm.createVectorMigrator({
|
|
443
|
+
listLibraries: () => state.listLibraries().map(item => item.id),
|
|
444
|
+
loadIndex: libraryId => libraryId == null ? state.loadGlobalIndex() : state.loadPrivateIndex(libraryId),
|
|
445
|
+
commit: async () => {}, forget: async () => {}, listFiles: async () => [],
|
|
446
|
+
})
|
|
447
|
+
const idsOf = scope => (migrator.membership()[scope] || []).sort()
|
|
448
|
+
assert.deepEqual(idsOf(f.libraryId), ['mem-migrated'], 'starts at the migration count — the reported bug state')
|
|
449
|
+
|
|
450
|
+
// 聊天写入:戳腿接管,计数跟着涨。
|
|
451
|
+
const written = await memory.privateRemember({ exec: f.exec, content: '切换后新写的记忆', title: '新记忆' })
|
|
452
|
+
assert.equal(state.loadPrivateIndex(f.libraryId).entries[written.id].vector.generation, 1)
|
|
453
|
+
assert.deepEqual(idsOf(f.libraryId), ['mem-migrated', written.id])
|
|
454
|
+
|
|
455
|
+
// 编辑重提交:行被重写进当前空间,戳刷新到当前代际。
|
|
456
|
+
await memory.privateUpdate({ exec: f.exec, id: written.id, content: '更新后的记忆内容' })
|
|
457
|
+
const updated = state.loadPrivateIndex(f.libraryId).entries[written.id]
|
|
458
|
+
assert.equal(updated.vector.generation, 1)
|
|
459
|
+
assert.equal(updated.vector.route, 'managed-local')
|
|
460
|
+
assert.deepEqual(idsOf(f.libraryId), ['mem-migrated', written.id])
|
|
461
|
+
|
|
462
|
+
// 批量恢复与评审接受:此前完全不落戳,是计数漏数的另外两条路。
|
|
463
|
+
await memory.importMemories({ libraryId: f.libraryId, memories: [{ content: '恢复回来的独特观察' }] })
|
|
464
|
+
const imported = Object.entries(state.loadPrivateIndex(f.libraryId).entries).find(([, item]) => item.content === '恢复回来的独特观察')[0]
|
|
465
|
+
assert.equal(state.loadPrivateIndex(f.libraryId).entries[imported].vector.generation, 1)
|
|
466
|
+
await memory.resolveMemoryReview({ libraryId: f.libraryId, id: 'mem-held', action: 'accept' })
|
|
467
|
+
assert.equal(state.loadPrivateIndex(f.libraryId).entries['mem-held'].vector.generation, 1)
|
|
468
|
+
assert.ok(idsOf(f.libraryId).includes(imported) && idsOf(f.libraryId).includes('mem-held'))
|
|
469
|
+
|
|
470
|
+
// 永久删除:引擎行先删、索引行后删,账本不知道 —— membership 必须把它扣掉。
|
|
471
|
+
await memory.privateArchive({ exec: f.exec, id: 'mem-migrated' })
|
|
472
|
+
await memory.privateDelete({ exec: f.exec, id: 'mem-migrated' })
|
|
473
|
+
assert.equal(idsOf(f.libraryId).includes('mem-migrated'), false, 'a permanently deleted migration item leaves the count')
|
|
474
|
+
|
|
475
|
+
// 全局整理:同样的口径,提交成功即落戳。
|
|
476
|
+
const clean = await curator.commitCuratorOutput({ memories: [curated()] }, { job: job() })
|
|
477
|
+
assert.equal(clean.created, 1)
|
|
478
|
+
const curatedId = Object.keys(state.loadGlobalIndex().entries)[0]
|
|
479
|
+
assert.equal(state.loadGlobalIndex().entries[curatedId].vector.route, 'managed-local')
|
|
480
|
+
assert.deepEqual((migrator.membership().global || []), [curatedId])
|
|
481
|
+
})
|
|
482
|
+
|
|
428
483
|
test('managed curator clean output commits under lease and held output is index-only', async () => {
|
|
429
484
|
activate()
|
|
430
485
|
const clean = await curator.commitCuratorOutput({ memories: [curated()] }, { job: job() })
|
|
@@ -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":[]}' }
|