@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
package/src/vault.js ADDED
@@ -0,0 +1,888 @@
1
+ /**
2
+ * The vault: a plain-text, self-describing memory store.
3
+ *
4
+ * Layout
5
+ * vault.json schema marker
6
+ * projects.json project registry (cross-project linking)
7
+ * episodes/YYYY-MM-DD.jsonl append-only raw record of what happened
8
+ * cards/YYYY-MM/<id>.md distilled, typed, time-bounded memories
9
+ * entities/<slug>.md one page per thing, with a mention timeline
10
+ * audit/YYYY-MM.log human-readable trace of every write
11
+ * .distill-state.json how far extraction has read each episode file
12
+ *
13
+ * Nothing here is a database: every artefact is a file a human can read, diff
14
+ * and delete. `cards/` is the semantic layer, `episodes/` the raw layer, and
15
+ * the entity pages are the index that makes cross-project recall possible.
16
+ */
17
+ import fsp from 'node:fs/promises'
18
+ import path from 'node:path'
19
+ import crypto from 'node:crypto'
20
+ import { paths } from './config.js'
21
+ import { renderGraphHtml } from './graph.js'
22
+ import { tokenize } from './search.js'
23
+
24
+ const CARD_KEYS = [
25
+ 'id',
26
+ 'kind',
27
+ 'title',
28
+ 'project',
29
+ 'entities',
30
+ 'created',
31
+ 'valid_from',
32
+ 'valid_to',
33
+ 'status',
34
+ 'confidence',
35
+ 'source',
36
+ 'supersedes',
37
+ 'superseded_by',
38
+ 'tags',
39
+ 'updated',
40
+ ]
41
+
42
+ const ENTITY_KEYS = ['id', 'name', 'type', 'aliases', 'first_seen', 'last_seen', 'mentions']
43
+
44
+ export const CARD_KINDS = ['decision', 'fact', 'open_question', 'preference', 'note']
45
+ export const CARD_STATUS = ['active', 'superseded', 'retracted']
46
+ export const CONFIDENCE = ['user_stated', 'verified', 'inferred']
47
+
48
+ const locks = new Map()
49
+
50
+ function withLock(key, fn) {
51
+ const previous = locks.get(key) ?? Promise.resolve()
52
+ const next = previous.then(fn, fn)
53
+ locks.set(
54
+ key,
55
+ next.then(
56
+ () => undefined,
57
+ () => undefined,
58
+ ),
59
+ )
60
+ return next
61
+ }
62
+
63
+ async function exists(file) {
64
+ try {
65
+ await fsp.access(file)
66
+ return true
67
+ } catch {
68
+ return false
69
+ }
70
+ }
71
+
72
+ async function ensureDir(dir) {
73
+ await fsp.mkdir(dir, { recursive: true })
74
+ }
75
+
76
+ async function writeAtomic(file, content) {
77
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(3).toString('hex')}`
78
+ await ensureDir(path.dirname(file))
79
+ await fsp.writeFile(tmp, content, 'utf8')
80
+ await fsp.rename(tmp, file)
81
+ }
82
+
83
+ async function readText(file, fallback = '') {
84
+ try {
85
+ return await fsp.readFile(file, 'utf8')
86
+ } catch {
87
+ return fallback
88
+ }
89
+ }
90
+
91
+ async function readJson(file, fallback) {
92
+ const text = await readText(file, '')
93
+ if (text.trim() === '') return fallback
94
+ try {
95
+ return JSON.parse(text)
96
+ } catch {
97
+ return fallback
98
+ }
99
+ }
100
+
101
+ export function nowIso() {
102
+ const d = new Date()
103
+ const pad = (n) => String(n).padStart(2, '0')
104
+ const offset = -d.getTimezoneOffset()
105
+ const sign = offset >= 0 ? '+' : '-'
106
+ const abs = Math.abs(offset)
107
+ return (
108
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
109
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
110
+ `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
111
+ )
112
+ }
113
+
114
+ export function today() {
115
+ return nowIso().slice(0, 10)
116
+ }
117
+
118
+ /* ---------------------------------------------------------------- frontmatter */
119
+
120
+ function encodeValue(value) {
121
+ if (value === null || value === undefined) return 'null'
122
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
123
+ if (typeof value === 'number') return String(value)
124
+ if (Array.isArray(value)) return `[${value.map(encodeValue).join(', ')}]`
125
+ if (typeof value === 'object') return JSON.stringify(value)
126
+ return JSON.stringify(String(value))
127
+ }
128
+
129
+ function serializeFrontmatter(data, keyOrder) {
130
+ const seen = new Set()
131
+ const lines = []
132
+ for (const key of keyOrder) {
133
+ if (!(key in data)) continue
134
+ seen.add(key)
135
+ lines.push(`${key}: ${encodeValue(data[key])}`)
136
+ }
137
+ for (const key of Object.keys(data)) {
138
+ if (seen.has(key)) continue
139
+ lines.push(`${key}: ${encodeValue(data[key])}`)
140
+ }
141
+ return `---\n${lines.join('\n')}\n---\n`
142
+ }
143
+
144
+ function decodeValue(raw) {
145
+ const value = raw.trim()
146
+ if (value === '' || value === 'null' || value === '~') return null
147
+ if (value === 'true') return true
148
+ if (value === 'false') return false
149
+ if (value.startsWith('[') || value.startsWith('{') || value.startsWith('"')) {
150
+ try {
151
+ return JSON.parse(value)
152
+ } catch {
153
+ return value
154
+ }
155
+ }
156
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value)
157
+ return value
158
+ }
159
+
160
+ export function parseFrontmatter(text) {
161
+ if (!text.startsWith('---')) return { data: {}, body: text }
162
+ const end = text.indexOf('\n---', 3)
163
+ if (end === -1) return { data: {}, body: text }
164
+ const head = text.slice(3, end).replace(/^\r?\n/, '')
165
+ const body = text.slice(text.indexOf('\n', end + 1) + 1).replace(/^\r?\n/, '')
166
+ const data = {}
167
+ for (const line of head.split(/\r?\n/)) {
168
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue
169
+ const colon = line.indexOf(':')
170
+ if (colon === -1) continue
171
+ const key = line.slice(0, colon).trim()
172
+ if (key === '') continue
173
+ data[key] = decodeValue(line.slice(colon + 1))
174
+ }
175
+ return { data, body }
176
+ }
177
+
178
+ /* ---------------------------------------------------------------- identifiers */
179
+
180
+ function rand(n) {
181
+ return crypto.randomBytes(8).toString('hex').slice(0, n)
182
+ }
183
+
184
+ export function newCardId() {
185
+ return `c-${today().replace(/-/g, '')}-${rand(4)}`
186
+ }
187
+
188
+ export function slugify(name, fallback = 'item') {
189
+ const slug = String(name ?? '')
190
+ .trim()
191
+ .toLowerCase()
192
+ .replace(/[\\/:*?"<>|#%{}^~[\]`']/g, '')
193
+ .replace(/\s+/g, '-')
194
+ .replace(/-+/g, '-')
195
+ .replace(/^[-.]+|[-.]+$/g, '')
196
+ .slice(0, 60)
197
+ if (slug === '') return `${fallback}-${rand(4)}`
198
+ return slug
199
+ }
200
+
201
+ export function normalizeTitle(title) {
202
+ return String(title ?? '')
203
+ .toLowerCase()
204
+ .replace(/[\s\u3000]+/g, '')
205
+ .replace(/[.,;:!?'"`~!@#$%^&*()_\-+=[\]{}\\|/<>,。;:!?、“”‘’()【】《》…—]/g, '')
206
+ }
207
+
208
+ /**
209
+ * How much two titles have in common, 0..1: Jaccard over the same tokens the
210
+ * search uses (CJK bigrams, Latin words). Wording varies between extraction
211
+ * runs — "蒸馏走独立模型接口" and "蒸馏用独立模型接口" are one memory, not two —
212
+ * and a graph with both has two nodes pulling retrieval apart.
213
+ */
214
+ export function titleSimilarity(left, right) {
215
+ const a = new Set(tokenize(left))
216
+ const b = new Set(tokenize(right))
217
+ if (a.size === 0 || b.size === 0) return 0
218
+ let shared = 0
219
+ for (const token of a) if (b.has(token)) shared += 1
220
+ return shared / (a.size + b.size - shared)
221
+ }
222
+
223
+ /**
224
+ * Two titles are the same memory at this much overlap. Measured on the
225
+ * evaluation set: unrelated cards in one project peak at 0.20 (the retired
226
+ * Zabbix card and the Uptime Kuma one that replaced it, which must stay
227
+ * separate), while the re-wordings one model produced for the same episode
228
+ * scored 0.60 to 0.78. The threshold sits in that gap, low enough to catch a
229
+ * short re-wording.
230
+ */
231
+ export const NEAR_DUPLICATE = 0.55
232
+
233
+ /* ---------------------------------------------------------------- vault */
234
+
235
+ export function createVault(cfg, redactor) {
236
+ const P = paths(cfg.vaultDir)
237
+ let ready = null
238
+ let cardCache = { at: 0, cards: null }
239
+
240
+ async function ensure() {
241
+ if (ready) return ready
242
+ ready = (async () => {
243
+ await ensureDir(P.episodes)
244
+ await ensureDir(P.cards)
245
+ await ensureDir(P.entities)
246
+ await ensureDir(P.audit)
247
+
248
+ if (!(await exists(P.vaultJson))) {
249
+ await writeAtomic(
250
+ P.vaultJson,
251
+ `${JSON.stringify({ schema: 'dsh-memory-graph/1', created: nowIso(), app: 'dsh-memory-graph' }, null, 2)}\n`,
252
+ )
253
+ }
254
+ if (!(await exists(P.projects))) {
255
+ await writeAtomic(P.projects, '{}\n')
256
+ }
257
+ if (!(await exists(P.distillState))) {
258
+ await writeAtomic(P.distillState, '{}\n')
259
+ }
260
+ // The vault is plain text so the owner can version it themselves; keep the
261
+ // OS junk and the per-machine distillation cursors out of that repo.
262
+ if (!(await exists(P.gitignore))) {
263
+ await writeAtomic(P.gitignore, '*.tmp\n.DS_Store\nThumbs.db\n.distill-state.json\n')
264
+ }
265
+ })()
266
+ return ready
267
+ }
268
+
269
+ function invalidate() {
270
+ cardCache = { at: 0, cards: null }
271
+ }
272
+
273
+ async function audit(scope, message) {
274
+ await ensure()
275
+ const stamp = nowIso()
276
+ const file = path.join(P.audit, `${stamp.slice(0, 7)}.log`)
277
+ await withLock(`audit:${file}`, () => fsp.appendFile(file, `[${stamp}] ${scope}: ${message}\n`, 'utf8'))
278
+ }
279
+
280
+ /* ------------------------------------------------------------ projects */
281
+
282
+ async function touchProject(slug, title) {
283
+ await ensure()
284
+ return withLock(`projects:${P.projects}`, async () => {
285
+ const registry = await readJson(P.projects, {})
286
+ const now = nowIso()
287
+ const entry = registry[slug] ?? { title: title ?? slug, firstSeen: now, cards: 0 }
288
+ entry.title = entry.title || title || slug
289
+ entry.lastSeen = now
290
+ registry[slug] = entry
291
+ await writeAtomic(P.projects, `${JSON.stringify(registry, null, 2)}\n`)
292
+ return entry
293
+ })
294
+ }
295
+
296
+ async function listProjects() {
297
+ await ensure()
298
+ const registry = await readJson(P.projects, {})
299
+ return Object.entries(registry).map(([slug, meta]) => ({ slug, ...meta }))
300
+ }
301
+
302
+ /* ------------------------------------------------------------ episodes */
303
+
304
+ async function appendEpisode(record) {
305
+ await ensure()
306
+ const file = path.join(P.episodes, `${today()}.jsonl`)
307
+ const line = `${JSON.stringify(record)}\n`
308
+ await withLock(`episode:${file}`, () => fsp.appendFile(file, line, 'utf8'))
309
+ // Keep projects.json honest even before the first card is distilled.
310
+ if (typeof record?.project === 'string' && record.project !== '') {
311
+ await touchProject(record.project).catch(() => {})
312
+ }
313
+ return { file: path.basename(file), bytes: Buffer.byteLength(line) }
314
+ }
315
+
316
+ async function episodeFiles() {
317
+ await ensure()
318
+ let names = []
319
+ try {
320
+ names = await fsp.readdir(P.episodes)
321
+ } catch {
322
+ return []
323
+ }
324
+ return names.filter((n) => n.endsWith('.jsonl')).sort()
325
+ }
326
+
327
+ async function readUndistilled(maxEpisodes) {
328
+ await ensure()
329
+ const state = await readJson(P.distillState, {})
330
+ const files = state.files ?? {}
331
+ const out = []
332
+ for (const name of await episodeFiles()) {
333
+ if (out.length >= maxEpisodes) break
334
+ const text = await readText(path.join(P.episodes, name), '')
335
+ if (text === '') continue
336
+ const lines = text.split('\n').filter((l) => l.trim() !== '')
337
+ const start = files[name] ?? 0
338
+ for (let i = start; i < lines.length && out.length < maxEpisodes; i++) {
339
+ try {
340
+ out.push({ file: name, line: i, record: JSON.parse(lines[i]) })
341
+ } catch {
342
+ // A truncated last line (process killed mid-append) is skipped, not fatal.
343
+ }
344
+ }
345
+ }
346
+ return out
347
+ }
348
+
349
+ async function markDistilled(entries) {
350
+ await ensure()
351
+ return withLock(`distill:${P.distillState}`, async () => {
352
+ const state = await readJson(P.distillState, {})
353
+ state.files = state.files ?? {}
354
+ for (const entry of entries) {
355
+ state.files[entry.file] = Math.max(state.files[entry.file] ?? 0, entry.line + 1)
356
+ }
357
+ state.lastRun = nowIso()
358
+ await writeAtomic(P.distillState, `${JSON.stringify(state, null, 2)}\n`)
359
+ })
360
+ }
361
+
362
+ /* ------------------------------------------------------------ cards */
363
+
364
+ function cardPath(card) {
365
+ return path.join(P.cards, String(card.created ?? nowIso()).slice(0, 7), `${card.id}.md`)
366
+ }
367
+
368
+ function renderCard(card) {
369
+ const data = {
370
+ id: card.id,
371
+ kind: card.kind,
372
+ title: card.title,
373
+ project: card.project,
374
+ entities: card.entities ?? [],
375
+ created: card.created,
376
+ valid_from: card.validFrom ?? null,
377
+ valid_to: card.validTo ?? null,
378
+ status: card.status ?? 'active',
379
+ confidence: card.confidence ?? 'inferred',
380
+ source: card.source ?? null,
381
+ supersedes: card.supersedes ?? [],
382
+ superseded_by: card.supersededBy ?? null,
383
+ tags: card.tags ?? [],
384
+ updated: card.updated ?? null,
385
+ }
386
+ const body = String(card.body ?? '').trim()
387
+ const footer = [
388
+ '',
389
+ '---',
390
+ `<!-- dsh-memory-graph · ${card.kind} · ${card.project} -->`,
391
+ ].join('\n')
392
+ return `${serializeFrontmatter(data, CARD_KEYS)}\n${body}\n${footer}\n`
393
+ }
394
+
395
+ function cardFromFile(file, text) {
396
+ const { data, body } = parseFrontmatter(text)
397
+ if (!data.id) return null
398
+ const cleanBody = body
399
+ .replace(/\n---\n<!-- dsh-memory-graph[^>]*-->\s*$/, '')
400
+ .replace(/\n?<!-- dsh-memory-graph[^>]*-->\s*$/, '')
401
+ .trim()
402
+ return {
403
+ id: data.id,
404
+ kind: data.kind ?? 'note',
405
+ title: data.title ?? '(untitled)',
406
+ project: data.project ?? cfg.defaultProject,
407
+ entities: Array.isArray(data.entities) ? data.entities : [],
408
+ created: data.created ?? nowIso(),
409
+ validFrom: data.valid_from ?? data.created ?? null,
410
+ validTo: data.valid_to ?? null,
411
+ status: data.status ?? 'active',
412
+ confidence: data.confidence ?? 'inferred',
413
+ source: data.source ?? null,
414
+ supersedes: Array.isArray(data.supersedes) ? data.supersedes : [],
415
+ supersededBy: data.superseded_by ?? null,
416
+ tags: Array.isArray(data.tags) ? data.tags : [],
417
+ updated: data.updated ?? null,
418
+ body: cleanBody,
419
+ file,
420
+ }
421
+ }
422
+
423
+ async function listCards({ includeRetracted = false } = {}) {
424
+ await ensure()
425
+ const fresh = Date.now() - cardCache.at < 2000 && cardCache.cards !== null
426
+ if (fresh) {
427
+ return includeRetracted ? cardCache.cards : cardCache.cards.filter((c) => c.status !== 'retracted')
428
+ }
429
+ let names = []
430
+ try {
431
+ names = await fsp.readdir(P.cards, { recursive: true })
432
+ } catch {
433
+ names = []
434
+ }
435
+ const files = names.filter((n) => n.endsWith('.md'))
436
+ const cards = []
437
+ for (const rel of files) {
438
+ const file = path.join(P.cards, rel)
439
+ const text = await readText(file, '')
440
+ const card = cardFromFile(file, text)
441
+ if (card) cards.push(card)
442
+ }
443
+ cards.sort((a, b) => String(a.created).localeCompare(String(b.created)))
444
+ cardCache = { at: Date.now(), cards }
445
+ return includeRetracted ? cards : cards.filter((c) => c.status !== 'retracted')
446
+ }
447
+
448
+ async function getCard(id) {
449
+ const cards = await listCards({ includeRetracted: true })
450
+ return cards.find((c) => c.id === id) ?? null
451
+ }
452
+
453
+ /**
454
+ * The card this write would duplicate: an exact title first, then the closest
455
+ * re-wording of an existing active title in the same project. A card that
456
+ * names its predecessor is a reversal, not a duplicate: "监控换成 X" after
457
+ * "监控换到 Y" reads alike but is a new decision, so `supersedes` turns the
458
+ * near-duplicate pass off and only an exact title can match.
459
+ * @returns {Promise<{card: object, exact: boolean, score: number}|null>}
460
+ */
461
+ async function findDuplicate(title, project, { allowNear = true } = {}) {
462
+ const target = normalizeTitle(title)
463
+ if (target === '') return null
464
+ const cards = (await listCards()).filter((card) => card.project === project)
465
+ const exact = cards.find((card) => normalizeTitle(card.title) === target)
466
+ if (exact) return { card: exact, exact: true, score: 1 }
467
+ if (!allowNear) return null
468
+ let best = null
469
+ let bestScore = 0
470
+ for (const card of cards) {
471
+ if (card.status !== 'active') continue
472
+ const score = titleSimilarity(title, card.title)
473
+ if (score >= NEAR_DUPLICATE && score > bestScore) {
474
+ best = card
475
+ bestScore = score
476
+ }
477
+ }
478
+ return best ? { card: best, exact: false, score: bestScore } : null
479
+ }
480
+
481
+ /**
482
+ * Create a card, or re-confirm the identical one already present.
483
+ * @returns {{ card: object, deduped: boolean }}
484
+ */
485
+ async function writeCard(input) {
486
+ await ensure()
487
+ return withLock('cards', async () => {
488
+ const project = input.project || cfg.defaultProject
489
+ const duplicate = await findDuplicate(input.title, project, {
490
+ allowNear: (input.supersedes ?? []).length === 0,
491
+ })
492
+ const existing = duplicate?.card ?? null
493
+ const stamp = nowIso()
494
+
495
+ if (existing) {
496
+ existing.updated = stamp
497
+ existing.status = 'active'
498
+ if (input.body && input.body.length > (existing.body ?? '').length) existing.body = input.body
499
+ const merged = new Set([...(existing.entities ?? []), ...(input.entities ?? [])])
500
+ existing.entities = [...merged]
501
+ await writeAtomic(cardPath(existing), renderCard(existing))
502
+ invalidate()
503
+ await touchProject(project)
504
+ const why = duplicate.exact
505
+ ? ''
506
+ : ` (near-duplicate ${duplicate.score.toFixed(2)} of "${String(input.title ?? '').trim()}")`
507
+ await audit('card', `re-confirmed ${existing.id} "${existing.title}"${why}`)
508
+ return { card: existing, deduped: true }
509
+ }
510
+
511
+ const card = {
512
+ id: input.id ?? newCardId(),
513
+ kind: CARD_KINDS.includes(input.kind) ? input.kind : 'note',
514
+ title: String(input.title ?? '').trim().slice(0, 160) || '(untitled)',
515
+ project,
516
+ entities: [...new Set((input.entities ?? []).map((e) => slugify(e)))].filter(Boolean),
517
+ created: input.created ?? stamp,
518
+ validFrom: input.validFrom ?? today(),
519
+ validTo: input.validTo ?? null,
520
+ status: 'active',
521
+ confidence: CONFIDENCE.includes(input.confidence) ? input.confidence : 'inferred',
522
+ source: input.source ?? null,
523
+ supersedes: input.supersedes ?? [],
524
+ supersededBy: null,
525
+ tags: input.tags ?? [],
526
+ updated: null,
527
+ body: String(input.body ?? '').trim(),
528
+ }
529
+
530
+ await writeAtomic(cardPath(card), renderCard(card))
531
+ invalidate()
532
+ await touchProject(project)
533
+
534
+ for (const oldId of card.supersedes) {
535
+ const old = await getCard(oldId)
536
+ if (!old) continue
537
+ old.status = 'superseded'
538
+ old.validTo = card.validFrom
539
+ old.supersededBy = card.id
540
+ await writeAtomic(cardPath(old), renderCard(old))
541
+ await audit('card', `${old.id} superseded by ${card.id}`)
542
+ }
543
+
544
+ await audit('card', `wrote ${card.id} [${card.kind}] "${card.title}" (${card.project})`)
545
+ return { card, deduped: false }
546
+ })
547
+ }
548
+
549
+ async function retractCard(id, reason) {
550
+ return withLock('cards', async () => {
551
+ const card = await getCard(id)
552
+ if (!card) return null
553
+ card.status = 'retracted'
554
+ card.validTo = card.validTo ?? today()
555
+ card.body = `${card.body}\n\n<!-- retracted: ${String(reason ?? 'no reason given').slice(0, 200)} -->`
556
+ await writeAtomic(cardPath(card), renderCard(card))
557
+ invalidate()
558
+ await audit('card', `retracted ${id}${reason ? ` (${reason})` : ''}`)
559
+ return card
560
+ })
561
+ }
562
+
563
+ async function purgeCard(id) {
564
+ return withLock('cards', async () => {
565
+ const card = await getCard(id)
566
+ if (!card) return false
567
+ await fsp.rm(card.file, { force: true })
568
+ invalidate()
569
+ await audit('card', `purged ${id}`)
570
+ return true
571
+ })
572
+ }
573
+
574
+ /* ------------------------------------------------------------ entities */
575
+
576
+ function entityPath(slug) {
577
+ return path.join(P.entities, `${slug}.md`)
578
+ }
579
+
580
+ function renderEntity(entity) {
581
+ const data = {
582
+ id: entity.id,
583
+ name: entity.name,
584
+ type: entity.type,
585
+ aliases: entity.aliases ?? [],
586
+ first_seen: entity.firstSeen,
587
+ last_seen: entity.lastSeen,
588
+ mentions: `${entity.mentions.length}`,
589
+ }
590
+ const lines = (entity.mentions ?? [])
591
+ .slice(-200)
592
+ .map((m) => `- ${String(m.date).slice(0, 10)} · ${m.note} (${m.cardId})`)
593
+ return `${serializeFrontmatter(data, ENTITY_KEYS)}\n${entity.name}\n\n${lines.join('\n')}\n`
594
+ }
595
+
596
+ async function readEntity(slug) {
597
+ const file = entityPath(slug)
598
+ const text = await readText(file, '')
599
+ if (text === '') return null
600
+ const { data, body } = parseFrontmatter(text)
601
+ const mentions = []
602
+ for (const line of body.split(/\r?\n/)) {
603
+ const m = /^- (\d{4}-\d{2}-\d{2}) · (.*) \((c-[^)]+)\)$/.exec(line.trim())
604
+ if (m) mentions.push({ date: m[1], note: m[2], cardId: m[3] })
605
+ }
606
+ return {
607
+ id: data.id ?? `e-${slug}`,
608
+ slug,
609
+ name: data.name ?? slug,
610
+ type: data.type ?? 'concept',
611
+ aliases: Array.isArray(data.aliases) ? data.aliases : [],
612
+ firstSeen: data.first_seen ?? null,
613
+ lastSeen: data.last_seen ?? null,
614
+ mentions,
615
+ }
616
+ }
617
+
618
+ async function listEntities() {
619
+ await ensure()
620
+ let names = []
621
+ try {
622
+ names = await fsp.readdir(P.entities)
623
+ } catch {
624
+ return []
625
+ }
626
+ const out = []
627
+ for (const name of names.filter((n) => n.endsWith('.md'))) {
628
+ const entity = await readEntity(name.replace(/\.md$/, ''))
629
+ if (entity) out.push(entity)
630
+ }
631
+ return out.sort((a, b) => (a.name > b.name ? 1 : -1))
632
+ }
633
+
634
+ async function linkEntities(names, card, { type = 'concept' } = {}) {
635
+ await ensure()
636
+ const linked = []
637
+ for (const rawName of names ?? []) {
638
+ const name = String(rawName ?? '').trim()
639
+ if (name === '' || name.length > 80) continue
640
+ const slug = slugify(name)
641
+ const entity = (await readEntity(slug)) ?? {
642
+ id: `e-${slug}`,
643
+ slug,
644
+ name,
645
+ type,
646
+ aliases: [],
647
+ firstSeen: today(),
648
+ lastSeen: today(),
649
+ mentions: [],
650
+ }
651
+ if (entity.aliases.length === 0 && normalizeTitle(entity.name) !== normalizeTitle(name)) {
652
+ entity.aliases = [entity.name]
653
+ }
654
+ entity.lastSeen = today()
655
+ if (!entity.mentions.some((m) => m.cardId === card.id)) {
656
+ entity.mentions.push({
657
+ date: today(),
658
+ cardId: card.id,
659
+ note: String(card.title).slice(0, 120).replace(/\n/g, ' '),
660
+ })
661
+ }
662
+ await writeAtomic(entityPath(slug), renderEntity(entity))
663
+ linked.push(slug)
664
+ }
665
+ return linked
666
+ }
667
+
668
+ /** Write a card and wire its entity pages in one step. */
669
+ async function saveCard(input) {
670
+ const { card, deduped } = await writeCard(input)
671
+ const slugs = await linkEntities(card.entities, card)
672
+ return { card, deduped, entities: slugs }
673
+ }
674
+
675
+ /* ------------------------------------------------------------ readme */
676
+
677
+ async function regenerateReadme() {
678
+ await ensure()
679
+ const cards = await listCards()
680
+ const entities = await listEntities()
681
+ const projects = await listProjects()
682
+
683
+ const byProject = new Map()
684
+ for (const card of cards) {
685
+ if (!byProject.has(card.project)) byProject.set(card.project, [])
686
+ byProject.get(card.project).push(card)
687
+ }
688
+
689
+ const lines = []
690
+ lines.push('# Memory vault · 记忆库')
691
+ lines.push('')
692
+ lines.push('`dsh-memory-graph` 写入的纯文本长期记忆。读它、grep 它、改它、删它——它是你的。')
693
+ lines.push('Plain-text long-term memory written by the **dsh-memory-graph** plugin — read it, grep it, edit it, delete it.')
694
+ lines.push('')
695
+ lines.push(`- 卡片 cards: **${cards.length}** · 实体 entities: **${entities.length}** · 项目 projects: **${projects.length}**`)
696
+ lines.push(`- 最后更新 last updated: ${nowIso()}`)
697
+ lines.push('- 想看图就打开同目录的 `graph.html` · open `graph.html` next to this file to see the graph')
698
+ lines.push('')
699
+ lines.push('## 分层 · Layers')
700
+ lines.push('')
701
+ lines.push('| 层 layer | 路径 path | 是什么 · what it is |')
702
+ lines.push('| --- | --- | --- |')
703
+ lines.push('| raw | `episodes/` | 每轮一条,只追加不修改 · one line per turn, append-only |')
704
+ lines.push('| semantic | `cards/` | 从情节蒸馏出的带类型和日期的记忆 · typed, dated memories distilled from episodes |')
705
+ lines.push('| index | `entities/` | 每样东西一页,附提及时间线 · one page per thing, with a mention timeline |')
706
+ lines.push('| trace | `audit/` | 每次写入、撤回、蒸馏的记录 · every write, retraction and extraction run |')
707
+ lines.push('')
708
+
709
+ for (const [project, list] of [...byProject.entries()].sort()) {
710
+ lines.push(`## ${project}`)
711
+ lines.push('')
712
+ const recent = list.slice(-25).reverse()
713
+ for (const card of recent) {
714
+ const mark = card.status === 'superseded' ? '~~' : ''
715
+ const label = `${mark}${card.title}${mark}`
716
+ const flag = card.status === 'active' ? '' : ` \`${card.status}\``
717
+ lines.push(`- \`${String(card.created).slice(0, 10)}\` **${card.kind}** — ${label}${flag} · [${card.id}](cards/${card.created.slice(0, 7)}/${card.id}.md)`)
718
+ }
719
+ lines.push('')
720
+ }
721
+
722
+ if (entities.length > 0) {
723
+ lines.push('## 实体 · Entities')
724
+ lines.push('')
725
+ lines.push(
726
+ entities
727
+ .slice(0, 120)
728
+ .map((e) => `[${e.name}](entities/${e.slug}.md)`)
729
+ .join(' · '),
730
+ )
731
+ lines.push('')
732
+ }
733
+
734
+ await writeAtomic(P.readme, `${lines.join('\n')}\n`)
735
+ }
736
+
737
+ /**
738
+ * The single-file graph page, as a string.
739
+ *
740
+ * `regenerateGraph` writes it to disk for people who open the file directly;
741
+ * this returns the same bytes to the route that embeds it in the app panel. One
742
+ * renderer, so the panel and the file can never disagree.
743
+ */
744
+ async function buildGraph() {
745
+ await ensure()
746
+ const cards = await listCards({ includeRetracted: true })
747
+ const entities = await listEntities()
748
+ const projects = await listProjects()
749
+ const html = renderGraphHtml({ cards, entities, projects, generatedAt: nowIso() })
750
+ return { html, cards, entities, projects }
751
+ }
752
+
753
+ async function graphPage() {
754
+ return (await buildGraph()).html
755
+ }
756
+
757
+ /**
758
+ * Write the single-file graph view next to the README. Same inputs as the
759
+ * readme, plus the retracted cards — the graph shows them faded rather than
760
+ * dropping them, because "this was retracted" is itself a memory.
761
+ */
762
+ async function regenerateGraph() {
763
+ const { html, cards, entities } = await buildGraph()
764
+ await writeAtomic(P.graph, html)
765
+ return {
766
+ file: P.graph,
767
+ bytes: Buffer.byteLength(html),
768
+ cards: cards.length,
769
+ entities: entities.length,
770
+ }
771
+ }
772
+
773
+ /* ------------------------------------------------------------ stats */
774
+
775
+ async function stats() {
776
+ await ensure()
777
+ const cards = await listCards({ includeRetracted: true })
778
+ const entities = await listEntities()
779
+ const projects = await listProjects()
780
+ const files = await episodeFiles()
781
+ let episodes = 0
782
+ for (const name of files) {
783
+ const text = await readText(path.join(P.episodes, name), '')
784
+ episodes += text.split('\n').filter((l) => l.trim() !== '').length
785
+ }
786
+ const state = await readJson(P.distillState, {})
787
+ return {
788
+ vaultDir: cfg.vaultDir,
789
+ cards: {
790
+ total: cards.length,
791
+ active: cards.filter((c) => c.status === 'active').length,
792
+ superseded: cards.filter((c) => c.status === 'superseded').length,
793
+ retracted: cards.filter((c) => c.status === 'retracted').length,
794
+ byKind: cards.reduce((acc, c) => ({ ...acc, [c.kind]: (acc[c.kind] ?? 0) + 1 }), {}),
795
+ },
796
+ entities: entities.length,
797
+ projects: projects.map((p) => p.slug),
798
+ episodes: { files: files.length, records: episodes },
799
+ distill: { lastRun: state.lastRun ?? null, cursor: state.files ?? {} },
800
+ privacy: { rules: redactor.ruleCount },
801
+ }
802
+ }
803
+
804
+ /**
805
+ * Every episode, newest last, optionally narrowed to one Session.
806
+ *
807
+ * `readUndistilled` walks from a cursor and stops at a budget, which is the
808
+ * wrong shape for a viewer that wants "what was said in THIS Session": the
809
+ * cursor may sit past those lines already, and a Session's episodes are
810
+ * usually the most recent records, so the whole set is read and filtered.
811
+ * `limit` keeps the newest N after filtering.
812
+ * @returns {Promise<Array<{file: string, line: number, record: object}>>}
813
+ */
814
+ async function readEpisodes({ session = null, limit = 0 } = {}) {
815
+ await ensure()
816
+ const out = []
817
+ for (const name of await episodeFiles()) {
818
+ const text = await readText(path.join(P.episodes, name), '')
819
+ if (text === '') continue
820
+ const lines = text.split('\n')
821
+ for (let i = 0; i < lines.length; i++) {
822
+ if (lines[i].trim() === '') continue
823
+ let record
824
+ try {
825
+ record = JSON.parse(lines[i])
826
+ } catch {
827
+ continue // a torn last line, not a reason to lose the file
828
+ }
829
+ if (session !== null && record?.session !== session) continue
830
+ out.push({ file: name, line: i, record })
831
+ }
832
+ }
833
+ if (limit > 0 && out.length > limit) return out.slice(out.length - limit)
834
+ return out
835
+ }
836
+
837
+ /**
838
+ * The automatic distiller's own bookkeeping.
839
+ *
840
+ * Kept out of `.distill-state.json` on purpose: that file is the *cursor*
841
+ * (which lines have been read) and is written by `markDistilled`. This one
842
+ * records attempts — including the ones that produced nothing — because the
843
+ * gap between two automatic runs must hold even when a run wrote no cards,
844
+ * or a quiet vault would be re-asked every minute.
845
+ */
846
+ async function readAutoState() {
847
+ return readJson(P.autoState, {})
848
+ }
849
+
850
+ async function writeAutoState(patch) {
851
+ return withLock(`auto:${P.autoState}`, async () => {
852
+ const state = await readJson(P.autoState, {})
853
+ Object.assign(state, patch)
854
+ await writeAtomic(P.autoState, `${JSON.stringify(state, null, 2)}\n`)
855
+ return state
856
+ })
857
+ }
858
+
859
+ return {
860
+ dir: cfg.vaultDir,
861
+ paths: P,
862
+ ensure,
863
+ audit,
864
+ appendEpisode,
865
+ episodeFiles,
866
+ readUndistilled,
867
+ readEpisodes,
868
+ readAutoState,
869
+ writeAutoState,
870
+ markDistilled,
871
+ listCards,
872
+ getCard,
873
+ writeCard,
874
+ saveCard,
875
+ retractCard,
876
+ purgeCard,
877
+ findDuplicate,
878
+ readEntity,
879
+ listEntities,
880
+ linkEntities,
881
+ listProjects,
882
+ touchProject,
883
+ regenerateReadme,
884
+ regenerateGraph,
885
+ graphPage,
886
+ stats,
887
+ }
888
+ }