dsh-memu-memory 1.1.3 → 1.2.0
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 +31 -2
- package/README.zh.md +30 -2
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +1 -1
- package/docs/emotion-model-spec.md +281 -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 +395 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +1 -1
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +33 -11
- 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 +115 -0
- package/lib/cycle.js +40 -6
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +221 -19
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +335 -40
- 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 +9 -1
- package/lib/state.js +29 -12
- package/lib/tools.js +49 -2
- package/lib/vector-profile.js +27 -0
- package/package.json +2 -1
- 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/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 +212 -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 +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -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-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-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -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 +536 -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/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 +208 -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 +733 -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 +164 -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,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,77 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
|
|
5
|
+
// 2026-10-30 现场:日志页的 7 个 useState + useEffect 被写在
|
|
6
|
+
// `if (!state) return ...` 之后。首屏少调 10 个 hook、数据到达后多调 10 个,
|
|
7
|
+
// React 抛 #310,整个设置弹窗和 WebUI 白屏,控制台只有一行 minified 报错。
|
|
8
|
+
//
|
|
9
|
+
// 这类 bug 很难被假 React 的渲染用例覆盖(它不校验 hook 顺序),所以用一条静态守卫:
|
|
10
|
+
// 组件函数体内、与组件同级的 React.use* 必须全部出现在提前 return 之前。
|
|
11
|
+
|
|
12
|
+
const source = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
13
|
+
|
|
14
|
+
// 从 `function Name(` 后的 `{` 开始做括号配对,跳过字符串与注释,拿到精确的函数体。
|
|
15
|
+
function bodyOf(name) {
|
|
16
|
+
const start = source.indexOf('function ' + name + '(')
|
|
17
|
+
assert.ok(start > 0, name + ' not found in lib/client.js')
|
|
18
|
+
let index = source.indexOf('{', source.indexOf(')', start))
|
|
19
|
+
assert.ok(index > start, name + ' has no function body')
|
|
20
|
+
const begin = index
|
|
21
|
+
let depth = 0
|
|
22
|
+
let quote = null
|
|
23
|
+
for (; index < source.length; index++) {
|
|
24
|
+
const ch = source[index]
|
|
25
|
+
if (quote) {
|
|
26
|
+
if (ch === '\\') { index++; continue }
|
|
27
|
+
if (ch === quote) quote = null
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
if (ch === "'" || ch === '"' || ch === '\u0060') { quote = ch; continue }
|
|
31
|
+
if (ch === '/' && source[index + 1] === '/') {
|
|
32
|
+
const newline = source.indexOf('\n', index)
|
|
33
|
+
index = newline < 0 ? source.length : newline
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
if (ch === '/' && source[index + 1] === '*') {
|
|
37
|
+
const close = source.indexOf('*/', index + 2)
|
|
38
|
+
index = close < 0 ? source.length : close + 1
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (ch === '{') depth += 1
|
|
42
|
+
else if (ch === '}') {
|
|
43
|
+
depth -= 1
|
|
44
|
+
if (depth === 0) return source.slice(begin, index + 1)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
assert.fail(name + ' body was never closed')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const [name, earlyReturn] of [
|
|
51
|
+
['SettingsPage', 'if (!state) return'],
|
|
52
|
+
['MemoryTab', 'if (!sessionId) return'],
|
|
53
|
+
]) {
|
|
54
|
+
test(`${name}: every component-level hook is declared before "${earlyReturn}"`, () => {
|
|
55
|
+
const body = bodyOf(name)
|
|
56
|
+
const lines = body.split('\n')
|
|
57
|
+
// 只认与组件同级(6 空格缩进)的提前 return:注释里也会出现这段文字,
|
|
58
|
+
// 嵌套回调里的 return 则不影响组件自身的 hook 顺序。
|
|
59
|
+
const literal = earlyReturn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
60
|
+
const atComponentLevel = new RegExp('^ {6}' + literal + '\\b')
|
|
61
|
+
const retLine = lines.findIndex(line => atComponentLevel.test(line))
|
|
62
|
+
assert.ok(retLine > 0, `${name} no longer contains "${earlyReturn}" — update this guard instead of deleting it`)
|
|
63
|
+
const later = []
|
|
64
|
+
for (let line = retLine + 1; line < lines.length; line++) {
|
|
65
|
+
// 同级 hook 缩进固定为 6 个空格(模块工厂内 4 + 函数体 2);嵌套组件的 hook 更深,
|
|
66
|
+
// 它们的顺序不受这个提前 return 影响。
|
|
67
|
+
if (!/^ {6}\S/.test(lines[line])) continue
|
|
68
|
+
if (/React\.use[A-Z]/.test(lines[line])) later.push(`${line + 1}: ${lines[line].trim().slice(0, 80)}`)
|
|
69
|
+
}
|
|
70
|
+
assert.deepEqual(later, [], `${name}: hook after the early return — React #310 blanks the panel. Move it above.\n` + later.join('\n'))
|
|
71
|
+
})
|
|
72
|
+
}
|