memory-eternal 0.10.7 → 0.10.9

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.
@@ -45,7 +45,7 @@ export function readPendingConfig(env = process.env) {
45
45
  * @param {string} text 完整内容
46
46
  * @returns {string} 目标文件路径
47
47
  */
48
- export function writeFileAtomicSync(file, text, { retries = 8, delayMs = 20 } = {}) {
48
+ export function writeFileAtomicSync(file, text, { retries = 12, delayMs = 25, onDegraded } = {}) {
49
49
  const tmp = file + '.' + process.pid + '.tmp'
50
50
  fs.writeFileSync(tmp, text, 'utf8')
51
51
  const retriable = (code) => code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'
@@ -60,6 +60,10 @@ export function writeFileAtomicSync(file, text, { retries = 8, delayMs = 20 } =
60
60
  if (i < retries) { sleepSync(delayMs); continue }
61
61
  // 退避仍失败(极端争用):退回直接覆盖写。宁可短暂退化到非原子,也不能把用户的
62
62
  // 配置改动整份丢掉 —— 这是这个函数存在的第一原则。
63
+ //
64
+ // 这是**可观测的退化**:并发读方在这个窗口里可能读到半截。tests/config-sync 的并发用例
65
+ // 靠 onDegraded 区分「原子路径失效」与「OS 拒绝了替换」—— 前者仍然必须红。
66
+ if (typeof onDegraded === 'function') { try { onDegraded(file) } catch { /* 回调异常不影响写入 */ } }
63
67
  fs.writeFileSync(file, text, 'utf8')
64
68
  try { fs.unlinkSync(tmp) } catch { /* 已清理 */ }
65
69
  return file
@@ -319,3 +323,78 @@ export async function drainPendingConfig(env, apply, maxTries = 5) {
319
323
  clearPendingConfig(env)
320
324
  return pending.patch
321
325
  }
326
+
327
+ // ---- 方案 A:把「应用 pending」搬进宿主允许的上下文(2026-10-08 实测结论)----------------
328
+ //
329
+ // 实测(同一宿主机、同一个 settings.update):
330
+ // · 从插件**回调**(setInterval / fs.watch)里调 → 抛「HMR transactions cannot be nested」,
331
+ // 且**一个字节都没提交**(与 patch 内容无关:只含宿主认识的键、值真的变了,同样失败);
332
+ // · 从插件**HTTP 请求处理器**里调 → 真的提交(130→132→130,revision 4→6)。
333
+ // 所以修法不是「换个 API」,而是**换调用上下文**:独立端保存后,请宿主在这条路由里应用 pending。
334
+
335
+ /** 宿主 drain 路由的令牌请求头(令牌写在心跳文件里,只有本机进程读得到)。 */
336
+ export const DRAIN_TOKEN_HEADER = 'x-memory-eternal-token'
337
+
338
+ /** 这个地址算不算「本机」:IPv4/IPv6 回环,含 IPv4-mapped 形式。 */
339
+ export function isLoopbackAddress(address) {
340
+ const s = String(address || '').trim().toLowerCase()
341
+ if (!s) return false
342
+ if (s === 'localhost' || s === '::1' || s === '0:0:0:0:0:0:0:1') return true
343
+ const v4 = s.startsWith('::ffff:') ? s.slice(7) : s
344
+ return /^127\./.test(v4)
345
+ }
346
+
347
+ /**
348
+ * 宿主侧:校验一次「请立刻应用 pending」的调用。放行返回空串,否则返回人话原因。
349
+ *
350
+ * 为什么两件都要验:宿主上的插件 API 是**无鉴权**的(任何本机页面都能 POST),所以这里必须同时
351
+ * 卡「只接受本机来源」与「心跳里的令牌」——令牌只写在共享配置目录的心跳文件里,远端页面拿不到。
352
+ */
353
+ export function drainRequestError({ method, headers, remoteAddress } = {}, token = '') {
354
+ if (String(method || '').toUpperCase() !== 'POST') return '需 POST'
355
+ if (!isLoopbackAddress(remoteAddress)) return '只接受本机请求'
356
+ if (!token) return '宿主未准备令牌'
357
+ const h = headers || {}
358
+ const got = String(h[DRAIN_TOKEN_HEADER] || h['X-Memory-Eternal-Token'] || '')
359
+ if (got !== token) return '令牌不匹配'
360
+ return ''
361
+ }
362
+
363
+ /**
364
+ * 独立端:请宿主**在 HTTP 请求上下文里**立刻应用 pending(方案 A 的客户端一半)。
365
+ *
366
+ * 失败分两类,调用方要区别对待:
367
+ * · status 404/405/0(旧宿主没这条路由 / 连不上)→ 退回「等宿主自己 drain」的老路,不是错误;
368
+ * · 其它 → 真错误(令牌不匹配、应用失败),把 body.error 如实报给用户。
369
+ *
370
+ * @param {{port?:number, token?:string, host?:string, timeoutMs?:number, fetchImpl?:Function}} opts
371
+ * @returns {Promise<{ok:boolean, status:number, body?:object, error?:string}>}
372
+ */
373
+ export async function triggerHostDrain({ port, token, host = '127.0.0.1', timeoutMs = 2000, fetchImpl } = {}) {
374
+ const listenPort = Number(port) || 0
375
+ if (!listenPort || !token) return { ok: false, status: 0, error: '宿主端口/令牌未知(旧宿主,或心跳还没刷新)' }
376
+ const doFetch = fetchImpl || (typeof fetch === 'function' ? fetch : null)
377
+ if (!doFetch) return { ok: false, status: 0, error: '当前运行时没有 fetch' }
378
+ const url = 'http://' + host + ':' + listenPort + '/memory-eternal/api/drain-pending'
379
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
380
+ const timer = controller ? setTimeout(() => { try { controller.abort() } catch { /* 已中止 */ } }, Math.max(0, Number(timeoutMs) || 0)) : null
381
+ try {
382
+ const res = await doFetch(url, {
383
+ method: 'POST',
384
+ headers: { [DRAIN_TOKEN_HEADER]: String(token) },
385
+ ...(controller ? { signal: controller.signal } : {}),
386
+ })
387
+ const text = await res.text()
388
+ let body = null
389
+ try { body = text ? JSON.parse(text) : null } catch { body = null }
390
+ const status = Number(res.status) || 0
391
+ if (!res.ok || !body || body.ok !== true) {
392
+ return { ok: false, status, body, error: (body && body.error) || ('HTTP ' + status) }
393
+ }
394
+ return { ok: true, status, body }
395
+ } catch (error) {
396
+ return { ok: false, status: 0, error: String((error && error.message) || error) }
397
+ } finally {
398
+ if (timer) clearTimeout(timer)
399
+ }
400
+ }
@@ -47,6 +47,10 @@ export function readHostMarker(env = process.env) {
47
47
  pid: Number(data.pid) || 0,
48
48
  at: Number(data.at) || 0,
49
49
  version: String(data.version || ''),
50
+ // 方案 A:宿主把自己 web server 的监听端口与 drain 令牌一起写进心跳,
51
+ // 独立端据此在**请求上下文**里叫醒宿主应用 pending(旧宿主没这两个字段 → 0/'',走回落)。
52
+ port: Number(data.port) || 0,
53
+ token: String(data.token || ''),
50
54
  }
51
55
  } catch { return null }
52
56
  }
package/lib/watchdog.js CHANGED
@@ -448,12 +448,19 @@ export async function waitForServedVersion(port, expect, { timeoutMs = 15000, in
448
448
  * @returns {Promise<{port:number, served:string, occupant:{pid:number,command:string}|null,
449
449
  * occupantIsOurs:boolean, watchdog:object|null, watchdogAlive:boolean, residentStartedAt:number}>}
450
450
  */
451
- export async function inspectResident({ env = process.env, port, probeVersion, findListener } = {}) {
451
+ export async function inspectResident({ env = process.env, port, probeVersion, findListener, retries = 3, retryDelayMs = 500, sleepFn } = {}) {
452
452
  const p = Number(port) || 0
453
453
  const entry = readWatchdogLock(env).watchdogs.find((w) => Number(w.port) === p && isPidAlive(w.pid)) || null
454
454
  let served = ''
455
455
  if (p) {
456
- try { served = String((await (probeVersion || probeServedVersion)(p)) || '') } catch { served = '' }
456
+ // issue #29 建议 3:一次探针打空(实例正忙 / GC / 冷启动)**不能**当成「旧版不自报版本」——
457
+ // 那个结论会直接触发漂移替换,于是每次启动都多起一次常驻实例。退避重试后再下结论。
458
+ const wait = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)))
459
+ for (let i = 0; ; i++) {
460
+ try { served = String((await (probeVersion || probeServedVersion)(p)) || '') } catch { served = '' }
461
+ if (served || i >= Math.max(0, Number(retries) || 0)) break
462
+ await wait(Math.max(0, Number(retryDelayMs) || 0))
463
+ }
457
464
  }
458
465
  let occupant = null
459
466
  // 只有「探不到版本」时才需要问端口占用者:能自报版本就已经证明是我们的服务了
package/lib/web.js CHANGED
@@ -246,13 +246,42 @@ export async function probeWebServer(port = DEFAULT_WEB_PORT, timeoutMs = 800) {
246
246
  }
247
247
  }
248
248
 
249
+ /**
250
+ * 宽松判定:这个端口上是不是**自家的 web**(旧版可能不自报 version / vaultDir,严格探针会判否)。
251
+ *
252
+ * 为什么需要(issue #29):升级期端口被**自家旧包**的 detached 实例占着时,严格探针认不出它,
253
+ * `ensureWebServer()` 就会向后漂到一个空端口 → 一段窗口里同时存在两个实例、两个端口,
254
+ * 而宿主记录的 `web-info` 指向的可能是随后被自愈收掉的那个。多探几个自家路由即可识别。
255
+ */
256
+ export async function looksLikeOurWeb(port = DEFAULT_WEB_PORT, timeoutMs = 600) {
257
+ for (const suffix of ['/overview', '/web-info', '/budget']) {
258
+ try {
259
+ const res = await fetch(`http://127.0.0.1:${port}${API_PREFIX}${suffix}`, { signal: AbortSignal.timeout(timeoutMs) })
260
+ if (!res.ok) continue
261
+ const data = await res.json().catch(() => null)
262
+ if (data && typeof data === 'object' && data.ok === true) return true
263
+ } catch { /* 换下一个探针 */ }
264
+ }
265
+ return false
266
+ }
267
+
268
+ /** 等端口上的自家实例变成「可严格识别」(自愈替换完成)—— 期间它可能被清掉再拉起。 */
269
+ async function waitForOurWeb(port, timeoutMs = 6000, intervalMs = 500) {
270
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0)
271
+ for (;;) {
272
+ if (await probeWebServer(port)) return true
273
+ if (Date.now() >= deadline) return false
274
+ await new Promise((r) => setTimeout(r, Math.max(100, Number(intervalMs) || 500)))
275
+ }
276
+ }
277
+
249
278
  /**
250
279
  * 确保 web server 存活(默认开启的实现核心)。
251
280
  * 已活 → 直接返回 URL;未活 → detached spawn 独立进程并轮询探活。
252
281
  * 端口被非本项目进程占用时向后漂移(+1..+10)。
253
282
  * @returns {Promise<{url:string, port:number, spawned:boolean}>}
254
283
  */
255
- export async function ensureWebServer({ port = DEFAULT_WEB_PORT, vaultRoot = defaultVaultDir(), spawnEnv = {}, totalTimeoutMs = 15000 } = {}) {
284
+ export async function ensureWebServer({ port = DEFAULT_WEB_PORT, vaultRoot = defaultVaultDir(), spawnEnv = {}, totalTimeoutMs = 15000, takeoverWaitMs = 6000 } = {}) {
256
285
  // 探测可用端口:先找「已是我们的服务」的端口,再找空闲端口
257
286
  let target = port
258
287
  let found = null
@@ -260,8 +289,16 @@ export async function ensureWebServer({ port = DEFAULT_WEB_PORT, vaultRoot = def
260
289
  const alive = await probeWebServer(p)
261
290
  if (alive) { found = { url: `http://127.0.0.1:${p}`, port: p, spawned: false }; break }
262
291
  // 该端口不是我们的服务:若连不上(空闲)就用它;连得上但不是我们的(被占用)则漂移
263
- const isFree = await probeWebServer(p, 300) === null && await isPortConnectable(p) === false
264
- if (isFree) { target = p; break }
292
+ if (await isPortConnectable(p) === false) { target = p; break }
293
+ // issue #29:占用者「像自家的 web、但严格探针认不出」(升级期旧包的 detached 实例)时,
294
+ // **不要立刻漂到 port+1** —— 自愈(restartResident)马上会把配置端口上的实例换成新版,
295
+ // 这时另起一个端口只会多出一个实例、并让 web-info 指向随后被收掉的那个(客户端 iframe
296
+ // 于是「拒绝连接」)。先等它接管;等不到再漂,并留下一条能查的日志。
297
+ if (await looksLikeOurWeb(p)) {
298
+ if (await waitForOurWeb(p, takeoverWaitMs)) { found = { url: `http://127.0.0.1:${p}`, port: p, spawned: false }; break }
299
+ if (await isPortConnectable(p) === false) { target = p; break }
300
+ process.stderr.write(`[memory-eternal] 端口 ${p} 被自家旧实例占着且 ${takeoverWaitMs}ms 内没让位 → 向后找空闲端口(web-info 会实时收敛,issue #29)\n`)
301
+ }
265
302
  target = p + 1
266
303
  }
267
304
  if (found) return found
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.7",
3
+ "version": "0.10.9",
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"
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"
68
68
  },
69
69
  "dependencies": {
70
70
  "@deepseek-ai/schemastery": "^3.18.1"