memory-eternal 0.18.2 → 0.21.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.
@@ -0,0 +1,458 @@
1
+ // 记忆核心 · 技能卡生成(kind=skill)
2
+ //
3
+ // 定位:技能卡是「本机实际安装/可用的技能」的镜像,**不由 agent 对话沉淀**,
4
+ // 而是由技能注册表(宿主 ctx.skills)或技能文件(独立 web / 无宿主时)扫描生成。
5
+ //
6
+ // 审核边界:技能卡与 core 一样**强制 pending**(见 lib/db.js enforceAudit)——
7
+ // 技能正文可能包含任意指令,必须由人确认后才进主库,避免绕过审核直接进入召回。
8
+ //
9
+ // 幂等:按 source=`skill:<name>` 对齐已有卡。
10
+ // - 待审卡内容变了 → 原地刷新(仍是同一张待审卡,不刷屏);
11
+ // - 已批准卡内容变了 → 新开一张「技能更新」待审卡(旧卡继续可用,审核后再替换);
12
+ // - 驳回/删除过的卡 → 不复活(尊重人的决定)。
13
+
14
+ import fs from 'node:fs'
15
+ import os from 'node:os'
16
+ import path from 'node:path'
17
+ import { createHash } from 'node:crypto'
18
+ import { writeCard, updateCardContent, ensureVault } from './vault.js'
19
+ import { getDb } from './db.js'
20
+
21
+ /** 技能卡类型与目录(与 lib/vault.js 的 KIND_ROOTS 保持一致)。 */
22
+ export const SKILL_KIND = 'skill'
23
+ export const SKILL_ROOT = '10-Skills'
24
+ const DEFAULT_MAX_CONTENT = 16000
25
+
26
+ /** 归一化正文,仅用于「内容是否变化」的比较。 */
27
+ function sameText(a, b) {
28
+ return String(a || '').replace(/\r\n/g, '\n').trim() === String(b || '').replace(/\r\n/g, '\n').trim()
29
+ }
30
+
31
+ /** 内容指纹(12 位足够区分同一技能的版本)。 */
32
+ export function skillContentHash(title, body) {
33
+ return createHash('sha1').update(String(title || '') + '\n' + String(body || '')).digest('hex').slice(0, 12)
34
+ }
35
+
36
+ /** 从 SKILL.md 文本里取 frontmatter;支持 description 的单行 / 多行块写法。 */
37
+ export function parseSkillFile(text, fallbackName = 'skill') {
38
+ const raw = String(text || '')
39
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw)
40
+ let head = ''
41
+ let body = raw
42
+ if (m) { head = m[1]; body = raw.slice(m[0].length) }
43
+ const meta = {}
44
+ const lines = head.split(/\r?\n/)
45
+ for (let i = 0; i < lines.length; i++) {
46
+ const line = lines[i]
47
+ const eq = line.indexOf(':')
48
+ if (eq <= 0 || /^\s/.test(line)) continue
49
+ const key = line.slice(0, eq).trim()
50
+ let value = line.slice(eq + 1).trim()
51
+ if (value === '>' || value === '|' || value === '>-' || value === '|-') {
52
+ const parts = []
53
+ while (i + 1 < lines.length && (/^\s+/.test(lines[i + 1]) || !lines[i + 1].trim())) {
54
+ i++
55
+ parts.push(lines[i].replace(/^\s+/, ''))
56
+ }
57
+ value = parts.join(value.startsWith('|') ? '\n' : ' ').trim()
58
+ }
59
+ meta[key] = value.replace(/^['"]|['"]$/g, '')
60
+ }
61
+ const name = String(meta.name || '').trim()
62
+ const description = String(meta.description || '').trim()
63
+ return {
64
+ name: name || fallbackName,
65
+ description,
66
+ content: body.trim(),
67
+ whenToUse: String(meta.whenToUse || meta['when-to-use'] || '').trim(),
68
+ // 与 DSH skill-filesystem 的准入对齐:必须有 frontmatter,且 name / description 都不能空。
69
+ // 文件系统兜底用这两个标记过滤掉 README / 随手放的 markdown,避免把非技能文件生成卡片。
70
+ hasFrontmatter: !!m,
71
+ hasName: !!name,
72
+ hasDescription: !!description,
73
+ }
74
+ }
75
+
76
+ /** 安全读取目录(不存在返回 [])。 */
77
+ function readDirs(dir) {
78
+ try { return fs.readdirSync(dir, { withFileTypes: true }) } catch { return [] }
79
+ }
80
+
81
+ /** 是否是目录(跟随 junction / symlink)。 */
82
+ function isDir(full) {
83
+ try { return fs.statSync(full).isDirectory() } catch { return false }
84
+ }
85
+
86
+ /**
87
+ * 找项目根:与 DSH skill-filesystem 一致,向上找 `.git`;找不到就用 cwd。
88
+ * 这样在子目录里运行也能发现 <repo>/.dsh/skills、<repo>/.agents/skills 等项目级技能。
89
+ */
90
+ export function findProjectRoot(cwd = process.cwd()) {
91
+ const start = path.resolve(cwd || process.cwd())
92
+ let current = start
93
+ while (true) {
94
+ if (fs.existsSync(path.join(current, '.git'))) return current
95
+ const parent = path.dirname(current)
96
+ if (parent === current) return start
97
+ current = parent
98
+ }
99
+ }
100
+
101
+ /** 读一个 skills 根下的所有 SKILL.md(<root>/<skill>/SKILL.md 或 <root>/<skill>.md)。 */
102
+ function collectFromSkillRoot(root, source) {
103
+ const out = []
104
+ for (const ent of readDirs(root)) {
105
+ // 各家 agent 的内部内置技能目录(如 Codex 的 .system)不是用户技能,跳过。
106
+ if (ent.name === '.system' || ent.name === 'node_modules') continue
107
+ const full = path.join(root, ent.name)
108
+ if (isDir(full)) {
109
+ const file = path.join(full, 'SKILL.md')
110
+ try {
111
+ const text = fs.readFileSync(file, 'utf8')
112
+ const parsed = parseSkillFile(text, ent.name)
113
+ // 与 DSH skill-filesystem 的准入一致:必须有 frontmatter,且 name / description 非空。
114
+ if (!parsed.hasFrontmatter || !parsed.hasName || !parsed.hasDescription) continue
115
+ out.push({ file, source, parsed })
116
+ } catch { /* 不是技能目录 */ }
117
+ } else if (/\.md$/i.test(ent.name)) {
118
+ const file = full
119
+ try {
120
+ const text = fs.readFileSync(file, 'utf8')
121
+ const parsed = parseSkillFile(text, ent.name.replace(/\.md$/i, ''))
122
+ if (!parsed.hasFrontmatter || !parsed.hasName || !parsed.hasDescription) continue
123
+ out.push({ file, source, parsed })
124
+ } catch { /* 读取失败跳过 */ }
125
+ }
126
+ }
127
+ return out
128
+ }
129
+
130
+ /** 扫描 node_modules 下某个包目录的 skills / .agents/skills。 */
131
+ function collectFromPackage(pkgDir, source) {
132
+ const out = []
133
+ for (const sub of ['skills', path.join('.agents', 'skills')]) {
134
+ const root = path.join(pkgDir, sub)
135
+ if (isDir(root)) out.push(...collectFromSkillRoot(root, source))
136
+ }
137
+ return out
138
+ }
139
+
140
+ /** 扫描一个 node_modules 目录(支持 @scope/pkg 两层)。 */
141
+ function collectFromNodeModules(nmDir, source) {
142
+ const out = []
143
+ for (const ent of readDirs(nmDir)) {
144
+ const full = path.join(nmDir, ent.name)
145
+ if (!isDir(full)) continue
146
+ if (ent.name.startsWith('@')) {
147
+ for (const sub of readDirs(full)) {
148
+ const pkg = path.join(full, sub.name)
149
+ if (isDir(pkg)) out.push(...collectFromPackage(pkg, source))
150
+ }
151
+ } else if (ent.name !== '.bin' && ent.name !== '.pnpm') {
152
+ out.push(...collectFromPackage(full, source))
153
+ }
154
+ }
155
+ return out
156
+ }
157
+
158
+ /**
159
+ * 文件系统兜底发现(独立 Web 进程 / 没有 ctx.skills 的宿主)。
160
+ *
161
+ * 覆盖(按各家 agent 的公开约定):
162
+ * - 项目级:<repo>/.dsh/skills、<repo>/.agents/skills、<repo>/.cursor/skills、
163
+ * <repo>/.claude/skills、<repo>/.codex/skills、<repo>/skills
164
+ * - 自定义:MEMORY_SKILL_DIRS(path.delimiter 分隔)
165
+ * - 用户级:DSH_HOME/skills、DSH_AGENTS_HOME(默认 ~/.agents)/skills、
166
+ * ~/.cursor/skills、~/.claude/skills、~/.codex/skills
167
+ * - 不扫描 ~/.cursor/skills-cursor:那是 Cursor 自己的内部内置技能目录。
168
+ * - 插件级:DSH_HOME/profiles/<profile>/node_modules/<pkg> 的 skills / .agents/skills
169
+ * - bundled:DSH_BUNDLED_SKILL_DIR、packageRoot/skills
170
+ *
171
+ * @returns {Array<{name:string, description:string, content:string, source:string, provider:string, path:string}>}
172
+ */
173
+ export function collectSkillFiles({ env = process.env, cwd = process.cwd(), packageRoot = '' } = {}) {
174
+ const userHome = os.homedir()
175
+ const dshHome = String(env.DSH_HOME || '').trim() || path.join(userHome, '.dsh')
176
+ const agentsHome = String(env.DSH_AGENTS_HOME || env.AGENTS_HOME || '').trim() || path.join(userHome, '.agents')
177
+ const cursorHome = String(env.CURSOR_HOME || '').trim() || path.join(userHome, '.cursor')
178
+ const claudeHome = String(env.CLAUDE_CONFIG_DIR || '').trim() || path.join(userHome, '.claude')
179
+ const codexHome = String(env.CODEX_HOME || '').trim() || path.join(userHome, '.codex')
180
+ const geminiHome = String(env.GEMINI_HOME || '').trim() || path.join(userHome, '.gemini')
181
+ const xdgConfig = String(env.XDG_CONFIG_HOME || '').trim() || path.join(userHome, '.config')
182
+ const projectRoot = findProjectRoot(cwd)
183
+
184
+ const cands = []
185
+ const add = (root, source) => { if (root && isDir(root)) cands.push({ root, source }) }
186
+ // 项目级:DSH 官方(.dsh / .agents),Cursor 兼容(.cursor / .claude / .codex)
187
+ add(path.join(projectRoot, '.dsh', 'skills'), 'project-dsh')
188
+ add(path.join(projectRoot, '.agents', 'skills'), 'project-agents')
189
+ add(path.join(projectRoot, '.cursor', 'skills'), 'project-cursor')
190
+ add(path.join(projectRoot, '.claude', 'skills'), 'project-claude')
191
+ add(path.join(projectRoot, '.codex', 'skills'), 'project-codex')
192
+ add(path.join(cwd, 'skills'), 'project')
193
+ // 自定义目录(运维/企业批量注入;多个目录用系统路径分隔符)
194
+ for (const dir of String(env.MEMORY_SKILL_DIRS || '').split(path.delimiter)) {
195
+ const s = dir.trim()
196
+ if (s) add(path.resolve(s), 'custom')
197
+ }
198
+ // 用户级:DSH → 跨 agent 共享目录 → 各家兼容目录
199
+ add(path.join(dshHome, 'skills'), 'user-dsh')
200
+ add(path.join(agentsHome, 'skills'), 'user-agents')
201
+ add(path.join(cursorHome, 'skills'), 'user-cursor')
202
+ add(path.join(claudeHome, 'skills'), 'user-claude')
203
+ add(path.join(codexHome, 'skills'), 'user-codex')
204
+ add(path.join(geminiHome, 'skills'), 'user-gemini')
205
+ add(path.join(xdgConfig, 'opencode', 'skills'), 'user-opencode')
206
+ // bundled / 本插件自带
207
+ const bundled = String(env.DSH_BUNDLED_SKILL_DIR || '').trim()
208
+ if (bundled) add(path.resolve(bundled), 'bundled')
209
+ if (packageRoot) add(path.join(packageRoot, 'skills'), 'bundled')
210
+
211
+ const found = []
212
+ for (const c of cands) found.push(...collectFromSkillRoot(c.root, c.source))
213
+
214
+ // 插件 skills(profile 的 node_modules)
215
+ const profiles = path.join(dshHome, 'profiles')
216
+ for (const ent of readDirs(profiles)) {
217
+ const nm = path.join(profiles, ent.name, 'node_modules')
218
+ if (isDir(nm)) found.push(...collectFromNodeModules(nm, 'plugin'))
219
+ }
220
+
221
+ // 去重:同一真实文件只取一次;同名技能先到先得(项目 > 自定义 > 用户 > 插件 > bundled)
222
+ const seenFile = new Set()
223
+ const seenName = new Set()
224
+ const out = []
225
+ for (const item of found) {
226
+ const p = item.parsed
227
+ if (!p || !p.name) continue
228
+ let real = item.file
229
+ try { real = fs.realpathSync(item.file) } catch { /* 保留原路径 */ }
230
+ const key = real.toLowerCase()
231
+ if (seenFile.has(key) || seenName.has(p.name)) continue
232
+ seenFile.add(key)
233
+ seenName.add(p.name)
234
+ out.push({
235
+ name: p.name,
236
+ description: p.description,
237
+ whenToUse: p.whenToUse,
238
+ content: p.content,
239
+ source: item.source,
240
+ provider: item.source,
241
+ path: item.file,
242
+ invocation: { modelInvocable: true, userInvocable: true },
243
+ })
244
+ }
245
+ return out
246
+ }
247
+
248
+ /** 从宿主技能注册表(ctx.skills)取「实际可用技能」的完整定义。 */
249
+ export async function collectSkillsFromRegistry(skills, { cwd = '', signal } = {}) {
250
+ const opts = {}
251
+ if (cwd) opts.cwd = cwd
252
+ if (signal) opts.signal = signal
253
+ let summaries = []
254
+ if (typeof skills.snapshot === 'function') {
255
+ const snap = await skills.snapshot(opts)
256
+ summaries = Array.isArray(snap && snap.skills) ? snap.skills : []
257
+ } else if (typeof skills.list === 'function') {
258
+ summaries = await skills.list(opts)
259
+ }
260
+ const out = []
261
+ for (const summary of summaries) {
262
+ if (!summary || !summary.name) continue
263
+ let content = ''
264
+ let description = String(summary.description || '')
265
+ let whenToUse = String(summary.whenToUse || '')
266
+ let file = summary.path || ''
267
+ try {
268
+ if (typeof skills.get === 'function') {
269
+ const def = await skills.get(summary.name, opts)
270
+ if (def) {
271
+ content = String(def.content || '')
272
+ description = String(def.description || description)
273
+ whenToUse = String(def.whenToUse || whenToUse)
274
+ file = def.path || file
275
+ }
276
+ }
277
+ } catch { /* 单个技能读不到正文时仍保留摘要卡 */ }
278
+ out.push({
279
+ name: summary.name,
280
+ description,
281
+ whenToUse,
282
+ content,
283
+ source: String(summary.source || 'skill'),
284
+ provider: String(summary.provider || ''),
285
+ path: file,
286
+ invocation: summary.invocation || { modelInvocable: true, userInvocable: true },
287
+ })
288
+ }
289
+ return out
290
+ }
291
+
292
+ /** 组装一张技能卡的标题 / 标签 / 正文。 */
293
+ export function buildSkillCardBody(skill, { maxContentChars = DEFAULT_MAX_CONTENT } = {}) {
294
+ const name = String(skill.name || 'skill').trim()
295
+ const source = String(skill.source || 'unknown')
296
+ const invocation = skill.invocation || {}
297
+ const modelOk = invocation.modelInvocable !== false
298
+ const userOk = invocation.userInvocable !== false
299
+ const content = String(skill.content || '').trim()
300
+ const clipped = content.length > maxContentChars
301
+ ? content.slice(0, maxContentChars) + '\n\n…(技能正文过长,已截断 ' + (content.length - maxContentChars) + ' 字)'
302
+ : content
303
+ const info = [
304
+ '- 名称:' + name,
305
+ '- 来源:' + source,
306
+ skill.provider ? '- 提供方:' + skill.provider : '',
307
+ skill.path ? '- 技能文件:' + skill.path : '',
308
+ '- 模型可调用:' + (modelOk ? '是' : '否'),
309
+ '- 用户可调用:' + (userOk ? '是' : '否'),
310
+ skill.whenToUse ? '- 何时使用:' + skill.whenToUse : '',
311
+ ].filter(Boolean)
312
+ const lines = [
313
+ '# ' + name,
314
+ '',
315
+ skill.description || '(技能未提供描述)',
316
+ '',
317
+ '## 技能信息',
318
+ '',
319
+ ...info,
320
+ '',
321
+ '## 技能说明',
322
+ '',
323
+ clipped || '(技能文件未提供正文)',
324
+ ]
325
+ const tags = ['skill', source].filter(Boolean)
326
+ if (!modelOk) tags.push('user-only')
327
+ return { title: name, body: lines.join('\n'), tags }
328
+ }
329
+
330
+ /** 查询两张表里所有由本模块生成的技能卡(source 以 skill: 开头)。 */
331
+ function loadExistingSkillCards(root) {
332
+ const db = getDb(root)
333
+ const sql = `
334
+ SELECT path, status, source, title, body FROM quarantine WHERE source LIKE 'skill:%'
335
+ UNION ALL
336
+ SELECT path, status, source, title, body FROM cards WHERE source LIKE 'skill:%'
337
+ `
338
+ const rows = db.prepare(sql).all()
339
+ const map = new Map()
340
+ for (const row of rows) {
341
+ if (!row.source) continue
342
+ if (!map.has(row.source)) map.set(row.source, [])
343
+ map.get(row.source).push(row)
344
+ }
345
+ return map
346
+ }
347
+
348
+ /**
349
+ * 把「实际技能」同步成 kind=skill 的待审卡。
350
+ *
351
+ * @param {string} root vault 根目录
352
+ * @param {object} [opts]
353
+ * @param {object} [opts.skills] 宿主 ctx.skills(有则优先;没有则文件系统兜底)
354
+ * @param {string} [opts.cwd] 技能解析用的工作区
355
+ * @param {NodeJS.ProcessEnv} [opts.env]
356
+ * @param {string} [opts.packageRoot] 本插件包根(兜底扫描自带 skills)
357
+ * @param {boolean} [opts.includeFilesystem=true] 是否同时扫描磁盘技能(默认 true;
358
+ * false 仅用于「只信注册表」的测试/特殊部署)
359
+ * @param {boolean} [opts.force] 忽略「已驳回/已删除不复活」的保护(仅内部/测试用)
360
+ * @returns {Promise<{ok:boolean, source:'registry'|'filesystem'|'registry+filesystem', total:number, created:number, updated:number, pendingUpdates:number, unchanged:number, skipped:number, removed:number, errors:string[]}>}
361
+ */
362
+ export async function syncSkillCards(root, { skills, cwd = '', env = process.env, packageRoot = '', force = false, includeFilesystem = true } = {}) {
363
+ await ensureVault(root)
364
+ let registryList = []
365
+ if (skills && (typeof skills.list === 'function' || typeof skills.snapshot === 'function')) {
366
+ try { registryList = await collectSkillsFromRegistry(skills, { cwd }) } catch { registryList = [] }
367
+ }
368
+ // 关键:ctx.skills.list() 默认只读**全局层**。DSH 的 agent 作用域技能
369
+ // (项目 .dsh/skills、.agents/skills、用户技能目录)不一定在这个层里,所以不能
370
+ // 因为注册表非空就跳过磁盘扫描 —— 否则技能审核里只会出现少数 bundled 技能。
371
+ const fileList = includeFilesystem ? collectSkillFiles({ env, cwd: cwd || process.cwd(), packageRoot }) : []
372
+ const source = registryList.length && fileList.length
373
+ ? 'registry+filesystem'
374
+ : (registryList.length ? 'registry' : 'filesystem')
375
+
376
+ // 同名只保留一个:注册表优先(invocation 元数据更完整),磁盘扫描兜底/补齐。
377
+ // 先塞磁盘、再覆盖注册表,等价于「注册表胜出」。
378
+ const byName = new Map()
379
+ for (const s of fileList) if (s && s.name && !byName.has(s.name)) byName.set(s.name, s)
380
+ for (const s of registryList) if (s && s.name) byName.set(s.name, s)
381
+ const skillsList = [...byName.values()].sort((a, b) => String(a.name).localeCompare(String(b.name)))
382
+
383
+ const existing = loadExistingSkillCards(root)
384
+ const currentNames = new Set(skillsList.map((s) => s.name))
385
+ const stats = { ok: true, source, total: skillsList.length, created: 0, updated: 0, pendingUpdates: 0, unchanged: 0, skipped: 0, removed: 0, errors: [] }
386
+
387
+ // removed:库里曾经生成过、这次扫描不再存在(只统计,不删除)
388
+ for (const key of existing.keys()) {
389
+ const m = /^skill:([^#]+)$/.exec(key)
390
+ if (m && !currentNames.has(m[1])) stats.removed++
391
+ }
392
+
393
+ for (const skill of skillsList) {
394
+ try {
395
+ const built = buildSkillCardBody(skill)
396
+ const baseSource = 'skill:' + skill.name
397
+ const hash = skillContentHash(built.title, built.body)
398
+ const baseRows = existing.get(baseSource) || []
399
+ const pending = baseRows.find((r) => r.status === 'pending')
400
+ const approved = baseRows.find((r) => r.status === 'approved')
401
+ const rejected = baseRows.find((r) => r.status === 'rejected' || r.status === 'deleted')
402
+
403
+ if (pending) {
404
+ if (!sameText(pending.body, built.body) || pending.title !== built.title) {
405
+ await updateCardContent(root, pending.path, { title: built.title, tags: built.tags, body: built.body, reason: '根据实际技能生成(内容有更新)' })
406
+ stats.updated++
407
+ } else stats.unchanged++
408
+ continue
409
+ }
410
+
411
+ if (approved) {
412
+ if (sameText(approved.body, built.body)) { stats.unchanged++; continue }
413
+ const updateSource = baseSource + '#' + hash
414
+ const prev = existing.get(updateSource) || []
415
+ if (prev.some((r) => r.status === 'pending' || r.status === 'approved' || r.status === 'rejected' || r.status === 'deleted')) {
416
+ stats.skipped++
417
+ continue
418
+ }
419
+ const r = await writeCard(root, {
420
+ kind: SKILL_KIND,
421
+ title: skill.name + '(技能更新)',
422
+ tags: built.tags,
423
+ body: built.body,
424
+ source: updateSource,
425
+ submittedBy: 'skill-sync',
426
+ status: 'pending',
427
+ reason: '技能内容有更新,等待审核',
428
+ }, { dedup: false })
429
+ if (r.ok) stats.pendingUpdates++
430
+ else stats.skipped++
431
+ continue
432
+ }
433
+
434
+ if (rejected && !force) { stats.skipped++; continue }
435
+
436
+ const r = await writeCard(root, {
437
+ kind: SKILL_KIND,
438
+ title: built.title,
439
+ tags: built.tags,
440
+ body: built.body,
441
+ source: baseSource,
442
+ submittedBy: 'skill-sync',
443
+ status: 'pending',
444
+ reason: '根据实际技能生成',
445
+ }, { dedup: false })
446
+ if (r.ok) stats.created++
447
+ else stats.skipped++
448
+ } catch (error) {
449
+ if (stats.errors.length < 10) stats.errors.push(skill.name + ':' + String((error && error.message) || error))
450
+ }
451
+ }
452
+ return stats
453
+ }
454
+
455
+ /** 给宿主/独立端复用的默认扫描(无需 ctx.skills)。 */
456
+ export async function syncSkillCardsFromFiles(root, { env = process.env, cwd = process.cwd(), packageRoot = '' } = {}) {
457
+ return syncSkillCards(root, { env, cwd, packageRoot })
458
+ }