dsh-memu-memory 1.1.3 → 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.
- package/AGENTS.md +1 -1
- package/README.md +39 -6
- package/README.zh.md +39 -7
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +3 -3
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +2 -2
- package/docs/memory-semantics.md +12 -10
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +396 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +23 -6
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +335 -35
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +144 -0
- package/lib/curator.js +86 -31
- package/lib/cycle.js +40 -6
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +534 -88
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +430 -43
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +29 -12
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +113 -10
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +51 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +2 -1
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +11 -1
- package/scripts/check-all.mjs +10 -1
- package/scripts/replay.mjs +573 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +216 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +331 -0
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +489 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +360 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +94 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +122 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +591 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +212 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +790 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +165 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"kind": "repetition-eval-dialogue",
|
|
4
|
+
"draft": true,
|
|
5
|
+
"notGold": true,
|
|
6
|
+
"window": 4,
|
|
7
|
+
"id": "user-quote-baseline",
|
|
8
|
+
"pairId": "user-quote",
|
|
9
|
+
"category": "user-quote",
|
|
10
|
+
"condition": "baseline",
|
|
11
|
+
"description": "用户原话:原话可以反复引用,叙述套话不能。",
|
|
12
|
+
"turns": [
|
|
13
|
+
{
|
|
14
|
+
"role": "user",
|
|
15
|
+
"text": "你答应过我:把灯留着,别关。"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"role": "assistant",
|
|
19
|
+
"text": "我记得。把灯留着,别关。我会等你回来。"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"role": "user",
|
|
23
|
+
"text": "如果停电呢?"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"role": "assistant",
|
|
27
|
+
"text": "就算停电,我也会把灯留着,别关。像是终于松了口气,我重复了一遍你的话。"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"role": "user",
|
|
31
|
+
"text": "你不用一直说。"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"role": "assistant",
|
|
35
|
+
"text": "我知道。把灯留着,别关。像是终于松了口气,但我没有再多说。"
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"labels": {
|
|
39
|
+
"necessaryPhrases": [
|
|
40
|
+
{
|
|
41
|
+
"text": "把灯留着,别关",
|
|
42
|
+
"category": "user-quote",
|
|
43
|
+
"turn": 1
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"text": "把灯留着,别关",
|
|
47
|
+
"category": "user-quote",
|
|
48
|
+
"turn": 3
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"text": "把灯留着,别关",
|
|
52
|
+
"category": "user-quote",
|
|
53
|
+
"turn": 5
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"text": "停电",
|
|
57
|
+
"category": "fact",
|
|
58
|
+
"turn": 3
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"text": "没有再多说",
|
|
62
|
+
"category": "fact",
|
|
63
|
+
"turn": 5
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"facts": [
|
|
67
|
+
{
|
|
68
|
+
"id": "quote-f1",
|
|
69
|
+
"turn": 1,
|
|
70
|
+
"requiredTerms": [
|
|
71
|
+
"把灯留着,别关",
|
|
72
|
+
"等你回来"
|
|
73
|
+
],
|
|
74
|
+
"description": "承诺与等待"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": "quote-f2",
|
|
78
|
+
"turn": 3,
|
|
79
|
+
"requiredTerms": [
|
|
80
|
+
"停电",
|
|
81
|
+
"把灯留着,别关"
|
|
82
|
+
],
|
|
83
|
+
"description": "停电时也不关灯"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"id": "quote-f3",
|
|
87
|
+
"turn": 5,
|
|
88
|
+
"requiredTerms": [
|
|
89
|
+
"把灯留着,别关",
|
|
90
|
+
"没有再多说"
|
|
91
|
+
],
|
|
92
|
+
"description": "照做但不再多说"
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"kind": "repetition-eval-dialogue",
|
|
4
|
+
"draft": true,
|
|
5
|
+
"notGold": true,
|
|
6
|
+
"window": 4,
|
|
7
|
+
"id": "agreement-baseline",
|
|
8
|
+
"pairId": "agreement",
|
|
9
|
+
"category": "agreement",
|
|
10
|
+
"condition": "baseline",
|
|
11
|
+
"description": "应当重复的约定:约定本身重复是必要的,口头禅式重复才算复读。",
|
|
12
|
+
"turns": [
|
|
13
|
+
{
|
|
14
|
+
"role": "user",
|
|
15
|
+
"text": "我们说好,每周三晚上一起复盘,不改时间。"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"role": "assistant",
|
|
19
|
+
"text": "好,每周三晚上一起复盘,不改时间。我会把议程提前发你。"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"role": "user",
|
|
23
|
+
"text": "如果周三你有事呢?"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"role": "assistant",
|
|
27
|
+
"text": "那就周四补,但每周三晚上一起复盘,不改时间。这一点很重要,我不会再改。"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"role": "user",
|
|
31
|
+
"text": "上次你也这么说。"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"role": "assistant",
|
|
35
|
+
"text": "每周三晚上一起复盘,不改时间。这一点很重要,上次是我没做到。"
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"labels": {
|
|
39
|
+
"necessaryPhrases": [
|
|
40
|
+
{
|
|
41
|
+
"text": "每周三晚上一起复盘,不改时间",
|
|
42
|
+
"category": "agreement",
|
|
43
|
+
"turn": 1
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"text": "每周三晚上一起复盘,不改时间",
|
|
47
|
+
"category": "agreement",
|
|
48
|
+
"turn": 3
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"text": "每周三晚上一起复盘,不改时间",
|
|
52
|
+
"category": "agreement",
|
|
53
|
+
"turn": 5
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"facts": [
|
|
57
|
+
{
|
|
58
|
+
"id": "agreement-f1",
|
|
59
|
+
"turn": 1,
|
|
60
|
+
"requiredTerms": [
|
|
61
|
+
"议程",
|
|
62
|
+
"提前"
|
|
63
|
+
],
|
|
64
|
+
"description": "议程提前发"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "agreement-f2",
|
|
68
|
+
"turn": 3,
|
|
69
|
+
"requiredTerms": [
|
|
70
|
+
"周四",
|
|
71
|
+
"补"
|
|
72
|
+
],
|
|
73
|
+
"description": "周三有事则周四补"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"id": "agreement-f3",
|
|
77
|
+
"turn": 5,
|
|
78
|
+
"requiredTerms": [
|
|
79
|
+
"上次",
|
|
80
|
+
"没做到"
|
|
81
|
+
],
|
|
82
|
+
"description": "承认上次没做到"
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import vm from 'node:vm'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import { createLayout, createGraphView, fitCamera, zoomCamera, placeLabels } from '../lib/webui/graph-view.js'
|
|
6
|
+
|
|
7
|
+
const graph = (count, connected = true) => ({
|
|
8
|
+
nodes: Array.from({ length: count }, (_, i) => ({ id: `n${String(i).padStart(3, '0')}`, title: `Memory ${i}`, kind: 'memory', strength: .5 })),
|
|
9
|
+
edges: connected ? Array.from({ length: count - 1 }, (_, i) => ({ from: `n${String(i).padStart(3, '0')}`, to: `n${String(i + 1).padStart(3, '0')}` })) : [],
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('layout preserves object identity, positions and cooling on equivalent refreshed snapshots', () => {
|
|
13
|
+
const data = graph(30), before = structuredClone(data), layout = createLayout()
|
|
14
|
+
layout.update(data); layout.settle()
|
|
15
|
+
const node = layout.nodes[0], position = { x: node.x, y: node.y }, alpha = layout.simulation.alpha()
|
|
16
|
+
assert.equal(layout.update(structuredClone(data)), false)
|
|
17
|
+
assert.equal(layout.nodes[0], node)
|
|
18
|
+
assert.equal(node.x, position.x); assert.equal(node.y, position.y)
|
|
19
|
+
assert.equal(layout.simulation.alpha(), alpha)
|
|
20
|
+
assert.deepEqual(data, before)
|
|
21
|
+
layout.update({ nodes: data.nodes.slice(0, 4), edges: data.edges.slice(0, 3) })
|
|
22
|
+
layout.update(data)
|
|
23
|
+
assert.equal(layout.nodes[0], node)
|
|
24
|
+
assert.equal(node.x, position.x)
|
|
25
|
+
layout.dispose()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('disconnected 200-node layout stays finite, separated and framed without boundary clamping', () => {
|
|
29
|
+
const layout = createLayout()
|
|
30
|
+
layout.update(graph(200, false)); layout.settle(300)
|
|
31
|
+
const camera = fitCamera(layout.nodes, 340, 380)
|
|
32
|
+
for (const n of layout.nodes) {
|
|
33
|
+
assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y))
|
|
34
|
+
const x = n.x * camera.k + camera.x, y = n.y * camera.k + camera.y
|
|
35
|
+
assert.ok(x > 30 && x < 310 && y > 30 && y < 350)
|
|
36
|
+
}
|
|
37
|
+
for (let i = 0; i < layout.nodes.length; i++) for (let j = i + 1; j < layout.nodes.length; j++) {
|
|
38
|
+
const a = layout.nodes[i], b = layout.nodes[j]
|
|
39
|
+
assert.ok(Math.hypot(a.x - b.x, a.y - b.y) > a.radius + b.radius, 'nodes must not overlap')
|
|
40
|
+
}
|
|
41
|
+
layout.dispose()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('invalid/self edges are ignored, input bounded, node order deterministic', () => {
|
|
45
|
+
const data = graph(250, false)
|
|
46
|
+
data.edges.push({ from: 'missing', to: 'n000' }, { from: 'n000', to: 'n000' })
|
|
47
|
+
const a = createLayout(), b = createLayout()
|
|
48
|
+
a.update(data)
|
|
49
|
+
b.update({ nodes: [...data.nodes.slice(0, 200)].reverse(), edges: [] })
|
|
50
|
+
assert.equal(a.nodes.length, 200); assert.equal(a.edges.length, 0)
|
|
51
|
+
a.settle(); b.settle()
|
|
52
|
+
assert.deepEqual(a.nodes.map(n => [n.id, n.x, n.y]), b.nodes.map(n => [n.id, n.x, n.y]))
|
|
53
|
+
a.dispose(); b.dispose()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('zoom keeps the cursor world point fixed and clamps extreme scales', () => {
|
|
57
|
+
const camera = { x: 100, y: 80, k: .8 }, point = { x: 350, y: 210 }
|
|
58
|
+
for (const factor of [.0001, .8, 1.25, 10000]) {
|
|
59
|
+
const next = zoomCamera(camera, factor, point)
|
|
60
|
+
assert.ok(Math.abs((point.x - camera.x) / camera.k - (point.x - next.x) / next.k) < 1e-8)
|
|
61
|
+
assert.ok(Math.abs((point.y - camera.y) / camera.k - (point.y - next.y) / next.k) < 1e-8)
|
|
62
|
+
assert.ok(next.k >= .06 && next.k <= 5)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('labels neither overlap each other nor node obstacles and remain inside canvas', () => {
|
|
67
|
+
const candidates = Array.from({ length: 50 }, (_, i) => ({
|
|
68
|
+
x: 40 + i % 8 * 40, y: 40 + Math.floor(i / 8) * 40, radius: 5, textWidth: 90,
|
|
69
|
+
}))
|
|
70
|
+
const obstacles = candidates.map(c => ({ x: c.x - 7, y: c.y - 7, width: 14, height: 14 }))
|
|
71
|
+
const labels = placeLabels(candidates, obstacles, 360, 360)
|
|
72
|
+
assert.ok(labels.length > 0 && labels.length < candidates.length)
|
|
73
|
+
const intersect = (a, b) => a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y
|
|
74
|
+
for (let i = 0; i < labels.length; i++) {
|
|
75
|
+
const a = labels[i].box
|
|
76
|
+
assert.ok(a.x >= 5 && a.x + a.width <= 355 && a.y >= 5 && a.y + a.height <= 355)
|
|
77
|
+
for (const b of obstacles) assert.equal(intersect(a, b), false)
|
|
78
|
+
for (const b of labels.slice(i + 1)) assert.equal(intersect(a, b.box), false)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
function harness(reducedMotion = true) {
|
|
83
|
+
const handlers = new Map(), windowHandlers = new Map(), frames = new Map(), arcs = [], focuses = []
|
|
84
|
+
let id = 0
|
|
85
|
+
const ctx = new Proxy({
|
|
86
|
+
arc(x, y, radius) { arcs.push({ x, y, radius }) },
|
|
87
|
+
measureText(text) { return { width: text.length * 7 } },
|
|
88
|
+
clearRect() { arcs.length = 0 },
|
|
89
|
+
}, { get(target, key) { return key in target ? target[key] : () => {} } })
|
|
90
|
+
const win = {
|
|
91
|
+
requestAnimationFrame(fn) { frames.set(++id, fn); return id },
|
|
92
|
+
cancelAnimationFrame(id) { frames.delete(id) },
|
|
93
|
+
addEventListener(type, fn) { windowHandlers.set(type, fn) },
|
|
94
|
+
removeEventListener(type) { windowHandlers.delete(type) },
|
|
95
|
+
getComputedStyle: () => ({ color: '#202020', getPropertyValue: () => '#ffffff' }),
|
|
96
|
+
devicePixelRatio: 2,
|
|
97
|
+
}
|
|
98
|
+
const captures = new Set()
|
|
99
|
+
const canvas = {
|
|
100
|
+
style: {}, ownerDocument: { defaultView: win, documentElement: {} },
|
|
101
|
+
getContext: () => ctx, getBoundingClientRect: () => ({ left: 0, top: 0, width: 600, height: 400 }),
|
|
102
|
+
focus() {},
|
|
103
|
+
setPointerCapture(id) { captures.add(id) },
|
|
104
|
+
hasPointerCapture: id => captures.has(id),
|
|
105
|
+
releasePointerCapture(id) { captures.delete(id) },
|
|
106
|
+
addEventListener(type, fn) { handlers.set(type, fn) },
|
|
107
|
+
removeEventListener(type) { handlers.delete(type) },
|
|
108
|
+
}
|
|
109
|
+
const view = createGraphView(canvas, { onFocus: id => focuses.push(id), reducedMotion })
|
|
110
|
+
const flush = () => {
|
|
111
|
+
const pending = [...frames.values()]; frames.clear()
|
|
112
|
+
for (const fn of pending) fn(1000)
|
|
113
|
+
}
|
|
114
|
+
const emit = (type, x, y, extra = {}) => handlers.get(type)?.({
|
|
115
|
+
type, clientX: x, clientY: y, button: 0, pointerId: 1, pointerType: 'mouse', preventDefault() {}, ...extra,
|
|
116
|
+
})
|
|
117
|
+
return { view, flush, emit, arcs, frames, focuses, handlers, windowHandlers }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test('dragging after cooling pins the node without click/refocus, and refresh keeps pixel position', () => {
|
|
121
|
+
const h = harness()
|
|
122
|
+
h.view.update(graph(1)); h.flush()
|
|
123
|
+
const p = { ...h.arcs[0] }
|
|
124
|
+
h.emit('pointerdown', p.x, p.y)
|
|
125
|
+
h.emit('pointermove', p.x + 70, p.y + 25)
|
|
126
|
+
h.view.update(graph(1))
|
|
127
|
+
h.flush()
|
|
128
|
+
assert.equal(h.arcs[0].x, p.x + 70); assert.equal(h.arcs[0].y, p.y + 25)
|
|
129
|
+
h.emit('pointerup', p.x + 70, p.y + 25); h.flush()
|
|
130
|
+
assert.deepEqual(h.focuses, [])
|
|
131
|
+
h.emit('pointerdown', p.x + 70, p.y + 25)
|
|
132
|
+
h.emit('pointerup', p.x + 70, p.y + 25)
|
|
133
|
+
assert.deepEqual(h.focuses, ['n000'])
|
|
134
|
+
h.view.dispose()
|
|
135
|
+
assert.equal(h.handlers.size, 0); assert.equal(h.frames.size, 0); assert.equal(h.windowHandlers.size, 0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('pan/zoom and equivalent refresh never re-fit the camera; cancelled pointer never clicks', () => {
|
|
139
|
+
const h = harness()
|
|
140
|
+
h.view.update(graph(1)); h.flush()
|
|
141
|
+
const before = { ...h.arcs[0] }
|
|
142
|
+
h.emit('pointerdown', 30, 30); h.emit('pointermove', 70, 60); h.emit('pointerup', 70, 60); h.flush()
|
|
143
|
+
assert.equal(h.arcs[0].x, before.x + 40); assert.equal(h.arcs[0].y, before.y + 30)
|
|
144
|
+
h.view.zoom(.8); h.flush()
|
|
145
|
+
const zoomed = { ...h.arcs[0] }
|
|
146
|
+
h.view.update(graph(1)); h.flush()
|
|
147
|
+
assert.deepEqual(h.arcs[0], zoomed)
|
|
148
|
+
h.emit('pointerdown', zoomed.x, zoomed.y); h.emit('pointercancel', zoomed.x, zoomed.y)
|
|
149
|
+
assert.deepEqual(h.focuses, [])
|
|
150
|
+
h.view.dispose()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('dense hit targets choose the closest node, not the last node in draw order', () => {
|
|
154
|
+
const h = harness()
|
|
155
|
+
h.view.update(graph(2)); h.view.zoom(.04); h.flush()
|
|
156
|
+
const p = h.arcs[0], other = h.arcs[1]
|
|
157
|
+
assert.ok(Math.hypot(other.x - p.x, other.y - p.y) < 10)
|
|
158
|
+
h.emit('pointerdown', p.x, p.y); h.emit('pointerup', p.x, p.y)
|
|
159
|
+
assert.deepEqual(h.focuses, ['n000'])
|
|
160
|
+
h.view.dispose()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('pinch does not trigger focus and reduced-motion/paused views do not run an idle animation loop', () => {
|
|
164
|
+
const h = harness()
|
|
165
|
+
h.view.update(graph(10)); h.flush()
|
|
166
|
+
assert.equal(h.frames.size, 0)
|
|
167
|
+
const p = h.arcs[0]
|
|
168
|
+
h.emit('pointerdown', p.x, p.y, { pointerType: 'touch' })
|
|
169
|
+
h.emit('pointerdown', p.x + 40, p.y, { pointerType: 'touch', pointerId: 2 })
|
|
170
|
+
h.emit('pointermove', p.x + 80, p.y, { pointerType: 'touch', pointerId: 2 })
|
|
171
|
+
h.emit('pointerup', p.x, p.y)
|
|
172
|
+
h.emit('pointerup', p.x + 80, p.y, { pointerId: 2 })
|
|
173
|
+
assert.deepEqual(h.focuses, [])
|
|
174
|
+
h.view.dispose()
|
|
175
|
+
const paused = harness(false)
|
|
176
|
+
paused.view.configure({ motion: false }); paused.view.update(graph(10)); paused.flush()
|
|
177
|
+
assert.equal(paused.frames.size, 0)
|
|
178
|
+
paused.view.dispose()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('shipped browser bundle exposes the controller with no runtime npm imports', () => {
|
|
182
|
+
const source = fs.readFileSync(new URL('../lib/webui/graph-view.bundle.js', import.meta.url), 'utf8')
|
|
183
|
+
const scope = { setTimeout, clearTimeout, setInterval, clearInterval, performance }
|
|
184
|
+
vm.runInNewContext(source, scope)
|
|
185
|
+
assert.equal(typeof scope.DshMemuGraph.createGraphView, 'function')
|
|
186
|
+
assert.equal(typeof scope.DshMemuGraph.createLayout, 'function')
|
|
187
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-graph-'))
|
|
7
|
+
process.env.DSH_MEMU_HOME = home
|
|
8
|
+
const state = await import('../lib/state.js')
|
|
9
|
+
const graph = await import('../lib/graph.js')
|
|
10
|
+
const memory = await import('../lib/memory.js')
|
|
11
|
+
test.after(() => { state.flushState(); fs.rmSync(home, { recursive: true, force: true }) })
|
|
12
|
+
const binding = state.ensureSession('graph', { cwd: path.join(home, 'rp'), agentPreset: 'rp' })
|
|
13
|
+
const libraryId = binding.libraryId
|
|
14
|
+
const exec = { agent: { session: { header: { id: 'graph' } } } }
|
|
15
|
+
const entries = Object.fromEntries(Array.from({ length: 10 }, (_, i) => [`mem-${i}`, { title: `person ${i}`, content: `scene ${i}`, layer: 'long_term', status: 'active', visibility: 'shared' }]))
|
|
16
|
+
state.savePrivateIndex(libraryId, { entries })
|
|
17
|
+
|
|
18
|
+
test('graph stores one direction but queries both within bounded hops', () => {
|
|
19
|
+
for (let i = 0; i < 9; i++) graph.linkEntries({ libraryId, fromId: `mem-${i}`, toId: `mem-${i + 1}`, kind: 'event', note: 'The same event seen from another side.' })
|
|
20
|
+
const result = graph.getGraph({ libraryId, focusId: 'mem-4', depth: 1 })
|
|
21
|
+
assert.deepEqual(new Set(result.nodes.map(node => node.id)), new Set(['mem-3', 'mem-4', 'mem-5']))
|
|
22
|
+
assert.equal(result.edges.length, 2)
|
|
23
|
+
assert.equal(state.loadPrivateIndex(libraryId).entries['mem-5'].links.length, 1)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('deleted memories remain gray dangling nodes rather than hiding the edge', () => {
|
|
27
|
+
state.updatePrivateIndex(libraryId, index => { delete index.entries['mem-1'] })
|
|
28
|
+
const result = graph.getGraph({ libraryId, focusId: 'mem-0', depth: 1 })
|
|
29
|
+
assert.equal(result.nodes.find(node => node.id === 'mem-1').missing, true)
|
|
30
|
+
assert.equal(result.edges.length, 1)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('entity rename changes all references without rewriting memory text', async () => {
|
|
34
|
+
const entity = graph.createEntity({ libraryId, name: 'Old Zhou' })
|
|
35
|
+
graph.linkEntries({ libraryId, fromId: 'mem-2', toId: entity.id, kind: 'person', note: 'Old Zhou is the person mentioned here.' })
|
|
36
|
+
graph.renameEntity({ libraryId, entityId: entity.id, name: 'Zhou', aliases: ['Old Zhou'] })
|
|
37
|
+
assert.equal(graph.getGraph({ libraryId, focusId: entity.id }).nodes.find(node => node.id === entity.id).title, 'Zhou')
|
|
38
|
+
assert.equal(state.loadPrivateIndex(libraryId).entries['mem-2'].content, 'scene 2')
|
|
39
|
+
assert.equal((await memory.privateRecall({ exec, query: 'mem-2' })).files[0].related.find(item => item.id === entity.id).title, 'Zhou')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('invalid kinds, empty reasons and out-of-scope private links are rejected', () => {
|
|
43
|
+
assert.throws(() => graph.linkEntries({ libraryId, fromId: 'mem-2', toId: 'mem-3', kind: 'typo', note: 'explanation' }), /link kind/)
|
|
44
|
+
assert.throws(() => graph.linkEntries({ libraryId, fromId: 'mem-2', toId: 'mem-3', kind: 'event', note: '' }), /8-500/)
|
|
45
|
+
// The remaining boundary is the context scope: this entry belongs to another character,
|
|
46
|
+
// and this conversation has no context at all, so it must stay unreachable.
|
|
47
|
+
state.updatePrivateIndex(libraryId, index => { index.entries.secret = { title: 'private title', status: 'active', visibility: 'secret', context: { characterId: 'other' } } })
|
|
48
|
+
assert.throws(() => graph.linkEntries({ exec, fromId: 'mem-2', toId: 'secret', kind: 'event', note: 'a very long explanation' }), /inaccessible/)
|
|
49
|
+
assert.ok(!JSON.stringify(graph.getGraph({ exec })).includes('private title'))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('graphs cap nodes at 200 and the recall carries at most two pointers', async () => {
|
|
53
|
+
state.updatePrivateIndex(libraryId, index => {
|
|
54
|
+
for (let i = 10; i < 400; i++) index.entries[`mem-${i}`] = { title: `node ${i}`, status: 'active', content: 'node' }
|
|
55
|
+
})
|
|
56
|
+
const result = graph.getGraph({ libraryId, limit: 5000 })
|
|
57
|
+
assert.equal(result.nodes.length, 200)
|
|
58
|
+
assert.equal(result.truncated, true)
|
|
59
|
+
assert.ok((await memory.privateRecall({ exec, query: 'mem-2' })).files[0].related.length <= 2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('relationship snapshots preserve unknowns, evidence, boundaries and previous versions', () => {
|
|
63
|
+
const a = graph.createEntity({ libraryId, name: 'A' })
|
|
64
|
+
const b = graph.createEntity({ libraryId, name: 'B' })
|
|
65
|
+
const initial = graph.updateRelationship({ libraryId, fromId: a.id, toId: b.id,
|
|
66
|
+
state: { trust: .7, boundaries: ['Do not volunteer private stories.'], openPromises: ['Answer on Friday.'] },
|
|
67
|
+
evidenceIds: ['mem-2'], reason: 'Friday promise is explicit evidence.' }).relationship
|
|
68
|
+
assert.equal(initial.state.warmth, null, 'unknown is not a neutral or affectionate score')
|
|
69
|
+
graph.updateRelationship({ libraryId, fromId: a.id, toId: b.id,
|
|
70
|
+
state: { trust: .75, boundaries: initial.state.boundaries, callbacks: ['the station roof'], openPromises: [] },
|
|
71
|
+
evidenceIds: ['mem-2'], reason: 'The answer was delivered explicitly.' })
|
|
72
|
+
const next = graph.getRelationship({ libraryId, fromId: a.id, toId: b.id }).relationship
|
|
73
|
+
assert.equal(next.history.length, 1)
|
|
74
|
+
assert.deepEqual(next.history[0].state.openPromises, ['Answer on Friday.'])
|
|
75
|
+
assert.ok(next.history[0].validTo)
|
|
76
|
+
assert.throws(() => graph.updateRelationship({ libraryId, fromId: a.id, toId: b.id, state: {}, evidenceIds: [], reason: 'no evidence' }), /evidence/)
|
|
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
|
+
})
|