@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +466 -0
  4. package/README.md +459 -0
  5. package/client.js +1953 -0
  6. package/cordis.patch.yml +13 -0
  7. package/docs/auto-settings.png +0 -0
  8. package/docs/distill-button.png +0 -0
  9. package/docs/graph-dark.png +0 -0
  10. package/docs/graph-light.png +0 -0
  11. package/docs/session-tab-cards.png +0 -0
  12. package/docs/session-tab-open.png +0 -0
  13. package/docs/session-tab.png +0 -0
  14. package/docs/vault-panel.png +0 -0
  15. package/eval/REPORT.md +437 -0
  16. package/eval/questions.json +277 -0
  17. package/eval/run.mjs +460 -0
  18. package/eval/seed.mjs +489 -0
  19. package/icon.svg +26 -0
  20. package/index.js +243 -0
  21. package/locale/en.json +6 -0
  22. package/locale/zh.json +6 -0
  23. package/package.json +77 -0
  24. package/src/auto.js +194 -0
  25. package/src/capture.js +237 -0
  26. package/src/config.js +280 -0
  27. package/src/distillapi.js +30 -0
  28. package/src/distillrun.js +106 -0
  29. package/src/extract.js +660 -0
  30. package/src/graph.js +1582 -0
  31. package/src/graphdata.js +475 -0
  32. package/src/http.js +32 -0
  33. package/src/pageapi.js +40 -0
  34. package/src/privacy.js +78 -0
  35. package/src/schema.js +323 -0
  36. package/src/search.js +317 -0
  37. package/src/tools.js +534 -0
  38. package/src/vault.js +888 -0
  39. package/test/auto.mjs +295 -0
  40. package/test/capture.mjs +264 -0
  41. package/test/client.mjs +1035 -0
  42. package/test/distillapi.mjs +212 -0
  43. package/test/extract.mjs +660 -0
  44. package/test/graphdata.mjs +295 -0
  45. package/test/live-supersede.mjs +78 -0
  46. package/test/live.mjs +156 -0
  47. package/test/page.mjs +116 -0
  48. package/test/schema.mjs +256 -0
  49. package/test/smoke.mjs +428 -0
  50. package/test/tools.mjs +238 -0
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Settings-form schema checks.
3
+ *
4
+ * The harness draws this plugin's page in 设置 (Settings) from the `Config`
5
+ * schema it exports: `@deepseek-ai/dsh-settings` keeps only the fields marked
6
+ * `.volatile()` and serves those as one editable form. Two kinds of rot are
7
+ * possible and both are silent: a field added to `src/config.js` but not to the
8
+ * form (invisible setting), and a default that disagrees between the form and
9
+ * the runtime (the form shows one thing, the plugin does another).
10
+ *
11
+ * The drift check below walks every leaf of the schema defaults and demands the
12
+ * same value from `resolveConfig({})`, so adding a field in only one place fails
13
+ * here instead of shipping.
14
+ *
15
+ * The last three checks are about the split that makes the page possible at all:
16
+ * a leaf is either volatile (live-editable in Settings) or structural (read once
17
+ * while activating, so editing it remounts the plugin). `client.js` declares the
18
+ * page's fields; the two descriptions must agree, or the page shows a knob the
19
+ * host refuses to write, or misses a knob the host serves.
20
+ */
21
+ import assert from 'node:assert/strict'
22
+ import path from 'node:path'
23
+ import { Config } from '../src/schema.js'
24
+ import { DEFAULT_SKIP_PATTERNS, resolveConfig, unwrapConfig } from '../src/config.js'
25
+
26
+ // client.js is a browser bundle; imported under Node it only publishes its
27
+ // field table on globalThis (see the entry-point guard at the bottom of it).
28
+ await import('../client.js')
29
+ const spec = globalThis.__memoryGraphFormSpec
30
+
31
+ let passed = 0
32
+ const check = (name, fn) => {
33
+ try {
34
+ fn()
35
+ passed += 1
36
+ console.log(` ok ${name}`)
37
+ } catch (error) {
38
+ console.log(` FAIL ${name}: ${error.message}`)
39
+ process.exitCode = 1
40
+ }
41
+ }
42
+
43
+ const walk = (value, prefix = '') => {
44
+ const leaves = []
45
+ for (const [key, child] of Object.entries(value)) {
46
+ const at = prefix === '' ? key : `${prefix}.${key}`
47
+ if (child !== null && typeof child === 'object' && !Array.isArray(child)) leaves.push(...walk(child, at))
48
+ else leaves.push([at, child])
49
+ }
50
+ return leaves
51
+ }
52
+
53
+ /** Every leaf node of a schema with its dotted path and its node. */
54
+ const schemaLeaves = (node, prefix = []) => {
55
+ if (!node) return []
56
+ if (node.type === 'object') {
57
+ return Object.entries(node.dict ?? {}).flatMap(([key, child]) => schemaLeaves(child, [...prefix, key]))
58
+ }
59
+ return [{ path: prefix.join('.'), node }]
60
+ }
61
+
62
+ const at = (object, dotted) => dotted.split('.').reduce((node, key) => node?.[key], object)
63
+
64
+ console.log('schema')
65
+
66
+ // `Config({})` validates to frozen `{ get() }` refs for every live leaf, so all
67
+ // value comparisons go through `unwrapConfig` — the same seam `src/config.js`
68
+ // uses when the plugin reads its configuration.
69
+ const defaults = unwrapConfig(Config({}))
70
+
71
+ check('an empty config block is valid and yields the documented defaults', () => {
72
+ assert.equal(defaults.enabled, true)
73
+ assert.equal(defaults.vaultDir, '')
74
+ assert.equal(defaults.extractor.enabled, false)
75
+ assert.equal(defaults.extractor.transport, 'harness')
76
+ assert.equal(defaults.extractor.useCurrentModel, true)
77
+ assert.equal(defaults.extractor.reasoningEffort, '')
78
+ assert.deepEqual(defaults.capture.skipPatterns, [...DEFAULT_SKIP_PATTERNS])
79
+ })
80
+
81
+ check('every field the plugin normalises also has a default in the form', () => {
82
+ const runtime = resolveConfig({})
83
+ // vaultDir is the one leaf that must differ: the form holds the user's raw
84
+ // text ('' means "the default vault") while the runtime holds an absolute path.
85
+ const skip = new Set(['vaultDir'])
86
+ const drift = []
87
+ for (const [key, value] of walk(defaults)) {
88
+ if (skip.has(key)) continue
89
+ const actual = at(runtime, key)
90
+ if (JSON.stringify(actual) !== JSON.stringify(value)) {
91
+ drift.push(`${key}: form ${JSON.stringify(value)} vs runtime ${JSON.stringify(actual)}`)
92
+ }
93
+ }
94
+ assert.deepEqual(drift, [], `settings form and runtime defaults disagree:\n${drift.join('\n')}`)
95
+ })
96
+
97
+ check('and no runtime default hides behind a missing form field', () => {
98
+ // The other direction: a knob `src/config.js` understands but the form never
99
+ // shows is an invisible setting, which is just as bad as a dead one.
100
+ const runtime = resolveConfig({})
101
+ const shown = new Set(walk(defaults).map(([key]) => key))
102
+ const hidden = walk(runtime)
103
+ .map(([key]) => key)
104
+ .filter((key) => key !== 'vaultDir' && !shown.has(key))
105
+ assert.deepEqual(hidden, [])
106
+ })
107
+
108
+ check('the runtime still fills what the form leaves blank', () => {
109
+ const runtime = resolveConfig({})
110
+ assert.equal(runtime.vaultDir, path.join(process.env.USERPROFILE ?? process.env.HOME ?? '', 'dsh-memory'))
111
+ // A blank model is a real answer in harness mode: `resolveRoute` says what is
112
+ // missing instead of quietly calling a different model than the user picked.
113
+ assert.equal(runtime.extractor.model, '')
114
+ // And http mode keeps its friendly default.
115
+ assert.equal(resolveConfig({ extractor: { transport: 'http' } }).extractor.model, 'deepseek-chat')
116
+ })
117
+
118
+ check('the transport union accepts exactly the two documented values', () => {
119
+ assert.equal(unwrapConfig(Config({ extractor: { transport: 'http' } })).extractor.transport, 'http')
120
+ assert.equal(unwrapConfig(Config({ extractor: { transport: 'harness' } })).extractor.transport, 'harness')
121
+ assert.throws(() => Config({ extractor: { transport: 'openai' } }))
122
+ })
123
+
124
+ check('numeric bounds reject what the runtime would silently clamp', () => {
125
+ assert.throws(() => Config({ extractor: { maxOutputTokens: 3000.5 } }), /256|multiple|step/)
126
+ assert.throws(() => Config({ retrieval: { hops: 3 } }))
127
+ // Values the runtime accepts must be accepted by the form too, or the form
128
+ // cannot express a config the plugin supports.
129
+ assert.equal(unwrapConfig(Config({ extractor: { maxOutputTokens: 3000 } })).extractor.maxOutputTokens, 3000)
130
+ assert.equal(unwrapConfig(Config({ retrieval: { hops: 2 } })).retrieval.hops, 2)
131
+ })
132
+
133
+ check('the inline key is marked secret so the form can mask it', () => {
134
+ const json = Config.toJSON()
135
+ const text = JSON.stringify(json)
136
+ assert.match(text, /"apiKey"/)
137
+ assert.match(text, /"secret"/)
138
+ assert.equal(Object.keys(json.refs ?? {}).length > 0, true, 'a schema with refs is what the Settings form projects')
139
+ })
140
+
141
+ check('every setting appears in the projected schema', () => {
142
+ const text = JSON.stringify(Config.toJSON())
143
+ const names = [
144
+ 'enabled',
145
+ 'vaultDir',
146
+ 'defaultProject',
147
+ 'crossProject',
148
+ 'capture',
149
+ 'userChars',
150
+ 'assistantChars',
151
+ 'includeToolOutput',
152
+ 'minChars',
153
+ 'skipPatterns',
154
+ 'privacy',
155
+ 'useDefaultPatterns',
156
+ 'denyPatterns',
157
+ 'redactEmails',
158
+ 'redactPaths',
159
+ 'extractor',
160
+ 'transport',
161
+ 'useCurrentModel',
162
+ 'provider',
163
+ 'reasoningEffort',
164
+ 'baseUrl',
165
+ 'path',
166
+ 'model',
167
+ 'apiKeyEnv',
168
+ 'apiKey',
169
+ 'maxOutputTokens',
170
+ 'temperature',
171
+ 'timeoutMs',
172
+ 'maxEpisodesPerRun',
173
+ 'retrieval',
174
+ 'limit',
175
+ 'hops',
176
+ 'snippetChars',
177
+ 'timelineLimit',
178
+ ]
179
+ const missing = names.filter((name) => !text.includes(`"${name}"`))
180
+ assert.deepEqual(missing, [])
181
+ })
182
+
183
+ check('a distilling config round-trips from the form into the runtime', () => {
184
+ const runtime = resolveConfig(Config({ extractor: { enabled: true, transport: 'harness', reasoningEffort: 'high' } }))
185
+ assert.equal(runtime.extractor.enabled, true)
186
+ assert.equal(runtime.extractor.transport, 'harness')
187
+ assert.equal(runtime.extractor.reasoningEffort, 'high')
188
+ })
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // The volatile / structural split
192
+ // ---------------------------------------------------------------------------
193
+
194
+ const leaves = schemaLeaves(Config)
195
+ const live = leaves.filter((leaf) => leaf.node.meta?.volatile === true)
196
+ const structural = leaves.filter((leaf) => leaf.node.meta?.volatile !== true)
197
+
198
+ check('the live knobs are volatile, which is the only thing 设置 can write', () => {
199
+ // `volatileForm()` in dsh-settings returns undefined unless some leaf is
200
+ // volatile, and an undefined form means the entry does not appear in 设置 at
201
+ // all — this is the check that would have caught the empty settings page.
202
+ assert.equal(
203
+ live.length,
204
+ spec.paths.length,
205
+ `expected one volatile leaf per page field, found ${live.length} live leaves for ${spec.paths.length} fields`,
206
+ )
207
+ assert.equal(
208
+ structural.length,
209
+ 4,
210
+ `only enabled / vaultDir / defaultProject / crossProject stay ordinary, found ${structural.length}`,
211
+ )
212
+ assert.deepEqual(
213
+ live.map((leaf) => leaf.path).sort(),
214
+ [...spec.paths].sort(),
215
+ 'the page and the schema must serve the same live fields',
216
+ )
217
+ })
218
+
219
+ check('the structural knobs stay ordinary so a change remounts the plugin', () => {
220
+ // They are consumed while activating (register tools, mkdir the vault, tag the
221
+ // session). A live write could not take effect, so they must not be volatile —
222
+ // and the page must say so instead of offering a switch that does nothing.
223
+ assert.deepEqual(structural.map((leaf) => leaf.path).sort(), ['crossProject', 'defaultProject', 'enabled', 'vaultDir'])
224
+ for (const knob of spec.structural) {
225
+ assert.ok(!spec.paths.includes(knob), `${knob} is structural and must not be on the Settings page`)
226
+ }
227
+ })
228
+
229
+ check('the page agrees with the schema about numbers', () => {
230
+ const byPath = new Map(leaves.map((leaf) => [leaf.path, leaf.node]))
231
+ for (const section of spec.sections) {
232
+ for (const field of section.fields) {
233
+ const node = byPath.get(field.path.join('.'))
234
+ assert.ok(node, `page field ${field.path.join('.')} is not in the schema`)
235
+ if (field.type !== 'number') continue
236
+ const bounds = node.meta ?? {}
237
+ assert.equal(field.min, bounds.min, `${field.path.join('.')}: page min disagrees with the schema`)
238
+ assert.equal(field.max, bounds.max, `${field.path.join('.')}: page max disagrees with the schema`)
239
+ }
240
+ }
241
+ })
242
+
243
+ check('every page field is rendered from something the form serves', () => {
244
+ const seen = new Set()
245
+ for (const section of spec.sections) {
246
+ for (const field of section.fields) {
247
+ const key = field.path.join('.')
248
+ assert.ok(!seen.has(key), `${key} is rendered twice`)
249
+ assert.ok(field.label, `${key} has no label`)
250
+ seen.add(key)
251
+ }
252
+ }
253
+ assert.equal(seen.size, spec.paths.length)
254
+ })
255
+
256
+ console.log(process.exitCode === 1 ? '\nSOME CHECKS FAILED' : `\nall ${passed} checks passed`)
package/test/smoke.mjs ADDED
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Smoke test: no network, no harness, no dependencies.
3
+ * node test/smoke.mjs
4
+ * Exercises the vault, the redactor, retrieval and the supersede chain.
5
+ */
6
+ import assert from 'node:assert/strict'
7
+ import fsp from 'node:fs/promises'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+ import { spawnSync } from 'node:child_process'
11
+
12
+ import { resolveConfig } from '../src/config.js'
13
+ import { createRedactor } from '../src/privacy.js'
14
+ import { createVault, NEAR_DUPLICATE, titleSimilarity } from '../src/vault.js'
15
+ import { searchCards, timelineCards, tokenize } from '../src/search.js'
16
+ import { buildPrompt, parseCards } from '../src/extract.js'
17
+ import { renderGraphHtml } from '../src/graph.js'
18
+
19
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-'))
20
+ const cfg = resolveConfig({ vaultDir: dir, extractor: { enabled: true } })
21
+ const redactor = createRedactor(cfg.privacy)
22
+ const vault = createVault(cfg, redactor)
23
+
24
+ const results = []
25
+ function check(name, fn) {
26
+ try {
27
+ fn()
28
+ results.push(` ok ${name}`)
29
+ } catch (error) {
30
+ results.push(` FAIL ${name}: ${error.message}`)
31
+ process.exitCode = 1
32
+ }
33
+ }
34
+
35
+ /* ------------------------------------------------------------------ redactor */
36
+ check('redacts api keys', () => {
37
+ const { text, hits } = redactor.redact('key is sk-abcdefghijklmnopqrstuvwxyz012345 and that is it')
38
+ assert.equal(text.includes('sk-abcdefghij'), false, text)
39
+ assert.ok(text.includes('[REDACTED]'), text)
40
+ assert.ok(hits.length > 0)
41
+ })
42
+ check('redacts emails', () => {
43
+ assert.equal(redactor.redact('mail me at a.b@example.com').text, 'mail me at [email]')
44
+ })
45
+ check('redacts home path', () => {
46
+ const { text } = redactor.redact(`config in ${os.homedir()}/x/y`)
47
+ assert.equal(text, 'config in ~/x/y')
48
+ })
49
+ check('leaves normal prose intact', () => {
50
+ const prose = '我们把存储从 SQLite 换成了纯文本,因为要能 diff。'
51
+ assert.equal(redactor.redact(prose).text, prose)
52
+ })
53
+
54
+ /* ------------------------------------------------------------------ tokenize */
55
+ check('tokenize latin + cjk', () => {
56
+ const tokens = tokenize('记忆图谱 memory graph 用纯文本存储')
57
+ assert.ok(tokens.includes('memory'), tokens.join(','))
58
+ assert.ok(tokens.includes('纯文'), tokens.join(','))
59
+ assert.ok(!tokens.includes('的'), 'stopword should be dropped')
60
+ })
61
+
62
+ /* ------------------------------------------------------------------ write */
63
+ const { card: storage, deduped: firstDeduped } = await vault.saveCard({
64
+ kind: 'decision',
65
+ title: '记忆存储用纯文本而不是 SQLite',
66
+ body: 'SQLite 要处理锁和二进制合并。纯文本可以直接 grep、直接读、直接备份。',
67
+ entities: ['memory-graph', 'vault格式', 'sqlite'],
68
+ confidence: 'user_stated',
69
+ project: 'memory-graph',
70
+ source: { kind: 'agent' },
71
+ })
72
+ check('writes a card', () => {
73
+ assert.equal(firstDeduped, false)
74
+ assert.match(storage.id, /^c-\d{8}-[0-9a-f]{4}$/)
75
+ })
76
+
77
+ const { card: again, deduped: secondDeduped } = await vault.saveCard({
78
+ kind: 'decision',
79
+ title: '记忆存储用纯文本而不是 SQLite。',
80
+ body: '短一点的重复内容',
81
+ entities: ['vault格式'],
82
+ project: 'memory-graph',
83
+ })
84
+ check('re-confirms an identical card instead of duplicating', () => {
85
+ assert.equal(secondDeduped, true)
86
+ assert.equal(again.id, storage.id)
87
+ })
88
+
89
+ /* ------------------------------------------------- near-duplicate titles */
90
+ check('titleSimilarity scores identical and unrelated titles at the ends', () => {
91
+ assert.equal(titleSimilarity('检索用 BM25', '检索用 BM25'), 1)
92
+ assert.equal(titleSimilarity('检索用 BM25', '监控从 Zabbix 换到 Uptime Kuma'), 0)
93
+ assert.equal(titleSimilarity('', 'anything'), 0)
94
+ const related = titleSimilarity('蒸馏走独立模型接口', '蒸馏用独立模型接口')
95
+ assert.ok(related > NEAR_DUPLICATE, `related re-wordings must clear the threshold, got ${related}`)
96
+ })
97
+
98
+ const reworded = await vault.saveCard({
99
+ kind: 'decision',
100
+ title: '记忆存储用纯文本而非 SQLite 数据库',
101
+ body: '纯文本可以 grep、可以 diff、可以随时删除,也不用处理二进制合并。',
102
+ entities: ['vault格式'],
103
+ project: 'memory-graph',
104
+ })
105
+ check('a re-worded title re-confirms the card instead of forking the graph', () => {
106
+ assert.equal(reworded.deduped, true)
107
+ assert.equal(reworded.card.id, storage.id)
108
+ assert.equal(reworded.card.title, storage.title, 'the older wording wins, so ids stay stable')
109
+ assert.match(reworded.card.body, /grep/, 'a fuller body still lands')
110
+ })
111
+
112
+ const neighbour = await vault.saveCard({
113
+ kind: 'decision',
114
+ title: '监控从 Zabbix 换到 Uptime Kuma',
115
+ body: '外部拨测换成 Kuma,Zabbix 只留下历史数据。',
116
+ entities: ['监控'],
117
+ project: 'memory-graph',
118
+ })
119
+ check('a neighbouring topic in the same project stays its own card', () => {
120
+ assert.equal(neighbour.deduped, false)
121
+ assert.notEqual(neighbour.card.id, storage.id)
122
+ })
123
+
124
+ const twin = await vault.saveCard({
125
+ kind: 'decision',
126
+ title: '记忆存储用纯文本不仅不是 SQLite',
127
+ body: '另一个项目的同名记忆不该被跨项目吞掉。',
128
+ entities: [],
129
+ project: 'other-project',
130
+ })
131
+ check('dedupe never crosses projects', () => {
132
+ assert.equal(twin.deduped, false)
133
+ assert.equal(twin.card.project, 'other-project')
134
+ })
135
+
136
+ const reversal = await vault.saveCard({
137
+ kind: 'decision',
138
+ title: '监控从 Uptime Kuma 换到 Prometheus + Grafana',
139
+ body: 'Kuma 只留外部拨测。',
140
+ entities: ['监控'],
141
+ project: 'memory-graph',
142
+ supersedes: [neighbour.card.id],
143
+ })
144
+ check('a reversal that names its predecessor is a new card, not a duplicate', () => {
145
+ assert.equal(reversal.deduped, false)
146
+ assert.notEqual(reversal.card.id, neighbour.card.id)
147
+ })
148
+
149
+ const { card: otherProject } = await vault.saveCard({
150
+ kind: 'preference',
151
+ title: '用户希望所有插件零依赖且不依赖构建步骤',
152
+ body: '不需要 bundler,装到哪个 profile 都能直接跑。',
153
+ entities: ['dsh插件', '零依赖'],
154
+ confidence: 'user_stated',
155
+ project: 'other-project',
156
+ })
157
+
158
+ const { card: privacy } = await vault.saveCard({
159
+ kind: 'fact',
160
+ title: '记忆写入前必须过滤凭据',
161
+ body: '内置规则覆盖 sk- / ghp_ / 私钥块;命中即替换为 [REDACTED]。',
162
+ entities: ['vault格式', '隐私边界', 'dsh插件'],
163
+ confidence: 'user_stated',
164
+ project: 'memory-graph',
165
+ })
166
+
167
+
168
+ /* ------------------------------------------------------------------ supersede */
169
+ const { card: replaced } = await vault.saveCard({
170
+ kind: 'decision',
171
+ title: '抽取模型默认关闭',
172
+ body: '第一版打算默认开启蒸馏。',
173
+ entities: ['抽取器'],
174
+ project: 'memory-graph',
175
+ })
176
+ const { card: replacement } = await vault.saveCard({
177
+ kind: 'decision',
178
+ title: '抽取模型默认关闭,改用显式调用',
179
+ body: '默认开启会在用户不知情时产生 API 费用。改成只有 memory_distill 才调用。',
180
+ entities: ['抽取器', '隐私边界'],
181
+ project: 'memory-graph',
182
+ supersedes: [replaced.id],
183
+ })
184
+
185
+ /* ------------------------------------------------------------------ search */
186
+ const cards = await vault.listCards({ includeRetracted: true })
187
+ const hits = searchCards(cards, '为什么不用 sqlite 存记忆', { limit: 5, hops: 1 })
188
+ check('finds the storage decision', () => {
189
+ assert.ok(hits.length > 0, 'no hits')
190
+ assert.equal(hits[0].card.id, storage.id, `got ${hits[0].card.title}`)
191
+ })
192
+ check('keyword search is project-agnostic', () => {
193
+ const crossHits = searchCards(cards, '零依赖 插件', { limit: 5, hops: 1 })
194
+ assert.ok(crossHits.some((hit) => hit.card.id === otherProject.id), 'cross-project miss')
195
+ })
196
+ check('entity expansion pulls in a linked neighbour', () => {
197
+ const linked = searchCards(cards, 'vault格式', { limit: 10, hops: 1 })
198
+ const ids = linked.map((hit) => hit.card.id)
199
+ assert.ok(ids.includes(storage.id) && ids.includes(privacy.id), ids.join(','))
200
+ })
201
+ check('superseded card is demoted but still findable', () => {
202
+ const chain = searchCards(cards, '抽取模型默认关闭', { limit: 5, hops: 0 })
203
+ const ids = chain.map((hit) => hit.card.id)
204
+ assert.ok(ids.includes(replacement.id), ids.join(','))
205
+ const old = cards.find((card) => card.id === replaced.id)
206
+ assert.equal(old.status, 'superseded')
207
+ assert.equal(old.supersededBy, replacement.id)
208
+ assert.ok(old.validTo, 'superseded card needs a valid_to')
209
+ })
210
+ check('no-match query degrades to recency, not an error', () => {
211
+ const none = searchCards(cards, '完全无关的词组zzz', { limit: 3, hops: 0 })
212
+ assert.ok(Array.isArray(none))
213
+ })
214
+
215
+ /* ------------------------------------------------------------------ timeline */
216
+ const timeline = timelineCards(cards, { limit: 10, entity: 'vault格式' })
217
+ check('timeline filters by entity', () => {
218
+ assert.ok(timeline.length >= 2, String(timeline.length))
219
+ assert.ok(timeline.every((card) => card.entities.includes('vault格式')))
220
+ })
221
+
222
+ /* ------------------------------------------------------------------ entities */
223
+ const entities = await vault.listEntities()
224
+ check('one page per entity, with mentions', () => {
225
+ const vaultFormat = entities.find((entity) => entity.slug === 'vault格式')
226
+ assert.ok(vaultFormat, entities.map((e) => e.slug).join(','))
227
+ assert.ok(vaultFormat.mentions.length >= 2, String(vaultFormat.mentions.length))
228
+ })
229
+ check('one entity carries memories from several projects', () => {
230
+ const hub = entities.find((entity) => entity.slug === 'dsh插件')
231
+ assert.ok(hub, 'shared entity missing')
232
+ const touched = new Set(hub.mentions.map((mention) => mention.cardId))
233
+ const projects = new Set(cards.filter((card) => touched.has(card.id)).map((card) => card.project))
234
+ assert.ok(projects.size >= 2, [...projects].join(','))
235
+ })
236
+
237
+ /* ------------------------------------------------------------------ episodes */
238
+ await vault.appendEpisode({ t: '2026-11-02T10:00:00+08:00', project: 'p', session: 's1', turn: 1, user: 'hello there friend', assistant: 'hi' })
239
+ await vault.appendEpisode({ t: '2026-11-02T10:05:00+08:00', project: 'p', session: 's1', turn: 2, user: 'second message', assistant: 'ok' })
240
+ const pending = await vault.readUndistilled(10)
241
+ check('undistilled episodes are read from the cursor', () => {
242
+ assert.equal(pending.length, 2, String(pending.length))
243
+ })
244
+ await vault.markDistilled(pending)
245
+ const after = await vault.readUndistilled(10)
246
+ check('cursor leaves nothing pending', () => {
247
+ assert.equal(after.length, 0, String(after.length))
248
+ })
249
+
250
+ /* ------------------------------------------------------------------ extract parsing */
251
+ check('parses a fenced json reply', () => {
252
+ const reply = '```json\n{"cards":[{"kind":"fact","title":"存储是纯文本","body":"因为要能 diff。","entities":["vault格式"],"confidence":"user_stated"}]}\n```'
253
+ const { cards: parsed } = parseCards(reply)
254
+ assert.equal(parsed.length, 1)
255
+ assert.equal(parsed[0].kind, 'fact')
256
+ assert.equal(parsed[0].title, '存储是纯文本')
257
+ })
258
+ check('parses json with prose around it', () => {
259
+ const { cards: parsed } = parseCards('Sure. {"cards":[]} done.')
260
+ assert.equal(parsed.length, 0)
261
+ })
262
+ check('survives a non-json reply', () => {
263
+ const { cards: parsed, skipped } = parseCards('I could not find anything durable.')
264
+ assert.equal(parsed.length, 0)
265
+ assert.ok(skipped)
266
+ })
267
+ check('drops invalid kinds and keeps valid ones', () => {
268
+ const { cards: parsed } = parseCards('{"cards":[{"kind":"garbage","title":"a","body":"b"},{"kind":"open_question","title":"c","body":"d"}]}')
269
+ assert.equal(parsed.length, 2)
270
+ assert.equal(parsed[0].kind, 'note')
271
+ assert.equal(parsed[1].kind, 'open_question')
272
+ })
273
+ check('rejects supersedes ids that are not card ids', () => {
274
+ const { cards: parsed } = parseCards('{"cards":[{"kind":"fact","title":"a","body":"b","supersedes":["not-an-id","c-20261102-abcd"]}]}')
275
+ assert.deepEqual(parsed[0].supersedes, ['c-20261102-abcd'])
276
+ })
277
+ const prompt = buildPrompt(pending, cards, 'memory-graph')
278
+ check('prompt carries the transcript and existing ids', () => {
279
+ assert.equal(prompt.length, 2)
280
+ assert.ok(prompt[1].content.includes('hello there friend'))
281
+ assert.ok(prompt[1].content.includes(storage.id), 'existing ids should be offered for supersedes')
282
+ })
283
+
284
+ /* ------------------------------------------------------------------ config */
285
+ check('a broken skip pattern is dropped instead of disabling capture', () => {
286
+ const bad = resolveConfig({ capture: { skipPatterns: ['^Real prompt', '[unclosed'] } })
287
+ assert.deepEqual(bad.capture.skipPatterns, ['^Real prompt'])
288
+ })
289
+ check('injection defaults are present out of the box', () => {
290
+ const plain = resolveConfig({})
291
+ assert.equal(plain.capture.skipPatterns.some((p) => p.includes('checkpoint')), true)
292
+ assert.equal(plain.capture.skipPatterns.some((p) => p.includes('system-reminder')), true)
293
+ })
294
+
295
+ /* ------------------------------------------------------------------ readme + stats */
296
+ await vault.regenerateReadme()
297
+ const readme = await fsp.readFile(path.join(dir, 'README.md'), 'utf8')
298
+ check('README is generated and links cards', () => {
299
+ assert.ok(readme.includes('# Memory vault'), readme.slice(0, 120))
300
+ assert.ok(readme.includes('记忆库'), 'the vault index should be readable in Chinese too')
301
+ assert.ok(readme.includes(storage.id), 'readme should link cards')
302
+ assert.ok(readme.includes('## memory-graph'))
303
+ })
304
+ const stats = await vault.stats()
305
+ check('stats count accurately', () => {
306
+ assert.ok(stats.cards.total >= 5, String(stats.cards.total))
307
+ assert.ok(stats.cards.superseded >= 1)
308
+ assert.ok(stats.entities >= 4)
309
+ assert.ok(stats.projects.includes('memory-graph'))
310
+ assert.equal(stats.episodes.records, 2)
311
+ })
312
+
313
+ const gitignore = await fsp.readFile(path.join(dir, '.gitignore'), 'utf8')
314
+ check('the vault carries a .gitignore for its own repo', () => {
315
+ assert.ok(gitignore.includes('.distill-state.json'), gitignore)
316
+ })
317
+
318
+ /* ------------------------------------------------------------------ forget */
319
+ await vault.retractCard(privacy.id, 'test retraction')
320
+ const retracted = await vault.getCard(privacy.id)
321
+ const visibleCards = await vault.listCards()
322
+ const retractedHits = searchCards(visibleCards, '凭据 过滤', { limit: 5 })
323
+ check('retract keeps the file and sets status', () => {
324
+ assert.equal(retracted.status, 'retracted')
325
+ assert.ok(!retractedHits.some((hit) => hit.card.id === privacy.id), 'retracted card should not surface')
326
+ })
327
+ await vault.purgeCard(privacy.id)
328
+
329
+ const stillThere = await vault.getCard(privacy.id)
330
+ check('purged card is gone', () => {
331
+ assert.equal(stillThere, null)
332
+ })
333
+
334
+ /* ------------------------------------------------------------------ graph */
335
+ const graphOut = await vault.regenerateGraph()
336
+ const graphHtml = await fsp.readFile(graphOut.file, 'utf8')
337
+ const graphCards = await vault.listCards({ includeRetracted: true })
338
+ const graphEntities = await vault.listEntities()
339
+ const graphProjects = await vault.listProjects()
340
+
341
+ check('regenerateGraph writes graph.html', () => {
342
+ assert.equal(graphOut.file, path.join(dir, 'graph.html'))
343
+ assert.equal(graphOut.bytes, Buffer.byteLength(graphHtml, 'utf8'))
344
+ assert.equal(graphOut.cards, graphCards.length)
345
+ assert.equal(graphOut.entities, graphEntities.length)
346
+ })
347
+ check('graph.html is a self-contained page', () => {
348
+ assert.ok(graphHtml.length > 5000, `only ${graphHtml.length} bytes`)
349
+ assert.ok(graphHtml.startsWith('<!doctype html>'), graphHtml.slice(0, 40))
350
+ assert.ok(graphHtml.includes('<svg'), 'the canvas should be inline svg')
351
+ assert.ok(graphHtml.includes('记忆图'), 'the header should name the view')
352
+ })
353
+ check('graph carries every card and entity', () => {
354
+ for (const card of graphCards) assert.ok(graphHtml.includes(card.id), card.id)
355
+ for (const entity of graphEntities) assert.ok(graphHtml.includes(entity.slug), entity.slug)
356
+ for (const label of ['决策', '事实', '待解', '偏好', '记录', '实体']) {
357
+ assert.ok(graphHtml.includes(label), `the legend should name ${label}`)
358
+ }
359
+ })
360
+ check('graph loads nothing from the network', () => {
361
+ for (const bad of ['http://', 'https://', 'src=', '@import', 'url(http']) {
362
+ assert.equal(graphHtml.includes(bad), false, `graph.html must not contain ${bad}`)
363
+ }
364
+ })
365
+
366
+ const inlineScript = graphHtml.match(/<script>([\s\S]*?)<\/script>/)
367
+ const inlineFile = path.join(dir, 'graph-inline.mjs')
368
+ await fsp.writeFile(inlineFile, inlineScript ? inlineScript[1] : '', 'utf8')
369
+ const inlineParse = spawnSync(process.execPath, ['--check', inlineFile], { encoding: 'utf8' })
370
+ check('the inline script parses', () => {
371
+ assert.ok(inlineScript, 'graph.html should contain one inline script')
372
+ assert.equal(inlineParse.status, 0, inlineParse.stderr)
373
+ })
374
+
375
+ const htmlA = renderGraphHtml({
376
+ cards: graphCards,
377
+ entities: graphEntities,
378
+ projects: graphProjects,
379
+ generatedAt: '2026-11-02T00:00:00+08:00',
380
+ })
381
+ const htmlB = renderGraphHtml({
382
+ cards: graphCards,
383
+ entities: graphEntities,
384
+ projects: graphProjects,
385
+ generatedAt: '2026-11-03T09:30:00+08:00',
386
+ })
387
+ check('two renders differ only in the timestamp', () => {
388
+ assert.equal(
389
+ htmlA.replace('2026-11-02T00:00:00+08:00', 'T'),
390
+ htmlB.replace('2026-11-03T09:30:00+08:00', 'T'),
391
+ )
392
+ })
393
+
394
+ const emptyDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-empty-'))
395
+ const emptyCfg = resolveConfig({ vaultDir: emptyDir })
396
+ const emptyVault = createVault(emptyCfg, createRedactor(emptyCfg.privacy))
397
+ const emptyOut = await emptyVault.regenerateGraph()
398
+ const emptyHtml = await fsp.readFile(emptyOut.file, 'utf8')
399
+ check('an empty vault renders a readable page, not a blank one', () => {
400
+ assert.equal(emptyOut.cards, 0)
401
+ assert.ok(emptyHtml.includes('记忆库还是空的'), 'the empty state should explain itself')
402
+ assert.ok(emptyHtml.length > 4000, `only ${emptyHtml.length} bytes`)
403
+ })
404
+ await emptyVault.saveCard({
405
+ kind: 'fact',
406
+ title: '只有一张卡片',
407
+ body: '一张卡片也能画成一张图。',
408
+ entities: ['孤岛'],
409
+ confidence: 'verified',
410
+ })
411
+ const singleOut = await emptyVault.regenerateGraph()
412
+ const singleHtml = await fsp.readFile(singleOut.file, 'utf8')
413
+ check('a one-card vault renders its single neuron', () => {
414
+ assert.equal(singleOut.cards, 1)
415
+ assert.equal(singleOut.entities, 1)
416
+ assert.ok(singleHtml.includes('只有一张卡片'))
417
+ assert.ok(singleHtml.includes('孤岛'))
418
+ })
419
+ await fsp.rm(emptyDir, { recursive: true, force: true })
420
+
421
+ console.log(results.join('\n'))
422
+ console.log(`\nvault: ${dir}`)
423
+ if (process.exitCode) {
424
+ console.log('\nSOME CHECKS FAILED')
425
+ } else {
426
+ console.log(`\nall ${results.length} checks passed`)
427
+ await fsp.rm(dir, { recursive: true, force: true })
428
+ }