memory-eternal 0.30.5 → 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.
@@ -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
- // 去重:同一真实文件只取一次;同名技能先到先得(项目 > 自定义 > 用户 > 插件 > bundled)
284
+ // 去重:同一真实文件只取一次;同名技能保留**版本最高**的一份。
285
+ // 版本相同/都未标注时保留先到先得(项目 > 自定义 > 用户 > 插件 > bundled)。
222
286
  const seenFile = new Set()
223
- const seenName = new Set()
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) || seenName.has(p.name)) continue
294
+ if (seenFile.has(key)) continue
232
295
  seenFile.add(key)
233
- seenName.add(p.name)
234
- out.push({
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
- // 同名只保留一个:注册表优先(invocation 元数据更完整),磁盘扫描兜底/补齐。
377
- // 先塞磁盘、再覆盖注册表,等价于「注册表胜出」。
466
+ // 同名只保留一个:**版本高的优先**;同版本注册表优先(invocation 元数据更完整)。
467
+ // 多个安装位置/多个版本同时存在时,不再按扫描顺序盲选。
378
468
  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)
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 hash = skillContentHash(built.title, built.body)
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
- 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++
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
- 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++
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
- const r = await writeCard(root, {
420
- kind: SKILL_KIND,
421
- title: skill.name + '(技能更新)',
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
- source: updateSource,
563
+ reason: '技能版本/内容有更新,等待审核',
564
+ source: 'skill-update:' + skill.name,
425
565
  submittedBy: 'skill-sync',
426
- status: 'pending',
427
- reason: '技能内容有更新,等待审核',
428
- }, { dedup: false })
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.30.5",
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.30.5",
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",