@ilaomianyang/dsh-memory-graph 0.10.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/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.en.md +466 -0
- package/README.md +459 -0
- package/client.js +1953 -0
- package/cordis.patch.yml +13 -0
- package/docs/auto-settings.png +0 -0
- package/docs/distill-button.png +0 -0
- package/docs/graph-dark.png +0 -0
- package/docs/graph-light.png +0 -0
- package/docs/session-tab-cards.png +0 -0
- package/docs/session-tab-open.png +0 -0
- package/docs/session-tab.png +0 -0
- package/docs/vault-panel.png +0 -0
- package/eval/REPORT.md +437 -0
- package/eval/questions.json +277 -0
- package/eval/run.mjs +460 -0
- package/eval/seed.mjs +489 -0
- package/icon.svg +26 -0
- package/index.js +243 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +77 -0
- package/src/auto.js +194 -0
- package/src/capture.js +237 -0
- package/src/config.js +280 -0
- package/src/distillapi.js +30 -0
- package/src/distillrun.js +106 -0
- package/src/extract.js +660 -0
- package/src/graph.js +1582 -0
- package/src/graphdata.js +475 -0
- package/src/http.js +32 -0
- package/src/pageapi.js +40 -0
- package/src/privacy.js +78 -0
- package/src/schema.js +323 -0
- package/src/search.js +317 -0
- package/src/tools.js +534 -0
- package/src/vault.js +888 -0
- package/test/auto.mjs +295 -0
- package/test/capture.mjs +264 -0
- package/test/client.mjs +1035 -0
- package/test/distillapi.mjs +212 -0
- package/test/extract.mjs +660 -0
- package/test/graphdata.mjs +295 -0
- package/test/live-supersede.mjs +78 -0
- package/test/live.mjs +156 -0
- package/test/page.mjs +116 -0
- package/test/schema.mjs +256 -0
- package/test/smoke.mjs +428 -0
- package/test/tools.mjs +238 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-view data checks.
|
|
3
|
+
*
|
|
4
|
+
* The panel in the Conversation row is only as honest as this payload: the
|
|
5
|
+
* filter that decides which episodes belong to the Session, the edges that claim
|
|
6
|
+
* a connection, and the caps that keep the drawing readable. None of that is
|
|
7
|
+
* visible from the browser side, so it is asserted here against a real vault in
|
|
8
|
+
* a temp directory.
|
|
9
|
+
*
|
|
10
|
+
* node test/graphdata.mjs
|
|
11
|
+
*/
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import fsp from 'node:fs/promises'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { resolveConfig } from '../src/config.js'
|
|
18
|
+
import { createRedactor } from '../src/privacy.js'
|
|
19
|
+
import { createVault } from '../src/vault.js'
|
|
20
|
+
import {
|
|
21
|
+
GRAPH_PATH,
|
|
22
|
+
buildSessionGraph,
|
|
23
|
+
createGraphHandler,
|
|
24
|
+
dominantProject,
|
|
25
|
+
layoutNodes,
|
|
26
|
+
recallQuery,
|
|
27
|
+
} from '../src/graphdata.js'
|
|
28
|
+
|
|
29
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-graphdata-'))
|
|
30
|
+
const results = []
|
|
31
|
+
function check(name, fn) {
|
|
32
|
+
try {
|
|
33
|
+
fn()
|
|
34
|
+
results.push(` ok ${name}`)
|
|
35
|
+
} catch (error) {
|
|
36
|
+
results.push(` FAIL ${name}: ${error.message}`)
|
|
37
|
+
process.exitCode = 1
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function checkAsync(name, fn) {
|
|
41
|
+
try {
|
|
42
|
+
await fn()
|
|
43
|
+
results.push(` ok ${name}`)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
results.push(` FAIL ${name}: ${error.message}`)
|
|
46
|
+
process.exitCode = 1
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ---------------------------------------------------------------- the vault */
|
|
51
|
+
|
|
52
|
+
const cfg = resolveConfig({ vaultDir: dir, defaultProject: 'proj-a' })
|
|
53
|
+
const vault = createVault(cfg, createRedactor(cfg.privacy))
|
|
54
|
+
await vault.ensure()
|
|
55
|
+
|
|
56
|
+
const LEAK = 'ASSISTANT-TEXT-MUST-NOT-LEAK'
|
|
57
|
+
await vault.appendEpisode({
|
|
58
|
+
t: '2026-11-02T10:00:00+08:00',
|
|
59
|
+
project: 'proj-a',
|
|
60
|
+
session: 'sess-1',
|
|
61
|
+
turn: 1,
|
|
62
|
+
user: '记忆库要用纯文本存。',
|
|
63
|
+
assistant: LEAK,
|
|
64
|
+
})
|
|
65
|
+
await vault.appendEpisode({
|
|
66
|
+
t: '2026-11-02T10:05:00+08:00',
|
|
67
|
+
project: 'proj-a',
|
|
68
|
+
session: 'sess-1',
|
|
69
|
+
turn: 2,
|
|
70
|
+
user: '实体页怎么把两个项目连起来?',
|
|
71
|
+
assistant: 'ok',
|
|
72
|
+
})
|
|
73
|
+
await vault.appendEpisode({
|
|
74
|
+
t: '2026-11-02T11:00:00+08:00',
|
|
75
|
+
project: 'proj-b',
|
|
76
|
+
session: 'sess-2',
|
|
77
|
+
turn: 1,
|
|
78
|
+
user: '别的会话在聊发布流程。',
|
|
79
|
+
assistant: 'ok',
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const cardA = await vault.saveCard({
|
|
83
|
+
title: '记忆库用纯文本存储',
|
|
84
|
+
body: '每个记忆一个文件,可以 diff。',
|
|
85
|
+
kind: 'decision',
|
|
86
|
+
project: 'proj-a',
|
|
87
|
+
entities: ['memory-vault', 'markdown'],
|
|
88
|
+
})
|
|
89
|
+
const cardB = await vault.saveCard({
|
|
90
|
+
title: '实体页把项目串起来',
|
|
91
|
+
body: '实体是跨项目的连接点。',
|
|
92
|
+
kind: 'fact',
|
|
93
|
+
project: 'proj-a',
|
|
94
|
+
entities: ['entity-page'],
|
|
95
|
+
})
|
|
96
|
+
const cardC = await vault.saveCard({
|
|
97
|
+
title: '纯文本也能 grep',
|
|
98
|
+
body: '不需要索引也能查。',
|
|
99
|
+
kind: 'note',
|
|
100
|
+
project: 'proj-b',
|
|
101
|
+
entities: ['memory-vault'],
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
/* ------------------------------------------------------------- pure helpers */
|
|
105
|
+
|
|
106
|
+
check('the route path is an exact /api path that Connection accepts', () => {
|
|
107
|
+
assert.equal(GRAPH_PATH, '/api/memory-graph.graph')
|
|
108
|
+
assert.equal(GRAPH_PATH.startsWith('/api/'), true)
|
|
109
|
+
assert.ok(GRAPH_PATH.length > '/api/'.length, 'connection rejects a bare channel path')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
check('the project is the majority of the episodes and null when none name one', () => {
|
|
113
|
+
assert.equal(dominantProject([{ project: 'a' }, { project: 'b' }, { project: 'b' }]), 'b')
|
|
114
|
+
assert.equal(dominantProject([{ project: '' }, {}, { project: 'x' }]), 'x')
|
|
115
|
+
assert.equal(dominantProject([]), null)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
check('the recall query is the newest turns, capped', () => {
|
|
119
|
+
const episodes = [{ user: 'one' }, { user: 'two' }, { user: 'three' }, { user: 'four' }]
|
|
120
|
+
assert.equal(recallQuery(episodes, { turns: 2 }), 'four\nthree', 'newest last')
|
|
121
|
+
assert.equal(recallQuery([{ user: '' }, { user: 'x' }]), 'x', 'empty turns are skipped')
|
|
122
|
+
const long = recallQuery([{ user: 'y'.repeat(4000) }], { maxChars: 120 })
|
|
123
|
+
assert.equal(long.length, 120)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
check('the layout is deterministic and stays inside the box', () => {
|
|
127
|
+
const nodes = [
|
|
128
|
+
{ key: 'a', k: 'session', r: 12 },
|
|
129
|
+
{ key: 'b', k: 'episode', r: 5 },
|
|
130
|
+
{ key: 'c', k: 'card', r: 7 },
|
|
131
|
+
{ key: 'd', k: 'entity', r: 6 },
|
|
132
|
+
]
|
|
133
|
+
const links = [
|
|
134
|
+
{ s: 'a', t: 'b', k: 'said' },
|
|
135
|
+
{ s: 'a', t: 'c', k: 'recall' },
|
|
136
|
+
{ s: 'c', t: 'd', k: 'mentions' },
|
|
137
|
+
]
|
|
138
|
+
layoutNodes(nodes, links)
|
|
139
|
+
const first = nodes.map((node) => [node.x, node.y])
|
|
140
|
+
for (const node of nodes) {
|
|
141
|
+
assert.equal(Number.isFinite(node.x) && Number.isFinite(node.y), true, `${node.key} has a position`)
|
|
142
|
+
assert.ok(node.x >= 0 && node.x <= 1080, `${node.key} x in the box`)
|
|
143
|
+
assert.ok(node.y >= 0 && node.y <= 640, `${node.key} y in the box`)
|
|
144
|
+
}
|
|
145
|
+
const again = nodes.map((node) => ({ ...node }))
|
|
146
|
+
layoutNodes(again, links)
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
again.map((node) => [node.x, node.y]),
|
|
149
|
+
first,
|
|
150
|
+
'same input, same picture: no Math.random',
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/* -------------------------------------------------------------- the payload */
|
|
155
|
+
|
|
156
|
+
let sessOne = null
|
|
157
|
+
await checkAsync('a session sees only its own episodes', async () => {
|
|
158
|
+
sessOne = await buildSessionGraph({ vault, session: 'sess-1' })
|
|
159
|
+
assert.equal(sessOne.ok, true)
|
|
160
|
+
assert.equal(sessOne.project, 'proj-a')
|
|
161
|
+
assert.equal(sessOne.counts.episodes, 2)
|
|
162
|
+
assert.equal(sessOne.empty, false)
|
|
163
|
+
const episodes = sessOne.nodes.filter((node) => node.k === 'episode')
|
|
164
|
+
assert.equal(episodes.length, 2)
|
|
165
|
+
assert.deepEqual(
|
|
166
|
+
episodes.map((node) => node.label),
|
|
167
|
+
['T1', 'T2'],
|
|
168
|
+
)
|
|
169
|
+
assert.equal(
|
|
170
|
+
sessOne.links.filter((link) => link.k === 'said').length,
|
|
171
|
+
2,
|
|
172
|
+
'the session said both episodes',
|
|
173
|
+
)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
await checkAsync('the payload never carries the assistant side', () => {
|
|
177
|
+
assert.equal(JSON.stringify(sessOne).includes(LEAK), false)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await checkAsync('recalled cards are marked, their entities are drawn, links are wired', async () => {
|
|
181
|
+
const cards = sessOne.nodes.filter((node) => node.k === 'card' && !node.foreign)
|
|
182
|
+
assert.ok(cards.length >= 2, 'both project cards are on the graph')
|
|
183
|
+
assert.ok(sessOne.recall.length >= 1, 'the session text retrieves something')
|
|
184
|
+
const hot = sessOne.nodes.filter((node) => node.k === 'card' && node.hot === true)
|
|
185
|
+
assert.ok(hot.length >= 1, 'at least one card is marked as recalled')
|
|
186
|
+
assert.equal(
|
|
187
|
+
sessOne.links.filter((link) => link.k === 'recall').length,
|
|
188
|
+
sessOne.recall.length,
|
|
189
|
+
'one recall edge per recalled card',
|
|
190
|
+
)
|
|
191
|
+
const entities = sessOne.nodes.filter((node) => node.k === 'entity')
|
|
192
|
+
assert.ok(entities.length >= 2, 'the cards mention entities')
|
|
193
|
+
assert.ok(
|
|
194
|
+
sessOne.links.some((link) => link.k === 'mentions'),
|
|
195
|
+
'a card is joined to the entity it mentions',
|
|
196
|
+
)
|
|
197
|
+
const hotNode = hot[0]
|
|
198
|
+
assert.equal(typeof hotNode.body, 'string', 'the panel can show the body on click')
|
|
199
|
+
assert.equal(typeof hotNode.title, 'string')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
await checkAsync('a card in another project is drawn dim when it shares an entity', async () => {
|
|
203
|
+
const foreign = sessOne.nodes.filter((node) => node.foreign === true)
|
|
204
|
+
assert.equal(foreign.length, 1, 'exactly the cross-project bridge')
|
|
205
|
+
assert.equal(foreign[0].id, cardC.card.id)
|
|
206
|
+
assert.equal(foreign[0].project, 'proj-b')
|
|
207
|
+
assert.ok(
|
|
208
|
+
sessOne.links.some((link) => link.s === `c:${cardC.card.id}` && link.k === 'mentions'),
|
|
209
|
+
'the bridge hangs off the shared entity',
|
|
210
|
+
)
|
|
211
|
+
assert.equal(sessOne.counts.foreign, 1)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
await checkAsync('every link points at a node that exists', () => {
|
|
215
|
+
const keys = new Set(sessOne.nodes.map((node) => node.key))
|
|
216
|
+
for (const link of sessOne.links) {
|
|
217
|
+
assert.equal(keys.has(link.s), true, `missing source ${link.s}`)
|
|
218
|
+
assert.equal(keys.has(link.t), true, `missing target ${link.t}`)
|
|
219
|
+
}
|
|
220
|
+
assert.equal(new Set(sessOne.nodes.map((node) => node.key)).size, sessOne.nodes.length, 'keys are unique')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
await checkAsync('a second session resolves to its own project', async () => {
|
|
224
|
+
const other = await buildSessionGraph({ vault, session: 'sess-2' })
|
|
225
|
+
assert.equal(other.project, 'proj-b')
|
|
226
|
+
assert.equal(other.counts.episodes, 1)
|
|
227
|
+
assert.ok(
|
|
228
|
+
other.nodes.some((node) => node.k === 'card' && node.id === cardC.card.id && !node.foreign),
|
|
229
|
+
'its own card is local here',
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
await checkAsync('an unknown session is an empty answer, not a failure', async () => {
|
|
234
|
+
const none = await buildSessionGraph({ vault, session: 'never-seen' })
|
|
235
|
+
assert.equal(none.ok, true)
|
|
236
|
+
assert.equal(none.empty, true)
|
|
237
|
+
assert.equal(none.counts.episodes, 0)
|
|
238
|
+
assert.equal(none.nodes.filter((node) => node.k === 'episode').length, 0)
|
|
239
|
+
assert.ok(
|
|
240
|
+
none.nodes.some((node) => node.k === 'session'),
|
|
241
|
+
'the session node still anchors the (empty) drawing',
|
|
242
|
+
)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
await checkAsync('the caps hold and say they held', async () => {
|
|
246
|
+
const capped = await buildSessionGraph({ vault, session: 'sess-1', limits: { episodes: 1 } })
|
|
247
|
+
assert.equal(capped.nodes.filter((node) => node.k === 'episode').length, 1)
|
|
248
|
+
assert.equal(capped.truncated.episodes, true)
|
|
249
|
+
assert.equal(capped.counts.episodes, 2, 'the count still reports the real total')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
/* ---------------------------------------------------------------- the route */
|
|
253
|
+
|
|
254
|
+
const request = (session) =>
|
|
255
|
+
new Request(`http://127.0.0.1:12180${GRAPH_PATH}?session=${encodeURIComponent(session)}`)
|
|
256
|
+
|
|
257
|
+
await checkAsync('the route answers JSON for a session', async () => {
|
|
258
|
+
const handler = createGraphHandler({ getVault: () => vault, getConfig: () => cfg })
|
|
259
|
+
const response = await handler(request('sess-1'))
|
|
260
|
+
assert.equal(response.status, 200)
|
|
261
|
+
assert.equal(response.headers.get('content-type').startsWith('application/json'), true)
|
|
262
|
+
assert.equal(response.headers.get('cache-control'), 'no-store')
|
|
263
|
+
const body = await response.json()
|
|
264
|
+
assert.equal(body.ok, true)
|
|
265
|
+
assert.equal(body.counts.episodes, 2)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
await checkAsync('a request without a session is refused, not guessed', async () => {
|
|
269
|
+
const handler = createGraphHandler({ getVault: () => vault, getConfig: () => cfg })
|
|
270
|
+
const response = await handler(new Request(`http://127.0.0.1:12180${GRAPH_PATH}`))
|
|
271
|
+
assert.equal(response.status, 400)
|
|
272
|
+
assert.equal((await response.json()).reason, 'missing-session')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
await checkAsync('a broken vault answers with something the panel can print', async () => {
|
|
276
|
+
const handler = createGraphHandler({
|
|
277
|
+
getVault: () => ({
|
|
278
|
+
readEpisodes: async () => {
|
|
279
|
+
throw new Error('vault is a directory of permission errors')
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
getConfig: () => cfg,
|
|
283
|
+
})
|
|
284
|
+
const response = await handler(request('sess-1'))
|
|
285
|
+
assert.equal(response.status, 500)
|
|
286
|
+
const body = await response.json()
|
|
287
|
+
assert.equal(body.ok, false)
|
|
288
|
+
assert.equal(body.reason, 'unavailable')
|
|
289
|
+
assert.match(body.message, /permission/)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
await fsp.rm(dir, { recursive: true, force: true })
|
|
293
|
+
|
|
294
|
+
console.log(results.join('\n'))
|
|
295
|
+
console.log(`\n${results.filter((line) => line.includes(' ok ')).length}/${results.length} checks passed`)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a later correction actually retire the earlier memory? This is the one
|
|
3
|
+
* behaviour that cannot be checked offline, because it depends on the model
|
|
4
|
+
* filling `supersedes` with a real id.
|
|
5
|
+
*
|
|
6
|
+
* node test/live-supersede.mjs [--keep]
|
|
7
|
+
*
|
|
8
|
+
* Round one distills a claim. Round two adds the user correcting it and distills
|
|
9
|
+
* again. The check passes when the old card ends up `superseded` and the new card
|
|
10
|
+
* points at it, which is the whole point of temporal validity.
|
|
11
|
+
*/
|
|
12
|
+
import fsp from 'node:fs/promises'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { resolveConfig } from '../src/config.js'
|
|
16
|
+
import { createRedactor } from '../src/privacy.js'
|
|
17
|
+
import { createVault } from '../src/vault.js'
|
|
18
|
+
import { distill } from '../src/extract.js'
|
|
19
|
+
|
|
20
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mem-supersede-'))
|
|
21
|
+
const cfg = resolveConfig({
|
|
22
|
+
vaultDir: dir,
|
|
23
|
+
extractor: {
|
|
24
|
+
enabled: true,
|
|
25
|
+
// Direct endpoint on purpose: a bare `node` process has no harness llm service.
|
|
26
|
+
transport: 'http',
|
|
27
|
+
apiKeyEnv: process.env.LIVE_KEY_ENV || 'DEEPSEEK_API_KEY',
|
|
28
|
+
baseUrl: process.env.LIVE_BASE_URL || 'https://api.deepseek.com/v1',
|
|
29
|
+
model: process.env.LIVE_MODEL || 'deepseek-chat',
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
const redactor = createRedactor(cfg.privacy)
|
|
33
|
+
const vault = createVault(cfg, redactor)
|
|
34
|
+
const project = 'memory-graph'
|
|
35
|
+
|
|
36
|
+
await vault.appendEpisode({
|
|
37
|
+
project,
|
|
38
|
+
t: '2026-11-01T09:00:00+08:00',
|
|
39
|
+
user: '记忆库在本地文件夹生成,方便上传到 GitHub 上共享。',
|
|
40
|
+
assistant: '纯文本文件就可以直接进 git,diff 和合并都没问题。',
|
|
41
|
+
})
|
|
42
|
+
const first = await distill({ cfg, vault, redactor })
|
|
43
|
+
if (!first.ok || first.cards.length === 0) {
|
|
44
|
+
console.error('round one produced no cards', first)
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
const oldCards = await vault.listCards({ includeRetracted: true })
|
|
48
|
+
console.log(`round 1: ${first.cards.map((card) => `${card.id} ${card.title}`).join(' | ')}`)
|
|
49
|
+
|
|
50
|
+
await vault.appendEpisode({
|
|
51
|
+
project,
|
|
52
|
+
t: '2026-11-02T10:00:00+08:00',
|
|
53
|
+
user: '更正一下:共享出去的是插件这个项目,不是记忆本身。记忆是私有的,不应该上传。纯文本只是为了可读、可移植、无锁。',
|
|
54
|
+
assistant: '明白,记忆库当私有数据对待,共享的是插件包。',
|
|
55
|
+
})
|
|
56
|
+
const second = await distill({ cfg, vault, redactor })
|
|
57
|
+
if (!second.ok) {
|
|
58
|
+
console.error('round two failed', second)
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
console.log(`round 2: ${second.cards.map((card) => `${card.id} ${card.title} supersedes=${JSON.stringify(card.supersedes ?? [])}`).join(' | ')}`)
|
|
62
|
+
|
|
63
|
+
const after = await vault.listCards({ includeRetracted: true })
|
|
64
|
+
const backwards = (card) => card.supersededBy ?? card.superseded_by ?? null
|
|
65
|
+
const retired = after.filter((card) => card.status === 'superseded')
|
|
66
|
+
const linking = after.filter((card) => (card.supersedes ?? []).length > 0)
|
|
67
|
+
|
|
68
|
+
console.log('')
|
|
69
|
+
console.log(`superseded cards: ${retired.map((card) => `${card.id} -> ${backwards(card)}`).join(', ') || 'none'}`)
|
|
70
|
+
console.log(`cards that point at another: ${linking.map((card) => `${card.id} -> ${card.supersedes.join(',')}`).join(', ') || 'none'}`)
|
|
71
|
+
|
|
72
|
+
const chained = retired.some((card) => backwards(card) && linking.some((newer) => newer.id === backwards(card) && newer.supersedes.includes(card.id)))
|
|
73
|
+
const ok = retired.length > 0 && linking.length > 0 && chained
|
|
74
|
+
console.log(ok ? '\nPASS: the correction retired the earlier memory, both directions linked' : '\nFAIL: the correction did not retire the earlier memory')
|
|
75
|
+
|
|
76
|
+
if (process.argv.includes('--keep')) console.log(`kept: ${dir}`)
|
|
77
|
+
else await fsp.rm(dir, { recursive: true, force: true })
|
|
78
|
+
process.exit(ok ? 0 : 1)
|
package/test/live.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end check against a real model. Not part of `npm test`: it needs
|
|
3
|
+
* network access and spends tokens.
|
|
4
|
+
*
|
|
5
|
+
* node test/live.mjs # seeded episodes, Chinese + English
|
|
6
|
+
* node test/live.mjs --log <session.jsonl> # replay a real session log instead
|
|
7
|
+
* node test/live.mjs --backlog 12 # 12 seeded episodes, to watch it batch
|
|
8
|
+
* node test/live.mjs --tokens 900 # a small output budget, to watch it split
|
|
9
|
+
* node test/live.mjs --dry # show what the model proposes, write nothing
|
|
10
|
+
* node test/live.mjs --keep # leave the temp vault on disk to look at
|
|
11
|
+
*
|
|
12
|
+
* The key is resolved exactly the way the plugin resolves it: env var named by
|
|
13
|
+
* `extractor.apiKeyEnv`, then the harness credential store. Override the endpoint
|
|
14
|
+
* with LIVE_BASE_URL / LIVE_MODEL / LIVE_KEY_ENV.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import fsp from 'node:fs/promises'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { resolveConfig } from '../src/config.js'
|
|
21
|
+
import { createRedactor } from '../src/privacy.js'
|
|
22
|
+
import { createVault } from '../src/vault.js'
|
|
23
|
+
import { distill } from '../src/extract.js'
|
|
24
|
+
import { searchCards } from '../src/search.js'
|
|
25
|
+
import { registerCapture } from '../src/capture.js'
|
|
26
|
+
|
|
27
|
+
const argv = process.argv.slice(2)
|
|
28
|
+
const flag = (name) => argv.includes(`--${name}`)
|
|
29
|
+
const value = (name) => {
|
|
30
|
+
const at = argv.indexOf(`--${name}`)
|
|
31
|
+
return at >= 0 ? argv[at + 1] : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mem-live-'))
|
|
35
|
+
const cfg = resolveConfig({
|
|
36
|
+
vaultDir: dir,
|
|
37
|
+
extractor: {
|
|
38
|
+
enabled: true,
|
|
39
|
+
// This script talks to an endpoint directly; the plugin's default transport
|
|
40
|
+
// borrows Settings instead, which a bare `node` process cannot do.
|
|
41
|
+
transport: 'http',
|
|
42
|
+
apiKeyEnv: process.env.LIVE_KEY_ENV || 'DEEPSEEK_API_KEY',
|
|
43
|
+
baseUrl: process.env.LIVE_BASE_URL || 'https://api.deepseek.com/v1',
|
|
44
|
+
model: process.env.LIVE_MODEL || 'deepseek-chat',
|
|
45
|
+
...(value('tokens') ? { maxOutputTokens: Number(value('tokens')) } : {}),
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
const redactor = createRedactor(cfg.privacy)
|
|
49
|
+
const vault = createVault(cfg, redactor)
|
|
50
|
+
|
|
51
|
+
const SEED = [
|
|
52
|
+
{
|
|
53
|
+
project: 'memory-graph',
|
|
54
|
+
t: '2026-10-28T10:12:00+08:00',
|
|
55
|
+
user: '我打算把长期记忆做成纯文本的库:episodes 存原始对话,cards 存蒸馏出来的记忆,entities 存实体索引。你觉得这样分层有问题吗?',
|
|
56
|
+
assistant:
|
|
57
|
+
'分层本身没问题,但要防止 cards 变成 episodes 的摘要备份。关键是 cards 必须有类型和有效期,否则过时的决策会一直毒害检索。建议每条卡片带 kind、valid_from/valid_to、supersedes,并且蒸馏只在离线显式触发,不挂在对话主路径上。',
|
|
58
|
+
tools: 0,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
project: 'memory-graph',
|
|
62
|
+
t: '2026-10-28T10:41:00+08:00',
|
|
63
|
+
user: '那就定下来:蒸馏用一个单独的模型接口,key 从环境变量读,不写进配置文件。另外记忆只在模型主动查的时候给,不做自动注入。',
|
|
64
|
+
assistant:
|
|
65
|
+
'同意。自动注入会把记忆塞进每一个轮次,token 成本和污染风险都不划算;工具化按需查询可控得多。apiKeyEnv 默认 MEMORY_LLM_API_KEY,配置文件里永远只出现变量名。',
|
|
66
|
+
tools: 3,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
project: 'blog-engine',
|
|
70
|
+
t: '2026-10-30T09:05:00+08:00',
|
|
71
|
+
user: 'The blog generator keeps rewriting my markdown footnotes. I want the pipeline to treat the markdown body as opaque: only rewrite frontmatter, never touch the rendered content.',
|
|
72
|
+
assistant:
|
|
73
|
+
'Then the transformer has to parse frontmatter and stop at the closing delimiter, passing the rest through byte-for-byte. Any markdown-aware step breaks that promise, so drop the remark/rehype pass from the publish path and run validation as a separate read-only check.',
|
|
74
|
+
tools: 1,
|
|
75
|
+
},
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
if (value('log')) {
|
|
79
|
+
const lines = fs.readFileSync(value('log'), 'utf8').split('\n').filter(Boolean)
|
|
80
|
+
const handlers = new Map()
|
|
81
|
+
const ctx = {
|
|
82
|
+
on(name, fn) {
|
|
83
|
+
if (!handlers.has(name)) handlers.set(name, [])
|
|
84
|
+
handlers.get(name).push(fn)
|
|
85
|
+
return () => {}
|
|
86
|
+
},
|
|
87
|
+
logger: { info() {}, warn() {} },
|
|
88
|
+
}
|
|
89
|
+
registerCapture(ctx, { cfg, vault, redactor })
|
|
90
|
+
const session = { id: 'live-replay', header: { cwd: process.cwd() } }
|
|
91
|
+
let seen = 0
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
let event
|
|
94
|
+
try {
|
|
95
|
+
event = JSON.parse(line)
|
|
96
|
+
} catch {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (!['user/message', 'assistant/message', 'tool/result', 'turn/end'].includes(event.type)) continue
|
|
100
|
+
seen += 1
|
|
101
|
+
for (const fn of handlers.get('session/event') ?? []) fn(session, event)
|
|
102
|
+
}
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
104
|
+
console.log(`replayed ${seen} events from ${value('log')}`)
|
|
105
|
+
} else {
|
|
106
|
+
const want = Number(value('backlog') ?? 0)
|
|
107
|
+
let i = 0
|
|
108
|
+
do {
|
|
109
|
+
const template = SEED[i % SEED.length]
|
|
110
|
+
const round = Math.floor(i / SEED.length)
|
|
111
|
+
await vault.appendEpisode({
|
|
112
|
+
...template,
|
|
113
|
+
// Same content, different turn: a backlog has to look like a real session
|
|
114
|
+
// continuing rather than the same episode copied verbatim.
|
|
115
|
+
t: new Date(Date.parse(template.t) + round * 3600_000).toISOString(),
|
|
116
|
+
user: round === 0 ? template.user : `${template.user}(第 ${round + 1} 次讨论)`,
|
|
117
|
+
})
|
|
118
|
+
i += 1
|
|
119
|
+
} while (i < Math.max(SEED.length, want))
|
|
120
|
+
console.log(`seeded ${i} episodes`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const stats = await vault.stats()
|
|
124
|
+
console.log(`vault ${dir}\nepisodes: ${stats.episodes.records} · cards: ${stats.cards.total} · projects: ${stats.projects.join(', ') || '-'}`)
|
|
125
|
+
if (stats.episodes.records === 0) {
|
|
126
|
+
console.error('no episodes to distill — nothing to check')
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const started = Date.now()
|
|
131
|
+
const report = await distill({ cfg, vault, redactor, dryRun: flag('dry') })
|
|
132
|
+
const batching = report.batches ? ` · ${report.batches.done}/${report.batches.planned} batch(es) of ${report.batches.size}` : ''
|
|
133
|
+
console.log(`\ndistill ${report.ok ? 'ok' : 'FAILED'} in ${Date.now() - started}ms · key=${report.keySource ?? '?'} · ${report.episodes} episode(s)${batching}`)
|
|
134
|
+
|
|
135
|
+
if (!report.ok) {
|
|
136
|
+
console.error(report.reason)
|
|
137
|
+
process.exit(1)
|
|
138
|
+
}
|
|
139
|
+
if (report.dryRun) {
|
|
140
|
+
for (const card of report.proposed) console.log(` [${card.kind}] ${card.title} · ${card.entities.join(', ')}`)
|
|
141
|
+
console.log(`\nskipped: ${report.skipped ?? '-'}`)
|
|
142
|
+
} else {
|
|
143
|
+
for (const card of report.cards) console.log(` ${card.id} [${card.kind}] ${card.title}${card.deduped ? ' (dup)' : ''}`)
|
|
144
|
+
const fresh = report.cards.filter((card) => !card.deduped).length
|
|
145
|
+
console.log(`written: ${fresh} new · ${report.cards.length - fresh} re-confirmed · usage: ${JSON.stringify(report.usage)}`)
|
|
146
|
+
console.log(`skipped: ${report.skipped ?? '-'}`)
|
|
147
|
+
|
|
148
|
+
// The loop only closes if what was written can be found again.
|
|
149
|
+
const probe = report.cards[0]?.title ?? 'memory'
|
|
150
|
+
const hits = searchCards(await vault.listCards(), probe, { limit: 3 })
|
|
151
|
+
console.log(`\nsearch "${probe.slice(0, 40)}" -> ${hits.length} hit(s)`)
|
|
152
|
+
for (const hit of hits) console.log(` ${hit.score.toFixed(2)} ${hit.card.id} ${hit.card.title}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (flag('keep')) console.log(`\nkept: ${dir}`)
|
|
156
|
+
else await fsp.rm(dir, { recursive: true, force: true })
|
package/test/page.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole-vault graph page.
|
|
3
|
+
*
|
|
4
|
+
* This route is what makes the graph viewable inside the app: the sidebar panel
|
|
5
|
+
* embeds it, so the page has to be real HTML, has to be the *same* renderer the
|
|
6
|
+
* vault writes to disk, and has to say something printable when the vault is
|
|
7
|
+
* unreadable instead of handing the panel a blank frame.
|
|
8
|
+
*
|
|
9
|
+
* node test/page.mjs
|
|
10
|
+
*/
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import fsp from 'node:fs/promises'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { PAGE_PATH, createPageHandler } from '../src/pageapi.js'
|
|
17
|
+
import { resolveConfig } from '../src/config.js'
|
|
18
|
+
import { createRedactor } from '../src/privacy.js'
|
|
19
|
+
import { createVault } from '../src/vault.js'
|
|
20
|
+
|
|
21
|
+
const results = []
|
|
22
|
+
function check(name, fn) {
|
|
23
|
+
return Promise.resolve()
|
|
24
|
+
.then(fn)
|
|
25
|
+
.then(
|
|
26
|
+
() => results.push(` ok ${name}`),
|
|
27
|
+
(error) => {
|
|
28
|
+
results.push(` FAIL ${name}: ${error.message}`)
|
|
29
|
+
process.exitCode = 1
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-page-'))
|
|
35
|
+
const cfg = resolveConfig({ vaultDir: dir })
|
|
36
|
+
const redactor = createRedactor(cfg.privacy)
|
|
37
|
+
const vault = createVault(cfg, redactor)
|
|
38
|
+
|
|
39
|
+
await check('the route sits on the authenticated /api channel like the other two', () => {
|
|
40
|
+
assert.ok(PAGE_PATH.startsWith('/api/'))
|
|
41
|
+
assert.ok(PAGE_PATH.includes('page'), 'it is the page door, not the graph door')
|
|
42
|
+
assert.ok(!PAGE_PATH.includes('?'), 'a query string would make the exact route never match')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
await check('an empty vault still answers with a page, not an error', async () => {
|
|
46
|
+
const handler = createPageHandler({ getVault: () => vault })
|
|
47
|
+
const response = await handler()
|
|
48
|
+
assert.equal(response.status, 200)
|
|
49
|
+
assert.match(response.headers.get('content-type'), /text\/html/)
|
|
50
|
+
const body = await response.text()
|
|
51
|
+
assert.ok(body.toLowerCase().includes('<!doctype html'), body.slice(0, 80))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
await check('it is the same renderer the vault writes to disk', async () => {
|
|
55
|
+
await vault.writeCard({
|
|
56
|
+
kind: 'decision',
|
|
57
|
+
project: '测试',
|
|
58
|
+
title: '整库图与应用里的面板是同一份渲染',
|
|
59
|
+
body: '因为第二份实现迟早会和库里的东西对不上。',
|
|
60
|
+
sources: [],
|
|
61
|
+
})
|
|
62
|
+
await vault.regenerateGraph()
|
|
63
|
+
const onDisk = await fsp.readFile(path.join(dir, 'graph.html'), 'utf8')
|
|
64
|
+
const handler = createPageHandler({ getVault: () => vault })
|
|
65
|
+
const served = await (await handler()).text()
|
|
66
|
+
// The timestamp differs between the two renders; everything structural must not.
|
|
67
|
+
// It carries the host's offset (`…T13:21:53+08:00`), not `Z`, so the normalizer
|
|
68
|
+
// has to accept both — matching only `Z` made this check pass or fail depending
|
|
69
|
+
// on whether the two renders landed in the same second.
|
|
70
|
+
const skeleton = (html) =>
|
|
71
|
+
html.replace(/<!--[^]*?-->/g, '').replace(/\d{4}-\d{2}-\d{2}T[\d:.]+(?:Z|[+-]\d{2}:\d{2})/g, 'T')
|
|
72
|
+
assert.equal(skeleton(served), skeleton(onDisk))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
await check('the page is frameable by its own origin and nothing else', async () => {
|
|
76
|
+
const handler = createPageHandler({ getVault: () => vault })
|
|
77
|
+
const response = await handler()
|
|
78
|
+
assert.equal(response.headers.get('x-frame-options'), 'SAMEORIGIN')
|
|
79
|
+
assert.equal(response.headers.get('cache-control'), 'no-store', 'a stale graph would be a lie')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
await check('a broken vault prints the reason instead of a blank frame', async () => {
|
|
83
|
+
const broken = {
|
|
84
|
+
graphPage: async () => {
|
|
85
|
+
throw new Error('EACCES: 读不了 <vault>/cards')
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
const handler = createPageHandler({ getVault: () => broken })
|
|
89
|
+
const response = await handler()
|
|
90
|
+
assert.equal(response.status, 500)
|
|
91
|
+
const body = await response.text()
|
|
92
|
+
assert.ok(body.includes('EACCES'), 'the reason is on the page')
|
|
93
|
+
assert.ok(body.includes('<vault>'), 'and it is escaped, not injected as markup')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
await check('no vault at all is a 500 with a reason, not a crash', async () => {
|
|
97
|
+
const handler = createPageHandler({ getVault: () => null })
|
|
98
|
+
const response = await handler()
|
|
99
|
+
assert.equal(response.status, 500)
|
|
100
|
+
assert.ok((await response.text()).includes('vault unavailable'))
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await check('the vault never reaches the browser as a path', async () => {
|
|
104
|
+
const handler = createPageHandler({ getVault: () => vault })
|
|
105
|
+
const body = await (await handler()).text()
|
|
106
|
+
assert.equal(body.includes(dir), false, 'the served page carries no vault path')
|
|
107
|
+
assert.equal(body.includes('episodes/'), false, 'and no vault-relative file names')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
console.log(results.join('\n'))
|
|
111
|
+
if (process.exitCode) {
|
|
112
|
+
console.log('\nSOME CHECKS FAILED')
|
|
113
|
+
} else {
|
|
114
|
+
console.log(`\nall ${results.length} checks passed`)
|
|
115
|
+
await fsp.rm(dir, { recursive: true, force: true })
|
|
116
|
+
}
|