memory-eternal 0.1.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/.claude-plugin/plugin.json +34 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.cursor-plugin/plugin.json +28 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.en.md +224 -0
- package/README.md +236 -0
- package/bin/dsh-memory.mjs +183 -0
- package/cordis.patch.yml +15 -0
- package/hooks/capture.mjs +77 -0
- package/hooks/claude-session.js +56 -0
- package/hooks/hooks.claude.json +12 -0
- package/hooks/hooks.codex.json +15 -0
- package/hooks/hooks.cursor.json +11 -0
- package/index.js +565 -0
- package/lib/api.js +321 -0
- package/lib/capture-run.js +98 -0
- package/lib/capture.js +267 -0
- package/lib/client.js +186 -0
- package/lib/llm-openai.js +84 -0
- package/lib/mcp.js +166 -0
- package/lib/setup.js +470 -0
- package/lib/sweep.js +56 -0
- package/lib/vault.js +802 -0
- package/lib/watchdog.js +201 -0
- package/lib/web.js +149 -0
- package/package.json +101 -0
- package/skills/memory-recall/SKILL.md +14 -0
- package/web/app.js +230 -0
- package/web/index.html +46 -0
package/lib/vault.js
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
// 记忆核心 · Markdown Vault 存储层
|
|
2
|
+
//
|
|
3
|
+
// 从 boujoy-harness 的记忆模块移植(web/boujoy_server.py 的知识库部分):
|
|
4
|
+
// - 卡片 = 带 YAML frontmatter 的 Markdown 文件,落在 02-06 主题目录;
|
|
5
|
+
// - 去重 = Jaccard 字符 bigram 相似度(阈值默认 0.62),命中后拒绝新建并返回原卡;
|
|
6
|
+
// - 检索 = CJK 感知:整词 + 字符 bigram 命中(无需全文引擎);
|
|
7
|
+
// - 图谱 = 卡片之间的 [[wikilink]] 与共享标签连线。
|
|
8
|
+
//
|
|
9
|
+
// 本文件不依赖 DSH 运行时,可单独单测。
|
|
10
|
+
|
|
11
|
+
import { promises as fs } from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
|
|
14
|
+
/** 主题目录根。 */
|
|
15
|
+
export const KIND_ROOTS = {
|
|
16
|
+
project: '02-Projects',
|
|
17
|
+
knowledge: '03-Knowledge',
|
|
18
|
+
content: '04-Content',
|
|
19
|
+
prompt: '05-Prompts',
|
|
20
|
+
business: '06-Business',
|
|
21
|
+
tool: '07-Tools',
|
|
22
|
+
mistake: '08-Mistakes',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const CAPTURE_KINDS = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake']
|
|
26
|
+
|
|
27
|
+
/** 从 Markdown 文本提取 frontmatter 与正文。 */
|
|
28
|
+
export function parseCard(text) {
|
|
29
|
+
// formatVersion 守卫:读侧始终向后兼容——旧卡无此字段 = v0 按旧逻辑读;
|
|
30
|
+
// 未来结构变更时升 FORMAT_VERSION 并在读侧加迁移分支,多副本环境
|
|
31
|
+
// (DSH profile + 全局)版本漂移不会互相写坏库。
|
|
32
|
+
const meta = { title: '', kind: 'knowledge', tags: [], created: '', updated: '', source: '', formatVersion: 0, status: 'pending', submittedBy: '', severity: 'info', reason: '', deletedAt: '' }
|
|
33
|
+
let body = text
|
|
34
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text)
|
|
35
|
+
if (m) {
|
|
36
|
+
body = text.slice(m[0].length)
|
|
37
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
38
|
+
const eq = line.indexOf(':')
|
|
39
|
+
if (eq <= 0) continue
|
|
40
|
+
const key = line.slice(0, eq).trim()
|
|
41
|
+
let value = line.slice(eq + 1).trim()
|
|
42
|
+
if (key === 'tags') {
|
|
43
|
+
value = value.replace(/^\[/, '').replace(/\]$/, '')
|
|
44
|
+
meta.tags = value.split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (key === 'formatVersion') {
|
|
48
|
+
meta.formatVersion = Number(value) || 0
|
|
49
|
+
} else if (key === 'kind' || key === 'title' || key === 'created' || key === 'updated' || key === 'source' || key === 'status' || key === 'submittedBy' || key === 'severity' || key === 'reason' || key === 'deletedAt') {
|
|
50
|
+
meta[key] = value.replace(/^['"]|['"]$/g, '')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// 审核中心为最高控制:无 status 字段的卡(含 agent 直写 vault 的绕过)一律视为待审核。
|
|
55
|
+
if (!meta.status) meta.status = 'pending'
|
|
56
|
+
// 标题:frontmatter 的 title 优先,否则取第一个 # 标题,否则取首行。
|
|
57
|
+
if (!meta.title) {
|
|
58
|
+
const h = /^#\s+(.+)$/m.exec(body)
|
|
59
|
+
meta.title = h ? h[1].trim() : body.trim().split(/\r?\n/)[0].slice(0, 60)
|
|
60
|
+
}
|
|
61
|
+
const summary = body.trim().slice(0, 200)
|
|
62
|
+
return { meta, body: body.trim(), summary }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 生成安全 slug(中文保留、非法字符替换为 -)。 */
|
|
66
|
+
export function safeSlug(name) {
|
|
67
|
+
const base = String(name || 'card')
|
|
68
|
+
.trim()
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[\s_]+/g, '-')
|
|
71
|
+
.replace(/[^\w\u4e00-\u9fff-]+/g, '')
|
|
72
|
+
.replace(/-+/g, '-')
|
|
73
|
+
.replace(/^-|-$/g, '')
|
|
74
|
+
return base || 'card'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Jaccard-like 相似度:字符 bigram 集合(移植 boujoy _text_similarity)。 */
|
|
78
|
+
export function textSimilarity(a, b) {
|
|
79
|
+
const bigrams = (text) => {
|
|
80
|
+
const cleaned = String(text)
|
|
81
|
+
.replace(/[\s#*_`|[\]()()\-—・]+/g, '')
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
const out = new Set()
|
|
84
|
+
for (let i = 0; i < cleaned.length - 1; i++) out.add(cleaned.slice(i, i + 2))
|
|
85
|
+
if (cleaned.length === 1) out.add(cleaned)
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
const ba = bigrams(a)
|
|
89
|
+
const bb = bigrams(b)
|
|
90
|
+
if (ba.size === 0 || bb.size === 0) return 0
|
|
91
|
+
let inter = 0
|
|
92
|
+
for (const g of ba) if (bb.has(g)) inter++
|
|
93
|
+
return inter / (ba.size + bb.size - inter)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 在目标目录中找与候选文本最相似的现有卡(去重守卫)。比较正文(剔除 frontmatter)。 */
|
|
97
|
+
export async function dedupCheck(dir, text, threshold = 0.62) {
|
|
98
|
+
let best = null
|
|
99
|
+
let entries = []
|
|
100
|
+
try {
|
|
101
|
+
entries = await fs.readdir(dir)
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
for (const name of entries) {
|
|
106
|
+
if (!name.toLowerCase().endsWith('.md')) continue
|
|
107
|
+
try {
|
|
108
|
+
const existing = await fs.readFile(path.join(dir, name), 'utf8')
|
|
109
|
+
// 只比较正文,避免 frontmatter 稀释相似度
|
|
110
|
+
const { body } = parseCard(existing)
|
|
111
|
+
const score = textSimilarity(text, body || existing)
|
|
112
|
+
if (score >= threshold && (!best || score > best.score)) best = { path: name, score }
|
|
113
|
+
} catch {
|
|
114
|
+
// 跳过不可读文件
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return best
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** CJK 感知查询词:整词 + 中文字符 bigram(移植 boujoy query_terms)。 */
|
|
121
|
+
export function queryTerms(query) {
|
|
122
|
+
const tokens = new Set(String(query).split(/[^\w\u4e00-\u9fff]+/).filter(Boolean))
|
|
123
|
+
for (let i = 0; i < query.length - 1; i++) {
|
|
124
|
+
const c1 = query[i]
|
|
125
|
+
const c2 = query[i + 1]
|
|
126
|
+
if (/[\u4e00-\u9fff]/.test(c1) && /[\u4e00-\u9fff]/.test(c2)) tokens.add(c1 + c2)
|
|
127
|
+
}
|
|
128
|
+
return tokens
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 目录名 → kind 推断(直写/非标准目录也归入可读集合)
|
|
132
|
+
function kindFromDir(topDir) {
|
|
133
|
+
for (const [k, v] of Object.entries(KIND_ROOTS)) { if (v.toLowerCase() === String(topDir || '').toLowerCase()) return k }
|
|
134
|
+
return 'knowledge'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 递归列出一个目录及其子孙目录下所有 *.md(相对路径,正斜杠)。 */
|
|
138
|
+
async function walkAll(dir, base = dir, out = []) {
|
|
139
|
+
let ents = []
|
|
140
|
+
try { ents = await fs.readdir(dir, { withFileTypes: true }) } catch { return out }
|
|
141
|
+
for (const ent of ents) {
|
|
142
|
+
const full = path.join(dir, ent.name)
|
|
143
|
+
if (ent.isDirectory()) await walkAll(full, base, out)
|
|
144
|
+
else if (ent.name.endsWith('.md')) out.push(path.relative(base, full).split(path.sep).join('/'))
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 递归列出 vault 下所有卡片(整个根目录 *.md,含直写到任意子目录的卡),返回解析后的卡摘要。 */
|
|
150
|
+
export async function listCards(root, { status } = {}) {
|
|
151
|
+
// 默认 status=['approved'](主库视图/统计/检索只用已批卡);audit/recycle 显式传 ['pending','rejected'] / ['deleted']
|
|
152
|
+
const want = status ?? ['approved']
|
|
153
|
+
const cards = []
|
|
154
|
+
const files = await walkAll(root)
|
|
155
|
+
for (const rel of files) {
|
|
156
|
+
try {
|
|
157
|
+
const full = path.join(root, rel)
|
|
158
|
+
const text = await fs.readFile(full, 'utf8')
|
|
159
|
+
const { meta, body, summary } = parseCard(text)
|
|
160
|
+
const stat = await fs.stat(full)
|
|
161
|
+
const s = meta.status || 'approved'
|
|
162
|
+
if (want && !want.includes(s)) continue
|
|
163
|
+
const topDir = rel.split('/')[0]
|
|
164
|
+
cards.push({
|
|
165
|
+
path: rel,
|
|
166
|
+
kind: meta.kind || kindFromDir(topDir),
|
|
167
|
+
title: meta.title || rel.replace(/\.[^.]*$/, '').replace(/^[^/]*\//, ''),
|
|
168
|
+
tags: meta.tags,
|
|
169
|
+
summary,
|
|
170
|
+
created: meta.created,
|
|
171
|
+
updated: meta.updated || stat.mtime.toISOString(),
|
|
172
|
+
mtime: stat.mtimeMs,
|
|
173
|
+
status: s,
|
|
174
|
+
submittedBy: meta.submittedBy || '',
|
|
175
|
+
severity: meta.severity || 'info',
|
|
176
|
+
reason: meta.reason || '',
|
|
177
|
+
deletedAt: meta.deletedAt || '',
|
|
178
|
+
})
|
|
179
|
+
} catch {
|
|
180
|
+
// 跳过坏文件
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
cards.sort((a, b) => b.mtime - a.mtime)
|
|
184
|
+
return cards
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 审核队列(待审核卡):status=pending。 */
|
|
188
|
+
export async function auditQueue(root) {
|
|
189
|
+
const cards = await listCards(root, { status: ['pending', 'rejected'] })
|
|
190
|
+
return { pending: cards.filter((c) => c.status === 'pending'), rejected: cards.filter((c) => c.status === 'rejected') }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 回收站(软删卡):status=deleted,按删除时间倒序。 */
|
|
194
|
+
export async function recycleList(root) {
|
|
195
|
+
const cards = await listCards(root, { status: ['deleted'] })
|
|
196
|
+
return cards.sort((a, b) => new Date(b.deletedAt || 0) - new Date(a.deletedAt || 0))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 清理超过 retentionDays 天的回收卡(永久删除)。 */
|
|
200
|
+
export async function purgeExpired(root, retentionDays = 30) {
|
|
201
|
+
const cut = Date.now() - retentionDays * 86400000
|
|
202
|
+
const items = await recycleList(root)
|
|
203
|
+
let purged = 0
|
|
204
|
+
for (const c of items) {
|
|
205
|
+
const d = new Date(c.deletedAt || 0).getTime()
|
|
206
|
+
if (d && d < cut) {
|
|
207
|
+
try { await deleteCard(root, c.path, { permanent: true }); purged++ } catch {}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, purged }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function walkMd(dir) {
|
|
214
|
+
const out = []
|
|
215
|
+
let entries
|
|
216
|
+
try {
|
|
217
|
+
entries = await fs.readdir(dir, { withFileTypes: true })
|
|
218
|
+
} catch {
|
|
219
|
+
return out
|
|
220
|
+
}
|
|
221
|
+
for (const ent of entries) {
|
|
222
|
+
const rel = path.join('.', ent.name)
|
|
223
|
+
if (ent.isDirectory()) {
|
|
224
|
+
out.push(...(await walkMd(path.join(dir, ent.name))).map((f) => path.join(rel, f)))
|
|
225
|
+
} else if (ent.name.toLowerCase().endsWith('.md')) {
|
|
226
|
+
out.push(rel.replace(/\\/g, '/'))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 确保 vault 目录结构存在(00-System + 02-08)。 */
|
|
233
|
+
export async function ensureVault(root) {
|
|
234
|
+
await fs.mkdir(path.join(root, '00-System'), { recursive: true })
|
|
235
|
+
for (const dir of Object.values(KIND_ROOTS)) {
|
|
236
|
+
await fs.mkdir(path.join(root, dir), { recursive: true })
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 读取一张卡(相对路径,安全限定在 vault 内)。 */
|
|
241
|
+
export async function readCard(root, rel) {
|
|
242
|
+
const target = resolveInside(root, rel)
|
|
243
|
+
if (!target) throw new Error('路径越界')
|
|
244
|
+
return fs.readFile(target, 'utf8')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 合并多张卡为一张(保留第一张的 kind/title 前缀「(合并)」,并集标签,用 --- 分隔正文,删原卡)。 */
|
|
248
|
+
export async function mergeCards(root, paths) {
|
|
249
|
+
const list = (Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean)
|
|
250
|
+
if (list.length < 2) return { ok: false, error: '至少选 2 张卡' }
|
|
251
|
+
const texts = []
|
|
252
|
+
for (const p of list) {
|
|
253
|
+
try { texts.push({ path: p, text: await readCard(root, p) }) } catch { /* 跳过读不到的 */ }
|
|
254
|
+
}
|
|
255
|
+
if (texts.length < 2) return { ok: false, error: '读取失败' }
|
|
256
|
+
const first = parseCard(texts[0].text)
|
|
257
|
+
const tags = new Set(first.meta.tags || [])
|
|
258
|
+
let combined = ''
|
|
259
|
+
texts.forEach((t, i) => {
|
|
260
|
+
let body = t.text
|
|
261
|
+
try { const p = parseCard(t.text); body = p.body; (p.meta.tags || []).forEach((x) => tags.add(x)) } catch {}
|
|
262
|
+
combined += (i ? '\n\n---\n\n' : '') + body.trim()
|
|
263
|
+
})
|
|
264
|
+
const title = (first.meta.title || '合并记忆') + '(合并)'
|
|
265
|
+
const r = await writeCard(root, { kind: first.meta.kind || 'knowledge', title, tags: [...tags], body: combined, source: 'merge', status: 'approved' }, { dedup: false })
|
|
266
|
+
if (!r.ok) return { ok: false, error: '写入失败' }
|
|
267
|
+
for (const t of texts) { try { await deleteCard(root, t.path, { permanent: true }) } catch { /* 尽力删除 */ } }
|
|
268
|
+
return { ok: true, path: r.path, merged: texts.length }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 软删卡片:标记 status=deleted + deletedAt(进回收站,文件保留可恢复)。不再立即 unlink。 */
|
|
272
|
+
export async function deleteCard(root, rel, { permanent = false } = {}) {
|
|
273
|
+
const target = resolveInside(root, rel)
|
|
274
|
+
if (!target) throw new Error('路径越界')
|
|
275
|
+
if (permanent) {
|
|
276
|
+
await fs.unlink(target)
|
|
277
|
+
const dir = path.dirname(target)
|
|
278
|
+
try { await fs.rmdir(dir) } catch { /* 目录非空则保留 */ }
|
|
279
|
+
return { ok: true, permanent: true }
|
|
280
|
+
}
|
|
281
|
+
// 软删:把 frontmatter status 改 deleted + deletedAt
|
|
282
|
+
const text = await fs.readFile(target, 'utf8')
|
|
283
|
+
const { meta, body } = parseCard(text)
|
|
284
|
+
const now = new Date().toISOString()
|
|
285
|
+
const rewritten = [
|
|
286
|
+
'---',
|
|
287
|
+
`formatVersion: 1`,
|
|
288
|
+
`kind: ${meta.kind || 'knowledge'}`,
|
|
289
|
+
`title: ${yamlString(meta.title)}`,
|
|
290
|
+
`tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
|
|
291
|
+
`created: ${meta.created || now}`,
|
|
292
|
+
`updated: ${now}`,
|
|
293
|
+
`status: deleted`,
|
|
294
|
+
`deletedAt: ${now}`,
|
|
295
|
+
...(meta.submittedBy ? [`submittedBy: ${yamlString(meta.submittedBy)}`] : []),
|
|
296
|
+
...(meta.severity ? [`severity: ${meta.severity}`] : []),
|
|
297
|
+
...(meta.reason ? [`reason: ${yamlString(meta.reason)}`] : []),
|
|
298
|
+
...(meta.source ? [`source: ${meta.source}`] : []),
|
|
299
|
+
'---',
|
|
300
|
+
'',
|
|
301
|
+
body.trim(),
|
|
302
|
+
'',
|
|
303
|
+
].join('\n')
|
|
304
|
+
await fs.writeFile(target, rewritten, 'utf8')
|
|
305
|
+
return { ok: true, soft: true }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 恢复软删卡:status=approved,清除 deletedAt。 */
|
|
309
|
+
export async function restoreCard(root, rel) {
|
|
310
|
+
const target = resolveInside(root, rel)
|
|
311
|
+
if (!target) throw new Error('路径越界')
|
|
312
|
+
const text = await fs.readFile(target, 'utf8')
|
|
313
|
+
const { meta, body } = parseCard(text)
|
|
314
|
+
const now = new Date().toISOString()
|
|
315
|
+
const rewritten = [
|
|
316
|
+
'---',
|
|
317
|
+
`formatVersion: 1`,
|
|
318
|
+
`kind: ${meta.kind || 'knowledge'}`,
|
|
319
|
+
`title: ${yamlString(meta.title)}`,
|
|
320
|
+
`tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
|
|
321
|
+
`created: ${meta.created || now}`,
|
|
322
|
+
`updated: ${now}`,
|
|
323
|
+
`status: approved`,
|
|
324
|
+
...(meta.submittedBy ? [`submittedBy: ${yamlString(meta.submittedBy)}`] : []),
|
|
325
|
+
...(meta.severity ? [`severity: ${meta.severity}`] : []),
|
|
326
|
+
...(meta.reason ? [`reason: ${yamlString(meta.reason)}`] : []),
|
|
327
|
+
...(meta.source ? [`source: ${meta.source}`] : []),
|
|
328
|
+
'---',
|
|
329
|
+
'',
|
|
330
|
+
body.trim(),
|
|
331
|
+
'',
|
|
332
|
+
].join('\n')
|
|
333
|
+
await fs.writeFile(target, rewritten, 'utf8')
|
|
334
|
+
return { ok: true, restored: true }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 更新卡状态(审核批准/驳回)。 */
|
|
338
|
+
export async function setCardStatus(root, rel, status) {
|
|
339
|
+
const target = resolveInside(root, rel)
|
|
340
|
+
if (!target) throw new Error('路径越界')
|
|
341
|
+
const text = await fs.readFile(target, 'utf8')
|
|
342
|
+
const { meta, body } = parseCard(text)
|
|
343
|
+
const now = new Date().toISOString()
|
|
344
|
+
const rewritten = [
|
|
345
|
+
'---',
|
|
346
|
+
`formatVersion: 1`,
|
|
347
|
+
`kind: ${meta.kind || 'knowledge'}`,
|
|
348
|
+
`title: ${yamlString(meta.title)}`,
|
|
349
|
+
`tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
|
|
350
|
+
`created: ${meta.created || now}`,
|
|
351
|
+
`updated: ${now}`,
|
|
352
|
+
`status: ${status}`,
|
|
353
|
+
...(meta.submittedBy ? [`submittedBy: ${yamlString(meta.submittedBy)}`] : []),
|
|
354
|
+
...(meta.severity ? [`severity: ${meta.severity}`] : []),
|
|
355
|
+
...(meta.reason ? [`reason: ${yamlString(meta.reason)}`] : []),
|
|
356
|
+
...(meta.source ? [`source: ${meta.source}`] : []),
|
|
357
|
+
'---',
|
|
358
|
+
'',
|
|
359
|
+
body.trim(),
|
|
360
|
+
'',
|
|
361
|
+
].join('\n')
|
|
362
|
+
await fs.writeFile(target, rewritten, 'utf8')
|
|
363
|
+
return { ok: true, status }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** 原子写卡;先去重(target 目录内),命中返回 {duplicate}。 */
|
|
367
|
+
export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'approved', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true } = {}) {
|
|
368
|
+
const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
|
|
369
|
+
const dir = path.join(root, kindRoot)
|
|
370
|
+
await fs.mkdir(dir, { recursive: true })
|
|
371
|
+
if (dedup) {
|
|
372
|
+
const hit = await dedupCheck(dir, body, threshold)
|
|
373
|
+
if (hit) return { ok: false, duplicate: { ...hit, path: `${kindRoot}/${hit.path}` } }
|
|
374
|
+
}
|
|
375
|
+
const slug = safeSlug(title)
|
|
376
|
+
let rel = `${slug}.md`
|
|
377
|
+
let index = 2
|
|
378
|
+
while (await exists(path.join(dir, rel))) {
|
|
379
|
+
rel = `${slug}-${index}.md`
|
|
380
|
+
index++
|
|
381
|
+
}
|
|
382
|
+
const now = new Date().toISOString()
|
|
383
|
+
const text = [
|
|
384
|
+
'---',
|
|
385
|
+
`formatVersion: 1`,
|
|
386
|
+
`kind: ${kind}`,
|
|
387
|
+
`title: ${yamlString(title)}`,
|
|
388
|
+
`tags: [${tags.map((t) => yamlString(t)).join(', ')}]`,
|
|
389
|
+
`created: ${now}`,
|
|
390
|
+
`updated: ${now}`,
|
|
391
|
+
`status: ${status}`,
|
|
392
|
+
...(deletedAt ? [`deletedAt: ${deletedAt}`] : []),
|
|
393
|
+
...(submittedBy ? [`submittedBy: ${yamlString(submittedBy)}`] : []),
|
|
394
|
+
...(severity ? [`severity: ${severity}`] : []),
|
|
395
|
+
...(reason ? [`reason: ${yamlString(reason)}`] : []),
|
|
396
|
+
...(source ? [`source: ${source}`] : []),
|
|
397
|
+
'---',
|
|
398
|
+
'',
|
|
399
|
+
`# ${title}`,
|
|
400
|
+
'',
|
|
401
|
+
body.trim(),
|
|
402
|
+
'',
|
|
403
|
+
].join('\n')
|
|
404
|
+
const target = path.join(dir, rel)
|
|
405
|
+
const tmp = target + '.tmp'
|
|
406
|
+
await fs.writeFile(tmp, text, 'utf8')
|
|
407
|
+
await fs.rename(tmp, target)
|
|
408
|
+
return { ok: true, path: `${kindRoot}/${rel}`, kind }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** 向已存在的卡追加「更新记录」段(去重命中时的正确动作,移植 boujoy 语义)。 */
|
|
412
|
+
export async function appendUpdate(root, rel, updateText, { threshold = 0.62 } = {}) {
|
|
413
|
+
const target = resolveInside(root, rel)
|
|
414
|
+
if (!target) throw new Error('路径越界')
|
|
415
|
+
const existing = await fs.readFile(target, 'utf8')
|
|
416
|
+
const { meta, body } = parseCard(existing)
|
|
417
|
+
const now = new Date().toISOString()
|
|
418
|
+
const updated = [
|
|
419
|
+
'---',
|
|
420
|
+
`formatVersion: 1`,
|
|
421
|
+
`kind: ${meta.kind || 'knowledge'}`,
|
|
422
|
+
`title: ${yamlString(meta.title)}`,
|
|
423
|
+
`tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
|
|
424
|
+
`created: ${meta.created || now}`,
|
|
425
|
+
`updated: ${now}`,
|
|
426
|
+
`status: ${meta.status || 'pending'}`,
|
|
427
|
+
...(meta.submittedBy ? [`submittedBy: ${yamlString(meta.submittedBy)}`] : []),
|
|
428
|
+
...(meta.severity ? [`severity: ${meta.severity}`] : []),
|
|
429
|
+
...(meta.reason ? [`reason: ${yamlString(meta.reason)}`] : []),
|
|
430
|
+
...(meta.source ? [`source: ${meta.source}`] : []),
|
|
431
|
+
...(meta.deletedAt ? [`deletedAt: ${meta.deletedAt}`] : []),
|
|
432
|
+
'---',
|
|
433
|
+
'',
|
|
434
|
+
body.trim(),
|
|
435
|
+
'',
|
|
436
|
+
'## 更新记录',
|
|
437
|
+
'',
|
|
438
|
+
`- ${now.slice(0, 10)}:${updateText.trim()}`,
|
|
439
|
+
'',
|
|
440
|
+
].join('\n')
|
|
441
|
+
const tmp = target + '.tmp'
|
|
442
|
+
await fs.writeFile(tmp, updated, 'utf8')
|
|
443
|
+
await fs.rename(tmp, target)
|
|
444
|
+
return { ok: true, path: rel, kind: meta.kind || 'knowledge', updated: now }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** 检索:整词/中文 bigram 命中 path + 正文。返回卡片摘要(带命中度)。minScore 过滤弱命中;semantic=true 加本地语义分;有反馈则据此反哺排序。 */
|
|
448
|
+
export async function search(root, query, { limit = 30, minScore = 0, semantic = false } = {}) {
|
|
449
|
+
const q = String(query || '').trim()
|
|
450
|
+
if (!q) return []
|
|
451
|
+
const cards = await listCards(root)
|
|
452
|
+
const wanted = queryTerms(q)
|
|
453
|
+
const out = []
|
|
454
|
+
for (const card of cards) {
|
|
455
|
+
let text
|
|
456
|
+
try {
|
|
457
|
+
text = await fs.readFile(path.join(root, card.path), 'utf8')
|
|
458
|
+
} catch {
|
|
459
|
+
continue
|
|
460
|
+
}
|
|
461
|
+
const haystack = `${card.path}\n${card.title}\n${text}`.toLowerCase()
|
|
462
|
+
let score = 0
|
|
463
|
+
if (haystack.includes(q.toLowerCase())) score = 3
|
|
464
|
+
for (const term of wanted) {
|
|
465
|
+
if (haystack.includes(term.toLowerCase())) score += 1
|
|
466
|
+
}
|
|
467
|
+
// 本地语义加分:Jaccard bigram 相似度(无 embedding 依赖),覆盖「同义改写/片段命中」
|
|
468
|
+
if (semantic) score += Math.round(textSimilarity(q, `${card.title} ${card.summary}`) * 6)
|
|
469
|
+
if (score > 0 && score >= minScore) {
|
|
470
|
+
out.push({ ...card, score })
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// 反馈反哺排序:对该查询「有用」的卡加分、「无关」的减分;query 与本次有词交集即生效。
|
|
474
|
+
const fb = await readFeedback(root)
|
|
475
|
+
const ql = q.toLowerCase()
|
|
476
|
+
const fbByPath = {}
|
|
477
|
+
for (const f of fb) {
|
|
478
|
+
if (!f.path) continue
|
|
479
|
+
const fq = String(f.query || '').toLowerCase()
|
|
480
|
+
const overlap = fq && (fq === ql || ql.includes(fq) || fq.includes(ql) || queryTerms(f.query).some((term) => wanted.includes(term)))
|
|
481
|
+
if (!overlap) continue
|
|
482
|
+
const rec = fbByPath[f.path] = fbByPath[f.path] || { useful: 0, irr: 0 }
|
|
483
|
+
if (f.useful) rec.useful += 1; else rec.irr += 1
|
|
484
|
+
}
|
|
485
|
+
for (const hit of out) {
|
|
486
|
+
const rec = fbByPath[hit.path]
|
|
487
|
+
if (rec) hit.score += rec.useful * 2 - rec.irr * 2
|
|
488
|
+
}
|
|
489
|
+
out.sort((a, b) => b.score - a.score || b.mtime - a.mtime)
|
|
490
|
+
return out.slice(0, limit)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** 当日简报:按 mtime 列出今天新增/更新的卡(供每日回顾)。 */
|
|
494
|
+
export function dailyBrief(cardsToday) {
|
|
495
|
+
if (!cardsToday || cardsToday.length === 0) return '今天还没有新的记忆沉淀。'
|
|
496
|
+
return cardsToday.map((c) => `- ${c.kind}|${c.title}`).join('\n')
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** 生成今日简报文件到 00-System/daily-YYYY-MM-DD.md,幂等(已存在则跳过)。 */
|
|
500
|
+
export async function generateDailyBrief(root) {
|
|
501
|
+
const d = new Date()
|
|
502
|
+
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
|
+
try {
|
|
506
|
+
if (await exists(file)) return { ok: true, existed: true, file }
|
|
507
|
+
const s = await stats(root)
|
|
508
|
+
const content = `---\nkind: prompt\ntitle: 每日回顾 ${date}\ntags: [每日回顾, 统计]\ncreated: ${new Date().toISOString()}\nupdated: ${new Date().toISOString()}\nsource: daily-brief\nstatus: approved\n---\n# 每日回顾 ${date}\n\n今日新增 ${s.today} · 近 7 天 ${s.week} · 总 ${s.total}\n\n## 今日沉淀\n${dailyBrief(s.todayCards)}\n`
|
|
509
|
+
await fs.mkdir(dir, { recursive: true })
|
|
510
|
+
await fs.writeFile(file, content, 'utf8')
|
|
511
|
+
return { ok: true, existed: false, file }
|
|
512
|
+
} catch (e) {
|
|
513
|
+
return { ok: false, error: String(e.message || e) }
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** 用量趋势:返回最近 N 天每天新增卡片数 [{date, count}]。 */
|
|
518
|
+
export async function dailyCounts(root, days = 30) {
|
|
519
|
+
const cards = await listCards(root)
|
|
520
|
+
const now = Date.now()
|
|
521
|
+
const buckets = {}
|
|
522
|
+
for (let d = 0; d < days; d++) {
|
|
523
|
+
const date = new Date(now - d * 86400000)
|
|
524
|
+
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
525
|
+
buckets[key] = 0
|
|
526
|
+
}
|
|
527
|
+
for (const c of cards) {
|
|
528
|
+
if (!c.created) continue
|
|
529
|
+
const d = new Date(c.created)
|
|
530
|
+
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
531
|
+
if (key in buckets) buckets[key]++
|
|
532
|
+
}
|
|
533
|
+
return Object.entries(buckets).sort(([a], [b]) => a.localeCompare(b)).map(([date, count]) => ({ date, count }))
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** 导出全部卡片原始 Markdown。 */
|
|
537
|
+
export async function exportCards(root) {
|
|
538
|
+
const cards = await listCards(root)
|
|
539
|
+
const out = []
|
|
540
|
+
for (const c of cards) {
|
|
541
|
+
try { out.push({ path: c.path, title: c.title, kind: c.kind, text: await fs.readFile(path.join(root, c.path), 'utf8') }) } catch { /* 跳过坏文件 */ }
|
|
542
|
+
}
|
|
543
|
+
return out
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** 图谱:节点 = 卡片;边 = [[wikilink]](同 vault 命中)或共享标签。 */
|
|
547
|
+
export async function graph(root) {
|
|
548
|
+
const cards = await listCards(root)
|
|
549
|
+
const byPath = new Map(cards.map((c) => [c.path, c]))
|
|
550
|
+
const nodes = cards.map((c) => ({
|
|
551
|
+
id: c.path,
|
|
552
|
+
title: c.title,
|
|
553
|
+
kind: c.kind,
|
|
554
|
+
tags: c.tags,
|
|
555
|
+
summary: c.summary.slice(0, 80),
|
|
556
|
+
updated: c.updated,
|
|
557
|
+
mtime: c.mtime,
|
|
558
|
+
linkCount: 0,
|
|
559
|
+
}))
|
|
560
|
+
const nodeById = new Map(nodes.map((n) => [n.id, n]))
|
|
561
|
+
const addDegree = (p) => { const n = nodeById.get(p); if (n) n.linkCount++ }
|
|
562
|
+
const edges = []
|
|
563
|
+
const seen = new Set()
|
|
564
|
+
const bodies = new Map()
|
|
565
|
+
const addEdge = (a, b, type) => {
|
|
566
|
+
const key = [a, b].sort().join('|') + '|' + type
|
|
567
|
+
if (seen.has(key)) return
|
|
568
|
+
seen.add(key)
|
|
569
|
+
edges.push({ source: a, target: b, type })
|
|
570
|
+
}
|
|
571
|
+
for (const card of cards) {
|
|
572
|
+
let text = ''
|
|
573
|
+
try {
|
|
574
|
+
text = await fs.readFile(path.join(root, card.path), 'utf8')
|
|
575
|
+
} catch {
|
|
576
|
+
continue
|
|
577
|
+
}
|
|
578
|
+
bodies.set(card.path, text)
|
|
579
|
+
// wikilinks:[[目标路径]] 或 [[标题]]
|
|
580
|
+
for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)) {
|
|
581
|
+
const raw = m[1].trim().replace(/\.md$/, '')
|
|
582
|
+
const candidates = [
|
|
583
|
+
`${card.kind === 'knowledge' ? KIND_ROOTS.knowledge : KIND_ROOTS[card.kind]}/${raw}.md`,
|
|
584
|
+
`${KIND_ROOTS.knowledge}/${raw}.md`,
|
|
585
|
+
`${raw}.md`,
|
|
586
|
+
]
|
|
587
|
+
for (const cand of candidates) {
|
|
588
|
+
if (cand !== card.path && byPath.has(cand)) {
|
|
589
|
+
addEdge(card.path, cand, 'link'); addDegree(card.path); addDegree(cand)
|
|
590
|
+
break
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// 也允许按标题匹配
|
|
594
|
+
for (const other of cards) {
|
|
595
|
+
if (other.path !== card.path && (other.title === raw || other.path.endsWith(`/${raw}.md`))) {
|
|
596
|
+
addEdge(card.path, other.path, 'link'); addDegree(card.path); addDegree(other.path)
|
|
597
|
+
break
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// 共享标签(同一标签出现两次以上才连线,避免全连)
|
|
602
|
+
for (const tag of card.tags) {
|
|
603
|
+
for (const other of cards) {
|
|
604
|
+
if (other.path !== card.path && other.tags.includes(tag)) addEdge(card.path, other.path, `tag:${tag}`)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// 相似度关联建议:仅在同 kind 或共享标签的候选间计算 Jaccard,>0.42 连一条「similar」边,每节点最多 1 条,避免稠密发丝球。
|
|
609
|
+
const similarCount = {}
|
|
610
|
+
const simCandidates = []
|
|
611
|
+
for (const c of cards) simCandidates.push(c.path)
|
|
612
|
+
for (let i = 0; i < simCandidates.length; i++) for (let j = i + 1; j < simCandidates.length; j++) {
|
|
613
|
+
const a = simCandidates[i], b = simCandidates[j]
|
|
614
|
+
const ca = byPath.get(a), cb = byPath.get(b)
|
|
615
|
+
if (!ca || !cb) continue
|
|
616
|
+
if (ca.kind !== cb.kind && !ca.tags.some((t) => cb.tags.includes(t))) continue
|
|
617
|
+
if ((similarCount[a] || 0) >= 1 || (similarCount[b] || 0) >= 1) continue
|
|
618
|
+
const sim = textSimilarity(bodies.get(a) || '', bodies.get(b) || '')
|
|
619
|
+
if (sim >= 0.42) { addEdge(a, b, 'similar'); addDegree(a); addDegree(b); similarCount[a] = (similarCount[a] || 0) + 1; similarCount[b] = (similarCount[b] || 0) + 1 }
|
|
620
|
+
}
|
|
621
|
+
return { nodes, edges }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** 统计(增强):overview 全量 + 今日/本周新增 + 今日新增卡片明细。 */
|
|
625
|
+
export async function stats(root) {
|
|
626
|
+
const cards = await listCards(root)
|
|
627
|
+
const byKind = {}
|
|
628
|
+
for (const c of cards) byKind[c.kind] = (byKind[c.kind] || 0) + 1
|
|
629
|
+
const now = Date.now()
|
|
630
|
+
const day = now - 86400000
|
|
631
|
+
const week = now - 7 * 86400000
|
|
632
|
+
const today = cards.filter((c) => c.mtime > day)
|
|
633
|
+
const tagSet = new Set()
|
|
634
|
+
for (const c of cards) for (const t of c.tags) tagSet.add(t)
|
|
635
|
+
return {
|
|
636
|
+
total: cards.length,
|
|
637
|
+
today: today.length,
|
|
638
|
+
week: cards.filter((c) => c.mtime > week).length,
|
|
639
|
+
tags: tagSet.size,
|
|
640
|
+
byKind,
|
|
641
|
+
todayCards: today.slice(0, 30).map((c) => ({ path: c.path, title: c.title, kind: c.kind, updated: c.updated, summary: c.summary.slice(0, 80) })),
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** 跨库聚合图谱:roots = [{name, root}],每个 vault 的子图合并(node id 前缀 profile 名避免冲突)。 */
|
|
646
|
+
export async function graphAll(roots) {
|
|
647
|
+
const nodes = []; const edges = []; const idSet = new Set()
|
|
648
|
+
for (const r of roots) {
|
|
649
|
+
let g
|
|
650
|
+
try { g = await graph(r.root) } catch { continue }
|
|
651
|
+
const pref = r.name ? `${r.name}::` : ''
|
|
652
|
+
const map = new Map()
|
|
653
|
+
for (const n of g.nodes) { const id = pref + n.id; if (!idSet.has(id)) { idSet.add(id); map.set(n.id, id); nodes.push({ ...n, id }) } else map.set(n.id, id) }
|
|
654
|
+
for (const e of g.edges) { const s = map.get(e.source) || (pref + e.source), t = map.get(e.target) || (pref + e.target); if (s && t) edges.push({ source: s, target: t, type: e.type }) }
|
|
655
|
+
}
|
|
656
|
+
return { nodes, edges }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** 跨库聚合检索:多个 vault 的结果合并(path 前缀 profile 名)。 */
|
|
660
|
+
export async function searchAll(roots, query, opts = {}) {
|
|
661
|
+
const out = []
|
|
662
|
+
for (const r of roots) {
|
|
663
|
+
let hits
|
|
664
|
+
try { hits = await search(r.root, query, opts) } catch { continue }
|
|
665
|
+
const pref = r.name ? `${r.name}::` : ''
|
|
666
|
+
for (const h of hits) out.push({ ...h, path: pref + h.path, score: (h.score || 0) + (r.name ? 0 : 0), profile: r.name || '' })
|
|
667
|
+
}
|
|
668
|
+
out.sort((a, b) => b.score - a.score || b.mtime - a.mtime)
|
|
669
|
+
return out.slice(0, opts.limit || 30)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** 整理建议(非破坏性预览):高相似卡对 + 陈旧卡(>90 天未更新且无连线)。 */
|
|
673
|
+
export async function optimizeCandidates(root) {
|
|
674
|
+
const cards = await listCards(root)
|
|
675
|
+
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
|
+
for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
|
|
681
|
+
const a = cards[i], b = cards[j]
|
|
682
|
+
if (a.kind !== b.kind) continue
|
|
683
|
+
const sim = textSimilarity(bodies.get(a.path) || '', bodies.get(b.path) || '')
|
|
684
|
+
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
|
+
}
|
|
686
|
+
merge.sort((x, y) => y.sim - x.sim)
|
|
687
|
+
const old = Date.now() - 90 * 86400000
|
|
688
|
+
const stale = cards.filter((c) => c.mtime < old).slice(0, 30).map((c) => ({ path: c.path, title: c.title, updated: c.updated }))
|
|
689
|
+
return { merge: merge.slice(0, 40), stale }
|
|
690
|
+
}
|
|
691
|
+
|
|
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
|
+
*/
|
|
702
|
+
export async function optimizeApply(root, opts = {}) {
|
|
703
|
+
const simThreshold = Number(opts.simThreshold ?? 0.55)
|
|
704
|
+
const staleDays = Number(opts.staleDays ?? 90)
|
|
705
|
+
const cleanupStale = Boolean(opts.cleanupStale)
|
|
706
|
+
const dryRun = Boolean(opts.dryRun)
|
|
707
|
+
const cands = await optimizeCandidates(root)
|
|
708
|
+
const merge = cands.merge
|
|
709
|
+
const stale = cands.stale
|
|
710
|
+
const merged = []
|
|
711
|
+
const staleDeleted = []
|
|
712
|
+
const staleSkipped = []
|
|
713
|
+
const errors = []
|
|
714
|
+
const used = new Set()
|
|
715
|
+
for (const pair of merge) {
|
|
716
|
+
if (used.has(pair.a.path) || used.has(pair.b.path)) continue
|
|
717
|
+
if (pair.sim < simThreshold) continue
|
|
718
|
+
if (!dryRun) {
|
|
719
|
+
try {
|
|
720
|
+
const r = await mergeCards(root, [pair.a.path, pair.b.path])
|
|
721
|
+
if (r && r.ok) {
|
|
722
|
+
merged.push({ from: [pair.a.path, pair.b.path], to: r.path, sim: pair.sim })
|
|
723
|
+
used.add(pair.a.path); used.add(pair.b.path)
|
|
724
|
+
} else { errors.push(`merge ${pair.a.path}+${pair.b.path}: ${r?.error || '失败'}`) }
|
|
725
|
+
} catch (e) { errors.push(`merge ${pair.a.path}+${pair.b.path}: ${e?.message || e}`) }
|
|
726
|
+
} else {
|
|
727
|
+
merged.push({ from: [pair.a.path, pair.b.path], to: '(dry-run)', sim: pair.sim })
|
|
728
|
+
used.add(pair.a.path); used.add(pair.b.path)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (cleanupStale) {
|
|
732
|
+
for (const s of stale) {
|
|
733
|
+
if (!dryRun) {
|
|
734
|
+
try { await deleteCard(root, s.path); staleDeleted.push(s.path) } catch (e) { errors.push(`delete ${s.path}: ${e?.message || e}`); staleSkipped.push(s.path) }
|
|
735
|
+
} else { staleDeleted.push(s.path) }
|
|
736
|
+
}
|
|
737
|
+
} else {
|
|
738
|
+
for (const s of stale) staleSkipped.push(s.path)
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
ok: true,
|
|
742
|
+
dryRun,
|
|
743
|
+
plan: { merged: merged.length, staleDeleted: staleDeleted.length, staleSkipped: staleSkipped.length },
|
|
744
|
+
merged,
|
|
745
|
+
staleDeleted,
|
|
746
|
+
staleSkipped,
|
|
747
|
+
errors,
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** 召回反馈记录:命中是否有用(供相关性调优)。文件存 vault 根 `.feedback.json`。 */
|
|
752
|
+
export async function readFeedback(root) {
|
|
753
|
+
try { return JSON.parse(await fs.readFile(path.join(root, '.feedback.json'), 'utf8')) } catch { return [] }
|
|
754
|
+
}
|
|
755
|
+
export async function addFeedback(root, rec) {
|
|
756
|
+
const list = await readFeedback(root)
|
|
757
|
+
list.unshift({ ts: Date.now(), ...rec })
|
|
758
|
+
await fs.writeFile(path.join(root, '.feedback.json'), JSON.stringify(list.slice(0, 500), null, 2), 'utf8')
|
|
759
|
+
return { ok: true }
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** 统计:按 kind 计数 + 最近 7 天更新数 + 总标签数。 */
|
|
763
|
+
export async function overview(root) { const cards = await listCards(root, { status: ['approved', 'pending', 'rejected'] })
|
|
764
|
+
const byKind = {}
|
|
765
|
+
const statusCounts = {}
|
|
766
|
+
for (const c of cards) { byKind[c.kind] = (byKind[c.kind] || 0) + 1; statusCounts[c.status] = (statusCounts[c.status] || 0) + 1 }
|
|
767
|
+
const week = Date.now() - 7 * 86400000
|
|
768
|
+
const recent = cards.filter((c) => c.mtime > week).length
|
|
769
|
+
const tagSet = new Set()
|
|
770
|
+
for (const c of cards) for (const t of c.tags) tagSet.add(t)
|
|
771
|
+
return {
|
|
772
|
+
total: cards.length,
|
|
773
|
+
byKind,
|
|
774
|
+
recent,
|
|
775
|
+
tags: tagSet.size,
|
|
776
|
+
status: statusCounts,
|
|
777
|
+
roots: Object.fromEntries(Object.entries(KIND_ROOTS).map(([k, v]) => [k, `${v}/`])),
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// -- helpers ---------------------------------------------------------------
|
|
782
|
+
|
|
783
|
+
function resolveInside(root, rel) {
|
|
784
|
+
const target = path.resolve(root, rel)
|
|
785
|
+
const rootResolved = path.resolve(root)
|
|
786
|
+
if (target !== rootResolved && !target.startsWith(rootResolved + path.sep)) return null
|
|
787
|
+
return target
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function exists(p) {
|
|
791
|
+
try {
|
|
792
|
+
await fs.access(p)
|
|
793
|
+
return true
|
|
794
|
+
} catch {
|
|
795
|
+
return false
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function yamlString(value) {
|
|
800
|
+
const s = String(value ?? '')
|
|
801
|
+
return /[:#\[\]{}"',&*!|>%@`]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
|
|
802
|
+
}
|