memory-eternal 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +5 -0
- package/lib/capture.js +2 -2
- package/lib/db.js +126 -0
- package/lib/vault.js +294 -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 } from './lib/db.js'
|
|
29
30
|
import { summarizeTurn, extractLastTurn, sliceNewEvents, resolveRoute, captureCard, captureUpdate, pickNeighbors } from './lib/capture.js'
|
|
30
31
|
import { createApi, json } from './lib/api.js'
|
|
31
32
|
|
|
@@ -96,6 +97,7 @@ const DSH_AGENT = 'deepseek-harness'
|
|
|
96
97
|
export function apply(ctx, config) {
|
|
97
98
|
const settings = ctx.settings.register('memory-eternal', Config, { base: config ?? {} })
|
|
98
99
|
|
|
100
|
+
// 首次激活:自动从 .md 文件迁移到 SQLite(幂等,已有数据则跳过)
|
|
99
101
|
const vaultDir = () => {
|
|
100
102
|
const cfg = settings.get() ?? {}
|
|
101
103
|
// 多 Vault:若配了 vaultProfiles 且选中了 activeVault,则用该 profile 的目录。
|
|
@@ -108,6 +110,9 @@ export function apply(ctx, config) {
|
|
|
108
110
|
return path.join(home, 'memory-vault')
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
// 自动迁移:从 .md 文件导入 SQLite(幂等,DB 有数据则跳过)
|
|
114
|
+
try { migrateFromMarkdown(vaultDir()).catch(() => {}) } catch {}
|
|
115
|
+
|
|
111
116
|
// 所有 profile 目录(当前激活 + 其余命名的),供跨库聚合。
|
|
112
117
|
const vaultRoots = () => {
|
|
113
118
|
const cfg = settings.get() ?? {}
|
package/lib/capture.js
CHANGED
|
@@ -113,8 +113,8 @@ export async function pickNeighbors(root, card, limit = DEDUP_NEIGHBORS) {
|
|
|
113
113
|
* 去重守卫:目标目录里已有高度相似卡片时返回它,否则返回 null。
|
|
114
114
|
* @returns Promise<null | { path, score }>
|
|
115
115
|
*/
|
|
116
|
-
export function makeDedupChecker(
|
|
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,126 @@
|
|
|
1
|
+
// 记忆核心 · SQLite 存储层(node:sqlite 内置,零依赖)
|
|
2
|
+
//
|
|
3
|
+
// 4 张表:config / cards / card_updates / feedback
|
|
4
|
+
// 首次启动自动从 .md 文件迁移(幂等)
|
|
5
|
+
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
7
|
+
import { promises as fs, mkdirSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { parseCard, KIND_ROOTS } from './vault.js'
|
|
10
|
+
|
|
11
|
+
const DB_FILE = 'memory-eternal.db'
|
|
12
|
+
|
|
13
|
+
/** 获取或创建 SQLite 数据库连接(单例 per root)。 */
|
|
14
|
+
const instances = new Map()
|
|
15
|
+
export function getDb(root) {
|
|
16
|
+
const resolved = path.resolve(root)
|
|
17
|
+
if (instances.has(resolved)) return instances.get(resolved)
|
|
18
|
+
// 确保目录存在(SQLite 需要目录才能创建 .db 文件)
|
|
19
|
+
mkdirSync(resolved, { recursive: true })
|
|
20
|
+
const dbPath = path.join(resolved, DB_FILE)
|
|
21
|
+
const db = new DatabaseSync(dbPath)
|
|
22
|
+
db.exec('PRAGMA journal_mode=WAL')
|
|
23
|
+
db.exec('PRAGMA foreign_keys=ON')
|
|
24
|
+
initTables(db)
|
|
25
|
+
instances.set(resolved, db)
|
|
26
|
+
return db
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function initTables(db) {
|
|
30
|
+
db.exec(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
32
|
+
key TEXT PRIMARY KEY,
|
|
33
|
+
value TEXT NOT NULL,
|
|
34
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS cards (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
path TEXT UNIQUE NOT NULL,
|
|
40
|
+
kind TEXT NOT NULL DEFAULT 'knowledge',
|
|
41
|
+
title TEXT NOT NULL DEFAULT '',
|
|
42
|
+
tags TEXT DEFAULT '[]',
|
|
43
|
+
body TEXT NOT NULL DEFAULT '',
|
|
44
|
+
summary TEXT DEFAULT '',
|
|
45
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
46
|
+
source TEXT DEFAULT '',
|
|
47
|
+
submitted_by TEXT DEFAULT '',
|
|
48
|
+
severity TEXT DEFAULT 'info',
|
|
49
|
+
reason TEXT DEFAULT '',
|
|
50
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
51
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
52
|
+
deleted_at TEXT
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_cards_status ON cards(status);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_cards_kind ON cards(kind);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_cards_deleted ON cards(deleted_at);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS card_updates (
|
|
60
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
61
|
+
card_id INTEGER NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
|
|
62
|
+
content TEXT NOT NULL,
|
|
63
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
query TEXT NOT NULL,
|
|
69
|
+
card_path TEXT NOT NULL,
|
|
70
|
+
useful INTEGER NOT NULL DEFAULT 0,
|
|
71
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
72
|
+
);
|
|
73
|
+
`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 从 .md 文件迁移到 SQLite(幂等:已存在的卡跳过)。 */
|
|
77
|
+
export async function migrateFromMarkdown(root) {
|
|
78
|
+
const db = getDb(root)
|
|
79
|
+
const existing = db.prepare('SELECT COUNT(*) as cnt FROM cards').get()
|
|
80
|
+
if (existing.cnt > 0) return { migrated: 0, skipped: existing.cnt }
|
|
81
|
+
|
|
82
|
+
let migrated = 0
|
|
83
|
+
const walk = async (dir, base) => {
|
|
84
|
+
let ents = []
|
|
85
|
+
try { ents = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
|
|
86
|
+
for (const ent of ents) {
|
|
87
|
+
const full = path.join(dir, ent.name)
|
|
88
|
+
if (ent.isDirectory()) { await walk(full, base); continue }
|
|
89
|
+
if (!ent.name.endsWith('.md')) continue
|
|
90
|
+
try {
|
|
91
|
+
const text = await fs.readFile(full, 'utf8')
|
|
92
|
+
const { meta, body, summary } = parseCard(text)
|
|
93
|
+
const rel = path.relative(base, full).split(path.sep).join('/')
|
|
94
|
+
const stat = await fs.stat(full)
|
|
95
|
+
db.prepare(`
|
|
96
|
+
INSERT OR IGNORE INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
98
|
+
`).run(
|
|
99
|
+
rel,
|
|
100
|
+
meta.kind || 'knowledge',
|
|
101
|
+
meta.title || rel.replace(/\.[^.]*$/, '').replace(/^[^/]*\//, ''),
|
|
102
|
+
JSON.stringify(meta.tags || []),
|
|
103
|
+
body.trim(),
|
|
104
|
+
summary,
|
|
105
|
+
meta.status || 'pending',
|
|
106
|
+
meta.source || '',
|
|
107
|
+
meta.submittedBy || '',
|
|
108
|
+
meta.severity || 'info',
|
|
109
|
+
meta.reason || '',
|
|
110
|
+
meta.created || stat.mtime.toISOString(),
|
|
111
|
+
meta.updated || stat.mtime.toISOString(),
|
|
112
|
+
meta.deletedAt || null,
|
|
113
|
+
)
|
|
114
|
+
migrated++
|
|
115
|
+
} catch { /* 跳过坏文件 */ }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
await walk(root, root)
|
|
119
|
+
return { migrated, skipped: 0 }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function closeDb(root) {
|
|
123
|
+
const resolved = path.resolve(root)
|
|
124
|
+
const db = instances.get(resolved)
|
|
125
|
+
if (db) { db.close(); instances.delete(resolved) }
|
|
126
|
+
}
|
package/lib/vault.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
// 记忆核心 ·
|
|
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 } 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,262 @@ 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: 'approved' }, { dedup: false })
|
|
266
|
-
if (!r.ok) return { ok: false, error: '写入失败' }
|
|
267
|
-
for (const t of texts) { try { await deleteCard(root, t.path, { permanent: true }) } catch { /* 尽力删除 */ } }
|
|
268
|
-
return { ok: true, path: r.path, merged: texts.length }
|
|
214
|
+
return text
|
|
269
215
|
}
|
|
270
216
|
|
|
271
|
-
/**
|
|
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
|
-
`formatVersion: 1`,
|
|
318
|
-
`kind: ${meta.kind || 'knowledge'}`,
|
|
319
|
-
`title: ${yamlString(meta.title)}`,
|
|
320
|
-
`tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
|
|
321
|
-
`created: ${meta.created || now}`,
|
|
322
|
-
`updated: ${now}`,
|
|
323
|
-
`status: approved`,
|
|
324
|
-
...(meta.submittedBy ? [`submittedBy: ${yamlString(meta.submittedBy)}`] : []),
|
|
325
|
-
...(meta.severity ? [`severity: ${meta.severity}`] : []),
|
|
326
|
-
...(meta.reason ? [`reason: ${yamlString(meta.reason)}`] : []),
|
|
327
|
-
...(meta.source ? [`source: ${meta.source}`] : []),
|
|
328
|
-
'---',
|
|
329
|
-
'',
|
|
330
|
-
body.trim(),
|
|
331
|
-
'',
|
|
332
|
-
].join('\n')
|
|
333
|
-
await fs.writeFile(target, rewritten, 'utf8')
|
|
234
|
+
const info = db.prepare('UPDATE cards SET status = ?, deleted_at = NULL, updated_at = ? WHERE path = ?').run('approved', now, rel)
|
|
235
|
+
if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
|
|
334
236
|
return { ok: true, restored: true }
|
|
335
237
|
}
|
|
336
238
|
|
|
337
|
-
/**
|
|
239
|
+
/** 更新卡状态。 */
|
|
338
240
|
export async function setCardStatus(root, rel, status) {
|
|
339
|
-
const
|
|
340
|
-
if (!target) throw new Error('路径越界')
|
|
341
|
-
const text = await fs.readFile(target, 'utf8')
|
|
342
|
-
const { meta, body } = parseCard(text)
|
|
241
|
+
const db = getDb(root)
|
|
343
242
|
const now = new Date().toISOString()
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
`formatVersion: 1`,
|
|
347
|
-
`kind: ${meta.kind || 'knowledge'}`,
|
|
348
|
-
`title: ${yamlString(meta.title)}`,
|
|
349
|
-
`tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
|
|
350
|
-
`created: ${meta.created || now}`,
|
|
351
|
-
`updated: ${now}`,
|
|
352
|
-
`status: ${status}`,
|
|
353
|
-
...(meta.submittedBy ? [`submittedBy: ${yamlString(meta.submittedBy)}`] : []),
|
|
354
|
-
...(meta.severity ? [`severity: ${meta.severity}`] : []),
|
|
355
|
-
...(meta.reason ? [`reason: ${yamlString(meta.reason)}`] : []),
|
|
356
|
-
...(meta.source ? [`source: ${meta.source}`] : []),
|
|
357
|
-
'---',
|
|
358
|
-
'',
|
|
359
|
-
body.trim(),
|
|
360
|
-
'',
|
|
361
|
-
].join('\n')
|
|
362
|
-
await fs.writeFile(target, rewritten, 'utf8')
|
|
243
|
+
const info = db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE path = ?').run(status, now, rel)
|
|
244
|
+
if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
|
|
363
245
|
return { ok: true, status }
|
|
364
246
|
}
|
|
365
247
|
|
|
366
|
-
/**
|
|
367
|
-
export async function writeCard(root, { kind, title, tags = [], body, source = '', status = '
|
|
248
|
+
/** 原子写卡;先去重,命中返回 {duplicate}。 */
|
|
249
|
+
export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true } = {}) {
|
|
250
|
+
const db = getDb(root)
|
|
368
251
|
const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
|
|
369
|
-
|
|
370
|
-
await fs.mkdir(dir, { recursive: true })
|
|
252
|
+
|
|
371
253
|
if (dedup) {
|
|
372
|
-
const hit = await dedupCheck(
|
|
373
|
-
if (hit) return { ok: false, duplicate: { ...hit, path:
|
|
254
|
+
const hit = await dedupCheck(root, body, threshold)
|
|
255
|
+
if (hit) return { ok: false, duplicate: { ...hit, path: hit.path } }
|
|
374
256
|
}
|
|
257
|
+
|
|
375
258
|
const slug = safeSlug(title)
|
|
376
|
-
let rel = `${slug}.md`
|
|
259
|
+
let rel = `${kindRoot}/${slug}.md`
|
|
377
260
|
let index = 2
|
|
378
|
-
while (
|
|
379
|
-
rel = `${slug}-${index}.md`
|
|
261
|
+
while (db.prepare('SELECT 1 FROM cards WHERE path = ?').get(rel)) {
|
|
262
|
+
rel = `${kindRoot}/${slug}-${index}.md`
|
|
380
263
|
index++
|
|
381
264
|
}
|
|
265
|
+
|
|
382
266
|
const now = new Date().toISOString()
|
|
383
|
-
const
|
|
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
|
-
'',
|
|
267
|
+
const summary = body.trim().slice(0, 200)
|
|
268
|
+
db.prepare(`
|
|
269
|
+
INSERT INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
|
|
270
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
271
|
+
`).run(
|
|
272
|
+
rel,
|
|
273
|
+
kind,
|
|
274
|
+
title,
|
|
275
|
+
JSON.stringify(tags),
|
|
401
276
|
body.trim(),
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
277
|
+
summary,
|
|
278
|
+
status,
|
|
279
|
+
source,
|
|
280
|
+
submittedBy,
|
|
281
|
+
severity,
|
|
282
|
+
reason,
|
|
283
|
+
now,
|
|
284
|
+
now,
|
|
285
|
+
deletedAt || null,
|
|
286
|
+
)
|
|
287
|
+
return { ok: true, path: rel, kind }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 向已存在的卡追加更新记录。 */
|
|
291
|
+
export async function appendUpdate(root, rel, updateText) {
|
|
292
|
+
const db = getDb(root)
|
|
293
|
+
const row = db.prepare('SELECT id FROM cards WHERE path = ?').get(rel)
|
|
294
|
+
if (!row) throw new Error('卡片不存在: ' + rel)
|
|
417
295
|
const now = new Date().toISOString()
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
296
|
+
db.prepare('INSERT INTO card_updates (card_id, content, created_at) VALUES (?, ?, ?)').run(row.id, updateText.trim(), now)
|
|
297
|
+
db.prepare('UPDATE cards SET updated_at = ? WHERE id = ?').run(now, row.id)
|
|
298
|
+
return { ok: true, path: rel, updated: now }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 合并多张卡为一张。 */
|
|
302
|
+
export async function mergeCards(root, paths) {
|
|
303
|
+
const list = (Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean)
|
|
304
|
+
if (list.length < 2) return { ok: false, error: '至少选 2 张卡' }
|
|
305
|
+
const db = getDb(root)
|
|
306
|
+
|
|
307
|
+
const rows = []
|
|
308
|
+
for (const p of list) {
|
|
309
|
+
const row = db.prepare('SELECT * FROM cards WHERE path = ?').get(p)
|
|
310
|
+
if (row) rows.push(row)
|
|
311
|
+
}
|
|
312
|
+
if (rows.length < 2) return { ok: false, error: '读取失败' }
|
|
313
|
+
|
|
314
|
+
const first = rows[0]
|
|
315
|
+
const tags = new Set(JSON.parse(first.tags || '[]'))
|
|
316
|
+
let combined = ''
|
|
317
|
+
rows.forEach((row, i) => {
|
|
318
|
+
const rowTags = JSON.parse(row.tags || '[]')
|
|
319
|
+
rowTags.forEach((t) => tags.add(t))
|
|
320
|
+
combined += (i ? '\n\n---\n\n' : '') + row.body.trim()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
const title = (first.title || '合并记忆') + '(合并)'
|
|
324
|
+
const r = await writeCard(root, { kind: first.kind || 'knowledge', title, tags: [...tags], body: combined, source: 'merge', status: 'pending', submittedBy: 'merge', reason: '多卡合并' }, { dedup: false })
|
|
325
|
+
if (!r.ok) return { ok: false, error: '写入失败' }
|
|
326
|
+
for (const row of rows) { try { await deleteCard(root, row.path, { permanent: true }) } catch {} }
|
|
327
|
+
return { ok: true, path: r.path, merged: rows.length }
|
|
445
328
|
}
|
|
446
329
|
|
|
447
|
-
/** 检索:整词/中文 bigram
|
|
330
|
+
/** 检索:整词/中文 bigram 命中。 */
|
|
448
331
|
export async function search(root, query, { limit = 30, minScore = 0, semantic = false } = {}) {
|
|
449
332
|
const q = String(query || '').trim()
|
|
450
333
|
if (!q) return []
|
|
451
|
-
const
|
|
334
|
+
const db = getDb(root)
|
|
335
|
+
const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
|
|
452
336
|
const wanted = queryTerms(q)
|
|
453
337
|
const out = []
|
|
454
|
-
for (const
|
|
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()
|
|
338
|
+
for (const row of rows) {
|
|
339
|
+
const card = rowToCard(row)
|
|
340
|
+
const haystack = `${card.path}\n${card.title}\n${row.body}`.toLowerCase()
|
|
462
341
|
let score = 0
|
|
463
342
|
if (haystack.includes(q.toLowerCase())) score = 3
|
|
464
343
|
for (const term of wanted) {
|
|
465
344
|
if (haystack.includes(term.toLowerCase())) score += 1
|
|
466
345
|
}
|
|
467
|
-
// 本地语义加分:Jaccard bigram 相似度(无 embedding 依赖),覆盖「同义改写/片段命中」
|
|
468
346
|
if (semantic) score += Math.round(textSimilarity(q, `${card.title} ${card.summary}`) * 6)
|
|
469
347
|
if (score > 0 && score >= minScore) {
|
|
470
|
-
out.push({ ...card, score, excerpt:
|
|
348
|
+
out.push({ ...card, score, excerpt: row.body.trim().slice(0, 300) })
|
|
471
349
|
}
|
|
472
350
|
}
|
|
473
|
-
|
|
351
|
+
|
|
352
|
+
// 反馈反哺排序
|
|
474
353
|
const fb = await readFeedback(root)
|
|
475
354
|
const ql = q.toLowerCase()
|
|
476
355
|
const fbByPath = {}
|
|
@@ -490,63 +369,79 @@ export async function search(root, query, { limit = 30, minScore = 0, semantic =
|
|
|
490
369
|
return out.slice(0, limit)
|
|
491
370
|
}
|
|
492
371
|
|
|
493
|
-
/**
|
|
372
|
+
/** 当日简报。 */
|
|
494
373
|
export function dailyBrief(cardsToday) {
|
|
495
374
|
if (!cardsToday || cardsToday.length === 0) return '今天还没有新的记忆沉淀。'
|
|
496
375
|
return cardsToday.map((c) => `- ${c.kind}|${c.title}`).join('\n')
|
|
497
376
|
}
|
|
498
377
|
|
|
499
|
-
/**
|
|
378
|
+
/** 生成今日简报卡(INSERT into cards)。 */
|
|
500
379
|
export async function generateDailyBrief(root) {
|
|
501
380
|
const d = new Date()
|
|
502
381
|
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
503
|
-
const dir = path.join(root, '00-System')
|
|
504
|
-
const file = path.join(dir, `daily-${date}.md`)
|
|
505
382
|
try {
|
|
506
|
-
|
|
383
|
+
const db = getDb(root)
|
|
384
|
+
// 幂等:检查是否已存在
|
|
385
|
+
const existing = db.prepare("SELECT 1 FROM cards WHERE title = ? AND status = 'approved'").get(`每日回顾 ${date}`)
|
|
386
|
+
if (existing) return { ok: true, existed: true }
|
|
387
|
+
|
|
507
388
|
const s = await stats(root)
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
389
|
+
const body = `今日新增 ${s.today} · 近 7 天 ${s.week} · 总 ${s.total}\n\n## 今日沉淀\n${dailyBrief(s.todayCards)}`
|
|
390
|
+
const now = new Date().toISOString()
|
|
391
|
+
db.prepare(`
|
|
392
|
+
INSERT INTO cards (path, kind, title, tags, body, summary, status, source, created_at, updated_at)
|
|
393
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
394
|
+
`).run(
|
|
395
|
+
`00-System/daily-${date}.md`,
|
|
396
|
+
'prompt',
|
|
397
|
+
`每日回顾 ${date}`,
|
|
398
|
+
JSON.stringify(['每日回顾', '统计']),
|
|
399
|
+
body,
|
|
400
|
+
body.slice(0, 200),
|
|
401
|
+
'approved',
|
|
402
|
+
'daily-brief',
|
|
403
|
+
now,
|
|
404
|
+
now,
|
|
405
|
+
)
|
|
406
|
+
return { ok: true, existed: false }
|
|
512
407
|
} catch (e) {
|
|
513
408
|
return { ok: false, error: String(e.message || e) }
|
|
514
409
|
}
|
|
515
410
|
}
|
|
516
411
|
|
|
517
|
-
/**
|
|
412
|
+
/** 用量趋势:最近 N 天每天新增卡片数。 */
|
|
518
413
|
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]++
|
|
414
|
+
const db = getDb(root)
|
|
415
|
+
const cut = new Date(Date.now() - days * 86400000).toISOString()
|
|
416
|
+
const rows = db.prepare("SELECT DATE(created_at) as d, COUNT(*) as cnt FROM cards WHERE created_at >= ? GROUP BY DATE(created_at)").all(cut)
|
|
417
|
+
const map = new Map(rows.map((r) => [r.d, r.cnt]))
|
|
418
|
+
const out = []
|
|
419
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
420
|
+
const dt = new Date(Date.now() - i * 86400000)
|
|
421
|
+
const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
|
|
422
|
+
out.push({ date: key, count: map.get(key) || 0 })
|
|
532
423
|
}
|
|
533
|
-
return
|
|
424
|
+
return out
|
|
534
425
|
}
|
|
535
426
|
|
|
536
427
|
/** 导出全部卡片原始 Markdown。 */
|
|
537
428
|
export async function exportCards(root) {
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
429
|
+
const db = getDb(root)
|
|
430
|
+
const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
|
|
431
|
+
return rows.map((row) => {
|
|
432
|
+
const { text } = rowToMarkdown(row)
|
|
433
|
+
return { path: row.path, title: row.title, kind: row.kind, text }
|
|
434
|
+
})
|
|
544
435
|
}
|
|
545
436
|
|
|
546
|
-
/** 图谱:节点 = 卡片;边 = [[wikilink]]
|
|
437
|
+
/** 图谱:节点 = 卡片;边 = [[wikilink]] 或共享标签。 */
|
|
547
438
|
export async function graph(root) {
|
|
548
|
-
const
|
|
439
|
+
const db = getDb(root)
|
|
440
|
+
const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
|
|
441
|
+
const cards = rows.map(rowToCard)
|
|
549
442
|
const byPath = new Map(cards.map((c) => [c.path, c]))
|
|
443
|
+
const bodyMap = new Map(rows.map((r) => [r.path, r.body]))
|
|
444
|
+
|
|
550
445
|
const nodes = cards.map((c) => ({
|
|
551
446
|
id: c.path,
|
|
552
447
|
title: c.title,
|
|
@@ -561,22 +456,15 @@ export async function graph(root) {
|
|
|
561
456
|
const addDegree = (p) => { const n = nodeById.get(p); if (n) n.linkCount++ }
|
|
562
457
|
const edges = []
|
|
563
458
|
const seen = new Set()
|
|
564
|
-
const bodies = new Map()
|
|
565
459
|
const addEdge = (a, b, type) => {
|
|
566
460
|
const key = [a, b].sort().join('|') + '|' + type
|
|
567
461
|
if (seen.has(key)) return
|
|
568
462
|
seen.add(key)
|
|
569
463
|
edges.push({ source: a, target: b, type })
|
|
570
464
|
}
|
|
465
|
+
|
|
571
466
|
for (const card of cards) {
|
|
572
|
-
|
|
573
|
-
try {
|
|
574
|
-
text = await fs.readFile(path.join(root, card.path), 'utf8')
|
|
575
|
-
} catch {
|
|
576
|
-
continue
|
|
577
|
-
}
|
|
578
|
-
bodies.set(card.path, text)
|
|
579
|
-
// wikilinks:[[目标路径]] 或 [[标题]]
|
|
467
|
+
const text = bodyMap.get(card.path) || ''
|
|
580
468
|
for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)) {
|
|
581
469
|
const raw = m[1].trim().replace(/\.md$/, '')
|
|
582
470
|
const candidates = [
|
|
@@ -590,7 +478,6 @@ export async function graph(root) {
|
|
|
590
478
|
break
|
|
591
479
|
}
|
|
592
480
|
}
|
|
593
|
-
// 也允许按标题匹配
|
|
594
481
|
for (const other of cards) {
|
|
595
482
|
if (other.path !== card.path && (other.title === raw || other.path.endsWith(`/${raw}.md`))) {
|
|
596
483
|
addEdge(card.path, other.path, 'link'); addDegree(card.path); addDegree(other.path)
|
|
@@ -598,51 +485,55 @@ export async function graph(root) {
|
|
|
598
485
|
}
|
|
599
486
|
}
|
|
600
487
|
}
|
|
601
|
-
// 共享标签(同一标签出现两次以上才连线,避免全连)
|
|
602
488
|
for (const tag of card.tags) {
|
|
603
489
|
for (const other of cards) {
|
|
604
490
|
if (other.path !== card.path && other.tags.includes(tag)) addEdge(card.path, other.path, `tag:${tag}`)
|
|
605
491
|
}
|
|
606
492
|
}
|
|
607
493
|
}
|
|
608
|
-
|
|
494
|
+
|
|
495
|
+
// 相似度关联
|
|
609
496
|
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 }
|
|
497
|
+
for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
|
|
498
|
+
const a = cards[i], b = cards[j]
|
|
499
|
+
if (a.kind !== b.kind && !a.tags.some((t) => b.tags.includes(t))) continue
|
|
500
|
+
if ((similarCount[a.path] || 0) >= 1 || (similarCount[b.path] || 0) >= 1) continue
|
|
501
|
+
const sim = textSimilarity(bodyMap.get(a.path) || '', bodyMap.get(b.path) || '')
|
|
502
|
+
if (sim >= 0.42) { addEdge(a.path, b.path, 'similar'); addDegree(a.path); addDegree(b.path); similarCount[a.path] = (similarCount[a.path] || 0) + 1; similarCount[b.path] = (similarCount[b.path] || 0) + 1 }
|
|
620
503
|
}
|
|
621
504
|
return { nodes, edges }
|
|
622
505
|
}
|
|
623
506
|
|
|
624
|
-
/**
|
|
507
|
+
/** 统计。 */
|
|
625
508
|
export async function stats(root) {
|
|
626
|
-
const
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
const
|
|
509
|
+
const db = getDb(root)
|
|
510
|
+
const totalRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved'").get()
|
|
511
|
+
const byKindRows = db.prepare("SELECT kind, COUNT(*) as cnt FROM cards WHERE status = 'approved' GROUP BY kind").all()
|
|
512
|
+
const byKind = Object.fromEntries(byKindRows.map((r) => [r.kind, r.cnt]))
|
|
513
|
+
|
|
514
|
+
const now = new Date()
|
|
515
|
+
const dayAgo = new Date(now.getTime() - 86400000).toISOString()
|
|
516
|
+
const weekAgo = new Date(now.getTime() - 7 * 86400000).toISOString()
|
|
517
|
+
const todayRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(dayAgo)
|
|
518
|
+
const weekRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(weekAgo)
|
|
519
|
+
const tagRows = db.prepare("SELECT tags FROM cards WHERE status = 'approved'").all()
|
|
633
520
|
const tagSet = new Set()
|
|
634
|
-
for (const
|
|
521
|
+
for (const r of tagRows) { for (const t of JSON.parse(r.tags || '[]')) tagSet.add(t) }
|
|
522
|
+
|
|
523
|
+
const todayCardsRows = db.prepare("SELECT * FROM cards WHERE status = 'approved' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 30").all(dayAgo)
|
|
524
|
+
const todayCards = todayCardsRows.map((row) => ({ path: row.path, title: row.title, kind: row.kind, updated: row.updated_at, summary: (row.summary || '').slice(0, 80) }))
|
|
525
|
+
|
|
635
526
|
return {
|
|
636
|
-
total:
|
|
637
|
-
today:
|
|
638
|
-
week:
|
|
527
|
+
total: totalRow.cnt,
|
|
528
|
+
today: todayRow.cnt,
|
|
529
|
+
week: weekRow.cnt,
|
|
639
530
|
tags: tagSet.size,
|
|
640
531
|
byKind,
|
|
641
|
-
todayCards
|
|
532
|
+
todayCards,
|
|
642
533
|
}
|
|
643
534
|
}
|
|
644
535
|
|
|
645
|
-
/**
|
|
536
|
+
/** 跨库聚合图谱。 */
|
|
646
537
|
export async function graphAll(roots) {
|
|
647
538
|
const nodes = []; const edges = []; const idSet = new Set()
|
|
648
539
|
for (const r of roots) {
|
|
@@ -656,7 +547,7 @@ export async function graphAll(roots) {
|
|
|
656
547
|
return { nodes, edges }
|
|
657
548
|
}
|
|
658
549
|
|
|
659
|
-
/**
|
|
550
|
+
/** 跨库聚合检索。 */
|
|
660
551
|
export async function searchAll(roots, query, opts = {}) {
|
|
661
552
|
const out = []
|
|
662
553
|
for (const r of roots) {
|
|
@@ -669,18 +560,18 @@ export async function searchAll(roots, query, opts = {}) {
|
|
|
669
560
|
return out.slice(0, opts.limit || 30)
|
|
670
561
|
}
|
|
671
562
|
|
|
672
|
-
/**
|
|
563
|
+
/** 整理建议(非破坏性预览)。 */
|
|
673
564
|
export async function optimizeCandidates(root) {
|
|
674
|
-
const
|
|
565
|
+
const db = getDb(root)
|
|
566
|
+
const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
|
|
567
|
+
const cards = rows.map(rowToCard)
|
|
568
|
+
const bodyMap = new Map(rows.map((r) => [r.path, (r.body || '').slice(0, 4000)]))
|
|
569
|
+
|
|
675
570
|
const merge = []
|
|
676
|
-
const bodies = new Map()
|
|
677
|
-
for (const c of cards) {
|
|
678
|
-
try { bodies.set(c.path, (await fs.readFile(path.join(root, c.path), 'utf8')).slice(0, 4000)) } catch { bodies.set(c.path, '') }
|
|
679
|
-
}
|
|
680
571
|
for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
|
|
681
572
|
const a = cards[i], b = cards[j]
|
|
682
573
|
if (a.kind !== b.kind) continue
|
|
683
|
-
const sim = textSimilarity(
|
|
574
|
+
const sim = textSimilarity(bodyMap.get(a.path) || '', bodyMap.get(b.path) || '')
|
|
684
575
|
if (sim >= 0.55) merge.push({ a: { path: a.path, title: a.title }, b: { path: b.path, title: b.title }, sim: Math.round(sim * 100) / 100 })
|
|
685
576
|
}
|
|
686
577
|
merge.sort((x, y) => y.sim - x.sim)
|
|
@@ -689,16 +580,7 @@ export async function optimizeCandidates(root) {
|
|
|
689
580
|
return { merge: merge.slice(0, 40), stale }
|
|
690
581
|
}
|
|
691
582
|
|
|
692
|
-
/**
|
|
693
|
-
* 一键优化执行:根据 /optimize 候选合并相似卡 + (可选)清理陈旧卡。
|
|
694
|
-
* @param {string} root
|
|
695
|
-
* @param {object} [opts]
|
|
696
|
-
* @param {number} [opts.simThreshold=0.55] 相似度阈值(与 optimizeCandidates 一致)
|
|
697
|
-
* @param {number} [opts.staleDays=90] 多久未更新视为「陈旧」
|
|
698
|
-
* @param {boolean} [opts.cleanupStale=false] 默认不动陈旧卡(用户敏感)
|
|
699
|
-
* @param {boolean} [opts.dryRun=false] 只返回计划,不执行
|
|
700
|
-
* @returns {Promise<{ok:true, plan:{merged:number, staleDeleted:number, staleSkipped:number}, merged:[{from,to}], staleDeleted:[string], staleSkipped:[string], errors:[string]}>}
|
|
701
|
-
*/
|
|
583
|
+
/** 一键优化执行。 */
|
|
702
584
|
export async function optimizeApply(root, opts = {}) {
|
|
703
585
|
const simThreshold = Number(opts.simThreshold ?? 0.55)
|
|
704
586
|
const staleDays = Number(opts.staleDays ?? 90)
|
|
@@ -748,19 +630,28 @@ export async function optimizeApply(root, opts = {}) {
|
|
|
748
630
|
}
|
|
749
631
|
}
|
|
750
632
|
|
|
751
|
-
/**
|
|
633
|
+
/** 读取反馈记录。 */
|
|
752
634
|
export async function readFeedback(root) {
|
|
753
|
-
|
|
635
|
+
const db = getDb(root)
|
|
636
|
+
const rows = db.prepare('SELECT * FROM feedback ORDER BY created_at DESC').all()
|
|
637
|
+
return rows.map((r) => ({ ts: new Date(r.created_at).getTime(), query: r.query, path: r.card_path, useful: r.useful }))
|
|
754
638
|
}
|
|
639
|
+
|
|
640
|
+
/** 添加反馈。 */
|
|
755
641
|
export async function addFeedback(root, rec) {
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
642
|
+
const db = getDb(root)
|
|
643
|
+
db.prepare('INSERT INTO feedback (query, card_path, useful) VALUES (?, ?, ?)').run(
|
|
644
|
+
rec.query || '',
|
|
645
|
+
rec.path || rec.card_path || '',
|
|
646
|
+
rec.useful ? 1 : 0,
|
|
647
|
+
)
|
|
759
648
|
return { ok: true }
|
|
760
649
|
}
|
|
761
650
|
|
|
762
|
-
/** 统计:按 kind
|
|
763
|
-
export async function overview(root) {
|
|
651
|
+
/** 统计:按 kind + status。 */
|
|
652
|
+
export async function overview(root) {
|
|
653
|
+
const db = getDb(root)
|
|
654
|
+
const cards = await listCards(root, { status: ['approved', 'pending', 'rejected'] })
|
|
764
655
|
const byKind = {}
|
|
765
656
|
const statusCounts = {}
|
|
766
657
|
for (const c of cards) { byKind[c.kind] = (byKind[c.kind] || 0) + 1; statusCounts[c.status] = (statusCounts[c.status] || 0) + 1 }
|
|
@@ -789,6 +680,7 @@ function resolveInside(root, rel) {
|
|
|
789
680
|
|
|
790
681
|
async function exists(p) {
|
|
791
682
|
try {
|
|
683
|
+
const { promises: fs } = await import('node:fs')
|
|
792
684
|
await fs.access(p)
|
|
793
685
|
return true
|
|
794
686
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|