memory-eternal 0.10.21 → 0.10.23

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/refine.js ADDED
@@ -0,0 +1,218 @@
1
+ // 记忆精炼 · 「矛盾扫描」(issue #36)
2
+ //
3
+ // 现有 /optimize 只按**相似度**聚类(>=0.55)并合并 —— 它解决「冗余」,但对「矛盾」是**危险**的:
4
+ // 两张语义相近、结论互斥的卡(两种不能同时成立的配置、已被推翻的旧结论)相似度同样很高,
5
+ // 会被当成重复、甚至被 mergeCards 合并掉(而 mergeCards 会**永久删除**源卡)。所以这里补另一半:
6
+ //
7
+ // 宽召回候选对 → LLM 只判「重复 / 补充 / 矛盾 / 无关」 → **人拍板**
8
+ //
9
+ // 两条硬约束:
10
+ // 1. 扫描**绝不写库**(纯预览);
11
+ // 2. apply 必须由调用方显式给 action,且**只做可恢复动作**(keepA/keepB 送回收站、keepBoth 不动数据);
12
+ // 决策记进 vault 下的 refine-decisions.json,避免同一对反复骚扰人。
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import { getDb } from './db.js'
16
+ import { deleteCard, mergeCards, textSimilarity } from './vault.js'
17
+ import { callLlmJson, routeCandidates, stripRuntimeNoise, looksTruncated } from './capture.js'
18
+
19
+ const DECISIONS_FILE = 'refine-decisions.json'
20
+ const RELATIONS = new Set(['duplicate', 'complement', 'conflict', 'unrelated'])
21
+ const pairKey = (a, b) => [String(a), String(b)].sort().join(' | ')
22
+
23
+ /** 读取历史决策(不存在则空表)。 */
24
+ export function readDecisions(root) {
25
+ try { return JSON.parse(fs.readFileSync(path.join(root, DECISIONS_FILE), 'utf8')) || {} } catch { return {} }
26
+ }
27
+
28
+ function writeDecisions(root, data) {
29
+ const file = path.join(root, DECISIONS_FILE)
30
+ const tmp = file + '.tmp'
31
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8')
32
+ fs.renameSync(tmp, file)
33
+ }
34
+
35
+ function rowsOf(root) {
36
+ const db = getDb(root)
37
+ return db.prepare("SELECT path, title, kind, body, updated_at FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
38
+ }
39
+
40
+ /**
41
+ * 宽召回:相似度 >= minSim 的卡片对(默认 0.30,含**近重复**那一段 —— 恰恰因为一键优化会按相似度
42
+ * 盲合并,语义相近但结论互斥的对(最危险的那种)必须也送进判别)。已决策过的对会被跳过。
43
+ * 用正文前缀算相似度(全量 O(n²) 太贵),并跳过已做过决策的对。
44
+ */
45
+ export async function refineCandidates(root, { minSim = 0.30, maxSim = 1.0, limit = 40, maxCards = 300 } = {}) {
46
+ const rows = rowsOf(root).slice(0, Math.max(2, Number(maxCards) || 300))
47
+ const decided = readDecisions(root)
48
+ const lite = rows.map((r) => ({ path: r.path, title: r.title, kind: r.kind, body: String(r.body || ''), head: String(r.body || '').slice(0, 600) }))
49
+ const out = []
50
+ for (let i = 0; i < lite.length; i++) {
51
+ for (let j = i + 1; j < lite.length; j++) {
52
+ const a = lite[i], b = lite[j]
53
+ if (decided[pairKey(a.path, b.path)]) continue
54
+ const sim = textSimilarity(a.head, b.head)
55
+ if (sim < minSim || sim > maxSim) continue
56
+ out.push({
57
+ a: { path: a.path, title: a.title, kind: a.kind, body: a.body.slice(0, 2000) },
58
+ b: { path: b.path, title: b.title, kind: b.kind, body: b.body.slice(0, 2000) },
59
+ sim: Math.round(sim * 1000) / 1000,
60
+ })
61
+ }
62
+ }
63
+ out.sort((x, y) => y.sim - x.sim)
64
+ return out.slice(0, Math.max(1, Number(limit) || 40))
65
+ }
66
+
67
+ /** 容错解析判别输出(模型偶尔裹代码块或有前后话)。 */
68
+ export function parseJudge(text) {
69
+ let s = String(text || '').trim()
70
+ s = s.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
71
+ const start = s.indexOf('{')
72
+ const end = s.lastIndexOf('}')
73
+ if (start >= 0 && end > start) s = s.slice(start, end + 1)
74
+ let obj = null
75
+ try { obj = JSON.parse(s) } catch { return null }
76
+ if (!obj || typeof obj !== 'object') return null
77
+ const relation = RELATIONS.has(String(obj.relation)) ? String(obj.relation) : 'unrelated'
78
+ const keep = ['A', 'B', 'both'].includes(String(obj.keep)) ? String(obj.keep) : 'both'
79
+ return {
80
+ relation,
81
+ keep,
82
+ conflict: String(obj.conflict || '').slice(0, 300),
83
+ reason: String(obj.reason || '').slice(0, 300),
84
+ }
85
+ }
86
+
87
+ /** 交给模型判别一对卡片的关系(只输出 JSON)。 */
88
+ export async function judgeRefinePair(llm, route, a, b) {
89
+ const system = [
90
+ '你是本地知识库的「关系判别器」。给你两张已审核的知识卡,你只做一件事:判断它们的关系。',
91
+ '只输出严格 JSON(不要 markdown 代码块、不要多余文字):',
92
+ '{"relation":"duplicate|complement|conflict|unrelated","conflict":"若矛盾,一句话说明冲突点;否则空串","keep":"A|B|both","reason":"一句话理由"}',
93
+ '判定口径:',
94
+ '- duplicate:说的基本是同一件事,可以合并;',
95
+ '- complement:不冲突,且各自有独立价值,应当都保留;',
96
+ '- conflict:结论 / 参数 / 做法**互斥**(例如两种不能同时成立的配置、旧结论已被新结论推翻)—— 这类必须人判断,不要建议自动合并;',
97
+ '- unrelated:只是字面有点像,实际无关。',
98
+ 'keep:若必须只留一张,留哪张(A 或 B);无法取舍或应当都留 → both。',
99
+ ].join('\n')
100
+ const pack = (tag, x) => '### 卡 ' + tag + ':' + x.title + '\n(路径 ' + x.path + ')\n' + String(x.body || '').slice(0, 1800)
101
+ const user = [pack('A', a), pack('B', b), '请输出 JSON。'].join('\n\n')
102
+ const res = await callLlmJson(llm, route, { system, user, maxTokens: 800, purpose: 'memory-refine-judge' })
103
+ if (res.failure) return { failure: res.failure }
104
+ const judge = parseJudge(res.text)
105
+ if (!judge) return { failure: { code: 'UNPARSEABLE_OUTPUT', message: '判别输出不是合法 JSON:' + String(res.text || '').slice(0, 120) } }
106
+ return { judge }
107
+ }
108
+
109
+ /**
110
+ * 扫描:宽召回 → 逐对判别。**只读**,返回发现(不动任何卡片)。
111
+ * @param {{llm:any, limit?:number, provider?:string, model?:string, minSim?:number, maxSim?:number}} opts
112
+ */
113
+ export async function refineScan(root, { llm, limit = 12, provider, model, minSim, maxSim } = {}) {
114
+ if (!llm) return { ok: false, error: '当前进程拿不到 llm 服务(请在 DSH 面板里跑,或由宿主触发)' }
115
+ const cands = await refineCandidates(root, { minSim, maxSim })
116
+ if (!cands.length) return { ok: true, findings: [], skipped: [], candidates: 0 }
117
+ let routes = []
118
+ try { routes = await routeCandidates(llm, { provider, model }) } catch { routes = [] }
119
+ if (!routes.length) return { ok: false, error: '取不到模型路由(llm.listProviders 为空)' }
120
+ const route = routes[0]
121
+ const findings = []
122
+ const skipped = []
123
+ for (const c of cands.slice(0, Math.max(1, Number(limit) || 12))) {
124
+ let r
125
+ try { r = await judgeRefinePair(llm, route, c.a, c.b) } catch (e) { r = { failure: { code: 'EXCEPTION', message: String((e && e.message) || e) } } }
126
+ if (r.failure) { skipped.push({ a: c.a.path, b: c.b.path, sim: c.sim, error: r.failure.code + ': ' + r.failure.message }); continue }
127
+ if (r.judge.relation === 'unrelated') { skipped.push({ a: c.a.path, b: c.b.path, sim: c.sim, error: 'unrelated' }); continue }
128
+ findings.push({ ...c, ...r.judge })
129
+ }
130
+ return { ok: true, candidates: cands.length, useModel: route.provider + '/' + route.model, findings, skipped }
131
+ }
132
+
133
+ /** 技能目录条目行的形态(与 capture.js 的噪声闸门同一口径)。 */
134
+ const CATALOG_LINE_RE = /^\s*[-*]\s*\x60[a-z0-9][a-z0-9-]{2,40}\x60:\s/
135
+
136
+ /**
137
+ * 找出「注入垃圾卡」:正文其实是被注入的运行时文本(技能目录、system-reminder、环境快照…),
138
+ * 剥掉噪声后几乎不剩人话。
139
+ *
140
+ * 为什么需要(实测):库里已经有一批「- \`code-review\`: Review the changes…」这种**和对话毫无关系**的卡
141
+ * —— DSH 的 available-skills 目录被当 user 消息注入,旧噪声闸门没挡住,于是被当成知识沉淀下来。
142
+ * 新版本已不再生成,但**存量还在**。这里只做**提议**(返回清单,不动数据),由人决定回收。
143
+ */
144
+ export async function findInjectedCards(root, { limit = 500 } = {}) {
145
+ const rows = rowsOf(root).slice(0, Math.max(1, Number(limit) || 500))
146
+ const out = []
147
+ for (const r of rows) {
148
+ const body = String(r.body || '')
149
+ if (!body.trim()) continue
150
+ const cleaned = stripRuntimeNoise(body)
151
+ const ratio = body.length ? cleaned.length / body.length : 0
152
+ const allLines = body.split('\n').filter((l) => l.trim())
153
+ const catalogLines = allLines.filter((l) => CATALOG_LINE_RE.test(l)).length
154
+ const reasons = []
155
+ if (/<available_skills>|<system-reminder>/i.test(body)) reasons.push('含运行时注入标签')
156
+ if (catalogLines >= 3 && catalogLines / Math.max(1, allLines.length) >= 0.3) reasons.push('技能目录条目 ' + catalogLines + ' 行')
157
+ if (ratio < 0.35 && cleaned.length < 200) reasons.push('剥离注入后只剩 ' + cleaned.length + ' 字')
158
+ if (looksTruncated(String(r.title || ''))) reasons.push('标题是碎片')
159
+ if (reasons.length) out.push({ path: r.path, title: r.title, kind: r.kind, reasons, ratio: Math.round(ratio * 100) / 100, cleanedLen: cleaned.length })
160
+ }
161
+ return out
162
+ }
163
+
164
+ /**
165
+ * 回收这些卡 —— **走回收站(可还原)**,不是永久删除;逐个报告失败项。
166
+ * @param {{paths?:string[]}} opts
167
+ */
168
+ export async function purgeInjected(root, { paths = [] } = {}) {
169
+ const list = (Array.isArray(paths) ? paths : []).map((x) => String(x || '').trim()).filter(Boolean)
170
+ if (!list.length) return { ok: false, error: '没有给出要回收的 path' }
171
+ const removed = []
172
+ const failed = []
173
+ for (const p of list) {
174
+ try {
175
+ const r = await deleteCard(root, p) // 默认非永久 → 进回收站
176
+ if (r && r.ok === false) failed.push({ path: p, error: r.error || '删除失败' })
177
+ else removed.push(p)
178
+ } catch (e) { failed.push({ path: p, error: String((e && e.message) || e) }) }
179
+ }
180
+ return { ok: failed.length === 0, removed, failed, recoverable: true }
181
+ }
182
+
183
+ /**
184
+ * 执行人的决定。**只做可恢复动作**:
185
+ * keepA / keepB → 另一张进**回收站**(deleteCard 默认非永久,可从回收中心还原)
186
+ * merge → 走现有 mergeCards(新卡 pending 进审核队列)
187
+ * keepBoth → 不动数据(常用于"前提不同、都留")
188
+ * 任何 action 都会把这一对记进 refine-decisions.json,避免下次扫描重复打扰。
189
+ */
190
+ export async function refineApply(root, { a, b, action, note = '', relation = '' } = {}) {
191
+ const pa = String(a || '').trim()
192
+ const pb = String(b || '').trim()
193
+ if (!pa || !pb) return { ok: false, error: '缺少 a / b' }
194
+ const act = String(action || '').trim()
195
+ if (!['keepA', 'keepB', 'merge', 'keepBoth'].includes(act)) return { ok: false, error: '未知动作:' + act + '(可选 keepA / keepB / merge / keepBoth)' }
196
+ let result = { ok: true, action: act }
197
+ if (act === 'keepA' || act === 'keepB') {
198
+ const drop = act === 'keepA' ? pb : pa
199
+ try {
200
+ const r = await deleteCard(root, drop) // 默认进回收站,可还原
201
+ result.removed = drop
202
+ result.recoverable = true
203
+ if (r && r.ok === false) { result.ok = false; result.error = r.error || '删除失败' }
204
+ } catch (e) { result.ok = false; result.error = String((e && e.message) || e) }
205
+ } else if (act === 'merge') {
206
+ try {
207
+ const r = await mergeCards(root, [pa, pb])
208
+ result.merged = r
209
+ if (!r || r.ok === false) { result.ok = false; result.error = (r && r.error) || '合并失败' }
210
+ } catch (e) { result.ok = false; result.error = String((e && e.message) || e) }
211
+ }
212
+ if (result.ok) {
213
+ const data = readDecisions(root)
214
+ data[pairKey(pa, pb)] = { action: act, relation: String(relation || ''), note: String(note || '').slice(0, 500), at: Date.now() }
215
+ try { writeDecisions(root, data) } catch (e) { result.recordError = String((e && e.message) || e) }
216
+ }
217
+ return result
218
+ }
package/lib/vault.js CHANGED
@@ -1058,7 +1058,15 @@ export async function optimizeApply(root, opts = {}) {
1058
1058
  const cleanupStale = Boolean(opts.cleanupStale)
1059
1059
  const dryRun = Boolean(opts.dryRun)
1060
1060
  const cands = await optimizeCandidates(root)
1061
- const merge = cands.merge
1061
+ // issue #36:**被判为矛盾的对绝不参与盲合并** —— 相似度高不等于可以合并,两张互斥的卡合并
1062
+ // 会把冲突悄悄"和解"掉(且 mergeCards 会永久删除源卡)。决策来自「矛盾扫描」的 refine-decisions.json。
1063
+ const { readDecisions } = await import('./refine.js')
1064
+ const decided = readDecisions(root)
1065
+ const merge = cands.merge.filter((p) => {
1066
+ const key = [String(p.a.path), String(p.b.path)].sort().join(' | ')
1067
+ const d = decided[key]
1068
+ return !(d && (d.relation === 'conflict' || d.action === 'keepBoth'))
1069
+ })
1062
1070
  const stale = cands.stale
1063
1071
  const merged = []
1064
1072
  const staleDeleted = []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.21",
3
+ "version": "0.10.23",
4
4
  "description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,7 +64,7 @@
64
64
  "prepublishOnly": "node build.mjs",
65
65
  "release": "node scripts/release.mjs",
66
66
  "release:dry": "node scripts/release.mjs --dry-run",
67
- "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs"
67
+ "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs && node tests/release-verify.test.mjs && node tests/refine.test.mjs"
68
68
  },
69
69
  "dependencies": {
70
70
  "@deepseek-ai/schemastery": "^3.18.1"
@@ -48,6 +48,29 @@ const NPM = (() => {
48
48
  return hit ? { cmd: process.execPath, args: [hit] } : { cmd: 'npm.cmd', args: [] }
49
49
  })()
50
50
 
51
+ /** 未完成的发布动作(推送失败 / Release 失败):最后汇总,并让退出码非零 —— 绝不淹没在"完成"里。 */
52
+ const problems = []
53
+
54
+ /**
55
+ * npm 上真的存在这个版本吗?**只信 version 端点**。
56
+ * 教训(0.10.21 实测):本地 packument 缓存会说"没有"(`npm view` 404),边缘缓存又会说"有"
57
+ * (`GET /memory-eternal` 的 versions 里查不到)—— 只有 `/memory-eternal/<版本>` 与 tarball URL 靠谱。
58
+ */
59
+ const npmHasVersion = async (v) => {
60
+ try {
61
+ const r = await fetch(`https://registry.npmjs.org/memory-eternal/${v}`, { cache: 'no-store' })
62
+ return r.status === 200
63
+ } catch { return false }
64
+ }
65
+ // 实测:这个 registry 的 version 端点偶尔要 2 分钟才可见(0.10.22 就因为 60s 超时中断过一次发布)→ 给足 3 分钟
66
+ const waitNpmVersion = async (v, { tries = 36, delayMs = 5000 } = {}) => {
67
+ for (let i = 0; i < tries; i++) {
68
+ if (await npmHasVersion(v)) return true
69
+ await new Promise((r) => setTimeout(r, delayMs))
70
+ }
71
+ return false
72
+ }
73
+
51
74
  const run = (cmd, argv, { cwd = ROOT, allowFail = false } = {}) => {
52
75
  // Windows 上 npm 只是 .cmd 垫片:execFileSync('npm', …) 直接 ENOENT(Node ≥18 起 spawn
53
76
  // .cmd 还要 shell:true,而 shell 拼参数对含空格/中文的 tag 与说明不安全)。所以优先用
@@ -130,15 +153,23 @@ step(3, 'npm publish')
130
153
  if (SKIP_NPM) {
131
154
  say('(已跳过)')
132
155
  } else {
133
- const already = run('npm', ['view', `memory-eternal@${pkg.version}`, 'version'], { allowFail: true })
134
- if (already.ok && already.out.includes(pkg.version)) {
135
- say(`npm 上已存在 ${pkg.version},跳过发布`)
136
- } else if (DRY) {
156
+ if (DRY) {
137
157
  say(`[dry-run] 将执行:npm publish --access public(${pkg.version})`)
138
158
  } else {
139
- const r = run('npm', ['publish', '--access public'], { allowFail: true })
140
- if (!r.ok) throw new Error(`npm publish 失败(2FA / 网络 / 版本冲突?):\n${r.out}`)
141
- say(`npm publish 完成:memory-eternal@${pkg.version}`)
159
+ const published = await npmHasVersion(pkg.version)
160
+ if (published) {
161
+ say(`npm 上已存在 ${pkg.version},跳过发布`)
162
+ } else {
163
+ const r = run('npm', ['publish', '--access public'], { allowFail: true })
164
+ if (!r.ok) throw new Error(`npm publish 失败(2FA / 网络 / 版本冲突?):\n${r.out}`)
165
+ say(`npm publish 已执行:memory-eternal@${pkg.version}(等待 registry 可见…)`)
166
+ }
167
+ // **不信 stdout,只信 registry**:publish 返回 0 不代表包真的上去了(0.10.21 实测:
168
+ // 本地 packument/npm view 缓存会假装没有,边缘缓存又会假装有)—— 用 version 端点实证。
169
+ if (!(await waitNpmVersion(pkg.version))) {
170
+ throw new Error(`npm 上查不到 ${pkg.version}(version 端点超时):发布可能未生效,请手动 \`npm publish --access public\` 后复核 https://registry.npmjs.org/memory-eternal/${pkg.version}`)
171
+ }
172
+ say(`npm 校验通过:https://registry.npmjs.org/memory-eternal/${pkg.version} → 200`)
142
173
  }
143
174
  }
144
175
 
@@ -160,6 +191,29 @@ for (const remote of remotes) {
160
191
  const a = run('git', ['push', remote, 'main'], { allowFail: true })
161
192
  const b = run('git', ['push', remote, tag], { allowFail: true })
162
193
  say(`${remote}: main ${a.ok ? '✓' : '✗ ' + a.out.slice(0, 200)} | tag ${b.ok ? '✓' : '✗ ' + b.out.slice(0, 200)}`)
194
+ // 推失败绝不能被"完成"淹没(0.10.18 实测:origin main 没上去、只有 tag 上去了,
195
+ // 而脚本照样打印完成 —— 用户按 GitHub main 安装就会拿到旧版)。记进问题清单。
196
+ if (!a.ok) problems.push(`${remote} main 未推送成功(${a.out.slice(0, 120)})→ 重试:git push ${remote} main`)
197
+ if (!b.ok) problems.push(`${remote} tag ${tag} 未推送成功(${b.out.slice(0, 120)})→ 重试:git push ${remote} ${tag}`)
198
+ }
199
+
200
+ // 推完再**实证**每个远端的 tag 指向:Gitee / GitHub 的 Release API 会在 main 还没推上去时
201
+ // 把 tag 建在"当时的 main"上(实测踩过三次:gitee v0.10.19/20/21、origin v0.10.22 —— 都是
202
+ // "Release 创建成功"但其实指向旧提交)。这里逐个核对 peeled 提交,不一致就强制修正并如实报告。
203
+ const headSha = run('git', ['rev-parse', 'HEAD']).out
204
+ for (const remote of remotes) {
205
+ if (DRY) continue
206
+ const ls = run('git', ['ls-remote', remote, `refs/tags/${tag}*`], { allowFail: true })
207
+ if (!ls.ok) { problems.push(`${remote} tag ${tag} 无法核对(${ls.out.slice(0, 120)})`); continue }
208
+ const peeled = (ls.out.split(/\r?\n/).find((l) => l.includes('^{}')) || '').split(/\s+/)[0] || ''
209
+ if (!peeled) { problems.push(`${remote} 上找不到 tag ${tag} → 重试:git push ${remote} ${tag}`); continue }
210
+ if (peeled !== headSha) {
211
+ const fix = run('git', ['push', '--force', remote, tag], { allowFail: true })
212
+ say(`${remote}: tag ${tag} 原指向 ${peeled.slice(0, 7)}(≠ HEAD ${headSha.slice(0, 7)})→ ${fix.ok ? '已强制修正' : '修正失败:' + fix.out.slice(0, 120)}`)
213
+ if (!fix.ok) problems.push(`${remote} tag ${tag} 指向错误且修正失败 → 重试:git push --force ${remote} ${tag}`)
214
+ } else {
215
+ say(`${remote}: tag ${tag} → ${headSha.slice(0, 7)} ✓`)
216
+ }
163
217
  }
164
218
 
165
219
  // ---- 5. GitHub Release ---------------------------------------------------
@@ -174,6 +228,7 @@ else {
174
228
  } else {
175
229
  const r = run('gh', ['release', 'create', tag, '--repo', 'EternalNight996/memory-eternal', '--title', title, '--notes-file', notesPath], { allowFail: true })
176
230
  say(r.ok ? `GitHub Release 创建成功:${r.out}` : `GitHub Release 失败:${r.out.slice(0, 300)}`)
231
+ if (!r.ok) problems.push('GitHub Release 创建失败 → 重试:gh release create ' + tag)
177
232
  }
178
233
  }
179
234
 
@@ -207,12 +262,18 @@ if (!giteeToken) {
207
262
  })
208
263
  const text = await res.text()
209
264
  if (res.ok) say(`Gitee Release 创建成功:${tag}`)
210
- else say(`⚠ Gitee Release 失败(HTTP ${res.status}):${text.slice(0, 300)}`)
265
+ else { say(`⚠ Gitee Release 失败(HTTP ${res.status}):${text.slice(0, 300)}`); problems.push('Gitee Release 创建失败(HTTP ' + res.status + ')') }
211
266
  }
212
267
  }
213
268
 
214
269
  // ---- 7. 汇总 ------------------------------------------------------------
215
270
  step(7, `完成${DRY ? '(dry-run,未写任何远端)' : ''}`)
216
271
  say(`版本 ${pkg.version} | tag ${tag}`)
272
+ if (problems.length) {
273
+ say('')
274
+ say('⚠ 有未完成的发布动作(**不要当作已发布完成**):')
275
+ for (const x of problems) say(' · ' + x)
276
+ process.exitCode = 1 // 让脚本/CI 能发现"三端没齐"
277
+ }
217
278
  say('提醒:桌面版用户需 `cd ~/.dsh/profiles/desktop && pnpm update memory-eternal` 后重启 DSH;')
218
279
  say(' dsh web 用户需 `cd ~/.dsh/profiles/web && pnpm add memory-eternal@' + pkg.version + '`。')