memory-eternal 0.10.0 → 0.10.2
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/.claude-plugin/plugin.json +2 -16
- package/.codex-plugin/plugin.json +2 -16
- package/.cursor-plugin/plugin.json +2 -16
- package/.mcp.json +9 -9
- package/LICENSE +21 -21
- package/README.en.md +16 -5
- package/README.md +20 -5
- package/bin/dsh-memory.mjs +318 -188
- package/examples/mcp-memory/memory-eternal.cordis.yml +59 -59
- package/hooks/capture.mjs +77 -77
- package/hooks/claude-session.js +56 -56
- package/hooks/hooks.claude.json +12 -12
- package/hooks/hooks.codex.json +15 -15
- package/hooks/hooks.cursor.json +11 -11
- package/index.js +177 -30
- package/lib/api.js +48 -14
- package/lib/capture-log.js +65 -59
- package/lib/capture.js +112 -7
- package/lib/client.js +13 -13
- package/lib/config-sync.js +161 -90
- package/lib/db.js +359 -359
- package/lib/feedback.js +214 -205
- package/lib/llm-openai.js +84 -84
- package/lib/mcp.js +197 -166
- package/lib/node-bin.js +99 -99
- package/lib/setup.js +470 -470
- package/lib/sse.js +51 -51
- package/lib/stall.js +30 -30
- package/lib/sweep.js +56 -56
- package/lib/vault-resolve.js +84 -84
- package/lib/vault.js +23 -6
- package/lib/watchdog.js +527 -392
- package/lib/web-assets.js +41 -0
- package/lib/web.js +316 -176
- package/package.json +12 -8
- package/scripts/release.mjs +204 -0
- package/skills/memory-recall/SKILL.md +14 -14
- package/web/app.js +12 -12
- package/web/index.html +46 -46
package/lib/db.js
CHANGED
|
@@ -1,359 +1,359 @@
|
|
|
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
|
-
/**
|
|
30
|
-
* 建表。
|
|
31
|
-
*
|
|
32
|
-
* 存储模型(v0.10「两套存储体系」):
|
|
33
|
-
* cards = **主库 / 正常区**:物理上只存 approved。召回、检索、图谱、去重、
|
|
34
|
-
* 导出这些读路径**不再需要写 `WHERE status='approved'`** —— 查主库即安全。
|
|
35
|
-
* quarantine = **隔离区 / 异常区**:pending(待审)/ rejected(驳回)/ deleted(回收站)。
|
|
36
|
-
* 审核中心与回收站的唯一数据源;除审核操作外无人读它。
|
|
37
|
-
*
|
|
38
|
-
* 这样做的原因:v0.9 及以前是「一张表 + status 列」,安全性依赖每一处 SQL 都记得加
|
|
39
|
-
* 状态条件。审计发现三处漏了(去重池把新知识追加进待审卡、readCard 直读未审核正文、
|
|
40
|
-
* /card 接口无状态校验)—— 只要有人再漏一处就会重演。物理分表后,漏写 `WHERE` 也不泄漏。
|
|
41
|
-
*/
|
|
42
|
-
function initTables(db) {
|
|
43
|
-
db.exec(`
|
|
44
|
-
CREATE TABLE IF NOT EXISTS config (
|
|
45
|
-
key TEXT PRIMARY KEY,
|
|
46
|
-
value TEXT NOT NULL,
|
|
47
|
-
updated_at TEXT DEFAULT (datetime('now'))
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
CREATE TABLE IF NOT EXISTS cards (
|
|
51
|
-
id INTEGER PRIMARY KEY,
|
|
52
|
-
path TEXT UNIQUE NOT NULL,
|
|
53
|
-
kind TEXT NOT NULL DEFAULT 'knowledge',
|
|
54
|
-
title TEXT NOT NULL DEFAULT '',
|
|
55
|
-
tags TEXT DEFAULT '[]',
|
|
56
|
-
body TEXT NOT NULL DEFAULT '',
|
|
57
|
-
summary TEXT DEFAULT '',
|
|
58
|
-
status TEXT NOT NULL DEFAULT 'approved',
|
|
59
|
-
source TEXT DEFAULT '',
|
|
60
|
-
submitted_by TEXT DEFAULT '',
|
|
61
|
-
severity TEXT DEFAULT 'info',
|
|
62
|
-
reason TEXT DEFAULT '',
|
|
63
|
-
created_at TEXT DEFAULT (datetime('now')),
|
|
64
|
-
updated_at TEXT DEFAULT (datetime('now')),
|
|
65
|
-
deleted_at TEXT
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
CREATE INDEX IF NOT EXISTS idx_cards_status ON cards(status);
|
|
69
|
-
CREATE INDEX IF NOT EXISTS idx_cards_kind ON cards(kind);
|
|
70
|
-
CREATE INDEX IF NOT EXISTS idx_cards_deleted ON cards(deleted_at);
|
|
71
|
-
|
|
72
|
-
-- 隔离区:结构与主库一致,另加 quarantined_at(进隔离区的时间)与 quarantine_reason
|
|
73
|
-
CREATE TABLE IF NOT EXISTS quarantine (
|
|
74
|
-
id INTEGER PRIMARY KEY,
|
|
75
|
-
path TEXT UNIQUE NOT NULL,
|
|
76
|
-
kind TEXT NOT NULL DEFAULT 'knowledge',
|
|
77
|
-
title TEXT NOT NULL DEFAULT '',
|
|
78
|
-
tags TEXT DEFAULT '[]',
|
|
79
|
-
body TEXT NOT NULL DEFAULT '',
|
|
80
|
-
summary TEXT DEFAULT '',
|
|
81
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
82
|
-
source TEXT DEFAULT '',
|
|
83
|
-
submitted_by TEXT DEFAULT '',
|
|
84
|
-
severity TEXT DEFAULT 'info',
|
|
85
|
-
reason TEXT DEFAULT '',
|
|
86
|
-
created_at TEXT DEFAULT (datetime('now')),
|
|
87
|
-
updated_at TEXT DEFAULT (datetime('now')),
|
|
88
|
-
deleted_at TEXT,
|
|
89
|
-
quarantined_at TEXT DEFAULT (datetime('now')),
|
|
90
|
-
quarantine_reason TEXT DEFAULT ''
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
CREATE INDEX IF NOT EXISTS idx_quarantine_status ON quarantine(status);
|
|
94
|
-
CREATE INDEX IF NOT EXISTS idx_quarantine_kind ON quarantine(kind);
|
|
95
|
-
CREATE INDEX IF NOT EXISTS idx_quarantine_deleted ON quarantine(deleted_at);
|
|
96
|
-
|
|
97
|
-
-- 更新记录:card_id 指向 cards.id **或** quarantine.id(两表共享 id 空间)。
|
|
98
|
-
-- 因此这里**不能**建外键:SQLite 的外键只认一张父表,卡被搬进隔离区时 FK 校验会直接失败
|
|
99
|
-
-- (实测 FOREIGN KEY constraint failed)。永久删除卡片时由 deleteCard 手工清理更新记录。
|
|
100
|
-
CREATE TABLE IF NOT EXISTS card_updates (
|
|
101
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
102
|
-
card_id INTEGER NOT NULL,
|
|
103
|
-
content TEXT NOT NULL,
|
|
104
|
-
created_at TEXT DEFAULT (datetime('now'))
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
CREATE INDEX IF NOT EXISTS idx_card_updates_card ON card_updates(card_id);
|
|
108
|
-
|
|
109
|
-
CREATE TABLE IF NOT EXISTS feedback (
|
|
110
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
111
|
-
query TEXT NOT NULL,
|
|
112
|
-
card_path TEXT NOT NULL,
|
|
113
|
-
useful INTEGER NOT NULL DEFAULT 0,
|
|
114
|
-
created_at TEXT DEFAULT (datetime('now'))
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
-- 审核日志:记录所有 status 变更,不可篡改
|
|
118
|
-
CREATE TABLE IF NOT EXISTS audit_log (
|
|
119
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
120
|
-
card_path TEXT NOT NULL,
|
|
121
|
-
old_status TEXT,
|
|
122
|
-
new_status TEXT NOT NULL,
|
|
123
|
-
changed_by TEXT DEFAULT 'system',
|
|
124
|
-
reason TEXT DEFAULT '',
|
|
125
|
-
created_at TEXT DEFAULT (datetime('now'))
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
-- 卡片 id 发号器(两张表**共用**)。
|
|
129
|
-
--
|
|
130
|
-
-- 为什么不用 AUTOINCREMENT:cards 与 quarantine 是两张 AUTOINCREMENT 表,各有一份
|
|
131
|
-
-- sqlite_sequence 计数器,且都从 1 开始 —— 于是两张表会同时出现 id=1,而卡需要在
|
|
132
|
-
-- 两表间搬家并保留 id,必然撞 UNIQUE constraint failed。实测确认:
|
|
133
|
-
-- 共享 id 空间 + 双 AUTOINCREMENT 在 SQLite 里无法自洽。
|
|
134
|
-
-- 改为「显式发号 + 单一持久计数器」:id 全库唯一、单调递增、删除后也不复用,
|
|
135
|
-
-- 而且任何一次 renumber 都是显式 error,不会静默串记录。
|
|
136
|
-
CREATE TABLE IF NOT EXISTS card_sequence (
|
|
137
|
-
name TEXT PRIMARY KEY,
|
|
138
|
-
seq INTEGER NOT NULL
|
|
139
|
-
);
|
|
140
|
-
`)
|
|
141
|
-
// 种子:取两表最大 id(老库升级时把已有卡都覆盖进去);没有则从 0 起
|
|
142
|
-
db.prepare(`INSERT INTO card_sequence (name, seq) VALUES ('card', 0) ON CONFLICT(name) DO NOTHING`).run()
|
|
143
|
-
db.prepare(`UPDATE card_sequence SET seq = (
|
|
144
|
-
SELECT MAX(m) FROM (SELECT MAX(id) m FROM cards UNION ALL SELECT MAX(id) FROM quarantine)
|
|
145
|
-
) WHERE name = 'card' AND seq < (
|
|
146
|
-
SELECT MAX(m) FROM (SELECT MAX(id) m FROM cards UNION ALL SELECT MAX(id) FROM quarantine)
|
|
147
|
-
)`).run()
|
|
148
|
-
migrateToQuarantine(db)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** 分配一个新的卡片 id(跨两张表唯一、单调递增)。 */
|
|
152
|
-
export function nextCardId(db) {
|
|
153
|
-
db.prepare("UPDATE card_sequence SET seq = seq + 1 WHERE name = 'card'").run()
|
|
154
|
-
const row = db.prepare("SELECT seq FROM card_sequence WHERE name = 'card'").get()
|
|
155
|
-
if (!row) throw new Error('card_sequence 未初始化')
|
|
156
|
-
return row.seq
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** 主库里的每一个 status 常量:只有它算「正常区」。 */
|
|
160
|
-
export const MAIN_STATUS = 'approved'
|
|
161
|
-
/** 隔离区里的三个 status 常量。 */
|
|
162
|
-
export const QUARANTINE_STATUSES = ['pending', 'rejected', 'deleted']
|
|
163
|
-
/** 状态 → 它应该待在哪张表。 */
|
|
164
|
-
export function tableForStatus(status) {
|
|
165
|
-
return String(status) === MAIN_STATUS ? 'cards' : 'quarantine'
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* 迁移到两套存储(幂等,可重复执行,每次 getDb 都会跑一遍):
|
|
170
|
-
* 把主库里所有非 approved 的行搬进隔离区,并带上它们的 card_updates。
|
|
171
|
-
*
|
|
172
|
-
* 关键性质:**绝不删除任何内容**,只换表。老版本代码回滚后看不到隔离区(审核队列会
|
|
173
|
-
* 显示为空),但数据还在同一个 .db 文件里,升级回来即恢复。
|
|
174
|
-
*/
|
|
175
|
-
export function migrateToQuarantine(db) {
|
|
176
|
-
let pending = []
|
|
177
|
-
try {
|
|
178
|
-
pending = db.prepare("SELECT id, path FROM cards WHERE status <> ?").all(MAIN_STATUS)
|
|
179
|
-
} catch { return 0 }
|
|
180
|
-
if (!pending.length) return 0
|
|
181
|
-
|
|
182
|
-
// 老库的 card_updates 有指向 cards(id) 的外键:把卡搬走时 FK 会拒绝删除主库行。
|
|
183
|
-
// 迁移期间**临时关掉外键校验**(SQLite 允许 PRAGMA foreign_keys 在无事务时切换),
|
|
184
|
-
// 搬完立刻恢复 —— 之后建表/写入仍受外键保护。
|
|
185
|
-
let fkWasOn = false
|
|
186
|
-
try {
|
|
187
|
-
fkWasOn = db.prepare('PRAGMA foreign_keys').get().foreign_keys === 1
|
|
188
|
-
if (fkWasOn) db.exec('PRAGMA foreign_keys = OFF')
|
|
189
|
-
} catch { fkWasOn = false }
|
|
190
|
-
|
|
191
|
-
const insertQuarantine = db.prepare(`
|
|
192
|
-
INSERT OR IGNORE INTO quarantine
|
|
193
|
-
(id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at, quarantined_at, quarantine_reason)
|
|
194
|
-
SELECT id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at, datetime('now'), '迁移:主库只保留 approved'
|
|
195
|
-
FROM cards WHERE id = ?
|
|
196
|
-
`)
|
|
197
|
-
const dropFromMain = db.prepare('DELETE FROM cards WHERE id = ?')
|
|
198
|
-
|
|
199
|
-
let moved = 0
|
|
200
|
-
for (const row of pending) {
|
|
201
|
-
try {
|
|
202
|
-
db.exec('BEGIN')
|
|
203
|
-
const info = insertQuarantine.run(row.id)
|
|
204
|
-
// card_updates 按 card_id 关联,id 不变 → 更新记录跟着卡一起走,无需改写
|
|
205
|
-
if (info.changes) moved++
|
|
206
|
-
dropFromMain.run(row.id)
|
|
207
|
-
db.exec('COMMIT')
|
|
208
|
-
} catch (e) {
|
|
209
|
-
try { db.exec('ROLLBACK') } catch { /* 已回滚 */ }
|
|
210
|
-
process.stderr.write(`[memory-eternal] 隔离迁移失败(${row.path}):${e?.message || e}\n`)
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
if (fkWasOn) { try { db.exec('PRAGMA foreign_keys = ON') } catch { /* 恢复失败:下次 getDb 会再设一次 */ } }
|
|
214
|
-
if (moved) process.stderr.write(`[memory-eternal] 已把 ${moved} 张未审核卡搬进隔离区(主库只留 approved)\n`)
|
|
215
|
-
return moved
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* 一致性不变量:主库只能有 approved。
|
|
220
|
-
* 供测试与「用量/今日」体检使用 —— 正常永远是 0,非 0 说明有代码绕过写入闸门。
|
|
221
|
-
*/
|
|
222
|
-
export function checkMainStoreInvariant(root) {
|
|
223
|
-
const db = getDb(root)
|
|
224
|
-
try {
|
|
225
|
-
const bad = db.prepare('SELECT status, COUNT(*) n FROM cards WHERE status <> ? GROUP BY status').all(MAIN_STATUS)
|
|
226
|
-
const total = db.prepare('SELECT COUNT(*) n FROM cards').get().n
|
|
227
|
-
const quarantined = db.prepare('SELECT status, COUNT(*) n FROM quarantine GROUP BY status').all()
|
|
228
|
-
return { ok: bad.length === 0, violations: bad, mainTotal: total, quarantine: quarantined }
|
|
229
|
-
} catch (e) {
|
|
230
|
-
return { ok: false, violations: [], mainTotal: 0, quarantine: [], error: String(e?.message || e) }
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* 审核守卫:根据 config 表的审核规则决定新卡 status。
|
|
236
|
-
* 所有写入路径必须经过此函数,杜绝越权。
|
|
237
|
-
*
|
|
238
|
-
* 规则:
|
|
239
|
-
* - config 表无 auditMode 记录 → 用 fallback(调用方传入的 status)
|
|
240
|
-
* - auditMode = 'none' → approved(全部免审)
|
|
241
|
-
* - auditMode = 'all' → 检查免审白名单,命中→approved,否则→pending
|
|
242
|
-
*
|
|
243
|
-
* @param {string} root - vault 根目录
|
|
244
|
-
* @param {string} kind - 卡片类型
|
|
245
|
-
* @param {string} submittedBy - 提交者
|
|
246
|
-
* @param {string} fallback - 调用方传入的 status(无配置时使用)
|
|
247
|
-
* @returns {'pending' | 'approved'}
|
|
248
|
-
*/
|
|
249
|
-
export function enforceAudit(root, kind, submittedBy, fallback = 'pending') {
|
|
250
|
-
const db = getDb(root)
|
|
251
|
-
const get = (key) => { try { return db.prepare('SELECT value FROM config WHERE key = ?').get(key)?.value } catch { return null } }
|
|
252
|
-
const mode = get('auditMode')
|
|
253
|
-
// 无配置 → 用调用方传入的 status
|
|
254
|
-
if (!mode) return fallback
|
|
255
|
-
if (mode === 'none') return 'approved'
|
|
256
|
-
const agentsRaw = get('auditExemptAgents') || '[]'
|
|
257
|
-
const kindsRaw = get('auditExemptKinds') || '[]'
|
|
258
|
-
let agents = [], kinds = []
|
|
259
|
-
try { agents = JSON.parse(agentsRaw) } catch { agents = agentsRaw.split(',').map(s => s.trim()).filter(Boolean) }
|
|
260
|
-
try { kinds = JSON.parse(kindsRaw) } catch { kinds = kindsRaw.split(',').map(s => s.trim()).filter(Boolean) }
|
|
261
|
-
if (agents.includes('__all__') || agents.includes(submittedBy)) return 'approved'
|
|
262
|
-
if (kinds.includes('__all__') || kinds.includes(kind)) return 'approved'
|
|
263
|
-
return 'pending'
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** 写入审核配置到 config 表(供 index.js 同步 DSH settings)。 */
|
|
267
|
-
export function setAuditConfig(root, { auditMode, auditExemptAgents, auditExemptKinds }) {
|
|
268
|
-
const db = getDb(root)
|
|
269
|
-
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`)
|
|
270
|
-
if (auditMode !== undefined) upsert.run('auditMode', String(auditMode))
|
|
271
|
-
if (auditExemptAgents !== undefined) upsert.run('auditExemptAgents', JSON.stringify(auditExemptAgents))
|
|
272
|
-
if (auditExemptKinds !== undefined) upsert.run('auditExemptKinds', JSON.stringify(auditExemptKinds))
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** 从 .md 文件迁移到 SQLite(幂等:已存在的卡跳过)。 */
|
|
276
|
-
export async function migrateFromMarkdown(root) {
|
|
277
|
-
const db = getDb(root)
|
|
278
|
-
const existing = db.prepare('SELECT COUNT(*) as cnt FROM cards').get()
|
|
279
|
-
if (existing.cnt > 0) return { migrated: 0, skipped: existing.cnt }
|
|
280
|
-
|
|
281
|
-
let migrated = 0
|
|
282
|
-
const walk = async (dir, base) => {
|
|
283
|
-
let ents = []
|
|
284
|
-
try { ents = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
|
|
285
|
-
for (const ent of ents) {
|
|
286
|
-
const full = path.join(dir, ent.name)
|
|
287
|
-
if (ent.isDirectory()) { await walk(full, base); continue }
|
|
288
|
-
if (!ent.name.endsWith('.md')) continue
|
|
289
|
-
try {
|
|
290
|
-
const text = await fs.readFile(full, 'utf8')
|
|
291
|
-
const { meta, body, summary } = parseCard(text)
|
|
292
|
-
const rel = path.relative(base, full).split(path.sep).join('/')
|
|
293
|
-
const stat = await fs.stat(full)
|
|
294
|
-
db.prepare(`
|
|
295
|
-
INSERT OR IGNORE INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
|
|
296
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
297
|
-
`).run(
|
|
298
|
-
rel,
|
|
299
|
-
meta.kind || 'knowledge',
|
|
300
|
-
meta.title || rel.replace(/\.[^.]*$/, '').replace(/^[^/]*\//, ''),
|
|
301
|
-
JSON.stringify(meta.tags || []),
|
|
302
|
-
body.trim(),
|
|
303
|
-
summary,
|
|
304
|
-
meta.status || 'pending',
|
|
305
|
-
meta.source || '',
|
|
306
|
-
meta.submittedBy || '',
|
|
307
|
-
meta.severity || 'info',
|
|
308
|
-
meta.reason || '',
|
|
309
|
-
meta.created || stat.mtime.toISOString(),
|
|
310
|
-
meta.updated || stat.mtime.toISOString(),
|
|
311
|
-
meta.deletedAt || null,
|
|
312
|
-
)
|
|
313
|
-
migrated++
|
|
314
|
-
} catch { /* 跳过坏文件 */ }
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
await walk(root, root)
|
|
318
|
-
return { migrated, skipped: 0 }
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export function closeDb(root) {
|
|
322
|
-
const resolved = path.resolve(root)
|
|
323
|
-
const db = instances.get(resolved)
|
|
324
|
-
if (db) { db.close(); instances.delete(resolved) }
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/** 关闭全部缓存连接(测试清理用:Windows 上开着连接删目录会 EBUSY)。 */
|
|
328
|
-
export function closeAllDb() {
|
|
329
|
-
for (const db of instances.values()) { try { db.close() } catch {} }
|
|
330
|
-
instances.clear()
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* SQLite 备份:用 VACUUM INTO 创建紧凑副本。
|
|
335
|
-
* 保留最近 maxKeep 个备份,自动清理旧的。
|
|
336
|
-
*/
|
|
337
|
-
export async function backupDb(root, { maxKeep = 7 } = {}) {
|
|
338
|
-
const resolved = path.resolve(root)
|
|
339
|
-
const dbPath = path.join(resolved, DB_FILE)
|
|
340
|
-
const backupDir = path.join(resolved, 'backups')
|
|
341
|
-
await fs.mkdir(backupDir, { recursive: true })
|
|
342
|
-
const now = new Date()
|
|
343
|
-
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
344
|
-
const time = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`
|
|
345
|
-
const backupPath = path.join(backupDir, `memory-eternal-${date}_${time}.db`)
|
|
346
|
-
try {
|
|
347
|
-
const db = getDb(root)
|
|
348
|
-
db.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`)
|
|
349
|
-
// 清理旧备份:保留最近 maxKeep 个
|
|
350
|
-
const files = await fs.readdir(backupDir)
|
|
351
|
-
const dbBackups = files.filter(f => f.startsWith('memory-eternal-') && f.endsWith('.db')).sort().reverse()
|
|
352
|
-
for (const old of dbBackups.slice(maxKeep)) {
|
|
353
|
-
try { await fs.unlink(path.join(backupDir, old)) } catch {}
|
|
354
|
-
}
|
|
355
|
-
return { ok: true, path: backupPath, kept: Math.min(dbBackups.length, maxKeep) }
|
|
356
|
-
} catch (e) {
|
|
357
|
-
return { ok: false, error: String(e.message || e) }
|
|
358
|
-
}
|
|
359
|
-
}
|
|
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
|
+
/**
|
|
30
|
+
* 建表。
|
|
31
|
+
*
|
|
32
|
+
* 存储模型(v0.10「两套存储体系」):
|
|
33
|
+
* cards = **主库 / 正常区**:物理上只存 approved。召回、检索、图谱、去重、
|
|
34
|
+
* 导出这些读路径**不再需要写 `WHERE status='approved'`** —— 查主库即安全。
|
|
35
|
+
* quarantine = **隔离区 / 异常区**:pending(待审)/ rejected(驳回)/ deleted(回收站)。
|
|
36
|
+
* 审核中心与回收站的唯一数据源;除审核操作外无人读它。
|
|
37
|
+
*
|
|
38
|
+
* 这样做的原因:v0.9 及以前是「一张表 + status 列」,安全性依赖每一处 SQL 都记得加
|
|
39
|
+
* 状态条件。审计发现三处漏了(去重池把新知识追加进待审卡、readCard 直读未审核正文、
|
|
40
|
+
* /card 接口无状态校验)—— 只要有人再漏一处就会重演。物理分表后,漏写 `WHERE` 也不泄漏。
|
|
41
|
+
*/
|
|
42
|
+
function initTables(db) {
|
|
43
|
+
db.exec(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
45
|
+
key TEXT PRIMARY KEY,
|
|
46
|
+
value TEXT NOT NULL,
|
|
47
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS cards (
|
|
51
|
+
id INTEGER PRIMARY KEY,
|
|
52
|
+
path TEXT UNIQUE NOT NULL,
|
|
53
|
+
kind TEXT NOT NULL DEFAULT 'knowledge',
|
|
54
|
+
title TEXT NOT NULL DEFAULT '',
|
|
55
|
+
tags TEXT DEFAULT '[]',
|
|
56
|
+
body TEXT NOT NULL DEFAULT '',
|
|
57
|
+
summary TEXT DEFAULT '',
|
|
58
|
+
status TEXT NOT NULL DEFAULT 'approved',
|
|
59
|
+
source TEXT DEFAULT '',
|
|
60
|
+
submitted_by TEXT DEFAULT '',
|
|
61
|
+
severity TEXT DEFAULT 'info',
|
|
62
|
+
reason TEXT DEFAULT '',
|
|
63
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
64
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
65
|
+
deleted_at TEXT
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_cards_status ON cards(status);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_cards_kind ON cards(kind);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_cards_deleted ON cards(deleted_at);
|
|
71
|
+
|
|
72
|
+
-- 隔离区:结构与主库一致,另加 quarantined_at(进隔离区的时间)与 quarantine_reason
|
|
73
|
+
CREATE TABLE IF NOT EXISTS quarantine (
|
|
74
|
+
id INTEGER PRIMARY KEY,
|
|
75
|
+
path TEXT UNIQUE NOT NULL,
|
|
76
|
+
kind TEXT NOT NULL DEFAULT 'knowledge',
|
|
77
|
+
title TEXT NOT NULL DEFAULT '',
|
|
78
|
+
tags TEXT DEFAULT '[]',
|
|
79
|
+
body TEXT NOT NULL DEFAULT '',
|
|
80
|
+
summary TEXT DEFAULT '',
|
|
81
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
82
|
+
source TEXT DEFAULT '',
|
|
83
|
+
submitted_by TEXT DEFAULT '',
|
|
84
|
+
severity TEXT DEFAULT 'info',
|
|
85
|
+
reason TEXT DEFAULT '',
|
|
86
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
87
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
88
|
+
deleted_at TEXT,
|
|
89
|
+
quarantined_at TEXT DEFAULT (datetime('now')),
|
|
90
|
+
quarantine_reason TEXT DEFAULT ''
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_quarantine_status ON quarantine(status);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_quarantine_kind ON quarantine(kind);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_quarantine_deleted ON quarantine(deleted_at);
|
|
96
|
+
|
|
97
|
+
-- 更新记录:card_id 指向 cards.id **或** quarantine.id(两表共享 id 空间)。
|
|
98
|
+
-- 因此这里**不能**建外键:SQLite 的外键只认一张父表,卡被搬进隔离区时 FK 校验会直接失败
|
|
99
|
+
-- (实测 FOREIGN KEY constraint failed)。永久删除卡片时由 deleteCard 手工清理更新记录。
|
|
100
|
+
CREATE TABLE IF NOT EXISTS card_updates (
|
|
101
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
102
|
+
card_id INTEGER NOT NULL,
|
|
103
|
+
content TEXT NOT NULL,
|
|
104
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_card_updates_card ON card_updates(card_id);
|
|
108
|
+
|
|
109
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
110
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
111
|
+
query TEXT NOT NULL,
|
|
112
|
+
card_path TEXT NOT NULL,
|
|
113
|
+
useful INTEGER NOT NULL DEFAULT 0,
|
|
114
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
-- 审核日志:记录所有 status 变更,不可篡改
|
|
118
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
119
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
120
|
+
card_path TEXT NOT NULL,
|
|
121
|
+
old_status TEXT,
|
|
122
|
+
new_status TEXT NOT NULL,
|
|
123
|
+
changed_by TEXT DEFAULT 'system',
|
|
124
|
+
reason TEXT DEFAULT '',
|
|
125
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
-- 卡片 id 发号器(两张表**共用**)。
|
|
129
|
+
--
|
|
130
|
+
-- 为什么不用 AUTOINCREMENT:cards 与 quarantine 是两张 AUTOINCREMENT 表,各有一份
|
|
131
|
+
-- sqlite_sequence 计数器,且都从 1 开始 —— 于是两张表会同时出现 id=1,而卡需要在
|
|
132
|
+
-- 两表间搬家并保留 id,必然撞 UNIQUE constraint failed。实测确认:
|
|
133
|
+
-- 共享 id 空间 + 双 AUTOINCREMENT 在 SQLite 里无法自洽。
|
|
134
|
+
-- 改为「显式发号 + 单一持久计数器」:id 全库唯一、单调递增、删除后也不复用,
|
|
135
|
+
-- 而且任何一次 renumber 都是显式 error,不会静默串记录。
|
|
136
|
+
CREATE TABLE IF NOT EXISTS card_sequence (
|
|
137
|
+
name TEXT PRIMARY KEY,
|
|
138
|
+
seq INTEGER NOT NULL
|
|
139
|
+
);
|
|
140
|
+
`)
|
|
141
|
+
// 种子:取两表最大 id(老库升级时把已有卡都覆盖进去);没有则从 0 起
|
|
142
|
+
db.prepare(`INSERT INTO card_sequence (name, seq) VALUES ('card', 0) ON CONFLICT(name) DO NOTHING`).run()
|
|
143
|
+
db.prepare(`UPDATE card_sequence SET seq = (
|
|
144
|
+
SELECT MAX(m) FROM (SELECT MAX(id) m FROM cards UNION ALL SELECT MAX(id) FROM quarantine)
|
|
145
|
+
) WHERE name = 'card' AND seq < (
|
|
146
|
+
SELECT MAX(m) FROM (SELECT MAX(id) m FROM cards UNION ALL SELECT MAX(id) FROM quarantine)
|
|
147
|
+
)`).run()
|
|
148
|
+
migrateToQuarantine(db)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 分配一个新的卡片 id(跨两张表唯一、单调递增)。 */
|
|
152
|
+
export function nextCardId(db) {
|
|
153
|
+
db.prepare("UPDATE card_sequence SET seq = seq + 1 WHERE name = 'card'").run()
|
|
154
|
+
const row = db.prepare("SELECT seq FROM card_sequence WHERE name = 'card'").get()
|
|
155
|
+
if (!row) throw new Error('card_sequence 未初始化')
|
|
156
|
+
return row.seq
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 主库里的每一个 status 常量:只有它算「正常区」。 */
|
|
160
|
+
export const MAIN_STATUS = 'approved'
|
|
161
|
+
/** 隔离区里的三个 status 常量。 */
|
|
162
|
+
export const QUARANTINE_STATUSES = ['pending', 'rejected', 'deleted']
|
|
163
|
+
/** 状态 → 它应该待在哪张表。 */
|
|
164
|
+
export function tableForStatus(status) {
|
|
165
|
+
return String(status) === MAIN_STATUS ? 'cards' : 'quarantine'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 迁移到两套存储(幂等,可重复执行,每次 getDb 都会跑一遍):
|
|
170
|
+
* 把主库里所有非 approved 的行搬进隔离区,并带上它们的 card_updates。
|
|
171
|
+
*
|
|
172
|
+
* 关键性质:**绝不删除任何内容**,只换表。老版本代码回滚后看不到隔离区(审核队列会
|
|
173
|
+
* 显示为空),但数据还在同一个 .db 文件里,升级回来即恢复。
|
|
174
|
+
*/
|
|
175
|
+
export function migrateToQuarantine(db) {
|
|
176
|
+
let pending = []
|
|
177
|
+
try {
|
|
178
|
+
pending = db.prepare("SELECT id, path FROM cards WHERE status <> ?").all(MAIN_STATUS)
|
|
179
|
+
} catch { return 0 }
|
|
180
|
+
if (!pending.length) return 0
|
|
181
|
+
|
|
182
|
+
// 老库的 card_updates 有指向 cards(id) 的外键:把卡搬走时 FK 会拒绝删除主库行。
|
|
183
|
+
// 迁移期间**临时关掉外键校验**(SQLite 允许 PRAGMA foreign_keys 在无事务时切换),
|
|
184
|
+
// 搬完立刻恢复 —— 之后建表/写入仍受外键保护。
|
|
185
|
+
let fkWasOn = false
|
|
186
|
+
try {
|
|
187
|
+
fkWasOn = db.prepare('PRAGMA foreign_keys').get().foreign_keys === 1
|
|
188
|
+
if (fkWasOn) db.exec('PRAGMA foreign_keys = OFF')
|
|
189
|
+
} catch { fkWasOn = false }
|
|
190
|
+
|
|
191
|
+
const insertQuarantine = db.prepare(`
|
|
192
|
+
INSERT OR IGNORE INTO quarantine
|
|
193
|
+
(id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at, quarantined_at, quarantine_reason)
|
|
194
|
+
SELECT id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at, datetime('now'), '迁移:主库只保留 approved'
|
|
195
|
+
FROM cards WHERE id = ?
|
|
196
|
+
`)
|
|
197
|
+
const dropFromMain = db.prepare('DELETE FROM cards WHERE id = ?')
|
|
198
|
+
|
|
199
|
+
let moved = 0
|
|
200
|
+
for (const row of pending) {
|
|
201
|
+
try {
|
|
202
|
+
db.exec('BEGIN')
|
|
203
|
+
const info = insertQuarantine.run(row.id)
|
|
204
|
+
// card_updates 按 card_id 关联,id 不变 → 更新记录跟着卡一起走,无需改写
|
|
205
|
+
if (info.changes) moved++
|
|
206
|
+
dropFromMain.run(row.id)
|
|
207
|
+
db.exec('COMMIT')
|
|
208
|
+
} catch (e) {
|
|
209
|
+
try { db.exec('ROLLBACK') } catch { /* 已回滚 */ }
|
|
210
|
+
process.stderr.write(`[memory-eternal] 隔离迁移失败(${row.path}):${e?.message || e}\n`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (fkWasOn) { try { db.exec('PRAGMA foreign_keys = ON') } catch { /* 恢复失败:下次 getDb 会再设一次 */ } }
|
|
214
|
+
if (moved) process.stderr.write(`[memory-eternal] 已把 ${moved} 张未审核卡搬进隔离区(主库只留 approved)\n`)
|
|
215
|
+
return moved
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 一致性不变量:主库只能有 approved。
|
|
220
|
+
* 供测试与「用量/今日」体检使用 —— 正常永远是 0,非 0 说明有代码绕过写入闸门。
|
|
221
|
+
*/
|
|
222
|
+
export function checkMainStoreInvariant(root) {
|
|
223
|
+
const db = getDb(root)
|
|
224
|
+
try {
|
|
225
|
+
const bad = db.prepare('SELECT status, COUNT(*) n FROM cards WHERE status <> ? GROUP BY status').all(MAIN_STATUS)
|
|
226
|
+
const total = db.prepare('SELECT COUNT(*) n FROM cards').get().n
|
|
227
|
+
const quarantined = db.prepare('SELECT status, COUNT(*) n FROM quarantine GROUP BY status').all()
|
|
228
|
+
return { ok: bad.length === 0, violations: bad, mainTotal: total, quarantine: quarantined }
|
|
229
|
+
} catch (e) {
|
|
230
|
+
return { ok: false, violations: [], mainTotal: 0, quarantine: [], error: String(e?.message || e) }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 审核守卫:根据 config 表的审核规则决定新卡 status。
|
|
236
|
+
* 所有写入路径必须经过此函数,杜绝越权。
|
|
237
|
+
*
|
|
238
|
+
* 规则:
|
|
239
|
+
* - config 表无 auditMode 记录 → 用 fallback(调用方传入的 status)
|
|
240
|
+
* - auditMode = 'none' → approved(全部免审)
|
|
241
|
+
* - auditMode = 'all' → 检查免审白名单,命中→approved,否则→pending
|
|
242
|
+
*
|
|
243
|
+
* @param {string} root - vault 根目录
|
|
244
|
+
* @param {string} kind - 卡片类型
|
|
245
|
+
* @param {string} submittedBy - 提交者
|
|
246
|
+
* @param {string} fallback - 调用方传入的 status(无配置时使用)
|
|
247
|
+
* @returns {'pending' | 'approved'}
|
|
248
|
+
*/
|
|
249
|
+
export function enforceAudit(root, kind, submittedBy, fallback = 'pending') {
|
|
250
|
+
const db = getDb(root)
|
|
251
|
+
const get = (key) => { try { return db.prepare('SELECT value FROM config WHERE key = ?').get(key)?.value } catch { return null } }
|
|
252
|
+
const mode = get('auditMode')
|
|
253
|
+
// 无配置 → 用调用方传入的 status
|
|
254
|
+
if (!mode) return fallback
|
|
255
|
+
if (mode === 'none') return 'approved'
|
|
256
|
+
const agentsRaw = get('auditExemptAgents') || '[]'
|
|
257
|
+
const kindsRaw = get('auditExemptKinds') || '[]'
|
|
258
|
+
let agents = [], kinds = []
|
|
259
|
+
try { agents = JSON.parse(agentsRaw) } catch { agents = agentsRaw.split(',').map(s => s.trim()).filter(Boolean) }
|
|
260
|
+
try { kinds = JSON.parse(kindsRaw) } catch { kinds = kindsRaw.split(',').map(s => s.trim()).filter(Boolean) }
|
|
261
|
+
if (agents.includes('__all__') || agents.includes(submittedBy)) return 'approved'
|
|
262
|
+
if (kinds.includes('__all__') || kinds.includes(kind)) return 'approved'
|
|
263
|
+
return 'pending'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 写入审核配置到 config 表(供 index.js 同步 DSH settings)。 */
|
|
267
|
+
export function setAuditConfig(root, { auditMode, auditExemptAgents, auditExemptKinds }) {
|
|
268
|
+
const db = getDb(root)
|
|
269
|
+
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`)
|
|
270
|
+
if (auditMode !== undefined) upsert.run('auditMode', String(auditMode))
|
|
271
|
+
if (auditExemptAgents !== undefined) upsert.run('auditExemptAgents', JSON.stringify(auditExemptAgents))
|
|
272
|
+
if (auditExemptKinds !== undefined) upsert.run('auditExemptKinds', JSON.stringify(auditExemptKinds))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 从 .md 文件迁移到 SQLite(幂等:已存在的卡跳过)。 */
|
|
276
|
+
export async function migrateFromMarkdown(root) {
|
|
277
|
+
const db = getDb(root)
|
|
278
|
+
const existing = db.prepare('SELECT COUNT(*) as cnt FROM cards').get()
|
|
279
|
+
if (existing.cnt > 0) return { migrated: 0, skipped: existing.cnt }
|
|
280
|
+
|
|
281
|
+
let migrated = 0
|
|
282
|
+
const walk = async (dir, base) => {
|
|
283
|
+
let ents = []
|
|
284
|
+
try { ents = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
|
|
285
|
+
for (const ent of ents) {
|
|
286
|
+
const full = path.join(dir, ent.name)
|
|
287
|
+
if (ent.isDirectory()) { await walk(full, base); continue }
|
|
288
|
+
if (!ent.name.endsWith('.md')) continue
|
|
289
|
+
try {
|
|
290
|
+
const text = await fs.readFile(full, 'utf8')
|
|
291
|
+
const { meta, body, summary } = parseCard(text)
|
|
292
|
+
const rel = path.relative(base, full).split(path.sep).join('/')
|
|
293
|
+
const stat = await fs.stat(full)
|
|
294
|
+
db.prepare(`
|
|
295
|
+
INSERT OR IGNORE INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
|
|
296
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
297
|
+
`).run(
|
|
298
|
+
rel,
|
|
299
|
+
meta.kind || 'knowledge',
|
|
300
|
+
meta.title || rel.replace(/\.[^.]*$/, '').replace(/^[^/]*\//, ''),
|
|
301
|
+
JSON.stringify(meta.tags || []),
|
|
302
|
+
body.trim(),
|
|
303
|
+
summary,
|
|
304
|
+
meta.status || 'pending',
|
|
305
|
+
meta.source || '',
|
|
306
|
+
meta.submittedBy || '',
|
|
307
|
+
meta.severity || 'info',
|
|
308
|
+
meta.reason || '',
|
|
309
|
+
meta.created || stat.mtime.toISOString(),
|
|
310
|
+
meta.updated || stat.mtime.toISOString(),
|
|
311
|
+
meta.deletedAt || null,
|
|
312
|
+
)
|
|
313
|
+
migrated++
|
|
314
|
+
} catch { /* 跳过坏文件 */ }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
await walk(root, root)
|
|
318
|
+
return { migrated, skipped: 0 }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function closeDb(root) {
|
|
322
|
+
const resolved = path.resolve(root)
|
|
323
|
+
const db = instances.get(resolved)
|
|
324
|
+
if (db) { db.close(); instances.delete(resolved) }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 关闭全部缓存连接(测试清理用:Windows 上开着连接删目录会 EBUSY)。 */
|
|
328
|
+
export function closeAllDb() {
|
|
329
|
+
for (const db of instances.values()) { try { db.close() } catch {} }
|
|
330
|
+
instances.clear()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* SQLite 备份:用 VACUUM INTO 创建紧凑副本。
|
|
335
|
+
* 保留最近 maxKeep 个备份,自动清理旧的。
|
|
336
|
+
*/
|
|
337
|
+
export async function backupDb(root, { maxKeep = 7 } = {}) {
|
|
338
|
+
const resolved = path.resolve(root)
|
|
339
|
+
const dbPath = path.join(resolved, DB_FILE)
|
|
340
|
+
const backupDir = path.join(resolved, 'backups')
|
|
341
|
+
await fs.mkdir(backupDir, { recursive: true })
|
|
342
|
+
const now = new Date()
|
|
343
|
+
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
344
|
+
const time = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`
|
|
345
|
+
const backupPath = path.join(backupDir, `memory-eternal-${date}_${time}.db`)
|
|
346
|
+
try {
|
|
347
|
+
const db = getDb(root)
|
|
348
|
+
db.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`)
|
|
349
|
+
// 清理旧备份:保留最近 maxKeep 个
|
|
350
|
+
const files = await fs.readdir(backupDir)
|
|
351
|
+
const dbBackups = files.filter(f => f.startsWith('memory-eternal-') && f.endsWith('.db')).sort().reverse()
|
|
352
|
+
for (const old of dbBackups.slice(maxKeep)) {
|
|
353
|
+
try { await fs.unlink(path.join(backupDir, old)) } catch {}
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, path: backupPath, kept: Math.min(dbBackups.length, maxKeep) }
|
|
356
|
+
} catch (e) {
|
|
357
|
+
return { ok: false, error: String(e.message || e) }
|
|
358
|
+
}
|
|
359
|
+
}
|