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 +14 -0
- package/lib/capture.js +2 -2
- package/lib/db.js +178 -0
- package/lib/vault.js +304 -402
- package/package.json +1 -1
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(
|
|
117
|
-
return (text, threshold = DEDUP_THRESHOLD) => dedupCheck(
|
|
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
|
-
// 记忆核心 ·
|
|
1
|
+
// 记忆核心 · SQLite Vault 存储层
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
138
|
-
async function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
/**
|
|
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
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
183
|
+
/** 回收站:status=deleted,按 deleted_at 倒序。 */
|
|
194
184
|
export async function recycleList(root) {
|
|
195
|
-
const
|
|
196
|
-
|
|
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
|
|
202
|
-
const
|
|
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
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
217
|
+
/** 软删卡片:status=deleted + deleted_at。 */
|
|
272
218
|
export async function deleteCard(root, rel, { permanent = false } = {}) {
|
|
273
|
-
const
|
|
274
|
-
if (!target) throw new Error('路径越界')
|
|
219
|
+
const db = getDb(root)
|
|
275
220
|
if (permanent) {
|
|
276
|
-
|
|
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
|
|
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,清除
|
|
230
|
+
/** 恢复软删卡:status=approved,清除 deleted_at。写审计日志。 */
|
|
309
231
|
export async function restoreCard(root, rel) {
|
|
310
|
-
const
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
370
|
-
|
|
259
|
+
|
|
260
|
+
// 审核守卫:数据库层强制,调用方传的 status 仅作 fallback
|
|
261
|
+
const enforcedStatus = enforceAudit(root, kind, submittedBy, status)
|
|
262
|
+
|
|
371
263
|
if (dedup) {
|
|
372
|
-
const hit = await dedupCheck(
|
|
373
|
-
if (hit) return { ok: false, duplicate: { ...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 (
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
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:
|
|
358
|
+
out.push({ ...card, score, excerpt: row.body.trim().slice(0, 300) })
|
|
471
359
|
}
|
|
472
360
|
}
|
|
473
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
/**
|
|
422
|
+
/** 用量趋势:最近 N 天每天新增卡片数。 */
|
|
518
423
|
export async function dailyCounts(root, days = 30) {
|
|
519
|
-
const
|
|
520
|
-
const
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
434
|
+
return out
|
|
534
435
|
}
|
|
535
436
|
|
|
536
437
|
/** 导出全部卡片原始 Markdown。 */
|
|
537
438
|
export async function exportCards(root) {
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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]]
|
|
447
|
+
/** 图谱:节点 = 卡片;边 = [[wikilink]] 或共享标签。 */
|
|
547
448
|
export async function graph(root) {
|
|
548
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
504
|
+
|
|
505
|
+
// 相似度关联
|
|
609
506
|
const similarCount = {}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
if (
|
|
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
|
-
/**
|
|
517
|
+
/** 统计。 */
|
|
625
518
|
export async function stats(root) {
|
|
626
|
-
const
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
const
|
|
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
|
|
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:
|
|
637
|
-
today:
|
|
638
|
-
week:
|
|
537
|
+
total: totalRow.cnt,
|
|
538
|
+
today: todayRow.cnt,
|
|
539
|
+
week: weekRow.cnt,
|
|
639
540
|
tags: tagSet.size,
|
|
640
541
|
byKind,
|
|
641
|
-
todayCards
|
|
542
|
+
todayCards,
|
|
642
543
|
}
|
|
643
544
|
}
|
|
644
545
|
|
|
645
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
573
|
+
/** 整理建议(非破坏性预览)。 */
|
|
673
574
|
export async function optimizeCandidates(root) {
|
|
674
|
-
const
|
|
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(
|
|
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
|
-
/**
|
|
643
|
+
/** 读取反馈记录。 */
|
|
752
644
|
export async function readFeedback(root) {
|
|
753
|
-
|
|
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
|
|
757
|
-
|
|
758
|
-
|
|
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
|
|
763
|
-
export async function overview(root) {
|
|
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.
|
|
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": {
|