memory-eternal 0.10.6 → 0.10.8

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/index.js CHANGED
@@ -19,6 +19,7 @@ import path from 'node:path'
19
19
  import os from 'node:os'
20
20
  import { spawn } from 'node:child_process'
21
21
  import { fileURLToPath } from 'node:url'
22
+ import { randomBytes } from 'node:crypto'
22
23
  const __filename = fileURLToPath(import.meta.url)
23
24
  const PACKAGE_ROOT = path.resolve(path.dirname(__filename))
24
25
  // 插件版本号(供「记忆配置」页面展示)
@@ -76,6 +77,13 @@ export const Config = z.object({
76
77
  recallLimit: z.number().min(1).max(20).default(5),
77
78
  recallSummaryLen: z.number().min(40).max(400).default(130),
78
79
  recallIncludeBody: z.boolean().default(false),
80
+ // 凭证取用提示(默认空 = 不注入):写进来的一整句会被追加到每个会话的 systemPrompt 段,
81
+ // 用来让 agent「需要 API key / token 时先想到去查记忆里的目录卡」。
82
+ // 为什么做成配置而不是写死:默认值必须对**所有**用户安全 —— 插件本身不假定任何本机
83
+ // 密钥布局(也不该在公开包里硬编码某台机器的路径);有需要的用户填一句自己的约定即可。
84
+ // 参考句式:需要 API key/token 时先查记忆目录卡(memory_recall「密钥 目录」),
85
+ // 再按卡片给的本机命令取用;禁止直接读凭据文件、禁止把值打印或写进卡片/代码。
86
+ secretHint: z.string().default(''),
79
87
  // 多 Vault / 多 Profile:命名分库,当前激活一个
80
88
  vaultProfiles: z.array(z.object({ name: z.string(), path: z.string() })).default([]),
81
89
  activeVault: z.string().default(''),
@@ -99,6 +107,12 @@ export const Config = z.object({
99
107
  // **v0.6.0 起默认 true**——DSH 进程内 setInterval 在 DSH 退出后失效;
100
108
  // 常驻 web 场景需要独立 watchdog;代价是 ~47 MB 额外常驻内存。
101
109
  watchdogAutoSpawn: z.boolean().default(true),
110
+ // 版本漂移自愈(默认开):常驻 web(7999)比 DSH 活得久 —— 升级 npm 包只换磁盘文件,
111
+ // 端口上那个进程仍跑着启动时加载的旧代码,而「同端口已有活着的 watchdog 就让位」的策略
112
+ // 让重启 DSH 永远换不掉它(issue #19/#23)。开启后,激活时若发现**端口上真正服务的版本
113
+ // ≠ 本机磁盘版本**,就用新代码重启常驻实例(delegate → restart),而不是继续委派给旧实例。
114
+ // 关掉只是不自动做,面板上的「重启常驻实例」按钮与 dsh-memory restart 始终可用。
115
+ autoRestartOnDrift: z.boolean().default(true),
102
116
  // 回收站保留天数:软删卡超过此天数自动永久删除(默认 30)
103
117
  recycleRetentionDays: z.number().min(1).max(3650).default(30),
104
118
  // 自动审核配置
@@ -350,6 +364,19 @@ export function apply(ctx, config) {
350
364
  // 否则 5 秒一轮的轮询会把日志刷爆)。
351
365
  let lastDrainError = ''
352
366
  let lastDrainErrorAt = 0
367
+ // 被本宿主 schema 过滤掉的键(升级期新独立页 + 旧宿主),随 drain 结果回报给独立端
368
+ let lastDrainIgnored = []
369
+ // ---- 方案 A:把「应用 pending」搬进宿主允许的上下文(2026-10-08 实测)---------------------------------
370
+ // 同一个 settings.update:从插件**回调**里调必然被 activation 守卫拒(HMR transactions cannot be
371
+ // nested,一个字节都不提交);从插件**HTTP 请求处理器**里调则真的提交(实测 revision 4→6)。
372
+ // 所以宿主把「自己的监听端口 + 一次性令牌」写进心跳,独立端保存后就地叫醒宿主:
373
+ // POST /memory-eternal/api/drain-pending(见下面的路由 + lib/config-sync.js 的 triggerHostDrain)。
374
+ const hostDrainToken = randomBytes(16).toString('hex')
375
+ let hostDrainPort = 0
376
+ const beatHostMarker = () => {
377
+ // 心跳失败不影响主流程:字段缺省时独立端会回落到「等宿主自己 drain」(旧行为)
378
+ try { writeHostMarker(process.env, { version: versionRef, port: hostDrainPort, token: hostDrainToken }) } catch { /* 心跳失败无妨 */ }
379
+ }
353
380
  const reportDrainError = (error) => {
354
381
  const dropped = !!(error && error.dropped)
355
382
  const msg = String((error && error.message) || error || '未知错误')
@@ -361,7 +388,10 @@ export function apply(ctx, config) {
361
388
  ? '独立 Web 端的配置改动连续失败已放弃(文件保留以便排查):' + msg
362
389
  : '独立 Web 端的配置改动应用失败,将重试:' + msg
363
390
  try { console.error('[memory-eternal] ' + why) } catch { /* 日志失败无妨 */ }
364
- try { logCapture('system', dropped ? 'fail' : 'warn', why) } catch { /* 日志失败无妨 */ }
391
+ // 用专用动作而不是 'fail':logCapture 里 `action==='fail'` 会把**自动沉淀健康状态**翻红,
392
+ // 于是「配置同步失败」会被念成「自动沉淀异常」(本会话实测:宿主重启前一直挂着这条假警报)。
393
+ // 配置同步是另一个子系统:照样写进日志/诊断信息,但不冒充沉淀管线坏了。
394
+ try { logCapture('system', dropped ? 'config-fail' : 'config-warn', why) } catch { /* 日志失败无妨 */ }
365
395
  }
366
396
  // issue #21:dsh-tui 一类宿主的能力守卫会在 settings.update **内部**的 describe() 上抛
367
397
  // 「root.events.emit is unavailable from a plugin activation」(守卫只区分「第三方插件 vs
@@ -374,8 +404,19 @@ export function apply(ctx, config) {
374
404
  // AsyncLocalStorage.run(token) 包住每次插件回调,并 patch 了 Fiber._execute,定时器 /
375
405
  // fs.watch 的每一次回调都带着 activation token(见 ccch1mneyyy/dsh-TUI#1348 的定位)。
376
406
  const applyPendingPatch = async (patch) => {
377
- const { applyPatchVerified } = await import('./lib/config-sync.js')
378
- return applyPatchVerified(patch, {
407
+ const { applyPatchVerified, filterKnownKeys } = await import('./lib/config-sync.js')
408
+ // 只把**本宿主 schema 里存在的键**交给 settings.update。独立 Web 端(新版)提交的是整张表单,
409
+ // 升级期它可能带上旧宿主还不认识的键(本会话实测:新独立页提交含 secretHint 的整包 → 旧宿主
410
+ // 按白名单丢掉 secretHint → 回读校验必然对不上 → 连续失败 5 次标 dropped,用户还会看到
411
+ // 「HMR transactions cannot be nested」这种与配置内容无关的报错)。过滤后:旧宿主只应用它认识的
412
+ // 那部分,不认识的记一行日志等宿主升级后再生效。
413
+ const { known: filtered, ignored } = filterKnownKeys(patch, Object.keys(Config.dict || {}))
414
+ lastDrainIgnored = ignored
415
+ if (ignored.length) {
416
+ try { console.error('[memory-eternal] 待应用配置里有本宿主不认识的键(宿主升级后生效):' + ignored.join(',')) } catch { /* 日志失败无妨 */ }
417
+ }
418
+ if (!Object.keys(filtered).length) return { applied: true, repaired: false, ignored }
419
+ const out = await applyPatchVerified(filtered, {
379
420
  apply: (p) => settings.update(p, undefined),
380
421
  read: () => settings.get() ?? {},
381
422
  onRepaired: (error) => {
@@ -384,8 +425,15 @@ export function apply(ctx, config) {
384
425
  } catch { /* 日志失败无妨 */ }
385
426
  },
386
427
  })
428
+ return { ...out, ignored }
387
429
  }
430
+ /**
431
+ * 应用一次 pending,并把结果**返回**给调用方(不再只是写日志)。
432
+ * 返回结构同时服务两条路:5 秒轮询 / fs.watch(忽略返回值)与 drain 路由(要如实回报给独立端)。
433
+ * @returns {Promise<{applied:string[], ignored:string[], error:string, dropped:boolean}>}
434
+ */
388
435
  const drainPending = async () => {
436
+ lastDrainIgnored = []
389
437
  try {
390
438
  const { drainPendingConfig } = await import('./lib/config-sync.js')
391
439
  const applied = await drainPendingConfig(process.env, applyPendingPatch)
@@ -394,10 +442,13 @@ export function apply(ctx, config) {
394
442
  syncConfigFile()
395
443
  syncAudit()
396
444
  try { console.error('[memory-eternal] 已应用独立 Web 端的配置改动: ' + Object.keys(applied).join(',')) } catch { /* 日志失败无妨 */ }
445
+ return { applied: Object.keys(applied), ignored: lastDrainIgnored, error: '', dropped: false }
397
446
  }
447
+ return { applied: [], ignored: lastDrainIgnored, error: '', dropped: false }
398
448
  } catch (error) {
399
449
  // 失败时保留文件由 config-sync.js 负责;这里只保证「失败可见」
400
450
  reportDrainError(error)
451
+ return { applied: [], ignored: lastDrainIgnored, error: String((error && error.message) || error), dropped: Boolean(error && error.dropped) }
401
452
  }
402
453
  }
403
454
  drainPending()
@@ -422,7 +473,7 @@ export function apply(ctx, config) {
422
473
  // 直接写共享配置(绕过会与宿主的 volatile 配置分叉)。心跳必须定期刷新:进程被硬杀时
423
474
  // 没有机会删文件,独立端靠「新鲜度 + pid 存活」两个条件判定,所以宁可多刷几次。
424
475
  ctx.effect(() => {
425
- const beat = () => { try { writeHostMarker(process.env, { version: versionRef }) } catch { /* 心跳失败不影响主流程 */ } }
476
+ const beat = () => beatHostMarker()
426
477
  beat()
427
478
  // unref:心跳只是「我在跑」的旁证,不该成为阻止进程退出的最后一根钉子
428
479
  // (测试里以假 ctx 跑 apply() 时会因此挂住不退,实测过一次)。
@@ -812,6 +863,11 @@ export function apply(ctx, config) {
812
863
  ...((cfg.auditMode ?? 'all') === 'none' ? [] : [
813
864
  '6. **审核红线(强制)**:写卡一律停在 pending,由用户在「审核中心」审批。**禁止**调用 /memory-eternal/api/audit/approve 或 /audit/reject 代替用户审批,也不得用任何等价方式绕过审核;用户说「记录一下 / 增加记忆」不等于允许免审入库。需要立刻可用时,写卡后明确告知用户「已进审核中心,待批准」。',
814
865
  ]),
866
+ // 凭证取用提示(用户自己填的整句,见 Config.secretHint):只在填了非空值时注入。
867
+ // 为什么走 systemPrompt 而不是「往卡里写值」:密钥的**值**入库会被每次召回带进模型
868
+ // 上下文(card.summary = 正文前 200 字,召回默认取前 130 字),而 agent 真正需要的
869
+ // 只是「去哪取、怎么取」——这句负责**触发**,目录卡负责细节,值留在本机凭据库。
870
+ ...((cfg.secretHint || '').trim() ? [String(cfg.secretHint).trim()] : []),
815
871
  ].join('\n')
816
872
  // 异常提示:沉淀管线坏了,靠这一句把消息送到用户面前(不依赖用户去翻页面)。
817
873
  const h = health.snapshot()
@@ -843,7 +899,9 @@ export function apply(ctx, config) {
843
899
  name: 'memory_recall',
844
900
  description:
845
901
  '从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
846
- '或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。',
902
+ '或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。' +
903
+ '需要 API key / token / 令牌等**凭证**时也先查这里(查「密钥 目录」):记忆里存的是目录' +
904
+ '(名字、存放位置、取用命令),**值不在记忆库里**,请按卡片给的本机命令取用,不要直接读凭据文件。',
847
905
  parameters: {
848
906
  query: { type: 'string', required: true, description: '检索关键词或自然语言描述,如「数据库选型」「用户偏好」' },
849
907
  limit: { type: 'number', description: '返回卡片数上限,默认 5' },
@@ -956,6 +1014,10 @@ export function apply(ctx, config) {
956
1014
  // 没有该服务的 profile(如 TUI)里子 fiber 保持 pending,主插件照常激活。
957
1015
  // (DSH 自身的 dsh-client-modules 也是这么写的:ctx.inject(['webServer'], …))
958
1016
  const registerApiRoutes = (webServer) => {
1017
+ // webServer 是晚到服务:它一就绪就把「监听端口」记下来并立刻刷新心跳 —— 独立端靠这个端口
1018
+ // 把 drain 请求发回来(port=0 时独立端自动回落到旧的「等宿主自己 drain」行为)。
1019
+ hostDrainPort = Number(webServer.port) || 0
1020
+ beatHostMarker()
959
1021
  const handleApi = createApi({
960
1022
  vaultDir, vaultRoots, getSettings: settings.get,
961
1023
  // 自动沉淀运行轨迹 + 健康状态:供「用量/今日」页排查「为什么没写卡」,异常时页面顶部亮红。
@@ -1016,16 +1078,33 @@ export function apply(ctx, config) {
1016
1078
  }
1017
1079
  return
1018
1080
  }
1081
+ if (pathname === API_PREFIX + '/drain-pending') {
1082
+ // 独立端保存后叫醒宿主(#方案 A):**HTTP 请求上下文**是宿主允许写配置的地方 ——
1083
+ // 实测同一个 settings.update 从回调里调必被 activation 守卫拒、从请求里调则真提交。
1084
+ // 两道闸门写在 lib/config-sync.js(有单测):只接受本机来源 + 心跳里的一次性令牌。
1085
+ const { drainRequestError } = await import('./lib/config-sync.js')
1086
+ const deny = drainRequestError(
1087
+ { method: req.method, headers: req.headers, remoteAddress: req.socket && req.socket.remoteAddress },
1088
+ hostDrainToken,
1089
+ )
1090
+ if (deny) return json(res, deny === '需 POST' ? 405 : 403, { ok: false, error: deny })
1091
+ // 端口可能刚刚才就绪(webServer 是晚到服务):顺手把心跳里的端口刷新到最新
1092
+ if (Number(webServer.port) !== hostDrainPort) { hostDrainPort = Number(webServer.port) || 0; beatHostMarker() }
1093
+ const drained = await drainPending()
1094
+ if (drained.applied.length) hub.broadcast('config', { at: Date.now(), source: 'drain-request', applied: drained.applied })
1095
+ // 失败也回 200:调用方要读到具体原因,而不是一个没有 body 的 500
1096
+ return json(res, 200, { ok: !drained.error, ...drained })
1097
+ }
1019
1098
  if (pathname === API_PREFIX + '/config') {
1020
1099
  const method = req.method || 'GET'
1021
1100
  if (method === 'GET') {
1022
1101
  const cfg = settings.get() ?? {}
1023
1102
  // 只暴露可安全展示/回填的字段
1024
1103
  const safe = {
1025
- autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody,
1104
+ autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody, secretHint: cfg.secretHint || '',
1026
1105
  captureMinChars: cfg.captureMinChars, captureCooldownMs: cfg.captureCooldownMs, dedupThreshold: cfg.dedupThreshold, maxCardsPerDay: cfg.maxCardsPerDay,
1027
1106
  distillEnabled: cfg.distillEnabled, dedupByLLM: cfg.dedupByLLM, captureMaxTokens: cfg.captureMaxTokens, recallMinScore: cfg.recallMinScore,
1028
- autoWeb: cfg.autoWeb, autoWebMode: cfg.autoWebMode, webPort: cfg.webPort, webCheckIntervalMs: cfg.webCheckIntervalMs, webMaxRestart: cfg.webMaxRestart, watchdogAutoSpawn: cfg.watchdogAutoSpawn, autoMcpSetup: cfg.autoMcpSetup,
1107
+ autoWeb: cfg.autoWeb, autoWebMode: cfg.autoWebMode, webPort: cfg.webPort, webCheckIntervalMs: cfg.webCheckIntervalMs, webMaxRestart: cfg.webMaxRestart, watchdogAutoSpawn: cfg.watchdogAutoSpawn, autoRestartOnDrift: cfg.autoRestartOnDrift, autoMcpSetup: cfg.autoMcpSetup,
1029
1108
  auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents || [], auditExemptKinds: cfg.auditExemptKinds || [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
1030
1109
  // 多库(#10):配置页可直接编辑
1031
1110
  vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [],
@@ -1088,7 +1167,7 @@ export function apply(ctx, config) {
1088
1167
  retried: !!(writeResult && writeResult.retried),
1089
1168
  note: pending.length
1090
1169
  ? '已写入;宿主尚未回流这些值(面板已本地生效,重启 DSH 后以配置文件为准)'
1091
- : '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19)',
1170
+ : '已保存。autoWebMode/watchdogAutoSpawn/webPort 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19)。版本漂移(升级后端口上仍是旧代码)由 autoRestartOnDrift 在下次激活时自动重启常驻实例,也可在「插件信息」点「重启常驻实例」立刻处理',
1092
1171
  })
1093
1172
  } catch (e) {
1094
1173
  if (e && e.code === 'SETTINGS_CONFLICT') return json(res, 409, { ok: false, error: '配置已被外部修改,请刷新后重试(revision conflict)' })
@@ -1204,22 +1283,59 @@ export function apply(ctx, config) {
1204
1283
  const wdPort = Number(cfg0.webPort) || 7999
1205
1284
  if (cfg0.watchdogAutoSpawn === true && cfg0.autoWeb !== false) {
1206
1285
  import('./lib/watchdog.js')
1207
- .then((m) => {
1208
- // 同端口已有**活着的** watchdog 时不再 spawn(issue #19):旧实现每次都 spawn 一个、
1209
- // 每次都打印 "spawned",而新进程会在锁上让位退出 —— 日志与实际状态完全相反,
1210
- // 排查时必然误判。这里如实打印 delegated,并提示版本漂移与替换命令。
1211
- const lock = m.readWatchdogLock(process.env)
1212
- const existing = lock.watchdogs.find((w) => Number(w.port) === wdPort && m.isPidAlive(w.pid))
1213
- if (existing) {
1214
- const cur = m.currentPkgVersion()
1215
- const stale = !!(existing.pkgVersion && cur && existing.pkgVersion !== cur)
1216
- const why = stale
1217
- ? `watchdog delegated to existing pid=${existing.pid} port=${wdPort}(常驻实例是旧版 v${existing.pkgVersion},当前 v${cur};如需换新版请执行 dsh-memory restart --port ${wdPort})`
1218
- : `watchdog delegated to existing pid=${existing.pid} port=${wdPort}(同端口已有常驻实例,本次不再 spawn)`
1286
+ .then(async (m) => {
1287
+ // 先体检再决策(issue #19/#23):同端口已有**活着的** watchdog 时不再 spawn ——
1288
+ // 旧实现每次都 spawn 一个、每次都打印 "spawned",而新进程会在锁上让位退出,
1289
+ // 日志与实际状态完全相反。但「有实例在跑」不等于「跑的是新代码」:常驻 web 比
1290
+ // DSH 活得久,升级只换磁盘文件,所以这里问的是**端口上真正服务的版本**,
1291
+ // 漂移就用新代码重启它(autoRestartOnDrift),而不是继续委派给旧代码。
1292
+ const info = await m.inspectResident({ port: wdPort })
1293
+ const cur = m.currentPkgVersion()
1294
+ const decision = m.decideResidentAction({
1295
+ served: info.served,
1296
+ expect: cur,
1297
+ watchdogAlive: info.watchdogAlive,
1298
+ occupantIsOurs: info.occupantIsOurs,
1299
+ autoRestart: cfg0.autoRestartOnDrift !== false,
1300
+ residentStartedAt: info.residentStartedAt,
1301
+ })
1302
+ const servedLabel = info.served || '(旧版不自报版本)'
1303
+
1304
+ if (decision.action === 'delegate') {
1305
+ const who = info.watchdog ? `pid=${info.watchdog.pid}` : '外部实例'
1306
+ const why = `watchdog delegated to ${who} port=${wdPort}(端口上服务 v${servedLabel},本机 v${cur || '?'}${decision.reason === 'debounced' ? ';刚替换过,防抖窗口内不重复重启' : ''})`
1219
1307
  console.error(`[memory-eternal] ${why}`)
1220
- logCapture('system', stale ? 'warn' : 'boot', why)
1308
+ logCapture('system', 'boot', why)
1221
1309
  return
1222
1310
  }
1311
+
1312
+ if (decision.action === 'restart') {
1313
+ // 「更新运行中的程序」的唯一安全形态 = 用新代码重启那个常驻进程(不是热替换内存里的模块)
1314
+ const why = `版本漂移:端口 ${wdPort} 上服务 v${servedLabel} ≠ 本机 v${cur}(${decision.reason})→ 用新代码重启常驻实例`
1315
+ console.error(`[memory-eternal] ${why}`)
1316
+ logCapture('system', 'warn', why)
1317
+ const out = await m.restartResident({
1318
+ port: wdPort,
1319
+ vaultRoot: vaultDir(),
1320
+ interval: Number(cfg0.webCheckIntervalMs) || 5000,
1321
+ maxRestart: Number(cfg0.webMaxRestart) || 10,
1322
+ })
1323
+ const done = out.ok
1324
+ ? `常驻实例已重启:端口 ${wdPort} 正在服务 v${out.served}(等待 ${Math.round(out.waitedMs / 1000)}s)`
1325
+ : `常驻实例重启未确认生效(${out.stage}):${out.error || '未知原因'} —— 可再试「设置 → 记忆 → 插件信息 → 重启常驻实例」,或执行 dsh-memory restart --port ${wdPort}`
1326
+ console.error(`[memory-eternal] ${done}`)
1327
+ logCapture('system', out.ok ? 'boot' : 'fail', done)
1328
+ return
1329
+ }
1330
+
1331
+ if (decision.action === 'warn') {
1332
+ const why = `常驻实例版本漂移(端口上服务 v${servedLabel} ≠ 本机 v${cur}),但 autoRestartOnDrift 已关闭 → 不自动重启;可点面板「重启常驻实例」或执行 dsh-memory restart --port ${wdPort}`
1333
+ console.error(`[memory-eternal] ${why}`)
1334
+ logCapture('system', 'warn', why)
1335
+ return
1336
+ }
1337
+
1338
+ // 端口上没有我们的服务(也没人占着)→ 照常拉起一个常驻 watchdog
1223
1339
  const port = wdPort
1224
1340
  // 用 nodeBinary() 而非 process.execPath:Electron 宿主下后者是 Electron
1225
1341
  // 主程序,spawn 出来不会执行 watchdog.js(见 lib/node-bin.js)。
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,16 +213,76 @@ 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}`,
238
+ })
239
+ return
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)。`,
174
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)
175
286
  return
176
287
  }
177
288
  case '/diagnostics': {
@@ -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 || '',
@@ -488,16 +600,29 @@ export function createApi(deps = {}) {
488
600
  }
489
601
  if (bad.length) return json(res, 400, { ok: false, error: '字段值不合法:' + bad.join('、'), fields: bad })
490
602
  if (!Object.keys(clean).length) return json(res, 400, { ok: false, error: '没有可写入的字段' })
491
- const { writePendingConfig, readPendingConfig, waitPendingOutcome, clearPendingConfig, applySharedConfigDirect, describeApplyFailure } = await import('./config-sync.js')
603
+ const { writePendingConfig, readPendingConfig, waitPendingOutcome, clearPendingConfig, applySharedConfigDirect, describeApplyFailure, triggerHostDrain } = await import('./config-sync.js')
604
+ const { hostAlive, readHostMarker } = await import('./host-heartbeat.js')
492
605
  // #16 建议 4:上一份改动是否已被判定失败?是的话这次不能再报「一切正常」——
493
606
  // 否则用户第 N 次点击保存,看到的仍是成功,而问题一直在(其实是 settings.update 在报错)。
494
607
  const before = readPendingConfig(process.env)
495
608
  const out = writePendingConfig(process.env, clean)
496
609
  hub.broadcast('config', { at: out.at, source: 'standalone', applied: Object.keys(clean) })
497
- // issue #21 建议 3:别急着报「一切正常」。宿主在跑时对 pending 文件有 fs.watch
498
- // (毫秒级),稍等一下回读,就能分清「真的应用了」还是「只是排上队了」——
499
- // 以前无论哪种都回 ok:true + 一句泛泛的 note,用户只会在几秒后从日志里发现失败。
500
- let outcome = await waitPendingOutcome(process.env, { timeoutMs: 800 })
610
+ // 方案 A(首选):宿主活着、且心跳里带了端口 + 令牌时,**直接请宿主在 HTTP 请求上下文里
611
+ // 应用**。为什么不继续等宿主自己 drain:宿主那条 drain 挂在回调上下文(setInterval /
612
+ // fs.watch),在带 activation 守卫的宿主上必然被拒(实测与 patch 内容无关)—— 等不来结果。
613
+ // 叫醒失败(旧宿主没这条路由 / 端口对不上 / 超时)不是错误:退回 issue #21 的等待路径。
614
+ let outcome = 'queued'
615
+ let hostDrainError = ''
616
+ const aliveHost = hostAlive(process.env) ? readHostMarker(process.env) : null
617
+ if (aliveHost && aliveHost.port && aliveHost.token) {
618
+ const call = await triggerHostDrain({ port: aliveHost.port, token: aliveHost.token, timeoutMs: 2500 })
619
+ if (call.ok) outcome = 'applied'
620
+ else if (call.status === 404 || call.status === 405 || call.status === 0) hostDrainError = call.error || ''
621
+ else { outcome = 'failed'; hostDrainError = call.error || '' }
622
+ }
623
+ // 回落(issue #21 建议 3):别急着报「一切正常」。宿主对 pending 文件有 fs.watch(毫秒级),
624
+ // 稍等一下回读,就能分清「真的应用了」还是「只是排上队了」。
625
+ if (outcome === 'queued') outcome = await waitPendingOutcome(process.env, { timeoutMs: 800 })
501
626
  // issue #21 的宿主无关路径:**本机没有活着的 DSH 宿主**时(Codex / Claude Code /
502
627
  // 只跑 dsh-memory serve 的用户),没有任何进程会来 drain pending 文件 —— 那句
503
628
  // 「DSH 未运行时下次启动生效」在那类环境下永远不会兑现。这时直接原子写共享配置
@@ -505,7 +630,6 @@ export function createApi(deps = {}) {
505
630
  // 宿主活着时绝不走这条:共享文件是宿主的派生镜像,绕过它直写会被下一次 syncConfigFile 盖掉。
506
631
  let appliedDirect = false
507
632
  if (outcome === 'queued') {
508
- const { hostAlive } = await import('./host-heartbeat.js')
509
633
  if (!hostAlive(process.env)) {
510
634
  try {
511
635
  applySharedConfigDirect(process.env, clean)
@@ -518,17 +642,24 @@ export function createApi(deps = {}) {
518
642
  const stale = before && (before.dropped || before.lastError)
519
643
  const advice = describeApplyFailure(before && before.lastError)
520
644
  const hostGuard = advice.kind === 'host-guard'
645
+ // 上一份已经被这个宿主的守卫判死(dropped + host-guard)→ 这次也不能再空头承诺
646
+ // 「DSH 端会自动同步」:直说去哪儿改,否则用户会一直看到一个永远不会生效的「已排队」。
647
+ const knownGuard = Boolean(before && before.dropped && hostGuard)
521
648
  const note = outcome === 'applied'
522
- ? '宿主已应用该改动。'
649
+ ? '宿主已在请求上下文里应用该改动(点保存即生效)。'
523
650
  : outcome === 'applied-direct'
524
651
  ? '本机没有 DSH 宿主在运行,已直接写入共享配置(hooks / MCP / 独立页立即生效)。'
525
652
  : outcome === 'failed'
526
- ? '改动已写入,但宿主侧应用失败:' + (hostGuard ? advice.hint : '详见「反馈异常」里的诊断信息。')
527
- : '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)'
653
+ ? '改动已写入,但宿主侧应用失败:' + (hostDrainError || (hostGuard ? advice.hint : '详见「反馈异常」里的诊断信息。'))
654
+ : knownGuard
655
+ ? '这个宿主不允许插件从回调上下文写配置(与配置内容无关):请到「DSH 设置 → 记忆」里改同一项。本次改动已在独立端重新排队,宿主升级/重启后仍会自动应用。'
656
+ : '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)'
528
657
  return json(res, 200, {
529
- ok: true, applied: Object.keys(clean), pending: appliedDirect ? [] : Object.keys(clean), retried: false, at: out.at,
658
+ ok: true, applied: Object.keys(clean), pending: (appliedDirect || outcome === 'applied') ? [] : Object.keys(clean), retried: false, at: out.at,
530
659
  pendingOutcome: outcome,
531
660
  appliedDirect,
661
+ // 方案 A 的现场:宿主叫醒失败的原因(旧宿主没路由 / 令牌不匹配 / 应用报错)如实回给界面
662
+ hostDrainError: hostDrainError || undefined,
532
663
  hostGuard: hostGuard || undefined,
533
664
  hint: hostGuard ? advice.hint : undefined,
534
665
  warning: stale