@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
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
|
+
}
|