memory-eternal 0.1.2 → 0.3.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, setAuditConfig } 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,18 @@ 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
+ // 同步审核配置到 SQLite config 表(enforceAudit 从此表读取规则)
116
+ const syncAudit = () => {
117
+ try {
118
+ const cfg = settings.get() ?? {}
119
+ setAuditConfig(vaultDir(), { auditMode: cfg.auditMode, auditExemptAgents: cfg.auditExemptAgents, auditExemptKinds: cfg.auditExemptKinds })
120
+ } catch {}
121
+ }
122
+ syncAudit()
123
+ settings.watch(syncAudit)
124
+
111
125
  // 所有 profile 目录(当前激活 + 其余命名的),供跨库聚合。
112
126
  const vaultRoots = () => {
113
127
  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,178 @@
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
+ -- 审核日志:记录所有 status 变更,不可篡改
75
+ CREATE TABLE IF NOT EXISTS audit_log (
76
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ card_path TEXT NOT NULL,
78
+ old_status TEXT,
79
+ new_status TEXT NOT NULL,
80
+ changed_by TEXT DEFAULT 'system',
81
+ reason TEXT DEFAULT '',
82
+ created_at TEXT DEFAULT (datetime('now'))
83
+ );
84
+ `)
85
+ }
86
+
87
+ /**
88
+ * 审核守卫:根据 config 表的审核规则决定新卡 status。
89
+ * 所有写入路径必须经过此函数,杜绝越权。
90
+ *
91
+ * 规则:
92
+ * - config 表无 auditMode 记录 → 用 fallback(调用方传入的 status)
93
+ * - auditMode = 'none' → approved(全部免审)
94
+ * - auditMode = 'all' → 检查免审白名单,命中→approved,否则→pending
95
+ *
96
+ * @param {string} root - vault 根目录
97
+ * @param {string} kind - 卡片类型
98
+ * @param {string} submittedBy - 提交者
99
+ * @param {string} fallback - 调用方传入的 status(无配置时使用)
100
+ * @returns {'pending' | 'approved'}
101
+ */
102
+ export function enforceAudit(root, kind, submittedBy, fallback = 'pending') {
103
+ const db = getDb(root)
104
+ const get = (key) => { try { return db.prepare('SELECT value FROM config WHERE key = ?').get(key)?.value } catch { return null } }
105
+ const mode = get('auditMode')
106
+ // 无配置 → 用调用方传入的 status
107
+ if (!mode) return fallback
108
+ if (mode === 'none') return 'approved'
109
+ const agentsRaw = get('auditExemptAgents') || '[]'
110
+ const kindsRaw = get('auditExemptKinds') || '[]'
111
+ let agents = [], kinds = []
112
+ try { agents = JSON.parse(agentsRaw) } catch { agents = agentsRaw.split(',').map(s => s.trim()).filter(Boolean) }
113
+ try { kinds = JSON.parse(kindsRaw) } catch { kinds = kindsRaw.split(',').map(s => s.trim()).filter(Boolean) }
114
+ if (agents.includes('__all__') || agents.includes(submittedBy)) return 'approved'
115
+ if (kinds.includes('__all__') || kinds.includes(kind)) return 'approved'
116
+ return 'pending'
117
+ }
118
+
119
+ /** 写入审核配置到 config 表(供 index.js 同步 DSH settings)。 */
120
+ export function setAuditConfig(root, { auditMode, auditExemptAgents, auditExemptKinds }) {
121
+ const db = getDb(root)
122
+ const upsert = db.prepare(`INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
123
+ if (auditMode !== undefined) upsert.run('auditMode', String(auditMode))
124
+ if (auditExemptAgents !== undefined) upsert.run('auditExemptAgents', JSON.stringify(auditExemptAgents))
125
+ if (auditExemptKinds !== undefined) upsert.run('auditExemptKinds', JSON.stringify(auditExemptKinds))
126
+ }
127
+
128
+ /** 从 .md 文件迁移到 SQLite(幂等:已存在的卡跳过)。 */
129
+ export async function migrateFromMarkdown(root) {
130
+ const db = getDb(root)
131
+ const existing = db.prepare('SELECT COUNT(*) as cnt FROM cards').get()
132
+ if (existing.cnt > 0) return { migrated: 0, skipped: existing.cnt }
133
+
134
+ let migrated = 0
135
+ const walk = async (dir, base) => {
136
+ let ents = []
137
+ try { ents = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
138
+ for (const ent of ents) {
139
+ const full = path.join(dir, ent.name)
140
+ if (ent.isDirectory()) { await walk(full, base); continue }
141
+ if (!ent.name.endsWith('.md')) continue
142
+ try {
143
+ const text = await fs.readFile(full, 'utf8')
144
+ const { meta, body, summary } = parseCard(text)
145
+ const rel = path.relative(base, full).split(path.sep).join('/')
146
+ const stat = await fs.stat(full)
147
+ db.prepare(`
148
+ INSERT OR IGNORE INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
149
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
150
+ `).run(
151
+ rel,
152
+ meta.kind || 'knowledge',
153
+ meta.title || rel.replace(/\.[^.]*$/, '').replace(/^[^/]*\//, ''),
154
+ JSON.stringify(meta.tags || []),
155
+ body.trim(),
156
+ summary,
157
+ meta.status || 'pending',
158
+ meta.source || '',
159
+ meta.submittedBy || '',
160
+ meta.severity || 'info',
161
+ meta.reason || '',
162
+ meta.created || stat.mtime.toISOString(),
163
+ meta.updated || stat.mtime.toISOString(),
164
+ meta.deletedAt || null,
165
+ )
166
+ migrated++
167
+ } catch { /* 跳过坏文件 */ }
168
+ }
169
+ }
170
+ await walk(root, root)
171
+ return { migrated, skipped: 0 }
172
+ }
173
+
174
+ export function closeDb(root) {
175
+ const resolved = path.resolve(root)
176
+ const db = instances.get(resolved)
177
+ if (db) { db.close(); instances.delete(resolved) }
178
+ }
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, enforceAudit } 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,272 @@ 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: 'pending', submittedBy: 'merge', reason: '多卡合并' }, { 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 existing = db.prepare('SELECT status FROM cards WHERE path = ?').get(rel)
235
+ if (!existing) throw new Error('卡片不存在: ' + rel)
236
+ const info = db.prepare('UPDATE cards SET status = ?, deleted_at = NULL, updated_at = ? WHERE path = ?').run('approved', now, rel)
237
+ if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
238
+ db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, 'approved', 'restore', '回收站恢复')
334
239
  return { ok: true, restored: true }
335
240
  }
336
241
 
337
- /** 更新卡状态(审核批准/驳回)。 */
338
- 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)
242
+ /** 更新卡状态(审核操作)。写审计日志,确保可追溯。 */
243
+ export async function setCardStatus(root, rel, status, { changedBy = 'system', reason = '' } = {}) {
244
+ const db = getDb(root)
343
245
  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')
246
+ const existing = db.prepare('SELECT status FROM cards WHERE path = ?').get(rel)
247
+ if (!existing) throw new Error('卡片不存在: ' + rel)
248
+ const info = db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE path = ?').run(status, now, rel)
249
+ if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
250
+ // 审计日志:记录每次 status 变更
251
+ db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, status, changedBy, reason)
363
252
  return { ok: true, status }
364
253
  }
365
254
 
366
- /** 原子写卡;先去重(target 目录内),命中返回 {duplicate}。 */
255
+ /** 原子写卡;先去重,命中返回 {duplicate}。审核守卫在数据库层强制。 */
367
256
  export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true } = {}) {
257
+ const db = getDb(root)
368
258
  const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
369
- const dir = path.join(root, kindRoot)
370
- await fs.mkdir(dir, { recursive: true })
259
+
260
+ // 审核守卫:数据库层强制,调用方传的 status 仅作 fallback
261
+ const enforcedStatus = enforceAudit(root, kind, submittedBy, status)
262
+
371
263
  if (dedup) {
372
- const hit = await dedupCheck(dir, body, threshold)
373
- if (hit) return { ok: false, duplicate: { ...hit, path: `${kindRoot}/${hit.path}` } }
264
+ const hit = await dedupCheck(root, body, threshold)
265
+ if (hit) return { ok: false, duplicate: { ...hit, path: hit.path } }
374
266
  }
267
+
375
268
  const slug = safeSlug(title)
376
- let rel = `${slug}.md`
269
+ let rel = `${kindRoot}/${slug}.md`
377
270
  let index = 2
378
- while (await exists(path.join(dir, rel))) {
379
- rel = `${slug}-${index}.md`
271
+ while (db.prepare('SELECT 1 FROM cards WHERE path = ?').get(rel)) {
272
+ rel = `${kindRoot}/${slug}-${index}.md`
380
273
  index++
381
274
  }
275
+
382
276
  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
- '',
277
+ const summary = body.trim().slice(0, 200)
278
+ db.prepare(`
279
+ INSERT INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
280
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
281
+ `).run(
282
+ rel,
283
+ kind,
284
+ title,
285
+ JSON.stringify(tags),
401
286
  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)
287
+ summary,
288
+ enforcedStatus,
289
+ source,
290
+ submittedBy,
291
+ severity,
292
+ reason,
293
+ now,
294
+ now,
295
+ deletedAt || null,
296
+ )
297
+ return { ok: true, path: rel, kind }
298
+ }
299
+
300
+ /** 向已存在的卡追加更新记录。 */
301
+ export async function appendUpdate(root, rel, updateText) {
302
+ const db = getDb(root)
303
+ const row = db.prepare('SELECT id FROM cards WHERE path = ?').get(rel)
304
+ if (!row) throw new Error('卡片不存在: ' + rel)
417
305
  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 }
306
+ db.prepare('INSERT INTO card_updates (card_id, content, created_at) VALUES (?, ?, ?)').run(row.id, updateText.trim(), now)
307
+ db.prepare('UPDATE cards SET updated_at = ? WHERE id = ?').run(now, row.id)
308
+ return { ok: true, path: rel, updated: now }
445
309
  }
446
310
 
447
- /** 检索:整词/中文 bigram 命中 path + 正文。返回卡片摘要(带命中度)。minScore 过滤弱命中;semantic=true 加本地语义分;有反馈则据此反哺排序。 */
311
+ /** 合并多张卡为一张。 */
312
+ export async function mergeCards(root, paths) {
313
+ const list = (Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean)
314
+ if (list.length < 2) return { ok: false, error: '至少选 2 张卡' }
315
+ const db = getDb(root)
316
+
317
+ const rows = []
318
+ for (const p of list) {
319
+ const row = db.prepare('SELECT * FROM cards WHERE path = ?').get(p)
320
+ if (row) rows.push(row)
321
+ }
322
+ if (rows.length < 2) return { ok: false, error: '读取失败' }
323
+
324
+ const first = rows[0]
325
+ const tags = new Set(JSON.parse(first.tags || '[]'))
326
+ let combined = ''
327
+ rows.forEach((row, i) => {
328
+ const rowTags = JSON.parse(row.tags || '[]')
329
+ rowTags.forEach((t) => tags.add(t))
330
+ combined += (i ? '\n\n---\n\n' : '') + row.body.trim()
331
+ })
332
+
333
+ const title = (first.title || '合并记忆') + '(合并)'
334
+ const r = await writeCard(root, { kind: first.kind || 'knowledge', title, tags: [...tags], body: combined, source: 'merge', status: 'pending', submittedBy: 'merge', reason: '多卡合并' }, { dedup: false })
335
+ if (!r.ok) return { ok: false, error: '写入失败' }
336
+ for (const row of rows) { try { await deleteCard(root, row.path, { permanent: true }) } catch {} }
337
+ return { ok: true, path: r.path, merged: rows.length }
338
+ }
339
+
340
+ /** 检索:整词/中文 bigram 命中。 */
448
341
  export async function search(root, query, { limit = 30, minScore = 0, semantic = false } = {}) {
449
342
  const q = String(query || '').trim()
450
343
  if (!q) return []
451
- const cards = await listCards(root)
344
+ const db = getDb(root)
345
+ const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
452
346
  const wanted = queryTerms(q)
453
347
  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()
348
+ for (const row of rows) {
349
+ const card = rowToCard(row)
350
+ const haystack = `${card.path}\n${card.title}\n${row.body}`.toLowerCase()
462
351
  let score = 0
463
352
  if (haystack.includes(q.toLowerCase())) score = 3
464
353
  for (const term of wanted) {
465
354
  if (haystack.includes(term.toLowerCase())) score += 1
466
355
  }
467
- // 本地语义加分:Jaccard bigram 相似度(无 embedding 依赖),覆盖「同义改写/片段命中」
468
356
  if (semantic) score += Math.round(textSimilarity(q, `${card.title} ${card.summary}`) * 6)
469
357
  if (score > 0 && score >= minScore) {
470
- out.push({ ...card, score, excerpt: text.replace(/^---[\s\S]*?---\r?\n?/, '').trim() })
358
+ out.push({ ...card, score, excerpt: row.body.trim().slice(0, 300) })
471
359
  }
472
360
  }
473
- // 反馈反哺排序:对该查询「有用」的卡加分、「无关」的减分;query 与本次有词交集即生效。
361
+
362
+ // 反馈反哺排序
474
363
  const fb = await readFeedback(root)
475
364
  const ql = q.toLowerCase()
476
365
  const fbByPath = {}
@@ -490,63 +379,79 @@ export async function search(root, query, { limit = 30, minScore = 0, semantic =
490
379
  return out.slice(0, limit)
491
380
  }
492
381
 
493
- /** 当日简报:按 mtime 列出今天新增/更新的卡(供每日回顾)。 */
382
+ /** 当日简报。 */
494
383
  export function dailyBrief(cardsToday) {
495
384
  if (!cardsToday || cardsToday.length === 0) return '今天还没有新的记忆沉淀。'
496
385
  return cardsToday.map((c) => `- ${c.kind}|${c.title}`).join('\n')
497
386
  }
498
387
 
499
- /** 生成今日简报文件到 00-System/daily-YYYY-MM-DD.md,幂等(已存在则跳过)。 */
388
+ /** 生成今日简报卡(INSERT into cards)。 */
500
389
  export async function generateDailyBrief(root) {
501
390
  const d = new Date()
502
391
  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
392
  try {
506
- if (await exists(file)) return { ok: true, existed: true, file }
393
+ const db = getDb(root)
394
+ // 幂等:检查是否已存在
395
+ const existing = db.prepare("SELECT 1 FROM cards WHERE title = ? AND status = 'approved'").get(`每日回顾 ${date}`)
396
+ if (existing) return { ok: true, existed: true }
397
+
507
398
  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 }
399
+ const body = `今日新增 ${s.today} · 近 7 天 ${s.week} · 总 ${s.total}\n\n## 今日沉淀\n${dailyBrief(s.todayCards)}`
400
+ const now = new Date().toISOString()
401
+ db.prepare(`
402
+ INSERT INTO cards (path, kind, title, tags, body, summary, status, source, created_at, updated_at)
403
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
404
+ `).run(
405
+ `00-System/daily-${date}.md`,
406
+ 'prompt',
407
+ `每日回顾 ${date}`,
408
+ JSON.stringify(['每日回顾', '统计']),
409
+ body,
410
+ body.slice(0, 200),
411
+ 'approved',
412
+ 'daily-brief',
413
+ now,
414
+ now,
415
+ )
416
+ return { ok: true, existed: false }
512
417
  } catch (e) {
513
418
  return { ok: false, error: String(e.message || e) }
514
419
  }
515
420
  }
516
421
 
517
- /** 用量趋势:返回最近 N 天每天新增卡片数 [{date, count}]。 */
422
+ /** 用量趋势:最近 N 天每天新增卡片数。 */
518
423
  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]++
424
+ const db = getDb(root)
425
+ const cut = new Date(Date.now() - days * 86400000).toISOString()
426
+ 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)
427
+ const map = new Map(rows.map((r) => [r.d, r.cnt]))
428
+ const out = []
429
+ for (let i = days - 1; i >= 0; i--) {
430
+ const dt = new Date(Date.now() - i * 86400000)
431
+ const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
432
+ out.push({ date: key, count: map.get(key) || 0 })
532
433
  }
533
- return Object.entries(buckets).sort(([a], [b]) => a.localeCompare(b)).map(([date, count]) => ({ date, count }))
434
+ return out
534
435
  }
535
436
 
536
437
  /** 导出全部卡片原始 Markdown。 */
537
438
  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
439
+ const db = getDb(root)
440
+ const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
441
+ return rows.map((row) => {
442
+ const { text } = rowToMarkdown(row)
443
+ return { path: row.path, title: row.title, kind: row.kind, text }
444
+ })
544
445
  }
545
446
 
546
- /** 图谱:节点 = 卡片;边 = [[wikilink]](同 vault 命中)或共享标签。 */
447
+ /** 图谱:节点 = 卡片;边 = [[wikilink]] 或共享标签。 */
547
448
  export async function graph(root) {
548
- const cards = await listCards(root)
449
+ const db = getDb(root)
450
+ const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
451
+ const cards = rows.map(rowToCard)
549
452
  const byPath = new Map(cards.map((c) => [c.path, c]))
453
+ const bodyMap = new Map(rows.map((r) => [r.path, r.body]))
454
+
550
455
  const nodes = cards.map((c) => ({
551
456
  id: c.path,
552
457
  title: c.title,
@@ -561,22 +466,15 @@ export async function graph(root) {
561
466
  const addDegree = (p) => { const n = nodeById.get(p); if (n) n.linkCount++ }
562
467
  const edges = []
563
468
  const seen = new Set()
564
- const bodies = new Map()
565
469
  const addEdge = (a, b, type) => {
566
470
  const key = [a, b].sort().join('|') + '|' + type
567
471
  if (seen.has(key)) return
568
472
  seen.add(key)
569
473
  edges.push({ source: a, target: b, type })
570
474
  }
475
+
571
476
  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:[[目标路径]] 或 [[标题]]
477
+ const text = bodyMap.get(card.path) || ''
580
478
  for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)) {
581
479
  const raw = m[1].trim().replace(/\.md$/, '')
582
480
  const candidates = [
@@ -590,7 +488,6 @@ export async function graph(root) {
590
488
  break
591
489
  }
592
490
  }
593
- // 也允许按标题匹配
594
491
  for (const other of cards) {
595
492
  if (other.path !== card.path && (other.title === raw || other.path.endsWith(`/${raw}.md`))) {
596
493
  addEdge(card.path, other.path, 'link'); addDegree(card.path); addDegree(other.path)
@@ -598,51 +495,55 @@ export async function graph(root) {
598
495
  }
599
496
  }
600
497
  }
601
- // 共享标签(同一标签出现两次以上才连线,避免全连)
602
498
  for (const tag of card.tags) {
603
499
  for (const other of cards) {
604
500
  if (other.path !== card.path && other.tags.includes(tag)) addEdge(card.path, other.path, `tag:${tag}`)
605
501
  }
606
502
  }
607
503
  }
608
- // 相似度关联建议:仅在同 kind 或共享标签的候选间计算 Jaccard,>0.42 连一条「similar」边,每节点最多 1 条,避免稠密发丝球。
504
+
505
+ // 相似度关联
609
506
  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 }
507
+ for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
508
+ const a = cards[i], b = cards[j]
509
+ if (a.kind !== b.kind && !a.tags.some((t) => b.tags.includes(t))) continue
510
+ if ((similarCount[a.path] || 0) >= 1 || (similarCount[b.path] || 0) >= 1) continue
511
+ const sim = textSimilarity(bodyMap.get(a.path) || '', bodyMap.get(b.path) || '')
512
+ 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
513
  }
621
514
  return { nodes, edges }
622
515
  }
623
516
 
624
- /** 统计(增强):overview 全量 + 今日/本周新增 + 今日新增卡片明细。 */
517
+ /** 统计。 */
625
518
  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)
519
+ const db = getDb(root)
520
+ const totalRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved'").get()
521
+ const byKindRows = db.prepare("SELECT kind, COUNT(*) as cnt FROM cards WHERE status = 'approved' GROUP BY kind").all()
522
+ const byKind = Object.fromEntries(byKindRows.map((r) => [r.kind, r.cnt]))
523
+
524
+ const now = new Date()
525
+ const dayAgo = new Date(now.getTime() - 86400000).toISOString()
526
+ const weekAgo = new Date(now.getTime() - 7 * 86400000).toISOString()
527
+ const todayRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(dayAgo)
528
+ const weekRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(weekAgo)
529
+ const tagRows = db.prepare("SELECT tags FROM cards WHERE status = 'approved'").all()
633
530
  const tagSet = new Set()
634
- for (const c of cards) for (const t of c.tags) tagSet.add(t)
531
+ for (const r of tagRows) { for (const t of JSON.parse(r.tags || '[]')) tagSet.add(t) }
532
+
533
+ const todayCardsRows = db.prepare("SELECT * FROM cards WHERE status = 'approved' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 30").all(dayAgo)
534
+ const todayCards = todayCardsRows.map((row) => ({ path: row.path, title: row.title, kind: row.kind, updated: row.updated_at, summary: (row.summary || '').slice(0, 80) }))
535
+
635
536
  return {
636
- total: cards.length,
637
- today: today.length,
638
- week: cards.filter((c) => c.mtime > week).length,
537
+ total: totalRow.cnt,
538
+ today: todayRow.cnt,
539
+ week: weekRow.cnt,
639
540
  tags: tagSet.size,
640
541
  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) })),
542
+ todayCards,
642
543
  }
643
544
  }
644
545
 
645
- /** 跨库聚合图谱:roots = [{name, root}],每个 vault 的子图合并(node id 前缀 profile 名避免冲突)。 */
546
+ /** 跨库聚合图谱。 */
646
547
  export async function graphAll(roots) {
647
548
  const nodes = []; const edges = []; const idSet = new Set()
648
549
  for (const r of roots) {
@@ -656,7 +557,7 @@ export async function graphAll(roots) {
656
557
  return { nodes, edges }
657
558
  }
658
559
 
659
- /** 跨库聚合检索:多个 vault 的结果合并(path 前缀 profile 名)。 */
560
+ /** 跨库聚合检索。 */
660
561
  export async function searchAll(roots, query, opts = {}) {
661
562
  const out = []
662
563
  for (const r of roots) {
@@ -669,18 +570,18 @@ export async function searchAll(roots, query, opts = {}) {
669
570
  return out.slice(0, opts.limit || 30)
670
571
  }
671
572
 
672
- /** 整理建议(非破坏性预览):高相似卡对 + 陈旧卡(>90 天未更新且无连线)。 */
573
+ /** 整理建议(非破坏性预览)。 */
673
574
  export async function optimizeCandidates(root) {
674
- const cards = await listCards(root)
575
+ const db = getDb(root)
576
+ const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
577
+ const cards = rows.map(rowToCard)
578
+ const bodyMap = new Map(rows.map((r) => [r.path, (r.body || '').slice(0, 4000)]))
579
+
675
580
  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
581
  for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
681
582
  const a = cards[i], b = cards[j]
682
583
  if (a.kind !== b.kind) continue
683
- const sim = textSimilarity(bodies.get(a.path) || '', bodies.get(b.path) || '')
584
+ const sim = textSimilarity(bodyMap.get(a.path) || '', bodyMap.get(b.path) || '')
684
585
  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
586
  }
686
587
  merge.sort((x, y) => y.sim - x.sim)
@@ -689,16 +590,7 @@ export async function optimizeCandidates(root) {
689
590
  return { merge: merge.slice(0, 40), stale }
690
591
  }
691
592
 
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
- */
593
+ /** 一键优化执行。 */
702
594
  export async function optimizeApply(root, opts = {}) {
703
595
  const simThreshold = Number(opts.simThreshold ?? 0.55)
704
596
  const staleDays = Number(opts.staleDays ?? 90)
@@ -748,19 +640,28 @@ export async function optimizeApply(root, opts = {}) {
748
640
  }
749
641
  }
750
642
 
751
- /** 召回反馈记录:命中是否有用(供相关性调优)。文件存 vault 根 `.feedback.json`。 */
643
+ /** 读取反馈记录。 */
752
644
  export async function readFeedback(root) {
753
- try { return JSON.parse(await fs.readFile(path.join(root, '.feedback.json'), 'utf8')) } catch { return [] }
645
+ const db = getDb(root)
646
+ const rows = db.prepare('SELECT * FROM feedback ORDER BY created_at DESC').all()
647
+ return rows.map((r) => ({ ts: new Date(r.created_at).getTime(), query: r.query, path: r.card_path, useful: r.useful }))
754
648
  }
649
+
650
+ /** 添加反馈。 */
755
651
  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')
652
+ const db = getDb(root)
653
+ db.prepare('INSERT INTO feedback (query, card_path, useful) VALUES (?, ?, ?)').run(
654
+ rec.query || '',
655
+ rec.path || rec.card_path || '',
656
+ rec.useful ? 1 : 0,
657
+ )
759
658
  return { ok: true }
760
659
  }
761
660
 
762
- /** 统计:按 kind 计数 + 最近 7 天更新数 + 总标签数。 */
763
- export async function overview(root) { const cards = await listCards(root, { status: ['approved', 'pending', 'rejected'] })
661
+ /** 统计:按 kind + status。 */
662
+ export async function overview(root) {
663
+ const db = getDb(root)
664
+ const cards = await listCards(root, { status: ['approved', 'pending', 'rejected'] })
764
665
  const byKind = {}
765
666
  const statusCounts = {}
766
667
  for (const c of cards) { byKind[c.kind] = (byKind[c.kind] || 0) + 1; statusCounts[c.status] = (statusCounts[c.status] || 0) + 1 }
@@ -789,6 +690,7 @@ function resolveInside(root, rel) {
789
690
 
790
691
  async function exists(p) {
791
692
  try {
693
+ const { promises: fs } = await import('node:fs')
792
694
  await fs.access(p)
793
695
  return true
794
696
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
5
  "license": "MIT",
6
6
  "repository": {