memory-eternal 0.10.20 → 0.10.22
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.en.md +5 -3
- package/README.md +5 -3
- package/bin/dsh-memory.mjs +61 -0
- package/index.js +2 -0
- package/lib/api.js +59 -4
- package/lib/capture.js +59 -1
- package/lib/client.js +14 -14
- package/lib/db.js +75 -0
- package/lib/refine.js +168 -0
- package/lib/vault.js +9 -1
- package/package.json +2 -2
- package/scripts/release.mjs +49 -8
- package/web/app.js +17 -17
package/lib/db.js
CHANGED
|
@@ -10,6 +10,75 @@ import { parseCard, KIND_ROOTS } from './vault.js'
|
|
|
10
10
|
|
|
11
11
|
const DB_FILE = 'memory-eternal.db'
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 每个库的「打开时健康检查」结果(issue #33)。
|
|
15
|
+
*
|
|
16
|
+
* 为什么需要:老实现打开后只执行 `PRAGMA journal_mode=WAL` 就往下走 —— 而这条 PRAGMA
|
|
17
|
+
* **失败时不抛错**(只是把实际模式当一行返回),文件/目录不可写时也不会当场抱怨;于是插件
|
|
18
|
+
* 会以「写不进去」的状态一路静默运行:探针仍报健康、看门狗仍在保活、面板只是统计空掉,
|
|
19
|
+
* 用户看到的是一句没有上下文的 `attempt to write a readonly database`(有时还伴随看门狗误判)。
|
|
20
|
+
* 现在打开时**读回真实 journal 模式 + 真写一次探针**,把结论记在这里,由 /overview 与
|
|
21
|
+
* `dsh-memory doctor` 如实报出来。
|
|
22
|
+
*/
|
|
23
|
+
const healthByRoot = new Map()
|
|
24
|
+
|
|
25
|
+
const CORRUPT_RE = /malformed|not a database|corrupt|disk image/i
|
|
26
|
+
|
|
27
|
+
/** 真的写一次主库(建探针表→删掉):验证「这个库现在能不能写」。temp 库不算,必须是主库。 */
|
|
28
|
+
function probeWrite(db) {
|
|
29
|
+
try {
|
|
30
|
+
db.exec('CREATE TABLE IF NOT EXISTS _me_write_probe(x INTEGER)')
|
|
31
|
+
db.exec('DROP TABLE IF EXISTS _me_write_probe')
|
|
32
|
+
return { writable: true, error: '' }
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { writable: false, error: String((e && e.message) || e) }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 打开后的健康检查:真实的 journal 模式 + 写探针(+ 损坏判定)。
|
|
40
|
+
* @returns {{journalMode:string, writable:boolean, error:string, corrupt:boolean, checkedAt:number}}
|
|
41
|
+
*/
|
|
42
|
+
export function checkDbHealth(db) {
|
|
43
|
+
const out = { journalMode: '', writable: false, error: '', corrupt: false, checkedAt: Date.now() }
|
|
44
|
+
try {
|
|
45
|
+
out.journalMode = String(db.prepare('PRAGMA journal_mode').get().journal_mode || '')
|
|
46
|
+
} catch (e) {
|
|
47
|
+
out.error = String((e && e.message) || e)
|
|
48
|
+
}
|
|
49
|
+
const w = probeWrite(db)
|
|
50
|
+
out.writable = w.writable
|
|
51
|
+
if (!w.writable) out.error = out.error || w.error
|
|
52
|
+
out.corrupt = CORRUPT_RE.test(out.error || '')
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 某个库最近一次打开时的健康结论(没打开过返回 null)。 */
|
|
57
|
+
export function dbStatus(root) {
|
|
58
|
+
return healthByRoot.get(path.resolve(root)) || null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 损坏 / 不可写时给人看的下一步(引导,不自动动数据)。 */
|
|
62
|
+
export function rebuildGuidance(root, status) {
|
|
63
|
+
const dir = path.resolve(root)
|
|
64
|
+
const lines = []
|
|
65
|
+
if (status && status.corrupt) {
|
|
66
|
+
lines.push('数据库文件疑似损坏:' + status.error)
|
|
67
|
+
lines.push('修复方式(不会自动删你的数据):')
|
|
68
|
+
lines.push('1) 先停下会写它的进程(退出 DSH / `dsh-memory stop --port 7999`),免得边恢复边写;')
|
|
69
|
+
lines.push('2) 备份现场:把 ' + path.join(dir, DB_FILE) + '(连同 -wal / -shm)复制到别处留证;')
|
|
70
|
+
lines.push('3) 查看可用备份:`dsh-memory doctor --vault "' + dir + '"`;')
|
|
71
|
+
lines.push('4) 用备份替换(备份文件在 ' + path.join(dir, 'backups') + '):把选中的 .db 复制成 ' + DB_FILE + ',并删掉同名 -wal / -shm;')
|
|
72
|
+
lines.push('5) 重开 DSH,然后用 `dsh-memory doctor` 复核 writable=true。')
|
|
73
|
+
} else if (status && !status.writable) {
|
|
74
|
+
lines.push('数据库当前**不可写**:' + status.error)
|
|
75
|
+
lines.push('配置仍可保存(配置不经过数据库),但卡片读写/审核/图谱会失败。')
|
|
76
|
+
lines.push('排查方向:目录与文件的 ACL(' + dir + ')、盘符是否只读挂载、是否有别的进程以独占方式打开它。')
|
|
77
|
+
lines.push('确认方式:`dsh-memory doctor --vault "' + dir + '"`。')
|
|
78
|
+
}
|
|
79
|
+
return lines
|
|
80
|
+
}
|
|
81
|
+
|
|
13
82
|
/** 获取或创建 SQLite 数据库连接(单例 per root)。 */
|
|
14
83
|
const instances = new Map()
|
|
15
84
|
export function getDb(root) {
|
|
@@ -22,6 +91,12 @@ export function getDb(root) {
|
|
|
22
91
|
db.exec('PRAGMA journal_mode=WAL')
|
|
23
92
|
db.exec('PRAGMA foreign_keys=ON')
|
|
24
93
|
initTables(db)
|
|
94
|
+
// 打开即体检:WAL 到底生效没有、这个库现在能不能写(#33)。结论供 /overview 与 doctor 使用。
|
|
95
|
+
const health = checkDbHealth(db)
|
|
96
|
+
healthByRoot.set(resolved, health)
|
|
97
|
+
if (!health.writable || (health.journalMode && health.journalMode !== 'wal')) {
|
|
98
|
+
for (const line of rebuildGuidance(resolved, health)) console.error('[memory-eternal] ' + line)
|
|
99
|
+
}
|
|
25
100
|
instances.set(resolved, db)
|
|
26
101
|
return db
|
|
27
102
|
}
|
package/lib/refine.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
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 } 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
|
+
/**
|
|
134
|
+
* 执行人的决定。**只做可恢复动作**:
|
|
135
|
+
* keepA / keepB → 另一张进**回收站**(deleteCard 默认非永久,可从回收中心还原)
|
|
136
|
+
* merge → 走现有 mergeCards(新卡 pending 进审核队列)
|
|
137
|
+
* keepBoth → 不动数据(常用于"前提不同、都留")
|
|
138
|
+
* 任何 action 都会把这一对记进 refine-decisions.json,避免下次扫描重复打扰。
|
|
139
|
+
*/
|
|
140
|
+
export async function refineApply(root, { a, b, action, note = '', relation = '' } = {}) {
|
|
141
|
+
const pa = String(a || '').trim()
|
|
142
|
+
const pb = String(b || '').trim()
|
|
143
|
+
if (!pa || !pb) return { ok: false, error: '缺少 a / b' }
|
|
144
|
+
const act = String(action || '').trim()
|
|
145
|
+
if (!['keepA', 'keepB', 'merge', 'keepBoth'].includes(act)) return { ok: false, error: '未知动作:' + act + '(可选 keepA / keepB / merge / keepBoth)' }
|
|
146
|
+
let result = { ok: true, action: act }
|
|
147
|
+
if (act === 'keepA' || act === 'keepB') {
|
|
148
|
+
const drop = act === 'keepA' ? pb : pa
|
|
149
|
+
try {
|
|
150
|
+
const r = await deleteCard(root, drop) // 默认进回收站,可还原
|
|
151
|
+
result.removed = drop
|
|
152
|
+
result.recoverable = true
|
|
153
|
+
if (r && r.ok === false) { result.ok = false; result.error = r.error || '删除失败' }
|
|
154
|
+
} catch (e) { result.ok = false; result.error = String((e && e.message) || e) }
|
|
155
|
+
} else if (act === 'merge') {
|
|
156
|
+
try {
|
|
157
|
+
const r = await mergeCards(root, [pa, pb])
|
|
158
|
+
result.merged = r
|
|
159
|
+
if (!r || r.ok === false) { result.ok = false; result.error = (r && r.error) || '合并失败' }
|
|
160
|
+
} catch (e) { result.ok = false; result.error = String((e && e.message) || e) }
|
|
161
|
+
}
|
|
162
|
+
if (result.ok) {
|
|
163
|
+
const data = readDecisions(root)
|
|
164
|
+
data[pairKey(pa, pb)] = { action: act, relation: String(relation || ''), note: String(note || '').slice(0, 500), at: Date.now() }
|
|
165
|
+
try { writeDecisions(root, data) } catch (e) { result.recordError = String((e && e.message) || e) }
|
|
166
|
+
}
|
|
167
|
+
return result
|
|
168
|
+
}
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.10.22",
|
|
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"
|
|
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"
|
package/scripts/release.mjs
CHANGED
|
@@ -48,6 +48,28 @@ 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
|
+
const waitNpmVersion = async (v, { tries = 12, delayMs = 5000 } = {}) => {
|
|
66
|
+
for (let i = 0; i < tries; i++) {
|
|
67
|
+
if (await npmHasVersion(v)) return true
|
|
68
|
+
await new Promise((r) => setTimeout(r, delayMs))
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
const run = (cmd, argv, { cwd = ROOT, allowFail = false } = {}) => {
|
|
52
74
|
// Windows 上 npm 只是 .cmd 垫片:execFileSync('npm', …) 直接 ENOENT(Node ≥18 起 spawn
|
|
53
75
|
// .cmd 还要 shell:true,而 shell 拼参数对含空格/中文的 tag 与说明不安全)。所以优先用
|
|
@@ -130,15 +152,23 @@ step(3, 'npm publish')
|
|
|
130
152
|
if (SKIP_NPM) {
|
|
131
153
|
say('(已跳过)')
|
|
132
154
|
} else {
|
|
133
|
-
|
|
134
|
-
if (already.ok && already.out.includes(pkg.version)) {
|
|
135
|
-
say(`npm 上已存在 ${pkg.version},跳过发布`)
|
|
136
|
-
} else if (DRY) {
|
|
155
|
+
if (DRY) {
|
|
137
156
|
say(`[dry-run] 将执行:npm publish --access public(${pkg.version})`)
|
|
138
157
|
} else {
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
|
|
158
|
+
const published = await npmHasVersion(pkg.version)
|
|
159
|
+
if (published) {
|
|
160
|
+
say(`npm 上已存在 ${pkg.version},跳过发布`)
|
|
161
|
+
} else {
|
|
162
|
+
const r = run('npm', ['publish', '--access public'], { allowFail: true })
|
|
163
|
+
if (!r.ok) throw new Error(`npm publish 失败(2FA / 网络 / 版本冲突?):\n${r.out}`)
|
|
164
|
+
say(`npm publish 已执行:memory-eternal@${pkg.version}(等待 registry 可见…)`)
|
|
165
|
+
}
|
|
166
|
+
// **不信 stdout,只信 registry**:publish 返回 0 不代表包真的上去了(0.10.21 实测:
|
|
167
|
+
// 本地 packument/npm view 缓存会假装没有,边缘缓存又会假装有)—— 用 version 端点实证。
|
|
168
|
+
if (!(await waitNpmVersion(pkg.version))) {
|
|
169
|
+
throw new Error(`npm 上查不到 ${pkg.version}(version 端点超时):发布可能未生效,请手动 \`npm publish --access public\` 后复核 https://registry.npmjs.org/memory-eternal/${pkg.version}`)
|
|
170
|
+
}
|
|
171
|
+
say(`npm 校验通过:https://registry.npmjs.org/memory-eternal/${pkg.version} → 200`)
|
|
142
172
|
}
|
|
143
173
|
}
|
|
144
174
|
|
|
@@ -160,6 +190,10 @@ for (const remote of remotes) {
|
|
|
160
190
|
const a = run('git', ['push', remote, 'main'], { allowFail: true })
|
|
161
191
|
const b = run('git', ['push', remote, tag], { allowFail: true })
|
|
162
192
|
say(`${remote}: main ${a.ok ? '✓' : '✗ ' + a.out.slice(0, 200)} | tag ${b.ok ? '✓' : '✗ ' + b.out.slice(0, 200)}`)
|
|
193
|
+
// 推失败绝不能被"完成"淹没(0.10.18 实测:origin main 没上去、只有 tag 上去了,
|
|
194
|
+
// 而脚本照样打印完成 —— 用户按 GitHub main 安装就会拿到旧版)。记进问题清单。
|
|
195
|
+
if (!a.ok) problems.push(`${remote} main 未推送成功(${a.out.slice(0, 120)})→ 重试:git push ${remote} main`)
|
|
196
|
+
if (!b.ok) problems.push(`${remote} tag ${tag} 未推送成功(${b.out.slice(0, 120)})→ 重试:git push ${remote} ${tag}`)
|
|
163
197
|
}
|
|
164
198
|
|
|
165
199
|
// ---- 5. GitHub Release ---------------------------------------------------
|
|
@@ -174,6 +208,7 @@ else {
|
|
|
174
208
|
} else {
|
|
175
209
|
const r = run('gh', ['release', 'create', tag, '--repo', 'EternalNight996/memory-eternal', '--title', title, '--notes-file', notesPath], { allowFail: true })
|
|
176
210
|
say(r.ok ? `GitHub Release 创建成功:${r.out}` : `GitHub Release 失败:${r.out.slice(0, 300)}`)
|
|
211
|
+
if (!r.ok) problems.push('GitHub Release 创建失败 → 重试:gh release create ' + tag)
|
|
177
212
|
}
|
|
178
213
|
}
|
|
179
214
|
|
|
@@ -207,12 +242,18 @@ if (!giteeToken) {
|
|
|
207
242
|
})
|
|
208
243
|
const text = await res.text()
|
|
209
244
|
if (res.ok) say(`Gitee Release 创建成功:${tag}`)
|
|
210
|
-
else say(`⚠ Gitee Release 失败(HTTP ${res.status}):${text.slice(0, 300)}`)
|
|
245
|
+
else { say(`⚠ Gitee Release 失败(HTTP ${res.status}):${text.slice(0, 300)}`); problems.push('Gitee Release 创建失败(HTTP ' + res.status + ')') }
|
|
211
246
|
}
|
|
212
247
|
}
|
|
213
248
|
|
|
214
249
|
// ---- 7. 汇总 ------------------------------------------------------------
|
|
215
250
|
step(7, `完成${DRY ? '(dry-run,未写任何远端)' : ''}`)
|
|
216
251
|
say(`版本 ${pkg.version} | tag ${tag}`)
|
|
252
|
+
if (problems.length) {
|
|
253
|
+
say('')
|
|
254
|
+
say('⚠ 有未完成的发布动作(**不要当作已发布完成**):')
|
|
255
|
+
for (const x of problems) say(' · ' + x)
|
|
256
|
+
process.exitCode = 1 // 让脚本/CI 能发现"三端没齐"
|
|
257
|
+
}
|
|
217
258
|
say('提醒:桌面版用户需 `cd ~/.dsh/profiles/desktop && pnpm update memory-eternal` 后重启 DSH;')
|
|
218
259
|
say(' dsh web 用户需 `cd ~/.dsh/profiles/web && pnpm add memory-eternal@' + pkg.version + '`。')
|