memory-eternal 0.1.1 → 0.2.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/index.js CHANGED
@@ -26,6 +26,7 @@ const versionRef = (() => { try { const p = JSON.parse(readFileSync(path.join(PA
26
26
  import z from '@deepseek-ai/schemastery'
27
27
  import { defineTool } from '@deepseek-ai/dsh-tools'
28
28
  import { ensureVault, search, generateDailyBrief } from './lib/vault.js'
29
+ import { migrateFromMarkdown } from './lib/db.js'
29
30
  import { summarizeTurn, extractLastTurn, sliceNewEvents, resolveRoute, captureCard, captureUpdate, pickNeighbors } from './lib/capture.js'
30
31
  import { createApi, json } from './lib/api.js'
31
32
 
@@ -96,6 +97,7 @@ const DSH_AGENT = 'deepseek-harness'
96
97
  export function apply(ctx, config) {
97
98
  const settings = ctx.settings.register('memory-eternal', Config, { base: config ?? {} })
98
99
 
100
+ // 首次激活:自动从 .md 文件迁移到 SQLite(幂等,已有数据则跳过)
99
101
  const vaultDir = () => {
100
102
  const cfg = settings.get() ?? {}
101
103
  // 多 Vault:若配了 vaultProfiles 且选中了 activeVault,则用该 profile 的目录。
@@ -108,6 +110,9 @@ export function apply(ctx, config) {
108
110
  return path.join(home, 'memory-vault')
109
111
  }
110
112
 
113
+ // 自动迁移:从 .md 文件导入 SQLite(幂等,DB 有数据则跳过)
114
+ try { migrateFromMarkdown(vaultDir()).catch(() => {}) } catch {}
115
+
111
116
  // 所有 profile 目录(当前激活 + 其余命名的),供跨库聚合。
112
117
  const vaultRoots = () => {
113
118
  const cfg = settings.get() ?? {}
package/lib/capture.js CHANGED
@@ -113,8 +113,8 @@ export async function pickNeighbors(root, card, limit = DEDUP_NEIGHBORS) {
113
113
  * 去重守卫:目标目录里已有高度相似卡片时返回它,否则返回 null。
114
114
  * @returns Promise<null | { path, score }>
115
115
  */
116
- export function makeDedupChecker(dir) {
117
- return (text, threshold = DEDUP_THRESHOLD) => dedupCheck(dir, text, threshold)
116
+ export function makeDedupChecker(root) {
117
+ return (text, threshold = DEDUP_THRESHOLD) => dedupCheck(root, text, threshold)
118
118
  }
119
119
 
120
120
  /** 写卡(带目录级去重守卫),命中返回 duplicate 而非写卡。 */
package/lib/db.js ADDED
@@ -0,0 +1,126 @@
1
+ // 记忆核心 · SQLite 存储层(node:sqlite 内置,零依赖)
2
+ //
3
+ // 4 张表:config / cards / card_updates / feedback
4
+ // 首次启动自动从 .md 文件迁移(幂等)
5
+
6
+ import { DatabaseSync } from 'node:sqlite'
7
+ import { promises as fs, mkdirSync } from 'node:fs'
8
+ import path from 'node:path'
9
+ import { parseCard, KIND_ROOTS } from './vault.js'
10
+
11
+ const DB_FILE = 'memory-eternal.db'
12
+
13
+ /** 获取或创建 SQLite 数据库连接(单例 per root)。 */
14
+ const instances = new Map()
15
+ export function getDb(root) {
16
+ const resolved = path.resolve(root)
17
+ if (instances.has(resolved)) return instances.get(resolved)
18
+ // 确保目录存在(SQLite 需要目录才能创建 .db 文件)
19
+ mkdirSync(resolved, { recursive: true })
20
+ const dbPath = path.join(resolved, DB_FILE)
21
+ const db = new DatabaseSync(dbPath)
22
+ db.exec('PRAGMA journal_mode=WAL')
23
+ db.exec('PRAGMA foreign_keys=ON')
24
+ initTables(db)
25
+ instances.set(resolved, db)
26
+ return db
27
+ }
28
+
29
+ function initTables(db) {
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS config (
32
+ key TEXT PRIMARY KEY,
33
+ value TEXT NOT NULL,
34
+ updated_at TEXT DEFAULT (datetime('now'))
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS cards (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ path TEXT UNIQUE NOT NULL,
40
+ kind TEXT NOT NULL DEFAULT 'knowledge',
41
+ title TEXT NOT NULL DEFAULT '',
42
+ tags TEXT DEFAULT '[]',
43
+ body TEXT NOT NULL DEFAULT '',
44
+ summary TEXT DEFAULT '',
45
+ status TEXT NOT NULL DEFAULT 'pending',
46
+ source TEXT DEFAULT '',
47
+ submitted_by TEXT DEFAULT '',
48
+ severity TEXT DEFAULT 'info',
49
+ reason TEXT DEFAULT '',
50
+ created_at TEXT DEFAULT (datetime('now')),
51
+ updated_at TEXT DEFAULT (datetime('now')),
52
+ deleted_at TEXT
53
+ );
54
+
55
+ CREATE INDEX IF NOT EXISTS idx_cards_status ON cards(status);
56
+ CREATE INDEX IF NOT EXISTS idx_cards_kind ON cards(kind);
57
+ CREATE INDEX IF NOT EXISTS idx_cards_deleted ON cards(deleted_at);
58
+
59
+ CREATE TABLE IF NOT EXISTS card_updates (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ card_id INTEGER NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
62
+ content TEXT NOT NULL,
63
+ created_at TEXT DEFAULT (datetime('now'))
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS feedback (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ query TEXT NOT NULL,
69
+ card_path TEXT NOT NULL,
70
+ useful INTEGER NOT NULL DEFAULT 0,
71
+ created_at TEXT DEFAULT (datetime('now'))
72
+ );
73
+ `)
74
+ }
75
+
76
+ /** 从 .md 文件迁移到 SQLite(幂等:已存在的卡跳过)。 */
77
+ export async function migrateFromMarkdown(root) {
78
+ const db = getDb(root)
79
+ const existing = db.prepare('SELECT COUNT(*) as cnt FROM cards').get()
80
+ if (existing.cnt > 0) return { migrated: 0, skipped: existing.cnt }
81
+
82
+ let migrated = 0
83
+ const walk = async (dir, base) => {
84
+ let ents = []
85
+ try { ents = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
86
+ for (const ent of ents) {
87
+ const full = path.join(dir, ent.name)
88
+ if (ent.isDirectory()) { await walk(full, base); continue }
89
+ if (!ent.name.endsWith('.md')) continue
90
+ try {
91
+ const text = await fs.readFile(full, 'utf8')
92
+ const { meta, body, summary } = parseCard(text)
93
+ const rel = path.relative(base, full).split(path.sep).join('/')
94
+ const stat = await fs.stat(full)
95
+ db.prepare(`
96
+ INSERT OR IGNORE INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
97
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
98
+ `).run(
99
+ rel,
100
+ meta.kind || 'knowledge',
101
+ meta.title || rel.replace(/\.[^.]*$/, '').replace(/^[^/]*\//, ''),
102
+ JSON.stringify(meta.tags || []),
103
+ body.trim(),
104
+ summary,
105
+ meta.status || 'pending',
106
+ meta.source || '',
107
+ meta.submittedBy || '',
108
+ meta.severity || 'info',
109
+ meta.reason || '',
110
+ meta.created || stat.mtime.toISOString(),
111
+ meta.updated || stat.mtime.toISOString(),
112
+ meta.deletedAt || null,
113
+ )
114
+ migrated++
115
+ } catch { /* 跳过坏文件 */ }
116
+ }
117
+ }
118
+ await walk(root, root)
119
+ return { migrated, skipped: 0 }
120
+ }
121
+
122
+ export function closeDb(root) {
123
+ const resolved = path.resolve(root)
124
+ const db = instances.get(resolved)
125
+ if (db) { db.close(); instances.delete(resolved) }
126
+ }
package/lib/vault.js CHANGED
@@ -1,15 +1,10 @@
1
- // 记忆核心 · Markdown Vault 存储层
1
+ // 记忆核心 · SQLite Vault 存储层
2
2
  //
3
- // 从 boujoy-harness 的记忆模块移植(web/boujoy_server.py 的知识库部分):
4
- // - 卡片 = 带 YAML frontmatter 的 Markdown 文件,落在 02-06 主题目录;
5
- // - 去重 = Jaccard 字符 bigram 相似度(阈值默认 0.62),命中后拒绝新建并返回原卡;
6
- // - 检索 = CJK 感知:整词 + 字符 bigram 命中(无需全文引擎);
7
- // - 图谱 = 卡片之间的 [[wikilink]] 与共享标签连线。
8
- //
9
- // 本文件不依赖 DSH 运行时,可单独单测。
3
+ // 纯工具函数保持不变;IO 函数全部改用 SQL。
4
+ // 底层依赖 lib/db.js 的 getDb(root) → DatabaseSync 实例。
10
5
 
11
- import { promises as fs } from 'node:fs'
12
6
  import path from 'node:path'
7
+ import { getDb } from './db.js'
13
8
 
14
9
  /** 主题目录根。 */
15
10
  export const KIND_ROOTS = {
@@ -26,9 +21,6 @@ export const CAPTURE_KINDS = ['project', 'knowledge', 'content', 'prompt', 'busi
26
21
 
27
22
  /** 从 Markdown 文本提取 frontmatter 与正文。 */
28
23
  export function parseCard(text) {
29
- // formatVersion 守卫:读侧始终向后兼容——旧卡无此字段 = v0 按旧逻辑读;
30
- // 未来结构变更时升 FORMAT_VERSION 并在读侧加迁移分支,多副本环境
31
- // (DSH profile + 全局)版本漂移不会互相写坏库。
32
24
  const meta = { title: '', kind: 'knowledge', tags: [], created: '', updated: '', source: '', formatVersion: 0, status: 'pending', submittedBy: '', severity: 'info', reason: '', deletedAt: '' }
33
25
  let body = text
34
26
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text)
@@ -51,9 +43,7 @@ export function parseCard(text) {
51
43
  }
52
44
  }
53
45
  }
54
- // 审核中心为最高控制:无 status 字段的卡(含 agent 直写 vault 的绕过)一律视为待审核。
55
46
  if (!meta.status) meta.status = 'pending'
56
- // 标题:frontmatter 的 title 优先,否则取第一个 # 标题,否则取首行。
57
47
  if (!meta.title) {
58
48
  const h = /^#\s+(.+)$/m.exec(body)
59
49
  meta.title = h ? h[1].trim() : body.trim().split(/\r?\n/)[0].slice(0, 60)
@@ -74,7 +64,7 @@ export function safeSlug(name) {
74
64
  return base || 'card'
75
65
  }
76
66
 
77
- /** Jaccard-like 相似度:字符 bigram 集合(移植 boujoy _text_similarity)。 */
67
+ /** Jaccard-like 相似度:字符 bigram 集合。 */
78
68
  export function textSimilarity(a, b) {
79
69
  const bigrams = (text) => {
80
70
  const cleaned = String(text)
@@ -93,31 +83,7 @@ export function textSimilarity(a, b) {
93
83
  return inter / (ba.size + bb.size - inter)
94
84
  }
95
85
 
96
- /** 在目标目录中找与候选文本最相似的现有卡(去重守卫)。比较正文(剔除 frontmatter)。 */
97
- export async function dedupCheck(dir, text, threshold = 0.62) {
98
- let best = null
99
- let entries = []
100
- try {
101
- entries = await fs.readdir(dir)
102
- } catch {
103
- return null
104
- }
105
- for (const name of entries) {
106
- if (!name.toLowerCase().endsWith('.md')) continue
107
- try {
108
- const existing = await fs.readFile(path.join(dir, name), 'utf8')
109
- // 只比较正文,避免 frontmatter 稀释相似度
110
- const { body } = parseCard(existing)
111
- const score = textSimilarity(text, body || existing)
112
- if (score >= threshold && (!best || score > best.score)) best = { path: name, score }
113
- } catch {
114
- // 跳过不可读文件
115
- }
116
- }
117
- return best
118
- }
119
-
120
- /** CJK 感知查询词:整词 + 中文字符 bigram(移植 boujoy query_terms)。 */
86
+ /** CJK 感知查询词:整词 + 中文字符 bigram。 */
121
87
  export function queryTerms(query) {
122
88
  const tokens = new Set(String(query).split(/[^\w\u4e00-\u9fff]+/).filter(Boolean))
123
89
  for (let i = 0; i < query.length - 1; i++) {
@@ -128,349 +94,262 @@ export function queryTerms(query) {
128
94
  return tokens
129
95
  }
130
96
 
131
- // 目录名 → kind 推断(直写/非标准目录也归入可读集合)
97
+ /** 在目标目录中找与候选文本最相似的现有卡(去重守卫)。比较正文。 */
98
+ export async function dedupCheck(root, text, threshold = 0.62) {
99
+ const db = getDb(root)
100
+ const rows = db.prepare('SELECT path, body FROM cards WHERE deleted_at IS NULL').all()
101
+ let best = null
102
+ for (const row of rows) {
103
+ const score = textSimilarity(text, row.body || '')
104
+ if (score >= threshold && (!best || score > best.score)) best = { path: row.path, score }
105
+ }
106
+ return best
107
+ }
108
+
109
+ // 目录名 → kind 推断
132
110
  function kindFromDir(topDir) {
133
111
  for (const [k, v] of Object.entries(KIND_ROOTS)) { if (v.toLowerCase() === String(topDir || '').toLowerCase()) return k }
134
112
  return 'knowledge'
135
113
  }
136
114
 
137
- /** 递归列出一个目录及其子孙目录下所有 *.md(相对路径,正斜杠)。 */
138
- async function walkAll(dir, base = dir, out = []) {
139
- let ents = []
140
- try { ents = await fs.readdir(dir, { withFileTypes: true }) } catch { return out }
141
- for (const ent of ents) {
142
- const full = path.join(dir, ent.name)
143
- if (ent.isDirectory()) await walkAll(full, base, out)
144
- else if (ent.name.endsWith('.md')) out.push(path.relative(base, full).split(path.sep).join('/'))
145
- }
146
- return out
115
+ /** 确保 vault 目录结构存在(即确保 DB 存在)。 */
116
+ export async function ensureVault(root) {
117
+ // getDb 会自动建表
118
+ getDb(root)
119
+ }
120
+
121
+ /** 将 SQL 行还原为调用方期望的卡片摘要对象。 */
122
+ function rowToCard(row) {
123
+ return {
124
+ path: row.path,
125
+ kind: row.kind,
126
+ title: row.title,
127
+ tags: JSON.parse(row.tags || '[]'),
128
+ summary: row.summary || '',
129
+ created: row.created_at,
130
+ updated: row.updated_at,
131
+ mtime: new Date(row.updated_at).getTime(),
132
+ status: row.status,
133
+ submittedBy: row.submitted_by || '',
134
+ severity: row.severity || 'info',
135
+ reason: row.reason || '',
136
+ deletedAt: row.deleted_at || '',
137
+ }
138
+ }
139
+
140
+ /** 将 SQL 行还原为完整 Markdown 文本(frontmatter + body)。 */
141
+ function rowToMarkdown(row) {
142
+ const tags = JSON.parse(row.tags || '[]')
143
+ const lines = [
144
+ '---',
145
+ `formatVersion: 1`,
146
+ `kind: ${row.kind}`,
147
+ `title: ${yamlString(row.title)}`,
148
+ `tags: [${tags.map((t) => yamlString(t)).join(', ')}]`,
149
+ `created: ${row.created_at}`,
150
+ `updated: ${row.updated_at}`,
151
+ `status: ${row.status}`,
152
+ ...(row.deleted_at ? [`deletedAt: ${row.deleted_at}`] : []),
153
+ ...(row.submitted_by ? [`submittedBy: ${yamlString(row.submitted_by)}`] : []),
154
+ ...(row.severity && row.severity !== 'info' ? [`severity: ${row.severity}`] : []),
155
+ ...(row.reason ? [`reason: ${yamlString(row.reason)}`] : []),
156
+ ...(row.source ? [`source: ${row.source}`] : []),
157
+ '---',
158
+ '',
159
+ `# ${row.title}`,
160
+ '',
161
+ row.body.trim(),
162
+ '',
163
+ ]
164
+ // 附加 card_updates
165
+ return { text: lines.join('\n'), tags }
147
166
  }
148
167
 
149
- /** 递归列出 vault 下所有卡片(整个根目录 *.md,含直写到任意子目录的卡),返回解析后的卡摘要。 */
168
+ /** 列出卡片(从 SQL)。 */
150
169
  export async function listCards(root, { status } = {}) {
151
- // 默认 status=['approved'](主库视图/统计/检索只用已批卡);audit/recycle 显式传 ['pending','rejected'] / ['deleted']
152
170
  const want = status ?? ['approved']
153
- const cards = []
154
- const files = await walkAll(root)
155
- for (const rel of files) {
156
- try {
157
- const full = path.join(root, rel)
158
- const text = await fs.readFile(full, 'utf8')
159
- const { meta, body, summary } = parseCard(text)
160
- const stat = await fs.stat(full)
161
- const s = meta.status || 'approved'
162
- if (want && !want.includes(s)) continue
163
- const topDir = rel.split('/')[0]
164
- cards.push({
165
- path: rel,
166
- kind: meta.kind || kindFromDir(topDir),
167
- title: meta.title || rel.replace(/\.[^.]*$/, '').replace(/^[^/]*\//, ''),
168
- tags: meta.tags,
169
- summary,
170
- created: meta.created,
171
- updated: meta.updated || stat.mtime.toISOString(),
172
- mtime: stat.mtimeMs,
173
- status: s,
174
- submittedBy: meta.submittedBy || '',
175
- severity: meta.severity || 'info',
176
- reason: meta.reason || '',
177
- deletedAt: meta.deletedAt || '',
178
- })
179
- } catch {
180
- // 跳过坏文件
181
- }
182
- }
183
- cards.sort((a, b) => b.mtime - a.mtime)
184
- return cards
171
+ const db = getDb(root)
172
+ const placeholders = want.map(() => '?').join(',')
173
+ const rows = db.prepare(`SELECT * FROM cards WHERE status IN (${placeholders}) ORDER BY updated_at DESC`).all(...want)
174
+ return rows.map(rowToCard)
185
175
  }
186
176
 
187
- /** 审核队列(待审核卡):status=pending。 */
177
+ /** 审核队列:status=pending 或 rejected。 */
188
178
  export async function auditQueue(root) {
189
179
  const cards = await listCards(root, { status: ['pending', 'rejected'] })
190
180
  return { pending: cards.filter((c) => c.status === 'pending'), rejected: cards.filter((c) => c.status === 'rejected') }
191
181
  }
192
182
 
193
- /** 回收站(软删卡):status=deleted,按删除时间倒序。 */
183
+ /** 回收站:status=deleted,按 deleted_at 倒序。 */
194
184
  export async function recycleList(root) {
195
- const cards = await listCards(root, { status: ['deleted'] })
196
- return cards.sort((a, b) => new Date(b.deletedAt || 0) - new Date(a.deletedAt || 0))
185
+ const db = getDb(root)
186
+ const rows = db.prepare('SELECT * FROM cards WHERE status = ? ORDER BY deleted_at DESC').all('deleted')
187
+ return rows.map(rowToCard)
197
188
  }
198
189
 
199
190
  /** 清理超过 retentionDays 天的回收卡(永久删除)。 */
200
191
  export async function purgeExpired(root, retentionDays = 30) {
201
- const cut = Date.now() - retentionDays * 86400000
202
- const items = await recycleList(root)
192
+ const db = getDb(root)
193
+ const cut = new Date(Date.now() - retentionDays * 86400000).toISOString()
194
+ const rows = db.prepare('SELECT path FROM cards WHERE status = ? AND deleted_at IS NOT NULL AND deleted_at < ?').all('deleted', cut)
203
195
  let purged = 0
204
- for (const c of items) {
205
- const d = new Date(c.deletedAt || 0).getTime()
206
- if (d && d < cut) {
207
- try { await deleteCard(root, c.path, { permanent: true }); purged++ } catch {}
208
- }
196
+ for (const row of rows) {
197
+ try { await deleteCard(root, row.path, { permanent: true }); purged++ } catch {}
209
198
  }
210
199
  return { ok: true, purged }
211
200
  }
212
201
 
213
- async function walkMd(dir) {
214
- const out = []
215
- let entries
216
- try {
217
- entries = await fs.readdir(dir, { withFileTypes: true })
218
- } catch {
219
- return out
220
- }
221
- for (const ent of entries) {
222
- const rel = path.join('.', ent.name)
223
- if (ent.isDirectory()) {
224
- out.push(...(await walkMd(path.join(dir, ent.name))).map((f) => path.join(rel, f)))
225
- } else if (ent.name.toLowerCase().endsWith('.md')) {
226
- out.push(rel.replace(/\\/g, '/'))
227
- }
228
- }
229
- return out
230
- }
231
-
232
- /** 确保 vault 目录结构存在(00-System + 02-08)。 */
233
- export async function ensureVault(root) {
234
- await fs.mkdir(path.join(root, '00-System'), { recursive: true })
235
- for (const dir of Object.values(KIND_ROOTS)) {
236
- await fs.mkdir(path.join(root, dir), { recursive: true })
237
- }
238
- }
239
-
240
- /** 读取一张卡(相对路径,安全限定在 vault 内)。 */
202
+ /** 读取一张卡(返回 Markdown 文本,兼容旧调用方)。 */
241
203
  export async function readCard(root, rel) {
242
- const target = resolveInside(root, rel)
243
- if (!target) throw new Error('路径越界')
244
- return fs.readFile(target, 'utf8')
245
- }
246
-
247
- /** 合并多张卡为一张(保留第一张的 kind/title 前缀「(合并)」,并集标签,用 --- 分隔正文,删原卡)。 */
248
- export async function mergeCards(root, paths) {
249
- const list = (Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean)
250
- if (list.length < 2) return { ok: false, error: '至少选 2 张卡' }
251
- const texts = []
252
- for (const p of list) {
253
- try { texts.push({ path: p, text: await readCard(root, p) }) } catch { /* 跳过读不到的 */ }
204
+ const db = getDb(root)
205
+ const row = db.prepare('SELECT * FROM cards WHERE path = ?').get(rel)
206
+ if (!row) throw new Error('卡片不存在: ' + rel)
207
+ const { text } = rowToMarkdown(row)
208
+ // 附加更新记录
209
+ const updates = db.prepare('SELECT content, created_at FROM card_updates WHERE card_id = ? ORDER BY created_at ASC').all(row.id)
210
+ if (updates.length > 0) {
211
+ const updateLines = updates.map((u) => `- ${u.created_at.slice(0, 10)}:${u.content}`)
212
+ return text + '\n## 更新记录\n\n' + updateLines.join('\n') + '\n'
254
213
  }
255
- if (texts.length < 2) return { ok: false, error: '读取失败' }
256
- const first = parseCard(texts[0].text)
257
- const tags = new Set(first.meta.tags || [])
258
- let combined = ''
259
- texts.forEach((t, i) => {
260
- let body = t.text
261
- try { const p = parseCard(t.text); body = p.body; (p.meta.tags || []).forEach((x) => tags.add(x)) } catch {}
262
- combined += (i ? '\n\n---\n\n' : '') + body.trim()
263
- })
264
- const title = (first.meta.title || '合并记忆') + '(合并)'
265
- const r = await writeCard(root, { kind: first.meta.kind || 'knowledge', title, tags: [...tags], body: combined, source: 'merge', status: 'approved' }, { dedup: false })
266
- if (!r.ok) return { ok: false, error: '写入失败' }
267
- for (const t of texts) { try { await deleteCard(root, t.path, { permanent: true }) } catch { /* 尽力删除 */ } }
268
- return { ok: true, path: r.path, merged: texts.length }
214
+ return text
269
215
  }
270
216
 
271
- /** 软删卡片:标记 status=deleted + deletedAt(进回收站,文件保留可恢复)。不再立即 unlink。 */
217
+ /** 软删卡片:status=deleted + deleted_at。 */
272
218
  export async function deleteCard(root, rel, { permanent = false } = {}) {
273
- const target = resolveInside(root, rel)
274
- if (!target) throw new Error('路径越界')
219
+ const db = getDb(root)
275
220
  if (permanent) {
276
- await fs.unlink(target)
277
- const dir = path.dirname(target)
278
- try { await fs.rmdir(dir) } catch { /* 目录非空则保留 */ }
221
+ db.prepare('DELETE FROM cards WHERE path = ?').run(rel)
279
222
  return { ok: true, permanent: true }
280
223
  }
281
- // 软删:把 frontmatter status 改 deleted + deletedAt
282
- const text = await fs.readFile(target, 'utf8')
283
- const { meta, body } = parseCard(text)
284
224
  const now = new Date().toISOString()
285
- const rewritten = [
286
- '---',
287
- `formatVersion: 1`,
288
- `kind: ${meta.kind || 'knowledge'}`,
289
- `title: ${yamlString(meta.title)}`,
290
- `tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
291
- `created: ${meta.created || now}`,
292
- `updated: ${now}`,
293
- `status: deleted`,
294
- `deletedAt: ${now}`,
295
- ...(meta.submittedBy ? [`submittedBy: ${yamlString(meta.submittedBy)}`] : []),
296
- ...(meta.severity ? [`severity: ${meta.severity}`] : []),
297
- ...(meta.reason ? [`reason: ${yamlString(meta.reason)}`] : []),
298
- ...(meta.source ? [`source: ${meta.source}`] : []),
299
- '---',
300
- '',
301
- body.trim(),
302
- '',
303
- ].join('\n')
304
- await fs.writeFile(target, rewritten, 'utf8')
225
+ const info = db.prepare('UPDATE cards SET status = ?, deleted_at = ?, updated_at = ? WHERE path = ?').run('deleted', now, now, rel)
226
+ if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
305
227
  return { ok: true, soft: true }
306
228
  }
307
229
 
308
- /** 恢复软删卡:status=approved,清除 deletedAt。 */
230
+ /** 恢复软删卡:status=approved,清除 deleted_at。 */
309
231
  export async function restoreCard(root, rel) {
310
- const target = resolveInside(root, rel)
311
- if (!target) throw new Error('路径越界')
312
- const text = await fs.readFile(target, 'utf8')
313
- const { meta, body } = parseCard(text)
232
+ const db = getDb(root)
314
233
  const now = new Date().toISOString()
315
- const rewritten = [
316
- '---',
317
- `formatVersion: 1`,
318
- `kind: ${meta.kind || 'knowledge'}`,
319
- `title: ${yamlString(meta.title)}`,
320
- `tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
321
- `created: ${meta.created || now}`,
322
- `updated: ${now}`,
323
- `status: approved`,
324
- ...(meta.submittedBy ? [`submittedBy: ${yamlString(meta.submittedBy)}`] : []),
325
- ...(meta.severity ? [`severity: ${meta.severity}`] : []),
326
- ...(meta.reason ? [`reason: ${yamlString(meta.reason)}`] : []),
327
- ...(meta.source ? [`source: ${meta.source}`] : []),
328
- '---',
329
- '',
330
- body.trim(),
331
- '',
332
- ].join('\n')
333
- await fs.writeFile(target, rewritten, 'utf8')
234
+ const info = db.prepare('UPDATE cards SET status = ?, deleted_at = NULL, updated_at = ? WHERE path = ?').run('approved', now, rel)
235
+ if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
334
236
  return { ok: true, restored: true }
335
237
  }
336
238
 
337
- /** 更新卡状态(审核批准/驳回)。 */
239
+ /** 更新卡状态。 */
338
240
  export async function setCardStatus(root, rel, status) {
339
- const target = resolveInside(root, rel)
340
- if (!target) throw new Error('路径越界')
341
- const text = await fs.readFile(target, 'utf8')
342
- const { meta, body } = parseCard(text)
241
+ const db = getDb(root)
343
242
  const now = new Date().toISOString()
344
- const rewritten = [
345
- '---',
346
- `formatVersion: 1`,
347
- `kind: ${meta.kind || 'knowledge'}`,
348
- `title: ${yamlString(meta.title)}`,
349
- `tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
350
- `created: ${meta.created || now}`,
351
- `updated: ${now}`,
352
- `status: ${status}`,
353
- ...(meta.submittedBy ? [`submittedBy: ${yamlString(meta.submittedBy)}`] : []),
354
- ...(meta.severity ? [`severity: ${meta.severity}`] : []),
355
- ...(meta.reason ? [`reason: ${yamlString(meta.reason)}`] : []),
356
- ...(meta.source ? [`source: ${meta.source}`] : []),
357
- '---',
358
- '',
359
- body.trim(),
360
- '',
361
- ].join('\n')
362
- await fs.writeFile(target, rewritten, 'utf8')
243
+ const info = db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE path = ?').run(status, now, rel)
244
+ if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
363
245
  return { ok: true, status }
364
246
  }
365
247
 
366
- /** 原子写卡;先去重(target 目录内),命中返回 {duplicate}。 */
367
- export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'approved', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true } = {}) {
248
+ /** 原子写卡;先去重,命中返回 {duplicate}。 */
249
+ export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true } = {}) {
250
+ const db = getDb(root)
368
251
  const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
369
- const dir = path.join(root, kindRoot)
370
- await fs.mkdir(dir, { recursive: true })
252
+
371
253
  if (dedup) {
372
- const hit = await dedupCheck(dir, body, threshold)
373
- if (hit) return { ok: false, duplicate: { ...hit, path: `${kindRoot}/${hit.path}` } }
254
+ const hit = await dedupCheck(root, body, threshold)
255
+ if (hit) return { ok: false, duplicate: { ...hit, path: hit.path } }
374
256
  }
257
+
375
258
  const slug = safeSlug(title)
376
- let rel = `${slug}.md`
259
+ let rel = `${kindRoot}/${slug}.md`
377
260
  let index = 2
378
- while (await exists(path.join(dir, rel))) {
379
- rel = `${slug}-${index}.md`
261
+ while (db.prepare('SELECT 1 FROM cards WHERE path = ?').get(rel)) {
262
+ rel = `${kindRoot}/${slug}-${index}.md`
380
263
  index++
381
264
  }
265
+
382
266
  const now = new Date().toISOString()
383
- const text = [
384
- '---',
385
- `formatVersion: 1`,
386
- `kind: ${kind}`,
387
- `title: ${yamlString(title)}`,
388
- `tags: [${tags.map((t) => yamlString(t)).join(', ')}]`,
389
- `created: ${now}`,
390
- `updated: ${now}`,
391
- `status: ${status}`,
392
- ...(deletedAt ? [`deletedAt: ${deletedAt}`] : []),
393
- ...(submittedBy ? [`submittedBy: ${yamlString(submittedBy)}`] : []),
394
- ...(severity ? [`severity: ${severity}`] : []),
395
- ...(reason ? [`reason: ${yamlString(reason)}`] : []),
396
- ...(source ? [`source: ${source}`] : []),
397
- '---',
398
- '',
399
- `# ${title}`,
400
- '',
267
+ const summary = body.trim().slice(0, 200)
268
+ db.prepare(`
269
+ INSERT INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
270
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
271
+ `).run(
272
+ rel,
273
+ kind,
274
+ title,
275
+ JSON.stringify(tags),
401
276
  body.trim(),
402
- '',
403
- ].join('\n')
404
- const target = path.join(dir, rel)
405
- const tmp = target + '.tmp'
406
- await fs.writeFile(tmp, text, 'utf8')
407
- await fs.rename(tmp, target)
408
- return { ok: true, path: `${kindRoot}/${rel}`, kind }
409
- }
410
-
411
- /** 向已存在的卡追加「更新记录」段(去重命中时的正确动作,移植 boujoy 语义)。 */
412
- export async function appendUpdate(root, rel, updateText, { threshold = 0.62 } = {}) {
413
- const target = resolveInside(root, rel)
414
- if (!target) throw new Error('路径越界')
415
- const existing = await fs.readFile(target, 'utf8')
416
- const { meta, body } = parseCard(existing)
277
+ summary,
278
+ status,
279
+ source,
280
+ submittedBy,
281
+ severity,
282
+ reason,
283
+ now,
284
+ now,
285
+ deletedAt || null,
286
+ )
287
+ return { ok: true, path: rel, kind }
288
+ }
289
+
290
+ /** 向已存在的卡追加更新记录。 */
291
+ export async function appendUpdate(root, rel, updateText) {
292
+ const db = getDb(root)
293
+ const row = db.prepare('SELECT id FROM cards WHERE path = ?').get(rel)
294
+ if (!row) throw new Error('卡片不存在: ' + rel)
417
295
  const now = new Date().toISOString()
418
- const updated = [
419
- '---',
420
- `formatVersion: 1`,
421
- `kind: ${meta.kind || 'knowledge'}`,
422
- `title: ${yamlString(meta.title)}`,
423
- `tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
424
- `created: ${meta.created || now}`,
425
- `updated: ${now}`,
426
- `status: ${meta.status || 'pending'}`,
427
- ...(meta.submittedBy ? [`submittedBy: ${yamlString(meta.submittedBy)}`] : []),
428
- ...(meta.severity ? [`severity: ${meta.severity}`] : []),
429
- ...(meta.reason ? [`reason: ${yamlString(meta.reason)}`] : []),
430
- ...(meta.source ? [`source: ${meta.source}`] : []),
431
- ...(meta.deletedAt ? [`deletedAt: ${meta.deletedAt}`] : []),
432
- '---',
433
- '',
434
- body.trim(),
435
- '',
436
- '## 更新记录',
437
- '',
438
- `- ${now.slice(0, 10)}:${updateText.trim()}`,
439
- '',
440
- ].join('\n')
441
- const tmp = target + '.tmp'
442
- await fs.writeFile(tmp, updated, 'utf8')
443
- await fs.rename(tmp, target)
444
- return { ok: true, path: rel, kind: meta.kind || 'knowledge', updated: now }
296
+ db.prepare('INSERT INTO card_updates (card_id, content, created_at) VALUES (?, ?, ?)').run(row.id, updateText.trim(), now)
297
+ db.prepare('UPDATE cards SET updated_at = ? WHERE id = ?').run(now, row.id)
298
+ return { ok: true, path: rel, updated: now }
299
+ }
300
+
301
+ /** 合并多张卡为一张。 */
302
+ export async function mergeCards(root, paths) {
303
+ const list = (Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean)
304
+ if (list.length < 2) return { ok: false, error: '至少选 2 张卡' }
305
+ const db = getDb(root)
306
+
307
+ const rows = []
308
+ for (const p of list) {
309
+ const row = db.prepare('SELECT * FROM cards WHERE path = ?').get(p)
310
+ if (row) rows.push(row)
311
+ }
312
+ if (rows.length < 2) return { ok: false, error: '读取失败' }
313
+
314
+ const first = rows[0]
315
+ const tags = new Set(JSON.parse(first.tags || '[]'))
316
+ let combined = ''
317
+ rows.forEach((row, i) => {
318
+ const rowTags = JSON.parse(row.tags || '[]')
319
+ rowTags.forEach((t) => tags.add(t))
320
+ combined += (i ? '\n\n---\n\n' : '') + row.body.trim()
321
+ })
322
+
323
+ const title = (first.title || '合并记忆') + '(合并)'
324
+ const r = await writeCard(root, { kind: first.kind || 'knowledge', title, tags: [...tags], body: combined, source: 'merge', status: 'pending', submittedBy: 'merge', reason: '多卡合并' }, { dedup: false })
325
+ if (!r.ok) return { ok: false, error: '写入失败' }
326
+ for (const row of rows) { try { await deleteCard(root, row.path, { permanent: true }) } catch {} }
327
+ return { ok: true, path: r.path, merged: rows.length }
445
328
  }
446
329
 
447
- /** 检索:整词/中文 bigram 命中 path + 正文。返回卡片摘要(带命中度)。minScore 过滤弱命中;semantic=true 加本地语义分;有反馈则据此反哺排序。 */
330
+ /** 检索:整词/中文 bigram 命中。 */
448
331
  export async function search(root, query, { limit = 30, minScore = 0, semantic = false } = {}) {
449
332
  const q = String(query || '').trim()
450
333
  if (!q) return []
451
- const cards = await listCards(root)
334
+ const db = getDb(root)
335
+ const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
452
336
  const wanted = queryTerms(q)
453
337
  const out = []
454
- for (const card of cards) {
455
- let text
456
- try {
457
- text = await fs.readFile(path.join(root, card.path), 'utf8')
458
- } catch {
459
- continue
460
- }
461
- const haystack = `${card.path}\n${card.title}\n${text}`.toLowerCase()
338
+ for (const row of rows) {
339
+ const card = rowToCard(row)
340
+ const haystack = `${card.path}\n${card.title}\n${row.body}`.toLowerCase()
462
341
  let score = 0
463
342
  if (haystack.includes(q.toLowerCase())) score = 3
464
343
  for (const term of wanted) {
465
344
  if (haystack.includes(term.toLowerCase())) score += 1
466
345
  }
467
- // 本地语义加分:Jaccard bigram 相似度(无 embedding 依赖),覆盖「同义改写/片段命中」
468
346
  if (semantic) score += Math.round(textSimilarity(q, `${card.title} ${card.summary}`) * 6)
469
347
  if (score > 0 && score >= minScore) {
470
- out.push({ ...card, score, excerpt: text.replace(/^---[\s\S]*?---\r?\n?/, '').trim() })
348
+ out.push({ ...card, score, excerpt: row.body.trim().slice(0, 300) })
471
349
  }
472
350
  }
473
- // 反馈反哺排序:对该查询「有用」的卡加分、「无关」的减分;query 与本次有词交集即生效。
351
+
352
+ // 反馈反哺排序
474
353
  const fb = await readFeedback(root)
475
354
  const ql = q.toLowerCase()
476
355
  const fbByPath = {}
@@ -490,63 +369,79 @@ export async function search(root, query, { limit = 30, minScore = 0, semantic =
490
369
  return out.slice(0, limit)
491
370
  }
492
371
 
493
- /** 当日简报:按 mtime 列出今天新增/更新的卡(供每日回顾)。 */
372
+ /** 当日简报。 */
494
373
  export function dailyBrief(cardsToday) {
495
374
  if (!cardsToday || cardsToday.length === 0) return '今天还没有新的记忆沉淀。'
496
375
  return cardsToday.map((c) => `- ${c.kind}|${c.title}`).join('\n')
497
376
  }
498
377
 
499
- /** 生成今日简报文件到 00-System/daily-YYYY-MM-DD.md,幂等(已存在则跳过)。 */
378
+ /** 生成今日简报卡(INSERT into cards)。 */
500
379
  export async function generateDailyBrief(root) {
501
380
  const d = new Date()
502
381
  const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
503
- const dir = path.join(root, '00-System')
504
- const file = path.join(dir, `daily-${date}.md`)
505
382
  try {
506
- if (await exists(file)) return { ok: true, existed: true, file }
383
+ const db = getDb(root)
384
+ // 幂等:检查是否已存在
385
+ const existing = db.prepare("SELECT 1 FROM cards WHERE title = ? AND status = 'approved'").get(`每日回顾 ${date}`)
386
+ if (existing) return { ok: true, existed: true }
387
+
507
388
  const s = await stats(root)
508
- const content = `---\nkind: prompt\ntitle: 每日回顾 ${date}\ntags: [每日回顾, 统计]\ncreated: ${new Date().toISOString()}\nupdated: ${new Date().toISOString()}\nsource: daily-brief\nstatus: approved\n---\n# 每日回顾 ${date}\n\n今日新增 ${s.today} · 近 7 天 ${s.week} · 总 ${s.total}\n\n## 今日沉淀\n${dailyBrief(s.todayCards)}\n`
509
- await fs.mkdir(dir, { recursive: true })
510
- await fs.writeFile(file, content, 'utf8')
511
- return { ok: true, existed: false, file }
389
+ const body = `今日新增 ${s.today} · 近 7 天 ${s.week} · 总 ${s.total}\n\n## 今日沉淀\n${dailyBrief(s.todayCards)}`
390
+ const now = new Date().toISOString()
391
+ db.prepare(`
392
+ INSERT INTO cards (path, kind, title, tags, body, summary, status, source, created_at, updated_at)
393
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
394
+ `).run(
395
+ `00-System/daily-${date}.md`,
396
+ 'prompt',
397
+ `每日回顾 ${date}`,
398
+ JSON.stringify(['每日回顾', '统计']),
399
+ body,
400
+ body.slice(0, 200),
401
+ 'approved',
402
+ 'daily-brief',
403
+ now,
404
+ now,
405
+ )
406
+ return { ok: true, existed: false }
512
407
  } catch (e) {
513
408
  return { ok: false, error: String(e.message || e) }
514
409
  }
515
410
  }
516
411
 
517
- /** 用量趋势:返回最近 N 天每天新增卡片数 [{date, count}]。 */
412
+ /** 用量趋势:最近 N 天每天新增卡片数。 */
518
413
  export async function dailyCounts(root, days = 30) {
519
- const cards = await listCards(root)
520
- const now = Date.now()
521
- const buckets = {}
522
- for (let d = 0; d < days; d++) {
523
- const date = new Date(now - d * 86400000)
524
- const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
525
- buckets[key] = 0
526
- }
527
- for (const c of cards) {
528
- if (!c.created) continue
529
- const d = new Date(c.created)
530
- const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
531
- if (key in buckets) buckets[key]++
414
+ const db = getDb(root)
415
+ const cut = new Date(Date.now() - days * 86400000).toISOString()
416
+ 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)
417
+ const map = new Map(rows.map((r) => [r.d, r.cnt]))
418
+ const out = []
419
+ for (let i = days - 1; i >= 0; i--) {
420
+ const dt = new Date(Date.now() - i * 86400000)
421
+ const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
422
+ out.push({ date: key, count: map.get(key) || 0 })
532
423
  }
533
- return Object.entries(buckets).sort(([a], [b]) => a.localeCompare(b)).map(([date, count]) => ({ date, count }))
424
+ return out
534
425
  }
535
426
 
536
427
  /** 导出全部卡片原始 Markdown。 */
537
428
  export async function exportCards(root) {
538
- const cards = await listCards(root)
539
- const out = []
540
- for (const c of cards) {
541
- try { out.push({ path: c.path, title: c.title, kind: c.kind, text: await fs.readFile(path.join(root, c.path), 'utf8') }) } catch { /* 跳过坏文件 */ }
542
- }
543
- return out
429
+ const db = getDb(root)
430
+ const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
431
+ return rows.map((row) => {
432
+ const { text } = rowToMarkdown(row)
433
+ return { path: row.path, title: row.title, kind: row.kind, text }
434
+ })
544
435
  }
545
436
 
546
- /** 图谱:节点 = 卡片;边 = [[wikilink]](同 vault 命中)或共享标签。 */
437
+ /** 图谱:节点 = 卡片;边 = [[wikilink]] 或共享标签。 */
547
438
  export async function graph(root) {
548
- const cards = await listCards(root)
439
+ const db = getDb(root)
440
+ const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
441
+ const cards = rows.map(rowToCard)
549
442
  const byPath = new Map(cards.map((c) => [c.path, c]))
443
+ const bodyMap = new Map(rows.map((r) => [r.path, r.body]))
444
+
550
445
  const nodes = cards.map((c) => ({
551
446
  id: c.path,
552
447
  title: c.title,
@@ -561,22 +456,15 @@ export async function graph(root) {
561
456
  const addDegree = (p) => { const n = nodeById.get(p); if (n) n.linkCount++ }
562
457
  const edges = []
563
458
  const seen = new Set()
564
- const bodies = new Map()
565
459
  const addEdge = (a, b, type) => {
566
460
  const key = [a, b].sort().join('|') + '|' + type
567
461
  if (seen.has(key)) return
568
462
  seen.add(key)
569
463
  edges.push({ source: a, target: b, type })
570
464
  }
465
+
571
466
  for (const card of cards) {
572
- let text = ''
573
- try {
574
- text = await fs.readFile(path.join(root, card.path), 'utf8')
575
- } catch {
576
- continue
577
- }
578
- bodies.set(card.path, text)
579
- // wikilinks:[[目标路径]] 或 [[标题]]
467
+ const text = bodyMap.get(card.path) || ''
580
468
  for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)) {
581
469
  const raw = m[1].trim().replace(/\.md$/, '')
582
470
  const candidates = [
@@ -590,7 +478,6 @@ export async function graph(root) {
590
478
  break
591
479
  }
592
480
  }
593
- // 也允许按标题匹配
594
481
  for (const other of cards) {
595
482
  if (other.path !== card.path && (other.title === raw || other.path.endsWith(`/${raw}.md`))) {
596
483
  addEdge(card.path, other.path, 'link'); addDegree(card.path); addDegree(other.path)
@@ -598,51 +485,55 @@ export async function graph(root) {
598
485
  }
599
486
  }
600
487
  }
601
- // 共享标签(同一标签出现两次以上才连线,避免全连)
602
488
  for (const tag of card.tags) {
603
489
  for (const other of cards) {
604
490
  if (other.path !== card.path && other.tags.includes(tag)) addEdge(card.path, other.path, `tag:${tag}`)
605
491
  }
606
492
  }
607
493
  }
608
- // 相似度关联建议:仅在同 kind 或共享标签的候选间计算 Jaccard,>0.42 连一条「similar」边,每节点最多 1 条,避免稠密发丝球。
494
+
495
+ // 相似度关联
609
496
  const similarCount = {}
610
- const simCandidates = []
611
- for (const c of cards) simCandidates.push(c.path)
612
- for (let i = 0; i < simCandidates.length; i++) for (let j = i + 1; j < simCandidates.length; j++) {
613
- const a = simCandidates[i], b = simCandidates[j]
614
- const ca = byPath.get(a), cb = byPath.get(b)
615
- if (!ca || !cb) continue
616
- if (ca.kind !== cb.kind && !ca.tags.some((t) => cb.tags.includes(t))) continue
617
- if ((similarCount[a] || 0) >= 1 || (similarCount[b] || 0) >= 1) continue
618
- const sim = textSimilarity(bodies.get(a) || '', bodies.get(b) || '')
619
- if (sim >= 0.42) { addEdge(a, b, 'similar'); addDegree(a); addDegree(b); similarCount[a] = (similarCount[a] || 0) + 1; similarCount[b] = (similarCount[b] || 0) + 1 }
497
+ for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
498
+ const a = cards[i], b = cards[j]
499
+ if (a.kind !== b.kind && !a.tags.some((t) => b.tags.includes(t))) continue
500
+ if ((similarCount[a.path] || 0) >= 1 || (similarCount[b.path] || 0) >= 1) continue
501
+ const sim = textSimilarity(bodyMap.get(a.path) || '', bodyMap.get(b.path) || '')
502
+ if (sim >= 0.42) { addEdge(a.path, b.path, 'similar'); addDegree(a.path); addDegree(b.path); similarCount[a.path] = (similarCount[a.path] || 0) + 1; similarCount[b.path] = (similarCount[b.path] || 0) + 1 }
620
503
  }
621
504
  return { nodes, edges }
622
505
  }
623
506
 
624
- /** 统计(增强):overview 全量 + 今日/本周新增 + 今日新增卡片明细。 */
507
+ /** 统计。 */
625
508
  export async function stats(root) {
626
- const cards = await listCards(root)
627
- const byKind = {}
628
- for (const c of cards) byKind[c.kind] = (byKind[c.kind] || 0) + 1
629
- const now = Date.now()
630
- const day = now - 86400000
631
- const week = now - 7 * 86400000
632
- const today = cards.filter((c) => c.mtime > day)
509
+ const db = getDb(root)
510
+ const totalRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved'").get()
511
+ const byKindRows = db.prepare("SELECT kind, COUNT(*) as cnt FROM cards WHERE status = 'approved' GROUP BY kind").all()
512
+ const byKind = Object.fromEntries(byKindRows.map((r) => [r.kind, r.cnt]))
513
+
514
+ const now = new Date()
515
+ const dayAgo = new Date(now.getTime() - 86400000).toISOString()
516
+ const weekAgo = new Date(now.getTime() - 7 * 86400000).toISOString()
517
+ const todayRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(dayAgo)
518
+ const weekRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(weekAgo)
519
+ const tagRows = db.prepare("SELECT tags FROM cards WHERE status = 'approved'").all()
633
520
  const tagSet = new Set()
634
- for (const c of cards) for (const t of c.tags) tagSet.add(t)
521
+ for (const r of tagRows) { for (const t of JSON.parse(r.tags || '[]')) tagSet.add(t) }
522
+
523
+ const todayCardsRows = db.prepare("SELECT * FROM cards WHERE status = 'approved' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 30").all(dayAgo)
524
+ const todayCards = todayCardsRows.map((row) => ({ path: row.path, title: row.title, kind: row.kind, updated: row.updated_at, summary: (row.summary || '').slice(0, 80) }))
525
+
635
526
  return {
636
- total: cards.length,
637
- today: today.length,
638
- week: cards.filter((c) => c.mtime > week).length,
527
+ total: totalRow.cnt,
528
+ today: todayRow.cnt,
529
+ week: weekRow.cnt,
639
530
  tags: tagSet.size,
640
531
  byKind,
641
- todayCards: today.slice(0, 30).map((c) => ({ path: c.path, title: c.title, kind: c.kind, updated: c.updated, summary: c.summary.slice(0, 80) })),
532
+ todayCards,
642
533
  }
643
534
  }
644
535
 
645
- /** 跨库聚合图谱:roots = [{name, root}],每个 vault 的子图合并(node id 前缀 profile 名避免冲突)。 */
536
+ /** 跨库聚合图谱。 */
646
537
  export async function graphAll(roots) {
647
538
  const nodes = []; const edges = []; const idSet = new Set()
648
539
  for (const r of roots) {
@@ -656,7 +547,7 @@ export async function graphAll(roots) {
656
547
  return { nodes, edges }
657
548
  }
658
549
 
659
- /** 跨库聚合检索:多个 vault 的结果合并(path 前缀 profile 名)。 */
550
+ /** 跨库聚合检索。 */
660
551
  export async function searchAll(roots, query, opts = {}) {
661
552
  const out = []
662
553
  for (const r of roots) {
@@ -669,18 +560,18 @@ export async function searchAll(roots, query, opts = {}) {
669
560
  return out.slice(0, opts.limit || 30)
670
561
  }
671
562
 
672
- /** 整理建议(非破坏性预览):高相似卡对 + 陈旧卡(>90 天未更新且无连线)。 */
563
+ /** 整理建议(非破坏性预览)。 */
673
564
  export async function optimizeCandidates(root) {
674
- const cards = await listCards(root)
565
+ const db = getDb(root)
566
+ const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
567
+ const cards = rows.map(rowToCard)
568
+ const bodyMap = new Map(rows.map((r) => [r.path, (r.body || '').slice(0, 4000)]))
569
+
675
570
  const merge = []
676
- const bodies = new Map()
677
- for (const c of cards) {
678
- try { bodies.set(c.path, (await fs.readFile(path.join(root, c.path), 'utf8')).slice(0, 4000)) } catch { bodies.set(c.path, '') }
679
- }
680
571
  for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
681
572
  const a = cards[i], b = cards[j]
682
573
  if (a.kind !== b.kind) continue
683
- const sim = textSimilarity(bodies.get(a.path) || '', bodies.get(b.path) || '')
574
+ const sim = textSimilarity(bodyMap.get(a.path) || '', bodyMap.get(b.path) || '')
684
575
  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 })
685
576
  }
686
577
  merge.sort((x, y) => y.sim - x.sim)
@@ -689,16 +580,7 @@ export async function optimizeCandidates(root) {
689
580
  return { merge: merge.slice(0, 40), stale }
690
581
  }
691
582
 
692
- /**
693
- * 一键优化执行:根据 /optimize 候选合并相似卡 + (可选)清理陈旧卡。
694
- * @param {string} root
695
- * @param {object} [opts]
696
- * @param {number} [opts.simThreshold=0.55] 相似度阈值(与 optimizeCandidates 一致)
697
- * @param {number} [opts.staleDays=90] 多久未更新视为「陈旧」
698
- * @param {boolean} [opts.cleanupStale=false] 默认不动陈旧卡(用户敏感)
699
- * @param {boolean} [opts.dryRun=false] 只返回计划,不执行
700
- * @returns {Promise<{ok:true, plan:{merged:number, staleDeleted:number, staleSkipped:number}, merged:[{from,to}], staleDeleted:[string], staleSkipped:[string], errors:[string]}>}
701
- */
583
+ /** 一键优化执行。 */
702
584
  export async function optimizeApply(root, opts = {}) {
703
585
  const simThreshold = Number(opts.simThreshold ?? 0.55)
704
586
  const staleDays = Number(opts.staleDays ?? 90)
@@ -748,19 +630,28 @@ export async function optimizeApply(root, opts = {}) {
748
630
  }
749
631
  }
750
632
 
751
- /** 召回反馈记录:命中是否有用(供相关性调优)。文件存 vault 根 `.feedback.json`。 */
633
+ /** 读取反馈记录。 */
752
634
  export async function readFeedback(root) {
753
- try { return JSON.parse(await fs.readFile(path.join(root, '.feedback.json'), 'utf8')) } catch { return [] }
635
+ const db = getDb(root)
636
+ const rows = db.prepare('SELECT * FROM feedback ORDER BY created_at DESC').all()
637
+ return rows.map((r) => ({ ts: new Date(r.created_at).getTime(), query: r.query, path: r.card_path, useful: r.useful }))
754
638
  }
639
+
640
+ /** 添加反馈。 */
755
641
  export async function addFeedback(root, rec) {
756
- const list = await readFeedback(root)
757
- list.unshift({ ts: Date.now(), ...rec })
758
- await fs.writeFile(path.join(root, '.feedback.json'), JSON.stringify(list.slice(0, 500), null, 2), 'utf8')
642
+ const db = getDb(root)
643
+ db.prepare('INSERT INTO feedback (query, card_path, useful) VALUES (?, ?, ?)').run(
644
+ rec.query || '',
645
+ rec.path || rec.card_path || '',
646
+ rec.useful ? 1 : 0,
647
+ )
759
648
  return { ok: true }
760
649
  }
761
650
 
762
- /** 统计:按 kind 计数 + 最近 7 天更新数 + 总标签数。 */
763
- export async function overview(root) { const cards = await listCards(root, { status: ['approved', 'pending', 'rejected'] })
651
+ /** 统计:按 kind + status。 */
652
+ export async function overview(root) {
653
+ const db = getDb(root)
654
+ const cards = await listCards(root, { status: ['approved', 'pending', 'rejected'] })
764
655
  const byKind = {}
765
656
  const statusCounts = {}
766
657
  for (const c of cards) { byKind[c.kind] = (byKind[c.kind] || 0) + 1; statusCounts[c.status] = (statusCounts[c.status] || 0) + 1 }
@@ -789,6 +680,7 @@ function resolveInside(root, rel) {
789
680
 
790
681
  async function exists(p) {
791
682
  try {
683
+ const { promises: fs } = await import('node:fs')
792
684
  await fs.access(p)
793
685
  return true
794
686
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
5
  "license": "MIT",
6
6
  "repository": {