memory-eternal 0.9.14 → 0.9.15

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/lib/vault.js CHANGED
@@ -1,941 +1,941 @@
1
- // 记忆核心 · SQLite Vault 存储层
2
- //
3
- // 纯工具函数保持不变;IO 函数全部改用 SQL。
4
- // 底层依赖 lib/db.js 的 getDb(root) → DatabaseSync 实例。
5
-
6
- import path from 'node:path'
7
- import fs from 'node:fs'
8
- import { getDb, enforceAudit } from './db.js'
9
- import { minhashSignature, lshCandidates } from './minhash.js'
10
-
11
- /** 主题目录根。 */
12
- export const KIND_ROOTS = {
13
- project: '02-Projects',
14
- knowledge: '03-Knowledge',
15
- content: '04-Content',
16
- prompt: '05-Prompts',
17
- business: '06-Business',
18
- tool: '07-Tools',
19
- mistake: '08-Mistakes',
20
- }
21
-
22
- export const CAPTURE_KINDS = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake']
23
-
24
- /** 从 Markdown 文本提取 frontmatter 与正文。 */
25
- export function parseCard(text) {
26
- const meta = { title: '', kind: 'knowledge', tags: [], created: '', updated: '', source: '', formatVersion: 0, status: 'pending', submittedBy: '', severity: 'info', reason: '', deletedAt: '' }
27
- let body = text
28
- const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text)
29
- if (m) {
30
- body = text.slice(m[0].length)
31
- for (const line of m[1].split(/\r?\n/)) {
32
- const eq = line.indexOf(':')
33
- if (eq <= 0) continue
34
- const key = line.slice(0, eq).trim()
35
- let value = line.slice(eq + 1).trim()
36
- if (key === 'tags') {
37
- value = value.replace(/^\[/, '').replace(/\]$/, '')
38
- meta.tags = value.split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
39
- continue
40
- }
41
- if (key === 'formatVersion') {
42
- meta.formatVersion = Number(value) || 0
43
- } else if (key === 'kind' || key === 'title' || key === 'created' || key === 'updated' || key === 'source' || key === 'status' || key === 'submittedBy' || key === 'severity' || key === 'reason' || key === 'deletedAt') {
44
- meta[key] = value.replace(/^['"]|['"]$/g, '')
45
- }
46
- }
47
- }
48
- if (!meta.status) meta.status = 'pending'
49
- if (!meta.title) {
50
- const h = /^#\s+(.+)$/m.exec(body)
51
- meta.title = h ? h[1].trim() : body.trim().split(/\r?\n/)[0].slice(0, 60)
52
- }
53
- const summary = body.trim().slice(0, 200)
54
- return { meta, body: body.trim(), summary }
55
- }
56
-
57
- /** 生成安全 slug(中文保留、非法字符替换为 -)。 */
58
- export function safeSlug(name) {
59
- const base = String(name || 'card')
60
- .trim()
61
- .toLowerCase()
62
- .replace(/[\s_]+/g, '-')
63
- .replace(/[^\w\u4e00-\u9fff-]+/g, '')
64
- .replace(/-+/g, '-')
65
- .replace(/^-|-$/g, '')
66
- return base || 'card'
67
- }
68
-
69
- /** 字符 bigram 集合(textSimilarity 的预处理部分,单独暴露供 O(n²) 场景复用)。 */
70
- export function bigramSet(text) {
71
- const cleaned = String(text)
72
- .replace(/[\s#*_`|[\]()()\-—・]+/g, '')
73
- .toLowerCase()
74
- const out = new Set()
75
- for (let i = 0; i < cleaned.length - 1; i++) out.add(cleaned.slice(i, i + 2))
76
- if (cleaned.length === 1) out.add(cleaned)
77
- return out
78
- }
79
-
80
- /**
81
- * 与 bigramSet 同规则(同一套清洗),但把每个 bigram 压成 32 位整数再入集合。
82
- * 为什么单独一份:Set<number> 的构建与求交都比 Set<string> 快数倍,
83
- * 而 **去重判定** 仍必须用字符串版(语义不能变)—— 所以图谱专用、互不干扰。
84
- */
85
- export function bigramHashSet(text) {
86
- const cleaned = String(text)
87
- .replace(/[\s#*_`|[\]()()\-—・]+/g, '')
88
- .toLowerCase()
89
- const out = new Set()
90
- for (let i = 0; i < cleaned.length - 1; i++) out.add(hash32(cleaned.slice(i, i + 2)))
91
- if (cleaned.length === 1) out.add(hash32(cleaned))
92
- return out
93
- }
94
-
95
- /** 32 位 FNV-1a:把 bigram 压成整数键。 */
96
- function hash32(s) {
97
- let h = 0x811c9dc5
98
- for (let i = 0; i < s.length; i++) {
99
- h ^= s.charCodeAt(i)
100
- h = Math.imul(h, 0x01000193) >>> 0
101
- }
102
- return h >>> 0
103
- }
104
-
105
- /**
106
- * 用预算好的 bigram 集合算 Jaccard(结果与 textSimilarity 完全一致)。
107
- *
108
- * `minRatio` 是数学上界剪枝:J ≤ min(|A|,|B|)/max(|A|,|B|),所以长度比低于阈值时
109
- * 必然 `J < 阈值`,可直接返回 0 **不迭代**(对「J ≥ 阈值」的判定无损,不是近似)。
110
- * 图谱的 O(n²) 配对就靠这一步从「每次重新分词两段正文」变成「集合求交」。
111
- */
112
- export function jaccardSets(ba, bb, minRatio = 0) {
113
- if (!ba || !bb || ba.size === 0 || bb.size === 0) return 0
114
- const [small, large] = ba.size <= bb.size ? [ba, bb] : [bb, ba]
115
- if (minRatio > 0 && small.size / large.size < minRatio) return 0
116
- let inter = 0
117
- for (const g of small) if (large.has(g)) inter++
118
- return inter / (ba.size + bb.size - inter)
119
- }
120
-
121
- /** Jaccard-like 相似度:字符 bigram 集合。 */
122
- export function textSimilarity(a, b) {
123
- return jaccardSets(bigramSet(a), bigramSet(b))
124
- }
125
-
126
- /** CJK 感知查询词:整词 + 中文字符 bigram。 */
127
- export function queryTerms(query) {
128
- const tokens = new Set(String(query).split(/[^\w\u4e00-\u9fff]+/).filter(Boolean))
129
- for (let i = 0; i < query.length - 1; i++) {
130
- const c1 = query[i]
131
- const c2 = query[i + 1]
132
- if (/[\u4e00-\u9fff]/.test(c1) && /[\u4e00-\u9fff]/.test(c2)) tokens.add(c1 + c2)
133
- }
134
- return tokens
135
- }
136
-
137
- /** 在目标目录中找与候选文本最相似的现有卡(去重守卫)。比较正文。 */
138
- export async function dedupCheck(root, text, threshold = 0.62) {
139
- const db = getDb(root)
140
- const rows = db.prepare('SELECT path, body FROM cards WHERE deleted_at IS NULL').all()
141
- let best = null
142
- for (const row of rows) {
143
- const score = textSimilarity(text, row.body || '')
144
- if (score >= threshold && (!best || score > best.score)) best = { path: row.path, score }
145
- }
146
- return best
147
- }
148
-
149
- // 目录名 → kind 推断
150
- function kindFromDir(topDir) {
151
- for (const [k, v] of Object.entries(KIND_ROOTS)) { if (v.toLowerCase() === String(topDir || '').toLowerCase()) return k }
152
- return 'knowledge'
153
- }
154
-
155
- /** 确保 vault 目录结构存在(即确保 DB 存在)。 */
156
- export async function ensureVault(root) {
157
- // getDb 会自动建表
158
- getDb(root)
159
- }
160
-
161
- /** 将 SQL 行还原为调用方期望的卡片摘要对象。 */
162
- function rowToCard(row) {
163
- return {
164
- path: row.path,
165
- kind: row.kind,
166
- title: row.title,
167
- tags: JSON.parse(row.tags || '[]'),
168
- summary: row.summary || '',
169
- created: row.created_at,
170
- updated: row.updated_at,
171
- mtime: new Date(row.updated_at).getTime(),
172
- status: row.status,
173
- submittedBy: row.submitted_by || '',
174
- severity: row.severity || 'info',
175
- reason: row.reason || '',
176
- deletedAt: row.deleted_at || '',
177
- }
178
- }
179
-
180
- /** 将 SQL 行还原为完整 Markdown 文本(frontmatter + body)。 */
181
- function rowToMarkdown(row) {
182
- const tags = JSON.parse(row.tags || '[]')
183
- const lines = [
184
- '---',
185
- `formatVersion: 1`,
186
- `kind: ${row.kind}`,
187
- `title: ${yamlString(row.title)}`,
188
- `tags: [${tags.map((t) => yamlString(t)).join(', ')}]`,
189
- `created: ${row.created_at}`,
190
- `updated: ${row.updated_at}`,
191
- `status: ${row.status}`,
192
- ...(row.deleted_at ? [`deletedAt: ${row.deleted_at}`] : []),
193
- ...(row.submitted_by ? [`submittedBy: ${yamlString(row.submitted_by)}`] : []),
194
- ...(row.severity && row.severity !== 'info' ? [`severity: ${row.severity}`] : []),
195
- ...(row.reason ? [`reason: ${yamlString(row.reason)}`] : []),
196
- ...(row.source ? [`source: ${row.source}`] : []),
197
- '---',
198
- '',
199
- `# ${row.title}`,
200
- '',
201
- row.body.trim(),
202
- '',
203
- ]
204
- // 附加 card_updates
205
- return { text: lines.join('\n'), tags }
206
- }
207
-
208
- /**
209
- * 智能体署名归一(与客户端 normAgent 同一套规则,供 SQL 过滤用):
210
- * 空 / agent / unknown / session:* / DeepSeek Harness → deepseek-harness;claude-code* → claude;其余精确匹配。
211
- */
212
- function agentWhere(agent, args) {
213
- const a = String(agent || '').toLowerCase()
214
- if (a === 'deepseek-harness') {
215
- args.push('', 'agent', 'unknown', 'deepseek-harness', 'deepseek harness')
216
- return "(LOWER(COALESCE(submitted_by,'')) IN (?,?,?,?,?) OR submitted_by LIKE 'session:%')"
217
- }
218
- if (a === 'claude') { args.push('claude', 'claude-code%'); return "(LOWER(COALESCE(submitted_by,'')) = ? OR submitted_by LIKE ?)" }
219
- args.push(a)
220
- return "LOWER(COALESCE(submitted_by,'')) = ?"
221
- }
222
-
223
- /** WHERE 组装:状态白名单 + 可选 kind / agent。 */
224
- function cardsWhere({ want, kind, agent }) {
225
- const args = [...want]
226
- let where = `status IN (${want.map(() => '?').join(',')})`
227
- if (kind) { where += ' AND kind = ?'; args.push(kind) }
228
- if (agent) where += ' AND ' + agentWhere(agent, args)
229
- return { where, args }
230
- }
231
-
232
- /** 标题排序用的中文排序器(拼音 + 数字感知);固定 zh-Hans-CN,避免随进程 locale 漂移。 */
233
- export const TITLE_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
234
-
235
- /**
236
- * 列出卡片(从 SQL)。
237
- * @param opts.status 状态白名单(默认 approved)
238
- * @param opts.kind 类型过滤(SQL)
239
- * @param opts.agent 署名过滤(服务端归一,如 deepseek-harness / claude)
240
- * @param opts.limit 返回条数上限(缺省=全部,供内部调用保持原语义)
241
- * @param opts.offset 偏移(配合 limit 做真分页)
242
- * @param opts.sort 'recent'(updated_at DESC,默认)| 'title'(拼音升序,跨页全局有序)
243
- */
244
- export async function listCards(root, { status, kind, agent, limit, offset = 0, sort = 'recent' } = {}) {
245
- const want = status ?? ['approved']
246
- const db = getDb(root)
247
- const { where, args } = cardsWhere({ want, kind, agent })
248
- if (sort === 'title') {
249
- // SQLite 的 ORDER BY 是 UTF-8 字节序,中文标题看着就是乱的 → 只取 path/title 用拼音排好,
250
- // 再回表取这一页的正文(避免为了排序把所有正文读进内存)。
251
- const light = db.prepare(`SELECT path, title FROM cards WHERE ${where}`).all(...args)
252
- light.sort((a, b) => TITLE_COLLATOR.compare(a.title || '', b.title || ''))
253
- const start = Math.max(0, offset | 0)
254
- const page = Number.isInteger(limit) && limit > 0 ? light.slice(start, start + limit) : light.slice(start)
255
- if (page.length === 0) return []
256
- const ph = page.map(() => '?').join(',')
257
- const rows = db.prepare(`SELECT * FROM cards WHERE path IN (${ph})`).all(...page.map((r) => r.path))
258
- const byPath = new Map(rows.map((r) => [r.path, rowToCard(r)]))
259
- return page.map((r) => byPath.get(r.path)).filter(Boolean)
260
- }
261
- if (Number.isInteger(limit) && limit > 0) {
262
- const rows = db.prepare(`SELECT * FROM cards WHERE ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`).all(...args, limit, Math.max(0, offset | 0))
263
- return rows.map(rowToCard)
264
- }
265
- const rows = db.prepare(`SELECT * FROM cards WHERE ${where} ORDER BY updated_at DESC`).all(...args)
266
- return rows.map(rowToCard)
267
- }
268
-
269
- /** 计数(分页接口要用 total 判断还有没有下一页)。 */
270
- export async function countCards(root, { status, kind, agent } = {}) {
271
- const db = getDb(root)
272
- const { where, args } = cardsWhere({ want: status ?? ['approved'], kind, agent })
273
- return db.prepare(`SELECT COUNT(*) n FROM cards WHERE ${where}`).get(...args).n
274
- }
275
-
276
- /** 审核队列:status=pending 或 rejected。 */
277
- export async function auditQueue(root) {
278
- const cards = await listCards(root, { status: ['pending', 'rejected'] })
279
- return { pending: cards.filter((c) => c.status === 'pending'), rejected: cards.filter((c) => c.status === 'rejected') }
280
- }
281
-
282
- /** 回收站:status=deleted,按 deleted_at 倒序。 */
283
- export async function recycleList(root) {
284
- const db = getDb(root)
285
- const rows = db.prepare('SELECT * FROM cards WHERE status = ? ORDER BY deleted_at DESC').all('deleted')
286
- return rows.map(rowToCard)
287
- }
288
-
289
- /** 清理超过 retentionDays 天的回收卡(永久删除)。 */
290
- export async function purgeExpired(root, retentionDays = 30) {
291
- const db = getDb(root)
292
- const cut = new Date(Date.now() - retentionDays * 86400000).toISOString()
293
- const rows = db.prepare('SELECT path FROM cards WHERE status = ? AND deleted_at IS NOT NULL AND deleted_at < ?').all('deleted', cut)
294
- let purged = 0
295
- for (const row of rows) {
296
- try { await deleteCard(root, row.path, { permanent: true }); purged++ } catch {}
297
- }
298
- return { ok: true, purged }
299
- }
300
-
301
- /** 读取一张卡(返回 Markdown 文本,兼容旧调用方)。 */
302
- export async function readCard(root, rel) {
303
- const db = getDb(root)
304
- const row = db.prepare('SELECT * FROM cards WHERE path = ?').get(rel)
305
- if (!row) throw new Error('卡片不存在: ' + rel)
306
- const { text } = rowToMarkdown(row)
307
- // 附加更新记录
308
- const updates = db.prepare('SELECT content, created_at FROM card_updates WHERE card_id = ? ORDER BY created_at ASC').all(row.id)
309
- if (updates.length > 0) {
310
- const updateLines = updates.map((u) => `- ${u.created_at.slice(0, 10)}:${u.content}`)
311
- return text + '\n## 更新记录\n\n' + updateLines.join('\n') + '\n'
312
- }
313
- return text
314
- }
315
-
316
- /** 软删卡片:status=deleted + deleted_at。 */
317
- export async function deleteCard(root, rel, { permanent = false } = {}) {
318
- const db = getDb(root)
319
- if (permanent) {
320
- db.prepare('DELETE FROM cards WHERE path = ?').run(rel)
321
- return { ok: true, permanent: true }
322
- }
323
- const now = new Date().toISOString()
324
- const info = db.prepare('UPDATE cards SET status = ?, deleted_at = ?, updated_at = ? WHERE path = ?').run('deleted', now, now, rel)
325
- if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
326
- return { ok: true, soft: true }
327
- }
328
-
329
- /** 恢复软删卡:status=approved,清除 deleted_at。写审计日志。 */
330
- export async function restoreCard(root, rel) {
331
- const db = getDb(root)
332
- const now = new Date().toISOString()
333
- const existing = db.prepare('SELECT status FROM cards WHERE path = ?').get(rel)
334
- if (!existing) throw new Error('卡片不存在: ' + rel)
335
- const info = db.prepare('UPDATE cards SET status = ?, deleted_at = NULL, updated_at = ? WHERE path = ?').run('approved', now, rel)
336
- if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
337
- db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, 'approved', 'restore', '回收站恢复')
338
- return { ok: true, restored: true }
339
- }
340
-
341
- /** 更新卡状态(审核操作)。写审计日志,确保可追溯。 */
342
- export async function setCardStatus(root, rel, status, { changedBy = 'system', reason = '' } = {}) {
343
- const db = getDb(root)
344
- const now = new Date().toISOString()
345
- const existing = db.prepare('SELECT status FROM cards WHERE path = ?').get(rel)
346
- if (!existing) throw new Error('卡片不存在: ' + rel)
347
- const info = db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE path = ?').run(status, now, rel)
348
- if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
349
- // 审计日志:记录每次 status 变更
350
- db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, status, changedBy, reason)
351
- return { ok: true, status }
352
- }
353
-
354
- /** 原子写卡;先去重,命中返回 {duplicate}。审核守卫在数据库层强制。 */
355
- export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true } = {}) {
356
- const db = getDb(root)
357
- const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
358
-
359
- // 审核守卫:数据库层强制,调用方传的 status 仅作 fallback
360
- const enforcedStatus = enforceAudit(root, kind, submittedBy, status)
361
-
362
- if (dedup) {
363
- const hit = await dedupCheck(root, body, threshold)
364
- if (hit) return { ok: false, duplicate: { ...hit, path: hit.path } }
365
- }
366
-
367
- const slug = safeSlug(title)
368
- let rel = `${kindRoot}/${slug}.md`
369
- let index = 2
370
- while (db.prepare('SELECT 1 FROM cards WHERE path = ?').get(rel)) {
371
- rel = `${kindRoot}/${slug}-${index}.md`
372
- index++
373
- }
374
-
375
- const now = new Date().toISOString()
376
- const summary = body.trim().slice(0, 200)
377
- db.prepare(`
378
- INSERT INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
379
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
380
- `).run(
381
- rel,
382
- kind,
383
- title,
384
- JSON.stringify(tags),
385
- body.trim(),
386
- summary,
387
- enforcedStatus,
388
- source,
389
- submittedBy,
390
- severity,
391
- reason,
392
- now,
393
- now,
394
- deletedAt || null,
395
- )
396
- return { ok: true, path: rel, kind }
397
- }
398
-
399
- /** 向已存在的卡追加更新记录。 */
400
- export async function appendUpdate(root, rel, updateText) {
401
- const db = getDb(root)
402
- const row = db.prepare('SELECT id FROM cards WHERE path = ?').get(rel)
403
- if (!row) throw new Error('卡片不存在: ' + rel)
404
- const now = new Date().toISOString()
405
- db.prepare('INSERT INTO card_updates (card_id, content, created_at) VALUES (?, ?, ?)').run(row.id, updateText.trim(), now)
406
- db.prepare('UPDATE cards SET updated_at = ? WHERE id = ?').run(now, row.id)
407
- return { ok: true, path: rel, updated: now }
408
- }
409
-
410
- /** 合并多张卡为一张。 */
411
- export async function mergeCards(root, paths) {
412
- const list = (Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean)
413
- if (list.length < 2) return { ok: false, error: '至少选 2 张卡' }
414
- const db = getDb(root)
415
-
416
- const rows = []
417
- for (const p of list) {
418
- const row = db.prepare('SELECT * FROM cards WHERE path = ?').get(p)
419
- if (row) rows.push(row)
420
- }
421
- if (rows.length < 2) return { ok: false, error: '读取失败' }
422
-
423
- const first = rows[0]
424
- const tags = new Set(JSON.parse(first.tags || '[]'))
425
- let combined = ''
426
- rows.forEach((row, i) => {
427
- const rowTags = JSON.parse(row.tags || '[]')
428
- rowTags.forEach((t) => tags.add(t))
429
- combined += (i ? '\n\n---\n\n' : '') + row.body.trim()
430
- })
431
-
432
- const title = (first.title || '合并记忆') + '(合并)'
433
- const r = await writeCard(root, { kind: first.kind || 'knowledge', title, tags: [...tags], body: combined, source: 'merge', status: 'pending', submittedBy: 'merge', reason: '多卡合并' }, { dedup: false })
434
- if (!r.ok) return { ok: false, error: '写入失败' }
435
- for (const row of rows) { try { await deleteCard(root, row.path, { permanent: true }) } catch {} }
436
- return { ok: true, path: r.path, merged: rows.length }
437
- }
438
-
439
- /** 检索:整词/中文 bigram 命中。 */
440
- export async function search(root, query, { limit = 30, minScore = 0, semantic = false } = {}) {
441
- const q = String(query || '').trim()
442
- if (!q) return []
443
- const db = getDb(root)
444
- const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
445
- const wanted = queryTerms(q)
446
- const out = []
447
- for (const row of rows) {
448
- const card = rowToCard(row)
449
- const haystack = `${card.path}\n${card.title}\n${row.body}`.toLowerCase()
450
- let score = 0
451
- if (haystack.includes(q.toLowerCase())) score = 3
452
- for (const term of wanted) {
453
- if (haystack.includes(term.toLowerCase())) score += 1
454
- }
455
- if (semantic) score += Math.round(textSimilarity(q, `${card.title} ${card.summary}`) * 6)
456
- if (score > 0 && score >= minScore) {
457
- out.push({ ...card, score, excerpt: row.body.trim().slice(0, 300) })
458
- }
459
- }
460
-
461
- // 反馈反哺排序
462
- const fb = await readFeedback(root)
463
- const ql = q.toLowerCase()
464
- const fbByPath = {}
465
- for (const f of fb) {
466
- if (!f.path) continue
467
- const fq = String(f.query || '').toLowerCase()
468
- const overlap = fq && (fq === ql || ql.includes(fq) || fq.includes(ql) || queryTerms(f.query).some((term) => wanted.includes(term)))
469
- if (!overlap) continue
470
- const rec = fbByPath[f.path] = fbByPath[f.path] || { useful: 0, irr: 0 }
471
- if (f.useful) rec.useful += 1; else rec.irr += 1
472
- }
473
- for (const hit of out) {
474
- const rec = fbByPath[hit.path]
475
- if (rec) hit.score += rec.useful * 2 - rec.irr * 2
476
- }
477
- out.sort((a, b) => b.score - a.score || b.mtime - a.mtime)
478
- return out.slice(0, limit)
479
- }
480
-
481
- /** 用量趋势:最近 N 天每天新增卡片数。 */
482
- export async function dailyCounts(root, days = 30) {
483
- const db = getDb(root)
484
- const cut = new Date(Date.now() - days * 86400000).toISOString()
485
- const rows = db.prepare("SELECT DATE(created_at) as d, COUNT(*) as cnt FROM cards WHERE created_at >= ? GROUP BY DATE(created_at)").all(cut)
486
- const map = new Map(rows.map((r) => [r.d, r.cnt]))
487
- const out = []
488
- for (let i = days - 1; i >= 0; i--) {
489
- const dt = new Date(Date.now() - i * 86400000)
490
- const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
491
- out.push({ date: key, count: map.get(key) || 0 })
492
- }
493
- return out
494
- }
495
-
496
- /** 导出全部卡片原始 Markdown。 */
497
- export async function exportCards(root) {
498
- const db = getDb(root)
499
- const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
500
- return rows.map((row) => {
501
- const { text } = rowToMarkdown(row)
502
- return { path: row.path, title: row.title, kind: row.kind, text }
503
- })
504
- }
505
-
506
- // 图谱缓存:一次生成约 300ms(301 卡),重复请求(多客户端/切页重开)直接命中。
507
- // 失效用「指纹」而不是 TTL 兜底:卡数 + 更新时间 + 状态直方图 + 审计日志/更新记录水位,
508
- // 任何写卡、审核、删卡、追加更新都会改变指纹 → 不存在脏读。
509
- const GRAPH_CACHE_MAX = 4
510
- const GRAPH_CACHE_TTL_MS = 5 * 60 * 1000
511
- const graphCache = new Map() // root -> { fp, value, at }
512
-
513
- // ---- P0-1:建图结果落盘 ------------------------------------------------------
514
- // 指纹一变就失效,命中直接读文件 —— 免掉整条建图管线(596 卡实测 ~600ms → ~25ms)。
515
- // 文件放 vault 根目录、原子写(tmp + rename),写失败/超限都只是「下次重建」,不影响功能。
516
- const GRAPH_CACHE_FILE = 'memory-eternal-graph-cache.json'
517
- const GRAPH_CACHE_MAX_BYTES = 8 * 1024 * 1024
518
- const graphCachePath = (root) => path.join(root, GRAPH_CACHE_FILE)
519
-
520
- function readGraphCache(root, fp) {
521
- try {
522
- const data = JSON.parse(fs.readFileSync(graphCachePath(root), 'utf8'))
523
- if (!data || data.fp !== fp) return null
524
- const v = data.value
525
- if (!v || !Array.isArray(v.nodes) || !Array.isArray(v.edges)) return null
526
- return v
527
- } catch { return null }
528
- }
529
-
530
- function writeGraphCache(root, fp, value) {
531
- try {
532
- const payload = JSON.stringify({ fp, at: Date.now(), value })
533
- if (payload.length > GRAPH_CACHE_MAX_BYTES) return
534
- const file = graphCachePath(root)
535
- const tmp = file + '.' + process.pid + '.tmp'
536
- fs.writeFileSync(tmp, payload)
537
- fs.renameSync(tmp, file)
538
- } catch { /* 缓存写失败不影响功能 */ }
539
- }
540
-
541
- function graphFingerprint(db) {
542
- const c = db.prepare('SELECT COUNT(*) n, MAX(updated_at) m, SUM(id) s FROM cards').get()
543
- const st = db.prepare('SELECT status, COUNT(*) n FROM cards GROUP BY status').all()
544
- const log = db.prepare('SELECT MAX(id) id FROM audit_log').get()
545
- let upd = 0
546
- try { upd = db.prepare('SELECT MAX(id) id FROM card_updates').get().id || 0 } catch { /* 老库无此表 */ }
547
- return [c.n, c.m || '', c.s || 0, st.map((r) => `${r.status}:${r.n}`).join(','), log.id || 0, upd].join('|')
548
- }
549
-
550
- /** 图谱:节点 = 卡片;边 = [[wikilink]] 或共享标签。 */
551
- export async function graph(root) {
552
- const db = getDb(root)
553
- const fp = graphFingerprint(db)
554
- const hit = graphCache.get(root)
555
- if (hit && hit.fp === fp && Date.now() - hit.at < GRAPH_CACHE_TTL_MS) return hit.value
556
- // P0-1:内存未命中就查磁盘缓存(指纹一致即视为有效)
557
- const onDisk = readGraphCache(root, fp)
558
- if (onDisk) {
559
- graphCache.set(root, { fp, value: onDisk, at: Date.now() })
560
- if (process.env.MEMORY_GRAPH_PROFILE === '1') console.error('[memory-eternal] graph: 命中磁盘缓存')
561
- return onDisk
562
- }
563
- // 只选图谱需要的列,不读 body(大幅减少内存和传输量)
564
- const rows = db.prepare("SELECT id, path, kind, title, tags, summary, status, updated_at FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
565
- // body 单独按需读取(只在需要 wikilink 匹配时)
566
- const bodyRows = db.prepare("SELECT path, body FROM cards WHERE status = 'approved'").all()
567
- const bodyMap = new Map(bodyRows.map((r) => [r.path, r.body]))
568
- const cards = rows.map((r) => ({
569
- path: r.path, kind: r.kind, title: r.title, tags: JSON.parse(r.tags || '[]'),
570
- summary: r.summary || '', updated: r.updated_at, mtime: new Date(r.updated_at).getTime(), status: r.status,
571
- }))
572
-
573
- const byPath = new Map(cards.map((c) => [c.path, c]))
574
-
575
- const nodes = cards.map((c) => ({
576
- id: c.path,
577
- title: c.title,
578
- kind: c.kind,
579
- tags: c.tags,
580
- summary: c.summary.slice(0, 80),
581
- updated: c.updated,
582
- mtime: c.mtime,
583
- linkCount: 0,
584
- }))
585
- const nodeById = new Map(nodes.map((n) => [n.id, n]))
586
- const addDegree = (p) => { const n = nodeById.get(p); if (n) n.linkCount++ }
587
- const edges = []
588
- const seen = new Set()
589
- const addEdge = (a, b, type) => {
590
- const key = [a, b].sort().join('|') + '|' + type
591
- if (seen.has(key)) return
592
- seen.add(key)
593
- edges.push({ source: a, target: b, type })
594
- }
595
-
596
- for (const card of cards) {
597
- const text = bodyMap.get(card.path) || ''
598
- for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)) {
599
- const raw = m[1].trim().replace(/\.md$/, '')
600
- const candidates = [
601
- `${card.kind === 'knowledge' ? KIND_ROOTS.knowledge : KIND_ROOTS[card.kind]}/${raw}.md`,
602
- `${KIND_ROOTS.knowledge}/${raw}.md`,
603
- `${raw}.md`,
604
- ]
605
- for (const cand of candidates) {
606
- if (cand !== card.path && byPath.has(cand)) {
607
- addEdge(card.path, cand, 'link'); addDegree(card.path); addDegree(cand)
608
- break
609
- }
610
- }
611
- for (const other of cards) {
612
- if (other.path !== card.path && (other.title === raw || other.path.endsWith(`/${raw}.md`))) {
613
- addEdge(card.path, other.path, 'link'); addDegree(card.path); addDegree(other.path)
614
- break
615
- }
616
- }
617
- }
618
- }
619
- // 标签倒排(tag → paths,按 cards 顺序):原先每个标签都要全表扫一遍,是 O(卡数²)
620
- const tagIndex = new Map()
621
- for (const c of cards) for (const t of c.tags) {
622
- const arr = tagIndex.get(t)
623
- if (arr) arr.push(c.path)
624
- else tagIndex.set(t, [c.path])
625
- }
626
- // 标签边:同标签「全连接」会爆炸 —— 593 卡时 2.4 万条边,占总 payload 的 99%,前端每帧都要遍历。
627
- // 改为「按共享标签数取 Top-K」:语义仍是「靠共享标签相邻」,但边数降一个数量级。
628
- // 对每张卡用标签倒排收集候选与其共享标签数,只保留权重最高的 K 个(双向去重后约 n*K/2 条)。
629
- const TAG_TOPK = 6
630
- const pairWeight = new Map() // `a\u0000b`(排序后)-> 已加边的共享标签数
631
- for (const card of cards) {
632
- const weights = new Map()
633
- for (const tag of card.tags) {
634
- for (const other of tagIndex.get(tag) || []) {
635
- if (other === card.path) continue
636
- weights.set(other, (weights.get(other) || 0) + 1)
637
- }
638
- }
639
- // 候选不超过 K 时全连(小库/小标签簇),超过才截断 Top-K
640
- const ranked = [...weights.entries()].sort((x, y) => y[1] - x[1])
641
- const top = weights.size <= TAG_TOPK ? ranked : ranked.slice(0, TAG_TOPK)
642
- for (const [other, w] of top) {
643
- const [x, y] = [card.path, other].sort()
644
- const key = `${x}\u0000${y}`
645
- if ((pairWeight.get(key) || 0) >= w) continue // 已按更高权重加过这条边
646
- pairWeight.set(key, w)
647
- const xc = byPath.get(x)
648
- const yc = byPath.get(y)
649
- const shared = xc && yc ? xc.tags.find((t) => yc.tags.includes(t)) : ''
650
- addEdge(x, y, shared ? `tag:${shared}` : 'tag')
651
- }
652
- }
653
-
654
- // 相似度关联(P0-3 + P1-1):
655
- // · bigram 一律用 32 位哈希入集合(构建与求交都比字符串快数倍,碰撞率可忽略);
656
- // · 候选来自两个互补来源:① 稀有 gram 倒排(按 df 计数排序,免每卡 sort 比较器)
657
- // ② MinHash + LSH 分桶(补足「共享 gram 都很常见」的近重复对);
658
- // · 只对候选做**精确** Jaccard —— 判定语义与旧实现一致,只是不再全量配对。
659
- const SIM_THRESHOLD = 0.42
660
- const DF_CAP = 24
661
- const RARE_CAP = 120
662
- const CAND_CAP = 40
663
- const MH_K = 48
664
- const MH_BANDS = 24
665
- const MH_ROWS = 2
666
- const prof = process.env.MEMORY_GRAPH_PROFILE === '1'
667
- let tStage = Date.now()
668
- const hashesByPath = new Map()
669
- const gramDf = new Map()
670
- for (const c of cards) {
671
- const s = bigramHashSet(bodyMap.get(c.path) || '')
672
- hashesByPath.set(c.path, s)
673
- for (const g of s) gramDf.set(g, (gramDf.get(g) || 0) + 1)
674
- }
675
- const tHash = Date.now() - tStage
676
- // ① 稀有 gram 倒排:df ∈ [1, DF_CAP] 直接进桶(计数排序),再按桶取最稀的 RARE_CAP 个
677
- tStage = Date.now()
678
- const rareByPath = new Map()
679
- for (const c of cards) {
680
- const buckets = []
681
- for (let df = 0; df <= DF_CAP; df++) buckets.push(null)
682
- for (const g of hashesByPath.get(c.path)) {
683
- const df = gramDf.get(g) || 0
684
- if (df > DF_CAP) continue
685
- if (!buckets[df]) buckets[df] = []
686
- buckets[df].push(g)
687
- }
688
- const rare = []
689
- for (let df = 1; df <= DF_CAP && rare.length < RARE_CAP; df++) {
690
- const b = buckets[df]
691
- if (!b) continue
692
- for (const g of b) { rare.push(g); if (rare.length >= RARE_CAP) break }
693
- }
694
- rareByPath.set(c.path, rare)
695
- }
696
- const rareIndex = new Map()
697
- for (const c of cards) {
698
- for (const g of rareByPath.get(c.path)) {
699
- const arr = rareIndex.get(g)
700
- if (arr) arr.push(c.path)
701
- else rareIndex.set(g, [c.path])
702
- }
703
- }
704
- const tRare = Date.now() - tStage
705
- // ② MinHash + LSH:签名只跑「稀有 gram」(≤120/卡),成本可控且正好覆盖近重复场景
706
- tStage = Date.now()
707
- const signatures = new Map()
708
- for (const c of cards) signatures.set(c.path, minhashSignature(rareByPath.get(c.path), MH_K))
709
- const lshCands = lshCandidates(signatures, { bands: MH_BANDS, rows: MH_ROWS, maxPerCard: CAND_CAP })
710
- const tSig = Date.now() - tStage
711
- tStage = Date.now()
712
- const similarCount = {}
713
- const tagSetByPath = new Map(cards.map((c) => [c.path, new Set(c.tags)]))
714
- const merged = new Map()
715
- let jaccardCalls = 0
716
- for (const card of cards) {
717
- merged.clear()
718
- for (const gram of rareByPath.get(card.path)) {
719
- for (const other of rareIndex.get(gram) || []) {
720
- if (other !== card.path) merged.set(other, (merged.get(other) || 0) + 1)
721
- }
722
- }
723
- for (const other of lshCands.get(card.path) || []) {
724
- if (other !== card.path) merged.set(other, (merged.get(other) || 0) + 2)
725
- }
726
- if (!merged.size) continue
727
- const ranked = [...merged.entries()].sort((x, y) => y[1] - x[1])
728
- const cands = ranked.length <= CAND_CAP ? ranked : ranked.slice(0, CAND_CAP)
729
- const A = hashesByPath.get(card.path)
730
- for (const [other] of cands) {
731
- if ((similarCount[card.path] || 0) >= 1) break
732
- if ((similarCount[other] || 0) >= 1) continue
733
- const b = byPath.get(other)
734
- if (!b) continue
735
- if (b.kind !== card.kind && !card.tags.some((t) => tagSetByPath.get(other).has(t))) continue
736
- const B = hashesByPath.get(other)
737
- const lo = Math.min(A.size, B.size)
738
- const hi = Math.max(A.size, B.size)
739
- if (!hi || lo / hi < SIM_THRESHOLD) continue // 无损上界:J ≤ min/max
740
- jaccardCalls++
741
- const sim = jaccardSets(A, B, SIM_THRESHOLD)
742
- if (sim >= SIM_THRESHOLD) { addEdge(card.path, other, 'similar'); addDegree(card.path); addDegree(other); similarCount[card.path] = 1; similarCount[other] = 1; break }
743
- }
744
- }
745
- const tCand = Date.now() - tStage
746
- if (prof) console.error('[memory-eternal] graph profile: hash=' + tHash + 'ms rare=' + tRare + 'ms sig=' + tSig + 'ms cand=' + tCand + 'ms jaccardCalls=' + jaccardCalls)
747
- const value = { nodes, edges }
748
- graphCache.set(root, { fp, value, at: Date.now() })
749
- writeGraphCache(root, fp, value)
750
- if (graphCache.size > GRAPH_CACHE_MAX) graphCache.delete(graphCache.keys().next().value)
751
- return value
752
- }
753
-
754
- /** 统计。 */
755
- export async function stats(root) {
756
- const db = getDb(root)
757
- const totalRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved'").get()
758
- const byKindRows = db.prepare("SELECT kind, COUNT(*) as cnt FROM cards WHERE status = 'approved' GROUP BY kind").all()
759
- const byKind = Object.fromEntries(byKindRows.map((r) => [r.kind, r.cnt]))
760
-
761
- const now = new Date()
762
- const dayAgo = new Date(now.getTime() - 86400000).toISOString()
763
- const weekAgo = new Date(now.getTime() - 7 * 86400000).toISOString()
764
- const todayRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(dayAgo)
765
- const weekRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(weekAgo)
766
- const tagRows = db.prepare("SELECT tags FROM cards WHERE status = 'approved'").all()
767
- const tagSet = new Set()
768
- for (const r of tagRows) { for (const t of JSON.parse(r.tags || '[]')) tagSet.add(t) }
769
-
770
- const todayCardsRows = db.prepare("SELECT * FROM cards WHERE status = 'approved' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 30").all(dayAgo)
771
- const todayCards = todayCardsRows.map((row) => ({ path: row.path, title: row.title, kind: row.kind, updated: row.updated_at, summary: (row.summary || '').slice(0, 80) }))
772
-
773
- return {
774
- total: totalRow.cnt,
775
- today: todayRow.cnt,
776
- week: weekRow.cnt,
777
- tags: tagSet.size,
778
- byKind,
779
- todayCards,
780
- }
781
- }
782
-
783
- /** 跨库聚合图谱。 */
784
- export async function graphAll(roots) {
785
- const nodes = []; const edges = []; const idSet = new Set()
786
- for (const r of roots) {
787
- let g
788
- try { g = await graph(r.root) } catch { continue }
789
- const pref = r.name ? `${r.name}::` : ''
790
- const map = new Map()
791
- for (const n of g.nodes) { const id = pref + n.id; if (!idSet.has(id)) { idSet.add(id); map.set(n.id, id); nodes.push({ ...n, id }) } else map.set(n.id, id) }
792
- for (const e of g.edges) { const s = map.get(e.source) || (pref + e.source), t = map.get(e.target) || (pref + e.target); if (s && t) edges.push({ source: s, target: t, type: e.type }) }
793
- }
794
- return { nodes, edges }
795
- }
796
-
797
- /** 跨库聚合检索。 */
798
- export async function searchAll(roots, query, opts = {}) {
799
- const out = []
800
- for (const r of roots) {
801
- let hits
802
- try { hits = await search(r.root, query, opts) } catch { continue }
803
- const pref = r.name ? `${r.name}::` : ''
804
- for (const h of hits) out.push({ ...h, path: pref + h.path, score: (h.score || 0) + (r.name ? 0 : 0), profile: r.name || '' })
805
- }
806
- out.sort((a, b) => b.score - a.score || b.mtime - a.mtime)
807
- return out.slice(0, opts.limit || 30)
808
- }
809
-
810
- /** 整理建议(非破坏性预览)。 */
811
- export async function optimizeCandidates(root) {
812
- const db = getDb(root)
813
- const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
814
- const cards = rows.map(rowToCard)
815
- const bodyMap = new Map(rows.map((r) => [r.path, (r.body || '').slice(0, 4000)]))
816
-
817
- const merge = []
818
- for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
819
- const a = cards[i], b = cards[j]
820
- if (a.kind !== b.kind) continue
821
- const sim = textSimilarity(bodyMap.get(a.path) || '', bodyMap.get(b.path) || '')
822
- if (sim >= 0.55) merge.push({ a: { path: a.path, title: a.title }, b: { path: b.path, title: b.title }, sim: Math.round(sim * 100) / 100 })
823
- }
824
- merge.sort((x, y) => y.sim - x.sim)
825
- const old = Date.now() - 90 * 86400000
826
- const stale = cards.filter((c) => c.mtime < old).slice(0, 30).map((c) => ({ path: c.path, title: c.title, updated: c.updated }))
827
- return { merge: merge.slice(0, 40), stale }
828
- }
829
-
830
- /** 一键优化执行。 */
831
- export async function optimizeApply(root, opts = {}) {
832
- const simThreshold = Number(opts.simThreshold ?? 0.55)
833
- const staleDays = Number(opts.staleDays ?? 90)
834
- const cleanupStale = Boolean(opts.cleanupStale)
835
- const dryRun = Boolean(opts.dryRun)
836
- const cands = await optimizeCandidates(root)
837
- const merge = cands.merge
838
- const stale = cands.stale
839
- const merged = []
840
- const staleDeleted = []
841
- const staleSkipped = []
842
- const errors = []
843
- const used = new Set()
844
- for (const pair of merge) {
845
- if (used.has(pair.a.path) || used.has(pair.b.path)) continue
846
- if (pair.sim < simThreshold) continue
847
- if (!dryRun) {
848
- try {
849
- const r = await mergeCards(root, [pair.a.path, pair.b.path])
850
- if (r && r.ok) {
851
- merged.push({ from: [pair.a.path, pair.b.path], to: r.path, sim: pair.sim })
852
- used.add(pair.a.path); used.add(pair.b.path)
853
- } else { errors.push(`merge ${pair.a.path}+${pair.b.path}: ${r?.error || '失败'}`) }
854
- } catch (e) { errors.push(`merge ${pair.a.path}+${pair.b.path}: ${e?.message || e}`) }
855
- } else {
856
- merged.push({ from: [pair.a.path, pair.b.path], to: '(dry-run)', sim: pair.sim })
857
- used.add(pair.a.path); used.add(pair.b.path)
858
- }
859
- }
860
- if (cleanupStale) {
861
- for (const s of stale) {
862
- if (!dryRun) {
863
- try { await deleteCard(root, s.path); staleDeleted.push(s.path) } catch (e) { errors.push(`delete ${s.path}: ${e?.message || e}`); staleSkipped.push(s.path) }
864
- } else { staleDeleted.push(s.path) }
865
- }
866
- } else {
867
- for (const s of stale) staleSkipped.push(s.path)
868
- }
869
- return {
870
- ok: true,
871
- dryRun,
872
- plan: { merged: merged.length, staleDeleted: staleDeleted.length, staleSkipped: staleSkipped.length },
873
- merged,
874
- staleDeleted,
875
- staleSkipped,
876
- errors,
877
- }
878
- }
879
-
880
- /** 读取反馈记录。 */
881
- export async function readFeedback(root) {
882
- const db = getDb(root)
883
- const rows = db.prepare('SELECT * FROM feedback ORDER BY created_at DESC').all()
884
- return rows.map((r) => ({ ts: new Date(r.created_at).getTime(), query: r.query, path: r.card_path, useful: r.useful }))
885
- }
886
-
887
- /** 添加反馈。 */
888
- export async function addFeedback(root, rec) {
889
- const db = getDb(root)
890
- db.prepare('INSERT INTO feedback (query, card_path, useful) VALUES (?, ?, ?)').run(
891
- rec.query || '',
892
- rec.path || rec.card_path || '',
893
- rec.useful ? 1 : 0,
894
- )
895
- return { ok: true }
896
- }
897
-
898
- /** 统计:按 kind + status。 */
899
- export async function overview(root) {
900
- const db = getDb(root)
901
- const cards = await listCards(root, { status: ['approved', 'pending', 'rejected'] })
902
- const byKind = {}
903
- const statusCounts = {}
904
- for (const c of cards) { byKind[c.kind] = (byKind[c.kind] || 0) + 1; statusCounts[c.status] = (statusCounts[c.status] || 0) + 1 }
905
- const week = Date.now() - 7 * 86400000
906
- const recent = cards.filter((c) => c.mtime > week).length
907
- const tagSet = new Set()
908
- for (const c of cards) for (const t of c.tags) tagSet.add(t)
909
- return {
910
- total: cards.length,
911
- byKind,
912
- recent,
913
- tags: tagSet.size,
914
- status: statusCounts,
915
- roots: Object.fromEntries(Object.entries(KIND_ROOTS).map(([k, v]) => [k, `${v}/`])),
916
- }
917
- }
918
-
919
- // -- helpers ---------------------------------------------------------------
920
-
921
- function resolveInside(root, rel) {
922
- const target = path.resolve(root, rel)
923
- const rootResolved = path.resolve(root)
924
- if (target !== rootResolved && !target.startsWith(rootResolved + path.sep)) return null
925
- return target
926
- }
927
-
928
- async function exists(p) {
929
- try {
930
- const { promises: fs } = await import('node:fs')
931
- await fs.access(p)
932
- return true
933
- } catch {
934
- return false
935
- }
936
- }
937
-
938
- function yamlString(value) {
939
- const s = String(value ?? '')
940
- return /[:#\[\]{}"',&*!|>%@`]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
941
- }
1
+ // 记忆核心 · SQLite Vault 存储层
2
+ //
3
+ // 纯工具函数保持不变;IO 函数全部改用 SQL。
4
+ // 底层依赖 lib/db.js 的 getDb(root) → DatabaseSync 实例。
5
+
6
+ import path from 'node:path'
7
+ import fs from 'node:fs'
8
+ import { getDb, enforceAudit } from './db.js'
9
+ import { minhashSignature, lshCandidates } from './minhash.js'
10
+
11
+ /** 主题目录根。 */
12
+ export const KIND_ROOTS = {
13
+ project: '02-Projects',
14
+ knowledge: '03-Knowledge',
15
+ content: '04-Content',
16
+ prompt: '05-Prompts',
17
+ business: '06-Business',
18
+ tool: '07-Tools',
19
+ mistake: '08-Mistakes',
20
+ }
21
+
22
+ export const CAPTURE_KINDS = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake']
23
+
24
+ /** 从 Markdown 文本提取 frontmatter 与正文。 */
25
+ export function parseCard(text) {
26
+ const meta = { title: '', kind: 'knowledge', tags: [], created: '', updated: '', source: '', formatVersion: 0, status: 'pending', submittedBy: '', severity: 'info', reason: '', deletedAt: '' }
27
+ let body = text
28
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text)
29
+ if (m) {
30
+ body = text.slice(m[0].length)
31
+ for (const line of m[1].split(/\r?\n/)) {
32
+ const eq = line.indexOf(':')
33
+ if (eq <= 0) continue
34
+ const key = line.slice(0, eq).trim()
35
+ let value = line.slice(eq + 1).trim()
36
+ if (key === 'tags') {
37
+ value = value.replace(/^\[/, '').replace(/\]$/, '')
38
+ meta.tags = value.split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
39
+ continue
40
+ }
41
+ if (key === 'formatVersion') {
42
+ meta.formatVersion = Number(value) || 0
43
+ } else if (key === 'kind' || key === 'title' || key === 'created' || key === 'updated' || key === 'source' || key === 'status' || key === 'submittedBy' || key === 'severity' || key === 'reason' || key === 'deletedAt') {
44
+ meta[key] = value.replace(/^['"]|['"]$/g, '')
45
+ }
46
+ }
47
+ }
48
+ if (!meta.status) meta.status = 'pending'
49
+ if (!meta.title) {
50
+ const h = /^#\s+(.+)$/m.exec(body)
51
+ meta.title = h ? h[1].trim() : body.trim().split(/\r?\n/)[0].slice(0, 60)
52
+ }
53
+ const summary = body.trim().slice(0, 200)
54
+ return { meta, body: body.trim(), summary }
55
+ }
56
+
57
+ /** 生成安全 slug(中文保留、非法字符替换为 -)。 */
58
+ export function safeSlug(name) {
59
+ const base = String(name || 'card')
60
+ .trim()
61
+ .toLowerCase()
62
+ .replace(/[\s_]+/g, '-')
63
+ .replace(/[^\w\u4e00-\u9fff-]+/g, '')
64
+ .replace(/-+/g, '-')
65
+ .replace(/^-|-$/g, '')
66
+ return base || 'card'
67
+ }
68
+
69
+ /** 字符 bigram 集合(textSimilarity 的预处理部分,单独暴露供 O(n²) 场景复用)。 */
70
+ export function bigramSet(text) {
71
+ const cleaned = String(text)
72
+ .replace(/[\s#*_`|[\]()()\-—・]+/g, '')
73
+ .toLowerCase()
74
+ const out = new Set()
75
+ for (let i = 0; i < cleaned.length - 1; i++) out.add(cleaned.slice(i, i + 2))
76
+ if (cleaned.length === 1) out.add(cleaned)
77
+ return out
78
+ }
79
+
80
+ /**
81
+ * 与 bigramSet 同规则(同一套清洗),但把每个 bigram 压成 32 位整数再入集合。
82
+ * 为什么单独一份:Set<number> 的构建与求交都比 Set<string> 快数倍,
83
+ * 而 **去重判定** 仍必须用字符串版(语义不能变)—— 所以图谱专用、互不干扰。
84
+ */
85
+ export function bigramHashSet(text) {
86
+ const cleaned = String(text)
87
+ .replace(/[\s#*_`|[\]()()\-—・]+/g, '')
88
+ .toLowerCase()
89
+ const out = new Set()
90
+ for (let i = 0; i < cleaned.length - 1; i++) out.add(hash32(cleaned.slice(i, i + 2)))
91
+ if (cleaned.length === 1) out.add(hash32(cleaned))
92
+ return out
93
+ }
94
+
95
+ /** 32 位 FNV-1a:把 bigram 压成整数键。 */
96
+ function hash32(s) {
97
+ let h = 0x811c9dc5
98
+ for (let i = 0; i < s.length; i++) {
99
+ h ^= s.charCodeAt(i)
100
+ h = Math.imul(h, 0x01000193) >>> 0
101
+ }
102
+ return h >>> 0
103
+ }
104
+
105
+ /**
106
+ * 用预算好的 bigram 集合算 Jaccard(结果与 textSimilarity 完全一致)。
107
+ *
108
+ * `minRatio` 是数学上界剪枝:J ≤ min(|A|,|B|)/max(|A|,|B|),所以长度比低于阈值时
109
+ * 必然 `J < 阈值`,可直接返回 0 **不迭代**(对「J ≥ 阈值」的判定无损,不是近似)。
110
+ * 图谱的 O(n²) 配对就靠这一步从「每次重新分词两段正文」变成「集合求交」。
111
+ */
112
+ export function jaccardSets(ba, bb, minRatio = 0) {
113
+ if (!ba || !bb || ba.size === 0 || bb.size === 0) return 0
114
+ const [small, large] = ba.size <= bb.size ? [ba, bb] : [bb, ba]
115
+ if (minRatio > 0 && small.size / large.size < minRatio) return 0
116
+ let inter = 0
117
+ for (const g of small) if (large.has(g)) inter++
118
+ return inter / (ba.size + bb.size - inter)
119
+ }
120
+
121
+ /** Jaccard-like 相似度:字符 bigram 集合。 */
122
+ export function textSimilarity(a, b) {
123
+ return jaccardSets(bigramSet(a), bigramSet(b))
124
+ }
125
+
126
+ /** CJK 感知查询词:整词 + 中文字符 bigram。 */
127
+ export function queryTerms(query) {
128
+ const tokens = new Set(String(query).split(/[^\w\u4e00-\u9fff]+/).filter(Boolean))
129
+ for (let i = 0; i < query.length - 1; i++) {
130
+ const c1 = query[i]
131
+ const c2 = query[i + 1]
132
+ if (/[\u4e00-\u9fff]/.test(c1) && /[\u4e00-\u9fff]/.test(c2)) tokens.add(c1 + c2)
133
+ }
134
+ return tokens
135
+ }
136
+
137
+ /** 在目标目录中找与候选文本最相似的现有卡(去重守卫)。比较正文。 */
138
+ export async function dedupCheck(root, text, threshold = 0.62) {
139
+ const db = getDb(root)
140
+ const rows = db.prepare('SELECT path, body FROM cards WHERE deleted_at IS NULL').all()
141
+ let best = null
142
+ for (const row of rows) {
143
+ const score = textSimilarity(text, row.body || '')
144
+ if (score >= threshold && (!best || score > best.score)) best = { path: row.path, score }
145
+ }
146
+ return best
147
+ }
148
+
149
+ // 目录名 → kind 推断
150
+ function kindFromDir(topDir) {
151
+ for (const [k, v] of Object.entries(KIND_ROOTS)) { if (v.toLowerCase() === String(topDir || '').toLowerCase()) return k }
152
+ return 'knowledge'
153
+ }
154
+
155
+ /** 确保 vault 目录结构存在(即确保 DB 存在)。 */
156
+ export async function ensureVault(root) {
157
+ // getDb 会自动建表
158
+ getDb(root)
159
+ }
160
+
161
+ /** 将 SQL 行还原为调用方期望的卡片摘要对象。 */
162
+ function rowToCard(row) {
163
+ return {
164
+ path: row.path,
165
+ kind: row.kind,
166
+ title: row.title,
167
+ tags: JSON.parse(row.tags || '[]'),
168
+ summary: row.summary || '',
169
+ created: row.created_at,
170
+ updated: row.updated_at,
171
+ mtime: new Date(row.updated_at).getTime(),
172
+ status: row.status,
173
+ submittedBy: row.submitted_by || '',
174
+ severity: row.severity || 'info',
175
+ reason: row.reason || '',
176
+ deletedAt: row.deleted_at || '',
177
+ }
178
+ }
179
+
180
+ /** 将 SQL 行还原为完整 Markdown 文本(frontmatter + body)。 */
181
+ function rowToMarkdown(row) {
182
+ const tags = JSON.parse(row.tags || '[]')
183
+ const lines = [
184
+ '---',
185
+ `formatVersion: 1`,
186
+ `kind: ${row.kind}`,
187
+ `title: ${yamlString(row.title)}`,
188
+ `tags: [${tags.map((t) => yamlString(t)).join(', ')}]`,
189
+ `created: ${row.created_at}`,
190
+ `updated: ${row.updated_at}`,
191
+ `status: ${row.status}`,
192
+ ...(row.deleted_at ? [`deletedAt: ${row.deleted_at}`] : []),
193
+ ...(row.submitted_by ? [`submittedBy: ${yamlString(row.submitted_by)}`] : []),
194
+ ...(row.severity && row.severity !== 'info' ? [`severity: ${row.severity}`] : []),
195
+ ...(row.reason ? [`reason: ${yamlString(row.reason)}`] : []),
196
+ ...(row.source ? [`source: ${row.source}`] : []),
197
+ '---',
198
+ '',
199
+ `# ${row.title}`,
200
+ '',
201
+ row.body.trim(),
202
+ '',
203
+ ]
204
+ // 附加 card_updates
205
+ return { text: lines.join('\n'), tags }
206
+ }
207
+
208
+ /**
209
+ * 智能体署名归一(与客户端 normAgent 同一套规则,供 SQL 过滤用):
210
+ * 空 / agent / unknown / session:* / DeepSeek Harness → deepseek-harness;claude-code* → claude;其余精确匹配。
211
+ */
212
+ function agentWhere(agent, args) {
213
+ const a = String(agent || '').toLowerCase()
214
+ if (a === 'deepseek-harness') {
215
+ args.push('', 'agent', 'unknown', 'deepseek-harness', 'deepseek harness')
216
+ return "(LOWER(COALESCE(submitted_by,'')) IN (?,?,?,?,?) OR submitted_by LIKE 'session:%')"
217
+ }
218
+ if (a === 'claude') { args.push('claude', 'claude-code%'); return "(LOWER(COALESCE(submitted_by,'')) = ? OR submitted_by LIKE ?)" }
219
+ args.push(a)
220
+ return "LOWER(COALESCE(submitted_by,'')) = ?"
221
+ }
222
+
223
+ /** WHERE 组装:状态白名单 + 可选 kind / agent。 */
224
+ function cardsWhere({ want, kind, agent }) {
225
+ const args = [...want]
226
+ let where = `status IN (${want.map(() => '?').join(',')})`
227
+ if (kind) { where += ' AND kind = ?'; args.push(kind) }
228
+ if (agent) where += ' AND ' + agentWhere(agent, args)
229
+ return { where, args }
230
+ }
231
+
232
+ /** 标题排序用的中文排序器(拼音 + 数字感知);固定 zh-Hans-CN,避免随进程 locale 漂移。 */
233
+ export const TITLE_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
234
+
235
+ /**
236
+ * 列出卡片(从 SQL)。
237
+ * @param opts.status 状态白名单(默认 approved)
238
+ * @param opts.kind 类型过滤(SQL)
239
+ * @param opts.agent 署名过滤(服务端归一,如 deepseek-harness / claude)
240
+ * @param opts.limit 返回条数上限(缺省=全部,供内部调用保持原语义)
241
+ * @param opts.offset 偏移(配合 limit 做真分页)
242
+ * @param opts.sort 'recent'(updated_at DESC,默认)| 'title'(拼音升序,跨页全局有序)
243
+ */
244
+ export async function listCards(root, { status, kind, agent, limit, offset = 0, sort = 'recent' } = {}) {
245
+ const want = status ?? ['approved']
246
+ const db = getDb(root)
247
+ const { where, args } = cardsWhere({ want, kind, agent })
248
+ if (sort === 'title') {
249
+ // SQLite 的 ORDER BY 是 UTF-8 字节序,中文标题看着就是乱的 → 只取 path/title 用拼音排好,
250
+ // 再回表取这一页的正文(避免为了排序把所有正文读进内存)。
251
+ const light = db.prepare(`SELECT path, title FROM cards WHERE ${where}`).all(...args)
252
+ light.sort((a, b) => TITLE_COLLATOR.compare(a.title || '', b.title || ''))
253
+ const start = Math.max(0, offset | 0)
254
+ const page = Number.isInteger(limit) && limit > 0 ? light.slice(start, start + limit) : light.slice(start)
255
+ if (page.length === 0) return []
256
+ const ph = page.map(() => '?').join(',')
257
+ const rows = db.prepare(`SELECT * FROM cards WHERE path IN (${ph})`).all(...page.map((r) => r.path))
258
+ const byPath = new Map(rows.map((r) => [r.path, rowToCard(r)]))
259
+ return page.map((r) => byPath.get(r.path)).filter(Boolean)
260
+ }
261
+ if (Number.isInteger(limit) && limit > 0) {
262
+ const rows = db.prepare(`SELECT * FROM cards WHERE ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`).all(...args, limit, Math.max(0, offset | 0))
263
+ return rows.map(rowToCard)
264
+ }
265
+ const rows = db.prepare(`SELECT * FROM cards WHERE ${where} ORDER BY updated_at DESC`).all(...args)
266
+ return rows.map(rowToCard)
267
+ }
268
+
269
+ /** 计数(分页接口要用 total 判断还有没有下一页)。 */
270
+ export async function countCards(root, { status, kind, agent } = {}) {
271
+ const db = getDb(root)
272
+ const { where, args } = cardsWhere({ want: status ?? ['approved'], kind, agent })
273
+ return db.prepare(`SELECT COUNT(*) n FROM cards WHERE ${where}`).get(...args).n
274
+ }
275
+
276
+ /** 审核队列:status=pending 或 rejected。 */
277
+ export async function auditQueue(root) {
278
+ const cards = await listCards(root, { status: ['pending', 'rejected'] })
279
+ return { pending: cards.filter((c) => c.status === 'pending'), rejected: cards.filter((c) => c.status === 'rejected') }
280
+ }
281
+
282
+ /** 回收站:status=deleted,按 deleted_at 倒序。 */
283
+ export async function recycleList(root) {
284
+ const db = getDb(root)
285
+ const rows = db.prepare('SELECT * FROM cards WHERE status = ? ORDER BY deleted_at DESC').all('deleted')
286
+ return rows.map(rowToCard)
287
+ }
288
+
289
+ /** 清理超过 retentionDays 天的回收卡(永久删除)。 */
290
+ export async function purgeExpired(root, retentionDays = 30) {
291
+ const db = getDb(root)
292
+ const cut = new Date(Date.now() - retentionDays * 86400000).toISOString()
293
+ const rows = db.prepare('SELECT path FROM cards WHERE status = ? AND deleted_at IS NOT NULL AND deleted_at < ?').all('deleted', cut)
294
+ let purged = 0
295
+ for (const row of rows) {
296
+ try { await deleteCard(root, row.path, { permanent: true }); purged++ } catch {}
297
+ }
298
+ return { ok: true, purged }
299
+ }
300
+
301
+ /** 读取一张卡(返回 Markdown 文本,兼容旧调用方)。 */
302
+ export async function readCard(root, rel) {
303
+ const db = getDb(root)
304
+ const row = db.prepare('SELECT * FROM cards WHERE path = ?').get(rel)
305
+ if (!row) throw new Error('卡片不存在: ' + rel)
306
+ const { text } = rowToMarkdown(row)
307
+ // 附加更新记录
308
+ const updates = db.prepare('SELECT content, created_at FROM card_updates WHERE card_id = ? ORDER BY created_at ASC').all(row.id)
309
+ if (updates.length > 0) {
310
+ const updateLines = updates.map((u) => `- ${u.created_at.slice(0, 10)}:${u.content}`)
311
+ return text + '\n## 更新记录\n\n' + updateLines.join('\n') + '\n'
312
+ }
313
+ return text
314
+ }
315
+
316
+ /** 软删卡片:status=deleted + deleted_at。 */
317
+ export async function deleteCard(root, rel, { permanent = false } = {}) {
318
+ const db = getDb(root)
319
+ if (permanent) {
320
+ db.prepare('DELETE FROM cards WHERE path = ?').run(rel)
321
+ return { ok: true, permanent: true }
322
+ }
323
+ const now = new Date().toISOString()
324
+ const info = db.prepare('UPDATE cards SET status = ?, deleted_at = ?, updated_at = ? WHERE path = ?').run('deleted', now, now, rel)
325
+ if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
326
+ return { ok: true, soft: true }
327
+ }
328
+
329
+ /** 恢复软删卡:status=approved,清除 deleted_at。写审计日志。 */
330
+ export async function restoreCard(root, rel) {
331
+ const db = getDb(root)
332
+ const now = new Date().toISOString()
333
+ const existing = db.prepare('SELECT status FROM cards WHERE path = ?').get(rel)
334
+ if (!existing) throw new Error('卡片不存在: ' + rel)
335
+ const info = db.prepare('UPDATE cards SET status = ?, deleted_at = NULL, updated_at = ? WHERE path = ?').run('approved', now, rel)
336
+ if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
337
+ db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, 'approved', 'restore', '回收站恢复')
338
+ return { ok: true, restored: true }
339
+ }
340
+
341
+ /** 更新卡状态(审核操作)。写审计日志,确保可追溯。 */
342
+ export async function setCardStatus(root, rel, status, { changedBy = 'system', reason = '' } = {}) {
343
+ const db = getDb(root)
344
+ const now = new Date().toISOString()
345
+ const existing = db.prepare('SELECT status FROM cards WHERE path = ?').get(rel)
346
+ if (!existing) throw new Error('卡片不存在: ' + rel)
347
+ const info = db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE path = ?').run(status, now, rel)
348
+ if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
349
+ // 审计日志:记录每次 status 变更
350
+ db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, status, changedBy, reason)
351
+ return { ok: true, status }
352
+ }
353
+
354
+ /** 原子写卡;先去重,命中返回 {duplicate}。审核守卫在数据库层强制。 */
355
+ export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true } = {}) {
356
+ const db = getDb(root)
357
+ const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
358
+
359
+ // 审核守卫:数据库层强制,调用方传的 status 仅作 fallback
360
+ const enforcedStatus = enforceAudit(root, kind, submittedBy, status)
361
+
362
+ if (dedup) {
363
+ const hit = await dedupCheck(root, body, threshold)
364
+ if (hit) return { ok: false, duplicate: { ...hit, path: hit.path } }
365
+ }
366
+
367
+ const slug = safeSlug(title)
368
+ let rel = `${kindRoot}/${slug}.md`
369
+ let index = 2
370
+ while (db.prepare('SELECT 1 FROM cards WHERE path = ?').get(rel)) {
371
+ rel = `${kindRoot}/${slug}-${index}.md`
372
+ index++
373
+ }
374
+
375
+ const now = new Date().toISOString()
376
+ const summary = body.trim().slice(0, 200)
377
+ db.prepare(`
378
+ INSERT INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
379
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
380
+ `).run(
381
+ rel,
382
+ kind,
383
+ title,
384
+ JSON.stringify(tags),
385
+ body.trim(),
386
+ summary,
387
+ enforcedStatus,
388
+ source,
389
+ submittedBy,
390
+ severity,
391
+ reason,
392
+ now,
393
+ now,
394
+ deletedAt || null,
395
+ )
396
+ return { ok: true, path: rel, kind }
397
+ }
398
+
399
+ /** 向已存在的卡追加更新记录。 */
400
+ export async function appendUpdate(root, rel, updateText) {
401
+ const db = getDb(root)
402
+ const row = db.prepare('SELECT id FROM cards WHERE path = ?').get(rel)
403
+ if (!row) throw new Error('卡片不存在: ' + rel)
404
+ const now = new Date().toISOString()
405
+ db.prepare('INSERT INTO card_updates (card_id, content, created_at) VALUES (?, ?, ?)').run(row.id, updateText.trim(), now)
406
+ db.prepare('UPDATE cards SET updated_at = ? WHERE id = ?').run(now, row.id)
407
+ return { ok: true, path: rel, updated: now }
408
+ }
409
+
410
+ /** 合并多张卡为一张。 */
411
+ export async function mergeCards(root, paths) {
412
+ const list = (Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean)
413
+ if (list.length < 2) return { ok: false, error: '至少选 2 张卡' }
414
+ const db = getDb(root)
415
+
416
+ const rows = []
417
+ for (const p of list) {
418
+ const row = db.prepare('SELECT * FROM cards WHERE path = ?').get(p)
419
+ if (row) rows.push(row)
420
+ }
421
+ if (rows.length < 2) return { ok: false, error: '读取失败' }
422
+
423
+ const first = rows[0]
424
+ const tags = new Set(JSON.parse(first.tags || '[]'))
425
+ let combined = ''
426
+ rows.forEach((row, i) => {
427
+ const rowTags = JSON.parse(row.tags || '[]')
428
+ rowTags.forEach((t) => tags.add(t))
429
+ combined += (i ? '\n\n---\n\n' : '') + row.body.trim()
430
+ })
431
+
432
+ const title = (first.title || '合并记忆') + '(合并)'
433
+ const r = await writeCard(root, { kind: first.kind || 'knowledge', title, tags: [...tags], body: combined, source: 'merge', status: 'pending', submittedBy: 'merge', reason: '多卡合并' }, { dedup: false })
434
+ if (!r.ok) return { ok: false, error: '写入失败' }
435
+ for (const row of rows) { try { await deleteCard(root, row.path, { permanent: true }) } catch {} }
436
+ return { ok: true, path: r.path, merged: rows.length }
437
+ }
438
+
439
+ /** 检索:整词/中文 bigram 命中。 */
440
+ export async function search(root, query, { limit = 30, minScore = 0, semantic = false } = {}) {
441
+ const q = String(query || '').trim()
442
+ if (!q) return []
443
+ const db = getDb(root)
444
+ const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
445
+ const wanted = queryTerms(q)
446
+ const out = []
447
+ for (const row of rows) {
448
+ const card = rowToCard(row)
449
+ const haystack = `${card.path}\n${card.title}\n${row.body}`.toLowerCase()
450
+ let score = 0
451
+ if (haystack.includes(q.toLowerCase())) score = 3
452
+ for (const term of wanted) {
453
+ if (haystack.includes(term.toLowerCase())) score += 1
454
+ }
455
+ if (semantic) score += Math.round(textSimilarity(q, `${card.title} ${card.summary}`) * 6)
456
+ if (score > 0 && score >= minScore) {
457
+ out.push({ ...card, score, excerpt: row.body.trim().slice(0, 300) })
458
+ }
459
+ }
460
+
461
+ // 反馈反哺排序
462
+ const fb = await readFeedback(root)
463
+ const ql = q.toLowerCase()
464
+ const fbByPath = {}
465
+ for (const f of fb) {
466
+ if (!f.path) continue
467
+ const fq = String(f.query || '').toLowerCase()
468
+ const overlap = fq && (fq === ql || ql.includes(fq) || fq.includes(ql) || queryTerms(f.query).some((term) => wanted.includes(term)))
469
+ if (!overlap) continue
470
+ const rec = fbByPath[f.path] = fbByPath[f.path] || { useful: 0, irr: 0 }
471
+ if (f.useful) rec.useful += 1; else rec.irr += 1
472
+ }
473
+ for (const hit of out) {
474
+ const rec = fbByPath[hit.path]
475
+ if (rec) hit.score += rec.useful * 2 - rec.irr * 2
476
+ }
477
+ out.sort((a, b) => b.score - a.score || b.mtime - a.mtime)
478
+ return out.slice(0, limit)
479
+ }
480
+
481
+ /** 用量趋势:最近 N 天每天新增卡片数。 */
482
+ export async function dailyCounts(root, days = 30) {
483
+ const db = getDb(root)
484
+ const cut = new Date(Date.now() - days * 86400000).toISOString()
485
+ const rows = db.prepare("SELECT DATE(created_at) as d, COUNT(*) as cnt FROM cards WHERE created_at >= ? GROUP BY DATE(created_at)").all(cut)
486
+ const map = new Map(rows.map((r) => [r.d, r.cnt]))
487
+ const out = []
488
+ for (let i = days - 1; i >= 0; i--) {
489
+ const dt = new Date(Date.now() - i * 86400000)
490
+ const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
491
+ out.push({ date: key, count: map.get(key) || 0 })
492
+ }
493
+ return out
494
+ }
495
+
496
+ /** 导出全部卡片原始 Markdown。 */
497
+ export async function exportCards(root) {
498
+ const db = getDb(root)
499
+ const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
500
+ return rows.map((row) => {
501
+ const { text } = rowToMarkdown(row)
502
+ return { path: row.path, title: row.title, kind: row.kind, text }
503
+ })
504
+ }
505
+
506
+ // 图谱缓存:一次生成约 300ms(301 卡),重复请求(多客户端/切页重开)直接命中。
507
+ // 失效用「指纹」而不是 TTL 兜底:卡数 + 更新时间 + 状态直方图 + 审计日志/更新记录水位,
508
+ // 任何写卡、审核、删卡、追加更新都会改变指纹 → 不存在脏读。
509
+ const GRAPH_CACHE_MAX = 4
510
+ const GRAPH_CACHE_TTL_MS = 5 * 60 * 1000
511
+ const graphCache = new Map() // root -> { fp, value, at }
512
+
513
+ // ---- P0-1:建图结果落盘 ------------------------------------------------------
514
+ // 指纹一变就失效,命中直接读文件 —— 免掉整条建图管线(596 卡实测 ~600ms → ~25ms)。
515
+ // 文件放 vault 根目录、原子写(tmp + rename),写失败/超限都只是「下次重建」,不影响功能。
516
+ const GRAPH_CACHE_FILE = 'memory-eternal-graph-cache.json'
517
+ const GRAPH_CACHE_MAX_BYTES = 8 * 1024 * 1024
518
+ const graphCachePath = (root) => path.join(root, GRAPH_CACHE_FILE)
519
+
520
+ function readGraphCache(root, fp) {
521
+ try {
522
+ const data = JSON.parse(fs.readFileSync(graphCachePath(root), 'utf8'))
523
+ if (!data || data.fp !== fp) return null
524
+ const v = data.value
525
+ if (!v || !Array.isArray(v.nodes) || !Array.isArray(v.edges)) return null
526
+ return v
527
+ } catch { return null }
528
+ }
529
+
530
+ function writeGraphCache(root, fp, value) {
531
+ try {
532
+ const payload = JSON.stringify({ fp, at: Date.now(), value })
533
+ if (payload.length > GRAPH_CACHE_MAX_BYTES) return
534
+ const file = graphCachePath(root)
535
+ const tmp = file + '.' + process.pid + '.tmp'
536
+ fs.writeFileSync(tmp, payload)
537
+ fs.renameSync(tmp, file)
538
+ } catch { /* 缓存写失败不影响功能 */ }
539
+ }
540
+
541
+ function graphFingerprint(db) {
542
+ const c = db.prepare('SELECT COUNT(*) n, MAX(updated_at) m, SUM(id) s FROM cards').get()
543
+ const st = db.prepare('SELECT status, COUNT(*) n FROM cards GROUP BY status').all()
544
+ const log = db.prepare('SELECT MAX(id) id FROM audit_log').get()
545
+ let upd = 0
546
+ try { upd = db.prepare('SELECT MAX(id) id FROM card_updates').get().id || 0 } catch { /* 老库无此表 */ }
547
+ return [c.n, c.m || '', c.s || 0, st.map((r) => `${r.status}:${r.n}`).join(','), log.id || 0, upd].join('|')
548
+ }
549
+
550
+ /** 图谱:节点 = 卡片;边 = [[wikilink]] 或共享标签。 */
551
+ export async function graph(root) {
552
+ const db = getDb(root)
553
+ const fp = graphFingerprint(db)
554
+ const hit = graphCache.get(root)
555
+ if (hit && hit.fp === fp && Date.now() - hit.at < GRAPH_CACHE_TTL_MS) return hit.value
556
+ // P0-1:内存未命中就查磁盘缓存(指纹一致即视为有效)
557
+ const onDisk = readGraphCache(root, fp)
558
+ if (onDisk) {
559
+ graphCache.set(root, { fp, value: onDisk, at: Date.now() })
560
+ if (process.env.MEMORY_GRAPH_PROFILE === '1') console.error('[memory-eternal] graph: 命中磁盘缓存')
561
+ return onDisk
562
+ }
563
+ // 只选图谱需要的列,不读 body(大幅减少内存和传输量)
564
+ const rows = db.prepare("SELECT id, path, kind, title, tags, summary, status, updated_at FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
565
+ // body 单独按需读取(只在需要 wikilink 匹配时)
566
+ const bodyRows = db.prepare("SELECT path, body FROM cards WHERE status = 'approved'").all()
567
+ const bodyMap = new Map(bodyRows.map((r) => [r.path, r.body]))
568
+ const cards = rows.map((r) => ({
569
+ path: r.path, kind: r.kind, title: r.title, tags: JSON.parse(r.tags || '[]'),
570
+ summary: r.summary || '', updated: r.updated_at, mtime: new Date(r.updated_at).getTime(), status: r.status,
571
+ }))
572
+
573
+ const byPath = new Map(cards.map((c) => [c.path, c]))
574
+
575
+ const nodes = cards.map((c) => ({
576
+ id: c.path,
577
+ title: c.title,
578
+ kind: c.kind,
579
+ tags: c.tags,
580
+ summary: c.summary.slice(0, 80),
581
+ updated: c.updated,
582
+ mtime: c.mtime,
583
+ linkCount: 0,
584
+ }))
585
+ const nodeById = new Map(nodes.map((n) => [n.id, n]))
586
+ const addDegree = (p) => { const n = nodeById.get(p); if (n) n.linkCount++ }
587
+ const edges = []
588
+ const seen = new Set()
589
+ const addEdge = (a, b, type) => {
590
+ const key = [a, b].sort().join('|') + '|' + type
591
+ if (seen.has(key)) return
592
+ seen.add(key)
593
+ edges.push({ source: a, target: b, type })
594
+ }
595
+
596
+ for (const card of cards) {
597
+ const text = bodyMap.get(card.path) || ''
598
+ for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)) {
599
+ const raw = m[1].trim().replace(/\.md$/, '')
600
+ const candidates = [
601
+ `${card.kind === 'knowledge' ? KIND_ROOTS.knowledge : KIND_ROOTS[card.kind]}/${raw}.md`,
602
+ `${KIND_ROOTS.knowledge}/${raw}.md`,
603
+ `${raw}.md`,
604
+ ]
605
+ for (const cand of candidates) {
606
+ if (cand !== card.path && byPath.has(cand)) {
607
+ addEdge(card.path, cand, 'link'); addDegree(card.path); addDegree(cand)
608
+ break
609
+ }
610
+ }
611
+ for (const other of cards) {
612
+ if (other.path !== card.path && (other.title === raw || other.path.endsWith(`/${raw}.md`))) {
613
+ addEdge(card.path, other.path, 'link'); addDegree(card.path); addDegree(other.path)
614
+ break
615
+ }
616
+ }
617
+ }
618
+ }
619
+ // 标签倒排(tag → paths,按 cards 顺序):原先每个标签都要全表扫一遍,是 O(卡数²)
620
+ const tagIndex = new Map()
621
+ for (const c of cards) for (const t of c.tags) {
622
+ const arr = tagIndex.get(t)
623
+ if (arr) arr.push(c.path)
624
+ else tagIndex.set(t, [c.path])
625
+ }
626
+ // 标签边:同标签「全连接」会爆炸 —— 593 卡时 2.4 万条边,占总 payload 的 99%,前端每帧都要遍历。
627
+ // 改为「按共享标签数取 Top-K」:语义仍是「靠共享标签相邻」,但边数降一个数量级。
628
+ // 对每张卡用标签倒排收集候选与其共享标签数,只保留权重最高的 K 个(双向去重后约 n*K/2 条)。
629
+ const TAG_TOPK = 6
630
+ const pairWeight = new Map() // `a\u0000b`(排序后)-> 已加边的共享标签数
631
+ for (const card of cards) {
632
+ const weights = new Map()
633
+ for (const tag of card.tags) {
634
+ for (const other of tagIndex.get(tag) || []) {
635
+ if (other === card.path) continue
636
+ weights.set(other, (weights.get(other) || 0) + 1)
637
+ }
638
+ }
639
+ // 候选不超过 K 时全连(小库/小标签簇),超过才截断 Top-K
640
+ const ranked = [...weights.entries()].sort((x, y) => y[1] - x[1])
641
+ const top = weights.size <= TAG_TOPK ? ranked : ranked.slice(0, TAG_TOPK)
642
+ for (const [other, w] of top) {
643
+ const [x, y] = [card.path, other].sort()
644
+ const key = `${x}\u0000${y}`
645
+ if ((pairWeight.get(key) || 0) >= w) continue // 已按更高权重加过这条边
646
+ pairWeight.set(key, w)
647
+ const xc = byPath.get(x)
648
+ const yc = byPath.get(y)
649
+ const shared = xc && yc ? xc.tags.find((t) => yc.tags.includes(t)) : ''
650
+ addEdge(x, y, shared ? `tag:${shared}` : 'tag')
651
+ }
652
+ }
653
+
654
+ // 相似度关联(P0-3 + P1-1):
655
+ // · bigram 一律用 32 位哈希入集合(构建与求交都比字符串快数倍,碰撞率可忽略);
656
+ // · 候选来自两个互补来源:① 稀有 gram 倒排(按 df 计数排序,免每卡 sort 比较器)
657
+ // ② MinHash + LSH 分桶(补足「共享 gram 都很常见」的近重复对);
658
+ // · 只对候选做**精确** Jaccard —— 判定语义与旧实现一致,只是不再全量配对。
659
+ const SIM_THRESHOLD = 0.42
660
+ const DF_CAP = 24
661
+ const RARE_CAP = 120
662
+ const CAND_CAP = 40
663
+ const MH_K = 48
664
+ const MH_BANDS = 24
665
+ const MH_ROWS = 2
666
+ const prof = process.env.MEMORY_GRAPH_PROFILE === '1'
667
+ let tStage = Date.now()
668
+ const hashesByPath = new Map()
669
+ const gramDf = new Map()
670
+ for (const c of cards) {
671
+ const s = bigramHashSet(bodyMap.get(c.path) || '')
672
+ hashesByPath.set(c.path, s)
673
+ for (const g of s) gramDf.set(g, (gramDf.get(g) || 0) + 1)
674
+ }
675
+ const tHash = Date.now() - tStage
676
+ // ① 稀有 gram 倒排:df ∈ [1, DF_CAP] 直接进桶(计数排序),再按桶取最稀的 RARE_CAP 个
677
+ tStage = Date.now()
678
+ const rareByPath = new Map()
679
+ for (const c of cards) {
680
+ const buckets = []
681
+ for (let df = 0; df <= DF_CAP; df++) buckets.push(null)
682
+ for (const g of hashesByPath.get(c.path)) {
683
+ const df = gramDf.get(g) || 0
684
+ if (df > DF_CAP) continue
685
+ if (!buckets[df]) buckets[df] = []
686
+ buckets[df].push(g)
687
+ }
688
+ const rare = []
689
+ for (let df = 1; df <= DF_CAP && rare.length < RARE_CAP; df++) {
690
+ const b = buckets[df]
691
+ if (!b) continue
692
+ for (const g of b) { rare.push(g); if (rare.length >= RARE_CAP) break }
693
+ }
694
+ rareByPath.set(c.path, rare)
695
+ }
696
+ const rareIndex = new Map()
697
+ for (const c of cards) {
698
+ for (const g of rareByPath.get(c.path)) {
699
+ const arr = rareIndex.get(g)
700
+ if (arr) arr.push(c.path)
701
+ else rareIndex.set(g, [c.path])
702
+ }
703
+ }
704
+ const tRare = Date.now() - tStage
705
+ // ② MinHash + LSH:签名只跑「稀有 gram」(≤120/卡),成本可控且正好覆盖近重复场景
706
+ tStage = Date.now()
707
+ const signatures = new Map()
708
+ for (const c of cards) signatures.set(c.path, minhashSignature(rareByPath.get(c.path), MH_K))
709
+ const lshCands = lshCandidates(signatures, { bands: MH_BANDS, rows: MH_ROWS, maxPerCard: CAND_CAP })
710
+ const tSig = Date.now() - tStage
711
+ tStage = Date.now()
712
+ const similarCount = {}
713
+ const tagSetByPath = new Map(cards.map((c) => [c.path, new Set(c.tags)]))
714
+ const merged = new Map()
715
+ let jaccardCalls = 0
716
+ for (const card of cards) {
717
+ merged.clear()
718
+ for (const gram of rareByPath.get(card.path)) {
719
+ for (const other of rareIndex.get(gram) || []) {
720
+ if (other !== card.path) merged.set(other, (merged.get(other) || 0) + 1)
721
+ }
722
+ }
723
+ for (const other of lshCands.get(card.path) || []) {
724
+ if (other !== card.path) merged.set(other, (merged.get(other) || 0) + 2)
725
+ }
726
+ if (!merged.size) continue
727
+ const ranked = [...merged.entries()].sort((x, y) => y[1] - x[1])
728
+ const cands = ranked.length <= CAND_CAP ? ranked : ranked.slice(0, CAND_CAP)
729
+ const A = hashesByPath.get(card.path)
730
+ for (const [other] of cands) {
731
+ if ((similarCount[card.path] || 0) >= 1) break
732
+ if ((similarCount[other] || 0) >= 1) continue
733
+ const b = byPath.get(other)
734
+ if (!b) continue
735
+ if (b.kind !== card.kind && !card.tags.some((t) => tagSetByPath.get(other).has(t))) continue
736
+ const B = hashesByPath.get(other)
737
+ const lo = Math.min(A.size, B.size)
738
+ const hi = Math.max(A.size, B.size)
739
+ if (!hi || lo / hi < SIM_THRESHOLD) continue // 无损上界:J ≤ min/max
740
+ jaccardCalls++
741
+ const sim = jaccardSets(A, B, SIM_THRESHOLD)
742
+ if (sim >= SIM_THRESHOLD) { addEdge(card.path, other, 'similar'); addDegree(card.path); addDegree(other); similarCount[card.path] = 1; similarCount[other] = 1; break }
743
+ }
744
+ }
745
+ const tCand = Date.now() - tStage
746
+ if (prof) console.error('[memory-eternal] graph profile: hash=' + tHash + 'ms rare=' + tRare + 'ms sig=' + tSig + 'ms cand=' + tCand + 'ms jaccardCalls=' + jaccardCalls)
747
+ const value = { nodes, edges }
748
+ graphCache.set(root, { fp, value, at: Date.now() })
749
+ writeGraphCache(root, fp, value)
750
+ if (graphCache.size > GRAPH_CACHE_MAX) graphCache.delete(graphCache.keys().next().value)
751
+ return value
752
+ }
753
+
754
+ /** 统计。 */
755
+ export async function stats(root) {
756
+ const db = getDb(root)
757
+ const totalRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved'").get()
758
+ const byKindRows = db.prepare("SELECT kind, COUNT(*) as cnt FROM cards WHERE status = 'approved' GROUP BY kind").all()
759
+ const byKind = Object.fromEntries(byKindRows.map((r) => [r.kind, r.cnt]))
760
+
761
+ const now = new Date()
762
+ const dayAgo = new Date(now.getTime() - 86400000).toISOString()
763
+ const weekAgo = new Date(now.getTime() - 7 * 86400000).toISOString()
764
+ const todayRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(dayAgo)
765
+ const weekRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(weekAgo)
766
+ const tagRows = db.prepare("SELECT tags FROM cards WHERE status = 'approved'").all()
767
+ const tagSet = new Set()
768
+ for (const r of tagRows) { for (const t of JSON.parse(r.tags || '[]')) tagSet.add(t) }
769
+
770
+ const todayCardsRows = db.prepare("SELECT * FROM cards WHERE status = 'approved' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 30").all(dayAgo)
771
+ const todayCards = todayCardsRows.map((row) => ({ path: row.path, title: row.title, kind: row.kind, updated: row.updated_at, summary: (row.summary || '').slice(0, 80) }))
772
+
773
+ return {
774
+ total: totalRow.cnt,
775
+ today: todayRow.cnt,
776
+ week: weekRow.cnt,
777
+ tags: tagSet.size,
778
+ byKind,
779
+ todayCards,
780
+ }
781
+ }
782
+
783
+ /** 跨库聚合图谱。 */
784
+ export async function graphAll(roots) {
785
+ const nodes = []; const edges = []; const idSet = new Set()
786
+ for (const r of roots) {
787
+ let g
788
+ try { g = await graph(r.root) } catch { continue }
789
+ const pref = r.name ? `${r.name}::` : ''
790
+ const map = new Map()
791
+ for (const n of g.nodes) { const id = pref + n.id; if (!idSet.has(id)) { idSet.add(id); map.set(n.id, id); nodes.push({ ...n, id }) } else map.set(n.id, id) }
792
+ for (const e of g.edges) { const s = map.get(e.source) || (pref + e.source), t = map.get(e.target) || (pref + e.target); if (s && t) edges.push({ source: s, target: t, type: e.type }) }
793
+ }
794
+ return { nodes, edges }
795
+ }
796
+
797
+ /** 跨库聚合检索。 */
798
+ export async function searchAll(roots, query, opts = {}) {
799
+ const out = []
800
+ for (const r of roots) {
801
+ let hits
802
+ try { hits = await search(r.root, query, opts) } catch { continue }
803
+ const pref = r.name ? `${r.name}::` : ''
804
+ for (const h of hits) out.push({ ...h, path: pref + h.path, score: (h.score || 0) + (r.name ? 0 : 0), profile: r.name || '' })
805
+ }
806
+ out.sort((a, b) => b.score - a.score || b.mtime - a.mtime)
807
+ return out.slice(0, opts.limit || 30)
808
+ }
809
+
810
+ /** 整理建议(非破坏性预览)。 */
811
+ export async function optimizeCandidates(root) {
812
+ const db = getDb(root)
813
+ const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
814
+ const cards = rows.map(rowToCard)
815
+ const bodyMap = new Map(rows.map((r) => [r.path, (r.body || '').slice(0, 4000)]))
816
+
817
+ const merge = []
818
+ for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
819
+ const a = cards[i], b = cards[j]
820
+ if (a.kind !== b.kind) continue
821
+ const sim = textSimilarity(bodyMap.get(a.path) || '', bodyMap.get(b.path) || '')
822
+ if (sim >= 0.55) merge.push({ a: { path: a.path, title: a.title }, b: { path: b.path, title: b.title }, sim: Math.round(sim * 100) / 100 })
823
+ }
824
+ merge.sort((x, y) => y.sim - x.sim)
825
+ const old = Date.now() - 90 * 86400000
826
+ const stale = cards.filter((c) => c.mtime < old).slice(0, 30).map((c) => ({ path: c.path, title: c.title, updated: c.updated }))
827
+ return { merge: merge.slice(0, 40), stale }
828
+ }
829
+
830
+ /** 一键优化执行。 */
831
+ export async function optimizeApply(root, opts = {}) {
832
+ const simThreshold = Number(opts.simThreshold ?? 0.55)
833
+ const staleDays = Number(opts.staleDays ?? 90)
834
+ const cleanupStale = Boolean(opts.cleanupStale)
835
+ const dryRun = Boolean(opts.dryRun)
836
+ const cands = await optimizeCandidates(root)
837
+ const merge = cands.merge
838
+ const stale = cands.stale
839
+ const merged = []
840
+ const staleDeleted = []
841
+ const staleSkipped = []
842
+ const errors = []
843
+ const used = new Set()
844
+ for (const pair of merge) {
845
+ if (used.has(pair.a.path) || used.has(pair.b.path)) continue
846
+ if (pair.sim < simThreshold) continue
847
+ if (!dryRun) {
848
+ try {
849
+ const r = await mergeCards(root, [pair.a.path, pair.b.path])
850
+ if (r && r.ok) {
851
+ merged.push({ from: [pair.a.path, pair.b.path], to: r.path, sim: pair.sim })
852
+ used.add(pair.a.path); used.add(pair.b.path)
853
+ } else { errors.push(`merge ${pair.a.path}+${pair.b.path}: ${r?.error || '失败'}`) }
854
+ } catch (e) { errors.push(`merge ${pair.a.path}+${pair.b.path}: ${e?.message || e}`) }
855
+ } else {
856
+ merged.push({ from: [pair.a.path, pair.b.path], to: '(dry-run)', sim: pair.sim })
857
+ used.add(pair.a.path); used.add(pair.b.path)
858
+ }
859
+ }
860
+ if (cleanupStale) {
861
+ for (const s of stale) {
862
+ if (!dryRun) {
863
+ try { await deleteCard(root, s.path); staleDeleted.push(s.path) } catch (e) { errors.push(`delete ${s.path}: ${e?.message || e}`); staleSkipped.push(s.path) }
864
+ } else { staleDeleted.push(s.path) }
865
+ }
866
+ } else {
867
+ for (const s of stale) staleSkipped.push(s.path)
868
+ }
869
+ return {
870
+ ok: true,
871
+ dryRun,
872
+ plan: { merged: merged.length, staleDeleted: staleDeleted.length, staleSkipped: staleSkipped.length },
873
+ merged,
874
+ staleDeleted,
875
+ staleSkipped,
876
+ errors,
877
+ }
878
+ }
879
+
880
+ /** 读取反馈记录。 */
881
+ export async function readFeedback(root) {
882
+ const db = getDb(root)
883
+ const rows = db.prepare('SELECT * FROM feedback ORDER BY created_at DESC').all()
884
+ return rows.map((r) => ({ ts: new Date(r.created_at).getTime(), query: r.query, path: r.card_path, useful: r.useful }))
885
+ }
886
+
887
+ /** 添加反馈。 */
888
+ export async function addFeedback(root, rec) {
889
+ const db = getDb(root)
890
+ db.prepare('INSERT INTO feedback (query, card_path, useful) VALUES (?, ?, ?)').run(
891
+ rec.query || '',
892
+ rec.path || rec.card_path || '',
893
+ rec.useful ? 1 : 0,
894
+ )
895
+ return { ok: true }
896
+ }
897
+
898
+ /** 统计:按 kind + status。 */
899
+ export async function overview(root) {
900
+ const db = getDb(root)
901
+ const cards = await listCards(root, { status: ['approved', 'pending', 'rejected'] })
902
+ const byKind = {}
903
+ const statusCounts = {}
904
+ for (const c of cards) { byKind[c.kind] = (byKind[c.kind] || 0) + 1; statusCounts[c.status] = (statusCounts[c.status] || 0) + 1 }
905
+ const week = Date.now() - 7 * 86400000
906
+ const recent = cards.filter((c) => c.mtime > week).length
907
+ const tagSet = new Set()
908
+ for (const c of cards) for (const t of c.tags) tagSet.add(t)
909
+ return {
910
+ total: cards.length,
911
+ byKind,
912
+ recent,
913
+ tags: tagSet.size,
914
+ status: statusCounts,
915
+ roots: Object.fromEntries(Object.entries(KIND_ROOTS).map(([k, v]) => [k, `${v}/`])),
916
+ }
917
+ }
918
+
919
+ // -- helpers ---------------------------------------------------------------
920
+
921
+ function resolveInside(root, rel) {
922
+ const target = path.resolve(root, rel)
923
+ const rootResolved = path.resolve(root)
924
+ if (target !== rootResolved && !target.startsWith(rootResolved + path.sep)) return null
925
+ return target
926
+ }
927
+
928
+ async function exists(p) {
929
+ try {
930
+ const { promises: fs } = await import('node:fs')
931
+ await fs.access(p)
932
+ return true
933
+ } catch {
934
+ return false
935
+ }
936
+ }
937
+
938
+ function yamlString(value) {
939
+ const s = String(value ?? '')
940
+ return /[:#\[\]{}"',&*!|>%@`]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
941
+ }