dsh-memu-memory 1.2.0 → 1.2.1

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.
@@ -0,0 +1,190 @@
1
+ // Deletion cascade and entity lifecycle. The invariant under test: only the
2
+ // *permanent* delete path may lose graph structure. Archiving is reversible, so
3
+ // its links and entities must survive byte-for-byte (the boundary tests here
4
+ // exist to pin that against a well-meant "clean up while we are at it").
5
+ import test from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+
11
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-graph-cascade-'))
12
+ process.env.DSH_MEMU_HOME = home
13
+ const state = await import('../lib/state.js')
14
+ const graph = await import('../lib/graph.js')
15
+ const memory = await import('../lib/memory.js')
16
+ const { indexDir } = await import('../lib/paths.js')
17
+
18
+ test.after(() => { state.flushState(); fs.rmSync(home, { recursive: true, force: true }) })
19
+
20
+ const binding = state.ensureSession('cascade', { cwd: path.join(home, 'rp'), agentPreset: 'rp' })
21
+ const libraryId = binding.libraryId
22
+ const exec = { agent: { session: { header: { id: 'cascade' } } } }
23
+ const entitiesFile = path.join(indexDir(), `${libraryId}.entities.json`)
24
+ const readStore = () => JSON.parse(fs.readFileSync(entitiesFile, 'utf8'))
25
+
26
+ state.savePrivateIndex(libraryId, { entries: {
27
+ 'mem-root': { title: 'root', content: 'root scene', layer: 'long_term', status: 'archived', visibility: 'shared' },
28
+ 'mem-peer': { title: 'peer', content: 'peer scene', layer: 'long_term', status: 'active', visibility: 'shared' },
29
+ 'mem-side': { title: 'side', content: 'side scene', layer: 'long_term', status: 'active', visibility: 'shared' },
30
+ 'mem-free': { title: 'free', content: 'free scene', layer: 'long_term', status: 'active', visibility: 'shared' },
31
+ } })
32
+
33
+ test('permanent delete sweeps inbound links and collects the entity only that row kept alive', async () => {
34
+ const lin = graph.createEntity({ libraryId, name: 'Lin' })
35
+ state.updatePrivateIndex(libraryId, index => {
36
+ index.entries['mem-root'].links = [
37
+ { to: 'mem-peer', kind: 'event', note: 'The same event seen from another side.', confidence: 0.9 },
38
+ { to: lin.id, kind: 'person', note: 'Lin is the person this scene is about.', confidence: 0.9 },
39
+ ]
40
+ index.entries['mem-peer'].links = [{ to: 'mem-root', kind: 'event', note: 'Back-reference to the very same event.', confidence: 0.8 }]
41
+ })
42
+ const result = await memory.privateDelete({ exec, id: 'mem-root' })
43
+ assert.equal(result.deleted, true)
44
+ const fresh = state.loadPrivateIndex(libraryId)
45
+ assert.equal(fresh.entries['mem-root'], undefined, 'the row itself goes')
46
+ assert.deepEqual(fresh.entries['mem-peer'].links, [], 'an inbound link from a surviving entry is swept')
47
+ assert.equal(readStore().entities[lin.id], undefined, 'an entity nothing else points at is collected')
48
+ const view = graph.getGraph({ libraryId })
49
+ assert.ok(!view.nodes.some(node => node.id === 'mem-root'))
50
+ assert.ok(!view.nodes.some(node => node.id === lin.id), 'no orphan placeholder node survives')
51
+ })
52
+
53
+ test('archiving keeps links and entities so the memory stays restorable', async () => {
54
+ const yun = graph.createEntity({ libraryId, name: 'Yun' })
55
+ graph.linkEntries({ libraryId, fromId: 'mem-side', toId: yun.id, kind: 'person', note: 'Yun is the person this scene is about.' })
56
+ await memory.privateArchive({ exec, libraryId, id: 'mem-side' })
57
+ assert.equal(state.loadPrivateIndex(libraryId).entries['mem-side'].links.length, 1, 'archiving must not sweep links')
58
+ assert.ok(readStore().entities[yun.id], 'the referenced entity must survive an archive')
59
+ // Restore brings the memory back with its graph intact — the reason the
60
+ // archive path is forbidden from ever cascading.
61
+ await memory.privateUpdate({ exec, libraryId, id: 'mem-side', status: 'active' })
62
+ assert.equal(state.loadPrivateIndex(libraryId).entries['mem-side'].links.length, 1)
63
+ assert.ok(graph.getGraph({ libraryId, focusId: yun.id, depth: 1 }).nodes.some(node => node.id === yun.id))
64
+ })
65
+
66
+ test('deleteEntity deletes an unreferenced entity and refuses a referenced one until force', () => {
67
+ const solo = graph.createEntity({ libraryId, name: 'Solo' })
68
+ const gone = graph.deleteEntity({ libraryId, entityId: solo.id })
69
+ assert.equal(gone.deleted, true)
70
+ assert.equal(readStore().entities[solo.id], undefined)
71
+ assert.throws(() => graph.deleteEntity({ libraryId, entityId: solo.id }), /unknown entity/)
72
+
73
+ const kept = graph.createEntity({ libraryId, name: 'Kept' })
74
+ graph.linkEntries({ libraryId, fromId: 'mem-free', toId: kept.id, kind: 'person', note: 'Kept is the person this scene is about.' })
75
+ assert.throws(() => graph.deleteEntity({ libraryId, entityId: kept.id }), /still referenced/)
76
+ assert.ok(readStore().entities[kept.id], 'a refused delete leaves the entity in place')
77
+ assert.equal(state.loadPrivateIndex(libraryId).entries['mem-free'].links.length, 1, 'and its links too')
78
+ const forced = graph.deleteEntity({ libraryId, entityId: kept.id, force: true })
79
+ assert.equal(forced.removedLinks, 1)
80
+ assert.deepEqual(state.loadPrivateIndex(libraryId).entries['mem-free'].links, [])
81
+ assert.equal(readStore().entities[kept.id], undefined)
82
+ })
83
+
84
+ test('force-delete drops relationship snapshots with the entity, and the freed endpoint is collectable', () => {
85
+ const p = graph.createEntity({ libraryId, name: 'P' })
86
+ const q = graph.createEntity({ libraryId, name: 'Q' })
87
+ graph.updateRelationship({ libraryId, fromId: p.id, toId: q.id, state: { trust: .5 }, evidenceIds: ['mem-free'], reason: 'The scene itself is explicit evidence.' })
88
+ assert.throws(() => graph.deleteEntity({ libraryId, entityId: p.id }), /relationship snapshot/)
89
+ const forced = graph.deleteEntity({ libraryId, entityId: p.id, force: true })
90
+ assert.equal(forced.removedRelationships, 1)
91
+ assert.equal(graph.getRelationship({ libraryId, fromId: p.id, toId: q.id }).relationship, null)
92
+ // q has no links and no snapshot left: a prune collects it, while Yun stays.
93
+ const pruned = graph.pruneOrphanEntities(libraryId)
94
+ assert.ok(pruned.removed.includes(q.id), 'the freed endpoint becomes an orphan')
95
+ const yun = Object.values(readStore().entities).find(entity => entity.name === 'Yun')
96
+ assert.ok(!pruned.removed.includes(yun.id), 'a still-linked entity is never collected')
97
+ })
98
+
99
+ test('restoreGraphData collects entities the restored memories do not refer to', () => {
100
+ const before = Object.keys(readStore().entities)
101
+ const data = {
102
+ entities: {
103
+ 'ent-ghost': { id: 'ent-ghost', name: 'Ghost', kind: 'person', aliases: [] },
104
+ 'ent-real': { id: 'ent-real', name: 'Real', kind: 'person', aliases: [] },
105
+ },
106
+ relationships: {},
107
+ }
108
+ // mem-free's link to the backup's entity id gets remapped to the restored id,
109
+ // which is what keeps 'Real' alive while 'Ghost' (nothing refers to it) goes.
110
+ state.updatePrivateIndex(libraryId, index => {
111
+ index.entries['mem-free'].links = [{ to: 'ent-real', kind: 'person', note: 'Real is the person this scene is about.', confidence: 0.9 }]
112
+ })
113
+ graph.restoreGraphData(libraryId, data, { 'mem-old': 'mem-free' })
114
+ const after = readStore().entities
115
+ assert.ok(before.every(id => after[id]), 'the restore must not touch pre-existing entities')
116
+ assert.ok(!Object.values(after).some(entity => entity.name === 'Ghost'), 'an entity nothing restored refers to is collected')
117
+ const real = Object.values(after).find(entity => entity.name === 'Real')
118
+ assert.ok(real, 'an entity the restored link points at survives')
119
+ assert.equal(state.loadPrivateIndex(libraryId).entries['mem-free'].links[0].to, real.id, 'the link is remapped to the new id')
120
+ })
121
+
122
+ test('the startup sweep collects old-format orphans and is a no-op on healthy data', () => {
123
+ const sweepBinding = state.ensureSession('sweep', { cwd: path.join(home, 'ws-sweep'), agentPreset: 'coding' })
124
+ const sweepLib = sweepBinding.libraryId
125
+ state.savePrivateIndex(sweepLib, { entries: {
126
+ 'mem-s': { title: 's', content: 's scene', layer: 'long_term', status: 'active', visibility: 'shared',
127
+ links: [{ to: 'ent-kept', kind: 'person', note: 'Kept is the person this scene is about.', confidence: 0.9 }] },
128
+ } })
129
+ const sweepFile = path.join(indexDir(), `${sweepLib}.entities.json`)
130
+ fs.writeFileSync(sweepFile, JSON.stringify({ version: 1, entities: {
131
+ 'ent-kept': { id: 'ent-kept', name: 'Kept', kind: 'person', aliases: [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z' },
132
+ 'ent-lorn': { id: 'ent-lorn', name: 'Lorn', kind: 'thing', aliases: [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z' },
133
+ }, relationships: {} }))
134
+ const result = graph.pruneOrphanEntitiesAll()
135
+ assert.equal(result.failed.length, 0)
136
+ const store = JSON.parse(fs.readFileSync(sweepFile, 'utf8'))
137
+ assert.ok(store.entities['ent-kept'], 'a referenced entity survives the sweep')
138
+ assert.equal(store.entities['ent-lorn'], undefined, 'old-format orphans are collected')
139
+ const healthy = fs.readFileSync(sweepFile, 'utf8')
140
+ const again = graph.pruneOrphanEntitiesAll()
141
+ assert.equal(again.removed, 0, 'healthy data is a no-op')
142
+ assert.equal(fs.readFileSync(sweepFile, 'utf8'), healthy, 'the no-op never rewrites the file')
143
+ })
144
+
145
+ test('one corrupt entity store fails alone and never blocks the sweep', () => {
146
+ const badBinding = state.ensureSession('sweep-bad', { cwd: path.join(home, 'ws-bad'), agentPreset: 'coding' })
147
+ fs.writeFileSync(path.join(indexDir(), `${badBinding.libraryId}.entities.json`), '{ not json')
148
+ const result = graph.pruneOrphanEntitiesAll()
149
+ assert.ok(result.failed.some(failure => failure.libraryId === badBinding.libraryId), 'the corrupt library is reported')
150
+ assert.equal(result.failed.length, 1, 'other libraries are unaffected')
151
+ assert.throws(() => graph.pruneOrphanEntities(badBinding.libraryId), /entity store/)
152
+ })
153
+
154
+ test('getGraph hides orphan entities by default and reveals them on includeOrphans', () => {
155
+ const lonely = graph.createEntity({ libraryId, name: 'Lonely' })
156
+ const yun = Object.values(readStore().entities).find(entity => entity.name === 'Yun')
157
+ const bare = graph.getGraph({ libraryId })
158
+ assert.ok(!bare.nodes.some(node => node.id === lonely.id), 'an entity no edge points at is hidden by default')
159
+ assert.ok(bare.nodes.some(node => node.id === yun.id), 'a linked entity stays visible')
160
+ assert.ok(bare.edges.some(edge => edge.from === 'mem-side' && edge.to === yun.id))
161
+ const wide = graph.getGraph({ libraryId, includeOrphans: true })
162
+ const orphanNode = wide.nodes.find(node => node.id === lonely.id)
163
+ assert.ok(orphanNode, 'includeOrphans reveals what old data left behind')
164
+ assert.equal(orphanNode.degree, 0)
165
+ graph.deleteEntity({ libraryId, entityId: lonely.id })
166
+ })
167
+
168
+ test('apply() schedules the sweep late, and a corrupt store only warns instead of blocking load', async () => {
169
+ const badLib = state.ensureSession('sweep-bad', { cwd: path.join(home, 'ws-bad'), agentPreset: 'coding' }).libraryId
170
+ fs.writeFileSync(path.join(indexDir(), `${badLib}.entities.json`), '{ still not json')
171
+ const info = []
172
+ const warn = []
173
+ const tools = []
174
+ const scope = {
175
+ effect(fn) { fn() },
176
+ tools: { register(tool) { tools.push(tool); return () => {} } },
177
+ webServer: { register() { return () => {} } },
178
+ connection: { requestRejection: () => undefined },
179
+ interval() { return 1 },
180
+ on() { return () => {} },
181
+ }
182
+ const ctx = { ...scope, logger: { info: (m) => info.push(m), warn: (m) => warn.push(m) }, inject(_deps, callback) { callback(scope) } }
183
+ const plugin = await import('../lib/index.js')
184
+ plugin.apply(ctx)
185
+ assert.ok(info.some(message => message.startsWith('dsh-memu-memory: loaded')), 'activation itself is unaffected')
186
+ assert.equal(warn.some(message => message.includes('orphan entity sweep')), false, 'scheduling must not run the sweep synchronously')
187
+ await new Promise(resolve => setTimeout(resolve, 4600))
188
+ assert.ok(warn.some(message => message.includes(`orphan entity sweep skipped ${badLib}`)), 'the sweep reports the corrupt library')
189
+ assert.equal(warn.some(message => message.includes('orphan entity sweep failed')), false, 'per-library failures stay contained')
190
+ })
@@ -75,3 +75,20 @@ test('relationship snapshots preserve unknowns, evidence, boundaries and previou
75
75
  assert.ok(next.history[0].validTo)
76
76
  assert.throws(() => graph.updateRelationship({ libraryId, fromId: a.id, toId: b.id, state: {}, evidenceIds: [], reason: 'no evidence' }), /evidence/)
77
77
  })
78
+
79
+ test('a dangling edge is reported as missing, matching the graph view placeholder', () => {
80
+ // mem-1 was deleted from the index in an earlier test; its inbound edge from
81
+ // mem-0 remains. getGraph renders a [deleted] placeholder for it, so recall
82
+ // must report the same edge with the same missing mark instead of dropping it.
83
+ const view = graph.getGraph({ libraryId, focusId: 'mem-0', depth: 1 })
84
+ assert.equal(view.nodes.find(node => node.id === 'mem-1').missing, true)
85
+ const related = graph.recallRelations(libraryId, state.loadPrivateIndex(libraryId), 'mem-0', null)
86
+ const dangling = related.find(item => item.id === 'mem-1')
87
+ assert.ok(dangling, 'the edge must be reported, not silently dropped')
88
+ assert.equal(dangling.missing, true)
89
+ assert.equal(dangling.title, '[deleted]')
90
+ // mem-2 still links at the Zhou entity: a resolvable relation is marked false.
91
+ const zhou = Object.values(graph.graphExport(libraryId).entities).find(entity => entity.name === 'Zhou')
92
+ const healthy = graph.recallRelations(libraryId, state.loadPrivateIndex(libraryId), 'mem-2', null)
93
+ assert.equal(healthy.find(item => item.id === zhou.id).missing, false)
94
+ })
@@ -69,9 +69,16 @@ test('eligibility and context are applied before the candidate cap', () => {
69
69
  test('the per-route candidate cap is bounded and deterministic', () => {
70
70
  const entries = {}
71
71
  const local = []
72
+ // Distinct bodies: near-identical candidates would fold into one cluster slot
73
+ // (fact-level dedup), and this test is about the cap, not the folding.
74
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz'
75
+ const token = (n) => alphabet[n % 26] + alphabet[Math.floor(n / 26) % 26]
72
76
  for (let i = 0; i < 60; i += 1) {
73
77
  const id = 'mem-' + String(i).padStart(2, '0')
74
- entries[id] = entry({ title: 'memory ' + i })
78
+ entries[id] = entry({
79
+ title: 'topic ' + token(180 + i),
80
+ content: [token(3 * i), token(3 * i + 1), token(3 * i + 2)].join('of'),
81
+ })
75
82
  local.push({ name: id })
76
83
  }
77
84
  assert.equal(memory.hybridCandidateLimit(1), 12)
@@ -83,8 +90,31 @@ test('the per-route candidate cap is bounded and deterministic', () => {
83
90
  assert.deepEqual(fused.files.map((file) => file.name), local.slice(0, 12).map((file) => file.name).sort((a, b) => a.localeCompare(b)))
84
91
  })
85
92
 
93
+ test('near-duplicate candidates fold into one candidate slot before the cap', () => {
94
+ const entries = {}
95
+ const local = []
96
+ for (let i = 0; i < 12; i += 1) {
97
+ const id = 'mem-dup-' + String(i).padStart(2, '0')
98
+ entries[id] = entry({ title: 'deploy window ' + (i ? 'variant ' + i : ''), content: 'the deploy window is thursday two to four am, grey releases only' })
99
+ local.push({ name: id })
100
+ }
101
+ entries['mem-real'] = entry({ title: 'reimbursement deadline', content: 'expense reports are due before the last working day of each month' })
102
+ const fused = memory.fuseRecallCandidates({ local: [...local, { name: 'mem-real' }], semantic: [], index: indexOf(entries), limit: 12 })
103
+ // The cluster spends one slot instead of twelve, so the real memory enters
104
+ // even though 13 candidates competed for 12 places.
105
+ assert.equal(fused.debug.localCandidates, 2)
106
+ assert.deepEqual(fused.files.map((file) => file.name), ['mem-dup-00', 'mem-real'])
107
+ assert.deepEqual(fused.debug.collapsed, [{ kept: 'mem-dup-00', dropped: local.slice(1).map((file) => file.name) }])
108
+ assert.equal(fused.debug.clusterCount, 1)
109
+ })
110
+
86
111
  test('equal fused scores fall back to the best rank and then the canonical id', () => {
87
- const index = indexOf({ 'mem-a': entry(), 'mem-b': entry() })
112
+ // Distinct texts: two byte-identical memories would fold as one fact before
113
+ // the tie-break this test is about ever gets a chance to run.
114
+ const index = indexOf({
115
+ 'mem-a': entry({ title: 'alpha topic', content: 'alpha body' }),
116
+ 'mem-b': entry({ title: 'beta topic', content: 'beta body' }),
117
+ })
88
118
  const first = memory.fuseRecallCandidates({ local: [{ name: 'mem-a' }], semantic: [{ name: 'mem-b' }], index })
89
119
  const second = memory.fuseRecallCandidates({ local: [{ name: 'mem-a' }], semantic: [{ name: 'mem-b' }], index })
90
120
  assert.deepEqual(first.files.map((file) => file.name), ['mem-a', 'mem-b'])
@@ -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() })