memory-eternal 0.31.0 → 0.31.1
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 +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/cursor-extension/package.json +1 -1
- package/dsh-tui-plugin/dsh-plugin.json +1 -1
- package/dsh-tui-plugin/package.json +1 -1
- package/lib/client.js +19 -19
- package/lib/skill-cards.js +172 -34
- package/orca-plugin/orca-plugin.json +1 -1
- package/package.json +1 -1
- package/web/app.js +25 -25
package/lib/skill-cards.js
CHANGED
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
// 技能正文可能包含任意指令,必须由人确认后才进主库,避免绕过审核直接进入召回。
|
|
8
8
|
//
|
|
9
9
|
// 幂等:按 source=`skill:<name>` 对齐已有卡。
|
|
10
|
-
// -
|
|
11
|
-
// -
|
|
10
|
+
// - 同名多版本同时安装 → 选 semver 最高的一份;
|
|
11
|
+
// - 待审卡版本/内容变了 → 原地刷新(仍是同一张待审卡,不刷屏);
|
|
12
|
+
// - 已批准卡版本/内容变了 → 生成 99-Revisions 待审修改,批准后覆盖原卡(审核前旧卡继续可用);
|
|
12
13
|
// - 驳回/删除过的卡 → 不复活(尊重人的决定)。
|
|
13
14
|
|
|
14
15
|
import fs from 'node:fs'
|
|
15
16
|
import os from 'node:os'
|
|
16
17
|
import path from 'node:path'
|
|
17
18
|
import { createHash } from 'node:crypto'
|
|
18
|
-
import { writeCard, updateCardContent, ensureVault } from './vault.js'
|
|
19
|
+
import { writeCard, updateCardContent, ensureVault, reviseCard } from './vault.js'
|
|
19
20
|
import { getDb } from './db.js'
|
|
20
21
|
|
|
21
22
|
/** 技能卡类型与目录(与 lib/vault.js 的 KIND_ROOTS 保持一致)。 */
|
|
@@ -33,6 +34,67 @@ export function skillContentHash(title, body) {
|
|
|
33
34
|
return createHash('sha1').update(String(title || '') + '\n' + String(body || '')).digest('hex').slice(0, 12)
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/** 宽松 semver 解析:接受 1、1.2、1.2.3 与 -pre / +build。 */
|
|
38
|
+
function parseSkillVersion(v) {
|
|
39
|
+
const m = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(v || '').trim())
|
|
40
|
+
if (!m) return null
|
|
41
|
+
return { nums: [Number(m[1]), Number(m[2] || 0), Number(m[3] || 0)], pre: m[4] || '' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 比较两个技能版本。返回 1/0/-1;解析不出 semver 时按字符串相等判定(不猜大小)。
|
|
46
|
+
* 空版本视为「未知」:选择多份安装时低于有版本的副本;更新判断时调用方会跳过比较。
|
|
47
|
+
*/
|
|
48
|
+
export function compareSkillVersions(a, b) {
|
|
49
|
+
const A = parseSkillVersion(a)
|
|
50
|
+
const B = parseSkillVersion(b)
|
|
51
|
+
if (!A && !B) return String(a || '') === String(b || '') ? 0 : 0
|
|
52
|
+
if (!A) return -1
|
|
53
|
+
if (!B) return 1
|
|
54
|
+
for (let i = 0; i < 3; i++) if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1
|
|
55
|
+
if (A.pre === B.pre) return 0
|
|
56
|
+
if (!A.pre) return 1
|
|
57
|
+
if (!B.pre) return -1
|
|
58
|
+
const as = A.pre.split('.')
|
|
59
|
+
const bs = B.pre.split('.')
|
|
60
|
+
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
|
61
|
+
const x = as[i]
|
|
62
|
+
const y = bs[i]
|
|
63
|
+
if (x === undefined) return -1
|
|
64
|
+
if (y === undefined) return 1
|
|
65
|
+
if (x === y) continue
|
|
66
|
+
const nx = /^\d+$/.test(x)
|
|
67
|
+
const ny = /^\d+$/.test(y)
|
|
68
|
+
if (nx && ny) return Number(x) < Number(y) ? -1 : 1
|
|
69
|
+
if (nx !== ny) return nx ? -1 : 1
|
|
70
|
+
return x < y ? -1 : 1
|
|
71
|
+
}
|
|
72
|
+
return 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 从技能卡正文里读出版本标记(旧卡可能没有)。 */
|
|
76
|
+
export function skillVersionFromBody(body) {
|
|
77
|
+
const m = /^-\s*(?:版本|Version)\s*[::]\s*(.+?)\s*$/mi.exec(String(body || ''))
|
|
78
|
+
return m ? m[1].trim() : ''
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 插件类技能:从最近的 package.json 读取版本(仅 node_modules 路径,避免把项目版本当成技能版本)。 */
|
|
82
|
+
function packageVersionForFile(file) {
|
|
83
|
+
if (!/[\\/]node_modules[\\/]/.test(String(file || ''))) return ''
|
|
84
|
+
let dir = path.dirname(file)
|
|
85
|
+
for (let i = 0; i < 6; i++) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = fs.readFileSync(path.join(dir, 'package.json'), 'utf8')
|
|
88
|
+
const version = String(JSON.parse(raw).version || '').trim()
|
|
89
|
+
if (version) return version
|
|
90
|
+
} catch { /* 继续向上 */ }
|
|
91
|
+
const parent = path.dirname(dir)
|
|
92
|
+
if (parent === dir) break
|
|
93
|
+
dir = parent
|
|
94
|
+
}
|
|
95
|
+
return ''
|
|
96
|
+
}
|
|
97
|
+
|
|
36
98
|
/** 从 SKILL.md 文本里取 frontmatter;支持 description 的单行 / 多行块写法。 */
|
|
37
99
|
export function parseSkillFile(text, fallbackName = 'skill') {
|
|
38
100
|
const raw = String(text || '')
|
|
@@ -63,6 +125,7 @@ export function parseSkillFile(text, fallbackName = 'skill') {
|
|
|
63
125
|
return {
|
|
64
126
|
name: name || fallbackName,
|
|
65
127
|
description,
|
|
128
|
+
version: String(meta.version || meta['skill-version'] || meta.skillVersion || '').trim(),
|
|
66
129
|
content: body.trim(),
|
|
67
130
|
whenToUse: String(meta.whenToUse || meta['when-to-use'] || '').trim(),
|
|
68
131
|
// 与 DSH skill-filesystem 的准入对齐:必须有 frontmatter,且 name / description 都不能空。
|
|
@@ -218,22 +281,24 @@ export function collectSkillFiles({ env = process.env, cwd = process.cwd(), pack
|
|
|
218
281
|
if (isDir(nm)) found.push(...collectFromNodeModules(nm, 'plugin'))
|
|
219
282
|
}
|
|
220
283
|
|
|
221
|
-
//
|
|
284
|
+
// 去重:同一真实文件只取一次;同名技能保留**版本最高**的一份。
|
|
285
|
+
// 版本相同/都未标注时保留先到先得(项目 > 自定义 > 用户 > 插件 > bundled)。
|
|
222
286
|
const seenFile = new Set()
|
|
223
|
-
const
|
|
224
|
-
const out = []
|
|
287
|
+
const groups = new Map()
|
|
225
288
|
for (const item of found) {
|
|
226
289
|
const p = item.parsed
|
|
227
290
|
if (!p || !p.name) continue
|
|
228
291
|
let real = item.file
|
|
229
292
|
try { real = fs.realpathSync(item.file) } catch { /* 保留原路径 */ }
|
|
230
293
|
const key = real.toLowerCase()
|
|
231
|
-
if (seenFile.has(key)
|
|
294
|
+
if (seenFile.has(key)) continue
|
|
232
295
|
seenFile.add(key)
|
|
233
|
-
|
|
234
|
-
|
|
296
|
+
const version = String(p.version || (item.source !== 'bundled' ? packageVersionForFile(item.file) : '') || '').trim()
|
|
297
|
+
if (!groups.has(p.name)) groups.set(p.name, [])
|
|
298
|
+
groups.get(p.name).push({
|
|
235
299
|
name: p.name,
|
|
236
300
|
description: p.description,
|
|
301
|
+
version,
|
|
237
302
|
whenToUse: p.whenToUse,
|
|
238
303
|
content: p.content,
|
|
239
304
|
source: item.source,
|
|
@@ -242,6 +307,14 @@ export function collectSkillFiles({ env = process.env, cwd = process.cwd(), pack
|
|
|
242
307
|
invocation: { modelInvocable: true, userInvocable: true },
|
|
243
308
|
})
|
|
244
309
|
}
|
|
310
|
+
const out = []
|
|
311
|
+
for (const list of groups.values()) {
|
|
312
|
+
let best = list[0]
|
|
313
|
+
for (const candidate of list) {
|
|
314
|
+
if (compareSkillVersions(candidate.version, best.version) > 0) best = candidate
|
|
315
|
+
}
|
|
316
|
+
out.push(best)
|
|
317
|
+
}
|
|
245
318
|
return out
|
|
246
319
|
}
|
|
247
320
|
|
|
@@ -263,6 +336,7 @@ export async function collectSkillsFromRegistry(skills, { cwd = '', signal } = {
|
|
|
263
336
|
let content = ''
|
|
264
337
|
let description = String(summary.description || '')
|
|
265
338
|
let whenToUse = String(summary.whenToUse || '')
|
|
339
|
+
let version = String(summary.version || (summary.metadata && summary.metadata.version) || '')
|
|
266
340
|
let file = summary.path || ''
|
|
267
341
|
try {
|
|
268
342
|
if (typeof skills.get === 'function') {
|
|
@@ -271,6 +345,7 @@ export async function collectSkillsFromRegistry(skills, { cwd = '', signal } = {
|
|
|
271
345
|
content = String(def.content || '')
|
|
272
346
|
description = String(def.description || description)
|
|
273
347
|
whenToUse = String(def.whenToUse || whenToUse)
|
|
348
|
+
version = String(def.version || (def.metadata && def.metadata.version) || version)
|
|
274
349
|
file = def.path || file
|
|
275
350
|
}
|
|
276
351
|
}
|
|
@@ -278,6 +353,7 @@ export async function collectSkillsFromRegistry(skills, { cwd = '', signal } = {
|
|
|
278
353
|
out.push({
|
|
279
354
|
name: summary.name,
|
|
280
355
|
description,
|
|
356
|
+
version: String(summary.source || '') === 'bundled' ? '' : version,
|
|
281
357
|
whenToUse,
|
|
282
358
|
content,
|
|
283
359
|
source: String(summary.source || 'skill'),
|
|
@@ -303,6 +379,7 @@ export function buildSkillCardBody(skill, { maxContentChars = DEFAULT_MAX_CONTEN
|
|
|
303
379
|
const info = [
|
|
304
380
|
'- 名称:' + name,
|
|
305
381
|
'- 来源:' + source,
|
|
382
|
+
skill.version ? '- 版本:' + String(skill.version).trim() : '',
|
|
306
383
|
skill.provider ? '- 提供方:' + skill.provider : '',
|
|
307
384
|
skill.path ? '- 技能文件:' + skill.path : '',
|
|
308
385
|
'- 模型可调用:' + (modelOk ? '是' : '否'),
|
|
@@ -345,6 +422,19 @@ function loadExistingSkillCards(root) {
|
|
|
345
422
|
return map
|
|
346
423
|
}
|
|
347
424
|
|
|
425
|
+
/** 查一张已批准技能卡当前待审的修改副本(审核通过后会覆盖原卡)。 */
|
|
426
|
+
function loadPendingRevision(root, targetPath) {
|
|
427
|
+
const db = getDb(root)
|
|
428
|
+
return db.prepare(`
|
|
429
|
+
SELECT q.path, q.title, q.body, q.source
|
|
430
|
+
FROM card_revisions r
|
|
431
|
+
JOIN quarantine q ON q.path = r.revision_path
|
|
432
|
+
WHERE r.target_path = ? AND q.status = 'pending'
|
|
433
|
+
ORDER BY r.updated_at DESC
|
|
434
|
+
LIMIT 1
|
|
435
|
+
`).get(targetPath) || null
|
|
436
|
+
}
|
|
437
|
+
|
|
348
438
|
/**
|
|
349
439
|
* 把「实际技能」同步成 kind=skill 的待审卡。
|
|
350
440
|
*
|
|
@@ -357,7 +447,7 @@ function loadExistingSkillCards(root) {
|
|
|
357
447
|
* @param {boolean} [opts.includeFilesystem=true] 是否同时扫描磁盘技能(默认 true;
|
|
358
448
|
* false 仅用于「只信注册表」的测试/特殊部署)
|
|
359
449
|
* @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[]}>}
|
|
450
|
+
* @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[], updates:Array}>}
|
|
361
451
|
*/
|
|
362
452
|
export async function syncSkillCards(root, { skills, cwd = '', env = process.env, packageRoot = '', force = false, includeFilesystem = true } = {}) {
|
|
363
453
|
await ensureVault(root)
|
|
@@ -373,16 +463,23 @@ export async function syncSkillCards(root, { skills, cwd = '', env = process.env
|
|
|
373
463
|
? 'registry+filesystem'
|
|
374
464
|
: (registryList.length ? 'registry' : 'filesystem')
|
|
375
465
|
|
|
376
|
-
//
|
|
377
|
-
//
|
|
466
|
+
// 同名只保留一个:**版本高的优先**;同版本注册表优先(invocation 元数据更完整)。
|
|
467
|
+
// 多个安装位置/多个版本同时存在时,不再按扫描顺序盲选。
|
|
378
468
|
const byName = new Map()
|
|
379
|
-
|
|
380
|
-
|
|
469
|
+
const consider = (s, preferRegistry) => {
|
|
470
|
+
if (!s || !s.name) return
|
|
471
|
+
const prev = byName.get(s.name)
|
|
472
|
+
if (!prev) { byName.set(s.name, s); return }
|
|
473
|
+
const cmp = compareSkillVersions(s.version, prev.version)
|
|
474
|
+
if (cmp > 0 || (cmp === 0 && preferRegistry)) byName.set(s.name, s)
|
|
475
|
+
}
|
|
476
|
+
for (const s of fileList) consider(s, false)
|
|
477
|
+
for (const s of registryList) consider(s, true)
|
|
381
478
|
const skillsList = [...byName.values()].sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
382
479
|
|
|
383
480
|
const existing = loadExistingSkillCards(root)
|
|
384
481
|
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: [] }
|
|
482
|
+
const stats = { ok: true, source, total: skillsList.length, created: 0, updated: 0, pendingUpdates: 0, unchanged: 0, skipped: 0, removed: 0, errors: [], updates: [] }
|
|
386
483
|
|
|
387
484
|
// removed:库里曾经生成过、这次扫描不再存在(只统计,不删除)
|
|
388
485
|
for (const key of existing.keys()) {
|
|
@@ -394,40 +491,81 @@ export async function syncSkillCards(root, { skills, cwd = '', env = process.env
|
|
|
394
491
|
try {
|
|
395
492
|
const built = buildSkillCardBody(skill)
|
|
396
493
|
const baseSource = 'skill:' + skill.name
|
|
397
|
-
const
|
|
494
|
+
const incomingVersion = String(skill.version || '').trim()
|
|
398
495
|
const baseRows = existing.get(baseSource) || []
|
|
399
496
|
const pending = baseRows.find((r) => r.status === 'pending')
|
|
400
497
|
const approved = baseRows.find((r) => r.status === 'approved')
|
|
401
498
|
const rejected = baseRows.find((r) => r.status === 'rejected' || r.status === 'deleted')
|
|
499
|
+
const sameCard = (row) => !!row && sameText(row.body, built.body) && row.title === built.title
|
|
500
|
+
|
|
501
|
+
const addUpdate = (fromVersion, toVersion, title, path, targetPath) => {
|
|
502
|
+
stats.updates.push({
|
|
503
|
+
name: skill.name,
|
|
504
|
+
fromVersion: String(fromVersion || '').trim(),
|
|
505
|
+
toVersion: String(toVersion || '').trim(),
|
|
506
|
+
title,
|
|
507
|
+
path,
|
|
508
|
+
targetPath: targetPath || '',
|
|
509
|
+
})
|
|
510
|
+
}
|
|
402
511
|
|
|
403
512
|
if (pending) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
513
|
+
const currentVersion = skillVersionFromBody(pending.body)
|
|
514
|
+
const cmp = (incomingVersion && currentVersion) ? compareSkillVersions(incomingVersion, currentVersion) : 0
|
|
515
|
+
if (cmp < 0) { stats.skipped++; continue }
|
|
516
|
+
if (sameCard(pending)) { stats.unchanged++; continue }
|
|
517
|
+
await updateCardContent(root, pending.path, {
|
|
518
|
+
title: built.title,
|
|
519
|
+
tags: built.tags,
|
|
520
|
+
body: built.body,
|
|
521
|
+
reason: '根据实际技能生成(版本/内容有更新)',
|
|
522
|
+
})
|
|
523
|
+
stats.updated++
|
|
524
|
+
addUpdate(currentVersion, incomingVersion, built.title, pending.path, pending.path)
|
|
408
525
|
continue
|
|
409
526
|
}
|
|
410
527
|
|
|
411
528
|
if (approved) {
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
|
|
529
|
+
const currentVersion = skillVersionFromBody(approved.body)
|
|
530
|
+
const cmp = (incomingVersion && currentVersion) ? compareSkillVersions(incomingVersion, currentVersion) : 0
|
|
531
|
+
if (sameCard(approved) && cmp <= 0) { stats.unchanged++; continue }
|
|
532
|
+
if (cmp < 0) { stats.skipped++; continue } // 不把已批准技能降级到旧版本
|
|
533
|
+
|
|
534
|
+
const revision = loadPendingRevision(root, approved.path)
|
|
535
|
+
if (revision) {
|
|
536
|
+
const revisionVersion = skillVersionFromBody(revision.body)
|
|
537
|
+
if (sameText(revision.body, built.body)) { stats.unchanged++; continue }
|
|
538
|
+
if (incomingVersion && revisionVersion && compareSkillVersions(incomingVersion, revisionVersion) < 0) {
|
|
539
|
+
stats.skipped++
|
|
540
|
+
continue
|
|
541
|
+
}
|
|
542
|
+
const title = built.title
|
|
543
|
+
const r = await reviseCard(root, {
|
|
544
|
+
target: approved.path,
|
|
545
|
+
title,
|
|
546
|
+
tags: built.tags,
|
|
547
|
+
body: built.body,
|
|
548
|
+
reason: '技能版本/内容有更新,等待审核',
|
|
549
|
+
source: 'skill-update:' + skill.name,
|
|
550
|
+
submittedBy: 'skill-sync',
|
|
551
|
+
})
|
|
552
|
+
stats.pendingUpdates++
|
|
553
|
+
addUpdate(revisionVersion || currentVersion, incomingVersion, title, r.path, approved.path)
|
|
417
554
|
continue
|
|
418
555
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
556
|
+
|
|
557
|
+
const title = built.title
|
|
558
|
+
const r = await reviseCard(root, {
|
|
559
|
+
target: approved.path,
|
|
560
|
+
title,
|
|
422
561
|
tags: built.tags,
|
|
423
562
|
body: built.body,
|
|
424
|
-
|
|
563
|
+
reason: '技能版本/内容有更新,等待审核',
|
|
564
|
+
source: 'skill-update:' + skill.name,
|
|
425
565
|
submittedBy: 'skill-sync',
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (r.ok) stats.pendingUpdates++
|
|
430
|
-
else stats.skipped++
|
|
566
|
+
})
|
|
567
|
+
stats.pendingUpdates++
|
|
568
|
+
addUpdate(currentVersion, incomingVersion, title, r.path, approved.path)
|
|
431
569
|
continue
|
|
432
570
|
}
|
|
433
571
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"id": "memory-eternal",
|
|
4
4
|
"publisher": "eternalnight996",
|
|
5
5
|
"name": "memory-eternal",
|
|
6
|
-
"version": "0.31.
|
|
6
|
+
"version": "0.31.1",
|
|
7
7
|
"description": "Memory Atlas for Orca: open the local memory vault, pending review and knowledge graph from a sandboxed Orca panel.",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "EternalNight996",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.1",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"icon": "assets/icon.png",
|
|
6
6
|
"license": "MIT",
|