dsh-memu-memory 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/README.md +19 -8
- package/README.zh.md +12 -6
- package/docs/contributing.md +16 -3
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -3
- package/lib/activity-log.js +22 -5
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +3 -3
- package/lib/client.js +1610 -117
- package/lib/consolidation-worker.js +68 -17
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1299 -83
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +190 -14
- package/lib/inbound.js +19 -0
- package/lib/index.js +33 -1
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +585 -76
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +128 -7
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +622 -13
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +124 -0
- package/lib/tools.js +230 -16
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/scripts/replay.mjs +573 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +144 -10
- package/tests/consolidation-snapshot.test.mjs +82 -4
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +324 -0
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +223 -0
- package/tests/graph.test.mjs +113 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +446 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +191 -2
|
@@ -0,0 +1,223 @@
|
|
|
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 () => { const badLib = state.ensureSession('sweep-bad', { cwd: path.join(home, 'ws-bad'), agentPreset: 'coding' }).libraryId
|
|
169
|
+
fs.writeFileSync(path.join(indexDir(), `${badLib}.entities.json`), '{ still not json')
|
|
170
|
+
const info = []
|
|
171
|
+
const warn = []
|
|
172
|
+
const tools = []
|
|
173
|
+
const scope = {
|
|
174
|
+
effect(fn) { fn() },
|
|
175
|
+
tools: { register(tool) { tools.push(tool); return () => {} } },
|
|
176
|
+
webServer: { register() { return () => {} } },
|
|
177
|
+
connection: { requestRejection: () => undefined },
|
|
178
|
+
interval() { return 1 },
|
|
179
|
+
on() { return () => {} },
|
|
180
|
+
}
|
|
181
|
+
const ctx = { ...scope, logger: { info: (m) => info.push(m), warn: (m) => warn.push(m) }, inject(_deps, callback) { callback(scope) } }
|
|
182
|
+
const plugin = await import('../lib/index.js')
|
|
183
|
+
plugin.apply(ctx)
|
|
184
|
+
assert.ok(info.some(message => message.startsWith('dsh-memu-memory: loaded')), 'activation itself is unaffected')
|
|
185
|
+
assert.equal(warn.some(message => message.includes('orphan entity sweep')), false, 'scheduling must not run the sweep synchronously')
|
|
186
|
+
await new Promise(resolve => setTimeout(resolve, 4600))
|
|
187
|
+
assert.ok(warn.some(message => message.includes(`orphan entity sweep skipped ${badLib}`)), 'the sweep reports the corrupt library')
|
|
188
|
+
assert.equal(warn.some(message => message.includes('orphan entity sweep failed')), false, 'per-library failures stay contained')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// ---- §6.4 包 D:linked 派生字段与删除路径的一致性 ----
|
|
192
|
+
// 只有 relationship、没有 memory link 的实体是历史,不是孤儿——linked 必须为
|
|
193
|
+
// true,启动 prune 也必须放行它;判定口径与 pruneOrphanEntities 完全一致。
|
|
194
|
+
|
|
195
|
+
test('a relationship-only entity is linked, not an orphan, and prune agrees', () => {
|
|
196
|
+
const soloA = graph.createEntity({ libraryId, name: 'RelOnlyA' })
|
|
197
|
+
const soloB = graph.createEntity({ libraryId, name: 'RelOnlyB' })
|
|
198
|
+
graph.updateRelationship({ libraryId, fromId: soloA.id, toId: soloB.id, state: { familiarity: 0.4 }, evidenceIds: ['mem-free'], reason: 'The free scene is explicit evidence.' })
|
|
199
|
+
const rows = graph.listEntities({ libraryId }).items
|
|
200
|
+
const rowA = rows.find(entity => entity.id === soloA.id)
|
|
201
|
+
const rowB = rows.find(entity => entity.id === soloB.id)
|
|
202
|
+
assert.ok(rowA && rowB, 'no session means the pair comes back with everyone else')
|
|
203
|
+
assert.equal(rowA.linked, true, 'a snapshot endpoint is a reference even without any memory link')
|
|
204
|
+
assert.equal(rowB.linked, true)
|
|
205
|
+
const pruned = graph.pruneOrphanEntities(libraryId)
|
|
206
|
+
assert.ok(!pruned.removed.includes(soloA.id) && !pruned.removed.includes(soloB.id),
|
|
207
|
+
'the prune must share the linked judgement, never collect snapshot-referenced entities')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('the linked flag tracks a refused delete through force, and cleanup drops the row', () => {
|
|
211
|
+
const kept = graph.createEntity({ libraryId, name: 'FlagKept' })
|
|
212
|
+
graph.linkEntries({ libraryId, fromId: 'mem-free', toId: kept.id, kind: 'person', note: 'FlagKept is the person this scene is about.' })
|
|
213
|
+
let row = graph.listEntities({ libraryId }).items.find(entity => entity.id === kept.id)
|
|
214
|
+
assert.equal(row.linked, true, 'an inbound link shows up as linked before any delete')
|
|
215
|
+
assert.throws(() => graph.deleteEntity({ libraryId, entityId: kept.id }), /still referenced/, 'the refusal keeps both the row and its link')
|
|
216
|
+
assert.ok(graph.listEntities({ libraryId }).items.some(entity => entity.id === kept.id))
|
|
217
|
+
const forced = graph.deleteEntity({ libraryId, entityId: kept.id, force: true })
|
|
218
|
+
assert.equal(forced.deleted, true)
|
|
219
|
+
assert.equal(forced.removedLinks, 1, 'force sweeps the inbound link')
|
|
220
|
+
assert.ok(state.loadPrivateIndex(libraryId).entries['mem-free'].links.every(link => link.to !== kept.id), 'the forced delete sweeps the inbound link of this entity')
|
|
221
|
+
row = graph.listEntities({ libraryId }).items.find(entity => entity.id === kept.id)
|
|
222
|
+
assert.equal(row, undefined, 'after the force delete the entity is gone from the listing too')
|
|
223
|
+
})
|
package/tests/graph.test.mjs
CHANGED
|
@@ -49,6 +49,44 @@ test('invalid kinds, empty reasons and out-of-scope private links are rejected',
|
|
|
49
49
|
assert.ok(!JSON.stringify(graph.getGraph({ exec })).includes('private title'))
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
+
test('an explicit link source persists on the record and surfaces on graph edges', () => {
|
|
53
|
+
state.updatePrivateIndex(libraryId, index => {
|
|
54
|
+
index.entries['mem-src-a'] = { title: 'alpha note', content: 'alpha body', status: 'active', visibility: 'shared' }
|
|
55
|
+
index.entries['mem-src-b'] = { title: 'beta note', content: 'beta body', status: 'active', visibility: 'shared' }
|
|
56
|
+
index.entries['mem-src-c'] = { title: 'gamma note', content: 'gamma body', status: 'active', visibility: 'shared' }
|
|
57
|
+
})
|
|
58
|
+
const linked = graph.linkEntries({
|
|
59
|
+
libraryId, fromId: 'mem-src-a', toId: 'mem-src-b', kind: 'relation',
|
|
60
|
+
note: 'Auto-linked because both memories share the stable name Alpha.', source: 'auto',
|
|
61
|
+
})
|
|
62
|
+
assert.equal(linked.source, 'auto')
|
|
63
|
+
const stored = () => state.loadPrivateIndex(libraryId).entries['mem-src-a'].links
|
|
64
|
+
assert.equal(stored().find(link => link.to === 'mem-src-b').source, 'auto', 'auto is persisted, not only returned')
|
|
65
|
+
|
|
66
|
+
// Replacement stays idempotent and the source survives the rewrite.
|
|
67
|
+
graph.linkEntries({
|
|
68
|
+
libraryId, fromId: 'mem-src-a', toId: 'mem-src-b', kind: 'relation',
|
|
69
|
+
note: 'Re-linked after a rerun with the same evidence.', source: 'auto',
|
|
70
|
+
})
|
|
71
|
+
const replaced = stored().filter(link => link.to === 'mem-src-b')
|
|
72
|
+
assert.equal(replaced.length, 1, 'the same (to, kind) pair is replaced in place')
|
|
73
|
+
assert.equal(replaced[0].source, 'auto')
|
|
74
|
+
|
|
75
|
+
// Only auto/user are meaningful; anything else is omitted rather than stored.
|
|
76
|
+
graph.linkEntries({
|
|
77
|
+
libraryId, fromId: 'mem-src-b', toId: 'mem-src-c', kind: 'event',
|
|
78
|
+
note: 'The same event seen from another side.', source: 'robot',
|
|
79
|
+
})
|
|
80
|
+
assert.equal(state.loadPrivateIndex(libraryId).entries['mem-src-b'].links[0].source, undefined)
|
|
81
|
+
|
|
82
|
+
const view = graph.getGraph({ libraryId, focusId: 'mem-src-b', depth: 1 })
|
|
83
|
+
const autoEdge = view.edges.find(edge => edge.from === 'mem-src-a' && edge.to === 'mem-src-b')
|
|
84
|
+
assert.equal(autoEdge.source, 'auto', 'the UI can tag an auto link')
|
|
85
|
+
assert.equal(autoEdge.kind, 'relation', 'link semantics are unchanged')
|
|
86
|
+
const plainEdge = view.edges.find(edge => edge.from === 'mem-src-b')
|
|
87
|
+
assert.equal(plainEdge.source, null, 'a link without a source reports null, not undefined')
|
|
88
|
+
})
|
|
89
|
+
|
|
52
90
|
test('graphs cap nodes at 200 and the recall carries at most two pointers', async () => {
|
|
53
91
|
state.updatePrivateIndex(libraryId, index => {
|
|
54
92
|
for (let i = 10; i < 400; i++) index.entries[`mem-${i}`] = { title: `node ${i}`, status: 'active', content: 'node' }
|
|
@@ -75,3 +113,78 @@ test('relationship snapshots preserve unknowns, evidence, boundaries and previou
|
|
|
75
113
|
assert.ok(next.history[0].validTo)
|
|
76
114
|
assert.throws(() => graph.updateRelationship({ libraryId, fromId: a.id, toId: b.id, state: {}, evidenceIds: [], reason: 'no evidence' }), /evidence/)
|
|
77
115
|
})
|
|
116
|
+
|
|
117
|
+
test('a dangling edge is reported as missing, matching the graph view placeholder', () => {
|
|
118
|
+
// mem-1 was deleted from the index in an earlier test; its inbound edge from
|
|
119
|
+
// mem-0 remains. getGraph renders a [deleted] placeholder for it, so recall
|
|
120
|
+
// must report the same edge with the same missing mark instead of dropping it.
|
|
121
|
+
const view = graph.getGraph({ libraryId, focusId: 'mem-0', depth: 1 })
|
|
122
|
+
assert.equal(view.nodes.find(node => node.id === 'mem-1').missing, true)
|
|
123
|
+
const related = graph.recallRelations(libraryId, state.loadPrivateIndex(libraryId), 'mem-0', null)
|
|
124
|
+
const dangling = related.find(item => item.id === 'mem-1')
|
|
125
|
+
assert.ok(dangling, 'the edge must be reported, not silently dropped')
|
|
126
|
+
assert.equal(dangling.missing, true)
|
|
127
|
+
assert.equal(dangling.title, '[deleted]')
|
|
128
|
+
// mem-2 still links at the Zhou entity: a resolvable relation is marked false.
|
|
129
|
+
const zhou = Object.values(graph.graphExport(libraryId).entities).find(entity => entity.name === 'Zhou')
|
|
130
|
+
const healthy = graph.recallRelations(libraryId, state.loadPrivateIndex(libraryId), 'mem-2', null)
|
|
131
|
+
assert.equal(healthy.find(item => item.id === zhou.id).missing, false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// ---- §6.4 包 D:listEntities 的 linked 派生字段与孤立判定 ----
|
|
135
|
+
// 前提(合并稿 0.2 纠正):无 session 本来就返回全部实体——不"放开过滤",
|
|
136
|
+
// 只加 linked 标记;有 session 才按可见引用收窄。
|
|
137
|
+
|
|
138
|
+
test('listEntities: without a session every entity returns, with a correct derived linked flag', () => {
|
|
139
|
+
const binding = state.ensureSession('vis-all', { cwd: path.join(home, 'ws-vis-all'), agentPreset: 'rp' })
|
|
140
|
+
const lib = binding.libraryId
|
|
141
|
+
const anchored = graph.createEntity({ libraryId: lib, name: 'Anchored' })
|
|
142
|
+
const drifting = graph.createEntity({ libraryId: lib, name: 'Drifting' })
|
|
143
|
+
state.savePrivateIndex(lib, { entries: {
|
|
144
|
+
'mem-anchored': { title: 'anchored', content: 'scene', layer: 'long_term', status: 'active', visibility: 'shared',
|
|
145
|
+
links: [{ to: anchored.id, kind: 'person', note: 'Anchored is the person this scene is about.', confidence: 0.9 }] },
|
|
146
|
+
} })
|
|
147
|
+
const all = graph.listEntities({ libraryId: lib })
|
|
148
|
+
assert.equal(all.items.length, 2, 'without a session the full entity list comes back')
|
|
149
|
+
assert.equal(all.total, 2)
|
|
150
|
+
const anchoredRow = all.items.find(entity => entity.id === anchored.id)
|
|
151
|
+
const driftingRow = all.items.find(entity => entity.id === drifting.id)
|
|
152
|
+
assert.equal(anchoredRow.linked, true, 'a link target is linked')
|
|
153
|
+
assert.equal(driftingRow.linked, false, 'an entity nothing points at is unlinked')
|
|
154
|
+
// linked 是运行时派生字段:实体文件里绝不出现它,启动 prune 的判定不受污染。
|
|
155
|
+
assert.equal(graph.graphExport(lib).entities[drifting.id].linked, undefined, 'the derived flag is never written back')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('listEntities: with a session only visible references count, and relationship evidence is session-filtered too', () => {
|
|
159
|
+
const binding = state.ensureSession('vis-scoped', { cwd: path.join(home, 'ws-vis-scoped'), agentPreset: 'rp' })
|
|
160
|
+
const lib = binding.libraryId
|
|
161
|
+
const exec = { agent: { session: { header: { id: 'vis-scoped' } } } }
|
|
162
|
+
const linked = graph.createEntity({ libraryId: lib, name: 'Linked' })
|
|
163
|
+
const secretTarget = graph.createEntity({ libraryId: lib, name: 'SecretTarget' })
|
|
164
|
+
const relVisible = graph.createEntity({ libraryId: lib, name: 'RelVisible' })
|
|
165
|
+
const relHidden = graph.createEntity({ libraryId: lib, name: 'RelHidden' })
|
|
166
|
+
const lonely = graph.createEntity({ libraryId: lib, name: 'Lonely' })
|
|
167
|
+
state.savePrivateIndex(lib, { entries: {
|
|
168
|
+
'mem-open': { title: 'open', content: 'open scene', layer: 'long_term', status: 'active', visibility: 'shared',
|
|
169
|
+
links: [{ to: linked.id, kind: 'person', note: 'Linked is the person this scene is about.', confidence: 0.9 }] },
|
|
170
|
+
'mem-hidden': { title: 'hidden', content: 'secret scene', layer: 'long_term', status: 'active', visibility: 'secret', context: { characterId: 'other' },
|
|
171
|
+
links: [{ to: secretTarget.id, kind: 'person', note: 'Secret is the person this scene is about.', confidence: 0.9 }] },
|
|
172
|
+
} })
|
|
173
|
+
// 关系快照也算引用:evidence 指向本会话可见记忆的算,指不可见记忆的不算。
|
|
174
|
+
graph.updateRelationship({ libraryId: lib, fromId: linked.id, toId: relVisible.id, state: { trust: 0.5 }, evidenceIds: ['mem-open'], reason: 'The open scene is explicit evidence.' })
|
|
175
|
+
graph.updateRelationship({ libraryId: lib, fromId: secretTarget.id, toId: relHidden.id, state: { trust: 0.5 }, evidenceIds: ['mem-hidden'], reason: 'The hidden scene is the only evidence here.' })
|
|
176
|
+
const scoped = graph.listEntities({ exec, libraryId: lib })
|
|
177
|
+
const ids = scoped.items.map(entity => entity.id).sort()
|
|
178
|
+
assert.deepEqual(ids, [linked.id, relVisible.id].sort(),
|
|
179
|
+
'a visible entry link and a relationship with visible evidence both count; secret-referenced and orphan entities do not')
|
|
180
|
+
assert.ok(scoped.items.every(entity => entity.linked === true), 'every session-visible row is linked by construction')
|
|
181
|
+
// 无 session:全量计入(两条快照都算引用),Linked/RelVisible/SecretTarget/RelHidden
|
|
182
|
+
// 都是 linked=true,只有 Lonely 是孤立。
|
|
183
|
+
const all = graph.listEntities({ libraryId: lib })
|
|
184
|
+
assert.equal(all.items.length, 5, 'no session means every entity, referenced or not')
|
|
185
|
+
const linkedMap = Object.fromEntries(all.items.map(entity => [entity.id, entity.linked]))
|
|
186
|
+
for (const entity of [linked, relVisible, secretTarget, relHidden]) {
|
|
187
|
+
assert.equal(linkedMap[entity.id], true, `${entity.name} is referenced and must not be misjudged as an orphan`)
|
|
188
|
+
}
|
|
189
|
+
assert.equal(linkedMap[lonely.id], false, 'the untouched entity stays unlinked')
|
|
190
|
+
})
|