dsh-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/lib/vault.js ADDED
@@ -0,0 +1,386 @@
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
+ /** 与 boujoy 一致的主题目录根。 */
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
+ }
22
+
23
+ export const CAPTURE_KINDS = ['project', 'knowledge', 'content', 'prompt', 'business']
24
+
25
+ /** 从 Markdown 文本提取 frontmatter 与正文。 */
26
+ export function parseCard(text) {
27
+ const meta = { title: '', kind: 'knowledge', tags: [], created: '', updated: '', source: '' }
28
+ let body = text
29
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text)
30
+ if (m) {
31
+ body = text.slice(m[0].length)
32
+ for (const line of m[1].split(/\r?\n/)) {
33
+ const eq = line.indexOf(':')
34
+ if (eq <= 0) continue
35
+ const key = line.slice(0, eq).trim()
36
+ let value = line.slice(eq + 1).trim()
37
+ if (key === 'tags') {
38
+ value = value.replace(/^\[/, '').replace(/\]$/, '')
39
+ meta.tags = value.split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
40
+ continue
41
+ }
42
+ if (key === 'kind' || key === 'title' || key === 'created' || key === 'updated' || key === 'source') {
43
+ meta[key] = value.replace(/^['"]|['"]$/g, '')
44
+ }
45
+ }
46
+ }
47
+ // 标题:frontmatter 的 title 优先,否则取第一个 # 标题,否则取首行。
48
+ if (!meta.title) {
49
+ const h = /^#\s+(.+)$/m.exec(body)
50
+ meta.title = h ? h[1].trim() : body.trim().split(/\r?\n/)[0].slice(0, 60)
51
+ }
52
+ const summary = body.trim().slice(0, 200)
53
+ return { meta, body: body.trim(), summary }
54
+ }
55
+
56
+ /** 生成安全 slug(中文保留、非法字符替换为 -)。 */
57
+ export function safeSlug(name) {
58
+ const base = String(name || 'card')
59
+ .trim()
60
+ .toLowerCase()
61
+ .replace(/[\s_]+/g, '-')
62
+ .replace(/[^\w\u4e00-\u9fff-]+/g, '')
63
+ .replace(/-+/g, '-')
64
+ .replace(/^-|-$/g, '')
65
+ return base || 'card'
66
+ }
67
+
68
+ /** Jaccard-like 相似度:字符 bigram 集合(移植 boujoy _text_similarity)。 */
69
+ export function textSimilarity(a, b) {
70
+ const bigrams = (text) => {
71
+ const cleaned = String(text)
72
+ .replace(/[\s#*_`|[\]()()\-—・]+/g, '')
73
+ .toLowerCase()
74
+ const out = new Set()
75
+ for (let i = 0; i < cleaned.length - 1; i++) out.add(cleaned.slice(i, i + 2))
76
+ if (cleaned.length === 1) out.add(cleaned)
77
+ return out
78
+ }
79
+ const ba = bigrams(a)
80
+ const bb = bigrams(b)
81
+ if (ba.size === 0 || bb.size === 0) return 0
82
+ let inter = 0
83
+ for (const g of ba) if (bb.has(g)) inter++
84
+ return inter / (ba.size + bb.size - inter)
85
+ }
86
+
87
+ /** 在目标目录中找与候选文本最相似的现有卡(去重守卫)。比较正文(剔除 frontmatter)。 */
88
+ export async function dedupCheck(dir, text, threshold = 0.62) {
89
+ let best = null
90
+ let entries = []
91
+ try {
92
+ entries = await fs.readdir(dir)
93
+ } catch {
94
+ return null
95
+ }
96
+ for (const name of entries) {
97
+ if (!name.toLowerCase().endsWith('.md')) continue
98
+ try {
99
+ const existing = await fs.readFile(path.join(dir, name), 'utf8')
100
+ // 只比较正文,避免 frontmatter 稀释相似度
101
+ const { body } = parseCard(existing)
102
+ const score = textSimilarity(text, body || existing)
103
+ if (score >= threshold && (!best || score > best.score)) best = { path: name, score }
104
+ } catch {
105
+ // 跳过不可读文件
106
+ }
107
+ }
108
+ return best
109
+ }
110
+
111
+ /** CJK 感知查询词:整词 + 中文字符 bigram(移植 boujoy query_terms)。 */
112
+ export function queryTerms(query) {
113
+ const tokens = new Set(String(query).split(/[^\w\u4e00-\u9fff]+/).filter(Boolean))
114
+ for (let i = 0; i < query.length - 1; i++) {
115
+ const c1 = query[i]
116
+ const c2 = query[i + 1]
117
+ if (/[\u4e00-\u9fff]/.test(c1) && /[\u4e00-\u9fff]/.test(c2)) tokens.add(c1 + c2)
118
+ }
119
+ return tokens
120
+ }
121
+
122
+ /** 递归列出 vault 下所有卡片(02-06 目录内 *.md),返回解析后的卡摘要。 */
123
+ export async function listCards(root) {
124
+ const cards = []
125
+ for (const kind of CAPTURE_KINDS) {
126
+ const dir = path.join(root, KIND_ROOTS[kind])
127
+ let files = []
128
+ try {
129
+ files = await walkMd(dir)
130
+ } catch {
131
+ continue
132
+ }
133
+ for (const rel of files) {
134
+ try {
135
+ const full = path.join(dir, rel)
136
+ const text = await fs.readFile(full, 'utf8')
137
+ const { meta, body, summary } = parseCard(text)
138
+ const stat = await fs.stat(full)
139
+ cards.push({
140
+ path: `${KIND_ROOTS[kind]}/${rel}`,
141
+ kind,
142
+ title: meta.title || rel.replace(/\.md$/, ''),
143
+ tags: meta.tags,
144
+ summary,
145
+ created: meta.created,
146
+ updated: meta.updated || stat.mtime.toISOString(),
147
+ mtime: stat.mtimeMs,
148
+ })
149
+ } catch {
150
+ // 跳过坏文件
151
+ }
152
+ }
153
+ }
154
+ cards.sort((a, b) => b.mtime - a.mtime)
155
+ return cards
156
+ }
157
+
158
+ async function walkMd(dir) {
159
+ const out = []
160
+ let entries
161
+ try {
162
+ entries = await fs.readdir(dir, { withFileTypes: true })
163
+ } catch {
164
+ return out
165
+ }
166
+ for (const ent of entries) {
167
+ const rel = path.join('.', ent.name)
168
+ if (ent.isDirectory()) {
169
+ out.push(...(await walkMd(path.join(dir, ent.name))).map((f) => path.join(rel, f)))
170
+ } else if (ent.name.toLowerCase().endsWith('.md')) {
171
+ out.push(rel.replace(/\\/g, '/'))
172
+ }
173
+ }
174
+ return out
175
+ }
176
+
177
+ /** 确保 vault 目录结构存在(00-System + 02-06)。 */
178
+ export async function ensureVault(root) {
179
+ await fs.mkdir(path.join(root, '00-System'), { recursive: true })
180
+ for (const dir of Object.values(KIND_ROOTS)) {
181
+ await fs.mkdir(path.join(root, dir), { recursive: true })
182
+ }
183
+ }
184
+
185
+ /** 读取一张卡(相对路径,安全限定在 vault 内)。 */
186
+ export async function readCard(root, rel) {
187
+ const target = resolveInside(root, rel)
188
+ if (!target) throw new Error('路径越界')
189
+ return fs.readFile(target, 'utf8')
190
+ }
191
+
192
+ /** 原子写卡;先去重(target 目录内),命中返回 {duplicate}。 */
193
+ export async function writeCard(root, { kind, title, tags = [], body, source = '' }, { threshold = 0.62, dedup = true } = {}) {
194
+ const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
195
+ const dir = path.join(root, kindRoot)
196
+ await fs.mkdir(dir, { recursive: true })
197
+ if (dedup) {
198
+ const hit = await dedupCheck(dir, body, threshold)
199
+ if (hit) return { ok: false, duplicate: { ...hit, path: `${kindRoot}/${hit.path}` } }
200
+ }
201
+ const slug = safeSlug(title)
202
+ let rel = `${slug}.md`
203
+ let index = 2
204
+ while (await exists(path.join(dir, rel))) {
205
+ rel = `${slug}-${index}.md`
206
+ index++
207
+ }
208
+ const now = new Date().toISOString()
209
+ const text = [
210
+ '---',
211
+ `kind: ${kind}`,
212
+ `title: ${yamlString(title)}`,
213
+ `tags: [${tags.map((t) => yamlString(t)).join(', ')}]`,
214
+ `created: ${now}`,
215
+ `updated: ${now}`,
216
+ ...(source ? [`source: ${source}`] : []),
217
+ '---',
218
+ '',
219
+ `# ${title}`,
220
+ '',
221
+ body.trim(),
222
+ '',
223
+ ].join('\n')
224
+ const target = path.join(dir, rel)
225
+ const tmp = target + '.tmp'
226
+ await fs.writeFile(tmp, text, 'utf8')
227
+ await fs.rename(tmp, target)
228
+ return { ok: true, path: `${kindRoot}/${rel}`, kind }
229
+ }
230
+
231
+ /** 向已存在的卡追加「更新记录」段(去重命中时的正确动作,移植 boujoy 语义)。 */
232
+ export async function appendUpdate(root, rel, updateText, { threshold = 0.62 } = {}) {
233
+ const target = resolveInside(root, rel)
234
+ if (!target) throw new Error('路径越界')
235
+ const existing = await fs.readFile(target, 'utf8')
236
+ const { meta, body } = parseCard(existing)
237
+ const now = new Date().toISOString()
238
+ const updated = [
239
+ '---',
240
+ `kind: ${meta.kind || 'knowledge'}`,
241
+ `title: ${yamlString(meta.title)}`,
242
+ `tags: [${(meta.tags || []).map((t) => yamlString(t)).join(', ')}]`,
243
+ `created: ${meta.created || now}`,
244
+ `updated: ${now}`,
245
+ ...(meta.source ? [`source: ${meta.source}`] : []),
246
+ '---',
247
+ '',
248
+ body.trim(),
249
+ '',
250
+ '## 更新记录',
251
+ '',
252
+ `- ${now.slice(0, 10)}:${updateText.trim()}`,
253
+ '',
254
+ ].join('\n')
255
+ const tmp = target + '.tmp'
256
+ await fs.writeFile(tmp, updated, 'utf8')
257
+ await fs.rename(tmp, target)
258
+ return { ok: true, path: rel, kind: meta.kind || 'knowledge', updated: now }
259
+ }
260
+
261
+ /** 检索:整词/中文 bigram 命中 path + 正文。返回卡片摘要(带命中度)。 */
262
+ export async function search(root, query, { limit = 30 } = {}) {
263
+ const q = String(query || '').trim()
264
+ if (!q) return []
265
+ const cards = await listCards(root)
266
+ const wanted = queryTerms(q)
267
+ const out = []
268
+ for (const card of cards) {
269
+ let text
270
+ try {
271
+ text = await fs.readFile(path.join(root, card.path), 'utf8')
272
+ } catch {
273
+ continue
274
+ }
275
+ const haystack = `${card.path}\n${card.title}\n${text}`.toLowerCase()
276
+ let score = 0
277
+ if (haystack.includes(q.toLowerCase())) score = 3
278
+ for (const term of wanted) {
279
+ if (haystack.includes(term.toLowerCase())) score += 1
280
+ }
281
+ if (score > 0) {
282
+ out.push({ ...card, score })
283
+ }
284
+ }
285
+ out.sort((a, b) => b.score - a.score || b.mtime - a.mtime)
286
+ return out.slice(0, limit)
287
+ }
288
+
289
+ /** 图谱:节点 = 卡片;边 = [[wikilink]](同 vault 命中)或共享标签。 */
290
+ export async function graph(root) {
291
+ const cards = await listCards(root)
292
+ const byPath = new Map(cards.map((c) => [c.path, c]))
293
+ const nodes = cards.map((c) => ({
294
+ id: c.path,
295
+ title: c.title,
296
+ kind: c.kind,
297
+ tags: c.tags,
298
+ summary: c.summary.slice(0, 80),
299
+ }))
300
+ const edges = []
301
+ const seen = new Set()
302
+ const addEdge = (a, b, type) => {
303
+ const key = [a, b].sort().join('|') + '|' + type
304
+ if (seen.has(key)) return
305
+ seen.add(key)
306
+ edges.push({ source: a, target: b, type })
307
+ }
308
+ for (const card of cards) {
309
+ let text = ''
310
+ try {
311
+ text = await fs.readFile(path.join(root, card.path), 'utf8')
312
+ } catch {
313
+ continue
314
+ }
315
+ // wikilinks:[[目标路径]] 或 [[标题]]
316
+ for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)) {
317
+ const raw = m[1].trim().replace(/\.md$/, '')
318
+ const candidates = [
319
+ `${card.kind === 'knowledge' ? KIND_ROOTS.knowledge : KIND_ROOTS[card.kind]}/${raw}.md`,
320
+ `${KIND_ROOTS.knowledge}/${raw}.md`,
321
+ `${raw}.md`,
322
+ ]
323
+ for (const cand of candidates) {
324
+ if (cand !== card.path && byPath.has(cand)) {
325
+ addEdge(card.path, cand, 'link')
326
+ break
327
+ }
328
+ }
329
+ // 也允许按标题匹配
330
+ for (const other of cards) {
331
+ if (other.path !== card.path && (other.title === raw || other.path.endsWith(`/${raw}.md`))) {
332
+ addEdge(card.path, other.path, 'link')
333
+ break
334
+ }
335
+ }
336
+ }
337
+ // 共享标签(同一标签出现两次以上才连线,避免全连)
338
+ for (const tag of card.tags) {
339
+ for (const other of cards) {
340
+ if (other.path !== card.path && other.tags.includes(tag)) addEdge(card.path, other.path, `tag:${tag}`)
341
+ }
342
+ }
343
+ }
344
+ return { nodes, edges }
345
+ }
346
+
347
+ /** 统计:按 kind 计数 + 最近 7 天更新数 + 总标签数。 */
348
+ export async function overview(root) {
349
+ const cards = await listCards(root)
350
+ const byKind = {}
351
+ for (const c of cards) byKind[c.kind] = (byKind[c.kind] || 0) + 1
352
+ const week = Date.now() - 7 * 86400000
353
+ const recent = cards.filter((c) => c.mtime > week).length
354
+ const tagSet = new Set()
355
+ for (const c of cards) for (const t of c.tags) tagSet.add(t)
356
+ return {
357
+ total: cards.length,
358
+ byKind,
359
+ recent,
360
+ tags: tagSet.size,
361
+ roots: Object.fromEntries(Object.entries(KIND_ROOTS).map(([k, v]) => [k, `${v}/`])),
362
+ }
363
+ }
364
+
365
+ // -- helpers ---------------------------------------------------------------
366
+
367
+ function resolveInside(root, rel) {
368
+ const target = path.resolve(root, rel)
369
+ const rootResolved = path.resolve(root)
370
+ if (target !== rootResolved && !target.startsWith(rootResolved + path.sep)) return null
371
+ return target
372
+ }
373
+
374
+ async function exists(p) {
375
+ try {
376
+ await fs.access(p)
377
+ return true
378
+ } catch {
379
+ return false
380
+ }
381
+ }
382
+
383
+ function yamlString(value) {
384
+ const s = String(value ?? '')
385
+ return /[:#\[\]{}"',&*!|>%@`]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
386
+ }
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "dsh-memory-eternal",
3
+ "version": "0.1.0",
4
+ "description": "记忆核心(Memory Eternal):把 boujoy-harness 的记忆模块搬进任意 DeepSeek Harness 的独立 DSH 插件——对话结束后自动沉淀知识卡到本地 Markdown Vault(去重、可检索),设置页提供图形化知识库(统计 / 搜索 / 知识图谱),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/EternalNight996/dsh-memory-eternal.git"
9
+ },
10
+ "homepage": "https://github.com/EternalNight996/dsh-memory-eternal",
11
+ "author": "EternalNight996",
12
+ "publishConfig": {
13
+ "registry": "https://registry.npmjs.org/"
14
+ },
15
+ "type": "module",
16
+ "main": "index.js",
17
+ "exports": {
18
+ ".": "./index.js",
19
+ "./client": "./lib/client.js",
20
+ "./cordis.patch.yml": "./cordis.patch.yml",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "lib",
26
+ "assets",
27
+ "docs",
28
+ "cordis.patch.yml",
29
+ "README.md",
30
+ "PUBLISH.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "keywords": [
37
+ "deepseek-harness",
38
+ "dsh",
39
+ "dsh-plugin",
40
+ "plugin",
41
+ "memory",
42
+ "knowledge-base",
43
+ "vault",
44
+ "markdown",
45
+ "knowledge-graph",
46
+ "rag",
47
+ "second-brain"
48
+ ],
49
+ "scripts": {
50
+ "build": "node build.mjs",
51
+ "build:client": "node build.mjs",
52
+ "prepublishOnly": "node build.mjs",
53
+ "test": "node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/api.test.mjs"
54
+ },
55
+ "dependencies": {
56
+ "@deepseek-ai/schemastery": "^3.18.1"
57
+ },
58
+ "peerDependencies": {
59
+ "@deepseek-ai/cordis": "^4.0.1",
60
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
61
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.7"
62
+ },
63
+ "devDependencies": {
64
+ "esbuild": "^0.24.0",
65
+ "@types/react": "^18.3.1"
66
+ },
67
+ "dsh": {
68
+ "client": {
69
+ "platform": "web",
70
+ "inject": [
71
+ "@deepseek-ai/dsh-client-runtime",
72
+ "@deepseek-ai/dsh-client-ui-settings",
73
+ "@deepseek-ai/dsh-client-locale",
74
+ "@deepseek-ai/dsh-client-connection",
75
+ "@deepseek-ai/dsh-api-remotes"
76
+ ]
77
+ },
78
+ "bundle": {
79
+ "patch": "./cordis.patch.yml"
80
+ }
81
+ }
82
+ }