memory-eternal 0.10.6 → 0.10.7

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/api.js CHANGED
@@ -20,6 +20,7 @@ import { buildDiagnostics } from './feedback.js'
20
20
  import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
21
21
  import { backupDb } from './db.js'
22
22
  import { createHub } from './sse.js'
23
+ import { probeServedVersion, restartResident, currentPkgVersion } from './watchdog.js'
23
24
 
24
25
  // 独立 web 的 SSE 中心:配置/数据变更即时推给已打开的页面(无需手动刷新)
25
26
  const hub = createHub()
@@ -31,6 +32,48 @@ export const API_PREFIX = '/memory-eternal/api'
31
32
  // npm 最新版本缓存(避免每次点「检查更新」都打 registry)
32
33
  const versionCache = { at: 0, latest: '' }
33
34
 
35
+ // 「重启常驻实例」的并发闸门:连点两次不能起两个替换助手(后一个会让位退出,但白折腾一次)
36
+ let residentRestarting = false
37
+
38
+ /** 解析 `x.y.z[-pre]`;解析不出来返回 null。 */
39
+ export function parseVersion(v) {
40
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || '').trim())
41
+ if (!m) return null
42
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || '' }
43
+ }
44
+
45
+ /**
46
+ * 比较两个版本号(a>b → 1)。
47
+ *
48
+ * 为什么需要:`latest !== onDisk` 这种判断在**本地跑着未发布版本**时会给出误导性结论
49
+ * (磁盘 v0.10.7、npm 还是 v0.10.6 → 面板提示「有新版本」并让你装回旧的)。
50
+ * 预发布标识按 semver 口径比(点分标识、数字段按数值比)—— `rc.2 < rc.10` 不是字符串比较。
51
+ */
52
+ export function compareVersions(a, b) {
53
+ const A = parseVersion(a)
54
+ const B = parseVersion(b)
55
+ if (!A || !B) return 0
56
+ for (let i = 0; i < 3; i++) if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1
57
+ if (A.pre === B.pre) return 0
58
+ if (!A.pre) return 1 // 正式版 > 预发布版
59
+ if (!B.pre) return -1
60
+ const as = A.pre.split('.')
61
+ const bs = B.pre.split('.')
62
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
63
+ const x = as[i]
64
+ const y = bs[i]
65
+ if (x === undefined) return -1 // 前缀相同、标识更少的那个更小
66
+ if (y === undefined) return 1
67
+ if (x === y) continue
68
+ const nx = /^\d+$/.test(x)
69
+ const ny = /^\d+$/.test(y)
70
+ if (nx && ny) return Number(x) < Number(y) ? -1 : 1
71
+ if (nx !== ny) return nx ? -1 : 1 // 数字标识 < 字母标识
72
+ return x < y ? -1 : 1
73
+ }
74
+ return 0
75
+ }
76
+
34
77
  export function createApi(deps = {}) {
35
78
  const vaultDir = deps.vaultDir ?? (() => '')
36
79
  const vaultRoots = deps.vaultRoots ?? (() => [{ name: '', root: vaultDir() }])
@@ -40,6 +83,14 @@ export function createApi(deps = {}) {
40
83
  // 自动沉淀运行轨迹(最新在前)+ 健康状态(异常时 UI 顶部亮红)。
41
84
  const getCaptureLog = deps.getCaptureLog ?? (() => null)
42
85
  const getCaptureHealth = deps.getCaptureHealth ?? (() => null)
86
+ // 「重启常驻实例」的实现(宿主与独立 web 共用 lib/watchdog.js 的那一份;注入仅为单测)
87
+ const doRestartResident = deps.restartResident ?? restartResident
88
+ const probeResidentVersion = deps.probeServedVersion ?? probeServedVersion
89
+ const pkgVersion = deps.currentPkgVersion ?? currentPkgVersion
90
+ // 本进程自己监听的端口(独立 web 传入;宿主为 0 = 不知道)。用来把「要重启/要看的是哪个
91
+ // 常驻实例」钉死在**本页所连的那个实例**上,而不是配置里的理想端口(web 退让到 port+1 时
92
+ // 两者不同,探错端口会给出错误结论)。
93
+ const selfPort = () => (typeof deps.selfPort === 'function' ? Number(deps.selfPort()) || 0 : Number(deps.selfPort) || 0)
43
94
 
44
95
  return async function handleApi(req, res) {
45
96
  const vaultRoot = vaultDir()
@@ -137,9 +188,9 @@ export function createApi(deps = {}) {
137
188
  return
138
189
  }
139
190
  case '/version-check': {
140
- // 版本跟踪:运行中(宿主启动时加载) vs 磁盘安装 vs npm 最新。
141
- // 典型场景:磁盘已更新,但 DSH 进程仍加载旧版 → UI 显示旧版本号、新接口 404。
142
- const loaded = (getDshInfo() || {}).version || ''
191
+ // 版本跟踪:运行中(服务本页的进程) vs 磁盘安装 vs npm 最新。
192
+ // 典型场景:磁盘已更新,但端口上的常驻进程仍加载旧版 → UI 显示旧版本号、新接口 404。
193
+ const loaded = (getDshInfo() || {}).version || process.env.MEMORY_ETERNAL_VERSION || ''
143
194
  let onDisk = ''
144
195
  try {
145
196
  const pj = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
@@ -162,18 +213,78 @@ export function createApi(deps = {}) {
162
213
  latest = versionCache.latest
163
214
  }
164
215
  } catch (e) { checkError = String((e && e.message) || e).slice(0, 120) }
216
+ // 常驻实例(端口上那个进程)自报的版本:宿主面板靠它区分「是本页这个进程旧」还是
217
+ // 「常驻 web 旧」——两者要做的操作不同(重启 DSH vs 重启常驻实例)。独立 web 页里
218
+ // 探到的就是它自己。
219
+ const cfgVc = getSettings() ?? {}
220
+ const residentPort = selfPort() || Number(cfgVc.webPort) || Number(process.env.MEMORY_WEB_PORT) || 7999
221
+ let residentServed = ''
222
+ try { residentServed = String((await probeResidentVersion(residentPort)) || '') } catch { residentServed = '' }
165
223
  json(res, 200, {
166
224
  ok: true,
167
225
  loaded,
168
226
  onDisk,
169
227
  latest,
170
228
  checkError,
229
+ resident: { port: residentPort, served: residentServed },
171
230
  stale: !!(loaded && onDisk && loaded !== onDisk),
172
- updateAvailable: !!(latest && onDisk && latest !== onDisk),
231
+ // 只在 **npm 上确实更新** 时提示升级:本地跑着未发布版本(磁盘 0.10.7 / npm 0.10.6)时
232
+ // 旧的 `latest !== onDisk` 会反过来劝用户装回旧版。
233
+ updateAvailable: !!(latest && onDisk && compareVersions(latest, onDisk) > 0),
173
234
  updateCommand: 'dsh plugin --profile <你的 profile> add memory-eternal@latest',
235
+ // 「重启常驻实例」的能力与命令(面板据此给一键按钮 / 手动指引)
236
+ canRestartResident: true,
237
+ restartCommand: `dsh-memory restart --port ${residentPort}`,
174
238
  })
175
239
  return
176
240
  }
241
+ case '/restart-self': {
242
+ // 「运行中还是旧版本」的一键修复:用新代码重启**常驻实例**(端口上那个进程)。
243
+ // 三条硬约束:
244
+ // ① 本进程很可能就是要被替换的旧 web → 先回响应,再调度替换(助手负责收旧实例);
245
+ // ② 常驻实例已经是最新版就别白折腾(宿主面板点这个按钮时最常见)——如实回 alreadyCurrent;
246
+ // ③ 同一时刻只允许一个替换在跑。
247
+ if (req.method && req.method !== 'POST') return json(res, 405, { ok: false, error: 'method not allowed' })
248
+ const cfgR = getSettings() ?? {}
249
+ const port = Number(query.get('port')) || selfPort() || Number(cfgR.webPort) || Number(process.env.MEMORY_WEB_PORT) || 7999
250
+ const expect = pkgVersion()
251
+ const served = await probeResidentVersion(port).catch(() => '')
252
+ if (served && expect && String(served) === String(expect)) {
253
+ return json(res, 200, {
254
+ ok: true, alreadyCurrent: true, port, served: String(served), expect,
255
+ note: `常驻实例(端口 ${port})已经在服务 v${served},无需重启。若「运行中」仍显示旧版本号,说明那一行来自另一个进程(例如 DSH 宿主),需重启桌面版 / DSH。`,
256
+ })
257
+ }
258
+ if (residentRestarting) {
259
+ return json(res, 200, { ok: true, alreadyRunning: true, port, expect, note: `端口 ${port} 的替换已在进行中,请稍候刷新页面。` })
260
+ }
261
+ residentRestarting = true
262
+ json(res, 200, {
263
+ ok: true, scheduled: true, port, served: String(served || ''), expect,
264
+ note: `已调度重启常驻实例(端口 ${port} → v${expect || '?'}):约 3-8 秒后新进程接管,请稍等再刷新页面(Ctrl+F5)。`,
265
+ })
266
+ setTimeout(() => {
267
+ Promise.resolve()
268
+ .then(() => doRestartResident({
269
+ port, vaultRoot, interval: Number(cfgR.webCheckIntervalMs) || 5000, maxRestart: Number(cfgR.webMaxRestart) || 10,
270
+ }))
271
+ .then(async (out) => {
272
+ // 本进程若正是被替换的旧 web,这里根本不会执行 —— 助手会把结论写进 capture 日志
273
+ try {
274
+ const { appendCaptureLog } = await import('./capture-log.js')
275
+ await appendCaptureLog({
276
+ time: Date.now(), sessionId: 'system', action: out && out.ok ? 'boot' : 'fail',
277
+ reason: out && out.ok
278
+ ? `面板一键重启常驻实例成功:端口 ${port} 正在服务 v${out.served}`
279
+ : `面板一键重启常驻实例未确认生效:${(out && out.error) || '未知原因'}`,
280
+ }, process.env)
281
+ } catch { /* 日志失败不影响替换 */ }
282
+ })
283
+ .catch(() => {})
284
+ .finally(() => { residentRestarting = false })
285
+ }, 300)
286
+ return
287
+ }
177
288
  case '/diagnostics': {
178
289
  // 「反馈异常」用:返回一段可直接贴进 issue 的**已脱敏**诊断文本。
179
290
  // 宿主与独立 web 共用这个实现;home 目录、各类 key/token 全都会被替换掉。
@@ -426,11 +537,12 @@ export function createApi(deps = {}) {
426
537
  webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000,
427
538
  webMaxRestart: Number(cfg.webMaxRestart) || 10,
428
539
  watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1',
540
+ autoRestartOnDrift: cfg.autoRestartOnDrift ?? true,
429
541
  autoMcpSetup: cfg.autoMcpSetup ?? false,
430
542
  })
431
543
  } catch (e) {
432
544
  // 配置读取异常时降级返回默认值,避免面板整块红屏
433
- json(res, 200, { ok: true, budgetChars: 80000, recallLimit: 5, embedding: '', autoWeb: true, autoWebMode: 'init', webPort: 7999, webCheckIntervalMs: 5000, webMaxRestart: 10, watchdogAutoSpawn: true, autoMcpSetup: false })
545
+ json(res, 200, { ok: true, budgetChars: 80000, recallLimit: 5, embedding: '', autoWeb: true, autoWebMode: 'init', webPort: 7999, webCheckIntervalMs: 5000, webMaxRestart: 10, watchdogAutoSpawn: true, autoRestartOnDrift: true, autoMcpSetup: false })
434
546
  }
435
547
  return
436
548
  }
@@ -444,10 +556,10 @@ export function createApi(deps = {}) {
444
556
  const cfg = getSettings() ?? {}
445
557
  const env = typeof process !== 'undefined' ? process.env : {}
446
558
  const safe = {
447
- autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false,
559
+ autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false, secretHint: cfg.secretHint || '',
448
560
  captureMinChars: cfg.captureMinChars ?? 200, captureCooldownMs: cfg.captureCooldownMs ?? 300000, dedupThreshold: cfg.dedupThreshold ?? 0.62, maxCardsPerDay: cfg.maxCardsPerDay ?? 60,
449
561
  distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ?? DEFAULT_CAPTURE_MAX_TOKENS, recallMinScore: cfg.recallMinScore ?? 2,
450
- autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0', autoWebMode: cfg.autoWebMode || 'init', webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999, webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000, webMaxRestart: Number(cfg.webMaxRestart) || 10, watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1', autoMcpSetup: cfg.autoMcpSetup ?? false,
562
+ autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0', autoWebMode: cfg.autoWebMode || 'init', webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999, webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000, webMaxRestart: Number(cfg.webMaxRestart) || 10, watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1', autoRestartOnDrift: cfg.autoRestartOnDrift ?? true, autoMcpSetup: cfg.autoMcpSetup ?? false,
451
563
  auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents ?? [], auditExemptKinds: cfg.auditExemptKinds ?? [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
452
564
  vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [], activeVault: cfg.activeVault || '',
453
565
  enabled: cfg.enabled !== false, vaultDir: cfg.vaultDir || '', captureProvider: cfg.captureProvider || '',
@@ -518,13 +630,18 @@ export function createApi(deps = {}) {
518
630
  const stale = before && (before.dropped || before.lastError)
519
631
  const advice = describeApplyFailure(before && before.lastError)
520
632
  const hostGuard = advice.kind === 'host-guard'
633
+ // 上一份已经被这个宿主的守卫判死(dropped + host-guard)→ 这次也不能再空头承诺
634
+ // 「DSH 端会自动同步」:直说去哪儿改,否则用户会一直看到一个永远不会生效的「已排队」。
635
+ const knownGuard = Boolean(before && before.dropped && hostGuard)
521
636
  const note = outcome === 'applied'
522
637
  ? '宿主已应用该改动。'
523
638
  : outcome === 'applied-direct'
524
639
  ? '本机没有 DSH 宿主在运行,已直接写入共享配置(hooks / MCP / 独立页立即生效)。'
525
640
  : outcome === 'failed'
526
641
  ? '改动已写入,但宿主侧应用失败:' + (hostGuard ? advice.hint : '详见「反馈异常」里的诊断信息。')
527
- : '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)'
642
+ : knownGuard
643
+ ? '这个宿主不允许插件从回调上下文写配置(与配置内容无关):请到「DSH 设置 → 记忆」里改同一项。本次改动已在独立端重新排队,宿主升级/重启后仍会自动应用。'
644
+ : '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)'
528
645
  return json(res, 200, {
529
646
  ok: true, applied: Object.keys(clean), pending: appliedDirect ? [] : Object.keys(clean), retried: false, at: out.at,
530
647
  pendingOutcome: outcome,